Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/catalog-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"pi-cursor-inference": patch
---

Advertise image, thinking, context-window, and Max Mode support from each model's matched Cursor catalog metadata, retain the real Cursor wire selection behind synthetic family and Max IDs, and omit unmatched selections instead of assigning permissive fallback capabilities.
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ records the complete history review performed before the repository became publi
- Native Pi OAuth through `/login cursor`, plus `PI_CURSOR_TOKEN` for headless use.
- Dynamic model discovery from `AvailableModels`, `GetUsableModels`, and
`GetDefaultModelForCli`.
- Catalog-backed context windows, image/thinking capabilities, and distinct Max Mode rows only
when Cursor advertises a meaningful variant difference.
- Per-model image and thinking support is advertised only when `AvailableModels` explicitly enables
it. Selectable families without complete catalog metadata are omitted rather than receiving
invented capabilities.
- Normal and Max rows respect Cursor's separate mode flags. Their context windows follow the
selected default variant's `context` parameter when present, then the catalog token limit.
- Streaming thinking text when the provider supplies it, opaque reasoning signatures, text,
usage, generic tool-call argument deltas, and tool continuations.
- Final response assembly always preserves routed thinking text when Cursor's final message carries
Expand Down Expand Up @@ -139,8 +142,9 @@ never erase non-empty streamed thinking, even when strict reconciliation is off.

Cursor Max Mode defaults to **off**, matching the IDE's ordinary composer model configuration.
Catalog entries with a distinct Max Mode appear as separate `-max` models and carry Cursor's
captured context parameter automatically. Advanced callers may override the Cursor-specific
`cursorMaxMode` and `cursorContext` sampling parameters per request.
captured context parameter automatically. A model that supports only Max Mode produces only its
`-max` row, while the request still uses the underlying selectable Cursor model ID. Advanced callers
may override the Cursor-specific `cursorMaxMode` and `cursorContext` sampling parameters per request.

## Verification

Expand Down
1 change: 1 addition & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,4 @@
- 2026-09-04 GitHub social preview uses a dedicated 2:1 composition because the 5:1 banner, 16:9 gallery, and square marks do not fit that surface. The approved design keeps the Quarter Turn palette, mark, outlined Geist Mono, and exact text in a left-to-right layout on a 1280×640 design grid. The SVG and opaque PNG render at 3840×1920; the proportion is the layout contract and the higher raster avoids treating GitHub's recommendation as a resolution ceiling. Both files are repository-only and remain outside the package whitelist.
- 2026-09-04 The operator designated Victor Araújo personally as the copyright holder for `pi-cursor-inference`. The MIT license attribution is `Copyright (c) 2026 Victor Araújo`; the GitHub organization remains repository hosting and is not the license owner.
- 2026-09-04 Turn accounting keeps live inference, price quotes, and billing evidence separate. A fresh extraction and exhaustive formatted-source audit of the exact pinned IDE 3.18.9 artifact corrected the initial evidence scope. RunInference supplies input, output, cache-read, and cache-write token counts but no typed monetary field. DashboardService `GetFilteredUsageEvents` exposes event rows with `TokenUsage.totalCents`, `cursorTokenFee`, `chargedCents`, model, timestamp, client type, and optional `conversationId`. DashboardService `GetClientUsageData` accepts `conversationId` plus int32 `timestampBeforeRequest` and returns named `costInCents` items, but exact-symbol searches find no caller or field consumer in the packaged 3.18.9 workbench or agent host; the schema alone does not establish its timestamp unit, item meanings, settlement timing, or concurrency behavior. `AiService.CheckUsageBasedPrice` returns cents and a price ID, but its only workbench caller is the usage-based-pricing preflight display and it has no call identifier. Cursor's official Admin API exposes a closely matching usage-event shape through Team Admin API-key authentication, and Agent SDK `get_usage()` belongs to Cursor's agent runtime. None of the monetary contracts carries RunInference's per-call `invocationId`; `GetClientUsageData` adds a conversation/time boundary, not an identifier-level join. When present, extended RunInference usage supplies exact per-message input, output, cache-read, and cache-write counts; basic usage supplies exact prompt and completion counts only, while an absent usage arm leaves unmeasured initialized zeros. Per-message cost remains zero, and `/cursor usage` currently calls only aggregate DashboardService methods. Filtered-event and client-usage endpoint access and semantics remain source-verified rather than live-verified. The exact modules, full-bundle caller searches, and field census are recorded in [`docs/protocol/turn-accounting-source-audit-2026-09-04.md`](protocol/turn-accounting-source-audit-2026-09-04.md).
- 2026-09-04 Model capabilities fail closed per matched `AvailableModels` row. `input` includes images and `reasoning` enables thinking controls only when the corresponding optional catalog boolean is explicitly true; missing values never become permissive defaults. Selectable `GetUsableModels` families without a name, alias, legacy-slug, or variant-slug join are omitted, and a default selection without complete metadata fails the refresh rather than inventing support. Normal rows honor `supports_non_max_mode`; Max rows honor `supports_max_mode`, including Max-only models. When a default variant supplies a `context` parameter such as `200k` or `1m`, that selected value is the effective Pi context window and takes precedence over the broader top-level token limit. The 64k output limit remains a conservative provider default because this catalog closure exposes no per-model output-token limit. Synthetic Pi family and `-max` IDs retain an explicit map to a real `GetUsableModels` selection even when `supports_thinking` is false, so display-only IDs are never sent as Cursor wire model IDs. Pi's current `Model` contract has no separate tool-capability field; arbitrary tools remain the RunInference provider contract rather than guessed model metadata.
17 changes: 13 additions & 4 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,23 @@ package; the emitted extension targets Node 24 and imports only Pi peers at runt

