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

# PLAN-local-G07: Provider-Pool Auth And Retry Follow-Up

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

`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으로 남긴다.

## 배경

직전 follow-up은 provider-pool selection-first 경로의 큰 방향은 유지했지만, tunnel auth header를 Node tunnel request 전송 이후에 붙이는 구조라 실제 wire request에는 auth가 반영되지 않는다. 또한 provider-pool normalized tool-validation retry가 retry 결과의 execution path를 검증하지 않아 tunnel result를 nil run handle로 이어갈 수 있고, 이를 잡는 provider-pool 전용 regression test도 없다. 이 계획은 직전 FAIL의 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_1.log`
- Current archived review: `agent-task/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/code_review_local_G07_1.log`
- Previous loop evidence: `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`
- Verdict: FAIL
- Required summary:
  - `apps/edge/internal/openai/chat_handler.go:281`: provider-pool tunnel auth runs after `SubmitProviderPool`; `SetHeaders` does not update the already-sent `ProviderTunnelRequest`.
  - `apps/edge/internal/service/run_dispatch.go:978`, `apps/edge/internal/service/run_dispatch.go:1180`, `apps/edge/internal/service/run_dispatch.go:1270`: tunnel request headers are copied and sent inside service before handler late-binding runs.
  - `apps/edge/internal/openai/chat_handler.go:640`, `apps/edge/internal/openai/stream.go:1552`: provider-pool retry accepts `ProviderPoolDispatchResult.Run` without checking `Path` or nil.
  - `apps/edge/internal/openai/server_test.go:4781`: required-provider-auth test does not assert no tunnel dispatch; provider-pool tool-validation retry tests are absent.
