Skip to content

Commit fe42694

Browse files
author
jun
committed
devlog: Cursor local models schema unit (research, audits, live evidence)
1 parent 13bc643 commit fe42694

15 files changed

Lines changed: 574 additions & 0 deletions
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# 000 — Research: Cursor Private Inference model-capability schema
2+
3+
Unit: `260902_cursor_local_models_schema`. Base: `origin/dev` at `85f7ef92a` (re-anchored after
4+
audit; the worktree HEAD `5fc7d073e` is an ancestor of it). Class C3 (public inbound contract change on
5+
`GET /v1/models`, docs guide, stacked PRs). Research only; no diffs in this document.
6+
7+
## Problem
8+
9+
OpenCodex-routed models appear in the model picker of the Cursor **Private Inference**
10+
build (release track `cursor-local`, `buildFlags.localMode = true`), but the picker shows
11+
no reasoning-effort control. Verified live 2026-09-02 on Cursor Private Inference 3.18.25
12+
(darwin-arm64) against the local proxy at `http://127.0.0.1:10100/v1`: agent turns complete
13+
(`ocx observe logs --json` rows with `inboundProtocol: "chat"`, `admissionKind: "loopback"`,
14+
`provider: openai-p3fa38a`, model `gpt-5.6-sol`), so transport is fine — only the
15+
effort ladder is missing.
16+
17+
## Where the gate is (Cursor side, read from the shipped bundle)
18+
19+
File: `Cursor Private Inference.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`.
20+
21+
1. Model discovery calls `GET {baseUrl}/models` (`tpe(baseUrl, "/models")`) with
22+
`authorization: Bearer <apiKey>` and a 2 s timeout, expects `{ data: [...] }`.
23+
2. `extendedCapabilitiesDetected = dme(data)` is true only if **some** row passes the
24+
zod-style schema `fme`:
25+
- `api_types`: non-empty string array containing at least one of
26+
`chat_completions | responses | openai_chat | openai_responses | anthropic_messages`
27+
(set `lme`);
28+
- `capabilities` (optional object): `context_length`, `max_output_tokens`
29+
(finite positive numbers), `output_modalities`, `input_modalities` (string[]),
30+
`supports_tool_use`, `supports_streaming`, `supports_reasoning`, `supports_vision`
31+
(booleans), `reasoning_effort` (string[]), `cost` (optional);
32+
- `cost` (optional).
33+
3. The picker builder `J(model, tier)` attaches the "Reasoning" control only when
34+
`I(model.id)` (a hard-coded regex table) yields an effort ladder **and**
35+
`model.extendedCapabilitiesDetected === true`. For entries with
36+
`effortRequiresReasoningCapability` (Gemini) it also needs
37+
`capabilities.supports_reasoning !== false`.
38+
4. The ladder shown is Cursor's table, not the gateway's list:
39+
- `gpt-5.6-(luna|sol|terra)` → `reasoning_effort` in `low|medium|high|xhigh`, default medium
40+
- `claude-opus-5`, `claude-sonnet-5`, `claude-opus-4.7/4.8` → `output_config.effort` low..max
41+
- `claude-opus-4.6`, `claude-opus-4.5`, `claude-sonnet-4.6` → low..max (no xhigh)
42+
- `grok-4.3/4.5/4.6`, `grok-build-latest` → `reasoning_effort` minimal..xhigh, default high
43+
- `gemini-*` → minimal..high, requires `supports_reasoning`
44+
- bare `gpt-5`, `gpt-5.x` → low..xhigh
45+
- anything else (e.g. `claude-fable-5-1`, `kimi-k3`) → no control
46+
The id is normalised first: lower-case, strip everything before the last `/`, strip `@...`.
47+
So `anthropic/claude-opus-5` matches `claude-opus-5`.
48+
5. On send, the chosen value is written as `reasoning_effort` (chat completions),
49+
`reasoning.effort` (responses) or `thinking + output_config.effort` (messages).
50+
51+
## What OpenCodex emits today
52+
53+
`src/server/index.ts`, raw OpenAI-list branch of `GET /v1/models` (around line 1481):
54+
55+
```json
56+
{ "id": "gpt-5.6-sol", "object": "model", "created": 0, "owned_by": "openai",
57+
"supports_reasoning_effort": true, "reasoning_effort": "low",
58+
"reasoning_efforts": [{ "value": "low", "label": "Low Effort", "default": true }, ...] }
59+
```
60+
61+
No `api_types`, no `capabilities`. `dme` returns false → no effort control. Confirmed by
62+
the live picker (only model names, "Add Models").
63+
64+
## Data available server-side for the new fields
65+
66+
- Effort ladder: `m.reasoningEfforts` / `nativeReasoningEfforts(slug)` (already used).
67+
- Context: `m.contextWindow` / `m.contextCap` for routed rows; `nativeOpenAiContextWindow(slug,
68+
nativeContextLimits(config))` for native rows (`src/codex/catalog/metadata.ts:266`).
69+
- Vision: `m.inputModalities` includes `"image"` (routed rows; `provider-fetch.ts:675-708`).
70+
- Max output tokens: not tracked for routed rows → omit (optional in Cursor's schema).
71+
- Tool use / streaming: every OpenCodex route supports both → constant `true`.
72+
- Anthropic messages: `/v1/messages` is served for every routed model, but Cursor picks
73+
`anthropic_messages` only when the base URL path ends in `/messages`; advertising it is
74+
harmless and true. Keep `api_types: ["chat_completions","responses","anthropic_messages"]`.
75+
76+
## Existing consumers of the raw list that must keep passing
77+
78+
- `tests/grok-models-effort-list.test.ts` (Grok Build ladder shape) — additive fields OK.
79+
- `tests/claude-models-discovery.test.ts` (Claude gateway branch, separate code path).
80+
- `tests/server-combo-failover-e2e.test.ts:824-846` asserts **exact** row literals with `toEqual`
81+
on six combo/vendor rows (audit blocker 1). Those literals must move to `toMatchObject` while
82+
keeping the explicit `is_combo` absence check at :846.
83+
- `tests/server-auth.test.ts`, `tests/ollama-native.test.ts`, `tests/provider-outbound.test.ts`,
84+
`tests/codex-catalog.test.ts`, `tests/gui-management-session.test.ts` read the list without
85+
key-set equality; all of them run at C as the focused set.
86+
87+
## Platform matrix (release track `cursor-local`)
88+
89+
The update endpoint answers 200 for `darwin-arm64`, `darwin-x64`, `darwin-universal`,
90+
`win32-x64`, `win32-arm64`, `linux-x64`, `linux-arm64` (3.18.25, 2026-09-02). Windows ships
91+
a system-setup installer, Linux an AppImage. Product identity is shared with regular Cursor
92+
(`applicationName: cursor`, `dataFolderName: .cursor`, same bundle id), so the two builds
93+
share `~/Library/Application Support/Cursor` / `%APPDATA%\Cursor` / `~/.config/Cursor`
94+
unless launched with `--user-data-dir`.
95+
96+
Configuration surfaces (same on all platforms): Settings → Models → Gateway
97+
(Base URL, API Key), or env `CURSOR_LOCAL_AGENT_BASE_URL`, `CURSOR_LOCAL_AGENT_API_KEY`,
98+
`CURSOR_LOCAL_AGENT_HEADERS`. Env is read via the shell-environment service, so a
99+
GUI-launched app needs the variable in the login environment, not just an interactive rc file.
100+
101+
Cursor sign-in is still required (login wall before the gateway modal). Cursor's own catalog,
102+
Tab completion and cloud agents are unavailable in local mode.
103+
104+
## Distribution stance
105+
106+
Cursor does not document this build (docs, changelog, staff forum answers through 2026-08 all
107+
say inference is cloud-side). The guide must describe how to use the build if the user
108+
already has it and must not host, link or script its download.
109+
110+
## Verifiers (PLAN-VERIFIER-REAL-01, run 2026-09-02)
111+
112+
| Command | Exit | Reads the change target? |
113+
|---|---|---|
114+
| `bun run typecheck` | 0 (baseline) | yes — tsc over `src/**` incl. `src/server/index.ts` |
115+
| `bun test tests/grok-models-effort-list.test.ts` | 0 (baseline) | yes — starts the server and fetches `/v1/models` |
116+
| `bun test tests/cursor-local-models-schema.test.ts` | n/a (new in 010) | yes — asserts the new fields |
117+
| `bun run privacy:scan` | 0 (baseline) | reads docs-site + devlog |
118+
| docs guide | — | human review + `rg downloads.cursor.com docs-site` must return 0 hits |
119+
120+
Full `bun run test` is forbidden by the user for this unit; exact-head CI is the gate.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# 005 — Audit round 1 (wp1 roadmap)
2+
3+
Reviewer: independent subagent, Claude Opus 5 (decorrelated from the planning model).
4+
Verdict: **GO-WITH-FIXES (blockers=4)**. Disposition of each item:
5+
6+
| # | Finding | Disposition | Where folded |
7+
|---|---|---|---|
8+
| 1 | `tests/server-combo-failover-e2e.test.ts:824-846` uses `toEqual` on complete row literals; the new keys would fail six assertions | folded | 000 consumer list; 010 file map (MODIFY → `toMatchObject` + explicit `is_combo` absence) |
9+
| 2 | Plan's focused test list would not have caught #1 under the no-full-suite constraint | folded | 010 accept criteria now enumerate all nine raw-list consumers |
10+
| 3 | Config-key verification pointed at the `src/types.ts` barrel | folded | 010 cites `src/types/provider.ts:362/364/440/442`; fallback clause deleted |
11+
| 4 | Base SHA `6fe46312c` stale; `origin/dev` is `85f7ef92a` | folded | 000 + 010 re-anchored; branch created from `origin/dev` |
12+
| n1 | `anthropic_messages` is safe only because OpenAI-family types are also advertised | folded | 010 helper comment + unit-test line |
13+
| n2 | Prefer a static import of the helper over `await import` | folded | 010 index.ts diff |
14+
| n3 | DEV-STACK-01: two-layer stack justified on revert independence | accepted | 020 unchanged |
15+
16+
Verifier evidence at this round: `bun run typecheck` exit 0; `bun test tests/grok-models-effort-list.test.ts`
17+
5 pass (starts a server and fetches `/v1/models`); `bun run privacy:scan` fails on `tests/provider-key-store.test.ts:41-42`
18+
at baseline dev, unrelated to this unit (verified by stashing the unit and rerunning).
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# 010 — Layer 1: advertise `api_types` + `capabilities` on the raw `/v1/models` list
2+
3+
Branch: `codex/cursor-local-models-schema` (base: `origin/dev` at `85f7ef92a`, created with
4+
`git switch -c codex/cursor-local-models-schema origin/dev`).
5+
PR 1 of the stack, targets `dev`. Thesis: one additive schema change on the OpenAI-shape
6+
model list so Cursor's local-agent runtime detects extended capabilities.
7+
8+
## File change map
9+
10+
| Path | Action | Why |
11+
|---|---|---|
12+
| `src/server/models-capabilities.ts` | NEW | Pure helper: build the `api_types` + `capabilities` fields from catalog data. Keeps `index.ts` from growing another inline lambda. |
13+
| `src/server/index.ts` | MODIFY | Spread the helper's output into `nativeModelRow` and the routed-row object in the raw-list branch. |
14+
| `tests/cursor-local-models-schema.test.ts` | NEW | Regression test: server start, GET `/v1/models`, assert schema on a native row and a routed row; assert Grok fields unchanged; assert `OPENCODEX_MODEL_API_TYPES` keeps an OpenAI-family member (load-bearing: Cursor routes to the Messages wire only when NO OpenAI-family type is present). |
15+
| `tests/server-combo-failover-e2e.test.ts` | MODIFY | Six `toEqual` row literals at :824-846 become `toMatchObject`; keep `is_combo` absence explicit (`expect(row.is_combo).toBeUndefined()`) so the combo-off path stays verified. |
16+
17+
Scope OUT: the Codex-catalog `{ models: [...] }` branch, Claude gateway branch, GUI, docs (020).
18+
19+
## `src/server/models-capabilities.ts` (NEW)
20+
21+
```ts
22+
/**
23+
* Extended capability advertisement for the OpenAI-shape `GET /v1/models` list.
24+
*
25+
* Cursor's local-agent runtime (the "Private Inference" build) only enables its reasoning
26+
* effort control when at least one row in `data[]` carries `api_types` (a non-empty array
27+
* naming an API family it can speak) and, optionally, a `capabilities` object. Plain OpenAI
28+
* clients ignore both keys. Every OpenCodex route serves chat completions, Responses and
29+
* Anthropic Messages, streams, and accepts tool calls, so those are constants; context and
30+
* vision come from catalog data when known and are omitted otherwise.
31+
*/
32+
// Membership is load-bearing for Cursor: its selector picks the Anthropic Messages wire only
33+
// when NO OpenAI-family type (chat_completions/responses/openai_chat/openai_responses) is
34+
// present. Keep at least one OpenAI-family entry. Guarded by a unit test.
35+
export const OPENCODEX_MODEL_API_TYPES = ["chat_completions", "responses", "anthropic_messages"] as const;
36+
37+
export interface ModelCapabilityInput {
38+
reasoningEfforts?: readonly string[];
39+
contextWindow?: number;
40+
inputModalities?: readonly string[];
41+
}
42+
43+
export interface ModelCapabilityFields {
44+
api_types: readonly string[];
45+
capabilities: {
46+
context_length?: number;
47+
supports_tool_use: true;
48+
supports_streaming: true;
49+
supports_reasoning: boolean;
50+
supports_vision?: boolean;
51+
reasoning_effort?: string[];
52+
};
53+
}
54+
55+
function positiveInt(value: unknown): number | undefined {
56+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
57+
}
58+
59+
export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabilityFields {
60+
const efforts = (input.reasoningEfforts ?? []).filter(e => typeof e === "string" && e.length > 0);
61+
const contextLength = positiveInt(input.contextWindow);
62+
const modalities = input.inputModalities;
63+
const supportsVision = Array.isArray(modalities) ? modalities.includes("image") : undefined;
64+
return {
65+
api_types: OPENCODEX_MODEL_API_TYPES,
66+
capabilities: {
67+
...(contextLength !== undefined ? { context_length: contextLength } : {}),
68+
supports_tool_use: true,
69+
supports_streaming: true,
70+
supports_reasoning: efforts.length > 0,
71+
...(supportsVision !== undefined ? { supports_vision: supportsVision } : {}),
72+
...(efforts.length > 0 ? { reasoning_effort: [...efforts] } : {}),
73+
},
74+
};
75+
}
76+
```
77+
78+
## `src/server/index.ts` (MODIFY, raw-list branch ~L1481-1553)
79+
80+
Before:
81+
82+
```ts
83+
const nativeModelRow = (id: string, metadataId = id) => ({
84+
id,
85+
object: "model",
86+
created: 0,
87+
owned_by: "openai",
88+
...grokEffortFields(
89+
nativeReasoningEfforts(metadataId),
90+
nativeDefaultReasoningEffort(metadataId),
91+
),
92+
});
93+
```
94+
95+
After:
96+
97+
```ts
98+
// modelCapabilityFields is a static import at the top of index.ts (pure helper, no
99+
// startup side effects). nativeOpenAiContextWindow / nativeInputModalities join the
100+
// existing catalog destructuring at ~L1347.
101+
const nativeLimits = nativeContextLimits(config);
102+
const nativeModelRow = (id: string, metadataId = id) => ({
103+
id,
104+
object: "model",
105+
created: 0,
106+
owned_by: "openai",
107+
...grokEffortFields(
108+
nativeReasoningEfforts(metadataId),
109+
nativeDefaultReasoningEffort(metadataId),
110+
),
111+
// Cursor local-agent discovery (Private Inference build) reads api_types +
112+
// capabilities; other OpenAI clients ignore them. See src/server/models-capabilities.ts.
113+
...modelCapabilityFields({
114+
reasoningEfforts: nativeReasoningEfforts(metadataId),
115+
contextWindow: nativeOpenAiContextWindow(metadataId, nativeLimits),
116+
inputModalities: nativeInputModalities(metadataId),
117+
}),
118+
});
119+
```
120+
121+
`nativeOpenAiContextWindow` and `nativeInputModalities` are added to the existing `await import("../codex/catalog")`
122+
destructuring at the top of the branch (both re-exported by `src/codex/catalog.ts:5`, verified). Native modalities come from the pinned upstream entry via `nativeInputModalities` (metadata.ts).
123+
124+
Routed row, before:
125+
126+
```ts
127+
...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}),
128+
...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
129+
};
130+
```
131+
132+
After:
133+
134+
```ts
135+
...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}),
136+
...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
137+
...modelCapabilityFields({
138+
reasoningEfforts: m.reasoningEfforts,
139+
contextWindow: m.contextCap ?? m.contextWindow,
140+
inputModalities: m.inputModalities,
141+
}),
142+
};
143+
```
144+
145+
`contextCap` wins over `contextWindow` because it is the operator-narrowed effective limit
146+
(`CatalogModel.contextCap`, `parsing.ts:117`).
147+
148+
## `tests/cursor-local-models-schema.test.ts` (NEW)
149+
150+
Modelled on `tests/grok-models-effort-list.test.ts` (same fixture: `kimi` provider with
151+
`liveModels: false`, seeded native entitlements, `OPENCODEX_HOME` tmp dir, `SERVER_BUDGET_MS`).
152+
153+
Assertions:
154+
155+
1. Routed row `kimi/k3` (config: `modelReasoningEfforts.k3 = ["low","high","max"]`,
156+
`modelContextWindows.k3 = 200000`, `modelInputModalities.k3 = ["text","image"]`):
157+
- `api_types` equals `["chat_completions","responses","anthropic_messages"]`
158+
- `capabilities` equals `{ context_length: 200000, supports_tool_use: true, supports_streaming: true, supports_reasoning: true, supports_vision: true, reasoning_effort: ["low","high","max"] }`
159+
- Grok fields still present and unchanged: `supports_reasoning_effort === true`,
160+
`reasoning_efforts[1].default === true`.
161+
2. Routed row `kimi/kimi-for-coding` (no efforts): `capabilities.supports_reasoning === false`,
162+
no `reasoning_effort` key.
163+
3. Native row `gpt-5.6-sol`: `api_types` present; `capabilities.reasoning_effort` equals
164+
`nativeReasoningEfforts("gpt-5.6-sol")`; `capabilities.context_length` is a positive number.
165+
4. Unit test of `modelCapabilityFields` directly: empty input yields
166+
`{ api_types, capabilities: { supports_tool_use: true, supports_streaming: true, supports_reasoning: false } }` with no
167+
`context_length`/`supports_vision`/`reasoning_effort` keys (activation scenario for the
168+
omit branches, C-ACTIVATION-GROUNDING-01).
169+
170+
Config keys confirmed on `OcxProviderConfig` in `src/types/provider.ts`: `modelContextWindows`
171+
(:362), `modelInputModalities` (:364), `modelReasoningEfforts` (:440),
172+
`modelDefaultReasoningEfforts` (:442). No fallback needed.
173+
174+
## Accept criteria
175+
176+
- `bun run typecheck` exit 0.
177+
- Focused set covering every raw-list consumer (audit blocker 2): `bun test tests/cursor-local-models-schema.test.ts tests/grok-models-effort-list.test.ts tests/server-combo-failover-e2e.test.ts tests/claude-models-discovery.test.ts tests/server-auth.test.ts tests/ollama-native.test.ts tests/provider-outbound.test.ts tests/codex-catalog.test.ts tests/gui-management-session.test.ts` exit 0. The full suite stays forbidden; exact-head CI is the gate.
178+
- Restarted local proxy: `curl -s http://127.0.0.1:10100/v1/models | jq '.data[] | select(.id=="gpt-5.6-sol") | {api_types, capabilities}'` shows both keys.
179+
- Live: Cursor Private Inference → Settings → Models → "Refresh model list" → composer
180+
model row for `gpt-5.6-sol` shows a Reasoning control with low/medium/high/xhigh.
181+
Screenshot saved as `devlog/_plan/260902_cursor_local_models_schema/011_effort_control.png`.
182+
- Live: send a turn with effort `high`; `ocx observe logs --json` last `gpt-5.6-sol` row
183+
shows the effort (field name to record at C — the log has `requestedEffort`/`reasoningEffort`
184+
or similar; if the log does not carry it, capture via `ocx debug provider on` request dump).
185+
186+
## Bypass / enforcement (PLAN-BYPASS-NAMED-01)
187+
188+
Not an enforcement change; no gate added. n/a.
189+
190+
## Field chain (PLAN-FIELD-CHAIN-01)
191+
192+
New output keys only (`api_types`, `capabilities`). Creation: helper. Serialization:
193+
`jsonResponse`. Deserialization: none server-side (`N/A` — inbound consumers are external
194+
clients). Consumers: Cursor local runtime (target), Grok Build (ignores), Codex (uses the other
195+
branch), Claude gateway (other branch), GUI API-keys page (`gui/src/pages/ApiKeys.tsx:170`) reads this list and only displays ids — additive keys are ignored.
42.5 KB
Loading
44.9 KB
Loading
48.6 KB
Loading

0 commit comments

Comments
 (0)