### 3. Catalog metadata and Max Mode

- Selectable `GetUsableModels` families join to `AvailableModels` rows through names, aliases,
legacy slugs, and variant legacy slugs.
- Matching rows supply context windows, image support, and thinking support.
- Selectable `GetUsableModels` families join to complete `AvailableModels` rows through names,
aliases, legacy slugs, and variant legacy slugs. Unmatched families are omitted instead of
receiving invented capabilities; an unmatched default fails catalog refresh.
- Matching rows advertise images and thinking only when their optional catalog flags are explicitly
true. Normal and Max rows separately honor `supports_non_max_mode` and `supports_max_mode`.
- A selected default variant's `context` parameter is the effective Pi context window when present;
the top-level normal/Max token limit is the fallback. This preserves cases where the base row
advertises a broad 1M ceiling but its ordinary default variant selects 200k.
- Synthetic Pi family and `-max` IDs retain a mapping to a real `GetUsableModels` selection even
when the catalog does not advertise thinking; Pi-facing row names are never sent as Cursor wire
model IDs unless they are themselves selectable.
- Grok 4.6 reports 256k and receives no redundant Max row because its captured normal and Max
metadata are equivalent.
- GPT-5.6 Sol reports 272k normally and a distinct 1M `-max` row.
- Catalog-selected context parameters flow into both the requested model and routing key.
- Unmatched families retain conservative 200k fallback metadata.
- The 64k output limit remains conservative because the selected catalog closure exposes no
per-model output-token limit. Pi exposes no separate per-model tool-capability field.

### 4. `/cursor` operator surface

