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

# PLAN-local-G07: 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으로 남긴다.

## 배경

현재 Chat/Responses 핸들러는 catalog match인 provider-pool route를 `routeUsesProviderTunnel`에서 즉시 tunnel로 판단한다. service는 이미 `SubmitRun`과 `SubmitProviderTunnel` 각각에서 queue admission과 slot release를 지원하지만, mixed group에서는 provider를 먼저 하나 선택한 뒤 그 provider의 path로만 실행해야 한다. 이 계획은 01 classifier plan의 execution path metadata를 사용해 같은 queue slot/lease에서 normalized RunRequest 또는 ProviderTunnelRequest 중 하나만 보내는 service-level dispatch를 만든다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 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

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/analyze-roadmap-position/SKILL.md`
- `agent-ops/skills/common/_templates/roadmap-position-report-template.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-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/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/mapper_test.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/run_dispatch_internal_test.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/node/internal/adapters/config_set.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/adapters/registry.go`
- `apps/node/internal/adapters/config_set_test.go`
- `apps/node/internal/adapters/factory_internal_test.go`
- `apps/node/internal/adapters/adapters_blackbox_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, `go test ./apps/edge/internal/service -count=1`.
  - S02: edge service/OpenAI handler tests proving Ollama-selected provider-pool dispatch does not use tunnel.
- 이 계획은 service에서 one-shot provider selection을 만들고 OpenAI handler가 catalog match만으로 tunnel을 고르지 않게 해 S01/S02 증거를 만든다.

### 테스트 환경 규칙

- 선택 test_env: `local`
- `agent-test/local/rules.md`, `edge-smoke`, `node-smoke`, `platform-common-smoke`를 읽었다.
- 기본 Go build cache permission 오류가 있었으므로 `GOCACHE=/tmp/iop-go-cache`를 사용한다. cached output으로 최종 검증을 대체하지 않는다.
- 적용 명령:
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1`
  - `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`

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

- service tests는 현재 `SubmitRun` provider-pool과 `SubmitProviderTunnel` provider-pool을 별도로 검증한다 ([apps/edge/internal/service/run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:121), [apps/edge/internal/service/run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:872)).
- 같은 admission 결과를 가지고 path에 따라 둘 중 하나만 실행하는 통합 surface가 없다.
- OpenAI tests는 provider-pool default가 tunnel이라고 기대하는 기존 테스트가 있어 selection-first 기대값으로 바꿔야 한다 ([apps/edge/internal/openai/server_test.go](/config/workspace/iop/apps/edge/internal/openai/server_test.go:4116)).

### 심볼 참조

- 기존 exported symbol 제거는 피한다.
- 새 service method를 추가할 경우 `openai.runService` interface와 `fakeRunService` call site를 함께 갱신한다 ([apps/edge/internal/openai/server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:18)).
- `routeUsesProviderTunnel`는 legacy direct provider route 판정으로 좁히거나 provider-pool을 제외하도록 변경할 수 있다 ([apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:353)).

### 분할 판단

- split policy를 평가했고 multi-plan을 선택했다.
- 공유 task group: `agent-task/m-model-group-mixed-provider-dispatch/`
- 선행 의존성:
  - predecessor 01: `agent-task/m-model-group-mixed-provider-dispatch/01-provider-path-classifier/`; active plan/review 존재, `complete.log` 없음. 이 plan은 01 PASS 후 실행한다.
- 후속 의존성:
  - `03-mixed-model-group-openai`는 이 plan PASS 후 실행한다.

### 범위 결정 근거

- 이 plan은 selection-first mechanics와 S01/S02 evidence까지만 다룬다.
- S05 mixed fixture matrix와 broader Chat/Responses passthrough byte-identity 회귀 조정은 03 plan으로 넘긴다.
- provider registry/config alias 보강은 01 plan 범위로 본다.

### 빌드 등급

- `local-G07`: service admission/lease, provider tunnel, normalized run, OpenAI handler interface를 함께 건드리는 cross-module 변경이지만 local Go tests로 닫을 수 있다.

## 구현 체크리스트

- [ ] 01-provider-path-classifier PASS/complete 여부를 확인하고, 미완료면 이 plan 구현을 시작하지 않는다.
- [ ] service에 provider-pool one-shot selection dispatch를 추가해 동일 admission slot으로 selected execution path에 따라 `RunRequest` 또는 `ProviderTunnelRequest` 중 하나만 전송한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1`.
- [ ] OpenAI Chat provider-pool default path가 catalog match만으로 tunnel을 고르지 않고 service selection 결과를 따르도록 통합한다. Ollama-selected provider-pool 요청은 tunnel 없이 normalized `SubmitRun`만 호출된다는 테스트를 추가한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

1. `01-provider-path-classifier`가 PASS되어 candidate execution path가 존재하는지 확인한다.
2. service one-shot selection helper/result type을 만든다.
3. 기존 `SubmitRun`/`SubmitProviderTunnel` queued path를 helper 재사용으로 정리해 slot release semantics가 유지되는지 검증한다.
4. OpenAI Chat handler와 fake service를 새 selection-first surface에 맞춘다.

### [MIXED_SELECT-1] One-Shot Provider Pool Dispatch In Service

#### 문제

