pi-antigravity-rotator 1.12.2 → 1.12.4

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 (3) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +1 -1
  3. package/src/compat.ts +221 -47
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.12.4] - 2026-05-18
4
+
5
+ ### Added
6
+ - **Claude `cache_control` stripping**: Anthropic requests often include `cache_control` objects which Google Cloud Code Assist API rejects with "Extra inputs are not permitted". The proxy now safely strips `cache_control` from all system and message content blocks before forwarding them to Gemini.
7
+ - **Claude `VALIDATED` Function Calling**: Automatically enforces `toolConfig: { functionCallingConfig: { mode: "VALIDATED" } }` for Claude models when tools are present, ensuring stricter schema adherence.
8
+ - **Adaptive Thinking Budgets**: Replaced static thinking budget values with a dynamic `MODEL_SPECS` mapping. `gemini-3-flash` now correctly uses adaptive thinking budgets (`-1`) which allows the model to decide its own optimal reasoning length, while Pro models use strict budgets (e.g. `10001` for high).
9
+ - **Max Output Tokens Enforcement**: The proxy now enforces hard `maxOutputTokens` caps based on the specific model's upper limits (e.g. `65535` vs `64000`), dynamically adjusting them to ensure there is enough room for both the thinking budget and the final output response without triggering upstream validation errors.
10
+
11
+ ## [1.12.3] - 2026-05-18
12
+
13
+ ### Fixed
14
+ - **Gemini 3.1 Pro High Deprecation (`400 Invalid Argument`)**: Google Cloud Code Assist deprecated the internal string `"gemini-3.1-pro-high"` and replaced it with `"gemini-pro-agent"`. The proxy now automatically maps `"gemini-3.1-pro-high"` to `"gemini-pro-agent"` under the hood when constructing the upstream payload, preventing `400` validation errors while allowing clients to continue using the `-high` alias.
15
+ - **Missing `thought_signature` on Tool Calls (`400 Invalid Argument`)**: Gemini thinking models strictly require a cryptographic Base64 `thought_signature` for all `functionCall` history parts, which the proxy normally caches in RAM. To prevent API rejection on cache misses (e.g. after a proxy restart or when using synthetic tool IDs), the proxy now gracefully collapses the orphaned tool exchange into a neutral user summary (`[Context: The assistant used tools...]`). This preserves the conversation context without triggering the `400` error or teaching the model bad tool-calling formats.
16
+
3
17
  ## [1.12.2] - 2026-05-18
4
18
 
5
19
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "1.12.2",
3
+ "version": "1.12.4",
4
4
  "description": "Multi-account rotation proxy for Google Antigravity with per-model routing, real-time quota tracking, and infringement detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/compat.ts CHANGED
@@ -86,6 +86,64 @@ export interface CompatCompletion {
86
86
  toolCalls?: OpenAIToolCall[];
87
87
  }
88
88
 
