@rahularya01/pi-cursor 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,1393 +0,0 @@
1
- /**
2
- * Cursor Provider Extension for pi
3
- *
4
- * Provides access to Cursor models (Claude, GPT, Gemini, etc.) via:
5
- * 1. Browser-based PKCE OAuth login to Cursor
6
- * 2. Native Pi streamSimple provider translating Pi context → Cursor gRPC protocol
7
- *
8
- * Usage:
9
- * /login cursor — authenticate via browser
10
- * /model — select any Cursor model
11
- *
12
- * Based on https://github.com/ephraimduncan/opencode-cursor by Ephraim Duncan.
13
- */
14
-
15
- import rawFallbackModels from "./models/catalog.json" with { type: "json" };
16
- import {
17
- readStoredCredential,
18
- type ExtensionAPI,
19
- type ExtensionCommandContext,
20
- type ExtensionContext,
21
- } from "@earendil-works/pi-coding-agent";
22
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
23
- import { appendFileSync } from "node:fs";
24
- import { createHash } from "node:crypto";
25
- import { tmpdir } from "node:os";
26
- import { join as pathJoin } from "node:path";
27
- import {
28
- generateCursorAuthParams,
29
- getTokenExpiry,
30
- pollCursorAuth,
31
- refreshCursorToken,
32
- } from "./auth/oauth.js";
33
- import { resolveSystemCursorAccessToken, type CredentialSource } from "./auth/cli-credentials.js";
34
- import { getLastDiagnostics, setLastAvailableModels } from "./diagnostics/index.js";
35
- import { redactSecrets } from "./utils/security.js";
36
- import { formatCursorUsage, getCursorUsageSummary } from "./usage.js";
37
- import {
38
- cleanupSessionState,
39
- createCursorNativeStream,
40
- getCursorAgentUrl,
41
- getCursorModels,
42
- getCursorParameterizedModels,
43
- type CursorModel,
44
- type CursorModelParameter,
45
- type CursorParameterizedModel,
46
- type CursorParameterizedVariant,
47
- } from "./stream/native-core.js";
48
-
49
- // ── Cost estimation ──
50
-
51
- interface ModelCost {
52
- input: number;
53
- output: number;
54
- cacheRead: number;
55
- cacheWrite: number;
56
- }
57
-
58
- let extensionDebugLogFilePath: string | undefined;
59
-
60
- function isExtensionDebugEnabled(): boolean {
61
- const raw = process.env.PI_CURSOR_PROVIDER_DEBUG?.trim().toLowerCase();
62
- return !!raw && raw !== "0" && raw !== "false" && raw !== "off";
63
- }
64
-
65
- function getExtensionDebugLogFilePath(): string {
66
- if (extensionDebugLogFilePath) return extensionDebugLogFilePath;
67
- const configured = process.env.PI_CURSOR_PROVIDER_EXTENSION_DEBUG_FILE?.trim();
68
- if (configured) {
69
- extensionDebugLogFilePath = configured;
70
- return extensionDebugLogFilePath;
71
- }
72
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
73
- extensionDebugLogFilePath = pathJoin(
74
- tmpdir(),
75
- `pi-cursor-provider-extension-debug-${stamp}-${process.pid}.log`,
76
- );
77
- return extensionDebugLogFilePath;
78
- }
79
-
80
- function truncateDebugValue(value: string, max = 240): string {
81
- return value.length > max
82
- ? `${value.slice(0, max)}…<truncated ${value.length - max} chars>`
83
- : value;
84
- }
85
-
86
- function summarizeBase64ImageData(data: string): {
87
- base64Length: number;
88
- byteLength?: number;
89
- sha256?: string;
90
- } {
91
- const summary: { base64Length: number; byteLength?: number; sha256?: string } = {
92
- base64Length: data.length,
93
- };
94
- try {
95
- const bytes = Buffer.from(data.replace(/\s/g, ""), "base64");
96
- if (bytes.length > 0) {
97
- summary.byteLength = bytes.length;
98
- summary.sha256 = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
99
- }
100
- } catch {
101
- // Invalid base64 — keep length-only summary.
102
- }
103
- return summary;
104
- }
105
-
106
- function summarizeImageBlock(type: unknown, mimeType: unknown, data: unknown): unknown {
107
- return {
108
- type,
109
- mimeType,
110
- ...(typeof data === "string"
111
- ? summarizeBase64ImageData(data)
112
- : { data: `<redacted base64 ${String(data ?? "").length} chars>` }),
113
- };
114
- }
115
-
116
- function summarizeDataImageUrl(url: string): unknown {
117
- const match = url.trim().match(/^data:([^;,]+)(?:;[^,]*)?;base64,(.*)$/is);
118
- if (!match)
119
- return {
120
- url: url.startsWith("data:image/")
121
- ? `<redacted data image ${url.length} chars>`
122
- : truncateDebugValue(url),
123
- };
124
- return {
125
- mimeType: match[1]?.toLowerCase(),
126
- ...summarizeBase64ImageData(match[2]!),
127
- };
128
- }
129
-
130
- function summarizeContent(content: unknown): unknown {
131
- if (typeof content === "string") return truncateDebugValue(content);
132
- if (!Array.isArray(content)) return content;
133
- return content.map((block) => {
134
- if (!block || typeof block !== "object") return block;
135
- const typed = block as Record<string, unknown>;
136
- switch (typed.type) {
137
- case "text":
138
- return { type: "text", text: truncateDebugValue(String(typed.text ?? "")) };
139
- case "thinking":
140
- return { type: "thinking", thinking: truncateDebugValue(String(typed.thinking ?? "")) };
141
- case "toolCall":
142
- return {
143
- type: "toolCall",
144
- id: typed.id,
145
- name: typed.name,
146
- arguments: typed.arguments,
147
- };
148
- case "image":
149
- return summarizeImageBlock("image", typed.mimeType, typed.data);
150
- case "image_url": {
151
- const url = (typed.image_url as Record<string, unknown> | undefined)?.url;
152
- const text = typeof url === "string" ? url : "";
153
- return { type: "image_url", image_url: summarizeDataImageUrl(text) };
154
- }
155
- default:
156
- return typed;
157
- }
158
- });
159
- }
160
-
161
- function summarizeMessage(message: unknown): unknown {
162
- if (!message || typeof message !== "object") return message;
163
- const typed = message as Record<string, unknown>;
164
- return {
165
- role: typed.role,
166
- stopReason: typed.stopReason,
167
- toolCallId: typed.toolCallId,
168
- toolName: typed.toolName,
169
- isError: typed.isError,
170
- errorMessage: typed.errorMessage,
171
- content: summarizeContent(typed.content),
172
- };
173
- }
174
-
175
- function summarizeBranchTail(
176
- ctx: {
177
- sessionManager?: {
178
- getBranch?: () => unknown[];
179
- getLeafId?: () => string | null;
180
- getSessionId?: () => string;
181
- };
182
- },
183
- limit = 6,
184
- ): unknown {
185
- try {
186
- const branch = ctx.sessionManager?.getBranch?.();
187
- if (!Array.isArray(branch)) return undefined;
188
- return {
189
- sessionId: ctx.sessionManager?.getSessionId?.(),
190
- leafId: ctx.sessionManager?.getLeafId?.(),
191
- size: branch.length,
192
- tail: branch.slice(-limit).map((entry) => {
193
- if (!entry || typeof entry !== "object") return entry;
194
- const typed = entry as Record<string, unknown>;
195
- return {
196
- type: typed.type,
197
- id: typed.id,
198
- parentId: typed.parentId,
199
- customType: typed.customType,
200
- message: summarizeMessage(typed.message),
201
- };
202
- }),
203
- };
204
- } catch (error) {
205
- return { error: error instanceof Error ? error.message : String(error) };
206
- }
207
- }
208
-
209
- export interface CursorToolResultImagePayload {
210
- toolCallId: string;
211
- images: Array<{ data: string; mimeType: string }>;
212
- }
213
-
214
- function payloadToolCallIds(payload: Record<string, unknown>): Set<string> {
215
- const ids = new Set<string>();
216
- const messages = Array.isArray(payload.messages) ? payload.messages : [];
217
- for (const message of messages) {
218
- if (!message || typeof message !== "object") continue;
219
- const typed = message as Record<string, unknown>;
220
- if (typed.role === "tool" && typeof typed.tool_call_id === "string" && typed.tool_call_id)
221
- ids.add(typed.tool_call_id);
222
- }
223
- return ids;
224
- }
225
-
226
- export function extractToolResultImagePayloads(
227
- ctx: { sessionManager?: { getBranch?: () => unknown[] } },
228
- payload: Record<string, unknown>,
229
- ): CursorToolResultImagePayload[] {
230
- const idsInPayload = payloadToolCallIds(payload);
231
- if (idsInPayload.size === 0) return [];
232
- const branch = ctx.sessionManager?.getBranch?.();
233
- if (!Array.isArray(branch)) return [];
234
-
235
- const byToolCallId = new Map<string, CursorToolResultImagePayload>();
236
- for (const entry of branch) {
237
- if (!entry || typeof entry !== "object") continue;
238
- const message = (entry as Record<string, unknown>).message;
239
- if (!message || typeof message !== "object") continue;
240
- const typed = message as Record<string, unknown>;
241
- const toolCallId = typeof typed.toolCallId === "string" ? typed.toolCallId : "";
242
- if (typed.role !== "toolResult" || !toolCallId || !idsInPayload.has(toolCallId)) continue;
243
- const content = Array.isArray(typed.content) ? typed.content : [];
244
- const images = content.flatMap((block) => {
245
- if (!block || typeof block !== "object") return [];
246
- const image = block as Record<string, unknown>;
247
- if (
248
- image.type !== "image" ||
249
- typeof image.data !== "string" ||
250
- typeof image.mimeType !== "string"
251
- )
252
- return [];
253
- return [{ data: image.data, mimeType: image.mimeType }];
254
- });
255
- if (images.length === 0) continue;
256
- const existing = byToolCallId.get(toolCallId);
257
- if (existing) existing.images.push(...images);
258
- else byToolCallId.set(toolCallId, { toolCallId, images });
259
- }
260
- return [...byToolCallId.values()];
261
- }
262
-
263
- function debugExtensionLog(event: string, data?: Record<string, unknown>): void {
264
- if (!isExtensionDebugEnabled()) return;
265
- const payload = JSON.stringify({
266
- ts: new Date().toISOString(),
267
- pid: process.pid,
268
- scope: "extension",
269
- event,
270
- ...data,
271
- });
272
- appendFileSync(getExtensionDebugLogFilePath(), `${payload}\n`, "utf8");
273
- }
274
-
275
- const MODEL_COST_TABLE: Record<string, ModelCost> = {
276
- "claude-4-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
277
- "claude-4.5-haiku": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
278
- "claude-4.5-opus": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
279
- "claude-4.5-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
280
- "claude-4.6-opus": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
281
- "claude-4.6-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
282
- "composer-1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
283
- "composer-1.5": { input: 3.5, output: 17.5, cacheRead: 0.35, cacheWrite: 0 },
284
- "composer-2": { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
285
- "gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 },
286
- "gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
287
- "gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
288
- "gemini-3.1-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
289
- "gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
290
- "gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
291
- "gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
292
- "gpt-5.2-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
293
- "gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
294
- "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
295
- "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
296
- "gpt-5.5": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
297
- "grok-4.20": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 },
298
- "kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
299
- };
300
-
301
- const MODEL_COST_PATTERNS: Array<{ match: (id: string) => boolean; cost: ModelCost }> = [
302
- {
303
- match: (id) => /claude.*opus.*fast/i.test(id),
304
- cost: { input: 30, output: 150, cacheRead: 3, cacheWrite: 37.5 },
305
- },
306
- { match: (id) => /claude.*opus/i.test(id), cost: MODEL_COST_TABLE["claude-4.6-opus"]! },
307
- { match: (id) => /claude.*haiku/i.test(id), cost: MODEL_COST_TABLE["claude-4.5-haiku"]! },
308
- { match: (id) => /claude.*sonnet/i.test(id), cost: MODEL_COST_TABLE["claude-4.6-sonnet"]! },
309
- { match: (id) => /composer/i.test(id), cost: MODEL_COST_TABLE["composer-1"]! },
310
- { match: (id) => /gpt-5\.5/i.test(id), cost: MODEL_COST_TABLE["gpt-5.5"]! },
311
- { match: (id) => /gpt-5\.4.*mini/i.test(id), cost: MODEL_COST_TABLE["gpt-5.4-mini"]! },
312
- { match: (id) => /gpt-5\.4/i.test(id), cost: MODEL_COST_TABLE["gpt-5.4"]! },
313
- { match: (id) => /gpt-5\.3/i.test(id), cost: MODEL_COST_TABLE["gpt-5.3-codex"]! },
314
- { match: (id) => /gpt-5\.2/i.test(id), cost: MODEL_COST_TABLE["gpt-5.2"]! },
315
- { match: (id) => /gpt-5.*mini/i.test(id), cost: MODEL_COST_TABLE["gpt-5-mini"]! },
316
- { match: (id) => /gpt-5/i.test(id), cost: MODEL_COST_TABLE["gpt-5"]! },
317
- { match: (id) => /gemini.*3\.1/i.test(id), cost: MODEL_COST_TABLE["gemini-3.1-pro"]! },
318
- { match: (id) => /gemini.*flash/i.test(id), cost: MODEL_COST_TABLE["gemini-2.5-flash"]! },
319
- { match: (id) => /gemini/i.test(id), cost: MODEL_COST_TABLE["gemini-3-pro"]! },
320
- { match: (id) => /grok/i.test(id), cost: MODEL_COST_TABLE["grok-4.20"]! },
321
- { match: (id) => /kimi/i.test(id), cost: MODEL_COST_TABLE["kimi-k2.5"]! },
322
- ];
323
-
324
- const DEFAULT_COST: ModelCost = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 };
325
-
326
- function estimateModelCost(modelId: string): ModelCost {
327
- const normalized = modelId.toLowerCase();
328
- const exact = MODEL_COST_TABLE[normalized];
329
- if (exact) return exact;
330
- const stripped = normalized.replace(
331
- /-(high|medium|low|preview|thinking|spark-preview|fast)$/g,
332
- "",
333
- );
334
- const strippedMatch = MODEL_COST_TABLE[stripped];
335
- if (strippedMatch) return strippedMatch;
336
- return MODEL_COST_PATTERNS.find((p) => p.match(normalized))?.cost ?? DEFAULT_COST;
337
- }
338
-
339
- // ── Effort-level dedup ──
340
-
341
- const CURSOR_EFFORT_SUFFIXES: Array<{ suffix: string; effort: string }> = [
342
- { suffix: "extra-high", effort: "xhigh" },
343
- { suffix: "minimal", effort: "minimal" },
344
- { suffix: "xhigh", effort: "xhigh" },
345
- { suffix: "medium", effort: "medium" },
346
- { suffix: "high", effort: "high" },
347
- { suffix: "low", effort: "low" },
348
- { suffix: "max", effort: "max" },
349
- { suffix: "none", effort: "none" },
350
- ];
351
-
352
- type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
353
- type CursorEffortMap = Record<PiThinkingLevel, string | null>;
354
-
355
- interface ParsedModelId {
356
- base: string; // model ID with effort stripped
357
- effort: string; // effort level, or "" if no effort suffix
358
- fast: boolean; // has -fast suffix
359
- thinking: boolean; // has -thinking suffix
360
- }
361
-
362
- function stripEffortSuffix(id: string): { remaining: string; effort: string } {
363
- for (const { suffix, effort } of CURSOR_EFFORT_SUFFIXES) {
364
- const marker = `-${suffix}`;
365
- if (id.endsWith(marker)) {
366
- return { remaining: id.slice(0, -marker.length), effort };
367
- }
368
- }
369
- return { remaining: id, effort: "" };
370
- }
371
-
372
- export function parseModelId(id: string): ParsedModelId {
373
- let remaining = id;
374
- let fast = false;
375
- let thinking = false;
376
-
377
- if (remaining.endsWith("-fast")) {
378
- fast = true;
379
- remaining = remaining.slice(0, -5);
380
- }
381
-
382
- // Cursor has used both orders for thinking effort variants:
383
- // claude-4.6-opus-max-thinking (effort before -thinking)
384
- // claude-opus-4-7-thinking-max (effort after -thinking)
385
- let effort: string;
386
- if (remaining.endsWith("-thinking")) {
387
- thinking = true;
388
- remaining = remaining.slice(0, -9);
389
- const parsed = stripEffortSuffix(remaining);
390
- remaining = parsed.remaining;
391
- effort = parsed.effort;
392
- } else {
393
- const parsed = stripEffortSuffix(remaining);
394
- remaining = parsed.remaining;
395
- effort = parsed.effort;
396
- if (remaining.endsWith("-thinking")) {
397
- thinking = true;
398
- remaining = remaining.slice(0, -9);
399
- }
400
- }
401
-
402
- return { base: remaining, effort, fast, thinking };
403
- }
404
-
405
- export interface CursorModelRouting {
406
- modelId: string;
407
- parameters?: CursorModelParameter[];
408
- requiresMaxMode?: boolean;
409
- requestedMaxMode?: boolean;
410
- }
411
-
412
- export interface ProcessedModel extends CursorModel {
413
- supportsEffort: boolean;
414
- effortMap?: CursorEffortMap;
415
- rawModelByEffort?: Record<string, string>;
416
- rawRoutingByEffort?: Record<string, CursorModelRouting>;
417
- }
418
-
419
- export function buildNoReasoningEffortLookup(models: ProcessedModel[]): Map<string, string> {
420
- const lookup = new Map<string, string>();
421
- for (const model of models) {
422
- if (
423
- model.supportsEffort &&
424
- model.effortMap &&
425
- Object.values(model.effortMap).includes("none")
426
- ) {
427
- lookup.set(model.id, "none");
428
- }
429
- }
430
- return lookup;
431
- }
432
-
433
- function routingForModel(model: CursorModel): CursorModelRouting | undefined {
434
- if (
435
- !model.requestedModelId &&
436
- !model.parameters?.length &&
437
- !model.requiresMaxMode &&
438
- typeof model.requestedMaxMode !== "boolean"
439
- ) {
440
- return undefined;
441
- }
442
- return {
443
- modelId: model.requestedModelId ?? model.id,
444
- ...(model.parameters?.length ? { parameters: model.parameters } : {}),
445
- ...(model.requiresMaxMode ? { requiresMaxMode: true } : {}),
446
- ...(typeof model.requestedMaxMode === "boolean"
447
- ? { requestedMaxMode: model.requestedMaxMode }
448
- : {}),
449
- };
450
- }
451
-
452
- function defaultRoutingEffort(model: ProcessedModel): string | undefined {
453
- const routes = model.rawRoutingByEffort;
454
- if (!routes) return undefined;
455
- const mappedMedium = model.effortMap?.medium;
456
- for (const effort of [mappedMedium, "medium", "", "low", "high", "none", "xhigh", "max"]) {
457
- if (typeof effort === "string" && routes[effort]) return effort;
458
- }
459
- return Object.keys(routes)[0];
460
- }
461
-
462
- export function buildRawModelLookup(
463
- models: ProcessedModel[],
464
- ): Map<string, Record<string, CursorModelRouting>> {
465
- const lookup = new Map<string, Record<string, CursorModelRouting>>();
466
- for (const model of models) {
467
- if (model.supportsEffort && model.rawRoutingByEffort) {
468
- const routes = { ...model.rawRoutingByEffort };
469
- if (model.effortMap) {
470
- for (const [piEffort, cursorEffort] of Object.entries(model.effortMap)) {
471
- if (typeof cursorEffort === "string" && !routes[piEffort] && routes[cursorEffort]) {
472
- routes[piEffort] = routes[cursorEffort];
473
- }
474
- }
475
- }
476
- const defaultEffort = defaultRoutingEffort(model);
477
- if (defaultEffort !== undefined && !routes[""])
478
- routes[""] = model.rawRoutingByEffort[defaultEffort]!;
479
- lookup.set(model.id, routes);
480
- continue;
481
- }
482
-
483
- const routing = routingForModel(model);
484
- if (routing) lookup.set(model.id, { "": routing });
485
- }
486
- return lookup;
487
- }
488
-
489
- export function applyRawCursorModelId(
490
- payload: Record<string, unknown>,
491
- rawRoutingByEffortByModelId: Map<string, Record<string, CursorModelRouting>>,
492
- ): void {
493
- if (typeof payload.model !== "string") return;
494
- const rawRoutingByEffort = rawRoutingByEffortByModelId.get(payload.model);
495
- const effort = typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : "";
496
- const routing = rawRoutingByEffort?.[effort];
497
- if (!routing) return;
498
- payload.cursor_model_id = routing.modelId;
499
- if (routing.parameters?.length) payload.cursor_model_parameters = routing.parameters;
500
- if (routing.requiresMaxMode) payload.cursor_requires_max_mode = true;
501
- if (typeof routing.requestedMaxMode === "boolean")
502
- payload.cursor_model_max_mode = routing.requestedMaxMode;
503
- }
504
-
505
- export function applyNoReasoningEffort(
506
- payload: Record<string, unknown>,
507
- thinkingLevel: string,
508
- noReasoningEffortByModelId: Map<string, string>,
509
- ): void {
510
- if (
511
- thinkingLevel !== "off" ||
512
- payload.reasoning_effort !== undefined ||
513
- typeof payload.model !== "string"
514
- )
515
- return;
516
- const noReasoningEffort = noReasoningEffortByModelId.get(payload.model);
517
- if (noReasoningEffort) payload.reasoning_effort = noReasoningEffort;
518
- }
519
-
520
- export function supportsReasoningModelId(id: string): boolean {
521
- const { base, effort, thinking } = parseModelId(id);
522
- if (effort || thinking) return true;
523
- if (base === "default" || base === "auto") return true;
524
- return /^(claude|composer|gemini|gpt|grok|kimi)(-|$)/i.test(base);
525
- }
526
-
527
- /**
528
- * Map only controls Cursor explicitly advertised. Null hides unsupported Pi
529
- * levels instead of silently routing them to a different Cursor effort.
530
- */
531
- export function buildEffortMap(efforts: Set<string>): CursorEffortMap {
532
- const supported = (effort: string): string | null => (efforts.has(effort) ? effort : null);
533
- return {
534
- off: supported("none"),
535
- minimal: supported("minimal"),
536
- low: supported("low"),
537
- // A bare Cursor model ID is the provider's default effort, equivalent to Pi medium.
538
- medium: efforts.has("medium") ? "medium" : supported(""),
539
- high: supported("high"),
540
- xhigh: supported("xhigh"),
541
- max: supported("max"),
542
- };
543
- }
544
-
545
- /** Dedup raw models: collapse effort variants into one entry with supportsReasoningEffort. */
546
- export function processModels(raw: CursorModel[]): ProcessedModel[] {
547
- // Group by (base, fast, thinking)
548
- const groups = new Map<
549
- string,
550
- {
551
- base: string;
552
- fast: boolean;
553
- thinking: boolean;
554
- efforts: Map<string, CursorModel>;
555
- }
556
- >();
557
-
558
- for (const model of raw) {
559
- const p = parseModelId(model.id);
560
- const key = `${p.base}|${p.fast}|${p.thinking}`;
561
- let g = groups.get(key);
562
- if (!g) {
563
- g = { base: p.base, fast: p.fast, thinking: p.thinking, efforts: new Map() };
564
- groups.set(key, g);
565
- }
566
- g.efforts.set(p.effort, model);
567
- }
568
-
569
- const result: ProcessedModel[] = [];
570
-
571
- for (const g of groups.values()) {
572
- const effortNames = new Set(g.efforts.keys());
573
-
574
- // Dedup when there are multiple effort variants, OR a single variant
575
- // whose effort is non-empty (e.g. claude-4.5-opus-high — strip the
576
- // mandatory effort suffix so the model appears as claude-4.5-opus
577
- // with effort mapping).
578
- const hasOnlyEffortVariants = g.efforts.size === 1 && !g.efforts.has("");
579
- const shouldDedup = effortNames.size >= 2 || hasOnlyEffortVariants;
580
- if (shouldDedup) {
581
- // Pick representative: prefer "medium" or default ("") for name/metadata
582
- const rep = g.efforts.get("medium") ?? g.efforts.get("") ?? [...g.efforts.values()][0]!;
583
-
584
- // Build deduped model ID: base + thinking/fast suffix (no effort)
585
- let id = g.base;
586
- if (g.thinking) id += "-thinking";
587
- if (g.fast) id += "-fast";
588
-
589
- const effortMap = buildEffortMap(effortNames);
590
- const rawModelByEffort = Object.fromEntries(
591
- [...g.efforts.entries()].map(([effort, model]) => [effort, model.id]),
592
- );
593
- const rawRoutingByEffort = Object.fromEntries(
594
- [...g.efforts.entries()].map(([effort, model]) => [
595
- effort,
596
- {
597
- modelId: model.requestedModelId ?? model.id,
598
- ...(model.parameters?.length ? { parameters: model.parameters } : {}),
599
- ...(model.requiresMaxMode ? { requiresMaxMode: true } : {}),
600
- ...(typeof model.requestedMaxMode === "boolean"
601
- ? { requestedMaxMode: model.requestedMaxMode }
602
- : {}),
603
- },
604
- ]),
605
- );
606
-
607
- result.push({
608
- ...rep,
609
- id,
610
- supportsEffort: true,
611
- effortMap,
612
- rawModelByEffort,
613
- rawRoutingByEffort,
614
- });
615
- } else {
616
- // Keep single entries as-is (base model without effort variants)
617
- for (const model of g.efforts.values()) {
618
- result.push({ ...model, supportsEffort: false });
619
- }
620
- }
621
- }
622
-
623
- return result.sort((a, b) => a.id.localeCompare(b.id));
624
- }
625
-
626
- export function modelConfig(m: ProcessedModel) {
627
- const input = (m.supportsImages === false ? ["text"] : ["text", "image"]) as ("text" | "image")[];
628
- return {
629
- id: m.id,
630
- name: m.name,
631
- // Pi's thinking control must only appear when Cursor exposed selectable
632
- // effort variants. A model name alone is not evidence of a controllable level.
633
- reasoning: m.supportsEffort,
634
- ...(m.supportsEffort &&
635
- m.effortMap && {
636
- thinkingLevelMap: m.effortMap,
637
- }),
638
- input,
639
- cost: estimateModelCost(m.id),
640
- contextWindow: m.contextWindow,
641
- maxTokens: m.maxTokens,
642
- };
643
- }
644
-
645
- const GPT55_VARIANTS = [
646
- {
647
- idPart: "",
648
- label: "272K",
649
- context: "272k",
650
- contextWindow: 272_000,
651
- requestedMaxMode: false,
652
- fastOptions: [false, true],
653
- },
654
- {
655
- idPart: "-max",
656
- label: "272K Max",
657
- context: "272k",
658
- contextWindow: 272_000,
659
- requestedMaxMode: true,
660
- fastOptions: [false, true],
661
- },
662
- {
663
- idPart: "-1m",
664
- label: "1M",
665
- context: "1m",
666
- contextWindow: 1_000_000,
667
- requestedMaxMode: true,
668
- fastOptions: [false],
669
- },
670
- ] as const;
671
-
672
- const GPT55_REASONING_LEVELS = [
673
- { suffix: "none", label: "None", value: "none" },
674
- { suffix: "low", label: "Low", value: "low" },
675
- { suffix: "medium", label: "", value: "medium" },
676
- { suffix: "high", label: "High", value: "high" },
677
- { suffix: "extra-high", label: "Extra High", value: "extra-high" },
678
- ] as const;
679
-
680
- function gpt55ParameterizedModels(): CursorModel[] {
681
- const models: CursorModel[] = [];
682
- for (const variant of GPT55_VARIANTS) {
683
- // Cursor treats maxMode as an orthogonal request flag. The model picker
684
- // cannot toggle Cursor-specific flags, so expose useful maxMode states as
685
- // explicit rows. Cursor's metadata does not include context=1m + fast=true,
686
- // so the 1M variant intentionally has fast=false only.
687
- for (const fast of variant.fastOptions) {
688
- for (const reasoning of GPT55_REASONING_LEVELS) {
689
- const id = `gpt-5.5${variant.idPart}-${reasoning.suffix}${fast ? "-fast" : ""}`;
690
- const nameParts = ["GPT-5.5", variant.label, reasoning.label, fast ? "Fast" : ""].filter(
691
- Boolean,
692
- );
693
- models.push({
694
- id,
695
- name: nameParts.join(" "),
696
- reasoning: true,
697
- contextWindow: variant.contextWindow,
698
- maxTokens: 64_000,
699
- requestedModelId: "gpt-5.5",
700
- requiresMaxMode: variant.context === "1m",
701
- requestedMaxMode: variant.requestedMaxMode,
702
- parameters: [
703
- { id: "context", value: variant.context },
704
- { id: "reasoning", value: reasoning.value },
705
- { id: "fast", value: String(fast) },
706
- ],
707
- });
708
- }
709
- }
710
- }
711
- return models;
712
- }
713
-
714
- function parameterValue(parameters: CursorModelParameter[], id: string): string | undefined {
715
- return parameters.find((parameter) => parameter.id === id)?.value;
716
- }
717
-
718
- function contextWindowFromParameter(context: string | undefined, fallback = 200_000): number {
719
- if (context === "272k") return 272_000;
720
- if (context === "1m") return 1_000_000;
721
- const k = context?.match(/^(\d+)k$/i)?.[1];
722
- if (k) return Number(k) * 1_000;
723
- const m = context?.match(/^(\d+)m$/i)?.[1];
724
- if (m) return Number(m) * 1_000_000;
725
- return fallback;
726
- }
727
-
728
- function cursorEffortSuffix(value: string): string {
729
- return value;
730
- }
731
-
732
- function cursorEffortLabel(value: string): string {
733
- return (
734
- GPT55_REASONING_LEVELS.find((level) => level.value === value)?.label ||
735
- ({ xhigh: "Extra High", max: "Max", none: "None" } as Record<string, string>)[value] ||
736
- value.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
737
- );
738
- }
739
-
740
- function metadataEffortParameterId(
741
- variant: CursorParameterizedVariant,
742
- ): "reasoning" | "effort" | undefined {
743
- if (variant.parameters.some((parameter) => parameter.id === "reasoning")) return "reasoning";
744
- if (variant.parameters.some((parameter) => parameter.id === "effort")) return "effort";
745
- return undefined;
746
- }
747
-
748
- function isDefaultContext(context: string | undefined): boolean {
749
- if (!context) return true;
750
- return context === "200k" || context === "272k" || context === "300k";
751
- }
752
-
753
- function contextIdPart(context: string | undefined): string {
754
- return context && !isDefaultContext(context) ? `-${context.toLowerCase()}` : "";
755
- }
756
-
757
- function contextLabel(context: string | undefined): string | undefined {
758
- if (!context || isDefaultContext(context)) return undefined;
759
- return context.toUpperCase();
760
- }
761
-
762
- function maxModeIdPart(
763
- modelName: string,
764
- context: string | undefined,
765
- requestedMaxMode: boolean,
766
- hasEffortParameter: boolean,
767
- ): string {
768
- // 1M context already names the Max/extended-context selection. For default
769
- // context windows, expose maxMode as an explicit row suffix. If the Cursor
770
- // model ID already contains "max" (for example gpt-5.1-codex-max), or if
771
- // this row has no effort parameter, use a clearer suffix so the model parser
772
- // does not confuse Max Mode with a Cursor effort value.
773
- if (!requestedMaxMode || context === "1m") return "";
774
- return !hasEffortParameter || /(^|-)max($|-)/i.test(modelName) ? "-max-mode" : "-max";
775
- }
776
-
777
- function maxModeLabel(
778
- modelName: string,
779
- context: string | undefined,
780
- requestedMaxMode: boolean,
781
- hasEffortParameter: boolean,
782
- ): string | undefined {
783
- const idPart = maxModeIdPart(modelName, context, requestedMaxMode, hasEffortParameter);
784
- if (!idPart) return undefined;
785
- return idPart === "-max-mode" ? "Max Mode" : "Max";
786
- }
787
-
788
- function parameterizedBaseId(
789
- modelName: string,
790
- variant: CursorParameterizedVariant,
791
- requestedMaxMode: boolean,
792
- hasEffortParameter: boolean,
793
- ): string {
794
- const context = parameterValue(variant.parameters, "context");
795
- return `${modelName}${contextIdPart(context)}${maxModeIdPart(modelName, context, requestedMaxMode, hasEffortParameter)}`;
796
- }
797
-
798
- function parameterizedBaseLabel(
799
- model: CursorParameterizedModel,
800
- variant: CursorParameterizedVariant,
801
- requestedMaxMode: boolean,
802
- hasEffortParameter: boolean,
803
- ): string[] {
804
- const context = parameterValue(variant.parameters, "context");
805
- return [
806
- model.clientDisplayName || model.name,
807
- contextLabel(context),
808
- maxModeLabel(model.name, context, requestedMaxMode, hasEffortParameter),
809
- ].filter(Boolean) as string[];
810
- }
811
-
812
- function hasVariantParameterSet(
813
- model: CursorParameterizedModel,
814
- parameters: CursorModelParameter[],
815
- ): boolean {
816
- const normalized = normalizeParameterValues(parameters);
817
- return model.variants.some(
818
- (variant) => normalizeParameterValues(variant.parameters) === normalized,
819
- );
820
- }
821
-
822
- function normalizeParameterValues(parameters: CursorModelParameter[]): string {
823
- return parameters
824
- .map((parameter) => `${parameter.id}=${parameter.value}`)
825
- .sort()
826
- .join(";");
827
- }
828
-
829
- function buildParameterizedRowsFromGroup(options: {
830
- model: CursorParameterizedModel;
831
- variants: CursorParameterizedVariant[];
832
- requestedMaxMode: boolean;
833
- effortParameterId?: "reasoning" | "effort";
834
- }): CursorModel[] {
835
- const first = options.variants[0];
836
- if (!first) return [];
837
- if (options.requestedMaxMode && !first.isMaxMode && !options.model.supportsMaxMode) return [];
838
-
839
- const context = parameterValue(first.parameters, "context");
840
- const fast = parameterValue(first.parameters, "fast") === "true";
841
- const thinking = parameterValue(first.parameters, "thinking") === "true";
842
- const hasEffortParameter = Boolean(options.effortParameterId);
843
- const baseId = parameterizedBaseId(
844
- options.model.name,
845
- first,
846
- options.requestedMaxMode,
847
- hasEffortParameter,
848
- );
849
- const baseLabelParts = parameterizedBaseLabel(
850
- options.model,
851
- first,
852
- options.requestedMaxMode,
853
- hasEffortParameter,
854
- );
855
- const contextWindow = contextWindowFromParameter(
856
- context,
857
- options.requestedMaxMode
858
- ? (options.model.contextTokenLimitForMaxMode ?? options.model.contextTokenLimit ?? 200_000)
859
- : (options.model.contextTokenLimit ?? 200_000),
860
- );
861
-
862
- return options.variants.flatMap((variant) => {
863
- const parameters = variant.parameters.map((parameter) => ({
864
- id: parameter.id,
865
- value: parameter.value,
866
- }));
867
- if (!hasVariantParameterSet(options.model, parameters)) return [];
868
-
869
- const effort = options.effortParameterId
870
- ? parameterValue(variant.parameters, options.effortParameterId)
871
- : undefined;
872
- const id = options.effortParameterId
873
- ? `${baseId}-${cursorEffortSuffix(effort!)}${thinking ? "-thinking" : ""}${fast ? "-fast" : ""}`
874
- : `${baseId}${thinking ? "-thinking" : ""}${fast ? "-fast" : ""}`;
875
- const name = [
876
- ...baseLabelParts,
877
- effort ? cursorEffortLabel(effort) : undefined,
878
- thinking ? "Thinking" : undefined,
879
- fast ? "Fast" : undefined,
880
- ]
881
- .filter(Boolean)
882
- .join(" ");
883
-
884
- return [
885
- {
886
- id,
887
- name,
888
- reasoning: Boolean(options.effortParameterId) || thinking,
889
- contextWindow,
890
- maxTokens: 64_000,
891
- requestedModelId: options.model.name,
892
- requiresMaxMode: variant.isMaxMode,
893
- requestedMaxMode: options.requestedMaxMode,
894
- supportsImages: options.model.supportsImages,
895
- parameters,
896
- } satisfies CursorModel,
897
- ];
898
- });
899
- }
900
-
901
- function parameterGroupKey(
902
- variant: CursorParameterizedVariant,
903
- effortParameterId?: string,
904
- ): string {
905
- const params = variant.parameters
906
- .filter((parameter) => parameter.id !== effortParameterId)
907
- .map((parameter) => `${parameter.id}=${parameter.value}`)
908
- .sort()
909
- .join(";");
910
- return `${variant.isMaxMode ? "max" : "nonmax"}|${params}`;
911
- }
912
-
913
- function shouldGenerateSyntheticMaxRows(
914
- model: CursorParameterizedModel,
915
- variant: CursorParameterizedVariant,
916
- ): boolean {
917
- // Cursor's metadata has both per-variant isMaxMode and model-level
918
- // supportsMaxMode. Some supported Max Mode combinations are represented only
919
- // by supportsMaxMode=true over a non-Max parameter set, so expose explicit
920
- // max-mode rows for every such advertised parameter set.
921
- return model.supportsMaxMode === true && !variant.isMaxMode;
922
- }
923
-
924
- export function modelsFromParameterizedMetadata(
925
- parameterizedModels: CursorParameterizedModel[],
926
- ): CursorModel[] {
927
- const rows: CursorModel[] = [];
928
- for (const model of parameterizedModels) {
929
- const groups = new Map<
930
- string,
931
- { effortParameterId?: "reasoning" | "effort"; variants: CursorParameterizedVariant[] }
932
- >();
933
- for (const variant of model.variants) {
934
- if (variant.parameters.length === 0) continue;
935
- const effortParameterId = metadataEffortParameterId(variant);
936
- const key = parameterGroupKey(variant, effortParameterId);
937
- const group = groups.get(key) ?? { effortParameterId, variants: [] };
938
- group.variants.push(variant);
939
- groups.set(key, group);
940
- }
941
-
942
- for (const group of groups.values()) {
943
- const first = group.variants[0];
944
- if (!first) continue;
945
- rows.push(
946
- ...buildParameterizedRowsFromGroup({
947
- model,
948
- variants: group.variants,
949
- requestedMaxMode: first.isMaxMode,
950
- effortParameterId: group.effortParameterId,
951
- }),
952
- );
953
- if (shouldGenerateSyntheticMaxRows(model, first)) {
954
- rows.push(
955
- ...buildParameterizedRowsFromGroup({
956
- model,
957
- variants: group.variants,
958
- requestedMaxMode: true,
959
- effortParameterId: group.effortParameterId,
960
- }),
961
- );
962
- }
963
- }
964
- }
965
- return rows;
966
- }
967
-
968
- function normalizeDisplayModel(model: CursorModel): CursorModel {
969
- if (model.id !== "default") return model;
970
- return {
971
- ...model,
972
- id: "auto",
973
- name: model.name && model.name !== "default" ? model.name : "Auto",
974
- requestedModelId: model.requestedModelId ?? "default",
975
- };
976
- }
977
-
978
- export function augmentCursorModels(
979
- raw: CursorModel[],
980
- parameterizedModels: CursorParameterizedModel[] = [],
981
- ): CursorModel[] {
982
- const byId = new Map<string, CursorModel>();
983
- const imageSupportByModelId = new Map(
984
- parameterizedModels
985
- .filter((model) => typeof model.supportsImages === "boolean")
986
- .map((model) => [model.name, model.supportsImages!]),
987
- );
988
- for (const model of raw.map(normalizeDisplayModel)) {
989
- const lookupId = model.requestedModelId ?? model.id;
990
- const metadataSupportsImages = imageSupportByModelId.get(lookupId);
991
- byId.set(model.id, {
992
- ...model,
993
- ...(model.supportsImages === undefined && metadataSupportsImages !== undefined
994
- ? { supportsImages: metadataSupportsImages }
995
- : {}),
996
- });
997
- }
998
-
999
- const metadataRows =
1000
- modelsFromParameterizedMetadata(parameterizedModels).map(normalizeDisplayModel);
1001
- for (const model of metadataRows) byId.set(model.id, model);
1002
-
1003
- // Fallback for static/offline discovery. Cursor exposes GPT-5.5 context as
1004
- // parameters (272K vs 1M), not distinct backend model IDs.
1005
- if (metadataRows.length === 0 && raw.some((model) => /^gpt-5\.5(?:-|$)/.test(model.id))) {
1006
- for (const model of gpt55ParameterizedModels()) byId.set(model.id, model);
1007
- }
1008
-
1009
- return [...byId.values()];
1010
- }
1011
-
1012
- export const FALLBACK_MODELS: CursorModel[] = augmentCursorModels(
1013
- rawFallbackModels as CursorModel[],
1014
- ).map((model) => ({
1015
- ...model,
1016
- reasoning: supportsReasoningModelId(model.id),
1017
- }));
1018
-
1019
- // ── Extension ──
1020
-
1021
- const CURSOR_PROVIDER_ID = "cursor";
1022
-
1023
- async function getStoredCursorOAuthAccessToken(): Promise<
1024
- { accessToken: string; source: CredentialSource } | undefined
1025
- > {
1026
- const credential = readStoredCredential(CURSOR_PROVIDER_ID);
1027
- if (!credential || credential.type !== "oauth") return undefined;
1028
-
1029
- if (Date.now() < credential.expires && credential.access) {
1030
- return { accessToken: credential.access, source: "pi_oauth" };
1031
- }
1032
-
1033
- // Refresh is handled by Pi's OAuth runtime on demand; avoid writing auth.json here.
1034
- if (credential.refresh) {
1035
- try {
1036
- const refreshed = await refreshCursorToken(credential.refresh);
1037
- return { accessToken: refreshed.access, source: "pi_oauth_refresh" };
1038
- } catch {
1039
- return undefined;
1040
- }
1041
- }
1042
- return undefined;
1043
- }
1044
-
1045
- async function getStartupCursorAccessToken(): Promise<
1046
- { accessToken: string; source: CredentialSource } | undefined
1047
- > {
1048
- const systemToken = await resolveSystemCursorAccessToken();
1049
- if (systemToken) return systemToken;
1050
- return getStoredCursorOAuthAccessToken();
1051
- }
1052
-
1053
- export function registerSessionLifecycleCleanup(pi: ExtensionAPI) {
1054
- const cleanupCurrentSession = (_event: unknown, ctx: ExtensionContext) => {
1055
- debugExtensionLog("session.cleanup_hook", {
1056
- sessionId: ctx.sessionManager.getSessionId(),
1057
- leafId: ctx.sessionManager.getLeafId?.(),
1058
- });
1059
- cleanupSessionState(ctx.sessionManager.getSessionId());
1060
- };
1061
-
1062
- pi.on("session_before_switch", cleanupCurrentSession);
1063
- pi.on("session_before_fork", cleanupCurrentSession);
1064
- pi.on("session_before_tree", cleanupCurrentSession);
1065
- pi.on("session_shutdown", cleanupCurrentSession);
1066
- }
1067
-
1068
- function registerExtensionDebugHooks(pi: ExtensionAPI) {
1069
- if (!isExtensionDebugEnabled()) return;
1070
-
1071
- pi.on("message_start", async (event, ctx) => {
1072
- if (ctx.model?.provider !== "cursor") return;
1073
- debugExtensionLog("message.start", {
1074
- sessionId: ctx.sessionManager.getSessionId(),
1075
- leafId: ctx.sessionManager.getLeafId?.(),
1076
- model: ctx.model?.id,
1077
- message: summarizeMessage((event as { message?: unknown }).message),
1078
- });
1079
- });
1080
-
1081
- pi.on("message_update", async (event, ctx) => {
1082
- if (ctx.model?.provider !== "cursor") return;
1083
- const typedEvent = event as {
1084
- message?: unknown;
1085
- assistantMessageEvent?: Record<string, unknown>;
1086
- };
1087
- debugExtensionLog("message.update", {
1088
- sessionId: ctx.sessionManager.getSessionId(),
1089
- leafId: ctx.sessionManager.getLeafId?.(),
1090
- model: ctx.model?.id,
1091
- assistantMessageEvent: typedEvent.assistantMessageEvent
1092
- ? {
1093
- type: typedEvent.assistantMessageEvent.type,
1094
- delta: truncateDebugValue(
1095
- String(
1096
- (typedEvent.assistantMessageEvent as Record<string, unknown>).delta ??
1097
- (typedEvent.assistantMessageEvent as Record<string, unknown>).content ??
1098
- "",
1099
- ),
1100
- ),
1101
- }
1102
- : undefined,
1103
- message: summarizeMessage(typedEvent.message),
1104
- });
1105
- });
1106
-
1107
- pi.on("message_end", async (event, ctx) => {
1108
- if (ctx.model?.provider !== "cursor") return;
1109
- debugExtensionLog("message.end", {
1110
- sessionId: ctx.sessionManager.getSessionId(),
1111
- leafId: ctx.sessionManager.getLeafId?.(),
1112
- model: ctx.model?.id,
1113
- message: summarizeMessage((event as { message?: unknown }).message),
1114
- branch: summarizeBranchTail(ctx),
1115
- });
1116
- });
1117
-
1118
- pi.on("context", async (event, ctx) => {
1119
- if (ctx.model?.provider !== "cursor") return;
1120
- const typedEvent = event as { messages?: unknown[] };
1121
- debugExtensionLog("context", {
1122
- sessionId: ctx.sessionManager.getSessionId(),
1123
- leafId: ctx.sessionManager.getLeafId?.(),
1124
- model: ctx.model?.id,
1125
- messageCount: Array.isArray(typedEvent.messages) ? typedEvent.messages.length : undefined,
1126
- messages: Array.isArray(typedEvent.messages)
1127
- ? typedEvent.messages.slice(-8).map((message) => summarizeMessage(message))
1128
- : undefined,
1129
- branch: summarizeBranchTail(ctx),
1130
- });
1131
- });
1132
-
1133
- pi.on("turn_end", async (event, ctx) => {
1134
- if (ctx.model?.provider !== "cursor") return;
1135
- const typedEvent = event as { turnIndex?: number; message?: unknown; toolResults?: unknown[] };
1136
- debugExtensionLog("turn.end", {
1137
- sessionId: ctx.sessionManager.getSessionId(),
1138
- leafId: ctx.sessionManager.getLeafId?.(),
1139
- model: ctx.model?.id,
1140
- turnIndex: typedEvent.turnIndex,
1141
- message: summarizeMessage(typedEvent.message),
1142
- toolResults: Array.isArray(typedEvent.toolResults)
1143
- ? typedEvent.toolResults.map((message) => summarizeMessage(message))
1144
- : undefined,
1145
- branch: summarizeBranchTail(ctx),
1146
- });
1147
- });
1148
-
1149
- debugExtensionLog("extension.debug_hooks_registered", {
1150
- logFile: getExtensionDebugLogFilePath(),
1151
- });
1152
- }
1153
-
1154
- export default async function (pi: ExtensionAPI) {
1155
- // Current access token, updated by login/refresh/getApiKey
1156
- let currentToken = "";
1157
- let currentTokenSource: CredentialSource | "none" = "none";
1158
- let noReasoningEffortByModelId = new Map<string, string>();
1159
- let rawModelByEffortByModelId = new Map<string, Record<string, CursorModelRouting>>();
1160
- let lastRegisteredModels: ProcessedModel[] = [];
1161
-
1162
- const getAccessToken = async () => {
1163
- if (!currentToken) {
1164
- const resolved = await getStartupCursorAccessToken();
1165
- if (resolved) {
1166
- currentToken = resolved.accessToken;
1167
- currentTokenSource = resolved.source;
1168
- }
1169
- }
1170
- if (!currentToken)
1171
- throw new Error("Not logged in to Cursor. Run /login cursor or log in via Cursor CLI");
1172
- return currentToken;
1173
- };
1174
-
1175
- const skipDedup = !!process.env.PI_CURSOR_RAW_MODELS;
1176
-
1177
- registerSessionLifecycleCleanup(pi);
1178
- registerExtensionDebugHooks(pi);
1179
- debugExtensionLog("extension.start", {
1180
- mode: "native-streamSimple",
1181
- debugLogFile: isExtensionDebugEnabled() ? getExtensionDebugLogFilePath() : undefined,
1182
- });
1183
-
1184
- const startupModels = await discoverStartupModels();
1185
- register(pi, startupModels.rawModels, startupModels.parameterizedModels);
1186
-
1187
- pi.registerCommand("cursor.models", {
1188
- description: "List Cursor runtime models registered by this provider",
1189
- handler: async (args, ctx: ExtensionCommandContext) => {
1190
- const all = /\ball\b/i.test(args || "");
1191
- const rows = all
1192
- ? lastRegisteredModels
1193
- : lastRegisteredModels.filter((m) => !/tab_|chat_/i.test(m.id));
1194
- const lines = [
1195
- `Cursor models (${rows.length}${all ? " all" : ""})`,
1196
- `endpoint=${getCursorAgentUrl()}`,
1197
- "",
1198
- ];
1199
- const maxId = Math.max(8, ...rows.map((m) => m.id.length));
1200
- for (const m of rows) {
1201
- const processed = m as ProcessedModel;
1202
- const levels = Object.entries(processed.effortMap ?? {})
1203
- .filter(([, cursorEffort]) => typeof cursorEffort === "string")
1204
- .map(([level]) => level)
1205
- .join("/");
1206
- const flags = [
1207
- processed.supportsEffort ? "thinking" : "",
1208
- levels ? `levels=${levels}` : "",
1209
- m.supportsImages ? "images" : "",
1210
- ]
1211
- .filter(Boolean)
1212
- .join(",");
1213
- lines.push(
1214
- `${m.id.padEnd(maxId)} ctx ${String(m.contextWindow ?? "?").padStart(7)} ${m.name || ""}${flags ? ` [${flags}]` : ""}`,
1215
- );
1216
- }
1217
- if (!rows.length) lines.push("No models registered. Run /login cursor first.");
1218
- const text = lines.join("\n");
1219
- if (ctx.hasUI) ctx.ui.notify(text, "info");
1220
- console.log(text);
1221
- },
1222
- });
1223
-
1224
- pi.registerCommand("cursor.usage", {
1225
- description: "Show Cursor plan quota and on-demand spend",
1226
- handler: async (_args, ctx: ExtensionCommandContext) => {
1227
- try {
1228
- const text = formatCursorUsage(await getCursorUsageSummary(getAccessToken));
1229
- if (ctx.hasUI) ctx.ui.notify(text, "info");
1230
- console.log(text);
1231
- } catch (error) {
1232
- const text = `Cursor usage unavailable: ${redactSecrets(
1233
- error instanceof Error ? error.message : String(error),
1234
- )}`;
1235
- if (ctx.hasUI) ctx.ui.notify(text, "error");
1236
- console.error(text);
1237
- }
1238
- },
1239
- });
1240
-
1241
- pi.registerCommand("cursor.doctor", {
1242
- description: "Show sanitized Cursor provider diagnostics",
1243
- handler: async (_args, ctx: ExtensionCommandContext) => {
1244
- const d = getLastDiagnostics();
1245
- const lines = [
1246
- `provider=${CURSOR_PROVIDER_ID}`,
1247
- `agentUrl=${getCursorAgentUrl()}`,
1248
- `tokenSource=${currentTokenSource || "none"}`,
1249
- `lastResolvedRuntimeModel=${d.resolvedRuntimeModel || "none"}`,
1250
- `availableModels=${d.availableModels || lastRegisteredModels.length || "none"}`,
1251
- `matchedModel=${d.matchedModelDebug || "none"}`,
1252
- `lastEndpoint=${d.endpoint || "none"}`,
1253
- `lastStatus=${d.status ?? "none"}`,
1254
- `lastRpc=${d.lastRpc || "none"}`,
1255
- `lastError=${d.error ? redactSecrets(d.error) : "none"}`,
1256
- "transport=native-streamSimple",
1257
- "runtimeCli=not-used",
1258
- "commands=/cursor.models /cursor.usage /cursor.doctor",
1259
- ];
1260
- const text = lines.join("\n");
1261
- if (ctx.hasUI) ctx.ui.notify(`Cursor doctor\n${text}`, "info");
1262
- console.log(text);
1263
- },
1264
- });
1265
-
1266
- async function discoverStartupModels(): Promise<{
1267
- rawModels: CursorModel[];
1268
- parameterizedModels: CursorParameterizedModel[];
1269
- }> {
1270
- if (process.env.PI_OFFLINE) return { rawModels: FALLBACK_MODELS, parameterizedModels: [] };
1271
-
1272
- let startupToken: { accessToken: string; source: CredentialSource } | undefined;
1273
- try {
1274
- startupToken = await getStartupCursorAccessToken();
1275
- } catch (err) {
1276
- debugExtensionLog("model_discovery.startup.token_failed", {
1277
- message: err instanceof Error ? err.message : String(err),
1278
- });
1279
- }
1280
-
1281
- if (!startupToken) {
1282
- debugExtensionLog("model_discovery.startup.skipped", { reason: "no_cursor_oauth_token" });
1283
- return { rawModels: FALLBACK_MODELS, parameterizedModels: [] };
1284
- }
1285
-
1286
- try {
1287
- currentToken = startupToken.accessToken;
1288
- currentTokenSource = startupToken.source;
1289
- const [discovered, parameterized] = await Promise.all([
1290
- getCursorModels(startupToken.accessToken),
1291
- getCursorParameterizedModels(startupToken.accessToken),
1292
- ]);
1293
- debugExtensionLog("model_discovery.startup", {
1294
- tokenSource: startupToken.source,
1295
- discoveredCount: discovered.length,
1296
- parameterizedCount: parameterized.length,
1297
- });
1298
- if (discovered.length > 0 || parameterized.length > 0) {
1299
- return {
1300
- rawModels: discovered.length > 0 ? discovered : FALLBACK_MODELS,
1301
- parameterizedModels: parameterized,
1302
- };
1303
- }
1304
- } catch (err) {
1305
- debugExtensionLog("model_discovery.startup.failed", {
1306
- tokenSource: startupToken.source,
1307
- message: err instanceof Error ? err.message : String(err),
1308
- });
1309
- }
1310
-
1311
- return { rawModels: FALLBACK_MODELS, parameterizedModels: [] };
1312
- }
1313
-
1314
- function register(
1315
- pi: ExtensionAPI,
1316
- rawModels: CursorModel[],
1317
- parameterizedModels: CursorParameterizedModel[] = [],
1318
- ) {
1319
- const augmentedModels = augmentCursorModels(rawModels, parameterizedModels);
1320
- const processed = skipDedup
1321
- ? augmentedModels.map((m) => ({ ...m, supportsEffort: false }) as ProcessedModel)
1322
- : processModels(augmentedModels);
1323
- lastRegisteredModels = processed;
1324
- setLastAvailableModels(
1325
- processed
1326
- .map((m) => m.id)
1327
- .slice(0, 24)
1328
- .join(","),
1329
- );
1330
- noReasoningEffortByModelId = buildNoReasoningEffortLookup(processed);
1331
- rawModelByEffortByModelId = buildRawModelLookup(processed);
1332
-
1333
- pi.registerProvider("cursor", {
1334
- baseUrl: getCursorAgentUrl(),
1335
- api: "cursor-native",
1336
- streamSimple: createCursorNativeStream({
1337
- getAccessToken,
1338
- getNoReasoningEffortByModelId: () => noReasoningEffortByModelId,
1339
- getRawModelRoutingByModelId: () => rawModelByEffortByModelId,
1340
- }),
1341
- models: processed.map(modelConfig),
1342
- oauth: {
1343
- name: "Cursor",
1344
-
1345
- async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
1346
- const { verifier, uuid, loginUrl } = await generateCursorAuthParams();
1347
- callbacks.onAuth({ url: loginUrl });
1348
- const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier);
1349
- currentToken = accessToken;
1350
- currentTokenSource = "pi_oauth";
1351
-
1352
- // Discover real models and re-register
1353
- const [discovered, parameterized] = await Promise.all([
1354
- getCursorModels(accessToken),
1355
- getCursorParameterizedModels(accessToken),
1356
- ]);
1357
- if (discovered.length > 0 || parameterized.length > 0) {
1358
- register(pi, discovered.length > 0 ? discovered : FALLBACK_MODELS, parameterized);
1359
- }
1360
-
1361
- return {
1362
- refresh: refreshToken,
1363
- access: accessToken,
1364
- expires: getTokenExpiry(accessToken),
1365
- };
1366
- },
1367
-
1368
- async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
1369
- const refreshed = await refreshCursorToken(credentials.refresh);
1370
- currentToken = refreshed.access;
1371
- currentTokenSource = "pi_oauth_refresh";
1372
-
1373
- // Discover real models on refresh too
1374
- const [discovered, parameterized] = await Promise.all([
1375
- getCursorModels(refreshed.access),
1376
- getCursorParameterizedModels(refreshed.access),
1377
- ]);
1378
- if (discovered.length > 0 || parameterized.length > 0) {
1379
- register(pi, discovered.length > 0 ? discovered : FALLBACK_MODELS, parameterized);
1380
- }
1381
-
1382
- return refreshed as OAuthCredentials;
1383
- },
1384
-
1385
- getApiKey(credentials: OAuthCredentials): string {
1386
- currentToken = credentials.access;
1387
- currentTokenSource = "pi_oauth";
1388
- return "cursor-native";
1389
- },
1390
- },
1391
- });
1392
- }
1393
- }