`SubmitRun`은 provider-pool일 때 `submitRunQueued`에서 admission을 하고 selected provider로 RunRequest를 보낸다 ([apps/edge/internal/service/run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:136)). `SubmitProviderTunnel`도 별도 admission을 하고 selected provider로 ProviderTunnelRequest를 보낸다 ([apps/edge/internal/service/run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:879)). 호출자가 path를 먼저 골라 두 경로 중 하나를 호출해야 하므로, mixed provider group에서는 provider 선택 전에 path가 결정되는 문제가 남는다.

#### 해결 방법

provider-pool 전용 one-shot surface를 추가한다. 구현명은 codebase 스타일에 맞춰 조정할 수 있지만, 핵심은 `resolveProviderPoolCandidates`와 `queue.admitWithReason`을 정확히 한 번 호출하고 selected candidate의 `executionPath`로만 분기하는 것이다.

Before:

```go
if routeUsesProviderTunnel(dispatch) {
    return service.SubmitProviderTunnel(ctx, tunnelReq)
}
return service.SubmitRun(ctx, runReq)
```

After:

```go
result, err := service.SubmitProviderPool(ctx, ProviderPoolDispatchRequest{
    Run: runReq,
    Tunnel: tunnelReq,
})
switch result.Path {
case ProviderPoolPathTunnel:
    return result.Tunnel
case ProviderPoolPathNormalized:
    return result.Run
}
```

Factor shared helpers so existing `SubmitRun` and `SubmitProviderTunnel` keep behavior but use the same selected-candidate dispatch internals where practical.

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

- [ ] `apps/edge/internal/service/run_dispatch.go`: provider-pool dispatch request/result type, one-shot method, helper extraction.
- [ ] `apps/edge/internal/service/model_queue.go`: no release semantic regression; only touch if helper requires small type additions.
- [ ] `apps/edge/internal/service/run_dispatch_internal_test.go`: tunnel and normalized one-shot tests.
- [ ] `apps/edge/internal/service/model_queue_test.go`: candidate/path selection edge tests if not covered in internal test.

#### 테스트 작성

- 작성: `TestSubmitProviderPoolSelectsTunnelProviderAndReleasesSlot`
- 작성: `TestSubmitProviderPoolSelectsNormalizedProviderAndDoesNotOpenTunnel`
- 작성: `TestSubmitProviderPoolUsesSingleAdmissionForMixedCandidates`
- assertion: selected provider의 adapter/target rewrite, sent protobuf type, queue inflight release, non-selected path not sent.

#### 중간 검증

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

기대 결과: PASS.

### [MIXED_SELECT-2] OpenAI Chat Uses Selection Result

#### 문제

`handleChatCompletions`는 `providerTunnelRoute := routeUsesProviderTunnel(dispatch)`를 먼저 계산하고 provider-pool이면 바로 passthrough/tunnel branch로 들어간다 ([apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:67)). `routeUsesProviderTunnel`는 `d.ProviderPool`이면 무조건 true를 반환한다 ([apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:357)). 그래서 catalog에 Ollama provider만 있어도 tunnel path로 고정된다.

#### 해결 방법

provider-pool route는 legacy direct provider route와 분리한다. catalog match는 provider-pool selection request를 만들고, service 결과가 tunnel이면 기존 passthrough writer를 사용하고 normalized면 기존 RunResult completion/stream path를 사용한다. legacy `openai_compat`/`vllm` direct route는 기존 tunnel default를 유지한다.

Before:

```go
func routeUsesProviderTunnel(d routeDispatch) bool {
    if d.ProviderPool {
        return true
    }
    return d.Adapter == "openai_compat" || d.Adapter == "vllm"
}
```

After:

```go
func routeUsesProviderTunnel(d routeDispatch) bool {
    if d.ProviderPool {
        return false
    }
    return d.Adapter == "openai_compat" || d.Adapter == "vllm"
}
```

Provider-pool handling should explicitly call the new service selection method before choosing response writer.

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

- [ ] `apps/edge/internal/openai/server.go`: `runService` interface에 selection-first method 추가.
- [ ] `apps/edge/internal/openai/chat_handler.go`: provider-pool branch를 selection-first로 전환.
- [ ] `apps/edge/internal/openai/server_test.go`: `fakeRunService`에 selection-first method 및 fixtures 추가.
- [ ] 기존 provider-pool default tunnel tests를 selected tunnel provider 기대값으로 조정.

#### 테스트 작성

- 작성: `TestChatCompletionsProviderPoolOllamaSelectionUsesNormalizedRun`
- 작성: `TestChatCompletionsProviderPoolTunnelSelectionUsesPassthrough`
- assertion: Ollama-selected fixture는 `len(tunnelReqs)==0`, `len(reqs)==1`, `ProviderPool=true`; tunnel-selected fixture는 반대.

#### 중간 검증

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

기대 결과: PASS.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `apps/edge/internal/service/run_dispatch.go` | MIXED_SELECT-1 |
| `apps/edge/internal/service/model_queue.go` | MIXED_SELECT-1 |
| `apps/edge/internal/service/run_dispatch_internal_test.go` | MIXED_SELECT-1 |
| `apps/edge/internal/service/model_queue_test.go` | MIXED_SELECT-1 |
| `apps/edge/internal/openai/server.go` | MIXED_SELECT-2 |
| `apps/edge/internal/openai/chat_handler.go` | MIXED_SELECT-2 |
| `apps/edge/internal/openai/server_test.go` | MIXED_SELECT-2 |

## 최종 검증

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

기대 결과: 모든 명령 PASS.

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