/** * 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 };