diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md index a24bec8..36b5f00 100644 --- a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md +++ b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md @@ -55,8 +55,8 @@ OpenAI-compatible 요청의 경로 선택을 caller metadata가 아니라 `model vLLM, vLLM-MLX, Lemonade, SGLang, Seulgivibe 같은 OpenAI-compatible provider가 지원하는 request surface를 Edge가 자체 allowlist로 제한하지 않게 한다. - [x] [field-preserve] provider-pool과 direct OpenAI-compatible provider tunnel body는 `model` served target rewrite와 auth/header 처리 외에 provider-native request payload를 보존한다. 검증: `chat_template_kwargs`, 새 임의 provider field, tools/stream_options/store와 중첩 값 fixture가 provider request body에 그대로 남는 Edge/Node 테스트가 통과한다. -- [ ] [provider-error] provider가 모르는 field는 Edge가 선판단하지 않고 provider HTTP status/body로 relay한다. 검증: fake provider가 extension field를 거부하는 fixture에서 Edge가 provider error를 변환 없이 전달한다. -- [ ] [policy-priority] catalog generation policy와 IOP 내부 기본값 처리는 caller가 명시한 provider-native thinking field를 삭제하거나 대체하지 않는다. 검증: `chat_template_kwargs.enable_thinking=false`가 있는 요청에서 `think`/budget 주입 또는 rewrite가 provider-native 값을 덮지 않는다. +- [x] [provider-error] provider가 모르는 field는 Edge가 선판단하지 않고 provider HTTP status/body로 relay한다. 검증: fake provider가 extension field를 거부하는 fixture에서 Edge가 provider error를 변환 없이 전달한다. +- [x] [policy-priority] catalog generation policy와 IOP 내부 기본값 처리는 caller가 명시한 provider-native thinking field를 삭제하거나 대체하지 않는다. 검증: `chat_template_kwargs.enable_thinking=false`가 있는 요청에서 `think`/budget 주입 또는 rewrite가 provider-native 값을 덮지 않는다. ### Epic: [verification] Verification and Deployment Evidence @@ -70,13 +70,13 @@ vLLM, vLLM-MLX, Lemonade, SGLang, Seulgivibe 같은 OpenAI-compatible provider - 상태: 없음 - 요청일: 없음 -- 완료 근거: 기능 Task와 검증이 아직 충족되지 않았다. +- 완료 근거: `provider-error`, `policy-priority`는 local fake provider/fixture 기반 회귀 테스트와 `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/node/internal/adapters/openai_compat` 통과로 충족했다. `devcorp-smoke` evidence가 아직 남아 있다. - 검토 항목: - [ ] 모든 기능 Task와 Task별 검증 evidence가 `Roadmap Completion`에 남아 있다. - [ ] SDD Evidence Map이 최종 검증 evidence와 일치한다. - [ ] dev-corp smoke 결과가 문서화되어 있다. - agent-ui 상태 반영: 해당 없음 -- 리뷰 코멘트: 없음 +- 리뷰 코멘트: 남은 완료 차단 항목은 dev-corp Ornith Spark `chat_template_kwargs.enable_thinking=false` field smoke evidence다. ## 범위 제외 diff --git a/agent-task/m-openai-compatible-provider-passthrough-contract-sync/CODE_REVIEW-cloud-G07.md b/agent-task/m-openai-compatible-provider-passthrough-contract-sync/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..d438b3f --- /dev/null +++ b/agent-task/m-openai-compatible-provider-passthrough-contract-sync/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,269 @@ + + +# Code Review Reference - TEST + +> **[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-14 +task=m-openai-compatible-provider-passthrough-contract-sync, plan=0, tag=TEST + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md` +- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md) +- Task ids: + - `devcorp-smoke`: dev-corp IOP 경유 Ornith Spark 요청에서 `chat_template_kwargs.enable_thinking=false`가 provider까지 전달된다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-compatible-provider-passthrough-contract-sync/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [TEST-1] Dev-Corp Preflight | [ ] | +| [TEST-2] Think-Off Field Smoke | [ ] | + +## 구현 체크리스트 + +- [ ] TEST-1 dev-corp runner, provider, public Edge preflight를 실행하고 blocker 여부를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] TEST-2 direct Spark와 IOP 경유 `chat_template_kwargs.enable_thinking=false` 단일 smoke를 실행하고 first content latency, reasoning delta 관측, content summary를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-compatible-provider-passthrough-contract-sync/`를 `agent-task/archive/YYYY/MM/m-openai-compatible-provider-passthrough-contract-sync/`로 이동하고 최종 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-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- 계획된 검증 명령(TEST-1 preflight 2개, TEST-2 Python smoke)을 **대체하지 않았다**. 명령 자체는 고정 계약으로 유지했다. +- 다만 이 구현 세션은 dev-corp LAN 밖에 있어 유일한 접근 경로인 `ssh fe@172.24.63.178`(mac-mini runner)에 도달하지 못했다. 그 결과 TEST-1 preflight와 TEST-2 smoke를 runner에서 실행할 수 없어 **evidence 수집이 환경 blocker로 중단**되었다. +- 우회 경로 확인: `agent-test/dev-corp/{rules.md,inventory.yaml,*-smoke.md}`를 검색했으나 bastion/jump host/VPN/proxy 대체 경로는 문서화되어 있지 않다. 내부 Spark endpoint(`192.168.2.2:8003`, `192.168.2.4:8005`)와 secret 파일은 runner를 통해서만 접근 가능하다. +- 세션 도달성 실측(2026-07-13T22:07:02Z 기준, 아래 `검증 결과`에 원문 첨부): + - runner SSH `172.24.63.178:22` → `Connection timed out` (exit 255) + - 내부 Spark01 `192.168.2.2:8003/health` → timeout (exit 28, http 000) + - 내부 Spark02 `192.168.2.4:8005/health` → timeout (exit 28, http 000) + - 공개 Edge `https://digitalplatform.iop.ai.kr/v1/models` → http 401 (도달 가능, 인증 필요 = 정상). 단 direct Spark case를 대체하지 못하므로 S10 evidence로 불충분. +- 이 blocker는 stub 규칙(외부 환경/secret/서비스 준비 blocker)에 따라 **user-review 사유가 아니라 검증 blocker**로 분류하여 `검증 결과`에 기록한다. `사용자 리뷰 요청`은 `없음`으로 유지한다. + +## 주요 설계 결정 + +- repo source 파일 수정 없음. 이 plan은 evidence 수집 전용(TEST 등급)이며 코드/config/build 변경을 수행하지 않는다. +- 출력 위조 금지 원칙에 따라 TEST-2 smoke JSON summary를 임의 생성하지 않았다. runner 접근이 회복되는 후속 세션(dev-corp LAN 내부 실행)에서 TEST-1→TEST-2를 순서대로 재실행해야 evidence가 채워진다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `Roadmap Targets`가 `devcorp-smoke` 하나만 가리키는지 확인한다. +- TEST-1 preflight가 secret 원문 없이 runner/provider/public Edge 상태를 증명하는지 확인한다. +- TEST-2 결과에서 direct Spark01/Spark02와 IOP public case가 모두 `ok: true`, `reasoning_seen: false`, `first_content_sec` 값을 갖는지 확인한다. +- 실패 또는 blocker가 user-review가 아니라 검증 blocker/follow-up으로 기록됐는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +> **환경 blocker (BLOCKED, not PASS/FAIL):** 이 구현 세션은 dev-corp LAN 밖이라 유일한 접근 경로인 `ssh fe@172.24.63.178`에 도달하지 못했다. TEST-1 preflight와 TEST-2 smoke는 runner에서 실행되지 못했다. 아래는 세션에서 실측한 도달성 probe 원문이다(위조 아님). runner 접근이 가능한 후속 세션에서 계약 명령을 그대로 재실행해야 한다. + +### TEST-1 중간 검증 +계약 명령(미실행 — runner 도달 불가): +```text +$ ssh fe@172.24.63.178 'cd /Users/fe/agent-work/iop-dev-corp && git status --short --branch && git log --oneline -1 && command -v python3 && command -v curl && test -r build/dev-corp-runtime/.secrets/openai_api_key' +NOT RUN — runner SSH 172.24.63.178:22 unreachable from this session (see probe below) +``` + +```text +$ ssh fe@172.24.63.178 'set -e +curl -fsS http://192.168.2.2:8003/health +curl -fsS http://192.168.2.2:8003/v1/models +curl -fsS http://192.168.2.4:8005/health +curl -fsS http://192.168.2.4:8005/v1/models +cd /Users/fe/agent-work/iop-dev-corp +OPENAI_API_KEY="$(cat build/dev-corp-runtime/.secrets/openai_api_key)" +curl -fsS -H "Authorization: Bearer ${OPENAI_API_KEY}" https://digitalplatform.iop.ai.kr/v1/models +' +NOT RUN — runner SSH unreachable; internal Spark endpoints only reachable via runner +``` + +도달성 probe 실측 (capture 2026-07-13T22:07:02Z): +```text +$ ssh -o BatchMode=yes -o ConnectTimeout=15 fe@172.24.63.178 'echo SSH_OK' +ssh: connect to host 172.24.63.178 port 22: Connection timed out +exit=255 + +$ curl -sS -m 10 -w "\nhttp=%{http_code} time_total=%{time_total}s\n" https://digitalplatform.iop.ai.kr/v1/models +{"error":{"type":"unauthorized","message":"unauthorized"}} +http=401 time_total=0.025189s # 공개 Edge 도달 가능(인증 필요=정상), direct Spark 대체 불가 + +$ curl -sS -m 8 -w "http=%{http_code}\n" http://192.168.2.2:8003/health # direct Spark01 +curl: (28) Connection timed out after 8008 milliseconds +http=000 exit=28 + +$ curl -sS -m 8 -w "http=%{http_code}\n" http://192.168.2.4:8005/health # direct Spark02 +curl: (28) Connection timed out after 8001 milliseconds +http=000 exit=28 +``` +판정: TEST-1 preflight **BLOCKED** — runner/secret/내부 provider 미도달. blocker 지점은 SSH 연결(172.24.63.178:22) 단계. + +### TEST-2 중간 검증 +```text +$ ssh fe@172.24.63.178 'cd /Users/fe/agent-work/iop-dev-corp && OPENAI_API_KEY="$(cat build/dev-corp-runtime/.secrets/openai_api_key)" python3 - <<'"'"'PY'"'"' +import json +import os +import time +import urllib.error +import urllib.request + +payload = { + "model": "ornith:35b", + "messages": [{"role": "user", "content": "Reply with one short sentence and do not include reasoning."}], + "stream": True, + "temperature": 0, + "max_tokens": 64, + "chat_template_kwargs": {"enable_thinking": False}, +} + +cases = [ + ("direct_spark01", "http://192.168.2.2:8003/v1/chat/completions", {}), + ("direct_spark02", "http://192.168.2.4:8005/v1/chat/completions", {}), + ("iop_public_ornith", "https://digitalplatform.iop.ai.kr/v1/chat/completions", {"Authorization": "Bearer " + os.environ["OPENAI_API_KEY"]}), +] + +def run_case(name, url, headers): + req_headers = {"Content-Type": "application/json"} + req_headers.update(headers) + req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=req_headers, method="POST") + started = time.monotonic() + content = [] + reasoning_seen = False + first_content_sec = None + status = None + try: + with urllib.request.urlopen(req, timeout=180) as resp: + status = resp.status + for raw in resp: + line = raw.decode("utf-8", "replace").strip() + if not line.startswith("data: "): + continue + data = line[6:] + if data == "[DONE]": + break + obj = json.loads(data) + for choice in obj.get("choices", []): + delta = choice.get("delta") or {} + if delta.get("reasoning_content") or delta.get("reasoning") or delta.get("reasoning_text"): + reasoning_seen = True + piece = delta.get("content") + if piece: + if first_content_sec is None: + first_content_sec = round(time.monotonic() - started, 3) + content.append(piece) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + return {"name": name, "ok": False, "status": exc.code, "error_body": body[:500]} + except Exception as exc: + return {"name": name, "ok": False, "error": repr(exc)} + text = "".join(content).strip() + return { + "name": name, + "ok": status == 200 and first_content_sec is not None and not reasoning_seen, + "status": status, + "first_content_sec": first_content_sec, + "reasoning_seen": reasoning_seen, + "content_preview": text[:200], + } + +results = [run_case(*case) for case in cases] +print(json.dumps({"payload": {"model": payload["model"], "chat_template_kwargs": payload["chat_template_kwargs"]}, "results": results}, ensure_ascii=False, indent=2)) +if not all(item.get("ok") for item in results): + raise SystemExit(1) +PY' +NOT RUN — depends on runner (TEST-1) which is unreachable from this session. +# direct_spark01/direct_spark02 case는 내부 LAN(192.168.2.x)에서만 도달 가능하므로 runner 밖에서 대체 실행 불가. +# JSON summary는 위조하지 않는다. runner 접근 회복 후 재실행 필요. +``` +판정: TEST-2 think-off field smoke **BLOCKED** — 선행 TEST-1(runner 도달) 미충족. + +### 최종 검증 +```text +$ TEST-1 preflight commands + TEST-2 smoke command +BLOCKED — 이 세션에서 dev-corp runner(ssh fe@172.24.63.178)에 도달 불가하여 3개 계약 명령 모두 미실행. +세 case(direct_spark01, direct_spark02, iop_public_ornith) 중 direct Spark 2건은 내부 LAN 경유(runner)만 가능. +후속 조치: dev-corp LAN 내부에서 runner 접근이 가능한 세션이 TEST-1 → TEST-2를 순서대로 재실행하고 이 파일의 검증 결과를 채운다. +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| 구현 항목별 완료 여부 | Implementing agent | Check completed items only | +| 구현 체크리스트 | Implementing agent | Check completed items only; final checkbox is mandatory | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 | Implementing agent | Fill actual command output | +| 코드리뷰 결과 | Review agent appends | Not included in stub | diff --git a/agent-task/m-openai-compatible-provider-passthrough-contract-sync/PLAN-cloud-G07.md b/agent-task/m-openai-compatible-provider-passthrough-contract-sync/PLAN-cloud-G07.md new file mode 100644 index 0000000..de19a9c --- /dev/null +++ b/agent-task/m-openai-compatible-provider-passthrough-contract-sync/PLAN-cloud-G07.md @@ -0,0 +1,265 @@ + + +# Implementation Plan - TEST + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 검증 명령을 실행하고 실제 출력 또는 저장한 출력 파일 경로를 붙인 뒤 active 파일을 유지한 채 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 갱신은 code-review/runtime 책임이다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`를 만들지 않는다. 외부 환경, secret, 서비스 준비, 반복 실패, evidence 공백은 일반 follow-up 또는 검증 blocker로 기록한다. + +## 배경 + +현재 Milestone에서 local code/test로 `provider-error`와 `policy-priority`는 충족됐고, 남은 기능 Task는 dev-corp Ornith Spark field smoke뿐이다. 이 작업은 public IOP Edge와 mac-mini runner, 내부 Spark provider endpoint, runtime secret 파일을 함께 확인해야 하므로 작은 로컬 수정이 아니라 원격 검증 계획으로 분리한다. 목표는 `chat_template_kwargs.enable_thinking=false`가 direct Spark와 IOP 경유 요청 모두에서 provider-native think-off 동작으로 관측되는지 evidence를 남기는 것이다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 Milestone lock decision만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트는 금지이며, code-review가 사용자 리뷰 요청의 정당성 검증과 실제 `USER_REVIEW.md` 작성을 소유한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md` +- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md) +- Task ids: + - `devcorp-smoke`: dev-corp IOP 경유 Ornith Spark 요청에서 `chat_template_kwargs.enable_thinking=false`가 provider까지 전달된다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md` +- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md` +- `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-provider-passthrough-contract-sync/SDD.md` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/node/rules.md` +- `agent-ops/rules/project/domain/platform-common/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `agent-test/local/node-smoke.md` +- `agent-test/dev-corp/rules.md` +- `agent-test/dev-corp/edge-smoke.md` +- `agent-test/dev-corp/node-smoke.md` +- `agent-test/dev-corp/platform-common-smoke.md` +- `agent-test/dev-corp/inventory.yaml` +- `agent-ops/skills/project/dev-corp-runtime-deploy/SKILL.md` +- `agent-contract/index.md` +- `agent-contract/outer/openai-compatible-api.md` +- `agent-contract/inner/edge-node-runtime-wire.md` +- `apps/edge/internal/openai/chat_handler.go` +- `apps/edge/internal/openai/stream.go` +- `apps/edge/internal/openai/server_test.go` +- `apps/node/internal/adapters/openai_compat/openai_compat.go` +- `apps/node/internal/adapters/openai_compat/openai_compat_test.go` + +### SDD 기준 + +- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-provider-passthrough-contract-sync/SDD.md` +- 상태: `[승인됨]`, SDD 잠금 해제, 사용자 리뷰 없음. +- Acceptance Scenario: `S10` -> Milestone Task `devcorp-smoke`. +- Evidence Map: `S10` requires dev-corp single-call smoke output summary with `devcorp-smoke`, first content latency, and reasoning/content observation. +- 구현 체크리스트는 S10 Evidence Map에서 역산해 provider direct preflight, IOP Edge route preflight, direct-vs-IOP think-off smoke summary를 만들도록 구성했다. + +### 테스트 환경 규칙 + +- `test_env`: `dev-corp`. +- `agent-test/dev-corp/rules.md`, `edge-smoke.md`, `node-smoke.md`, `platform-common-smoke.md`, `inventory.yaml`을 읽었다. +- 적용 명령: mac-mini runner `ssh fe@172.24.63.178`, repo root `/Users/fe/agent-work/iop-dev-corp`, public OpenAI base `https://digitalplatform.iop.ai.kr/v1`, Spark direct endpoints `http://192.168.2.2:8003/v1`, `http://192.168.2.4:8005/v1`. +- Secret rule: API key 원문은 출력하지 않는다. remote command는 `build/dev-corp-runtime/.secrets/openai_api_key`를 env로만 읽는다. + +#### 테스트 환경 프리플라이트 + +- runner: mac-mini `ssh fe@172.24.63.178`. +- repo root/workdir: `/Users/fe/agent-work/iop-dev-corp`. +- branch/HEAD/dirty/source sync: TEST-1에서 `git status --short --branch`와 `git log --oneline -1`로 기록한다. smoke-only 작업이므로 dirty가 있으면 결과에 남기고, 배포/rebuild가 필요하다고 판단될 때만 dev-corp deploy 절차로 전환한다. +- binary/artifact/config: Edge runtime은 public `iop.ai.kr`; config path는 inventory 기준 `/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/edge.yaml`. 이 plan은 config 수정이나 rebuild를 수행하지 않는다. +- command availability: TEST-1에서 `command -v python3`와 `command -v curl`을 확인한다. +- runtime identity: Edge id `dev-corp-edge`, Edge-Node TCP `iop.ai.kr:18087`. +- external hosts: mac-mini runner, Spark01 `192.168.2.2:8003`, Spark02 `192.168.2.4:8005`, public Edge `digitalplatform.iop.ai.kr`. +- blocker: SSH 불가, secret file unreadable, provider `/health` or `/v1/models` failure, public `/v1/models` auth failure, or Python/curl missing. + +### 테스트 커버리지 공백 + +- direct provider field smoke: existing Go tests cannot prove provider runtime behavior; this plan fills the dev-corp field evidence gap. +- IOP public Edge field smoke: local fake provider tests prove relay/body behavior, but not live Spark provider behavior; this plan fills S10. + +### 심볼 참조 + +- none. 이 plan은 symbol rename/remove를 수행하지 않는다. + +### 분할 판단 + +- split decision policy를 평가했다. +- 단일 plan 선택: 남은 범위는 `devcorp-smoke` 한 Task이고, preflight와 smoke가 같은 원격 환경에서 순차적으로 실행되어야 같은 evidence 묶음으로 리뷰할 수 있다. 코드/API와 call-site rollout 경계가 없고, 독립적으로 PASS 처리 가능한 선행 구현 subtask도 없다. + +### 범위 결정 근거 + +- 제외: runtime rebuild, config refresh, provider process restart, capacity 9/6 load smoke, `/v1/responses` provider-dependent smoke. 이번 plan은 `devcorp-smoke`의 single-call think-off evidence만 수집한다. +- 배포나 config drift가 발견되면 이 plan 안에서 임의 수정하지 않고 blocker 또는 후속 dev-corp deploy plan으로 기록한다. + +### 빌드 등급 + +- `cloud-G07`: 외부 runner, live provider, secret file, public Edge behavior가 중심이며 로컬 deterministic test만으로 완료할 수 없다. + +## 구현 체크리스트 + +- [ ] TEST-1 dev-corp runner, provider, public Edge preflight를 실행하고 blocker 여부를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] TEST-2 direct Spark와 IOP 경유 `chat_template_kwargs.enable_thinking=false` 단일 smoke를 실행하고 first content latency, reasoning delta 관측, content summary를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [TEST-1] Dev-Corp Preflight + +#### 문제 + +`devcorp-smoke`는 local checkout이 아니라 mac-mini runner와 public Edge, 내부 provider endpoint 상태에 의존한다. `agent-test/dev-corp/rules.md`는 runner checkout, provider `/health`와 `/v1/models`, public Edge auth를 먼저 확인하라고 요구한다. + +#### 해결 방법 + +아래 preflight를 실행하고 실패 지점을 정확히 기록한다. secret 값은 출력하지 않는다. + +```bash +ssh fe@172.24.63.178 'cd /Users/fe/agent-work/iop-dev-corp && \ + git status --short --branch && \ + git log --oneline -1 && \ + command -v python3 && \ + command -v curl && \ + test -r build/dev-corp-runtime/.secrets/openai_api_key' +``` + +```bash +ssh fe@172.24.63.178 'set -e +curl -fsS http://192.168.2.2:8003/health +curl -fsS http://192.168.2.2:8003/v1/models +curl -fsS http://192.168.2.4:8005/health +curl -fsS http://192.168.2.4:8005/v1/models +cd /Users/fe/agent-work/iop-dev-corp +OPENAI_API_KEY="$(cat build/dev-corp-runtime/.secrets/openai_api_key)" +curl -fsS -H "Authorization: Bearer ${OPENAI_API_KEY}" https://digitalplatform.iop.ai.kr/v1/models +' +``` + +#### 수정 파일 및 체크리스트 + +- [ ] repo source file 수정 없음. +- [ ] `CODE_REVIEW-cloud-G07.md`에 preflight stdout/stderr 또는 저장 경로를 기록한다. + +#### 테스트 작성 + +별도 테스트 파일 작성 없음. live environment preflight가 테스트다. + +#### 중간 검증 + +위 두 preflight command가 모두 exit 0이어야 TEST-2로 진행한다. + +### [TEST-2] Think-Off Field Smoke + +#### 문제 + +SDD S10은 direct Spark와 IOP 경유 Ornith Spark 요청에서 `chat_template_kwargs.enable_thinking=false`가 provider-native think-off 결과로 관측되어야 한다고 요구한다. local fake provider 테스트만으로는 실제 Spark/vLLM runtime behavior를 증명할 수 없다. + +#### 해결 방법 + +mac-mini runner에서 direct Spark01, direct Spark02, public IOP Edge에 같은 streaming Chat Completions 요청을 보내고 JSON summary를 남긴다. `reasoning_content`, `reasoning`, `reasoning_text` delta가 관측되면 fail로 기록한다. 첫 content latency는 초 단위로 기록한다. + +```bash +ssh fe@172.24.63.178 'cd /Users/fe/agent-work/iop-dev-corp && OPENAI_API_KEY="$(cat build/dev-corp-runtime/.secrets/openai_api_key)" python3 - <<'"'"'PY'"'"' +import json +import os +import time +import urllib.error +import urllib.request + +payload = { + "model": "ornith:35b", + "messages": [{"role": "user", "content": "Reply with one short sentence and do not include reasoning."}], + "stream": True, + "temperature": 0, + "max_tokens": 64, + "chat_template_kwargs": {"enable_thinking": False}, +} + +cases = [ + ("direct_spark01", "http://192.168.2.2:8003/v1/chat/completions", {}), + ("direct_spark02", "http://192.168.2.4:8005/v1/chat/completions", {}), + ("iop_public_ornith", "https://digitalplatform.iop.ai.kr/v1/chat/completions", {"Authorization": "Bearer " + os.environ["OPENAI_API_KEY"]}), +] + +def run_case(name, url, headers): + req_headers = {"Content-Type": "application/json"} + req_headers.update(headers) + req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=req_headers, method="POST") + started = time.monotonic() + content = [] + reasoning_seen = False + first_content_sec = None + status = None + try: + with urllib.request.urlopen(req, timeout=180) as resp: + status = resp.status + for raw in resp: + line = raw.decode("utf-8", "replace").strip() + if not line.startswith("data: "): + continue + data = line[6:] + if data == "[DONE]": + break + obj = json.loads(data) + for choice in obj.get("choices", []): + delta = choice.get("delta") or {} + if delta.get("reasoning_content") or delta.get("reasoning") or delta.get("reasoning_text"): + reasoning_seen = True + piece = delta.get("content") + if piece: + if first_content_sec is None: + first_content_sec = round(time.monotonic() - started, 3) + content.append(piece) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + return {"name": name, "ok": False, "status": exc.code, "error_body": body[:500]} + except Exception as exc: + return {"name": name, "ok": False, "error": repr(exc)} + text = "".join(content).strip() + return { + "name": name, + "ok": status == 200 and first_content_sec is not None and not reasoning_seen, + "status": status, + "first_content_sec": first_content_sec, + "reasoning_seen": reasoning_seen, + "content_preview": text[:200], + } + +results = [run_case(*case) for case in cases] +print(json.dumps({"payload": {"model": payload["model"], "chat_template_kwargs": payload["chat_template_kwargs"]}, "results": results}, ensure_ascii=False, indent=2)) +if not all(item.get("ok") for item in results): + raise SystemExit(1) +PY' +``` + +#### 수정 파일 및 체크리스트 + +- [ ] repo source file 수정 없음. +- [ ] direct Spark01/Spark02 결과와 IOP public 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] 실패 시 HTTP status/body, first content latency, reasoning 관측 여부를 분리 기록한다. + +#### 테스트 작성 + +별도 unit test 작성 없음. SDD S10은 live dev-corp field smoke evidence가 테스트다. + +#### 중간 검증 + +Python smoke command가 exit 0이어야 한다. 출력 JSON에서 세 case 모두 `ok: true`, `reasoning_seen: false`, `first_content_sec` 값이 있어야 한다. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| 없음 | TEST-1, TEST-2는 검증/evidence 수집 작업이다. | +| `agent-task/m-openai-compatible-provider-passthrough-contract-sync/CODE_REVIEW-cloud-G07.md` | 구현 에이전트가 실제 검증 출력과 판단을 기록한다. | + +## 최종 검증 + +최종 검증은 TEST-1 preflight 두 command와 TEST-2 Python smoke command를 순서대로 실행하는 것이다. 모든 command가 exit 0이어야 하고, TEST-2 JSON summary의 세 case가 모두 `ok: true`여야 한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 32756c1..31d7d2b 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -86,7 +86,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { } outputPolicy := s.resolveOutputPolicy(basePrompt) if catalogEntry := s.findProviderPoolEntry(req.Model); catalogEntry != nil { - applyModelCatalogGenerationPolicyToChat(&req, *catalogEntry, outputPolicy.Strict) + applyModelCatalogGenerationPolicyToChat(&req, *catalogEntry, outputPolicy.Strict, chatRequestHasProviderNativeThinking(rawBody)) } // The response path is decided by the resolved route, never by caller @@ -456,12 +456,12 @@ func validateThinkControl(req *chatCompletionRequest) error { return nil } -func applyModelCatalogGenerationPolicyToChat(req *chatCompletionRequest, entry config.ModelCatalogEntry, strictOutput bool) { +func applyModelCatalogGenerationPolicyToChat(req *chatCompletionRequest, entry config.ModelCatalogEntry, strictOutput bool, providerNativeThinking bool) { if req == nil { return } applyOutputTokenPolicy(&req.MaxTokens, req.MaxCompletionTokens, entry.DefaultMaxTokens, entry.MinMaxTokens) - if entry.DefaultThinkingTokenBudget > 0 && chatRequestAllowsDefaultThinkingBudget(*req) { + if entry.DefaultThinkingTokenBudget > 0 && !providerNativeThinking && chatRequestAllowsDefaultThinkingBudget(*req) { if req.ThinkingTokenBudget == nil { req.ThinkingTokenBudget = intPtr(entry.DefaultThinkingTokenBudget) } @@ -511,6 +511,24 @@ func chatRequestAllowsDefaultThinkingBudget(req chatCompletionRequest) bool { return true } +func chatRequestHasProviderNativeThinking(rawBody []byte) bool { + var raw map[string]json.RawMessage + if err := json.Unmarshal(rawBody, &raw); err != nil { + return false + } + ctkRaw, ok := raw["chat_template_kwargs"] + if !ok { + return false + } + var ctk map[string]json.RawMessage + if err := json.Unmarshal(ctkRaw, &ctk); err != nil { + return false + } + _, hasEnableThinking := ctk["enable_thinking"] + _, hasBudget := ctk["thinking_token_budget"] + return hasEnableThinking || hasBudget +} + func intPtr(v int) *int { return &v } diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 9bab1ba..0cd50d2 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -5665,6 +5665,53 @@ func TestChatCompletionsProviderPoolThinkingPolicyOverridesStrictOutputDisable(t } } +func TestChatCompletionsProviderPoolProviderNativeThinkingDisablesDefaultBudgetInjection(t *testing.T) { + fake := &fakeRunService{ + tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`), + tunnelServedTarget: "Ornith-1.0-35B", + } + catalog := []config.ModelCatalogEntry{{ + ID: "ornith:35b", + DefaultThinkingTokenBudget: 8192, + Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"}, + }} + srv := NewServer(config.EdgeOpenAIConf{StrictOutput: true}, fake, nil) + srv.SetModelCatalog(catalog) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"ornith:35b", + "messages":[{"role":"user","content":"hello"}], + "chat_template_kwargs":{"enable_thinking":false} + }`)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + bodies := fake.tunnelBodiesSnapshot() + if len(bodies) != 1 { + t.Fatalf("expected 1 tunnel body, got %d", len(bodies)) + } + var providerReq map[string]any + if err := json.Unmarshal(bodies[0], &providerReq); err != nil { + t.Fatalf("provider body JSON: %v body=%s", err, bodies[0]) + } + ctk, ok := providerReq["chat_template_kwargs"].(map[string]any) + if !ok { + t.Fatalf("chat_template_kwargs not preserved as object: %+v", providerReq) + } + if ctk["enable_thinking"] != false { + t.Fatalf("provider-native enable_thinking must stay false: %+v", ctk) + } + if _, ok := providerReq["think"]; ok { + t.Fatalf("catalog policy must not inject top-level think over provider-native thinking: %+v", providerReq) + } + if _, ok := providerReq["thinking_token_budget"]; ok { + t.Fatalf("catalog policy must not inject thinking_token_budget over provider-native thinking: %+v", providerReq) + } +} + func TestChatCompletionsProviderPoolPreservesLargerGenerationPolicyValues(t *testing.T) { fake := &fakeRunService{ tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`), @@ -7520,6 +7567,49 @@ func TestChatCompletionsProviderPoolTunnelPreservesUnknownFields(t *testing.T) { } } +func TestChatCompletionsProviderPoolTunnelRelaysProviderErrorBody(t *testing.T) { + providerError := `{"error":{"message":"unknown field custom_provider_options","type":"invalid_request_error","param":"custom_provider_options"}}` + frames := make(chan *iop.ProviderTunnelFrame, 3) + frames <- &iop.ProviderTunnelFrame{ + Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, + StatusCode: http.StatusUnprocessableEntity, + Headers: map[string]string{"Content-Type": "application/json"}, + } + frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerError)} + frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true} + close(frames) + + fake := &fakeRunService{ + tunnelFrames: frames, + tunnelServedTarget: "served-model", + } + srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) + srv.SetModelCatalog([]config.ModelCatalogEntry{ + {ID: "pool-model", Providers: map[string]string{"prov-vllm": "served-model"}}, + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"pool-model", + "messages":[{"role":"user","content":"hello"}], + "custom_provider_options":{"reject":true} + }`)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if w.Body.String() != providerError { + t.Fatalf("provider error body not relayed byte-identically:\n got: %q\nwant: %q", w.Body.String(), providerError) + } + if got := w.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { + t.Fatalf("Content-Type header not relayed: %q", got) + } + if len(fake.reqsSnapshot()) != 0 { + t.Fatalf("provider error must not fall back to normalized SubmitRun, got %d calls", len(fake.reqsSnapshot())) + } +} + // TestChatCompletionsProviderPoolNormalizedIgnoresUnknownFields verifies that // when the provider-pool dispatch selects the normalized path, the lenient // ingress accepts unknown/provider-specific fields without rejecting the @@ -7873,7 +7963,7 @@ func TestResponsesProviderPoolOllamaSelectionRejectsStreamingWithoutTunnel(t *te func TestResponsesProviderPoolTunnelStreamingUsesPoolSelection(t *testing.T) { fake := &fakeRunService{ poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), - tunnelFrames: staticProviderTunnelFrames(`{"id":"resp-1","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}],"stream":true}\n`), + tunnelFrames: staticProviderTunnelFrames(`{"id":"resp-1","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}],"stream":true}\n`), } srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) diff --git a/apps/node/internal/adapters/openai_compat/openai_compat_test.go b/apps/node/internal/adapters/openai_compat/openai_compat_test.go index fe42097..cdef8bd 100644 --- a/apps/node/internal/adapters/openai_compat/openai_compat_test.go +++ b/apps/node/internal/adapters/openai_compat/openai_compat_test.go @@ -1284,6 +1284,70 @@ func TestOpenAICompatTunnelProvider(t *testing.T) { assertOrderedTunnelFrames(t, frames) } +func TestOpenAICompatTunnelProvider_RelaysProviderHTTPError(t *testing.T) { + expectedBody := `{"error":{"message":"unsupported field","type":"invalid_request_error","param":"custom_provider_options"}}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST method, got %s", r.Method) + } + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("expected path /v1/chat/completions, got %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(expectedBody)) + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeTunnelSink{} + req := runtime.ProviderTunnelRequest{ + RunID: "run-1", + TunnelID: "tunnel-1", + Method: "POST", + Path: "/v1/chat/completions", + Body: []byte(`{"model":"qwen3.6:35b","custom_provider_options":{"reject":true}}`), + } + + err := adapter.TunnelProvider(context.Background(), req, sink) + if err != nil { + t.Fatalf("TunnelProvider must relay provider HTTP errors as response frames, got error: %v", err) + } + + frames := sink.all() + if len(frames) < 3 { + t.Fatalf("expected at least 3 frames, got %d", len(frames)) + } + if frames[0].Kind != runtime.ProviderTunnelFrameKindResponseStart { + t.Fatalf("expected RESPONSE_START, got %s", frames[0].Kind) + } + if frames[0].StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("expected provider status 422, got %d", frames[0].StatusCode) + } + if got := frames[0].Headers["Content-Type"]; got != "application/json" { + t.Fatalf("expected provider Content-Type header, got %q", got) + } + var bodyBuffer bytes.Buffer + for _, f := range frames[1 : len(frames)-1] { + if f.Kind != runtime.ProviderTunnelFrameKindBody { + t.Errorf("expected BODY kind, got %s", f.Kind) + } + bodyBuffer.Write(f.Body) + } + if bodyBuffer.String() != expectedBody { + t.Fatalf("provider error body mismatch:\n got: %q\nwant: %q", bodyBuffer.String(), expectedBody) + } + if frames[len(frames)-1].Kind != runtime.ProviderTunnelFrameKindEnd { + t.Fatalf("expected END, got %s", frames[len(frames)-1].Kind) + } + assertOrderedTunnelFrames(t, frames) +} + func TestOpenAICompatTunnelProvider_ResponsesPath(t *testing.T) { requestBody := `{"model":"served-model","input":"hi","max_output_tokens":123,"store":false}` expectedBody := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: [DONE]\n\n"