405 lines
17 KiB
Go
405 lines
17 KiB
Go
package provider
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func sqliteFixture(t *testing.T, path string, stmts ...string) {
|
|
t.Helper()
|
|
os.MkdirAll(filepath.Dir(path), 0o700)
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
for _, s := range stmts {
|
|
if _, err := db.Exec(s); err != nil {
|
|
t.Fatalf("%s: %v", s, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func itemsOf(t *testing.T, id string) map[string]AppImport {
|
|
t.Helper()
|
|
for _, s := range ImportSources() {
|
|
if s.ID == id {
|
|
if !s.Found || s.Error != "" {
|
|
t.Fatalf("%s: found %v, %s", id, s.Found, s.Error)
|
|
}
|
|
out := map[string]AppImport{}
|
|
for _, it := range s.Items {
|
|
out[it.Ref] = it
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
t.Fatalf("no source %s", id)
|
|
return nil
|
|
}
|
|
|
|
// CC Switch keeps each agent's own settings; magpie reads them as the
|
|
// providers they point at, and adds only what the user picks.
|
|
func TestImportCCSwitch(t *testing.T) {
|
|
isolate(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home) // Windows
|
|
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) // Windows: never the real Alma
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
|
sqliteFixture(t, filepath.Join(home, ".cc-switch", "cc-switch.db"),
|
|
`CREATE TABLE providers (id TEXT, app_type TEXT, name TEXT, settings_config TEXT, website_url TEXT, category TEXT, created_at INTEGER, sort_index INTEGER, PRIMARY KEY (id, app_type))`,
|
|
`INSERT INTO providers VALUES ('official','claude','Claude Official','{"env":{}}','','official',1,0)`,
|
|
`INSERT INTO providers VALUES ('relay','claude','Some Relay','{"env":{"ANTHROPIC_BASE_URL":"https://relay.example.com","ANTHROPIC_AUTH_TOKEN":"sk-relay","ANTHROPIC_MODEL":"claude-sonnet-5"}}','https://relay.example.com','custom',2,1)`,
|
|
`INSERT INTO providers VALUES ('relay','codex','Some Relay','{"auth":{"OPENAI_API_KEY":"sk-relay"},"config":"model_provider = \"relay\"\nmodel = \"gpt-5.5\"\n\n[model_providers.relay]\nname = \"relay\"\nbase_url = \"https://relay.example.com/v1\"\nwire_api = \"responses\"\n"}','','custom',3,0)`,
|
|
`INSERT INTO providers VALUES ('ds','claude','DeepSeek','{"env":{"ANTHROPIC_BASE_URL":"https://api.deepseek.com/anthropic","ANTHROPIC_AUTH_TOKEN":"sk-ds"}}','','cn_official',4,2)`,
|
|
`INSERT INTO providers VALUES ('dial','pi','dial','{"api":"openai-completions","apiKey":"magpie","baseUrl":"http://127.0.0.1:3425/v1"}','','custom',5,0)`,
|
|
`INSERT INTO providers VALUES ('g','gemini','Gemini relay','{"env":{"GOOGLE_GEMINI_BASE_URL":"https://g.example.com","GEMINI_API_KEY":"k"}}','','custom',6,0)`,
|
|
)
|
|
// DeepSeek is already here under its preset id, with another key
|
|
if err := Save(FromPresetKey(t, "deepseek", "sk-other")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
items := itemsOf(t, "cc-switch")
|
|
if it := items["claude/official"]; it.Skip == "" {
|
|
t.Fatalf("official sign-in offered: %+v", it)
|
|
}
|
|
if it := items["pi/dial"]; it.Skip == "" {
|
|
t.Fatalf("magpie itself offered: %+v", it)
|
|
}
|
|
if it := items["gemini/g"]; it.Skip == "" {
|
|
t.Fatalf("gemini relay offered: %+v", it)
|
|
}
|
|
relay := items["claude/relay"]
|
|
if relay.Skip != "" || relay.Status != "new" || relay.Provider.Anthropic != "https://relay.example.com" || relay.Provider.Responses != "https://relay.example.com/v1" || relay.From != "Claude Code, Codex" {
|
|
t.Fatalf("relay: %+v", relay)
|
|
}
|
|
if _, ok := items["codex/relay"]; ok {
|
|
t.Fatal("the Codex half of the relay listed on its own")
|
|
}
|
|
ds := items["claude/ds"]
|
|
if ds.Status != "taken" || ds.Provider.ID != "deepseek" || ds.Provider.Preset != "deepseek" || ds.KeyOf != "deepseek" {
|
|
t.Fatalf("deepseek: %+v", ds)
|
|
}
|
|
|
|
added, err := ImportFromApps([]AppPick{{Source: "cc-switch", Ref: "claude/relay"}, {Source: "cc-switch", Ref: "claude/ds", Mode: "key"}})
|
|
if err != nil || len(added) != 2 {
|
|
t.Fatalf("import: %v %v", added, err)
|
|
}
|
|
if p, err := Find("some-relay"); err != nil || p.Key != "sk-relay" || len(p.Models) != 2 {
|
|
t.Fatalf("relay saved: %+v", p)
|
|
}
|
|
if p, _ := Find("deepseek"); p.Key != "sk-other" || len(p.Keys) != 1 || p.Keys[0].Key != "sk-ds" {
|
|
t.Fatalf("deepseek's second key: %+v", p)
|
|
}
|
|
if it := itemsOf(t, "cc-switch")["claude/ds"]; it.Status != "same" {
|
|
t.Fatalf("second key again: %+v", it)
|
|
}
|
|
// imported once, it is there already
|
|
if it := itemsOf(t, "cc-switch")["claude/relay"]; it.Status != "same" {
|
|
t.Fatalf("after import: %+v", it)
|
|
}
|
|
}
|
|
|
|
func TestImportAlma(t *testing.T) {
|
|
isolate(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home) // Windows
|
|
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) // Windows: never the real Alma
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
cfg, _ := os.UserConfigDir()
|
|
sqliteFixture(t, filepath.Join(cfg, "alma", "chat_threads.db"),
|
|
`CREATE TABLE providers (id TEXT PRIMARY KEY, name TEXT, type TEXT, api_key TEXT, models TEXT, base_url TEXT, enabled INTEGER, created_at TEXT, api_format TEXT, is_response_api INTEGER, custom_headers TEXT)`,
|
|
`INSERT INTO providers VALUES ('a1','OpenRouter','openrouter','sk-or','["anthropic/claude-sonnet-5"]',NULL,1,'1',NULL,0,NULL)`,
|
|
`INSERT INTO providers VALUES ('a2','My Proxy','custom','sk-p','[]','https://proxy.example.com/v1',0,'2','openai-chat',0,'{"X-Team":"a"}')`,
|
|
`INSERT INTO providers VALUES ('a3','Copilot','copilot','','[]',NULL,1,'3',NULL,0,NULL)`,
|
|
`INSERT INTO providers VALUES ('a4','junk','custom','x','[]','bbb',0,'4',NULL,0,NULL)`,
|
|
)
|
|
items := itemsOf(t, "alma")
|
|
if it := items["a1"]; it.Provider.Preset != "openrouter" || it.Provider.Key != "sk-or" || len(it.Provider.Models) != 1 {
|
|
t.Fatalf("openrouter: %+v", it)
|
|
}
|
|
if it := items["a2"]; it.Off == "" || it.Provider.Chat != "https://proxy.example.com/v1" || it.Provider.Headers["X-Team"] != "a" {
|
|
t.Fatalf("proxy: %+v", it)
|
|
}
|
|
if items["a3"].Skip == "" || items["a4"].Skip == "" {
|
|
t.Fatalf("sign-in or junk offered: %+v %+v", items["a3"], items["a4"])
|
|
}
|
|
}
|
|
|
|
func TestImportCodexConfig(t *testing.T) {
|
|
isolate(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home) // Windows
|
|
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) // Windows: never the real Alma
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
|
t.Setenv("CODEX_HOME", filepath.Join(home, ".codex"))
|
|
t.Setenv("OPENAI_API_KEY", "sk-unrelated")
|
|
t.Setenv("RELAY_KEY", "sk-environment")
|
|
config := codexConfigPath()
|
|
if err := os.MkdirAll(filepath.Dir(config), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data := `model_provider = "magpie"
|
|
model = "deepseek/deepseek-chat"
|
|
model_catalog_json = "magpie-models.json"
|
|
|
|
[model_providers.magpie]
|
|
base_url = "http://127.0.0.1:3448/v1"
|
|
experimental_bearer_token = "magpie"
|
|
|
|
[model_providers.deepseek]
|
|
name = "DeepSeek"
|
|
base_url = "https://relay.example.com/v1"
|
|
wire_api = "responses"
|
|
experimental_bearer_token = "sk-explicit"
|
|
|
|
[model_providers.deepseek.http_headers]
|
|
X-Org = " abc "
|
|
|
|
[model_providers."my.relay"]
|
|
base_url = "https://other.example.com/v1"
|
|
experimental_bearer_token = "sk-other"
|
|
|
|
[model_providers."my.relay".http_headers]
|
|
X-Org = "xyz"
|
|
|
|
[model_providers.openrouter]
|
|
name = "OpenRouter"
|
|
base_url = "https://openrouter.ai/api/v1"
|
|
wire_api = "chat"
|
|
experimental_bearer_token = "sk-openrouter"
|
|
|
|
[model_providers.openrouter.http_headers]
|
|
X-Org = "preset"
|
|
|
|
[model_providers.unkeyed]
|
|
base_url = "https://unkeyed.example.com/v1"
|
|
env_key = "RELAY_KEY"
|
|
|
|
[profiles."ds"]
|
|
model_provider = "deepseek"
|
|
model = "deepseek-chat"
|
|
model_catalog_json = "models.json"
|
|
|
|
[profiles.inherited]
|
|
model_provider = "deepseek"
|
|
model = "gpt-6-sol"
|
|
|
|
[profiles.magpiecatalog]
|
|
model_provider = "deepseek"
|
|
model = "gpt-6-sol"
|
|
model_catalog_json = "magpie-models.json"
|
|
`
|
|
if err := os.WriteFile(config, []byte(data), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(filepath.Dir(config), "models.json"), []byte(`{"models":[{"slug":"deepseek-chat","visibility":"list"},{"slug":"deepseek-reasoner","visibility":"list"},{"slug":"old-model","visibility":"hide"}]}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(filepath.Dir(config), "magpie-models.json"), []byte(`{"models":[{"slug":"openrouter/x-ai/grok-4.7","visibility":"list"},{"slug":"group/auto-glm-5-3","visibility":"list"}]}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items := itemsOf(t, "codex")
|
|
if len(items) != 5 || items["my.relay"].Ref != "my.relay" || items["my.relay"].Skip != "" {
|
|
t.Fatalf("provider tables: %+v", items)
|
|
}
|
|
if got := items["my.relay"].Provider.Headers["X-Org"]; got != "xyz" {
|
|
t.Fatalf("quoted provider's header = %q, want %q", got, "xyz")
|
|
}
|
|
if p := items["openrouter"].Provider; p.Preset != "openrouter" || p.Headers["X-Org"] != "preset" {
|
|
t.Fatalf("preset import should keep the preset and its headers: %+v", p)
|
|
}
|
|
if items["magpie"].Skip == "" || items["unkeyed"].Skip == "" {
|
|
t.Fatalf("gateway or env-key provider offered: %+v %+v", items["magpie"], items["unkeyed"])
|
|
}
|
|
ds := items["deepseek"]
|
|
if ds.Skip != "" || ds.Provider.Key != "sk-explicit" || ds.Provider.Responses != "https://relay.example.com/v1" || strings.Join(ds.Provider.Models, ",") != "deepseek-chat,deepseek-reasoner,gpt-6-sol" {
|
|
t.Fatalf("Codex import: %+v", ds)
|
|
}
|
|
if _, err := Find("deepseek"); err == nil {
|
|
t.Fatal("Codex config became a provider before import")
|
|
}
|
|
if added, err := ImportFromApps([]AppPick{{Source: "codex", Ref: "deepseek"}, {Source: "codex", Ref: "openrouter"}}); err != nil || len(added) != 2 {
|
|
t.Fatalf("import: %v %v", added, err)
|
|
}
|
|
if it := itemsOf(t, "codex")["deepseek"]; it.Status != "same" {
|
|
t.Fatalf("reimport after header normalization: %+v", it)
|
|
}
|
|
if err := os.Remove(config); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, err := Find("deepseek")
|
|
if err != nil || p.Key != "sk-explicit" || strings.Join(p.Models, ",") != "deepseek-chat,deepseek-reasoner,gpt-6-sol" {
|
|
t.Fatalf("imported provider did not persist: %+v %v", p, err)
|
|
}
|
|
if p.Headers["X-Org"] != "abc" {
|
|
t.Fatalf("Codex http_headers lost during import: got %q, want %q", p.Headers["X-Org"], "abc")
|
|
}
|
|
if p, err := Find("openrouter"); err != nil || p.Preset != "openrouter" || p.Headers["X-Org"] != "preset" {
|
|
t.Fatalf("preset import lost its preset or headers: %+v %v", p, err)
|
|
}
|
|
}
|
|
|
|
// One key for two Anthropic workspaces: the entry naming the other workspace
|
|
// is a provider of its own, still the preset's, under the next free id.
|
|
func TestImportPresetOtherHeaders(t *testing.T) {
|
|
isolate(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home) // Windows
|
|
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) // Windows: never the real Alma
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
|
first, _ := FromPreset("anthropic")
|
|
first.Key, first.Headers = "sk-ant", map[string]string{"anthropic-workspace-id": "ws1"}
|
|
if err := Save(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, _ := os.UserConfigDir()
|
|
sqliteFixture(t, filepath.Join(cfg, "alma", "chat_threads.db"),
|
|
`CREATE TABLE providers (id TEXT PRIMARY KEY, name TEXT, type TEXT, api_key TEXT, models TEXT, base_url TEXT, enabled INTEGER, created_at TEXT, api_format TEXT, is_response_api INTEGER, custom_headers TEXT)`,
|
|
`INSERT INTO providers VALUES ('ws2','Anthropic','anthropic','sk-ant','[]',NULL,1,'2',NULL,0,'{"anthropic-workspace-id":"ws2"}')`,
|
|
)
|
|
same, _ := imported("Anthropic", "sk-ant", endpoints{anthropic: "https://api.anthropic.com"}, nil)
|
|
same.Headers = map[string]string{"anthropic-workspace-id": " ws1 "}
|
|
if it := settle([]AppImport{{Provider: same}}, load().Providers, map[string]bool{})[0]; it.Status != "same" {
|
|
t.Fatalf("same key and headers should be the provider already here: %+v", it)
|
|
}
|
|
items := itemsOf(t, "alma")
|
|
if it := items["ws2"]; it.Status != "taken" || it.KeyOf != "" || it.Provider.Preset != "anthropic" || it.Provider.Headers["anthropic-workspace-id"] != "ws2" {
|
|
t.Fatalf("other workspace should be a provider of its own: %+v", it)
|
|
}
|
|
if added, err := ImportFromApps([]AppPick{{Source: "alma", Ref: "ws2"}}); err != nil || len(added) != 1 {
|
|
t.Fatalf("import: %v %v", added, err)
|
|
}
|
|
p, err := Find("anthropic-2")
|
|
if err != nil || p.Preset != "anthropic" || p.Headers["anthropic-workspace-id"] != "ws2" || p.Anthropic != "https://api.anthropic.com" || p.Name != "Anthropic 2" {
|
|
t.Fatalf("second Anthropic: %+v %v", p, err)
|
|
}
|
|
if p, _ := Find("anthropic"); p.Headers["anthropic-workspace-id"] != "ws1" {
|
|
t.Fatalf("first Anthropic changed: %+v", p)
|
|
}
|
|
if it := itemsOf(t, "alma")["ws2"]; it.Status != "same" {
|
|
t.Fatalf("reimport: %+v", it)
|
|
}
|
|
}
|
|
|
|
func TestImportHeadersDistinguishExistingProvider(t *testing.T) {
|
|
existing := Provider{ID: "relay", Key: "sk-shared", Responses: "https://relay.example.com/v1"}
|
|
incoming := existing
|
|
incoming.Headers = map[string]string{"X-Org": "abc"}
|
|
items := settle([]AppImport{{Provider: incoming}}, []Provider{existing}, map[string]bool{})
|
|
if items[0].Status != "taken" || items[0].KeyOf != "" {
|
|
t.Fatalf("provider with new headers should be importable separately: %+v", items[0])
|
|
}
|
|
existing.Headers = incoming.Headers
|
|
incoming.Key = "sk-other"
|
|
items = settle([]AppImport{{Provider: incoming}}, []Provider{existing}, map[string]bool{})
|
|
if items[0].KeyOf != "relay" {
|
|
t.Fatalf("provider with matching headers should accept another key: %+v", items[0])
|
|
}
|
|
}
|
|
|
|
func TestCodexImportModelsSameBasename(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home) // Windows
|
|
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) // Windows: never the real Alma
|
|
config := filepath.Join(home, "other", "config.toml")
|
|
models := []byte(`{"models":[{"slug":"custom-model","visibility":"list"}]}`)
|
|
for _, path := range []string{
|
|
filepath.Join(home, ".codex", "magpie-models.json"),
|
|
filepath.Join(home, "other", "magpie-models.json"),
|
|
} {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, models, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if got := codexImportModels(config, "../.codex/magpie-models.json"); len(got) != 0 {
|
|
t.Fatalf("magpie's own catalog was imported: %v", got)
|
|
}
|
|
if got := codexImportModels(config, "magpie-models.json"); len(got) != 1 || got[0] != "custom-model" {
|
|
t.Fatalf("user catalog with the same basename was skipped: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestClaudeConfigDirImport(t *testing.T) {
|
|
isolate(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home) // Windows
|
|
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) // Windows: never the real Alma
|
|
t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(home, "custom-claude"))
|
|
path := claudeSettingsPath()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(`{"env":{"ANTHROPIC_BASE_URL":"https://relay.example.com/anthropic","ANTHROPIC_AUTH_TOKEN":"sk-claude","ANTHROPIC_MODEL":"claude-sonnet-5"}}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items := itemsOf(t, "claude-code")
|
|
if items["settings"].Skip != "" || items["settings"].Provider.Key != "sk-claude" {
|
|
t.Fatalf("Claude custom config dir: %+v", items)
|
|
}
|
|
}
|
|
|
|
func FromPresetKey(t *testing.T, id, key string) Provider {
|
|
p, err := FromPreset(id)
|
|
if err != nil {
|
|
t.Fatalf("no preset %s", id)
|
|
}
|
|
p.Key = key
|
|
return p
|
|
}
|
|
|
|
func TestImportAppsByName(t *testing.T) {
|
|
for i := 1; i < len(appReaders); i++ {
|
|
if strings.ToLower(appReaders[i-1].name) > strings.ToLower(appReaders[i].name) {
|
|
t.Fatalf("%s listed before %s", appReaders[i-1].name, appReaders[i].name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An app with nothing to bring over lists no items, not null: the window
|
|
// reads each source's items.
|
|
func TestImportSourcesNeverNull(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home) // Windows
|
|
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) // Windows: never the real Alma
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
|
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
|
|
os.WriteFile(filepath.Join(home, ".claude", "settings.json"), []byte(`{"model": "opus"}`), 0o644)
|
|
for _, s := range ImportSources() {
|
|
b, _ := json.Marshal(s)
|
|
if s.Items == nil || strings.Contains(string(b), `"items":null`) {
|
|
t.Fatalf("%s: items is null: %s", s.ID, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSqlitePath(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
`C:/Users/me/.cc-switch/cc-switch.db`: "/C:/Users/me/.cc-switch/cc-switch.db",
|
|
"/Users/me/.cc-switch/cc-switch.db": "/Users/me/.cc-switch/cc-switch.db",
|
|
} {
|
|
if got := sqlitePath(in); got != want {
|
|
t.Errorf("sqlitePath(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|