diff --git a/src/constants.js b/src/constants.js index 7c4e75a..06f9af7 100644 --- a/src/constants.js +++ b/src/constants.js @@ -146,11 +146,12 @@ export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CONFIG.callbackPort} // Model fallback mapping - maps primary model to fallback when quota exhausted export const MODEL_FALLBACK_MAP = { - 'gemini-3-pro-high': 'claude-sonnet-4-5-thinking', + 'gemini-3-pro-high': 'claude-opus-4-5-thinking', 'gemini-3-pro-low': 'claude-sonnet-4-5', + 'gemini-3-flash': 'claude-sonnet-4-5-thinking', 'claude-opus-4-5-thinking': 'gemini-3-pro-high', - 'claude-sonnet-4-5-thinking': 'gemini-3-pro-high', - 'claude-sonnet-4-5': 'gemini-3-pro-low' + 'claude-sonnet-4-5-thinking': 'gemini-3-flash', + 'claude-sonnet-4-5': 'gemini-3-flash' }; export default { diff --git a/src/format/request-converter.js b/src/format/request-converter.js index 98a378c..3e97406 100644 --- a/src/format/request-converter.js +++ b/src/format/request-converter.js @@ -15,6 +15,7 @@ import { removeTrailingThinkingBlocks, reorderAssistantContent, filterUnsignedThinkingBlocks, + hasGeminiHistory, needsThinkingRecovery, closeToolLoopForThinking } from './thinking-utils.js'; @@ -77,15 +78,20 @@ export function convertAnthropicToGoogle(anthropicRequest) { } } - // Apply thinking recovery for thinking models when needed - // - Gemini: needs recovery for tool loops/interrupted tools (stripped thinking) - // - Claude: needs recovery ONLY when cross-model (incompatible Gemini signatures will be dropped) + // Apply thinking recovery for Gemini thinking models when needed + // Gemini needs recovery for tool loops/interrupted tools (stripped thinking) let processedMessages = messages; - const targetFamily = isClaudeModel ? 'claude' : isGeminiModel ? 'gemini' : null; - if (isThinking && targetFamily && needsThinkingRecovery(messages)) { - logger.debug(`[RequestConverter] Applying thinking recovery for ${targetFamily}`); - processedMessages = closeToolLoopForThinking(messages, targetFamily); + if (isGeminiModel && isThinking && needsThinkingRecovery(messages)) { + logger.debug('[RequestConverter] Applying thinking recovery for Gemini'); + processedMessages = closeToolLoopForThinking(messages, 'gemini'); + } + + // For Claude: apply recovery only for cross-model (Gemini→Claude) switch + // Detected by checking if history has Gemini-style tool_use with thoughtSignature + if (isClaudeModel && isThinking && hasGeminiHistory(messages) && needsThinkingRecovery(messages)) { + logger.debug('[RequestConverter] Applying thinking recovery for Claude (cross-model from Gemini)'); + processedMessages = closeToolLoopForThinking(messages, 'claude'); } // Convert messages to contents, then filter unsigned thinking blocks @@ -108,6 +114,8 @@ export function convertAnthropicToGoogle(anthropicRequest) { // SAFETY: Google API requires at least one part per content message // This happens when all thinking blocks are filtered out (unsigned) if (parts.length === 0) { + // Use '.' instead of '' because claude models reject empty text parts. + // A single period is invisible in practice but satisfies the API requirement. logger.warn('[RequestConverter] WARNING: Empty parts array after filtering, adding placeholder'); parts.push({ text: '.' }); } diff --git a/src/format/thinking-utils.js b/src/format/thinking-utils.js index 06bca85..14ce530 100644 --- a/src/format/thinking-utils.js +++ b/src/format/thinking-utils.js @@ -27,6 +27,21 @@ export function hasValidSignature(part) { return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH; } +/** + * Check if conversation history contains Gemini-style messages. + * Gemini puts thoughtSignature on tool_use blocks, Claude puts signature on thinking blocks. + * @param {Array} messages - Array of messages + * @returns {boolean} True if any tool_use has thoughtSignature (Gemini pattern) + */ +export function hasGeminiHistory(messages) { + return messages.some(msg => + Array.isArray(msg.content) && + msg.content.some(block => + block.type === 'tool_use' && block.thoughtSignature !== undefined + ) + ); +} + /** * Sanitize a thinking part by keeping only allowed fields */ @@ -434,14 +449,14 @@ function stripInvalidThinkingBlocks(messages, targetFamily = null) { return false; } - // Check family compatibility if targetFamily is provided - if (targetFamily) { + // Check family compatibility only for Gemini targets + // Claude can validate its own signatures, so we don't drop for Claude + if (targetFamily === 'gemini') { const signature = block.thought === true ? block.thoughtSignature : block.signature; const signatureFamily = getCachedSignatureFamily(signature); - // Strict validation: If we don't know the family (cache miss) or it doesn't match, - // we drop it. We don't assume validity for unknown signatures. - if (signatureFamily !== targetFamily) { + // For Gemini: drop unknown or mismatched signatures + if (!signatureFamily || signatureFamily !== targetFamily) { strippedCount++; return false; } @@ -450,6 +465,7 @@ function stripInvalidThinkingBlocks(messages, targetFamily = null) { return true; }); + // Use '.' instead of '' because claude models reject empty text parts if (msg.content) { return { ...msg, content: filtered.length > 0 ? filtered : [{ type: 'text', text: '.' }] }; } else if (msg.parts) {