<!-- task=m-model-group-mixed-provider-dispatch/03-mixed-model-group-openai plan=0 tag=MIXED_GROUP -->

# PLAN-local-G06: Mixed Model Group OpenAI Fixtures

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

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

## 배경

01/02 plan이 provider path classifier와 selection-first dispatch를 제공하면, 마지막으로 mixed model group이 실제 OpenAI surface에서 두 provider 종류를 모두 후보로 유지하고 선택 결과에 따라 다른 path를 실행한다는 fixture 증거가 필요하다. 현재 OpenAI tests는 provider-pool catalog match를 tunnel default로 보는 기존 기대값이 많아, mixed/Ollama-only group이 normalized path를 타는 회귀 증거가 부족하다. 이 계획은 S05 acceptance를 닫기 위한 OpenAI/service 테스트 matrix를 추가한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 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:
  - `mixed-model-group`: 같은 model group 안의 vLLM/vLLM-MLX/Lemonade/openweight cloud provider와 Ollama provider가 모두 후보로 남고, 선택된 provider별로 tunnel 또는 normalized path가 실행된다.
- 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: S05
- Milestone Task id: `mixed-model-group`
- Evidence Map:
  - S05: mixed and Ollama-only model group fixtures.
  - 검증: `go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/internal/service -count=1`.
- 이 계획은 service candidate retention과 OpenAI surface path selection fixture를 모두 추가해 S05를 닫는다.

### 테스트 환경 규칙

- 선택 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 candidate tests는 disabled/provider-first 일부를 다루지만, 같은 catalog model group 안에 OpenAI-compatible provider와 Ollama provider가 함께 남는 S05 fixture가 없다.
- OpenAI tests는 `TestChatCompletionsProviderPoolDispatch` 등에서 tunnel default를 전제로 한다 ([apps/edge/internal/openai/server_test.go](/config/workspace/iop/apps/edge/internal/openai/server_test.go:4116)).
- `fakeRunService`는 tunnel/normalized 호출 기록은 있지만, selection-first 결과를 mixed group case별로 명시하는 fixture field가 필요하다 ([apps/edge/internal/openai/server_test.go](/config/workspace/iop/apps/edge/internal/openai/server_test.go:25)).

### 심볼 참조

- 기존 exported symbol 제거는 피한다.
- 02 plan에서 추가된 selection-first service method와 fake service extension을 재사용한다.
- 새 test helper가 필요하면 `server_test.go` 내부 helper로 한정한다.

### 분할 판단

- split policy를 평가했고 multi-plan을 선택했다.
- 공유 task group: `agent-task/m-model-group-mixed-provider-dispatch/`
- 선행 의존성:
  - predecessor 01: active plan/review 존재, `complete.log` 없음.
  - predecessor 02: active plan/review 존재, `complete.log` 없음.
- 이 plan은 01과 02가 PASS된 뒤 실행해야 한다. 선행 complete.log가 없으면 구현을 시작하지 않는다.

### 범위 결정 근거

- `/v1/responses` provider-pool behavior는 02에서 shared helper 변화에 따른 회귀 검증 대상일 수 있지만, S05 completion fixture는 Chat/OpenAI server tests와 service tests에 집중한다.
- provider auth, sideband byte-identity, write failure/cancel timeout 등 기존 passthrough semantics는 기존 tests를 통과시키는 회귀 범위로만 다룬다.
- contract/spec 문서 갱신은 이 milestone의 `contract-spec` task 범위라 제외한다.

### 빌드 등급

- `local-G06`: 선행 service/interface 변경 위에 test matrix와 bounded handler adjustments를 추가하는 작업이며 local Go package tests로 검증 가능하다.

## 구현 체크리스트

- [x] 01-provider-path-classifier와 02-selection-first-provider-dispatch PASS/complete 여부를 확인하고, 미완료면 이 plan 구현을 시작하지 않는다.
- [x] service test에 mixed provider group 후보 retention fixture를 추가해 OpenAI-compatible provider와 Ollama provider가 같은 model group 후보로 모두 남고 각 execution path가 유지됨을 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1`. 추가 테스트: `TestResolveProviderPoolCandidatesKeepsMixedProviderTypes`.
- [x] OpenAI server test에 mixed group과 Ollama-only group fixtures를 추가해 selected OpenAI-compatible provider는 tunnel, selected Ollama provider는 normalized path를 실행함을 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`. 추가 테스트: `TestChatCompletionsMixedProviderPoolTunnelSelection`, `TestChatCompletionsMixedProviderPoolOllamaSelection`, `TestChatCompletionsOllamaOnlyProviderPoolUsesNormalizedPath`.
- [x] 기존 provider-pool passthrough tests가 selection-first 이후에도 tunnel-selected provider fixture임을 명확히 하도록 helper/expectation을 정리한다.
- [x] CODE_REVIEW-local-G06.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채웠다.

## 의존 관계 및 구현 순서

1. 01/02 plan의 PASS `complete.log` 존재 여부를 확인한다.
2. service mixed candidate retention test를 먼저 추가해 S05 후보 조건을 고정한다.
3. OpenAI fake service selection fixture를 이용해 tunnel-selected와 normalized-selected cases를 추가한다.
4. 기존 passthrough tests가 실패하면 provider selection fixture를 tunnel로 명시해 기존 byte-identity assertions가 계속 같은 의미를 갖게 한다.

### [MIXED_GROUP-1] Service Mixed Candidate Retention

#### 문제

