pi-antigravity-rotator 2.1.1 → 2.1.3

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,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.1.3] - 2026-05-27
6
+
7
+ ### Fixed
8
+ - **Anthropic Tool Use Content Blocks**: Anthropic `tool_use` and `tool_result` content blocks in message history are now correctly converted to Gemini `functionCall` / `functionResponse` parts when proxying through the Anthropic-compatible `/v1/messages` adapter, preventing `400 INVALID_ARGUMENT` errors on multi-turn tool conversations. (PR [#7](https://github.com/tuxevil/pi-antigravity-rotator/pull/7) by [@javargasm](https://github.com/javargasm))
9
+ - **Anthropic Tool Forwarding**: `tools` and `tool_choice` from Anthropic `/v1/messages` requests are now properly forwarded to the Gemini upstream, enabling full Anthropic-native tool calling through the rotator.
10
+ - **Anthropic Tool Response Streaming**: Streaming and non-streaming Anthropic responses now correctly emit `tool_use` content blocks and set `stop_reason: "tool_use"` when function calls are present.
11
+ - **JSON Schema `anyOf`/`oneOf`/`allOf` Collapse**: The Claude schema sanitizer now collapses composite schema keywords (`anyOf`, `oneOf`, `allOf`) to their first variant before forwarding to Gemini, preventing schema corruption during the Gemini proto round-trip.
12
+
13
+ ### Added
14
+ - **Anthropic Tool Conversion Tests**: Added dedicated test cases for Anthropic tool conversions and JSON schema type collapsing in the compat test suite. ([@javargasm](https://github.com/javargasm))
15
+
16
+ ## [2.1.2] - 2026-05-25
17
+
18
+ ### Added
19
+ - **Developer Role Support**: Added comprehensive compatibility and validation support for the newer `"developer"` role (introduced by OpenAI to replace the system prompt on models like o1/gpt-4o).
20
+ - **Developer Message Routing**: Automatically routes messages with the `"developer"` role as system instructions upstream in the Antigravity request mapping.
21
+ - **Improved Adapter Coverage**: Extended type validation and integration testing in the compat adapters to fully cover the new schema additions.
22
+
5
23
  ## [2.1.1] - 2026-05-21
6
24
 
7
25
  ### Added
package/README.md CHANGED
@@ -458,7 +458,7 @@ Current adapter scope:
458
458
 
459
459
  - Text chat/messages.
460
460
  - **Responses API compatibility**: Supports `POST /v1/responses` plus basic in-memory retrieve/delete/cancel/input-items endpoints for Codex-style agents.
461
- - **Model Role Support**: Fully supports the `"model"` role in chat message histories (e.g., from Pi or Hermes agents), validating and routing it identically to the `"assistant"` role.
461
+ - **Developer and Model Role Support**: Fully supports the `"developer"` (mapped to system instructions) and `"model"` roles in chat message histories, validating and routing them correctly.
462
462
  - **Request Normalization**: Automatically normalizes loose inputs (non-array messages), legacy prompt/input fields (e.g. `prompt` strings/arrays or `input` structures), and raw native Antigravity requests (`request.contents`) into standard OpenAI/Anthropic format.
463
463
  - **Native Reasoning visibility**: Models with thinking capabilities (Gemini 3 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6 Thinking) automatically expose their interleaved thinking blocks in real-time as OpenAI `reasoning_content` or Anthropic `thinking_delta` chunks.
464
464
  - Streaming mode is supported as compatibility SSE. The adapter buffers the upstream Antigravity stream, then emits one OpenAI/Anthropic-compatible final delta. Native token-by-token pass-through is not implemented yet.
@@ -467,6 +467,13 @@ Current adapter scope:
467
467
  - Responses-compatible tool support is currently limited to `type: "function"` tools. Built-in tools like `web_search`, `file_search`, `computer`, or `code_interpreter` are rejected explicitly.
468
468
 
469
469
 
470
+ ## Contributors
471
+
472
+ Thanks to these amazing people who have contributed to the project:
473
+
474
+ - **[@javargasm](https://github.com/javargasm)** (Jeisson Alexander Vargas Marroquin) — Anthropic tool-use compatibility layer (`tool_use`/`tool_result` content block conversion), JSON schema round-trip fixes, and compat test suite expansion. ([PR #3](https://github.com/tuxevil/pi-antigravity-rotator/pull/3), [PR #7](https://github.com/tuxevil/pi-antigravity-rotator/pull/7))
475
+
476
+
470
477
  ## Connecting Codex / VS Code Agents
471
478
 
472
479
  `pi-antigravity-rotator` can act as the multi-account rotation backend for agentic frameworks, including **Codex** executing in VS Code or in the terminal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
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
@@ -10,7 +10,7 @@ import { withRotation, flattenHeaders, type RequestBody } from "./proxy.js";
10
10
  const compatLogger = logger.child("compat");
11
11
 
12
12
  export interface ChatMessage {
13
- role: "system" | "user" | "assistant" | "model" | "tool";
13
+ role: "system" | "developer" | "user" | "assistant" | "model" | "tool";
14
14
  content: string | Array<{ type: string; text?: string;[key: string]: unknown }> | null;
15
15
  tool_calls?: OpenAIToolCall[];
16
16
  tool_call_id?: string;
@@ -470,16 +470,17 @@ function sanitizeClaudeViaGeminiSchema(schema: unknown): unknown {
470
470
  }
471
471
  }
472
472
 
473
- // General case: recurse and sanitize each variant.
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.
474
478
  const cleaned = value.map(sanitizeClaudeViaGeminiSchema).filter(
475
479
  (v) => isRecord(v) && Object.keys(v).length > 0,
476
480
  );
477
- if (cleaned.length === 1) {
481
+ if (cleaned.length >= 1) {
478
482
  Object.assign(out, cleaned[0]);
479
- } else if (cleaned.length > 1) {
480
- out[key] = cleaned;
481
483
  }
482
- // cleaned.length === 0: skip entirely
483
484
  }
484
485
  continue;
485
486
  }
@@ -531,7 +532,7 @@ function convertToolChoiceToGemini(toolChoice: unknown): GeminiToolConfig | unde
531
532
  function validateMessages(value: unknown): value is ChatMessage[] {
532
533
  return Array.isArray(value) && value.every((msg) => {
533
534
  if (!isRecord(msg)) return false;
534
- if (!["system", "user", "assistant", "model", "tool"].includes(String(msg.role))) return false;
535
+ if (!["system", "developer", "user", "assistant", "model", "tool"].includes(String(msg.role))) return false;
535
536
  return typeof msg.content === "string" || msg.content === null || Array.isArray(msg.content);
536
537
  });
537
538
  }
@@ -1009,7 +1010,7 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
1009
1010
  // Separate system messages from conversation turns
1010
1011
  const systemParts: string[] = [];
1011
1012
  const conversationMessages = input.messages.filter((msg) => {
1012
- if (msg.role === "system") {
1013
+ if (msg.role === "system" || msg.role === "developer") {
1013
1014
  const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
1014
1015
  if (text) systemParts.push(text);
1015
1016
  return false;
@@ -1209,16 +1210,109 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
1209
1210
  };
1210
1211
  }
1211
1212
 
1213
+ /** Convert Anthropic tools [{name, description, input_schema}] → OpenAI format [{type:"function", function:{name, description, parameters}}] */
1214
+ function convertAnthropicToolsToOpenAI(tools: unknown): OpenAITool[] | undefined {
1215
+ if (!Array.isArray(tools) || tools.length === 0) return undefined;
1216
+ const result: OpenAITool[] = [];
1217
+ for (const t of tools) {
1218
+ if (!isRecord(t) || !isNonEmptyString(t.name)) continue;
1219
+ result.push({
1220
+ type: "function",
1221
+ function: {
1222
+ name: t.name as string,
1223
+ ...(typeof t.description === "string" ? { description: t.description } : {}),
1224
+ ...(isRecord(t.input_schema) ? { parameters: t.input_schema as Record<string, unknown> } : {}),
1225
+ },
1226
+ });
1227
+ }
1228
+ return result.length > 0 ? result : undefined;
1229
+ }
1230
+
1231
+ /** Convert Anthropic tool_choice → OpenAI tool_choice */
1232
+ function convertAnthropicToolChoice(toolChoice: unknown): unknown {
1233
+ if (!isRecord(toolChoice)) return toolChoice;
1234
+ if (toolChoice.type === "auto") return "auto";
1235
+ if (toolChoice.type === "any") return "required";
1236
+ if (toolChoice.type === "tool" && isNonEmptyString(toolChoice.name)) {
1237
+ return { type: "function", function: { name: toolChoice.name } };
1238
+ }
1239
+ return "auto";
1240
+ }
1241
+
1242
+ /**
1243
+ * Convert Anthropic-format messages (tool_use / tool_result content blocks)
1244
+ * to OpenAI-format messages (tool_calls array / role:"tool" messages).
1245
+ */
1246
+ function convertAnthropicMessagesToOpenAI(messages: ChatMessage[]): ChatMessage[] {
1247
+ const result: ChatMessage[] = [];
1248
+ for (const msg of messages) {
1249
+ // Assistant messages with tool_use content blocks → tool_calls
1250
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
1251
+ const blocks = msg.content as Array<Record<string, unknown>>;
1252
+ const toolUseBlocks = blocks.filter(
1253
+ (b) => isRecord(b) && b.type === "tool_use" && isNonEmptyString(b.name),
1254
+ );
1255
+ if (toolUseBlocks.length > 0) {
1256
+ const textParts = blocks
1257
+ .filter((b) => isRecord(b) && b.type === "text" && typeof b.text === "string")
1258
+ .map((b) => b.text as string)
1259
+ .join("");
1260
+ const toolCalls: OpenAIToolCall[] = toolUseBlocks.map((b) => ({
1261
+ id: (b.id as string) || `call_${Date.now().toString(36)}`,
1262
+ type: "function" as const,
1263
+ function: {
1264
+ name: b.name as string,
1265
+ arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}),
1266
+ },
1267
+ }));
1268
+ result.push({ role: "assistant", content: textParts || null, tool_calls: toolCalls });
1269
+ continue;
1270
+ }
1271
+ }
1272
+ // User messages with tool_result content blocks → role:"tool" messages
1273
+ if (msg.role === "user" && Array.isArray(msg.content)) {
1274
+ const blocks = msg.content as Array<Record<string, unknown>>;
1275
+ const toolResults = blocks.filter((b) => isRecord(b) && b.type === "tool_result");
1276
+ if (toolResults.length > 0) {
1277
+ const otherBlocks = blocks.filter((b) => !isRecord(b) || b.type !== "tool_result");
1278
+ if (otherBlocks.length > 0) {
1279
+ result.push({ role: "user", content: otherBlocks as ChatMessage["content"] });
1280
+ }
1281
+ for (const tr of toolResults) {
1282
+ const content = typeof tr.content === "string"
1283
+ ? tr.content
1284
+ : Array.isArray(tr.content)
1285
+ ? extractTextFromUnknownContent(tr.content)
1286
+ : JSON.stringify(tr.content ?? "");
1287
+ result.push({
1288
+ role: "tool",
1289
+ content,
1290
+ tool_call_id: tr.tool_use_id as string,
1291
+ });
1292
+ }
1293
+ continue;
1294
+ }
1295
+ }
1296
+ result.push(msg);
1297
+ }
1298
+ return result;
1299
+ }
1300
+
1212
1301
  export function anthropicToAntigravityBody(input: AnthropicMessagesRequest): RequestBody {
1213
1302
  const systemText = typeof input.system === "string" ? input.system : Array.isArray(input.system) ? extractText(input.system as ChatMessage["content"]) : "";
1303
+ const tools = convertAnthropicToolsToOpenAI(input.tools);
1304
+ const toolChoice = convertAnthropicToolChoice(input.tool_choice);
1305
+ const convertedMessages = convertAnthropicMessagesToOpenAI(input.messages);
1214
1306
  return openAIToAntigravityBody({
1215
1307
  model: input.model,
1216
1308
  stream: input.stream,
1217
1309
  temperature: input.temperature,
1218
1310
  max_tokens: input.max_tokens,
1311
+ tools,
1312
+ tool_choice: toolChoice,
1219
1313
  messages: [
1220
1314
  ...(systemText ? [{ role: "system" as const, content: systemText }] : []),
1221
- ...input.messages,
1315
+ ...convertedMessages,
1222
1316
  ],
1223
1317
  });
1224
1318
  }
@@ -1410,11 +1504,28 @@ function writeAnthropicStream(res: ServerResponse, model: string, completion: Co
1410
1504
  res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: contentIndex })}\n\n`);
1411
1505
  contentIndex++;
1412
1506
  }
1413
- res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: contentIndex, content_block: { type: "text", text: "" } })}\n\n`);
1414
- if (completion.text) res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: contentIndex, delta: { type: "text_delta", text: completion.text } })}\n\n`);
1415
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: contentIndex })}\n\n`);
1507
+ if (completion.text) {
1508
+ res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: contentIndex, content_block: { type: "text", text: "" } })}\n\n`);
1509
+ res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: contentIndex, delta: { type: "text_delta", text: completion.text } })}\n\n`);
1510
+ res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: contentIndex })}\n\n`);
1511
+ contentIndex++;
1512
+ }
1513
+ // Emit tool_use content blocks if present
1514
+ let hasToolUse = false;
1515
+ if (completion.toolCalls && completion.toolCalls.length > 0) {
1516
+ hasToolUse = true;
1517
+ for (const tc of completion.toolCalls) {
1518
+ let parsedInput: unknown;
1519
+ try { parsedInput = JSON.parse(tc.function.arguments || "{}"); } catch { parsedInput = {}; }
1520
+ res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: contentIndex, content_block: { type: "tool_use", id: tc.id, name: tc.function.name, input: {} } })}\n\n`);
1521
+ res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: contentIndex, delta: { type: "input_json_delta", partial_json: JSON.stringify(parsedInput) } })}\n\n`);
1522
+ res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: contentIndex })}\n\n`);
1523
+ contentIndex++;
1524
+ }
1525
+ }
1526
+ const stopReason = hasToolUse ? "tool_use" : "end_turn";
1416
1527
  // message_delta: include both input_tokens and output_tokens so hermes shows full context count
