diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eed1d50 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index a4e3f80..2b65854 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ agent-ops/rules/private/ # Local runtime/test artifacts /iop.db /*.log +/dist/ +/.env # Web Portal artifacts apps/web/node_modules/ diff --git a/Makefile b/Makefile index d9aee08..ed2e506 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/apps/edge/README.md b/apps/edge/README.md index e693b40..d94631f 100644 --- a/apps/edge/README.md +++ b/apps/edge/README.md @@ -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 실행은 다음 단계에서 붙인다. diff --git a/apps/edge/internal/bootstrap/runtime.go b/apps/edge/internal/bootstrap/runtime.go index 5699209..392cf4c 100644 --- a/apps/edge/internal/bootstrap/runtime.go +++ b/apps/edge/internal/bootstrap/runtime.go @@ -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 } diff --git a/apps/edge/internal/node/mapper.go b/apps/edge/internal/node/mapper.go index 9c6a558..c16bf0c 100644 --- a/apps/edge/internal/node/mapper.go +++ b/apps/edge/internal/node/mapper.go @@ -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), + }, }, }) } diff --git a/apps/edge/internal/node/mapper_test.go b/apps/edge/internal/node/mapper_test.go index 7915928..3b81221 100644 --- a/apps/edge/internal/node/mapper_test.go +++ b/apps/edge/internal/node/mapper_test.go @@ -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 { diff --git a/apps/edge/internal/openai/server.go b/apps/edge/internal/openai/server.go new file mode 100644 index 0000000..5bfe304 --- /dev/null +++ b/apps/edge/internal/openai/server.go @@ -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"` +} diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go new file mode 100644 index 0000000..4b5dc92 --- /dev/null +++ b/apps/edge/internal/openai/server_test.go @@ -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") + } +} diff --git a/apps/edge/internal/opsconsole/events_test.go b/apps/edge/internal/opsconsole/events_test.go index b5962b2..5db78cd 100644 --- a/apps/edge/internal/opsconsole/events_test.go +++ b/apps/edge/internal/opsconsole/events_test.go @@ -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{ diff --git a/apps/edge/internal/opsconsole/status.go b/apps/edge/internal/opsconsole/status.go index 2c31c0f..8f7538f 100644 --- a/apps/edge/internal/opsconsole/status.go +++ b/apps/edge/internal/opsconsole/status.go @@ -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 { diff --git a/apps/node/README.md b/apps/node/README.md index 2da209b..ab32590 100644 --- a/apps/node/README.md +++ b/apps/node/README.md @@ -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 `, `codex exec --dangerously-bypass-approvals-and-sandbox`, `cline -y --json --config /config/.cline/profiles/ollama-dgx `. 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을 판단한다. diff --git a/apps/node/internal/adapters/cli/status/claude.go b/apps/node/internal/adapters/cli/status/claude.go index dbf2760..0c59110 100644 --- a/apps/node/internal/adapters/cli/status/claude.go +++ b/apps/node/internal/adapters/cli/status/claude.go @@ -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 diff --git a/apps/node/internal/adapters/cli/status/claude_test.go b/apps/node/internal/adapters/cli/status/claude_test.go index 026e9c9..7b2b957 100644 --- a/apps/node/internal/adapters/cli/status/claude_test.go +++ b/apps/node/internal/adapters/cli/status/claude_test.go @@ -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 diff --git a/apps/node/internal/adapters/cli/status/parser.go b/apps/node/internal/adapters/cli/status/parser.go index d721f1b..ec9272b 100644 --- a/apps/node/internal/adapters/cli/status/parser.go +++ b/apps/node/internal/adapters/cli/status/parser.go @@ -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 { diff --git a/apps/node/internal/adapters/cli/status/parser_test.go b/apps/node/internal/adapters/cli/status/parser_test.go index 25dd6a6..318c0b1 100644 --- a/apps/node/internal/adapters/cli/status/parser_test.go +++ b/apps/node/internal/adapters/cli/status/parser_test.go @@ -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)" diff --git a/apps/node/internal/adapters/cli/status/status.go b/apps/node/internal/adapters/cli/status/status.go index a3b6138..37610c3 100644 --- a/apps/node/internal/adapters/cli/status/status.go +++ b/apps/node/internal/adapters/cli/status/status.go @@ -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 diff --git a/apps/node/internal/adapters/cli/status/status_test.go b/apps/node/internal/adapters/cli/status/status_test.go index f56302e..157be40 100644 --- a/apps/node/internal/adapters/cli/status/status_test.go +++ b/apps/node/internal/adapters/cli/status/status_test.go @@ -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", diff --git a/apps/node/internal/adapters/factory.go b/apps/node/internal/adapters/factory.go index 61bb202..2c11cea 100644 --- a/apps/node/internal/adapters/factory.go +++ b/apps/node/internal/adapters/factory.go @@ -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 { diff --git a/apps/node/internal/adapters/factory_internal_test.go b/apps/node/internal/adapters/factory_internal_test.go index 00d4fee..3fa54a9 100644 --- a/apps/node/internal/adapters/factory_internal_test.go +++ b/apps/node/internal/adapters/factory_internal_test.go @@ -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) { diff --git a/apps/node/internal/adapters/ollama/ollama.go b/apps/node/internal/adapters/ollama/ollama.go index c7eda3e..6eacc1f 100644 --- a/apps/node/internal/adapters/ollama/ollama.go +++ b/apps/node/internal/adapters/ollama/ollama.go @@ -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"` } diff --git a/apps/node/internal/adapters/ollama/ollama_test.go b/apps/node/internal/adapters/ollama/ollama_test.go new file mode 100644 index 0000000..4d197e5 --- /dev/null +++ b/apps/node/internal/adapters/ollama/ollama_test.go @@ -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) + } +} diff --git a/apps/node/internal/adapters/vllm/vllm.go b/apps/node/internal/adapters/vllm/vllm.go index 72020e2..41e202b 100644 --- a/apps/node/internal/adapters/vllm/vllm.go +++ b/apps/node/internal/adapters/vllm/vllm.go @@ -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"` +} diff --git a/apps/node/internal/adapters/vllm/vllm_test.go b/apps/node/internal/adapters/vllm/vllm_test.go new file mode 100644 index 0000000..b01db6f --- /dev/null +++ b/apps/node/internal/adapters/vllm/vllm_test.go @@ -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) + } +} diff --git a/bin/build/field-binaries.sh b/bin/build/field-binaries.sh new file mode 100755 index 0000000..f92f9fc --- /dev/null +++ b/bin/build/field-binaries.sh @@ -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= release version label + COMMIT= commit label + BUILD_DATE= 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" diff --git a/configs/edge.yaml b/configs/edge.yaml index 86ba135..3e92fe9 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 96b30fe..cfb4d2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/architecture.md b/docs/architecture.md index c5fb4eb..7f2fbc5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`을 기준 운영 도구로 둔다. + ## 라이브러리 선택 | 라이브러리 | 역할 | diff --git a/docs/deploy-dev.md b/docs/deploy-dev.md new file mode 100644 index 0000000..ccb777e --- /dev/null +++ b/docs/deploy-dev.md @@ -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//-/` 아래에 생성된다. + +```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=: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`은 여러 번 실행해도 같은 결과로 수렴해야 한다. 기존 설정 파일은 기본적으로 덮어쓰지 않고, 강제 갱신이 필요하면 명시 옵션을 별도로 둔다. diff --git a/packages/config/config.go b/packages/config/config.go index da9251e..2ee87a2 100644 --- a/packages/config/config.go +++ b/packages/config/config.go @@ -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) diff --git a/packages/config/config_test.go b/packages/config/config_test.go index 80a7823..9aa6dcd 100644 --- a/packages/config/config_test.go +++ b/packages/config/config_test.go @@ -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") diff --git a/proto/gen/iop/runtime.pb.go b/proto/gen/iop/runtime.pb.go index 93e783d..f93fae5 100644 --- a/proto/gen/iop/runtime.pb.go +++ b/proto/gen/iop/runtime.pb.go @@ -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" + diff --git a/proto/iop/runtime.proto b/proto/iop/runtime.proto index 093cf65..381753f 100644 --- a/proto/iop/runtime.proto +++ b/proto/iop/runtime.proto @@ -185,7 +185,8 @@ message CLICompletionMarker { } message OllamaAdapterConfig { - string base_url = 1; + string base_url = 1; + int32 context_size = 2; } message VllmAdapterConfig { diff --git a/scripts/e2e-openai-ollama.sh b/scripts/e2e-openai-ollama.sh new file mode 100755 index 0000000..2c2efe1 --- /dev/null +++ b/scripts/e2e-openai-ollama.sh @@ -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" < "$NODE_CONFIG" < "$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." diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh index 06ed3d8..0d51288 100755 --- a/scripts/e2e-smoke.sh +++ b/scripts/e2e-smoke.sh @@ -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\\] " "$EDGE_OUT" "empty node message found" check_node_messages_relayed_to_edge