<!-- task=m-openai-compatible-provider-passthrough-contract-sync/02+01_model_route_dispatch plan=1 tag=REVIEW_API -->

# Plan - Review Follow-up for Responses Provider-Pool Dispatch

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

이 follow-up은 이전 리뷰의 Required 이슈 2개만 고친다. 구현 완료 전에는 반드시 같은 디렉터리의 `CODE_REVIEW-local-G06.md`에서 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다.

선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 근거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 외부 환경, secret, 서비스 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니며 `검증 결과` 또는 `계획 대비 변경 사항`에 기록한다. `USER_REVIEW.md`, `complete.log`, archive 이동은 code-review-skill 전용이다.

## 배경

이전 구현은 Responses provider-pool을 `SubmitProviderPool` 기반으로 옮겼지만, `stream:true`와 strict decode 실패 경로에서 selected provider를 보기 전에 tunnel로 결정하거나 `Run` 없이 `SubmitProviderPool`을 호출한다. 그 결과 OpenAI-compatible provider passthrough와 Ollama/CLI/native normalized path를 같은 model-driven 선택 규칙으로 보장하지 못한다. 이 follow-up은 selected-after-prepare 구조를 추가해 tunnel branch는 raw body를 보존하고 normalized branch만 strict validation을 수행하게 한다.

## 사용자 리뷰 요청 흐름

구현 중 새 결정이 필요해 보여도 직접 질문하지 않는다. 선택된 Milestone lock decision이 실제 구현을 차단할 때만 `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록하고 code-review가 `USER_REVIEW.md` 작성 여부를 판단한다.

## Archive Evidence Snapshot

- Current archived plan: `agent-task/m-openai-compatible-provider-passthrough-contract-sync/02+01_model_route_dispatch/plan_local_G06_0.log`
- Current archived review: `agent-task/m-openai-compatible-provider-passthrough-contract-sync/02+01_model_route_dispatch/code_review_local_G06_0.log`
- Verdict: FAIL
- Required summary:
  - `apps/edge/internal/openai/responses_handler.go:230`: provider-pool `stream:true` is sent to `tunnelResponsesPassthrough` before selected provider execution path is known.
  - `apps/edge/internal/openai/responses_handler.go:277`: strict decode failure calls `SubmitProviderPool` with `Run.ModelGroupKey` empty, while real service resolves candidates from `req.Run`.
- Affected files:
  - `apps/edge/internal/openai/responses_handler.go`
  - `apps/edge/internal/service/run_dispatch.go`
  - `apps/edge/internal/openai/server_test.go`
- Verification evidence from failed loop:
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool.*(Tunnel|Ollama|Native|Normalized|Mixed)|TestChatCompletions.*ProviderPool' -count=1` PASS, but fake service did not catch missing `Run.ModelGroupKey`.
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -run 'TestResolveProviderPoolCandidates.*ExecutionPath|TestResolveProviderPoolCandidatesKeepsMixedProviderTypes' -count=1` PASS.
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/node/internal/adapters/openai_compat -count=1` PASS.
- Roadmap carryover:
  - Milestone task remains `model-route`.
  - Predecessor satisfied by `agent-task/archive/2026/07/m-openai-compatible-provider-passthrough-contract-sync/01_selector_metadata_surface/complete.log`.
- Specific archive files above may be read if exact prior context is needed. Do not search `agent-task/archive/**` broadly.

## 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:
  - `model-route`: Chat Completions와 Responses에서 `model` -> route/provider capability -> passthrough 또는 normalized 경로를 사용
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/edge/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/local/platform-common-smoke.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/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/service/run_dispatch.go`

### SDD 기준

- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-provider-passthrough-contract-sync/SDD.md`
- Status: `[승인됨]`, SDD lock 해제.
- Targeted Acceptance Scenarios:
  - `S02` / `model-route`: OpenAI-compatible provider 선택 시 provider tunnel passthrough.
  - `S03` / `model-route`: CLI/Ollama/native provider 선택 시 normalized `RunRequest`.
