<!-- task=m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch plan=1 tag=REVIEW_MIXED_SELECT -->

# PLAN-local-G07: Review Follow-Up for Selection-First Provider Dispatch

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 구현의 마지막 필수 단계다. 검증을 실행하고, active plan/review 파일을 유지한 채 리뷰 준비 상태를 보고한다. 종결 처리, log rename, `complete.log`, archive 이동은 code-review 에이전트 전용이다.

선택된 Milestone의 `구현 잠금 > 결정 필요` 항목이 실제 구현을 막는 경우에만 대응 review stub의 `사용자 리뷰 요청` 섹션에 연결 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/외부 서비스 준비, 일반 scope 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 follow-up plan으로 남긴다.

## 배경

이전 구현은 provider-pool selection-first surface와 Chat handler early return을 추가했지만, tunnel-only provider auth와 normalized retry 계약이 selection 결과보다 먼저/밖에서 처리된다. 또한 strict-output provider-pool passthrough 관측 label과 handler 테스트 더블이 계획의 exact dispatch 조건을 충분히 검증하지 못한다. 이 follow-up은 기존 selection-first 방향은 유지하되, 리뷰에서 확인된 Required 경로만 좁게 보완한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 Milestone lock 결정이 구현을 차단할 때만 active `CODE_REVIEW-*-G??.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/model-group-mixed-provider-dispatch.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
- Task ids:
  - `selection-first-path`: Provider-pool route가 provider 후보를 먼저 선택하고 같은 queue slot/lease로 ProviderTunnelRequest 또는 normalized RunRequest 중 하나를 실행한다.
- Completion mode: check-on-pass

## Archive Evidence Snapshot

- Current archived plan: `agent-task/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/plan_local_G07_0.log`
- Current archived review: `agent-task/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/code_review_local_G07_0.log`
- Verdict: FAIL
- Required summary:
  - `apps/edge/internal/openai/chat_handler.go:955`: provider auth required check runs before provider-pool selection, so normalized/Ollama selection can be blocked by tunnel auth.
  - `apps/edge/internal/openai/chat_handler.go:1010` and `apps/edge/internal/openai/chat_handler.go:1012`: provider-pool normalized path passes empty `SubmitRunRequest{}` into retry-capable completion functions.
  - `apps/edge/internal/openai/chat_handler.go:110`: strict-output omitted-mode provider-pool tunnel path can be labeled `transformed` in usage metrics even though response is pure passthrough.
  - `apps/edge/internal/openai/server_test.go:181`, `apps/edge/internal/openai/server_test.go:271`, `apps/edge/internal/openai/server_test.go:7287`: fake normalized path records `req.Run` twice and the Ollama-selected test accepts "at least 1" instead of exactly one normalized dispatch.
- Affected files: `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/stream.go`, `apps/edge/internal/openai/server_test.go`; service request types in `apps/edge/internal/service/run_dispatch.go` may be touched only if needed to delay tunnel auth until after selection.
- Verification evidence from prior loop:
  - `git diff --check` initially failed on trailing whitespace; reviewer fixed it and reran successfully.
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1` passed.
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1` passed.
  - Targeted provider-pool tests passed but did not cover the Required auth/retry/count gaps.
- Narrow archive reread allowed if needed: only the two archived files listed above.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.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-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-spec/index.md`
- `agent-spec/runtime/edge-node-execution.md`
- `agent-spec/runtime/provider-pool-config-refresh.md`
- `agent-spec/input/openai-compatible-surface.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
- `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`
- `agent-task/archive/2026/07/m-model-group-mixed-provider-dispatch/01-provider-path-classifier/complete.log`
- `agent-task/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/plan_local_G07_0.log`
- `agent-task/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/code_review_local_G07_0.log`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/strict_output.go`
- `apps/edge/internal/openai/tool_validation.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/run_dispatch_internal_test.go`
- `apps/edge/internal/service/model_queue_test.go`

### SDD 기준

- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`
- 상태: `[승인됨]`, SDD lock 해제
- 대상 Acceptance Scenario: S01, S02
- Milestone Task id: `selection-first-path`
- Evidence Map:
  - S01: service queue/selection unit tests and `go test ./apps/edge/internal/service -count=1`
  - S02: OpenAI handler tests proving Ollama-selected provider-pool dispatch does not use tunnel
- 이 follow-up은 S02를 막는 provider-auth 선검증과 handler retry/test evidence 공백을 닫아 `selection-first-path` PASS 증거를 신뢰 가능하게 만든다.

### 테스트 환경 규칙

- 선택 test_env: `local`
- `agent-test/local/rules.md`를 읽었고, 현재 checkout에서 Go package test를 사용한다.
- `GOCACHE=/tmp/iop-go-cache`를 사용한다. cached output으로 최종 검증을 대체하지 않기 위해 package tests는 `-count=1`을 붙인다.
- 적용 명령:
  - `git diff --check`
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1`

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

- provider auth required + selected normalized provider: 현재 없음. provider-pool selection 이후 normalized path면 missing provider auth header가 400을 만들지 않는 regression test가 필요하다.
- provider-pool normalized tool-validation retry: 현재 없음. retry가 빈 request가 아니라 provider-pool dispatch context를 보존함을 검증해야 한다.
- strict output + omitted response mode + selected tunnel provider: 현재 status/body/header만 검증하고 usage metric response_mode label을 검증하지 않는다.
- Ollama-selected handler test: 현재 normalized request를 두 번 기록하는 fake를 허용하고 exact once를 assert하지 않는다.

### 심볼 참조

- 새 public symbol 제거/rename 없음.
- 변경 가능성이 있는 call site:
  - `completeChatCompletion`: `apps/edge/internal/openai/chat_handler.go`, tests direct call 없음.
  - `streamChatCompletion`/`streamBufferedChatCompletion`: `apps/edge/internal/openai/stream.go`, direct unit call at `apps/edge/internal/openai/server_test.go:3726`.
  - `ProviderPoolDispatchRequest`/`SubmitProviderTunnelRequest`: `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/server_test.go`, `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/service/run_dispatch_internal_test.go`.

### 분할 판단

- 이 follow-up은 기존 split subtask `02-selection-first-provider-dispatch` 안의 review loop 연장이다.
- 공유 task group: `agent-task/m-model-group-mixed-provider-dispatch/`
- 선행 의존성: `01-provider-path-classifier`는 `agent-task/archive/2026/07/m-model-group-mixed-provider-dispatch/01-provider-path-classifier/complete.log` 기준 PASS.
- 별도 subtask split은 만들지 않는다. Required 항목이 모두 Chat handler/provider-pool dispatch 후처리와 같은 test double에 묶여 있고, 같은 package tests로 검증된다.

### 범위 결정 근거

- Responses provider-pool path는 이번 Required에 포함하지 않는다. `routeUsesProviderTunnel` 유지로 Responses는 기존 raw passthrough 계약을 유지하며, selection-first Chat handler 보완이 직접 범위다.
- provider path classifier, config alias, Node adapter 구현은 선행 task PASS 범위이므로 수정하지 않는다.
- contract/spec 문서 갱신은 이 subtask의 PASS 조건이 아니다. 현재 구현 보정과 tests만 수행한다.

### 빌드 등급

- `local-G07`: OpenAI Chat handler, retry helper, provider-pool test double, 일부 service request type이 연결되지만 모두 local Go tests로 검증 가능한 bounded follow-up이다.

## 구현 체크리스트

- [ ] provider-pool Chat tunnel auth required 검증을 selection 이후 tunnel path에만 적용한다. normalized-selected provider-pool 요청은 missing provider auth header 때문에 실패하지 않아야 한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth|TestChatProviderTunnelProviderAuthRequired' -count=1`.
- [ ] provider-pool normalized completion/stream retry가 빈 `SubmitRunRequest{}`를 사용하지 않도록 retry context를 보존한다. tool-validation retry regression을 추가한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*ToolValidation|ToolValidation' -count=1`.
- [ ] strict-output omitted-mode provider-pool tunnel response의 usage metric label이 `passthrough`로 남도록 수정하고 regression을 추가한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'StrictOutputProviderPoolDefaultKeepsPassthroughPath|Usage' -count=1`.
- [ ] fake `SubmitProviderPool` normalized 기록을 1회로 정리하고 Ollama-selected handler test가 `len(reqs)==1`, `len(tunnelReqs)==0`을 assert하도록 고친다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestChatCompletionsProviderPoolOllamaSelectionUsesNormalizedRun|TestChatCompletionsProviderPoolTunnelSelectionUsesPassthrough' -count=1`.
- [ ] 최종 검증을 실행한다: `git diff --check`, `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`, `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1`.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

