Merge pull request #544 from jeffersonwarrior/fix/cloud-code-assist-api-errors
Fix Cloud Code Assist API errors (429/400)
This commit is contained in:
@@ -78,6 +78,7 @@
|
|||||||
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
|
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
|
||||||
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
|
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
|
||||||
- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
|
- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
|
||||||
|
- Agents: sanitize Cloud Code Assist tool call IDs and detect format/quota errors for failover. (#544) — thanks @jeffersonwarrior
|
||||||
- Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj
|
- Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj
|
||||||
- Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete
|
- Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete
|
||||||
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
|
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
classifyFailoverReason,
|
classifyFailoverReason,
|
||||||
formatAssistantErrorText,
|
formatAssistantErrorText,
|
||||||
isBillingErrorMessage,
|
isBillingErrorMessage,
|
||||||
|
isCloudCodeAssistFormatError,
|
||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
isFailoverErrorMessage,
|
isFailoverErrorMessage,
|
||||||
isMessagingToolDuplicate,
|
isMessagingToolDuplicate,
|
||||||
normalizeTextForComparison,
|
normalizeTextForComparison,
|
||||||
sanitizeGoogleTurnOrdering,
|
sanitizeGoogleTurnOrdering,
|
||||||
sanitizeSessionMessagesImages,
|
sanitizeSessionMessagesImages,
|
||||||
|
sanitizeToolCallId,
|
||||||
validateGeminiTurns,
|
validateGeminiTurns,
|
||||||
} from "./pi-embedded-helpers.js";
|
} from "./pi-embedded-helpers.js";
|
||||||
import {
|
import {
|
||||||
@@ -258,12 +260,34 @@ describe("classifyFailoverReason", () => {
|
|||||||
it("returns a stable reason", () => {
|
it("returns a stable reason", () => {
|
||||||
expect(classifyFailoverReason("invalid api key")).toBe("auth");
|
expect(classifyFailoverReason("invalid api key")).toBe("auth");
|
||||||
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
|
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
|
||||||
|
expect(classifyFailoverReason("resource has been exhausted")).toBe(
|
||||||
|
"rate_limit",
|
||||||
|
);
|
||||||
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
|
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
|
||||||
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
||||||
|
expect(classifyFailoverReason("string should match pattern")).toBeNull();
|
||||||
expect(classifyFailoverReason("bad request")).toBeNull();
|
expect(classifyFailoverReason("bad request")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isCloudCodeAssistFormatError", () => {
|
||||||
|
it("matches format errors", () => {
|
||||||
|
const samples = [
|
||||||
|
"INVALID_REQUEST_ERROR: string should match pattern",
|
||||||
|
"messages.1.content.1.tool_use.id",
|
||||||
|
"tool_use.id should match pattern",
|
||||||
|
"invalid request format",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(isCloudCodeAssistFormatError(sample)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated errors", () => {
|
||||||
|
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("formatAssistantErrorText", () => {
|
describe("formatAssistantErrorText", () => {
|
||||||
const makeAssistantError = (errorMessage: string): AssistantMessage =>
|
const makeAssistantError = (errorMessage: string): AssistantMessage =>
|
||||||
({
|
({
|
||||||
@@ -277,6 +301,20 @@ describe("formatAssistantErrorText", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sanitizeToolCallId", () => {
|
||||||
|
it("keeps valid tool call IDs", () => {
|
||||||
|
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces invalid characters with underscores", () => {
|
||||||
|
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default for empty IDs", () => {
|
||||||
|
expect(sanitizeToolCallId("")).toBe("default_tool_id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("sanitizeGoogleTurnOrdering", () => {
|
describe("sanitizeGoogleTurnOrdering", () => {
|
||||||
it("prepends a synthetic user turn when history starts with assistant", () => {
|
it("prepends a synthetic user turn when history starts with assistant", () => {
|
||||||
const input = [
|
const input = [
|
||||||
|
|||||||
@@ -103,7 +103,17 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
content as ContentBlock[],
|
content as ContentBlock[],
|
||||||
label,
|
label,
|
||||||
)) as unknown as typeof toolMsg.content;
|
)) as unknown as typeof toolMsg.content;
|
||||||
out.push({ ...toolMsg, content: nextContent });
|
const sanitizedToolCallId = toolMsg.toolCallId
|
||||||
|
? sanitizeToolCallId(toolMsg.toolCallId)
|
||||||
|
: undefined;
|
||||||
|
const sanitizedMsg = {
|
||||||
|
...toolMsg,
|
||||||
|
content: nextContent,
|
||||||
|
...(sanitizedToolCallId && {
|
||||||
|
toolCallId: sanitizedToolCallId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
out.push(sanitizedMsg);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,14 +143,32 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
||||||
return rec.text.trim().length > 0;
|
return rec.text.trim().length > 0;
|
||||||
});
|
});
|
||||||
const sanitizedContent = (await sanitizeContentBlocksImages(
|
// Also sanitize tool call IDs in assistant messages (function call blocks)
|
||||||
filteredContent as unknown as ContentBlock[],
|
const sanitizedContent = await Promise.all(
|
||||||
|
filteredContent.map(async (block) => {
|
||||||
|
if (
|
||||||
|
block &&
|
||||||
|
typeof block === "object" &&
|
||||||
|
(block as { type?: unknown }).type === "functionCall" &&
|
||||||
|
(block as { id?: unknown }).id
|
||||||
|
) {
|
||||||
|
const functionBlock = block as { type: string; id: string };
|
||||||
|
return {
|
||||||
|
...functionBlock,
|
||||||
|
id: sanitizeToolCallId(functionBlock.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const finalContent = (await sanitizeContentBlocksImages(
|
||||||
|
sanitizedContent as unknown as ContentBlock[],
|
||||||
label,
|
label,
|
||||||
)) as unknown as typeof assistantMsg.content;
|
)) as unknown as typeof assistantMsg.content;
|
||||||
if (sanitizedContent.length === 0) {
|
if (finalContent.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
out.push({ ...assistantMsg, content: sanitizedContent });
|
out.push({ ...assistantMsg, content: finalContent });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,7 +285,10 @@ export function isRateLimitErrorMessage(raw: string): boolean {
|
|||||||
const value = raw.toLowerCase();
|
const value = raw.toLowerCase();
|
||||||
return (
|
return (
|
||||||
/rate[_ ]limit|too many requests|429/.test(value) ||
|
/rate[_ ]limit|too many requests|429/.test(value) ||
|
||||||
value.includes("exceeded your current quota")
|
value.includes("exceeded your current quota") ||
|
||||||
|
value.includes("resource has been exhausted") ||
|
||||||
|
value.includes("quota exceeded") ||
|
||||||
|
value.includes("resource_exhausted")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,11 +338,26 @@ export function isAuthErrorMessage(raw: string): boolean {
|
|||||||
value.includes("unauthorized") ||
|
value.includes("unauthorized") ||
|
||||||
value.includes("forbidden") ||
|
value.includes("forbidden") ||
|
||||||
value.includes("access denied") ||
|
value.includes("access denied") ||
|
||||||
|
value.includes("expired") ||
|
||||||
|
value.includes("token has expired") ||
|
||||||
/\b401\b/.test(value) ||
|
/\b401\b/.test(value) ||
|
||||||
/\b403\b/.test(value)
|
/\b403\b/.test(value)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCloudCodeAssistFormatError(raw: string): boolean {
|
||||||
|
const value = raw.toLowerCase();
|
||||||
|
if (!value) return false;
|
||||||
|
return (
|
||||||
|
value.includes("invalid_request_error") ||
|
||||||
|
value.includes("string should match pattern") ||
|
||||||
|
value.includes("tool_use.id") ||
|
||||||
|
value.includes("tool_use_id") ||
|
||||||
|
value.includes("messages.1.content.1.tool_use.id") ||
|
||||||
|
value.includes("invalid request format")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isAuthAssistantError(
|
export function isAuthAssistantError(
|
||||||
msg: AssistantMessage | undefined,
|
msg: AssistantMessage | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -482,6 +528,31 @@ export function normalizeTextForComparison(text: string): string {
|
|||||||
* Uses substring matching to handle LLM elaboration (e.g., wrapping in quotes,
|
* Uses substring matching to handle LLM elaboration (e.g., wrapping in quotes,
|
||||||
* adding context, or slight rephrasing that includes the original).
|
* adding context, or slight rephrasing that includes the original).
|
||||||
*/
|
*/
|
||||||
|
// ── Tool Call ID Sanitization (Google Cloud Code Assist) ───────────────────────
|
||||||
|
// Google Cloud Code Assist rejects tool call IDs that contain invalid characters.
|
||||||
|
// OpenAI Codex generates IDs like "call_abc123|item_456" with pipe characters,
|
||||||
|
// but Google requires IDs matching ^[a-zA-Z0-9_-]+$ pattern.
|
||||||
|
// This function sanitizes tool call IDs by replacing invalid characters with underscores.
|
||||||
|
|
||||||
|
export function sanitizeToolCallId(id: string): string {
|
||||||
|
if (!id || typeof id !== "string") return "default_tool_id";
|
||||||
|
|
||||||
|
const cloudCodeAssistPatternReplacement = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
const trimmedInvalidStartChars = cloudCodeAssistPatternReplacement.replace(
|
||||||
|
/^[^a-zA-Z0-9_-]+/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
return trimmedInvalidStartChars.length > 0
|
||||||
|
? trimmedInvalidStartChars
|
||||||
|
: "sanitized_tool_id";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidCloudCodeAssistToolId(id: string): boolean {
|
||||||
|
if (!id || typeof id !== "string") return false;
|
||||||
|
return /^[a-zA-Z0-9_-]+$/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
export function isMessagingToolDuplicate(
|
export function isMessagingToolDuplicate(
|
||||||
text: string,
|
text: string,
|
||||||
sentTexts: string[],
|
sentTexts: string[],
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import {
|
|||||||
ensureSessionHeader,
|
ensureSessionHeader,
|
||||||
formatAssistantErrorText,
|
formatAssistantErrorText,
|
||||||
isAuthAssistantError,
|
isAuthAssistantError,
|
||||||
|
isCloudCodeAssistFormatError,
|
||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
isFailoverAssistantError,
|
isFailoverAssistantError,
|
||||||
isFailoverErrorMessage,
|
isFailoverErrorMessage,
|
||||||
@@ -1527,9 +1528,14 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
const assistantFailoverReason = classifyFailoverReason(
|
const assistantFailoverReason = classifyFailoverReason(
|
||||||
lastAssistant?.errorMessage ?? "",
|
lastAssistant?.errorMessage ?? "",
|
||||||
);
|
);
|
||||||
|
const cloudCodeAssistFormatError = lastAssistant?.errorMessage
|
||||||
|
? isCloudCodeAssistFormatError(lastAssistant.errorMessage)
|
||||||
|
: false;
|
||||||
|
|
||||||
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
|
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
|
||||||
const shouldRotate = (!aborted && failoverFailure) || timedOut;
|
const shouldRotate =
|
||||||
|
(!aborted && (failoverFailure || cloudCodeAssistFormatError)) ||
|
||||||
|
timedOut;
|
||||||
|
|
||||||
if (shouldRotate) {
|
if (shouldRotate) {
|
||||||
// Mark current profile for cooldown before rotating
|
// Mark current profile for cooldown before rotating
|
||||||
@@ -1550,6 +1556,11 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
|
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (cloudCodeAssistFormatError) {
|
||||||
|
log.warn(
|
||||||
|
`Profile ${lastProfileId} hit Cloud Code Assist format error. Tool calls will be sanitized on retry.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const rotated = await advanceAuthProfile();
|
const rotated = await advanceAuthProfile();
|
||||||
if (rotated) {
|
if (rotated) {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ function resolveUserPath(input: string): string {
|
|||||||
|
|
||||||
export const STATE_DIR_CLAWDBOT = resolveStateDir();
|
export const STATE_DIR_CLAWDBOT = resolveStateDir();
|
||||||
|
|
||||||
|
// Legacy exports for backward compatibility during Clawdis → Clawdbot rebrand
|
||||||
|
export const STATE_DIR_CLAWDIS = STATE_DIR_CLAWDBOT;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config file path (JSON5).
|
* Config file path (JSON5).
|
||||||
* Can be overridden via CLAWDBOT_CONFIG_PATH environment variable.
|
* Can be overridden via CLAWDBOT_CONFIG_PATH environment variable.
|
||||||
@@ -60,6 +63,9 @@ export function resolveConfigPath(
|
|||||||
|
|
||||||
export const CONFIG_PATH_CLAWDBOT = resolveConfigPath();
|
export const CONFIG_PATH_CLAWDBOT = resolveConfigPath();
|
||||||
|
|
||||||
|
// Legacy exports for backward compatibility during Clawdis → Clawdbot rebrand
|
||||||
|
export const CONFIG_PATH_CLAWDIS = CONFIG_PATH_CLAWDBOT;
|
||||||
|
|
||||||
export const DEFAULT_GATEWAY_PORT = 18789;
|
export const DEFAULT_GATEWAY_PORT = 18789;
|
||||||
|
|
||||||
const OAUTH_FILENAME = "oauth.json";
|
const OAUTH_FILENAME = "oauth.json";
|
||||||
|
|||||||
Reference in New Issue
Block a user