@oh-my-pi/pi-coding-agent 8.12.10 → 8.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Google Gemini Web Search Provider
3
+ *
4
+ * Uses Gemini's Google Search grounding via Cloud Code Assist API.
5
+ * Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
6
+ * Returns synthesized answers with citations and source metadata from grounding chunks.
7
+ */
8
+ import { refreshGoogleCloudToken } from "@oh-my-pi/pi-ai";
9
+ import { getAgentDbPath, getConfigDirPaths } from "../../../config";
10
+ import { AgentStorage } from "../../../session/agent-storage";
11
+ import type { WebSearchCitation, WebSearchResponse, WebSearchSource } from "../../../web/search/types";
12
+ import { WebSearchProviderError } from "../../../web/search/types";
13
+
14
+ const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
15
+ const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
16
+ const DEFAULT_MODEL = "gemini-2.5-flash";
17
+
18
+ // Headers for Gemini CLI (prod endpoint)
19
+ const GEMINI_CLI_HEADERS = {
20
+ "User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
21
+ "X-Goog-Api-Client": "gl-node/22.17.0",
22
+ "Client-Metadata": JSON.stringify({
23
+ ideType: "IDE_UNSPECIFIED",
24
+ platform: "PLATFORM_UNSPECIFIED",
25
+ pluginType: "GEMINI",
26
+ }),
27
+ };
28
+
29
+ // Headers for Antigravity (sandbox endpoint)
30
+ const ANTIGRAVITY_HEADERS = {
31
+ "User-Agent": "antigravity/1.11.5 darwin/arm64",
32
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
33
+ "Client-Metadata": JSON.stringify({
34
+ ideType: "IDE_UNSPECIFIED",
35
+ platform: "PLATFORM_UNSPECIFIED",
36
+ pluginType: "GEMINI",
37
+ }),
38
+ };
39
+
40
+ export interface GeminiSearchParams {
41
+ query: string;
42
+ system_prompt?: string;
43
+ num_results?: number;
44
+ }
45
+
46
+ /** OAuth credential stored in agent.db */
47
+ interface GeminiOAuthCredential {
48
+ type: "oauth";
49
+ access: string;
50
+ refresh?: string;
51
+ expires: number;
52
+ projectId?: string;
53
+ }
54
+
55
+ /** Auth info for Gemini API requests */
56
+ interface GeminiAuth {
57
+ accessToken: string;
58
+ refreshToken?: string;
59
+ projectId: string;
60
+ isAntigravity: boolean;
61
+ storage: AgentStorage;
62
+ credentialId: number;
63
+ }
64
+
65
+ /**
66
+ * Finds valid Gemini OAuth credentials from agent.db.
67
+ * Checks google-antigravity first (daily sandbox, more quota), then google-gemini-cli (prod).
68
+ * @returns OAuth credential with access token and project ID, or null if none found
69
+ */
70
+ async function findGeminiAuth(): Promise<GeminiAuth | null> {
71
+ const configDirs = getConfigDirPaths("", { project: false });
72
+ const expiryBuffer = 5 * 60 * 1000; // 5 minutes
73
+ const now = Date.now();
74
+
75
+ // Try providers in order: antigravity first (more quota), then gemini-cli
76
+ const providers = ["google-antigravity", "google-gemini-cli"] as const;
77
+
78
+ for (const configDir of configDirs) {
79
+ try {
80
+ const storage = await AgentStorage.open(getAgentDbPath(configDir));
81
+
82
+ for (const provider of providers) {
83
+ const records = storage.listAuthCredentials(provider);
84
+
85
+ for (const record of records) {
86
+ const credential = record.credential;
87
+ if (credential.type !== "oauth") continue;
88
+
89
+ const oauthCred = credential as GeminiOAuthCredential;
90
+ if (!oauthCred.access) continue;
91
+
92
+ // Get projectId from credential
93
+ const projectId = oauthCred.projectId;
94
+ if (!projectId) continue;
95
+
96
+ // Check if token is expired (or about to expire)
97
+ if (oauthCred.expires <= now + expiryBuffer) {
98
+ // Try to refresh if we have a refresh token
99
+ if (oauthCred.refresh) {
100
+ try {
101
+ const refreshed = await refreshGoogleCloudToken(oauthCred.refresh, projectId);
102
+ // Update the credential in storage
103
+ const updated = {
104
+ ...oauthCred,
105
+ access: refreshed.access,
106
+ refresh: refreshed.refresh ?? oauthCred.refresh,
107
+ expires: refreshed.expires,
108
+ };
109
+ storage.updateAuthCredential(record.id, updated);
110
+ return {
111
+ accessToken: refreshed.access,
112
+ refreshToken: refreshed.refresh ?? oauthCred.refresh,
113
+ projectId,
114
+ isAntigravity: provider === "google-antigravity",
115
+ storage,
116
+ credentialId: record.id,
117
+ };
118
+ } catch {
119
+ // Refresh failed, skip this credential
120
+ continue;
121
+ }
122
+ }
123
+ // No refresh token or refresh failed
124
+ continue;
125
+ }
126
+
127
+ return {
128
+ accessToken: oauthCred.access,
129
+ refreshToken: oauthCred.refresh,
130
+ projectId,
131
+ isAntigravity: provider === "google-antigravity",
132
+ storage,
133
+ credentialId: record.id,
134
+ };
135
+ }
136
+ }
137
+ } catch {
138
+ // Continue to next config directory
139
+ }
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ /** Cloud Code Assist API response types */
146
+ interface GeminiGroundingChunk {
147
+ web?: {
148
+ uri?: string;
149
+ title?: string;
150
+ };
151
+ }
152
+
153
+ interface GeminiGroundingSupport {
154
+ segment?: {
155
+ startIndex?: number;
156
+ endIndex?: number;
157
+ text?: string;
158
+ };
159
+ groundingChunkIndices?: number[];
160
+ confidenceScores?: number[];
161
+ }
162
+
163
+ interface GeminiGroundingMetadata {
164
+ groundingChunks?: GeminiGroundingChunk[];
165
+ groundingSupports?: GeminiGroundingSupport[];
166
+ webSearchQueries?: string[];
167
+ }
168
+
169
+ interface CloudCodeResponseChunk {
170
+ response?: {
171
+ candidates?: Array<{
172
+ content?: {
173
+ role: string;
174
+ parts?: Array<{ text?: string }>;
175
+ };
176
+ finishReason?: string;
177
+ groundingMetadata?: GeminiGroundingMetadata;
178
+ }>;
179
+ usageMetadata?: {
180
+ promptTokenCount?: number;
181
+ candidatesTokenCount?: number;
182
+ totalTokenCount?: number;
183
+ };
184
+ modelVersion?: string;
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Calls the Cloud Code Assist API with Google Search grounding enabled.
190
+ * @param auth - Authentication info (access token and project ID)
191
+ * @param query - Search query from the user
192
+ * @param systemPrompt - Optional system prompt
193
+ * @returns Parsed response with answer, sources, and usage
194
+ * @throws {WebSearchProviderError} If the API request fails
195
+ */
196
+ async function callGeminiSearch(
197
+ auth: GeminiAuth,
198
+ query: string,
199
+ systemPrompt?: string,
200
+ ): Promise<{
201
+ answer: string;
202
+ sources: WebSearchSource[];
203
+ citations: WebSearchCitation[];
204
+ searchQueries: string[];
205
+ model: string;
206
+ usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
207
+ }> {
208
+ const endpoint = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT : DEFAULT_ENDPOINT;
209
+ const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
210
+ const headers = auth.isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
211
+
212
+ const requestBody = {
213
+ project: auth.projectId,
214
+ model: DEFAULT_MODEL,
215
+ request: {
216
+ contents: [
217
+ {
218
+ role: "user",
219
+ parts: [{ text: query }],
220
+ },
221
+ ],
222
+ // Add googleSearch tool for grounding
223
+ tools: [{ googleSearch: {} }],
224
+ ...(systemPrompt && {
225
+ systemInstruction: {
226
+ parts: [{ text: systemPrompt }],
227
+ },
228
+ }),
229
+ },
230
+ userAgent: "pi-web-search",
231
+ requestId: `search-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
232
+ };
233
+
234
+ const response = await fetch(url, {
235
+ method: "POST",
236
+ headers: {
237
+ Authorization: `Bearer ${auth.accessToken}`,
238
+ "Content-Type": "application/json",
239
+ Accept: "text/event-stream",
240
+ ...headers,
241
+ },
242
+ body: JSON.stringify(requestBody),
243
+ });
244
+
245
+ if (!response.ok) {
246
+ const errorText = await response.text();
247
+ throw new WebSearchProviderError(
248
+ "gemini",
249
+ `Gemini Cloud Code API error (${response.status}): ${errorText}`,
250
+ response.status,
251
+ );
252
+ }
253
+
254
+ if (!response.body) {
255
+ throw new WebSearchProviderError("gemini", "Gemini API returned no response body", 500);
256
+ }
257
+
258
+ // Parse SSE stream
259
+ const answerParts: string[] = [];
260
+ const sources: WebSearchSource[] = [];
261
+ const citations: WebSearchCitation[] = [];
262
+ const searchQueries: string[] = [];
263
+ const seenUrls = new Set<string>();
264
+ let model = DEFAULT_MODEL;
265
+ let usage: { inputTokens: number; outputTokens: number; totalTokens: number } | undefined;
266
+
267
+ const reader = response.body.getReader();
268
+ const decoder = new TextDecoder();
269
+ let buffer = "";
270
+
271
+ try {
272
+ while (true) {
273
+ const { done, value } = await reader.read();
274
+ if (done) break;
275
+
276
+ buffer += decoder.decode(value, { stream: true });
277
+ const lines = buffer.split("\n");
278
+ buffer = lines.pop() || "";
279
+
280
+ for (const line of lines) {
281
+ if (!line.startsWith("data:")) continue;
282
+
283
+ const jsonStr = line.slice(5).trim();
284
+ if (!jsonStr) continue;
285
+
286
+ let chunk: CloudCodeResponseChunk;
287
+ try {
288
+ chunk = JSON.parse(jsonStr) as CloudCodeResponseChunk;
289
+ } catch {
290
+ continue;
291
+ }
292
+
293
+ const responseData = chunk.response;
294
+ if (!responseData) continue;
295
+
296
+ const candidate = responseData.candidates?.[0];
297
+
298
+ // Extract text content
299
+ if (candidate?.content?.parts) {
300
+ for (const part of candidate.content.parts) {
301
+ if (part.text) {
302
+ answerParts.push(part.text);
303
+ }
304
+ }
305
+ }
306
+
307
+ // Extract grounding metadata
308
+ const groundingMetadata = candidate?.groundingMetadata;
309
+ if (groundingMetadata) {
310
+ // Extract sources from grounding chunks
311
+ if (groundingMetadata.groundingChunks) {
312
+ for (const grChunk of groundingMetadata.groundingChunks) {
313
+ if (grChunk.web?.uri) {
314
+ const sourceUrl = grChunk.web.uri;
315
+ if (!seenUrls.has(sourceUrl)) {
316
+ seenUrls.add(sourceUrl);
317
+ sources.push({
318
+ title: grChunk.web.title ?? sourceUrl,
319
+ url: sourceUrl,
320
+ });
321
+ }
322
+ }
323
+ }
324
+ }
325
+
326
+ // Extract citations from grounding supports
327
+ if (groundingMetadata.groundingSupports && groundingMetadata.groundingChunks) {
328
+ for (const support of groundingMetadata.groundingSupports) {
329
+ const citedText = support.segment?.text;
330
+ const chunkIndices = support.groundingChunkIndices ?? [];
331
+
332
+ for (const idx of chunkIndices) {
333
+ const grChunk = groundingMetadata.groundingChunks[idx];
334
+ if (grChunk?.web?.uri) {
335
+ citations.push({
336
+ url: grChunk.web.uri,
337
+ title: grChunk.web.title ?? grChunk.web.uri,
338
+ citedText,
339
+ });
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ // Extract search queries
346
+ if (groundingMetadata.webSearchQueries) {
347
+ for (const q of groundingMetadata.webSearchQueries) {
348
+ if (!searchQueries.includes(q)) {
349
+ searchQueries.push(q);
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ // Extract usage metadata
356
+ if (responseData.usageMetadata) {
357
+ usage = {
358
+ inputTokens: responseData.usageMetadata.promptTokenCount ?? 0,
359
+ outputTokens: responseData.usageMetadata.candidatesTokenCount ?? 0,
360
+ totalTokens: responseData.usageMetadata.totalTokenCount ?? 0,
361
+ };
362
+ }
363
+
364
+ // Extract model version
365
+ if (responseData.modelVersion) {
366
+ model = responseData.modelVersion;
367
+ }
368
+ }
369
+ }
370
+ } finally {
371
+ reader.releaseLock();
372
+ }
373
+
374
+ return {
375
+ answer: answerParts.join(""),
376
+ sources,
377
+ citations,
378
+ searchQueries,
379
+ model,
380
+ usage,
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Executes a web search using Google Gemini with Google Search grounding.
386
+ * Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
387
+ * @param params - Search parameters including query and optional settings
388
+ * @returns Search response with synthesized answer, sources, and citations
389
+ * @throws {Error} If no Gemini OAuth credentials are configured
390
+ */
391
+ export async function searchGemini(params: GeminiSearchParams): Promise<WebSearchResponse> {
392
+ const auth = await findGeminiAuth();
393
+ if (!auth) {
394
+ throw new Error(
395
+ "No Gemini OAuth credentials found. Login with 'omp /login google-gemini-cli' or 'omp /login google-antigravity' to enable Gemini web search.",
396
+ );
397
+ }
398
+
399
+ const result = await callGeminiSearch(auth, params.query, params.system_prompt);
400
+
401
+ let sources = result.sources;
402
+
403
+ // Apply num_results limit if specified
404
+ if (params.num_results && sources.length > params.num_results) {
405
+ sources = sources.slice(0, params.num_results);
406
+ }
407
+
408
+ return {
409
+ provider: "gemini",
410
+ answer: result.answer || undefined,
411
+ sources,
412
+ citations: result.citations.length > 0 ? result.citations : undefined,
413
+ searchQueries: result.searchQueries.length > 0 ? result.searchQueries : undefined,
414
+ usage: result.usage,
415
+ model: result.model,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Checks if Gemini web search is available.
421
+ * @returns True if valid OAuth credentials exist for google-gemini-cli or google-antigravity
422
+ */
423
+ export async function hasGeminiWebSearch(): Promise<boolean> {
424
+ const auth = await findGeminiAuth();
425
+ return auth !== null;
426
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** Supported web search providers */
8
- export type WebSearchProvider = "exa" | "anthropic" | "perplexity";
8
+ export type WebSearchProvider = "exa" | "anthropic" | "perplexity" | "gemini" | "codex";
9
9
 
10
10
  /** Source returned by search (all providers) */
11
11
  export interface WebSearchSource {