1. test double의 normalized 기록을 먼저 정리해 exact dispatch assertion이 신뢰 가능하게 만든다.
2. provider auth를 tunnel-only timing으로 이동한다.
3. normalized retry context를 보존한다.
4. strict-output passthrough label regression을 추가하고 전체 package tests를 실행한다.

### [REVIEW_MIXED_SELECT-1] Tunnel-Only Provider Auth And Passthrough Label

#### 문제

[apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:955)는 provider-pool selection 전에 `providerTunnelAuthHeaders`를 호출한다. `ProviderAuth.Required`가 true이면 selected path가 normalized여도 400이 된다. 또한 [apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:110)는 strict-output omitted mode를 provider-pool에서도 `transformed`로 바꿔, tunnel response metrics label을 실제 passthrough와 다르게 만든다.

#### 해결 방법

provider auth required 검증은 tunnel path가 선택된 뒤, node tunnel request를 보내기 전에만 실행한다. 필요하면 service request에 generic tunnel preparation callback을 추가해 `SubmitProviderPool`이 selection 이후 callback을 실행하고, 에러 시 queue slot을 release한 뒤 node로 보내지 않게 한다.

Before:

```go
providerAuthHeaders, authErr := s.providerTunnelAuthHeaders(r)
if authErr != nil {
    writeError(w, http.StatusBadRequest, "invalid_request_error", authErr.Error())
    return
}
poolReq.Tunnel.Headers = providerAuthHeaders
```

After:

```go
poolReq.Tunnel.PrepareHeaders = func() (map[string]string, error) {
    return s.providerTunnelAuthHeaders(r)
}
```

그리고 omitted provider-pool response mode는 tunnel path에서 `passthrough` label을 유지한다. strict output이 normalized path에 필요한 값은 output policy/input으로만 처리하고 tunnel metric label을 `transformed`로 바꾸지 않는다.

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

- [ ] `apps/edge/internal/openai/chat_handler.go`: provider-pool pre-selection auth check 제거, tunnel-only auth hook 연결, strict-output provider-pool responseMode override 제거 또는 path별 label 분리.
- [ ] `apps/edge/internal/service/run_dispatch.go`: selection 이후 tunnel dispatch 직전에 header callback이 필요하면 추가하고, callback error 시 slot release 후 return.
- [ ] `apps/edge/internal/openai/server_test.go`: normalized selection + required provider auth missing regression, strict-output passthrough metric/label regression.

#### 테스트 작성

- 작성: `TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth`
  - `ProviderAuth.Required=true`, missing provider auth header, `poolDispatchPath=normalized`
  - 기대: 200, `len(reqs)==1`, `len(tunnelReqs)==0`
