@wenathlan/getry 2.0.165 → 2.0.167

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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/accounts.ts +2087 -0
  3. package/antigravity.ts +2171 -0
  4. package/auth.ts +265 -0
  5. package/cli.ts +3 -3
  6. package/constants.ts +950 -0
  7. package/db.ts +810 -0
  8. package/docs/distributionlanes.md +1 -1
  9. package/docs/features.md +1 -2
  10. package/docs/gateway.md +2 -3
  11. package/docs/getry.md +33 -0
  12. package/engine.ts +1535 -0
  13. package/fingerprint.ts +1461 -0
  14. package/gateway.ts +1306 -0
  15. package/gatewaycheck.ts +142 -0
  16. package/intelligence.ts +169 -0
  17. package/json.ts +932 -0
  18. package/labels.ts +262 -0
  19. package/llm.ts +2709 -0
  20. package/models.ts +4017 -0
  21. package/nodeshim.ts +265 -0
  22. package/oauth.ts +2201 -0
  23. package/package.json +580 -225
  24. package/project.ts +1293 -0
  25. package/providers.ts +3238 -0
  26. package/quota.ts +1891 -0
  27. package/recovery.ts +2434 -0
  28. package/request.ts +4496 -0
  29. package/search.ts +1761 -0
  30. package/server.ts +766 -0
  31. package/streaming.ts +4398 -0
  32. package/{tests → test}/cli.test.ts +1 -1
  33. package/{tests → test}/gateway.test.ts +2 -2
  34. package/{tests → test}/gatewayversions.test.ts +1 -1
  35. package/{tests → test}/intelligence.test.ts +1 -1
  36. package/{tests → test}/keyrotation.test.ts +1 -1
  37. package/{tests → test}/onboardingstate.test.ts +1 -1
  38. package/{tests → test}/providerfixtures.ts +12 -12
  39. package/{tests → test}/researchfixtures.ts +1 -1
  40. package/{tests → test}/sandboxmodality.test.ts +1 -1
  41. package/thinking.ts +416 -0
  42. package/types.ts +809 -0
  43. package/utils.ts +263 -0
  44. package/validate.ts +61 -0
  45. package/versionregistry.ts +1325 -0
  46. /package/{tests → test}/debonairlane.test.ts +0 -0
  47. /package/{tests → test}/design.test.ts +0 -0
  48. /package/{tests → test}/family.test.ts +0 -0
  49. /package/{tests → test}/gatewayserver.ts +0 -0
  50. /package/{tests → test}/jsonpolicy.test.ts +0 -0
  51. /package/{tests → test}/llm.test.ts +0 -0
  52. /package/{tests → test}/modelroute.test.ts +0 -0
  53. /package/{tests → test}/promptlibrary.test.ts +0 -0
  54. /package/{tests → test}/providercli.test.ts +0 -0
  55. /package/{tests → test}/providerconstants.test.ts +0 -0
  56. /package/{tests → test}/providerfingerprint.test.ts +0 -0
  57. /package/{tests → test}/providermodels.test.ts +0 -0
  58. /package/{tests → test}/provideroauth.test.ts +0 -0
  59. /package/{tests → test}/routerotation.test.ts +0 -0
  60. /package/{tests → test}/serverauth.test.ts +0 -0
  61. /package/{tests → test}/serverengine.test.ts +0 -0
  62. /package/{tests → test}/serverproperty.test.ts +0 -0
  63. /package/{tests → test}/serverutils.test.ts +0 -0
  64. /package/{tests → test}/streamfallback.test.ts +0 -0
  65. /package/{tests → test}/thinkingladder.test.ts +0 -0
  66. /package/{tests → test}/torturellm.test.ts +0 -0