`resolveProviderPoolCandidates`는 catalog providers를 순회하며 후보를 만든다 ([apps/edge/internal/service/run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:596)). 하지만 S05처럼 vLLM/vLLM-MLX/Lemonade/openweight cloud provider와 Ollama provider가 같은 model group에 있을 때 둘 다 후보로 남는지, path metadata가 다른지 직접 증명하는 test가 없다.

#### 해결 방법

service test fixture를 추가한다. catalog model `mixed-model`에 `prov-vllm`, `prov-vllm-mlx`, `prov-lemonade`, `prov-ollama`를 넣고, node store의 provider configs에는 각 provider의 served model, health, capacity를 모두 valid로 둔다. resolver 결과에서 provider ids가 모두 존재하고 OpenAI-compatible 계열은 tunnel path, Ollama는 normalized path인지 assertion한다.

Before:

```go
candidates, _, err := svc.resolveProviderPoolCandidates(req, store, catalog)
// existing tests assert disabled/provider-first subsets only
```

After:

```go
candidates, _, err := svc.resolveProviderPoolCandidates(req, store, catalog)
assertCandidate("prov-vllm", providerExecutionPathTunnel)
assertCandidate("prov-ollama", providerExecutionPathNormalized)
```

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

- [ ] `apps/edge/internal/service/model_queue_test.go` 또는 `run_dispatch_internal_test.go`: mixed candidate retention test 추가.
- [ ] 필요 시 helper 함수로 candidates by providerID map 생성.

#### 테스트 작성

- 작성: `TestResolveProviderPoolCandidatesKeepsMixedProviderTypes`
- assertion: all valid providers remain, served target and adapter key are preserved, execution path differs by provider type.

#### 중간 검증

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

기대 결과: PASS.

### [MIXED_GROUP-2] OpenAI Mixed/Ollama-Only Fixtures

#### 문제

OpenAI server tests currently record tunnel requests and run requests separately but provider-pool tests assume catalog match implies tunnel ([apps/edge/internal/openai/server_test.go](/config/workspace/iop/apps/edge/internal/openai/server_test.go:77), [apps/edge/internal/openai/server_test.go](/config/workspace/iop/apps/edge/internal/openai/server_test.go:4116)). S05 needs selected-provider-specific behavior.

#### 해결 방법

02 plan의 selection-first fake service hook을 사용해 three cases를 추가한다.

Before:

```go
catalog := []config.ModelCatalogEntry{{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}}}
// request always expects tunnel
```

After:

```go
catalog := []config.ModelCatalogEntry{{
    ID: "mixed-model",
    Providers: map[string]string{"prov-vllm": "served-vllm", "prov-ollama": "served-ollama"},
}}
fake.selectProviderPath = normalized // or tunnel
```

Cases:
- mixed group, selected tunnel provider: one tunnel request, no normalized run.
- mixed group, selected Ollama provider: one normalized run, no tunnel request.
- Ollama-only model group: normalized run, no tunnel request.

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

- [ ] `apps/edge/internal/openai/server_test.go`: fake service fixture fields/helpers 추가 또는 02 helper 재사용.
- [ ] `apps/edge/internal/openai/server_test.go`: mixed/tunnel-selected test.
- [ ] `apps/edge/internal/openai/server_test.go`: mixed/Ollama-selected test.
- [ ] `apps/edge/internal/openai/server_test.go`: Ollama-only group normalized test.

#### 테스트 작성

- 작성: `TestChatCompletionsMixedProviderPoolTunnelSelection`
- 작성: `TestChatCompletionsMixedProviderPoolOllamaSelection`
- 작성: `TestChatCompletionsOllamaOnlyProviderPoolUsesNormalizedPath`

#### 중간 검증

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

기대 결과: PASS.

### [MIXED_GROUP-3] Existing Passthrough Fixture Alignment

#### 문제

Provider-pool passthrough, sideband, provider auth, write failure, generation policy tests use a helper catalog with a generic provider id and expect tunnel behavior. After selection-first, those tests should remain valid only when the selected provider is OpenAI-compatible/tunnel-capable.

#### 해결 방법

Existing helpers such as `chatSidebandServer`, `chatProviderAuthServer`, `chatPassthroughServer`, `responsesProviderTunnelServer` should explicitly configure fake selected path/provider as tunnel where needed. Do not weaken byte-identity or cancel assertions; make the provider selection fixture explicit.

Before:

```go
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}}})
```

After:

```go
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "pool-model", Providers: map[string]string{"prov-vllm": "served-model"}}})
fake.selectedProviderPath = providerPathTunnel
```

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

- [ ] `apps/edge/internal/openai/server_test.go`: helper catalog provider ids/types/selection fixture 정리.
- [ ] 필요한 경우 comments에서 “catalog match == tunnel” 표현을 “selected tunnel provider”로 바꾼다.
- [ ] 기존 byte identity/provider auth/cancel tests assertion은 유지한다.

#### 테스트 작성

- 새 behavior test는 MIXED_GROUP-2에서 작성한다.
- 여기서는 기존 tests가 selection fixture를 명확히 하는 refactor 성격이며 별도 test name 추가는 선택 사항이다.

#### 중간 검증

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

기대 결과: PASS.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `apps/edge/internal/service/model_queue_test.go` | MIXED_GROUP-1 |
| `apps/edge/internal/openai/server_test.go` | MIXED_GROUP-2, MIXED_GROUP-3 |

> 참고: `run_dispatch_internal_test.go`, `chat_handler.go`, `server.go`는 변경 필요 없었음.

## 최종 검증

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

$ GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
ok  	iop/apps/edge/internal/openai	11.719s
```

기대 결과: 모든 명령 PASS.

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