1417
- res.write(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { input_tokens: completion.inputTokens, output_tokens: completion.outputTokens } })}\n\n`);
1528
+ res.write(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { input_tokens: completion.inputTokens, output_tokens: completion.outputTokens } })}\n\n`);
1418
1529
  res.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
1419
1530
  res.end();
1420
1531
  }
@@ -1448,6 +1559,8 @@ async function streamCompatSse(
1448
1559
 
1449
1560
  let anthropicActiveBlockIndex = -1;
1450
1561
  let anthropicActiveBlockType: "thinking" | "text" | null = null;
1562
+ let anthropicHasToolUse = false;
1563
+ const anthropicToolCalls: OpenAIToolCall[] = [];
1451
1564
 
1452
1565
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
1453
1566
 
@@ -1539,6 +1652,20 @@ async function streamCompatSse(
1539
1652
  }
1540
1653
  if (format === "openai") {
1541
1654
  res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { tool_calls: [{ index: toolCallIndex - 1, id: callId, type: "function", function: { name, arguments: args } }] }, finish_reason: null }] })}\n\n`);
1655
+ } else {
1656
+ // Close any active text/thinking block before emitting tool_use
1657
+ if (anthropicActiveBlockType !== null) {
1658
+ res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`);
1659
+ anthropicActiveBlockType = null;
1660
+ }
1661
+ anthropicActiveBlockIndex++;
1662
+ anthropicHasToolUse = true;
1663
+ anthropicToolCalls.push({ id: callId, type: "function", function: { name, arguments: args } });
1664
+ let parsedInput: unknown;
1665
+ try { parsedInput = JSON.parse(args); } catch { parsedInput = {}; }
1666
+ res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: anthropicActiveBlockIndex, content_block: { type: "tool_use", id: callId, name, input: {} } })}\n\n`);
1667
+ res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: anthropicActiveBlockIndex, delta: { type: "input_json_delta", partial_json: JSON.stringify(parsedInput) } })}\n\n`);
1668
+ res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`);
1542
1669
  }
1543
1670
  }
1544
1671
  }
@@ -1571,14 +1698,15 @@ async function streamCompatSse(
1571
1698
  if (anthropicActiveBlockType !== null) {
1572
1699
  res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`);
