pi-antigravity-rotator 2.1.3 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.1.5] - 2026-05-27
6
+
7
+ ### Fixed
8
+ - **Claude tool_use/tool_result ordering via Responses API**: Resolved a persistent `400 INVALID_ARGUMENT` (`messages.1: tool_use ids were found without tool_result blocks immediately after`) error when using Claude models (e.g. `claude-sonnet-4-6`) through the OpenAI Responses API (used by Codex and similar agents). Three structural issues were corrected in the Gemini content turn builder:
9
+ - **Parallel function_call merging**: Codex sends parallel tool calls as separate `function_call` input items. Each was creating its own assistant turn, but Claude requires all `tool_use` blocks in a single assistant message. Consecutive `function_call` items are now merged into one assistant message with multiple `tool_calls`.
10
+ - **Text/tool_call separation**: Codex sends the assistant's narration text (`"Let me explore..."`) and its `function_call` items as separate input items. The narration was creating a `model` Gemini turn between the `functionCall` turn and the `functionResponse` turn, breaking Claude's strict ordering. Text-only model turns that follow a `functionCall` model turn are now suppressed.
11
+ - **Consecutive tool result merging**: Multiple `functionResponse` parts are now merged into a single `user` Gemini turn, ensuring all `tool_result` blocks appear in one message directly after the `tool_use` assistant message.
12
+
13
+
14
+ ### Improved
15
+ - **Less Lossy Schema Collapsing for Claude**: The `sanitizeClaudeViaGeminiSchema` function now handles complex `anyOf`/`oneOf`/`allOf` schemas with significantly less information loss:
16
+ - **Nullable detection (lossless)**: `anyOf: [{type: X}, {type: "null"}]` patterns are now converted to `{type: X, nullable: true}` instead of losing the null variant.
17
+ - **`allOf` deep merge (lossless)**: `allOf` variants are now deep-merged (properties union + required union) instead of picking only the first variant.
18
+ - **`anyOf`/`oneOf` object merge**: When all variants are objects, properties are merged into a union and only fields required in ALL variants remain required, preserving wider input acceptance.
19
+ - The first-variant fallback is still used for truly incompatible mixed-type unions.
20
+
21
+ ### Fixed
22
+ - **README: Incorrect model names in Codex section**: Removed references to nonexistent `claude-3-5-sonnet` and `gemini-3-pro` models, replaced with actual supported models (`claude-opus-4-6-thinking`, `gemini-3.1-pro`, `gpt-oss-120b`, etc.).
23
+
24
+ ### Added
25
+ - **Schema sanitizer tests**: Added test cases for nullable detection, `allOf` deep merge, and `anyOf` object variant merging.
26
+
5
27
  ## [2.1.3] - 2026-05-27
6
28
 
7
29
  ### Fixed
package/README.md CHANGED
@@ -500,9 +500,10 @@ To connect Codex to your local rotator:
500
500
 
501
501
  3. **Select a Supported Model**:
502
502
  Configure Codex to target one of the following models supported by the rotator (which will be mapped to the best available Google Antigravity account/model under the hood):
503
- - `gemini-3.5-flash` or `gemini-3.5-flash-high` / `gemini-3.5-flash-low` (Recommended for fast general reasoning)
504
- - `gemini-3-pro` or `gemini-pro-agent` (For deep reasoning)
505
- - `claude-sonnet-4-6` or `claude-3-5-sonnet` (Alternative routing fallback)
503
+ - `gemini-3.5-flash` or `gemini-3.5-flash-high` / `gemini-3.5-flash-low` / `gemini-3.5-flash-medium` (Recommended for fast general reasoning)
504
+ - `gemini-3.1-pro` or `gemini-pro-agent` / `gemini-3.1-pro-high` / `gemini-3.1-pro-low` (For deep reasoning)
505
+ - `claude-sonnet-4-6` or `claude-opus-4-6-thinking` (Claude models via Vertex AI)
506
+ - `gpt-oss-120b` or `gpt-oss-120b-medium` (Open-source GPT model)
506
507
 
507
508
  Example Codex configuration entry:
