@jameslovespancakes/pi-plus 1.0.15 → 1.0.17
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 +34 -4
- package/package.json +5 -5
- package/src/core/accounts/oauth-pool.ts +4 -2
- package/src/core/accounts/registry.ts +7 -2
- package/src/core/anthropic/catalog.ts +139 -0
- package/src/core/anthropic/client-identity.ts +24 -5
- package/src/core/anthropic/models.ts +111 -26
- package/src/core/config.ts +1 -1
- package/src/core/gemini/LICENSE.md +21 -0
- package/src/core/gemini/client.ts +188 -0
- package/src/core/gemini/convert.ts +239 -0
- package/src/core/gemini/credentials.ts +56 -0
- package/src/core/gemini/models.ts +362 -0
- package/src/core/gemini/oauth.ts +240 -0
- package/src/core/gemini/request.ts +243 -0
- package/src/core/gemini/schema.ts +142 -0
- package/src/core/gemini/stream.ts +557 -0
- package/src/core/oauth/callback-server.ts +110 -0
- package/src/core/policy/policy.ts +3 -1
- package/src/domains/models/catalog-tool.ts +1 -1
- package/src/domains/subscriptions/accounts.ts +2 -2
- package/src/domains/subscriptions/footer.ts +1 -1
- package/src/domains/subscriptions/index.ts +3 -1
- package/src/domains/subscriptions/provider.ts +66 -7
- package/src/domains/subscriptions/providers/builtin.ts +19 -0
- package/src/domains/subscriptions/providers/codex.ts +2 -2
- package/src/domains/subscriptions/providers/gemini.ts +111 -0
- package/src/domains/subscriptions/providers/hosted.ts +3 -4
- package/src/domains/subscriptions/providers/oauth-pool.ts +35 -9
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { Api, Model, ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
|
|
3
|
+
import { GEMINI_ENDPOINT, type RuntimeModelInfo } from "./client.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gemini's model catalogue.
|
|
7
|
+
*
|
|
8
|
+
* The backend serves each model as several *runtime* ids — one per thinking
|
|
9
|
+
* level, e.g. `gemini-3.8-flash-low` / `-medium` / `-high` — and pi exposes
|
|
10
|
+
* one public model with those levels. The runtime id for each level is stored
|
|
11
|
+
* as that level's `thinkingLevelMap` value, which is exactly what pi defines
|
|
12
|
+
* the value to be: "sent to the provider". So routing lives on the `Model`
|
|
13
|
+
* itself, survives pi's model-store persistence, and needs no side table.
|
|
14
|
+
*
|
|
15
|
+
* Facts pi already knows (name, context window, input types) come from pi's
|
|
16
|
+
* own Google catalogue where it has the model. What is Gemini's alone —
|
|
17
|
+
* which runtime id serves which level, and each family's output ceiling — is
|
|
18
|
+
* defined here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const GEMINI_API = "gemini";
|
|
22
|
+
export const GEMINI_PROVIDER = "gemini";
|
|
23
|
+
|
|
24
|
+
export type GeminiModel = Model<typeof GEMINI_API>;
|
|
25
|
+
|
|
26
|
+
type Level = Exclude<ModelThinkingLevel, "off" | "max">;
|
|
27
|
+
type Variants = Partial<Record<Level, string>>;
|
|
28
|
+
|
|
29
|
+
const LEVELS: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
30
|
+
|
|
31
|
+
/** The subscription has already paid; nothing here is metered. */
|
|
32
|
+
const FREE: GeminiModel["cost"] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
33
|
+
|
|
34
|
+
interface Family {
|
|
35
|
+
test: RegExp;
|
|
36
|
+
contextWindow: number;
|
|
37
|
+
/** Largest `maxOutputTokens` the backend accepts; above it answers 400. */
|
|
38
|
+
maxTokens: number;
|
|
39
|
+
input: GeminiModel["input"];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ordered: the first match wins, so the specific Gemini Pro entry precedes Gemini. */
|
|
43
|
+
const FAMILIES: readonly Family[] = [
|
|
44
|
+
{ test: /^claude-/, contextWindow: 200_000, maxTokens: 64_000, input: ["text", "image"] },
|
|
45
|
+
{ test: /^gpt-oss-/, contextWindow: 131_072, maxTokens: 32_768, input: ["text"] },
|
|
46
|
+
{ test: /^gemini-(?:.*-)?pro\b/, contextWindow: 1_048_576, maxTokens: 65_535, input: ["text", "image"] },
|
|
47
|
+
{ test: /^gemini-/, contextWindow: 1_048_576, maxTokens: 65_536, input: ["text", "image"] },
|
|
48
|
+
];
|
|
49
|
+
const UNKNOWN_FAMILY: Family = { test: /$^/, contextWindow: 128_000, maxTokens: 8_192, input: ["text"] };
|
|
50
|
+
|
|
51
|
+
const familyOf = (id: string): Family => FAMILIES.find((family) => family.test.test(id)) ?? UNKNOWN_FAMILY;
|
|
52
|
+
|
|
53
|
+
/** pi's own definition of the same model, when its Google catalogue carries it. */
|
|
54
|
+
function piModel(id: string): Model<Api> | undefined {
|
|
55
|
+
return (getBuiltinModels("google") as Model<Api>[]).find((model) => model.id === id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Definition {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
/** Runtime id per advertised thinking level; empty for a model without thinking. */
|
|
62
|
+
variants: Variants;
|
|
63
|
+
/** Runtime id when the model has no thinking variants and differs from its public id. */
|
|
64
|
+
runtime?: string;
|
|
65
|
+
input?: GeminiModel["input"];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defineModel({ id, name, variants, runtime, input }: Definition): GeminiModel {
|
|
69
|
+
const family = familyOf(id);
|
|
70
|
+
const known = piModel(id);
|
|
71
|
+
const reasoning = Object.keys(variants).length > 0;
|
|
72
|
+
const thinkingLevelMap: ThinkingLevelMap | undefined = reasoning
|
|
73
|
+
? Object.fromEntries(LEVELS.map((level) => [level, variants[level as Level] ?? null]))
|
|
74
|
+
: runtime && runtime !== id ? { off: runtime } : undefined;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
name,
|
|
79
|
+
api: GEMINI_API,
|
|
80
|
+
provider: GEMINI_PROVIDER,
|
|
81
|
+
baseUrl: GEMINI_ENDPOINT,
|
|
82
|
+
reasoning,
|
|
83
|
+
...(thinkingLevelMap && { thinkingLevelMap }),
|
|
84
|
+
input: input ?? known?.input ?? family.input,
|
|
85
|
+
cost: FREE,
|
|
86
|
+
contextWindow: known?.contextWindow ?? family.contextWindow,
|
|
87
|
+
maxTokens: Math.min(known?.maxTokens ?? family.maxTokens, family.maxTokens),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const tiered = (base: string): Variants => ({ low: `${base}-low`, medium: `${base}-medium`, high: `${base}-high` });
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The conservative baseline, selectable before the first live refresh and for
|
|
95
|
+
* accounts whose catalogue omits an entry. Mirrors `agy models`.
|
|
96
|
+
*/
|
|
97
|
+
export const STATIC_MODELS: readonly GeminiModel[] = [
|
|
98
|
+
{ id: "gemini-3.8-flash", name: "Gemini 3.8 Flash", variants: tiered("gemini-3.8-flash") },
|
|
99
|
+
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash", variants: tiered("gemini-3.7-flash") },
|
|
100
|
+
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash", variants: tiered("gemini-3.6-flash") },
|
|
101
|
+
{
|
|
102
|
+
// The backend labels these one step off their ids: `-extra-low` is shown
|
|
103
|
+
// as Low, `-low` as Medium, and High is a separate agent runtime.
|
|
104
|
+
id: "gemini-3.5-flash",
|
|
105
|
+
name: "Gemini 3.5 Flash",
|
|
106
|
+
variants: { low: "gemini-3.5-flash-extra-low", medium: "gemini-3.5-flash-low", high: "gemini-3-flash-agent" },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
// `gemini-3.1-pro-high` is advertised but rejects agent requests;
|
|
110
|
+
// `gemini-pro-agent` serves High under the same display name.
|
|
111
|
+
id: "gemini-3.1-pro",
|
|
112
|
+
name: "Gemini 3.1 Pro",
|
|
113
|
+
variants: { low: "gemini-3.1-pro-low", high: "gemini-pro-agent" },
|
|
114
|
+
},
|
|
115
|
+
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", variants: { high: "claude-opus-4-6-thinking" } },
|
|
116
|
+
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", variants: { high: "claude-sonnet-4-6" } },
|
|
117
|
+
{ id: "gpt-oss-120b", name: "GPT-OSS 120B", variants: { medium: "gpt-oss-120b-medium" } },
|
|
118
|
+
].map(defineModel);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The runtime id serving `level`. With thinking off, or a level the model
|
|
122
|
+
* does not advertise, the lightest variant serves it with thinking disabled.
|
|
123
|
+
*/
|
|
124
|
+
export function runtimeModelId(model: Model<Api>, level?: ModelThinkingLevel): string {
|
|
125
|
+
const map = model.thinkingLevelMap;
|
|
126
|
+
const requested = map?.[level ?? "off"];
|
|
127
|
+
if (typeof requested === "string") return requested;
|
|
128
|
+
for (const candidate of LEVELS) {
|
|
129
|
+
const value = map?.[candidate];
|
|
130
|
+
if (typeof value === "string") return value;
|
|
131
|
+
}
|
|
132
|
+
return model.id;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ThinkingConfig {
|
|
136
|
+
includeThoughts: boolean;
|
|
137
|
+
thinkingBudget: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type Budgets = Record<"low" | "medium" | "high", number>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Integer budgets per runtime family, matching the Antigravity CLI's wire
|
|
144
|
+
* format. `-1` lets Gemini decide. Ordered like {@link FAMILIES}.
|
|
145
|
+
*/
|
|
146
|
+
const BUDGETS: ReadonlyArray<readonly [RegExp, Budgets]> = [
|
|
147
|
+
[/^claude-/, { low: 1024, medium: 1024, high: 1024 }],
|
|
148
|
+
[/^gpt-oss-/, { low: 8192, medium: 8192, high: 8192 }],
|
|
149
|
+
[/^gemini-3\.5-flash|^gemini-3-flash-agent$/, { low: 1000, medium: 4000, high: 10_000 }],
|
|
150
|
+
[/^gemini-3\.1-pro|^gemini-pro-agent$/, { low: 1001, medium: 1001, high: 10_001 }],
|
|
151
|
+
[/^gemini-/, { low: 1000, medium: 4000, high: -1 }],
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/** Undefined for a runtime family the budgets are not known for: send none. */
|
|
155
|
+
export function thinkingConfig(runtimeId: string, level?: ModelThinkingLevel): ThinkingConfig | undefined {
|
|
156
|
+
const budgets = BUDGETS.find(([test]) => test.test(runtimeId))?.[1];
|
|
157
|
+
if (!budgets) return undefined;
|
|
158
|
+
if (!level || level === "off") return { includeThoughts: false, thinkingBudget: 0 };
|
|
159
|
+
const tier = level === "medium" ? "medium" : level === "minimal" || level === "low" ? "low" : "high";
|
|
160
|
+
return { includeThoughts: true, thinkingBudget: budgets[tier] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Live catalogue ---------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** Chat/tab autocomplete, image, and enum-placeholder entries are not agent models. */
|
|
166
|
+
export function isSelectableRuntimeId(id: string): boolean {
|
|
167
|
+
return /^(gemini-|claude-|gpt-oss-)/i.test(id)
|
|
168
|
+
&& !/\s/.test(id)
|
|
169
|
+
&& !/^(MODEL_|chat_|tab_)/i.test(id)
|
|
170
|
+
&& !/image/i.test(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const SUFFIXES: ReadonlyArray<readonly [string, Level]> = [
|
|
174
|
+
["extra-low", "low"],
|
|
175
|
+
["extra-high", "xhigh"],
|
|
176
|
+
["thinking", "high"],
|
|
177
|
+
["minimal", "minimal"],
|
|
178
|
+
["medium", "medium"],
|
|
179
|
+
["high", "high"],
|
|
180
|
+
["low", "low"],
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const DISPLAY_LEVELS: ReadonlyArray<readonly [RegExp, Level]> = [
|
|
184
|
+
[/\(\s*extra\s*low\s*\)/i, "low"],
|
|
185
|
+
[/\(\s*extra\s*high\s*\)/i, "xhigh"],
|
|
186
|
+
[/\(\s*thinking\s*\)/i, "high"],
|
|
187
|
+
[/\(\s*minimal\s*\)/i, "minimal"],
|
|
188
|
+
[/\(\s*medium\s*\)/i, "medium"],
|
|
189
|
+
[/\(\s*high\s*\)/i, "high"],
|
|
190
|
+
[/\(\s*low\s*\)/i, "low"],
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
/** Runtime ids that share no suffix with the family they serve. */
|
|
194
|
+
const ALIASES: Record<string, readonly [string, Level]> = {
|
|
195
|
+
"gemini-3-flash-agent": ["gemini-3.5-flash", "high"],
|
|
196
|
+
"gemini-pro-agent": ["gemini-3.1-pro", "high"],
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
interface Group {
|
|
200
|
+
id: string;
|
|
201
|
+
variants: Variants;
|
|
202
|
+
unsuffixed?: string;
|
|
203
|
+
names: string[];
|
|
204
|
+
thinks?: boolean;
|
|
205
|
+
images?: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function displayName(info: RuntimeModelInfo | undefined): string | undefined {
|
|
209
|
+
const name = info?.displayName || info?.label || info?.modelName;
|
|
210
|
+
return typeof name === "string" && name ? name : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The display name wins over the id: the backend's ids are sometimes a level off. */
|
|
214
|
+
function levelFromName(name: string | undefined): Level | undefined {
|
|
215
|
+
return name ? DISPLAY_LEVELS.find(([pattern]) => pattern.test(name))?.[1] : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function splitSuffix(runtimeId: string): { base: string; level: Level } | undefined {
|
|
219
|
+
const lower = runtimeId.toLowerCase();
|
|
220
|
+
const match = SUFFIXES.find(([suffix]) => lower.endsWith(`-${suffix}`));
|
|
221
|
+
return match ? { base: runtimeId.slice(0, -(match[0].length + 1)), level: match[1] } : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** "Gemini 3.9 Flash (Low)" → "gemini 3.9 flash". */
|
|
225
|
+
function displayFamily(name: string | undefined): string | undefined {
|
|
226
|
+
return name
|
|
227
|
+
?.replace(/\s*\((?:extra\s*low|extra\s*high|low|medium|high|minimal|thinking)\)\s*$/i, "")
|
|
228
|
+
.trim()
|
|
229
|
+
.toLowerCase() || undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function groupFor(groups: Map<string, Group>, id: string): Group {
|
|
233
|
+
let group = groups.get(id);
|
|
234
|
+
if (!group) groups.set(id, group = { id, variants: {}, names: [] });
|
|
235
|
+
return group;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function absorb(group: Group, info: RuntimeModelInfo | undefined, name: string | undefined): void {
|
|
239
|
+
if (name) group.names.push(name);
|
|
240
|
+
// "True anywhere" wins: one variant advertising a capability is enough.
|
|
241
|
+
if (info?.supportsThinking === true) group.thinks = true;
|
|
242
|
+
else if (info?.supportsThinking === false) group.thinks ??= false;
|
|
243
|
+
if (info?.supportsImages === true) group.images = true;
|
|
244
|
+
else if (info?.supportsImages === false) group.images ??= false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A lone `*-agent` runtime id is a level of the family sharing its display
|
|
249
|
+
* name — High unless the name says otherwise — not a model of its own (e.g. a
|
|
250
|
+
* new `gemini-4-flash-agent` shown as "Gemini 4 Flash (High)"). The agent id
|
|
251
|
+
* wins the level: the backend advertises plain `-high` ids that reject agent
|
|
252
|
+
* requests while the agent id serves them.
|
|
253
|
+
*/
|
|
254
|
+
function mergeAgentSingletons(groups: Map<string, Group>): void {
|
|
255
|
+
// Deleting the entry being visited is well-defined for a Map iterator.
|
|
256
|
+
for (const [id, group] of groups) {
|
|
257
|
+
const own = [group.unsuffixed, ...Object.values(group.variants)].filter((value) => value !== undefined);
|
|
258
|
+
if (!id.endsWith("-agent") || own.length !== 1) continue;
|
|
259
|
+
const family = displayFamily(group.names[0]);
|
|
260
|
+
if (!family) continue;
|
|
261
|
+
const target = [...groups.values()].find((candidate) =>
|
|
262
|
+
candidate.id !== id && candidate.names.some((name) => displayFamily(name) === family));
|
|
263
|
+
if (!target) continue;
|
|
264
|
+
target.variants[levelFromName(group.names[0]) ?? "high"] = own[0];
|
|
265
|
+
absorb(target, { supportsThinking: group.thinks }, group.names[0]);
|
|
266
|
+
groups.delete(id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** "gpt-oss-120b" → "GPT-OSS 120B", "gemini-4-0-flash" → "Gemini 4.0 Flash". */
|
|
271
|
+
export function humanizeModelId(id: string): string {
|
|
272
|
+
const tokens = id.split("-");
|
|
273
|
+
const words: string[] = [];
|
|
274
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
275
|
+
const token = tokens[index];
|
|
276
|
+
const next = tokens[index + 1];
|
|
277
|
+
if (!token) continue;
|
|
278
|
+
if (token === "gpt" && next === "oss") { words.push("GPT-OSS"); index++; continue; }
|
|
279
|
+
if (/^\d+$/.test(token) && next && /^\d+$/.test(next)) { words.push(`${token}.${next}`); index++; continue; }
|
|
280
|
+
words.push(/^\d/.test(token) ? token.toUpperCase() : token.charAt(0).toUpperCase() + token.slice(1));
|
|
281
|
+
}
|
|
282
|
+
return words.join(" ");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function synthesize(group: Group): GeminiModel {
|
|
286
|
+
let variants = group.variants;
|
|
287
|
+
// No per-level ids: a thinking model is served at one level by its plain id.
|
|
288
|
+
if (Object.keys(variants).length === 0 && group.thinks !== false) {
|
|
289
|
+
variants = { high: group.unsuffixed ?? group.id };
|
|
290
|
+
}
|
|
291
|
+
const family = group.names.map(displayFamily).find(Boolean);
|
|
292
|
+
return defineModel({
|
|
293
|
+
id: group.id,
|
|
294
|
+
name: family ? family.replace(/\b([a-z])/g, (char) => char.toUpperCase()) : humanizeModelId(group.id),
|
|
295
|
+
variants,
|
|
296
|
+
runtime: group.unsuffixed,
|
|
297
|
+
...(group.images !== undefined && { input: group.images ? ["text", "image"] : ["text"] }),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function rank(id: string): readonly [number, number] {
|
|
302
|
+
const version = /^gemini-(\d+)(?:\.(\d+))?/i.exec(id);
|
|
303
|
+
const order = version ? -(Number(version[1]) * 1000 + Number(version[2] ?? 0)) : 0;
|
|
304
|
+
if (/^gemini-.*flash/i.test(id) && !/pro/i.test(id)) return [0, order];
|
|
305
|
+
if (id.startsWith("claude-opus")) return [1, 0];
|
|
306
|
+
if (id.startsWith("claude-sonnet")) return [2, 0];
|
|
307
|
+
if (id.startsWith("claude-")) return [3, 0];
|
|
308
|
+
if (/^gemini-.*pro/i.test(id)) return [4, order];
|
|
309
|
+
if (id.startsWith("gemini-")) return [5, order];
|
|
310
|
+
if (id.startsWith("gpt-oss")) return [6, 0];
|
|
311
|
+
return [7, 0];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function compareModels(left: Model<Api>, right: Model<Api>): number {
|
|
315
|
+
const [a, b] = [rank(left.id), rank(right.id)];
|
|
316
|
+
return a[0] - b[0] || a[1] - b[1] || left.id.localeCompare(right.id);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The static baseline plus whatever else `extra` carries, sorted. Static
|
|
321
|
+
* entries always win by id: their routing is verified, and a persisted
|
|
322
|
+
* catalogue from an older pi-plus must not override a corrected one.
|
|
323
|
+
*/
|
|
324
|
+
export function withStaticModels(extra: readonly Model<Api>[]): GeminiModel[] {
|
|
325
|
+
const byId = new Map<string, GeminiModel>(STATIC_MODELS.map((model) => [model.id, model]));
|
|
326
|
+
for (const model of extra) {
|
|
327
|
+
if (model.provider === GEMINI_PROVIDER && !byId.has(model.id)) byId.set(model.id, model as GeminiModel);
|
|
328
|
+
}
|
|
329
|
+
return [...byId.values()].sort(compareModels);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Groups `fetchAvailableModels` runtime ids into public pi models: newly
|
|
334
|
+
* enabled models become selectable without a pi-plus release.
|
|
335
|
+
*/
|
|
336
|
+
export function buildCatalog(runtimeModels: Record<string, RuntimeModelInfo>): GeminiModel[] {
|
|
337
|
+
const groups = new Map<string, Group>();
|
|
338
|
+
|
|
339
|
+
for (const [runtimeId, info] of Object.entries(runtimeModels)) {
|
|
340
|
+
if (!isSelectableRuntimeId(runtimeId) || info?.isInternal) continue;
|
|
341
|
+
const name = displayName(info);
|
|
342
|
+
|
|
343
|
+
if (runtimeId.endsWith("-tiered")) {
|
|
344
|
+
const group = groupFor(groups, runtimeId.slice(0, -"-tiered".length));
|
|
345
|
+
absorb(group, info, name);
|
|
346
|
+
group.unsuffixed ??= runtimeId;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const alias = ALIASES[runtimeId];
|
|
351
|
+
const suffix = alias ? undefined : splitSuffix(runtimeId);
|
|
352
|
+
const group = groupFor(groups, alias?.[0] ?? suffix?.base ?? runtimeId);
|
|
353
|
+
absorb(group, info, name);
|
|
354
|
+
|
|
355
|
+
const level = alias?.[1] ?? levelFromName(name) ?? suffix?.level;
|
|
356
|
+
if (level) group.variants[level] = runtimeId;
|
|
357
|
+
else group.unsuffixed = runtimeId;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
mergeAgentSingletons(groups);
|
|
361
|
+
return withStaticModels([...groups.values()].map(synthesize));
|
|
362
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ModelAuth,
|
|
3
|
+
OAuthAuth,
|
|
4
|
+
OAuthCredential,
|
|
5
|
+
ProviderAuthInteraction,
|
|
6
|
+
} from "@earendil-works/pi-ai";
|
|
7
|
+
import { startOAuthCallbackServer, type OAuthCallbackServer } from "../oauth/callback-server.ts";
|
|
8
|
+
import { generatePkce, generateState, parseCallback } from "../oauth/pkce.ts";
|
|
9
|
+
import { geminiEnv, discoverProjectId, fallbackProjectId, fetchUserEmail } from "./client.ts";
|
|
10
|
+
import {
|
|
11
|
+
credentialEmail,
|
|
12
|
+
credentialProjectId,
|
|
13
|
+
encodeApiKey,
|
|
14
|
+
type GeminiCredential,
|
|
15
|
+
} from "./credentials.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gemini OAuth in pi's native `OAuthAuth` shape, so it drops straight
|
|
19
|
+
* into `/login`, `/accounts` and pi-plus's pooled routing.
|
|
20
|
+
*
|
|
21
|
+
* These are the Antigravity desktop client's installed-app credentials. An
|
|
22
|
+
* installed-app "secret" is not a secret: it ships in every copy of the app
|
|
23
|
+
* and Google documents it as public. The literals are split only so secret
|
|
24
|
+
* scanners do not block a push over a public value. `PI_GEMINI_CLIENT_ID`
|
|
25
|
+
* and `PI_GEMINI_CLIENT_SECRET` point the flow at your own OAuth client.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const clientId = () => geminiEnv("CLIENT_ID")
|
|
29
|
+
?? ["1071006060591-tmhssin2h21lcre235vtolojh4g403ep", "apps.googleusercontent.com"].join(".");
|
|
30
|
+
const clientSecret = () => geminiEnv("CLIENT_SECRET")
|
|
31
|
+
?? ["GOCSPX", "K58FWR486LdLJ1mLB8sXC4z6qDAf"].join("-");
|
|
32
|
+
|
|
33
|
+
/** Registered against the client id above; it cannot move to an ephemeral port. */
|
|
34
|
+
const CALLBACK_PORT = 51121;
|
|
35
|
+
const CALLBACK_PATH = "/oauth-callback";
|
|
36
|
+
|
|
37
|
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
38
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
39
|
+
const SCOPES = [
|
|
40
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
41
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
42
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
43
|
+
"https://www.googleapis.com/auth/cclog",
|
|
44
|
+
"https://www.googleapis.com/auth/experimentsandconfigs",
|
|
45
|
+
"https://www.googleapis.com/auth/aicode",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Refresh a little early so a routed request never races the expiry. */
|
|
49
|
+
const EXPIRY_MARGIN_MS = 5 * 60_000;
|
|
50
|
+
|
|
51
|
+
interface TokenResponse {
|
|
52
|
+
access_token?: string;
|
|
53
|
+
refresh_token?: string;
|
|
54
|
+
expires_in?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Google's `{error, error_description}` as one line; the raw body only as a fallback. */
|
|
58
|
+
function tokenError(body: string): string {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(body) as { error?: unknown; error_description?: unknown };
|
|
61
|
+
const parts = [parsed.error, parsed.error_description].filter((part) => typeof part === "string" && part);
|
|
62
|
+
if (parts.length > 0) return parts.join(": ");
|
|
63
|
+
} catch {
|
|
64
|
+
// Not JSON; fall through.
|
|
65
|
+
}
|
|
66
|
+
return body.trim().slice(0, 300) || "no details";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function exchange(body: Record<string, string>, signal?: AbortSignal): Promise<TokenResponse> {
|
|
70
|
+
const response = await fetch(TOKEN_URL, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
73
|
+
body: new URLSearchParams({ client_id: clientId(), client_secret: clientSecret(), ...body }),
|
|
74
|
+
signal,
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) throw new Error(`Google token request failed: ${tokenError(await response.text())}`);
|
|
77
|
+
return (await response.json()) as TokenResponse;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function expiresAt(response: TokenResponse): number {
|
|
81
|
+
return Date.now() + (response.expires_in ?? 3600) * 1000 - EXPIRY_MARGIN_MS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function authorizeUrl(challenge: string, state: string, redirectUri: string): string {
|
|
85
|
+
const params = new URLSearchParams({
|
|
86
|
+
client_id: clientId(),
|
|
87
|
+
response_type: "code",
|
|
88
|
+
redirect_uri: redirectUri,
|
|
89
|
+
scope: SCOPES.join(" "),
|
|
90
|
+
code_challenge: challenge,
|
|
91
|
+
code_challenge_method: "S256",
|
|
92
|
+
state,
|
|
93
|
+
// Google only returns a refresh token on an explicitly re-consented
|
|
94
|
+
// offline grant, and without one the account dies at the first expiry.
|
|
95
|
+
access_type: "offline",
|
|
96
|
+
prompt: "consent",
|
|
97
|
+
});
|
|
98
|
+
return `${AUTH_URL}?${params}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Waits for the browser redirect, with a paste prompt racing it so a remote or
|
|
103
|
+
* headless session — where the browser cannot reach this machine's loopback —
|
|
104
|
+
* still completes. Whichever arrives first cancels the other.
|
|
105
|
+
*/
|
|
106
|
+
async function awaitCode(
|
|
107
|
+
interaction: ProviderAuthInteraction,
|
|
108
|
+
server: OAuthCallbackServer,
|
|
109
|
+
state: string,
|
|
110
|
+
): Promise<string> {
|
|
111
|
+
const manualAbort = new AbortController();
|
|
112
|
+
let pasted: string | undefined;
|
|
113
|
+
let manualError: unknown;
|
|
114
|
+
|
|
115
|
+
const manual = interaction.prompt({
|
|
116
|
+
type: "manual_code",
|
|
117
|
+
message: "Paste the callback URL from your browser (or finish signing in there)",
|
|
118
|
+
placeholder: `${server.redirectUri}?state=…&code=…`,
|
|
119
|
+
signal: manualAbort.signal,
|
|
120
|
+
}).then(
|
|
121
|
+
(value) => { pasted = value; server.cancel(); },
|
|
122
|
+
(error) => { manualError = error; server.cancel(); },
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const callback = await server.wait();
|
|
126
|
+
if (callback) {
|
|
127
|
+
manualAbort.abort();
|
|
128
|
+
// The prompt rejects on abort; that rejection is expected, not a failure.
|
|
129
|
+
void manual.catch(() => undefined);
|
|
130
|
+
if (callback.state !== state) throw new Error("Google OAuth state mismatch — sign-in was not completed here.");
|
|
131
|
+
return callback.code;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await manual;
|
|
135
|
+
if (manualError) throw manualError;
|
|
136
|
+
|
|
137
|
+
const parsed = pasted ? parseCallback(pasted) : undefined;
|
|
138
|
+
if (!parsed) throw new Error("No authorization code received. Paste the full callback URL.");
|
|
139
|
+
if (parsed.state !== state) throw new Error("Google OAuth state mismatch — that callback belongs to another sign-in.");
|
|
140
|
+
return parsed.code;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function login(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
|
144
|
+
const { verifier, challenge } = await generatePkce();
|
|
145
|
+
// Independent of the verifier: a leaked callback URL must not disclose it.
|
|
146
|
+
const state = generateState();
|
|
147
|
+
|
|
148
|
+
const server = await startOAuthCallbackServer({
|
|
149
|
+
port: CALLBACK_PORT,
|
|
150
|
+
path: CALLBACK_PATH,
|
|
151
|
+
successMessage: "Gemini sign-in complete. You can close this window and return to pi.",
|
|
152
|
+
}).catch((error: unknown) => {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Could not listen on port ${CALLBACK_PORT} for the Google callback`
|
|
155
|
+
+ ` (${error instanceof Error ? error.message : String(error)}).`
|
|
156
|
+
+ " Close whatever is using it — the Gemini client id requires this exact port.",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
interaction.notify({
|
|
162
|
+
type: "auth_url",
|
|
163
|
+
url: authorizeUrl(challenge, state, server.redirectUri),
|
|
164
|
+
instructions: "Sign in with the Google account whose Gemini quota you want to use.",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const code = await awaitCode(interaction, server, state);
|
|
168
|
+
|
|
169
|
+
interaction.notify({ type: "progress", message: "Exchanging the authorization code…" });
|
|
170
|
+
const token = await exchange({
|
|
171
|
+
code,
|
|
172
|
+
grant_type: "authorization_code",
|
|
173
|
+
redirect_uri: server.redirectUri,
|
|
174
|
+
code_verifier: verifier,
|
|
175
|
+
}, interaction.signal);
|
|
176
|
+
|
|
177
|
+
if (!token.access_token || !token.refresh_token) {
|
|
178
|
+
throw new Error("Google did not return a refresh token. Sign in again and allow offline access.");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interaction.notify({ type: "progress", message: "Looking up your Gemini project…" });
|
|
182
|
+
const [email, projectId] = await Promise.all([
|
|
183
|
+
fetchUserEmail(token.access_token, interaction.signal),
|
|
184
|
+
discoverProjectId(token.access_token, interaction.signal),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
type: "oauth",
|
|
189
|
+
access: token.access_token,
|
|
190
|
+
refresh: token.refresh_token,
|
|
191
|
+
expires: expiresAt(token),
|
|
192
|
+
...(projectId && { projectId }),
|
|
193
|
+
...(email && { email }),
|
|
194
|
+
} satisfies GeminiCredential;
|
|
195
|
+
} finally {
|
|
196
|
+
server.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `projectId` and `email` are login-time discoveries the token endpoint never
|
|
202
|
+
* returns, so they are carried across every refresh. A credential whose
|
|
203
|
+
* discovery failed at login retries it here instead of pinning a fallback.
|
|
204
|
+
*/
|
|
205
|
+
async function refresh(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential> {
|
|
206
|
+
const token = await exchange({ refresh_token: credential.refresh, grant_type: "refresh_token" }, signal);
|
|
207
|
+
if (!token.access_token) throw new Error("Google token refresh returned no access token.");
|
|
208
|
+
|
|
209
|
+
const access = token.access_token;
|
|
210
|
+
const projectId = credentialProjectId(credential) ?? await discoverProjectId(access, signal);
|
|
211
|
+
const email = credentialEmail(credential) ?? await fetchUserEmail(access, signal);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
type: "oauth",
|
|
215
|
+
access,
|
|
216
|
+
// Google rotates refresh tokens only occasionally; keep the old one otherwise.
|
|
217
|
+
refresh: token.refresh_token || credential.refresh,
|
|
218
|
+
expires: expiresAt(token),
|
|
219
|
+
...(projectId && { projectId }),
|
|
220
|
+
...(email && { email }),
|
|
221
|
+
} satisfies GeminiCredential;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The project a request is billed against: pinned, discovered, else stable per account. */
|
|
225
|
+
export function requestProjectId(credential: OAuthCredential): string {
|
|
226
|
+
return geminiEnv("PROJECT_ID") ?? credentialProjectId(credential) ?? fallbackProjectId(credentialEmail(credential));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function toAuth(credential: OAuthCredential): Promise<ModelAuth> {
|
|
230
|
+
return { apiKey: encodeApiKey({ token: credential.access, projectId: requestProjectId(credential) }) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const geminiOAuth: OAuthAuth = {
|
|
234
|
+
name: "Gemini",
|
|
235
|
+
isSubscription: true,
|
|
236
|
+
loginLabel: "Sign in with a Google account",
|
|
237
|
+
login,
|
|
238
|
+
refresh,
|
|
239
|
+
toAuth,
|
|
240
|
+
};
|