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

# Plan - API Model Route Dispatch

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

이 subtask는 `agent-task/m-openai-compatible-provider-passthrough-contract-sync/01_selector_metadata_surface/complete.log`가 생긴 뒤에만 구현한다. 구현 완료 전에는 반드시 같은 디렉터리의 `CODE_REVIEW-local-G06.md`에서 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다.

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

## 배경

route-contract는 Chat Completions와 Responses 모두 `model`로 provider pool을 고른 뒤 selected provider capability에 따라 OpenAI-compatible provider는 raw passthrough, Ollama/CLI/native provider는 normalized `RunRequest` 경로를 타야 한다. Chat Completions provider-pool은 이미 `SubmitProviderPool`을 사용하지만, Responses handler는 provider-pool도 `routeUsesProviderTunnel(dispatch)`에서 바로 tunnel로 보내 selected execution path를 보지 못한다. 이 작업은 selector surface 정리 후 Responses provider-pool dispatch를 Chat과 같은 model-driven 선택 규칙으로 맞춘다.

## 사용자 리뷰 요청 흐름

구현 중 새 결정이 필요해 보여도 직접 질문하지 않는다. 선택된 Milestone lock decision이 실제 구현을 차단할 때만 `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록하고 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:
  - `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-ops/skills/common/router.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/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/local/platform-common-smoke.md`
- `agent-roadmap/current.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/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/model_queue_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`
- Status: `[승인됨]`, SDD lock 해제.
- Targeted Acceptance Scenarios:
  - `S02`: model이 OpenAI-compatible provider를 가리키면 Chat/Responses가 provider tunnel passthrough를 사용한다.
  - `S03`: model이 CLI/Ollama/native provider를 가리키면 Chat/Responses가 normalized `RunRequest`를 사용한다.
- Evidence Map 반영:
  - Chat Completions의 기존 model-route 테스트는 유지한다.
  - Responses provider-pool에 OpenAI-compatible selection passthrough 테스트와 Ollama/native normalized selection 테스트를 추가한다.
  - raw tunnel path는 provider body를 보존하고, normalized path는 RunRequest 변환을 검증한다.

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md`와 `edge-smoke`, `node-smoke`, `platform-common-smoke` profile을 읽었다.
- 이 계획은 Edge handler/service dispatch 단위이므로 Go package unit test가 직접 검증이다.
- 기본 `go test`는 `/config/.cache/go-build` permission 오류가 있었고, `GOCACHE=/tmp/iop-go-cache` preflight는 성공했다.
- 최종 검증 명령은 fresh 실행을 위해 `-count=1`을 사용한다.

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

- Chat Completions provider-pool에는 selected OpenAI-compatible tunnel, Ollama normalized, mixed pool 테스트가 이미 있다.
- Responses provider-pool은 현재 provider-pool이면 selected execution path와 무관하게 tunnel로 가는 테스트가 중심이라 Ollama/CLI/native normalized selection coverage가 없다.
- `SubmitProviderTunnel`은 provider-pool admission 후 selected execution path를 gate하지 않으므로 Responses provider-pool에서 그대로 쓰면 normalized provider도 tunnel로 갈 수 있다.

### 심볼 참조

- 변경 가능 대상:
  - `routeUsesProviderTunnel(dispatch)`: `chat_handler.go`, `responses_handler.go`. Responses provider-pool 직접 tunnel 조건에서 분리 필요.
  - `tunnelResponsesPassthrough`: `responses_handler.go`, `server_test.go`. Legacy direct provider tunnel과 selected OpenAI-compatible provider-pool에서만 사용해야 한다.
  - `service.ProviderPoolDispatchRequest`: `run_dispatch.go`, `chat_handler.go`, fake run service in `server_test.go`. Responses normalized build를 selected path 후에 준비하려면 hook 추가 가능성이 있다.
  - `SubmitProviderPool`: `run_dispatch.go`, tests. Chat path는 유지하고 Responses path를 추가한다.

### 분할 판단

- Split decision policy를 적용했다.
- Shared task group: `agent-task/m-openai-compatible-provider-passthrough-contract-sync/`.
- Sibling tasks:
  - `01_selector_metadata_surface`: selector/header/envelope/metadata 표면 제거.
  - `02+01_model_route_dispatch`: 이 계획. predecessor index `01` 필요.
- Predecessor resolution:
  - Active candidate `agent-task/m-openai-compatible-provider-passthrough-contract-sync/01_selector_metadata_surface/complete.log`: 현재 없음.
  - Archive lookup은 수행하지 않았다. 새로 생성한 sibling이므로 구현은 active predecessor `complete.log`가 생긴 뒤 시작한다.

