@tt-a1i/openpi 0.5.0 → 0.6.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/README.md +18 -10
- package/SETUP.md +8 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/bin/openpi.js +25 -15
- package/extensions/ai-providers/LICENSE.upstream +23 -0
- package/extensions/ai-providers/README.md +59 -0
- package/extensions/ai-providers/antigravity/credentials.ts +52 -0
- package/extensions/ai-providers/antigravity/discovery.ts +130 -0
- package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
- package/extensions/ai-providers/antigravity/models.ts +84 -0
- package/extensions/ai-providers/antigravity/oauth.ts +700 -0
- package/extensions/ai-providers/antigravity/provider.ts +1116 -0
- package/extensions/ai-providers/antigravity/routing.ts +340 -0
- package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
- package/extensions/ai-providers/cursor/constants.ts +5 -0
- package/extensions/ai-providers/cursor/credentials.ts +14 -0
- package/extensions/ai-providers/cursor/discovery.ts +291 -0
- package/extensions/ai-providers/cursor/input-images.ts +106 -0
- package/extensions/ai-providers/cursor/models.ts +45 -0
- package/extensions/ai-providers/cursor/oauth.ts +263 -0
- package/extensions/ai-providers/cursor/proto.ts +1064 -0
- package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
- package/extensions/ai-providers/cursor/provider.ts +1175 -0
- package/extensions/ai-providers/cursor/proxy.ts +213 -0
- package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
- package/extensions/ai-providers/index.ts +86 -0
- package/extensions/ai-providers/oauth-adapter.ts +81 -0
- package/extensions/ai-providers/usage.ts +10 -0
- package/extensions/background-terminals/index.ts +8 -1
- package/extensions/background-terminals/src/manager.ts +3 -5
- package/extensions/background-terminals/src/result-delivery.ts +43 -23
- package/extensions/cron/index.ts +68 -27
- package/extensions/cron/schedule.ts +5 -1
- package/extensions/model-info/cache-diagnostics.ts +220 -0
- package/extensions/model-info/index.ts +45 -1
- package/extensions/plan-mode/index.ts +75 -4
- package/extensions/setup/index.ts +15 -3
- package/extensions/shared/child-session.ts +25 -5
- package/extensions/shared/completion-inbox.ts +193 -0
- package/extensions/shared/setup-config.ts +10 -1
- package/extensions/shared/structured-output.ts +154 -0
- package/extensions/subagents/index.ts +44 -4
- package/extensions/subagents/src/backends/pi.ts +76 -5
- package/extensions/subagents/src/domain.ts +16 -1
- package/extensions/subagents/src/manager.ts +5 -0
- package/extensions/subagents/src/prompt.ts +17 -3
- package/extensions/subagents/src/result-artifact.ts +32 -0
- package/extensions/subagents/src/result-delivery.ts +33 -14
- package/extensions/ui-customization/footer.ts +16 -5
- package/extensions/user-input-fold/index.ts +42 -6
- package/extensions/web/index.ts +25 -2
- package/extensions/workflows/acceptance.ts +43 -19
- package/extensions/workflows/completion-projection.ts +3 -1
- package/extensions/workflows/dashboard.ts +8 -0
- package/extensions/workflows/index.ts +13 -0
- package/extensions/workflows/model.ts +5 -1
- package/extensions/workflows/prompt.ts +4 -10
- package/extensions/workflows/result-delivery.ts +96 -22
- package/extensions/workflows/retention.ts +6 -0
- package/extensions/workflows/runner.ts +6 -71
- package/package.json +7 -7
- package/skills/subagents/REFERENCE.md +3 -2
- package/skills/subagents/SKILL.md +1 -0
- package/skills/workflows/REFERENCE.md +3 -3
- package/skills/workflows/SKILL.md +1 -1
- package/web/adapter/pi-adapter.ts +3 -0
- package/web/host/pi-coding-agent-entry.ts +162 -0
- package/web/host/web-host.ts +330 -50
- package/web/protocol/types.ts +5 -0
- package/web/runtime/pi-runtime.ts +240 -25
- package/web/runtime/types.ts +32 -1
- package/web/ui/app.js +343 -41
- package/web/ui/index.html +3 -0
- package/web/ui/styles.css +119 -37
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { Model, ThinkingLevel } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
|
|
3
|
+
export type AntigravityModelDefinition = Omit<
|
|
4
|
+
Model<string>,
|
|
5
|
+
"api" | "provider" | "baseUrl"
|
|
6
|
+
> & {
|
|
7
|
+
/** Provider-private fields preserved by pi's extension model composer. */
|
|
8
|
+
requestModelId?: string;
|
|
9
|
+
antigravityEffortRouting?: Partial<Record<ThinkingLevel, string>>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Family = {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
members: readonly string[];
|
|
16
|
+
defaultWireId: string;
|
|
17
|
+
routes?: Partial<Record<ThinkingLevel, string>>;
|
|
18
|
+
mode: "budget" | "google-level";
|
|
19
|
+
budgets?: Partial<Record<ThinkingLevel, number>>;
|
|
20
|
+
mandatory?: boolean;
|
|
21
|
+
retiredMembers?: readonly string[];
|
|
22
|
+
preserveAbsentEffortRoutes?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function thinkingPairs(
|
|
26
|
+
pairs: readonly (readonly [id: string, name: string])[],
|
|
27
|
+
): Family[] {
|
|
28
|
+
return pairs.map(([id, name]) => ({
|
|
29
|
+
id,
|
|
30
|
+
name,
|
|
31
|
+
members: [id, `${id}-thinking`],
|
|
32
|
+
defaultWireId: id,
|
|
33
|
+
routes: {
|
|
34
|
+
minimal: `${id}-thinking`,
|
|
35
|
+
low: `${id}-thinking`,
|
|
36
|
+
medium: `${id}-thinking`,
|
|
37
|
+
high: `${id}-thinking`,
|
|
38
|
+
xhigh: `${id}-thinking`,
|
|
39
|
+
max: `${id}-thinking`,
|
|
40
|
+
},
|
|
41
|
+
mode: "budget",
|
|
42
|
+
preserveAbsentEffortRoutes: true,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FAMILIES: readonly Family[] = [
|
|
47
|
+
{
|
|
48
|
+
id: "gemini-3.7-flash",
|
|
49
|
+
name: "Gemini 3.7 Flash",
|
|
50
|
+
members: [
|
|
51
|
+
"gemini-3.7-flash-low",
|
|
52
|
+
"gemini-3.7-flash-medium",
|
|
53
|
+
"gemini-3.7-flash-high",
|
|
54
|
+
],
|
|
55
|
+
defaultWireId: "gemini-3.7-flash-low",
|
|
56
|
+
routes: {
|
|
57
|
+
minimal: "gemini-3.7-flash-low",
|
|
58
|
+
low: "gemini-3.7-flash-low",
|
|
59
|
+
medium: "gemini-3.7-flash-medium",
|
|
60
|
+
high: "gemini-3.7-flash-high",
|
|
61
|
+
xhigh: "gemini-3.7-flash-high",
|
|
62
|
+
max: "gemini-3.7-flash-high",
|
|
63
|
+
},
|
|
64
|
+
mode: "google-level",
|
|
65
|
+
mandatory: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "gemini-3.6-flash",
|
|
69
|
+
name: "Gemini 3.6 Flash",
|
|
70
|
+
members: [
|
|
71
|
+
"gemini-3.6-flash-low",
|
|
72
|
+
"gemini-3.6-flash-medium",
|
|
73
|
+
"gemini-3.6-flash-high",
|
|
74
|
+
"gemini-3.6-flash-tiered",
|
|
75
|
+
],
|
|
76
|
+
defaultWireId: "gemini-3.6-flash-low",
|
|
77
|
+
routes: {
|
|
78
|
+
minimal: "gemini-3.6-flash-low",
|
|
79
|
+
low: "gemini-3.6-flash-low",
|
|
80
|
+
medium: "gemini-3.6-flash-medium",
|
|
81
|
+
high: "gemini-3.6-flash-high",
|
|
82
|
+
xhigh: "gemini-3.6-flash-high",
|
|
83
|
+
max: "gemini-3.6-flash-high",
|
|
84
|
+
},
|
|
85
|
+
mode: "google-level",
|
|
86
|
+
mandatory: true,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "gemini-3.5-flash",
|
|
90
|
+
name: "Gemini 3.5 Flash",
|
|
91
|
+
members: [
|
|
92
|
+
"gemini-3.5-flash-extra-low",
|
|
93
|
+
"gemini-3.5-flash-low",
|
|
94
|
+
"gemini-3-flash-agent",
|
|
95
|
+
],
|
|
96
|
+
defaultWireId: "gemini-3.5-flash-extra-low",
|
|
97
|
+
routes: {
|
|
98
|
+
minimal: "gemini-3.5-flash-extra-low",
|
|
99
|
+
low: "gemini-3.5-flash-extra-low",
|
|
100
|
+
medium: "gemini-3.5-flash-low",
|
|
101
|
+
high: "gemini-3-flash-agent",
|
|
102
|
+
xhigh: "gemini-3-flash-agent",
|
|
103
|
+
max: "gemini-3-flash-agent",
|
|
104
|
+
},
|
|
105
|
+
mode: "budget",
|
|
106
|
+
budgets: {
|
|
107
|
+
minimal: 1_000,
|
|
108
|
+
low: 1_000,
|
|
109
|
+
medium: 4_000,
|
|
110
|
+
high: 10_000,
|
|
111
|
+
xhigh: 10_000,
|
|
112
|
+
max: 10_000,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "gemini-3.1-pro",
|
|
117
|
+
name: "Gemini 3.1 Pro",
|
|
118
|
+
members: [
|
|
119
|
+
"gemini-3.1-pro-low",
|
|
120
|
+
"gemini-pro-agent",
|
|
121
|
+
// Discovery still publishes this deployment, but requests always fail.
|
|
122
|
+
"gemini-3.1-pro-high",
|
|
123
|
+
],
|
|
124
|
+
defaultWireId: "gemini-3.1-pro-low",
|
|
125
|
+
routes: {
|
|
126
|
+
minimal: "gemini-3.1-pro-low",
|
|
127
|
+
low: "gemini-3.1-pro-low",
|
|
128
|
+
medium: "gemini-3.1-pro-low",
|
|
129
|
+
high: "gemini-pro-agent",
|
|
130
|
+
xhigh: "gemini-pro-agent",
|
|
131
|
+
max: "gemini-pro-agent",
|
|
132
|
+
},
|
|
133
|
+
mode: "budget",
|
|
134
|
+
budgets: {
|
|
135
|
+
minimal: 1_001,
|
|
136
|
+
low: 1_001,
|
|
137
|
+
medium: 1_001,
|
|
138
|
+
high: 10_001,
|
|
139
|
+
xhigh: 10_001,
|
|
140
|
+
max: 10_001,
|
|
141
|
+
},
|
|
142
|
+
retiredMembers: ["gemini-3.1-pro-high"],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "gemini-3-pro",
|
|
146
|
+
name: "Gemini 3 Pro",
|
|
147
|
+
members: ["gemini-3-pro-low", "gemini-3-pro-high"],
|
|
148
|
+
defaultWireId: "gemini-3-pro-low",
|
|
149
|
+
routes: {
|
|
150
|
+
minimal: "gemini-3-pro-low",
|
|
151
|
+
low: "gemini-3-pro-low",
|
|
152
|
+
medium: "gemini-3-pro-low",
|
|
153
|
+
high: "gemini-3-pro-high",
|
|
154
|
+
xhigh: "gemini-3-pro-high",
|
|
155
|
+
max: "gemini-3-pro-high",
|
|
156
|
+
},
|
|
157
|
+
mode: "google-level",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "gpt-oss-120b",
|
|
161
|
+
name: "GPT-OSS 120B",
|
|
162
|
+
members: ["gpt-oss-120b-medium"],
|
|
163
|
+
defaultWireId: "gpt-oss-120b-medium",
|
|
164
|
+
mode: "budget",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "claude-sonnet-4-6",
|
|
168
|
+
name: "Claude Sonnet 4.6",
|
|
169
|
+
members: ["claude-sonnet-4-6", "claude-sonnet-4-6-thinking"],
|
|
170
|
+
defaultWireId: "claude-sonnet-4-6",
|
|
171
|
+
mode: "budget",
|
|
172
|
+
retiredMembers: ["claude-sonnet-4-6-thinking"],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "claude-opus-4-6",
|
|
176
|
+
name: "Claude Opus 4.6",
|
|
177
|
+
members: ["claude-opus-4-6-thinking", "claude-opus-4-6"],
|
|
178
|
+
defaultWireId: "claude-opus-4-6-thinking",
|
|
179
|
+
mode: "budget",
|
|
180
|
+
retiredMembers: ["claude-opus-4-6"],
|
|
181
|
+
},
|
|
182
|
+
...thinkingPairs([
|
|
183
|
+
["claude-sonnet-4-5", "Claude Sonnet 4.5"],
|
|
184
|
+
["claude-opus-4-5", "Claude Opus 4.5"],
|
|
185
|
+
["gemini-2.5-flash", "Gemini 2.5 Flash"],
|
|
186
|
+
]),
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const familyById = new Map<string, Family>();
|
|
190
|
+
for (const family of FAMILIES) {
|
|
191
|
+
familyById.set(family.id, family);
|
|
192
|
+
for (const member of family.members) familyById.set(member, family);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function clampLevel(
|
|
196
|
+
level: ThinkingLevel,
|
|
197
|
+
): "minimal" | "low" | "medium" | "high" {
|
|
198
|
+
if (level === "xhigh" || level === "max") return "high";
|
|
199
|
+
return level;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function collapseAntigravityModels<T extends AntigravityModelDefinition>(
|
|
203
|
+
models: readonly T[],
|
|
204
|
+
): T[] {
|
|
205
|
+
const byId = new Map(models.map((model) => [model.id, model]));
|
|
206
|
+
const consumed = new Set<string>();
|
|
207
|
+
const collapsed: T[] = [];
|
|
208
|
+
|
|
209
|
+
for (const model of models) {
|
|
210
|
+
if (consumed.has(model.id)) continue;
|
|
211
|
+
const family = familyById.get(model.id);
|
|
212
|
+
if (!family) {
|
|
213
|
+
collapsed.push(model);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (collapsed.some((entry) => entry.id === family.id)) continue;
|
|
217
|
+
for (const member of family.members) consumed.add(member);
|
|
218
|
+
consumed.add(family.id);
|
|
219
|
+
const logical = byId.get(family.id);
|
|
220
|
+
const source =
|
|
221
|
+
logical ?? family.members.map((id) => byId.get(id)).find(Boolean);
|
|
222
|
+
if (!source) continue;
|
|
223
|
+
const retired = new Set(family.retiredMembers ?? []);
|
|
224
|
+
const liveWireIds = new Set(
|
|
225
|
+
family.members.filter((id) => byId.has(id) && !retired.has(id)),
|
|
226
|
+
);
|
|
227
|
+
if (logical?.requestModelId && !retired.has(logical.requestModelId)) {
|
|
228
|
+
liveWireIds.add(logical.requestModelId);
|
|
229
|
+
} else if (logical && family.members.includes(family.id)) {
|
|
230
|
+
liveWireIds.add(family.id);
|
|
231
|
+
}
|
|
232
|
+
// A discovery response containing only a retired deployment is unusable;
|
|
233
|
+
// consume it without publishing a logical model that cannot be requested.
|
|
234
|
+
if (liveWireIds.size === 0) continue;
|
|
235
|
+
const requestModelId = liveWireIds.has(family.defaultWireId)
|
|
236
|
+
? family.defaultWireId
|
|
237
|
+
: liveWireIds.values().next().value;
|
|
238
|
+
if (!requestModelId) continue;
|
|
239
|
+
const effortRouting: Partial<Record<ThinkingLevel, string>> = {};
|
|
240
|
+
for (const [effort, target] of Object.entries(family.routes ?? {}) as [
|
|
241
|
+
ThinkingLevel,
|
|
242
|
+
string,
|
|
243
|
+
][]) {
|
|
244
|
+
if (
|
|
245
|
+
!retired.has(target) &&
|
|
246
|
+
(liveWireIds.has(target) || family.preserveAbsentEffortRoutes)
|
|
247
|
+
) {
|
|
248
|
+
effortRouting[effort] = target;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
collapsed.push({
|
|
252
|
+
...source,
|
|
253
|
+
id: family.id,
|
|
254
|
+
name: family.name,
|
|
255
|
+
reasoning: true,
|
|
256
|
+
...(requestModelId !== family.id ? { requestModelId } : {}),
|
|
257
|
+
...(Object.keys(effortRouting).length > 0
|
|
258
|
+
? { antigravityEffortRouting: effortRouting }
|
|
259
|
+
: {}),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return collapsed;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function routeAntigravityModel(
|
|
267
|
+
modelId: string,
|
|
268
|
+
reasoning: ThinkingLevel | undefined,
|
|
269
|
+
thinkingBudgets: Partial<Record<ThinkingLevel, number>> | undefined,
|
|
270
|
+
overrides?: Pick<
|
|
271
|
+
AntigravityModelDefinition,
|
|
272
|
+
"requestModelId" | "antigravityEffortRouting"
|
|
273
|
+
>,
|
|
274
|
+
): { wireModelId: string; thinkingConfig?: Record<string, unknown> } {
|
|
275
|
+
const family = familyById.get(modelId);
|
|
276
|
+
if (!family) {
|
|
277
|
+
if (!reasoning) return { wireModelId: modelId };
|
|
278
|
+
if (modelId.toLowerCase().includes("claude")) {
|
|
279
|
+
const level = clampLevel(reasoning);
|
|
280
|
+
const budget =
|
|
281
|
+
thinkingBudgets?.[level] ??
|
|
282
|
+
{
|
|
283
|
+
minimal: 1_024,
|
|
284
|
+
low: 8_192,
|
|
285
|
+
medium: 16_384,
|
|
286
|
+
high: 32_768,
|
|
287
|
+
}[level];
|
|
288
|
+
return {
|
|
289
|
+
wireModelId: modelId,
|
|
290
|
+
thinkingConfig: { includeThoughts: true, thinkingBudget: budget },
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
wireModelId: modelId,
|
|
295
|
+
thinkingConfig: {
|
|
296
|
+
includeThoughts: true,
|
|
297
|
+
thinkingLevel: clampLevel(reasoning).toUpperCase(),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const effective = reasoning ?? (family.mandatory ? "minimal" : undefined);
|
|
303
|
+
const discoveredRouting = overrides?.antigravityEffortRouting;
|
|
304
|
+
const wireModelId = effective
|
|
305
|
+
? ((discoveredRouting
|
|
306
|
+
? (discoveredRouting[effective] ?? overrides?.requestModelId)
|
|
307
|
+
: (family.routes?.[effective] ?? overrides?.requestModelId)) ??
|
|
308
|
+
family.defaultWireId)
|
|
309
|
+
: (overrides?.requestModelId ?? family.defaultWireId);
|
|
310
|
+
if (!effective) {
|
|
311
|
+
return {
|
|
312
|
+
wireModelId,
|
|
313
|
+
thinkingConfig: { includeThoughts: false, thinkingBudget: 0 },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (family.mode === "google-level") {
|
|
317
|
+
const level = clampLevel(effective);
|
|
318
|
+
return {
|
|
319
|
+
wireModelId,
|
|
320
|
+
thinkingConfig: {
|
|
321
|
+
includeThoughts: true,
|
|
322
|
+
thinkingLevel: level === "minimal" ? "LOW" : level.toUpperCase(),
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const level = clampLevel(effective);
|
|
327
|
+
const budget =
|
|
328
|
+
thinkingBudgets?.[level] ??
|
|
329
|
+
family.budgets?.[effective] ??
|
|
330
|
+
{
|
|
331
|
+
minimal: 1_024,
|
|
332
|
+
low: 8_192,
|
|
333
|
+
medium: 16_384,
|
|
334
|
+
high: 32_768,
|
|
335
|
+
}[level];
|
|
336
|
+
return {
|
|
337
|
+
wireModelId,
|
|
338
|
+
thinkingConfig: { includeThoughts: true, thinkingBudget: budget },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promise.withResolvers type shim.
|
|
3
|
+
*
|
|
4
|
+
* The repo tsconfig targets ES2022 libs, but openpi runs on Node >=22.19
|
|
5
|
+
* (package.json engines), where Promise.withResolvers is available since
|
|
6
|
+
* Node 22.0. This declaration matches lib.es2024.promise.withresolvers.d.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface PromiseConstructor {
|
|
11
|
+
withResolvers<T>(): {
|
|
12
|
+
promise: Promise<T>;
|
|
13
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
14
|
+
reject: (reason?: unknown) => void;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Wire constants shared by Cursor OAuth, discovery, and AgentService. */
|
|
2
|
+
export const CURSOR_API_URL = "https://api2.cursor.sh";
|
|
3
|
+
export const CURSOR_CLIENT_VERSION = "cli-2026.07.23-e383d2b";
|
|
4
|
+
export const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run";
|
|
5
|
+
export const CURSOR_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OAuthCredentials } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
|
|
3
|
+
/** Cursor stores the short-lived access JWT and its refresh token together. */
|
|
4
|
+
export type CursorCredentials = OAuthCredentials;
|
|
5
|
+
|
|
6
|
+
/** pi passes the return value of this function to `streamSimple.apiKey`. */
|
|
7
|
+
export function getCursorApiKey(credentials: CursorCredentials): string {
|
|
8
|
+
return credentials.access;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Accept a bare access token for hand-written or older auth.json entries. */
|
|
12
|
+
export function decodeCursorApiKey(value: string | undefined): string {
|
|
13
|
+
return value?.trim() ?? "";
|
|
14
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as http2 from "node:http2";
|
|
3
|
+
import type { Api, Model, RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
CURSOR_API_URL,
|
|
6
|
+
CURSOR_CLIENT_VERSION,
|
|
7
|
+
CURSOR_MODELS_PATH,
|
|
8
|
+
} from "./constants.ts";
|
|
9
|
+
import type { CursorModelDefinition } from "./models.ts";
|
|
10
|
+
import {
|
|
11
|
+
GetUsableModelsRequestSchema,
|
|
12
|
+
GetUsableModelsResponseSchema,
|
|
13
|
+
type ModelDetails,
|
|
14
|
+
} from "./proto.ts";
|
|
15
|
+
import { create, fromBinary, toBinary } from "./protobuf.ts";
|
|
16
|
+
import { connectCursorHttp2 } from "./proxy.ts";
|
|
17
|
+
|
|
18
|
+
export interface CursorModelDiscoveryOptions {
|
|
19
|
+
apiKey: string;
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
clientVersion?: string;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
customModelIds?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FALLBACK_CONTEXT_WINDOW = 200_000;
|
|
28
|
+
const DEFAULT_MAX_TOKENS = 64_000;
|
|
29
|
+
const MAX_DISCOVERY_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
30
|
+
const ONE_MILLION_CONTEXT_WINDOW = 1_000_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* GetUsableModels has no numeric context-window field. Recover 1M only from
|
|
34
|
+
* signals Cursor does send; use 200k as the conservative unknown-model fallback.
|
|
35
|
+
*/
|
|
36
|
+
function resolveContextWindow(details: ModelDetails, id: string): number {
|
|
37
|
+
const labels = [
|
|
38
|
+
id,
|
|
39
|
+
details.displayName,
|
|
40
|
+
details.displayNameShort,
|
|
41
|
+
details.displayModelId,
|
|
42
|
+
...details.aliases,
|
|
43
|
+
].join(" ");
|
|
44
|
+
if (/\b1m\b/i.test(labels)) return ONE_MILLION_CONTEXT_WINDOW;
|
|
45
|
+
if (details.maxMode && /claude|gemini|gpt-5\.6-sol/i.test(id)) {
|
|
46
|
+
return ONE_MILLION_CONTEXT_WINDOW;
|
|
47
|
+
}
|
|
48
|
+
if (isNativeOneMillionModel(id)) return ONE_MILLION_CONTEXT_WINDOW;
|
|
49
|
+
return FALLBACK_CONTEXT_WINDOW;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Cursor serves these coding families with a native, unlabeled 1M window. */
|
|
53
|
+
function isNativeOneMillionModel(id: string): boolean {
|
|
54
|
+
const bareId = id.split("/").at(-1)?.toLowerCase() ?? id.toLowerCase();
|
|
55
|
+
if (/^(?:kimi-)?k3$/.test(bareId)) return true;
|
|
56
|
+
|
|
57
|
+
const glm =
|
|
58
|
+
/^glm-(\d{1,2})(?:\.(\d+))?(v)?(?:-(air|turbo|flashx|flash|preview))?$/.exec(
|
|
59
|
+
bareId,
|
|
60
|
+
);
|
|
61
|
+
if (!glm || glm[3]) return false;
|
|
62
|
+
const variant = glm[4];
|
|
63
|
+
if (variant && variant !== "air" && variant !== "turbo") return false;
|
|
64
|
+
const major = Number(glm[1]);
|
|
65
|
+
const minor = Number(glm[2] ?? 0);
|
|
66
|
+
return major > 5 || (major === 5 && minor >= 2);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fetch account-specific models over Cursor's HTTP/2 Connect endpoint.
|
|
71
|
+
* `null` means transport/protocol failure; an empty successful response is
|
|
72
|
+
* deliberately represented as `[]` so callers can choose their fallback policy.
|
|
73
|
+
*/
|
|
74
|
+
export async function fetchCursorUsableModels(
|
|
75
|
+
options: CursorModelDiscoveryOptions,
|
|
76
|
+
): Promise<CursorModelDefinition[] | null> {
|
|
77
|
+
const token = options.apiKey.trim();
|
|
78
|
+
if (!token || options.signal?.aborted) return null;
|
|
79
|
+
const baseUrl = (options.baseUrl ?? CURSOR_API_URL).replace(/\/+$/, "");
|
|
80
|
+
const request = create(GetUsableModelsRequestSchema, {
|
|
81
|
+
customModelIds: normalizeModelIds(options.customModelIds),
|
|
82
|
+
});
|
|
83
|
+
const requestBytes = toBinary(GetUsableModelsRequestSchema, request);
|
|
84
|
+
const responseBytes = await requestHttp2(
|
|
85
|
+
baseUrl,
|
|
86
|
+
requestBytes,
|
|
87
|
+
token,
|
|
88
|
+
options,
|
|
89
|
+
);
|
|
90
|
+
if (!responseBytes) return null;
|
|
91
|
+
|
|
92
|
+
const payload = decodeUnaryPayload(responseBytes);
|
|
93
|
+
if (!payload) return null;
|
|
94
|
+
let response;
|
|
95
|
+
try {
|
|
96
|
+
response = fromBinary(GetUsableModelsResponseSchema, payload);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const models: CursorModelDefinition[] = [];
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
for (const details of response.models) {
|
|
104
|
+
const normalized = normalizeCursorModel(details, baseUrl);
|
|
105
|
+
if (!normalized || seen.has(normalized.id)) continue;
|
|
106
|
+
seen.add(normalized.id);
|
|
107
|
+
models.push(normalized);
|
|
108
|
+
}
|
|
109
|
+
models.sort((left, right) => left.id.localeCompare(right.id));
|
|
110
|
+
return models;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Provider extension hook: discovery always uses the credential passed by pi. */
|
|
114
|
+
export async function fetchCursorModels(
|
|
115
|
+
context: RefreshModelsContext,
|
|
116
|
+
): Promise<CursorModelDefinition[]> {
|
|
117
|
+
if (!context.allowNetwork) return [];
|
|
118
|
+
context.signal.throwIfAborted();
|
|
119
|
+
const credential = context.credential;
|
|
120
|
+
const apiKey =
|
|
121
|
+
credential?.type === "oauth"
|
|
122
|
+
? credential.access
|
|
123
|
+
: credential?.type === "api_key"
|
|
124
|
+
? credential.key
|
|
125
|
+
: undefined;
|
|
126
|
+
if (!apiKey) return [];
|
|
127
|
+
const discovered = await fetchCursorUsableModels({
|
|
128
|
+
apiKey,
|
|
129
|
+
signal: context.signal,
|
|
130
|
+
});
|
|
131
|
+
context.signal.throwIfAborted();
|
|
132
|
+
if (discovered === null) {
|
|
133
|
+
throw new Error("Cursor model discovery failed");
|
|
134
|
+
}
|
|
135
|
+
return discovered;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeModelIds(ids: readonly string[] | undefined): string[] {
|
|
139
|
+
if (!ids) return [];
|
|
140
|
+
const result = new Set<string>();
|
|
141
|
+
for (const id of ids) {
|
|
142
|
+
if (typeof id !== "string") continue;
|
|
143
|
+
const value = id.trim();
|
|
144
|
+
if (value) result.add(value);
|
|
145
|
+
}
|
|
146
|
+
return [...result];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeCursorModel(
|
|
150
|
+
details: ModelDetails,
|
|
151
|
+
baseUrl: string,
|
|
152
|
+
): CursorModelDefinition | null {
|
|
153
|
+
const id = details.modelId.trim();
|
|
154
|
+
if (!id) return null;
|
|
155
|
+
const name =
|
|
156
|
+
[
|
|
157
|
+
details.displayName,
|
|
158
|
+
details.displayNameShort,
|
|
159
|
+
details.displayModelId,
|
|
160
|
+
...details.aliases,
|
|
161
|
+
]
|
|
162
|
+
.map((value) => value.trim())
|
|
163
|
+
.find(Boolean) ?? id;
|
|
164
|
+
const multimodal = supportsCursorImages(id);
|
|
165
|
+
return {
|
|
166
|
+
id,
|
|
167
|
+
name,
|
|
168
|
+
api: "cursor-agent",
|
|
169
|
+
provider: "cursor",
|
|
170
|
+
baseUrl,
|
|
171
|
+
reasoning: Boolean(details.thinkingDetails) || /^cursor-grok-\d/i.test(id),
|
|
172
|
+
input: multimodal ? ["text", "image"] : ["text"],
|
|
173
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
174
|
+
contextWindow: resolveContextWindow(details, id),
|
|
175
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
176
|
+
...(details.maxMode ? { cursorMaxMode: true } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** GetUsableModels omits modality metadata for Cursor-native image families. */
|
|
181
|
+
function supportsCursorImages(id: string): boolean {
|
|
182
|
+
const lower = id.toLowerCase();
|
|
183
|
+
if (/claude|gemini|gpt-|codex/.test(lower)) return true;
|
|
184
|
+
const bareId = lower.split("/").at(-1) ?? lower;
|
|
185
|
+
return (
|
|
186
|
+
/^(?:kimi-)?k3(?:$|[._:-])/.test(bareId) ||
|
|
187
|
+
/^cursor-grok-4(?:$|[._:-])/.test(bareId) ||
|
|
188
|
+
/^(?:cursor-)?composer-2\.5(?:$|[._:-])/.test(bareId)
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildHeaders(
|
|
193
|
+
apiKey: string,
|
|
194
|
+
clientVersion: string,
|
|
195
|
+
): Record<string, string> {
|
|
196
|
+
return {
|
|
197
|
+
"content-type": "application/proto",
|
|
198
|
+
te: "trailers",
|
|
199
|
+
authorization: `Bearer ${apiKey}`,
|
|
200
|
+
"x-ghost-mode": "true",
|
|
201
|
+
"x-cursor-client-version": clientVersion,
|
|
202
|
+
"x-cursor-client-type": "cli",
|
|
203
|
+
"x-request-id": randomUUID(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function requestHttp2(
|
|
208
|
+
baseUrl: string,
|
|
209
|
+
body: Uint8Array,
|
|
210
|
+
apiKey: string,
|
|
211
|
+
options: CursorModelDiscoveryOptions,
|
|
212
|
+
): Promise<Uint8Array | null> {
|
|
213
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
214
|
+
let client: http2.ClientHttp2Session;
|
|
215
|
+
try {
|
|
216
|
+
client = await connectCursorHttp2(baseUrl, {
|
|
217
|
+
signal: options.signal,
|
|
218
|
+
timeoutMs,
|
|
219
|
+
});
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const { promise, resolve } = Promise.withResolvers<Uint8Array | null>();
|
|
224
|
+
let settled = false;
|
|
225
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
226
|
+
let removeAbortListener: (() => void) | undefined;
|
|
227
|
+
const finish = (result: Uint8Array | null, destroy = false) => {
|
|
228
|
+
if (settled) return;
|
|
229
|
+
settled = true;
|
|
230
|
+
if (timer) clearTimeout(timer);
|
|
231
|
+
removeAbortListener?.();
|
|
232
|
+
if (destroy) client.destroy();
|
|
233
|
+
else client.close();
|
|
234
|
+
resolve(result);
|
|
235
|
+
};
|
|
236
|
+
timer = setTimeout(() => finish(null, true), timeoutMs);
|
|
237
|
+
client.once("error", () => finish(null, true));
|
|
238
|
+
const req = client.request({
|
|
239
|
+
":method": "POST",
|
|
240
|
+
":path": CURSOR_MODELS_PATH,
|
|
241
|
+
...buildHeaders(apiKey, options.clientVersion ?? CURSOR_CLIENT_VERSION),
|
|
242
|
+
});
|
|
243
|
+
const chunks: Buffer[] = [];
|
|
244
|
+
let responseBytes = 0;
|
|
245
|
+
req.on("response", (headers) => {
|
|
246
|
+
const status = Number(headers[":status"] ?? 0);
|
|
247
|
+
if (status < 200 || status >= 300) finish(null, true);
|
|
248
|
+
});
|
|
249
|
+
req.on("data", (chunk: Buffer) => {
|
|
250
|
+
responseBytes += chunk.length;
|
|
251
|
+
if (responseBytes > MAX_DISCOVERY_RESPONSE_BYTES) {
|
|
252
|
+
req.close(http2.constants.NGHTTP2_CANCEL);
|
|
253
|
+
finish(null, true);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
chunks.push(chunk);
|
|
257
|
+
});
|
|
258
|
+
req.on("end", () => finish(new Uint8Array(Buffer.concat(chunks))));
|
|
259
|
+
req.once("error", () => finish(null, true));
|
|
260
|
+
const onAbort = () => {
|
|
261
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
262
|
+
req.close(http2.constants.NGHTTP2_CANCEL);
|
|
263
|
+
finish(null, true);
|
|
264
|
+
};
|
|
265
|
+
if (options.signal) {
|
|
266
|
+
if (options.signal.aborted) onAbort();
|
|
267
|
+
else {
|
|
268
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
269
|
+
removeAbortListener = () =>
|
|
270
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
req.end(Buffer.from(body));
|
|
274
|
+
return promise;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Decode the first uncompressed Connect data frame, or accept raw unary proto. */
|
|
278
|
+
export function decodeUnaryPayload(body: Uint8Array): Uint8Array | null {
|
|
279
|
+
if (body.length === 0) return body;
|
|
280
|
+
if (body.length < 5) return body;
|
|
281
|
+
const flags = body[0]!;
|
|
282
|
+
const size = new DataView(body.buffer, body.byteOffset, 5).getUint32(1);
|
|
283
|
+
const end = 5 + size;
|
|
284
|
+
// Unary Connect responses are normally one data frame followed by an
|
|
285
|
+
// optional end-stream frame. If the prefix is not a valid uncompressed frame,
|
|
286
|
+
// accept the raw protobuf response used by older Cursor deployments.
|
|
287
|
+
if (flags > 3 || end > body.length || (flags & 1) !== 0) return body;
|
|
288
|
+
const data = body.subarray(5, end);
|
|
289
|
+
if ((flags & 2) !== 0) return null;
|
|
290
|
+
return data;
|
|
291
|
+
}
|