package api import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) func resetModelProbeHooks(t *testing.T) { t.Helper() origTCPProbe := probeTCPServiceFunc origOllamaProbe := probeOllamaModelFunc origOpenAIProbe := probeOpenAICompatibleModelFunc origCommandProbe := probeCommandAvailableFunc origNow := modelProbeNowFunc resetModelProbeCache() t.Cleanup(func() { probeTCPServiceFunc = origTCPProbe probeOllamaModelFunc = origOllamaProbe probeOpenAICompatibleModelFunc = origOpenAIProbe probeCommandAvailableFunc = origCommandProbe modelProbeNowFunc = origNow resetModelProbeCache() }) } func addModelAndLoadLatest(t *testing.T, configPath string, body string) *config.ModelConfig { t.Helper() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if len(cfg.ModelList) == 0 { t.Fatal("model_list should contain the newly added model") } return cfg.ModelList[len(cfg.ModelList)-1] } func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) var mu sync.Mutex var openAIProbes []string var ollamaProbes []string var tcpProbes []string probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { mu.Lock() openAIProbes = append(openAIProbes, apiBase+"|"+modelID+"|"+apiKey) mu.Unlock() return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" && apiKey == "" } probeOllamaModelFunc = func(apiBase, modelID string) bool { mu.Lock() ollamaProbes = append(ollamaProbes, apiBase+"|"+modelID) mu.Unlock() return apiBase == "http://localhost:11434/v1" && modelID == "llama3" } probeTCPServiceFunc = func(apiBase string) bool { mu.Lock() tcpProbes = append(tcpProbes, apiBase) mu.Unlock() return apiBase == "http://127.0.0.1:4321" } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{ { ModelName: "openai-oauth", Model: "openai/gpt-5.4", AuthMethod: "oauth", }, { ModelName: "vllm-local", Model: "vllm/custom-model", APIBase: "http://127.0.0.1:8000/v1", }, { ModelName: "ollama-default", Model: "ollama/llama3", }, { ModelName: "vllm-remote", Model: "vllm/custom-model", APIBase: "https://models.example.com/v1", APIKeys: config.SimpleSecureStrings("remote-key"), }, { ModelName: "copilot-gpt-5.4", Model: "github-copilot/gpt-5.4", APIBase: "http://127.0.0.1:4321", AuthMethod: "oauth", }, } cfg.Agents.Defaults.ModelName = "openai-oauth" err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } err = json.Unmarshal(rec.Body.Bytes(), &resp) if err != nil { t.Fatalf("Unmarshal() error = %v", err) } gotAvailable := make(map[string]bool, len(resp.Models)) gotStatus := make(map[string]string, len(resp.Models)) for _, model := range resp.Models { gotAvailable[model.ModelName] = model.Available gotStatus[model.ModelName] = model.Status } if gotAvailable["openai-oauth"] { t.Fatalf("openai oauth model available = true, want false without stored credential") } if !gotAvailable["vllm-local"] { t.Fatalf("vllm local model available = false, want true when local probe succeeds") } if !gotAvailable["ollama-default"] { t.Fatalf("ollama default model available = false, want true when default local probe succeeds") } if !gotAvailable["vllm-remote"] { t.Fatalf("remote vllm model available = false, want true with api_key") } if !gotAvailable["copilot-gpt-5.4"] { t.Fatalf("copilot model available = false, want true when local bridge probe succeeds") } if gotStatus["openai-oauth"] != modelStatusUnconfigured { t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured) } if gotStatus["vllm-local"] != modelStatusAvailable { t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusAvailable) } if gotStatus["ollama-default"] != modelStatusAvailable { t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusAvailable) } if gotStatus["vllm-remote"] != modelStatusAvailable { t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusAvailable) } if gotStatus["copilot-gpt-5.4"] != modelStatusAvailable { t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusAvailable) } if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" { t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes) } if len(ollamaProbes) != 1 || ollamaProbes[0] != "http://localhost:11434/v1|llama3" { t.Fatalf("ollama probes = %#v, want default local probe", ollamaProbes) } if len(tcpProbes) != 1 || tcpProbes[0] != "http://127.0.0.1:4321" { t.Fatalf("tcp probes = %#v, want only local copilot probe", tcpProbes) } } func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "claude-oauth", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "oauth", }} cfg.Agents.Defaults.ModelName = "claude-oauth" err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } if setCredentialErr := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ AccessToken: "anthropic-token", Provider: oauthProviderAnthropic, AuthMethod: "oauth", }); setCredentialErr != nil { t.Fatalf("SetCredential() error = %v", setCredentialErr) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } err = json.Unmarshal(rec.Body.Bytes(), &resp) if err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Available { t.Fatalf("oauth model available = false, want true with stored credential") } } func TestHasModelConfiguration_OAuthWithoutMappedCredentialFallsBackToAPIKey(t *testing.T) { noKey := &config.ModelConfig{ Provider: "gemini", Model: "gemini-2.5-flash", AuthMethod: "oauth", } if hasModelConfiguration(noKey) { t.Fatal("oauth model without credential mapping and api key should be unconfigured") } withKey := &config.ModelConfig{ Provider: "gemini", Model: "gemini-2.5-flash", AuthMethod: "oauth", APIKeys: config.SimpleSecureStrings("gemini-key"), } if !hasModelConfiguration(withKey) { t.Fatal("oauth model without credential mapping should fall back to api key configuration") } } func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "gemini-flash", Provider: "antigravity", Model: "gemini-3-flash", }} err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{ AccessToken: "antigravity-token", Provider: oauthProviderGoogleAntigravity, AuthMethod: "oauth", }); err != nil { t.Fatalf("SetCredential() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Available { t.Fatal("antigravity model available = false, want true with stored credential even without auth_method") } } func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "bedrock-claude", Provider: "bedrock", Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", }} if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { t.Fatalf("SaveConfig() error = %v", saveErr) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Available { t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials") } if resp.Models[0].Status != modelStatusAvailable { t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable) } } func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) probeCommandAvailableFunc = func(command string) bool { switch command { case "claude": return false case "codex": return true default: return false } } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{ { ModelName: "claude-cli-model", Provider: "claude-cli", Model: "claude-cli", }, { ModelName: "codex-cli-model", Provider: "codex-cli", Model: "codex-cli", }, } if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { t.Fatalf("SaveConfig() error = %v", saveErr) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` ProviderOptions []providers.ModelProviderOption `json:"provider_options"` } if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { t.Fatalf("Unmarshal() error = %v", unmarshalErr) } modelsByName := make(map[string]modelResponse, len(resp.Models)) for _, model := range resp.Models { modelsByName[model.ModelName] = model } if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable { t.Fatalf( "claude-cli status = (%t, %q), want (%t, %q)", model.Available, model.Status, false, modelStatusUnreachable, ) } if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable { t.Fatalf( "codex-cli status = (%t, %q), want (%t, %q)", model.Available, model.Status, true, modelStatusAvailable, ) } optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) for _, option := range resp.ProviderOptions { optionsByID[option.ID] = option } if option, ok := optionsByID["claude-cli"]; !ok { t.Fatal("claude-cli provider option missing") } else if option.CreateAllowed { t.Fatal("claude-cli should not be creatable when the claude command is missing") } if option, ok := optionsByID["codex-cli"]; !ok { t.Fatal("codex-cli provider option missing") } else if !option.CreateAllowed { t.Fatal("codex-cli should be creatable when the codex command is available") } } func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) started := make(chan string, 2) release := make(chan struct{}) probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { started <- apiBase + "|" + modelID <-release return true } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{ { ModelName: "local-vllm-a", Model: "vllm/custom-a", APIBase: "http://127.0.0.1:8000/v1", }, { ModelName: "local-vllm-b", Model: "vllm/custom-b", APIBase: "http://127.0.0.1:8001/v1", }, } if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) recCh := make(chan *httptest.ResponseRecorder, 1) go func() { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) recCh <- rec }() for i := 0; i < 2; i++ { select { case <-started: case <-time.After(200 * time.Millisecond): t.Fatal("expected both local probes to start before the first one completed") } } close(release) rec := <-recCh if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) var gotProbe string probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { gotProbe = apiBase + "|" + modelID + "|" + apiKey return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" && apiKey == "" } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "vllm-local", Model: "vllm/custom-model", APIBase: "http://0.0.0.0:8000/v1", }} if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Available { t.Fatal("wildcard-bound local model available = false, want true after probe host normalization") } if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") } } func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { return false } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "vllm-local-down", Model: "vllm/custom-model", APIBase: "http://127.0.0.1:8000/v1", APIKeys: config.SimpleSecureStrings("test-key"), }} if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if resp.Models[0].Available { t.Fatal("unreachable local model available = true, want false") } if resp.Models[0].Status != modelStatusUnreachable { t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable) } if resp.Models[0].APIKey == "" { t.Fatal("masked API key preview should still be returned when API key is configured") } } func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) var gotProbe string probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { gotProbe = apiBase + "|" + modelID + "|" + apiKey return true } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "vllm-local", Provider: "vllm", Model: "custom-model", APIBase: "http://127.0.0.1:8000/v1", }} if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") } } func TestHandleAddModel_PersistsAPIKey(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"new-model", "model":"openai/gpt-4o-mini", "api_key":"sk-new-model-key" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if len(cfg.ModelList) != 2 { t.Fatalf("len(model_list) = %d, want 2", len(cfg.ModelList)) } added := cfg.ModelList[1] if added.ModelName != "new-model" { t.Fatalf("model_name = %q, want %q", added.ModelName, "new-model") } if added.APIKey() != "sk-new-model-key" { t.Fatalf("api_key = %q, want %q", added.APIKey(), "sk-new-model-key") } } func TestHandleAddModel_PersistsProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"nvidia-glm", "provider":"nvidia", "model":"z-ai/glm-5.1", "api_key":"nv-key" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } added := cfg.ModelList[len(cfg.ModelList)-1] if added.Provider != "nvidia" { t.Fatalf("provider = %q, want %q", added.Provider, "nvidia") } if added.Model != "z-ai/glm-5.1" { t.Fatalf("model = %q, want %q", added.Model, "z-ai/glm-5.1") } } func TestHandleListModels_ReturnsStreamingConfig(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "streaming-model", Provider: "openai", Model: "gpt-4o-mini", APIKeys: config.SimpleSecureStrings("sk-existing"), Streaming: config.ModelStreamingConfig{Enabled: true}, }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Streaming.Enabled { t.Fatal("streaming.enabled = false, want true") } } func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"bad-provider", "provider":"not-supported", "model":"gpt-4o-mini" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if !strings.Contains(rec.Body.String(), `provider "not-supported" is not supported`) { t.Fatalf("body = %q, want unsupported provider error", rec.Body.String()) } } func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"bedrock-claude", "provider":"bedrock", "model":"us.anthropic.claude-sonnet-4-20250514-v1:0" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } added := cfg.ModelList[len(cfg.ModelList)-1] if got := added.Provider; got != "bedrock" { t.Fatalf("provider = %q, want %q", got, "bedrock") } if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { t.Fatalf("model = %q, want bedrock model ID", got) } } func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "elevenlabs-asr", Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"new-model", "provider":"openai", "model":"gpt-4o-mini", "api_key":"sk-new-model-key" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if len(updated.ModelList) != 2 { t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) } if got := updated.ModelList[0].Provider; got != "elevenlabs" { t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") } if got := updated.ModelList[0].Model; got != "scribe_v1" { t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") } } func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "elevenlabs-asr", Provider: "elevenlabs", Model: "scribe_v2", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"new-model", "provider":"openai", "model":"gpt-4o-mini", "api_key":"sk-new-model-key" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "elevenlabs" { t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") } if got := updated.ModelList[0].Model; got != "scribe_v1" { t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") } } func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) probeCommandAvailableFunc = func(command string) bool { return false } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"claude-cli-model", "provider":"claude-cli", "model":"claude-cli" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if !strings.Contains(rec.Body.String(), `provider "claude-cli" is not available for new models`) { t.Fatalf("body = %q, want missing cli command error", rec.Body.String()) } } func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() added := addModelAndLoadLatest(t, configPath, `{ "model_name":"gemini-flash", "provider":"antigravity", "model":"gemini-3-flash" }`) if got := added.AuthMethod; got != "oauth" { t.Fatalf("auth_method = %q, want %q", got, "oauth") } } func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() added := addModelAndLoadLatest(t, configPath, `{ "model_name":"openai-oauth", "provider":"openai", "model":"gpt-5.4", "auth_method":"OAuth" }`) if got := added.AuthMethod; got != "oauth" { t.Fatalf("auth_method = %q, want %q", got, "oauth") } } func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"openai-gpt", "provider":"openai", "model":"openai/gpt-4o-mini", "api_key":"sk-openai" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } added := cfg.ModelList[len(cfg.ModelList)-1] if got := added.Provider; got != "openai" { t.Fatalf("provider = %q, want %q", got, "openai") } if got := added.Model; got != "openai/gpt-4o-mini" { t.Fatalf("model = %q, want %q", got, "openai/gpt-4o-mini") } } func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"new-model-headers", "model":"openai/gpt-4o-mini", "custom_headers":{"X-Source":"coding-plan","X-Agent":"openclaw"} }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if len(cfg.ModelList) != 2 { t.Fatalf("len(model_list) = %d, want 2", len(cfg.ModelList)) } added := cfg.ModelList[1] if added.CustomHeaders == nil { t.Fatal("custom_headers should not be nil") } if got := added.CustomHeaders["X-Source"]; got != "coding-plan" { t.Fatalf("custom_headers[X-Source] = %q, want %q", got, "coding-plan") } if got := added.CustomHeaders["X-Agent"]; got != "openclaw" { t.Fatalf("custom_headers[X-Agent] = %q, want %q", got, "openclaw") } } func TestHandleAddModel_PersistsToolSchemaTransform(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"new-model-transform", "model":"openai/gpt-4o-mini", "tool_schema_transform":"simple" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } added := cfg.ModelList[len(cfg.ModelList)-1] if got := added.ToolSchemaTransform; got != "simple" { t.Fatalf("tool_schema_transform = %q, want %q", got, "simple") } } func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "editable", Model: "openai/gpt-4o-mini", APIKeys: config.SimpleSecureStrings("sk-existing"), CustomHeaders: map[string]string{"X-Source": "coding-plan"}, }} err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) // Omitted custom_headers should preserve existing value. recPreserve := httptest.NewRecorder() reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"editable", "model":"openai/gpt-4o-mini" }`)) reqPreserve.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recPreserve, reqPreserve) if recPreserve.Code != http.StatusOK { t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String()) } afterPreserve, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() after preserve error = %v", err) } if got := afterPreserve.ModelList[0].CustomHeaders["X-Source"]; got != "coding-plan" { t.Fatalf("preserved custom_headers[X-Source] = %q, want %q", got, "coding-plan") } // Empty object should clear custom_headers. recClear := httptest.NewRecorder() reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"editable", "model":"openai/gpt-4o-mini", "custom_headers":{} }`)) reqClear.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recClear, reqClear) if recClear.Code != http.StatusOK { t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String()) } afterClear, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() after clear error = %v", err) } if afterClear.ModelList[0].CustomHeaders != nil { t.Fatalf("custom_headers = %#v, want nil", afterClear.ModelList[0].CustomHeaders) } } func TestHandleUpdateModel_ToolSchemaTransformPreserveAndClear(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "editable", Model: "openai/gpt-4o-mini", APIKeys: config.SimpleSecureStrings("sk-existing"), ToolSchemaTransform: "simple", }} err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) recPreserve := httptest.NewRecorder() reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"editable", "model":"openai/gpt-4o-mini" }`)) reqPreserve.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recPreserve, reqPreserve) if recPreserve.Code != http.StatusOK { t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String()) } afterPreserve, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() after preserve error = %v", err) } if got := afterPreserve.ModelList[0].ToolSchemaTransform; got != "simple" { t.Fatalf("preserved tool_schema_transform = %q, want %q", got, "simple") } recClear := httptest.NewRecorder() reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"editable", "model":"openai/gpt-4o-mini", "tool_schema_transform":"" }`)) reqClear.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recClear, reqClear) if recClear.Code != http.StatusOK { t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String()) } afterClear, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() after clear error = %v", err) } if afterClear.ModelList[0].ToolSchemaTransform != "" { t.Fatalf("tool_schema_transform = %q, want empty", afterClear.ModelList[0].ToolSchemaTransform) } } func TestHandleUpdateModel_StreamingPreserveAndChange(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "editable", Provider: "openai", Model: "gpt-4o-mini", APIKeys: config.SimpleSecureStrings("sk-existing"), Streaming: config.ModelStreamingConfig{Enabled: true}, }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) recPreserve := httptest.NewRecorder() reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"editable", "provider":"openai", "model":"gpt-4o-mini" }`)) reqPreserve.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recPreserve, reqPreserve) if recPreserve.Code != http.StatusOK { t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String()) } afterPreserve, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() after preserve error = %v", err) } if !afterPreserve.ModelList[0].Streaming.Enabled { t.Fatal("preserved streaming.enabled = false, want true") } recChange := httptest.NewRecorder() reqChange := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"editable", "provider":"openai", "model":"gpt-4o-mini", "streaming":{"enabled":false} }`)) reqChange.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recChange, reqChange) if recChange.Code != http.StatusOK { t.Fatalf("change status = %d, want %d, body=%s", recChange.Code, http.StatusOK, recChange.Body.String()) } afterChange, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() after change error = %v", err) } if afterChange.ModelList[0].Streaming.Enabled { t.Fatal("streaming.enabled = true, want false after explicit update") } } func TestHandleUpdateModel_PersistsProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "editable", Model: "gpt-4o", Provider: "openai", }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"editable", "provider":"openrouter", "model":"openai/gpt-4o" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "openrouter" { t.Fatalf("provider = %q, want %q", got, "openrouter") } } func TestHandleUpdateModel_PreservesExplicitProviderPrefixedModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "editable", Model: "gpt-4o", Provider: "openai", }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"editable", "provider":"openai", "model":"openai/gpt-5.4" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "openai" { t.Fatalf("provider = %q, want %q", got, "openai") } if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") } } func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "openrouter-auto-explicit", Provider: "openrouter", Model: "openrouter/auto", }} err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } err = json.Unmarshal(rec.Body.Bytes(), &resp) if err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if got := resp.Models[0].Provider; got != "openrouter" { t.Fatalf("provider = %q, want %q", got, "openrouter") } if got := resp.Models[0].Model; got != "openrouter/auto" { t.Fatalf("model = %q, want %q", got, "openrouter/auto") } } func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "elevenlabs-asr", Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if got := resp.Models[0].Provider; got != "elevenlabs" { t.Fatalf("provider = %q, want %q", got, "elevenlabs") } if got := resp.Models[0].Model; got != "scribe_v1" { t.Fatalf("model = %q, want %q", got, "scribe_v1") } if resp.Models[0].DefaultModelAllowed { t.Fatal("elevenlabs ASR model should not be allowed as the default chat model") } } func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "legacy-openrouter", Model: "openrouter/openai/gpt-5.4", }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) // Simulate an older client: it reads GET /api/models, ignores the new // provider field, then PUTs the visible model string back unchanged. recList := httptest.NewRecorder() reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(recList, reqList) if recList.Code != http.StatusOK { t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) } var listResp struct { Models []modelResponse `json:"models"` } if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(listResp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) } if got := listResp.Models[0].Provider; got != "openrouter" { t.Fatalf("provider = %q, want %q", got, "openrouter") } if got := listResp.Models[0].Model; got != "openai/gpt-5.4" { t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") } recUpdate := httptest.NewRecorder() reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"legacy-openrouter", "model":"openai/gpt-5.4" }`)) reqUpdate.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recUpdate, reqUpdate) if recUpdate.Code != http.StatusOK { t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "openrouter" { t.Fatalf("provider = %q, want %q", got, "openrouter") } if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") } } func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "elevenlabs-asr", Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) recList := httptest.NewRecorder() reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(recList, reqList) if recList.Code != http.StatusOK { t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) } var listResp struct { Models []modelResponse `json:"models"` } if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(listResp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) } if got := listResp.Models[0].Provider; got != "elevenlabs" { t.Fatalf("provider = %q, want %q", got, "elevenlabs") } if got := listResp.Models[0].Model; got != "scribe_v1" { t.Fatalf("model = %q, want %q", got, "scribe_v1") } recUpdate := httptest.NewRecorder() reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"elevenlabs-asr", "model":"scribe_v1", "api_base":"https://api.elevenlabs.io" }`)) reqUpdate.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recUpdate, reqUpdate) if recUpdate.Code != http.StatusOK { t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "elevenlabs" { t.Fatalf("provider = %q, want %q", got, "elevenlabs") } if got := updated.ModelList[0].Model; got != "scribe_v1" { t.Fatalf("model = %q, want %q", got, "scribe_v1") } if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") } } func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "elevenlabs-asr", Provider: "elevenlabs", Model: "scribe_v2", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) recList := httptest.NewRecorder() reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(recList, reqList) if recList.Code != http.StatusOK { t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) } var listResp struct { Models []modelResponse `json:"models"` } if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(listResp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) } if got := listResp.Models[0].Provider; got != "elevenlabs" { t.Fatalf("provider = %q, want %q", got, "elevenlabs") } if got := listResp.Models[0].Model; got != "scribe_v1" { t.Fatalf("model = %q, want %q after GET normalization", got, "scribe_v1") } recUpdate := httptest.NewRecorder() reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"elevenlabs-asr", "provider":"elevenlabs", "model":"scribe_v1", "api_base":"https://api.elevenlabs.io" }`)) reqUpdate.Header.Set("Content-Type", "application/json") mux.ServeHTTP(recUpdate, reqUpdate) if recUpdate.Code != http.StatusOK { t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "elevenlabs" { t.Fatalf("provider = %q, want %q", got, "elevenlabs") } if got := updated.ModelList[0].Model; got != "scribe_v1" { t.Fatalf("model = %q, want %q", got, "scribe_v1") } if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") } } func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "elevenlabs-asr", Provider: "elevenlabs", Model: "scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), }} cfg.Agents.Defaults.ModelName = "elevenlabs-asr" if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"elevenlabs-asr", "provider":"elevenlabs", "model":"scribe_v1", "api_base":"https://api.elevenlabs.io" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.Agents.Defaults.ModelName; got != "" { t.Fatalf("default model = %q, want cleared default", got) } } func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ "model_name":"elevenlabs-asr", "provider":"elevenlabs", "model":"scribe_v2" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if !strings.Contains(rec.Body.String(), `provider "elevenlabs" only supports model "scribe_v1"`) { t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String()) } } func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "legacy-openrouter", Model: "openrouter/openai/gpt-5.4", }} if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"legacy-openrouter", "model":"openai/gpt-5.5" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "openrouter" { t.Fatalf("provider = %q, want %q", got, "openrouter") } if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" { t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5") } } func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "legacy-openrouter", Model: "openrouter/openai/gpt-5.4", }} err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` ProviderOptions []providers.ModelProviderOption `json:"provider_options"` } if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if got := resp.Models[0].Provider; got != "openrouter" { t.Fatalf("provider = %q, want %q", got, "openrouter") } if got := resp.Models[0].Model; got != "openai/gpt-5.4" { t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") } optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) for _, option := range resp.ProviderOptions { optionsByID[option.ID] = option } if len(optionsByID) == 0 { t.Fatal("provider_options should not be empty") } if option, ok := optionsByID["openai"]; !ok { t.Fatal("openai provider option missing") } else if option.DefaultAPIBase != "https://api.openai.com/v1" { t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1") } else if !option.SupportsFetch { t.Fatal("openai provider option should report supports_fetch") } else if option.DisplayName != "OpenAI" { t.Fatalf("openai display_name = %q, want %q", option.DisplayName, "OpenAI") } else if len(option.CommonModels) == 0 { t.Fatal("openai common_models should not be empty") } if option, ok := optionsByID["anthropic"]; !ok { t.Fatal("anthropic provider option missing") } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") } if _, ok := optionsByID["azure"]; !ok { t.Fatal("azure provider option missing") } if option, ok := optionsByID["github-copilot"]; !ok { t.Fatal("github-copilot provider option missing") } else if option.DefaultAPIBase != "localhost:4321" { t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") } else if !option.Local { t.Fatal("github-copilot should be marked local") } if option, ok := optionsByID["elevenlabs"]; !ok { t.Fatal("elevenlabs provider option missing") } else { if option.DefaultAPIBase != "https://api.elevenlabs.io" { t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") } if option.DefaultModelAllowed { t.Fatal("elevenlabs should be marked as not allowed for default chat model selection") } } if option, ok := optionsByID["lmstudio"]; !ok { t.Fatal("lmstudio provider option missing") } else if !option.EmptyAPIKeyAllowed { t.Fatal("lmstudio should allow empty api keys") } if option, ok := optionsByID["gpt4free"]; !ok { t.Fatal("gpt4free provider option missing") } else { if option.DefaultAPIBase != "http://localhost:1337/v1" { t.Fatalf("gpt4free default_api_base = %q, want %q", option.DefaultAPIBase, "http://localhost:1337/v1") } if !option.EmptyAPIKeyAllowed { t.Fatal("gpt4free should allow empty api keys") } if !option.SupportsFetch { t.Fatal("gpt4free provider option should report supports_fetch") } } if option, ok := optionsByID["siliconflow"]; !ok { t.Fatal("siliconflow provider option missing") } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" { t.Fatalf( "siliconflow default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.siliconflow.cn/v1", ) } if option, ok := optionsByID["bedrock"]; !ok { t.Fatal("bedrock provider option missing") } else if !option.CreateAllowed { t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime") } if option, ok := optionsByID["antigravity"]; !ok { t.Fatal("antigravity provider option missing") } else { if option.DefaultAuthMethod != "oauth" { t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") } if !option.AuthMethodLocked { t.Fatal("antigravity auth method should be locked") } } if option, ok := optionsByID["qwen-portal"]; !ok { t.Fatal("qwen-portal provider option missing") } else if len(option.Aliases) == 0 || option.Aliases[0] != "qwen" { t.Fatalf("qwen-portal aliases = %#v, want to include qwen", option.Aliases) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "" { t.Fatalf("persisted provider = %q, want unchanged empty provider", got) } if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { t.Fatalf("persisted model = %q, want unchanged legacy model", got) } } func TestHandleListModels_ReturnsProviderField(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "nvidia-glm", Provider: "nvidia", Model: "z-ai/glm-5.1", APIKeys: config.SimpleSecureStrings("nv-key"), }} if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if got := resp.Models[0].Provider; got != "nvidia" { t.Fatalf("provider = %q, want %q", got, "nvidia") } } func TestHandleListModels_PreservesKnownProviderInCatalog(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "bedrock-claude", Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", }} if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` ProviderOptions []providers.ModelProviderOption `json:"provider_options"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if got := resp.Models[0].Provider; got != "bedrock" { t.Fatalf("provider = %q, want %q", got, "bedrock") } if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0") } foundBedrock := false for _, option := range resp.ProviderOptions { if option.ID == "bedrock" { foundBedrock = true if !option.CreateAllowed { t.Fatal("bedrock should stay creatable in provider_options") } } } if !foundBedrock { t.Fatal("bedrock should be included in provider_options for compatibility") } } func TestHandleUpdateModel_AllowsExistingBedrockProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{{ ModelName: "bedrock-claude", Provider: "bedrock", Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", APIBase: "us-west-2", }} if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { t.Fatalf("SaveConfig() error = %v", saveErr) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ "model_name":"bedrock-claude", "provider":"bedrock", "model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0", "api_base":"us-east-1" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := updated.ModelList[0].Provider; got != "bedrock" { t.Fatalf("provider = %q, want %q", got, "bedrock") } if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" { t.Fatalf("model = %q, want updated bedrock model", got) } if got := updated.ModelList[0].APIBase; got != "us-east-1" { t.Fatalf("api_base = %q, want %q", got, "us-east-1") } } func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{ { ModelName: "plain-openai", Model: "gpt-4o", }, { ModelName: "explicit-google", Provider: "google", Model: "gemini-2.5-pro", }, { ModelName: "explicit-qwen-intl", Provider: "qwen-international", Model: "qwen3-coder-plus", }, } if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 3 { t.Fatalf("len(models) = %d, want 3", len(resp.Models)) } if got := resp.Models[0].Provider; got != "openai" { t.Fatalf("provider[0] = %q, want %q", got, "openai") } if got := resp.Models[0].Model; got != "gpt-4o" { t.Fatalf("model[0] = %q, want %q", got, "gpt-4o") } if got := resp.Models[1].Provider; got != "gemini" { t.Fatalf("provider[1] = %q, want %q", got, "gemini") } if got := resp.Models[1].Model; got != "gemini-2.5-pro" { t.Fatalf("model[1] = %q, want %q", got, "gemini-2.5-pro") } if got := resp.Models[2].Provider; got != "qwen-intl" { t.Fatalf("provider[2] = %q, want %q", got, "qwen-intl") } if got := resp.Models[2].Model; got != "qwen3-coder-plus" { t.Fatalf("model[2] = %q, want %q", got, "qwen3-coder-plus") } } // TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent // model as default returns 404. This covers the case where virtual models (which are // filtered by SaveConfig) cannot be set as default. func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() // First save a valid config with a primary model cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{ {ModelName: "gpt-4", Model: "openai/gpt-4o"}, } if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } // Try to set a non-existent model (like a virtual model name) as default h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{ "model_name": "gpt-4__key_1" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) // Should return 404 because the virtual model doesn't exist in the persisted config if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "not found") { t.Fatalf("error message should mention 'not found', got: %s", rec.Body.String()) } } func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []*config.ModelConfig{ { ModelName: "elevenlabs-asr", Provider: "elevenlabs", Model: "scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), }, } if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{ "model_name": "elevenlabs-asr" }`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") { t.Fatalf("body = %q, want default chat model rejection", rec.Body.String()) } } func TestMaskAPIKey(t *testing.T) { tests := []struct { name string key string want string }{ { name: "empty key", key: "", want: "", }, { name: "short key fully masked", key: "abcd", want: "****", }, { name: "length 8 boundary fully masked", key: "12345678", want: "****", }, { name: "length 9 boundary shows last 2", key: "123456789", want: "123****89", }, { name: "length 12 boundary shows last 2", key: "abcdefghijkl", want: "abc****kl", }, { name: "length 13 boundary shows last 4", key: "abcdefghijklm", want: "abc****jklm", }, { name: "typical api key", key: "sk-1234567890abcd", want: "sk-****abcd", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := maskAPIKey(tc.key) if got != tc.want { t.Fatalf("maskAPIKey(%q) = %q, want %q", tc.key, got, tc.want) } if tc.key != "" { displayed := strings.Replace(tc.want, "****", "", 1) if len(tc.key) <= 8 { if displayed != "" { t.Fatalf("maskAPIKey(%q) displayed part = %q, want empty", tc.key, displayed) } } else { if len(displayed)*10 > len(tc.key)*6 { t.Fatalf( "maskAPIKey(%q) displayed length = %d, want at most 60%% of %d", tc.key, len(displayed), len(tc.key), ) } } } }) } } func TestFetchOpenAICompatibleModels_ResponseShapes(t *testing.T) { tests := []struct { name string response string apiKey string wantLen int wantFirst struct { id, ownedBy string } wantSecond struct { id, ownedBy string } }{ { name: "envelope shape", response: `{"data":[{"id":"gpt-4o","owned_by":"openai"},{"id":"gpt-4o-mini","owned_by":"openai"}]}`, apiKey: "test-key", wantLen: 2, wantFirst: struct { id, ownedBy string }{id: "gpt-4o", ownedBy: "openai"}, wantSecond: struct { id, ownedBy string }{id: "gpt-4o-mini", ownedBy: "openai"}, }, { name: "bare array shape", response: `[{"id":"qwen-max","owned_by":"qwen"},{"id":"qwen-plus","owned_by":"qwen"}]`, apiKey: "", wantLen: 2, wantFirst: struct { id, ownedBy string }{id: "qwen-max", ownedBy: "qwen"}, wantSecond: struct { id, ownedBy string }{id: "qwen-plus", ownedBy: "qwen"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, tt.response) })) defer srv.Close() models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", tt.apiKey) if err != nil { t.Fatalf("error = %v", err) } if len(models) != tt.wantLen { t.Fatalf("len(models) = %d, want %d", len(models), tt.wantLen) } if models[0].ID != tt.wantFirst.id || models[0].OwnedBy != tt.wantFirst.ownedBy { t.Fatalf("models[0] = %+v, want {ID:%s OwnedBy:%s}", models[0], tt.wantFirst.id, tt.wantFirst.ownedBy) } if models[1].ID != tt.wantSecond.id || models[1].OwnedBy != tt.wantSecond.ownedBy { t.Fatalf("models[1] = %+v, want {ID:%s OwnedBy:%s}", models[1], tt.wantSecond.id, tt.wantSecond.ownedBy) } }) } } func TestFetchOpenAICompatibleModels_EmptyEnvelopeReturnsEmptySlice(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"data":[]}`) })) defer srv.Close() models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k") if err != nil { t.Fatalf("error = %v", err) } if len(models) != 0 { t.Fatalf("len(models) = %d, want 0", len(models)) } } func TestFetchOpenAICompatibleModels_EmptyBareArrayReturnsEmptySlice(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `[]`) })) defer srv.Close() models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k") if err != nil { t.Fatalf("error = %v", err) } if len(models) != 0 { t.Fatalf("len(models) = %d, want 0", len(models)) } } func TestFetchOpenAICompatibleModels_UnrecognizedShape(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"models":[],"error":"unsupported"}`) })) defer srv.Close() _, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k") if err == nil { t.Fatal("error = nil, want unrecognized shape error") } if !strings.Contains(err.Error(), "unrecognized shape") { t.Fatalf("error = %q, want it to contain 'unrecognized shape'", err.Error()) } } func TestFetchOpenAICompatibleModels_FiltersEmptyIDs(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"data":[`+ `{"id":"gpt-4o","owned_by":"openai"},`+ `{"id":"","owned_by":"openai"},`+ `{"id":"gpt-4o-mini"}]}`) })) defer srv.Close() models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k") if err != nil { t.Fatalf("error = %v", err) } if len(models) != 2 { t.Fatalf("len(models) = %d, want 2 (empty IDs should be filtered)", len(models)) } if models[0].ID != "gpt-4o" { t.Fatalf("models[0].ID = %q, want %q", models[0].ID, "gpt-4o") } if models[1].ID != "gpt-4o-mini" { t.Fatalf("models[1].ID = %q, want %q", models[1].ID, "gpt-4o-mini") } } func TestFetchOpenAICompatibleModels_SetsAuthorizationHeader(t *testing.T) { var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"data":[{"id":"m1"}]}`) })) defer srv.Close() if _, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "my-secret-key"); err != nil { t.Fatalf("error = %v", err) } if gotAuth != "Bearer my-secret-key" { t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer my-secret-key") } } func TestFetchOpenAICompatibleModels_NoAuthHeaderWhenKeyEmpty(t *testing.T) { var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `[{"id":"m1"}]`) })) defer srv.Close() if _, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", ""); err != nil { t.Fatalf("error = %v", err) } if gotAuth != "" { t.Fatalf("Authorization = %q, want empty", gotAuth) } } func TestHandleFetchModels_SiliconFlowUsesOpenAICompatibleEndpoint(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() var gotPath string var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"data":[{"id":"deepseek-ai/DeepSeek-V3","owned_by":"siliconflow"}]}`) })) defer srv.Close() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models/fetch", bytes.NewBufferString(fmt.Sprintf(`{ "provider":"siliconflow", "api_key":"sk-siliconflow", "api_base":"%s" }`, srv.URL))) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if gotPath != "/models" { t.Fatalf("path = %q, want %q", gotPath, "/models") } if gotAuth != "Bearer sk-siliconflow" { t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer sk-siliconflow") } var resp struct { Models []upstreamModel `json:"models"` Total int `json:"total"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if resp.Total != 1 || len(resp.Models) != 1 { t.Fatalf("response = %+v, want one fetched model", resp) } if resp.Models[0].ID != "deepseek-ai/DeepSeek-V3" { t.Fatalf("model id = %q, want %q", resp.Models[0].ID, "deepseek-ai/DeepSeek-V3") } } func TestHandleFetchModels_ModelIndexUsesStoredKey(t *testing.T) { var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"data":[{"id":"gpt-4o","owned_by":"openai"}]}`) })) defer srv.Close() tmp := t.TempDir() oldHome := os.Getenv("PICOCLAW_HOME") t.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")) defer func() { if oldHome != "" { os.Setenv("PICOCLAW_HOME", oldHome) } else { os.Unsetenv("PICOCLAW_HOME") } }() cfg := config.DefaultConfig() cfg.ModelList = []*config.ModelConfig{ { ModelName: "my-openai", Provider: "openai", Model: "gpt-4o", APIKeys: config.SimpleSecureStrings("sk-stored-secret"), APIBase: srv.URL, }, } configPath := filepath.Join(tmp, "config.json") if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig error: %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) idx := 0 body := fmt.Sprintf(`{"provider":"openai","api_base":"%s","model_index":%d}`, srv.URL, idx) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models/fetch", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if gotAuth != "Bearer sk-stored-secret" { t.Fatalf("Authorization = %q, want stored key to be used", gotAuth) } var resp struct { Models []upstreamModel `json:"models"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal error: %v", err) } if len(resp.Models) != 1 || resp.Models[0].ID != "gpt-4o" { t.Fatalf("unexpected response: %+v", resp) } } func TestHandleFetchModels_ModelIndexProviderMismatchRejectsKey(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "" { t.Error("stored key should NOT be sent to mismatched provider") } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"data":[]}`) })) defer srv.Close() tmp := t.TempDir() t.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")) cfg := config.DefaultConfig() cfg.ModelList = []*config.ModelConfig{ { ModelName: "my-openai", Provider: "openai", Model: "gpt-4o", APIKeys: config.SimpleSecureStrings("sk-openai-secret"), APIBase: "https://api.openai.com/v1", }, } configPath := filepath.Join(tmp, "config.json") if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig error: %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) body := fmt.Sprintf(`{"provider":"siliconflow","api_base":"%s","model_index":0}`, srv.URL) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/models/fetch", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) }