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:
parent
f430a07a3d
commit
5cd7aea826
6 changed files with 714 additions and 8 deletions
|
|
@ -55,8 +55,8 @@ OpenAI-compatible 요청의 경로 선택을 caller metadata가 아니라 `model
|
||||||
vLLM, vLLM-MLX, Lemonade, SGLang, Seulgivibe 같은 OpenAI-compatible provider가 지원하는 request surface를 Edge가 자체 allowlist로 제한하지 않게 한다.
|
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 테스트가 통과한다.
|
- [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를 변환 없이 전달한다.
|
- [x] [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] [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
|
### 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`에 남아 있다.
|
- [ ] 모든 기능 Task와 Task별 검증 evidence가 `Roadmap Completion`에 남아 있다.
|
||||||
- [ ] SDD Evidence Map이 최종 검증 evidence와 일치한다.
|
- [ ] SDD Evidence Map이 최종 검증 evidence와 일치한다.
|
||||||
- [ ] dev-corp smoke 결과가 문서화되어 있다.
|
- [ ] dev-corp smoke 결과가 문서화되어 있다.
|
||||||
- agent-ui 상태 반영: 해당 없음
|
- agent-ui 상태 반영: 해당 없음
|
||||||
- 리뷰 코멘트: 없음
|
- 리뷰 코멘트: 남은 완료 차단 항목은 dev-corp Ornith Spark `chat_template_kwargs.enable_thinking=false` field smoke evidence다.
|
||||||
|
|
||||||
## 범위 제외
|
## 범위 제외
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||||
|
|
@ -86,7 +86,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
outputPolicy := s.resolveOutputPolicy(basePrompt)
|
outputPolicy := s.resolveOutputPolicy(basePrompt)
|
||||||
if catalogEntry := s.findProviderPoolEntry(req.Model); catalogEntry != nil {
|
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
|
// The response path is decided by the resolved route, never by caller
|
||||||
|
|
@ -456,12 +456,12 @@ func validateThinkControl(req *chatCompletionRequest) error {
|
||||||
return nil
|
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 {
|
if req == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applyOutputTokenPolicy(&req.MaxTokens, req.MaxCompletionTokens, entry.DefaultMaxTokens, entry.MinMaxTokens)
|
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 {
|
if req.ThinkingTokenBudget == nil {
|
||||||
req.ThinkingTokenBudget = intPtr(entry.DefaultThinkingTokenBudget)
|
req.ThinkingTokenBudget = intPtr(entry.DefaultThinkingTokenBudget)
|
||||||
}
|
}
|
||||||
|
|
@ -511,6 +511,24 @@ func chatRequestAllowsDefaultThinkingBudget(req chatCompletionRequest) bool {
|
||||||
return true
|
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 {
|
func intPtr(v int) *int {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestChatCompletionsProviderPoolPreservesLargerGenerationPolicyValues(t *testing.T) {
|
||||||
fake := &fakeRunService{
|
fake := &fakeRunService{
|
||||||
tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`),
|
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
|
// TestChatCompletionsProviderPoolNormalizedIgnoresUnknownFields verifies that
|
||||||
// when the provider-pool dispatch selects the normalized path, the lenient
|
// when the provider-pool dispatch selects the normalized path, the lenient
|
||||||
// ingress accepts unknown/provider-specific fields without rejecting the
|
// ingress accepts unknown/provider-specific fields without rejecting the
|
||||||
|
|
@ -7873,7 +7963,7 @@ func TestResponsesProviderPoolOllamaSelectionRejectsStreamingWithoutTunnel(t *te
|
||||||
func TestResponsesProviderPoolTunnelStreamingUsesPoolSelection(t *testing.T) {
|
func TestResponsesProviderPoolTunnelStreamingUsesPoolSelection(t *testing.T) {
|
||||||
fake := &fakeRunService{
|
fake := &fakeRunService{
|
||||||
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
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)
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
||||||
|
|
|
||||||
|
|
@ -1284,6 +1284,70 @@ func TestOpenAICompatTunnelProvider(t *testing.T) {
|
||||||
assertOrderedTunnelFrames(t, frames)
|
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) {
|
func TestOpenAICompatTunnelProvider_ResponsesPath(t *testing.T) {
|
||||||
requestBody := `{"model":"served-model","input":"hi","max_output_tokens":123,"store":false}`
|
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"
|
expectedBody := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: [DONE]\n\n"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue