@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 +5 -5
- package/src/core/anthropic/models.ts +5 -2
- package/src/core/gemini/convert.ts +239 -0
- package/src/core/gemini/models.ts +2 -2
- package/src/core/gemini/request.ts +37 -53
- package/src/core/gemini/stream.ts +82 -51
- package/src/domains/subscriptions/providers/builtin.ts +19 -0
- package/src/domains/subscriptions/providers/codex.ts +2 -2
- package/src/domains/subscriptions/providers/hosted.ts +3 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jameslovespancakes/pi-plus",
|
|
3
|
-
"version": "1.0.
|
|
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": "
|
|
53
|
-
"@earendil-works/pi-ai": "
|
|
54
|
-
"@earendil-works/pi-coding-agent": "
|
|
55
|
-
"@earendil-works/pi-tui": "
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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 (
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
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,
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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(
|
|
162
|
-
return
|
|
163
|
-
functionDeclarations:
|
|
164
|
-
|
|
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(
|
|
167
|
-
: { parametersJsonSchema: selfContainedSchema(
|
|
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
|
|
202
|
+
export function buildRequest(
|
|
208
203
|
model: Model<Api>,
|
|
209
204
|
context: TranscriptContext,
|
|
210
205
|
projectId: string,
|
|
211
206
|
options: RequestOptions = {},
|
|
212
|
-
):
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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`.
|
|
36
|
-
*
|
|
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
|
|
174
|
-
*
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
:
|
|
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
|
|
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 =
|
|
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
|
-
...
|
|
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:
|
|
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 {
|
|
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:
|
|
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:
|
|
13
|
+
createProvider: () => builtinProvider("xai"),
|
|
15
14
|
};
|
|
16
15
|
|
|
17
16
|
export const kimiAccounts = createPooledOAuthAdapter(KIMI_SPEC);
|