Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 65 additions & 29 deletions src/adapters/cursor/catalog.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -120,12 +126,8 @@ export const CURSOR_CAPABILITIES: Record<string, CursorCapability> = {
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,
Expand All @@ -135,24 +137,6 @@ export const CURSOR_CAPABILITIES: Record<string, CursorCapability> = {
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,
Expand Down Expand Up @@ -382,13 +366,26 @@ const REAL_1M_WIRE_IDS: ReadonlySet<string> = 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));
Expand Down Expand Up @@ -537,17 +534,32 @@ export interface CursorResolvedSelection {
readonly known: boolean;
}

type CursorLiveClaudeWireIdentity = Pick<NormalizedCursorClaudeId, "sourceBaseId" | "spelling">;

/**
* 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`;
Expand Down Expand Up @@ -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;
Expand All @@ -604,6 +621,25 @@ export function resolveCursorSelection(
* arrives — never from window size (devlog 260828 blocker-4 fold).
*/
let liveCursorMaxModeBases: ReadonlySet<string> = new Set();
let liveCursorClaudeWireIdentities: ReadonlyMap<string, CursorLiveClaudeWireIdentity> = new Map();

export function recordLiveCursorClaudeModels(liveIds: readonly string[]): void {
const next = new Map<string, CursorLiveClaudeWireIdentity>();
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope live spellings to the selected Cursor provider

When a configuration contains two named providers using adapter: "cursor", every successful discovery replaces this process-global map with its own roster, while resolveCursorSelection reads it without knowing which provider is handling the request. If the accounts expose different Fable spellings or marker orders, whichever discovery finishes last causes requests through the other provider to use an unsupported wire ID and receive ERROR_BAD_MODEL_NAME. Store the identities per provider/account and select the corresponding map in the request path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed here: a single process has one cursor adapter roster today, matching the existing liveCursorMaxModeBases precedent. Keying by provider name is recorded as the follow-up if a second Cursor-adapter provider ever ships (050 §risks).

}

export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap<string, CursorLiveClaudeWireIdentity> {
return liveCursorClaudeWireIdentities;
}

export function resetLiveCursorClaudeWireIdentitiesForTests(): void {
liveCursorClaudeWireIdentities = new Map();
}

export function recordLiveCursorMaxModeModels(liveIds: readonly string[]): void {
const bases = new Set<string>();
Expand Down
76 changes: 76 additions & 0 deletions src/adapters/cursor/claude-id.ts
Original file line number Diff line number Diff line change
@@ -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<readonly [RegExp, (m: RegExpExecArray) => { 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<NormalizedCursorClaudeId, "sourceBaseId" | "spelling">,
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}`;
}
35 changes: 23 additions & 12 deletions src/adapters/cursor/effort-map.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { composeCursorClaudeWireId, normalizeCursorClaudeId } from "./claude-id";

/**
* Per-model Cursor reasoning-effort mapping.
*
Expand All @@ -23,11 +25,8 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
// 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
Expand Down Expand Up @@ -55,8 +54,6 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
"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"],
Expand Down Expand Up @@ -122,8 +119,6 @@ const CURSOR_THINKING_FAMILIES: Readonly<Record<string, { source: string; order:
"claude-sonnet-5-thinking": { source: "claude-sonnet-5", order: "thinking-then-effort" },
"claude-fable-5-thinking": { source: "claude-fable-5", order: "thinking-then-effort" },
"claude-fable-5-1-thinking": { source: "claude-fable-5-1", order: "thinking-then-effort" },
"claude-fable-5.1-thinking": { source: "claude-fable-5.1", order: "thinking-then-effort" },
"claude-5.1-fable-thinking": { source: "claude-5.1-fable", order: "effort-then-thinking" },
"claude-4.6-opus-thinking": { source: "claude-4.6-opus", order: "effort-then-thinking" },
"claude-4.5-opus-thinking": { source: "claude-4.5-opus", order: "effort-then-thinking" },
"claude-4.6-sonnet-thinking": { source: "claude-4.6-sonnet", order: "effort-then-thinking" },
Expand All @@ -148,6 +143,12 @@ function normalizeRequestedEffort(reasoning: string | undefined): string | undef
return normalized === "ultra" ? "max" : normalized;
}

function cursorEffortLookupId(modelId: string): string {
const claude = normalizeCursorClaudeId(modelId);
if (!claude) return modelId;
return `${claude.canonicalBaseId}${claude.thinking ? "-thinking" : ""}${claude.fast ? "-fast" : ""}`;
}

/** Collapse a Codex reasoning-effort label to a low/medium/high rank for clamping onto a model's tiers. */
function codexEffortRank(reasoning: string | undefined): "low" | "medium" | "high" {
switch (normalizeRequestedEffort(reasoning) ?? "") {
Expand All @@ -172,7 +173,7 @@ function codexEffortRank(reasoning: string | undefined): "low" | "medium" | "hig
* the model takes no suffix (bare). Literal model tiers pass through; unknown efforts clamp by rank.
*/
export function cursorEffortSuffix(baseModelId: string, reasoning: string | undefined): string | undefined {
const tiers = CURSOR_MODEL_EFFORT_TIERS[baseModelId];
const tiers = CURSOR_MODEL_EFFORT_TIERS[cursorEffortLookupId(baseModelId)];
if (!tiers || tiers.length === 0) return undefined;
const requested = normalizeRequestedEffort(reasoning);
if (requested && tiers.includes(requested)) return requested;
Expand All @@ -188,15 +189,15 @@ export function cursorEffortSuffix(baseModelId: string, reasoning: string | unde

/** The Codex-facing picker ladder for a Cursor model, sorted in canonical Codex effort order. */
export function cursorModelEffortLadder(baseModelId: string): string[] | undefined {
const tiers = CURSOR_MODEL_EFFORT_TIERS[baseModelId];
const tiers = CURSOR_MODEL_EFFORT_TIERS[cursorEffortLookupId(baseModelId)];
if (!tiers || tiers.length === 0) return undefined;
const tierSet = new Set(tiers);
return CURSOR_PICKER_EFFORT_ORDER.filter(effort => tierSet.has(effort));
}

/** 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;
}

/**
Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1422,16 +1422,20 @@ async function fetchProviderModelsWithAuth(
});
if (liveResult.ok) {
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.
const forCache = withConfiguredRetention(result, { retainComboTargets: false });
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");
}
Expand Down
Loading
Loading