- 작성/보강: strict-output provider-pool omitted-mode tunnel test에서 usage metric label 또는 observable metric state가 `passthrough`임을 검증.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth|TestChatProviderTunnelProviderAuthRequired|TestChatCompletionsStrictOutputProviderPoolDefaultKeepsPassthroughPath' -count=1
```

기대 결과: PASS.

### [REVIEW_MIXED_SELECT-2] Provider-Pool Normalized Retry Context

#### 문제

[apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:1010)와 [apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:1012)는 retry-capable completion functions에 빈 `SubmitRunRequest{}`를 넘긴다. tool validation retry는 [apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:496)와 [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:1534)의 `submitReq`를 사용하므로, provider-pool model key/input/metadata 없이 재시도된다.

#### 해결 방법

`completeChatCompletion`과 buffered stream retry가 retry 실행 함수를 주입받을 수 있게 refactor한다. 기본 path는 기존 `s.service.SubmitRun`을 사용한다. provider-pool normalized path는 원래 `ProviderPoolDispatchRequest`를 보존한 retry function을 넘겨 `SubmitProviderPool`로 다시 들어가고, normalized result만 `RunResult`로 반환한다. tunnel result가 선택되면 handle을 닫고 명시 오류를 반환하거나, call site가 tunnel writer로 전환하도록 별도 명확한 처리를 구현한다. 어떤 경우에도 빈 request retry나 selection-first 우회를 허용하지 않는다.

Before:

```go
retryReq := submitReq
retryReq.Metadata = toolValidationAttemptMetadata(submitReq.Metadata, attempt, failedRunID, valErr.Error())
next, submitErr := s.service.SubmitRun(r.Context(), retryReq)
```

After:

```go
next, submitErr := retryRun(r.Context(), retryReq)
```

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

- [ ] `apps/edge/internal/openai/chat_handler.go`: `completeChatCompletion` retry injection 또는 provider-pool 전용 retry helper 추가.
- [ ] `apps/edge/internal/openai/stream.go`: `streamBufferedChatCompletion` retry injection 또는 provider-pool 전용 retry helper 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: non-stream 및 buffered stream 중 최소 하나의 provider-pool normalized tool-validation retry regression 추가. 가능하면 둘 다 작성.

#### 테스트 작성

- 작성: `TestChatCompletionsProviderPoolNormalizedToolValidationRetryPreservesPoolRequest`
  - 첫 run은 invalid tool call, retry run은 valid result.
  - 기대: retry request `ProviderPool=true`, `ModelGroupKey`/metadata/input 유지, 빈 request 없음.
- 작성 또는 보강: streaming buffered path에서 tools request가 같은 retry function을 사용함을 검증.

#### 중간 검증

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

기대 결과: PASS.

### [REVIEW_MIXED_SELECT-3] Exact Handler Dispatch Tests

#### 문제

[apps/edge/internal/openai/server_test.go](/config/workspace/iop/apps/edge/internal/openai/server_test.go:181)와 [apps/edge/internal/openai/server_test.go](/config/workspace/iop/apps/edge/internal/openai/server_test.go:271)가 normalized provider-pool request를 두 번 기록한다. [apps/edge/internal/openai/server_test.go](/config/workspace/iop/apps/edge/internal/openai/server_test.go:7287)는 `len(reqs) < 1`만 검사해 이 문제를 통과시킨다.

#### 해결 방법

fake `SubmitProviderPool`은 선택된 path request를 정확히 한 번만 기록한다. Ollama-selected handler test는 `len(reqs)==1`, `len(tunnelReqs)==0`, `reqs[0].ProviderPool==true`, `reqs[0].ModelGroupKey` 보존을 assert한다.

Before:

```go
if len(reqs) < 1 {
    t.Fatalf("expected at least 1 normalized SubmitRun for Ollama path, got %d", len(reqs))
}
```

After:

```go
if len(reqs) != 1 {
    t.Fatalf("expected exactly 1 normalized SubmitRun for Ollama path, got %d", len(reqs))
}
```

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

- [ ] `apps/edge/internal/openai/server_test.go`: fake normalized recording duplicate 제거.
- [ ] `apps/edge/internal/openai/server_test.go`: Ollama-selected and tunnel-selected handler assertions exact once/no opposite path로 보강.

#### 테스트 작성

- 보강: `TestChatCompletionsProviderPoolOllamaSelectionUsesNormalizedRun`
- 보강: `TestChatCompletionsProviderPoolTunnelSelectionUsesPassthrough`

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestChatCompletionsProviderPoolOllamaSelectionUsesNormalizedRun|TestChatCompletionsProviderPoolTunnelSelectionUsesPassthrough' -count=1
```

기대 결과: PASS.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `apps/edge/internal/openai/chat_handler.go` | REVIEW_MIXED_SELECT-1, REVIEW_MIXED_SELECT-2 |
| `apps/edge/internal/openai/stream.go` | REVIEW_MIXED_SELECT-2 |
| `apps/edge/internal/openai/server_test.go` | REVIEW_MIXED_SELECT-1, REVIEW_MIXED_SELECT-2, REVIEW_MIXED_SELECT-3 |
| `apps/edge/internal/service/run_dispatch.go` | REVIEW_MIXED_SELECT-1, only if tunnel preparation hook is needed |

## 최종 검증

```bash
git diff --check
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1
```

기대 결과: 모든 명령 PASS.

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