@jameslovespancakes/pi-plus 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,14 @@ Your live quota, always in the footer:
73
73
  With more than two accounts only the two most recently used are listed, so the
74
74
  footer stays a fixed height however many you pool.
75
75
 
76
+ The right-hand column follows the model in use. It shows Codex by default and
77
+ swaps to Gemini while a `gemini/*` model is selected: one bar per quota family
78
+ (Flash, Pro, and Claude or GPT-OSS), pooled across your Gemini accounts, with
79
+ the active family highlighted. Whether a family resets weekly or every five
80
+ hours depends on the account's plan; the reset time shows which. Kimi and Grok
81
+ publish no usage endpoint, so their column shows only the last rate-limit
82
+ reading, if any.
83
+
76
84
  Account and routing commands are provider-agnostic. Sequential routing uses
77
85
  account order; quota-aware routing uses reported capacity and fairly probes
78
86
  accounts whose provider does not publish quota headers.
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.18",
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
@@ -61,12 +61,16 @@ export function parseQuota(body: any, now = Date.now()): QuotaSnapshot {
61
61
  };
62
62
  };
63
63
 
64
+ // `limits` also restates the session and weekly windows (`kind: session`,
65
+ // `weekly_all`) with no scope. Only model-scoped entries are extra limits;
66
+ // keeping the others filed the 5h and 7d windows a second time as "scoped".
64
67
  const scoped = (Array.isArray(body?.limits) ? body.limits : [])