1573
1700
  }
1701
+ const anthropicStopReason = anthropicHasToolUse ? "tool_use" : "end_turn";
1574
1702
  // message_delta carries output_tokens; also include input_tokens so Hermes shows full context count
1575
- res.write(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { input_tokens: inputTokens, output_tokens: outputTokens } })}\n\n`);
1703
+ res.write(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: anthropicStopReason, stop_sequence: null }, usage: { input_tokens: inputTokens, output_tokens: outputTokens } })}\n\n`);
1576
1704
  res.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
1577
1705
  }
1578
1706
  res.end();
1579
1707
  }
1580
1708
 
1581
- return { text, inputTokens, outputTokens, responseId, toolCalls: undefined };
1709
+ return { text, inputTokens, outputTokens, responseId, toolCalls: anthropicToolCalls.length > 0 ? anthropicToolCalls : undefined };
1582
1710
  }
1583
1711
 
1584
1712
  async function streamResponsesSse(
@@ -2122,18 +2250,28 @@ export async function handleAnthropicMessages(req: IncomingMessage, res: ServerR
2122
2250
  if (result.streamed) {
2123
2251
  return;
2124
2252
  }
2253
+ const contentBlocks: Array<Record<string, unknown>> = [];
2254
+ if (result.completion.thinkingText) {
2255
+ contentBlocks.push({ type: "thinking", thinking: result.completion.thinkingText });
2256
+ }
2257
+ if (result.completion.text) {
2258
+ contentBlocks.push({ type: "text", text: result.completion.text });
2259
+ }
2260
+ if (result.completion.toolCalls && result.completion.toolCalls.length > 0) {
2261
+ for (const tc of result.completion.toolCalls) {
2262
+ let parsedInput: unknown;
2263
+ try { parsedInput = JSON.parse(tc.function.arguments || "{}"); } catch { parsedInput = {}; }
2264
+ contentBlocks.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: parsedInput });
2265
+ }
2266
+ }
2267
+ const stopReason = (result.completion.toolCalls && result.completion.toolCalls.length > 0) ? "tool_use" : "end_turn";
2125
2268
  writeJson(res, 200, {
2126
2269
  id: `msg_${started.toString(36)}`,
2127
2270
  type: "message",
2128
2271
  role: "assistant",
2129
2272
  model: validation.value.model,
2130
- content: result.completion.thinkingText
2131
- ? [
2132
- { type: "thinking", thinking: result.completion.thinkingText },
2133
- { type: "text", text: result.completion.text }
2134
- ]
2135
- : [{ type: "text", text: result.completion.text }],
2136
- stop_reason: "end_turn",
2273
+ content: contentBlocks,
2274
+ stop_reason: stopReason,
2137
2275
  stop_sequence: null,
2138
2276
  usage: {
2139
2277
  input_tokens: result.completion.inputTokens,