- Affected files: `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/stream.go`, `apps/edge/internal/openai/server_test.go`; `apps/edge/internal/openai/server.go` only if interface shape must change.
- Verification evidence from this review:
  - `git diff --check`: passed with no output.
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth|TestChatProviderTunnelProviderAuthRequired|ProviderPool.*ToolValidation|ToolValidation|TestChatCompletionsStrictOutputProviderPoolDefaultKeepsPassthroughPath|TestChatCompletionsProviderPoolOllamaSelectionUsesNormalizedRun|TestChatCompletionsProviderPoolTunnelSelectionUsesPassthrough' -count=1`: passed, but the regex is satisfied by non-provider-pool ToolValidation tests.
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1`: passed.
  - `rg --line-number "ProviderPool|provider-pool|pool" apps/edge/internal/openai/server_test.go | rg "Tool|tool|retry|Retry|Validation|validation"`: no output, confirming provider-pool tool-validation regression is missing.
- Narrow archive reread allowed if needed: only the four loop log files listed above.

## 분석 결과

### 읽은 파일

- `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-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `agent-contract/inner/edge-config-runtime-refresh.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/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/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`
- `agent-task/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/PLAN-local-G07.md`
- `agent-task/m-model-group-mixed-provider-dispatch/02-selection-first-provider-dispatch/CODE_REVIEW-local-G07.md`
- `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/run_result.go`
- `apps/edge/internal/openai/usage_metrics_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/run_dispatch_internal_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은 S01/S02 completion evidence를 신뢰 가능하게 만들기 위해, tunnel 선택 시 auth/header가 dispatch 전에 결정되고 normalized retry가 provider-pool context를 유지한다는 handler/service regression을 추가한다.

### 테스트 환경 규칙

- 선택 test_env: `local`
- `agent-test/local/rules.md`를 읽었고 Go package test는 `GOCACHE=/tmp/iop-go-cache`와 `-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`
- 이번 follow-up은 실제 provider endpoint나 field bootstrap을 요구하지 않는 bounded package test 범위다.

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

- provider-pool tunnel auth missing: 기존 `TestChatProviderTunnelProviderAuthRequired`는 status 400만 보고 tunnel dispatch count를 확인하지 않는다.
- provider-pool tunnel auth forwarding: fake `tunnelReqsSnapshot`이 late handle headers를 request에 덧씌워 실제 wire header 누락을 숨긴다.
- provider-pool normalized tool-validation retry: provider-pool을 포함한 retry regression test가 없다.
- exact normalized dispatch와 strict-output omitted passthrough path는 현재 테스트가 일부 보강되어 있으나, 위 두 auth/retry 공백 때문에 PASS evidence가 부족하다.

### 심볼 참조

- `ProviderTunnelResult.SetHeaders`는 새로 추가된 interface method이며 현재 handler/fake/handle에서만 쓰인다. 이번 follow-up에서 제거하거나 pre-dispatch callback으로 대체하면 `rg --sort path "SetHeaders\\(" apps/edge/internal`로 모든 call site를 갱신한다.
- `SubmitProviderPool` call sites: `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/server_test.go`, `apps/edge/internal/service/run_dispatch_internal_test.go`.
- `completeChatCompletion`/`streamChatCompletion`/`streamBufferedChatCompletion` signature call sites는 `apps/edge/internal/openai/chat_handler.go`와 `apps/edge/internal/openai/stream.go` 안에서만 확인된다.

### 분할 판단

- 기존 split subtask `02-selection-first-provider-dispatch`의 review loop 연장이다.
- 공유 task group: `agent-task/m-model-group-mixed-provider-dispatch/`
- 새 subtask로 나누지 않는다. 두 Required 모두 provider-pool Chat handler와 service tunnel dispatch boundary를 함께 고쳐야 하며 같은 `apps/edge/internal/openai` 및 `apps/edge/internal/service` package tests로 검증된다.

### 범위 결정 근거

- Responses provider-pool path는 이번 Required에 포함하지 않는다.
- provider path classifier와 config alias 확장은 직전/선행 범위이므로 수정하지 않는다.
- agent-contract/agent-spec 문서 갱신은 이번 follow-up의 PASS 조건이 아니다. 현재 계약 위반을 코드와 tests로 바로잡는 것이 범위다.
- live external provider smoke, field bootstrap, Control Plane/Client 작업은 제외한다.

### 빌드 등급

- `local-G07`: service dispatch boundary, OpenAI Chat handler retry, fake test double이 연결되지만 범위가 명확하고 deterministic Go package tests로 검증 가능하다.

## 구현 체크리스트

- [ ] provider-pool tunnel path에서 provider auth/header preparation을 `buildProviderTunnelRequest`와 Node `Send` 전에 실행한다. missing required auth는 queue slot release 후 400/invalid_request_error로 끝나며 tunnel request가 기록/전송되지 않아야 한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'TestChatProviderTunnelProviderAuthRequired|TestChatCompletionsProviderPoolTunnelProviderAuthForwardedBeforeDispatch|TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth' -count=1`.
- [ ] provider-pool tool-validation retry가 normalized `ProviderPoolDispatchResult`만 계속 처리하게 하고, tunnel/nil result는 close 후 명시 오류로 처리한다. provider-pool normalized retry regression을 추가한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*ToolValidation|ToolValidation.*ProviderPool' -count=1`.
- [ ] 최종 검증을 실행한다: `git diff --check`, `rg --sort path --line-number '(ProviderPool|provider-pool|pool).*(ToolValidation|tool validation|tool-validation|retry)|(ToolValidation|tool validation|tool-validation|retry).*(ProviderPool|provider-pool|pool)' apps/edge/internal/openai/server_test.go`, `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. service 경계에 pre-dispatch tunnel preparation hook을 추가하거나 동등한 구조로 auth/header를 wire request 생성 전에 확정한다.
2. handler의 provider-pool auth late-binding과 fake의 header 덧씌우기 착시를 제거한다.
3. retry result extraction을 normalized-only로 방어하고 provider-pool retry regression을 추가한다.
4. 최종 package tests와 deterministic `rg` 확인을 실행한다.

### [REVIEW_REVIEW_MIXED_SELECT-1] Pre-Dispatch Provider Auth For Provider-Pool Tunnel

#### 문제

`apps/edge/internal/openai/chat_handler.go:281`은 `SubmitProviderPool`이 이미 tunnel request를 보낸 뒤 provider auth를 검사한다. `apps/edge/internal/service/run_dispatch.go:1270`은 `SubmitProviderTunnelRequest.Headers`를 proto request로 복사하고, `apps/edge/internal/service/run_dispatch.go:1180`은 그 proto를 Node로 전송한다. 따라서 handler의 late `SetHeaders`는 wire request를 바꾸지 못한다.

#### 해결 방법

provider-pool tunnel 선택 이후, 그러나 `buildProviderTunnelRequest`와 `openProviderTunnel` 이전에 실행되는 preparation hook을 service request 타입에 둔다. hook은 header map을 만들거나 request를 보정하고, error를 반환하면 `releaseSlotWithLong` 후 dispatch를 중단한다.

Before:

```go
result, err := s.service.SubmitProviderPool(r.Context(), poolReq)
providerAuthHeaders, authErr := s.providerTunnelAuthHeaders(r)
if tun, ok := result.Tunnel.(interface{ SetHeaders(map[string]string) }); ok {
    tun.SetHeaders(providerAuthHeaders)
}
```

After:

```go
poolReq.PrepareTunnel = func(req edgeservice.SubmitProviderTunnelRequest) (edgeservice.SubmitProviderTunnelRequest, error) {
    headers, err := s.providerTunnelAuthHeaders(r)
    if err != nil {
        return req, err
    }
    req.Headers = headers
    return req, nil
}
```

`SubmitProviderPool`는 tunnel path에서 adapter/target을 확정한 뒤 `PrepareTunnel`을 호출하고, 그 결과로 `buildProviderTunnelRequest`를 호출한다.

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

- [ ] `apps/edge/internal/service/run_dispatch.go`: provider-pool tunnel pre-dispatch preparation hook 추가, error 시 slot release, late `SetHeaders` interface 제거 또는 비사용.
- [ ] `apps/edge/internal/openai/chat_handler.go`: provider auth를 preparation hook으로 전달하고, `SubmitProviderPool` 반환 뒤 auth 검사/late header mutation 제거.
- [ ] `apps/edge/internal/openai/server_test.go`: fake `SubmitProviderPool`이 service처럼 preparation hook을 dispatch 전 실행하게 수정하고, `tunnelReqsSnapshot`의 late handle header 덧씌우기 제거.
- [ ] `apps/edge/internal/openai/server.go`: interface type 변경이 필요하면 반영.

#### 테스트 작성

- 보강: `TestChatProviderTunnelProviderAuthRequired`
  - missing provider auth, tunnel-selected provider-pool
  - 기대: 400, `len(fake.tunnelReqsSnapshot()) == 0`
- 작성: `TestChatCompletionsProviderPoolTunnelProviderAuthForwardedBeforeDispatch`
  - provider auth header 있음, tunnel-selected provider-pool
  - 기대: fake가 기록한 tunnel request `Headers["Authorization"] == "Bearer <token>"`
- 유지: `TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth`
  - normalized-selected provider-pool은 missing provider auth로 막히지 않는다.

#### 중간 검증

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

기대 결과: PASS.

### [REVIEW_REVIEW_MIXED_SELECT-2] Provider-Pool Retry Result Guard And Regression

#### 문제

`apps/edge/internal/openai/chat_handler.go:640`과 `apps/edge/internal/openai/stream.go:1552`는 provider-pool retry 결과에서 `ppd.Run`을 무조건 다음 handle로 사용한다. retry selection이 tunnel path를 고르면 `Run == nil`일 수 있고, 다음 `collectChatCompletionOutput`에서 nil handle panic이 난다. 또한 provider-pool 전용 ToolValidation retry 테스트가 없어 현재 regex 검증은 일반 ToolValidation tests만으로 통과한다.

#### 해결 방법

retry result extraction helper를 만들거나 같은 guard를 두 call site에 적용한다. direct `RunResult`는 허용한다. `*ProviderPoolDispatchResult`는 `Path == ProviderPoolPathNormalized && Run != nil`일 때만 허용한다. tunnel result가 오면 tunnel handle을 close하고 retry error를 반환해 HTTP/SSE error로 끝낸다.

Before:

```go
} else if ppd, ok := next.(*edgeservice.ProviderPoolDispatchResult); ok {
    handle = ppd.Run
}
```

After:

```go
} else if ppd, ok := next.(*edgeservice.ProviderPoolDispatchResult); ok {
    if ppd.Path != edgeservice.ProviderPoolPathNormalized || ppd.Run == nil {
        if ppd.Tunnel != nil {
            ppd.Tunnel.Close()
        }
        writeError(w, http.StatusBadGateway, "tool_validation_retry_error", "provider-pool retry selected tunnel path")
        return
    }
    handle = ppd.Run
}
```

streaming path는 `writeSSEErrorWithType`로 같은 오류를 반환한다.

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

- [ ] `apps/edge/internal/openai/chat_handler.go`: non-stream retry extraction guard 추가.
- [ ] `apps/edge/internal/openai/stream.go`: buffered stream retry extraction guard 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: provider-pool normalized tool-validation retry regression 추가.

#### 테스트 작성

- 작성: `TestChatCompletionsProviderPoolNormalizedToolValidationRetryPreservesPoolRequest`
  - 첫 provider-pool normalized run은 invalid text tool call, retry run은 valid result.
  - 기대: fake `SubmitProviderPool` 요청 2회, 두 번째 `Run.ProviderPool=true`, `Run.ModelGroupKey` 유지, retry metadata `iop_tool_validation_attempt=2` 유지.
- 작성 또는 보강: `TestChatCompletionsProviderPoolToolValidationRetryRejectsTunnelResult`
  - retry가 tunnel path를 반환하도록 fake를 구성한다.
  - 기대: panic 없이 `tool_validation_retry_error`로 종료하고 tunnel handle close가 관측된다.
- streaming buffered path는 가능하면 같은 helper를 검증하는 stream variant를 추가한다. 시간이 부족하면 non-stream + shared helper test로 충분하다고 `계획 대비 변경 사항`에 근거를 남긴다.

#### 중간 검증

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

기대 결과: PASS이며 provider-pool 이름을 가진 ToolValidation retry 테스트가 실제로 실행된다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/run_dispatch.go` | REVIEW_REVIEW_MIXED_SELECT-1 |
| `apps/edge/internal/openai/chat_handler.go` | REVIEW_REVIEW_MIXED_SELECT-1, REVIEW_REVIEW_MIXED_SELECT-2 |
| `apps/edge/internal/openai/stream.go` | REVIEW_REVIEW_MIXED_SELECT-2 |
| `apps/edge/internal/openai/server.go` | REVIEW_REVIEW_MIXED_SELECT-1 if interface changes |
| `apps/edge/internal/openai/server_test.go` | REVIEW_REVIEW_MIXED_SELECT-1, REVIEW_REVIEW_MIXED_SELECT-2 |

## 최종 검증

```bash
git diff --check
rg --sort path --line-number '(ProviderPool|provider-pool|pool).*(ToolValidation|tool validation|tool-validation|retry)|(ToolValidation|tool validation|tool-validation|retry).*(ProviderPool|provider-pool|pool)' apps/edge/internal/openai/server_test.go
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. `rg`는 provider-pool tool-validation retry test 이름 또는 관련 assertion을 최소 1개 이상 출력해야 한다.

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