Expand Down
60 changes: 40 additions & 20 deletions src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ function tooltipText(tooltip: AvailableModel['tooltipData']): string {

function hasDistinctMaxMode(model: AvailableModel): boolean {
if (model.supportsMaxMode !== true) return false;
if (model.supportsNonMaxMode === false) return true;
if (
model.contextTokenLimitForMaxMode !== undefined &&
model.contextTokenLimitForMaxMode !== model.contextTokenLimit
Expand All @@ -264,17 +265,28 @@ function variantContext(model: AvailableModel, maxMode: boolean): string | undef
return variant?.parameterValues.find((parameter) => parameter.id === 'context')?.value;
}

function contextWindow(model: AvailableModel | undefined, maxMode: boolean): number {
function contextParameterTokens(value: string | undefined): number | undefined {
const matched = /^(\d+(?:\.\d+)?)([km])$/u.exec(value ?? '');
if (matched === null) return undefined;
const amount = Number(matched[1]);
const multiplier = matched[2] === 'm' ? 1_000_000 : 1_000;
const tokens = amount * multiplier;
return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined;
}

function contextWindow(model: AvailableModel, maxMode: boolean): number {
const selected = contextParameterTokens(variantContext(model, maxMode));
if (selected !== undefined) return selected;
const captured = maxMode
? (model?.contextTokenLimitForMaxMode ?? model?.contextTokenLimit)
: model?.contextTokenLimit;
? (model.contextTokenLimitForMaxMode ?? model.contextTokenLimit)
: model.contextTokenLimit;
return captured !== undefined && captured > 0 ? captured : defaultContextWindow;
}

function providerModel(
family: ModelFamily,
baseUrl: string,
base: AvailableModel | undefined,
base: AvailableModel,
maxMode: boolean,
): Model<'cursor-inference'> {
const representative =
Expand All @@ -284,35 +296,36 @@ function providerModel(
if (representative === undefined) throw new Error(`Cursor model family '${family.id}' is empty`);
const thinkingLevelMap: ThinkingLevelMap = {};
for (const member of family.members) thinkingLevelMap[member.level] = member.model.modelId;
const inferredReasoning = Object.entries(thinkingLevelMap).some(
([level, modelId]) => level !== 'off' && typeof modelId === 'string',
);
const reasoning = base?.supportsThinking ?? inferredReasoning;
const reasoning = base.supportsThinking === true;
const capturedName =
base?.clientDisplayName === undefined || base.clientDisplayName === ''
base.clientDisplayName === undefined || base.clientDisplayName === ''
? displayName(representative.model).replace(
/ (?:None|Minimal|Low|Medium|High|Extra High|Max)(?= Fast$|$)/u,
'',
)
: base.clientDisplayName;
const context = base === undefined ? undefined : variantContext(base, maxMode);
const context = variantContext(base, maxMode);
const publishedId = `${family.id}${maxMode ? '-max' : ''}`;
const requiresWireModelMap = family.members.some(({ model }) => model.modelId !== publishedId);
const samplingParams = {
...(maxMode ? { cursorMaxMode: true } : {}),
...(context === undefined ? {} : { cursorContext: context }),
};
return {
id: `${family.id}${maxMode ? '-max' : ''}`,
id: publishedId,
name: `${capturedName}${maxMode ? ' Max' : ''}`,
provider: 'cursor',
api: 'cursor-inference',
baseUrl,
reasoning,
input: base?.supportsImages === false ? ['text'] : ['text', 'image'],
input: base.supportsImages === true ? ['text', 'image'] : ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: contextWindow(base, maxMode),
maxTokens: defaultMaxTokens,
...(Object.keys(samplingParams).length === 0 ? {} : { samplingParams }),
...(reasoning && Object.keys(thinkingLevelMap).length > 0 ? { thinkingLevelMap } : {}),
...(Object.keys(thinkingLevelMap).length > 0 && (reasoning || requiresWireModelMap)
? { thinkingLevelMap }
: {}),
};
}

Expand All @@ -329,20 +342,27 @@ export function catalogModels(
if (base.models.length === 0) throw new Error('Cursor AvailableModels returned no models');
const models = modelFamilies(usable.models).flatMap((family) => {
const baseModel = baseModelFor(family, base.models);
if (baseModel === undefined) return [];
return [
providerModel(family, origin, baseModel, false),
...(baseModel !== undefined && hasDistinctMaxMode(baseModel)
? [providerModel(family, origin, baseModel, true)]
: []),
...(baseModel.supportsNonMaxMode === false
? []
: [providerModel(family, origin, baseModel, false)]),
...(hasDistinctMaxMode(baseModel) ? [providerModel(family, origin, baseModel, true)] : []),
];
});
if (models.length === 0) throw new Error('Cursor GetUsableModels returned no models');
const defaultId = defaultModel.model?.modelId;
if (defaultId !== undefined && defaultId !== '') {
if (models.length === 0)
throw new Error('Cursor catalog returned no fully described usable models');
const defaultSelection = defaultModel.model;
if (defaultSelection !== undefined && defaultSelection.modelId !== '') {
const defaultId = defaultSelection.modelId;
const selections = new Set(usable.models.map(({ modelId }) => modelId));
if (!selections.has(defaultId)) {
throw new Error(`Cursor default model '${defaultId}' is not usable`);
}
const defaultFamily = familyFor(defaultSelection).id;
if (!models.some(({ id }) => id === defaultFamily || id === `${defaultFamily}-max`)) {
throw new Error(`Cursor default model '${defaultId}' has no complete catalog metadata`);
}
}
return models;
}
Expand Down
Loading