cursor: a model's name no longer says its context, a grey 1M beside it does (Leo on Discord: Cursor 模型的展示名仍保留上游文本中的 1M) — Cursor names its large models "Claude Opus 5.5 1M", and magpie showed that as the name while it already kept the 1M as the model's context. The name now leaves it out ("(1M)" too); a name that would then be another model's ("GPT-5.5 Mini (1M)" beside "GPT-5.5 Mini") keeps it, as that is what tells them apart. Ids, efforts and the Cursor id each request goes to are unchanged, and a name the user gave still wins. So the context still shows, a model holding a million tokens or more has a small grey 1M by its name in the provider's models, the gateway's list and the model picker. Tested with a fake Cursor list in a sandbox magpie web, dark and light

This commit is contained in:
yetone committed 2026-09-29 13:26:16 +08:00
1 parent 73af4c282c
commit a3d59eee01
10 files changed
+162 -7

No files matched your search

+3
View File
@@ -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
+1 -1
View File
@@ -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...)
}
+3 -1
View File
@@ -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; }
+19 -1
View File
@@ -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");
+1
View File
@@ -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} 个图像模型",
+2 -2
View File
@@ -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)],
+2 -1
View File
@@ -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() {
+31
View File
@@ -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.
+99
View File
@@ -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)
}
}
+1 -1
View File
@@ -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":