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:
toki 2026-06-18 09:45:38 +09:00
parent 9d791a4694
commit 747fa27fa7
12 changed files with 92 additions and 45 deletions

View file

@ -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를 목표로 합니다.

View file

@ -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 경로 안정화 보완

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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`보다 우선한다.

View file

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