feat: routing-policy-model-orchestration - responses passthrough & model group mixed provider dispatch

This commit is contained in:
toki 2026-07-11 12:42:16 +09:00
parent b38f978652
commit 12a26d4b4c
20 changed files with 2694 additions and 89 deletions

View file

@ -125,8 +125,10 @@ CLI agent 실행으로 라우팅되는 요청의 최소 형태:
현재 구현 메모:
- `/v1/responses`는 non-streaming 요청만 지원한다.
- OpenAI-compatible provider model group route(provider pool, `openai_compat`, `vllm`)의 `/v1/responses` 호출은 raw passthrough parity가 구현되기 전까지 `400 invalid_request_error`로 거부한다. 이 경로는 normalized `SubmitRun`으로 fallback하지 않는다.
- 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를 사용한다.
- `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에는 복사하지 않는다.
@ -202,11 +204,11 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa
OpenAI-compatible inference provider route는 요청 metadata의 `iop_response_mode`로 응답 경로를 고른다.
- `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도 붙이지 않는다.
- `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`를 함께 반환한다. 이 모드는 provider-original byte-identical response로 표시하지 않는다.
- 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`로 라벨링한다.
알 수 없는 `metadata.iop_response_mode` 값은 silent fallback 없이 `400 invalid_request_error`로 거부한다.
현재 Chat Completions provider route는 raw `passthrough``passthrough+sideband`만 지원한다. `/v1/responses` raw passthrough parity는 별도 구현/계약 갱신 대상이며, parity 전 provider model group `/v1/responses` 요청은 normalized path로 처리하지 않는다.
현재 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`를 확장 지점으로 사용한다.
Think 제어 field:
@ -229,7 +231,7 @@ Think 제어 field:
| 명시적 `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="transformed"` | provider model group route에서는 `400 invalid_request_error`로 거부한다. | dev-corp `gemma4:26b` provider-pool에서는 사용하지 않는다. |
| `/v1/responses` 호출 | provider model group raw passthrough parity 전까지 지원하지 않는다. | `gemma4:26b` provider-pool 외부 호출은 `/v1/chat/completions` 기준으로 측정한다. |
| `/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`를 쓴다. |
현재 구현에서 `think=false`를 “provider에는 기본 think를 유지하되 IOP가 응답에서 reasoning만 감추는 hide-only 모드”로 해석하지 않는다. 그런 동작이 필요하면 provider/vLLM 설정 변경이 아니라 Edge provider-pool passthrough 응답 filtering 정책을 별도 구현/계약 갱신해야 한다.

View file

@ -24,6 +24,10 @@ IOP의 OpenAI-compatible, A2A, IOP native 입력 표면에서 들어온 요청
- 경로: [seulgivibe-openai-compatible-provider](milestones/seulgivibe-openai-compatible-provider.md)
- 요약: Seulgivibe Claude/OpenAI 프록시를 OpenAI-compatible provider family로 관리하고, 정적 catalog, 요청 시점 provider token forwarding, Codex Responses passthrough를 구현한다.
- [계획] Model Group Mixed Provider Dispatch
- 경로: [model-group-mixed-provider-dispatch](milestones/model-group-mixed-provider-dispatch.md)
- 요약: model group provider pool에서 OpenAI-compatible wire provider와 Ollama/CLI 같은 normalized provider를 같은 후보군으로 두고, 기존 capacity+priority 선택 뒤 provider capability에 따라 raw passthrough 또는 normalized 실행 경로를 자동 결정한다.
- [스케치] OpenAI-compatible 하이브리드 라우팅과 컨텍스트 최적화
- 경로: [openai-compatible-hybrid-routing-context-optimization](milestones/openai-compatible-hybrid-routing-context-optimization.md)
- 요약: skill/예약어 기반 lane/grade 라우팅을 고신뢰 경로로 유지하고, DiffusionGemma 같은 로컬 모델을 자동 triage, negative guard, cloud context 최적화 보조, 주기적 scoring policy 학습 루프로 선택적으로 사용하는 hybrid local/cloud routing 방향을 스케치한다.

View file

