feat: update inference provider extension phase and edge app changes
- Update PHASE.md for inference-provider-extension - Archive vllm-provider-serving-validation milestone and SDD - Update edge app OpenAI handlers (chat, responses, stream, run_result) - Update tests and e2e scripts for vLLM integration - Update READMEs for edge and node apps
This commit is contained in:
parent
9d791a4694
commit
747fa27fa7
12 changed files with 92 additions and 45 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# IOP 로드맵 현황 및 진행 계획
|
||||
|
||||
작성 기준: 2026-06-15
|
||||
작성 기준: 2026-06-18
|
||||
|
||||
# 1. 월별 로드맵
|
||||
|
||||
|
|
@ -12,8 +12,8 @@
|
|||
| --- | --- |
|
||||
| 목표 | Control Plane - Edge - Node 구조의 기본 실행 경로를 구축하고, OpenAI-compatible 입력 표면과 CLI Agent/Automation 실행 기반을 실제 운영 가능한 방향으로 정리합니다. |
|
||||
| 예상 투입인원 | 1명 |
|
||||
| 현재 구현 상태 | Edge-Node 소켓 실행 경로와 Node adapter execution 기반 완료<br>Ollama provider 기반 OpenAI-compatible chat completions 경로 안정화<br>Control Plane과 Flutter client를 통한 Edge/Node 운영 관찰 기반 구축<br>CLI Automation Runtime, persistent terminal, Codex/App Server 계열 실행 경로 정리<br>Domain Agent registry, bootstrap command, enrollment 경계 정리<br>OpenAI Responses input surface와 `metadata.workspace` 기반 workspace-bound agent execution contract 완료<br>Workspace port/env 표준화로 Control Plane, Edge, Node, Client, OpenAI-compatible, A2A, wire, metrics, DB/cache 포트 기준 정렬<br>Node 단일 연결에서 여러 target/provider/model candidate를 다루는 multi-target serving 기반 완료<br>Provider availability, capacity, admission queue snapshot 기반 완료<br>Lemonade provider의 모델 조회, non-streaming/streaming chat 경로 검증 완료 |
|
||||
| 완료된 기능 | Edge-Node 실행 기반<br>Ollama 서빙 안정화 기반<br>Control Plane과 Client 운영 기반<br>CLI Automation Runtime 안정화<br>Domain Agent Registry와 Bootstrap Command 발급<br>OpenAI-compatible Responses 입력 표면<br>OpenAI Workspace Agent Execution Contract<br>Workspace port/env 표준화<br>Node multi-target serving 기반<br>Provider availability와 capacity queue 기반<br>Lemonade provider serving 검증 |
|
||||
| 현재 구현 상태 | Edge-Node 소켓 실행 경로와 Node adapter execution 기반 완료<br>Ollama provider 기반 OpenAI-compatible chat completions 경로 안정화<br>Control Plane과 Flutter client를 통한 Edge/Node 운영 관찰 기반 구축<br>CLI Automation Runtime, persistent terminal, Codex/App Server 계열 실행 경로 정리<br>Domain Agent registry, bootstrap command, enrollment 경계 정리<br>OpenAI Responses input surface와 `metadata.workspace` 기반 workspace-bound agent execution contract 완료<br>Workspace port/env 표준화로 Control Plane, Edge, Node, Client, OpenAI-compatible, A2A, wire, metrics, DB/cache 포트 기준 정렬<br>Node 단일 연결에서 여러 target/provider/model candidate를 다루는 multi-target serving 기반 완료<br>Provider availability, capacity, admission queue snapshot 기반 완료<br>Lemonade provider의 모델 조회, non-streaming/streaming chat 경로 검증 완료<br>Edge model group queue scheduling과 model route queue 정책 정합화 완료 |
|
||||
| 완료된 기능 | Edge-Node 실행 기반<br>Ollama 서빙 안정화 기반<br>Control Plane과 Client 운영 기반<br>CLI Automation Runtime 안정화<br>Domain Agent Registry와 Bootstrap Command 발급<br>OpenAI-compatible Responses 입력 표면<br>OpenAI Workspace Agent Execution Contract<br>Workspace port/env 표준화<br>Node multi-target serving 기반<br>Provider availability와 capacity queue 기반<br>Lemonade provider serving 검증<br>Edge model group queue scheduling MVP<br>Model route queue 정책 정합화 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,9 +23,9 @@
|
|||
| --- | --- |
|
||||
| 목표 | 같은 Edge 안에서 동일 `model` 요청을 model group queue로 묶고, 여러 Node 후보에 순차 dispatch하는 구조를 정리합니다. Lemonade 이후 vLLM과 SGLang provider 검증을 이어가며 1차 KPI 범위의 속도/디바이스 효율 축을 닫습니다. |
|
||||
| 예상 투입인원 | 1명 |
|
||||
| 현재 구현 상태 | Node-local admission/capacity 기반 완료<br>Edge model group queue scheduling 전환 진행 중<br>vLLM provider serving validation 진행 중<br>SGLang provider serving validation 계획 상태 |
|
||||
| 주요 작업 | OpenAI-compatible `model` 값을 queue group key로 사용하는 Edge-owned FIFO 정리<br>Node 후보별 capacity, in-flight, queued snapshot을 기준으로 dispatch 흐름 정리<br>vLLM OpenAI-compatible provider의 모델 조회와 chat 경로 검증<br>SGLang provider의 최소 serving 경로 검증<br>Provider별 adapter/config/target/model 매핑 기준 정리 |
|
||||
| 완성 예정 기능 | Edge model group queue scheduling MVP<br>같은 Edge 내부의 다중 Node 후보 순차 dispatch<br>vLLM provider 최소 serving 검증<br>SGLang provider 최소 serving 검증 |
|
||||
| 현재 구현 상태 | Node-local admission/capacity 기반 완료<br>Edge model group queue scheduling과 같은 Edge 내부의 다중 Node 후보 순차 dispatch 기반 완료<br>OpenAI-compatible `model` route queue 정책 정합화 완료<br>vLLM provider serving validation 완료: provider boundary, DGX Spark vLLM container, config contract, 모델 조회/chat, streaming, split-host field smoke 검증 PASS 근거와 코드 레벨 완료 리뷰까지 정리하고 archive함<br>SGLang provider serving validation을 다음 활성 마일스톤으로 지정함. 현재 계획/잠금 상태이며 SDD 승인과 provider 경계·실테스트 endpoint/model/auth/streaming 결정이 필요함 |
|
||||
| 주요 작업 | 완료: OpenAI-compatible `model` 값을 queue group key로 사용하는 Edge-owned FIFO 정리<br>완료: Node 후보별 capacity, in-flight, queued snapshot을 기준으로 dispatch 흐름 정리<br>완료: Model route queue 정책 원천 정합화<br>완료: vLLM OpenAI-compatible provider의 config contract, 모델 조회, chat, streaming, split-host field smoke 검증<br>다음: SGLang provider SDD gate 해소와 최소 serving 경로 검증<br>계획: Provider별 adapter/config/target/model 매핑 기준을 vLLM/SGLang 검증 결과로 보강 |
|
||||
| 완성 예정 기능 | vLLM provider 최소 serving 검증<br>SGLang provider 최소 serving 검증<br>Provider별 adapter/config/target/model 매핑 기준 보강 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
| 구분 | 주요 산출물 |
|
||||
| ----------------| -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 5월 ~ 6월 중순 | Edge-Node 실행 기반, Ollama serving 안정화, Control Plane/Client 운영 기반, CLI Automation Runtime, Domain Agent registry/bootstrap, OpenAI Responses input surface, Workspace Agent Execution Contract, Workspace port/env 표준화, Lemonade provider 검증 결과 |
|
||||
| 6월 중순 ~ 7월 | Edge model group queue scheduling 결과, vLLM/SGLang provider 검증 결과, provider별 adapter/config/target/model 매핑 기준 |
|
||||
| 6월 중순 ~ 7월 | Edge model group queue scheduling과 model route queue 정책 정합화 결과, vLLM/SGLang provider 검증 결과, provider별 adapter/config/target/model 매핑 기준 |
|
||||
| 7월 ~ 8월 | 사용량/토큰/로그 추적 MVP 정의, Provider Catalog 초안, 로컬 디바이스 provider 상태 관리 기준, 운영 확인 표면 초안 |
|
||||
| 8월 ~ 9월 | CLI Agent 사용량 알림/자동 이어받기 MVP, 원격 작업 환경 MVP, 단계 호출과 runtime schema 검증 MVP, Nexo 알림 연동 경계 |
|
||||
| 9월 ~ 10월 | 1차 KPI 범위 feature MVP 통합 검증 결과, E2E smoke 기준, 운영 전환 체크리스트, known limitation, 2차 범위 분리 결과 |
|
||||
|
|
@ -102,9 +102,9 @@
|
|||
|
||||
# 4. 보고용 요약
|
||||
|
||||
IOP는 현재 Control Plane - Edge - Node 기반의 실행 오케스트레이션 구조와 OpenAI-compatible 실행 표면을 상당 부분 구축한 상태입니다. Edge-Node 실행 경로, Ollama serving, Control Plane/Client 운영 기반, CLI Automation Runtime, Domain Agent registry/bootstrap, OpenAI Responses input surface, workspace-bound agent execution contract, Workspace port/env 표준화, Node multi-target serving, provider availability/capacity queue, Lemonade provider 검증까지 완료되었습니다.
|
||||
IOP는 현재 Control Plane - Edge - Node 기반의 실행 오케스트레이션 구조와 OpenAI-compatible 실행 표면을 상당 부분 구축한 상태입니다. Edge-Node 실행 경로, Ollama serving, Control Plane/Client 운영 기반, CLI Automation Runtime, Domain Agent registry/bootstrap, OpenAI Responses input surface, workspace-bound agent execution contract, Workspace port/env 표준화, Node multi-target serving, provider availability/capacity queue, Lemonade provider 검증, Edge model group queue scheduling, model route queue 정책 정합화까지 완료되었습니다.
|
||||
|
||||
현재 진행 중인 핵심 작업은 Edge model group queue scheduling 전환과 vLLM provider serving validation입니다. 이 작업은 같은 Edge 안에서 동일 model 요청을 여러 Node 후보에 효율적으로 분산하고, 추가 local inference provider를 IOP의 운영 경로에 올리기 위한 1차 KPI의 속도/디바이스 효율 축입니다.
|
||||
현재 vLLM provider serving validation은 완료 및 archive 처리되었습니다. Edge model group queue scheduling과 model route queue 정책 정합화는 완료된 기반으로 보고, 다음 활성 마일스톤은 SGLang provider serving validation입니다. SGLang은 현재 계획/잠금 상태이며, SDD 승인과 provider 경계·실테스트 endpoint/model/auth/streaming 결정이 다음 진입 조건입니다.
|
||||
|
||||
이후 7월부터 8월까지는 사용자/토큰/사용량/로그 추적과 Provider Catalog, 로컬 디바이스 provider 상태 관리를 정리합니다. 이 단계는 완성된 billing이나 enterprise IAM이 아니라, 운영자가 IOP의 사용량과 provider 상태를 확인할 수 있는 feature MVP를 목표로 합니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Edge OpenAI-compatible 입력 표면에서 vLLM의 모델 조회, non-streaming/
|
|||
|
||||
## 상태
|
||||
|
||||
[진행중]
|
||||
[완료]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
|
|
@ -44,21 +44,26 @@ Edge OpenAI-compatible 입력 표면에서 vLLM의 모델 조회, non-streaming/
|
|||
|
||||
- [x] [provider-boundary] vLLM provider를 독립 adapter로 둘지 OpenAI-compatible inference server 공통 adapter로 둘지 결정 근거가 정리되어 있다.
|
||||
- [x] [spark-container] `agent-test/local/rules.md`의 DGX Spark field host에서 ARM64/Blackwell용 vLLM container image/tag, model cache/volume, launch command, health check 기준이 정리되고 container 기동이 검증되어 있다. 검증: DGX Spark vLLM endpoint의 OpenAI-compatible `/v1/models`가 응답한다.
|
||||
- [ ] [config-contract] vLLM endpoint, served model name, model alias, auth/header, timeout, option passthrough 설정 계약이 정리되어 있다.
|
||||
- [ ] [models-chat] Edge OpenAI-compatible `/v1/models`와 non-streaming `/v1/chat/completions`가 vLLM provider로 수렴하는 기준이 검증되어 있다.
|
||||
- [ ] [streaming] streaming `/v1/chat/completions`에서 SSE chunk, finish reason, 종료 신호가 vLLM provider 경로로 안정적으로 전달되는지 검증되어 있다.
|
||||
- [ ] [field-smoke] split-host field smoke에서 vLLM target/model을 선택해 모델 조회와 chat 왕복이 검증되어 있다.
|
||||
- [x] [config-contract] vLLM endpoint, served model name, model alias, auth/header, timeout, option passthrough 설정 계약이 정리되어 있다.
|
||||
- [x] [models-chat] Edge OpenAI-compatible `/v1/models`와 non-streaming `/v1/chat/completions`가 vLLM provider로 수렴하는 기준이 검증되어 있다.
|
||||
- [x] [streaming] streaming `/v1/chat/completions`에서 SSE chunk, finish reason, 종료 신호가 vLLM provider 경로로 안정적으로 전달되는지 검증되어 있다.
|
||||
- [x] [field-smoke] split-host field smoke에서 vLLM target/model을 선택해 모델 조회와 chat 왕복이 검증되어 있다.
|
||||
- [x] [follow-up-scope] 코드 수정이 필요한 항목은 이 Milestone의 추가 Task 또는 같은 Milestone task group의 후속 plan으로 정리되어 있다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: `spark-container`는 PASS evidence로 충족되었고, `config-contract`, `models-chat`, `streaming`, `field-smoke`가 아직 남아 있다.
|
||||
- 상태: 통과
|
||||
- 요청일: 2026-06-18
|
||||
- 완료 근거: `01_spark_container`, `02+01_config_contract`, `03+02_openai_vllm_smoke`, `04+03_field_smoke` complete.log가 모두 PASS이며 `Roadmap Completion`으로 `spark-container`, `config-contract`, `models-chat`, `streaming`, `field-smoke` 완료를 증명한다.
|
||||
- 완료 근거: SDD `S01`~`S05`의 `Spec Completion` PASS 근거가 complete.log에 남아 있고, `provider-boundary`와 `follow-up-scope`는 이미 체크된 상태로 남은 기능 Task가 없다.
|
||||
- 코드 레벨 리뷰: 2026-06-18 Edge OpenAI 응답 변환, Node `openai_compat`/vLLM adapter, routing/config, vLLM smoke script를 검토했고 완료 차단 이슈는 없다.
|
||||
- 보완 완료: provider `finish_reason`을 Edge non-streaming/streaming 응답에 보존하도록 수정하고, vLLM smoke가 fake/real 모드 모두에서 `finish_reason`을 검증하도록 보강했다.
|
||||
- 검증 완료: `go test -count=1 ./apps/edge/internal/openai ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/service`, `go test ./packages/go/config ./apps/edge/internal/node ./apps/node/internal/adapters ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai ./apps/edge/internal/service`, `bash scripts/e2e-openai-vllm.sh`, `git diff --check` PASS.
|
||||
- 남은 차단 항목: 없음
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
- [x] 사용자가 완료 결과를 확인했다
|
||||
- [x] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 2026-06-18 사용자 승인에 따라 완료 처리 및 archive 이동.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
|
|
@ -84,7 +89,9 @@ Edge OpenAI-compatible 입력 표면에서 vLLM의 모델 조회, non-streaming/
|
|||
- 결정됨: field smoke 단계의 vLLM endpoint 인증/추가 헤더는 없음으로 둔다. 추후 인증이 필요한 환경은 config contract 확장에서 별도 옵션으로 다룬다.
|
||||
- 결정됨: vLLM streaming은 지원 전제로 검증한다. `/v1/chat/completions`의 `stream: true` SSE chunk, `finish_reason`, 종료 신호를 확인 대상으로 둔다.
|
||||
- 완료됨: `agent-task/archive/2026/06/m-vllm-provider-serving-validation/01_spark_container/complete.log`에서 `spark-container`와 SDD scenario `S01` PASS를 확인했다.
|
||||
- 진행중: `agent-task/m-vllm-provider-serving-validation/02+01_config_contract`, `03+02_openai_vllm_smoke`, `04+03_field_smoke` 활성 plan/review가 남아 있다.
|
||||
- 완료됨: `agent-task/archive/2026/06/m-vllm-provider-serving-validation/02+01_config_contract/complete.log`에서 `config-contract`와 SDD scenario `S02` PASS를 확인했다.
|
||||
- 완료됨: `agent-task/archive/2026/06/m-vllm-provider-serving-validation/03+02_openai_vllm_smoke/complete.log`에서 `models-chat`, `streaming`과 SDD scenarios `S03`, `S04` PASS를 확인했다.
|
||||
- 완료됨: `agent-task/archive/2026/06/m-vllm-provider-serving-validation/04+03_field_smoke/complete.log`에서 `field-smoke`와 SDD scenario `S05` PASS를 확인했다.
|
||||
- 외부 근거: `https://recipes.vllm.ai/Qwen/Qwen3.6-35B-A3B`, `https://huggingface.co/Qwen/Qwen3.6-35B-A3B`, `https://build.nvidia.com/spark/vllm`
|
||||
- 선행 작업: Ollama 실테스트와 후속 안정화, Node 단일 통로 멀티 타겟 서빙 기반
|
||||
- 후속 작업: vLLM provider 실테스트에서 확인된 serving 경로 안정화 보완
|
||||
|
|
@ -37,9 +37,9 @@ Ollama 경로가 안정화된 뒤, 그 결과를 기준선으로 삼아 Lemonade
|
|||
- 경로: `agent-roadmap/archive/phase/inference-provider-extension/milestones/model-route-queue-policy-alignment.md`
|
||||
- 요약: Edge-owned model group queue의 정책 원천을 `openai.model_routes[]`의 model alias로 정리하고, Node/provider instance에는 per-node capacity 기준만 남긴다.
|
||||
|
||||
- [진행중] vLLM provider 서빙 경로 추가
|
||||
- 경로: `agent-roadmap/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md`
|
||||
- 요약: Ollama 경로와 Node 단일 통로 멀티 타겟 기준선을 바탕으로 vLLM OpenAI-compatible provider를 붙이고, 모델 조회와 non-streaming/streaming chat을 검증한다.
|
||||
- [완료] vLLM provider 서빙 경로 추가
|
||||
- 경로: `agent-roadmap/archive/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md`
|
||||
- 요약: vLLM OpenAI-compatible provider의 config contract, 모델 조회, non-streaming/streaming chat, split-host field smoke 검증을 완료했고, 코드 레벨 완료 리뷰까지 통과해 archive했다.
|
||||
|
||||
- [계획] SGLang provider 서빙 경로 추가
|
||||
- 경로: `agent-roadmap/phase/inference-provider-extension/milestones/sglang-provider-serving-validation.md`
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ curl -s http://127.0.0.1:18081/v1/responses \
|
|||
-d '{"model":"qwen3.6:35b-a3b-bf16","input":"hello","stream":false,"metadata":{"request_id":"example","workspace":"/config/workspace/iop","nomadcode":{"task_id":"task-123","source":"manual"}}}'
|
||||
```
|
||||
|
||||
vLLM은 같은 `adapter + target` 경계를 쓰도록 설정과 `/v1/models` capability 조회 skeleton만 먼저 열어두었다. 실제 completion 실행은 다음 단계에서 붙인다.
|
||||
vLLM 같은 OpenAI-compatible inference server는 `openai_compat` adapter와 `provider: "vllm"` instance로 연결한다. 이 경로는 `/v1/models`, non-streaming/streaming `/v1/chat/completions`, provider header, request `options` 전달을 지원하며, Edge의 model route alias를 실제 served model로 매핑한다.
|
||||
|
||||
### A2A Agent Input
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ func chatRunMetadata(runMeta map[string]string, req chatCompletionRequest, outpu
|
|||
}
|
||||
|
||||
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
|
||||
text, reasoning, usage, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
text, reasoning, finishReason, usage, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
|
||||
return
|
||||
|
|
@ -134,7 +134,7 @@ func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request,
|
|||
Choices: []chatCompletionChoice{{
|
||||
Index: 0,
|
||||
Message: chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning},
|
||||
FinishReason: "stop",
|
||||
FinishReason: finishReason,
|
||||
}},
|
||||
Usage: usage,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) completeResponse(w http.ResponseWriter, r *http.Request, req responsesRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
|
||||
text, reasoning, usage, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
text, reasoning, _, usage, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
|
||||
return
|
||||
|
|
|
|||
|
|
@ -9,32 +9,33 @@ import (
|
|||
edgeservice "iop/apps/edge/internal/service"
|
||||
)
|
||||
|
||||
func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout time.Duration) (string, string, *openAIUsage, error) {
|
||||
func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout time.Duration) (string, string, string, *openAIUsage, error) {
|
||||
if stream.Events == nil {
|
||||
return "", "", nil, fmt.Errorf("run stream unavailable")
|
||||
return "", "", "", nil, fmt.Errorf("run stream unavailable")
|
||||
}
|
||||
var contentBuilder strings.Builder
|
||||
var reasoningBuilder strings.Builder
|
||||
finishReason := "stop"
|
||||
var usage *openAIUsage
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", "", nil, ctx.Err()
|
||||
return "", "", "", nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
return "", "", nil, fmt.Errorf("run timed out")
|
||||
return "", "", "", nil, fmt.Errorf("run timed out")
|
||||
case nodeEvent, ok := <-stream.NodeEvents:
|
||||
if !ok {
|
||||
stream.NodeEvents = nil
|
||||
continue
|
||||
}
|
||||
if edgeservice.IsNodeDisconnected(nodeEvent) {
|
||||
return "", "", nil, fmt.Errorf("node disconnected")
|
||||
return "", "", "", nil, fmt.Errorf("node disconnected")
|
||||
}
|
||||
case event, ok := <-stream.Events:
|
||||
if !ok {
|
||||
return "", "", nil, fmt.Errorf("run stream closed")
|
||||
return "", "", "", nil, fmt.Errorf("run stream closed")
|
||||
}
|
||||
if event == nil {
|
||||
continue
|
||||
|
|
@ -45,6 +46,9 @@ func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout
|
|||
case "reasoning_delta":
|
||||
reasoningBuilder.WriteString(event.GetDelta())
|
||||
case "complete":
|
||||
if reason := event.GetMetadata()["finish_reason"]; reason != "" {
|
||||
finishReason = reason
|
||||
}
|
||||
if u := event.GetUsage(); u != nil {
|
||||
usage = &openAIUsage{
|
||||
PromptTokens: int(u.GetInputTokens()),
|
||||
|
|
@ -52,7 +56,7 @@ func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout
|
|||
TotalTokens: int(u.GetInputTokens() + u.GetOutputTokens()),
|
||||
}
|
||||
}
|
||||
return contentBuilder.String(), reasoningBuilder.String(), usage, nil
|
||||
return contentBuilder.String(), reasoningBuilder.String(), finishReason, usage, nil
|
||||
case "error", "cancelled":
|
||||
msg := event.GetError()
|
||||
if msg == "" {
|
||||
|
|
@ -61,7 +65,7 @@ func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout
|
|||
if msg == "" {
|
||||
msg = "run failed"
|
||||
}
|
||||
return "", "", nil, fmt.Errorf("%s", msg)
|
||||
return "", "", "", nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,32 @@ func TestChatCompletionsUsesRequestModelWhenNoConfiguredTarget(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsPreservesProviderFinishReason(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "truncated"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"qwen3.6:35b",
|
||||
"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())
|
||||
}
|
||||
var resp chatCompletionResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Choices[0].FinishReason != "length" {
|
||||
t.Fatalf("finish_reason: got %q, want length", resp.Choices[0].FinishReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsPassesOllamaOptions(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
||||
|
|
@ -155,7 +181,7 @@ func TestChatCompletionsPassesOllamaOptions(t *testing.T) {
|
|||
func TestChatCompletionsStreamsSSE(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
|
|
@ -174,6 +200,9 @@ func TestChatCompletionsStreamsSSE(t *testing.T) {
|
|||
if !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") {
|
||||
t.Fatalf("unexpected SSE body:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"finish_reason":"length"`) {
|
||||
t.Fatalf("streaming finish_reason did not preserve provider value:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamingReportsClosedRunStream(t *testing.T) {
|
||||
|
|
@ -925,7 +954,7 @@ func TestCollectRunResultTimesOut(t *testing.T) {
|
|||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
_, _, _, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout())
|
||||
_, _, _, _, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout())
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
|
|
@ -1228,7 +1257,7 @@ func TestCollectRunResultFailsWhenEventStreamCloses(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
_, _, _, err := collectRunResult(context.Background(), handle.Stream(), handle.WaitTimeout())
|
||||
_, _, _, _, err := collectRunResult(context.Background(), handle.Stream(), handle.WaitTimeout())
|
||||
if err == nil {
|
||||
t.Fatal("expected closed stream error")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
|
|||
}},
|
||||
})
|
||||
case "complete":
|
||||
finishReason := event.GetMetadata()["finish_reason"]
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
|
|
@ -124,7 +128,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
|
|||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{},
|
||||
FinishReason: "stop",
|
||||
FinishReason: finishReason,
|
||||
}},
|
||||
})
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
|
|
@ -149,7 +153,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
|
|||
}
|
||||
|
||||
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle edgeservice.RunResult, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy) {
|
||||
text, reasoning, _, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
text, reasoning, finishReason, _, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
writeSSEError(w, flusher, err.Error())
|
||||
return
|
||||
|
|
@ -186,7 +190,7 @@ func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Req
|
|||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{},
|
||||
FinishReason: "stop",
|
||||
FinishReason: finishReason,
|
||||
}},
|
||||
})
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ internal/
|
|||
adapters/
|
||||
mock/ — 에코 테스트 어댑터
|
||||
ollama/ — Ollama /api/chat 스트리밍 어댑터
|
||||
vllm/ — vLLM OpenAI-compatible 어댑터 skeleton
|
||||
openai_compat/ — vLLM/Lemonade 같은 OpenAI-compatible 공통 어댑터
|
||||
vllm/ — vLLM 직접 OpenAI-compatible 어댑터
|
||||
cli/ — CLI 프로세스 어댑터 (claude/antigravity/codex/opencode/cline)
|
||||
store/ — SQLite 실행 이력
|
||||
```
|
||||
|
|
@ -116,7 +117,8 @@ node는 adapter execution(`RunRequest`) 외에도 edge가 보내는 `NodeCommand
|
|||
|---|---|---|
|
||||
| `mock` | 입력 에코, 스트리밍 테스트용 | 구현 완료 |
|
||||
| `ollama` | 로컬 Ollama `/api/chat` 스트리밍 연동 | 기본 구현 완료 |
|
||||
| `vllm` | vLLM OpenAI-compatible API | `/v1/models` 조회 skeleton |
|
||||
| `openai_compat` | vLLM/Lemonade 등 OpenAI-compatible inference server | `/v1/models`, streaming `/v1/chat/completions`, headers/options passthrough 구현 완료 |
|
||||
| `vllm` | vLLM 직접 OpenAI-compatible API | 기본 구현 완료 |
|
||||
| `cli` | claude/antigravity/codex/opencode/cline CLI 실행 | 구현 완료 |
|
||||
|
||||
Ollama adapter는 edge에서 받은 내부 target을 Ollama model 이름으로 사용한다. Edge OpenAI-compatible HTTP API에서 `openai.target`을 지정하면 외부 `model` 값과 무관하게 해당 Ollama model로 라우팅하고, `openai.target`을 비우면 요청의 `model` 값을 그대로 내부 target으로 전달한다. `adapters.ollama.context_size`가 설정되어 있으면 Ollama `/api/chat` 요청의 `options.num_ctx`를 강제하며, 요청 input의 `options.num_ctx`보다 우선한다.
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ if [ "$MODE" = "fake" ]; then
|
|||
else
|
||||
grep -Eq '"content":"[^"]' "$CHAT_OUT"
|
||||
fi
|
||||
grep -q '"finish_reason":"' "$CHAT_OUT"
|
||||
|
||||
# Streaming chat completion: SSE chunks, finish_reason, and terminating [DONE].
|
||||
STREAM_OUT="$TMP_DIR/stream.txt"
|
||||
|
|
@ -267,8 +268,8 @@ curl -fsS -N \
|
|||
"http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$STREAM_OUT"
|
||||
if [ "$MODE" = "fake" ]; then
|
||||
grep -q '"content":"IOP_OPENAI_"' "$STREAM_OUT"
|
||||
grep -q '"finish_reason":"stop"' "$STREAM_OUT"
|
||||
fi
|
||||
grep -q '"finish_reason":"' "$STREAM_OUT"
|
||||
grep -q 'data: \[DONE\]' "$STREAM_OUT"
|
||||
|
||||
if grep -i -E "node reported error|error run_id=|\[[^]]+-evt\] error|panic:" "$EDGE_OUT" "$NODE_OUT" >/dev/null; then
|
||||
|
|
|
|||
Loading…
Reference in a new issue