Files
clawdbot/patches/@mariozechner__pi-ai.patch
Muhammed Mukhthar CM eb5f758f6b fix(auth): improve multi-account round-robin rotation and 429 handling
This commit fixes several issues with multi-account OAuth rotation that
were causing slow responses and inefficient account cycling.

## Changes

### 1. Fix usageStats race condition (auth-profiles.ts)

The `markAuthProfileUsed`, `markAuthProfileCooldown`, `markAuthProfileGood`,
and `clearAuthProfileCooldown` functions were using a stale in-memory store
passed as a parameter. Long-running sessions would overwrite usageStats
updates from concurrent sessions when saving.

**Fix:** Re-read the store from disk before each update to get fresh
usageStats from other sessions, then merge the update.

### 2. Capture AbortError from waitForCompactionRetry (pi-embedded-runner.ts)

When a request timed out, `session.abort()` was called which throws an
`AbortError`. The code structure was:

```javascript
try {
  await session.prompt(params.prompt);
} catch (err) {
  promptError = err;  // Catches AbortError here
}
await waitForCompactionRetry();  // But THIS also throws AbortError!
```

The second `AbortError` from `waitForCompactionRetry()` escaped and
bypassed the rotation/fallback logic entirely.

**Fix:** Wrap `waitForCompactionRetry()` in its own try/catch to capture
the error as `promptError`, enabling proper timeout handling.

Root cause analysis and fix proposed by @erikpr1994 in #313.

Fixes #313

### 3. Fail fast on 429 rate limits (pi-ai patch)

The pi-ai library was retrying 429 errors up to 3 times with exponential
backoff before throwing. This meant a rate-limited account would waste
30+ seconds retrying before our rotation code could try the next account.

**Fix:** Patch google-gemini-cli.js to:
- Throw immediately on first 429 (no retries)
- Not catch and retry 429 errors in the network error handler

This allows the caller to rotate to the next account instantly on rate limit.

