gateway, gui: Claude Desktop can list a model of 1M or more once, as its 1M entry (#1272)

Claude Desktop reads magpie's gateway models and, for every row whose
max_input_tokens is 1,000,000 or more and whose id has no [1m] suffix,
adds a second "1M context window" entry; an id that already ends in [1m]
gets one entry (BIt/PIt/JLt in Desktop's index chunk). So every 1M model
showed twice. A new switch under Agents → Claude Desktop, "Only each
model's largest window" (settings desktopLongest, off by default), lists
such a model by its [1m] id. Models under 1M keep their real window;
fast/non-fast, providers and groups stay separate rows. The base id still
resolves (aliased, Resolve and SwitchedOff trim [1m]), so sessions saved
on it keep working. Desktop needs a restart to re-read the list.

Verified:
- TestClaudeDesktopLongestOnly fails without the change ("on: 6 entries
  of 4 rows") and passes with it; the [1m] and old plain ids reach
  upstream as the same model.
- Desktop's own discovery and menu code, extracted from a copy of
  app.asar, run over magpie's output: off 4 rows → 6 entries, on 4 rows
  → 4 entries.
- desktop-longest.test.cjs (Chromium and WebKit, en/zh, 1100/640px)
  times out on origin/main's app.js and passes 8/8; gui-ja, gui-de,
  gui-zh-tw, i18n-keys, desktop-model-menu, desktop-tiers,
  agent-models-redraw and click-scroll pass 44/44.
- go test (sandbox HOME, nogui) gateway, gui, settings, agent pass;
  go vet for darwin, linux and windows.
- Full GUI suite: 3802/3809. agent-expand-steady, panel-arrange,
  redact-rule-row, remote-magpie and signin-again pass alone;
  context-window fails alone with origin/main's assets too.
Not verified: the real Desktop menu after a restart (its live config was
not touched).
This commit is contained in:
yetone committed 2026-10-08 18:11:30 +08:00
1 parent 6030fe88ca
commit a2a7fb1123
9 files changed
+304 -1

No files matched your search

+15 -1
View File
@@ -152,7 +152,8 @@ func DesktopID(e provider.Entry) string { return claudeLooking(e) }
// V4.1 Flash", not with its provider's beside it). Desktop shows the name,
// not the id, and its description after it, so none is given; it folds rows
// of one name into one entry, so two models of one name keep their
// provider's after it (desktopNames).
// provider's after it (desktopNames). A model of 1M or more is listed by
// its "[1m]" id under settings.DesktopLongest (desktop1M).
func desktopModels(entries []provider.Entry) []map[string]any {
names := desktopNames(entries)
// the tier each model stands in for: the user's pick (a model picked
@@ -165,11 +166,15 @@ func desktopModels(entries []provider.Entry) []map[string]any {
tierOf[id] = t
}
}
longest := settings.Load().DesktopLongest
data := make([]map[string]any, 0, len(entries))
for i, e := range entries {
m := modelObject(e)
m["display_name"] = names[i]
m["id"] = claudeLooking(e)
if longest && e.Context >= desktop1M {
m["id"] = m["id"].(string) + "[1m]"
}
if t := tierOf[e.ID]; t != "" {
m["anthropic_family_tier"], m["is_family_default"] = t, true
} else if t := claudeTier(e.ID); t != "" && picked[t] == "" {
@@ -180,6 +185,15 @@ func desktopModels(entries []provider.Entry) []map[string]any {
return data
}
// desktop1M is the max_input_tokens from which Claude Desktop adds a 1M
// entry beside a gateway model whose id has no "[1m]" (the discovery in its
// app.asar, 2.7032: supports_1m when given, else max_input_tokens >= 1e6;
// one whose id has "[1m]" gets none). Under settings.DesktopLongest such a
// model is listed by that 1M id alone (#1272), which magpie serves as the
// plain one (aliased, provider.Resolve take "[1m]" off), so a session
// saved on the plain id still runs.
const desktop1M = 1_000_000
// Claude Desktop's Code tab runs Claude Code with ANTHROPIC_DEFAULT_<TIER>_MODEL
// set to "" for every tier, unless the gateway's /v1/models tags a model
// with anthropic_family_tier (shortnameIdentityOverrides in its app.asar,
+115
View File
@@ -0,0 +1,115 @@
package gateway
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// Claude Desktop shows a gateway model of 1M tokens or more twice, its
// plain entry and a "1M context window" one it adds itself (#1272: 21
// models made 34 entries). With DesktopLongest on, such a model is listed
// by its "[1m]" id alone, so Desktop's menu has one entry per model; one
// under 1M (872K) keeps its own id and window, and a request on the plain id
// a session was saved with is still served.
func TestClaudeDesktopLongestOnly(t *testing.T) {
f := &fake{reply: sse(
`event: message_start`+"\n"+`data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"x","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}`,
`event: message_stop`+"\n"+`data: {"type":"message_stop"}`)}
up := setup(t, provider.Anthropic, f)
p := provider.Provider{ID: "vend", Name: "Vend", Key: "k", Anthropic: up.URL, Models: []string{"deepseek-v4-pro", "exo-free", "claude-opus-4-8"}}
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
for model, n := range map[string]int{"deepseek-v4-pro": 1_048_576, "exo-free": 872_000, "claude-opus-4-8": 1_000_000} {
now, err := provider.Find(p.ID)
if err != nil {
t.Fatal(err)
}
if err := provider.SetContext(*now, model, n); err != nil {
t.Fatal(err)
}
}
type row struct {
ID string `json:"id"`
MaxInput int `json:"max_input_tokens"`
}
list := func() map[string]row {
t.Helper()
req := httptest.NewRequest("GET", "/v1/models?limit=1000", nil)
req.Header.Set("x-api-key", TokenFor("claude-desktop"))
rec := httptest.NewRecorder()
New().Handler().ServeHTTP(rec, req)
var out struct{ Data []row }
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
got := map[string]row{}
for _, d := range out.Data {
got[DesktopCatalogID(strings.TrimSuffix(d.ID, "[1m]"))] = d
}
return got
}
// the entries Desktop's menu makes of the list: one per row, and one
// more for a row of 1M or more whose id has no "[1m]"
entries := func(rows map[string]row) int {
n := 0
for _, r := range rows {
n++
if r.MaxInput >= 1_000_000 && !strings.HasSuffix(r.ID, "[1m]") {
n++
}
}
return n
}
off := list()
if entries(off) != len(off)+2 {
t.Fatalf("off: %d entries of %d rows, want two 1M extras: %v", entries(off), len(off), off)
}
for id, r := range off {
if strings.HasSuffix(r.ID, "[1m]") {
t.Fatalf("off: %s listed as %s", id, r.ID)
}
}
s := settings.Load()
s.DesktopLongest = true
if err := settings.Save(s); err != nil {
t.Fatal(err)
}
on := list()
if entries(on) != len(on) || len(on) != len(off) {
t.Fatalf("on: %d entries of %d rows (off had %d rows): %v", entries(on), len(on), len(off), on)
}
for id, want := range map[string]string{
"vend/deepseek-v4-pro": off["vend/deepseek-v4-pro"].ID + "[1m]",
"vend/claude-opus-4-8": off["vend/claude-opus-4-8"].ID + "[1m]",
"vend/exo-free": off["vend/exo-free"].ID,
"fake/m1": off["fake/m1"].ID,
} {
if r := on[id]; r.ID != want {
t.Errorf("on: %s listed as %q, want %q", id, r.ID, want)
}
}
if r := on["vend/exo-free"]; r.MaxInput != 872_000 {
t.Errorf("exo-free's window told as %d, want its own 872000", r.MaxInput)
}
// the 1M id and the plain id an older session holds both reach the model
for _, id := range []string{on["vend/deepseek-v4-pro"].ID, off["vend/deepseek-v4-pro"].ID} {
f.calls = 0
req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(`{"model":"`+id+`","max_tokens":10,"stream":true,"tools":[{"name":"t","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("x-api-key", TokenFor("claude-desktop"))
rec := httptest.NewRecorder()
New().Handler().ServeHTTP(rec, req)
var sent struct{ Model string }
if rec.Code != 200 || f.calls != 1 || json.Unmarshal(f.got, &sent) != nil || sent.Model != "deepseek-v4-pro" {
t.Fatalf("%s: %d %s, %d calls, sent %s", id, rec.Code, rec.Body, f.calls, f.got)
}
}
}
+18
View File
@@ -1017,6 +1017,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
in.CodexAgentsV1 = cur.CodexAgentsV1
in.FullContext = cur.FullContext // set on its own (full-context below)
in.CompactAt = cur.CompactAt // and so is the threshold
// Claude Desktop's list, set in its row on the Agents page
in.DesktopLongest = cur.DesktopLongest
in.CodexTitles = cur.CodexTitles // set on its own (codex-titles below)
// and so is the model Codex's auto-review runs on (codex-auto-review)
@@ -1168,6 +1170,22 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
}
writeJSON(rw, settingsState())
})
// whether Claude Desktop lists a model of 1M or more once, by its 1M id
// (settings.DesktopLongest, #1272): it reads the list as it starts
mux.HandleFunc("POST /api/settings/desktop-longest", 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
}
s := settings.Load()
s.DesktopLongest = in.On
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
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
+3
View File
@@ -3198,6 +3198,9 @@ body.window .row + .row.agent.conn > .who { min-height: 41px; } /* the line abov
.ag-vl.ag-menu-cap { flex-wrap: nowrap; align-items: flex-start; line-height: 1.6; }
.ag-menu-cap > .ag-dot { margin-top: calc(0.8em - 3px); }
.ag-menu-cap > span:last-child { flex: 1; min-width: 0; }
/* Claude Desktop's "largest window only" switch (#1272): its words beside it, the hint after them */
.ag-longest { cursor: pointer; }
.ag-longest-say { color: var(--fg-2); }
/* which copy is left on the old list: a sentence that wraps as one, under the warning's words */
.ag-vl.ag-stale-copy { display: block; padding-left: 14px; font-size: 12px; line-height: 1.6; color: var(--fg-2); }
.ag-hint { font-size: 12px; color: var(--muted); }
+35
View File
@@ -1236,6 +1236,40 @@ async function addProviderFromAgents() {
$("#addProvider")?.click();
}
// desktopLongestLine: Claude Desktop lists a model of 1M or more twice, the
// plain one and a "1M context window" one of its own; switched on, magpie
// lists it by its 1M id alone, one entry each (#1272). Desktop reads the
// list as it starts.
function desktopLongestLine() {
const on = !!state.settings?.desktopLongest;
const l = el("label", "ag-vl ag-longest");
const b = el("button", "lib-switch use-credits" + (on ? " on" : ""));
b.type = "button";
b.setAttribute("role", "switch");
b.setAttribute("aria-checked", String(on));
b.append(el("i"));
const say = el("span", "ag-longest-say", t("Only each model's largest window"));
b.setAttribute("aria-label", say.textContent);
const hint = el("span", "ag-hint", t("A model of 1M or more is listed once, as its 1M entry. Quit and reopen Claude Desktop after a change."));
l.append(b, say, hint);
b.onclick = async (e) => {
e.stopPropagation();
e.preventDefault();
if (b.disabled) return;
b.disabled = true;
try {
prefs = await writingPrefs(api("settings/desktop-longest", { on: !on }));
state.settings = prefs;
status(t("Saved — quit and reopen Claude Desktop to see it"), "ok");
renderAgents();
} catch (err) {
b.disabled = false;
status(t(err.message), "err");
}
};
return l;
}
// connectPanel: a connected agent's row, opened
function connectPanel(a, { fields, fieldBtn }) {
const box = el("div", "ag-exp");
@@ -1352,6 +1386,7 @@ function connectPanel(a, { fields, fieldBtn }) {
w.append(el("span", "ag-dot"), el("span", "", t("The Codex app's model menu shows only the first {max}: the last {n} models can't be picked there (the Codex CLI's /model lists them all). In Pick, turn off the ones you don't use, or drag the ones you use to the front under Order.", { max: CODEX_APP_MENU, n: a.models.shown - CODEX_APP_MENU })));
v.append(w);
}
if (a.id === "claude-desktop") v.append(desktopLongestLine());
}
// what a new session starts on: optional, the agent's own last pick
// unset; Codex's is the very value its /model picks, so one choice
+12
View File
@@ -413,6 +413,9 @@ const I18N = {
"claude-* models need a Claude subscription in magpie": "claude-* 模型要先在 magpie 里添加 Claude 订阅",
"Pick": "挑选",
"The Codex app's model menu shows only the first {max}: the last {n} models can't be picked there (the Codex CLI's /model lists them all). In Pick, turn off the ones you don't use, or drag the ones you use to the front under Order.": "Codex App 的模型菜单只显示前 {max} 个:后面 {n} 个模型在那里选不到(Codex CLI 的 /model 能列出全部)。在「挑选」里关掉不用的,或在「排序」里把要用的拖到前面。",
"Only each model's largest window": "只显示每个模型的最大上下文",
"A model of 1M or more is listed once, as its 1M entry. Quit and reopen Claude Desktop after a change.": "上下文达到 1M 或以上的模型只列一项,即它的 1M 版本。改动后需退出并重新打开 Claude Desktop。",
"Saved — quit and reopen Claude Desktop to see it": "已保存——退出并重新打开 Claude Desktop 后生效",
"Model list": "模型列表",
"New sessions": "新会话默认",
"The same choice as {agent}'s {cmd}: change it here or there": "与 {agent} 的 {cmd} 同步,两边均可修改",
@@ -4162,6 +4165,9 @@ const I18N = {
"claude-* models need a Claude subscription in magpie": "claude-* 模型要先在 magpie 裡新增 Claude 訂閱",
"Pick": "挑選",
"The Codex app's model menu shows only the first {max}: the last {n} models can't be picked there (the Codex CLI's /model lists them all). In Pick, turn off the ones you don't use, or drag the ones you use to the front under Order.": "Codex App 的模型選單只顯示前 {max} 個:後面 {n} 個模型在那裡選不到(Codex CLI 的 /model 能列出全部)。在「挑選」裡關掉不用的,或在「排序」裡把要用的拖到前面。",
"Only each model's largest window": "只顯示每個模型的最大上下文",
"A model of 1M or more is listed once, as its 1M entry. Quit and reopen Claude Desktop after a change.": "上下文達到 1M 或以上的模型只列一項,即它的 1M 版本。改動後需結束並重新開啟 Claude Desktop。",
"Saved — quit and reopen Claude Desktop to see it": "已儲存——結束並重新開啟 Claude Desktop 後生效",
"Model list": "模型列表",
"New sessions": "新會話預設",
"The same choice as {agent}'s {cmd}: change it here or there": "與 {agent} 的 {cmd} 同步,兩邊均可修改",
@@ -7876,6 +7882,9 @@ const I18N = {
"claude-* models need a Claude subscription in magpie": "claude-* モデルには magpie の Claude サブスクリプションが必要です",
"Pick": "選択",
"The Codex app's model menu shows only the first {max}: the last {n} models can't be picked there (the Codex CLI's /model lists them all). In Pick, turn off the ones you don't use, or drag the ones you use to the front under Order.": "Codex アプリのモデルメニューには先頭の {max} 個しか表示されず、残りの {n} 個のモデルはそこで選べません(Codex CLI の /model にはすべて表示されます)。「選択」で使わないモデルをオフにするか、「並び順」で使うモデルを先頭へドラッグしてください。",
"Only each model's largest window": "各モデルの最大コンテキストのみ表示",
"A model of 1M or more is listed once, as its 1M entry. Quit and reopen Claude Desktop after a change.": "1M 以上のコンテキストを持つモデルは 1M 版の 1 項目だけ表示されます。変更後は Claude Desktop を終了して開き直してください。",
"Saved — quit and reopen Claude Desktop to see it": "保存しました。Claude Desktop を終了して開き直すと反映されます",
"Model list": "モデル一覧",
"New sessions": "新しいセッション",
"The same choice as {agent}'s {cmd}: change it here or there": "{agent} の {cmd} と同じ選択です。ここでもそちらでも変更できます",
@@ -11608,6 +11617,9 @@ const I18N = {
"claude-* models need a Claude subscription in magpie": "claude-*-Modelle benötigen ein Claude-Abo in magpie",
"Pick": "Wählen",
"The Codex app's model menu shows only the first {max}: the last {n} models can't be picked there (the Codex CLI's /model lists them all). In Pick, turn off the ones you don't use, or drag the ones you use to the front under Order.": "Das Modellmenü der Codex-App zeigt nur die ersten {max}: Die letzten {n} Modelle lassen sich dort nicht wählen (/model in der Codex-CLI listet alle). Schalte unter „Wählen“ ungenutzte aus oder zieh unter „Reihenfolge“ die genutzten nach vorn.",
"Only each model's largest window": "Nur das größte Kontextfenster jedes Modells",
"A model of 1M or more is listed once, as its 1M entry. Quit and reopen Claude Desktop after a change.": "Ein Modell mit 1M oder mehr erscheint nur einmal, als 1M-Eintrag. Beende Claude Desktop nach einer Änderung und öffne es neu.",
"Saved — quit and reopen Claude Desktop to see it": "Gespeichert – beende Claude Desktop und öffne es neu, um es zu sehen",
"Model list": "Modellliste",
"New sessions": "Neue Sitzungen",
"The same choice as {agent}'s {cmd}: change it here or there": "Dieselbe Auswahl wie {cmd} von {agent}: hier oder dort ändern",
+1
View File
@@ -153,6 +153,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
Window: []int{900, 700},
WindowMaximised: true,
FullContext: true,
DesktopLongest: true,
GatewayMode: "off",
}
if err := settings.Save(was); err != nil {
@@ -0,0 +1,98 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Claude Desktop lists a model of 1M or more twice, its plain entry and a
// "1M context window" one it adds (#1272). Claude Desktop's opened row has a
// switch under its model list that lists such a model once, by its 1M entry:
// off as before, it posts the new say at once, shows it, says Desktop has to
// be reopened, and no click scrolls the page. In English and Chinese, wide
// and narrow. No backend: faked here.
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");
function fixture(lang) {
const settings = { lang, theme: "light" };
const posts = [];
const state = () => ({
agents: [{
id: "claude-desktop", name: "Claude Desktop", path: "/test/claude_desktop_config.json", icon: "claude-color", wired: true,
fields: [{ key: "provider", label: "provider", value: "magpie", options: [{ value: "magpie", label: "magpie", icon: "magpie" }] }],
models: { shown: 2, listed: 2, by: [{ name: "OpenCode Go", icon: "opencode", n: 2 }], names: ["DeepSeek V4 Pro", "Exo Free"] },
}],
profiles: [], settings,
});
async function serve(route) {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
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 json(state());
if (url.pathname === "/api/settings/desktop-longest") {
const body = req.postDataJSON();
posts.push(body);
settings.desktopLongest = body.on;
return json({ ...settings });
}
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true } });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname.startsWith("/api/")) return 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 });
}
return { serve, posts };
}
const W = {
en: { say: "Only each model's largest window", hint: "Quit and reopen Claude Desktop", details: "Details" },
zh: { say: "只显示每个模型的最大上下文", hint: "退出并重新打开 Claude Desktop", details: "详情" },
};
const cd = '.row.agent[data-id="claude-desktop"]';
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
for (const width of [1100, 640]) {
test(`${engine} ${lang} ${width}px: Claude Desktop's row switches its list to each model's largest window`, async (t) => {
const w = W[lang];
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await browser.newPage({ viewport: { width, height: 700 } });
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const fx = fixture(lang);
await page.route("**/*", fx.serve);
t.after(() => browser.close());
await page.goto("http://magpie.test/");
await page.locator(cd).waitFor();
await page.locator(`${cd} .ag-link`, { hasText: w.details }).click();
const line = page.locator(`${cd} .ag-exp .ag-longest`);
await line.waitFor();
const sw = line.locator(".lib-switch");
assert.equal(await line.locator(".ag-longest-say").textContent(), w.say);
assert.ok((await line.locator(".ag-hint").textContent()).includes(w.hint));
assert.equal(await sw.getAttribute("aria-checked"), "false", "on before it was switched");
const box = await line.boundingBox();
assert.ok(box.width <= width && box.height > 0, JSON.stringify(box));
const y = await page.evaluate(() => scrollY);
await sw.click();
await page.waitForFunction((sel) => document.querySelector(sel)?.getAttribute("aria-checked") === "true", `${cd} .ag-longest .lib-switch`);
assert.deepEqual(fx.posts, [{ on: true }]);
// and off again, by its words
await page.locator(`${cd} .ag-longest .ag-longest-say`).click();
await page.waitForFunction((sel) => document.querySelector(sel)?.getAttribute("aria-checked") === "false", `${cd} .ag-longest .lib-switch`);
assert.deepEqual(fx.posts, [{ on: true }, { on: false }]);
assert.equal(await page.evaluate(() => scrollY), y, "a click scrolled the page");
assert.equal(await page.locator("select").count(), 0, "a native select");
assert.deepEqual(errors, []);
});
}
}
}
+7
View File
@@ -312,6 +312,13 @@ type Settings struct {
// 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"`
// DesktopLongest has Claude Desktop list a model of 1M tokens or more
// once, as its 1M entry (its id with Claude Code's "[1m]"), instead of
// twice (#1272): Desktop adds a "1M context window" entry beside any
// gateway model whose max_input_tokens is 1M or more and whose id has no
// "[1m]", and adds none to one whose id has it. Off, as before, it shows
// both, the plain one compacting at Claude Code's 200K.
DesktopLongest bool `json:"desktopLongest,omitempty"`
// CompactAt is the window told for a longer one when FullContext is
// off, in tokens: 0 is WorkingWindow (#876: 272K was the only one).
// A provider's or a model's own (ModelCompacts) comes before it.