### 범위 결정 근거

- 이 작업은 provider-pool selected execution path에 따른 Responses dispatch와 관련 tests에 한정한다.
- selector/sideband/metadata cleanup은 `01_selector_metadata_surface` 완료를 전제로 하며 이 계획에서 다시 도입하지 않는다.
- Node adapter의 tunnel payload relay와 normalized adapter mapping은 기존 contract 검증 범위로 둔다. 필요한 경우 테스트만 실행한다.
- provider candidate scoring, queue admission, model registry schema는 변경하지 않는다.

### 빌드 등급

- `local-G06`: 범위는 Edge Responses handler와 service dispatch hook/test로 좁고 모든 기대 동작이 SDD/contract와 기존 Chat provider-pool 패턴에 고정되어 있으며 local Go test로 검증 가능하다.

## 구현 체크리스트

- [ ] [API-1] Responses provider-pool에서 selected provider execution path에 따라 passthrough 또는 normalized run을 선택하도록 dispatch를 정리한다.
- [ ] [API-2] Responses provider-pool tunnel/normalized selection 회귀 테스트를 추가하고 기존 Chat model-route 테스트를 보존한다.
- [ ] [API-3] provider-pool dispatch, OpenAI handler, node adapter 검증 명령을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

`02+01_model_route_dispatch`는 subtask directory 이름 기준으로 predecessor `01_selector_metadata_surface`에만 의존한다. 구현 시작 전 `agent-task/m-openai-compatible-provider-passthrough-contract-sync/01_selector_metadata_surface/complete.log`가 존재하는지 확인하고, 없으면 구현하지 말고 `검증 결과` 또는 보고에 missing predecessor로 기록한다.

### [API-1] Responses Provider-Pool Dispatch

#### 문제

Responses handler는 provider-pool route도 `routeUsesProviderTunnel(dispatch)`에서 바로 tunnel로 보내 selected provider execution path를 확인하지 못한다.

```go
// apps/edge/internal/openai/responses_handler.go:49
if routeUsesProviderTunnel(dispatch) {
	if req.Background != nil && *req.Background {
		writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", "background mode is not supported")
		return
	}
```

`SubmitProviderTunnel`의 provider-pool path도 selected candidate가 normalized provider인지 확인하지 않고 tunnel 요청을 만든다.

```go
// apps/edge/internal/service/run_dispatch.go:1134
func (s *RunService) SubmitProviderTunnel(ctx context.Context, req ProviderTunnelRequest) (ProviderTunnelResult, error) {
	if req.Dispatch.ProviderPool {
		return s.submitProviderTunnelQueued(ctx, req)
	}
```

#### 해결 방법

Responses provider-pool은 Chat provider-pool처럼 `SubmitProviderPool`을 사용한다. selected path가 `providerExecutionPathTunnel`이면 raw Responses body를 model rewrite만 적용해 provider tunnel로 보낸다. selected path가 `providerExecutionPathRun`이면 Responses request를 normalized `RunRequest`로 변환해 `dispatchProviderPoolRun`을 사용한다.

권장 구현은 `service.ProviderPoolDispatchRequest`에 선택 후 normalized request를 준비하는 hook을 추가하는 것이다. 이렇게 하면 tunnel 선택에서는 strict normalized decode 때문에 raw passthrough가 막히지 않고, run 선택에서만 Responses normalized validation을 수행할 수 있다.

```go
// after sketch in apps/edge/internal/service/run_dispatch.go
type ProviderPoolDispatchRequest struct {
	Dispatch       ModelDispatch
	Model          string
	Prompt         string
	Run            RunRequest
	Tunnel         ProviderTunnelRequest
	PrepareTunnel  func(SelectedProvider) (ProviderTunnelRequest, error)
	PrepareRun     func(SelectedProvider) (RunRequest, error)
}
```

```go
// after sketch in SubmitProviderPool normalized branch
runReq := req.Run
if req.PrepareRun != nil {
	runReq, err = req.PrepareRun(selected)
	if err != nil {
		return ProviderPoolDispatchResult{}, err
	}
}
return s.dispatchProviderPoolRun(ctx, candidateNode, providerKey, providerType, req, runReq)
```

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

