diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 1ab248a754..157da1eaa3 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -12,6 +12,7 @@ import { cursorClientThreadOwner, cursorCoveredPrefixDigest, cursorInstructionDigest, + cursorRequestEmitsFastVariant, } from "./cursor/request-builder"; import { createLiveCursorTransport, @@ -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"; @@ -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, diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index 3351deab16..25894e504c 100644 --- a/src/adapters/cursor/catalog.ts +++ b/src/adapters/cursor/catalog.ts @@ -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; @@ -523,20 +549,25 @@ export function resolveCursorSelection( pickedId: string, reasoning: string | undefined, liveMaxModeIds?: ReadonlySet, + 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"; @@ -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; diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index e73d5e98ea..51651bb07a 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -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/` 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; @@ -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, @@ -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 } : {}), @@ -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 } : {}), diff --git a/src/providers/fastwire.ts b/src/providers/fastwire.ts index 34116e7133..7eae6b0fe1 100644 --- a/src/providers/fastwire.ts +++ b/src/providers/fastwire.ts @@ -14,6 +14,9 @@ const FAST_WIRE_ADAPTERS: Readonly> "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({ @@ -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); @@ -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"; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 8ed01545ab..3f71a1a8a7 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -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"; @@ -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" }, + // 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])), + 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` diff --git a/src/types/provider.ts b/src/types/provider.ts index 7f24c00611..a3a4dd4c10 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -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>; /** Policy for non-canonical caller-provided tier values. */ diff --git a/src/usage/log.ts b/src/usage/log.ts index a32b8aee44..7e056f97b0 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -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" @@ -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 diff --git a/tests/cursor-fast-tier.test.ts b/tests/cursor-fast-tier.test.ts new file mode 100644 index 0000000000..43ec6bd66b --- /dev/null +++ b/tests/cursor-fast-tier.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { cursorFastCapableBases, upgradeToFast } from "../src/adapters/cursor/catalog"; +import { createCursorRequest, cursorRequestEmitsFastVariant } from "../src/adapters/cursor/request-builder"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { decideTier } from "../src/providers/fastwire"; +import { fastPolicyForModel, serviceTierSupportFromPolicy } from "../src/providers/service-tier"; +import type { OcxParsedRequest, TierDecision } from "../src/types"; + +const FAST_DECISION: TierDecision = { kind: "set", value: "fast" }; + +function parsedFor(modelId: string, reasoning?: string, decision?: TierDecision): OcxParsedRequest { + return { + modelId, + context: { systemPrompt: [], messages: [{ role: "user", content: "hi" }] }, + options: { + ...(reasoning ? { reasoning } : {}), + ...(decision ? { tierDecision: decision } : {}), + }, + } as OcxParsedRequest; +} + +const cursorConfig = () => providerConfigSeed(getProviderRegistryEntry("cursor")!); + +/** + * Codex's Fast toggle is OpenAI's `service_tier`, and Cursor has no tier field — its fast + * product is a different model variant. Before this, `service_tier` on a Cursor route was + * silently dropped and no Cursor row could advertise the toggle at all + * (devlog 260902_cursor_unified_identity/020). + * + * These drive each new conditional path and assert the observable effect, rather than + * asserting that a table contains a value. + */ +describe("Codex Fast reaches Cursor's fast variant", () => { + test("only bases with a fast variant advertise the tier", () => { + const config = cursorConfig(); + const support = (id: string) => + serviceTierSupportFromPolicy(fastPolicyForModel(config, id, "cursor")); + + for (const base of cursorFastCapableBases()) expect(support(base)).toBe(true); + // No dead toggle: a base with no fast wire must publish definitive negative evidence, + // not "unknown" (which Codex would render as an offerable tier). + for (const base of ["kimi-k3", "gpt-5.6-sol", "glm-5.3", "gemini-3.7-flash"]) { + expect(support(base)).toBe(false); + } + }); + + test("the toggle produces a set decision only on a fast-capable base", () => { + const config = cursorConfig(); + const decide = (id: string) => + decideTier(fastPolicyForModel(config, id, "cursor"), undefined, "priority"); + + expect(decide("claude-opus-5")).toEqual(FAST_DECISION); + expect(decide("grok-4.6")).toEqual(FAST_DECISION); + expect(decide("kimi-k3")).toEqual({ kind: "drop" }); + }); + + test("a thinking umbrella pick upgrades to thinking-fast, not the regular-fast sibling", () => { + // The regular-fast sibling is a different product with a shorter ladder, and for + // claude-opus-5 its regular family is quarantined. + expect(createCursorRequest(parsedFor("cursor/claude-opus-5", "max")).modelId) + .toBe("claude-opus-5-thinking-max"); + expect(createCursorRequest(parsedFor("cursor/claude-opus-5", "max", FAST_DECISION)).modelId) + .toBe("claude-opus-5-thinking-max-fast"); + expect(upgradeToFast("claude-opus-5", "thinking")).toBe("thinkingFast"); + }); + + test("grok keeps the parameterized fast shape instead of a flattened id", () => { + const request = createCursorRequest(parsedFor("cursor/grok-4.6", "high", FAST_DECISION)); + expect(request.modelId).toBe("grok-4.6"); + expect(request.requestedModelParameters).toEqual([ + { id: "effort", value: "high" }, + { id: "fast", value: "true" }, + ]); + // Off, it keeps the cursor- prefix the regular variant requires. + expect(createCursorRequest(parsedFor("cursor/grok-4.6", "high")).modelId) + .toBe("cursor-grok-4.6-high"); + }); + + test("a base without a fast variant is byte-identical with the toggle on", () => { + const off = createCursorRequest(parsedFor("cursor/kimi-k3", "max")); + const on = createCursorRequest(parsedFor("cursor/kimi-k3", "max", FAST_DECISION)); + expect(on.modelId).toBe(off.modelId); + expect(on.requestedModelParameters).toEqual(off.requestedModelParameters); + }); + + test("telemetry reports the variant that the wire will actually carry", () => { + // tierLogForRunTurn runs BEFORE runTurn, so this must be computable from parsed alone. + expect(cursorRequestEmitsFastVariant(parsedFor("cursor/claude-opus-5", "max", FAST_DECISION))).toBe(true); + expect(cursorRequestEmitsFastVariant(parsedFor("cursor/grok-4.6", "high", FAST_DECISION))).toBe(true); + expect(cursorRequestEmitsFastVariant(parsedFor("cursor/kimi-k3", "max", FAST_DECISION))).toBe(false); + expect(cursorRequestEmitsFastVariant(parsedFor("cursor/claude-opus-5", "max"))).toBe(false); + }); + + test("an explicit legacy variant id still wins over the toggle", () => { + // Alias retention: a pinned session naming a variant must not be re-pointed. + expect(createCursorRequest(parsedFor("cursor/claude-opus-5-thinking", "high", FAST_DECISION)).modelId) + .toBe("claude-opus-5-thinking-high-fast"); + expect(createCursorRequest(parsedFor("cursor/claude-opus-4-8-thinking-fast", "max")).modelId) + .toBe("claude-opus-4-8-thinking-max-fast"); + }); +}); diff --git a/tests/fastwire-policy.test.ts b/tests/fastwire-policy.test.ts index a29debcb34..14d1c4da72 100644 --- a/tests/fastwire-policy.test.ts +++ b/tests/fastwire-policy.test.ts @@ -644,8 +644,17 @@ describe("FastWire config and registry validation", () => { })).toBeNull(); }); - test("A1 adds no explicit registry FastWire declaration", () => { - expect(PROVIDER_REGISTRY.every(entry => entry.fastWire === undefined)).toBeTrue(); + test("cursor is the only registry FastWire declaration, and it is the variant wire", () => { + // A1 shipped none; Cursor's Fast is a model VARIANT rather than a service_tier field, + // so it must declare its own wire instead of inheriting the OpenAI adapter default. + // Every other provider still gets its wire from defaultFastWireForAdapter. + const declared = PROVIDER_REGISTRY.filter(entry => entry.fastWire !== undefined); + expect(declared.map(entry => entry.id)).toEqual(["cursor"]); + expect(declared[0]?.fastWire).toEqual({ + kind: "cursor-variant", + canonicalToWire: { priority: "fast" }, + foreignCallerTiers: "drop", + }); }); });