From 12a26d4b4ce84c2d77660e7c5e8bc8b861fe80ea Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 11 Jul 2026 12:42:16 +0900 Subject: [PATCH] feat: routing-policy-model-orchestration - responses passthrough & model group mixed provider dispatch --- agent-contract/outer/openai-compatible-api.md | 12 +- .../PHASE.md | 4 + .../model-group-mixed-provider-dispatch.md | 107 ++++ .../seulgivibe-openai-compatible-provider.md | 10 +- .../SDD.md | 139 +++++ .../SDD.md | 9 +- agent-spec/input/openai-compatible-surface.md | 12 +- .../code_review_cloud_G07_0.log} | 89 ++- .../code_review_cloud_G07_1.log | 216 ++++++++ .../code_review_local_G05_2.log | 221 ++++++++ .../complete.log | 49 ++ .../plan_cloud_G07_0.log} | 0 .../plan_cloud_G07_1.log | 267 +++++++++ .../plan_local_G05_2.log | 247 +++++++++ .../edge/internal/openai/responses_handler.go | 522 +++++++++++++++++- apps/edge/internal/openai/server_test.go | 453 ++++++++++++++- apps/edge/internal/openai/stream.go | 75 ++- apps/edge/internal/openai/types.go | 11 + .../internal/openai/usage_metrics_test.go | 266 +++++++++ .../openai_compat/openai_compat_test.go | 74 +++ 20 files changed, 2694 insertions(+), 89 deletions(-) create mode 100644 agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md create mode 100644 agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md rename agent-task/{m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/CODE_REVIEW-cloud-G07.md => archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_0.log} (50%) create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_local_G05_2.log create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/complete.log rename agent-task/{m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/PLAN-cloud-G07.md => archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_0.log} (100%) create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_1.log create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_local_G05_2.log diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index af6d38b..20da65c 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -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 정책을 별도 구현/계약 갱신해야 한다. diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md b/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md index d26430a..e013b95 100644 --- a/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md +++ b/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md @@ -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 방향을 스케치한다. diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md new file mode 100644 index 0000000..43c319c --- /dev/null +++ b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md @@ -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 +- 확인 필요: 없음 diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md index d053c9c..43afc59 100644 --- a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md +++ b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md @@ -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 정리 - 확인 필요: 없음 diff --git a/agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md b/agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md new file mode 100644 index 0000000..94705d8 --- /dev/null +++ b/agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md @@ -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: 없음 diff --git a/agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md b/agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md index 760a7c3..5d70e0d 100644 --- a/agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md +++ b/agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md @@ -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: 없음 diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 7d9e3c8..68eb502 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -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`를 확장 지점으로 사용한다. diff --git a/agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/CODE_REVIEW-cloud-G07.md b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_0.log similarity index 50% rename from agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/CODE_REVIEW-cloud-G07.md rename to agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_0.log index 6b41308..e567929 100644 --- a/agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/CODE_REVIEW-cloud-G07.md +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_0.log @@ -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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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`를 작성한다. diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_1.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_1.log new file mode 100644 index 0000000..e384c7d --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_cloud_G07_1.log @@ -0,0 +1,216 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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를 작성한다. 사용자 리뷰 게이트는 트리거하지 않는다. diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_local_G05_2.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_local_G05_2.log new file mode 100644 index 0000000..dbcbdd4 --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/code_review_local_G05_2.log @@ -0,0 +1,221 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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` 완료 이벤트 메타데이터를 보고한다. diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/complete.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/complete.log new file mode 100644 index 0000000..2450eb6 --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/PLAN-cloud-G07.md b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_0.log similarity index 100% rename from agent-task/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/PLAN-cloud-G07.md rename to agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_0.log diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_1.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_1.log new file mode 100644 index 0000000..5f149be --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_cloud_G07_1.log @@ -0,0 +1,267 @@ + + +# 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 + +- 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_local_G05_2.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_local_G05_2.log new file mode 100644 index 0000000..1ea03d0 --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough/plan_local_G05_2.log @@ -0,0 +1,247 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go index 0070e56..3b0c90f 100644 --- a/apps/edge/internal/openai/responses_handler.go +++ b/apps/edge/internal/openai/responses_handler.go @@ -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()) diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 8621ace..db7b493 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -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 diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index 4a57cb5..547d5b3 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -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) { diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index 1e96391..f4fcd65 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -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 { diff --git a/apps/edge/internal/openai/usage_metrics_test.go b/apps/edge/internal/openai/usage_metrics_test.go index 17c4196..a9ce936 100644 --- a/apps/edge/internal/openai/usage_metrics_test.go +++ b/apps/edge/internal/openai/usage_metrics_test.go @@ -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 diff --git a/apps/node/internal/adapters/openai_compat/openai_compat_test.go b/apps/node/internal/adapters/openai_compat/openai_compat_test.go index 9ee316e..fe42097 100644 --- a/apps/node/internal/adapters/openai_compat/openai_compat_test.go +++ b/apps/node/internal/adapters/openai_compat/openai_compat_test.go @@ -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{})