package http import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "runtime" "strings" "testing" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/crypto" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) func TestProvidersHandlerRegisterInMemoryAppliesCodexPoolDefaults(t *testing.T) { providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "openai-codex", ProviderType: store.ProviderChatGPTOAuth, APIKey: "token", Enabled: true, Settings: json.RawMessage(`{ "codex_pool": { "strategy": "round_robin", "extra_provider_names": ["codex-work"] } }`), } handler.registerInMemory(provider) runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant() error = %v", err) } codex, ok := runtimeProvider.(*providers.CodexProvider) if !ok { t.Fatalf("runtime provider = %T, want *providers.CodexProvider", runtimeProvider) } defaults := codex.RoutingDefaults() if defaults == nil { t.Fatal("RoutingDefaults() = nil, want defaults") } if defaults.Strategy != store.ChatGPTOAuthStrategyRoundRobin { t.Fatalf("Strategy = %q, want %q", defaults.Strategy, store.ChatGPTOAuthStrategyRoundRobin) } if len(defaults.ExtraProviderNames) != 1 || defaults.ExtraProviderNames[0] != "codex-work" { t.Fatalf("ExtraProviderNames = %#v, want [\"codex-work\"]", defaults.ExtraProviderNames) } } // TestProvidersHandlerRegisterInMemoryUsesDBNameForAnthropic guards the onboarding verify flow: // when an Anthropic provider is created via HTTP with a custom name, the in-memory registry // must key the provider by that DB name — not the hardcoded "anthropic" default. Otherwise // handleVerifyProvider's GetForTenant(p.TenantID, p.Name) lookup fails with "provider not registered". // See commit 7fcf0327 for the matching fix on the startup path (cmd/gateway_providers.go). func TestProvidersHandlerRegisterInMemoryUsesDBNameForAnthropic(t *testing.T) { providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "my-anthropic", ProviderType: store.ProviderAnthropicNative, APIKey: "sk-ant-test", Enabled: true, } handler.registerInMemory(provider) got, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant(%q) error = %v, want provider registered under DB name", provider.Name, err) } if got.Name() != provider.Name { t.Fatalf("Name() = %q, want %q", got.Name(), provider.Name) } // Negative: the hardcoded default "anthropic" must NOT be registered when the user chose a different name. if _, err := providerReg.GetForTenant(provider.TenantID, "anthropic"); err == nil { t.Fatal("GetForTenant(\"anthropic\") succeeded, want not-found — provider should only live under its DB name") } } func TestProvidersHandlerRegisterInMemoryMiniMaxUsesM3Default(t *testing.T) { providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "minimax", ProviderType: store.ProviderMiniMax, APIKey: "token", Enabled: true, } handler.registerInMemory(provider) runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant() error = %v", err) } if got := runtimeProvider.DefaultModel(); got != "MiniMax-M3" { t.Fatalf("DefaultModel() = %q, want MiniMax-M3", got) } openai, ok := runtimeProvider.(*providers.OpenAIProvider) if !ok { t.Fatalf("runtime provider = %T, want *providers.OpenAIProvider", runtimeProvider) } if got := openai.APIBase(); got != "https://api.minimax.io/v1" { t.Fatalf("APIBase() = %q, want MiniMax default base", got) } } func TestProvidersHandlerRegisterInMemoryMiniMaxUsesOpenAIChatCompletionsPath(t *testing.T) { var capturedPath string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []map[string]any{ { "message": map[string]string{"content": "ok"}, "finish_reason": "stop", }, }, }) })) t.Cleanup(upstream.Close) providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "minimax", ProviderType: store.ProviderMiniMax, APIBase: upstream.URL, APIKey: "token", Enabled: true, } handler.registerInMemory(provider) runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant() error = %v", err) } _, err = runtimeProvider.Chat(context.Background(), providers.ChatRequest{ Messages: []providers.Message{{Role: "user", Content: "hi"}}, }) if err != nil { t.Fatalf("Chat() error = %v", err) } if capturedPath != "/chat/completions" { t.Fatalf("captured path = %q, want /chat/completions", capturedPath) } } func TestProvidersHandlerRegisterInMemoryZaiUsesGLM52Default(t *testing.T) { for _, tt := range []struct { providerType string wantBase string }{ {providerType: store.ProviderZai, wantBase: "https://api.z.ai/api/paas/v4"}, {providerType: store.ProviderZaiCoding, wantBase: "https://api.z.ai/api/coding/paas/v4"}, } { t.Run(tt.providerType, func(t *testing.T) { providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: tt.providerType, ProviderType: tt.providerType, APIKey: "token", Enabled: true, } handler.registerInMemory(provider) runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant() error = %v", err) } if got := runtimeProvider.DefaultModel(); got != "glm-5.2" { t.Fatalf("DefaultModel() = %q, want glm-5.2", got) } openai, ok := runtimeProvider.(*providers.OpenAIProvider) if !ok { t.Fatalf("runtime provider = %T, want *providers.OpenAIProvider", runtimeProvider) } if got := openai.APIBase(); got != tt.wantBase { t.Fatalf("APIBase() = %q, want %q", got, tt.wantBase) } }) } } // TestProvidersHandlerRegisterInMemoryUsesDBNameForClaudeCLI mirrors the Anthropic guard for Claude CLI. // Custom-named CLI providers must be registered under their DB name to be locatable via verify. func TestProvidersHandlerRegisterInMemoryUsesDBNameForClaudeCLI(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("skipping on Windows: shell-script fake binary not portable") } providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") // Create a fake executable so exec.LookPath succeeds — the binary-existence guard // added in registerInMemory (parity with startup path) would otherwise skip registration // when the test runner has no real `claude` binary in PATH. fakeCLI := writeFakeClaudeBinary(t) provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "claude-max", ProviderType: store.ProviderClaudeCLI, APIBase: fakeCLI, Enabled: true, } handler.registerInMemory(provider) got, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant(%q) error = %v, want provider registered under DB name", provider.Name, err) } if got.Name() != provider.Name { t.Fatalf("Name() = %q, want %q", got.Name(), provider.Name) } // Negative: the hardcoded default "claude-cli" must NOT be registered when the user chose a different name. if _, err := providerReg.GetForTenant(provider.TenantID, "claude-cli"); err == nil { t.Fatal("GetForTenant(\"claude-cli\") succeeded, want not-found — provider should only live under its DB name") } } // TestProvidersHandlerRegisterInMemorySkipsClaudeCLIWhenBinaryMissing guards the binary-existence check // added to mirror cmd/gateway_providers.go. If the configured CLI path does not resolve via exec.LookPath, // registerInMemory must return without registering — otherwise verify would succeed on a provider that // cannot actually spawn the CLI. func TestProvidersHandlerRegisterInMemorySkipsClaudeCLIWhenBinaryMissing(t *testing.T) { providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") // Absolute path to a file that cannot exist — exec.LookPath must fail. missingPath := filepath.Join(t.TempDir(), "definitely-not-claude") provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "claude-broken", ProviderType: store.ProviderClaudeCLI, APIBase: missingPath, Enabled: true, } handler.registerInMemory(provider) if _, err := providerReg.GetForTenant(provider.TenantID, provider.Name); err == nil { t.Fatal("GetForTenant succeeded, want not-found — registration should be skipped when binary missing") } } // TestProvidersHandlerRegisterInMemoryAnthropicUsesModelRegistry verifies the HTTP onboarding path // wires the forward-compat ModelRegistry into Anthropic providers, matching the startup path behavior // (cmd/gateway_providers.go:349). Without this, model alias resolution and token counting fall back // to static defaults — affects cost accounting and forward-compat for new model IDs. func TestProvidersHandlerRegisterInMemoryAnthropicUsesModelRegistry(t *testing.T) { providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") modelReg := providers.NewInMemoryRegistry() handler.SetModelRegistry(modelReg) provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "my-anthropic", ProviderType: store.ProviderAnthropicNative, APIKey: "sk-ant-test", Enabled: true, } handler.registerInMemory(provider) got, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant() error = %v", err) } // Reflection probe: the AnthropicProvider stores its registry in an unexported "registry" field. // No public getter exists, but this wiring is small enough that a structural check is warranted — // the alternative is no coverage, and the field name is stable (see internal/providers/anthropic.go). v := reflect.ValueOf(got).Elem().FieldByName("registry") if !v.IsValid() { t.Fatal("AnthropicProvider.registry field not found — implementation drift") } // Field is an interface; .IsNil() panics on non-interface kinds, so guard. if v.Kind() == reflect.Interface && v.IsNil() { t.Fatal("AnthropicProvider.registry is nil, want ModelRegistry set via SetModelRegistry") } } func TestProvidersHandlerRegisterInMemoryDashScopeProxyKeepsCacheDetection(t *testing.T) { providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "qwen-richard", ProviderType: store.ProviderDashScope, APIBase: "https://proxy.internal/v1", APIKey: "sk-test", Enabled: true, } handler.registerInMemory(provider) got, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant() error = %v", err) } assertRuntimeProviderWrapsDashScopeSystem(t, got, "qwen3.6-plus") } func TestProvidersHandlerRegisterInMemoryBailianProxyKeepsCacheDetection(t *testing.T) { providerReg := providers.NewRegistry(nil) handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: uuid.New(), Name: "qwen-richard", ProviderType: store.ProviderBailian, APIBase: "https://proxy.internal/v1", APIKey: "sk-test", Enabled: true, } handler.registerInMemory(provider) got, err := providerReg.GetForTenant(provider.TenantID, provider.Name) if err != nil { t.Fatalf("GetForTenant() error = %v", err) } assertRuntimeProviderWrapsDashScopeSystem(t, got, "qwen3.6-plus") } func assertRuntimeProviderWrapsDashScopeSystem(t *testing.T, runtimeProvider providers.Provider, model string) { t.Helper() bodyBuilder, ok := runtimeProvider.(interface { BuildRequestBodyForTest(string, providers.ChatRequest, bool) map[string]any }) if !ok { t.Fatalf("runtime provider = %T, want BuildRequestBodyForTest", runtimeProvider) } body := bodyBuilder.BuildRequestBodyForTest(model, providers.ChatRequest{ Messages: []providers.Message{ {Role: "system", Content: "Stable prefix\n" + providers.CacheBoundaryMarker + "\nDynamic suffix"}, {Role: "user", Content: "Hi"}, }, }, false) msgs := body["messages"].([]map[string]any) if _, ok := msgs[0]["content"].([]map[string]any); !ok { t.Fatalf("system content = %T, want DashScope cache blocks", msgs[0]["content"]) } } // writeFakeClaudeBinary creates an executable stub in a temp dir so exec.LookPath resolves it. // Returns the absolute path suitable for use as APIBase on a Claude CLI provider record. func writeFakeClaudeBinary(t *testing.T) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "claude-stub") // Minimal POSIX script — registerInMemory only cares that the path resolves, not what it does. if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("write fake binary: %v", err) } return path } func setupProvidersAdminToken(t *testing.T) string { t.Helper() token := "system-admin-key" setupTestCache(t, map[string]*store.APIKeyData{ crypto.HashAPIKey(token): { ID: uuid.New(), Scopes: []string{"operator.admin"}, }, }) return token } func TestProvidersHandlerCreateRejectsIncompatibleEmbeddingDimensions(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := `{ "name": "voyage", "provider_type": "openai_compat", "api_base": "https://api.voyageai.com/v1", "enabled": true, "settings": { "embedding": { "enabled": true, "model": "voyage-4-nano", "dimensions": 2048 } } }` req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewBufferString(body)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d", w.Code, http.StatusBadRequest) } if !strings.Contains(w.Body.String(), "1536") { t.Fatalf("response body = %q, want mention of 1536", w.Body.String()) } if len(providerStore.providers) != 0 { t.Fatalf("provider store mutated on invalid create: %#v", providerStore.providers) } } func TestProvidersHandlerCreateAllows1536EmbeddingDimensions(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := `{ "name": "gemini-emb", "provider_type": "gemini_native", "api_base": "https://generativelanguage.googleapis.com/v1beta/openai", "api_key": "token", "enabled": true, "settings": { "embedding": { "enabled": true, "model": "gemini-embedding-001", "dimensions": 1536 } } }` req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewBufferString(body)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) } if len(providerStore.providers) != 1 { t.Fatalf("provider count = %d, want 1", len(providerStore.providers)) } } func TestProvidersHandlerCreateAllowsClaudeCLIExecutablePath(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := map[string]any{ "name": "claude-local", "provider_type": store.ProviderClaudeCLI, "api_base": writeFakeClaudeBinary(t), "enabled": true, } rawBody, err := json.Marshal(body) if err != nil { t.Fatalf("Marshal() error = %v", err) } req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewReader(rawBody)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) } if got := providerStore.providers["claude-local"].APIBase; got == "" || !filepath.IsAbs(got) { t.Fatalf("stored Claude CLI api_base = %q, want absolute executable path", got) } } func TestProvidersHandlerUpdateAllowsClaudeCLIExecutablePath(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, Name: "claude-local", ProviderType: store.ProviderClaudeCLI, APIBase: "claude", Enabled: true, } if err := providerStore.CreateProvider(context.Background(), provider); err != nil { t.Fatalf("CreateProvider() error = %v", err) } handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) nextPath := writeFakeClaudeBinary(t) body := map[string]any{"api_base": nextPath} rawBody, err := json.Marshal(body) if err != nil { t.Fatalf("Marshal() error = %v", err) } req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewReader(rawBody)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String()) } current, err := providerStore.GetProvider(context.Background(), provider.ID) if err != nil { t.Fatalf("GetProvider() error = %v", err) } if current.APIBase != nextPath { t.Fatalf("api_base = %q, want %q", current.APIBase, nextPath) } } // TestProvidersHandlerCreateAllowsACPExecutablePath guards the fix for issue #1481: // ACP provider api_base is an executable command/path, not a URL, and must not be // rejected by the SSRF URL validator. func TestProvidersHandlerCreateAllowsACPExecutablePath(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := map[string]any{ "name": "acp-gemini", "provider_type": store.ProviderACP, "api_base": writeFakeClaudeBinary(t), // any absolute path is acceptable "enabled": true, } rawBody, err := json.Marshal(body) if err != nil { t.Fatalf("Marshal() error = %v", err) } req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewReader(rawBody)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) } if got := providerStore.providers["acp-gemini"].APIBase; got == "" || !filepath.IsAbs(got) { t.Fatalf("stored ACP api_base = %q, want absolute executable path", got) } } // TestProvidersHandlerCreateAllowsEmptyACPBinary confirms that an empty api_base // is accepted at create time; the runtime path may fall back to config/env. func TestProvidersHandlerCreateAllowsEmptyACPBinary(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := map[string]any{ "name": "acp-empty", "provider_type": store.ProviderACP, "enabled": true, } rawBody, err := json.Marshal(body) if err != nil { t.Fatalf("Marshal() error = %v", err) } req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewReader(rawBody)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) } } // TestProvidersHandlerCreateRejectsACPURL ensures ACP api_base cannot be a URL // (it must be an executable path/command). func TestProvidersHandlerCreateRejectsACPURL(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := map[string]any{ "name": "acp-url", "provider_type": store.ProviderACP, "api_base": "http://127.0.0.1:9090", "enabled": true, } rawBody, err := json.Marshal(body) if err != nil { t.Fatalf("Marshal() error = %v", err) } req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewReader(rawBody)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String()) } } // TestProvidersHandlerUpdateAllowsACPExecutablePath guards update-time validation // for ACP providers. func TestProvidersHandlerUpdateAllowsACPExecutablePath(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, Name: "acp-local", ProviderType: store.ProviderACP, APIBase: "gemini", Enabled: true, } if err := providerStore.CreateProvider(context.Background(), provider); err != nil { t.Fatalf("CreateProvider() error = %v", err) } handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) nextPath := writeFakeClaudeBinary(t) body := map[string]any{"api_base": nextPath} rawBody, err := json.Marshal(body) if err != nil { t.Fatalf("Marshal() error = %v", err) } req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewReader(rawBody)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String()) } current, err := providerStore.GetProvider(context.Background(), provider.ID) if err != nil { t.Fatalf("GetProvider() error = %v", err) } if current.APIBase != nextPath { t.Fatalf("api_base = %q, want %q", current.APIBase, nextPath) } } // TestProvidersHandlerUpdateRejectsACPURL ensures update-time validation rejects // a URL-valued api_base for ACP providers. func TestProvidersHandlerUpdateRejectsACPURL(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, Name: "acp-local", ProviderType: store.ProviderACP, APIBase: "gemini", Enabled: true, } if err := providerStore.CreateProvider(context.Background(), provider); err != nil { t.Fatalf("CreateProvider() error = %v", err) } handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := map[string]any{"api_base": "http://127.0.0.1:9090"} rawBody, err := json.Marshal(body) if err != nil { t.Fatalf("Marshal() error = %v", err) } req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewReader(rawBody)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String()) } } func TestProvidersHandlerUpdateMarshalsSettingsForStore(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, Name: "openai", ProviderType: store.ProviderOpenAICompat, Enabled: true, Settings: json.RawMessage(`{}`), } if err := providerStore.CreateProvider(context.Background(), provider); err != nil { t.Fatalf("CreateProvider() error = %v", err) } handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := `{"settings":{"timeout_seconds":60}}` req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewBufferString(body)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String()) } current, err := providerStore.GetProvider(context.Background(), provider.ID) if err != nil { t.Fatalf("GetProvider() error = %v", err) } if got, want := string(current.Settings), `{"timeout_seconds":60}`; got != want { t.Fatalf("settings = %s, want %s", got, want) } } func TestProvidersHandlerUpdateRejectsIncompatibleEmbeddingDimensions(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() provider := &store.LLMProviderData{ BaseModel: store.BaseModel{ID: uuid.New()}, Name: "voyage", ProviderType: store.ProviderOpenAICompat, APIBase: "https://api.voyageai.com/v1", Enabled: true, Settings: json.RawMessage(`{"embedding":{"enabled":true,"model":"voyage-4-nano","dimensions":1536}}`), } if err := providerStore.CreateProvider(context.Background(), provider); err != nil { t.Fatalf("CreateProvider() error = %v", err) } handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") mux := http.NewServeMux() handler.RegisterRoutes(mux) body := `{ "settings": { "embedding": { "enabled": true, "model": "voyage-4-nano", "dimensions": 2048 } } }` req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewBufferString(body)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d", w.Code, http.StatusBadRequest) } current, err := providerStore.GetProvider(context.Background(), provider.ID) if err != nil { t.Fatalf("GetProvider() error = %v", err) } es := store.ParseEmbeddingSettings(current.Settings) if es == nil || es.Dimensions != 1536 { t.Fatalf("embedding dimensions = %+v, want 1536 preserved", es) } }