package config import ( "os" "path/filepath" "strings" "testing" ) func TestResolveModel(t *testing.T) { p := &Provider{ Models: Models{ Default: "ds-chat", Sonnet: "ds-chat", Haiku: "ds-haiku", Opus: "ds-opus", Extra: map[string]string{"claude-3-5-haiku-20241022": "ds-fast"}, }, } cases := []struct { requested string want string }{ {"claude-sonnet-4-5-20250929", "ds-chat"}, {"claude-3-5-haiku-20241022", "ds-fast"}, // exact Extra match wins {"some-haiku-model", "ds-haiku"}, // keyword match {"claude-opus-4-1", "ds-opus"}, {"ds-chat", "ds-chat"}, // already an upstream name {"unknown-model", "ds-chat"}, {"", "ds-chat"}, } for _, tc := range cases { if got := p.ResolveModel(tc.requested); got != tc.want { t.Errorf("ResolveModel(%q) = %q, want %q", tc.requested, got, tc.want) } } } func TestResolveModelWithoutDefaults(t *testing.T) { p := &Provider{Models: Models{Sonnet: "kimi-k2"}} if got := p.ResolveModel("claude-3-7-sonnet"); got != "kimi-k2" { t.Errorf("sonnet request = %q, want kimi-k2", got) } // No default configured: unknown models pass through unchanged. if got := p.ResolveModel("weird-model"); got != "weird-model" { t.Errorf("unknown request = %q, want passthrough", got) } } func TestStoreRoundTrip(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, err := Load() if err != nil { t.Fatalf("Load: %v", err) } if !strings.HasPrefix(store.Config().RelayToken, "crl_") { t.Fatalf("relay token = %q, want crl_ prefix", store.Config().RelayToken) } if store.Config().Language != LanguageChinese { t.Fatalf("default language = %q", store.Config().Language) } p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test", Models{Default: "deepseek-chat", Sonnet: "deepseek-chat", Haiku: "deepseek-chat", Opus: "deepseek-chat"}, TargetClaude) if err != nil { t.Fatalf("AddProvider: %v", err) } if _, err := store.ActivateClaude(p.ID); err != nil { t.Fatalf("ActivateClaude: %v", err) } reloaded, err := Load() if err != nil { t.Fatalf("reload: %v", err) } if got := reloaded.ActiveClaude(); got == nil || got.Name != "DeepSeek" { t.Fatalf("active provider after reload = %+v", got) } if len(reloaded.Config().Providers) != 1 { t.Fatalf("providers after reload = %d", len(reloaded.Config().Providers)) } } func TestAddProviderValidation(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, _ := Load() cases := []struct { name, baseURL, apiKey string }{ {"", "https://x.com/v1", "sk"}, {"p", "", "sk"}, {"p", "ftp://x.com", "sk"}, {"p", "https://x.com/v1", ""}, } for _, tc := range cases { if _, err := store.AddProvider(tc.name, "", tc.baseURL, tc.apiKey, Models{}, TargetClaude); err == nil { t.Errorf("AddProvider(%q, %q, %q) succeeded, want error", tc.name, tc.baseURL, tc.apiKey) } } } func TestUpdateProviderKeepsKeyWhenEmpty(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, _ := Load() p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk-secret", Models{Default: "m1"}, TargetClaude) updated, err := store.UpdateProvider(p.ID, "p2", "", "https://x.com/v1/", "", Models{Default: "m2"}, TargetClaude) if err != nil { t.Fatalf("UpdateProvider: %v", err) } if updated.APIKey != "sk-secret" { t.Errorf("api key = %q, want sk-secret", updated.APIKey) } if updated.Name != "p2" || updated.Models.Default != "m2" { t.Errorf("update lost fields: %+v", updated) } if updated.BaseURL != "https://x.com/v1" { t.Errorf("base url = %q, want trailing slash trimmed", updated.BaseURL) } } func TestDeleteProviderClearsActive(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, _ := Load() p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{}, TargetClaude) store.ActivateClaude(p.ID) if err := store.DeleteProvider(p.ID); err != nil { t.Fatalf("DeleteProvider: %v", err) } if store.ActiveClaude() != nil { t.Error("active provider survived deletion") } } func TestActivateCodexRoundTrip(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, err := Load() if err != nil { t.Fatalf("Load: %v", err) } p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test", Models{Default: "deepseek-chat"}, TargetCodex) if err != nil { t.Fatalf("AddProvider: %v", err) } if _, err := store.ActivateCodex(p.ID); err != nil { t.Fatalf("ActivateCodex: %v", err) } if err := store.SetCodexPriorProvider("custom"); err != nil { t.Fatalf("SetCodexPriorProvider: %v", err) } reloaded, err := Load() if err != nil { t.Fatalf("reload: %v", err) } active := reloaded.ActiveCodex() if active == nil || active.Name != "DeepSeek" { t.Fatalf("active codex provider after reload = %+v", active) } if !active.EnabledForCodex { t.Error("enabled_for_codex not persisted") } if reloaded.Config().CodexPriorProvider != "custom" { t.Errorf("prior provider = %q, want custom", reloaded.Config().CodexPriorProvider) } if err := reloaded.ClearActiveCodex(); err != nil { t.Fatalf("ClearActiveCodex: %v", err) } if reloaded.ActiveCodex() != nil { t.Error("active codex provider survived clear") } if _, err := reloaded.ActivateCodex("p_missing"); err == nil { t.Error("ActivateCodex accepted an unknown id") } } func TestUpdateProviderKeepsEnabledFlags(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, err := Load() if err != nil { t.Fatalf("Load: %v", err) } claude, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test", Models{Default: "deepseek-chat"}, TargetClaude) if err != nil { t.Fatalf("AddProvider: %v", err) } codex, err := store.AddProvider("Kimi", "moonshot", "https://api.moonshot.cn/v1", "sk-kimi", Models{Default: "kimi-k2-0905-preview"}, TargetCodex) if err != nil { t.Fatalf("AddProvider: %v", err) } if _, err := store.ActivateClaude(claude.ID); err != nil { t.Fatalf("ActivateClaude: %v", err) } if _, err := store.ActivateCodex(codex.ID); err != nil { t.Fatalf("ActivateCodex: %v", err) } updated, err := store.UpdateProvider(claude.ID, "DeepSeek 2", "deepseek", "https://api.deepseek.com/v1", "", Models{Default: "deepseek-reasoner"}, "") if err != nil { t.Fatalf("UpdateProvider: %v", err) } if !updated.EnabledForClaude { t.Error("enabled_for_claude lost across update") } if updated.EnabledForCodex { t.Error("a Claude provider must not carry the Codex flag") } if updated.Target != TargetClaude { t.Errorf("target = %q after an update without target, want claude", updated.Target) } if updated.APIKey != "sk-test" { t.Errorf("api key = %q, want the stored key kept", updated.APIKey) } updatedCodex, err := store.UpdateProvider(codex.ID, "Kimi 2", "moonshot", "https://api.moonshot.cn/v1", "", Models{Default: "kimi-k2-0905-preview"}, "") if err != nil { t.Fatalf("UpdateProvider codex: %v", err) } if !updatedCodex.EnabledForCodex || updatedCodex.Target != TargetCodex { t.Errorf("codex provider after update = %+v", updatedCodex) } } func TestProviderTargetMigration(t *testing.T) { home := t.TempDir() t.Setenv("CROSSRELAY_HOME", home) legacy := `{ "version": 1, "relay_token": "crl_legacy", "providers": [ {"id":"p_1","name":"claude-side","base_url":"https://a.example.com/v1","api_key":"sk","models":{"default":"m"},"enabled_for_claude":true,"created_at":"2026-10-01T00:00:00Z"}, {"id":"p_2","name":"codex-side","base_url":"https://b.example.com/v1","api_key":"sk","models":{"default":"m"},"enabled_for_codex":true,"created_at":"2026-10-01T00:00:00Z"}, {"id":"p_3","name":"never-active","base_url":"https://c.example.com/v1","api_key":"sk","models":{"default":"m"},"created_at":"2026-10-01T00:00:00Z"} ], "gateway": {"port_mode": "auto"}, "language": "zh-CN" }` if err := os.WriteFile(filepath.Join(home, "config.json"), []byte(legacy), 0o600); err != nil { t.Fatalf("write legacy config: %v", err) } store, err := Load() if err != nil { t.Fatalf("Load: %v", err) } targets := map[string]string{} for _, p := range store.Config().Providers { targets[p.Name] = p.Target } if targets["claude-side"] != TargetClaude { t.Errorf("claude-side target = %q, want claude", targets["claude-side"]) } if targets["codex-side"] != TargetCodex { t.Errorf("codex-side target = %q, want codex", targets["codex-side"]) } if targets["never-active"] != TargetClaude { t.Errorf("never-active target = %q, want claude", targets["never-active"]) } } func TestProviderTargetDefaultsAndValidation(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, err := Load() if err != nil { t.Fatalf("Load: %v", err) } p, err := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{}, "") if err != nil { t.Fatalf("AddProvider: %v", err) } if p.Target != TargetClaude { t.Errorf("default target = %q, want claude", p.Target) } if _, err := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{}, "bogus"); err == nil { t.Error("AddProvider accepted an unknown target") } } func TestActivateRejectsWrongTarget(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, err := Load() if err != nil { t.Fatalf("Load: %v", err) } claude, _ := store.AddProvider("c", "", "https://x.com/v1", "sk", Models{}, TargetClaude) codex, _ := store.AddProvider("x", "", "https://x.com/v1", "sk", Models{}, TargetCodex) if _, err := store.ActivateClaude(codex.ID); err == nil { t.Error("ActivateClaude accepted a Codex provider") } if _, err := store.ActivateCodex(claude.ID); err == nil { t.Error("ActivateCodex accepted a Claude provider") } if _, err := store.ActivateClaude(claude.ID); err != nil { t.Errorf("ActivateClaude on a Claude provider: %v", err) } if _, err := store.ActivateCodex(codex.ID); err != nil { t.Errorf("ActivateCodex on a Codex provider: %v", err) } } func TestDeleteProviderClearsCodexActive(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, err := Load() if err != nil { t.Fatalf("Load: %v", err) } p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test", Models{Default: "deepseek-chat"}, TargetCodex) if err != nil { t.Fatalf("AddProvider: %v", err) } if _, err := store.ActivateCodex(p.ID); err != nil { t.Fatalf("ActivateCodex: %v", err) } if err := store.DeleteProvider(p.ID); err != nil { t.Fatalf("DeleteProvider: %v", err) } if store.ActiveCodex() != nil { t.Error("deleted provider stayed active for codex") } } func TestSetGatewayValidation(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, _ := Load() if err := store.SetGateway(Gateway{PortMode: "fixed", FixedPort: 8787}); err != nil { t.Fatalf("SetGateway: %v", err) } if err := store.SetGateway(Gateway{PortMode: "fixed", FixedPort: 0}); err == nil { t.Error("fixed port 0 accepted") } if err := store.SetGateway(Gateway{PortMode: "bogus"}); err == nil { t.Error("bogus port mode accepted") } if err := store.SetLanguage("fr"); err == nil { t.Error("unknown language accepted") } } func TestProviderProtocol(t *testing.T) { t.Setenv("CROSSRELAY_HOME", t.TempDir()) store, err := Load() if err != nil { t.Fatalf("Load: %v", err) } // 1. Default protocol when omitted should be openai_chat. p1, err := store.AddProvider("Claude-Default", "", "https://api.example.com/v1", "sk-1", Models{}, TargetClaude) if err != nil { t.Fatalf("AddProvider: %v", err) } if p1.Protocol != ProtocolOpenAIChat || p1.APIFormat != ProtocolOpenAIChat { t.Errorf("expected default protocol %q, got protocol=%q, api_format=%q", ProtocolOpenAIChat, p1.Protocol, p1.APIFormat) } // 2. Explicit protocol should be saved. p2, err := store.AddProvider("Claude-Anthropic", "", "https://api.anthropic.com/v1", "sk-2", Models{}, TargetClaude, ProtocolAnthropic) if err != nil { t.Fatalf("AddProvider with protocol: %v", err) } if p2.Protocol != ProtocolAnthropic { t.Errorf("expected protocol %q, got %q", ProtocolAnthropic, p2.Protocol) } // 3. Update protocol. updated, err := store.UpdateProvider(p2.ID, "Claude-Anthropic-Updated", "", "https://api.anthropic.com/v1", "sk-2", Models{}, TargetClaude, ProtocolOpenAIResponses) if err != nil { t.Fatalf("UpdateProvider with protocol: %v", err) } if updated.Protocol != ProtocolOpenAIResponses { t.Errorf("expected updated protocol %q, got %q", ProtocolOpenAIResponses, updated.Protocol) } // 4. Persistence round-trip. reloaded, err := Load() if err != nil { t.Fatalf("Load reloaded: %v", err) } found := reloaded.FindProvider(p2.ID) if found == nil || found.Protocol != ProtocolOpenAIResponses { t.Errorf("expected persisted protocol %q, got %+v", ProtocolOpenAIResponses, found) } }