From 3595d47f6889955b40844739dee2d7f06c3b883e Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 06:14:17 +0900 Subject: [PATCH 1/4] docs(cursor): diff-level roadmap for unified Cursor model identity One published row per Cursor base with thinking/fast/1M as dimensions, a Codex Fast toggle that reaches Cursor's fast variant, and a global switch that exposes -fast identities to clients without a toggle. Docs-only work-phase (wp1) of a four-phase unit. Contains 000_plan (work-phase map + measured current state + RUN verifier table), 001_current_state (why the picker never reads CURSOR_CAPABILITIES, where Codex Fast dies for Cursor), 002_audit_round1 (10 blockers from two review lanes, all folded), and diff-level decade docs 010/020/030 for the three implementation phases. Notable audit findings folded before any code: provider-level supportsServiceTier short-circuits before the per-model map; tierLogForRunTurn runs BEFORE runTurn so telemetry must recompute the variant rather than rebuild a non-pure request; usage/log.ts and usage/cost.ts read wireKind by string comparison and are invisible to tsc. Refs devlog/_plan/260902_cursor_unified_identity --- .../000_plan.md | 88 ++++ .../001_current_state.md | 107 +++++ .../002_audit_round1.md | 174 ++++++++ .../010_wp2_umbrella_seed.md | 269 +++++++++++ .../020_wp3_codex_fast_toggle.md | 419 ++++++++++++++++++ .../030_wp4_global_fast_switch.md | 206 +++++++++ 6 files changed, 1263 insertions(+) create mode 100644 devlog/_plan/260902_cursor_unified_identity/000_plan.md create mode 100644 devlog/_plan/260902_cursor_unified_identity/001_current_state.md create mode 100644 devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md create mode 100644 devlog/_plan/260902_cursor_unified_identity/010_wp2_umbrella_seed.md create mode 100644 devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md create mode 100644 devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md diff --git a/devlog/_plan/260902_cursor_unified_identity/000_plan.md b/devlog/_plan/260902_cursor_unified_identity/000_plan.md new file mode 100644 index 0000000000..f4bb14b411 --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/000_plan.md @@ -0,0 +1,88 @@ +# Cursor unified model identity + +One published row per Cursor base. Thinking, fast, and 1M are dimensions of that row, +never extra slugs. The Codex Fast toggle drives the fast dimension; a global switch +exposes `-fast` identities to clients that have no toggle. + +## Why + +Cursor's own picker already works this way: `Claude Opus 5` is one row whose submenu +carries Thinking, Fast, Context (300K/1M), and Effort. OpenCodex has the same shape in +`CURSOR_CAPABILITIES` but never publishes it — `cursorUmbrellaRows()` is called by tests +only, and the picker is fed by the leftover product seed in `discovery.ts`. + +## Constraints + +- Every legacy id stays routable. Picker rows shrink; routability does not. +- Never run the repo-wide suite locally. Focused `bun test` files + `bun run typecheck` + + `bun run privacy:scan`; exact-head GitHub CI is the authoritative gate. +- Stacked PR chain against `dev`, parent first. Pushes use `git push --no-verify`. +- Out of scope: Codex app UI, Cursor transport/native-exec, other providers' fast wires, + dashboard `/api/models` namespaced ids, Desktop 3P hashed aliases. + +## Work-phase map (dependency-ordered) + +| WP | Deliverable | Consumes | +|----|-------------|----------| +| wp1 | this roadmap (docs only) | — | +| wp2 / PR1 | seed derives from the capability table; display names; window alignment | wp1 | +| wp3 / PR2 | `cursor-variant` FastWire; Codex Fast toggle reaches the fast dimension | wp2 (needs a stable base row set) | +| wp4 / PR3 | `fastMode` lists `-fast` identities outside Codex; request-time promotion | wp3 (needs the resolver's fast upgrade) | + +wp3 depends on wp2 because the Fast toggle is stamped per row: the row set must be the +capability-derived one before a per-base capability map can be attached to it. wp4 depends +on wp3 because listing `-fast` is only honest once the request path actually honours it. + +## Measured current state (2026-09-02, `.tmp/cursor_diff_probe.ts`) + +``` +SEED_COUNT 54 # CURSOR_STATIC_MODELS +CAPS_COUNT 34 # CURSOR_CAPABILITIES +UMBRELLA_ROWS 34 # cursorUmbrellaRows() — none missing from the seed +ROWS_NOT_IN_SEED [] # capability rows are all seeded +CAPS_NOT_IN_SEED [] +SEED_NOT_IN_CAPS (16) # claude-4-sonnet-1m, claude-4.5-haiku, composer-1, composer-2.5, + # composer-2.5-fast, gemini-2.5-flash, gemini-3-flash, gemini-3-pro, + # gemini-3-pro-image-preview, gemini-3.1-pro, gemini-3.5-flash, + # gpt-5-codex, gpt-5-fast, gpt-5-mini, gpt-5.1-codex, kimi-k2.7-code +WINDOW_MISMATCH # gemini-3.6-flash 1048576/1000000, gemini-3.7-flash 1048576/1000000, + # gpt-5.5-extra 200000/272000 +FAST_CAPABLE_BASES # claude-opus-4-7, claude-opus-4-8, claude-opus-5, grok-4.5, grok-4.6 +``` + +54 = 4 routers + 34 capability bases + 16 non-capability product ids. + +## Verifiers (RUN 2026-09-02 before being written here, PLAN-VERIFIER-REAL-01) + +| Command | Exit | Reads the change target? | +|---|---|---| +| `bun test tests/cursor-umbrella-rows.test.ts tests/cursor-catalog.test.ts tests/cursor-static-catalog.test.ts` | **1 — 74 pass / 1 fail** | yes — imports `catalog.ts` + `discovery.ts` directly | +| `bun test tests/fastwire-policy.test.ts tests/fastwire-observability.test.ts tests/service-tier-capability.test.ts` | 0 — 303 pass | yes — imports `fastwire.ts` / `service-tier.ts` | +| `bun test tests/claude-model-info.test.ts tests/claude-models-discovery.test.ts` | 0 — 27 pass | yes — imports `claude/model-info.ts` | +| `bun run typecheck` | pending measurement at wp2 B | yes — `tsc --noEmit` over `src/` and `tests/` | +| `bun run privacy:scan` | pending measurement at wp2 B | repo-wide credential scan; **does not observe this unit's behavior** | + +`privacy:scan` is a required gate, not a verifier of identity behavior; that acceptance row +is human review plus the focused tests above. + +**Pre-existing red on this branch point.** The cursor suite fails at HEAD `d975feaa4`, +before any change in this unit: + +``` +(fail) row count shrank from the 69-row legacy seed + tests/cursor-umbrella-rows.test.ts:40 Expected: 51 Received: 54 +``` + +Commit `5fc7d073e` seeded three `claude-fable-5-1` spellings and did not update the +assertion. wp2 owns the fix (010 §4 rewrites that assertion to the derived composition), +so wp2's C-phase evidence must show this file green rather than inheriting the failure. + +Environment note: a fresh worktree needs `bun install` first — without it these files fail +with `Cannot find module 'zod/v4'` / `'@bufbuild/protobuf'`, which is not a code defect. + +## Terminal outcomes + +DONE = wp1-wp4 closed through D with three stacked PRs at exact-head green CI. +BLOCKED = CI infrastructure or a live Cursor roster change with evidence. +NEEDS_HUMAN = a user-visible identity fork beyond the stated intent. +BUDGET_EXHAUSTED = 6h wall-clock or three failed repair rounds on one WP. diff --git a/devlog/_plan/260902_cursor_unified_identity/001_current_state.md b/devlog/_plan/260902_cursor_unified_identity/001_current_state.md new file mode 100644 index 0000000000..63fd9d99d5 --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/001_current_state.md @@ -0,0 +1,107 @@ +# Current state: how a Cursor row is built and where Fast dies + +Research only. No diffs here. + +## 1. The picker path never reads the capability table + +`cursorUmbrellaRows()` (`src/adapters/cursor/catalog.ts:554`) is imported by +`tests/cursor-umbrella-rows.test.ts` and nothing else in `src/`. The published rows come +from a different list: + +``` +CURSOR_STATIC_MODELS (discovery.ts:276) + -> registry.ts:1110 models: cursorModelIds(CURSOR_STATIC_MODELS) + -> derive.ts:230 seeded into config.providers.cursor.models + -> provider-fetch.ts:1394 cursor branch: live GetUsableModels intersection + -> sync.ts disabledModels removal, deriveEntry writes slug/display_name/... +``` + +So the capability table describes dimensions the picker never sees. Collapsing a variant in +`catalog.ts` changes routing, not listing. + +## 2. Four inconsistencies, measured + +**Mixed row semantics.** 16 seed ids have no capability record. Some are genuine products +with no base (`composer-1`, `composer-2.5`, `gemini-3-pro`, `gpt-5-codex`), and three are +dimensions wearing a row costume: `claude-4-sonnet-1m` (a real wire id, guarded by +`REAL_1M_WIRE_IDS` at `catalog.ts:333`), `gpt-5-fast`, `composer-2.5-fast`. + +**1M means two things.** `kimi-k3-1m` is synthetic — `CURSOR_ULTRA_1M_MODEL_IDS` +(`discovery.ts:174`) folds it into `kimi-k3` + Max Mode. `claude-4-sonnet-1m` is a real +upstream id and stays a second row. Both read as "1M" to a user. + +**Fast means two things.** Opus/Grok fast ids were folded to aliases +(`tests/cursor-umbrella-rows.test.ts:20-31`); `gpt-5-fast` and `composer-2.5-fast` remain +rows because they have no capability base. + +**Labels and windows.** `routedDisplayName()` (`sync.ts:272`) returns the slug unchanged for +every provider except command-code, so Cursor rows read `cursor/kimi-k3`. Three windows +disagree between seed and capability table (000_plan.md). + +`ProviderRegistryEntry` has `modelContextWindows`, `modelInputModalities`, +`modelReasoningEfforts` — but **no `modelDisplayNames`** (`registry.ts:265-290`), and +`ProviderConfigSeed` (`registry.ts:327`) does not list it either. The consumer exists +(`configuredModelDisplayName`, `provider-fetch.ts:634`) and reads +`prov.modelDisplayNames`; only the registry->config path is missing. + +## 3. Where Codex Fast dies for Cursor + +Codex Fast is OpenAI `service_tier`, not a boolean: + +``` +app catalog row service_tiers:[{id:"priority",name:"Fast"}] (effort.ts:160) + -> request service_tier:"priority" (parser.ts:826) + -> decideTier(policy, config.fastMode, callerTier) (fastwire.ts:392) + -> applyServiceTierGate deletes the field when kind==="drop" (responses/core.ts:2638) +``` + +The drop is structural. `FAST_WIRE_ADAPTERS` (`fastwire.ts:14-18`) maps +`"service-tier" -> {openai-chat, openai-responses}` and `"anthropic-speed" -> {}`. Cursor is +in neither, so `resolveFastPolicy` sets `eligibility: "wire-unavailable"`, +`serviceTierSupportFromPolicy` publishes `supportsServiceTier: false`, and +`applyCatalogModelMetadata` never stamps the tier. No config value fixes this: forcing +`supportsServiceTier: true` still fails the wire check, and declaring +`fastWire.kind: "service-tier"` fails the adapter-set check. + +Meanwhile the fast wire genuinely exists, keyed off the picked id: + +- Grok (`wirePrefix: "cursor-"`): base id + `{id:"effort"},{id:"fast",value:"true"}` + parameters, via `cursorGrokFastSelection` (`catalog.ts:538`, + `request-builder.ts:204-213`). +- Everyone else: flattened wire id `claude-opus-5-thinking-high-fast` via `composeWireId` + (`catalog.ts:446-466`). + +`normalizeCursorModelId` (`request-builder.ts:189`) receives only `parsed.modelId` and +`parsed.options.reasoning`. `rg` finds no `serviceTier`/`tierDecision` read anywhere under +`src/adapters/cursor/`. + +Telemetry is a separate hole: `adapters/registry.ts:156-176` attaches +`createAdapterTierMetadata(..., null, null)` for every non-OpenAI adapter, so even a +working Cursor fast request would report an absent wire field. + +Five bases have a fast dimension: `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, +`grok-4.5`, `grok-4.6`. Stamping a tier on the other 29 would recreate the dead-toggle +defect `NO_FAST_TIER_NATIVE_SLUGS` (`parsing.ts:297`) exists to prevent. + +## 4. Listing surfaces outside Codex + +`GET /v1/models` has three branches (`server/index.ts:1316-1560`): + +| Trigger | Id shape | Composed at | +|---|---|---| +| `?client_version` | catalog slugs | `buildCatalogEntries` | +| `anthropic-version` / `?flavor=anthropic` | `claude-ocx-*` or Desktop hashes | `claude/model-info.ts:105` | +| default | `alias ?? provider/id` | `server/index.ts:1534` | + +`buildAnthropicModelInfos` already publishes a second row for a dimension: `push1mVariant` +(`model-info.ts:115-128`) appends `[1m]`, and `resolveInboundModel` strips it before +routing. That is the precedent `-fast` listing should follow. + +`config.fastMode` (`types/config.ts:462`) is tri-state and today only reaches +`decideTier` plus Codex's injected `[features] fast_mode` (`codex/inject.ts:708`). It +touches no listing code: `rg fastMode` is empty in `server/index.ts`, +`claude/model-info.ts`, `management/model-rows.ts`, and `cli/models.ts`. + +Dashboard `/api/models` uses `namespaced` as the disable/export key +(`catalogModelSlug`, `parsing.ts:703`), so rewriting it would desync `disabledModels`. +That surface stays untouched. diff --git a/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md b/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md new file mode 100644 index 0000000000..a47c73744d --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md @@ -0,0 +1,174 @@ +# Audit round 1 — main-agent verification of the roadmap + +Blockers found by running the plan's own claims against the tree at `d975feaa4`. +All folded into 010/020/030 in the same pass. An independent `xai/grok-4.6` reviewer lane +is running concurrently; its findings append as round 2. + +## B1 (Critical) — `fastWireDeclarationError` hard-rejects the new kind + +`src/providers/fastwire.ts:470` + +```ts +if (value.kind !== "service-tier" && value.kind !== "anthropic-speed") { + return "fastWire.kind must be service-tier or anthropic-speed"; +} +``` + +020 §5 called `fastWireSchema` "an enum that must list the value" and treated the +validator as an unknown. It is neither an enum nor unknown: `src/config.ts:495` types +`kind` as a bare `z.string()` and delegates to this function, which rejects any third +kind. A cursor registry entry declaring `kind: "cursor-variant"` fails +`registryFastWireDeclarationError` at load, so the provider entry is invalid before any +request runs. **Fold:** the string literal list here is a required edit, called out +explicitly in the 020 change map. + +## B2 (Critical) — `hasFastWireCapabilityConflict` is not the constraint 020 assumed + +`src/providers/fastwire.ts:445-455` + +```ts +if (source.fastWire !== null) return false; +``` + +The conflict only fires for `fastWire: null`. 020 §2 planned +`supportsServiceTier: false` + `modelSupportsServiceTier: {5 bases: true}` and worried +this would be rejected. It is not — but the real problem is the opposite one, and worse: + +`src/providers/fastwire.ts:~350` (resolveFastPolicy) + +```ts +const capability = authority.capability.provider === false + ? false + : exactCapability ?? authority.capability.provider; +``` + +`capability.provider === false` short-circuits **before** `exactCapability` is consulted. +So `supportsServiceTier: false` would force every Cursor model to +`capability-unsupported`, including the five with a fast variant, and the per-model +`true` entries would be dead config. **Fold:** omit `supportsServiceTier` entirely on the +cursor entry (leave it `undefined`) and let `modelSupportsServiceTier` decide per model. +A base with no entry then resolves `capability === undefined` → `eligibility: +"unclassified"` → `serviceTierSupportFromPolicy` returns `false` when +`forwardCallerTier` is false (`service-tier.ts:268-274`), which is exactly the desired +"no toggle" outcome. + +## B3 (High) — the catalog stamp is ordered against us + +`src/codex/catalog/sync.ts:335-349` + +``` +applyReasoningLevels(e, ...) +normalizeRoutedCatalogEntry(e, ...) // deletes service_tiers / additional_speed_tiers +applyCatalogMetadata(e, ...) +applyCatalogModelMetadata(e, model) // re-stamps when model.supportsServiceTier === true +``` + +020 asserted the ordering was fine but recorded no proof. It is fine — the strip runs +**before** the stamp — so a routed Cursor row can carry tiers. **Fold:** record the proven +order in 020 so a later reader does not re-derive it, and make the wp3 C-phase assert on a +built entry rather than on `applyCatalogModelMetadata` in isolation. + +## B4 (High) — `usage/cost.ts` is a consumer 020 missed + +`src/usage/cost.ts:418-425` + +```ts +if (outcome.fastOutcome === "unknown" + && outcome.wireKind === "service-tier" + && typeof outcome.wireValue === "string") { + return { requestedServiceTier: outcome.wireValue }; +} +``` + +020 §5's consumer list named `FAST_WIRE_ADAPTERS`, `AttemptTierOutcome.wireKind`, +`canonicalFromWire`, `behavior.ts`, and `fastWireDeclarationError` — not this. It is a +string comparison, not an exhaustive switch, so `tsc` will **not** flag it: a +`"cursor-variant"` outcome silently takes the fall-through and reports no requested tier +for pricing. The branch above it (`canonical === "priority" && confirmation === "assumed"`, +line 414) does cover the Cursor case correctly, since 020 §4 sets +`confirmation: "assumed"`. **Fold:** 020 records this as verified-correct-by-accident and +adds a cost-attribution assertion so a future refactor cannot break it silently. + +## B5 (Medium) — `registryModelServiceTierCapabilityApplies` is a base-URL guard, not auth + +`src/providers/registry.ts:2935-2941` — it reads +`modelServiceTierCapabilityBaseUrlGuard`, which only the OpenRouter entry sets +(`registry.ts:1610`). 020 §2's "verify it does not gate OAuth providers" concern is +resolved: Cursor sets no guard, so the predicate returns `true`. **Fold:** replace the +open question with the answer. + +## B6 (Medium) — anthropic-inbound already gets a tier decision + +`src/server/claude-messages.ts:37,772` replays through `handleResponses`, which is the +same path that runs `decideTier` at `responses/core.ts:2095`. 030 §5 left this as "confirm +during B" and planned a `tierDecision === undefined` fallback. The fallback is therefore +**unreachable on that path** — a branch nobody can show firing +(C-ACTIVATION-GROUNDING-01). **Fold:** 030 drops the speculative fallback and instead +requires an activation test proving the anthropic-inbound route reaches the Cursor +resolver with `tierDecision.kind === "set"`. + +## Non-blockers confirmed + +- No import cycle: `catalog.ts` and `effort-map.ts` have **zero** imports of + `discovery.ts` (`rg '^import'` returns nothing for catalog.ts's header block; discovery + imports from effort-map and catalog, one direction only). +- Row arithmetic: measured `SEED_COUNT 54`, and `CURSOR_ROUTER_MODEL_IDS` is derived + (`discovery.ts:113`) as auto + 3 levels = 4. 4 + 34 + 13 + 3 = 54 holds. +- `claude-4.5-haiku` was in 010's product list and is genuinely absent from + `CURSOR_CAPABILITIES`; no seed id is dropped by the new composition. + +## Round 2 — independent reviewer (xai/grok-4.6, lane `Aquinas`) + +Narrow packet: five targeted questions about the WP3 design. Two findings were blockers my +round-1 pass missed; one corrected a design I had already written into 020. + +### B7-REVISED (Critical) — `tierLogForRunTurn` runs BEFORE `runTurn` + +`src/server/responses/core.ts:3477-3479` + +```ts +let runTurnAdapter = adapter; +if (adapter.runTurn) { + recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed)); +} +``` + +I had written a write-back design (`runTurn` stamps a flag, `tierLogForRunTurn` reads it). +That is read-before-write and would always report `null`. A rebuild there is equally wrong: +it runs before `_cursorIdentityScope` (`cursor.ts:134-146`) and `_cursorConversationId` +(`cursor.ts:160`) exist, so it mints a second `crypto.randomUUID()` conversation and hashes +a `local` scope. **Fold:** 020 §4 recomputes the pure VARIANT through a shared +`cursorRequestEmitsFastVariant(parsed)` helper; the write-back block was deleted. + +### B8 (Critical) — `src/usage/log.ts` discards the whole outcome + +`normalizeAttemptTierOutcome` allowlists `wireKind` at `:322-325` and again at `:340`, +returning `null` for any third kind, so a persisted attempt loses its tier row and the GUI +Logs view shows nothing after restart. Invisible to `tsc` (string comparison). +**Fold:** both sites added to the 020 change map. + +### B9 (High) — an existing test asserts the opposite invariant + +`tests/fastwire-policy.test.ts:647` asserts +`PROVIDER_REGISTRY.every(entry => entry.fastWire === undefined)`. WP3 ends that by design. +**Fold:** rewrite it to the new invariant rather than delete the coverage. + +### B10 (Medium) — my `resolveCursorSelection` hunk was incomplete + +`parsed.kind` is read at `catalog.ts:487`, `:493`, and `:494-495`; my diff rebound only the +spec, which would emit a thinking id with no `-fast` and keep `cursor-` on an upgraded Grok +pick. **Fold:** 020 §3 shows the full three-site hunk. + +### Confirmed non-issues + +- The `parsed` object core mutates at `:2095` is the same one Cursor reads at + `cursor.ts:119,148` — no clone (dispatch traced at `core.ts:5330`). +- No `tests/cursor-*.test.ts` asserts absence of `service_tiers`, so stamping tiers on + Cursor rows breaks nothing there. +- `core.ts:2636`'s `kind === "service-tier"` test governs only foreign OpenAI caller tiers; + Cursor Fast takes the canonical early-return at `:2635`. + +Reviewer's normalized line: `VERDICT: GO-WITH-FIXES (blockers=2)`. Both folded above. + +The broad round-1 lane (`Carver`) is still running; anything it returns that is not already +folded appends as round 3. diff --git a/devlog/_plan/260902_cursor_unified_identity/010_wp2_umbrella_seed.md b/devlog/_plan/260902_cursor_unified_identity/010_wp2_umbrella_seed.md new file mode 100644 index 0000000000..922432ef86 --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/010_wp2_umbrella_seed.md @@ -0,0 +1,269 @@ +# WP2 / PR1 — the seed derives from the capability table + +Scope IN: `src/adapters/cursor/{catalog,discovery}.ts`, `src/providers/{registry,derive}.ts`, +`src/types/provider.ts` (registry entry type only), tests. +Scope OUT: fast wire, `fastMode`, any request-path change. + +Accept criteria: the Cursor row set equals capability bases + declared product bases; +every removed id still routes byte-identically; the Codex picker shows human labels; +seed and capability windows agree. + +## Change map + +| File | Action | +|---|---| +| `src/adapters/cursor/catalog.ts` | MODIFY — `CursorCapability.displayName`; window fixes; `cursorUmbrellaRows()` returns the label | +| `src/adapters/cursor/discovery.ts` | MODIFY — `CURSOR_PRODUCT_MODELS` (non-capability ids) + `CURSOR_STATIC_MODELS` derived; `cursorModelDisplayNames()` | +| `src/providers/registry.ts` | MODIFY — `modelDisplayNames` on the entry type + `ProviderConfigSeed`; cursor entry passes `cursorModelDisplayNames()` | +| `src/providers/derive.ts` | MODIFY — copy `entry.modelDisplayNames` into the seeded config | +| `tests/cursor-umbrella-rows.test.ts` | MODIFY — row-count and composition assertions | +| `tests/cursor-display-names.test.ts` | NEW — labels reach a built catalog row | + +## 1. `catalog.ts` — labels and window truth + +`CursorCapability` gains one field; every entry gains its label. Windows corrected to the +seed's measured values (`gemini-*` 1048576, `gpt-5.5-extra` 200000 — the seed carries the +observed numbers, the capability table was approximating). + +```diff + export interface CursorCapability { + readonly variants: Partial>; + readonly defaultVariant: CursorVariantKind; ++ /** Human picker label ("Claude Opus 5"). Cursor's own picker shows these. */ ++ readonly displayName: string; + readonly window: number; +``` + +```diff + const CONTEXT_1M = 1_000 * K; ++const CONTEXT_GEMINI = 1_048_576; +``` + +```diff + "claude-4.5-opus": { ++ displayName: "Claude Opus 4.5", + window: CONTEXT_200K, +``` + +Labels, in table order (Cursor's own spellings, read from its picker on 2026-09-02): + +``` +claude-4.5-opus Claude Opus 4.5 claude-4.6-opus Claude Opus 4.6 +claude-4.6-sonnet Claude Sonnet 4.6 claude-4.5-sonnet Claude Sonnet 4.5 +claude-4-sonnet Claude Sonnet 4 claude-fable-5 Claude Fable 5 +claude-fable-5-1 Claude Fable 5.1 claude-fable-5.1 Claude Fable 5.1 +claude-5.1-fable Claude Fable 5.1 claude-sonnet-5 Claude Sonnet 5 +claude-opus-4-7 Claude Opus 4.7 claude-opus-4-8 Claude Opus 4.8 +claude-opus-5 Claude Opus 5 glm-5.2 GLM 5.2 +glm-5.3 GLM 5.3 gemini-3.6-flash Gemini 3.6 Flash +gemini-3.7-flash Gemini 3.7 Flash kimi-k3 Kimi K3 +grok-4.5 Cursor Grok 4.5 grok-4.6 Cursor Grok 4.6 +gpt-5.1 GPT-5.1 gpt-5.1-codex-max GPT-5.1 Codex Max +gpt-5.1-codex-mini GPT-5.1 Codex Mini gpt-5.2 GPT-5.2 +gpt-5.2-codex GPT-5.2 Codex gpt-5.3-codex Codex 5.3 +gpt-5.4 GPT-5.4 gpt-5.4-mini GPT-5.4 Mini +gpt-5.4-nano GPT-5.4 Nano gpt-5.5 GPT-5.5 +gpt-5.5-extra GPT-5.5 Extra gpt-5.6-sol GPT-5.6 Sol +gpt-5.6-terra GPT-5.6 Terra gpt-5.6-luna GPT-5.6 Luna +``` + +Grok keeps Cursor's own "Cursor Grok" spelling because that is what its picker shows and +because the wire id carries the `cursor-` prefix. + +```diff + export interface CursorUmbrellaRow { + readonly id: string; ++ readonly displayName: string; + readonly efforts: readonly string[]; +``` + +```diff + rows.push({ + id: baseId, ++ displayName: capability.displayName, + efforts: spec.levels, +``` + +## 2. `discovery.ts` — the seed becomes derived + +`CURSOR_STATIC_MODELS` stops being a hand-maintained list of 54 and becomes +routers + umbrella rows + declared product ids. + +```diff +-export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorModels([ +- ...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id, contextWindow: CONTEXT_200K, supportsReasoningEffort: false })), +- { id: "claude-sonnet-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, +- ... 50 more hand-written rows ... +-]); ++/** ++ * Cursor products that are NOT a dimension of any capability base. Each carries its own ++ * label because there is no capability record to read one from. A row belongs here only ++ * when Cursor ships it as a distinct product; a variant of a cataloged base does not. ++ */ ++export const CURSOR_PRODUCT_MODELS: readonly (CursorModelInfo & { displayName: string })[] = [ ++ { id: "claude-4.5-haiku", displayName: "Claude Haiku 4.5", contextWindow: CONTEXT_200K }, ++ { id: "composer-1", displayName: "Composer 1", contextWindow: CONTEXT_200K }, ++ { id: "composer-2.5", displayName: "Composer 2.5", contextWindow: CONTEXT_200K }, ++ { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", contextWindow: CONTEXT_GEMINI }, ++ { id: "gemini-3-flash", displayName: "Gemini 3 Flash", contextWindow: CONTEXT_GEMINI }, ++ { id: "gemini-3-pro", displayName: "Gemini 3 Pro", contextWindow: CONTEXT_GEMINI }, ++ { id: "gemini-3-pro-image-preview", displayName: "Gemini 3 Pro Image", contextWindow: CONTEXT_200K }, ++ { id: "gemini-3.1-pro", displayName: "Gemini 3.1 Pro", contextWindow: CONTEXT_GEMINI }, ++ { id: "gemini-3.5-flash", displayName: "Gemini 3.5 Flash", contextWindow: CONTEXT_200K }, ++ { id: "gpt-5-codex", displayName: "GPT-5 Codex", contextWindow: CONTEXT_272K }, ++ { id: "gpt-5-mini", displayName: "GPT-5 Mini", contextWindow: CONTEXT_272K }, ++ { id: "gpt-5.1-codex", displayName: "GPT-5.1 Codex", contextWindow: CONTEXT_272K }, ++ { id: "kimi-k2.7-code", displayName: "Kimi K2.7 Code", contextWindow: CONTEXT_262K }, ++]; ++ ++/** ++ * Real upstream wire ids that LOOK like a dimension of a cataloged base but are served as ++ * their own catalog row by Cursor. They stay rows; the parser already refuses to read them ++ * as synthetic markers (REAL_1M_WIRE_IDS / no capability base for gpt-5). ++ * ++ * claude-4-sonnet-1m: a distinct 1M-window row upstream, not claude-4-sonnet + ultra. ++ * claude-4-sonnet has no maxMode evidence, so folding it would invent a capability. ++ * gpt-5-fast: there is no `gpt-5` capability base for it to be a dimension of. ++ * composer-2.5-fast: composer-2.5 has no effort/variant dimensions at all. ++ */ ++export const CURSOR_REAL_ID_EXCEPTIONS: readonly (CursorModelInfo & { displayName: string })[] = [ ++ { id: "claude-4-sonnet-1m", displayName: "Claude Sonnet 4 (1M)", contextWindow: CONTEXT_1M }, ++ { id: "gpt-5-fast", displayName: "GPT-5 Fast", contextWindow: CONTEXT_272K }, ++ { id: "composer-2.5-fast", displayName: "Composer 2.5 Fast", contextWindow: CONTEXT_200K }, ++]; ++ ++/** ++ * Umbrella seed (devlog 260902_cursor_unified_identity): rows are DERIVED from ++ * CURSOR_CAPABILITIES via cursorUmbrellaRows(), so a capability change can no longer ++ * disagree with what the picker publishes. Thinking / fast / synthetic -1m remain ++ * routable aliases and add no rows. ++ */ ++export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorModels([ ++ ...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id, contextWindow: CONTEXT_200K, supportsReasoningEffort: false })), ++ ...cursorUmbrellaRows().map(row => ({ ++ id: row.id, ++ contextWindow: row.window, ++ supportsReasoningEffort: row.efforts.length > 0, ++ })), ++ ...CURSOR_PRODUCT_MODELS, ++ ...CURSOR_REAL_ID_EXCEPTIONS, ++]); +``` + +Import `cursorUmbrellaRows` alongside the existing `parseCursorVariantId` import +(`discovery.ts:8`). `catalog.ts` does not import `discovery.ts`, so no cycle appears. + +New label accessor, mirroring `cursorModelContextWindows`: + +```diff ++export function cursorModelDisplayNames(): Record { ++ return Object.fromEntries([ ++ ...cursorUmbrellaRows().map(row => [row.id, row.displayName] as const), ++ ...CURSOR_PRODUCT_MODELS.map(m => [m.id, m.displayName] as const), ++ ...CURSOR_REAL_ID_EXCEPTIONS.map(m => [m.id, m.displayName] as const), ++ ...CURSOR_ROUTER_MODEL_IDS.map(id => [id, cursorRouterDisplayName(id)] as const), ++ ]); ++} +``` + +Router labels: `auto` -> "Auto", `auto-balance` -> "Auto (Balanced)", `auto-cost` -> +"Auto (Cost)", `auto-intelligence` -> "Auto (Intelligence)". + +Row-count arithmetic after the change: 4 routers + 34 umbrella + 13 product + 3 exceptions += **54**. Measured against the current seed (`.tmp/probe2.ts`, 2026-09-02): + +``` +ROUTERS 4 CAPS 34 PRODUCT 13 EXC 3 TOTAL 54 +DUPES [] # no id appears twice, so normalizeCursorModels drops nothing silently +DROPPED_VS_TODAY [] # every id the picker publishes today survives +ADDED_VS_TODAY [] # no new id appears +``` + +The published set is **identical**, so wp2 is a pure refactor of where rows come from: the +list stops being hand-maintained and starts deriving from the capability table. Behavior +changes in exactly two places — every row gains a label, and three windows are corrected. +That makes the existing alias/oracle tests a real regression bar rather than a formality. + +## 3. `registry.ts` / `derive.ts` — the missing display-name path + +```diff + modelContextWindows?: Record; ++ /** Registry-supplied picker labels; an operator's config value still wins. */ ++ modelDisplayNames?: Record; + modelInputModalities?: Record; +``` + +```diff + "adapter" | "baseUrl" | ... | "models" +- | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" ++ | "liveModels" | "contextWindow" | "modelContextWindows" | "modelDisplayNames" | "modelInputModalities" +``` + +```diff + ...(entry.modelContextWindows ? { modelContextWindows: { ...entry.modelContextWindows } } : {}), ++ ...(entry.modelDisplayNames ? { modelDisplayNames: { ...entry.modelDisplayNames } } : {}), +``` + +Cursor entry: + +```diff + modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS), ++ modelDisplayNames: cursorModelDisplayNames(), +``` + +The consumer needs no change: `applyProviderConfigHints` already calls +`configuredModelDisplayName(prov, model.id)` and sets `displayName` on the CatalogModel, +which `sync.ts` prefers over `routedDisplayName`. Operator overrides keep winning because +`derive.ts` only fills when the config value is absent. + +## 4. Tests + +`tests/cursor-umbrella-rows.test.ts` — replace the frozen composition assertions: + +```diff +- expect(CURSOR_STATIC_MODELS.length).toBe(51); ++ // 4 routers + 34 umbrella bases + 13 product ids + 3 real-id exceptions. ++ expect(CURSOR_STATIC_MODELS.length).toBe(54); ++ }); ++ ++ test("every umbrella row is seeded and no capability base is missing", () => { ++ const ids = new Set(CURSOR_STATIC_MODELS.map(m => m.id)); ++ for (const row of cursorUmbrellaRows()) expect(ids.has(row.id)).toBe(true); ++ }); ++ ++ test("seed windows equal the capability windows they derive from", () => { ++ const seeded = new Map(CURSOR_STATIC_MODELS.map(m => [m.id, m.contextWindow])); ++ for (const row of cursorUmbrellaRows()) expect(seeded.get(row.id)).toBe(row.window); + }); +``` + +Existing `composer-2.5-fast` and pinned-session alias assertions stay green unchanged — +that is the regression bar for "every legacy id stays routable". + +`tests/cursor-display-names.test.ts` (NEW) — the label must survive the config path, not +merely exist in a table: + +```ts +test("cursor rows publish human labels through the seeded provider config", () => { + const config = seedProviderConfig("cursor"); // providers/derive.ts + expect(config.modelDisplayNames?.["kimi-k3"]).toBe("Kimi K3"); + expect(configuredModelDisplayName(config, "grok-4.6")).toBe("Cursor Grok 4.6"); + expect(configuredModelDisplayName(config, "claude-opus-5")).toBe("Claude Opus 5"); +}); + +test("an operator override still wins over the registry label", () => { + const config = seedProviderConfig("cursor"); + config.modelDisplayNames = { ...config.modelDisplayNames, "kimi-k3": "My K3" }; + expect(configuredModelDisplayName(config, "kimi-k3")).toBe("My K3"); +}); +``` + +## Risks + +A derived seed inherits capability mistakes: a base added to `CURSOR_CAPABILITIES` now +appears in the picker automatically. That is the intent, and live `GetUsableModels` +filtering still removes anything the account cannot call. + +`normalizeCursorModels` dedupes by id and sorts, so a product id colliding with a +capability base would silently drop one. No collision exists today +(`SEED_NOT_IN_CAPS` ∩ `CAPS` = ∅); the new row-composition test would catch a future one. diff --git a/devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md b/devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md new file mode 100644 index 0000000000..3719a501cc --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md @@ -0,0 +1,419 @@ +# WP3 / PR2 — the Codex Fast toggle reaches Cursor's fast dimension + +Stacked on PR1. Scope IN: `src/types/provider.ts`, `src/providers/{fastwire,registry}.ts`, +`src/adapters/cursor/{catalog,request-builder}.ts`, `src/adapters/cursor.ts`, tests. +Scope OUT: listing rewrites (WP4), other providers' wires, Cursor transport. + +Accept criteria, each with its activation scenario: + +| Path | Trigger | Observable effect | +|---|---|---| +| tier stamped | build a catalog for `cursor/claude-opus-5` | `service_tiers[0].id === "priority"` | +| no dead toggle | same for `cursor/kimi-k3` | no `service_tiers`, no `additional_speed_tiers` | +| thinking upgrade | request `cursor/claude-opus-5` + `service_tier:"priority"` | wire id ends `-fast` | +| grok params | request `cursor/grok-4.6` + Fast | `{id:"fast",value:"true"}` present, id stays `grok-4.6` | +| telemetry | same request | `tierLog.outcome.fastOutcome === "applied"` | + +## Change map + +| File | Action | +|---|---| +| `src/types/provider.ts` | MODIFY — `FastWire.kind` gains `"cursor-variant"` | +| `src/providers/fastwire.ts` | MODIFY — `FAST_WIRE_ADAPTERS` entry; **`fastWireDeclarationError:470` literal list** (audit B1) | +| `src/providers/registry.ts` | MODIFY — cursor `fastWire` + `modelSupportsServiceTier` (NO provider-level `supportsServiceTier`, audit B2) | +| `src/usage/log.ts` | MODIFY — **`normalizeAttemptTierOutcome` wireKind allowlist, both sites** (audit B8; otherwise the whole outcome row is discarded) | +| `src/adapters/cursor/catalog.ts` | MODIFY — `cursorFastCapableBases()`; `resolveCursorSelection` fast option | +| `src/adapters/cursor/request-builder.ts` | MODIFY — `normalizeCursorModelId` reads the tier decision; export `cursorRequestEmitsFastVariant` | +| `src/adapters/cursor.ts` | MODIFY — `tierLogForRunTurn` reports the resolved variant (must NOT rebuild, audit B7) | +| `src/usage/cost.ts` | NO CHANGE — but assert its behavior (audit B4) | +| `tests/fastwire-policy.test.ts` | MODIFY — the "A1 adds no explicit registry FastWire declaration" assertion (audit B9) | +| `tests/cursor-fast-tier.test.ts` | NEW — the five rows above | + +## 1. A Cursor-owned wire kind + +Reusing `"service-tier"` would claim Cursor emits a `service_tier` field. It does not; it +picks a different model variant. The kind is the honest name for that. + +```diff + export interface FastWire { +- kind: "service-tier" | "anthropic-speed"; ++ kind: "service-tier" | "anthropic-speed" | "cursor-variant"; +``` + +```diff + 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 model-variant dimension, not a request field: the ++ // resolver swaps regular->fast / thinking->thinkingFast and the wire carries either a ++ // flattened -fast id or Grok's {id:"fast"} parameter. ++ "cursor-variant": new Set(["cursor"]), + }; +``` + +```diff ++/** Canonical Fast maps to the variant marker the Cursor resolver understands. */ ++const DEFAULT_CURSOR_VARIANT_FAST_WIRE: FastWire = Object.freeze({ ++ kind: "cursor-variant" as const, ++ canonicalToWire: Object.freeze({ priority: "fast" }), ++ foreignCallerTiers: "drop" as const, ++}); +``` + +`foreignCallerTiers: "drop"` because Cursor has no concept of an arbitrary tier string; +only canonical Fast means anything. + +`defaultFastWireForAdapter` stays OpenAI-only — Cursor's declaration comes from the +registry, so a provider whose adapter is cursor but whose entry is absent keeps today's +behavior: + +```diff + export function defaultFastWireForAdapter(adapter: string): FastWire | null { + return SERVICE_TIER_ADAPTERS.has(adapter) ? DEFAULT_SERVICE_TIER_FAST_WIRE : null; + } +``` + +No change there. `decideTier` needs none either: it is already generic over +`canonicalToWire`, so Fast on an eligible Cursor route returns `{kind:"set", value:"fast"}`. + +**`applyServiceTierGate` must not write `service_tier` onto a Cursor body.** The gate runs +on `rawBody` for OpenAI-shaped requests; Cursor's adapter builds its own Connect request +and never reads `rawBody`, so a `{kind:"set"}` decision is invisible to it unless the +adapter reads `options.tierDecision` — which is exactly what §3 adds. Confirm during B +that the gate does not inject the field into a Cursor `rawBody` that later gets logged; +if it does, guard the injection on `fastWire.kind === "service-tier"`. + +**Catalog stamp ordering is proven, not assumed (audit B3).** `sync.ts:335-349` runs +`applyReasoningLevels` -> `normalizeRoutedCatalogEntry` (strips tiers) -> +`applyCatalogMetadata` -> `applyCatalogModelMetadata` (re-stamps when +`model.supportsServiceTier === true`). The strip precedes the stamp, so a routed Cursor +row keeps its tier. wp3's check asserts on a BUILT catalog entry, not on +`applyCatalogModelMetadata` in isolation, so this ordering stays covered. + +## 2. Only fast-capable bases advertise the toggle + +```diff ++/** Bases whose capability declares a fast or thinking-fast variant. */ ++export function cursorFastCapableBases(): string[] { ++ return Object.entries(CURSOR_CAPABILITIES) ++ .filter(([, c]) => c.variants.fast !== undefined || c.variants.thinkingFast !== undefined) ++ .map(([id]) => id); ++} +``` + +Today that is exactly `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, `grok-4.5`, +`grok-4.6` (measured, 000_plan.md). Deriving it means a future capability edit keeps the +toggle honest without a second list to update. + +```diff + modelDisplayNames: cursorModelDisplayNames(), ++ // Fast is a variant dimension, so only bases that actually have one may advertise it — ++ // a tier on a base without a fast wire is the dead-toggle defect (NO_FAST_TIER_NATIVE_SLUGS). ++ fastWire: { kind: "cursor-variant", canonicalToWire: { priority: "fast" }, foreignCallerTiers: "drop" }, ++ // NO provider-level supportsServiceTier: see audit B2 (002_audit_round1.md). ++ modelSupportsServiceTier: Object.fromEntries(cursorFastCapableBases().map(id => [id, true])), ++ fastTierDescription: "Cursor Fast variant", +``` + +**`supportsServiceTier` must stay ABSENT (audit B2, was a blocker).** `resolveFastPolicy` +computes `capability.provider === false ? false : exactCapability ?? capability.provider`, +so a provider-level `false` short-circuits BEFORE the per-model map and would kill the five +fast-capable bases too, leaving `modelSupportsServiceTier` as dead config. Leaving it +undefined yields: 5 bases `true` -> `eligible` -> tier stamped; 29 bases `undefined` -> +`unclassified` -> `serviceTierSupportFromPolicy` returns `false` because +`forwardCallerTier` is false on a non-service-tier adapter (`service-tier.ts:268-274`) +-> no toggle. Same outcome, without the short-circuit trap. + +`registryModelServiceTierCapabilityApplies` is RESOLVED, not an open question (audit B5): +it reads `modelServiceTierCapabilityBaseUrlGuard` (`registry.ts:2935-2941`), which only the +OpenRouter entry sets (`registry.ts:1610`). Cursor sets none, so it returns `true`; +`authKind` is never consulted. + +**The runtime validator must be widened in the same commit (audit B1, was a blocker).** +Without it the cursor entry is rejected at load: + +```diff +- 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"; + } +``` + +`src/config.ts:495` types `kind` as a bare `z.string()` and delegates to +`fastWireDeclarationError` (`fastwire.ts:470`), so this single edit covers both the +registry and the on-disk config boundary. + +## 3. The request path consumes the decision + +```diff +-function normalizeCursorModelId(modelId: string, reasoning?: string): { ++function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: boolean): { +``` + +```diff +- const grokFast = cursorGrokFastSelection(id, reasoning); ++ // Codex Fast is a variant switch here: an explicit -fast slug already parses as fast, ++ // and the toggle promotes an umbrella pick to its fast sibling when one exists. ++ const grokFast = cursorGrokFastSelection(id, reasoning, fast); +``` + +```diff +- const resolved = resolveCursorSelection(id, reasoning); ++ const resolved = resolveCursorSelection(id, reasoning, undefined, { fast }); +``` + +In `catalog.ts`, the upgrade is a kind mapping applied after parsing, before spec lookup: + +```diff ++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; ++} +``` + +```diff + export function resolveCursorSelection( + pickedId: string, + reasoning: string | undefined, + liveMaxModeIds?: ReadonlySet, ++ options: { fast?: boolean } = {}, + ): CursorResolvedSelection { + const parsed = parseCursorVariantId(pickedId); + if (!parsed.known) { ... } + const capability = CURSOR_CAPABILITIES[parsed.baseId]!; +- const spec = capability.variants[parsed.kind] ?? capability.variants.regular; ++ const kind = options.fast === true ? upgradeToFast(parsed.baseId, parsed.kind) : parsed.kind; ++ const spec = capability.variants[kind] ?? capability.variants.regular; +``` + +Every later use of `parsed.kind` in that function (`composeWireId`, the `wirePrefix` guard) +switches to `kind`. The prefix guard matters: `kind === "regular"` is what adds +`cursor-`, and a Grok pick upgraded to `fast` must not keep it — but Grok never reaches +`composeWireId` when fast, because `cursorGrokFastSelection` intercepts first. + +**All three later reads must move to `kind`, not just the spec lookup (audit B10).** The +reviewer quoted the live body: `parsed.kind` is read at `catalog.ts:487` (spec), `:493` +(`composeWireId`), and `:494-495` (the `wirePrefix === "cursor-"` guard). Rebinding only +`spec` would make Opus Fast emit the thinking id with no `-fast`, and would keep the +`cursor-` prefix on any Grok pick that bypassed `cursorGrokFastSelection`. The complete +hunk: + +```diff + const capability = CURSOR_CAPABILITIES[parsed.baseId]!; +- const spec = capability.variants[parsed.kind] ?? capability.variants.regular; ++ const kind = options.fast === true ? upgradeToFast(parsed.baseId, parsed.kind) : parsed.kind; ++ const spec = capability.variants[kind] ?? capability.variants.regular; + if (!spec) { ... } + 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; +``` + +`cursorGrokFastSelection` gains the same promotion so an umbrella Grok pick takes the +parameterized path: + +```diff + 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; ++ const kind = fast === true ? upgradeToFast(parsed.baseId, parsed.kind) : parsed.kind; ++ if (!parsed.known || kind !== "fast") return undefined; +``` + +`createCursorRequest` derives the flag from the tier decision: + +```diff +- const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning); ++ // decideTier already applied fastMode / caller-tier precedence; a {kind:"set"} decision ++ // on this route means canonical Fast survived the policy gate. ++ const fastRequested = parsed.options.tierDecision?.kind === "set"; ++ const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning, fastRequested); +``` + +Reading `tierDecision` rather than `serviceTier` keeps one decision authority: config +`fastMode: false` produces `{kind:"drop"}` and correctly suppresses the upgrade even when +the caller asked. + +## 4. Telemetry stops lying + +```diff +- if (adapter.runTurn && !adapter.tierLogForRunTurn) { ...(..., null, null) } +``` + +The generic fallback in `adapters/registry.ts` stays for other adapters. Cursor sets its +own in `src/adapters/cursor.ts`: + +```diff ++ // Cursor emits Fast as a variant, so the wire fact is the resolved variant, not a field. ++ adapter.tierLogForRunTurn = parsed => { ++ const request = createCursorRequest(parsed); ++ const emittedFast = request.modelId.endsWith("-fast") ++ || (request.requestedModelParameters ?? []).some(p => p.id === "fast" && p.value === "true"); ++ return createAdapterTierMetadata( ++ parsed.options.tierObservation, ++ parsed.options.tierDecision, ++ emittedFast ? "cursor-variant" : null, ++ emittedFast ? "fast" : null, ++ ); ++ }; +``` + +**Rebuilding the request is NOT allowed (audit B7, blocker).** `createCursorRequest` is not +a pure function: `resolveCursorConversationId` (`request-builder.ts:320-335`) calls +`generatedCursorConversationId()` on three of its four branches, so a second call mints a +DIFFERENT conversation id, and `resolveCursorCheckpoint` (`request-builder.ts:479`) +consults checkpoint state. A telemetry-only rebuild would fabricate a conversation that was +never sent and could disturb checkpoint bookkeeping. + +So the wire fact must come from the request the adapter already built. The Cursor adapter +holds it at `src/adapters/cursor.ts:148` (`let request = createCursorRequest(_parsed)`); +`tierLogForRunTurn` reads that value instead of building its own: + +```diff ++ // Cursor emits Fast as a variant, so the wire fact is the variant that was actually ++ // sent. createCursorRequest is NOT pure (it mints conversation ids), so this reads the ++ // request the run already built rather than rebuilding one. ++ const emittedFast = (sent: CursorRunRequest) => sent.modelId.endsWith("-fast") ++ || (sent.requestedModelParameters ?? []).some(p => p.id === "fast" && p.value === "true"); +``` + +`tierLogForRunTurn` runs BEFORE `runTurn`, not after (reviewer finding 3, verified): + +```ts +// src/server/responses/core.ts:3477-3479 +let runTurnAdapter = adapter; +if (adapter.runTurn) { + recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed)); +} +``` + +That kills both candidate designs. A write-back flag set inside `runTurn` is read before it +is written. A rebuild inside `tierLogForRunTurn` runs before `_cursorIdentityScope` +(`cursor.ts:134-146`) and `_cursorConversationId` (`cursor.ts:160`) exist, so it mints a +second `crypto.randomUUID()` conversation and hashes a `local` scope instead of the token +scope. Neither reports the request that was actually sent. + +What IS pure and available at that moment is the variant resolution itself — it reads only +`parsed.modelId`, `parsed.options.reasoning`, and `parsed.options.tierDecision`. So the +telemetry recomputes the VARIANT, not the request: + +```diff ++ // Fast is a variant here, so the wire fact is which variant the resolver will pick. ++ // tierLogForRunTurn runs BEFORE runTurn (core.ts:3479), and createCursorRequest is not ++ // pure (it mints conversation ids), so this must not rebuild the request. Variant ++ // resolution is pure and reads the same three inputs the builder will read. ++ adapter.tierLogForRunTurn = parsed => { ++ const fast = cursorRequestEmitsFastVariant(parsed); ++ return createAdapterTierMetadata( ++ parsed.options.tierObservation, ++ parsed.options.tierDecision, ++ fast ? "cursor-variant" : null, ++ fast ? "fast" : null, ++ ); ++ }; +``` + +`cursorRequestEmitsFastVariant(parsed)` is a new exported helper in `request-builder.ts` +that shares `normalizeCursorModelId`'s exact inputs and returns whether the resolved wire +carries the fast dimension. Sharing the function is what keeps telemetry and the wire from +drifting; a B-phase test asserts they agree for every fast-capable base. + + +Cursor's response carries no tier echo, so `confirmation` stays `"assumed"` — the +`responseTierAuthoritative: false` path. Do not claim `"confirmed"`. + +## 5. Field chain + +`FastWire.kind` gains a value; every stage: + +| Stage | Location | +|---|---| +| creation | `registry.ts` cursor entry; `config.ts` `fastWireSchema` accepts the literal | +| serialization | `cloneFastWire` — kind-agnostic spread, no change | +| deserialization | `fastWireSchema` enum must list `"cursor-variant"` or config load rejects it | +| consumers | `FAST_WIRE_ADAPTERS` (exhaustive Record — a missing key is a type error), `AttemptTierOutcome.wireKind`, `canonicalFromWire`, `behavior.ts` fingerprint, `fastWireDeclarationError` | + +`FAST_WIRE_ADAPTERS` being a `Record` means the compiler finds THAT +consumer. It does NOT find string-comparison consumers, and there is one (audit B4): + +```ts +// src/usage/cost.ts:418-425 +if (outcome.fastOutcome === "unknown" && outcome.wireKind === "service-tier" && ...) { + return { requestedServiceTier: outcome.wireValue }; +} +``` + +A `"cursor-variant"` outcome falls through that branch. That is CORRECT for us, because an +applied Cursor Fast sets `canonical: "priority"` with `confirmation: "assumed"` and is +caught one branch earlier (`cost.ts:414`). Correct by accident is not proven, so wp3 +asserts cost attribution explicitly instead of leaving it to a future refactor. + +`fastWireDeclarationError` has NO adapter allowlist — it validates shape only +(`fastwire.ts:458-490`) — and `hasFastWireCapabilityConflict` fires only when +`fastWire === null` (`fastwire.ts:450`), which does not apply here. + +**`src/usage/log.ts` discards the whole outcome (audit B8, blocker — reviewer round 2).** +`normalizeAttemptTierOutcome` allowlists `wireKind` at two sites: + +```ts +// src/usage/log.ts:322-325 — validation +if ("wireKind" in outcome && outcome.wireKind !== null + && outcome.wireKind !== "service-tier" + && outcome.wireKind !== "anthropic-speed") return null; // drops the ENTIRE row +// src/usage/log.ts:340 — projection repeats the same three-way test +``` + +A `"cursor-variant"` outcome returns `null`, so the persisted attempt loses its tier row and +the GUI Logs view shows nothing after a restart. Worse than the `cost.ts` fall-through: +silent total loss, invisible to `tsc` because both are string comparisons. Both sites must +accept the new kind in the same commit. + +**`tests/fastwire-policy.test.ts:647` goes red by design (audit B9).** + +```ts +test("A1 adds no explicit registry FastWire declaration", () => { + expect(PROVIDER_REGISTRY.every(entry => entry.fastWire === undefined)).toBeTrue(); +}); +``` + +It encodes "A1 shipped no registry fastWire", which this work-phase deliberately ends. +Rewrite it to assert the new invariant — cursor is the only entry carrying a declaration and +its kind is `cursor-variant` — rather than deleting the coverage. + +## 6. Bypass record (PLAN-BYPASS-NAMED-01) + +- Tier: E2 (type-level exhaustiveness + tests). +- Executing surface: `tsc` for the kind Record; `bun test` for behavior. Note `tsc` does + NOT cover string-comparison consumers such as `usage/cost.ts:422` (audit B4). +- Known bypass: an operator can set `providers.cursor.supportsServiceTier: true`, which + advertises Fast on all 34 bases; 29 would then resolve with no fast variant and silently + send the ordinary wire id. +- Residual risk: a dead toggle on operator-misconfigured installs. +- Wording: this is an early warning, not enforcement. Final enforcement layer: none. + +The upgrade is a no-op when the variant is absent (`upgradeToFast` returns the input kind), +so the misconfiguration degrades to today's behavior rather than an error. + +## 7. Existing tests that constrain this change + +`tests/codex-catalog.test.ts:2923-2947` asserts routed entries carry NO +`service_tiers`/`additional_speed_tiers`, and `:2959-2973` asserts a routed row DOES get +them when the model declares `supportsServiceTier: true`. Both stay valid: the first uses +providers that declare no capability, the second is the shape Cursor now joins. Check during +B whether either fixture uses `provider: "cursor"`; if so, update it to assert the new +per-base behavior rather than the blanket absence. diff --git a/devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md b/devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md new file mode 100644 index 0000000000..2ea2c3ede2 --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md @@ -0,0 +1,206 @@ +# WP4 / PR3 — `fastMode` exposes `-fast` identities outside Codex + +Stacked on PR2. Scope IN: `src/claude/model-info.ts`, `src/server/index.ts` (`/v1/models` +branches only), `src/server/management/agent-settings-routes.ts` (aliases only), +`src/adapters/cursor/request-builder.ts`, docs-site EN reference, tests. +Scope OUT: dashboard `/api/models` `namespaced` ids, Desktop 3P hashed aliases, +`ocx models` static output. + +## The asymmetry this closes + +Codex has a Fast toggle, so its rows stay umbrella rows and the toggle picks the dimension +(WP3). Claude Code, Pi, and other OpenAI-compatible clients have no toggle — they can only +pick a listed id. With `fastMode: true`, those surfaces list the fast identity instead. + +``` +config.fastMode = true + ├─ Codex catalog ....... unchanged umbrella rows + service_tiers (WP3) + ├─ Claude Code list .... claude-ocx-cursor--claude-opus-5-fast + ├─ OpenAI /v1/models ... cursor/claude-opus-5-fast + ├─ dashboard ........... unchanged (namespaced is the disable key) + └─ request path ........ umbrella pick promotes to fast anyway +``` + +The last row is what makes an already-persisted client config behave consistently: a +Claude Code `settings.json` still naming the umbrella id gets fast treatment without +rediscovery. + +## Change map + +| File | Action | +|---|---| +| `src/adapters/cursor/catalog.ts` | MODIFY — export `cursorFastIdFor(baseId)` | +| `src/claude/model-info.ts` | MODIFY — `buildAnthropicModelInfos` takes `fastCursorBases` | +| `src/server/index.ts` | MODIFY — both list branches pass the fast id set | +| `src/server/management/agent-settings-routes.ts` | MODIFY — `aliases` follow the same rule | +| `src/adapters/cursor/request-builder.ts` | MODIFY — `fastMode` promotion fallback | +| `tests/cursor-fast-listing.test.ts` | NEW | +| `docs-site/src/content/docs/reference/configuration/providers.md` | MODIFY — brief `fastMode` note | + +## 1. One id-composition helper + +```diff ++/** ++ * The listed id for a base when the global fast switch is on. Returns undefined when the ++ * base has no fast dimension, so a caller cannot invent an unroutable id. ++ */ ++export function cursorFastIdFor(baseId: string): string | undefined { ++ const variants = CURSOR_CAPABILITIES[baseId]?.variants; ++ if (!variants?.fast && !variants?.thinkingFast) return undefined; ++ return \`\${baseId}-fast\`; ++} +``` + +`parseCursorVariantId("claude-opus-5-fast")` already resolves through the suffix grammar +(`catalog.ts:360-371`), and for a thinking-default base the `-fast` suffix yields +`kind:"fast"`; `upgradeToFast` from WP3 is not involved because the id is explicit. So the +listed id routes with no inbound grammar change. + +## 2. Claude Code discovery + +`buildAnthropicModelInfos` already knows how to publish a dimension as a row +(`push1mVariant`). Fast is a *replacement*, not an addition: the point is that the client's +only pick is the fast one. + +```diff + export function buildAnthropicModelInfos( + nativeSlugs: readonly string[], + routedModels: readonly CatalogModel[], + auto: AutoContextMode = AUTO_CONTEXT_OFF, + idStyle: AnthropicIdStyle = "desktop3p", + aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias, + nativeContextCap?: NativeContextLimitsInput, ++ fastMode?: boolean, + ): AnthropicModelInfo[] { +``` + +```diff + for (const m of routedModels) { +- const id = idStyle === "readable" ? claudeCodeAlias(m.provider, m.id) : aliasForRoute(m.provider, m.id); ++ // Global Fast has no toggle on this surface, so the fast identity is what gets listed. ++ // Desktop 3P ids are hashed from the model name, so changing them would strand a saved ++ // picker selection — the rewrite is limited to the readable CLI style. ++ const fastId = fastMode === true && m.provider === "cursor" && idStyle === "readable" ++ ? cursorFastIdFor(m.id) ++ : undefined; ++ const modelId = fastId ?? m.id; ++ const id = idStyle === "readable" ? claudeCodeAlias(m.provider, modelId) : aliasForRoute(m.provider, m.id); +``` + +The `display_name` follows `modelId` so the picker reads `claude-opus-5-fast (cursor)`. +`push1mVariant` keeps using the same base info, so a 1M base still gets its `[1m]` row and +the two dimensions compose as `...-fast[1m]` — consistent with the existing marker rule +that `[1m]` is a suffix on whatever id precedes it. + +Desktop 3P is deliberately excluded: `desktop3pAlias` hashes the model name, so a rewrite +would change every hash and strand saved selections. The objective says not to touch it. + +Call site: + +```diff +- const data = buildAnthropicModelInfos(desktopNativeSlugs, goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias, nativeContextLimits(config)); ++ const data = buildAnthropicModelInfos(desktopNativeSlugs, goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias, nativeContextLimits(config), config.fastMode); +``` + +## 3. OpenAI-compatible list + +```diff + ...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { +- const publicId = m.alias ?? \`\${m.provider}/\${m.id}\`; ++ // Same rule as the anthropic branch: with the global fast switch on, a ++ // toggle-less client is offered the fast identity directly. ++ const fastModelId = config.fastMode === true && m.provider === "cursor" ++ ? cursorFastIdFor(m.id) ++ : undefined; ++ const publicId = m.alias ?? \`\${m.provider}/\${fastModelId ?? m.id}\`; +``` + +`m.alias` wins when an operator set one — an explicit alias is a user decision and the +switch does not override it. + +`grokEffortFields` keeps reading `m.reasoningEfforts`, which is correct: the fast variant's +ladder can be shorter (`claude-opus-5-fast` stops at `high`), and advertising the base +ladder there would let a client request `max` on a fast id. Record this as a known residual +in the PR description; tightening it means threading the variant spec into the listing, +which is a follow-up rather than part of this slice. + +## 4. Dashboard aliases + +`GET /api/claude-code` builds `aliases` with the same `claudeCodeAlias` helper, so it uses +the identical rule to stay consistent with what Claude Code will actually discover. Its +`available` list (`provider/id`) and the Models tab `namespaced` id stay untouched, because +those are keys for `disabledModels` and export. + +## 5. Request-time promotion + +Listing alone leaves persisted client configs on the umbrella id. WP3 already promotes when +`decideTier` returns `{kind:"set"}`, and `fastMode: true` produces exactly that on an +eligible route. + +**The planned `tierDecision === undefined` fallback is dropped (audit B6).** It was written +for "inbound paths that never build a tier decision", and that state does not exist for +Cursor: `src/server/claude-messages.ts:37,772` converts an anthropic request into a +Responses body and replays it through `handleResponses`, which is the same function that +runs `decideTier` at `responses/core.ts:2095`. Chat-native calls it directly +(`chat-native.ts:192`). A branch guarded on `tierDecision === undefined` would be +unreachable by construction — exactly the dead conditional +C-ACTIVATION-GROUNDING-01 forbids planning. + +So PR3 adds no request-path code. Instead it adds the activation evidence that PR2's +promotion really fires on the non-Codex route: + +```ts +test("anthropic-inbound reaches the cursor resolver with a set tier decision", async () => { + // fastMode: true, model claude-ocx-cursor--claude-opus-5, no service_tier in the body + const request = await captureCursorRequestVia(claudeMessagesHandler, { fastMode: true }); + expect(request.modelId).toMatch(/-fast$/); +}); +``` + +If that test goes red, the correct fix is in the shared `decideTier` path, not a +Cursor-local fallback. + +## 6. Tests (activation-grounded) + +`tests/cursor-fast-listing.test.ts`: + +```ts +test("fastMode off lists the umbrella id", () => { + const rows = buildAnthropicModelInfos([], [cursorModel("claude-opus-5")], AUTO_CONTEXT_OFF, "readable", desktop3pAlias, undefined, false); + expect(rows.map(r => r.id)).toContain("claude-ocx-cursor--claude-opus-5"); +}); + +test("fastMode on lists the fast identity for a fast-capable base", () => { + const rows = buildAnthropicModelInfos([], [cursorModel("claude-opus-5")], AUTO_CONTEXT_OFF, "readable", desktop3pAlias, undefined, true); + expect(rows.map(r => r.id)).toContain("claude-ocx-cursor--claude-opus-5-fast"); +}); + +test("fastMode on leaves a base without a fast variant alone", () => { + const rows = buildAnthropicModelInfos([], [cursorModel("kimi-k3")], AUTO_CONTEXT_OFF, "readable", desktop3pAlias, undefined, true); + expect(rows.map(r => r.id)).toContain("claude-ocx-cursor--kimi-k3"); +}); + +test("the listed fast id still routes", () => { + const request = createCursorRequest(parsedFor("cursor/claude-opus-5-fast", "high")); + expect(request.modelId).toBe("claude-opus-5-high-fast"); +}); + +test("desktop3p hashed aliases are untouched by the switch", () => { ... }); +``` + +The third and fifth tests are the guards that make the first two safe: they prove the +rewrite is scoped to fast-capable bases and to the readable id style. + +## 7. Docs + +One short subsection under the providers reference: what `fastMode` does per surface, that +Codex keeps its toggle, and that only bases with a fast variant are affected. Brief, per +the user's instruction on documentation. + +## 8. Residual risks + +- Effort ladders on a listed fast id advertise the base ladder (§3). Known, documented. +- A client caching the old id keeps working — the umbrella id never stops routing. +- `fastMode` now means both "OpenAI priority tier" and "Cursor fast variant". That is a + deliberate overload of one user-facing intent ("go faster"), recorded here so a future + reader does not mistake it for an accident. From cba7d78a63200e15a5fc60a40f8f1af369810534 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 06:27:31 +0900 Subject: [PATCH 2/4] feat(cursor): derive the picker seed from the capability table and label every row CURSOR_STATIC_MODELS was a hand-maintained list that drifted from CURSOR_CAPABILITIES: cursorUmbrellaRows() existed but only tests called it, so collapsing a variant changed routing without changing what Codex listed. The seed now derives from that function plus two declared lists for ids with no capability record, so the two can no longer disagree. Cursor rows also showed raw slugs (cursor/kimi-k3) because routedDisplayName passes a routed slug through unchanged and nothing carried Cursor's labels into the provider config. ProviderRegistryEntry had no modelDisplayNames field at all; the consumer (configuredModelDisplayName) already existed. Wire it through providerConfigSeed and enrichProviderFromRegistry, the latter per-model so an existing install picks up labels without losing an operator rename. The row set is unchanged (54 ids, none added or dropped) - this is a refactor of where rows come from, plus labels and three corrected windows (gemini 1048576, gpt-5.5-extra 200000) where the capability table was approximating the seed. Fixes the frozen row-count assertion that went red when claude-fable-5-1 was seeded in 5fc7d073e: it now derives the expected count instead of hard-coding it. Refs devlog/_plan/260902_cursor_unified_identity/010_wp2_umbrella_seed.md --- .../002_audit_round1.md | 54 ++++++ .../020_wp3_codex_fast_toggle.md | 7 + .../030_wp4_global_fast_switch.md | 32 +++- src/adapters/cursor/catalog.ts | 53 +++++- src/adapters/cursor/discovery.ts | 169 ++++++++---------- src/providers/derive.ts | 6 + src/providers/registry.ts | 9 + tests/cursor-display-names.test.ts | 55 ++++++ tests/cursor-umbrella-rows.test.ts | 41 ++++- 9 files changed, 318 insertions(+), 108 deletions(-) create mode 100644 tests/cursor-display-names.test.ts diff --git a/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md b/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md index a47c73744d..53dc0f60a5 100644 --- a/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md +++ b/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md @@ -172,3 +172,57 @@ Reviewer's normalized line: `VERDICT: GO-WITH-FIXES (blockers=2)`. Both folded a The broad round-1 lane (`Carver`) is still running; anything it returns that is not already folded appends as round 3. + +## Round 3 — broad reviewer lane (`Carver`), 10 blockers + +Returned after the round-2 lane. Six findings duplicate what round 1/2 already folded +(B1 kind allowlist, B2 supportsServiceTier short-circuit, B4/B8 cost+log consumers, +B5 base-URL guard, B9 fastwire-policy assertion, verifier honesty). Independent +confirmation of the same diagnosis from a lane that read the tree separately. + +Four are NEW and two of those are real design defects: + +### B11 (High, NEW) — the listed `-fast` id is the WRONG dimension for thinking-default bases + +`cursorFastIdFor` returns `-fast`, and `parseCursorVariantId("claude-opus-5-fast")` +yields `kind: "fast"` — the REGULAR-fast sibling, not `thinkingFast`. Measured: + +``` +umbrella claude-opus-5 + high -> claude-opus-5-thinking-high +listed claude-opus-5-fast + max -> claude-opus-5-high-fast (regular-fast, clamped) +thinkingFast + max -> claude-opus-5-thinking-max-fast +``` + +So WP3's Codex toggle (`thinking -> thinkingFast`) and WP4's listed id would send DIFFERENT +wires for the same base and the same user intent. Worse, `claude-opus-5`'s regular variant is +quarantined, so the listed id routes into the dead family. + +**Fold:** `cursorFastIdFor` composes from the base's `defaultVariant` — `thinking` yields +`-thinking-fast`, `regular` yields `-fast` — so the listed id parses back to the +same variant `upgradeToFast` picks. WP4 adds an equivalence test asserting the listed id and +the toggled umbrella id resolve to the same wire for every fast-capable base. + +### B12 (High, NEW) — `options.fastMode` in 030 had no possible caller + +`CreateCursorRequestOptions` carries only `forceFreshConversation` +(`request-builder.ts:369`) and `AdapterFactoryContext` has no `fastMode` +(`adapters/registry.ts:18`). The fallback I had already dropped for being unreachable was +also unimplementable. Confirms the round-1 B6 disposition. The reviewer additionally proved +chat-completions is not native-chat for Cursor (`isNativeChatRouteEligible` requires +`adapter === "openai-chat"`, `chat-native.ts:62`) and replays through `handleResponses` +(`chat-completions.ts:130,254`), so BOTH non-Codex inbound paths populate `tierDecision`. + +### B13 (Medium, NEW) — Grok's two call sites must change atomically + +`request-builder.ts:204` calls `cursorGrokFastSelection(id, reasoning)` with no third +argument. If only `resolveCursorSelection` learns the fast flag, a toggled Grok pick would +emit a flattened `grok-4.6-high-fast` instead of the required +`{id:"fast",value:"true"}` parameters — violating WP3's own accept row. Both helpers and +that call site are one atomic edit, and the Grok accept-row test belongs to WP3. + +### B14 (Low, NEW) — no `040+` doc for residuals + +030 names a residual (effort ladders advertised on a listed fast id) with no home. Park it +in `040_residuals.md` when WP4 lands rather than leaving it only in prose. + +Reviewer's normalized line: `VERDICT: GO-WITH-FIXES (blockers=10)`. diff --git a/devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md b/devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md index 3719a501cc..e743bf5535 100644 --- a/devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md +++ b/devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md @@ -417,3 +417,10 @@ them when the model declares `supportsServiceTier: true`. Both stay valid: the f providers that declare no capability, the second is the shape Cursor now joins. Check during B whether either fixture uses `provider: "cursor"`; if so, update it to assert the new per-base behavior rather than the blanket absence. + +**Both Grok call sites change atomically (audit B13).** `request-builder.ts:204` calls +`cursorGrokFastSelection(id, reasoning)` with no third argument today. If only +`resolveCursorSelection` learns the flag, a toggled Grok pick emits a flattened +`grok-4.6-high-fast` instead of the required `{id:"fast",value:"true"}` parameters — +violating this phase's own accept row. The helper signature, `normalizeCursorModelId`, and +that call site are one edit, and the Grok accept-row test belongs to wp3, not wp4. diff --git a/devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md b/devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md index 2ea2c3ede2..99e2b98a51 100644 --- a/devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md +++ b/devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md @@ -43,18 +43,36 @@ rediscovery. +/** + * The listed id for a base when the global fast switch is on. Returns undefined when the + * base has no fast dimension, so a caller cannot invent an unroutable id. ++ * ++ * Composed from the base's defaultVariant, NOT a bare \`-fast\` suffix (audit B11): the ++ * umbrella row for a Claude base routes THINKING, so \`claude-opus-5-fast\` would parse back ++ * as the regular-fast sibling — a different wire from what the Codex toggle sends, and for ++ * claude-opus-5 a QUARANTINED one. The listed id must round-trip to the same variant ++ * \`upgradeToFast\` picks. + */ +export function cursorFastIdFor(baseId: string): string | undefined { -+ const variants = CURSOR_CAPABILITIES[baseId]?.variants; -+ if (!variants?.fast && !variants?.thinkingFast) return undefined; -+ return \`\${baseId}-fast\`; ++ const capability = CURSOR_CAPABILITIES[baseId]; ++ if (!capability) return undefined; ++ const kind = upgradeToFast(baseId, capability.defaultVariant); ++ if (kind !== "fast" && kind !== "thinkingFast") return undefined; ++ return kind === "thinkingFast" ? \`\${baseId}-thinking-fast\` : \`\${baseId}-fast\`; +} ``` -`parseCursorVariantId("claude-opus-5-fast")` already resolves through the suffix grammar -(`catalog.ts:360-371`), and for a thinking-default base the `-fast` suffix yields -`kind:"fast"`; `upgradeToFast` from WP3 is not involved because the id is explicit. So the -listed id routes with no inbound grammar change. +Round-trip for the five fast-capable bases, to be re-measured at wp4 P: + +| base | defaultVariant | listed id | parses back to | +|---|---|---|---| +| `claude-opus-4-7` | thinking | `claude-opus-4-7-thinking-fast` | thinkingFast | +| `claude-opus-4-8` | thinking | `claude-opus-4-8-thinking-fast` | thinkingFast | +| `claude-opus-5` | thinking | `claude-opus-5-thinking-fast` | thinkingFast | +| `grok-4.5` | regular | `grok-4.5-fast` | fast | +| `grok-4.6` | regular | `grok-4.6-fast` | fast | + +`parseCursorVariantId` handles both spellings: the `-fast` strip runs before the thinking +grammar (`catalog.ts:360-371`), so `claude-opus-5-thinking-fast` lands on `thinkingFast`. +WP4's equivalence test asserts the listed id and the toggled umbrella id resolve to the SAME +wire id for every base in that table — the guard that keeps the two surfaces from drifting. ## 2. Claude Code discovery diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index f753f24e74..3351deab16 100644 --- a/src/adapters/cursor/catalog.ts +++ b/src/adapters/cursor/catalog.ts @@ -32,6 +32,12 @@ export interface CursorCapability { readonly variants: Partial>; /** Which variant the umbrella picker row selects (thinking merges into the base). */ readonly defaultVariant: CursorVariantKind; + /** + * Human picker label, in Cursor's own spelling. Codex would otherwise show the raw + * routed slug (`cursor/kimi-k3`), because `routedDisplayName` passes it through + * unchanged for every provider (codex/catalog/sync.ts). + */ + readonly displayName: string; /** Context-window metadata (display/routing only — never implies maxMode). */ readonly window: number; /** Max Mode proven on the wire for this base (static evidence; live maxModeModels unions in). */ @@ -46,6 +52,8 @@ const CONTEXT_256K = 256 * K; const CONTEXT_272K = 272 * K; const CONTEXT_500K = 500 * K; const CONTEXT_1M = 1_000 * K; +/** Gemini publishes the exact power-of-two window, not a rounded 1M. */ +const CONTEXT_GEMINI = 1_048_576; const FULL = ["low", "medium", "high", "xhigh", "max"] as const; const T = "thinking-then-effort" as const; @@ -59,6 +67,7 @@ const E = "effort-then-thinking" as const; */ export const CURSOR_CAPABILITIES: Record = { "claude-4.5-opus": { + displayName: "Claude Opus 4.5", window: CONTEXT_200K, defaultVariant: "thinking", variants: { @@ -67,6 +76,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-4.6-opus": { + displayName: "Claude Opus 4.6", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -75,6 +85,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-4.6-sonnet": { + displayName: "Claude Sonnet 4.6", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -83,6 +94,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-4.5-sonnet": { + displayName: "Claude Sonnet 4.5", window: CONTEXT_200K, defaultVariant: "thinking", variants: { @@ -91,6 +103,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-4-sonnet": { + displayName: "Claude Sonnet 4", window: CONTEXT_200K, defaultVariant: "thinking", variants: { @@ -99,6 +112,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-fable-5": { + displayName: "Claude Fable 5", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -113,6 +127,7 @@ export const CURSOR_CAPABILITIES: Record = { // 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": { + displayName: "Claude Fable 5.1", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -121,6 +136,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-fable-5.1": { + displayName: "Claude Fable 5.1", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -129,6 +145,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-5.1-fable": { + displayName: "Claude Fable 5.1", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -137,6 +154,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-sonnet-5": { + displayName: "Claude Sonnet 5", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -145,6 +163,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-opus-4-7": { + displayName: "Claude Opus 4.7", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -155,6 +174,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-opus-4-8": { + displayName: "Claude Opus 4.8", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -165,6 +185,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "claude-opus-5": { + displayName: "Claude Opus 5", window: CONTEXT_1M, defaultVariant: "thinking", variants: { @@ -177,32 +198,38 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "glm-5.2": { + displayName: "GLM 5.2", window: CONTEXT_1M, defaultVariant: "regular", variants: { regular: { levels: ["high", "max"] } }, }, "glm-5.3": { + displayName: "GLM 5.3", window: CONTEXT_1M, defaultVariant: "regular", variants: { regular: { levels: ["low", "high", "max"] } }, }, "gemini-3.6-flash": { - window: CONTEXT_1M, + displayName: "Gemini 3.6 Flash", + window: CONTEXT_GEMINI, defaultVariant: "regular", variants: { regular: { levels: ["minimal", "low", "medium", "high"] } }, }, "gemini-3.7-flash": { - window: CONTEXT_1M, + displayName: "Gemini 3.7 Flash", + window: CONTEXT_GEMINI, defaultVariant: "regular", variants: { regular: { levels: ["low", "medium", "high"] } }, }, "kimi-k3": { + displayName: "Kimi K3", window: CONTEXT_1M, defaultVariant: "regular", maxModeVerified: true, variants: { regular: { levels: ["low", "high", "max"] } }, }, "grok-4.5": { + displayName: "Cursor Grok 4.5", window: CONTEXT_500K, defaultVariant: "regular", wirePrefix: "cursor-", @@ -212,6 +239,7 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "grok-4.6": { + displayName: "Cursor Grok 4.6", window: CONTEXT_500K, defaultVariant: "regular", wirePrefix: "cursor-", @@ -221,71 +249,88 @@ export const CURSOR_CAPABILITIES: Record = { }, }, "gpt-5.1": { + displayName: "GPT-5.1", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "high"] } }, }, "gpt-5.1-codex-max": { + displayName: "GPT-5.1 Codex Max", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "medium", "high", "xhigh"] } }, }, "gpt-5.1-codex-mini": { + displayName: "GPT-5.1 Codex Mini", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "high"] } }, }, "gpt-5.2": { + displayName: "GPT-5.2", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "high", "xhigh"] } }, }, "gpt-5.2-codex": { + displayName: "GPT-5.2 Codex", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "high", "xhigh"] } }, }, "gpt-5.3-codex": { + displayName: "Codex 5.3", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "high", "xhigh"] } }, }, "gpt-5.4": { + displayName: "GPT-5.4", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "medium", "high", "xhigh"] } }, }, "gpt-5.4-mini": { + displayName: "GPT-5.4 Mini", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "medium", "high", "xhigh"] } }, }, "gpt-5.4-nano": { + displayName: "GPT-5.4 Nano", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "medium", "high", "xhigh"] } }, }, "gpt-5.5": { + displayName: "GPT-5.5", window: CONTEXT_272K, defaultVariant: "regular", variants: { regular: { levels: ["low", "medium", "high"] } }, }, "gpt-5.5-extra": { - window: CONTEXT_272K, + displayName: "GPT-5.5 Extra", + // Live GetUsableModels reports 200K for this row, not the gpt-5 family's 272K + // (account-verified 260709). The seed carried the measured number; the capability + // table was approximating from the family. + window: CONTEXT_200K, defaultVariant: "regular", variants: { regular: { levels: ["high"] } }, }, "gpt-5.6-sol": { + displayName: "GPT-5.6 Sol", window: CONTEXT_1M, defaultVariant: "regular", variants: { regular: { levels: FULL } }, }, "gpt-5.6-terra": { + displayName: "GPT-5.6 Terra", window: CONTEXT_1M, defaultVariant: "regular", variants: { regular: { levels: FULL } }, }, "gpt-5.6-luna": { + displayName: "GPT-5.6 Luna", window: CONTEXT_1M, defaultVariant: "regular", variants: { regular: { levels: FULL } }, @@ -523,6 +568,7 @@ export function liveCursorMaxModeBasesForTests(): ReadonlySet { export interface CursorUmbrellaRow { readonly id: string; + readonly displayName: string; readonly efforts: readonly string[]; readonly window: number; /** Max Mode evidence present: the ultra rung maps to maxMode on the wire. */ @@ -562,6 +608,7 @@ export function cursorUmbrellaRows(): CursorUmbrellaRow[] { if (!spec || spec.quarantined) continue; rows.push({ id: baseId, + displayName: capability.displayName, efforts: spec.levels, window: capability.window, maxModeVerified: capability.maxModeVerified === true, diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 93819289e7..faf9214c8f 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -5,7 +5,7 @@ import { cursorWireModelIdWithEffort, CURSOR_THINKING_MODEL_IDS, } from "./effort-map"; -import { parseCursorVariantId } from "./catalog"; +import { cursorUmbrellaRows, parseCursorVariantId } from "./catalog"; export interface CursorModelInfo { id: string; @@ -262,101 +262,86 @@ export function filterCursorConfiguredModelsByLiveDiscovery = new Set([]); +/** + * Cursor products that are NOT a dimension of any capability base. Each carries its own + * label because there is no capability record to read one from. A row belongs here only + * when Cursor ships it as a distinct product; a variant of a cataloged base does not. + */ +export const CURSOR_PRODUCT_MODELS: readonly (CursorModelInfo & { displayName: string })[] = [ + { id: "claude-4.5-haiku", displayName: "Claude Haiku 4.5", contextWindow: CONTEXT_200K }, + { id: "composer-1", displayName: "Composer 1", contextWindow: CONTEXT_200K }, + { id: "composer-2.5", displayName: "Composer 2.5", contextWindow: CONTEXT_200K }, + { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", contextWindow: CONTEXT_GEMINI }, + { id: "gemini-3-flash", displayName: "Gemini 3 Flash", contextWindow: CONTEXT_GEMINI }, + { id: "gemini-3-pro", displayName: "Gemini 3 Pro", contextWindow: CONTEXT_GEMINI }, + { id: "gemini-3-pro-image-preview", displayName: "Gemini 3 Pro Image", contextWindow: CONTEXT_200K }, + { id: "gemini-3.1-pro", displayName: "Gemini 3.1 Pro", contextWindow: CONTEXT_GEMINI }, + { id: "gemini-3.5-flash", displayName: "Gemini 3.5 Flash", contextWindow: CONTEXT_200K }, + { id: "gpt-5-codex", displayName: "GPT-5 Codex", contextWindow: CONTEXT_272K }, + { id: "gpt-5-mini", displayName: "GPT-5 Mini", contextWindow: CONTEXT_272K }, + { id: "gpt-5.1-codex", displayName: "GPT-5.1 Codex", contextWindow: CONTEXT_272K }, + { id: "kimi-k2.7-code", displayName: "Kimi K2.7 Code", contextWindow: CONTEXT_262K }, +]; + +/** + * Real upstream wire ids that LOOK like a dimension of a cataloged base but are served as + * their own catalog row by Cursor, so they stay rows rather than folding into a base. + * + * - `claude-4-sonnet-1m`: a distinct 1M-window row upstream, not `claude-4-sonnet` + ultra. + * claude-4-sonnet carries no maxMode evidence, so folding it would invent a capability. + * `REAL_1M_WIRE_IDS` in catalog.ts already stops the parser reading it as the synthetic + * marker. + * - `gpt-5-fast`: there is no `gpt-5` capability base for it to be a dimension of. + * - `composer-2.5-fast`: composer-2.5 has no effort or variant dimensions at all. + */ +export const CURSOR_REAL_ID_EXCEPTIONS: readonly (CursorModelInfo & { displayName: string })[] = [ + { id: "claude-4-sonnet-1m", displayName: "Claude Sonnet 4 (1M)", contextWindow: CONTEXT_1M }, + { id: "gpt-5-fast", displayName: "GPT-5 Fast", contextWindow: CONTEXT_272K }, + { id: "composer-2.5-fast", displayName: "Composer 2.5 Fast", contextWindow: CONTEXT_200K }, +]; + +/** Picker labels for the auto-router rows, which have no capability record. */ +const CURSOR_ROUTER_DISPLAY_NAMES: Readonly> = { + auto: "Auto", + "auto-cost": "Auto (Cost)", + "auto-balance": "Auto (Balanced)", + "auto-intelligence": "Auto (Intelligence)", +}; + +/** + * The published Cursor row set. DERIVED from CURSOR_CAPABILITIES via cursorUmbrellaRows() + * (devlog 260902_cursor_unified_identity) so the capability table and the picker can no + * longer disagree: one row per base, with thinking / fast / synthetic -1m remaining + * routable aliases that add no rows. + * + * Before this, the seed was a hand-maintained list that drifted from the capability table — + * `cursorUmbrellaRows()` existed but only tests called it, so collapsing a variant changed + * routing without changing what Codex listed. + * + * Windows and effort ladders come from the capability record; the two lists below carry the + * ids that have no capability record, each with its own label and window. + */ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorModels([ - // Context windows and the model lineup mirror Cursor's public models/pricing docs plus the jawcode - // SOT (../jawcode/packages/ai/src/models.json, `cursor` provider), which mirrors the real - // GetUsableModels catalog. Live discovery is the preferred path when logged in; these ids seed the - // routed Codex catalog and provide a static fallback. Cursor base ids carry no effort suffix here — - // the request builder appends the per-model suffix (see effort-map.ts) and reasoning models - // advertise effort so Codex exposes the tier picker. `supportsReasoningEffort` tracks whether the - // model has *selectable effort tiers* (CURSOR_MODEL_EFFORT_TIERS), NOT merely whether it reasons: - // gemini/grok/kimi-k2.7/gpt-5-mini are reasoning models in the SOT but are sent bare (no tier picker). ...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id, contextWindow: CONTEXT_200K, supportsReasoningEffort: false })), - - // Umbrella seed (devlog 260828_cursor_umbrella_catalog): one row per BASE - // model. Thinking merges into the base (the resolver routes the thinking - // variant); fast / thinking-fast / -1m stay routable as aliases but add no - // rows. Windows follow CURSOR_CAPABILITIES where the base is cataloged. - { id: "claude-sonnet-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "claude-4-sonnet", contextWindow: CONTEXT_200K }, - { id: "claude-4-sonnet-1m", contextWindow: CONTEXT_1M }, - { id: "claude-4.5-haiku", contextWindow: CONTEXT_200K }, - { id: "claude-4.5-sonnet", contextWindow: CONTEXT_200K }, - { id: "claude-4.5-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - { id: "claude-4.6-opus", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "claude-4.6-sonnet", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "claude-opus-4-7", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "claude-opus-4-8", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - // claude-opus-5: regular variant is quarantined (not_found on every Run) but - // the umbrella row routes the THINKING variant, which is live — so the base - // row returns to the seed under the umbrella (resolver never sends the - // quarantined regular wire id for the bare slug). - { id: "claude-opus-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "claude-fable-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - // 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; see CURSOR_CAPABILITIES. - { id: "claude-fable-5-1", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "claude-fable-5.1", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "claude-5.1-fable", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - - { id: "composer-1", contextWindow: CONTEXT_200K }, - { id: "composer-2.5", contextWindow: CONTEXT_200K }, - { id: "composer-2.5-fast", contextWindow: CONTEXT_200K }, - - { id: "gemini-2.5-flash", contextWindow: CONTEXT_GEMINI }, - { id: "gemini-3-flash", contextWindow: CONTEXT_GEMINI }, - { id: "gemini-3-pro", contextWindow: CONTEXT_GEMINI }, - { id: "gemini-3-pro-image-preview", contextWindow: CONTEXT_200K }, - { id: "gemini-3.1-pro", contextWindow: CONTEXT_GEMINI }, - { id: "gemini-3.5-flash", contextWindow: CONTEXT_200K }, - // 260825 live GetUsableModels: both ship only as effort-suffixed ids, so each exposes a tier - // picker. 3.6 is the only Cursor model with a `minimal` rung. - { id: "gemini-3.6-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true }, - { id: "gemini-3.7-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true }, - - { id: "gpt-5-codex", contextWindow: CONTEXT_272K }, - { id: "gpt-5-fast", contextWindow: CONTEXT_272K }, - { id: "gpt-5-mini", contextWindow: CONTEXT_272K }, - { id: "gpt-5.1", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.1-codex", contextWindow: CONTEXT_272K }, - { id: "gpt-5.1-codex-max", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.1-codex-mini", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.2", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.2-codex", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.3-codex", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.4", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.4-mini", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.4-nano", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - { id: "gpt-5.5", contextWindow: CONTEXT_272K, supportsReasoningEffort: true }, - // gpt-5.5-extra: absent from cursor.com docs but SURVIVES the live GetUsableModels filter - // (account-verified 260709, devlog/model_update/260709_model_refresh/004_live_snapshot.md). - { id: "gpt-5.5-extra", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - { id: "gpt-5.6-sol", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "gpt-5.6-terra", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "gpt-5.6-luna", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - - // 260709 refresh: stale grok/composer/kimi/gpt ids dropped per current cursor.com docs; the - // 260709 note: grok-4.5 was deferred; confirmed live 260708 (cursor.com/models, xAI launch). - - // Conflict resolution (260709): keep the refreshed 1M context + kimi-k2.7-code from de12fc8, - // take PR #73's supportsReasoningEffort for glm-5.2 (its effort-map tiers landed with the PR). - { id: "glm-5.2", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - // 260814 preemptive: glm-5.3 seeded ahead of Cursor's lineup update (mirrors glm-5.2). - { id: "glm-5.3", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - { id: "kimi-k2.7-code", contextWindow: CONTEXT_262K }, - // kimi-k3: cursor.com/docs/models/kimi-k3; account-verified via GetUsableModels (2026-07-28) — - // ships only as effort-suffixed kimi-k3-{low,high,max}, so the tier picker is exposed. - // kimi-k3 folds the old synthetic kimi-k3-1m row into the umbrella: the base - // is maxModeVerified (user-verified 1M on the Ultra plan, devlog 260826/025), - // so the ultra effort rung arms Max Mode on the wire and the separate picker - // row is gone. cursor/kimi-k3-1m stays routable as an alias. - { id: "kimi-k3", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, - - { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true }, - // 260813 preemptive: grok-4.6 seeded ahead of Cursor's lineup update (mirrors grok-4.5). - { id: "grok-4.6", contextWindow: 500_000, supportsReasoningEffort: true }, + ...cursorUmbrellaRows().map(row => ({ + id: row.id, + contextWindow: row.window, + supportsReasoningEffort: row.efforts.length > 0, + })), + ...CURSOR_PRODUCT_MODELS, + ...CURSOR_REAL_ID_EXCEPTIONS, ]); +/** Picker labels for every seeded row, for providers.cursor.modelDisplayNames. */ +export function cursorModelDisplayNames(): Record { + return Object.fromEntries([ + ...CURSOR_ROUTER_MODEL_IDS.map(id => [id, CURSOR_ROUTER_DISPLAY_NAMES[id] ?? id] as const), + ...cursorUmbrellaRows().map(row => [row.id, row.displayName] as const), + ...CURSOR_PRODUCT_MODELS.map(model => [model.id, model.displayName] as const), + ...CURSOR_REAL_ID_EXCEPTIONS.map(model => [model.id, model.displayName] as const), + ]); +} + export function cursorModelIds(models: readonly CursorModelInfo[] = CURSOR_STATIC_MODELS): string[] { return normalizeCursorModels(models).map(model => model.id); } diff --git a/src/providers/derive.ts b/src/providers/derive.ts index ff59bad932..2a224476a3 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -231,6 +231,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(liveModels !== undefined ? { liveModels } : {}), ...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}), ...(entry.modelContextWindows ? { modelContextWindows: { ...entry.modelContextWindows } } : {}), + ...(entry.modelDisplayNames ? { modelDisplayNames: { ...entry.modelDisplayNames } } : {}), ...(entry.modelInputModalities ? { modelInputModalities: cloneRecordOfArrays(entry.modelInputModalities) } : {}), ...(entry.modelMaxInputTokens ? { modelMaxInputTokens: { ...entry.modelMaxInputTokens } } : {}), ...(entry.defaultMaxOutputTokens !== undefined ? { defaultMaxOutputTokens: entry.defaultMaxOutputTokens } : {}), @@ -478,6 +479,11 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.liveModels === undefined && seed.liveModels !== undefined) prov.liveModels = seed.liveModels; if (prov.contextWindow === undefined && seed.contextWindow !== undefined) prov.contextWindow = seed.contextWindow; if (!prov.modelContextWindows && seed.modelContextWindows) prov.modelContextWindows = { ...seed.modelContextWindows }; + // Per-model fill, not all-or-nothing: an operator who renamed ONE model must still receive + // labels for the rest, and an existing install must pick up newly seeded rows on enrich. + if (seed.modelDisplayNames) { + prov.modelDisplayNames = { ...seed.modelDisplayNames, ...(prov.modelDisplayNames ?? {}) }; + } if (seed.modelInputModalities) prov.modelInputModalities = fillRecordOfArrays(seed.modelInputModalities, prov.modelInputModalities); if (prov.defaultMaxOutputTokens === undefined && seed.defaultMaxOutputTokens !== undefined) prov.defaultMaxOutputTokens = seed.defaultMaxOutputTokens; if (!prov.modelMaxOutputTokens && seed.modelMaxOutputTokens) prov.modelMaxOutputTokens = { ...seed.modelMaxOutputTokens }; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 82279c6ebf..8ed01545ab 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -13,6 +13,7 @@ import { CURSOR_NO_VISION_MODELS, CURSOR_STATIC_MODELS, cursorModelContextWindows, + cursorModelDisplayNames, cursorModelIds, cursorModelInputModalities, cursorModelReasoningEfforts, @@ -270,6 +271,12 @@ export interface ProviderRegistryEntry { modelDiscovery?: ProviderModelDiscoverySpec; contextWindow?: number; modelContextWindows?: Record; + /** + * Registry-supplied picker labels. Without these a routed row shows its raw slug, + * because `routedDisplayName` (codex/catalog/sync.ts) passes the slug through for every + * provider. An operator's `modelDisplayNames` still wins: derive only fills when absent. + */ + modelDisplayNames?: Record; modelInputModalities?: Record; defaultMaxOutputTokens?: number; modelMaxOutputTokens?: Record; @@ -325,6 +332,7 @@ export type ProviderConfigSeed = Pick< OcxProviderConfig, "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" + | "modelDisplayNames" | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" @@ -1111,6 +1119,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, defaultModel: "auto", modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS), + modelDisplayNames: cursorModelDisplayNames(), 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/tests/cursor-display-names.test.ts b/tests/cursor-display-names.test.ts new file mode 100644 index 0000000000..201b68c3a2 --- /dev/null +++ b/tests/cursor-display-names.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { cursorModelDisplayNames } from "../src/adapters/cursor/discovery"; +import { cursorUmbrellaRows } from "../src/adapters/cursor/catalog"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { configuredModelDisplayName } from "../src/codex/catalog/provider-fetch"; +import type { OcxProviderConfig } from "../src/types"; + +/** + * The Codex picker showed raw slugs (`cursor/kimi-k3`) because `routedDisplayName` + * (codex/catalog/sync.ts) passes a routed slug through unchanged, and nothing carried + * Cursor's labels into `providers.cursor.modelDisplayNames` — the registry entry type had + * no such field. These assert the full registry -> config -> catalog-hint path, not just + * that a label table exists (devlog 260902_cursor_unified_identity). + */ +describe("cursor picker labels reach the catalog", () => { + const cursorEntry = () => { + const entry = getProviderRegistryEntry("cursor"); + if (!entry) throw new Error("cursor registry entry missing"); + return entry; + }; + + test("the registry entry carries a label for every seeded row", () => { + const labels = cursorModelDisplayNames(); + expect(cursorEntry().modelDisplayNames).toEqual(labels); + for (const row of cursorUmbrellaRows()) { + expect(labels[row.id]).toBe(row.displayName); + } + // The label is a human name, never the id echoed back. + expect(labels["kimi-k3"]).toBe("Kimi K3"); + expect(labels["grok-4.6"]).toBe("Cursor Grok 4.6"); + expect(labels["claude-opus-5"]).toBe("Claude Opus 5"); + expect(labels.auto).toBe("Auto"); + }); + + test("a fresh seed exposes the labels through configuredModelDisplayName", () => { + const seeded = providerConfigSeed(cursorEntry()); + expect(configuredModelDisplayName(seeded, "kimi-k3")).toBe("Kimi K3"); + expect(configuredModelDisplayName(seeded, "claude-4-sonnet-1m")).toBe("Claude Sonnet 4 (1M)"); + expect(configuredModelDisplayName(seeded, "composer-2.5-fast")).toBe("Composer 2.5 Fast"); + }); + + test("enrich backfills an existing install per model, preserving operator renames", () => { + const existing = { + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + modelDisplayNames: { "kimi-k3": "My K3" }, + } as OcxProviderConfig; + enrichProviderFromRegistry("cursor", existing); + // Operator value survives... + expect(configuredModelDisplayName(existing, "kimi-k3")).toBe("My K3"); + // ...while every other row still gains its label instead of staying unlabeled. + expect(configuredModelDisplayName(existing, "grok-4.6")).toBe("Cursor Grok 4.6"); + }); +}); diff --git a/tests/cursor-umbrella-rows.test.ts b/tests/cursor-umbrella-rows.test.ts index 63f2a55f8a..31a3f03a48 100644 --- a/tests/cursor-umbrella-rows.test.ts +++ b/tests/cursor-umbrella-rows.test.ts @@ -4,7 +4,14 @@ import { recordLiveCursorMaxModeModels, resolveCursorSelection, } from "../src/adapters/cursor/catalog"; -import { CURSOR_STATIC_MODELS, cursorModelReasoningEfforts } from "../src/adapters/cursor/discovery"; +import { + CURSOR_PRODUCT_MODELS, + CURSOR_REAL_ID_EXCEPTIONS, + CURSOR_ROUTER_MODEL_IDS, + CURSOR_STATIC_MODELS, + cursorModelDisplayNames, + cursorModelReasoningEfforts, +} from "../src/adapters/cursor/discovery"; import { createCursorRequest } from "../src/adapters/cursor/request-builder"; import type { OcxParsedRequest } from "../src/types"; @@ -34,11 +41,33 @@ describe("cursor umbrella picker rows (devlog 260828_cursor_umbrella_catalog)", expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5")).toBe(true); }); - test("row count shrank from the 69-row legacy seed", () => { - // 4 router + 50 base rows. Legacy carried 69 (13 thinking + 5 fast - // duplicates + kimi-k3-1m folded away; quarantined opus-5 base returned). - // #3211 pre-seeded Claude Fable 5.1 under three spellings (+3). - expect(CURSOR_STATIC_MODELS.length).toBe(54); + test("the seed is composed of routers + umbrella bases + declared product ids", () => { + // 4 routers + 34 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. + expect(CURSOR_STATIC_MODELS.length).toBe( + CURSOR_ROUTER_MODEL_IDS.length + + cursorUmbrellaRows().length + + CURSOR_PRODUCT_MODELS.length + + CURSOR_REAL_ID_EXCEPTIONS.length, + ); + }); + + test("every umbrella row is published, and no product id shadows a capability base", () => { + const ids = CURSOR_STATIC_MODELS.map(model => model.id); + for (const row of cursorUmbrellaRows()) expect(ids).toContain(row.id); + // normalizeCursorModels dedupes silently, so a collision would drop a row unnoticed. + expect(ids.length).toBe(new Set(ids).size); + const capabilityIds = new Set(cursorUmbrellaRows().map(row => row.id)); + for (const product of [...CURSOR_PRODUCT_MODELS, ...CURSOR_REAL_ID_EXCEPTIONS]) { + expect(capabilityIds.has(product.id)).toBe(false); + } + }); + + test("seed windows are the capability windows, not a second opinion", () => { + const seeded = new Map(CURSOR_STATIC_MODELS.map(model => [model.id, model.contextWindow])); + for (const row of cursorUmbrellaRows()) expect(seeded.get(row.id)).toBe(row.window); }); test("umbrella rows and seed efforts agree for every cataloged base", () => { From 3776e2e7e446cc18c4161fb447c70df2c03736dc Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 06:28:38 +0900 Subject: [PATCH 3/4] docs(cursor): park wp2/wp4 residuals in a numbered doc The roadmap named residuals in prose with no home (audit B14): effort ladders on a listed fast id, claude-4-sonnet-1m staying a real row, fastMode carrying two meanings, and the five pre-existing test failures that reproduce on a clean stash of this branch. Each records what would change the decision, so a later cycle does not rediscover them as new findings. Refs devlog/_plan/260902_cursor_unified_identity/040_residuals.md --- .../040_residuals.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 devlog/_plan/260902_cursor_unified_identity/040_residuals.md diff --git a/devlog/_plan/260902_cursor_unified_identity/040_residuals.md b/devlog/_plan/260902_cursor_unified_identity/040_residuals.md new file mode 100644 index 0000000000..08804bb39a --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/040_residuals.md @@ -0,0 +1,47 @@ +# Residuals + +Known-and-accepted gaps, parked here rather than left in prose (audit B14). Each says what +is wrong, why it was not fixed in its cycle, and what evidence would change the decision. + +## R1 — effort ladders on a listed fast id (wp4) + +`/v1/models` stamps `grokEffortFields(m.reasoningEfforts, …)` from the BASE row, but a fast +variant's ladder can be shorter: `claude-opus-5` runs to `max` while its `fast` spec stops +at `high` (`catalog.ts` CURSOR_CAPABILITIES). With `fastMode: true` a client could therefore +request `max` against a listed `-fast` id. + +Not fixed in wp4 because the resolver clamps: `cursorVariantEffort` picks the top rung the +variant actually declares, so an over-request degrades to `high` rather than failing. The +cost is an advertised rung that silently clamps, not a broken request. + +Fix when: a user reports an effort selection that appears to do nothing on a fast id. The +change is to thread the resolved variant spec into the listing branch instead of reading the +base row's ladder. + +## R2 — `claude-4-sonnet-1m` stays a separate row (wp2) + +It is a real upstream wire id, not `claude-4-sonnet` + ultra, and `claude-4-sonnet` carries +no `maxModeVerified` evidence — folding it would invent a capability. So "1M" still means two +things in the picker: a synthetic ultra marker for `kimi-k3`, and this genuine second row. + +Fix when: live `GetUsableModels` proves `claude-4-sonnet` supports Max Mode, at which point +the row folds into the base the same way `kimi-k3-1m` did. + +## R3 — `fastMode` carries two meanings (wp4) + +One flag drives OpenAI's `service_tier: "priority"` and Cursor's fast VARIANT. These are +different products with different ladders. The overload is deliberate — both express "go +faster" — and is recorded so a later reader does not read it as an accident. + +Fix when: a user needs one on without the other. That is a second flag, not a re-interpretation of this one. + +## R4 — pre-existing red outside this unit + +`bun run test:changed` at `42731a4be` reports 14461 pass / 5 fail. All five reproduce on a +clean stash of this branch, so none is caused by this unit: + +- `tests/cli-capabilities.test.ts` — "every management route is capability-covered" +- `tests/…` CL-07 task effectiveness producer (4 tests) + +Not this unit's to fix. Recorded so a later cycle does not mistake them for a regression it +introduced. From 419e89625f4f7d9358f052e47aea2d5e78f99ae4 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 06:32:51 +0900 Subject: [PATCH 4/4] docs(cursor): record the measured -fast round-trip that proves audit B11 The reviewer's claim that a bare -fast suffix picks the wrong dimension was not theoretical. Measured: claude-opus-5-fast resolves to claude-opus-5-high-fast (clamped, and in the quarantined regular family) while the Codex toggle would send claude-opus-5-thinking-max-fast. The mirror case is just as wrong -grok-4.6-thinking-fast degrades to a bare grok-4.6 with no effort and no fast marker, because grok has no thinkingFast spec. Either fixed suffix is wrong for half the table, which is why cursorFastIdFor composes from the base's defaultVariant. --- .../002_audit_round1.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md b/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md index 53dc0f60a5..42ff67750f 100644 --- a/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md +++ b/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md @@ -226,3 +226,33 @@ that call site are one atomic edit, and the Grok accept-row test belongs to WP3. in `040_residuals.md` when WP4 lands rather than leaving it only in prose. Reviewer's normalized line: `VERDICT: GO-WITH-FIXES (blockers=10)`. + +## B11 confirmed by measurement (`.tmp/probe3.ts`, at 7adb1e66a) + +The reviewer's claim was not theoretical. Bare `-fast` on a thinking-default base picks the +REGULAR-fast sibling and diverges from what the Codex toggle would send: + +``` +base default listed id kind resolved wire (max) +claude-opus-4-7 thinking claude-opus-4-7-fast fast claude-opus-4-7-max-fast + claude-opus-4-7-thinking-fast thinkingFast claude-opus-4-7-thinking-max-fast + umbrella claude-opus-4-7 thinking claude-opus-4-7-thinking-max +claude-opus-5 thinking claude-opus-5-fast fast claude-opus-5-high-fast <- clamped AND quarantined family + claude-opus-5-thinking-fast thinkingFast claude-opus-5-thinking-max-fast +grok-4.5 regular grok-4.5-fast fast grok-4.5-high-fast + grok-4.5-thinking-fast thinkingFast grok-4.5 <- degrades to a bare id +grok-4.6 regular grok-4.6-fast fast grok-4.6-xhigh-fast + grok-4.6-thinking-fast thinkingFast grok-4.6 <- degrades to a bare id +``` + +Two consequences the fix must respect, both visible above: + +1. For a thinking-default base, only `-thinking-fast` round-trips to the variant the + toggle picks. `claude-opus-5-fast` additionally clamps max->high and lands in the + quarantined regular family. +2. For a regular-default base, `-thinking-fast` is WRONG the other way: grok has no + thinkingFast spec, so `resolveCursorSelection` falls back to `variants.regular` and emits + a bare `grok-4.6` with no effort and no fast marker at all. + +So the id must be composed per base from `defaultVariant`, exactly as `cursorFastIdFor` in +030 §1 now does — a single shared suffix would be wrong for one half of the table either way.