<!-- task=02+01_edge_dispatch plan=0 tag=API -->
# Code Review - API

## 리뷰어에게

각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. 이 plan은 `01_pool_schema` PASS 이후 구현되어야 하므로 predecessor completion 근거도 확인합니다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md`
- Task ids:
  - `target-rewrite`: Edge가 선택 provider의 served model을 concrete target으로 rewrite해 Node에 전달
  - `selection-policy`: available provider 중 최저 `in_flight / capacity` load ratio 우선 선택, deterministic tie-break, queue timeout 적용
- Completion mode: check-on-pass

## Spec Targets

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md`
- Acceptance Scenarios:
  - `S03`: Edge-owned target rewrite
  - `S04`: load-ratio provider selection policy
- Evidence Map:
  - `S03`, `S04`는 이 task directory의 `complete.log`에 Roadmap Completion과 Spec Completion 근거가 있어야 한다.

## 구현 체크리스트

- [x] `apps/edge/internal/openai` handler/route resolution에서 canonical client model alias를 유지하고 provider pool 대상인지 service layer에 전달한다.
- [x] `apps/edge/internal/service/run_dispatch.go`의 candidate resolution이 `models[].providers`와 `nodes[].providers[]`를 기준으로 provider 후보를 만들도록 확장한다.
- [x] Queue candidate/model group에 provider id, node id, concrete served target, capacity/in-flight/queued 상태를 보존하고 선택 후 Node 요청은 selected provider의 target으로 rewrite한다.
- [x] `apps/edge/internal/service/model_queue.go`의 available candidate selection을 lowest `in_flight / capacity` 우선, deterministic tie-break 순서로 바꾸고 queue timeout 동작을 유지한다.
- [x] status/provider snapshot에 선택 결과가 반영되도록 in-flight/queued/load-ratio 업데이트 경로를 보강한다.
- [x] OpenAI/service/model_queue/status tests에 provider pool dispatch, target rewrite, load-ratio ranking, deterministic tie-break, timeout 회귀를 추가한다.
- [x] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G07.md`에 붙여 넣는다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 구현 노트

### 변경 파일 목록

| 파일 | 변경 유형 | 주요 내용 |
|------|----------|-----------|
| `apps/edge/internal/service/model_queue.go` | 핵심 수정 | `candidateNode`에 `providerID`, `servedTarget` 추가; `inflightRec`에 `providerID` 추가; `admitResult.node` → `candidate *candidateNode`; `findAvailableNodeLocked` load-ratio 선택 + `candidateLess` tie-break; `trackInflight` providerID 파라미터 추가; `getStatsForProviderLocked` 신규 추가 |
| `apps/edge/internal/service/run_dispatch.go` | 핵심 수정 | `SubmitRunRequest.ProviderPool bool` 추가; `submitRunQueued`에서 `selected *candidateNode`로 처리 + target rewrite; `resolveQueueCandidates`에 provider-pool 분기 추가; `resolveProviderPoolCandidates` 신규 구현 |
| `apps/edge/internal/service/service.go` | 소규모 | `modelCatalog []config.ModelCatalogEntry` 필드 + `SetModelCatalog` 메서드 추가 |
| `apps/edge/internal/openai/server.go` | 소규모 | `modelCatalog` 필드 + `SetModelCatalog` 메서드 추가 (생성자 미변경) |
| `apps/edge/internal/openai/routes.go` | 소규모 | `handleModels`에 catalog 우선 목록 반환 분기 추가 |
| `apps/edge/internal/openai/chat_handler.go` | 중간 | `routeDispatch.ProviderPool` 추가; `findProviderPoolEntry`; `resolveRouteDispatch`에 catalog 최우선 분기; `handleChatCompletions`에 `ProviderPool` 전달 |
| `apps/edge/internal/openai/responses_handler.go` | 소규모 | `SubmitRunRequest`에 `ProviderPool: dispatch.ProviderPool` 추가 |
| `apps/edge/internal/input/manager.go` | 소규모 | `openaiServer.SetModelCatalog(cfg.Models)` 추가 |
| `apps/edge/internal/bootstrap/runtime.go` | 소규모 | `svc.SetModelCatalog(cfg.Models)` 추가 |
| `apps/edge/internal/service/model_queue_test.go` | 테스트 | `admitResult.candidate` 반영; `trackInflight` providerID 추가; provider pool 신규 3개 테스트 |
| `apps/edge/internal/openai/server_test.go` | 테스트 | provider pool catalog 3개 신규 테스트 |
| `apps/edge/internal/service/status_provider_test.go` | 테스트 | 기존 2개 테스트를 `inflightByRun` + `candidateNode.providerID` 기반으로 업데이트 |

### 주요 결정

1. **`NewServer` 생성자 미변경**: 기존 server_test.go가 3-인자 생성자를 광범위하게 사용하므로 `SetModelCatalog` setter 방식으로 연결.
2. **`getStatsForProviderLocked`**: adapter 이름 기반 구 방식(`getStatsForAdapterLocked`) 대신 `inflightByRun.providerID` + queue candidate `providerID` 기반으로 완전 교체. provider-pool 경로 dispatch만 providerID를 설정하므로 레거시 adapter-only 경로는 별도 영향 없음.
3. **status_provider_test.go 2개 테스트**: REVIEW_API-2 마킹된 테스트들이 구 adapter key 기반 주입을 사용했으나, 새 tracking 방식(inflightByRun + candidateNode.providerID)으로 상태 주입 방식 수정.
4. **01_pool_schema predecessor**: task 디렉터리는 없으나 git log에서 `feat: provider catalog device status implementation` 커밋으로 schema가 이미 구현된 것을 확인, 선행 의존성 충족으로 판단하고 진행.

## 계획 대비 변경 사항

- **predecessor complete.log 없음**: `01_pool_schema/complete.log` 대신 git log `feat: provider catalog device status implementation`으로 schema 구현을 확인.
- **status_provider_test.go 수정 추가**: PLAN에는 없었으나, 기존 2개 테스트(`TestListNodeSnapshotsProviderEmptyAdapterFallbackToProviderID`, `TestListNodeSnapshotsProviderIdDiffersFromAdapterKey`)가 새 providerID tracking 방식과 불일치해 업데이트 필요. 이는 구현 변경의 자연스러운 결과.
- **검증 명령 범위 확장**: API-3에 `TestListNodeSnapshots` 패턴 추가 (기존 계획 명령에 누락된 테스트 포함).

## 사용자 리뷰 요청

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

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

## 검증 결과

_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._

### API-1 중간 검증

```bash
go test ./apps/edge/internal/openai -run 'TestOpenAI.*Model|TestOpenAI.*Route|TestOpenAI.*Provider|TestHandleModels|TestChatCompletions' -count=1 -v
```

```text
=== RUN   TestHandleModelsProviderPoolCatalog
--- PASS: TestHandleModelsProviderPoolCatalog (0.00s)
=== RUN   TestChatCompletionsProviderPoolDispatch
--- PASS: TestChatCompletionsProviderPoolDispatch (0.00s)
=== RUN   TestChatCompletionsProviderPoolFallsBackToLegacyRoute
--- PASS: TestChatCompletionsProviderPoolFallsBackToLegacyRoute (0.00s)
PASS
ok  	iop/apps/edge/internal/openai	0.007s
```

### API-2 중간 검증

```bash
go test ./apps/edge/internal/service -run 'TestModelQueue.*Provider|TestSubmitRun.*Provider|TestService.*Provider|TestBuildRunRequest|TestProviderStatus' -count=1 -v
```

```text
=== RUN   TestModelQueueProviderLoadRatioSelection
--- PASS: TestModelQueueProviderLoadRatioSelection (0.00s)
=== RUN   TestModelQueueProviderServedTargetRewrite
--- PASS: TestModelQueueProviderServedTargetRewrite (0.00s)
=== RUN   TestProviderStatusInflightTracking
--- PASS: TestProviderStatusInflightTracking (0.00s)
=== RUN   TestSubmitRunModelQueueUsesRoutePolicyBeforeProviderInstancePolicy
--- PASS: TestSubmitRunModelQueueUsesRoutePolicyBeforeProviderInstancePolicy (0.00s)
=== RUN   TestBuildRunRequestNormalizesSessionAndTimeout
--- PASS: TestBuildRunRequestNormalizesSessionAndTimeout (0.00s)
--- (중략: 모든 BuldRunRequest/SubmitRun 테스트 PASS)
=== RUN   TestServiceCapabilitiesPreservesProviderSnapshots
--- PASS: TestServiceCapabilitiesPreservesProviderSnapshots (0.00s)
PASS
ok  	iop/apps/edge/internal/service	0.133s
```

### API-3 중간 검증

```bash
go test ./apps/edge/internal/service -run 'TestModelQueue.*Timeout|TestProviderStatus|TestStatusProvider|TestListNodeSnapshots' -count=1 -v
```

```text
=== RUN   TestModelQueueTimeout
--- PASS: TestModelQueueTimeout (0.02s)
=== RUN   TestProviderStatusInflightTracking
--- PASS: TestProviderStatusInflightTracking (0.00s)
=== RUN   TestListNodeSnapshotsUsesEdgeQueueState
--- PASS: TestListNodeSnapshotsUsesEdgeQueueState (0.00s)
=== RUN   TestListNodeSnapshotsTypeRouteSingleNamedInstance
--- PASS: TestListNodeSnapshotsTypeRouteSingleNamedInstance (0.00s)
=== RUN   TestListNodeSnapshotsProviderEmptyAdapterFallbackToProviderID
    status_provider_test.go:217: provider snapshot[0]: id=standalone-provider, adapter=, InFlight=1, Queued=1, Capacity=2, LoadRatio=0.50
