diff --git a/IOP_ROADMAP_STATUS.md b/IOP_ROADMAP_STATUS.md
index 2cee31c..f06f303 100644
--- a/IOP_ROADMAP_STATUS.md
+++ b/IOP_ROADMAP_STATUS.md
@@ -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 기반 완료
Ollama provider 기반 OpenAI-compatible chat completions 경로 안정화
Control Plane과 Flutter client를 통한 Edge/Node 운영 관찰 기반 구축
CLI Automation Runtime, persistent terminal, Codex/App Server 계열 실행 경로 정리
Domain Agent registry, bootstrap command, enrollment 경계 정리
OpenAI Responses input surface와 `metadata.workspace` 기반 workspace-bound agent execution contract 완료
Workspace port/env 표준화로 Control Plane, Edge, Node, Client, OpenAI-compatible, A2A, wire, metrics, DB/cache 포트 기준 정렬
Node 단일 연결에서 여러 target/provider/model candidate를 다루는 multi-target serving 기반 완료
Provider availability, capacity, admission queue snapshot 기반 완료
Lemonade provider의 모델 조회, non-streaming/streaming chat 경로 검증 완료 |
-| 완료된 기능 | Edge-Node 실행 기반
Ollama 서빙 안정화 기반
Control Plane과 Client 운영 기반
CLI Automation Runtime 안정화
Domain Agent Registry와 Bootstrap Command 발급
OpenAI-compatible Responses 입력 표면
OpenAI Workspace Agent Execution Contract
Workspace port/env 표준화
Node multi-target serving 기반
Provider availability와 capacity queue 기반
Lemonade provider serving 검증 |
+| 현재 구현 상태 | Edge-Node 소켓 실행 경로와 Node adapter execution 기반 완료
Ollama provider 기반 OpenAI-compatible chat completions 경로 안정화
Control Plane과 Flutter client를 통한 Edge/Node 운영 관찰 기반 구축
CLI Automation Runtime, persistent terminal, Codex/App Server 계열 실행 경로 정리
Domain Agent registry, bootstrap command, enrollment 경계 정리
OpenAI Responses input surface와 `metadata.workspace` 기반 workspace-bound agent execution contract 완료
Workspace port/env 표준화로 Control Plane, Edge, Node, Client, OpenAI-compatible, A2A, wire, metrics, DB/cache 포트 기준 정렬
Node 단일 연결에서 여러 target/provider/model candidate를 다루는 multi-target serving 기반 완료
Provider availability, capacity, admission queue snapshot 기반 완료
Lemonade provider의 모델 조회, non-streaming/streaming chat 경로 검증 완료
Edge model group queue scheduling과 model route queue 정책 정합화 완료 |
+| 완료된 기능 | Edge-Node 실행 기반
Ollama 서빙 안정화 기반
Control Plane과 Client 운영 기반
CLI Automation Runtime 안정화
Domain Agent Registry와 Bootstrap Command 발급
OpenAI-compatible Responses 입력 표면
OpenAI Workspace Agent Execution Contract
Workspace port/env 표준화
Node multi-target serving 기반
Provider availability와 capacity queue 기반
Lemonade provider serving 검증
Edge model group queue scheduling MVP
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 기반 완료
Edge model group queue scheduling 전환 진행 중
vLLM provider serving validation 진행 중
SGLang provider serving validation 계획 상태 |
-| 주요 작업 | OpenAI-compatible `model` 값을 queue group key로 사용하는 Edge-owned FIFO 정리
Node 후보별 capacity, in-flight, queued snapshot을 기준으로 dispatch 흐름 정리
vLLM OpenAI-compatible provider의 모델 조회와 chat 경로 검증
SGLang provider의 최소 serving 경로 검증
Provider별 adapter/config/target/model 매핑 기준 정리 |
-| 완성 예정 기능 | Edge model group queue scheduling MVP
같은 Edge 내부의 다중 Node 후보 순차 dispatch
vLLM provider 최소 serving 검증
SGLang provider 최소 serving 검증 |
+| 현재 구현 상태 | Node-local admission/capacity 기반 완료
Edge model group queue scheduling과 같은 Edge 내부의 다중 Node 후보 순차 dispatch 기반 완료
OpenAI-compatible `model` route queue 정책 정합화 완료
vLLM provider serving validation 완료: provider boundary, DGX Spark vLLM container, config contract, 모델 조회/chat, streaming, split-host field smoke 검증 PASS 근거와 코드 레벨 완료 리뷰까지 정리하고 archive함
SGLang provider serving validation을 다음 활성 마일스톤으로 지정함. 현재 계획/잠금 상태이며 SDD 승인과 provider 경계·실테스트 endpoint/model/auth/streaming 결정이 필요함 |
+| 주요 작업 | 완료: OpenAI-compatible `model` 값을 queue group key로 사용하는 Edge-owned FIFO 정리
완료: Node 후보별 capacity, in-flight, queued snapshot을 기준으로 dispatch 흐름 정리
완료: Model route queue 정책 원천 정합화
완료: vLLM OpenAI-compatible provider의 config contract, 모델 조회, chat, streaming, split-host field smoke 검증
다음: SGLang provider SDD gate 해소와 최소 serving 경로 검증
계획: Provider별 adapter/config/target/model 매핑 기준을 vLLM/SGLang 검증 결과로 보강 |
+| 완성 예정 기능 | vLLM provider 최소 serving 검증
SGLang provider 최소 serving 검증
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를 목표로 합니다.
diff --git a/agent-roadmap/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md b/agent-roadmap/archive/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md
similarity index 74%
rename from agent-roadmap/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md
rename to agent-roadmap/archive/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md
index cdee095..fd1260c 100644
--- a/agent-roadmap/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md
+++ b/agent-roadmap/archive/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md
@@ -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 경로 안정화 보완
diff --git a/agent-roadmap/sdd/inference-provider-extension/vllm-provider-serving-validation/SDD.md b/agent-roadmap/archive/sdd/inference-provider-extension/vllm-provider-serving-validation/SDD.md
similarity index 100%
rename from agent-roadmap/sdd/inference-provider-extension/vllm-provider-serving-validation/SDD.md
rename to agent-roadmap/archive/sdd/inference-provider-extension/vllm-provider-serving-validation/SDD.md
diff --git a/agent-roadmap/phase/inference-provider-extension/PHASE.md b/agent-roadmap/phase/inference-provider-extension/PHASE.md
index a784738..5a4e9a9 100644
--- a/agent-roadmap/phase/inference-provider-extension/PHASE.md
+++ b/agent-roadmap/phase/inference-provider-extension/PHASE.md
@@ -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`
diff --git a/apps/edge/README.md b/apps/edge/README.md
index 81f49e3..d35ba5c 100644
--- a/apps/edge/README.md
+++ b/apps/edge/README.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
diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go
index efe3412..3e9784c 100644
--- a/apps/edge/internal/openai/chat_handler.go
+++ b/apps/edge/internal/openai/chat_handler.go
@@ -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,
})
diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go
index ced5083..f10420b 100644
--- a/apps/edge/internal/openai/responses_handler.go
+++ b/apps/edge/internal/openai/responses_handler.go
@@ -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
diff --git a/apps/edge/internal/openai/run_result.go b/apps/edge/internal/openai/run_result.go
index 71916cd..d7f8742 100644
--- a/apps/edge/internal/openai/run_result.go
+++ b/apps/edge/internal/openai/run_result.go
@@ -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)
}
}
}
diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go
index b41d117..6f48dc2 100644
--- a/apps/edge/internal/openai/server_test.go
+++ b/apps/edge/internal/openai/server_test.go
@@ -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")
}
diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go
index 7e758ff..d640227 100644
--- a/apps/edge/internal/openai/stream.go
+++ b/apps/edge/internal/openai/stream.go
@@ -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")
diff --git a/apps/node/README.md b/apps/node/README.md
index b366f64..975c60c 100644
--- a/apps/node/README.md
+++ b/apps/node/README.md
@@ -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`보다 우선한다.
diff --git a/scripts/e2e-openai-vllm.sh b/scripts/e2e-openai-vllm.sh
index d99ed72..54ad8b8 100755
--- a/scripts/e2e-openai-vllm.sh
+++ b/scripts/e2e-openai-vllm.sh
@@ -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