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/go/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 { switch v := req.Options["num_ctx"].(type) { case float64: gotNumCtx = int(v) case int: gotNumCtx = v } } 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 TestOllamaExecutePassesOptionsAndTopLevelFields(t *testing.T) { 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) } if req.Options["temperature"].(float64) != 0.2 || req.Options["top_p"].(float64) != 0.9 || req.Options["num_predict"].(float64) != 32 { t.Fatalf("unexpected options: %+v", req.Options) } switch v := req.Options["num_ctx"].(type) { case float64: if int(v) != 262144 { t.Fatalf("profile context_size should override request num_ctx: got %d, want 262144", int(v)) } case int: if v != 262144 { t.Fatalf("profile context_size should override request num_ctx: got %d, want 262144", v) } default: t.Fatalf("unexpected num_ctx type %T in %+v", v, req.Options) } if req.KeepAlive != "10m" || req.Think != false || req.Format != "json" { t.Fatalf("top-level fields not passed: keep_alive=%v think=%v format=%v", req.KeepAlive, req.Think, req.Format) } if tools, ok := req.Tools.([]any); !ok || len(tools) != 1 { t.Fatalf("tools not passed: %+v", req.Tools) } w.Header().Set("Content-Type", "application/x-ndjson") _, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"ok"},"done":false}` + "\n")) _, _ = w.Write([]byte(`{"done":true}` + "\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-options", Target: "llama-test", Input: map[string]any{ "prompt": "hi", "options": map[string]any{ "temperature": 0.2, "top_p": 0.9, "num_predict": float64(32), "num_ctx": float64(4096), }, "keep_alive": "10m", "think": false, "format": "json", "tools": []any{map[string]any{"type": "function"}}, }, }, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } } func TestOllamaExecutePreservesNativeToolCalls(t *testing.T) { 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) } w.Header().Set("Content-Type", "application/x-ndjson") _, _ = w.Write([]byte(`{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]},"done":false}` + "\n")) _, _ = w.Write([]byte(`{"done":true}` + "\n")) })) defer server.Close() adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop()) sink := &fakeSink{} err := adapter.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-tools", Target: "llama-test", Input: map[string]any{"prompt": "status"}, }, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } events := sink.all() complete := events[len(events)-1] if complete.Metadata["finish_reason"] != "tool_calls" { t.Fatalf("finish_reason: %+v", complete.Metadata) } var toolCalls []map[string]any if err := json.Unmarshal([]byte(complete.Metadata[runtimeMetadataOpenAIToolCalls]), &toolCalls); err != nil { t.Fatalf("tool_calls metadata JSON: %v", err) } if len(toolCalls) != 1 || toolCalls[0]["id"] != "call_1" { t.Fatalf("tool_calls: %+v", toolCalls) } } 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, Capacity: 5, MaxQueue: 10, QueueTimeoutMS: 15000, RequestTimeoutMS: 30000, }, 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) } if caps.MaxConcurrency != 5 { t.Fatalf("expected MaxConcurrency 5, got %d", caps.MaxConcurrency) } if caps.MaxQueue != 10 { t.Fatalf("expected MaxQueue 10, got %d", caps.MaxQueue) } if caps.QueueTimeoutMS != 15000 { t.Fatalf("expected QueueTimeoutMS 15000, got %d", caps.QueueTimeoutMS) } if caps.RequestTimeoutMS != 30000 { t.Fatalf("expected RequestTimeoutMS 30000, got %d", caps.RequestTimeoutMS) } // Verify default capacity fallback adapterDefault := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop()) capsDefault, err := adapterDefault.Capabilities(context.Background()) if err != nil { t.Fatalf("Capabilities failed: %v", err) } if capsDefault.MaxConcurrency != 4 { t.Fatalf("expected default MaxConcurrency 4, got %d", capsDefault.MaxConcurrency) } } func TestOllamaHandleCommandPassesNativeAPI(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.RequestURI() != "/api/show?verbose=true" { t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) } var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body["model"] != "gemma4:26b" { t.Fatalf("body not passed: %+v", body) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"license":"ok"}`)) })) defer server.Close() adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop()) resp, err := adapter.HandleCommand(context.Background(), noderuntime.CommandRequest{ RequestID: "ollama-1", Type: noderuntime.CommandTypeOllamaAPI, Adapter: "ollama", Metadata: map[string]string{ "ollama_method": "POST", "ollama_path": "/api/show?verbose=true", "ollama_body": `{"model":"gemma4:26b"}`, }, }) if err != nil { t.Fatalf("HandleCommand failed: %v", err) } if resp.Result["status_code"] != "202" || resp.Result["content_type"] != "application/json" || resp.Result["body"] != `{"license":"ok"}` { t.Fatalf("unexpected response: %+v", resp.Result) } } func TestOllamaMultiEndpoint(t *testing.T) { var ( gotModelA string gotNumCtxA int gotModelB string gotNumCtxB int ) makeServer := func(gotModel *string, gotNumCtx *int, reply string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/tags" { _, _ = w.Write([]byte(`{"models":[]}`)) return } if r.URL.Path != "/api/chat" { t.Errorf("unexpected path %s", r.URL.Path) return } var req ollamaChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Errorf("decode request: %v", err) return } *gotModel = req.Model if v, ok := req.Options["num_ctx"].(float64); ok { *gotNumCtx = int(v) } w.Header().Set("Content-Type", "application/x-ndjson") _, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"` + reply + `"},"done":false}` + "\n")) _, _ = w.Write([]byte(`{"done":true}` + "\n")) })) } serverA := makeServer(&gotModelA, &gotNumCtxA, "a") defer serverA.Close() serverB := makeServer(&gotModelB, &gotNumCtxB, "b") defer serverB.Close() adapterA := New(config.OllamaConf{BaseURL: serverA.URL, ContextSize: 4096}, zap.NewNop(), "ollama-a") adapterB := New(config.OllamaConf{BaseURL: serverB.URL, ContextSize: 8192}, zap.NewNop(), "ollama-b") sinkA := &fakeSink{} if err := adapterA.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-a", Target: "model-a", Input: map[string]any{"prompt": "test"}, }, sinkA); err != nil { t.Fatalf("A execute: %v", err) } sinkB := &fakeSink{} if err := adapterB.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-b", Target: "model-b", Input: map[string]any{"prompt": "test"}, }, sinkB); err != nil { t.Fatalf("B execute: %v", err) } if gotModelA != "model-a" { t.Errorf("A model: got %q, want model-a", gotModelA) } if gotNumCtxA != 4096 { t.Errorf("A num_ctx: got %d, want 4096", gotNumCtxA) } if gotModelB != "model-b" { t.Errorf("B model: got %q, want model-b", gotModelB) } if gotNumCtxB != 8192 { t.Errorf("B num_ctx: got %d, want 8192", gotNumCtxB) } capsA, _ := adapterA.Capabilities(context.Background()) if capsA.InstanceKey != "ollama-a" { t.Errorf("A InstanceKey: got %q, want ollama-a", capsA.InstanceKey) } capsB, _ := adapterB.Capabilities(context.Background()) if capsB.InstanceKey != "ollama-b" { t.Errorf("B InstanceKey: got %q, want ollama-b", capsB.InstanceKey) } } func TestOllamaContextSizeForcedOverRequestNumCtx(t *testing.T) { var gotNumCtx int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req ollamaChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } if v, ok := req.Options["num_ctx"].(float64); ok { gotNumCtx = int(v) } w.Header().Set("Content-Type", "application/x-ndjson") _, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"ok"},"done":false}` + "\n")) _, _ = w.Write([]byte(`{"done":true}` + "\n")) })) defer server.Close() // profile context_size=262144 must override request options.num_ctx=8192 adapter := New(config.OllamaConf{BaseURL: server.URL, ContextSize: 262144}, zap.NewNop()) sink := &fakeSink{} err := adapter.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-forced", Target: "llama-test", Input: map[string]any{ "prompt": "hi", "options": map[string]any{"num_ctx": float64(8192), "temperature": 0.5}, }, }, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } if gotNumCtx != 262144 { t.Fatalf("Edge-owned context_size must override request num_ctx: got %d, want 262144", gotNumCtx) } } func TestOllamaExecuteStreamsThinkingDeltasSeparately(t *testing.T) { 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) } w.Header().Set("Content-Type", "application/x-ndjson") _, _ = w.Write([]byte(`{"message":{"role":"assistant","thinking":"thinking..."},"done":false}` + "\n")) _, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"answer"},"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}, zap.NewNop()) sink := &fakeSink{} err := adapter.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-thinking", Target: "llama-test", Input: map[string]any{"prompt": "say hello"}, }, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } 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].Type != noderuntime.EventTypeReasoningDelta || events[1].Delta != "thinking..." { t.Fatalf("expected reasoning_delta event, got %+v", events[1]) } if events[2].Type != noderuntime.EventTypeDelta || events[2].Delta != "answer" { t.Fatalf("expected delta event, got %+v", events[2]) } 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 TestOllamaProbeProviderAvailability(t *testing.T) { t.Run("200_ok_and_target_hit", func(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()) res, err := adapter.ProbeProvider(context.Background(), "llama-a") if err != nil { t.Fatalf("ProbeProvider failed: %v", err) } if res.Status != noderuntime.ProviderStatusAvailable { t.Errorf("expected Status available, got %s", res.Status) } if len(res.Targets) != 2 || res.Targets[0] != "llama-a" { t.Errorf("unexpected Targets: %+v", res.Targets) } }) t.Run("200_ok_and_target_miss", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"models":[{"name":"llama-a"}]}`)) })) defer server.Close() adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop()) res, err := adapter.ProbeProvider(context.Background(), "llama-b") if err != nil { t.Fatalf("ProbeProvider failed: %v", err) } if res.Status != noderuntime.ProviderStatusUnavailable { t.Errorf("expected Status unavailable, got %s", res.Status) } if !strings.Contains(res.Detail, "not found") { t.Errorf("expected 'not found' in detail, got %s", res.Detail) } }) t.Run("500_internal_error", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop()) res, err := adapter.ProbeProvider(context.Background(), "llama-a") if err != nil { t.Fatalf("ProbeProvider failed: %v", err) } if res.Status != noderuntime.ProviderStatusUnavailable { t.Errorf("expected Status unavailable, got %s", res.Status) } }) t.Run("empty_target", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"models":[{"name":"llama-a"}]}`)) })) defer server.Close() adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop()) res, err := adapter.ProbeProvider(context.Background(), "") if err != nil { t.Fatalf("ProbeProvider failed: %v", err) } if res.Status != noderuntime.ProviderStatusAvailable { t.Errorf("expected Status available, got %s", res.Status) } }) }