diff --git a/CLAUDE.md b/CLAUDE.md index eed2a46..c608e84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,13 @@ Claude Code CLI → Express Server (server.js) → CloudCode Client → Antigrav - **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) - **src/cloudcode-client.js**: Makes requests to Antigravity Cloud Code API with retry/failover logic, handles both streaming and non-streaming -- **src/format-converter.js**: Bidirectional conversion between Anthropic and Google Generative AI formats, including thinking blocks and tool calls +- **src/format/**: Format conversion module (Anthropic ↔ Google Generative AI) + - `index.js` - Re-exports all converters + - `request-converter.js` - Anthropic → Google request conversion + - `response-converter.js` - Google → Anthropic response conversion + - `content-converter.js` - Message content and role conversion + - `schema-sanitizer.js` - JSON Schema cleaning for Gemini API compatibility + - `thinking-utils.js` - Thinking block validation, filtering, and reordering - **src/account-manager.js**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown - **src/oauth.js**: Google OAuth implementation for adding accounts - **src/token-extractor.js**: Extracts tokens from local Antigravity app installation (legacy single-account mode) diff --git a/src/cloudcode-client.js b/src/cloudcode-client.js index b75a2dc..89ae9e2 100644 --- a/src/cloudcode-client.js +++ b/src/cloudcode-client.js @@ -22,7 +22,7 @@ import { import { convertAnthropicToGoogle, convertGoogleToAnthropic -} from './format-converter.js'; +} from './format/index.js'; import { formatDuration, sleep } from './utils/helpers.js'; import { isRateLimitError, isAuthError } from './errors.js'; diff --git a/src/format-converter.js b/src/format-converter.js deleted file mode 100644 index 3551298..0000000 --- a/src/format-converter.js +++ /dev/null @@ -1,828 +0,0 @@ -/** - * Format Converter - * Converts between Anthropic Messages API format and Google Generative AI format - * - * Based on patterns from: - * - https://github.com/NoeFabris/opencode-antigravity-auth - * - https://github.com/1rgs/claude-code-proxy - */ - -import crypto from 'crypto'; -import { - MIN_SIGNATURE_LENGTH, - GEMINI_MAX_OUTPUT_TOKENS, - getModelFamily, - isThinkingModel -} from './constants.js'; - -/** - * Sentinel value to skip thought signature validation for Gemini models. - * Per Google documentation, this value can be used when Claude Code strips - * the thoughtSignature field from tool_use blocks in multi-turn requests. - * See: https://ai.google.dev/gemini-api/docs/thought-signatures - */ -const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator'; - -/** - * Check if a part is a thinking block - * @param {Object} part - Content part to check - * @returns {boolean} True if the part is a thinking block - */ -function isThinkingPart(part) { - return part.type === 'thinking' || - part.type === 'redacted_thinking' || - part.thinking !== undefined || - part.thought === true; -} - -/** - * Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars) - */ -function hasValidSignature(part) { - const signature = part.thought === true ? part.thoughtSignature : part.signature; - return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH; -} - -/** - * Sanitize a thinking part by keeping only allowed fields - */ -function sanitizeThinkingPart(part) { - // Gemini-style thought blocks: { thought: true, text, thoughtSignature } - if (part.thought === true) { - const sanitized = { thought: true }; - if (part.text !== undefined) sanitized.text = part.text; - if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature; - return sanitized; - } - - // Anthropic-style thinking blocks: { type: "thinking", thinking, signature } - if (part.type === 'thinking' || part.thinking !== undefined) { - const sanitized = { type: 'thinking' }; - if (part.thinking !== undefined) sanitized.thinking = part.thinking; - if (part.signature !== undefined) sanitized.signature = part.signature; - return sanitized; - } - - return part; -} - -/** - * Filter content array, keeping only thinking blocks with valid signatures. - * Since signature_delta transmits signatures properly, cache is no longer needed. - */ -function filterContentArray(contentArray) { - const filtered = []; - - for (const item of contentArray) { - if (!item || typeof item !== 'object') { - filtered.push(item); - continue; - } - - if (!isThinkingPart(item)) { - filtered.push(item); - continue; - } - - // Keep items with valid signatures - if (hasValidSignature(item)) { - filtered.push(sanitizeThinkingPart(item)); - continue; - } - - // Drop unsigned thinking blocks - console.log('[FormatConverter] Dropping unsigned thinking block'); - } - - return filtered; -} - -/** - * Filter unsigned thinking blocks from contents (Gemini format) - * - * @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format - * @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed - */ -export function filterUnsignedThinkingBlocks(contents) { - return contents.map(content => { - if (!content || typeof content !== 'object') return content; - - if (Array.isArray(content.parts)) { - return { ...content, parts: filterContentArray(content.parts) }; - } - - return content; - }); -} - -/** - * Remove trailing unsigned thinking blocks from assistant messages. - * Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks. - * This function removes thinking blocks from the end of content arrays. - * - * @param {Array} content - Array of content blocks - * @returns {Array} Content array with trailing unsigned thinking blocks removed - */ -export function removeTrailingThinkingBlocks(content) { - if (!Array.isArray(content)) return content; - if (content.length === 0) return content; - - // Work backwards from the end, removing thinking blocks - let endIndex = content.length; - for (let i = content.length - 1; i >= 0; i--) { - const block = content[i]; - if (!block || typeof block !== 'object') break; - - // Check if it's a thinking block (any format) - const isThinking = isThinkingPart(block); - - if (isThinking) { - // Check if it has a valid signature - if (!hasValidSignature(block)) { - endIndex = i; - } else { - break; // Stop at signed thinking block - } - } else { - break; // Stop at first non-thinking block - } - } - - if (endIndex < content.length) { - console.log('[FormatConverter] Removed', content.length - endIndex, 'trailing unsigned thinking blocks'); - return content.slice(0, endIndex); - } - - return content; -} - -/** - * Sanitize a thinking block by removing extra fields like cache_control. - * Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking) - */ -function sanitizeAnthropicThinkingBlock(block) { - if (!block) return block; - - if (block.type === 'thinking') { - const sanitized = { type: 'thinking' }; - if (block.thinking !== undefined) sanitized.thinking = block.thinking; - if (block.signature !== undefined) sanitized.signature = block.signature; - return sanitized; - } - - if (block.type === 'redacted_thinking') { - const sanitized = { type: 'redacted_thinking' }; - if (block.data !== undefined) sanitized.data = block.data; - return sanitized; - } - - return block; -} - -/** - * Filter thinking blocks: keep only those with valid signatures. - * Blocks without signatures are dropped (API requires signatures). - * Also sanitizes blocks to remove extra fields like cache_control. - * - * @param {Array} content - Array of content blocks - * @returns {Array} Filtered content with only valid signed thinking blocks - */ -export function restoreThinkingSignatures(content) { - if (!Array.isArray(content)) return content; - - const originalLength = content.length; - const filtered = []; - - for (const block of content) { - if (!block || block.type !== 'thinking') { - filtered.push(block); - continue; - } - - // Keep blocks with valid signatures (>= MIN_SIGNATURE_LENGTH chars), sanitized - if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) { - filtered.push(sanitizeAnthropicThinkingBlock(block)); - } - // Unsigned thinking blocks are dropped - } - - if (filtered.length < originalLength) { - console.log(`[FormatConverter] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`); - } - - return filtered; -} - -/** - * Reorder content so that: - * 1. Thinking blocks come first (required when thinking is enabled) - * 2. Text blocks come in the middle (filtering out empty/useless ones) - * 3. Tool_use blocks come at the end (required before tool_result) - * - * Claude API requires that when thinking is enabled, assistant messages must start with thinking. - * - * @param {Array} content - Array of content blocks - * @returns {Array} Reordered content array - */ -export function reorderAssistantContent(content) { - if (!Array.isArray(content)) return content; - - // Even for single-element arrays, we need to sanitize thinking blocks - if (content.length === 1) { - const block = content[0]; - if (block && (block.type === 'thinking' || block.type === 'redacted_thinking')) { - return [sanitizeAnthropicThinkingBlock(block)]; - } - return content; - } - - const thinkingBlocks = []; - const textBlocks = []; - const toolUseBlocks = []; - let droppedEmptyBlocks = 0; - - for (const block of content) { - if (!block) continue; - - if (block.type === 'thinking' || block.type === 'redacted_thinking') { - // Sanitize thinking blocks to remove cache_control and other extra fields - thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block)); - } else if (block.type === 'tool_use') { - toolUseBlocks.push(block); - } else if (block.type === 'text') { - // Only keep text blocks with meaningful content - if (block.text && block.text.trim().length > 0) { - textBlocks.push(block); - } else { - droppedEmptyBlocks++; - } - } else { - // Other block types go in the text position - textBlocks.push(block); - } - } - - if (droppedEmptyBlocks > 0) { - console.log(`[FormatConverter] Dropped ${droppedEmptyBlocks} empty text block(s)`); - } - - const reordered = [...thinkingBlocks, ...textBlocks, ...toolUseBlocks]; - - // Log only if actual reordering happened (not just filtering) - if (reordered.length === content.length) { - const originalOrder = content.map(b => b?.type || 'unknown').join(','); - const newOrder = reordered.map(b => b?.type || 'unknown').join(','); - if (originalOrder !== newOrder) { - console.log('[FormatConverter] Reordered assistant content'); - } - } - - return reordered; -} - -/** - * Convert Anthropic message content to Google Generative AI parts - */ -function convertContentToParts(content, isClaudeModel = false, isGeminiModel = false) { - if (typeof content === 'string') { - return [{ text: content }]; - } - - if (!Array.isArray(content)) { - return [{ text: String(content) }]; - } - - const parts = []; - - for (const block of content) { - if (block.type === 'text') { - // Skip empty text blocks - they cause API errors - if (block.text && block.text.trim()) { - parts.push({ text: block.text }); - } - } else if (block.type === 'image') { - // Handle image content - if (block.source?.type === 'base64') { - // Base64-encoded image - parts.push({ - inlineData: { - mimeType: block.source.media_type, - data: block.source.data - } - }); - } else if (block.source?.type === 'url') { - // URL-referenced image - parts.push({ - fileData: { - mimeType: block.source.media_type || 'image/jpeg', - fileUri: block.source.url - } - }); - } - } else if (block.type === 'document') { - // Handle document content (e.g. PDF) - if (block.source?.type === 'base64') { - parts.push({ - inlineData: { - mimeType: block.source.media_type, - data: block.source.data - } - }); - } else if (block.source?.type === 'url') { - parts.push({ - fileData: { - mimeType: block.source.media_type || 'application/pdf', - fileUri: block.source.url - } - }); - } - } else if (block.type === 'tool_use') { - // Convert tool_use to functionCall (Google format) - // For Claude models, include the id field - const functionCall = { - name: block.name, - args: block.input || {} - }; - - if (isClaudeModel && block.id) { - functionCall.id = block.id; - } - - // Build the part with functionCall - const part = { functionCall }; - - // For Gemini models, include thoughtSignature at the part level - // This is required by Gemini 3+ for tool calls to work correctly - if (isGeminiModel) { - // Use thoughtSignature from the block if Claude Code preserved it - // Otherwise, use the sentinel value to skip validation (Claude Code strips non-standard fields) - // See: https://ai.google.dev/gemini-api/docs/thought-signatures - part.thoughtSignature = block.thoughtSignature || GEMINI_SKIP_SIGNATURE; - } - - parts.push(part); - } else if (block.type === 'tool_result') { - // Convert tool_result to functionResponse (Google format) - let responseContent = block.content; - if (typeof responseContent === 'string') { - responseContent = { result: responseContent }; - } else if (Array.isArray(responseContent)) { - const texts = responseContent - .filter(c => c.type === 'text') - .map(c => c.text) - .join('\n'); - responseContent = { result: texts }; - } - - const functionResponse = { - name: block.tool_use_id || 'unknown', - response: responseContent - }; - - // For Claude models, the id field must match the tool_use_id - if (isClaudeModel && block.tool_use_id) { - functionResponse.id = block.tool_use_id; - } - - parts.push({ functionResponse }); - } else if (block.type === 'thinking') { - // Handle thinking blocks - only those with valid signatures - if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) { - // Convert to Gemini format with signature - parts.push({ - text: block.thinking, - thought: true, - thoughtSignature: block.signature - }); - } - // Unsigned thinking blocks are dropped upstream - } - } - - return parts; -} - -/** - * Convert Anthropic role to Google role - */ -function convertRole(role) { - if (role === 'assistant') return 'model'; - if (role === 'user') return 'user'; - return 'user'; // Default to user -} - -/** - * Convert Anthropic Messages API request to the format expected by Cloud Code - * - * Uses Google Generative AI format, but for Claude models: - * - Keeps tool_result in Anthropic format (required by Claude API) - * - * @param {Object} anthropicRequest - Anthropic format request - * @returns {Object} Request body for Cloud Code API - */ -export function convertAnthropicToGoogle(anthropicRequest) { - const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest; - const modelName = anthropicRequest.model || ''; - const modelFamily = getModelFamily(modelName); - const isClaudeModel = modelFamily === 'claude'; - const isGeminiModel = modelFamily === 'gemini'; - const isThinking = isThinkingModel(modelName); - - const googleRequest = { - contents: [], - generationConfig: {} - }; - - // Handle system instruction - if (system) { - let systemParts = []; - if (typeof system === 'string') { - systemParts = [{ text: system }]; - } else if (Array.isArray(system)) { - // Filter for text blocks as system prompts are usually text - // Anthropic supports text blocks in system prompts - systemParts = system - .filter(block => block.type === 'text') - .map(block => ({ text: block.text })); - } - - if (systemParts.length > 0) { - googleRequest.systemInstruction = { - parts: systemParts - }; - } - } - - // Add interleaved thinking hint for Claude thinking models with tools - if (isClaudeModel && isThinking && tools && tools.length > 0) { - const hint = 'Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer.'; - if (!googleRequest.systemInstruction) { - googleRequest.systemInstruction = { parts: [{ text: hint }] }; - } else { - const lastPart = googleRequest.systemInstruction.parts[googleRequest.systemInstruction.parts.length - 1]; - if (lastPart && lastPart.text) { - lastPart.text = `${lastPart.text}\n\n${hint}`; - } else { - googleRequest.systemInstruction.parts.push({ text: hint }); - } - } - } - - // Convert messages to contents, then filter unsigned thinking blocks - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; - let msgContent = msg.content; - - // For assistant messages, process thinking blocks and reorder content - if ((msg.role === 'assistant' || msg.role === 'model') && Array.isArray(msgContent)) { - // First, try to restore signatures for unsigned thinking blocks from cache - msgContent = restoreThinkingSignatures(msgContent); - // Remove trailing unsigned thinking blocks - msgContent = removeTrailingThinkingBlocks(msgContent); - // Reorder: thinking first, then text, then tool_use - msgContent = reorderAssistantContent(msgContent); - } - - const parts = convertContentToParts(msgContent, isClaudeModel, isGeminiModel); - const content = { - role: convertRole(msg.role), - parts: parts - }; - googleRequest.contents.push(content); - } - - // Filter unsigned thinking blocks for Claude models - if (isClaudeModel) { - googleRequest.contents = filterUnsignedThinkingBlocks(googleRequest.contents); - } - - // Generation config - if (max_tokens) { - googleRequest.generationConfig.maxOutputTokens = max_tokens; - } - if (temperature !== undefined) { - googleRequest.generationConfig.temperature = temperature; - } - if (top_p !== undefined) { - googleRequest.generationConfig.topP = top_p; - } - if (top_k !== undefined) { - googleRequest.generationConfig.topK = top_k; - } - if (stop_sequences && stop_sequences.length > 0) { - googleRequest.generationConfig.stopSequences = stop_sequences; - } - - // Enable thinking for thinking models (Claude and Gemini 3+) - if (isThinking) { - if (isClaudeModel) { - // Claude thinking config - const thinkingConfig = { - include_thoughts: true - }; - - // Only set thinking_budget if explicitly provided - const thinkingBudget = thinking?.budget_tokens; - if (thinkingBudget) { - thinkingConfig.thinking_budget = thinkingBudget; - console.log('[FormatConverter] Claude thinking enabled with budget:', thinkingBudget); - } else { - console.log('[FormatConverter] Claude thinking enabled (no budget specified)'); - } - - googleRequest.generationConfig.thinkingConfig = thinkingConfig; - } else if (isGeminiModel) { - // Gemini thinking config (uses camelCase) - const thinkingConfig = { - includeThoughts: true, - thinkingBudget: thinking?.budget_tokens || 16000 - }; - console.log('[FormatConverter] Gemini thinking enabled with budget:', thinkingConfig.thinkingBudget); - - googleRequest.generationConfig.thinkingConfig = thinkingConfig; - } - } - - // Convert tools to Google format - if (tools && tools.length > 0) { - const functionDeclarations = tools.map((tool, idx) => { - // Extract name from various possible locations - const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`; - - // Extract description from various possible locations - const description = tool.description || tool.function?.description || tool.custom?.description || ''; - - // Extract schema from various possible locations - const schema = tool.input_schema - || tool.function?.input_schema - || tool.function?.parameters - || tool.custom?.input_schema - || tool.parameters - || { type: 'object' }; - - // Sanitize schema for general compatibility - let parameters = sanitizeSchema(schema); - - // For Gemini models, apply additional cleaning for VALIDATED mode - if (isGeminiModel) { - parameters = cleanSchemaForGemini(parameters); - } - - return { - name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64), - description: description, - parameters - }; - }); - - googleRequest.tools = [{ functionDeclarations }]; - console.log('[FormatConverter] Tools:', JSON.stringify(googleRequest.tools).substring(0, 300)); - } - - // Cap max tokens for Gemini models - if (isGeminiModel && googleRequest.generationConfig.maxOutputTokens > GEMINI_MAX_OUTPUT_TOKENS) { - console.log(`[FormatConverter] Capping Gemini max_tokens from ${googleRequest.generationConfig.maxOutputTokens} to ${GEMINI_MAX_OUTPUT_TOKENS}`); - googleRequest.generationConfig.maxOutputTokens = GEMINI_MAX_OUTPUT_TOKENS; - } - - return googleRequest; -} - -/** - * Sanitize JSON Schema for Antigravity API compatibility. - * Uses allowlist approach - only permit known-safe JSON Schema features. - * Converts "const" to equivalent "enum" for compatibility. - * Generates placeholder schema for empty tool schemas. - */ -function sanitizeSchema(schema) { - if (!schema || typeof schema !== 'object') { - // Empty/missing schema - generate placeholder with reason property - return { - type: 'object', - properties: { - reason: { - type: 'string', - description: 'Reason for calling this tool' - } - }, - required: ['reason'] - }; - } - - // Allowlist of permitted JSON Schema fields - const ALLOWED_FIELDS = new Set([ - 'type', - 'description', - 'properties', - 'required', - 'items', - 'enum', - 'title' - ]); - - const sanitized = {}; - - for (const [key, value] of Object.entries(schema)) { - // Convert "const" to "enum" for compatibility - if (key === 'const') { - sanitized.enum = [value]; - continue; - } - - // Skip fields not in allowlist - if (!ALLOWED_FIELDS.has(key)) { - continue; - } - - if (key === 'properties' && value && typeof value === 'object') { - sanitized.properties = {}; - for (const [propKey, propValue] of Object.entries(value)) { - sanitized.properties[propKey] = sanitizeSchema(propValue); - } - } else if (key === 'items' && value && typeof value === 'object') { - if (Array.isArray(value)) { - sanitized.items = value.map(item => sanitizeSchema(item)); - } else { - sanitized.items = sanitizeSchema(value); - } - } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - sanitized[key] = sanitizeSchema(value); - } else { - sanitized[key] = value; - } - } - - // Ensure we have at least a type - if (!sanitized.type) { - sanitized.type = 'object'; - } - - // If object type with no properties, add placeholder - if (sanitized.type === 'object' && (!sanitized.properties || Object.keys(sanitized.properties).length === 0)) { - sanitized.properties = { - reason: { - type: 'string', - description: 'Reason for calling this tool' - } - }; - sanitized.required = ['reason']; - } - - return sanitized; -} - -/** - * Cleans JSON schema for Gemini API compatibility. - * Removes unsupported fields that cause VALIDATED mode errors. - * - * Gemini's VALIDATED mode rejects schemas with certain JSON Schema keywords - * that are not supported by the Gemini API. - * - * @param {Object} schema - The JSON schema to clean - * @returns {Object} Cleaned schema safe for Gemini API - */ -function cleanSchemaForGemini(schema) { - if (!schema || typeof schema !== 'object') return schema; - if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini); - - const result = { ...schema }; - - // Remove unsupported keywords that cause VALIDATED mode errors - const unsupported = [ - 'additionalProperties', 'default', '$schema', '$defs', - 'definitions', '$ref', '$id', '$comment', 'title', - 'minLength', 'maxLength', 'pattern', 'format', - 'minItems', 'maxItems', 'examples' - ]; - - for (const key of unsupported) { - delete result[key]; - } - - // Check for unsupported 'format' in string types - if (result.type === 'string' && result.format) { - const allowed = ['enum', 'date-time']; - if (!allowed.includes(result.format)) { - delete result.format; - } - } - - // Recursively clean nested schemas - for (const [key, value] of Object.entries(result)) { - if (typeof value === 'object' && value !== null) { - result[key] = cleanSchemaForGemini(value); - } - } - - // Validate that required array only contains properties that exist - // Gemini's VALIDATED mode requires this - if (result.required && Array.isArray(result.required) && result.properties) { - const definedProps = new Set(Object.keys(result.properties)); - result.required = result.required.filter(prop => definedProps.has(prop)); - // If required is now empty, remove it - if (result.required.length === 0) { - delete result.required; - } - } - - return result; -} - -/** - * Convert Google Generative AI response to Anthropic Messages API format - * - * @param {Object} googleResponse - Google format response (the inner response object) - * @param {string} model - The model name used - * @returns {Object} Anthropic format response - */ -export function convertGoogleToAnthropic(googleResponse, model) { - // Handle the response wrapper - const response = googleResponse.response || googleResponse; - - const candidates = response.candidates || []; - const firstCandidate = candidates[0] || {}; - const content = firstCandidate.content || {}; - const parts = content.parts || []; - - // Convert parts to Anthropic content blocks - const anthropicContent = []; - let hasToolCalls = false; - - for (const part of parts) { - if (part.text !== undefined) { - // Handle thinking blocks - if (part.thought === true) { - const signature = part.thoughtSignature || ''; - - // Include thinking blocks in the response for Claude Code - anthropicContent.push({ - type: 'thinking', - thinking: part.text, - signature: signature - }); - } else { - anthropicContent.push({ - type: 'text', - text: part.text - }); - } - } else if (part.functionCall) { - // Convert functionCall to tool_use - // Use the id from the response if available, otherwise generate one - const toolId = part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`; - const toolUseBlock = { - type: 'tool_use', - id: toolId, - name: part.functionCall.name, - input: part.functionCall.args || {} - }; - - // For Gemini 3+, include thoughtSignature from the part level - if (part.thoughtSignature && part.thoughtSignature.length >= MIN_SIGNATURE_LENGTH) { - toolUseBlock.thoughtSignature = part.thoughtSignature; - } - - anthropicContent.push(toolUseBlock); - hasToolCalls = true; - } - } - - // Determine stop reason - const finishReason = firstCandidate.finishReason; - let stopReason = 'end_turn'; - if (finishReason === 'STOP') { - stopReason = 'end_turn'; - } else if (finishReason === 'MAX_TOKENS') { - stopReason = 'max_tokens'; - } else if (finishReason === 'TOOL_USE' || hasToolCalls) { - stopReason = 'tool_use'; - } - - // Extract usage metadata - // Note: Antigravity's promptTokenCount is the TOTAL (includes cached), - // but Anthropic's input_tokens excludes cached. We subtract to match. - const usageMetadata = response.usageMetadata || {}; - const promptTokens = usageMetadata.promptTokenCount || 0; - const cachedTokens = usageMetadata.cachedContentTokenCount || 0; - - return { - id: `msg_${crypto.randomBytes(16).toString('hex')}`, - type: 'message', - role: 'assistant', - content: anthropicContent.length > 0 ? anthropicContent : [{ type: 'text', text: '' }], - model: model, - stop_reason: stopReason, - stop_sequence: null, - usage: { - input_tokens: promptTokens - cachedTokens, - output_tokens: usageMetadata.candidatesTokenCount || 0, - cache_read_input_tokens: cachedTokens, - cache_creation_input_tokens: 0 - } - }; -} - -export default { - convertAnthropicToGoogle, - convertGoogleToAnthropic -}; diff --git a/src/format/content-converter.js b/src/format/content-converter.js new file mode 100644 index 0000000..d8a1f87 --- /dev/null +++ b/src/format/content-converter.js @@ -0,0 +1,151 @@ +/** + * Content Converter + * Converts Anthropic message content to Google Generative AI parts format + */ + +import { MIN_SIGNATURE_LENGTH } from '../constants.js'; + +/** + * Sentinel value to skip thought signature validation for Gemini models. + * Per Google documentation, this value can be used when Claude Code strips + * the thoughtSignature field from tool_use blocks in multi-turn requests. + * See: https://ai.google.dev/gemini-api/docs/thought-signatures + */ +const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator'; + +/** + * Convert Anthropic role to Google role + * @param {string} role - Anthropic role ('user', 'assistant') + * @returns {string} Google role ('user', 'model') + */ +export function convertRole(role) { + if (role === 'assistant') return 'model'; + if (role === 'user') return 'user'; + return 'user'; // Default to user +} + +/** + * Convert Anthropic message content to Google Generative AI parts + * @param {string|Array} content - Anthropic message content + * @param {boolean} isClaudeModel - Whether the model is a Claude model + * @param {boolean} isGeminiModel - Whether the model is a Gemini model + * @returns {Array} Google Generative AI parts array + */ +export function convertContentToParts(content, isClaudeModel = false, isGeminiModel = false) { + if (typeof content === 'string') { + return [{ text: content }]; + } + + if (!Array.isArray(content)) { + return [{ text: String(content) }]; + } + + const parts = []; + + for (const block of content) { + if (block.type === 'text') { + // Skip empty text blocks - they cause API errors + if (block.text && block.text.trim()) { + parts.push({ text: block.text }); + } + } else if (block.type === 'image') { + // Handle image content + if (block.source?.type === 'base64') { + // Base64-encoded image + parts.push({ + inlineData: { + mimeType: block.source.media_type, + data: block.source.data + } + }); + } else if (block.source?.type === 'url') { + // URL-referenced image + parts.push({ + fileData: { + mimeType: block.source.media_type || 'image/jpeg', + fileUri: block.source.url + } + }); + } + } else if (block.type === 'document') { + // Handle document content (e.g. PDF) + if (block.source?.type === 'base64') { + parts.push({ + inlineData: { + mimeType: block.source.media_type, + data: block.source.data + } + }); + } else if (block.source?.type === 'url') { + parts.push({ + fileData: { + mimeType: block.source.media_type || 'application/pdf', + fileUri: block.source.url + } + }); + } + } else if (block.type === 'tool_use') { + // Convert tool_use to functionCall (Google format) + // For Claude models, include the id field + const functionCall = { + name: block.name, + args: block.input || {} + }; + + if (isClaudeModel && block.id) { + functionCall.id = block.id; + } + + // Build the part with functionCall + const part = { functionCall }; + + // For Gemini models, include thoughtSignature at the part level + // This is required by Gemini 3+ for tool calls to work correctly + if (isGeminiModel) { + // Use thoughtSignature from the block if Claude Code preserved it + // Otherwise, use the sentinel value to skip validation (Claude Code strips non-standard fields) + // See: https://ai.google.dev/gemini-api/docs/thought-signatures + part.thoughtSignature = block.thoughtSignature || GEMINI_SKIP_SIGNATURE; + } + + parts.push(part); + } else if (block.type === 'tool_result') { + // Convert tool_result to functionResponse (Google format) + let responseContent = block.content; + if (typeof responseContent === 'string') { + responseContent = { result: responseContent }; + } else if (Array.isArray(responseContent)) { + const texts = responseContent + .filter(c => c.type === 'text') + .map(c => c.text) + .join('\n'); + responseContent = { result: texts }; + } + + const functionResponse = { + name: block.tool_use_id || 'unknown', + response: responseContent + }; + + // For Claude models, the id field must match the tool_use_id + if (isClaudeModel && block.tool_use_id) { + functionResponse.id = block.tool_use_id; + } + + parts.push({ functionResponse }); + } else if (block.type === 'thinking') { + // Handle thinking blocks - only those with valid signatures + if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) { + // Convert to Gemini format with signature + parts.push({ + text: block.thinking, + thought: true, + thoughtSignature: block.signature + }); + } + // Unsigned thinking blocks are dropped upstream + } + } + + return parts; +} diff --git a/src/format/index.js b/src/format/index.js new file mode 100644 index 0000000..d2ec1ab --- /dev/null +++ b/src/format/index.js @@ -0,0 +1,20 @@ +/** + * Format Converter Module + * Converts between Anthropic Messages API format and Google Generative AI format + */ + +// Re-export all from each module +export * from './request-converter.js'; +export * from './response-converter.js'; +export * from './content-converter.js'; +export * from './schema-sanitizer.js'; +export * from './thinking-utils.js'; + +// Default export for backward compatibility +import { convertAnthropicToGoogle } from './request-converter.js'; +import { convertGoogleToAnthropic } from './response-converter.js'; + +export default { + convertAnthropicToGoogle, + convertGoogleToAnthropic +}; diff --git a/src/format/request-converter.js b/src/format/request-converter.js new file mode 100644 index 0000000..1a0e7e8 --- /dev/null +++ b/src/format/request-converter.js @@ -0,0 +1,195 @@ +/** + * Request Converter + * Converts Anthropic Messages API requests to Google Generative AI format + */ + +import { + GEMINI_MAX_OUTPUT_TOKENS, + getModelFamily, + isThinkingModel +} from '../constants.js'; +import { convertContentToParts, convertRole } from './content-converter.js'; +import { sanitizeSchema, cleanSchemaForGemini } from './schema-sanitizer.js'; +import { + restoreThinkingSignatures, + removeTrailingThinkingBlocks, + reorderAssistantContent, + filterUnsignedThinkingBlocks +} from './thinking-utils.js'; + +/** + * Convert Anthropic Messages API request to the format expected by Cloud Code + * + * Uses Google Generative AI format, but for Claude models: + * - Keeps tool_result in Anthropic format (required by Claude API) + * + * @param {Object} anthropicRequest - Anthropic format request + * @returns {Object} Request body for Cloud Code API + */ +export function convertAnthropicToGoogle(anthropicRequest) { + const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest; + const modelName = anthropicRequest.model || ''; + const modelFamily = getModelFamily(modelName); + const isClaudeModel = modelFamily === 'claude'; + const isGeminiModel = modelFamily === 'gemini'; + const isThinking = isThinkingModel(modelName); + + const googleRequest = { + contents: [], + generationConfig: {} + }; + + // Handle system instruction + if (system) { + let systemParts = []; + if (typeof system === 'string') { + systemParts = [{ text: system }]; + } else if (Array.isArray(system)) { + // Filter for text blocks as system prompts are usually text + // Anthropic supports text blocks in system prompts + systemParts = system + .filter(block => block.type === 'text') + .map(block => ({ text: block.text })); + } + + if (systemParts.length > 0) { + googleRequest.systemInstruction = { + parts: systemParts + }; + } + } + + // Add interleaved thinking hint for Claude thinking models with tools + if (isClaudeModel && isThinking && tools && tools.length > 0) { + const hint = 'Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer.'; + if (!googleRequest.systemInstruction) { + googleRequest.systemInstruction = { parts: [{ text: hint }] }; + } else { + const lastPart = googleRequest.systemInstruction.parts[googleRequest.systemInstruction.parts.length - 1]; + if (lastPart && lastPart.text) { + lastPart.text = `${lastPart.text}\n\n${hint}`; + } else { + googleRequest.systemInstruction.parts.push({ text: hint }); + } + } + } + + // Convert messages to contents, then filter unsigned thinking blocks + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + let msgContent = msg.content; + + // For assistant messages, process thinking blocks and reorder content + if ((msg.role === 'assistant' || msg.role === 'model') && Array.isArray(msgContent)) { + // First, try to restore signatures for unsigned thinking blocks from cache + msgContent = restoreThinkingSignatures(msgContent); + // Remove trailing unsigned thinking blocks + msgContent = removeTrailingThinkingBlocks(msgContent); + // Reorder: thinking first, then text, then tool_use + msgContent = reorderAssistantContent(msgContent); + } + + const parts = convertContentToParts(msgContent, isClaudeModel, isGeminiModel); + const content = { + role: convertRole(msg.role), + parts: parts + }; + googleRequest.contents.push(content); + } + + // Filter unsigned thinking blocks for Claude models + if (isClaudeModel) { + googleRequest.contents = filterUnsignedThinkingBlocks(googleRequest.contents); + } + + // Generation config + if (max_tokens) { + googleRequest.generationConfig.maxOutputTokens = max_tokens; + } + if (temperature !== undefined) { + googleRequest.generationConfig.temperature = temperature; + } + if (top_p !== undefined) { + googleRequest.generationConfig.topP = top_p; + } + if (top_k !== undefined) { + googleRequest.generationConfig.topK = top_k; + } + if (stop_sequences && stop_sequences.length > 0) { + googleRequest.generationConfig.stopSequences = stop_sequences; + } + + // Enable thinking for thinking models (Claude and Gemini 3+) + if (isThinking) { + if (isClaudeModel) { + // Claude thinking config + const thinkingConfig = { + include_thoughts: true + }; + + // Only set thinking_budget if explicitly provided + const thinkingBudget = thinking?.budget_tokens; + if (thinkingBudget) { + thinkingConfig.thinking_budget = thinkingBudget; + console.log('[RequestConverter] Claude thinking enabled with budget:', thinkingBudget); + } else { + console.log('[RequestConverter] Claude thinking enabled (no budget specified)'); + } + + googleRequest.generationConfig.thinkingConfig = thinkingConfig; + } else if (isGeminiModel) { + // Gemini thinking config (uses camelCase) + const thinkingConfig = { + includeThoughts: true, + thinkingBudget: thinking?.budget_tokens || 16000 + }; + console.log('[RequestConverter] Gemini thinking enabled with budget:', thinkingConfig.thinkingBudget); + + googleRequest.generationConfig.thinkingConfig = thinkingConfig; + } + } + + // Convert tools to Google format + if (tools && tools.length > 0) { + const functionDeclarations = tools.map((tool, idx) => { + // Extract name from various possible locations + const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`; + + // Extract description from various possible locations + const description = tool.description || tool.function?.description || tool.custom?.description || ''; + + // Extract schema from various possible locations + const schema = tool.input_schema + || tool.function?.input_schema + || tool.function?.parameters + || tool.custom?.input_schema + || tool.parameters + || { type: 'object' }; + + // Sanitize schema for general compatibility + let parameters = sanitizeSchema(schema); + + // For Gemini models, apply additional cleaning for VALIDATED mode + if (isGeminiModel) { + parameters = cleanSchemaForGemini(parameters); + } + + return { + name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64), + description: description, + parameters + }; + }); + + googleRequest.tools = [{ functionDeclarations }]; + console.log('[RequestConverter] Tools:', JSON.stringify(googleRequest.tools).substring(0, 300)); + } + + // Cap max tokens for Gemini models + if (isGeminiModel && googleRequest.generationConfig.maxOutputTokens > GEMINI_MAX_OUTPUT_TOKENS) { + console.log(`[RequestConverter] Capping Gemini max_tokens from ${googleRequest.generationConfig.maxOutputTokens} to ${GEMINI_MAX_OUTPUT_TOKENS}`); + googleRequest.generationConfig.maxOutputTokens = GEMINI_MAX_OUTPUT_TOKENS; + } + + return googleRequest; +} diff --git a/src/format/response-converter.js b/src/format/response-converter.js new file mode 100644 index 0000000..53ba447 --- /dev/null +++ b/src/format/response-converter.js @@ -0,0 +1,101 @@ +/** + * Response Converter + * Converts Google Generative AI responses to Anthropic Messages API format + */ + +import crypto from 'crypto'; +import { MIN_SIGNATURE_LENGTH } from '../constants.js'; + +/** + * Convert Google Generative AI response to Anthropic Messages API format + * + * @param {Object} googleResponse - Google format response (the inner response object) + * @param {string} model - The model name used + * @returns {Object} Anthropic format response + */ +export function convertGoogleToAnthropic(googleResponse, model) { + // Handle the response wrapper + const response = googleResponse.response || googleResponse; + + const candidates = response.candidates || []; + const firstCandidate = candidates[0] || {}; + const content = firstCandidate.content || {}; + const parts = content.parts || []; + + // Convert parts to Anthropic content blocks + const anthropicContent = []; + let hasToolCalls = false; + + for (const part of parts) { + if (part.text !== undefined) { + // Handle thinking blocks + if (part.thought === true) { + const signature = part.thoughtSignature || ''; + + // Include thinking blocks in the response for Claude Code + anthropicContent.push({ + type: 'thinking', + thinking: part.text, + signature: signature + }); + } else { + anthropicContent.push({ + type: 'text', + text: part.text + }); + } + } else if (part.functionCall) { + // Convert functionCall to tool_use + // Use the id from the response if available, otherwise generate one + const toolId = part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`; + const toolUseBlock = { + type: 'tool_use', + id: toolId, + name: part.functionCall.name, + input: part.functionCall.args || {} + }; + + // For Gemini 3+, include thoughtSignature from the part level + if (part.thoughtSignature && part.thoughtSignature.length >= MIN_SIGNATURE_LENGTH) { + toolUseBlock.thoughtSignature = part.thoughtSignature; + } + + anthropicContent.push(toolUseBlock); + hasToolCalls = true; + } + } + + // Determine stop reason + const finishReason = firstCandidate.finishReason; + let stopReason = 'end_turn'; + if (finishReason === 'STOP') { + stopReason = 'end_turn'; + } else if (finishReason === 'MAX_TOKENS') { + stopReason = 'max_tokens'; + } else if (finishReason === 'TOOL_USE' || hasToolCalls) { + stopReason = 'tool_use'; + } + + // Extract usage metadata + // Note: Antigravity's promptTokenCount is the TOTAL (includes cached), + // but Anthropic's input_tokens excludes cached. We subtract to match. + const usageMetadata = response.usageMetadata || {}; + const promptTokens = usageMetadata.promptTokenCount || 0; + const cachedTokens = usageMetadata.cachedContentTokenCount || 0; + + return { + id: `msg_${crypto.randomBytes(16).toString('hex')}`, + type: 'message', + role: 'assistant', + content: anthropicContent.length > 0 ? anthropicContent : [{ type: 'text', text: '' }], + model: model, + stop_reason: stopReason, + stop_sequence: null, + usage: { + input_tokens: promptTokens - cachedTokens, + output_tokens: usageMetadata.candidatesTokenCount || 0, + cache_read_input_tokens: cachedTokens, + cache_creation_input_tokens: 0 + } + }; +} diff --git a/src/format/schema-sanitizer.js b/src/format/schema-sanitizer.js new file mode 100644 index 0000000..733e58e --- /dev/null +++ b/src/format/schema-sanitizer.js @@ -0,0 +1,516 @@ +/** + * Schema Sanitizer + * Cleans and transforms JSON schemas for Gemini/Antigravity API compatibility + * + * Uses a multi-phase pipeline matching opencode-antigravity-auth approach: + * - Phase 1: Convert $refs to description hints + * - Phase 2a: Merge allOf schemas + * - Phase 2b: Flatten anyOf/oneOf (select best option) + * - Phase 2c: Flatten type arrays + update required for nullable + * - Phase 3: Remove unsupported keywords + * - Phase 4: Final cleanup (required array validation) + */ + +/** + * Append a hint to a schema's description field. + * Format: "existing (hint)" or just "hint" if no existing description. + * + * @param {Object} schema - Schema object to modify + * @param {string} hint - Hint text to append + * @returns {Object} Modified schema with appended description + */ +function appendDescriptionHint(schema, hint) { + if (!schema || typeof schema !== 'object') return schema; + const result = { ...schema }; + result.description = result.description + ? `${result.description} (${hint})` + : hint; + return result; +} + +/** + * Score a schema option for anyOf/oneOf selection. + * Higher scores = more preferred schemas. + * + * @param {Object} schema - Schema option to score + * @returns {number} Score (0-3) + */ +function scoreSchemaOption(schema) { + if (!schema || typeof schema !== 'object') return 0; + + // Score 3: Object types with properties (most informative) + if (schema.type === 'object' || schema.properties) return 3; + + // Score 2: Array types with items + if (schema.type === 'array' || schema.items) return 2; + + // Score 1: Any other non-null type + if (schema.type && schema.type !== 'null') return 1; + + // Score 0: Null or no type + return 0; +} + +/** + * Convert $ref references to description hints. + * Replaces { $ref: "#/$defs/Foo" } with { type: "object", description: "See: Foo" } + * + * @param {Object} schema - Schema to process + * @returns {Object} Schema with refs converted to hints + */ +function convertRefsToHints(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(convertRefsToHints); + + const result = { ...schema }; + + // Handle $ref at this level + if (result.$ref && typeof result.$ref === 'string') { + // Extract definition name from ref path (e.g., "#/$defs/Foo" -> "Foo") + const parts = result.$ref.split('/'); + const defName = parts[parts.length - 1] || 'unknown'; + const hint = `See: ${defName}`; + + // Merge with existing description if present + const description = result.description + ? `${result.description} (${hint})` + : hint; + + // Replace with object type and hint + return { type: 'object', description }; + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + result.properties = {}; + for (const [key, value] of Object.entries(schema.properties)) { + result.properties[key] = convertRefsToHints(value); + } + } + + // Recursively process items + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(convertRefsToHints); + } else if (typeof result.items === 'object') { + result.items = convertRefsToHints(result.items); + } + } + + // Recursively process anyOf/oneOf/allOf + for (const key of ['anyOf', 'oneOf', 'allOf']) { + if (Array.isArray(result[key])) { + result[key] = result[key].map(convertRefsToHints); + } + } + + return result; +} + +/** + * Merge all schemas in an allOf array into a single schema. + * Properties and required arrays are merged; other fields use first occurrence. + * + * @param {Object} schema - Schema with potential allOf to merge + * @returns {Object} Schema with allOf merged + */ +function mergeAllOf(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(mergeAllOf); + + let result = { ...schema }; + + // Process allOf if present + if (Array.isArray(result.allOf) && result.allOf.length > 0) { + const mergedProperties = {}; + const mergedRequired = new Set(); + const otherFields = {}; + + for (const subSchema of result.allOf) { + if (!subSchema || typeof subSchema !== 'object') continue; + + // Merge properties (later overrides earlier) + if (subSchema.properties) { + for (const [key, value] of Object.entries(subSchema.properties)) { + mergedProperties[key] = value; + } + } + + // Union required arrays + if (Array.isArray(subSchema.required)) { + for (const req of subSchema.required) { + mergedRequired.add(req); + } + } + + // Copy other fields (first occurrence wins) + for (const [key, value] of Object.entries(subSchema)) { + if (key !== 'properties' && key !== 'required' && !(key in otherFields)) { + otherFields[key] = value; + } + } + } + + // Apply merged content + delete result.allOf; + + // Merge other fields first (parent takes precedence) + for (const [key, value] of Object.entries(otherFields)) { + if (!(key in result)) { + result[key] = value; + } + } + + // Merge properties (allOf properties override parent for same keys) + if (Object.keys(mergedProperties).length > 0) { + result.properties = { ...mergedProperties, ...(result.properties || {}) }; + } + + // Merge required + if (mergedRequired.size > 0) { + const parentRequired = Array.isArray(result.required) ? result.required : []; + result.required = [...new Set([...mergedRequired, ...parentRequired])]; + } + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = mergeAllOf(value); + } + result.properties = newProps; + } + + // Recursively process items + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(mergeAllOf); + } else if (typeof result.items === 'object') { + result.items = mergeAllOf(result.items); + } + } + + return result; +} + +/** + * Flatten anyOf/oneOf by selecting the best option based on scoring. + * Adds type hints to description when multiple types existed. + * + * @param {Object} schema - Schema with potential anyOf/oneOf + * @returns {Object} Flattened schema + */ +function flattenAnyOfOneOf(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(flattenAnyOfOneOf); + + let result = { ...schema }; + + // Handle anyOf or oneOf + for (const unionKey of ['anyOf', 'oneOf']) { + if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) { + const options = result[unionKey]; + + // Collect type names for hint + const typeNames = []; + let bestOption = null; + let bestScore = -1; + + for (const option of options) { + if (!option || typeof option !== 'object') continue; + + // Collect type name + const typeName = option.type || (option.properties ? 'object' : null); + if (typeName && typeName !== 'null') { + typeNames.push(typeName); + } + + // Score and track best option + const score = scoreSchemaOption(option); + if (score > bestScore) { + bestScore = score; + bestOption = option; + } + } + + // Remove the union key + delete result[unionKey]; + + // Merge best option into result + if (bestOption) { + // Preserve parent description + const parentDescription = result.description; + + // Recursively flatten the best option + const flattenedOption = flattenAnyOfOneOf(bestOption); + + // Merge fields from selected option + for (const [key, value] of Object.entries(flattenedOption)) { + if (key === 'description') { + // Merge descriptions if different + if (value && value !== parentDescription) { + result.description = parentDescription + ? `${parentDescription} (${value})` + : value; + } + } else if (!(key in result) || key === 'type' || key === 'properties' || key === 'items') { + result[key] = value; + } + } + + // Add type hint if multiple types existed + if (typeNames.length > 1) { + const uniqueTypes = [...new Set(typeNames)]; + result = appendDescriptionHint(result, `Accepts: ${uniqueTypes.join(' | ')}`); + } + } + } + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = flattenAnyOfOneOf(value); + } + result.properties = newProps; + } + + // Recursively process items + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(flattenAnyOfOneOf); + } else if (typeof result.items === 'object') { + result.items = flattenAnyOfOneOf(result.items); + } + } + + return result; +} + +/** + * Flatten array type fields and track nullable properties. + * Converts { type: ["string", "null"] } to { type: "string" } with nullable hint. + * + * @param {Object} schema - Schema to process + * @param {Set} nullableProps - Set to collect nullable property names (mutated) + * @param {string} currentPropName - Current property name (for tracking) + * @returns {Object} Flattened schema + */ +function flattenTypeArrays(schema, nullableProps = null, currentPropName = null) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(s => flattenTypeArrays(s, nullableProps)); + + let result = { ...schema }; + + // Handle array type fields + if (Array.isArray(result.type)) { + const types = result.type; + 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; + + // Add hint for multiple types + if (nonNullTypes.length > 1) { + result = appendDescriptionHint(result, `Accepts: ${nonNullTypes.join(' | ')}`); + } + + // Track nullable and add hint + if (hasNull) { + result = appendDescriptionHint(result, 'nullable'); + // Track this property as nullable for required array update + if (nullableProps && currentPropName) { + nullableProps.add(currentPropName); + } + } + } + + // Recursively process properties, tracking nullable ones + if (result.properties && typeof result.properties === 'object') { + const childNullableProps = new Set(); + const newProps = {}; + + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = flattenTypeArrays(value, childNullableProps, key); + } + result.properties = newProps; + + // Remove nullable properties from required array + if (Array.isArray(result.required) && childNullableProps.size > 0) { + result.required = result.required.filter(prop => !childNullableProps.has(prop)); + if (result.required.length === 0) { + delete result.required; + } + } + } + + // Recursively process items + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(item => flattenTypeArrays(item, nullableProps)); + } else if (typeof result.items === 'object') { + result.items = flattenTypeArrays(result.items, nullableProps); + } + } + + return result; +} + +/** + * Sanitize JSON Schema for Antigravity API compatibility. + * Uses allowlist approach - only permit known-safe JSON Schema features. + * Converts "const" to equivalent "enum" for compatibility. + * Generates placeholder schema for empty tool schemas. + */ +export function sanitizeSchema(schema) { + if (!schema || typeof schema !== 'object') { + // Empty/missing schema - generate placeholder with reason property + return { + type: 'object', + properties: { + reason: { + type: 'string', + description: 'Reason for calling this tool' + } + }, + required: ['reason'] + }; + } + + // Allowlist of permitted JSON Schema fields + const ALLOWED_FIELDS = new Set([ + 'type', + 'description', + 'properties', + 'required', + 'items', + 'enum', + 'title' + ]); + + const sanitized = {}; + + for (const [key, value] of Object.entries(schema)) { + // Convert "const" to "enum" for compatibility + if (key === 'const') { + sanitized.enum = [value]; + continue; + } + + // Skip fields not in allowlist + if (!ALLOWED_FIELDS.has(key)) { + continue; + } + + if (key === 'properties' && value && typeof value === 'object') { + sanitized.properties = {}; + for (const [propKey, propValue] of Object.entries(value)) { + sanitized.properties[propKey] = sanitizeSchema(propValue); + } + } else if (key === 'items' && value && typeof value === 'object') { + if (Array.isArray(value)) { + sanitized.items = value.map(item => sanitizeSchema(item)); + } else { + sanitized.items = sanitizeSchema(value); + } + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + sanitized[key] = sanitizeSchema(value); + } else { + sanitized[key] = value; + } + } + + // Ensure we have at least a type + if (!sanitized.type) { + sanitized.type = 'object'; + } + + // If object type with no properties, add placeholder + if (sanitized.type === 'object' && (!sanitized.properties || Object.keys(sanitized.properties).length === 0)) { + sanitized.properties = { + reason: { + type: 'string', + description: 'Reason for calling this tool' + } + }; + sanitized.required = ['reason']; + } + + return sanitized; +} + +/** + * Cleans JSON schema for Gemini API compatibility. + * Uses a multi-phase pipeline matching opencode-antigravity-auth approach. + * + * @param {Object} schema - The JSON schema to clean + * @returns {Object} Cleaned schema safe for Gemini API + */ +export function cleanSchemaForGemini(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini); + + // Phase 1: Convert $refs to hints + let result = convertRefsToHints(schema); + + // Phase 2a: Merge allOf schemas + result = mergeAllOf(result); + + // Phase 2b: Flatten anyOf/oneOf + result = flattenAnyOfOneOf(result); + + // Phase 2c: Flatten type arrays and update required for nullable + result = flattenTypeArrays(result); + + // Phase 3: Remove unsupported keywords + const unsupported = [ + 'additionalProperties', 'default', '$schema', '$defs', + 'definitions', '$ref', '$id', '$comment', 'title', + 'minLength', 'maxLength', 'pattern', 'format', + 'minItems', 'maxItems', 'examples', 'allOf', 'anyOf', 'oneOf' + ]; + + for (const key of unsupported) { + delete result[key]; + } + + // Check for unsupported 'format' in string types + if (result.type === 'string' && result.format) { + const allowed = ['enum', 'date-time']; + if (!allowed.includes(result.format)) { + delete result.format; + } + } + + // Phase 4: Final cleanup - recursively clean nested schemas and validate required + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = cleanSchemaForGemini(value); + } + result.properties = newProps; + } + + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(cleanSchemaForGemini); + } else if (typeof result.items === 'object') { + result.items = cleanSchemaForGemini(result.items); + } + } + + // Validate that required array only contains properties that exist + if (result.required && Array.isArray(result.required) && result.properties) { + const definedProps = new Set(Object.keys(result.properties)); + result.required = result.required.filter(prop => definedProps.has(prop)); + if (result.required.length === 0) { + delete result.required; + } + } + + return result; +} diff --git a/src/format/thinking-utils.js b/src/format/thinking-utils.js new file mode 100644 index 0000000..9429c3e --- /dev/null +++ b/src/format/thinking-utils.js @@ -0,0 +1,259 @@ +/** + * Thinking Block Utilities + * Handles thinking block processing, validation, and filtering + */ + +import { MIN_SIGNATURE_LENGTH } from '../constants.js'; + +/** + * Check if a part is a thinking block + * @param {Object} part - Content part to check + * @returns {boolean} True if the part is a thinking block + */ +export function isThinkingPart(part) { + return part.type === 'thinking' || + part.type === 'redacted_thinking' || + part.thinking !== undefined || + part.thought === true; +} + +/** + * Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars) + */ +export function hasValidSignature(part) { + const signature = part.thought === true ? part.thoughtSignature : part.signature; + return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH; +} + +/** + * Sanitize a thinking part by keeping only allowed fields + */ +export function sanitizeThinkingPart(part) { + // Gemini-style thought blocks: { thought: true, text, thoughtSignature } + if (part.thought === true) { + const sanitized = { thought: true }; + if (part.text !== undefined) sanitized.text = part.text; + if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature; + return sanitized; + } + + // Anthropic-style thinking blocks: { type: "thinking", thinking, signature } + if (part.type === 'thinking' || part.thinking !== undefined) { + const sanitized = { type: 'thinking' }; + if (part.thinking !== undefined) sanitized.thinking = part.thinking; + if (part.signature !== undefined) sanitized.signature = part.signature; + return sanitized; + } + + return part; +} + +/** + * Sanitize a thinking block by removing extra fields like cache_control. + * Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking) + */ +export function sanitizeAnthropicThinkingBlock(block) { + if (!block) return block; + + if (block.type === 'thinking') { + const sanitized = { type: 'thinking' }; + if (block.thinking !== undefined) sanitized.thinking = block.thinking; + if (block.signature !== undefined) sanitized.signature = block.signature; + return sanitized; + } + + if (block.type === 'redacted_thinking') { + const sanitized = { type: 'redacted_thinking' }; + if (block.data !== undefined) sanitized.data = block.data; + return sanitized; + } + + return block; +} + +/** + * Filter content array, keeping only thinking blocks with valid signatures. + */ +function filterContentArray(contentArray) { + const filtered = []; + + for (const item of contentArray) { + if (!item || typeof item !== 'object') { + filtered.push(item); + continue; + } + + if (!isThinkingPart(item)) { + filtered.push(item); + continue; + } + + // Keep items with valid signatures + if (hasValidSignature(item)) { + filtered.push(sanitizeThinkingPart(item)); + continue; + } + + // Drop unsigned thinking blocks + console.log('[ThinkingUtils] Dropping unsigned thinking block'); + } + + return filtered; +} + +/** + * Filter unsigned thinking blocks from contents (Gemini format) + * + * @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format + * @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed + */ +export function filterUnsignedThinkingBlocks(contents) { + return contents.map(content => { + if (!content || typeof content !== 'object') return content; + + if (Array.isArray(content.parts)) { + return { ...content, parts: filterContentArray(content.parts) }; + } + + return content; + }); +} + +/** + * Remove trailing unsigned thinking blocks from assistant messages. + * Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks. + * + * @param {Array} content - Array of content blocks + * @returns {Array} Content array with trailing unsigned thinking blocks removed + */ +export function removeTrailingThinkingBlocks(content) { + if (!Array.isArray(content)) return content; + if (content.length === 0) return content; + + // Work backwards from the end, removing thinking blocks + let endIndex = content.length; + for (let i = content.length - 1; i >= 0; i--) { + const block = content[i]; + if (!block || typeof block !== 'object') break; + + // Check if it's a thinking block (any format) + const isThinking = isThinkingPart(block); + + if (isThinking) { + // Check if it has a valid signature + if (!hasValidSignature(block)) { + endIndex = i; + } else { + break; // Stop at signed thinking block + } + } else { + break; // Stop at first non-thinking block + } + } + + if (endIndex < content.length) { + console.log('[ThinkingUtils] Removed', content.length - endIndex, 'trailing unsigned thinking blocks'); + return content.slice(0, endIndex); + } + + return content; +} + +/** + * Filter thinking blocks: keep only those with valid signatures. + * Blocks without signatures are dropped (API requires signatures). + * Also sanitizes blocks to remove extra fields like cache_control. + * + * @param {Array} content - Array of content blocks + * @returns {Array} Filtered content with only valid signed thinking blocks + */ +export function restoreThinkingSignatures(content) { + if (!Array.isArray(content)) return content; + + const originalLength = content.length; + const filtered = []; + + for (const block of content) { + if (!block || block.type !== 'thinking') { + filtered.push(block); + continue; + } + + // Keep blocks with valid signatures (>= MIN_SIGNATURE_LENGTH chars), sanitized + if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) { + filtered.push(sanitizeAnthropicThinkingBlock(block)); + } + // Unsigned thinking blocks are dropped + } + + if (filtered.length < originalLength) { + console.log(`[ThinkingUtils] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`); + } + + return filtered; +} + +/** + * Reorder content so that: + * 1. Thinking blocks come first (required when thinking is enabled) + * 2. Text blocks come in the middle (filtering out empty/useless ones) + * 3. Tool_use blocks come at the end (required before tool_result) + * + * @param {Array} content - Array of content blocks + * @returns {Array} Reordered content array + */ +export function reorderAssistantContent(content) { + if (!Array.isArray(content)) return content; + + // Even for single-element arrays, we need to sanitize thinking blocks + if (content.length === 1) { + const block = content[0]; + if (block && (block.type === 'thinking' || block.type === 'redacted_thinking')) { + return [sanitizeAnthropicThinkingBlock(block)]; + } + return content; + } + + const thinkingBlocks = []; + const textBlocks = []; + const toolUseBlocks = []; + let droppedEmptyBlocks = 0; + + for (const block of content) { + if (!block) continue; + + if (block.type === 'thinking' || block.type === 'redacted_thinking') { + // Sanitize thinking blocks to remove cache_control and other extra fields + thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block)); + } else if (block.type === 'tool_use') { + toolUseBlocks.push(block); + } else if (block.type === 'text') { + // Only keep text blocks with meaningful content + if (block.text && block.text.trim().length > 0) { + textBlocks.push(block); + } else { + droppedEmptyBlocks++; + } + } else { + // Other block types go in the text position + textBlocks.push(block); + } + } + + if (droppedEmptyBlocks > 0) { + console.log(`[ThinkingUtils] Dropped ${droppedEmptyBlocks} empty text block(s)`); + } + + const reordered = [...thinkingBlocks, ...textBlocks, ...toolUseBlocks]; + + // Log only if actual reordering happened (not just filtering) + if (reordered.length === content.length) { + const originalOrder = content.map(b => b?.type || 'unknown').join(','); + const newOrder = reordered.map(b => b?.type || 'unknown').join(','); + if (originalOrder !== newOrder) { + console.log('[ThinkingUtils] Reordered assistant content'); + } + } + + return reordered; +}