diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 8c8a6282..2c27c130 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -16,6 +16,7 @@ import ( "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" ) // Claude Code reads its endpoint from the `env` block of settings.json. @@ -136,6 +137,13 @@ var claudeAliases = []string{"default", "best", "opus", "sonnet", "haiku", "fabl // is 1M whatever it says. const claudeContextEnv = "CLAUDE_CODE_MAX_CONTEXT_TOKENS" +// claudeCompactEnv is where Claude Code compacts a conversation, the smaller +// of it and the model's window, a [1m] one's too. magpie sets it to +// settings.WorkingWindow, so a 1M model isn't run to 1M with every turn +// sending all of it (X: Chen, turns of ~550K tokens waiting 70–90s for a +// first token); settings.FullContext leaves it out. +const claudeCompactEnv = "CLAUDE_CODE_AUTO_COMPACT_WINDOW" + // claudeCapsEnv tells Claude Code what a model it doesn't know can do, as // "=effort,xhigh_effort;=…" (a trailing * a prefix, [1m] // taken off the model first, the model lowercased, the pattern not). Run on @@ -327,6 +335,37 @@ func claudeIn(at place) *Agent { } return nil } + // the auto-compact window magpie last wrote, kept apart from the + // user's own in the same way + compactKey := at.key("claude.compact_window") + compactOurs := func() bool { + cur := env(claudeCompactEnv) + return cur != "" && cur == stashLoad()[compactKey] + } + dropCompact := func() error { + defer forget(compactKey) + if compactOurs() { + return edit.DelJSON(path, "env."+claudeCompactEnv) + } + return nil + } + // writeCompact has Claude Code compact at the working window + // (settings.WorkingWindow) on magpie, unless settings.FullContext + writeCompact := func() error { + if env(claudeCompactEnv) != "" && !compactOurs() { + forget(compactKey) + return nil + } + if settings.Load().FullContext { + return dropCompact() + } + w := strconv.Itoa(settings.WorkingWindow) + stash(map[string]string{compactKey: w}) + if env(claudeCompactEnv) == w { + return nil + } + return edit.SetJSON(path, edit.KV{Path: "env." + claudeCompactEnv, Value: w}) + } // Claude Code's /model lists what modelPicker says (2.1.287): while it // runs on magpie, that is every one of magpie's models, so the user // picks among them in Claude Code itself. One the user wrote is theirs. @@ -421,6 +460,9 @@ func claudeIn(at place) *Agent { if err := dropWindow(); err != nil { return "", err } + if err := dropCompact(); err != nil { + return "", err + } if err := dropCaps(); err != nil { return "", err } @@ -464,6 +506,9 @@ func claudeIn(at place) *Agent { if err := dropWindow(); err != nil { return err } + if err := dropCompact(); err != nil { + return err + } if err := dropCaps(); err != nil { return err } @@ -572,6 +617,9 @@ func claudeIn(at place) *Agent { m, _ := tierAt(tiers[t]) models = append(models, m) } + if err := writeCompact(); err != nil { + return err + } if err := writePicker(); err != nil { return err } @@ -879,6 +927,9 @@ func claudeIn(at place) *Agent { for _, t := range claudeTiers { models = append(models, env(tierEnv(t))) } + if err := writeCompact(); err != nil { + return err + } if err := writePicker(); err != nil { return err } diff --git a/internal/agent/claude_compact_test.go b/internal/agent/claude_compact_test.go new file mode 100644 index 00000000..907470d2 --- /dev/null +++ b/internal/agent/claude_compact_test.go @@ -0,0 +1,71 @@ +package agent + +import ( + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" +) + +// TestClaudeCompactWindow: on magpie Claude Code compacts at the working +// window (CLAUDE_CODE_AUTO_COMPACT_WINDOW), a [1m] model too, so a 1M model's +// conversation isn't run to 1M with every turn sending all of it (X: Chen); +// settings.FullContext takes it away, a value the user set is theirs, and +// Claude Code as installed has none. +func TestClaudeCompactWindow(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: "v", Name: "V", Chat: "https://example.test/v1", Key: "k", + Models: []string{"big"}}); err != nil { + t.Fatal(err) + } + if err := catalog.SaveLive("v", "https://example.test/v1", []catalog.Model{{ID: "big", Context: 1000000}}); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".claude", "settings.json") + writeFile(t, path, `{}`) + a := claude(home) + compact := func() string { v, _ := edit.GetJSON(path, "env."+claudeCompactEnv); return v } + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + + must(a.Field("model").Set("v/big[1m]")) + if compact() != "272000" { + t.Fatalf("on magpie: %q", compact()) + } + must(settings.Save(settings.Settings{FullContext: true})) + must(a.Sync()) + if compact() != "" { + t.Fatalf("full context: %q", compact()) + } + must(settings.Save(settings.Settings{})) + must(a.Sync()) + if compact() != "272000" { + t.Fatalf("back to the working window: %q", compact()) + } + must(a.Field("model").Set("")) + if compact() != "" { + t.Fatalf("Claude Code as installed: %q", compact()) + } + // the user's own is left as it is + must(edit.SetJSON(path, edit.KV{Path: "env." + claudeCompactEnv, Value: "500000"})) + must(a.Field("model").Set("v/big[1m]")) + must(a.Sync()) + if compact() != "500000" { + t.Fatalf("the user's own: %q", compact()) + } + must(a.Field("model").Set("")) + if compact() != "500000" { + t.Fatalf("reset took the user's own: %q", compact()) + } +} diff --git a/internal/codexcat/codexcat.go b/internal/codexcat/codexcat.go index c76c2025..576ff67c 100644 --- a/internal/codexcat/codexcat.go +++ b/internal/codexcat/codexcat.go @@ -83,7 +83,11 @@ func Entries(ms []catalog.Model, after int) []any { Tools []string `json:"experimental_supported_tools"` Modalities []string `json:"input_modalities"` Context *int `json:"context_window,omitempty"` - Tiers []tier `json:"service_tiers"` + // the model's whole window when Context is the working one + // (settings.Working): Codex's model_context_window may raise it + // that far, as it does OpenAI's own models' + MaxContext *int `json:"max_context_window,omitempty"` + Tiers []tier `json:"service_tiers"` // Without the search, Codex puts every MCP tool's schema (a // ChatGPT sign-in's apps' among them) in every request, 190K // tokens before the first word (#258); with it, they are named in @@ -108,6 +112,7 @@ func Entries(ms []catalog.Model, after int) []any { } own := CacheEntries() v1 := V1() + work := settings.Load() var entries []any for i, m := range ms { if raw, ok := own[strings.TrimPrefix(m.ID, "codex/")]; ok && strings.HasPrefix(m.ID, "codex/") { @@ -146,7 +151,11 @@ func Entries(ms []catalog.Model, after int) []any { e.Modalities = append(e.Modalities, "image") } if c := m.Context; c > 0 { - e.Context = &c + w := work.Working(c) + e.Context = &w + if w < c { + e.MaxContext = &c + } } e.Truncation.Mode, e.Truncation.Limit = "tokens", 10000 for _, ef := range m.Efforts { diff --git a/internal/codexcat/codexcat_test.go b/internal/codexcat/codexcat_test.go index eaa68f5e..4194024c 100644 --- a/internal/codexcat/codexcat_test.go +++ b/internal/codexcat/codexcat_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" ) // Codex's own models, routed through magpie, keep what Codex knows of them @@ -69,7 +70,8 @@ func TestCodexCatalogImages(t *testing.T) { // A model magpie describes has Codex search its MCP tools rather than send // every one in each request (#258), with its prompt, context window, and -// neither code mode nor Responses Lite. +// neither code mode nor Responses Lite. A window above the working window +// is told as that, the whole one as its max, unless settings.FullContext. func TestCodexCatalogToolSearch(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("USERPROFILE", os.Getenv("HOME")) @@ -83,9 +85,21 @@ func TestCodexCatalogToolSearch(t *testing.T) { t.Fatalf("%v", got) } e := got.Models[0] - if e["supports_search_tool"] != true || e["base_instructions"] != Prompt || e["context_window"] != float64(996147) { + if e["supports_search_tool"] != true || e["base_instructions"] != Prompt || e["context_window"] != float64(settings.WorkingWindow) || e["max_context_window"] != float64(996147) { t.Errorf("entry: %v", e) } + if err := settings.Save(settings.Settings{FullContext: true}); err != nil { + t.Fatal(err) + } + var full struct { + Models []map[string]any `json:"models"` + } + json.Unmarshal(Catalog([]catalog.Model{ + {ID: "group/auto-gemini-3-8-flash", Name: "auto", Context: 996147}, + }), &full) + if e := full.Models[0]; e["context_window"] != float64(996147) || e["max_context_window"] != nil { + t.Errorf("full context: %v", e) + } for _, off := range []string{"tool_mode", "use_responses_lite", "multi_agent_version", "model_messages"} { if v, ok := e[off]; ok { t.Errorf("%s = %v", off, v) diff --git a/internal/gui/api.go b/internal/gui/api.go index f28228d4..e00ab9ba 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -832,6 +832,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { // how agents' lists name models, set on its own for the agents to be told in.PlainNames, in.PlainOwnNames = cur.PlainNames, cur.PlainOwnNames in.CodexAgentsV1 = cur.CodexAgentsV1 + in.FullContext = cur.FullContext // set on its own (full-context below) in.CodexTitles = cur.CodexTitles // set on its own (codex-titles below) in.ChinaMirror = cur.ChinaMirror // the Plugins page's, set on its own // which Codex accounts spend a reset by themselves, set on the Usage card @@ -971,6 +972,21 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { } writeJSON(rw, settingsState()) }) + // whether Codex and Claude Code are told a model's whole context window + // or the working one (settings.FullContext): their lists are written + // again + mux.HandleFunc("POST /api/settings/full-context", func(rw http.ResponseWriter, r *http.Request) { + var in struct{ On bool } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(rw, err) + return + } + if err := provider.SetFullContext(in.On); err != nil { + fail(rw, err) + return + } + writeJSON(rw, settingsState()) + }) // where Codex's requests for a thread's title go (#705): "" as Codex // sends them, "off", or a model's id mux.HandleFunc("POST /api/settings/codex-titles", func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index e76ce329..155531d6 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -14927,6 +14927,10 @@ function renderTrayUsage(s, keep) { // magpie's other models (#141) $("#codexAgentsV1Segs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.codexAgentsV1 ? "on" : "off", (v) => writingPrefs(api("settings/codex-agents-v1", { on: v === "on" })).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); }))); + // a 1M model's whole window, or compacting at the working one (X: Chen, + // 70–90s to a first token at 550K) + $("#fullContextSegs").replaceChildren(segs([["off", t("Compact at 272K")], ["on", t("Full window")]], s.fullContext ? "on" : "off", (v) => + writingPrefs(api("settings/full-context", { on: v === "on" })).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); }))); renderCodexTitles(s); const rate = s.fx?.rate; const currencySub = $("#currencySub"); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 6b7fb2c1..af474df9 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -398,6 +398,10 @@ const I18N = { "Agents’ lists put each model’s provider after its name, or not after names you set": "写给 agent 的模型列表在模型名后带上供应商,或自定义的名称不带", "Not on names I set": "自定义名称不带供应商", "Codex subagents on any model": "Codex 子代理可用任意模型", + "Long conversations": "长对话", + "Codex and Claude Code compact a conversation at 272K tokens on a model with a larger window, as they do on OpenAI’s and Anthropic’s own; the model’s full window lets it grow further, with every turn slower and costlier": "模型窗口超过 272K 时,Codex 和 Claude Code 在 272K tokens 处压缩对话,和它们在 OpenAI、Anthropic 自家模型上一样;用模型的完整窗口则对话可以继续变长,但每一轮都更慢、更贵", + "Compact at 272K": "272K 时压缩", + "Full window": "完整窗口", "Codex thread titles": "Codex 会话标题", "Codex’s own (ChatGPT)": "Codex 自带(ChatGPT)", "as Codex sends them": "按 Codex 原样发送", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 0135bd79..16c4a0e4 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -256,6 +256,10 @@
Codex subagents on any model
Codex’s OpenAI models hand subagents their tasks as text (multi-agent V1), so a GPT lead can spawn them on magpie’s other models. New threads only; Codex’s own multi_agent_v2 setting still wins
+
+
Long conversations
Codex and Claude Code compact a conversation at 272K tokens on a model with a larger window, as they do on OpenAI’s and Anthropic’s own; the model’s full window lets it grow further, with every turn slower and costlier
+
+
Codex thread titles
diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index 3c0659c1..e351f9b6 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -139,6 +139,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { UpdateSkip: "0.1.500", UpdateMirror: "https://mirror.example/", Window: []int{900, 700}, + FullContext: true, } if err := settings.Save(was); err != nil { t.Fatal(err) diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index b3a0a7f3..bb1e60ac 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -692,6 +692,21 @@ func SetCodexAgentsV1(on bool) error { return nil } +// SetFullContext turns settings.FullContext on or off: Codex and Claude +// Code are told a model's whole window, or the working one, again. +func SetFullContext(on bool) error { + s := settings.Load() + if s.FullContext == on { + return nil + } + s.FullContext = on + if err := settings.Save(s); err != nil { + return err + } + catalog.Touched() + return nil +} + // SetPlainNames has the agents' model lists name models by their names // alone (see Labels), or with their providers' again, and the agents told. func SetPlainNames(on bool) error { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index cfafdd86..39d51da3 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -215,6 +215,14 @@ type Settings struct { // as Codex sends them, "off" answered by magpie with no title and sent // nowhere, or a model's id (provider/model, group/) that writes it. CodexTitles string `json:"codexTitles,omitempty"` + // FullContext has Codex and Claude Code told a model's whole context + // window. Off, a window above WorkingWindow is told as WorkingWindow, + // so they compact a long conversation there instead of sending ever + // more of it on every turn (X: Chen, a 1M DeepSeek model through + // Codex and Claude Code took 70–90s to its first token at 550K): + // what OpenAI does with its own models, 272K though they can take + // more, and Anthropic with its, 200K unless a [1m] one is picked. + FullContext bool `json:"fullContext,omitempty"` // ChinaMirror is the Plugins page's 「国内镜像」 switch: the plugin list, // npm (the plugins' packages and what npm says of them) and Bun's // downloads are asked of mirrors in China first, and of their official @@ -498,6 +506,20 @@ func CarryPerModel(in, cur *Settings) { } } +// WorkingWindow is the context window Codex and Claude Code are told for a +// model with a larger one, unless FullContext: Codex's own for OpenAI's +// models that can take more. +const WorkingWindow = 272000 + +// Working is the context window an agent is told for a model with one of +// n tokens (see FullContext). +func (s Settings) Working(n int) int { + if !s.FullContext && n > WorkingWindow { + return WorkingWindow + } + return n +} + // KeepOwn puts back cur's settings that are this computer's own, which a // sync or a restored backup never brings from another: the window's size, // the proxy, the Dock, and what the menu bar or tray shows beside magpie's