From 063671ce36e8fb1203a52caec481ab792704906a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 21:34:10 +0900 Subject: [PATCH 1/4] docs(devlog): Cursor bundle effort-table roadmap (260902_cursor_bundle_effort_table wp0) --- .../000_research.md | 113 +++++ .../001_bundle_protocol.md | 366 +++++++++++++++ .../005_audit_round1.md | 41 ++ .../010_wp1_bundle_effort_table.md | 283 ++++++++++++ .../020_wp2_models_max_output.md | 416 ++++++++++++++++++ .../030_wp3_effort_variant_rows.md | 416 ++++++++++++++++++ .../040_wp4_gui_provenance_hint.md | 101 +++++ .../050_wp5_claude_id_normalizer.md | 200 +++++++++ .../060_wp6_guide.md | 132 ++++++ 9 files changed, 2068 insertions(+) create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/000_research.md create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md create mode 100644 devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/000_research.md b/devlog/_plan/260902_cursor_bundle_effort_table/000_research.md new file mode 100644 index 0000000000..d22042c5c6 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/000_research.md @@ -0,0 +1,113 @@ +# 000 — Research: Cursor's effort table lives in the bundle, not the gateway + +Unit: `260902_cursor_bundle_effort_table`. Base: `origin/dev` at `ee24bab40`. Class C3 +(public inbound contract on `GET /v1/models`, management route, GUI, provider adapter, docs). +Research only; no diffs in this document. + +## Problem + +The Integrations > Cursor card predicts which routed models get a **Reasoning** control in +Cursor Private Inference. For `anthropic/claude-fable-5-1`, `cursor/claude-fable-5-1`, +`cursor/kimi-k3`, `google-antigravity/claude-opus-4-6-thinking`, +`opencode-free/muse-spark-1.2-contributor-free` and `lidge/qwen3.8-27b-nvfp4` it shows "—", +and the live picker agrees: no effort control. The user expected the gateway's ladder +(`reasoning_effort: [...]`) to drive the control. It does not. + +## Where the decision is made (Cursor 3.18.25, read from the shipped bundle) + +Bundle: `/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js` +(9,932,774 bytes, md5 `c2b57b0141b05e7e6e56cdcc206b95a5`; byte-identical between +`Cursor.app` and `Cursor Private Inference.app`, so the table is shared and only the +`localMode` code path differs). + +1. `E(id)`: lower-case, keep the part after the last `/`, drop `@...`. +2. `I(id)`: first match in the family table `b` (verbatim in `001_bundle_protocol.md`): + + | family id | regex | ladder | param | default | outputCap | + |---|---|---|---|---|---| + | anthropic-opus-5 | `^claude-opus-5$` | low·medium·high·xhigh·max | output_config.effort | high | 128000 | + | anthropic-opus-4-7-4-8 | `^claude-opus-4[-.](?:7\|8)$` | same | same | high | 128000 | + | anthropic-opus-4-6 | `^claude-opus-4[-.]6$` | low·medium·high·max | same | high | 128000 | + | anthropic-opus-4-5 | `^claude-opus-4[-.]5$` | low·medium·high·max | same | high | 64000 | + | anthropic-sonnet-4-6 | `^claude-sonnet-4[-.]6$` | low·medium·high·max | same | high | 64000 | + | anthropic-sonnet-5 | `^claude-sonnet-5$` | low·medium·high·xhigh·max | same | high | 128000 | + | anthropic-sonnet-no-effort | `^claude-sonnet-4(?:[-.]5)?$` | none | — | — | 64000 | + | anthropic-haiku-4-5 | `^claude-haiku-4[-.]5$` | none | — | — | 32768 | + | grok-4.3 / 4.5 / 4.6 / grok-build-latest | `^grok-4[.-]3$` etc. | minimal·low·medium·high·xhigh | reasoning_effort | high | — | + | grok-reasoning-no-effort | composer / 4.20 variants | none | — | — | — | + | gpt-5.6 | `^gpt-5[.-]6-(?:luna\|sol\|terra)$` | low·medium·high·xhigh | reasoning_effort | medium | — | + | gemini-no-effort | `^gemini-3\.[1-9].*flash-lite` | none | — | — | — | + | gemini | `^gemini-` | minimal·low·medium·high | reasoning_effort | medium | — (needs `supports_reasoning`) | + + Plus `_(id)`: bare `^gpt-5(?:\.\d+)?$` → low·medium·high·xhigh, default medium. +3. `J(model, tier)` attaches the Reasoning parameter only when `I(id).effort` exists AND + `extendedCapabilitiesDetected === true` (row passed the `fme` schema). For a model with + no family it falls through to `_(id)`, and otherwise the control is absent. +4. `x(model)` / `C(models)`: a row with `capabilities.supports_reasoning === true` and no + family is reported as drift. The workbench logs it as + `"Local provider advertises reasoning support for a model with no hardcoded Bottlerocket + effort family; reasoning controls will be unavailable until it is added to + bottlerocket-families"` (`reportLocalProviderReasoningDrift`, + `out/vs/workbench/workbench.desktop.main.js`). + +Consequence: no `/v1/models` field can add a ladder for `fable`, `kimi`, `qwen` or +`muse`. `fable` appears in the bundle only in the Bedrock id list and the +`isFable5` heuristic; there is no effort family for it in 3.18.25. + +Why regular Cursor showed Fable 5.1 with effort tiers: that picker is Cursor's cloud +catalog (`GetUsableModels`), which carries effort-suffixed ids. The local build reads +only the gateway list and this table. + +## What opencodex does today + +- `src/server/models-capabilities.ts` `CURSOR_EFFORT_FAMILIES`: a hand-copied static + mirror of the table above (3.18.25). It cannot follow a Cursor update. +- `src/server/management/cursor-integration-routes.ts`: `reasoning: cursorEffortFamily(id)` + per visible model; `null` renders as "—". No provenance, no hint. +- `src/integrations/cursor-detect.ts`: finds the install root and version from + `product.json` (`nameLong`), injectable deps, read-only. +- `src/adapters/cursor/{catalog,effort-map,discovery}.ts` + `src/usage/expected-prices.ts`: + Fable 5.1 is seeded three times (`claude-fable-5-1`, `claude-fable-5.1`, + `claude-5.1-fable`) because Cursor has used both Anthropic-style and version-first + spellings and the live roster decides which one survives. +- Guide `docs-site/src/content/docs/guides/cursor-private-inference.md`: documents the + table and the "no control" rows; no install/identify section beyond "opencodex does not + distribute it", no bundle path, no env-var setup. + +## Levers, in dependency order + +1. **Read the table from the installed bundle** (wp1). The proxy already knows the install + root; the table is a stable minified literal (`{id:"…",matches:e=>/…/u.test(e),effort:X}` + with `X` one of `w/T/k/S` or an inline object). Parse regex + ladder + default + + outputCap; cache by path+mtime+size; fall back to the static mirror when there is no + install, the literal is not found, or a regex fails to compile. Surface + `{ source: "bundle" | "static", version }` in the status route. +2. **Send everything the bundle reads** (wp2): top-level `long_context_threshold_tokens` + is read directly by the picker (`kye(e.long_context_threshold_tokens)`) alongside + `pricing.overrides[].min_prompt_tokens`; `capabilities.max_output_tokens` is used when + the family has no `outputCap`. Both are missing today. +3. **Effort-variant rows** (wp3, opt-in): the only way a table-less model gets an effort + choice inside Cursor is separate rows. Off by default, byte-identical list when off. +4. **GUI provenance + hint** (wp4). 5. **Adapter normalizer** (wp5). 6. **Guide** (wp6). + +## Distribution stance (unchanged) + +Cursor does not document or link the Private Inference build; the update endpoint +`api2.cursor.sh/updates/api/update/darwin-arm64/cursor-local/3.18.25` answered 404 on +2026-09-02. The guide identifies an already-installed build and configures it; it never +hosts, links, or scripts a download (`rg 'downloads.cursor.com|cursor-local/'` stays 0). + +## Verifiers (PLAN-VERIFIER-REAL-01, run 2026-09-02) + +| Command | Exit | Reads the change target? | +|---|---|---| +| `bun run typecheck` | 0 (baseline) | yes — tsc over `src/**` | +| `bun test tests/cursor-integration-status.test.ts` | 0 (baseline) | yes — imports `cursorEffortFamily`, starts the server, reads the status route | +| `bun test tests/cursor-local-models-schema.test.ts` | 0 (baseline) | yes — starts the server and reads `/v1/models` | +| `bun test tests/cursor-catalog.test.ts` | 0 (baseline) | yes — adapter catalog/effort-map | +| `bun run privacy:scan` | 0 (baseline) | reads docs-site + devlog | +| `bun run lint:gui && bun run build:gui` | 0 (baseline) | wp4 only | + +Repository-wide `bun run test` is forbidden for this unit (user instruction); exact-head +CI on each PR is the full gate. + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md b/devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md new file mode 100644 index 0000000000..0ab24b5f9f --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md @@ -0,0 +1,366 @@ +# 001 — Cursor Private Inference bundle protocol + +Scope: static inspection of Cursor Private Inference 3.18.25. Evidence comes from the installed macOS bundle; Windows/Linux layout is derived from the repository’s existing install detector, not from inspected binaries. + +## Build identity and bundle paths + +`product.json` reports: + +```json +{ + "nameLong": "Cursor Private Inference", + "version": "3.18.25", + "quality": "stable", + "commit": "280eca2911f1774689696e5f1efa5a4f97a87af0", + "realCommit": "280eca2911f1774689696e5f1efa5a4f97a87af3", + "date": "2026-08-31T23:07:17.484Z", + "applicationName": "cursor", + "dataFolderName": ".cursor" +} +``` + +`buildFlags` and `releaseTrack` are absent. `quality: "stable"` is the only release-channel field. The workbench bundle, not `product.json`, enables the build: + +```js +fl={...,localMode:!1},fl.localMode=!0 +``` + +Paths relative to the install root: + +| Platform | product.json | agent bundle | +|---|---|---| +| macOS | `Contents/Resources/app/product.json` | `Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js` | +| Windows | `resources/app/product.json` | `resources/app/extensions/cursor-agent-exec/dist/main.js` | +| Linux package/extracted AppImage | `resources/app/product.json` | `resources/app/extensions/cursor-agent-exec/dist/main.js` | + +The inspected bundle is 9,932,774 bytes, MD5 `c2b57b0141b05e7e6e56cdcc206b95a5`. Regular Cursor 3.18.25 has a byte-identical `cursor-agent-exec` bundle; local-mode activation differs in the workbench. + +## Configuration inputs + +Workbench provider resolution precedence for both API key and Base URL is: + +1. requested model credentials: `modelDetails.apiKey`, `modelDetails.openaiApiBaseUrl`, or `modelDetails.apiKeyCredentials.{apiKey,baseUrl}`; +2. stored secret `openAIKey` and application storage `openAIBaseUrl`; +3. `CURSOR_LOCAL_AGENT_API_KEY` / `CURSOR_LOCAL_AGENT_BASE_URL`; +4. compatibility variables `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL`. + +Therefore environment variables do not override an already-saved gateway. A URL whose path is `/` becomes `/v1`; trailing slashes are removed. + +Provider-specific environment variables: + +| Variable | Reader/effect | +|---|---| +| `CURSOR_LOCAL_AGENT_BASE_URL` | fallback Base URL | +| `CURSOR_LOCAL_AGENT_API_KEY` | fallback API key | +| `CURSOR_LOCAL_AGENT_HEADERS` | custom headers; newline-separated `Name: value`, not `key=value` | +| `CURSOR_LOCAL_AGENT_ALLOW_CURSOR_HOST` | comma-separated hosts for which an Anthropic `/messages` Base URL is stripped before SDK construction | +| `CURSOR_LOCAL_AGENT_INFERENCE_METADATA` | outgoing `x-cursor-metadata` header | +| `CURSOR_AGENT_LOCAL_REQUEST_LOG` | JSONL request log path; `0`, `false`, or `off` disables | +| `CURSOR_AGENT_LOCAL_REQUEST_LOG_HTML` | companion rendered-log path | +| `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` | lowest-precedence compatibility fallback | + +`CURSOR_LOCAL_AGENT_HEADERS` rejects invalid HTTP names/values, `User-Agent`, and unresolved `{...}` placeholders. It expands `{gitOrgRepo}` and `{gitBranch}`. + +Persistent settings/state read by the desktop path include `useOpenAIKey`, `openAIBaseUrl`, secure `openAIKey`, `availableDefaultModels2`, `localProviderModelIds`, `localProviderAgentModelIds`, `modelPickerDisplayConfiguration`, and `aiSettings.modelConfig.composer`. + +The agent library also supports: + +```ts +localProvider: { + kind: "http", + endpoints: Array<{ + baseUrl: string; + apiKey?: string; + apiKeyHelper?: { scriptPath: string; ttlMs: number }; + }>; +} +customHeaders?: Record; +``` + +`apiKeyHelper` runs the readable script through `/bin/sh`, caches stdout by `scriptPath + ttlMs`, and sets both `Authorization: Bearer ...` and `X-Api-Key`. The desktop IPC currently constructs exactly one endpoint and passes no `apiKeyHelper`; these are library capabilities, not exposed desktop settings. + +## `/models` discovery, cache, fallback, and endpoint selection + +There are two fetch paths: + +- `spe`, the per-turn metadata probe, uses `Vme=2e3`: a 2-second abort timeout. +- `vye` / `fetchLocalProviderModels`, used for full picker enrichment, has no explicit timeout. + +Both send `GET {normalizedBaseUrl}/models`, `User-Agent: Cursor/`, custom headers, and `Authorization: Bearer `. A helper-derived key additionally sends `X-Api-Key`. + +`npe` caches successful raw catalogs forever by Base URL string only. `rpe` deduplicates concurrent probes. A full enrichment fetch overwrites `npe`; per-turn probes never revalidate it. + +The curated fallback list is verbatim: + +```js +const P=["claude-opus-4-8","claude-sonnet-5","claude-sonnet-4-6","gemini-3-pro-preview","grok-4.5"]; +``` + +It is used only when discovery fails for `inference.tesla.com` or a subdomain. Generic gateways do not receive this fallback; they retain persisted/local companion models where available. + +For multiple endpoints, all are probed concurrently. Model resolution attempts: + +1. exact full-id match; +2. Composer compatibility aliases; +3. a unique match after stripping the prefix before the last `/`; +4. a unique `-preview` suffix match. + +An exact requested id across endpoints wins; otherwise the first resolved candidate in endpoint order wins. If the selected catalog id differs, `remappedModelId` replaces the request’s model id. If none resolve, the first endpoint is used. + +## Wire API selection and request rewriting + +A Base URL ending in `/messages` forces `anthropic_messages`. + +Otherwise: + +- only `anthropic_messages`, with no OpenAI-family entry, selects Anthropic Messages; +- `responses` or `openai_responses` selects Responses; +- otherwise `chat_completions` or `openai_chat` selects Chat Completions; +- Responses wins when both Responses and Chat are advertised; +- an explicit caller `apiType` overrides discovery. + +Effort rewriting reads model parameter ids `reasoning`, `effort`, or `thought_level`: + +| Selected wire | Request fields | +|---|---| +| Responses | `reasoning: { ...existing, effort }`; remove `reasoning_effort` | +| Chat Completions | `reasoning_effort: effort` | +| Anthropic Messages | `thinking:{type:"adaptive",display:"summarized"}` and `output_config.effort`; remove `top_p` and `top_k` | + +The selected value must occur in Cursor’s hard-coded ladder. `output_config.effort` is accepted only on Anthropic Messages; `reasoning_effort` families are accepted only on OpenAI-compatible wires. Unsupported combinations have both `reasoning` and `reasoning_effort` removed. + +Consequently, with the documented `/v1` Base URL, Claude controls can render but their `output_config.effort` is removed because Responses is selected. A `/v1/messages` Base URL enables Claude effort but removes GPT/Grok/Gemini effort. The desktop’s singleton endpoint cannot automatically split these families. + +For Anthropic Messages only, `max_tokens` is overwritten when extended capabilities were detected: + +```js +family.outputCap !== undefined + ? family.outputCap + : advertised capabilities.max_output_tokens +``` + +Known Claude families therefore prefer Cursor’s hard-coded 32K/64K/128K cap over the advertised value. OpenAI-compatible requests do not consume the advertised maximum in this rewrite layer. + +## Extended-capability schema and optional-field sources + +Exact schema fragment: + +```js +const lme=new Set(["chat_completions","responses","openai_chat","openai_responses","anthropic_messages"]); +const mme=on.KC([on.g1(on.L5()),on.YO(on.g1(on.L5()))]); +const pme=on.Ik({ + context_length:on.ai().finite().positive().optional(), + max_output_tokens:on.ai().finite().positive().optional(), + output_modalities:on.YO(on.Yj()).optional(), + input_modalities:on.YO(on.Yj()).optional(), + supports_tool_use:on.zM().optional(), + supports_streaming:on.zM().optional(), + supports_reasoning:on.zM().optional(), + supports_vision:on.zM().optional(), + reasoning_effort:on.YO(on.Yj()).optional(), + cost:mme.optional() +}); +const fme=on.Ik({ + api_types:on.YO(on.Yj().min(1)).min(1).refine(e=>e.some(e=>lme.has(e))), + capabilities:pme.optional(), + cost:mme.optional() +}); +``` + +`extendedCapabilitiesDetected = data.some(row => fme.safeParse(row).success)`. One qualifying row flips the endpoint globally. + +When extended mode is true, an individual picker row requires `api_types`, `capabilities`, a supported API type, `supports_tool_use === true`, `supports_streaming === true`, and `output_modalities` containing `"text"`. Mixed legacy/extended catalogs can therefore lose otherwise valid rows. + +Optional-field normalization: + +- `context_length`: `capabilities.context_length` wins; top-level `context_length` fills it only when absent. +- `max_output_tokens`: read only from `capabilities.max_output_tokens`. +- modalities, support booleans, reasoning ladder: read only from `capabilities`. +- long-context threshold precedence: + 1. `cost.long_context.threshold_tokens`; + 2. `capabilities.cost.long_context.threshold_tokens`; + 3. smallest positive `pricing.overrides[].min_prompt_tokens`. +- Raw top-level `long_context_threshold_tokens` is not read. The parser creates its internal top-level field only from the three sources above. +- Nested `cost.long_context` conflicts with `mme`’s numeric-record schema and can prevent that row from satisfying `fme`. `pricing` is outside `fme`, making it the safe encoding currently used by OpenCodex. + +## Feature toggles gated by extended capabilities + +Direct uses found across all 34 occurrences: + +- expose the model-family Reasoning parameter; +- switch local web-search requests from `web_search_preview` to `web_search`; +- enable strict row admission during picker enrichment; +- allow Anthropic `max_tokens` injection from family/advertised limits; +- carry the flag into request rewriting and picker metadata. + +No additional image, MCP, ordinary function-tool, streaming, or vision feature toggle is directly keyed on this flag. + +## Unsupported reasoning drift + +`findUnsupportedReasoningModelIds` normalizes ids exactly like the effort table, deduplicates them, and reports ids whose row has `supports_reasoning === true` but matches neither a table family nor bare GPT-5. + +After a successful non-empty local picker enrichment, the workbench emits one structured `transport` error per id: + +> Local provider advertises reasoning support for a model with no hardcoded Bottlerocket effort family; reasoning controls will be unavailable until it is added to bottlerocket-families + +Malformed/dropped rows and failed enrichment do not reach this log. + +## Effort table, verbatim + +```js +const w={param:"reasoning_effort",values:["low","medium","high","xhigh"],defaultValue:"medium"}; +function _(e){const t=function(e){let t=e.trim().toLowerCase();const n=t.lastIndexOf("/");-1!==n&&(t=t.slice(n+1));const r=t.indexOf("@");return-1!==r&&(t=t.slice(0,r)),t}(e);if(/^gpt-5(?:\.\d+)?$/u.test(t))return w} +const T={param:"output_config.effort",values:["low","medium","high","max"],defaultValue:"high"}; +const k={param:"output_config.effort",values:["low","medium","high","xhigh","max"],defaultValue:"high"}; +const S={param:"reasoning_effort",values:["minimal","low","medium","high","xhigh"],defaultValue:"high"}; +const b=[ +{id:"anthropic-opus-5",matches:e=>/^claude-opus-5$/u.test(e),effort:k,outputCap:128e3}, +{id:"anthropic-opus-4-7-4-8",matches:e=>/^claude-opus-4[-.](?:7|8)$/u.test(e),effort:k,outputCap:128e3}, +{id:"anthropic-opus-4-6",matches:e=>/^claude-opus-4[-.]6$/u.test(e),effort:T,outputCap:128e3}, +{id:"anthropic-opus-4-5",matches:e=>/^claude-opus-4[-.]5$/u.test(e),effort:T,outputCap:64e3}, +{id:"anthropic-sonnet-4-6",matches:e=>/^claude-sonnet-4[-.]6$/u.test(e),effort:T,outputCap:64e3}, +{id:"anthropic-sonnet-5",matches:e=>/^claude-sonnet-5$/u.test(e),effort:k,outputCap:128e3}, +{id:"anthropic-sonnet-no-effort",matches:e=>/^claude-sonnet-4(?:[-.]5)?$/u.test(e),outputCap:64e3}, +{id:"anthropic-haiku-4-5",matches:e=>/^claude-haiku-4[-.]5$/u.test(e),outputCap:32768}, +{id:"grok-4.3",matches:e=>/^grok-4[.-]3$/u.test(e),effort:S}, +{id:"grok-4.5",matches:e=>/^grok-4[.-]5(?:-(?:batch|build|nocomp))?$/u.test(e),effort:S}, +{id:"grok-4.6",matches:e=>/^grok-4[.-]6(?:-(?:batch|build|nocomp))?$/u.test(e),effort:S}, +{id:"grok-build-latest",matches:e=>/^grok-build-latest$/u.test(e),effort:S}, +{id:"grok-reasoning-no-effort",matches:e=>/^grok-(?:composer(?:-2\.5(?:-fast)?)?|4\.20-0309-reasoning|4\.20-multi-agent-0309|420-clanker-reasoning)$/u.test(e)}, +{id:"gpt-5.6",matches:e=>/^gpt-5[.-]6-(?:luna|sol|terra)$/u.test(e),effort:{param:"reasoning_effort",values:["low","medium","high","xhigh"],defaultValue:"medium"}}, +{id:"gemini-no-effort",matches:e=>/^gemini-3\.[1-9].*flash-lite/u.test(e)}, +{id:"gemini",matches:e=>/^gemini-/u.test(e),effort:{param:"reasoning_effort",values:["minimal","low","medium","high"],defaultValue:"medium"},effortRequiresReasoningCapability:!0} +]; +``` + +## Diff-level implications for wp2 + +Do not add top-level `long_context_threshold_tokens`; this build ignores the raw field. Keep `pricing.overrides[].min_prompt_tokens`. + +Modify `src/server/models-capabilities.ts`. + +Before: + +```ts +export interface ModelCapabilityInput { + reasoningEfforts?: readonly string[]; + contextWindow?: number; + longContextWindow?: number; + inputModalities?: readonly string[]; +} +``` + +After: + +```ts +export interface ModelCapabilityInput { + reasoningEfforts?: readonly string[]; + contextWindow?: number; + longContextWindow?: number; + maxOutputTokens?: number; + inputModalities?: readonly string[]; +} +``` + +Add `max_output_tokens?: number` to `ModelCapabilityFields.capabilities`, compute `const maxOutputTokens = positiveInt(input.maxOutputTokens)`, and spread it into `capabilities` only when defined. + +Modify `src/server/index.ts`: + +```ts +import { modelRecordValue } from "../reasoning-effort"; +``` + +Routed-row call, before: + +```ts +contextWindow: m.contextWindow, +inputModalities: m.inputModalities, +``` + +After: + +```ts +contextWindow: m.contextWindow, +maxOutputTokens: provider + ? modelRecordValue(provider.modelMaxOutputTokens, m.id) ?? provider.defaultMaxOutputTokens + : undefined, +inputModalities: m.inputModalities, +``` + +Do not invent native limits where OpenCodex has no authoritative output-limit source. + +Modify `tests/cursor-local-models-schema.test.ts`: + +- Add test `"max_output_tokens is emitted only from an authoritative provider output limit"`. +- Extend `capabilityConfig()` with `defaultMaxOutputTokens: 16000` and `modelMaxOutputTokens: { k3: 32768 }`. +- Assert `k3.capabilities.max_output_tokens === 32768`. +- Assert `kimi-for-coding.capabilities.max_output_tokens === 16000`. +- Assert native `gpt-5.6-sol` omits `max_output_tokens`. +- In `"a larger opt-in window becomes context_length with the default window as the long-context threshold"`, assert no top-level `long_context_threshold_tokens` is emitted and retain the `pricing` assertion. + +Focused verifier: + +```sh +bun run typecheck +bun test tests/cursor-local-models-schema.test.ts tests/grok-models-effort-list.test.ts tests/server-combo-failover-e2e.test.ts +``` + +## Diff-level implications for wp6 + +Modify `docs-site/src/content/docs/guides/cursor-private-inference.md`. + +Replace the `CURSOR_LOCAL_AGENT_HEADERS` claim that it uses `key=value` pairs with: + +```md +`CURSOR_LOCAL_AGENT_HEADERS` is optional. Its value is newline-separated HTTP header +lines (`Header-Name: value`). It rejects `User-Agent` and invalid or unresolved values. +``` + +Add gateway precedence immediately after the environment block: + +```md +Saved Gateway settings and per-model credentials take precedence over these environment +variables. Clear the saved gateway first if you intend to switch it through the environment. +`ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are lower-precedence compatibility fallbacks. +``` + +Correct the wire section: + +```md +With a `/v1` Base URL, Cursor prefers Responses. GPT/Grok/Gemini effort is sent on that +wire, but Claude's `output_config.effort` is removed because it is Messages-only. A Base URL +ending in `/messages` reverses that behavior: Claude effort is sent, while OpenAI-family +effort fields are removed. One desktop gateway cannot split both families automatically. +``` + +Add an “Identify the installed build” subsection containing the platform-relative bundle paths, `nameLong`, `version`, `quality`, and the fact that `localMode` is in the workbench bundle rather than `product.json`. + +Update troubleshooting to say the cache has no TTL; Refresh performs full discovery, while restart or a changed Base URL is the fallback if stale metadata remains. + +Verification: + +```sh +cd docs-site && bun run build +bun run privacy:scan +rg -n 'downloads.cursor.com|cursor-local/' docs-site/src/content/docs/guides/cursor-private-inference.md +``` + +## RISKS + +- This is an undocumented, minified private protocol and can change without schema versioning. +- Static bundle inspection does not prove every provider/wire combination end to end. +- One extended row globally enables strict filtering and can make mixed legacy rows disappear. +- Cursor’s hard-coded Claude output cap overrides the gateway-advertised maximum; OpenCodex must still enforce its own limit. +- The desktop exposes only one endpoint even though the library supports several. +- Windows/Linux bundle contents were not inspected; only their repository-defined layout is recorded. +- `apiKeyHelper` hard-codes `/bin/sh`, making its cross-platform behavior doubtful even if a future desktop path exposes it. + +## OPEN QUESTIONS + +- Should wp2 advertise provider output limits now, given that known Claude families ignore them in favor of Cursor’s cap? +- Should wp6 document `/messages` as a supported Claude-only profile, or only warn that Claude effort is inert on `/v1`? +- Does OpenCodex’s Messages ingress preserve `thinking + output_config.effort` for every routed Claude provider? +- Does Refresh reliably overwrite `npe` in all desktop flows, or are restart/Base-URL changes still required in practice? +- Are Windows and Linux 3.18.25 bundles byte-identical to the inspected macOS bundle? + + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md b/devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md new file mode 100644 index 0000000000..77de5339ce --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md @@ -0,0 +1,41 @@ +# 005 — Audit round 1 (roadmap, wp0) + +Two dispatched reviewers (gpt-5.6-sol high, agents 01a06204… and 01a06210…) produced no output +within 6 and 6 wait cycles and were retired (DISPATCH-RETIRE-01). The main agent audited +directly against the tree on 2026-09-02; evidence below is from commands run in this session. + +## Checks + +1. 010 parser vs the real bundle: `bun /tmp/ocx-effort-probe.js` applying the 010 regexes to + `main.js` → 4 effort constants (w/T/k/S), **16 families**, every `effort:` ref resolves, + outputCaps 128000/64000/32768 read, bare rule `^gpt-5(?:\.\d+)?$` → w. PASS. +2. Before-snippets: `cursor-integration-routes.ts:64-72` matches 010; `context.ts:60` + `readRuntimePort` seam exists for the injected loader; `responses/core.ts:2753` + `parsed = parseRequest(body)`; `chat-completions.ts:136` `isNativeChatRouteEligible`; + `claude-messages.ts:646` `wantsNativePassthrough` — all match 030. PASS. +3. Grammar collision (030): `rg -- '--(low|medium|high|xhigh|max|minimal|none|ultra)\b'` over + registry.ts, effort-map.ts, generated/model-metadata.ts → 0 hits. PASS. +4. Lab boundary: `models-capabilities.ts` has no imports; the planned + `cursor-effort-table.ts` imports node:fs/path + a type. Nothing reaches src/lab. PASS. +5. 050 vs `tests/cursor-catalog.test.ts:101-103` (exact ids `gpt-5.1-codex-max`, + `gpt-5.5-extra`): the normalizer only accepts `claude-*` stems, so ordering it first cannot + mis-parse those. REAL_1M ordering is stated in 050. PASS. +6. 040 i18n: `gui/src/i18n/provider.tsx:25` falls back to `en` per key. Residual resolved. +7. 020 vs 001 contradiction (top-level long_context_threshold_tokens): resolved in 020 by + dropping the field; bundle check `void 0!==i?{long_context_threshold_tokens:i}:{}` confirms + Cursor derives it. PASS. +8. Field chains: effortTable/family (010) create in the route → JSON → cursor-api.ts type → + rendered in 040; tableLess/effortRows (030) same; cursorEffortRows (030) config type + zod + + effort-row.ts + three ingress handlers + status route; maxOutputTokens (020) metadata → + CatalogModel → provider-fetch → aggregation → index.ts row. Complete. + +## Blockers + +1. Medium — 030 §6: `claude-messages.ts:648-654` already applies an `effortOverride` + (`extractOcxEffortDirective`) via `output_config.effort` before translation. wp3's P must + reconcile the row effort with that path (reuse it, or justify injecting the internal + Responses `reasoning.effort` as the lane proposed because of the `none` rung). Folded as a + P-phase task of wp3; not a wp0 blocker. + +VERDICT: GO-WITH-FIXES (blockers=1) + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md b/devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md new file mode 100644 index 0000000000..df04aa8361 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md @@ -0,0 +1,283 @@ +# 010 — wp1: read Cursor's effort table from the installed bundle + +Depends on: 000. Delivers: `src/integrations/cursor-effort-table.ts` (NEW), a resolver in +`src/server/models-capabilities.ts`, provenance on the status route, tests. PR 1; targets +`dev` directly (independent of wp2..wp6). + +Loop-spec: archetype spec-satisfaction; trigger = status card shows "—" for ids the bundle +would render; goal = the card follows the installed Cursor build instead of a hand copy; +non-goals = changing what Cursor renders, writing into a Cursor install; verifier = +`bun test tests/cursor-integration-status.test.ts tests/cursor-effort-table.test.ts` + typecheck; +stop = both green and exact-head CI green; escalation = if the minified literal shape differs on +Windows/Linux builds, keep the static fallback and record it in 011. + +## File change map + +### NEW `src/integrations/cursor-effort-table.ts` + +```ts +/** + * Cursor's local-agent effort table, read from the installed bundle. + * + * Cursor Private Inference decides which model rows get a Reasoning control from a table + * compiled into extensions/cursor-agent-exec/dist/main.js, not from the gateway's + * reasoning_effort list (devlog 260902_cursor_bundle_effort_table/000). Reading that table + * from the install the dashboard already detects lets the prediction follow a Cursor update + * instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size); + * any parse failure yields null so the caller falls back to the static mirror. + */ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { CursorInstall } from "./cursor-detect"; + +export interface CursorEffortFamily { + id: string; + pattern: RegExp; + /** [] = family matched but Cursor shows no control. */ + ladder: readonly string[]; + param?: "reasoning_effort" | "output_config.effort"; + defaultValue?: string; + outputCap?: number; + requiresReasoningCapability: boolean; +} + +export interface CursorEffortTable { + families: readonly CursorEffortFamily[]; + /** The bare gpt-5 / gpt-5.x rule that runs when no family matched. */ + bareGpt5: { pattern: RegExp; ladder: readonly string[]; defaultValue: string } | null; + version: string | null; + bundlePath: string; +} + +const BUNDLE_MAX_BYTES = 32 * 1024 * 1024; + +/** Bundle path under the install root cursor-detect reports. */ +export function cursorAgentBundlePath(install: Pick, platform = process.platform): string { + const tail = ["extensions", "cursor-agent-exec", "dist", "main.js"]; + return platform === "darwin" + ? join(install.path, "Contents", "Resources", "app", ...tail) + : join(install.path, "resources", "app", ...tail); +} + +/** + * Parse the family table out of the minified source: + * const w={param:"reasoning_effort",values:[...],defaultValue:"medium"}; + * ...const T={...},k={...},S={...},b=[{id:"...",matches:e=>/.../u.test(e),effort:k,outputCap:128e3},...]; + * Identifier names are minifier-assigned, so binding is by structure: every + * ={param:"...",values:[...],defaultValue:"..."} is an effort constant, and a family's + * effort: is either such an identifier or an inline object. + */ +export function parseCursorEffortTable(source: string): Omit | null { + const constants = new Map(); + const constRe = /(?:const |,)([A-Za-z_$][\w$]*)=\{param:"(reasoning_effort|output_config\.effort)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/gu; + for (const m of source.matchAll(constRe)) { + constants.set(m[1]!, { param: m[2]!, values: splitStrings(m[3]!), defaultValue: m[4]! }); + } + const tableStart = source.indexOf('=[{id:"anthropic-'); + if (tableStart === -1) return null; + const tableEnd = source.indexOf("];", tableStart); + if (tableEnd === -1) return null; + const body = source.slice(tableStart + 2, tableEnd + 1); + const entryRe = /\{id:"([^"]+)",matches:e=>\/((?:\\\/|[^/])+)\/([a-z]*)\.test\(e\)((?:,(?:effort:(?:[A-Za-z_$][\w$]*|\{[^}]*\})|outputCap:[\de.]+|effortRequiresReasoningCapability:!0))*)\}/gu; + const families: CursorEffortFamily[] = []; + for (const m of body.matchAll(entryRe)) { + let pattern: RegExp; + try { pattern = new RegExp(m[2]!, m[3]!); } catch { return null; } + const tail = m[4]!; + const effortRef = /effort:([A-Za-z_$][\w$]*)(?:,|$)/u.exec(tail)?.[1]; + const inline = /effort:\{param:"([^"]+)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/u.exec(tail); + const effort = inline + ? { param: inline[1]!, values: splitStrings(inline[2]!), defaultValue: inline[3]! } + : effortRef ? constants.get(effortRef) : undefined; + if (effortRef && !inline && !effort) return null; // unknown constant: structure changed + const cap = /outputCap:([\de.]+)/u.exec(tail)?.[1]; + families.push({ + id: m[1]!, + pattern, + ladder: effort?.values ?? [], + ...(effort ? { param: effort.param as CursorEffortFamily["param"], defaultValue: effort.defaultValue } : {}), + ...(cap ? { outputCap: Number(cap) } : {}), + requiresReasoningCapability: tail.includes("effortRequiresReasoningCapability:!0"), + }); + } + if (families.length === 0) return null; + const bareRe = /if\(\/(\^gpt-5[^/]+)\/([a-z]*)\.test\(t\)\)return ([A-Za-z_$][\w$]*)\}/u.exec(source); + const bareConst = bareRe ? constants.get(bareRe[3]!) : undefined; + const bareGpt5 = bareRe && bareConst + ? { pattern: new RegExp(bareRe[1]!, bareRe[2]!), ladder: bareConst.values, defaultValue: bareConst.defaultValue } + : null; + return { families, bareGpt5 }; +} + +function splitStrings(list: string): string[] { + return [...list.matchAll(/"([^"]+)"/gu)].map(m => m[1]!); +} + +export interface CursorEffortTableDeps { + platform: string; + stat(path: string): { mtimeMs: number; size: number } | null; + readText(path: string): string | null; +} + +export function realCursorEffortTableDeps(): CursorEffortTableDeps { + return { + platform: process.platform, + stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } }, + readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } }, + }; +} + +let cache: { key: string; table: CursorEffortTable | null } | null = null; + +/** Table from the Private Inference install, else null (caller falls back to the static mirror). */ +export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null { + if (!install) return null; + const bundlePath = cursorAgentBundlePath(install, deps.platform); + const st = deps.stat(bundlePath); + if (!st || st.size > BUNDLE_MAX_BYTES) return null; + const key = `${bundlePath}|${st.mtimeMs}|${st.size}`; + if (cache?.key === key) return cache.table; + const text = deps.readText(bundlePath); + const parsed = text ? parseCursorEffortTable(text) : null; + const table = parsed ? { ...parsed, version: install.version, bundlePath } : null; + cache = { key, table }; + return table; +} + +export function resetCursorEffortTableCacheForTests(): void { cache = null; } +``` + +### MODIFY `src/server/models-capabilities.ts` + +Keep `CURSOR_EFFORT_FAMILIES` as the static mirror (comment becomes "fallback mirror of the +3.18.25 table; the live table is read by src/integrations/cursor-effort-table.ts"). Add: + +```ts +import type { CursorEffortTable } from "../integrations/cursor-effort-table"; + +export interface CursorEffortPrediction { + ladder: string[] | null; + source: "bundle" | "static"; + /** Bundle family id when one matched (e.g. "anthropic-opus-5"); null otherwise. */ + family: string | null; + outputCap?: number; +} + +export function normalizeCursorPickerId(modelId: string): string { + let id = modelId.trim().toLowerCase(); + const slash = id.lastIndexOf("/"); + if (slash !== -1) id = id.slice(slash + 1); + const at = id.indexOf("@"); + if (at !== -1) id = id.slice(0, at); + return id; +} + +export function predictCursorEffort(modelId: string, table: CursorEffortTable | null): CursorEffortPrediction { + const id = normalizeCursorPickerId(modelId); + if (table) { + for (const family of table.families) { + if (family.pattern.test(id)) { + return { + ladder: family.ladder.length > 0 ? [...family.ladder] : null, + source: "bundle", + family: family.id, + ...(family.outputCap !== undefined ? { outputCap: family.outputCap } : {}), + }; + } + } + if (table.bareGpt5?.pattern.test(id)) return { ladder: [...table.bareGpt5.ladder], source: "bundle", family: "gpt-5" }; + return { ladder: null, source: "bundle", family: null }; + } + return { ladder: cursorEffortFamily(modelId), source: "static", family: null }; +} +``` + +`cursorEffortFamily` is unchanged in behavior (the existing test keeps passing) and reuses +`normalizeCursorPickerId`. + +### MODIFY `src/server/management/cursor-integration-routes.ts` + +Before (lines 64-72): +```ts + const models = ids.map(id => { + const tier = nativeOpenAiContextTier(id, limits); + return { + id, + reasoning: cursorEffortFamily(id), + context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, + }; + }); +``` +After: +```ts + const table = (deps.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference); + const models = ids.map(id => { + const tier = nativeOpenAiContextTier(id, limits); + const predicted = predictCursorEffort(id, table); + return { + id, + reasoning: predicted.ladder, + family: predicted.family, + context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, + }; + }); + const effortTable = table + ? { source: "bundle" as const, version: table.version, families: table.families.length } + : { source: "static" as const, version: null, families: null }; +``` + +- `CursorIntegrationStatus` gains `effortTable: { source: "bundle" | "static"; version: string | null; families: number | null }` + and each model row gains `family: string | null`; `effortTable` is added to the returned object. +- `ManagementContext.deps` (`src/server/management/context.ts`, next to `readRuntimePort`) gains + optional `loadCursorEffortTable?: (install: CursorInstall | undefined) => CursorEffortTable | null` + so the route test injects a fixture table without touching /Applications. +- Imports: `predictCursorEffort` replaces `cursorEffortFamily`; `loadCursorEffortTable` from + `../../integrations/cursor-effort-table`. + +### MODIFY `gui/src/pages/integrations/cursor-api.ts` + +Add `effortTable` and `family` to the TS interface only (rendering is wp4). No behavior change. + +### NEW `tests/fixtures/cursor-agent-exec-effort-table.min.js` + +The verbatim literal window from 3.18.25 (`const w={param:...}` through +`effortRequiresReasoningCapability:!0}];`, ~3.6 KB) with unrelated minified code before and +after, so the parser proves it scans rather than matches at offset 0. + +### NEW `tests/cursor-effort-table.test.ts` + +1. `parseCursorEffortTable(fixture)`: 16 families; `anthropic-opus-5` → ladder low..max, param + output_config.effort, default high, outputCap 128000; `gemini` → requiresReasoningCapability + true; `anthropic-haiku-4-5` → ladder [] and outputCap 32768; `bareGpt5` default medium. +2. `predictCursorEffort("anthropic/claude-opus-5", table)` → source bundle, family anthropic-opus-5; + `"anthropic/claude-fable-5-1"` and `"cursor/kimi-k3"` → ladder null, source bundle, family null; + `"gpt-5.4"` → bareGpt5 ladder; `"xai/grok-4.6@main"` → grok-4.6 (the @ strip). +3. Fallback activation (C-ACTIVATION-GROUNDING-01): `loadCursorEffortTable` with `stat` → null + returns null; with a bundle lacking the literal → null; with a malformed regex (`/[/u`) → null; + `predictCursorEffort(id, null)` → source static with the mirror ladder. +4. Cache: two loads with equal stat call `readText` once; a changed mtime re-reads. + +### MODIFY `tests/cursor-integration-status.test.ts` + +Existing route case passes `deps: { loadCursorEffortTable: () => null }` and asserts +`effortTable.source === "static"`; a new case injects the parsed fixture and asserts +`source === "bundle"`, `version === "3.18.25"`, `families === 16`, and +`models.find(m => m.id === "kimi/k3").family === null`. + +## Scope boundary + +IN: files above. OUT: GUI rendering (wp4), `/v1/models` row shape (wp2), any write into a +Cursor install. The bundle read is bounded (32 MiB), read-only, and never executes Cursor code. + +## Accept criteria + +- `bun run typecheck` 0; `bun test tests/cursor-effort-table.test.ts tests/cursor-integration-status.test.ts` 0. +- On this machine `curl /api/native-integrations/cursor` shows `effortTable.source: "bundle"`, + `version: "3.18.25"`, `families: 16`, and `anthropic/claude-fable-5-1` keeps `reasoning: null`. +- `tests/core-lab-boundary.test.ts` unaffected (no import from src/lab). + +## Bypass fields (PLAN-BYPASS-NAMED-01) + +Tier E3 (runtime read with fallback); surface: the status route; bypass: a build whose literal +shape changed falls back to static, and the GUI shows "static" so the drift is visible; residual +risk: a newer build that renamed a param; wording: "prediction", never "enforcement". diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md b/devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md new file mode 100644 index 0000000000..260ad12885 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md @@ -0,0 +1,416 @@ +# 020 — wp2: /v1/models hardening (max_output_tokens; threshold stays in pricing.overrides) + +Depends on: 010. Own PR against `dev`. + +Loop-spec: spec-satisfaction; trigger = rows omit `capabilities.max_output_tokens` although the +bundle reads it (Anthropic wire `max_tokens` when no family outputCap; tooltip "Max output"); +goal = advertise an authoritative output ceiling where opencodex has one; non-goals = inventing +limits, changing `supports_reasoning`; verifier = the focused list below + typecheck; stop = +green + exact-head CI. + +## Decision recorded at P (conflict between research lanes) + +Lane B proposed also emitting a top-level `long_context_threshold_tokens`. Lane D read the +parser (001 §"Extended-capability schema"): Cursor's row normaliser computes that field itself +from `cost.long_context.threshold_tokens` → `capabilities.cost.long_context.threshold_tokens` → +smallest `pricing.overrides[].min_prompt_tokens`, and the raw top-level key is never read +(bundle: `void 0!==i?{long_context_threshold_tokens:i}:{}` where `i` is derived from those +three). Emitting it would be dead data and a nested `cost.long_context` breaks the `mme` +numeric-record schema. **wp2 keeps `pricing.overrides` as the only threshold carrier and adds +no top-level key.** The test asserts its absence so nobody re-adds it. + +## Design (Lane B, folded; threshold item removed) + +## Findings + +- `modelCapabilityFields` currently emits `pricing.overrides` for long tiers but omits Cursor’s validated top-level `long_context_threshold_tokens` ([models-capabilities.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/models-capabilities.ts:97)). +- The generated tuple’s third column is `maxTokens`; `rowToMetadata` exposes it as `ModelMetadata.maxTokens`. It is the model output-token budget, not an input limit ([model-metadata.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/generated/model-metadata.ts:38), [generator](/Users/jun/.codex/worktrees/4ed0/opencodex/scripts/generate-model-metadata.ts:90)). +- `CatalogModel` has `contextWindow`, `maxInputTokens`, and `inputModalities`, but no output-token field ([parsing.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/parsing.ts:95)). +- Routed context/modalities arrive through provider configuration and live `/models` parsing; generated metadata also supplies them when jawcode rows are appended ([provider-fetch.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/provider-fetch.ts:682), [provider-fetch.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/provider-fetch.ts:1209), [provider-fetch.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/provider-fetch.ts:2364)). +- `supports_reasoning` is already honest: it is `true` only when the advertised ladder is non-empty. Generated metadata’s boolean `reasoning` flag is not consulted, and should remain unused here ([models-capabilities.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/models-capabilities.ts:118)). + +## Diff-level design + +### 1. Extend the Cursor capability projection + +[src/server/models-capabilities.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/models-capabilities.ts:57) + +Change the contracts to: + +```ts +export interface ModelCapabilityInput { + reasoningEfforts?: readonly string[]; + contextWindow?: number; + longContextWindow?: number; + maxOutputTokens?: number; + inputModalities?: readonly string[]; +} + +export interface ModelCapabilityFields { + api_types: readonly string[]; + capabilities: { + context_length?: number; + max_output_tokens?: number; + output_modalities: string[]; + input_modalities?: string[]; + supports_tool_use: true; + supports_streaming: true; + supports_reasoning: boolean; + supports_vision?: boolean; + reasoning_effort?: string[]; + }; + long_context_threshold_tokens?: number; + pricing?: { overrides: Array<{ min_prompt_tokens: number }> }; +} +``` + +The exact function signature remains: + +```ts +export function modelCapabilityFields( + input: ModelCapabilityInput, +): ModelCapabilityFields +``` + +Inside it, add: + +```ts +const maxOutputTokens = positiveInt(input.maxOutputTokens); +``` + +Then emit: + +```ts +capabilities: { + ...(hasLongTier + ? { context_length: longContextLength } + : contextLength !== undefined ? { context_length: contextLength } : {}), + ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}), + output_modalities: ["text"], + // existing fields unchanged +}, +...(hasLongTier + ? { + long_context_threshold_tokens: contextLength, + pricing: { overrides: [{ min_prompt_tokens: contextLength }] }, + } + : {}), +``` + +Do not add `cost.long_context`: the requested contract is the validated top-level threshold while retaining the existing pricing override. + +### 2. Expose native output limits from canonical metadata + +[src/codex/catalog/metadata.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/metadata.ts:266) + +Add beside the native context helpers: + +```ts +export function nativeOpenAiMaxOutputTokens(slug: string): number | undefined { + const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug); + return positiveInt(getModelMetadata("openai", sourceSlug)?.maxTokens); +} +``` + +This also gives `gpt-daybreak-blue-latest` Sol’s inherited 128k output limit. + +Export it through [src/codex/catalog.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog.ts:5): + +```ts +nativeOpenAiMaxOutputTokens, +``` + +Do not edit `src/generated/model-metadata.ts` or its generator; the required column already exists. + +### 3. Add output limits to `CatalogModel` + +[src/codex/catalog/parsing.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/parsing.ts:113) + +```ts +contextWindow?: number; +maxInputTokens?: number; +/** Model-scoped output-token ceiling; omitted when no authoritative value is known. */ +maxOutputTokens?: number; +``` + +### 4. Carry routed values through provider discovery + +[src/codex/catalog/provider-fetch.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/provider-fetch.ts:566) + +Include model-scoped output metadata in the gather fingerprint: + +```ts +maxOut: prov.modelMaxOutputTokens ?? null, +``` + +Add a resolver near the existing configured-limit helpers: + +```ts +function generatedMaxOutputTokens( + providerName: string, + id: string, +): number | undefined { + const metadataProvider = resolveMetadataProvider(providerName); + if (!metadataProvider) return undefined; + const metadata = getModelMetadata(metadataProvider, id) + ?? (shouldCaseFoldMetadataModelId(providerName) + ? getModelMetadataCaseInsensitive(metadataProvider, id) + : undefined); + return positiveSafeInteger(metadata?.maxTokens); +} + +function routedMaxOutputTokens( + providerName: string, + provider: OcxProviderConfig, + model: CatalogModel, +): number | undefined { + const discovered = positiveSafeInteger(model.maxOutputTokens); + const generated = generatedMaxOutputTokens(providerName, model.id); + const configured = positiveSafeInteger( + modelRecordValue(provider.modelMaxOutputTokens, model.id), + ); + const authoritative = discovered ?? generated; + if (configured === undefined) return authoritative; + return authoritative === undefined + ? configured + : Math.min(authoritative, configured); +} +``` + +Intentionally exclude `defaultMaxOutputTokens`: it is a request default, not a model-specific ceiling. + +In `applyProviderConfigHints`: + +```ts +const maxOutputTokens = routedMaxOutputTokens(name, prov, model); +``` + +and in `hinted`: + +```ts +...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), +``` + +In `catalogHintsFromModelsApiItem`, parse only explicit output-limit fields: + +```ts +const maxOutputTokens = positiveSafeInteger( + capabilityRecord?.max_output_tokens, + limits?.max_output_tokens, + metadata?.max_output_tokens, + item.max_output_tokens, +); +``` + +Return it alongside the existing limits: + +```ts +...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), +``` + +When `augmentRoutedModelsWithMetadata` constructs missing jawcode rows, add: + +```ts +...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 + ? { maxOutputTokens: meta.maxTokens } + : {}), +``` + +For trusted OpenAI API rows, call `routedMaxOutputTokens` using the existing live row as the discovered input and emit the result. Add `maxOutputTokens` to `normalizedOpenAiApiSignature` so metadata collisions remain observable. + +For custom-model replacement merging, conservatively take the minimum positive value from `base.maxOutputTokens` and `replaced?.maxOutputTokens`, exactly as `maxInputTokens` is currently merged. + +### 5. Preserve output limits through combos + +[src/codex/catalog/aggregation.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/aggregation.ts:122) + +A combo has a known output ceiling only when every member has one: + +```ts +const knownMaxOutputTokens = members + .map(member => member.maxOutputTokens) + .filter((value): value is number => typeof value === "number" && value > 0); +const maxOutputTokens = knownMaxOutputTokens.length === members.length + ? Math.min(...knownMaxOutputTokens) + : undefined; +``` + +Add to the returned row: + +```ts +...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), +``` + +In `provider-fetch.ts`, add `maxOutputTokens` to `ComboCatalogMemberFallback`, native synthetic members, native-alias fallback metadata, and `withFallbackMetadata`. A fallback may fill an unknown output limit, but must never replace a smaller discovered one. + +### 6. Wire the fields into `/v1/models` + +[src/server/index.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/index.ts:1357) + +Add `nativeOpenAiMaxOutputTokens` to the dynamic catalog import. + +Native call: + +```ts +...modelCapabilityFields({ + reasoningEfforts: nativeReasoningEfforts(metadataId), + ...nativeContextInput(metadataId), + maxOutputTokens: nativeOpenAiMaxOutputTokens(metadataId), + inputModalities: nativeInputModalities(metadataId), +}), +``` + +Routed call: + +```ts +...modelCapabilityFields({ + reasoningEfforts: m.reasoningEfforts, + contextWindow: m.contextWindow, + maxOutputTokens: m.maxOutputTokens, + inputModalities: m.inputModalities, +}), +``` + +No change to reasoning derivation: neither `cursorEffortFamily()` nor generated `metadata.reasoning` should affect `supports_reasoning`. + +## Exact test changes + +### `tests/cursor-local-models-schema.test.ts` + +Update `capabilityConfig()`: + +```ts +modelMaxOutputTokens: { k3: 64_000 }, +``` + +In `a larger opt-in window becomes context_length...`, add: + +```ts +expect(tiered.long_context_threshold_tokens).toBe(272000); +expect("long_context_threshold_tokens" in flat).toBe(false); +``` + +Add test: + +```ts +test("max output tokens are sanitized independently of reasoning", () => { + expect(modelCapabilityFields({ maxOutputTokens: 128000 }).capabilities.max_output_tokens) + .toBe(128000); + expect("max_output_tokens" in modelCapabilityFields({ maxOutputTokens: 0 }).capabilities) + .toBe(false); + expect(modelCapabilityFields({ maxOutputTokens: 1.9 }).capabilities.supports_reasoning) + .toBe(false); +}); +``` + +In `routed rows carry api_types...`: + +```ts +expect(k3Caps.max_output_tokens).toBe(64_000); +expect(solCaps.max_output_tokens).toBe(128_000); +expect(sol!.long_context_threshold_tokens).toBe(272_000); +expect("max_output_tokens" in plainCaps).toBe(false); +``` + +### `tests/provider-model-discovery-contract.test.ts` + +Extend `accepts only positive safe-integer token limits from live metadata`: + +```ts +expect(catalogHintsFromModelsApiItem("example", { + id: "valid-output", + capabilities: { max_output_tokens: 8192 }, +})).toEqual({ maxOutputTokens: 8192 }); + +expect(catalogHintsFromModelsApiItem("example", { + id: "invalid-output", + capabilities: { max_output_tokens: 0.5 }, +})).toEqual({}); +``` + +Also cover `Number.MAX_SAFE_INTEGER + 1`. + +### `tests/codex-catalog.test.ts` + +In `DeepSeek catalog sync appends V4 rows missing from /v1/models`, assert: + +```ts +expect(models.find(model => model.id === "deepseek-v4-flash")?.maxOutputTokens) + .toBe(384_000); +``` + +Add: + +```ts +test("combo output ceiling is the smallest known member ceiling and stays unknown if any member is unknown", () => { + const known = deriveComboCatalogModel("known-output", normalizedCombo(), [ + { provider: "a", id: "m1", contextWindow: 128_000, maxOutputTokens: 64_000 }, + { provider: "b", id: "m2", contextWindow: 128_000, maxOutputTokens: 32_000 }, + ]); + expect(known?.maxOutputTokens).toBe(32_000); + + const partial = deriveComboCatalogModel("partial-output", normalizedCombo(), [ + { provider: "a", id: "m1", contextWindow: 128_000, maxOutputTokens: 64_000 }, + { provider: "b", id: "m2", contextWindow: 128_000 }, + ]); + expect(partial).not.toHaveProperty("maxOutputTokens"); +}); +``` + +### `tests/grok-models-effort-list.test.ts` + +Keep all current Grok assertions. In `models with an empty tier list advertise no effort fields`, add: + +```ts +const capabilities = plain!.capabilities as Record; +expect(capabilities.supports_reasoning).toBe(false); +expect("reasoning_effort" in capabilities).toBe(false); +``` + +This pins the independent Grok/Cursor representations to the same ladder truth. + +### `tests/server-combo-failover-e2e.test.ts` + +In `ordinary /v1/models restores a non-OpenAI selector...`, add: + +```ts +modelMaxOutputTokens: { "deepseek-chat": 64_000 }, +``` + +Extend the response-row type with: + +```ts +capabilities?: { max_output_tokens?: number }; +``` + +Assert both the combo alias and restored routed row advertise `64_000`. Existing `toMatchObject` row-literal assertions remain valid because they intentionally match subsets. + +## Worker verification + +```bash +bun test tests/cursor-local-models-schema.test.ts +bun test tests/provider-model-discovery-contract.test.ts +bun test tests/codex-catalog.test.ts +bun test tests/grok-models-effort-list.test.ts +bun test tests/server-combo-failover-e2e.test.ts +bun run typecheck +bun run test:changed +``` + +Do not run `bun run test` or bare `bun test`. + +## RISKS + +- `modelMaxOutputTokens` is currently used as an adapter fallback, not a runtime-enforced hard ceiling. Treating a model-scoped value as an advertised ceiling is conservative when it lowers the generated/live value, but `defaultMaxOutputTokens` must remain excluded. +- Generated metadata can become stale; live explicit capability data should therefore win, with configured values allowed only to narrow. +- Combo propagation must require every member to be known. Taking the minimum of only known members would overstate a route whose unknown target may support less. +- Cursor Private Inference behavior remains statically established, not end-to-end verified against this endpoint. + +## OPEN QUESTIONS + +- Non-blocking policy choice: should user-supplied `modelMaxOutputTokens` be considered authoritative enough to advertise? Recommendation: yes for the model-scoped map, no for the provider-wide default. +- Should a future phase emit `cost.long_context.threshold_tokens` as well? Recommendation: no in wp2; top-level threshold plus the retained pricing override satisfies the stated schema without inventing cost data. +- A controlled Cursor Private Inference E2E should still verify that the new fields actually render Context/output controls in build 3.18.25. + + + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md b/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md new file mode 100644 index 0000000000..1d54b9b448 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md @@ -0,0 +1,416 @@ +# 030 — wp3: opt-in effort-variant rows for table-less models + +Depends on: 010 (wp1 resolver `predictCursorEffort`; "table-less" = `ladder === null` from the +bundle table, falling back to `cursorEffortFamily(id) === null`), 020 (row shape). Own PR. + +Loop-spec: spec-satisfaction; trigger = fable/kimi/qwen rows render no Reasoning control in +Cursor and no gateway field can add one (000); goal = with `cursorEffortRows: true` the picker +lists `--` rows that route to the base model with that effort; non-goals = any change +when the flag is off (byte-identical list), rows for models Cursor already renders; verifier = +`bun test tests/cursor-effort-rows.test.ts tests/cursor-local-models-schema.test.ts tests/cursor-integration-status.test.ts` ++ typecheck; stop = green + exact-head CI. Grammar decision: `--` (evidence below); +NEEDS_HUMAN condition from the goal (ambiguity) is NOT triggered. + +Design produced by a sol/high research lane on 2026-09-02 (read-only, no files changed), folded +verbatim below. Amendments made at P of the wp3 cycle: (a) "table-less" must consult +`predictCursorEffort(id, table).ladder === null` once wp1 has landed, with `cursorEffortFamily` +as the static fallback, so the projection follows the installed bundle; (b) exact known full +model ids take precedence over the synthetic grammar (open question 1 → yes). + +--- + +No files were modified. The untracked `devlog/_plan/260902_cursor_bundle_effort_table/` appeared concurrently and was left untouched. No tests were run. + +## Recommendation + +Use `--`. + +Evidence: + +- `@` is stripped by Cursor’s matcher and already appears in Codex account-selector values ([models-capabilities.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/models-capabilities.ts:45), [config.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/types/config.ts:681)). +- `:` is already a family separator in `modelRecordValue()` and is common in real model tags ([reasoning-effort.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/reasoning-effort.ts:115)). +- Single `-` collides with ordinary IDs such as `gpt-5.1-codex-max` and Cursor’s own effort suffixes. +- `--` has no routing/catalog semantics today. `routedSlug()` preserves hyphens while only encoding inner slashes ([slug-codec.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/providers/slug-codec.ts:24)). + +Enabling the feature should explicitly reserve terminal `--(none|minimal|low|medium|high|xhigh|max|ultra)` for synthetic rows. The parser must only activate when the flag is exactly `true` and the base is table-less. + +## Diff-level design + +### 1. Configuration contract + +[src/types/config.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/types/config.ts:366) + +Before: + +```ts +defaultModelAliases?: boolean; +``` + +After: + +```ts +defaultModelAliases?: boolean; +/** + * Opt-in Cursor Private Inference compatibility rows. When true, `/v1/models` + * adds `--` selectors for reasoning-capable model ids absent + * from Cursor's built-in effort table. Omitted/false preserves discovery output. + */ +cursorEffortRows?: boolean; +``` + +[src/config.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/config.ts:1046) + +Before: + +```ts +defaultProvider: z.string().min(1).default("openai"), +defaultModelAliases: z.boolean().optional(), +``` + +After: + +```ts +defaultProvider: z.string().min(1).default("openai"), +defaultModelAliases: z.boolean().optional(), +// Malformed hand edits disable this opt-in projection without rejecting providers. +cursorEffortRows: z.boolean().optional().catch(false), +``` + +### 2. Single grammar owner + +Create `src/server/effort-row.ts`: + +```ts +import { + canonicalizeReasoningEfforts, + isDeclaredReasoningEffort, +} from "../reasoning-effort"; +import type { OcxConfig } from "../types"; +import { cursorEffortFamily } from "./models-capabilities"; + +const EFFORT_ROW_SEPARATOR = "--"; + +export interface ParsedEffortRowId { + baseId: string; + effort: string; +} + +export function effortRowId(baseId: string, effort: string): string { + return `${baseId}${EFFORT_ROW_SEPARATOR}${effort}`; +} + +export function parseEffortRowId( + id: string, + config: Pick, +): ParsedEffortRowId | null { + if (config.cursorEffortRows !== true) return null; + + const separator = id.lastIndexOf(EFFORT_ROW_SEPARATOR); + if (separator <= 0) return null; + + const baseId = id.slice(0, separator); + const effort = id.slice(separator + EFFORT_ROW_SEPARATOR.length); + if (!isDeclaredReasoningEffort(effort)) return null; + + // Cursor-table models retain Cursor's native control and never gain variants. + if (cursorEffortFamily(baseId) !== null) return null; + return { baseId, effort }; +} + +export function expandCursorEffortRow( + row: T, + efforts: readonly string[] | undefined, + config: Pick, +): T[] { + if (config.cursorEffortRows !== true || cursorEffortFamily(row.id) !== null) { + return [row]; + } + + const supported = canonicalizeReasoningEfforts( + (efforts ?? []).filter(isDeclaredReasoningEffort), + ); + return [ + row, + ...supported.map(effort => ({ ...row, id: effortRowId(row.id, effort) })), + ]; +} +``` + +Cloning the complete base row and changing only `id` preserves `api_types`, `capabilities`, modalities, context fields, `long_context_threshold_tokens`, `pricing.overrides`, and `cost.long_context` without reconstructing Cursor’s validated schema. + +### 3. `/v1/models` expansion + +[src/server/index.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/index.ts:1511) + +Import `expandCursorEffortRow`. Refactor each row site to pass the same ladder already used by `modelCapabilityFields()`. + +Before: + +```ts +const data = [ + ...visibleNatives.map(id => nativeModelRow(id)), + ...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)), + ...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + // ... + return { id: publicId, /* complete row */ }; + })), +]; +``` + +After: + +```ts +const routedRows = await Promise.all( + uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + // Existing publicId/provider/alias calculation remains unchanged. + const row = { id: publicId, /* existing complete row, unchanged */ }; + return expandCursorEffortRow(row, m.reasoningEfforts, config); + }), +); + +const data = [ + ...visibleNatives.flatMap(id => + expandCursorEffortRow( + nativeModelRow(id), + nativeReasoningEfforts(id), + config, + ) + ), + ...visibleAccountNatives.flatMap(({ id, metadataId }) => + expandCursorEffortRow( + nativeModelRow(id, metadataId), + nativeReasoningEfforts(metadataId), + config, + ) + ), + ...routedRows.flat(), +]; +``` + +When omitted/false, `expandCursorEffortRow()` returns the original row only, preserving order and serialized bytes. + +Do not modify `routeModel()`, `routeConcreteModel()`, `knownModelIdsForProvider()`, `routedSlug()`, or alias resolution. Synthetic IDs are removed before those parsers run. + +### 4. Responses inbound + +[src/server/responses/core.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/responses/core.ts:2750) + +Immediately after `parseRequest(body)`, before logging, shadow interception, or `routeModel()`: + +```ts +parsed = parseRequest(body); +const effortRow = parseEffortRowId(parsed.modelId, config); +if (effortRow) { + parsed.modelId = effortRow.baseId; + parsed.options.reasoning = effortRow.effort; + + const raw = parsed._rawBody as Record; + raw.model = effortRow.baseId; + raw.reasoning = { + ...(isRec(raw.reasoning) ? raw.reasoning : {}), + effort: effortRow.effort, + }; +} +``` + +The row effort intentionally overrides any contradictory body effort: selecting the row is the user’s effort choice. + +Writing both `parsed.options.reasoning` and `_rawBody.reasoning.effort` follows the existing dual-shape contract used by `applyEffortCap()` and `nativeEffortClamp()` ([effort-policy.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/effort-policy.ts:159), [core.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/responses/core.ts:2140)). + +### 5. Chat Completions inbound + +Modify [src/server/chat-completions.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/chat-completions.ts:89), not `chat-native.ts`. + +Before routing: + +```ts +const requestedModel = chatBody.model as string; +``` + +After: + +```ts +const requestedModel = chatBody.model as string; +const effortRow = parseEffortRowId(requestedModel, config); +if (effortRow) chatBody.model = effortRow.baseId; +``` + +After `chatCompletionsToResponsesBody(chatBody)`: + +```ts +if (effortRow) { + internalBody.reasoning = { + ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}), + effort: effortRow.effort, + }; +} +``` + +Prevent only synthetic-row requests from taking the native-chat shortcut: + +```ts +if (!effortRow && isNativeChatRouteEligible(route, chatBody)) { + chatNativeRoute = route; +} +``` + +This is necessary because [chat-native.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/chat-native.ts:120) sends directly and does not enter the Responses choke point where the existing cap/clamp runs. Ordinary Chat requests remain unchanged. + +Keep `requestedModel` as the original synthetic ID so Chat response-model echoing remains stable. + +### 6. Anthropic Messages inbound + +[src/server/claude-messages.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/claude-messages.ts:605) + +Resolve after `ocx-route` extraction, but before native passthrough and before translation: + +```ts +let effortRow: ParsedEffortRowId | null = null; +let requestedModel = ""; + +if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { + requestedModel = anthropicBody.model; + effortRow = parseEffortRowId(requestedModel, config); + if (effortRow) anthropicBody.model = effortRow.baseId; +} +``` + +Change native passthrough: + +```ts +if ( + !effortRow + && isRec(anthropicBody) + && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model) +) { + return await anthropicNativePassthrough(/* unchanged arguments */); +} +``` + +After `anthropicToResponsesTranslation()`: + +```ts +internalBody = translation.body; +if (effortRow) { + internalBody.reasoning = { + ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}), + effort: effortRow.effort, + }; +} +``` + +Do not inject through `output_config.effort`: the current Claude translator excludes `none`, while OpenCodex ladders may legitimately publish it. Directly injecting the internal Responses shape supports every declared effort and still reaches the existing cap/clamp. + +Use the preserved `requestedModel` for Anthropic response conversion. + +### 7. Cursor status projection + +[src/server/management/cursor-integration-routes.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/management/cursor-integration-routes.ts:23) + +Extend each model: + +```ts +{ + id: string; + reasoning: string[] | null; + tableLess: boolean; + effortRows: string[]; + context: { defaultWindow: number; longWindow: number } | null; +} +``` + +Projection: + +```ts +const family = cursorEffortFamily(id); +return { + id, + reasoning: family, + tableLess: family === null, + effortRows: config.cursorEffortRows === true && family === null + ? canonicalizeReasoningEfforts(reasoningEfforts) + .map(effort => effortRowId(id, effort)) + : [], + context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, +}; +``` + +Thus “table-less” has one owner: `cursorEffortFamily(id) === null`. Do not duplicate Cursor’s regex in management or GUI code. + +Mirror the fields in [gui/src/pages/integrations/cursor-api.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/gui/src/pages/integrations/cursor-api.ts:12). Rendering can remain a later lane; the status contract is sufficient for the GUI to distinguish native controls from variant rows. + +Update existing exact-object assertions in `tests/cursor-integration-status.test.ts` with `tableLess` and `effortRows`. + +### 8. Documentation + +Update: + +- `docs-site/src/content/docs/reference/configuration/providers.md`: add `cursorEffortRows?: boolean`, default off, grammar, and reservation warning. +- `docs-site/src/content/docs/guides/cursor-private-inference.md`: replace “set a provider default” as the sole workaround with the opt-in variant-row workflow and examples: + - `anthropic/claude-fable-5-1--high` + - `cursor/kimi-k3--max` +- State that existing table-matched IDs receive no variants and that refresh/restart may be required because Cursor caches `/models`. + +## Tests + +Create `tests/cursor-effort-rows.test.ts` with these exact cases: + +1. `parseEffortRowId enables only the -- grammar behind cursorEffortRows` + - Off/omitted returns `null`. + - `@high`, `:high`, and `-high` return `null`. + - Invalid/empty suffixes return `null`. + - `kimi/k3--high` resolves to `{ baseId: "kimi/k3", effort: "high" }`. + +2. `Cursor-table model ids never become effort rows` + - `anthropic/claude-opus-5--high` and `gpt-5.6-sol--high` return `null`. + +3. `cursorEffortRows false is byte-identical to an omitted flag` + - Compare raw `/v1/models` response text for otherwise identical configs. + +4. `raw model discovery clones one complete row per supported effort only for table-less ids` + - Fable/Kimi get one row per exact ladder member. + - GPT-5.6/Opus do not. + - Strip only `id` and assert every variant’s remaining object deeply equals its base row. + +5. `Responses effort rows route the base model and pass through the existing cap` + - Request `...--max` with child marker and `subagentEffortCap: "high"`. + - Captured upstream body contains base model and `reasoning.effort: "high"`. + +6. `Chat effort rows use Responses normalization instead of the native-chat shortcut` + - OpenAI-chat provider; `...--max` plus child cap. + - Captured upstream Chat body has base model and capped `reasoning_effort`. + +7. `Messages effort rows resolve after route directives and before native passthrough` + - Assert translated upstream request uses the base model and chosen/capped effort. + - Include a `none` row to prove translation does not depend on `output_config`. + +8. `Cursor integration status marks table-less bases and reports generated row ids` + - Fable/Kimi: `tableLess: true`, populated `effortRows` when enabled. + - Opus/GPT: `tableLess: false`, empty `effortRows`. + +Focused verification: + +```bash +bun test tests/cursor-effort-rows.test.ts +bun test tests/cursor-local-models-schema.test.ts +bun test tests/cursor-integration-status.test.ts +bun run typecheck +``` + +Do not run the repository-wide suite in this lane. + +## RISKS + +- `--` is not globally forbidden in upstream model IDs. Enabling the flag reserves a terminal `--` suffix; document this. A later hardening can give exact known full model IDs precedence over synthetic parsing. +- Combo/policy aliases can be table-less. Their generated effort must continue through normal combo/policy target selection; never resolve a concrete target inside `effort-row.ts`. +- Chat native and Anthropic native passthrough bypass the shared cap/clamp. Synthetic rows must force the existing Responses replay path as specified. +- Mutating only the parsed effort or only the raw body creates adapter-dependent behavior. Both representations are mandatory on direct Responses requests. +- Cursor caches model discovery by Base URL, so correct server behavior may not appear until refresh/restart. + +## OPEN QUESTIONS + +- Should an exact real model ID ending in `--high` always beat the synthetic grammar, even after `cursorEffortRows` is enabled? Recommended: yes, once an exact-known-ID check can cover static, live, custom, combo, policy, and alias rows consistently. +- Should the dashboard merely report `effortRows`, or render them inline under each table-less base? This lane recommends the API contract now and leaves presentation to the UI/UX lane. +- Should synthetic rows be added for table-less aliases of otherwise table-matched models? Recommended: yes—the matcher sees the public ID, so an alias such as `opus` genuinely has no Cursor control. + + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md b/devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md new file mode 100644 index 0000000000..47079cd593 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md @@ -0,0 +1,101 @@ +# 040 — wp4: Integrations > Cursor shows ladder provenance and a table-less hint + +Depends on: 010 (status fields `effortTable`, `family`), 030 (`tableLess`, `effortRows`). Own PR; +title/description mention "gui", so the PR must carry a screenshot (enforce-target). + +Loop-spec: spec-satisfaction; trigger = "—" in the Reasoning column explains nothing; goal = the +user sees WHY a row has no control (Cursor's table, which build) and WHAT to do (turn on +`cursorEffortRows`, or set a provider default); non-goals = new pages, other locales than en/ko; +verifier = `bun run lint:gui && bun run build:gui` + a rendered screenshot; stop = green + +exact-head CI. + +## File change map + +### MODIFY `gui/src/pages/integrations/cursor-api.ts` + +```ts +export interface CursorIntegrationStatus { + // ...existing + effortTable: { source: "bundle" | "static"; version: string | null; families: number | null }; + models: Array<{ + id: string; + reasoning: string[] | null; + family: string | null; + tableLess: boolean; + effortRows: string[]; + context: { defaultWindow: number; longWindow: number } | null; + }>; +} +``` + +### MODIFY `gui/src/pages/integrations/CursorIntegrationPage.tsx` + +1. Under the "What Cursor will show" heading (line ~141), replace the static hint paragraph with a + provenance line: +```tsx +