@ -0,0 +1,107 @@
# Milestone: Model Group Mixed Provider Dispatch
## 위치
- Roadmap: [ROADMAP.md](../../../ROADMAP.md)
- Phase: [PHASE.md](../PHASE.md)
## 목표
Model group provider pool이 OpenAI-compatible wire provider와 native/normalized provider를 같은 후보군으로 다룰 수 있게 한다.
Edge는 기존 `capacity + priority` 기준으로 provider를 먼저 선택하고, 선택된 provider의 capability에 따라 OpenAI-compatible provider는 provider tunnel raw passthrough 라인으로, Ollama/CLI 같은 native provider는 normalized 실행 경로로 자동 dispatch한다.
Client 요청은 provider 실행 경로를 고르는 필드를 넣지 않으며, provider-specific/custom request field는 provider-pool route에서 먼저 보존한 뒤 선택된 실행 경로의 계약에 맞게 처리한다.
## 상태
[계획]
## 승격 조건
- 없음
## 구현 잠금
- 상태: 해제
- SDD: 필요
- SDD 문서: [SDD.md](../../../sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md)
- SDD 사유: Model group provider-pool dispatch, OpenAI-compatible request surface, Edge-Node runtime path, provider capability 계약이 함께 바뀌는 Milestone이다.
- 잠금 해제 조건:
- [x] SDD 잠금이 해제되어 있다
- [x] SDD 사용자 리뷰가 없거나 승인/해결되었다
- [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다
- [x] Evidence Map이 완료 시 `Roadmap Completion`과 최종 검증 evidence로 검증 가능하게 연결되어 있다
- 결정 필요: 없음
## 범위
- `models[]` provider-pool/model group에서 OpenAI-compatible provider와 normalized provider를 동일 후보로 평가하는 selection-first dispatch
- `capacity + priority` 기준의 기존 provider 선택 정책 유지
- provider capability 기반 실행 경로 분기
- provider type/label/capability가 `openai_compat`, `openai_api`, `vllm`, `vllm-mlx`, `lemonade`, `sglang`, `seulgivibe_*`, openweight cloud request model 계열인 provider: provider tunnel raw passthrough 라인과 sideband observation
- provider type/capability가 `ollama`, `cli` 및 OpenAI-compatible wire를 지원하지 않는 provider: normalized
- Seulgivibe Claude/OpenAI provider는 현재 구현 중인 Seulgivibe Milestone의 auth/catalog/Responses passthrough 범위를 유지하되, 이 Milestone의 classifier에서는 `seulgivibe_claude`, `seulgivibe_openai` 모두 OpenAI-compatible passthrough-capable line에 포함한다.
- Ollama provider를 model group에서 제외하지 않고, 작은 capacity/priority 설정으로 운영자가 후보 가중치를 조절하는 방식
- model group 요청에서 client-controlled `metadata.iop_response_mode` 또는 동등한 passthrough/normalized selector를 제거/거부하는 계약
- provider-pool Chat/Responses route의 raw request 보존과 provider-specific/custom field 처리
- 선택된 provider id/type/adapter/target/execution path를 sideband/usage/log observation에 남기는 기준
- 관련 `agent-contract`, `agent-spec`, config 예시, Edge/Node 테스트 갱신
## 기능
### Epic: [mixed-dispatch] Mixed Provider Dispatch
Model group이 provider 종류에 따라 normalized-only로 후퇴하지 않고, 선택된 provider 성격에 맞는 실행 경로로 dispatch되는 capability를 묶는다.
- [ ] [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 type/label/capability classifier가 OpenAI-compatible wire provider를 provider tunnel raw passthrough line으로, Ollama/CLI/native provider를 normalized로 분류한다. Seulgivibe aliases는 이 passthrough-capable line에 포함한다. 검증: `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 capability 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를 포함해 통과한다.
### Epic: [model-group-surface] Model Group Client Surface
Client가 OpenAI-compatible 표면으로 호출하되 model group 내부 실행 방식을 직접 고르지 않도록 계약과 handler를 정리한다.
- [ ] [no-client-response-mode] Model group route에서 `metadata.iop_response_mode` 또는 동등한 passthrough/normalized selector를 지원하지 않도록 계약, 구현, 테스트를 정리한다. 검증: [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md), [openai-compatible-surface.md](../../../../agent-spec/input/openai-compatible-surface.md), handler tests가 model group selector 거부/제거 기준과 일치한다.
- [ ] [custom-field-preservation] Provider-pool Chat route가 Responses route처럼 strict decode 전에 raw body와 routing envelope를 분리하고, passthrough-capable provider 선택 시 unknown/Codex/provider-specific fields를 model rewrite 외에는 보존한다. 검증: `go test ./apps/edge/internal/openai -count=1`이 Chat unknown field preservation과 normalized-provider 선택 시 supported field mapping/unsupported policy를 확인한다.
- [ ] [sideband-observation] Provider-pool 실행 결과가 selected provider, adapter, served target, execution path, queue decision, usage 후보를 sideband/log/metric에 남기며 표준 client 응답에는 불필요한 custom field를 섞지 않는다. 검증: `go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/internal/service -count=1`이 standard-client response와 IOP-aware observation fixture를 함께 확인한다.
### Epic: [contract-spec] Contract and Spec Sync
Model group mixed dispatch의 공개/내부 계약을 문서와 구현 타입에 맞춘다.
- [ ] [contract-sync] `agent-contract``agent-spec`가 model group의 provider-derived execution path, custom field 보존, client selector 금지, normalized/passthrough 경계를 같은 용어로 설명한다. 검증: contract/spec diff와 관련 Go tests가 SDD Evidence Map에 연결된다.
- [ ] [config-examples] provider-first config 예시가 mixed model group과 Ollama-only model group을 보여주되, Ollama는 capacity/priority로만 가중치를 조절한다. 검증: `go test ./packages/go/config -count=1`과 config fixture validation이 통과한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 기능 Task가 아직 충족되지 않았다.
- 검토 항목:
- [ ] `complete.log``Roadmap Completion`이 각 기능 Task id를 기록한다.
- [ ] 최종 검증 출력이 SDD Evidence Map과 일치한다.
- [ ] model group 요청에서 client-controlled response path selector가 남아 있지 않다.
- [ ] mixed group에서 Ollama가 후보에서 제외되지 않고 normalized path로만 실행된다.
- agent-ui 상태 반영: 해당 없음
- 리뷰 코멘트: 없음
## 범위 제외
- provider 선택 기준을 `capacity + priority` 밖의 score policy로 확장하는 작업
- Ollama를 고동시성 기본 provider로 취급하거나 Ollama capacity를 자동 상향하는 작업
- client 요청이 model group 실행 경로를 직접 고르는 새 field/header/query parameter
- provider-specific endpoint credential, token source, auth forwarding 정책 추가
- provider response payload를 모든 provider에 대해 byte-identical하게 강제하는 작업
- cloud fallback, route scorer, policy learning loop 구현
## 작업 컨텍스트
- 관련 경로: `packages/go/config`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/internal/node`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/adapters/ollama`, `apps/node/internal/runtime`, `proto/iop/runtime.proto`, [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md), [edge-node-runtime-wire.md](../../../../agent-contract/inner/edge-node-runtime-wire.md), [edge-config-runtime-refresh.md](../../../../agent-contract/inner/edge-config-runtime-refresh.md)
- 표준선(선택): Model group은 normalized provider만 묶는 추상화가 아니다. OpenAI-compatible wire provider와 native provider를 같은 candidate set에 두고, 선택된 provider가 실행 경로를 결정한다.
- 표준선(선택): OpenAI-compatible wire를 지원하는 openweight cloud provider, Seulgivibe Claude/OpenAI provider, 로컬 vLLM/vLLM-MLX/Lemonade/SGLang 계열은 provider tunnel raw passthrough 라인에 속한다. sideband는 내부 observation 또는 문서화된 extension-safe 지점으로만 다루며, client 요청 selector가 아니다.
- 표준선(선택): Ollama와 CLI처럼 OpenAI-compatible wire를 지원하지 않는 provider는 model group 안에서도 normalized로 실행한다. Ollama는 제외하지 않으며 운영자는 capacity/priority로 낮은 동시성을 표현한다.
- 표준선(선택): Model group client request에는 `passthrough`, `passthrough+sideband`, `normalized`, `transformed` 같은 실행 경로 selector를 넣지 않는다.
- 표준선(선택): 표준 OpenAI-compatible client 요청은 표준-compatible 응답을 받고, IOP-aware/custom 요청은 문서화된 extension-safe 지점에서만 sideband/custom observation을 볼 수 있다.
- 우선순위/정합성: 현재 active 흐름에서는 [OpenAI-compatible 출력 검증 필터](../../knowledge-tool-optimization-extension/milestones/openai-compatible-output-validation-filters.md)와 [Seulgivibe OpenAI-compatible Provider 연동](seulgivibe-openai-compatible-provider.md)을 함께 고려한다. Seulgivibe 구현은 이 Milestone의 passthrough-capable provider line과 충돌하지 않아야 하며, mixed provider dispatch 자체는 본 Milestone이 소유한다.
- 선행 작업: [OpenAI-compatible Raw Tunnel과 Sideband Passthrough](../../../archive/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md), [Model Alias Provider Pool과 Provider Catalog](../../operational-observability-provider-management/milestones/provider-catalog-device-status.md)
- 후속 작업: OpenAI-compatible 하이브리드 라우팅과 컨텍스트 최적화, route scorer, policy learning loop
- 확인 필요: 없음

View file

@ -40,6 +40,8 @@ Codex `wire_api=responses` 경로가 provider tunnel을 통해 동작하도록 `
- OpenAI/Codex 모델 `gpt-5.1`, `gpt-5.5` 정적 model catalog
- 사용자별 raw token을 inbound request header에서 읽어 provider tunnel `Authorization` header로 전달하는 경계
- Chat Completions provider tunnel과 Responses provider tunnel의 Seulgivibe passthrough 지원
- Seulgivibe provider가 model group에 참여할 때 `seulgivibe_claude`, `seulgivibe_openai`를 OpenAI-compatible passthrough-capable provider line으로 유지하는 기준
- model group request에서 client-controlled `metadata.iop_response_mode` 또는 동등한 passthrough/normalized selector를 받지 않는 기준
## 기능
@ -49,7 +51,7 @@ Seulgivibe를 IOP 내부에서는 provider-first OpenAI-compatible resource로
- [ ] [config-auth-catalog] Seulgivibe Claude/OpenAI provider aliases, `openai.provider_auth` schema, 정적 model catalog/계약 예시가 추가되어 있다. 검증: `go test ./packages/go/config -count=1`, `go test ./apps/edge/internal/node -count=1`, secret pattern scan이 통과한다.
- [ ] [provider-token-tunnel] Edge OpenAI Chat Completions provider tunnel이 configured request header의 raw user token을 provider `Authorization` header로 전달하고 missing-required를 dispatch 전에 차단한다. 검증: `go test ./apps/edge/internal/openai -count=1`이 auth forwarding/missing tests를 포함해 통과한다.
- [ ] [responses-passthrough] OpenAI-compatible provider route에서 `/v1/responses` raw passthrough가 동작하고 Codex-style unknown fields, streaming, provider auth, model rewrite를 보존/검증한다. 검증: `go test ./apps/edge/internal/openai -count=1`이 Responses passthrough tests를 포함해 통과한다.
- [ ] [responses-passthrough] OpenAI-compatible provider route에서 `/v1/responses` raw passthrough가 동작하고 Codex-style unknown fields, streaming, provider auth, model rewrite를 보존/검증한다. model group request는 client-controlled response mode selector를 받지 않는다. 검증: `go test ./apps/edge/internal/openai -count=1`이 Responses passthrough와 selector rejection tests를 포함해 통과한다.
## 완료 리뷰
@ -60,6 +62,7 @@ Seulgivibe를 IOP 내부에서는 provider-first OpenAI-compatible resource로
- [ ] 세 subtask의 `complete.log`가 각 Roadmap Completion task id를 기록한다.
- [ ] 최종 검증 출력이 SDD Evidence Map과 일치한다.
- [ ] 실제 token 값이 tracked 문서/config/test output에 남지 않았다.
- [ ] Seulgivibe model group 요청에서 client-controlled response path selector가 남아 있지 않다.
- agent-ui 상태 반영: 해당 없음
- 리뷰 코멘트: 없음
@ -68,16 +71,17 @@ Seulgivibe를 IOP 내부에서는 provider-first OpenAI-compatible resource로
- host-local `~/.claude/anthropic_key.sh`, `~/.codex/config.toml`, Pi coding 설정 변경
- 실제 JWT/API key/token 값을 tracked config, docs, task artifact에 저장
- Seulgivibe `/v1/models` endpoint를 catalog source of truth로 사용하는 방식
- provider response payload의 model echo rewrite 또는 sideband injection을 Responses passthrough에 강제하는 작업
- provider response payload의 model echo rewrite 또는 sideband injection을 Responses 기본 passthrough에 강제하는 작업
- billing/chargeback, 조직 IAM, 장기 retention 정책
## 작업 컨텍스트
- 관련 경로: `packages/go/config`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/internal/node`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/runtime`, `proto/iop/runtime.proto`, [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md)
- 표준선(선택): Seulgivibe는 새 wire adapter가 아니라 OpenAI-compatible provider family로 관리하고, provider별 특수 처리는 generation passthrough 밖의 auth/catalog/config 경계에만 둔다.
- 표준선(선택): Seulgivibe Claude/OpenAI provider aliases는 [Model Group Mixed Provider Dispatch](model-group-mixed-provider-dispatch.md)의 passthrough-capable provider line에 포함된다. mixed provider dispatch의 일반 selection/path 분기는 해당 Milestone이 소유하고, 본 Milestone은 Seulgivibe auth/catalog/Responses raw passthrough를 소유한다.
- 표준선(선택): 사용자별 provider token은 request-time raw value로만 받고, Edge가 provider tunnel request header로 변환한다. Node나 host-local helper script가 사용자 token source of truth가 되지 않는다.
- 표준선(선택): provider `/models` endpoint가 실패해도 IOP `/v1/models`는 top-level `models[]` catalog를 source of truth로 노출한다.
- 우선순위 순서: [OpenAI-compatible 출력 검증 필터](../../knowledge-tool-optimization-extension/milestones/openai-compatible-output-validation-filters.md) 완료 후 본 Milestone을 진행한다.
- 선행 작업: [OpenAI-compatible Raw Tunnel과 Sideband Passthrough](../../../archive/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md), [Model Alias Provider Pool과 Provider Catalog](../../operational-observability-provider-management/milestones/provider-catalog-device-status.md)
- 후속 작업: 자동 route scorer 구현, provider auth per-provider granularity, Seulgivibe live smoke profile 정리
- 후속 작업: [Model Group Mixed Provider Dispatch](model-group-mixed-provider-dispatch.md), 자동 route scorer 구현, provider auth per-provider granularity, Seulgivibe live smoke profile 정리
- 확인 필요: 없음

View file

@ -0,0 +1,139 @@
# SDD: Model Group Mixed Provider Dispatch
## 위치
- Milestone: [Model Group Mixed Provider Dispatch](../../../phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
- Phase: [PHASE.md](../../../phase/routing-policy-model-orchestration/PHASE.md)
## 상태
[승인됨]
## SDD 잠금
- 상태: 해제
- 사용자 리뷰: 없음
- 잠금 항목:
- 없음
## 문제 / 비목표
- 문제: 현재 provider-pool/model group route는 provider pool이면 raw tunnel을 먼저 가정하는 경향이 있어, 같은 model group 안에 Ollama 같은 normalized-only provider가 들어오면 선택된 provider capability와 실행 경로가 어긋날 수 있다. 반대로 model group 전체를 normalized로 낮추면 vLLM/vLLM-MLX/Lemonade/openweight cloud provider의 provider-original passthrough 장점이 사라진다. Model group은 provider 후보를 동일하게 평가하되, 선택된 provider 성격에 따라 실행 경로를 자동 결정해야 한다.
- 비목표:
- provider 선택 기준을 `capacity + priority` 밖의 score policy로 바꾼다.
- Ollama를 model group에서 제외한다.
- Ollama를 고동시성 기본 provider로 취급한다.
- client 요청 field로 passthrough/normalized/transformed 경로를 선택하게 한다.
- 이 Milestone에서 cloud fallback, route learning loop, provider auth 정책을 추가한다.
## Source of Truth
| 영역 | 기준 | 메모 |
|------|------|------|
| Roadmap | [Model Group Mixed Provider Dispatch](../../../phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md) | 목표, 기능 Task, 범위 제외 기준 |
| Contract | [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md), [edge-node-runtime-wire.md](../../../../agent-contract/inner/edge-node-runtime-wire.md), [edge-config-runtime-refresh.md](../../../../agent-contract/inner/edge-config-runtime-refresh.md) | 외부 OpenAI-compatible model group surface, provider tunnel, normalized run dispatch, provider config 계약 |
| Spec | [openai-compatible-surface.md](../../../../agent-spec/input/openai-compatible-surface.md), [provider-pool-config-refresh.md](../../../../agent-spec/runtime/provider-pool-config-refresh.md), [edge-node-execution.md](../../../../agent-spec/runtime/edge-node-execution.md) | 현재 구현 surface와 runtime behavior 문서 |
| Code | `packages/go/config`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/internal/node`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/adapters/ollama`, `apps/node/internal/runtime`, `proto/iop/runtime.proto` | config validation, provider selection, OpenAI handler, Edge-Node wire, adapter capability 구현 기준 |
| User Decision | 현재 사용자 요청 | Ollama는 model group에서 제외하지 않는다. 후보는 동일하게 두고 capacity/priority로 가중한다. model group request에는 response path selector를 두지 않는다. OpenAI-compatible wire provider는 provider tunnel raw passthrough line으로, Ollama/CLI/native provider는 normalized로 처리한다. Seulgivibe Claude/OpenAI aliases는 passthrough-capable line에 포함한다. custom request fields는 provider-pool ingress에서 보존한다. |
## State Machine
| 상태 | 진입 조건 | 다음 상태 | 근거 |
|------|-----------|-----------|------|
| `ingress` | OpenAI-compatible Chat 또는 Responses request가 `models[]` catalog의 model group id를 사용한다 | `route-envelope-parsed` 또는 `invalid-request` | Edge OpenAI handler |
| `route-envelope-parsed` | raw body는 보존하고 routing에 필요한 `model`, `metadata`, `stream` 등 최소 envelope를 읽었다 | `candidate-selection` | provider-pool route |
| `invalid-request` | model group request가 `metadata.iop_response_mode` 또는 동등한 execution path selector를 포함하거나 필수 routing field가 없다 | terminal error | model group client surface contract |
| `candidate-selection` | model group의 provider mapping과 connected node status가 있다 | `provider-selected` 또는 `no-capacity` | existing capacity/priority/health rules |
| `no-capacity` | 사용 가능한 provider candidate가 없다 | terminal error | queue policy |
| `provider-selected` | queue slot이 특정 provider id/node/adapter/served target에 할당되었다 | `execution-path-resolved` | selected provider capability |
| `execution-path-resolved` | selected provider가 provider tunnel을 지원한다 | `passthrough-dispatch` | OpenAI-compatible wire provider |
| `execution-path-resolved` | selected provider가 normalized-only다 | `normalized-dispatch` | Ollama/CLI/native provider |
| `passthrough-dispatch` | raw request body를 model rewrite와 provider auth/header 정책만 적용해 Node tunnel로 보낸다 | `provider-response-relayed` 또는 `provider-error` | `ProviderTunnelRequest` |
| `normalized-dispatch` | standard OpenAI-compatible request subset을 internal `RunRequest`로 변환해 selected adapter로 보낸다 | `normalized-response-built` 또는 `provider-error` | `RunRequest`/adapter `Execute` |
| `provider-response-relayed` | provider tunnel frames가 caller response로 relay되고 sideband/log/metric observation이 기록된다 | terminal success | passthrough bridge |
| `normalized-response-built` | normalized runtime events가 OpenAI-compatible response shape로 변환되고 sideband/log/metric observation이 기록된다 | terminal success | normalized response builder |
| `provider-error` | provider HTTP/tunnel 또는 adapter execution이 실패한다 | terminal error | OpenAI-compatible error mapping |
## Interface Contract
- 계약 원문: [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md)
- 입력:
- `model`: caller-facing model group id다. `models[]` catalog와 provider mapping이 source of truth다.
- `metadata.workspace`, `metadata.task_id` 등 routing/operation metadata는 기존 OpenAI-compatible surface를 따른다.
- `metadata.iop_response_mode` 또는 같은 의미의 execution path selector는 model group route에서 지원하지 않는다. 포함 시 silent fallback 없이 `400 invalid_request_error`로 거부한다.
- unknown/Codex/provider-specific request fields는 provider-pool ingress에서 strict decode로 버리거나 거부하지 않는다.
- `nodes[].providers[].capacity`, `priority`, `enabled`, health/status, model membership은 기존 provider selection 기준이다.
- 실행 경로:
- OpenAI-compatible wire provider는 Node adapter가 `ProviderTunnelAdapter` capability를 제공해야 하며, model group에서는 provider tunnel raw passthrough 실행 경로를 사용한다.
- `ollama`, `cli` 및 OpenAI-compatible wire를 지원하지 않는 native provider는 normalized `RunRequest` 실행 경로를 사용한다.
- provider type/label/capability가 `openai_compat`, `openai_api`, `vllm`, `vllm-mlx`, `lemonade`, `sglang`, `seulgivibe_claude`, `seulgivibe_openai` 계열이면 tunnel-capable provider로 분류한다.
- provider type/capability가 `ollama`, `cli`이면 normalized-only provider로 분류한다.
- Seulgivibe Claude/OpenAI provider는 현재 Seulgivibe Milestone에서 다루는 auth/catalog/Responses passthrough 구현을 유지하면서도, 이 classifier에서는 OpenAI-compatible passthrough-capable line에 포함된다.
- selection에서 provider type만을 이유로 Ollama를 제외하지 않는다. 운영자는 작은 capacity/priority로 Ollama 동시성 한계를 표현한다.
- 출력:
- 표준 OpenAI-compatible client 요청에는 표준-compatible response를 반환하고 불필요한 IOP custom field/event를 섞지 않는다.
- passthrough-capable provider 선택 시 provider HTTP status/header/body 또는 provider SSE를 최대한 보존하며, model rewrite와 문서화된 IOP extension-safe 지점만 예외다.
- normalized provider 선택 시 runtime event를 OpenAI-compatible Chat/Responses response shape로 변환한다.
- 모든 실행 경로는 내부 observation으로 selected provider id/type, adapter, served target, execution path, queue decision, usage 후보를 남긴다.
- IOP-aware/custom request surface가 문서화된 extension-safe 지점을 사용하면 sideband/custom observation을 받을 수 있다. 표준-only request에는 응답 custom field를 만들지 않는다.
- custom field 처리:
- passthrough-capable provider가 선택되면 raw body의 unknown/Codex/provider-specific field는 model rewrite와 명시 변환 외에는 provider로 보존 전달한다.
- normalized-only provider가 선택되면 standard OpenAI-compatible subset과 IOP가 아는 extension만 native adapter request로 변환한다. 변환할 수 없는 provider-specific required field는 dispatch 전 명시적 unsupported error로 끝내고, 단순 unknown field는 observation에 남기되 native provider request에 임의로 invent하지 않는다.
- 금지:
- model group 전체를 normalized로 낮춰 vLLM/vLLM-MLX/Lemonade/openweight cloud provider의 raw passthrough 경로를 잃지 않는다.
- provider pool이라는 이유만으로 Ollama/CLI/native provider에 `ProviderTunnelRequest`를 보내지 않는다.
- client가 `metadata.iop_response_mode`로 model group의 passthrough/normalized/transformed 경로를 고르게 하지 않는다.
- provider selection을 두 번 수행해 queue slot과 실제 실행 provider가 달라지게 하지 않는다.
## Acceptance Scenarios
| ID | Milestone Task | Given | When | Then |
|----|----------------|-------|------|------|
| S01 | `selection-first-path` | mixed model group에 vLLM provider와 Ollama provider가 있고 둘 다 healthy/capacity가 있다 | provider-pool route를 실행한다 | 기존 capacity/priority 기준으로 provider를 한 번 선택하고, 같은 selected provider로 tunnel 또는 normalized 실행을 수행한다 |
| S02 | `selection-first-path` | selected provider가 Ollama다 | Chat Completions request를 처리한다 | Edge가 `ProviderTunnelRequest`를 보내지 않고 normalized `RunRequest`로 Ollama adapter를 실행한다 |
| S03 | `provider-path-classifier` | provider type/label/capability가 `vllm`, `vllm-mlx`, `lemonade`, `openai_compat`, `seulgivibe_claude`, `seulgivibe_openai` 중 하나다 | execution path를 resolve한다 | provider tunnel capable로 분류하고 raw passthrough path를 선택한다 |
| S04 | `provider-path-classifier` | provider type/capability가 `ollama` 또는 `cli`다 | execution path를 resolve한다 | normalized-only로 분류하고 model group 후보에서는 제외하지 않는다 |
| S05 | `mixed-model-group` | Ollama-only model group config가 있다 | OpenAI-compatible Chat request를 보낸다 | provider-pool route가 정상 후보를 찾고 normalized OpenAI-compatible response를 반환한다 |
| S06 | `no-client-response-mode` | model group request가 `metadata.iop_response_mode`를 포함한다 | Edge handler가 route envelope를 parse한다 | request는 `400 invalid_request_error`로 거부되고 provider dispatch가 발생하지 않는다 |
| S07 | `custom-field-preservation` | Chat request에 Codex/provider-specific unknown field가 있고 selected provider가 tunnel-capable이다 | provider tunnel request body를 만든다 | unknown field가 model rewrite 외에는 그대로 provider로 전달된다 |
| S08 | `custom-field-preservation` | 같은 unknown field request에서 selected provider가 Ollama다 | normalized request를 만든다 | standard field는 Ollama request로 변환되고, 변환 불가능한 required field는 explicit unsupported error 또는 observation으로 처리된다 |
| S09 | `sideband-observation` | 표준 OpenAI-compatible client가 model group을 호출한다 | provider가 성공 응답을 반환한다 | caller response에는 불필요한 IOP custom field가 없고, internal log/metric에는 selected provider와 execution path가 남는다 |
| S10 | `contract-sync` | contract/spec 문서를 검토한다 | Milestone 구현 diff가 준비된다 | model group의 provider-derived execution path, selector 금지, custom field 보존 기준이 문서와 구현에 동일하게 반영되어 있다 |
## Evidence Map
| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 |
|----------|-------------------|------------------|---------------------------|
| S01 | service queue/selection unit tests | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``selection-first-path``go test ./apps/edge/internal/service -count=1` 결과 |
| S02 | Edge service/openai handler tests proving no tunnel for Ollama | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``selection-first-path`와 Ollama normalized dispatch assertion |
| S03 | provider classifier tests for OpenAI-compatible aliases | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``provider-path-classifier`, config/node/adapters test 결과 |
| S04 | provider classifier tests for Ollama/CLI | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``provider-path-classifier`, normalized-only provider candidate assertion |
| S05 | mixed and Ollama-only model group fixtures | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``mixed-model-group`, `go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/internal/service -count=1` 결과 |
| S06 | handler rejection tests and contract/spec diff | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``no-client-response-mode`, 400 invalid_request_error assertion |
| S07 | Chat raw body preservation tunnel test | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``custom-field-preservation`, provider request body fixture |
| S08 | normalized custom field policy test | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``custom-field-preservation`, unsupported/observation assertion |
| S09 | standard response and observation tests | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``sideband-observation`, response body/log/metric fixture |
| S10 | contract/spec sync review | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion``contract-sync`, linked contract/spec paths and final test output |
## Cross-repo Dependencies
- 없음
## Drift Check
- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다.
- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다.
- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다.
- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다.
## 사용자 리뷰 이력
- 없음
## 작업 컨텍스트
- 표준선: Model group provider 후보는 provider type 때문에 제외하지 않고 기존 capacity/priority/health/model membership으로 선택한다.
- 표준선: 선택된 provider가 OpenAI-compatible wire를 지원하면 raw tunnel 기반 passthrough path를 사용하고, Ollama/CLI/native provider면 normalized path를 사용한다. Seulgivibe Claude/OpenAI aliases는 이 passthrough-capable line에 포함한다.
- 표준선: model group client request는 provider execution path를 지정하지 않는다. `metadata.iop_response_mode`는 model group route에서 제거/거부 대상이다.
- 표준선: Chat provider-pool ingress는 Responses provider-pool ingress처럼 raw body와 route envelope를 분리해 custom/provider-specific field를 보존할 준비를 한다.
- 후속 SDD: 없음

View file

@ -33,7 +33,7 @@
| Contract | [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md) | 외부 OpenAI-compatible request/response, model list, provider auth header 계약 |
| Code | `packages/go/config`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/internal/node`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/runtime`, `proto/iop/runtime.proto` | config schema, route dispatch, provider tunnel, Node adapter header relay 구현 기준 |
| External Provider | Seulgivibe Claude/OpenAI proxy | Claude path와 OpenAI path 모두 OpenAI-compatible provider로 취급한다. Catalog는 IOP static config가 source of truth다. |
| User Decision | 없음 | 세부 구현은 기존 provider-first/openai_compat/passthrough 표준선으로 확정 가능하다. |
| User Decision | 현재 사용자 요청 | 세부 구현은 기존 provider-first/openai_compat/passthrough 표준선으로 확정 가능하다. Seulgivibe Claude/OpenAI aliases는 model group mixed dispatch에서 OpenAI-compatible passthrough-capable provider line에 포함하며, model group request가 `metadata.iop_response_mode`로 실행 경로를 고르지 않는다. |
## State Machine
@ -63,12 +63,14 @@
- IOP `/v1/models`: static `models[]` catalog의 Seulgivibe model ids를 반환한다.
- Chat Completions provider tunnel: provider status/header/body bytes를 raw relay하고 provider auth header를 포함한다.
- Responses provider tunnel: `/v1/responses` raw body를 model rewrite 외에는 보존해 provider로 전달하고 raw response를 relay한다.
- Seulgivibe provider가 model group에 포함될 때 execution path는 provider-derived이며, Seulgivibe aliases는 OpenAI-compatible passthrough-capable line으로 분류된다.
- 금지:
- IOP runtime이 `~/.claude/anthropic_key.sh`, `~/.codex/config.toml`, local env helper를 token source로 읽지 않는다.
- 실제 token 값을 tracked config/docs/task artifact/log에 쓰지 않는다.
- inbound IOP auth `Authorization` header를 provider token source로 재사용하지 않는다.
- Seulgivibe `/v1/models` 실패를 IOP catalog 노출 실패로 연결하지 않는다.
- Responses passthrough body를 Chat Completions shape인 `max_tokens`로 변환하지 않는다.
- model group request에서 `metadata.iop_response_mode` 또는 동등한 passthrough/normalized selector로 Seulgivibe 실행 경로를 고르게 하지 않는다.
## Acceptance Scenarios
@ -78,6 +80,7 @@
| S02 | `config-auth-catalog` | `/v1/models` provider endpoint가 catalog source로 안정적이지 않다 | IOP model list와 provider pool validation을 수행한다 | static `models[]` catalog가 Claude/OpenAI model ids를 노출하고 provider served model membership을 검증한다 |
| S03 | `provider-token-tunnel` | caller가 configured provider auth header에 raw user token을 넣는다 | Chat Completions provider tunnel을 연다 | Edge가 provider `Authorization` header를 만들고 missing-required는 dispatch 전에 400으로 차단한다 |
| S04 | `responses-passthrough` | Codex-style `/v1/responses` payload가 provider-pool model id, unknown fields, `stream`을 포함할 수 있다 | Responses provider route가 실행된다 | Edge가 strict normalized parser를 우회해 raw body를 model rewrite만 적용한 뒤 `/v1/responses` provider tunnel로 전달한다 |
| S05 | `responses-passthrough` | Seulgivibe model group request가 `metadata.iop_response_mode` 또는 동등한 execution path selector를 포함한다 | Edge handler가 route envelope를 parse한다 | request는 `400 invalid_request_error`로 거부되고 provider dispatch가 발생하지 않는다 |
## Evidence Map
@ -87,6 +90,7 @@
| S02 | static catalog validation and secret scan | `agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog` | `Roadmap Completion``config-auth-catalog`와 secret pattern scan 결과 |
| S03 | Edge OpenAI handler auth forwarding/missing tests | `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth` | `Roadmap Completion``provider-token-tunnel``go test ./apps/edge/internal/openai -count=1` 결과 |
| S04 | Edge OpenAI Responses tunnel tests | `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough` | `Roadmap Completion``responses-passthrough``go test ./apps/edge/internal/openai -count=1` 결과 |
| S05 | Edge OpenAI handler selector rejection tests | `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough` | `Roadmap Completion``responses-passthrough``metadata.iop_response_mode` 거부 assertion |
## Cross-repo Dependencies
@ -107,5 +111,6 @@
- 표준선: Seulgivibe는 OpenAI-compatible provider family로 관리하고, generation request body는 provider tunnel passthrough를 우선한다.
- 표준선: 사용자별 provider token은 request-time header value이며 IOP config에는 token source와 forwarding rule만 둔다.
- 표준선: `/v1/responses` provider route는 provider-original passthrough를 우선하고, response body model echo rewrite나 sideband insertion은 하지 않는다.
- 표준선: `/v1/responses` provider route는 provider-original `passthrough`를 기본값으로 둔다. Seulgivibe model group request가 `metadata.iop_response_mode`로 response path를 선택하지 않는다. response body model echo rewrite는 하지 않는다.
- 표준선: Seulgivibe Claude/OpenAI aliases는 Model Group Mixed Provider Dispatch의 passthrough-capable provider line에 포함한다. mixed provider dispatch 일반화는 별도 Milestone이 소유한다.
- 후속 SDD: 없음

View file

@ -60,11 +60,12 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
| 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`를 선택할 수 있다. |
| provider raw passthrough | `passthrough`는 provider status/header/body bytes를 기존 Edge-Node tunnel로 relay하고 pure response body에 IOP sideband를 섞지 않는다. |
| sideband extension | `passthrough+sideband`는 provider body와 IOP route/usage/assembled observation을 명시적 extension stream/envelope로 함께 노출하며 provider-original byte identity로 표시하지 않는다. |
| 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로 표시하지 않는다. |
| 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 | `/v1/responses`는 현재 string input의 non-streaming 요청만 지원한다. |
| 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로 집계한다. |
| 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`으로 전파한다. |
@ -111,7 +112,7 @@ sequenceDiagram
- `configs/edge.yaml``openai` 섹션이 listener, bearer token, legacy adapter/target, model routes, strict output을 제공한다.
- top-level `models[]`가 있으면 OpenAI model list와 provider-pool dispatch에서 legacy route보다 우선한다.
- OpenAI request의 `metadata.workspace`는 absolute path가 필요한 route에서만 필수 검증된다.
- OpenAI Chat Completions request의 `metadata.iop_response_mode``passthrough`, `passthrough+sideband`, `transformed`만 허용한다. provider route에서 생략하면 `passthrough`다.
- OpenAI Chat Completions와 provider route Responses request의 `metadata.iop_response_mode``passthrough`, `passthrough+sideband`, `transformed`만 허용한다. provider route에서 생략하면 `passthrough`다. Responses provider 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로 만든다.
- Node complete event metadata의 `openai_tool_calls``openai_text_tool_fallback`은 response tool call 복원에 쓰인다.
@ -132,12 +133,13 @@ sequenceDiagram
## 한계와 주의사항
- `/v1/responses`현재 non-streaming string input만 지원한다.
- normalized(non-provider) `/v1/responses`는 non-streaming string input만 지원한다. provider model group route의 `/v1/responses`는 raw passthrough로 streaming과 Codex/unknown field를 그대로 provider에 전달한다.
- `/v1/completions`는 제공하지 않는다.
- 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`를 끼운다.
- text tool-call synthesis는 요청 `tools[]` schema를 기준으로만 수행한다. 자연어 추론으로 tool call을 만들지 않는다.
- private token이나 endpoint 원문은 tracked spec/docs에 남기지 않는다.
- `metadata.user`는 identity source가 아니며 사용되지 않는다.
@ -154,3 +156,5 @@ sequenceDiagram
- 2026-07-08: Chat Completions provider raw tunnel, response mode, sideband/transformed semantics를 현재 코드와 계약 기준으로 반영.
- 2026-07-10: principal token 기반 usage metering, Prometheus metric, Grafana query guide, reasoning/cached token breakdown을 Milestone completion evidence 기준으로 반영.
- 2026-07-10: 일별 Usage 비용/ROI 리포트 MVP 종료 검토에서 daily/monthly rollup, usage origin breakdown, cloud-equivalent cost, avoided-cost ROI 문서 표면을 반영.
- 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`를 확장 지점으로 사용한다.

View file

@ -43,43 +43,50 @@ task=m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough, pla
| 항목 | 완료 여부 |
|------|---------|
| [SEULGI_RESPONSES-1] Route before strict Responses normalization | [ ] |
| [SEULGI_RESPONSES-2] Responses raw tunnel submitter | [ ] |
| [SEULGI_RESPONSES-3] Replace old reject expectations | [ ] |
| [SEULGI_RESPONSES-1] Route before strict Responses normalization | [x] |
| [SEULGI_RESPONSES-2] Responses raw tunnel submitter | [x] |
| [SEULGI_RESPONSES-3] Replace old reject expectations | [x] |
## 구현 체크리스트
- [ ] [SEULGI_RESPONSES-1] `/v1/responses` handler를 provider route 판단 전 raw body 보존 구조로 바꾼다.
- [ ] [SEULGI_RESPONSES-2] provider route용 Responses raw tunnel submitter/model rewrite를 추가하고 provider auth header를 적용한다.
- [ ] [SEULGI_RESPONSES-3] 기존 reject tests를 passthrough tests로 갱신하고 raw body/stream/auth behavior를 검증한다.
- [ ] `go test ./apps/edge/internal/openai -count=1`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] [SEULGI_RESPONSES-1] `/v1/responses` handler를 provider route 판단 전 raw body 보존 구조로 바꾼다.
- [x] [SEULGI_RESPONSES-2] provider route용 Responses raw tunnel submitter/model rewrite를 추가하고 provider auth header를 적용한다.
- [x] [SEULGI_RESPONSES-3] 기존 reject tests를 passthrough tests로 갱신하고 raw body/stream/auth behavior를 검증한다.
- [x] `go test ./apps/edge/internal/openai -count=1`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/`를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 계획 `After` 스니펫은 handler 시작부에서 `runMeta`/workspace 검증을 provider/non-provider 공통으로 hoist하는 형태였으나, 정규 경로의 기존 error 순서와 metadata 파싱 시점을 그대로 보존하기 위해 provider tunnel 분기 안에서만 metadata 파싱·principal overwrite·workspace 검증을 수행하고, 정규 경로는 원본 그대로(strict decode → stream/background reject → prompt → metadata → workspace) 유지했다. 라우팅에 필요한 값은 lenient envelope에서만 얻는다.
- 정규 경로의 기존 `findProviderPoolEntry(req.Model)` catalog generation policy 블록은 그대로 남겼다. 이 블록은 이전에도 provider-pool 모델이 상위에서 reject/tunnel 분기되어 도달 불가능한 dead branch였고, 재구조화 후에도 provider-pool은 항상 tunnel 분기로 빠지므로 동일하게 도달 불가능하다. 정규(non-provider) 경로 behavior를 바꾸지 않기 위해 삭제하지 않았다.
- Responses tunnel의 응답 relay는 계획대로 기존 `writeProviderTunnelResponse(w, r, handle, env.Stream, "")`를 재사용한다. 이 writer는 내부 usage metric label을 `usageEndpointChatCompletions`로 고정하므로 Responses passthrough의 body-observed usage metric이 chat_completions endpoint label로 집계된다. dispatch-error 경로만 `usageEndpointResponses` label을 사용한다. 응답 bytes는 verbatim relay되어 계약(passthrough 무결성)에는 영향이 없으며, endpoint label 정합성은 별도 후속 범위로 남긴다.
- 계획의 `TestResponsesProviderPoolAppliesGenerationPolicy`/`...ThinkingPolicyOverridesStrictOutputDisable` 두 reject 테스트는 이름이 새 behavior와 어긋나므로 각각 `TestResponsesProviderPoolPreservesRawOutputTokens`, `TestResponsesProviderPoolPassthroughOmitsThinkingPolicy`로 rename했다(둘 다 `TestResponsesProviderPool` prefix 유지, 중간 검증 regex `TestResponsesProviderPool|TestResponsesProviderTunnel`에 계속 매칭). `TestResponsesProviderPoolDispatch`는 이름을 유지하고 기대값만 passthrough로 갱신했다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `decodeResponsesEnvelope`는 `json.Unmarshal`로 `model`/`metadata`/`stream`/`background`만 lenient하게 추출하고 unknown field를 reject하지 않는다. 이로써 provider route 판단이 strict normalization보다 먼저 일어나고, Codex/Responses의 미지원 필드(tools, parallel_tool_calls, store, max_output_tokens 등)가 provider passthrough body에 보존된다.
- `rewriteResponsesModel(rawBody, target)`은 caller 원본 JSON에서 `model` 필드만 served target으로 치환하고 나머지는 그대로 재직렬화한다. Chat의 `rewriteChatCompletionModel`과 달리 `max_tokens`/`think`/`thinking_token_budget` 주입을 하지 않아 Responses raw passthrough semantics(caller body 우선)를 지킨다. 빈 target은 body 무변경, invalid JSON은 `invalid JSON request`로 reject.
- `tunnelResponsesPassthrough`는 02의 `providerTunnelAuthHeaders(r)`를 재사용해 provider auth를 Chat tunnel과 동일하게 주입하고, `SubmitProviderTunnelRequest`의 `Path`를 `/v1/responses`, `Method`를 POST로 설정한다. `runMeta`에는 token/header 원문을 넣지 않고 model/stream/response_mode/estimate/context_class만 기록한다.
- 응답 model echo rewrite는 `requestModel` 인자를 빈 문자열로 넘겨 끈다(`newProviderModelRewriter("")`가 nil 반환). Responses passthrough는 provider-original bytes를 우선한다.
- streaming은 caller `stream` 값을 tunnel `Stream`으로 전달하고, `writeProviderTunnelResponse`의 stream relay 경로가 raw SSE bytes를 그대로 흘린다. Node/proto 변경은 없다.
## 사용자 리뷰 요청
@ -112,28 +119,51 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
> 주: 로컬 기본 `GOCACHE`가 permission denied라 세션 scratchpad로 `GOCACHE`를 지정해 실행했다. 명령/패키지/`-count=1`은 계획 계약 그대로다.
### SEULGI_RESPONSES-1 중간 검증
```
$ go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelAllowsUnknownFields|TestResponsesProviderPoolDispatch' -count=1
(output)
ok iop/apps/edge/internal/openai 0.009s
```
### SEULGI_RESPONSES-2 중간 검증
```
$ go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnel(SubmitsResponsesPath|RewritesOnlyModel|ForwardsProviderAuthHeader|Streaming)' -count=1
(output)
ok iop/apps/edge/internal/openai 0.006s
```
### SEULGI_RESPONSES-3 중간 검증
```
$ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponsesProviderTunnel' -count=1
(output)
$ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponsesProviderTunnel' -count=1 -v
=== RUN TestResponsesProviderPoolDispatch
--- PASS: TestResponsesProviderPoolDispatch (0.00s)
=== RUN TestResponsesProviderPoolPreservesRawOutputTokens
--- PASS: TestResponsesProviderPoolPreservesRawOutputTokens (0.00s)
=== RUN TestResponsesProviderPoolPassthroughOmitsThinkingPolicy
--- PASS: TestResponsesProviderPoolPassthroughOmitsThinkingPolicy (0.00s)
=== RUN TestResponsesProviderTunnelAllowsUnknownFields
--- PASS: TestResponsesProviderTunnelAllowsUnknownFields (0.00s)
=== RUN TestResponsesProviderTunnelSubmitsResponsesPath
--- PASS: TestResponsesProviderTunnelSubmitsResponsesPath (0.00s)
=== RUN TestResponsesProviderTunnelRewritesOnlyModel
--- PASS: TestResponsesProviderTunnelRewritesOnlyModel (0.00s)
=== RUN TestResponsesProviderTunnelForwardsProviderAuthHeader
=== RUN TestResponsesProviderTunnelForwardsProviderAuthHeader/forwards_raw_token_with_scheme
=== RUN TestResponsesProviderTunnelForwardsProviderAuthHeader/missing_required_token_rejected
--- PASS: TestResponsesProviderTunnelForwardsProviderAuthHeader (0.00s)
--- PASS: TestResponsesProviderTunnelForwardsProviderAuthHeader/forwards_raw_token_with_scheme (0.00s)
--- PASS: TestResponsesProviderTunnelForwardsProviderAuthHeader/missing_required_token_rejected (0.00s)
=== RUN TestResponsesProviderTunnelStreaming
--- PASS: TestResponsesProviderTunnelStreaming (0.00s)
PASS
ok iop/apps/edge/internal/openai 0.007s
```
### 최종 검증
```
$ go test ./apps/edge/internal/openai -count=1
(output)
ok iop/apps/edge/internal/openai 1.828s
```
---
@ -141,3 +171,20 @@ $ go test ./apps/edge/internal/openai -count=1
> **[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.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/openai/responses_handler.go:279`에서 Responses passthrough용 `usageEndpointResponses` metric labels를 만들지만, 성공 relay는 `apps/edge/internal/openai/responses_handler.go:303`에서 `requestModel=""`로 `writeProviderTunnelResponse`를 호출합니다. 공유 writer는 `apps/edge/internal/openai/stream.go:471`에서 항상 `usageEndpointChatCompletions`와 `strings.TrimSpace(requestModel)`를 사용하므로, successful `/v1/responses` provider passthrough usage/request metrics가 endpoint=`chat.completions`, model_group=empty로 기록됩니다. `writeProviderTunnelResponse`가 caller-provided usage labels 또는 model group/endpoint/response mode를 받게 바꾸고, `/v1/responses` passthrough success metric이 endpoint=`responses`, model_group=request alias, response_mode=`passthrough`로 증가하는 테스트를 추가하세요.
- Required: 새 구현은 provider-pool/`openai_compat`/`vllm` route의 `/v1/responses`를 raw passthrough로 허용하지만, `agent-contract/outer/openai-compatible-api.md:129`, `agent-contract/outer/openai-compatible-api.md:209`, `agent-contract/outer/openai-compatible-api.md:232`는 여전히 해당 호출이 raw parity 전까지 `400`으로 거부된다고 명시합니다. `agent-spec/input/openai-compatible-surface.md:135`도 `/v1/responses`가 non-streaming string input만 지원한다고 적어 provider passthrough streaming behavior와 맞지 않습니다. OpenAI-compatible 계약과 matching living spec을 새 behavior, model rewrite only, unknown field preservation, streaming relay, provider auth, Responses usage metric label 기준에 맞게 갱신하세요.
- 다음 단계: FAIL 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.

View file

@ -0,0 +1,216 @@
<!-- task=m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough plan=1 tag=REVIEW_SEULGI_RESPONSES -->
# Code Review Reference - REVIEW_SEULGI_RESPONSES
> **[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-11
task=m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough, plan=1, tag=REVIEW_SEULGI_RESPONSES
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md)
- Task ids:
- `responses-passthrough`: OpenAI-compatible provider route에서 `/v1/responses` raw passthrough가 동작
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current task path: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough`
- Archived loop logs:
- Plan: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_0.log`
- Review: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/openai/responses_handler.go:303` calls `writeProviderTunnelResponse(..., "")`; `apps/edge/internal/openai/stream.go:471` then records success metrics as endpoint=`chat.completions`, model_group=empty instead of endpoint=`responses`, model_group=request alias.
- `agent-contract/outer/openai-compatible-api.md:129`, `:209`, `:232` and `agent-spec/input/openai-compatible-surface.md:135` still describe provider `/v1/responses` as unsupported or non-streaming-only.
- Verification evidence from prior loop:
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1` PASS.
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/...` PASS.
- Roadmap carryover: keep `responses-passthrough` as the only completion target.
- Narrow reread allowed when needed: only the two archived loop logs listed above and the predecessor complete logs cited in `분할 판단`; do not search `agent-task/archive/**` broadly.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_SEULGI_RESPONSES-1] Responses passthrough usage labels | [x] |
| [REVIEW_SEULGI_RESPONSES-2] Contract and spec sync | [x] |
## 구현 체크리스트
- [x] [REVIEW_SEULGI_RESPONSES-1] `/v1/responses` provider passthrough success metrics가 endpoint=`responses`, model_group=request alias, response_mode=`passthrough`로 기록되게 고치고 regression test를 추가한다.
- [x] [REVIEW_SEULGI_RESPONSES-2] OpenAI-compatible contract와 matching agent-spec을 Responses provider raw passthrough behavior에 맞게 갱신하고 stale unsupported 문구 검색을 통과시킨다.
- [x] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1`를 실행한다.
- [x] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/`를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 검증 명령은 계획의 고정 계약 그대로 실행했다. 대체 없음.
- 계획과 실질적으로 동일하게 구현했다. Responses passthrough 경로에서는 이미 dispatch-error emit용으로 계산해 둔 `metricLabels`(endpoint=`responses`, response_mode=`passthrough`) 변수를 그대로 writer에 전달해 중복 계산을 피했다. Chat passthrough 경로에서는 계획의 After 스니펫대로 `tunnelChatCompletionPassthrough`에서 `usageEndpointChatCompletions` 라벨을 계산해 writer에 넘겼다.
- `submitChatCompletionTunnel` 내부의 dispatch-error용 `metricLabels`(stream.go:432)는 별도 경로라 그대로 두었다. writer로 넘기는 라벨과 의미가 같아 회귀 없음.
## 주요 설계 결정
- `writeProviderTunnelResponse`가 더 이상 `requestModel`로부터 usage label을 재계산하지 않고 caller가 넘긴 `metricLabels usageLabels`를 그대로 사용하도록 signature를 변경했다. 이렇게 하면 Chat(`chat.completions`)과 Responses(`responses`) 경로가 각자 올바른 endpoint/model_group으로 success/error/cancel usage를 귀속한다. `requestModel`은 provider model-echo rewrite 용도로만 남겼다(Responses는 빈 문자열이라 rewrite 비활성).
- Responses success 경로에 model_group=request alias, endpoint=`responses`, response_mode=`passthrough`가 기록되는지 검증하는 regression test를 추가하고, 과거 버그 라벨(endpoint=`chat.completions`, model_group="")이 증가하지 않는지도 함께 assert했다.
- 계약/spec은 normalized(non-provider) `/v1/responses`(strict, non-streaming string input)와 provider route raw passthrough(model rewrite only, unknown/Codex field 보존, streaming raw SSE relay, provider auth forwarding, sideband/echo rewrite 없음)를 분리해 기술하고, provider 경로 usage metric label(endpoint=`responses`)을 명시했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Responses passthrough success metrics use endpoint=`responses`, model_group=request alias, response_mode=`passthrough`.
- Chat Completions passthrough metric behavior remains unchanged.
- OpenAI-compatible contract no longer says provider `/v1/responses` is unsupported before parity.
- Agent spec distinguishes normalized non-provider Responses limits from provider raw passthrough streaming behavior.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_SEULGI_RESPONSES-1 중간 검증
```
$ GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelPassthroughObservesUsageMetrics|TestProviderTunnelPassthroughPreservesBodyAndObservesUsage' -count=1
ok iop/apps/edge/internal/openai 0.010s
```
### REVIEW_SEULGI_RESPONSES-2 중간 검증
```
$ if rg --sort path -n 'raw passthrough parity가 구현되기 전까지|raw passthrough parity 전까지 지원하지 않는다|provider model group raw passthrough parity 전까지' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md; then exit 1; fi
(no matches; exit code 0)
```
### 최종 검증
```
$ GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1
ok iop/apps/edge/internal/openai 1.847s
$ GOCACHE=/tmp/iop-gocache go test ./apps/edge/...
ok iop/apps/edge/cmd/edge 0.065s
ok iop/apps/edge/internal/bootstrap 0.297s
ok iop/apps/edge/internal/configrefresh (cached)
ok iop/apps/edge/internal/controlplane (cached)
ok iop/apps/edge/internal/edgecmd (cached)
ok iop/apps/edge/internal/edgevalidate (cached)
ok iop/apps/edge/internal/events (cached)
ok iop/apps/edge/internal/input 0.009s
ok iop/apps/edge/internal/input/a2a (cached)
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/openai 1.833s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/transport (cached)
$ if rg --sort path -n 'raw passthrough parity가 구현되기 전까지|raw passthrough parity 전까지 지원하지 않는다|provider model group raw passthrough parity 전까지' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md; then exit 1; fi
(no matches; exit code 0)
```
---
> **[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.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation; do not ask the user directly during implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
### 종합 판정: FAIL
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Fail | Responses passthrough usage metric 검증이 실제 Node tunnel relay와 다른 `USAGE` frame에 의존해, provider body의 Responses usage가 token metric으로 집계되지 않는다. |
| Completeness | Fail | `REVIEW_SEULGI_RESPONSES-1`의 request/usage metrics 완료 조건 중 실제 provider-reported usage body 경로가 닫히지 않았다. |
| Test coverage | Fail | 새 regression test가 실제 `openai_compat` tunnel relay가 만드는 body-only frame 흐름을 검증하지 않는다. |
| API contract | Fail | `agent-spec/input/openai-compatible-surface.md`는 provider-reported `input`, `output`, `reasoning`, `cached_input` token usage 집계를 현재 동작으로 둔다. |
| Code quality | Pass | 변경 구조 자체는 좁고 shared writer label 주입 방식은 기존 경로와 맞는다. |
| Implementation deviation | Fail | 계획의 Responses usage metric 검증이 실제 provider body usage 관측 대신 인위적 proto `USAGE` frame으로 충족됐다. |
| Verification trust | Fail | 기록된 `TestResponsesProviderTunnelPassthroughObservesUsageMetrics` PASS는 실제 Node relay가 `USAGE` frame을 emit하지 않는 경로를 대표하지 않는다. |
| Spec conformance | Fail | SDD S04 passthrough 자체는 구현됐지만, matching spec의 OpenAI usage metering 동작과 검증 evidence가 불충분하다. |
### 발견된 문제
- Required: `apps/edge/internal/openai/usage_metrics_test.go:251`의 Responses usage regression은 `PROVIDER_TUNNEL_FRAME_KIND_USAGE`를 직접 주입하지만, 실제 `apps/node/internal/adapters/openai_compat/openai_compat.go:132`-`170`의 `TunnelProvider`는 provider response를 `BODY` frame들 다음 `END` frame으로만 relay한다. Edge writer는 `apps/edge/internal/openai/stream.go:624`-`628`에서 `USAGE` frame이 있을 때만 proto usage를 기록하고, body parser인 `providerUsageEnvelope`도 `apps/edge/internal/openai/stream.go:884`-`892`에서 Chat Completions의 `prompt_tokens`/`completion_tokens`만 읽는다. 따라서 실제 `/v1/responses` provider body가 OpenAI Responses schema의 `usage.input_tokens`/`usage.output_tokens`를 담아도 `agent-spec/input/openai-compatible-surface.md:64`의 provider-reported token usage metric 집계가 빠진다. 수정은 Responses passthrough body/SSE usage schema를 읽는 endpoint-aware parser를 추가하거나 Node tunnel이 `/v1/responses` body usage를 `USAGE` frame으로 emit하게 하고, test fixture는 인위적 `USAGE` frame 없이 실제 Responses usage body/SSE만으로 `usage_source=provider_reported`와 token counter delta를 검증해야 한다.
### 다음 단계
FAIL follow-up plan/review를 작성한다. 사용자 리뷰 게이트는 트리거하지 않는다.

View file

@ -0,0 +1,221 @@
<!-- task=m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough plan=2 tag=REVIEW_REVIEW_SEULGI_RESPONSES -->
# Code Review Reference - REVIEW_REVIEW_SEULGI_RESPONSES
> **[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-11
task=m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough, plan=2, tag=REVIEW_REVIEW_SEULGI_RESPONSES
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md)
- Task ids:
- `responses-passthrough`: OpenAI-compatible provider route에서 `/v1/responses` raw passthrough가 동작
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current task path: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough`
- Archived loop logs:
- Plan: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_1.log`
- Review: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/openai/usage_metrics_test.go:251` injects a proto `USAGE` frame for Responses usage metrics.
- Real `apps/node/internal/adapters/openai_compat/openai_compat.go:132`-`170` emits provider response `BODY` frames and then `END`, with no `USAGE` frame.
- `apps/edge/internal/openai/stream.go:884`-`892` parses Chat usage keys only, so Responses body usage keys are not observed into token metrics.
- Verification evidence from this review:
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1` PASS.
- `GOCACHE=/tmp/iop-gocache go test ./apps/node/internal/adapters/openai_compat -count=1` PASS.
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/... -count=1` PASS.
- stale unsupported text search PASS.
- Roadmap carryover: keep `responses-passthrough` as the only completion target.
- Narrow reread allowed when needed: only the two archived loop logs listed above; do not search `agent-task/archive/**` broadly.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_SEULGI_RESPONSES-1] Responses body usage metrics | [x] |
## 구현 체크리스트
- [x] [REVIEW_REVIEW_SEULGI_RESPONSES-1] `/v1/responses` provider passthrough가 실제 body-only Responses usage JSON/SSE에서 provider-reported usage metrics를 관측하도록 고치고 regression tests를 추가한다.
- [x] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelPassthroughObservesUsageMetrics|TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics|TestProviderTunnelPassthroughPreservesBodyAndObservesUsage' -count=1`를 실행한다.
- [x] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1`를 실행한다.
- [x] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/... -count=1`를 실행한다.
- [x] `GOCACHE=/tmp/iop-gocache go test ./apps/node/internal/adapters/openai_compat -count=1`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/`를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_계획에 명시된 변경 사항 외 추가/변경 없이 plan대로 구현했다._
## 주요 설계 결정
- `providerUsageEnvelope`에 Chat Completions(`prompt_tokens`/`completion_tokens`)과 Responses(`input_tokens`/`output_tokens`) fields를 모두 추가했다.
- `recordUsage`는 Chat 키가 0이 아니면 Chat 키를 사용하고, 없으면 Responses 키를 사용한다.
- `consumeSSELine`은 이미 있는 Chat SSE 파싱 뒤에 Responses SSE nested `response.usage` 파싱을 추가했다.
- 기존 Chat SSE `usage`와 Responses nested `response.usage`가 함께 있는 경우, Chat SSE가 먼저 파싱되고 `recordUsage`가 Chat 키를 채우므로 Responses nested usage는 `a.usage.inputTokens == 0 && a.usage.outputTokens == 0` 조건에서 확인해 body duplication을 피한다.
- `TestResponsesProviderTunnelPassthroughObservesUsageMetrics`를 proto USAGE frame 대신 body-only Responses usage JSON fixture로 교체했다.
- 새로운 `TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics`를 추가해 Responses streaming SSE의 nested `response.usage` 관측을 검증했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Responses passthrough usage regression must not depend on proto `USAGE` frames.
- Realistic Responses non-stream body usage fields (`input_tokens`, `output_tokens`, details) increment endpoint=`responses` token metrics.
- Realistic Responses SSE usage event increments endpoint=`responses` token metrics while preserving raw SSE bytes.
- Existing Chat passthrough body usage metrics remain green.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_SEULGI_RESPONSES-1 중간 검증
```
$ GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelPassthroughObservesUsageMetrics|TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics|TestProviderTunnelPassthroughPreservesBodyAndObservesUsage' -count=1
=== RUN TestProviderTunnelPassthroughPreservesBodyAndObservesUsage
--- PASS: TestProviderTunnelPassthroughPreservesBodyAndObservesUsage (0.00s)
=== RUN TestResponsesProviderTunnelPassthroughObservesUsageMetrics
--- PASS: TestResponsesProviderTunnelPassthroughObservesUsageMetrics (0.00s)
=== RUN TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics
--- PASS: TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics (0.00s)
PASS
ok iop/apps/edge/internal/openai 0.011s
```
### 최종 검증
```
$ GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1
ok iop/apps/edge/internal/openai 1.830s
$ GOCACHE=/tmp/iop-gocache go test ./apps/edge/... -count=1
ok iop/apps/edge/cmd/edge 0.070s
ok iop/apps/edge/internal/bootstrap 0.301s
ok iop/apps/edge/internal/configrefresh 0.033s
ok iop/apps/edge/internal/controlplane 4.459s
ok iop/apps/edge/internal/edgecmd 0.025s
ok iop/apps/edge/internal/edgevalidate 0.006s
ok iop/apps/edge/internal/events 0.005s
ok iop/apps/edge/internal/input 0.019s
ok iop/apps/edge/internal/input/a2a 0.016s
ok iop/apps/edge/internal/node 0.016s
ok iop/apps/edge/internal/openai 1.831s
ok iop/apps/edge/internal/opsconsole 0.009s
ok iop/apps/edge/internal/service 0.933s
ok iop/apps/edge/internal/transport 2.046s
$ GOCACHE=/tmp/iop-gocache go test ./apps/node/internal/adapters/openai_compat -count=1
ok iop/apps/node/internal/adapters/openai_compat 0.121s
```
---
> **[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.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation; do not ask the user directly during implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
### 종합 판정: PASS
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Pass | Responses passthrough writer가 caller-provided labels를 사용하고, body-only Responses JSON/SSE usage를 provider-reported metrics로 관측한다. |
| Completeness | Pass | 계획의 구현 항목과 검증 항목이 모두 완료되었고, review stub의 구현 에이전트 소유 섹션도 채워졌다. |
| Test coverage | Pass | Non-stream body-only Responses usage, streaming body-only Responses usage, 기존 Chat passthrough usage 회귀가 targeted test와 package test로 검증됐다. |
| API contract | Pass | OpenAI-compatible contract/spec의 Responses provider passthrough 및 usage metric 설명과 코드 동작이 일치한다. |
| Code quality | Pass | 변경은 shared tunnel writer label 주입과 usage parser 확장으로 좁게 유지됐고 debug/dead code가 없다. |
| Implementation deviation | Pass | 계획 대비 실질 변경은 없으며 추가 Node adapter test는 실제 tunnel frame 형태를 보강하는 누적 증거로 범위에 부합한다. |
| Verification trust | Pass | 리뷰어가 fresh `-count=1` Go 검증과 stale text search를 재실행해 구현 기록과 일치함을 확인했다. |
| Spec conformance | Pass | SDD S04/S05의 Responses passthrough evidence와 `responses-passthrough` Roadmap Target을 충족한다. |
### 발견된 문제
없음
### 다음 단계
PASS finalization을 진행한다. `complete.log` 작성 후 task directory를 archive로 이동하고, `m-seulgivibe-openai-compatible-provider` 완료 이벤트 메타데이터를 보고한다.

View file

@ -0,0 +1,49 @@
# Complete - m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough
## 완료 일시
2026-07-11
## 요약
Seulgivibe OpenAI-compatible Provider 연동의 `/v1/responses` provider passthrough subtask를 3회 리뷰 루프 끝에 PASS로 종료했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | Responses provider passthrough 기본 구현 뒤 success usage label과 contract/spec sync가 부족했다. |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | Responses usage regression이 실제 Node tunnel relay와 다른 proto `USAGE` frame에 의존했다. |
| `plan_local_G05_2.log` | `code_review_local_G05_2.log` | PASS | Body-only Responses JSON/SSE usage observation, endpoint=`responses` metrics, regression tests가 충족됐다. |
## 구현/정리 내용
- `/v1/responses` provider raw passthrough가 strict normalized parser를 우회해 provider `POST /v1/responses`로 전달되고, model rewrite 외 unknown/Codex fields와 streaming SSE를 보존한다.
- Responses passthrough success metrics가 endpoint=`responses`, response_mode=`passthrough`/`passthrough+sideband`, model_group=request alias로 기록되게 정리했다.
- Provider body-only Responses JSON/SSE의 `usage.input_tokens`/`usage.output_tokens`와 detail token을 Edge writer가 provider-reported usage metrics로 관측하게 했다.
- OpenAI-compatible contract와 matching agent-spec을 Responses provider raw passthrough behavior에 맞게 동기화했다.
- Node `openai_compat` tunnel이 `/v1/responses` path를 provider body-only `BODY`/`END` frames로 relay하는 테스트 evidence를 보강했다.
## 최종 검증
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelPassthroughObservesUsageMetrics|TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics|TestProviderTunnelPassthroughPreservesBodyAndObservesUsage' -count=1` - PASS; `ok iop/apps/edge/internal/openai 0.010s`.
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1` - PASS; `ok iop/apps/edge/internal/openai 1.833s`.
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/... -count=1` - PASS; Edge packages all passed, including `apps/edge/internal/openai 1.833s` and `apps/edge/internal/transport 2.038s`.
- `GOCACHE=/tmp/iop-gocache go test ./apps/node/internal/adapters/openai_compat -count=1` - PASS; `ok iop/apps/node/internal/adapters/openai_compat 0.123s`.
- `if rg --sort path -n 'raw passthrough parity가 구현되기 전까지|raw passthrough parity 전까지 지원하지 않는다|provider model group raw passthrough parity 전까지' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md; then exit 1; fi` - PASS; no stale unsupported text matched.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md)
- Completed task ids:
- `responses-passthrough`: PASS; evidence=`agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_local_G05_2.log`, `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_local_G05_2.log`; verification=`GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,267 @@
<!-- task=m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough plan=1 tag=REVIEW_SEULGI_RESPONSES -->
# Implementation Plan - REVIEW_SEULGI_RESPONSES
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 완료 조건이다. 구현 후 검증을 실행하고, 실제 stdout/stderr를 붙이고, active 파일은 그대로 둔 뒤 review ready로 보고한다. selected Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청`에 정확한 근거를 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 외부 secret/service 준비, 검증 증거 공백, 일반 범위 조정은 사용자 리뷰 요청이 아니라 후속 plan 또는 검증 기록으로 처리한다. finalization은 code-review-skill 전용이다.
## 배경
이전 loop는 `/v1/responses` provider raw tunnel의 핵심 dispatch/body/auth/stream tests를 구현했지만, 성공 metering이 shared Chat tunnel labels로 기록되고 OpenAI-compatible 계약/spec 문서가 이전 reject behavior를 유지했다. 이 follow-up은 코드 동작, usage metric labels, 계약 문서를 같은 상태로 맞춰 `responses-passthrough` Roadmap Task를 PASS 가능하게 만든다.
## 사용자 리뷰 요청 흐름
선택된 Milestone lock decision이 실구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자에게 질문하지 않고, code-review가 그 요청을 검증해 `USER_REVIEW.md` 작성 여부를 결정한다.
## Archive Evidence Snapshot
- Current task path: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough`
- Archived loop logs:
- Plan: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_0.log`
- Review: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/openai/responses_handler.go:303` calls `writeProviderTunnelResponse(..., "")`; `apps/edge/internal/openai/stream.go:471` then records success metrics as endpoint=`chat.completions`, model_group=empty instead of endpoint=`responses`, model_group=request alias.
- `agent-contract/outer/openai-compatible-api.md:129`, `:209`, `:232` and `agent-spec/input/openai-compatible-surface.md:135` still describe provider `/v1/responses` as unsupported or non-streaming-only.
- Verification evidence from prior loop:
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1` PASS.
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/...` PASS.
- Roadmap carryover: keep `responses-passthrough` as the only completion target.
- Narrow reread allowed when needed: only the two archived loop logs listed above and the predecessor complete logs cited in `분할 판단`; do not search `agent-task/archive/**` broadly.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md)
- Task ids:
- `responses-passthrough`: OpenAI-compatible provider route에서 `/v1/responses` raw passthrough가 동작
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-spec/index.md`
- `agent-spec/input/openai-compatible-surface.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md`
- `agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/openai/usage_metrics_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/runtime/types.go`
- `proto/iop/runtime.proto`
- `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log`
- `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/complete.log`
- `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_0.log`
- `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_0.log`
### SDD 기준
- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md`
- 상태: `[승인됨]`
- SDD 잠금: 해제
- 대상 Acceptance Scenario:
- S04 -> `responses-passthrough`: Codex-style `/v1/responses` payload를 provider route에서 strict normalized parser 없이 raw tunnel로 전달한다.
- Evidence Map:
- S04 requires Edge OpenAI Responses tunnel tests and `go test ./apps/edge/internal/openai -count=1` evidence.
- 반영:
- metric label regression test는 S04의 provider tunnel success path가 OpenAI-compatible surface 관측에도 올바르게 잡히는지 확인한다.
- 계약/spec 갱신은 SDD Interface Contract의 Responses provider tunnel output과 OpenAI-compatible contract source of truth를 일치시킨다.
### 테스트 환경 규칙
- `test_env=local`.
- `agent-test/local/rules.md`와 `agent-test/local/edge-smoke.md`를 읽었다.
- 적용 규칙: 변경 패키지 대상 Go test, Edge OpenAI-compatible 입력 표면 변경이므로 가능한 `go test ./apps/edge/...` 회귀 확인.
- 외부 Seulgivibe/provider live smoke는 endpoint/credential이 필요하므로 이 follow-up의 필수 검증이 아니다. Fake provider tunnel tests로 deterministic 검증한다.
- Go test cache output은 이 작업에서 충분하지 않다. `-count=1`을 사용한다.
### 테스트 커버리지 공백
- 공백: successful `/v1/responses` provider passthrough가 endpoint=`responses`, model_group=request alias, response_mode=`passthrough`로 request/usage metrics를 emit하는 테스트가 없다.
- 공백: contract/spec stale 문구 제거를 검증하는 deterministic search가 없다.
- 기존 커버: dispatch path, raw body model rewrite, unknown fields, provider auth, streaming relay는 `server_test.go`의 Responses provider tunnel tests가 커버한다.
### 심볼 참조
- renamed/removed symbol 없음.
- 변경 예정 call site:
- `apps/edge/internal/openai/stream.go`: `tunnelChatCompletionPassthrough` -> `writeProviderTunnelResponse` call.
- `apps/edge/internal/openai/responses_handler.go`: `tunnelResponsesPassthrough` -> `writeProviderTunnelResponse` call.
### 분할 판단
- 이 follow-up은 같은 split subtask `03+01,02_responses_passthrough` 안에서 prior FAIL을 수습한다. 새 sibling subtask를 만들지 않는다.
- predecessor `01`: satisfied by `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log`.
- predecessor `02`: satisfied by `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/complete.log`.
- 두 Required는 같은 `/v1/responses` provider passthrough completion evidence를 닫기 위한 코드+계약 보완이며, 별도 split은 같은 PASS evidence를 불필요하게 나눈다.
### 범위 결정 근거
- 포함: `apps/edge/internal/openai` successful provider tunnel metric labels, targeted usage metric test, `agent-contract/outer/openai-compatible-api.md`, `agent-spec/input/openai-compatible-surface.md`.
- 제외: provider response model echo rewrite, Responses sideband mode, Node/proto contract 변경, live provider smoke, pricing/Grafana 문서 갱신.
- 기존 Chat Completions passthrough metric behavior는 유지해야 한다.
### 빌드 등급
`cloud-G07`. Follow-up은 bounded하지만 API contract, living spec, OpenAI usage metrics, shared tunnel writer call sites를 함께 맞춰야 하며 prior review evidence를 보존해야 한다.
## 구현 체크리스트
- [x] [REVIEW_SEULGI_RESPONSES-1] `/v1/responses` provider passthrough success metrics가 endpoint=`responses`, model_group=request alias, response_mode=`passthrough`로 기록되게 고치고 regression test를 추가한다.
- [x] [REVIEW_SEULGI_RESPONSES-2] OpenAI-compatible contract와 matching agent-spec을 Responses provider raw passthrough behavior에 맞게 갱신하고 stale unsupported 문구 검색을 통과시킨다.
- [x] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1`를 실행한다.
- [x] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_SEULGI_RESPONSES-1] Responses Passthrough Usage Labels
#### 문제
`apps/edge/internal/openai/responses_handler.go:279`는 Responses용 labels를 만들지만, success relay는 `responses_handler.go:303`에서 `requestModel=""`로 shared writer를 호출한다. `apps/edge/internal/openai/stream.go:471`은 writer 안에서 endpoint=`chat.completions`, model_group=`strings.TrimSpace(requestModel)`로 labels를 다시 만들기 때문에 Responses success metrics가 잘못 분류된다.
Before:
```go
// apps/edge/internal/openai/responses_handler.go:279
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(env.Model), usageEndpointResponses, responseModePassthrough)
handle, err := s.service.SubmitProviderTunnel(r.Context(), tunnelReq)
// ...
s.writeProviderTunnelResponse(w, r, handle, env.Stream, "")
```
```go
// apps/edge/internal/openai/stream.go:471
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(requestModel), usageEndpointChatCompletions, responseModePassthrough)
```
#### 해결 방법
`writeProviderTunnelResponse`가 caller가 계산한 `usageLabels`를 받게 하거나 equivalent options struct를 도입한다. Chat path는 기존 chat labels를 넘기고, Responses path는 이미 만든 responses labels를 넘긴다. `requestModel`은 model echo rewrite 용도로만 남겨서 Responses body rewrite는 계속 꺼둔다.
After:
```go
// apps/edge/internal/openai/stream.go
func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Request, handle edgeservice.ProviderTunnelResult, reqStream bool, requestModel string, metricLabels usageLabels) {
// no local usageEndpointChatCompletions recompute here
}
```
```go
// apps/edge/internal/openai/responses_handler.go
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(env.Model), usageEndpointResponses, responseModePassthrough)
handle, err := s.service.SubmitProviderTunnel(r.Context(), tunnelReq)
// ...
s.writeProviderTunnelResponse(w, r, handle, env.Stream, "", metricLabels)
```
```go
// apps/edge/internal/openai/stream.go
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(req.Model), usageEndpointChatCompletions, responseModePassthrough)
s.writeProviderTunnelResponse(w, r, handle, req.Stream, req.Model, metricLabels)
```
#### 수정 파일 및 체크리스트
- [x] `apps/edge/internal/openai/stream.go`: writer signature/call site update, Chat passthrough labels preserved.
- [x] `apps/edge/internal/openai/responses_handler.go`: Responses passthrough passes responses labels to writer.
- [x] `apps/edge/internal/openai/usage_metrics_test.go`: Responses passthrough success metric regression test 추가.
#### 테스트 작성
- Add `TestResponsesProviderTunnelPassthroughObservesUsageMetrics` in `apps/edge/internal/openai/usage_metrics_test.go`.
- Fixture: provider-pool model `pool-model`, principal token auth or default anonymous labels matching existing helpers, `staticProviderTunnelFrames` with a Responses-like body.
- Assertions:
- HTTP body remains provider-original.
- `openAIRequestsTotal` delta is `1` for labels endpoint=`responses`, response_mode=`passthrough`, status=`success`, model_group=`pool-model`.
- The old wrong labels endpoint=`chat.completions`, model_group="" do not increment for the same call.
#### 중간 검증
```bash
GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelPassthroughObservesUsageMetrics|TestProviderTunnelPassthroughPreservesBodyAndObservesUsage' -count=1
```
Expected: both tests pass; Chat passthrough usage regression remains green.
### [REVIEW_SEULGI_RESPONSES-2] Contract And Spec Sync
#### 문제
Implementation now allows provider `/v1/responses` passthrough, but the outer contract still says the path is unsupported:
```markdown
<!-- agent-contract/outer/openai-compatible-api.md:129 -->
- OpenAI-compatible provider model group route(provider pool, `openai_compat`, `vllm`)의 `/v1/responses` 호출은 raw passthrough parity가 구현되기 전까지 `400 invalid_request_error`로 거부한다.
```
`agent-contract/outer/openai-compatible-api.md:209` and `:232` repeat the same pre-parity state. `agent-spec/input/openai-compatible-surface.md:135` also says `/v1/responses` is non-streaming string input only, which is no longer true for provider raw passthrough routes.
#### 해결 방법
Update the OpenAI-compatible contract and matching living spec to describe the new split behavior:
- normalized non-provider `/v1/responses`: keep strict field validation and non-streaming string input limit.
- provider route (`provider pool`, `openai_compat`, `vllm`): raw passthrough to `POST /v1/responses`, model field rewrite only, unknown/Codex fields preserved, `stream:true` relayed as raw SSE, provider auth forwarding applied, no response model echo rewrite or sideband injection.
- usage metric labels for Responses passthrough use endpoint=`responses`, response_mode=`passthrough`, model_group=request alias.
- Remove stale “raw parity before implementation” unsupported statements.
#### 수정 파일 및 체크리스트
- [x] `agent-contract/outer/openai-compatible-api.md`: Responses API and provider route sections updated.
- [x] `agent-spec/input/openai-compatible-surface.md`: feature list, settings/flow, limitations, verification/change history updated.
#### 테스트 작성
- No Go test for docs-only text.
- Add deterministic stale-text verification command below.
#### 중간 검증
```bash
if rg --sort path -n 'raw passthrough parity가 구현되기 전까지|raw passthrough parity 전까지 지원하지 않는다|provider model group raw passthrough parity 전까지' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md; then exit 1; fi
```
Expected: no matches and exit code 0.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/stream.go` | REVIEW_SEULGI_RESPONSES-1 |
| `apps/edge/internal/openai/responses_handler.go` | REVIEW_SEULGI_RESPONSES-1 |
| `apps/edge/internal/openai/usage_metrics_test.go` | REVIEW_SEULGI_RESPONSES-1 |
| `agent-contract/outer/openai-compatible-api.md` | REVIEW_SEULGI_RESPONSES-2 |
| `agent-spec/input/openai-compatible-surface.md` | REVIEW_SEULGI_RESPONSES-2 |
## 최종 검증
```bash
GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1
GOCACHE=/tmp/iop-gocache go test ./apps/edge/...
if rg --sort path -n 'raw passthrough parity가 구현되기 전까지|raw passthrough parity 전까지 지원하지 않는다|provider model group raw passthrough parity 전까지' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md; then exit 1; fi
```
Expected: both Go test commands pass with fresh execution; stale unsupported-contract search prints no matches and exits 0.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,247 @@
<!-- task=m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough plan=2 tag=REVIEW_REVIEW_SEULGI_RESPONSES -->
# Implementation Plan - REVIEW_REVIEW_SEULGI_RESPONSES
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 완료 조건이다. 구현 후 검증을 실행하고, 실제 stdout/stderr를 붙이고, active 파일은 그대로 둔 뒤 review ready로 보고한다. selected Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청`에 정확한 근거를 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 외부 secret/service 준비, 검증 증거 공백, 일반 범위 조정은 사용자 리뷰 요청이 아니라 후속 plan 또는 검증 기록으로 처리한다. finalization은 code-review-skill 전용이다.
## 배경
이전 loop는 Responses passthrough metric labels를 endpoint=`responses`로 넘기도록 고쳤지만, regression test가 실제 Node tunnel relay와 다른 proto `USAGE` frame을 직접 주입했다. 현재 `openai_compat` tunnel은 provider body를 `BODY` frame으로만 relay하므로, `/v1/responses` provider body의 `usage.input_tokens`/`usage.output_tokens`를 Edge writer가 직접 관측해야 provider-reported token metrics가 실제 경로에서 유지된다.
## 사용자 리뷰 요청 흐름
선택된 Milestone lock decision이 실구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자에게 질문하지 않고, code-review가 그 요청을 검증해 `USER_REVIEW.md` 작성 여부를 결정한다.
## Archive Evidence Snapshot
- Current task path: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough`
- Archived loop logs:
- Plan: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_1.log`
- Review: `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/openai/usage_metrics_test.go:251` injects a proto `USAGE` frame for Responses usage metrics.
- Real `apps/node/internal/adapters/openai_compat/openai_compat.go:132`-`170` emits provider response `BODY` frames and then `END`, with no `USAGE` frame.
- `apps/edge/internal/openai/stream.go:884`-`892` parses Chat usage keys only, so Responses body usage keys are not observed into token metrics.
- Verification evidence from this review:
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1` PASS.
- `GOCACHE=/tmp/iop-gocache go test ./apps/node/internal/adapters/openai_compat -count=1` PASS.
- `GOCACHE=/tmp/iop-gocache go test ./apps/edge/... -count=1` PASS.
- stale unsupported text search PASS.
- Roadmap carryover: keep `responses-passthrough` as the only completion target.
- Narrow reread allowed when needed: only the two archived loop logs listed above; do not search `agent-task/archive/**` broadly.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md)
- Task ids:
- `responses-passthrough`: OpenAI-compatible provider route에서 `/v1/responses` raw passthrough가 동작
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `agent-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-spec/index.md`
- `agent-spec/input/openai-compatible-surface.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md`
- `agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/usage_metrics_test.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/adapters/openai_compat/openai_compat_test.go`
- `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_1.log`
- `agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_1.log`
### SDD 기준
- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md`
- 상태: `[승인됨]`
- SDD 잠금: 해제
- 대상 Acceptance Scenario:
- S04 -> `responses-passthrough`: Codex-style `/v1/responses` payload를 provider route에서 strict normalized parser 없이 raw tunnel로 전달한다.
- Evidence Map:
- S04 requires Edge OpenAI Responses tunnel tests and `go test ./apps/edge/internal/openai -count=1` evidence.
- 반영:
- 이번 follow-up은 raw tunnel 자체가 아니라 S04 completion evidence의 usage metric trust를 닫는다.
- `go test ./apps/edge/internal/openai -count=1`를 최종 evidence로 유지한다.
### 테스트 환경 규칙
- `test_env=local`.
- `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/node-smoke.md`를 읽었다.
- 적용 규칙: Edge OpenAI-compatible 입력 표면 변경이므로 대상 package test와 `go test ./apps/edge/...`를 실행한다.
- 현재 worktree에는 Node adapter tunnel test 변경도 있으므로 `go test ./apps/node/internal/adapters/openai_compat -count=1`를 최종 검증에 포함한다.
- 외부 provider live smoke는 endpoint/credential이 필요하므로 이 follow-up의 필수 검증이 아니다.
- Go test cache output은 허용하지 않는다. 모든 Go 검증은 `-count=1`을 사용한다.
### 테스트 커버리지 공백
- 공백: `/v1/responses` provider passthrough가 실제 Node tunnel처럼 body-only frame으로 Responses usage JSON을 돌려줄 때 endpoint=`responses`, response_mode=`passthrough`, model_group=request alias, usage_source=`provider_reported`와 token counters가 증가하는 테스트가 없다.
- 공백: streaming Responses SSE에서 `response.completed`/root usage event가 body-only로 들어오는 경우의 token metric 관측 테스트가 없다.
- 기존 커버: Chat passthrough body-only usage, Chat sideband body/proto merge, Responses dispatch/body/auth/stream passthrough, Responses label-only proto usage frame regression.
### 심볼 참조
- renamed/removed symbol 없음.
- 변경 예정 call site:
- `apps/edge/internal/openai/stream.go`: `writeProviderTunnelResponse` 내부 `providerChatAssembler` 생성 및 usage parsing.
### 분할 판단
- split decision policy를 평가했다.
- 단일 plan 유지: 하나의 Required이며, touch 예정 파일은 `apps/edge/internal/openai/stream.go`와 `apps/edge/internal/openai/usage_metrics_test.go`로 좁다.
- API/foundation과 broad rollout split 불필요: public contract 변경 없이 parser/test 보강만 한다.
- 서로 다른 검증 profile split 불필요: 모두 local Go test로 검증한다.
- predecessor `01`, `02`는 이전 loop에서 이미 만족된 상태로 본다. 이번 follow-up은 같은 split subtask `03+01,02_responses_passthrough`의 FAIL 수습이다.
### 범위 결정 근거
- 포함: Responses provider passthrough body/SSE usage 관측, targeted regression tests.
- 제외: provider response body 변형, sideband support 추가, Node tunnel `USAGE` frame 생산 변경, 외부 live provider smoke, contract/spec 문서 갱신.
- 이유: contract/spec 문구는 이미 Responses passthrough를 반영했고, 이번 Required는 실제 metric evidence trust 문제다.
### 빌드 등급
`local-G05`. 범위가 bounded이고 로컬 Go test로 deterministic하게 검증 가능하지만, shared passthrough writer의 usage parser를 건드리므로 G05로 둔다.
## 구현 체크리스트
- [ ] [REVIEW_REVIEW_SEULGI_RESPONSES-1] `/v1/responses` provider passthrough가 실제 body-only Responses usage JSON/SSE에서 provider-reported usage metrics를 관측하도록 고치고 regression tests를 추가한다.
- [ ] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelPassthroughObservesUsageMetrics|TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics|TestProviderTunnelPassthroughPreservesBodyAndObservesUsage' -count=1`를 실행한다.
- [ ] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1`를 실행한다.
- [ ] `GOCACHE=/tmp/iop-gocache go test ./apps/edge/... -count=1`를 실행한다.
- [ ] `GOCACHE=/tmp/iop-gocache go test ./apps/node/internal/adapters/openai_compat -count=1`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_REVIEW_SEULGI_RESPONSES-1] Responses Body Usage Metrics
#### 문제
`apps/edge/internal/openai/usage_metrics_test.go:251`는 Responses usage metric regression에서 proto `USAGE` frame을 직접 주입한다.
```go
// apps/edge/internal/openai/usage_metrics_test.go:251
frames := make(chan *iop.ProviderTunnelFrame, 5)
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerBody)}
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE,
Usage: &iop.Usage{InputTokens: 9, OutputTokens: 6},
}
```
하지만 실제 `apps/node/internal/adapters/openai_compat/openai_compat.go:132`-`170`의 tunnel relay는 body chunks와 `END`만 emit한다. Edge body parser도 `apps/edge/internal/openai/stream.go:884`-`892`에서 Chat usage keys만 읽는다.
```go
// apps/edge/internal/openai/stream.go:884
type providerUsageEnvelope struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CompletionTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
}
```
#### 해결 방법
`providerUsageEnvelope`가 Chat Completions usage와 Responses usage를 모두 관측하도록 확장한다. Body는 절대 변형하지 않는다.
After:
```go
type providerUsageEnvelope struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
PromptTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CompletionTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
InputTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"input_tokens_details"`
OutputTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"output_tokens_details"`
}
```
`recordUsage`는 Chat keys를 우선하고 없으면 Responses keys를 사용한다. `consumeSSELine`은 root `usage`와 Responses streaming event의 nested `response.usage`를 모두 확인한다. Non-streaming `observation()`은 기존 root `usage` parse가 Responses keys까지 읽게 만들면 충분하다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/openai/stream.go`: `providerUsageEnvelope`에 Responses usage fields를 추가한다.
- [ ] `apps/edge/internal/openai/stream.go`: `recordUsage`가 `prompt/completion` 또는 `input/output` usage를 `usageObservation`으로 변환한다.
- [ ] `apps/edge/internal/openai/stream.go`: streaming body parser가 Responses SSE의 nested `response.usage`를 관측한다.
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: `TestResponsesProviderTunnelPassthroughObservesUsageMetrics`를 body-only Responses usage fixture로 바꾼다.
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: streaming Responses body-only usage regression test `TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics`를 추가한다.
#### 테스트 작성
- Update `TestResponsesProviderTunnelPassthroughObservesUsageMetrics`.
- Fixture: `staticProviderTunnelFrames` 또는 equivalent body-only frames, no proto `USAGE` frame.
- Provider body: Responses JSON with `usage.input_tokens`, `usage.output_tokens`, `usage.input_tokens_details.cached_tokens`, `usage.output_tokens_details.reasoning_tokens`.
- Assertions: body preserved, request counter delta under endpoint=`responses`, response_mode=`passthrough`, model_group=request alias, usage_source=`provider_reported`; old wrong chat/empty-model counter unchanged; token counters input/output/reasoning/cached_input increment.
- Add `TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics`.
- Fixture: SSE body-only frames with `Content-Type: text/event-stream`, a Responses delta event, and a usage-bearing event such as `data: {"type":"response.completed","response":{"usage":{...}}}`.
- Assertions: raw SSE body preserved, endpoint=`responses` request counter has provider_reported source, token counters increment, no proto `USAGE` frame is used.
- Keep existing Chat passthrough usage tests green.
#### 중간 검증
```bash
GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelPassthroughObservesUsageMetrics|TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics|TestProviderTunnelPassthroughPreservesBodyAndObservesUsage' -count=1
```
Expected: all listed tests pass with fresh execution.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/stream.go` | REVIEW_REVIEW_SEULGI_RESPONSES-1 |
| `apps/edge/internal/openai/usage_metrics_test.go` | REVIEW_REVIEW_SEULGI_RESPONSES-1 |
## 최종 검증
```bash
GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelPassthroughObservesUsageMetrics|TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics|TestProviderTunnelPassthroughPreservesBodyAndObservesUsage' -count=1
GOCACHE=/tmp/iop-gocache go test ./apps/edge/internal/openai -count=1
GOCACHE=/tmp/iop-gocache go test ./apps/edge/... -count=1
GOCACHE=/tmp/iop-gocache go test ./apps/node/internal/adapters/openai_compat -count=1
```
Expected: all commands pass with fresh execution; cached output is not acceptable for the Go commands.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,8 +1,10 @@
package openai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
@ -11,6 +13,7 @@ import (
"go.uber.org/zap"
edgeservice "iop/apps/edge/internal/service"
iop "iop/proto/gen/iop"
)
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
@ -20,8 +23,74 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
}
defer r.Body.Close()
// The raw body is preserved for the provider tunnel passthrough path, which
// forwards the caller's own Responses payload (model rewritten to the served
// target) without strict normalization.
rawBody, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
return
}
// The route decision runs before strict normalization: only the
// routing-relevant envelope fields are decoded leniently so provider
// passthrough can preserve Codex/Responses unknown fields (max_output_tokens,
// tools, store, ...). Strict field validation stays on the normalized path.
env, err := decodeResponsesEnvelope(rawBody)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
dispatch, ok := s.resolveRouteDispatch(env.Model)
if !ok {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
if routeUsesProviderTunnel(dispatch) {
runMeta, workspace, err := parseOpenAIMetadata(env.Metadata)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
// Overwrite (not merge-if-absent): the authenticated caller identity must
// win over any caller-supplied metadata.iop_principal_* spoof attempt.
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
}
switch responseMode {
case responseModePassthrough:
case responseModePassthroughSideband:
if err := validateWorkspaceForRoute(dispatch, workspace); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
estimate := estimateInputTokens(string(rawBody), runMeta, nil, nil)
contextClass := classifyContext(estimate, s.longContextThreshold())
s.tunnelResponsesPassthroughSideband(w, r, env, dispatch, runMeta, rawBody, estimate, contextClass)
return
case responseModeTransformed:
writeError(w, http.StatusBadRequest, "invalid_request_error", "metadata.iop_response_mode=transformed is not supported for /v1/responses provider routes")
return
}
if err := validateWorkspaceForRoute(dispatch, workspace); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
estimate := estimateInputTokens(string(rawBody), runMeta, nil, nil)
contextClass := classifyContext(estimate, s.longContextThreshold())
s.tunnelResponsesPassthrough(w, r, env, dispatch, runMeta, rawBody, estimate, contextClass)
return
}
// Non-provider routes keep the normalized RunEvent path: strict decode,
// stream/background rejection, prompt build, and SubmitRun.
var req responsesRequest
if err := decodeResponsesRequest(json.NewDecoder(r.Body), &req); err != nil {
if err := decodeResponsesRequest(json.NewDecoder(bytes.NewReader(rawBody)), &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
@ -58,19 +127,10 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
runMeta[k] = v
}
dispatch, ok := s.resolveRouteDispatch(req.Model)
if !ok {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
if err := validateWorkspaceForRoute(dispatch, workspace); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if routeUsesProviderTunnel(dispatch) {
writeError(w, http.StatusBadRequest, "invalid_request_error", "/v1/responses is not supported for OpenAI-compatible provider model groups until raw passthrough parity is implemented")
return
}
var defaultThinkingTokenBudget int
if catalogEntry := s.findProviderPoolEntry(req.Model); catalogEntry != nil {
applyModelCatalogGenerationPolicyToResponses(&req, *catalogEntry)
@ -176,6 +236,448 @@ func decodeResponsesRequest(dec *json.Decoder, req *responsesRequest) error {
return nil
}
// decodeResponsesEnvelope leniently extracts only the routing-relevant fields
// (model, metadata, stream, background) from a /v1/responses request body. It
// does not reject unknown fields so the provider tunnel passthrough can forward
// Codex/Responses payloads verbatim; strict field validation stays on the
// normalized non-provider path.
func decodeResponsesEnvelope(rawBody []byte) (responsesEnvelope, error) {
var env responsesEnvelope
if err := json.Unmarshal(rawBody, &env); err != nil {
return responsesEnvelope{}, fmt.Errorf("invalid JSON request")
}
return env, nil
}
// tunnelResponsesPassthrough serves a /v1/responses request over the raw
// provider tunnel (SDD S04): the caller's original body is forwarded with only
// the model field rewritten to the served target, provider auth is injected
// from the configured request header, and provider status/headers/body bytes
// are relayed to the caller unmodified. Streaming is honored when the caller
// requested it. No IOP sideband fields, model-echo rewrite, or output-token
// normalization are applied.
func (s *Server) tunnelResponsesPassthrough(w http.ResponseWriter, r *http.Request, env responsesEnvelope, dispatch routeDispatch, runMeta map[string]string, rawBody []byte, estimate int, contextClass string) {
providerAuthHeaders, err := s.providerTunnelAuthHeaders(r)
if err != nil {
// Missing required provider auth is rejected before dispatch; the raw
// token is never echoed into the error surface.
writeError(w, http.StatusBadRequest, "invalid_request_error", "provider auth token is required")
return
}
metadata := make(map[string]string, len(runMeta)+5)
for k, v := range runMeta {
metadata[k] = v
}
metadata["openai_model"] = env.Model
metadata["openai_stream"] = strconv.FormatBool(env.Stream)
metadata[responseModeMetadataKey] = responseModePassthrough
metadata["estimated_input_tokens"] = strconv.Itoa(estimate)
metadata["context_class"] = contextClass
tunnelReq := edgeservice.SubmitProviderTunnelRequest{
NodeRef: dispatch.NodeRef,
ModelGroupKey: strings.TrimSpace(env.Model),
Adapter: dispatch.Adapter,
Target: dispatch.Target,
SessionID: dispatch.SessionID,
Method: http.MethodPost,
Path: "/v1/responses",
Headers: providerAuthHeaders,
BuildBody: func(target string) ([]byte, error) {
return rewriteResponsesModel(rawBody, target)
},
Stream: env.Stream,
TimeoutSec: dispatch.TimeoutSec,
MaxQueue: dispatch.MaxQueue,
QueueTimeoutMS: dispatch.QueueTimeoutMS,
Metadata: metadata,
EstimatedInputTokens: estimate,
ContextClass: contextClass,
ProviderPool: dispatch.ProviderPool,
}
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(env.Model), usageEndpointResponses, responseModePassthrough)
handle, err := s.service.SubmitProviderTunnel(r.Context(), tunnelReq)
if err != nil {
emitUsageMetrics(metricLabels, usageStatusForError(err), usageObservation{})
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
return
}
defer handle.Close()
s.logger.Info("openai responses passthrough dispatch",
zap.String("run_id", handle.Dispatch().RunID),
zap.String("node_id", handle.Dispatch().NodeID),
zap.String("model_group", handle.Dispatch().ModelGroupKey),
zap.String("adapter", handle.Dispatch().Adapter),
zap.String("target", handle.Dispatch().Target),
zap.Bool("stream", env.Stream),
zap.Int("estimated_input_tokens", handle.Dispatch().EstimatedInputTokens),
zap.String("context_class", handle.Dispatch().ContextClass),
zap.String("queue_reason", handle.Dispatch().QueueReason),
)
// requestModel is left empty so the shared tunnel writer relays provider
// bytes verbatim without rewriting the provider-echoed model back to a
// caller alias: Responses passthrough prefers provider-original bytes.
// metricLabels carries endpoint=responses, response_mode=passthrough, and
// the request model alias so success/error usage is attributed correctly.
s.writeProviderTunnelResponse(w, r, handle, env.Stream, "", metricLabels)
}
// tunnelResponsesPassthroughSideband serves an explicit /v1/responses
// passthrough+sideband request. The provider request remains raw passthrough
// with model rewrite only; the response is extended after the provider returns.
// Non-streaming JSON object responses receive sideband metadata under the
// top-level `metadata` field. Streaming responses interleave `event:
// iop.sideband` events.
func (s *Server) tunnelResponsesPassthroughSideband(w http.ResponseWriter, r *http.Request, env responsesEnvelope, dispatch routeDispatch, runMeta map[string]string, rawBody []byte, estimate int, contextClass string) {
providerAuthHeaders, err := s.providerTunnelAuthHeaders(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", "provider auth token is required")
return
}
metadata := make(map[string]string, len(runMeta)+5)
for k, v := range runMeta {
metadata[k] = v
}
metadata["openai_model"] = env.Model
metadata["openai_stream"] = strconv.FormatBool(env.Stream)
metadata[responseModeMetadataKey] = responseModePassthroughSideband
metadata["estimated_input_tokens"] = strconv.Itoa(estimate)
metadata["context_class"] = contextClass
tunnelReq := edgeservice.SubmitProviderTunnelRequest{
NodeRef: dispatch.NodeRef,
ModelGroupKey: strings.TrimSpace(env.Model),
Adapter: dispatch.Adapter,
Target: dispatch.Target,
SessionID: dispatch.SessionID,
Method: http.MethodPost,
Path: "/v1/responses",
Headers: providerAuthHeaders,
BuildBody: func(target string) ([]byte, error) {
return rewriteResponsesModel(rawBody, target)
},
Stream: env.Stream,
TimeoutSec: dispatch.TimeoutSec,
MaxQueue: dispatch.MaxQueue,
QueueTimeoutMS: dispatch.QueueTimeoutMS,
Metadata: metadata,
EstimatedInputTokens: estimate,
ContextClass: contextClass,
ProviderPool: dispatch.ProviderPool,
}
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(env.Model), usageEndpointResponses, responseModePassthroughSideband)
handle, err := s.service.SubmitProviderTunnel(r.Context(), tunnelReq)
if err != nil {
emitUsageMetrics(metricLabels, usageStatusForError(err), usageObservation{})
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
return
}
defer handle.Close()
s.logger.Info("openai responses sideband dispatch",
zap.String("run_id", handle.Dispatch().RunID),
zap.String("node_id", handle.Dispatch().NodeID),
zap.String("model_group", handle.Dispatch().ModelGroupKey),
zap.String("adapter", handle.Dispatch().Adapter),
zap.String("target", handle.Dispatch().Target),
zap.Bool("stream", env.Stream),
zap.Int("estimated_input_tokens", handle.Dispatch().EstimatedInputTokens),
zap.String("context_class", handle.Dispatch().ContextClass),
zap.String("queue_reason", handle.Dispatch().QueueReason),
)
if env.Stream {
s.writeResponsesProviderTunnelSidebandStream(w, r, handle, metricLabels)
return
}
s.writeResponsesProviderTunnelSidebandResponse(w, r, handle, metricLabels)
}
// rewriteResponsesModel replaces only the model field of the caller's original
// /v1/responses request JSON so the provider receives its served model name.
// Every other field (input, instructions, tools, max_output_tokens, and any
// Codex/Responses-specific field) is forwarded without IOP rewriting. An empty
// target leaves the body untouched; invalid JSON is rejected.
func rewriteResponsesModel(rawBody []byte, target string) ([]byte, error) {
if strings.TrimSpace(target) == "" {
return rawBody, nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(rawBody, &raw); err != nil {
return nil, fmt.Errorf("invalid JSON request")
}
modelJSON, err := json.Marshal(target)
if err != nil {
return nil, err
}
raw["model"] = modelJSON
return json.Marshal(raw)
}
const responsesSidebandObject = "iop.responses.sideband"
type responsesSidebandPayload struct {
Object string `json:"object"`
Metadata map[string]any `json:"metadata"`
}
func responsesSidebandMetadata() map[string]any {
return map[string]any{
responseModeMetadataKey: responseModePassthroughSideband,
}
}
func responsesSidebandPayloadBytes() []byte {
payload, err := json.Marshal(responsesSidebandPayload{
Object: responsesSidebandObject,
Metadata: responsesSidebandMetadata(),
})
if err != nil {
return nil
}
return payload
}
func injectResponsesSidebandMetadata(body []byte) []byte {
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil {
return body
}
if obj == nil {
return body
}
metadata := map[string]any{}
if raw, ok := obj["metadata"]; ok && len(raw) > 0 && string(raw) != "null" {
_ = json.Unmarshal(raw, &metadata)
if metadata == nil {
metadata = map[string]any{}
}
}
for k, v := range responsesSidebandMetadata() {
metadata[k] = v
}
encodedMetadata, err := json.Marshal(metadata)
if err != nil {
return body
}
obj["metadata"] = encodedMetadata
rewritten, err := json.Marshal(obj)
if err != nil {
return body
}
return rewritten
}
func (s *Server) writeResponsesProviderTunnelSidebandStream(w http.ResponseWriter, r *http.Request, handle edgeservice.ProviderTunnelResult, metricLabels usageLabels) {
frames := handle.Stream().Frames
if frames == nil {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", "tunnel stream unavailable")
return
}
flusher, _ := w.(http.Flusher)
timer := time.NewTimer(handle.WaitTimeout())
defer timer.Stop()
assembler := &providerChatAssembler{streaming: true}
wroteHeader := false
metricStatus := usageStatusError
var protoObs usageObservation
tail := ""
writeSideband := func() {
payload := responsesSidebandPayloadBytes()
if len(payload) == 0 {
return
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", sidebandSSEEventName, payload)
if flusher != nil {
flusher.Flush()
}
}
defer func() {
emitUsageMetrics(metricLabels, metricStatus, mergeUsageObservation(assembler.usageObservation(), protoObs))
}()
for {
select {
case <-r.Context().Done():
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), r.Context().Err())
metricStatus = usageStatusCancel
return
case <-timer.C:
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), errRunTimedOut)
metricStatus = usageStatusCancel
if !wroteHeader {
writeError(w, http.StatusBadGateway, "run_error", errRunTimedOut.Error())
}
return
case frame, ok := <-frames:
if !ok {
if !wroteHeader {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", "tunnel stream closed before provider response")
}
return
}
switch frame.GetKind() {
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START:
if wroteHeader {
continue
}
copyProviderResponseHeaders(w.Header(), frame.GetHeaders())
w.Header().Del("Content-Length")
w.Header().Set(responseModeHeaderName, responseModePassthroughSideband)
status := int(frame.GetStatusCode())
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
wroteHeader = true
writeSideband()
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY:
body := frame.GetBody()
if len(body) == 0 {
continue
}
if !wroteHeader {
w.Header().Set(responseModeHeaderName, responseModePassthroughSideband)
w.WriteHeader(http.StatusOK)
wroteHeader = true
writeSideband()
}
if _, err := w.Write(body); err != nil {
s.sendCancelRun(handle.Dispatch())
metricStatus = usageStatusCancel
return
}
assembler.Write(body)
tail = sseTail(tail, body)
if flusher != nil {
flusher.Flush()
}
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR:
msg := frame.GetError()
if msg == "" {
msg = "provider tunnel failed"
}
if !wroteHeader {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", msg)
return
}
s.logger.Warn("openai responses sideband tunnel error after response start",
zap.String("run_id", handle.Dispatch().RunID),
zap.String("error", msg),
)
return
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE:
protoObs = mergeUsageObservation(protoObs, usageObservationFromProtoUsage(frame.GetUsage()))
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END:
if !wroteHeader {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", "tunnel ended before provider response")
return
}
if tail != "" && !strings.HasSuffix(tail, "\n\n") {
fmt.Fprint(w, "\n\n")
if flusher != nil {
flusher.Flush()
}
}
metricStatus = usageStatusSuccess
return
}
}
}
}
func (s *Server) writeResponsesProviderTunnelSidebandResponse(w http.ResponseWriter, r *http.Request, handle edgeservice.ProviderTunnelResult, metricLabels usageLabels) {
frames := handle.Stream().Frames
if frames == nil {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", "tunnel stream unavailable")
return
}
timer := time.NewTimer(handle.WaitTimeout())
defer timer.Stop()
assembler := &providerChatAssembler{}
var body bytes.Buffer
providerStatus := 0
providerHeaders := map[string]string{}
metricStatus := usageStatusError
var protoObs usageObservation
defer func() {
// Non-streaming assembler usage is parsed lazily from the buffered JSON
// body, so force parsing before emitting metrics.
_ = assembler.observation()
emitUsageMetrics(metricLabels, metricStatus, mergeUsageObservation(assembler.usageObservation(), protoObs))
}()
for {
select {
case <-r.Context().Done():
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), r.Context().Err())
metricStatus = usageStatusCancel
return
case <-timer.C:
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), errRunTimedOut)
metricStatus = usageStatusCancel
writeError(w, http.StatusBadGateway, "run_error", errRunTimedOut.Error())
return
case frame, ok := <-frames:
if !ok {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", "tunnel stream closed before provider response")
return
}
switch frame.GetKind() {
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START:
if providerStatus != 0 {
continue
}
providerStatus = int(frame.GetStatusCode())
if providerStatus == 0 {
providerStatus = http.StatusOK
}
providerHeaders = frame.GetHeaders()
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY:
body.Write(frame.GetBody())
assembler.Write(frame.GetBody())
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR:
msg := frame.GetError()
if msg == "" {
msg = "provider tunnel failed"
}
writeError(w, http.StatusBadGateway, "provider_tunnel_error", msg)
return
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE:
protoObs = mergeUsageObservation(protoObs, usageObservationFromProtoUsage(frame.GetUsage()))
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END:
if providerStatus == 0 {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", "tunnel ended before provider response")
return
}
copyProviderResponseHeaders(w.Header(), providerHeaders)
w.Header().Del("Content-Length")
w.Header().Set(responseModeHeaderName, responseModePassthroughSideband)
w.WriteHeader(providerStatus)
if _, err := w.Write(injectResponsesSidebandMetadata(body.Bytes())); err != nil {
s.sendCancelRun(handle.Dispatch())
metricStatus = usageStatusCancel
return
}
metricStatus = usageStatusSuccess
return
}
}
}
}
func (s *Server) completeResponse(w http.ResponseWriter, r *http.Request, req responsesRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(req.Model), usageEndpointResponses, "normalized")
text, reasoning, _, _, usage, _, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())

View file

@ -5704,14 +5704,26 @@ func TestChatCompletionsLegacyRouteSetsProviderPoolFalse(t *testing.T) {
}
}
// TestResponsesProviderPoolDispatch verifies that /v1/responses does not send
// provider-pool models through the normalized RunEvent path before raw parity
// exists.
func TestResponsesProviderPoolDispatch(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}}
// responsesProviderTunnelServer builds a server whose catalog routes
// "pool-model" through the OpenAI-compatible provider tunnel, wired to the given
// provider frames and served target.
func responsesProviderTunnelServer(frames chan *iop.ProviderTunnelFrame, servedTarget string) (*Server, *fakeRunService) {
fake := &fakeRunService{tunnelFrames: frames, tunnelServedTarget: servedTarget}
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
srv.SetModelCatalog([]config.ModelCatalogEntry{
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
})
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.
func TestResponsesProviderPoolDispatch(t *testing.T) {
fake := &fakeRunService{
tunnelFrames: staticProviderTunnelFrames(`{"id":"resp-1","object":"response"}`),
tunnelServedTarget: "model-a",
}
catalog := []config.ModelCatalogEntry{
{ID: "prov-vllm:model-a", Providers: map[string]string{"prov-vllm": "model-a"}},
}
@ -5725,22 +5737,32 @@ func TestResponsesProviderPoolDispatch(t *testing.T) {
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusBadRequest {
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "/v1/responses is not supported for OpenAI-compatible provider model groups until raw passthrough parity is implemented") {
t.Fatalf("expected provider responses rejection, got %s", w.Body.String())
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("provider-pool /v1/responses must not call SubmitRun, got %d calls", len(fake.reqsSnapshot()))
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
if reqs[0].Path != "/v1/responses" {
t.Fatalf("tunnel path: got %q want /v1/responses", reqs[0].Path)
}
if reqs[0].Method != http.MethodPost {
t.Fatalf("tunnel method: got %q want POST", reqs[0].Method)
}
}
func TestResponsesProviderPoolAppliesGenerationPolicy(t *testing.T) {
fake := &fakeRunService{events: bufferedRunEvents(
&iop.RunEvent{Type: "delta", Delta: "ok"},
&iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}},
)}
// TestResponsesProviderPoolPreservesRawOutputTokens verifies that raw
// passthrough preserves the caller's Responses-shaped max_output_tokens and does
// not inject the Chat-shaped max_tokens or generation policy.
func TestResponsesProviderPoolPreservesRawOutputTokens(t *testing.T) {
fake := &fakeRunService{
tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`),
tunnelServedTarget: "Ornith-1.0-35B",
}
catalog := []config.ModelCatalogEntry{{
ID: "ornith:35b",
DefaultMaxTokens: 32768,
@ -5759,22 +5781,42 @@ func TestResponsesProviderPoolAppliesGenerationPolicy(t *testing.T) {
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusBadRequest {
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "/v1/responses is not supported for OpenAI-compatible provider model groups until raw passthrough parity is implemented") {
t.Fatalf("expected provider responses rejection, got %s", w.Body.String())
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("provider-pool /v1/responses must not call SubmitRun, got %d calls", len(fake.reqsSnapshot()))
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
if providerReq["model"] != "Ornith-1.0-35B" {
t.Fatalf("served model rewrite not applied: %+v", providerReq["model"])
}
if providerReq["max_output_tokens"].(float64) != 4096 {
t.Fatalf("caller max_output_tokens must be preserved: %+v", providerReq)
}
if _, ok := providerReq["max_tokens"]; ok {
t.Fatalf("passthrough must not inject Chat-shaped max_tokens: %+v", providerReq)
}
if _, ok := providerReq["thinking_token_budget"]; ok {
t.Fatalf("passthrough must not inject thinking_token_budget: %+v", providerReq)
}
}
func TestResponsesProviderPoolThinkingPolicyOverridesStrictOutputDisable(t *testing.T) {
fake := &fakeRunService{events: bufferedRunEvents(
&iop.RunEvent{Type: "delta", Delta: "ok"},
&iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}},
)}
// TestResponsesProviderPoolPassthroughOmitsThinkingPolicy verifies that raw
// passthrough forwards the caller body as-is under strict output: the
// normalized-path thinking policy is not injected into the provider body.
func TestResponsesProviderPoolPassthroughOmitsThinkingPolicy(t *testing.T) {
fake := &fakeRunService{
tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`),
tunnelServedTarget: "Ornith-1.0-35B",
}
catalog := []config.ModelCatalogEntry{{
ID: "ornith:35b",
DefaultThinkingTokenBudget: 8192,
@ -5790,15 +5832,368 @@ func TestResponsesProviderPoolThinkingPolicyOverridesStrictOutputDisable(t *test
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusBadRequest {
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "/v1/responses is not supported for OpenAI-compatible provider model groups until raw passthrough parity is implemented") {
t.Fatalf("expected provider responses rejection, got %s", w.Body.String())
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("provider-pool /v1/responses must not call SubmitRun, got %d calls", len(fake.reqsSnapshot()))
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
if _, ok := providerReq["think"]; ok {
t.Fatalf("passthrough must not inject think: %+v", providerReq)
}
if _, ok := providerReq["thinking_token_budget"]; ok {
t.Fatalf("passthrough must not inject thinking_token_budget: %+v", providerReq)
}
}
// TestResponsesProviderTunnelAllowsUnknownFields verifies that Codex/Responses
// unknown fields (tools, parallel_tool_calls, store) are accepted on the
// provider passthrough route and preserved in the forwarded tunnel body.
func TestResponsesProviderTunnelAllowsUnknownFields(t *testing.T) {
srv, fake := responsesProviderTunnelServer(staticProviderTunnelFrames(`{"ok":true}`), "served-model")
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"pool-model",
"input":"hi",
"tools":[{"type":"web_search"}],
"parallel_tool_calls":true,
"store":false
}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("provider passthrough must accept unknown fields: got %d body=%s", w.Code, w.Body.String())
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
if _, ok := providerReq["tools"]; !ok {
t.Fatalf("tools must be preserved on passthrough: %+v", providerReq)
}
if providerReq["parallel_tool_calls"] != true {
t.Fatalf("parallel_tool_calls must be preserved: %+v", providerReq)
}
if providerReq["store"] != false {
t.Fatalf("store must be preserved: %+v", providerReq)
}
}
// TestResponsesProviderTunnelSubmitsResponsesPath verifies the passthrough
// dispatches a provider tunnel POST to /v1/responses and never calls SubmitRun.
func TestResponsesProviderTunnelSubmitsResponsesPath(t *testing.T) {
srv, fake := responsesProviderTunnelServer(staticProviderTunnelFrames(`{"ok":true}`), "served-model")
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi"}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("responses passthrough must not call SubmitRun, got %d", len(fake.reqsSnapshot()))
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
if reqs[0].Path != "/v1/responses" {
t.Fatalf("tunnel path: got %q want /v1/responses", reqs[0].Path)
}
if reqs[0].Method != http.MethodPost {
t.Fatalf("tunnel method: got %q want POST", reqs[0].Method)
}
}
// TestResponsesProviderTunnelRewritesOnlyModel verifies the caller model alias
// is rewritten to the provider-served model while max_output_tokens, tools, and
// arbitrary fields are preserved verbatim.
func TestResponsesProviderTunnelRewritesOnlyModel(t *testing.T) {
srv, fake := responsesProviderTunnelServer(staticProviderTunnelFrames(`{"ok":true}`), "served-model")
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"pool-model",
"input":"hi",
"max_output_tokens":123,
"tools":[{"type":"web_search"}],
"custom_field":"keep-me"
}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
if providerReq["model"] != "served-model" {
t.Fatalf("model must be rewritten to served target: %+v", providerReq["model"])
}
if providerReq["max_output_tokens"].(float64) != 123 {
t.Fatalf("max_output_tokens must be preserved: %+v", providerReq)
}
if _, ok := providerReq["tools"]; !ok {
t.Fatalf("tools must be preserved: %+v", providerReq)
}
if providerReq["custom_field"] != "keep-me" {
t.Fatalf("arbitrary fields must be preserved: %+v", providerReq)
}
}
// TestResponsesProviderTunnelForwardsProviderAuthHeader verifies the provider
// auth helper is applied to the Responses tunnel: a raw request-header token is
// forwarded as the configured target header, and a missing required token is
// rejected before dispatch.
func TestResponsesProviderTunnelForwardsProviderAuthHeader(t *testing.T) {
auth := config.EdgeOpenAIProviderAuthConf{
Enabled: true,
FromHeader: "X-IOP-Provider-Authorization",
TargetHeader: "Authorization",
Scheme: "Bearer",
Required: true,
}
t.Run("forwards raw token with scheme", func(t *testing.T) {
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi"}`))
req.Header.Set("X-IOP-Provider-Authorization", "user-token")
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
if got := reqs[0].Headers["Authorization"]; got != "Bearer user-token" {
t.Fatalf("provider Authorization header: got %q want %q", got, "Bearer user-token")
}
})
t.Run("missing required token rejected", func(t *testing.T) {
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi"}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
t.Fatalf("missing required provider auth must not dispatch: got %d tunnel requests", got)
}
})
}
// TestResponsesProviderTunnelStreaming verifies a stream:true Responses request
// sets Stream=true on the tunnel dispatch and relays raw SSE bytes verbatim.
func TestResponsesProviderTunnelStreaming(t *testing.T) {
frames := make(chan *iop.ProviderTunnelFrame, 4)
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
StatusCode: 200,
Headers: map[string]string{"Content-Type": "text/event-stream"},
}
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
Body: []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n"),
}
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
Body: []byte("data: [DONE]\n\n"),
}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
close(frames)
srv, fake := responsesProviderTunnelServer(frames, "served-model")
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi","stream":true}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
if !reqs[0].Stream {
t.Fatalf("tunnel request Stream must be true for a stream:true responses request")
}
body := w.Body.String()
if !strings.Contains(body, "response.output_text.delta") || !strings.Contains(body, "[DONE]") {
t.Fatalf("SSE body must be relayed verbatim, got %q", body)
}
}
// 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) {
cases := []struct {
name string
metadata string
wantStatus int
wantTunnel bool
wantMessage string
}{
{
name: "explicit passthrough uses responses tunnel",
metadata: `"metadata":{"iop_response_mode":"passthrough"}`,
wantStatus: http.StatusOK,
wantTunnel: true,
},
{
name: "sideband mode uses responses tunnel",
metadata: `"metadata":{"iop_response_mode":"passthrough+sideband"}`,
wantStatus: http.StatusOK,
wantTunnel: true,
},
{
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",
},
{
name: "unknown mode fails fast",
metadata: `"metadata":{"iop_response_mode":"rawish"}`,
wantStatus: http.StatusBadRequest,
wantMessage: "metadata.iop_response_mode must be one of passthrough, passthrough+sideband, or transformed",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv, fake := responsesProviderTunnelServer(staticProviderTunnelFrames(`{"ok":true}`), "served-model")
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(fmt.Sprintf(`{
"model":"pool-model",
"input":"hi",
%s
}`, tc.metadata)))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: got %d want %d body=%s", w.Code, tc.wantStatus, w.Body.String())
}
if tc.wantMessage != "" && !strings.Contains(w.Body.String(), tc.wantMessage) {
t.Fatalf("body must contain %q, got %s", tc.wantMessage, w.Body.String())
}
gotTunnel := len(fake.tunnelReqsSnapshot()) > 0
if gotTunnel != tc.wantTunnel {
t.Fatalf("tunnel dispatch: got %v want %v", gotTunnel, tc.wantTunnel)
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("responses provider route must not call SubmitRun, got %d", len(fake.reqsSnapshot()))
}
})
}
}
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")
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"pool-model",
"input":"hi",
"metadata":{"iop_response_mode":"passthrough+sideband"}
}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
if got := reqs[0].Metadata[responseModeMetadataKey]; got != responseModePassthroughSideband {
t.Fatalf("tunnel response mode metadata: got %q want %q", got, responseModePassthroughSideband)
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON: %v body=%s", err, w.Body.String())
}
if body["id"] != "resp-1" || body["output_text"] != "hi" {
t.Fatalf("provider response fields must be preserved: %+v", body)
}
metadata, ok := body["metadata"].(map[string]any)
if !ok {
t.Fatalf("metadata must be an object: %+v", body["metadata"])
}
if metadata["request_id"] != "req-1" {
t.Fatalf("provider metadata must be preserved: %+v", metadata)
}
if metadata[responseModeMetadataKey] != responseModePassthroughSideband {
t.Fatalf("sideband response mode must be injected into metadata: %+v", metadata)
}
}
func TestResponsesProviderTunnelSidebandStreamingInjectsEvent(t *testing.T) {
frames := make(chan *iop.ProviderTunnelFrame, 4)
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
StatusCode: 200,
Headers: map[string]string{"Content-Type": "text/event-stream"},
}
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
Body: []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n"),
}
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
Body: []byte("data: [DONE]\n\n"),
}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
close(frames)
srv, fake := responsesProviderTunnelServer(frames, "served-model")
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"pool-model",
"input":"hi",
"stream":true,
"metadata":{"iop_response_mode":"passthrough+sideband"}
}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 || !reqs[0].Stream {
t.Fatalf("expected one streaming tunnel dispatch, got %#v", reqs)
}
body := w.Body.String()
for _, marker := range []string{
"event: iop.sideband",
`"object":"iop.responses.sideband"`,
`"iop_response_mode":"passthrough+sideband"`,
"response.output_text.delta",
"data: [DONE]",
} {
if !strings.Contains(body, marker) {
t.Fatalf("streaming sideband body must contain %q, got %q", marker, body)
}
}
}
// TestChatCompletionsProviderPoolFallsBackToLegacyRoute verifies that when the

View file

@ -333,7 +333,8 @@ func (s *Server) tunnelChatCompletionPassthrough(w http.ResponseWriter, r *http.
return
}
defer handle.Close()
s.writeProviderTunnelResponse(w, r, handle, req.Stream, req.Model)
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(req.Model), usageEndpointChatCompletions, responseModePassthrough)
s.writeProviderTunnelResponse(w, r, handle, req.Stream, req.Model, metricLabels)
}
// tunnelChatCompletionPassthroughSideband serves a Chat Completions request
@ -455,8 +456,12 @@ func (s *Server) submitChatCompletionTunnel(w http.ResponseWriter, r *http.Reque
// writeProviderTunnelResponse relays ordered tunnel frames to the HTTP caller.
// The response-start frame sets status/headers, body frames are written and
// flushed in order, and END terminates the response. Caller disconnect and
// wait timeout propagate cancellation to the Node cancel path.
func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Request, handle edgeservice.ProviderTunnelResult, reqStream bool, requestModel string) {
// wait timeout propagate cancellation to the Node cancel path. The caller
// supplies metricLabels so success/error/cancel usage is attributed to the
// right endpoint (Chat vs Responses) and model_group; the writer never
// recomputes labels from requestModel, which is reserved for model-echo
// rewriting only.
func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Request, handle edgeservice.ProviderTunnelResult, reqStream bool, requestModel string, metricLabels usageLabels) {
frames := handle.Stream().Frames
if frames == nil {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", "tunnel stream unavailable")
@ -468,7 +473,6 @@ func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Requ
assembler := &providerChatAssembler{streaming: reqStream}
modelRewriter := newProviderModelRewriter(reqStream, requestModel)
metricLabels := s.usageLabelsFor(r.Context(), strings.TrimSpace(requestModel), usageEndpointChatCompletions, responseModePassthrough)
wroteHeader := false
bodyBytes := 0
@ -875,33 +879,58 @@ type providerChatDeltaEnvelope struct {
} `json:"tool_calls"`
}
// providerUsageEnvelope matches the OpenAI-compatible usage object, including
// the optional detail objects that carry reasoning and cached-input tokens.
// providerUsageEnvelope matches the OpenAI-compatible usage object from
// both Chat Completions and Responses. Chat Completions uses prompt_tokens/
// completion_tokens; Responses uses input_tokens/output_tokens. The optional
// detail objects carry reasoning and cached-input tokens for both formats.
type providerUsageEnvelope struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
PromptTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CompletionTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
InputTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"input_tokens_details"`
OutputTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"output_tokens_details"`
}
// recordUsage stores the latest provider-reported usage. The provider tunnel
// body is never mutated; this observation feeds Edge-internal metrics only
// (SDD S05/D04).
// (SDD S05/D04). Chat Completions keys (prompt_tokens/completion_tokens) take
// precedence; if absent, Responses keys (input_tokens/output_tokens) are used.
func (a *providerChatAssembler) recordUsage(u *providerUsageEnvelope) {
if u == nil {
return
}
a.usage.inputTokens = u.PromptTokens
a.usage.outputTokens = u.CompletionTokens
if d := u.PromptTokensDetails; d != nil {
a.usage.cachedInputTokens = d.CachedTokens
// Prefer Chat Completions keys; fall back to Responses keys.
if u.PromptTokens != 0 {
a.usage.inputTokens = u.PromptTokens
a.usage.outputTokens = u.CompletionTokens
if d := u.PromptTokensDetails; d != nil {
a.usage.cachedInputTokens = d.CachedTokens
}
if d := u.CompletionTokensDetails; d != nil {
a.usage.reasoningTokens = d.ReasoningTokens
}
return
}
if d := u.CompletionTokensDetails; d != nil {
a.usage.reasoningTokens = d.ReasoningTokens
if u.InputTokens != 0 {
a.usage.inputTokens = u.InputTokens
a.usage.outputTokens = u.OutputTokens
if d := u.InputTokensDetails; d != nil {
a.usage.cachedInputTokens = d.CachedTokens
}
if d := u.OutputTokensDetails; d != nil {
a.usage.reasoningTokens = d.ReasoningTokens
}
}
}
@ -964,6 +993,22 @@ func (a *providerChatAssembler) consumeSSELine(line string) {
a.consumeDelta(choice.Delta)
}
a.recordUsage(chunk.Usage)
// Responses streaming: nested response.usage from events like
// response.completed. This captures provider-reported token usage from
// the Responses API SSE payload (SDD S05/D04).
if a.usage.inputTokens == 0 && a.usage.outputTokens == 0 {
var respEvent struct {
Response struct {
Usage *providerUsageEnvelope `json:"usage"`
} `json:"response"`
}
if err := json.Unmarshal([]byte(payload), &respEvent); err == nil {
if respEvent.Response.Usage != nil {
a.recordUsage(respEvent.Response.Usage)
}
}
}
}
func (a *providerChatAssembler) consumeDelta(delta providerChatDeltaEnvelope) {

View file

@ -1933,6 +1933,17 @@ type responsesRequest struct {
TopP *float64 `json:"top_p,omitempty"`
}
// responsesEnvelope holds the routing-relevant fields decoded leniently from a
// /v1/responses request body before strict normalization. Unknown fields are
// ignored so the provider tunnel passthrough can forward Codex/Responses
// payloads verbatim.
type responsesEnvelope struct {
Model string `json:"model"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Stream bool `json:"stream"`
Background bool `json:"background,omitempty"`
}
func (req responsesRequest) providerOptions() map[string]any {
options := map[string]any{}
if req.MaxOutputTokens != nil {

View file

@ -236,6 +236,272 @@ func TestProviderTunnelPassthroughPreservesBodyAndObservesUsage(t *testing.T) {
}
}
// TestResponsesProviderTunnelPassthroughObservesUsageMetrics verifies that a
// successful /v1/responses provider passthrough attributes its usage metrics to
// endpoint=responses, response_mode=passthrough, and model_group=request alias,
// while the response body stays provider-original. It is a regression test for
// the shared tunnel writer previously recomputing chat.completions labels with an
// empty model_group for the Responses path (REVIEW_SEULGI_RESPONSES-1).
func TestResponsesProviderTunnelPassthroughObservesUsageMetrics(t *testing.T) {
const rawToken = "sk-responses-passthrough-token"
const edgeID = "edge-responses-passthrough-usage"
const model = "pool-model"
// Body-only Responses JSON: no separate USAGE frame. This matches how the
// real Node tunnel relay returns usage as part of the body JSON.
providerBody := `{"id":"resp-1","object":"response","output_text":"hi there","usage":{"input_tokens":9,"output_tokens":6,"input_tokens_details":{"cached_tokens":3},"output_tokens_details":{"reasoning_tokens":4}}}`
frames := make(chan *iop.ProviderTunnelFrame, 5)
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerBody)}
// NOTE: No proto USAGE frame — usage is only in the body JSON.
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"}}})
labels := usageLabels{
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
modelGroup: model, endpoint: usageEndpointResponses, responseMode: responseModePassthrough,
}
reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "user:alice", "alice", "iop-tok-alice", model,
usageEndpointResponses, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported,
))
// The pre-fix bug recorded success under endpoint=chat.completions with an
// empty model_group; assert that mislabeled counter does not move.
wrongBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "user:alice", "alice", "iop-tok-alice", "",
usageEndpointChatCompletions, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported,
))
before := map[string]float64{
tokenTypeInput: requestTokenValue(t, labels, tokenTypeInput),
tokenTypeOutput: requestTokenValue(t, labels, tokenTypeOutput),
tokenTypeReasoning: requestTokenValue(t, labels, tokenTypeReasoning),
tokenTypeCachedInput: requestTokenValue(t, labels, tokenTypeCachedInput),
}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"pool-model",
"input":"hello"
}`))
req.Header.Set("Authorization", "Bearer "+rawToken)
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if w.Body.String() != providerBody {
t.Fatalf("responses passthrough body must be preserved byte-for-byte.\n got: %s\nwant: %s", w.Body.String(), providerBody)
}
reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "user:alice", "alice", "iop-tok-alice", model,
usageEndpointResponses, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported,
))
if reqAfter-reqBefore != 1 {
t.Fatalf("requests_total responses/success: got delta %v, want 1", reqAfter-reqBefore)
}
wrongAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "user:alice", "alice", "iop-tok-alice", "",
usageEndpointChatCompletions, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported,
))
if wrongAfter-wrongBefore != 0 {
t.Fatalf("mislabeled chat.completions/empty-model counter must not move: got delta %v, want 0", wrongAfter-wrongBefore)
}
// Body-only Responses usage (no proto USAGE frame): input, output, reasoning,
// cached_input all come from the body JSON (REVIEW_REVIEW_SEULGI_RESPONSES-1).
for tokenType, want := range map[string]float64{
tokenTypeInput: 9,
tokenTypeOutput: 6,
tokenTypeReasoning: 4,
tokenTypeCachedInput: 3,
} {
got := requestTokenValue(t, labels, tokenType) - before[tokenType]
if got != want {
t.Fatalf("responses token_type %s: got delta %v, want %v", tokenType, got, want)
}
}
}
// TestResponsesProviderTunnelSidebandObservesUsageMetrics verifies that a
// successful /v1/responses provider passthrough+sideband response records usage
// under endpoint=responses and response_mode=passthrough+sideband.
func TestResponsesProviderTunnelSidebandObservesUsageMetrics(t *testing.T) {
const rawToken = "sk-responses-sideband-token"
const edgeID = "edge-responses-sideband-usage"
const model = "pool-model"
providerBody := `{"id":"resp-1","object":"response","output_text":"hi","metadata":{"request_id":"req-1"},"usage":{"input_tokens":8,"output_tokens":5,"input_tokens_details":{"cached_tokens":2},"output_tokens_details":{"reasoning_tokens":1}}}`
frames := make(chan *iop.ProviderTunnelFrame, 4)
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerBody)}
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"}}})
labels := usageLabels{
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
modelGroup: model, endpoint: usageEndpointResponses, responseMode: responseModePassthroughSideband,
}
reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "user:alice", "alice", "iop-tok-alice", model,
usageEndpointResponses, responseModePassthroughSideband, usageStatusSuccess, usageSourceProviderReported,
))
before := map[string]float64{
tokenTypeInput: requestTokenValue(t, labels, tokenTypeInput),
tokenTypeOutput: requestTokenValue(t, labels, tokenTypeOutput),
tokenTypeReasoning: requestTokenValue(t, labels, tokenTypeReasoning),
tokenTypeCachedInput: requestTokenValue(t, labels, tokenTypeCachedInput),
}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"pool-model",
"input":"hello",
"metadata":{"iop_response_mode":"passthrough+sideband"}
}`))
req.Header.Set("Authorization", "Bearer "+rawToken)
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"iop_response_mode":"passthrough+sideband"`) {
t.Fatalf("sideband response must inject metadata marker, got %s", w.Body.String())
}
reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "user:alice", "alice", "iop-tok-alice", model,
usageEndpointResponses, responseModePassthroughSideband, usageStatusSuccess, usageSourceProviderReported,
))
if reqAfter-reqBefore != 1 {
t.Fatalf("requests_total responses sideband/success: got delta %v, want 1", reqAfter-reqBefore)
}
for tokenType, want := range map[string]float64{
tokenTypeInput: 8,
tokenTypeOutput: 5,
tokenTypeReasoning: 1,
tokenTypeCachedInput: 2,
} {
got := requestTokenValue(t, labels, tokenType) - before[tokenType]
if got != want {
t.Fatalf("responses sideband token_type %s: got delta %v, want %v", tokenType, got, want)
}
}
}
// TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics verifies
// that a streaming /v1/responses provider passthrough with body-only SSE events
// (no proto USAGE frame) still observes provider-reported usage metrics
// (REVIEW_REVIEW_SEULGI_RESPONSES-1).
func TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics(t *testing.T) {
const rawToken = "sk-responses-streaming-token"
const edgeID = "edge-responses-streaming-usage"
const model = "pool-model"
// Responses streaming SSE: delta event followed by a usage-bearing event
// (response.completed with nested usage), matching the real provider tunnel.
providerBody := `data: {"type":"response.created","response":{"id":"resp-1"}}
data: {"type":"response.output_item.added","item":{"id":"msg-1","type":"message","role":"assistant"}}
data: {"type":"response.message.delta","delta":"hello world"}
data: {"type":"response.message.completed","item":{"id":"msg-1","role":"assistant","content":[{"type":"output_text","text":"hello world"}],"status":"completed"}}
data: {"type":"response.completed","response":{"id":"resp-1","usage":{"input_tokens":15,"output_tokens":10,"input_tokens_details":{"cached_tokens":5},"output_tokens_details":{"reasoning_tokens":2}}}}
data: [DONE]
`
frames := make(chan *iop.ProviderTunnelFrame, 5)
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
StatusCode: 200,
Headers: map[string]string{"Content-Type": "text/event-stream"},
}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerBody)}
// NOTE: No proto USAGE frame — usage is only in the SSE body.
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"}}})
labels := usageLabels{
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
modelGroup: model, endpoint: usageEndpointResponses, responseMode: responseModePassthrough,
}
reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "user:alice", "alice", "iop-tok-alice", model,
usageEndpointResponses, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported,
))
before := map[string]float64{
tokenTypeInput: requestTokenValue(t, labels, tokenTypeInput),
tokenTypeOutput: requestTokenValue(t, labels, tokenTypeOutput),
tokenTypeReasoning: requestTokenValue(t, labels, tokenTypeReasoning),
tokenTypeCachedInput: requestTokenValue(t, labels, tokenTypeCachedInput),
}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"pool-model",
"input":"hello",
"stream":true
}`))
req.Header.Set("Authorization", "Bearer "+rawToken)
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
// Raw SSE body must be preserved byte-for-byte: no model echo rewrite on
// passthrough because requestModel is empty (Responses passthrough prefers
// provider-original bytes). Streaming body is written directly to the
// response writer without modification.
if w.Body.String() != providerBody {
t.Fatalf("streaming SSE body must be preserved byte-for-byte.\n got: %s\nwant: %s", w.Body.String(), providerBody)
}
reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "user:alice", "alice", "iop-tok-alice", model,
usageEndpointResponses, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported,
))
if reqAfter-reqBefore != 1 {
t.Fatalf("requests_total responses/success: got delta %v, want 1", reqAfter-reqBefore)
}
// Body-only Responses streaming usage: all token types from SSE body.
for tokenType, want := range map[string]float64{
tokenTypeInput: 15,
tokenTypeOutput: 10,
tokenTypeReasoning: 2,
tokenTypeCachedInput: 5,
} {
got := requestTokenValue(t, labels, tokenType) - before[tokenType]
if got != want {
t.Fatalf("responses streaming token_type %s: got delta %v, want %v", tokenType, got, want)
}
}
}
// TestOpenAIScopeExcludesA2AAndNonOpenAISurfaces documents that usage metering is
// scoped to the OpenAI-compatible input surface only (SDD S04). The endpoint
// label allowlist carries the OpenAI routes and no A2A/CLI endpoint value, and

View file

@ -1284,6 +1284,80 @@ func TestOpenAICompatTunnelProvider(t *testing.T) {
assertOrderedTunnelFrames(t, frames)
}
func TestOpenAICompatTunnelProvider_ResponsesPath(t *testing.T) {
requestBody := `{"model":"served-model","input":"hi","max_output_tokens":123,"store":false}`
expectedBody := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: [DONE]\n\n"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST method, got %s", r.Method)
}
if r.URL.Path != "/v1/responses" {
t.Errorf("expected path /v1/responses, got %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("expected Authorization header, got %s", r.Header.Get("Authorization"))
}
var got bytes.Buffer
if _, err := got.ReadFrom(r.Body); err != nil {
t.Fatalf("read request body: %v", err)
}
if got.String() != requestBody {
t.Errorf("request body mismatch: got %q want %q", got.String(), requestBody)
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(expectedBody))
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeTunnelSink{}
req := runtime.ProviderTunnelRequest{
RunID: "run-1",
TunnelID: "tunnel-1",
Method: "POST",
Path: "/v1/responses",
Headers: map[string]string{"Authorization": "Bearer test-key"},
Body: []byte(requestBody),
Stream: true,
}
err := adapter.TunnelProvider(context.Background(), req, sink)
if err != nil {
t.Fatalf("TunnelProvider failed: %v", err)
}
frames := sink.all()
if len(frames) < 3 {
t.Fatalf("expected at least 3 frames, got %d", len(frames))
}
if frames[0].Kind != runtime.ProviderTunnelFrameKindResponseStart {
t.Errorf("expected RESPONSE_START, got %s", frames[0].Kind)
}
if frames[0].StatusCode != http.StatusOK {
t.Errorf("expected 200 OK, got %d", frames[0].StatusCode)
}
var bodyBuffer bytes.Buffer
for _, f := range frames[1 : len(frames)-1] {
if f.Kind != runtime.ProviderTunnelFrameKindBody {
t.Errorf("expected BODY kind, got %s", f.Kind)
}
bodyBuffer.Write(f.Body)
}
if bodyBuffer.String() != expectedBody {
t.Errorf("body mismatch: got %q, want %q", bodyBuffer.String(), expectedBody)
}
if frames[len(frames)-1].Kind != runtime.ProviderTunnelFrameKindEnd {
t.Errorf("expected END, got %s", frames[len(frames)-1].Kind)
}
assertOrderedTunnelFrames(t, frames)
}
func TestOpenAICompatTunnelProvider_Cancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
handlerObservedCancel := make(chan struct{})