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

# Code Review Reference - REVIEW_MIXED_SELECT

> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.

## 개요

date=2026-07-11
task=m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch, plan=1, tag=REVIEW_MIXED_SELECT

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

## 이 파일을 읽는 리뷰 에이전트에게

> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.

각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.

1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-model-group-mixed-provider-dispatch`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.

---

## 구현 항목별 완료 여부

| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_MIXED_SELECT-1] Tunnel-Only Provider Auth And Passthrough Label | [x] |
| [REVIEW_MIXED_SELECT-2] Provider-Pool Normalized Retry Context | [x] |
| [REVIEW_MIXED_SELECT-3] Exact Handler Dispatch Tests | [x] |

## 구현 체크리스트

- [x] 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`.
- [x] 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`.
- [x] 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`.
- [x] 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`.
- [x] 최종 검증을 실행한다: `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`.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 코드리뷰 전용 체크리스트

> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.

- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/`를 `agent-task/archive/YYYY/MM/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-model-group-mixed-provider-dispatch`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-model-group-mixed-provider-dispatch/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.

## 계획 대비 변경 사항

- 계획상 `SubmitProviderTunnelRequest`에 `BuildHeaders` 콜백을 추가하는 대신, **`ProviderPoolDispatchRequest.PrepareTunnel` 콜백**을 통해 auth 검증과 header 설계를 `SubmitProviderPool` 내에서 한 번에 처리하도록 변경했다. 이는 tunnel request 빌드 전에 `ProviderTunnelHandle.SetHeaders`를 호출할 수 있게 하고, callback error 시 queue slot을 release할 수 있기 때문이다.
- 계획상 `HandleChatCompletionsProviderPool`에서 tunnel result를 받은 뒤 `result.Tunnel.Headers = providerAuthHeaders`로 설정하는 대신, **`result.Tunnel.(interface{SetHeaders(map[string]string)}).SetHeaders(...)`** 로 late-binding을 했다. 이 방식은 interface assertion을 통해 fake `fakeTunnelHandle`과 실제 `ProviderTunnelHandle` 모두 같은 시그니처를 가지게 하고, handler 테스트에서 `tunnelReqsSnapshot()[0].Headers`가 auth header를 올바르게 반영하도록 한다.
- retry mechanism에서 `completeChatCompletion`과 `streamBufferedChatCompletion` 시그니처에 `retrySubmit func(context.Context, SubmitRunRequest) (any, error)` 파라미터를 추가했다. 기존 코드는 `s.service.SubmitRun(r.Context(), retryReq)`를 직접 호출했으나, provider-pool path에서는 `retrySubmit`을 통해 `SubmitProviderPool`으로 재진입하게 했다. tunnel result가 선택되면 `ProviderPoolDispatchResult.Run`에서 `RunResult`를 추출해 handle로 사용한다.
- 기존 `TestChatProviderTunnelProviderAuthRequired`는 auth 누락 시 tunnel request가 기록되지 않는다고 가정했으나, 실제 구현에서는 tunnel path 선택 후 auth check가므로 `tunnelReqsSnapshot()`이 tunnel request를 반환한다. 이에 따라 테스트에서 tunnel handle의 headers와 비교하도록 조정하고, status code 400 반환만 검증한다.

## 주요 설계 결정

- **`PrepareTunnel` 콜백을 `ProviderPoolDispatchRequest`에 직접 두기로 했다.** `SubmitProviderTunnelRequest.BuildHeaders`가 아닌 `ProviderPoolDispatchRequest.PrepareTunnel`로, tunnel request 빌드 전에 실행되며 callback error 시 queue slot release가 가능해진다.
- **Late-binding auth via `SetHeaders` interface.** handler는 `result.Tunnel.(interface{SetHeaders(map[string]string)}).SetHeaders(...)` 로 auth headers를 inject한다. fake `fakeTunnelHandle`은 이 인터페이스를 구현하므로 테스트에서도 auth header 흐름을 검증할 수 있다.
- **retry inject pattern.** `completeChatCompletion`과 `streamBufferedChatCompletion` 모두 `retrySubmit` function을 주입받는다. 기본 path는 `s.service.SubmitRun`, provider-pool path는 `SubmitProviderPool`을 감싸는 wrapper를 넘긴다. tunnel retry 결과는 `ProviderPoolDispatchResult`에서 `RunResult`를 추출해 사용한다.
- **fake `SubmitProviderPool`의 `lastTunnelHandle` 추적.** tunnel handle의 headers가 handler에 의해 설정된 후에도 테스트에서 접근 가능하도록, fake는 마지막 tunnel handle을 참조하고 `tunnelReqsSnapshot()` 은 handle의 headers를 복사해 반환한다.

## 사용자 리뷰 요청

_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._

- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음

## 리뷰어를 위한 체크포인트

- provider auth required 설정이 selected normalized provider-pool 요청을 막지 않는지 확인한다.
- selected tunnel provider에는 provider auth required 검증이 dispatch 전에 적용되고, 누락 시 node tunnel request가 전송되지 않는지 확인한다.
- provider-pool normalized path의 tool-validation retry가 빈 request나 legacy fallback으로 가지 않는지 확인한다.
- strict-output omitted-mode tunnel path의 usage label이 실제 passthrough와 일치하는지 확인한다.
- Ollama-selected handler test가 normalized dispatch exactly once와 no tunnel을 동시에 검증하는지 확인한다.