- Evidence Map 반영:
  - `S02`는 Responses unknown/stream passthrough가 `SubmitProviderPool` 선택 이후 tunnel branch에서만 발생하는 테스트로 증명한다.
  - `S03`은 Responses unknown/stream 요청이 normalized provider 선택 시 tunnel로 가지 않고 400 validation 또는 normalized path 규칙을 따르는 테스트로 증명한다.

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md`를 읽었다.
- Matched profiles: `agent-test/local/edge-smoke.md`, `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`.
- 적용 명령: 변경 패키지 Go test와 Edge OpenAI handler/service/node adapter 관련 package test.
- 외부 provider credential이나 field smoke는 이 follow-up 범위가 아니다. 변경은 deterministic unit test로 재현 가능하다.

### 테스트 커버리지 공백

- 기존 Responses provider-pool tests는 fake service가 `ProviderPoolDispatchRequest.Run.ModelGroupKey`를 검증하지 않아 real `SubmitProviderPool`와 달랐다.
- 기존 테스트에는 `stream:true` 요청에서 selected provider가 normalized인 케이스가 없다.
- 기존 테스트에는 Responses unknown field 요청에서 selected provider가 normalized인 케이스가 없다.
- 기존 tunnel unknown-field 테스트는 fake tunnel path만 확인하고, `SubmitProviderPool`에 complete Run request가 전달되는지 보지 않는다.

### 심볼 참조

- `ProviderPoolDispatchRequest`: `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/responses_handler.go`, `apps/edge/internal/openai/server_test.go`.
- `SubmitProviderPool`: service implementation and fake in `apps/edge/internal/openai/server_test.go`.
- `tunnelResponsesPassthrough`: `responses_handler.go` direct non-provider tunnel path and current provider-pool stream shortcut.

### 분할 판단

- Split decision policy를 재평가했다.
- Shared task group: `agent-task/m-openai-compatible-provider-passthrough-contract-sync/`.
- Current subtask: `02+01_model_route_dispatch`.
- Predecessor `01_selector_metadata_surface` is satisfied by `agent-task/archive/2026/07/m-openai-compatible-provider-passthrough-contract-sync/01_selector_metadata_surface/complete.log`.
- Follow-up은 같은 Responses provider-pool dispatch correction 안의 좁은 repair이며, 별도 split은 필요 없다.

### 범위 결정 근거

- 포함: `ProviderPoolDispatchRequest`의 optional run preparation, Responses provider-pool handler 분기, fake service/test coverage.
- 제외: Chat behavior change, provider scoring/queue policy, Node adapter implementation, contract 문서 갱신, dev-corp live provider smoke.
- Chat handler는 새 optional field로 compile이 깨지지 않게만 유지한다.

### 빌드 등급

- `local-G06`: Edge handler와 service dispatch surface의 bounded bug fix이며, contract가 명확하고 deterministic Go tests로 검증 가능하다.

## 구현 체크리스트

- [x] [REVIEW_API-1] `SubmitProviderPool`에 selected normalized branch에서만 `Run`을 준비/검증하는 optional hook을 추가한다.
- [x] [REVIEW_API-2] Responses provider-pool handler에서 stream/unknown-field 판단을 selected provider 이후로 옮기고, tunnel branch는 raw body passthrough를 유지한다.
- [x] [REVIEW_API-3] Responses provider-pool stream/unknown-field mixed path regression tests와 fake service guard를 추가한다.
- [x] [REVIEW_API-4] provider-pool dispatch, OpenAI handler, node adapter 검증 명령을 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

`02+01_model_route_dispatch`는 predecessor `01_selector_metadata_surface` 완료 후 진행한다. 해당 predecessor는 archive complete log로 충족되어 있으므로 이 follow-up 구현을 진행할 수 있다.

### [REVIEW_API-1] ProviderPool Run Preparation Hook

#### 문제

`SubmitProviderPool`은 후보 선택을 `req.Run`에서 시작한다.

```go
// apps/edge/internal/service/run_dispatch.go:954
candidates, policy, err := s.resolveQueueCandidates(req.Run)
```

그런데 이전 Responses strict decode 실패 fallback은 `Run`을 비운 채 `Tunnel`만 채웠다.

```go
// apps/edge/internal/openai/responses_handler.go:277
poolReq := edgeservice.ProviderPoolDispatchRequest{
	Tunnel: edgeservice.SubmitProviderTunnelRequest{
```

#### 해결 방법

`ProviderPoolDispatchRequest`에 optional `PrepareRun func(SubmitRunRequest) (SubmitRunRequest, error)`를 추가한다. `SubmitProviderPool`은 candidate를 고른 뒤 `providerExecutionPathNormalized` branch에서만 이 hook을 호출하고, 반환된 request를 `dispatchProviderPoolRun`에 넘긴다. Tunnel branch에서는 호출하지 않는다.

```go
// after
type ProviderPoolDispatchRequest struct {
	Run           SubmitRunRequest
	Tunnel        SubmitProviderTunnelRequest
	PrepareTunnel prepareTunnelFunc
	PrepareRun    prepareRunFunc
}
```

```go
// after
case providerExecutionPathNormalized:
	runReq := req.Run
	if req.PrepareRun != nil {
		runReq, err = req.PrepareRun(runReq)
		if err != nil {
			s.queue.releaseSlotWithLong(req.Run.ModelGroupKey, selected.entry.NodeID, selected.providerID, longReserved)
			return nil, err
		}
	}
	return s.dispatchProviderPoolRun(ctx, runReq, adapter, target, selected, queueReason, longReserved)
```

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

- [x] `apps/edge/internal/service/run_dispatch.go`: `prepareRunFunc`와 `PrepareRun` field를 추가하고 normalized branch에서만 호출한다.
- [x] `apps/edge/internal/openai/chat_handler.go`: compile 영향만 확인한다. Chat behavior는 변경하지 않는다.
- [x] `apps/edge/internal/openai/server_test.go`: fake `SubmitProviderPool`도 normalized path에서 `PrepareRun`을 호출하도록 맞춘다.

#### 테스트 작성

- `apps/edge/internal/openai/server_test.go`: fake service가 `SubmitProviderPool` 요청의 `Run.ModelGroupKey`가 비면 테스트 실패/에러가 나도록 guard를 둔다.
- Service 단위 helper가 이미 있으면 `PrepareRun`이 normalized branch에서만 호출되는 테스트를 추가한다. 복잡한 registry fixture가 과하면 openai fake regression으로 대체하고 그 이유를 `계획 대비 변경 사항`에 적는다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool.*(Unknown|Streaming|Tunnel|Ollama|Mixed)' -count=1
```

예상 결과: Responses provider-pool regression tests PASS.

### [REVIEW_API-2] Responses Selected-After-Prepare Dispatch

#### 문제

`stream:true` provider-pool 요청은 selected provider를 확인하기 전에 tunnel로 보내진다.

```go
// apps/edge/internal/openai/responses_handler.go:230
if env.Stream {
	...
	s.tunnelResponsesPassthrough(w, r, env, dispatch, runMeta, rawBody, estimate, contextClass)
	return
}
```

이 경로는 normalized provider 선택 시에도 `SubmitProviderTunnel` queued path를 사용할 수 있어 SDD S03을 깬다.

#### 해결 방법

Responses provider-pool handler는 항상 populated base `Run`과 `Tunnel`을 가진 `ProviderPoolDispatchRequest`를 만든다. Tunnel branch는 `BuildBody`로 `rewriteResponsesModel(rawBody, target)`만 수행한다. Normalized branch의 strict decode, `stream/background` rejection, `parseResponsesInput`, prompt/options/generation policy 구성은 `PrepareRun` closure 안에서 수행한다.

Validation error는 HTTP 400 `invalid_request_error`로 반환되도록 typed error 또는 sentinel wrapping을 사용한다. Provider auth error는 기존처럼 400, backend dispatch error는 502로 유지한다.

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

- [x] `apps/edge/internal/openai/responses_handler.go`: `env.Stream` pre-selection tunnel shortcut을 제거한다.
- [x] `apps/edge/internal/openai/responses_handler.go`: strict decode 실패 전용 tunnel fallback branch를 제거하고 `PrepareRun`으로 이동한다.
- [x] `apps/edge/internal/openai/responses_handler.go`: `SubmitProviderPool` error handling에서 normalized validation error를 400으로 매핑한다.
- [x] `apps/edge/internal/openai/responses_handler.go`: non-provider direct tunnel path는 기존 `tunnelResponsesPassthrough`를 유지한다.

#### 테스트 작성

- `TestResponsesProviderPoolTunnelUnknownFieldsUsesPoolSelection`: OpenAI-compatible selected path에서 `tools`, `parallel_tool_calls`, `store`, custom field가 raw tunnel body에 보존되고 `Run.ModelGroupKey`가 채워진 채 `SubmitProviderPool`을 통과한다.
- `TestResponsesProviderPoolOllamaSelectionRejectsUnknownFieldsWithoutTunnel`: normalized selected path에서 unknown field 요청은 400이며 tunnel/run dispatch가 발생하지 않는다.
- `TestResponsesProviderPoolOllamaSelectionRejectsStreamingWithoutTunnel`: normalized selected path에서 `stream:true` 요청은 400이며 tunnel dispatch가 발생하지 않는다.
- `TestResponsesProviderPoolTunnelStreamingUsesPoolSelection`: tunnel selected path에서 `stream:true`는 `SubmitProviderPool`을 통해 tunnel로 relay되고 raw SSE body를 보존한다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool.*(UnknownFields|Streaming|TunnelSelection|OllamaSelection|Mixed)' -count=1
```

예상 결과: tunnel selected path는 passthrough, normalized selected path는 validation/no-tunnel behavior를 증명한다.

### [REVIEW_API-3] Test Double Fidelity

#### 문제

이전 tests는 PASS했지만 fake `SubmitProviderPool`가 real service의 candidate resolution requirement를 재현하지 않았다. real service는 `req.Run.ModelGroupKey`를 반드시 필요로 한다.

#### 해결 방법

fake service에 pool call recording과 request guard를 추가한다. `SubmitProviderPool`가 호출된 횟수, `Run.ModelGroupKey`, path별 `PrepareRun`/`PrepareTunnel` 호출 여부를 tests에서 검증할 수 있게 한다.

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

- [x] `apps/edge/internal/openai/server_test.go`: fake service에 `poolReqs` 또는 equivalent snapshot을 추가한다.
- [x] `apps/edge/internal/openai/server_test.go`: normalized path에서 `PrepareRun` 결과를 `reqs`에 기록한다.
- [x] `apps/edge/internal/openai/server_test.go`: tunnel path에서는 `PrepareRun`이 호출되지 않음을 검증할 수 있게 한다.

#### 테스트 작성

- REVIEW_API-2의 regression tests가 이 fake fidelity를 직접 사용한다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool.*|TestChatCompletions.*ProviderPool' -count=1
```

예상 결과: 기존 Chat provider-pool tests는 behavior 변경 없이 PASS.

### [REVIEW_API-4] Final Verification

#### 문제

변경은 Edge OpenAI handler, service dispatch surface, fake tests를 함께 건드리므로 focused와 package-level 검증이 필요하다.

#### 해결 방법

아래 최종 검증 명령을 fresh test로 실행하고 stdout/stderr를 review stub에 붙인다.

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

- [x] `apps/edge/internal/openai` focused tests 실행.
- [x] `apps/edge/internal/service` provider-pool classification tests 실행.
- [x] OpenAI/service/node adapter package tests 실행.
- [x] deterministic `rg`로 provider-pool Responses가 pre-selection tunnel shortcut을 남기지 않았는지 확인한다.

#### 테스트 작성

- 이 항목은 추가 테스트 작성 항목이 아니라 plan-wide verification 항목이다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/node/internal/adapters/openai_compat -count=1
```

예상 결과: 세 package 모두 PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/run_dispatch.go` | REVIEW_API-1 |
| `apps/edge/internal/openai/responses_handler.go` | REVIEW_API-1, REVIEW_API-2 |
| `apps/edge/internal/openai/chat_handler.go` | REVIEW_API-1 compile compatibility |
| `apps/edge/internal/openai/server_test.go` | REVIEW_API-1, REVIEW_API-2, REVIEW_API-3 |

## PLAN 대비 변경 사항

1. **Chat handler에도 `PrepareRun` 설정 추가**: 계획에서는 `chat_handler.go`를 compile 영향만 확인하는 것으로만 명시했지만, fake service의 `req.PrepareRun == nil` guard로 인해 chat handler도 `PrepareRun`을 설정해야 했다. Chat은 lenient decode를 사용하므로 `PrepareRun`은 identity function으로 처리한다.
2. **Fake service에서 `poolPrepareRunCalled` 설정 위치 조정**: 계획에서는 `PrepareRun` 호출 성공 후 `poolPrepareRunCalled = true`를 설정하는 것으로 명시했지만, normalized path에서 validation error 발생 시 호출은 되었으나 `poolPrepareRunCalled`가 false가 되는 문제를 발견하여, 호출 시점(에러 여부 무관)에 설정하도록 수정했다.
3. **Fake service에서 디버깅용 panic 제거**: 기존 구현에 남아있던 `panic(fmt.Sprintf("DEBUG after setting poolPrepareRunCalled=true: ..."))` 코드를 제거했다.
4. **`poolRetrySubmit`에도 `PrepareRun` 전달**: Chat handler의 tool validation retry 경로에서 `SubmitProviderPool` 호출 시 `PrepareRun`도 전달하도록 수정했다.

## 최종 검증

```bash
test -f agent-task/archive/2026/07/m-openai-compatible-provider-passthrough-contract-sync/01_selector_metadata_surface/complete.log
```

예상 결과: exit status 0.

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool.*(Unknown|Streaming|Tunnel|Ollama|Mixed)|TestChatCompletions.*ProviderPool' -count=1
```

```
ok  	ipop/apps/edge/internal/openai	0.012s
```

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -run 'TestResolveProviderPoolCandidates.*ExecutionPath|TestResolveProviderPoolCandidatesKeepsMixedProviderTypes' -count=1
```

```
ok  	ipop/apps/edge/internal/service	0.004s
```

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/node/internal/adapters/openai_compat -count=1
```

```
ok  	ipop/apps/edge/internal/openai	6.732s
ok  	ipop/apps/edge/internal/service	1.051s
ok  	ipop/apps/node/internal/adapters/openai_compat	0.123s
```

```bash
bash -lc 'rg --sort path "tunnelResponsesPassthrough\\(w, r, env, dispatch" apps/edge/internal/openai/responses_handler.go'
```

```
apps/edge/internal/openai/responses_handler.go:115: s.tunnelResponsesPassthrough(w, r, env, dispatch, runMeta, rawBody, estimate, contextClass)
```

_해당 호출은 non-provider direct tunnel path에서만 발생하며, provider-pool pre-selection branch에서는 호출되지 않는다._

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