package/search.ts ADDED
@@ -0,0 +1,1761 @@
1
+ /**
2
+ * @file search.ts
3
+ * @module provider/search
4
+ * @description
5
+ * Third-person observer: the library watches how Antigravity Manager and
6
+ * Gemini CLI perform grounding via googleSearch + urlContext and reproduces
7
+ * the contract without relying on localhost v1 proxy. The observer calls
8
+ * cloudcode-pa.googleapis.com directly, cascades endpoints on 403/404/5xx,
9
+ * injects native Gemini tools, and parses groundingMetadata rigorously.
10
+ *
11
+ * Layout: library-first, root-first — file lives beside its siblings
12
+ * Runtime: only node:* builtins + global fetch (Node >=18). Zero external deps.
13
+ * Date governance: 25/08/2026 — Canto do Buriti, PI, BR — version awareness 2.0.0
14
+ *
15
+ * Contract:
16
+ * - Endpoint: https://cloudcode-pa.googleapis.com/v1internal:generateContent (primary)
17
+ * cascade: daily-cloudcode-pa.googleapis.com, sandbox fallback
18
+ * - No localhost v1 base-url proxy, direct cloudcode-pa (security + user choice)
19
+ * - Native tools: googleSearch + urlContext — both injected when enabled
20
+ * - Model: SEARCH_MODEL gemini-3-flash with preview fallback chain
21
+ * - Thinking: thinkingLevel → thinkingBudget mapping (8192/16384/32768) clamped 32k
22
+ * - Grounding parse: groundingMetadata.webSearchQueries, groundingChunks[].web.uri/title,
23
+ * groundingSupports, searchEntryPoint.renderedContent
24
+ * - Proxy support: env HTTPS_PROXY/HTTP_PROXY/NO_PROXY + options.proxyUrl
25
+ * manual CONNECT tunneling via node:net + node:tls — no external deps
26
+ * - Bypass metadata: mimics antigravity/{ver} {os}/{arch} UA, strips x-goog-user-project,
27
+ * jitter 0-80ms, FNV-1a deterministic session_id note (not needed here but coherent)
28
+ *
29
+ * EN: Antigravity bypass relies on mimicking antigravity/{ver} {os}/{arch} UA
30
+ * and bypassing x-goog-user-project stripping, endpoint cascade 403/404/5xx.
31
+ * PT-BR: Bypass Antigravity depende de imitar UA antigravity/{ver} {os}/{arch}
32
+ * e remover x-goog-user-project, cascata de endpoints em 403/404/5xx.
33
+ *
34
+ * Production-ready: JSDoc, exhaustive error handling, timeout, abort, proxy,
35
+ * endpoint cascade, thinking budget, grounding parsing, no placeholder.
36
+ *
37
+ * @author devthink
38
+ * @license MIT
39
+ * @version 2.0.0
40
+ */
41
+
42
+ import * as os from "node:os";
43
+ import { platform as osPlatform, arch as osArch, release as osRelease } from "node:os";
44
+ import * as net from "node:net";
45
+ import * as tls from "node:tls";
46
+ import * as http from "node:http";
47
+ import * as https from "node:https";
48
+ import { createHash } from "node:crypto";
49
+ import { join as pathJoin } from "node:path";
50
+ import { mkdirSync, writeFileSync } from "node:fs";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Frozen-owner imports — identical values owned by constants.ts / core.ts /
54
+ // models.ts, re-exported below so the module surface stays unchanged.
55
+ // ---------------------------------------------------------------------------
56
+
57
+ import {
58
+ ANTIGRAVITY_USER_AGENT_FALLBACK,
59
+ ANTIGRAVITY_VERSION_FALLBACK,
60
+ DEFAULT_TIMEOUT_MS,
61
+ GENERATE_CONTENT_ENDPOINT,
62
+ PLUGIN_VERSION,
63
+ STREAM_GENERATE_ENDPOINT,
64
+ } from "./constants.js";
65
+ /* the os leaf constants (the fetch timeout and the jitter pair) stay
66
+ * devthink-owned behind the published specifier — never copied into the
67
+ * gateway. */
68
+ import { FETCH_TIMEOUT_MS, JITTER_MAX_MS, JITTER_MIN_MS } from "@wenathlan/devthink/constants";
69
+ import { fnv1a32, getJitter, isRetryableStatus } from "@wenathlan/devthink/core";
70
+ import {
71
+ buildAntagravityHeaders,
72
+ getDynamicUserAgent,
73
+ getXGoogApiClient,
74
+ normalizeArch,
75
+ normalizePlatform,
76
+ } from "./fingerprint.js";
77
+ // v2.1.15 Phase A: model-identification families (search/default model ids,
78
+ // thinking tables, image-gen helpers) are imported from models.ts — THE
79
+ // single owner — instead of the former constants.js re-exports.
80
+ import {
81
+ CODE_ASSIST_ENDPOINTS,
82
+ SEARCH_MODEL,
83
+ SEARCH_MODEL_FALLBACKS,
84
+ SEARCH_MODEL_PREVIEW,
85
+ THINKING_BUDGET_MAP,
86
+ THINKING_BUDGET_MAX_CLAUDE,
87
+ THINKING_BUDGET_MAX_GEMINI,
88
+ THINKING_LEVELS,
89
+ buildImageGenConfig,
90
+ isImageGenModel,
91
+ } from "./models.js";
92
+ import type { ThinkingLevel } from "./models.js";
93
+ // v2.1.15 Phase A: parseGrounding moved from core.ts — web-citation
94
+ // extraction is search-grounding domain logic.
95
+ function parseGrounding(meta: any): { uri: string; title?: string }[] {
96
+ const chunks = meta?.groundingChunks || meta?.grounding_chunks || [];
97
+ return chunks.map((c: any) => (c.web ? { uri: c.web.uri, title: c.web.title } : null)).filter(Boolean);
98
+ }
99
+
100
+ export {
101
+ ANTIGRAVITY_USER_AGENT_FALLBACK,
102
+ ANTIGRAVITY_VERSION_FALLBACK,
103
+ CODE_ASSIST_ENDPOINTS,
104
+ DEFAULT_TIMEOUT_MS,
105
+ GENERATE_CONTENT_ENDPOINT,
106
+ JITTER_MAX_MS,
107
+ JITTER_MIN_MS,
108
+ SEARCH_MODEL,
109
+ SEARCH_MODEL_FALLBACKS,
110
+ SEARCH_MODEL_PREVIEW,
111
+ STREAM_GENERATE_ENDPOINT,
112
+ THINKING_BUDGET_MAP,
113
+ THINKING_BUDGET_MAX_CLAUDE,
114
+ THINKING_BUDGET_MAX_GEMINI,
115
+ THINKING_LEVELS,
116
+ buildImageGenConfig,
117
+ getDynamicUserAgent,
118
+ getJitter,
119
+ isImageGenModel,
120
+ };
121
+ export type { ThinkingLevel };
122
+ export { parseGrounding };
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Constants — observer notes prod is authoritative
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** Endpoint cascade — observer watches traffic: PROD first, then DAILY, SANDBOX */
129
+ export const DEFAULT_ENDPOINT_ORDER: readonly string[] = [
130
+ CODE_ASSIST_ENDPOINTS.PROD,
131
+ CODE_ASSIST_ENDPOINTS.DAILY,
132
+ CODE_ASSIST_ENDPOINTS.SANDBOX,
133
+ ] as const;
134
+
135
+ /** Timeouts — observer notes 10s per control call, 60s for content */
136
+ export const SEARCH_TIMEOUT_MS = 60_000 as const;
137
+ // local variant: diverges from models (models.FETCH_TIMEOUT_MS is the 10s control-plane timeout; this file pins it to the 60s content timeout)
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Types — grounding contract from Gemini API
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Web chunk returned by groundingMetadata.
145
+ * Observer documents: each chunk points to a source URL used for grounding.
146
+ */
147
+ export interface WebGroundingChunk {
148
+ /** source uri, e.g. https://developer.mozilla.org/... */
149
+ uri: string;
150
+ /** title returned by search */
151
+ title: string;
152
+ /** derived domain — convenience */
153
+ domain?: string | undefined;
154
+ }
155
+
156
+ /** Raw web object inside groundingChunks */
157
+ export interface GroundingChunkWeb {
158
+ uri?: string;
159
+ title?: string;
160
+ domain?: string;
161
+ }
162
+
163
+ export interface GroundingChunk {
164
+ web?: GroundingChunkWeb;
165
+ /** alternative shapes: retrievedContext etc. — kept for resilience */
166
+ retrievedContext?: { uri?: string; title?: string; text?: string };
167
+ [k: string]: unknown;
168
+ }
169
+
170
+ export interface GroundingSupportSegment {
171
+ startIndex?: number;
172
+ endIndex?: number;
173
+ text?: string;
174
+ }
175
+
176
+ export interface GroundingSupport {
177
+ segment?: GroundingSupportSegment;
178
+ groundingChunkIndices?: number[];
179
+ confidenceScores?: number[];
180
+ }
181
+
182
+ export interface SearchEntryPoint {
183
+ renderedContent?: string;
184
+ [k: string]: unknown;
185
+ }
186
+
187
+ export interface GroundingMetadata {
188
+ webSearchQueries?: string[];
189
+ groundingChunks?: GroundingChunk[];
190
+ groundingSupports?: GroundingSupport[];
191
+ searchEntryPoint?: SearchEntryPoint;
192
+ retrievalQueries?: string[];
193
+ [k: string]: unknown;
194
+ }
195
+
196
+ /** Request shape */
197
+ export interface ExecuteSearchInput {
198
+ /** user query to ground with googleSearch/urlContext */
199
+ query: string;
200
+ /** optional system instruction — helps guide grounded answer */
201
+ systemPrompt?: string | undefined;
202
+ /** optional additional context appended to user query for better grounding */
203
+ additionalContext?: string | undefined;
204
+ }
205
+
206
+ export interface ExecuteSearchAuth {
207
+ /** OAuth access_token Bearer — required */
208
+ accessToken: string;
209
+ /** project id resolved via loadCodeAssist/onboardUser — optional but improves success */
210
+ projectId?: string;
211
+ /** optional custom user agent — fallback uses dynamic UA */
212
+ userAgent?: string;
213
+ /** optional antigravity version for UA — defaults to 1.19.2 */
214
+ version?: string;
215
+ /** optional client metadata override */
216
+ clientMetadata?: string;
217
+ }
218
+
219
+ export interface ExecuteSearchOptions {
220
+ /** model — defaults to SEARCH_MODEL gemini-3-flash */
221
+ model?: string;
222
+ /** thinking level mapping to budget */
223
+ thinkingLevel?: ThinkingLevel;
224
+ /** direct budget override — clamped 0-32768 */
225
+ thinkingBudget?: number;
226
+ /** include groundingMetadata in result — default true */
227
+ includeGrounding?: boolean;
228
+ /** inject url_context companion tool — default true per spec */
229
+ useUrlContext?: boolean;
230
+ /** inject google_search tool — default true */
231
+ useGoogleSearch?: boolean;
232
+ /** proxy url e.g. http://127.0.0.1:8080 — null disables env proxy */
233
+ proxyUrl?: string | null;
234
+ /** timeout per fetch — default 60s */
235
+ timeoutMs?: number;
236
+ /** custom endpoint list for cascade — default PROD/DAILY/SANDBOX */
237
+ endpoints?: string[];
238
+ /** include rendered search entry point? */
239
+ includeSearchEntryPoint?: boolean;
240
+ /** temperature for grounded answer — default 0.2 */
241
+ temperature?: number;
242
+ /** max output tokens — default 8192 */
243
+ maxOutputTokens?: number;
244
+ /** debug logging */
245
+ debug?: boolean;
246
+ /** jitter between endpoint retries — default true */
247
+ jitter?: boolean;
248
+ /** max retries across endpoints — internal */
249
+ maxRetries?: number;
250
+ }
251
+
252
+ /** Final grounded result */
253
+ export interface SearchResult {
254
+ /** synthesized answer text */
255
+ text: string;
256
+ /** original query */
257
+ query: string;
258
+ /** model used */
259
+ model: string;
260
+ /** true if grounding sources returned */
261
+ grounded: boolean;
262
+ /** queries Gemini issued to Google Search */
263
+ webSearchQueries: string[];
264
+ /** parsed web sources — uri/title */
265
+ groundingChunks: WebGroundingChunk[];
266
+ /** raw supports — segment attribution */
267
+ groundingSupports?: GroundingSupport[] | undefined;
268
+ /** rendered search entry point HTML if present */
269
+ searchEntryPoint?: SearchEntryPoint | undefined;
270
+ /** raw groundingMetadata as returned */
271
+ groundingMetadata?: GroundingMetadata | undefined;
272
+ /** raw candidate response for debugging */
273
+ raw?: unknown | undefined;
274
+ /** endpoint that succeeded */
275
+ endpointUsed?: string | undefined;
276
+ /** thinking budget used */
277
+ thinkingBudgetUsed?: number | undefined;
278
+ /** was fallback project used? */
279
+ isFallbackProject?: boolean;
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Error — production-ready with status context
284
+ // ---------------------------------------------------------------------------
285
+
286
+ export class SearchError extends Error {
287
+ public readonly status?: number | undefined;
288
+ public readonly endpoint?: string | undefined;
289
+ public readonly retryable: boolean;
290
+ public readonly code?: string | undefined;
291
+
292
+ constructor(
293
+ message: string,
294
+ opts: { status?: number; endpoint?: string; retryable?: boolean; code?: string; cause?: unknown } = {},
295
+ ) {
296
+ super(message);
297
+ this.name = "SearchError";
298
+ this.status = opts.status;
299
+ this.endpoint = opts.endpoint;
300
+ this.retryable = opts.retryable ?? false;
301
+ this.code = opts.code;
302
+ if (opts.cause) (this as any).cause = opts.cause;
303
+ }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Observer helpers — third-person, coherent with fingerprint.ts / project.ts
308
+ // ---------------------------------------------------------------------------
309
+
310
+ // v2.1.16: the local normalizePlatform/normalizeArch copies were deleted —
311
+ // byte-identical (every branch) to the fingerprint.ts exports, which are now
312
+ // imported above (search is L4, fingerprint is L2 — downward import, allowed).
313
+
314
+ // getDynamicUserAgent is imported from constants.js (identical signature and
315
+ // outputs; the owner's trim placement differs but the fallback constant carries
316
+ // no whitespace, so results match for every input) and re-exported above.
317
+
318
+ // v2.1.16: the stale hardcoded pluginVersion literal (2.1.14) was replaced
319
+ // by the constants.js PLUGIN_VERSION import — the version-envelope owner — so
320
+ // the Client-Metadata header stops drifting behind the release version. The
321
+ // ANTIGRAVITY ideType/ideVersion/osVersion/arch fields stay local (the
322
+ // fingerprint.ts owner builder derives them differently).
323
+ function buildClientMetadata(version?: string): string {
324
+ const ver = version ?? ANTIGRAVITY_VERSION_FALLBACK;
325
+ const plat = normalizePlatform(osPlatform()).toUpperCase();
326
+ const archUpper = normalizeArch(osArch()).toUpperCase();
327
+ const parts = [
328
+ `ideType=ANTIGRAVITY`,
329
+ `platform=${plat}`,
330
+ `ideVersion=${ver}`,
331
+ `pluginVersion=${PLUGIN_VERSION}`,
332
+ `osVersion=${osRelease()}`,
333
+ `arch=${archUpper}`,
334
+ ];
335
+ return parts.join(",");
336
+ }
337
+
338
+ // getXGoogApiClient is imported from constants.js (byte-identical body:
339
+ // `antigravity/{ver} gl-node/{node} gax/4.9.0 grpc/1.14.0`).
340
+
341
+ // local variant: diverges from models (models.buildAntagravityHeaders takes an optional UA, omits X-Goog-Api-Client/Client-Metadata and strips FORBIDDEN_HEADERS; this variant requires the UA, emits both bypass headers and strips only x-goog-user-project)
342
+
343
+ /**
344
+ * Builds Antigravity header map for cloudcode-pa.
345
+ * Observer notes: AM only sends User-Agent on content requests, not X-Goog-Api-Client
346
+ * for onboardUser/loadCodeAssist, but for generateContent it sends both.
347
+ * Also strips x-goog-user-project to avoid 403 IAM (requirement).
348
+ *
349
+ * @param accessToken - bearer
350
+ * @param userAgent - UA string
351
+ * @param extra - extra headers
352
+ */
353
+
354
+ /**
355
+ * FNV-1a 32-bit — deterministic hashing for logs, not secrets.
356
+ */
357
+ // local variant: diverges from core (uses plain multiplication `(hash * 0x01000193) >>> 0` instead of Math.imul; floats lose low bits so results differ from core/models for most inputs)
358
+
359
+ function hashToken(token: string): string {
360
+ try {
361
+ return createHash("sha256").update(token).digest("hex").slice(0, 16);
362
+ } catch {
363
+ return fnv1a32(token).toString(16);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Jitter 0-80ms anti-rate-limit — observed in AM.
369
+ * Identical to constants.getJitter — imported from the frozen owner and
370
+ * re-exported above.
371
+ */
372
+
373
+ function sleep(ms: number): Promise<void> {
374
+ return new Promise((r) => setTimeout(r, ms));
375
+ }
376
+
377
+ /**
378
+ * Checks if HTTP status is retryable for endpoint cascade.
379
+ * Observer: 403 project IAM mismatch, 404 rollout, 5xx instability → try next endpoint.
380
+ */
381
+ // local variant: diverges from models (models.isRetryableStatus also treats 408 as retryable; this cascade variant does not)
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Proxy support — only node:* builtins + fetch
385
+ // ---------------------------------------------------------------------------
386
+
387
+ export interface ProxyResolution {
388
+ /** final proxy url to use, null if disabled */
389
+ proxyUrl: string | null;
390
+ /** reason / source for debugging */
391
+ source: "option" | "env_https_proxy" | "env_http_proxy" | "env_all_proxy" | "disabled" | "none" | "no_proxy_bypass";
392
+ }
393
+
394
+ /**
395
+ * Parses NO_PROXY env — comma separated list of host suffixes, IPs, CIDRs (simplified).
396
+ */
397
+ export function isNoProxyMatch(targetHost: string, noProxyRaw: string | undefined): boolean {
398
+ if (!noProxyRaw) return false;
399
+ const list = noProxyRaw
400
+ .split(",")
401
+ .map((s) => s.trim().toLowerCase())
402
+ .filter(Boolean);
403
+ if (list.length === 0) return false;
404
+ const host = targetHost.toLowerCase();
405
+ for (const pattern of list) {
406
+ if (pattern === "*" || pattern === "*.*") return true;
407
+ if (pattern === host) return true;
408
+ if (pattern.startsWith(".")) {
409
+ // .example.com matches foo.example.com
410
+ if (host.endsWith(pattern)) return true;
411
+ }
412
+ if (host.endsWith(`.${pattern}`)) return true;
413
+ if (pattern.startsWith("*.")) {
414
+ const suffix = pattern.slice(2);
415
+ if (host === suffix || host.endsWith(`.${suffix}`)) return true;
416
+ }
417
+ }
418
+ return false;
419
+ }
420
+
421
+ /**
422
+ * Resolves proxy URL respecting options.proxyUrl, env vars, and NO_PROXY.
423
+ * Observer scans env case-insensitively as per curl conventions.
424
+ *
425
+ * @param optionsProxy - explicit option, string or null (null disables)
426
+ * @param targetUrl - target to check NO_PROXY against
427
+ */
428
+ export function resolveProxyUrl(optionsProxy: string | null | undefined, targetUrl?: string): ProxyResolution {
429
+ // explicit null disables proxy even if env present
430
+ if (optionsProxy === null) {
431
+ return { proxyUrl: null, source: "disabled" };
432
+ }
433
+ if (typeof optionsProxy === "string" && optionsProxy.trim().length > 0) {
434
+ return { proxyUrl: optionsProxy.trim(), source: "option" };
435
+ }
436
+
437
+ // env var resolution — order HTTPS_PROXY > HTTP_PROXY > ALL_PROXY
438
+ const envHttps = process.env.HTTPS_PROXY || process.env.https_proxy;
439
+ const envHttp = process.env.HTTP_PROXY || process.env.http_proxy;
440
+ const envAll = process.env.ALL_PROXY || process.env.all_proxy;
441
+ const noProxyRaw = process.env.NO_PROXY || process.env.no_proxy;
442
+
443
+ if (targetUrl) {
444
+ try {
445
+ const u = new URL(targetUrl);
446
+ if (isNoProxyMatch(u.hostname, noProxyRaw)) {
447
+ return { proxyUrl: null, source: "no_proxy_bypass" };
448
+ }
449
+ } catch {
450
+ // ignore malformed target
451
+ }
452
+ }
453
+
454
+ if (envHttps && envHttps.trim()) {
455
+ return { proxyUrl: envHttps.trim(), source: "env_https_proxy" };
456
+ }
457
+ if (envHttp && envHttp.trim()) {
458
+ return { proxyUrl: envHttp.trim(), source: "env_http_proxy" };
459
+ }
460
+ if (envAll && envAll.trim()) {
461
+ return { proxyUrl: envAll.trim(), source: "env_all_proxy" };
462
+ }
463
+ return { proxyUrl: null, source: "none" };
464
+ }
465
+
466
+ /**
467
+ * Builds Proxy-Authorization header if proxy URL contains credentials.
468
+ */
469
+ function getProxyAuthHeader(proxyUrl: URL): string | null {
470
+ if (proxyUrl.username) {
471
+ try {
472
+ const user = decodeURIComponent(proxyUrl.username);
473
+ const pass = decodeURIComponent(proxyUrl.password);
474
+ const token = Buffer.from(`${user}:${pass}`).toString("base64");
475
+ return `Basic ${token}`;
476
+ } catch {
477
+ return null;
478
+ }
479
+ }
480
+ return null;
481
+ }
482
+
483
+ /**
484
+ * Decodes chunked transfer-encoding.
485
+ * Observer implements minimal but spec-compliant decoder for proxy HTTPS path.
486
+ */
487
+ function decodeChunked(body: string): string {
488
+ let out = "";
489
+ let i = 0;
490
+ const len = body.length;
491
+ while (i < len) {
492
+ const crlf = body.indexOf("\r\n", i);
493
+ if (crlf === -1) break;
494
+ const line = body.slice(i, crlf).trim();
495
+ if (!line) {
496
+ i = crlf + 2;
497
+ continue;
498
+ }
499
+ // chunk size may have extension ; — ignore
500
+ const semi = line.indexOf(";");
501
+ const sizeHex = semi === -1 ? line : line.slice(0, semi);
502
+ const size = parseInt(sizeHex, 16);
503
+ if (Number.isNaN(size)) break;
504
+ if (size === 0) break; // last chunk
505
+ i = crlf + 2;
506
+ if (i + size > len) {
507
+ // incomplete — take what we have
508
+ out += body.slice(i);
509
+ break;
510
+ }
511
+ out += body.slice(i, i + size);
512
+ i += size;
513
+ // skip trailing CRLF after chunk
514
+ if (body.slice(i, i + 2) === "\r\n") i += 2;
515
+ }
516
+ return out;
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // Low-level proxy fetch implementations — only node:net/tls/http/https
521
+ // ---------------------------------------------------------------------------
522
+
523
+ interface RawProxyResult {
524
+ status: number;
525
+ statusText: string;
526
+ headers: Record<string, string>;
527
+ bodyText: string;
528
+ }
529
+
530
+ interface ProxyFetchResponse {
531
+ ok: boolean;
532
+ status: number;
533
+ statusText: string;
534
+ headers: { get: (name: string) => string | null; [k: string]: any };
535
+ text: () => Promise<string>;
536
+ json: () => Promise<any>;
537
+ arrayBuffer?: () => Promise<ArrayBuffer>;
538
+ }
539
+
540
+ /**
541
+ * Creates a fetch-like Response wrapper from raw proxy result.
542
+ */
543
+ function wrapRawResult(raw: RawProxyResult): ProxyFetchResponse {
544
+ const lowerHeaders: Record<string, string> = {};
545
+ for (const [k, v] of Object.entries(raw.headers)) lowerHeaders[k.toLowerCase()] = v;
546
+
547
+ return {
548
+ ok: raw.status >= 200 && raw.status < 300,
549
+ status: raw.status,
550
+ statusText: raw.statusText,
551
+ headers: {
552
+ get: (name: string) => {
553
+ const v = lowerHeaders[name.toLowerCase()];
554
+ return v ?? null;
555
+ },
556
+ ...raw.headers,
557
+ },
558
+ text: async () => raw.bodyText,
559
+ json: async () => {
560
+ try {
561
+ return JSON.parse(raw.bodyText);
562
+ } catch (e: any) {
563
+ throw new SearchError(
564
+ `proxy response not JSON: ${e?.message ?? String(e)} — body: ${raw.bodyText.slice(0, 500)}`,
565
+ {
566
+ status: raw.status,
567
+ },
568
+ );
569
+ }
570
+ },
571
+ };
572
+ }
573
+
574
+ /**
575
+ * HTTP target via HTTP proxy — absolute URL in request line.
576
+ * Observer uses node:http.request which already handles chunked.
577
+ *
578
+ * @param targetUrl - full target url
579
+ * @param proxyUrl - proxy url object
580
+ * @param init - fetch init
581
+ * @param timeoutMs - timeout
582
+ * @param signal - abort signal
583
+ */
584
+ function httpRequestViaHttpProxy(
585
+ targetUrl: URL,
586
+ proxyUrl: URL,
587
+ init: RequestInit,
588
+ timeoutMs: number,
589
+ signal?: AbortSignal,
590
+ ): Promise<RawProxyResult> {
591
+ return new Promise<RawProxyResult>((resolve, reject) => {
592
+ const method = init.method ?? "POST";
593
+ const bodyStr = typeof init.body === "string" ? init.body : init.body ? (init.body as any).toString() : undefined;
594
+
595
+ const proxyAuth = getProxyAuthHeader(proxyUrl);
596
+ const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
597
+
598
+ // Ensure proxy-required headers
599
+ if (!headers["Host"]) headers["Host"] = targetUrl.host;
600
+ if (proxyAuth) headers["Proxy-Authorization"] = proxyAuth;
601
+ if (!headers["Connection"]) headers["Connection"] = "close";
602
+
603
+ const reqOpts: http.RequestOptions = {
604
+ host: proxyUrl.hostname,
605
+ port: proxyUrl.port ? parseInt(proxyUrl.port, 10) : 80,
606
+ method,
607
+ path: targetUrl.toString(), // absolute URI for HTTP proxy
608
+ headers,
609
+ };
610
+
611
+ let finished = false;
612
+ const cleanup = () => {
613
+ finished = true;
614
+ };
615
+
616
+ const req = http.request(reqOpts, (res) => {
617
+ const chunks: Buffer[] = [];
618
+ res.on("data", (c: Buffer) => chunks.push(c));
619
+ res.on("end", () => {
620
+ if (finished) return;
621
+ cleanup();
622
+ const bodyBuf = Buffer.concat(chunks);
623
+ const bodyText = bodyBuf.toString("utf8");
624
+ const hdrs: Record<string, string> = {};
625
+ for (const [k, v] of Object.entries(res.headers)) {
626
+ if (Array.isArray(v)) hdrs[k.toLowerCase()] = v.join(", ");
627
+ else if (v) hdrs[k.toLowerCase()] = String(v);
628
+ }
629
+ resolve({
630
+ status: res.statusCode ?? 0,
631
+ statusText: res.statusMessage ?? "",
632
+ headers: hdrs,
633
+ bodyText,
634
+ });
635
+ });
636
+ });
637
+
638
+ req.on("error", (err) => {
639
+ if (finished) return;
640
+ cleanup();
641
+ reject(err);
642
+ });
643
+
644
+ if (timeoutMs) {
645
+ req.setTimeout(timeoutMs, () => {
646
+ req.destroy(new Error(`proxy http request timeout after ${timeoutMs}ms to ${targetUrl.hostname}`));
647
+ });
648
+ }
649
+
650
+ if (signal) {
651
+ if (signal.aborted) {
652
+ req.destroy(new Error("aborted"));
653
+ return;
654
+ }
655
+ const onAbort = () => {
656
+ req.destroy(new Error("aborted by signal"));
657
+ };
658
+ signal.addEventListener("abort", onAbort, { once: true });
659
+ req.on("close", () => signal.removeEventListener("abort", onAbort));
660
+ }
661
+
662
+ if (bodyStr) req.write(bodyStr);
663
+ req.end();
664
+ });
665
+ }
666
+
667
+ /**
668
+ * HTTPS target via HTTP proxy using CONNECT tunneling + TLS.
669
+ * Only node:net + node:tls builtins — observer builds tunnel manually.
670
+ *
671
+ * Flow:
672
+ * 1. TCP connect to proxy
673
+ * 2. Send CONNECT host:443
674
+ * 3. Wait for 200
675
+ * 4. TLS over socket
676
+ * 5. Send HTTPS request manually
677
+ *
678
+ * @param targetUrl - https url
679
+ * @param proxyUrl - http proxy url
680
+ * @param init - fetch init
681
+ * @param timeoutMs - timeout
682
+ * @param signal - abort signal
683
+ */
684
+ function httpsRequestViaHttpProxyConnect(
685
+ targetUrl: URL,
686
+ proxyUrl: URL,
687
+ init: RequestInit,
688
+ timeoutMs: number,
689
+ signal?: AbortSignal,
690
+ ): Promise<RawProxyResult> {
691
+ return new Promise<RawProxyResult>((resolve, reject) => {
692
+ const targetHost = targetUrl.hostname;
693
+ const targetPort = targetUrl.port ? parseInt(targetUrl.port, 10) : 443;
694
+ const proxyHost = proxyUrl.hostname;
695
+ const proxyPort = proxyUrl.port ? parseInt(proxyUrl.port, 10) : 8080;
696
+
697
+ let settled = false;
698
+ const doReject = (err: any) => {
699
+ if (settled) return;
700
+ settled = true;
701
+ reject(err);
702
+ };
703
+ const doResolve = (v: RawProxyResult) => {
704
+ if (settled) return;
705
+ settled = true;
706
+ resolve(v);
707
+ };
708
+
709
+ const socket = net.createConnection({ host: proxyHost, port: proxyPort });
710
+
711
+ if (signal?.aborted) {
712
+ socket.destroy(new Error("aborted before connect"));
713
+ return;
714
+ }
715
+
716
+ const abortHandler = () => {
717
+ socket.destroy(new Error("aborted by signal"));
718
+ };
719
+ signal?.addEventListener("abort", abortHandler, { once: true });
720
+
721
+ let timeoutId: NodeJS.Timeout | null = null;
722
+ if (timeoutMs) {
723
+ timeoutId = setTimeout(() => {
724
+ socket.destroy(new Error(`proxy CONNECT timeout ${timeoutMs}ms`));
725
+ }, timeoutMs);
726
+ }
727
+
728
+ socket.on("error", (err) => {
729
+ if (timeoutId) clearTimeout(timeoutId);
730
+ signal?.removeEventListener("abort", abortHandler);
731
+ doReject(err);
732
+ });
733
+
734
+ socket.on("connect", () => {
735
+ const proxyAuth = getProxyAuthHeader(proxyUrl);
736
+ const authLine = proxyAuth ? `Proxy-Authorization: ${proxyAuth}\r\n` : "";
737
+ const connectReq =
738
+ `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` +
739
+ `Host: ${targetHost}:${targetPort}\r\n` +
740
+ `${authLine}` +
741
+ `Proxy-Connection: Keep-Alive\r\n` +
742
+ `Connection: Keep-Alive\r\n\r\n`;
743
+ socket.write(connectReq);
744
+ });
745
+
746
+ let connectBuf = "";
747
+ const onConnectData = (chunk: Buffer) => {
748
+ connectBuf += chunk.toString("utf8");
749
+ if (!connectBuf.includes("\r\n\r\n")) return; // wait full header
750
+
751
+ socket.off("data", onConnectData);
752
+
753
+ const headerEnd = connectBuf.indexOf("\r\n\r\n");
754
+ const headerPart = connectBuf.slice(0, headerEnd);
755
+ const lines = headerPart.split("\r\n");
756
+ const statusLine = lines[0] ?? "";
757
+ const m = statusLine.match(/HTTP\/\d\.\d\s+(\d+)/);
758
+ const status = m ? parseInt(m[1]!, 10) : 0;
759
+
760
+ if (status !== 200) {
761
+ if (timeoutId) clearTimeout(timeoutId);
762
+ signal?.removeEventListener("abort", abortHandler);
763
+ socket.destroy();
764
+ doReject(new SearchError(`proxy CONNECT failed: ${statusLine}`, { status, retryable: status >= 500 }));
765
+ return;
766
+ }
767
+
768
+ // Tunnel established — leftover after \r\n\r\n may be start of TLS handshake? Actually not, it should be empty.
769
+ // Now TLS over socket
770
+ const tlsSocket = tls.connect({
771
+ socket: socket as any,
772
+ servername: targetHost,
773
+ ALPNProtocols: ["http/1.1"],
774
+ } as any);
775
+
776
+ tlsSocket.on("error", (err) => {
777
+ if (timeoutId) clearTimeout(timeoutId);
778
+ signal?.removeEventListener("abort", abortHandler);
779
+ doReject(err);
780
+ });
781
+
782
+ if (timeoutMs) {
783
+ tlsSocket.setTimeout(timeoutMs, () => {
784
+ tlsSocket.destroy(new Error(`TLS request timeout ${timeoutMs}ms`));
785
+ });
786
+ }
787
+
788
+ tlsSocket.on("secureConnect", () => {
789
+ const method = init.method ?? "POST";
790
+ const path = `${targetUrl.pathname}${targetUrl.search}`;
791
+ const initHeaders = (init.headers as Record<string, string>) ?? {};
792
+ const headers: Record<string, string> = { ...initHeaders };
793
+
794
+ if (!headers["Host"]) headers["Host"] = targetUrl.host;
795
+ if (!headers["Connection"]) headers["Connection"] = "close";
796
+
797
+ const bodyStr =
798
+ typeof init.body === "string"
799
+ ? init.body
800
+ : init.body
801
+ ? Buffer.isBuffer(init.body)
802
+ ? (init.body as Buffer).toString("utf8")
803
+ : String(init.body)
804
+ : "";
805
+
806
+ if (bodyStr && !headers["Content-Length"]) {
807
+ headers["Content-Length"] = Buffer.byteLength(bodyStr).toString();
808
+ }
809
+
810
+ let reqStr = `${method} ${path} HTTP/1.1\r\n`;
811
+ for (const [k, v] of Object.entries(headers)) {
812
+ reqStr += `${k}: ${v}\r\n`;
813
+ }
814
+ reqStr += `\r\n`;
815
+ if (bodyStr) reqStr += bodyStr;
816
+
817
+ tlsSocket.write(reqStr);
818
+ });
819
+
820
+ let respBuf = Buffer.alloc(0);
821
+ tlsSocket.on("data", (d: Buffer) => {
822
+ respBuf = Buffer.concat([respBuf, d]);
823
+ });
824
+
825
+ tlsSocket.on("end", () => {
826
+ if (timeoutId) clearTimeout(timeoutId);
827
+ signal?.removeEventListener("abort", abortHandler);
828
+ try {
829
+ const respStr = respBuf.toString("utf8");
830
+ const hdrEnd = respStr.indexOf("\r\n\r\n");
831
+ if (hdrEnd === -1) {
832
+ doReject(new SearchError(`invalid HTTPS response via proxy tunnel from ${targetHost}`));
833
+ return;
834
+ }
835
+ const hdrPart = respStr.slice(0, hdrEnd);
836
+ let bodyPart = respStr.slice(hdrEnd + 4);
837
+ const hdrLines = hdrPart.split("\r\n");
838
+ const statusLine = hdrLines[0] ?? "";
839
+ const sm = statusLine.match(/HTTP\/\d\.\d\s+(\d+)\s*(.*)/);
840
+ const statusCode = sm ? parseInt(sm[1]!, 10) : 0;
841
+ const statusText = sm ? (sm[2] ?? "") : "";
842
+
843
+ const hdrs: Record<string, string> = {};
844
+ for (let i = 1; i < hdrLines.length; i++) {
845
+ const line = hdrLines[i]!;
846
+ const idx = line.indexOf(":");
847
+ if (idx === -1) continue;
848
+ const k = line.slice(0, idx).trim().toLowerCase();
849
+ const v = line.slice(idx + 1).trim();
850
+ hdrs[k] = v;
851
+ }
852
+
853
+ // Handle chunked
854
+ if (hdrs["transfer-encoding"]?.toLowerCase() === "chunked") {
855
+ bodyPart = decodeChunked(bodyPart);
856
+ }
857
+
858
+ doResolve({
859
+ status: statusCode,
860
+ statusText,
861
+ headers: hdrs,
862
+ bodyText: bodyPart,
863
+ });
864
+ } catch (e: any) {
865
+ doReject(new SearchError(`failed parsing HTTPS proxy response: ${e?.message ?? String(e)}`, { cause: e }));
866
+ } finally {
867
+ try {
868
+ tlsSocket.destroy();
869
+ } catch {
870
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
871
+ }
872
+ }
873
+ });
874
+ };
875
+
876
+ socket.on("data", onConnectData);
877
+ });
878
+ }
879
+
880
+ /**
881
+ * Generic proxy-aware fetch — observer tries native fetch if no proxy,
882
+ * otherwise manual CONNECT for https or absolute-URL for http.
883
+ *
884
+ * @param url - target
885
+ * @param init - fetch init
886
+ * @param proxyUrlStr - resolved proxy url string or null
887
+ * @param timeoutMs - timeout
888
+ */
889
+ async function fetchWithProxy(
890
+ url: string,
891
+ init: RequestInit,
892
+ proxyUrlStr: string | null,
893
+ timeoutMs: number,
894
+ ): Promise<{
895
+ ok: boolean;
896
+ status: number;
897
+ statusText?: string;
898
+ headers: any;
899
+ text: () => Promise<string>;
900
+ json: () => Promise<any>;
901
+ }> {
902
+ if (!proxyUrlStr) {
903
+ // native path — with timeout + signal merge
904
+ const controller = new AbortController();
905
+ const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms ${url}`)), timeoutMs);
906
+
907
+ // merge external signal if present
908
+ const externalSignal = init.signal as AbortSignal | undefined;
909
+ let onExternalAbort: (() => void) | null = null;
910
+ if (externalSignal) {
911
+ if (externalSignal.aborted) {
912
+ clearTimeout(timer);
913
+ controller.abort(externalSignal.reason);
914
+ } else {
915
+ onExternalAbort = () => controller.abort(externalSignal.reason);
916
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
917
+ }
918
+ }
919
+
920
+ try {
921
+ const res = await fetch(url, { ...init, signal: controller.signal });
922
+ return res as any;
923
+ } finally {
924
+ clearTimeout(timer);
925
+ if (onExternalAbort && externalSignal) {
926
+ externalSignal.removeEventListener("abort", onExternalAbort);
927
+ }
928
+ }
929
+ }
930
+
931
+ // proxy path
932
+ let proxyUrl: URL;
933
+ let targetUrl: URL;
934
+ try {
935
+ proxyUrl = new URL(proxyUrlStr);
936
+ } catch {
937
+ throw new SearchError(`invalid proxyUrl: ${proxyUrlStr}`);
938
+ }
939
+ try {
940
+ targetUrl = new URL(url);
941
+ } catch {
942
+ throw new SearchError(`invalid target url: ${url}`);
943
+ }
944
+
945
+ const method = init.method ?? "POST";
946
+
947
+ const proxyResult =
948
+ targetUrl.protocol === "https:"
949
+ ? await httpsRequestViaHttpProxyConnect(
950
+ targetUrl,
951
+ proxyUrl,
952
+ { ...init, method },
953
+ timeoutMs,
954
+ init.signal as AbortSignal | undefined,
955
+ )
956
+ : await httpRequestViaHttpProxy(
957
+ targetUrl,
958
+ proxyUrl,
959
+ { ...init, method },
960
+ timeoutMs,
961
+ init.signal as AbortSignal | undefined,
962
+ );
963
+
964
+ return wrapRawResult(proxyResult) as any;
965
+ }
966
+
967
+ // ---------------------------------------------------------------------------
968
+ // Fetch with timeout helper — mirrors project.ts fetchWithTimeout
969
+ // ---------------------------------------------------------------------------
970
+
971
+ /**
972
+ * Fetch with timeout using AbortController.
973
+ * Observer pattern: never leaks abort timer.
974
+ *
975
+ * @param url - url
976
+ * @param init - init
977
+ * @param timeoutMs - timeout
978
+ * @param proxyUrl - optional proxy
979
+ */
980
+ // local variant: diverges from models (models.fetchWithTimeout takes `init.timeoutMs` and aborts a plain fetch; this variant takes an explicit timeoutMs plus proxyUrl and routes through fetchWithProxy)
981
+ export async function fetchWithProxyTimeout(
982
+ url: string,
983
+ init: RequestInit,
984
+ timeoutMs: number = FETCH_TIMEOUT_MS,
985
+ proxyUrl: string | null = null,
986
+ ): Promise<any> {
987
+ return fetchWithProxy(url, init, proxyUrl, timeoutMs);
988
+ }
989
+
990
+ // ---------------------------------------------------------------------------
991
+ // Thinking budget resolver — per RESEARCH.md 100+ terms
992
+ // ---------------------------------------------------------------------------
993
+
994
+ /**
995
+ * Resolves thinking budget from level or direct number.
996
+ * Observer clamps to Gemini max 32768.
997
+ *
998
+ * @param level - thinkingLevel minimal/low/medium/high
999
+ * @param directBudget - optional direct thinkingBudget
1000
+ * @returns clamped budget number
1001
+ */
1002
+ export function resolveThinkingBudget(level?: ThinkingLevel, directBudget?: number): number {
1003
+ if (typeof directBudget === "number" && Number.isFinite(directBudget)) {
1004
+ const clamped = Math.max(0, Math.min(directBudget, THINKING_BUDGET_MAX_GEMINI));
1005
+ return Math.floor(clamped);
1006
+ }
1007
+ if (level && (THINKING_LEVELS as readonly string[]).includes(level)) {
1008
+ return THINKING_BUDGET_MAP[level] ?? THINKING_BUDGET_MAP.medium;
1009
+ }
1010
+ // default medium per governance
1011
+ return THINKING_BUDGET_MAP.medium;
1012
+ }
1013
+
1014
+ // ---------------------------------------------------------------------------
1015
+ // Model helpers — 2026 awareness
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ /**
1019
+ * Normalizes model name — strips models/ prefix for body, keeps original for logging.
1020
+ * Observer notes cloudcode-pa accepts both gemini-3-flash and models/gemini-3-flash.
1021
+ *
1022
+ * @param raw - model id
1023
+ */
1024
+ export function normalizeModelName(raw: string): { bare: string; withPrefix: string; original: string } {
1025
+ const original = String(raw ?? "").trim();
1026
+ const bare = original.replace(/^models\//i, "").trim();
1027
+ const withPrefix = `models/${bare}`;
1028
+ return { bare, withPrefix, original };
1029
+ }
1030
+
1031
+ /**
1032
+ * Builds request body for cloudcode-pa v1internal:generateContent.
1033
+ * Observer has seen two shapes in the wild; prefers wrapped shape when projectId present.
1034
+ *
1035
+ * Shape A (Antigravity internal with project):
1036
+ * { model, project, request: { contents, tools, generationConfig, systemInstruction } }
1037
+ *
1038
+ * Shape B (flat Gemini SA):
1039
+ * { model, contents, tools, generationConfig, systemInstruction }
1040
+ *
1041
+ * @param model - bare model
1042
+ * @param projectId - optional project
1043
+ * @param contents - Gemini contents array
1044
+ * @param tools - googleSearch + urlContext tools
1045
+ * @param generationConfig - thinking + temp
1046
+ * @param systemInstruction - optional system instruction
1047
+ */
1048
+ function buildGenerateContentBody(opts: {
1049
+ model: string;
1050
+ projectId?: string | undefined;
1051
+ contents: any[];
1052
+ tools: any[];
1053
+ generationConfig: any;
1054
+ systemInstruction?: any | undefined;
1055
+ }): any {
1056
+ const { model, projectId, contents, tools, generationConfig, systemInstruction } = opts;
1057
+
1058
+ if (projectId) {
1059
+ // wrapped — observed in AM content traffic
1060
+ return {
1061
+ model,
1062
+ project: projectId,
1063
+ request: {
1064
+ contents,
1065
+ tools,
1066
+ generationConfig,
1067
+ ...(systemInstruction ? { systemInstruction } : {}),
1068
+ },
1069
+ };
1070
+ }
1071
+ // flat
1072
+ return {
1073
+ model,
1074
+ contents,
1075
+ tools,
1076
+ generationConfig,
1077
+ ...(systemInstruction ? { systemInstruction } : {}),
1078
+ };
1079
+ }
1080
+
1081
+ /**
1082
+ * Builds alternative body with models/ prefix if primary fails with 404/400.
1083
+ * Fallback chain: bare → withPrefix → wrapped with other model variant.
1084
+ */
1085
+ function buildAlternativeBodies(
1086
+ primaryBody: any,
1087
+ normalized: ReturnType<typeof normalizeModelName>,
1088
+ projectId?: string,
1089
+ ): any[] {
1090
+ const bodies: any[] = [];
1091
+ const { bare, withPrefix } = normalized;
1092
+
1093
+ // 1. try bare flat if primary was wrapped
1094
+ if (primaryBody.project) {
1095
+ bodies.push({
1096
+ model: bare,
1097
+ contents: primaryBody.request.contents,
1098
+ tools: primaryBody.request.tools,
1099
+ generationConfig: primaryBody.request.generationConfig,
1100
+ ...(primaryBody.request.systemInstruction ? { systemInstruction: primaryBody.request.systemInstruction } : {}),
1101
+ });
1102
+ }
1103
+ // 2. try withPrefix
1104
+ bodies.push({
1105
+ ...primaryBody,
1106
+ model: withPrefix,
1107
+ });
1108
+ if (primaryBody.request) {
1109
+ bodies.push({
1110
+ model: withPrefix,
1111
+ project: projectId,
1112
+ request: { ...primaryBody.request },
1113
+ });
1114
+ bodies.push({
1115
+ model: withPrefix,
1116
+ contents: primaryBody.request.contents,
1117
+ tools: primaryBody.request.tools,
1118
+ generationConfig: primaryBody.request.generationConfig,
1119
+ ...(primaryBody.request.systemInstruction ? { systemInstruction: primaryBody.request.systemInstruction } : {}),
1120
+ });
1121
+ } else {
1122
+ bodies.push({
1123
+ model: withPrefix,
1124
+ contents: primaryBody.contents,
1125
+ tools: primaryBody.tools,
1126
+ generationConfig: primaryBody.generationConfig,
1127
+ ...(primaryBody.systemInstruction ? { systemInstruction: primaryBody.systemInstruction } : {}),
1128
+ });
1129
+ }
1130
+ // 3. try fallback models from SEARCH_MODEL_FALLBACKS
1131
+ for (const fallbackModel of SEARCH_MODEL_FALLBACKS) {
1132
+ if (fallbackModel === bare || fallbackModel === withPrefix) continue;
1133
+ bodies.push({
1134
+ model: fallbackModel,
1135
+ project: projectId,
1136
+ request: primaryBody.request ?? {
1137
+ contents: primaryBody.contents,
1138
+ tools: primaryBody.tools,
1139
+ generationConfig: primaryBody.generationConfig,
1140
+ },
1141
+ });
1142
+ if (bodies.length > 8) break;
1143
+ }
1144
+ return bodies;
1145
+ }
1146
+
1147
+ // ---------------------------------------------------------------------------
1148
+ // Grounding parse — rigorous, handles heterogenous server shapes
1149
+ // ---------------------------------------------------------------------------
1150
+
1151
+ /**
1152
+ * Extracts text from Gemini candidate content parts.
1153
+ * Observer notes parts may be [{text}, {thought}, {inlineData}] — only text counted.
1154
+ *
1155
+ * @param candidate - candidate object
1156
+ */
1157
+ function extractTextFromCandidate(candidate: any): string {
1158
+ if (!candidate) return "";
1159
+ // direct text field (some internal shapes)
1160
+ if (typeof candidate.text === "string") return candidate.text;
1161
+ if (candidate.content?.parts && Array.isArray(candidate.content.parts)) {
1162
+ const texts: string[] = [];
1163
+ for (const part of candidate.content.parts) {
1164
+ if (part && typeof part.text === "string" && part.text.length > 0) {
1165
+ // thought parts are flagged think? but per spec includeThoughts=false we get only answer
1166
+ if (part.thought === true) continue;
1167
+ texts.push(part.text);
1168
+ }
1169
+ }
1170
+ if (texts.length > 0) return texts.join("\n");
1171
+ }
1172
+ // alternative: candidates[0].content.parts aggregation handled outside
1173
+ return "";
1174
+ }
1175
+
1176
+ /**
1177
+ * Parses groundingMetadata from various response shapes.
1178
+ * Observer is resilient: checks candidate.groundingMetadata, response.groundingMetadata,
1179
+ * and alternative field grounding_metadata.
1180
+ *
1181
+ * @param response - full JSON response
1182
+ * @param candidate - first candidate
1183
+ */
1184
+ function parseGroundingMetadataFromResponse(response: any, candidate: any): GroundingMetadata | undefined {
1185
+ if (candidate?.groundingMetadata) return candidate.groundingMetadata as GroundingMetadata;
1186
+ if (candidate?.grounding_metadata) return candidate.grounding_metadata as GroundingMetadata;
1187
+ if (response?.groundingMetadata) return response.groundingMetadata as GroundingMetadata;
1188
+ if (response?.grounding_metadata) return response.grounding_metadata as GroundingMetadata;
1189
+ // sometimes in candidates[0].citationMetadata.grounding? not
1190
+ if (response?.candidates && Array.isArray(response.candidates) && response.candidates[0]?.groundingMetadata) {
1191
+ return response.candidates[0].groundingMetadata as GroundingMetadata;
1192
+ }
1193
+ return undefined;
1194
+ }
1195
+
1196
+ /**
1197
+ * Normalizes groundingChunks into WebGroundingChunk[] with uri/title/domain.
1198
+ * Observer filters out chunks without uri, dedupes by uri preservation order.
1199
+ *
1200
+ * @param meta - groundingMetadata
1201
+ */
1202
+ function normalizeGroundingChunks(meta: GroundingMetadata | undefined): WebGroundingChunk[] {
1203
+ if (!meta?.groundingChunks || !Array.isArray(meta.groundingChunks)) return [];
1204
+ const seen = new Set<string>();
1205
+ const out: WebGroundingChunk[] = [];
1206
+ for (const ch of meta.groundingChunks) {
1207
+ const web = (ch as any).web ?? (ch as any).retrievedContext ?? {};
1208
+ const rawUri = web.uri ?? (ch as any).uri ?? "";
1209
+ const rawTitle = web.title ?? (ch as any).title ?? "";
1210
+ if (!rawUri || typeof rawUri !== "string") continue;
1211
+ // normalize uri
1212
+ let uri: string;
1213
+ try {
1214
+ // basic validation — must be http(s)
1215
+ const u = new URL(rawUri);
1216
+ if (!u.protocol.startsWith("http")) continue;
1217
+ uri = u.toString();
1218
+ } catch {
1219
+ continue;
1220
+ }
1221
+ if (seen.has(uri)) continue;
1222
+ seen.add(uri);
1223
+ let domain: string | undefined;
1224
+ try {
1225
+ domain = new URL(uri).hostname;
1226
+ } catch {
1227
+ domain = undefined;
1228
+ }
1229
+ out.push({
1230
+ uri,
1231
+ title: String(rawTitle || domain || uri).slice(0, 500),
1232
+ domain,
1233
+ });
1234
+ }
1235
+ return out;
1236
+ }
1237
+
1238
+ /**
1239
+ * Extracts webSearchQueries from groundingMetadata.
1240
+ */
1241
+ function extractWebSearchQueries(meta: GroundingMetadata | undefined): string[] {
1242
+ if (!meta) return [];
1243
+ const queries = meta.webSearchQueries ?? meta.retrievalQueries ?? [];
1244
+ if (!Array.isArray(queries)) return [];
1245
+ return queries
1246
+ .filter((q) => typeof q === "string" && (q as string).trim().length > 0)
1247
+ .map((q) => String(q).trim())
1248
+ .slice(0, 20); // cap 20 queries per spec sanity
1249
+ }
1250
+
1251
+ // ---------------------------------------------------------------------------
1252
+ // Core — executeSearch
1253
+ // ---------------------------------------------------------------------------
1254
+
1255
+ /**
1256
+ * Executes grounded search using Gemini native googleSearch + urlContext tools.
1257
+ * Third-person observer pattern: imitates Antigravity's google_search tool wrapper
1258
+ * but calls cloudcode-pa.googleapis.com directly, no localhost proxy.
1259
+ *
1260
+ * The observer:
1261
+ * 1. Validates query + accessToken.
1262
+ * 2. Resolves proxy (env + option) with NO_PROXY bypass.
1263
+ * 3. Resolves thinking budget from level or direct number, clamped 32k.
1264
+ * 4. Builds contents + tools [{googleSearch:{}},{urlContext:{}}] + generationConfig.
1265
+ * 5. Builds Antigravity headers (Bearer, UA dynamic, Client-Metadata, Api-Client)
1266
+ * stripping x-goog-user-project per bypass spec.
1267
+ * 6. Cascades endpoints PROD → DAILY → SANDBOX on 403/404/5xx with jitter 0-80ms.
1268
+ * 7. Parses groundingMetadata.groundingChunks[].web.uri/title and webSearchQueries.
1269
+ *
1270
+ * @param input - query + optional systemPrompt
1271
+ * @param auth - OAuth access token + projectId + UA override
1272
+ * @param opts - model, thinking, proxy, timeout, endpoints, etc.
1273
+ * @returns grounded SearchResult with parsed chunks
1274
+ *
1275
+ * @throws SearchError on validation, timeout, 401, exhausted cascade, or invalid JSON.
1276
+ *
1277
+ * @example
1278
+ * ```ts
1279
+ * const result = await executeSearch(
1280
+ * { query: "latest Gemini 3 flash grounding changes 2026" },
1281
+ * { accessToken: "...", projectId: "rising-fact-p41fc" },
1282
+ * { thinkingLevel: "medium", useUrlContext: true }
1283
+ * );
1284
+ * console.log(result.text);
1285
+ * console.log(result.groundingChunks.map(c=>c.uri));
1286
+ * ```
1287
+ */
1288
+ export async function executeSearch(
1289
+ input: ExecuteSearchInput,
1290
+ auth: ExecuteSearchAuth,
1291
+ opts: ExecuteSearchOptions = {},
1292
+ ): Promise<SearchResult> {
1293
+ // -----------------------------------------------------------------------
1294
+ // Validation — production ready, no placeholder
1295
+ // -----------------------------------------------------------------------
1296
+ if (!input || typeof input.query !== "string" || input.query.trim().length === 0) {
1297
+ throw new SearchError("query must be a non-empty string", { code: "INVALID_QUERY" });
1298
+ }
1299
+ const query = input.query.trim();
1300
+ if (query.length > 20000) {
1301
+ throw new SearchError(`query too long (${query.length} > 20000 chars)`, { code: "QUERY_TOO_LONG" });
1302
+ }
1303
+ if (!auth || typeof auth.accessToken !== "string" || auth.accessToken.trim().length === 0) {
1304
+ throw new SearchError("accessToken required — missing auth.accessToken Bearer", { code: "MISSING_TOKEN" });
1305
+ }
1306
+ const accessToken = auth.accessToken.trim();
1307
+ // basic Bearer format check — JWT-ish or ya29.
1308
+ if (accessToken.length < 20) {
1309
+ throw new SearchError("accessToken appears too short to be valid", { code: "INVALID_TOKEN" });
1310
+ }
1311
+
1312
+ // -----------------------------------------------------------------------
1313
+ // Resolve model — SEARCH_MODEL gemini-3-flash per spec
1314
+ // -----------------------------------------------------------------------
1315
+ const rawModel = (opts.model ?? SEARCH_MODEL).trim() || SEARCH_MODEL;
1316
+ const normalizedModel = normalizeModelName(rawModel);
1317
+ const modelBare = normalizedModel.bare || SEARCH_MODEL;
1318
+
1319
+ // -----------------------------------------------------------------------
1320
+ // Thinking budget — level → budget map, clamped
1321
+ // -----------------------------------------------------------------------
1322
+ const thinkingBudget = resolveThinkingBudget(opts.thinkingLevel, opts.thinkingBudget);
1323
+
1324
+ // -----------------------------------------------------------------------
1325
+ // Tools — native googleSearch + urlContext per requirement
1326
+ // -----------------------------------------------------------------------
1327
+ const useGoogleSearch = opts.useGoogleSearch !== false; // default true
1328
+ const useUrlContext = opts.useUrlContext !== false; // default true
1329
+ const tools: any[] = [];
1330
+ if (useGoogleSearch) tools.push({ googleSearch: {} });
1331
+ if (useUrlContext) tools.push({ urlContext: {} });
1332
+ // fallback — at least one grounding tool required
1333
+ if (tools.length === 0) tools.push({ googleSearch: {} });
1334
+
1335
+ // -----------------------------------------------------------------------
1336
+ // Contents + system instruction
1337
+ // -----------------------------------------------------------------------
1338
+ const contents: any[] = [
1339
+ {
1340
+ role: "user",
1341
+ parts: [
1342
+ {
1343
+ text: input.additionalContext ? `${query}\n\nContext: ${input.additionalContext}` : query,
1344
+ },
1345
+ ],
1346
+ },
1347
+ ];
1348
+
1349
+ const systemInstruction = input.systemPrompt
1350
+ ? { parts: [{ text: String(input.systemPrompt).slice(0, 8000) }] }
1351
+ : undefined;
1352
+
1353
+ const generationConfig: any = {
1354
+ // Thinking — Gemini 3 supports thinkingConfig
1355
+ thinkingConfig: {
1356
+ thinkingBudget,
1357
+ includeThoughts: false,
1358
+ },
1359
+ temperature: typeof opts.temperature === "number" ? opts.temperature : 0.2,
1360
+ maxOutputTokens: typeof opts.maxOutputTokens === "number" ? opts.maxOutputTokens : 8192,
1361
+ };
1362
+
1363
+ const primaryBody = buildGenerateContentBody({
1364
+ model: modelBare,
1365
+ projectId: auth.projectId,
1366
+ contents,
1367
+ tools,
1368
+ generationConfig,
1369
+ systemInstruction,
1370
+ });
1371
+
1372
+ // -----------------------------------------------------------------------
1373
+ // Proxy resolution — supports HTTP_PROXY/HTTPS_PROXY/NO_PROXY
1374
+ // -----------------------------------------------------------------------
1375
+ // Use first endpoint as NO_PROXY check target — if endpoint in NO_PROXY list, bypass
1376
+ const firstEndpointForProxyCheck = (opts.endpoints?.[0] ?? DEFAULT_ENDPOINT_ORDER[0]) + GENERATE_CONTENT_ENDPOINT;
1377
+ const proxyResolution = resolveProxyUrl(opts.proxyUrl, firstEndpointForProxyCheck);
1378
+ const proxyUrlStr = proxyResolution.proxyUrl;
1379
+
1380
+ // -----------------------------------------------------------------------
1381
+ // UA + headers — observer builds dynamic UA antigravity/{ver} {os}/{arch}
1382
+ // -----------------------------------------------------------------------
1383
+ const version = auth.version ?? ANTIGRAVITY_VERSION_FALLBACK;
1384
+ const userAgent = auth.userAgent ?? getDynamicUserAgent(version);
1385
+ const extraHeaders: Record<string, string> = {};
1386
+ // allow projectId as header? No — strip per bypass. Project sent in body only.
1387
+
1388
+ const baseHeaders = buildAntagravityHeaders(accessToken, { userAgent, extra: extraHeaders });
1389
+
1390
+ // -----------------------------------------------------------------------
1391
+ // Endpoint cascade
1392
+ // -----------------------------------------------------------------------
1393
+ const endpoints = opts.endpoints && opts.endpoints.length > 0 ? opts.endpoints : [...DEFAULT_ENDPOINT_ORDER];
1394
+ const timeoutMs = typeof opts.timeoutMs === "number" && opts.timeoutMs > 0 ? opts.timeoutMs : SEARCH_TIMEOUT_MS;
1395
+ const jitterEnabled = opts.jitter !== false;
1396
+
1397
+ let lastError: SearchError | Error | null = null;
1398
+ let attemptedBodiesTried = 0;
1399
+
1400
+ // For debugging
1401
+ if (opts.debug) {
1402
+ // eslint-disable-next-line no-console
1403
+ console.debug(
1404
+ `[search] observer start queryLen=${query.length} model=${modelBare} budget=${thinkingBudget} proxy=${proxyResolution.source} tokenHash=${hashToken(accessToken)} endpoints=${endpoints.join(",")}`,
1405
+ );
1406
+ }
1407
+
1408
+ // Try each endpoint, with body fallback strategy
1409
+ for (let epIdx = 0; epIdx < endpoints.length; epIdx++) {
1410
+ const base = endpoints[epIdx]!.replace(/\/$/, "");
1411
+ const url = `${base}${GENERATE_CONTENT_ENDPOINT}`;
1412
+
1413
+ // Try primary body + alternative bodies if 400/404
1414
+ const bodiesToTry = [primaryBody, ...buildAlternativeBodies(primaryBody, normalizedModel, auth.projectId)];
1415
+ // deduplicate by JSON stringify quickly via model name (small optimization)
1416
+ const seenModels = new Set<string>();
1417
+ const dedupedBodies: any[] = [];
1418
+ for (const b of bodiesToTry) {
1419
+ const key = `${b.model}|${b.project ?? ""}|${b.request ? "wrapped" : "flat"}`;
1420
+ if (seenModels.has(key)) continue;
1421
+ seenModels.add(key);
1422
+ dedupedBodies.push(b);
1423
+ }
1424
+
1425
+ for (let bodyIdx = 0; bodyIdx < dedupedBodies.length; bodyIdx++) {
1426
+ const body = dedupedBodies[bodyIdx];
1427
+ attemptedBodiesTried++;
1428
+
1429
+ // To avoid infinite loops, limit total attempts
1430
+ if (opts.maxRetries && attemptedBodiesTried > (opts.maxRetries ?? 8) * endpoints.length) break;
1431
+
1432
+ const fetchHeaders = { ...baseHeaders };
1433
+
1434
+ const fetchInit: RequestInit = {
1435
+ method: "POST",
1436
+ headers: fetchHeaders as any,
1437
+ body: JSON.stringify(body),
1438
+ };
1439
+
1440
+ if (opts.debug) {
1441
+ // eslint-disable-next-line no-console
1442
+ console.debug(
1443
+ `[search] attempt ep=${base} bodyModel=${body.model} wrapped=${!!body.request} attempt=${attemptedBodiesTried}`,
1444
+ );
1445
+ }
1446
+
1447
+ let res: any;
1448
+ try {
1449
+ res = await fetchWithProxyTimeout(url, fetchInit, timeoutMs, proxyUrlStr);
1450
+ } catch (networkErr: any) {
1451
+ // network failure — retryable
1452
+ const err = new SearchError(`network failure to ${base}: ${networkErr?.message ?? String(networkErr)}`, {
1453
+ endpoint: base,
1454
+ retryable: true,
1455
+ code: "NETWORK_ERROR",
1456
+ cause: networkErr,
1457
+ });
1458
+ lastError = err;
1459
+ if (opts.debug) console.debug(`[search] network error ${err.message}`);
1460
+ // jitter before next endpoint/body
1461
+ if (jitterEnabled) await sleep(getJitter());
1462
+ continue; // next body / endpoint
1463
+ }
1464
+
1465
+ // HTTP status handling
1466
+ const status: number = res.status ?? 0;
1467
+ if (!res.ok) {
1468
+ let bodyText = "";
1469
+ try {
1470
+ bodyText = await res.text();
1471
+ } catch {
1472
+ bodyText = "";
1473
+ }
1474
+
1475
+ // Parse error for log trimming
1476
+ const truncated = bodyText.slice(0, 2000);
1477
+
1478
+ const retryable = isRetryableStatus(status);
1479
+ const code =
1480
+ status === 401
1481
+ ? "UNAUTHORIZED"
1482
+ : status === 403
1483
+ ? "FORBIDDEN"
1484
+ : status === 429
1485
+ ? "RATE_LIMITED"
1486
+ : status >= 500
1487
+ ? "SERVER_ERROR"
1488
+ : "HTTP_ERROR";
1489
+
1490
+ const err = new SearchError(`search failed ${status} at ${base}: ${truncated}`, {
1491
+ status,
1492
+ endpoint: base,
1493
+ retryable,
1494
+ code,
1495
+ });
1496
+
1497
+ lastError = err;
1498
+
1499
+ if (opts.debug) {
1500
+ // eslint-disable-next-line no-console
1501
+ console.debug(`[search] http ${status} retryable=${retryable} body=${truncated.slice(0, 500)}`);
1502
+ }
1503
+
1504
+ // 401 Unauthorized — no point retrying other endpoints with same token
1505
+ if (status === 401) {
1506
+ throw err;
1507
+ }
1508
+
1509
+ // 403/404/429/5xx — try next endpoint (cascade) or next body variant if 400
1510
+ if (status === 400 && bodyIdx === 0) {
1511
+ // bad request maybe due to model naming — try next body variant without switching endpoint
1512
+ if (jitterEnabled) await sleep(getJitter());
1513
+ continue;
1514
+ }
1515
+
1516
+ if (retryable) {
1517
+ if (jitterEnabled) await sleep(getJitter());
1518
+ // break inner body loop to try next endpoint
1519
+ break;
1520
+ }
1521
+
1522
+ // non-retryable 400 etc. — if first body, try alternative bodies
1523
+ if (status === 400 && bodyIdx < dedupedBodies.length - 1) {
1524
+ if (jitterEnabled) await sleep(getJitter());
1525
+ continue;
1526
+ }
1527
+
1528
+ // otherwise keep as lastError and try next endpoint
1529
+ if (jitterEnabled) await sleep(getJitter());
1530
+ break;
1531
+ }
1532
+
1533
+ // -----------------------------------------------------------------
1534
+ // Success path — parse JSON
1535
+ // -----------------------------------------------------------------
1536
+ let json: any;
1537
+ try {
1538
+ json = await res.json();
1539
+ } catch (parseErr: any) {
1540
+ let txt = "";
1541
+ try {
1542
+ txt = await res.text();
1543
+ } catch {
1544
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1545
+ }
1546
+ throw new SearchError(
1547
+ `failed parsing search JSON from ${base}: ${parseErr?.message ?? String(parseErr)} — text: ${txt.slice(0, 1000)}`,
1548
+ {
1549
+ status,
1550
+ endpoint: base,
1551
+ code: "INVALID_JSON",
1552
+ cause: parseErr,
1553
+ },
1554
+ );
1555
+ }
1556
+
1557
+ // Response shape may be array? Some internal endpoints wrap in []?
1558
+ // Normalize: if json is array, take first
1559
+ const responseObj = Array.isArray(json) ? json[0] : json;
1560
+
1561
+ // Candidates extraction — heterogenous shapes
1562
+ const candidates = responseObj?.candidates ?? responseObj?.response?.candidates ?? [];
1563
+ const firstCandidate = Array.isArray(candidates) && candidates.length > 0 ? candidates[0] : responseObj;
1564
+
1565
+ const text =
1566
+ extractTextFromCandidate(firstCandidate) ||
1567
+ (typeof responseObj?.text === "string" ? responseObj.text : "") ||
1568
+ (typeof responseObj?.output === "string" ? responseObj.output : "");
1569
+
1570
+ if (!text || text.trim().length === 0) {
1571
+ // Sometimes grounding only returns metadata without text when query is just URLs? Treat as empty but still return grounding
1572
+ if (opts.debug)
1573
+ console.debug(`[search] empty text but status ok, json keys=${Object.keys(responseObj).join(",")}`);
1574
+ }
1575
+
1576
+ const groundingMeta = parseGroundingMetadataFromResponse(responseObj, firstCandidate);
1577
+ const groundingChunks = normalizeGroundingChunks(groundingMeta);
1578
+ const webSearchQueries = extractWebSearchQueries(groundingMeta);
1579
+
1580
+ const result: SearchResult = {
1581
+ text: text ?? "",
1582
+ query,
1583
+ model: body.model ?? modelBare,
1584
+ grounded: groundingChunks.length > 0 || webSearchQueries.length > 0,
1585
+ webSearchQueries,
1586
+ groundingChunks,
1587
+ groundingSupports: groundingMeta?.groundingSupports,
1588
+ searchEntryPoint: groundingMeta?.searchEntryPoint,
1589
+ groundingMetadata: opts.includeGrounding !== false ? groundingMeta : undefined,
1590
+ raw: responseObj,
1591
+ endpointUsed: base,
1592
+ thinkingBudgetUsed: thinkingBudget,
1593
+ isFallbackProject: auth.projectId === "rising-fact-p41fc" || body.project === "rising-fact-p41fc",
1594
+ };
1595
+
1596
+ if (opts.debug) {
1597
+ // eslint-disable-next-line no-console
1598
+ console.debug(
1599
+ `[search] success endpoint=${base} grounded=${result.grounded} chunks=${result.groundingChunks.length} queries=${result.webSearchQueries.length} textLen=${result.text.length}`,
1600
+ );
1601
+ }
1602
+
1603
+ return result;
1604
+ } // end bodies loop
1605
+
1606
+ // after body loop for this endpoint, if we exhausted bodies and have retryable error, continue to next endpoint
1607
+ // jitter already applied
1608
+ } // end endpoints loop
1609
+
1610
+ // -----------------------------------------------------------------------
1611
+ // All endpoints failed — throw last error with context
1612
+ // -----------------------------------------------------------------------
1613
+ if (lastError) {
1614
+ if (lastError instanceof SearchError) throw lastError;
1615
+ throw new SearchError(
1616
+ `all search endpoints failed: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
1617
+ {
1618
+ code: "ALL_ENDPOINTS_FAILED",
1619
+ cause: lastError,
1620
+ },
1621
+ );
1622
+ }
1623
+ throw new SearchError(
1624
+ `all search endpoints failed without specific error — attempted ${endpoints.length} endpoints, ${attemptedBodiesTried} bodies`,
1625
+ {
1626
+ code: "ALL_ENDPOINTS_FAILED",
1627
+ },
1628
+ );
1629
+ }
1630
+
1631
+ // ---------------------------------------------------------------------------
1632
+ // Convenience wrappers — library-first root-first exports
1633
+ // ---------------------------------------------------------------------------
1634
+
1635
+ /**
1636
+ * Convenience: execute search with project fallback rising-fact-p41fc.
1637
+ * Observer notes this fallback works for Antigravity but 403 for Gemini CLI models.
1638
+ *
1639
+ * @param query - search query
1640
+ * @param accessToken - bearer token
1641
+ * @param opts - optional search options + projectId override
1642
+ */
1643
+ export async function searchWithFallback(
1644
+ query: string,
1645
+ accessToken: string,
1646
+ opts: ExecuteSearchOptions & { projectId?: string; systemPrompt?: string } = {},
1647
+ ): Promise<SearchResult> {
1648
+ const projectId = opts.projectId ?? "rising-fact-p41fc";
1649
+ return executeSearch({ query, systemPrompt: opts.systemPrompt }, { accessToken, projectId }, opts);
1650
+ }
1651
+
1652
+ /**
1653
+ * Extracts only grounding URIs from result — helper for TUI.
1654
+ *
1655
+ * @param result - SearchResult
1656
+ * @returns uri strings
1657
+ */
1658
+ export function extractGroundingUris(result: SearchResult): string[] {
1659
+ return result.groundingChunks.map((c) => c.uri).filter(Boolean);
1660
+ }
1661
+
1662
+ /**
1663
+ * Extracts grounding titles for UI display.
1664
+ */
1665
+ export function extractGroundingTitles(result: SearchResult): Array<{ uri: string; title: string }> {
1666
+ return result.groundingChunks.map((c) => ({ uri: c.uri, title: c.title }));
1667
+ }
1668
+
1669
+ // ---------------------------------------------------------------------------
1670
+ // Plugin helpers - search gating, grounding formatting, image generation
1671
+ // (ported from search(3).ts lineage)
1672
+ // ---------------------------------------------------------------------------
1673
+
1674
+ /**
1675
+ * Decides whether native googleSearch grounding should be enabled for a model
1676
+ * given plugin config (`google_search_enabled`: true | "auto" | falsy).
1677
+ */
1678
+ export const shouldEnableSearch = (model: string, cfg: any): boolean => {
1679
+ if (cfg?.google_search_enabled === true) return true;
1680
+ if (cfg?.google_search_enabled === "auto") {
1681
+ const l = model.toLowerCase();
1682
+ return l.includes("gemini") && !l.includes("image");
1683
+ }
1684
+ return false;
1685
+ };
1686
+
1687
+ /** Builds the native grounding tool list: googleSearch + urlContext. */
1688
+ export const buildSearchTools = (): any[] => [{ googleSearch: {} }, { urlContext: {} }];
1689
+
1690
+ /** Normalizes groundingMetadata chunks into {uri,title} pairs.
1691
+ * Identical to core.parseGrounding — imported from the frozen owner and
1692
+ * re-exported above. */
1693
+
1694
+ /** Formats grounding sources as markdown citation lines. */
1695
+ export const extractCitations = (grounding: any[]): string =>
1696
+ grounding.map((g) => `[${g.title || "source"}](${g.uri})`).join("\n");
1697
+
1698
+ /**
1699
+ * Saves a base64-encoded PNG under ~/.config/opencode/generated-images (or a
1700
+ * custom dir) using an timestamped random filename; returns the written path.
1701
+ */
1702
+ export const saveGeneratedImage = async (b64: string, dir?: string): Promise<string> => {
1703
+ const base = dir || pathJoin(os.homedir(), ".config", "opencode", "generated-images");
1704
+ try {
1705
+ mkdirSync(base, { recursive: true });
1706
+ } catch {
1707
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1708
+ }
1709
+ const name = `img-${Date.now()}-${Math.random().toString(16).slice(2)}.png`;
1710
+ const filePath = pathJoin(base, name);
1711
+ const buf = Buffer.from(b64, "base64");
1712
+ writeFileSync(filePath, buf);
1713
+ return filePath;
1714
+ };
1715
+
1716
+ /** Renders grounding sources as a markdown bullet list. */
1717
+ export const groundingToMarkdown = (grounding: any[]): string =>
1718
+ grounding.map((g) => `- [${g.title || g.uri}](${g.uri})`).join("\n");
1719
+
1720
+ // ---------------------------------------------------------------------------
1721
+ // Barrel default export — library-first
1722
+ // ---------------------------------------------------------------------------
1723
+
1724
+ export const Search = {
1725
+ SEARCH_MODEL,
1726
+ SEARCH_MODEL_PREVIEW,
1727
+ SEARCH_MODEL_FALLBACKS,
1728
+ CODE_ASSIST_ENDPOINTS,
1729
+ DEFAULT_ENDPOINT_ORDER,
1730
+ GENERATE_CONTENT_ENDPOINT,
1731
+ THINKING_BUDGET_MAP,
1732
+ THINKING_LEVELS,
1733
+ THINKING_BUDGET_MAX_GEMINI,
1734
+ ANTIGRAVITY_VERSION_FALLBACK,
1735
+ ANTIGRAVITY_USER_AGENT_FALLBACK,
1736
+ getDynamicUserAgent,
1737
+ buildAntagravityHeaders,
1738
+ fetchWithProxyTimeout,
1739
+ resolveProxyUrl,
1740
+ isNoProxyMatch,
1741
+ resolveThinkingBudget,
1742
+ normalizeModelName,
1743
+ executeSearch,
1744
+ searchWithFallback,
1745
+ extractGroundingUris,
1746
+ extractGroundingTitles,
1747
+ shouldEnableSearch,
1748
+ buildSearchTools,
1749
+ parseGrounding,
1750
+ extractCitations,
1751
+ isImageGenModel,
1752
+ buildImageGenConfig,
1753
+ saveGeneratedImage,
1754
+ groundingToMarkdown,
1755
+ SearchError,
1756
+ isRetryableStatus,
1757
+ getJitter,
1758
+ fnv1a32,
1759
+ };
1760
+
1761
+ export default Search;