feat(node): inference provider availability probing을 추가한다
Adapter에 ProviderProber 인터페이스를 도입하여 provider endpoint와 target model의 가용성을 확인한다. - runtime에 ProviderStatus, ProviderProbeResult 타입을 추가한다 - ollama/vllm/mock adapter에 ProbeProvider를 구현한다 - node handleCapabilitiesCommand에서 Prober 인터페이스를 호출하여 target-aware provider status를 응답한다 - 각 adapter 및 node 테스트를 추가한다 - 로드맵 문서를 갱신한다
This commit is contained in:
parent
bf0265ed48
commit
0e02958684
15 changed files with 1434 additions and 32 deletions
|
|
@ -20,7 +20,7 @@ Ollama 경로가 안정화된 뒤, 그 결과를 기준선으로 삼아 Lemonade
|
|||
- 경로: `agent-roadmap/archive/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md`
|
||||
- 요약: 하나의 Node 연결이 여러 CLI profile, terminal gateway, Ollama/vLLM/SGLang 같은 추론 엔진 연결, 여러 model target을 동시에 제공할 수 있도록 config/proto/routing/runtime 기준선을 정리했고, 최종 코드 리뷰와 검증까지 통과해 archive했다.
|
||||
|
||||
- [계획] Node provider 상태와 Capacity Queue 기반
|
||||
- [진행중] Node provider 상태와 Capacity Queue 기반
|
||||
- 경로: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
|
||||
- 요약: NomadCode workspace 실행 계약이 닫힌 뒤 provider별 health/model probe 차이를 Node adapter 내부로 숨기고, Edge가 공통으로 볼 수 있는 최소 상태와 capacity/in-flight/queued snapshot, FIFO admission queue 기준선을 만든다.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Ollama, Lemonade, vLLM, SGLang 같은 provider별 상태 확인 방식 차이를
|
|||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
[진행중]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ Ollama, Lemonade, vLLM, SGLang 같은 provider별 상태 확인 방식 차이를
|
|||
|
||||
Provider별 probe 차이를 Node 내부로 감추고 Edge가 라우팅 입력으로 사용할 수 있는 최소 상태 snapshot을 정의한다.
|
||||
|
||||
- [ ] [status-model] Node가 Edge에 노출하는 provider 상태 모델은 `unknown`, `available`, `unavailable`로 제한하고, provider가 직접 보장하지 않는 `degraded`, `starting`, `draining` 같은 상태는 넣지 않는다.
|
||||
- [x] [status-model] Node가 Edge에 노출하는 provider 상태 모델은 `unknown`, `available`, `unavailable`로 제한하고, provider가 직접 보장하지 않는 `degraded`, `starting`, `draining` 같은 상태는 넣지 않는다.
|
||||
- [ ] [probe-contract] Ollama, Lemonade, vLLM, SGLang 등 provider adapter가 endpoint 생존 여부와 target/model 사용 가능 여부를 확인하는 공통 probe 인터페이스를 구현한다.
|
||||
- [ ] [edge-snapshot] Edge가 provider별 구현 세부를 모르고 Node의 provider 상태, capacity, in-flight, queued 값을 조회하거나 이벤트로 받을 수 있는 계약을 정리한다.
|
||||
|
||||
|
|
@ -77,6 +77,9 @@ Provider별 동시 처리 한도를 IOP가 소유하고, 한도를 넘는 요청
|
|||
- 표준선(선택): provider가 직접 제공하지 않는 상태를 IOP 공통 상태로 만들지 않고, 초기 상태는 `unknown`, `available`, `unavailable`만 사용한다.
|
||||
- 표준선(선택): capacity와 queue는 provider 기능이 아니라 Node의 공통 provider execution wrapper 책임으로 둔다.
|
||||
- 표준선(선택): Edge routing은 provider별 endpoint가 아니라 Node가 제공하는 availability/load snapshot을 입력으로 삼는다.
|
||||
- 진행 근거: `status-model`은 `apps/node/internal/runtime/types.go`와 Node `CAPABILITIES` 응답에 제한 상태 모델을 추가했고 `go test ./apps/node/...`로 검증했다.
|
||||
- 활성 plan: `agent-task/m-provider-availability-capacity-queue-foundation/01_probe_contract/PLAN-local-G05.md`가 `probe-contract`를 다룬다.
|
||||
- 활성 plan: `agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/PLAN-cloud-G06.md`가 `edge-snapshot`을 다루며 `01_probe_contract` 완료에 의존한다.
|
||||
- 선행 작업: Node 단일 통로 멀티 타겟 서빙 기반, OpenAI Workspace Agent Execution Contract
|
||||
- 후속 작업: Lemonade provider 서빙 경로 추가, vLLM provider 서빙 경로 추가, SGLang provider 서빙 경로 추가, model group alias와 capacity-aware routing Milestone
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
<!-- task=m-provider-availability-capacity-queue-foundation/01_probe_contract plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - 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`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-14
|
||||
task=m-provider-availability-capacity-queue-foundation/01_probe_contract, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
|
||||
- Task ids:
|
||||
- `probe-contract`: Ollama, Lemonade, vLLM, SGLang 등 provider adapter가 endpoint 생존 여부와 target/model 사용 가능 여부를 확인하는 공통 probe 인터페이스를 구현한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-provider-availability-capacity-queue-foundation/01_probe_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-provider-availability-capacity-queue-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Runtime Provider Probe Contract | [x] |
|
||||
| [API-2] Provider Adapter Probe Implementations | [x] |
|
||||
| [API-3] Node Capabilities Uses Probe | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] runtime에 provider probe 결과 타입과 interface를 추가하고 `ProviderStatus` 제한 모델을 재사용한다.
|
||||
- [x] Ollama adapter가 `/api/tags` endpoint 생존과 target/model 존재 여부를 `available|unavailable`로 반환하도록 구현하고 테스트한다.
|
||||
- [x] vLLM adapter가 `/v1/models` endpoint 생존과 target/model 존재 여부를 `available|unavailable`로 반환하도록 구현하고 SGLang 호환 경로를 문서화한다.
|
||||
- [x] Node `CAPABILITIES` command가 provider prober를 발견하면 target-aware probe 결과로 `provider_status`와 targets를 채우도록 구현한다.
|
||||
- [x] `go test ./apps/node/...`를 실행한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-provider-availability-capacity-queue-foundation/01_probe_contract/`를 `agent-task/archive/YYYY/MM/m-provider-availability-capacity-queue-foundation/01_probe_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-provider-availability-capacity-queue-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
없음
|
||||
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `Ollama` 및 `vLLM` 어댑터가 `runtime.ProviderProber` 인터페이스를 구현하도록 추가하여, 각 모델 공급자의 엔드포인트 생존과 특정 타겟 모델의 존재 여부를 헬스체크할 수 있는 공통 프로브 로직을 캡슐화하였습니다.
|
||||
- `vLLM` 어댑터 구현 내 SGLang 호환성 언급을 추가하여 OpenAI 호환 규격을 통한 프로빙이 SGLang에서도 동일하게 적용됨을 보장하였습니다.
|
||||
- Node의 `handleCapabilitiesCommand` 내에서 어댑터가 `ProviderProber` 인터페이스를 지원하면 실행 시점에 `ProbeProvider`를 능동적으로 호출해 그 결과를 `provider_status` 및 `targets`에 병합하도록 하였습니다. 프로빙 에러는 커맨드 전체의 실패로 처리하지 않고 `unavailable` 상태 및 상세 에러 메시지를 `provider_detail`에 담아 리턴하도록 설계했습니다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- Provider status가 `unknown|available|unavailable` 외 값으로 노출되지 않는지 확인한다.
|
||||
- Probe 실패가 command transport error와 혼동되지 않는지 확인한다.
|
||||
- Ollama/vLLM target miss와 endpoint failure 테스트가 모두 있는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### API-1 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/node/internal/runtime ./apps/node/internal/node
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/node 0.121s
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/node/internal/adapters/ollama ./apps/node/internal/adapters/vllm
|
||||
ok iop/apps/node/internal/adapters/ollama 0.010s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.010s
|
||||
```
|
||||
|
||||
### API-3 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/node/internal/node
|
||||
ok iop/apps/node/internal/node 0.121s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ go test -count=1 ./apps/node/...
|
||||
ok iop/apps/node/cmd/node 0.012s
|
||||
ok iop/apps/node/internal/adapters 0.010s
|
||||
ok iop/apps/node/internal/adapters/cli 47.166s
|
||||
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.924s
|
||||
? iop/apps/node/internal/adapters/mock [no test files]
|
||||
ok iop/apps/node/internal/adapters/ollama 0.011s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.011s
|
||||
ok iop/apps/node/internal/bootstrap 0.269s
|
||||
ok iop/apps/node/internal/node 0.129s
|
||||
ok iop/apps/node/internal/router 0.010s
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/store 0.051s
|
||||
ok iop/apps/node/internal/terminal 0.569s
|
||||
ok iop/apps/node/internal/transport 5.144s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ownership
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
|
||||
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify |
|
||||
| 구현 항목별 완료 여부 | Implementing agent checks only | Item names remain fixed |
|
||||
| 구현 체크리스트 | Implementing agent checks only | Text/order remain fixed |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text |
|
||||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user-only blocker exists |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
|
||||
| 검증 결과 | Implementing agent | Paste actual output |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- Correctness: Pass
|
||||
- Completeness: Pass
|
||||
- Test coverage: Pass
|
||||
- API contract: Pass
|
||||
- Code quality: Pass
|
||||
- Plan deviation: Pass
|
||||
- Verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 리뷰 중 직접 정리:
|
||||
- `apps/node/internal/adapters/ollama/ollama_test.go`와 `apps/node/internal/adapters/vllm/vllm_test.go`의 EOF formatting nit를 정리했다.
|
||||
- 검증:
|
||||
- `go test -count=1 ./apps/node/internal/runtime ./apps/node/internal/node ./apps/node/internal/adapters/ollama ./apps/node/internal/adapters/vllm` - PASS
|
||||
- `go test -count=1 ./apps/node/...` - PASS
|
||||
- `./scripts/e2e-smoke.sh` - PASS; 보조 edge-node smoke로 `/capabilities`, `/transport`, `/sessions`, foreground/background 메시지 relay를 확인했다.
|
||||
- `git diff --check` - PASS
|
||||
- 다음 단계: PASS 종결. active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# Complete - m-provider-availability-capacity-queue-foundation/01_probe_contract
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-14
|
||||
|
||||
## 요약
|
||||
|
||||
Provider probe contract 구현 리뷰 1회차를 PASS로 종결했다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | runtime provider probe contract, Ollama/vLLM/mock probe 구현, Node CAPABILITIES probe 반영, 관련 테스트와 보조 smoke 검증 통과 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `runtime.ProviderStatus`, `ProviderProbeResult`, `ProviderProber` 계약을 추가하고 provider status normalization 기준을 유지했다.
|
||||
- Ollama `/api/tags`와 vLLM/SGLang 호환 `/v1/models` probe를 target-aware `available|unavailable` 결과로 반환하도록 구현했다.
|
||||
- Node `CAPABILITIES` command가 optional provider prober를 사용해 `provider_status`, `targets`, `provider_detail`을 응답에 반영하도록 했다.
|
||||
- 리뷰 중 `ollama_test.go`, `vllm_test.go` EOF formatting nit를 정리했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `go test -count=1 ./apps/node/internal/runtime ./apps/node/internal/node ./apps/node/internal/adapters/ollama ./apps/node/internal/adapters/vllm` - PASS; runtime/node/adapter probe 관련 대상 패키지 통과
|
||||
- `go test -count=1 ./apps/node/...` - PASS; node 전체 패키지 통과
|
||||
- `./scripts/e2e-smoke.sh` - PASS; 보조 edge-node smoke에서 `/capabilities`, `/transport`, `/sessions`, foreground/background 메시지 relay 확인
|
||||
- `git diff --check` - PASS; whitespace 문제 없음
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
|
||||
- Completed task ids:
|
||||
- `probe-contract`: PASS; evidence=`agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh`, `git diff --check`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
<!-- task=m-provider-availability-capacity-queue-foundation/01_probe_contract plan=0 tag=API -->
|
||||
|
||||
# Probe Contract Plan - API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 구현 후 검증을 실행하고 실제 출력, 설계 결정, 계획 대비 변경 사항을 기록한 뒤 active 파일을 남긴 채 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 적고 멈춘다. 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 명령 재실행이나 산출물 수집으로 해소 가능한 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 Node adapter는 `Capabilities`에서 targets와 concurrency만 반환하며 provider endpoint 생존과 target/model 가용성 확인 계약이 없다. `status-model` 작은 작업으로 `unknown|available|unavailable` 모델은 생겼지만, Ollama/vLLM/SGLang 계열 provider가 같은 방식으로 상태를 생산하는 인터페이스는 아직 없다. 이 작업은 provider별 probe 차이를 adapter 내부에 숨기는 공통 probe 경계를 만든다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 차단은 `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 구현 에이전트는 채팅으로 직접 선택지를 내거나 사용자 입력 도구를 호출하지 않는다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
|
||||
- Task ids:
|
||||
- `probe-contract`: Ollama, Lemonade, vLLM, SGLang 등 provider adapter가 endpoint 생존 여부와 target/model 사용 가능 여부를 확인하는 공통 probe 인터페이스를 구현한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/inference-provider-extension/PHASE.md`
|
||||
- `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
|
||||
- `agent-ops/rules/project/rules.md`
|
||||
- `agent-ops/rules/project/domain/node/rules.md`
|
||||
- `agent-ops/rules/project/domain/edge/rules.md`
|
||||
- `agent-ops/rules/project/domain/platform-common/rules.md`
|
||||
- `agent-ops/rules/project/domain/testing/rules.md`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/node-smoke.md`
|
||||
- `agent-test/local/edge-smoke.md`
|
||||
- `agent-test/local/platform-common-smoke.md`
|
||||
- `apps/node/internal/runtime/types.go`
|
||||
- `apps/node/internal/node/node.go`
|
||||
- `apps/node/internal/node/node_test.go`
|
||||
- `apps/node/internal/adapters/ollama/ollama.go`
|
||||
- `apps/node/internal/adapters/ollama/ollama_test.go`
|
||||
- `apps/node/internal/adapters/vllm/vllm.go`
|
||||
- `apps/node/internal/adapters/vllm/vllm_test.go`
|
||||
- `apps/node/internal/adapters/mock/mock.go`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
test_env는 `local`이다. `agent-test/local/rules.md`를 읽었고, 이 subtask의 실제 변경 범위는 `apps/node/**`라 `agent-test/local/node-smoke.md`를 적용한다. 기본 검증은 `go test ./apps/node/...`이며, adapter 실행/stream/cancel 경로를 바꾸면 repo 내부 edge-node 진단 또는 full-cycle 검증을 추가해야 한다. `edge-smoke`와 `platform-common-smoke`는 provider-state 전체 분류 때문에 읽었지만, 이 subtask가 proto/config/edge 파일을 수정하지 않으면 적용하지 않는다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- `Capabilities` command는 `provider_status` 제한만 검증되어 있고 provider endpoint 생존 또는 target/model 가용성 probe는 아직 검증하지 않는다.
|
||||
- Ollama `/api/tags` 실패와 vLLM `/v1/models` 실패가 `unavailable`로 접히는 회귀 테스트가 없다.
|
||||
- target이 명시됐지만 provider model 목록에 없을 때 `unavailable`로 나오는 테스트가 없다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
none. 새 interface/type 추가가 중심이며 기존 symbol rename/remove는 없다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
split decision policy를 평가했다. shared task group은 `m-provider-availability-capacity-queue-foundation`이다. `01_probe_contract`는 독립 subtask이며 predecessor가 없다. `02+01_edge_snapshot`은 이 probe 결과를 Edge-visible snapshot으로 운반하므로 `01_probe_contract`의 `complete.log`가 필요하다. API foundation과 Edge/proto rollout 경계가 분리되어 multi-plan이 맞다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
이 subtask는 Node runtime interface와 provider adapter probe까지만 다룬다. proto schema, Edge service/Control Plane status, capacity queue admission은 `02+01_edge_snapshot` 또는 capacity-queue Epic에서 다룬다. Lemonade adapter 신규 구현도 후속 Milestone 범위라 제외한다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
`local-G05`. 변경 범위가 Node 내부 interface와 adapter tests로 제한되고, httptest 기반 검증으로 reviewer가 재현 가능하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] runtime에 provider probe 결과 타입과 interface를 추가하고 `ProviderStatus` 제한 모델을 재사용한다.
|
||||
- [ ] Ollama adapter가 `/api/tags` endpoint 생존과 target/model 존재 여부를 `available|unavailable`로 반환하도록 구현하고 테스트한다.
|
||||
- [ ] vLLM adapter가 `/v1/models` endpoint 생존과 target/model 존재 여부를 `available|unavailable`로 반환하도록 구현하고 SGLang 호환 경로를 문서화한다.
|
||||
- [ ] Node `CAPABILITIES` command가 provider prober를 발견하면 target-aware probe 결과로 `provider_status`와 targets를 채우도록 구현한다.
|
||||
- [ ] `go test ./apps/node/...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [API-1] Runtime Provider Probe Contract
|
||||
|
||||
문제: [apps/node/internal/runtime/types.go](/config/workspace/iop/apps/node/internal/runtime/types.go:77)는 제한된 status 모델만 있고, [apps/node/internal/runtime/types.go](/config/workspace/iop/apps/node/internal/runtime/types.go:174)의 adapter 계약은 execution/capabilities만 가진다.
|
||||
|
||||
해결 방법:
|
||||
|
||||
```go
|
||||
// before apps/node/internal/runtime/types.go:174
|
||||
type Adapter interface {
|
||||
Name() string
|
||||
Capabilities(ctx context.Context) (Capabilities, error)
|
||||
Execute(ctx context.Context, spec ExecutionSpec, sink EventSink) error
|
||||
}
|
||||
```
|
||||
|
||||
`ProviderProbeResult`와 optional `ProviderProber` interface를 추가한다. Result는 `AdapterName`, `InstanceKey`, `Target`, `Targets`, `Status`, `Detail`, `Metadata` 정도로 제한하고 `NormalizeProviderStatus`를 통과한 status만 외부로 내보낸다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `apps/node/internal/runtime/types.go`에 `ProviderProbeResult`와 `ProviderProber` 추가
|
||||
- [ ] status normalization helper 재사용
|
||||
|
||||
테스트 작성: 직접 test file은 만들지 않는다. Node command와 adapter tests에서 interface 동작을 검증한다.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
go test ./apps/node/internal/runtime ./apps/node/internal/node
|
||||
```
|
||||
|
||||
### [API-2] Provider Adapter Probe Implementations
|
||||
|
||||
문제: [apps/node/internal/adapters/ollama/ollama.go](/config/workspace/iop/apps/node/internal/adapters/ollama/ollama.go:195)와 [apps/node/internal/adapters/vllm/vllm.go](/config/workspace/iop/apps/node/internal/adapters/vllm/vllm.go:165)는 model list fetch 실패를 `nil` targets로 숨긴다. 이 때문에 endpoint down과 empty model list를 Edge-visible 상태로 구분할 수 없다.
|
||||
|
||||
해결 방법: `fetchTargets`를 context-aware helper로 바꾸고 error를 반환한다. `ProbeProvider(ctx,target)`는 endpoint error면 `unavailable`, target이 비어 있고 endpoint/list decode가 성공하면 `available`, target이 목록에 있으면 `available`, 없으면 `unavailable`을 반환한다. vLLM adapter는 OpenAI-compatible `/v1/models` 기준이므로 SGLang도 같은 구현 경로를 사용한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `apps/node/internal/adapters/ollama/ollama.go`
|
||||
- [ ] `apps/node/internal/adapters/ollama/ollama_test.go`
|
||||
- [ ] `apps/node/internal/adapters/vllm/vllm.go`
|
||||
- [ ] `apps/node/internal/adapters/vllm/vllm_test.go`
|
||||
- [ ] 필요 시 `apps/node/internal/adapters/mock/mock.go`는 항상 `available`인 test-friendly probe로 둔다
|
||||
|
||||
테스트 작성: httptest server로 `200 + model list`, `500`, target miss를 각각 검증한다. Test names는 `TestOllamaProbeProviderAvailability`, `TestVllmProbeProviderAvailability`.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/ollama ./apps/node/internal/adapters/vllm
|
||||
```
|
||||
|
||||
### [API-3] Node Capabilities Uses Probe
|
||||
|
||||
문제: [apps/node/internal/node/node.go](/config/workspace/iop/apps/node/internal/node/node.go:306)는 `Capabilities`만 호출한다. request target이 있어도 target/model 사용 가능 여부를 반영하지 못한다.
|
||||
|
||||
해결 방법:
|
||||
|
||||
```go
|
||||
// before apps/node/internal/node/node.go:311
|
||||
caps, err := adapter.Capabilities(ctx)
|
||||
if err != nil {
|
||||
return n.commandErrorResponse(req, err.Error())
|
||||
}
|
||||
```
|
||||
|
||||
`adapter.(runtime.ProviderProber)`가 가능하면 `ProbeProvider(ctx, req.GetTarget())`를 호출하고 result map의 `provider_status`, `targets`, optional `provider_detail`을 probe 결과로 채운다. Probe error는 command error로 만들지 말고 `provider_status=unavailable` 결과로 반환한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `apps/node/internal/node/node.go`
|
||||
- [ ] `apps/node/internal/node/node_test.go`
|
||||
|
||||
테스트 작성: 기존 `TestOnCommandRequest_CapabilitiesProviderStatusModel` 옆에 prober test double을 추가해 target hit/miss와 probe error 결과를 검증한다.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
go test ./apps/node/internal/node
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `apps/node/internal/runtime/types.go` | API-1 |
|
||||
| `apps/node/internal/adapters/ollama/ollama.go` | API-2 |
|
||||
| `apps/node/internal/adapters/ollama/ollama_test.go` | API-2 |
|
||||
| `apps/node/internal/adapters/vllm/vllm.go` | API-2 |
|
||||
| `apps/node/internal/adapters/vllm/vllm_test.go` | API-2 |
|
||||
| `apps/node/internal/adapters/mock/mock.go` | API-2 |
|
||||
| `apps/node/internal/node/node.go` | API-3 |
|
||||
| `apps/node/internal/node/node_test.go` | API-3 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test ./apps/node/...
|
||||
```
|
||||
|
||||
기대 결과: 모든 node package test가 통과한다. Go test cache 출력은 허용하지 않는다. fresh 실행이 필요하면 구현 에이전트가 `go test -count=1 ./apps/node/...`로 대체하고 사유와 출력을 review stub에 기록한다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
<!-- task=m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - 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`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-14
|
||||
task=m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
|
||||
- Task ids:
|
||||
- `edge-snapshot`: Edge가 provider별 구현 세부를 모르고 Node의 provider 상태, capacity, in-flight, queued 값을 조회하거나 이벤트로 받을 수 있는 계약을 정리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G06.md` -> `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` -> `plan_cloud_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-provider-availability-capacity-queue-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Typed Provider Snapshot Proto | [ ] |
|
||||
| [API-2] Node Snapshot Population | [ ] |
|
||||
| [API-3] Edge Service and Status Propagation | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `01_probe_contract` predecessor의 `complete.log`가 있는지 확인하고 없으면 구현을 시작하지 않는다.
|
||||
- [ ] provider snapshot typed proto를 추가하고 `make proto`로 Go generated files를 갱신한다.
|
||||
- [ ] Node `CAPABILITIES` response가 status, capacity, in-flight, queued를 typed snapshot과 result map 양쪽에 일관되게 담도록 구현한다.
|
||||
- [ ] Edge service DTO와 command path가 typed provider snapshot을 보존하도록 구현한다.
|
||||
- [ ] Control Plane Edge status 또는 Edge-owned status surface가 provider snapshots를 노출하도록 구현한다.
|
||||
- [ ] `go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/`를 `agent-task/archive/YYYY/MM/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-provider-availability-capacity-queue-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `01_probe_contract` predecessor completion evidence 없이 구현이 진행되지 않았는지 확인한다.
|
||||
- proto 원본과 generated Go 파일이 `make proto` 결과로 일치하는지 확인한다.
|
||||
- `queued=0` 기준선이 queue 미구현 상태로 명시되어 있고 capacity-queue 정책을 선구현하지 않았는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### API-1 중간 검증
|
||||
```bash
|
||||
$ make proto
|
||||
(output)
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/node/internal/node
|
||||
(output)
|
||||
```
|
||||
|
||||
### API-3 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/edge/internal/service ./apps/edge/internal/controlplane ./apps/edge/internal/transport
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...
|
||||
(output)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ownership
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
|
||||
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify |
|
||||
| 구현 항목별 완료 여부 | Implementing agent checks only | Item names remain fixed |
|
||||
| 구현 체크리스트 | Implementing agent checks only | Text/order remain fixed |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text |
|
||||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user-only blocker exists |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
|
||||
| 검증 결과 | Implementing agent | Paste actual output |
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
<!-- task=m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot plan=0 tag=API -->
|
||||
|
||||
# Edge Snapshot Plan - API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 구현 후 검증을 실행하고 실제 출력, 설계 결정, 계획 대비 변경 사항을 기록한 뒤 active 파일을 남긴 채 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 적고 멈춘다. 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 명령 재실행이나 산출물 수집으로 해소 가능한 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
Edge는 현재 node registry와 generic run capability만 볼 수 있고, provider별 status/capacity/in-flight/queued snapshot은 typed contract로 갖고 있지 않다. `01_probe_contract`가 provider status를 생산한 뒤, 이 작업은 Edge가 provider 구현 세부를 몰라도 조회할 수 있는 snapshot 계약을 연결한다. capacity queue가 아직 완성되지 않았으므로 이 plan은 `queued=0` 기준선을 명시하고 이후 queue 작업이 값을 교체할 수 있게 한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 차단은 `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 구현 에이전트는 채팅으로 직접 선택지를 내거나 사용자 입력 도구를 호출하지 않는다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
|
||||
- Task ids:
|
||||
- `edge-snapshot`: Edge가 provider별 구현 세부를 모르고 Node의 provider 상태, capacity, in-flight, queued 값을 조회하거나 이벤트로 받을 수 있는 계약을 정리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/inference-provider-extension/PHASE.md`
|
||||
- `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
|
||||
- `agent-ops/rules/project/rules.md`
|
||||
- `agent-ops/rules/project/domain/node/rules.md`
|
||||
- `agent-ops/rules/project/domain/edge/rules.md`
|
||||
- `agent-ops/rules/project/domain/platform-common/rules.md`
|
||||
- `agent-ops/rules/project/domain/testing/rules.md`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/node-smoke.md`
|
||||
- `agent-test/local/edge-smoke.md`
|
||||
- `agent-test/local/platform-common-smoke.md`
|
||||
- `apps/node/internal/runtime/types.go`
|
||||
- `apps/node/internal/node/node.go`
|
||||
- `apps/node/internal/node/run_manager.go`
|
||||
- `apps/node/internal/node/node_test.go`
|
||||
- `apps/edge/internal/service/node_command.go`
|
||||
- `apps/edge/internal/service/status_provider.go`
|
||||
- `apps/edge/internal/service/service_test.go`
|
||||
- `apps/edge/internal/controlplane/connector.go`
|
||||
- `apps/edge/internal/opsconsole/status.go`
|
||||
- `proto/iop/runtime.proto`
|
||||
- `proto/iop/control.proto`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
test_env는 `local`이다. `node-smoke`, `edge-smoke`, `platform-common-smoke`를 모두 적용한다. proto 변경 시 `make proto`가 필수이며, generated Go diff는 원본 proto에서 만들어야 한다. 기본 검증은 `go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...`이고, Edge status/control-plane snapshot이 바뀌므로 관련 edge service/controlplane 테스트를 포함한다. 사용자 실행 파이프라인에 닿지만 이 plan은 query/status 계약 변경이므로 full-cycle 실제 구동은 구현 변경 규모에 따라 code-review에서 필수 여부를 다시 판단한다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- `NodeCommandResponse`는 [proto/iop/runtime.proto](/config/workspace/iop/proto/iop/runtime.proto:103)의 free-form `result` map만 있고 typed provider snapshot payload가 없다.
|
||||
- `EdgeNodeSnapshot`은 [proto/iop/control.proto](/config/workspace/iop/proto/iop/control.proto:77)에서 node/config만 담고 provider snapshot field가 없다.
|
||||
- Edge service `NodeCommandView`는 [apps/edge/internal/service/node_command.go](/config/workspace/iop/apps/edge/internal/service/node_command.go:107)에서 result map만 보존한다.
|
||||
- Control Plane status builder는 [apps/edge/internal/controlplane/connector.go](/config/workspace/iop/apps/edge/internal/controlplane/connector.go:367)에서 provider snapshot을 넣을 자리가 없다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
none. 새 proto message/fields와 DTO field 추가가 중심이며 기존 symbol rename/remove는 없다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
split decision policy를 평가했다. shared task group은 `m-provider-availability-capacity-queue-foundation`이다. 이 subtask는 `02+01_edge_snapshot`이므로 predecessor index `01`이 필요하다. 현재 `agent-task/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log`는 없어서 predecessor는 missing이다. 구현은 `01_probe_contract`가 PASS되어 `complete.log`가 생긴 뒤 시작해야 한다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
이 subtask는 Edge가 조회할 수 있는 provider snapshot contract까지 다룬다. FIFO queue admission, `queue_timeout`, `max_queue` reject, request scheduling 정책은 capacity-queue Epic의 별도 task 범위다. queue가 없는 현재 기준선에서는 `queued=0`을 명시적으로 반환하고, queue task가 이 값을 실제 queue 길이로 교체하게 둔다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
`cloud-G06`. proto schema, generated code, Node command, Edge service, Control Plane status가 함께 움직이는 cross-domain API 변경이다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `01_probe_contract` predecessor의 `complete.log`가 있는지 확인하고 없으면 구현을 시작하지 않는다.
|
||||
- [ ] provider snapshot typed proto를 추가하고 `make proto`로 Go generated files를 갱신한다.
|
||||
- [ ] Node `CAPABILITIES` response가 status, capacity, in-flight, queued를 typed snapshot과 result map 양쪽에 일관되게 담도록 구현한다.
|
||||
- [ ] Edge service DTO와 command path가 typed provider snapshot을 보존하도록 구현한다.
|
||||
- [ ] Control Plane Edge status 또는 Edge-owned status surface가 provider snapshots를 노출하도록 구현한다.
|
||||
- [ ] `go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
`02+01_edge_snapshot`은 directory name 기준으로 predecessor `01_probe_contract`에만 의존한다. 구현 전 `agent-task/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log` 또는 matching archive `agent-task/archive/*/*/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log`가 있어야 한다. 이 plan 작성 시점에는 missing이다.
|
||||
|
||||
### [API-1] Typed Provider Snapshot Proto
|
||||
|
||||
문제: [proto/iop/runtime.proto](/config/workspace/iop/proto/iop/runtime.proto:103)의 `NodeCommandResponse`는 map만 있고 provider snapshot의 `status`, `capacity`, `in_flight`, `queued`를 typed contract로 표현하지 못한다. [proto/iop/control.proto](/config/workspace/iop/proto/iop/control.proto:77)의 `EdgeNodeSnapshot`도 provider field가 없다.
|
||||
|
||||
해결 방법:
|
||||
|
||||
```proto
|
||||
// before proto/iop/runtime.proto:103
|
||||
message NodeCommandResponse {
|
||||
string request_id = 1;
|
||||
...
|
||||
map<string, string> result = 8;
|
||||
}
|
||||
```
|
||||
|
||||
`runtime.proto`에 `ProviderSnapshot` message를 추가하고 `NodeCommandResponse`에 `repeated ProviderSnapshot provider_snapshots = 9;`를 추가한다. `control.proto`의 `EdgeNodeSnapshot`에는 `repeated ProviderSnapshot provider_snapshots = 6;`를 추가한다. `ProviderSnapshot.status`는 string으로 두되 값은 `unknown|available|unavailable`만 허용한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `proto/iop/runtime.proto`
|
||||
- [ ] `proto/iop/control.proto`
|
||||
- [ ] `proto/gen/iop/runtime.pb.go`는 `make proto`로만 갱신
|
||||
- [ ] `proto/gen/iop/control.pb.go`는 `make proto`로만 갱신
|
||||
|
||||
테스트 작성: proto generated compile 검증으로 충분하다. 별도 proto unit test는 추가하지 않는다.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
make proto
|
||||
```
|
||||
|
||||
### [API-2] Node Snapshot Population
|
||||
|
||||
문제: [apps/node/internal/node/node.go](/config/workspace/iop/apps/node/internal/node/node.go:317)는 string map만 채우며, [apps/node/internal/node/run_manager.go](/config/workspace/iop/apps/node/internal/node/run_manager.go:55)의 active count는 snapshot으로 노출되지 않는다.
|
||||
|
||||
해결 방법:
|
||||
|
||||
```go
|
||||
// before apps/node/internal/node/node.go:317
|
||||
result := map[string]string{
|
||||
"adapter": caps.AdapterName,
|
||||
"instance_key": caps.InstanceKey,
|
||||
"targets": strings.Join(targets, ","),
|
||||
"max_concurrency": strconv.Itoa(caps.MaxConcurrency),
|
||||
"provider_status": string(runtime.NormalizeProviderStatus(caps.ProviderStatus)),
|
||||
}
|
||||
```
|
||||
|
||||
Node command response에 provider snapshot을 채운다. `capacity`는 `caps.MaxConcurrency`, `in_flight`는 해당 adapter permit의 `activeCount`, `queued`는 queue 미구현 기준선으로 `0`을 넣는다. result map에도 기존 console 호환을 위해 `capacity`, `in_flight`, `queued` keys를 추가한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `apps/node/internal/node/node.go`
|
||||
- [ ] `apps/node/internal/node/run_manager.go` 필요 시 snapshot helper 추가
|
||||
- [ ] `apps/node/internal/node/node_test.go`
|
||||
|
||||
테스트 작성: active run 중 `CAPABILITIES`를 호출해 `in_flight=1`, capacity 값, queued=0을 검증한다. status 값은 `01_probe_contract`의 probe 결과를 사용한다.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
go test ./apps/node/internal/node
|
||||
```
|
||||
|
||||
### [API-3] Edge Service and Status Propagation
|
||||
|
||||
문제: [apps/edge/internal/service/node_command.go](/config/workspace/iop/apps/edge/internal/service/node_command.go:198)는 `resp.GetResult()`만 보존한다. [apps/edge/internal/service/status_provider.go](/config/workspace/iop/apps/edge/internal/service/status_provider.go:12)의 `NodeSnapshot`에도 provider snapshot field가 없다. [apps/edge/internal/controlplane/connector.go](/config/workspace/iop/apps/edge/internal/controlplane/connector.go:367)는 status response nodes에 provider data를 싣지 않는다.
|
||||
|
||||
해결 방법: `NodeCommandView`와 `NodeSnapshot`에 `ProviderSnapshots []*iop.ProviderSnapshot`를 추가한다. `sendNodeCommand`는 `resp.GetProviderSnapshots()`를 보존한다. Control Plane status path는 이미 Edge-owned snapshot DTO를 쓰므로 `EdgeNodeSnapshot.ProviderSnapshots`에 복사한다. live query가 필요한 곳은 `Service.Capabilities` 응답을 우선 사용하고, registry-only snapshot에는 nil을 허용한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `apps/edge/internal/service/node_command.go`
|
||||
- [ ] `apps/edge/internal/service/status_provider.go`
|
||||
- [ ] `apps/edge/internal/controlplane/connector.go`
|
||||
- [ ] `apps/edge/internal/opsconsole/status.go`는 typed snapshot이 있으면 stable sorted render에 주요 keys가 보이도록 유지
|
||||
- [ ] `apps/edge/internal/service/service_test.go`
|
||||
- [ ] `apps/edge/internal/controlplane/connector_test.go`
|
||||
- [ ] `apps/edge/internal/transport/server_test.go`
|
||||
|
||||
테스트 작성: service command test에서 provider snapshots가 preserved 되는지, controlplane status test에서 EdgeNodeSnapshot에 provider snapshots가 복사되는지 검증한다.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
go test ./apps/edge/internal/service ./apps/edge/internal/controlplane ./apps/edge/internal/transport
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `proto/iop/runtime.proto` | API-1 |
|
||||
| `proto/iop/control.proto` | API-1 |
|
||||
| `proto/gen/iop/runtime.pb.go` | API-1 |
|
||||
| `proto/gen/iop/control.pb.go` | API-1 |
|
||||
| `apps/node/internal/node/node.go` | API-2 |
|
||||
| `apps/node/internal/node/run_manager.go` | API-2 |
|
||||
| `apps/node/internal/node/node_test.go` | API-2 |
|
||||
| `apps/edge/internal/service/node_command.go` | API-3 |
|
||||
| `apps/edge/internal/service/status_provider.go` | API-3 |
|
||||
| `apps/edge/internal/controlplane/connector.go` | API-3 |
|
||||
| `apps/edge/internal/opsconsole/status.go` | API-3 |
|
||||
| `apps/edge/internal/service/service_test.go` | API-3 |
|
||||
| `apps/edge/internal/controlplane/connector_test.go` | API-3 |
|
||||
| `apps/edge/internal/transport/server_test.go` | API-3 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...
|
||||
```
|
||||
|
||||
기대 결과: 모든 listed package test가 통과한다. proto 변경 후 `make proto` 결과가 source proto와 일치해야 한다. Go test cache 출력은 허용하지 않는다. fresh 실행이 필요하면 구현 에이전트가 `go test -count=1 ...`로 대체하고 사유와 출력을 review stub에 기록한다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -30,6 +30,17 @@ func (m *Mock) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|||
AdapterName: Name,
|
||||
Targets: []string{"mock-echo", "mock-stream"},
|
||||
MaxConcurrency: 16,
|
||||
ProviderStatus: runtime.ProviderStatusAvailable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Mock) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
|
||||
targets := []string{"mock-echo", "mock-stream"}
|
||||
return runtime.ProviderProbeResult{
|
||||
AdapterName: Name,
|
||||
Target: target,
|
||||
Targets: targets,
|
||||
Status: runtime.ProviderStatusAvailable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,13 +50,20 @@ func New(cfg config.OllamaConf, logger *zap.Logger, instanceName ...string) *Oll
|
|||
|
||||
func (o *Ollama) Name() string { return Name }
|
||||
|
||||
func (o *Ollama) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
targets := o.fetchTargets()
|
||||
func (o *Ollama) Capabilities(ctx context.Context) (runtime.Capabilities, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
targets, err := o.fetchTargets(probeCtx)
|
||||
status := runtime.ProviderStatusAvailable
|
||||
if err != nil {
|
||||
status = runtime.ProviderStatusUnavailable
|
||||
}
|
||||
return runtime.Capabilities{
|
||||
AdapterName: Name,
|
||||
InstanceKey: o.instanceName,
|
||||
Targets: targets,
|
||||
MaxConcurrency: 4,
|
||||
ProviderStatus: runtime.NormalizeProviderStatus(status),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -192,24 +199,65 @@ func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink r
|
|||
return fmt.Errorf("ollama adapter: stream ended without done=true")
|
||||
}
|
||||
|
||||
func (o *Ollama) fetchTargets() []string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
func (o *Ollama) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
targets, err := o.fetchTargets(probeCtx)
|
||||
|
||||
result := runtime.ProviderProbeResult{
|
||||
AdapterName: Name,
|
||||
InstanceKey: o.instanceName,
|
||||
Target: target,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
|
||||
result.Detail = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Targets = targets
|
||||
|
||||
if target == "" {
|
||||
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, t := range targets {
|
||||
if t == target || (strings.Index(target, ":") == -1 && t == target+":latest") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
|
||||
} else {
|
||||
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
|
||||
result.Detail = fmt.Sprintf("target model %q not found in provider models", target)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *Ollama) fetchTargets(ctx context.Context) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(o.baseURL, "/api/tags"), nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
resp, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil
|
||||
return nil, fmt.Errorf("status code %d", resp.StatusCode)
|
||||
}
|
||||
var tags ollamaTagsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
|
||||
return nil
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
out := make([]string, 0, len(tags.Models))
|
||||
for _, model := range tags.Models {
|
||||
|
|
@ -217,7 +265,7 @@ func (o *Ollama) fetchTargets() []string {
|
|||
out = append(out, model.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (o *Ollama) HandleCommand(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) {
|
||||
|
|
|
|||
|
|
@ -401,3 +401,78 @@ func TestOllamaExecuteStreamsThinkingDeltasSeparately(t *testing.T) {
|
|||
t.Fatalf("unexpected usage: %+v", events[3].Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaProbeProviderAvailability(t *testing.T) {
|
||||
t.Run("200_ok_and_target_hit", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/tags" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"models":[{"name":"llama-a"},{"name":"llama-b"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
|
||||
res, err := adapter.ProbeProvider(context.Background(), "llama-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeProvider failed: %v", err)
|
||||
}
|
||||
if res.Status != noderuntime.ProviderStatusAvailable {
|
||||
t.Errorf("expected Status available, got %s", res.Status)
|
||||
}
|
||||
if len(res.Targets) != 2 || res.Targets[0] != "llama-a" {
|
||||
t.Errorf("unexpected Targets: %+v", res.Targets)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("200_ok_and_target_miss", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"models":[{"name":"llama-a"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
|
||||
res, err := adapter.ProbeProvider(context.Background(), "llama-b")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeProvider failed: %v", err)
|
||||
}
|
||||
if res.Status != noderuntime.ProviderStatusUnavailable {
|
||||
t.Errorf("expected Status unavailable, got %s", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Detail, "not found") {
|
||||
t.Errorf("expected 'not found' in detail, got %s", res.Detail)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("500_internal_error", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
|
||||
res, err := adapter.ProbeProvider(context.Background(), "llama-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeProvider failed: %v", err)
|
||||
}
|
||||
if res.Status != noderuntime.ProviderStatusUnavailable {
|
||||
t.Errorf("expected Status unavailable, got %s", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty_target", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"models":[{"name":"llama-a"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
|
||||
res, err := adapter.ProbeProvider(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeProvider failed: %v", err)
|
||||
}
|
||||
if res.Status != noderuntime.ProviderStatusAvailable {
|
||||
t.Errorf("expected Status available, got %s", res.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,12 +47,20 @@ func New(cfg config.VllmConf, logger *zap.Logger, instanceName ...string) *Vllm
|
|||
|
||||
func (v *Vllm) Name() string { return Name }
|
||||
|
||||
func (v *Vllm) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
func (v *Vllm) Capabilities(ctx context.Context) (runtime.Capabilities, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
targets, err := v.fetchTargets(probeCtx)
|
||||
status := runtime.ProviderStatusAvailable
|
||||
if err != nil {
|
||||
status = runtime.ProviderStatusUnavailable
|
||||
}
|
||||
return runtime.Capabilities{
|
||||
AdapterName: Name,
|
||||
InstanceKey: v.instanceName,
|
||||
Targets: v.fetchTargets(),
|
||||
Targets: targets,
|
||||
MaxConcurrency: 8,
|
||||
ProviderStatus: runtime.NormalizeProviderStatus(status),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -162,27 +170,70 @@ func (v *Vllm) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink run
|
|||
return fmt.Errorf("vllm adapter: stream ended without [DONE]")
|
||||
}
|
||||
|
||||
func (v *Vllm) fetchTargets() []string {
|
||||
if v.endpoint == "" {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
// ProbeProvider checks the availability of the vLLM (or OpenAI-compatible, e.g., SGLang) endpoint
|
||||
// and the presence of the target model using the OpenAI-compatible /v1/models endpoint.
|
||||
func (v *Vllm) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
targets, err := v.fetchTargets(probeCtx)
|
||||
|
||||
result := runtime.ProviderProbeResult{
|
||||
AdapterName: Name,
|
||||
InstanceKey: v.instanceName,
|
||||
Target: target,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
|
||||
result.Detail = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Targets = targets
|
||||
|
||||
if target == "" {
|
||||
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, t := range targets {
|
||||
if t == target {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
|
||||
} else {
|
||||
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
|
||||
result.Detail = fmt.Sprintf("target model %q not found in provider models", target)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (v *Vllm) fetchTargets(ctx context.Context) ([]string, error) {
|
||||
if v.endpoint == "" {
|
||||
return nil, fmt.Errorf("vllm adapter: endpoint is required")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(v.endpoint, "/v1/models"), nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil
|
||||
return nil, fmt.Errorf("status code %d", resp.StatusCode)
|
||||
}
|
||||
var models vllmModelsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&models); err != nil {
|
||||
return nil
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
out := make([]string, 0, len(models.Data))
|
||||
for _, model := range models.Data {
|
||||
|
|
@ -190,7 +241,7 @@ func (v *Vllm) fetchTargets() []string {
|
|||
out = append(out, model.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func messagesFromInput(input map[string]any) []vllmMessage {
|
||||
|
|
|
|||
|
|
@ -188,3 +188,78 @@ func TestVllmInstanceKey(t *testing.T) {
|
|||
t.Fatalf("AdapterName: got %q, want %q", caps.AdapterName, Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVllmProbeProviderAvailability(t *testing.T) {
|
||||
t.Run("200_ok_and_target_hit", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
|
||||
res, err := adapter.ProbeProvider(context.Background(), "model-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeProvider failed: %v", err)
|
||||
}
|
||||
if res.Status != runtime.ProviderStatusAvailable {
|
||||
t.Errorf("expected Status available, got %s", res.Status)
|
||||
}
|
||||
if len(res.Targets) != 2 || res.Targets[0] != "model-a" {
|
||||
t.Errorf("unexpected Targets: %+v", res.Targets)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("200_ok_and_target_miss", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"model-a"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
|
||||
res, err := adapter.ProbeProvider(context.Background(), "model-b")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeProvider failed: %v", err)
|
||||
}
|
||||
if res.Status != runtime.ProviderStatusUnavailable {
|
||||
t.Errorf("expected Status unavailable, got %s", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Detail, "not found") {
|
||||
t.Errorf("expected 'not found' in detail, got %s", res.Detail)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("500_internal_error", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
|
||||
res, err := adapter.ProbeProvider(context.Background(), "model-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeProvider failed: %v", err)
|
||||
}
|
||||
if res.Status != runtime.ProviderStatusUnavailable {
|
||||
t.Errorf("expected Status unavailable, got %s", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty_target", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"model-a"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
|
||||
res, err := adapter.ProbeProvider(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeProvider failed: %v", err)
|
||||
}
|
||||
if res.Status != runtime.ProviderStatusAvailable {
|
||||
t.Errorf("expected Status available, got %s", res.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,21 +312,65 @@ func (n *Node) handleCapabilitiesCommand(ctx context.Context, req *iop.NodeComma
|
|||
if err != nil {
|
||||
return n.commandErrorResponse(req, err.Error())
|
||||
}
|
||||
|
||||
targets := append([]string(nil), caps.Targets...)
|
||||
providerStatus := caps.ProviderStatus
|
||||
providerDetail := ""
|
||||
|
||||
if prober, ok := adapter.(runtime.ProviderProber); ok {
|
||||
probeRes, err := prober.ProbeProvider(ctx, req.GetTarget())
|
||||
if err != nil {
|
||||
providerStatus = runtime.ProviderStatusUnavailable
|
||||
providerDetail = err.Error()
|
||||
} else {
|
||||
providerStatus = probeRes.Status
|
||||
providerDetail = probeRes.Detail
|
||||
if len(probeRes.Targets) > 0 {
|
||||
targets = append([]string(nil), probeRes.Targets...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(targets)
|
||||
|
||||
n.adapterPermitsMu.Lock()
|
||||
pm, ok := n.adapterPermits[req.GetAdapter()]
|
||||
n.adapterPermitsMu.Unlock()
|
||||
inFlight := 0
|
||||
if ok {
|
||||
inFlight = pm.activeCount()
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"adapter": caps.AdapterName,
|
||||
"instance_key": caps.InstanceKey,
|
||||
"targets": strings.Join(targets, ","),
|
||||
"max_concurrency": strconv.Itoa(caps.MaxConcurrency),
|
||||
"provider_status": string(runtime.NormalizeProviderStatus(providerStatus)),
|
||||
"capacity": strconv.Itoa(caps.MaxConcurrency),
|
||||
"in_flight": strconv.Itoa(inFlight),
|
||||
"queued": "0",
|
||||
}
|
||||
if providerDetail != "" {
|
||||
result["provider_detail"] = providerDetail
|
||||
}
|
||||
|
||||
providerSnapshot := &iop.ProviderSnapshot{
|
||||
Adapter: req.GetAdapter(),
|
||||
Status: string(runtime.NormalizeProviderStatus(providerStatus)),
|
||||
Capacity: int32(caps.MaxConcurrency),
|
||||
InFlight: int32(inFlight),
|
||||
Queued: 0,
|
||||
}
|
||||
|
||||
return &iop.NodeCommandResponse{
|
||||
RequestId: req.GetRequestId(),
|
||||
Type: req.GetType(),
|
||||
Adapter: req.GetAdapter(),
|
||||
Target: req.GetTarget(),
|
||||
SessionId: req.GetSessionId(),
|
||||
Result: result,
|
||||
RequestId: req.GetRequestId(),
|
||||
Type: req.GetType(),
|
||||
Adapter: req.GetAdapter(),
|
||||
Target: req.GetTarget(),
|
||||
SessionId: req.GetSessionId(),
|
||||
Result: result,
|
||||
ProviderSnapshots: []*iop.ProviderSnapshot{providerSnapshot},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -107,12 +107,18 @@ func (a *instanceKeyAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec,
|
|||
}
|
||||
|
||||
type commandAdapter struct {
|
||||
lastReq runtime.CommandRequest
|
||||
lastReq runtime.CommandRequest
|
||||
providerStatus runtime.ProviderStatus
|
||||
}
|
||||
|
||||
func (a *commandAdapter) Name() string { return "command" }
|
||||
func (a *commandAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
return runtime.Capabilities{AdapterName: "command", Targets: []string{"v2", "v1"}, MaxConcurrency: 3}, nil
|
||||
return runtime.Capabilities{
|
||||
AdapterName: "command",
|
||||
Targets: []string{"v2", "v1"},
|
||||
MaxConcurrency: 3,
|
||||
ProviderStatus: a.providerStatus,
|
||||
}, nil
|
||||
}
|
||||
func (a *commandAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
||||
return nil
|
||||
|
|
@ -136,6 +142,47 @@ func (a *commandAdapter) HandleCommand(ctx context.Context, req runtime.CommandR
|
|||
}
|
||||
}
|
||||
|
||||
type proberTestAdapter struct {
|
||||
providerStatus runtime.ProviderStatus
|
||||
probeTargets []string
|
||||
probeErr error
|
||||
}
|
||||
|
||||
func (a *proberTestAdapter) Name() string { return "prober" }
|
||||
func (a *proberTestAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
return runtime.Capabilities{
|
||||
AdapterName: "prober",
|
||||
Targets: []string{"model-a"},
|
||||
MaxConcurrency: 3,
|
||||
ProviderStatus: a.providerStatus,
|
||||
}, nil
|
||||
}
|
||||
func (a *proberTestAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
||||
return nil
|
||||
}
|
||||
func (a *proberTestAdapter) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
|
||||
if a.probeErr != nil {
|
||||
return runtime.ProviderProbeResult{}, a.probeErr
|
||||
}
|
||||
status := runtime.ProviderStatusAvailable
|
||||
found := false
|
||||
for _, t := range a.probeTargets {
|
||||
if t == target {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if target != "" && !found {
|
||||
status = runtime.ProviderStatusUnavailable
|
||||
}
|
||||
return runtime.ProviderProbeResult{
|
||||
AdapterName: "prober",
|
||||
Target: target,
|
||||
Targets: a.probeTargets,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fixedRouter struct {
|
||||
adapterName string
|
||||
adapter runtime.Adapter
|
||||
|
|
@ -558,12 +605,241 @@ func TestOnCommandRequest_Capabilities(t *testing.T) {
|
|||
if got := resp.GetResult()["max_concurrency"]; got != "3" {
|
||||
t.Fatalf("result[max_concurrency]: got %q want %q", got, "3")
|
||||
}
|
||||
if got := resp.GetResult()["provider_status"]; got != "unknown" {
|
||||
t.Fatalf("result[provider_status]: got %q want %q", got, "unknown")
|
||||
}
|
||||
if got := resp.GetResult()["capacity"]; got != "3" {
|
||||
t.Fatalf("result[capacity]: got %q want %q", got, "3")
|
||||
}
|
||||
if got := resp.GetResult()["in_flight"]; got != "0" {
|
||||
t.Fatalf("result[in_flight]: got %q want %q", got, "0")
|
||||
}
|
||||
if got := resp.GetResult()["queued"]; got != "0" {
|
||||
t.Fatalf("result[queued]: got %q want %q", got, "0")
|
||||
}
|
||||
if len(resp.GetProviderSnapshots()) != 1 {
|
||||
t.Fatalf("expected 1 provider snapshot, got %d", len(resp.GetProviderSnapshots()))
|
||||
}
|
||||
snap := resp.GetProviderSnapshots()[0]
|
||||
if snap.GetAdapter() != "command" {
|
||||
t.Fatalf("snap.Adapter: got %q want %q", snap.GetAdapter(), "command")
|
||||
}
|
||||
if snap.GetStatus() != "unknown" {
|
||||
t.Fatalf("snap.Status: got %q want %q", snap.GetStatus(), "unknown")
|
||||
}
|
||||
if snap.GetCapacity() != 3 {
|
||||
t.Fatalf("snap.Capacity: got %d want %d", snap.GetCapacity(), 3)
|
||||
}
|
||||
if snap.GetInFlight() != 0 {
|
||||
t.Fatalf("snap.InFlight: got %d want %d", snap.GetInFlight(), 0)
|
||||
}
|
||||
if snap.GetQueued() != 0 {
|
||||
t.Fatalf("snap.Queued: got %d want %d", snap.GetQueued(), 0)
|
||||
}
|
||||
// Capabilities must not go through the adapter CommandHandler.
|
||||
if ca.lastReq.RequestID != "" {
|
||||
t.Fatalf("expected CommandHandler to be skipped, got %+v", ca.lastReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnCommandRequest_Capabilities_InFlight(t *testing.T) {
|
||||
ba := newBlockingAdapter()
|
||||
router := &fixedRouter{adapterName: "blocking", adapters: make(map[string]runtime.Adapter)}
|
||||
router.adapters["blocking"] = ba
|
||||
n, _ := makeNode(t, router)
|
||||
|
||||
// Start a background run which will block.
|
||||
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
||||
RunId: "run-in-flight",
|
||||
Adapter: "blocking",
|
||||
Target: "v1",
|
||||
Background: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnRunRequest: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ba.started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("adapter never started")
|
||||
}
|
||||
|
||||
// Now query Capabilities while the run is active.
|
||||
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
||||
RequestId: "req-cap",
|
||||
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
||||
Adapter: "blocking",
|
||||
Target: "v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnCommandRequest: %v", err)
|
||||
}
|
||||
if resp.GetError() != "" {
|
||||
t.Fatalf("expected no error, got %q", resp.GetError())
|
||||
}
|
||||
|
||||
// Verify capacity, in_flight, queued values.
|
||||
if got := resp.GetResult()["capacity"]; got != "0" {
|
||||
t.Fatalf("result[capacity]: got %q want %q", got, "0")
|
||||
}
|
||||
if got := resp.GetResult()["in_flight"]; got != "1" {
|
||||
t.Fatalf("result[in_flight]: got %q want %q", got, "1")
|
||||
}
|
||||
if got := resp.GetResult()["queued"]; got != "0" {
|
||||
t.Fatalf("result[queued]: got %q want %q", got, "0")
|
||||
}
|
||||
|
||||
if len(resp.GetProviderSnapshots()) != 1 {
|
||||
t.Fatalf("expected 1 provider snapshot, got %d", len(resp.GetProviderSnapshots()))
|
||||
}
|
||||
snap := resp.GetProviderSnapshots()[0]
|
||||
if snap.GetAdapter() != "blocking" {
|
||||
t.Fatalf("snap.Adapter: got %q want %q", snap.GetAdapter(), "blocking")
|
||||
}
|
||||
if snap.GetStatus() != "unknown" {
|
||||
t.Fatalf("snap.Status: got %q want %q", snap.GetStatus(), "unknown")
|
||||
}
|
||||
if snap.GetCapacity() != 0 {
|
||||
t.Fatalf("snap.Capacity: got %d want %d", snap.GetCapacity(), 0)
|
||||
}
|
||||
if snap.GetInFlight() != 1 {
|
||||
t.Fatalf("snap.InFlight: got %d want %d", snap.GetInFlight(), 1)
|
||||
}
|
||||
if snap.GetQueued() != 0 {
|
||||
t.Fatalf("snap.Queued: got %d want %d", snap.GetQueued(), 0)
|
||||
}
|
||||
|
||||
// Clean up.
|
||||
if err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{RunId: "run-in-flight"}); err != nil {
|
||||
t.Fatalf("OnCancel: %v", err)
|
||||
}
|
||||
<-ba.done
|
||||
}
|
||||
|
||||
func TestOnCommandRequest_CapabilitiesProviderStatusModel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status runtime.ProviderStatus
|
||||
want string
|
||||
}{
|
||||
{name: "available", status: runtime.ProviderStatusAvailable, want: "available"},
|
||||
{name: "unavailable", status: runtime.ProviderStatusUnavailable, want: "unavailable"},
|
||||
{name: "invalid status folds to unknown", status: runtime.ProviderStatus("degraded"), want: "unknown"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ca := &commandAdapter{providerStatus: tc.status}
|
||||
router := &fixedRouter{adapterName: "command", adapters: make(map[string]runtime.Adapter)}
|
||||
router.adapters["command"] = ca
|
||||
n, _ := makeNode(t, router)
|
||||
|
||||
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
||||
RequestId: "req-cap-status",
|
||||
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
||||
Adapter: "command",
|
||||
Target: "codex",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnCommandRequest: %v", err)
|
||||
}
|
||||
if resp.GetError() != "" {
|
||||
t.Fatalf("expected no error, got %q", resp.GetError())
|
||||
}
|
||||
if got := resp.GetResult()["provider_status"]; got != tc.want {
|
||||
t.Fatalf("result[provider_status]: got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnCommandRequest_CapabilitiesWithProber(t *testing.T) {
|
||||
t.Run("probe_target_hit", func(t *testing.T) {
|
||||
pa := &proberTestAdapter{
|
||||
providerStatus: runtime.ProviderStatusAvailable,
|
||||
probeTargets: []string{"model-a", "model-b"},
|
||||
}
|
||||
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
|
||||
router.adapters["prober"] = pa
|
||||
n, _ := makeNode(t, router)
|
||||
|
||||
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
||||
RequestId: "req-cap-prober",
|
||||
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
||||
Adapter: "prober",
|
||||
Target: "model-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnCommandRequest: %v", err)
|
||||
}
|
||||
if resp.GetError() != "" {
|
||||
t.Fatalf("expected no error, got %q", resp.GetError())
|
||||
}
|
||||
if got := resp.GetResult()["provider_status"]; got != "available" {
|
||||
t.Fatalf("result[provider_status]: got %q want %q", got, "available")
|
||||
}
|
||||
if got := resp.GetResult()["targets"]; got != "model-a,model-b" {
|
||||
t.Fatalf("result[targets]: got %q want %q", got, "model-a,model-b")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("probe_target_miss", func(t *testing.T) {
|
||||
pa := &proberTestAdapter{
|
||||
providerStatus: runtime.ProviderStatusAvailable,
|
||||
probeTargets: []string{"model-a"},
|
||||
}
|
||||
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
|
||||
router.adapters["prober"] = pa
|
||||
n, _ := makeNode(t, router)
|
||||
|
||||
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
||||
RequestId: "req-cap-prober",
|
||||
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
||||
Adapter: "prober",
|
||||
Target: "model-c",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnCommandRequest: %v", err)
|
||||
}
|
||||
if resp.GetError() != "" {
|
||||
t.Fatalf("expected no error, got %q", resp.GetError())
|
||||
}
|
||||
if got := resp.GetResult()["provider_status"]; got != "unavailable" {
|
||||
t.Fatalf("result[provider_status]: got %q want %q", got, "unavailable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("probe_error_folds_to_unavailable", func(t *testing.T) {
|
||||
pa := &proberTestAdapter{
|
||||
providerStatus: runtime.ProviderStatusAvailable,
|
||||
probeErr: fmt.Errorf("network error"),
|
||||
}
|
||||
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
|
||||
router.adapters["prober"] = pa
|
||||
n, _ := makeNode(t, router)
|
||||
|
||||
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
|
||||
RequestId: "req-cap-prober",
|
||||
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
|
||||
Adapter: "prober",
|
||||
Target: "model-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnCommandRequest: %v", err)
|
||||
}
|
||||
// Probe error should NOT cause a command error, but set provider_status to unavailable
|
||||
if resp.GetError() != "" {
|
||||
t.Fatalf("expected no error, got %q", resp.GetError())
|
||||
}
|
||||
if got := resp.GetResult()["provider_status"]; got != "unavailable" {
|
||||
t.Fatalf("result[provider_status]: got %q want %q", got, "unavailable")
|
||||
}
|
||||
if got := resp.GetResult()["provider_detail"]; got != "network error" {
|
||||
t.Fatalf("result[provider_detail]: got %q want %q", got, "network error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOnCommandRequest_CapabilitiesWithoutCommandHandler verifies CAPABILITIES
|
||||
// succeeds for an adapter that does not implement runtime.CommandHandler.
|
||||
func TestOnCommandRequest_CapabilitiesWithoutCommandHandler(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -74,12 +74,32 @@ type UsageStats struct {
|
|||
OutputTokens int
|
||||
}
|
||||
|
||||
// ProviderStatus is the minimal Edge-visible provider availability state.
|
||||
type ProviderStatus string
|
||||
|
||||
const (
|
||||
ProviderStatusUnknown ProviderStatus = "unknown"
|
||||
ProviderStatusAvailable ProviderStatus = "available"
|
||||
ProviderStatusUnavailable ProviderStatus = "unavailable"
|
||||
)
|
||||
|
||||
// NormalizeProviderStatus keeps provider state within the public baseline.
|
||||
func NormalizeProviderStatus(status ProviderStatus) ProviderStatus {
|
||||
switch status {
|
||||
case ProviderStatusAvailable, ProviderStatusUnavailable:
|
||||
return status
|
||||
default:
|
||||
return ProviderStatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
ProviderStatus ProviderStatus
|
||||
}
|
||||
|
||||
// RunRequest is the node-domain representation of an incoming run request.
|
||||
|
|
@ -146,6 +166,23 @@ type CommandHandler interface {
|
|||
HandleCommand(ctx context.Context, req CommandRequest) (CommandResponse, error)
|
||||
}
|
||||
|
||||
// ProviderProbeResult contains the target-aware probe result of a provider.
|
||||
type ProviderProbeResult struct {
|
||||
AdapterName string
|
||||
InstanceKey string
|
||||
Target string
|
||||
Targets []string
|
||||
Status ProviderStatus
|
||||
Detail string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// ProviderProber is an optional interface that adapters may implement
|
||||
// to support active endpoint and target availability checks.
|
||||
type ProviderProber interface {
|
||||
ProbeProvider(ctx context.Context, target string) (ProviderProbeResult, error)
|
||||
}
|
||||
|
||||
// EventSink receives RuntimeEvents emitted during execution.
|
||||
type EventSink interface {
|
||||
Emit(ctx context.Context, event RuntimeEvent) error
|
||||
|
|
|
|||
Loading…
Reference in a new issue