+ {status.effortTable.source === "bundle" + ? t("integrations.cursor.ladderFromBundle", { version: status.effortTable.version ?? "?" }) + : t("integrations.cursor.ladderFromStatic")} +

+``` +2. Reasoning cell (line ~155): when `model.reasoning` is null render +```tsx + + + {model.effortRows.length > 0 + ? {t("integrations.cursor.effortRowsOn", { n: model.effortRows.length })} + : {t("integrations.cursor.effortRowsOff")}} + +``` + otherwise the existing `join(" · ")`. +3. After the table, one paragraph (only when any row is table-less): +```tsx +{status.models.some(m => m.tableLess) && ( +

{t("integrations.cursor.tableLessHint")}

+)} +``` + +### MODIFY `gui/src/styles-integrations.css` + +`.cursor-effort-rows { margin-left: .5rem; font-size: .85em; }` — nothing else. + +### MODIFY `gui/src/i18n/en.ts` (after `integrations.cursor.modelsHint`) + +```ts +"integrations.cursor.ladderFromBundle": "Reasoning ladders read from the installed Cursor Private Inference {version} bundle. Cursor decides them; opencodex only reports its table.", +"integrations.cursor.ladderFromStatic": "Reasoning ladders are a static mirror of Cursor 3.18.25 (no Private Inference install found to read). Context lists the default and the opt-in window.", +"integrations.cursor.noControlTitle": "This id is not in Cursor's built-in effort table, so Cursor shows no Reasoning control.", +"integrations.cursor.effortRowsOn": "{n} effort rows published", +"integrations.cursor.effortRowsOff": "no effort rows", +"integrations.cursor.tableLessHint": "Rows marked — get no Reasoning control in Cursor. Turn on cursorEffortRows to publish one picker entry per effort (id--effort), or set modelDefaultReasoningEfforts on the provider for a fixed default.", +``` + +### MODIFY `gui/src/i18n/ko.ts` (same keys) + +```ts +"integrations.cursor.ladderFromBundle": "Reasoning 사다리는 설치된 Cursor Private Inference {version} 번들에서 읽었습니다. 사다리는 Cursor가 정하고 opencodex는 그 표를 보여줄 뿐입니다.", +"integrations.cursor.ladderFromStatic": "Reasoning 사다리는 Cursor 3.18.25의 정적 미러입니다(읽을 Private Inference 설치를 찾지 못함). Context는 기본 창과 옵트인 창입니다.", +"integrations.cursor.noControlTitle": "이 id는 Cursor 내장 effort 표에 없어서 Cursor가 Reasoning 컨트롤을 보여주지 않습니다.", +"integrations.cursor.effortRowsOn": "effort 행 {n}개 게시됨", +"integrations.cursor.effortRowsOff": "effort 행 없음", +"integrations.cursor.tableLessHint": "—로 표시된 행은 Cursor에서 Reasoning 컨트롤이 없습니다. cursorEffortRows를 켜면 effort마다 picker 항목(id--effort)을 하나씩 게시하고, 고정 기본값은 provider의 modelDefaultReasoningEfforts로 정합니다.", +``` + +Other locales are untouched; `t()` falls back to en for missing keys (verify in +`gui/src/i18n/index.ts` at P of this cycle; if there is no fallback, add the en strings to the +other locale files verbatim). + +### MODIFY `tests/cursor-integration-status.test.ts` + +No new server behaviour; keep. GUI evidence is the screenshot (C-RENDER-GROUNDING-01): run +`bun run build:gui`, start the proxy from this checkout on a temp `OPENCODEX_HOME`, open +`/#/integrations/cursor` in agbrowse at 1280x720, capture with a table-less row visible and +attach to the PR and to `041_wp4_screenshot.png` in this unit. + +## Accept criteria + +- `bun run lint:gui` 0; `bun run build:gui` 0; typecheck 0. +- Screenshot shows the provenance line reading "3.18.25 bundle" on this machine and the hint + paragraph under the table. +- With `cursorEffortRows` off nothing else on the page changes. diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md b/devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md new file mode 100644 index 0000000000..abed83b024 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md @@ -0,0 +1,200 @@ +# 050 — wp5: canonical Claude-id normalizer for the Cursor adapter + +Depends on: 000 (independent of wp1-wp4; own PR against `dev`). Design produced by a sol/high +research lane on 2026-09-02 and folded here; the lane read the current tree and changed no files. + +Loop-spec: spec-satisfaction; trigger = Fable 5.1 seeded three times because Cursor spells Claude +ids both Anthropic-style (`claude-fable-5-1`) and version-first (`claude-5.1-fable`); goal = one +capability base per Claude model, any live spelling resolves to it, wire ids are composed back in +the spelling the live roster exposed; non-goals = picker id churn for saved configs, non-Claude +families; verifier = the focused test list at the bottom + typecheck; stop = green + exact-head CI. + +## File change map + +### NEW `src/adapters/cursor/claude-id.ts` + +```ts +export type CursorClaudeSpelling = "anthropic" | "version-first"; + +export interface NormalizedCursorClaudeId { + /** The sole key used by CURSOR_CAPABILITIES and pricing metadata. */ + canonicalBaseId: string; + /** Exact input stem, preserving `5-1` versus `5.1` for wire round-trips. */ + sourceBaseId: string; + spelling: CursorClaudeSpelling; + thinking: boolean; + fast: boolean; + level?: string; +} + +const CLAUDE_LEVELS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "extra-high"]); + +/** Existing picker bases whose canonical key stays version-first (saved configs). */ +const VERSION_FIRST_CANONICAL_BASES = new Set([ + "claude-4.5-haiku", "claude-4.5-opus", "claude-4.6-opus", "claude-4.5-sonnet", "claude-4.6-sonnet", "claude-4-sonnet", +]); + +function parseClaudeBase(raw: string): { canonicalBaseId: string; sourceBaseId: string; spelling: CursorClaudeSpelling } | undefined { + const anthropic = /^claude-(fable|haiku|opus|sonnet)-(\d+(?:[.-]\d+)*)$/.exec(raw); + if (anthropic) { + const family = anthropic[1]!; + const version = anthropic[2]!.replaceAll(".", "-"); + const versionFirst = `claude-${version.replaceAll("-", ".")}-${family}`; + return { + canonicalBaseId: VERSION_FIRST_CANONICAL_BASES.has(versionFirst) ? versionFirst : `claude-${family}-${version}`, + sourceBaseId: raw, + spelling: "anthropic", + }; + } + const versionFirst = /^claude-(\d+(?:\.\d+)*)-(fable|haiku|opus|sonnet)$/.exec(raw); + if (!versionFirst) return undefined; + const sourceBaseId = `claude-${versionFirst[1]!}-${versionFirst[2]!}`; + return { + canonicalBaseId: VERSION_FIRST_CANONICAL_BASES.has(sourceBaseId) ? sourceBaseId : `claude-${versionFirst[2]!}-${versionFirst[1]!.replaceAll(".", "-")}`, + sourceBaseId, + spelling: "version-first", + }; +} + +export function normalizeCursorClaudeId(raw: string): NormalizedCursorClaudeId | undefined { + const id = raw.trim().toLowerCase(); + const patterns: ReadonlyArray { base: string; thinking: boolean; fast: boolean; level?: string }]> = [ + [/^(.*)-thinking-([a-z-]+)-fast$/, m => ({ base: m[1]!, thinking: true, fast: true, level: m[2]! })], + [/^(.*)-([a-z-]+)-thinking-fast$/, m => ({ base: m[1]!, thinking: true, fast: true, level: m[2]! })], + [/^(.*)-thinking-([a-z-]+)$/, m => ({ base: m[1]!, thinking: true, fast: false, level: m[2]! })], + [/^(.*)-([a-z-]+)-thinking$/, m => ({ base: m[1]!, thinking: true, fast: false, level: m[2]! })], + [/^(.*)-([a-z-]+)-fast$/, m => ({ base: m[1]!, thinking: false, fast: true, level: m[2]! })], + [/^(.*)-thinking-fast$/, m => ({ base: m[1]!, thinking: true, fast: true })], + [/^(.*)-thinking$/, m => ({ base: m[1]!, thinking: true, fast: false })], + [/^(.*)-fast$/, m => ({ base: m[1]!, thinking: false, fast: true })], + ]; + for (const [pattern, dims] of patterns) { + const match = pattern.exec(id); + if (!match) continue; + const parsed = dims(match); + if (parsed.level && !CLAUDE_LEVELS.has(parsed.level)) continue; + const base = parseClaudeBase(parsed.base); + if (base) return { ...base, ...parsed, sourceBaseId: base.sourceBaseId }; + } + const base = parseClaudeBase(id); + return base ? { ...base, thinking: false, fast: false } : undefined; +} + +export function composeCursorClaudeWireId( + identity: Pick, + options: { thinking: boolean; fast: boolean; effort?: string; bareThinking?: boolean }, +): string { + const { sourceBaseId: base, spelling } = identity; + const fast = options.fast ? "-fast" : ""; + if (!options.thinking) return options.effort ? `${base}-${options.effort}${fast}` : `${base}${fast}`; + if (options.bareThinking || !options.effort) return `${base}-thinking${fast}`; + return spelling === "version-first" ? `${base}-${options.effort}-thinking${fast}` : `${base}-thinking-${options.effort}${fast}`; +} +``` + +`sourceBaseId` is required: `claude-fable-5-1` and `claude-fable-5.1` are both "anthropic" spelling +but differ on the wire. + +### MODIFY `src/adapters/cursor/catalog.ts` + +1. Replace the three Fable 5.1 entries (lines ~123-155) with one `"claude-fable-5-1"` entry + (displayName "Claude Fable 5.1", CONTEXT_1M, defaultVariant thinking, regular/thinking FULL, order T). +2. At the top of `parseCursorVariantId` (before the exact-identity lookup, line ~383): +```ts + const claude = normalizeCursorClaudeId(id); + if (claude && CURSOR_CAPABILITIES[claude.canonicalBaseId]) { + const explicitVariant = claude.thinking || claude.fast || claude.level !== undefined; + return { + baseId: claude.canonicalBaseId, + kind: explicitVariant + ? claude.thinking ? (claude.fast ? "thinkingFast" : "thinking") : claude.fast ? "fast" : "regular" + : defaultKindFor(claude.canonicalBaseId), + ...(claude.level ? { level: claude.level } : {}), + ultra: false, + known: true, + }; + } +``` + `REAL_1M_WIRE_IDS` (`claude-4-sonnet-1m`) must still be checked first; the normalizer does not + recognise `-1m`, so ordering: REAL_1M check → normalizer → existing chain. +3. Beside `liveCursorMaxModeBases` (line ~606): +```ts +type CursorLiveClaudeWireIdentity = Pick; +let liveCursorClaudeWireIdentities: ReadonlyMap = new Map(); + +export function recordLiveCursorClaudeModels(liveIds: readonly string[]): void { + const next = new Map(); + for (const rawId of liveIds) { + const n = normalizeCursorClaudeId(rawId.startsWith("cursor-") ? rawId.slice(7) : rawId); + if (!n || !CURSOR_CAPABILITIES[n.canonicalBaseId]) continue; + if (!next.has(n.canonicalBaseId)) next.set(n.canonicalBaseId, { sourceBaseId: n.sourceBaseId, spelling: n.spelling }); + } + liveCursorClaudeWireIdentities = next; // replaced, never merged: a renamed model must not keep a stale spelling +} +export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap { return liveCursorClaudeWireIdentities; } +export function resetLiveCursorClaudeWireIdentitiesForTests(): void { liveCursorClaudeWireIdentities = new Map(); } +``` +4. `composeWireId(baseId, kind, effort, claudeIdentity?)` (line ~545): when `claudeIdentity` is + given, return `composeCursorClaudeWireId(claudeIdentity, { thinking, fast, effort, bareThinking: spec.order === "bare" })`; + the non-Claude body is unchanged. +5. `resolveCursorSelection`: `const claudeIdentity = liveCursorClaudeWireIdentities.get(parsed.baseId) ?? (requestedClaude ? { sourceBaseId, spelling } : undefined)` + where `requestedClaude = normalizeCursorClaudeId(pickedId)`. Precedence: live roster spelling → + the spelling the saved config used → capability base. + +### MODIFY `src/codex/catalog/provider-fetch.ts` (line ~1424) + +`recordLiveCursorClaudeModels(liveResult.models);` immediately inside `if (liveResult.ok)`, before +`filterCursorConfiguredModelsByLiveDiscovery`. Not cleared on failure (stale-cache parity). + +### MODIFY `src/adapters/cursor/effort-map.ts` + +- Keep only `claude-fable-5-1` and `claude-fable-5-1-thinking`; delete the `5.1`/`5.1-fable` rows + and their thinking rows (lines ~28-30, ~63-65) and the matching `CURSOR_THINKING_FAMILIES` rows. +- Add `cursorEffortLookupId(modelId)`: normalise via `normalizeCursorClaudeId`, return + `canonicalBaseId + (thinking ? "-thinking" : "") + (fast ? "-fast" : "")`, else the input. Use it in + `cursorEffortSuffix`, `cursorModelEffortLadder`, `cursorModelHasEffortTiers`, `cursorWireModelIdWithEffort`. +- `cursorWireModelIdWithEffort` composes Claude ids through `composeCursorClaudeWireId` with the + input's own spelling, so a version-first saved alias keeps effort-then-thinking order. + +### MODIFY `src/adapters/cursor/discovery.ts` + +No structural change: once `parseCursorVariantId` canonicalises, the base comparison in +`isCursorModelAvailableForAccount` (line ~86) matches across spellings. `CURSOR_STATIC_MODELS` +now derives one Fable 5.1 row. + +### MODIFY `src/usage/expected-prices.ts` + +Keep only the `cursor / claude-fable-5-1` overlay row (delete lines 108-109). In +`findExpectedPriceOverlay`, after the exact lookup misses and only when `provider === "cursor"`, +retry with `normalizeCursorClaudeId(modelId)?.canonicalBaseId`. + +## Tests + +NEW `tests/cursor-claude-id.test.ts`: normalizes the three Fable 5.1 spellings to one base; extracts +thinking/fast/effort from both marker orders; preserves `sourceBaseId` for dotted round-trips; does +not absorb `claude-4-sonnet-1m` or unknown products; composes both orders correctly. + +MODIFY `tests/cursor-catalog.test.ts`: all three spellings parse to `baseId: "claude-fable-5-1"`; +legacy aliases stay routable with no live roster; live roster spelling overrides; dotted spelling +preserved exactly; Fable 5.1 contributes one umbrella row. +MODIFY `tests/cursor-effort-suffix.test.ts`: keep the three wire cases (renamed group), add the +shared-ladder case, keep ERROR_BAD_MODEL_NAME order cases. +MODIFY `tests/cursor-discovery.test.ts`: one canonical-row assertion replaces the three-seed loop; +cross-spelling live ids admit the row; sibling Claude versions do not cross-activate. +MODIFY `tests/cursor-umbrella-rows.test.ts`: count comment; aliases are not rows; live spelling map +resets atomically. +MODIFY `tests/usage-cost.test.ts`: three-spelling resolution loop stays; overlay membership has only +`cursor/claude-fable-5-1`; overlay count 61 → 59. + +Verifier: `bun test tests/cursor-claude-id.test.ts tests/cursor-catalog.test.ts tests/cursor-effort-suffix.test.ts tests/cursor-discovery.test.ts tests/cursor-umbrella-rows.test.ts tests/usage-cost.test.ts` +then `bun run typecheck` and `bun run test:changed`. + +## Risks / open decisions (carried into this cycle's P) + +- The module-global spelling map follows the Max-Mode precedent; if one process ever routes two + Cursor accounts with different rosters it must be keyed by provider. Not the case today + (one `cursor` provider entry); record as accepted. +- When a roster exposes both spellings, first-seen wins (roster order). Acceptable: both are + callable by construction. +- Pricing fallback is bounded to `provider === "cursor"` and recognised Claude ids; other providers + keep exact-only lookup. diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md b/devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md new file mode 100644 index 0000000000..65dd8f7726 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md @@ -0,0 +1,132 @@ +# 060 — wp6: guide — identify the build, isolate it, wire the gateway, read the table + +Depends on: 010, 030, 040 (documents what they shipped). Own PR against `dev`. Docs only. + +Loop-spec: spec-satisfaction; trigger = the guide names the table but not how to tell which +build you have, where the table lives, or the env-var path; goal = a reader with the app already +installed can identify it, keep it apart from regular Cursor, connect opencodex, and understand +every "—"; non-goals = hosting/linking a download (`rg 'downloads.cursor.com|cursor-local/'` +stays 0), other locales; verifier = `bun run privacy:scan`, `cd docs-site && bun run build`, +the rg check; stop = green + exact-head CI. + +## MODIFY `docs-site/src/content/docs/guides/cursor-private-inference.md` + +### 1. New section after "Before you start": "Identify the installed build" + +```md +## Identify the installed build + +Both builds are named "Cursor" in the Dock and share the bundle id, so check `product.json`: + +| Platform | product.json | +|---|---| +| macOS | `/Applications/Cursor Private Inference.app/Contents/Resources/app/product.json` | +| Windows | `%LOCALAPPDATA%\\Programs\\cursor-private-inference\\resources\\app\\product.json` | +| Linux | `/resources/app/product.json` (an AppImage must be extracted first) | + +`nameLong` is `"Cursor Private Inference"` for the local-agent build and `"Cursor"` for the +regular one; `version` is the build (3.18.25 at the time of writing). The dashboard's +Integrations > Cursor card runs the same check and lists what it found. Local mode is switched +on inside the workbench bundle, not in `product.json`, so there is no flag to flip: if +`nameLong` says regular Cursor, that install cannot reach a loopback gateway. + +opencodex does not distribute this build and Cursor does not document it. If you do not have it, +this page does not apply; use the [`ocx-cursor`](https://www.npmjs.com/package/ocx-cursor) bridge +with a public HTTPS endpoint instead. +``` + +### 2. "Configure the gateway": add the environment path and precedence (after the Settings steps) + +```md +### Through the environment + +`CURSOR_LOCAL_AGENT_BASE_URL` and `CURSOR_LOCAL_AGENT_API_KEY` are read when no gateway has +been saved in Settings. They must be in the login environment (launchctl setenv on macOS, the +user environment on Windows, the session on Linux), not only in an interactive shell rc file, +because a GUI-launched app does not read your shell. + +Precedence, highest first: per-model credentials → the saved Settings gateway → +`CURSOR_LOCAL_AGENT_*` → `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` (compatibility +fallback). Clear the saved gateway before switching through the environment. + +`CURSOR_LOCAL_AGENT_HEADERS` is optional: newline-separated `Header-Name: value` lines +(`User-Agent` and unresolved `{...}` placeholders are rejected; `{gitOrgRepo}` and +`{gitBranch}` are expanded). +``` + +Fix the existing sentence that describes `CURSOR_LOCAL_AGENT_HEADERS` as `key=value` pairs if +present (001 §"Configuration inputs"). + +### 3. "Models and reasoning effort": replace the intro and the closing paragraph + +Before: +```md +2. The model id, after stripping everything up to the last `/`, must match Cursor's own + effort table. Cursor decides the ladder, not opencodex: +``` +After: +```md +2. The model id, after stripping everything up to the last `/` and any `@…` suffix, must + match Cursor's own effort table. That table is compiled into the app at + `/…/app/extensions/cursor-agent-exec/dist/main.js`; opencodex reads it from the + detected install so the dashboard prediction follows a Cursor update (the card says which + build it read, or "static mirror" when none was found). Cursor decides the ladder, not + opencodex, and no `/v1/models` field can add a model to that table: +``` + +Before (closing paragraph): +```md +So `anthropic/claude-opus-5` works, and opencodex's `max`/`ultra` tiers for GPT-5.6 are not +reachable from this picker. For a model with no control, set a default in opencodex instead +(`modelDefaultReasoningEfforts` on the provider); that default applies when Cursor sends no +effort. +``` +After: +```md +So `anthropic/claude-opus-5` works, and opencodex's `max`/`ultra` tiers for GPT-5.6 are not +reachable from this picker. + +### Models with no control + +`anthropic/claude-fable-5-1`, `cursor/kimi-k3`, and anything else outside the table get no +Reasoning control, and Cursor logs one line per such id when the gateway advertises +`supports_reasoning`: "Local provider advertises reasoning support for a model with no +hardcoded Bottlerocket effort family". Two ways to still choose an effort: + +- **Effort rows** (`cursorEffortRows: true` in opencodex config, default off): the gateway + publishes one picker entry per effort for table-less models, `anthropic/claude-fable-5-1--high`, + `cursor/kimi-k3--max`, and routes each to the base model with that effort. Models Cursor + already renders get no extra rows. Press Refresh model list after turning it on. +- **A fixed default** (`modelDefaultReasoningEfforts` on the provider): applies when Cursor + sends no effort. +``` + +### 4. "Max is two different things": append the wire caveat (from 001) + +```md +With a `/v1` Base URL Cursor sends turns to `/v1/responses`, so GPT/Grok/Gemini effort travels +as `reasoning.effort`; Claude's `output_config.effort` is Messages-only and is dropped on that +wire, which is why a Claude row that does show a control still runs at the provider default. +A Base URL ending in `/messages` reverses it: Claude effort is sent, OpenAI-family effort is +dropped. One gateway entry cannot serve both families; effort rows (above) side-step this +because opencodex applies the effort itself. +``` + +### 5. "Verify": two table rows + +```md +| models listed but no Reasoning control | opencodex older than v2.41, or the id is not in Cursor's table (dashboard shows —); turn on `cursorEffortRows` or set a provider default | +| a schema change is not picked up | Cursor caches `/models` per Base URL string with no expiry; Refresh model list re-reads, otherwise restart or temporarily change the URL spelling (`localhost` vs `127.0.0.1`) | +``` + +### 6. Configuration reference + +`docs-site/src/content/docs/reference/configuration/providers.md` (or the top-level config +reference, verified at P): one entry for `cursorEffortRows` (boolean, default false, grammar +`--`, reserved suffix warning). + +## Accept criteria + +- `rg -n 'downloads.cursor.com|cursor-local/' docs-site/src/content/docs/guides/cursor-private-inference.md` → 0 hits. +- `bun run privacy:scan` 0; `cd docs-site && bun run build` 0. +- Every claim about the bundle cites 001 (bundle path, precedence, header format, cache). From 8af13df3349f35103784425dd7b9af922999578a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 21:46:41 +0900 Subject: [PATCH 2/4] feat(cursor): read Cursor Private Inference effort table from the installed bundle Predict the Reasoning ladder from the table compiled into the detected Cursor Private Inference install, with the static 3.18.25 mirror as fallback, and expose the provenance on the integration status route. --- gui/src/pages/integrations/cursor-api.ts | 2 + src/integrations/cursor-effort-table.ts | 129 ++++++++++++++++++ src/server/management/context.ts | 3 + .../management/cursor-integration-routes.ts | 14 +- src/server/models-capabilities.ts | 43 +++++- tests/cursor-effort-table.test.ts | 104 ++++++++++++++ tests/cursor-integration-status.test.ts | 44 +++++- .../cursor-agent-exec-effort-table.min.js | 3 + 8 files changed, 330 insertions(+), 12 deletions(-) create mode 100644 src/integrations/cursor-effort-table.ts create mode 100644 tests/cursor-effort-table.test.ts create mode 100644 tests/fixtures/cursor-agent-exec-effort-table.min.js diff --git a/gui/src/pages/integrations/cursor-api.ts b/gui/src/pages/integrations/cursor-api.ts index f3f23a3538..18e077f24c 100644 --- a/gui/src/pages/integrations/cursor-api.ts +++ b/gui/src/pages/integrations/cursor-api.ts @@ -12,6 +12,7 @@ export interface CursorSeen { export interface CursorModelExpectation { id: string; reasoning: string[] | null; + family: string | null; context: { defaultWindow: number; longWindow: number } | null; } @@ -20,6 +21,7 @@ export interface CursorIntegrationStatus { regularCursor: { installed: boolean; path: string | null }; gateway: { baseUrl: string; apiKeyMode: "credential" | "placeholder"; placeholder: string }; lastSeen: CursorSeen | null; + effortTable: { source: "bundle" | "static"; version: string | null; families: number | null }; models: CursorModelExpectation[]; guideUrl: string; } diff --git a/src/integrations/cursor-effort-table.ts b/src/integrations/cursor-effort-table.ts new file mode 100644 index 0000000000..fb78c93a8b --- /dev/null +++ b/src/integrations/cursor-effort-table.ts @@ -0,0 +1,129 @@ +/** + * Cursor's local-agent effort table, read from the installed bundle. + * + * Cursor Private Inference decides which model rows get a Reasoning control from a table + * compiled into extensions/cursor-agent-exec/dist/main.js, not from the gateway's + * reasoning_effort list (devlog 260902_cursor_bundle_effort_table/000). Reading that table + * from the install the dashboard already detects lets the prediction follow a Cursor update + * instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size); + * any parse failure yields null so the caller falls back to the static mirror. + */ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { CursorInstall } from "./cursor-detect"; + +export interface CursorEffortFamily { + id: string; + pattern: RegExp; + /** [] = family matched but Cursor shows no control. */ + ladder: readonly string[]; + param?: "reasoning_effort" | "output_config.effort"; + defaultValue?: string; + outputCap?: number; + requiresReasoningCapability: boolean; +} + +export interface CursorEffortTable { + families: readonly CursorEffortFamily[]; + /** The bare gpt-5 / gpt-5.x rule that runs when no family matched. */ + bareGpt5: { pattern: RegExp; ladder: readonly string[]; defaultValue: string } | null; + version: string | null; + bundlePath: string; +} + +const BUNDLE_MAX_BYTES = 32 * 1024 * 1024; + +/** Bundle path under the install root cursor-detect reports. */ +export function cursorAgentBundlePath(install: Pick, platform: string = process.platform): string { + const tail = ["extensions", "cursor-agent-exec", "dist", "main.js"]; + return platform === "darwin" + ? join(install.path, "Contents", "Resources", "app", ...tail) + : join(install.path, "resources", "app", ...tail); +} + +/** + * Parse the family table out of the minified source: + * const w={param:"reasoning_effort",values:[...],defaultValue:"medium"}; + * ...const T={...},k={...},S={...},b=[{id:"...",matches:e=>/.../u.test(e),effort:k,outputCap:128e3},...]; + * Identifier names are minifier-assigned, so binding is by structure: every + * ={param:"...",values:[...],defaultValue:"..."} is an effort constant, and a family's + * effort: is either such an identifier or an inline object. + */ +export function parseCursorEffortTable(source: string): Omit | null { + const constants = new Map(); + const constRe = /(?:const |,)([A-Za-z_$][\w$]*)=\{param:"(reasoning_effort|output_config\.effort)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/gu; + for (const m of source.matchAll(constRe)) { + constants.set(m[1]!, { param: m[2]!, values: splitStrings(m[3]!), defaultValue: m[4]! }); + } + const tableStart = source.indexOf('=[{id:"anthropic-'); + if (tableStart === -1) return null; + const tableEnd = source.indexOf("];", tableStart); + if (tableEnd === -1) return null; + const body = source.slice(tableStart + 2, tableEnd + 1); + const entryRe = /\{id:"([^"]+)",matches:e=>\/((?:\\\/|[^/])+)\/([a-z]*)\.test\(e\)((?:,(?:effort:(?:[A-Za-z_$][\w$]*|\{[^}]*\})|outputCap:[\de.]+|effortRequiresReasoningCapability:!0))*)\}/gu; + const families: CursorEffortFamily[] = []; + for (const m of body.matchAll(entryRe)) { + let pattern: RegExp; + try { pattern = new RegExp(m[2]!, m[3]!); } catch { return null; } + const tail = m[4]!; + const effortRef = /effort:([A-Za-z_$][\w$]*)(?:,|$)/u.exec(tail)?.[1]; + const inline = /effort:\{param:"([^"]+)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/u.exec(tail); + const effort = inline + ? { param: inline[1]!, values: splitStrings(inline[2]!), defaultValue: inline[3]! } + : effortRef ? constants.get(effortRef) : undefined; + if (effortRef && !inline && !effort) return null; // unknown constant: structure changed + const cap = /outputCap:([\de.]+)/u.exec(tail)?.[1]; + families.push({ + id: m[1]!, + pattern, + ladder: effort?.values ?? [], + ...(effort ? { param: effort.param as CursorEffortFamily["param"], defaultValue: effort.defaultValue } : {}), + ...(cap ? { outputCap: Number(cap) } : {}), + requiresReasoningCapability: tail.includes("effortRequiresReasoningCapability:!0"), + }); + } + if (families.length === 0) return null; + const bareRe = /if\(\/(\^gpt-5[^/]+)\/([a-z]*)\.test\(t\)\)return ([A-Za-z_$][\w$]*)\}/u.exec(source); + const bareConst = bareRe ? constants.get(bareRe[3]!) : undefined; + const bareGpt5 = bareRe && bareConst + ? { pattern: new RegExp(bareRe[1]!, bareRe[2]!), ladder: bareConst.values, defaultValue: bareConst.defaultValue } + : null; + return { families, bareGpt5 }; +} + +function splitStrings(list: string): string[] { + return [...list.matchAll(/"([^"]+)"/gu)].map(m => m[1]!); +} + +export interface CursorEffortTableDeps { + platform: string; + stat(path: string): { mtimeMs: number; size: number } | null; + readText(path: string): string | null; +} + +export function realCursorEffortTableDeps(): CursorEffortTableDeps { + return { + platform: process.platform, + stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } }, + readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } }, + }; +} + +let cache: { key: string; table: CursorEffortTable | null } | null = null; + +/** Table from the Private Inference install, else null (caller falls back to the static mirror). */ +export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null { + if (!install) return null; + const bundlePath = cursorAgentBundlePath(install, deps.platform); + const st = deps.stat(bundlePath); + if (!st || st.size > BUNDLE_MAX_BYTES) return null; + const key = `${bundlePath}|${st.mtimeMs}|${st.size}`; + if (cache?.key === key) return cache.table; + const text = deps.readText(bundlePath); + const parsed = text ? parseCursorEffortTable(text) : null; + const table = parsed ? { ...parsed, version: install.version, bundlePath } : null; + cache = { key, table }; + return table; +} + +export function resetCursorEffortTableCacheForTests(): void { cache = null; } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 49aedf4990..13c922e575 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -11,6 +11,8 @@ import type { injectGrokConfig } from "../../grok/inject"; import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; import type { probeClaudeDesktopPolicy } from "../../claude/desktop-policy"; import type { RuntimePortState } from "../../config/process-state"; +import type { CursorInstall } from "../../integrations/cursor-detect"; +import type { CursorEffortTable } from "../../integrations/cursor-effort-table"; import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types"; import type { performCodexRestart, @@ -58,6 +60,7 @@ export interface ManagementApiDeps { * on the developer's real runtime state file. */ readRuntimePort?: (pid: number) => RuntimePortState | null; + loadCursorEffortTable?: (install: CursorInstall | undefined) => CursorEffortTable | null; clearThreadAccountMap?: () => void; clearProviderQuotaCache?: () => void; primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise | void; diff --git a/src/server/management/cursor-integration-routes.ts b/src/server/management/cursor-integration-routes.ts index c7a263f804..03cef0a541 100644 --- a/src/server/management/cursor-integration-routes.ts +++ b/src/server/management/cursor-integration-routes.ts @@ -12,9 +12,10 @@ import { readRuntimePort } from "../../config/process-state"; import { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextTier, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } from "../../codex/catalog"; import { cursorLastSeen, type CursorSeen } from "../../integrations/cursor-seen"; import { detectCursorInstalls, type CursorInstall } from "../../integrations/cursor-detect"; +import { loadCursorEffortTable } from "../../integrations/cursor-effort-table"; import { configuredApiAuthToken, isApiAuthRequired, jsonResponse } from "../auth-cors"; import { fetchAllModels } from "../management-api"; -import { cursorEffortFamily } from "../models-capabilities"; +import { predictCursorEffort } from "../models-capabilities"; import type { ManagementContext } from "./context"; export const CURSOR_GATEWAY_PLACEHOLDER_KEY = "opencodex-loopback"; @@ -25,9 +26,11 @@ export interface CursorIntegrationStatus { regularCursor: { installed: boolean; path: string | null }; gateway: { baseUrl: string; apiKeyMode: "credential" | "placeholder"; placeholder: string }; lastSeen: CursorSeen | null; + effortTable: { source: "bundle" | "static"; version: string | null; families: number | null }; models: Array<{ id: string; reasoning: string[] | null; + family: string | null; context: { defaultWindow: number; longWindow: number } | null; }>; guideUrl: string; @@ -62,14 +65,20 @@ export async function buildCursorIntegrationStatus( ...visibleNativeSlugs(config), ...uniqueCatalogModelsForRawPublicList(goModels).map(model => model.alias ?? `${model.provider}/${model.id}`), ]; + const table = (deps.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference); const models = ids.map(id => { const tier = nativeOpenAiContextTier(id, limits); + const predicted = predictCursorEffort(id, table); return { id, - reasoning: cursorEffortFamily(id), + reasoning: predicted.ladder, + family: predicted.family, context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, }; }); + const effortTable = table + ? { source: "bundle" as const, version: table.version, families: table.families.length } + : { source: "static" as const, version: null, families: null }; return { privateInference: { @@ -84,6 +93,7 @@ export async function buildCursorIntegrationStatus( placeholder: CURSOR_GATEWAY_PLACEHOLDER_KEY, }, lastSeen: cursorLastSeen(), + effortTable, models, guideUrl: CURSOR_GUIDE_URL, }; diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index c7d6aa3c82..c3086f408e 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -1,3 +1,5 @@ +import type { CursorEffortTable } from "../integrations/cursor-effort-table"; + /** * Extended capability advertisement for the OpenAI-shape `GET /v1/models` list. * @@ -23,7 +25,8 @@ export const OPENAI_FAMILY_API_TYPES: ReadonlySet = new Set(["chat_compl * The reasoning-effort ladder Cursor's local-agent runtime attaches to a model, keyed by the * model id after its last `/`. Cursor decides this from its own table rather than from the * gateway's `reasoning_effort` list, so the dashboard can only PREDICT it; the values here - * mirror that table (read from the 3.18.25 bundle) and carry no Cursor behavior of their own. + * form the fallback mirror of the 3.18.25 table; the live table is read by + * `src/integrations/cursor-effort-table.ts`. These values carry no Cursor behavior of their own. * Null means Cursor shows no Reasoning control for the id. Distinct from * `src/adapters/cursor/effort-map.ts`, which maps opencodex efforts onto Cursor's *backend* * tiers for the outbound provider; this is what Cursor's *local* picker renders. @@ -43,15 +46,47 @@ const CURSOR_EFFORT_FAMILIES: ReadonlyArray<{ test: RegExp; ladder: readonly str ]; export function cursorEffortFamily(modelId: string): string[] | null { + const id = normalizeCursorPickerId(modelId); + for (const family of CURSOR_EFFORT_FAMILIES) { + if (family.test.test(id)) return family.ladder.length > 0 ? [...family.ladder] : null; + } + return null; +} + +export interface CursorEffortPrediction { + ladder: string[] | null; + source: "bundle" | "static"; + /** Bundle family id when one matched (e.g. "anthropic-opus-5"); null otherwise. */ + family: string | null; + outputCap?: number; +} + +export function normalizeCursorPickerId(modelId: string): string { let id = modelId.trim().toLowerCase(); const slash = id.lastIndexOf("/"); if (slash !== -1) id = id.slice(slash + 1); const at = id.indexOf("@"); if (at !== -1) id = id.slice(0, at); - for (const family of CURSOR_EFFORT_FAMILIES) { - if (family.test.test(id)) return family.ladder.length > 0 ? [...family.ladder] : null; + return id; +} + +export function predictCursorEffort(modelId: string, table: CursorEffortTable | null): CursorEffortPrediction { + const id = normalizeCursorPickerId(modelId); + if (table) { + for (const family of table.families) { + if (family.pattern.test(id)) { + return { + ladder: family.ladder.length > 0 ? [...family.ladder] : null, + source: "bundle", + family: family.id, + ...(family.outputCap !== undefined ? { outputCap: family.outputCap } : {}), + }; + } + } + if (table.bareGpt5?.pattern.test(id)) return { ladder: [...table.bareGpt5.ladder], source: "bundle", family: "gpt-5" }; + return { ladder: null, source: "bundle", family: null }; } - return null; + return { ladder: cursorEffortFamily(modelId), source: "static", family: null }; } export interface ModelCapabilityInput { diff --git a/tests/cursor-effort-table.test.ts b/tests/cursor-effort-table.test.ts new file mode 100644 index 0000000000..5c73cf0788 --- /dev/null +++ b/tests/cursor-effort-table.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + loadCursorEffortTable, + parseCursorEffortTable, + resetCursorEffortTableCacheForTests, + type CursorEffortTable, + type CursorEffortTableDeps, +} from "../src/integrations/cursor-effort-table"; +import type { CursorInstall } from "../src/integrations/cursor-detect"; +import { predictCursorEffort } from "../src/server/models-capabilities"; + +const FIXTURE = readFileSync(join(import.meta.dir, "fixtures/cursor-agent-exec-effort-table.min.js"), "utf8"); +const INSTALL: CursorInstall = { build: "private-inference", path: "/Applications/Cursor Private Inference.app", version: "3.18.25" }; + +function parsedFixtureTable(): CursorEffortTable { + const parsed = parseCursorEffortTable(FIXTURE); + if (!parsed) throw new Error("Cursor effort fixture did not parse"); + return { ...parsed, version: INSTALL.version, bundlePath: "/fixture/main.js" }; +} + +describe("Cursor installed-bundle effort table", () => { + beforeEach(() => resetCursorEffortTableCacheForTests()); + + test("parses the 3.18.25 literal window from unrelated minified source", () => { + const table = parsedFixtureTable(); + expect(table.families).toHaveLength(16); + expect(table.families.find(family => family.id === "anthropic-opus-5")).toMatchObject({ + ladder: ["low", "medium", "high", "xhigh", "max"], + param: "output_config.effort", + defaultValue: "high", + outputCap: 128000, + }); + expect(table.families.find(family => family.id === "gemini")?.requiresReasoningCapability).toBe(true); + expect(table.families.find(family => family.id === "anthropic-haiku-4-5")).toMatchObject({ + ladder: [], + outputCap: 32768, + }); + expect(table.bareGpt5?.defaultValue).toBe("medium"); + }); + + test("predicts by normalized picker id and preserves unmatched bundle rows as null", () => { + const table = parsedFixtureTable(); + expect(predictCursorEffort("anthropic/claude-opus-5", table)).toMatchObject({ + ladder: ["low", "medium", "high", "xhigh", "max"], + source: "bundle", + family: "anthropic-opus-5", + }); + expect(predictCursorEffort("anthropic/claude-fable-5-1", table)).toEqual({ ladder: null, source: "bundle", family: null }); + expect(predictCursorEffort("cursor/kimi-k3", table)).toEqual({ ladder: null, source: "bundle", family: null }); + expect(predictCursorEffort("gpt-5.4", table)).toEqual({ + ladder: ["low", "medium", "high", "xhigh"], + source: "bundle", + family: "gpt-5", + }); + expect(predictCursorEffort("xai/grok-4.6@main", table)).toMatchObject({ + ladder: ["minimal", "low", "medium", "high", "xhigh"], + source: "bundle", + family: "grok-4.6", + }); + }); + + test("activates the static fallback for missing installs, missing literals, and malformed regexes", () => { + const missingStat: CursorEffortTableDeps = { + platform: "darwin", + stat: () => null, + readText: () => { throw new Error("readText must not run without a stat"); }, + }; + expect(loadCursorEffortTable(INSTALL, missingStat)).toBeNull(); + + const loadSource = (source: string, mtimeMs: number) => loadCursorEffortTable(INSTALL, { + platform: "darwin", + stat: () => ({ mtimeMs, size: source.length }), + readText: () => source, + }); + expect(loadSource("function unrelated(){}", 1)).toBeNull(); + expect(loadSource(FIXTURE.replace("/^claude-opus-5$/u", "/[/u"), 2)).toBeNull(); + expect(predictCursorEffort("anthropic/claude-opus-5", null)).toEqual({ + ladder: ["low", "medium", "high", "xhigh", "max"], + source: "static", + family: null, + }); + }); + + test("caches by bundle path, mtime, and size and re-reads after mtime changes", () => { + let mtimeMs = 1; + let reads = 0; + const deps: CursorEffortTableDeps = { + platform: "darwin", + stat: () => ({ mtimeMs, size: FIXTURE.length }), + readText: () => { + reads += 1; + return FIXTURE; + }, + }; + expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16); + expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16); + expect(reads).toBe(1); + mtimeMs = 2; + expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16); + expect(reads).toBe(2); + }); +}); diff --git a/tests/cursor-integration-status.test.ts b/tests/cursor-integration-status.test.ts index a0955d9f54..93ad11a7e4 100644 --- a/tests/cursor-integration-status.test.ts +++ b/tests/cursor-integration-status.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { mkdtempSync, readFileSync} from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; @@ -8,6 +8,7 @@ import { seedCodexModelEntitlementsForTests, } from "../src/codex/model-entitlements"; import { cursorProductJsonCandidates, detectCursorInstalls, type CursorDetectDeps } from "../src/integrations/cursor-detect"; +import { parseCursorEffortTable, type CursorEffortTable } from "../src/integrations/cursor-effort-table"; import { cursorLastSeen, recordCursorSeen, resetCursorSeenForTests } from "../src/integrations/cursor-seen"; import { cursorEffortFamily } from "../src/server/models-capabilities"; import { startServer } from "../src/server"; @@ -93,6 +94,14 @@ describe("cursorEffortFamily", () => { const previousHome = process.env.OPENCODEX_HOME; let testHome = ""; +const CURSOR_EFFORT_FIXTURE = readFileSync(join(import.meta.dir, "fixtures/cursor-agent-exec-effort-table.min.js"), "utf8"); +const STATIC_CURSOR_EFFORT_DEPS = { managementApi: { loadCursorEffortTable: () => null } }; + +function fixtureEffortTable(): CursorEffortTable { + const parsed = parseCursorEffortTable(CURSOR_EFFORT_FIXTURE); + if (!parsed) throw new Error("Cursor effort fixture did not parse"); + return { ...parsed, version: "3.18.25", bundlePath: "/fixture/main.js" }; +} function statusConfig(): OcxConfig { return { @@ -134,7 +143,7 @@ describe("GET /api/native-integrations/cursor", () => { test("reports gateway values, model expectations, and a last-seen Cursor request", async () => { seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); saveConfig(statusConfig()); - const server = startServer(0); + const server = startServer(0, STATIC_CURSOR_EFFORT_DEPS); try { const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); const headers = { "x-opencodex-api-key": adminToken }; @@ -144,7 +153,8 @@ describe("GET /api/native-integrations/cursor", () => { const first = await before.json() as { gateway: { baseUrl: string; apiKeyMode: string; placeholder: string }; lastSeen: unknown; - models: Array<{ id: string; reasoning: string[] | null; context: { defaultWindow: number; longWindow: number } | null }>; + effortTable: { source: string }; + models: Array<{ id: string; reasoning: string[] | null; family: string | null; context: { defaultWindow: number; longWindow: number } | null }>; privateInference: { installed: boolean }; guideUrl: string; }; @@ -152,14 +162,16 @@ describe("GET /api/native-integrations/cursor", () => { expect(first.gateway.apiKeyMode).toBe("placeholder"); expect(first.gateway.placeholder).toBe("opencodex-loopback"); expect(first.lastSeen).toBeNull(); + expect(first.effortTable.source).toBe("static"); expect(typeof first.privateInference.installed).toBe("boolean"); expect(first.guideUrl).toContain("cursor-private-inference"); const k3 = first.models.find(model => model.id === "kimi/k3"); - expect(k3).toEqual({ id: "kimi/k3", reasoning: null, context: null }); + expect(k3).toEqual({ id: "kimi/k3", reasoning: null, family: null, context: null }); const sol = first.models.find(model => model.id === "gpt-5.6-sol"); expect(sol).toEqual({ id: "gpt-5.6-sol", reasoning: ["low", "medium", "high", "xhigh"], + family: null, context: { defaultWindow: 272000, longWindow: 922000 }, }); @@ -178,7 +190,7 @@ describe("GET /api/native-integrations/cursor", () => { const config = statusConfig(); config.apiKeys = [{ id: "k1", name: "test", key: "ocx_test_key_value_1234567890", createdAt: new Date(0).toISOString() }]; saveConfig(config); - const server = startServer(0); + const server = startServer(0, STATIC_CURSOR_EFFORT_DEPS); try { const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); const res = await fetch(new URL("/api/native-integrations/cursor", server.url), { headers: { "x-opencodex-api-key": adminToken } }); @@ -192,7 +204,7 @@ describe("GET /api/native-integrations/cursor", () => { test("a disabled model leaves the prediction the same way it leaves /v1/models", async () => { seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); saveConfig({ ...statusConfig(), disabledModels: ["kimi/k3"] }); - const server = startServer(0); + const server = startServer(0, STATIC_CURSOR_EFFORT_DEPS); try { const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); const status = await fetch(new URL("/api/native-integrations/cursor", server.url), { headers: { "x-opencodex-api-key": adminToken } }); @@ -205,4 +217,24 @@ describe("GET /api/native-integrations/cursor", () => { await server.stop(true); } }); + + test("reports bundle effort-table provenance and unmatched model families through the server deps seam", async () => { + saveConfig(statusConfig()); + const server = startServer(0, { managementApi: { loadCursorEffortTable: () => fixtureEffortTable() } }); + try { + const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); + const status = await fetch(new URL("/api/native-integrations/cursor", server.url), { + headers: { "x-opencodex-api-key": adminToken }, + }); + expect(status.status).toBe(200); + const body = await status.json() as { + effortTable: { source: string; version: string | null; families: number | null }; + models: Array<{ id: string; family: string | null }>; + }; + expect(body.effortTable).toEqual({ source: "bundle", version: "3.18.25", families: 16 }); + expect(body.models.find(model => model.id === "kimi/k3")?.family).toBeNull(); + } finally { + await server.stop(true); + } + }); }); diff --git a/tests/fixtures/cursor-agent-exec-effort-table.min.js b/tests/fixtures/cursor-agent-exec-effort-table.min.js new file mode 100644 index 0000000000..46585ca56b --- /dev/null +++ b/tests/fixtures/cursor-agent-exec-effort-table.min.js @@ -0,0 +1,3 @@ +function zz(e){return e+1} +const w={param:"reasoning_effort",values:["low","medium","high","xhigh"],defaultValue:"medium"};function _(e){const t=function(e){let t=e.trim().toLowerCase();const n=t.lastIndexOf("/");-1!==n&&(t=t.slice(n+1));const r=t.indexOf("@");return-1!==r&&(t=t.slice(0,r)),t}(e);if(/^gpt-5(?:\.\d+)?$/u.test(t))return w}const T={param:"output_config.effort",values:["low","medium","high","max"],defaultValue:"high"},k={param:"output_config.effort",values:["low","medium","high","xhigh","max"],defaultValue:"high"},S={param:"reasoning_effort",values:["minimal","low","medium","high","xhigh"],defaultValue:"high"},b=[{id:"anthropic-opus-5",matches:e=>/^claude-opus-5$/u.test(e),effort:k,outputCap:128e3},{id:"anthropic-opus-4-7-4-8",matches:e=>/^claude-opus-4[-.](?:7|8)$/u.test(e),effort:k,outputCap:128e3},{id:"anthropic-opus-4-6",matches:e=>/^claude-opus-4[-.]6$/u.test(e),effort:T,outputCap:128e3},{id:"anthropic-opus-4-5",matches:e=>/^claude-opus-4[-.]5$/u.test(e),effort:T,outputCap:64e3},{id:"anthropic-sonnet-4-6",matches:e=>/^claude-sonnet-4[-.]6$/u.test(e),effort:T,outputCap:64e3},{id:"anthropic-sonnet-5",matches:e=>/^claude-sonnet-5$/u.test(e),effort:k,outputCap:128e3},{id:"anthropic-sonnet-no-effort",matches:e=>/^claude-sonnet-4(?:[-.]5)?$/u.test(e),outputCap:64e3},{id:"anthropic-haiku-4-5",matches:e=>/^claude-haiku-4[-.]5$/u.test(e),outputCap:32768},{id:"grok-4.3",matches:e=>/^grok-4[.-]3$/u.test(e),effort:S},{id:"grok-4.5",matches:e=>/^grok-4[.-]5(?:-(?:batch|build|nocomp))?$/u.test(e),effort:S},{id:"grok-4.6",matches:e=>/^grok-4[.-]6(?:-(?:batch|build|nocomp))?$/u.test(e),effort:S},{id:"grok-build-latest",matches:e=>/^grok-build-latest$/u.test(e),effort:S},{id:"grok-reasoning-no-effort",matches:e=>/^grok-(?:composer(?:-2\.5(?:-fast)?)?|4\.20-0309-reasoning|4\.20-multi-agent-0309|420-clanker-reasoning)$/u.test(e)},{id:"gpt-5.6",matches:e=>/^gpt-5[.-]6-(?:luna|sol|terra)$/u.test(e),effort:{param:"reasoning_effort",values:["low","medium","high","xhigh"],defaultValue:"medium"}},{id:"gemini-no-effort",matches:e=>/^gemini-3\.[1-9].*flash-lite/u.test(e)},{id:"gemini",matches:e=>/^gemini-/u.test(e),effort:{param:"reasoning_effort",values:["minimal","low","medium","high"],defaultValue:"medium"},effortRequiresReasoningCapability:!0}]; +const yy={ok:!0};yy.ok&&zz(1) From 965b3002da3d55d6e2d530899604e30d7396f3d3 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 22:16:57 +0900 Subject: [PATCH 3/4] fix(cursor): reject partial effort tables and guard the bare gpt-5 regex; gate gemini on supports_reasoning --- src/integrations/cursor-effort-table.ts | 23 +++++++++++++++---- .../management/cursor-integration-routes.ts | 17 +++++++++----- src/server/models-capabilities.ts | 19 +++++++++++++-- tests/cursor-effort-table.test.ts | 13 +++++++++++ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/integrations/cursor-effort-table.ts b/src/integrations/cursor-effort-table.ts index fb78c93a8b..6a33b33e41 100644 --- a/src/integrations/cursor-effort-table.ts +++ b/src/integrations/cursor-effort-table.ts @@ -23,10 +23,16 @@ export interface CursorEffortFamily { requiresReasoningCapability: boolean; } +export interface CursorBareGpt5Rule { + pattern: RegExp; + ladder: readonly string[]; + defaultValue: string; +} + export interface CursorEffortTable { families: readonly CursorEffortFamily[]; /** The bare gpt-5 / gpt-5.x rule that runs when no family matched. */ - bareGpt5: { pattern: RegExp; ladder: readonly string[]; defaultValue: string } | null; + bareGpt5: CursorBareGpt5Rule | null; version: string | null; bundlePath: string; } @@ -62,6 +68,10 @@ export function parseCursorEffortTable(source: string): Omit\/((?:\\\/|[^/])+)\/([a-z]*)\.test\(e\)((?:,(?:effort:(?:[A-Za-z_$][\w$]*|\{[^}]*\})|outputCap:[\de.]+|effortRequiresReasoningCapability:!0))*)\}/gu; const families: CursorEffortFamily[] = []; + // Every "{id:" opener in the window must be consumed by entryRe. A build that adds a + // property to one family would otherwise drop that family silently and the caller would + // report a bundle-sourced "no control" for it instead of falling back to the mirror. + const openers = body.split('{id:"').length - 1; for (const m of body.matchAll(entryRe)) { let pattern: RegExp; try { pattern = new RegExp(m[2]!, m[3]!); } catch { return null; } @@ -82,12 +92,15 @@ export function parseCursorEffortTable(source: string): Omit model.alias ?? `${model.provider}/${model.id}`), + // supportsReasoning mirrors what the /v1/models row advertises (a non-empty ladder); the + // gemini family withholds its control when it is false. + const ids: Array<{ id: string; supportsReasoning: boolean }> = [ + ...visibleNativeSlugs(config).map(id => ({ id, supportsReasoning: nativeReasoningEfforts(id).length > 0 })), + ...uniqueCatalogModelsForRawPublicList(goModels).map(model => ({ + id: model.alias ?? `${model.provider}/${model.id}`, + supportsReasoning: (model.reasoningEfforts ?? []).length > 0, + })), ]; const table = (deps.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference); - const models = ids.map(id => { + const models = ids.map(({ id, supportsReasoning }) => { const tier = nativeOpenAiContextTier(id, limits); - const predicted = predictCursorEffort(id, table); + const predicted = predictCursorEffort(id, table, supportsReasoning); return { id, reasoning: predicted.ladder, diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index c3086f408e..370b41cee4 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -70,11 +70,24 @@ export function normalizeCursorPickerId(modelId: string): string { return id; } -export function predictCursorEffort(modelId: string, table: CursorEffortTable | null): CursorEffortPrediction { +/** + * `supportsReasoning` is what the gateway row will advertise in + * `capabilities.supports_reasoning`; Cursor's gemini family withholds its control when that is + * false (`effortRequiresReasoningCapability`). Callers that do not know the row pass nothing + * and get the id-only prediction. + */ +export function predictCursorEffort( + modelId: string, + table: CursorEffortTable | null, + supportsReasoning?: boolean, +): CursorEffortPrediction { const id = normalizeCursorPickerId(modelId); if (table) { for (const family of table.families) { if (family.pattern.test(id)) { + if (family.requiresReasoningCapability && supportsReasoning === false) { + return { ladder: null, source: "bundle", family: family.id }; + } return { ladder: family.ladder.length > 0 ? [...family.ladder] : null, source: "bundle", @@ -86,7 +99,9 @@ export function predictCursorEffort(modelId: string, table: CursorEffortTable | if (table.bareGpt5?.pattern.test(id)) return { ladder: [...table.bareGpt5.ladder], source: "bundle", family: "gpt-5" }; return { ladder: null, source: "bundle", family: null }; } - return { ladder: cursorEffortFamily(modelId), source: "static", family: null }; + const staticLadder = cursorEffortFamily(modelId); + const gated = supportsReasoning === false && id.startsWith("gemini-") ? null : staticLadder; + return { ladder: gated, source: "static", family: null }; } export interface ModelCapabilityInput { diff --git a/tests/cursor-effort-table.test.ts b/tests/cursor-effort-table.test.ts index 5c73cf0788..f3f32fda58 100644 --- a/tests/cursor-effort-table.test.ts +++ b/tests/cursor-effort-table.test.ts @@ -76,6 +76,10 @@ describe("Cursor installed-bundle effort table", () => { }); expect(loadSource("function unrelated(){}", 1)).toBeNull(); expect(loadSource(FIXTURE.replace("/^claude-opus-5$/u", "/[/u"), 2)).toBeNull(); + // A build that adds a property to ONE family row must not yield a partial table. + expect(loadSource(FIXTURE.replace('effort:k,outputCap:128e3}', 'effort:k,outputCap:128e3,newFlag:!0}'), 3)).toBeNull(); + // A malformed bare gpt-5 pattern rejects the whole parse instead of throwing. + expect(loadSource(FIXTURE.replace("/^gpt-5(?:\\.\\d+)?$/u.test(t)", "/^gpt-5(/u.test(t)"), 4)).toBeNull(); expect(predictCursorEffort("anthropic/claude-opus-5", null)).toEqual({ ladder: ["low", "medium", "high", "xhigh", "max"], source: "static", @@ -83,6 +87,15 @@ describe("Cursor installed-bundle effort table", () => { }); }); + test("gemini withholds its ladder when the row will not advertise supports_reasoning", () => { + const table = parsedFixtureTable(); + expect(predictCursorEffort("cursor/gemini-3.7-flash", table, true).ladder).toEqual(["minimal", "low", "medium", "high"]); + expect(predictCursorEffort("cursor/gemini-3.7-flash", table, false)).toEqual({ ladder: null, source: "bundle", family: "gemini" }); + expect(predictCursorEffort("cursor/gemini-3.7-flash", null, false).ladder).toBeNull(); + // Other families ignore the flag: Cursor gates only gemini on it. + expect(predictCursorEffort("anthropic/claude-opus-5", table, false).ladder).toHaveLength(5); + }); + test("caches by bundle path, mtime, and size and re-reads after mtime changes", () => { let mtimeMs = 1; let reads = 0; From 25ff6691ed495bdae038dcf738062ccca23c495e Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 22:39:23 +0900 Subject: [PATCH 4/4] fix(cursor): bind the bare gpt-5 rule by shape, not minified identifier --- src/integrations/cursor-effort-table.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/integrations/cursor-effort-table.ts b/src/integrations/cursor-effort-table.ts index 6a33b33e41..bca6a53c30 100644 --- a/src/integrations/cursor-effort-table.ts +++ b/src/integrations/cursor-effort-table.ts @@ -93,7 +93,8 @@ export function parseCursorEffortTable(source: string): Omit