@jameslovespancakes/pi-plus 1.0.16 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jameslovespancakes/pi-plus",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "type": "module",
5
5
  "description": "pi and more",
6
6
  "license": "MIT",
@@ -49,10 +49,10 @@
49
49
  "ws": "^8.18.3"
50
50
  },
51
51
  "peerDependencies": {
52
- "@earendil-works/pi-agent-core": "^0.87.0",
53
- "@earendil-works/pi-ai": "^0.87.0",
54
- "@earendil-works/pi-coding-agent": "^0.87.0",
55
- "@earendil-works/pi-tui": "^0.87.0",
52
+ "@earendil-works/pi-agent-core": "*",
53
+ "@earendil-works/pi-ai": "*",
54
+ "@earendil-works/pi-coding-agent": "*",
55
+ "@earendil-works/pi-tui": "*",
56
56
  "typebox": "*"
57
57
  },
58
58
  "scripts": {
@@ -1,5 +1,8 @@
1
1
  import type { Model } from "@earendil-works/pi-ai";
2
- import { ANTHROPIC_MODELS as PI_ANTHROPIC_MODELS } from "@earendil-works/pi-ai/providers/anthropic.models";
2
+ // `providers/all` is one of the entry points pi supplies from its own copy;
3
+ // a deep `providers/anthropic.models` import has nothing to resolve against
4
+ // on a clean install.
5
+ import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
3
6
  import { cachedAnthropicModels, type LiveModel } from "./catalog.ts";
4
7
 
5
8
  /**
@@ -106,7 +109,7 @@ function nameFor(model: LiveModel): string {
106
109
  return version ? `Claude ${pretty} ${version}` : `Claude ${pretty}`;
107
110
  }
108
111
 
109
- const base = Object.values(PI_ANTHROPIC_MODELS as unknown as Record<string, AnthropicModel>);
112
+ const base = getBuiltinModels("anthropic") as unknown as AnthropicModel[];
110
113
 
111
114
  /**
112
115
  * Newest model per family, used as the template for a model the live list
@@ -0,0 +1,239 @@
1
+ import {
2
+ collapseSystemMessages,
3
+ withoutInitialSystemMessage,
4
+ type Api,
5
+ type AssistantMessage,
6
+ type Message,
7
+ type Model,
8
+ type TranscriptContext,
9
+ } from "@earendil-works/pi-ai";
10
+
11
+ /**
12
+ * pi messages → Gemini `contents`.
13
+ *
14
+ * A port of pi-ai's `convertMessages` / `transformMessages` from its Google
15
+ * adapter (MIT). pi does not supply that module to extensions — it hands out
16
+ * only its package root, `compat`, `oauth` and `providers/all`, and installs
17
+ * packages without their peers — so importing it resolves to nothing on a
18
+ * clean install. The behaviour is kept identical to pi's: thought-signature
19
+ * validation, cross-model thinking demoted to text, tool-call id
20
+ * normalisation, synthetic results for orphaned calls, and multimodal
21
+ * function responses. Re-check it against pi's adapter on a pi upgrade.
22
+ */
23
+
24
+ export interface Part {
25
+ text?: string;
26
+ thought?: boolean;
27
+ thoughtSignature?: string;
28
+ inlineData?: { mimeType?: string; data?: string };
29
+ functionCall?: { name?: string; args?: Record<string, unknown>; id?: string };
30
+ functionResponse?: { name?: string; id?: string; response?: Record<string, unknown>; parts?: Part[] };
31
+ }
32
+
33
+ export interface Content {
34
+ role: "user" | "model";
35
+ parts: Part[];
36
+ }
37
+
38
+ /** Unpaired UTF-16 surrogates are rejected by the JSON the API parses. */
39
+ export function sanitizeSurrogates(text: string): string {
40
+ return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
41
+ }
42
+
43
+ // Thought signatures must be base64 (TYPE_BYTES); anything else is rejected.
44
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
45
+ const isValidSignature = (signature?: string): signature is string =>
46
+ !!signature && signature.length % 4 === 0 && BASE64.test(signature);
47
+
48
+ function geminiMajor(modelId: string): number | undefined {
49
+ const match = /^gemini(?:-live)?-(\d+)/i.exec(modelId);
50
+ return match ? Number.parseInt(match[1], 10) : undefined;
51
+ }
52
+
53
+ /** Claude, GPT-OSS and Gemini 3+ need explicit ids on function calls and responses. */
54
+ export function requiresToolCallId(modelId: string): boolean {
55
+ const major = geminiMajor(modelId);
56
+ return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-") || (major !== undefined && major >= 3);
57
+ }
58
+
59
+ function supportsMultimodalFunctionResponse(modelId: string): boolean {
60
+ const major = geminiMajor(modelId);
61
+ return major === undefined || major >= 3;
62
+ }
63
+
64
+ const USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
65
+ const TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";
66
+
67
+ function withoutImages<T extends { type: string }>(content: T[], placeholder: string): T[] {
68
+ const result: T[] = [];
69
+ let previousWasPlaceholder = false;
70
+ for (const block of content) {
71
+ if (block.type === "image") {
72
+ if (!previousWasPlaceholder) result.push({ type: "text", text: placeholder } as unknown as T);
73
+ previousWasPlaceholder = true;
74
+ continue;
75
+ }
76
+ result.push(block);
77
+ previousWasPlaceholder = (block as { text?: string }).text === placeholder;
78
+ }
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * Makes a history replayable on `model`: another model's thinking becomes
84
+ * text, its signatures are dropped, its tool-call ids are normalised, failed
85
+ * turns are skipped, and every tool call is answered.
86
+ */
87
+ function transformMessages(messages: Message[], model: Model<Api>, normalizeId: (id: string) => string): Message[] {
88
+ const idMap = new Map<string, string>();
89
+ const vision = model.input.includes("image");
90
+
91
+ const transformed = messages.map((raw): Message => {
92
+ const message = (raw.content == null ? { ...raw, content: [] } : raw) as Message;
93
+
94
+ if (message.role === "user") {
95
+ return !vision && Array.isArray(message.content)
96
+ ? { ...message, content: withoutImages(message.content, USER_IMAGE_PLACEHOLDER) }
97
+ : message;
98
+ }
99
+ if (message.role === "toolResult") {
100
+ const content = vision ? message.content : withoutImages(message.content, TOOL_IMAGE_PLACEHOLDER);
101
+ const toolCallId = idMap.get(message.toolCallId) ?? message.toolCallId;
102
+ return { ...message, content, toolCallId };
103
+ }
104
+ if (message.role !== "assistant") return message;
105
+
106
+ const sameModel = message.provider === model.provider && message.api === model.api && message.model === model.id;
107
+ const content = message.content.flatMap((block): AssistantMessage["content"] => {
108
+ if (block.type === "thinking") {
109
+ if (block.redacted) return sameModel ? [block] : [];
110
+ if (sameModel && block.thinkingSignature) return [block];
111
+ if (!block.thinking || block.thinking.trim() === "") return [];
112
+ return sameModel ? [block] : [{ type: "text", text: block.thinking }];
113
+ }
114
+ if (block.type === "text") return sameModel ? [block] : [{ type: "text", text: block.text }];
115
+ if (block.type === "toolCall" && !sameModel) {
116
+ const { thoughtSignature: _dropped, ...call } = block;
117
+ const id = normalizeId(block.id);
118
+ if (id !== block.id) idMap.set(block.id, id);
119
+ return [{ ...call, id }];
120
+ }
121
+ return [block];
122
+ });
123
+ return { ...message, content };
124
+ });
125
+
126
+ // Every tool call needs a result before the next turn; an unanswered one
127
+ // (an interrupted run, a user message mid-tool-flow) gets a synthetic error.
128
+ const result: Message[] = [];
129
+ let pending: { id: string; name: string }[] = [];
130
+ let answered = new Set<string>();
131
+ const closePending = () => {
132
+ for (const call of pending) {
133
+ if (answered.has(call.id)) continue;
134
+ result.push({
135
+ role: "toolResult",
136
+ toolCallId: call.id,
137
+ toolName: call.name,
138
+ content: [{ type: "text", text: "No result provided" }],
139
+ isError: true,
140
+ timestamp: Date.now(),
141
+ });
142
+ }
143
+ pending = [];
144
+ answered = new Set();
145
+ };
146
+
147
+ for (const message of transformed) {
148
+ if (message.role === "assistant") {
149
+ closePending();
150
+ // Incomplete turns are not replayed; the model resumes from the last good state.
151
+ if (message.stopReason === "error" || message.stopReason === "aborted") continue;
152
+ const calls = message.content.filter((block) => block.type === "toolCall");
153
+ if (calls.length > 0) {
154
+ pending = calls.map((call) => ({ id: call.id, name: call.name }));
155
+ answered = new Set();
156
+ }
157
+ result.push(message);
158
+ } else if (message.role === "toolResult") {
159
+ answered.add(message.toolCallId);
160
+ result.push(message);
161
+ } else {
162
+ if (message.role === "user") closePending();
163
+ result.push(message);
164
+ }
165
+ }
166
+ closePending();
167
+ return result;
168
+ }
169
+
170
+ /** Gemini `contents` for a transcript; the system prompt travels separately. */
171
+ export function convertMessages(model: Model<Api>, context: TranscriptContext): Content[] {
172
+ // Gemini has no mid-conversation system messages; the prompt is systemInstruction.
173
+ const conversation = withoutInitialSystemMessage(collapseSystemMessages(context).messages);
174
+ const includeIds = requiresToolCallId(model.id);
175
+ const normalizeId = (id: string) => includeIds ? id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) : id;
176
+ const contents: Content[] = [];
177
+
178
+ for (const message of transformMessages(conversation, model, normalizeId)) {
179
+ if (message.role === "user") {
180
+ const parts: Part[] = typeof message.content === "string"
181
+ ? [{ text: sanitizeSurrogates(message.content) }]
182
+ : message.content.map((item) => item.type === "text"
183
+ ? { text: sanitizeSurrogates(item.text) }
184
+ : { inlineData: { mimeType: item.mimeType, data: item.data } });
185
+ if (parts.length > 0) contents.push({ role: "user", parts });
186
+ } else if (message.role === "assistant") {
187
+ const sameModel = message.provider === model.provider && message.model === model.id;
188
+ const signature = (value?: string) => sameModel && isValidSignature(value) ? value : undefined;
189
+ const parts: Part[] = [];
190
+
191
+ for (const block of message.content) {
192
+ if (block.type === "text") {
193
+ const thoughtSignature = signature(block.textSignature);
194
+ // An empty part that carries a signature must still be echoed back,
195
+ // or the reasoning chain breaks and turns end with an empty STOP.
196
+ if ((!block.text || block.text.trim() === "") && !thoughtSignature) continue;
197
+ parts.push({ text: sanitizeSurrogates(block.text), ...(thoughtSignature && { thoughtSignature }) });
198
+ } else if (block.type === "thinking") {
199
+ if (sameModel) {
200
+ const thoughtSignature = signature(block.thinkingSignature);
201
+ if ((!block.thinking || block.thinking.trim() === "") && !thoughtSignature) continue;
202
+ parts.push({ thought: true, text: sanitizeSurrogates(block.thinking), ...(thoughtSignature && { thoughtSignature }) });
203
+ } else if (block.thinking && block.thinking.trim() !== "") {
204
+ parts.push({ text: sanitizeSurrogates(block.thinking) });
205
+ }
206
+ } else if (block.type === "toolCall") {
207
+ const thoughtSignature = signature(block.thoughtSignature);
208
+ parts.push({
209
+ functionCall: { name: block.name, args: block.arguments ?? {}, ...(includeIds && { id: block.id }) },
210
+ ...(thoughtSignature && { thoughtSignature }),
211
+ });
212
+ }
213
+ }
214
+ if (parts.length > 0) contents.push({ role: "model", parts });
215
+ } else if (message.role === "toolResult") {
216
+ const text = message.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
217
+ const images = model.input.includes("image") ? message.content.filter((item) => item.type === "image") : [];
218
+ const imageParts: Part[] = images.map((image) => ({ inlineData: { mimeType: image.mimeType, data: image.data } }));
219
+ const nested = imageParts.length > 0 && supportsMultimodalFunctionResponse(model.id);
220
+ const value = text.length > 0 ? sanitizeSurrogates(text) : imageParts.length > 0 ? "(see attached image)" : "";
221
+
222
+ const part: Part = {
223
+ functionResponse: {
224
+ name: message.toolName,
225
+ response: message.isError ? { error: value } : { output: value },
226
+ ...(nested && { parts: imageParts }),
227
+ ...(includeIds && { id: message.toolCallId }),
228
+ },
229
+ };
230
+ // Every function response of a turn must share one user turn.
231
+ const last = contents.at(-1);
232
+ if (last?.role === "user" && last.parts.some((existing) => existing.functionResponse)) last.parts.push(part);
233
+ else contents.push({ role: "user", parts: [part] });
234
+
235
+ if (imageParts.length > 0 && !nested) contents.push({ role: "user", parts: [{ text: "Tool result image:" }, ...imageParts] });
236
+ }
237
+ }
238
+ return contents;
239
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Api, Model, ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
2
- import { GOOGLE_MODELS } from "@earendil-works/pi-ai/providers/google.models";
2
+ import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
3
3
  import { GEMINI_ENDPOINT, type RuntimeModelInfo } from "./client.ts";
4
4
 
5
5
  /**
@@ -52,7 +52,7 @@ const familyOf = (id: string): Family => FAMILIES.find((family) => family.test.t
52
52
 
53
53
  /** pi's own definition of the same model, when its Google catalogue carries it. */
54
54
  function piModel(id: string): Model<Api> | undefined {
55
- return (GOOGLE_MODELS as Record<string, Model<Api>>)[id];
55
+ return (getBuiltinModels("google") as Model<Api>[]).find((model) => model.id === id);
56
56
  }
57
57
 
58
58
  interface Definition {
@@ -1,42 +1,29 @@
1
- import type { Api, Model, ModelThinkingLevel, ToolChoice, TranscriptContext } from "@earendil-works/pi-ai";
2
- import { sanitizeSurrogates } from "@earendil-works/pi-ai/utils/sanitize-unicode";
3
- import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
1
+ import {
2
+ getCurrentSystemPrompt,
3
+ getCurrentTools,
4
+ type Api,
5
+ type Model,
6
+ type ModelThinkingLevel,
7
+ type Tool,
8
+ type ToolChoice,
9
+ type TranscriptContext,
10
+ } from "@earendil-works/pi-ai";
4
11
  import { stableUuid } from "./client.ts";
12
+ import { convertMessages, sanitizeSurrogates, type Content, type Part } from "./convert.ts";
5
13
  import { runtimeModelId, thinkingConfig } from "./models.ts";
6
14
  import { bridgeSchema, selfContainedSchema } from "./schema.ts";
7
15
 
8
16
  /**
9
17
  * Builds a Gemini `streamGenerateContent` request.
10
18
  *
11
- * Inside the envelope the body is ordinary Gemini, so message conversion,
12
- * thought-signature validation, tool-call ids and function-calling mode all
13
- * come from pi's own Google adapter. What is added here is only what this
14
- * backend demands beyond the public Gemini API: the runtime model id, its
15
- * thinking budget, the Claude/GPT-OSS schema bridge, a few conversation-shape
16
- * repairs it enforces, and the agent envelope it expects.
19
+ * Inside the envelope the body is ordinary Gemini, converted the way pi's
20
+ * own Google adapter converts it (see convert.ts). What is added here is only
21
+ * what this backend demands beyond the public Gemini API: the runtime model
22
+ * id, its thinking budget, the Claude/GPT-OSS schema bridge, a few
23
+ * conversation-shape repairs it enforces, and the agent envelope it expects.
17
24
  */
18
25
 
19
- type GoogleShared = typeof import("@earendil-works/pi-ai/api/google-shared");
20
- let googleSharedModule: Promise<GoogleShared> | undefined;
21
-
22
- /** Loaded on first request, as pi loads its own Google adapter: it pulls in `@google/genai`. */
23
- export function googleShared(): Promise<GoogleShared> {
24
- return googleSharedModule ??= import("@earendil-works/pi-ai/api/google-shared");
25
- }
26
-
27
- export interface Part {
28
- text?: string;
29
- thought?: boolean;
30
- thoughtSignature?: string;
31
- inlineData?: { mimeType?: string; data?: string };
32
- functionCall?: { name?: string; args?: Record<string, unknown>; id?: string };
33
- functionResponse?: { name?: string; id?: string; response?: Record<string, unknown>; parts?: Part[] };
34
- }
35
-
36
- export interface Content {
37
- role: "user" | "model";
38
- parts: Part[];
39
- }
26
+ export type { Content, Part };
40
27
 
41
28
  export interface RequestOptions {
42
29
  /** Level after pi's clamp; undefined means thinking off. */
@@ -158,15 +145,23 @@ export function repairContents(contents: Content[], requireSignatures: boolean):
158
145
  return turns;
159
146
  }
160
147
 
161
- function toolDeclarations(declared: { functionDeclarations: Record<string, unknown>[] }[], bridge: boolean) {
162
- return declared.map((group) => ({
163
- functionDeclarations: group.functionDeclarations.map(({ parametersJsonSchema, parameters, ...rest }) => ({
164
- ...rest,
148
+ function toolDeclarations(tools: Tool[], bridge: boolean) {
149
+ return [{
150
+ functionDeclarations: tools.map((tool) => ({
151
+ name: tool.name,
152
+ description: tool.description,
165
153
  ...(bridge
166
- ? { parameters: bridgeSchema(parametersJsonSchema ?? parameters) }
167
- : { parametersJsonSchema: selfContainedSchema(parametersJsonSchema ?? parameters) }),
154
+ ? { parameters: bridgeSchema(tool.parameters) }
155
+ : { parametersJsonSchema: selfContainedSchema(tool.parameters) }),
168
156
  })),
169
- }));
157
+ }];
158
+ }
159
+
160
+ /** pi's tool choice as Gemini's calling mode; omitted unless asked, as pi does. */
161
+ function callingMode(toolChoice: RequestOptions["toolChoice"]): string | undefined {
162
+ if (toolChoice === "none") return "NONE";
163
+ if (toolChoice === "any") return "ANY";
164
+ return toolChoice ? "AUTO" : undefined;
170
165
  }
171
166
 
172
167
  /** Random signed 64-bit decimal, the shape the Antigravity CLI uses for session ids. */
@@ -204,32 +199,21 @@ function envelope(context: TranscriptContext, contents: Content[], runtimeId: st
204
199
  };
205
200
  }
206
201
 
207
- export async function buildRequest(
202
+ export function buildRequest(
208
203
  model: Model<Api>,
209
204
  context: TranscriptContext,
210
205
  projectId: string,
211
206
  options: RequestOptions = {},
212
- ): Promise<GeminiRequest> {
213
- const google = await googleShared();
214
- // pi's Google helpers are typed against its own Google APIs; the body inside
215
- // the envelope has exactly the same model semantics.
216
- const googleModel = model as unknown as Model<"google-generative-ai">;
207
+ ): GeminiRequest {
217
208
  const runtimeId = runtimeModelId(model, options.reasoning);
218
-
219
- const contents = repairContents(
220
- google.convertMessages(googleModel, context) as unknown as Content[],
221
- requiresThoughtSignatures(runtimeId),
222
- );
209
+ const contents = repairContents(convertMessages(model, context), requiresThoughtSignatures(runtimeId));
223
210
 
224
211
  // The system prompt and tools live in the transcript's system messages,
225
212
  // never on the context object; reading them any other way sends neither.
226
213
  const systemPrompt = getCurrentSystemPrompt(context.messages);
227
214
  const tools = getCurrentTools(context.messages);
228
215
  // Strict tool sampling (Gemini's VALIDATED mode) is not offered by this backend.
229
- const declared = google.convertTools(tools, false, false);
230
- const mode = tools.length > 0
231
- ? google.resolveGoogleFunctionCallingMode(tools, options.toolChoice, false)
232
- : undefined;
216
+ const mode = tools.length > 0 ? callingMode(options.toolChoice) : undefined;
233
217
 
234
218
  const thinking = thinkingConfig(runtimeId, options.reasoning);
235
219
  const generationConfig = {
@@ -247,7 +231,7 @@ export async function buildRequest(
247
231
  contents,
248
232
  ...(systemPrompt && { systemInstruction: { role: "user", parts: [{ text: sanitizeSurrogates(systemPrompt) }] } }),
249
233
  generationConfig,
250
- ...(declared && { tools: toolDeclarations(declared, usesToolBridge(runtimeId)) }),
234
+ ...(tools.length > 0 && { tools: toolDeclarations(tools, usesToolBridge(runtimeId)) }),
251
235
  ...(mode !== undefined && { toolConfig: { functionCallingConfig: { mode } } }),
252
236
  sessionId,
253
237
  labels,
@@ -1,40 +1,39 @@
1
- import type {
2
- Api,
3
- AssistantMessage,
4
- AssistantMessageEventStream,
5
- Model,
6
- ModelThinkingLevel,
7
- ProviderStreams,
8
- SimpleStreamOptions,
9
- StopReason,
10
- StreamOptions,
11
- TextContent,
12
- ThinkingContent,
13
- ToolCall,
14
- ToolChoice,
15
- TranscriptContext,
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,
16
23
  } from "@earendil-works/pi-ai";
17
- import { calculateCost, clampThinkingLevel } from "@earendil-works/pi-ai";
18
- import { buildBaseOptions } from "@earendil-works/pi-ai/api/simple-options";
19
- import { formatProviderError, normalizeProviderError } from "@earendil-works/pi-ai/utils/error-body";
20
- // pi exports the concrete stream class from this subpath; the bare name on the
21
- // package root is the interface, which cannot be constructed.
22
- import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
23
- import { headersToRecord, providerHeadersToRecord } from "@earendil-works/pi-ai/utils/headers";
24
- import { retryProviderRequest } from "@earendil-works/pi-ai/utils/provider-retry";
25
24
  import { geminiHeaders, endpointsFor } from "./client.ts";
25
+ import type { Part } from "./convert.ts";
26
26
  import { decodeApiKey } from "./credentials.ts";
27
27
  import { GEMINI_API, runtimeModelId } from "./models.ts";
28
- import { buildRequest, googleShared, type Part } from "./request.ts";
28
+ import { buildRequest } from "./request.ts";
29
29
 
30
30
  /**
31
31
  * Gemini streaming transport.
32
32
  *
33
33
  * The request is Gemini inside an agent envelope, posted to
34
34
  * `v1internal:streamGenerateContent`, and every SSE frame comes back wrapped
35
- * in `.response`. Retry policy, error formatting, stop-reason mapping and
36
- * thought-signature retention are pi's; what is here is what this backend
37
- * needs beyond them — endpoint fallback, reading quota walls out of the
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
38
37
  * response body, and a watchdog for streams that go silent.
39
38
  */
40
39
 
@@ -170,19 +169,39 @@ export function describeFailure(status: number, body: string, runtimeId: string)
170
169
  }
171
170
 
172
171
  /**
173
- * Response headers plus what the body said about retrying, in the form pi's
174
- * `retryProviderRequest` and the account pool both read.
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.
175
174
  */
176
175
  function failureHeaders(headers: Headers, failure: Failure): Headers {
177
176
  const next = new Headers(headers);
178
- // A quota wall will not clear within any retry backoff; spend nothing on it.
179
- if (failure.quotaWall) next.set("x-should-retry", "false");
180
177
  if (failure.retryAfterSeconds !== undefined && !next.has("retry-after")) {
181
178
  next.set("retry-after", String(failure.retryAfterSeconds));
182
179
  }
183
180
  return next;
184
181
  }
185
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
+
186
205
  // --- Transport -------------------------------------------------------------
187
206
 
188
207
  function pause(ms: number, signal?: AbortSignal): Promise<void> {
@@ -274,17 +293,12 @@ export const stream = (
274
293
  try {
275
294
  const { token, projectId } = decodeApiKey(options?.apiKey);
276
295
  const runtimeId = runtimeModelId(model, options?.reasoning);
277
- const google = await googleShared();
278
296
 
279
- let body: unknown = await buildRequest(model, context, projectId, options);
297
+ let body: unknown = buildRequest(model, context, projectId, options);
280
298
  body = (await options?.onPayload?.(body, model)) ?? body;
281
299
  const payload = JSON.stringify(body);
282
300
 
283
- const headers: Record<string, string> = {
284
- ...geminiHeaders(token),
285
- // Same precedence and null-suppression rules as pi's own adapters.
286
- ...providerHeadersToRecord({ ...model.headers, ...options?.headers }),
287
- };
301
+ const headers = mergeHeaders(geminiHeaders(token), model.headers, options?.headers);
288
302
  const fetchImpl = options?.fetch ?? globalThis.fetch;
289
303
  const headerTimeout = options?.timeoutMs ?? HEADER_TIMEOUT_MS;
290
304
  const idleTimeout = options?.timeoutMs ?? STALL_TIMEOUT_MS;
@@ -307,7 +321,7 @@ export const stream = (
307
321
  headerTimeout,
308
322
  );
309
323
  if (response.ok) {
310
- await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
324
+ await options?.onResponse?.({ status: response.status, headers: toRecord(response.headers) }, model);
311
325
  return response;
312
326
  }
313
327
  const failure = describeFailure(response.status, await response.text(), runtimeId);
@@ -318,7 +332,7 @@ export const stream = (
318
332
  const { status, headers: reported, failure } = failed!;
319
333
  // Reported with the body's reset time, so the account pool can hold a
320
334
  // quota-walled account out of routing until it actually resets.
321
- await options?.onResponse?.({ status, headers: headersToRecord(reported) }, model);
335
+ await options?.onResponse?.({ status, headers: toRecord(reported) }, model);
322
336
  // The shape pi's retry policy reads: `status` plus `Headers`.
323
337
  throw Object.assign(new Error(failure.message), { status, headers: reported });
324
338
  };
@@ -360,7 +374,7 @@ export const stream = (
360
374
  for (const part of candidate?.content?.parts ?? []) {
361
375
  if (part.text !== undefined) {
362
376
  received = true;
363
- const thinking = google.isThinkingPart(part);
377
+ const thinking = part.thought === true;
364
378
  if (!block || (thinking ? block.type !== "thinking" : block.type !== "text")) {
365
379
  closeBlock(block);
366
380
  block = thinking
@@ -373,11 +387,11 @@ export const stream = (
373
387
 
374
388
  if (block.type === "thinking") {
375
389
  block.thinking += part.text;
376
- block.thinkingSignature = google.retainThoughtSignature(block.thinkingSignature, part.thoughtSignature);
390
+ block.thinkingSignature = retainSignature(block.thinkingSignature, part.thoughtSignature);
377
391
  events.push({ type: "thinking_delta", contentIndex: index(), delta: part.text, partial: output });
378
392
  } else {
379
393
  block.text += part.text;
380
- block.textSignature = google.retainThoughtSignature(block.textSignature, part.thoughtSignature);
394
+ block.textSignature = retainSignature(block.textSignature, part.thoughtSignature);
381
395
  events.push({ type: "text_delta", contentIndex: index(), delta: part.text, partial: output });
382
396
  }
383
397
  }
@@ -409,7 +423,7 @@ export const stream = (
409
423
  output.rawStopReason = candidate.finishReason;
410
424
  output.stopReason = output.content.some((item) => item.type === "toolCall")
411
425
  ? "toolUse"
412
- : google.mapStopReasonString(candidate.finishReason);
426
+ : stopReasonOf(candidate.finishReason);
413
427
  }
414
428
 
415
429
  const usage = data.usageMetadata;
@@ -475,11 +489,7 @@ export const stream = (
475
489
  output.rawStopReason = undefined;
476
490
  started = false;
477
491
  }
478
- received = await consume(await retryProviderRequest(send, {
479
- maxRetries: options?.maxRetries,
480
- maxRetryDelayMs: options?.maxRetryDelayMs,
481
- signal: options?.signal,
482
- }));
492
+ received = await consume(await send());
483
493
  }
484
494
 
485
495
  if (!received) throw new Error("Gemini returned an empty response.");
@@ -499,7 +509,7 @@ export const stream = (
499
509
  events.end();
500
510
  } catch (error) {
501
511
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
502
- output.errorMessage = formatProviderError(normalizeProviderError(error), "Gemini");
512
+ output.errorMessage = formatThrownValue(error);
503
513
  events.push({ type: "error", reason: output.stopReason, error: output });
504
514
  events.end();
505
515
  }
@@ -508,14 +518,35 @@ export const stream = (
508
518
  return events;
509
519
  };
510
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
+
511
539
  export const streamSimple = (
512
540
  model: Model<Api>,
513
541
  context: TranscriptContext,
514
542
  options?: SimpleStreamOptions,
515
543
  ): AssistantMessageEventStream => {
516
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;
517
547
  return stream(model, context, {
518
- ...buildBaseOptions(model, context, options, options?.apiKey),
548
+ ...options,
549
+ maxTokens: model.contextWindow > 0 ? Math.min(requested, Math.max(1, room)) : requested,
519
550
  toolChoice: options?.toolChoice,
520
551
  reasoning: level === "off" ? undefined : level,
521
552
  });
@@ -0,0 +1,19 @@
1
+ import type { Api, Provider } from "@earendil-works/pi-ai";
2
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
3
+
4
+ /**
5
+ * One of pi's own providers, from the host's copy of pi-ai.
6
+ *
7
+ * pi hands extensions its pi-ai through a fixed set of entry points (the
8
+ * package root, `compat`, `oauth` and `providers/all`) and installs packages
9
+ * without their peers. A deep import such as `pi-ai/providers/openai-codex`
10
+ * therefore has no copy to resolve against on a clean install, and wherever a
11
+ * stray copy does exist it is a different version from the host — the exact
12
+ * way stale provider definitions have dropped tools before.
13
+ * `providers/all` is always the host's, so providers come from here.
14
+ */
15
+ export function builtinProvider<TApi extends Api>(id: string): Provider<TApi> {
16
+ const provider = builtinProviders().find((candidate) => candidate.id === id);
17
+ if (!provider) throw new Error(`This version of pi has no built-in "${id}" provider.`);
18
+ return provider as unknown as Provider<TApi>;
19
+ }
@@ -1,5 +1,4 @@
1
1
  import type { OAuthCredential } from "@earendil-works/pi-ai";
2
- import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
3
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
3
  import type { RoutingMode } from "../../../core/accounts/registry.ts";
5
4
  import { normalizeRoutingMode, type AccountQuotaState } from "../../../core/accounts/routing.ts";
@@ -13,6 +12,7 @@ import {
13
12
  saveCodexAccounts,
14
13
  type CodexAccount,
15
14
  } from "../../../core/codex/store.ts";
15
+ import { builtinProvider } from "./builtin.ts";
16
16
  import {
17
17
  chooseCredential,
18
18
  createPooledOAuthAdapter,
@@ -146,7 +146,7 @@ function markCodexRateLimited(accountId: string, headers: Record<string, string>
146
146
  export const CODEX_SPEC: PooledOAuthProviderSpec<"openai-codex-responses"> = {
147
147
  id: "openai-codex",
148
148
  label: "Codex",
149
- createProvider: openaiCodexProvider,
149
+ createProvider: () => builtinProvider("openai-codex"),
150
150
  store: CODEX_STORE,
151
151
  addPrompt: "Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
152
152
  describeAccount: (account) => describePlan(account as PooledOAuthAccount & CodexAccount),
@@ -1,17 +1,16 @@
1
- import { kimiCodingProvider } from "@earendil-works/pi-ai/providers/kimi-coding";
2
- import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
1
+ import { builtinProvider } from "./builtin.ts";
3
2
  import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
4
3
 
5
4
  export const KIMI_SPEC: PooledOAuthProviderSpec<"anthropic-messages"> = {
6
5
  id: "kimi-coding",
7
6
  label: "Kimi",
8
- createProvider: kimiCodingProvider,
7
+ createProvider: () => builtinProvider("kimi-coding"),
9
8
  };
10
9
 
11
10
  export const XAI_SPEC: PooledOAuthProviderSpec<"openai-responses"> = {
12
11
  id: "xai",
13
12
  label: "Grok",
14
- createProvider: xaiProvider,
13
+ createProvider: () => builtinProvider("xai"),
15
14
  };
16
15
 
17
16
  export const kimiAccounts = createPooledOAuthAdapter(KIMI_SPEC);