diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 870f6868..9366b9d3 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -176,12 +176,13 @@ type Agent struct { // names, never taken for one of magpie's models nor moved by what // matches the picker (RenameRefs moves the names in it). SplitSuffix func(v string) (model, suffix string, one bool) - // Prefixed, when set, is what each of magpie's values in the agent's - // fields starts with ("magpie/", its provider there): a value without - // it is a model of one of the agent's own providers, never magpie's, - // even when magpie has a provider of the same name (OpenHanako's own - // deepseek/deepseek-v4-pro, #835). - Prefixed string + // Spelled, when set, says whether a value of the agent's fields is + // spelled as one of magpie's there (prefixed: it starts with + // "magpie/", its provider in the agent): one that isn't is a model of + // one of the agent's own providers, never magpie's, even when magpie + // has a provider of the same name (OpenHanako's own + // deepseek/deepseek-v4-pro read as magpie's deepseek, #835). + Spelled func(v string) bool // detect, when set, says whether the agent is here in place of looking // for its files and binary: a distro's, probed once. detect func() bool diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 335b7665..a3749472 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -135,6 +135,30 @@ func usesMagpie(vals ...string) bool { return false } +// prefixed is an agent's Spelled whose values of magpie's are all +// magpie/, as its provider/model names them. +func prefixed(v string) bool { return strings.HasPrefix(v, magpieID+"/") } + +// openCodeSpelled is the Spelled of an OpenCode config at path: magpie's +// provider, or one of the file's own that sends to magpie's gateway (v1), +// as openCodeRef names a model there. +func openCodeSpelled(path string, v1 func() string) func(string) bool { + return func(v string) bool { + if prefixed(v) { + return true + } + p, ref, ok := strings.Cut(v, "/") + if !ok || p == "" { + return false + } + if !strings.Contains(p, ".") { + base, _ := edit.GetJSON(path, "provider."+p+".options.baseURL") + return sameGateway(base, v1()) + } + return ownGatewayProvider(path, ref, v1()) == p + } +} + // pair joins a provider field and a model field into one "provider/model" // value, which is how OpenCode already spells it and how people think of it. func pairGet(get func(string) (string, bool), pKey, mKey string) func() string { @@ -581,7 +605,7 @@ func openCodeLike(at place, id, name, icon, bin, dir, auth string, ua []string, } } return &Agent{ - ID: id, Name: name, Icon: icon, Aliases: aliases, + ID: id, Name: name, Icon: icon, Aliases: aliases, Spelled: openCodeSpelled(path, at.v1), UA: ua, Bin: bin, Dir: dir, Path: path, Check: func() string { @@ -772,7 +796,7 @@ func piLike(at place, id, name, dir string) *Agent { return edit.SetJSON(modelsPath, edit.KV{Path: "providers." + magpieID, Value: magpieProviderJSONAt("pi", id, at.gw())}) } return &Agent{ - ID: id, Name: name, Icon: id, Bin: id, Dir: dir, Path: path, + ID: id, Name: name, Icon: id, Bin: id, Dir: dir, Path: path, Spelled: prefixed, Check: func() string { if p, _ := get("defaultProvider"); p != magpieID { return "" @@ -866,7 +890,7 @@ func goose(home, cfg string) *Agent { set := func(kvs ...edit.KV) error { return edit.SetYAMLTop(path, kvs...) } provider := gooseProviderPath(path) return &Agent{ - ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path, + ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path, Spelled: prefixed, UA: []string{"goose"}, Check: func() string { if p, _ := get("GOOSE_PROVIDER"); p != gooseProviderID { @@ -1125,7 +1149,7 @@ func crushAt(at place, path, data string) *Agent { } } return &Agent{ - ID: "crush", Name: "Crush", Icon: "crush", Bin: "crush", Dir: filepath.Dir(path), Path: path, + ID: "crush", Name: "Crush", Icon: "crush", Bin: "crush", Dir: filepath.Dir(path), Path: path, Spelled: prefixed, UA: []string{"crush"}, Check: func() string { large, _ := pick("models.large.provider") diff --git a/internal/agent/agy.go b/internal/agent/agy.go index 6c807076..08d9b318 100644 --- a/internal/agent/agy.go +++ b/internal/agent/agy.go @@ -98,7 +98,7 @@ func agy(home string) *Agent { return edit.SetJSON(path, edit.KV{Path: key, Value: v}) } return &Agent{ - ID: "agy", Name: "Antigravity CLI", Icon: "antigravity-color", Aliases: []string{"antigravity-cli"}, + ID: "agy", Name: "Antigravity CLI", Icon: "antigravity-color", Aliases: []string{"antigravity-cli"}, Spelled: prefixed, Bin: "agy", Dir: dir, Path: path, Launch: func() string { if v := get(); usesMagpie(v) { diff --git a/internal/agent/applied.go b/internal/agent/applied.go index f6901217..f288259c 100644 --- a/internal/agent/applied.go +++ b/internal/agent/applied.go @@ -218,7 +218,7 @@ func magpieValue(a *Agent, f Field, v string, vals map[string]string) bool { return false } v, _, one := a.split(v) - if !one || a.Prefixed != "" && !strings.HasPrefix(v, a.Prefixed) { + if !one || a.Spelled != nil && !a.Spelled(v) { return false } if isMagpie(v) { diff --git a/internal/agent/aside.go b/internal/agent/aside.go index 0c38ddbb..b49f07a5 100644 --- a/internal/agent/aside.go +++ b/internal/agent/aside.go @@ -61,7 +61,7 @@ func asideIn(at place) *Agent { return nil } a := &Agent{ - ID: "aside", Name: "Aside", Icon: "aside", Bin: "aside", Path: path, + ID: "aside", Name: "Aside", Icon: "aside", Bin: "aside", Path: path, Spelled: prefixed, // ~/.aside, the folder the installer makes, and not the account's: // ~/.aside/u/0 only appears once someone has signed in, so an Aside // that is installed and not yet used is not detected by it, and the diff --git a/internal/agent/atomcode.go b/internal/agent/atomcode.go index 95821ad6..d40fb267 100644 --- a/internal/agent/atomcode.go +++ b/internal/agent/atomcode.go @@ -108,7 +108,7 @@ func atomcode(home string) *Agent { return edit.SetTOMLTablesMatching(path, atomcodeAccountNames, atomcodeTablePrefixes, nil) } return atomic(&Agent{ - ID: "atomcode", Name: "AtomCode", Icon: "atomcode", + ID: "atomcode", Name: "AtomCode", Icon: "atomcode", Spelled: prefixed, UA: []string{"atomcode"}, Bin: "atomcode", Dir: dir, Path: path, Sync: func() error { diff --git a/internal/agent/cline.go b/internal/agent/cline.go index b9ce6789..043f482e 100644 --- a/internal/agent/cline.go +++ b/internal/agent/cline.go @@ -157,7 +157,7 @@ func cline(home string) *Agent { return "cline" } return &Agent{ - ID: "cline", Name: "Cline", Icon: "cline", Aliases: []string{"cline-cli"}, + ID: "cline", Name: "Cline", Icon: "cline", Aliases: []string{"cline-cli"}, Spelled: prefixed, UA: []string{"cline"}, Bin: "cline", Dir: dir, Path: path, Sync: func() error { diff --git a/internal/agent/commandcode.go b/internal/agent/commandcode.go index ef27565a..62fd0c00 100644 --- a/internal/agent/commandcode.go +++ b/internal/agent/commandcode.go @@ -39,7 +39,7 @@ func commandCodeIn(at place) *Agent { return edit.DelJSON(providers, "provider."+magpieID) } return &Agent{ - ID: "commandcode", Name: "Command Code", Icon: "commandcode", Aliases: []string{"command-code", "cmd"}, + ID: "commandcode", Name: "Command Code", Icon: "commandcode", Aliases: []string{"command-code", "cmd"}, Spelled: prefixed, UA: []string{"command-code", "commandcode"}, Bin: "command-code", Dir: dir, Path: path, Sync: func() error { diff --git a/internal/agent/droid.go b/internal/agent/droid.go index 93dd3bf0..edf5a518 100644 --- a/internal/agent/droid.go +++ b/internal/agent/droid.go @@ -205,7 +205,7 @@ func droidIn(at place) *Agent { return droidCustom{}, false } return &Agent{ - ID: "droid", Name: "Droid", Icon: "factory", Aliases: []string{"factory", "factory-droid"}, + ID: "droid", Name: "Droid", Icon: "factory", Aliases: []string{"factory", "factory-droid"}, Spelled: prefixed, // droid's requests to a custom model carry factory-cli/ UA: []string{"factory-cli"}, Bin: "droid", Dir: dir, Path: path, diff --git a/internal/agent/dsh.go b/internal/agent/dsh.go index 5b8b26bf..9d872f94 100644 --- a/internal/agent/dsh.go +++ b/internal/agent/dsh.go @@ -93,7 +93,7 @@ func dshAt(at place) *Agent { path = files[0] } return &Agent{ - ID: "dsh", Name: "DeepSeek Harness", Icon: "deepseek-color", Aliases: []string{"deepseek-harness"}, + ID: "dsh", Name: "DeepSeek Harness", Icon: "deepseek-color", Aliases: []string{"deepseek-harness"}, Spelled: prefixed, UA: []string{"deepseek-harness"}, Bin: "dsh", Dir: dir, Path: path, Sync: func() error { return dshSync(dir, gw()) }, diff --git a/internal/agent/empryo.go b/internal/agent/empryo.go index 04507980..e1c4ad04 100644 --- a/internal/agent/empryo.go +++ b/internal/agent/empryo.go @@ -102,7 +102,7 @@ func empryoAt(at place) *Agent { return edit.SetJSON(path, edit.KV{Path: empryoModel, Value: v}) } return atomic(&Agent{ - ID: "empryo", Name: "Empryo", Icon: "empryo-color", Aliases: []string{"soulforge"}, + ID: "empryo", Name: "Empryo", Icon: "empryo-color", Aliases: []string{"soulforge"}, Spelled: prefixed, Bin: "empryo", Dir: dir, Path: path, Notice: func() string { if Running(`(^|/)empryo( |$)`) { diff --git a/internal/agent/fx.go b/internal/agent/fx.go index 48aef621..0d6ff077 100644 --- a/internal/agent/fx.go +++ b/internal/agent/fx.go @@ -57,7 +57,7 @@ func fxIn(at place) *Agent { return edit.DelJSON(path, keys...) } return &Agent{ - ID: "fx", Name: "fx", Icon: "fx", + ID: "fx", Name: "fx", Icon: "fx", Spelled: prefixed, Bin: "fx", Dir: dir, Path: path, Sync: func() error { return syncJSON(path, "providers."+magpieID, func() any { return fxProvider(fxModels(path)[magpieID]) }) diff --git a/internal/agent/grok.go b/internal/agent/grok.go index ea285c18..d254a0e6 100644 --- a/internal/agent/grok.go +++ b/internal/agent/grok.go @@ -105,7 +105,7 @@ func grokIn(at place) *Agent { return catalog.Efforts(magpieModels("grok"), ref) } return atomic(&Agent{ - ID: "grok", Name: "Grok Build", Icon: "xai", Aliases: []string{"grok-build", "grok-cli"}, + ID: "grok", Name: "Grok Build", Icon: "xai", Aliases: []string{"grok-build", "grok-cli"}, Spelled: prefixed, UA: []string{"grok-shell", "grok-pager", "xai-grok-build"}, // grok-pager: its terminal front end Dir: dir, Path: path, Sync: func() error { diff --git a/internal/agent/hanako.go b/internal/agent/hanako.go index d94de326..a9479551 100644 --- a/internal/agent/hanako.go +++ b/internal/agent/hanako.go @@ -93,7 +93,7 @@ func hanako(home string) *Agent { Dir: dir, Path: path, // its own providers' models are provider/id as magpie's are: one // is magpie's only on magpie's provider - Prefixed: magpieID + "/", + Spelled: prefixed, Sync: func() error { cur, ok := hanakoCurrent(dir) if !ok || hanakoSame(cur, hanakoProvider()) { diff --git a/internal/agent/hermes.go b/internal/agent/hermes.go index 9852ebd6..ebd055f5 100644 --- a/internal/agent/hermes.go +++ b/internal/agent/hermes.go @@ -52,7 +52,7 @@ func hermesIn(at place) *Agent { return edit.SetYAML(path, kvs...) } return &Agent{ - ID: "hermes", Name: "Hermes Agent", Icon: "hermes", Aliases: []string{"hermes-agent"}, + ID: "hermes", Name: "Hermes Agent", Icon: "hermes", Aliases: []string{"hermes-agent"}, Spelled: prefixed, UA: []string{"hermes-agent"}, Bin: "hermes", Dir: dir, Path: path, Sync: func() error { diff --git a/internal/agent/kimi.go b/internal/agent/kimi.go index 9856f5c1..3cdf403c 100644 --- a/internal/agent/kimi.go +++ b/internal/agent/kimi.go @@ -155,7 +155,7 @@ func kimiIn(at place) *Agent { return err == nil && t != nil } return atomic(&Agent{ - ID: "kimi", Name: "Kimi Code", Icon: "kimi", Aliases: []string{"kimi-code", "kimi-cli"}, + ID: "kimi", Name: "Kimi Code", Icon: "kimi", Aliases: []string{"kimi-code", "kimi-cli"}, Spelled: prefixed, UA: []string{"kimicli"}, Bin: "kimi", Dir: dir, Path: path, Sync: func() error { diff --git a/internal/agent/minimax.go b/internal/agent/minimax.go index d632d280..c82a1e3d 100644 --- a/internal/agent/minimax.go +++ b/internal/agent/minimax.go @@ -76,7 +76,7 @@ func miniMaxAt(at place, dir string) *Agent { return edit.SetYAML(path, kvs...) } return atomic(&Agent{ - ID: "minimax-code", Name: "MiniMax Code", Icon: "minimax-color", Aliases: []string{"mcode"}, + ID: "minimax-code", Name: "MiniMax Code", Icon: "minimax-color", Aliases: []string{"mcode"}, Spelled: prefixed, UA: []string{mcodeUA}, Bin: "mcode", Dir: dir, Path: path, Sync: func() error { diff --git a/internal/agent/morph.go b/internal/agent/morph.go index da19eb53..be9564bd 100644 --- a/internal/agent/morph.go +++ b/internal/agent/morph.go @@ -90,7 +90,7 @@ func morphIn(at place) *Agent { return 0 } return &Agent{ - ID: "morph", Name: "Mister Morph", Icon: "mistermorph-color", Aliases: []string{"mistermorph", "mister-morph"}, + ID: "morph", Name: "Mister Morph", Icon: "mistermorph-color", Aliases: []string{"mistermorph", "mister-morph"}, Spelled: prefixed, UA: []string{morphUA}, // no Bin: morph is other tools' name too; ~/.morph is made by its // first run, `morph install` or the desktop app's setup diff --git a/internal/agent/omp.go b/internal/agent/omp.go index 5c47c903..075405ad 100644 --- a/internal/agent/omp.go +++ b/internal/agent/omp.go @@ -270,7 +270,7 @@ func ompAt(at place, dir string, entry func() ompProviderEntry) *Agent { } } return &Agent{ - ID: "omp", Name: "omp", Icon: "omp", Aliases: []string{"oh-my-pi"}, + ID: "omp", Name: "omp", Icon: "omp", Aliases: []string{"oh-my-pi"}, Spelled: prefixed, UA: []string{"oh-my-pi"}, Bin: "omp", Dir: dir, Path: path, // a role's thinking level is omp's, after whichever model it is on; diff --git a/internal/agent/openchamber.go b/internal/agent/openchamber.go index 9005ecf3..3de30425 100644 --- a/internal/agent/openchamber.go +++ b/internal/agent/openchamber.go @@ -182,7 +182,7 @@ func openChamber(home, cfg string) *Agent { return edit.DelJSON(ocPath, "provider."+magpieID) } return &Agent{ - ID: "openchamber", Name: "OpenChamber", Icon: "openchamber", Aliases: []string{"chamber"}, + ID: "openchamber", Name: "OpenChamber", Icon: "openchamber", Aliases: []string{"chamber"}, Spelled: openCodeSpelled(ocPath, gatewayV1), // its model requests are its OpenCode's, and counted as OpenCode's Bin: "openchamber", Dir: s.dir, Path: s.prefs(), Notice: func() string { diff --git a/internal/agent/qoder.go b/internal/agent/qoder.go index a030d852..99c99be4 100644 --- a/internal/agent/qoder.go +++ b/internal/agent/qoder.go @@ -113,7 +113,7 @@ func qoderAt(at place, b qoderBuild) *Agent { return ok && qoderKeyed(get(slot+".apiKey"), b.id) } return &Agent{ - ID: b.id, Name: b.name, Icon: "qoder", Aliases: b.aliases, + ID: b.id, Name: b.name, Icon: "qoder", Aliases: b.aliases, Spelled: prefixed, Bin: b.bin, Dir: dir, Path: path, Sync: func() error { ref, ok := cutMagpie(get("model.name")) diff --git a/internal/agent/spelled_test.go b/internal/agent/spelled_test.go new file mode 100644 index 00000000..93bfa789 --- /dev/null +++ b/internal/agent/spelled_test.go @@ -0,0 +1,74 @@ +package agent + +import ( + "path/filepath" + "testing" +) + +// An agent that spells a model provider/model, magpie's as magpie/: +// one of its own providers named as one of magpie's (its own relay, and +// magpie's relay) is its own model, not magpie's. The row isn't connected +// on it, and Disconnect from magpie puts it back and is done, rather than +// leaving the row connected on the model it went back to (#835, OpenHanako's +// own deepseek/deepseek-v4-pro). +func TestOwnProviderNamedAsMagpies(t *testing.T) { + ids := []string{"agy", "opencode", "openchamber", "mimocode", "pi", "aside", "omo", "goose", "zed", "vscode", + "crush", "dsh", "commandcode", "fx", "omp", "hermes", "morph", "kimi", "empryo", "minimax-code", "droid", + "qoder", "qoder-cn", "grok", "atomcode", "cline"} + for _, id := range ids { + t.Run(id, func(t *testing.T) { + syncHome(t) + a, err := Find(id) + if err != nil { + t.Fatal(err) + } + f := &a.Fields[0] + if id == "cline" { + // a model of OpenRouter's, the provider Cline is on + hanakoWrite(t, a.Path, `{"version":1,"modes":{},"providers":{},"lastUsedProvider":"openrouter"}`, 0o600) + } + if err := f.Set("relay/glm-4.6"); err != nil { + t.Fatal(err) + } + if v := f.Get(); v != "relay/glm-4.6" { + t.Fatalf("own model reads %q", v) + } + if a.Wired() { + t.Fatal("its own relay/glm-4.6 reads as magpie's") + } + if err := a.Connect(); err != nil { + t.Fatal(err) + } + if !a.Wired() { + t.Fatalf("not connected after Connect: %v", a.Values()) + } + if err := a.Disconnect(); err != nil { + t.Fatal(err) + } + if a.Wired() { + t.Fatalf("still connected after Disconnect: %v", a.Values()) + } + if v := f.Get(); v != "relay/glm-4.6" { + t.Errorf("Disconnect left %q, not its own relay/glm-4.6", v) + } + }) + } +} + +// OpenCode's own provider that sends to magpie's gateway is magpie's all +// the same (openCodeRef names a model there by it). +func TestOpenCodeGatewayProviderIsMagpies(t *testing.T) { + syncHome(t) + a, err := Find("opencode") + if err != nil { + t.Fatal(err) + } + hanakoWrite(t, a.Path, `{"provider":{"relay":{"options":{"baseURL":"`+gatewayV1()+`"},"models":{"relay/glm-4.6":{}}}},"model":"relay/relay/glm-4.6"}`, 0o644) + if !a.Wired() { + t.Fatalf("a model through its own provider at the gateway isn't magpie's: %v (%s)", a.Values(), filepath.Base(a.Path)) + } + hanakoWrite(t, a.Path, `{"provider":{"relay":{"options":{"baseURL":"https://x/v1"},"models":{"glm-4.6":{}}}},"model":"relay/glm-4.6"}`, 0o644) + if a.Wired() { + t.Fatalf("its own relay elsewhere reads as magpie's: %v", a.Values()) + } +} diff --git a/internal/agent/vscode.go b/internal/agent/vscode.go index 39b1a30d..db91de52 100644 --- a/internal/agent/vscode.go +++ b/internal/agent/vscode.go @@ -89,7 +89,7 @@ func vscodeAt(dir string) *Agent { return edit.DelJSON(path, vscodeDefault) } return atomic(&Agent{ - ID: "vscode", Name: "VS Code", Icon: "vscode", Aliases: []string{"vs-code", "copilot-chat", "vscode-chat"}, + ID: "vscode", Name: "VS Code", Icon: "vscode", Aliases: []string{"vs-code", "copilot-chat", "vscode-chat"}, Spelled: prefixed, Bin: "code", Dir: dir, Path: path, UA: []string{vscodeUA}, Notice: func() string { diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index f8907f33..b496609e 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -477,7 +477,7 @@ func wslAgent(k wslKind, d distro) *Agent { // files, which starts the distro. func asleep(live *Agent, k wslKind, d distro) *Agent { started := false - a := &Agent{ID: live.ID, Name: live.Name, Icon: live.Icon, WSL: d.Name, detect: live.detect, + a := &Agent{ID: live.ID, Name: live.Name, Icon: live.Icon, WSL: d.Name, detect: live.detect, Spelled: live.Spelled, Notice: func() string { if started { return live.Notice() diff --git a/internal/agent/zed.go b/internal/agent/zed.go index be462d34..af4f926d 100644 --- a/internal/agent/zed.go +++ b/internal/agent/zed.go @@ -38,7 +38,7 @@ func zedAt(dir string) *Agent { model := pairGet(func(k string) (string, bool) { return edit.GetJSON(path, k) }, zedModel+".provider", zedModel+".model") key := "zed:" + path + ":" return atomic(&Agent{ - ID: "zed", Name: "Zed", Icon: "zed", Bin: "zed", Dir: dir, Path: path, + ID: "zed", Name: "Zed", Icon: "zed", Bin: "zed", Dir: dir, Path: path, Spelled: prefixed, UA: []string{"zed"}, Notice: func() string { if usesMagpie(model()) && Running(`(^|/)(zed|zeditor|zed-editor)( |$)`) {