From 774b254ec1af9f08a9ad7902d126f1cf9bba3799 Mon Sep 17 00:00:00 2001 From: Davide Date: Sat, 20 Jun 2026 13:41:06 +0200 Subject: [PATCH] fix(rate-limit): implement createRateLimitErrorResponse for quota handling - Introduced a new response function to handle rate limit errors with HTTP 403 status. - Updated existing error responses to utilize the new function for consistency. - Cleaned up unused code and improved readability in related functions. --- packages/opencode/src/plugin.ts | 82 +- .../opencode/src/plugin/request-helpers.ts | 4021 +++++++++-------- 2 files changed, 2055 insertions(+), 2048 deletions(-) diff --git a/packages/opencode/src/plugin.ts b/packages/opencode/src/plugin.ts index 79c7ccc..dc58ace 100644 --- a/packages/opencode/src/plugin.ts +++ b/packages/opencode/src/plugin.ts @@ -38,6 +38,7 @@ import { resolveModelWithTier } from "./plugin/transform/model-resolver"; import { isEmptyResponseBody, createSyntheticErrorResponse, + createRateLimitErrorResponse, } from "./plugin/request-helpers"; import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token"; import { startOAuthListener, type OAuthListener } from "./plugin/server"; @@ -80,16 +81,10 @@ const MAX_OAUTH_ACCOUNTS = 10; const MAX_WARMUP_SESSIONS = 1000; const MAX_WARMUP_RETRIES = 2; const MAX_TOTAL_CAPACITY_RETRIES = 4; -const CAPACITY_BACKOFF_TIERS_MS = [5000, 10000, 20000, 30000, 60000]; - function isCapacityRetryBudgetExhausted(totalCapacityRetries: number): boolean { return totalCapacityRetries >= MAX_TOTAL_CAPACITY_RETRIES; } -function getCapacityBackoffDelay(consecutiveFailures: number): number { - const index = Math.min(consecutiveFailures, CAPACITY_BACKOFF_TIERS_MS.length - 1); - return CAPACITY_BACKOFF_TIERS_MS[Math.max(0, index)] ?? 5000; -} const warmupAttemptedSessionIds = new Set(); const warmupSucceededSessionIds = new Set(); @@ -153,7 +148,7 @@ async function triggerAsyncQuotaRefreshForAccount( const accounts = accountManager.getAccounts(); const account = accounts[accountIndex]; - if (!account || account.enabled === false) return; + if (!account || !account.enabled) return; const accountKey = account.email ?? `idx-${accountIndex}`; if (quotaRefreshInProgressByEmail.has(accountKey)) return; @@ -246,16 +241,11 @@ function isWSL2(): boolean { } function isRemoteEnvironment(): boolean { - if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) { - return true; - } - if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) { - return true; - } - if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !isWSL()) { - return true; - } - return false; + return !!( + process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION || + process.env.REMOTE_CONTAINERS || process.env.CODESPACES || + (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !isWSL()) + ); } function shouldSkipLocalServer(): boolean { @@ -546,7 +536,7 @@ async function verifyAccountAccess( try { responseBody = await response.text(); } catch { - responseBody = ""; + // responseBody stays empty string } if (response.ok) { @@ -1165,18 +1155,6 @@ function resetRateLimitState(accountIndex: number, quotaKey: string): void { rateLimitStateByAccountQuota.delete(stateKey); } -/** - * Reset all rate limit state for an account (all quotas). - * Used when account is completely healthy. - */ -function resetAllRateLimitStateForAccount(accountIndex: number): void { - for (const key of rateLimitStateByAccountQuota.keys()) { - if (key.startsWith(`${accountIndex}:`)) { - rateLimitStateByAccountQuota.delete(key); - } - } -} - function headerStyleToQuotaKey(headerStyle: HeaderStyle, family: ModelFamily): string { if (family === "claude") return "claude"; return headerStyle === "antigravity" ? "gemini-antigravity" : "gemini-cli"; @@ -1546,8 +1524,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Validate that stored accounts are in sync with OpenCode's auth // If OpenCode's refresh token doesn't match any stored account, clear stale storage - const authParts = parseRefreshParts(auth.refresh); - const storedAccounts = await loadAccounts(); + // authParts, storedAccounts removed (unused) // Note: AccountManager now ensures the current auth is always included in accounts @@ -1723,7 +1700,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( const accountCount = accountManager.getAccountCount(); const routingDecision = resolveHeaderRoutingDecision(urlString, family, config); const { - cliFirst, preferredHeaderStyle, explicitQuota, allowQuotaFallback, @@ -1782,15 +1758,14 @@ export const createAntigravityPlugin = (providerId: string) => async ( `All accounts over ${threshold}% quota threshold. Resets in ${waitTimeFormatted}.`, "error" ); - return createSyntheticErrorResponse( + return createRateLimitErrorResponse( `Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` + `Quota resets in ${waitTimeFormatted}. ` + `Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.`, - model ?? "unknown", + softQuotaWaitMs ?? undefined, ); } - const waitSecValue = Math.max(1, Math.ceil(softQuotaWaitMs / 1000)); pushDebug(`all-over-soft-quota family=${family} accounts=${accountCount} waitMs=${softQuotaWaitMs}`); if (!softQuotaToastShown) { @@ -1833,11 +1808,11 @@ export const createAntigravityPlugin = (providerId: string) => async ( ); // Return a proper rate limit error response - return createSyntheticErrorResponse( - `All ${accountCount} account(s) rate-limited for ${family}. ` + + return createRateLimitErrorResponse( + `All ${accountCount} account(s) rate limited for ${family}. ` + `Quota resets in ${waitTimeFormatted}. ` + `Add more accounts with \`opencode auth login\` or wait and retry.`, - model ?? "unknown", + waitMs, ); } @@ -2316,7 +2291,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( } const defaultRetryMs = (config.default_retry_after_seconds ?? 60) * 1000; - const maxBackoffMs = (config.max_backoff_seconds ?? 60) * 1000; const headerRetryMs = retryAfterMsFromResponse(response, defaultRetryMs); const bodyInfo = await extractRetryInfoFromBody(response); const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs; @@ -2377,7 +2351,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Only now do we call getRateLimitBackoff, which increments the global failure tracker const quotaKey = headerStyleToQuotaKey(headerStyle, family); - const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs); + const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs); // Calculate potential backoffs const smartBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs); @@ -2409,8 +2383,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( getHealthTracker().recordRateLimit(account.index); - const accountLabel = account.email || `Account ${account.index + 1}`; - // Progressive retry for standard 429s: 1st 429 → 1s then switch (if enabled) or retry same if (attempt === 1 && rateLimitReason !== "QUOTA_EXHAUSTED") { await showToast(`Rate limited. Quick retry in 1s...`, "warning"); @@ -2504,8 +2476,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( } } - const quotaName = headerStyle === "antigravity" ? "Antigravity" : "Gemini CLI"; - if (accountCount > 1) { const quotaMsg = bodyInfo.quotaResetTime ? ` (quota resets ${bodyInfo.quotaResetTime})` @@ -2815,9 +2785,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( lastFailure.dumpContext, ); } - return createSyntheticErrorResponse( - lastError?.message || `Exceeded max account switches (${maxAccountSwitches}). All accounts rate-limited.`, - model ?? "unknown", + return createRateLimitErrorResponse( + lastError?.message || `Exceeded max account switches (${maxAccountSwitches}). All accounts rate limited.`, + undefined, ); } @@ -2840,9 +2810,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( ); } - return createSyntheticErrorResponse( + return createRateLimitErrorResponse( lastError?.message || "All Antigravity endpoints failed", - model ?? "unknown", + undefined, ); } @@ -2868,9 +2838,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( ); } - return createSyntheticErrorResponse( + return createRateLimitErrorResponse( lastError?.message || "All Antigravity accounts failed", - model ?? "unknown", + undefined, ); } }, @@ -2903,7 +2873,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( while (true) { const now = Date.now(); const existingAccounts = existingStorage.accounts.map((acc, idx) => { - let status: 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown' = 'unknown'; + let status: 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown'; if (acc.verificationRequired) { status = 'verification-required'; @@ -3786,12 +3756,12 @@ function toWarmupStreamUrl(value: RequestInfo): string { } function extractModelFromUrl(urlString: string): string | null { - const match = urlString.match(/\/models\/([^:\/?]+)(?::\w+)?/); + const match = urlString.match(/\/models\/([^:/?]+)(?::\w+)?/); return match?.[1] ?? null; } function extractModelFromUrlWithSuffix(urlString: string): string | null { - const match = urlString.match(/\/models\/([^:\/\?]+)/); + const match = urlString.match(/\/models\/([^:/?]+)/); return match?.[1] ?? null; } @@ -3840,7 +3810,7 @@ function resolveHeaderRoutingDecision( cliFirst, preferredHeaderStyle, explicitQuota, - allowQuotaFallback: family === "gemini" && !!(config.quota_style_fallback ?? false), + allowQuotaFallback: family === "gemini" && (config.quota_style_fallback ?? false), }; } diff --git a/packages/opencode/src/plugin/request-helpers.ts b/packages/opencode/src/plugin/request-helpers.ts index 30fa04f..fc29e97 100644 --- a/packages/opencode/src/plugin/request-helpers.ts +++ b/packages/opencode/src/plugin/request-helpers.ts @@ -1,13 +1,8 @@ -import { getKeepThinking } from "./config"; -import { createLogger } from "./logger"; -import { cacheSignature } from "./cache"; -import { - EMPTY_SCHEMA_PLACEHOLDER_NAME, - EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, - SKIP_THOUGHT_SIGNATURE, -} from "../constants"; -import { processImageData } from "./image-saver"; -import type { GoogleSearchConfig } from "./transform/types"; +import {getKeepThinking} from "./config"; +import {createLogger} from "./logger"; +import {EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, EMPTY_SCHEMA_PLACEHOLDER_NAME,} from "../constants"; +import {processImageData} from "./image-saver"; +import type {GoogleSearchConfig} from "./transform/types"; const log = createLogger("request-helpers"); @@ -23,30 +18,30 @@ const ANTIGRAVITY_PREVIEW_LINK = "https://goo.gle/enable-preview-features"; // T * Claude/Gemini reject these in VALIDATED mode. */ const UNSUPPORTED_CONSTRAINTS = [ - "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", - "pattern", "minItems", "maxItems", "format", - "default", "examples", + "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", + "pattern", "minItems", "maxItems", "format", + "default", "examples", ] as const; /** * Keywords that should be removed after hint extraction. */ const UNSUPPORTED_KEYWORDS = [ - ...UNSUPPORTED_CONSTRAINTS, - "$schema", "$defs", "definitions", "const", "$ref", "additionalProperties", - "propertyNames", "title", "$id", "$comment", + ...UNSUPPORTED_CONSTRAINTS, + "$schema", "$defs", "definitions", "const", "$ref", "additionalProperties", + "propertyNames", "title", "$id", "$comment", ] as const; /** * Appends a hint to a schema's description field. */ function appendDescriptionHint(schema: any, hint: string): any { - if (!schema || typeof schema !== "object") { - return schema; - } - const existing = typeof schema.description === "string" ? schema.description : ""; - const newDescription = existing ? `${existing} (${hint})` : hint; - return { ...schema, description: newDescription }; + if (!schema || typeof schema !== "object") { + return schema; + } + const existing = typeof schema.description === "string" ? schema.description : ""; + const newDescription = existing ? `${existing} (${hint})` : hint; + return {...schema, description: newDescription}; } /** @@ -54,30 +49,30 @@ function appendDescriptionHint(schema: any, hint: string): any { * $ref: "#/$defs/Foo" → { type: "object", description: "See: Foo" } */ function convertRefsToHints(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map(item => convertRefsToHints(item)); - } - - // If this object has $ref, replace it with a hint - if (typeof schema.$ref === "string") { - const refVal = schema.$ref; - const defName = refVal.includes("/") ? refVal.split("/").pop() : refVal; - const hint = `See: ${defName}`; - const existingDesc = typeof schema.description === "string" ? schema.description : ""; - const newDescription = existingDesc ? `${existingDesc} (${hint})` : hint; - return { type: "object", description: newDescription }; - } - - // Recursively process all properties - const result: any = {}; - for (const [key, value] of Object.entries(schema)) { - result[key] = convertRefsToHints(value); - } - return result; + if (!schema || typeof schema !== "object") { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map(item => convertRefsToHints(item)); + } + + // If this object has $ref, replace it with a hint + if (typeof schema.$ref === "string") { + const refVal = schema.$ref; + const defName = refVal.includes("/") ? refVal.split("/").pop() : refVal; + const hint = `See: ${defName}`; + const existingDesc = typeof schema.description === "string" ? schema.description : ""; + const newDescription = existingDesc ? `${existingDesc} (${hint})` : hint; + return {type: "object", description: newDescription}; + } + + // Recursively process all properties + const result: any = {}; + for (const [key, value] of Object.entries(schema)) { + result[key] = convertRefsToHints(value); + } + return result; } /** @@ -85,23 +80,23 @@ function convertRefsToHints(schema: any): any { * { const: "foo" } → { enum: ["foo"] } */ function convertConstToEnum(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map(item => convertConstToEnum(item)); - } - - const result: any = {}; - for (const [key, value] of Object.entries(schema)) { - if (key === "const" && !schema.enum) { - result.enum = [value]; - } else { - result[key] = convertConstToEnum(value); + if (!schema || typeof schema !== "object") { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map(item => convertConstToEnum(item)); + } + + const result: any = {}; + for (const [key, value] of Object.entries(schema)) { + if (key === "const" && !schema.enum) { + result.enum = [value]; + } else { + result[key] = convertConstToEnum(value); + } } - } - return result; + return result; } /** @@ -109,30 +104,30 @@ function convertConstToEnum(schema: any): any { * { enum: ["a", "b", "c"] } → adds "(Allowed: a, b, c)" to description */ function addEnumHints(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } + if (!schema || typeof schema !== "object") { + return schema; + } - if (Array.isArray(schema)) { - return schema.map(item => addEnumHints(item)); - } + if (Array.isArray(schema)) { + return schema.map(item => addEnumHints(item)); + } - let result: any = { ...schema }; + let result: any = {...schema}; - // Add enum hint if enum has 2-10 items - if (Array.isArray(result.enum) && result.enum.length > 1 && result.enum.length <= 10) { - const vals = result.enum.map((v: any) => String(v)).join(", "); - result = appendDescriptionHint(result, `Allowed: ${vals}`); - } + // Add enum hint if enum has 2-10 items + if (Array.isArray(result.enum) && result.enum.length > 1 && result.enum.length <= 10) { + const vals = result.enum.map((v: any) => String(v)).join(", "); + result = appendDescriptionHint(result, `Allowed: ${vals}`); + } - // Recursively process nested objects - for (const [key, value] of Object.entries(result)) { - if (key !== "enum" && typeof value === "object" && value !== null) { - result[key] = addEnumHints(value); + // Recursively process nested objects + for (const [key, value] of Object.entries(result)) { + if (key !== "enum" && typeof value === "object" && value !== null) { + result[key] = addEnumHints(value); + } } - } - return result; + return result; } /** @@ -140,28 +135,28 @@ function addEnumHints(schema: any): any { * { additionalProperties: false } → adds "(No extra properties allowed)" to description */ function addAdditionalPropertiesHints(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } + if (!schema || typeof schema !== "object") { + return schema; + } - if (Array.isArray(schema)) { - return schema.map(item => addAdditionalPropertiesHints(item)); - } + if (Array.isArray(schema)) { + return schema.map(item => addAdditionalPropertiesHints(item)); + } - let result: any = { ...schema }; + let result: any = {...schema}; - if (result.additionalProperties === false) { - result = appendDescriptionHint(result, "No extra properties allowed"); - } + if (result.additionalProperties === false) { + result = appendDescriptionHint(result, "No extra properties allowed"); + } - // Recursively process nested objects - for (const [key, value] of Object.entries(result)) { - if (key !== "additionalProperties" && typeof value === "object" && value !== null) { - result[key] = addAdditionalPropertiesHints(value); + // Recursively process nested objects + for (const [key, value] of Object.entries(result)) { + if (key !== "additionalProperties" && typeof value === "object" && value !== null) { + result[key] = addAdditionalPropertiesHints(value); + } } - } - return result; + return result; } /** @@ -169,31 +164,31 @@ function addAdditionalPropertiesHints(schema: any): any { * { minLength: 1, maxLength: 100 } → adds "(minLength: 1) (maxLength: 100)" to description */ function moveConstraintsToDescription(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } + if (!schema || typeof schema !== "object") { + return schema; + } - if (Array.isArray(schema)) { - return schema.map(item => moveConstraintsToDescription(item)); - } + if (Array.isArray(schema)) { + return schema.map(item => moveConstraintsToDescription(item)); + } - let result: any = { ...schema }; + let result: any = {...schema}; - // Move constraint values to description - for (const constraint of UNSUPPORTED_CONSTRAINTS) { - if (result[constraint] !== undefined && typeof result[constraint] !== "object") { - result = appendDescriptionHint(result, `${constraint}: ${result[constraint]}`); + // Move constraint values to description + for (const constraint of UNSUPPORTED_CONSTRAINTS) { + if (result[constraint] !== undefined && typeof result[constraint] !== "object") { + result = appendDescriptionHint(result, `${constraint}: ${result[constraint]}`); + } } - } - // Recursively process nested objects - for (const [key, value] of Object.entries(result)) { - if (typeof value === "object" && value !== null) { - result[key] = moveConstraintsToDescription(value); + // Recursively process nested objects + for (const [key, value] of Object.entries(result)) { + if (typeof value === "object" && value !== null) { + result[key] = moveConstraintsToDescription(value); + } } - } - return result; + return result; } /** @@ -202,73 +197,73 @@ function moveConstraintsToDescription(schema: any): any { * → { properties: { a: ..., b: ... } } */ function mergeAllOf(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } + if (!schema || typeof schema !== "object") { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map(item => mergeAllOf(item)); + } - if (Array.isArray(schema)) { - return schema.map(item => mergeAllOf(item)); - } + let result: any = {...schema}; - let result: any = { ...schema }; + // If this object has allOf, merge its contents + if (Array.isArray(result.allOf)) { + const merged: any = {}; + const mergedRequired: string[] = []; - // If this object has allOf, merge its contents - if (Array.isArray(result.allOf)) { - const merged: any = {}; - const mergedRequired: string[] = []; + for (const item of result.allOf) { + if (!item || typeof item !== "object") continue; - for (const item of result.allOf) { - if (!item || typeof item !== "object") continue; + // Merge properties + if (item.properties && typeof item.properties === "object") { + merged.properties = {...merged.properties, ...item.properties}; + } - // Merge properties - if (item.properties && typeof item.properties === "object") { - merged.properties = { ...merged.properties, ...item.properties }; - } + // Merge required arrays + if (Array.isArray(item.required)) { + for (const req of item.required) { + if (!mergedRequired.includes(req)) { + mergedRequired.push(req); + } + } + } - // Merge required arrays - if (Array.isArray(item.required)) { - for (const req of item.required) { - if (!mergedRequired.includes(req)) { - mergedRequired.push(req); - } + // Copy other fields from allOf items + for (const [key, value] of Object.entries(item)) { + if (key !== "properties" && key !== "required" && merged[key] === undefined) { + merged[key] = value; + } + } } - } - // Copy other fields from allOf items - for (const [key, value] of Object.entries(item)) { - if (key !== "properties" && key !== "required" && merged[key] === undefined) { - merged[key] = value; + // Apply merged content to result + if (merged.properties) { + result.properties = {...result.properties, ...merged.properties}; + } + if (mergedRequired.length > 0) { + const existingRequired = Array.isArray(result.required) ? result.required : []; + result.required = Array.from(new Set([...existingRequired, ...mergedRequired])); } - } - } - // Apply merged content to result - if (merged.properties) { - result.properties = { ...result.properties, ...merged.properties }; - } - if (mergedRequired.length > 0) { - const existingRequired = Array.isArray(result.required) ? result.required : []; - result.required = Array.from(new Set([...existingRequired, ...mergedRequired])); - } + // Copy other merged fields + for (const [key, value] of Object.entries(merged)) { + if (key !== "properties" && key !== "required" && result[key] === undefined) { + result[key] = value; + } + } - // Copy other merged fields - for (const [key, value] of Object.entries(merged)) { - if (key !== "properties" && key !== "required" && result[key] === undefined) { - result[key] = value; - } + delete result.allOf; } - delete result.allOf; - } - - // Recursively process nested objects - for (const [key, value] of Object.entries(result)) { - if (typeof value === "object" && value !== null) { - result[key] = mergeAllOf(value); + // Recursively process nested objects + for (const [key, value] of Object.entries(result)) { + if (typeof value === "object" && value !== null) { + result[key] = mergeAllOf(value); + } } - } - return result; + return result; } /** @@ -276,29 +271,29 @@ function mergeAllOf(schema: any): any { * Higher score = more preferred. */ function scoreSchemaOption(schema: any): { score: number; typeName: string } { - if (!schema || typeof schema !== "object") { - return { score: 0, typeName: "unknown" }; - } + if (!schema || typeof schema !== "object") { + return {score: 0, typeName: "unknown"}; + } - const type = schema.type; + const type = schema.type; - // Object or has properties = highest priority - if (type === "object" || schema.properties) { - return { score: 3, typeName: "object" }; - } + // Object or has properties = highest priority + if (type === "object" || schema.properties) { + return {score: 3, typeName: "object"}; + } - // Array or has items = second priority - if (type === "array" || schema.items) { - return { score: 2, typeName: "array" }; - } + // Array or has items = second priority + if (type === "array" || schema.items) { + return {score: 2, typeName: "array"}; + } - // Any other non-null type - if (type && type !== "null") { - return { score: 1, typeName: type }; - } + // Any other non-null type + if (type && type !== "null") { + return {score: 1, typeName: type}; + } - // Null or no type - return { score: 0, typeName: type || "null" }; + // Null or no type + return {score: 0, typeName: type || "null"}; } /** @@ -311,50 +306,50 @@ function scoreSchemaOption(schema: any): { score: number; typeName: string } { * - anyOf: [{ type: "string", const: "a" }, { type: "string", const: "b" }] */ function tryMergeEnumFromUnion(options: any[]): string[] | null { - if (!Array.isArray(options) || options.length === 0) { - return null; - } + if (!Array.isArray(options) || options.length === 0) { + return null; + } - const enumValues: string[] = []; + const enumValues: string[] = []; - for (const option of options) { - if (!option || typeof option !== "object") { - return null; - } + for (const option of options) { + if (!option || typeof option !== "object") { + return null; + } - // Check for const value - if (option.const !== undefined) { - enumValues.push(String(option.const)); - continue; - } + // Check for const value + if (option.const !== undefined) { + enumValues.push(String(option.const)); + continue; + } - // Check for single-value enum - if (Array.isArray(option.enum) && option.enum.length === 1) { - enumValues.push(String(option.enum[0])); - continue; - } + // Check for single-value enum + if (Array.isArray(option.enum) && option.enum.length === 1) { + enumValues.push(String(option.enum[0])); + continue; + } - // Check for multi-value enum (merge all values) - if (Array.isArray(option.enum) && option.enum.length > 0) { - for (const val of option.enum) { - enumValues.push(String(val)); - } - continue; - } + // Check for multi-value enum (merge all values) + if (Array.isArray(option.enum) && option.enum.length > 0) { + for (const val of option.enum) { + enumValues.push(String(val)); + } + continue; + } - // If option has complex structure (properties, items, etc.), it's not a simple enum - if (option.properties || option.items || option.anyOf || option.oneOf || option.allOf) { - return null; - } + // If option has complex structure (properties, items, etc.), it's not a simple enum + if (option.properties || option.items || option.anyOf || option.oneOf || option.allOf) { + return null; + } - // If option has only type (no const/enum), it's not an enum pattern - if (option.type && !option.const && !option.enum) { - return null; + // If option has only type (no const/enum), it's not an enum pattern + if (option.type) { + return null; + } } - } - // Only return if we found actual enum values - return enumValues.length > 0 ? enumValues : null; + // Only return if we found actual enum values + return enumValues.length > 0 ? enumValues : null; } /** @@ -367,90 +362,90 @@ function tryMergeEnumFromUnion(options: any[]): string[] | null { * → { type: "string", enum: ["a", "b"] } */ function flattenAnyOfOneOf(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map(item => flattenAnyOfOneOf(item)); - } - - let result: any = { ...schema }; - - // Process anyOf or oneOf - for (const unionKey of ["anyOf", "oneOf"] as const) { - if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) { - const options = result[unionKey]; - const parentDesc = typeof result.description === "string" ? result.description : ""; - - // First, check if this is an enum pattern (anyOf with const/enum values) - // This is crucial for tools like WebFetch where format: anyOf[{const:"text"},{const:"markdown"},{const:"html"}] - const mergedEnum = tryMergeEnumFromUnion(options); - if (mergedEnum !== null) { - // This is an enum pattern - merge all values into a single enum - const { [unionKey]: _, ...rest } = result; - result = { - ...rest, - type: "string", - enum: mergedEnum, - }; - // Preserve parent description - if (parentDesc) { - result.description = parentDesc; - } - continue; - } + if (!schema || typeof schema !== "object") { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map(item => flattenAnyOfOneOf(item)); + } + + let result: any = {...schema}; + + // Process anyOf or oneOf + for (const unionKey of ["anyOf", "oneOf"] as const) { + if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) { + const options = result[unionKey]; + const parentDesc = typeof result.description === "string" ? result.description : ""; + + // First, check if this is an enum pattern (anyOf with const/enum values) + // This is crucial for tools like WebFetch where format: anyOf[{const:"text"},{const:"markdown"},{const:"html"}] + const mergedEnum = tryMergeEnumFromUnion(options); + if (mergedEnum !== null) { + // This is an enum pattern - merge all values into a single enum + const {[unionKey]: _, ...rest} = result; + result = { + ...rest, + type: "string", + enum: mergedEnum, + }; + // Preserve parent description + if (parentDesc) { + result.description = parentDesc; + } + continue; + } - // Not an enum pattern - use standard flattening logic - // Score each option and find the best - let bestIdx = 0; - let bestScore = -1; - const allTypes: string[] = []; + // Not an enum pattern - use standard flattening logic + // Score each option and find the best + let bestIdx = 0; + let bestScore = -1; + const allTypes: string[] = []; + + for (let i = 0; i < options.length; i++) { + const {score, typeName} = scoreSchemaOption(options[i]); + if (typeName) { + allTypes.push(typeName); + } + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } - for (let i = 0; i < options.length; i++) { - const { score, typeName } = scoreSchemaOption(options[i]); - if (typeName) { - allTypes.push(typeName); - } - if (score > bestScore) { - bestScore = score; - bestIdx = i; - } - } + // Select the best option and flatten it recursively + let selected = flattenAnyOfOneOf(options[bestIdx]) || {type: "string"}; + + // Preserve parent description + if (parentDesc) { + const childDesc = typeof selected.description === "string" ? selected.description : ""; + if (childDesc && childDesc !== parentDesc) { + selected = {...selected, description: `${parentDesc} (${childDesc})`}; + } else if (!childDesc) { + selected = {...selected, description: parentDesc}; + } + } - // Select the best option and flatten it recursively - let selected = flattenAnyOfOneOf(options[bestIdx]) || { type: "string" }; + if (allTypes.length > 1) { + const uniqueTypes = Array.from(new Set(allTypes)); + const hint = `Accepts: ${uniqueTypes.join(" | ")}`; + selected = appendDescriptionHint(selected, hint); + } - // Preserve parent description - if (parentDesc) { - const childDesc = typeof selected.description === "string" ? selected.description : ""; - if (childDesc && childDesc !== parentDesc) { - selected = { ...selected, description: `${parentDesc} (${childDesc})` }; - } else if (!childDesc) { - selected = { ...selected, description: parentDesc }; + // Replace result with selected schema, preserving other fields + const {[unionKey]: _, description: __, ...rest} = result; + result = {...rest, ...selected}; } - } - - if (allTypes.length > 1) { - const uniqueTypes = Array.from(new Set(allTypes)); - const hint = `Accepts: ${uniqueTypes.join(" | ")}`; - selected = appendDescriptionHint(selected, hint); - } - - // Replace result with selected schema, preserving other fields - const { [unionKey]: _, description: __, ...rest } = result; - result = { ...rest, ...selected }; } - } - // Recursively process nested objects - for (const [key, value] of Object.entries(result)) { - if (typeof value === "object" && value !== null) { - result[key] = flattenAnyOfOneOf(value); + // Recursively process nested objects + for (const [key, value] of Object.entries(result)) { + if (typeof value === "object" && value !== null) { + result[key] = flattenAnyOfOneOf(value); + } } - } - return result; + return result; } /** @@ -458,151 +453,151 @@ function flattenAnyOfOneOf(schema: any): any { * { type: ["string", "null"] } → { type: "string", description: "(nullable)" } */ function flattenTypeArrays(schema: any, nullableFields?: Map, currentPath?: string): any { - if (!schema || typeof schema !== "object") { - return schema; - } + if (!schema || typeof schema !== "object") { + return schema; + } - if (Array.isArray(schema)) { - return schema.map((item, idx) => flattenTypeArrays(item, nullableFields, `${currentPath || ""}[${idx}]`)); - } + if (Array.isArray(schema)) { + return schema.map((item, idx) => flattenTypeArrays(item, nullableFields, `${currentPath || ""}[${idx}]`)); + } - let result: any = { ...schema }; - const localNullableFields = nullableFields || new Map(); + let result: any = {...schema}; + const localNullableFields = nullableFields || new Map(); - // Handle type array - if (Array.isArray(result.type)) { - const types = result.type as string[]; - const hasNull = types.includes("null"); - const nonNullTypes = types.filter(t => t !== "null" && t); + // Handle type array + if (Array.isArray(result.type)) { + const types = result.type as string[]; + const hasNull = types.includes("null"); + const nonNullTypes = types.filter(t => t !== "null" && t); - // Select first non-null type, or "string" as fallback - const firstType = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; - result.type = firstType; + // Select first non-null type, or "string" as fallback + result.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; - // Add hint for multiple types - if (nonNullTypes.length > 1) { - result = appendDescriptionHint(result, `Accepts: ${nonNullTypes.join(" | ")}`); - } + // Add hint for multiple types + if (nonNullTypes.length > 1) { + result = appendDescriptionHint(result, `Accepts: ${nonNullTypes.join(" | ")}`); + } - // Add nullable hint - if (hasNull) { - result = appendDescriptionHint(result, "nullable"); + // Add nullable hint + if (hasNull) { + result = appendDescriptionHint(result, "nullable"); + } } - } - // Recursively process properties - if (result.properties && typeof result.properties === "object") { - const newProps: any = {}; - for (const [propKey, propValue] of Object.entries(result.properties)) { - const propPath = currentPath ? `${currentPath}.properties.${propKey}` : `properties.${propKey}`; - const processed = flattenTypeArrays(propValue, localNullableFields, propPath); - newProps[propKey] = processed; - - // Track nullable fields for required array cleanup - if (processed && typeof processed === "object" && - typeof processed.description === "string" && - processed.description.includes("nullable")) { - const objectPath = currentPath || ""; - const existing = localNullableFields.get(objectPath) || []; - existing.push(propKey); - localNullableFields.set(objectPath, existing); - } + // Recursively process properties + if (result.properties && typeof result.properties === "object") { + const newProps: any = {}; + for (const [propKey, propValue] of Object.entries(result.properties)) { + const propPath = currentPath ? `${currentPath}.properties.${propKey}` : `properties.${propKey}`; + const processed = flattenTypeArrays(propValue, localNullableFields, propPath); + newProps[propKey] = processed; + + // Track nullable fields for required array cleanup + if (processed && typeof processed === "object" && + typeof processed.description === "string" && + processed.description.includes("nullable")) { + const objectPath = currentPath || ""; + const existing = localNullableFields.get(objectPath) || []; + existing.push(propKey); + localNullableFields.set(objectPath, existing); + } + } + result.properties = newProps; } - result.properties = newProps; - } - // Remove nullable fields from required array - if (Array.isArray(result.required) && !nullableFields) { - // Only at root level, filter out nullable fields - const nullableAtRoot = localNullableFields.get("") || []; - if (nullableAtRoot.length > 0) { - result.required = result.required.filter((r: string) => !nullableAtRoot.includes(r)); - if (result.required.length === 0) { - delete result.required; - } + // Remove nullable fields from required array + if (Array.isArray(result.required) && !nullableFields) { + // Only at root level, filter out nullable fields + const nullableAtRoot = localNullableFields.get("") || []; + if (nullableAtRoot.length > 0) { + result.required = result.required.filter((r: string) => !nullableAtRoot.includes(r)); + if (result.required.length === 0) { + delete result.required; + } + } } - } - // Recursively process other nested objects - for (const [key, value] of Object.entries(result)) { - if (key !== "properties" && typeof value === "object" && value !== null) { - result[key] = flattenTypeArrays(value, localNullableFields, `${currentPath || ""}.${key}`); + // Recursively process other nested objects + for (const [key, value] of Object.entries(result)) { + if (key !== "properties" && typeof value === "object" && value !== null) { + result[key] = flattenTypeArrays(value, localNullableFields, `${currentPath || ""}.${key}`); + } } - } - return result; + return result; } /** * Phase 3: Removes unsupported keywords after hints have been extracted. + * @param schema - The JSON Schema node to clean up * @param insideProperties - When true, keys are property NAMES (preserve); when false, keys are JSON Schema keywords (filter). */ function removeUnsupportedKeywords(schema: any, insideProperties: boolean = false): any { - if (!schema || typeof schema !== "object") { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map(item => removeUnsupportedKeywords(item, false)); - } - - const result: any = {}; - for (const [key, value] of Object.entries(schema)) { - if (!insideProperties && (UNSUPPORTED_KEYWORDS as readonly string[]).includes(key)) { - continue; - } - - if (typeof value === "object" && value !== null) { - if (key === "properties") { - const propertiesResult: any = {}; - for (const [propName, propSchema] of Object.entries(value as object)) { - propertiesResult[propName] = removeUnsupportedKeywords(propSchema, false); - } - result[key] = propertiesResult; - } else { - result[key] = removeUnsupportedKeywords(value, false); - } - } else { - result[key] = value; + if (!schema || typeof schema !== "object") { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map(item => removeUnsupportedKeywords(item, false)); + } + + const result: any = {}; + for (const [key, value] of Object.entries(schema)) { + if (!insideProperties && (UNSUPPORTED_KEYWORDS as readonly string[]).includes(key)) { + continue; + } + + if (typeof value === "object" && value !== null) { + if (key === "properties") { + const propertiesResult: any = {}; + for (const [propName, propSchema] of Object.entries(value as object)) { + propertiesResult[propName] = removeUnsupportedKeywords(propSchema, false); + } + result[key] = propertiesResult; + } else { + result[key] = removeUnsupportedKeywords(value, false); + } + } else { + result[key] = value; + } } - } - return result; + return result; } /** * Phase 3b: Cleans up required fields - removes entries that don't exist in properties. */ function cleanupRequiredFields(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } + if (!schema || typeof schema !== "object") { + return schema; + } - if (Array.isArray(schema)) { - return schema.map(item => cleanupRequiredFields(item)); - } + if (Array.isArray(schema)) { + return schema.map(item => cleanupRequiredFields(item)); + } - let result: any = { ...schema }; + let result: any = {...schema}; - // Clean up required array if properties exist - if (Array.isArray(result.required) && result.properties && typeof result.properties === "object") { - const validRequired = result.required.filter((req: string) => - Object.prototype.hasOwnProperty.call(result.properties, req) - ); - if (validRequired.length === 0) { - delete result.required; - } else if (validRequired.length !== result.required.length) { - result.required = validRequired; + // Clean up required array if properties exist + if (Array.isArray(result.required) && result.properties && typeof result.properties === "object") { + const validRequired = result.required.filter((req: string) => + Object.prototype.hasOwnProperty.call(result.properties, req) + ); + if (validRequired.length === 0) { + delete result.required; + } else if (validRequired.length !== result.required.length) { + result.required = validRequired; + } } - } - // Recursively process nested objects - for (const [key, value] of Object.entries(result)) { - if (typeof value === "object" && value !== null) { - result[key] = cleanupRequiredFields(value); + // Recursively process nested objects + for (const [key, value] of Object.entries(result)) { + if (typeof value === "object" && value !== null) { + result[key] = cleanupRequiredFields(value); + } } - } - return result; + return result; } /** @@ -610,79 +605,79 @@ function cleanupRequiredFields(schema: any): any { * Claude VALIDATED mode requires at least one property. */ function addEmptySchemaPlaceholder(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map(item => addEmptySchemaPlaceholder(item)); - } - - let result: any = { ...schema }; - - // Check if this is an empty object schema - const isObjectType = result.type === "object"; - - if (isObjectType) { - const hasProperties = - result.properties && - typeof result.properties === "object" && - Object.keys(result.properties).length > 0; - - if (!hasProperties) { - result.properties = { - [EMPTY_SCHEMA_PLACEHOLDER_NAME]: { - type: "boolean", - description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, - }, - }; - result.required = [EMPTY_SCHEMA_PLACEHOLDER_NAME]; + if (!schema || typeof schema !== "object") { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map(item => addEmptySchemaPlaceholder(item)); + } + + let result: any = {...schema}; + + // Check if this is an empty object schema + const isObjectType = result.type === "object"; + + if (isObjectType) { + const hasProperties = + result.properties && + typeof result.properties === "object" && + Object.keys(result.properties).length > 0; + + if (!hasProperties) { + result.properties = { + [EMPTY_SCHEMA_PLACEHOLDER_NAME]: { + type: "boolean", + description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, + }, + }; + result.required = [EMPTY_SCHEMA_PLACEHOLDER_NAME]; + } } - } - // Recursively process nested objects - for (const [key, value] of Object.entries(result)) { - if (typeof value === "object" && value !== null) { - result[key] = addEmptySchemaPlaceholder(value); + // Recursively process nested objects + for (const [key, value] of Object.entries(result)) { + if (typeof value === "object" && value !== null) { + result[key] = addEmptySchemaPlaceholder(value); + } } - } - return result; + return result; } /** * Cleans a JSON schema for Antigravity API compatibility. * Transforms unsupported features into description hints while preserving semantic information. - * + * * Ported from CLIProxyAPI's CleanJSONSchemaForAntigravity (gemini_schema.go) */ export function cleanJSONSchemaForAntigravity(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema; - } + if (!schema || typeof schema !== "object") { + return schema; + } - let result = schema; + let result = schema; - // Phase 1: Convert and add hints - result = convertRefsToHints(result); - result = convertConstToEnum(result); - result = addEnumHints(result); - result = addAdditionalPropertiesHints(result); - result = moveConstraintsToDescription(result); + // Phase 1: Convert and add hints + result = convertRefsToHints(result); + result = convertConstToEnum(result); + result = addEnumHints(result); + result = addAdditionalPropertiesHints(result); + result = moveConstraintsToDescription(result); - // Phase 2: Flatten complex structures - result = mergeAllOf(result); - result = flattenAnyOfOneOf(result); - result = flattenTypeArrays(result); + // Phase 2: Flatten complex structures + result = mergeAllOf(result); + result = flattenAnyOfOneOf(result); + result = flattenTypeArrays(result); - // Phase 3: Cleanup - result = removeUnsupportedKeywords(result); - result = cleanupRequiredFields(result); + // Phase 3: Cleanup + result = removeUnsupportedKeywords(result); + result = cleanupRequiredFields(result); - // Phase 4: Add placeholder for empty object schemas - result = addEmptySchemaPlaceholder(result); + // Phase 4: Add placeholder for empty object schemas + result = addEmptySchemaPlaceholder(result); - return result; + return result; } // ============================================================================ @@ -690,38 +685,40 @@ export function cleanJSONSchemaForAntigravity(schema: any): any { // ============================================================================ export interface AntigravityApiError { - code?: number; - message?: string; - status?: string; - [key: string]: unknown; + code?: number; + message?: string; + status?: string; + + [key: string]: unknown; } /** * Minimal representation of Antigravity API responses we touch. */ export interface AntigravityApiBody { - response?: unknown; - error?: AntigravityApiError; - [key: string]: unknown; + response?: unknown; + error?: AntigravityApiError; + + [key: string]: unknown; } /** * Usage metadata exposed by Antigravity responses. Fields are optional to reflect partial payloads. */ export interface AntigravityUsageMetadata { - totalTokenCount?: number; - promptTokenCount?: number; - candidatesTokenCount?: number; - cachedContentTokenCount?: number; - thoughtsTokenCount?: number; + totalTokenCount?: number; + promptTokenCount?: number; + candidatesTokenCount?: number; + cachedContentTokenCount?: number; + thoughtsTokenCount?: number; } /** * Normalized thinking configuration accepted by Antigravity. */ export interface ThinkingConfig { - thinkingBudget?: number; - includeThoughts?: boolean; + thinkingBudget?: number; + includeThoughts?: boolean; } /** @@ -735,10 +732,10 @@ export const DEFAULT_THINKING_BUDGET = 16000; * Models with "thinking", "gemini-3", or "opus" in their name support extended thinking. */ export function isThinkingCapableModel(modelName: string): boolean { - const lowerModel = modelName.toLowerCase(); - return lowerModel.includes("thinking") - || lowerModel.includes("gemini-3") - || lowerModel.includes("opus"); + const lowerModel = modelName.toLowerCase(); + return lowerModel.includes("thinking") + || lowerModel.includes("gemini-3") + || lowerModel.includes("opus"); } /** @@ -746,113 +743,113 @@ export function isThinkingCapableModel(modelName: string): boolean { * Supports both Gemini-style thinkingConfig and Anthropic-style thinking options. */ export function extractThinkingConfig( - requestPayload: Record, - rawGenerationConfig: Record | undefined, - extraBody: Record | undefined, + requestPayload: Record, + rawGenerationConfig: Record | undefined, + extraBody: Record | undefined, ): ThinkingConfig | undefined { - const thinkingConfig = rawGenerationConfig?.thinkingConfig - ?? extraBody?.thinkingConfig - ?? requestPayload.thinkingConfig; + const thinkingConfig = rawGenerationConfig?.thinkingConfig + ?? extraBody?.thinkingConfig + ?? requestPayload.thinkingConfig; - if (thinkingConfig && typeof thinkingConfig === "object") { - const config = thinkingConfig as Record; - return { - includeThoughts: Boolean(config.includeThoughts), - thinkingBudget: typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET, - }; - } + if (thinkingConfig && typeof thinkingConfig === "object") { + const config = thinkingConfig as Record; + return { + includeThoughts: Boolean(config.includeThoughts), + thinkingBudget: typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET, + }; + } - // Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N } - const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking; - if (anthropicThinking && typeof anthropicThinking === "object") { - const thinking = anthropicThinking as Record; - if (thinking.type === "enabled" || thinking.budgetTokens) { - return { - includeThoughts: true, - thinkingBudget: typeof thinking.budgetTokens === "number" ? thinking.budgetTokens : DEFAULT_THINKING_BUDGET, - }; + // Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N } + const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking; + if (anthropicThinking && typeof anthropicThinking === "object") { + const thinking = anthropicThinking as Record; + if (thinking.type === "enabled" || thinking.budgetTokens) { + return { + includeThoughts: true, + thinkingBudget: typeof thinking.budgetTokens === "number" ? thinking.budgetTokens : DEFAULT_THINKING_BUDGET, + }; + } } - } - return undefined; + return undefined; } /** * Variant thinking config extracted from OpenCode's providerOptions. */ export interface VariantThinkingConfig { - /** Gemini 3 native thinking level (low/medium/high) */ - thinkingLevel?: string; - /** Numeric thinking budget for Claude and Gemini 2.5 */ - thinkingBudget?: number; - /** Whether to include thoughts in output */ - includeThoughts?: boolean; - /** Google Search configuration */ - googleSearch?: GoogleSearchConfig; + /** Gemini 3 native thinking level (low/medium/high) */ + thinkingLevel?: string; + /** Numeric thinking budget for Claude and Gemini 2.5 */ + thinkingBudget?: number; + /** Whether to include thoughts in output */ + includeThoughts?: boolean; + /** Google Search configuration */ + googleSearch?: GoogleSearchConfig; } /** * Extracts variant thinking config from OpenCode's providerOptions. - * + * * All Antigravity models route through the Google provider, so we only check * providerOptions.google. Supports two formats: - * + * * 1. Gemini 3 native: { google: { thinkingLevel: "high", includeThoughts: true } } * 2. Budget-based (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget: 32000 } } } - * + * * When providerOptions is missing or has no thinking config (common with OpenCode * model variants), falls back to extracting from generationConfig directly: * 3. generationConfig fallback: { thinkingConfig: { thinkingBudget: 8192 } } */ export function extractVariantThinkingConfig( - providerOptions: Record | undefined, - generationConfig?: Record | undefined + providerOptions: Record | undefined, + generationConfig?: Record | undefined ): VariantThinkingConfig | undefined { - const result: VariantThinkingConfig = {}; - - // Primary path: extract from providerOptions.google - const google = (providerOptions?.google) as Record | undefined; - if (google) { - // Gemini 3 native format: { google: { thinkingLevel: "high", includeThoughts: true } } - // thinkingLevel takes priority over thinkingBudget - they are mutually exclusive - if (typeof google.thinkingLevel === "string") { - result.thinkingLevel = google.thinkingLevel; - result.includeThoughts = typeof google.includeThoughts === "boolean" ? google.includeThoughts : undefined; - } else if (google.thinkingConfig && typeof google.thinkingConfig === "object") { - // Budget-based format (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget } } } - // Only used when thinkingLevel is not present - const tc = google.thinkingConfig as Record; - if (typeof tc.thinkingBudget === "number") { - result.thinkingBudget = tc.thinkingBudget; - } - } - - // Extract Google Search config - if (google.googleSearch && typeof google.googleSearch === "object") { - const search = google.googleSearch as Record; - result.googleSearch = { - mode: search.mode === 'auto' || search.mode === 'off' ? search.mode : undefined, - threshold: typeof search.threshold === 'number' ? search.threshold : undefined, - }; - } - } - - // Fallback: OpenCode may pass thinking config in generationConfig - // instead of providerOptions (common when using model variants) - if (result.thinkingBudget === undefined && !result.thinkingLevel && generationConfig) { - if (generationConfig.thinkingConfig && typeof generationConfig.thinkingConfig === "object") { - const tc = generationConfig.thinkingConfig as Record; - if (typeof tc.thinkingLevel === "string") { - // Gemini 3 native format sent via generationConfig - result.thinkingLevel = tc.thinkingLevel; - result.includeThoughts = typeof tc.includeThoughts === "boolean" ? tc.includeThoughts : undefined; - } else if (typeof tc.thinkingBudget === "number") { - result.thinkingBudget = tc.thinkingBudget; - } - } - } - - return Object.keys(result).length > 0 ? result : undefined; + const result: VariantThinkingConfig = {}; + + // Primary path: extract from providerOptions.google + const google = (providerOptions?.google) as Record | undefined; + if (google) { + // Gemini 3 native format: { google: { thinkingLevel: "high", includeThoughts: true } } + // thinkingLevel takes priority over thinkingBudget - they are mutually exclusive + if (typeof google.thinkingLevel === "string") { + result.thinkingLevel = google.thinkingLevel; + result.includeThoughts = typeof google.includeThoughts === "boolean" ? google.includeThoughts : undefined; + } else if (google.thinkingConfig && typeof google.thinkingConfig === "object") { + // Budget-based format (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget } } } + // Only used when thinkingLevel is not present + const tc = google.thinkingConfig as Record; + if (typeof tc.thinkingBudget === "number") { + result.thinkingBudget = tc.thinkingBudget; + } + } + + // Extract Google Search config + if (google.googleSearch && typeof google.googleSearch === "object") { + const search = google.googleSearch as Record; + result.googleSearch = { + mode: search.mode === 'auto' || search.mode === 'off' ? search.mode : undefined, + threshold: typeof search.threshold === 'number' ? search.threshold : undefined, + }; + } + } + + // Fallback: OpenCode may pass thinking config in generationConfig + // instead of providerOptions (common when using model variants) + if (result.thinkingBudget === undefined && !result.thinkingLevel && generationConfig) { + if (generationConfig.thinkingConfig && typeof generationConfig.thinkingConfig === "object") { + const tc = generationConfig.thinkingConfig as Record; + if (typeof tc.thinkingLevel === "string") { + // Gemini 3 native format sent via generationConfig + result.thinkingLevel = tc.thinkingLevel; + result.includeThoughts = typeof tc.includeThoughts === "boolean" ? tc.includeThoughts : undefined; + } else if (typeof tc.thinkingBudget === "number") { + result.thinkingBudget = tc.thinkingBudget; + } + } + } + + return Object.keys(result).length > 0 ? result : undefined; } /** @@ -861,29 +858,29 @@ export function extractVariantThinkingConfig( * The filterUnsignedThinkingBlocks function will handle signature validation/restoration. */ export function resolveThinkingConfig( - userConfig: ThinkingConfig | undefined, - isThinkingModel: boolean, - _isClaudeModel: boolean, - _hasAssistantHistory: boolean, + userConfig: ThinkingConfig | undefined, + isThinkingModel: boolean, + _isClaudeModel: boolean, + _hasAssistantHistory: boolean, ): ThinkingConfig | undefined { - // For thinking-capable models (including Claude thinking models), enable thinking by default - // The signature validation/restoration is handled by filterUnsignedThinkingBlocks - if (isThinkingModel && !userConfig) { - return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }; - } + // For thinking-capable models (including Claude thinking models), enable thinking by default + // The signature validation/restoration is handled by filterUnsignedThinkingBlocks + if (isThinkingModel && !userConfig) { + return {includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET}; + } - return userConfig; + return userConfig; } /** * Checks if a part is a thinking/reasoning block (Anthropic or Gemini style). */ function isThinkingPart(part: Record): boolean { - return part.type === "thinking" - || part.type === "redacted_thinking" - || part.type === "reasoning" - || part.thinking !== undefined - || part.thought === true; + return part.type === "thinking" + || part.type === "redacted_thinking" + || part.type === "reasoning" + || part.thinking !== undefined + || part.thought === true; } /** @@ -891,7 +888,7 @@ function isThinkingPart(part: Record): boolean { * Used to detect foreign thinking blocks that might have unknown type values. */ function hasSignatureField(part: Record): boolean { - return part.signature !== undefined || part.thoughtSignature !== undefined; + return part.signature !== undefined || part.thoughtSignature !== undefined; } /** @@ -903,15 +900,15 @@ function hasSignatureField(part: Record): boolean { * - Gemini: { functionCall }, { functionResponse } */ function isToolBlock(part: Record): boolean { - return part.type === "tool_use" - || part.type === "tool_result" - || part.tool_use_id !== undefined - || part.tool_call_id !== undefined - || part.tool_result !== undefined - || part.tool_use !== undefined - || part.toolUse !== undefined - || part.functionCall !== undefined - || part.functionResponse !== undefined; + return part.type === "tool_use" + || part.type === "tool_result" + || part.tool_use_id !== undefined + || part.tool_call_id !== undefined + || part.tool_result !== undefined + || part.tool_use !== undefined + || part.toolUse !== undefined + || part.functionCall !== undefined + || part.functionResponse !== undefined; } /** @@ -920,48 +917,49 @@ function isToolBlock(part: Record): boolean { * Claude will generate fresh thinking for each turn. */ function stripAllThinkingBlocks(contentArray: any[]): any[] { - return contentArray.map(item => { - if (!item || typeof item !== "object") return item; - if (isToolBlock(item)) return item; - if (isThinkingPart(item) || hasSignatureField(item)) { - // Preserve cache_control from stripped thinking parts - const cc = (item as Record).cache_control; - // Use plain empty text part — thinking-format sentinels get converted by the proxy - // into Claude thinking blocks missing the required `thinking` field. - const sentinel: Record = { text: "." }; - if (cc) sentinel.cache_control = cc; - return sentinel; - } - return item; - }); + return contentArray.map(item => { + if (!item || typeof item !== "object") return item; + if (isToolBlock(item)) return item; + if (isThinkingPart(item) || hasSignatureField(item)) { + // Preserve cache_control from stripped thinking parts + const cc = (item as Record).cache_control; + // Use plain empty text part — thinking-format sentinels get converted by the proxy + // into Claude thinking blocks missing the required `thinking` field. + const sentinel: Record = {text: "."}; + if (cc) sentinel.cache_control = cc; + return sentinel; + } + return item; + }); } + function removeTrailingThinkingBlocks( - contentArray: any[], - sessionId?: string, - getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, + contentArray: any[], + sessionId?: string, + getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, ): any[] { - // Find the last index that is a trailing unsigned thinking block - // Work backwards: replace trailing unsigned thinking blocks with sentinels - // to preserve array length and cache breakpoints - const result = [...contentArray]; + // Find the last index that is a trailing unsigned thinking block + // Work backwards: replace trailing unsigned thinking blocks with sentinels + // to preserve array length and cache breakpoints + const result = [...contentArray]; - for (let i = result.length - 1; i >= 0; i--) { - if (!isThinkingPart(result[i])) break; + for (let i = result.length - 1; i >= 0; i--) { + if (!isThinkingPart(result[i])) break; - const part = result[i]; - const isValid = sessionId && getCachedSignatureFn - ? isOurCachedSignature(part as Record, sessionId, getCachedSignatureFn) - : hasValidSignature(part as Record); - if (isValid) break; + const part = result[i]; + const isValid = sessionId && getCachedSignatureFn + ? isOurCachedSignature(part as Record, sessionId, getCachedSignatureFn) + : hasValidSignature(part as Record); + if (isValid) break; - // Replace with sentinel instead of popping — preserves array length - const cc = part?.cache_control; - const sentinel: Record = { text: "." }; - if (cc) sentinel.cache_control = cc; - result[i] = sentinel; - } + // Replace with sentinel instead of popping — preserves array length + const cc = part?.cache_control; + const sentinel: Record = {text: "."}; + if (cc) sentinel.cache_control = cc; + result[i] = sentinel; + } - return result; + return result; } /** @@ -969,16 +967,16 @@ function removeTrailingThinkingBlocks( * A valid signature is a non-empty string with at least 50 characters. */ function hasValidSignature(part: Record): boolean { - const signature = part.thought === true ? part.thoughtSignature : part.signature; - return typeof signature === "string" && signature.length >= 50; + const signature = part.thought === true ? part.thoughtSignature : part.signature; + return typeof signature === "string" && signature.length >= 50; } /** * Gets the signature from a thinking part, if present. */ function getSignature(part: Record): string | undefined { - const signature = part.thought === true ? part.thoughtSignature : part.signature; - return typeof signature === "string" ? signature : undefined; + const signature = part.thought === true ? part.thoughtSignature : part.signature; + return typeof signature === "string" ? signature : undefined; } /** @@ -987,46 +985,46 @@ function getSignature(part: Record): string | undefined { * which would cause "Invalid signature" errors when sent to Antigravity Claude. */ function isOurCachedSignature( - part: Record, - sessionId: string | undefined, - getCachedSignatureFn: ((sessionId: string, text: string) => string | undefined) | undefined, + part: Record, + sessionId: string | undefined, + getCachedSignatureFn: ((sessionId: string, text: string) => string | undefined) | undefined, ): boolean { - if (!sessionId || !getCachedSignatureFn) { - return false; - } + if (!sessionId || !getCachedSignatureFn) { + return false; + } - const text = getThinkingText(part); - if (!text) { - return false; - } + const text = getThinkingText(part); + if (!text) { + return false; + } - const partSignature = getSignature(part); - if (!partSignature) { - return false; - } + const partSignature = getSignature(part); + if (!partSignature) { + return false; + } - const cachedSignature = getCachedSignatureFn(sessionId, text); - return cachedSignature === partSignature; + const cachedSignature = getCachedSignatureFn(sessionId, text); + return cachedSignature === partSignature; } /** * Gets the text content from a thinking part. */ function getThinkingText(part: Record): string { - if (typeof part.text === "string") return part.text; - if (typeof part.thinking === "string") return part.thinking; + if (typeof part.text === "string") return part.text; + if (typeof part.thinking === "string") return part.thinking; - if (part.text && typeof part.text === "object") { - const maybeText = (part.text as any).text; - if (typeof maybeText === "string") return maybeText; - } + if (part.text && typeof part.text === "object") { + const maybeText = (part.text as any).text; + if (typeof maybeText === "string") return maybeText; + } - if (part.thinking && typeof part.thinking === "object") { - const maybeText = (part.thinking as any).text ?? (part.thinking as any).thinking; - if (typeof maybeText === "string") return maybeText; - } + if (part.thinking && typeof part.thinking === "object") { + const maybeText = (part.thinking as any).text ?? (part.thinking as any).thinking; + if (typeof maybeText === "string") return maybeText; + } - return ""; + return ""; } /** @@ -1034,16 +1032,16 @@ function getThinkingText(part: Record): string { * These fields can be injected by SDKs, but Claude rejects them inside thinking blocks. */ function stripCacheControlRecursively(obj: unknown): unknown { - if (obj === null || obj === undefined) return obj; - if (typeof obj !== "object") return obj; - if (Array.isArray(obj)) return obj.map(item => stripCacheControlRecursively(item)); + if (obj === null || obj === undefined) return obj; + if (typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(item => stripCacheControlRecursively(item)); - const result: Record = {}; - for (const [key, value] of Object.entries(obj as Record)) { - if (key === "cache_control" || key === "providerOptions") continue; - result[key] = stripCacheControlRecursively(value); - } - return result; + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (key === "cache_control" || key === "providerOptions") continue; + result[key] = stripCacheControlRecursively(value); + } + return result; } /** @@ -1052,207 +1050,210 @@ function stripCacheControlRecursively(obj: unknown): unknown { * Returns null if the thinking block has no valid content. */ function sanitizeThinkingPart(part: Record): Record | null { - // Gemini-style thought blocks: { thought: true, text, thoughtSignature } - if (part.thought === true) { - let textContent: unknown = part.text; - if (typeof textContent === "object" && textContent !== null) { - const maybeText = (textContent as any).text; - textContent = typeof maybeText === "string" ? maybeText : undefined; - } + // Gemini-style thought blocks: { thought: true, text, thoughtSignature } + if (part.thought === true) { + let textContent: unknown = part.text; + if (typeof textContent === "object" && textContent !== null) { + const maybeText = (textContent as any).text; + textContent = typeof maybeText === "string" ? maybeText : undefined; + } + + const hasContent = typeof textContent === "string" && textContent.trim().length > 0; + if (!hasContent && !part.thoughtSignature) { + return null; + } - const hasContent = typeof textContent === "string" && textContent.trim().length > 0; - if (!hasContent && !part.thoughtSignature) { - return null; + const sanitized: Record = {thought: true}; + sanitized.text = typeof textContent === "string" ? textContent : ""; + if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature; + if (part.cache_control !== undefined) sanitized.cache_control = part.cache_control; + return sanitized; } - const sanitized: Record = { thought: true }; - sanitized.text = typeof textContent === "string" ? textContent : ""; - if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature; - if (part.cache_control !== undefined) sanitized.cache_control = part.cache_control; - return sanitized; - } + // Anthropic-style thinking/redacted_thinking blocks: { type: "thinking"|"redacted_thinking", thinking, signature } + if (part.type === "thinking" || part.type === "redacted_thinking" || part.thinking !== undefined) { + let thinkingContent: unknown = part.thinking ?? part.text; + if (thinkingContent !== undefined && typeof thinkingContent === "object" && thinkingContent !== null) { + const maybeText = (thinkingContent as any).text ?? (thinkingContent as any).thinking; + thinkingContent = typeof maybeText === "string" ? maybeText : undefined; + } - // Anthropic-style thinking/redacted_thinking blocks: { type: "thinking"|"redacted_thinking", thinking, signature } - if (part.type === "thinking" || part.type === "redacted_thinking" || part.thinking !== undefined) { - let thinkingContent: unknown = part.thinking ?? part.text; - if (thinkingContent !== undefined && typeof thinkingContent === "object" && thinkingContent !== null) { - const maybeText = (thinkingContent as any).text ?? (thinkingContent as any).thinking; - thinkingContent = typeof maybeText === "string" ? maybeText : undefined; - } + const hasContent = typeof thinkingContent === "string" && thinkingContent.trim().length > 0; + if (!hasContent && !part.signature) { + return null; + } - const hasContent = typeof thinkingContent === "string" && thinkingContent.trim().length > 0; - if (!hasContent && !part.signature) { - return null; + const sanitized: Record = {type: part.type === "redacted_thinking" ? "redacted_thinking" : "thinking"}; + sanitized.thinking = typeof thinkingContent === "string" ? thinkingContent : ""; + if (part.signature !== undefined) sanitized.signature = part.signature; + if (part.cache_control !== undefined) sanitized.cache_control = part.cache_control; + return sanitized; } - const sanitized: Record = { type: part.type === "redacted_thinking" ? "redacted_thinking" : "thinking" }; - sanitized.thinking = typeof thinkingContent === "string" ? thinkingContent : ""; - if (part.signature !== undefined) sanitized.signature = part.signature; - if (part.cache_control !== undefined) sanitized.cache_control = part.cache_control; - return sanitized; - } + // Reasoning blocks (OpenCode format): { type: "reasoning", text, signature } + if (part.type === "reasoning") { + let textContent: unknown = part.text; + if (typeof textContent === "object" && textContent !== null) { + const maybeText = (textContent as any).text; + textContent = typeof maybeText === "string" ? maybeText : undefined; + } - // Reasoning blocks (OpenCode format): { type: "reasoning", text, signature } - if (part.type === "reasoning") { - let textContent: unknown = part.text; - if (typeof textContent === "object" && textContent !== null) { - const maybeText = (textContent as any).text; - textContent = typeof maybeText === "string" ? maybeText : undefined; - } + const hasContent = typeof textContent === "string" && textContent.trim().length > 0; + if (!hasContent && !part.signature) { + return null; + } - const hasContent = typeof textContent === "string" && textContent.trim().length > 0; - if (!hasContent && !part.signature) { - return null; + const sanitized: Record = {type: "reasoning"}; + sanitized.text = typeof textContent === "string" ? textContent : ""; + if (part.signature !== undefined) sanitized.signature = part.signature; + if (part.cache_control !== undefined) sanitized.cache_control = part.cache_control; + return sanitized; } - const sanitized: Record = { type: "reasoning" }; - sanitized.text = typeof textContent === "string" ? textContent : ""; - if (part.signature !== undefined) sanitized.signature = part.signature; - if (part.cache_control !== undefined) sanitized.cache_control = part.cache_control; - return sanitized; - } - - // Fallback: only strip cache_control from nested content objects, not part-level markers. - // Part-level cache_control is used by OpenCode for prompt caching and must be preserved. - return stripCacheControlRecursively(part) as Record;} + // Fallback: only strip cache_control from nested content objects, not part-level markers. + // Part-level cache_control is used by OpenCode for prompt caching and must be preserved. + return stripCacheControlRecursively(part) as Record; +} function findLastAssistantIndex(contents: any[], roleValue: "model" | "assistant"): number { - for (let i = contents.length - 1; i >= 0; i--) { - const content = contents[i]; - if (content && typeof content === "object" && content.role === roleValue) { - return i; + for (let i = contents.length - 1; i >= 0; i--) { + const content = contents[i]; + if (content && typeof content === "object" && content.role === roleValue) { + return i; + } } - } - return -1; + return -1; } function filterContentArray( - contentArray: any[], - sessionId?: string, - getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, - isClaudeModel?: boolean, - isLastAssistantMessage: boolean = false, + contentArray: any[], + sessionId?: string, + getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, + isClaudeModel?: boolean, + isLastAssistantMessage: boolean = false, ): any[] { - // For Claude models, strip thinking blocks by default for reliability - // User can opt-in to keep thinking via config: { "keep_thinking": true } - if (isClaudeModel && !getKeepThinking()) { - return stripAllThinkingBlocks(contentArray); - } - - const filtered: any[] = []; - - for (const item of contentArray) { - if (!item || typeof item !== "object") { - filtered.push(item); - continue; - } - - if (isToolBlock(item)) { - if (!isClaudeModel) { - filtered.push(item); - continue; - } - - const sanitizedToolBlock = { ...(item as Record) }; - delete (sanitizedToolBlock as any).signature; - delete (sanitizedToolBlock as any).thoughtSignature; - delete (sanitizedToolBlock as any).thought_signature; - delete (sanitizedToolBlock as any).thought; - filtered.push(sanitizedToolBlock); - continue; - } - - const isThinking = isThinkingPart(item); - const hasSignature = hasSignatureField(item); - - if (!isThinking && !hasSignature) { - filtered.push(item); - continue; - } - - if (isClaudeModel && (isThinking || hasSignature)) { - // Use plain empty text part — thinking-format sentinels get converted by the proxy - // into Claude thinking blocks with missing required fields - const sentinel: Record = { text: "." }; - if (item.cache_control) sentinel.cache_control = item.cache_control; - filtered.push(sentinel); - continue; - } - // For the LAST assistant message with thinking blocks: - // - If signature is OUR cached signature, pass through unchanged - // - Otherwise inject sentinel to bypass Antigravity validation - // NOTE: We can't trust signatures just because they're >= 50 chars - Claude returns - // its own signatures which are long but invalid for Antigravity. - if (isLastAssistantMessage && (isThinking || hasSignature)) { - // First check if it's our cached signature - if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) { - const sanitized = sanitizeThinkingPart(item); - if (sanitized) { - filtered.push(sanitized); - } else { - // sanitizeThinkingPart returned null — use sentinel to preserve array length - const sentinel: Record = { text: "." }; - if (item.cache_control) sentinel.cache_control = item.cache_control; - filtered.push(sentinel); - } - continue; - } - - // Not our signature (or no signature) - use plain empty text sentinel // Thinking-format sentinels get converted by the proxy into Claude thinking blocks with missing required fields - const existingSignature = item.signature || item.thoughtSignature; - const signatureInfo = existingSignature ? `foreign signature (${String(existingSignature).length} chars)` : "no signature"; - log.debug(`Injecting plain text sentinel for last-message thinking block with ${signatureInfo}`); - const sentinel: Record = { text: "." }; - if (item.cache_control) sentinel.cache_control = item.cache_control; - filtered.push(sentinel); - continue; } - - if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) { - const sanitized = sanitizeThinkingPart(item); - if (sanitized) { - filtered.push(sanitized); - } else { - // sanitizeThinkingPart returned null — use sentinel to preserve array length - const sentinel: Record = { text: "." }; - if (item.cache_control) sentinel.cache_control = item.cache_control; - filtered.push(sentinel); - } - continue; - } - - if (sessionId && getCachedSignatureFn) { - const text = getThinkingText(item); - if (text) { - const cachedSignature = getCachedSignatureFn(sessionId, text); - if (cachedSignature && cachedSignature.length >= 50) { - const restoredPart = { ...item }; - if ((item as any).thought === true) { - (restoredPart as any).thoughtSignature = cachedSignature; - } else { - (restoredPart as any).signature = cachedSignature; - } - const sanitized = sanitizeThinkingPart(restoredPart as Record); - if (sanitized) { - filtered.push(sanitized); - } else { - // sanitizeThinkingPart returned null — use sentinel to preserve array length - const sentinel: Record = { text: "." }; + // For Claude models, strip thinking blocks by default for reliability + // User can opt-in to keep thinking via config: { "keep_thinking": true } + if (isClaudeModel && !getKeepThinking()) { + return stripAllThinkingBlocks(contentArray); + } + + const filtered: any[] = []; + + for (const item of contentArray) { + if (!item || typeof item !== "object") { + filtered.push(item); + continue; + } + + if (isToolBlock(item)) { + if (!isClaudeModel) { + filtered.push(item); + continue; + } + + const sanitizedToolBlock = {...(item as Record)}; + delete (sanitizedToolBlock as any).signature; + delete (sanitizedToolBlock as any).thoughtSignature; + delete (sanitizedToolBlock as any).thought_signature; + delete (sanitizedToolBlock as any).thought; + filtered.push(sanitizedToolBlock); + continue; + } + + const isThinking = isThinkingPart(item); + const hasSignature = hasSignatureField(item); + + if (!isThinking && !hasSignature) { + filtered.push(item); + continue; + } + + if (isClaudeModel && (isThinking || hasSignature)) { + // Use plain empty text part — thinking-format sentinels get converted by the proxy + // into Claude thinking blocks with missing required fields + const sentinel: Record = {text: "."}; if (item.cache_control) sentinel.cache_control = item.cache_control; filtered.push(sentinel); - } - continue; + continue; + } + // For the LAST assistant message with thinking blocks: + // - If signature is OUR cached signature, pass through unchanged + // - Otherwise inject sentinel to bypass Antigravity validation + // NOTE: We can't trust signatures just because they're >= 50 chars - Claude returns + // its own signatures which are long but invalid for Antigravity. + if (isLastAssistantMessage && (isThinking || hasSignature)) { + // First check if it's our cached signature + if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) { + const sanitized = sanitizeThinkingPart(item); + if (sanitized) { + filtered.push(sanitized); + } else { + // sanitizeThinkingPart returned null — use sentinel to preserve array length + const sentinel: Record = {text: "."}; + if (item.cache_control) sentinel.cache_control = item.cache_control; + filtered.push(sentinel); + } + continue; + } + + // Not our signature (or no signature) - use plain empty text sentinel // Thinking-format sentinels get converted by the proxy into Claude thinking blocks with missing required fields + const existingSignature = item.signature || item.thoughtSignature; + const signatureInfo = existingSignature ? `foreign signature (${String(existingSignature).length} chars)` : "no signature"; + log.debug(`Injecting plain text sentinel for last-message thinking block with ${signatureInfo}`); + const sentinel: Record = {text: "."}; + if (item.cache_control) sentinel.cache_control = item.cache_control; + filtered.push(sentinel); + continue; + } + + if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) { + const sanitized = sanitizeThinkingPart(item); + if (sanitized) { + filtered.push(sanitized); + } else { + // sanitizeThinkingPart returned null — use sentinel to preserve array length + const sentinel: Record = {text: "."}; + if (item.cache_control) sentinel.cache_control = item.cache_control; + filtered.push(sentinel); + } + continue; } - } - } - // Catch-all: thinking/signature part that didn't match any branch above - // Use sentinel instead of silently dropping to preserve array length - const sentinel: Record = { text: "." }; - if (item.cache_control) sentinel.cache_control = item.cache_control; - filtered.push(sentinel); - } + if (sessionId && getCachedSignatureFn) { + const text = getThinkingText(item); + if (text) { + const cachedSignature = getCachedSignatureFn(sessionId, text); + if (cachedSignature && cachedSignature.length >= 50) { + const restoredPart = {...item}; + if ((item as any).thought === true) { + (restoredPart as any).thoughtSignature = cachedSignature; + } else { + (restoredPart as any).signature = cachedSignature; + } + const sanitized = sanitizeThinkingPart(restoredPart as Record); + if (sanitized) { + filtered.push(sanitized); + } else { + // sanitizeThinkingPart returned null — use sentinel to preserve array length + const sentinel: Record = {text: "."}; + if (item.cache_control) sentinel.cache_control = item.cache_control; + filtered.push(sentinel); + } + continue; + } + } + } + + // Catch-all: thinking/signature part that didn't match any branch above + // Use sentinel instead of silently dropping to preserve array length + const sentinel: Record = {text: "."}; + if (item.cache_control) sentinel.cache_control = item.cache_control; + filtered.push(sentinel); + } - return filtered;} + return filtered; +} /** * Filters thinking blocks from contents unless the signature matches our cache. @@ -1261,150 +1262,151 @@ function filterContentArray( * @param contents - The contents array from the request * @param sessionId - Optional session ID for signature cache lookup * @param getCachedSignatureFn - Optional function to retrieve cached signatures + * @param isClaudeModel - Whether the model is a Claude model (affects signature validation) */ export function filterUnsignedThinkingBlocks( - contents: any[], - sessionId?: string, - getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, - isClaudeModel?: boolean, + contents: any[], + sessionId?: string, + getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, + isClaudeModel?: boolean, ): any[] { - const lastAssistantIdx = findLastAssistantIndex(contents, "model"); + const lastAssistantIdx = findLastAssistantIndex(contents, "model"); - return contents.map((content: any, idx: number) => { - if (!content || typeof content !== "object") { - return content; - } + return contents.map((content: any, idx: number) => { + if (!content || typeof content !== "object") { + return content; + } - const isLastAssistant = idx === lastAssistantIdx; + const isLastAssistant = idx === lastAssistantIdx; - if (Array.isArray((content as any).parts)) { - const filteredParts = filterContentArray( - (content as any).parts, - sessionId, - getCachedSignatureFn, - isClaudeModel, - isLastAssistant, - ); + if (Array.isArray((content as any).parts)) { + const filteredParts = filterContentArray( + (content as any).parts, + sessionId, + getCachedSignatureFn, + isClaudeModel, + isLastAssistant, + ); - const trimmedParts = (content as any).role === "model" && !isClaudeModel - ? removeTrailingThinkingBlocks(filteredParts, sessionId, getCachedSignatureFn) - : filteredParts; + const trimmedParts = (content as any).role === "model" && !isClaudeModel + ? removeTrailingThinkingBlocks(filteredParts, sessionId, getCachedSignatureFn) + : filteredParts; - return { ...content, parts: trimmedParts }; - } + return {...content, parts: trimmedParts}; + } - if (Array.isArray((content as any).content)) { - const isAssistantRole = (content as any).role === "assistant"; - const isLastAssistantContent = idx === lastAssistantIdx || - (isAssistantRole && idx === findLastAssistantIndex(contents, "assistant")); - - const filteredContent = filterContentArray( - (content as any).content, - sessionId, - getCachedSignatureFn, - isClaudeModel, - isLastAssistantContent, - ); + if (Array.isArray((content as any).content)) { + const isAssistantRole = (content as any).role === "assistant"; + const isLastAssistantContent = idx === lastAssistantIdx || + (isAssistantRole && idx === findLastAssistantIndex(contents, "assistant")); - const trimmedContent = isAssistantRole && !isClaudeModel - ? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn) - : filteredContent; + const filteredContent = filterContentArray( + (content as any).content, + sessionId, + getCachedSignatureFn, + isClaudeModel, + isLastAssistantContent, + ); - return { ...content, content: trimmedContent }; - } + const trimmedContent = isAssistantRole && !isClaudeModel + ? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn) + : filteredContent; - return content; - }); + return {...content, content: trimmedContent}; + } + + return content; + }); } /** * Filters thinking blocks from Anthropic-style messages[] payloads using cached signatures. */ export function filterMessagesThinkingBlocks( - messages: any[], - sessionId?: string, - getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, - isClaudeModel?: boolean, + messages: any[], + sessionId?: string, + getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, + isClaudeModel?: boolean, ): any[] { - const lastAssistantIdx = findLastAssistantIndex(messages, "assistant"); + const lastAssistantIdx = findLastAssistantIndex(messages, "assistant"); - return messages.map((message: any, idx: number) => { - if (!message || typeof message !== "object") { - return message; - } + return messages.map((message: any, idx: number) => { + if (!message || typeof message !== "object") { + return message; + } - if (Array.isArray((message as any).content)) { - const isAssistantRole = (message as any).role === "assistant"; - const isLastAssistant = isAssistantRole && idx === lastAssistantIdx; - - const filteredContent = filterContentArray( - (message as any).content, - sessionId, - getCachedSignatureFn, - isClaudeModel, - isLastAssistant, - ); + if (Array.isArray((message as any).content)) { + const isAssistantRole = (message as any).role === "assistant"; + const isLastAssistant = isAssistantRole && idx === lastAssistantIdx; - const trimmedContent = isAssistantRole && !isClaudeModel - ? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn) - : filteredContent; + const filteredContent = filterContentArray( + (message as any).content, + sessionId, + getCachedSignatureFn, + isClaudeModel, + isLastAssistant, + ); - return { ...message, content: trimmedContent }; - } + const trimmedContent = isAssistantRole && !isClaudeModel + ? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn) + : filteredContent; + + return {...message, content: trimmedContent}; + } - return message; - }); + return message; + }); } export function deepFilterThinkingBlocks( - payload: unknown, - sessionId?: string, - getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, - isClaudeModel?: boolean, + payload: unknown, + sessionId?: string, + getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, + isClaudeModel?: boolean, ): unknown { - const visited = new WeakSet(); + const visited = new WeakSet(); - const walk = (value: unknown): void => { - if (!value || typeof value !== "object") { - return; - } + const walk = (value: unknown): void => { + if (!value || typeof value !== "object") { + return; + } - if (visited.has(value as object)) { - return; - } + if (visited.has(value as object)) { + return; + } - visited.add(value as object); + visited.add(value as object); - if (Array.isArray(value)) { - value.forEach((item) => walk(item)); - return; - } + if (Array.isArray(value)) { + value.forEach((item) => walk(item)); + return; + } - const obj = value as Record; + const obj = value as Record; - if (Array.isArray(obj.contents)) { - obj.contents = filterUnsignedThinkingBlocks( - obj.contents as any[], - sessionId, - getCachedSignatureFn, - isClaudeModel, - ); - } + if (Array.isArray(obj.contents)) { + obj.contents = filterUnsignedThinkingBlocks( + obj.contents as any[], + sessionId, + getCachedSignatureFn, + isClaudeModel, + ); + } - if (Array.isArray(obj.messages)) { - obj.messages = filterMessagesThinkingBlocks( - obj.messages as any[], - sessionId, - getCachedSignatureFn, - isClaudeModel, - ); - } + if (Array.isArray(obj.messages)) { + obj.messages = filterMessagesThinkingBlocks( + obj.messages as any[], + sessionId, + getCachedSignatureFn, + isClaudeModel, + ); + } - Object.keys(obj).forEach((key) => walk(obj[key])); - }; + Object.keys(obj).forEach((key) => walk(obj[key])); + }; - walk(payload); - return payload; + walk(payload); + return payload; } /** @@ -1413,88 +1415,88 @@ export function deepFilterThinkingBlocks( * Claude responses through Antigravity may use candidates structure with Anthropic-style parts. */ function transformGeminiCandidate(candidate: any): any { - if (!candidate || typeof candidate !== "object") { - return candidate; - } - - const content = candidate.content; - if (!content || typeof content !== "object" || !Array.isArray(content.parts)) { - return candidate; - } - - const thinkingTexts: string[] = []; - const transformedParts = content.parts.map((part: any) => { - if (!part || typeof part !== "object") { - return part; + if (!candidate || typeof candidate !== "object") { + return candidate; } - // Handle Gemini-style: thought: true - if (part.thought === true) { - const thinkingText = typeof part.text === "string" ? part.text : ""; - thinkingTexts.push(thinkingText); - // Clean object — NO spread to prevent thinking: leaking into output - const transformed: Record = { - type: "reasoning", - text: thinkingText, - thought: true, - }; - const sig = part.thoughtSignature || part.signature; - if (typeof sig === "string" && sig) transformed.thoughtSignature = sig; - if (part.cache_control) transformed.cache_control = part.cache_control; - return transformed; - } - - // Handle Anthropic-style in candidates: type: "thinking" - if (part.type === "thinking") { - const thinkingText = typeof part.thinking === "string" ? part.thinking : typeof part.text === "string" ? part.text : ""; - thinkingTexts.push(thinkingText); - // Clean object — NO spread to prevent thinking: leaking into output - const transformed: Record = { - type: "reasoning", - text: thinkingText, - thought: true, - }; - const sig = part.thoughtSignature || part.signature; - if (typeof sig === "string" && sig) transformed.thoughtSignature = sig; - if (part.cache_control) transformed.cache_control = part.cache_control; - return transformed; - } - // Handle functionCall: parse JSON strings in args and ensure args is always defined - // (Ported from LLM-API-Key-Proxy's _extract_tool_call) - // Fix: When Claude calls a tool with no parameters, args may be undefined. - // opencode expects state.input to be a record, so we must ensure args: {} as fallback. - if (part.functionCall) { - const parsedArgs = part.functionCall.args - ? recursivelyParseJsonStrings(part.functionCall.args) - : {}; - return { - ...part, - functionCall: { - ...part.functionCall, - args: parsedArgs, - }, - }; + const content = candidate.content; + if (!content || typeof content !== "object" || !Array.isArray(content.parts)) { + return candidate; } - // Handle image data (inlineData) - save to disk and return file path - if (part.inlineData) { - const result = processImageData({ - mimeType: part.inlineData.mimeType, - data: part.inlineData.data, - }); - if (result) { - return { text: result }; - } - } + const thinkingTexts: string[] = []; + const transformedParts = content.parts.map((part: any) => { + if (!part || typeof part !== "object") { + return part; + } + + // Handle Gemini-style: thought: true + if (part.thought === true) { + const thinkingText = typeof part.text === "string" ? part.text : ""; + thinkingTexts.push(thinkingText); + // Clean object — NO spread to prevent thinking: leaking into output + const transformed: Record = { + type: "reasoning", + text: thinkingText, + thought: true, + }; + const sig = part.thoughtSignature || part.signature; + if (typeof sig === "string" && sig) transformed.thoughtSignature = sig; + if (part.cache_control) transformed.cache_control = part.cache_control; + return transformed; + } + + // Handle Anthropic-style in candidates: type: "thinking" + if (part.type === "thinking") { + const thinkingText = typeof part.thinking === "string" ? part.thinking : typeof part.text === "string" ? part.text : ""; + thinkingTexts.push(thinkingText); + // Clean object — NO spread to prevent thinking: leaking into output + const transformed: Record = { + type: "reasoning", + text: thinkingText, + thought: true, + }; + const sig = part.thoughtSignature || part.signature; + if (typeof sig === "string" && sig) transformed.thoughtSignature = sig; + if (part.cache_control) transformed.cache_control = part.cache_control; + return transformed; + } + // Handle functionCall: parse JSON strings in args and ensure args is always defined + // (Ported from LLM-API-Key-Proxy's _extract_tool_call) + // Fix: When Claude calls a tool with no parameters, args may be undefined. + // opencode expects state.input to be a record, so we must ensure args: {} as fallback. + if (part.functionCall) { + const parsedArgs = part.functionCall.args + ? recursivelyParseJsonStrings(part.functionCall.args) + : {}; + return { + ...part, + functionCall: { + ...part.functionCall, + args: parsedArgs, + }, + }; + } + + // Handle image data (inlineData) - save to disk and return file path + if (part.inlineData) { + const result = processImageData({ + mimeType: part.inlineData.mimeType, + data: part.inlineData.data, + }); + if (result) { + return {text: result}; + } + } - return part; - }); + return part; + }); - return { - ...candidate, - content: { ...content, parts: transformedParts }, - ...(thinkingTexts.length > 0 ? { reasoning_content: thinkingTexts.join("\n\n") } : {}), - }; + return { + ...candidate, + content: {...content, parts: transformedParts}, + ...(thinkingTexts.length > 0 ? {reasoning_content: thinkingTexts.join("\n\n")} : {}), + }; } /** @@ -1503,212 +1505,213 @@ function transformGeminiCandidate(candidate: any): any { * Also extracts reasoning_content for Anthropic-style responses. */ export function transformThinkingParts(response: unknown): unknown { - if (!response || typeof response !== "object") { - return response; - } - - const resp = response as Record; - const result: Record = { ...resp }; - const reasoningTexts: string[] = []; - - // Handle Anthropic-style content array (type: "thinking") - if (Array.isArray(resp.content)) { - const transformedContent: any[] = []; - for (const block of resp.content) { - if (block && typeof block === "object" && (block as any).type === "thinking") { - const thinkingText = typeof (block as any).thinking === "string" ? (block as any).thinking : typeof (block as any).text === "string" ? (block as any).text : ""; - reasoningTexts.push(thinkingText); - // Clean object — NO spread to prevent thinking: leaking into output - const transformed: Record = { - type: "reasoning", - text: thinkingText, - thought: true, - }; - const sig = (block as any).thoughtSignature || (block as any).signature; - if (typeof sig === "string" && sig) transformed.thoughtSignature = sig; - if ((block as any).cache_control) transformed.cache_control = (block as any).cache_control; - - transformedContent.push(transformed); } else { - transformedContent.push(block); - } + if (!response || typeof response !== "object") { + return response; + } + + const resp = response as Record; + const result: Record = {...resp}; + const reasoningTexts: string[] = []; + + // Handle Anthropic-style content array (type: "thinking") + if (Array.isArray(resp.content)) { + const transformedContent: any[] = []; + for (const block of resp.content) { + if (block && typeof block === "object" && (block as any).type === "thinking") { + const thinkingText = typeof (block as any).thinking === "string" ? (block as any).thinking : typeof (block as any).text === "string" ? (block as any).text : ""; + reasoningTexts.push(thinkingText); + // Clean object — NO spread to prevent thinking: leaking into output + const transformed: Record = { + type: "reasoning", + text: thinkingText, + thought: true, + }; + const sig = (block as any).thoughtSignature || (block as any).signature; + if (typeof sig === "string" && sig) transformed.thoughtSignature = sig; + if ((block as any).cache_control) transformed.cache_control = (block as any).cache_control; + + transformedContent.push(transformed); + } else { + transformedContent.push(block); + } + } + result.content = transformedContent; } - result.content = transformedContent; - } - // Handle Gemini-style candidates array - if (Array.isArray(resp.candidates)) { - result.candidates = resp.candidates.map(transformGeminiCandidate); - } + // Handle Gemini-style candidates array + if (Array.isArray(resp.candidates)) { + result.candidates = resp.candidates.map(transformGeminiCandidate); + } - // Add reasoning_content if we found any thinking blocks (for Anthropic-style) - if (reasoningTexts.length > 0 && !result.reasoning_content) { - result.reasoning_content = reasoningTexts.join("\n\n"); - } + // Add reasoning_content if we found any thinking blocks (for Anthropic-style) + if (reasoningTexts.length > 0 && !result.reasoning_content) { + result.reasoning_content = reasoningTexts.join("\n\n"); + } - return result; + return result; } /** * Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0. */ export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined { - if (!config || typeof config !== "object") { - return undefined; - } + if (!config || typeof config !== "object") { + return undefined; + } - const record = config as Record; - const budgetRaw = record.thinkingBudget ?? record.thinking_budget; - const includeRaw = record.includeThoughts ?? record.include_thoughts; + const record = config as Record; + const budgetRaw = record.thinkingBudget ?? record.thinking_budget; + const includeRaw = record.includeThoughts ?? record.include_thoughts; - const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined; - const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined; + const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined; + const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined; - const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0; - const finalInclude = enableThinking ? includeThoughts ?? false : false; + const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0; + const finalInclude = enableThinking ? includeThoughts ?? false : false; - if (!enableThinking && finalInclude === false && thinkingBudget === undefined && includeThoughts === undefined) { - return undefined; - } + if (!enableThinking && !finalInclude && thinkingBudget === undefined && includeThoughts === undefined) { + return undefined; + } - const normalized: ThinkingConfig = {}; - if (thinkingBudget !== undefined) { - normalized.thinkingBudget = thinkingBudget; - } - if (finalInclude !== undefined) { - normalized.includeThoughts = finalInclude; - } - return normalized; + const normalized: ThinkingConfig = {}; + if (thinkingBudget !== undefined) { + normalized.thinkingBudget = thinkingBudget; + } + if (finalInclude !== undefined) { + normalized.includeThoughts = finalInclude; + } + return normalized; } /** * Parses an Antigravity API body; handles array-wrapped responses the API sometimes returns. */ export function parseAntigravityApiBody(rawText: string): AntigravityApiBody | null { - try { - const parsed = JSON.parse(rawText); - if (Array.isArray(parsed)) { - const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null); - if (firstObject && typeof firstObject === "object") { - return firstObject as AntigravityApiBody; - } - return null; - } + try { + const parsed = JSON.parse(rawText); + if (Array.isArray(parsed)) { + const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null); + if (firstObject && typeof firstObject === "object") { + return firstObject as AntigravityApiBody; + } + return null; + } - if (parsed && typeof parsed === "object") { - return parsed as AntigravityApiBody; - } + if (parsed && typeof parsed === "object") { + return parsed as AntigravityApiBody; + } - return null; - } catch { - return null; - } + return null; + } catch { + return null; + } } /** * Extracts usageMetadata from a response object, guarding types. */ export function extractUsageMetadata(body: AntigravityApiBody): AntigravityUsageMetadata | null { - const usage = (body.response && typeof body.response === "object" - ? (body.response as { usageMetadata?: unknown }).usageMetadata - : undefined) as AntigravityUsageMetadata | undefined; + const usage = (body.response && typeof body.response === "object" + ? (body.response as { usageMetadata?: unknown }).usageMetadata + : undefined) as AntigravityUsageMetadata | undefined; - if (!usage || typeof usage !== "object") { - return null; - } + if (!usage || typeof usage !== "object") { + return null; + } - const asRecord = usage as Record; - const toNumber = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined; + const asRecord = usage as Record; + const toNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; - return { - totalTokenCount: toNumber(asRecord.totalTokenCount), - promptTokenCount: toNumber(asRecord.promptTokenCount), - candidatesTokenCount: toNumber(asRecord.candidatesTokenCount), - cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount), - thoughtsTokenCount: toNumber(asRecord.thoughtsTokenCount), - }; + return { + totalTokenCount: toNumber(asRecord.totalTokenCount), + promptTokenCount: toNumber(asRecord.promptTokenCount), + candidatesTokenCount: toNumber(asRecord.candidatesTokenCount), + cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount), + thoughtsTokenCount: toNumber(asRecord.thoughtsTokenCount), + }; } /** * Walks SSE lines to find a usage-bearing response chunk. */ export function extractUsageFromSsePayload(payload: string): AntigravityUsageMetadata | null { - const lines = payload.split("\n"); - for (const line of lines) { - if (!line.startsWith("data:")) { - continue; - } - const jsonText = line.slice(5).trim(); - if (!jsonText) { - continue; - } - try { - const parsed = JSON.parse(jsonText); - if (parsed && typeof parsed === "object") { - const usage = extractUsageMetadata({ response: (parsed as Record).response }); - if (usage) { - return usage; + const lines = payload.split("\n"); + for (const line of lines) { + if (!line.startsWith("data:")) { + continue; + } + const jsonText = line.slice(5).trim(); + if (!jsonText) { + continue; + } + try { + const parsed = JSON.parse(jsonText); + if (parsed && typeof parsed === "object") { + const usage = extractUsageMetadata({response: (parsed as Record).response}); + if (usage) { + return usage; + } + } + } catch { + // continue (loop naturally continues) } - } - } catch { - continue; } - } - return null; + return null; } /** * Enhances 404 errors for Antigravity models with a direct preview-access message. */ export function rewriteAntigravityPreviewAccessError( - body: AntigravityApiBody, - status: number, - requestedModel?: string, + body: AntigravityApiBody, + status: number, + requestedModel?: string, ): AntigravityApiBody | null { - if (!needsPreviewAccessOverride(status, body, requestedModel)) { - return null; - } + if (!needsPreviewAccessOverride(status, body, requestedModel)) { + return null; + } - const error: AntigravityApiError = body.error ?? {}; - const trimmedMessage = typeof error.message === "string" ? error.message.trim() : ""; - const messagePrefix = trimmedMessage.length > 0 - ? trimmedMessage - : "Antigravity preview features are not enabled for this account."; - const enhancedMessage = `${messagePrefix} Request preview access at ${ANTIGRAVITY_PREVIEW_LINK} before using this model.`; + const error: AntigravityApiError = body.error ?? {}; + const trimmedMessage = typeof error.message === "string" ? error.message.trim() : ""; + const messagePrefix = trimmedMessage.length > 0 + ? trimmedMessage + : "Antigravity preview features are not enabled for this account."; + const enhancedMessage = `${messagePrefix} Request preview access at ${ANTIGRAVITY_PREVIEW_LINK} before using this model.`; - return { - ...body, - error: { - ...error, - message: enhancedMessage, - }, - }; + return { + ...body, + error: { + ...error, + message: enhancedMessage, + }, + }; } function needsPreviewAccessOverride( - status: number, - body: AntigravityApiBody, - requestedModel?: string, + status: number, + body: AntigravityApiBody, + requestedModel?: string, ): boolean { - if (status !== 404) { - return false; - } + if (status !== 404) { + return false; + } - if (isAntigravityModel(requestedModel)) { - return true; - } + if (isAntigravityModel(requestedModel)) { + return true; + } - const errorMessage = typeof body.error?.message === "string" ? body.error.message : ""; - return isAntigravityModel(errorMessage); + const errorMessage = typeof body.error?.message === "string" ? body.error.message : ""; + return isAntigravityModel(errorMessage); } function isAntigravityModel(target?: string): boolean { - if (!target) { - return false; - } + if (!target) { + return false; + } - // Check for Antigravity models instead of Gemini 3 - return /antigravity/i.test(target) || /opus/i.test(target) || /claude/i.test(target); + // Check for Antigravity models instead of Gemini 3 + return /antigravity/i.test(target) || /opus/i.test(target) || /claude/i.test(target); } // ============================================================================ @@ -1717,169 +1720,167 @@ function isAntigravityModel(target?: string): boolean { /** * Checks if a JSON response body represents an empty response. - * + * * Empty responses occur when: * - No candidates in Gemini format * - No choices in OpenAI format * - Candidates/choices exist but have no content - * + * * @param text - The response body text (should be valid JSON) * @returns true if the response is empty */ export function isEmptyResponseBody(text: string): boolean { - if (!text || !text.trim()) { - return true; - } - - try { - const parsed = JSON.parse(text); - - // Check for empty candidates (Gemini/Antigravity format) - if (parsed.candidates !== undefined) { - if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) { - return true; - } - - // Check if first candidate has empty content - const firstCandidate = parsed.candidates[0]; - if (!firstCandidate) { - return true; - } - - // Check for empty parts in content - const content = firstCandidate.content; - if (!content || typeof content !== "object") { - return true; - } - - const parts = content.parts; - if (!Array.isArray(parts) || parts.length === 0) { + if (!text || !text.trim()) { return true; - } - - // Check if all parts are empty (no text, no functionCall) - const hasContent = parts.some((part: any) => { - if (!part || typeof part !== "object") return false; - if (typeof part.text === "string" && part.text.length > 0) return true; - if (part.functionCall) return true; - if (part.thought === true && typeof part.text === "string") return true; - return false; - }); - - if (!hasContent) { - return true; - } } - - // Check for empty choices (OpenAI format - shouldn't occur but handle it) - if (parsed.choices !== undefined) { - if (!Array.isArray(parsed.choices) || parsed.choices.length === 0) { - return true; - } - - const firstChoice = parsed.choices[0]; - if (!firstChoice) { - return true; - } - - // Check for empty message/delta - const message = firstChoice.message || firstChoice.delta; - if (!message) { - return true; - } - - // Check if message has content or tool_calls - if (!message.content && !message.tool_calls && !message.reasoning_content) { - return true; - } - } - - // Check response wrapper (Antigravity envelope) - if (parsed.response !== undefined) { - const response = parsed.response; - if (!response || typeof response !== "object") { + + try { + const parsed = JSON.parse(text); + + // Check for empty candidates (Gemini/Antigravity format) + if (parsed.candidates !== undefined) { + if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) { + return true; + } + + // Check if first candidate has empty content + const firstCandidate = parsed.candidates[0]; + if (!firstCandidate) { + return true; + } + + // Check for empty parts in content + const content = firstCandidate.content; + if (!content || typeof content !== "object") { + return true; + } + + const parts = content.parts; + if (!Array.isArray(parts) || parts.length === 0) { + return true; + } + + // Check if all parts are empty (no text, no functionCall) + if (!parts.some((part: any) => { + if (!part || typeof part !== "object") return false; + if (typeof part.text === "string" && part.text.length > 0) return true; + if (part.functionCall) return true; + return part.thought === true && typeof part.text === "string"; + + })) { + return true; + } + } + + // Check for empty choices (OpenAI format - shouldn't occur but handle it) + if (parsed.choices !== undefined) { + if (!Array.isArray(parsed.choices) || parsed.choices.length === 0) { + return true; + } + + const firstChoice = parsed.choices[0]; + if (!firstChoice) { + return true; + } + + // Check for empty message/delta + const message = firstChoice.message || firstChoice.delta; + if (!message) { + return true; + } + + // Check if message has content or tool_calls + if (!message.content && !message.tool_calls && !message.reasoning_content) { + return true; + } + } + + // Check response wrapper (Antigravity envelope) + if (parsed.response !== undefined) { + const response = parsed.response; + if (!response || typeof response !== "object") { + return true; + } + return isEmptyResponseBody(JSON.stringify(response)); + } + + return false; + } catch { + // JSON parse error - treat as empty return true; - } - return isEmptyResponseBody(JSON.stringify(response)); } - - return false; - } catch { - // JSON parse error - treat as empty - return true; - } } /** * Checks if a streaming SSE response yielded zero meaningful chunks. - * + * * This is used after consuming a streaming response to determine if retry is needed. */ export interface StreamingChunkCounter { - increment: () => void; - getCount: () => number; - hasContent: () => boolean; + increment: () => void; + getCount: () => number; + hasContent: () => boolean; } export function createStreamingChunkCounter(): StreamingChunkCounter { - let count = 0; - let hasRealContent = false; + let count = 0; + let hasRealContent = false; - return { - increment: () => { - count++; - }, - getCount: () => count, - hasContent: () => hasRealContent || count > 0, - }; + return { + increment: () => { + count++; + }, + getCount: () => count, + hasContent: () => hasRealContent || count > 0, + }; } /** * Checks if an SSE line contains meaningful content. - * + * * @param line - A single SSE line (e.g., "data: {...}") * @returns true if the line contains content worth counting */ export function isMeaningfulSseLine(line: string): boolean { - if (!line.startsWith("data: ")) { - return false; - } - - const data = line.slice(6).trim(); - - if (data === "[DONE]") { - return false; - } - - if (!data) { - return false; - } - - try { - const parsed = JSON.parse(data); - - // Check for candidates with content - if (parsed.candidates && Array.isArray(parsed.candidates)) { - for (const candidate of parsed.candidates) { - const parts = candidate?.content?.parts; - if (Array.isArray(parts) && parts.length > 0) { - for (const part of parts) { - if (typeof part?.text === "string" && part.text.length > 0) return true; - if (part?.functionCall) return true; - } - } - } - } - - // Check response wrapper - if (parsed.response?.candidates) { - return isMeaningfulSseLine(`data: ${JSON.stringify(parsed.response)}`); - } - - return false; - } catch { - return false; - } + if (!line.startsWith("data: ")) { + return false; + } + + const data = line.slice(6).trim(); + + if (data === "[DONE]") { + return false; + } + + if (!data) { + return false; + } + + try { + const parsed = JSON.parse(data); + + // Check for candidates with content + if (parsed.candidates && Array.isArray(parsed.candidates)) { + for (const candidate of parsed.candidates) { + const parts = candidate?.content?.parts; + if (Array.isArray(parts) && parts.length > 0) { + for (const part of parts) { + if (typeof part?.text === "string" && part.text.length > 0) return true; + if (part?.functionCall) return true; + } + } + } + } + + // Check response wrapper + if (parsed.response?.candidates) { + return isMeaningfulSseLine(`data: ${JSON.stringify(parsed.response)}`); + } + + return false; + } catch { + return false; + } } // ============================================================================ @@ -1888,17 +1889,17 @@ export function isMeaningfulSseLine(line: string): boolean { /** * Recursively parses JSON strings in nested data structures. - * + * * This is a port of LLM-API-Key-Proxy's _recursively_parse_json_strings() function. - * + * * Handles: * - JSON-stringified values: {"files": "[{...}]"} → {"files": [{...}]} * - Malformed double-encoded JSON (extra trailing chars) * - Escaped control characters (\\n → \n, \\t → \t) - * + * * This is useful because Antigravity sometimes returns JSON-stringified values * in tool arguments, which can cause downstream parsing issues. - * + * * @param obj - The object to recursively parse * @param skipParseKeys - Set of keys whose values should NOT be parsed as JSON (preserved as strings) * @param currentKey - The current key being processed (internal use) @@ -1906,127 +1907,127 @@ export function isMeaningfulSseLine(line: string): boolean { */ // Keys whose string values should NOT be parsed as JSON - they contain literal text content const SKIP_PARSE_KEYS = new Set([ - "oldString", - "newString", - "content", - "filePath", - "path", - "text", - "code", - "source", - "data", - "body", - "message", - "prompt", - "input", - "output", - "result", - "value", - "query", - "pattern", - "replacement", - "template", - "script", - "command", - "snippet", + "oldString", + "newString", + "content", + "filePath", + "path", + "text", + "code", + "source", + "data", + "body", + "message", + "prompt", + "input", + "output", + "result", + "value", + "query", + "pattern", + "replacement", + "template", + "script", + "command", + "snippet", ]); export function recursivelyParseJsonStrings( - obj: unknown, - skipParseKeys: Set = SKIP_PARSE_KEYS, - currentKey?: string, + obj: unknown, + skipParseKeys: Set = SKIP_PARSE_KEYS, + currentKey?: string, ): unknown { - if (obj === null || obj === undefined) { - return obj; - } + if (obj === null || obj === undefined) { + return obj; + } - if (Array.isArray(obj)) { - return obj.map((item) => recursivelyParseJsonStrings(item, skipParseKeys)); - } + if (Array.isArray(obj)) { + return obj.map((item) => recursivelyParseJsonStrings(item, skipParseKeys)); + } - if (typeof obj === "object") { - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = recursivelyParseJsonStrings(value, skipParseKeys, key); + if (typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = recursivelyParseJsonStrings(value, skipParseKeys, key); + } + return result; } - return result; - } - if (typeof obj !== "string") { - return obj; - } + if (typeof obj !== "string") { + return obj; + } - if (currentKey && skipParseKeys.has(currentKey)) { - return obj; - } + if (currentKey && skipParseKeys.has(currentKey)) { + return obj; + } - const stripped = obj.trim(); + const stripped = obj.trim(); - // Check if string contains control character escape sequences - // that need unescaping (\\n, \\t but NOT \\" or \\\\) - const hasControlCharEscapes = obj.includes("\\n") || obj.includes("\\t"); - const hasIntentionalEscapes = obj.includes('\\"') || obj.includes("\\\\"); + // Check if string contains control character escape sequences + // that need unescaping (\\n, \\t but NOT \\" or \\\\) + const hasControlCharEscapes = obj.includes("\\n") || obj.includes("\\t"); + const hasIntentionalEscapes = obj.includes('\\"') || obj.includes("\\\\"); - if (hasControlCharEscapes && !hasIntentionalEscapes) { - try { - // Use JSON.parse with quotes to unescape the string - return JSON.parse(`"${obj}"`); - } catch { - // Continue with original processing - } - } - - // Check if it looks like JSON (starts with { or [) - if (stripped && (stripped[0] === "{" || stripped[0] === "[")) { - // Try standard parsing first - if ( - (stripped.startsWith("{") && stripped.endsWith("}")) || - (stripped.startsWith("[") && stripped.endsWith("]")) - ) { - try { - const parsed = JSON.parse(obj); - return recursivelyParseJsonStrings(parsed); - } catch { - // Continue - } - } - - // Handle malformed JSON: array that doesn't end with ] - if (stripped.startsWith("[") && !stripped.endsWith("]")) { - try { - const lastBracket = stripped.lastIndexOf("]"); - if (lastBracket > 0) { - const cleaned = stripped.slice(0, lastBracket + 1); - const parsed = JSON.parse(cleaned); - log.debug("Auto-corrected malformed JSON array", { - truncatedChars: stripped.length - cleaned.length, - }); - return recursivelyParseJsonStrings(parsed); - } - } catch { - // Continue - } - } - - // Handle malformed JSON: object that doesn't end with } - if (stripped.startsWith("{") && !stripped.endsWith("}")) { - try { - const lastBrace = stripped.lastIndexOf("}"); - if (lastBrace > 0) { - const cleaned = stripped.slice(0, lastBrace + 1); - const parsed = JSON.parse(cleaned); - log.debug("Auto-corrected malformed JSON object", { - truncatedChars: stripped.length - cleaned.length, - }); - return recursivelyParseJsonStrings(parsed); - } - } catch { - // Continue - } - } - } - - return obj; + if (hasControlCharEscapes && !hasIntentionalEscapes) { + try { + // Use JSON.parse with quotes to unescape the string + return JSON.parse(`"${obj}"`); + } catch { + // Continue with original processing + } + } + + // Check if it looks like JSON (starts with { or [) + if (stripped && (stripped[0] === "{" || stripped[0] === "[")) { + // Try standard parsing first + if ( + (stripped.startsWith("{") && stripped.endsWith("}")) || + (stripped.startsWith("[") && stripped.endsWith("]")) + ) { + try { + const parsed = JSON.parse(obj); + return recursivelyParseJsonStrings(parsed); + } catch { + // Continue + } + } + + // Handle malformed JSON: array that doesn't end with ] + if (stripped.startsWith("[") && !stripped.endsWith("]")) { + try { + const lastBracket = stripped.lastIndexOf("]"); + if (lastBracket > 0) { + const cleaned = stripped.slice(0, lastBracket + 1); + const parsed = JSON.parse(cleaned); + log.debug("Auto-corrected malformed JSON array", { + truncatedChars: stripped.length - cleaned.length, + }); + return recursivelyParseJsonStrings(parsed); + } + } catch { + // Continue + } + } + + // Handle malformed JSON: object that doesn't end with } + if (stripped.startsWith("{") && !stripped.endsWith("}")) { + try { + const lastBrace = stripped.lastIndexOf("}"); + if (lastBrace > 0) { + const cleaned = stripped.slice(0, lastBrace + 1); + const parsed = JSON.parse(cleaned); + log.debug("Auto-corrected malformed JSON object", { + truncatedChars: stripped.length - cleaned.length, + }); + return recursivelyParseJsonStrings(parsed); + } + } catch { + // Continue + } + } + } + + return obj; } // ============================================================================ @@ -2035,236 +2036,236 @@ export function recursivelyParseJsonStrings( /** * Groups function calls with their responses, handling ID mismatches. - * + * * This is a port of LLM-API-Key-Proxy's _fix_tool_response_grouping() function. - * + * * When context compaction or other processes strip tool responses, the tool call * IDs become orphaned. This function attempts to recover by: - * + * * 1. Pass 1: Match by exact ID (normal case) * 2. Pass 2: Match by function name (for ID mismatches) * 3. Pass 3: Match "unknown_function" orphans or take first available * 4. Fallback: Create placeholder responses for missing tool results - * + * * @param contents - Array of Gemini-style content messages * @returns Fixed contents array with matched tool responses */ export function fixToolResponseGrouping(contents: any[]): any[] { - if (!Array.isArray(contents) || contents.length === 0) { - return contents; - } - - const newContents: any[] = []; - - // Track pending tool call groups that need responses - const pendingGroups: Array<{ - ids: string[]; - funcNames: string[]; - insertAfterIdx: number; - }> = []; - - // Collected orphan responses (by ID) - const collectedResponses = new Map(); - - for (const content of contents) { - const role = content.role; - const parts = content.parts || []; - - // Check if this is a tool response message - const responseParts = parts.filter((p: any) => p?.functionResponse); - - if (responseParts.length > 0) { - // Collect responses by ID (skip duplicates) - for (const resp of responseParts) { - const respId = resp.functionResponse?.id || ""; - if (respId && !collectedResponses.has(respId)) { - collectedResponses.set(respId, resp); - } - } - - // Try to satisfy the most recent pending group - for (let i = pendingGroups.length - 1; i >= 0; i--) { - const group = pendingGroups[i]!; - if (group.ids.every(id => collectedResponses.has(id))) { - // All IDs found - build the response group - const groupResponses = group.ids.map(id => { - const resp = collectedResponses.get(id); - collectedResponses.delete(id); - return resp; - }); - newContents.push({ parts: groupResponses, role: "user" }); - pendingGroups.splice(i, 1); - break; // Only satisfy one group at a time - } - } - continue; // Don't add the original response message - } - - if (role === "model") { - // Check for function calls in this model message - const funcCalls = parts.filter((p: any) => p?.functionCall); - newContents.push(content); - - if (funcCalls.length > 0) { - const callIds = funcCalls - .map((fc: any) => fc.functionCall?.id || "") - .filter(Boolean); - const funcNames = funcCalls - .map((fc: any) => fc.functionCall?.name || ""); - - if (callIds.length > 0) { - pendingGroups.push({ - ids: callIds, - funcNames, - insertAfterIdx: newContents.length - 1, - }); - } - } - } else { - newContents.push(content); - } - } - - // Handle remaining pending groups with orphan recovery - // Process in reverse order so insertions don't shift indices - pendingGroups.sort((a, b) => b.insertAfterIdx - a.insertAfterIdx); - - for (const group of pendingGroups) { - const groupResponses: any[] = []; - - for (let i = 0; i < group.ids.length; i++) { - const expectedId = group.ids[i]!; - const expectedName = group.funcNames[i] || ""; - - if (collectedResponses.has(expectedId)) { - // Direct ID match - ideal case - groupResponses.push(collectedResponses.get(expectedId)); - collectedResponses.delete(expectedId); - } else if (collectedResponses.size > 0) { - // Need to find an orphan response - let matchedId: string | null = null; - - // Pass 1: Match by function name - for (const [orphanId, orphanResp] of collectedResponses) { - const orphanName = orphanResp.functionResponse?.name || ""; - if (orphanName === expectedName) { - matchedId = orphanId; - break; - } - } - - // Pass 2: Match "unknown_function" orphans - if (!matchedId) { - for (const [orphanId, orphanResp] of collectedResponses) { - if (orphanResp.functionResponse?.name === "unknown_function") { - matchedId = orphanId; - break; + if (!Array.isArray(contents) || contents.length === 0) { + return contents; + } + + const newContents: any[] = []; + + // Track pending tool call groups that need responses + const pendingGroups: Array<{ + ids: string[]; + funcNames: string[]; + insertAfterIdx: number; + }> = []; + + // Collected orphan responses (by ID) + const collectedResponses = new Map(); + + for (const content of contents) { + const role = content.role; + const parts = content.parts || []; + + // Check if this is a tool response message + const responseParts = parts.filter((p: any) => p?.functionResponse); + + if (responseParts.length > 0) { + // Collect responses by ID (skip duplicates) + for (const resp of responseParts) { + const respId = resp.functionResponse?.id || ""; + if (respId && !collectedResponses.has(respId)) { + collectedResponses.set(respId, resp); + } } - } - } - - // Pass 3: Take first available - if (!matchedId) { - matchedId = collectedResponses.keys().next().value ?? null; - } - - if (matchedId) { - const orphanResp = collectedResponses.get(matchedId)!; - collectedResponses.delete(matchedId); - - // Fix the ID and name to match expected - orphanResp.functionResponse.id = expectedId; - if (orphanResp.functionResponse.name === "unknown_function" && expectedName) { - orphanResp.functionResponse.name = expectedName; - } - - log.debug("Auto-repaired tool ID mismatch", { - mappedFrom: matchedId, - mappedTo: expectedId, - functionName: expectedName, - }); - - groupResponses.push(orphanResp); - } - } else { - // No responses available - create placeholder - const placeholder = { - functionResponse: { - name: expectedName || "unknown_function", - response: { - result: { - error: "Tool response was lost during context processing. " + - "This is a recovered placeholder.", - recovered: true, - }, - }, - id: expectedId, - }, - }; - - log.debug("Created placeholder response for missing tool", { - id: expectedId, - name: expectedName, - }); - - groupResponses.push(placeholder); - } + + // Try to satisfy the most recent pending group + for (let i = pendingGroups.length - 1; i >= 0; i--) { + const group = pendingGroups[i]!; + if (group.ids.every(id => collectedResponses.has(id))) { + // All IDs found - build the response group + const groupResponses = group.ids.map(id => { + const resp = collectedResponses.get(id); + collectedResponses.delete(id); + return resp; + }); + newContents.push({parts: groupResponses, role: "user"}); + pendingGroups.splice(i, 1); + break; // Only satisfy one group at a time + } + } + continue; // Don't add the original response message + } + + if (role === "model") { + // Check for function calls in this model message + const funcCalls = parts.filter((p: any) => p?.functionCall); + newContents.push(content); + + if (funcCalls.length > 0) { + const callIds = funcCalls + .map((fc: any) => fc.functionCall?.id || "") + .filter(Boolean); + const funcNames = funcCalls + .map((fc: any) => fc.functionCall?.name || ""); + + if (callIds.length > 0) { + pendingGroups.push({ + ids: callIds, + funcNames, + insertAfterIdx: newContents.length - 1, + }); + } + } + } else { + newContents.push(content); + } } - - if (groupResponses.length > 0) { - // Insert at correct position (after the model message that made the calls) - newContents.splice(group.insertAfterIdx + 1, 0, { - parts: groupResponses, - role: "user", - }); + + // Handle remaining pending groups with orphan recovery + // Process in reverse order so insertions don't shift indices + pendingGroups.sort((a, b) => b.insertAfterIdx - a.insertAfterIdx); + + for (const group of pendingGroups) { + const groupResponses: any[] = []; + + for (let i = 0; i < group.ids.length; i++) { + const expectedId = group.ids[i]!; + const expectedName = group.funcNames[i] || ""; + + if (collectedResponses.has(expectedId)) { + // Direct ID match - ideal case + groupResponses.push(collectedResponses.get(expectedId)); + collectedResponses.delete(expectedId); + } else if (collectedResponses.size > 0) { + // Need to find an orphan response + let matchedId: string | null = null; + + // Pass 1: Match by function name + for (const [orphanId, orphanResp] of collectedResponses) { + const orphanName = orphanResp.functionResponse?.name || ""; + if (orphanName === expectedName) { + matchedId = orphanId; + break; + } + } + + // Pass 2: Match "unknown_function" orphans + if (!matchedId) { + for (const [orphanId, orphanResp] of collectedResponses) { + if (orphanResp.functionResponse?.name === "unknown_function") { + matchedId = orphanId; + break; + } + } + } + + // Pass 3: Take first available + if (!matchedId) { + matchedId = collectedResponses.keys().next().value ?? null; + } + + if (matchedId) { + const orphanResp = collectedResponses.get(matchedId)!; + collectedResponses.delete(matchedId); + + // Fix the ID and name to match expected + orphanResp.functionResponse.id = expectedId; + if (orphanResp.functionResponse.name === "unknown_function" && expectedName) { + orphanResp.functionResponse.name = expectedName; + } + + log.debug("Auto-repaired tool ID mismatch", { + mappedFrom: matchedId, + mappedTo: expectedId, + functionName: expectedName, + }); + + groupResponses.push(orphanResp); + } + } else { + // No responses available - create placeholder + const placeholder = { + functionResponse: { + name: expectedName || "unknown_function", + response: { + result: { + error: "Tool response was lost during context processing. " + + "This is a recovered placeholder.", + recovered: true, + }, + }, + id: expectedId, + }, + }; + + log.debug("Created placeholder response for missing tool", { + id: expectedId, + name: expectedName, + }); + + groupResponses.push(placeholder); + } + } + + if (groupResponses.length > 0) { + // Insert at correct position (after the model message that made the calls) + newContents.splice(group.insertAfterIdx + 1, 0, { + parts: groupResponses, + role: "user", + }); + } } - } - - return newContents; + + return newContents; } /** * Checks if contents have any tool call/response ID mismatches. - * + * * @param contents - Array of Gemini-style content messages * @returns Object with mismatch details */ export function detectToolIdMismatches(contents: any[]): { - hasMismatches: boolean; - expectedIds: string[]; - foundIds: string[]; - missingIds: string[]; - orphanIds: string[]; + hasMismatches: boolean; + expectedIds: string[]; + foundIds: string[]; + missingIds: string[]; + orphanIds: string[]; } { - const expectedIds: string[] = []; - const foundIds: string[] = []; - - for (const content of contents) { - const parts = content.parts || []; - - for (const part of parts) { - if (part?.functionCall?.id) { - expectedIds.push(part.functionCall.id); - } - if (part?.functionResponse?.id) { - foundIds.push(part.functionResponse.id); - } - } - } - - const expectedSet = new Set(expectedIds); - const foundSet = new Set(foundIds); - - const missingIds = expectedIds.filter(id => !foundSet.has(id)); - const orphanIds = foundIds.filter(id => !expectedSet.has(id)); - - return { - hasMismatches: missingIds.length > 0 || orphanIds.length > 0, - expectedIds, - foundIds, - missingIds, - orphanIds, - }; + const expectedIds: string[] = []; + const foundIds: string[] = []; + + for (const content of contents) { + const parts = content.parts || []; + + for (const part of parts) { + if (part?.functionCall?.id) { + expectedIds.push(part.functionCall.id); + } + if (part?.functionResponse?.id) { + foundIds.push(part.functionResponse.id); + } + } + } + + const expectedSet = new Set(expectedIds); + const foundSet = new Set(foundIds); + + const missingIds = expectedIds.filter(id => !foundSet.has(id)); + const orphanIds = foundIds.filter(id => !expectedSet.has(id)); + + return { + hasMismatches: missingIds.length > 0 || orphanIds.length > 0, + expectedIds, + foundIds, + missingIds, + orphanIds, + }; } // ============================================================================ @@ -2276,23 +2277,23 @@ export function detectToolIdMismatches(contents: any[]): { * Works on Claude format messages. */ export function findOrphanedToolUseIds(messages: any[]): Set { - const toolUseIds = new Set(); - const toolResultIds = new Set(); - - for (const msg of messages) { - if (Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.id) { - toolUseIds.add(block.id); - } - if (block.type === "tool_result" && block.tool_use_id) { - toolResultIds.add(block.tool_use_id); + const toolUseIds = new Set(); + const toolResultIds = new Set(); + + for (const msg of messages) { + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.id) { + toolUseIds.add(block.id); + } + if (block.type === "tool_result" && block.tool_use_id) { + toolResultIds.add(block.tool_use_id); + } + } } - } } - } - return new Set([...toolUseIds].filter((id) => !toolResultIds.has(id))); + return new Set([...toolUseIds].filter((id) => !toolResultIds.has(id))); } /** @@ -2307,94 +2308,94 @@ export function findOrphanedToolUseIds(messages: any[]): Set { * @returns Fixed messages with placeholder tool_results for orphans */ export function fixClaudeToolPairing(messages: any[]): any[] { - if (!Array.isArray(messages) || messages.length === 0) { - return messages; - } - - // 1. Collect all tool_use IDs from assistant messages - const toolUseMap = new Map(); - - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; - if (msg.role === "assistant" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.id) { - toolUseMap.set(block.id, { name: block.name || `tool-${toolUseMap.size}`, msgIndex: i }); - } - } - } - } - - // 2. Collect all tool_result IDs from user messages - const toolResultIds = new Set(); - - for (const msg of messages) { - if (msg.role === "user" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_result" && block.tool_use_id) { - toolResultIds.add(block.tool_use_id); - } - } - } - } - - // 3. Find orphaned tool_use (no matching tool_result) - const orphans: Array<{ id: string; name: string; msgIndex: number }> = []; - - for (const [id, info] of toolUseMap) { - if (!toolResultIds.has(id)) { - orphans.push({ id, ...info }); - } - } - - if (orphans.length === 0) { - return messages; - } - - // 4. Group orphans by message index (insert after each assistant message) - const orphansByMsgIndex = new Map(); - for (const orphan of orphans) { - const existing = orphansByMsgIndex.get(orphan.msgIndex) || []; - existing.push(orphan); - orphansByMsgIndex.set(orphan.msgIndex, existing); - } - - // 5. Build new messages array with injected tool_results - const result: any[] = []; - - for (let i = 0; i < messages.length; i++) { - result.push(messages[i]); - - const orphansForMsg = orphansByMsgIndex.get(i); - if (orphansForMsg && orphansForMsg.length > 0) { - // Check if next message is user with tool_result - if so, merge into it - const nextMsg = messages[i + 1]; - if (nextMsg?.role === "user" && Array.isArray(nextMsg.content)) { - // Will be handled when we push nextMsg - add to its content - const placeholders = orphansForMsg.map((o) => ({ - type: "tool_result", - tool_use_id: o.id, - content: `[Tool "${o.name}" execution was cancelled or failed]`, - is_error: true, - })); - // Prepend placeholders to next message's content - nextMsg.content = [...placeholders, ...nextMsg.content]; - } else { - // Inject new user message with placeholder tool_results - result.push({ - role: "user", - content: orphansForMsg.map((o) => ({ - type: "tool_result", - tool_use_id: o.id, - content: `[Tool "${o.name}" execution was cancelled or failed]`, - is_error: true, - })), - }); - } + if (!Array.isArray(messages) || messages.length === 0) { + return messages; + } + + // 1. Collect all tool_use IDs from assistant messages + const toolUseMap = new Map(); + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.id) { + toolUseMap.set(block.id, {name: block.name || `tool-${toolUseMap.size}`, msgIndex: i}); + } + } + } + } + + // 2. Collect all tool_result IDs from user messages + const toolResultIds = new Set(); + + for (const msg of messages) { + if (msg.role === "user" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_result" && block.tool_use_id) { + toolResultIds.add(block.tool_use_id); + } + } + } + } + + // 3. Find orphaned tool_use (no matching tool_result) + const orphans: Array<{ id: string; name: string; msgIndex: number }> = []; + + for (const [id, info] of toolUseMap) { + if (!toolResultIds.has(id)) { + orphans.push({id, ...info}); + } + } + + if (orphans.length === 0) { + return messages; + } + + // 4. Group orphans by message index (insert after each assistant message) + const orphansByMsgIndex = new Map(); + for (const orphan of orphans) { + const existing = orphansByMsgIndex.get(orphan.msgIndex) || []; + existing.push(orphan); + orphansByMsgIndex.set(orphan.msgIndex, existing); + } + + // 5. Build new messages array with injected tool_results + const result: any[] = []; + + for (let i = 0; i < messages.length; i++) { + result.push(messages[i]); + + const orphansForMsg = orphansByMsgIndex.get(i); + if (orphansForMsg && orphansForMsg.length > 0) { + // Check if next message is user with tool_result - if so, merge into it + const nextMsg = messages[i + 1]; + if (nextMsg?.role === "user" && Array.isArray(nextMsg.content)) { + // Will be handled when we push nextMsg - add to its content + const placeholders = orphansForMsg.map((o) => ({ + type: "tool_result", + tool_use_id: o.id, + content: `[Tool "${o.name}" execution was cancelled or failed]`, + is_error: true, + })); + // Prepend placeholders to next message's content + nextMsg.content = [...placeholders, ...nextMsg.content]; + } else { + // Inject new user message with placeholder tool_results + result.push({ + role: "user", + content: orphansForMsg.map((o) => ({ + type: "tool_result", + tool_use_id: o.id, + content: `[Tool "${o.name}" execution was cancelled or failed]`, + is_error: true, + })), + }); + } + } } - } - return result; + return result; } /** @@ -2402,23 +2403,23 @@ export function fixClaudeToolPairing(messages: any[]): any[] { * Called when fixClaudeToolPairing() fails to pair all tools. */ function removeOrphanedToolUse(messages: any[], orphanIds: Set): any[] { - return messages - .map((msg) => { - if (msg.role === "assistant" && Array.isArray(msg.content)) { - return { - ...msg, - content: msg.content.filter( - (block: any) => block.type !== "tool_use" || !orphanIds.has(block.id) - ), - }; - } - return msg; - }) - .filter( - (msg) => - // Remove empty assistant messages - !(msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0) - ); + return messages + .map((msg) => { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + return { + ...msg, + content: msg.content.filter( + (block: any) => block.type !== "tool_use" || !orphanIds.has(block.id) + ), + }; + } + return msg; + }) + .filter( + (msg) => + // Remove empty assistant messages + !(msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0) + ); } /** @@ -2426,27 +2427,27 @@ function removeOrphanedToolUse(messages: any[], orphanIds: Set): any[] { * Defense in depth: tries gentle fix first, then nuclear removal. */ export function validateAndFixClaudeToolPairing(messages: any[]): any[] { - if (!Array.isArray(messages) || messages.length === 0) { - return messages; - } + if (!Array.isArray(messages) || messages.length === 0) { + return messages; + } - // First: Try gentle fix (inject placeholder tool_results) - let fixed = fixClaudeToolPairing(messages); + // First: Try gentle fix (inject placeholder tool_results) + let fixed = fixClaudeToolPairing(messages); - // Second: Validate - find any remaining orphans - const orphanIds = findOrphanedToolUseIds(fixed); + // Second: Validate - find any remaining orphans + const orphanIds = findOrphanedToolUseIds(fixed); - if (orphanIds.size === 0) { - return fixed; - } + if (orphanIds.size === 0) { + return fixed; + } - // Third: Nuclear option - remove orphaned tool_use entirely - // This should rarely happen, but provides defense in depth - console.warn("[antigravity] fixClaudeToolPairing left orphans, applying nuclear option", { - orphanIds: [...orphanIds], - }); + // Third: Nuclear option - remove orphaned tool_use entirely + // This should rarely happen, but provides defense in depth + console.warn("[antigravity] fixClaudeToolPairing left orphans, applying nuclear option", { + orphanIds: [...orphanIds], + }); - return removeOrphanedToolUse(fixed, orphanIds); + return removeOrphanedToolUse(fixed, orphanIds); } // ============================================================================ @@ -2458,160 +2459,161 @@ export function validateAndFixClaudeToolPairing(messages: any[]): any[] { * Port of LLM-API-Key-Proxy's _format_type_hint() */ function formatTypeHint(propData: Record, depth = 0): string { - const type = propData.type as string ?? "unknown"; - - // Handle enum values - if (propData.enum && Array.isArray(propData.enum)) { - const enumVals = propData.enum as unknown[]; - if (enumVals.length <= 5) { - return `string ENUM[${enumVals.map(v => JSON.stringify(v)).join(", ")}]`; - } - return `string ENUM[${enumVals.length} options]`; - } - - // Handle const values - if (propData.const !== undefined) { - return `string CONST=${JSON.stringify(propData.const)}`; - } - - if (type === "array") { - const items = propData.items as Record | undefined; - if (items && typeof items === "object") { - const itemType = items.type as string ?? "unknown"; - if (itemType === "object") { - const nestedProps = items.properties as Record | undefined; - const nestedReq = items.required as string[] | undefined ?? []; - if (nestedProps && depth < 1) { - const nestedList = Object.entries(nestedProps).map(([n, d]) => { - const t = (d as Record).type as string ?? "unknown"; - const req = nestedReq.includes(n) ? " REQUIRED" : ""; - return `${n}: ${t}${req}`; - }); - return `ARRAY_OF_OBJECTS[${nestedList.join(", ")}]`; + const type = propData.type as string ?? "unknown"; + + // Handle enum values + if (propData.enum && Array.isArray(propData.enum)) { + const enumVals = propData.enum as unknown[]; + if (enumVals.length <= 5) { + return `string ENUM[${enumVals.map(v => JSON.stringify(v)).join(", ")}]`; + } + return `string ENUM[${enumVals.length} options]`; + } + + // Handle const values + if (propData.const !== undefined) { + return `string CONST=${JSON.stringify(propData.const)}`; + } + + if (type === "array") { + const items = propData.items as Record | undefined; + if (items && typeof items === "object") { + const itemType = items.type as string ?? "unknown"; + if (itemType === "object") { + const nestedProps = items.properties as Record | undefined; + const nestedReq = items.required as string[] | undefined ?? []; + if (nestedProps && depth < 1) { + const nestedList = Object.entries(nestedProps).map(([n, d]) => { + const t = (d as Record).type as string ?? "unknown"; + const req = nestedReq.includes(n) ? " REQUIRED" : ""; + return `${n}: ${t}${req}`; + }); + return `ARRAY_OF_OBJECTS[${nestedList.join(", ")}]`; + } + return "ARRAY_OF_OBJECTS"; + } + return `ARRAY_OF_${itemType.toUpperCase()}`; } - return "ARRAY_OF_OBJECTS"; - } - return `ARRAY_OF_${itemType.toUpperCase()}`; + return "ARRAY"; } - return "ARRAY"; - } - if (type === "object") { - const nestedProps = propData.properties as Record | undefined; - const nestedReq = propData.required as string[] | undefined ?? []; - if (nestedProps && depth < 1) { - const nestedList = Object.entries(nestedProps).map(([n, d]) => { - const t = (d as Record).type as string ?? "unknown"; - const req = nestedReq.includes(n) ? " REQUIRED" : ""; - return `${n}: ${t}${req}`; - }); - return `object{${nestedList.join(", ")}}`; + if (type === "object") { + const nestedProps = propData.properties as Record | undefined; + const nestedReq = propData.required as string[] | undefined ?? []; + if (nestedProps && depth < 1) { + const nestedList = Object.entries(nestedProps).map(([n, d]) => { + const t = (d as Record).type as string ?? "unknown"; + const req = nestedReq.includes(n) ? " REQUIRED" : ""; + return `${n}: ${t}${req}`; + }); + return `object{${nestedList.join(", ")}}`; + } } - } - return type; + return type; } /** * Injects parameter signatures into tool descriptions. * Port of LLM-API-Key-Proxy's _inject_signature_into_descriptions() - * + * * This helps prevent tool hallucination by explicitly listing parameters * in the description, making it harder for the model to hallucinate * parameters from its training data. - * + * * @param tools - Array of tool definitions (Gemini format) * @param promptTemplate - Template for the signature (default: "\\n\\nSTRICT PARAMETERS: {params}.") * @returns Modified tools array with signatures injected */ export function injectParameterSignatures( - tools: any[], - promptTemplate = "\n\n⚠️ STRICT PARAMETERS: {params}.", + tools: any[], + promptTemplate = "\n\n⚠️ STRICT PARAMETERS: {params}.", ): any[] { - if (!tools || !Array.isArray(tools)) return tools; - - return tools.map((tool) => { - const declarations = tool.functionDeclarations; - if (!Array.isArray(declarations)) return tool; - - const newDeclarations = declarations.map((decl: any) => { - // Skip if signature already injected (avoids duplicate injection) - if (decl.description?.includes("STRICT PARAMETERS:")) { - return decl; - } - - const schema = decl.parameters || decl.parametersJsonSchema; - if (!schema) return decl; - - const required = schema.required as string[] ?? []; - const properties = schema.properties as Record ?? {}; - - if (Object.keys(properties).length === 0) return decl; - - const paramList = Object.entries(properties).map(([propName, propData]) => { - const typeHint = formatTypeHint(propData as Record); - const isRequired = required.includes(propName); - return `${propName} (${typeHint}${isRequired ? ", REQUIRED" : ""})`; - }); - - const sigStr = promptTemplate.replace("{params}", paramList.join(", ")); - - return { - ...decl, - description: (decl.description || "") + sigStr, - }; - }); + if (!tools || !Array.isArray(tools)) return tools; + + return tools.map((tool) => { + const declarations = tool.functionDeclarations; + if (!Array.isArray(declarations)) return tool; + + const newDeclarations = declarations.map((decl: any) => { + // Skip if signature already injected (avoids duplicate injection) + if (decl.description?.includes("STRICT PARAMETERS:")) { + return decl; + } + + const schema = decl.parameters || decl.parametersJsonSchema; + if (!schema) return decl; + + const required = schema.required as string[] ?? []; + const properties = schema.properties as Record ?? {}; + + if (Object.keys(properties).length === 0) return decl; + + const paramList = Object.entries(properties).map(([propName, propData]) => { + const typeHint = formatTypeHint(propData as Record); + const isRequired = required.includes(propName); + return `${propName} (${typeHint}${isRequired ? ", REQUIRED" : ""})`; + }); - return { ...tool, functionDeclarations: newDeclarations }; - }); + const sigStr = promptTemplate.replace("{params}", paramList.join(", ")); + + return { + ...decl, + description: (decl.description || "") + sigStr, + }; + }); + + return {...tool, functionDeclarations: newDeclarations}; + }); } /** * Injects a tool hardening system instruction into the request payload. * Port of LLM-API-Key-Proxy's _inject_tool_hardening_instruction() - * + * * @param payload - The Gemini request payload * @param instructionText - The instruction text to inject */ export function injectToolHardeningInstruction( - payload: Record, - instructionText: string, + payload: Record, + instructionText: string, ): void { - if (!instructionText) return; + if (!instructionText) return; - // Skip if instruction already present (avoids duplicate injection) - const existing = payload.systemInstruction as Record | undefined; - if (existing && typeof existing === "object" && "parts" in existing) { - const parts = existing.parts as Array<{ text?: string }>; - if (Array.isArray(parts) && parts.some(p => p.text?.includes("CRITICAL TOOL USAGE INSTRUCTIONS"))) { - return; + // Skip if instruction already present (avoids duplicate injection) + const existing = payload.systemInstruction as Record | undefined; + if (existing && typeof existing === "object" && "parts" in existing) { + const parts = existing.parts as Array<{ text?: string }>; + if (Array.isArray(parts) && parts.some(p => p.text?.includes("CRITICAL TOOL USAGE INSTRUCTIONS"))) { + return; + } } - } - const instructionPart = { text: instructionText }; + const instructionPart = {text: instructionText}; - if (payload.systemInstruction) { - if (existing && typeof existing === "object" && "parts" in existing) { - const parts = existing.parts as unknown[]; - if (Array.isArray(parts)) { - parts.push(instructionPart); - } - } else if (typeof existing === "string") { - payload.systemInstruction = { - role: "user", - parts: [{ text: existing }, instructionPart], - }; } else { - payload.systemInstruction = { - role: "user", - parts: [instructionPart], - }; - } - } else { - payload.systemInstruction = { - role: "user", - parts: [instructionPart], - }; - } + if (payload.systemInstruction) { + if (existing && typeof existing === "object" && "parts" in existing) { + const parts = existing.parts as unknown[]; + if (Array.isArray(parts)) { + parts.push(instructionPart); + } + } else if (typeof existing === "string") { + payload.systemInstruction = { + role: "user", + parts: [{text: existing}, instructionPart], + }; + } else { + payload.systemInstruction = { + role: "user", + parts: [instructionPart], + }; + } + } else { + payload.systemInstruction = { + role: "user", + parts: [instructionPart], + }; + } } // ============================================================================ @@ -2622,84 +2624,84 @@ export function injectToolHardeningInstruction( /** * Assigns IDs to functionCall parts and returns the pending call IDs by name. * This is the first pass of tool ID assignment. - * + * * @param contents - Gemini-style contents array * @returns Object with modified contents and pending call IDs map */ export function assignToolIdsToContents( - contents: any[] + contents: any[] ): { contents: any[]; pendingCallIdsByName: Map; toolCallCounter: number } { - if (!Array.isArray(contents)) { - return { contents, pendingCallIdsByName: new Map(), toolCallCounter: 0 }; - } - - let toolCallCounter = 0; - const pendingCallIdsByName = new Map(); - - const newContents = contents.map((content: any) => { - if (!content || !Array.isArray(content.parts)) { - return content; - } - - const newParts = content.parts.map((part: any) => { - if (part && typeof part === "object" && part.functionCall) { - const call = { ...part.functionCall }; - if (!call.id) { - call.id = `tool-call-${++toolCallCounter}`; - } - const nameKey = typeof call.name === "string" ? call.name : `tool-${toolCallCounter}`; - const queue = pendingCallIdsByName.get(nameKey) || []; - queue.push(call.id); - pendingCallIdsByName.set(nameKey, queue); - return { ...part, functionCall: call }; - } - return part; - }); + if (!Array.isArray(contents)) { + return {contents, pendingCallIdsByName: new Map(), toolCallCounter: 0}; + } - return { ...content, parts: newParts }; - }); + let toolCallCounter = 0; + const pendingCallIdsByName = new Map(); - return { contents: newContents, pendingCallIdsByName, toolCallCounter }; + const newContents = contents.map((content: any) => { + if (!content || !Array.isArray(content.parts)) { + return content; + } + + const newParts = content.parts.map((part: any) => { + if (part && typeof part === "object" && part.functionCall) { + const call = {...part.functionCall}; + if (!call.id) { + call.id = `tool-call-${++toolCallCounter}`; + } + const nameKey = typeof call.name === "string" ? call.name : `tool-${toolCallCounter}`; + const queue = pendingCallIdsByName.get(nameKey) || []; + queue.push(call.id); + pendingCallIdsByName.set(nameKey, queue); + return {...part, functionCall: call}; + } + return part; + }); + + return {...content, parts: newParts}; + }); + + return {contents: newContents, pendingCallIdsByName, toolCallCounter}; } /** * Matches functionResponse IDs to their corresponding functionCall IDs. * This is the second pass of tool ID assignment. - * + * * @param contents - Gemini-style contents array * @param pendingCallIdsByName - Map of function names to pending call IDs * @returns Modified contents with matched response IDs */ export function matchResponseIdsToContents( - contents: any[], - pendingCallIdsByName: Map + contents: any[], + pendingCallIdsByName: Map ): any[] { - if (!Array.isArray(contents)) { - return contents; - } - - return contents.map((content: any) => { - if (!content || !Array.isArray(content.parts)) { - return content; - } - - const newParts = content.parts.map((part: any) => { - if (part && typeof part === "object" && part.functionResponse) { - const resp = { ...part.functionResponse }; - if (!resp.id && typeof resp.name === "string") { - const queue = pendingCallIdsByName.get(resp.name); - if (queue && queue.length > 0) { - resp.id = queue.shift(); - pendingCallIdsByName.set(resp.name, queue); - } - } - return { ...part, functionResponse: resp }; - } - return part; - }); + if (!Array.isArray(contents)) { + return contents; + } - return { ...content, parts: newParts }; - }); + return contents.map((content: any) => { + if (!content || !Array.isArray(content.parts)) { + return content; + } + + const newParts = content.parts.map((part: any) => { + if (part && typeof part === "object" && part.functionResponse) { + const resp = {...part.functionResponse}; + if (!resp.id && typeof resp.name === "string") { + const queue = pendingCallIdsByName.get(resp.name); + if (queue && queue.length > 0) { + resp.id = queue.shift(); + pendingCallIdsByName.set(resp.name, queue); + } + } + return {...part, functionResponse: resp}; + } + return part; + }); + + return {...content, parts: newParts}; + }); } /** @@ -2709,52 +2711,52 @@ export function matchResponseIdsToContents( * 2. Response ID matching for functionResponses * 3. Orphan recovery via fixToolResponseGrouping * 4. Claude format pairing fix via validateAndFixClaudeToolPairing - * + * * @param payload - Request payload object * @param isClaude - Whether this is a Claude model request * @returns Object with fix applied status */ export function applyToolPairingFixes( - payload: Record, - isClaude: boolean + payload: Record, + isClaude: boolean ): { contentsFixed: boolean; messagesFixed: boolean } { - let contentsFixed = false; - let messagesFixed = false; + let contentsFixed = false; + let messagesFixed = false; - if (!isClaude) { - return { contentsFixed, messagesFixed }; - } + if (!isClaude) { + return {contentsFixed, messagesFixed}; + } - // Fix Gemini format (contents[]) - if (Array.isArray(payload.contents)) { - // First pass: assign IDs to functionCalls - const { contents: contentsWithIds, pendingCallIdsByName } = assignToolIdsToContents( - payload.contents as any[] - ); + // Fix Gemini format (contents[]) + if (Array.isArray(payload.contents)) { + // First pass: assign IDs to functionCalls + const {contents: contentsWithIds, pendingCallIdsByName} = assignToolIdsToContents( + payload.contents as any[] + ); - // Second pass: match functionResponse IDs - const contentsWithMatchedIds = matchResponseIdsToContents(contentsWithIds, pendingCallIdsByName); + // Second pass: match functionResponse IDs + const contentsWithMatchedIds = matchResponseIdsToContents(contentsWithIds, pendingCallIdsByName); - // Third pass: fix orphan recovery - payload.contents = fixToolResponseGrouping(contentsWithMatchedIds); - contentsFixed = true; + // Third pass: fix orphan recovery + payload.contents = fixToolResponseGrouping(contentsWithMatchedIds); + contentsFixed = true; - log.debug("Applied tool pairing fixes to contents[]", { - originalLength: (payload.contents as any[]).length, - }); - } + log.debug("Applied tool pairing fixes to contents[]", { + originalLength: (payload.contents as any[]).length, + }); + } - // Fix Claude format (messages[]) - if (Array.isArray(payload.messages)) { - payload.messages = validateAndFixClaudeToolPairing(payload.messages as any[]); - messagesFixed = true; + // Fix Claude format (messages[]) + if (Array.isArray(payload.messages)) { + payload.messages = validateAndFixClaudeToolPairing(payload.messages as any[]); + messagesFixed = true; - log.debug("Applied tool pairing fixes to messages[]", { - originalLength: (payload.messages as any[]).length, - }); - } + log.debug("Applied tool pairing fixes to messages[]", { + originalLength: (payload.messages as any[]).length, + }); + } - return { contentsFixed, messagesFixed }; + return {contentsFixed, messagesFixed}; } // ============================================================================ @@ -2772,35 +2774,70 @@ export function applyToolPairingFixes( * it records a normal `step-finish`. */ export function createSyntheticErrorResponse( - errorMessage: string, - _requestedModel: string = "unknown", + errorMessage: string, + _requestedModel: string = "unknown", ): Response { - const outputTokens = Math.max(1, Math.ceil(errorMessage.length / 4)); - const event = { - candidates: [ - { - content: { - role: "model", - parts: [{ text: errorMessage }], + const outputTokens = Math.max(1, Math.ceil(errorMessage.length / 4)); + const event = { + candidates: [ + { + content: { + role: "model", + parts: [{text: errorMessage}], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: outputTokens, + totalTokenCount: outputTokens, }, - finishReason: "STOP", - }, - ], - usageMetadata: { - promptTokenCount: 0, - candidatesTokenCount: outputTokens, - totalTokenCount: outputTokens, - }, - }; - - return new Response(`data: ${JSON.stringify(event)}\n\n`, { - status: 200, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Antigravity-Synthetic": "true", - "X-Antigravity-Error-Type": "synthetic_error", - }, - }); + }; + + return new Response(`data: ${JSON.stringify(event)}\n\n`, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Antigravity-Synthetic": "true", + "X-Antigravity-Error-Type": "synthetic_error", + }, + }); } + +/** + * Create a proper HTTP 403 response for rate-limit / quota-exhausted situations. + * + * HTTP 403 (not 429) because OpenCode treats 429 as retryable (isRetryable=true + * from SDK) and retries up to 3 times before emitting session.error, during + * which Omo Slim's ForegroundFallbackManager never fires. 403 stops immediately + * -> session.error emitted -> Omo Slim catches it and triggers model fallback. + * + * Body is plain text so OpenCode renders it as a readable error message + * instead of dumping raw JSON. Text contains "rate limit" which matches + * Omo Slim's isRateLimitError regex (/rate.?limit/i). + */ +export function createRateLimitErrorResponse( + errorMessage: string, + retryAfterMs?: number, +): Response { + const headers: Record = { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + "X-Antigravity-Synthetic": "true", + "X-Antigravity-Error-Type": "rate_limit_all_accounts", + }; + + if (retryAfterMs !== undefined && retryAfterMs > 0) { + headers["Retry-After"] = String(Math.ceil(retryAfterMs / 1000)); + headers["retry-after-ms"] = String(retryAfterMs); + } + + return new Response(errorMessage, { + status: 403, + headers, + }); +} +