--- PASS: TestListNodeSnapshotsProviderEmptyAdapterFallbackToProviderID (0.00s)
=== RUN   TestListNodeSnapshotsProviderIdDiffersFromAdapterKey
--- PASS: TestListNodeSnapshotsProviderIdDiffersFromAdapterKey (0.00s)
=== RUN   TestListNodeSnapshotsWithConfig
--- PASS: TestListNodeSnapshotsWithConfig (0.00s)
=== RUN   TestListNodeSnapshotsIncludesAgentKind
--- PASS: TestListNodeSnapshotsIncludesAgentKind (0.00s)
PASS
ok  	iop/apps/edge/internal/service	0.028s
```

### 최종 검증

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

```text
ok  	iop/apps/edge/internal/openai	1.509s
ok  	iop/apps/edge/internal/service	0.335s
(git diff --check: 출력 없음 — whitespace 이슈 없음)
```

## 리뷰 결과

_code-review 에이전트 전용._

- 판정: 미실행
- 필수 수정: 없음
- 제안: 없음
- Nit: 없음

## 코드리뷰 결과

- 종합 판정: FAIL
- 차원별 평가:
  - correctness: Fail
  - completeness: Fail
  - test coverage: Fail
  - API contract: Fail
  - code quality: Pass
  - plan deviation: Fail
  - verification trust: Warn
  - spec conformance: Fail
- 발견된 문제:
  - Required: `apps/edge/internal/service/run_dispatch.go:500`에서 provider-pool candidate가 `prov.Adapter`를 보존하지 않고, `submitRunQueued`는 `apps/edge/internal/service/run_dispatch.go:132`에서 target만 rewrite합니다. OpenAI provider-pool 요청은 `Adapter`가 비어 들어오며, Node 라우터는 `apps/node/internal/router/router.go:25`에서 빈 adapter를 즉시 거부합니다. SDD는 `resolution.adapter`를 Node에 전달해야 한다고 명시합니다(`agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md:84`). Fix: `candidateNode`에 dispatch adapter/instance key를 보존하고 선택 후 `req.Adapter`와 `req.Target`을 함께 rewrite하며, service-level provider-pool `SubmitRun` 테스트에서 실제 `iop.RunRequest.Adapter`/`Target`을 검증하세요.
  - Required: `apps/edge/internal/service/model_queue.go:63`, `apps/edge/internal/service/model_queue.go:157`, `apps/edge/internal/service/model_queue.go:196`, `apps/edge/internal/service/model_queue.go:346`이 in-flight/capacity를 nodeID 단위로 계산합니다. 같은 Node 안에 여러 provider가 같은 model alias를 제공하면 첫 provider 실행이 같은 Node의 다른 provider capacity까지 잠가 버리고, provider별 `in_flight / capacity` load ratio가 아니라 node-level ratio로 선택됩니다. SDD는 Node 아래 여러 provider와 provider별 in-flight/load-ratio 선택을 요구합니다(`SDD.md:70`, `SDD.md:76`, `SDD.md:112`). Fix: legacy 경로는 기존 node key를 유지하되 provider-pool candidate는 `(nodeID, providerID)` slot key로 admission/release/queue dispatch를 계산하고, 같은 Node의 두 provider가 독립 capacity로 dispatch되는 회귀 테스트를 추가하세요.
  - Required: `apps/edge/internal/service/run_dispatch.go:491`의 provider 후보 생성은 `catalogEntry.Providers[prov.ID]`만 확인하고 `prov.Health`, `prov.Models` membership, capacity availability 조건을 후보 필터에 반영하지 않습니다. SDD는 served model이 provider `models[]` 안에 없으면 route가 유효하지 않고, unavailable 후보를 제외하며, capacity가 0/unknown이면 제외 또는 unavailable로 보아야 한다고 정합니다(`SDD.md:68`, `SDD.md:78`, `SDD.md:94`, `SDD.md:112`). Fix: 런타임 후보 생성도 config validation과 같은 membership/availability 불변식을 방어적으로 확인하고, unavailable/invalid provider가 선택되지 않는 tests를 추가하세요.
- 다음 단계: FAIL이므로 user-review gate 없이 후속 `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성한다.

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

- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_0.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_0.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-provider-catalog-device-status/02+01_edge_dispatch/`를 `agent-task/archive/YYYY/MM/m-provider-catalog-device-status/02+01_edge_dispatch/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-provider-catalog-device-status`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-provider-catalog-device-status/`를 제거하거나, 남은 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가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
