diff --git a/internal/agent/zcode.go b/internal/agent/zcode.go index 6b887eb3..8a2e0660 100644 --- a/internal/agent/zcode.go +++ b/internal/agent/zcode.go @@ -45,6 +45,12 @@ import ( // // magpie writes both files, so an older ZCode sees its models too. A model // the user set by hand in ZCode (a manual rule) keeps what they set. +// +// magpie's gateway answers all three of ZCode's API formats at the same +// address (/v1/messages, /chat/completions, /responses), so the format the +// user picked in ZCode's provider settings is theirs: a sync keeps it in both +// files (zcodeFormat), and only a provider magpie adds new starts on +// Anthropic Messages. func zcode(home string) *Agent { dir := filepath.Join(home, ".zcode") @@ -137,7 +143,7 @@ func zcodeProviderJSON(path string, keep bool) any { if keep { base, token = zcodeKept(filepath.Dir(path)) } - return map[string]any{"name": "magpie", "kind": "anthropic", "enabled": on, "source": "custom", + return map[string]any{"name": "magpie", "kind": zcodeKinds[zcodeFormat(filepath.Dir(path))], "enabled": on, "source": "custom", "options": map[string]any{"apiKey": token, "baseURL": base}, "models": ms} } @@ -215,6 +221,73 @@ func zcodeKept(dir string) (string, string) { return zcodeAddress(was, wasKey) } +// zcodeKinds are ZCode's API formats in provider_config.json (api.type) and +// the kind each is in config.json, as ZCode maps one to the other. +var zcodeKinds = map[string]string{ + "anthropic-messages": "anthropic", + "openai-chat-completions": "openai-compatible", + "openai-responses": "openai", +} + +// zcodeFormat is the API format magpie's provider has in ZCode's files in +// dir: the one the user picked in ZCode's settings, else Anthropic Messages. +// magpie writes both files together, so where they differ the user changed +// one of them in ZCode (provider_config.json from 3.14 on, config.json +// before): the one written last has their choice, which is carried to the +// other. +func zcodeFormat(dir string) string { + rules, kind := zcodeRuleFormat(dir), "" + path := filepath.Join(dir, "config.json") + if k, _ := edit.GetJSON(path, "provider."+magpieID+".kind"); k != "" { + for f, fk := range zcodeKinds { + if fk == k { + kind = f + } + } + } + switch { + case kind == "" && rules == "": + return "anthropic-messages" + case kind == "": + return rules + case rules == "" || rules == kind: + return kind + } + a, errA := os.Stat(filepath.Join(dir, "provider_config.json")) + b, errB := os.Stat(path) + if errA == nil && errB == nil && b.ModTime().After(a.ModTime()) { + return kind + } + return rules +} + +// zcodeRuleFormat is magpie's provider's API format in provider_config.json +// in dir, or "" when it has none ZCode knows. +func zcodeRuleFormat(dir string) string { + var r struct { + Config struct { + ProviderConfigRules struct { + ProviderRules []struct { + ProviderID string `json:"providerId"` + Config struct { + API struct { + Type string `json:"type"` + } `json:"api"` + } `json:"config"` + } `json:"providerRules"` + } `json:"providerConfigRules"` + } `json:"config"` + } + b, _ := os.ReadFile(filepath.Join(dir, "provider_config.json")) + json.Unmarshal(b, &r) + for _, p := range r.Config.ProviderConfigRules.ProviderRules { + if _, ok := zcodeKinds[p.Config.API.Type]; ok && p.ProviderID == magpieID { + return p.Config.API.Type + } + } + return "" +} + // onAnotherMachine says base is an http(s) address whose host isn't this // machine's loopback. func onAnotherMachine(base string) bool { @@ -378,7 +451,7 @@ func zcodeRules(path string, on, keep bool) error { "config": map[string]any{ "group": "standard-personal", "access": map[string]any{"type": "api-key", "apiKey": token}, - "api": map[string]any{"type": "anthropic-messages", "baseUrl": base}, + "api": map[string]any{"type": zcodeFormat(filepath.Dir(path)), "baseUrl": base}, "personalModelIds": ids, "modelOrder": ids, }} // turned off in ZCode, it stays off diff --git a/internal/agent/zcode_test.go b/internal/agent/zcode_test.go index 23483685..8c6e8843 100644 --- a/internal/agent/zcode_test.go +++ b/internal/agent/zcode_test.go @@ -6,8 +6,10 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" ) @@ -390,3 +392,102 @@ func TestZCodeKeepsRemoteAddress(t *testing.T) { } } } + +// The API format the user picks for magpie's provider in ZCode's settings +// stays theirs: magpie answers all three, and a sync put Anthropic Messages +// back on every start (杰多夫 on Discord). A change made in either file is +// carried to the other; a provider magpie adds new starts on Anthropic. +func TestZCodeKeepsUserFormat(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro"}}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".zcode", "v2") + path, rules := filepath.Join(dir, "config.json"), filepath.Join(dir, "provider_config.json") + os.MkdirAll(dir, 0o755) + // what ZCode's own settings do: write the file, after magpie's write + edited := func(p string, change func(doc map[string]any)) { + b, _ := os.ReadFile(p) + doc := map[string]any{} + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + change(doc) + b, _ = json.Marshal(doc) + os.WriteFile(p, b, 0o600) + later := time.Now().Add(time.Minute) + os.Chtimes(p, later, later) + } + rule := func(doc map[string]any) map[string]any { + for _, r := range doc["config"].(map[string]any)["providerConfigRules"].(map[string]any)["providerRules"].([]any) { + if r := r.(map[string]any); r["providerId"] == "magpie" { + return r["config"].(map[string]any)["api"].(map[string]any) + } + } + t.Fatal("no magpie rule") + return nil + } + formats := func() (kind, api string) { + k, _ := edit.GetJSON(path, "provider.magpie.kind") + b, _ := os.ReadFile(rules) + doc := map[string]any{} + json.Unmarshal(b, &doc) + r := rule(doc) + if r["baseUrl"] == "" { + t.Fatalf("no address: %s", b) + } + api, _ = r["type"].(string) + return k, api + } + a := zcode(home) + if err := a.Field("provider").Set("magpie"); err != nil { + t.Fatal(err) + } + if k, api := formats(); k != "anthropic" || api != "anthropic-messages" { + t.Fatalf("new provider: %q %q", k, api) + } + + // ZCode 3.14 on writes provider_config.json + for _, c := range []struct{ api, kind string }{ + {"openai-chat-completions", "openai-compatible"}, + {"openai-responses", "openai"}, + {"anthropic-messages", "anthropic"}, + } { + edited(rules, func(doc map[string]any) { rule(doc)["type"] = c.api }) + for range 2 { // and on the next start + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if k, api := formats(); k != c.kind || api != c.api { + t.Fatalf("picked %s: kind %q, api %q", c.api, k, api) + } + } + } + + // an older ZCode writes config.json + edited(path, func(doc map[string]any) { + doc["provider"].(map[string]any)["magpie"].(map[string]any)["kind"] = "openai-compatible" + }) + later := time.Now().Add(2 * time.Minute) // past rules' edit above + os.Chtimes(path, later, later) + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if k, api := formats(); k != "openai-compatible" || api != "openai-chat-completions" { + t.Fatalf("config.json's kind: %q %q", k, api) + } + + // a format ZCode doesn't have isn't kept + edited(rules, func(doc map[string]any) { rule(doc)["type"] = "grpc" }) + edited(path, func(doc map[string]any) { doc["provider"].(map[string]any)["magpie"].(map[string]any)["kind"] = "grpc" }) + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if k, api := formats(); k != "anthropic" || api != "anthropic-messages" { + t.Fatalf("unknown format: %q %q", k, api) + } +}