@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/request.ts ADDED
@@ -0,0 +1,4496 @@
1
+ /**
2
+ * @file request.ts
3
+ * @module provider/request
4
+ * @description
5
+ * THE single request-building module of the provider family of the merged library. v2.1.14
6
+ * consolidation: request.ts absorbed request-helpers.ts in full, so the
7
+ * entire request-building category — schema cleaning, tool validation,
8
+ * thinking budgets, fingerprint headers, session/prompt id builders, model
9
+ * classification, request transforms/builders and cascade fetch — lives in
10
+ * this one file.
11
+ *
12
+ * Third-person observer view: the library watches how OpenCode transforms
13
+ * user messages (OpenAI and Anthropic shapes) into Gemini/Claude payloads
14
+ * and how Antigravity Manager forwards them to cloudcode-pa.googleapis.com
15
+ * without using localhost v1 proxy.
16
+ *
17
+ * Responsibilities:
18
+ * - transformRequest: OpenCode -> Gemini normalized request (contents, system, tools, genConfig)
19
+ * - buildAntigravityRequest / buildGeminiCLIRequest: wraps normalized request into
20
+ * cloudcode-pa envelope with deterministic session_id / user_prompt_id (FNV-1a),
21
+ * endpoint cascade, fingerprint headers, and x-goog-user-project stripping
22
+ * - schema cleaning: tolerant Gemini-shaping cleanJsonSchema plus the strict
23
+ * allowlist cleanJsonSchemaStrict / cleanToolSchemaTolerant family
24
+ * - tool name validation, sanitization and deduplication (two parallel
25
+ * families: validateToolNames and validateToolNamesDetailed)
26
+ * - message normalization: normalizeMessages and normalizeIncomingMessages
27
+ * - thinking level parsing, budget maps, family-aware budgets and variant hints
28
+ * - deterministic id builders over FNV-1a 32/64-bit digests
29
+ * - model classification (isGeminiCLIOnlyModel, isLikelyAntigravityOnlyModel,
30
+ * variant extraction) and the Gemini CLI bypass constants
31
+ * - google_search injection when enabled; cascade fetch with retry
32
+ *
33
+ * Architecture governance:
34
+ * - Date: 25/08/2026 — Canto do Buriti, Piauí, Brazil
35
+ * - Library-first, root-first: file lives beside its siblings (no src/ nesting)
36
+ * - Only node:* builtins + global fetch (Node >=18) — zero external deps
37
+ * - Direct cloudcode-pa usage: https://cloudcode-pa.googleapis.com,
38
+ * https://daily-cloudcode-pa.googleapis.com,
39
+ * https://daily-cloudcode-pa.sandbox.googleapis.com
40
+ * - No localhost v1 base-url proxy (explicit requirement)
41
+ * - Deterministic IDs via FNV-1a (32/64-bit) — reproducible per conversation
42
+ * - Strip x-goog-user-project header to avoid 403 IAM (issue pi-mono #1830)
43
+ * - Skip sandbox for gemini-cli models to avoid 404/403 cascade (#233)
44
+ *
45
+ * v2.1.14 merge notes (request.ts + request-helpers.ts -> request.ts):
46
+ * - Identical duplicated symbols collapsed to a single definition.
47
+ * - Divergent duplicates resolved toward the more defensive variant; the
48
+ * losing variant survives under a parallel export whenever its behavior
49
+ * was not equivalent:
50
+ * cleanJsonSchemaStrict <- request-helpers cleanJsonSchema (strict allowlist)
51
+ * cleanToolSchemaTolerant <- request-helpers cleanToolSchema (tolerant normalizer)
52
+ * validateToolNamesDetailed <- request-helpers validateToolNames (rich validator)
53
+ * buildGenerationConfigFromRequest <- request.ts buildGenerationConfig (request-shaped)
54
+ * - Canonical fnv1a32 hashes UTF-16 code units (charCodeAt), matching the
55
+ * repo-wide implementation in core.ts; the UTF-8 byte variant that lived
56
+ * only in request-helpers.ts was dropped as the outlier.
57
+ * - Canonical TOOL_NAME_REGEX is the letter-start + 64-cap pattern shared
58
+ * with core.ts (TOOL_REGEX); the underscore-start variant was dropped.
59
+ * - Canonical generateSessionId/generateUserPromptId are the validating
60
+ * versions (throw RequestHelpersError on empty seed).
61
+ * - The RequestHelpers barrel is re-exported from this module; the default
62
+ * export remains RequestPipeline.
63
+ *
64
+ * @author devthink
65
+ * @license MIT
66
+ * @version 2.1.16
67
+ */
68
+
69
+ import * as os from "node:os";
70
+
71
+ const osPlatform = os.platform;
72
+ const osArch = os.arch;
73
+
74
+ import { randomBytes, randomInt } from "node:crypto";
75
+ // v2.1.15 Phase B: raw shared values (identity UA, client metadata family,
76
+ // project fallback, forbidden header lists, FNV constants, cloudcode bases,
77
+ // antigravity version fallback) come from constants.js; generic utilities
78
+ // (fnv hash family, retry predicate) from core.js; identity masquerade
79
+ // (gl-node api-client snapshot, client-metadata builder) from fingerprint.js.
80
+ import {
81
+ ANTIGRAVITY_VERSION_FALLBACK,
82
+ CLIENT_METADATA,
83
+ CLIENT_METADATA_RAW,
84
+ CLIENT_METADATA_STRING,
85
+ CLOUDCODE_DAILY_BASE,
86
+ CLOUDCODE_PA_BASE,
87
+ CODE_ASSIST_BASE_URL,
88
+ CODE_ASSIST_PATH_MAP,
89
+ CONTENT_TIMEOUT_MS,
90
+ FALLBACK_PROJECT_ID,
91
+ FNV_OFFSET_BASIS,
92
+ FNV_PRIME,
93
+ FORBIDDEN_HEADERS,
94
+ GEMINI_CLI_OAUTH_CLIENT_ID,
95
+ GEMINI_CLI_OAUTH_CLIENT_SECRET,
96
+ GEMINI_CLI_USER_AGENT,
97
+ HEADERS_TO_STRIP,
98
+ PLUGIN_VERSION,
99
+ PROJECT_FALLBACK,
100
+ } from "./constants.js";
101
+ import { fnv1a32, fnv1a32Hex, getJitter, isRetryableStatus } from "@wenathlan/devthink/core";
102
+
103
+ const MODELS_2026_SET = new Set<string>(FETCH_AVAILABLE_MODELS_2026 as readonly string[]);
104
+ export const TOOL_NAME_MAX_LENGTH = 64 as const;
105
+
106
+ export const TOOL_NAME_MIN_LENGTH = 1 as const;
107
+
108
+ export const THINKING_LEVEL_TO_BUDGET_GEMINI: Record<string, number> = {
109
+ none: 0,
110
+ minimal: 0, // 0 desativa thinking em Gemini nativo
111
+ low: 1024,
112
+ medium: 4096,
113
+ high: 8192,
114
+ ultra: 16384,
115
+ };
116
+
117
+ export const THINKING_LEVEL_TO_BUDGET_CLAUDE: Record<string, number> = {
118
+ none: 0,
119
+ minimal: 1024,
120
+ low: 2048,
121
+ medium: 8192,
122
+ high: 16384,
123
+ ultra: 32768,
124
+ };
125
+
126
+ /**
127
+ * Thinking budget ceiling shared by the clamp paths. v2.1.16: the value
128
+ * (64000) is byte-identical to models.ts THINKING_BUDGET_MAX_CLAUDE — the
129
+ * thinking-table owner — so this export now aliases that import instead of
130
+ * repeating the literal.
131
+ */
132
+ export const THINKING_BUDGET_MAX = THINKING_BUDGET_MAX_CLAUDE;
133
+
134
+ // candidate for models.js ownership (THINKING_BUDGET_MIN): models.ts exports
135
+ // no minimum-thinking-budget constant yet, so the 1024 floor stays local.
136
+ export const THINKING_BUDGET_MIN = 1024 as const;
137
+
138
+ // v2.1.15 Phase C: historical alias names for the gemini-cli client pair.
139
+ export {
140
+ GEMINI_CLI_OAUTH_CLIENT_ID as GEMINI_CLI_CLIENT_ID,
141
+ GEMINI_CLI_OAUTH_CLIENT_SECRET as GEMINI_CLI_CLIENT_SECRET,
142
+ } from "./constants.js";
143
+
144
+ import {
145
+ buildClientMetadata,
146
+ getDynamicUserAgent,
147
+ getFingerprintHeaders,
148
+ normalizeArch,
149
+ normalizePlatform,
150
+ X_GOOG_API_CLIENT_GEMINI_CLI as X_GOOG_API_CLIENT,
151
+ } from "./fingerprint.js";
152
+ import type { FetchAvailableModel2026, ThinkingLevel } from "./models.js";
153
+ // v2.1.15 Phase A: model-identification families (the observed 2026-08-25
154
+ // fetchAvailableModels list, the Gemini-CLI-only routing list, search model
155
+ // ids, thinking tables and the endpoint routing orders/classifiers) are
156
+ // imported from models.ts — THE single owner. The historical local names are
157
+ // re-exported below so this module's public surface stays unchanged.
158
+ import {
159
+ CODE_ASSIST_ENDPOINTS,
160
+ ENDPOINT_ORDER_ANTIGRAVITY,
161
+ ENDPOINT_ORDER_GEMINI_CLI,
162
+ FETCH_AVAILABLE_MODELS_2026,
163
+ GEMINI_CLI_ONLY_MODELS,
164
+ getEndpointsForModel,
165
+ isGeminiCLIOnlyModel,
166
+ SEARCH_MODEL,
167
+ SEARCH_MODEL_PREVIEW,
168
+ THINKING_BUDGET_MAP,
169
+ THINKING_BUDGET_MAX_CLAUDE,
170
+ THINKING_BUDGET_MAX_GEMINI,
171
+ THINKING_LEVELS,
172
+ } from "./models.js";
173
+
174
+ export type { ThinkingLevel } from "./models.js";
175
+ /** Historical request-module alias of the models.ts owner list. */
176
+ export {
177
+ ENDPOINT_ORDER_ANTIGRAVITY,
178
+ ENDPOINT_ORDER_GEMINI_CLI,
179
+ FETCH_AVAILABLE_MODELS_2026 as MODELS_2026,
180
+ GEMINI_CLI_ONLY_MODELS,
181
+ getEndpointsForModel,
182
+ isGeminiCLIOnlyModel,
183
+ SEARCH_MODEL,
184
+ SEARCH_MODEL_PREVIEW,
185
+ THINKING_BUDGET_MAP,
186
+ THINKING_BUDGET_MAX_CLAUDE,
187
+ THINKING_BUDGET_MAX_GEMINI,
188
+ THINKING_LEVELS,
189
+ } from "./models.js";
190
+
191
+ // v2.1.15 Phase A: the ModelId2026 alias re-export was deleted — the name
192
+ // is owned by models.ts (import it from there when needed).
193
+
194
+ /**
195
+ * Linear-time trailing-slash strip (regex-free; safe on uncontrolled input —
196
+ * the former /\/+$/.replace was polynomial and flagged as ReDoS-prone).
197
+ */
198
+ function stripTrailingSlashes(value: string): string {
199
+ let end = value.length;
200
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
201
+ return end === value.length ? value : value.slice(0, end);
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Private utilities merged from request-helpers.ts
206
+ // ---------------------------------------------------------------------------
207
+
208
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
209
+ return (
210
+ typeof v === "object" && v !== null && !Array.isArray(v) && Object.prototype.toString.call(v) === "[object Object]"
211
+ );
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Constants — endpoint cascade, models 2026, budgets, headers
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Official cloudcode-pa endpoints — prod is authoritative in 2026,
220
+ * daily and sandbox are rollout / canary fallbacks.
221
+ * Third-person: observer copies order from Antigravity Manager binary.
222
+ */
223
+ // v2.1.15 Phase D: the host map ENDPOINTS lives in constants.js (raw value owner).
224
+
225
+ /** Default cascade order for Antigravity pool (re-exported from models.ts above). */
226
+
227
+ /** Stream endpoint suffix used by Antigravity */
228
+
229
+ /** Antigravity version fallback — governance requires 1.19.2 (bypass ban <1.15.8) */
230
+
231
+ /** Fallback project id */
232
+
233
+ // v2.1.15 Phase A: MODELS_2026 (the 22-id observed fetchAvailableModels
234
+ // list), GEMINI_CLI_ONLY_MODELS, SEARCH_MODEL, SEARCH_MODEL_PREVIEW,
235
+ // THINKING_LEVELS/ThinkingLevel and THINKING_BUDGET_MAP moved to models.ts
236
+ // and are imported + re-exported at the top of this file.
237
+
238
+ /** FNV-1a constants — 32-bit */
239
+
240
+ /** Forbidden headers stripped case-insensitively — observer notes AM never sends them on content */
241
+
242
+ /**
243
+ * OpenCode function tool — accepts OpenAI, Anthropic, or Gemini pre-shaped.
244
+ */
245
+ export type OpenCodeTool =
246
+ | { type: "function"; function: { name: string; description?: string; parameters?: any } }
247
+ | { name: string; description?: string; input_schema?: any; inputSchema?: any; parameters?: any; [k: string]: any }
248
+ | { functionDeclarations: Array<{ name: string; description?: string; parameters?: any }> };
249
+
250
+ export interface TransformRequestInput {
251
+ /** Model id, e.g. gemini-3-flash-preview or claude-opus-4-6-thinking-low */
252
+ model: string;
253
+ /** Message history in OpenCode format */
254
+ messages: OpenCodeMessage[];
255
+ /** Optional tools — OpenAI / Anthropic / Gemini shapes tolerated */
256
+ tools?: OpenCodeTool[] | any[];
257
+ /** Optional system instruction as string or array of texts */
258
+ system?: string | string[] | null;
259
+ /** Optional thinking level minimal|low|medium|high|max — maps to budget */
260
+ thinkingLevel?: ThinkingLevel | string;
261
+ /** Optional explicit thinking budget — overrides level map */
262
+ thinkingBudget?: number;
263
+ /** Whether to inject google_search grounding tool */
264
+ googleSearchEnabled?: boolean;
265
+ /** Optional generationConfig passthrough (temperature, maxTokens, etc) */
266
+ generationConfig?: Record<string, any>;
267
+ /** Optional seed for deterministic session_id — defaults to hash of messages+model */
268
+ sessionSeed?: string;
269
+ /** Optional seed for user_prompt_id — defaults to last user message */
270
+ userPromptSeed?: string;
271
+ /** Optional projectId — not used in transformRequest but kept for symmetry */
272
+ projectId?: string;
273
+ }
274
+
275
+ /**
276
+ * Gemini content part.
277
+ * v2.1.14 merge: unions the request.ts declaration (thought/thoughtSignature)
278
+ * with the request-helpers.ts declaration (fileData plus the explicit
279
+ * GeminiFunctionCall/GeminiFunctionResponse sub-shapes).
280
+ */
281
+ export interface GeminiPart {
282
+ text?: string;
283
+ thought?: boolean;
284
+ thoughtSignature?: string;
285
+ inlineData?: { mimeType: string; data: string };
286
+ fileData?: { mimeType: string; fileUri: string };
287
+ functionCall?: GeminiFunctionCall;
288
+ functionResponse?: GeminiFunctionResponse;
289
+ [k: string]: unknown;
290
+ }
291
+
292
+ /**
293
+ * Gemini content turn.
294
+ * v2.1.14 merge: keeps the "system" role from request.ts alongside the
295
+ * user/model pair used by both halves.
296
+ */
297
+ export interface OpenCodeMessage {
298
+ role: "system" | "user" | "assistant" | "tool" | "model" | string;
299
+ content?: string | any[] | null;
300
+ tool_calls?: Array<{ id: string; type?: string; function: { name: string; arguments: string | object } }>;
301
+ tool_call_id?: string;
302
+ name?: string;
303
+ // Anthropic extras
304
+ thinking?: string;
305
+ signature?: string;
306
+ [k: string]: any;
307
+ }
308
+
309
+ export interface GeminiContent {
310
+ role: "user" | "model" | "system";
311
+ parts: GeminiPart[];
312
+ }
313
+
314
+ /** System instruction content shape (live pipeline lineage). */
315
+ export type GeminiSystem = { parts: GeminiPart[] } | undefined;
316
+
317
+ export interface GeminiTool {
318
+ functionDeclarations?: Array<{ name: string; description?: string; parameters?: any }>;
319
+ }
320
+
321
+ export interface TransformedRequest {
322
+ /** Normalized model id stripped of antigravity- prefix for routing but original preserved */
323
+ model: string;
324
+ /** Original model string exactly as user provided */
325
+ originalModel: string;
326
+ /** Gemini contents array ready for cloudcode-pa */
327
+ contents: GeminiContent[];
328
+ /** System instruction aggregated */
329
+ systemInstruction?: { role: "system"; parts: GeminiPart[] } | null;
330
+ /** Tools normalized to Gemini functionDeclarations */
331
+ tools?: GeminiTool[] | null;
332
+ /** Generation config with thinking budget mapped if provided */
333
+ generationConfig?: Record<string, any> | null;
334
+ /** Deterministic session_id via FNV-1a */
335
+ sessionId: string;
336
+ /** Deterministic user_prompt_id via FNV-1a */
337
+ userPromptId: string;
338
+ /** Thinking budget resolved */
339
+ thinkingBudget?: number;
340
+ /** Thinking level resolved */
341
+ thinkingLevel?: ThinkingLevel;
342
+ }
343
+
344
+ export const ALLOWED_SCHEMA_KEYS = new Set([
345
+ "type",
346
+ "properties",
347
+ "required",
348
+ "description",
349
+ "enum",
350
+ "items",
351
+ "anyOf",
352
+ "oneOf",
353
+ "format",
354
+ "minimum",
355
+ "maximum",
356
+ "minLength",
357
+ "maxLength",
358
+ "pattern",
359
+ "title",
360
+ "nullable",
361
+ "propertyOrdering",
362
+ ]);
363
+
364
+ /**
365
+ * Keys kept by the tolerant cleaner {@link cleanJsonSchema}: the module
366
+ * allowlist minus propertyOrdering (the cloudcode-pa tool-schema validator
367
+ * rejects that key on parameters). Derived — never re-listed — from
368
+ * {@link ALLOWED_SCHEMA_KEYS} so the two policies cannot drift apart.
369
+ */
370
+ const CLEAN_SCHEMA_KEYS: ReadonlySet<string> = new Set(
371
+ [...ALLOWED_SCHEMA_KEYS].filter((k) => k !== "propertyOrdering"),
372
+ );
373
+
374
+ export const MAX_TOOLS_PER_REQUEST = 128 as const;
375
+
376
+ /** Allowed top-level keys of a tool definition after cleaning. */
377
+ export const ALLOWED_TOOL_TOP_KEYS = ["name", "description", "parameters"] as const;
378
+
379
+ /** Direct production cloudcode-pa endpoint — bypasses any localhost proxy. */
380
+ export const DIRECT_ENDPOINT = "https://cloudcode-pa.googleapis.com" as const;
381
+ /** Daily (canary) cloudcode-pa endpoint. */
382
+ export const DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com" as const;
383
+ /** Autopush sandbox endpoint. */
384
+ export const AUTOPUSH_ENDPOINT = "https://autopush-cloudcode-pa.sandbox.googleapis.com" as const;
385
+
386
+ /** Cascade order used by the observer when retrying endpoints. */
387
+ export const ENDPOINT_CASCADE = [DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, DIRECT_ENDPOINT] as const;
388
+
389
+ /**
390
+ * Headers stripped before sending to avoid 403 IAM (pi-mono #1830); Antigravity
391
+ * Manager never sends these on content requests.
392
+ */
393
+
394
+ // v2.1.16: GEMINI_CLI_ONLY_EXACT (10-id exact list) was deleted — zero
395
+ // consumers repo-wide (only the RequestHelpers bundle listed it). Routing
396
+ // consults models.isGeminiCLIOnlyModel / getEndpointsForModel instead.
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Errors
400
+ // ---------------------------------------------------------------------------
401
+
402
+ /**
403
+ * Thrown when transformRequest cannot normalize input.
404
+ */
405
+ export class RequestTransformationError extends Error {
406
+ public readonly code: string;
407
+ constructor(message: string, code = "REQUEST_TRANSFORM_ERROR") {
408
+ super(message);
409
+ this.name = "RequestTransformationError";
410
+ this.code = code;
411
+ }
412
+ }
413
+
414
+ // ============================================================================
415
+ // 11. MERGED OBSERVER HELPERS (from request-helpers v2 — third-person module)
416
+ // ============================================================================
417
+
418
+ /**
419
+ * Domain error for the merged observer helpers. Thrown instead of generic Error
420
+ * so callers can distinguish cleaning/normalization failures from transport.
421
+ */
422
+ export class RequestHelpersError extends Error {
423
+ public readonly code: string;
424
+
425
+ constructor(message: string, code = "REQUEST_HELPERS_ERROR", cause?: unknown) {
426
+ super(message);
427
+ this.name = "RequestHelpersError";
428
+ this.code = code;
429
+ if (cause) {
430
+ (this as { cause?: unknown }).cause = cause;
431
+ }
432
+ // Maintain proper prototype chain for instanceof
433
+ Object.setPrototypeOf(this, RequestHelpersError.prototype);
434
+ }
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // FNV-1a — deterministic hashing for session_id / user_prompt_id
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /**
442
+ * Computes FNV-1a 32-bit hash.
443
+ * Third-person: observer notes this hash is fast, no crypto deps, deterministic.
444
+ *
445
+ * @param input - string to hash
446
+ * @returns unsigned 32-bit integer
447
+ */
448
+
449
+ /**
450
+ * Generates hex string padded to 8 chars from FNV-1a.
451
+ *
452
+ * @param input - seed
453
+ * @returns 8-char hex
454
+ */
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Merged: deterministic ids over FNV-1a hex digests
458
+ // ---------------------------------------------------------------------------
459
+
460
+ /**
461
+ * Returns the FNV-1a 32-bit digest of input padded to 8 hex chars.
462
+ *
463
+ * @param input - seed string
464
+ */
465
+ export function fnv1aHex(input: string): string {
466
+ const h = fnv1a32(input);
467
+ return h.toString(16).padStart(8, "0");
468
+ }
469
+
470
+ /**
471
+ * Returns a 16-char hex digest from two independent FNV-1a rounds, used when
472
+ * longer session ids are needed without reaching for crypto randomness.
473
+ *
474
+ * @param input - seed string
475
+ */
476
+ export function fnv1aHex16(input: string): string {
477
+ const low = fnv1a32(input);
478
+ const high = fnv1a32(`__high__:${input}`);
479
+ return `${high.toString(16).padStart(8, "0")}${low.toString(16).padStart(8, "0")}`;
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Merged from request-helpers.ts — validating id generators (canonical).
484
+ // v2.1.14 merge: these replace request.ts's non-validating wrappers; the ids
485
+ // stay deterministic via the canonical generateDeterministicId above.
486
+ // ---------------------------------------------------------------------------
487
+
488
+ /**
489
+ * Alias kept for fingerprint.ts compatibility.
490
+ */
491
+ export const fnv1aHash = fnv1a32;
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Model helpers — 2026 support, variant extraction, cli-only detection
495
+ // ---------------------------------------------------------------------------
496
+
497
+ /**
498
+ * Normalizes model id — trims, lowercases for detection, preserves original case for request.
499
+ * Also strips antigravity- prefix for internal detection but keeps original model field elsewhere.
500
+ *
501
+ * @param modelId - raw model string
502
+ * @returns object with base, original, normalized
503
+ */
504
+ export function parseModelId(modelId: string): {
505
+ original: string;
506
+ normalized: string;
507
+ base: string;
508
+ variantSuffixes: string[];
509
+ } {
510
+ if (!modelId || typeof modelId !== "string") {
511
+ throw new RequestTransformationError("model id must be non-empty string", "INVALID_MODEL");
512
+ }
513
+ const original = modelId.trim();
514
+ if (!original) throw new RequestTransformationError("model id empty after trim", "INVALID_MODEL");
515
+ const lower = original.toLowerCase();
516
+ // Extract antigravity- prefix if present
517
+ const withoutAntigravity = lower.startsWith("antigravity-") ? lower.slice("antigravity-".length) : lower;
518
+ // Variant suffixes like -customtools, -thinking-low, -thinking-max
519
+ const variantSuffixes: string[] = [];
520
+ let base = withoutAntigravity;
521
+
522
+ // Known suffixes to peel
523
+ const peelSuffixes = [
524
+ "-customtools",
525
+ "-search",
526
+ "-grounding",
527
+ "-thinking-max",
528
+ "-thinking-low",
529
+ "-thinking-medium",
530
+ "-thinking-high",
531
+ "-thinking-minimal",
532
+ "-thinking",
533
+ ];
534
+ // Peel iteratively from end
535
+ let peeled = true;
536
+ while (peeled) {
537
+ peeled = false;
538
+ for (const sfx of peelSuffixes) {
539
+ if (base.endsWith(sfx)) {
540
+ variantSuffixes.unshift(sfx.replace(/^-/, ""));
541
+ base = base.slice(0, -sfx.length);
542
+ peeled = true;
543
+ break;
544
+ }
545
+ }
546
+ }
547
+
548
+ return { original, normalized: withoutAntigravity, base, variantSuffixes };
549
+ }
550
+
551
+ // v2.1.15 Phase A: isGeminiCLIOnlyModel moved to models.ts (this exact
552
+ // implementation is the canonical one) — imported + re-exported at the top
553
+ // of this file.
554
+
555
+ // ============================================================================
556
+ // 03. isLikelyAntigravityOnlyModel()
557
+ // ============================================================================
558
+
559
+ const ANTIGRAVITY_ONLY_EXACT = new Set<string>([
560
+ "antigravity-gemini-3-pro",
561
+ "antigravity-gemini-3.1-pro",
562
+ "antigravity-gemini-3-flash",
563
+ "antigravity-claude-sonnet-4-6",
564
+ "antigravity-claude-opus-4-6-thinking",
565
+ "gemini-3.1-pro-preview-customtools",
566
+ "gemini-3.1-pro-preview-customtools-thinking",
567
+ "antigravity-gemini-3-pro-thinking",
568
+ "antigravity-gemini-3.1-pro-thinking",
569
+ ]);
570
+
571
+ /**
572
+ * Detects whether a model only works through the Antigravity endpoint (cloudcode-pa
573
+ * v1internal) and not through public genai. Heuristic based on prefixes and suffixes.
574
+ *
575
+ * @param modelId - model id (e.g. "antigravity-claude-opus-4-6-thinking")
576
+ * @returns true when the model most likely only works via Antigravity
577
+ *
578
+ * Rules:
579
+ * - any model starting with antigravity- => true
580
+ * - contains "claude" => true (Claude only via the Antigravity bypass)
581
+ * - contains "customtools" => true (customtools is an Antigravity 2026 feature)
582
+ * - is in the ANTIGRAVITY_ONLY_EXACT set
583
+ * - contains "rising-fact" or "unwritten-orb" etc? No — those are projects, not models
584
+ */
585
+ export function isLikelyAntigravityOnlyModel(modelId: string): boolean {
586
+ if (!modelId || typeof modelId !== "string") return false;
587
+ const id = modelId.trim().toLowerCase();
588
+ if (!id) return false;
589
+
590
+ if (id.startsWith("antigravity-")) return true;
591
+ if (ANTIGRAVITY_ONLY_EXACT.has(id)) return true;
592
+ if (id.includes("claude")) return true;
593
+ if (id.includes("customtools")) return true;
594
+ if (id.includes("antigravity")) return true;
595
+ // 3.1 preview models with the custom suffix are Antigravity-only in practice
596
+ if (id.includes("3.1") && id.includes("custom")) return true;
597
+ // models with thinking + the antigravity family
598
+ if (id.includes("-thinking") && id.includes("gemini-3")) {
599
+ // native gemini-3 pro has no native thinking, only via antigravity
600
+ // but keep it simple: a preview thinking model is antigravity-only too
601
+ if (id.startsWith("antigravity-") || id.includes("preview")) {
602
+ // avoids a false positive for gemini-3-pro-preview which is native, so true only with thinking
603
+ if (id.includes("thinking")) return true;
604
+ }
605
+ }
606
+ return false;
607
+ }
608
+
609
+ // v2.1.15 Phase A: getEndpointsForModel moved to models.ts (this exact
610
+ // implementation is the canonical one) — imported + re-exported at the top
611
+ // of this file.
612
+
613
+ // ============================================================================
614
+ // 10. extractVariants()
615
+ // ============================================================================
616
+
617
+ export interface ExtractVariantsResult {
618
+ input: string;
619
+ base: string;
620
+ isAntigravity: boolean;
621
+ isThinking: boolean;
622
+ isCustomTools: boolean;
623
+ isPreview: boolean;
624
+ family: string;
625
+ variants: string[]; // todas variantes geradas
626
+ candidatesInCatalog: string[]; // filtradas que existem em MODELS_2026
627
+ relatedModels: Array<{ id: string; inCatalog: boolean }>;
628
+ }
629
+
630
+ /**
631
+ * Extrai variantes de um modelId, removendo prefixos/sufixos e gerando candidatos.
632
+ *
633
+ * Ex: "antigravity-claude-opus-4-6-thinking" =>
634
+ * base: "claude-opus-4-6"
635
+ * variants: ["antigravity-claude-opus-4-6-thinking", "antigravity-claude-opus-4-6", "claude-opus-4-6-thinking", "claude-opus-4-6", ...]
636
+ */
637
+ export function extractVariants(modelId: string): ExtractVariantsResult {
638
+ if (!modelId || typeof modelId !== "string") {
639
+ return {
640
+ input: String(modelId),
641
+ base: String(modelId),
642
+ isAntigravity: false,
643
+ isThinking: false,
644
+ isCustomTools: false,
645
+ isPreview: false,
646
+ family: String(modelId),
647
+ variants: [],
648
+ candidatesInCatalog: [],
649
+ relatedModels: [],
650
+ };
651
+ }
652
+
653
+ const original = modelId.trim();
654
+ const lower = original.toLowerCase();
655
+
656
+ const isAntigravity = lower.startsWith("antigravity-") || lower.includes("antigravity");
657
+ const isThinking = lower.includes("thinking");
658
+ const isCustomTools =
659
+ lower.includes("customtools") || lower.includes("custom_tools") || lower.includes("custom-tools");
660
+ const isPreview = lower.includes("preview");
661
+
662
+ // Remove prefixo antigravity-
663
+ let base = lower;
664
+ if (base.startsWith("antigravity-")) base = base.slice("antigravity-".length);
665
+
666
+ // Remove sufixos iterativamente: -thinking, -customtools, -preview-customtools, -preview, -custom
667
+ const suffixes = [
668
+ "-thinking",
669
+ "-customtools",
670
+ "-custom_tools",
671
+ "-custom-tools",
672
+ "-preview-customtools",
673
+ "-preview",
674
+ "-custom",
675
+ ];
676
+ let changed = true;
677
+ while (changed) {
678
+ changed = false;
679
+ for (const suf of suffixes) {
680
+ if (base.endsWith(suf)) {
681
+ base = base.slice(0, -suf.length);
682
+ changed = true;
683
+ break;
684
+ }
685
+ }
686
+ }
687
+
688
+ // the clean base may still carry -preview in the middle? strip it again
689
+ // the family is the base + the family heuristic (e.g. gemini-3-pro)
690
+ const family = base;
691
+
692
+ // generates the combinatorial variants
693
+ const variantSet = new Set<string>();
694
+
695
+ const prefixes = ["", "antigravity-"];
696
+ const suffixCombos = ["", "-preview", "-preview-customtools", "-customtools", "-thinking", "-preview-thinking"];
697
+
698
+ // generates every prefix + base + suffix combination
699
+ for (const pre of prefixes) {
700
+ for (const suf of suffixCombos) {
701
+ variantSet.add(`${pre}${base}${suf}`);
702
+ }
703
+ }
704
+
705
+ // also adds variations with the original base + antigravity
706
+ variantSet.add(original.toLowerCase());
707
+ variantSet.add(`antigravity-${base}`);
708
+ if (isThinking) {
709
+ variantSet.add(`${base}-thinking`);
710
+ variantSet.add(`antigravity-${base}-thinking`);
711
+ }
712
+ if (isCustomTools) {
713
+ variantSet.add(`${base}-preview-customtools`);
714
+ variantSet.add(`antigravity-${base}-preview-customtools`);
715
+ variantSet.add(`${base}-customtools`);
716
+ }
717
+
718
+ // Claude-specific variants
719
+ if (base.includes("claude")) {
720
+ variantSet.add(`antigravity-${base}`);
721
+ variantSet.add(`antigravity-${base}-thinking`);
722
+ variantSet.add(`${base}-thinking`);
723
+ }
724
+
725
+ // Variantes gemini 2.5/3.x
726
+ if (base.startsWith("gemini-")) {
727
+ variantSet.add(base);
728
+ variantSet.add(`${base}-preview`);
729
+ variantSet.add(`antigravity-${base}`);
730
+ // gemini-3-pro => gemini-3-pro-preview
731
+ if (!base.includes("preview") && base.match(/^gemini-\d/)) {
732
+ variantSet.add(`${base}-preview`);
733
+ variantSet.add(`antigravity-${base}`);
734
+ }
735
+ }
736
+
737
+ // Normaliza e deduplica, remove vazios
738
+ const variants = Array.from(variantSet)
739
+ .filter((v) => v && v.length > 2)
740
+ .sort();
741
+
742
+ const candidatesInCatalog = variants.filter((v) => MODELS_2026_SET.has(v));
743
+ const relatedModels = variants.map((id) => ({ id, inCatalog: MODELS_2026_SET.has(id) }));
744
+
745
+ return {
746
+ input: original,
747
+ base,
748
+ isAntigravity,
749
+ isThinking,
750
+ isCustomTools,
751
+ isPreview,
752
+ family,
753
+ variants,
754
+ candidatesInCatalog,
755
+ relatedModels,
756
+ };
757
+ }
758
+
759
+ /**
760
+ * Helper que extrai variantes de lista ou objeto
761
+ */
762
+ export function extractVariantsFromAny(input: unknown): ExtractVariantsResult[] {
763
+ if (typeof input === "string") return [extractVariants(input)];
764
+ if (Array.isArray(input)) {
765
+ const out: ExtractVariantsResult[] = [];
766
+ for (const item of input) {
767
+ if (typeof item === "string") out.push(extractVariants(item));
768
+ else if (isPlainObject(item) && typeof (item as any).id === "string") out.push(extractVariants((item as any).id));
769
+ else if (isPlainObject(item) && typeof (item as any).model === "string")
770
+ out.push(extractVariants((item as any).model));
771
+ }
772
+ return out;
773
+ }
774
+ if (isPlainObject(input)) {
775
+ const keys = Object.keys(input as object);
776
+ // se chaves parecem model ids
777
+ if (keys.every((k) => k.includes("gemini") || k.includes("claude") || k.includes("antigravity"))) {
778
+ return keys.map((k) => extractVariants(k));
779
+ }
780
+ }
781
+ return [];
782
+ }
783
+
784
+ // ---------------------------------------------------------------------------
785
+ // Merged: model detection and variant extraction
786
+ // ---------------------------------------------------------------------------
787
+
788
+ /**
789
+ * Detailed model variant extraction result used for routing and budgets.
790
+ */
791
+ export interface ModelVariantInfo {
792
+ original: string;
793
+ normalized: string;
794
+ base: string;
795
+ family: "gemini" | "claude" | "unknown";
796
+ isPreview: boolean;
797
+ isThinking: boolean;
798
+ thinkingVariant: "low" | "max" | null;
799
+ isCustomTools: boolean;
800
+ isClaude: boolean;
801
+ isGemini: boolean;
802
+ isAntigravityWrapper: boolean;
803
+ budgetHint?: number | undefined;
804
+ group: "antigravity" | "gemini-cli";
805
+ }
806
+
807
+ /**
808
+ * Extracts routing/budget metadata from a model id: family detection, preview
809
+ * and customtools flags, thinking-low/max variant, antigravity wrapper and the
810
+ * suggested quota group.
811
+ *
812
+ * @param modelId - raw model id
813
+ * @throws {RequestHelpersError} when modelId is empty
814
+ */
815
+ export function extractModelVariant(modelId: string): ModelVariantInfo {
816
+ if (typeof modelId !== "string" || modelId.trim() === "") {
817
+ throw new RequestHelpersError("extractModelVariant requires non-empty modelId", "INVALID_MODEL");
818
+ }
819
+ const original = modelId.trim();
820
+ const normalized = original.toLowerCase();
821
+
822
+ const isPreview = normalized.includes("preview");
823
+ const isThinking = normalized.includes("thinking");
824
+ const isCustomTools = normalized.includes("customtools");
825
+ const isClaude = normalized.includes("claude");
826
+ const isGemini = normalized.includes("gemini");
827
+ const isAntigravityWrapper = normalized.startsWith("antigravity-");
828
+
829
+ let thinkingVariant: "low" | "max" | null = null;
830
+ if (normalized.includes("-low") || normalized.endsWith("low")) {
831
+ if (normalized.includes("thinking-low") || normalized.match(/-low$/)) {
832
+ thinkingVariant = "low";
833
+ }
834
+ }
835
+ if (normalized.includes("-max") || normalized.endsWith("max")) {
836
+ if (normalized.includes("thinking-max") || normalized.match(/-max$/)) {
837
+ thinkingVariant = "max";
838
+ }
839
+ }
840
+ if (normalized.includes("max") && thinkingVariant !== "low") {
841
+ if (isThinking) thinkingVariant = normalized.includes("max") ? "max" : thinkingVariant;
842
+ }
843
+
844
+ let family: ModelVariantInfo["family"] = "unknown";
845
+ if (isClaude) family = "claude";
846
+ else if (isGemini) family = "gemini";
847
+
848
+ let base = normalized;
849
+ base = base.replace(/^antigravity-/, "");
850
+ base = base.replace(/-preview/g, "");
851
+ base = base.replace(/-customtools/g, "");
852
+ base = base.replace(/-thinking-max/g, "");
853
+ base = base.replace(/-thinking-low/g, "");
854
+ base = base.replace(/-thinking/g, "");
855
+ if (isClaude) {
856
+ base = base.replace(/-low$/g, "");
857
+ base = base.replace(/-max$/g, "");
858
+ }
859
+ base = base.replace(/--+/g, "-").replace(/^-|-$/g, "");
860
+
861
+ const group: ModelVariantInfo["group"] = isGeminiCLIOnlyModel(original) ? "gemini-cli" : "antigravity";
862
+
863
+ const budgetHint = isThinking
864
+ ? getThinkingBudgetForVariant(thinkingVariant)
865
+ : isGemini
866
+ ? THINKING_BUDGET_MAP.medium
867
+ : undefined;
868
+
869
+ return {
870
+ original,
871
+ normalized,
872
+ base,
873
+ family,
874
+ isPreview,
875
+ isThinking,
876
+ thinkingVariant,
877
+ isCustomTools,
878
+ isClaude,
879
+ isGemini,
880
+ isAntigravityWrapper,
881
+ budgetHint,
882
+ group,
883
+ };
884
+ }
885
+
886
+ /**
887
+ * Checklist alias of {@link extractModelVariant}.
888
+ */
889
+ export const parseModelVariant = extractModelVariant;
890
+
891
+ // ---------------------------------------------------------------------------
892
+ // Headers — fingerprint, User-Agent dynamic, strip forbidden
893
+ // ---------------------------------------------------------------------------
894
+
895
+ /**
896
+ * Builds dynamic User-Agent antigravity/{version} {os}/{arch}.
897
+ * Third-person observer copies AM behavior to bypass ban on 1.11.5.
898
+ *
899
+ * @param version - optional semver
900
+ */
901
+ export function buildDynamicUserAgent(version?: string): string {
902
+ const ver = version && /^\d+\.\d+\.\d+/.test(version) ? version.trim() : ANTIGRAVITY_VERSION_FALLBACK;
903
+ try {
904
+ const plat = normalizePlatform(os.platform());
905
+ const arch = normalizeArch(os.arch());
906
+ return `antigravity/${ver} ${plat}/${arch}`;
907
+ } catch {
908
+ return `antigravity/${ver}`;
909
+ }
910
+ }
911
+
912
+ /**
913
+ * Builds Client-Metadata header.
914
+ *
915
+ * @param version - optional version
916
+ */
917
+
918
+ /**
919
+ * Builds X-Goog-Api-Client header — only for gemini-cli mode.
920
+ *
921
+ * @param version - optional
922
+ */
923
+ export function buildApiClient(version?: string): string {
924
+ const ver = version && /^\d+\.\d+\.\d+/.test(version) ? version.trim() : ANTIGRAVITY_VERSION_FALLBACK;
925
+ try {
926
+ const nodeVer = process.versions.node ?? "20.0.0";
927
+ return `antigravity/${ver} gl-node/${nodeVer} gax/4.9.0 grpc/1.14.0`;
928
+ } catch {
929
+ return `antigravity/${ver}`;
930
+ }
931
+ }
932
+
933
+ /**
934
+ * Generates fingerprint headers for given style.
935
+ * Observer knows:
936
+ * - AM only sends User-Agent on content requests (not on onboard/load)
937
+ * - Never sends X-Goog-QuotaUser nor X-Client-Device-Id
938
+ * - Gemini CLI includes X-Goog-Api-Client
939
+ *
940
+ * @param style - 'antigravity' or 'gemini-cli'
941
+ * @param version - optional antigravity version
942
+ * @returns headers record
943
+ */
944
+ // v2.1.15 Phase B: getFingerprintHeaders moved to fingerprint.js (identity owner).
945
+
946
+ /**
947
+ * Alias required by checklist naming: strip x-goog-user-project header explicit.
948
+ *
949
+ * @param headers - headers map
950
+ * @returns cleaned map
951
+ */
952
+ export function stripXGoogUserProjectHeader(headers: Record<string, string>): Record<string, string> {
953
+ return stripForbiddenHeaders(headers);
954
+ }
955
+
956
+ // ---------------------------------------------------------------------------
957
+ // Merged: transport helpers — retry classification and header stripping
958
+ // ---------------------------------------------------------------------------
959
+
960
+ /**
961
+ * Checks whether an HTTP status should trigger cascade retry:
962
+ * 429, 403, 404 and the whole 5xx range.
963
+ *
964
+ * @param status - HTTP status code
965
+ */
966
+
967
+ // ---------------------------------------------------------------------------
968
+ // Tool name sanitization & validation
969
+ // ---------------------------------------------------------------------------
970
+
971
+ // ---------------------------------------------------------------------------
972
+ // JSON Schema cleaning — fixes Invalid JSON payload Unknown name 'parameters'
973
+ // ---------------------------------------------------------------------------
974
+
975
+ // ---------------------------------------------------------------------------
976
+ // Merged: strict JSON schema cleaning for tool definitions
977
+ // ---------------------------------------------------------------------------
978
+
979
+ /**
980
+ * Recursively cleans a JSON Schema node down to only the keys listed in
981
+ * {@link ALLOWED_SCHEMA_KEYS}. This removes $schema/$ref/title/
982
+ * additionalProperties/default/examples and similar unknown names that trigger
983
+ * "Invalid JSON payload Unknown name" errors upstream.
984
+ *
985
+ * v2.1.14 merge note: renamed from cleanJsonSchema (request-helpers.ts) to
986
+ * cleanJsonSchemaStrict; the tolerant Gemini-shaping normalizer from
987
+ * request.ts owns the cleanJsonSchema name. ALLOWED_SCHEMA_KEYS is now the
988
+ * full allowlist Set from request.ts; the switch below still only emits the
989
+ * six core keys, so the observable behavior of this strict cleaner is
990
+ * unchanged by the merge.
991
+ *
992
+ * @param schema - raw schema node
993
+ * @param depth - recursion guard, throws past depth 12
994
+ * @returns cleaned schema object or undefined when not salvageable
995
+ * @throws {RequestHelpersError} when depth exceeds 12 (possible circular schema)
996
+ */
997
+ export function cleanJsonSchemaStrict(schema: unknown, depth = 0): Record<string, unknown> | undefined {
998
+ if (depth > 12) {
999
+ throw new RequestHelpersError("JSON schema depth >12 — possible circular", "SCHEMA_TOO_DEEP");
1000
+ }
1001
+ if (!isPlainObject(schema)) {
1002
+ return undefined;
1003
+ }
1004
+
1005
+ const out: Record<string, unknown> = {};
1006
+
1007
+ for (const key of Object.keys(schema)) {
1008
+ if (!ALLOWED_SCHEMA_KEYS.has(key)) {
1009
+ continue;
1010
+ }
1011
+
1012
+ const val = (schema as Record<string, unknown>)[key];
1013
+
1014
+ switch (key) {
1015
+ case "type": {
1016
+ if (typeof val === "string" && ["object", "string", "number", "integer", "boolean", "array"].includes(val)) {
1017
+ out.type = val;
1018
+ }
1019
+ break;
1020
+ }
1021
+ case "description": {
1022
+ if (typeof val === "string") out.description = val.slice(0, 1024);
1023
+ break;
1024
+ }
1025
+ case "enum": {
1026
+ if (Array.isArray(val)) {
1027
+ const filtered = val
1028
+ .filter((e) => typeof e === "string" || typeof e === "number" || typeof e === "boolean")
1029
+ .slice(0, 100);
1030
+ if (filtered.length > 0) out.enum = filtered;
1031
+ }
1032
+ break;
1033
+ }
1034
+ case "required": {
1035
+ if (Array.isArray(val)) {
1036
+ const req = val.filter((r) => typeof r === "string").slice(0, 100);
1037
+ if (req.length > 0) out.required = req;
1038
+ }
1039
+ break;
1040
+ }
1041
+ case "properties": {
1042
+ if (isPlainObject(val)) {
1043
+ const props: Record<string, unknown> = {};
1044
+ for (const propName of Object.keys(val)) {
1045
+ const propSchema = (val as Record<string, unknown>)[propName];
1046
+ const cleaned = cleanJsonSchemaStrict(propSchema, depth + 1);
1047
+ if (cleaned) {
1048
+ props[propName] = cleaned;
1049
+ }
1050
+ }
1051
+ out.properties = props;
1052
+ }
1053
+ break;
1054
+ }
1055
+ case "items": {
1056
+ const cleaned = cleanJsonSchemaStrict(val, depth + 1);
1057
+ if (cleaned) out.items = cleaned;
1058
+ break;
1059
+ }
1060
+ default:
1061
+ break;
1062
+ }
1063
+ }
1064
+
1065
+ return out;
1066
+ }
1067
+
1068
+ /**
1069
+ * Cleaned tool definition compatible with Gemini functionDeclarations.
1070
+ */
1071
+ export interface CleanedTool {
1072
+ name: string;
1073
+ description?: string | undefined;
1074
+ parameters: {
1075
+ type: "object";
1076
+ properties: Record<string, unknown>;
1077
+ required?: string[] | undefined;
1078
+ description?: string | undefined;
1079
+ };
1080
+ }
1081
+
1082
+ /**
1083
+ * Cleans a single raw tool definition (OpenAI wrapper, Anthropic input_schema
1084
+ * or plain parameters) into a {@link CleanedTool} with a sanitized name and a
1085
+ * root-object schema enforced.
1086
+ *
1087
+ * v2.1.14 merge note: keeps its name; it now builds on
1088
+ * cleanJsonSchemaStrict (the strict cleaner formerly exported as the
1089
+ * request-helpers cleanJsonSchema).
1090
+ *
1091
+ * @param raw - raw tool definition
1092
+ * @throws {RequestHelpersError} on missing/invalid objects or names
1093
+ */
1094
+ export function cleanRawToolSchema(raw: unknown): CleanedTool {
1095
+ if (!isPlainObject(raw) && !(raw as any)?.function) {
1096
+ throw new RequestHelpersError(`cleanToolSchema expects object, got ${typeof raw}`, "INVALID_TOOL_SCHEMA");
1097
+ }
1098
+
1099
+ // Unwrap OpenAI style { type:"function", function:{...} }
1100
+ let src: Record<string, unknown> = raw as Record<string, unknown>;
1101
+ if (isPlainObject((src as any).function)) {
1102
+ src = (src as any).function as Record<string, unknown>;
1103
+ }
1104
+
1105
+ const potentialSchemas = [
1106
+ (src as any).parameters,
1107
+ (src as any).input_schema,
1108
+ (src as any).schema,
1109
+ (src as any).parameters_json,
1110
+ ];
1111
+
1112
+ let parametersRaw: unknown;
1113
+ for (const cand of potentialSchemas) {
1114
+ if (cand && isPlainObject(cand)) {
1115
+ parametersRaw = cand;
1116
+ break;
1117
+ }
1118
+ }
1119
+
1120
+ if (!parametersRaw) {
1121
+ parametersRaw = { type: "object", properties: {} };
1122
+ }
1123
+
1124
+ const cleanedParams = cleanJsonSchemaStrict(parametersRaw, 0) ?? { type: "object", properties: {} };
1125
+
1126
+ // Enforce root object type required by Gemini functionDeclarations
1127
+ if ((cleanedParams as any).type !== "object") {
1128
+ (cleanedParams as any).type = "object";
1129
+ }
1130
+ if (!(cleanedParams as any).properties) {
1131
+ (cleanedParams as any).properties = {};
1132
+ }
1133
+
1134
+ const rawName = (src as any).name;
1135
+ if (typeof rawName !== "string" || rawName.trim() === "") {
1136
+ throw new RequestHelpersError("tool schema missing name", "INVALID_TOOL_NAME");
1137
+ }
1138
+ const sanitized = sanitizeToolName(rawName);
1139
+
1140
+ const descriptionRaw = (src as any).description;
1141
+ const description = typeof descriptionRaw === "string" ? descriptionRaw.slice(0, 4096) : undefined;
1142
+
1143
+ return {
1144
+ name: sanitized,
1145
+ description,
1146
+ parameters: cleanedParams as unknown as CleanedTool["parameters"],
1147
+ };
1148
+ }
1149
+
1150
+ /**
1151
+ * Cleans an array of raw tools into deduplicated {@link CleanedTool} entries
1152
+ * (last wins per sanitized name), capped at {@link MAX_TOOLS_PER_REQUEST}.
1153
+ * Invalid tools are skipped rather than failing the request.
1154
+ *
1155
+ * @param tools - array of raw tool definitions
1156
+ * @throws {RequestHelpersError} when input is not an array
1157
+ */
1158
+ export function cleanToolsArray(tools: unknown): CleanedTool[] {
1159
+ if (!Array.isArray(tools)) {
1160
+ throw new RequestHelpersError("cleanToolsArray expects array", "INVALID_TOOLS");
1161
+ }
1162
+ const map = new Map<string, CleanedTool>();
1163
+ for (const t of tools) {
1164
+ try {
1165
+ const cleaned = cleanRawToolSchema(t);
1166
+ map.set(cleaned.name, cleaned);
1167
+ if (map.size >= MAX_TOOLS_PER_REQUEST) break;
1168
+ } catch (toolerror) {
1169
+ // a malformed tool schema drops that one tool and keeps the rest of
1170
+ // the request alive — the skip is the surfacing
1171
+ void toolerror;
1172
+ }
1173
+ }
1174
+ return Array.from(map.values());
1175
+ }
1176
+
1177
+ // ---------------------------------------------------------------------------
1178
+ // Thinking blocks mapping
1179
+ // ---------------------------------------------------------------------------
1180
+
1181
+ /**
1182
+ * Maps a thinking block to Gemini thought part.
1183
+ * For Gemini 3 and Claude via Antigravity, thought parts must have thought: true.
1184
+ * Optionally includes thoughtSignature for multi-turn preservation.
1185
+ *
1186
+ * @param text - thinking text
1187
+ * @param signature - optional signature for LRU cache in streaming.ts
1188
+ * @returns GeminiPart
1189
+ */
1190
+ export function mapThinkingBlock(text: string, signature?: string): GeminiPart {
1191
+ if (!text || typeof text !== "string") text = String(text ?? "");
1192
+ const part: GeminiPart = {
1193
+ text,
1194
+ thought: true,
1195
+ };
1196
+ if (signature && typeof signature === "string" && signature.length > 0) {
1197
+ part.thoughtSignature = signature.slice(0, 10000);
1198
+ }
1199
+ return part;
1200
+ }
1201
+
1202
+ /**
1203
+ * Sorts parts so that thought parts come first, then text, then function calls.
1204
+ * Required because Gemini/Claude require thinking block order validation.
1205
+ *
1206
+ * @param parts - parts array
1207
+ * @returns sorted parts
1208
+ */
1209
+ export function sortThoughtFirst(parts: GeminiPart[]): GeminiPart[] {
1210
+ if (!Array.isArray(parts)) return [];
1211
+ const thoughts = parts.filter((p) => p.thought === true);
1212
+ const others = parts.filter((p) => p.thought !== true);
1213
+ // Preserve relative order within groups, thoughts first
1214
+ return [...thoughts, ...others];
1215
+ }
1216
+
1217
+ /**
1218
+ * Filters and normalizes thinking content — removes empty thinking and ensures
1219
+ * signature preservation.
1220
+ *
1221
+ * @param parts - incoming parts
1222
+ * @returns filtered parts
1223
+ */
1224
+ export function filterAndMapThinkingParts(parts: any[]): GeminiPart[] {
1225
+ const out: GeminiPart[] = [];
1226
+ for (const p of parts) {
1227
+ if (!p) continue;
1228
+ // Already Gemini thought
1229
+ if (p.thought === true && p.text) {
1230
+ out.push(mapThinkingBlock(String(p.text), p.thoughtSignature));
1231
+ continue;
1232
+ }
1233
+ // Anthropic thinking type
1234
+ if (p.type === "thinking" || p.type === "reasoning") {
1235
+ const txt = p.thinking ?? p.text ?? p.reasoning ?? "";
1236
+ const sig = p.signature ?? p.thoughtSignature;
1237
+ if (txt) out.push(mapThinkingBlock(String(txt), sig ? String(sig) : undefined));
1238
+ continue;
1239
+ }
1240
+ // Direct string thinking marker
1241
+ if (typeof p === "object" && "thinking" in p && typeof p.thinking === "string") {
1242
+ out.push(mapThinkingBlock(p.thinking, p.signature));
1243
+ continue;
1244
+ }
1245
+ // Keep other parts as-is for later mapping (text, etc)
1246
+ out.push(p);
1247
+ }
1248
+ return sortThoughtFirst(out);
1249
+ }
1250
+
1251
+ /**
1252
+ * Detects thinking blocks that must be filtered before dispatch: thought flags,
1253
+ * type discriminators, reasoning_content/reasoning fields.
1254
+ *
1255
+ * @param block - any part-like object
1256
+ */
1257
+ export function isThinkingBlock(block: unknown): boolean {
1258
+ if (!block || typeof block !== "object") return false;
1259
+ const b = block as Record<string, unknown>;
1260
+
1261
+ if ((b as any).thought === true) return true;
1262
+ if ((b as any).thinking === true) return true;
1263
+
1264
+ const t = (b as any).type;
1265
+ if (typeof t === "string") {
1266
+ const lt = t.toLowerCase();
1267
+ if (lt === "thinking" || lt === "reasoning" || lt === "thought" || lt === "internal_reasoning") return true;
1268
+ }
1269
+
1270
+ if ("thinking" in b && typeof b.thinking === "string" && (b as any).type === "thinking") return true;
1271
+ if ("reasoning_content" in b) return true;
1272
+ if ("reasoning" in b && typeof b.reasoning === "string") return true;
1273
+
1274
+ return false;
1275
+ }
1276
+
1277
+ // ---------------------------------------------------------------------------
1278
+ // Merged: standalone thinking-block filters
1279
+ // ---------------------------------------------------------------------------
1280
+
1281
+ /**
1282
+ * Filters thinking blocks from a parts array.
1283
+ *
1284
+ * @param parts - array of parts in any shape
1285
+ * @throws {RequestHelpersError} when input is not an array
1286
+ */
1287
+ export function filterThinkingBlocksFromParts(parts: unknown): GeminiPart[] {
1288
+ if (!Array.isArray(parts)) {
1289
+ throw new RequestHelpersError("filterThinkingBlocksFromParts expects array", "INVALID_PARTS");
1290
+ }
1291
+ const filtered: GeminiPart[] = [];
1292
+ for (const p of parts) {
1293
+ if (isThinkingBlock(p)) continue;
1294
+ if (isPlainObject(p)) {
1295
+ const obj = p as Record<string, unknown>;
1296
+ if ((obj as any).thought === true) continue;
1297
+ }
1298
+ filtered.push(p as GeminiPart);
1299
+ }
1300
+ return filtered;
1301
+ }
1302
+
1303
+ /**
1304
+ * Filters thinking parts inside each Gemini message, dropping messages that
1305
+ * become empty (including system).
1306
+ *
1307
+ * @param messages - Gemini-shaped messages
1308
+ * @throws {RequestHelpersError} when input is not an array
1309
+ */
1310
+ export function filterThinkingBlocksFromMessages(messages: unknown): GeminiMessage[] {
1311
+ if (!Array.isArray(messages)) {
1312
+ throw new RequestHelpersError("filterThinkingBlocksFromMessages expects array", "INVALID_MESSAGES");
1313
+ }
1314
+ const out: GeminiMessage[] = [];
1315
+ for (const raw of messages) {
1316
+ if (!isPlainObject(raw)) continue;
1317
+ const msg = raw as { role?: string; parts?: unknown[] };
1318
+ const role = msg.role;
1319
+ const partsRaw = msg.parts;
1320
+ if (!Array.isArray(partsRaw)) continue;
1321
+ const filteredParts = filterThinkingBlocksFromParts(partsRaw);
1322
+ if (filteredParts.length === 0) {
1323
+ if (role === "system") continue;
1324
+ continue;
1325
+ }
1326
+ out.push({
1327
+ role: (role as GeminiMessage["role"]) ?? "user",
1328
+ parts: filteredParts,
1329
+ _rawRole: (raw as any)._rawRole,
1330
+ });
1331
+ }
1332
+ return out;
1333
+ }
1334
+
1335
+ /**
1336
+ * Auto-detecting filter: routes message arrays (role+parts items) to
1337
+ * {@link filterThinkingBlocksFromMessages} and everything else to
1338
+ * {@link filterThinkingBlocksFromParts}.
1339
+ *
1340
+ * Named `filterThinkingBlocksAuto` because the historical overloaded
1341
+ * `filterThinkingBlocks` keeps its original tag-stripping/string semantics.
1342
+ *
1343
+ * @param input - parts array or messages array
1344
+ * @throws {RequestHelpersError} when input is not an array
1345
+ */
1346
+ export function filterThinkingBlocksAuto(input: unknown): unknown[] {
1347
+ if (!Array.isArray(input)) {
1348
+ throw new RequestHelpersError("filterThinkingBlocks expects array", "INVALID_INPUT");
1349
+ }
1350
+ if (input.length === 0) return [];
1351
+
1352
+ const first = input[0] as any;
1353
+ if (isPlainObject(first) && "role" in first && "parts" in first) {
1354
+ return filterThinkingBlocksFromMessages(input);
1355
+ }
1356
+ return filterThinkingBlocksFromParts(input);
1357
+ }
1358
+
1359
+ // ============================================================================
1360
+ // 06. filterThinkingBlocks()
1361
+ // ============================================================================
1362
+
1363
+ export interface ThoughtLikePart {
1364
+ thought?: boolean;
1365
+ thinking?: boolean;
1366
+ type?: string;
1367
+ text?: string;
1368
+ content?: string;
1369
+ reasoning?: string;
1370
+ [k: string]: unknown;
1371
+ }
1372
+
1373
+ /**
1374
+ * Strips thinking/reasoning blocks from content.
1375
+ * Supports:
1376
+ * - string: removes <thinking>...</thinking>, <thought>...</thought>, <think>...</think> tags
1377
+ * - array of Gemini parts (with thought:true) => filtered out
1378
+ * - array of OpenCode-like blocks
1379
+ */
1380
+
1381
+ // bounded windows keep every tag scan linear (the code-scanning redos
1382
+ // law): the attribute and body arms carry hard ceilings so an adversarial
1383
+ // "<thinking>" run degrades to a bounded scan, never a polynomial one.
1384
+ const THINKING_TAG_REGEX = /<(thinking|thought|think|reasoning|Thought|Thinking)[^>]{0,256}>[\s\S]{0,262144}?<\/\1\s{0,16}>/gi;
1385
+ const THINKING_SELF_CLOSED_REGEX = /<(thinking|thought|think)[^>]{0,256}\/>/gi;
1386
+ const THINKING_INLINE_PREFIX = /^(?:Thought:|Thinking:|Reasoning:)\s*/i;
1387
+
1388
+ export function filterThinkingBlocks(input: string): string;
1389
+ export function filterThinkingBlocks<T extends ThoughtLikePart>(input: T[]): T[];
1390
+ export function filterThinkingBlocks(input: unknown): unknown;
1391
+ export function filterThinkingBlocks(input: unknown): unknown {
1392
+ if (typeof input === "string") {
1393
+ let out = input;
1394
+ // strip the tagged blocks
1395
+ out = out.replace(THINKING_TAG_REGEX, "");
1396
+ out = out.replace(THINKING_SELF_CLOSED_REGEX, "");
1397
+ // removes multiple lines that are only the Thinking: prefix
1398
+ // also cleans the stray "<thinking>" artifacts (bounded attribute
1399
+ // windows — the redos law)
1400
+ out = out.replace(/<\/?thinking[^>]{0,256}>/gi, "");
1401
+ out = out.replace(/<\/?thought[^>]{0,256}>/gi, "");
1402
+ out = out.replace(/<\/?think[^>]{0,256}>/gi, "");
1403
+ out = out.replace(/<\/?reasoning[^>]{0,256}>/gi, "");
1404
+ // removes the lines starting with Thought:
1405
+ out = out
1406
+ .split("\n")
1407
+ .filter((line) => {
1408
+ const trimmed = line.trim();
1409
+ if (!trimmed) return true;
1410
+ // when the whole line is a thinking marker, remove it
1411
+ if (/^(Thought|Thinking|Reasoning):\s*$/i.test(trimmed)) return false;
1412
+ return true;
1413
+ })
1414
+ .join("\n");
1415
+ // trims the excess blank space left behind
1416
+ out = out.replace(/\n{3,}/g, "\n\n").trim();
1417
+ return out;
1418
+ }
1419
+
1420
+ if (Array.isArray(input)) {
1421
+ return (input as ThoughtLikePart[]).filter((part) => {
1422
+ if (!part || typeof part !== "object") return true;
1423
+ // Gemini: { thought: true }
1424
+ if ((part as any).thought === true) return false;
1425
+ if ((part as any).thinking === true) return false;
1426
+ const t = (part as any).type?.toLowerCase?.();
1427
+ if (t === "thinking" || t === "thought" || t === "reasoning") return false;
1428
+ // textual content that is only a thinking tag
1429
+ const text = (part as any).text ?? (part as any).content ?? (part as any).reasoning;
1430
+ if (typeof text === "string") {
1431
+ const trimmed = text.trim();
1432
+ if (!trimmed) return true;
1433
+ // when the whole content is wrapped in thinking tags, filter the whole block
1434
+ if (/^<(thinking|thought|think|reasoning)[\s\S]*<\/(thinking|thought|think|reasoning)>$/i.test(trimmed))
1435
+ return false;
1436
+ // when it contains only the tag, filter it
1437
+ if (/^<\/?(thinking|thought|think|reasoning)[^>]*>$/i.test(trimmed)) return false;
1438
+ }
1439
+ return true;
1440
+ });
1441
+ }
1442
+
1443
+ // when it is a single object with parts
1444
+ if (isPlainObject(input)) {
1445
+ const obj = input as Record<string, unknown>;
1446
+ if (Array.isArray(obj["parts"])) {
1447
+ return {
1448
+ ...obj,
1449
+ parts: filterThinkingBlocks(obj["parts"] as unknown[]),
1450
+ };
1451
+ }
1452
+ if (Array.isArray(obj["content"])) {
1453
+ return {
1454
+ ...obj,
1455
+ content: filterThinkingBlocks(obj["content"] as unknown[]),
1456
+ };
1457
+ }
1458
+ }
1459
+
1460
+ return input;
1461
+ }
1462
+
1463
+ // ---------------------------------------------------------------------------
1464
+ // Google Search tool injection
1465
+ // ---------------------------------------------------------------------------
1466
+
1467
+ /**
1468
+ * Definition for google_search built-in grounding tool wrapped as function declaration
1469
+ * for compatibility with OpenCode → Gemini flow.
1470
+ * Observer notes: original plugin shekohex uses wrapper to call separate search API.
1471
+ */
1472
+ export const GOOGLE_SEARCH_FUNCTION_DECLARATION = {
1473
+ name: "google_search",
1474
+ description:
1475
+ "Search the web for real-time information, factual grounding, current events, documentation, and recent knowledge. Use when user asks for latest information, news, or requires browsing to answer.",
1476
+ parameters: {
1477
+ type: "OBJECT",
1478
+ properties: {
1479
+ query: {
1480
+ type: "STRING",
1481
+ description: "Search query string, 3-10 words focused keywords",
1482
+ },
1483
+ },
1484
+ required: ["query"],
1485
+ },
1486
+ } as const;
1487
+
1488
+ export const URL_CONTEXT_FUNCTION_DECLARATION = {
1489
+ name: "url_context",
1490
+ description: "Fetch and provide context from a URL for grounding.",
1491
+ parameters: {
1492
+ type: "OBJECT",
1493
+ properties: {
1494
+ url: {
1495
+ type: "STRING",
1496
+ description: "HTTP(S) URL to fetch",
1497
+ },
1498
+ },
1499
+ required: ["url"],
1500
+ },
1501
+ } as const;
1502
+
1503
+ /**
1504
+ * Injects google_search tool into declarations if enabled and not already present.
1505
+ *
1506
+ * @param declarations - existing declarations array
1507
+ * @param enabled - whether injection enabled
1508
+ * @param includeUrlContext - whether to also inject url_context
1509
+ * @returns new declarations array with injection
1510
+ */
1511
+ export function injectGoogleSearchTool(
1512
+ declarations: Array<{ name: string; description?: string; parameters?: any; originalName: string }>,
1513
+ enabled?: boolean,
1514
+ includeUrlContext = false,
1515
+ ): Array<{ name: string; description?: string; parameters?: any; originalName: string }> {
1516
+ if (!enabled) return declarations;
1517
+ const existingNames = new Set(declarations.map((d) => d.name.toLowerCase()));
1518
+ const result = [...declarations];
1519
+
1520
+ if (!existingNames.has("google_search")) {
1521
+ result.push({
1522
+ name: "google_search",
1523
+ description: GOOGLE_SEARCH_FUNCTION_DECLARATION.description,
1524
+ parameters: GOOGLE_SEARCH_FUNCTION_DECLARATION.parameters,
1525
+ originalName: "google_search",
1526
+ });
1527
+ }
1528
+
1529
+ if (includeUrlContext && !existingNames.has("url_context")) {
1530
+ result.push({
1531
+ name: "url_context",
1532
+ description: URL_CONTEXT_FUNCTION_DECLARATION.description,
1533
+ parameters: URL_CONTEXT_FUNCTION_DECLARATION.parameters,
1534
+ originalName: "url_context",
1535
+ });
1536
+ }
1537
+
1538
+ return result;
1539
+ }
1540
+
1541
+ // ---------------------------------------------------------------------------
1542
+ // Content helpers — inlineData, data URI, function args parsing
1543
+ // ---------------------------------------------------------------------------
1544
+
1545
+ /**
1546
+ * Parses OpenAI tool arguments which may be stringified JSON.
1547
+ *
1548
+ * @param args - args string or object
1549
+ * @returns parsed object
1550
+ */
1551
+ export function parseFunctionArgs(args: string | object | any): Record<string, any> {
1552
+ if (!args) return {};
1553
+ if (typeof args === "object" && !Array.isArray(args)) return args as Record<string, any>;
1554
+ if (typeof args === "string") {
1555
+ const trimmed = args.trim();
1556
+ if (!trimmed) return {};
1557
+ try {
1558
+ const parsed = JSON.parse(trimmed);
1559
+ if (parsed && typeof parsed === "object") return parsed;
1560
+ return { value: parsed };
1561
+ } catch {
1562
+ // If not JSON, treat as single string value if schema expects string, else wrap
1563
+ return { query: trimmed, input: trimmed };
1564
+ }
1565
+ }
1566
+ return { value: args };
1567
+ }
1568
+
1569
+ // v2.1.16: dead parseDataUri was deleted — zero callers repo-wide. The live
1570
+ // data-URI parser is the private parseDataUrl below (near-twin that also
1571
+ // guards against non-string input), used by contentPartsToGeminiParts.
1572
+
1573
+ /**
1574
+ * Attempts to extract last user prompt text for deterministic user_prompt_id.
1575
+ *
1576
+ * @param messages - messages array
1577
+ * @returns last user text or fallback
1578
+ */
1579
+ export function extractLastUserPrompt(messages: OpenCodeMessage[]): string {
1580
+ if (!Array.isArray(messages) || messages.length === 0) return "empty-prompt";
1581
+ for (let i = messages.length - 1; i >= 0; i--) {
1582
+ const m = messages[i];
1583
+ if (!m) continue;
1584
+ if (String(m.role).toLowerCase() !== "user") continue;
1585
+ const c = m.content;
1586
+ if (typeof c === "string" && c.trim()) return c.trim().slice(0, 2000);
1587
+ if (Array.isArray(c)) {
1588
+ for (const part of c) {
1589
+ if (!part) continue;
1590
+ if (part.type === "text" && part.text && String(part.text).trim()) {
1591
+ return String(part.text).trim().slice(0, 2000);
1592
+ }
1593
+ if (typeof part === "object" && "text" in part && typeof (part as any).text === "string") {
1594
+ const t = String((part as any).text).trim();
1595
+ if (t) return t.slice(0, 2000);
1596
+ }
1597
+ }
1598
+ }
1599
+ }
1600
+ // Fallback: stringify whole
1601
+ try {
1602
+ return JSON.stringify(messages).slice(0, 2000);
1603
+ } catch {
1604
+ return "fallback-prompt";
1605
+ }
1606
+ }
1607
+
1608
+ // ============================================================================
1609
+ // 07. normalizeMessages() - OpenCode -> Gemini
1610
+ // ============================================================================
1611
+
1612
+ export interface OpenCodeContentPart {
1613
+ type?: string; // "text" | "image_url" | "tool_call" etc
1614
+ text?: string;
1615
+ image_url?: { url: string; detail?: string };
1616
+ // tool
1617
+ id?: string;
1618
+ tool_call_id?: string;
1619
+ name?: string;
1620
+ // para compat
1621
+ function?: { name: string; arguments: string | object };
1622
+ [k: string]: unknown;
1623
+ }
1624
+
1625
+ export interface OpenCodeToolCall {
1626
+ id: string;
1627
+ type: "function";
1628
+ function: { name: string; arguments: string | object };
1629
+ }
1630
+
1631
+ export interface GeminiFunctionCall {
1632
+ name: string;
1633
+ args: Record<string, unknown>;
1634
+ }
1635
+
1636
+ export interface GeminiFunctionResponse {
1637
+ name: string;
1638
+ response: Record<string, unknown>;
1639
+ }
1640
+
1641
+ export interface NormalizedMessagesResult {
1642
+ systemInstruction?: GeminiSystemInstruction;
1643
+ contents: GeminiContent[];
1644
+ /** mensagens originais ignoradas por serem vazias */
1645
+ droppedCount: number;
1646
+ }
1647
+
1648
+ /**
1649
+ * Converte data URL para mime + base64
1650
+ */
1651
+ function parseDataUrl(url: string): { mimeType: string; data: string } | null {
1652
+ const m = url.match(/^data:([^;]+);base64,(.*)$/);
1653
+ if (!m) return null;
1654
+ return { mimeType: m[1]!, data: m[2]! };
1655
+ }
1656
+
1657
+ function safeJsonParse(str: string): Record<string, unknown> | null {
1658
+ try {
1659
+ const parsed = JSON.parse(str);
1660
+ if (isPlainObject(parsed)) return parsed as Record<string, unknown>;
1661
+ // when it is not an object, wrap it
1662
+ return { result: parsed } as Record<string, unknown>;
1663
+ } catch {
1664
+ return null;
1665
+ }
1666
+ }
1667
+
1668
+ function contentPartsToGeminiParts(content: string | OpenCodeContentPart[] | null | undefined): GeminiPart[] {
1669
+ if (content == null) return [];
1670
+ if (typeof content === "string") {
1671
+ if (!content.trim()) return [];
1672
+ return [{ text: content }];
1673
+ }
1674
+ if (Array.isArray(content)) {
1675
+ const parts: GeminiPart[] = [];
1676
+ for (const part of content) {
1677
+ if (!part) continue;
1678
+ if (typeof part === "string") {
1679
+ // fallback when the array carries a string
1680
+ if ((part as unknown as string).trim()) parts.push({ text: part as unknown as string });
1681
+ continue;
1682
+ }
1683
+ const p = part as OpenCodeContentPart;
1684
+ if (p.type === "text" && p.text) {
1685
+ parts.push({ text: p.text });
1686
+ } else if (p.type === "image_url" && p.image_url?.url) {
1687
+ const url = p.image_url.url;
1688
+ const data = parseDataUrl(url);
1689
+ if (data) {
1690
+ parts.push({ inlineData: data });
1691
+ } else {
1692
+ // when it is https, Gemini prefers fileData, but keep the reference text to avoid failure
1693
+ // it could download it, but zero-dep: keep it as fileUri
1694
+ // detect the mime via the simple extension
1695
+ parts.push({ fileData: { mimeType: "image/jpeg", fileUri: url } });
1696
+ }
1697
+ } else if ((p.type === "image" || (p as any).inlineData) && (p as any).inlineData) {
1698
+ parts.push({ inlineData: (p as any).inlineData });
1699
+ } else if (p.text) {
1700
+ // generic fallback with text
1701
+ parts.push({ text: p.text });
1702
+ } else if ((p as any).content && typeof (p as any).content === "string") {
1703
+ parts.push({ text: (p as any).content });
1704
+ }
1705
+ }
1706
+ return parts;
1707
+ }
1708
+ return [];
1709
+ }
1710
+
1711
+ /**
1712
+ * Normaliza lista de mensagens OpenCode (OpenAI-like) para formato Gemini
1713
+ * Cloud Code Assist v1internal.
1714
+ *
1715
+ * - system => systemInstruction (combinado)
1716
+ * - user => role user
1717
+ * - assistant => role model, com functionCall se tiver tool_calls
1718
+ * - tool => role user com functionResponse
1719
+ * - filtra thinking blocks automaticamente
1720
+ * - merge mensagens consecutivas de mesmo role
1721
+ *
1722
+ * @param messages - mensagens OpenCode
1723
+ * @param opts - includeSystemAsUser? Se true, system vai como user prefixado
1724
+ */
1725
+ export function normalizeMessages(
1726
+ messages: OpenCodeMessage[],
1727
+ opts?: { mergeConsecutive?: boolean; includeSystemAsUser?: boolean; filterThinking?: boolean },
1728
+ ): NormalizedMessagesResult {
1729
+ const mergeConsecutive = opts?.mergeConsecutive ?? true;
1730
+ const includeSystemAsUser = opts?.includeSystemAsUser ?? false;
1731
+ const filterThinking = opts?.filterThinking ?? true;
1732
+
1733
+ if (!Array.isArray(messages)) {
1734
+ return { contents: [], droppedCount: 0 };
1735
+ }
1736
+
1737
+ const systemParts: GeminiPart[] = [];
1738
+ const contents: GeminiContent[] = [];
1739
+ let dropped = 0;
1740
+
1741
+ for (const msg of messages) {
1742
+ if (!msg || typeof msg !== "object") {
1743
+ dropped++;
1744
+ continue;
1745
+ }
1746
+ const roleRaw = (msg.role ?? "user").toLowerCase();
1747
+
1748
+ // SYSTEM
1749
+ if (roleRaw === "system") {
1750
+ const parts = contentPartsToGeminiParts(msg.content as any);
1751
+ if (parts.length === 0) {
1752
+ dropped++;
1753
+ continue;
1754
+ }
1755
+ const filtered = filterThinking ? (filterThinkingBlocks(parts) as GeminiPart[]) : parts;
1756
+ if (includeSystemAsUser) {
1757
+ // quando solicitado, system vira user com prefixo
1758
+ if (filtered.length > 0) {
1759
+ contents.push({ role: "user", parts: filtered.map((p) => (p.text ? { text: `[System] ${p.text}` } : p)) });
1760
+ }
1761
+ } else {
1762
+ systemParts.push(...filtered);
1763
+ }
1764
+ continue;
1765
+ }
1766
+
1767
+ // TOOL -> functionResponse
1768
+ if (roleRaw === "tool") {
1769
+ const toolName = msg.name ?? (msg as any).tool_name ?? msg.tool_call_id ?? "unknown_tool";
1770
+ let responseContent: string;
1771
+ if (typeof msg.content === "string") responseContent = msg.content;
1772
+ else if (Array.isArray(msg.content)) {
1773
+ responseContent = (msg.content as OpenCodeContentPart[]).map((p) => p.text ?? "").join("\n");
1774
+ } else responseContent = String(msg.content ?? "");
1775
+
1776
+ const parsed = safeJsonParse(responseContent);
1777
+ const response: Record<string, unknown> = parsed ?? { content: responseContent };
1778
+
1779
+ const part: GeminiPart = {
1780
+ functionResponse: {
1781
+ name: toolName,
1782
+ response,
1783
+ },
1784
+ };
1785
+ // Gemini exige que functionResponse venha em role user
1786
+ contents.push({ role: "user", parts: [part] });
1787
+ continue;
1788
+ }
1789
+
1790
+ // ASSISTANT -> model
1791
+ if (roleRaw === "assistant" || roleRaw === "model" || roleRaw === "bot") {
1792
+ const parts: GeminiPart[] = [];
1793
+
1794
+ // text content
1795
+ const textParts = contentPartsToGeminiParts(msg.content as any);
1796
+ parts.push(...textParts);
1797
+
1798
+ // tool_calls -> functionCall
1799
+ if (Array.isArray(msg.tool_calls)) {
1800
+ for (const tc of msg.tool_calls) {
1801
+ if (!tc || !tc.function?.name) continue;
1802
+ let args: Record<string, unknown> = {};
1803
+ const rawArgs = tc.function.arguments;
1804
+ if (typeof rawArgs === "string") {
1805
+ const parsed = safeJsonParse(rawArgs);
1806
+ args = parsed ?? { _raw: rawArgs };
1807
+ // when raw was a pure string, try to keep an object
1808
+ if (!parsed) {
1809
+ // try parsing again as a generic object
1810
+ try {
1811
+ args = JSON.parse(rawArgs) as Record<string, unknown>;
1812
+ } catch {
1813
+ args = { input: rawArgs };
1814
+ }
1815
+ }
1816
+ } else if (isPlainObject(rawArgs)) {
1817
+ args = rawArgs as Record<string, unknown>;
1818
+ }
1819
+ // sanitize the function name via our validation
1820
+ const cleanName = sanitizeToolName(tc.function.name);
1821
+ parts.push({ functionCall: { name: cleanName, args } });
1822
+ }
1823
+ }
1824
+
1825
+ if (parts.length === 0) {
1826
+ dropped++;
1827
+ continue;
1828
+ }
1829
+ const filtered = filterThinking ? (filterThinkingBlocks(parts) as GeminiPart[]) : parts;
1830
+ if (filtered.length === 0) {
1831
+ dropped++;
1832
+ continue;
1833
+ }
1834
+ contents.push({ role: "model", parts: filtered });
1835
+ continue;
1836
+ }
1837
+
1838
+ // USER (default)
1839
+ {
1840
+ const parts = contentPartsToGeminiParts(msg.content as any);
1841
+ if (parts.length === 0) {
1842
+ dropped++;
1843
+ continue;
1844
+ }
1845
+ const filtered = filterThinking ? (filterThinkingBlocks(parts) as GeminiPart[]) : parts;
1846
+ if (filtered.length === 0) {
1847
+ dropped++;
1848
+ continue;
1849
+ }
1850
+ contents.push({ role: "user", parts: filtered });
1851
+ }
1852
+ }
1853
+
1854
+ // Merge consecutivo mesmo role
1855
+ let finalContents = contents;
1856
+ if (mergeConsecutive && contents.length > 1) {
1857
+ const merged: GeminiContent[] = [];
1858
+ let cur: GeminiContent | null = null;
1859
+ for (const c of contents) {
1860
+ if (cur && cur.role === c.role) {
1861
+ cur.parts.push(...c.parts);
1862
+ } else {
1863
+ if (cur) merged.push(cur);
1864
+ cur = { role: c.role, parts: [...c.parts] };
1865
+ }
1866
+ }
1867
+ if (cur) merged.push(cur);
1868
+ finalContents = merged;
1869
+ }
1870
+
1871
+ const result: NormalizedMessagesResult = {
1872
+ contents: finalContents,
1873
+ droppedCount: dropped,
1874
+ };
1875
+
1876
+ if (systemParts.length > 0) {
1877
+ result.systemInstruction = { role: "system", parts: systemParts };
1878
+ }
1879
+
1880
+ return result;
1881
+ }
1882
+
1883
+ // ---------------------------------------------------------------------------
1884
+ // Merged: message normalization (incoming shapes to Gemini messages)
1885
+ // ---------------------------------------------------------------------------
1886
+
1887
+ /**
1888
+ * Normalizes any role spelling onto the Gemini system/user/model triad.
1889
+ * Tool-ish roles map to user because Cloud Code Assist expects
1890
+ * functionResponse parts inside a user turn.
1891
+ *
1892
+ * @param rawRole - incoming role string
1893
+ */
1894
+ export function normalizeRole(rawRole: string): "system" | "user" | "model" {
1895
+ if (typeof rawRole !== "string") return "user";
1896
+ const l = rawRole.trim().toLowerCase();
1897
+ switch (l) {
1898
+ case "system":
1899
+ case "developer":
1900
+ case "system_instruction":
1901
+ case "instruction":
1902
+ case "sys":
1903
+ return "system";
1904
+ case "assistant":
1905
+ case "model":
1906
+ case "ai":
1907
+ case "bot":
1908
+ case "agent":
1909
+ case "assistant_model":
1910
+ return "model";
1911
+ case "user":
1912
+ case "human":
1913
+ case "end_user":
1914
+ return "user";
1915
+ case "tool":
1916
+ case "function":
1917
+ case "tool_result":
1918
+ case "tool_response":
1919
+ case "function_response":
1920
+ case "function_result":
1921
+ case "tool_result_content":
1922
+ return "user";
1923
+ default:
1924
+ return "user";
1925
+ }
1926
+ }
1927
+
1928
+ /**
1929
+ * Normalizes one content element (string, block array or object) into Gemini
1930
+ * parts. Skips thinking blocks, degrades images to markers and converts
1931
+ * tool_use/tool_result blocks into functionCall/functionResponse parts.
1932
+ *
1933
+ * @param content - incoming content of any shape
1934
+ */
1935
+ export function normalizeContentToParts(content: unknown): GeminiPart[] {
1936
+ const parts: GeminiPart[] = [];
1937
+
1938
+ if (content == null) return parts;
1939
+
1940
+ if (typeof content === "string") {
1941
+ if (content.trim() !== "") parts.push({ text: content });
1942
+ return parts;
1943
+ }
1944
+
1945
+ if (Array.isArray(content)) {
1946
+ for (const blk of content) {
1947
+ if (Array.isArray(blk)) {
1948
+ parts.push(...normalizeContentToParts(blk));
1949
+ continue;
1950
+ }
1951
+ if (typeof blk === "string") {
1952
+ if (blk.trim() !== "") parts.push({ text: blk });
1953
+ continue;
1954
+ }
1955
+ if (!isPlainObject(blk)) continue;
1956
+ const b = blk as Record<string, unknown>;
1957
+
1958
+ if (isThinkingBlock(b)) continue;
1959
+
1960
+ if (b.type === "text" && typeof b.text === "string") {
1961
+ parts.push({ text: b.text as string });
1962
+ continue;
1963
+ }
1964
+ if (typeof b.text === "string" && (b.type === undefined || (b as any).type === "text")) {
1965
+ if (!(b as any).thought) parts.push({ text: b.text as string });
1966
+ continue;
1967
+ }
1968
+ if (b.type === "image_url" || b.type === "image") {
1969
+ parts.push({ text: "[image]" });
1970
+ continue;
1971
+ }
1972
+ if (b.type === "tool_use") {
1973
+ const nameRaw = (b as any).name ?? (b as any).tool_name ?? "unknown_tool";
1974
+ try {
1975
+ const name = sanitizeToolName(String(nameRaw));
1976
+ const input = (b as any).input ?? (b as any).arguments ?? {};
1977
+ parts.push({
1978
+ functionCall: {
1979
+ name,
1980
+ args: isPlainObject(input) ? (input as Record<string, unknown>) : {},
1981
+ },
1982
+ });
1983
+ } catch {
1984
+ // Skip invalid tool_use blocks
1985
+ }
1986
+ continue;
1987
+ }
1988
+ if (b.type === "tool_result" || b.type === "tool_response" || b.type === "function_response") {
1989
+ const nameRaw = (b as any).name ?? (b as any).tool_name ?? (b as any).tool_use_id ?? "tool";
1990
+ try {
1991
+ const name = sanitizeToolName(String(nameRaw));
1992
+ let respContent = (b as any).content;
1993
+ if (Array.isArray(respContent)) {
1994
+ respContent = respContent
1995
+ .map((c: any) => (typeof c === "string" ? c : (c.text ?? JSON.stringify(c))))
1996
+ .join("\n");
1997
+ } else if (typeof respContent !== "string") {
1998
+ respContent = JSON.stringify(respContent ?? {});
1999
+ }
2000
+ parts.push({
2001
+ functionResponse: {
2002
+ name,
2003
+ response: { result: String(respContent).slice(0, 20000) },
2004
+ },
2005
+ });
2006
+ } catch {
2007
+ // Skip invalid tool_result blocks
2008
+ }
2009
+ continue;
2010
+ }
2011
+ if ((b as any).functionCall) {
2012
+ parts.push(b as unknown as GeminiPart);
2013
+ continue;
2014
+ }
2015
+ if ((b as any).functionResponse) {
2016
+ parts.push(b as unknown as GeminiPart);
2017
+ continue;
2018
+ }
2019
+ if (typeof b.text === "string") {
2020
+ parts.push({ text: b.text });
2021
+ }
2022
+ }
2023
+ return parts;
2024
+ }
2025
+
2026
+ if (isPlainObject(content)) {
2027
+ const c = content as Record<string, unknown>;
2028
+ if (typeof c.text === "string" && c.text.trim() !== "") {
2029
+ parts.push({ text: c.text });
2030
+ }
2031
+ return parts;
2032
+ }
2033
+
2034
+ return parts;
2035
+ }
2036
+
2037
+ /**
2038
+ * Gemini message with normalized role plus raw-role provenance.
2039
+ */
2040
+ export interface GeminiMessage {
2041
+ role: "system" | "user" | "model";
2042
+ parts: GeminiPart[];
2043
+ _rawRole?: string;
2044
+ }
2045
+
2046
+ /**
2047
+ * Generic incoming OpenCode / OpenAI / Anthropic message shape.
2048
+ */
2049
+ export interface IncomingMessage {
2050
+ role: string;
2051
+ content?: string | unknown[] | Record<string, unknown> | null;
2052
+ name?: string;
2053
+ tool_calls?: Array<{
2054
+ id?: string;
2055
+ type?: string;
2056
+ function?: { name: string; arguments?: string | Record<string, unknown> };
2057
+ }>;
2058
+ tool_call_id?: string;
2059
+ tool_use_id?: string;
2060
+ [k: string]: unknown;
2061
+ }
2062
+
2063
+ /**
2064
+ * Normalizes incoming OpenCode / OpenAI / Anthropic messages into Gemini
2065
+ * messages: role mapping via normalizeRole, content via normalizeContentToParts,
2066
+ * OpenAI tool_calls converted to functionCall parts, thinking blocks filtered,
2067
+ * consecutive same-role messages merged (except system).
2068
+ *
2069
+ * Named `normalizeIncomingMessages` because the historical `normalizeMessages`
2070
+ * keeps its original signature in this module.
2071
+ *
2072
+ * @param messages - array of incoming messages of any shape
2073
+ * @throws {RequestHelpersError} when input is not an array
2074
+ */
2075
+ export function normalizeIncomingMessages(messages: unknown): GeminiMessage[] {
2076
+ if (!Array.isArray(messages)) {
2077
+ throw new RequestHelpersError(`normalizeMessages expects array, got ${typeof messages}`, "INVALID_MESSAGES");
2078
+ }
2079
+
2080
+ const out: GeminiMessage[] = [];
2081
+
2082
+ for (const raw of messages) {
2083
+ if (!isPlainObject(raw)) continue;
2084
+ const msg = raw as IncomingMessage;
2085
+ const roleRaw = typeof msg.role === "string" ? msg.role : "user";
2086
+ const normRole = normalizeRole(roleRaw);
2087
+
2088
+ let parts = normalizeContentToParts(msg.content as unknown);
2089
+
2090
+ if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
2091
+ for (const tc of msg.tool_calls) {
2092
+ try {
2093
+ const fn = tc.function ?? (tc as any);
2094
+ const nameRaw = (fn as any).name ?? tc.id ?? "tool";
2095
+ const name = sanitizeToolName(String(nameRaw));
2096
+ let args: Record<string, unknown> = {};
2097
+ const argsRaw = (fn as any).arguments;
2098
+ if (typeof argsRaw === "string") {
2099
+ try {
2100
+ const parsed = JSON.parse(argsRaw);
2101
+ if (isPlainObject(parsed)) args = parsed as Record<string, unknown>;
2102
+ } catch {
2103
+ // Leave empty args when not JSON
2104
+ }
2105
+ } else if (isPlainObject(argsRaw)) {
2106
+ args = argsRaw as Record<string, unknown>;
2107
+ }
2108
+ parts.push({ functionCall: { name, args } });
2109
+ } catch (parseerror) {
2110
+ // a malformed streamed tool fragment drops that fragment and keeps
2111
+ // the stream alive — the skip is the surfacing
2112
+ void parseerror;
2113
+ }
2114
+ }
2115
+ }
2116
+
2117
+ if (parts.length === 0) {
2118
+ if (normRole === "system") continue;
2119
+ continue;
2120
+ }
2121
+
2122
+ parts = parts.filter((p) => !isThinkingBlock(p));
2123
+
2124
+ if (parts.length === 0) continue;
2125
+
2126
+ out.push({
2127
+ role: normRole,
2128
+ parts,
2129
+ _rawRole: roleRaw,
2130
+ });
2131
+ }
2132
+
2133
+ const merged: GeminiMessage[] = [];
2134
+ for (const cur of out) {
2135
+ const last = merged[merged.length - 1];
2136
+ if (last && last.role === cur.role && cur.role !== "system") {
2137
+ last.parts.push(...cur.parts);
2138
+ } else {
2139
+ merged.push(cur);
2140
+ }
2141
+ }
2142
+
2143
+ return merged;
2144
+ }
2145
+
2146
+ // ============================================================================
2147
+ // 09. enhanceAgySdkErrorResponse()
2148
+ // ============================================================================
2149
+
2150
+ export interface EnhancedAgyError {
2151
+ message: string;
2152
+ code?: string | undefined;
2153
+ status?: number | undefined;
2154
+ statusText?: string | undefined;
2155
+ details?: unknown | undefined;
2156
+ isRetryable: boolean;
2157
+ isAuthError: boolean;
2158
+ isQuotaError: boolean;
2159
+ isModelError: boolean;
2160
+ isThinkingBudgetError: boolean;
2161
+ original: unknown;
2162
+ suggestion?: string | undefined;
2163
+ requestId?: string | undefined;
2164
+ modelId?: string | undefined;
2165
+ }
2166
+
2167
+ function extractJsonFromText(text: string): Record<string, unknown> | null {
2168
+ try {
2169
+ return JSON.parse(text) as Record<string, unknown>;
2170
+ } catch {
2171
+ // tenta extrair json embebido — the old greedy regex
2172
+ // (/\{[\s\S]*\}/) answered polynomial time on adversarial "{{{{"
2173
+ // runs (the code-scanning redos law); the indexOf/lastIndexOf pair
2174
+ // walks the string once, linear always.
2175
+ const start = text.indexOf("{");
2176
+ const end = text.lastIndexOf("}");
2177
+ if (start !== -1 && end > start) {
2178
+ try {
2179
+ return JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>;
2180
+ } catch {
2181
+ return null;
2182
+ }
2183
+ }
2184
+ return null;
2185
+ }
2186
+ }
2187
+
2188
+ /**
2189
+ * Enriches an error coming from the Agentic SDK / Cloud Code Assist.
2190
+ * Detects auth, quota, model not found, thinking budget etc and suggests a fix.
2191
+ *
2192
+ * @param error - the raw error (Error, Response, object, string)
2193
+ * @param context - optional context with modelId, requestId etc
2194
+ */
2195
+ export function enhanceAgySdkErrorResponse(
2196
+ error: unknown,
2197
+ context?: { modelId?: string; requestId?: string },
2198
+ ): EnhancedAgyError {
2199
+ let message = "Unknown Agentic SDK error";
2200
+ let code: string | undefined;
2201
+ let status: number | undefined;
2202
+ let statusText: string | undefined;
2203
+ let details: unknown;
2204
+ let requestId = context?.requestId;
2205
+ let modelId = context?.modelId;
2206
+
2207
+ // normalize the input
2208
+ if (typeof error === "string") {
2209
+ message = error;
2210
+ const parsed = extractJsonFromText(error);
2211
+ if (parsed) {
2212
+ details = parsed;
2213
+ if (typeof (parsed as any).message === "string") message = (parsed as any).message;
2214
+ if (typeof (parsed as any).error === "string") message = (parsed as any).error;
2215
+ if (typeof (parsed as any).code === "string") code = (parsed as any).code;
2216
+ if ((parsed as any).error?.message) message = (parsed as any).error.message;
2217
+ if ((parsed as any).error?.code) code = (parsed as any).error.code;
2218
+ if ((parsed as any).error?.status) code = (parsed as any).error.status;
2219
+ }
2220
+ } else if (error instanceof Error) {
2221
+ message = error.message || message;
2222
+ details = { name: error.name, stack: error.stack?.split("\n").slice(0, 5).join("\n") };
2223
+ // tries to extract the status from common properties
2224
+ const anyErr = error as any;
2225
+ if (typeof anyErr.status === "number") status = anyErr.status;
2226
+ if (typeof anyErr.statusCode === "number") status = anyErr.statusCode;
2227
+ if (typeof anyErr.code === "string") code = anyErr.code;
2228
+ if (typeof anyErr.requestId === "string") requestId = anyErr.requestId;
2229
+ } else if (isPlainObject(error)) {
2230
+ const obj = error as Record<string, unknown>;
2231
+ if (typeof obj["message"] === "string") message = obj["message"] as string;
2232
+ else if (typeof (obj["error"] as any)?.message === "string") message = (obj["error"] as any).message;
2233
+ else if (typeof obj["error"] === "string") message = obj["error"] as string;
2234
+
2235
+ if (typeof obj["code"] === "string") code = obj["code"] as string;
2236
+ else if (typeof (obj["error"] as any)?.code === "string") code = (obj["error"] as any).code;
2237
+ else if (typeof obj["status"] === "string") code = obj["status"] as string;
2238
+
2239
+ if (typeof obj["status"] === "number") status = obj["status"] as number;
2240
+ if (typeof obj["statusCode"] === "number") status = obj["statusCode"] as number;
2241
+ if (typeof obj["httpStatus"] === "number") status = obj["httpStatus"] as number;
2242
+
2243
+ if (typeof obj["statusText"] === "string") statusText = obj["statusText"] as string;
2244
+ if (typeof obj["requestId"] === "string") requestId = obj["requestId"] as string;
2245
+ if (typeof obj["modelId"] === "string") modelId = obj["modelId"] as string;
2246
+
2247
+ details = obj;
2248
+ } else {
2249
+ details = error;
2250
+ try {
2251
+ message = String(error);
2252
+ } catch {
2253
+ // keep default
2254
+ }
2255
+ }
2256
+
2257
+ const lowerMsg = message.toLowerCase();
2258
+ const lowerCode = (code ?? "").toLowerCase();
2259
+
2260
+ const isAuthError =
2261
+ status === 401 ||
2262
+ status === 403 ||
2263
+ lowerCode.includes("unauthenticated") ||
2264
+ lowerCode.includes("permission_denied") ||
2265
+ lowerCode.includes("forbidden") ||
2266
+ lowerMsg.includes("unauthenticated") ||
2267
+ lowerMsg.includes("permission denied") ||
2268
+ lowerMsg.includes("unauthorized") ||
2269
+ lowerMsg.includes("invalid authentication") ||
2270
+ lowerMsg.includes("requires authentication") ||
2271
+ (lowerMsg.includes("auth") && lowerMsg.includes("failed"));
2272
+
2273
+ const isQuotaError =
2274
+ status === 429 ||
2275
+ lowerCode.includes("resource_exhausted") ||
2276
+ lowerCode.includes("quota") ||
2277
+ lowerCode.includes("rate_limit") ||
2278
+ lowerMsg.includes("quota") ||
2279
+ lowerMsg.includes("rate limit") ||
2280
+ lowerMsg.includes("too many requests") ||
2281
+ lowerMsg.includes("resource exhausted") ||
2282
+ lowerMsg.includes("exceeded");
2283
+
2284
+ const isModelError =
2285
+ status === 404 ||
2286
+ lowerCode.includes("model_not_found") ||
2287
+ (lowerCode.includes("not_found") && lowerMsg.includes("model")) ||
2288
+ lowerMsg.includes("model not found") ||
2289
+ lowerMsg.includes("unknown model") ||
2290
+ lowerMsg.includes("model not supported") ||
2291
+ lowerMsg.includes("invalid model");
2292
+
2293
+ const isThinkingBudgetError =
2294
+ (lowerMsg.includes("thinking") && (lowerMsg.includes("budget") || lowerMsg.includes("token"))) ||
2295
+ lowerCode.includes("thinking_budget") ||
2296
+ lowerMsg.includes("thinkingBudget");
2297
+
2298
+ const isRetryable =
2299
+ status === 408 ||
2300
+ status === 429 ||
2301
+ status === 500 ||
2302
+ status === 502 ||
2303
+ status === 503 ||
2304
+ status === 504 ||
2305
+ lowerCode.includes("unavailable") ||
2306
+ lowerCode.includes("deadline_exceeded") ||
2307
+ lowerCode.includes("aborted") ||
2308
+ lowerCode.includes("internal") ||
2309
+ (isQuotaError && status === 429); // quota 429 retryable with backoff
2310
+
2311
+ let suggestion: string | undefined;
2312
+ if (isAuthError) {
2313
+ suggestion = `Reauthenticate with OAuth: client ${GEMINI_CLI_OAUTH_CLIENT_ID}. Check whether the token expired, scopes ${"https://www.googleapis.com/auth/cloud-platform"}. Try clearing the cache at ~/.config/opencode/auth/ and running the auth flow again. Headers: User-Agent=${GEMINI_CLI_USER_AGENT}, X-Goog-Api-Client=${X_GOOG_API_CLIENT}`;
2314
+ } else if (isQuotaError) {
2315
+ suggestion = `Quota exceeded. Check retrieveUserQuotaSummary at ${CLOUDCODE_PA_BASE}/v1internal:retrieveUserQuotaSummary. Switch to the fallback model ${PROJECT_FALLBACK} or wait for the reset. Consider an alternative model: ${FETCH_AVAILABLE_MODELS_2026.filter((m) => !m.includes("claude")).join(", ")}`;
2316
+ } else if (isModelError) {
2317
+ suggestion = `Model ${modelId ?? "unknown"} not found. Valid 2026 models: ${FETCH_AVAILABLE_MODELS_2026.join(", ")}. Check whether it needs the antigravity- prefix and the ${CLOUDCODE_PA_BASE}/v1internal endpoint. For Claude use isLikelyAntigravityOnlyModel() to decide the routing.`;
2318
+ } else if (isThinkingBudgetError) {
2319
+ suggestion = `Invalid thinking budget. Use buildGenerationConfig() with thinkingLevel minimal/low/medium/high. For Gemini: minimal=0, low=1024, medium=4096, high=8192. For Claude: minimal=1024, low=2048, medium=8192, high=16384. Check that the model supports thinking (claude-opus thinking or gemini-3 with thinkingConfig).`;
2320
+ } else if (isRetryable) {
2321
+ suggestion = `Transient error (${status ?? code}). Try again with exponential backoff (500ms base, jitter). Endpoint: ${CLOUDCODE_PA_BASE}/v1internal. Project fallback: ${PROJECT_FALLBACK}.`;
2322
+ }
2323
+
2324
+ // anonymizes the secret in the message
2325
+ const safeMessage = message.replace(/GOCSPX-[A-Za-z0-9\-_]+/g, "[REDACTED_SECRET]");
2326
+
2327
+ return {
2328
+ message: safeMessage,
2329
+ code,
2330
+ status,
2331
+ statusText,
2332
+ details,
2333
+ isRetryable,
2334
+ isAuthError,
2335
+ isQuotaError,
2336
+ isModelError,
2337
+ isThinkingBudgetError,
2338
+ original: error,
2339
+ suggestion,
2340
+ requestId,
2341
+ modelId,
2342
+ };
2343
+ }
2344
+
2345
+ // ---------------------------------------------------------------------------
2346
+ // Core: transformRequest — OpenCode → Gemini normalized
2347
+ // ---------------------------------------------------------------------------
2348
+
2349
+ /**
2350
+ * Transforms OpenCode request (OpenAI/Anthropic mixed) into Gemini/Antigravity
2351
+ * normalized request ready for cloudcode-pa envelope.
2352
+ *
2353
+ * Steps performed by third-person observer:
2354
+ * - Validates model, messages
2355
+ * - Parses model variant to extract thinkingLevel/budget
2356
+ * - Aggregates system instructions
2357
+ * - Normalizes contents: user ↔ model, maps thinking blocks first,
2358
+ * handles tool_use/tool_result pairing, handles OpenAI tool_calls
2359
+ * - Cleans tool schemas, sanitizes tool names, ensures uniqueness
2360
+ * - Injects google_search tool when enabled
2361
+ * - Generates deterministic session_id and user_prompt_id via FNV-1a
2362
+ * - Builds generationConfig with thinking budget if applicable
2363
+ * - Supports 2026 models aliasing
2364
+ *
2365
+ * @param input - transform input
2366
+ * @returns transformed request
2367
+ * @throws RequestTransformationError on invalid input
2368
+ */
2369
+
2370
+ // ---------------------------------------------------------------------------
2371
+ // Build envelope for cloudcode-pa — endpoint cascade, headers, body
2372
+ // ---------------------------------------------------------------------------
2373
+
2374
+ // ---------------------------------------------------------------------------
2375
+ // Convenience: fetch with cascade (optional helper for plugin.ts integration)
2376
+ // ---------------------------------------------------------------------------
2377
+
2378
+ // ---------------------------------------------------------------------------
2379
+ // Merged: Gemini CLI bypass constants (canonical values from legacy module)
2380
+ // ---------------------------------------------------------------------------
2381
+
2382
+ // v2.1.15 Phase C: the local GEMINI_CLI_OAUTH_CLIENT_ID/SECRET assembled
2383
+ // pair was byte-identical to constants.js — deleted; constants.js is the
2384
+ // single owner of the gemini-cli client pair.
2385
+
2386
+ // v2.1.16: two orphaned bare literals (the full GeminiCLI UA string and the
2387
+ // IDE_UNSPECIFIED Client-Metadata string) were deleted — unbound top-level
2388
+ // expression statements left behind by the v2.1.15 merge, dead residue with
2389
+ // no runtime effect. The live values are the constants.js imports above
2390
+ // (GEMINI_CLI_USER_AGENT / CLIENT_METADATA_STRING).
2391
+
2392
+ /** X-Goog-Api-Client value observed on real Gemini CLI traffic. */
2393
+
2394
+ /** Client metadata object injected by Gemini CLI for bypass requests. */
2395
+
2396
+ /** Fallback project id when no cloud project is resolved. */
2397
+
2398
+ /** Production Cloud Code Assist base endpoint. */
2399
+
2400
+ /** Daily (canary) Cloud Code Assist base endpoint — default for pure Gemini CLI bypass. */
2401
+
2402
+ /** v1internal method suffixes observed on Cloud Code Assist traffic.
2403
+ * v2.1.15 Phase A: renamed from the historical local name
2404
+ * CODE_ASSIST_ENDPOINTS (a method-path map) so the host-map semantic of that
2405
+ * name resolves to the models.ts owner; re-exported below for surface
2406
+ * continuity. */
2407
+ export const CODE_ASSIST_METHOD_PATHS = {
2408
+ GENERATE: "/v1internal:generateContent",
2409
+ STREAM_GENERATE: "/v1internal:streamGenerateContent",
2410
+ LOAD: "/v1internal:loadCodeAssist",
2411
+ } as const;
2412
+
2413
+ /** Cloud Code Assist HOST map (canonical owner: models.ts).
2414
+ * @deprecated legacy alias kept for the historical request-module surface. */
2415
+ export { CODE_ASSIST_ENDPOINTS } from "./models.js";
2416
+
2417
+ /**
2418
+ * Union of model ids listed in {@link FETCH_AVAILABLE_MODELS_2026}.
2419
+ */
2420
+ // v2.1.15 Phase A: the local Model2026 alias was deleted — models.ts owns
2421
+ // the Model2026 family name.
2422
+
2423
+ // ---------------------------------------------------------------------------
2424
+ // Merged: FNV-1a 64-bit — longer deterministic digests
2425
+ // ---------------------------------------------------------------------------
2426
+
2427
+ const FNV64_OFFSET_BASIS = BigInt("14695981039346656037");
2428
+ const FNV64_PRIME = BigInt("1099511628211");
2429
+ const FNV64_MASK = BigInt("0xffffffffffffffff");
2430
+
2431
+ /**
2432
+ * Computes FNV-1a 64-bit hash over UTF-8 bytes.
2433
+ * Complements the 32-bit variant when longer deterministic digests are needed.
2434
+ *
2435
+ * @param input - string to hash
2436
+ * @returns unsigned 64-bit integer as bigint
2437
+ */
2438
+ export function fnv1a64BigInt(input: string): bigint {
2439
+ let hash: bigint = FNV64_OFFSET_BASIS;
2440
+ const buf = Buffer.from(input, "utf8");
2441
+ for (let i = 0; i < buf.length; i++) {
2442
+ hash ^= BigInt(buf[i]!);
2443
+ hash = (hash * FNV64_PRIME) & FNV64_MASK;
2444
+ }
2445
+ return hash;
2446
+ }
2447
+
2448
+ function toHex8(n: number): string {
2449
+ return (n >>> 0).toString(16).padStart(8, "0");
2450
+ }
2451
+
2452
+ function toHex16(n: bigint): string {
2453
+ return n.toString(16).padStart(16, "0");
2454
+ }
2455
+
2456
+ // ---------------------------------------------------------------------------
2457
+ // Merged: deterministic session / prompt ids keyed by directory
2458
+ // ---------------------------------------------------------------------------
2459
+
2460
+ /**
2461
+ * Options controlling {@link buildUserPromptId} output shape.
2462
+ */
2463
+ export interface BuildPromptIdOptions {
2464
+ /** Custom prefix, defaults to "agyp_". */
2465
+ prefix?: string;
2466
+ /** Extra seed for disambiguation, e.g. projectId or sessionId. */
2467
+ seed?: string;
2468
+ /** Include timestamp? Breaks determinism, discouraged but supported. */
2469
+ includeTimestamp?: boolean;
2470
+ /** Hash length: "short" yields 8 hex chars, "long" adds a 12-char 64-bit suffix. */
2471
+ length?: "short" | "long";
2472
+ }
2473
+
2474
+ /**
2475
+ * Generates a deterministic session_id via FNV-1a over the working directory.
2476
+ * Same directory implies same session_id, which keeps fingerprints stable.
2477
+ *
2478
+ * @param directory - optional directory; falls back to cwd, then home dir
2479
+ * @returns session id like `sess_1a2b3c4d5e6f7a8b9c0d`
2480
+ */
2481
+ export function buildSessionIdFromDir(directory?: string | null): string {
2482
+ let dir = directory?.trim();
2483
+ if (!dir) {
2484
+ try {
2485
+ dir = process.cwd();
2486
+ } catch {
2487
+ dir = os.homedir() || FALLBACK_PROJECT_ID;
2488
+ }
2489
+ }
2490
+ // Normalize separators so Windows and POSIX paths hash identically
2491
+ dir = stripTrailingSlashes(dir.replace(/\\/g, "/"));
2492
+ const h = fnv1a32(dir);
2493
+ return `sess_${toHex8(h)}${toHex8(fnv1a32(dir.split("").reverse().join("")))}`.slice(0, 20);
2494
+ }
2495
+
2496
+ /**
2497
+ * Generates a deterministic user_prompt_id via FNV-1a.
2498
+ * Used by the Cloud Code backend for deduplication.
2499
+ *
2500
+ * @param input - prompt content; objects are JSON.stringify-ed; omitted input
2501
+ * falls back to hostname|cwd|pid for per-workspace stability
2502
+ * @param opts - options object or bare seed string
2503
+ * @returns id like `agyp_1a2b3c4d` or `agyp_1a2b3c4d-9f8e7d6c5b4a`
2504
+ */
2505
+ export function buildUserPromptId(
2506
+ input?: string | object | null | undefined,
2507
+ opts?: BuildPromptIdOptions | string,
2508
+ ): string {
2509
+ let options: BuildPromptIdOptions = {};
2510
+ if (typeof opts === "string") options = { seed: opts };
2511
+ else if (opts && typeof opts === "object") options = opts;
2512
+
2513
+ const prefix = options.prefix ?? "agyp_";
2514
+ const length = options.length ?? "short";
2515
+
2516
+ let baseStr: string;
2517
+ if (input == null) {
2518
+ try {
2519
+ baseStr = `${os.hostname()}|${process.cwd()}|${process.pid}`;
2520
+ } catch {
2521
+ baseStr = `fallback|${FALLBACK_PROJECT_ID}`;
2522
+ }
2523
+ } else if (typeof input === "string") baseStr = input;
2524
+ else {
2525
+ try {
2526
+ baseStr = JSON.stringify(input);
2527
+ } catch {
2528
+ baseStr = String(input);
2529
+ }
2530
+ }
2531
+
2532
+ if (options.seed) baseStr = `${baseStr}|${options.seed}`;
2533
+ if (options.includeTimestamp) baseStr = `${baseStr}|${Date.now()}`;
2534
+
2535
+ const h32 = fnv1a32(baseStr);
2536
+ if (length === "short") return `${prefix}${toHex8(h32)}`;
2537
+
2538
+ const h64 = fnv1a64BigInt(baseStr);
2539
+ return `${prefix}${toHex8(h32)}-${toHex16(h64).slice(0, 12)}`;
2540
+ }
2541
+
2542
+ /**
2543
+ * Compatibility alias for fingerprint.ts: derives the session id from a directory.
2544
+ * Kept alongside {@link generateSessionId}, which hashes an arbitrary seed instead.
2545
+ *
2546
+ * @param directory - optional directory path
2547
+ * @returns deterministic session id
2548
+ */
2549
+ export function buildSessionId(directory?: string): string {
2550
+ return buildSessionIdFromDir(directory);
2551
+ }
2552
+
2553
+ // ---------------------------------------------------------------------------
2554
+ // Merged: thinking level normalization and budget mapping
2555
+ // ---------------------------------------------------------------------------
2556
+
2557
+ // v2.1.14 merge: request.ts's duplicate short THINKING_LEVEL_TO_BUDGET_* maps
2558
+ // (see constants above) and its normalizeThinkingLevel-based
2559
+ // mapThinkingLevelToBudget were dropped in favor of the richer
2560
+ // request-helpers.ts versions merged below.
2561
+
2562
+ /**
2563
+ * Normalizes free-form thinking input (string, options object, or message field)
2564
+ * into a canonical level. Unknown values collapse to "medium"; explicit
2565
+ * none/off inputs yield "none".
2566
+ *
2567
+ * @param input - raw thinking spec
2568
+ * @returns canonical level including "none"
2569
+ */
2570
+ export function normalizeThinkingLevel(input: unknown): ThinkingLevel | "none" {
2571
+ if (!input) return "medium";
2572
+ let level: string;
2573
+
2574
+ if (typeof input === "string") level = input;
2575
+ else if (typeof input === "object" && input !== null) {
2576
+ const obj = input as any;
2577
+ if (typeof obj.level === "string") level = obj.level;
2578
+ else if (typeof obj.thinkingLevel === "string") level = obj.thinkingLevel;
2579
+ else if (typeof obj.thinking_level === "string") level = obj.thinking_level;
2580
+ else level = "medium";
2581
+ } else level = "medium";
2582
+
2583
+ level = level.toLowerCase().trim();
2584
+ if (level === "low" || level === "minimal" || level === "none") return level;
2585
+ if (level === "high" || level === "medium") return level;
2586
+ // Map common variations onto canonical levels
2587
+ if (["1", "small", "light"].includes(level)) return "low";
2588
+ if (["2", "default", "balanced"].includes(level)) return "medium";
2589
+ if (["3", "large", "deep", "max"].includes(level)) return "high";
2590
+ return "medium";
2591
+ }
2592
+
2593
+ // ---------------------------------------------------------------------------
2594
+ // Merged: thinking level parsing and budget mapping
2595
+ // ---------------------------------------------------------------------------
2596
+
2597
+ /**
2598
+ * Parses free-form input into a canonical thinking level. Also maps
2599
+ * none/off onto minimal for compatibility.
2600
+ *
2601
+ * @param input - level-like value
2602
+ * @returns canonical level or undefined when not parseable
2603
+ */
2604
+ export function parseThinkingLevel(input: unknown): ThinkingLevel | undefined {
2605
+ if (typeof input !== "string") return undefined;
2606
+ const l = input.trim().toLowerCase();
2607
+ if ((THINKING_LEVELS as readonly string[]).includes(l)) {
2608
+ return l as ThinkingLevel;
2609
+ }
2610
+ if (l === "none" || l === "off") return "minimal";
2611
+ return undefined;
2612
+ }
2613
+
2614
+ /**
2615
+ * Maps a thinking level (or numeric budget) onto its token budget, clamping
2616
+ * numbers into [THINKING_BUDGET_MIN, THINKING_BUDGET_MAX]. Unknown named levels
2617
+ * safely fall back to medium.
2618
+ *
2619
+ * @param level - level name, numeric budget or numeric string
2620
+ * @throws {RequestHelpersError} on non-finite numbers or empty strings
2621
+ */
2622
+ export function mapThinkingLevelToBudget(level: string | number): number {
2623
+ if (typeof level === "number") {
2624
+ if (!Number.isFinite(level)) {
2625
+ throw new RequestHelpersError(`thinking budget number not finite: ${level}`, "INVALID_THINKING_BUDGET");
2626
+ }
2627
+ return Math.max(THINKING_BUDGET_MIN, Math.min(THINKING_BUDGET_MAX, Math.floor(level)));
2628
+ }
2629
+
2630
+ if (typeof level !== "string") {
2631
+ throw new RequestHelpersError(
2632
+ `thinking level must be string|number, got ${typeof level}`,
2633
+ "INVALID_THINKING_LEVEL",
2634
+ );
2635
+ }
2636
+
2637
+ const trimmed = level.trim().toLowerCase();
2638
+
2639
+ // Numeric strings act as direct budgets
2640
+ const asNum = Number(trimmed);
2641
+ if (trimmed !== "" && Number.isFinite(asNum) && /^\d+$/.test(trimmed)) {
2642
+ return Math.max(THINKING_BUDGET_MIN, Math.min(THINKING_BUDGET_MAX, Math.floor(asNum)));
2643
+ }
2644
+
2645
+ const parsed = parseThinkingLevel(trimmed);
2646
+ if (parsed === "minimal" || parsed === "low" || parsed === "medium" || parsed === "high" || parsed === "max") {
2647
+ return THINKING_BUDGET_MAP[parsed];
2648
+ }
2649
+
2650
+ if (trimmed === "") {
2651
+ throw new RequestHelpersError("empty thinking level", "INVALID_THINKING_LEVEL");
2652
+ }
2653
+
2654
+ return THINKING_BUDGET_MAP.medium;
2655
+ }
2656
+
2657
+ /**
2658
+ * Spec-required alias of {@link mapThinkingLevelToBudget}.
2659
+ */
2660
+ export const thinkingLevelToBudget = mapThinkingLevelToBudget;
2661
+
2662
+ /**
2663
+ * Budget hint for a parsed variant suffix.
2664
+ *
2665
+ * @param variant - "low", "max" or null
2666
+ */
2667
+ export function getThinkingBudgetForVariant(variant: "low" | "max" | null): number {
2668
+ if (variant === "low") return THINKING_BUDGET_MAP.low;
2669
+ if (variant === "max") return THINKING_BUDGET_MAP.max;
2670
+ return THINKING_BUDGET_MAP.medium;
2671
+ }
2672
+
2673
+ // ---------------------------------------------------------------------------
2674
+ // Merged: OpenCode request shapes and Cloud Code Assist payload types
2675
+ // ---------------------------------------------------------------------------
2676
+
2677
+ /**
2678
+ * Anthropic/OpenAI-style content block tolerated inside OpenCode messages.
2679
+ */
2680
+ export interface OpenCodeContentBlock {
2681
+ type?: string;
2682
+ text?: string;
2683
+ thinking?: string;
2684
+ level?: ThinkingLevel | string;
2685
+ thought?: string;
2686
+ id?: string;
2687
+ name?: string;
2688
+ input?: unknown;
2689
+ args?: unknown;
2690
+ tool_use_id?: string;
2691
+ tool_call_id?: string;
2692
+ content?: unknown;
2693
+ source?: { type?: string; media_type?: string; data?: string };
2694
+ [key: string]: unknown;
2695
+ }
2696
+
2697
+ /**
2698
+ * Full OpenCode request envelope with thinking/search/session passthrough fields.
2699
+ */
2700
+ export interface OpenCodeRequest {
2701
+ model?: string;
2702
+ messages?: OpenCodeMessage[];
2703
+ tools?: OpenCodeTool[];
2704
+ tool_choice?: unknown;
2705
+ thinking?:
2706
+ | ThinkingLevel
2707
+ | string
2708
+ | { level?: ThinkingLevel | string; budget?: number; type?: string; budget_tokens?: number };
2709
+ thinkingLevel?: ThinkingLevel | string;
2710
+ thinking_level?: ThinkingLevel | string;
2711
+ directory?: string;
2712
+ cwd?: string;
2713
+ project?: string;
2714
+ projectId?: string;
2715
+ temperature?: number;
2716
+ top_p?: number;
2717
+ topP?: number;
2718
+ top_k?: number;
2719
+ topK?: number;
2720
+ max_tokens?: number;
2721
+ maxTokens?: number;
2722
+ google_search?: boolean;
2723
+ enable_google_search?: boolean;
2724
+ enableGoogleSearch?: boolean;
2725
+ session_id?: string;
2726
+ user_prompt_id?: string;
2727
+ [key: string]: unknown;
2728
+ }
2729
+
2730
+ /**
2731
+ * System instruction shape accepted by Cloud Code Assist.
2732
+ */
2733
+ export interface GeminiSystemInstruction {
2734
+ role?: "system";
2735
+ parts: GeminiPart[];
2736
+ }
2737
+
2738
+ /**
2739
+ * Function declaration shape used in Gemini tools payloads.
2740
+ */
2741
+ export interface GeminiFunctionDeclaration {
2742
+ name: string;
2743
+ description?: string;
2744
+ parameters?: any;
2745
+ }
2746
+
2747
+ /**
2748
+ * Generation config including thinkingConfig with level/budget mapping.
2749
+ * v2.1.14 merge: unions the request.ts declaration (top-level thinking
2750
+ * compat fields) with the request-helpers.ts declaration (penalties,
2751
+ * responseMimeType and the index signature).
2752
+ */
2753
+ export interface GeminiGenerationConfig {
2754
+ temperature?: number;
2755
+ topP?: number;
2756
+ topK?: number;
2757
+ maxOutputTokens?: number;
2758
+ stopSequences?: string[];
2759
+ candidateCount?: number;
2760
+ presencePenalty?: number;
2761
+ frequencyPenalty?: number;
2762
+ thinkingConfig?: {
2763
+ thinkingLevel?: string;
2764
+ thinkingBudget?: number;
2765
+ includeThoughts?: boolean;
2766
+ };
2767
+ responseMimeType?: string;
2768
+ // Compat: some backend versions expect these at the top level too
2769
+ thinkingLevel?: string;
2770
+ thinkingBudget?: number;
2771
+ [k: string]: unknown;
2772
+ }
2773
+
2774
+ /**
2775
+ * Inner `request` field of the Cloud Code Assist v1internal envelope.
2776
+ */
2777
+ export interface CloudCodeAssistInnerRequest {
2778
+ contents: GeminiContent[];
2779
+ systemInstruction?: GeminiSystemInstruction;
2780
+ tools?: any[];
2781
+ toolConfig?: { functionCallingConfig?: { mode: string; allowedFunctionNames?: string[] } };
2782
+ generationConfig?: GeminiGenerationConfig;
2783
+ }
2784
+
2785
+ /**
2786
+ * Options bag accepted by the merged transform helpers.
2787
+ */
2788
+ export interface TransformOptions {
2789
+ projectId?: string;
2790
+ directory?: string;
2791
+ enableGoogleSearch?: boolean;
2792
+ thinkingLevel?: ThinkingLevel | string;
2793
+ model?: string;
2794
+ sessionId?: string;
2795
+ userPromptId?: string;
2796
+ temperature?: number;
2797
+ topP?: number;
2798
+ topK?: number;
2799
+ maxTokens?: number;
2800
+ }
2801
+
2802
+ /**
2803
+ * Result of {@link transformTools}: ready-made tools payload plus tool config.
2804
+ */
2805
+ export interface TransformToolsResult {
2806
+ tools: any[] | undefined;
2807
+ toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } } | undefined;
2808
+ sanitizedMap: Record<string, string>;
2809
+ }
2810
+
2811
+ /**
2812
+ * Complete fetch payload for Antigravity / Cloud Code Assist endpoints.
2813
+ */
2814
+ export interface AntigravityFetchPayload {
2815
+ url: string;
2816
+ method: "POST";
2817
+ headers: Record<string, string>;
2818
+ body: {
2819
+ model: string;
2820
+ project: string;
2821
+ request: CloudCodeAssistInnerRequest;
2822
+ user_prompt_id: string;
2823
+ session_id: string;
2824
+ requestId?: string;
2825
+ };
2826
+ rawBody: string;
2827
+ model: string;
2828
+ project: string;
2829
+ user_prompt_id: string;
2830
+ session_id: string;
2831
+ isGeminiCLIOnly: boolean;
2832
+ }
2833
+
2834
+ // ---------------------------------------------------------------------------
2835
+ // Merged: generation config builder and bypass headers
2836
+ // ---------------------------------------------------------------------------
2837
+
2838
+ /**
2839
+ * Builds a GeminiGenerationConfig from an OpenCode request plus overrides.
2840
+ *
2841
+ * v2.1.14 merge note: renamed from buildGenerationConfig so the richer
2842
+ * options-object builder merged from request-helpers.ts can own that name.
2843
+ * Resolves the thinking level (override > opts > request > default) and maps
2844
+ * it onto the canonical budget map.
2845
+ *
2846
+ * @param req - OpenCode request with optional sampling/thinking fields
2847
+ * @param opts - transform-level overrides
2848
+ * @param thinkingLevelOverride - explicit level that wins over everything else
2849
+ * @returns generation config ready for CloudCodeAssistInnerRequest
2850
+ */
2851
+ export function buildGenerationConfigFromRequest(
2852
+ req: OpenCodeRequest,
2853
+ opts?: TransformOptions,
2854
+ thinkingLevelOverride?: ThinkingLevel,
2855
+ ): GeminiGenerationConfig {
2856
+ const thinkingLevel =
2857
+ thinkingLevelOverride ||
2858
+ normalizeThinkingLevel(
2859
+ opts?.thinkingLevel ??
2860
+ req.thinkingLevel ??
2861
+ req.thinking_level ??
2862
+ (req as any).thinking?.level ??
2863
+ req.thinking ??
2864
+ "medium",
2865
+ );
2866
+
2867
+ const thinkingBudget = mapThinkingLevelToBudget(thinkingLevel);
2868
+
2869
+ const temperature = opts?.temperature ?? req.temperature;
2870
+ const topP = opts?.topP ?? req.topP ?? req.top_p;
2871
+ const topK = opts?.topK ?? req.topK ?? req.top_k;
2872
+ const maxTokens = opts?.maxTokens ?? req.max_tokens ?? req.maxTokens;
2873
+
2874
+ const cfg: GeminiGenerationConfig = {};
2875
+
2876
+ if (typeof temperature === "number") cfg.temperature = temperature;
2877
+ if (typeof topP === "number") cfg.topP = topP;
2878
+ if (typeof topK === "number") cfg.topK = topK;
2879
+ if (typeof maxTokens === "number") cfg.maxOutputTokens = maxTokens;
2880
+
2881
+ cfg.thinkingConfig = {
2882
+ thinkingLevel,
2883
+ thinkingBudget,
2884
+ includeThoughts: true,
2885
+ };
2886
+ // Compat: some backend versions expect these fields at the top level too
2887
+ cfg.thinkingLevel = thinkingLevel;
2888
+ cfg.thinkingBudget = thinkingBudget;
2889
+
2890
+ return cfg;
2891
+ }
2892
+
2893
+ // ---------------------------------------------------------------------------
2894
+ // Merged from request-helpers.ts — canonical buildGenerationConfig (options
2895
+ // object with clamping, NaN guards and family-aware budget maps)
2896
+ // ---------------------------------------------------------------------------
2897
+
2898
+ export interface BuildGenerationConfigOptions {
2899
+ temperature?: number;
2900
+ topP?: number;
2901
+ topK?: number;
2902
+ maxTokens?: number; // alias para maxOutputTokens
2903
+ maxOutputTokens?: number;
2904
+ thinkingLevel?: ThinkingLevel;
2905
+ thinkingBudget?: number; // override direto
2906
+ includeThoughts?: boolean;
2907
+ modelId?: string;
2908
+ stopSequences?: string[];
2909
+ candidateCount?: number;
2910
+ presencePenalty?: number;
2911
+ frequencyPenalty?: number;
2912
+ // extras Gemini
2913
+ responseMimeType?: string;
2914
+ }
2915
+
2916
+ /**
2917
+ * Builds the GenerationConfig for cloudcode-pa v1internal.
2918
+ * Maps thinkingLevel (minimal/low/medium/high) to thinkingBudget.
2919
+ *
2920
+ * - For Gemini models: minimal=0 (disables), low=1024, medium=4096, high=8192
2921
+ * - For Claude models (via Antigravity): larger budgets
2922
+ * - When a direct thinkingBudget is supplied, use it as is
2923
+ * - When thinkingLevel=none/minimal with includeThoughts=false, omit includeThoughts to save tokens
2924
+ *
2925
+ * @param opts - generation options
2926
+ * @returns config compatible with v1internal:generateContent
2927
+ */
2928
+ export function buildGenerationConfig(opts: BuildGenerationConfigOptions = {}): GeminiGenerationConfig {
2929
+ const config: GeminiGenerationConfig = {};
2930
+
2931
+ if (typeof opts.temperature === "number" && !Number.isNaN(opts.temperature)) {
2932
+ config.temperature = Math.max(0, Math.min(2, opts.temperature));
2933
+ }
2934
+ if (typeof opts.topP === "number" && !Number.isNaN(opts.topP)) {
2935
+ config.topP = Math.max(0, Math.min(1, opts.topP));
2936
+ }
2937
+ if (typeof opts.topK === "number" && !Number.isNaN(opts.topK)) {
2938
+ config.topK = Math.max(1, Math.floor(opts.topK));
2939
+ }
2940
+
2941
+ const maxT = opts.maxOutputTokens ?? opts.maxTokens;
2942
+ if (typeof maxT === "number" && !Number.isNaN(maxT)) {
2943
+ config.maxOutputTokens = Math.max(1, Math.floor(maxT));
2944
+ }
2945
+
2946
+ if (Array.isArray(opts.stopSequences) && opts.stopSequences.length > 0) {
2947
+ config.stopSequences = opts.stopSequences.filter((s) => typeof s === "string" && s.length > 0).slice(0, 5);
2948
+ }
2949
+
2950
+ if (typeof opts.candidateCount === "number") {
2951
+ config.candidateCount = Math.max(1, Math.min(8, Math.floor(opts.candidateCount)));
2952
+ }
2953
+
2954
+ if (opts.responseMimeType) config.responseMimeType = opts.responseMimeType;
2955
+
2956
+ // thinking
2957
+ const modelId = (opts.modelId ?? "").toLowerCase();
2958
+ const isClaude = modelId.includes("claude") || modelId.includes("opus") || modelId.includes("sonnet");
2959
+ const budgetMap = isClaude ? THINKING_LEVEL_TO_BUDGET_CLAUDE : THINKING_LEVEL_TO_BUDGET_GEMINI;
2960
+
2961
+ let budget: number | undefined;
2962
+ let levelNorm: string | undefined;
2963
+
2964
+ if (typeof opts.thinkingBudget === "number" && !Number.isNaN(opts.thinkingBudget)) {
2965
+ budget = Math.max(0, Math.floor(opts.thinkingBudget));
2966
+ levelNorm = opts.thinkingLevel?.toLowerCase?.() ?? (budget === 0 ? "none" : "custom");
2967
+ } else if (opts.thinkingLevel) {
2968
+ levelNorm = opts.thinkingLevel.toLowerCase().trim();
2969
+ if (levelNorm in budgetMap) {
2970
+ budget = budgetMap[levelNorm];
2971
+ } else {
2972
+ // heuristic fallback: try to map synonyms
2973
+ if (["off", "disable", "disabled", "zero"].includes(levelNorm)) {
2974
+ budget = 0;
2975
+ levelNorm = "none";
2976
+ } else if (["min", "minimum"].includes(levelNorm)) {
2977
+ budget = budgetMap["minimal"];
2978
+ levelNorm = "minimal";
2979
+ } else {
2980
+ // default to medium when unknown
2981
+ budget = budgetMap["medium"];
2982
+ }
2983
+ }
2984
+ }
2985
+
2986
+ if (budget !== undefined) {
2987
+ config.thinkingConfig = {};
2988
+ if (levelNorm) config.thinkingConfig.thinkingLevel = levelNorm;
2989
+ config.thinkingConfig.thinkingBudget = budget;
2990
+
2991
+ // includeThoughts: true only when budget > 0 or explicitly requested
2992
+ // for minimal/none with budget 0, force false to save tokens
2993
+ let includeThoughts = opts.includeThoughts;
2994
+ if (includeThoughts === undefined) {
2995
+ includeThoughts = budget > 0;
2996
+ }
2997
+ config.thinkingConfig.includeThoughts = includeThoughts;
2998
+
2999
+ // for Gemini with budget 0 and includeThoughts false we could omit thinkingConfig
3000
+ // but keep it for clarity, except when it is none
3001
+ if (budget === 0 && !includeThoughts && (levelNorm === "none" || levelNorm === "minimal")) {
3002
+ // for native Gemini, removing thinkingConfig on disable is valid, but Claude keeps 0
3003
+ if (!isClaude) {
3004
+ // keep it anyway with budget 0 to make the disable explicit
3005
+ // do not remove
3006
+ }
3007
+ }
3008
+ }
3009
+
3010
+ // presence/frequency — not native to Gemini, but some proxies accept them; keep them when they arrive
3011
+ if (typeof opts.presencePenalty === "number") config.presencePenalty = opts.presencePenalty;
3012
+ if (typeof opts.frequencyPenalty === "number") config.frequencyPenalty = opts.frequencyPenalty;
3013
+
3014
+ return config;
3015
+ }
3016
+
3017
+ /**
3018
+ * Builds the exact header set used by Gemini CLI bypass traffic:
3019
+ * User-Agent, X-Goog-Api-Client, Client-Metadata (header and mirrored
3020
+ * X-Goog-Client-Metadata), JSON content type, gzip accept-encoding, and an
3021
+ * optional Bearer authorization merged last so extras can override.
3022
+ *
3023
+ * @param token - optional OAuth token; "Bearer " prefix added when missing
3024
+ * @param extra - additional headers merged after the defaults
3025
+ * @returns headers record ready for fetch
3026
+ */
3027
+ export function buildBypassHeaders(token?: string, extra?: Record<string, string>): Record<string, string> {
3028
+ const h: Record<string, string> = {
3029
+ "User-Agent": GEMINI_CLI_USER_AGENT,
3030
+ "X-Goog-Api-Client": X_GOOG_API_CLIENT,
3031
+ "Client-Metadata": CLIENT_METADATA_STRING,
3032
+ "X-Goog-Client-Metadata": CLIENT_METADATA_STRING,
3033
+ "Content-Type": "application/json",
3034
+ Accept: "application/json",
3035
+ "Accept-Encoding": "gzip",
3036
+ };
3037
+ if (token && token.trim()) {
3038
+ const t = token.trim();
3039
+ h["Authorization"] = t.startsWith("Bearer ") ? t : `Bearer ${t}`;
3040
+ }
3041
+ if (extra) {
3042
+ for (const [k, v] of Object.entries(extra)) if (v) h[k] = v;
3043
+ }
3044
+ return h;
3045
+ }
3046
+
3047
+ // ---------------------------------------------------------------------------
3048
+ // Merged: tool schema cleaning (strict allowed-keys variant)
3049
+ // ---------------------------------------------------------------------------
3050
+
3051
+ /**
3052
+ * Cleans one JSON Schema for Gemini function declarations: drops every key not
3053
+ * present in {@link ALLOWED_SCHEMA_KEYS} (recursively), normalizes oneOf to
3054
+ * anyOf, filters required/enum arrays, and defaults type to object when
3055
+ * properties exist.
3056
+ *
3057
+ * @param schema - raw schema node of any shape
3058
+ * @returns cleaned schema (same reference type semantics as input primitives)
3059
+ */
3060
+ export function cleanToolSchema(schema: unknown): any {
3061
+ if (schema == null) return schema;
3062
+ if (typeof schema !== "object") return schema;
3063
+ if (Array.isArray(schema)) return schema.map(cleanToolSchema);
3064
+
3065
+ const src = schema as Record<string, unknown>;
3066
+ const out: Record<string, unknown> = {};
3067
+
3068
+ for (const [k, v] of Object.entries(src)) {
3069
+ if (!ALLOWED_SCHEMA_KEYS.has(k)) {
3070
+ // Allow recursion through anyOf/oneOf while dropping $schema/$ref/additionalProperties etc.
3071
+ if (k === "anyOf" || k === "oneOf") {
3072
+ // Normalize oneOf to anyOf for compatibility
3073
+ out["anyOf"] = Array.isArray(v) ? (v as unknown[]).map(cleanToolSchema) : cleanToolSchema(v);
3074
+ }
3075
+ continue;
3076
+ }
3077
+
3078
+ if (k === "properties" && v && typeof v === "object" && !Array.isArray(v)) {
3079
+ const props = v as Record<string, unknown>;
3080
+ const cleanedProps: Record<string, unknown> = {};
3081
+ for (const [propName, propSchema] of Object.entries(props)) {
3082
+ cleanedProps[propName] = cleanToolSchema(propSchema);
3083
+ }
3084
+ out[k] = cleanedProps;
3085
+ } else if (k === "items" && v && typeof v === "object") {
3086
+ out[k] = cleanToolSchema(v);
3087
+ } else if (k === "anyOf" && Array.isArray(v)) {
3088
+ out[k] = (v as unknown[]).map(cleanToolSchema);
3089
+ } else if (k === "required" && Array.isArray(v)) {
3090
+ out[k] = (v as string[]).filter((s) => typeof s === "string");
3091
+ } else if (k === "enum" && Array.isArray(v)) {
3092
+ out[k] = v;
3093
+ } else {
3094
+ out[k] = v;
3095
+ }
3096
+ }
3097
+
3098
+ // Default type to object when properties exist without a type
3099
+ if (out.properties && !out.type) out.type = "object";
3100
+
3101
+ return out;
3102
+ }
3103
+
3104
+ // ============================================================================
3105
+ // 04. cleanToolSchema() - limpeza de JSON Schema para Gemini
3106
+ // ============================================================================
3107
+
3108
+ const DROP_KEYS = new Set<string>([
3109
+ "$schema",
3110
+ "$id",
3111
+ "$comment",
3112
+ "$anchor",
3113
+ "$defs",
3114
+ "definitions",
3115
+ "examples",
3116
+ "example",
3117
+ "deprecated",
3118
+ "const", // Gemini dislikes const in some versions; convert to enum
3119
+ "format", // keep? Gemini accepts some formats, but remove json-specific ones to be safe
3120
+ ]);
3121
+
3122
+ const VALID_JSON_TYPES = new Set<string>(["string", "number", "integer", "boolean", "object", "array", "null"]);
3123
+
3124
+ /**
3125
+ * Normalizes the `type` field: when it is an array with null, keep the main type
3126
+ * When it is an array with multiple types, pick the first non-null
3127
+ */
3128
+ function normalizeTypeField(typeVal: unknown): string | undefined {
3129
+ if (typeof typeVal === "string") {
3130
+ const low = typeVal.toLowerCase();
3131
+ return VALID_JSON_TYPES.has(low) ? low : undefined;
3132
+ }
3133
+ if (Array.isArray(typeVal)) {
3134
+ const filtered = (typeVal as unknown[])
3135
+ .map((t) => (typeof t === "string" ? t.toLowerCase() : ""))
3136
+ .filter((t) => t && t !== "null" && VALID_JSON_TYPES.has(t));
3137
+ if (filtered.length > 0) return filtered[0];
3138
+ // fallback
3139
+ if ((typeVal as string[]).includes("string")) return "string";
3140
+ return undefined;
3141
+ }
3142
+ return undefined;
3143
+ }
3144
+
3145
+ export interface CleanToolSchemaOptions {
3146
+ maxDepth?: number;
3147
+ keepFormat?: boolean;
3148
+ keepDescription?: boolean;
3149
+ }
3150
+
3151
+ /**
3152
+ * Cleans and normalizes a tool's JSON Schema into the shape Gemini / Cloud Code Assist accepts.
3153
+ *
3154
+ * What it does:
3155
+ * - removes $schema, $id, examples, etc
3156
+ * - removes empty undefined / null keys
3157
+ * - normalizes type to lowercase and singular
3158
+ * - converts `const: X` to `enum: [X]`
3159
+ * - recursively cleans properties, items, anyOf/oneOf/allOf
3160
+ * - guarantees an object with properties carries type=object
3161
+ * - removes empty properties
3162
+ * - stops infinite depth (>20)
3163
+ *
3164
+ * v2.1.14 merge note: renamed from cleanToolSchema (request-helpers.ts) to
3165
+ * cleanToolSchemaTolerant; the strict allowlist cleaner from request.ts owns
3166
+ * the cleanToolSchema name.
3167
+ *
3168
+ * @param schema - the raw schema (any)
3169
+ * @param opts - options
3170
+ * @returns the cleaned schema
3171
+ */
3172
+ export function cleanToolSchemaTolerant(schema: unknown, opts?: CleanToolSchemaOptions, _depth = 0): unknown {
3173
+ const maxDepth = opts?.maxDepth ?? 20;
3174
+ const depth = _depth;
3175
+
3176
+ if (depth > maxDepth) return schema;
3177
+ if (schema == null) return schema;
3178
+ if (typeof schema === "boolean") return schema;
3179
+
3180
+ if (Array.isArray(schema)) {
3181
+ return (schema as unknown[])
3182
+ .map((item) => cleanToolSchemaTolerant(item, opts, depth + 1))
3183
+ .filter((v) => v !== undefined);
3184
+ }
3185
+
3186
+ if (!isPlainObject(schema)) {
3187
+ return schema;
3188
+ }
3189
+
3190
+ const out: Record<string, unknown> = {};
3191
+
3192
+ for (const [rawKey, rawVal] of Object.entries(schema as Record<string, unknown>)) {
3193
+ if (rawVal === undefined) continue;
3194
+ if (DROP_KEYS.has(rawKey)) {
3195
+ // exception: when keepFormat is set and the key is format, keep it
3196
+ if (rawKey === "format" && opts?.keepFormat) {
3197
+ // keep only the formats safe for Gemini
3198
+ const fmt = String(rawVal).toLowerCase();
3199
+ if (["date-time", "date", "enum", "int32", "int64", "double", "float"].includes(fmt)) {
3200
+ out[rawKey] = fmt;
3201
+ }
3202
+ continue;
3203
+ }
3204
+ // exception: description is always kept by default
3205
+ continue;
3206
+ }
3207
+
3208
+ if (rawKey === "type") {
3209
+ const normalized = normalizeTypeField(rawVal);
3210
+ if (normalized) out[rawKey] = normalized;
3211
+ // when the type array included null, Gemini uses nullable via `nullable:true`? Newer Gemini uses type without null but accepts null in an array?
3212
+ // Omit null and do not set nullable for compatibility. If needed, one could set out['nullable']=true.
3213
+ continue;
3214
+ }
3215
+
3216
+ if (rawKey === "const") {
3217
+ // const => enum
3218
+ out["enum"] = [rawVal];
3219
+ continue;
3220
+ }
3221
+
3222
+ if (rawKey === "properties" && isPlainObject(rawVal)) {
3223
+ const cleanedProps: Record<string, unknown> = {};
3224
+ for (const [propKey, propVal] of Object.entries(rawVal as Record<string, unknown>)) {
3225
+ const cleaned = cleanToolSchemaTolerant(propVal, opts, depth + 1);
3226
+ if (cleaned !== undefined) cleanedProps[propKey] = cleaned;
3227
+ }
3228
+ if (Object.keys(cleanedProps).length > 0) out[rawKey] = cleanedProps;
3229
+ continue;
3230
+ }
3231
+
3232
+ if (rawKey === "items") {
3233
+ out[rawKey] = cleanToolSchemaTolerant(rawVal, opts, depth + 1);
3234
+ continue;
3235
+ }
3236
+
3237
+ if (rawKey === "additionalProperties") {
3238
+ if (typeof rawVal === "boolean") {
3239
+ out[rawKey] = rawVal;
3240
+ } else if (isPlainObject(rawVal)) {
3241
+ out[rawKey] = cleanToolSchemaTolerant(rawVal, opts, depth + 1);
3242
+ }
3243
+ continue;
3244
+ }
3245
+
3246
+ if (["anyOf", "oneOf", "allOf"].includes(rawKey) && Array.isArray(rawVal)) {
3247
+ const cleanedArr = (rawVal as unknown[])
3248
+ .map((v) => cleanToolSchemaTolerant(v, opts, depth + 1))
3249
+ .filter((v) => v !== undefined && v !== null) as unknown[];
3250
+ if (cleanedArr.length > 0) out[rawKey] = cleanedArr;
3251
+ continue;
3252
+ }
3253
+
3254
+ if (rawKey === "enum" && Array.isArray(rawVal)) {
3255
+ // filter valid enums, remove duplicates
3256
+ const uniq = Array.from(new Set((rawVal as unknown[]).filter((x) => x != null)));
3257
+ if (uniq.length > 0) out[rawKey] = uniq;
3258
+ continue;
3259
+ }
3260
+
3261
+ if (rawKey === "required" && Array.isArray(rawVal)) {
3262
+ const uniq = Array.from(
3263
+ new Set((rawVal as unknown[]).filter((x) => typeof x === "string" && (x as string).length > 0)),
3264
+ ) as string[];
3265
+ if (uniq.length > 0) out[rawKey] = uniq;
3266
+ continue;
3267
+ }
3268
+
3269
+ // generic recursion for objects
3270
+ if (isPlainObject(rawVal)) {
3271
+ const cleaned = cleanToolSchemaTolerant(rawVal, opts, depth + 1);
3272
+ if (
3273
+ cleaned !== undefined &&
3274
+ (typeof cleaned !== "object" || Object.keys(cleaned as object).length > 0 || Array.isArray(cleaned))
3275
+ ) {
3276
+ out[rawKey] = cleaned;
3277
+ }
3278
+ continue;
3279
+ }
3280
+
3281
+ if (Array.isArray(rawVal)) {
3282
+ // for generic arrays (e.g. enum already handled, but the others)
3283
+ out[rawKey] = rawVal
3284
+ .map((v) => (isPlainObject(v) ? cleanToolSchemaTolerant(v, opts, depth + 1) : v))
3285
+ .filter((v) => v !== undefined);
3286
+ continue;
3287
+ }
3288
+
3289
+ // primitives: string, number, boolean
3290
+ out[rawKey] = rawVal;
3291
+ }
3292
+
3293
+ // heuristic: when it has properties but no type, assume object
3294
+ if (out["properties"] && !out["type"]) {
3295
+ out["type"] = "object";
3296
+ }
3297
+ // when it is object with empty properties and no additionalProperties, remove properties to avoid the empty schema Gemini rejects
3298
+ if (
3299
+ out["type"] === "object" &&
3300
+ out["properties"] &&
3301
+ isPlainObject(out["properties"]) &&
3302
+ Object.keys(out["properties"] as object).length === 0
3303
+ ) {
3304
+ delete out["properties"];
3305
+ }
3306
+
3307
+ return out;
3308
+ }
3309
+
3310
+ /**
3311
+ * Plural version that cleans a map of tools or an array of function declarations
3312
+ *
3313
+ * v2.1.14 merge note: canonical plural (superset of request.ts's array-only
3314
+ * variant; strict-array cleaning stays available via schemas.map(cleanToolSchema)).
3315
+ */
3316
+ export function cleanToolSchemas(tools: unknown, opts?: CleanToolSchemaOptions): unknown {
3317
+ if (Array.isArray(tools)) {
3318
+ return tools.map((t) => cleanToolSchemaTolerant(t, opts)).filter(Boolean);
3319
+ }
3320
+ if (isPlainObject(tools)) {
3321
+ // pode ser { functions: [...] } ou { name: { parameters: schema } }
3322
+ const obj = tools as Record<string, unknown>;
3323
+ if (obj["parameters"] || obj["properties"] || obj["type"]) {
3324
+ return cleanToolSchemaTolerant(obj, opts);
3325
+ }
3326
+ const cleaned: Record<string, unknown> = {};
3327
+ for (const [k, v] of Object.entries(obj)) {
3328
+ cleaned[k] = cleanToolSchemaTolerant(v, opts);
3329
+ }
3330
+ return cleaned;
3331
+ }
3332
+ return cleanToolSchemaTolerant(tools, opts);
3333
+ }
3334
+
3335
+ // ---------------------------------------------------------------------------
3336
+ // Merged: tool name validation
3337
+ // ---------------------------------------------------------------------------
3338
+
3339
+ /**
3340
+ * Checks whether a tool name already satisfies the Gemini naming rules.
3341
+ *
3342
+ * @param name - candidate name
3343
+ * @returns true when valid without sanitization
3344
+ */
3345
+ export function isValidToolName(name: string): boolean {
3346
+ return TOOL_NAME_REGEX.test(name);
3347
+ }
3348
+
3349
+ /**
3350
+ * Detailed result of {@link validateToolNames}.
3351
+ */
3352
+ export interface ValidateToolNamesResult {
3353
+ valid: string[];
3354
+ invalid: string[];
3355
+ sanitized: Record<string, string>;
3356
+ duplicates: string[];
3357
+ }
3358
+
3359
+ /**
3360
+ * Validates a list of tool names, reporting duplicates and computing sanitized
3361
+ * replacements for invalid entries.
3362
+ *
3363
+ * @param names - raw tool names
3364
+ * @returns valid/invalid partitions plus sanitized mapping
3365
+ */
3366
+ export function validateToolNames(names: string[]): ValidateToolNamesResult {
3367
+ const valid: string[] = [];
3368
+ const invalid: string[] = [];
3369
+ const sanitized: Record<string, string> = {};
3370
+ const seen = new Set<string>();
3371
+ const duplicates: string[] = [];
3372
+
3373
+ for (const raw of names) {
3374
+ if (!raw || typeof raw !== "string") {
3375
+ invalid.push(String(raw));
3376
+ continue;
3377
+ }
3378
+ if (seen.has(raw)) duplicates.push(raw);
3379
+ seen.add(raw);
3380
+
3381
+ if (isValidToolName(raw)) valid.push(raw);
3382
+ else {
3383
+ invalid.push(raw);
3384
+ sanitized[raw] = sanitizeToolName(raw);
3385
+ }
3386
+ }
3387
+ return { valid, invalid, sanitized, duplicates };
3388
+ }
3389
+
3390
+ // v2.1.14 merge: request.ts's delegating isLikelyAntigravityOnlyModel alias
3391
+ // (formerly directly below) was dropped; the richer standalone heuristic from
3392
+ // request-helpers.ts (see the model helpers section above) is canonical.
3393
+
3394
+ // ---------------------------------------------------------------------------
3395
+ // Merged: tool name validation
3396
+ // ---------------------------------------------------------------------------
3397
+
3398
+ /**
3399
+ * Validates one tool name against the Gemini naming rules.
3400
+ *
3401
+ * @param name - candidate name of any type
3402
+ */
3403
+ export function validateToolName(name: unknown): boolean {
3404
+ if (typeof name !== "string") return false;
3405
+ const t = name.trim();
3406
+ if (t.length < TOOL_NAME_MIN_LENGTH || t.length > TOOL_NAME_MAX_LENGTH) return false;
3407
+ return TOOL_NAME_REGEX.test(t);
3408
+ }
3409
+
3410
+ // ============================================================================
3411
+ // 05. validateToolNames()
3412
+ // ============================================================================
3413
+
3414
+ // Tool name validation constants (TOOL_NAME_REGEX, TOOL_NAME_MAX_LENGTH)
3415
+ // are module-wide exports defined in the constants section above.
3416
+
3417
+ export interface InvalidToolName {
3418
+ name: string;
3419
+ reason: string;
3420
+ }
3421
+
3422
+ export interface SanitizedToolName {
3423
+ original: string;
3424
+ sanitized: string;
3425
+ }
3426
+
3427
+ export interface ValidateToolNamesDetailedResult {
3428
+ valid: boolean;
3429
+ validNames: string[];
3430
+ invalid: InvalidToolName[];
3431
+ sanitized: SanitizedToolName[];
3432
+ allSanitizedValid: boolean;
3433
+ }
3434
+
3435
+ /**
3436
+ * Valida lista de nomes de tools. Gemini exige ^[A-Za-z_][A-Za-z0-9_]*$ e max 64.
3437
+ *
3438
+ * v2.1.14 merge note: renamed from validateToolNames (request-helpers.ts);
3439
+ * the canonical TOOL_NAME_REGEX (letter start + 64 cap, from request.ts)
3440
+ * now backs this validator.
3441
+ *
3442
+ * @param tools - array of strings, array of {name}, or Record<string,any>
3443
+ * @returns the result with valid/invalid/sanitized
3444
+ */
3445
+ export function validateToolNamesDetailed(
3446
+ tools: Array<string | { name: string; [k: string]: unknown }> | Record<string, unknown>,
3447
+ ): ValidateToolNamesDetailedResult {
3448
+ let names: string[] = [];
3449
+
3450
+ if (Array.isArray(tools)) {
3451
+ for (const item of tools) {
3452
+ if (typeof item === "string") names.push(item);
3453
+ else if (isPlainObject(item) && typeof (item as any).name === "string") names.push((item as any).name);
3454
+ }
3455
+ } else if (isPlainObject(tools)) {
3456
+ // when it is a Record of toolName -> schema, the keys are the names
3457
+ names = Object.keys(tools as Record<string, unknown>);
3458
+ }
3459
+
3460
+ // deduplicate keeping order for validation, but keep duplicates for the report? Dedup for validNames
3461
+ const seen = new Set<string>();
3462
+ const uniqueNames: string[] = [];
3463
+ for (const n of names) {
3464
+ if (!seen.has(n)) {
3465
+ seen.add(n);
3466
+ uniqueNames.push(n);
3467
+ }
3468
+ }
3469
+
3470
+ const invalid: InvalidToolName[] = [];
3471
+ const validNames: string[] = [];
3472
+ const sanitized: SanitizedToolName[] = [];
3473
+
3474
+ for (const name of uniqueNames) {
3475
+ if (!name || typeof name !== "string") {
3476
+ invalid.push({ name: String(name), reason: "empty or not string" });
3477
+ sanitized.push({ original: String(name), sanitized: "_tool" });
3478
+ continue;
3479
+ }
3480
+ if (name.length > TOOL_NAME_MAX_LENGTH) {
3481
+ invalid.push({ name, reason: `exceeds ${TOOL_NAME_MAX_LENGTH} chars` });
3482
+ } else if (!TOOL_NAME_REGEX.test(name)) {
3483
+ let reason = "must start with letter or underscore and contain only alphanum + underscore";
3484
+ if (/^[0-9]/.test(name)) reason = "must not start with digit";
3485
+ else if (/[^A-Za-z0-9_]/.test(name)) reason = "contains invalid characters (only A-Za-z0-9_ allowed)";
3486
+ else if (!name.length) reason = "empty";
3487
+ invalid.push({ name, reason });
3488
+ } else {
3489
+ validNames.push(name);
3490
+ }
3491
+
3492
+ const s = sanitizeToolName(name);
3493
+ // when the original is invalid, add the sanitized one
3494
+ if (!TOOL_NAME_REGEX.test(name) || name.length > TOOL_NAME_MAX_LENGTH) {
3495
+ sanitized.push({ original: name, sanitized: s });
3496
+ }
3497
+ }
3498
+
3499
+ const allSanitized = sanitized.map((s) => s.sanitized).concat(validNames);
3500
+ const allSanitizedValid = allSanitized.every((n) => TOOL_NAME_REGEX.test(n) && n.length <= TOOL_NAME_MAX_LENGTH);
3501
+
3502
+ return {
3503
+ valid: invalid.length === 0,
3504
+ validNames,
3505
+ invalid,
3506
+ sanitized,
3507
+ allSanitizedValid,
3508
+ };
3509
+ }
3510
+
3511
+ // ---------------------------------------------------------------------------
3512
+ // Merged: transformTools — clean + validate + google_search injection
3513
+ // ---------------------------------------------------------------------------
3514
+
3515
+ /**
3516
+ * Converts OpenCode tool definitions into the Cloud Code Assist tools payload:
3517
+ * validates names, sanitizes invalid ones, cleans each schema, deduplicates by
3518
+ * final name, optionally injects `{ google_search: {} }`, and emits the
3519
+ * matching AUTO function-calling toolConfig.
3520
+ *
3521
+ * Kept alongside {@link validateAndSanitizeTools}, which returns flat
3522
+ * declarations instead of a wrapped payload.
3523
+ *
3524
+ * @param openTools - raw tools in OpenAI/Anthropic/Gemini shapes
3525
+ * @param enableGoogleSearch - inject the google_search built-in tool
3526
+ * @returns tools payload, tool config and sanitized-name map
3527
+ */
3528
+ export function transformTools(
3529
+ openTools: OpenCodeTool[] | undefined,
3530
+ enableGoogleSearch?: boolean,
3531
+ ): TransformToolsResult {
3532
+ const sanitizedMap: Record<string, string> = {};
3533
+ const functionDeclarations: GeminiFunctionDeclaration[] = [];
3534
+
3535
+ if (openTools && Array.isArray(openTools) && openTools.length > 0) {
3536
+ const names: string[] = (openTools as Array<{ name?: string }>).map((t) => t.name).filter(Boolean) as string[];
3537
+ const validation = validateToolNames(names);
3538
+
3539
+ for (const inv of validation.invalid) {
3540
+ sanitizedMap[inv] = validation.sanitized[inv] || sanitizeToolName(inv);
3541
+ }
3542
+
3543
+ for (const entry of openTools as any[]) {
3544
+ if (!entry || !entry.name) continue;
3545
+ const originalName: string = entry.name;
3546
+ const name = isValidToolName(originalName)
3547
+ ? originalName
3548
+ : sanitizedMap[originalName] || sanitizeToolName(originalName);
3549
+
3550
+ // Accept the schema from any known alias key
3551
+ const rawSchema = entry.input_schema ??
3552
+ entry.inputSchema ??
3553
+ entry.parameters ??
3554
+ entry.schema ?? { type: "object", properties: {} };
3555
+
3556
+ const cleaned = cleanToolSchema(rawSchema);
3557
+
3558
+ functionDeclarations.push({
3559
+ name,
3560
+ description: entry.description || `Function ${name}`,
3561
+ parameters: cleaned,
3562
+ });
3563
+ }
3564
+ }
3565
+
3566
+ const toolsPayload: any[] = [];
3567
+
3568
+ if (functionDeclarations.length > 0) {
3569
+ // Deduplicate by name — last declaration wins
3570
+ const dedup = new Map<string, GeminiFunctionDeclaration>();
3571
+ for (const fd of functionDeclarations) dedup.set(fd.name, fd);
3572
+ toolsPayload.push({ functionDeclarations: Array.from(dedup.values()) });
3573
+ }
3574
+
3575
+ if (enableGoogleSearch) {
3576
+ // Gemini 3+ uses { google_search: {} }, legacy uses { google_search_retrieval: {} }
3577
+ toolsPayload.push({ google_search: {} });
3578
+ }
3579
+
3580
+ const tools = toolsPayload.length > 0 ? toolsPayload : undefined;
3581
+
3582
+ let toolConfig: TransformToolsResult["toolConfig"];
3583
+ if (functionDeclarations.length > 0) {
3584
+ toolConfig = {
3585
+ functionCallingConfig: {
3586
+ mode: "AUTO",
3587
+ allowedFunctionNames: Array.from(new Set(functionDeclarations.map((f) => f.name))),
3588
+ },
3589
+ };
3590
+ }
3591
+
3592
+ return { tools, toolConfig, sanitizedMap };
3593
+ }
3594
+
3595
+ // ---------------------------------------------------------------------------
3596
+ // Merged: RequestTransformer barrel
3597
+ // ---------------------------------------------------------------------------
3598
+
3599
+ /**
3600
+ * Named bundle mirroring the legacy ONDA 4 module surface. The default export
3601
+ * remains {@link RequestPipeline}; both barrels stay available.
3602
+ */
3603
+ export const RequestTransformer = {
3604
+ // bypass constants
3605
+ GEMINI_CLI_OAUTH_CLIENT_ID,
3606
+ GEMINI_CLI_OAUTH_CLIENT_SECRET,
3607
+ GEMINI_CLI_USER_AGENT,
3608
+ X_GOOG_API_CLIENT,
3609
+ CLIENT_METADATA,
3610
+ CLIENT_METADATA_STRING,
3611
+ FALLBACK_PROJECT_ID,
3612
+ CLOUDCODE_PA_BASE,
3613
+ CLOUDCODE_DAILY_BASE,
3614
+ // legacy member keeps its historical method-path value (v2.1.15 Phase A
3615
+ // renamed the local map to CODE_ASSIST_METHOD_PATHS; the module-level
3616
+ // CODE_ASSIST_ENDPOINTS export now resolves to the models.ts host map)
3617
+ CODE_ASSIST_ENDPOINTS: CODE_ASSIST_METHOD_PATHS,
3618
+ CODE_ASSIST_METHOD_PATHS,
3619
+ MODELS_2026: FETCH_AVAILABLE_MODELS_2026,
3620
+ THINKING_BUDGET_MAP,
3621
+
3622
+ // core FNV
3623
+ fnv1a32,
3624
+ fnv1a64BigInt,
3625
+ buildSessionIdFromDir,
3626
+ buildSessionId,
3627
+ buildUserPromptId,
3628
+
3629
+ // tools
3630
+ sanitizeToolName,
3631
+ isValidToolName,
3632
+ validateToolNames,
3633
+ cleanToolSchema,
3634
+ cleanToolSchemas,
3635
+ transformTools,
3636
+
3637
+ // thinking
3638
+ normalizeThinkingLevel,
3639
+ mapThinkingLevelToBudget,
3640
+
3641
+ // models
3642
+ isGeminiCLIOnlyModel,
3643
+ isLikelyAntigravityOnlyModel,
3644
+
3645
+ // generation
3646
+ buildGenerationConfigFromRequest,
3647
+ buildBypassHeaders,
3648
+
3649
+ // main
3650
+ transformRequest,
3651
+ buildAntigravityRequest,
3652
+ buildGeminiCLIRequest,
3653
+ } as const;
3654
+
3655
+ // ---------------------------------------------------------------------------
3656
+ // Barrel exports — library-first
3657
+ // ---------------------------------------------------------------------------
3658
+
3659
+ export const RequestPipeline = {
3660
+ transformRequest,
3661
+ buildAntigravityRequest,
3662
+ buildGeminiCLIRequest,
3663
+ isGeminiCLIOnlyModel,
3664
+ getEndpointsForModel,
3665
+ fnv1a32,
3666
+ fnv1a32Hex,
3667
+ generateDeterministicId,
3668
+ generateSessionId,
3669
+ generateUserPromptId,
3670
+ sanitizeToolName,
3671
+ ensureUniqueToolNames,
3672
+ validateAndSanitizeTools,
3673
+ cleanJsonSchema,
3674
+ mapThinkingBlock,
3675
+ sortThoughtFirst,
3676
+ filterAndMapThinkingParts,
3677
+ injectGoogleSearchTool,
3678
+ stripForbiddenHeaders,
3679
+ stripXGoogUserProjectHeader,
3680
+ getFingerprintHeaders,
3681
+ buildDynamicUserAgent,
3682
+ fetchWithCascade,
3683
+ parseModelId,
3684
+ extractThinkingLevel,
3685
+ };
3686
+
3687
+ // ============================================================================
3688
+ // 12. DEFAULT EXPORT + NAMED EXPORTS BUNDLE
3689
+ // ============================================================================
3690
+
3691
+ export function stripForbiddenHeaders(headers: Record<string, string>): Record<string, string> {
3692
+ if (!headers || typeof headers !== "object") return {};
3693
+ const forbidden = new Set(FORBIDDEN_HEADERS.map((h) => h.toLowerCase()));
3694
+ const out: Record<string, string> = {};
3695
+ for (const [k, v] of Object.entries(headers)) {
3696
+ if (!k) continue;
3697
+ const low = k.toLowerCase().trim();
3698
+ if (forbidden.has(low)) continue;
3699
+ if (low === "x-goog-user-project") continue;
3700
+ out[k] = v;
3701
+ }
3702
+ return out;
3703
+ }
3704
+ // ===========================================================================
3705
+ /** Cloud Code streaming method path (family owner — former plugin.ts local). */
3706
+ export const STREAM_METHOD = "/v1internal:streamGenerateContent?alt=sse" as const;
3707
+ /** Cloud Code non-streaming method path. */
3708
+ export const GENERATE_METHOD = "/v1internal:generateContent" as const;
3709
+ /** Cloud Code loadCodeAssist method path (project discovery). */
3710
+ export const LOAD_METHOD = "/v1internal:loadCodeAssist" as const;
3711
+ /** Cloud Code onboardUser method path (project discovery). */
3712
+ export const ONBOARD_METHOD = "/v1internal:onboardUser" as const;
3713
+
3714
+ // v2.1.15 Phase D: LIVE request pipeline moved from plugin.ts — request
3715
+ // building lives ONLY in request.ts. This is the opencode-validated chain
3716
+ // (transformRequest + buildCloudCodeBody + buildAntigravityRequest +
3717
+ // buildGeminiCLIRequest + fetchWithCascade with their helper cluster).
3718
+ // The former RequestHelpers-lineage implementations of these families were
3719
+ // deleted; the live shapes are now the canonical public API.
3720
+ // ===========================================================================
3721
+
3722
+ export const TOOL_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
3723
+
3724
+ function sanitizeToolName(name: string): string {
3725
+ if (typeof name !== "string") return `tool_${fnv1a32Hex(String(name ?? "")).slice(0, 8)}`;
3726
+ let t = name.trim();
3727
+ if (!t) return `tool_${fnv1a32Hex(name).slice(0, 8)}`;
3728
+ t = t.replace(/[^a-zA-Z0-9_]/g, "_").replace(/__+/g, "_");
3729
+ if (!/^[a-zA-Z]/.test(t)) t = `tool_${t.replace(/^[^a-zA-Z]+/, "")}`;
3730
+ t = t.slice(0, 64);
3731
+ if (!TOOL_NAME_REGEX.test(t)) t = `a${t.slice(0, 63)}`;
3732
+ return t;
3733
+ }
3734
+ function ensureUniqueToolNames(names: string[]): string[] {
3735
+ const seen = new Map<string, number>();
3736
+ const out: string[] = [];
3737
+ for (const raw of names) {
3738
+ let cand = raw;
3739
+ const cnt = seen.get(raw) ?? 0;
3740
+ if (cnt > 0 || out.includes(cand)) {
3741
+ let idx = 1;
3742
+ while (out.includes(`${raw}_${idx}`)) idx++;
3743
+ cand = `${raw}_${idx}`.slice(0, 64);
3744
+ }
3745
+ seen.set(raw, (seen.get(raw) ?? 0) + 1);
3746
+ out.push(cand);
3747
+ }
3748
+ return out;
3749
+ }
3750
+ function cleanJsonSchema(input: any, seen = new WeakSet()): any {
3751
+ if (input == null) return { type: "OBJECT", properties: {} };
3752
+ if (typeof input === "boolean")
3753
+ return input ? { type: "OBJECT", properties: {} } : { type: "OBJECT", properties: {}, description: "disallowed" };
3754
+ if (Array.isArray(input)) return input.map((i) => cleanJsonSchema(i, seen));
3755
+ if (typeof input !== "object") return { type: "STRING" };
3756
+ if (seen.has(input)) return { type: "OBJECT", properties: {} };
3757
+ seen.add(input);
3758
+ const out: any = {};
3759
+ if (input.type) {
3760
+ const rt = Array.isArray(input.type) ? (input.type.find((t: any) => t !== "null") ?? input.type[0]) : input.type;
3761
+ if (typeof rt === "string") out.type = rt.toUpperCase();
3762
+ }
3763
+ for (const k of Object.keys(input)) {
3764
+ if (!CLEAN_SCHEMA_KEYS.has(k)) continue;
3765
+ const v = (input as any)[k];
3766
+ if (k === "properties" && typeof v === "object" && v !== null) {
3767
+ out.properties = {};
3768
+ for (const [pk, pv] of Object.entries(v)) out.properties[pk] = cleanJsonSchema(pv, seen);
3769
+ } else if ((k === "items" || k === "anyOf" || k === "oneOf") && v) {
3770
+ out[k] = cleanJsonSchema(v, seen);
3771
+ } else if (v !== undefined) {
3772
+ out[k] = v;
3773
+ }
3774
+ }
3775
+ if (!out.type && out.properties) out.type = "OBJECT";
3776
+ if (!out.type) out.type = "OBJECT";
3777
+ return out;
3778
+ }
3779
+ function validateAndSanitizeTools(rawTools?: any[]): { declarations: any[] } {
3780
+ if (!rawTools || !Array.isArray(rawTools) || rawTools.length === 0) return { declarations: [] };
3781
+ const decls: any[] = [];
3782
+ for (const entry of rawTools) {
3783
+ if (!entry) continue;
3784
+ if (entry.functionDeclarations && Array.isArray(entry.functionDeclarations)) {
3785
+ for (const fd of entry.functionDeclarations) {
3786
+ if (!fd?.name) continue;
3787
+ decls.push({
3788
+ name: sanitizeToolName(String(fd.name)),
3789
+ description: fd.description ? String(fd.description).slice(0, 1024) : undefined,
3790
+ parameters: fd.parameters ? cleanJsonSchema(fd.parameters) : { type: "OBJECT", properties: {} },
3791
+ originalName: String(fd.name),
3792
+ });
3793
+ }
3794
+ continue;
3795
+ }
3796
+ if (entry.type === "function" && entry.function) {
3797
+ const fn = entry.function;
3798
+ if (!fn.name) continue;
3799
+ decls.push({
3800
+ name: sanitizeToolName(String(fn.name)),
3801
+ description: fn.description ? String(fn.description).slice(0, 1024) : undefined,
3802
+ parameters: fn.parameters ? cleanJsonSchema(fn.parameters) : { type: "OBJECT", properties: {} },
3803
+ originalName: String(fn.name),
3804
+ });
3805
+ continue;
3806
+ }
3807
+ if (entry.name) {
3808
+ const schemaRaw = entry.input_schema ?? entry.inputSchema ?? entry.parameters ?? entry.schema;
3809
+ decls.push({
3810
+ name: sanitizeToolName(String(entry.name)),
3811
+ description: entry.description ? String(entry.description).slice(0, 1024) : undefined,
3812
+ parameters: schemaRaw ? cleanJsonSchema(schemaRaw) : { type: "OBJECT", properties: {} },
3813
+ originalName: String(entry.name),
3814
+ });
3815
+ }
3816
+ }
3817
+ const names = decls.map((d) => d.name);
3818
+ const unique = ensureUniqueToolNames(names);
3819
+ for (let i = 0; i < decls.length; i++) decls[i].name = unique[i];
3820
+ return { declarations: decls };
3821
+ }
3822
+
3823
+ // v2.1.15 Phase A: isGeminiCLIOnlyModel moved to models.ts (canonical
3824
+ // request-lineage implementation — classification superset of this local
3825
+ // variant) — imported and re-exported at the top of this file.
3826
+
3827
+ // v2.1.15 Phase D: shouldSkipSandbox lives in models.js (classifier owner) — imported.
3828
+
3829
+ // v2.1.15 Phase A: getEndpointsForModel moved to models.ts (canonical
3830
+ // request-lineage implementation; the former local shouldSkipSandbox
3831
+ // consultation is subsumed by the canonical classifier) — imported and
3832
+ // re-exported at the top of this file.
3833
+
3834
+ function buildDynamicUserAgentFromPlatform(version?: string): string {
3835
+ return getDynamicUserAgent(version);
3836
+ }
3837
+
3838
+ function buildCommonHeaders(
3839
+ opts: { accessToken: string; userAgent?: string | undefined; version?: string | undefined },
3840
+ style: "antigravity" | "gemini-cli" = "antigravity",
3841
+ ): Record<string, string> {
3842
+ const ua = opts.userAgent ?? buildDynamicUserAgentFromPlatform(opts.version);
3843
+ const headers: Record<string, string> = {
3844
+ Authorization: `Bearer ${opts.accessToken}`,
3845
+ "Content-Type": "application/json",
3846
+ "User-Agent": ua,
3847
+ };
3848
+ // gemini-cli adds X-Goog-Api-Client but AM does not — observer includes only for gemini-cli pool when cli_first
3849
+ if (style === "gemini-cli") {
3850
+ try {
3851
+ const nodeVer = (process as any)?.versions?.node ?? "20.0.0";
3852
+ headers["X-Goog-Api-Client"] =
3853
+ `antigravity/${opts.version ?? ANTIGRAVITY_VERSION_FALLBACK} gl-node/${nodeVer} gax/4.9.0 grpc/1.14.0`;
3854
+ headers["Client-Metadata"] =
3855
+ `ideType=ANTIGRAVITY,platform=${normalizePlatform(osPlatform()).toUpperCase()},ideVersion=${opts.version ?? ANTIGRAVITY_VERSION_FALLBACK},pluginVersion=${PLUGIN_VERSION}`;
3856
+ } catch {
3857
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3858
+ }
3859
+ }
3860
+ return stripForbiddenHeaders(headers);
3861
+ }
3862
+
3863
+ // v2.1.15 Phase B: buildAntigravityClientMetadata was dead (zero callers) — deleted;
3864
+ // fingerprint.js buildClientMetadata is the canonical metadata builder.
3865
+
3866
+ // v2.1.15 Phase A: mapModelToGroup moved to models.ts (canonical merged
3867
+ // implementation) — imported and re-exported at the top of this file.
3868
+
3869
+ // deterministic ids
3870
+ export function generateDeterministicId(seed: string): string {
3871
+ return fnv1a32Hex(seed);
3872
+ }
3873
+ export function generateSessionId(seed?: string, offsetEnabled?: boolean): string {
3874
+ const base = seed ?? `${Date.now()}-${randomBytes(4).toString("hex")}`;
3875
+ const offset = offsetEnabled ? randomInt(0, 1001) : 0;
3876
+ return fnv1a32Hex(`${base}-${offset}-session`);
3877
+ }
3878
+ export function generateUserPromptId(seed?: string, offsetEnabled?: boolean): string {
3879
+ const base = seed ?? `${Date.now()}-${randomBytes(4).toString("hex")}`;
3880
+ const offset = offsetEnabled ? randomInt(0, 1001) : 0;
3881
+ // 9router pid_offset trick 0-1000 added to user_prompt_id for anti-fingerprint jitter
3882
+ return fnv1a32Hex(`${base}-${offset}-user-prompt-${offsetEnabled ? offset : 0}`);
3883
+ }
3884
+
3885
+ // ---------------------------------------------------------------------------
3886
+ // Transform OpenAI/Anthropic messages → Gemini contents
3887
+ // ---------------------------------------------------------------------------
3888
+
3889
+ // v2.1.15 Phase D: the live pipeline uses the exported superset interfaces
3890
+ // GeminiPart/GeminiContent/GeminiSystem above (one type family, one owner).
3891
+
3892
+ interface TransformRequestOptions {
3893
+ model: string;
3894
+ messages?: any[] | undefined;
3895
+ tools?: any[] | undefined;
3896
+ system?: string | any | undefined;
3897
+ thinkingLevel?: string | undefined;
3898
+ thinkingBudget?: number | undefined;
3899
+ googleSearchEnabled?: boolean | undefined;
3900
+ generationConfig?: any | undefined;
3901
+ sessionSeed?: string | undefined;
3902
+ userPromptSeed?: string | undefined;
3903
+ projectId?: string | undefined;
3904
+ pidOffsetEnabled?: boolean | undefined;
3905
+ claudeToolHardening?: boolean | undefined;
3906
+ keepThinking?: boolean | undefined;
3907
+ }
3908
+
3909
+ interface TransformResult {
3910
+ contents: GeminiContent[];
3911
+ systemInstruction?: GeminiSystem | undefined;
3912
+ tools?: any[] | undefined;
3913
+ toolConfig?: any | undefined;
3914
+ generationConfig?: any | undefined;
3915
+ sessionId: string;
3916
+ userPromptId: string;
3917
+ thinkingBudget?: number | undefined;
3918
+ thinkingLevel?: ThinkingLevel | undefined;
3919
+ }
3920
+
3921
+ function extractThinkingLevel(modelId: string, explicit?: string): { level?: ThinkingLevel; budget?: number } {
3922
+ const exp = (explicit ?? "").toLowerCase().trim();
3923
+ if (exp && (THINKING_LEVELS as readonly string[]).includes(exp))
3924
+ return { level: exp as ThinkingLevel, budget: THINKING_BUDGET_MAP[exp as ThinkingLevel] };
3925
+ const lower = (modelId ?? "").toLowerCase();
3926
+ for (const lvl of THINKING_LEVELS)
3927
+ if (lower.includes(`-${lvl}`))
3928
+ return { level: lvl as ThinkingLevel, budget: THINKING_BUDGET_MAP[lvl as ThinkingLevel] };
3929
+ if (lower.includes("thinking"))
3930
+ return {
3931
+ level: lower.includes("claude") ? "medium" : "low",
3932
+ budget: THINKING_BUDGET_MAP[lower.includes("claude") ? "medium" : "low"],
3933
+ };
3934
+ return {};
3935
+ }
3936
+
3937
+ function openAIContentToParts(content: any): GeminiPart[] {
3938
+ if (content == null) return [{ text: "" }];
3939
+ if (typeof content === "string") return [{ text: content }];
3940
+ if (Array.isArray(content)) {
3941
+ const parts: GeminiPart[] = [];
3942
+ for (const c of content) {
3943
+ if (!c) continue;
3944
+ if (typeof c === "string") parts.push({ text: c });
3945
+ else if (c.type === "text" && c.text) parts.push({ text: c.text });
3946
+ else if (c.type === "image_url" && c.image_url?.url) {
3947
+ // data url handling (linear parse, no capture-group regex on uncontrolled input)
3948
+ const url = c.image_url.url;
3949
+ const sep = url.lastIndexOf(";base64,");
3950
+ if (url.startsWith("data:") && sep > 5 && sep + 8 < url.length) {
3951
+ parts.push({ inlineData: { mimeType: url.slice(5, sep), data: url.slice(sep + 8) } });
3952
+ } else parts.push({ text: `[image: ${url.slice(0, 200)}]` });
3953
+ } else if (c.text) parts.push({ text: String(c.text) });
3954
+ }
3955
+ return parts.length ? parts : [{ text: "" }];
3956
+ }
3957
+ if (typeof content === "object" && (content as any).text) return [{ text: String((content as any).text) }];
3958
+ return [{ text: String(content) }];
3959
+ }
3960
+
3961
+ export function transformRequest(opts: TransformRequestOptions): TransformResult {
3962
+ const {
3963
+ model,
3964
+ messages = [],
3965
+ tools,
3966
+ system,
3967
+ thinkingLevel: explicitThinkingLevel,
3968
+ thinkingBudget: explicitBudget,
3969
+ googleSearchEnabled,
3970
+ generationConfig,
3971
+ sessionSeed,
3972
+ userPromptSeed,
3973
+ pidOffsetEnabled,
3974
+ claudeToolHardening,
3975
+ keepThinking,
3976
+ } = opts;
3977
+
3978
+ const { level: inferredLevel, budget: inferredBudget } = extractThinkingLevel(model, explicitThinkingLevel);
3979
+ const finalLevel = inferredLevel ?? (explicitThinkingLevel as ThinkingLevel | undefined);
3980
+ const finalBudget = explicitBudget ?? inferredBudget ?? (finalLevel ? THINKING_BUDGET_MAP[finalLevel] : undefined);
3981
+
3982
+ // system instruction extraction
3983
+ let sysText = "";
3984
+ if (typeof system === "string") sysText = system;
3985
+ else if (Array.isArray(system)) sysText = system.map((s) => (typeof s === "string" ? s : (s.text ?? ""))).join("\n");
3986
+ else if (system && typeof system === "object" && (system as any).text) sysText = String((system as any).text);
3987
+
3988
+ // also extract system role from messages
3989
+ const filteredMessages: any[] = [];
3990
+ for (const m of messages) {
3991
+ if (!m) continue;
3992
+ if (m.role === "system") {
3993
+ const t =
3994
+ typeof m.content === "string"
3995
+ ? m.content
3996
+ : Array.isArray(m.content)
3997
+ ? m.content.map((c: any) => c.text ?? "").join("\n")
3998
+ : String(m.content ?? "");
3999
+ sysText = sysText ? `${sysText}\n${t}` : t;
4000
+ } else {
4001
+ filteredMessages.push(m);
4002
+ }
4003
+ }
4004
+
4005
+ const systemInstruction: GeminiSystem = sysText ? { parts: [{ text: sysText }] } : undefined;
4006
+
4007
+ const contents: GeminiContent[] = [];
4008
+ for (const m of filteredMessages) {
4009
+ if (!m) continue;
4010
+ const roleRaw = String(m.role ?? "user").toLowerCase();
4011
+ const role: "user" | "model" = roleRaw === "assistant" || roleRaw === "model" ? "model" : "user";
4012
+
4013
+ // tool messages
4014
+ if (roleRaw === "tool" || roleRaw === "function") {
4015
+ const toolName = m.tool_call_id ?? m.name ?? m.toolName ?? "tool";
4016
+ const sanitizedName = sanitizeToolName(String(toolName));
4017
+ const contentStr = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? {});
4018
+ let parsed: any;
4019
+ try {
4020
+ parsed = JSON.parse(contentStr);
4021
+ } catch {
4022
+ parsed = { result: contentStr };
4023
+ }
4024
+ contents.push({
4025
+ role: "user",
4026
+ parts: [{ functionResponse: { name: sanitizedName, response: parsed } }],
4027
+ });
4028
+ continue;
4029
+ }
4030
+
4031
+ // assistant with tool_calls
4032
+ if (role === "model" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
4033
+ const parts: GeminiPart[] = [];
4034
+ const textParts = openAIContentToParts(m.content);
4035
+ for (const tp of textParts) if (tp.text) parts.push(tp);
4036
+ for (const tc of m.tool_calls) {
4037
+ const fn = tc.function ?? tc;
4038
+ const name = sanitizeToolName(String(fn.name ?? tc.name ?? "tool"));
4039
+ let args: any = fn.arguments ?? fn.args ?? {};
4040
+ if (typeof args === "string") {
4041
+ try {
4042
+ args = JSON.parse(args);
4043
+ } catch {
4044
+ args = { raw: args };
4045
+ }
4046
+ }
4047
+ parts.push({ functionCall: { name, args } });
4048
+ }
4049
+ if (parts.length > 0) contents.push({ role, parts });
4050
+ continue;
4051
+ }
4052
+
4053
+ // Anthropic tool_use
4054
+ if (Array.isArray(m.content) && m.content.some((c: any) => c?.type === "tool_use")) {
4055
+ const parts: GeminiPart[] = [];
4056
+ for (const c of m.content) {
4057
+ if (c.type === "text") parts.push({ text: c.text });
4058
+ else if (c.type === "tool_use")
4059
+ parts.push({ functionCall: { name: sanitizeToolName(c.name), args: c.input ?? {} } });
4060
+ }
4061
+ contents.push({ role, parts: parts.length ? parts : [{ text: "" }] });
4062
+ continue;
4063
+ }
4064
+
4065
+ // regular
4066
+ const parts = openAIContentToParts(m.content ?? "");
4067
+ // thinking blocks mapping when model is thinking variant: if parts contain thinking tag? skip for now
4068
+ contents.push({ role, parts: parts.length ? parts : [{ text: "" }] });
4069
+ }
4070
+
4071
+ // tools sanitization + google_search injection
4072
+ let geminiTools: any[] | undefined;
4073
+ if (tools && tools.length > 0) {
4074
+ const { declarations } = validateAndSanitizeTools(tools);
4075
+ if (declarations.length > 0) {
4076
+ // claude_tool_hardening: prefix descriptions so Claude models respect schema constraints
4077
+ const harden = !!claudeToolHardening && model.toLowerCase().includes("claude");
4078
+ geminiTools = [
4079
+ {
4080
+ functionDeclarations: declarations.map((d) => ({
4081
+ name: d.name,
4082
+ description: harden ? `[tool_hardening] ${d.description ?? ""}`.trim() : d.description,
4083
+ parameters: d.parameters,
4084
+ })),
4085
+ },
4086
+ ];
4087
+ }
4088
+ }
4089
+
4090
+ if (googleSearchEnabled) {
4091
+ const modelLower = model.toLowerCase();
4092
+ const isGemini = modelLower.includes("gemini");
4093
+ if (isGemini) {
4094
+ // inject google_search grounding tool as per Gemini spec
4095
+ if (!geminiTools) geminiTools = [];
4096
+ // avoid duplicate
4097
+ const hasSearch = geminiTools.some((t: any) => t.googleSearch || t.google_search);
4098
+ if (!hasSearch) geminiTools.push({ googleSearch: {} });
4099
+ }
4100
+ }
4101
+
4102
+ // generationConfig
4103
+ const genConfig: any = { ...(generationConfig ?? {}) };
4104
+ if (finalBudget) {
4105
+ // Gemini 3 thinking budget
4106
+ genConfig.thinkingConfig = { thinkingBudget: finalBudget, ...(finalLevel ? { thinkingLevel: finalLevel } : {}) };
4107
+ }
4108
+ if (keepThinking) {
4109
+ // keep_thinking: request reasoning back and preserve signatures end-to-end
4110
+ genConfig.thinkingConfig = { ...(genConfig.thinkingConfig ?? {}), includeThoughts: true, keepThinking: true };
4111
+ }
4112
+ // normalize temperature etc
4113
+ if (genConfig.temperature === undefined) genConfig.temperature = 1.0;
4114
+
4115
+ const sessionId = generateSessionId(sessionSeed ?? `${model}-${contents.length}-${Date.now()}`, pidOffsetEnabled);
4116
+ const userPromptId = generateUserPromptId(
4117
+ userPromptSeed ?? `${model}-${sysText.slice(0, 50)}-${Date.now()}`,
4118
+ pidOffsetEnabled,
4119
+ );
4120
+
4121
+ return {
4122
+ contents,
4123
+ systemInstruction,
4124
+ tools: geminiTools,
4125
+ generationConfig: genConfig,
4126
+ sessionId,
4127
+ userPromptId,
4128
+ thinkingBudget: finalBudget,
4129
+ thinkingLevel: finalLevel,
4130
+ };
4131
+ }
4132
+
4133
+ export function buildCloudCodeBody(projectId: string, inner: TransformResult): Record<string, any> {
4134
+ // Gemini CLI exact wrapping: {model, project, request: {contents, systemInstruction, tools, generationConfig, toolConfig?}}
4135
+ // NOTE: session_id / user_prompt_id are metadata ONLY (logs/fingerprint) — the live
4136
+ // cloudcode-pa v1internal:streamGenerateContent schema rejects unknown root fields
4137
+ // (verified live 2026-08-26: "Unknown name \"session_id\": Cannot find field" -> 400).
4138
+ const request: any = {
4139
+ contents: inner.contents,
4140
+ };
4141
+ if (inner.systemInstruction) request.systemInstruction = inner.systemInstruction;
4142
+ if (inner.tools && inner.tools.length > 0) request.tools = inner.tools;
4143
+ if (inner.toolConfig) request.toolConfig = inner.toolConfig;
4144
+ if (inner.generationConfig) request.generationConfig = inner.generationConfig;
4145
+
4146
+ return {
4147
+ model: undefined, // filled by caller wrapper
4148
+ project: projectId,
4149
+ request,
4150
+ };
4151
+ }
4152
+
4153
+ export interface BuildOptions {
4154
+ model: string;
4155
+ messages?: any[] | undefined;
4156
+ tools?: any[] | undefined;
4157
+ system?: any | undefined;
4158
+ thinkingLevel?: string | undefined;
4159
+ thinkingBudget?: number | undefined;
4160
+ googleSearchEnabled?: boolean | undefined;
4161
+ generationConfig?: any | undefined;
4162
+ accessToken: string;
4163
+ projectId: string;
4164
+ endpointOverrides?: string[] | undefined;
4165
+ userAgent?: string | undefined;
4166
+ antigravityVersion?: string | undefined;
4167
+ methodSuffix?: string | undefined;
4168
+ sessionSeed?: string | undefined;
4169
+ userPromptSeed?: string | undefined;
4170
+ pidOffsetEnabled?: boolean | undefined;
4171
+ claudeToolHardening?: boolean | undefined;
4172
+ keepThinking?: boolean | undefined;
4173
+ }
4174
+
4175
+ export interface BuildResult {
4176
+ url: string;
4177
+ endpoints: string[];
4178
+ baseEndpoints: string[];
4179
+ headers: Record<string, string>;
4180
+ body: Record<string, any>;
4181
+ sessionId: string;
4182
+ userPromptId: string;
4183
+ isGeminiCLIOnly: boolean;
4184
+ method: string;
4185
+ innerRequest: TransformResult;
4186
+ }
4187
+
4188
+ /**
4189
+ * Shared prologue of {@link buildAntigravityRequest} and
4190
+ * {@link buildGeminiCLIRequest}: validates the common build options, resolves
4191
+ * the fallback project id and runs the 16-field {@link transformRequest}
4192
+ * call. v2.1.16 pure refactor — the two builders previously carried a
4193
+ * byte-identical 25-line copy of this block; wire bytes and thrown error
4194
+ * messages are unchanged.
4195
+ *
4196
+ * @param options - shared build options (model, messages, tools, seeds, ...)
4197
+ * @param label - error-message suffix ("" for the antigravity variant,
4198
+ * " for Gemini CLI" for the gemini-cli variant)
4199
+ */
4200
+ function prepareBuildOptions(
4201
+ options: BuildOptions,
4202
+ label = "",
4203
+ ): { projectId: string; token: string; model: string; inner: TransformResult } {
4204
+ if (!options) throw new Error(`build options required${label}`);
4205
+ const projectId = String(options.projectId ?? "").trim() || FALLBACK_PROJECT_ID;
4206
+ const token = String(options.accessToken ?? "").trim();
4207
+ if (!token) throw new Error("accessToken required");
4208
+ const model = String(options.model ?? "").trim();
4209
+ if (!model) throw new Error(`model required${label}`);
4210
+
4211
+ const inner = transformRequest({
4212
+ model: options.model,
4213
+ messages: options.messages,
4214
+ tools: options.tools,
4215
+ system: options.system,
4216
+ thinkingLevel: options.thinkingLevel,
4217
+ thinkingBudget: options.thinkingBudget,
4218
+ googleSearchEnabled: options.googleSearchEnabled,
4219
+ generationConfig: options.generationConfig,
4220
+ sessionSeed: options.sessionSeed,
4221
+ userPromptSeed: options.userPromptSeed,
4222
+ projectId,
4223
+ pidOffsetEnabled: options.pidOffsetEnabled,
4224
+ claudeToolHardening: options.claudeToolHardening,
4225
+ keepThinking: options.keepThinking,
4226
+ });
4227
+
4228
+ return { projectId, token, model, inner };
4229
+ }
4230
+
4231
+ export function buildAntigravityRequest(options: BuildOptions): BuildResult {
4232
+ const { projectId, token, model, inner } = prepareBuildOptions(options);
4233
+
4234
+ const bases = getEndpointsForModel(model, options.endpointOverrides);
4235
+ const fullEndpoints = bases.map((b) => `${stripTrailingSlashes(b)}${options.methodSuffix ?? STREAM_METHOD}`);
4236
+
4237
+ const version = options.antigravityVersion ?? ANTIGRAVITY_VERSION_FALLBACK;
4238
+ const headers = buildCommonHeaders({ accessToken: token, userAgent: options.userAgent, version }, "antigravity");
4239
+
4240
+ const bodyWrapper = buildCloudCodeBody(projectId, inner);
4241
+ // bodyWrapper.model set here
4242
+ bodyWrapper.model = model;
4243
+ // keep request key as spec requires {model, project, request}
4244
+ // session_id / user_prompt_id stay OUT of the wire body (schema-verified) —
4245
+ // they remain available as BuildResult.sessionId / .userPromptId metadata.
4246
+ const body = {
4247
+ model: bodyWrapper.model,
4248
+ project: bodyWrapper.project,
4249
+ request: bodyWrapper.request,
4250
+ };
4251
+
4252
+ return {
4253
+ url: fullEndpoints[0]!,
4254
+ endpoints: fullEndpoints,
4255
+ baseEndpoints: bases,
4256
+ headers,
4257
+ body,
4258
+ sessionId: inner.sessionId,
4259
+ userPromptId: inner.userPromptId,
4260
+ isGeminiCLIOnly: isGeminiCLIOnlyModel(model),
4261
+ method: "POST",
4262
+ innerRequest: inner,
4263
+ };
4264
+ }
4265
+
4266
+ export function buildGeminiCLIRequest(options: BuildOptions): BuildResult {
4267
+ const { projectId, token, model, inner } = prepareBuildOptions(options, " for Gemini CLI");
4268
+
4269
+ const rawBases = getEndpointsForModel(model, options.endpointOverrides);
4270
+ const filteredBases = rawBases.filter((b) => !b.toLowerCase().includes("sandbox"));
4271
+ const bases = filteredBases.length > 0 ? filteredBases : [CODE_ASSIST_ENDPOINTS.PROD];
4272
+ const fullEndpoints = bases.map((b) => `${stripTrailingSlashes(b)}${options.methodSuffix ?? STREAM_METHOD}`);
4273
+
4274
+ const version = options.antigravityVersion ?? ANTIGRAVITY_VERSION_FALLBACK;
4275
+ const headers = buildCommonHeaders({ accessToken: token, userAgent: options.userAgent, version }, "gemini-cli");
4276
+
4277
+ const bodyWrapper = buildCloudCodeBody(projectId, inner);
4278
+ // session_id / user_prompt_id stay OUT of the wire body (schema-verified live:
4279
+ // cloudcode-pa rejects unknown root fields with 400 INVALID_ARGUMENT) —
4280
+ // they remain as BuildResult metadata for logging/fingerprinting.
4281
+ const body = {
4282
+ model,
4283
+ project: projectId,
4284
+ request: bodyWrapper.request,
4285
+ };
4286
+
4287
+ return {
4288
+ url: fullEndpoints[0]!,
4289
+ endpoints: fullEndpoints,
4290
+ baseEndpoints: bases,
4291
+ headers,
4292
+ body,
4293
+ sessionId: inner.sessionId,
4294
+ userPromptId: inner.userPromptId,
4295
+ isGeminiCLIOnly: true,
4296
+ method: "POST",
4297
+ innerRequest: inner,
4298
+ };
4299
+ }
4300
+
4301
+ export async function fetchWithCascade(
4302
+ buildResult: BuildResult,
4303
+ fetchOptions?: { signal?: AbortSignal; timeoutMs?: number },
4304
+ ): Promise<Response> {
4305
+ if (!buildResult || !Array.isArray(buildResult.endpoints) || buildResult.endpoints.length === 0)
4306
+ throw new Error("buildResult with endpoints required");
4307
+ let lastError: Error | null = null;
4308
+
4309
+ for (const endpointUrl of buildResult.endpoints) {
4310
+ const controller = new AbortController();
4311
+ const timeoutMs = fetchOptions?.timeoutMs ?? CONTENT_TIMEOUT_MS;
4312
+ const to = setTimeout(() => controller.abort(), timeoutMs);
4313
+
4314
+ const signal = fetchOptions?.signal
4315
+ ? (() => {
4316
+ const combined = new AbortController();
4317
+ const onExt = () => combined.abort();
4318
+ fetchOptions.signal!.addEventListener("abort", onExt, { once: true });
4319
+ controller.signal.addEventListener("abort", () => combined.abort());
4320
+ return combined.signal;
4321
+ })()
4322
+ : controller.signal;
4323
+
4324
+ try {
4325
+ const jitter = getJitter();
4326
+ if (jitter > 0) await new Promise((r) => setTimeout(r, jitter));
4327
+
4328
+ const headers = stripForbiddenHeaders(buildResult.headers);
4329
+
4330
+ const res = await fetch(endpointUrl, {
4331
+ method: buildResult.method,
4332
+ headers,
4333
+ body: JSON.stringify(buildResult.body),
4334
+ signal,
4335
+ });
4336
+
4337
+ if (res.status === 403 || res.status === 404 || res.status >= 500) {
4338
+ // Permanent entitlement/region denials must surface immediately with
4339
+ // Google's own message — retrying other endpoints/accounts only burns
4340
+ // minutes and hides the real cause (verified live: UNSUPPORTED_LOCATION).
4341
+ if (res.status === 403) {
4342
+ const probe = res.clone();
4343
+ const bodyTxt = await probe.text().catch(() => "");
4344
+ const low = bodyTxt.toLowerCase();
4345
+ if (low.includes("location") || low.includes("not eligible") || low.includes("unsupported_location")) {
4346
+ clearTimeout(to);
4347
+ return res;
4348
+ }
4349
+ }
4350
+ lastError = new Error(`cascade retryable status ${res.status} at ${endpointUrl}`);
4351
+ continue;
4352
+ }
4353
+ clearTimeout(to);
4354
+ return res;
4355
+ } catch (e: any) {
4356
+ clearTimeout(to);
4357
+ lastError = e instanceof Error ? e : new Error(String(e));
4358
+ if (fetchOptions?.signal?.aborted) throw lastError;
4359
+ } finally {
4360
+ clearTimeout(to);
4361
+ }
4362
+ }
4363
+ throw lastError ?? new Error("all endpoints failed in cascade");
4364
+ }
4365
+
4366
+ export const BYPASS_CONSTANTS = {
4367
+ directEndpoint: DIRECT_ENDPOINT,
4368
+ dailyEndpoint: DAILY_ENDPOINT,
4369
+ sandboxEndpoint: AUTOPUSH_ENDPOINT,
4370
+ endpointCascade: ENDPOINT_CASCADE,
4371
+ headersToStrip: HEADERS_TO_STRIP,
4372
+ toolNamePattern: TOOL_NAME_REGEX.source,
4373
+ maxToolNameLength: TOOL_NAME_MAX_LENGTH,
4374
+ maxToolsPerRequest: MAX_TOOLS_PER_REQUEST,
4375
+ thinkingBudgetMap: THINKING_BUDGET_MAP,
4376
+ fnvOffsetBasis: FNV_OFFSET_BASIS,
4377
+ fnvPrime: FNV_PRIME,
4378
+ } as const;
4379
+
4380
+ // ===========================================================================
4381
+ // v2.1.15 Phase D: Cloud Code method-path maps moved from fingerprint.ts and
4382
+ // quota.ts — the API surface lives only in request.ts. (The bare ENDPOINTS
4383
+ // name is the constants.js host map; these method maps are API_PATHS.)
4384
+ // ===========================================================================
4385
+
4386
+ /** Full Cloud Code v1internal method URLs on the production base. */
4387
+ export const API_PATHS = {
4388
+ loadCodeAssist: `${CODE_ASSIST_BASE_URL}${CODE_ASSIST_PATH_MAP.LOAD_CODE_ASSIST}`,
4389
+ onboardUser: `${CODE_ASSIST_BASE_URL}${CODE_ASSIST_PATH_MAP.ONBOARD_USER}`,
4390
+ fetchAvailableModels: `${CODE_ASSIST_BASE_URL}${CODE_ASSIST_PATH_MAP.FETCH_AVAILABLE_MODELS}`,
4391
+ generateContent: `${CODE_ASSIST_BASE_URL}${CODE_ASSIST_PATH_MAP.GENERATE_CONTENT}`,
4392
+ streamGenerateContent: `${CODE_ASSIST_BASE_URL}${CODE_ASSIST_PATH_MAP.STREAM_GENERATE_CONTENT}`,
4393
+ retrieveUserQuotaSummary: `${CODE_ASSIST_BASE_URL}${CODE_ASSIST_PATH_MAP.RETRIEVE_USER_QUOTA_SUMMARY}`,
4394
+ } as const;
4395
+
4396
+ /** Daily-build method URLs (quota lineage: antigravity quota reads the daily base). */
4397
+ export const API_PATHS_DAILY = {
4398
+ loadCodeAssist: `${CLOUDCODE_DAILY_BASE}${CODE_ASSIST_PATH_MAP.LOAD_CODE_ASSIST}`,
4399
+ retrieveUserQuotaSummary: `${CLOUDCODE_DAILY_BASE}${CODE_ASSIST_PATH_MAP.RETRIEVE_USER_QUOTA_SUMMARY}`,
4400
+ } as const;
4401
+
4402
+ export const RequestHelpers = {
4403
+ // constants
4404
+ GEMINI_CLI_OAUTH_CLIENT_ID,
4405
+ GEMINI_CLI_OAUTH_CLIENT_SECRET,
4406
+ GEMINI_CLI_USER_AGENT,
4407
+ X_GOOG_API_CLIENT,
4408
+ CLIENT_METADATA_RAW,
4409
+ CLOUDCODE_PA_BASE,
4410
+ PROJECT_FALLBACK,
4411
+ MODELS_2026: FETCH_AVAILABLE_MODELS_2026,
4412
+
4413
+ // core
4414
+ fnv1a32,
4415
+ fnv1a64BigInt,
4416
+ buildUserPromptId,
4417
+ buildSessionIdFromDir,
4418
+ isLikelyAntigravityOnlyModel,
4419
+ cleanToolSchemaTolerant,
4420
+ cleanToolSchemas,
4421
+ validateToolNamesDetailed,
4422
+ sanitizeToolName,
4423
+ filterThinkingBlocks,
4424
+ normalizeMessages,
4425
+ buildGenerationConfig,
4426
+ enhanceAgySdkErrorResponse,
4427
+ extractVariants,
4428
+ extractVariantsFromAny,
4429
+
4430
+ // mappings
4431
+ THINKING_LEVEL_TO_BUDGET_GEMINI,
4432
+ THINKING_LEVEL_TO_BUDGET_CLAUDE,
4433
+
4434
+ // merged: FNV + deterministic ids
4435
+ FNV_OFFSET_BASIS,
4436
+ FNV_PRIME,
4437
+ fnv1aHex,
4438
+ fnv1aHex16,
4439
+ fnv1aHash,
4440
+ generateDeterministicId,
4441
+ generateSessionId,
4442
+ generateUserPromptId,
4443
+
4444
+ // merged: thinking budgets
4445
+ THINKING_BUDGET_MAP,
4446
+ THINKING_LEVELS,
4447
+ THINKING_BUDGET_MIN,
4448
+ THINKING_BUDGET_MAX,
4449
+ THINKING_BUDGET_MAX_GEMINI,
4450
+ THINKING_BUDGET_MAX_CLAUDE,
4451
+ parseThinkingLevel,
4452
+ mapThinkingLevelToBudget,
4453
+ thinkingLevelToBudget,
4454
+ getThinkingBudgetForVariant,
4455
+
4456
+ // merged: model detection
4457
+ isGeminiCLIOnlyModel,
4458
+ extractModelVariant,
4459
+ parseModelVariant,
4460
+
4461
+ // merged: tool names and schema cleaning
4462
+ TOOL_NAME_REGEX,
4463
+ TOOL_NAME_MIN_LENGTH,
4464
+ TOOL_NAME_MAX_LENGTH,
4465
+ MAX_TOOLS_PER_REQUEST,
4466
+ ALLOWED_SCHEMA_KEYS,
4467
+ ALLOWED_TOOL_TOP_KEYS,
4468
+ validateToolName,
4469
+ cleanJsonSchemaStrict,
4470
+ cleanRawToolSchema,
4471
+ cleanToolsArray,
4472
+
4473
+ // merged: message normalization and thinking filters
4474
+ normalizeRole,
4475
+ normalizeContentToParts,
4476
+ normalizeIncomingMessages,
4477
+ isThinkingBlock,
4478
+ filterThinkingBlocksAuto,
4479
+ filterThinkingBlocksFromParts,
4480
+ filterThinkingBlocksFromMessages,
4481
+
4482
+ // merged: transport helpers
4483
+ DIRECT_ENDPOINT,
4484
+ DAILY_ENDPOINT,
4485
+ AUTOPUSH_ENDPOINT,
4486
+ ENDPOINT_CASCADE,
4487
+ HEADERS_TO_STRIP,
4488
+ BYPASS_CONSTANTS,
4489
+ isRetryableStatus,
4490
+ stripForbiddenHeaders,
4491
+
4492
+ // merged: error type
4493
+ RequestHelpersError,
4494
+ } as const;
4495
+
4496
+ export default RequestPipeline;