From 5aca7d092489ddab9b2fdce98ead3c3f416b3db8 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 06:26:09 +0800 Subject: [PATCH] codex: effort left at Default stays Default and says the level Codex takes then ("default (medium)"), and isn't written over with medium when a model is picked or the catalog synced (lgtm on Discord) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lgtm set Codex · WSL Ubuntu's 推理强度 to 默认 and it came back as 中. Two things did that, on every platform (the WSL row is the same codexIn code as the local one): - The effort field's Get answered an unset model_reasoning_effort on a magpie model with codexcat.DefaultEffort (medium), so right after the pick the slider and the row read 中, not 默认. - settle() counted an unset effort as one the model doesn't take and wrote DefaultEffort into config.toml: on every Sync for a magpie model, on Join, and on a model pick, Codex's own models included. So Default was pinned as medium, and on Codex's own models whose default is low (gpt-6.1-sol, gpt-6-astra, gpt-5.6-sol in 0.162's models.json) magpie overrode Codex's own default. With model_reasoning_effort unset, Codex takes the catalog entry's default_reasoning_level (core/src/client.rs build_reasoning). Checked for real with codex-cli 0.162.0 in a sandbox CODEX_HOME: a model_catalog_json entry with default_reasoning_level "low" and no model_reasoning_effort sent {"reasoning":{"effort":"low"}} to a stand-in Responses endpoint. Now settle replaces only a set effort the model doesn't take, Get returns the unset value, and the effort control's options start with Default carrying Takes: the level Codex then takes, from the entry it reads (codexcat.TakesEffort: Codex's own models_cache entry for its models and a ChatGPT account's codex/ ones, else the one Entries writes). The GUI shows it as "default (medium)" / 默认(中) / 預設(中) / デフォルト(中) / Standard (mittel). A stopped WSL distro's options get the same Default (codexEfforts). Tests: TestCodexDefaultEffortStaysDefault fails on the old settle ("Default written over: model_reasoning_effort = \"high\"", "Default shows as \"high\"") and passes now. TestCodexCustomProviderModelHasEfforts now expects Default first with Takes "high" and an unset value read as "". codex-default-effort.test.cjs (window at 560 and 360px, tray panel; en, zh, ja, de; Chromium and WebKit) fails on the old effortName and passes now. Run: gofmt -l (clean); go vet -tags nogui on darwin, GOOS=linux and GOOS=windows for ./internal/agent ./internal/codexcat ./internal/gui ./internal/tui and the main package; go test -tags nogui of those and ./internal/catalog under a scratch HOME (all ok); the GUI effort, gui-ja, gui-de, gui-zh-tw and i18n-keys tests (all pass), and the whole GUI suite in both engines: 4711 pass, 8 fail, all in library-servers-bulk (chromium, ".lib-everyon moved from 290 to 288") and purpose-filter (webkit), which fail the same way run alone on origin/main c1c88779. Not run on a real WSL distro. --- internal/agent/agent.go | 4 + internal/agent/codex.go | 48 ++++--- internal/agent/codex_effort_test.go | 71 ++++++++- internal/agent/wsl.go | 5 +- internal/codexcat/codexcat.go | 19 +++ internal/gui/assets/app.js | 5 +- internal/gui/assets/i18n.js | 4 + .../gui/tests/codex-default-effort.test.cjs | 136 ++++++++++++++++++ 8 files changed, 262 insertions(+), 30 deletions(-) create mode 100644 internal/gui/tests/codex-default-effort.test.cjs diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 421f5904..f76a2d86 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -40,6 +40,10 @@ type Option struct { // Context is the tokens the model takes, when known; the picker marks // the large ones Context int `json:"context,omitempty"` + // Takes, on an effort field's default (Value ""), is the level the + // agent takes for the model with none set (Codex: the catalog entry's + // default_reasoning_level), shown beside "default" + Takes string `json:"takes,omitempty"` // Direct names who the agent asks for this model itself, on its own // sign-in or key, with magpie not in the way ("Anthropic"): its config // then names no magpie endpoint, which is right, not a failed setup diff --git a/internal/agent/codex.go b/internal/agent/codex.go index a488e7b2..a2891b78 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -80,6 +80,21 @@ func codex(home string) *Agent { return codexIn(here(home)) } // codexIn is Codex as it lives at a place: this machine's home, or a WSL // distro's (see wsl.go). +// codexEfforts are the effort control's choices for a model among ms: +// Default first, with the level Codex takes for it when none is set, as +// the catalog entry it reads says it, then the model's levels. +func codexEfforts(ms []catalog.Model, model string) []Option { + e := catalog.Efforts(ms, model) + if len(e) == 0 { + return static("low", "medium", "high", "xhigh") + } + d := Option{Value: "", Note: "Codex's default for the model", Takes: codexcat.TakesEffort(ms, model)} + if d.Takes != "" { + d.Note += ": " + d.Takes + } + return append([]Option{d}, static(e...)...) +} + func codexIn(at place) *Agent { dir := filepath.Join(at.home, ".codex") path := filepath.Join(dir, "config.toml") @@ -142,7 +157,10 @@ func codexIn(at place) *Agent { return catalog.Codex() } var dropSubEffort func() error - // keep the effort valid for the model; a fresh model gets its default. + // keep the effort valid for the model: one set that the model doesn't + // take becomes its default. One left unset stays unset — Codex then + // takes the model's own default, which is what Default on the effort + // control asked for (lgtm: Default turned into medium on its own). // Routed, the Codex app offers magpie's models' efforts too (#310). settle := func() error { if routed() { @@ -152,7 +170,7 @@ func codexIn(at place) *Agent { } ms := models() model, effort := get("model"), get("model_reasoning_effort") - if e := catalog.Efforts(ms, model); len(e) > 0 && !contains(e, effort) { + if e := catalog.Efforts(ms, model); len(e) > 0 && effort != "" && !contains(e, effort) { if err := edit.SetTOMLTop(path, edit.KV{Path: "model_reasoning_effort", Value: codexcat.DefaultEffort(e)}); err != nil { return err } @@ -954,31 +972,19 @@ func codexIn(at place) *Agent { }, { Key: "effort", Label: "effort", - // unset, Codex takes the model's default, as the catalog - // magpie wrote says it — shown as such rather than as none - Get: func() string { - if e := get("model_reasoning_effort"); e != "" { - return e - } - if m := get("model"); isMagpie(m) { - if e := catalog.Efforts(models(), m); len(e) > 0 { - return codexcat.DefaultEffort(e) - } - } - return "" - }, + // unset reads as Default, which it is; the level Codex then + // takes is on the Default option (Takes), not the value, so + // picking Default doesn't read back as medium (lgtm) + Get: func() string { return get("model_reasoning_effort") }, Set: func(v string) error { if v == "" { return edit.DelTOMLTop(path, "model_reasoning_effort") } return edit.SetTOMLTop(path, edit.KV{Path: "model_reasoning_effort", Value: v}) }, - Options: func(cur map[string]string) []Option { - if e := catalog.Efforts(models(), cur["model"]); len(e) > 0 { - return static(e...) - } - return static("low", "medium", "high", "xhigh") - }, + // Default first, with the level Codex takes for the model + // when none is set, as the catalog entry it reads says it + Options: func(cur map[string]string) []Option { return codexEfforts(models(), cur["model"]) }, }, { // Codex lists only the first few models in the spawn_agent diff --git a/internal/agent/codex_effort_test.go b/internal/agent/codex_effort_test.go index 2cfeca91..0c2c5e69 100644 --- a/internal/agent/codex_effort_test.go +++ b/internal/agent/codex_effort_test.go @@ -68,18 +68,23 @@ func TestCodexCustomProviderModelHasEfforts(t *testing.T) { } effort := cx.Fields[1] var opts []string - for _, o := range effort.Options(map[string]string{"model": "volc/glm-5.3-flash"}) { + ef := effort.Options(map[string]string{"model": "volc/glm-5.3-flash"}) + for _, o := range ef { opts = append(opts, o.Value) } - if strings.Join(opts, ",") != "low,high,max" { + if strings.Join(opts, ",") != ",low,high,max" { t.Errorf("effort options: %v", opts) } + // Default says what Codex takes then: the catalog's default + if ef[0].Takes != "high" { + t.Errorf("default takes %q", ef[0].Takes) + } - // unset, the control shows what Codex takes: the catalog's default + // unset, the control shows Default, which is what was picked if err := effort.Set(""); err != nil { t.Fatal(err) } - if v := effort.Get(); v != "high" { + if v := effort.Get(); v != "" { t.Errorf("unset effort shows %q", v) } @@ -95,3 +100,61 @@ func TestCodexCustomProviderModelHasEfforts(t *testing.T) { t.Errorf("sync left the effort:\n%s", cfg) } } + +// Default on Codex's effort control stays Default (lgtm on Discord: picked +// on Codex · WSL Ubuntu, it turned into medium): an effort left unset is +// not written in when a model is picked or the catalog synced, and the +// control reads it as Default, with the level Codex then takes beside it — +// for Codex's own model, its own default (gpt-6.1-sol's low), not medium. +func TestCodexDefaultEffortStaysDefault(t *testing.T) { + home, read := codexHome(t, `{"tokens":{"access_token":"x","id_token":"x.e30.x"}}`, "") + os.WriteFile(filepath.Join(home, ".codex", "models_cache.json"), []byte(`{"models":[ + {"slug":"gpt-6.1-sol","display_name":"GPT-6.1 Sol","priority":1,"default_reasoning_level":"low", + "supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"}]}]}`), 0o644) + os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755) + os.WriteFile(catalog.CachePath(), []byte(`{ + "zai":{"models":{"glm-5.3-flash":{"id":"glm-5.3-flash","reasoning_options":[{"type":"effort","values":["low","high","max"]}]}}}}`), 0o644) + catalog.Reset() + t.Cleanup(catalog.Reset) + if err := provider.Save(provider.Provider{ID: "volc", Name: "Volc", Key: "k", Chat: "http://127.0.0.1:1/api/v3", + Models: []string{"glm-5.3-flash"}}); err != nil { + t.Fatal(err) + } + cx := codex(home) + model, effort := cx.Fields[0], cx.Fields[1] + unset := func(when string) { + t.Helper() + if cfg := read(); strings.Contains(cfg, "model_reasoning_effort") { + t.Errorf("%s: Default written over:\n%s", when, cfg) + } + if v := effort.Get(); v != "" { + t.Errorf("%s: Default shows as %q", when, v) + } + } + if err := model.Set("volc/glm-5.3-flash"); err != nil { + t.Fatal(err) + } + unset("magpie's model picked") + if err := cx.Sync(); err != nil { + t.Fatal(err) + } + unset("catalog synced") + if err := model.Set("gpt-6.1-sol"); err != nil { + t.Fatal(err) + } + unset("Codex's own model picked") + if d := effort.Options(map[string]string{"model": "gpt-6.1-sol"})[0]; d.Value != "" || d.Takes != "low" { + t.Errorf("Codex's own model's default: %+v", d) + } + + // a level set that the model doesn't take is still settled + if err := effort.Set("max"); err != nil { + t.Fatal(err) + } + if err := model.Set("gpt-6.1-sol"); err != nil { + t.Fatal(err) + } + if v := effort.Get(); v == "max" || v == "" { + t.Errorf("max left on a model without it: %q", v) + } +} diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index 3c89da80..4f1c68ec 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -255,10 +255,7 @@ var wslKinds = []wslKind{ } case "effort": return func(cur map[string]string) []Option { - if e := catalog.Efforts(append(catalog.Codex(), magpieModels("codex")...), cur["model"]); len(e) > 0 { - return static(e...) - } - return static("low", "medium", "high", "xhigh") + return codexEfforts(append(catalog.Codex(), magpieModels("codex")...), cur["model"]) } } return nil diff --git a/internal/codexcat/codexcat.go b/internal/codexcat/codexcat.go index 9758e451..ac1d654c 100644 --- a/internal/codexcat/codexcat.go +++ b/internal/codexcat/codexcat.go @@ -38,6 +38,25 @@ func DefaultEffort(e []string) string { return e[0] } +// TakesEffort is the effort Codex takes for the model id when none is set: +// the default_reasoning_level of the entry it reads for it. Codex's own +// model and a ChatGPT account's (codex/) keep their entry from Codex's +// models_cache.json, so it is that entry's (gpt-6.1-sol: low); one of +// magpie's other models takes the one Entries writes. "" when the model +// has no levels, or Codex's entry names none. +func TakesEffort(ms []catalog.Model, id string) string { + e := catalog.Efforts(ms, id) + if len(e) == 0 { + return "" + } + slug, chatgpt := strings.CutPrefix(id, "codex/") + if raw, ok := CacheEntries()[slug]; ok && (chatgpt || slug == id) { + d, _ := raw["default_reasoning_level"].(string) + return d + } + return DefaultEffort(e) +} + // Catalog renders models as a whole models.json. func Catalog(ms []catalog.Model) []byte { out := struct { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 23d5366f..4e5bee0b 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3744,7 +3744,10 @@ function openPicker(agent, field, anchor, ev, only) { } function effortName(option) { - if (!option?.value) return t("default"); + // the agent's default, with the level it takes then when the agent says + // (Codex: the model's default_reasoning_level), so Default doesn't read + // as a level of its own that turned into medium (lgtm) + if (!option?.value) return option?.takes ? t("default ({level})", { level: t(option.takes) }) : t("default"); return t(option.label || option.value); } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index f493ef56..1a6733a3 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -872,6 +872,7 @@ const I18N = { "agent default": "Agent 默认值", "every magpie model as a provider in T3 Code, on Claude Code": "magpie 的全部模型作为 T3 Code 里的一个供应商(经 Claude Code 运行)", "default": "默认", + "default ({level})": "默认({level})", "off": "关闭", "none": "不思考", "minimal": "最低", @@ -4754,6 +4755,7 @@ const I18N = { "agent default": "Agent 預設值", "every magpie model as a provider in T3 Code, on Claude Code": "magpie 的全部模型作為 T3 Code 裡的一個供應商(經 Claude Code 執行)", "default": "預設", + "default ({level})": "預設({level})", "off": "關閉", "none": "不思考", "minimal": "最低", @@ -8606,6 +8608,7 @@ const I18N = { "agent default": "エージェントのデフォルト", "every magpie model as a provider in T3 Code, on Claude Code": "magpie のすべてのモデルを T3 Code のプロバイダとして(Claude Code 上で)", "default": "デフォルト", + "default ({level})": "デフォルト({level})", "off": "オフ", "none": "なし", "minimal": "最小", @@ -12477,6 +12480,7 @@ const I18N = { "agent default": "Standard des Agenten", "every magpie model as a provider in T3 Code, on Claude Code": "alle magpie-Modelle als Anbieter in T3 Code, über Claude Code", "default": "Standard", + "default ({level})": "Standard ({level})", "off": "aus", "none": "keiner", "minimal": "minimal", diff --git a/internal/gui/tests/codex-default-effort.test.cjs b/internal/gui/tests/codex-default-effort.test.cjs new file mode 100644 index 00000000..46e65a2f --- /dev/null +++ b/internal/gui/tests/codex-default-effort.test.cjs @@ -0,0 +1,136 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Codex's effort left at Default reads as Default, with the level Codex +// then takes for the model beside it (lgtm on Discord: Default turned into +// 中 on Codex · WSL Ubuntu). Its row and slider say "default (medium)", a +// level picked shows the level, and Default picked again posts "" and +// reads as Default again, not as medium. +// English, Chinese, Japanese and German, Chromium and WebKit, the window +// and the tray panel. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const levels = ["low", "medium", "high", "xhigh"]; +const codex = (effort) => ({ + id: "codex", name: "Codex", icon: "codex-color", path: "/h/.codex/config.toml", wired: true, + fields: [ + { key: "model", label: "model", value: "volc/glm", options: [{ value: "volc/glm", label: "GLM", ref: "volc/glm" }] }, + { key: "effort", label: "effort", value: effort, options: [{ value: "", note: "Codex's default for the model: medium", takes: "medium" }, ...levels.map((value) => ({ value }))] }, + ], +}); +const filler = Array.from({ length: 6 }, (_, i) => ({ id: "pi" + i, name: "Pi " + i, path: "/p", fields: [{ key: "model", label: "model", value: "", options: [] }] })); + +function serve(lang, sets) { + let effort = ""; + const st = () => ({ agents: [codex(effort), ...filler], profiles: [], settings: { lang, theme: "light" } }); + return async (route) => { + const req = route.request(), url = new URL(req.url()); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return route.fulfill({ json: st() }); + if (url.pathname === "/api/set") { + const b = req.postDataJSON(); + sets.push(b); + if (b.field === "effort") effort = b.value; + return route.fulfill({ json: st() }); + } + if (url.pathname === "/api/plugins") return route.fulfill({ json: { plugins: [] } }); + if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] }); + if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [] } }); + if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], gateway: { running: true } } }); + if (url.pathname.startsWith("/api/")) return route.fulfill({ json: {} }); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const words = { + en: { def: "default (medium)", high: "high" }, + zh: { def: "默认(中)", high: "高" }, + ja: { def: "デフォルト(中)", high: "高" }, + de: { def: "Standard (mittel)", high: "hoch" }, +}; +const row = `.row.agent[data-id="codex"]`; +const noSideScroll = (page) => page.evaluate(() => document.scrollingElement.scrollWidth <= innerWidth); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh", "ja", "de"]) { + const w = words[lang]; + test(`${engine} ${lang}: Codex's Default effort reads as Default`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const errors = []; + t.after(async () => { + if (errors.length) console.log(errors); + await browser.close(); + }); + const open = async (mode, sets, width) => { + const page = await (await browser.newContext({ viewport: { width, height: 640 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, sets)); + await page.goto("http://magpie.test/" + (mode ? "?mode=" + mode : "")); + await page.locator(row).waitFor(); + return page; + }; + const slide = (page, i) => page.locator("#effortRange").evaluate((r, i) => { + r.value = String(i); + r.dispatchEvent(new Event("input", { bubbles: true })); + r.dispatchEvent(new Event("change", { bubbles: true })); + }, i); + + for (const width of [560, 360]) { + await t.test(`the window at ${width}px`, async () => { + const sets = []; + const page = await open("", sets, width); + let field = page.locator(`${row} .field[data-key="effort"]`).first(); + if (!(await field.isVisible())) { + await page.locator(`${row} .ag-link`).click(); + field = page.locator(`${row} .ag-exp .field[data-key="effort"]`); + } + await field.waitFor(); + assert.equal(await field.locator(".v").textContent(), w.def); + const box = await field.boundingBox(); + assert(box.x >= 0 && box.x + box.width <= width, `the effort runs off the window: ${JSON.stringify(box)}`); + + await field.click(); + await page.locator("#effortControl:not([hidden])").waitFor(); + assert.equal(await page.locator("#effortValue").textContent(), w.def); + assert.equal(await page.locator("#effortMin").textContent(), w.def); + // Default, then the model's four levels: no stop of its own added + assert.equal(await page.locator("#effortTicks i").count(), 5); + await slide(page, 3); + assert.equal(await page.locator("#effortValue").textContent(), w.high); + await page.waitForFunction(() => document.querySelector('.row.agent[data-id="codex"] .field[data-key="effort"] .v')?.textContent !== document.querySelector("#effortMin")?.textContent); + await slide(page, 0); + assert.equal(await page.locator("#effortValue").textContent(), w.def); + for (let i = 0; i < 100 && sets.length < 2; i++) await page.waitForTimeout(50); + await page.waitForFunction((d) => [...document.querySelectorAll('.row.agent[data-id="codex"] .field[data-key="effort"] .v')].some((v) => v.textContent === d), w.def); + assert.deepEqual(sets, [{ agent: "codex", field: "effort", value: "high" }, { agent: "codex", field: "effort", value: "" }]); + assert(await noSideScroll(page), "the window scrolls sideways"); + }); + } + + await t.test("the panel", async () => { + const sets = []; + const page = await open("panel", sets, 440); + assert.equal(await page.locator(`${row} .ag-sum .eff`).getAttribute("data-l"), "0"); + await page.locator(`${row} .ag-sum`).click(); + await page.waitForTimeout(700); + const box = page.locator(`${row} .ag-open .effort-control`); + assert.equal(await box.locator(".effort-ticks i").count(), 5); + assert.equal(await box.locator(".effort-head b").textContent(), w.def); + assert.equal(await box.locator(".effort-ends span").first().textContent(), w.def); + await box.locator(".eslide").press("ArrowRight"); + await page.waitForTimeout(300); + assert.deepEqual(sets, [{ agent: "codex", field: "effort", value: "low" }]); + assert(await noSideScroll(page), "the panel scrolls sideways"); + }); + + assert.deepEqual(errors, []); + }); + } +}