picoclaw/web/backend/api/models_test.go
Guoguo 30938df40b
fix(web): use stored API key when fetching models for saved providers (#2910)
When editing an existing model, the edit form initializes apiKey as
empty for security. This caused "Fetch Available Models" to reject with
"please enter API Key first" even though the key is saved server-side.

Add model_index support: the frontend passes the model's index to the
backend, which looks up the stored key from config. The key never leaves
the backend. Provider and API base are validated to prevent a stored key
from being sent to an unrelated endpoint.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:51:45 +08:00

2683 lines
80 KiB
Go

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