/** * Content Converter * Converts Anthropic message content to Google Generative AI parts format */ import { MIN_SIGNATURE_LENGTH, GEMINI_SKIP_SIGNATURE } from '../constants.js'; import { getCachedSignature, getCachedSignatureFamily } from './signature-cache.js'; import { logger } from '../utils/logger.js'; /** * 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 = []; const deferredInlineData = []; // Collect inlineData to add at the end (Issue #91) for (const block of content) { if (!block) continue; 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) { // Priority: block.thoughtSignature > cache > GEMINI_SKIP_SIGNATURE let signature = block.thoughtSignature; if (!signature && block.id) { signature = getCachedSignature(block.id); if (signature) { logger.debug(`[ContentConverter] Restored signature from cache for: ${block.id}`); } } part.thoughtSignature = signature || GEMINI_SKIP_SIGNATURE; } parts.push(part); } else if (block.type === 'tool_result') { // Convert tool_result to functionResponse (Google format) let responseContent = block.content; let imageParts = []; if (typeof responseContent === 'string') { responseContent = { result: responseContent }; } else if (Array.isArray(responseContent)) { // Extract images from tool results first (e.g., from Read tool reading image files) for (const item of responseContent) { if (item.type === 'image' && item.source?.type === 'base64') { imageParts.push({ inlineData: { mimeType: item.source.media_type, data: item.source.data } }); } } // Extract text content const texts = responseContent .filter(c => c.type === 'text') .map(c => c.text) .join('\n'); responseContent = { result: texts || (imageParts.length > 0 ? 'Image attached' : '') }; } const functionResponse = { name: block.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 }); // Defer images from the tool result to end of parts array (Issue #91) // This ensures all functionResponse parts are consecutive deferredInlineData.push(...imageParts); } else if (block.type === 'thinking') { // Handle thinking blocks with signature compatibility check if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) { const signatureFamily = getCachedSignatureFamily(block.signature); const targetFamily = isClaudeModel ? 'claude' : isGeminiModel ? 'gemini' : null; // For Claude: drop unknown or non-Claude signatures. // This prevents resumed sessions from forwarding stale signatures that // fail upstream validation with "Invalid `signature` in `thinking` block". if (isClaudeModel && (!signatureFamily || signatureFamily !== 'claude')) { logger.debug('[ContentConverter] Dropping untrusted thinking signature for Claude model'); continue; } // Drop blocks with incompatible signatures for Gemini (cross-model switch) if (isGeminiModel && signatureFamily && targetFamily && signatureFamily !== targetFamily) { logger.debug(`[ContentConverter] Dropping incompatible ${signatureFamily} thinking for ${targetFamily} model`); continue; } // Drop blocks with unknown signature origin for Gemini (cold cache - safe default) if (isGeminiModel && !signatureFamily && targetFamily) { logger.debug(`[ContentConverter] Dropping thinking with unknown signature origin`); continue; } // Compatible - convert to Gemini format with signature parts.push({ text: block.thinking, thought: true, thoughtSignature: block.signature }); } // Unsigned thinking blocks are dropped (existing behavior) } } // Add deferred inlineData at the end (Issue #91) // This ensures functionResponse parts are consecutive, which Claude's API requires parts.push(...deferredInlineData); return parts; }