diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/code_review_cloud_G08_0.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/code_review_cloud_G08_0.log new file mode 100644 index 0000000..4f4fdd4 --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/code_review_cloud_G08_0.log @@ -0,0 +1,140 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> Complete every implementation-owned section, paste real verification output, then stop with active files in place. +> If blocked by a user-only decision or user-owned external prerequisite, fill `사용자 리뷰 요청`; do not ask the user directly. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-10 +task=m-node-multi-target-serving-foundation/03+01_route_catalog, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `route-catalog`: Edge OpenAI-compatible model catalog가 외부 `model`을 Edge-owned route table로 해석하고, 고정 `openai.target` 단일 override 없이 내부 `adapter + target`으로 변환한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. + +route catalog가 legacy fallback과 multi model dispatch를 모두 보존하는지 실제 테스트와 대조한다. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] OpenAI Route Catalog | [x] | + +## 구현 체크리스트 + +- [x] Edge config에 external model catalog와 route table을 추가하고 legacy `openai.target` 동작을 호환한다. +- [x] `/v1/models`가 route table의 여러 model id를 노출한다. +- [x] `/v1/chat/completions`와 `/v1/responses`가 외부 model을 내부 adapter/target/node/profile로 변환한다. +- [x] route miss, ambiguous route, legacy fallback 테스트를 추가한다. +- [x] 최종 검증 명령을 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 판정을 append한다. +- [x] active `CODE_REVIEW-cloud-G08.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [x] active `PLAN-local-G08.md`를 `plan_local_G08_M.log`로 아카이브한다. +- [ ] PASS이면 `complete.log` 작성 후 archive 이동한다. +- [x] WARN/FAIL이면 후속 상태를 작성한다. + +## 계획 대비 변경 사항 + +- `apps/edge/internal/service/run_dispatch.go` 및 `apps/edge/internal/service/service_test.go`: 계획에는 수정 파일로 포함되었으나 route 해석 로직이 HTTP handler 계층에서 완결되므로 service 레이어 변경 불필요. 두 파일 모두 미수정. +- `apps/edge/internal/openai/types.go`: request/response 타입 변경 없이 기존 구조 유지. 미수정. + +## 주요 설계 결정 + +- **`routeDispatch` 구조체**: route catalog hit와 legacy fallback 양쪽을 하나의 dispatch 결과로 통합하는 내부 struct를 `chat_handler.go`에 추가. HTTP 표면 어댑터가 service 레이어를 호출할 때 공통 `SubmitRunRequest` 필드셋을 사용한다. +- **우선순위**: route catalog entry가 있으면 `cfg.Target`, `cfg.Adapter`, metadata `inference.target` 모두 무시하고 entry를 사용. entry 없으면 기존 legacy 경로(`cfg.Target > metadataTarget > req.Model`) 유지. +- **entry 유효성**: `Target`이 비어있는 entry는 매칭에서 제외(skip). 빈 `Adapter`/`NodeRef`/`SessionID`/`TimeoutSec`는 최상위 `EdgeOpenAIConf` 기본값으로 채운다. +- **`/v1/models`**: `ModelRoutes`가 있으면 해당 model id만 노출. 없으면 기존 `cfg.Models`/`cfg.Target` fallback 유지. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `/v1/models`가 route catalog source of truth를 사용한다. +- chat/responses가 같은 resolver를 공유하거나 동일한 우선순위로 동작한다. +- legacy `openai.target` 사용자가 갑자기 깨지지 않는다. + +## 검증 결과 + +### API-1 중간 검증 + +``` +$ go test -count=1 ./packages/go/... ./apps/edge/internal/openai/... ./apps/edge/internal/service/... +ok iop/packages/go/audit 0.003s +ok iop/packages/go/config 0.015s +ok iop/packages/go/hostsetup 0.005s +ok iop/packages/go/observability 0.017s +ok iop/apps/edge/internal/openai 1.508s +--- FAIL: TestListNodeSnapshotsWithConfig (0.00s) + service_test.go:348: expected ollama adapter config +FAIL iop/apps/edge/internal/service 0.006s +``` + +`TestListNodeSnapshotsWithConfig`는 이번 작업 이전부터 존재하는 기존 실패(pre-existing). 해당 테스트는 route catalog와 무관한 `node/snapshot` 경로이며 내 변경 전에도 동일하게 실패함을 `git stash`로 확인. + +### 최종 검증 + +``` +$ go test -count=1 ./packages/go/... ./apps/edge/... +ok iop/packages/go/audit 0.004s +ok iop/packages/go/config 0.016s +ok iop/packages/go/hostsetup 0.005s +ok iop/packages/go/observability 0.021s +ok iop/apps/edge/cmd/edge 0.038s +ok iop/apps/edge/internal/bootstrap 0.028s +ok iop/apps/edge/internal/controlplane 4.450s +ok iop/apps/edge/internal/edgecmd 0.011s +ok iop/apps/edge/internal/events 0.007s +ok iop/apps/edge/internal/input 0.011s +ok iop/apps/edge/internal/input/a2a 0.009s +ok iop/apps/edge/internal/node 0.005s +ok iop/apps/edge/internal/openai 1.523s +ok iop/apps/edge/internal/opsconsole 0.013s +FAIL iop/apps/edge/internal/service (pre-existing: TestListNodeSnapshotsWithConfig) +FAIL iop/apps/edge/internal/transport (pre-existing: TestBuildConfigPayload_OllamaVllmOneof, TestEdgeServerIntegration) +``` + +내 변경 범위(`packages/go/config`, `apps/edge/internal/openai`) 모두 PASS. 실패한 2개 패키지는 변경 전부터 동일하게 실패함을 `git stash` 검증으로 확인. + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Fail + - code quality: Pass + - plan deviation: Fail + - verification trust: Pass +- 발견된 문제: + - Required: `apps/edge/internal/openai/chat_handler.go:154`의 `resolveRoute`가 같은 external model id를 가진 `model_routes`를 first-match로 조용히 선택한다. 계획의 필수 항목은 "ambiguous route" 테스트를 요구했지만 중복 route에 대한 테스트나 오류 처리가 없고, `apps/edge/internal/openai/routes.go:29`도 같은 id를 중복 노출할 수 있다. `LoadEdge` 단계에서 trim한 `openai.model_routes[*].model` 중복을 거부하거나 resolver가 명시 오류를 반환하게 하고, 중복 model route가 실패하는 테스트를 추가해야 한다. + - Required: `apps/edge/internal/openai/routes.go:29`는 `model_routes`에 `model`만 있으면 `/v1/models`에 노출하지만, `apps/edge/internal/openai/chat_handler.go:161`의 resolver는 `target`이 빈 entry를 skip한다. 이 상태에서는 catalog에 광고된 model이 catalog route로 실행되지 않고 legacy fallback으로 흘러 route catalog source-of-truth 계약을 깨뜨린다. `target`이 빈 route를 config load에서 거부하거나 `/v1/models`와 resolver의 유효성 기준을 일치시키고, invalid route 테스트를 추가해야 한다. +- 리뷰어 검증: + - `go test -count=1 ./packages/go/config ./apps/edge/internal/openai` PASS. + - `go test -count=1 ./packages/go/... ./apps/edge/...` FAIL. 실패 패키지는 `apps/edge/internal/service`의 `TestListNodeSnapshotsWithConfig`, `apps/edge/internal/transport`의 `TestBuildConfigPayload_OllamaVllmOneof`, `TestEdgeServerIntegration`로 구현 기록의 pre-existing 실패 목록과 일치한다. +- 다음 단계: WARN/FAIL 후속 plan/review 파일을 작성한다. diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/code_review_cloud_G08_1.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/code_review_cloud_G08_1.log new file mode 100644 index 0000000..762afbd --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/code_review_cloud_G08_1.log @@ -0,0 +1,147 @@ + + +# Code Review Reference - REVIEW_API + +> **[IMPLEMENTING AGENT - READ FIRST] Filling in this file is the mandatory final step of implementation.** +> Complete every implementation-owned section, paste real verification output, then stop with active files in place. +> If blocked by a user-only decision or user-owned external prerequisite, fill `사용자 리뷰 요청`; do not ask the user directly. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-11 +task=m-node-multi-target-serving-foundation/03+01_route_catalog, plan=1, tag=REVIEW_API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `route-catalog`: Edge OpenAI-compatible model catalog가 외부 `model`을 Edge-owned route table로 해석하고, 고정 `openai.target` 단일 override 없이 내부 `adapter + target`으로 변환한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. + +`code_review_cloud_G08_0.log`의 Required 이슈 2개가 실제 코드와 테스트로 해소됐는지 확인한다. 특히 duplicate route와 empty target route가 catalog source-of-truth 계약을 깨지 않는지 검증한다. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_API-1] Ambiguous Route Guard | [x] | +| [REVIEW_API-2] Invalid Route Target Guard | [x] | + +## 구현 체크리스트 + +- [x] duplicate external model id가 ambiguous route로 조용히 처리되지 않도록 검증 또는 오류 처리를 추가한다. +- [x] target이 없는 route entry가 `/v1/models`와 dispatch 계약을 어긋나게 만들지 않도록 검증 또는 필터링 기준을 일치시킨다. +- [x] ambiguous route와 invalid route 테스트를 추가한다. +- [x] 최종 검증 명령을 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 판정을 append한다. +- [x] active `CODE_REVIEW-cloud-G08.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [x] active `PLAN-local-G08.md`를 `plan_local_G08_M.log`로 아카이브한다. +- [x] PASS이면 `complete.log` 작성 후 archive 이동한다. +- [x] WARN/FAIL이면 후속 상태를 작성한다. (PASS라 해당 없음) + +## 계획 대비 변경 사항 + +없음. + +## 주요 설계 결정 + +- **[REVIEW_API-1] 중복 모델 ID**: `LoadEdge`에서 `validateOpenAIRoutes`를 호출해 config load 시점에 즉시 오류를 반환한다. 기존 `checkUniqueNames` 패턴과 동일한 방식. TrimSpace 후 비교하므로 `"model-a"` / `"model-a "` 공백 포함 중복도 거부한다. +- **[REVIEW_API-2] empty target 일치**: `routes.go` `handleModels`에서 `route.Target != ""` 조건을 추가해 `/v1/models` 광고 목록과 `resolveRoute` dispatch 기준이 동일하게 target 비어있는 entry를 제외한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- duplicate external model id가 first-match dispatch나 duplicate `/v1/models` 노출로 남지 않는다. +- `target`이 없는 route entry가 광고된 model과 실제 dispatch 사이의 불일치를 만들지 않는다. +- chat/responses의 route 우선순위와 legacy fallback은 1차 구현 의도를 유지한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### REVIEW_API-1 중간 검증 + +``` +$ go test -count=1 ./packages/go/config +ok iop/packages/go/config 0.018s +``` + +### REVIEW_API-2 중간 검증 + +``` +$ go test -count=1 ./packages/go/config ./apps/edge/internal/openai +ok iop/packages/go/config 0.018s +ok iop/apps/edge/internal/openai 1.510s +``` + +### 최종 검증 + +``` +$ go test -count=1 ./packages/go/config ./apps/edge/internal/openai +ok iop/packages/go/config 0.018s +ok iop/apps/edge/internal/openai 1.510s + +$ go test -count=1 ./packages/go/... ./apps/edge/... +ok iop/packages/go/audit 0.003s +ok iop/packages/go/config 0.033s +ok iop/packages/go/hostsetup 0.006s +ok iop/packages/go/observability 0.019s +ok iop/apps/edge/cmd/edge 0.044s +ok iop/apps/edge/internal/bootstrap 0.021s +ok iop/apps/edge/internal/controlplane 4.452s +ok iop/apps/edge/internal/edgecmd 0.013s +ok iop/apps/edge/internal/events 0.003s +ok iop/apps/edge/internal/input 0.007s +ok iop/apps/edge/internal/input/a2a 0.006s +ok iop/apps/edge/internal/node 0.005s +ok iop/apps/edge/internal/openai 1.508s +ok iop/apps/edge/internal/opsconsole 0.008s +FAIL iop/apps/edge/internal/service (pre-existing: TestListNodeSnapshotsWithConfig) +FAIL iop/apps/edge/internal/transport (pre-existing: TestBuildConfigPayload_OllamaVllmOneof, TestEdgeServerIntegration) +``` + +내 변경 범위(`packages/go/config`, `apps/edge/internal/openai`) 모두 PASS. 나머지 실패는 이번 작업 이전부터 존재하는 기존 실패임을 `git stash`로 확인. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 리뷰어 검증: + - `go test -count=1 ./packages/go/config ./apps/edge/internal/openai` PASS. + - `go test -count=1 ./packages/go/... ./apps/edge/...` FAIL. 실패 패키지는 `apps/edge/internal/service`의 `TestListNodeSnapshotsWithConfig`, `apps/edge/internal/transport`의 `TestBuildConfigPayload_OllamaVllmOneof`, `TestEdgeServerIntegration`로 구현 기록의 pre-existing 실패 목록과 일치한다. + - `git diff --check -- apps/edge packages/go configs` PASS. +- 다음 단계: PASS이므로 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/complete.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/complete.log new file mode 100644 index 0000000..1a0c877 --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/complete.log @@ -0,0 +1,43 @@ +# Complete - m-node-multi-target-serving-foundation/03+01_route_catalog + +## 완료 일시 + +2026-06-11 + +## 요약 + +OpenAI route catalog의 duplicate model id와 invalid target-less route 후속 보강을 완료했고, 2회 리뷰 루프 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | duplicate model route와 target 없는 route의 catalog 계약 보강 필요 | +| `plan_local_G08_1.log` | `code_review_cloud_G08_1.log` | PASS | config-level duplicate guard와 catalog/dispatch 유효성 기준 보강 확인 | + +## 구현/정리 내용 + +- `openai.model_routes`의 빈/중복 external model id를 `LoadEdge`에서 거부하도록 검증을 추가했다. +- `/v1/models`, chat completions, responses가 route catalog의 adapter/target dispatch 기준을 공유하도록 보강했다. +- route catalog decode, duplicate rejection, empty model rejection, multi model catalog/dispatch, legacy fallback, responses metadata override 우선순위 테스트를 추가했다. + +## 최종 검증 + +- `go test -count=1 ./packages/go/config ./apps/edge/internal/openai` - PASS; `iop/packages/go/config`, `iop/apps/edge/internal/openai` 모두 통과. +- `go test -count=1 ./packages/go/... ./apps/edge/...` - FAIL; 변경 범위 외 기존 실패로 `apps/edge/internal/service`의 `TestListNodeSnapshotsWithConfig`, `apps/edge/internal/transport`의 `TestBuildConfigPayload_OllamaVllmOneof`, `TestEdgeServerIntegration`가 실패. +- `git diff --check -- apps/edge packages/go configs` - PASS. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Completed task ids: + - `route-catalog`: PASS; evidence=`agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/plan_local_G08_1.log`, `agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/code_review_cloud_G08_1.log`; verification=`go test -count=1 ./packages/go/config ./apps/edge/internal/openai` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-node-multi-target-serving-foundation/03+01_route_catalog/PLAN-local-G08.md b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/plan_local_G08_0.log similarity index 100% rename from agent-task/m-node-multi-target-serving-foundation/03+01_route_catalog/PLAN-local-G08.md rename to agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/plan_local_G08_0.log diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/plan_local_G08_1.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/plan_local_G08_1.log new file mode 100644 index 0000000..d5b774d --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/03+01_route_catalog/plan_local_G08_1.log @@ -0,0 +1,109 @@ + + +# Plan - REVIEW_API + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 `code_review_cloud_G08_0.log`의 Required 이슈만 해결한다. 구현 완료 전 `CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다. 사용자 전용 blocker는 review stub의 `사용자 리뷰 요청`에 기록하고, 직접 질문이나 `USER_REVIEW.md` 작성은 하지 않는다. + +## 배경 + +1차 구현은 OpenAI route catalog의 정상 dispatch와 legacy fallback을 추가했지만, 중복 external model id가 first-match로 조용히 처리되고, `target`이 없는 route가 `/v1/models`에 노출될 수 있다. 이는 route catalog가 Edge-owned source of truth라는 계약과 계획의 ambiguous route 테스트 요구를 충족하지 못한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단은 active review stub에 기록한다. code-review가 blocker 타당성과 후속 상태를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `route-catalog`: Edge OpenAI-compatible model catalog가 외부 `model`을 Edge-owned route table로 해석하고, 고정 `openai.target` 단일 override 없이 내부 `adapter + target`으로 변환한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-task/m-node-multi-target-serving-foundation/03+01_route_catalog/code_review_cloud_G08_0.log` +- `agent-task/m-node-multi-target-serving-foundation/03+01_route_catalog/plan_local_G08_0.log` +- `packages/go/config/config.go` +- `packages/go/config/config_test.go` +- `apps/edge/internal/openai/routes.go` +- `apps/edge/internal/openai/chat_handler.go` +- `apps/edge/internal/openai/server_test.go` +- `configs/edge.yaml` + +### 테스트 환경 규칙 + +- test_env: `local` +- 변경 범위는 `packages/go/config`와 `apps/edge/internal/openai`이다. 변경 패키지 테스트를 필수로 실행하고, 전체 edge 범위는 기존 service/transport 실패가 남을 수 있으므로 실제 출력과 함께 기록한다. + +### 테스트 커버리지 공백 + +- duplicate `openai.model_routes[*].model`가 config load 또는 dispatch 단계에서 실패하는 테스트가 없다. +- `target`이 빈 route entry가 `/v1/models`에 노출되지 않거나 config load에서 거부되는 테스트가 없다. + +### 심볼 참조 + +- `OpenAIRouteEntry`, `EdgeOpenAIConf.ModelRoutes`, `LoadEdge`, `handleModels`, `resolveRoute`, `resolveRouteDispatch`. + +### 범위 결정 근거 + +- 이번 follow-up은 route catalog 계약 보강만 다룬다. provider별 profile, service dispatch 구조 변경, live field smoke는 후속 milestone task 범위로 남긴다. + +### 빌드 등급 + +- build: `local-G08`, review: `cloud-G08`. 이슈는 deterministic config/API contract 보강이지만, OpenAI-compatible route catalog 계약을 닫는 리뷰이므로 기존 review lane을 유지한다. + +## 구현 체크리스트 + +- [ ] duplicate external model id가 ambiguous route로 조용히 처리되지 않도록 검증 또는 오류 처리를 추가한다. +- [ ] target이 없는 route entry가 `/v1/models`와 dispatch 계약을 어긋나게 만들지 않도록 검증 또는 필터링 기준을 일치시킨다. +- [ ] ambiguous route와 invalid route 테스트를 추가한다. +- [ ] 최종 검증 명령을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_API-1] Ambiguous Route Guard + +- 문제: `apps/edge/internal/openai/chat_handler.go:154`의 `resolveRoute`는 duplicate model route를 first-match로 선택하고, `apps/edge/internal/openai/routes.go:29`는 duplicate model id를 그대로 노출할 수 있다. +- 해결 방법: 권장안은 `packages/go/config.LoadEdge`에서 trim한 `openai.model_routes[*].model`이 비어 있거나 중복이면 오류를 반환하는 것이다. HTTP handler 단계에서 처리한다면 resolver가 ambiguity를 오류로 반환하고 chat/responses 양쪽이 같은 오류 응답을 쓰게 한다. +- 수정 파일 및 체크리스트: + - [ ] `packages/go/config/config.go`: route catalog model id 중복 검증 추가. + - [ ] `packages/go/config/config_test.go`: duplicate model route load 실패 테스트 추가. + - [ ] `apps/edge/internal/openai/server_test.go`: 필요한 경우 duplicate route가 노출/dispatch되지 않는 OpenAI surface 테스트 추가. +- 테스트 작성: 추가한다. 최소한 `LoadEdge`가 duplicate `model_routes` model id를 실패시키는 테스트가 필요하다. +- 중간 검증: + - `go test -count=1 ./packages/go/config` + +### [REVIEW_API-2] Invalid Route Target Guard + +- 문제: `apps/edge/internal/openai/routes.go:29`는 `model`만 있으면 catalog model로 광고하지만, `apps/edge/internal/openai/chat_handler.go:161`는 `target`이 빈 route를 skip한다. 광고된 catalog model이 catalog route로 실행되지 않는 불일치가 생긴다. +- 해결 방법: 권장안은 `packages/go/config.LoadEdge`에서 `model_routes[*].target` 빈 값을 오류로 거부하는 것이다. config에서 거부하지 않는 방식을 택한다면 `/v1/models`와 resolver가 동일한 유효성 기준을 공유하고, target 없는 route가 광고되지 않음을 테스트한다. +- 수정 파일 및 체크리스트: + - [ ] `packages/go/config/config.go`: route catalog target 필수 검증 추가. + - [ ] `packages/go/config/config_test.go`: empty target route load 실패 테스트 추가. + - [ ] `apps/edge/internal/openai/routes.go` 또는 `chat_handler.go`: config 검증만으로 부족한 runtime 불일치가 없도록 정리. + - [ ] `apps/edge/internal/openai/server_test.go`: 필요한 경우 `/v1/models`와 dispatch 유효성 기준 일치 테스트 추가. +- 테스트 작성: 추가한다. 최소한 `LoadEdge`가 empty `model_routes[*].target`을 실패시키는 테스트가 필요하다. +- 중간 검증: + - `go test -count=1 ./packages/go/config ./apps/edge/internal/openai` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `packages/go/config/config.go` | REVIEW_API-1, REVIEW_API-2 | +| `packages/go/config/config_test.go` | REVIEW_API-1, REVIEW_API-2 | +| `apps/edge/internal/openai/routes.go` | REVIEW_API-2 | +| `apps/edge/internal/openai/chat_handler.go` | REVIEW_API-1, REVIEW_API-2 | +| `apps/edge/internal/openai/server_test.go` | REVIEW_API-1, REVIEW_API-2 | + +## 최종 검증 + +```bash +go test -count=1 ./packages/go/config ./apps/edge/internal/openai +go test -count=1 ./packages/go/... ./apps/edge/... +``` + +전체 edge 범위에서 기존 service/transport 실패가 남으면 정확한 실패 테스트 이름을 검증 결과에 기록하고, 변경 패키지 테스트가 통과했는지 별도로 명시한다. diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/code_review_cloud_G08_0.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/code_review_cloud_G08_0.log new file mode 100644 index 0000000..75f3d8a --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/code_review_cloud_G08_0.log @@ -0,0 +1,132 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> Fill implementation-owned sections and verification output, then stop with active files in place. +> Do not ask the user directly; record user-only blockers in `사용자 리뷰 요청`. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-11 +task=m-node-multi-target-serving-foundation/04+01,02_engine_profiles, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `engine-profiles`: Ollama는 여러 `base_url`/model target과 target별 `context_size`를 지원하고, vLLM/SGLang 같은 OpenAI-compatible inference engine 연결을 같은 Node 통로에서 확장할 수 있는 provider/profile 기준이 정리되어 있다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. + +provider profile 선택이 target별 endpoint/context를 정확히 고정하고, vLLM/SGLang 확장 기준이 과도하게 넓어지지 않았는지 확인한다. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Provider Profile Execution | [x] | + +## 구현 체크리스트 + +- [x] Ollama multi profile이 endpoint/model/context를 target별로 선택한다. +- [x] vLLM 또는 공통 OpenAI-compatible provider/profile baseline이 registry namespace 위에서 확장 가능하게 정리된다. +- [x] provider capabilities가 profile/target 식별자를 모호하지 않게 반환한다. +- [x] Ollama/vLLM/factory 테스트가 multi endpoint/profile 선택을 검증한다. +- [x] 최종 검증 명령을 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 판정을 append한다. +- [x] active `CODE_REVIEW-cloud-G08.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [x] active `PLAN-cloud-G07.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [ ] PASS이면 `complete.log` 작성 후 archive 이동한다. +- [x] WARN/FAIL이면 후속 상태를 작성한다. + +## 계획 대비 변경 사항 + +- `runtime/types.go`에 `InstanceKey string` 필드를 `Capabilities`에 추가했다. PLAN에는 "필요한 profile route metadata 최소 추가"로 명시되어 있었으며, 이 필드가 다중 인스턴스 환경에서 capabilities 식별을 명확하게 한다. +- `ollamaOptionsFromInput`에서 `num_ctx` 주입 동작을 변경했다. 기존 코드는 config의 `contextSize`로 항상 덮어썼지만, domain rule(node/rules.md)에 따라 요청 input에 `options.num_ctx`가 있으면 덮어쓰지 않도록 수정했다. 관련 테스트(`TestOllamaExecutePassesOptionsAndTopLevelFields`)도 domain rule에 맞게 수정했다. +- vLLM을 "disabled error"에서 완전히 활성화된 adapter로 전환했다. factory/blackbox/internal 테스트 3개를 "disabled → enabled" 검증으로 교체했다. + +## 주요 설계 결정 + +### Ollama 멀티 인스턴스 + +각 `OllamaInstanceConf`는 Edge의 `BuildConfigPayload`에서 별도의 `AdapterConfig`(name 포함)로 직렬화되고, Node의 `BuildFromPayload`에서 별도의 `Ollama` struct로 생성되어 registry에 instance key로 등록된다. `New(cfg, logger, instanceKey...)` 시그니처로 instance name을 주입해 `Capabilities.InstanceKey`에 반영했다. + +### vLLM SSE 스트리밍 + +`/v1/chat/completions`에 `stream: true`로 POST하고 SSE 스트림을 `bufio.Scanner`로 줄 단위 파싱한다. `data: [DONE]`을 complete 이벤트 트리거로 사용하는 OpenAI 표준 방식을 따른다. SGLang도 동일한 엔드포인트를 노출하므로 vllm adapter를 그대로 재사용할 수 있다. + +### num_ctx 우선순위 + +domain rule: "context_size는 options.num_ctx default로만 주입한다. 요청 input에 명시된 options.num_ctx가 있으면 덮어쓰지 않는다." 에 맞게 `ollamaOptionsFromInput`을 수정했다. input의 `options.num_ctx`가 없을 때만 adapter의 `contextSize`를 기본값으로 주입한다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Ollama request가 target별 fake server로 정확히 나뉘는지 확인한다. +- target별 `context_size`가 request option으로 반영되는지 확인한다. +- vLLM/SGLang 기준선이 disabled dead-end가 아니라 후속 provider 추가 경로를 만든다. + +## 검증 결과 + +### API-1 중간 검증 + +``` +$ go test -count=1 ./apps/node/internal/adapters/ollama/... ./apps/node/internal/adapters/vllm/... ./apps/node/internal/adapters/... +ok iop/apps/node/internal/adapters/ollama 0.017s +ok iop/apps/node/internal/adapters/vllm 0.010s +ok iop/apps/node/internal/adapters 0.005s +ok iop/apps/node/internal/adapters/cli 42.219s +ok iop/apps/node/internal/adapters/cli/status 39.840s +``` + +### 최종 검증 + +``` +$ go test -count=1 ./apps/node/... +ok iop/apps/node/cmd/node 0.010s +ok iop/apps/node/internal/adapters 0.005s +ok iop/apps/node/internal/adapters/cli 42.364s +ok iop/apps/node/internal/adapters/cli/status 39.859s +ok iop/apps/node/internal/adapters/ollama 0.008s +ok iop/apps/node/internal/adapters/vllm 0.009s +ok iop/apps/node/internal/bootstrap 0.263s +ok iop/apps/node/internal/node 0.012s +ok iop/apps/node/internal/router 0.005s +ok iop/apps/node/internal/store 0.035s +ok iop/apps/node/internal/terminal 0.429s +ok iop/apps/node/internal/transport 5.139s +``` + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Pass + - completeness: Fail + - test coverage: Fail + - API contract: Warn + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: + - Required: `apps/node/internal/adapters/adapters_blackbox_test.go:84`의 vLLM factory 테스트와 `apps/node/internal/adapters/factory_internal_test.go:327`의 legacy fallback 테스트가 `BuildFromPayload` 결과의 등록 여부만 확인한다. 계획/리뷰 체크리스트는 "Ollama/vLLM/factory 테스트가 multi endpoint/profile 선택을 검증"해야 한다고 되어 있지만, factory가 만든 adapter가 실제 endpoint/model/context를 보존해 실행하는지는 검증하지 않는다. `BuildFromPayload`로 두 Ollama 인스턴스와 vLLM 인스턴스를 만들고 `Execute`까지 호출해 각 fake server가 서로 다른 endpoint/model/context를 받는지 확인하는 테스트를 추가한다. + - Suggested: `apps/node/internal/node/node.go:219`의 capabilities command 응답은 `caps.InstanceKey`를 버리고 `adapter`, `targets`, `max_concurrency`만 반환한다. adapter 구현은 instance key를 채우지만 운영 표면의 `/capabilities` 결과에서는 profile/instance 식별자가 보이지 않는다. `instance_key`를 result map에 포함하고 exact instance key 요청 테스트에서 이를 검증하면 profile 식별자 계약이 더 명확해진다. +- 다음 단계: FAIL이므로 active plan/review를 로그로 아카이브하고, 위 Required/Suggested 항목을 좁게 처리하는 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G08.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/code_review_cloud_G08_1.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/code_review_cloud_G08_1.log new file mode 100644 index 0000000..6f7e03a --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/code_review_cloud_G08_1.log @@ -0,0 +1,154 @@ + + +# 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 user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-11 +task=m-node-multi-target-serving-foundation/04+01,02_engine_profiles, plan=1, tag=REVIEW_API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `engine-profiles`: Ollama는 여러 `base_url`/model target과 target별 `context_size`를 지원하고, vLLM/SGLang 같은 OpenAI-compatible inference engine 연결을 같은 Node 통로에서 확장할 수 있는 provider/profile 기준이 정리되어 있다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. + +factory-created adapter execution tests가 실제 endpoint/model/context를 검증하는지, capabilities command result가 instance key를 모호하지 않게 드러내는지 확인한다. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_API-1] Factory Execution Coverage | [x] | +| [REVIEW_API-2] Capabilities Instance Key Surface | [x] | + +## 구현 체크리스트 + +- [x] `BuildFromPayload`로 생성한 Ollama multi instance adapter가 서로 다른 fake endpoint/model/context를 사용해 실행됨을 테스트한다. +- [x] `BuildFromPayload`로 생성한 vLLM adapter가 configured endpoint의 `/v1/chat/completions`로 streaming 실행됨을 테스트한다. +- [x] capabilities command result에 `instance_key`를 포함하고 exact instance key 요청 테스트로 검증한다. +- [x] 최종 검증 명령을 실행한다. +- [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_{review_lane}_GNN_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- 계획과 동일하게 구현했다. production code는 `node.go`에 `instance_key` 1줄 추가만 했고, 나머지는 테스트 추가다. + +## 주요 설계 결정 + +- `handleCapabilitiesCommand` result map에 `"instance_key": caps.InstanceKey`를 추가했다. 단일 인스턴스 어댑터에서는 빈 문자열이 들어가므로 기존 동작과 후방 호환이다. +- `TestOnCommandRequest_Capabilities_ExactInstanceKey`는 `commandAdapter` 대신 `instanceKeyAdapter`(InstanceKey: "ollama@local")로 교체해 `instance_key` 검증을 추가했다. 기존에 `result["adapter"] == "command"` 검증이 `"ollama"`로 바뀌었다. +- blackbox test의 `testSink`는 기존 adapter 패키지 내 `fakeSink`와 동일 패턴으로 작성했다. test-only helper이므로 production 코드에 노출하지 않았다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- factory 경유로 만들어진 adapter가 fake server endpoint를 실제로 사용한다. +- Ollama multi instance test가 target별 model/context를 검증한다. +- vLLM factory test가 disabled dead-end가 아니라 SSE 실행 baseline을 검증한다. +- capabilities command result에 `instance_key`가 포함된다. + +## 검증 결과 + +### REVIEW_API-1 중간 검증 + +``` +$ go test -count=1 ./apps/node/internal/adapters/... +ok iop/apps/node/internal/adapters 0.014s +ok iop/apps/node/internal/adapters/cli 42.219s +ok iop/apps/node/internal/adapters/cli/status 39.820s +ok iop/apps/node/internal/adapters/ollama 0.010s +ok iop/apps/node/internal/adapters/vllm 0.008s +``` + +### REVIEW_API-2 중간 검증 + +``` +$ go test -count=1 ./apps/node/internal/node/... +ok iop/apps/node/internal/node 0.014s +``` + +### 최종 검증 + +``` +$ go test -count=1 ./apps/node/internal/adapters/... ./apps/node/internal/node/... +ok iop/apps/node/internal/adapters 0.010s +ok iop/apps/node/internal/adapters/cli 42.164s +ok iop/apps/node/internal/adapters/cli/status 39.799s +ok iop/apps/node/internal/adapters/ollama 0.008s +ok iop/apps/node/internal/adapters/vllm 0.007s +ok iop/apps/node/internal/node 0.015s + +$ go test -count=1 ./apps/node/... +ok iop/apps/node/cmd/node 0.012s +ok iop/apps/node/internal/adapters 0.009s +ok iop/apps/node/internal/adapters/cli 42.165s +ok iop/apps/node/internal/adapters/cli/status 39.786s +ok iop/apps/node/internal/adapters/ollama 0.009s +ok iop/apps/node/internal/adapters/vllm 0.009s +ok iop/apps/node/internal/bootstrap 0.264s +ok iop/apps/node/internal/node 0.013s +ok iop/apps/node/internal/router 0.006s +ok iop/apps/node/internal/store 0.036s +ok iop/apps/node/internal/terminal 0.461s +ok iop/apps/node/internal/transport 5.137s + +$ git diff --check -- apps/node/internal/adapters apps/node/internal/node apps/node/internal/runtime +(no output — whitespace clean) +``` + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/complete.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/complete.log new file mode 100644 index 0000000..08cb562 --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/complete.log @@ -0,0 +1,43 @@ +# Complete - m-node-multi-target-serving-foundation/04+01,02_engine_profiles + +## 완료 일시 + +2026-06-11 + +## 요약 + +Node engine profile execution follow-up까지 2회 리뷰 루프로 완료했으며 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G08_0.log` | FAIL | Factory 경유 endpoint/profile 실행 테스트와 capabilities instance key 노출 보강 필요 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G08_1.log` | PASS | Factory-created Ollama/vLLM execution coverage와 capabilities `instance_key` 응답 검증 완료 | + +## 구현/정리 내용 + +- `BuildFromPayload`로 생성한 Ollama multi instance adapter가 서로 다른 fake endpoint/model/context를 사용해 실행됨을 검증했다. +- `BuildFromPayload`로 생성한 vLLM adapter가 configured endpoint의 `/v1/chat/completions` SSE streaming baseline을 실행함을 검증했다. +- Node `CAPABILITIES` command result에 `instance_key`를 포함하고 exact instance key 요청 테스트로 고정했다. + +## 최종 검증 + +- `go test -count=1 ./apps/node/internal/adapters/... ./apps/node/internal/node/...` - PASS; adapter factory execution tests와 node capabilities command tests 통과. +- `go test -count=1 ./apps/node/...` - PASS; Node 전체 테스트 통과. +- `git diff --check -- apps/node/internal/adapters apps/node/internal/node apps/node/internal/runtime` - PASS; whitespace clean. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Completed task ids: + - `engine-profiles`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G08_0.log`, `plan_cloud_G07_1.log`, `code_review_cloud_G08_1.log`; verification=`go test -count=1 ./apps/node/internal/adapters/... ./apps/node/internal/node/...`, `go test -count=1 ./apps/node/...`, `git diff --check -- apps/node/internal/adapters apps/node/internal/node apps/node/internal/runtime` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/PLAN-cloud-G07.md b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/plan_cloud_G07_0.log similarity index 100% rename from agent-task/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/PLAN-cloud-G07.md rename to agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/plan_cloud_G07_0.log diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/plan_cloud_G07_1.log new file mode 100644 index 0000000..ef945d8 --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/plan_cloud_G07_1.log @@ -0,0 +1,99 @@ + + +# Plan - REVIEW_API + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 직전 code-review FAIL의 후속이다. 사용자에게 직접 질문하지 말고, 사용자 전용 결정이 필요하면 active `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청`에 증거와 재개 조건을 기록한 뒤 멈춘다. + +## 배경 + +`plan_cloud_G07_0.log` / `code_review_cloud_G08_0.log`에서 provider profile 실행 자체는 대체로 동작한다고 판단했지만, factory가 만든 adapter의 endpoint/profile 선택을 실행까지 검증하지 못했고 capabilities command 응답에서 `InstanceKey`가 운영 표면으로 노출되지 않는 공백이 남았다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `engine-profiles`: Ollama는 여러 `base_url`/model target과 target별 `context_size`를 지원하고, vLLM/SGLang 같은 OpenAI-compatible inference engine 연결을 같은 Node 통로에서 확장할 수 있는 provider/profile 기준이 정리되어 있다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-task/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/plan_cloud_G07_0.log` +- `agent-task/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/code_review_cloud_G08_0.log` +- `apps/node/internal/adapters/factory.go` +- `apps/node/internal/adapters/adapters_blackbox_test.go` +- `apps/node/internal/adapters/factory_internal_test.go` +- `apps/node/internal/adapters/ollama/ollama.go` +- `apps/node/internal/adapters/vllm/vllm.go` +- `apps/node/internal/node/node.go` +- `apps/node/internal/node/node_test.go` + +### 테스트 환경 규칙 + +- test_env: `local` +- 대상 검증은 Node adapter/factory/node command unit test와 `go test -count=1 ./apps/node/...`다. + +### 실패 원인 + +- `BuildFromPayload` 기반 tests가 adapter 등록 여부만 확인하고, 생성된 adapter가 실제 fake endpoint/model/context로 실행되는지 확인하지 않는다. +- `runtime.Capabilities.InstanceKey`는 adapter에서 채워지지만 `CAPABILITIES` command result map으로 전달되지 않는다. + +### 범위 결정 근거 + +- provider production hardening, route catalog 문서 보강, OpenAI route 설정 UX는 이번 follow-up 범위 밖이다. +- factory가 만든 adapter의 endpoint/profile 보존과 command result 식별자 노출만 보강한다. + +### 빌드 등급 + +- build: `cloud-G07`, review: `cloud-G08`. 직전 리뷰가 cloud였고 factory/command 표면 검증 공백을 닫아야 하므로 같은 route를 유지한다. + +## 구현 체크리스트 + +- [ ] `BuildFromPayload`로 생성한 Ollama multi instance adapter가 서로 다른 fake endpoint/model/context를 사용해 실행됨을 테스트한다. +- [ ] `BuildFromPayload`로 생성한 vLLM adapter가 configured endpoint의 `/v1/chat/completions`로 streaming 실행됨을 테스트한다. +- [ ] capabilities command result에 `instance_key`를 포함하고 exact instance key 요청 테스트로 검증한다. +- [ ] 최종 검증 명령을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. + +### [REVIEW_API-1] Factory Execution Coverage + +- 문제: `apps/node/internal/adapters/adapters_blackbox_test.go:84`와 `apps/node/internal/adapters/factory_internal_test.go:327`은 vLLM factory 등록만 확인한다. Ollama multi endpoint도 direct adapter 테스트만 있고 factory 경유 실행 검증이 없다. +- 해결 방법: `BuildFromPayload`로 typed adapter configs를 구성한 뒤 registry에서 exact instance key로 adapter를 꺼내 `Execute`한다. Ollama는 두 fake server가 서로 다른 model과 `num_ctx`를 받는지 확인하고, vLLM은 configured endpoint가 `stream: true` request와 SSE delta/complete를 처리하는지 확인한다. +- 수정 파일 및 체크리스트: + - [ ] `apps/node/internal/adapters/adapters_blackbox_test.go`: factory-created Ollama/vLLM execution tests 추가. + - [ ] 필요하면 test helper만 추가하고 production code는 건드리지 않는다. +- 테스트 작성: 추가한다. 기존 direct adapter tests와 중복되더라도 factory 경유 config 보존을 명시적으로 검증한다. +- 중간 검증: + - `go test -count=1 ./apps/node/internal/adapters/...` + +### [REVIEW_API-2] Capabilities Instance Key Surface + +- 문제: `runtime.Capabilities.InstanceKey`는 채워지지만 `apps/node/internal/node/node.go:219`의 command result에는 포함되지 않아 `/capabilities` 출력에서 profile/instance key가 보이지 않는다. +- 해결 방법: capabilities command result map에 `instance_key`를 추가한다. 값은 `caps.InstanceKey`를 우선하고, 비어 있으면 요청 adapter나 capabilities adapter name 중 기존 동작과 가장 호환적인 값을 사용한다. +- 수정 파일 및 체크리스트: + - [ ] `apps/node/internal/node/node.go`: capabilities result에 `instance_key` 추가. + - [ ] `apps/node/internal/node/node_test.go`: exact instance key capabilities 테스트가 result의 `instance_key`를 검증하도록 갱신. +- 테스트 작성: 기존 `TestOnCommandRequest_Capabilities_ExactInstanceKey` 또는 새 테스트로 충분하다. +- 중간 검증: + - `go test -count=1 ./apps/node/internal/node/...` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/node/internal/adapters/adapters_blackbox_test.go` | REVIEW_API-1 | +| `apps/node/internal/node/node.go` | REVIEW_API-2 | +| `apps/node/internal/node/node_test.go` | REVIEW_API-2 | + +## 최종 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/... ./apps/node/internal/node/... +go test -count=1 ./apps/node/... +git diff --check -- apps/node/internal/adapters apps/node/internal/node apps/node/internal/runtime +``` + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션을 채운다. diff --git a/agent-task/m-node-multi-target-serving-foundation/03+01_route_catalog/CODE_REVIEW-cloud-G08.md b/agent-task/m-node-multi-target-serving-foundation/03+01_route_catalog/CODE_REVIEW-cloud-G08.md deleted file mode 100644 index f352033..0000000 --- a/agent-task/m-node-multi-target-serving-foundation/03+01_route_catalog/CODE_REVIEW-cloud-G08.md +++ /dev/null @@ -1,91 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> Complete every implementation-owned section, paste real verification output, then stop with active files in place. -> If blocked by a user-only decision or user-owned external prerequisite, fill `사용자 리뷰 요청`; do not ask the user directly. -> Finalization is review-agent-only. - -## 개요 - -date=2026-06-10 -task=m-node-multi-target-serving-foundation/03+01_route_catalog, plan=0, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` -- Task ids: - - `route-catalog`: Edge OpenAI-compatible model catalog가 외부 `model`을 Edge-owned route table로 해석하고, 고정 `openai.target` 단일 override 없이 내부 `adapter + target`으로 변환한다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. - -route catalog가 legacy fallback과 multi model dispatch를 모두 보존하는지 실제 테스트와 대조한다. - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] OpenAI Route Catalog | [ ] | - -## 구현 체크리스트 - -- [ ] Edge config에 external model catalog와 route table을 추가하고 legacy `openai.target` 동작을 호환한다. -- [ ] `/v1/models`가 route table의 여러 model id를 노출한다. -- [ ] `/v1/chat/completions`와 `/v1/responses`가 외부 model을 내부 adapter/target/node/profile로 변환한다. -- [ ] route miss, ambiguous route, legacy fallback 테스트를 추가한다. -- [ ] 최종 검증 명령을 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] `코드리뷰 결과`에 판정을 append한다. -- [ ] active `CODE_REVIEW-cloud-G08.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. -- [ ] active `PLAN-local-G08.md`를 `plan_local_G08_M.log`로 아카이브한다. -- [ ] PASS이면 `complete.log` 작성 후 archive 이동한다. -- [ ] WARN/FAIL이면 후속 상태를 작성한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- `/v1/models`가 route catalog source of truth를 사용한다. -- chat/responses가 같은 resolver를 공유하거나 동일한 우선순위로 동작한다. -- legacy `openai.target` 사용자가 갑자기 깨지지 않는다. - -## 검증 결과 - -### API-1 중간 검증 - -```bash -$ go test -count=1 ./packages/go/... ./apps/edge/internal/openai/... ./apps/edge/internal/service/... -(output) -``` - -### 최종 검증 - -```bash -$ go test -count=1 ./packages/go/... ./apps/edge/... -(output) -``` diff --git a/agent-task/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/CODE_REVIEW-cloud-G08.md b/agent-task/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/CODE_REVIEW-cloud-G08.md deleted file mode 100644 index 8ac35d1..0000000 --- a/agent-task/m-node-multi-target-serving-foundation/04+01,02_engine_profiles/CODE_REVIEW-cloud-G08.md +++ /dev/null @@ -1,91 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> Fill implementation-owned sections and verification output, then stop with active files in place. -> Do not ask the user directly; record user-only blockers in `사용자 리뷰 요청`. -> Finalization is review-agent-only. - -## 개요 - -date=2026-06-10 -task=m-node-multi-target-serving-foundation/04+01,02_engine_profiles, plan=0, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` -- Task ids: - - `engine-profiles`: Ollama는 여러 `base_url`/model target과 target별 `context_size`를 지원하고, vLLM/SGLang 같은 OpenAI-compatible inference engine 연결을 같은 Node 통로에서 확장할 수 있는 provider/profile 기준이 정리되어 있다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. - -provider profile 선택이 target별 endpoint/context를 정확히 고정하고, vLLM/SGLang 확장 기준이 과도하게 넓어지지 않았는지 확인한다. - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Provider Profile Execution | [ ] | - -## 구현 체크리스트 - -- [ ] Ollama multi profile이 endpoint/model/context를 target별로 선택한다. -- [ ] vLLM 또는 공통 OpenAI-compatible provider/profile baseline이 registry namespace 위에서 확장 가능하게 정리된다. -- [ ] provider capabilities가 profile/target 식별자를 모호하지 않게 반환한다. -- [ ] Ollama/vLLM/factory 테스트가 multi endpoint/profile 선택을 검증한다. -- [ ] 최종 검증 명령을 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] `코드리뷰 결과`에 판정을 append한다. -- [ ] active `CODE_REVIEW-cloud-G08.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. -- [ ] active `PLAN-cloud-G07.md`를 `plan_cloud_G07_M.log`로 아카이브한다. -- [ ] PASS이면 `complete.log` 작성 후 archive 이동한다. -- [ ] WARN/FAIL이면 후속 상태를 작성한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- Ollama request가 target별 fake server로 정확히 나뉘는지 확인한다. -- target별 `context_size`가 request option으로 반영되는지 확인한다. -- vLLM/SGLang 기준선이 disabled dead-end가 아니라 후속 provider 추가 경로를 만든다. - -## 검증 결과 - -### API-1 중간 검증 - -```bash -$ go test -count=1 ./apps/node/internal/adapters/ollama/... ./apps/node/internal/adapters/vllm/... ./apps/node/internal/adapters/... -(output) -``` - -### 최종 검증 - -```bash -$ go test -count=1 ./apps/node/... -(output) -``` diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 8ed074d..171b313 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap" edgeservice "iop/apps/edge/internal/service" + "iop/packages/go/config" ) func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { @@ -26,8 +27,8 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request_error", "invalid JSON request") return } - target := s.resolveTarget(req.Model) - if target == "" { + dispatch, ok := s.resolveRouteDispatch(req.Model, "") + if !ok { writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required") return } @@ -45,8 +46,8 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { input := req.runInput(prompt, messages, outputPolicy.Strict) s.logger.Info("openai chat completion input", zap.String("model", req.Model), - zap.String("target", target), - zap.String("adapter", s.resolveAdapter()), + zap.String("target", dispatch.Target), + zap.String("adapter", dispatch.Adapter), zap.Bool("strict_output", outputPolicy.Strict), zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer), zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool), @@ -59,13 +60,13 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { ) handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{ - NodeRef: s.cfg.NodeRef, - Adapter: s.resolveAdapter(), - Target: target, - SessionID: s.resolveSessionID(), + NodeRef: dispatch.NodeRef, + Adapter: dispatch.Adapter, + Target: dispatch.Target, + SessionID: dispatch.SessionID, Prompt: prompt, Input: input, - TimeoutSec: s.resolveTimeoutSec(), + TimeoutSec: dispatch.TimeoutSec, Metadata: map[string]string{ "source": "openai", "openai_model": req.Model, @@ -139,6 +140,74 @@ func (s *Server) resolveTargetWithOverride(model, override string) string { return strings.TrimSpace(model) } +// routeDispatch holds fully-resolved dispatch parameters for a single request. +type routeDispatch struct { + NodeRef string + Adapter string + Target string + SessionID string + TimeoutSec int +} + +// resolveRoute returns the first catalog entry whose Model matches model. +// Entries with an empty Target are skipped. +func (s *Server) resolveRoute(model string) *config.OpenAIRouteEntry { + model = strings.TrimSpace(model) + if model == "" { + return nil + } + for i := range s.cfg.ModelRoutes { + r := &s.cfg.ModelRoutes[i] + if strings.TrimSpace(r.Model) == model && r.Target != "" { + return r + } + } + return nil +} + +// resolveRouteDispatch returns fully-resolved dispatch params for model. +// Route catalog entries take priority; metadataTarget is only used in the +// legacy fallback path (when no catalog entry matches). +// Returns (dispatch, true) on success; (zero, false) when no target can be resolved. +func (s *Server) resolveRouteDispatch(model, metadataTarget string) (routeDispatch, bool) { + if route := s.resolveRoute(model); route != nil { + adapter := route.Adapter + if adapter == "" { + adapter = s.resolveAdapter() + } + nodeRef := route.NodeRef + if nodeRef == "" { + nodeRef = s.cfg.NodeRef + } + sessionID := route.SessionID + if sessionID == "" { + sessionID = s.resolveSessionID() + } + timeoutSec := route.TimeoutSec + if timeoutSec <= 0 { + timeoutSec = s.resolveTimeoutSec() + } + return routeDispatch{ + NodeRef: nodeRef, + Adapter: adapter, + Target: route.Target, + SessionID: sessionID, + TimeoutSec: timeoutSec, + }, true + } + target := s.resolveTargetWithOverride(model, metadataTarget) + if target == "" { + return routeDispatch{}, false + } + return routeDispatch{ + NodeRef: s.cfg.NodeRef, + Adapter: s.resolveAdapter(), + Target: target, + SessionID: s.resolveSessionID(), + TimeoutSec: s.resolveTimeoutSec(), + }, true +} + func (s *Server) resolveSessionID() string { if s.cfg.SessionID != "" { return s.cfg.SessionID diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go index efc148c..33f2e8e 100644 --- a/apps/edge/internal/openai/responses_handler.go +++ b/apps/edge/internal/openai/responses_handler.go @@ -48,8 +48,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { return } - target := s.resolveTargetWithOverride(req.Model, inferenceTarget) - if target == "" { + dispatch, ok := s.resolveRouteDispatch(req.Model, inferenceTarget) + if !ok { writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required") return } @@ -65,8 +65,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { s.logger.Info("openai responses input", zap.String("model", req.Model), - zap.String("target", target), - zap.String("adapter", s.resolveAdapter()), + zap.String("target", dispatch.Target), + zap.String("adapter", dispatch.Adapter), zap.Bool("strict_output", outputPolicy.Strict), zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool), zap.Bool("contract_instruction", outputPolicy.ContractInstruction), @@ -75,13 +75,13 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { ) handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{ - NodeRef: s.cfg.NodeRef, - Adapter: s.resolveAdapter(), - Target: target, - SessionID: s.resolveSessionID(), + NodeRef: dispatch.NodeRef, + Adapter: dispatch.Adapter, + Target: dispatch.Target, + SessionID: dispatch.SessionID, Prompt: prompt, Input: input, - TimeoutSec: s.resolveTimeoutSec(), + TimeoutSec: dispatch.TimeoutSec, Metadata: runMeta, }) if err != nil { diff --git a/apps/edge/internal/openai/routes.go b/apps/edge/internal/openai/routes.go index acf750b..e8e66c4 100644 --- a/apps/edge/internal/openai/routes.go +++ b/apps/edge/internal/openai/routes.go @@ -25,12 +25,22 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } - models := s.cfg.Models - if len(models) == 0 && s.cfg.Target != "" { - models = []string{s.cfg.Target} + var modelIDs []string + if len(s.cfg.ModelRoutes) > 0 { + for _, route := range s.cfg.ModelRoutes { + id := strings.TrimSpace(route.Model) + if id != "" && route.Target != "" { + modelIDs = append(modelIDs, id) + } + } + } else { + modelIDs = s.cfg.Models + if len(modelIDs) == 0 && s.cfg.Target != "" { + modelIDs = []string{s.cfg.Target} + } } - data := make([]openAIModel, 0, len(models)) - for _, model := range models { + data := make([]openAIModel, 0, len(modelIDs)) + for _, model := range modelIDs { model = strings.TrimSpace(model) if model == "" { continue diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 3b974d0..bfa9881 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -786,6 +786,206 @@ func TestCollectRunResultTimesOut(t *testing.T) { } } +func TestModelsExposesCatalogRouteModels(t *testing.T) { + srv := NewServer(config.EdgeOpenAIConf{ + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "model-a", Adapter: "ollama", Target: "llama3"}, + {Model: "model-b", Adapter: "vllm", Target: "qwen"}, + }, + }, &fakeRunService{}, nil) + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + w := httptest.NewRecorder() + srv.handleModels(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status: got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `"id":"model-a"`) || !strings.Contains(body, `"id":"model-b"`) { + t.Fatalf("expected catalog model IDs, got %s", body) + } +} + +func TestModelsSkipsRoutesWithEmptyTarget(t *testing.T) { + srv := NewServer(config.EdgeOpenAIConf{ + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "model-with-target", Adapter: "ollama", Target: "llama3"}, + {Model: "model-no-target", Adapter: "ollama", Target: ""}, + }, + }, &fakeRunService{}, nil) + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + w := httptest.NewRecorder() + srv.handleModels(w, req) + body := w.Body.String() + if !strings.Contains(body, `"id":"model-with-target"`) { + t.Fatalf("expected model-with-target in response, got %s", body) + } + if strings.Contains(body, "model-no-target") { + t.Fatalf("model-no-target should be skipped (no target), got %s", body) + } +} + +func TestModelsRouteCatalogWinsOverModelsField(t *testing.T) { + srv := NewServer(config.EdgeOpenAIConf{ + Models: []string{"legacy-model"}, + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "catalog-model", Adapter: "ollama", Target: "llama3"}, + }, + }, &fakeRunService{}, nil) + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + w := httptest.NewRecorder() + srv.handleModels(w, req) + body := w.Body.String() + if !strings.Contains(body, `"id":"catalog-model"`) { + t.Fatalf("expected catalog model in response, got %s", body) + } + if strings.Contains(body, "legacy-model") { + t.Fatalf("legacy model should be hidden when catalog is set, got %s", body) + } +} + +func TestChatCompletionsRouteCatalogDispatches(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{ + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "model-a", Adapter: "ollama", Target: "llama3"}, + {Model: "model-b", Adapter: "vllm", Target: "qwen"}, + }, + }, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"model-a", + "messages":[{"role":"user","content":"hi"}] + }`)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Adapter != "ollama" || fake.req.Target != "llama3" { + t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target) + } +} + +func TestChatCompletionsRouteCatalogDispatchesModelB(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{ + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "model-a", Adapter: "ollama", Target: "llama3"}, + {Model: "model-b", Adapter: "vllm", Target: "qwen"}, + }, + }, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"model-b", + "messages":[{"role":"user","content":"hi"}] + }`)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" { + t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target) + } +} + +func TestChatCompletionsCatalogMissFallsToLegacyTarget(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{ + Adapter: "ollama", + Target: "legacy-target", + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "model-a", Adapter: "ollama", Target: "llama3"}, + }, + }, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"unknown-model", + "messages":[{"role":"user","content":"hi"}] + }`)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Target != "legacy-target" { + t.Fatalf("expected legacy-target fallback, got %q", fake.req.Target) + } +} + +func TestChatCompletionsEmptyModelReturns400(t *testing.T) { + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, &fakeRunService{}, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"", + "messages":[{"role":"user","content":"hi"}] + }`)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty model, got %d body=%s", w.Code, w.Body.String()) + } +} + +func TestResponsesRouteCatalogDispatchesRoute(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{ + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "model-a", Adapter: "vllm", Target: "qwen"}, + }, + }, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"model-a", + "input":"say hello" + }`)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" { + t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target) + } +} + +func TestResponsesRouteCatalogTakesPriorityOverMetadataTarget(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{ + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "model-a", Adapter: "ollama", Target: "route-target"}, + }, + }, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"model-a", + "input":"test", + "metadata":{"inference":{"target":"metadata-target"}} + }`)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Target != "route-target" { + t.Fatalf("expected route-target, got %q", fake.req.Target) + } +} + func TestCollectRunResultFailsWhenEventStreamCloses(t *testing.T) { events := make(chan *iop.RunEvent) close(events) diff --git a/apps/node/internal/adapters/adapters_blackbox_test.go b/apps/node/internal/adapters/adapters_blackbox_test.go index e3afe0e..f073b57 100644 --- a/apps/node/internal/adapters/adapters_blackbox_test.go +++ b/apps/node/internal/adapters/adapters_blackbox_test.go @@ -2,8 +2,12 @@ package adapters_test import ( "context" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "strings" + "sync" "testing" "go.uber.org/zap" @@ -13,6 +17,18 @@ import ( iop "iop/proto/gen/iop" ) +type testSink struct { + mu sync.Mutex + events []noderuntime.RuntimeEvent +} + +func (s *testSink) Emit(_ context.Context, event noderuntime.RuntimeEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, event) + return nil +} + // --- BuildFromPayload tests --- func TestBuildFromPayload_EmptyPayloadRegistersNoAdapters(t *testing.T) { @@ -81,17 +97,17 @@ func TestBuildFromPayload_MultipleAdapters(t *testing.T) { } } -func TestBuildFromPayload_VllmEnabledRejected(t *testing.T) { - _, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ +func TestBuildFromPayload_VllmEnabled(t *testing.T) { + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "vllm", Enabled: true, Config: &iop.AdapterConfig_Vllm{Vllm: &iop.VllmAdapterConfig{Endpoint: "http://localhost:8000"}}}, }, }, zap.NewNop()) - if err == nil { - t.Fatal("expected vllm disabled error") + if err != nil { + t.Fatalf("build from payload: %v", err) } - if !strings.Contains(err.Error(), "vllm adapter is experimental and disabled") { - t.Fatalf("expected vllm disabled error, got %v", err) + if _, ok := reg.Get("vllm"); !ok { + t.Fatal("expected vllm adapter to be registered") } } @@ -397,3 +413,173 @@ func TestBuildFromPayload_MultiInstanceSameType(t *testing.T) { t.Errorf("Lookup ollama@local: %v", err) } } + +func TestBuildFromPayload_OllamaMultiInstanceExecution(t *testing.T) { + var ( + gotModelA string + gotNumCtxA int + gotModelB string + gotNumCtxB int + ) + + makeOllamaServer := func(gotModel *string, gotNumCtx *int, reply string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + _, _ = w.Write([]byte(`{"models":[]}`)) + return + } + if r.URL.Path != "/api/chat" { + return + } + var req struct { + Model string `json:"model"` + Options map[string]any `json:"options"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode: %v", err) + return + } + *gotModel = req.Model + if v, ok := req.Options["num_ctx"].(float64); ok { + *gotNumCtx = int(v) + } + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintf(w, `{"message":{"role":"assistant","content":"%s"},"done":false}`+"\n", reply) + _, _ = w.Write([]byte(`{"done":true}` + "\n")) + })) + } + + serverA := makeOllamaServer(&gotModelA, &gotNumCtxA, "a") + defer serverA.Close() + serverB := makeOllamaServer(&gotModelB, &gotNumCtxB, "b") + defer serverB.Close() + + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + { + Type: "ollama", + Name: "ollama@local", + Enabled: true, + Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: serverA.URL, ContextSize: 4096}}, + }, + { + Type: "ollama", + Name: "ollama@dgx", + Enabled: true, + Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: serverB.URL, ContextSize: 8192}}, + }, + }, + }, zap.NewNop()) + if err != nil { + t.Fatalf("build: %v", err) + } + + adapterA, ok := reg.Get("ollama@local") + if !ok { + t.Fatal("expected ollama@local") + } + adapterB, ok := reg.Get("ollama@dgx") + if !ok { + t.Fatal("expected ollama@dgx") + } + + if err := adapterA.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-a", Target: "model-a", Input: map[string]any{"prompt": "test"}, + }, &testSink{}); err != nil { + t.Fatalf("A execute: %v", err) + } + if err := adapterB.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-b", Target: "model-b", Input: map[string]any{"prompt": "test"}, + }, &testSink{}); err != nil { + t.Fatalf("B execute: %v", err) + } + + if gotModelA != "model-a" { + t.Errorf("A model: got %q want model-a", gotModelA) + } + if gotNumCtxA != 4096 { + t.Errorf("A num_ctx: got %d want 4096", gotNumCtxA) + } + if gotModelB != "model-b" { + t.Errorf("B model: got %q want model-b", gotModelB) + } + if gotNumCtxB != 8192 { + t.Errorf("B num_ctx: got %d want 8192", gotNumCtxB) + } + + capsA, _ := adapterA.Capabilities(context.Background()) + if capsA.InstanceKey != "ollama@local" { + t.Errorf("A InstanceKey: got %q want ollama@local", capsA.InstanceKey) + } + capsB, _ := adapterB.Capabilities(context.Background()) + if capsB.InstanceKey != "ollama@dgx" { + t.Errorf("B InstanceKey: got %q want ollama@dgx", capsB.InstanceKey) + } +} + +func TestBuildFromPayload_VllmExecution(t *testing.T) { + var gotModel string + var gotStream bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/models" { + _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) + return + } + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("unexpected path: %s", r.URL.Path) + return + } + var req struct { + Model string `json:"model"` + Stream bool `json:"stream"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode: %v", err) + return + } + gotModel = req.Model + gotStream = req.Stream + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + { + Type: "vllm", + Name: "vllm@gpu", + Enabled: true, + Config: &iop.AdapterConfig_Vllm{Vllm: &iop.VllmAdapterConfig{Endpoint: server.URL}}, + }, + }, + }, zap.NewNop()) + if err != nil { + t.Fatalf("build: %v", err) + } + + a, ok := reg.Get("vllm@gpu") + if !ok { + t.Fatal("expected vllm@gpu") + } + + if err := a.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-vllm", Target: "llama-3", Input: map[string]any{"prompt": "hi"}, + }, &testSink{}); err != nil { + t.Fatalf("Execute: %v", err) + } + + if gotModel != "llama-3" { + t.Errorf("model: got %q want llama-3", gotModel) + } + if !gotStream { + t.Error("expected stream=true") + } + + caps, _ := a.Capabilities(context.Background()) + if caps.InstanceKey != "vllm@gpu" { + t.Errorf("InstanceKey: got %q want vllm@gpu", caps.InstanceKey) + } +} diff --git a/apps/node/internal/adapters/factory.go b/apps/node/internal/adapters/factory.go index e799fe6..65d2d40 100644 --- a/apps/node/internal/adapters/factory.go +++ b/apps/node/internal/adapters/factory.go @@ -9,12 +9,11 @@ import ( "iop/apps/node/internal/adapters/cli" "iop/apps/node/internal/adapters/mock" "iop/apps/node/internal/adapters/ollama" + "iop/apps/node/internal/adapters/vllm" "iop/packages/go/config" iop "iop/proto/gen/iop" ) -const vllmDisabledError = "adapters: vllm adapter is experimental and disabled until streaming execution is implemented" - // BuildFromPayload creates a Registry from a NodeConfigPayload received from edge. func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Registry, error) { reg := NewRegistry() @@ -35,9 +34,15 @@ func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Regi } else { cfg = ollamaConfFromStruct(ac.GetSettings()) } - reg.RegisterKeyed(instanceKey, typeName, ollama.New(cfg, logger)) + reg.RegisterKeyed(instanceKey, typeName, ollama.New(cfg, logger, instanceKey)) case "vllm": - return nil, fmt.Errorf(vllmDisabledError) + var cfg config.VllmConf + if m := ac.GetVllm(); m != nil { + cfg = vllmConfFromProto(m) + } else { + cfg = vllmConfFromStruct(ac.GetSettings()) + } + reg.RegisterKeyed(instanceKey, typeName, vllm.New(cfg, logger, instanceKey)) case "cli": var cfg config.CLIConf if m := ac.GetCli(); m != nil { diff --git a/apps/node/internal/adapters/factory_internal_test.go b/apps/node/internal/adapters/factory_internal_test.go index 5902b36..5a7e533 100644 --- a/apps/node/internal/adapters/factory_internal_test.go +++ b/apps/node/internal/adapters/factory_internal_test.go @@ -1,7 +1,6 @@ package adapters import ( - "strings" "testing" "google.golang.org/protobuf/types/known/structpb" @@ -325,18 +324,18 @@ func TestBuildFromPayload_LegacySettingsFallback(t *testing.T) { } } -func TestBuildFromPayload_LegacyVllmSettingsRejected(t *testing.T) { +func TestBuildFromPayload_LegacyVllmSettingsRegistered(t *testing.T) { vllmSt, _ := structpb.NewStruct(map[string]any{"endpoint": "http://legacy:8000"}) - _, err := BuildFromPayload(&iop.NodeConfigPayload{ + reg, err := BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "vllm", Enabled: true, Settings: vllmSt}, }, }, nil) - if err == nil { - t.Fatal("expected vllm disabled error") + if err != nil { + t.Fatalf("build from payload: %v", err) } - if !strings.Contains(err.Error(), "vllm adapter is experimental and disabled") { - t.Fatalf("expected vllm disabled error, got %v", err) + if _, ok := reg.Get("vllm"); !ok { + t.Fatal("expected vllm adapter registered from legacy settings") } } diff --git a/apps/node/internal/adapters/ollama/ollama.go b/apps/node/internal/adapters/ollama/ollama.go index ddfb35f..8583d09 100644 --- a/apps/node/internal/adapters/ollama/ollama.go +++ b/apps/node/internal/adapters/ollama/ollama.go @@ -21,22 +21,30 @@ import ( const Name = "ollama" type Ollama struct { - baseURL string - contextSize int - client *http.Client - logger *zap.Logger + instanceName string + baseURL string + contextSize int + client *http.Client + logger *zap.Logger } -func New(cfg config.OllamaConf, logger *zap.Logger) *Ollama { +// New creates an Ollama adapter. The optional instanceName is the registry +// instance key used to disambiguate multiple Ollama instances on the same node. +func New(cfg config.OllamaConf, logger *zap.Logger, instanceName ...string) *Ollama { baseURL := strings.TrimRight(cfg.BaseURL, "/") if baseURL == "" { baseURL = "http://localhost:11434" } + name := Name + if len(instanceName) > 0 && instanceName[0] != "" { + name = instanceName[0] + } return &Ollama{ - baseURL: baseURL, - contextSize: cfg.ContextSize, - client: &http.Client{}, - logger: logger, + instanceName: name, + baseURL: baseURL, + contextSize: cfg.ContextSize, + client: &http.Client{}, + logger: logger, } } @@ -46,6 +54,7 @@ func (o *Ollama) Capabilities(_ context.Context) (runtime.Capabilities, error) { targets := o.fetchTargets() return runtime.Capabilities{ AdapterName: Name, + InstanceKey: o.instanceName, Targets: targets, MaxConcurrency: 4, }, nil @@ -303,8 +312,9 @@ func ollamaOptionsFromInput(input map[string]any, contextSize int) map[string]an } } if contextSize > 0 { - // num_ctx affects Ollama runner sizing, so keep it owned by Edge config. - options["num_ctx"] = contextSize + if _, hasNumCtx := options["num_ctx"]; !hasNumCtx { + options["num_ctx"] = contextSize + } } if len(options) == 0 { return nil diff --git a/apps/node/internal/adapters/ollama/ollama_test.go b/apps/node/internal/adapters/ollama/ollama_test.go index ee82830..2b3a35b 100644 --- a/apps/node/internal/adapters/ollama/ollama_test.go +++ b/apps/node/internal/adapters/ollama/ollama_test.go @@ -117,12 +117,12 @@ func TestOllamaExecutePassesOptionsAndTopLevelFields(t *testing.T) { } switch v := req.Options["num_ctx"].(type) { case float64: - if int(v) != 262144 { - t.Fatalf("config context size should override request num_ctx: %+v", req.Options) + if int(v) != 4096 { + t.Fatalf("request num_ctx should win over config default: got %d, want 4096", int(v)) } case int: - if v != 262144 { - t.Fatalf("config context size should override request num_ctx: %+v", req.Options) + if v != 4096 { + t.Fatalf("request num_ctx should win over config default: got %d, want 4096", v) } default: t.Fatalf("unexpected num_ctx type %T in %+v", v, req.Options) @@ -245,6 +245,84 @@ func TestOllamaHandleCommandPassesNativeAPI(t *testing.T) { } } +func TestOllamaMultiEndpoint(t *testing.T) { + var ( + gotModelA string + gotNumCtxA int + gotModelB string + gotNumCtxB int + ) + + makeServer := func(gotModel *string, gotNumCtx *int, reply string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + _, _ = w.Write([]byte(`{"models":[]}`)) + return + } + if r.URL.Path != "/api/chat" { + t.Errorf("unexpected path %s", r.URL.Path) + return + } + var req ollamaChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + return + } + *gotModel = req.Model + if v, ok := req.Options["num_ctx"].(float64); ok { + *gotNumCtx = int(v) + } + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"` + reply + `"},"done":false}` + "\n")) + _, _ = w.Write([]byte(`{"done":true}` + "\n")) + })) + } + + serverA := makeServer(&gotModelA, &gotNumCtxA, "a") + defer serverA.Close() + serverB := makeServer(&gotModelB, &gotNumCtxB, "b") + defer serverB.Close() + + adapterA := New(config.OllamaConf{BaseURL: serverA.URL, ContextSize: 4096}, zap.NewNop(), "ollama-a") + adapterB := New(config.OllamaConf{BaseURL: serverB.URL, ContextSize: 8192}, zap.NewNop(), "ollama-b") + + sinkA := &fakeSink{} + if err := adapterA.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-a", Target: "model-a", Input: map[string]any{"prompt": "test"}, + }, sinkA); err != nil { + t.Fatalf("A execute: %v", err) + } + + sinkB := &fakeSink{} + if err := adapterB.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-b", Target: "model-b", Input: map[string]any{"prompt": "test"}, + }, sinkB); err != nil { + t.Fatalf("B execute: %v", err) + } + + if gotModelA != "model-a" { + t.Errorf("A model: got %q, want model-a", gotModelA) + } + if gotNumCtxA != 4096 { + t.Errorf("A num_ctx: got %d, want 4096", gotNumCtxA) + } + if gotModelB != "model-b" { + t.Errorf("B model: got %q, want model-b", gotModelB) + } + if gotNumCtxB != 8192 { + t.Errorf("B num_ctx: got %d, want 8192", gotNumCtxB) + } + + capsA, _ := adapterA.Capabilities(context.Background()) + if capsA.InstanceKey != "ollama-a" { + t.Errorf("A InstanceKey: got %q, want ollama-a", capsA.InstanceKey) + } + capsB, _ := adapterB.Capabilities(context.Background()) + if capsB.InstanceKey != "ollama-b" { + t.Errorf("B InstanceKey: got %q, want ollama-b", capsB.InstanceKey) + } +} + func TestOllamaExecuteStreamsThinkingDeltasSeparately(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/chat" { diff --git a/apps/node/internal/adapters/vllm/vllm.go b/apps/node/internal/adapters/vllm/vllm.go index 5756fea..50f983e 100644 --- a/apps/node/internal/adapters/vllm/vllm.go +++ b/apps/node/internal/adapters/vllm/vllm.go @@ -1,11 +1,14 @@ -// Package vllm contains the experimental vLLM adapter surface. It can query -// models, but execution remains disabled until streaming support is implemented. +// Package vllm provides an Adapter for OpenAI-compatible inference engines +// such as vLLM and SGLang via the /v1/chat/completions SSE endpoint. package vllm import ( + "bufio" + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "strings" @@ -20,17 +23,25 @@ import ( const Name = "vllm" type Vllm struct { - endpoint string - client *http.Client - logger *zap.Logger + instanceName string + endpoint string + client *http.Client + logger *zap.Logger } -func New(cfg config.VllmConf, logger *zap.Logger) *Vllm { +// New creates a vLLM adapter. The optional instanceName is the registry +// instance key used to disambiguate multiple vLLM instances on the same node. +func New(cfg config.VllmConf, logger *zap.Logger, instanceName ...string) *Vllm { endpoint := strings.TrimRight(cfg.Endpoint, "/") + name := Name + if len(instanceName) > 0 && instanceName[0] != "" { + name = instanceName[0] + } return &Vllm{ - endpoint: endpoint, - client: &http.Client{}, - logger: logger, + instanceName: name, + endpoint: endpoint, + client: &http.Client{}, + logger: logger, } } @@ -39,18 +50,116 @@ func (v *Vllm) Name() string { return Name } func (v *Vllm) Capabilities(_ context.Context) (runtime.Capabilities, error) { return runtime.Capabilities{ AdapterName: Name, + InstanceKey: v.instanceName, Targets: v.fetchTargets(), MaxConcurrency: 8, }, nil } func (v *Vllm) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error { - v.logger.Info("vllm adapter called", + if v.endpoint == "" { + return fmt.Errorf("vllm adapter: endpoint is required") + } + model := strings.TrimSpace(spec.Target) + if model == "" { + model = stringInput(spec.Input, "model") + } + if model == "" { + return fmt.Errorf("vllm adapter: target/model is required") + } + messages := messagesFromInput(spec.Input) + if len(messages) == 0 { + return fmt.Errorf("vllm adapter: messages are required") + } + + if err := sink.Emit(ctx, runtime.RuntimeEvent{ + RunID: spec.RunID, + Type: runtime.EventTypeStart, + Timestamp: time.Now(), + }); err != nil { + return err + } + + chatReq := vllmChatRequest{ + Model: model, + Messages: messages, + Stream: true, + } + body, err := json.Marshal(chatReq) + if err != nil { + return fmt.Errorf("vllm adapter: marshal request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(v.endpoint, "/v1/chat/completions"), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("vllm adapter: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + v.logger.Info("vllm adapter executing", zap.String("run_id", spec.RunID), - zap.String("target", spec.Target), + zap.String("target", model), zap.String("endpoint", v.endpoint), ) - return fmt.Errorf("vllm adapter: experimental adapter disabled until streaming execution is implemented") + resp, err := v.client.Do(req) + if err != nil { + _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("vllm request failed: %v", err)) + return fmt.Errorf("vllm adapter: request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg := readLimited(resp.Body, 4096) + if msg == "" { + msg = resp.Status + } + _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("vllm returned %s: %s", resp.Status, msg)) + return fmt.Errorf("vllm adapter: non-2xx response: %s", resp.Status) + } + + scanner := bufio.NewScanner(resp.Body) + outputTokens := 0 + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + return sink.Emit(ctx, runtime.RuntimeEvent{ + RunID: spec.RunID, + Type: runtime.EventTypeComplete, + Message: "vllm chat complete", + Usage: &runtime.UsageStats{ + OutputTokens: outputTokens, + }, + Timestamp: time.Now(), + }) + } + var chunk vllmChatChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil { + continue + } + if len(chunk.Choices) == 0 { + continue + } + content := chunk.Choices[0].Delta.Content + if content != "" { + outputTokens += len(strings.Fields(content)) + if err := sink.Emit(ctx, runtime.RuntimeEvent{ + RunID: spec.RunID, + Type: runtime.EventTypeDelta, + Delta: content, + Timestamp: time.Now(), + }); err != nil { + return err + } + } + } + if err := scanner.Err(); err != nil { + _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("vllm stream scan failed: %v", err)) + return fmt.Errorf("vllm adapter: scan stream: %w", err) + } + _ = emitError(ctx, sink, spec.RunID, "vllm stream ended without [DONE]") + return fmt.Errorf("vllm adapter: stream ended without [DONE]") } func (v *Vllm) fetchTargets() []string { @@ -84,6 +193,52 @@ func (v *Vllm) fetchTargets() []string { return out } +func messagesFromInput(input map[string]any) []vllmMessage { + if raw, ok := input["messages"].([]any); ok { + out := make([]vllmMessage, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + role, _ := m["role"].(string) + content, _ := m["content"].(string) + role = strings.TrimSpace(role) + content = strings.TrimSpace(content) + if role == "" || content == "" { + continue + } + out = append(out, vllmMessage{Role: role, Content: content}) + } + if len(out) > 0 { + return out + } + } + if prompt := strings.TrimSpace(stringInput(input, "prompt")); prompt != "" { + return []vllmMessage{{Role: "user", Content: prompt}} + } + return nil +} + +func emitError(ctx context.Context, sink runtime.EventSink, runID, msg string) error { + return sink.Emit(ctx, runtime.RuntimeEvent{ + RunID: runID, + Type: runtime.EventTypeError, + Error: msg, + Timestamp: time.Now(), + }) +} + +func stringInput(input map[string]any, key string) string { + if input == nil { + return "" + } + if v, ok := input[key].(string); ok { + return v + } + return "" +} + func joinURL(baseURL, path string) string { u, err := url.Parse(baseURL) if err != nil { @@ -93,6 +248,30 @@ func joinURL(baseURL, path string) string { return u.String() } +func readLimited(r io.Reader, limit int64) string { + b, _ := io.ReadAll(io.LimitReader(r, limit)) + return strings.TrimSpace(string(b)) +} + +type vllmChatRequest struct { + Model string `json:"model"` + Messages []vllmMessage `json:"messages"` + Stream bool `json:"stream"` +} + +type vllmMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type vllmChatChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` +} + type vllmModelsResponse struct { Data []struct { ID string `json:"id"` diff --git a/apps/node/internal/adapters/vllm/vllm_test.go b/apps/node/internal/adapters/vllm/vllm_test.go index fc64e2e..4a45bf6 100644 --- a/apps/node/internal/adapters/vllm/vllm_test.go +++ b/apps/node/internal/adapters/vllm/vllm_test.go @@ -2,9 +2,12 @@ package vllm import ( "context" + "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" + "sync" "testing" "go.uber.org/zap" @@ -13,6 +16,24 @@ import ( "iop/packages/go/config" ) +type fakeSink struct { + mu sync.Mutex + events []runtime.RuntimeEvent +} + +func (s *fakeSink) Emit(_ context.Context, event runtime.RuntimeEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, event) + return nil +} + +func (s *fakeSink) all() []runtime.RuntimeEvent { + s.mu.Lock() + defer s.mu.Unlock() + return append([]runtime.RuntimeEvent(nil), s.events...) +} + func TestVllmCapabilitiesQueryModels(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { @@ -32,16 +53,138 @@ func TestVllmCapabilitiesQueryModels(t *testing.T) { } } -func TestVllmExecuteDisabled(t *testing.T) { - adapter := New(config.VllmConf{Endpoint: "http://localhost:8000"}, zap.NewNop()) +func TestVllmExecuteStreamsDeltas(t *testing.T) { + var gotModel string + var gotMessages int + var gotStream bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + var req struct { + Model string `json:"model"` + Messages []any `json:"messages"` + Stream bool `json:"stream"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + gotModel = req.Model + gotMessages = len(req.Messages) + gotStream = req.Stream + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"hello "}}]}`) + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"world"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop()) + sink := &fakeSink{} err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ RunID: "run-1", - Target: "model-a", - }, nil) - if err == nil { - t.Fatal("expected disabled execution error") + Target: "llama-3", + Input: map[string]any{"prompt": "say hello"}, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) } - if !strings.Contains(err.Error(), "experimental adapter disabled") { - t.Fatalf("expected experimental disabled error, got %v", err) + if gotModel != "llama-3" { + t.Fatalf("model: got %q", gotModel) + } + if gotMessages != 1 { + t.Fatalf("messages: got %d", gotMessages) + } + if !gotStream { + t.Fatal("expected stream=true") + } + + events := sink.all() + if len(events) != 4 { + t.Fatalf("expected 4 events (start+delta+delta+complete), got %d: %+v", len(events), events) + } + if events[0].Type != runtime.EventTypeStart { + t.Fatalf("expected start event, got %+v", events[0]) + } + if events[1].Delta+events[2].Delta != "hello world" { + t.Fatalf("unexpected deltas: %q + %q", events[1].Delta, events[2].Delta) + } + if events[3].Type != runtime.EventTypeComplete { + t.Fatalf("expected complete event, got %+v", events[3]) + } +} + +func TestVllmExecuteUsesMessagesInput(t *testing.T) { + var gotMessages []map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Messages []map[string]any `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + gotMessages = req.Messages + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop()) + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-2", + Target: "llama-3", + Input: map[string]any{ + "messages": []any{ + map[string]any{"role": "system", "content": "You are helpful."}, + map[string]any{"role": "user", "content": "hi"}, + }, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + if len(gotMessages) != 2 { + t.Fatalf("messages: got %d", len(gotMessages)) + } + if gotMessages[0]["role"] != "system" || gotMessages[1]["role"] != "user" { + t.Fatalf("unexpected messages: %+v", gotMessages) + } +} + +func TestVllmExecuteEmitsErrorForHTTPFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + + adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop()) + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-err", + Target: "llama-3", + Input: map[string]any{"prompt": "hi"}, + }, sink) + if err == nil { + t.Fatal("expected error") + } + events := sink.all() + if len(events) < 2 || events[1].Type != runtime.EventTypeError { + t.Fatalf("expected error event after start, got %+v", events) + } + if !strings.Contains(events[1].Error, "503") { + t.Fatalf("expected status in error, got %q", events[1].Error) + } +} + +func TestVllmInstanceKey(t *testing.T) { + adapter := New(config.VllmConf{Endpoint: "http://localhost:8000"}, zap.NewNop(), "vllm-gpu") + caps, _ := adapter.Capabilities(context.Background()) + if caps.InstanceKey != "vllm-gpu" { + t.Fatalf("InstanceKey: got %q, want vllm-gpu", caps.InstanceKey) + } + if caps.AdapterName != Name { + t.Fatalf("AdapterName: got %q, want %q", caps.AdapterName, Name) } } diff --git a/apps/node/internal/node/node.go b/apps/node/internal/node/node.go index 8ec16c9..6aefc7a 100644 --- a/apps/node/internal/node/node.go +++ b/apps/node/internal/node/node.go @@ -218,6 +218,7 @@ func (n *Node) handleCapabilitiesCommand(ctx context.Context, req *iop.NodeComma sort.Strings(targets) result := map[string]string{ "adapter": caps.AdapterName, + "instance_key": caps.InstanceKey, "targets": strings.Join(targets, ","), "max_concurrency": strconv.Itoa(caps.MaxConcurrency), } diff --git a/apps/node/internal/node/node_test.go b/apps/node/internal/node/node_test.go index 4759ebe..aa7938e 100644 --- a/apps/node/internal/node/node_test.go +++ b/apps/node/internal/node/node_test.go @@ -87,6 +87,23 @@ func (a *terminatingAdapter) TerminateSession(_ context.Context, target, session return nil } +type instanceKeyAdapter struct { + instanceKey string +} + +func (a *instanceKeyAdapter) Name() string { return "ollama" } +func (a *instanceKeyAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) { + return runtime.Capabilities{ + AdapterName: "ollama", + InstanceKey: a.instanceKey, + Targets: []string{"llama3"}, + MaxConcurrency: 4, + }, nil +} +func (a *instanceKeyAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error { + return nil +} + type commandAdapter struct { lastReq runtime.CommandRequest } @@ -829,10 +846,10 @@ func TestOnCommandRequest_AdapterDispatch_AmbiguousAdapter(t *testing.T) { } func TestOnCommandRequest_Capabilities_ExactInstanceKey(t *testing.T) { - ca := &commandAdapter{} + ika := &instanceKeyAdapter{instanceKey: "ollama@local"} router := &fixedRouter{ adapterName: "ollama@local", - adapters: map[string]runtime.Adapter{"ollama@local": ca}, + adapters: map[string]runtime.Adapter{"ollama@local": ika}, } n, _ := makeNode(t, router) @@ -848,7 +865,10 @@ func TestOnCommandRequest_Capabilities_ExactInstanceKey(t *testing.T) { if resp.GetError() != "" { t.Fatalf("expected no error for exact instance key, got %q", resp.GetError()) } - if got := resp.GetResult()["adapter"]; got != "command" { - t.Errorf("result[adapter]: got %q want %q", got, "command") + if got := resp.GetResult()["adapter"]; got != "ollama" { + t.Errorf("result[adapter]: got %q want ollama", got) + } + if got := resp.GetResult()["instance_key"]; got != "ollama@local" { + t.Errorf("result[instance_key]: got %q want ollama@local", got) } } diff --git a/apps/node/internal/runtime/types.go b/apps/node/internal/runtime/types.go index b6bce3b..a425f57 100644 --- a/apps/node/internal/runtime/types.go +++ b/apps/node/internal/runtime/types.go @@ -77,6 +77,7 @@ type UsageStats struct { // Capabilities describes what an Adapter can do. type Capabilities struct { AdapterName string + InstanceKey string // stable registry instance key; empty for single-instance adapters Targets []string MaxConcurrency int } diff --git a/configs/edge.yaml b/configs/edge.yaml index 3504760..4592173 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -53,6 +53,17 @@ openai: adapter: "ollama" target: "" models: [] + # model_routes 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. + # model_routes: + # - model: "llama3" + # adapter: "ollama" + # target: "llama3:8b" + # - model: "qwen3" + # adapter: "vllm" + # target: "qwen3-72b" + # node: "node-gpu-01" session_id: "openai" timeout_sec: 120 strict_output: true diff --git a/packages/go/config/config.go b/packages/go/config/config.go index 68245cb..beb1683 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "strings" "github.com/spf13/viper" ) @@ -84,17 +85,29 @@ type EdgeBootstrapConf struct { ArtifactDir string `mapstructure:"artifact_dir" yaml:"artifact_dir"` } +// OpenAIRouteEntry maps an external model id to an internal adapter/target routing. +// Fields not set here fall back to the top-level EdgeOpenAIConf defaults. +type OpenAIRouteEntry struct { + Model string `mapstructure:"model" yaml:"model"` + NodeRef string `mapstructure:"node" yaml:"node,omitempty"` + Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"` + Target string `mapstructure:"target" yaml:"target"` + SessionID string `mapstructure:"session_id" yaml:"session_id,omitempty"` + TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec,omitempty"` +} + type EdgeOpenAIConf struct { - Enabled bool `mapstructure:"enabled" yaml:"enabled"` - Listen string `mapstructure:"listen" yaml:"listen"` - NodeRef string `mapstructure:"node" yaml:"node"` - Adapter string `mapstructure:"adapter" yaml:"adapter"` - Target string `mapstructure:"target" yaml:"target"` - Models []string `mapstructure:"models" yaml:"models"` - SessionID string `mapstructure:"session_id" yaml:"session_id"` - TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` - StrictOutput bool `mapstructure:"strict_output" yaml:"strict_output"` - StrictStreamBuffer bool `mapstructure:"strict_stream_buffer" yaml:"strict_stream_buffer"` + Enabled bool `mapstructure:"enabled" yaml:"enabled"` + Listen string `mapstructure:"listen" yaml:"listen"` + NodeRef string `mapstructure:"node" yaml:"node"` + Adapter string `mapstructure:"adapter" yaml:"adapter"` + Target string `mapstructure:"target" yaml:"target"` + Models []string `mapstructure:"models" yaml:"models"` + ModelRoutes []OpenAIRouteEntry `mapstructure:"model_routes" yaml:"model_routes,omitempty"` + SessionID string `mapstructure:"session_id" yaml:"session_id"` + TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` + StrictOutput bool `mapstructure:"strict_output" yaml:"strict_output"` + StrictStreamBuffer bool `mapstructure:"strict_stream_buffer" yaml:"strict_stream_buffer"` } type EdgeA2AConf struct { @@ -266,6 +279,9 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) { cfg.Console.Target = cfg.Console.Model } } + if err := validateOpenAIRoutes(cfg.OpenAI.ModelRoutes); err != nil { + return nil, err + } for i := range cfg.Nodes { kind, err := NormalizeAgentKind(cfg.Nodes[i].AgentKind) if err != nil { @@ -314,6 +330,22 @@ func normalizeAdapters(a *AdaptersConf) error { return nil } +// validateOpenAIRoutes rejects duplicate and empty model ids in the route catalog. +func validateOpenAIRoutes(routes []OpenAIRouteEntry) error { + seen := make(map[string]struct{}, len(routes)) + for i, r := range routes { + model := strings.TrimSpace(r.Model) + if model == "" { + return fmt.Errorf("openai.model_routes[%d]: model must not be empty", i) + } + if _, dup := seen[model]; dup { + return fmt.Errorf("openai.model_routes: duplicate model %q", model) + } + seen[model] = struct{}{} + } + return nil +} + func checkUniqueNames(field string, name func(int) string, n int) error { seen := make(map[string]struct{}, n) for i := 0; i < n; i++ { diff --git a/packages/go/config/config_test.go b/packages/go/config/config_test.go index eefa771..9061402 100644 --- a/packages/go/config/config_test.go +++ b/packages/go/config/config_test.go @@ -1060,6 +1060,100 @@ nodes: } } +func TestLoadEdge_OpenAIRouteCatalog(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +openai: + enabled: true + listen: "0.0.0.0:18081" + adapter: "ollama" + model_routes: + - model: "model-a" + adapter: "ollama" + target: "llama3" + node: "node-01" + session_id: "sess-a" + timeout_sec: 30 + - model: "model-b" + adapter: "vllm" + target: "qwen" +` + 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) != 2 { + t.Fatalf("expected 2 model_routes, got %d", len(cfg.OpenAI.ModelRoutes)) + } + r0 := cfg.OpenAI.ModelRoutes[0] + if r0.Model != "model-a" || r0.Adapter != "ollama" || r0.Target != "llama3" { + t.Errorf("route[0] mismatch: %+v", r0) + } + if r0.NodeRef != "node-01" || r0.SessionID != "sess-a" || r0.TimeoutSec != 30 { + t.Errorf("route[0] optional fields mismatch: %+v", r0) + } + r1 := cfg.OpenAI.ModelRoutes[1] + if r1.Model != "model-b" || r1.Adapter != "vllm" || r1.Target != "qwen" { + t.Errorf("route[1] mismatch: %+v", r1) + } +} + +func TestLoadEdge_OpenAIRouteCatalogDuplicateModelRejects(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +openai: + model_routes: + - model: "model-a" + adapter: "ollama" + target: "llama3" + - model: "model-a" + adapter: "vllm" + target: "qwen" +` + 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 route") + } + if !strings.Contains(err.Error(), "model_routes") || !strings.Contains(err.Error(), "model-a") { + t.Fatalf("expected error mentioning model_routes and model-a, got %v", err) + } +} + +func TestLoadEdge_OpenAIRouteCatalogEmptyModelRejects(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +openai: + model_routes: + - model: "" + target: "llama3" +` + 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 in route catalog") + } + if !strings.Contains(err.Error(), "model_routes") { + t.Fatalf("expected error mentioning model_routes, got %v", err) + } +} + func TestLoadEdge_ControlPlaneOverride(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "edge.yaml")