diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 8cd387a0..854a16ae 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -166,7 +166,9 @@ var claudeAliases = []string{"default", "best", "opus", "sonnet", "haiku", "fabl // auto-compact window (CLAUDE_CODE_AUTO_COMPACT_WINDOW, the autoCompactWindow // setting) is only ever the smaller of its own value and this one, so this // is what tells it where a 128K or a 400K model runs out. A name marked [1m] -// is 1M whatever it says. +// is 1M whatever it says. It is the one window Claude Code knows for any +// such model (2.1.293: none is taken from a gateway), Claude Desktop's Code +// tab's too, which runs on this settings.json (claudeDesktopWindow). const claudeContextEnv = "CLAUDE_CODE_MAX_CONTEXT_TOKENS" // claudeCompactEnv is where Claude Code compacts a conversation, the smaller @@ -650,13 +652,15 @@ func claudeIn(at place) *Agent { // writeCaps says what magpie's models can do, the ones in models first; // Claude Desktop's ids too while it runs on magpie, its Code tab being // Claude Code on this settings.json + desktopOn := func() bool { + return at.id == "" && at.sys == nil && desktopWired(desktopPathsOf(desktopDirs(desktopdir.OS, at.home, os.Getenv))) + } writeCaps := func(models ...string) error { if env(claudeCapsEnv) != "" && !capsOurs() { forget(capsKey) return nil } - desktop := at.id == "" && at.sys == nil && desktopWired(desktopPathsOf(desktopDirs(desktopdir.OS, at.home, os.Getenv))) - v := claudeCapabilities(models, desktop) + v := claudeCapabilities(models, desktopOn()) if v == "" { return dropCaps() } @@ -666,6 +670,31 @@ func claudeIn(at place) *Agent { stash(map[string]string{capsKey: v}) return edit.SetJSON(path, edit.KV{Path: "env." + claudeCapsEnv, Value: v}) } + // writeWindow tells Claude Code the window of the models it runs on + // (claudeWindow), and while Claude Desktop runs on magpie, of those its + // Code tab runs it on too (claudeDesktopWindow): the least of them, as + // one value serves them all. One the user set is theirs. + writeWindow := func(main string, tiers map[string]string) error { + if env(claudeContextEnv) != "" && !windowOurs() { + forget(windowKey) + return nil + } + w := claudeWindow(main, tiers) + if desktopOn() { + if d := claudeDesktopWindow(); d > 0 && (w == 0 || d < w) { + w = d + } + } + if w <= 0 { + return dropWindow() + } + v := strconv.Itoa(w) + stash(map[string]string{windowKey: v}) + if v == env(claudeContextEnv) { + return nil + } + return edit.SetJSON(path, edit.KV{Path: "env." + claudeContextEnv, Value: v}) + } // what was last written that an open Claude Code session doesn't see: // it reads settings.json at start-up, only its env as it goes stale := "" @@ -908,15 +937,8 @@ func claudeIn(at place) *Agent { stash(map[string]string{mainKey: main, wroteAt: at.gw()}) // the new model's window replaces the old one's, when magpie knows // it; one the user set is theirs - if env(claudeContextEnv) == "" || windowOurs() { - if w := claudeWindow(main, tiers); w > 0 { - kvs = append(kvs, edit.KV{Path: "env." + claudeContextEnv, Value: strconv.Itoa(w)}) - stash(map[string]string{windowKey: strconv.Itoa(w)}) - } else if err := dropWindow(); err != nil { - return err - } - } else { - forget(windowKey) + if err := writeWindow(main, tiers); err != nil { + return err } models := []string{main} for _, t := range claudeTiers { @@ -1312,8 +1334,15 @@ func claudeIn(at place) *Agent { } } models := []string{mainModel()} + tiers := map[string]string{} for _, t := range claudeTiers { - models = append(models, env(tierEnv(t))) + tiers[t] = env(tierEnv(t)) + models = append(models, tiers[t]) + } + // the windows of the models as they are now, Claude Desktop's + // among them (#1458) + if err := writeWindow(mainModel(), tiers); err != nil { + return err } if err := writeCompact(); err != nil { return err @@ -1682,6 +1711,33 @@ func claudeWindow(main string, tiers map[string]string) int { return w } +// claudeDesktopWindow is the least window of the models Claude Desktop's +// Code tab runs Claude Code on that it takes claudeContextEnv for, 0 when +// it knows none. Desktop hands Claude Code the id it lists a model by +// (gateway.DesktopID) and nothing of its window: it reads the gateway's +// max_input_tokens only to offer a 1M entry (2.31226.1), and Claude Code +// takes no window from a gateway's /v1/models (its model catalog is +// Anthropic's alone, and even there one past 200K counts as 200K for a +// model it doesn't know). So a mythos-magpie id of a 272K model ran as +// 200K, and with the auto-compact window shown in its place, a 1M one as +// that (#1458). Left out are a model listed by its "[1m]" id, which is 1M +// whatever this says, and one named as a Claude model Claude Code knows, +// which has its own; Claude Code takes one value for all the rest, so it +// is the least, which none of them outgrows. +func claudeDesktopWindow() int { + shown, _ := provider.CatalogFor("claude-desktop") + w := 0 + for _, e := range shown { + if e.Context <= 0 || gateway.DesktopListed1M(e) || claudeName(gateway.DesktopID(e)) != "" { + continue + } + if w == 0 || e.Context < w { + w = e.Context + } + } + return w +} + // claudeOutput is the reply length to tell Claude Code for the models it // runs on (main first), 0 to leave it to Claude Code: when the main model is // a Claude one, which Claude Code knows; when a model it runs on that isn't diff --git a/internal/agent/claude_desktop_window_test.go b/internal/agent/claude_desktop_window_test.go new file mode 100644 index 00000000..11bb8611 --- /dev/null +++ b/internal/agent/claude_desktop_window_test.go @@ -0,0 +1,176 @@ +package agent + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/gateway" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" +) + +// desktopWindowSandbox is a HOME with provider v's models: gpt 272K and +// grok 256K (reasoning, so Desktop lists them by mythos-magpie ids), glm +// 1M, plain of no known window, and a Claude-named one Claude Code knows. +func desktopWindowSandbox(t *testing.T) (home, path string) { + home = t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local")) + 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: "v", Name: "V", Chat: "http://127.0.0.1:1/v1", Key: "k", + Models: []string{"gpt", "grok", "glm", "plain", "claude-opus-4-6"}}); err != nil { + t.Fatal(err) + } + if err := catalog.SaveLive("v", "http://127.0.0.1:1/v1", []catalog.Model{ + {ID: "gpt", Context: 272000}, {ID: "grok", Context: 256000}, {ID: "glm", Context: 1000000}, + {ID: "plain"}, {ID: "claude-opus-4-6", Context: 128000}, + }); err != nil { + t.Fatal(err) + } + for _, id := range []string{"v/gpt", "v/grok", "v/claude-opus-4-6"} { + if err := provider.SetModelEfforts(id, []string{"low", "medium", "high"}); err != nil { + t.Fatal(err) + } + } + path = filepath.Join(home, ".claude", "settings.json") + writeFile(t, path, `{"theme":"dark"}`) + return home, path +} + +// TestClaudeDesktopContextWindow (#1458): Claude Desktop's Code tab runs +// Claude Code on ~/.claude/settings.json with the id Desktop lists a model +// by, and hands it nothing of the model's window; Claude Code 2.1.293 takes +// a mythos-magpie id it doesn't know for 200K unless +// CLAUDE_CODE_MAX_CONTEXT_TOKENS says otherwise, so GPT 272K and Grok 256K +// showed "/ 200K". While Desktop is on magpie, magpie tells Claude Code the +// least window of Desktop's models (it takes one value for all of them), +// whatever model Claude Code itself is on, and never more than one of them +// takes; a model Claude Code knows, and one listed by its 1M id, are left +// out; the user's own value is theirs. +func TestClaudeDesktopContextWindow(t *testing.T) { + home, path := desktopWindowSandbox(t) + a := claude(home) + window := func() string { v, _ := edit.GetJSON(path, "env."+claudeContextEnv); return v } + set := func(key, v string) { + t.Helper() + if err := a.Field(key).Set(v); err != nil { + t.Fatal(err) + } + } + desktop := func(v string) { + t.Helper() + if err := claudeDesktop(home).Field("provider").Set(v); err != nil { + t.Fatal(err) + } + } + + // the ids Desktop's Code tab hands Claude Code are ones it doesn't know + for _, e := range provider.Catalog() { + if e.ID == "v/gpt" || e.ID == "v/grok" { + if id := gateway.DesktopID(e); !strings.HasPrefix(id, "mythos-magpie-") || claudeName(id) != "" { + t.Fatalf("%s is listed as %s", e.ID, id) + } + } + } + + // Claude Code on magpie on a model of no known window: nothing to + // tell it until Desktop is on magpie + set("model", "v/plain") + if window() != "" { + t.Fatalf("plain, Desktop off: %q", window()) + } + desktop("magpie") + if window() != "256000" { + t.Fatalf("Desktop on magpie, Claude Code on plain: %q, want Grok's 256000", window()) + } + // Claude Code's own model of 272K is told no more than Desktop's 256K + set("model", "v/gpt") + if window() != "256000" { + t.Fatalf("main gpt beside Desktop's grok: %q", window()) + } + // a [1m] main model, which before told nothing at all + set("model", "v/glm[1m]") + if window() != "256000" { + t.Fatalf("[1m] main model: %q", window()) + } + // a model added later reaches it with the rest of the catalog + if err := provider.Save(provider.Provider{ID: "w", Name: "W", Chat: "http://127.0.0.1:1/v1", Key: "k", Models: []string{"small"}}); err != nil { + t.Fatal(err) + } + if err := catalog.SaveLive("w", "http://127.0.0.1:1/v1", []catalog.Model{{ID: "small", Context: 128000}}); err != nil { + t.Fatal(err) + } + if err := a.Sync(); err != nil || window() != "128000" { + t.Fatalf("after w/small was added: %q %v", window(), err) + } + if err := provider.Delete("w"); err != nil { + t.Fatal(err) + } + if err := a.Sync(); err != nil || window() != "256000" { + t.Fatalf("after w/small went: %q %v", window(), err) + } + // Desktop off: Claude Code's own model's window again, or none + desktop("") + set("model", "v/gpt") + if window() != "272000" { + t.Fatalf("Desktop off, main gpt: %q", window()) + } + set("model", "claude-opus-4-8") + if window() != "" { + t.Fatalf("Desktop off, Claude's own model: %q", window()) + } + + // the user's own value is theirs, through Desktop, a switch and a Sync + if err := edit.SetJSON(path, edit.KV{Path: "env." + claudeContextEnv, Value: "64000"}); err != nil { + t.Fatal(err) + } + desktop("magpie") + set("model", "v/gpt") + if err := a.Sync(); err != nil || window() != "64000" { + t.Fatalf("the user's value was overwritten: %q %v", window(), err) + } + set("model", "") + if window() != "64000" { + t.Fatalf("the user's value was taken away: %q", window()) + } + if v, _ := edit.GetJSON(path, "theme"); v != "dark" { + t.Fatal("theme lost") + } +} + +// TestClaudeDesktopContextWindow1M: a model Desktop lists by its 1M id +// (settings.DesktopLongest) runs at 1M whatever the value says, so it adds +// nothing to it; listed plain too, it runs at the value, its own 1M when it +// is the only one. +func TestClaudeDesktopContextWindow1M(t *testing.T) { + home, path := desktopWindowSandbox(t) + // Desktop is shown glm alone (the Agents page's model list) + if err := provider.SetHiddenModels("claude-desktop", []string{"v/gpt", "v/grok", "v/plain", "v/claude-opus-4-6"}); err != nil { + t.Fatal(err) + } + a := claude(home) + window := func() string { v, _ := edit.GetJSON(path, "env."+claudeContextEnv); return v } + if err := a.Field("model").Set("v/plain"); err != nil { + t.Fatal(err) + } + if err := claudeDesktop(home).Field("provider").Set("magpie"); err != nil { + t.Fatal(err) + } + if window() != "1000000" { + t.Fatalf("glm listed plain: %q", window()) + } + s := settings.Load() + s.DesktopLongest = true + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := a.Sync(); err != nil || window() != "" { + t.Fatalf("glm listed by its 1M id alone: %q %v", window(), err) + } +} diff --git a/internal/gateway/desktop.go b/internal/gateway/desktop.go index dc30879c..8b56b105 100644 --- a/internal/gateway/desktop.go +++ b/internal/gateway/desktop.go @@ -174,7 +174,7 @@ func desktopModels(entries []provider.Entry) []map[string]any { m := modelObject(e) m["display_name"] = names[i] m["id"] = claudeLooking(e) - if longest && e.Context >= desktop1M { + if desktopListed1M(e, longest) { m["id"] = m["id"].(string) + "[1m]" m["description"] = desktop1MSaid } @@ -207,6 +207,16 @@ func desktopModels(entries []provider.Entry) []map[string]any { // that gives a session the 1M window. const desktop1M = 1_000_000 +// desktopListed1M says e is listed to Claude Desktop by its "[1m]" id, +// longest being settings.DesktopLongest. +func desktopListed1M(e provider.Entry, longest bool) bool { return longest && e.Context >= desktop1M } + +// DesktopListed1M says Claude Desktop is shown e by its "[1m]" id, which +// its Code tab hands Claude Code, so a session on it has the 1M window. +func DesktopListed1M(e provider.Entry) bool { + return desktopListed1M(e, settings.Load().DesktopLongest) +} + // desktop1MSaid is the description Claude Desktop gives the 1M entry it // adds (WLt in its app.asar, 2.7032). const desktop1MSaid = "1M context window" diff --git a/internal/gui/api.go b/internal/gui/api.go index e0297890..36bf8928 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -1191,11 +1191,17 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { return } s := settings.Load() + was := s.DesktopLongest s.DesktopLongest = in.On if err := settings.Save(s); err != nil { fail(rw, err) return } + // a model listed by its 1M id leaves the window Claude Code is + // told for Desktop's others, or joins it (#1458) + if was != in.On { + catalog.Touched() + } writeJSON(rw, settingsState()) }) // whether Codex and Claude Code are told a model's whole context window