From ff7330680f9c88a5809505c38b7eed5c78921150 Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 9 Jul 2026 12:47:20 +0900 Subject: [PATCH] refactor: reject transformed mode and responses for provider-pool/tunnel routes --- agent-contract/outer/openai-compatible-api.md | 14 +- .../code_review_local_G06_0.log | 322 ++++++++++++++++++ .../complete.log | 37 ++ .../plan_local_G06_0.log} | 48 ++- .../CODE_REVIEW-local-G06.md | 154 --------- apps/edge/internal/openai/chat_handler.go | 11 +- .../edge/internal/openai/responses_handler.go | 4 + apps/edge/internal/openai/server_test.go | 305 +++++++++-------- 8 files changed, 585 insertions(+), 310 deletions(-) create mode 100644 agent-task/archive/2026/07/openai_passthrough_model_groups/code_review_local_G06_0.log create mode 100644 agent-task/archive/2026/07/openai_passthrough_model_groups/complete.log rename agent-task/{openai_passthrough_model_groups/PLAN-local-G06.md => archive/2026/07/openai_passthrough_model_groups/plan_local_G06_0.log} (81%) delete mode 100644 agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 96e7d48..4d025c8 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -100,6 +100,7 @@ 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하지 않는다. - `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에는 복사하지 않는다. @@ -176,10 +177,10 @@ OpenAI-compatible inference provider route는 요청 metadata의 `iop_response_m - `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로 표시하지 않는다. -- `metadata.iop_response_mode="transformed"`: raw tunnel을 쓰지 않고 normalized IOP output path를 사용하며 `X-IOP-Response-Mode: transformed`로 라벨링한다. 이 응답은 provider-original byte identity 검증 대상이 아니다. +- `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`로 거부한다. -현재 raw passthrough/sideband/transformed mode는 Chat Completions provider route 계약이다. `/v1/responses` raw passthrough parity는 별도 구현/계약 갱신 대상이다. +현재 Chat Completions provider route는 raw `passthrough`와 `passthrough+sideband`만 지원한다. `/v1/responses` raw passthrough parity는 별도 구현/계약 갱신 대상이며, parity 전 provider model group `/v1/responses` 요청은 normalized path로 처리하지 않는다. Think 제어 field: @@ -214,11 +215,11 @@ Provider별 think-control 정책: - `reasoning_effort=low|medium|high` -> `unsupported think control` 오류 반환 - Unknown / 기타 provider: 요청 field를 그대로 전달하되, provider가 지원하지 않는 값은 backend 또는 adapter error가 될 수 있다. -Provider pool model catalog의 `models[]` entry가 generation policy를 제공하면 Edge는 요청을 내부 실행으로 넘기기 전에 다음 값을 보정한다. +Provider pool model catalog의 `models[]` entry가 generation policy를 제공하면 Edge는 요청을 내부 실행 또는 Chat Completions provider tunnel로 넘기기 전에 다음 값을 보정한다. - `default_max_tokens`: caller가 출력 token limit을 생략했을 때 `max_tokens` 또는 `max_output_tokens`로 주입한다. - `min_max_tokens`: caller가 너무 작은 출력 token limit을 보냈을 때 해당 값까지 올린다. caller 값이 더 크면 보존한다. -- `default_thinking_token_budget`: caller가 `thinking_token_budget`을 생략했을 때 내부 실행 입력에 주입한다. provider-pool catalog에 이 값이 있으면 Edge는 `think=true`도 함께 주입해 vLLM/vLLM-MLX 계열 adapter가 `chat_template_kwargs.enable_thinking=true`로 전달하도록 한다. +- `default_thinking_token_budget`: caller가 `thinking_token_budget`을 생략했을 때 내부 실행 입력 또는 Chat Completions provider tunnel body에 주입한다. strict output가 함께 활성화된 provider-pool Chat Completions 요청에서는 Edge가 `think=true`도 함께 주입해 vLLM/vLLM-MLX 계열 adapter가 `chat_template_kwargs.enable_thinking=true`로 전달하도록 한다. Conflict 정책: @@ -230,8 +231,9 @@ Conflict 정책: Strict output 모드: -- strict output가 활성화되면 `think=true`가 명시되지 않은 요청은 내부 실행 입력에서 `think=false`로 낮춘다. -- 단, provider-pool `models[]` entry의 `default_thinking_token_budget`가 적용되는 모델은 catalog의 thinking policy가 우선한다. 이 경우 요청이 `think=false` 또는 `reasoning_effort=none`을 명시하지 않았다면 strict output에서도 `think=true`와 `thinking_token_budget`을 내부 실행 입력에 넣는다. +- strict output가 활성화되면 non-provider normalized route에서 `think=true`가 명시되지 않은 요청은 내부 실행 입력에서 `think=false`로 낮춘다. +- strict output만으로 OpenAI-compatible provider model group route를 `transformed` normalized path로 전환하지 않는다. provider model group omitted mode는 계속 raw `passthrough`다. +- provider-pool `models[]` entry의 `default_thinking_token_budget`가 적용되는 모델은 catalog의 thinking policy가 우선한다. 이 경우 요청이 `think=false` 또는 `reasoning_effort=none`을 명시하지 않았다면 strict output에서도 `think=true`와 `thinking_token_budget`을 내부 실행 입력 또는 Chat Completions provider tunnel body에 넣는다. `tools`가 있는 Chat Completions 요청에서 provider route(`openai_compat`, `vllm`, `ollama`, provider pool)는 forced tool 선택 객체와 `"none"` 같은 명시적 `tool_choice`를 backend에 전달한다. 단, `"auto"`는 OpenAI-compatible 기본값과 같으므로 provider request에서는 생략한다. 일부 vLLM 계열 backend는 explicit/default `"auto"`를 `--enable-auto-tool-choice`/`--tool-call-parser` 없이 400으로 거부한다. 이 400이 발생하고 요청 tool이 정확히 1개이면 Node adapter는 해당 tool에 대한 forced `tool_choice`로 1회 재시도한다. forced tool도 `--tool-call-parser` 요구로 거부되거나 여러 tool이라 forced를 고를 수 없으면, Node adapter는 `tools`/`tool_choice`를 제거하고 text tool-call system instruction을 leading system message에 병합해 1회 재시도하며 완료 metadata에 `openai_text_tool_fallback: "true"`를 싣는다. provider가 native OpenAI-compatible `tool_calls`를 반환하면 Node는 내부 `RunEvent.metadata["openai_tool_calls"]` JSON으로 보존하고, Edge는 이를 OpenAI-compatible `message.tool_calls` 또는 stream `delta.tool_calls`로 반환하며 `finish_reason: "tool_calls"`를 사용한다. diff --git a/agent-task/archive/2026/07/openai_passthrough_model_groups/code_review_local_G06_0.log b/agent-task/archive/2026/07/openai_passthrough_model_groups/code_review_local_G06_0.log new file mode 100644 index 0000000..325f561 --- /dev/null +++ b/agent-task/archive/2026/07/openai_passthrough_model_groups/code_review_local_G06_0.log @@ -0,0 +1,322 @@ + + +# Code Review Reference - API + +> **[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-09 +task=openai_passthrough_model_groups, plan=0, tag=API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/openai_passthrough_model_groups/`로 이동한다. 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` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Chat Completions provider groups must not use transformed | [x] | +| [API-2] Responses provider groups must not use normalized until raw parity exists | [x] | +| [API-3] Dev-corp rollout must be Edge/Node coordinated | [x] | + +## 구현 체크리스트 + +- [x] [API-1] Chat Completions provider tunnel route에서 `metadata.iop_response_mode="transformed"`가 normalized `SubmitRun`으로 들어가지 못하게 거부하고, passthrough/sideband tunnel 동작과 generation policy body rewrite 테스트를 갱신한다. +- [x] [API-2] `/v1/responses` provider tunnel route가 raw parity 없이 normalized `SubmitRun`으로 들어가지 못하게 거부하고, 계약 문서와 provider-pool Responses 테스트를 갱신한다. +- [x] [API-3] dev-corp 배포는 Edge-only로 진행하지 않도록 제한을 기록하고, Edge/Node ProviderTunnel 계약 동기화와 coordinated rollout smoke를 별도 후속 plan으로 남긴다. +- [x] 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 실제 stdout/stderr로 기록한다. +- [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_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_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/openai_passthrough_model_groups/`를 `agent-task/archive/YYYY/MM/openai_passthrough_model_groups/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.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로 이동한다. + +## 계획 대비 변경 사항 + +- provider route의 omitted mode는 strict output가 켜져 있어도 `transformed`로 자동 전환하지 않고 raw passthrough를 유지하도록 했다. 계획의 목적이 provider model group의 normalized fallback 회귀 차단이므로, strict output default도 provider route에서는 normalized path로 보내지 않는 쪽이 안전하다. +- 기존 normalized-path 테스트 중 direct `openai_compat + transformed` fixture는 `ollama + transformed` non-tunnel route로 옮겼다. `openai_compat`/`vllm`은 provider tunnel route로 분류되어 이번 변경 뒤에는 explicit `transformed`가 거부되어야 하기 때문이다. +- dev-corp 배포 요청은 이번 턴에 이어지지 않았으므로 별도 `dev_corp_passthrough_rollout` active plan 파일은 만들지 않았다. 후속 rollout 최소 범위와 Edge-only 금지는 active plan/review에 기록했다. +- `agent-test/local/rules.md`가 없어 `create-test` 흐름에 따라 ignored local-only `agent-test/local/*` baseline을 생성하고 읽은 뒤 검증했다. 이 파일들은 `.gitignore` 대상이라 tracked 변경에는 포함되지 않는다. + +## 주요 설계 결정 + +- `handleChatCompletions`에서 `providerTunnelRoute := routeUsesProviderTunnel(dispatch)`를 한 번 계산하고, provider route이면 explicit `metadata.iop_response_mode="transformed"`를 `400 invalid_request_error`로 거부한다. +- strict output의 implicit transformed 전환은 non-provider route에만 적용한다. provider-pool/openai_compat/vllm route는 omitted mode를 raw passthrough로 유지한다. +- `/v1/responses`는 dispatch/workspace validation 직후 `routeUsesProviderTunnel(dispatch)`이면 `400 invalid_request_error`로 거부해 provider-pool/openai_compat/vllm route가 normalized `SubmitRun`으로 fallback하지 못하게 했다. +- provider-pool Chat Completions generation policy 테스트는 normalized `SubmitRun` input 대신 tunnel `BuildBody` 결과를 검사해 `max_tokens`, `thinking_token_budget`, `think`, served model rewrite가 provider request body에 반영되는지 검증한다. +- `agent-contract/outer/openai-compatible-api.md`는 provider model group의 Chat `transformed` 거부, Responses provider group parity 전 거부, strict output이 provider route를 normalized path로 전환하지 않는다는 제한을 코드와 맞췄다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Provider-pool/openai_compat/vllm Chat Completions route에서 `transformed`가 `SubmitRun`으로 들어가지 않는지 확인한다. +- Passthrough/sideband tunnel은 계속 `SubmitProviderTunnel`을 쓰고 provider body rewrite가 generation policy를 반영하는지 확인한다. +- `/v1/responses` provider route가 raw parity 없이 normalized path를 타지 않고 명확히 거부되는지 확인한다. +- CLI/legacy non-provider normalized Responses 경로와 legacy Ollama smoke가 깨지지 않았는지 확인한다. +- `agent-contract/outer/openai-compatible-api.md`가 구현과 같은 제한을 말하는지 확인한다. +- dev-corp 배포가 Edge-only로 안내되지 않고, Edge/Node 같은 source ref 빌드 및 coordinated rollout smoke 후속 plan이 기록되어 있는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### API-1 중간 검증 + +```bash +$ go test ./apps/edge/internal/openai -run 'TestChatCompletions(ResponseModeRouting|ProviderPoolDispatch|PassthroughLegacyProviderRouteUsesTunnel|ProviderPoolAppliesGenerationPolicy|ProviderPoolThinkingPolicyOverridesStrictOutputDisable|ProviderPoolPreservesLargerGenerationPolicyValues)' -count=1 +ok iop/apps/edge/internal/openai 0.964s +``` + +### API-2 중간 검증 + +```bash +$ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponsesDispatchesNonStreamingRequest|TestResponsesReturnsOpenAICompatibleShape' -count=1 +ok iop/apps/edge/internal/openai 0.639s +``` + +### API-3 중간 검증 + +```bash +$ git diff -- agent-task/openai_passthrough_model_groups/PLAN-local-G06.md agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md +diff --git a/agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md b/agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md +index b859036..3b96ddd 100644 +--- a/agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md ++++ b/agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md +@@ -37,11 +37,13 @@ task=openai_passthrough_model_groups, plan=0, tag=API + |------|---------| + | [API-1] Chat Completions provider groups must not use transformed | [ ] | + | [API-2] Responses provider groups must not use normalized until raw parity exists | [ ] | ++| [API-3] Dev-corp rollout must be Edge/Node coordinated | [ ] | + + ## 구현 체크리스트 + + - [ ] [API-1] Chat Completions provider tunnel route에서 `metadata.iop_response_mode="transformed"`가 normalized `SubmitRun`으로 들어가지 못하게 거부하고, passthrough/sideband tunnel 동작과 generation policy body rewrite 테스트를 갱신한다. + - [ ] [API-2] `/v1/responses` provider tunnel route가 raw parity 없이 normalized `SubmitRun`으로 들어가지 못하게 거부하고, 계약 문서와 provider-pool Responses 테스트를 갱신한다. ++- [ ] [API-3] dev-corp 배포는 Edge-only로 진행하지 않도록 제한을 기록하고, Edge/Node ProviderTunnel 계약 동기화와 coordinated rollout smoke를 별도 후속 plan으로 남긴다. + - [ ] 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 실제 stdout/stderr로 기록한다. + - [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +@@ -91,6 +93,7 @@ _기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직 + - `/v1/responses` provider route가 raw parity 없이 normalized path를 타지 않고 명확히 거부되는지 확인한다. + - CLI/legacy non-provider normalized Responses 경로와 legacy Ollama smoke가 깨지지 않았는지 확인한다. + - `agent-contract/outer/openai-compatible-api.md`가 구현과 같은 제한을 말하는지 확인한다. ++- dev-corp 배포가 Edge-only로 안내되지 않고, Edge/Node 같은 source ref 빌드 및 coordinated rollout smoke 후속 plan이 기록되어 있는지 확인한다. + + ## 검증 결과 + +@@ -117,6 +120,13 @@ $ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestRespon + (output) + ``` + ++### API-3 중간 검증 ++ ++```bash ++$ git diff -- agent-task/openai_passthrough_model_groups/PLAN-local-G06.md agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md ++(output) ++``` ++ + ### 최종 검증 + + ```bash +diff --git a/agent-task/openai_passthrough_model_groups/PLAN-local-G06.md b/agent-task/openai_passthrough_model_groups/PLAN-local-G06.md +index f40ebdf..34d4297 100644 +--- a/agent-task/openai_passthrough_model_groups/PLAN-local-G06.md ++++ b/agent-task/openai_passthrough_model_groups/PLAN-local-G06.md +@@ -83,6 +83,7 @@ not applicable. 활성 Milestone `agent-roadmap/phase/routing-policy-model-orche + - 현재 `TestChatCompletionsResponseModeRouting`은 provider-pool `transformed`가 normalized `SubmitRun`을 타는 것을 기대한다: `apps/edge/internal/openai/server_test.go:4108`. 요구사항과 반대라 회귀 테스트로 바꿔야 한다. + - Chat provider-pool generation policy 테스트는 `transformed` normalized input을 검사한다: `apps/edge/internal/openai/server_test.go:5305`. passthrough tunnel body rewrite 검사로 바꿔야 한다. + - `/v1/responses` provider-pool 테스트는 normalized `SubmitRun`을 성공 경로로 검증한다: `apps/edge/internal/openai/server_test.go:5438`. provider route에서는 raw parity 전까지 거부하는 테스트로 바꿔야 한다. ++- dev-corp live runtime에서 Edge-only passthrough 전환은 Node runtime이 같은 ProviderTunnelRequest 계약을 처리하지 못하면 Chat Completions 요청이 provider response frame 없이 대기할 수 있다. local fake tunnel tests만으로는 stale Node artifact나 Edge/Node ref mismatch를 검증하지 못하므로 배포 전 runtime compatibility gate가 별도로 필요하다. + + ### 심볼 참조 + +@@ -95,7 +96,8 @@ split decision policy를 먼저 평가했다. API contract 문구, OpenAI handle + ### 범위 결정 근거 + + - 포함: Chat Completions provider tunnel route에서 explicit `transformed` normalized path 차단, `/v1/responses` provider tunnel route normalized dispatch 차단, 계약 문서와 OpenAI server tests 갱신. +-- 제외: provider raw tunnel wire/proto 변경, Node adapter 구현, provider-pool admission/queue 변경, `/v1/responses` raw passthrough parity 구현, 하이브리드 라우팅 Milestone 구현. ++- 포함: dev-corp 배포는 Edge-only 금지로 기록하고, Edge/Node ProviderTunnel 계약을 같은 source ref로 맞추는 후속 rollout plan 필요성을 명시한다. ++- 제외: provider raw tunnel wire/proto 변경, Node adapter 구현, provider-pool admission/queue 변경, `/v1/responses` raw passthrough parity 구현, 하이브리드 라우팅 Milestone 구현, dev-corp Edge/Node 실제 배포. + - legacy CLI/normal adapter 경로의 normalized `SubmitRun`은 유지한다. + - `ollama` legacy route의 `/v1/responses` normalized 경로는 이 plan에서 바꾸지 않는다. 현재 tunnel provider route 판정은 `ProviderPool`, `openai_compat`, `vllm`에 한정되어 있다. + +@@ -107,6 +109,7 @@ split decision policy를 먼저 평가했다. API contract 문구, OpenAI handle + + - [ ] [API-1] Chat Completions provider tunnel route에서 `metadata.iop_response_mode="transformed"`가 normalized `SubmitRun`으로 들어가지 못하게 거부하고, passthrough/sideband tunnel 동작과 generation policy body rewrite 테스트를 갱신한다. + - [ ] [API-2] `/v1/responses` provider tunnel route가 raw parity 없이 normalized `SubmitRun`으로 들어가지 못하게 거부하고, 계약 문서와 provider-pool Responses 테스트를 갱신한다. ++- [ ] [API-3] dev-corp 배포는 Edge-only로 진행하지 않도록 제한을 기록하고, Edge/Node ProviderTunnel 계약 동기화와 coordinated rollout smoke를 별도 후속 plan으로 남긴다. + - [ ] 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 실제 stdout/stderr로 기록한다. + - [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +@@ -242,6 +245,47 @@ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponse + + 기대 결과: PASS. provider-pool Responses는 400을 반환하고 `SubmitRun`을 호출하지 않으며, legacy Responses 경로는 계속 동작한다. + ++### [API-3] Dev-Corp Rollout Must Be Edge/Node Coordinated ++ ++#### 문제 ++ ++dev-corp live runtime에서 Edge만 최신 passthrough 코드로 교체하면 `openai chat completion passthrough dispatch`까지는 들어가도, 연결된 Node runtime이 같은 ProviderTunnelRequest/ProviderTunnelFrame 계약을 처리하지 못하는 경우 Chat Completions 응답이 provider body 없이 대기할 수 있다. 이 plan의 local fake-provider tests는 handler 계약 회귀는 잡지만, 원격 Node artifact가 같은 source ref에서 빌드되었는지와 live Node가 tunnel frames를 반환하는지는 검증하지 못한다. ++ ++#### 해결 방법 ++ ++G06 구현 완료 후 dev-corp 배포는 Edge-only로 진행하지 않는다. 별도 follow-up plan에서 같은 source ref 기준 Edge binary, macOS Node binary, Linux ARM64 Node binary를 함께 빌드/배포하고, live smoke로 omitted-mode passthrough, explicit sideband, explicit transformed 400, Node reconnect, provider snapshots 회복을 검증한다. ++ ++Follow-up plan 최소 범위: ++ ++```text ++task_group: dev_corp_passthrough_rollout ++scope: ++- Edge/Node binaries built from the same G06 source ref ++- dev-corp runtime artifact replacement for Edge and connected provider Nodes ++- /v1/chat/completions omitted mode passthrough smoke ++- metadata.iop_response_mode=passthrough+sideband smoke ++- metadata.iop_response_mode=transformed rejection smoke ++- Control Plane provider snapshots in_flight=0 queued=0 recovery check ++``` ++ ++#### 수정 파일 및 체크리스트 ++ ++- [ ] `agent-task/openai_passthrough_model_groups/PLAN-local-G06.md`: Edge-only 배포 금지와 follow-up rollout 범위를 기록한다. ++- [ ] `agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md`: 구현 에이전트 체크리스트와 리뷰 포커스에 runtime compatibility gate를 반영한다. ++- [ ] G06 구현 에이전트는 dev-corp 배포 요청이 이어질 경우 `dev_corp_passthrough_rollout` 후속 plan을 작성하거나, 이미 작성된 후속 plan 경로를 `CODE_REVIEW-local-G06.md`의 `계획 대비 변경 사항`에 기록한다. ++ ++#### 테스트 작성 ++ ++테스트 코드는 작성하지 않는다. 이 항목은 local unit test로 검증 가능한 handler behavior가 아니라 live runtime rollout gate다. G06 code change 검증은 API-1/API-2 테스트가 담당하고, dev-corp runtime 검증은 후속 rollout plan의 smoke가 담당한다. ++ ++#### 중간 검증 ++ ++```bash ++git diff -- agent-task/openai_passthrough_model_groups/PLAN-local-G06.md agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md ++``` ++ ++기대 결과: Edge-only 배포 금지, Edge/Node coordinated rollout 필요성, 후속 plan 최소 smoke 범위가 active plan/review에 명시되어 있다. ++ + ## 수정 파일 요약 + + | 파일 | 항목 | +@@ -250,6 +294,8 @@ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponse + | `apps/edge/internal/openai/responses_handler.go` | API-2 | + | `apps/edge/internal/openai/server_test.go` | API-1, API-2 | + | `agent-contract/outer/openai-compatible-api.md` | API-2 | ++| `agent-task/openai_passthrough_model_groups/PLAN-local-G06.md` | API-3 | ++| `agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md` | API-3 | +``` + +### 최종 검증 + +```bash +$ go test ./apps/edge/internal/openai -count=1 +ok iop/apps/edge/internal/openai 2.443s +``` + +```bash +$ go test ./apps/edge/... -count=1 +ok iop/apps/edge/cmd/edge 0.876s +ok iop/apps/edge/internal/bootstrap 1.849s +ok iop/apps/edge/internal/configrefresh 4.091s +ok iop/apps/edge/internal/controlplane 7.626s +ok iop/apps/edge/internal/edgecmd 6.381s +ok iop/apps/edge/internal/edgevalidate 5.459s +ok iop/apps/edge/internal/events 2.105s +ok iop/apps/edge/internal/input 4.694s +ok iop/apps/edge/internal/input/a2a 7.050s +ok iop/apps/edge/internal/node 7.723s +ok iop/apps/edge/internal/openai 9.430s +ok iop/apps/edge/internal/opsconsole 7.615s +ok iop/apps/edge/internal/service 9.149s +ok iop/apps/edge/internal/transport 9.065s +``` + +```bash +$ make test-openai-ollama +./scripts/e2e-openai-ollama.sh +[openai-ollama] OpenAI-compatible Ollama serving test PASSED. +``` + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - implementation deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS 종결 절차를 진행한다. active review/plan을 `.log`로 아카이브하고 `complete.log`를 작성한 뒤 task directory를 `agent-task/archive/2026/07/openai_passthrough_model_groups/`로 이동한다. + +--- + +> **[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. + +## Section Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| 구현 항목별 완료 여부 | Implementing agent checks only | Item names are fixed | +| 구현 체크리스트 | Implementing agent checks only | Text/order copied from plan | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected Milestone lock blocks implementation | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | +| 검증 결과 | Implementing agent fills output only | Command changes require `계획 대비 변경 사항` | diff --git a/agent-task/archive/2026/07/openai_passthrough_model_groups/complete.log b/agent-task/archive/2026/07/openai_passthrough_model_groups/complete.log new file mode 100644 index 0000000..a46c2c8 --- /dev/null +++ b/agent-task/archive/2026/07/openai_passthrough_model_groups/complete.log @@ -0,0 +1,37 @@ +# Complete - openai_passthrough_model_groups + +## 완료 일시 + +2026-07-09T12:41:15+0900 + +## 요약 + +OpenAI-compatible provider model group passthrough 회귀 차단 hotfix를 1개 review loop로 검토했고 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | provider Chat transformed 거부, Responses provider route 거부, 계약/테스트 갱신이 계획과 일치함 | + +## 구현/정리 내용 + +- Chat Completions provider tunnel route에서 explicit `metadata.iop_response_mode="transformed"`를 `400 invalid_request_error`로 거부해 normalized `SubmitRun` fallback을 막았다. +- Strict output 기본 전환은 provider tunnel route에는 적용하지 않아 provider model group omitted mode가 raw passthrough를 유지하게 했다. +- `/v1/responses` provider model group route는 raw passthrough parity 전까지 `400 invalid_request_error`로 거부하게 했다. +- provider-pool generation policy가 Chat provider tunnel body에 반영되는지 테스트하고, 계약 문서의 response mode/Responses 제한을 코드와 맞췄다. +- dev-corp 배포는 이번 task 범위 밖으로 두고, Edge-only 배포 금지와 Edge/Node coordinated rollout smoke 필요성을 active plan/review 로그에 기록했다. + +## 최종 검증 + +- `go test ./apps/edge/internal/openai -count=1` - PASS; `ok iop/apps/edge/internal/openai 2.789s` +- `go test ./apps/edge/... -count=1` - PASS; edge 하위 패키지 전체 통과, `apps/edge/internal/openai` 포함 +- `make test-openai-ollama` - PASS; `[openai-ollama] OpenAI-compatible Ollama serving test PASSED.` + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- dev-corp 배포 요청 시 `dev_corp_passthrough_rollout` 후속 plan으로 Edge/Node binaries를 같은 source ref에서 배포하고 omitted-mode passthrough, sideband, transformed rejection, provider snapshots 회복 smoke를 확인한다. diff --git a/agent-task/openai_passthrough_model_groups/PLAN-local-G06.md b/agent-task/archive/2026/07/openai_passthrough_model_groups/plan_local_G06_0.log similarity index 81% rename from agent-task/openai_passthrough_model_groups/PLAN-local-G06.md rename to agent-task/archive/2026/07/openai_passthrough_model_groups/plan_local_G06_0.log index f40ebdf..34d4297 100644 --- a/agent-task/openai_passthrough_model_groups/PLAN-local-G06.md +++ b/agent-task/archive/2026/07/openai_passthrough_model_groups/plan_local_G06_0.log @@ -83,6 +83,7 @@ not applicable. 활성 Milestone `agent-roadmap/phase/routing-policy-model-orche - 현재 `TestChatCompletionsResponseModeRouting`은 provider-pool `transformed`가 normalized `SubmitRun`을 타는 것을 기대한다: `apps/edge/internal/openai/server_test.go:4108`. 요구사항과 반대라 회귀 테스트로 바꿔야 한다. - Chat provider-pool generation policy 테스트는 `transformed` normalized input을 검사한다: `apps/edge/internal/openai/server_test.go:5305`. passthrough tunnel body rewrite 검사로 바꿔야 한다. - `/v1/responses` provider-pool 테스트는 normalized `SubmitRun`을 성공 경로로 검증한다: `apps/edge/internal/openai/server_test.go:5438`. provider route에서는 raw parity 전까지 거부하는 테스트로 바꿔야 한다. +- dev-corp live runtime에서 Edge-only passthrough 전환은 Node runtime이 같은 ProviderTunnelRequest 계약을 처리하지 못하면 Chat Completions 요청이 provider response frame 없이 대기할 수 있다. local fake tunnel tests만으로는 stale Node artifact나 Edge/Node ref mismatch를 검증하지 못하므로 배포 전 runtime compatibility gate가 별도로 필요하다. ### 심볼 참조 @@ -95,7 +96,8 @@ split decision policy를 먼저 평가했다. API contract 문구, OpenAI handle ### 범위 결정 근거 - 포함: Chat Completions provider tunnel route에서 explicit `transformed` normalized path 차단, `/v1/responses` provider tunnel route normalized dispatch 차단, 계약 문서와 OpenAI server tests 갱신. -- 제외: provider raw tunnel wire/proto 변경, Node adapter 구현, provider-pool admission/queue 변경, `/v1/responses` raw passthrough parity 구현, 하이브리드 라우팅 Milestone 구현. +- 포함: dev-corp 배포는 Edge-only 금지로 기록하고, Edge/Node ProviderTunnel 계약을 같은 source ref로 맞추는 후속 rollout plan 필요성을 명시한다. +- 제외: provider raw tunnel wire/proto 변경, Node adapter 구현, provider-pool admission/queue 변경, `/v1/responses` raw passthrough parity 구현, 하이브리드 라우팅 Milestone 구현, dev-corp Edge/Node 실제 배포. - legacy CLI/normal adapter 경로의 normalized `SubmitRun`은 유지한다. - `ollama` legacy route의 `/v1/responses` normalized 경로는 이 plan에서 바꾸지 않는다. 현재 tunnel provider route 판정은 `ProviderPool`, `openai_compat`, `vllm`에 한정되어 있다. @@ -107,6 +109,7 @@ split decision policy를 먼저 평가했다. API contract 문구, OpenAI handle - [ ] [API-1] Chat Completions provider tunnel route에서 `metadata.iop_response_mode="transformed"`가 normalized `SubmitRun`으로 들어가지 못하게 거부하고, passthrough/sideband tunnel 동작과 generation policy body rewrite 테스트를 갱신한다. - [ ] [API-2] `/v1/responses` provider tunnel route가 raw parity 없이 normalized `SubmitRun`으로 들어가지 못하게 거부하고, 계약 문서와 provider-pool Responses 테스트를 갱신한다. +- [ ] [API-3] dev-corp 배포는 Edge-only로 진행하지 않도록 제한을 기록하고, Edge/Node ProviderTunnel 계약 동기화와 coordinated rollout smoke를 별도 후속 plan으로 남긴다. - [ ] 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 실제 stdout/stderr로 기록한다. - [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. @@ -242,6 +245,47 @@ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponse 기대 결과: PASS. provider-pool Responses는 400을 반환하고 `SubmitRun`을 호출하지 않으며, legacy Responses 경로는 계속 동작한다. +### [API-3] Dev-Corp Rollout Must Be Edge/Node Coordinated + +#### 문제 + +dev-corp live runtime에서 Edge만 최신 passthrough 코드로 교체하면 `openai chat completion passthrough dispatch`까지는 들어가도, 연결된 Node runtime이 같은 ProviderTunnelRequest/ProviderTunnelFrame 계약을 처리하지 못하는 경우 Chat Completions 응답이 provider body 없이 대기할 수 있다. 이 plan의 local fake-provider tests는 handler 계약 회귀는 잡지만, 원격 Node artifact가 같은 source ref에서 빌드되었는지와 live Node가 tunnel frames를 반환하는지는 검증하지 못한다. + +#### 해결 방법 + +G06 구현 완료 후 dev-corp 배포는 Edge-only로 진행하지 않는다. 별도 follow-up plan에서 같은 source ref 기준 Edge binary, macOS Node binary, Linux ARM64 Node binary를 함께 빌드/배포하고, live smoke로 omitted-mode passthrough, explicit sideband, explicit transformed 400, Node reconnect, provider snapshots 회복을 검증한다. + +Follow-up plan 최소 범위: + +```text +task_group: dev_corp_passthrough_rollout +scope: +- Edge/Node binaries built from the same G06 source ref +- dev-corp runtime artifact replacement for Edge and connected provider Nodes +- /v1/chat/completions omitted mode passthrough smoke +- metadata.iop_response_mode=passthrough+sideband smoke +- metadata.iop_response_mode=transformed rejection smoke +- Control Plane provider snapshots in_flight=0 queued=0 recovery check +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-task/openai_passthrough_model_groups/PLAN-local-G06.md`: Edge-only 배포 금지와 follow-up rollout 범위를 기록한다. +- [ ] `agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md`: 구현 에이전트 체크리스트와 리뷰 포커스에 runtime compatibility gate를 반영한다. +- [ ] G06 구현 에이전트는 dev-corp 배포 요청이 이어질 경우 `dev_corp_passthrough_rollout` 후속 plan을 작성하거나, 이미 작성된 후속 plan 경로를 `CODE_REVIEW-local-G06.md`의 `계획 대비 변경 사항`에 기록한다. + +#### 테스트 작성 + +테스트 코드는 작성하지 않는다. 이 항목은 local unit test로 검증 가능한 handler behavior가 아니라 live runtime rollout gate다. G06 code change 검증은 API-1/API-2 테스트가 담당하고, dev-corp runtime 검증은 후속 rollout plan의 smoke가 담당한다. + +#### 중간 검증 + +```bash +git diff -- agent-task/openai_passthrough_model_groups/PLAN-local-G06.md agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md +``` + +기대 결과: Edge-only 배포 금지, Edge/Node coordinated rollout 필요성, 후속 plan 최소 smoke 범위가 active plan/review에 명시되어 있다. + ## 수정 파일 요약 | 파일 | 항목 | @@ -250,6 +294,8 @@ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponse | `apps/edge/internal/openai/responses_handler.go` | API-2 | | `apps/edge/internal/openai/server_test.go` | API-1, API-2 | | `agent-contract/outer/openai-compatible-api.md` | API-2 | +| `agent-task/openai_passthrough_model_groups/PLAN-local-G06.md` | API-3 | +| `agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md` | API-3 | ## 최종 검증 diff --git a/agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md b/agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md deleted file mode 100644 index b859036..0000000 --- a/agent-task/openai_passthrough_model_groups/CODE_REVIEW-local-G06.md +++ /dev/null @@ -1,154 +0,0 @@ - - -# Code Review Reference - API - -> **[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-09 -task=openai_passthrough_model_groups, plan=0, tag=API - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. - -1. 판정을 append한다. -2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다. -3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/openai_passthrough_model_groups/`로 이동한다. 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` 위치에서 체크한 뒤 보고한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Chat Completions provider groups must not use transformed | [ ] | -| [API-2] Responses provider groups must not use normalized until raw parity exists | [ ] | - -## 구현 체크리스트 - -- [ ] [API-1] Chat Completions provider tunnel route에서 `metadata.iop_response_mode="transformed"`가 normalized `SubmitRun`으로 들어가지 못하게 거부하고, passthrough/sideband tunnel 동작과 generation policy body rewrite 테스트를 갱신한다. -- [ ] [API-2] `/v1/responses` provider tunnel route가 raw parity 없이 normalized `SubmitRun`으로 들어가지 못하게 거부하고, 계약 문서와 provider-pool Responses 테스트를 갱신한다. -- [ ] 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 실제 stdout/stderr로 기록한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. -- [ ] `.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/openai_passthrough_model_groups/`를 `agent-task/archive/YYYY/MM/openai_passthrough_model_groups/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.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로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 연결 대상: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- Provider-pool/openai_compat/vllm Chat Completions route에서 `transformed`가 `SubmitRun`으로 들어가지 않는지 확인한다. -- Passthrough/sideband tunnel은 계속 `SubmitProviderTunnel`을 쓰고 provider body rewrite가 generation policy를 반영하는지 확인한다. -- `/v1/responses` provider route가 raw parity 없이 normalized path를 타지 않고 명확히 거부되는지 확인한다. -- CLI/legacy non-provider normalized Responses 경로와 legacy Ollama smoke가 깨지지 않았는지 확인한다. -- `agent-contract/outer/openai-compatible-api.md`가 구현과 같은 제한을 말하는지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. -- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. - -### API-1 중간 검증 - -```bash -$ go test ./apps/edge/internal/openai -run 'TestChatCompletions(ResponseModeRouting|ProviderPoolDispatch|PassthroughLegacyProviderRouteUsesTunnel|ProviderPoolAppliesGenerationPolicy|ProviderPoolThinkingPolicyOverridesStrictOutputDisable|ProviderPoolPreservesLargerGenerationPolicyValues)' -count=1 -(output) -``` - -### API-2 중간 검증 - -```bash -$ go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponsesDispatchesNonStreamingRequest|TestResponsesReturnsOpenAICompatibleShape' -count=1 -(output) -``` - -### 최종 검증 - -```bash -$ go test ./apps/edge/internal/openai -count=1 -(output) -``` - -```bash -$ go test ./apps/edge/... -count=1 -(output) -``` - -```bash -$ make test-openai-ollama -(output) -``` - ---- - -> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** -> If anything is blank, go back and fill it in before saving this file. -> Leave review-agent-only sections unchanged. - -## Section Ownership - -| Section | Owner | Note | -|---------|-------|------| -| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | -| 구현 항목별 완료 여부 | Implementing agent checks only | Item names are fixed | -| 구현 체크리스트 | Implementing agent checks only | Text/order copied from plan | -| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | -| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text | -| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected Milestone lock blocks implementation | -| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | -| 검증 결과 | Implementing agent fills output only | Command changes require `계획 대비 변경 사항` | diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 2ea612b..06eea37 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -59,18 +59,23 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) return } + providerTunnelRoute := routeUsesProviderTunnel(dispatch) basePrompt := promptFromMessages(req.Messages) if strings.TrimSpace(basePrompt) == "" { writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required") return } outputPolicy := s.resolveOutputPolicy(basePrompt) - if responseMode == responseModePassthrough && !responseModeWasExplicit(runMeta) && outputPolicy.Strict { + if responseMode == responseModePassthrough && !responseModeWasExplicit(runMeta) && outputPolicy.Strict && !providerTunnelRoute { responseMode = responseModeTransformed } if catalogEntry := s.findProviderPoolEntry(req.Model); catalogEntry != nil { applyModelCatalogGenerationPolicyToChat(&req, *catalogEntry, outputPolicy.Strict) } + if providerTunnelRoute && responseMode == responseModeTransformed { + writeError(w, http.StatusBadRequest, "invalid_request_error", "metadata.iop_response_mode=transformed is not supported for OpenAI-compatible provider model groups") + return + } // OpenAI-compatible provider routes default to pure passthrough (SDD D02): // provider status/headers/body bytes are relayed to the caller unmodified @@ -78,12 +83,12 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { // passthrough+sideband keeps the provider tunnel but exposes IOP // observations through the sideband extension surface (SDD S05). switch { - case responseMode == responseModePassthrough && routeUsesProviderTunnel(dispatch): + case responseMode == responseModePassthrough && providerTunnelRoute: estimate := s.estimateChatInputTokens(basePrompt, runMeta, req.Tools, req.ToolChoice) contextClass := classifyContext(estimate, s.longContextThreshold()) s.tunnelChatCompletionPassthrough(w, r, req, dispatch, runMeta, rawBody, estimate, contextClass) return - case responseMode == responseModePassthroughSideband && routeUsesProviderTunnel(dispatch): + case responseMode == responseModePassthroughSideband && providerTunnelRoute: estimate := s.estimateChatInputTokens(basePrompt, runMeta, req.Tools, req.ToolChoice) contextClass := classifyContext(estimate, s.longContextThreshold()) s.tunnelChatCompletionPassthroughSideband(w, r, req, dispatch, runMeta, rawBody, estimate, contextClass) diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go index 6a66054..e29b91d 100644 --- a/apps/edge/internal/openai/responses_handler.go +++ b/apps/edge/internal/openai/responses_handler.go @@ -62,6 +62,10 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { 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) diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 1ec2fd3..7fbdfd8 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -192,6 +192,15 @@ func relayProviderFixtureFrames(providerURL, method, path string, body []byte) ( return frames, nil } +func staticProviderTunnelFrames(body string) chan *iop.ProviderTunnelFrame { + frames := make(chan *iop.ProviderTunnelFrame, 3) + 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(body)} + frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true} + close(frames) + return frames +} + func (s *fakeRunService) CancelRun(_ context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) { s.cancelMu.Lock() s.cancelCalls = append(s.cancelCalls, req) @@ -380,7 +389,7 @@ func TestChatCompletionsPreservesProviderFinishReason(t *testing.T) { fake.events <- &iop.RunEvent{Type: "delta", Delta: "truncated"} fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -477,7 +486,7 @@ func TestChatCompletionsOmitsProviderToolChoiceAuto(t *testing.T) { fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -510,7 +519,7 @@ func TestChatCompletionsPassesProviderForcedToolChoice(t *testing.T) { fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -679,7 +688,7 @@ func TestChatCompletionsSynthesizesTextToolCallsForProviderRoute(t *testing.T) { fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n\n\n\n[\"git status\"]\n\n\n"} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -712,7 +721,7 @@ func TestChatCompletionsSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceh fake.events <- &iop.RunEvent{Type: "delta", Delta: "두 줄을 `18083` -> `18081`로 수정하고 검증을 진행한다.\n\n" + editToolCallFixture()} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -763,7 +772,7 @@ func TestChatCompletionsStreamSynthesizesEditToolCallWithMarkdownAndAngleBracket fake.events <- &iop.RunEvent{Type: "delta", Delta: "수정합니다.\n\n" + editToolCallFixture()} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -827,7 +836,7 @@ func TestChatCompletionsSynthesizesProviderTextToolCallsWhenFallbackMarked(t *te }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -873,7 +882,7 @@ func TestChatCompletionsSanitizesKnownSentinelToken(t *testing.T) { fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer <|mask_end|>"} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -941,7 +950,7 @@ func TestChatCompletionsPassesThroughNativeToolCallsFromProviderRoute(t *testing }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -984,7 +993,7 @@ func TestChatCompletionsNativeToolCallPreservesLeadingProse(t *testing.T) { }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -1030,7 +1039,7 @@ func TestChatCompletionsNormalizesNativeToolCallArgumentsForProviderRoute(t *tes }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -1081,7 +1090,7 @@ func TestChatCompletionsRetriesMalformedToolCallBeforeResponse(t *testing.T) { runIDs: []string{"run-bad", "run-good"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -1141,7 +1150,7 @@ func TestChatCompletionsFailsMalformedToolCallAfterRetryLimit(t *testing.T) { runIDs: []string{"run-bad-1", "run-bad-2"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -1174,7 +1183,7 @@ func TestChatCompletionsToolChoiceNoneSkipsRuntimeToolValidation(t *testing.T) { }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -1219,7 +1228,7 @@ func TestChatCompletionsToolsStreamRetriesMalformedToolCallBeforeChunk(t *testin runIDs: []string{"run-bad", "run-good"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -1278,7 +1287,7 @@ func TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk( runIDs: []string{"run-bad", "run-good"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -1331,7 +1340,7 @@ func TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimi runIDs: []string{"run-bad-1", "run-bad-2"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -1835,7 +1844,7 @@ func TestChatCompletionsMapsMaxCompletionTokens(t *testing.T) { fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"from-request", @@ -1925,7 +1934,7 @@ func TestChatCompletionsStreamWithToolsBuffersTextUntilComplete(t *testing.T) { &iop.RunEvent{Type: "delta", Delta: "streaming before complete"}, &iop.RunEvent{Type: "complete"}, )} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"stream-model", @@ -2009,7 +2018,7 @@ func TestChatCompletionsStreamPassesThroughNativeToolCallsFromProviderRoute(t *t }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"stream-model", @@ -2045,7 +2054,7 @@ func TestChatCompletionsStreamDropsRawTextWhenNativeToolCallsArrive(t *testing.T }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"stream-model", @@ -2081,7 +2090,7 @@ func TestChatCompletionsToolsStreamNativeToolCallPreservesLeadingProse(t *testin }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"stream-model", @@ -2141,7 +2150,7 @@ func TestChatCompletionsStreamDropsPartialTextToolCallSuffixWhenNativeToolCallsA }, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"stream-model", @@ -2456,7 +2465,7 @@ func TestChatCompletionsStreamSanitizesKnownSentinelTokenAcrossDeltas(t *testing fake.events <- &iop.RunEvent{Type: "delta", Delta: "_end|> world"} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"stream-model", @@ -3310,7 +3319,7 @@ func TestResponsesSanitizesKnownSentinelToken(t *testing.T) { fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer <|mask_end|>"} fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"client-model", "input":"say hello" @@ -3674,7 +3683,7 @@ func TestChatCompletionsRouteCatalogDispatchesModelB(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "model-a", Adapter: "ollama", Target: "llama3"}, - {Model: "model-b", Adapter: "vllm", Target: "qwen", MaxQueue: 5, QueueTimeoutMS: 2000}, + {Model: "model-b", Adapter: "ollama", Target: "qwen", MaxQueue: 5, QueueTimeoutMS: 2000}, }, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ @@ -3688,7 +3697,7 @@ func TestChatCompletionsRouteCatalogDispatchesModelB(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" { + if fake.req.Adapter != "ollama" || fake.req.Target != "qwen" { t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target) } if fake.req.ModelGroupKey != "model-b" { @@ -3746,7 +3755,7 @@ func TestResponsesRouteCatalogDispatchesRoute(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ ModelRoutes: []config.OpenAIRouteEntry{ - {Model: "model-a", Adapter: "vllm", Target: "qwen"}, + {Model: "model-a", Adapter: "ollama", Target: "qwen"}, }, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ @@ -3759,7 +3768,7 @@ func TestResponsesRouteCatalogDispatchesRoute(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" { + if fake.req.Adapter != "ollama" || fake.req.Target != "qwen" { t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target) } if fake.req.ModelGroupKey != "model-a" { @@ -4143,11 +4152,13 @@ func TestChatCompletionsProviderPoolDispatch(t *testing.T) { } } -func TestChatCompletionsStrictOutputProviderPoolDefaultUsesTransformedPath(t *testing.T) { - fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)} - fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"} - fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} - fake.events <- &iop.RunEvent{Type: "complete"} +func TestChatCompletionsStrictOutputProviderPoolDefaultKeepsPassthroughPath(t *testing.T) { + frames := make(chan *iop.ProviderTunnelFrame, 3) + 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(`{"ok":true}`)} + frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true} + close(frames) + fake := &fakeRunService{tunnelFrames: frames} catalog := []config.ModelCatalogEntry{ {ID: "qwen3.6:35b", Providers: map[string]string{"prov-vllm": "Qwen3-35B-A22B"}}, @@ -4166,28 +4177,14 @@ func TestChatCompletionsStrictOutputProviderPoolDefaultUsesTransformedPath(t *te if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - if len(fake.tunnelReqsSnapshot()) != 0 { - t.Fatal("strict output default must keep normalized path unless passthrough is explicit") + if len(fake.tunnelReqsSnapshot()) != 1 { + t.Fatalf("strict output default must keep provider routes on passthrough, got %d tunnel dispatches", len(fake.tunnelReqsSnapshot())) } - reqs := fake.reqsSnapshot() - if len(reqs) != 1 { - t.Fatalf("expected 1 SubmitRun call, got %d", len(reqs)) + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("strict output provider route must not fall back to normalized SubmitRun, got %d calls", len(fake.reqsSnapshot())) } - if !reqs[0].ProviderPool { - t.Fatal("provider-pool dispatch should be preserved on transformed path") - } - if got := reqs[0].Metadata[responseModeMetadataKey]; got != responseModeTransformed { - t.Fatalf("response mode metadata: got %q, want %q", got, responseModeTransformed) - } - if got := w.Header().Get(responseModeHeaderName); got != responseModeTransformed { - t.Fatalf("response mode header: got %q, want %q", got, responseModeTransformed) - } - var resp chatCompletionResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if resp.Choices[0].Message.ReasoningContent != "think" { - t.Fatalf("reasoning_content: got %q, want think", resp.Choices[0].Message.ReasoningContent) + if got := w.Header().Get(responseModeHeaderName); got != "" { + t.Fatalf("pure passthrough must not carry response mode header, got %q", got) } } @@ -4213,10 +4210,10 @@ func TestChatCompletionsResponseModeRouting(t *testing.T) { wantTunnel: true, }, { - name: "transformed mode keeps normalized path", - metadata: `"metadata":{"iop_response_mode":"transformed"}`, - wantStatus: http.StatusOK, - wantRun: true, + name: "transformed mode is rejected for provider route", + metadata: `"metadata":{"iop_response_mode":"transformed"}`, + wantStatus: http.StatusBadRequest, + wantMessage: "metadata.iop_response_mode=transformed is not supported for OpenAI-compatible provider model groups", }, { name: "unknown mode fails fast", @@ -4521,13 +4518,10 @@ func TestChatCompletionsTransformedModeLabelsIOPOutput(t *testing.T) { &iop.RunEvent{Type: "delta", Delta: "ok"}, &iop.RunEvent{Type: "complete"}, )} - srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) - srv.SetModelCatalog([]config.ModelCatalogEntry{ - {ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}}, - }) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "backend-model"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ - "model":"pool-model", + "model":"client-model", "messages":[{"role":"user","content":"hello"}], "metadata":{"iop_response_mode":"transformed"} }`)) @@ -4921,35 +4915,39 @@ func TestChatCompletionsPassthroughLegacyProviderRouteUsesTunnel(t *testing.T) { } } -// TestChatCompletionsExplicitNonPassthroughModeKeepsNormalizedPath verifies -// that an explicit transformed response mode stays on the legacy normalized -// RunEvent path. -func TestChatCompletionsExplicitNonPassthroughModeKeepsNormalizedPath(t *testing.T) { +// TestChatCompletionsLegacyProviderRouteRejectsTransformedMode verifies that +// explicit transformed mode cannot send a legacy provider route back through +// the normalized RunEvent path. +func TestChatCompletionsLegacyProviderRouteRejectsTransformedMode(t *testing.T) { fake := &fakeRunService{events: bufferedRunEvents( &iop.RunEvent{Type: "delta", Delta: "ok"}, &iop.RunEvent{Type: "complete"}, )} - srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) - srv.SetModelCatalog([]config.ModelCatalogEntry{ - {ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}}, - }) + srv := NewServer(config.EdgeOpenAIConf{ + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "compat-model", Adapter: "openai_compat", Target: "backend-model"}, + }, + }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ - "model":"pool-model", + "model":"compat-model", "messages":[{"role":"user","content":"hello"}], "metadata":{"iop_response_mode":"transformed"} }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) - if w.Code != http.StatusOK { + if w.Code != http.StatusBadRequest { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - if len(fake.tunnelReqsSnapshot()) != 0 { - t.Error("explicit non-passthrough mode must not use the tunnel") + if !strings.Contains(w.Body.String(), "metadata.iop_response_mode=transformed is not supported for OpenAI-compatible provider model groups") { + t.Fatalf("expected transformed rejection, got %s", w.Body.String()) } - if len(fake.reqsSnapshot()) != 1 { - t.Fatalf("expected normalized SubmitRun dispatch, got %d", len(fake.reqsSnapshot())) + if len(fake.tunnelReqsSnapshot()) != 0 { + t.Error("rejected transformed mode must not use the tunnel") + } + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("rejected transformed mode must not use SubmitRun, got %d calls", len(fake.reqsSnapshot())) } } @@ -5396,10 +5394,10 @@ func TestChatCompletionsPassthroughSidebandNonStreamingTimeoutSendsCancelRun(t * } func TestChatCompletionsProviderPoolAppliesGenerationPolicy(t *testing.T) { - fake := &fakeRunService{events: bufferedRunEvents( - &iop.RunEvent{Type: "delta", Delta: "ok"}, - &iop.RunEvent{Type: "complete"}, - )} + fake := &fakeRunService{ + tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`), + tunnelServedTarget: "Ornith-1.0-35B", + } catalog := []config.ModelCatalogEntry{{ ID: "ornith:35b", DefaultMaxTokens: 32768, @@ -5410,14 +5408,10 @@ func TestChatCompletionsProviderPoolAppliesGenerationPolicy(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) srv.SetModelCatalog(catalog) - // Explicit non-passthrough mode keeps the normalized RunEvent path where - // catalog generation policy applies; omitted mode now defaults to tunnel - // passthrough for provider-pool routes. req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"ornith:35b", "messages":[{"role":"user","content":"hello"}], - "max_tokens":4096, - "metadata":{"iop_response_mode":"transformed"} + "max_tokens":4096 }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) @@ -5425,23 +5419,33 @@ func TestChatCompletionsProviderPoolAppliesGenerationPolicy(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - options, ok := fake.req.Input["options"].(map[string]any) - if !ok { - t.Fatalf("options not passed: %+v", fake.req.Input) + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("provider-pool generation policy must stay on tunnel path, got %d SubmitRun calls", len(fake.reqsSnapshot())) } - if options["max_tokens"].(int) != 32768 { - t.Fatalf("max_tokens policy not applied: %+v", options) + bodies := fake.tunnelBodiesSnapshot() + if len(bodies) != 1 { + t.Fatalf("expected 1 tunnel body, got %d", len(bodies)) } - if fake.req.Input["thinking_token_budget"].(int) != 8192 { - t.Fatalf("thinking_token_budget policy not applied: %+v", fake.req.Input) + 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_tokens"].(float64) != 32768 { + t.Fatalf("max_tokens policy not applied: %+v", providerReq) + } + if providerReq["thinking_token_budget"].(float64) != 8192 { + t.Fatalf("thinking_token_budget policy not applied: %+v", providerReq) } } func TestChatCompletionsProviderPoolThinkingPolicyOverridesStrictOutputDisable(t *testing.T) { - fake := &fakeRunService{events: bufferedRunEvents( - &iop.RunEvent{Type: "delta", Delta: "ok"}, - &iop.RunEvent{Type: "complete"}, - )} + fake := &fakeRunService{ + tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`), + tunnelServedTarget: "Ornith-1.0-35B", + } catalog := []config.ModelCatalogEntry{{ ID: "ornith:35b", DefaultThinkingTokenBudget: 8192, @@ -5452,8 +5456,7 @@ func TestChatCompletionsProviderPoolThinkingPolicyOverridesStrictOutputDisable(t req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"ornith:35b", - "messages":[{"role":"user","content":"hello"}], - "metadata":{"iop_response_mode":"transformed"} + "messages":[{"role":"user","content":"hello"}] }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) @@ -5461,19 +5464,30 @@ func TestChatCompletionsProviderPoolThinkingPolicyOverridesStrictOutputDisable(t if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - if fake.req.Input["think"] != true { - t.Fatalf("provider-pool thinking policy should keep thinking enabled under strict output: %+v", fake.req.Input) + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("provider-pool thinking policy must stay on tunnel path, got %d SubmitRun calls", len(fake.reqsSnapshot())) } - if fake.req.Input["thinking_token_budget"].(int) != 8192 { - t.Fatalf("thinking_token_budget policy not applied: %+v", fake.req.Input) + 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["think"] != true { + t.Fatalf("provider-pool thinking policy should keep thinking enabled under strict output: %+v", providerReq) + } + if providerReq["thinking_token_budget"].(float64) != 8192 { + t.Fatalf("thinking_token_budget policy not applied: %+v", providerReq) } } func TestChatCompletionsProviderPoolPreservesLargerGenerationPolicyValues(t *testing.T) { - fake := &fakeRunService{events: bufferedRunEvents( - &iop.RunEvent{Type: "delta", Delta: "ok"}, - &iop.RunEvent{Type: "complete"}, - )} + fake := &fakeRunService{ + tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`), + tunnelServedTarget: "Ornith-1.0-35B", + } catalog := []config.ModelCatalogEntry{{ ID: "ornith:35b", DefaultMaxTokens: 32768, @@ -5488,8 +5502,7 @@ func TestChatCompletionsProviderPoolPreservesLargerGenerationPolicyValues(t *tes "model":"ornith:35b", "messages":[{"role":"user","content":"hello"}], "max_tokens":40000, - "thinking_token_budget":2048, - "metadata":{"iop_response_mode":"transformed"} + "thinking_token_budget":2048 }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) @@ -5497,15 +5510,22 @@ func TestChatCompletionsProviderPoolPreservesLargerGenerationPolicyValues(t *tes if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - options, ok := fake.req.Input["options"].(map[string]any) - if !ok { - t.Fatalf("options not passed: %+v", fake.req.Input) + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("provider-pool generation policy must stay on tunnel path, got %d SubmitRun calls", len(fake.reqsSnapshot())) } - if options["max_tokens"].(int) != 40000 { - t.Fatalf("larger max_tokens should be preserved: %+v", options) + bodies := fake.tunnelBodiesSnapshot() + if len(bodies) != 1 { + t.Fatalf("expected 1 tunnel body, got %d", len(bodies)) } - if fake.req.Input["thinking_token_budget"].(int) != 2048 { - t.Fatalf("explicit thinking_token_budget should be preserved: %+v", fake.req.Input) + 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["max_tokens"].(float64) != 40000 { + t.Fatalf("larger max_tokens should be preserved: %+v", providerReq) + } + if providerReq["thinking_token_budget"].(float64) != 2048 { + t.Fatalf("explicit thinking_token_budget should be preserved: %+v", providerReq) } } @@ -5543,9 +5563,9 @@ func TestChatCompletionsLegacyRouteSetsProviderPoolFalse(t *testing.T) { } } -// TestResponsesProviderPoolDispatch verifies that the /v1/responses endpoint -// sets ProviderPool=true and leaves Adapter/Target empty when the request model -// matches a catalog entry, mirroring chat/completions provider-pool semantics. +// 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"} @@ -5564,17 +5584,14 @@ func TestResponsesProviderPoolDispatch(t *testing.T) { w := httptest.NewRecorder() srv.handleResponses(w, req) - if w.Code != http.StatusOK { + if w.Code != http.StatusBadRequest { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - if !fake.req.ProviderPool { - t.Error("ProviderPool should be true for catalog-matched model via /v1/responses") + 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 fake.req.ModelGroupKey != "prov-vllm:model-a" { - t.Errorf("ModelGroupKey: got %q, want prov-vllm:model-a", fake.req.ModelGroupKey) - } - if fake.req.Adapter != "" || fake.req.Target != "" { - t.Errorf("Adapter/Target should be empty for provider-pool dispatch, got %q/%q", fake.req.Adapter, fake.req.Target) + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("provider-pool /v1/responses must not call SubmitRun, got %d calls", len(fake.reqsSnapshot())) } } @@ -5601,18 +5618,14 @@ func TestResponsesProviderPoolAppliesGenerationPolicy(t *testing.T) { w := httptest.NewRecorder() srv.handleResponses(w, req) - if w.Code != http.StatusOK { + if w.Code != http.StatusBadRequest { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - options, ok := fake.req.Input["options"].(map[string]any) - if !ok { - t.Fatalf("options not passed: %+v", fake.req.Input) + 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 options["max_tokens"].(int) != 32768 { - t.Fatalf("max_output_tokens policy not applied: %+v", options) - } - if fake.req.Input["thinking_token_budget"].(int) != 8192 { - t.Fatalf("thinking_token_budget policy not applied: %+v", fake.req.Input) + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("provider-pool /v1/responses must not call SubmitRun, got %d calls", len(fake.reqsSnapshot())) } } @@ -5636,14 +5649,14 @@ func TestResponsesProviderPoolThinkingPolicyOverridesStrictOutputDisable(t *test w := httptest.NewRecorder() srv.handleResponses(w, req) - if w.Code != http.StatusOK { + if w.Code != http.StatusBadRequest { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - if fake.req.Input["think"] != true { - t.Fatalf("provider-pool thinking policy should keep thinking enabled under strict output: %+v", fake.req.Input) + 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 fake.req.Input["thinking_token_budget"].(int) != 8192 { - t.Fatalf("thinking_token_budget policy not applied: %+v", fake.req.Input) + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("provider-pool /v1/responses must not call SubmitRun, got %d calls", len(fake.reqsSnapshot())) } } @@ -5758,7 +5771,7 @@ func TestChatCompletionsProviderStreamSynthesizesTextToolCalls(t *testing.T) { fake.events <- &iop.RunEvent{Type: "delta", Delta: "[\"echo 1\"]"} fake.events <- &iop.RunEvent{Type: "complete"} - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -5798,7 +5811,7 @@ func TestChatCompletionsProviderStreamBlocksUnknownTextToolCall(t *testing.T) { runIDs: []string{"run-bad-1", "run-bad-2"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -5870,7 +5883,7 @@ func TestChatCompletionsFailsMalformedTextToolCallAfterRetryLimit(t *testing.T) runIDs: []string{"run-bad-1", "run-bad-2"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -5909,7 +5922,7 @@ func TestChatCompletionsProviderStreamBlocksMalformedTextToolCall(t *testing.T) runIDs: []string{"run-bad-1", "run-bad-2"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", @@ -6034,7 +6047,7 @@ func TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema(t *tes runIDs: []string{"run-bad-1", "run-bad-2"}, } - srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b",