From a3d59eee010b2ffcbe279c3d7c2178c538ea3e20 Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 29 Sep 2026 13:26:16 +0800 Subject: [PATCH] =?UTF-8?q?cursor:=20a=20model's=20name=20no=20longer=20sa?= =?UTF-8?q?ys=20its=20context,=20a=20grey=201M=20beside=20it=20does=20(Leo?= =?UTF-8?q?=20on=20Discord:=20Cursor=20=E6=A8=A1=E5=9E=8B=E7=9A=84?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=90=8D=E4=BB=8D=E4=BF=9D=E7=95=99=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E6=96=87=E6=9C=AC=E4=B8=AD=E7=9A=84=201M)=20=E2=80=94?= =?UTF-8?q?=20Cursor=20names=20its=20large=20models=20"Claude=20Opus=205.5?= =?UTF-8?q?=201M",=20and=20magpie=20showed=20that=20as=20the=20name=20whil?= =?UTF-8?q?e=20it=20already=20kept=20the=201M=20as=20the=20model's=20conte?= =?UTF-8?q?xt.=20The=20name=20now=20leaves=20it=20out=20("(1M)"=20too);=20?= =?UTF-8?q?a=20name=20that=20would=20then=20be=20another=20model's=20("GPT?= =?UTF-8?q?-5.5=20Mini=20(1M)"=20beside=20"GPT-5.5=20Mini")=20keeps=20it,?= =?UTF-8?q?=20as=20that=20is=20what=20tells=20them=20apart.=20Ids,=20effor?= =?UTF-8?q?ts=20and=20the=20Cursor=20id=20each=20request=20goes=20to=20are?= =?UTF-8?q?=20unchanged,=20and=20a=20name=20the=20user=20gave=20still=20wi?= =?UTF-8?q?ns.=20So=20the=20context=20still=20shows,=20a=20model=20holding?= =?UTF-8?q?=20a=20million=20tokens=20or=20more=20has=20a=20small=20grey=20?= =?UTF-8?q?1M=20by=20its=20name=20in=20the=20provider's=20models,=20the=20?= =?UTF-8?q?gateway's=20list=20and=20the=20model=20picker.=20Tested=20with?= =?UTF-8?q?=20a=20fake=20Cursor=20list=20in=20a=20sandbox=20magpie=20web,?= =?UTF-8?q?=20dark=20and=20light?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/agent.go | 3 + internal/agent/route.go | 2 +- internal/gui/assets/app.css | 4 +- internal/gui/assets/app.js | 20 +++++- internal/gui/assets/i18n.js | 1 + internal/gui/assets/routing.js | 4 +- internal/gui/groups.go | 3 +- internal/provider/cursor_models.go | 31 ++++++++ internal/provider/cursor_names_test.go | 99 ++++++++++++++++++++++++++ internal/provider/models.go | 2 +- 10 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 internal/provider/cursor_names_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 04c45850..2ed7f561 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -28,6 +28,9 @@ type Option struct { GroupIcon string `json:"groupIcon,omitempty"` Ref string `json:"ref,omitempty"` // the catalog model, the same in every agent Free bool `json:"free,omitempty"` // costs its subscription nothing + // Context is the tokens the model takes, when known; the picker marks + // the large ones + Context int `json:"context,omitempty"` } // Field is one tunable setting of an agent. Set with an empty value puts diff --git a/internal/agent/route.go b/internal/agent/route.go index 3613cef6..5c1c0b05 100644 --- a/internal/agent/route.go +++ b/internal/agent/route.go @@ -37,7 +37,7 @@ func viaMagpie(agent, prefix string) []Option { note = a.User + " · via magpie" } out = append(out, Option{Value: prefix + e.ID, Label: e.Name, Note: note, - Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free}) + Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Context: e.Context}) } return append(groups, out...) } diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index a61316ac..b258be36 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -790,7 +790,7 @@ body.window .foot { padding-left: 18px; } .pop li .ic { width: 16px; height: 16px; } .pop li .option-words { display: flex; align-items: center; min-width: 0; flex: 1 1 auto; gap: 9px; } .pop li .v { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; } -.pop li .badge.free { flex: none; margin-left: -4px; padding: 0 4px; font-size: 9px; line-height: 14px; align-self: center; } +.pop li .badge.free, .pop li .badge.ctx { flex: none; margin-left: -4px; padding: 0 4px; font-size: 9px; line-height: 14px; align-self: center; } .pop li .n { flex: 1 1 0; min-width: 0; font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; } .pop li .favorite { display: none; align-items: center; justify-content: center; color: var(--faint); width: 24px; height: 24px; border-radius: 6px; flex: none; } .pop li:hover .favorite, .pop li .favorite.on { display: inline-flex; } @@ -976,6 +976,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .provider.off .name { color: var(--muted); } .badge.off { background: var(--pill); color: var(--muted); } .badge.free { background: var(--green-soft); color: var(--green); } +/* a model's context, when it is a large one: grey, as it is a fact, not a warning */ +.badge.ctx { background: var(--pill); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); letter-spacing: .02em; } .mchip .badge { margin: 0 -3px 0 1px; padding: 0 4px; font-size: 9px; line-height: 14px; } .row.model .name .badge { margin-left: 6px; padding: 0 4px; font: 600 9px/14px var(--font); } .provider .pswitch { margin-left: 8px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index ab087ec6..ed9cc5fd 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1301,6 +1301,18 @@ function freeBadge(plan) { return f; } +// contextTag: the small grey 1M by a model that holds a million tokens or +// more (Cursor's names said it, and no longer do); the usual 128K–400K +// aren't marked, as nearly every model has one of those; nor is a name +// that says it itself ("GPT-5.5 Mini (1M)", kept to tell it from another) +function contextTag(n, name) { + if (!(n >= 1e6) || /\b\d+M\b/.test(name || "")) return null; + const m = n / 1e6; + const tag = el("span", "badge ctx", (Number.isInteger(m) ? m : m.toFixed(1)) + "M"); + tag.title = t("holds {n} tokens", { n: n.toLocaleString() }); + return tag; +} + function renderList() { const list = $("#list"); list.replaceChildren(); @@ -1319,6 +1331,8 @@ function renderList() { const own = pick.field.label === "sign-in"; words.append(el("span", "v", own ? t(o.label || o.value) : o.label || o.value)); if (o.free || (pick.modelPicker && o.value && namedFree(o.value, o.label))) words.append(freeBadge(o.free)); + const ctx = contextTag(o.context, o.label); + if (ctx) words.append(ctx); let note = o.note && o.note !== (o.label || o.value) ? (own ? t(o.note) : o.note) : ""; if (q && o.group && !note) note = o.group; if (note) words.append(el("span", "n", note)); @@ -1885,7 +1899,7 @@ function gatewayModels() { // the routing groups first, as the agents' pickers list them const out = (providers.gateway.groups || []).map((g) => ({ id: g.id, name: g.name, icons: g.icons, group: true, provider: { name: [t("routing group"), g.providers.join(", ")].filter(Boolean).join(" · ") } })); - for (const p of providers.providers) for (const m of p.models) if (m.on) out.push({ id: `${p.id}/${m.id}`, name: m.name, provider: p }); + for (const p of providers.providers) for (const m of p.models) if (m.on) out.push({ id: `${p.id}/${m.id}`, name: m.name, provider: p, context: m.context }); return out; } @@ -2041,6 +2055,8 @@ function renderGatewayModels() { const who = el("div", "who"); const name = el("div", "name", m.id); if (namedFree(m.id, m.name)) name.append(freeBadge(false)); + const ctx = contextTag(m.context, m.name); + if (ctx) name.append(ctx); who.append(name, el("div", "sub", m.name && m.name !== m.id.split("/")[1] ? `${m.name} · ${m.provider.name}` : m.provider.name)); row.append(m.group ? stackIcon(m.icons) : icon(m.provider.icon || "generic"), who, copyBtn(m.id, t("Model id"))); row.title = t("Use this model in the snippets"); @@ -3355,6 +3371,8 @@ function renderModels(p) { // or one its vendor names free (#185) const free = m.free || namedFree(m.id, m.name); if (free) c.append(el("span", "badge free", t("free"))); + const ctx = contextTag(m.context, m.name); + if (ctx) c.append(ctx); if (m.default) c.title = `${m.id} · ${m.default}`; else if (m.name && m.name !== m.id) c.title = m.id; if (free) c.title = (c.title || m.id) + " · " + t(m.free ? "free: it doesn't use the plan's credits" : "free: so its name says"); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 825b31ea..e7a179a2 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -205,6 +205,7 @@ const I18N = { "free": "免费", "free: it doesn't use the plan's credits": "免费:不消耗套餐积分", "free: so its name says": "免费:模型名称如此标明", + "holds {n} tokens": "上下文 {n} tokens", "{n} model": "{n} 个模型", "{n} models": "{n} 个模型", "{n} image model": "{n} 个图像模型", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index d182bcff..416d2b53 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -1741,7 +1741,7 @@ const subs = groups.groups.filter((x) => !x.hidden && x.id !== g?.id && !(g && x.holds?.includes(g.id)) && !d.members.includes("group/" + x.id)) .map((x) => ({ value: "group/" + x.id, label: x.name, note: "group/" + x.id, icons: groupIcons(x), group: ROUTING_GROUPS, ref: "group/" + x.id })); const options = [...subs, ...groups.models.filter((x) => !d.members.includes(x.id)) - .map((x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.providerName, ref: x.id }))]; + .map((x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.providerName, ref: x.id, context: x.context }))]; openPicker({ id: "", name: "", fields: [] }, { key: "member", label: "model", value: "", options, onPick: (id) => { if (id && !d.members.includes(id)) d.members.push(id); draw(); drawRules(); @@ -1867,7 +1867,7 @@ if (sg) cb.append(stackIcon(groupIcons(sg)), el("span", "", `${sg.name} · ${t("routing group")}`)); else if (d.classifier) cb.append(icon(m?.icon || "generic"), el("span", "", m ? `${m.name || m.id} · ${m.providerName}` : d.classifier)); else cb.append(el("span", "", t("choose a model"))); - const opt = (x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.providerName, ref: x.id }); + const opt = (x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.providerName, ref: x.id, context: x.context }); const subs = groups.groups.filter((x) => !x.hidden && x.id !== g?.id) .map((x) => ({ value: "group/" + x.id, label: x.name, note: "group/" + x.id, icons: groupIcons(x), group: ROUTING_GROUPS, ref: "group/" + x.id })); cb.onclick = (ev) => openPicker({ id: "", name: "", fields: [] }, { key: "classifier", label: "model", value: d.classifier, options: [...deciders.map(opt), ...subs, ...groups.models.map(opt)], diff --git a/internal/gui/groups.go b/internal/gui/groups.go index ac66b4c5..a64c107a 100644 --- a/internal/gui/groups.go +++ b/internal/gui/groups.go @@ -51,6 +51,7 @@ type modelRef struct { Provider string `json:"provider"` PName string `json:"providerName"` Icon string `json:"icon,omitempty"` + Context int `json:"context,omitempty"` // the tokens it takes, when known } type poolJSON struct { @@ -118,7 +119,7 @@ func groupsState() groupsJSON { served := provider.Served() for _, e := range served { if e.Group == "" { - out.Models = append(out.Models, modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon}) + out.Models = append(out.Models, modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon, Context: e.Context}) } } for _, g := range provider.Groups() { diff --git a/internal/provider/cursor_models.go b/internal/provider/cursor_models.go index b7d236fe..77a54159 100644 --- a/internal/provider/cursor_models.go +++ b/internal/provider/cursor_models.go @@ -9,6 +9,7 @@ package provider // way to ask for it. import ( + "regexp" "slices" "strings" @@ -169,6 +170,36 @@ func collapseCursorModels(raw []catalog.Model) []catalog.Model { return out } +// cursorCapacity is the context Cursor puts in a model's name ("Claude +// Opus 5.5 1M", "GPT-5.5 (1M)"). +var cursorCapacity = regexp.MustCompile(`\(\s*\d+M\s*\)|\b\d+M\b`) + +// withoutCursorCapacity is Cursor's list, collapsed, with the context taken +// out of each name: it is the model's Context, shown apart. A name that +// would then be another model's ("GPT-5.5 1M" beside a "GPT-5.5") keeps +// it, as that is what tells the two apart. Ids stay as they are. +func withoutCursorCapacity(ms []catalog.Model) []catalog.Model { + strip := func(name string) string { + return strings.Join(strings.Fields(cursorCapacity.ReplaceAllString(name, " ")), " ") + } + names := map[string]int{} // each name, as listed or stripped, by how many models have it + for _, m := range ms { + names[strings.ToLower(m.Name)]++ + if s := strip(m.Name); s != m.Name { + names[strings.ToLower(s)]++ + } + } + out := slices.Clone(ms) + for i, m := range out { + s := strip(m.Name) + if s == m.Name || s == "" || names[strings.ToLower(s)] > 1 { + continue + } + out[i].Name = s + } + return out +} + // CursorVariants are Cursor's ids a model magpie offers stands for, by // effort, "" the default; ok is false for a model that stands for none, // which goes to Cursor as it is. diff --git a/internal/provider/cursor_names_test.go b/internal/provider/cursor_names_test.go new file mode 100644 index 00000000..ef6afb15 --- /dev/null +++ b/internal/provider/cursor_names_test.go @@ -0,0 +1,99 @@ +package provider + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/catalog" +) + +// The context Cursor puts in a model's name is its Context, shown apart: +// the name goes without it, unless it is what tells two models apart. +func TestCursorNamesWithoutCapacity(t *testing.T) { + in := []catalog.Model{ + {ID: "claude-opus-5-5", Name: "Claude Opus 5.5 1M", Context: 1_000_000}, + {ID: "gpt-5.6-sol", Name: "GPT-5.6 Sol (1M) Thinking", Context: 1_000_000}, + {ID: "gpt-5.5", Name: "GPT-5.5", Context: 200_000}, + {ID: "gpt-5.5-1m", Name: "GPT-5.5 1M", Context: 1_000_000}, + {ID: "grok-a", Name: "Grok 1M", Context: 1_000_000}, + {ID: "grok-b", Name: "Grok 2M", Context: 2_000_000}, + {ID: "composer-2.5", Name: "Composer 2.5", Context: 200_000}, + {ID: "only", Name: "1M", Context: 1_000_000}, + } + var got []string + for _, m := range withoutCursorCapacity(in) { + got = append(got, m.ID+"|"+m.Name) + } + want := []string{ + "claude-opus-5-5|Claude Opus 5.5", + "gpt-5.6-sol|GPT-5.6 Sol Thinking", + "gpt-5.5|GPT-5.5", + "gpt-5.5-1m|GPT-5.5 1M", // beside a GPT-5.5, the 1M is what tells it apart + "grok-a|Grok 1M", // both would be Grok + "grok-b|Grok 2M", + "composer-2.5|Composer 2.5", + "only|1M", // nothing would be left + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("got\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if in[0].Name != "Claude Opus 5.5 1M" { + t.Fatalf("the list given was changed: %q", in[0].Name) + } +} + +// Through Available and the catalog: names go without the 1M, ids, +// efforts, contexts and the ids each effort asks Cursor for stay, and a +// name the user gave a model is still its name. +func TestCursorAvailableNames(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + was := cursorStatus + defer func() { cursorStatus = was }() + cursorStatus = &cliIdentity{name: "cursor-test", exe: func() string { return "/bin/sh" }, ask: func() (string, string, bool, error) { return "me@example.com", "Pro", true, nil }} + raw := withCursorContexts(parseCursorModels(cursorListed)) + if err := catalog.SaveLive("cursor", "", raw); err != nil { + t.Fatal(err) + } + if err := Save(Provider{ID: "cursor", Models: []string{"claude-opus-4-7", "gpt-5.5", "gpt-5.5-fast", "claude-4.6-sonnet-medium-thinking"}}); err != nil { + t.Fatal(err) + } + p, err := Find("cursor") + if err != nil { + t.Fatal(err) + } + var got []string + for _, m := range p.Available() { + got = append(got, m.ID+"|"+m.Name+"|"+strings.Join(m.Efforts, ",")) + } + for _, w := range []string{ + "claude-opus-4-7|Claude Opus 4.7|low,xhigh,max", + "claude-opus-4-7-thinking|Claude Opus 4.7 Thinking|low,xhigh", + "gpt-5.5|GPT-5.5|none,medium,xhigh", + "gpt-5.5-fast|GPT-5.5 Fast|medium,xhigh", + "claude-4.6-sonnet-medium-thinking|Claude Sonnet 4.6 Thinking|", + } { + if !strings.Contains(strings.Join(got, "\n"), w) { + t.Errorf("no %s in\n%s", w, strings.Join(got, "\n")) + } + } + if vs, ok := cursorVariantsIn(raw, "claude-opus-4-7"); !ok || vs[""] != "claude-opus-4-7-xhigh" { + t.Errorf("claude-opus-4-7 asks for %v", vs) + } + if err := SetModelName("cursor/claude-opus-4-7", "Opus Big"); err != nil { + t.Fatal(err) + } + byID := map[string]Entry{} + for _, e := range Catalog() { + byID[e.ID] = e + } + if e := byID["cursor/claude-opus-4-7"]; e.Name != "Opus Big" || e.Default != "Claude Opus 4.7" || e.Model != "claude-opus-4-7" || e.Context != 1_000_000 { + t.Errorf("named: %+v", e) + } + if e := byID["cursor/gpt-5.5"]; e.Name != "GPT-5.5" || e.Default != "" || e.Context != 1_000_000 { + t.Errorf("gpt-5.5: %+v", e) + } +} diff --git a/internal/provider/models.go b/internal/provider/models.go index 9b6efb78..eca269c8 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -42,7 +42,7 @@ func (p Provider) Available() []catalog.Model { if live, _, ok := catalog.Live(p.ID); ok { switch p.ID { case "cursor": - live = collapseCursorModels(withCursorContexts(live)) + live = withoutCursorCapacity(collapseCursorModels(withCursorContexts(live))) case "devin": live = withDevinContexts(devinCollapse(live, devinCached(), p.Models)) case "antigravity":