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
17 changes: 17 additions & 0 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
cursorClientThreadOwner,
cursorCoveredPrefixDigest,
cursorInstructionDigest,
cursorRequestEmitsFastVariant,
} from "./cursor/request-builder";
import {
createLiveCursorTransport,
Expand All @@ -26,6 +27,7 @@ import {
invalidateCursorCheckpoint,
} from "./cursor/checkpoint-store";
import { debugProviderDiagnostic } from "../lib/debug";
import { createAdapterTierMetadata } from "../providers/fastwire";
import { estimateTokens } from "../lib/token-estimate";
import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
import { runCursorTurnWithRetry } from "./cursor/transport-retry";
Expand Down Expand Up @@ -100,6 +102,21 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
return {
name: "cursor",

// Cursor emits Fast as a model variant, so the generic "no field emitted" fallback in
// adapters/registry.ts would report every Fast turn as downgraded. This recomputes the
// variant from the same pure inputs the builder uses: tierLogForRunTurn runs BEFORE
// runTurn, and createCursorRequest mints conversation ids, so rebuilding it here would
// describe a request that was never sent.
tierLogForRunTurn(parsed) {
const fast = cursorRequestEmitsFastVariant(parsed);
return createAdapterTierMetadata(
parsed.options.tierObservation,
parsed.options.tierDecision,
fast ? "cursor-variant" : null,
fast ? "fast" : null,
);
},

buildRequest() {
return {
url: provider.baseUrl || CURSOR_API_URL,
Expand Down
43 changes: 39 additions & 4 deletions src/adapters/cursor/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,32 @@ function defaultKindFor(baseId: string): CursorVariantKind {
return CURSOR_CAPABILITIES[baseId]?.defaultVariant ?? "regular";
}

/**
* Promote a variant to its fast sibling when the base declares one, else leave it alone.
*
* Thinking must map to thinkingFast rather than to the plain fast variant: the umbrella row
* for a Claude base routes THINKING, and its regular-fast sibling is a different product
* with a shorter ladder (claude-opus-5-fast stops at high) whose regular family is
* quarantined. A base with no fast dimension keeps its kind, so Fast degrades to today's
* behavior instead of erroring.
*/
export function upgradeToFast(baseId: string, kind: CursorVariantKind): CursorVariantKind {
const variants = CURSOR_CAPABILITIES[baseId]?.variants;
if (!variants) return kind;
if (kind === "thinking" || kind === "thinkingFast") {
return variants.thinkingFast ? "thinkingFast" : kind;
}
return variants.fast ? "fast" : kind;
}

/** Bases whose capability declares a fast or thinking-fast variant. */
export function cursorFastCapableBases(): string[] {
return Object.entries(CURSOR_CAPABILITIES)
.filter(([, capability]) => capability.variants.fast !== undefined
|| capability.variants.thinkingFast !== undefined)
.map(([baseId]) => baseId);
}

function normalizeRequestedEffort(reasoning: string | undefined): string | undefined {
const normalized = reasoning?.toLowerCase();
return normalized === "ultra" ? "max" : normalized;
Expand Down Expand Up @@ -523,20 +549,25 @@ export function resolveCursorSelection(
pickedId: string,
reasoning: string | undefined,
liveMaxModeIds?: ReadonlySet<string>,
options: { fast?: boolean } = {},
): CursorResolvedSelection {
const parsed = parseCursorVariantId(pickedId);
if (!parsed.known) {
return { wireId: pickedId, canonicalId: pickedId, maxMode: false, known: false };
}
const capability = CURSOR_CAPABILITIES[parsed.baseId]!;
const spec = capability.variants[parsed.kind] ?? capability.variants.regular;
// Codex's Fast toggle is a variant switch here; every later read must use the upgraded
// kind, not parsed.kind, or the wire id loses its -fast marker (or keeps the cursor-
// prefix that only the regular variant takes).
const kind = options.fast === true ? upgradeToFast(parsed.baseId, parsed.kind) : parsed.kind;
const spec = capability.variants[kind] ?? capability.variants.regular;
if (!spec) {
return { wireId: parsed.baseId, canonicalId: parsed.baseId, maxMode: false, known: true };
}
const requested = parsed.level ?? reasoning;
const effort = cursorVariantEffort(spec, requested);
const canonicalId = composeWireId(parsed.baseId, parsed.kind, effort);
const wireId = capability.wirePrefix && parsed.kind === "regular"
const canonicalId = composeWireId(parsed.baseId, kind, effort);
const wireId = capability.wirePrefix && kind === "regular"
? `${capability.wirePrefix}${canonicalId}`
: canonicalId;
const ultraRequested = parsed.ultra || reasoning?.toLowerCase() === "ultra";
Expand Down Expand Up @@ -584,9 +615,13 @@ export interface CursorUmbrellaRow {
export function cursorGrokFastSelection(
pickedId: string,
reasoning: string | undefined,
fast?: boolean,
): { wireBaseId: string; effort: string } | undefined {
const parsed = parseCursorVariantId(pickedId);
if (!parsed.known || parsed.kind !== "fast") return undefined;
// Both call paths must learn the flag together: if only resolveCursorSelection did, a
// toggled Grok pick would emit a flattened grok-4.6-high-fast id, which the wire rejects.
const kind = fast === true ? upgradeToFast(parsed.baseId, parsed.kind) : parsed.kind;
if (!parsed.known || kind !== "fast") return undefined;
const capability = CURSOR_CAPABILITIES[parsed.baseId];
if (capability?.wirePrefix !== "cursor-") return undefined;
const spec = capability.variants.fast;
Expand Down
35 changes: 31 additions & 4 deletions src/adapters/cursor/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,40 @@ function catalogLimitNote(kept: readonly OcxTool[], omitted: readonly OcxTool[])
: `[opencodex] Cursor's transport limit allows ${kept.length} of ${kept.length + omitted.length} client tools this turn. Omitted and unavailable this turn: ${omittedSummary}.`;
}

/**
* True when this turn should take Cursor's fast variant.
*
* Reads the tier DECISION rather than the raw caller field so one authority owns precedence:
* `decideTier` has already applied config `fastMode`, the caller's `service_tier`, and the
* route's eligibility, so `fastMode: false` correctly suppresses a caller's Fast request.
* A `{kind:"set"}` decision on a Cursor route means canonical Fast survived that gate.
*/
export function cursorFastRequested(parsed: OcxParsedRequest): boolean {
return parsed.options.tierDecision?.kind === "set";
}

/**
* Whether the wire this request will carry expresses the fast variant, for tier telemetry.
*
* Recomputed from the same pure inputs the builder uses rather than read off a built
* request: `tierLogForRunTurn` runs BEFORE `runTurn` (server/responses/core.ts), and
* `createCursorRequest` is not pure — it mints conversation ids — so rebuilding there would
* report a request that was never sent.
*/
export function cursorRequestEmitsFastVariant(parsed: OcxParsedRequest): boolean {
if (!cursorFastRequested(parsed)) return false;
const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning, true);
return model.modelId.endsWith("-fast")
|| (model.requestedModelParameters ?? []).some(p => p.id === "fast" && p.value === "true");
}

/**
* Resolve a `cursor/<model>` selection + Codex reasoning effort to Cursor's requested model shape.
* Most models encode effort in a flat id (`claude-4.6-opus-high`). Grok Fast is parameterized
* instead: current Cursor clients send the matching Grok base id plus `effort` and `fast` parameters.
* A fully-qualified id (one that is not a known effort base) passes through unchanged.
*/
function normalizeCursorModelId(modelId: string, reasoning?: string): {
function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: boolean): {
modelId: string;
requestedModelParameters?: readonly CursorRequestedModelParameter[];
routingLevel?: CursorRoutingLevel;
Expand All @@ -201,7 +228,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): {
const id = selection.modelId;
// Grok Fast stays parameterized: current Cursor clients send the base id
// plus effort/fast parameters instead of the flattened -fast id.
const grokFast = cursorGrokFastSelection(id, reasoning);
const grokFast = cursorGrokFastSelection(id, reasoning, fast);
if (grokFast) {
return {
...selection,
Expand All @@ -212,7 +239,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): {
],
};
}
const resolved = resolveCursorSelection(id, reasoning);
const resolved = resolveCursorSelection(id, reasoning, undefined, { fast });
return {
...selection,
...(resolved.maxMode ? { maxMode: true } : {}),
Expand Down Expand Up @@ -455,7 +482,7 @@ export function createCursorRequest(
const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice);
const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
const limitNote = catalogLimitNote(budget.tools, budget.omitted);
const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning);
const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning, cursorFastRequested(parsed));
const request: CursorRunRequest = {
modelId: model.modelId,
...(model.requestedModelParameters ? { requestedModelParameters: model.requestedModelParameters } : {}),
Expand Down
12 changes: 10 additions & 2 deletions src/providers/fastwire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const FAST_WIRE_ADAPTERS: Readonly<Record<FastWire["kind"], ReadonlySet<string>>
"service-tier": SERVICE_TIER_ADAPTERS,
// A1 deliberately has no adapter implementation for Anthropic speed.
"anthropic-speed": new Set(),
// Cursor expresses Fast as a variant dimension of the picked model, resolved in the
// request builder, so the adapter set is exactly the cursor adapter.
"cursor-variant": new Set(["cursor"]),
};

const DEFAULT_SERVICE_TIER_FAST_WIRE: FastWire = Object.freeze({
Expand Down Expand Up @@ -209,8 +212,13 @@ export function resolveFastPolicy(
// On classified routes this permission applies only to a caller's foreign tier: proxy-owned
// canonical Fast has already passed capability validation. On unclassified routes every caller
// tier still needs the final wire's forwarding permission.
// A wire that declares `foreignCallerTiers: "drop"` cannot carry an arbitrary tier string at
// all — cursor-variant resolves a MODEL VARIANT, so there is nothing to forward a foreign
// value into. Without this, an unclassified route on such a wire projects "unknown" support
// and Codex would show a Fast toggle on a base that has no fast variant.
const forwardCallerTier = capability !== false
&& callerWireAvailable
&& fastWire?.foreignCallerTiers !== "drop"
&& forwardCallerServiceTier !== false
&& (adapter !== "openai-chat" || authority.capability.chatServiceTier === true);

Expand Down Expand Up @@ -467,8 +475,8 @@ export function fastWireDeclarationError(source: {
}
if (value === null) return null;
if (!isPlainRecord(value)) return "fastWire must be an object, null, or absent";
if (value.kind !== "service-tier" && value.kind !== "anthropic-speed") {
return "fastWire.kind must be service-tier or anthropic-speed";
if (value.kind !== "service-tier" && value.kind !== "anthropic-speed" && value.kind !== "cursor-variant") {
return "fastWire.kind must be service-tier, anthropic-speed, or cursor-variant";
}
if (value.foreignCallerTiers !== "verbatim" && value.foreignCallerTiers !== "drop") {
return "fastWire.foreignCallerTiers must be verbatim or drop";
Expand Down
10 changes: 10 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
cursorModelInputModalities,
cursorModelReasoningEfforts,
} from "../adapters/cursor/discovery";
import { cursorFastCapableBases } from "../adapters/cursor/catalog";
import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts";
import { isCanonicalOpenRouterTarget } from "./openrouter-routing";

Expand Down Expand Up @@ -1120,6 +1121,15 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
defaultModel: "auto",
modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS),
modelDisplayNames: cursorModelDisplayNames(),
// Cursor's Fast product is a model VARIANT, not a service_tier field, so the wire kind
// is cursor-variant and the request builder consumes the decision.
fastWire: { kind: "cursor-variant", canonicalToWire: { priority: "fast" }, foreignCallerTiers: "drop" },
Comment on lines +1124 to +1126

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document the Cursor Fast variant behavior

This declaration makes Fast newly visible for five Cursor models and changes requests from their ordinary/thinking identity to Cursor's Fast model variant, but the commit contains no docs-site/ update explaining which models support the toggle or that Cursor does not send an OpenAI service_tier field. Update the English Cursor/Codex model documentation and keep translated pages consistent so operators can understand the new user-visible routing behavior.

AGENTS.md reference: AGENTS.md:L343-L344

Useful? React with 👍 / 👎.

// Deliberately NO provider-level supportsServiceTier: resolveFastPolicy short-circuits on
// `capability.provider === false` BEFORE consulting the per-model map, which would make
// these entries dead config. Absent leaves unlisted bases "unclassified", and a
// non-service-tier adapter cannot forward a caller tier, so they still publish no toggle.
modelSupportsServiceTier: Object.fromEntries(cursorFastCapableBases().map(id => [id, true])),

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 Normalize retained Cursor aliases before applying Fast

When an existing session or explicit request uses a retained alias such as claude-opus-5-thinking, route.modelId remains that alias, but this capability map contains only base IDs. Because resolveFastPolicy performs an exact model lookup, the alias is treated as unclassified and decideTier returns drop, so neither the caller's Fast toggle nor fastMode: true reaches the request builder. The new test bypasses this failure by injecting FAST_DECISION directly; normalize Cursor IDs through parseCursorVariantId before policy lookup (or classify every retained alias) and add an end-to-end policy regression.

AGENTS.md reference: AGENTS.md:L339-L342

Useful? React with 👍 / 👎.

fastTierDescription: "Cursor Fast variant",
modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS),
modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS),
// Kimi K3 documents `max` as its API default, and its Cursor ladder has no `medium`
Expand Down
8 changes: 7 additions & 1 deletion src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@ export interface ProviderRequestPacingConfig extends RequestPacingRule {
}

export interface FastWire {
kind: "service-tier" | "anthropic-speed";
/**
* How the provider expresses Fast on the wire. `service-tier` is OpenAI's
* `service_tier` request field; `cursor-variant` is a MODEL-VARIANT switch, because
* Cursor has no tier field — its fast product is a different model id
* (`claude-opus-5-thinking-high-fast`) or a `{id:"fast"}` request parameter for Grok.
*/
kind: "service-tier" | "anthropic-speed" | "cursor-variant";
/** Canonical tier name to upstream wire spelling. */
canonicalToWire: Readonly<Record<string, string>>;
/** Policy for non-canonical caller-provided tier values. */
Expand Down
8 changes: 6 additions & 2 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,8 @@ function normalizeAttemptTierOutcome(raw: unknown): AttemptTierOutcome | null {
if ("wireKind" in outcome
&& outcome.wireKind !== null
&& outcome.wireKind !== "service-tier"
&& outcome.wireKind !== "anthropic-speed") return null;
&& outcome.wireKind !== "anthropic-speed"
&& outcome.wireKind !== "cursor-variant") return null;
if ("wireValue" in outcome && outcome.wireValue !== null && typeof outcome.wireValue !== "string") return null;
if ("fastDowngradeReason" in outcome
&& (typeof outcome.fastDowngradeReason !== "string"
Expand All @@ -337,7 +338,10 @@ function normalizeAttemptTierOutcome(raw: unknown): AttemptTierOutcome | null {
const responseServiceTier = sanitizeLogMetadataString(outcome.responseServiceTier);
return {
...(outcome.canonical === "priority" ? { canonical: "priority" as const } : {}),
...(outcome.wireKind === null || outcome.wireKind === "service-tier" || outcome.wireKind === "anthropic-speed"
...(outcome.wireKind === null
|| outcome.wireKind === "service-tier"
|| outcome.wireKind === "anthropic-speed"
|| outcome.wireKind === "cursor-variant"
? { wireKind: outcome.wireKind }
: {}),
...(outcome.wireValue === null
Expand Down
Loading
Loading