65
68
  .map((limit: any) => {
69
+ const name = limit?.scope?.model?.display_name;
66
70
  const used = pct(limit?.percent);
67
- if (used === undefined) return undefined;
71
+ if (typeof name !== "string" || !name || used === undefined) return undefined;
68
72
  return {
69
- id: String(limit?.scope?.model?.display_name ?? limit?.id ?? "scoped").toLowerCase(),
73
+ id: name.toLowerCase(),
70
74
  usedPercent: used,
71
75
  remainingPercent: 100 - used,
72
76
  resetsAt: typeof limit?.resets_at === "string" ? limit.resets_at : undefined,
@@ -154,6 +154,45 @@ export async function fetchUserEmail(token: string, signal?: AbortSignal): Promi
154
154
  }
155
155
  }
156
156
 
157
+ /** One `retrieveUserQuota` bucket: a runtime model's remaining share of its window. */
158
+ export interface QuotaBucket {
159
+ modelId: string;
160
+ /** 0..1 of the window left. */
161
+ remainingFraction: number;
162
+ /** ISO time the window resets; absent for buckets with no window. */
163
+ resetTime?: string;
164
+ }
165
+
166
+ /**
167
+ * The account's quota, one bucket per runtime model id. Endpoints are tried in
168
+ * order and the first answer wins: quota is per account, not per endpoint.
169
+ * Throws only when no endpoint answered at all.
170
+ */
171
+ export async function fetchUserQuota(
172
+ token: string,
173
+ projectId: string,
174
+ signal?: AbortSignal,
175
+ ): Promise<QuotaBucket[]> {
176
+ for (const endpoint of GEMINI_ENDPOINTS) {
177
+ const answer = await postJson(endpoint, "retrieveUserQuota", token, { project: projectId }, signal);
178
+ if (!isRecord(answer)) continue;
179
+ const buckets = Array.isArray(answer.buckets) ? answer.buckets : [];
180
+ return buckets.flatMap((bucket): QuotaBucket[] => {
181
+ if (!isRecord(bucket) || typeof bucket.modelId !== "string") return [];
182
+ // proto3 JSON omits zero values, so an exhausted bucket arrives with no
183
+ // `remainingFraction` at all. Absent means empty, not unknown.
184
+ const fraction = bucket.remainingFraction === undefined ? 0 : Number(bucket.remainingFraction);
185
+ if (!Number.isFinite(fraction)) return [];
186
+ return [{
187
+ modelId: bucket.modelId,
188
+ remainingFraction: Math.min(1, Math.max(0, fraction)),
189
+ ...(typeof bucket.resetTime === "string" && bucket.resetTime && { resetTime: bucket.resetTime }),
190
+ }];
191
+ });
192
+ }
193
+ throw new Error("Gemini did not return quota from any endpoint.");
194
+ }
195
+
157
196
  /** One entry of `fetchAvailableModels`, keyed by its runtime model id. */
158
197
  export interface RuntimeModelInfo {
159
198
  isInternal?: boolean;
@@ -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 {
@@ -0,0 +1,58 @@
1
+ import type { QuotaBucket } from "./client.ts";
2
+
3
+ /**
4
+ * Gemini quota, grouped the way the backend pools it.
5
+ *
6
+ * `retrieveUserQuota` answers per runtime model id (`gemini-3.8-flash-high`,
7
+ * `gemini-pro-agent`, `claude-opus-4-6-thinking`, …), but the ids of one
8
+ * family draw on one shared allowance: every Flash variant reports the same
9
+ * fraction and reset, as does every Pro variant. The window differs by plan —
10
+ * a paid account resets weekly, a free one every five hours — so the bars are
11
+ * labelled by family and the reset time says which window it is.
12
+ */
13
+
14
+ export const GEMINI_QUOTA_FAMILIES = ["Flash", "Pro", "Claude", "GPT"] as const;
15
+ export type GeminiQuotaFamily = typeof GEMINI_QUOTA_FAMILIES[number];
16
+
17
+ /**
18
+ * The family a public or runtime model id draws quota from. Undefined for ids
19
+ * that are not agent models (tab completion, image, the `-lite` helpers the
20
+ * client uses for commit messages and search), so they never skew a family.
21
+ */
22
+ export function geminiQuotaFamily(modelId: string | undefined): GeminiQuotaFamily | undefined {
23
+ const id = (modelId ?? "").toLowerCase();
24
+ if (id.startsWith("claude-")) return "Claude";
25
+ if (id.startsWith("gpt-oss")) return "GPT";
26
+ if (!id.startsWith("gemini-") || /image|lite/.test(id)) return undefined;
27
+ if (/(^|-)pro(-|$)/.test(id)) return "Pro";
28
+ if (/(^|-)flash(-|$)/.test(id)) return "Flash";
29
+ return undefined;
30
+ }
31
+
32
+ export interface GeminiFamilyQuota {
33
+ family: GeminiQuotaFamily;
34
+ /** Percent left, 0..100. */
35
+ remaining: number;
36
+ resetAt?: number;
37
+ }
38
+
39
+ /**
40
+ * One figure per family: the most depleted bucket in it, so a variant that
41
+ * has run dry is never hidden behind a sibling that has not.
42
+ */
43
+ export function summarizeGeminiQuota(buckets: readonly QuotaBucket[]): GeminiFamilyQuota[] {
44
+ const byFamily = new Map<GeminiQuotaFamily, GeminiFamilyQuota>();
45
+ for (const bucket of buckets) {
46
+ const family = geminiQuotaFamily(bucket.modelId);
47
+ if (!family) continue;
48
+ const remaining = bucket.remainingFraction * 100;
49
+ const parsed = bucket.resetTime ? Date.parse(bucket.resetTime) : Number.NaN;
50
+ const resetAt = Number.isFinite(parsed) ? parsed : undefined;
51
+ const current = byFamily.get(family);
52
+ const lower = !current || remaining < current.remaining;
53
+ const sooner = current && remaining === current.remaining
54
+ && resetAt !== undefined && (current.resetAt === undefined || resetAt < current.resetAt);
55
+ if (lower || sooner) byFamily.set(family, { family, remaining, ...(resetAt !== undefined && { resetAt }) });
56
+ }
57
+ return GEMINI_QUOTA_FAMILIES.flatMap((family) => byFamily.get(family) ?? []);
58
+ }
@@ -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,