385 lines
13 KiB
Go
385 lines
13 KiB
Go
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)
|
|
}
|
|
}
|