기능: OpenAI 서빙과 필드 배포 구성을 추가한다

필드 테스트를 위해 edge OpenAI-compatible 경로와 node adapter 설정을 확장하고, Jenkins 바이너리 빌드 및 control-plane/web compose 배포 구성을 함께 정리한다.
This commit is contained in:
toki 2026-05-19 16:04:23 +09:00
parent d91e331158
commit a52ecb8efe
35 changed files with 2188 additions and 103 deletions

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
COMPOSE_PROJECT_NAME=iop-dev
IOP_CONTROL_PLANE_HTTP_PORT=9080
IOP_CONTROL_PLANE_WIRE_PORT=19080
IOP_WEB_PORT=3000
IOP_POSTGRES_USER=iop
IOP_POSTGRES_PASSWORD=iop_dev_password
IOP_POSTGRES_DB=iop
IOP_POSTGRES_PORT=5432
IOP_REDIS_PORT=6379

2
.gitignore vendored
View file

@ -5,6 +5,8 @@ agent-ops/rules/private/
# Local runtime/test artifacts
/iop.db
/*.log
/dist/
/.env
# Web Portal artifacts
apps/web/node_modules/

View file

@ -1,4 +1,4 @@
.PHONY: all build tidy test test-e2e proto clean
.PHONY: all build tidy test test-e2e test-openai-ollama proto clean
GOFLAGS ?= -trimpath
@ -17,8 +17,12 @@ test:
go test ./...
test-e2e:
@echo "NOTE: test-e2e runs auxiliary smoke only; completion requires bin/edge.sh + bin/node.sh user-flow verification."
@echo "NOTE: test-e2e runs auxiliary smoke plus fake Ollama OpenAI serving; completion still requires user-flow verification when changing runtime paths."
./scripts/e2e-smoke.sh
./scripts/e2e-openai-ollama.sh
test-openai-ollama:
./scripts/e2e-openai-ollama.sh
# Requires: protoc + protoc-gen-go (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest)
proto:

View file

@ -2,7 +2,7 @@
여러 Node를 하나의 로컬 실행 그룹으로 묶고, IOP 내부 TCP/protobuf 프로토콜을 통해 adapter execution 요청과 이벤트 스트림을 중계한다.
Edge는 현재 단계에서 OpenAI-compatible HTTP API gateway가 아니라 Node registry, node configuration, runtime routing, stream relay를 검증하는 백엔드 실행 그룹 컨트롤러다. 외부 HTTP 호환 계층은 node/transport 경계가 안정화된 뒤 별도 표면으로 추가한다.
Edge는 Node registry, node configuration, runtime routing, stream relay를 담당하는 백엔드 실행 그룹 컨트롤러다. 현재는 진단용 ops console과 함께 최소 OpenAI-compatible HTTP API 표면(`/v1/models`, `/v1/chat/completions`)을 제공하며, 이 HTTP 표면은 edge service를 통해 기존 edge-node transport를 사용한다.
**현재 상태: 초기 구현**
node 등록/레지스트리/transport와 edge 콘솔 기반 수동 통신 테스트가 구현되어 있다.
@ -18,6 +18,7 @@ ops console은 edge-local diagnostic surface이고, HTTP/API는 central/remote m
- `internal/transport`: node TCP/protobuf 연결, register handshake, protobuf message 수신
- `internal/node`: node registry와 사전 등록 node store
- `internal/service`: `SubmitRun`, `TerminateSession`, `UsageStatus`, `ListNodes`, `ResolveNode`
- `internal/openai`: OpenAI-compatible HTTP serving 표면. 외부 `model`은 이 경계에서 내부 `adapter + target`으로 변환된다.
- `internal/events`: `RunEvent`, `EdgeNodeEvent` in-process publish/subscribe fanout
- `cmd/edge`: serve/console CLI 진입점과 console 출력 포맷
@ -135,3 +136,39 @@ edge-node transport 연결은 **node id당 1개** TCP 연결만 유지한다.
프롬프트 없이 TCP/protobuf edge 서버만 실행하려면 기존처럼 `go run ./apps/edge/cmd/edge serve -c configs/edge.yaml`를 사용한다.
`bin/node.sh`의 edge 대기 시간은 `IOP_NODE_WAIT_TIMEOUT`, 확인 간격은 `IOP_NODE_WAIT_INTERVAL`, 접속 주소는 `IOP_EDGE_ADDR` 환경 변수로 임시 override할 수 있다.
## OpenAI-Compatible Serving
`configs/edge.yaml``openai` 섹션을 켜면 edge가 별도 HTTP listener를 열고 외부 agent가 OpenAI API 형태로 접속할 수 있다. 1차 구현은 Ollama를 주 테스트 경로로 삼는다.
```yaml
openai:
enabled: true
listen: "0.0.0.0:8080"
adapter: "ollama"
target: "qwen3.6:35b-a3b-bf16"
models:
- "qwen3.6:35b-a3b-bf16"
session_id: "cline"
timeout_sec: 300
nodes:
- id: "node-dgx-01"
adapters:
ollama:
enabled: true
base_url: "http://192.168.0.91:11434"
context_size: 262144
```
`openai.target`이 비어 있으면 HTTP 요청의 `model` 값을 내부 target으로 사용한다. 값이 있으면 Cline 같은 외부 agent가 보낸 `model`과 무관하게 YAML의 target으로 고정 라우팅한다.
기본 non-streaming과 streaming SSE 응답을 모두 지원한다.
```bash
curl -s http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3.6:35b-a3b-bf16","messages":[{"role":"user","content":"hello"}]}'
```
vLLM은 같은 `adapter + target` 경계를 쓰도록 설정과 `/v1/models` capability 조회 skeleton만 먼저 열어두었다. 실제 completion 실행은 다음 단계에서 붙인다.

View file

@ -3,11 +3,13 @@ package bootstrap
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
edgeopenai "iop/apps/edge/internal/openai"
edgeservice "iop/apps/edge/internal/service"
"iop/apps/edge/internal/transport"
"iop/packages/config"
@ -26,6 +28,7 @@ type Runtime struct {
EventBus *edgeevents.Bus
Service *edgeservice.Service
Server *transport.Server
OpenAI *edgeopenai.Server
}
func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
@ -42,6 +45,7 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
bus := edgeevents.NewBus()
svc := edgeservice.New(registry, bus)
openaiServer := edgeopenai.NewServer(cfg.OpenAI, svc, logger.Named("openai"))
server, err := transport.NewServer(cfg.Server.Listen, registry, nodeStore, logger)
if err != nil {
@ -56,6 +60,7 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
EventBus: bus,
Service: svc,
Server: server,
OpenAI: openaiServer,
}, nil
}
@ -69,6 +74,10 @@ func (r *Runtime) Start(ctx context.Context) error {
if err := r.Server.Start(ctx); err != nil {
return err
}
if err := r.OpenAI.Start(ctx); err != nil {
_ = r.Server.Stop()
return err
}
go func() {
if err := observability.ServeMetrics(r.Cfg.Metrics.Port); err != nil {
r.Logger.Warn("metrics server exited", zap.Error(err))
@ -78,7 +87,13 @@ func (r *Runtime) Start(ctx context.Context) error {
}
func (r *Runtime) Stop() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
openAIErr := r.OpenAI.Stop(ctx)
err := r.Server.Stop()
_ = r.Logger.Sync()
if err == nil {
err = openAIErr
}
return err
}

View file

@ -34,7 +34,10 @@ func BuildConfigPayload(rec *NodeRecord) (*iop.NodeConfigPayload, error) {
Type: "ollama",
Enabled: true,
Config: &iop.AdapterConfig_Ollama{
Ollama: &iop.OllamaAdapterConfig{BaseUrl: rec.Adapters.Ollama.BaseURL},
Ollama: &iop.OllamaAdapterConfig{
BaseUrl: rec.Adapters.Ollama.BaseURL,
ContextSize: int32(rec.Adapters.Ollama.ContextSize),
},
},
})
}

View file

@ -14,8 +14,9 @@ func TestBuildConfigPayload_OllamaEnabled(t *testing.T) {
Token: "token",
Adapters: config.AdaptersConf{
Ollama: config.OllamaConf{
Enabled: true,
BaseURL: "http://localhost:11434",
Enabled: true,
BaseURL: "http://localhost:11434",
ContextSize: 262144,
},
},
Runtime: config.RuntimeConf{
@ -55,6 +56,9 @@ func TestBuildConfigPayload_OllamaEnabled(t *testing.T) {
if ollamaCfg.BaseUrl != "http://localhost:11434" {
t.Errorf("expected ollama base url %q, got %q", "http://localhost:11434", ollamaCfg.BaseUrl)
}
if ollamaCfg.ContextSize != 262144 {
t.Errorf("expected ollama context size %d, got %d", 262144, ollamaCfg.ContextSize)
}
}
}
if !mockFound {

View file

@ -0,0 +1,505 @@
package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"go.uber.org/zap"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/config"
)
type runService interface {
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
}
type Server struct {
cfg config.EdgeOpenAIConf
service runService
logger *zap.Logger
server *http.Server
}
func NewServer(cfg config.EdgeOpenAIConf, svc runService, logger *zap.Logger) *Server {
if logger == nil {
logger = zap.NewNop()
}
return &Server{cfg: cfg, service: svc, logger: logger}
}
func (s *Server) Enabled() bool {
return s != nil && s.cfg.Enabled
}
func (s *Server) Start(ctx context.Context) error {
if !s.Enabled() {
return nil
}
if s.cfg.Listen == "" {
s.cfg.Listen = "0.0.0.0:8080"
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/v1/models", s.handleModels)
mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions)
s.server = &http.Server{
Addr: s.cfg.Listen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
ln, err := net.Listen("tcp", s.cfg.Listen)
if err != nil {
return fmt.Errorf("openai server listen %s: %w", s.cfg.Listen, err)
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.server.Shutdown(shutdownCtx)
}()
go func() {
s.logger.Info("openai-compatible server listening", zap.String("addr", s.cfg.Listen))
if err := s.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Warn("openai-compatible server exited", zap.Error(err))
}
}()
return nil
}
func (s *Server) Stop(ctx context.Context) error {
if s == nil || s.server == nil {
return nil
}
return s.server.Shutdown(ctx)
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
models := s.cfg.Models
if len(models) == 0 && s.cfg.Target != "" {
models = []string{s.cfg.Target}
}
data := make([]openAIModel, 0, len(models))
for _, model := range models {
model = strings.TrimSpace(model)
if model == "" {
continue
}
data = append(data, openAIModel{
ID: model,
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "iop",
})
}
writeJSON(w, http.StatusOK, openAIModelsResponse{Object: "list", Data: data})
}
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
defer r.Body.Close()
var req chatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", "invalid JSON request")
return
}
target := s.resolveTarget(req.Model)
if target == "" {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
prompt := promptFromMessages(req.Messages)
if strings.TrimSpace(prompt) == "" {
writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required")
return
}
handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
NodeRef: s.cfg.NodeRef,
Adapter: s.resolveAdapter(),
Target: target,
SessionID: s.resolveSessionID(),
Prompt: prompt,
TimeoutSec: s.resolveTimeoutSec(),
Metadata: map[string]string{
"source": "openai",
"openai_model": req.Model,
"openai_stream": fmt.Sprintf("%t", req.Stream),
},
})
if err != nil {
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
return
}
defer handle.Close()
if req.Stream {
s.streamChatCompletion(w, r, req, handle)
return
}
s.completeChatCompletion(w, r, req, handle)
}
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle) {
text, usage, err := collectRunText(r.Context(), handle)
if err != nil {
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
return
}
created := time.Now().Unix()
writeJSON(w, http.StatusOK, chatCompletionResponse{
ID: "chatcmpl-" + handle.RunID,
Object: "chat.completion",
Created: created,
Model: responseModel(req.Model, handle.Target),
Choices: []chatCompletionChoice{{
Index: 0,
Message: chatMessage{Role: "assistant", Content: text},
FinishReason: "stop",
}},
Usage: usage,
})
}
func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming_not_supported", "response writer does not support streaming")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
created := time.Now().Unix()
id := "chatcmpl-" + handle.RunID
model := responseModel(req.Model, handle.Target)
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Role: "assistant"},
}},
})
for {
select {
case <-r.Context().Done():
return
case nodeEvent := <-handle.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
writeSSEError(w, flusher, "node disconnected")
return
}
case event := <-handle.Events:
if event == nil {
continue
}
switch event.GetType() {
case "delta":
if event.GetDelta() == "" {
continue
}
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Content: event.GetDelta()},
}},
})
case "complete":
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{},
FinishReason: "stop",
}},
})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
return
case "error", "cancelled":
msg := event.GetError()
if msg == "" {
msg = event.GetMessage()
}
if msg == "" {
msg = "run failed"
}
writeSSEError(w, flusher, msg)
return
}
case <-time.After(handle.WaitTimeout()):
writeSSEError(w, flusher, "run timed out")
return
}
}
}
func collectRunText(ctx context.Context, handle *edgeservice.RunHandle) (string, *openAIUsage, error) {
var b strings.Builder
var usage *openAIUsage
timer := time.NewTimer(handle.WaitTimeout())
defer timer.Stop()
for {
select {
case <-ctx.Done():
return "", nil, ctx.Err()
case <-timer.C:
return "", nil, fmt.Errorf("run timed out")
case nodeEvent := <-handle.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
return "", nil, fmt.Errorf("node disconnected")
}
case event := <-handle.Events:
if event == nil {
continue
}
switch event.GetType() {
case "delta":
b.WriteString(event.GetDelta())
case "complete":
if u := event.GetUsage(); u != nil {
usage = &openAIUsage{
PromptTokens: int(u.GetInputTokens()),
CompletionTokens: int(u.GetOutputTokens()),
TotalTokens: int(u.GetInputTokens() + u.GetOutputTokens()),
}
}
return b.String(), usage, nil
case "error", "cancelled":
msg := event.GetError()
if msg == "" {
msg = event.GetMessage()
}
if msg == "" {
msg = "run failed"
}
return "", nil, fmt.Errorf("%s", msg)
}
}
}
}
func (s *Server) resolveAdapter() string {
if s.cfg.Adapter != "" {
return s.cfg.Adapter
}
return "ollama"
}
func (s *Server) resolveTarget(model string) string {
if s.cfg.Target != "" {
return s.cfg.Target
}
return strings.TrimSpace(model)
}
func (s *Server) resolveSessionID() string {
if s.cfg.SessionID != "" {
return s.cfg.SessionID
}
return edgeservice.DefaultSessionID
}
func (s *Server) resolveTimeoutSec() int {
if s.cfg.TimeoutSec > 0 {
return s.cfg.TimeoutSec
}
return edgeservice.DefaultTimeoutSec
}
func promptFromMessages(messages []chatMessage) string {
var b strings.Builder
for _, msg := range messages {
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
role := strings.TrimSpace(msg.Role)
if role == "" {
role = "user"
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(role)
b.WriteString(": ")
b.WriteString(content)
}
return b.String()
}
func responseModel(requestModel, target string) string {
if requestModel != "" {
return requestModel
}
return target
}
func httpStatusForRunError(err error) int {
if errors.Is(err, context.Canceled) {
return http.StatusRequestTimeout
}
return http.StatusBadGateway
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Error: errorBody{Type: code, Message: message}})
}
func writeSSE(w http.ResponseWriter, flusher http.Flusher, v any) {
b, err := json.Marshal(v)
if err != nil {
return
}
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
func writeSSEError(w http.ResponseWriter, flusher http.Flusher, message string) {
writeSSE(w, flusher, errorResponse{Error: errorBody{Type: "run_error", Message: message}})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
func (m *chatMessage) UnmarshalJSON(b []byte) error {
var raw struct {
Role string `json:"role"`
Content any `json:"content"`
}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
m.Role = raw.Role
m.Content = contentToString(raw.Content)
return nil
}
func contentToString(v any) string {
switch t := v.(type) {
case string:
return t
case []any:
parts := make([]string, 0, len(t))
for _, item := range t {
if m, ok := item.(map[string]any); ok {
if text, ok := m["text"].(string); ok {
parts = append(parts, text)
}
}
}
return strings.Join(parts, "\n")
default:
b, _ := json.Marshal(t)
return string(b)
}
}
type chatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChoice `json:"choices"`
Usage *openAIUsage `json:"usage,omitempty"`
}
type chatCompletionChoice struct {
Index int `json:"index"`
Message chatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type chatCompletionChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChunkChoice `json:"choices"`
}
type chatCompletionChunkChoice struct {
Index int `json:"index"`
Delta chatDelta `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
}
type chatDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
}
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type openAIModel struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
type openAIModelsResponse struct {
Object string `json:"object"`
Data []openAIModel `json:"data"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Type string `json:"type"`
Message string `json:"message"`
}

View file

@ -0,0 +1,162 @@
package openai
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
type fakeRunService struct {
req edgeservice.SubmitRunRequest
events chan *iop.RunEvent
}
func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
s.req = req
return &edgeservice.RunHandle{
RunDispatch: edgeservice.RunDispatch{
RunID: "run-test",
Target: req.Target,
TimeoutSec: 5,
},
RunStream: edgeservice.RunStream{
Events: s.events,
NodeEvents: make(chan *iop.EdgeNodeEvent),
},
}, nil
}
func TestChatCompletionsDispatchesConfiguredOllamaTarget(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
fake.events <- &iop.RunEvent{Type: "delta", Delta: " world"}
fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 2, OutputTokens: 2}}
srv := NewServer(config.EdgeOpenAIConf{
Adapter: "ollama",
Target: "llama-fixed",
SessionID: "cline",
TimeoutSec: 15,
}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"client-model",
"messages":[{"role":"system","content":"brief"},{"role":"user","content":"say hello"}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if fake.req.Adapter != "ollama" || fake.req.Target != "llama-fixed" {
t.Fatalf("dispatch target mismatch: %+v", fake.req)
}
if fake.req.SessionID != "cline" || fake.req.TimeoutSec != 15 {
t.Fatalf("execution config mismatch: %+v", fake.req)
}
if !strings.Contains(fake.req.Prompt, "system: brief") || !strings.Contains(fake.req.Prompt, "user: say hello") {
t.Fatalf("prompt did not include messages: %q", fake.req.Prompt)
}
var resp chatCompletionResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Choices[0].Message.Content != "hello world" {
t.Fatalf("content: got %q", resp.Choices[0].Message.Content)
}
if resp.Usage == nil || resp.Usage.TotalTokens != 4 {
t.Fatalf("usage: %+v", resp.Usage)
}
}
func TestChatCompletionsUsesRequestModelWhenNoConfiguredTarget(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"from-request",
"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())
}
if fake.req.Target != "from-request" {
t.Fatalf("target: got %q", fake.req.Target)
}
}
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"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"stream-model",
"stream":true,
"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())
}
body := w.Body.String()
if !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") {
t.Fatalf("unexpected SSE body:\n%s", body)
}
}
func TestModelsUsesConfiguredModelsOrTarget(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Target: "fallback-model"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
w := httptest.NewRecorder()
srv.handleModels(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "fallback-model") {
t.Fatalf("expected target model, got %s", w.Body.String())
}
srv = NewServer(config.EdgeOpenAIConf{Models: []string{"a", "b"}}, &fakeRunService{}, nil)
w = httptest.NewRecorder()
srv.handleModels(w, req)
if !strings.Contains(w.Body.String(), `"id":"a"`) || !strings.Contains(w.Body.String(), `"id":"b"`) {
t.Fatalf("expected configured models, got %s", w.Body.String())
}
}
func TestCollectRunTextTimesOut(t *testing.T) {
handle := &edgeservice.RunHandle{
RunDispatch: edgeservice.RunDispatch{TimeoutSec: 1},
RunStream: edgeservice.RunStream{
Events: make(chan *iop.RunEvent),
NodeEvents: make(chan *iop.EdgeNodeEvent),
},
}
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
_, _, err := collectRunText(ctx, handle)
if err == nil {
t.Fatal("expected timeout error")
}
}

View file

@ -357,6 +357,40 @@ func TestFormatUsageStatus_PrintsGeminiModelUsage(t *testing.T) {
}
}
func TestFormatUsageStatus_PrintsClaudeStatusMetadata(t *testing.T) {
var out bytes.Buffer
FormatUsageStatus(&out, "local-node", "claude-tui", "default", &iop.AgentUsageStatus{
Metadata: map[string]string{
"status_kind": "claude",
"claude_status_version": "2.1.144",
"claude_status_login_method": "Claude Pro account",
"claude_status_model": "opus (claude-opus-4-7)",
"claude_status_cwd": "/config/workspace/go-iop",
"claude_status_session_id": "e26e764d",
"claude_status_email": "hidden@example.com",
"claude_status_organization": "hidden org",
"claude_status_session_name": "/rename to add a name",
"claude_status_mcp_servers": "3 need auth",
},
})
got := out.String()
for _, want := range []string{
"Claude status:",
" version = 2.1.144",
" login_method = Claude Pro account",
" model = opus (claude-opus-4-7)",
" cwd = /config/workspace/go-iop",
" session_id = e26e764d",
} {
if !strings.Contains(got, want) {
t.Errorf("expected %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "hidden@example.com") || strings.Contains(got, "hidden org") {
t.Errorf("expected private Claude fields to stay hidden, got:\n%s", got)
}
}
func TestFormatUsageStatus_Error(t *testing.T) {
var out bytes.Buffer
FormatUsageStatus(&out, "local-node", "codex", "default", &iop.AgentUsageStatus{

View file

@ -130,6 +130,9 @@ func FormatUsageStatus(out io.Writer, nodeAlias, targetName, sessionID string, s
if formatGeminiModelUsage(out, metadata) {
hasParsedLimits = true
}
if formatClaudeStatus(out, metadata) {
hasParsedLimits = true
}
if !hasParsedLimits {
if status.GetRawOutput() == "" {
@ -148,6 +151,31 @@ func FormatUsageStatus(out io.Writer, nodeAlias, targetName, sessionID string, s
}
}
func formatClaudeStatus(out io.Writer, metadata map[string]string) bool {
if usageStatusMetadata(metadata, "status_kind") != "claude" {
return false
}
rows := []struct {
label string
key string
}{
{"version", "claude_status_version"},
{"login_method", "claude_status_login_method"},
{"model", "claude_status_model"},
{"cwd", "claude_status_cwd"},
{"session_id", "claude_status_session_id"},
}
printed := false
fmt.Fprintln(out, "Claude status:")
for _, row := range rows {
if value := usageStatusMetadata(metadata, row.key); value != "" {
fmt.Fprintf(out, " %s = %s\n", row.label, value)
printed = true
}
}
return printed
}
func usageStatusLabel(metadata map[string]string, key, fallback string) string {
if metadata != nil {
if label, ok := metadata[key]; ok {

View file

@ -15,8 +15,8 @@ internal/
transport/ — Edge로 연결하는 TCP/protobuf client와 session 처리
adapters/
mock/ — 에코 테스트 어댑터
ollama/ — Ollama API 어댑터 (TODO)
vllm/ — vLLM OpenAI-compatible 어댑터 (TODO)
ollama/ — Ollama /api/chat 스트리밍 어댑터
vllm/ — vLLM OpenAI-compatible 어댑터 skeleton
cli/ — CLI 프로세스 어댑터 (claude/gemini/codex/opencode/cline)
store/ — SQLite 실행 이력
```
@ -104,10 +104,12 @@ node는 adapter execution(`RunRequest`) 외에도 edge가 보내는 `NodeCommand
| 어댑터 | 설명 | 상태 |
|---|---|---|
| `mock` | 입력 에코, 스트리밍 테스트용 | 구현 완료 |
| `ollama` | 로컬 Ollama 서버 연동 | TODO |
| `vllm` | vLLM OpenAI-compatible API | TODO |
| `ollama` | 로컬 Ollama `/api/chat` 스트리밍 연동 | 기본 구현 완료 |
| `vllm` | vLLM OpenAI-compatible API | `/v1/models` 조회 skeleton |
| `cli` | claude/gemini/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`로 전달한다.
`claude`, `gemini`, `codex`, `opencode`, `cline`처럼 기본이 interactive TUI인 CLI는 기본적으로 non-interactive 모드로 설정한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`, `cline -y --json --config /config/.cline/profiles/ollama-dgx <prompt>`.
Claude는 용도별로 두 profile을 둔다. `claude``claude -p` 기반 one-shot profile이며 Agent SDK/credit 경로 검증용으로 유지한다. `claude-tui``persistent: true`, `terminal: true`, `mode: "persistent-lazy"`로 일반 Claude Code TUI를 첫 요청 시 lazy start한다. TUI 출력은 structured JSON이 아니므로 idle timeout 기반으로 completion을 판단한다.

View file

@ -110,27 +110,25 @@ func (c *ClaudeChecker) Check(ctx context.Context) (*UsageStatus, error) {
}
time.Sleep(500 * time.Millisecond)
// 2. Request /usage
sendText("\x15/usage\r")
// 2. Request Claude's status panel. This is intentionally the same command
// path for both the headless "claude" profile and the persistent
// "claude-tui" profile; the checker starts a short-lived TUI solely to read
// status output.
sendText("\x15/status\r")
// 3a. Wait for the /usage panel itself to open (the panel tab strip contains
// "Usage" highlighted between "Status/Config/.../Stats"). This separates
// "command palette didn't accept /usage" from "panel opened but data hasn't
// rendered" — they have different error tails.
usagePanelRegex := regexp.MustCompile(`(?i)Current\s+session|Current\s+week|Esc\s+to\s+cancel|d\s+to\s+day`)
if _, err := waitForAny([]waitPattern{{name: "usage-panel", pattern: usagePanelRegex}}, 15*time.Second); err != nil {
return nil, fmt.Errorf("claude /usage panel did not open: %w", err)
// 3. Wait until the status screen is visible, then wait for the terminal
// output to settle. Older Claude builds/tests may still render usage-style
// "Current session / Current week" blocks, so those markers remain accepted.
statusPanelRegex := regexp.MustCompile(`(?i)Version\s*:|Session\s*ID\s*:|Login\s+method\s*:|Current\s+session|Current\s+week|Esc\s+to\s+cancel|d\s+to\s+day`)
if _, err := waitForAny([]waitPattern{{name: "status-panel", pattern: statusPanelRegex}}, 20*time.Second); err != nil {
return nil, fmt.Errorf("claude /status output did not open: %w", err)
}
// 3b. Wait for both session and week data to parse from the terminal
// visible screen. We do not send `d`/`w` nudges: in real Claude they
// transition the lower panel between Last-24h and Last-7d views and can
// shift the Current session / Current week block off the visible region
// mid-render, which has been observed to corrupt the screen state right
// when the parser would have otherwise succeeded. The screen-aware
// parser handles cursor repaint without any input from us.
fullUsageWait := func(timeout time.Duration) error {
timeoutCh := time.After(timeout)
waitForQuietOutput := func(idleTimeout, maxWait time.Duration) error {
idleCh := time.NewTimer(idleTimeout)
defer idleCh.Stop()
maxCh := time.NewTimer(maxWait)
defer maxCh.Stop()
for {
if st, _ := ParseStatusOutput(fullOutput); st != nil && st.DailyLimit != "" && st.WeeklyLimit != "" {
return nil
@ -138,23 +136,30 @@ func (c *ClaudeChecker) Check(ctx context.Context) (*UsageStatus, error) {
select {
case <-ctx.Done():
return ctx.Err()
case <-timeoutCh:
visTail := tailLines(renderVisibleScreen(fullOutput, 60, 200), 40)
rawTail := tailLines(cleanANSI(fullOutput), 40)
return fmt.Errorf("timeout waiting for complete Claude usage block\nvisible-screen tail:\n%s\nraw-clean tail:\n%s", visTail, rawTail)
case <-maxCh.C:
return nil
case <-idleCh.C:
return nil
case chunk, ok := <-chunks:
if !ok {
return io.EOF
}
fullOutput += chunk
if !idleCh.Stop() {
select {
case <-idleCh.C:
default:
}
}
idleCh.Reset(idleTimeout)
}
}
}
if err := fullUsageWait(60 * time.Second); err != nil {
if err := waitForQuietOutput(1500*time.Millisecond, 10*time.Second); err != nil {
if st, parseErr := ParseStatusOutput(fullOutput); parseErr == nil && st != nil && strings.TrimSpace(st.RawOutput) != "" {
return st, nil
}
return nil, fmt.Errorf("failed waiting for usage output: %w", err)
return nil, fmt.Errorf("failed waiting for status output: %w", err)
}
// Wait a bit more for full output

View file

@ -9,7 +9,7 @@ import (
"time"
)
func TestClaudeCheckerRequestsUsage(t *testing.T) {
func TestClaudeCheckerRequestsStatus(t *testing.T) {
dir := t.TempDir()
fakeClaude := filepath.Join(dir, "claude")
@ -22,14 +22,14 @@ sleep 0.1
# Read command from stdin
IFS= read -r command
# If not /usage (strip Ctrl+S prefix \x15 if present)
# If not /status (strip Ctrl+S prefix \x15 if present)
cmd="${command#?}"
if [ "$cmd" != "/usage" ] && [ "$command" != "/usage" ]; then
if [ "$cmd" != "/status" ] && [ "$command" != "/status" ]; then
printf 'unexpected command: %%s\n' "$command"
exit 3
fi
# Print Claude /usage output
# Print Claude /status output
printf 'Current session: [] 2%% used\n'
printf 'Resets 10am (Asia/Seoul)\n'
printf '\n'
@ -81,7 +81,7 @@ sleep 0.1
IFS= read -r command
cmd="${command#?}"
if [ "$cmd" != "/usage" ] && [ "$command" != "/usage" ]; then
if [ "$cmd" != "/status" ] && [ "$command" != "/status" ]; then
printf 'unexpected command: %%s\n' "$command"
exit 3
fi
@ -137,7 +137,7 @@ sleep 0.1
IFS= read -r command
cmd="${command#?}"
if [ "$cmd" != "/usage" ] && [ "$command" != "/usage" ]; then
if [ "$cmd" != "/status" ] && [ "$command" != "/status" ]; then
printf 'unexpected command: %%s\n' "$command"
exit 3
fi
@ -172,7 +172,7 @@ func TestClaudeCheckerSelectsUsageAndParsesRepaintedScreen(t *testing.T) {
dir := t.TempDir()
fakeClaude := filepath.Join(dir, "claude")
// Mimics the real TUI: startup banner, then /usage panel that first paints
// Mimics the real TUI: startup banner, then /status panel that first paints
// a loading state ("Scanning local sessions...", "Esc to cancel") via cursor
// repaints, then later renders the actual Current session / Current week
// content. The parser must wait through the loading state rather than
@ -184,7 +184,7 @@ sleep 0.1
IFS= read -r command
cmd="${command#?}"
if [ "$cmd" != "/usage" ] && [ "$command" != "/usage" ]; then
if [ "$cmd" != "/status" ] && [ "$command" != "/status" ]; then
printf 'unexpected command: %%s\n' "$command"
exit 3
fi
@ -253,7 +253,7 @@ func TestClaudeCheckerParsesCursorRepaintedUsageScreen(t *testing.T) {
dir := t.TempDir()
fakeClaude := filepath.Join(dir, "claude")
// Fake TUI that paints the /usage panel via cursor-addressed writes,
// Fake TUI that paints the /status panel via cursor-addressed writes,
// matching how real Claude renders. We first write misleading payload
// text ("21% of your usage" / "Resets garbage time") at rows 6/7, then
// reposition the cursor and use EL (erase line) to overwrite those rows
@ -272,7 +272,7 @@ sleep 0.1
IFS= read -r command
cmd="${command#?}"
if [ "$cmd" != "/usage" ] && [ "$command" != "/usage" ]; then
if [ "$cmd" != "/status" ] && [ "$command" != "/status" ]; then
printf 'unexpected command: %s\n' "$command"
exit 3
fi
@ -336,7 +336,7 @@ exit 0
}
}
func TestClaudeCheckerNotUsageExits(t *testing.T) {
func TestClaudeCheckerNotStatusExits(t *testing.T) {
dir := t.TempDir()
fakeClaude := filepath.Join(dir, "claude")
@ -344,7 +344,7 @@ func TestClaudeCheckerNotUsageExits(t *testing.T) {
printf 'Claude Code v2.1.142\n'
printf 'Welcome back\n'
IFS= read -r command
# This is not /usage, exit with failure
# This is not /status, exit with failure
exit 3
`
if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
@ -360,9 +360,9 @@ exit 3
// so it should fail or timeout
if err == nil {
// The faker exited early so pty Read will return EOF
// This is acceptable - we just want to verify non-/usage input
// This is acceptable - we just want to verify non-/status input
// causes the exit code 3 behavior
t.Log("fake claude exited before /usage as expected")
t.Log("fake claude exited before /status as expected")
}
}
@ -391,7 +391,7 @@ sleep 0.1
IFS= read -r command
cmd="${command#?}"
if [ "$cmd" != "/usage" ] && [ "$command" != "/usage" ]; then
if [ "$cmd" != "/status" ] && [ "$command" != "/status" ]; then
printf 'unexpected command: %%s\n' "$command"
exit 3
fi

View file

@ -52,6 +52,9 @@ func ParseStatusOutput(raw string) (*UsageStatus, error) {
if pick := pickMoreComplete(visStatus, rawStatus); pick != nil {
return pick, nil
}
if rawStatus.parseClaudeStatus(cleanRaw) || rawStatus.parseClaudeStatus(visible) {
return rawStatus, nil
}
// Neither input produced Claude data — try Gemini, then Codex.
parsedGemini := rawStatus.parseGeminiQuota(cleanRaw)
@ -275,6 +278,62 @@ func (s *UsageStatus) parseClaudeUsage(cleanRaw string) bool {
return s.DailyLimit != "" || s.WeeklyLimit != ""
}
func (s *UsageStatus) parseClaudeStatus(cleanRaw string) bool {
lower := strings.ToLower(cleanRaw)
if !strings.Contains(lower, "claudecode") && !strings.Contains(lower, "claude code") {
return false
}
fields := map[string]string{
"version": "claude_status_version",
"sessionname": "claude_status_session_name",
"sessionid": "claude_status_session_id",
"cwd": "claude_status_cwd",
"loginmethod": "claude_status_login_method",
"organization": "claude_status_organization",
"email": "claude_status_email",
"model": "claude_status_model",
"mcpservers": "claude_status_mcp_servers",
}
found := 0
for _, line := range strings.Split(cleanRaw, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
normalized := normalizeClaudeStatusKey(key)
metadataKey, ok := fields[normalized]
if !ok {
continue
}
value = strings.TrimSpace(value)
if value == "" {
continue
}
if s.Metadata == nil {
s.Metadata = make(map[string]string)
}
s.Metadata[metadataKey] = value
found++
}
if found == 0 {
return false
}
s.Metadata["status_kind"] = "claude"
return true
}
func normalizeClaudeStatusKey(key string) string {
var b strings.Builder
for _, r := range strings.ToLower(key) {
if unicode.IsLetter(r) {
b.WriteRune(r)
}
}
return b.String()
}
// parseCodexUsage attempts to parse Codex output format and sets metadata labels.
func (s *UsageStatus) parseCodexUsage(cleanRaw string) {
if match := dailyLimitRegex.FindStringSubmatch(cleanRaw); len(match) > 2 {

View file

@ -56,6 +56,32 @@ func TestParseStatusOutput_ClaudeUsage(t *testing.T) {
}
}
func TestParseStatusOutput_ClaudeStatusPanel(t *testing.T) {
rawOutput := "ClaudeCode v2.1.144\n\n" +
"Version: 2.1.144\n" +
"SessionID: e26e764d-dd46-4c7e-9509-5a9b964ce993\n" +
"cwd: /config/workspace/go-iop\n" +
"Loginmethod: Claude Pro account\n" +
"Model: opus (claude-opus-4-7)\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.Metadata["status_kind"] != "claude" {
t.Fatalf("status_kind: got %q", status.Metadata["status_kind"])
}
if status.Metadata["claude_status_version"] != "2.1.144" {
t.Errorf("version: got %q", status.Metadata["claude_status_version"])
}
if status.Metadata["claude_status_login_method"] != "Claude Pro account" {
t.Errorf("login_method: got %q", status.Metadata["claude_status_login_method"])
}
if status.Metadata["claude_status_model"] != "opus (claude-opus-4-7)" {
t.Errorf("model: got %q", status.Metadata["claude_status_model"])
}
}
func TestParseStatusOutput_ClaudeUsageWithANSICursorSpacing(t *testing.T) {
rawOutput := "Current\x1b[1C session: [███░░░░░░░░░░░░░░░░░] 2%\x1b[1C used\nResets 10am (Asia/Seoul)\n\n" +
"Current week (all models): [#░░░░░░░░░░░░░░░░░░░░] 0% used\nResets May 16, 6pm (Asia/Seoul)"

View file

@ -55,7 +55,7 @@ func NewChecker(target string, profile config.CLIProfileConf) (Checker, error) {
switch {
case target == "codex" || cmdBase == "codex":
return NewCodexChecker(profile.Command), nil
case target == "claude" || cmdBase == "claude":
case target == "claude" || target == "claude-tui" || cmdBase == "claude":
return NewClaudeChecker(profile.Command), nil
case target == "gemini" || cmdBase == "gemini":
return NewGeminiChecker(profile.Command), nil

View file

@ -64,6 +64,24 @@ func TestNewChecker_ClaudeUsesProfileCommand(t *testing.T) {
}
}
func TestNewChecker_ClaudeTUIUsesClaudeChecker(t *testing.T) {
checker, err := NewChecker("claude-tui", config.CLIProfileConf{
Command: "/tmp/claude-wrapper",
})
if err != nil {
t.Fatalf("NewChecker failed: %v", err)
}
claude, ok := checker.(*ClaudeChecker)
if !ok {
t.Fatalf("expected *ClaudeChecker, got %T", checker)
}
if claude.command != "/tmp/claude-wrapper" {
t.Errorf("expected command /tmp/claude-wrapper, got %q", claude.command)
}
}
func TestNewChecker_GeminiUsesProfileCommand(t *testing.T) {
checker, err := NewChecker("gemini", config.CLIProfileConf{
Command: "/tmp/mygemini",

View file

@ -58,7 +58,11 @@ func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Regi
}
func ollamaConfFromProto(m *iop.OllamaAdapterConfig) config.OllamaConf {
return config.OllamaConf{Enabled: true, BaseURL: m.GetBaseUrl()}
return config.OllamaConf{
Enabled: true,
BaseURL: m.GetBaseUrl(),
ContextSize: int(m.GetContextSize()),
}
}
func vllmConfFromProto(m *iop.VllmAdapterConfig) config.VllmConf {
@ -110,9 +114,33 @@ func ollamaConfFromStruct(s *structpb.Struct) config.OllamaConf {
if v, ok := m["base_url"].(string); ok {
cfg.BaseURL = v
}
if v, ok := intSetting(m, "context_size"); ok {
cfg.ContextSize = v
} else if v, ok := intSetting(m, "num_ctx"); ok {
cfg.ContextSize = v
}
return cfg
}
func intSetting(m map[string]any, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case int:
return n, true
case int32:
return int(n), true
case int64:
return int(n), true
case float64:
return int(n), true
default:
return 0, false
}
}
func vllmConfFromStruct(s *structpb.Struct) config.VllmConf {
cfg := config.VllmConf{Enabled: true}
if s == nil {

View file

@ -210,7 +210,10 @@ func TestCLIConfFromStruct_OpencodeSSEModeLegacy(t *testing.T) {
// when an older edge sends AdapterConfig.settings instead of the typed oneof.
func TestOllamaConfFromStruct_LegacyPayload(t *testing.T) {
st, err := structpb.NewStruct(map[string]any{"base_url": "http://legacy:11434"})
st, err := structpb.NewStruct(map[string]any{
"base_url": "http://legacy:11434",
"context_size": float64(262144),
})
if err != nil {
t.Fatalf("structpb: %v", err)
}
@ -221,6 +224,9 @@ func TestOllamaConfFromStruct_LegacyPayload(t *testing.T) {
if cfg.BaseURL != "http://legacy:11434" {
t.Fatalf("base_url: got %q", cfg.BaseURL)
}
if cfg.ContextSize != 262144 {
t.Fatalf("context_size: got %d", cfg.ContextSize)
}
}
func TestVllmConfFromStruct_LegacyPayload(t *testing.T) {

View file

@ -2,8 +2,15 @@
package ollama
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
@ -14,35 +21,269 @@ import (
const Name = "ollama"
type Ollama struct {
baseURL string
logger *zap.Logger
baseURL string
contextSize int
client *http.Client
logger *zap.Logger
}
func New(cfg config.OllamaConf, logger *zap.Logger) *Ollama {
baseURL := strings.TrimRight(cfg.BaseURL, "/")
if baseURL == "" {
baseURL = "http://localhost:11434"
}
return &Ollama{
baseURL: cfg.BaseURL,
logger: logger,
baseURL: baseURL,
contextSize: cfg.ContextSize,
client: &http.Client{},
logger: logger,
}
}
func (o *Ollama) Name() string { return Name }
func (o *Ollama) Capabilities(_ context.Context) (runtime.Capabilities, error) {
// TODO: query /api/tags from Ollama to enumerate available models.
targets := o.fetchTargets()
return runtime.Capabilities{
AdapterName: Name,
Targets: []string{},
Targets: targets,
MaxConcurrency: 4,
}, nil
}
func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
// TODO: implement streaming call to Ollama /api/generate or /api/chat.
// Use net/http with chunked JSON response (ndjson).
o.logger.Info("ollama adapter called",
model := strings.TrimSpace(spec.Target)
if model == "" {
model = stringInput(spec.Input, "model")
}
if model == "" {
return fmt.Errorf("ollama adapter: target/model is required")
}
messages := ollamaMessagesFromInput(spec.Input)
if len(messages) == 0 {
return fmt.Errorf("ollama adapter: prompt/messages are required")
}
if err := sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
}); err != nil {
return err
}
chatReq := ollamaChatRequest{
Model: model,
Messages: messages,
Stream: true,
}
if o.contextSize > 0 {
chatReq.Options = &ollamaOptions{NumCtx: o.contextSize}
}
body, err := json.Marshal(chatReq)
if err != nil {
return fmt.Errorf("ollama adapter: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(o.baseURL, "/api/chat"), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("ollama adapter: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
o.logger.Info("ollama adapter executing",
zap.String("run_id", spec.RunID),
zap.String("target", spec.Target),
zap.String("target", model),
zap.String("base_url", o.baseURL),
zap.Int("context_size", o.contextSize),
)
return fmt.Errorf("ollama adapter: not yet implemented")
resp, err := o.client.Do(req)
if err != nil {
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama request failed: %v", err))
return fmt.Errorf("ollama adapter: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := readLimited(resp.Body, 4096)
if msg == "" {
msg = resp.Status
}
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama returned %s: %s", resp.Status, msg))
return fmt.Errorf("ollama adapter: non-2xx response: %s", resp.Status)
}
decoder := json.NewDecoder(resp.Body)
outputTokens := 0
for {
var chunk ollamaChatChunk
if err := decoder.Decode(&chunk); err != nil {
if err == io.EOF {
break
}
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama stream decode failed: %v", err))
return fmt.Errorf("ollama adapter: decode stream: %w", err)
}
if chunk.Error != "" {
_ = emitError(ctx, sink, spec.RunID, chunk.Error)
return fmt.Errorf("ollama adapter: %s", chunk.Error)
}
if chunk.Message.Content != "" {
outputTokens += len(strings.Fields(chunk.Message.Content))
if err := sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: chunk.Message.Content,
Timestamp: time.Now(),
}); err != nil {
return err
}
}
if chunk.Done {
inputTokens := chunk.PromptEvalCount
if inputTokens == 0 {
inputTokens = countMessageWords(messages)
}
if chunk.EvalCount > 0 {
outputTokens = chunk.EvalCount
}
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
Message: "ollama chat complete",
Usage: &runtime.UsageStats{
InputTokens: inputTokens,
OutputTokens: outputTokens,
},
Timestamp: time.Now(),
})
}
}
_ = emitError(ctx, sink, spec.RunID, "ollama stream ended without done=true")
return fmt.Errorf("ollama adapter: stream ended without done=true")
}
func (o *Ollama) fetchTargets() []string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(o.baseURL, "/api/tags"), nil)
if err != nil {
return nil
}
resp, err := o.client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil
}
var tags ollamaTagsResponse
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return nil
}
out := make([]string, 0, len(tags.Models))
for _, model := range tags.Models {
if model.Name != "" {
out = append(out, model.Name)
}
}
return out
}
func ollamaMessagesFromInput(input map[string]any) []ollamaMessage {
if raw, ok := input["messages"].([]any); ok {
out := make([]ollamaMessage, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
role, _ := m["role"].(string)
content, _ := m["content"].(string)
role = strings.TrimSpace(role)
content = strings.TrimSpace(content)
if role == "" || content == "" {
continue
}
out = append(out, ollamaMessage{Role: role, Content: content})
}
if len(out) > 0 {
return out
}
}
prompt := strings.TrimSpace(stringInput(input, "prompt"))
if prompt == "" {
return nil
}
return []ollamaMessage{{Role: "user", Content: prompt}}
}
func stringInput(input map[string]any, key string) string {
if input == nil {
return ""
}
if v, ok := input[key].(string); ok {
return v
}
return ""
}
func countMessageWords(messages []ollamaMessage) int {
n := 0
for _, msg := range messages {
n += len(strings.Fields(msg.Content))
}
return n
}
func emitError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
}
func joinURL(baseURL, path string) string {
u, err := url.Parse(baseURL)
if err != nil {
return strings.TrimRight(baseURL, "/") + path
}
u.Path = strings.TrimRight(u.Path, "/") + path
return u.String()
}
func readLimited(r io.Reader, limit int64) string {
b, _ := io.ReadAll(io.LimitReader(r, limit))
return strings.TrimSpace(string(b))
}
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []ollamaMessage `json:"messages"`
Stream bool `json:"stream"`
Options *ollamaOptions `json:"options,omitempty"`
}
type ollamaOptions struct {
NumCtx int `json:"num_ctx,omitempty"`
}
type ollamaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ollamaChatChunk struct {
Message ollamaMessage `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
Error string `json:"error"`
}
type ollamaTagsResponse struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}

View file

@ -0,0 +1,144 @@
package ollama
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"go.uber.org/zap"
noderuntime "iop/apps/node/internal/runtime"
"iop/packages/config"
)
type fakeSink struct {
mu sync.Mutex
events []noderuntime.RuntimeEvent
}
func (s *fakeSink) Emit(_ context.Context, event noderuntime.RuntimeEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
s.events = append(s.events, event)
return nil
}
func (s *fakeSink) all() []noderuntime.RuntimeEvent {
s.mu.Lock()
defer s.mu.Unlock()
return append([]noderuntime.RuntimeEvent(nil), s.events...)
}
func TestOllamaExecuteStreamsChatDeltas(t *testing.T) {
var gotModel string
var gotPrompt string
var gotNumCtx int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
var req ollamaChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
gotModel = req.Model
if req.Options != nil {
gotNumCtx = req.Options.NumCtx
}
if len(req.Messages) != 1 {
t.Fatalf("expected one message, got %+v", req.Messages)
}
gotPrompt = req.Messages[0].Content
w.Header().Set("Content-Type", "application/x-ndjson")
_, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"hello "},"done":false}` + "\n"))
_, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"world"},"done":false}` + "\n"))
_, _ = w.Write([]byte(`{"done":true,"prompt_eval_count":3,"eval_count":2}` + "\n"))
}))
defer server.Close()
adapter := New(config.OllamaConf{BaseURL: server.URL, ContextSize: 262144}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "llama-test",
Input: map[string]any{"prompt": "say hello"},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
if gotModel != "llama-test" {
t.Fatalf("model: got %q", gotModel)
}
if gotPrompt != "say hello" {
t.Fatalf("prompt: got %q", gotPrompt)
}
if gotNumCtx != 262144 {
t.Fatalf("num_ctx: got %d", gotNumCtx)
}
events := sink.all()
if len(events) != 4 {
t.Fatalf("expected 4 events, got %+v", events)
}
if events[0].Type != noderuntime.EventTypeStart {
t.Fatalf("expected start event, got %+v", events[0])
}
if events[1].Delta+events[2].Delta != "hello world" {
t.Fatalf("unexpected delta text: %+v", events)
}
if events[3].Type != noderuntime.EventTypeComplete {
t.Fatalf("expected complete event, got %+v", events[3])
}
if events[3].Usage == nil || events[3].Usage.InputTokens != 3 || events[3].Usage.OutputTokens != 2 {
t.Fatalf("unexpected usage: %+v", events[3].Usage)
}
}
func TestOllamaExecuteEmitsErrorForHTTPFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "boom", http.StatusBadGateway)
}))
defer server.Close()
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), noderuntime.ExecutionSpec{
RunID: "run-err",
Target: "llama-test",
Input: map[string]any{"prompt": "hi"},
}, sink)
if err == nil {
t.Fatal("expected error")
}
events := sink.all()
if len(events) < 2 || events[1].Type != noderuntime.EventTypeError {
t.Fatalf("expected error event, got %+v", events)
}
if !strings.Contains(events[1].Error, "boom") {
t.Fatalf("expected HTTP body in error, got %q", events[1].Error)
}
}
func TestOllamaCapabilitiesQueryTags(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/tags" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"models":[{"name":"llama-a"},{"name":"llama-b"}]}`))
}))
defer server.Close()
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
caps, err := adapter.Capabilities(context.Background())
if err != nil {
t.Fatalf("Capabilities failed: %v", err)
}
got := strings.Join(caps.Targets, ",")
if got != "llama-a,llama-b" {
t.Fatalf("targets: got %q", got)
}
}

View file

@ -4,7 +4,12 @@ package vllm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
@ -16,12 +21,15 @@ const Name = "vllm"
type Vllm struct {
endpoint string
client *http.Client
logger *zap.Logger
}
func New(cfg config.VllmConf, logger *zap.Logger) *Vllm {
endpoint := strings.TrimRight(cfg.Endpoint, "/")
return &Vllm{
endpoint: cfg.Endpoint,
endpoint: endpoint,
client: &http.Client{},
logger: logger,
}
}
@ -29,10 +37,9 @@ func New(cfg config.VllmConf, logger *zap.Logger) *Vllm {
func (v *Vllm) Name() string { return Name }
func (v *Vllm) Capabilities(_ context.Context) (runtime.Capabilities, error) {
// TODO: query /v1/models from vLLM endpoint.
return runtime.Capabilities{
AdapterName: Name,
Targets: []string{},
Targets: v.fetchTargets(),
MaxConcurrency: 8,
}, nil
}
@ -47,3 +54,49 @@ func (v *Vllm) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink run
)
return fmt.Errorf("vllm adapter: not yet implemented")
}
func (v *Vllm) fetchTargets() []string {
if v.endpoint == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(v.endpoint, "/v1/models"), nil)
if err != nil {
return nil
}
resp, err := v.client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil
}
var models vllmModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&models); err != nil {
return nil
}
out := make([]string, 0, len(models.Data))
for _, model := range models.Data {
if model.ID != "" {
out = append(out, model.ID)
}
}
return out
}
func joinURL(baseURL, path string) string {
u, err := url.Parse(baseURL)
if err != nil {
return strings.TrimRight(baseURL, "/") + path
}
u.Path = strings.TrimRight(u.Path, "/") + path
return u.String()
}
type vllmModelsResponse struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}

View file

@ -0,0 +1,32 @@
package vllm
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
"iop/packages/config"
)
func TestVllmCapabilitiesQueryModels(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`))
}))
defer server.Close()
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
caps, err := adapter.Capabilities(context.Background())
if err != nil {
t.Fatalf("Capabilities failed: %v", err)
}
if got := strings.Join(caps.Targets, ","); got != "model-a,model-b" {
t.Fatalf("targets: got %q", got)
}
}

97
bin/build/field-binaries.sh Executable file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DIST_DIR="${DIST_DIR:-"$ROOT_DIR/dist/field"}"
TARGETS="${TARGETS:-}"
APPS="${APPS:-"edge node"}"
CGO_ENABLED="${CGO_ENABLED:-0}"
GOFLAGS="${GOFLAGS:-"-trimpath"}"
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'USAGE'
Build field-deployable IOP edge/node binaries.
Usage:
bin/build/field-binaries.sh
Environment:
APPS="edge node" apps to build
TARGETS="linux/amd64 linux/arm64" GOOS/GOARCH target list
DIST_DIR=dist/field output directory
VERSION=<value> release version label
COMMIT=<value> commit label
BUILD_DATE=<value> build date label
CGO_ENABLED=0 Go CGO setting
GOFLAGS="-trimpath" extra go build flags
LDFLAGS="-s -w" linker flags
USAGE
exit 0
fi
VERSION="${VERSION:-"$(git -C "$ROOT_DIR" describe --tags --always --dirty 2>/dev/null || echo dev)"}"
COMMIT="${COMMIT:-"$(git -C "$ROOT_DIR" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"}"
BUILD_DATE="${BUILD_DATE:-"$(date -u +%Y%m%dT%H%M%SZ)"}"
LDFLAGS="${LDFLAGS:-"-s -w"}"
if [[ -z "$TARGETS" ]]; then
TARGETS="$(go env GOOS)/$(go env GOARCH)"
fi
checksum() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
return
fi
shasum -a 256 "$@"
}
build_one() {
local app="$1"
local goos="$2"
local goarch="$3"
local output_dir="$DIST_DIR/$VERSION/$goos-$goarch"
local output="$output_dir/iop-$app"
mkdir -p "$output_dir"
echo "building $app for $goos/$goarch -> $output"
(
cd "$ROOT_DIR"
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED="$CGO_ENABLED" \
go build $GOFLAGS -ldflags "$LDFLAGS" -o "$output" "./apps/$app/cmd/$app"
)
checksum "$output" > "$output.sha256"
}
for target in $TARGETS; do
IFS=/ read -r goos goarch extra <<< "$target"
if [[ -z "${goos:-}" || -z "${goarch:-}" || -n "${extra:-}" ]]; then
echo "invalid target '$target'; expected GOOS/GOARCH" >&2
exit 1
fi
for app in $APPS; do
case "$app" in
edge|node) build_one "$app" "$goos" "$goarch" ;;
*)
echo "unsupported app '$app'; expected edge or node" >&2
exit 1
;;
esac
done
output_dir="$DIST_DIR/$VERSION/$goos-$goarch"
{
echo "version=$VERSION"
echo "commit=$COMMIT"
echo "build_date=$BUILD_DATE"
echo "target=$goos/$goarch"
echo "apps=$APPS"
} > "$output_dir/manifest.env"
(
cd "$output_dir"
checksum iop-* | grep -v '\.sha256$' > SHA256SUMS
)
done
echo "field binaries written to $DIST_DIR/$VERSION"

View file

@ -15,6 +15,17 @@ logging:
metrics:
port: 9092
openai:
enabled: true
listen: "0.0.0.0:8080"
node: ""
adapter: "ollama"
target: "qwen3.6:35b-a3b-bf16"
models:
- "qwen3.6:35b-a3b-bf16"
session_id: "cline"
timeout_sec: 300
console:
adapter: "cli"
target: "claude-tui"
@ -29,8 +40,9 @@ nodes:
token: "changeme"
adapters:
ollama:
enabled: false
base_url: "http://localhost:11434"
enabled: true
base_url: "http://192.168.0.91:11434"
context_size: 262144
vllm:
enabled: false
endpoint: "http://localhost:8000"
@ -106,28 +118,6 @@ nodes:
persistent: false
terminal: false
mode: "opencode-sse"
cline-dgx:
command: "/config/.npm-global/bin/cline"
args:
- "-y"
- "--json"
- "--config"
- "/config/.cline/profiles/ollama-dgx"
env: []
persistent: false
terminal: false
output_format: "cline-json"
cline-m1:
command: "/config/.npm-global/bin/cline"
args:
- "-y"
- "--json"
- "--config"
- "/config/.cline/profiles/ollama-m1"
env: []
persistent: false
terminal: false
output_format: "cline-json"
runtime:
concurrency: 4
workspace_root: "/tmp/iop/workspace"

View file

@ -1,11 +1,53 @@
name: ${COMPOSE_PROJECT_NAME:-iop-dev}
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${IOP_POSTGRES_USER:-iop}
POSTGRES_PASSWORD: ${IOP_POSTGRES_PASSWORD:-iop_dev_password}
POSTGRES_DB: ${IOP_POSTGRES_DB:-iop}
ports:
- "${IOP_POSTGRES_PORT:-5432}:5432"
volumes:
- control-plane-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
- "${IOP_REDIS_PORT:-6379}:6379"
volumes:
- control-plane-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
control-plane:
build:
context: ..
dockerfile: go-iop/apps/control-plane/Dockerfile
restart: unless-stopped
environment:
IOP_DATABASE_URL: "postgres://${IOP_POSTGRES_USER:-iop}:${IOP_POSTGRES_PASSWORD:-iop_dev_password}@postgres:5432/${IOP_POSTGRES_DB:-iop}?sslmode=disable"
IOP_REDIS_URL: "redis://redis:6379/0"
ports:
- "9080:9080"
- "19080:19080"
- "${IOP_CONTROL_PLANE_HTTP_PORT:-9080}:9080"
- "${IOP_CONTROL_PLANE_WIRE_PORT:-19080}:19080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9080/healthz"]
interval: 10s
@ -18,10 +60,15 @@ services:
dockerfile: Dockerfile
environment:
CONTROL_PLANE_INTERNAL_HTTP_URL: "http://control-plane:9080"
NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL: "http://localhost:9080"
NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL: "tcp://localhost:19080"
NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL: "http://localhost:${IOP_CONTROL_PLANE_HTTP_PORT:-9080}"
NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL: "tcp://localhost:${IOP_CONTROL_PLANE_WIRE_PORT:-19080}"
restart: unless-stopped
ports:
- "3000:3000"
- "${IOP_WEB_PORT:-3000}:3000"
depends_on:
control-plane:
condition: service_healthy
volumes:
control-plane-postgres:
control-plane-redis:

View file

@ -102,6 +102,32 @@ type Adapter interface {
- Control Plane은 향후 Edge와 소켓 기반 연결을 맺는다.
- Control Plane이 Node에 직접 연결하거나 매 요청마다 Node를 직접 스케줄링하는 구조는 목표가 아니다.
## 배포 철학
Control Plane과 Web은 중앙 운영면으로 묶어 compose 기반 서비스로 배포한다. dev 필드 테스트 compose에는 Control Plane DB와 Redis 후보를 함께 포함해 이후 schema, audit, event, queue 작업을 같은 배포 단위 안에서 진행한다.
Edge와 Node는 Docker 이미지로 만들지 않고 호스트 단일 바이너리로 배포한다. Jenkins는 Edge/Node 바이너리만 빌드하고, 각 호스트는 배포된 바이너리의 `setup` 명령을 통해 systemd 실행 환경을 준비한다.
운영 CLI는 분기된 설치 방식을 만들지 않는다. 초기 공식 경로는 `iop-edge setup``iop-node setup` 하나로 고정하고, 검토나 CI 확인은 별도 `render` 명령이 아니라 `--dry-run` 옵션으로 흡수한다.
```text
build:
Jenkins -> bin/build/field-binaries.sh -> iop-edge, iop-node
control host:
docker compose -> postgres, redis, control-plane, web
edge host:
iop-edge setup -> systemd unit + config/data directories
systemd -> iop-edge serve --config /etc/iop/edge.yaml
node host:
iop-node setup -> systemd unit + config/data directories
systemd -> iop-node serve --config /etc/iop/node.yaml
```
`setup`은 idempotent해야 한다. 기존 설정 파일은 기본적으로 덮어쓰지 않고, 필요한 디렉터리와 unit만 생성/갱신하며, `--enable`, `--start`, `--restart`, `--dry-run` 같은 옵션으로 운영 차이를 표현한다. `status`, `logs`, `start`, `stop`, `restart` 같은 명령은 초기 CLI에 넣지 않고 `systemctl``journalctl`을 기준 운영 도구로 둔다.
## 라이브러리 선택
| 라이브러리 | 역할 |

171
docs/deploy-dev.md Normal file
View file

@ -0,0 +1,171 @@
# Dev Field Deployment
이 문서는 dev 필드 테스트 기준의 배포 단위를 정리한다. Control Plane과 Web은 compose 서비스로 묶고, Edge와 Node는 호스트에서 직접 실행하는 단일 바이너리로 배포한다.
## 배포 단위
```text
control host
docker compose
- postgres
- redis
- control-plane
- web
edge host
iop-edge binary
edge.yaml
node host
iop-node binary
node.yaml
```
Edge와 Node는 Docker 이미지로 만들지 않는다. Jenkins는 repo의 shell entrypoint를 호출해 Edge/Node 바이너리 산출물을 만든다.
## Edge/Node 바이너리 빌드
기본 빌드는 현재 호스트의 `GOOS/GOARCH` 대상으로 `edge`, `node`를 모두 만든다.
```bash
bin/build/field-binaries.sh
```
Jenkins에서 Linux amd64/arm64를 함께 만들 때는 다음처럼 호출한다.
```bash
TARGETS="linux/amd64 linux/arm64" \
VERSION="${BUILD_TAG}" \
COMMIT="${GIT_COMMIT}" \
bin/build/field-binaries.sh
```
산출물은 `dist/field/<version>/<goos>-<goarch>/` 아래에 생성된다.
```text
iop-edge
iop-edge.sha256
iop-node
iop-node.sha256
SHA256SUMS
manifest.env
```
## Control Plane/Web Compose
루트의 `.env.example`을 참고해 필요한 값만 `.env`로 복사해 조정한다.
```bash
cp .env.example .env
docker compose up --build -d
```
서비스 포트 기본값은 다음과 같다.
| Service | Port | Purpose |
|---|---:|---|
| web | 3000 | Web Portal |
| control-plane | 9080 | HTTP health/bootstrap |
| control-plane wire | 19080 | IOP wire endpoint |
| postgres | 5432 | Control Plane DB |
| redis | 6379 | Control Plane cache/queue 후보 |
현재 Control Plane 구현은 DB/Redis를 아직 사용하지 않지만, dev compose에는 먼저 포함해 이후 schema, queue, audit, event 처리 작업을 같은 배포 단위 안에서 진행할 수 있게 둔다.
헬스체크는 다음 명령으로 확인한다.
```bash
docker compose ps
curl -fsS http://localhost:${IOP_CONTROL_PLANE_HTTP_PORT:-9080}/healthz
curl -fsS http://localhost:${IOP_CONTROL_PLANE_HTTP_PORT:-9080}/readyz
```
## Edge 실행
Jenkins 산출물의 `iop-edge`를 edge host에 배포한 뒤, 초기에는 dev 환경의 `edge.yaml`을 지정해 직접 실행한다.
```bash
./iop-edge serve --config /etc/iop/edge.yaml
```
콘솔 기반 현장 smoke가 필요하면 다음처럼 실행한다.
```bash
./iop-edge console --config /etc/iop/edge.yaml
```
dev 초기 단계에서는 repo의 `configs/edge.yaml`을 기준으로 포트와 node token을 맞춘다. 이후 Control Plane enrollment/config sync가 붙으면 edge 설정 파일은 bootstrap 정보만 남기는 방향으로 줄인다.
## Node 실행
Jenkins 산출물의 `iop-node`를 node host에 배포한 뒤, 초기에는 edge 주소와 token이 들어 있는 `node.yaml`로 직접 실행한다.
```bash
./iop-node serve --config /etc/iop/node.yaml
```
repo root에서 수동으로 검증할 때는 기존 helper를 사용할 수 있다.
```bash
IOP_EDGE_ADDR=<edge-host>:9090 ./bin/node.sh
```
## Field Smoke
최소 확인 순서는 다음을 기준으로 한다.
1. `docker compose ps`에서 `postgres`, `redis`, `control-plane`, `web`이 healthy/running인지 확인한다.
2. edge host에서 `iop-edge console --config /etc/iop/edge.yaml`을 실행한다.
3. node host에서 `iop-node serve --config /etc/iop/node.yaml`을 실행한다.
4. edge console에서 `/nodes`로 node 등록을 확인한다.
5. 메시지 2회를 보내고 각 요청의 start, message, complete event가 edge console에 표시되는지 확인한다.
6. `/capabilities`, `/transport`, `/sessions`를 실행해 command 응답을 확인한다.
보조 자동 smoke는 repo root에서 다음 명령으로 실행한다.
```bash
make test-e2e
```
이 명령은 보조 확인이며, 필드 배포 완료 기준은 실제 edge/node 바이너리 실행 흐름 확인이다.
## 다음 결정 지점
### Edge/Node setup CLI
다음 구현 단계에서는 Edge/Node 바이너리에 `setup` 명령을 추가한다. 공식 운영 경로는 하나로 고정한다.
```bash
sudo iop-edge setup --config /etc/iop/edge.yaml --enable --start
sudo iop-node setup --config /etc/iop/node.yaml --enable --start
```
`setup`은 다음을 담당한다.
- 실행 user/group 준비
- `/etc/iop``/var/lib/iop/{edge,node}` 디렉터리 준비
- 설정 파일이 없을 때만 기본 템플릿 생성
- systemd unit 생성 또는 갱신
- `systemctl daemon-reload`
- 옵션에 따른 `enable`, `start`, `restart`
별도 `render`, `service install`, `service status` 명령은 초기 범위에 넣지 않는다. 검토와 CI 확인은 `setup --dry-run`으로 흡수하고, 상태/로그/재시작은 `systemctl``journalctl`을 기준 운영 도구로 둔다.
초기 CLI 표면은 다음 정도로 제한한다.
```text
iop-edge serve
iop-edge console
iop-edge setup
iop-edge config print
iop-edge config check
iop-edge version
iop-node serve
iop-node setup
iop-node config print
iop-node config check
iop-node version
```
구현 시 `setup`은 여러 번 실행해도 같은 결과로 수렴해야 한다. 기존 설정 파일은 기본적으로 덮어쓰지 않고, 강제 갱신이 필요하면 명시 옵션을 별도로 둔다.

View file

@ -13,6 +13,7 @@ type NodeConfig struct {
type EdgeConfig struct {
Edge EdgeInfo `mapstructure:"edge" yaml:"edge"`
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
OpenAI EdgeOpenAIConf `mapstructure:"openai" yaml:"openai"`
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
@ -45,6 +46,17 @@ type EdgeServerConf struct {
Listen string `mapstructure:"listen" yaml:"listen"`
}
type EdgeOpenAIConf struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
Listen string `mapstructure:"listen" yaml:"listen"`
NodeRef string `mapstructure:"node" yaml:"node"`
Adapter string `mapstructure:"adapter" yaml:"adapter"`
Target string `mapstructure:"target" yaml:"target"`
Models []string `mapstructure:"models" yaml:"models"`
SessionID string `mapstructure:"session_id" yaml:"session_id"`
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"`
}
type EdgeConsoleConf struct {
Adapter string `mapstructure:"adapter" yaml:"adapter"`
Target string `mapstructure:"target" yaml:"target"`
@ -107,8 +119,9 @@ type AdaptersConf struct {
}
type OllamaConf struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
BaseURL string `mapstructure:"base_url" yaml:"base_url"`
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
BaseURL string `mapstructure:"base_url" yaml:"base_url"`
ContextSize int `mapstructure:"context_size" yaml:"context_size"`
}
type VllmConf struct {
@ -187,6 +200,11 @@ func setDefaults(v *viper.Viper) {
func setEdgeDefaults(v *viper.Viper) {
v.SetDefault("server.listen", "0.0.0.0:9090")
v.SetDefault("openai.enabled", false)
v.SetDefault("openai.listen", "0.0.0.0:8080")
v.SetDefault("openai.adapter", "ollama")
v.SetDefault("openai.session_id", "openai")
v.SetDefault("openai.timeout_sec", 120)
v.SetDefault("logging.level", "info")
v.SetDefault("metrics.port", 9092)
v.SetDefault("tls.enabled", false)

View file

@ -65,6 +65,104 @@ func TestLoadEdge_ConsoleTimeoutDefault(t *testing.T) {
}
}
func TestLoadEdge_OpenAIDefaults(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
if err := os.WriteFile(f, []byte("server:\n listen: \"0.0.0.0:9090\"\n"), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.OpenAI.Enabled {
t.Fatal("expected openai.enabled=false by default")
}
if cfg.OpenAI.Listen != "0.0.0.0:8080" {
t.Fatalf("expected openai.listen default, got %q", cfg.OpenAI.Listen)
}
if cfg.OpenAI.Adapter != "ollama" {
t.Fatalf("expected openai.adapter=%q, got %q", "ollama", cfg.OpenAI.Adapter)
}
if cfg.OpenAI.SessionID != "openai" {
t.Fatalf("expected openai.session_id=%q, got %q", "openai", cfg.OpenAI.SessionID)
}
if cfg.OpenAI.TimeoutSec != 120 {
t.Fatalf("expected openai.timeout_sec=120, got %d", cfg.OpenAI.TimeoutSec)
}
}
func TestLoadEdge_OpenAIOverride(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := `
server:
listen: "0.0.0.0:9090"
openai:
enabled: true
listen: "127.0.0.1:8088"
node: "node0"
adapter: "ollama"
target: "llama-test"
models:
- "llama-test"
session_id: "cline"
timeout_sec: 45
`
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if !cfg.OpenAI.Enabled {
t.Fatal("expected openai.enabled=true")
}
if cfg.OpenAI.Listen != "127.0.0.1:8088" || cfg.OpenAI.NodeRef != "node0" {
t.Fatalf("unexpected openai routing config: %+v", cfg.OpenAI)
}
if cfg.OpenAI.Target != "llama-test" || len(cfg.OpenAI.Models) != 1 || cfg.OpenAI.Models[0] != "llama-test" {
t.Fatalf("unexpected openai model config: %+v", cfg.OpenAI)
}
if cfg.OpenAI.SessionID != "cline" || cfg.OpenAI.TimeoutSec != 45 {
t.Fatalf("unexpected openai execution config: %+v", cfg.OpenAI)
}
}
func TestLoadEdge_OllamaContextSize(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := `
server:
listen: "0.0.0.0:9090"
nodes:
- alias: "local"
adapters:
ollama:
enabled: true
base_url: "http://192.168.0.91:11434"
context_size: 262144
`
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
ollama := cfg.Nodes[0].Adapters.Ollama
if !ollama.Enabled {
t.Fatal("expected ollama.enabled=true")
}
if ollama.BaseURL != "http://192.168.0.91:11434" || ollama.ContextSize != 262144 {
t.Fatalf("unexpected ollama config: %+v", ollama)
}
}
func TestLoadEdge_ConsoleSessionDefaults(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")

View file

@ -1562,6 +1562,7 @@ func (x *CLICompletionMarker) GetRegex() string {
type OllamaAdapterConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
BaseUrl string `protobuf:"bytes,1,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"`
ContextSize int32 `protobuf:"varint,2,opt,name=context_size,json=contextSize,proto3" json:"context_size,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1603,6 +1604,13 @@ func (x *OllamaAdapterConfig) GetBaseUrl() string {
return ""
}
func (x *OllamaAdapterConfig) GetContextSize() int32 {
if x != nil {
return x.ContextSize
}
return 0
}
type VllmAdapterConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
Endpoint string `protobuf:"bytes,1,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
@ -1857,9 +1865,10 @@ const file_proto_iop_runtime_proto_rawDesc = "" +
"resumeArgs\"?\n" +
"\x13CLICompletionMarker\x12\x12\n" +
"\x04line\x18\x01 \x01(\tR\x04line\x12\x14\n" +
"\x05regex\x18\x02 \x01(\tR\x05regex\"0\n" +
"\x05regex\x18\x02 \x01(\tR\x05regex\"S\n" +
"\x13OllamaAdapterConfig\x12\x19\n" +
"\bbase_url\x18\x01 \x01(\tR\abaseUrl\"/\n" +
"\bbase_url\x18\x01 \x01(\tR\abaseUrl\x12!\n" +
"\fcontext_size\x18\x02 \x01(\x05R\vcontextSize\"/\n" +
"\x11VllmAdapterConfig\x12\x1a\n" +
"\bendpoint\x18\x01 \x01(\tR\bendpoint\"\\\n" +
"\x11NodeRuntimeConfig\x12 \n" +

View file

@ -185,7 +185,8 @@ message CLICompletionMarker {
}
message OllamaAdapterConfig {
string base_url = 1;
string base_url = 1;
int32 context_size = 2;
}
message VllmAdapterConfig {

204
scripts/e2e-openai-ollama.sh Executable file
View file

@ -0,0 +1,204 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TMP_DIR=$(mktemp -d)
EDGE_PID=""
NODE_PID=""
OLLAMA_PID=""
EDGE_FD_OPEN=0
cleanup() {
if [ "$EDGE_FD_OPEN" -eq 1 ]; then
{ echo "/exit" >&3; } 2>/dev/null || true
exec 3>&- 2>/dev/null || true
fi
if [ -n "$EDGE_PID" ]; then wait "$EDGE_PID" 2>/dev/null || true; fi
if [ -n "$NODE_PID" ]; then kill "$NODE_PID" 2>/dev/null || true; fi
if [ -n "$OLLAMA_PID" ]; then kill "$OLLAMA_PID" 2>/dev/null || true; fi
rm -rf "$TMP_DIR" 2>/dev/null || true
}
trap cleanup EXIT
PORT=$((31000 + RANDOM % 5000))
OPENAI_PORT=$((36000 + RANDOM % 5000))
OLLAMA_PORT=$((41000 + RANDOM % 5000))
EDGE_METRICS_PORT=$((46000 + RANDOM % 5000))
NODE_METRICS_PORT=$((51000 + RANDOM % 5000))
MODEL="fake-ollama-model"
FAKE_OLLAMA="$TMP_DIR/fake_ollama.go"
cat > "$FAKE_OLLAMA" <<'EOF'
package main
import (
"encoding/json"
"log"
"net/http"
"os"
)
type chatRequest struct {
Model string `json:"model"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
Options struct {
NumCtx int `json:"num_ctx"`
} `json:"options"`
}
func main() {
if len(os.Args) < 2 {
log.Fatal("missing listen addr")
}
mux := http.NewServeMux()
mux.HandleFunc("/api/tags", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"models":[{"name":"fake-ollama-model"}]}`))
})
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Model != "fake-ollama-model" {
http.Error(w, "unexpected model: "+req.Model, http.StatusBadRequest)
return
}
if req.Options.NumCtx != 262144 {
http.Error(w, "unexpected num_ctx", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
_, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"IOP_OPENAI_"},"done":false}` + "\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
_, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"OLLAMA_OK"},"done":false}` + "\n"))
_, _ = w.Write([]byte(`{"done":true,"prompt_eval_count":5,"eval_count":2}` + "\n"))
})
log.Fatal(http.ListenAndServe(os.Args[1], mux))
}
EOF
go run "$FAKE_OLLAMA" "127.0.0.1:$OLLAMA_PORT" > "$TMP_DIR/fake_ollama.out" 2>&1 &
OLLAMA_PID=$!
wait_port() {
local host="$1"
local port="$2"
local label="$3"
local deadline=$((SECONDS + 20))
while ! timeout 1 bash -c 'cat < /dev/null > /dev/tcp/"$1"/"$2"' _ "$host" "$port" 2>/dev/null; do
if (( SECONDS >= deadline )); then
echo "[openai-ollama] $label did not open on $host:$port"
return 1
fi
sleep 0.2
done
}
wait_port 127.0.0.1 "$OLLAMA_PORT" "fake ollama"
EDGE_CONFIG="$TMP_DIR/edge.yaml"
NODE_CONFIG="$TMP_DIR/node.yaml"
cat > "$EDGE_CONFIG" <<EOF
server:
listen: "127.0.0.1:$PORT"
metrics:
port: $EDGE_METRICS_PORT
openai:
enabled: true
listen: "127.0.0.1:$OPENAI_PORT"
adapter: "ollama"
target: "$MODEL"
models:
- "$MODEL"
session_id: "cline"
timeout_sec: 30
console:
adapter: mock
target: mock-echo
session_id: default
nodes:
- id: test-node
alias: test-node
token: test-token
runtime:
workspace_root: "$TMP_DIR/workspace"
adapters:
ollama:
enabled: true
base_url: "http://127.0.0.1:$OLLAMA_PORT"
context_size: 262144
EOF
cat > "$NODE_CONFIG" <<EOF
transport:
edge_addr: "127.0.0.1:$PORT"
token: test-token
reconnect_interval: 1s
metrics:
port: $NODE_METRICS_PORT
node:
id: test-node
alias: test-node
EOF
EDGE_OUT="$TMP_DIR/edge.out"
NODE_OUT="$TMP_DIR/node.out"
mkfifo "$TMP_DIR/edge_fifo"
IOP_EDGE_CONFIG="$EDGE_CONFIG" "$REPO_ROOT/bin/edge.sh" < "$TMP_DIR/edge_fifo" > "$EDGE_OUT" 2>&1 &
EDGE_PID=$!
exec 3> "$TMP_DIR/edge_fifo"
EDGE_FD_OPEN=1
wait_port 127.0.0.1 "$PORT" "edge node transport"
wait_port 127.0.0.1 "$OPENAI_PORT" "edge openai api"
IOP_NODE_CONFIG="$NODE_CONFIG" "$REPO_ROOT/bin/node.sh" > "$NODE_OUT" 2>&1 &
NODE_PID=$!
deadline=$((SECONDS + 30))
while ! grep -q '\[node0-evt\] connected reason="registered"' "$EDGE_OUT"; do
if (( SECONDS >= deadline )); then
echo "[openai-ollama] node registration timed out"
echo "=== EDGE OUTPUT ==="; cat "$EDGE_OUT"
echo "=== NODE OUTPUT ==="; cat "$NODE_OUT"
exit 1
fi
sleep 0.2
done
MODELS_OUT="$TMP_DIR/models.json"
curl -fsS "http://127.0.0.1:$OPENAI_PORT/v1/models" > "$MODELS_OUT"
grep -q "$MODEL" "$MODELS_OUT"
CHAT_OUT="$TMP_DIR/chat.json"
curl -fsS \
-H "Content-Type: application/json" \
-d '{"model":"client-request-model","messages":[{"role":"user","content":"say the test token"}]}' \
"http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$CHAT_OUT"
grep -q "IOP_OPENAI_OLLAMA_OK" "$CHAT_OUT"
STREAM_OUT="$TMP_DIR/stream.txt"
curl -fsS -N \
-H "Content-Type: application/json" \
-d '{"model":"client-request-model","stream":true,"messages":[{"role":"user","content":"stream the test token"}]}' \
"http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$STREAM_OUT"
grep -q '"content":"IOP_OPENAI_"' "$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
echo "[openai-ollama] detected failure marker"
echo "=== EDGE OUTPUT ==="; cat "$EDGE_OUT"
echo "=== NODE OUTPUT ==="; cat "$NODE_OUT"
exit 1
fi
echo "[openai-ollama] OpenAI-compatible Ollama serving test PASSED."

View file

@ -31,6 +31,7 @@ STATUS_TIMEOUT="${IOP_E2E_STATUS_TIMEOUT:-$RUN_TIMEOUT}"
COMMAND_SETTLE_SECONDS="${IOP_E2E_COMMAND_SETTLE_SECONDS:-0.2}"
IS_PERSISTENT=0
HAS_STATUS=0
EXPECT_TAIL_MESSAGES=0
FIRST_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_alpha"
FIRST_EXPECTED="SMOKE_ALPHA"
FIRST_TAIL_EXPECTED="IOP_SMOKE_ALPHA_TAIL"
@ -44,6 +45,7 @@ BACKGROUND_TAIL_EXPECTED="IOP_SMOKE_BG_TAIL"
if [ "$PROFILE" = "mock" ]; then
echo "[e2e] preparing honest mock smoke test (using scripted cli adapter)..."
IS_PERSISTENT=1
EXPECT_TAIL_MESSAGES=1
TARGET="fake-cli"
MOCK_CLI="$TMP_DIR/fake-cli.sh"
cat <<'EOF' > "$MOCK_CLI"
@ -459,11 +461,13 @@ if [ "$(grep -c "\\[node0-evt\\] complete run_id=" "$EDGE_OUT" || true)" -lt 3 ]
FAIL=1
fi
check_grep "$FIRST_EXPECTED" "$EDGE_OUT" "foreground run 1 response not found"
check_grep "$FIRST_TAIL_EXPECTED" "$EDGE_OUT" "foreground run 1 tail response not found"
check_grep "$SECOND_EXPECTED" "$EDGE_OUT" "foreground run 2 response not found"
check_grep "$SECOND_TAIL_EXPECTED" "$EDGE_OUT" "foreground run 2 tail response not found"
check_grep "$BACKGROUND_EXPECTED" "$EDGE_OUT" "background run response not found"
check_grep "$BACKGROUND_TAIL_EXPECTED" "$EDGE_OUT" "background run tail response not found"
if [ "$EXPECT_TAIL_MESSAGES" -eq 1 ]; then
check_grep "$FIRST_TAIL_EXPECTED" "$EDGE_OUT" "foreground run 1 tail response not found"
check_grep "$SECOND_TAIL_EXPECTED" "$EDGE_OUT" "foreground run 2 tail response not found"
check_grep "$BACKGROUND_TAIL_EXPECTED" "$EDGE_OUT" "background run tail response not found"
fi
check_no_grep "\\[node0-msg\\] <empty>" "$EDGE_OUT" "empty node message found"
check_node_messages_relayed_to_edge