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..42ff67750f --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/002_audit_round1.md @@ -0,0 +1,258 @@ +# 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. + +## 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)`. + +## 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. 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..e743bf5535 --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/020_wp3_codex_fast_toggle.md @@ -0,0 +1,426 @@ +# 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. + +**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 new file mode 100644 index 0000000000..99e2b98a51 --- /dev/null +++ b/devlog/_plan/260902_cursor_unified_identity/030_wp4_global_fast_switch.md @@ -0,0 +1,224 @@ +# 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. ++ * ++ * 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 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\`; ++} +``` + +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 + +`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. 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. 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", () => {