package adapters_test import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "go.uber.org/zap" "iop/apps/node/internal/adapters" noderuntime "iop/apps/node/internal/runtime" iop "iop/proto/gen/iop" ) type testSink struct { mu sync.Mutex events []noderuntime.RuntimeEvent } func (s *testSink) Emit(_ context.Context, event noderuntime.RuntimeEvent) error { s.mu.Lock() defer s.mu.Unlock() s.events = append(s.events, event) return nil } // --- BuildFromPayload tests --- func TestBuildFromPayload_EmptyPayloadRegistersNoAdapters(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{}, zap.NewNop()) if err != nil { t.Fatalf("build from payload: %v", err) } if got := len(reg.All()); got != 0 { t.Fatalf("expected no adapters, got %d", got) } if _, ok := reg.Get("mock"); ok { t.Fatal("mock adapter must not be registered implicitly") } } func TestBuildFromPayload_ExplicitMockEnabled(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "mock", Enabled: true}, }, }, zap.NewNop()) if err != nil { t.Fatalf("build from payload: %v", err) } if _, ok := reg.Get("mock"); !ok { t.Fatal("expected explicit mock adapter to be registered") } } func TestBuildFromPayload_OllamaEnabled(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ { Type: "ollama", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "http://localhost:11434"}}, }, }, }, zap.NewNop()) if err != nil { t.Fatalf("build from payload: %v", err) } if _, ok := reg.Get("ollama"); !ok { t.Fatal("expected ollama adapter to be registered") } } func TestBuildFromPayload_MultipleAdapters(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "ollama", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "x"}}}, {Type: "cli", Enabled: true, Config: &iop.AdapterConfig_Cli{Cli: &iop.CLIAdapterConfig{ Profiles: map[string]*iop.CLIProfileConfig{ "codex": {Command: "codex", Persistent: true, ResponseIdleTimeoutMs: 1500, StartupIdleTimeoutMs: 300}, }, }}}, }, }, zap.NewNop()) if err != nil { t.Fatalf("build from payload: %v", err) } for _, name := range []string{"ollama", "cli"} { if _, ok := reg.Get(name); !ok { t.Fatalf("expected %s adapter to be registered", name) } } } func TestBuildFromPayload_VllmEnabled(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "vllm", Enabled: true, Config: &iop.AdapterConfig_Vllm{Vllm: &iop.VllmAdapterConfig{Endpoint: "http://localhost:8000"}}}, }, }, zap.NewNop()) if err != nil { t.Fatalf("build from payload: %v", err) } if _, ok := reg.Get("vllm"); !ok { t.Fatal("expected vllm adapter to be registered") } } func TestBuildFromPayload_UnknownType(t *testing.T) { _, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "unknown", Enabled: true}, }, }, zap.NewNop()) if err == nil { t.Fatal("expected unknown adapter type error") } } // --- Registry lifecycle tests --- type lifecycleAdapter struct { name string log *[]string } func (a *lifecycleAdapter) Name() string { return a.name } func (a *lifecycleAdapter) Capabilities(_ context.Context) (noderuntime.Capabilities, error) { return noderuntime.Capabilities{AdapterName: a.name}, nil } func (a *lifecycleAdapter) Execute(_ context.Context, _ noderuntime.ExecutionSpec, _ noderuntime.EventSink) error { return nil } func (a *lifecycleAdapter) Start(_ context.Context) error { *a.log = append(*a.log, "start:"+a.name) return nil } func (a *lifecycleAdapter) Stop(_ context.Context) error { *a.log = append(*a.log, "stop:"+a.name) return nil } type failingLifecycleAdapter struct { name string log *[]string } func (a *failingLifecycleAdapter) Name() string { return a.name } func (a *failingLifecycleAdapter) Capabilities(_ context.Context) (noderuntime.Capabilities, error) { return noderuntime.Capabilities{AdapterName: a.name}, nil } func (a *failingLifecycleAdapter) Execute(_ context.Context, _ noderuntime.ExecutionSpec, _ noderuntime.EventSink) error { return nil } func (a *failingLifecycleAdapter) Start(_ context.Context) error { *a.log = append(*a.log, "start:"+a.name) return fmt.Errorf("start failed: %s", a.name) } func (a *failingLifecycleAdapter) Stop(_ context.Context) error { *a.log = append(*a.log, "stop:"+a.name) return nil } type failingStopAdapter struct { name string log *[]string stopErr error } func (a *failingStopAdapter) Name() string { return a.name } func (a *failingStopAdapter) Capabilities(_ context.Context) (noderuntime.Capabilities, error) { return noderuntime.Capabilities{AdapterName: a.name}, nil } func (a *failingStopAdapter) Execute(_ context.Context, _ noderuntime.ExecutionSpec, _ noderuntime.EventSink) error { return nil } func (a *failingStopAdapter) Start(_ context.Context) error { *a.log = append(*a.log, "start:"+a.name) return nil } func (a *failingStopAdapter) Stop(_ context.Context) error { *a.log = append(*a.log, "stop:"+a.name) return a.stopErr } func TestRegistryLifecycle_StartStopOrder(t *testing.T) { log := []string{} reg := adapters.NewRegistry() reg.Register(&lifecycleAdapter{name: "first", log: &log}) reg.Register(&lifecycleAdapter{name: "second", log: &log}) ctx := context.Background() if err := reg.Start(ctx); err != nil { t.Fatalf("Start: %v", err) } if err := reg.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } want := []string{"start:first", "start:second", "stop:second", "stop:first"} if len(log) != len(want) { t.Fatalf("expected %v, got %v", want, log) } for i, got := range log { if got != want[i] { t.Fatalf("log[%d]: want %q, got %q", i, want[i], got) } } } func TestRegistryLifecycle_StartFailureStopsStartedAdapters(t *testing.T) { log := []string{} reg := adapters.NewRegistry() reg.Register(&lifecycleAdapter{name: "first", log: &log}) reg.Register(&failingLifecycleAdapter{name: "second", log: &log}) ctx := context.Background() if err := reg.Start(ctx); err == nil { t.Fatal("expected Start to return error") } want := []string{"start:first", "start:second", "stop:first"} if len(log) != len(want) { t.Fatalf("expected log %v, got %v", want, log) } for i, got := range log { if got != want[i] { t.Fatalf("log[%d]: want %q, got %q", i, want[i], got) } } } func TestRegistryLifecycle_StopContinuesOnFailingAdapter(t *testing.T) { log := []string{} reg := adapters.NewRegistry() reg.Register(&lifecycleAdapter{name: "first", log: &log}) reg.Register(&failingStopAdapter{name: "second", log: &log, stopErr: fmt.Errorf("stop failed: second")}) ctx := context.Background() if err := reg.Start(ctx); err != nil { t.Fatalf("Start: %v", err) } if err := reg.Stop(ctx); err == nil { t.Fatal("expected non-nil error from Stop") } want := []string{"start:first", "start:second", "stop:second", "stop:first"} if len(log) != len(want) { t.Fatalf("expected log %v, got %v", want, log) } for i, got := range log { if got != want[i] { t.Fatalf("log[%d]: want %q, got %q", i, want[i], got) } } } func TestRegistryLifecycle_NonLifecycleAdapterSkipped(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "mock", Enabled: true}, }, }, zap.NewNop()) if err != nil { t.Fatalf("build: %v", err) } ctx := context.Background() if err := reg.Start(ctx); err != nil { t.Fatalf("Start: %v", err) } if err := reg.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } } // --- Registry namespace tests --- func TestRegistry_MultiInstanceSameType(t *testing.T) { log := []string{} reg := adapters.NewRegistry() reg.RegisterKeyed("ollama@local", "ollama", &lifecycleAdapter{name: "ollama@local", log: &log}) reg.RegisterKeyed("ollama@dgx", "ollama", &lifecycleAdapter{name: "ollama@dgx", log: &log}) a1, ok1 := reg.Get("ollama@local") a2, ok2 := reg.Get("ollama@dgx") if !ok1 || !ok2 { t.Fatalf("expected both instances to be retrievable by key; ok1=%v ok2=%v", ok1, ok2) } if a1 == a2 { t.Fatal("expected distinct adapter instances") } if a1.Name() != "ollama@local" || a2.Name() != "ollama@dgx" { t.Errorf("adapter names: got %q and %q", a1.Name(), a2.Name()) } all := reg.All() if len(all) != 2 { t.Fatalf("expected 2 adapters in All(), got %d", len(all)) } } func TestRegistry_LegacyLookupSingleInstance(t *testing.T) { reg := adapters.NewRegistry() reg.RegisterKeyed("ollama@prod", "ollama", &lifecycleAdapter{name: "ollama@prod", log: nil}) // Exact key lookup must work. a, err := reg.Lookup("ollama@prod") if err != nil { t.Fatalf("Lookup by instance key: %v", err) } if a.Name() != "ollama@prod" { t.Errorf("expected ollama@prod, got %q", a.Name()) } // Legacy type-name lookup works when there is exactly one instance. a2, err := reg.Lookup("ollama") if err != nil { t.Fatalf("legacy Lookup by type name: %v", err) } if a2.Name() != "ollama@prod" { t.Errorf("expected ollama@prod via legacy lookup, got %q", a2.Name()) } } func TestRegistry_AmbiguousLegacyLookup(t *testing.T) { log := []string{} reg := adapters.NewRegistry() reg.RegisterKeyed("ollama@local", "ollama", &lifecycleAdapter{name: "ollama@local", log: &log}) reg.RegisterKeyed("ollama@dgx", "ollama", &lifecycleAdapter{name: "ollama@dgx", log: &log}) _, err := reg.Lookup("ollama") if err == nil { t.Fatal("expected ambiguous error for type-name lookup with multiple instances") } if !strings.Contains(err.Error(), "ambiguous") { t.Errorf("expected 'ambiguous' in error, got %v", err) } if !strings.Contains(err.Error(), "instance key") { t.Errorf("expected guidance to use instance key, got %v", err) } } func TestRegistry_LifecycleOrderMultiInstance(t *testing.T) { log := []string{} reg := adapters.NewRegistry() reg.RegisterKeyed("ollama@local", "ollama", &lifecycleAdapter{name: "ollama@local", log: &log}) reg.RegisterKeyed("ollama@dgx", "ollama", &lifecycleAdapter{name: "ollama@dgx", log: &log}) reg.RegisterKeyed("cli", "cli", &lifecycleAdapter{name: "cli", log: &log}) ctx := context.Background() if err := reg.Start(ctx); err != nil { t.Fatalf("Start: %v", err) } if err := reg.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } want := []string{ "start:ollama@local", "start:ollama@dgx", "start:cli", "stop:cli", "stop:ollama@dgx", "stop:ollama@local", } if len(log) != len(want) { t.Fatalf("expected log %v, got %v", want, log) } for i, got := range log { if got != want[i] { t.Fatalf("log[%d]: want %q, got %q", i, want[i], got) } } } func TestBuildFromPayload_MultiInstanceSameType(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ { Type: "ollama", Name: "ollama@local", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "http://localhost:11434"}}, }, { Type: "ollama", Name: "ollama@dgx", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "http://192.168.1.10:11434"}}, }, }, }, zap.NewNop()) if err != nil { t.Fatalf("build from payload: %v", err) } if len(reg.All()) != 2 { t.Fatalf("expected 2 adapters, got %d", len(reg.All())) } if _, ok := reg.Get("ollama@local"); !ok { t.Error("expected ollama@local to be registered") } if _, ok := reg.Get("ollama@dgx"); !ok { t.Error("expected ollama@dgx to be registered") } // Legacy type-name lookup must fail with ambiguous error. if _, err := reg.Lookup("ollama"); err == nil { t.Error("expected ambiguous error for legacy type-name lookup") } // Exact key lookup must succeed. if _, err := reg.Lookup("ollama@local"); err != nil { t.Errorf("Lookup ollama@local: %v", err) } } func TestBuildFromPayload_OllamaMultiInstanceExecution(t *testing.T) { var ( gotModelA string gotNumCtxA int gotModelB string gotNumCtxB int ) makeOllamaServer := 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" { return } var req struct { Model string `json:"model"` Options map[string]any `json:"options"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Errorf("decode: %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") _, _ = fmt.Fprintf(w, `{"message":{"role":"assistant","content":"%s"},"done":false}`+"\n", reply) _, _ = w.Write([]byte(`{"done":true}` + "\n")) })) } serverA := makeOllamaServer(&gotModelA, &gotNumCtxA, "a") defer serverA.Close() serverB := makeOllamaServer(&gotModelB, &gotNumCtxB, "b") defer serverB.Close() reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ { Type: "ollama", Name: "ollama@local", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: serverA.URL, ContextSize: 4096}}, }, { Type: "ollama", Name: "ollama@dgx", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: serverB.URL, ContextSize: 8192}}, }, }, }, zap.NewNop()) if err != nil { t.Fatalf("build: %v", err) } adapterA, ok := reg.Get("ollama@local") if !ok { t.Fatal("expected ollama@local") } adapterB, ok := reg.Get("ollama@dgx") if !ok { t.Fatal("expected ollama@dgx") } if err := adapterA.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-a", Target: "model-a", Input: map[string]any{"prompt": "test"}, }, &testSink{}); err != nil { t.Fatalf("A execute: %v", err) } if err := adapterB.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-b", Target: "model-b", Input: map[string]any{"prompt": "test"}, }, &testSink{}); 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@local" { t.Errorf("A InstanceKey: got %q want ollama@local", capsA.InstanceKey) } capsB, _ := adapterB.Capabilities(context.Background()) if capsB.InstanceKey != "ollama@dgx" { t.Errorf("B InstanceKey: got %q want ollama@dgx", capsB.InstanceKey) } } func TestBuildFromPayload_VllmExecution(t *testing.T) { var gotModel string var gotStream bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/models" { _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) return } if r.URL.Path != "/v1/chat/completions" { t.Errorf("unexpected path: %s", r.URL.Path) return } var req struct { Model string `json:"model"` Stream bool `json:"stream"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Errorf("decode: %v", err) return } gotModel = req.Model gotStream = req.Stream w.Header().Set("Content-Type", "text/event-stream") _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") })) defer server.Close() reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ { Type: "vllm", Name: "vllm@gpu", Enabled: true, Config: &iop.AdapterConfig_Vllm{Vllm: &iop.VllmAdapterConfig{Endpoint: server.URL}}, }, }, }, zap.NewNop()) if err != nil { t.Fatalf("build: %v", err) } a, ok := reg.Get("vllm@gpu") if !ok { t.Fatal("expected vllm@gpu") } if err := a.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-vllm", Target: "llama-3", Input: map[string]any{"prompt": "hi"}, }, &testSink{}); err != nil { t.Fatalf("Execute: %v", err) } if gotModel != "llama-3" { t.Errorf("model: got %q want llama-3", gotModel) } if !gotStream { t.Error("expected stream=true") } caps, _ := a.Capabilities(context.Background()) if caps.InstanceKey != "vllm@gpu" { t.Errorf("InstanceKey: got %q want vllm@gpu", caps.InstanceKey) } } func TestBuildFromPayload_OpenAICompatTypedRegistered(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ { Type: "openai_compat", Name: "lemonade-local", Enabled: true, Config: &iop.AdapterConfig_OpenaiCompat{OpenaiCompat: &iop.OpenAICompatAdapterConfig{ Provider: "lemonade", Endpoint: "http://localhost:8000", }}, }, }, }, zap.NewNop()) if err != nil { t.Fatalf("build: %v", err) } a, ok := reg.Get("lemonade-local") if !ok { t.Fatal("expected lemonade-local registered") } if a.Name() != "openai_compat" { t.Fatalf("adapter name: got %q want openai_compat", a.Name()) } caps, _ := a.Capabilities(context.Background()) if caps.InstanceKey != "lemonade-local" { t.Errorf("InstanceKey: got %q want lemonade-local", caps.InstanceKey) } } func TestBuildFromPayload_OpenAICompatExecution(t *testing.T) { var gotAuth string var gotBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/models" { _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"lemonade-model"}]}`)) return } if r.URL.Path != "/v1/chat/completions" { t.Errorf("unexpected path: %s", r.URL.Path) return } gotAuth = r.Header.Get("Authorization") if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { t.Errorf("decode: %v", err) return } w.Header().Set("Content-Type", "text/event-stream") _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"LEMONADE_"}}]}`) _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}`) _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") })) defer server.Close() reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ { Type: "openai_compat", Name: "lemonade-local", Enabled: true, Config: &iop.AdapterConfig_OpenaiCompat{OpenaiCompat: &iop.OpenAICompatAdapterConfig{ Provider: "lemonade", Endpoint: server.URL, Headers: map[string]string{"Authorization": "Bearer secret"}, }}, }, }, }, zap.NewNop()) if err != nil { t.Fatalf("build: %v", err) } a, ok := reg.Get("lemonade-local") if !ok { t.Fatal("expected lemonade-local") } sink := &testSink{} if err := a.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-lemonade", Target: "lemonade-model", Input: map[string]any{ "messages": []any{map[string]any{"role": "user", "content": "hi"}}, "options": map[string]any{"temperature": 0.2, "max_tokens": float64(8)}, }, }, sink); err != nil { t.Fatalf("Execute: %v", err) } if gotAuth != "Bearer secret" { t.Errorf("Authorization: got %q want Bearer secret", gotAuth) } if gotBody["model"] != "lemonade-model" { t.Errorf("model: got %v want lemonade-model", gotBody["model"]) } if gotBody["stream"] != true { t.Errorf("expected stream=true, got %v", gotBody["stream"]) } if gotBody["temperature"] != 0.2 || gotBody["max_tokens"] != float64(8) { t.Errorf("options passthrough: got temperature=%v max_tokens=%v", gotBody["temperature"], gotBody["max_tokens"]) } sink.mu.Lock() defer sink.mu.Unlock() var text string var complete *noderuntime.RuntimeEvent for i := range sink.events { ev := sink.events[i] if ev.Type == noderuntime.EventTypeDelta { text += ev.Delta } if ev.Type == noderuntime.EventTypeComplete { complete = &sink.events[i] } } if text != "LEMONADE_OK" { t.Errorf("streamed text: got %q want LEMONADE_OK", text) } if complete == nil || complete.Metadata["finish_reason"] != "stop" { t.Errorf("expected complete with finish_reason stop, got %+v", complete) } }