89
+ // ---------------------------------------------------------------------------
90
+ // Model-specific specs — mirrors Antigravity-Manager model_specs.json
91
+ // ---------------------------------------------------------------------------
92
+ interface ModelSpec {
93
+ maxOutputTokens: number;
94
+ thinkingBudget: number; // -1 = adaptive (model decides), >=0 = fixed
95
+ isThinking: boolean;
96
+ }
97
+ const MODEL_SPECS: Record<string, ModelSpec> = {
98
+ "gemini-pro-agent": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
99
+ "gemini-3-flash-agent": { maxOutputTokens: 65536, thinkingBudget: -1, isThinking: true },
100
+ "gemini-3-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
101
+ "gemini-3-pro-low": { maxOutputTokens: 65535, thinkingBudget: 1001, isThinking: true },
102
+ "gemini-3.1-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
103
+ "gemini-3.1-pro-low": { maxOutputTokens: 65535, thinkingBudget: 1001, isThinking: true },
104
+ "gemini-3.1-pro-preview": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
105
+ "gemini-3-flash": { maxOutputTokens: 65536, thinkingBudget: 32768, isThinking: true },
106
+ "gemini-2.5-flash": { maxOutputTokens: 65535, thinkingBudget: 24576, isThinking: true },
107
+ "gemini-2.5-pro": { maxOutputTokens: 65535, thinkingBudget: 1024, isThinking: true },
108
+ "claude-sonnet-4-6": { maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
109
+ "claude-sonnet-4-6-thinking":{ maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
110
+ "claude-opus-4-6-thinking": { maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
111
+ };
112
+ const GEMINI_MAX_OUTPUT_TOKENS = 65536;
113
+ const CLAUDE_MAX_OUTPUT_TOKENS = 64000;
114
+ const FALLBACK_THINKING_BUDGET = 24576;
115
+ const CLAUDE_DEFAULT_THINKING_BUDGET = 32768;
116
+
117
+ function getModelFamily(model: string): "claude" | "gemini" | "unknown" {
118
+ const l = model.toLowerCase();
119
+ if (l.includes("claude")) return "claude";
120
+ if (l.includes("gemini")) return "gemini";
121
+ return "unknown";
122
+ }
123
+
124
+ function getModelSpec(model: string): ModelSpec {
125
+ const lower = model.toLowerCase();
126
+ if (MODEL_SPECS[lower]) return MODEL_SPECS[lower];
127
+ for (const [key, spec] of Object.entries(MODEL_SPECS)) {
128
+ if (lower.includes(key)) return spec;
129
+ }
130
+ const family = getModelFamily(model);
131
+ if (family === "claude") return { maxOutputTokens: CLAUDE_MAX_OUTPUT_TOKENS, thinkingBudget: CLAUDE_DEFAULT_THINKING_BUDGET, isThinking: true };
132
+ if (family === "gemini") return { maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS, thinkingBudget: FALLBACK_THINKING_BUDGET, isThinking: true };
133
+ return { maxOutputTokens: 65536, thinkingBudget: FALLBACK_THINKING_BUDGET, isThinking: false };
134
+ }
135
+
136
+ function isThinkingModel(model: string): boolean {
137
+ const spec = getModelSpec(model);
138
+ if (spec.isThinking) return true;
139
+ const l = model.toLowerCase();
140
+ if (l.includes("gemini")) {
141
+ const m = l.match(/gemini-(\d+)/);
142
+ if (m && parseInt(m[1], 10) >= 3) return true;
143
+ }
144
+ return false;
145
+ }
146
+
89
147
  type AntigravityPart = { text: string } | { inlineData: { mimeType: string; data: string } };
90
148
 
91
149
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -120,12 +178,29 @@ function cacheThoughtSignature(callId: string, signature: string): void {
120
178
  thoughtSignatureCache.set(callId, signature);
121
179
  }
122
180
 
181
+ /**
182
+ * Strip cache_control fields from content blocks.
183
+ * Cloud Code API rejects cache_control with "Extra inputs are not permitted".
184
+ */
185
+ function cleanCacheControl<T>(content: T): T {
186
+ if (!Array.isArray(content)) return content;
187
+ return content.map((block: Record<string, unknown>) => {
188
+ if (!block || typeof block !== "object") return block;
189
+ if ("cache_control" in block) {
190
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
191
+ const { cache_control: _cc, ...rest } = block;
192
+ return rest;
193
+ }
194
+ return block;
195
+ }) as T;
196
+ }
197
+
123
198
  function extractText(content: ChatMessage["content"]): string {
124
199
  if (typeof content === "string") return content;
125
200
  if (!Array.isArray(content)) return "";
126
- return content
127
- .filter((p) => (p.type === "text" && typeof p.text === "string") || (p.type === "thinking" && typeof p.thinking === "string"))
128
- .map((p) => p.type === "thinking" ? `[Thinking]\n${p.thinking}\n[/Thinking]` : p.text)
201
+ return cleanCacheControl(content)
202
+ .filter((p: { type?: string; text?: string; thinking?: string }) => (p.type === "text" && typeof p.text === "string") || (p.type === "thinking" && typeof p.thinking === "string"))
203
+ .map((p: { type?: string; text?: string; thinking?: string }) => p.type === "thinking" ? `[Thinking]\n${p.thinking}\n[/Thinking]` : (p.text as string))
129
204
  .join("\n");
130
205
  }
131
206
 
@@ -246,12 +321,15 @@ function sanitizeClaudeViaGeminiSchema(schema: unknown): unknown {
246
321
  if (!isRecord(schema)) return schema;
247
322
 
248
323
  // Only remove fields that Gemini's API layer truly rejects at the network level.
249
- // We keep Draft 2020-12 keywords like minimum/maximum/pattern/title/etc.
324
+ // We keep standard Draft 2020-12 keywords but must strip exclusiveMinimum/exclusiveMaximum
325
+ // as boolean values (Draft 4) — the API layer rejects them even for Claude-bound requests.
250
326
  const UNSUPPORTED = new Set([
251
327
  "$schema", "$id", "$ref", "$defs", "definitions",
252
328
  "if", "then", "else", "not",
253
329
  "patternProperties", "unevaluatedProperties", "unevaluatedItems",
254
330
  "contentEncoding", "contentMediaType",
331
+ // Gemini's protobuf layer rejects these regardless of target model
332
+ "exclusiveMinimum", "exclusiveMaximum",
255
333
  ]);
256
334
 
257
335
  const out: Record<string, unknown> = {};
@@ -411,10 +489,41 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
411
489
  // Determine if model is Claude — affects schema sanitization and tool call ID handling
412
490
  const isClaude = /^claude-/i.test(input.model);
413
491
 
492
+ // Use model specs to determine thinking support
493
+ const isThinking = isThinkingModel(input.model);
494
+ const isGeminiThinking = !isClaude && isThinking;
495
+
414
496
  const contents: GeminiContent[] = [];
415
497
  for (let i = 0; i < conversationMessages.length; i++) {
416
498
  const msg = conversationMessages[i];
417
499
  if (msg.role === "assistant") {
500
+ // Check if this is a thinking model turn with tool calls that have no cached signatures.
501
+ // If so, we collapse the tool exchange into a neutral user summary instead of
502
+ // injecting [Tool call: ...] text that the model will learn to mimic.
503
+ const hasMissingSig =
504
+ isGeminiThinking &&
505
+ Array.isArray(msg.tool_calls) &&
506
+ msg.tool_calls.length > 0 &&
507
+ !thoughtSignatureCache.has(msg.tool_calls[0].id);
508
+
509
+ if (hasMissingSig) {
510
+ // Build a summary of what the model did and what results came back.
511
+ // We collect the paired tool result(s) from the immediately following messages.
512
+ const toolNames = msg.tool_calls!.map((tc) => tc.function.name).join(", ");
513
+ const resultParts: string[] = [];
514
+ while (i + 1 < conversationMessages.length && conversationMessages[i + 1].role === "tool") {
515
+ i++;
516
+ const toolMsg = conversationMessages[i];
517
+ const toolText = typeof toolMsg.content === "string" ? toolMsg.content : extractText(toolMsg.content);
518
+ resultParts.push(`${toolMsg.name || "tool"}: ${toolText.slice(0, 500)}`);
519
+ }
520
+ const summaryText = `[Context: The assistant used tools (${toolNames}) and received results:\n${resultParts.join("\n")}]`;
521
+ contents.push({ role: "user", parts: [{ text: summaryText }] });
522
+ // Add a minimal model acknowledgement to avoid consecutive user turns
523
+ contents.push({ role: "model", parts: [{ text: "Understood, I have the tool results." }] });
524
+ continue;
525
+ }
526
+
418
527
  const parts: unknown[] = [];
419
528
  if (msg.content) {
420
529
  const textContent = typeof msg.content === "string" ? msg.content : extractText(msg.content);
@@ -427,28 +536,24 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
427
536
  // signatures on older historical turns are silently ignored.
428
537
  let isFirstInMessage = true;
429
538
  for (const tc of msg.tool_calls) {
539
+ let args: unknown;
430
540
  try {
431
- const args = typeof tc.function.arguments === "string" ? JSON.parse(tc.function.arguments) : tc.function.arguments;
432
- // Only the first functionCall part in a model turn needs the signature
433
- const cachedSig = isFirstInMessage ? thoughtSignatureCache.get(tc.id) : undefined;
434
- parts.push({
435
- ...(cachedSig ? { thoughtSignature: cachedSig } : {}),
436
- // Include id only for Claude — Gemini native models reject the id field
437
- functionCall: { ...(isClaude ? { id: tc.id } : {}), name: tc.function.name, args },
438
- });
541
+ args = typeof tc.function.arguments === "string" ? JSON.parse(tc.function.arguments) : tc.function.arguments;
439
542
  } catch {
440
- const cachedSig = isFirstInMessage ? thoughtSignatureCache.get(tc.id) : undefined;
441
- parts.push({
442
- ...(cachedSig ? { thoughtSignature: cachedSig } : {}),
443
- functionCall: { ...(isClaude ? { id: tc.id } : {}), name: tc.function.name, args: {} },
444
- });
543
+ args = {};
445
544
  }
545
+ // Only the first functionCall part in a model turn needs the signature
546
+ const cachedSig = isFirstInMessage ? thoughtSignatureCache.get(tc.id) : undefined;
547
+ parts.push({
548
+ ...(cachedSig ? { thoughtSignature: cachedSig } : {}),
549
+ // Include id only for Claude — Gemini native models reject the id field
550
+ functionCall: { ...(isClaude ? { id: tc.id } : {}), name: tc.function.name, args },
551
+ });
446
552
  isFirstInMessage = false;
447
553
  }
448
554
  }
449
555
  if (parts.length > 0) contents.push({ role: "model", parts });
450
556
  } else if (msg.role === "tool") {
451
- const prevMsg = conversationMessages[i - 1];
452
557
  const responseText = typeof msg.content === "string" ? msg.content : extractText(msg.content);
453
558
  const fnName = msg.name || "unknown";
454
559
  // Include tool_call_id so Gemini can pass it as tool_use_id to Claude
@@ -460,6 +565,7 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
460
565
  } else {
461
566
  // user message
462
567
  const msgParts = extractParts(msg.content);
568
+
463
569
  if (msgParts.length > 0) contents.push({ role: "user", parts: msgParts });
464
570
  }
465
571
  }
@@ -472,35 +578,84 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
472
578
  const geminiTools = convertOpenAIToolsToGemini(inputTools, isClaude);
473
579
  const geminiToolConfig = input.tool_choice !== undefined ? convertToolChoiceToGemini(input.tool_choice) : undefined;
474
580
 
475
- // Map OpenAI reasoning_effort Gemini thinkingLevel
476
- const thinkingLevel = mapReasoningEffortToThinkingLevel(input.reasoning_effort, input.model);
581
+ // Cap maxOutputTokens to model limits and build thinkingConfig
582
+ const modelSpec = getModelSpec(input.model);
583
+ const modelFamily = getModelFamily(input.model);
584
+ let maxOutputTokens = typeof input.max_tokens === "number" ? input.max_tokens : undefined;
585
+ if (maxOutputTokens && maxOutputTokens > modelSpec.maxOutputTokens) {
586
+ compatLogger.debug(`Capping ${input.model} maxOutputTokens ${maxOutputTokens} → ${modelSpec.maxOutputTokens}`);
587
+ maxOutputTokens = modelSpec.maxOutputTokens;
588
+ }
589
+
590
+ let thinkingConfigObj: Record<string, unknown> | undefined;
591
+ if (modelFamily === "claude" && isThinking) {
592
+ // Claude: snake_case keys required by v1internal
593
+ const tb = modelSpec.thinkingBudget;
594
+ thinkingConfigObj = { include_thoughts: true, thinking_budget: tb };
595
+ if (!maxOutputTokens || maxOutputTokens <= tb) {
596
+ maxOutputTokens = Math.min(tb + 8192, modelSpec.maxOutputTokens);
597
+ compatLogger.debug(`Adjusted Claude maxOutputTokens → ${maxOutputTokens}`);
598
+ }
599
+ } else if (isThinking) {
600
+ // Gemini: camelCase keys; thinkingBudget=-1 means adaptive (omit the field)
601
+ const tb = modelSpec.thinkingBudget;
602
+ thinkingConfigObj = tb === -1
603
+ ? { includeThoughts: true }
604
+ : { includeThoughts: true, thinkingBudget: tb };
605
+ if (tb !== -1 && (!maxOutputTokens || maxOutputTokens <= tb)) {
606
+ maxOutputTokens = Math.min(tb + 8192, modelSpec.maxOutputTokens);
607
+ compatLogger.debug(`Adjusted Gemini maxOutputTokens → ${maxOutputTokens}`);
608
+ }
609
+ } else if (input.reasoning_effort) {
610
+ // Non-thinking models with explicit reasoning_effort hint
611
+ const budgets: Record<string, number> = { low: Math.round(modelSpec.thinkingBudget / 4), medium: Math.round(modelSpec.thinkingBudget / 2), high: modelSpec.thinkingBudget };
612
+ const b = budgets[input.reasoning_effort.toLowerCase()];
613
+ if (b) thinkingConfigObj = { includeThoughts: true, thinkingBudget: b };
614
+ }
615
+
616
+ const generationConfig: Record<string, unknown> = {
617
+ ...(typeof input.temperature === "number" ? { temperature: input.temperature } : {}),
618
+ ...(maxOutputTokens ? { maxOutputTokens } : {}),
619
+ ...(thinkingConfigObj ? { thinkingConfig: thinkingConfigObj } : {}),
620
+ };
477
621
 
478
622
  const request: Record<string, unknown> = {
479
623
  contents,
480
- generationConfig: {
481
- ...(typeof input.temperature === "number" ? { temperature: input.temperature } : {}),
482
- ...(typeof input.max_tokens === "number" ? { maxOutputTokens: input.max_tokens } : {}),
483
- // Always request thought blocks. Models that don't support thinking ignore this.
484
- thinkingConfig: {
485
- includeThoughts: true,
486
- ...(thinkingLevel ? { thinkingLevel } : {}),
487
- },
488
- },
624
+ generationConfig,
489
625
  };
490
626
 
491
627
  if (systemParts.length > 0) {
492
- request.systemInstruction = {
493
- role: "user",
494
- parts: [{ text: systemParts.join("\n\n") }],
495
- };
628
+ if (!isClaude && isThinking) {
629
+ // Gemini thinking models (gemini-3.1-pro-high/low) reject the systemInstruction
630
+ // field entirely — prepend system prompt to the first user content turn instead.
631
+ const firstTurn = contents[0];
632
+ if (firstTurn && firstTurn.role === "user" && (firstTurn.parts[0] as any)?.text !== undefined) {
633
+ (firstTurn.parts[0] as any).text = systemParts.join("\n\n") + "\n\n" + (firstTurn.parts[0] as any).text;
634
+ } else if (firstTurn && firstTurn.role === "user") {
635
+ firstTurn.parts.unshift({ text: systemParts.join("\n\n") + "\n\n" });
636
+ } else {
637
+ contents.unshift({
638
+ role: "user",
639
+ parts: [{ text: systemParts.join("\n\n") }],
640
+ });
641
+ }
642
+ } else {
643
+ request.systemInstruction = {
644
+ role: "system",
645
+ parts: [{ text: systemParts.join("\n\n") }],
646
+ };
647
+ }
496
648
  }
497
649
 
498
650
  if (geminiTools.length > 0) request.tools = geminiTools;
499
651
  if (geminiToolConfig) request.toolConfig = geminiToolConfig;
500
652
 
653
+ let mappedModel = input.model;
654
+ if (mappedModel === "gemini-3.1-pro-high") mappedModel = "gemini-pro-agent";
655
+
501
656
  return {
502
657
  project: "compat-placeholder",
503
- model: input.model,
658
+ model: mappedModel,
504
659
  userAgent: "antigravity",
505
660
  requestType: "agent",
506
661
  request,
@@ -522,28 +677,47 @@ export function anthropicToAntigravityBody(input: AnthropicMessagesRequest): Req
522
677
  }
523
678
 
524
679
  /**
525
- * Maps an OpenAI reasoning_effort string to a Gemini thinkingLevel.
526
- * Gemini 3 Pro only supports LOW and HIGH; Flash supports MINIMAL/LOW/MEDIUM/HIGH.
680
+ * Maps an OpenAI reasoning_effort / model name suffix to a Gemini thinkingBudget integer.
681
+ * Cloud Code Assist uses thinkingBudget (integer token count), not thinkingLevel (string).
682
+ * Values match models.json: -high=10001, -low=1001, flash=dynamic(-1 means dynamic).
683
+ * Returns undefined for models that don't need an explicit budget (e.g. Claude, plain flash).
527
684
  */
528
- function mapReasoningEffortToThinkingLevel(effort: string | undefined, modelId: string): string | undefined {
529
- const isGemini3Pro = /gemini-3(?:\.1)?-pro/i.test(modelId);
530
-
685
+ function mapReasoningEffortToThinkingLevel(effort: string | undefined, modelId: string): number | undefined {
686
+ const lowerModel = modelId.toLowerCase();
687
+ const isGemini31Pro = /gemini-3\.1-pro/i.test(modelId);
688
+ const isGemini3Flash = lowerModel.includes("gemini-3-flash");
689
+
531
690
  let effectiveEffort = effort;
532
691
  if (!effectiveEffort) {
533
- const lowerModel = modelId.toLowerCase();
534
- if (lowerModel.endsWith("-high") || lowerModel.includes("claude-")) effectiveEffort = "high";
692
+ if (lowerModel.endsWith("-high") || lowerModel.includes("gemini-pro-agent")) effectiveEffort = "high";
535
693
  else if (lowerModel.endsWith("-low")) effectiveEffort = "low";
536
- else if (lowerModel.includes("gemini-3-flash")) effectiveEffort = "high";
694
+ else if (isGemini3Flash) effectiveEffort = "high";
695
+ // Claude models: skip — thinking is handled by the anthropic-beta header
537
696
  }
538
697
 
539
698
  if (!effectiveEffort) return undefined;
540
699
 
541
- switch (effectiveEffort.toLowerCase()) {
542
- case "low": return isGemini3Pro ? "LOW" : "LOW";
543
- case "medium": return isGemini3Pro ? "HIGH" : "MEDIUM";
544
- case "high": return "HIGH";
545
- default: return undefined;
700
+ // Gemini 3.1 Pro uses fixed budgets matching models.json
701
+ if (isGemini31Pro) {
702
+ switch (effectiveEffort.toLowerCase()) {
703
+ case "high": return 10001;
704
+ case "medium": return 5000;
705
+ case "low": return 1001;
706
+ default: return undefined;
707
+ }
546
708
  }
709
+
710
+ // Flash uses dynamic budget (-1 means let the model decide)
711
+ if (isGemini3Flash) {
712
+ switch (effectiveEffort.toLowerCase()) {
713
+ case "high": return -1;
714
+ case "medium": return 4096;
715
+ case "low": return 1024;
716
+ default: return undefined;
717
+ }
718
+ }
719
+
720
+ return undefined;
547
721
  }
548
722
 
549
723
  export function parseAntigravitySse(raw: string): CompatCompletion {