- [ ] `apps/edge/internal/service/run_dispatch.go`: optional `PrepareRun` hook 또는 동등한 selected-after-prepare 구조를 추가한다.
- [ ] `apps/edge/internal/openai/responses_handler.go`: provider-pool Responses path를 `SubmitProviderPool` 기반으로 분리한다.
- [ ] `apps/edge/internal/openai/chat_handler.go`: shared helper signature 변경 시 Chat compile을 맞춘다. Chat 동작은 변경하지 않는다.
- [ ] `apps/edge/internal/openai/server_test.go`: fake run service가 새 hook을 지원하도록 갱신한다.

#### 테스트 작성

- `apps/edge/internal/openai/server_test.go`
  - `TestResponsesProviderPoolTunnelSelectionUsesPassthrough`: selected OpenAI-compatible provider는 tunnel을 사용하고 raw body와 model rewrite를 보존한다.
  - `TestResponsesProviderPoolOllamaSelectionUsesNormalizedRun`: selected Ollama/native provider는 tunnel을 호출하지 않고 normalized `RunRequest`를 submit한다.
  - mixed provider-pool에서 selected provider type에 따라 tunnel/run이 갈리는지 Chat 기존 테스트와 같은 fixture로 검증한다.

#### 중간 검증

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

예상 결과: Responses provider-pool OpenAI-compatible selection은 tunnel PASS, Ollama/native selection은 normalized run PASS.

### [API-2] Route Test Coverage Preservation

#### 문제

Chat은 이미 model-route 회귀 테스트가 있지만 Responses 테스트는 raw tunnel assumption에 묶여 있어 SDD S02/S03를 둘 다 보장하지 못한다.

```go
// apps/edge/internal/openai/server_test.go:6128
func TestResponsesProviderPoolDispatch(t *testing.T) {
```

#### 해결 방법

Responses 기존 provider-pool passthrough 테스트는 OpenAI-compatible selected path 기준으로 유지하고, normalized selected path 테스트를 추가한다. Chat provider-pool tests는 compile 실패가 없도록 fake service 변경만 맞춘다.

```go
// after sketch
func TestResponsesProviderPoolOllamaSelectionUsesNormalizedRun(t *testing.T) {
	// selected provider type: ollama
	// assert SubmitRun path and no tunnel payload
}
```

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

- [ ] `apps/edge/internal/openai/server_test.go`: Responses provider-pool tunnel/normalized/mixed tests 추가 또는 갱신.
- [ ] `apps/edge/internal/service/model_queue_test.go`: execution path classification 테스트가 깨지지 않는지 확인한다. 필요 시 새 hook 관련 테스트만 추가한다.

#### 테스트 작성

- Regression test required.
- 기존 Chat tests가 이미 SDD의 Chat portion을 덮으므로 새 Chat test는 compile/update가 필요한 경우에만 추가한다.
- Responses normalized path는 test name과 assertion을 명확히 추가한다.

#### 중간 검증

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

예상 결과: provider execution path classification PASS.

### [API-3] Final Route Verification

#### 문제

Responses provider-pool fix는 Edge handler, service dispatch interface, fake tests를 함께 건드리므로 package 단위 회귀가 필요하다.

#### 해결 방법

Edge OpenAI handler, service, node OpenAI-compatible adapter package를 fresh test로 실행한다. selector cleanup plan 이후 문자열 검색이 이미 완료되어 있어야 하며, 이 plan은 model-route behavior만 검증한다.

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

- [ ] `apps/edge/internal/openai` 전체 테스트 실행.
- [ ] `apps/edge/internal/service` 전체 테스트 실행.
- [ ] `apps/node/internal/adapters/openai_compat` 전체 테스트 실행.

#### 테스트 작성

- 이 항목은 추가 테스트 작성 항목이 아니라 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/openai/responses_handler.go` | API-1 |
| `apps/edge/internal/openai/chat_handler.go` | API-1 |
| `apps/edge/internal/service/run_dispatch.go` | API-1 |
| `apps/edge/internal/openai/server_test.go` | API-1, API-2 |
| `apps/edge/internal/service/model_queue_test.go` | API-2 |
| `apps/node/internal/adapters/openai_compat/openai_compat_test.go` | API-3 |

## 최종 검증

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

예상 결과: exit status 0. 없으면 이 subtask 구현을 시작하지 않는다.

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

```bash
bash -lc 'rg --sort path "routeUsesProviderTunnel\\(dispatch\\)" apps/edge/internal/openai/responses_handler.go || test "$?" -eq 1'
```

예상 결과: provider-pool Responses를 무조건 tunnel로 보내는 조건이 남지 않는다. legacy direct provider tunnel 조건이 남는 경우 해당 분기가 provider-pool과 분리되어 있음을 코드리뷰에서 확인한다.

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