@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.
@@ -0,0 +1,557 @@
1
+ // Only pi-ai's package root: it is one of the entry points pi supplies to
2
+ // extensions from its own copy. Deep imports have nothing to resolve against
3
+ // on a clean install (see convert.ts).
4
+ import {
5
+ calculateCost,
6
+ clampThinkingLevel,
7
+ createAssistantMessageEventStream,
8
+ formatThrownValue,
9
+ type Api,
10
+ type AssistantMessage,
11
+ type AssistantMessageEventStream,
12
+ type Model,
13
+ type ModelThinkingLevel,
14
+ type ProviderStreams,
15
+ type SimpleStreamOptions,
16
+ type StopReason,
17
+ type StreamOptions,
18
+ type TextContent,
19
+ type ThinkingContent,
20
+ type ToolCall,
21
+ type ToolChoice,
22
+ type TranscriptContext,
23
+ } from "@earendil-works/pi-ai";
24
+ import { geminiHeaders, endpointsFor } from "./client.ts";
25
+ import type { Part } from "./convert.ts";
26
+ import { decodeApiKey } from "./credentials.ts";
27
+ import { GEMINI_API, runtimeModelId } from "./models.ts";
28
+ import { buildRequest } from "./request.ts";
29
+
30
+ /**
31
+ * Gemini streaming transport.
32
+ *
33
+ * The request is Gemini inside an agent envelope, posted to
34
+ * `v1internal:streamGenerateContent`, and every SSE frame comes back wrapped
35
+ * in `.response`. Retrying is pi's: its session retries on the wording
36
+ * below. What is here is what this backend needs beyond that — endpoint fallback, reading quota walls out of the
37
+ * response body, and a watchdog for streams that go silent.
38
+ */
39
+
40
+ export interface GeminiStreamOptions extends StreamOptions {
41
+ /** Level after pi's clamp; undefined means thinking off. */
42
+ reasoning?: ModelThinkingLevel;
43
+ toolChoice?: ToolChoice | "any";
44
+ }
45
+
46
+ /** Worth trying the next endpoint: capacity and rollout differ between them. */
47
+ const ENDPOINT_FALLBACK_STATUS = new Set([403, 404, 429, 500, 502, 503, 504]);
48
+
49
+ /**
50
+ * Defaults when pi passes no `timeoutMs`, which pi defines as covering both
51
+ * the response and stream idleness. The header deadline catches a warm socket
52
+ * that never answers; a healthy stream emits continuously, so the idle
53
+ * deadline catches one that went silent after its headers.
54
+ */
55
+ const HEADER_TIMEOUT_MS = 180_000;
56
+ const STALL_TIMEOUT_MS = 120_000;
57
+
58
+ /**
59
+ * The endpoint occasionally ends a stream having emitted nothing. An empty
60
+ * assistant message stalls the agent loop, so a couple of replays is the
61
+ * difference between "works" and "randomly does nothing".
62
+ */
63
+ const MAX_EMPTY_STREAM_REPLAYS = 2;
64
+ const EMPTY_STREAM_BACKOFF_MS = 500;
65
+
66
+ let toolCallCounter = 0;
67
+
68
+ interface Chunk {
69
+ error?: { message?: string };
70
+ response?: ChunkBody;
71
+ }
72
+
73
+ interface ChunkBody {
74
+ candidates?: { content?: { parts?: Part[] }; finishReason?: string }[];
75
+ usageMetadata?: {
76
+ promptTokenCount?: number;
77
+ candidatesTokenCount?: number;
78
+ thoughtsTokenCount?: number;
79
+ totalTokenCount?: number;
80
+ cachedContentTokenCount?: number;
81
+ };
82
+ responseId?: string;
83
+ }
84
+
85
+ // --- Failure classification ----------------------------------------------
86
+
87
+ export interface Failure {
88
+ message: string;
89
+ /** An account-level limit that will not clear by retrying soon. */
90
+ quotaWall: boolean;
91
+ /** Server-stated delay, from the body: Google does not send `retry-after`. */
92
+ retryAfterSeconds?: number;
93
+ }
94
+
95
+ function backendMessage(body: string): { text: string; retryDelay?: number } {
96
+ try {
97
+ const parsed = JSON.parse(body) as { error?: { message?: unknown; details?: { retryDelay?: unknown }[] } };
98
+ const text = typeof parsed.error?.message === "string" ? parsed.error.message : body;
99
+ const delay = parsed.error?.details?.map((detail) => detail?.retryDelay).find((value) => typeof value === "string");
100
+ const seconds = typeof delay === "string" ? Number.parseFloat(delay) : Number.NaN;
101
+ return { text, ...(Number.isFinite(seconds) && { retryDelay: Math.ceil(seconds) }) };
102
+ } catch {
103
+ return { text: body };
104
+ }
105
+ }
106
+
107
+ /** "2h5m30s", "3 hours", "45m" → seconds. */
108
+ export function parseDuration(text: string): number | undefined {
109
+ let seconds = 0;
110
+ let matched = false;
111
+ for (const [pattern, unit] of [[/(\d+)\s*d/i, 86_400], [/(\d+)\s*h/i, 3_600], [/(\d+)\s*m(?!s)/i, 60], [/(\d+)\s*s/i, 1]] as const) {
112
+ const value = pattern.exec(text)?.[1];
113
+ if (value) { seconds += Number(value) * unit; matched = true; }
114
+ }
115
+ return matched ? seconds : undefined;
116
+ }
117
+
118
+ /**
119
+ * Turns a failed response into an actionable message.
120
+ *
121
+ * The wording is load-bearing: pi retries errors that mention `429` or a 5xx
122
+ * status and never retries `quota exceeded`. A quota wall is therefore
123
+ * phrased as exhaustion, and transient throttling as a rate limit, so pi's
124
+ * own retry policy makes the right call for each.
125
+ */
126
+ export function describeFailure(status: number, body: string, runtimeId: string): Failure {
127
+ const { text, retryDelay } = backendMessage(body);
128
+ const detail = text.trim().replace(/\s+/g, " ").slice(0, 400) || "no details";
129
+
130
+ if (status === 429) {
131
+ const reset = /Resets? in ([^.\n"]+)/i.exec(text)?.[1]?.trim();
132
+ const quotaWall = /Individual quota reached/i.test(text)
133
+ || reset !== undefined
134
+ || (!/rate.?limit/i.test(text) && /quota exceeded|exceeded your|limit reached|reached your|daily limit/i.test(text));
135
+ const retryAfterSeconds = retryDelay ?? (reset ? parseDuration(reset) : undefined);
136
+ return quotaWall
137
+ ? {
138
+ quotaWall,
139
+ retryAfterSeconds,
140
+ message: `Gemini quota exceeded for this account (429)${reset ? `; resets in ${reset}` : ""}.`
141
+ + " Switch models, add an account with /accounts add gemini, or wait for the reset.",
142
+ }
143
+ : { quotaWall, retryAfterSeconds, message: `Gemini rate limited this request (429): ${detail}` };
144
+ }
145
+
146
+ const message = (() => {
147
+ switch (status) {
148
+ case 400:
149
+ return /Invalid JSON payload|Unknown name/i.test(text)
150
+ ? `Gemini rejected the request format (400): ${detail}`
151
+ : `Gemini rejected the request (400): ${detail}`;
152
+ case 401:
153
+ return "Gemini authentication failed (401). Run /login gemini, or /accounts reauth for a pooled account.";
154
+ case 403:
155
+ return `Gemini denied access for this account (403): ${detail}`;
156
+ case 404:
157
+ return /Requested entity was not found/i.test(text)
158
+ ? `Gemini does not serve ${runtimeId} to this account (404). Pick another model with /model.`
159
+ : `Gemini could not find the requested resource (404): ${detail}`;
160
+ case 503:
161
+ return /No capacity available/i.test(text)
162
+ ? `Gemini has no capacity for ${runtimeId} right now (503). Retry shortly or switch models.`
163
+ : `Gemini is temporarily unavailable (503): ${detail}`;
164
+ default:
165
+ return `Gemini request failed (${status}): ${detail}`;
166
+ }
167
+ })();
168
+ return { message, quotaWall: false, ...(retryDelay !== undefined && { retryAfterSeconds: retryDelay }) };
169
+ }
170
+
171
+ /**
172
+ * Response headers plus the retry delay the body stated, in the form the
173
+ * account pool reads when it decides how long to hold an account back.
174
+ */
175
+ function failureHeaders(headers: Headers, failure: Failure): Headers {
176
+ const next = new Headers(headers);
177
+ if (failure.retryAfterSeconds !== undefined && !next.has("retry-after")) {
178
+ next.set("retry-after", String(failure.retryAfterSeconds));
179
+ }
180
+ return next;
181
+ }
182
+
183
+ function toRecord(headers: Headers): Record<string, string> {
184
+ const record: Record<string, string> = {};
185
+ headers.forEach((value, name) => { record[name] = value; });
186
+ return record;
187
+ }
188
+
189
+ /** pi's header rule: later layers win, and a null value removes the header. */
190
+ function mergeHeaders(...layers: Array<Record<string, string | null | undefined> | undefined>): Record<string, string> {
191
+ const merged: Record<string, string | null | undefined> = {};
192
+ for (const layer of layers) Object.assign(merged, layer);
193
+ return Object.fromEntries(Object.entries(merged).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
194
+ }
195
+
196
+ /** "STOP" and "MAX_TOKENS" are successes; every other finish reason is a failure, as in pi. */
197
+ function stopReasonOf(finishReason: string): StopReason {
198
+ return finishReason === "STOP" ? "stop" : finishReason === "MAX_TOKENS" ? "length" : "error";
199
+ }
200
+
201
+ /** Some backends send a signature only on a block's first delta; keep it. */
202
+ const retainSignature = (existing: string | undefined, incoming: string | undefined) =>
203
+ typeof incoming === "string" && incoming.length > 0 ? incoming : existing;
204
+
205
+ // --- Transport -------------------------------------------------------------
206
+
207
+ function pause(ms: number, signal?: AbortSignal): Promise<void> {
208
+ return new Promise((resolve, reject) => {
209
+ if (signal?.aborted) return reject(signal.reason);
210
+ const onAbort = () => { clearTimeout(timer); reject(signal?.reason); };
211
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
212
+ signal?.addEventListener("abort", onAbort, { once: true });
213
+ });
214
+ }
215
+
216
+ /**
217
+ * fetch with a response-header deadline. The deadline disarms once headers
218
+ * arrive; a long healthy body is never cut. Caller cancellation stays bound
219
+ * to the body until it is consumed.
220
+ */
221
+ async function fetchWithDeadline(
222
+ fetchImpl: typeof fetch,
223
+ url: string,
224
+ init: RequestInit,
225
+ signal: AbortSignal | undefined,
226
+ timeoutMs: number,
227
+ ): Promise<Response> {
228
+ if (timeoutMs <= 0) return fetchImpl(url, { ...init, signal });
229
+ const deadline = new AbortController();
230
+ const timer = setTimeout(
231
+ () => deadline.abort(new Error(`Gemini timed out: no response headers within ${Math.round(timeoutMs / 1000)}s`)),
232
+ timeoutMs,
233
+ );
234
+ try {
235
+ return await fetchImpl(url, { ...init, signal: signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal });
236
+ } catch (error) {
237
+ throw deadline.signal.aborted && !signal?.aborted ? deadline.signal.reason : error;
238
+ } finally {
239
+ clearTimeout(timer);
240
+ }
241
+ }
242
+
243
+ /** A read that fails instead of hanging when the stream goes silent. */
244
+ async function readWithin<T>(read: Promise<T>, ms: number): Promise<T> {
245
+ if (ms <= 0) return read;
246
+ let timer: ReturnType<typeof setTimeout> | undefined;
247
+ const stalled = new Promise<never>((_, reject) => {
248
+ timer = setTimeout(() => reject(new Error(`Gemini stream timed out: no data for ${Math.round(ms / 1000)}s`)), ms);
249
+ });
250
+ try {
251
+ return await Promise.race([read, stalled]);
252
+ } finally {
253
+ clearTimeout(timer);
254
+ }
255
+ }
256
+
257
+ function emptyUsage(): AssistantMessage["usage"] {
258
+ return {
259
+ input: 0,
260
+ output: 0,
261
+ cacheRead: 0,
262
+ cacheWrite: 0,
263
+ totalTokens: 0,
264
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
265
+ };
266
+ }
267
+
268
+ /** Tool-call ids must be `[A-Za-z0-9_-]{1,64}` to replay on Claude; blanks and repeats get a fresh one. */
269
+ function toolCallId(provided: string | undefined, name: string, taken: (id: string) => boolean): string {
270
+ const cleaned = (provided ?? "").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
271
+ return cleaned && !taken(cleaned) ? cleaned : `${name || "tool"}_${Date.now()}_${++toolCallCounter}`;
272
+ }
273
+
274
+ export const stream = (
275
+ model: Model<Api>,
276
+ context: TranscriptContext,
277
+ options?: GeminiStreamOptions,
278
+ ): AssistantMessageEventStream => {
279
+ const events = createAssistantMessageEventStream();
280
+
281
+ void (async () => {
282
+ const output: AssistantMessage = {
283
+ role: "assistant",
284
+ content: [],
285
+ api: GEMINI_API,
286
+ provider: model.provider,
287
+ model: model.id,
288
+ usage: emptyUsage(),
289
+ stopReason: "stop",
290
+ timestamp: Date.now(),
291
+ };
292
+
293
+ try {
294
+ const { token, projectId } = decodeApiKey(options?.apiKey);
295
+ const runtimeId = runtimeModelId(model, options?.reasoning);
296
+
297
+ let body: unknown = buildRequest(model, context, projectId, options);
298
+ body = (await options?.onPayload?.(body, model)) ?? body;
299
+ const payload = JSON.stringify(body);
300
+
301
+ const headers = mergeHeaders(geminiHeaders(token), model.headers, options?.headers);
302
+ const fetchImpl = options?.fetch ?? globalThis.fetch;
303
+ const headerTimeout = options?.timeoutMs ?? HEADER_TIMEOUT_MS;
304
+ const idleTimeout = options?.timeoutMs ?? STALL_TIMEOUT_MS;
305
+
306
+ /**
307
+ * One attempt across every endpoint. Only the response acted on is
308
+ * reported to `onResponse`: an endpoint that fell through is an internal
309
+ * detail, and reporting its 429 would mark an account that then served
310
+ * the request as exhausted.
311
+ */
312
+ const send = async (): Promise<Response> => {
313
+ let failed: { status: number; headers: Headers; failure: Failure } | undefined;
314
+ for (const endpoint of endpointsFor(model.baseUrl)) {
315
+ options?.signal?.throwIfAborted();
316
+ const response = await fetchWithDeadline(
317
+ fetchImpl,
318
+ `${endpoint}/v1internal:streamGenerateContent?alt=sse`,
319
+ { method: "POST", headers, body: payload },
320
+ options?.signal,
321
+ headerTimeout,
322
+ );
323
+ if (response.ok) {
324
+ await options?.onResponse?.({ status: response.status, headers: toRecord(response.headers) }, model);
325
+ return response;
326
+ }
327
+ const failure = describeFailure(response.status, await response.text(), runtimeId);
328
+ failed = { status: response.status, headers: failureHeaders(response.headers, failure), failure };
329
+ if (failure.quotaWall || !ENDPOINT_FALLBACK_STATUS.has(response.status)) break;
330
+ }
331
+
332
+ const { status, headers: reported, failure } = failed!;
333
+ // Reported with the body's reset time, so the account pool can hold a
334
+ // quota-walled account out of routing until it actually resets.
335
+ await options?.onResponse?.({ status, headers: toRecord(reported) }, model);
336
+ // The shape pi's retry policy reads: `status` plus `Headers`.
337
+ throw Object.assign(new Error(failure.message), { status, headers: reported });
338
+ };
339
+
340
+ let started = false;
341
+ const ensureStarted = () => {
342
+ if (started) return;
343
+ events.push({ type: "start", partial: output });
344
+ started = true;
345
+ };
346
+
347
+ const index = () => output.content.length - 1;
348
+ const closeBlock = (block: TextContent | ThinkingContent | null) => {
349
+ if (!block) return;
350
+ if (block.type === "text") {
351
+ events.push({ type: "text_end", contentIndex: index(), content: block.text, partial: output });
352
+ } else {
353
+ events.push({ type: "thinking_end", contentIndex: index(), content: block.thinking, partial: output });
354
+ }
355
+ };
356
+
357
+ const consume = async (response: Response): Promise<boolean> => {
358
+ if (!response.body) throw new Error("Gemini returned no response body.");
359
+
360
+ let received = false;
361
+ let block: TextContent | ThinkingContent | null = null;
362
+ const reader = response.body.getReader();
363
+ const decoder = new TextDecoder();
364
+ const abort = () => void reader.cancel().catch(() => undefined);
365
+ options?.signal?.addEventListener("abort", abort);
366
+ let buffer = "";
367
+
368
+ const handle = (chunk: Chunk) => {
369
+ if (chunk.error) throw new Error(`Gemini stream error: ${chunk.error.message ?? JSON.stringify(chunk.error)}`);
370
+ const data = chunk.response ?? (chunk as ChunkBody);
371
+ output.responseId ||= data.responseId;
372
+
373
+ const candidate = data.candidates?.[0];
374
+ for (const part of candidate?.content?.parts ?? []) {
375
+ if (part.text !== undefined) {
376
+ received = true;
377
+ const thinking = part.thought === true;
378
+ if (!block || (thinking ? block.type !== "thinking" : block.type !== "text")) {
379
+ closeBlock(block);
380
+ block = thinking
381
+ ? { type: "thinking", thinking: "", thinkingSignature: undefined }
382
+ : { type: "text", text: "" };
383
+ output.content.push(block);
384
+ ensureStarted();
385
+ events.push({ type: thinking ? "thinking_start" : "text_start", contentIndex: index(), partial: output });
386
+ }
387
+
388
+ if (block.type === "thinking") {
389
+ block.thinking += part.text;
390
+ block.thinkingSignature = retainSignature(block.thinkingSignature, part.thoughtSignature);
391
+ events.push({ type: "thinking_delta", contentIndex: index(), delta: part.text, partial: output });
392
+ } else {
393
+ block.text += part.text;
394
+ block.textSignature = retainSignature(block.textSignature, part.thoughtSignature);
395
+ events.push({ type: "text_delta", contentIndex: index(), delta: part.text, partial: output });
396
+ }
397
+ }
398
+
399
+ if (part.functionCall) {
400
+ received = true;
401
+ closeBlock(block);
402
+ block = null;
403
+
404
+ const name = part.functionCall.name ?? "";
405
+ const toolCall: ToolCall = {
406
+ type: "toolCall",
407
+ id: toolCallId(part.functionCall.id, name, (id) =>
408
+ output.content.some((item) => item.type === "toolCall" && item.id === id)),
409
+ name,
410
+ arguments: (part.functionCall.args ?? {}) as ToolCall["arguments"],
411
+ ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
412
+ };
413
+
414
+ output.content.push(toolCall);
415
+ ensureStarted();
416
+ events.push({ type: "toolcall_start", contentIndex: index(), partial: output });
417
+ events.push({ type: "toolcall_delta", contentIndex: index(), delta: JSON.stringify(toolCall.arguments), partial: output });
418
+ events.push({ type: "toolcall_end", contentIndex: index(), toolCall, partial: output });
419
+ }
420
+ }
421
+
422
+ if (candidate?.finishReason) {
423
+ output.rawStopReason = candidate.finishReason;
424
+ output.stopReason = output.content.some((item) => item.type === "toolCall")
425
+ ? "toolUse"
426
+ : stopReasonOf(candidate.finishReason);
427
+ }
428
+
429
+ const usage = data.usageMetadata;
430
+ if (usage) {
431
+ const cacheRead = usage.cachedContentTokenCount ?? 0;
432
+ const reasoning = usage.thoughtsTokenCount ?? 0;
433
+ output.usage = {
434
+ // promptTokenCount already includes the cached tokens.
435
+ input: (usage.promptTokenCount ?? 0) - cacheRead,
436
+ output: (usage.candidatesTokenCount ?? 0) + reasoning,
437
+ reasoning,
438
+ cacheRead,
439
+ cacheWrite: 0,
440
+ totalTokens: usage.totalTokenCount ?? 0,
441
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
442
+ };
443
+ calculateCost(model, output.usage);
444
+ }
445
+ };
446
+
447
+ try {
448
+ for (;;) {
449
+ options?.signal?.throwIfAborted();
450
+ const { done, value } = await readWithin(reader.read(), idleTimeout);
451
+ if (done) break;
452
+
453
+ buffer += decoder.decode(value, { stream: true });
454
+ const lines = buffer.split("\n");
455
+ buffer = lines.pop() ?? "";
456
+
457
+ for (const line of lines) {
458
+ if (!line.startsWith("data:")) continue;
459
+ const json = line.slice(5).trim();
460
+ if (!json || json === "[DONE]") continue;
461
+ let chunk: Chunk;
462
+ try {
463
+ chunk = JSON.parse(json) as Chunk;
464
+ } catch {
465
+ continue; // A partial frame; the next read completes it.
466
+ }
467
+ handle(chunk);
468
+ }
469
+ }
470
+ } catch (error) {
471
+ void reader.cancel().catch(() => undefined);
472
+ throw error;
473
+ } finally {
474
+ options?.signal?.removeEventListener("abort", abort);
475
+ }
476
+
477
+ closeBlock(block);
478
+ return received;
479
+ };
480
+
481
+ let received = false;
482
+ for (let attempt = 0; attempt <= MAX_EMPTY_STREAM_REPLAYS && !received; attempt++) {
483
+ if (attempt > 0) {
484
+ await pause(EMPTY_STREAM_BACKOFF_MS * 2 ** (attempt - 1), options?.signal);
485
+ // Reset rather than append: the replay restates the whole message.
486
+ output.content = [];
487
+ output.usage = emptyUsage();
488
+ output.stopReason = "stop";
489
+ output.rawStopReason = undefined;
490
+ started = false;
491
+ }
492
+ received = await consume(await send());
493
+ }
494
+
495
+ if (!received) throw new Error("Gemini returned an empty response.");
496
+ options?.signal?.throwIfAborted();
497
+ // A terminal stop reason inside a 200 response is still a failure, and
498
+ // the `done` event cannot carry one.
499
+ if (output.stopReason === "aborted" || output.stopReason === "error") {
500
+ throw new Error(`Gemini stopped the response: ${output.rawStopReason ?? "unknown reason"}.`);
501
+ }
502
+
503
+ ensureStarted();
504
+ events.push({
505
+ type: "done",
506
+ reason: output.stopReason as Extract<StopReason, "stop" | "length" | "toolUse" | "deferred">,
507
+ message: output,
508
+ });
509
+ events.end();
510
+ } catch (error) {
511
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
512
+ output.errorMessage = formatThrownValue(error);
513
+ events.push({ type: "error", reason: output.stopReason, error: output });
514
+ events.end();
515
+ }
516
+ })();
517
+
518
+ return events;
519
+ };
520
+
521
+ /** Headroom pi leaves between the estimated context and the window. */
522
+ const CONTEXT_SAFETY_TOKENS = 4096;
523
+
524
+ /**
525
+ * The context already used: the last response's reported usage, plus about
526
+ * four characters per token for anything after it. The same estimate pi uses
527
+ * to keep an output ceiling from pushing a request past the context window.
528
+ */
529
+ function estimateContextTokens(context: TranscriptContext): number {
530
+ const messages = context.messages;
531
+ let index = messages.length - 1;
532
+ while (index >= 0 && !(messages[index].role === "assistant" && (messages[index] as AssistantMessage).usage?.totalTokens)) index--;
533
+ const usage = index >= 0 ? (messages[index] as AssistantMessage).usage : undefined;
534
+ const counted = usage ? usage.input + usage.output + usage.cacheRead + usage.cacheWrite : 0;
535
+ const trailing = messages.slice(index + 1).reduce((sum, message) => sum + JSON.stringify(message).length, 0);
536
+ return counted + Math.ceil(trailing / 4);
537
+ }
538
+
539
+ export const streamSimple = (
540
+ model: Model<Api>,
541
+ context: TranscriptContext,
542
+ options?: SimpleStreamOptions,
543
+ ): AssistantMessageEventStream => {
544
+ const level = options?.reasoning && model.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
545
+ const requested = options?.maxTokens ?? model.maxTokens;
546
+ const room = model.contextWindow - estimateContextTokens(context) - CONTEXT_SAFETY_TOKENS;
547
+ return stream(model, context, {
548
+ ...options,
549
+ maxTokens: model.contextWindow > 0 ? Math.min(requested, Math.max(1, room)) : requested,
550
+ toolChoice: options?.toolChoice,
551
+ reasoning: level === "off" ? undefined : level,
552
+ });
553
+ };
554
+
555
+ export function geminiApi(): ProviderStreams {
556
+ return { stream, streamSimple };
557
+ }
@@ -0,0 +1,110 @@
1
+ import { createServer, type Server } from "node:http";
2
+
3
+ /**
4
+ * One-shot loopback server for an OAuth redirect.
5
+ *
6
+ * pi keeps its own callback servers private (`pi-ai/dist/auth/oauth/*` is not
7
+ * an export path), so providers we add ourselves need this. Nothing here is
8
+ * provider-specific: the port and path come from the caller, because an
9
+ * installed-app client id is registered against one exact redirect URI and
10
+ * cannot use an ephemeral port.
11
+ *
12
+ * `wait()` resolves once — with the callback parameters, or with undefined
13
+ * after `cancel()`, which is how a manual paste prompt takes over on a
14
+ * headless or remote machine where the browser cannot reach this process.
15
+ */
16
+
17
+ export interface OAuthCallback {
18
+ code: string;
19
+ state: string;
20
+ }
21
+
22
+ export interface OAuthCallbackServer {
23
+ readonly redirectUri: string;
24
+ wait(): Promise<OAuthCallback | undefined>;
25
+ /** Unblocks `wait()` with undefined; the caller still has to `close()`. */
26
+ cancel(): void;
27
+ close(): void;
28
+ }
29
+
30
+ export interface OAuthCallbackServerOptions {
31
+ port: number;
32
+ path: string;
33
+ /** Overridable for containers that cannot bind loopback. */
34
+ host?: string;
35
+ successMessage?: string;
36
+ }
37
+
38
+ const STYLE = "font:16px system-ui,sans-serif;max-width:32rem;margin:20vh auto;padding:0 1.5rem;text-align:center";
39
+
40
+ function page(title: string, message: string, details?: string): string {
41
+ const escape = (text: string) =>
42
+ text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
43
+ return `<!doctype html><meta charset="utf-8"><title>${escape(title)}</title>`
44
+ + `<body style="${STYLE}"><h1 style="font-size:1.25rem">${escape(title)}</h1>`
45
+ + `<p>${escape(message)}</p>${details ? `<pre>${escape(details)}</pre>` : ""}</body>`;
46
+ }
47
+
48
+ export function oauthSuccessHtml(message: string): string {
49
+ return page("Signed in", message);
50
+ }
51
+
52
+ export function oauthErrorHtml(message: string, details?: string): string {
53
+ return page("Sign-in failed", message, details);
54
+ }
55
+
56
+ export function startOAuthCallbackServer(options: OAuthCallbackServerOptions): Promise<OAuthCallbackServer> {
57
+ const host = options.host ?? process.env.PI_OAUTH_CALLBACK_HOST ?? "127.0.0.1";
58
+ const redirectUri = `http://localhost:${options.port}${options.path}`;
59
+
60
+ return new Promise((resolve, reject) => {
61
+ let settle: ((value: OAuthCallback | undefined) => void) | undefined;
62
+ const waited = new Promise<OAuthCallback | undefined>((resolveWait) => {
63
+ let settled = false;
64
+ settle = (value) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ resolveWait(value);
68
+ };
69
+ });
70
+
71
+ const html = (response: import("node:http").ServerResponse, status: number, body: string) => {
72
+ response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
73
+ response.end(body);
74
+ };
75
+
76
+ const server: Server = createServer((request, response) => {
77
+ const url = new URL(request.url ?? "", redirectUri);
78
+ if (url.pathname !== options.path) {
79
+ html(response, 404, oauthErrorHtml("Callback route not found."));
80
+ return;
81
+ }
82
+
83
+ const error = url.searchParams.get("error");
84
+ if (error) {
85
+ html(response, 400, oauthErrorHtml("Authentication did not complete.", error));
86
+ return;
87
+ }
88
+
89
+ const code = url.searchParams.get("code");
90
+ const state = url.searchParams.get("state");
91
+ if (!code || !state) {
92
+ html(response, 400, oauthErrorHtml("Missing code or state parameter."));
93
+ return;
94
+ }
95
+
96
+ html(response, 200, oauthSuccessHtml(options.successMessage ?? "You can close this window."));
97
+ settle?.({ code, state });
98
+ });
99
+
100
+ server.on("error", reject);
101
+ server.listen(options.port, host, () => {
102
+ resolve({
103
+ redirectUri,
104
+ wait: () => waited,
105
+ cancel: () => settle?.(undefined),
106
+ close: () => server.close(),
107
+ });
108
+ });
109
+ });
110
+ }
@@ -15,7 +15,9 @@ export interface PolicyFile {
15
15
  }
16
16
 
17
17
  const DEFAULT_POLICY: PolicyFile = {
18
- autoApprove: ["anthropic/*", "openai-codex/*"],
18
+ // `google/*` is the metered Gemini API; `gemini/*` is the subscription,
19
+ // which the account's plan has already paid for.
20
+ autoApprove: ["anthropic/*", "openai-codex/*", "gemini/*"],
19
21
  requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
20
22
  deny: [],
21
23
  };
@@ -21,7 +21,7 @@ import { fitId } from "../../ui/format.ts";
21
21
  * pick a model per task instead of relying on fixed small/medium/big profiles.
22
22
  */
23
23
 
24
- const SUBSCRIPTION_PROVIDERS = new Set(["anthropic", "openai-codex", "kimi-coding"]);
24
+ const SUBSCRIPTION_PROVIDERS = new Set(["anthropic", "openai-codex", "gemini", "kimi-coding"]);
25
25
 
26
26
  type SortKey = "coding" | "intelligence" | "agentic" | "reasoning" | "cost" | "speed" | "cost_efficiency";
27
27