fix(openai): provider native thinking을 보존한다

OpenAI-compatible passthrough에서 caller가 보낸 provider-native thinking field가 catalog 기본 thinking budget으로 덮이지 않아야 한다. provider가 거부한 extension field도 Edge가 선판단하지 않고 provider status/body로 relay되는지 테스트 근거를 남긴다.
This commit is contained in:
toki 2026-07-14 07:21:37 +09:00
parent f430a07a3d
commit 5cd7aea826
6 changed files with 714 additions and 8 deletions

View file

@ -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다.
## 범위 제외

View file

@ -0,0 +1,269 @@
<!-- task=m-openai-compatible-provider-passthrough-contract-sync plan=0 tag=TEST -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |

View file

@ -0,0 +1,265 @@
<!-- task=m-openai-compatible-provider-passthrough-contract-sync plan=0 tag=TEST -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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
}

View file

@ -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)

View file

@ -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"