<!-- task=m-openai-compatible-provider-passthrough-contract-sync plan=1 tag=REVIEW_TEST -->

# Implementation Plan - REVIEW_TEST

## 이 파일을 읽는 구현 에이전트에게

이 plan은 이전 TEST 루프의 FAIL follow-up이다. 직접 사용자에게 질문하지 말고, dev-corp 배포/검증 경로를 실제로 수행한 뒤 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 채운다. secret/token 원문은 출력하거나 기록하지 않는다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우며, 배포 stale, SSH, secret, provider 상태, 검증 공백은 일반 follow-up blocker로 기록한다.

## 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

## Archive Evidence Snapshot

- Archived plan: `agent-task/m-openai-compatible-provider-passthrough-contract-sync/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-openai-compatible-provider-passthrough-contract-sync/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required issue summary:
  - `iop_public_ornith` failed the TEST-2 smoke with HTTP 400 `chat_template_kwargs is not supported for /v1/chat/completions`; direct Spark01/Spark02 passed with `ok:true`, fast first content, and `reasoning_seen:false`.
  - Reviewer rerun on 2026-07-14 reproduced the same shape: direct Spark01 `ok:true` first_content `0.095s`, direct Spark02 `ok:true` first_content `0.081s`, IOP public Ornith HTTP 400.
- Affected files/surfaces:
  - No source-code fix is expected from the previous evidence. The blocker is deployed dev-corp runtime parity.
  - Remote runner checkout observed at `10ede93 chore(dev-corp): Control Plane compose 배포 상태를 반영한다`.
  - Local source contains the required passthrough handling at `apps/edge/internal/openai/chat_handler.go:519` and the provider-native preservation test at `apps/edge/internal/openai/server_test.go:5681`.
  - `git show 10ede93:apps/edge/internal/openai/chat_handler.go | rg chat_template_kwargs` returned no matches, so the observed public Edge 400 is consistent with stale deployed code.
- Verification evidence carried forward:
  - TEST-1 preflight passed on runner: clean `main...origin/main`, `python3` and `curl` present, secret file readable without printing the secret, Spark01/Spark02 `/health` and `/v1/models` passed, public Edge `/v1/models` auth passed.
  - TEST-2 gate remains unmet because all three cases must be `ok:true`; the IOP public case is still `ok:false`.
- Roadmap carryover:
  - Continue claiming only `devcorp-smoke`; do not mark roadmap completion unless the final TEST-2 smoke exits 0 with `iop_public_ornith` included.
- Narrow archive reread allowed if exact prior stdout is needed:
  - `agent-task/m-openai-compatible-provider-passthrough-contract-sync/plan_cloud_G07_0.log`
  - `agent-task/m-openai-compatible-provider-passthrough-contract-sync/code_review_cloud_G07_0.log`

## 분석 결과

### 읽을 파일

- `agent-ops/skills/project/dev-corp-runtime-deploy/SKILL.md`
- `agent-test/dev-corp/rules.md`
- `agent-test/dev-corp/inventory.yaml`
- `agent-test/dev-corp/edge-smoke.md`
- `agent-test/dev-corp/node-smoke.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.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-contract/outer/openai-compatible-api.md`
- `agent-contract/inner/edge-node-runtime-wire.md`

### SDD 기준

- SDD `S10`은 Milestone Task `devcorp-smoke`와 연결되어 있다.
- Required Evidence는 dev-corp single-call smoke output summary이며, `devcorp-smoke`, first content latency, reasoning/content observation이 `Roadmap Completion`에서 확인 가능해야 한다.
- 이번 follow-up은 stale 배포를 현재 source로 맞춘 뒤 S10 evidence를 다시 수집하는 작업이다.

### 범위 결정 근거

- 포함: dev-corp runner clean sync, passthrough 포함 ref 확인, Go pre-build test, dev-corp runtime rebuild/redeploy, public Edge/Node runtime parity 확인, 동일 TEST-2 think-off smoke 재실행.
- 제외: provider runtime option 튜닝, capacity 9/6 부하 smoke, `/v1/responses` 검증, 로드맵 직접 수정, archive 문서 재작성.
- source code 변경은 기본적으로 기대하지 않는다. 배포 절차 중 빌드 스크립트/설정 결함이 새로 발견되면 그 결함과 최소 수정 필요성을 `CODE_REVIEW`에 기록한다.

### 빌드 등급

- `cloud-G07`: 외부 runner, public Edge, provider node, runtime deploy parity와 secret-safe smoke가 중심이다.

## 구현 체크리스트

- [ ] REVIEW_TEST-1 dev-corp runtime을 passthrough 포함 source ref로 clean sync, test, rebuild, redeploy하고 source/build/deploy parity evidence를 `CODE_REVIEW-cloud-G07.md`에 기록한다.
- [ ] REVIEW_TEST-2 redeploy 후 동일 `chat_template_kwargs.enable_thinking=false` TEST-2 smoke를 실행해 direct Spark01, direct Spark02, IOP public Ornith 세 case가 모두 `ok:true`인지 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_TEST-1] Dev-Corp Runtime Parity Deploy

#### 문제

현재 public IOP Edge는 `chat_template_kwargs`를 unsupported field로 거부한다. 이전 루프와 리뷰 재검증 모두 remote runner checkout HEAD가 `10ede93`인 상태를 확인했으며, 이 ref는 passthrough 구현 커밋 이전이다. S10을 닫으려면 public Edge/Node runtime이 passthrough 포함 source로 맞아야 한다.

#### 해결 방법

`agent-ops/skills/project/dev-corp-runtime-deploy/SKILL.md`의 절차를 따른다.

Spark direct/provider 확인은 반드시 mac-mini runner `ssh fe@172.24.63.178` 안에서 수행한다. 현재 작업 머신에서 `192.168.2.x` Spark endpoint를 직접 치는 경로는 기준 경로가 아니며, public Edge 검증도 mac-mini local endpoint가 아니라 `https://digitalplatform.iop.ai.kr/v1`을 사용한다.

필수 최소 evidence:

```bash
ssh fe@172.24.63.178 'cd /Users/fe/agent-work/iop-dev-corp && git fetch origin main && git reset --hard origin/main && git clean -fd && git status --short --branch && git log --oneline -5 && git merge-base --is-ancestor 5cd7aea HEAD && git merge-base --is-ancestor 33f8f5e HEAD && git show HEAD:apps/edge/internal/openai/chat_handler.go | grep chat_template_kwargs'
```

배포 전 provider direct preflight:

```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
curl -fsS http://192.168.2.3:8004/health
curl -fsS http://192.168.2.3:8004/v1/models
'
```

빌드 전 테스트:

```bash
ssh fe@172.24.63.178 'cd /Users/fe/agent-work/iop-dev-corp && go test ./...'
```

그 다음 같은 source ref에서 dev-corp Control Plane, public Edge, macOS Node, Linux ARM64 Node binary를 rebuild/redeploy한다. public Edge runtime은 `iop.ai.kr` 경로를 기준으로 하고, Node `edge_addr`는 `iop.ai.kr:18087`이어야 한다. mac-mini local endpoint를 public Edge evidence로 쓰지 않는다.

#### 수정 파일 및 체크리스트

- [ ] remote runner checkout이 passthrough 포함 source ref로 clean sync됐다.
- [ ] pre-build `go test ./...` 결과를 기록했다.
- [ ] Edge/Node binaries가 같은 source ref에서 rebuild/redeploy됐다.
- [ ] public `/v1/models`와 Node/provider 연결 상태를 secret 원문 없이 기록했다.

#### 테스트 작성

별도 unit test 작성 없음. 이미 로컬 소스에는 provider-native preservation unit test가 존재하며, 이 plan은 dev-corp runtime parity와 field evidence를 채운다.

#### 중간 검증

배포 후 public Edge `/v1/models`가 `ornith:35b`를 노출하고, stale 400 재현 원인이었던 deployed source/binary parity가 해소됐음을 기록한다.

### [REVIEW_TEST-2] Think-Off Field Smoke Re-run

#### 문제

이전 TEST-2는 direct Spark 두 case만 통과했고, S10의 핵심인 IOP public Ornith case가 실패했다.

#### 해결 방법

redeploy 후 runner에서 동일 payload/cases로 smoke를 실행한다. 출력에는 secret 원문이 없어야 한다.

```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'
```

#### 수정 파일 및 체크리스트

- [ ] direct Spark01/Spark02 결과와 IOP public Ornith 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다.
- [ ] 세 case 모두 `ok:true`, `reasoning_seen:false`, `first_content_sec` 값을 가진다.
- [ ] 실패 시 HTTP status/body, 배포 ref, Edge/Node parity, provider direct 상태를 분리 기록한다.

#### 테스트 작성

별도 unit test 작성 없음. S10은 live dev-corp field smoke evidence가 테스트다.

#### 중간 검증

Python smoke command가 exit 0이어야 한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `agent-task/m-openai-compatible-provider-passthrough-contract-sync/CODE_REVIEW-cloud-G07.md` | 구현 에이전트가 배포/검증 출력과 판단을 기록한다. |
| source files | 기본적으로 수정 없음. 배포 절차 결함을 고치는 경우에만 최소 변경하고 근거를 기록한다. |

## 최종 검증

- REVIEW_TEST-1 deploy parity evidence가 있어야 한다.
- REVIEW_TEST-2 smoke command가 exit 0이어야 한다.
- `iop_public_ornith`가 `ok:true`, `status:200`, `reasoning_seen:false`, `first_content_sec` 값을 가져야 한다.

모든 작업 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