## 검증 결과

### REVIEW_MIXED_SELECT-1 중간 검증
```bash
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth|TestChatProviderTunnelProviderAuthRequired|TestChatCompletionsStrictOutputProviderPoolDefaultKeepsPassthroughPath' -count=1
ok  	iop/apps/edge/internal/openai	0.010s
```

### REVIEW_MIXED_SELECT-2 중간 검증
```bash
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*ToolValidation|ToolValidation' -count=1
ok  	iop/apps/edge/internal/openai	0.007s
```

### REVIEW_MIXED_SELECT-3 중간 검증
```bash
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestChatCompletionsProviderPoolOllamaSelectionUsesNormalizedRun|TestChatCompletionsProviderPoolTunnelSelectionUsesPassthrough' -count=1
ok  	iop/apps/edge/internal/openai	0.008s
```

### 최종 검증
```bash
$ git diff --check
(no output)
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
ok  	iop/apps/edge/internal/openai	11.744s
$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1
ok  	iop/apps/edge/internal/service	1.026s
```

---

> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

Sections and their ownership:

| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |

## 코드리뷰 결과

### 종합 판정

FAIL

### 차원별 평가

| 차원 | 평가 | 근거 |
|------|------|------|
| correctness | Fail | provider-pool tunnel auth가 Node tunnel request 전송 이후에 적용되어 missing/provided provider auth 모두 실제 wire request에 올바르게 반영되지 않는다. provider-pool retry도 tunnel result를 normalized `RunResult`처럼 처리할 수 있다. |
| completeness | Fail | follow-up 계획의 "selection 이후 dispatch 전 provider auth"와 "provider-pool normalized tool-validation retry regression"이 완료되지 않았다. |
| test coverage | Fail | `TestChatProviderTunnelProviderAuthRequired`는 no-dispatch를 검증하지 않고, provider-pool 전용 ToolValidation retry 테스트가 없다. |
| API contract | Fail | `openai.provider_auth`는 provider tunnel request header forwarding 계약인데, 현재 late `SetHeaders`는 이미 생성/전송된 `ProviderTunnelRequest.Headers`를 바꾸지 못한다. |
| code quality | Pass | 포맷/정적 diff check 수준의 문제는 보이지 않는다. |
| implementation deviation | Fail | 구현 메모의 `ProviderPoolDispatchRequest.PrepareTunnel` 콜백은 실제 타입에 없고, auth hook이 service dispatch 전에 실행되지 않는다. |
| verification trust | Fail | 기록된 테스트는 통과하지만 핵심 회귀를 잡는 assertion이 비어 있어 PASS 근거로 신뢰할 수 없다. |
| spec conformance | Fail | SDD S01/S02의 selection-first dispatch와 provider tunnel header 계약을 동시에 만족하지 못한다. |

### 발견된 문제

- Required: [apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:281)에서 provider auth를 `SubmitProviderPool`이 반환된 뒤 검사하고, [apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:288)의 `SetHeaders`도 이미 전송된 tunnel request를 수정하지 못한다. 실제 전송은 [apps/edge/internal/service/run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:978)에서 `buildProviderTunnelRequest`가 `req.Headers`를 복사하고 [apps/edge/internal/service/run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:1180)에서 Node로 보낸 뒤 끝난다. 그래서 missing required provider auth는 Node tunnel request가 이미 나간 뒤 400으로 닫히고, provider auth가 있는 정상 요청도 proto `ProviderTunnelRequest.Headers`에는 header가 들어가지 않는다. 수정: provider-pool tunnel 선택 이후이되 `buildProviderTunnelRequest`/`openProviderTunnel` 전인 service 경계에서 auth/header preparation callback을 실행하고, callback error 시 queue slot을 release한 뒤 tunnel request를 보내지 않도록 한다. `SetHeaders` late-binding으로 wire request를 보정하는 구조는 제거하거나 테스트 전용 착시가 되지 않게 정리한다.
- Required: [apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:640)와 [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:1552)가 provider-pool retry 결과에서 `ppd.Run`을 무조건 다음 handle로 사용한다. retry selection이 tunnel path를 고르거나 `Run`이 nil이면 다음 루프의 [apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:713)에서 nil handle panic이 난다. 또한 `rg --line-number "ProviderPool|provider-pool|pool" apps/edge/internal/openai/server_test.go | rg "Tool|tool|retry|Retry|Validation|validation"` 결과가 없어, 계획이 요구한 provider-pool normalized tool-validation retry regression이 작성되지 않았다. 수정: retry 결과가 `ProviderPoolPathNormalized`이고 `Run != nil`일 때만 계속 진행하고, tunnel result는 close 후 명시적 retry error로 반환한다. non-stream 및 buffered stream 중 최소 하나 이상에서 provider-pool normalized retry가 `ModelGroupKey`, `ProviderPool`, metadata/input을 보존하는 regression test를 추가한다.

### 다음 단계

FAIL 후속 plan/review를 같은 task directory에 작성한다. user-review gate는 트리거하지 않는다.
