diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 20da65c..e62d98c 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -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 diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md b/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md index e862982..7ef10bb 100644 --- a/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md +++ b/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md @@ -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 실행 경로로 자동 결정한다. diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md index 896066f..62ff5ac 100644 --- a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md +++ b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md @@ -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 diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index f480128..e03dacb 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -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를 반영. diff --git a/agent-task/m-model-group-mixed-provider-dispatch/04_custom_field_preservation/CODE_REVIEW-local-G06.md b/agent-task/m-model-group-mixed-provider-dispatch/04_custom_field_preservation/CODE_REVIEW-local-G06.md new file mode 100644 index 0000000..e07b87e --- /dev/null +++ b/agent-task/m-model-group-mixed-provider-dispatch/04_custom_field_preservation/CODE_REVIEW-local-G06.md @@ -0,0 +1,113 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고하고 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-`이면 완료 이벤트 메타데이터를 보고하고 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. diff --git a/agent-task/m-model-group-mixed-provider-dispatch/04_custom_field_preservation/PLAN-local-G06.md b/agent-task/m-model-group-mixed-provider-dispatch/04_custom_field_preservation/PLAN-local-G06.md new file mode 100644 index 0000000..d5128c5 --- /dev/null +++ b/agent-task/m-model-group-mixed-provider-dispatch/04_custom_field_preservation/PLAN-local-G06.md @@ -0,0 +1,212 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-model-group-mixed-provider-dispatch/05_sideband_observation/CODE_REVIEW-local-G07.md b/agent-task/m-model-group-mixed-provider-dispatch/05_sideband_observation/CODE_REVIEW-local-G07.md new file mode 100644 index 0000000..a269bcb --- /dev/null +++ b/agent-task/m-model-group-mixed-provider-dispatch/05_sideband_observation/CODE_REVIEW-local-G07.md @@ -0,0 +1,111 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고하고 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. diff --git a/agent-task/m-model-group-mixed-provider-dispatch/05_sideband_observation/PLAN-local-G07.md b/agent-task/m-model-group-mixed-provider-dispatch/05_sideband_observation/PLAN-local-G07.md new file mode 100644 index 0000000..976bb80 --- /dev/null +++ b/agent-task/m-model-group-mixed-provider-dispatch/05_sideband_observation/PLAN-local-G07.md @@ -0,0 +1,232 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 7c802f1..5308e69 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -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 diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go index 3b0c90f..2b49908 100644 --- a/apps/edge/internal/openai/responses_handler.go +++ b/apps/edge/internal/openai/responses_handler.go @@ -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()) diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index d668f82..a00d4f1 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -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", diff --git a/apps/edge/internal/openai/usage_metrics_test.go b/apps/edge/internal/openai/usage_metrics_test.go index a9ce936..b58cc74 100644 --- a/apps/edge/internal/openai/usage_metrics_test.go +++ b/apps/edge/internal/openai/usage_metrics_test.go @@ -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", diff --git a/docs/openai-compatible-api-contract.md b/docs/openai-compatible-api-contract.md index 2ebda22..2f078d7 100644 --- a/docs/openai-compatible-api-contract.md +++ b/docs/openai-compatible-api-contract.md @@ -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)를 기준으로 한다.