From e950eb080abedc173b858217a60309c00d5468c7 Mon Sep 17 00:00:00 2001 From: yetone Date: Sun, 4 Oct 2026 18:27:06 +0800 Subject: [PATCH] =?UTF-8?q?codex,=20claude:=20a=20model=20with=20a=20windo?= =?UTF-8?q?w=20above=20272K=20is=20compacted=20at=20272K,=20as=20OpenAI's?= =?UTF-8?q?=20and=20Anthropic's=20own=20are=20(X:=20Chen=20=E2=80=94=20thr?= =?UTF-8?q?ough=20magpie=20on=20DeepSeek=20flash,=20Codex=20and=20Claude?= =?UTF-8?q?=20Code=20both=20"wait"=20before=20every=20answer,=20and=20more?= =?UTF-8?q?=20the=20longer=20the=20task)=20=E2=80=94=20magpie=20told=20bot?= =?UTF-8?q?h=20agents=20a=201M=20model's=20whole=20window,=20so=20neither?= =?UTF-8?q?=20compacted=20until=20near=201M=20and=20every=20turn=20sent=20?= =?UTF-8?q?the=20whole=20conversation=20again:=20the=20live=20ledger=20had?= =?UTF-8?q?=20turns=20of=20~550K=20tokens=20waiting=2070=E2=80=9390s=20for?= =?UTF-8?q?=20a=20first=20token,=20while=20magpie's=20own=20time=20before?= =?UTF-8?q?=20the=20vendor=20stays=20under=200.2s.=20OpenAI=20runs=20its?= =?UTF-8?q?=20own=201M-capable=20models=20at=20272K=20in=20Codex=20(contex?= =?UTF-8?q?t=5Fwindow=20272000,=20max=5Fcontext=5Fwindow=20872000)=20and?= =?UTF-8?q?=20Anthropic=20its=20at=20200K=20unless=20a=20[1m]=20one=20is?= =?UTF-8?q?=20picked;=20magpie=20now=20does=20the=20same=20by=20default.?= =?UTF-8?q?=20Codex's=20catalog=20gives=20such=20a=20model=20context=5Fwin?= =?UTF-8?q?dow=20272000=20and=20its=20whole=20window=20as=20max=5Fcontext?= =?UTF-8?q?=5Fwindow;=20Claude=20Code=20gets=20CLAUDE=5FCODE=5FAUTO=5FCOMP?= =?UTF-8?q?ACT=5FWINDOW=3D272000=20while=20it=20runs=20on=20magpie=20(it?= =?UTF-8?q?=20compacts=20at=20the=20smaller=20of=20that=20and=20the=20mode?= =?UTF-8?q?l's=20window,=20a=20[1m]=20model's=20too),=20kept=20apart=20fro?= =?UTF-8?q?m=20a=20value=20the=20user=20set=20and=20taken=20out=20with=20m?= =?UTF-8?q?agpie.=20Settings=20=E2=86=92=20Long=20conversations=20("Compac?= =?UTF-8?q?t=20at=20272K"=20/=20"Full=20window",=20en=20and=20zh)=20turns?= =?UTF-8?q?=20it=20off=20(settings.FullContext),=20and=20both=20agents'=20?= =?UTF-8?q?lists=20are=20written=20again=20at=20once.=20In=20a=20sandbox?= =?UTF-8?q?=20with=20the=20owner's=20real=20accounts:=20Codex's=20/models?= =?UTF-8?q?=20listed=20group/test=20and=20group/auto-glm-5-3-flash=20at=20?= =?UTF-8?q?272000=20with=20max=201000000=20(stepfun's=2032K=20and=2016K=20?= =?UTF-8?q?as=20they=20were,=20gpt-6-*=20still=20272000/872000),=20Claude?= =?UTF-8?q?=20Code=20on=20deepseek/deepseek-flash[1m]=20answered=20and=20i?= =?UTF-8?q?ts=20/context=20said=2016.3k=20/=20272k,=20and=20Full=20window?= =?UTF-8?q?=20turned=20both=20back=20to=201M=20(/context=2016.3k=20/=201m)?= =?UTF-8?q?.=20TestClaudeCompactWindow=20and=20the=20extended=20TestCodexC?= =?UTF-8?q?atalogToolSearch=20fail=20on=20the=20old=20code.=20go=20vet=20(?= =?UTF-8?q?darwin,=20GOOS=3Dwindows),=20builds=20(darwin,=20GOOS=3Dlinux,?= =?UTF-8?q?=20GOOS=3Dwindows)=20and=20go=20test=20-tags=20nogui=20-count?= =?UTF-8?q?=3D1=20./...=20under=20a=20temp=20HOME=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/claude.go | 51 +++++++++++++++++++ internal/agent/claude_compact_test.go | 71 +++++++++++++++++++++++++++ internal/codexcat/codexcat.go | 13 ++++- internal/codexcat/codexcat_test.go | 18 ++++++- internal/gui/api.go | 16 ++++++ internal/gui/assets/app.js | 4 ++ internal/gui/assets/i18n.js | 4 ++ internal/gui/assets/index.html | 4 ++ internal/gui/settings_keep_test.go | 1 + internal/provider/modelprefs.go | 15 ++++++ internal/settings/settings.go | 22 +++++++++ 11 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 internal/agent/claude_compact_test.go 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