feat: m-node-multi-target-serving-foundation G07/G08 work - route catalog, engine profiles, edge/node updates

This commit is contained in:
toki 2026-06-11 14:43:24 +09:00
parent 30099e54d2
commit 738bd1939c
29 changed files with 1994 additions and 271 deletions

View file

@ -0,0 +1,140 @@
<!-- task=m-node-multi-target-serving-foundation/03+01_route_catalog plan=0 tag=API -->
# 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 파일을 작성한다.

View file

@ -0,0 +1,147 @@
<!-- task=m-node-multi-target-serving-foundation/03+01_route_catalog plan=1 tag=REVIEW_API -->
# 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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,109 @@
<!-- task=m-node-multi-target-serving-foundation/03+01_route_catalog plan=1 tag=REVIEW_API -->
# 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 실패가 남으면 정확한 실패 테스트 이름을 검증 결과에 기록하고, 변경 패키지 테스트가 통과했는지 별도로 명시한다.

View file

@ -0,0 +1,132 @@
<!-- task=m-node-multi-target-serving-foundation/04+01,02_engine_profiles plan=0 tag=API -->
# 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`를 작성한다.

View file

@ -0,0 +1,154 @@
<!-- task=m-node-multi-target-serving-foundation/04+01,02_engine_profiles plan=1 tag=REVIEW_API -->
# 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,99 @@
<!-- task=m-node-multi-target-serving-foundation/04+01,02_engine_profiles plan=1 tag=REVIEW_API -->
# 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`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -1,91 +0,0 @@
<!-- task=m-node-multi-target-serving-foundation/03+01_route_catalog plan=0 tag=API -->
# 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)
```

View file

@ -1,91 +0,0 @@
<!-- task=m-node-multi-target-serving-foundation/04+01,02_engine_profiles plan=0 tag=API -->
# 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)
```

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -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

View file

@ -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" {

View file

@ -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"`

View file

@ -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)
}
}

View file

@ -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),
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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++ {

View file

@ -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")