iop/apps/node/internal/adapters/adapters_blackbox_test.go

585 lines
17 KiB
Go

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