From f89c01754e6adf567282c1dcb774a5f5cf0f3018 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 22:49:05 +0900 Subject: [PATCH 1/2] refactor(cursor): canonical Claude-id normalizer replaces the three Fable 5.1 seeds Every Claude spelling Cursor has used (claude-fable-5-1, claude-fable-5.1, claude-5.1-fable, with -thinking/-fast/effort suffixes) resolves to one capability base; wire ids are composed back in the spelling the live GetUsableModels roster exposed, else the spelling the saved config used. --- src/adapters/cursor/catalog.ts | 94 ++++++++++++++++++++--------- src/adapters/cursor/claude-id.ts | 76 +++++++++++++++++++++++ src/adapters/cursor/effort-map.ts | 35 +++++++---- src/codex/catalog/provider-fetch.ts | 3 +- src/usage/expected-prices.ts | 17 +++--- tests/cursor-catalog.test.ts | 70 +++++++++++++++++++++ tests/cursor-claude-id.test.ts | 85 ++++++++++++++++++++++++++ tests/cursor-discovery.test.ts | 14 +++-- tests/cursor-effort-suffix.test.ts | 17 +++++- tests/cursor-umbrella-rows.test.ts | 30 ++++++++- tests/usage-cost.test.ts | 12 ++-- 11 files changed, 389 insertions(+), 64 deletions(-) create mode 100644 src/adapters/cursor/claude-id.ts create mode 100644 tests/cursor-claude-id.test.ts diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index d355708736..5e8f82c794 100644 --- a/src/adapters/cursor/catalog.ts +++ b/src/adapters/cursor/catalog.ts @@ -1,3 +1,9 @@ +import { + composeCursorClaudeWireId, + normalizeCursorClaudeId, + type NormalizedCursorClaudeId, +} from "./claude-id"; + /** * Cursor umbrella catalog — the single source of truth for cursor model * identities (devlog 260828_cursor_umbrella_catalog). @@ -120,12 +126,8 @@ export const CURSOR_CAPABILITIES: Record = { thinking: { levels: FULL, order: T }, }, }, - // 260902 preemptive: Claude Fable 5.1 seeded ahead of Cursor's lineup update, mirroring - // claude-fable-5 (same 1M window and full effort ladder). Cursor has spelled Claude ids - // both Anthropic-style (`claude-opus-4-7`, thinking-then-effort) and version-first - // (`claude-4.6-opus`, effort-then-thinking), so all three plausible spellings are seeded; - // the live GetUsableModels filter drops whichever the roster does not expose. Collapse to - // the one real spelling once it is observed. + // Claude Fable 5.1 has one canonical capability row. Saved aliases and the live roster's + // exact spelling are normalized and round-tripped at the adapter boundary. "claude-fable-5-1": { displayName: "Claude Fable 5.1", window: CONTEXT_1M, @@ -135,24 +137,6 @@ export const CURSOR_CAPABILITIES: Record = { thinking: { levels: FULL, order: T }, }, }, - "claude-fable-5.1": { - displayName: "Claude Fable 5.1", - window: CONTEXT_1M, - defaultVariant: "thinking", - variants: { - regular: { levels: FULL }, - thinking: { levels: FULL, order: T }, - }, - }, - "claude-5.1-fable": { - displayName: "Claude Fable 5.1", - window: CONTEXT_1M, - defaultVariant: "thinking", - variants: { - regular: { levels: FULL }, - thinking: { levels: FULL, order: E }, - }, - }, "claude-sonnet-5": { displayName: "Claude Sonnet 5", window: CONTEXT_1M, @@ -382,13 +366,26 @@ const REAL_1M_WIRE_IDS: ReadonlySet = new Set(["claude-4-sonnet-1m"]); export function parseCursorVariantId(rawId: string): ParsedCursorVariantId { const id = rawId.trim(); + if (REAL_1M_WIRE_IDS.has(id)) { + return { baseId: id, kind: "regular", ultra: false, known: false }; + } + const claude = normalizeCursorClaudeId(id); + if (claude && CURSOR_CAPABILITIES[claude.canonicalBaseId]) { + const explicitVariant = claude.thinking || claude.fast || claude.level !== undefined; + return { + baseId: claude.canonicalBaseId, + kind: explicitVariant + ? claude.thinking ? (claude.fast ? "thinkingFast" : "thinking") : claude.fast ? "fast" : "regular" + : defaultKindFor(claude.canonicalBaseId), + ...(claude.level ? { level: claude.level } : {}), + ultra: false, + known: true, + }; + } // 1. Exact base identity. if (CURSOR_CAPABILITIES[id]) { return { baseId: id, kind: defaultKindFor(id), ultra: false, known: true }; } - if (REAL_1M_WIRE_IDS.has(id)) { - return { baseId: id, kind: "regular", ultra: false, known: false }; - } // 2. cursor- wire prefix (regular grok wire forms). if (id.startsWith("cursor-")) { const inner = parseCursorVariantId(id.slice("cursor-".length)); @@ -537,17 +534,32 @@ export interface CursorResolvedSelection { readonly known: boolean; } +type CursorLiveClaudeWireIdentity = Pick; + /** * Compose a variant's flattened wire id, reproducing the legacy effort-map * order rules exactly (thinking-then-effort / effort-then-thinking / bare; * fast marker terminal; wrong order is ERROR_BAD_MODEL_NAME on the wire). */ -function composeWireId(baseId: string, kind: CursorVariantKind, effort: string | undefined): string { +function composeWireId( + baseId: string, + kind: CursorVariantKind, + effort: string | undefined, + claudeIdentity?: CursorLiveClaudeWireIdentity, +): string { const capability = CURSOR_CAPABILITIES[baseId]; const spec = capability?.variants[kind]; if (!capability || !spec) return baseId; const thinking = kind === "thinking" || kind === "thinkingFast"; const fast = kind === "fast" || kind === "thinkingFast"; + if (claudeIdentity) { + return composeCursorClaudeWireId(claudeIdentity, { + thinking, + fast, + effort, + bareThinking: spec.order === "bare", + }); + } if (thinking) { const order = spec.order ?? "thinking-then-effort"; if (order === "bare" || effort === undefined) return `${baseId}-thinking`; @@ -587,7 +599,12 @@ export function resolveCursorSelection( } const requested = parsed.level ?? reasoning; const effort = cursorVariantEffort(spec, requested); - const canonicalId = composeWireId(parsed.baseId, kind, effort); + const requestedClaude = normalizeCursorClaudeId(pickedId); + const claudeIdentity = liveCursorClaudeWireIdentities.get(parsed.baseId) + ?? (requestedClaude + ? { sourceBaseId: requestedClaude.sourceBaseId, spelling: requestedClaude.spelling } + : undefined); + const canonicalId = composeWireId(parsed.baseId, kind, effort, claudeIdentity); const wireId = capability.wirePrefix && kind === "regular" ? `${capability.wirePrefix}${canonicalId}` : canonicalId; @@ -604,6 +621,25 @@ export function resolveCursorSelection( * arrives — never from window size (devlog 260828 blocker-4 fold). */ let liveCursorMaxModeBases: ReadonlySet = new Set(); +let liveCursorClaudeWireIdentities: ReadonlyMap = new Map(); + +export function recordLiveCursorClaudeModels(liveIds: readonly string[]): void { + const next = new Map(); + for (const rawId of liveIds) { + const n = normalizeCursorClaudeId(rawId.startsWith("cursor-") ? rawId.slice(7) : rawId); + if (!n || !CURSOR_CAPABILITIES[n.canonicalBaseId]) continue; + if (!next.has(n.canonicalBaseId)) next.set(n.canonicalBaseId, { sourceBaseId: n.sourceBaseId, spelling: n.spelling }); + } + liveCursorClaudeWireIdentities = next; +} + +export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap { + return liveCursorClaudeWireIdentities; +} + +export function resetLiveCursorClaudeWireIdentitiesForTests(): void { + liveCursorClaudeWireIdentities = new Map(); +} export function recordLiveCursorMaxModeModels(liveIds: readonly string[]): void { const bases = new Set(); diff --git a/src/adapters/cursor/claude-id.ts b/src/adapters/cursor/claude-id.ts new file mode 100644 index 0000000000..9394f6ab97 --- /dev/null +++ b/src/adapters/cursor/claude-id.ts @@ -0,0 +1,76 @@ +export type CursorClaudeSpelling = "anthropic" | "version-first"; + +export interface NormalizedCursorClaudeId { + /** The sole key used by CURSOR_CAPABILITIES and pricing metadata. */ + canonicalBaseId: string; + /** Exact input stem, preserving `5-1` versus `5.1` for wire round-trips. */ + sourceBaseId: string; + spelling: CursorClaudeSpelling; + thinking: boolean; + fast: boolean; + level?: string; +} + +const CLAUDE_LEVELS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "extra-high"]); + +/** Existing picker bases whose canonical key stays version-first (saved configs). */ +const VERSION_FIRST_CANONICAL_BASES = new Set([ + "claude-4.5-haiku", "claude-4.5-opus", "claude-4.6-opus", "claude-4.5-sonnet", "claude-4.6-sonnet", "claude-4-sonnet", +]); + +function parseClaudeBase(raw: string): { canonicalBaseId: string; sourceBaseId: string; spelling: CursorClaudeSpelling } | undefined { + const anthropic = /^claude-(fable|haiku|opus|sonnet)-(\d+(?:[.-]\d+)*)$/.exec(raw); + if (anthropic) { + const family = anthropic[1]!; + const version = anthropic[2]!.replaceAll(".", "-"); + const versionFirst = `claude-${version.replaceAll("-", ".")}-${family}`; + return { + canonicalBaseId: VERSION_FIRST_CANONICAL_BASES.has(versionFirst) ? versionFirst : `claude-${family}-${version}`, + sourceBaseId: raw, + spelling: "anthropic", + }; + } + const versionFirst = /^claude-(\d+(?:\.\d+)*)-(fable|haiku|opus|sonnet)$/.exec(raw); + if (!versionFirst) return undefined; + const sourceBaseId = `claude-${versionFirst[1]!}-${versionFirst[2]!}`; + return { + canonicalBaseId: VERSION_FIRST_CANONICAL_BASES.has(sourceBaseId) ? sourceBaseId : `claude-${versionFirst[2]!}-${versionFirst[1]!.replaceAll(".", "-")}`, + sourceBaseId, + spelling: "version-first", + }; +} + +export function normalizeCursorClaudeId(raw: string): NormalizedCursorClaudeId | undefined { + const id = raw.trim().toLowerCase(); + const patterns: ReadonlyArray { base: string; thinking: boolean; fast: boolean; level?: string }]> = [ + [/^(.*)-thinking-([a-z-]+)-fast$/, m => ({ base: m[1]!, thinking: true, fast: true, level: m[2]! })], + [/^(.*)-([a-z-]+)-thinking-fast$/, m => ({ base: m[1]!, thinking: true, fast: true, level: m[2]! })], + [/^(.*)-thinking-([a-z-]+)$/, m => ({ base: m[1]!, thinking: true, fast: false, level: m[2]! })], + [/^(.*)-([a-z-]+)-thinking$/, m => ({ base: m[1]!, thinking: true, fast: false, level: m[2]! })], + [/^(.*)-([a-z-]+)-fast$/, m => ({ base: m[1]!, thinking: false, fast: true, level: m[2]! })], + [/^(.*)-thinking-fast$/, m => ({ base: m[1]!, thinking: true, fast: true })], + [/^(.*)-thinking$/, m => ({ base: m[1]!, thinking: true, fast: false })], + [/^(.*)-fast$/, m => ({ base: m[1]!, thinking: false, fast: true })], + ]; + for (const [pattern, dims] of patterns) { + const match = pattern.exec(id); + if (!match) continue; + const parsed = dims(match); + if (parsed.level && !CLAUDE_LEVELS.has(parsed.level)) continue; + const base = parseClaudeBase(parsed.base); + if (base) return { ...base, ...parsed, sourceBaseId: base.sourceBaseId }; + } + const base = parseClaudeBase(id); + return base ? { ...base, thinking: false, fast: false } : undefined; +} + +export function composeCursorClaudeWireId( + identity: Pick, + options: { thinking: boolean; fast: boolean; effort?: string; bareThinking?: boolean }, +): string { + const { sourceBaseId: base, spelling } = identity; + const fast = options.fast ? "-fast" : ""; + if (!options.thinking) return options.effort ? `${base}-${options.effort}${fast}` : `${base}${fast}`; + if (options.bareThinking || !options.effort) return `${base}-thinking${fast}`; + return spelling === "version-first" ? `${base}-${options.effort}-thinking${fast}` : `${base}-thinking-${options.effort}${fast}`; +} diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index 371e2f40be..8855892f06 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -1,3 +1,5 @@ +import { composeCursorClaudeWireId, normalizeCursorClaudeId } from "./claude-id"; + /** * Per-model Cursor reasoning-effort mapping. * @@ -23,11 +25,8 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // max is always the top tier (canonical order: low < medium < high < xhigh < max), confirmed // against Anthropic's effort ladder docs and Cursor's live model lineup. "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], - // 260902 preemptive: Fable 5.1 seeded ahead of Cursor's lineup update (mirrors fable-5) under - // the three spellings Cursor has used for Claude ids. + // Fable 5.1 aliases normalize onto this sole capability ladder. "claude-fable-5-1": ["low", "medium", "high", "xhigh", "max"], - "claude-fable-5.1": ["low", "medium", "high", "xhigh", "max"], - "claude-5.1-fable": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], // Opus Fast tiers from the 260822 GetUsableModels dump (devlog .../300): the wire // exposes {base-without-fast}-{effort}-fast only; suffix derivation at the bottom of @@ -55,8 +54,6 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { "claude-sonnet-5-thinking": ["low", "medium", "high", "xhigh", "max"], "claude-fable-5-thinking": ["low", "medium", "high", "xhigh", "max"], "claude-fable-5-1-thinking": ["low", "medium", "high", "xhigh", "max"], - "claude-fable-5.1-thinking": ["low", "medium", "high", "xhigh", "max"], - "claude-5.1-fable-thinking": ["low", "medium", "high", "xhigh", "max"], "claude-4.6-opus-thinking": ["high", "max"], "claude-4.5-opus-thinking": ["high"], "claude-4.6-sonnet-thinking": ["medium"], @@ -122,8 +119,6 @@ const CURSOR_THINKING_FAMILIES: Readonly tierSet.has(effort)); @@ -196,7 +197,7 @@ export function cursorModelEffortLadder(baseModelId: string): string[] | undefin /** Base models known to carry a reasoning-effort suffix (everything else is sent bare). */ export function cursorModelHasEffortTiers(baseModelId: string): boolean { - return (CURSOR_MODEL_EFFORT_TIERS[baseModelId]?.length ?? 0) > 0; + return (CURSOR_MODEL_EFFORT_TIERS[cursorEffortLookupId(baseModelId)]?.length ?? 0) > 0; } /** @@ -205,7 +206,17 @@ export function cursorModelHasEffortTiers(baseModelId: string): boolean { * and send the base model plus requested_model parameters instead. */ export function cursorWireModelIdWithEffort(baseModelId: string, effortSuffix: string): string { - const thinking = CURSOR_THINKING_FAMILIES[baseModelId]; + const lookupId = cursorEffortLookupId(baseModelId); + const thinking = CURSOR_THINKING_FAMILIES[lookupId]; + const claude = normalizeCursorClaudeId(baseModelId); + if (claude) { + return composeCursorClaudeWireId(claude, { + thinking: claude.thinking, + fast: claude.fast, + effort: effortSuffix, + bareThinking: thinking?.order === "bare", + }); + } if (thinking) { const { source, order } = thinking; // Cursor writes the thinking marker on either side of the effort depending on family diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 66b7f63a88..d94cb99eb5 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -49,7 +49,7 @@ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -1421,6 +1421,7 @@ async function fetchProviderModelsWithAuth( ...(cursorFetch ? { fetch: cursorFetch } : {}), }); if (liveResult.ok) { + recordLiveCursorClaudeModels(liveResult.models); const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); // Live Max-Mode evidence feeds the umbrella resolver's ultra gate // (devlog 260828_cursor_umbrella_catalog; union with static evidence). diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 23e5412150..dfa26fe174 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -1,3 +1,5 @@ +import { normalizeCursorClaudeId } from "../adapters/cursor/claude-id"; + /** * Expected-price overlay for models whose jawcode cost rows are missing or all-zero * (subscription/OAuth surfaces). Sourced from official pricing pages only @@ -100,13 +102,8 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ // bundle collapses anthropic-apikey onto anthropic). { provider: "anthropic", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input`, verifiedAt: "2026-09-02", status: "verified" }, { provider: "anthropic-apikey", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input`, verifiedAt: "2026-09-02", status: "verified" }, - // Cursor seeds Fable 5.1 preemptively under three spellings (adapters/cursor/catalog.ts); - // the model-level vendor fallback only searches jawcode metadata, which has no Fable 5.1 - // row yet, so each Cursor spelling needs its own overlay. Vendor list price, like the - // cursor/claude-opus-5 row. + // Cursor canonicalizes every Fable 5.1 spelling onto this sole overlay row. { provider: "cursor", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" }, - { provider: "cursor", modelId: "claude-fable-5.1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" }, - { provider: "cursor", modelId: "claude-5.1-fable", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" }, // claude-opus-5 is exposed by three providers but absent from the jawcode bundle, so // cost resolution returned null and the Logs `~$` column rendered an em dash. The // model-level vendor fallback only searches jawcode metadata, never overlays, so one @@ -236,8 +233,14 @@ export function findExpectedPriceOverlay( overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, ): ExpectedPriceOverlay | undefined { const exact = overlays.filter(row => row.provider === provider && row.modelId === modelId); - return exact.find(row => row.status === "verified") + const match = exact.find(row => row.status === "verified") ?? exact.find(row => row.status === "verified-derived"); + if (match || provider !== "cursor") return match; + const canonicalBaseId = normalizeCursorClaudeId(modelId)?.canonicalBaseId; + if (!canonicalBaseId) return undefined; + const canonical = overlays.filter(row => row.provider === provider && row.modelId === canonicalBaseId); + return canonical.find(row => row.status === "verified") + ?? canonical.find(row => row.status === "verified-derived"); } /** OpenAI Fast price multipliers retained as a compatibility export. */ diff --git a/tests/cursor-catalog.test.ts b/tests/cursor-catalog.test.ts index 5a690b1614..a5826fd151 100644 --- a/tests/cursor-catalog.test.ts +++ b/tests/cursor-catalog.test.ts @@ -3,6 +3,8 @@ import { CURSOR_CAPABILITIES, cursorUmbrellaRows, parseCursorVariantId, + recordLiveCursorClaudeModels, + resetLiveCursorClaudeWireIdentitiesForTests, resolveCursorSelection, } from "../src/adapters/cursor/catalog"; import { @@ -32,6 +34,26 @@ const LEGACY_EFFORT_IDS = [ const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra", undefined] as const; +const EXISTING_CLAUDE_WIRE_SNAPSHOT = { + "claude-opus-5@high": "claude-opus-5-thinking-high", + "claude-opus-5-thinking-fast@max": "claude-opus-5-thinking-max-fast", + "claude-4.6-opus@max": "claude-4.6-opus-max-thinking", + "claude-4.6-opus-thinking@high": "claude-4.6-opus-high-thinking", + "claude-4.5-sonnet@high": "claude-4.5-sonnet-thinking", + "claude-4.5-sonnet-thinking@max": "claude-4.5-sonnet-thinking", +} as const; + +function existingClaudeWireSnapshot(): Record { + return { + "claude-opus-5@high": resolveCursorSelection("claude-opus-5", "high").wireId, + "claude-opus-5-thinking-fast@max": resolveCursorSelection("claude-opus-5-thinking-fast", "max").wireId, + "claude-4.6-opus@max": resolveCursorSelection("claude-4.6-opus", "max").wireId, + "claude-4.6-opus-thinking@high": resolveCursorSelection("claude-4.6-opus-thinking", "high").wireId, + "claude-4.5-sonnet@high": resolveCursorSelection("claude-4.5-sonnet", "high").wireId, + "claude-4.5-sonnet-thinking@max": resolveCursorSelection("claude-4.5-sonnet-thinking", "max").wireId, + }; +} + /** Legacy composition: what request-builder sends today for a picked id + effort. */ function legacyWireId(pickedId: string, reasoning: string | undefined): string { // request-builder strips the synthetic -1m marker before composing. @@ -95,6 +117,23 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = } } }); + + test("existing Claude wire ids are byte-identical before and after live-roster state is reset", () => { + resetLiveCursorClaudeWireIdentitiesForTests(); + const before = existingClaudeWireSnapshot(); + try { + recordLiveCursorClaudeModels([ + "claude-5-opus-thinking-high", + "claude-opus-4-6-thinking-high", + "claude-sonnet-4-5-thinking", + ]); + } finally { + resetLiveCursorClaudeWireIdentitiesForTests(); + } + const after = existingClaudeWireSnapshot(); + expect(before).toEqual(EXISTING_CLAUDE_WIRE_SNAPSHOT); + expect(after).toEqual(EXISTING_CLAUDE_WIRE_SNAPSHOT); + }); }); describe("parser precedence", () => { @@ -120,6 +159,16 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = expect(parseCursorVariantId("grok-4.6-high-fast")).toMatchObject({ baseId: "grok-4.6", kind: "fast", level: "high" }); }); + test("every Fable 5.1 spelling parses to the canonical capability base", () => { + for (const id of ["claude-fable-5-1", "claude-fable-5.1", "claude-5.1-fable"]) { + expect(parseCursorVariantId(id), id).toMatchObject({ + baseId: "claude-fable-5-1", + kind: "thinking", + known: true, + }); + } + }); + test("unknown ids pass through unchanged", () => { const parsed = parseCursorVariantId("composer-9.9-special"); expect(parsed.known).toBe(false); @@ -142,6 +191,26 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = expect(resolveCursorSelection("claude-opus-5-thinking-fast", "max").wireId).toBe("claude-opus-5-thinking-max-fast"); }); + test("Fable 5.1 saved aliases stay routable with their exact spelling when no roster is recorded", () => { + resetLiveCursorClaudeWireIdentitiesForTests(); + expect(resolveCursorSelection("claude-fable-5.1", "high").wireId).toBe("claude-fable-5.1-thinking-high"); + expect(resolveCursorSelection("claude-5.1-fable", "max").wireId).toBe("claude-5.1-fable-max-thinking"); + expect(resolveCursorSelection("claude-fable-5.1-thinking", "xhigh").wireId) + .toBe("claude-fable-5.1-thinking-xhigh"); + expect(resolveCursorSelection("claude-5.1-fable-thinking", "max").wireId) + .toBe("claude-5.1-fable-max-thinking"); + }); + + test("the live roster spelling overrides both requested and canonical spellings", () => { + recordLiveCursorClaudeModels(["claude-5.1-fable-high-thinking"]); + try { + expect(resolveCursorSelection("claude-fable-5-1", "high").wireId).toBe("claude-5.1-fable-high-thinking"); + expect(resolveCursorSelection("claude-fable-5.1", "high").wireId).toBe("claude-5.1-fable-high-thinking"); + } finally { + resetLiveCursorClaudeWireIdentitiesForTests(); + } + }); + test("ultra arms maxMode only on evidence-gated bases", () => { const kimi = resolveCursorSelection("kimi-k3-1m", "ultra"); expect(kimi.maxMode).toBe(true); @@ -164,6 +233,7 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = expect(ids).not.toContain("claude-opus-5-thinking"); expect(ids).not.toContain("claude-opus-5-fast"); expect(ids).not.toContain("kimi-k3-1m"); + expect(ids.filter(id => id.includes("fable") && id.includes("5-1"))).toEqual(["claude-fable-5-1"]); expect(rows.length).toBe(Object.keys(CURSOR_CAPABILITIES).length); const kimi = rows.find(row => row.id === "kimi-k3"); expect(kimi?.maxModeVerified).toBe(true); diff --git a/tests/cursor-claude-id.test.ts b/tests/cursor-claude-id.test.ts new file mode 100644 index 0000000000..7c424d17bf --- /dev/null +++ b/tests/cursor-claude-id.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + composeCursorClaudeWireId, + normalizeCursorClaudeId, +} from "../src/adapters/cursor/claude-id"; + +describe("Cursor Claude id normalization", () => { + test("normalizes every observed Fable 5.1 spelling to one capability base", () => { + for (const id of ["claude-fable-5-1", "claude-fable-5.1", "claude-5.1-fable"]) { + expect(normalizeCursorClaudeId(id), id).toMatchObject({ + canonicalBaseId: "claude-fable-5-1", + thinking: false, + fast: false, + }); + } + }); + + test("extracts thinking, fast, and effort from both marker orders", () => { + expect(normalizeCursorClaudeId("claude-fable-5.1-thinking-xhigh-fast")).toMatchObject({ + canonicalBaseId: "claude-fable-5-1", + sourceBaseId: "claude-fable-5.1", + spelling: "anthropic", + thinking: true, + fast: true, + level: "xhigh", + }); + expect(normalizeCursorClaudeId("claude-5.1-fable-max-thinking-fast")).toMatchObject({ + canonicalBaseId: "claude-fable-5-1", + sourceBaseId: "claude-5.1-fable", + spelling: "version-first", + thinking: true, + fast: true, + level: "max", + }); + expect(normalizeCursorClaudeId("claude-opus-5-high-fast")).toMatchObject({ + canonicalBaseId: "claude-opus-5", + thinking: false, + fast: true, + level: "high", + }); + }); + + test("preserves the exact dotted source base for wire round-trips", () => { + expect(normalizeCursorClaudeId(" CLAUDE-FABLE-5.1-THINKING-HIGH ")).toMatchObject({ + canonicalBaseId: "claude-fable-5-1", + sourceBaseId: "claude-fable-5.1", + spelling: "anthropic", + thinking: true, + fast: false, + level: "high", + }); + }); + + test("does not absorb real 1m rows or unknown Claude products", () => { + expect(normalizeCursorClaudeId("claude-4-sonnet-1m")).toBeUndefined(); + expect(normalizeCursorClaudeId("claude-fable-5-1-preview")).toBeUndefined(); + expect(normalizeCursorClaudeId("claude-composer-5-1")).toBeUndefined(); + }); + + test("composes Anthropic-style and version-first wire orders exactly", () => { + const anthropic = normalizeCursorClaudeId("claude-fable-5.1")!; + const versionFirst = normalizeCursorClaudeId("claude-5.1-fable")!; + expect(composeCursorClaudeWireId(anthropic, { + thinking: true, + fast: true, + effort: "xhigh", + })).toBe("claude-fable-5.1-thinking-xhigh-fast"); + expect(composeCursorClaudeWireId(versionFirst, { + thinking: true, + fast: false, + effort: "max", + })).toBe("claude-5.1-fable-max-thinking"); + expect(composeCursorClaudeWireId(versionFirst, { + thinking: true, + fast: true, + effort: "high", + bareThinking: true, + })).toBe("claude-5.1-fable-thinking-fast"); + expect(composeCursorClaudeWireId(anthropic, { + thinking: false, + fast: true, + effort: "medium", + })).toBe("claude-fable-5.1-medium-fast"); + }); +}); diff --git a/tests/cursor-discovery.test.ts b/tests/cursor-discovery.test.ts index efa3062ba8..0087a2bb8e 100644 --- a/tests/cursor-discovery.test.ts +++ b/tests/cursor-discovery.test.ts @@ -57,12 +57,10 @@ describe("Cursor discovery metadata", () => { expect(ids).toContain("glm-5.2"); expect(ids).toContain("kimi-k2.7-code"); expect(ids).toContain("kimi-k3"); - // 260902 preemptive seed: Fable 5.1 registered ahead of Cursor's lineup update, at 1M, - // under the three spellings Cursor has used for Claude ids. - for (const spelling of ["claude-fable-5-1", "claude-fable-5.1", "claude-5.1-fable"]) { - expect(ids).toContain(spelling); - expect(cursorModelContextWindows(CURSOR_STATIC_MODELS)[spelling]).toBe(1_000_000); - } + // Fable 5.1 has one canonical picker row; saved/live spellings stay adapter aliases. + expect(ids.filter(id => id.includes("fable") && (id.includes("5-1") || id.includes("5.1")))) + .toEqual(["claude-fable-5-1"]); + expect(cursorModelContextWindows(CURSOR_STATIC_MODELS)["claude-fable-5-1"]).toBe(1_000_000); // Any live Fable spelling the seed does not carry still infers a 1M window. expect(inferCursorContextWindow("claude-fable-6")).toBe(1_000_000); // Umbrella merge (devlog 260828): fast duplicate rows folded into bases. @@ -91,6 +89,10 @@ describe("Cursor discovery metadata", () => { expect(isCursorModelAvailableForAccount("claude-4-sonnet", ["claude-4-sonnet-1m"])).toBe(false); expect(isCursorModelAvailableForAccount("gpt-5.5", ["gpt-5.5-extra-high"])).toBe(false); expect(isCursorModelAvailableForAccount("gpt-5.5-extra", ["gpt-5.5-extra-high"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-fable-5-1", ["claude-fable-5.1-thinking-high"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-fable-5-1", ["claude-5.1-fable-high-thinking"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-fable-5-1", ["claude-fable-5-2-thinking-high"])).toBe(false); + expect(isCursorModelAvailableForAccount("claude-fable-5-2", ["claude-fable-5-1-thinking-high"])).toBe(false); // Issue #117: Cursor GetUsableModels may return ids with a `cursor-` wire prefix. expect(isCursorModelAvailableForAccount("grok-4.5", ["cursor-grok-4.5-high"])).toBe(true); diff --git a/tests/cursor-effort-suffix.test.ts b/tests/cursor-effort-suffix.test.ts index c509e2fc56..917c2b1b2e 100644 --- a/tests/cursor-effort-suffix.test.ts +++ b/tests/cursor-effort-suffix.test.ts @@ -203,6 +203,19 @@ describe("Cursor per-model reasoning-effort suffix", () => { expect(cursorModelEffortLadder("glm-5.2")).toEqual(["high", "max"]); expect(cursorModelEffortLadder("composer-2.5")).toBeUndefined(); }); + + test("all Fable 5.1 spellings share the canonical effort ladder", () => { + for (const id of [ + "claude-fable-5-1", + "claude-fable-5.1", + "claude-5.1-fable", + "claude-fable-5.1-thinking", + "claude-5.1-fable-thinking", + ]) { + expect(cursorModelEffortLadder(id), id).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(cursorEffortSuffix(id, "xhigh"), id).toBe("xhigh"); + } + }); }); describe("#2569 Cursor catalog tracks the live GetUsableModels roster", () => { @@ -232,7 +245,7 @@ describe("#2569 Cursor catalog tracks the live GetUsableModels roster", () => { }); }); -describe("#2569 Cursor explicit-thinking variants", () => { +describe("#2569 Cursor explicit-thinking wire order", () => { /** * Suffix ORDER differs per family and the wrong one is rejected ERROR_BAD_MODEL_NAME. * Cases recorded from the live GetUsableModels roster on 2026-08-25. @@ -244,9 +257,9 @@ describe("#2569 Cursor explicit-thinking variants", () => { ["claude-opus-4-8-thinking-fast", "xhigh", "claude-opus-4-8-thinking-xhigh-fast"], ["claude-sonnet-5-thinking", "medium", "claude-sonnet-5-thinking-medium"], ["claude-fable-5-thinking", "xhigh", "claude-fable-5-thinking-xhigh"], + // The same canonical Fable family preserves each input's own wire spelling/order. ["claude-fable-5-1-thinking", "xhigh", "claude-fable-5-1-thinking-xhigh"], ["claude-fable-5.1-thinking", "xhigh", "claude-fable-5.1-thinking-xhigh"], - // Version-first spelling follows the 4.x families: marker at the END. ["claude-5.1-fable-thinking", "max", "claude-5.1-fable-max-thinking"], // The marker moves to the END for these families. ["claude-4.6-opus-thinking", "max", "claude-4.6-opus-max-thinking"], diff --git a/tests/cursor-umbrella-rows.test.ts b/tests/cursor-umbrella-rows.test.ts index 31a3f03a48..7cb1bb1186 100644 --- a/tests/cursor-umbrella-rows.test.ts +++ b/tests/cursor-umbrella-rows.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import { cursorUmbrellaRows, + liveCursorClaudeWireIdentitiesForTests, + recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels, + resetLiveCursorClaudeWireIdentitiesForTests, resolveCursorSelection, } from "../src/adapters/cursor/catalog"; import { @@ -33,6 +36,9 @@ describe("cursor umbrella picker rows (devlog 260828_cursor_umbrella_catalog)", expect(ids).not.toContain("claude-opus-5-fast"); expect(ids).not.toContain("grok-4.5-fast"); expect(ids).not.toContain("grok-4.6-fast"); + expect(ids).not.toContain("claude-fable-5.1"); + expect(ids).not.toContain("claude-5.1-fable"); + expect(ids.filter(id => id === "claude-fable-5-1")).toHaveLength(1); // composer-2.5-fast has no umbrella base with effort dimensions; it stays. expect(ids).toContain("composer-2.5-fast"); }); @@ -42,7 +48,7 @@ describe("cursor umbrella picker rows (devlog 260828_cursor_umbrella_catalog)", }); test("the seed is composed of routers + umbrella bases + declared product ids", () => { - // 4 routers + 34 umbrella bases + 13 product ids + 3 real-id exceptions. + // 4 routers + 32 umbrella bases + 13 product ids + 3 real-id exceptions. // Derived, not frozen: the hard-coded count drifted twice already (51 -> 54 when // #3211 pre-seeded Claude Fable 5.1 under three spellings), so the expectation now // comes from the same capability table the seed is built from. @@ -125,4 +131,26 @@ describe("cursor umbrella picker rows (devlog 260828_cursor_umbrella_catalog)", expect(resolveCursorSelection("kimi-k3", "ultra").maxMode).toBe(true); }); }); + + describe("live Claude wire identity", () => { + test("each successful roster replaces the spelling map atomically and reset clears it", () => { + resetLiveCursorClaudeWireIdentitiesForTests(); + recordLiveCursorClaudeModels([ + "claude-5.1-fable-high-thinking", + "claude-opus-5-thinking-high", + ]); + expect([...liveCursorClaudeWireIdentitiesForTests().entries()]).toEqual([ + ["claude-fable-5-1", { sourceBaseId: "claude-5.1-fable", spelling: "version-first" }], + ["claude-opus-5", { sourceBaseId: "claude-opus-5", spelling: "anthropic" }], + ]); + + recordLiveCursorClaudeModels(["claude-fable-5.1-thinking-xhigh"]); + expect([...liveCursorClaudeWireIdentitiesForTests().entries()]).toEqual([ + ["claude-fable-5-1", { sourceBaseId: "claude-fable-5.1", spelling: "anthropic" }], + ]); + + resetLiveCursorClaudeWireIdentitiesForTests(); + expect(liveCursorClaudeWireIdentitiesForTests().size).toBe(0); + }); + }); }); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index f6bf02d482..c840837b91 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -195,10 +195,10 @@ describe("resolveMatchedPrice", () => { expect(price?.sourceRef).toContain("0.025x"); } expect(resolveMatchedPrice("anthropic-pb51d9b", "claude-fable-5-1")?.cost4).toEqual(COST4); - // Cursor seeds the id preemptively under three spellings and jawcode has no row, so - // each carries its own (derived) overlay rather than falling through to null. + // Cursor accepts all three spellings but pricing stores one canonical overlay row. for (const spelling of ["claude-fable-5-1", "claude-fable-5.1", "claude-5.1-fable"]) { expect(resolveMatchedPrice("cursor", spelling), spelling).toMatchObject({ cost4: COST4, source: "expected", status: "verified-derived" }); + expect(findExpectedPriceOverlay("cursor", spelling)?.modelId, spelling).toBe("claude-fable-5-1"); } // The cheaper cache-hit rate must not leak onto Fable 5, which stays at 0.1x. expect(resolveMatchedPrice("anthropic", "claude-fable-5")?.cost4.cacheRead).toBe(1); @@ -297,16 +297,14 @@ describe("resolveMatchedPrice", () => { expect(resolveMatchedPrice("openrouter", "anthropic-claude-3.5-sonnet")).toBeNull(); }); - test("16. shipped overlay membership: 61 keys, including Fable 5.1, Opus 5 and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(61); + test("16. shipped overlay membership: 59 keys, including canonical Fable 5.1, Opus 5 and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(59); expect(EXPECTED_PRICE_OVERLAYS.some(row => row.status === "unverified")).toBe(false); const keys = new Set(EXPECTED_PRICE_OVERLAYS.map(row => `${row.provider}/${row.modelId}`)); for (const expected of [ "anthropic/claude-fable-5-1", "anthropic-apikey/claude-fable-5-1", "cursor/claude-fable-5-1", - "cursor/claude-fable-5.1", - "cursor/claude-5.1-fable", "anthropic/claude-opus-5", "cursor/claude-opus-5", "kiro/claude-opus-5", @@ -365,6 +363,8 @@ describe("resolveMatchedPrice", () => { "openai/daybreak-blue-latest", "openai/daybreak-red-latest", "openai-apikey/gpt-daybreak-blue-latest", + "cursor/claude-fable-5.1", + "cursor/claude-5.1-fable", ]) { expect(keys.has(impossible)).toBe(false); } From 4340a05aee56530d00b91223a01bda73693f049d Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 23:08:22 +0900 Subject: [PATCH 2/2] fix(cursor): publish live Claude spelling and Max-Mode evidence only after the cache accepts the capture --- src/codex/catalog/provider-fetch.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index d94cb99eb5..8025ad9471 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1421,11 +1421,7 @@ async function fetchProviderModelsWithAuth( ...(cursorFetch ? { fetch: cursorFetch } : {}), }); if (liveResult.ok) { - recordLiveCursorClaudeModels(liveResult.models); const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); - // Live Max-Mode evidence feeds the umbrella resolver's ultra gate - // (devlog 260828_cursor_umbrella_catalog; union with static evidence). - recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); const result = available.length > 0 ? available : configured; // Cache the discovery-filtered roster without combo retention so a later // gather can re-apply the current capture's retain set on read. @@ -1433,6 +1429,13 @@ async function fetchProviderModelsWithAuth( if (!setCached(name, forCache, Date.now(), cacheGeneration)) { return observed(withConfiguredRetention(configured), "degraded"); } + // Publish roster-derived state only for a discovery the cache accepted: a stale + // in-flight capture (generation revoked by a credential/config change) must not + // overwrite the spelling or Max-Mode evidence of the newer one. + recordLiveCursorClaudeModels(liveResult.models); + // Live Max-Mode evidence feeds the umbrella resolver's ultra gate + // (devlog 260828_cursor_umbrella_catalog; union with static evidence). + recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); markProviderDiscoveryOk(name, liveResult.models.length); return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); }