Note: We submitted this fix upstream (https://github.com/badlogic/pi-mono/pull/504)
but it was closed without merging. Keeping as a local patch for now.

## Testing

With 6 Antigravity accounts configured:
- Accounts rotate properly based on lastUsed (round-robin)
- 429s trigger immediate rotation to next account
- usageStats persist correctly across concurrent sessions
- Cooldown tracking works as expected

## Before/After

**Before:** Multiple 429 retries on same account, 30-90s delays
**After:** Instant rotation on 429, responses in seconds
2026-01-07 00:56:32 +01:00

244 lines
11 KiB
Diff

diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..56866774e47444b5d333961c9b20fce582363124 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -10,13 +10,27 @@ import { transformMessages } from "./transorm-messages.js";
export function convertMessages(model, context) {
const contents = [];
const transformedMessages = transformMessages(context.messages, model);
+
+ /**
+ * Helper to add content while merging consecutive messages of the same role.
+ * Gemini/Cloud Code Assist requires strict role alternation (user/model/user/model).
+ * Consecutive messages of the same role cause "function call turn" errors.
+ */
+ function addContent(role, parts) {
+ if (parts.length === 0) return;
+ const lastContent = contents[contents.length - 1];
+ if (lastContent?.role === role) {
+ // Merge into existing message of same role
+ lastContent.parts.push(...parts);
+ } else {
+ contents.push({ role, parts });
+ }
+ }
+
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
- contents.push({
- role: "user",
- parts: [{ text: sanitizeSurrogates(msg.content) }],
- });
+ addContent("user", [{ text: sanitizeSurrogates(msg.content) }]);
}
else {
const parts = msg.content.map((item) => {
@@ -35,10 +49,7 @@ export function convertMessages(model, context) {
const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts;
if (filteredParts.length === 0)
continue;
- contents.push({
- role: "user",
- parts: filteredParts,
- });
+ addContent("user", filteredParts);
}
}
else if (msg.role === "assistant") {
@@ -51,9 +62,19 @@ export function convertMessages(model, context) {
parts.push({ text: sanitizeSurrogates(block.text) });
}
else if (block.type === "thinking") {
- // Thinking blocks require signatures for Claude via Antigravity.
- // If signature is missing (e.g. from GPT-OSS), convert to regular text with delimiters.
- if (block.thinkingSignature) {
+ // Thinking blocks handling varies by model:
+ // - Claude via Antigravity: requires thinkingSignature
+ // - Gemini: skip entirely (doesn't understand thoughtSignature, and mimics <thinking> tags)
+ // - Other models: convert to text with delimiters
+ const isGemini = model.id.toLowerCase().includes("gemini");
+ const isClaude = model.id.toLowerCase().includes("claude");
+ if (isGemini) {
+ // Skip thinking blocks entirely for Gemini - it doesn't support them
+ // and will mimic <thinking> tags if we convert to text
+ continue;
+ }
+ else if (block.thinkingSignature && isClaude) {
+ // Claude via Antigravity requires the signature
parts.push({
thought: true,
text: sanitizeSurrogates(block.thinking),
@@ -61,6 +82,7 @@ export function convertMessages(model, context) {
});
}
else {
+ // Other models: convert to text with delimiters
parts.push({
text: `<thinking>\n${sanitizeSurrogates(block.thinking)}\n</thinking>`,
});
@@ -85,10 +107,7 @@ export function convertMessages(model, context) {
}
if (parts.length === 0)
continue;
- contents.push({
- role: "model",
- parts,
- });
+ addContent("model", parts);
}
else if (msg.role === "toolResult") {
// Extract text and image content
@@ -125,27 +144,94 @@ export function convertMessages(model, context) {
}
// Cloud Code Assist API requires all function responses to be in a single user turn.
// Check if the last content is already a user turn with function responses and merge.
+ // Use addContent for proper role alternation handling.
const lastContent = contents[contents.length - 1];
if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) {
lastContent.parts.push(functionResponsePart);
}
else {
- contents.push({
- role: "user",
- parts: [functionResponsePart],
- });
+ addContent("user", [functionResponsePart]);
}
// For older models, add images in a separate user message
+ // Note: This may create consecutive user messages, but addContent will merge them
if (hasImages && !supportsMultimodalFunctionResponse) {
- contents.push({
- role: "user",
- parts: [{ text: "Tool result image:" }, ...imageParts],
- });
+ addContent("user", [{ text: "Tool result image:" }, ...imageParts]);
}
}
}
return contents;
}
+/**
+ * Sanitize JSON Schema for Google Cloud Code Assist API.
+ * Removes unsupported keywords like patternProperties, const, anyOf, etc.
+ * and converts to a format compatible with Google's function declarations.
+ */
+function sanitizeSchemaForGoogle(schema) {
+ if (!schema || typeof schema !== 'object') {
+ return schema;
+ }
+ // If it's an array, sanitize each element
+ if (Array.isArray(schema)) {
+ return schema.map(item => sanitizeSchemaForGoogle(item));
+ }
+ const sanitized = {};
+ // List of unsupported JSON Schema keywords that Google's API doesn't understand
+ const unsupportedKeywords = [
+ 'patternProperties',
+ 'const',
+ 'anyOf',
+ 'oneOf',
+ 'allOf',
+ 'not',
+ '$schema',
+ '$id',
+ '$ref',
+ '$defs',
+ 'definitions',
+ 'if',
+ 'then',
+ 'else',
+ 'dependentSchemas',
+ 'dependentRequired',
+ 'unevaluatedProperties',
+ 'unevaluatedItems',
+ 'contentEncoding',
+ 'contentMediaType',
+ 'contentSchema',
+ 'deprecated',
+ 'readOnly',
+ 'writeOnly',
+ 'examples',
+ '$comment',
+ 'additionalProperties',
+ ];
+ // TODO(steipete): lossy schema scrub; revisit when Google supports these keywords.
+ for (const [key, value] of Object.entries(schema)) {
+ // Skip unsupported keywords
+ if (unsupportedKeywords.includes(key)) {
+ continue;
+ }
+ // Recursively sanitize nested objects
+ if (key === 'properties' && typeof value === 'object' && value !== null) {
+ sanitized[key] = {};
+ for (const [propKey, propValue] of Object.entries(value)) {
+ sanitized[key][propKey] = sanitizeSchemaForGoogle(propValue);
+ }
+ } else if (key === 'items' && typeof value === 'object') {
+ sanitized[key] = sanitizeSchemaForGoogle(value);
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ sanitized[key] = sanitizeSchemaForGoogle(value);
+ } else {
+ sanitized[key] = value;
+ }
+ }
+ // Ensure type: "object" is present when properties or required exist
+ // Google API requires type to be set when these fields are present
+ if (('properties' in sanitized || 'required' in sanitized) && !('type' in sanitized)) {
+ sanitized.type = 'object';
+ }
+ return sanitized;
+}
/**
* Convert tools to Gemini function declarations format.
*/
@@ -157,7 +243,7 @@ export function convertTools(tools) {
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
- parameters: tool.parameters,
+ parameters: sanitizeSchemaForGoogle(tool.parameters),
})),
},
];
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..31bae0aface1319487ce62d35f1f3b6ed334863e 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -486,7 +486,6 @@ function convertTools(tools) {
name: tool.name,
description: tool.description,
parameters: tool.parameters, // TypeBox already generates JSON Schema
- strict: null,
}));
}
function mapStopReason(status) {
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -168,7 +168,12 @@ async function* streamCompletion(params, options) {
break; // Success, exit retry loop
}
const errorText = await response.text();
- // Check if retryable
+ // PATCH: Fail immediately on 429 to let caller rotate accounts
+ if (response.status === 429) {
+ console.log(`[pi-ai] 429 rate limit - failing fast to rotate account`);
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
+ }
+ // Check if retryable (non-429 errors)
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
const serverDelay = extractRetryDelay(errorText);
@@ -183,6 +188,10 @@ async function* streamCompletion(params, options) {
if (error instanceof Error && error.message === "Request was aborted") {
throw error;
}
+ // PATCH: Don't retry 429 errors - let caller rotate accounts
+ if (error instanceof Error && error.message.includes("429")) {
+ throw error;
+ }
lastError = error instanceof Error ? error : new Error(String(error));
// Network errors are retryable
if (attempt < MAX_RETRIES) {