feat: custom field preservation and sideband observation
- Add custom field preservation implementation - Add sideband observation support - Update OpenAI-compatible API contract - Update roadmap and task files
This commit is contained in:
parent
ceefb85055
commit
8473a5450e
13 changed files with 794 additions and 132 deletions
|
|
@ -126,9 +126,9 @@ CLI agent 실행으로 라우팅되는 요청의 최소 형태:
|
|||
현재 구현 메모:
|
||||
|
||||
- normalized(non-provider) `/v1/responses` route는 strict field validation을 유지하며 non-streaming string input만 지원한다.
|
||||
- OpenAI-compatible provider model group route(provider pool, `openai_compat`, `vllm`)의 `/v1/responses` 호출은 raw passthrough로 provider `POST /v1/responses`에 전달한다. caller body는 `model` field만 served target으로 rewrite하고, unknown/Codex field(`max_output_tokens`, `tools`, `store`, ...)는 보존하며, `stream:true`는 provider raw SSE로 relay한다. provider auth forwarding이 적용되고, response model echo rewrite는 적용하지 않는다. 이 경로는 normalized `SubmitRun`으로 fallback하지 않는다.
|
||||
- `/v1/responses` provider route에서 명시적 `metadata.iop_response_mode="passthrough+sideband"`는 opt-in extension surface다. non-streaming provider JSON object 응답은 top-level `metadata` object를 만들거나 병합해 IOP sideband metadata를 삽입한다. streaming 응답은 provider SSE event stream 사이에 `event: iop.sideband`를 삽입한다. sideband 내용은 `metadata` object 아래 확장 가능하며, 현재 최소 marker는 `iop_response_mode="passthrough+sideband"`다. `"transformed"`는 `400 invalid_request_error`로 거부한다.
|
||||
- Responses provider passthrough success usage metric label은 endpoint=`responses`, response_mode=`passthrough` 또는 `passthrough+sideband`, model_group=request alias를 사용한다.
|
||||
- provider-pool model group route(`models[]`)의 `/v1/responses` 호출은 `metadata.iop_response_mode`를 명시하면 값과 무관하게 `400 invalid_request_error`로 거부한다. 생략 시 raw passthrough로 provider `POST /v1/responses`에 전달한다. caller body는 `model` field만 served target으로 rewrite하고, unknown/Codex field(`max_output_tokens`, `tools`, `store`, ...)는 보존하며, `stream:true`는 provider raw SSE로 relay한다. provider auth forwarding이 적용되고, response model echo rewrite는 적용하지 않는다. 이 경로는 normalized `SubmitRun`으로 fallback하지 않는다.
|
||||
- direct legacy provider route(`openai.model_routes[]`의 `openai_compat`/`vllm` adapter)에서 명시적 `metadata.iop_response_mode="passthrough+sideband"`는 opt-in extension surface다. non-streaming provider JSON object 응답은 top-level `metadata` object를 만들거나 병합해 IOP sideband metadata를 삽입한다. streaming 응답은 provider SSE event stream 사이에 `event: iop.sideband`를 삽입한다. sideband 내용은 `metadata` object 아래 확장 가능하며, 현재 최소 marker는 `iop_response_mode="passthrough+sideband"`다. `"transformed"`는 provider tunnel route에서 `400 invalid_request_error`로 거부한다.
|
||||
- Responses provider passthrough success usage metric label은 endpoint=`responses`, response_mode=`passthrough` 또는 direct sideband route의 `passthrough+sideband`, model_group=request alias를 사용한다.
|
||||
- `metadata`는 최대 16개 string key/value를 허용한다. key는 64자 이하, value는 512자 이하를 기준으로 한다.
|
||||
- CLI route의 `metadata.workspace`는 이 문서의 계약 기준이다. 구현은 이 값을 Edge service의 run workspace와 Node CLI adapter의 process working directory로 전달해야 한다.
|
||||
- `metadata.workspace`는 `RunRequest.Workspace`로 전달하고 generic run metadata에는 복사하지 않는다.
|
||||
|
|
@ -201,14 +201,14 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa
|
|||
|
||||
### Chat Completions response mode
|
||||
|
||||
OpenAI-compatible inference provider route는 요청 metadata의 `iop_response_mode`로 응답 경로를 고른다.
|
||||
Provider route의 응답 경로는 route 종류에 따라 다르다.
|
||||
|
||||
- `metadata.iop_response_mode` 생략 또는 `passthrough`: provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. Chat Completions 성공 응답의 top-level `model` echo가 provider-served model이면 caller가 요청한 IOP model alias로 정규화한다. reasoning/content/tool_calls 같은 provider payload field는 보존한다. pure `passthrough` 응답 body에는 IOP sideband field/event를 섞지 않고, `X-IOP-Response-Mode` header도 붙이지 않는다.
|
||||
- Chat Completions의 `metadata.iop_response_mode="passthrough+sideband"`: provider body와 IOP route/usage/assembled observation을 명시적 IOP extension surface로 함께 노출한다. streaming 응답은 provider SSE event 경계 사이에 `event: iop.sideband`를 추가하고, non-streaming 응답은 `iop.chat.passthrough_sideband` envelope로 provider body와 `iop_sideband`를 함께 반환한다. `/v1/responses`의 같은 모드는 위 Responses API 섹션처럼 응답 `metadata` 또는 SSE `event: iop.sideband`를 사용한다. 이 모드는 provider-original byte-identical response로 표시하지 않는다.
|
||||
- `metadata.iop_response_mode="transformed"`: OpenAI-compatible provider model group route(provider pool, `openai_compat`, `vllm`)에서는 지원하지 않으며 `400 invalid_request_error`로 거부한다. 이 제한은 provider model group이 normalized `SubmitRun` path로 회귀하지 않게 하기 위한 것이다. non-provider normalized route에서는 raw tunnel을 쓰지 않고 normalized IOP output path를 사용하며 `X-IOP-Response-Mode: transformed`로 라벨링한다.
|
||||
- provider-pool model group route(`models[]`)는 `metadata.iop_response_mode`를 caller 선택자로 받지 않는다. 생략 시 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. Chat Completions 성공 응답의 top-level `model` echo가 provider-served model이면 caller가 요청한 IOP model alias로 정규화한다. reasoning/content/tool_calls 같은 provider payload field는 보존한다. pure passthrough 응답 body에는 IOP sideband field/event를 섞지 않고, `X-IOP-Response-Mode` header도 붙이지 않는다.
|
||||
- provider-pool model group route에서 `metadata.iop_response_mode`를 명시하면 `passthrough`, `passthrough+sideband`, `transformed`, 알 수 없는 값 모두 `400 invalid_request_error`로 거부한다. 이 제한은 provider model group이 client response-mode selector나 normalized `SubmitRun` path로 회귀하지 않게 하기 위한 것이다.
|
||||
- direct legacy provider route(`openai.model_routes[]`의 `openai_compat`/`vllm` adapter)는 `metadata.iop_response_mode` 생략 또는 `passthrough`를 raw provider tunnel로 처리한다. Chat Completions의 `metadata.iop_response_mode="passthrough+sideband"`는 provider body와 IOP route/usage/assembled observation을 명시적 IOP extension surface로 함께 노출한다. streaming 응답은 provider SSE event 경계 사이에 `event: iop.sideband`를 추가하고, non-streaming 응답은 `iop.chat.passthrough_sideband` envelope로 provider body와 `iop_sideband`를 함께 반환한다. `/v1/responses`의 같은 모드는 위 Responses API 섹션처럼 응답 `metadata` 또는 SSE `event: iop.sideband`를 사용한다. 이 모드는 provider-original byte-identical response로 표시하지 않는다.
|
||||
- direct legacy provider route에서 `metadata.iop_response_mode="transformed"`는 지원하지 않으며 `400 invalid_request_error`로 거부한다. non-provider normalized route에서는 raw tunnel을 쓰지 않고 normalized IOP output path를 사용하며 `X-IOP-Response-Mode: transformed`로 라벨링한다.
|
||||
|
||||
알 수 없는 `metadata.iop_response_mode` 값은 silent fallback 없이 `400 invalid_request_error`로 거부한다.
|
||||
현재 Chat Completions provider route는 raw `passthrough`와 `passthrough+sideband`를 지원한다. provider model group `/v1/responses` 요청은 raw `passthrough`로 provider `POST /v1/responses`에 전달하며(위 Responses API 섹션 참고), normalized path로 fallback하지 않는다. `/v1/responses` provider 경로도 명시적 `passthrough+sideband`를 지원하지만, Chat envelope를 재사용하지 않고 Responses body의 `metadata` 또는 SSE `event: iop.sideband`를 확장 지점으로 사용한다.
|
||||
알 수 없는 `metadata.iop_response_mode` 값은 silent fallback 없이 `400 invalid_request_error`로 거부한다. provider-pool model group route는 값 검증 전에 explicit selector 자체를 거부한다.
|
||||
|
||||
Think 제어 field:
|
||||
|
||||
|
|
@ -229,9 +229,9 @@ Think 제어 field:
|
|||
| `think=false` | Edge validation conflict가 없으면 요청은 통과하고 model catalog의 default thinking budget 주입은 억제된다. 이후 provider body에 `think:false`가 반영될 수 있으나, 현재 `gemma4:26b` provider 최적화 값과 충돌하거나 backend별로 무시/실패/품질 저하가 날 수 있다. | “숨기기만” 하는 옵션이 아니다. dev-corp `gemma4:26b` 기본 안정 호출에서는 생략한다. |
|
||||
| `reasoning_effort="none"` | `think=false`와 같은 disable 의도로 해석되어 default thinking budget 주입을 억제한다. provider tunnel에서는 runtime/provider 지원 여부에 의존한다. | `think=false`와 같은 이유로 기본 안정 호출에서는 생략한다. |
|
||||
| 명시적 `thinking_token_budget` | 0 이상이면 conflict validation 후 provider tunnel body에 반영될 수 있다. catalog 기본 budget 대신 caller 값으로 provider thinking budget을 바꾸는 요청이다. | 최적화된 `gemma4:26b` 기본값을 바꾸는 측정으로만 사용한다. 일반 표준 안정 호출에서는 생략한다. |
|
||||
| `metadata.iop_response_mode="passthrough+sideband"` | provider content는 보존하고 IOP sideband observation만 추가한다. reasoning hide를 수행하지 않는다. | route/usage 관측이 필요할 때만 사용한다. |
|
||||
| `metadata.iop_response_mode="passthrough+sideband"` | provider-pool model group route에서는 `400 invalid_request_error`로 거부한다. | dev-corp `gemma4:26b` provider-pool에서는 request selector로 사용하지 않는다. route/usage 관측은 metric/log surface를 기준으로 둔다. |
|
||||
| `metadata.iop_response_mode="transformed"` | provider model group route에서는 `400 invalid_request_error`로 거부한다. | dev-corp `gemma4:26b` provider-pool에서는 사용하지 않는다. |
|
||||
| `/v1/responses` 호출 | provider model group route에서 raw `passthrough`로 provider `POST /v1/responses`에 전달한다. `model`만 rewrite하고 unknown/Codex field는 보존하며 `stream:true`는 raw SSE로 relay한다. 명시적 `passthrough+sideband`는 non-stream 응답 `metadata` 또는 SSE `event: iop.sideband`를 확장 지점으로 쓴다. usage metric은 endpoint=`responses`로 측정한다. | Codex 스타일 `/v1/responses` 호출을 provider-pool로 그대로 넘겨 측정할 수 있다. Chat 기반 호출은 `/v1/chat/completions`를 쓴다. |
|
||||
| `/v1/responses` 호출 | provider-pool model group route에서 raw `passthrough`로 provider `POST /v1/responses`에 전달한다. `model`만 rewrite하고 unknown/Codex field는 보존하며 `stream:true`는 raw SSE로 relay한다. 명시적 `metadata.iop_response_mode`는 값과 무관하게 `400 invalid_request_error`로 거부한다. usage metric은 endpoint=`responses`로 측정한다. | Codex 스타일 `/v1/responses` 호출을 provider-pool로 그대로 넘겨 측정할 수 있다. Chat 기반 호출은 `/v1/chat/completions`를 쓴다. |
|
||||
|
||||
현재 구현에서 `think=false`를 “provider에는 기본 think를 유지하되 IOP가 응답에서 reasoning만 감추는 hide-only 모드”로 해석하지 않는다. 그런 동작이 필요하면 provider/vLLM 설정 변경이 아니라 Edge provider-pool passthrough 응답 filtering 정책을 별도 구현/계약 갱신해야 한다.
|
||||
|
||||
|
|
@ -297,6 +297,7 @@ text tool-call을 구조화할 때 Edge는 route와 무관하게 요청의 `tool
|
|||
금지:
|
||||
|
||||
- `metadata.source`, `metadata.cli`, `metadata.inference`, `metadata.nomadcode`
|
||||
- provider-pool model group route에서 `metadata.iop_response_mode`를 명시하는 방식
|
||||
- `metadata.iop_response_mode`에 `passthrough`, `passthrough+sideband`, `transformed` 외 값을 넣는 방식
|
||||
- `options`, `chat_template_kwargs`, `format`, `keep_alive` 같은 provider/Ollama 전용 request field
|
||||
- `session_id`, `timeout_sec` 같은 IOP 실행 제어 field
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ IOP의 OpenAI-compatible, A2A, IOP native 입력 표면에서 들어온 요청
|
|||
- 경로: [seulgivibe-openai-compatible-provider](../../archive/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md)
|
||||
- 요약: Seulgivibe Claude/OpenAI 프록시를 OpenAI-compatible provider family로 관리하고, model group에서는 passthrough(+sideband) 계열 provider로 분류한다. 정적 catalog, 요청 시점 provider token forwarding, Codex Responses passthrough는 코드 감사와 spec sync까지 통과해 archive했다.
|
||||
|
||||
- [계획] Model Group Mixed Provider Dispatch
|
||||
- [진행중] Model Group Mixed Provider Dispatch
|
||||
- 경로: [model-group-mixed-provider-dispatch](milestones/model-group-mixed-provider-dispatch.md)
|
||||
- 요약: model group provider pool에서 OpenAI-compatible provider와 Ollama/CLI 같은 normalized provider를 같은 후보군으로 두고, 기존 capacity+priority 선택 뒤 OpenAI-compatible 지원 provider는 모두 passthrough, native provider는 normalized 실행 경로로 자동 결정한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Client 요청은 provider 실행 경로를 고르는 필드를 넣지 않으며,
|
|||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
[진행중]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
|
|
@ -55,9 +55,9 @@ Client 요청은 provider 실행 경로를 고르는 필드를 넣지 않으며,
|
|||
|
||||
Model group이 provider 종류에 따라 normalized-only로 후퇴하지 않고, 선택된 provider의 OpenAI-compatible 지원 여부에 맞는 실행 경로로 dispatch되는 기능을 묶는다.
|
||||
|
||||
- [ ] [selection-first-path] Provider-pool route가 provider 후보를 먼저 선택하고 같은 queue slot/lease로 `ProviderTunnelRequest` 또는 normalized `RunRequest` 중 하나를 실행한다. 검증: `go test ./apps/edge/internal/service -count=1`에서 double scheduling 없이 selected provider path가 고정됨을 확인한다.
|
||||
- [ ] [provider-path-classifier] Provider classifier가 OpenAI-compatible 호출 방식을 지원하는 모든 provider를 passthrough 방식으로, Ollama/CLI/native provider를 normalized로 분류한다. Seulgivibe aliases는 passthrough provider에 포함한다. 검증: `go test ./packages/go/config -count=1`, `go test ./apps/edge/internal/node -count=1`, `go test ./apps/node/internal/adapters -count=1`이 provider alias와 adapter implementation case를 포함해 통과한다.
|
||||
- [ ] [mixed-model-group] 같은 model group 안의 vLLM/vLLM-MLX/Lemonade/openweight cloud provider와 Ollama provider가 모두 후보로 남고, 선택된 provider별로 tunnel 또는 normalized path가 실행된다. 검증: `go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/internal/service -count=1`이 mixed group, Ollama-only group, tunnel-only group fixture를 포함해 통과한다.
|
||||
- [x] [selection-first-path] Provider-pool route가 provider 후보를 먼저 선택하고 같은 queue slot/lease로 `ProviderTunnelRequest` 또는 normalized `RunRequest` 중 하나를 실행한다. 검증: `go test ./apps/edge/internal/service -count=1`에서 double scheduling 없이 selected provider path가 고정됨을 확인한다.
|
||||
- [x] [provider-path-classifier] Provider classifier가 OpenAI-compatible 호출 방식을 지원하는 모든 provider를 passthrough 방식으로, Ollama/CLI/native provider를 normalized로 분류한다. Seulgivibe aliases는 passthrough provider에 포함한다. 검증: `go test ./packages/go/config -count=1`, `go test ./apps/edge/internal/node -count=1`, `go test ./apps/node/internal/adapters -count=1`이 provider alias와 adapter implementation case를 포함해 통과한다.
|
||||
- [x] [mixed-model-group] 같은 model group 안의 vLLM/vLLM-MLX/Lemonade/openweight cloud provider와 Ollama provider가 모두 후보로 남고, 선택된 provider별로 tunnel 또는 normalized path가 실행된다. 검증: `go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/internal/service -count=1`이 mixed group, Ollama-only group, tunnel-only group fixture를 포함해 통과한다.
|
||||
|
||||
### Epic: [model-group-surface] Model Group Client Surface
|
||||
|
||||
|
|
|
|||
|
|
@ -59,14 +59,14 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
|
|||
| legacy route 변환 | legacy route는 외부 `model`을 route entry의 `adapter`, `target`, `node`, `session_id`, queue policy로 변환한다. |
|
||||
| metadata/workspace 처리 | `metadata.workspace`는 `RunRequest.workspace`로 분리하고, 일반 metadata는 최대 16개 string key/value만 허용한다. |
|
||||
| Chat Completions | `/v1/chat/completions`는 non-streaming과 streaming SSE를 지원한다. |
|
||||
| Chat Completions response mode | OpenAI-compatible provider route는 `metadata.iop_response_mode` 생략 시 `passthrough`로 동작하고, 명시적으로 `passthrough+sideband` 또는 `transformed`를 선택할 수 있다. |
|
||||
| Chat Completions response mode | provider-pool model group route는 `metadata.iop_response_mode` 생략 시 `passthrough`로 동작하고, 명시적 selector는 값과 무관하게 거부한다. direct legacy provider route는 명시적 `passthrough+sideband` extension을 지원하며 `transformed`는 거부한다. |
|
||||
| provider raw passthrough | `passthrough`는 provider status/header/body bytes를 기존 Edge-Node tunnel로 relay하고 pure response body에 IOP sideband를 섞지 않는다. |
|
||||
| sideband extension | Chat Completions `passthrough+sideband`는 provider body와 IOP route/usage/assembled observation을 명시적 extension stream/envelope로 함께 노출한다. Responses `passthrough+sideband`는 응답 `metadata` 또는 `event: iop.sideband`를 확장 지점으로 사용한다. 둘 다 provider-original byte identity로 표시하지 않는다. |
|
||||
| sideband extension | direct legacy provider route의 `passthrough+sideband`는 provider body와 IOP route/usage/assembled observation을 명시적 extension stream/envelope로 함께 노출한다. Responses `passthrough+sideband`는 응답 `metadata` 또는 `event: iop.sideband`를 확장 지점으로 사용한다. 둘 다 provider-original byte identity로 표시하지 않는다. |
|
||||
| OpenAI usage metering | Edge는 OpenAI-compatible request terminal status와 provider-reported `input`, `output`, `reasoning`, `cached_input` token usage를 Prometheus counter로 집계한다. |
|
||||
| reasoning observation metric | provider가 reasoning token을 보고하지 않고 reasoning text만 관측되면 token 추정 없이 관측 횟수와 character count 보조 metric만 emit한다. |
|
||||
| Grafana usage surface | 1차 조회 표면은 Prometheus/Grafana query guide이며 daily/monthly rollup, usage origin breakdown, operator-managed cloud price baseline, cloud-equivalent cost, avoided-cost ROI 기준을 문서로 제공한다. Control Plane/Client dashboard와 request-level ledger는 후속 범위다. |
|
||||
| Responses API | normalized(non-provider) `/v1/responses`는 string input의 non-streaming 요청만 지원한다. provider model group route는 `/v1/responses`를 raw passthrough로 provider `POST /v1/responses`에 전달한다. |
|
||||
| Responses provider passthrough | provider route의 `/v1/responses`는 `model`만 served target으로 rewrite하고 unknown/Codex field를 보존하며 `stream:true`를 raw SSE로 relay한다. provider auth forwarding을 적용하고 response model echo rewrite는 하지 않는다. 명시적 `passthrough+sideband`는 non-stream 응답의 top-level `metadata` 또는 streaming `event: iop.sideband`를 확장 지점으로 사용한다. usage metric은 endpoint=`responses`, response_mode=`passthrough` 또는 `passthrough+sideband`, model_group=request alias로 집계한다. |
|
||||
| Responses provider passthrough | provider-pool model group route의 `/v1/responses`는 `metadata.iop_response_mode` 명시를 거부하고, 생략 시 `model`만 served target으로 rewrite해 unknown/Codex field와 `stream:true` raw SSE를 provider로 relay한다. direct legacy provider route의 명시적 `passthrough+sideband`는 non-stream 응답의 top-level `metadata` 또는 streaming `event: iop.sideband`를 확장 지점으로 사용한다. usage metric은 endpoint=`responses`, response_mode=`passthrough` 또는 direct sideband route의 `passthrough+sideband`, model_group=request alias로 집계한다. |
|
||||
| strict output | strict output이 켜져 있으면 XML completion contract 기반 instruction 또는 prompt prefix를 추가할 수 있다. |
|
||||
| tool call 처리 | Chat Completions `tools`는 provider native metadata 복원 또는 text tool-call synthesis/validation 경로를 사용한다. |
|
||||
| cancel 전파 | HTTP caller timeout/cancel이 cancel-worthy error이면 Node `CancelRun`으로 전파한다. |
|
||||
|
|
@ -114,9 +114,10 @@ sequenceDiagram
|
|||
- top-level `models[]`가 있으면 OpenAI model list와 provider-pool dispatch에서 legacy route보다 우선한다.
|
||||
- `openai.provider_auth`는 provider tunnel forwarding rule만 저장하고 raw provider token 값은 request-time header에서만 읽는다. inbound IOP `Authorization` header를 provider token source로 재사용하지 않는다.
|
||||
- OpenAI request의 `metadata.workspace`는 absolute path가 필요한 route에서만 필수 검증된다.
|
||||
- OpenAI Chat Completions와 provider route Responses request의 `metadata.iop_response_mode`는 `passthrough`, `passthrough+sideband`, `transformed`만 허용한다. provider route에서 생략하면 `passthrough`다. Responses provider route의 `transformed`는 지원하지 않는다.
|
||||
- provider-pool model group Chat Completions와 Responses request는 `metadata.iop_response_mode`를 명시하면 `passthrough`, `passthrough+sideband`, `transformed`, unknown 모두 거부한다. 생략하면 `passthrough`다.
|
||||
- direct legacy provider route는 `metadata.iop_response_mode` 생략 또는 `passthrough`를 raw tunnel로 처리하고, `passthrough+sideband`를 extension surface로 지원한다. provider tunnel route의 `transformed`는 지원하지 않는다.
|
||||
- run metadata에는 `openai_model`, `openai_stream`, `strict_output`, `estimated_input_tokens`, `context_class`가 들어갈 수 있다.
|
||||
- provider tunnel metadata에는 response mode와 routing context가 들어가고, sideband mode는 route/usage/assembled observation을 확장 surface로 만든다.
|
||||
- provider tunnel metadata에는 response mode와 routing context가 들어가고, direct sideband mode는 route/usage/assembled observation을 확장 surface로 만든다.
|
||||
- Node complete event metadata의 `openai_tool_calls`와 `openai_text_tool_fallback`은 response tool call 복원에 쓰인다.
|
||||
- usage metric은 `iop_openai_requests_total`, `iop_openai_usage_tokens_total`, `iop_openai_reasoning_observed_total`, `iop_openai_reasoning_chars_total`로 emit된다.
|
||||
- usage label은 `edge_id`, `principal_ref`, `principal_alias`, `token_ref`, `model_group`, `endpoint`, `response_mode`, `status`, `usage_source`, `token_type`처럼 낮은 cardinality 값만 사용한다.
|
||||
|
|
@ -140,8 +141,9 @@ sequenceDiagram
|
|||
- OpenAI-compatible request에 provider/Ollama 전용 root field를 추가하지 않는다.
|
||||
- workspace는 prompt 본문에 섞지 않고 metadata에서 분리한다.
|
||||
- pure `passthrough` body는 provider-original byte stream이며 IOP sideband나 transformed label을 포함하지 않는다.
|
||||
- `passthrough+sideband`와 `transformed`는 provider-original byte identity로 취급하지 않는다.
|
||||
- Responses provider route의 `passthrough+sideband`는 Chat Completions sideband envelope를 쓰지 않는다. non-streaming JSON object 응답은 `metadata` object를 병합하고, streaming 응답은 `event: iop.sideband`를 끼운다.
|
||||
- direct legacy provider route의 `passthrough+sideband`와 non-provider normalized route의 `transformed`는 provider-original byte identity로 취급하지 않는다.
|
||||
- provider-pool model group route는 `metadata.iop_response_mode`를 request selector로 받지 않는다.
|
||||
- direct legacy Responses provider route의 `passthrough+sideband`는 Chat Completions sideband envelope를 쓰지 않는다. non-streaming JSON object 응답은 `metadata` object를 병합하고, streaming 응답은 `event: iop.sideband`를 끼운다.
|
||||
- text tool-call synthesis는 요청 `tools[]` schema를 기준으로만 수행한다. 자연어 추론으로 tool call을 만들지 않는다.
|
||||
- private token이나 endpoint 원문은 tracked spec/docs에 남기지 않는다.
|
||||
- `metadata.user`는 identity source가 아니며 사용되지 않는다.
|
||||
|
|
@ -162,3 +164,4 @@ sequenceDiagram
|
|||
- 2026-07-11: provider model group `/v1/responses` raw passthrough 동작(모델 rewrite, unknown/Codex field 보존, streaming relay, provider auth forwarding)과 endpoint=`responses` usage metric label을 현재 코드 기준으로 반영.
|
||||
- 2026-07-11: Responses provider route의 명시적 `passthrough+sideband` 동작을 반영. non-stream은 응답 `metadata`, stream은 `event: iop.sideband`를 확장 지점으로 사용한다.
|
||||
- 2026-07-11: provider auth forwarding과 Seulgivibe OpenAI-compatible provider family surface를 종료 검토 기준으로 보강.
|
||||
- 2026-07-12: provider-pool model group route에서 명시적 `metadata.iop_response_mode`를 거부하고, direct legacy provider route에서만 sideband extension selector를 유지하는 현재 surface를 반영.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
<!-- task=m-model-group-mixed-provider-dispatch/04_custom_field_preservation plan=0 tag=SURFACE_FIELD -->
|
||||
|
||||
# Code Review Reference - SURFACE_FIELD
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-07-12
|
||||
task=m-model-group-mixed-provider-dispatch/04_custom_field_preservation, plan=0, tag=SURFACE_FIELD
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
|
||||
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
|
||||
- Task ids:
|
||||
- `custom-field-preservation`: Provider-pool Chat route raw body/envelope 분리와 unknown/Codex/provider-specific field 보존
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 판정 append, active file log archive, PASS 시 `complete.log` 작성과 task archive 이동까지 끝난 상태를 의미합니다. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고하고 roadmap 수정은 런타임 책임입니다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [SURFACE_FIELD-1] envelope-first Chat provider-pool decode | [ ] |
|
||||
| [SURFACE_FIELD-2] provider tunnel unknown field preservation test | [ ] |
|
||||
| [SURFACE_FIELD-3] normalized provider field policy test | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [SURFACE_FIELD-1] Chat Completions provider-pool route가 strict unknown-field decode 전에 routing envelope를 읽고, model group selector 거부와 route resolution을 envelope 기준으로 수행하게 한다.
|
||||
- [ ] [SURFACE_FIELD-2] OpenAI-compatible provider 선택 시 `rewriteChatCompletionModel`이 model rewrite와 catalog generation policy 외에는 caller raw body unknown/Codex/provider-specific field를 보존함을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode|MixedProviderPool' -count=1`.
|
||||
- [ ] [SURFACE_FIELD-3] normalized-provider 선택 시 standard supported field만 normalized `RunRequest.Input`에 반영되고 unknown provider-specific field가 native request에 임의 전파되지 않음을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPool.*Normalized' -count=1`.
|
||||
- [ ] [SURFACE_FIELD-4] 최종 OpenAI handler 패키지 검증을 실행한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`와 `PLAN-*-G??.md`를 `.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 task artifact를 unignore하는지 확인한다.
|
||||
- [ ] PASS이면 `complete.log`를 작성하고 active task 디렉터리를 archive로 이동한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고하고 roadmap 수정은 하지 않는다.
|
||||
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW gate를 처리한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- provider-pool Chat route만 lenient unknown-field decode를 사용하고 legacy/non-provider strict decode는 유지되는가.
|
||||
- tunnel path의 raw body는 model rewrite와 catalog policy 외 unknown field를 보존하는가.
|
||||
- normalized path는 unknown provider-specific field를 native `RunRequest.Input`에 임의 전파하지 않는가.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
### SURFACE_FIELD-1 중간 검증
|
||||
```bash
|
||||
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode' -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode|MixedProviderPool' -count=1
|
||||
(output)
|
||||
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
<!-- task=m-model-group-mixed-provider-dispatch/04_custom_field_preservation plan=0 tag=SURFACE_FIELD -->
|
||||
|
||||
# Plan - SURFACE_FIELD
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현이다. 코드를 바꾸고 검증을 실행한 뒤 실제 변경 내용과 stdout/stderr를 기록하고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다. 환경/secret/service 차단, 범위 조정, 검증 공백은 사용자 리뷰가 아니라 계획 대비 변경 사항 또는 follow-up 근거로 기록한다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 Chat Completions provider-pool 요청은 raw body를 읽지만, route를 알기 전에 `decodeChatCompletionRequest`가 unknown field를 거부한다. Responses provider-pool은 envelope를 먼저 읽어 raw passthrough에서 unknown/Codex/provider-specific field를 보존하므로 Chat과 계약이 어긋난다. 이 작업은 model group Chat route도 Responses처럼 routing envelope와 strict normalized decode를 분리한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
사용자 리뷰 요청은 선택된 Milestone lock 결정이 구현을 실제로 막을 때만 active `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트, 채팅 선택지, `request_user_input` 호출은 금지하며, code-review가 요청 유효성 검증과 `USER_REVIEW.md` 작성을 소유한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
|
||||
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
|
||||
- Task ids:
|
||||
- `custom-field-preservation`: Provider-pool Chat route raw body/envelope 분리와 unknown/Codex/provider-specific field 보존
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/edge-smoke.md`
|
||||
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
|
||||
- `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`
|
||||
- `agent-contract/outer/openai-compatible-api.md`
|
||||
- `agent-spec/input/openai-compatible-surface.md`
|
||||
- `apps/edge/internal/openai/chat_handler.go`
|
||||
- `apps/edge/internal/openai/responses_handler.go`
|
||||
- `apps/edge/internal/openai/types.go`
|
||||
- `apps/edge/internal/openai/stream.go`
|
||||
- `apps/edge/internal/openai/server_test.go`
|
||||
|
||||
### SDD 기준
|
||||
|
||||
- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`, 상태 `[승인됨]`, 잠금 해제.
|
||||
- 대상 Acceptance Scenario: S07, S08.
|
||||
- Evidence Map: S07은 Chat raw body preservation tunnel test, S08은 normalized provider 선택 시 supported field mapping/unsupported policy assertion을 요구한다.
|
||||
- 구현 체크리스트는 Chat ingress decode 순서, provider tunnel body 보존, normalized path field 정책 테스트를 이 Evidence Map에서 역산했다.
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- test_env: `local`.
|
||||
- `agent-test/local/rules.md`와 `agent-test/local/edge-smoke.md`를 읽었고 Edge/OpenAI-compatible 변경이므로 변경 패키지 대상 `go test`가 필수다.
|
||||
- 현재 checkout 내부 검증만 사용한다. 외부 provider endpoint, credential, Docker, 장기 runtime 프리플라이트는 필요 없다.
|
||||
- 검증 후보 명령은 실제 실행 확인됨: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- 현재 `TestChatCompletionsMixedProviderPoolTunnelSelection`은 tunnel dispatch만 검증하고 Chat unknown field 보존은 검증하지 않는다.
|
||||
- 현재 `TestResponsesProviderTunnelStreaming` 계열은 Responses raw passthrough 보존을 검증하지만 Chat provider-pool ingress의 strict decode 회귀를 막지 못한다.
|
||||
- 이 계획은 provider-pool tunnel selected path의 unknown field 보존과 normalized selected path의 supported-field mapping/unknown drop-or-observe 정책을 새 regression test로 채운다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- renamed/removed symbol 없음.
|
||||
- 새 helper 후보 `decodeChatCompletionEnvelope` 또는 `decodeChatCompletionRequest(..., strictUnknown bool)`는 신규 심볼이다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- split decision policy를 먼저 평가했다.
|
||||
- 공유 task group: `m-model-group-mixed-provider-dispatch`.
|
||||
- sibling plan:
|
||||
- `04_custom_field_preservation`: 독립, predecessor 없음.
|
||||
- `05_sideband_observation`: 독립, predecessor 없음.
|
||||
- 분리 사유: custom-field 작업은 Chat ingress/raw body와 handler tests 중심이고, sideband-observation은 service dispatch DTO/log/metric observation 중심이라 ownership과 검증 위험이 다르다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- Responses handler는 이미 envelope-first 구조이므로 수정하지 않는다.
|
||||
- service provider selection, RunDispatch observation, metric label 변경은 `05_sideband_observation` 범위다.
|
||||
- contract/spec 전체 동기화는 Milestone의 `contract-sync` task가 따로 있으므로 여기서는 구현에 직접 필요한 테스트와 handler 동작만 다룬다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- `local-G06`: OpenAI handler 내부의 bounded behavior change이며 로컬 Go test로 검증 가능하지만, provider-pool tunnel/normalized 분기와 raw JSON 보존을 함께 다뤄 중간 난도다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [SURFACE_FIELD-1] Chat Completions provider-pool route가 strict unknown-field decode 전에 routing envelope를 읽고, model group selector 거부와 route resolution을 envelope 기준으로 수행하게 한다.
|
||||
- [ ] [SURFACE_FIELD-2] OpenAI-compatible provider 선택 시 `rewriteChatCompletionModel`이 model rewrite와 catalog generation policy 외에는 caller raw body unknown/Codex/provider-specific field를 보존함을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode|MixedProviderPool' -count=1`.
|
||||
- [ ] [SURFACE_FIELD-3] normalized-provider 선택 시 standard supported field만 normalized `RunRequest.Input`에 반영되고 unknown provider-specific field가 native request에 임의 전파되지 않음을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPool.*Normalized' -count=1`.
|
||||
- [ ] [SURFACE_FIELD-4] 최종 OpenAI handler 패키지 검증을 실행한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [SURFACE_FIELD-1] Envelope-First Chat Provider-Pool Decode
|
||||
|
||||
#### 문제
|
||||
|
||||
- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:37): handler가 route dispatch 전 `decodeChatCompletionRequest`를 호출한다.
|
||||
- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:337): `decodeChatCompletionRequest`가 allowlist 밖 field를 즉시 `400`으로 거부한다.
|
||||
- [responses_handler.go](/config/workspace/iop/apps/edge/internal/openai/responses_handler.go:35): Responses는 이미 envelope-first로 provider passthrough unknown field를 보존한다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Before ([chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:37)):
|
||||
|
||||
```go
|
||||
var req chatCompletionRequest
|
||||
if err := decodeChatCompletionRequest(json.NewDecoder(bytes.NewReader(rawBody)), &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
env, err := decodeChatCompletionEnvelope(rawBody)
|
||||
if err != nil { ... }
|
||||
dispatch, ok := s.resolveRouteDispatch(env.Model)
|
||||
...
|
||||
var req chatCompletionRequest
|
||||
if dispatch.ProviderPool {
|
||||
err = decodeChatCompletionRequestLenient(json.NewDecoder(bytes.NewReader(rawBody)), &req)
|
||||
} else {
|
||||
err = decodeChatCompletionRequest(json.NewDecoder(bytes.NewReader(rawBody)), &req)
|
||||
}
|
||||
```
|
||||
|
||||
`decodeChatCompletionRequestLenient`는 JSON 형식과 known-field validation은 유지하지만 unknown top-level field를 거부하지 않는다. `metadata.iop_response_mode` selector 거부는 envelope metadata + principal metadata merge 뒤에 계속 수행한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/openai/chat_handler.go`: envelope type/helper 추가 또는 decode 함수 strict flag 추가.
|
||||
- [ ] `apps/edge/internal/openai/chat_handler.go`: provider-pool route에서 lenient decode 사용, non-provider/legacy strict decode 유지.
|
||||
- [ ] `apps/edge/internal/openai/chat_handler.go`: selector rejection 위치가 provider-pool dispatch 전임을 유지.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- `apps/edge/internal/openai/server_test.go`에 provider-pool unknown field 보존/normalized 정책 테스트 추가.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode' -count=1
|
||||
```
|
||||
|
||||
### [SURFACE_FIELD-2] Provider Tunnel Raw Body Preservation Test
|
||||
|
||||
#### 문제
|
||||
|
||||
- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:258): provider-pool tunnel body는 `rewriteChatCompletionModel(rawBody, target, req)`로 생성된다.
|
||||
- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:534): rewrite helper는 raw JSON map에서 model/max token/thinking policy만 수정하므로 unknown field 보존이 가능하지만 test가 없다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
`TestChatCompletionsProviderPoolTunnelPreservesUnknownFields`를 추가한다. request body에 `store`, `parallel_tool_calls`, `codex_trace`, `custom_provider_options`, nested unknown object를 넣고 selected tunnel path로 dispatch한다. `fake.tunnelBodies[0]`를 JSON map으로 읽어 `model == served-model`, unknown fields preserved, no IOP sideband/header가 pure response에 섞이지 않음을 검증한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: tunnel path fixture 추가.
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: `fake.tunnelBodiesSnapshot()`가 없으면 기존 snapshot pattern에 맞춰 helper 추가.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 작성한다. Assertion 목표: model rewrite 외 unknown/Codex/provider-specific field 보존.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPoolTunnelPreservesUnknownFields' -count=1
|
||||
```
|
||||
|
||||
### [SURFACE_FIELD-3] Normalized Provider Field Policy Test
|
||||
|
||||
#### 문제
|
||||
|
||||
- [types.go](/config/workspace/iop/apps/edge/internal/openai/types.go:70): normalized input은 known standard fields만 `RunRequest.Input`으로 변환한다.
|
||||
- SDD S08은 normalized provider 선택 시 변환 가능한 standard field만 native adapter request로 가고, provider-specific unknown field는 임의 invent되지 않아야 한다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
`TestChatCompletionsProviderPoolNormalizedIgnoresUnknownFields`를 추가한다. selected normalized path에서 unknown field가 있어도 strict ingress reject가 나지 않고, `RunRequest.Input`에는 supported option/tool fields만 존재하며 unknown key가 없음을 검증한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: normalized path fixture 추가.
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: `fake.reqsSnapshot()[0].Input` assertions 추가.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 작성한다. Assertion 목표: unknown field는 normalized native input에 전파되지 않고 supported field mapping은 유지된다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPoolNormalizedIgnoresUnknownFields' -count=1
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `apps/edge/internal/openai/chat_handler.go` | SURFACE_FIELD-1 |
|
||||
| `apps/edge/internal/openai/server_test.go` | SURFACE_FIELD-2, SURFACE_FIELD-3 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode|MixedProviderPool' -count=1
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
|
||||
```
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<!-- task=m-model-group-mixed-provider-dispatch/05_sideband_observation plan=0 tag=SURFACE_OBS -->
|
||||
|
||||
# Code Review Reference - SURFACE_OBS
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
|
||||
> Finalization is review-agent-only.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-07-12
|
||||
task=m-model-group-mixed-provider-dispatch/05_sideband_observation, plan=0, tag=SURFACE_OBS
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
|
||||
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
|
||||
- Task ids:
|
||||
- `sideband-observation`: Provider-pool 실행 결과 selected provider, adapter, served target, execution path, queue decision, usage observation 기록
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. PASS 시 task archive와 완료 이벤트 메타데이터 보고까지 처리하고 roadmap 수정은 런타임에 맡깁니다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [SURFACE_OBS-1] service dispatch observation DTO | [ ] |
|
||||
| [SURFACE_OBS-2] OpenAI log/sideband observation boundary | [ ] |
|
||||
| [SURFACE_OBS-3] usage observation regression | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [SURFACE_OBS-1] service provider-pool dispatch info에 selected provider id/type과 execution path를 추가하고 tunnel/normalized 양쪽에서 채운다.
|
||||
- [ ] [SURFACE_OBS-2] OpenAI provider-pool dispatch logs와 direct sideband route observation이 selected provider/adapter/target/execution path/queue reason을 기록하도록 보강한다.
|
||||
- [ ] [SURFACE_OBS-3] 표준 provider-pool Chat response에는 `iop_sideband`, `iop.sideband`, `X-IOP-Response-Mode`가 섞이지 않고, internal observation만 남음을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Observation|AssembledLogs|UsageMetrics' -count=1`.
|
||||
- [ ] [SURFACE_OBS-4] service와 OpenAI 전체 패키지 검증을 실행한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1` 및 `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`와 `PLAN-*-G??.md`를 `.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 task artifact를 unignore하는지 확인한다.
|
||||
- [ ] PASS이면 `complete.log`를 작성하고 active task 디렉터리를 archive로 이동한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고하고 roadmap 수정은 하지 않는다.
|
||||
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW gate를 처리한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `RunDispatch` provider observation fields가 provider-pool tunnel/normalized 양쪽에서 채워지는가.
|
||||
- 표준 provider-pool response에는 sideband/custom field/header가 추가되지 않는가.
|
||||
- provider id/type/execution path/queue reason은 logs/sideband/internal observation으로 남고 secret/raw payload label은 추가되지 않는가.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
### SURFACE_OBS-1 중간 검증
|
||||
```bash
|
||||
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -run 'SubmitProviderPool.*Observation|DispatchInfo|ClassifiesExecutionPath' -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1
|
||||
(output)
|
||||
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
<!-- task=m-model-group-mixed-provider-dispatch/05_sideband_observation plan=0 tag=SURFACE_OBS -->
|
||||
|
||||
# Plan - SURFACE_OBS
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현이다. 코드를 바꾸고 검증을 실행한 뒤 실제 변경 내용과 stdout/stderr를 기록하고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다.
|
||||
|
||||
## 배경
|
||||
|
||||
model group은 selected provider가 실행 경로를 결정하지만, 현재 `RunDispatch`에는 `queue_reason`은 있어도 selected provider id/type과 execution path가 없다. OpenAI handler log와 sideband observation은 adapter/target 중심이라, model group 관측에서 어떤 provider와 경로가 선택됐는지 일관되게 추적하기 어렵다. 이 작업은 표준 client 응답을 깨끗하게 유지하면서 service dispatch DTO와 internal log/metric/sideband 관측을 보강한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
사용자 리뷰 요청은 선택된 Milestone lock 결정이 구현을 실제로 막을 때만 active `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트, 채팅 선택지, `request_user_input` 호출은 금지하며, code-review가 요청 유효성 검증과 `USER_REVIEW.md` 작성을 소유한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
|
||||
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
|
||||
- Task ids:
|
||||
- `sideband-observation`: Provider-pool 실행 결과 selected provider, adapter, served target, execution path, queue decision, usage observation 기록
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/edge-smoke.md`
|
||||
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
|
||||
- `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`
|
||||
- `agent-contract/outer/openai-compatible-api.md`
|
||||
- `agent-spec/input/openai-compatible-surface.md`
|
||||
- `apps/edge/internal/service/model_queue.go`
|
||||
- `apps/edge/internal/service/run_dispatch.go`
|
||||
- `apps/edge/internal/service/model_queue_test.go`
|
||||
- `apps/edge/internal/service/service_test.go`
|
||||
- `apps/edge/internal/service/run_dispatch_internal_test.go`
|
||||
- `apps/edge/internal/openai/chat_handler.go`
|
||||
- `apps/edge/internal/openai/responses_handler.go`
|
||||
- `apps/edge/internal/openai/stream.go`
|
||||
- `apps/edge/internal/openai/usage_metrics.go`
|
||||
- `apps/edge/internal/openai/usage_metrics_test.go`
|
||||
- `apps/edge/internal/openai/server_test.go`
|
||||
|
||||
### SDD 기준
|
||||
|
||||
- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`, 상태 `[승인됨]`, 잠금 해제.
|
||||
- 대상 Acceptance Scenario: S09.
|
||||
- Evidence Map: S09는 standard response와 observation tests를 요구한다.
|
||||
- 구현 체크리스트는 service dispatch info 보강, OpenAI log/sideband observation 반영, standard response no-custom-field regression, service/openai package tests에서 역산했다.
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- test_env: `local`.
|
||||
- `agent-test/local/rules.md`와 `agent-test/local/edge-smoke.md`를 읽었다.
|
||||
- Edge/OpenAI-compatible와 service dispatch DTO 변경이므로 `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/internal/service -count=1`을 최종 검증으로 둔다.
|
||||
- 두 명령은 현재 checkout에서 실제 실행 확인됨. 외부 provider, secret, Docker, 장기 runtime 프리플라이트는 필요 없다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- service `RunDispatch`는 selected provider id/type/execution path를 노출하지 않으므로 이를 검증하는 테스트가 없다.
|
||||
- OpenAI provider-pool dispatch logs는 path/adapter/target/queue_reason 일부만 검증되고, standard response에 IOP custom observation이 섞이지 않는 provider-pool-specific regression이 부족하다.
|
||||
- usage metrics는 token/endpoint/response_mode 중심 검증이 있으나 selected execution path가 dispatch info/log와 일치하는지 확인하지 않는다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- renamed/removed symbol 없음.
|
||||
- `RunDispatch` 구조체 필드 추가가 예상되며 named composite literal call sites는 `rg 'RunDispatch{' apps/edge/internal` 결과 기준으로 service/openai tests에 있다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- split decision policy를 먼저 평가했다.
|
||||
- 공유 task group: `m-model-group-mixed-provider-dispatch`.
|
||||
- sibling plan:
|
||||
- `04_custom_field_preservation`: 독립.
|
||||
- `05_sideband_observation`: 독립.
|
||||
- 이 plan은 service DTO와 OpenAI observation/log/test를 함께 다루며, custom-field raw body preservation과 구현/검증 축이 달라 별도 subtask가 안전하다. predecessor 없음.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- Prometheus raw prompt/response나 secret/high-cardinality payload label은 추가하지 않는다.
|
||||
- 표준 OpenAI-compatible response body/header에는 sideband/custom field를 추가하지 않는다.
|
||||
- direct legacy `passthrough+sideband` extension schema는 유지하되, provider-pool model group request selector는 다시 추가하지 않는다.
|
||||
- contract/spec 전체 문구 정리는 `contract-sync` task 범위로 남긴다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- `local-G07`: service dispatch DTO, OpenAI logs/sideband, tests 두 패키지에 걸친 변경이며 로컬 deterministic Go tests로 검증 가능하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [SURFACE_OBS-1] service provider-pool dispatch info에 selected provider id/type과 execution path를 추가하고 tunnel/normalized 양쪽에서 채운다.
|
||||
- [ ] [SURFACE_OBS-2] OpenAI provider-pool dispatch logs와 direct sideband route observation이 selected provider/adapter/target/execution path/queue reason을 기록하도록 보강한다.
|
||||
- [ ] [SURFACE_OBS-3] 표준 provider-pool Chat response에는 `iop_sideband`, `iop.sideband`, `X-IOP-Response-Mode`가 섞이지 않고, internal observation만 남음을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Observation|AssembledLogs|UsageMetrics' -count=1`.
|
||||
- [ ] [SURFACE_OBS-4] service와 OpenAI 전체 패키지 검증을 실행한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1` 및 `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [SURFACE_OBS-1] Provider-Pool Dispatch Observation DTO
|
||||
|
||||
#### 문제
|
||||
|
||||
- [run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:53): `RunDispatch`에 `ProviderID`, `ProviderType`, `ExecutionPath`가 없다.
|
||||
- [model_queue.go](/config/workspace/iop/apps/edge/internal/service/model_queue.go:45): `candidateNode`는 provider id와 execution path를 갖지만 provider type은 보존하지 않는다.
|
||||
- [run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:1013): `SubmitProviderPool` tunnel result의 `DispatchInfo`가 selected provider 관측 필드를 전달할 수 없다.
|
||||
- [run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:1083): normalized provider-pool `RunDispatch`도 같은 공백이 있다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Before ([run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:53)):
|
||||
|
||||
```go
|
||||
type RunDispatch struct {
|
||||
RunID string
|
||||
NodeID string
|
||||
ModelGroupKey string
|
||||
Adapter string
|
||||
Target string
|
||||
QueueReason string
|
||||
}
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
type RunDispatch struct {
|
||||
...
|
||||
ProviderID string
|
||||
ProviderType string
|
||||
ExecutionPath string
|
||||
QueueReason string
|
||||
}
|
||||
```
|
||||
|
||||
`candidateNode`에 `providerType`을 추가하고 `resolveProviderPoolCandidates`에서 `prov.Type`을 저장한다. provider-pool tunnel/normalized dispatch에서 `ProviderID`, `ProviderType`, `ExecutionPath`를 채우고 legacy direct dispatch는 빈 값으로 둔다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/service/model_queue.go`: `candidateNode.providerType` 추가.
|
||||
- [ ] `apps/edge/internal/service/run_dispatch.go`: `RunDispatch` 필드 추가와 provider-pool fill path 보강.
|
||||
- [ ] `apps/edge/internal/service/run_dispatch_internal_test.go` 또는 `model_queue_test.go`: selected provider observation regression 추가.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 작성한다. Assertion 목표: SubmitProviderPool tunnel/normalized result가 provider id/type/execution path/queue reason/adapter/target을 모두 담는다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -run 'SubmitProviderPool.*Observation|DispatchInfo|ClassifiesExecutionPath' -count=1
|
||||
```
|
||||
|
||||
### [SURFACE_OBS-2] OpenAI Observation And Standard Response Boundary
|
||||
|
||||
#### 문제
|
||||
|
||||
- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:277): provider-pool log가 path/adapter/target/queue_reason은 기록하지만 provider id/type/execution path 필드가 없다.
|
||||
- [stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:746): sideband route observation schema는 provider status까지 있으나 provider id/type/execution path/queue reason이 없다.
|
||||
- [stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:465): pure passthrough writer는 body를 relay하고 metrics를 emit하지만 표준 response에 custom sideband를 섞지 않는 provider-pool-specific regression이 필요하다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
OpenAI log fields에 `provider_id`, `provider_type`, `execution_path`, `queue_reason`을 추가한다. direct legacy sideband route는 빈 provider fields를 omit할 수 있게 `omitempty`를 사용하고, provider-pool standard response는 sideband extension writer를 타지 않게 유지한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/openai/chat_handler.go`: provider-pool dispatch log fields 추가.
|
||||
- [ ] `apps/edge/internal/openai/responses_handler.go`: provider tunnel/sideband logs가 새 dispatch fields를 보존하도록 추가.
|
||||
- [ ] `apps/edge/internal/openai/stream.go`: sideband route observation schema에 provider/execution/queue fields 추가.
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: observer logger 기반 regression 추가.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 작성한다. Assertion 목표: provider-pool standard response body/header에는 IOP custom sideband가 없고, observer logs에는 selected provider/execution/queue fields가 있다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Observation|AssembledLogs' -count=1
|
||||
```
|
||||
|
||||
### [SURFACE_OBS-3] Usage Observation Regression
|
||||
|
||||
#### 문제
|
||||
|
||||
- [usage_metrics.go](/config/workspace/iop/apps/edge/internal/openai/usage_metrics.go:49): usage labels는 low-cardinality allowlist를 유지한다.
|
||||
- [stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:487): pure passthrough usage observation은 provider body/proto usage에서 metrics를 emit한다.
|
||||
- SDD S09는 usage 후보가 internal observation에 남아야 하지만 표준 client response에는 custom field가 없어야 한다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Prometheus labels에는 raw prompt/response/secret을 추가하지 않는다. provider id label 추가가 필요하다고 판단되면 configured provider id만 low-cardinality로 추가하고 label allowlist test를 함께 수정한다. 그렇지 않으면 provider id/type/path는 logs/dispatch sideband, usage 후보는 기존 usage metrics로 분리 기록한다. 선택한 해석은 `주요 설계 결정`에 기록한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/openai/usage_metrics.go`: label 변경 여부를 확정하고 필요 시 allowlist 업데이트.
|
||||
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: metric allowlist와 provider-pool standard response metric regression 업데이트.
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: response body/header no-sideband assertion 추가 또는 보강.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 작성한다. Assertion 목표: usage metrics는 계속 emit되고 표준 response에는 observation custom field가 없다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'UsageMetrics|ProviderPool.*Observation' -count=1
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `apps/edge/internal/service/model_queue.go` | SURFACE_OBS-1 |
|
||||
| `apps/edge/internal/service/run_dispatch.go` | SURFACE_OBS-1 |
|
||||
| `apps/edge/internal/service/run_dispatch_internal_test.go` | SURFACE_OBS-1 |
|
||||
| `apps/edge/internal/openai/chat_handler.go` | SURFACE_OBS-2 |
|
||||
| `apps/edge/internal/openai/responses_handler.go` | SURFACE_OBS-2 |
|
||||
| `apps/edge/internal/openai/stream.go` | SURFACE_OBS-2 |
|
||||
| `apps/edge/internal/openai/server_test.go` | SURFACE_OBS-2, SURFACE_OBS-3 |
|
||||
| `apps/edge/internal/openai/usage_metrics.go` | SURFACE_OBS-3 |
|
||||
| `apps/edge/internal/openai/usage_metrics_test.go` | SURFACE_OBS-3 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1
|
||||
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
|
||||
```
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -49,17 +49,20 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||
for k, v := range principalMetadata(r.Context()) {
|
||||
runMeta[k] = v
|
||||
}
|
||||
responseMode, err := parseResponseMode(runMeta)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
dispatch, ok := s.resolveRouteDispatch(req.Model)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
|
||||
return
|
||||
}
|
||||
if dispatch.ProviderPool && responseModeWasExplicit(runMeta) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", "metadata.iop_response_mode is not supported for model group routes")
|
||||
return
|
||||
}
|
||||
responseMode, err := parseResponseMode(runMeta)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateWorkspaceForRoute(dispatch, workspace); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||
for k, v := range principalMetadata(r.Context()) {
|
||||
runMeta[k] = v
|
||||
}
|
||||
if dispatch.ProviderPool && responseModeWasExplicit(runMeta) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", "metadata.iop_response_mode is not supported for model group routes")
|
||||
return
|
||||
}
|
||||
responseMode, err := parseResponseMode(runMeta)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
|
|
|
|||
|
|
@ -4503,7 +4503,7 @@ func TestChatCompletionsStrictOutputProviderPoolDefaultKeepsPassthroughPath(t *t
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseModeRouting(t *testing.T) {
|
||||
func TestChatCompletionsProviderPoolRejectsResponseMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metadata string
|
||||
|
|
@ -4513,28 +4513,28 @@ func TestChatCompletionsResponseModeRouting(t *testing.T) {
|
|||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "explicit passthrough uses tunnel",
|
||||
metadata: `"metadata":{"iop_response_mode":"passthrough"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantTunnel: true,
|
||||
name: "explicit passthrough is rejected",
|
||||
metadata: `"metadata":{"iop_response_mode":"passthrough"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantMessage: "metadata.iop_response_mode is not supported for model group routes",
|
||||
},
|
||||
{
|
||||
name: "sideband mode uses tunnel with IOP extension surface",
|
||||
metadata: `"metadata":{"iop_response_mode":"passthrough+sideband"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantTunnel: true,
|
||||
name: "sideband mode is rejected",
|
||||
metadata: `"metadata":{"iop_response_mode":"passthrough+sideband"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantMessage: "metadata.iop_response_mode is not supported for model group routes",
|
||||
},
|
||||
{
|
||||
name: "transformed mode is rejected for provider route",
|
||||
name: "transformed mode is rejected",
|
||||
metadata: `"metadata":{"iop_response_mode":"transformed"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantMessage: "metadata.iop_response_mode=transformed is not supported for OpenAI-compatible provider model groups",
|
||||
wantMessage: "metadata.iop_response_mode is not supported for model group routes",
|
||||
},
|
||||
{
|
||||
name: "unknown mode fails fast",
|
||||
name: "unknown mode is rejected before dispatch",
|
||||
metadata: `"metadata":{"iop_response_mode":"rawish"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantMessage: "metadata.iop_response_mode must be one of passthrough, passthrough+sideband, or transformed",
|
||||
wantMessage: "metadata.iop_response_mode is not supported for model group routes",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
|
@ -4581,20 +4581,25 @@ func TestChatCompletionsResponseModeRouting(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// chatSidebandServer returns an openai.Server backed by the given tunnel
|
||||
// frames for explicit passthrough+sideband fixtures.
|
||||
// chatSidebandServer returns a direct OpenAI-compatible provider route backed by
|
||||
// the given tunnel frames for explicit passthrough+sideband fixtures. It avoids
|
||||
// provider-pool because model group routes reject caller response mode selectors.
|
||||
func chatSidebandServer(frames chan *iop.ProviderTunnelFrame) (*Server, *fakeRunService) {
|
||||
fake := &fakeRunService{
|
||||
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
||||
tunnelFrames: frames,
|
||||
tunnelFrames: frames,
|
||||
}
|
||||
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
||||
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
||||
})
|
||||
srv := chatProviderRouteServer(fake, nil)
|
||||
return srv, fake
|
||||
}
|
||||
|
||||
func chatProviderRouteServer(fake *fakeRunService, logger *zap.Logger) *Server {
|
||||
return NewServer(config.EdgeOpenAIConf{
|
||||
ModelRoutes: []config.OpenAIRouteEntry{
|
||||
{Model: "pool-model", Adapter: "openai_compat", Target: "served-model"},
|
||||
},
|
||||
}, fake, logger)
|
||||
}
|
||||
|
||||
// TestChatCompletionsPassthroughSidebandStreamingExposesIOPExtension verifies
|
||||
// SDD S05 for the streaming path: explicit passthrough+sideband preserves the
|
||||
// provider SSE events contiguously and exposes IOP route/usage/assembled
|
||||
|
|
@ -5262,8 +5267,7 @@ func TestChatCompletionsPassthroughProviderPoolGenerationPolicy(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"pool-model",
|
||||
"messages":[{"role":"user","content":"hello"}],
|
||||
"max_completion_tokens":20,
|
||||
"metadata":{"iop_response_mode":"passthrough"}
|
||||
"max_completion_tokens":20
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
|
@ -5842,10 +5846,7 @@ func TestChatCompletionsPassthroughSidebandStreamWriteFailureSendsCancelRunOnce(
|
|||
// exactly one CancelRun while the tunnel is still in flight.
|
||||
func TestChatCompletionsPassthroughSidebandStreamContextCancelSendsCancelRun(t *testing.T) {
|
||||
fake := &fakeRunService{tunnelFrames: make(chan *iop.ProviderTunnelFrame)}
|
||||
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
||||
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
||||
})
|
||||
srv := chatProviderRouteServer(fake, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
|
@ -5872,10 +5873,7 @@ func TestChatCompletionsPassthroughSidebandStreamContextCancelSendsCancelRun(t *
|
|||
// exactly one CancelRun while the tunnel is still in flight.
|
||||
func TestChatCompletionsPassthroughSidebandNonStreamingContextCancelSendsCancelRun(t *testing.T) {
|
||||
fake := &fakeRunService{tunnelFrames: make(chan *iop.ProviderTunnelFrame)}
|
||||
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
||||
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
||||
})
|
||||
srv := chatProviderRouteServer(fake, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
|
@ -5904,10 +5902,7 @@ func TestChatCompletionsPassthroughSidebandNonStreamingTimeoutSendsCancelRun(t *
|
|||
tunnelFrames: make(chan *iop.ProviderTunnelFrame),
|
||||
tunnelWaitTimeout: 50 * time.Millisecond,
|
||||
}
|
||||
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
||||
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
||||
})
|
||||
srv := chatProviderRouteServer(fake, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"pool-model",
|
||||
|
|
@ -6114,6 +6109,19 @@ func responsesProviderTunnelServer(frames chan *iop.ProviderTunnelFrame, servedT
|
|||
return srv, fake
|
||||
}
|
||||
|
||||
func responsesLegacyProviderTunnelServer(frames chan *iop.ProviderTunnelFrame, servedTarget string) (*Server, *fakeRunService) {
|
||||
fake := &fakeRunService{
|
||||
tunnelFrames: frames,
|
||||
tunnelServedTarget: servedTarget,
|
||||
}
|
||||
srv := NewServer(config.EdgeOpenAIConf{
|
||||
ModelRoutes: []config.OpenAIRouteEntry{
|
||||
{Model: "pool-model", Adapter: "openai_compat", Target: "served-model"},
|
||||
},
|
||||
}, fake, nil)
|
||||
return srv, fake
|
||||
}
|
||||
|
||||
// TestResponsesProviderPoolDispatch verifies that /v1/responses sends
|
||||
// provider-pool models through the raw provider tunnel (POST /v1/responses)
|
||||
// instead of the normalized RunEvent path.
|
||||
|
|
@ -6441,10 +6449,7 @@ func TestResponsesProviderTunnelStreaming(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestResponsesProviderTunnelResponseModeRouting documents the Responses
|
||||
// provider boundary: passthrough and passthrough+sideband use the raw provider
|
||||
// tunnel, transformed is rejected, and unknown modes fail fast.
|
||||
func TestResponsesProviderTunnelResponseModeRouting(t *testing.T) {
|
||||
func TestResponsesProviderPoolRejectsResponseMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metadata string
|
||||
|
|
@ -6453,28 +6458,28 @@ func TestResponsesProviderTunnelResponseModeRouting(t *testing.T) {
|
|||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "explicit passthrough uses responses tunnel",
|
||||
metadata: `"metadata":{"iop_response_mode":"passthrough"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantTunnel: true,
|
||||
name: "explicit passthrough is rejected",
|
||||
metadata: `"metadata":{"iop_response_mode":"passthrough"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantMessage: "metadata.iop_response_mode is not supported for model group routes",
|
||||
},
|
||||
{
|
||||
name: "sideband mode uses responses tunnel",
|
||||
metadata: `"metadata":{"iop_response_mode":"passthrough+sideband"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantTunnel: true,
|
||||
name: "sideband mode is rejected",
|
||||
metadata: `"metadata":{"iop_response_mode":"passthrough+sideband"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantMessage: "metadata.iop_response_mode is not supported for model group routes",
|
||||
},
|
||||
{
|
||||
name: "transformed mode is rejected",
|
||||
metadata: `"metadata":{"iop_response_mode":"transformed"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantMessage: "metadata.iop_response_mode=transformed is not supported for /v1/responses provider routes",
|
||||
wantMessage: "metadata.iop_response_mode is not supported for model group routes",
|
||||
},
|
||||
{
|
||||
name: "unknown mode fails fast",
|
||||
name: "unknown mode is rejected before dispatch",
|
||||
metadata: `"metadata":{"iop_response_mode":"rawish"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantMessage: "metadata.iop_response_mode must be one of passthrough, passthrough+sideband, or transformed",
|
||||
wantMessage: "metadata.iop_response_mode is not supported for model group routes",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
|
@ -6507,7 +6512,7 @@ func TestResponsesProviderTunnelResponseModeRouting(t *testing.T) {
|
|||
|
||||
func TestResponsesProviderTunnelSidebandInjectsMetadata(t *testing.T) {
|
||||
frames := staticProviderTunnelFrames(`{"id":"resp-1","object":"response","output_text":"hi","metadata":{"request_id":"req-1"}}`)
|
||||
srv, fake := responsesProviderTunnelServer(frames, "served-model")
|
||||
srv, fake := responsesLegacyProviderTunnelServer(frames, "served-model")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
|
||||
"model":"pool-model",
|
||||
"input":"hi",
|
||||
|
|
@ -6563,7 +6568,7 @@ func TestResponsesProviderTunnelSidebandStreamingInjectsEvent(t *testing.T) {
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv, fake := responsesProviderTunnelServer(frames, "served-model")
|
||||
srv, fake := responsesLegacyProviderTunnelServer(frames, "served-model")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
|
||||
"model":"pool-model",
|
||||
"input":"hi",
|
||||
|
|
@ -7214,11 +7219,7 @@ func TestChatCompletionsAssembledLogsSidebandNonStreaming(t *testing.T) {
|
|||
close(frames)
|
||||
|
||||
fake := &fakeRunService{tunnelFrames: frames}
|
||||
catalog := []config.ModelCatalogEntry{
|
||||
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
||||
}
|
||||
srv := NewServer(config.EdgeOpenAIConf{}, fake, logger)
|
||||
srv.SetModelCatalog(catalog)
|
||||
srv := chatProviderRouteServer(fake, logger)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"pool-model",
|
||||
|
|
@ -7333,8 +7334,7 @@ func TestChatCompletionsAssembledLogsPassthrough(t *testing.T) {
|
|||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"pool-model",
|
||||
"messages":[{"role":"user","content":"hello"}],
|
||||
"metadata":{"iop_response_mode":"passthrough"}
|
||||
"messages":[{"role":"user","content":"hello"}]
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
|
@ -7395,11 +7395,7 @@ func TestChatCompletionsAssembledLogsSidebandStream(t *testing.T) {
|
|||
close(frames)
|
||||
|
||||
fake := &fakeRunService{tunnelFrames: frames}
|
||||
catalog := []config.ModelCatalogEntry{
|
||||
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
||||
}
|
||||
srv := NewServer(config.EdgeOpenAIConf{}, fake, logger)
|
||||
srv.SetModelCatalog(catalog)
|
||||
srv := chatProviderRouteServer(fake, logger)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"pool-model",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ func principalTokenCfg(rawToken, adapter string) config.EdgeOpenAIConf {
|
|||
}
|
||||
}
|
||||
|
||||
func providerRouteUsageMetricServer(rawToken, edgeID, model, target string, fake *fakeRunService) *Server {
|
||||
conf := principalTokenCfg(rawToken, "ollama")
|
||||
conf.ModelRoutes = []config.OpenAIRouteEntry{
|
||||
{Model: model, Adapter: "openai_compat", Target: target},
|
||||
}
|
||||
srv := NewServer(conf, fake, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
return srv
|
||||
}
|
||||
|
||||
func requestTokenValue(t *testing.T, l usageLabels, tokenType string) float64 {
|
||||
t.Helper()
|
||||
return testutil.ToFloat64(openAIUsageTokensTotal.WithLabelValues(
|
||||
|
|
@ -345,13 +355,10 @@ func TestResponsesProviderTunnelSidebandObservesUsageMetrics(t *testing.T) {
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{
|
||||
PrincipalTokens: []config.OpenAIPrincipalTokenConf{
|
||||
{TokenRef: "iop-tok-alice", TokenHashSHA256: sha256Hex(rawToken), PrincipalRef: "user:alice", PrincipalAlias: "alice"},
|
||||
},
|
||||
}, &fakeRunService{tunnelFrames: frames, tunnelServedTarget: "served-model"}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(
|
||||
rawToken, edgeID, model, "served-model",
|
||||
&fakeRunService{tunnelFrames: frames, tunnelServedTarget: "served-model"},
|
||||
)
|
||||
|
||||
labels := usageLabels{
|
||||
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
|
||||
|
|
@ -548,9 +555,7 @@ data: [DONE]
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), &fakeRunService{tunnelFrames: frames}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", &fakeRunService{tunnelFrames: frames})
|
||||
|
||||
reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
|
||||
edgeID, "user:alice", "alice", "iop-tok-alice", model,
|
||||
|
|
@ -603,9 +608,7 @@ func TestProviderTunnelSidebandNonStreamingEmitsUsageMetrics(t *testing.T) {
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), &fakeRunService{tunnelFrames: frames}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", &fakeRunService{tunnelFrames: frames})
|
||||
|
||||
labels := usageLabels{
|
||||
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
|
||||
|
|
@ -664,9 +667,7 @@ func TestProviderTunnelSidebandStreamCallerCancelEmitsCancelMetric(t *testing.T)
|
|||
frames := make(chan *iop.ProviderTunnelFrame)
|
||||
|
||||
fake := &fakeRunService{tunnelFrames: frames, tunnelWaitTimeout: 3 * time.Second}
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), fake, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", fake)
|
||||
|
||||
reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
|
||||
edgeID, "user:alice", "alice", "iop-tok-alice", model,
|
||||
|
|
@ -870,9 +871,7 @@ func TestProviderTunnelSidebandStreamBodyWriteFailureEmitsCancelMetric(t *testin
|
|||
|
||||
// Use a writer that fails on the second write.
|
||||
fake := &fakeRunService{tunnelFrames: frames, tunnelWaitTimeout: 3 * time.Second}
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), fake, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served", fake)
|
||||
|
||||
cancelBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
|
||||
edgeID, "user:alice", "alice", "iop-tok-alice", model,
|
||||
|
|
@ -945,9 +944,7 @@ data: [DONE]
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), &fakeRunService{tunnelFrames: frames}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", &fakeRunService{tunnelFrames: frames})
|
||||
|
||||
labels := usageLabels{
|
||||
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
|
||||
|
|
@ -1016,9 +1013,7 @@ func TestProviderTunnelSidebandNonStreamingEmitsUsageMetricsBodyOnly(t *testing.
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), &fakeRunService{tunnelFrames: frames}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", &fakeRunService{tunnelFrames: frames})
|
||||
|
||||
labels := usageLabels{
|
||||
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
|
||||
|
|
@ -1093,9 +1088,7 @@ data: [DONE]
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), &fakeRunService{tunnelFrames: frames}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", &fakeRunService{tunnelFrames: frames})
|
||||
|
||||
labels := usageLabels{
|
||||
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
|
||||
|
|
@ -1164,9 +1157,7 @@ func TestProviderTunnelSidebandResponseProtoOnlyReasoningCachedInput(t *testing.
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), &fakeRunService{tunnelFrames: frames}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", &fakeRunService{tunnelFrames: frames})
|
||||
|
||||
labels := usageLabels{
|
||||
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
|
||||
|
|
@ -1242,9 +1233,7 @@ data: [DONE]
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), &fakeRunService{tunnelFrames: frames}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", &fakeRunService{tunnelFrames: frames})
|
||||
|
||||
labels := usageLabels{
|
||||
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
|
||||
|
|
@ -1315,9 +1304,7 @@ func TestProviderTunnelSidebandResponseBodyAndProtoNoDoubleCount(t *testing.T) {
|
|||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
|
||||
srv := NewServer(principalTokenCfg(rawToken, "ollama"), &fakeRunService{tunnelFrames: frames}, nil)
|
||||
srv.SetEdgeID(edgeID)
|
||||
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}})
|
||||
srv := providerRouteUsageMetricServer(rawToken, edgeID, model, "served-model", &fakeRunService{tunnelFrames: frames})
|
||||
|
||||
labels := usageLabels{
|
||||
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
주요 현재 동작:
|
||||
|
||||
- Chat Completions provider route의 기본 응답 mode는 provider-original `passthrough`다.
|
||||
- caller는 `metadata.iop_response_mode`에 `passthrough`, `passthrough+sideband`, `transformed` 중 하나를 넣어 응답 경로를 선택할 수 있다.
|
||||
- `passthrough+sideband`와 `transformed`는 IOP 확장/변환 응답이며 provider-original byte identity로 취급하지 않는다.
|
||||
- provider-pool model group route는 caller가 `metadata.iop_response_mode`를 명시하면 값과 무관하게 거부하고, 생략 시 provider-original `passthrough`로 동작한다.
|
||||
- direct legacy provider route는 `passthrough+sideband` extension selector를 지원한다. `passthrough+sideband`와 `transformed`는 IOP 확장/변환 응답이며 provider-original byte identity로 취급하지 않는다.
|
||||
- dev-corp `gemma4:26b` provider-pool에서 `think`/`stream`/`include_reasoning` 요청 파라미터만 바꿔 측정하는 범위는 계약 원문의 `dev-corp gemma4:26b provider-pool passthrough 파라미터 범위` 표를 기준으로 한다.
|
||||
- dev-corp `iop.ai.kr` 직접 호출 가이드는 [dev-corp-openai-compatible-call-guide.md](./dev-corp-openai-compatible-call-guide.md)를 기준으로 한다.
|
||||
- dev-corp Pi coding agent 설정 가이드는 [dev-corp-pi-settings-guide.md](./dev-corp-pi-settings-guide.md)를 기준으로 한다.
|
||||
|
|
|
|||
Loading…
Reference in a new issue