@jameslovespancakes/pi-plus 1.0.15 → 1.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/package.json +5 -5
- package/src/core/accounts/oauth-pool.ts +4 -2
- package/src/core/accounts/registry.ts +7 -2
- package/src/core/anthropic/catalog.ts +139 -0
- package/src/core/anthropic/client-identity.ts +24 -5
- package/src/core/anthropic/models.ts +111 -26
- package/src/core/config.ts +1 -1
- package/src/core/gemini/LICENSE.md +21 -0
- package/src/core/gemini/client.ts +188 -0
- package/src/core/gemini/convert.ts +239 -0
- package/src/core/gemini/credentials.ts +56 -0
- package/src/core/gemini/models.ts +362 -0
- package/src/core/gemini/oauth.ts +240 -0
- package/src/core/gemini/request.ts +243 -0
- package/src/core/gemini/schema.ts +142 -0
- package/src/core/gemini/stream.ts +557 -0
- package/src/core/oauth/callback-server.ts +110 -0
- package/src/core/policy/policy.ts +3 -1
- package/src/domains/models/catalog-tool.ts +1 -1
- package/src/domains/subscriptions/accounts.ts +2 -2
- package/src/domains/subscriptions/footer.ts +1 -1
- package/src/domains/subscriptions/index.ts +3 -1
- package/src/domains/subscriptions/provider.ts +66 -7
- package/src/domains/subscriptions/providers/builtin.ts +19 -0
- package/src/domains/subscriptions/providers/codex.ts +2 -2
- package/src/domains/subscriptions/providers/gemini.ts +111 -0
- package/src/domains/subscriptions/providers/hosted.ts +3 -4
- package/src/domains/subscriptions/providers/oauth-pool.ts +35 -9
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemini's `v1internal` control plane: project discovery, the account
|
|
5
|
+
* email, and the live model list.
|
|
6
|
+
*
|
|
7
|
+
* Plain `fetch`, no pi types, so everything here is unit-testable without a
|
|
8
|
+
* session. The wire details (endpoints, User-Agent, request bodies) follow
|
|
9
|
+
* the Antigravity CLI as tracked by `pi-antigravity`; the backend identifies
|
|
10
|
+
* its callers and refuses ones it does not recognise.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Daily carries rollouts first, so it leads; production is the last resort. */
|
|
14
|
+
export const GEMINI_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
15
|
+
export const GEMINI_ENDPOINTS: readonly string[] = [
|
|
16
|
+
GEMINI_ENDPOINT,
|
|
17
|
+
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
|
18
|
+
"https://cloudcode-pa.googleapis.com",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const GEMINI_USER_AGENT =
|
|
22
|
+
"antigravity/cli/1.1.23 (aidev_client; os_type=linux; arch=amd64; cl=974125021; auth_method=consumer)";
|
|
23
|
+
|
|
24
|
+
/** Metadata lookups must be quick; a stalled endpoint falls through to the next. */
|
|
25
|
+
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
26
|
+
|
|
27
|
+
export function geminiEnv(name: string): string | undefined {
|
|
28
|
+
const value = process.env[`PI_GEMINI_${name}`]?.trim();
|
|
29
|
+
return value || undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function geminiHeaders(token: string): Record<string, string> {
|
|
33
|
+
return {
|
|
34
|
+
Authorization: `Bearer ${token}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"User-Agent": GEMINI_USER_AGENT,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Endpoints for a request, in order. A base URL the user configured for the
|
|
42
|
+
* provider (pi's `models.json` override) is used alone; otherwise every
|
|
43
|
+
* Gemini endpoint is tried.
|
|
44
|
+
*/
|
|
45
|
+
export function endpointsFor(baseUrl?: string): readonly string[] {
|
|
46
|
+
const configured = baseUrl?.trim().replace(/\/+$/, "");
|
|
47
|
+
return configured && configured !== GEMINI_ENDPOINT ? [configured] : GEMINI_ENDPOINTS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function timeoutSignal(signal?: AbortSignal): AbortSignal {
|
|
55
|
+
const timeout = AbortSignal.timeout(DISCOVERY_TIMEOUT_MS);
|
|
56
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** POSTs to one endpoint; undefined for any non-2xx or transport failure. */
|
|
60
|
+
async function postJson(endpoint: string, path: string, token: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(`${endpoint}/v1internal:${path}`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: geminiHeaders(token),
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
signal: timeoutSignal(signal),
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) return undefined;
|
|
69
|
+
return await response.json();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// A caller abort is a decision, not an endpoint failure to route around.
|
|
72
|
+
if (signal?.aborted) throw error;
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** UUID-shaped id derived from a seed, so the same account always maps to the same value. */
|
|
78
|
+
export function stableUuid(seed: string): string {
|
|
79
|
+
const bytes = createHash("sha1").update(seed).digest().subarray(0, 16);
|
|
80
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
81
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
82
|
+
const hex = bytes.toString("hex");
|
|
83
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Project for an account Google allocated none to. Consumer Gemini
|
|
88
|
+
* accounts are served without one, so a stable per-account value is enough;
|
|
89
|
+
* `PI_GEMINI_PROJECT_ID` pins a real one.
|
|
90
|
+
*/
|
|
91
|
+
export function fallbackProjectId(email?: string): string {
|
|
92
|
+
return geminiEnv("PROJECT_ID") ?? stableUuid(`antigravity:${email || "antigravity-default"}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The project field has moved between response shapes; accept each of them. */
|
|
96
|
+
export function extractProjectId(data: unknown): string | undefined {
|
|
97
|
+
if (!isRecord(data)) return undefined;
|
|
98
|
+
const direct = data.geminiProjectId
|
|
99
|
+
?? data.projectId
|
|
100
|
+
?? data.backendProjectId
|
|
101
|
+
?? data.userDefinedCloudaicompanionProject
|
|
102
|
+
?? data.cloudaicompanionProject
|
|
103
|
+
?? data.project;
|
|
104
|
+
if (typeof direct === "string" && direct) return direct;
|
|
105
|
+
if (isRecord(direct) && typeof direct.id === "string" && direct.id) return direct.id;
|
|
106
|
+
|
|
107
|
+
for (const key of ["projects", "projectIds", "cloudaicompanionProjects"]) {
|
|
108
|
+
const list = data[key];
|
|
109
|
+
if (!Array.isArray(list)) continue;
|
|
110
|
+
for (const item of list) {
|
|
111
|
+
if (typeof item === "string" && item) return item;
|
|
112
|
+
const nested = extractProjectId(item);
|
|
113
|
+
if (nested) return nested;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The account's project, or undefined when Google allocated none — callers
|
|
121
|
+
* then fall back to {@link fallbackProjectId}. `PI_GEMINI_PROJECT_ID` wins.
|
|
122
|
+
*/
|
|
123
|
+
export async function discoverProjectId(token: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
124
|
+
const pinned = geminiEnv("PROJECT_ID");
|
|
125
|
+
if (pinned) return pinned;
|
|
126
|
+
|
|
127
|
+
for (const endpoint of GEMINI_ENDPOINTS) {
|
|
128
|
+
const status = await postJson(endpoint, "loadCodeAssist", token, { metadata: { ideType: "ANTIGRAVITY" } }, signal);
|
|
129
|
+
if (status === undefined) continue;
|
|
130
|
+
const project = extractProjectId(status);
|
|
131
|
+
if (project) return project;
|
|
132
|
+
// The account answered but carries no project: ask for its list instead.
|
|
133
|
+
for (const listing of GEMINI_ENDPOINTS) {
|
|
134
|
+
const projects = await postJson(listing, "listCloudAICompanionProjects", token, {}, signal);
|
|
135
|
+
if (projects !== undefined) return extractProjectId(projects);
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Account label for the picker. Best effort: an account without it still works. */
|
|
143
|
+
export async function fetchUserEmail(token: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
|
146
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
147
|
+
signal: timeoutSignal(signal),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) return undefined;
|
|
150
|
+
const email = ((await response.json()) as { email?: unknown }).email;
|
|
151
|
+
return typeof email === "string" && email ? email : undefined;
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One entry of `fetchAvailableModels`, keyed by its runtime model id. */
|
|
158
|
+
export interface RuntimeModelInfo {
|
|
159
|
+
isInternal?: boolean;
|
|
160
|
+
displayName?: string;
|
|
161
|
+
label?: string;
|
|
162
|
+
modelName?: string;
|
|
163
|
+
supportsThinking?: boolean;
|
|
164
|
+
supportsImages?: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The models this account can use, merged across every endpoint so rollouts
|
|
169
|
+
* that exist only on daily or sandbox still appear. Throws only when no
|
|
170
|
+
* endpoint answered at all.
|
|
171
|
+
*/
|
|
172
|
+
export async function fetchAvailableModels(
|
|
173
|
+
token: string,
|
|
174
|
+
projectId: string,
|
|
175
|
+
signal?: AbortSignal,
|
|
176
|
+
): Promise<Record<string, RuntimeModelInfo>> {
|
|
177
|
+
const answers = await Promise.all(GEMINI_ENDPOINTS.map((endpoint) =>
|
|
178
|
+
postJson(endpoint, "fetchAvailableModels", token, { project: projectId }, signal)));
|
|
179
|
+
|
|
180
|
+
const catalogues = answers.filter(isRecord);
|
|
181
|
+
if (catalogues.length === 0) throw new Error("Gemini did not return a model list from any endpoint.");
|
|
182
|
+
|
|
183
|
+
const merged: Record<string, RuntimeModelInfo> = {};
|
|
184
|
+
for (const catalogue of catalogues) {
|
|
185
|
+
if (isRecord(catalogue.models)) Object.assign(merged, catalogue.models);
|
|
186
|
+
}
|
|
187
|
+
return merged;
|
|
188
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Gemini request needs two things per account: the OAuth access token
|
|
5
|
+
* and the Cloud Code project the account is served under. pi's OAuth path
|
|
6
|
+
* hands the stream only a `ModelAuth` (`apiKey`/`headers`/`baseUrl`), so the
|
|
7
|
+
* project has to travel inside one of those.
|
|
8
|
+
*
|
|
9
|
+
* It rides in `apiKey` rather than a header because a header would be sent to
|
|
10
|
+
* Google verbatim. The encoding is private to this provider: `toAuth()`
|
|
11
|
+
* produces it, the stream consumes it, and the account pool reads the token
|
|
12
|
+
* back out to attribute quota. All three share this one definition.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface GeminiCredential extends OAuthCredential {
|
|
16
|
+
/** Project discovered at login; absent when Google allocated none. */
|
|
17
|
+
projectId?: string;
|
|
18
|
+
email?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GeminiApiKey {
|
|
22
|
+
token: string;
|
|
23
|
+
projectId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function encodeApiKey(key: GeminiApiKey): string {
|
|
27
|
+
return JSON.stringify({ token: key.token, projectId: key.projectId });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Throws with a recoverable instruction rather than letting Google answer 401. */
|
|
31
|
+
export function decodeApiKey(apiKey: string | undefined): GeminiApiKey {
|
|
32
|
+
if (!apiKey) throw new Error("Gemini requires OAuth. Run /login gemini.");
|
|
33
|
+
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(apiKey);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("Gemini credentials are unreadable. Run /login gemini to re-authenticate.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { token, projectId } = (parsed ?? {}) as Partial<GeminiApiKey>;
|
|
42
|
+
if (typeof token !== "string" || !token || typeof projectId !== "string" || !projectId) {
|
|
43
|
+
throw new Error("Gemini credentials are missing a token or project. Run /login gemini.");
|
|
44
|
+
}
|
|
45
|
+
return { token, projectId };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function credentialProjectId(credential: OAuthCredential): string | undefined {
|
|
49
|
+
const value = (credential as GeminiCredential).projectId;
|
|
50
|
+
return typeof value === "string" && value ? value : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function credentialEmail(credential: OAuthCredential): string | undefined {
|
|
54
|
+
const value = (credential as GeminiCredential).email;
|
|
55
|
+
return typeof value === "string" && value ? value : undefined;
|
|
56
|
+
}
|