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). 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..bca6a53c30 --- /dev/null +++ b/src/integrations/cursor-effort-table.ts @@ -0,0 +1,143 @@ +/** + * 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 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: CursorBareGpt5Rule | 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[] = []; + // 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; } + 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 || families.length !== openers) return null; + // The tested variable and the returned constant are minifier-assigned names; bind by shape. + const bareRe = /if\(\/(\^gpt-5[^/]+)\/([a-z]*)\.test\([A-Za-z_$][\w$]*\)\)return ([A-Za-z_$][\w$]*)\}/u.exec(source); + const bareConst = bareRe ? constants.get(bareRe[3]!) : undefined; + let bareGpt5: CursorBareGpt5Rule | null = null; + if (bareRe && bareConst) { + let pattern: RegExp; + try { pattern = new RegExp(bareRe[1]!, bareRe[2]!); } catch { return null; } + bareGpt5 = { pattern, ladder: bareConst.values, defaultValue: bareConst.defaultValue }; + } + 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..f4fc69a3d2 100644 --- a/src/server/management/cursor-integration-routes.ts +++ b/src/server/management/cursor-integration-routes.ts @@ -9,12 +9,13 @@ * started — plus which active models will show Cursor's Reasoning and Context controls. */ import { readRuntimePort } from "../../config/process-state"; -import { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextTier, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } from "../../codex/catalog"; +import { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextTier, nativeReasoningEfforts, 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; @@ -58,18 +61,29 @@ export async function buildCursorIntegrationStatus( // Same visibility rules as the raw /v1/models list Cursor will read: disabled models and // provider allowlists drop out here too, or the prediction shows rows Cursor never gets. const goModels = filterCatalogVisibleModels(await fetchAllModels(config), config); - const ids = [ - ...visibleNativeSlugs(config), - ...uniqueCatalogModelsForRawPublicList(goModels).map(model => 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 models = ids.map(id => { + const table = (deps.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference); + const models = ids.map(({ id, supportsReasoning }) => { const tier = nativeOpenAiContextTier(id, limits); + const predicted = predictCursorEffort(id, table, supportsReasoning); 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 +98,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..370b41cee4 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,62 @@ 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; +} + +/** + * `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", + 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; + 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 new file mode 100644 index 0000000000..f3f32fda58 --- /dev/null +++ b/tests/cursor-effort-table.test.ts @@ -0,0 +1,117 @@ +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(); + // 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", + family: null, + }); + }); + + 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; + 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)