508
509
  ```json
@@ -512,7 +513,7 @@ To connect Codex to your local rotator:
512
513
  ### Features Enabled for Codex Agents
513
514
 
514
515
  - **Native Reasoning Visibility**: If using models with thinking enabled (e.g., `gemini-3.5-flash-high`), interleaved reasoning/thinking blocks are streamed back in real-time as OpenAI `reasoning_content` chunks. This lets Codex inspect the model's inner thoughts before it acts.
515
- - **Function / Tool Routing**: Function calls emitted by Codex are fully translated to Gemini `functionCalls` and returned back to Codex safely, enabling full agentic capabilities.
516
+ - **Function / Tool Routing**: Function calls emitted by Codex are fully translated to Gemini `functionCalls` and returned back to Codex safely, enabling full agentic capabilities. Multi-turn tool conversations work correctly for all models including Claude (`claude-sonnet-4-6`, `claude-opus-4-6-thinking`) — parallel tool calls are batched into a single turn and tool results are properly grouped to satisfy Claude's strict `tool_use`/`tool_result` ordering requirements.
516
517
  - **Strict Validation**: The rotator strictly validates the Responses input contract and rejects unsupported tools (e.g., `web_search`) proactively to ensure Codex doesn't hit unexpected runtime exceptions.
517
518
 
518
519
  ## Development Checks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
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
@@ -470,17 +470,98 @@ function sanitizeClaudeViaGeminiSchema(schema: unknown): unknown {
470
470
  }
471
471
  }
472
472
 
473
- // General case: collapse to the first valid variant.
474
- // Gemini's Schema proto serialization corrupts complex anyOf/oneOf
475
- // during the round-trip to Claude, causing JSON Schema draft 2020-12
476
- // validation failures. Collapsing is lossy but functional — the tool
477
- // still works, just with a narrower accepted input type.
473
+ // Sanitize all variants first.
478
474
  const cleaned = value.map(sanitizeClaudeViaGeminiSchema).filter(
479
475
  (v) => isRecord(v) && Object.keys(v).length > 0,
476
+ ) as Record<string, unknown>[];
477
+
478
+ if (cleaned.length === 0) {
479
+ // All variants collapsed to nothing — skip entirely.
480
+ continue;
481
+ }
482
+
483
+ // Case 3: nullable pattern — anyOf/oneOf with exactly one {type:"null"}
484
+ // variant and one or more real variants. Convert to the real variant
485
+ // with nullable:true. This is lossless — Gemini's proto supports nullable.
486
+ // e.g. anyOf:[{type:"string"},{type:"null"}] → {type:"string",nullable:true}
487
+ if (key !== "allOf") {
488
+ const nullIdx = cleaned.findIndex((v) => v.type === "null" && Object.keys(v).length === 1);
489
+ if (nullIdx !== -1) {
490
+ const nonNull = cleaned.filter((_, i) => i !== nullIdx);
491
+ if (nonNull.length === 1) {
492
+ Object.assign(out, nonNull[0], { nullable: true });
493
+ continue;
494
+ }
495
+ if (nonNull.length > 1) {
496
+ // Multiple non-null variants + null → collapse non-null variants,
497
+ // then mark nullable. Still lossy but preserves nullability.
498
+ Object.assign(out, nonNull[0], { nullable: true });
499
+ continue;
500
+ }
501
+ }
502
+ }
503
+
504
+ // Case 4: allOf — deep merge all variants (allOf = intersection).
505
+ // Merging properties from all variants is semantically correct.
506
+ if (key === "allOf") {
507
+ const merged: Record<string, unknown> = {};
508
+ let mergedProperties: Record<string, unknown> = {};
509
+ let mergedRequired: string[] = [];
510
+ for (const variant of cleaned) {
511
+ for (const [vk, vv] of Object.entries(variant)) {
512
+ if (vk === "properties" && isRecord(vv)) {
513
+ mergedProperties = { ...mergedProperties, ...vv };
514
+ } else if (vk === "required" && Array.isArray(vv)) {
515
+ mergedRequired = [...new Set([...mergedRequired, ...vv])];
516
+ } else {
517
+ merged[vk] = vv;
518
+ }
519
+ }
520
+ }
521
+ if (Object.keys(mergedProperties).length > 0) merged["properties"] = mergedProperties;
522
+ if (mergedRequired.length > 0) merged["required"] = mergedRequired;
523
+ Object.assign(out, merged);
524
+ continue;
525
+ }
526
+
527
+ // Case 5: anyOf/oneOf where all variants are objects with properties —
528
+ // merge all properties together, making all optional (union of shapes).
529
+ // This is mildly lossy (accepts wider input) but doesn't reject valid inputs.
530
+ const allObjects = cleaned.every(
531
+ (v) => v.type === "object" && isRecord(v.properties),
480
532
  );
481
- if (cleaned.length >= 1) {
482
- Object.assign(out, cleaned[0]);
533
+ if (allObjects && cleaned.length > 1) {
534
+ const unionProperties: Record<string, unknown> = {};
535
+ for (const variant of cleaned) {
536
+ const props = variant.properties as Record<string, unknown>;
537
+ for (const [pk, pv] of Object.entries(props)) {
538
+ if (!(pk in unionProperties)) unionProperties[pk] = pv;
539
+ }
540
+ }
541
+ // Only keep required fields that exist in ALL variants
542
+ const allRequired = cleaned.map((v) =>
543
+ Array.isArray(v.required) ? new Set(v.required as string[]) : new Set<string>(),
544
+ );
545
+ const commonRequired = [...allRequired[0]].filter((r) =>
546
+ allRequired.every((s) => s.has(r)),
547
+ );
548
+ const base = { ...cleaned[0] };
549
+ base["properties"] = unionProperties;
550
+ if (commonRequired.length > 0) {
551
+ base["required"] = commonRequired;
552
+ } else {
553
+ delete base["required"];
554
+ }
555
+ Object.assign(out, base);
556
+ continue;
483
557
  }
558
+
559
+ // Fallback: collapse to the first valid variant.
560
+ // Gemini's Schema proto serialization corrupts complex anyOf/oneOf
561
+ // during the round-trip to Claude, causing JSON Schema draft 2020-12
562
+ // validation failures. Collapsing is lossy but functional — the tool
563
+ // still works, just with a narrower accepted input type.
564
+ Object.assign(out, cleaned[0]);
484
565
  }
485
566
  continue;
486
567
  }
@@ -643,11 +724,19 @@ function parseResponsesInput(input: unknown, callIdToName: Map<string, string> =
643
724
  name: rawItem.name,
644
725
  arguments: args,
645
726
  });
646
- messages.push({
647
- role: "assistant",
648
- content: null,
649
- tool_calls: [{ id: callId, type: "function", function: { name: rawItem.name, arguments: args } }],
650
- });
727
+ // Merge consecutive function_call items into a single assistant message
728
+ // so Claude sees one assistant turn with multiple tool_calls rather than
729
+ // separate assistant turns (which would each require their own tool_result).
730
+ const lastMsg = messages[messages.length - 1];
731
+ if (lastMsg && lastMsg.role === "assistant" && Array.isArray(lastMsg.tool_calls)) {
732
+ lastMsg.tool_calls.push({ id: callId, type: "function", function: { name: rawItem.name, arguments: args } });
733
+ } else {
734
+ messages.push({
735
+ role: "assistant",
736
+ content: null,
737
+ tool_calls: [{ id: callId, type: "function", function: { name: rawItem.name, arguments: args } }],
738
+ });
739
+ }
651
740
  continue;
652
741
  }
653
742
 
@@ -1090,7 +1179,34 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
1090
1179
  isFirstInMessage = false;
1091
1180
  }
1092
1181
  }
1093
- if (parts.length > 0) contents.push({ role: "model", parts });
1182
+ if (parts.length > 0) {
1183
+ // For Claude: handle two scenarios that break tool_use/tool_result ordering.
1184
+ // 1. Text-only model turn after a functionCall model turn: Codex sends
1185
+ // assistant text and function_calls as separate items. The text-only turn
1186
+ // would split functionCall from functionResponse — skip it entirely.
1187
+ // 2. Model turn with functionCalls: strip any text parts since Google's
1188
+ // v1internal translator may split mixed parts into separate Claude messages.
1189
+ if (isClaude) {
1190
+ const lastContent = contents[contents.length - 1];
1191
+ const prevHasFunctionCall = lastContent && lastContent.role === "model" && lastContent.parts.some((p: any) => p.functionCall);
1192
+ const hasFunctionCall = parts.some((p: any) => p.functionCall);
1193
+ if (prevHasFunctionCall && !hasFunctionCall) {
1194
+ // Skip text-only model turn after functionCall turn
1195
+ } else if (hasFunctionCall) {
1196
+ // Strip text parts, keep only functionCall parts
1197
+ const fcOnly = parts.filter((p: any) => p.functionCall);
1198
+ if (prevHasFunctionCall) {
1199
+ lastContent.parts.push(...fcOnly);
1200
+ } else {
1201
+ contents.push({ role: "model", parts: fcOnly });
1202
+ }
1203
+ } else {
1204
+ contents.push({ role: "model", parts });
1205
+ }
1206
+ } else {
1207
+ contents.push({ role: "model", parts });
1208
+ }
1209
+ }
1094
1210
  } else if (msg.role === "tool") {
1095
1211
  const responseText = typeof msg.content === "string" ? msg.content : extractText(msg.content);
1096
1212
  const fnName = msg.name || "unknown";
@@ -1106,7 +1222,16 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
1106
1222
  : { output: parsed };
1107
1223
  } catch { responseData = { output: responseText }; }
1108
1224
  // Include id only for Claude — Gemini native models reject the id field in functionResponse
1109
- contents.push({ role: "user", parts: [{ functionResponse: { ...(isClaude && toolCallId ? { id: toolCallId } : {}), name: fnName, response: responseData } }] });
1225
+ const fnResponsePart = { functionResponse: { ...(isClaude && toolCallId ? { id: toolCallId } : {}), name: fnName, response: responseData } };
1226
+ // Merge consecutive tool results into a single user turn.
1227
+ // Claude (via Vertex) requires ALL tool_result blocks in one message
1228
+ // directly after the assistant message with tool_use blocks.
1229
+ const lastContent = contents[contents.length - 1];
1230
+ if (lastContent && lastContent.role === "user" && Array.isArray(lastContent.parts) && lastContent.parts.length > 0 && isRecord(lastContent.parts[0] as any) && (lastContent.parts[0] as any).functionResponse !== undefined) {
1231
+ lastContent.parts.push(fnResponsePart);
1232
+ } else {
1233
+ contents.push({ role: "user", parts: [fnResponsePart] });
1234
+ }
1110
1235
  } else {
1111
1236
  // user message
1112
1237
  const msgParts = extractParts(msg.content);