From 747fa27fa71812a3cf7a4818df5d27788c6b7c1e Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 18 Jun 2026 09:45:38 +0900 Subject: [PATCH] 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 --- IOP_ROADMAP_STATUS.md | 18 +++++----- .../vllm-provider-serving-validation.md | 31 +++++++++------- .../vllm-provider-serving-validation/SDD.md | 0 .../inference-provider-extension/PHASE.md | 6 ++-- apps/edge/README.md | 2 +- apps/edge/internal/openai/chat_handler.go | 4 +-- .../edge/internal/openai/responses_handler.go | 2 +- apps/edge/internal/openai/run_result.go | 20 ++++++----- apps/edge/internal/openai/server_test.go | 35 +++++++++++++++++-- apps/edge/internal/openai/stream.go | 10 ++++-- apps/node/README.md | 6 ++-- scripts/e2e-openai-vllm.sh | 3 +- 12 files changed, 92 insertions(+), 45 deletions(-) rename agent-roadmap/{ => archive}/phase/inference-provider-extension/milestones/vllm-provider-serving-validation.md (74%) rename agent-roadmap/{ => archive}/sdd/inference-provider-extension/vllm-provider-serving-validation/SDD.md (100%) 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