refactor: reject transformed mode and responses for provider-pool/tunnel routes
This commit is contained in:
parent
d2d4449703
commit
ff7330680f
8 changed files with 585 additions and 310 deletions
|
|
@ -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"`를 사용한다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,322 @@
|
|||
<!-- task=openai_passthrough_model_groups plan=0 tag=API -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 `계획 대비 변경 사항` |
|
||||
|
|
@ -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를 확인한다.
|
||||
|
|
@ -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 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
<!-- task=openai_passthrough_model_groups plan=0 tag=API -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 `계획 대비 변경 사항` |
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"git status\"]\n</parameter>\n</function>\n</tool_call>"}
|
||||
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: "<tool_call><function=run_commands><parameter=commands>[\"echo 1\"]</parameter></function></tool_call>"}
|
||||
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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue