<!-- 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
