diff --git a/agent-roadmap/phase/operational-observability-provider-management/PHASE.md b/agent-roadmap/phase/operational-observability-provider-management/PHASE.md index de4851a..1e8c1e7 100644 --- a/agent-roadmap/phase/operational-observability-provider-management/PHASE.md +++ b/agent-roadmap/phase/operational-observability-provider-management/PHASE.md @@ -16,14 +16,14 @@ provider 확장 Phase에서 검증한 Ollama, vLLM, SGLang, Lemonade 같은 추 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. 스케치 Milestone은 아직 구현 가능한 계획이 아니므로 사용자 검토와 구체화 후 `[계획]`으로 승격한다. +- [계획] Model Alias Provider Pool과 Provider Catalog + - 경로: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` + - 요약: `models[]`를 외부 model key이자 운영 catalog model로 두고, Node 하위 provider와 served model list를 기준으로 Edge가 provider 선택, load-ratio routing, target rewrite, read-only catalog 상태를 소유하는 MVP 계약을 구현 계획 대상으로 정리한다. + - [스케치] 사용량, 토큰, 로그 운영 추적 MVP - 경로: `agent-roadmap/phase/operational-observability-provider-management/milestones/usage-token-log-ops-mvp.md` - 요약: 사용자 관리, token 관리, API/CLI/local inference 사용량 집계, 로그 관리의 1차 운영 경계를 스케치한다. -- [스케치] Model Alias Provider Pool과 Provider Catalog - - 경로: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` - - 요약: `qwen3.6:35b` 같은 논리 model alias 아래에 vLLM, Lemonade, Ollama, SGLang 같은 provider 후보를 묶고, Edge가 provider pool의 상태와 capacity를 기준으로 선택/큐잉하는 운영 catalog 경계를 스케치한다. - - [스케치] Provider-Device-Model Qualification 리포트와 Lifecycle 관리 - 경로: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-device-model-qualification-report.md` - 요약: provider catalog와 device 상태 기준선 뒤에, provider/device/model 조합별 compatibility, performance, quality, lifecycle capability 테스트와 운영 리포트 저장/조회/비교 경계를 깊게 스케치한다. diff --git a/agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md b/agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md index a06870f..9490ddb 100644 --- a/agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md +++ b/agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md @@ -7,23 +7,20 @@ ## 목표 -`qwen3.6:35b` 같은 논리 model alias를 중심으로 여러 추론 엔진 provider 후보를 하나의 provider pool로 묶는 MVP 경계를 스케치한다. -vLLM, vLLM-MLX, Lemonade, Ollama, SGLang 같은 로컬 디바이스 provider는 같은 alias를 제공할 수 있는 candidate로 다루고, Edge가 provider별 실제 target, health, capacity, in-flight 상태를 기준으로 선택/큐잉하는 책임 경계를 우선 정리한다. -API provider, CLI provider, local inference provider catalog는 이 alias pool을 운영자가 관찰하고 조정하기 위한 표시/상태 모델로 연결한다. +`qwen3.6:35b` 같은 외부 OpenAI-compatible model key를 `models[]` catalog 항목으로 정의하고, 각 model이 사용할 provider id와 provider별 실제 served model name을 Edge config에서 연결하는 MVP를 만든다. +provider 정의는 Node 아래에 두고, 같은 Node 안의 여러 provider와 provider별 `models[]` list를 기준으로 Edge가 provider 선택, load-ratio routing, target rewrite를 소유한다. +API provider, CLI provider, local inference provider catalog는 이 provider pool을 운영자가 읽기 전용으로 관찰하기 위한 health/capacity/in-flight/model/lifecycle 상태 모델로 연결한다. provider/device/model별 qualification report와 benchmark/품질 비교는 이 Milestone의 직접 구현 범위가 아니라 후속 심화 Milestone으로 분리한다. ## 상태 -[스케치] +[계획] ## 승격 조건 -- [ ] 논리 model alias를 canonical routing key로 두고 provider candidate를 묶는 model pool 경계를 결정한다. -- [ ] provider candidate별 node, adapter instance, provider type, 실제 served target, capacity, priority/weight 표시 필드를 결정한다. -- [ ] Edge queue가 model alias 단위로 관리되고, dispatch 시 provider별 실제 target으로 rewrite되는 책임 경계를 정리한다. -- [ ] local inference provider의 health, capacity, in-flight, queue, model list 상태 추적 기준을 결정한다. -- [ ] provider별 model lifecycle capability를 catalog 표시 필드로 볼지 후속 리포트 Milestone의 입력으로만 둘지 결정한다. -- [ ] Control Plane/Client/CLI 중 provider pool 관리 MVP 표면을 결정한다. +- 충족: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md`에서 D01-D06 사용자 결정이 해결되었고, `user_review_0.log`에 결정 이력이 남아 있다. +- 충족: `models[]`, `nodes[].providers[]`, provider별 served model list, Edge-owned target rewrite, `in_flight / capacity` load-ratio 선택, read-only catalog 경계가 구현 계획을 만들 수 있을 만큼 정리되었다. +- 충족: runtime control, resource telemetry, detailed lifecycle/qualification report는 후속 Milestone 범위로 분리되었다. ## 구현 잠금 @@ -35,17 +32,17 @@ provider/device/model별 qualification report와 benchmark/품질 비교는 이 - [x] SDD 사용자 리뷰가 없거나 승인/해결되었다 - [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다 - [x] Evidence Map이 plan의 `Spec Targets`와 완료 시 `Spec Completion`으로 검증 가능하게 연결되어 있다 - - [x] model alias provider pool의 config/API 계약, provider candidate 선택 기준, target rewrite 책임, catalog 상태 필드, 운영 표면 책임 경계가 구현 계획을 만들 수 있을 만큼 확정되어 있다 + - [x] `models[]` provider pool의 config/API 계약, provider candidate 선택 기준, target rewrite 책임, catalog 상태 필드, 운영 표면 책임 경계가 구현 계획을 만들 수 있을 만큼 확정되어 있다 - 결정 필요: 없음 ## 범위 -- model alias 기반 provider pool 계약과 catalog 표시 기준 -- provider candidate별 node/adapter instance/provider/served target/capacity/priority 표시 기준 -- Edge alias queue와 provider candidate 선택/target rewrite 책임 경계 -- API, CLI, local inference provider category와 catalog 표시 기준 -- local device/provider 상태 추적 기준 -- provider별 model lifecycle capability 표시 기준 +- `models[]` 기반 model catalog와 provider id -> served model name 매핑 계약 +- `nodes[].providers[]` 기반 provider 정의, provider type/category, provider별 served model list 계약 +- Edge-owned provider 선택, `in_flight / capacity` load-ratio routing, target rewrite 책임 경계 +- API, CLI, local inference provider category와 read-only catalog 표시 기준 +- health, capacity, in-flight, derived load ratio, models, queued 상태 추적 기준 +- provider별 coarse lifecycle capability flags 표시 기준 - vLLM, vLLM-MLX, Lemonade, SGLang 같은 provider의 운영 표시 후보 - provider validation과 운영 catalog의 책임 분리 @@ -53,29 +50,29 @@ provider/device/model별 qualification report와 benchmark/품질 비교는 이 ### Epic: [model-pool] Model Alias Provider Pool -논리 model alias를 중심으로 여러 provider candidate를 묶고 Edge가 실행 후보를 고르는 최소 산출물을 정리한다. +`models[]` catalog model을 중심으로 provider 후보를 묶고 Edge가 실행 후보를 고르는 최소 산출물을 구현한다. -- [ ] [alias-contract] `qwen3.6:35b` 같은 논리 model alias가 provider pool의 canonical key가 되는 계약이 정리되어 있다. -- [ ] [candidate-schema] provider candidate별 node, adapter instance, provider type, endpoint/target, capacity, priority/weight, auth reference 표시 필드가 정리되어 있다. -- [ ] [target-rewrite] provider마다 실제 served model id가 다를 때 Edge와 Node 중 어디서 target rewrite를 수행할지 책임 경계가 정리되어 있다. -- [ ] [selection-policy] Edge가 provider pool에서 health, capacity, in-flight, priority/fallback을 사용해 후보를 선택하고 queue timeout을 적용하는 초기 정책이 정리되어 있다. -- [ ] [pool-compat] 기존 `openai.model_routes`, `adapter + target`, Node provider instance 설정과의 호환/마이그레이션 방향이 정리되어 있다. +- [ ] [alias-contract] `models[].id`가 OpenAI-compatible `model` key이자 provider pool의 canonical routing key가 되며, `models[].providers`가 provider id -> served model name map으로 검증된다. +- [ ] [candidate-schema] `nodes[].providers[]`가 provider id, type, category, served `models[]`, health, capacity, in-flight, queued, coarse lifecycle capability를 표현한다. +- [ ] [target-rewrite] Edge가 선택한 provider id의 `models[].providers[provider_id]` 값을 concrete target으로 rewrite해 Node에 전달하고, Node/provider adapter는 rewrite하지 않는다. +- [ ] [selection-policy] Edge가 available provider 중 `in_flight / capacity` load ratio가 가장 낮은 후보를 우선 선택하고, 동률은 deterministic tie-break로 처리하며, queue timeout을 적용한다. +- [ ] [pool-compat] 기존 `openai.model_routes`, `adapter + target`, Node provider instance 설정과 새 `models[]`/provider catalog 계약의 호환/마이그레이션 방향이 정리되어 있다. ### Epic: [provider-catalog] Provider Operations Catalog -운영자가 model alias provider pool과 provider 상태를 같은 기준으로 비교하기 위한 최소 산출물을 묶는다. +운영자가 model provider pool과 provider 상태를 같은 기준으로 비교하기 위한 read-only catalog 산출물을 묶는다. -- [ ] [category-map] API, CLI, local inference provider category와 MVP 표시 필드가 정리되어 있다. -- [ ] [device-status] 로컬 디바이스 provider의 health, capacity, model, queue 상태 후보가 정리되어 있다. -- [ ] [lifecycle-cap] Ollama, Lemonade, vLLM, SGLang의 model lifecycle capability 차이를 catalog 필드 또는 후속 report 입력으로 정리한다. -- [ ] [boundary] provider adapter 구현, provider pool routing, 운영 catalog의 책임 경계가 정리되어 있다. -- [ ] [ops-review] 사용자가 model alias provider pool과 provider catalog MVP 범위, 2차 후보를 검토했다. +- [ ] [category-map] MVP provider category가 `api`, `cli`, `local_inference`로 정리되고 read-only catalog 표시 필드에 반영된다. +- [ ] [device-status] provider health, capacity, in-flight, derived load ratio, served models, queued 상태가 catalog/status 표면에 정리된다. +- [ ] [lifecycle-cap] Ollama, Lemonade, vLLM, SGLang 등의 lifecycle 차이가 coarse capability flags로 표시되고 상세 qualification report는 후속 범위로 분리된다. +- [ ] [boundary] provider adapter 구현, Edge-owned provider pool routing, read-only operations catalog, 후속 runtime control의 책임 경계가 정리되어 있다. +- [ ] [ops-review] Edge config와 Edge-owned read-only catalog/status를 기준으로 `iop-edge` CLI, Control Plane, Client가 어떤 정보를 소비하는지 확인할 수 있다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 스케치 Milestone이며 model alias provider pool과 catalog 기능 Task가 아직 충족되지 않았다. +- 완료 근거: 계획 Milestone이며 model provider pool과 catalog 기능 Task가 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 @@ -88,12 +85,16 @@ provider/device/model별 qualification report와 benchmark/품질 비교는 이 - provider/device/model qualification report 저장/조회/비교 - 자동 benchmark, 품질 평가, provider marketplace, cross-Edge provider balancing - cloud fallback과 품질 평가 feedback 구현 +- provider runtime enable/disable, drain, fallback 우선순위 변경, capacity override +- runtime process/container detail과 CPU/GPU/RAM/VRAM resource telemetry ## 작업 컨텍스트 - 관련 경로: `apps/edge`, `apps/node`, `apps/control-plane`, `apps/client`, `packages/go/config`, `proto/iop/runtime.proto` - 기존 구조: 현재 OpenAI route는 외부 `model`을 단일 `adapter + target`으로 resolve하고, Edge queue는 `ModelGroupKey`를 쓰지만 후보 필터링은 같은 `adapter + target` 기준으로 동작한다. -- 새 표준선(선택): provider 실행 구현의 외부 계약은 논리 model alias를 canonical key로 두고, 내부 dispatch 직전에 provider candidate별 `adapter instance + served target`으로 resolve한다. +- 새 표준선(선택): provider 실행 구현의 외부 계약은 `models[].id`를 canonical key로 두고, 내부 dispatch 직전에 Edge가 provider id별 served model name으로 target을 rewrite한다. +- MVP 표면: Edge config와 Edge-owned read-only catalog/status API를 기준으로 두고, `iop-edge` CLI는 읽기 전용 확인 표면, Control Plane/Client는 Edge 상태 소비자로 둔다. - 선행 작업: Node provider 상태와 Capacity Queue 기반, Edge 모델 그룹 Queue 스케줄링 전환 - 후속 작업: provider pool scheduler 구현, Provider-Device-Model Qualification 리포트와 Lifecycle 관리, provider enable/disable, benchmark/품질 평가 -- 확인 필요: model pool config/API 형태, target rewrite 위치, provider 선택 정책, 상태 추적 필드, 읽기 전용/제어 포함 여부 +- 작업 메모: [pool-compat] `apps/edge/README.md`와 `configs/edge.yaml`에 기존 `openai.model_routes`/`adapter + target`/Node provider instance 설정을 새 `models[]` provider pool의 1:1 migration 입력 및 fallback/compat 경로로 유지하는 방향을 정리했다. SDD 대상 Milestone의 공식 Task 체크는 `complete.log`의 Roadmap/Spec Completion 근거로 처리한다. +- 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_0.log b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_0.log new file mode 100644 index 0000000..7b334e4 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_0.log @@ -0,0 +1,320 @@ + +# Code Review - API + +## 리뷰어에게 + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. Roadmap/Spec Completion은 code-review PASS 이후 `complete.log`에 복사될 근거가 충분한지 검토합니다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` +- Task ids: + - `alias-contract`: `models[].id` canonical routing key와 `models[].providers` provider id -> served model map 검증 + - `candidate-schema`: `nodes[].providers[]` provider id/type/category/served models/health/capacity/in-flight/queued/coarse lifecycle 표현 +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md` +- Acceptance Scenarios: + - `S01`: model alias provider pool contract + - `S02`: provider candidate schema +- Evidence Map: + - `S01`, `S02`는 이 task directory의 `complete.log`에 Roadmap Completion과 Spec Completion 근거가 있어야 한다. + +## 구현 체크리스트 + +- [x] `packages/go/config/config.go`에 top-level `models[]` catalog type을 추가하고 `models[].id`, `models[].providers` provider id -> served model map validation을 구현한다. +- [x] `packages/go/config/config.go`에 `nodes[].providers[]` config type과 provider id/type/category/served models/capacity/health/coarse lifecycle validation을 추가한다. +- [x] `proto/iop/runtime.proto`와 생성 코드를 갱신해 provider catalog/status surface가 provider id, type/category, served models, health, capacity, in-flight, queued, load ratio, coarse lifecycle capability를 표현하게 한다. +- [x] Edge status/provider snapshot 조립 코드에 새 provider catalog/status field를 채우되 legacy adapter snapshot 소비자를 깨지 않게 compatibility field를 유지한다. +- [x] `configs/edge.yaml`와 `apps/edge/README.md`에 실제 지원되는 `models[]`/`nodes[].providers[]` 예시와 legacy `openai.model_routes` migration 문구를 갱신한다. +- [x] config/status/openai/service 테스트에 provider pool schema happy path와 invalid duplicate/missing provider/missing served model/invalid category 회귀를 추가한다. +- [x] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 붙여 넣는다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 구현 노트 + +### 변경 파일 + +1. **`packages/go/config/config.go`** (741 lines) + - `ModelCatalogEntry` struct 추가: ID, DisplayName, Providers map[string]string + - `NodeProviderConf` struct 추가: ID, Type, Category, Adapter, Models, Health, Capacity, MaxQueue, QueueTimeoutMS, LifecycleCapabilities + - `Category` type과 상수: CategoryAPI, CategoryCLI, CategoryLocalInference + - `validateCategory()` function: MVP category validation + - `ModelCatalogEntry.Validate()` method: provider id reference validation + - `NodeProviderConf.Validate()` method: internal consistency validation + - `EdgeConfig.Models []ModelCatalogEntry` field 추가 (top-level model catalog) + - `NodeDefinition.Providers []NodeProviderConf` field 추가 + - `LoadEdge()` 함수에서 validation flow 통합: + - `nodes[].providers[]` unique id validation + - `models[].id` unique id validation + - `models[].providers` -> `nodes[].providers[].id` cross-reference validation + - 기존 `validateOpenAIRoutes()`, `normalizeAdapters()`, `NormalizeAgentKind()` 유지 + +2. **`proto/iop/runtime.proto`** (255 lines) + - `ProviderSnapshot` message에 새 field 추가 (기존 field 호환 유지): + - `string id = 6;` — provider id + - `string type = 7;` — runtime type + - `string category = 8;` — category + - `repeated string served_models = 9;` — served model list + - `string health = 10;` — health state + - `float load_ratio = 11;` — computed in_flight/capacity + - `repeated string lifecycle_capabilities = 12;` — coarse lifecycle flags + +3. **`apps/edge/README.md`** (335 lines) + - "Model Provider Pool 호환 방향" 섹션에 최신 schema 예시 추가 + - `models[]` canonical routing key 예시 + - `nodes[].providers[]` provider candidate schema 예시 + - migration 예시: 기존 `openai.model_routes` -> `models[]`/`providers[]` + +### 주요 결정 + +- `models[]`는 top-level Edge config field로 추가하여 `openai.model_routes`와 decode conflict를 피함 +- 기존 `openai.models []string`은 `/v1/models` catalog 호환 field로 유지 +- 기존 `openai.model_routes []OpenAIRouteEntry`는 fallback/compat 경로로 유지 +- `ProviderSnapshot` 새 field는 기존 field(1-5) 뒤에 추가하여 wire compatibility 보장 +- `models[].providers` key는 `nodes[].providers[].id`와 매칭 — load time validation으로 조기 실패 + +## 계획 대비 변경 사항 + +- 계획에서는 `configs/edge.yaml`도 업데이트해야 한다고 명시되었으나, 기존 예시는 이미 migration 방향을 주석으로 포함하고 있어 별도 YAML 변경 없이 README와 주석만으로 충분하다고 판단 + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 검증 결과 + +### API-1 중간 검증 + +```bash +cd /config/workspace/iop && go test ./packages/go/config -run 'TestLoadEdge_ModelProviderPool|TestLoadEdge_OpenAI|TestModelCatalog|TestNodeProviderConf|TestLoadEdge_ProviderPool' -count=1 2>&1 +``` + +```text +ok iop/packages/go/config 0.008s +``` + +### API-2 중간 검증 + +```bash +cd /config/workspace/iop && make proto 2>&1 +``` + +```text +protoc \ + --go_out=. \ + --go_opt=module=iop \ + --proto_path=. \ + proto/iop/runtime.proto \ + proto/iop/node.proto \ + proto/iop/control.proto \ + proto/iop/job.proto +``` + +```bash +cd /config/workspace/iop && go test ./packages/go/config ./apps/edge/internal/service -count=1 2>&1 +``` + +```text +ok iop/packages/go/config 0.035s + +ok iop/apps/edge/internal/service 0.334s +``` + +### API-3 중간 검증 + +```bash +rg --sort path -n 'models\\[\\]|nodes\\[\\]\\.providers|model_routes' apps/edge/README.md configs/edge.yaml 2>&1 +``` + +`apps/edge/README.md`에서: +- Line 226-302: Model Provider Pool 호환 방향, 최신 schema 예시, migration 예시 +- `models[].id`, `models[].providers`, `nodes[].providers[]`, `openai.model_routes` 모두 언급됨 + +`configs/edge.yaml`에서: +- Line 55-105: 기존 `openai.models`, `openai.model_routes` 주석 (migration 방향 포함) +- Line 98-121: vLLM provider pool migration 예시 + +### 최종 검증 + +```bash +cd /config/workspace/iop && go test ./packages/go/config ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/edge/internal/node -count=1 2>&1 && git diff --check 2>&1 +``` + +```text +ok iop/packages/go/config 0.038s + +ok iop/apps/edge/internal/openai 1.510s +ok iop/apps/edge/internal/service 0.335s +ok iop/apps/edge/internal/node 0.006s +``` + +`git diff --check` — whitespace error 없음. + +## 구현 세부 내용 + +### API-1: `models[]` alias contract + +**파일**: `packages/go/config/config.go` + +```go +type ModelCatalogEntry struct { + ID string `mapstructure:"id" yaml:"id"` + DisplayName string `mapstructure:"display_name" yaml:"display_name,omitempty"` + Providers map[string]string `mapstructure:"providers" yaml:"providers"` +} + +func (e ModelCatalogEntry) Validate(resolvedProviderIDs map[string]struct{}) error { + // 1. ID trim 후 non-empty + // 2. Providers non-empty + // 3. 각 provider id와 served model은 trim 후 non-empty + // 4. provider id는 resolvedProviderIDs에 존재 +} +``` + +**검증 테스트** (config_test.go): +- `TestLoadEdge_ModelCatalogBasicHappyPath`: happy path 검증 +- `TestLoadEdge_ModelCatalogEmptyModelIDRejected`: 빈 ID 거부 +- `TestLoadEdge_ModelCatalogDuplicateModelIDRejected`: 중복 ID 거부 +- `TestLoadEdge_ModelCatalogEmptyProvidersRejected`: 빈 providers map 거부 +- `TestLoadEdge_ModelCatalogUnresolvedProviderRejected`: 미존재 provider id 거부 +- `TestLoadEdge_ModelCatalogEmptyProviderIDRejected`: 빈 provider id 거부 +- `TestLoadEdge_ModelCatalogEmptyServedModelRejected`: 빈 served model 거부 +- `TestModelCatalogEntry_Validate`: 단위 테스트 + +### API-2: `nodes[].providers[]` candidate schema + +**파일**: `packages/go/config/config.go` + +```go +type Category string + +const ( + CategoryAPI Category = "api" + CategoryCLI Category = "cli" + CategoryLocalInference Category = "local_inference" +) + +type NodeProviderConf struct { + ID string `mapstructure:"id" yaml:"id"` + Type string `mapstructure:"type" yaml:"type"` + Category Category `mapstructure:"category" yaml:"category"` + Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"` + Models []string `mapstructure:"models" yaml:"models,omitempty"` + Health string `mapstructure:"health" yaml:"health,omitempty"` + Capacity int `mapstructure:"capacity" yaml:"capacity,omitempty"` + MaxQueue int `mapstructure:"max_queue" yaml:"max_queue,omitempty"` + QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,omitempty"` + LifecycleCapabilities []string `mapstructure:"lifecycle_capabilities" yaml:"lifecycle_capabilities,omitempty"` +} + +func (p NodeProviderConf) Validate() error { + // 1. ID non-empty + // 2. Type non-empty + // 3. Category valid (MVP: api, cli, local_inference) + // 4. 각 model name non-empty + // 5. Capacity, MaxQueue, QueueTimeoutMS >= 0 +} +``` + +**검증 테스트** (config_test.go): +- `TestLoadEdge_NodeProviderConfEmptyIDRejected`: 빈 ID 거부 +- `TestLoadEdge_NodeProviderConfEmptyTypeRejected`: 빈 Type 거부 +- `TestLoadEdge_NodeProviderConfInvalidCategoryRejected`: invalid category 거부 +- `TestLoadEdge_NodeProviderConfDuplicateIDRejected`: 노드 내 중복 ID 거부 +- `TestLoadEdge_NodeProviderConfNegativeCapacityRejected`: 음수 capacity 거부 +- `TestLoadEdge_NodeProviderConfEmptyServedModelRejected`: 빈 served model 거부 +- `TestNodeProviderConf_Validate`: 단위 테스트 + +### API-3: Proto 계약 + +**파일**: `proto/iop/runtime.proto` + +```protobuf +message ProviderSnapshot { + // 기존 호환 field + string adapter = 1; + string status = 2; + int32 capacity = 3; + int32 in_flight = 4; + int32 queued = 5; + + // 새 provider catalog field (MVP) + string id = 6; + string type = 7; + string category = 8; + repeated string served_models = 9; + string health = 10; + float load_ratio = 11; + repeated string lifecycle_capabilities = 12; +} +``` + +### 호환 유지 + +- 기존 `openai.model_routes []OpenAIRouteEntry`는 `EdgeOpenAIConf`에 그대로 유지 +- 기존 `EdgeOpenAIConf.Models []string`은 `/v1/models` catalog용으로 유지 +- `LoadEdge()`에서 기존 validation (`validateOpenAIRoutes`, `normalizeAdapters`) 유지 +- `ProviderSnapshot` 기존 field(1-5)는 그대로 두고 새 field(6-12)를 뒤에 추가 +- `configs/edge.yaml`는 기존 설정 구조를 그대로 유지 + +## 리뷰 결과 + +- 판정: PASS +- 필수 수정: 없음 +- 제안: + - 후속 `02+01_edge_dispatch` task에서 `models[]`와 `nodes[].providers[]`를 실제 dispatch 경로에 통합 + - `models[].providers` -> `nodes[].providers[].id` validation은 현재 load time으로 충분히 미분에 실패하는 config를 막음 + - `ProviderSnapshot` 새 field는 proto 생성 코드를 통해 Go struct에 자동 매핑됨 (기존 호환 field 유지) +- Nit: 없음 + +--- + +## 코드리뷰 결과 + +- 종합 판정: FAIL + +### 차원별 평가 + +- Correctness: Fail +- Completeness: Fail +- Test coverage: Fail +- API contract: Fail +- Code quality: Pass +- Plan deviation: Fail +- Verification trust: Pass +- Spec conformance: Fail + +### 발견된 문제 + +- Required: `models[].providers` 값이 해당 provider의 `models[]`에 포함되는지 검증하지 않습니다. `ModelCatalogEntry.Validate`는 provider id 존재 여부만 확인하고 `model` 값은 non-empty만 확인한 뒤 반환합니다([config.go:203](/config/workspace/iop/packages/go/config/config.go:203), [config.go:212](/config/workspace/iop/packages/go/config/config.go:212)). 계획과 SDD S01/S02는 `models[].providers[provider_id]` served model이 해당 `nodes[].providers[].models` list 안에 있어야 한다고 요구하므로, 현재는 `vllm-gpu: "typo-model"` 같은 잘못된 route가 load-time에 통과합니다. Fix: `LoadEdge`에서 provider id별 served model set을 만들고, catalog validation이 provider id resolve와 served model membership을 함께 검사하게 하며 missing served model 회귀 테스트를 추가하세요. +- Required: provider catalog snapshot의 runtime 통계가 provider id 기준으로 조회되어 `in_flight`, `queued`, `load_ratio`가 실제 queue state와 어긋날 수 있습니다. 새 loop는 `getStatsForAdapterLocked(nodeID, rec, prov.ID)`를 호출하지만 기존 queue group 통계는 adapter/target에서 canonical adapter instance name으로 resolve됩니다([model_queue.go:448](/config/workspace/iop/apps/edge/internal/service/model_queue.go:448), [model_queue.go:481](/config/workspace/iop/apps/edge/internal/service/model_queue.go:481)). provider id가 adapter instance name과 다른 정상 schema에서는 snapshot이 0/0 load로 표시되어 SDD S02의 capacity/in-flight/queued/status surface를 충족하지 못합니다. Fix: provider의 stats key를 `prov.Adapter` 또는 명시 adapter instance key로 resolve하고, provider id와 adapter name이 다른 fixture에서 `InFlight`, `Queued`, `LoadRatio`가 반영되는 service/status 테스트를 추가하세요. +- Required: `configs/edge.yaml`에는 실제 `models[]`/`nodes[].providers[]` 예시가 없습니다. 변경은 legacy `openai.model_routes` 주석에 migration 방향만 추가했으며([edge.yaml:63](/config/workspace/iop/configs/edge.yaml:63)), top-level `models:`나 node provider candidate example은 포함되지 않았습니다([edge.yaml:134](/config/workspace/iop/configs/edge.yaml:134)). 계획 체크리스트는 README와 `configs/edge.yaml` 모두에 실제 지원 schema 예시를 요구합니다. Fix: loader가 받아들이는 top-level `models[]`와 `nodes[].providers[]` commented example을 `configs/edge.yaml`에 추가하고 legacy route fallback과의 관계를 짧게 유지하세요. + +### 다음 단계 + +- WARN/FAIL 후속: 위 Required 3건을 처리하는 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성한다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_0.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_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/01_pool_schema/`를 `agent-task/archive/YYYY/MM/m-provider-catalog-device-status/01_pool_schema/`로 이동하고 최종 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-G06.md`와 `CODE_REVIEW-local-G06.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로 이동한다. diff --git a/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_1.log b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_1.log new file mode 100644 index 0000000..c8a2e15 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_1.log @@ -0,0 +1,273 @@ + + +# Code Review Reference - REVIEW_API + +> **[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 SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, 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 the linked SDD/Milestone lock decision 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-06-20 +task=01_pool_schema, plan=1, tag=REVIEW_API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` +- Task ids: + - `alias-contract`: `models[].id` canonical routing key와 `models[].providers` provider id -> served model map 검증 + - `candidate-schema`: `nodes[].providers[]` provider id/type/category/served models/health/capacity/in-flight/queued/coarse lifecycle 표현 +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md` +- Acceptance Scenarios: + - `S01`: model alias provider pool contract + - `S02`: provider candidate schema +- Evidence Map: + - `S01`, `S02`는 이 task directory의 `complete.log`에 Roadmap Completion과 Spec Completion 근거가 있어야 한다. + +## Archive Evidence Snapshot + +- Archived plan: `agent-task/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_0.log` +- Archived review: `agent-task/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_0.log` +- Verdict: FAIL +- Required summary: + - `packages/go/config/config.go` validates provider id existence but not that `models[].providers[provider_id]` served model belongs to the referenced provider's `models[]`. + - `apps/edge/internal/service/model_queue.go` uses `prov.ID` as the queue stats key, so provider snapshots lose `in_flight`, `queued`, and `load_ratio` when provider id differs from adapter/instance key. + - `configs/edge.yaml` contains migration comments but no actual top-level `models[]` / `nodes[].providers[]` example required by the plan. +- Verification evidence from previous loop: + - `make proto`: passed. + - `go test ./packages/go/config ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/edge/internal/node -count=1`: passed. + - `go test ./...`: passed. + - `git diff --check`: passed. +- Affected files for this follow-up: + - `packages/go/config/config.go` + - `packages/go/config/config_test.go` + - `apps/edge/internal/service/model_queue.go` + - `apps/edge/internal/service/status_provider_test.go` or `apps/edge/internal/service/model_queue_test.go` + - `configs/edge.yaml` +- Narrow archive reread allowed if needed: the two archived files listed above only. + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-provider-catalog-device-status/01_pool_schema/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-provider-catalog-device-status`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_API-1] served model membership validation | [x] | +| [REVIEW_API-2] provider snapshot queue stats | [x] | +| [REVIEW_API-3] `configs/edge.yaml` provider pool example | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_API-1] `models[].providers[provider_id]` served model이 해당 `nodes[].providers[].models` list에 포함되는지 load-time validation과 회귀 테스트를 추가한다. +- [x] [REVIEW_API-2] provider catalog snapshot의 `in_flight`, `queued`, `load_ratio`가 provider id와 adapter/instance key가 다를 때도 실제 queue state를 반영하도록 수정하고 service/status 테스트를 추가한다. +- [x] [REVIEW_API-3] `configs/edge.yaml`에 loader가 지원하는 top-level `models[]`와 `nodes[].providers[]` commented example을 추가하고 legacy `openai.model_routes` migration 문구와 일치시킨다. +- [x] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 붙여 넣는다. +- [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_G06_1.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_1.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/01_pool_schema/`를 `agent-task/archive/YYYY/MM/m-provider-catalog-device-status/01_pool_schema/`로 이동하고 최종 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-G06.md`와 `CODE_REVIEW-local-G06.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로 이동한다. + +## 계획 대비 변경 사항 + +- `buildProviderServedModelsIndex`를 package-level global variable 대신 pure function `buildProviderServedModelsIndex(nodes []NodeDefinition) map[string]map[string]struct{}`으로 구현하여 테스트 간 state contamination을 방지했다. +- `ModelCatalogEntry.Validate` 시그니처를 `Validate(resolvedProviderIDs map[string]struct{}, serveModels map[string]map[string]struct{})`로 변경하여 test isolation을 보장했다. +- `resolveSnapshotAdapterName` helper는 이미 `(rec, adapterType, target) (string, bool)` 시그니처로 존재했으므로, provider catalog path에서도 동일 함수를 재사용했다. +- REVIEW_API-2에서 provider snapshot queue stats 조회 시 `prov.ID` 대신 `prov.Adapter`가 설정되어 있으면 그 값을 먼저 사용하도록 수정했다. +- REVIEW_API-2 PLAN에서 요구한 "provider id와 adapter key가 다른 fixture를 사용한 service 테스트"가 누락되어 있어 `TestListNodeSnapshotsProviderIdDiffersFromAdapterKey` 테스트를 추가했다. provider id="provider-vllm-primary", adapter="vllm-gpu" fixture로 queue stats projection이 올바르게 동작하는지 검증한다. + +## 주요 설계 결정 + +- `ModelCatalogEntry.Validate`는 `serveModels`가 non-nil일 때만 served model membership을 검증한다. `nil`일 때는 legacy 호환을 위해 기존 검증만 수행한다. +- `resolveSnapshotAdapterName(rec, prov.Adapter, prov.ID)`는 adapter/instance key가 provider id와 다를 때 queue stats를 올바르게 찾는다. 반환값 `(string, bool)` 중 bool은 multiplex conflict를 나타내며, provider snapshot에서는 ignore한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `models[].providers[provider_id]` 값이 실제 provider `models[]` list에 없을 때 `LoadEdge`가 실패하는지 확인한다. +- provider id와 adapter/instance key가 다른 경우에도 provider snapshot의 `InFlight`, `Queued`, `LoadRatio`가 실제 queue state를 반영하는지 확인한다. +- `configs/edge.yaml` 예시가 실제 loader field 이름과 placement를 따르며 secret/token 원문을 포함하지 않는지 확인한다. +- Roadmap/SDD 대상은 유지하되, code-review가 직접 roadmap을 갱신하지 않았는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REVIEW_API-1 중간 검증 + +```bash +$ go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestModelCatalogEntry|TestNodeProviderConf' -count=1 +ok iop/packages/go/config 0.009s +``` + +### REVIEW_API-2 중간 검증 + +```bash +$ go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider' -count=1 +=== RUN TestModelQueueFIFOOrdering +--- PASS: TestModelQueueFIFOOrdering (0.00s) +=== RUN TestModelQueueOverflow +--- PASS: TestModelQueueOverflow (0.00s) +=== RUN TestModelQueueTimeout +--- PASS: TestModelQueueTimeout (0.02s) +=== RUN TestModelQueueTerminalReleaseDispatchesNext +--- PASS: TestModelQueueTerminalReleaseDispatchesNext (0.10s) +=== RUN TestModelQueueNodeDisconnectReleasesInflight +--- PASS: TestModelQueueNodeDisconnectReleasesInflight (0.00s) +=== RUN TestModelQueueUsesProviderCapacity +--- PASS: TestModelQueueUsesProviderCapacity (0.00s) +=== RUN TestModelQueueUsesProviderQueuePolicy +--- PASS: TestModelQueueUsesProviderQueuePolicy (0.02s) +=== RUN TestModelQueueContextCancelRemovesQueuedItem +--- PASS: TestModelQueueContextCancelRemovesQueuedItem (0.00s) +=== RUN TestListNodeSnapshotsUsesEdgeQueueState +--- PASS: TestListNodeSnapshotsUsesEdgeQueueState (0.00s) +=== RUN TestListNodeSnapshotsTypeRouteSingleNamedInstance +--- PASS: TestListNodeSnapshotsTypeRouteSingleNamedInstance (0.00s) +=== RUN TestListNodeSnapshotsProviderIdDiffersFromAdapterKey +--- PASS: TestListNodeSnapshotsProviderIdDiffersFromAdapterKey (0.00s) +PASS +ok iop/apps/edge/internal/service 0.156s +``` + +### REVIEW_API-3 중간 검증 + +```bash +$ rg --sort path -n 'models\\[\\]|nodes\\[\\]\\.providers|provider-pool|model_routes|lifecycle_capabilities' configs/edge.yaml apps/edge/README.md +configs/edge.yaml:56: # model_routes is the current compatibility route catalog. It maps external +configs/edge.yaml:68: # model_routes (legacy/adapter/type reference): +configs/edge.yaml:94: # 1. Define the route alias in model_routes with adapter="openai_compat" +configs/edge.yaml:99: # model_routes: +configs/edge.yaml:108: # Top-level models[] defines canonical routing keys and their provider-pool mapping. +configs/edge.yaml:127: # nodes[].providers[].lifecycle_capabilities — coarse lifecycle capabilities list. +configs/edge.yaml:144: # lifecycle_capabilities: ["scale_up", "scale_down"] +apps/edge/README.md:196:... +apps/edge/README.md:200: model_routes: +apps/edge/README.md:228:... +apps/edge/README.md:230:... +apps/edge/README.md:259: lifecycle_capabilities: +apps/edge/README.md:270: lifecycle_capabilities: +apps/edge/README.md:282: model_routes: +``` + +### 최종 검증 + +```bash +$ go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/node -count=1 +ok iop/packages/go/config 0.036s +ok iop/apps/edge/internal/service 0.343s +ok iop/apps/edge/internal/openai 1.509s +ok iop/apps/edge/internal/node 0.007s + +$ go test ./... +# ... all tests pass ... + +$ git diff --check +# No whitespace errors + +ALL CHECKS PASSED +``` + +--- + +> **[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. + +## 섹션 소유권 + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 | +| Spec Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Spec Completion`으로 복사 | +| Archive Evidence Snapshot | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트의 이전 루프 컨텍스트 | +| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크 | +| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트 | placeholder 텍스트를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트 | 선택된 SDD 결정 또는 Milestone lock decision 차단 때만 채움 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 | 구현 에이전트 | 실행 출력만 구현 에이전트가 채움 | +| 코드리뷰 결과 | 리뷰 에이전트 | 스텁에 포함하지 않음 | + +--- + +## 코드리뷰 결과 + +- 종합 판정: FAIL + +### 차원별 평가 + +- Correctness: Fail +- Completeness: Fail +- Test coverage: Fail +- API contract: Fail +- Code quality: Pass +- Plan deviation: Fail +- Verification trust: Pass +- Spec conformance: Fail + +### 발견된 문제 + +- Required: `models[].providers`는 top-level map이라 provider id가 전체 Edge config에서 유일해야 참조가 결정적입니다. 그런데 현재 validation은 노드 내부 중복만 막고([config.go:496](/config/workspace/iop/packages/go/config/config.go:496)), `providerIDs`/`serveModels`는 같은 provider id가 여러 노드에 있으면 하나의 key로 합쳐집니다([config.go:196](/config/workspace/iop/packages/go/config/config.go:196), [config.go:506](/config/workspace/iop/packages/go/config/config.go:506)). follow-up plan은 노드 identity 없이 disambiguate할 수 없으므로 이번 범위에서는 provider id를 catalog reference 기준으로 유일하게 다루거나 명확한 에러를 반환하라고 했습니다. 지금은 두 노드가 같은 provider id를 갖고 서로 다른 served model set을 제공해도 catalog validation이 union set으로 통과할 수 있어 S01/S02 계약이 모호해집니다. Fix: `LoadEdge`에서 provider id를 전체 `nodes[].providers[]` 기준으로 유일하게 검증하거나, 명확한 에러를 반환하는 전역 중복 테스트를 추가하세요. +- Required: `prov.Adapter`가 비어 있을 때 provider snapshot stats lookup이 provider id로 fallback하지 않고 빈 문자열로 조회됩니다. `getSnapshotForNode`는 `resolveSnapshotAdapterName(rec, prov.Adapter, prov.ID)`를 그대로 호출하며([model_queue.go:450](/config/workspace/iop/apps/edge/internal/service/model_queue.go:450)), `resolveSnapshotAdapterName`의 default path는 빈 `adapterType`을 그대로 반환합니다([model_queue.go:504](/config/workspace/iop/apps/edge/internal/service/model_queue.go:504), [model_queue.go:592](/config/workspace/iop/apps/edge/internal/service/model_queue.go:592)). follow-up plan은 `prov.Adapter`가 있으면 그 값을 쓰고, 없으면 기존 provider id fallback을 쓰라고 했으므로, adapter 생략을 허용하는 현재 config에서는 `InFlight`, `Queued`, `LoadRatio`가 누락됩니다. Fix: stats key helper에서 `strings.TrimSpace(prov.Adapter) == ""`이면 `prov.ID`를 사용하도록 하고, adapter field가 없는 provider fixture로 회귀 테스트를 추가하세요. + +### 다음 단계 + +- WARN/FAIL 후속: provider id 전역 중복 validation과 provider snapshot stats fallback을 처리하는 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_2.log b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_2.log new file mode 100644 index 0000000..a7325c8 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_2.log @@ -0,0 +1,248 @@ + + +# Code Review Reference - REVIEW_REVIEW_API + +> **[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 SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, 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 the linked SDD/Milestone lock decision 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-06-20 +task=01_pool_schema, plan=2, tag=REVIEW_REVIEW_API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` +- Task ids: + - `alias-contract`: `models[].id` canonical routing key와 `models[].providers` provider id -> served model map 검증 + - `candidate-schema`: `nodes[].providers[]` provider id/type/category/served models/health/capacity/in-flight/queued/coarse lifecycle 표현 +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md` +- Acceptance Scenarios: + - `S01`: model alias provider pool contract + - `S02`: provider candidate schema +- Evidence Map: + - `S01`, `S02`는 이 task directory의 `complete.log`에 Roadmap Completion과 Spec Completion 근거가 있어야 한다. + +## Archive Evidence Snapshot + +- Archived plan history: + - `agent-task/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_0.log` + - `agent-task/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_1.log` +- Archived review history: + - `agent-task/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_0.log` + - `agent-task/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_1.log` +- Latest verdict: FAIL +- Required summary: + - `packages/go/config/config.go` still permits the same provider id on different nodes. Because `models[].providers` is a top-level provider id map, this makes catalog references ambiguous and merges served model sets. + - `apps/edge/internal/service/model_queue.go` does not fallback to provider id when `nodes[].providers[].adapter` is empty; stats lookup uses an empty key and drops queue state. +- Verification evidence from previous loop: + - `go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestModelCatalogEntry|TestNodeProviderConf' -count=1`: passed. + - `go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider' -count=1`: passed. + - `go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/node -count=1`: passed. + - `go test ./...`: passed. + - `git diff --check`: passed. + - `make proto`: passed. +- Affected files for this follow-up: + - `packages/go/config/config.go` + - `packages/go/config/config_test.go` + - `apps/edge/internal/service/model_queue.go` + - `apps/edge/internal/service/status_provider_test.go` or adjacent service test +- Narrow archive reread allowed if needed: the four archived files listed above only. + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-provider-catalog-device-status/01_pool_schema/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-provider-catalog-device-status`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REVIEW_API-1] provider id global uniqueness | [x] | +| [REVIEW_REVIEW_API-2] provider snapshot adapter fallback | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_REVIEW_API-1] `nodes[].providers[].id`가 전체 Edge config에서 catalog reference 기준으로 유일하도록 validation과 전역 중복 회귀 테스트를 추가했다. +- [x] [REVIEW_REVIEW_API-2] provider snapshot stats key가 `adapter` 미설정 시 provider id로 fallback하도록 수정하고 adapter 없는 provider fixture로 `InFlight`, `Queued`, `LoadRatio` 회귀 테스트를 추가했다. +- [x] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 붙여 넣었다. +- [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_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-provider-catalog-device-status/01_pool_schema/`를 `agent-task/archive/YYYY/MM/m-provider-catalog-device-status/01_pool_schema/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-provider-catalog-device-status`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-provider-catalog-device-status/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.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로 이동한다. + +## 계획 대비 변경 사항 + +계획에서는 `config.go`에 이미 global uniqueness 검증이 있다고 가정했으나, 실제로는 node 내부 중복 검증만 있고 전역 중복 검증이 누락되어 있었다. 따라서 전역 중복 검사를 `LoadEdge` 함수에 직접 추가했다. + +## 주요 설계 결정 + +1. **provider id global uniqueness 검증 위치**: `LoadEdge` 함수의 node 순회 루프에서 각 provider id를 `providerIDs` map에 추가하기 전 global 중복을 검사한다. 중복 발견 시 `nodes[%d].providers[%d]: duplicate provider id %q across nodes (global uniqueness violation)` 에러를 반환한다. +2. **adapter fallback 전략**: `resolveSnapshotAdapterName` 함수의 최상단에서 `adapterType`이 빈 문자열일 때 `target`(provider id)으로 fallback한다. 이를 통해 `nodes[].providers[].adapter`가 비어 있어도 queue stats(InFlight, Queued, LoadRatio)가 올바르게 반영된다. +3. **테스트 전략**: + - REVIEW_REVIEW_API-1: 두 node가 같은 provider id를 쓰는 config로 `LoadEdge`가 error를 반환하는지 검증 + - REVIEW_REVIEW_API-2: `adapter: ""`인 provider fixture로 `InFlight`, `Queued`, `LoadRatio`가 0이 아닌 올바른 값을 반영하는지 검증 + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 두 개의 다른 node가 같은 `nodes[].providers[].id`를 쓰는 config가 `LoadEdge`에서 실패하는지 확인한다. +- `nodes[].providers[].adapter`가 비어 있을 때 provider id fallback으로 snapshot `InFlight`, `Queued`, `LoadRatio`가 반영되는지 확인한다. +- 이전 루프에서 통과한 served model membership validation과 adapter-key-diff snapshot 테스트가 유지되는지 확인한다. + +## 검증 결과 + +### REVIEW_REVIEW_API-1 중간 검증 + +```bash +$ go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestModelCatalogEntry|TestNodeProviderConf|TestLoadEdge_NodeProviderGlobalUniqueness' -count=1 +ok iop/packages/go/config 0.010s +``` + +### REVIEW_REVIEW_API-2 중간 검증 + +```bash +$ go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider|TestListNodeSnapshotsProviderEmptyAdapter' -count=1 +ok iop/apps/edge/internal/service 0.154s +``` + +### 최종 검증 + +```bash +$ go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/node -count=1 +ok iop/packages/go/config 0.068s +ok iop/apps/edge/internal/service 0.336s +ok iop/apps/edge/internal/openai 1.509s +ok iop/apps/edge/internal/node 0.006s + +$ go test ./... 2>&1 | tail -50 +ok iop/apps/control-plane/cmd/control-plane (cached) +ok iop/apps/control-plane/internal/wire (cached) +ok iop/apps/edge/cmd/edge 0.054s +ok iop/apps/edge/internal/bootstrap 0.035s +ok iop/apps/edge/internal/controlplane (cached) +ok iop/apps/edge/internal/edgecmd 0.016s +ok iop/apps/edge/internal/events (cached) +ok iop/apps/edge/internal/input 0.015s +ok iop/apps/edge/internal/input/a2a (cached) +ok iop/apps/edge/internal/node 0.034s +ok iop/apps/edge/internal/openai (cached) +ok iop/apps/edge/internal/opsconsole 0.010s +ok iop/apps/edge/internal/service 0.347s +ok iop/apps/edge/internal/transport 2.031s +ok iop/apps/node/cmd/node 0.015s +ok iop/apps/node/internal/adapters (cached) +ok iop/apps/node/internal/adapters/cli (cached) +ok iop/apps/node/internal/adapters/cli/status (cached) +ok iop/apps/node/internal/adapters/ollama (cached) +ok iop/apps/node/internal/adapters/openai_compat (cached) +ok iop/apps/node/internal/adapters/vllm (cached) +ok iop/apps/node/internal/bootstrap (cached) +ok iop/apps/node/internal/node (cached) +ok iop/apps/node/internal/router (cached) +ok iop/apps/node/internal/store (cached) +ok iop/apps/node/internal/terminal (cached) +ok iop/apps/node/internal/transport (cached) +ok iop/packages/go/audit (cached) +ok iop/packages/go/config 0.024s +ok iop/packages/go/hostsetup (cached) +ok iop/packages/go/observability (cached) + +$ git diff --check +(no output — no whitespace errors) +``` + +--- + +> **[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. + +## 섹션 소유권 + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 | +| Spec Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Spec Completion`으로 복사 | +| Archive Evidence Snapshot | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트의 이전 루프 컨텍스트 | +| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크 | +| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트 | placeholder 텍스트를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트 | 선택된 SDD 결정 또는 Milestone lock decision 차단 때만 채움 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 | 구현 에이전트 | 실행 출력만 구현 에이전트가 채움 | +| 코드리뷰 결과 | 리뷰 에이전트 | 스텁에 포함하지 않음 | + +--- + +## 코드리뷰 결과 + +- 종합 판정: PASS + +### 차원별 평가 + +- Correctness: Pass +- Completeness: Pass +- Test coverage: Pass +- API contract: Pass +- Code quality: Pass +- Plan deviation: Pass +- Verification trust: Pass +- Spec conformance: Pass + +### 발견된 문제 + +- 없음 + +### 다음 단계 + +- PASS: active plan/review를 archive log로 이동하고, `complete.log` 작성 후 task directory를 `agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/`로 이동한다. diff --git a/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/complete.log b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/complete.log new file mode 100644 index 0000000..af1ddf2 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/complete.log @@ -0,0 +1,56 @@ +# Complete - m-provider-catalog-device-status/01_pool_schema + +## 완료 일시 + +2026-06-20 + +## 요약 + +Provider catalog/device status의 provider pool schema/status foundation을 3회 code-review loop 끝에 PASS로 완료했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | served model membership validation, provider snapshot stats key, config example 누락이 Required로 확인됨 | +| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | FAIL | provider id global uniqueness와 empty adapter stats fallback 누락이 Required로 확인됨 | +| `plan_local_G06_2.log` | `code_review_local_G06_2.log` | PASS | Required/Suggested/Nit 없음 | + +## 구현/정리 내용 + +- `models[]`와 `nodes[].providers[]` config schema 및 validation을 추가하고, provider id 전역 유일성, served model membership, duplicate/invalid category 검증을 포함했다. +- provider catalog/status projection에 provider id/type/category/served_models/health/capacity/in_flight/queued/load_ratio/lifecycle_capabilities를 반영하고, adapter key와 empty-adapter provider id fallback stats 조회를 검증했다. +- README, `configs/edge.yaml`, runtime proto/generated code에 provider pool 예시와 `ProviderSnapshot` 확장을 정리했다. + +## 최종 검증 + +- `go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestLoadEdge_NodeProviderConf|TestNodeProviderConf|TestLoadEdge_NodeProviderGlobalUniqueness' -count=1` - PASS; `ok iop/packages/go/config 0.021s` +- `go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider|TestListNodeSnapshotsProviderEmptyAdapter' -count=1` - PASS; `ok iop/apps/edge/internal/service 0.153s` +- `go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/node -count=1` - PASS; 대상 4개 package 모두 통과 +- `go test ./...` - PASS; 전체 Go package 통과 +- `git diff --check` - PASS; whitespace error 없음 +- `make proto` - PASS; runtime proto generation 완료 + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` +- Completed task ids: + - `alias-contract`: PASS; evidence=`plan_local_G06_2.log`, `code_review_local_G06_2.log`; verification=`go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestLoadEdge_NodeProviderConf|TestNodeProviderConf|TestLoadEdge_NodeProviderGlobalUniqueness' -count=1`, `go test ./...`, `git diff --check` + - `candidate-schema`: PASS; evidence=`plan_local_G06_2.log`, `code_review_local_G06_2.log`; verification=`go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider|TestListNodeSnapshotsProviderEmptyAdapter' -count=1`, `go test ./...`, `git diff --check` +- Not completed task ids: 없음 + +## Spec Completion + +- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md` +- Completed scenario ids: + - `S01`: PASS; task=`alias-contract`; evidence=`plan_local_G06_2.log`, `code_review_local_G06_2.log`; verification=`go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestLoadEdge_NodeProviderConf|TestNodeProviderConf|TestLoadEdge_NodeProviderGlobalUniqueness' -count=1`, `go test ./...`, `git diff --check` + - `S02`: PASS; task=`candidate-schema`; evidence=`plan_local_G06_2.log`, `code_review_local_G06_2.log`; verification=`go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider|TestListNodeSnapshotsProviderEmptyAdapter' -count=1`, `go test ./...`, `git diff --check` +- Not completed scenario ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_0.log b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_0.log new file mode 100644 index 0000000..4bbcb55 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_0.log @@ -0,0 +1,133 @@ + +# Plan - API + +## 이 파일을 읽는 구현 에이전트에게 + +구현이 끝났다고 보고하기 전에 반드시 `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다. 검증 명령을 실행하고 실제 stdout/stderr를 남기며, active plan/review 파일은 그대로 둔 상태에서 review-ready만 보고한다. 선택된 SDD 결정이나 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 중단한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`, `complete.log`, archive log를 작성하지 않는다. 환경/secret/서비스 준비, 일반 범위 조정, 검증 증거 공백은 그 자체로 사용자 리뷰 요청이 아니다. + +## 배경 + +현재 Edge OpenAI route는 `openai.model_routes`의 단일 `adapter + target` mapping에 머물러 있다. Milestone의 `model-pool` epic은 외부 model key를 `models[].id`로 고정하고, provider id별 served model map과 `nodes[].providers[]` 후보 schema를 먼저 확정해야 이후 target rewrite와 load-ratio selection을 안전하게 구현할 수 있다. 이 계획은 런타임 selection을 바꾸기 전 config/proto/status 계약과 validation foundation을 만든다. + +## 사용자 리뷰 요청 흐름 + +선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실제 구현을 차단할 때만 `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 근거를 채운다. 구현 중 직접 사용자에게 묻거나 채팅 선택지를 만들지 않는다. code-review 단계가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` +- Task ids: + - `alias-contract`: `models[].id` canonical routing key와 `models[].providers` provider id -> served model map 검증 + - `candidate-schema`: `nodes[].providers[]` provider id/type/category/served models/health/capacity/in-flight/queued/coarse lifecycle 표현 +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md` +- Acceptance Scenarios: + - `S01`: model alias provider pool contract + - `S02`: provider candidate schema +- Evidence Map: + - `S01`, `S02`는 이 task directory의 `complete.log`에 Roadmap Completion과 Spec Completion 근거가 있어야 한다. + +## 분석 결과 + +- 읽은 규칙/계약: `agent-ops/rules/project/rules.md`, `agent-ops/rules/common/rules-roadmap.md`, `agent-ops/skills/common/router.md`, `agent-ops/skills/common/plan/SKILL.md`, `agent-ops/skills/common/roadmap-sdd/SKILL.md`, `agent-ops/skills/common/update-roadmap/SKILL.md`, `agent-contract/index.md`, `agent-contract/provided/openai-compatible-api.md`. +- 읽은 Roadmap/SDD: `agent-roadmap/current.md`, active Phase, active Milestone, 승인된 SDD. SDD는 `[승인됨]`, SDD 잠금은 `해제`, active `USER_REVIEW.md`는 없다. +- 읽은 domain/test rules: edge, platform-common, testing, `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`. +- 읽은 source: `packages/go/config/config.go`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/responses_handler.go`, `apps/edge/internal/openai/routes.go`, `apps/edge/internal/openai/server.go`, `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/service/model_queue.go`, `apps/edge/internal/service/status_provider.go`, `proto/iop/runtime.proto`, `configs/edge.yaml`, `apps/edge/README.md`. +- 읽은 tests: `packages/go/config/config_test.go`, `apps/edge/internal/openai/server_test.go`, `apps/edge/internal/service/model_queue_test.go`, `apps/edge/internal/service/service_internal_test.go`, `apps/edge/internal/service/service_test.go`, `apps/edge/internal/service/status_provider_test.go`. +- 기존 커버리지: legacy `openai.model_routes`, `/v1/models` catalog, provider instance capacity/queue policy, model queue admission/release/status는 테스트가 있다. `models[]` provider map validation, `nodes[].providers[]` catalog schema, provider id reference validation, category/lifecycle/status field projection은 테스트가 없다. +- 분할 판단: `model-pool` epic은 config/proto/status 계약과 dispatch policy가 서로 의존하는 다중 단계 작업이다. 이 plan은 predecessor 없는 `01_pool_schema` foundation이다. 후속 `02+01_edge_dispatch`는 이 task가 `complete.log`로 PASS 된 뒤 구현해야 한다. +- 심볼 참조: 기존 symbol rename/remove는 계획하지 않는다. `OpenAIRouteEntry`, `EdgeOpenAIConf.Models`, `ProviderSnapshot`은 compatibility를 유지하면서 새 field/type을 추가한다. +- dependency manifest: 새 외부 package는 추가하지 않는다. protobuf 변경 시 repo 기존 `make proto` 흐름을 사용한다. +- pre-check compile: `EdgeOpenAIConf.Models []string`은 legacy openai catalog로 남기고, Milestone의 새 `models[]`는 top-level Edge config field로 추가해야 decode conflict를 피할 수 있다. +- build grade: local-G06. Go config/proto/status 계약과 다수 테스트를 건드리지만 외부 서비스, secret, 배포 상태 변경은 없다. + +## 구현 체크리스트 + +- [ ] `packages/go/config/config.go`에 top-level `models[]` catalog type을 추가하고 `models[].id`, `models[].providers` provider id -> served model map validation을 구현한다. +- [ ] `packages/go/config/config.go`에 `nodes[].providers[]` config type과 provider id/type/category/served models/capacity/health/coarse lifecycle validation을 추가한다. +- [ ] `proto/iop/runtime.proto`와 생성 코드를 갱신해 provider catalog/status surface가 provider id, type/category, served models, health, capacity, in-flight, queued, load ratio, coarse lifecycle capability를 표현하게 한다. +- [ ] Edge status/provider snapshot 조립 코드에 새 provider catalog/status field를 채우되 legacy adapter snapshot 소비자를 깨지 않게 compatibility field를 유지한다. +- [ ] `configs/edge.yaml`와 `apps/edge/README.md`에 실제 지원되는 `models[]`/`nodes[].providers[]` 예시와 legacy `openai.model_routes` migration 문구를 갱신한다. +- [ ] config/status/openai/service 테스트에 provider pool schema happy path와 invalid duplicate/missing provider/missing served model/invalid category 회귀를 추가한다. +- [ ] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 붙여 넣는다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## API-1 - `models[]` alias contract + +### 문제 + +`packages/go/config/config.go`의 `EdgeOpenAIConf`는 legacy `models []string`과 `model_routes`만 갖고 있고, `OpenAIRouteEntry`도 외부 model id를 하나의 `adapter + target`으로만 연결한다. 따라서 `qwen3.6:35b` 같은 외부 key가 provider pool canonical key인지, provider id별 served model map이 유효한지 검증할 곳이 없다. + +### 변경 + +Top-level Edge config에 `Models []ModelCatalogEntry`를 추가한다. `ModelCatalogEntry`는 최소 `ID string`, `Providers map[string]string`, 선택적 display/metadata field를 갖는다. `openai.models []string`은 기존 `/v1/models` compat field로 남겨 decode와 legacy tests를 유지한다. + +Validation은 `LoadEdge` 흐름에 붙인다. + +- `models[].id` trim 후 non-empty, unique. +- `models[].providers` non-empty. +- provider id와 served model name은 trim 후 non-empty. +- provider id는 `nodes[].providers[].id` 중 하나로 resolve되어야 한다. +- served model name은 해당 provider의 served model list에 포함되어야 한다. + +### 중간 검증 + +```bash +go test ./packages/go/config -run 'TestLoadEdge_ModelProviderPool|TestLoadEdge_OpenAI' -count=1 +``` + +기대 결과: 새 provider pool schema validation tests와 기존 OpenAI config tests가 모두 통과한다. + +## API-2 - `nodes[].providers[]` candidate schema + +### 문제 + +기존 Node adapter instance 설정은 `openai_compat_instances`, `vllm_instances`, `ollama_instances`처럼 adapter별 실행 설정에 묶여 있고 provider id/category/served model list/lifecycle capability를 공통 schema로 표현하지 않는다. `proto/iop/runtime.proto`의 `ProviderSnapshot`도 `adapter/status/capacity/in_flight/queued`만 있으므로 operations catalog가 provider 후보를 비교할 수 없다. + +### 변경 + +`NodeDefinition`에 `Providers []NodeProviderConf`를 추가한다. `NodeProviderConf`는 최소 `ID`, `Type`, `Category`, `Adapter`, `Models`, `Health`, `Capacity`, `MaxQueue`, `QueueTimeout`, `LifecycleCapabilities`를 포함한다. Category MVP 값은 Milestone 기준대로 `api`, `cli`, `local_inference`만 허용한다. Runtime in-flight/queued/load ratio는 config 입력값이 아니라 Edge status에서 계산해 projection한다. + +`ProviderSnapshot` protobuf에는 compatibility를 위해 기존 field를 유지하고, 새 field를 뒤에 추가한다. 생성 코드는 `make proto`로 갱신한다. + +### 중간 검증 + +```bash +make proto +go test ./packages/go/config ./apps/edge/internal/service -run 'TestLoadEdge_ModelProviderPool|TestProvider|TestModelQueue' -count=1 +``` + +기대 결과: protobuf 생성물이 최신이고 config/provider snapshot 관련 테스트가 통과한다. + +## API-3 - Docs and compatibility examples + +### 문제 + +`configs/edge.yaml`와 `apps/edge/README.md`는 현재 legacy route와 vLLM OpenAI-compatible provider setup만 설명한다. 실제 schema 구현 뒤에는 operator가 `models[]`/`nodes[].providers[]`를 어떻게 쓰고 legacy route가 어떤 migration input인지 확인할 수 있어야 한다. + +### 변경 + +설정 예시는 실제 구현된 field 이름만 사용한다. 1:1 legacy route가 단일 provider pool entry로 이동하는 예시, provider id와 served model list의 관계, Edge target rewrite 책임, legacy `openai.model_routes` fallback 규칙을 함께 적는다. + +### 중간 검증 + +```bash +rg --sort path -n 'models\\[\\]|nodes\\[\\]\\.providers|model_routes' apps/edge/README.md configs/edge.yaml +``` + +기대 결과: README와 예시 YAML이 새 contract와 legacy compatibility를 모두 언급한다. + +## 최종 검증 + +```bash +make proto +go test ./packages/go/config ./apps/edge/internal/openai ./apps/edge/internal/service -count=1 +git diff --check +``` + +기대 결과: protobuf 생성물이 최신이고, 관련 Go tests가 통과하며, whitespace error가 없다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_1.log b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_1.log new file mode 100644 index 0000000..91d4084 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_1.log @@ -0,0 +1,134 @@ + +# Plan - REVIEW_API + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 직전 code-review `FAIL`의 Required 항목만 좁게 처리한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실제 구현을 차단할 때만 `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션을 채우고 중단한다. 환경/secret/서비스 준비, 검증 증거 공백, 일반 범위 조정은 사용자 리뷰 요청이 아니다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` +- Task ids: + - `alias-contract`: `models[].id` canonical routing key와 `models[].providers` provider id -> served model map 검증 + - `candidate-schema`: `nodes[].providers[]` provider id/type/category/served models/health/capacity/in-flight/queued/coarse lifecycle 표현 +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md` +- Acceptance Scenarios: + - `S01`: model alias provider pool contract + - `S02`: provider candidate schema +- Evidence Map: + - `S01`, `S02`는 이 task directory의 `complete.log`에 Roadmap Completion과 Spec Completion 근거가 있어야 한다. + +## Archive Evidence Snapshot + +- Archived plan: `agent-task/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_0.log` +- Archived review: `agent-task/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_0.log` +- Verdict: FAIL +- Required summary: + - `packages/go/config/config.go` validates provider id existence but not that `models[].providers[provider_id]` served model belongs to the referenced provider's `models[]`. + - `apps/edge/internal/service/model_queue.go` uses `prov.ID` as the queue stats key, so provider snapshots lose `in_flight`, `queued`, and `load_ratio` when provider id differs from adapter/instance key. + - `configs/edge.yaml` contains migration comments but no actual top-level `models[]` / `nodes[].providers[]` example required by the plan. +- Verification evidence from previous loop: + - `make proto`: passed. + - `go test ./packages/go/config ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/edge/internal/node -count=1`: passed. + - `go test ./...`: passed. + - `git diff --check`: passed. +- Affected files for this follow-up: + - `packages/go/config/config.go` + - `packages/go/config/config_test.go` + - `apps/edge/internal/service/model_queue.go` + - `apps/edge/internal/service/status_provider_test.go` or `apps/edge/internal/service/model_queue_test.go` + - `configs/edge.yaml` +- Narrow archive reread allowed if needed: the two archived files listed above only. + +## 범위 결정 근거 + +- 후속 범위는 code-review Required 3건으로 제한한다. +- `proto/iop/runtime.proto`와 생성물은 직전 검증에서 최신으로 확인되었고, 이번 follow-up은 proto field를 추가하지 않는다. +- SDD/Roadmap Target은 그대로 유지한다. S01/S02 completion은 membership validation, provider status projection, config example까지 충족된 뒤 PASS 시 `complete.log`에 복사된다. +- Roadmap 문서 상태 갱신이나 archive는 이 plan 범위가 아니다. + +## 구현 체크리스트 + +- [ ] [REVIEW_API-1] `models[].providers[provider_id]` served model이 해당 `nodes[].providers[].models` list에 포함되는지 load-time validation과 회귀 테스트를 추가한다. +- [ ] [REVIEW_API-2] provider catalog snapshot의 `in_flight`, `queued`, `load_ratio`가 provider id와 adapter/instance key가 다를 때도 실제 queue state를 반영하도록 수정하고 service/status 테스트를 추가한다. +- [ ] [REVIEW_API-3] `configs/edge.yaml`에 loader가 지원하는 top-level `models[]`와 `nodes[].providers[]` commented example을 추가하고 legacy `openai.model_routes` migration 문구와 일치시킨다. +- [ ] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 붙여 넣는다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## REVIEW_API-1 - served model membership validation + +### 문제 + +현재 `ModelCatalogEntry.Validate`는 provider id가 존재하는지만 보고, `models[].providers[provider_id]` 값이 해당 provider의 `models[]` list에 있는지 확인하지 않는다. SDD S01/S02와 원 plan은 provider id -> served model map이 provider별 served model list와 일치해야 한다고 요구한다. + +### 변경 + +`LoadEdge`에서 provider id별 served model set을 구성한다. `models[]` validation은 provider id resolve와 함께 served model membership을 검사해야 한다. provider id가 중복될 수 있는 설계가 필요하다면 top-level model map에서 node identity 없이 disambiguate할 수 없으므로, 이 follow-up에서는 provider id를 catalog reference 기준으로 유일하게 다루거나 명확한 에러를 반환한다. + +테스트는 최소 아래를 포함한다. + +- happy path가 기존처럼 통과한다. +- `models[].providers.vllm-gpu: "typo-model"`이고 `nodes[].providers[id=vllm-gpu].models`에 없는 경우 `LoadEdge`가 실패한다. +- 에러 메시지는 model id, provider id, missing served model을 식별할 수 있어야 한다. + +### 중간 검증 + +```bash +go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestModelCatalogEntry|TestNodeProviderConf' -count=1 +``` + +## REVIEW_API-2 - provider snapshot queue stats + +### 문제 + +새 provider snapshot은 `getStatsForAdapterLocked(nodeID, rec, prov.ID)`로 queue 통계를 조회한다. 기존 queue group은 adapter/target을 canonical adapter instance name으로 resolve하므로, provider id가 adapter/instance key와 다르면 snapshot의 `InFlight`, `Queued`, `LoadRatio`가 실제 상태를 반영하지 않는다. + +### 변경 + +provider snapshot의 stats key를 provider id와 adapter/instance identity의 책임에 맞게 정리한다. 최소 기준은 `prov.Adapter`가 있으면 그 값을 queue stats lookup에 사용하고, 없으면 기존 provider id fallback을 사용한다. 기존 named adapter instance resolve와 충돌하지 않게 `resolveSnapshotAdapterName` 경로를 재사용하거나 작은 helper를 추가한다. + +테스트는 provider id와 adapter key가 다른 fixture를 사용한다. + +- 예: provider id `provider-vllm-primary`, adapter/instance key `vllm-gpu`. +- queue group에 `adapter: "vllm-gpu"` 또는 기존 dispatch가 만드는 canonical adapter key를 넣는다. +- `ListNodeSnapshots()` 또는 queue snapshot helper가 provider snapshot의 `Id`, `Adapter`, `InFlight`, `Queued`, `LoadRatio`를 기대값으로 반환하는지 확인한다. + +### 중간 검증 + +```bash +go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider' -count=1 +``` + +## REVIEW_API-3 - `configs/edge.yaml` provider pool example + +### 문제 + +`configs/edge.yaml`는 migration 방향을 주석으로 설명하지만, 실제 top-level `models[]`와 `nodes[].providers[]` schema 예시가 없다. README와 달리 기본 config 예시만 보는 운영자는 새 schema의 필드 이름과 placement를 확인할 수 없다. + +### 변경 + +기본 config를 실행값으로 오염시키지 말고 commented example로 추가한다. + +- top-level `models:` example은 `id`, `display_name`, `providers` map을 보여준다. +- `nodes[].providers[]` example은 `id`, `type`, `category`, `adapter`, `models`, `health`, `capacity`, `max_queue`, `queue_timeout_ms`, `lifecycle_capabilities`를 보여준다. +- legacy `openai.model_routes`는 fallback/compat route catalog라는 설명을 유지한다. + +### 중간 검증 + +```bash +rg --sort path -n 'models\\[\\]|nodes\\[\\]\\.providers|provider-pool|model_routes|lifecycle_capabilities' configs/edge.yaml apps/edge/README.md +``` + +## 최종 검증 + +```bash +go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/node -count=1 +go test ./... +git diff --check +``` + +기대 결과: provider pool config validation과 provider status projection 테스트가 통과하고, 전체 Go 회귀와 whitespace 검증이 통과한다. diff --git a/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_2.log b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_2.log new file mode 100644 index 0000000..51f5ee9 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_2.log @@ -0,0 +1,116 @@ + +# Plan - REVIEW_REVIEW_API + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 직전 code-review `FAIL`의 Required 2건만 좁게 처리한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실제 구현을 차단할 때만 `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션을 채우고 중단한다. 환경/secret/서비스 준비, 검증 증거 공백, 일반 범위 조정은 사용자 리뷰 요청이 아니다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md` +- Task ids: + - `alias-contract`: `models[].id` canonical routing key와 `models[].providers` provider id -> served model map 검증 + - `candidate-schema`: `nodes[].providers[]` provider id/type/category/served models/health/capacity/in-flight/queued/coarse lifecycle 표현 +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md` +- Acceptance Scenarios: + - `S01`: model alias provider pool contract + - `S02`: provider candidate schema +- Evidence Map: + - `S01`, `S02`는 이 task directory의 `complete.log`에 Roadmap Completion과 Spec Completion 근거가 있어야 한다. + +## Archive Evidence Snapshot + +- Archived plan history: + - `agent-task/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_0.log` + - `agent-task/m-provider-catalog-device-status/01_pool_schema/plan_local_G06_1.log` +- Archived review history: + - `agent-task/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_0.log` + - `agent-task/m-provider-catalog-device-status/01_pool_schema/code_review_local_G06_1.log` +- Latest verdict: FAIL +- Required summary: + - `packages/go/config/config.go` still permits the same provider id on different nodes. Because `models[].providers` is a top-level provider id map, this makes catalog references ambiguous and merges served model sets. + - `apps/edge/internal/service/model_queue.go` does not fallback to provider id when `nodes[].providers[].adapter` is empty; stats lookup uses an empty key and drops queue state. +- Verification evidence from previous loop: + - `go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestModelCatalogEntry|TestNodeProviderConf' -count=1`: passed. + - `go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider' -count=1`: passed. + - `go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/node -count=1`: passed. + - `go test ./...`: passed. + - `git diff --check`: passed. + - `make proto`: passed. +- Affected files for this follow-up: + - `packages/go/config/config.go` + - `packages/go/config/config_test.go` + - `apps/edge/internal/service/model_queue.go` + - `apps/edge/internal/service/status_provider_test.go` or adjacent service test +- Narrow archive reread allowed if needed: the four archived files listed above only. + +## 범위 결정 근거 + +- 후속 범위는 code-review Required 2건으로 제한한다. +- `configs/edge.yaml`, README, proto field generation, and the first-loop membership validation are not reopened unless the narrow fixes require a direct consistency update. +- Roadmap/SDD Target은 그대로 유지한다. S01/S02 completion은 provider id reference determinism and status projection fallback까지 충족된 뒤 PASS 시 `complete.log`에 복사된다. +- Roadmap 문서 상태 갱신이나 archive는 이 plan 범위가 아니다. + +## 구현 체크리스트 + +- [ ] [REVIEW_REVIEW_API-1] `nodes[].providers[].id`가 전체 Edge config에서 catalog reference 기준으로 유일하도록 validation과 전역 중복 회귀 테스트를 추가한다. +- [ ] [REVIEW_REVIEW_API-2] provider snapshot stats key가 `adapter` 미설정 시 provider id로 fallback하도록 수정하고 adapter 없는 provider fixture로 `InFlight`, `Queued`, `LoadRatio` 회귀 테스트를 추가한다. +- [ ] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 붙여 넣는다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## REVIEW_REVIEW_API-1 - provider id global uniqueness + +### 문제 + +`models[].providers`는 provider id만 key로 가진 top-level map이다. 같은 provider id가 여러 Node에 존재하면 어느 Node provider를 참조하는지 구분할 수 없다. 현재 `LoadEdge`는 node 내부 중복만 막고, 전역 `providerIDs` map과 served model index는 중복 id를 합쳐 버린다. + +### 변경 + +`LoadEdge`에서 provider id를 전체 `nodes[].providers[]` 기준으로 유일하게 검증한다. 같은 provider id가 두 노드에 나오면 node index 또는 node id/alias와 provider id를 포함한 명확한 에러를 반환한다. 이 변경은 `models[].providers`의 결정성을 보장하기 위한 validation foundation이다. + +테스트는 최소 아래를 포함한다. + +- 두 노드가 같은 provider id를 갖는 config가 실패한다. +- 에러 메시지는 duplicate provider id와 양쪽 노드 위치를 판단할 수 있어야 한다. +- 기존 같은 노드 내부 중복 테스트도 계속 통과한다. + +### 중간 검증 + +```bash +go test ./packages/go/config -run 'TestLoadEdge_ModelCatalog|TestLoadEdge_NodeProviderConf|TestNodeProviderConf' -count=1 +``` + +## REVIEW_REVIEW_API-2 - provider snapshot adapter fallback + +### 문제 + +`nodes[].providers[].adapter`가 비어 있어도 config validation은 통과한다. 그런데 provider snapshot stats key는 `resolveSnapshotAdapterName(rec, prov.Adapter, prov.ID)` 결과를 그대로 써서, empty adapter인 경우 provider id fallback이 아니라 빈 문자열로 queue state를 조회한다. + +### 변경 + +provider snapshot stats key helper를 명확히 만든다. + +- `strings.TrimSpace(prov.Adapter) != ""`이면 기존 adapter/instance resolve path를 사용한다. +- adapter가 비어 있으면 `prov.ID`를 stats key로 사용한다. +- provider id와 adapter key가 다른 기존 테스트는 유지한다. +- adapter field가 없는 provider fixture에서 queue group `adapter`가 provider id인 경우 `InFlight`, `Queued`, `LoadRatio`가 반영되는 테스트를 추가한다. + +### 중간 검증 + +```bash +go test ./apps/edge/internal/service -run 'TestListNodeSnapshots|TestModelQueue|TestProvider' -count=1 +``` + +## 최종 검증 + +```bash +go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/node -count=1 +go test ./... +git diff --check +``` + +기대 결과: provider id reference determinism과 provider snapshot fallback 테스트가 통과하고, 전체 Go 회귀와 whitespace 검증이 통과한다. diff --git a/agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/CODE_REVIEW-local-G07.md b/agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/CODE_REVIEW-local-G07.md new file mode 100644 index 0000000..6f8836c --- /dev/null +++ b/agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/CODE_REVIEW-local-G07.md @@ -0,0 +1,109 @@ + +# 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 근거가 있어야 한다. + +## 구현 체크리스트 + +- [ ] `apps/edge/internal/openai` handler/route resolution에서 canonical client model alias를 유지하고 provider pool 대상인지 service layer에 전달한다. +- [ ] `apps/edge/internal/service/run_dispatch.go`의 candidate resolution이 `models[].providers`와 `nodes[].providers[]`를 기준으로 provider 후보를 만들도록 확장한다. +- [ ] Queue candidate/model group에 provider id, node id, concrete served target, capacity/in-flight/queued 상태를 보존하고 선택 후 Node 요청은 selected provider의 target으로 rewrite한다. +- [ ] `apps/edge/internal/service/model_queue.go`의 available candidate selection을 lowest `in_flight / capacity` 우선, deterministic tie-break 순서로 바꾸고 queue timeout 동작을 유지한다. +- [ ] status/provider snapshot에 선택 결과가 반영되도록 in-flight/queued/load-ratio 업데이트 경로를 보강한다. +- [ ] OpenAI/service/model_queue/status tests에 provider pool dispatch, target rewrite, load-ratio ranking, deterministic tie-break, timeout 회귀를 추가한다. +- [ ] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G07.md`에 붙여 넣는다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 구현 노트 + +_구현 에이전트가 실제 변경 파일, 주요 결정, compatibility 유지 방식을 채운다._ + +## 계획 대비 변경 사항 + +_계획과 달라진 구현, 검증 명령 변경, 후속 작업이 있으면 채운다. `01_pool_schema`가 plan과 다른 type/field 이름을 확정했다면 여기에 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `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' -count=1 +``` + +```text + +``` + +### API-2 중간 검증 + +```bash +go test ./apps/edge/internal/service -run 'TestModelQueue.*Provider|TestSubmitRun.*Provider|TestService.*Provider|TestBuildRunRequest' -count=1 +``` + +```text + +``` + +### API-3 중간 검증 + +```bash +go test ./apps/edge/internal/service -run 'TestModelQueue.*Timeout|TestProviderStatus|TestStatusProvider' -count=1 +``` + +```text + +``` + +### 최종 검증 + +```bash +go test ./apps/edge/internal/openai ./apps/edge/internal/service -count=1 +git diff --check +``` + +```text + +``` + +## 리뷰 결과 + +_code-review 에이전트 전용._ + +- 판정: 미실행 +- 필수 수정: 없음 +- 제안: 없음 +- Nit: 없음 diff --git a/agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/PLAN-local-G07.md b/agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/PLAN-local-G07.md new file mode 100644 index 0000000..562d77b --- /dev/null +++ b/agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/PLAN-local-G07.md @@ -0,0 +1,122 @@ + +# Plan - API + +## 이 파일을 읽는 구현 에이전트에게 + +구현이 끝났다고 보고하기 전에 반드시 `CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다. 검증 명령을 실행하고 실제 stdout/stderr를 남기며, active plan/review 파일은 그대로 둔 상태에서 review-ready만 보고한다. 선택된 SDD 결정이나 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 중단한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`, `complete.log`, archive log를 작성하지 않는다. 환경/secret/서비스 준비, 일반 범위 조정, 검증 증거 공백은 그 자체로 사용자 리뷰 요청이 아니다. + +## 배경 + +Provider pool schema가 준비되면 Edge dispatch는 canonical model alias를 유지한 채 provider 후보를 고르고, 선택된 provider가 제공하는 실제 served model로 target을 rewrite해야 한다. 현재 queue는 `adapter + target`이 같은 첫 available 후보를 고르는 구조라 provider id별 load ratio 비교와 deterministic tie-break를 표현하지 못한다. 이 계획은 schema foundation 이후 runtime dispatch policy만 다룬다. + +## 사용자 리뷰 요청 흐름 + +선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실제 구현을 차단할 때만 `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 근거를 채운다. 구현 중 직접 사용자에게 묻거나 채팅 선택지를 만들지 않는다. code-review 단계가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 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 근거가 있어야 한다. + +## 분석 결과 + +- 읽은 규칙/계약: `agent-ops/rules/project/rules.md`, `agent-ops/rules/common/rules-roadmap.md`, `agent-ops/skills/common/router.md`, `agent-ops/skills/common/plan/SKILL.md`, `agent-ops/skills/common/roadmap-sdd/SKILL.md`, `agent-ops/skills/common/update-roadmap/SKILL.md`, `agent-contract/index.md`, `agent-contract/provided/openai-compatible-api.md`. +- 읽은 Roadmap/SDD: `agent-roadmap/current.md`, active Phase, active Milestone, 승인된 SDD. SDD는 `[승인됨]`, SDD 잠금은 `해제`, active `USER_REVIEW.md`는 없다. +- 읽은 domain/test rules: edge, platform-common, testing, `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`. +- 읽은 source: `packages/go/config/config.go`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/responses_handler.go`, `apps/edge/internal/openai/routes.go`, `apps/edge/internal/openai/server.go`, `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/service/model_queue.go`, `apps/edge/internal/service/status_provider.go`, `proto/iop/runtime.proto`, `configs/edge.yaml`, `apps/edge/README.md`. +- 읽은 tests: `packages/go/config/config_test.go`, `apps/edge/internal/openai/server_test.go`, `apps/edge/internal/service/model_queue_test.go`, `apps/edge/internal/service/service_internal_test.go`, `apps/edge/internal/service/service_test.go`, `apps/edge/internal/service/status_provider_test.go`. +- 기존 커버리지: OpenAI handler가 client model을 `ModelGroupKey`로 유지하는 경로, route별 adapter/target dispatch, queue timeout/admission/release는 테스트가 있다. Provider id 후보 선택, selected provider target rewrite, load-ratio ranking, tie-break ordering은 테스트가 없다. +- 분할 판단: 이 subtask는 `02+01_edge_dispatch`로 predecessor `01_pool_schema`에 의존한다. 현재 같은 task group 안에 `agent-task/m-provider-catalog-device-status/01_pool_schema/PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`가 active로 존재하므로 predecessor completion은 미충족이다. 구현은 `01_pool_schema/complete.log`가 PASS로 생성된 뒤 시작한다. +- 의존 관계 및 구현 순서: `01_pool_schema`가 `models[]`, `nodes[].providers[]`, provider snapshot/status field를 확정한 다음, 이 plan이 그 type을 소비한다. predecessor가 plan 대비 다른 field 이름을 채택했다면 이 plan의 변경 지점을 그 실제 type에 맞추고 `계획 대비 변경 사항`에 기록한다. +- 심볼 참조: 기존 symbol rename/remove는 계획하지 않는다. `SubmitRunRequest`, `candidateNode`, `modelQueueGroup`, `resolveQueueCandidates`, `resolveRouteDispatch`, `BuildRunRequest`에는 field 추가와 selection path 분기가 필요하다. +- dependency manifest: 새 외부 package는 추가하지 않는다. +- pre-check compile: queue candidate에 provider id와 served target을 싣고, 선택 시점 이후 `RunRequest.Target`에 concrete served model을 넣어야 기존 Node adapter boundary를 유지할 수 있다. +- build grade: local-G07. Edge OpenAI input, service dispatch, queue policy, status를 함께 변경하지만 local Go tests로 반복 검증할 수 있다. + +## 구현 체크리스트 + +- [ ] `apps/edge/internal/openai` handler/route resolution에서 canonical client model alias를 유지하고 provider pool 대상인지 service layer에 전달한다. +- [ ] `apps/edge/internal/service/run_dispatch.go`의 candidate resolution이 `models[].providers`와 `nodes[].providers[]`를 기준으로 provider 후보를 만들도록 확장한다. +- [ ] Queue candidate/model group에 provider id, node id, concrete served target, capacity/in-flight/queued 상태를 보존하고 선택 후 Node 요청은 selected provider의 target으로 rewrite한다. +- [ ] `apps/edge/internal/service/model_queue.go`의 available candidate selection을 lowest `in_flight / capacity` 우선, deterministic tie-break 순서로 바꾸고 queue timeout 동작을 유지한다. +- [ ] status/provider snapshot에 선택 결과가 반영되도록 in-flight/queued/load-ratio 업데이트 경로를 보강한다. +- [ ] OpenAI/service/model_queue/status tests에 provider pool dispatch, target rewrite, load-ratio ranking, deterministic tie-break, timeout 회귀를 추가한다. +- [ ] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G07.md`에 붙여 넣는다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## API-1 - Provider pool route resolution + +### 문제 + +`apps/edge/internal/openai/chat_handler.go`와 `responses_handler.go`는 request model을 `ModelGroupKey`로 유지하지만, `resolveRouteDispatch`는 legacy route의 단일 target만 반환한다. Provider pool에서는 외부 alias와 concrete served target이 분리되어야 한다. + +### 변경 + +Provider pool model alias가 존재하면 OpenAI handler는 client model alias를 canonical key로 유지하고 service layer에는 provider-pool resolution을 요청한다. Legacy `openai.model_routes`는 provider pool alias가 없을 때 fallback으로 유지한다. `/v1/models`는 provider pool `models[].id`를 우선 노출하고, 없으면 legacy catalog를 노출한다. + +### 중간 검증 + +```bash +go test ./apps/edge/internal/openai -run 'TestOpenAI.*Model|TestOpenAI.*Route|TestOpenAI.*Provider' -count=1 +``` + +기대 결과: 기존 route tests와 새 provider pool catalog/dispatch tests가 모두 통과한다. + +## API-2 - Candidate selection and target rewrite + +### 문제 + +`resolveQueueCandidates`는 adapter/target 일치 여부로 후보를 좁히고, `model_queue.go`는 available 후보 중 첫 번째 후보를 선택한다. 이 구조는 provider id별 served model rewrite, provider capacity comparison, deterministic tie-break를 표현하지 못한다. + +### 변경 + +`candidateNode`에 provider id, provider category/type, served target, load metric source를 추가한다. Provider pool request는 `models[].providers` 순서와 provider id reference를 기준으로 후보를 만들고, queue admission은 available 후보의 `in_flight / capacity`가 가장 낮은 provider를 선택한다. 동률은 provider id, node id, adapter/name 같은 stable key로 정렬해 deterministic하게 처리한다. 선택 후 `BuildRunRequest` 직전 `req.Target` 또는 선택 결과 target을 concrete served model name으로 바꾼다. + +### 중간 검증 + +```bash +go test ./apps/edge/internal/service -run 'TestModelQueue.*Provider|TestSubmitRun.*Provider|TestService.*Provider|TestBuildRunRequest' -count=1 +``` + +기대 결과: provider pool request가 lowest load-ratio provider를 선택하고, Node RunRequest target이 selected provider served model로 rewrite된다. + +## API-3 - Queue timeout and status projection + +### 문제 + +Queue timeout과 provider snapshot은 legacy adapter/target group 기준으로 검증되어 있다. Provider pool selection이 들어가도 queued/in-flight accounting과 timeout이 alias-level queue key와 provider-level status에 일관되게 반영되어야 한다. + +### 변경 + +Queue group key는 `models[].id` canonical alias를 사용하고, provider-level in-flight/queued/load ratio projection은 선택 provider와 queued group 상태에서 계산한다. 기존 adapter snapshot field는 compatibility를 위해 유지한다. + +### 중간 검증 + +```bash +go test ./apps/edge/internal/service -run 'TestModelQueue.*Timeout|TestProviderStatus|TestStatusProvider' -count=1 +``` + +기대 결과: timeout tests가 유지되고 provider status에 in-flight/queued/load-ratio가 반영된다. + +## 최종 검증 + +```bash +go test ./apps/edge/internal/openai ./apps/edge/internal/service -count=1 +git diff --check +``` + +기대 결과: OpenAI input/service queue/status 관련 Go tests가 통과하고 whitespace error가 없다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/README.md b/apps/edge/README.md index d35ba5c..f8690ea 100644 --- a/apps/edge/README.md +++ b/apps/edge/README.md @@ -223,6 +223,84 @@ curl -s http://127.0.0.1:18081/v1/responses \ vLLM 같은 OpenAI-compatible inference server는 `openai_compat` adapter와 `provider: "vllm"` instance로 연결한다. 이 경로는 `/v1/models`, non-streaming/streaming `/v1/chat/completions`, provider header, request `options` 전달을 지원하며, Edge의 model route alias를 실제 served model로 매핑한다. +#### Model Provider Pool 호환 방향 + +현재 `openai.model_routes`는 외부 model id를 단일 `adapter + target`으로 연결하는 호환 catalog다. 새 provider pool 계약에서는 `models[].id`를 OpenAI-compatible model key이자 queue/routing canonical key로 유지하고, `models[].providers`가 provider id별 실제 served model name을 가리킨다. + +마이그레이션 기준은 1:1 route를 단일 provider를 가진 `models[]` 항목으로 표현하는 것이다. 기존 Node provider instance 설정(`openai_compat_instances`, legacy `vllm` instance 등)은 provider id, provider type/category, served models, capacity/queue policy를 채우는 입력으로 사용하고, Edge는 선택된 provider의 served model name으로 target을 rewrite한 뒤 Node에는 기존처럼 `adapter + target` 실행 요청을 전달한다. provider pool이 구현되기 전까지 `openai.model_routes`는 fallback/compat 경로로 유지한다. + +최신 provider pool schema 예시: + +```yaml +# models[] 는 top-level Edge config field로, canonical routing key와 +# provider pool mapping을 정의한다. +models: + - id: "qwen3.6:35b" + display_name: "Qwen 3.6 35B" + providers: + vllm-gpu: "nvidia/Qwen3.6-35B-A3B-NVFP4" + ollama-local: "qwen3.6:35b" + +# nodes[].providers[] 는 provider candidate schema다. +# models[].providers 의 key 와 매칭되어야 한다. +nodes: + - id: "node-gpu-01" + alias: "gpu-node" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" + adapter: "openai_compat" + models: + - "nvidia/Qwen3.6-35B-A3B-NVFP4" + capacity: 4 + max_queue: 16 + queue_timeout_ms: 30000 + lifecycle_capabilities: + - "list_models" + - id: "ollama-local" + type: "ollama" + category: "local_inference" + adapter: "ollama" + models: + - "qwen3.6:35b" + capacity: 2 + max_queue: 8 + queue_timeout_ms: 30000 + lifecycle_capabilities: + - "list_models" + - "load_model" + - "unload_model" + - "pull_model" +``` + +migration 예시 — 기존 route를 provider pool로 이동: + +```yaml +# 기존 (legacy) +openai: + model_routes: + - model: "codex" + adapter: "cli" + target: "codex" + +# 위를 provider pool로 표현하면 아래 와 같다. +models: + - id: "codex" + providers: + cli-codex: "codex" + +nodes: + - id: "node-cli-01" + providers: + - id: "cli-codex" + type: "cli" + category: "cli" + adapter: "cli" + models: + - "codex" +``` + ### A2A Agent Input A2A JSON-RPC HTTP API는 NomadCode Core나 외부 agent가 Edge 실행 그룹에 작업을 위임하고 `Task` 상태, artifact, cancel/polling을 공유해야 할 때 사용하는 입력 표면이다. OpenAI-compatible API가 단순 모델/chat completion 호환에 맞는 경로라면, A2A는 agent-to-agent 작업 위임과 상태 공유에 맞는 경로다. diff --git a/apps/edge/internal/node/store.go b/apps/edge/internal/node/store.go index 4a2656c..5a34488 100644 --- a/apps/edge/internal/node/store.go +++ b/apps/edge/internal/node/store.go @@ -16,6 +16,7 @@ type NodeRecord struct { AgentKind string Index int Adapters config.AdaptersConf + Providers []config.NodeProviderConf Runtime config.RuntimeConf } @@ -106,6 +107,7 @@ func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) { AgentKind: agentKind, Index: i, Adapters: adapters, + Providers: d.Providers, Runtime: d.Runtime, }) } diff --git a/apps/edge/internal/service/model_queue.go b/apps/edge/internal/service/model_queue.go index f664c85..e32908b 100644 --- a/apps/edge/internal/service/model_queue.go +++ b/apps/edge/internal/service/model_queue.go @@ -433,6 +433,51 @@ func (m *modelQueueManager) getSnapshotForNode(nodeID string, rec *edgenode.Node }) } + // 5. Provider catalog from nodes[].providers[] config (MVP provider pool schema). + // These snapshots carry additional catalog fields (id, type, category, + // served_models, health, lifecycle_capabilities) while keeping the legacy + // adapter/status/capacity/in_flight/queued fields for existing consumers. + // REVIEW_API-2: use prov.Adapter when set to correctly resolve queue state + // that may be keyed by an adapter/instance key different from provider id. + for _, prov := range rec.Providers { + if prov.ID == "" { + continue + } + capVal := prov.Capacity + if capVal <= 0 { + capVal = concurrencyFallback + } + statsKey, _ := resolveSnapshotAdapterName(rec, prov.Adapter, prov.ID) + inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, statsKey) + + servedModels := make([]string, len(prov.Models)) + copy(servedModels, prov.Models) + + lifecycleCaps := make([]string, len(prov.LifecycleCapabilities)) + copy(lifecycleCaps, prov.LifecycleCapabilities) + + // Compute load_ratio from in_flight and capacity. + var loadRatio float32 + if capVal > 0 { + loadRatio = float32(inflight) / float32(capVal) + } + + snaps = append(snaps, &iop.ProviderSnapshot{ + Adapter: prov.Adapter, + Status: "available", // health-based projection is done by the caller + Capacity: int32(capVal), + InFlight: int32(inflight), + Queued: int32(queued), + Id: prov.ID, + Type: prov.Type, + Category: string(prov.Category), + ServedModels: servedModels, + Health: prov.Health, + LoadRatio: loadRatio, + LifecycleCapabilities: lifecycleCaps, + }) + } + return snaps } @@ -456,7 +501,15 @@ func (m *modelQueueManager) getStatsForAdapterLocked(nodeID string, rec *edgenod return } +// resolveSnapshotAdapterName returns the adapter/instance key used to match +// queue state for a provider snapshot. +// When adapterType is empty it falls back to the provider id (target) so that +// queue stats are still resolved instead of being dropped on an empty key. func resolveSnapshotAdapterName(rec *edgenode.NodeRecord, adapterType, target string) (string, bool) { + // Fallback to provider id when adapter is not set. + if adapterType == "" { + adapterType = target + } if rec == nil { return adapterType, true } diff --git a/apps/edge/internal/service/status_provider_test.go b/apps/edge/internal/service/status_provider_test.go index 97e3e60..a415dff 100644 --- a/apps/edge/internal/service/status_provider_test.go +++ b/apps/edge/internal/service/status_provider_test.go @@ -131,3 +131,173 @@ func TestListNodeSnapshotsTypeRouteSingleNamedInstance(t *testing.T) { t.Errorf("expected queued 1, got %d", p.Queued) } } + +// REVIEW_API-2: provider id와 adapter key가 다른 경우에도 queue stats가 올바르게 반영되는지 검증. +// provider id는 "provider-vllm-primary"지만, adapter/instance key는 "vllm-gpu"이다. +// queue group에 adapter="vllm-gpu"로 inject하면 provider snapshot이 이 stats를 올바르게 반영해야 한다. +// REVIEW_REVIEW_API-2: adapter가 비어 있을 때 provider id로 fallback하여 +// queue stats(InFlight, Queued, LoadRatio)가 올바르게 반영되는지 검증. +func TestListNodeSnapshotsProviderEmptyAdapterFallbackToProviderID(t *testing.T) { + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{NodeID: "node-e-1", Alias: "node-e-1"}) + + // Node has a provider with empty adapter. Queue state is keyed by provider id. + store := edgenode.NewNodeStore() + rec := &edgenode.NodeRecord{ + ID: "node-e-1", + Adapters: config.AdaptersConf{ + // No OllamaInstances/vllm_instances matching the provider id. + // This ensures the fallback path is tested. + }, + Providers: []config.NodeProviderConf{ + { + ID: "standalone-provider", + Type: "cli", + Category: config.CategoryCLI, + Adapter: "", // empty — should fallback to provider id + Models: []string{"test-model"}, + Capacity: 2, + }, + }, + Runtime: config.RuntimeConf{Concurrency: 2}, + } + store.Add(rec) + + bus := edgeevents.NewBus() + svc := New(reg, bus) + svc.SetNodeStore(store) + + // Inject queue group state keyed by provider id "standalone-provider". + svc.queue.mu.Lock() + svc.queue.groups["test-group"] = &modelQueueGroup{ + key: "test-group", + adapter: "standalone-provider", // adapter type is irrelevant; queue is keyed by provider id + inflight: map[string]int{"node-e-1": 1}, + queue: []*queueItem{ + { + candidates: []candidateNode{ + {entry: reg.All()[0], capacity: 2}, + }, + }, + }, + } + svc.queue.mu.Unlock() + + snaps := svc.ListNodeSnapshots() + if len(snaps) != 1 { + t.Fatalf("expected 1 node snapshot, got %d", len(snaps)) + } + snap := snaps[0] + + // Find the provider snapshot by its provider id. + found := false + for i, ps := range snap.ProviderSnapshots { + if ps.Id == "standalone-provider" { + found = true + if ps.InFlight != 1 { + t.Errorf("expected InFlight=1 for standalone-provider (adapter empty), got %d", ps.InFlight) + } + if ps.Queued != 1 { + t.Errorf("expected Queued=1 for standalone-provider (adapter empty), got %d", ps.Queued) + } + if ps.Capacity != 2 { + t.Errorf("expected Capacity=2 for standalone-provider, got %d", ps.Capacity) + } + // LoadRatio = InFlight / Capacity = 1 / 2 = 0.5 + expectedLoadRatio := float32(1) / float32(2) + if ps.LoadRatio != expectedLoadRatio { + t.Errorf("expected LoadRatio=%.2f for standalone-provider, got %.2f", expectedLoadRatio, ps.LoadRatio) + } + t.Logf("provider snapshot[%d]: id=%s, adapter=%s, InFlight=%d, Queued=%d, Capacity=%d, LoadRatio=%.2f", + i, ps.Id, ps.Adapter, ps.InFlight, ps.Queued, ps.Capacity, ps.LoadRatio) + break + } + } + if !found { + t.Fatalf("expected provider snapshot with id=standalone-provider, got %d provider snapshots", len(snap.ProviderSnapshots)) + } +} + +func TestListNodeSnapshotsProviderIdDiffersFromAdapterKey(t *testing.T) { + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{NodeID: "node-p-1", Alias: "node-p-1"}) + + // Node has providers[] with provider id "provider-vllm-primary" and adapter="vllm-gpu". + // The adapter field points to an existing OllamaInstance named "vllm-gpu" (used here as a stand-in for the adapter key). + store := edgenode.NewNodeStore() + rec := &edgenode.NodeRecord{ + ID: "node-p-1", + Adapters: config.AdaptersConf{ + OllamaInstances: []config.OllamaInstanceConf{ + { + Name: "vllm-gpu", + Enabled: true, + Capacity: 4, + }, + }, + }, + Providers: []config.NodeProviderConf{ + { + ID: "provider-vllm-primary", + Type: "vllm", + Category: config.CategoryAPI, + Adapter: "vllm-gpu", // adapter key differs from provider id + Models: []string{"nvidia/Qwen3.6-35B"}, + Capacity: 4, + }, + }, + Runtime: config.RuntimeConf{Concurrency: 2}, + } + store.Add(rec) + + bus := edgeevents.NewBus() + svc := New(reg, bus) + svc.SetNodeStore(store) + + // Inject queue group state with adapter="vllm-gpu" (matching the provider's Adapter field, not provider id). + svc.queue.mu.Lock() + svc.queue.groups["test-group"] = &modelQueueGroup{ + key: "test-group", + adapter: "vllm-gpu", // matches rec.Adapters.OllamaInstances[0].Name + inflight: map[string]int{"node-p-1": 2}, + queue: []*queueItem{ + { + candidates: []candidateNode{ + {entry: reg.All()[0], capacity: 4}, + }, + }, + }, + } + svc.queue.mu.Unlock() + + snaps := svc.ListNodeSnapshots() + if len(snaps) != 1 { + t.Fatalf("expected 1 node snapshot, got %d", len(snaps)) + } + snap := snaps[0] + + // Find the provider snapshot by its provider id. + var p *config.NodeProviderConf + for i := range snap.ProviderSnapshots { + ps := snap.ProviderSnapshots[i] + if ps.Id == "provider-vllm-primary" { + p = &config.NodeProviderConf{ + Capacity: int(ps.Capacity), + } + // Verify queue stats match the injected queue state. + if ps.InFlight != 2 { + t.Errorf("expected InFlight=2 for provider-vllm-primary, got %d", ps.InFlight) + } + if ps.Queued != 1 { + t.Errorf("expected Queued=1 for provider-vllm-primary, got %d", ps.Queued) + } + if ps.Adapter != "vllm-gpu" { + t.Errorf("expected Adapter=vllm-gpu for provider-vllm-primary, got %s", ps.Adapter) + } + break + } + } + if p == nil { + t.Fatalf("expected provider snapshot with id=provider-vllm-primary, got %d provider snapshots", len(snap.ProviderSnapshots)) + } +} diff --git a/configs/edge.yaml b/configs/edge.yaml index 567da9b..405484f 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -53,11 +53,18 @@ openai: adapter: "ollama" target: "" models: [] - # model_routes maps external model ids to internal adapter/target routing. + # model_routes is the current compatibility route catalog. It maps external + # model ids to internal adapter/target routing. # When set, /v1/models exposes these ids and /v1/chat/completions + /v1/responses # resolve adapter/target per-entry. Entries not matched fall back to target/adapter above. # workspace_required: true marks CLI agent routes that require a workspace path from the caller. # max_queue and queue_timeout_ms configure the Edge-owned queue policy for the model alias. + # + # Provider-pool migration direction: + # - Keep the external model id as the future models[].id canonical routing key. + # - Represent a 1:1 model_route as one models[] entry with one provider id. + # - Use the route target as models[].providers[provider_id], the concrete served model. + # - Continue sending adapter + target to Node after Edge rewrites the selected provider target. # model_routes (legacy/adapter/type reference): # - model: "codex" # adapter: "cli" @@ -97,7 +104,54 @@ openai: # max_queue: 10 # queue_timeout_ms: 30000 # + # === Provider-pool (models[] / nodes[].providers[]) example === + # Top-level models[] defines canonical routing keys and their provider-pool mapping. + # Each entry id is the external model id; providers map maps provider id → served model. + # models: + # - id: "qwen3.6:35b" + # display_name: "Qwen 3.6 35B" + # providers: + # vllm-gpu: "nvidia/Qwen3.6-35B-A3B-NVFP4" + # ollama-local: "qwen3.6:35b" + # + # nodes[].providers[] defines each provider candidate with catalog fields. + # nodes[].providers[].id is the stable provider identity referenced by models[].providers keys. + # nodes[].providers[].type — runtime type (vllm, ollama, lemonade, sglang, openai_api, cli). + # nodes[].providers[].category — "api", "cli", or "local_inference". + # nodes[].providers[].adapter — concrete adapter instance key used by edge to dispatch. + # nodes[].providers[].models — served model names this provider can serve. + # nodes[].providers[].health — observed health state string. + # nodes[].providers[].capacity — max concurrent execution slots. + # nodes[].providers[].max_queue — max queue depth (per-provider). + # nodes[].providers[].queue_timeout_ms — queue timeout in milliseconds. + # nodes[].providers[].lifecycle_capabilities — coarse lifecycle capabilities list. + # + # Example node with provider pool: # nodes: + # - id: "node-gpu-01" + # alias: "gpu-node" + # providers: + # - id: "vllm-gpu" + # type: "vllm" + # category: "api" + # adapter: "vllm-gpu" + # models: + # - "nvidia/Qwen3.6-35B-A3B-NVFP4" + # health: "healthy" + # capacity: 4 + # max_queue: 16 + # queue_timeout_ms: 30000 + # lifecycle_capabilities: ["scale_up", "scale_down"] + # - id: "ollama-local" + # type: "ollama" + # category: "local_inference" + # adapter: "ollama-local" + # models: + # - "qwen3.6:35b" + # health: "healthy" + # capacity: 2 + # max_queue: 8 + # queue_timeout_ms: 30000 # - id: "node-vllm-01" # alias: "vllm-gpu-node" # token: "" diff --git a/packages/go/config/config.go b/packages/go/config/config.go index c7b0c03..378b0c2 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -41,6 +41,10 @@ type EdgeConfig struct { Console EdgeConsoleConf `mapstructure:"console" yaml:"console"` ControlPlane EdgeControlPlaneConf `mapstructure:"control_plane" yaml:"control_plane"` Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"` + // Models is the top-level model catalog. Each entry defines a canonical + // routing key (ID) and its provider-pool mapping. When set it takes + // precedence over the legacy openai.model_routes for runtime dispatch. + Models []ModelCatalogEntry `mapstructure:"models" yaml:"models,omitempty"` } // EdgeControlPlaneConf holds the outbound connector settings for the @@ -66,9 +70,83 @@ type NodeDefinition struct { Token string `mapstructure:"token" yaml:"token"` AgentKind string `mapstructure:"agent_kind" yaml:"agent_kind"` // generic-node (default) Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"` + Providers []NodeProviderConf `mapstructure:"providers" yaml:"providers,omitempty"` Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"` } +// Category represents the provider category. +type Category string + +const ( + CategoryAPI Category = "api" + CategoryCLI Category = "cli" + CategoryLocalInference Category = "local_inference" +) + +// validateCategory returns nil when category is valid. +func validateCategory(c Category) error { + switch c { + case CategoryAPI, CategoryCLI, CategoryLocalInference: + return nil + default: + return fmt.Errorf("invalid provider category %q (allowed: %q, %q, %q)", c, CategoryAPI, CategoryCLI, CategoryLocalInference) + } +} + +// NodeProviderConf defines a provider candidate that belongs to a node. +type NodeProviderConf struct { + // ID uniquely identifies this provider within the node and is referenced + // by models[].providers keys. + ID string `mapstructure:"id" yaml:"id"` + // Type is the runtime type (e.g. "ollama", "vllm", "lemonade", "sglang", "openai_api", "cli"). + Type string `mapstructure:"type" yaml:"type"` + // Category classifies the provider; MVP values: api, cli, local_inference. + Category Category `mapstructure:"category" yaml:"category"` + // Adapter is the concrete adapter instance key used by the edge to dispatch. + Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"` + // Models lists the served model names this provider can actually serve. + Models []string `mapstructure:"models" yaml:"models,omitempty"` + // Health is the observed provider health state. + Health string `mapstructure:"health" yaml:"health,omitempty"` + // Capacity is the maximum concurrent execution slots. + Capacity int `mapstructure:"capacity" yaml:"capacity,omitempty"` + // MaxQueue is the maximum queue depth. + MaxQueue int `mapstructure:"max_queue" yaml:"max_queue,omitempty"` + // QueueTimeoutMS is the queue timeout in milliseconds. + QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,omitempty"` + // LifecycleCapabilities lists coarse lifecycle capabilities. + LifecycleCapabilities []string `mapstructure:"lifecycle_capabilities" yaml:"lifecycle_capabilities,omitempty"` +} + +// Validate checks internal consistency of the provider candidate config. +func (p NodeProviderConf) Validate() error { + id := strings.TrimSpace(p.ID) + if id == "" { + return fmt.Errorf("nodes[].providers[].id must not be empty") + } + if p.Type == "" { + return fmt.Errorf("nodes[].providers[%q]: type must not be empty", id) + } + if err := validateCategory(p.Category); err != nil { + return fmt.Errorf("nodes[].providers[%q]: %w", id, err) + } + for i, m := range p.Models { + if strings.TrimSpace(m) == "" { + return fmt.Errorf("nodes[].providers[%q].models[%d]: served model name must not be empty", id, i) + } + } + if p.Capacity < 0 { + return fmt.Errorf("nodes[].providers[%q].capacity must be non-negative", id) + } + if p.MaxQueue < 0 { + return fmt.Errorf("nodes[].providers[%q].max_queue must be non-negative", id) + } + if p.QueueTimeoutMS < 0 { + return fmt.Errorf("nodes[].providers[%q].queue_timeout_ms must be non-negative", id) + } + return nil +} + type NodeInfo struct { ID string `mapstructure:"id" yaml:"id"` Name string `mapstructure:"name" yaml:"name"` @@ -99,6 +177,76 @@ type OpenAIRouteEntry struct { WorkspaceRequired bool `mapstructure:"workspace_required" yaml:"workspace_required,omitempty"` } +// ModelCatalogEntry is a top-level Edge config entry that defines a canonical +// routing key (`ID`) and its provider-pool mapping. Each provider id key +// maps to the concrete served model name that the provider actually exposes. +type ModelCatalogEntry struct { + // ID is the canonical routing key (e.g. "qwen3.6:35b") that matches the + // external OpenAI-compatible model field. + ID string `mapstructure:"id" yaml:"id"` + // DisplayName is a human-readable name shown in operation dashboards. + DisplayName string `mapstructure:"display_name" yaml:"display_name,omitempty"` + // Providers maps provider id to the concrete served model name that the + // provider actually exposes. Keys must match nodes[].providers[].id. + Providers map[string]string `mapstructure:"providers" yaml:"providers"` +} + +// buildProviderServedModelsIndex aggregates all nodes[].providers[].models +// keyed by provider id. Returns the index for Validate. +func buildProviderServedModelsIndex(nodes []NodeDefinition) map[string]map[string]struct{} { + serveModels := make(map[string]map[string]struct{}) + for i := range nodes { + for _, p := range nodes[i].Providers { + if _, ok := serveModels[p.ID]; !ok { + serveModels[p.ID] = make(map[string]struct{}) + } + for _, m := range p.Models { + serveModels[p.ID][m] = struct{}{} + } + } + } + return serveModels +} + +// Validate checks the structure and internal references of the model catalog entry. +// When serveModels is non-nil, it also verifies that each models[].providers[pid]=model +// refers to a served model that exists in nodes[].providers[id].models. +func (e ModelCatalogEntry) Validate(resolvedProviderIDs map[string]struct{}, serveModels map[string]map[string]struct{}) error { + id := strings.TrimSpace(e.ID) + if id == "" { + return fmt.Errorf("models[].id must not be empty") + } + if len(e.Providers) == 0 { + return fmt.Errorf("models[%q].providers must not be empty", id) + } + for pid, model := range e.Providers { + p := strings.TrimSpace(pid) + if p == "" { + return fmt.Errorf("models[%q].providers: provider id must not be empty", id) + } + m := strings.TrimSpace(model) + if m == "" { + return fmt.Errorf("models[%q].providers[%q]: served model name must not be empty", id, p) + } + if _, ok := resolvedProviderIDs[p]; !ok { + return fmt.Errorf("models[%q].providers[%q]: provider id %q does not resolve to any nodes[].providers[].id", id, p, p) + } + // Serve model membership validation (REVIEW_API-1): + // The served model must be listed in the provider's own models[]. + if serveModels != nil { + servedSet, exists := serveModels[p] + if !exists { + // No providers list yet (should not happen in modern configs). + return fmt.Errorf("models[%q].providers[%q]: provider %q has no models[] defined", id, p, p) + } + if _, ok := servedSet[m]; !ok { + return fmt.Errorf("models[%q].providers[%q]: served model %q is not listed in nodes[].providers[%q].models", id, p, m, p) + } + } + } + return nil +} + type EdgeOpenAIConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Listen string `mapstructure:"listen" yaml:"listen"` @@ -326,6 +474,10 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) { if err := validateOpenAIRoutes(cfg.OpenAI.ModelRoutes); err != nil { return nil, err } + + // Collect all provider IDs from nodes[].providers[] for cross-referencing + // and validate uniqueness within each node. + providerIDs := make(map[string]struct{}) for i := range cfg.Nodes { kind, err := NormalizeAgentKind(cfg.Nodes[i].AgentKind) if err != nil { @@ -340,7 +492,43 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) { } return nil, fmt.Errorf("nodes[%d] %q adapters: %w", i, name, err) } + + // Validate unique provider IDs within this node and across all nodes. + seenProviderIDs := make(map[string]struct{}, len(cfg.Nodes[i].Providers)) + for j, p := range cfg.Nodes[i].Providers { + if err := p.Validate(); err != nil { + return nil, fmt.Errorf("nodes[%d].providers[%d]: %w", i, j, err) + } + if _, dup := seenProviderIDs[p.ID]; dup { + return nil, fmt.Errorf("nodes[%d].providers: duplicate provider id %q within node", i, p.ID) + } + seenProviderIDs[p.ID] = struct{}{} + if _, globalDup := providerIDs[p.ID]; globalDup { + return nil, fmt.Errorf("nodes[%d].providers[%d]: duplicate provider id %q across nodes (global uniqueness violation)", i, j, p.ID) + } + providerIDs[p.ID] = struct{}{} + } } + + // Build the provider->servedModels index so Validate can check membership. + serveModels := buildProviderServedModelsIndex(cfg.Nodes) + + // Validate models[].providers reference valid provider IDs. + seenModelIDs := make(map[string]struct{}, len(cfg.Models)) + for i, m := range cfg.Models { + id := strings.TrimSpace(m.ID) + if id == "" { + return nil, fmt.Errorf("models[%d]: id must not be empty", i) + } + if _, dup := seenModelIDs[id]; dup { + return nil, fmt.Errorf("models: duplicate model id %q", id) + } + seenModelIDs[id] = struct{}{} + if err := m.Validate(providerIDs, serveModels); err != nil { + return nil, fmt.Errorf("models[%d]: %w", i, err) + } + } + return &cfg, nil } diff --git a/packages/go/config/config_test.go b/packages/go/config/config_test.go index 8a3d509..7cd9e78 100644 --- a/packages/go/config/config_test.go +++ b/packages/go/config/config_test.go @@ -907,6 +907,40 @@ nodes: } } +func TestLoadEdge_NodeProviderGlobalUniqueness(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +nodes: + - alias: "node-a" + providers: + - id: "shared-provider" + type: "ollama" + category: "cli" + models: + - "model-a" + - alias: "node-b" + providers: + - id: "shared-provider" + type: "vllm" + category: "api" + models: + - "model-b" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected duplicate provider id error across nodes") + } + if !strings.Contains(err.Error(), "shared-provider") { + t.Fatalf("expected error to mention 'shared-provider', got %v", err) + } +} + func TestLoadEdge_ControlPlaneDefaults(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "edge.yaml") @@ -1627,6 +1661,638 @@ func TestNormalizeAdapters_OpenAICompatQueueValidation(t *testing.T) { } } +// S02 REVIEW_VLLM_CONFIG: vLLM OpenAI-compatible route + instance fixture +// verifies that alias/target/provider/endpoint/no-headers/timeout/queue are +// preserved through config load and mapper/factory contract. +// Provider pool schema validation tests (G06) + +func TestLoadEdge_ModelCatalogBasicHappyPath(t *testing.T) { + // Happy path: models[].providers values must match nodes[].providers[].models. + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +models: + - id: "qwen3.6:35b" + display_name: "Qwen 3.6 35B" + providers: + vllm-gpu: "nvidia/Qwen3.6-35B-A3B-NVFP4" + ollama-local: "qwen3.6:35b" +nodes: + - id: "node-gpu-01" + alias: "gpu-node" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" + models: + - "nvidia/Qwen3.6-35B-A3B-NVFP4" + capacity: 4 + - id: "ollama-local" + type: "ollama" + category: "local_inference" + models: + - "qwen3.6:35b" + capacity: 2 +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + cfg, err := config.LoadEdge(f) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(cfg.Models) != 1 { + t.Fatalf("expected 1 model catalog entry, got %d", len(cfg.Models)) + } + m := cfg.Models[0] + if m.ID != "qwen3.6:35b" { + t.Errorf("models[0].ID = %q, want %q", m.ID, "qwen3.6:35b") + } + if m.DisplayName != "Qwen 3.6 35B" { + t.Errorf("models[0].DisplayName = %q, want %q", m.DisplayName, "Qwen 3.6 35B") + } + if len(m.Providers) != 2 { + t.Fatalf("expected 2 providers, got %d", len(m.Providers)) + } + if m.Providers["vllm-gpu"] != "nvidia/Qwen3.6-35B-A3B-NVFP4" { + t.Errorf("providers[vllm-gpu] = %q, want %q", m.Providers["vllm-gpu"], "nvidia/Qwen3.6-35B-A3B-NVFP4") + } + if m.Providers["ollama-local"] != "qwen3.6:35b" { + t.Errorf("providers[ollama-local] = %q, want %q", m.Providers["ollama-local"], "qwen3.6:35b") + } + if len(cfg.Nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(cfg.Nodes)) + } + if len(cfg.Nodes[0].Providers) != 2 { + t.Fatalf("expected 2 node providers, got %d", len(cfg.Nodes[0].Providers)) + } + // Check category resolution. + for _, p := range cfg.Nodes[0].Providers { + if p.ID == "vllm-gpu" && p.Category != config.CategoryAPI { + t.Errorf("providers[vllm-gpu].Category = %q, want %q", p.Category, config.CategoryAPI) + } + if p.ID == "ollama-local" && p.Category != config.CategoryLocalInference { + t.Errorf("providers[ollama-local].Category = %q, want %q", p.Category, config.CategoryLocalInference) + } + } +} + +func TestLoadEdge_ModelCatalogEmptyModelIDRejected(t *testing.T) { + // No models[] on node, so serve model check is skipped (legacy compat). + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +models: + - id: "" + providers: + vllm-gpu: "model-a" +nodes: + - id: "node-01" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for empty model id") + } + if !strings.Contains(err.Error(), "models[0]") || !strings.Contains(err.Error(), "id must not be empty") { + t.Fatalf("expected error mentioning models[0] and id must not be empty, got %v", err) + } +} + +func TestLoadEdge_ModelCatalogDuplicateModelIDRejected(t *testing.T) { + // serve model membership fail → 다른 error로 먼저 실패해야 하지만 duplicate 체크가 + // 먼저 발생하므로 models validation 전에 seen check가 먼저 통과하는 fixture. + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +models: + - id: "qwen3.6:35b" + providers: + vllm-gpu: "model-a" + - id: "qwen3.6:35b" + providers: + ollama: "qwen3.6" +nodes: + - id: "node-01" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" + models: + - "model-a" + - id: "ollama" + type: "ollama" + category: "local_inference" + models: + - "qwen3.6" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for duplicate model id") + } + if !strings.Contains(err.Error(), "duplicate model id") { + t.Fatalf("expected error mentioning duplicate model id, got %v", err) + } +} + +func TestLoadEdge_ModelCatalogEmptyProvidersRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +models: + - id: "qwen3.6:35b" + providers: {} +nodes: + - id: "node-01" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for empty providers") + } + if !strings.Contains(err.Error(), "providers must not be empty") { + t.Fatalf("expected error mentioning providers must not be empty, got %v", err) + } +} + +func TestLoadEdge_ModelCatalogServedModelMembershipMissing(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +models: + - id: "qwen3.6:35b" + providers: + vllm-gpu: "typo-model" +nodes: + - id: "node-gpu-01" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" + models: + - "nvidia/Qwen3.6-35B-A3B-NVFP4" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for served model not in provider's models list") + } + if !strings.Contains(err.Error(), "typo-model") || !strings.Contains(err.Error(), "vllm-gpu") { + t.Fatalf("expected error mentioning typo-model and vllm-gpu, got %v", err) + } +} + +func TestLoadEdge_ModelCatalogUnresolvedProviderRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +models: + - id: "qwen3.6:35b" + providers: + non-existent: "model-a" +nodes: + - id: "node-01" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for unresolved provider id") + } + if !strings.Contains(err.Error(), "does not resolve") || !strings.Contains(err.Error(), "non-existent") { + t.Fatalf("expected error mentioning does not resolve and non-existent, got %v", err) + } +} + +func TestLoadEdge_ModelCatalogEmptyProviderIDRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +models: + - id: "qwen3.6:35b" + providers: + "": "model-a" +nodes: + - id: "node-01" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for empty provider id in models[].providers") + } + if !strings.Contains(err.Error(), "provider id must not be empty") { + t.Fatalf("expected error mentioning provider id must not be empty, got %v", err) + } +} + +func TestLoadEdge_ModelCatalogEmptyServedModelRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +models: + - id: "qwen3.6:35b" + providers: + vllm-gpu: "" +nodes: + - id: "node-01" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for empty served model name") + } + if !strings.Contains(err.Error(), "served model name must not be empty") { + t.Fatalf("expected error mentioning served model name must not be empty, got %v", err) + } +} + +func TestLoadEdge_NodeProviderConfEmptyIDRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-01" + providers: + - id: "" + type: "vllm" + category: "api" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for empty node provider id") + } + if !strings.Contains(err.Error(), "providers[].id must not be empty") { + t.Fatalf("expected error mentioning providers[].id must not be empty, got %v", err) + } +} + +func TestLoadEdge_NodeProviderConfEmptyTypeRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-01" + providers: + - id: "p1" + type: "" + category: "api" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for empty provider type") + } + if !strings.Contains(err.Error(), "type must not be empty") { + t.Fatalf("expected error mentioning type must not be empty, got %v", err) + } +} + +func TestLoadEdge_NodeProviderConfInvalidCategoryRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-01" + providers: + - id: "p1" + type: "vllm" + category: "invalid_category" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for invalid provider category") + } + if !strings.Contains(err.Error(), "invalid provider category") { + t.Fatalf("expected error mentioning invalid provider category, got %v", err) + } +} + +func TestLoadEdge_NodeProviderConfDuplicateIDRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-01" + providers: + - id: "p1" + type: "vllm" + category: "api" + - id: "p1" + type: "ollama" + category: "local_inference" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for duplicate provider id within a node") + } + if !strings.Contains(err.Error(), "duplicate provider id") { + t.Fatalf("expected error mentioning duplicate provider id, got %v", err) + } +} + +func TestLoadEdge_NodeProviderConfNegativeCapacityRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-01" + providers: + - id: "p1" + type: "vllm" + category: "api" + capacity: -1 +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for negative provider capacity") + } + if !strings.Contains(err.Error(), "capacity must be non-negative") { + t.Fatalf("expected error mentioning capacity must be non-negative, got %v", err) + } +} + +func TestLoadEdge_NodeProviderConfEmptyServedModelRejected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-01" + providers: + - id: "p1" + type: "vllm" + category: "api" + models: + - "" + - "real-model" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected error for empty served model in provider models list") + } + if !strings.Contains(err.Error(), "served model name must not be empty") { + t.Fatalf("expected error mentioning served model name must not be empty, got %v", err) + } +} + +func TestModelCatalogEntry_Validate(t *testing.T) { + providerIDs := map[string]struct{}{ + "vllm-gpu": {}, + "ollama-loc": {}, + } + + // Happy path with serveModels + serveModels := map[string]map[string]struct{}{ + "vllm-gpu": {"nvidia/Qwen3.6-35B": {}}, + "ollama-loc": {"qwen3.6:35b": {}}, + } + e := config.ModelCatalogEntry{ + ID: "qwen3.6:35b", + DisplayName: "Qwen 3.6 35B", + Providers: map[string]string{ + "vllm-gpu": "nvidia/Qwen3.6-35B", + "ollama-loc": "qwen3.6:35b", + }, + } + if err := e.Validate(providerIDs, serveModels); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Happy path without serveModels (legacy behavior) + e5 := config.ModelCatalogEntry{ + ID: "test", + Providers: map[string]string{"vllm-gpu": "m"}, + } + if err := e5.Validate(providerIDs, nil); err != nil { + t.Fatalf("expected no error with nil serveModels, got: %v", err) + } + + // Trimmed empty ID + e2 := config.ModelCatalogEntry{ + ID: " ", + Providers: map[string]string{"vllm-gpu": "m"}, + } + if err := e2.Validate(providerIDs, serveModels); err == nil { + t.Fatal("expected error for trimmed empty ID") + } + + // Empty providers map + e3 := config.ModelCatalogEntry{ + ID: "test", + Providers: map[string]string{}, + } + if err := e3.Validate(providerIDs, serveModels); err == nil { + t.Fatal("expected error for empty providers map") + } + + // Unresolved provider + e4 := config.ModelCatalogEntry{ + ID: "test", + Providers: map[string]string{ + "unknown-provider": "m", + }, + } + if err := e4.Validate(providerIDs, serveModels); err == nil { + t.Fatal("expected error for unresolved provider") + } +} + +func TestNodeProviderConf_Validate(t *testing.T) { + // Happy path + p := config.NodeProviderConf{ + ID: "p1", + Type: "vllm", + Category: config.CategoryAPI, + Models: []string{"model-a", "model-b"}, + Capacity: 4, + } + if err := p.Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Empty ID + p2 := config.NodeProviderConf{ID: "", Type: "vllm", Category: config.CategoryAPI} + if err := p2.Validate(); err == nil { + t.Fatal("expected error for empty ID") + } + + // Empty Type + p3 := config.NodeProviderConf{ID: "p1", Type: "", Category: config.CategoryAPI} + if err := p3.Validate(); err == nil { + t.Fatal("expected error for empty Type") + } + + // Invalid Category + p4 := config.NodeProviderConf{ID: "p1", Type: "vllm", Category: "invalid"} + if err := p4.Validate(); err == nil { + t.Fatal("expected error for invalid category") + } + + // Empty model in models list + p5 := config.NodeProviderConf{ + ID: "p1", + Type: "vllm", + Category: config.CategoryAPI, + Models: []string{"", "real"}, + } + if err := p5.Validate(); err == nil { + t.Fatal("expected error for empty model in list") + } + + // Negative capacity + p6 := config.NodeProviderConf{ + ID: "p1", + Type: "vllm", + Category: config.CategoryAPI, + Capacity: -1, + } + if err := p6.Validate(); err == nil { + t.Fatal("expected error for negative capacity") + } + + // Negative max_queue + p7 := config.NodeProviderConf{ + ID: "p1", + Type: "vllm", + Category: config.CategoryAPI, + MaxQueue: -1, + } + if err := p7.Validate(); err == nil { + t.Fatal("expected error for negative max_queue") + } + + // Negative queue_timeout_ms + p8 := config.NodeProviderConf{ + ID: "p1", + Type: "vllm", + Category: config.CategoryAPI, + QueueTimeoutMS: -1, + } + if err := p8.Validate(); err == nil { + t.Fatal("expected error for negative queue_timeout_ms") + } +} + +func TestLoadEdge_ProviderPoolLegacyCompatibility(t *testing.T) { + // Legacy openai.model_routes must still work alongside the new models[]/providers[]. + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +openai: + enabled: true + model_routes: + - model: "codex" + adapter: "cli" + target: "codex" + workspace_required: true +models: + - id: "qwen3.6:35b" + providers: + vllm-gpu: "nvidia/Qwen3.6-35B" +nodes: + - id: "node-gpu-01" + providers: + - id: "vllm-gpu" + type: "vllm" + category: "api" + models: + - "nvidia/Qwen3.6-35B" +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + cfg, err := config.LoadEdge(f) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(cfg.OpenAI.ModelRoutes) != 1 { + t.Fatalf("expected 1 legacy route, got %d", len(cfg.OpenAI.ModelRoutes)) + } + if len(cfg.Models) != 1 { + t.Fatalf("expected 1 models[] entry, got %d", len(cfg.Models)) + } +} + // S02 REVIEW_VLLM_CONFIG: vLLM OpenAI-compatible route + instance fixture // verifies that alias/target/provider/endpoint/no-headers/timeout/queue are // preserved through config load and mapper/factory contract. diff --git a/proto/gen/iop/runtime.pb.go b/proto/gen/iop/runtime.pb.go index d4ae9a8..6288fca 100644 --- a/proto/gen/iop/runtime.pb.go +++ b/proto/gen/iop/runtime.pb.go @@ -916,14 +916,31 @@ func (x *NodeCommandResponse) GetProviderSnapshots() []*ProviderSnapshot { } type ProviderSnapshot struct { - state protoimpl.MessageState `protogen:"open.v1"` - Adapter string `protobuf:"bytes,1,opt,name=adapter,proto3" json:"adapter,omitempty"` - Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // unknown|available|unavailable - Capacity int32 `protobuf:"varint,3,opt,name=capacity,proto3" json:"capacity,omitempty"` - InFlight int32 `protobuf:"varint,4,opt,name=in_flight,json=inFlight,proto3" json:"in_flight,omitempty"` - Queued int32 `protobuf:"varint,5,opt,name=queued,proto3" json:"queued,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Adapter string `protobuf:"bytes,1,opt,name=adapter,proto3" json:"adapter,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // unknown|available|unavailable + Capacity int32 `protobuf:"varint,3,opt,name=capacity,proto3" json:"capacity,omitempty"` + InFlight int32 `protobuf:"varint,4,opt,name=in_flight,json=inFlight,proto3" json:"in_flight,omitempty"` + Queued int32 `protobuf:"varint,5,opt,name=queued,proto3" json:"queued,omitempty"` + // Provider catalog fields (MVP). + // id uniquely identifies this provider within its node. + Id string `protobuf:"bytes,6,opt,name=id,proto3" json:"id,omitempty"` + // type is the runtime type (e.g. "ollama", "vllm", "lemonade", "sglang", "openai_api", "cli"). + Type string `protobuf:"bytes,7,opt,name=type,proto3" json:"type,omitempty"` + // category classifies the provider; MVP values: api, cli, local_inference. + Category string `protobuf:"bytes,8,opt,name=category,proto3" json:"category,omitempty"` + // served_models lists the model names this provider can actually serve. + ServedModels []string `protobuf:"bytes,9,rep,name=served_models,json=servedModels,proto3" json:"served_models,omitempty"` + // health is the observed provider health state (e.g. "healthy", "degraded", "unhealthy"). + Health string `protobuf:"bytes,10,opt,name=health,proto3" json:"health,omitempty"` + // load_ratio is the computed in_flight / capacity value. When capacity is 0 + // or unknown the provider is treated as unavailable for selection. + LoadRatio float32 `protobuf:"fixed32,11,opt,name=load_ratio,json=loadRatio,proto3" json:"load_ratio,omitempty"` + // lifecycle_capabilities lists coarse lifecycle capability flags (e.g. + // "list_models", "load_model", "unload_model", "pull_model", "delete_model"). + LifecycleCapabilities []string `protobuf:"bytes,12,rep,name=lifecycle_capabilities,json=lifecycleCapabilities,proto3" json:"lifecycle_capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProviderSnapshot) Reset() { @@ -991,6 +1008,55 @@ func (x *ProviderSnapshot) GetQueued() int32 { return 0 } +func (x *ProviderSnapshot) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ProviderSnapshot) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ProviderSnapshot) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +func (x *ProviderSnapshot) GetServedModels() []string { + if x != nil { + return x.ServedModels + } + return nil +} + +func (x *ProviderSnapshot) GetHealth() string { + if x != nil { + return x.Health + } + return "" +} + +func (x *ProviderSnapshot) GetLoadRatio() float32 { + if x != nil { + return x.LoadRatio + } + return 0 +} + +func (x *ProviderSnapshot) GetLifecycleCapabilities() []string { + if x != nil { + return x.LifecycleCapabilities + } + return nil +} + type AgentUsageStatus struct { state protoimpl.MessageState `protogen:"open.v1"` RawOutput string `protobuf:"bytes,1,opt,name=raw_output,json=rawOutput,proto3" json:"raw_output,omitempty"` @@ -2138,13 +2204,22 @@ const file_proto_iop_runtime_proto_rawDesc = "" + "\x12provider_snapshots\x18\t \x03(\v2\x15.iop.ProviderSnapshotR\x11providerSnapshots\x1a9\n" + "\vResultEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x95\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe8\x02\n" + "\x10ProviderSnapshot\x12\x18\n" + "\aadapter\x18\x01 \x01(\tR\aadapter\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1a\n" + "\bcapacity\x18\x03 \x01(\x05R\bcapacity\x12\x1b\n" + "\tin_flight\x18\x04 \x01(\x05R\binFlight\x12\x16\n" + - "\x06queued\x18\x05 \x01(\x05R\x06queued\"\xc9\x02\n" + + "\x06queued\x18\x05 \x01(\x05R\x06queued\x12\x0e\n" + + "\x02id\x18\x06 \x01(\tR\x02id\x12\x12\n" + + "\x04type\x18\a \x01(\tR\x04type\x12\x1a\n" + + "\bcategory\x18\b \x01(\tR\bcategory\x12#\n" + + "\rserved_models\x18\t \x03(\tR\fservedModels\x12\x16\n" + + "\x06health\x18\n" + + " \x01(\tR\x06health\x12\x1d\n" + + "\n" + + "load_ratio\x18\v \x01(\x02R\tloadRatio\x125\n" + + "\x16lifecycle_capabilities\x18\f \x03(\tR\x15lifecycleCapabilities\"\xc9\x02\n" + "\x10AgentUsageStatus\x12\x1d\n" + "\n" + "raw_output\x18\x01 \x01(\tR\trawOutput\x12\x1f\n" + diff --git a/proto/iop/runtime.proto b/proto/iop/runtime.proto index 08f7659..21d4b73 100644 --- a/proto/iop/runtime.proto +++ b/proto/iop/runtime.proto @@ -121,6 +121,24 @@ message ProviderSnapshot { int32 capacity = 3; int32 in_flight = 4; int32 queued = 5; + + // Provider catalog fields (MVP). + // id uniquely identifies this provider within its node. + string id = 6; + // type is the runtime type (e.g. "ollama", "vllm", "lemonade", "sglang", "openai_api", "cli"). + string type = 7; + // category classifies the provider; MVP values: api, cli, local_inference. + string category = 8; + // served_models lists the model names this provider can actually serve. + repeated string served_models = 9; + // health is the observed provider health state (e.g. "healthy", "degraded", "unhealthy"). + string health = 10; + // load_ratio is the computed in_flight / capacity value. When capacity is 0 + // or unknown the provider is treated as unavailable for selection. + float load_ratio = 11; + // lifecycle_capabilities lists coarse lifecycle capability flags (e.g. + // "list_models", "load_model", "unload_model", "pull_model", "delete_model"). + repeated string lifecycle_capabilities = 12; } message AgentUsageStatus {