diff --git a/src/lib/geminiNexus/ClaudeRequestMapper.ts b/src/lib/geminiNexus/ClaudeRequestMapper.ts index 5a1859a..811ff63 100644 --- a/src/lib/geminiNexus/ClaudeRequestMapper.ts +++ b/src/lib/geminiNexus/ClaudeRequestMapper.ts @@ -42,6 +42,139 @@ interface RequestConfig { // --- Main Logic --- +type ToolConfig = { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } }; + +/** + * Maps the request tool_choice to a Gemini toolConfig. + * Without declared tools only an explicit 'none' produces a config. + */ +function resolveToolConfig( + toolChoice: ClaudeRequest['tool_choice'], + hasTools: boolean, +): ToolConfig | undefined { + if (toolChoice === 'none') { + return { functionCallingConfig: { mode: 'NONE' } }; + } + if (!hasTools) { + return undefined; + } + if ( + typeof toolChoice === 'object' && + toolChoice.type === 'function' && + toolChoice.function?.name + ) { + return { + functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [toolChoice.function.name] }, + }; + } + return { functionCallingConfig: { mode: 'AUTO' } }; +} + +function resolveThinkingEnabled(claudeReq: ClaudeRequest, finalModel: string): boolean { + const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase(); + if (thinkingType === 'enabled' || thinkingType === 'adaptive') { + return true; + } + return !claudeReq.thinking && shouldEnableThinkingByDefault(finalModel, claudeReq.model); +} + +function buildSafetySettings(): SafetySetting[] { + return [ + { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' }, + { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' }, + { category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'OFF' }, + ]; +} + +/** + * Image generation endpoints reject tools, system prompts and thinking/JSON output settings, + * so they are stripped before the image config is attached. + */ +function applyImageGenerationOverrides( + innerRequest: { + tools?: unknown; + systemInstruction?: unknown; + generationConfig?: GenerationConfig; + }, + imageConfig: ImageConfig, +): void { + delete innerRequest.tools; + delete innerRequest.systemInstruction; + + const genConfig = innerRequest.generationConfig || {}; + delete genConfig.thinkingConfig; + delete genConfig.responseMimeType; + delete genConfig.responseModalities; + genConfig.imageConfig = imageConfig; + innerRequest.generationConfig = genConfig; +} + +interface InnerRequest { + contents: GeminiContent[]; + safetySettings: SafetySetting[]; + systemInstruction?: { parts: { text: string }[] }; + generationConfig?: GenerationConfig; + tools?: GeminiToolDeclaration[]; + toolConfig?: ToolConfig; +} + +/** Copies only the sections that are present, so empty ones never reach the upstream payload. */ +function attachOptionalSections( + innerRequest: InnerRequest, + sections: { + systemInstruction: InnerRequest['systemInstruction'] | null; + generationConfig: GenerationConfig; + tools: InnerRequest['tools'] | null; + toolConfig: ToolConfig | undefined; + }, +): void { + if (sections.systemInstruction) { + innerRequest.systemInstruction = sections.systemInstruction; + } + if (Object.keys(sections.generationConfig).length > 0) { + innerRequest.generationConfig = sections.generationConfig; + } + if (sections.tools) { + innerRequest.tools = sections.tools; + } + if (sections.toolConfig) { + innerRequest.toolConfig = sections.toolConfig; + } +} + +function buildRequestEnvelope( + innerRequest: object, + config: RequestConfig, + claudeReq: ClaudeRequest, + projectId: string | undefined, + userAgent: string | undefined, +): GeminiInternalRequest { + const requestId = `agent-${uuidv4()}`; + + const normalizedProjectId = projectId?.trim(); + + const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION; + const body: GeminiInternalRequest = { + requestId: requestId, + request: innerRequest as GeminiInternalRequest['request'], + model: config.finalModel, + userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion), + requestType: config.requestType, + }; + + if (normalizedProjectId) { + body.project = normalizedProjectId; + } + + if (claudeReq.metadata?.user_id) { + body.sessionId = claudeReq.metadata.user_id; + } + + return body; +} + /** * Transforms Claude request into Gemini internal request format * @param claudeReq Claude API request @@ -76,15 +209,10 @@ export function transformClaudeRequestIn( const allowDummyThought = config.finalModel.startsWith('gemini-'); // 4. Generation Config & Thinking - const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase(); - const autoThinkingEnabled = - !claudeReq.thinking && shouldEnableThinkingByDefault(config.finalModel, claudeReq.model); - let isThinkingEnabled = - thinkingType === 'enabled' || thinkingType === 'adaptive' || autoThinkingEnabled; - // Thinking is always kept enabled for thinking-capable models. // All functionCall/functionResponse/thinking parts get unconditional // thoughtSignature injection in buildContents(), so no guard is needed here. + const isThinkingEnabled = resolveThinkingEnabled(claudeReq, config.finalModel); const generationConfig = buildGenerationConfig( claudeReq, @@ -110,61 +238,16 @@ export function transformClaudeRequestIn( const tools = buildTools(claudeReq.tools, hasWebSearchTool, config.finalModel); // 5. Safety Settings - const safetySettings = [ - { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' }, - { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' }, - { category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'OFF' }, - ]; - - // Build inner request - const innerRequest: { - contents: GeminiContent[]; - safetySettings: SafetySetting[]; - systemInstruction?: { parts: { text: string }[] }; - generationConfig?: GenerationConfig; - tools?: GeminiToolDeclaration[]; - toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } }; - } = { - contents, - safetySettings, - }; + const safetySettings = buildSafetySettings(); + const innerRequest: InnerRequest = { contents, safetySettings }; deepCleanUndefined(innerRequest); - - if (systemInstruction) { - innerRequest.systemInstruction = systemInstruction; - } - - if (generationConfig && Object.keys(generationConfig).length > 0) { - innerRequest.generationConfig = generationConfig; - } - - if (tools) { - innerRequest.tools = tools; - const toolChoice = claudeReq.tool_choice; - if (toolChoice === 'none') { - innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } }; - } else if (toolChoice === 'auto' || toolChoice === undefined) { - innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }; - } else if ( - typeof toolChoice === 'object' && - toolChoice.type === 'function' && - toolChoice.function?.name - ) { - innerRequest.toolConfig = { - functionCallingConfig: { - mode: 'ANY', - allowedFunctionNames: [toolChoice.function.name], - }, - }; - } else { - innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }; - } - } else if (claudeReq.tool_choice === 'none') { - innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } }; - } + attachOptionalSections(innerRequest, { + systemInstruction, + generationConfig, + tools, + toolConfig: resolveToolConfig(claudeReq.tool_choice, !!tools), + }); // Inject googleSearch tool if needed (and not already done by buildTools) if (config.injectGoogleSearch && !hasWebSearchTool) { @@ -173,42 +256,10 @@ export function transformClaudeRequestIn( // Inject imageConfig if present (for image generation models) if (config.imageConfig) { - // 1. Remove tools (image generation does not support tools) - delete innerRequest.tools; - // 2. Remove systemInstruction (image generation does not support system prompts) - delete innerRequest.systemInstruction; - - // 3. Clean generationConfig - const genConfig = innerRequest.generationConfig || {}; - delete genConfig.thinkingConfig; - delete genConfig.responseMimeType; - delete genConfig.responseModalities; - genConfig.imageConfig = config.imageConfig; - innerRequest.generationConfig = genConfig; + applyImageGenerationOverrides(innerRequest, config.imageConfig); } - const requestId = `agent-${uuidv4()}`; - - const normalizedProjectId = projectId?.trim(); - - const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION; - const body: GeminiInternalRequest = { - requestId: requestId, - request: innerRequest as GeminiInternalRequest['request'], - model: config.finalModel, - userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion), - requestType: config.requestType, - }; - - if (normalizedProjectId) { - body.project = normalizedProjectId; - } - - if (claudeReq.metadata?.user_id) { - body.sessionId = claudeReq.metadata.user_id; - } - - return body; + return buildRequestEnvelope(innerRequest, config, claudeReq, projectId, userAgent); } /** @@ -464,6 +515,142 @@ function hasValidSignatureForFunctionCalls( return false; } +type ContentPart = GeminiContent['parts'][number]; + +interface ContentBuildState { + /** Most recent thought signature seen while walking the messages */ + lastThoughtSignature: string | null; + toolIdToName: Map; +} + +type ContentBlock = Exclude[number]; + +function buildTextPart(block: any, msg: Message): ContentPart | null { + if (!block.text || block.text === '(no content)' || isEmpty(block.text.trim())) { + return null; + } + let text = block.text.trim(); + if (msg.role === 'function' && msg.name) { + text = `Function result (${msg.name}):\n${text}`; + } else if (msg.name) { + text = `[${msg.name}]: ${text}`; + } + return { text }; +} + +function buildThinkingPart(block: any, state: ContentBuildState): ContentPart { + const part: any = { text: block.thinking, thought: true }; + cleanJsonSchema(part); + if (block.signature) { + state.lastThoughtSignature = block.signature; + part.thoughtSignature = block.signature; + } else { + // Inject fallback signature for thinking blocks without one + part.thoughtSignature = SignatureStore.get() || 'skip_thought_signature_validator'; + } + return part; +} + +function buildToolUsePart(block: any, state: ContentBuildState): ContentPart { + const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } }; + cleanJsonSchema(part); + state.toolIdToName.set(block.id, block.name); + // Always inject thoughtSignature on functionCall parts. + // The v1internal API rejects functionCalls without signatures when thinking is enabled. + const finalSig = block.signature || state.lastThoughtSignature || SignatureStore.get(); + part.thoughtSignature = finalSig || 'skip_thought_signature_validator'; + return part; +} + +function mergeToolResultContent(content: any): string { + if (isString(content)) { + return content; + } + if (Array.isArray(content)) { + return content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text) + .join('\n'); + } + return ''; +} + +function buildToolResultPart(block: any, state: ContentBuildState): ContentPart { + const funcName = state.toolIdToName.get(block.tool_use_id) || block.tool_use_id; + let mergedContent = mergeToolResultContent(block.content); + if (isEmpty(mergedContent.trim())) { + mergedContent = block.is_error + ? 'Tool execution failed with no output.' + : 'Command executed successfully.'; + } + const part: any = { + functionResponse: { + name: funcName, + response: block.is_error ? { error: mergedContent } : { result: mergedContent }, + id: block.tool_use_id, + }, + }; + // Always inject thoughtSignature on functionResponse parts for v1internal compatibility + part.thoughtSignature = + state.lastThoughtSignature || SignatureStore.get() || 'skip_thought_signature_validator'; + return part; +} + +function buildPartFromBlock( + block: ContentBlock, + msg: Message, + state: ContentBuildState, +): ContentPart | null { + switch (block.type) { + case 'text': + return buildTextPart(block, msg); + case 'thinking': + return buildThinkingPart(block, state); + case 'image': + return block.source.type === 'base64' + ? { inlineData: { mimeType: block.source.media_type, data: block.source.data } } + : null; + case 'tool_use': + return buildToolUsePart(block, state); + case 'tool_result': + return buildToolResultPart(block, state); + case 'redacted_thinking': + return { text: `[Redacted Thinking: ${block.data}]`, thought: true }; + default: + return null; + } +} + +function getContentBlocks(msg: Message): ContentBlock[] { + if (Array.isArray(msg.content)) { + return msg.content; + } + return msg.content ? [{ type: 'text' as const, text: msg.content }] : []; +} + +function toGeminiRole(role: string): string { + if (role === 'assistant') return 'model'; + return role === 'function' ? 'user' : role; +} + +function buildMessageParts(msg: Message, state: ContentBuildState): ContentPart[] { + const parts: ContentPart[] = []; + for (const block of getContentBlocks(msg)) { + const part = buildPartFromBlock(block, msg, state); + if (part) { + parts.push(part); + } + } + return parts; +} + +/** Prepends a placeholder thought when the final model turn carries none. */ +function ensureThoughtPart(parts: ContentPart[]): void { + if (!parts.some((p) => p.thought === true)) { + parts.unshift({ text: 'Thinking...', thought: true }); + } +} + /** * Builds message contents * Converts Claude message list to Gemini content format @@ -476,96 +663,16 @@ function buildContents( mappedModel: string, ): GeminiContent[] { const contents: GeminiContent[] = []; - let lastThoughtSignature: string | null = null; + const state: ContentBuildState = { lastThoughtSignature: null, toolIdToName }; + + const lastIndex = messages.length - 1; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; - let role = msg.role === 'assistant' ? 'model' : msg.role; - if (role === 'function') { - role = 'user'; - } - const parts: { - text?: string; - inlineData?: { - mimeType: string; - data: string; - }; - thought?: boolean; - }[] = []; - const contentBlocks = Array.isArray(msg.content) - ? msg.content - : msg.content - ? [{ type: 'text' as const, text: msg.content }] - : []; - - for (const block of contentBlocks) { - if (block.type === 'text') { - if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim())) { - let text = block.text.trim(); - if (msg.role === 'function' && msg.name) { - text = `Function result (${msg.name}):\n${text}`; - } else if (msg.name) { - text = `[${msg.name}]: ${text}`; - } - parts.push({ text }); - } - } else if (block.type === 'thinking') { - const part: any = { text: block.thinking, thought: true }; - cleanJsonSchema(part); - if (block.signature) { - lastThoughtSignature = block.signature; - part.thoughtSignature = block.signature; - } else { - // Inject fallback signature for thinking blocks without one - const fallbackSig = SignatureStore.get() || 'skip_thought_signature_validator'; - part.thoughtSignature = fallbackSig; - } - parts.push(part); - } else if (block.type === 'image') { - if (block.source.type === 'base64') - parts.push({ - inlineData: { mimeType: block.source.media_type, data: block.source.data }, - }); - } else if (block.type === 'tool_use') { - const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } }; - cleanJsonSchema(part); - toolIdToName.set(block.id, block.name); - // Always inject thoughtSignature on functionCall parts. - // The v1internal API rejects functionCalls without signatures when thinking is enabled. - const finalSig = block.signature || lastThoughtSignature || SignatureStore.get(); - part.thoughtSignature = finalSig || 'skip_thought_signature_validator'; - parts.push(part); - } else if (block.type === 'tool_result') { - const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id; - let mergedContent = ''; - if (isString(block.content)) mergedContent = block.content; - else if (Array.isArray(block.content)) - mergedContent = block.content - .filter((b: any) => b.type === 'text') - .map((b: any) => b.text) - .join('\n'); - if (isEmpty(mergedContent.trim())) - mergedContent = block.is_error - ? 'Tool execution failed with no output.' - : 'Command executed successfully.'; - const part: any = { - functionResponse: { - name: funcName, - response: block.is_error ? { error: mergedContent } : { result: mergedContent }, - id: block.tool_use_id, - }, - }; - // Always inject thoughtSignature on functionResponse parts for v1internal compatibility - part.thoughtSignature = - lastThoughtSignature || SignatureStore.get() || 'skip_thought_signature_validator'; - parts.push(part); - } else if (block.type === 'redacted_thinking') { - parts.push({ text: `[Redacted Thinking: ${block.data}]`, thought: true }); - } - } - if (allowDummyThought && role === 'model' && isThinkingEnabled && i === messages.length - 1) { - const hasThought = parts.some((p) => p.thought === true); - if (!hasThought) parts.unshift({ text: 'Thinking...', thought: true }); + const role = toGeminiRole(msg.role); + const parts = buildMessageParts(msg, state); + if (allowDummyThought && isThinkingEnabled && role === 'model' && i === lastIndex) { + ensureThoughtPart(parts); } if (parts.length > 0) contents.push({ role, parts }); } @@ -626,6 +733,91 @@ function buildTools( return null; } +const DEFAULT_STOP_SEQUENCES = [ + '<|user|>', + '<|endoftext|>', + '<|end_of_turn|>', + '[DONE]', + '\n\nHuman:', +]; + +function buildThinkingConfig( + claudeReq: ClaudeRequest, + hasWebSearch: boolean, + mappedModel: string, +): GenerationConfig['thinkingConfig'] { + const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase(); + const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true }; + if (thinkingType === 'adaptive') { + if (isClaudeModel(mappedModel)) { + thinkingConfig.thinkingLevel = resolveAdaptiveThinkingLevel(claudeReq); + } else { + thinkingConfig.thinkingBudget = 24576; + } + } else if (claudeReq.thinking?.budget_tokens) { + let budget = claudeReq.thinking.budget_tokens; + const isFlash = hasWebSearch || isGeminiFlashModel(mappedModel); + if (isFlash) { + budget = Math.min(budget, 24576); + } + thinkingConfig.thinkingBudget = budget; + } else { + thinkingConfig.thinkingBudget = getThinkingBudget(mappedModel); + } + return thinkingConfig; +} + +function applyOpenAISampling( + config: GenerationConfig, + claudeReq: ClaudeRequest, + mappedModel: string, + maxTokens: number | undefined, +): void { + config.temperature = claudeReq.temperature ?? 1.0; + config.topP = claudeReq.top_p ?? 0.95; + config.maxOutputTokens = maxTokens ?? getMaxOutputTokens(mappedModel); + if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) { + config.stopSequences = claudeReq.stop_sequences; + } +} + +function applyNativeSampling( + config: GenerationConfig, + claudeReq: ClaudeRequest, + maxTokens: number | undefined, +): void { + if (claudeReq.temperature !== undefined) { + config.temperature = claudeReq.temperature; + } + if (claudeReq.top_p !== undefined) { + config.topP = claudeReq.top_p; + } + if (claudeReq.top_k !== undefined) { + config.topK = claudeReq.top_k; + } + if (maxTokens !== undefined) { + config.maxOutputTokens = maxTokens; + } + if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) { + config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...DEFAULT_STOP_SEQUENCES])]; + } else { + config.stopSequences = [...DEFAULT_STOP_SEQUENCES]; + } +} + +function applyResponseFormat(config: GenerationConfig, claudeReq: ClaudeRequest): void { + const format = claudeReq.response_format; + if (!format) { + return; + } + if (format.type === 'json_object') { + config.responseMimeType = 'application/json'; + } else if (format.type === 'json_schema' && format.json_schema) { + config.responseMimeType = 'application/json'; + config.responseSchema = cleanJsonSchema(format.json_schema); + } +} + /** * build generation config * convert claude request parameters to gemini generation config @@ -637,89 +829,22 @@ function buildGenerationConfig( isThinkingEnabled: boolean, ): GenerationConfig { const source = String(claudeReq.metadata?.source || '').toLowerCase(); - const isOpenAIPath = source === 'openai'; const config: GenerationConfig = {}; - const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase(); - - const buildThinkingConfig = (): GenerationConfig['thinkingConfig'] => { - const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true }; - if (thinkingType === 'adaptive') { - if (isClaudeModel(mappedModel)) { - thinkingConfig.thinkingLevel = resolveAdaptiveThinkingLevel(claudeReq); - } else { - thinkingConfig.thinkingBudget = 24576; - } - } else if (claudeReq.thinking?.budget_tokens) { - let budget = claudeReq.thinking.budget_tokens; - const isFlash = hasWebSearch || isGeminiFlashModel(mappedModel); - if (isFlash) { - budget = Math.min(budget, 24576); - } - thinkingConfig.thinkingBudget = budget; - } else { - thinkingConfig.thinkingBudget = getThinkingBudget(mappedModel); - } - return thinkingConfig; - }; - const maxTokens = claudeReq.max_completion_tokens ?? claudeReq.max_tokens; - if (isOpenAIPath) { - config.temperature = claudeReq.temperature ?? 1.0; - config.topP = claudeReq.top_p ?? 0.95; - if (maxTokens !== undefined) { - config.maxOutputTokens = maxTokens; - } else { - config.maxOutputTokens = getMaxOutputTokens(mappedModel); - } - if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) { - config.stopSequences = claudeReq.stop_sequences; - } + if (source === 'openai') { + applyOpenAISampling(config, claudeReq, mappedModel, maxTokens); if (isThinkingEnabled) { - config.thinkingConfig = buildThinkingConfig(); + config.thinkingConfig = buildThinkingConfig(claudeReq, hasWebSearch, mappedModel); } } else { if (isThinkingEnabled) { - config.thinkingConfig = buildThinkingConfig(); - } - if (claudeReq.temperature !== undefined) { - config.temperature = claudeReq.temperature; - } - if (claudeReq.top_p !== undefined) { - config.topP = claudeReq.top_p; - } - if (claudeReq.top_k !== undefined) { - config.topK = claudeReq.top_k; - } - if (maxTokens !== undefined) { - config.maxOutputTokens = maxTokens; - } - const defaultStopSequences = [ - '<|user|>', - '<|endoftext|>', - '<|end_of_turn|>', - '[DONE]', - '\n\nHuman:', - ]; - if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) { - config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])]; - } else { - config.stopSequences = defaultStopSequences; - } - } - - if (claudeReq.response_format) { - if (claudeReq.response_format.type === 'json_object') { - config.responseMimeType = 'application/json'; - } else if ( - claudeReq.response_format.type === 'json_schema' && - claudeReq.response_format.json_schema - ) { - config.responseMimeType = 'application/json'; - config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema); + config.thinkingConfig = buildThinkingConfig(claudeReq, hasWebSearch, mappedModel); } + applyNativeSampling(config, claudeReq, maxTokens); } + applyResponseFormat(config, claudeReq); return config; } diff --git a/src/lib/geminiNexus/ModelMapping.ts b/src/lib/geminiNexus/ModelMapping.ts index 267e921..d317348 100644 --- a/src/lib/geminiNexus/ModelMapping.ts +++ b/src/lib/geminiNexus/ModelMapping.ts @@ -171,6 +171,92 @@ export function normalizeGeminiModelAlias(modelId: string): string { return GEMINI_MODEL_ALIASES[normalizedModelId] ?? modelId; } +interface OpenAIRouteRule { + matches: (lowerModel: string) => boolean; + /** Mapping keys tried in order; the first one configured by the user wins */ + targets: { key: string; label: string }[]; +} + +/** Evaluated in order; a rule whose mapping keys are all unset falls through to the next one. */ +const OPENAI_ROUTE_RULES: OpenAIRouteRule[] = [ + { + // GPT-4 Series (includes GPT-4 classic, o1, o3, etc., excludes 4o/mini/turbo) + matches: (lowerModel) => + (lowerModel.startsWith('gpt-4') && + !lowerModel.includes('o') && + !lowerModel.includes('mini') && + !lowerModel.includes('turbo')) || + lowerModel.startsWith('o1-') || + lowerModel.startsWith('o3-') || + lowerModel === 'gpt-4', + targets: [{ key: 'gpt-4-series', label: 'GPT-4 series' }], + }, + { + // GPT-4o / 3.5 Series (Balanced & Lightweight, includes 4o, mini, turbo) + matches: (lowerModel) => + lowerModel.includes('4o') || + lowerModel.startsWith('gpt-3.5') || + (lowerModel.includes('mini') && !lowerModel.includes('gemini')) || + lowerModel.includes('turbo'), + targets: [{ key: 'gpt-4o-series', label: 'GPT-4o/3.5 series' }], + }, + { + // GPT-5 Series (gpt-5, gpt-5.1, gpt-5.2, etc.); falls back to the GPT-4 series mapping + matches: (lowerModel) => lowerModel.startsWith('gpt-5'), + targets: [ + { key: 'gpt-5-series', label: 'GPT-5 series' }, + { key: 'gpt-4-series', label: 'GPT-4 series (GPT-5 fallback)' }, + ], + }, +]; + +function resolveOpenAIFamilyRoute( + originalModel: string, + lowerModel: string, + openaiMapping: Record, +): string | undefined { + for (const rule of OPENAI_ROUTE_RULES) { + if (!rule.matches(lowerModel)) { + continue; + } + for (const target of rule.targets) { + const mappedModel = openaiMapping[target.key]; + if (mappedModel) { + logger.info(`[Router] Using ${target.label} mapping: ${originalModel} -> ${mappedModel}`); + return mappedModel; + } + } + } + return undefined; +} + +function resolveAnthropicFamilyRoute( + originalModel: string, + lowerModel: string, + anthropicMapping: Record, +): string | undefined { + if (!lowerModel.startsWith('claude-')) { + return undefined; + } + + let familyKey = 'claude-default'; + if (lowerModel.includes('4-5') || lowerModel.includes('4.5')) { + familyKey = 'claude-4.5-series'; + } else if (lowerModel.includes('3-5') || lowerModel.includes('3.5')) { + familyKey = 'claude-3.5-series'; + } + + if (anthropicMapping[familyKey]) { + logger.warn( + `[Router] Using Anthropic series mapping: ${originalModel} -> ${anthropicMapping[familyKey]}`, + ); + return anthropicMapping[familyKey]; + } + + // Fallback to legacy exact mapping + return anthropicMapping[originalModel] || undefined; +} + /** * Core Model Routing Engine * Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin) @@ -199,80 +285,15 @@ export function resolveModelRoute( const lowerModel = originalModel.toLowerCase(); - // 2. Check family group mapping (OpenAI Series) - // GPT-4 Series (includes GPT-4 classic, o1, o3, etc., excludes 4o/mini/turbo) - if ( - (lowerModel.startsWith('gpt-4') && - !lowerModel.includes('o') && - !lowerModel.includes('mini') && - !lowerModel.includes('turbo')) || - lowerModel.startsWith('o1-') || - lowerModel.startsWith('o3-') || - lowerModel === 'gpt-4' - ) { - if (openaiMapping['gpt-4-series']) { - logger.info( - `[Router] Using GPT-4 series mapping: ${originalModel} -> ${openaiMapping['gpt-4-series']}`, - ); - return openaiMapping['gpt-4-series']; - } + // 2. Check family group mapping (OpenAI Series, then Anthropic Series) + const familyRoute = + resolveOpenAIFamilyRoute(originalModel, lowerModel, openaiMapping) ?? + resolveAnthropicFamilyRoute(originalModel, lowerModel, anthropicMapping); + if (familyRoute !== undefined) { + return familyRoute; } - // GPT-4o / 3.5 Series (Balanced & Lightweight, includes 4o, mini, turbo) - if ( - lowerModel.includes('4o') || - lowerModel.startsWith('gpt-3.5') || - (lowerModel.includes('mini') && !lowerModel.includes('gemini')) || - lowerModel.includes('turbo') - ) { - if (openaiMapping['gpt-4o-series']) { - logger.info( - `[Router] Using GPT-4o/3.5 series mapping: ${originalModel} -> ${openaiMapping['gpt-4o-series']}`, - ); - return openaiMapping['gpt-4o-series']; - } - } - - // GPT-5 Series (gpt-5, gpt-5.1, gpt-5.2, etc.) - if (lowerModel.startsWith('gpt-5')) { - // Prefer gpt-5-series mapping, fallback to gpt-4-series if missing - if (openaiMapping['gpt-5-series']) { - logger.info( - `[Router] Using GPT-5 series mapping: ${originalModel} -> ${openaiMapping['gpt-5-series']}`, - ); - return openaiMapping['gpt-5-series']; - } - if (openaiMapping['gpt-4-series']) { - logger.info( - `[Router] Using GPT-4 series mapping (GPT-5 fallback): ${originalModel} -> ${openaiMapping['gpt-4-series']}`, - ); - return openaiMapping['gpt-4-series']; - } - } - - // 3. Check family group mapping (Anthropic Series) - if (lowerModel.startsWith('claude-')) { - let familyKey = 'claude-default'; - if (lowerModel.includes('4-5') || lowerModel.includes('4.5')) { - familyKey = 'claude-4.5-series'; - } else if (lowerModel.includes('3-5') || lowerModel.includes('3.5')) { - familyKey = 'claude-3.5-series'; - } - - if (anthropicMapping[familyKey]) { - logger.warn( - `[Router] Using Anthropic series mapping: ${originalModel} -> ${anthropicMapping[familyKey]}`, - ); - return anthropicMapping[familyKey]; - } - - // Fallback to legacy exact mapping - if (anthropicMapping[originalModel]) { - return anthropicMapping[originalModel]; - } - } - - // 4. Fall through to system default mapping logic + // 3. Fall through to system default mapping logic return mapClaudeModelToGemini(originalModel); } diff --git a/src/lib/geminiNexus/types.ts b/src/lib/geminiNexus/types.ts index 773e937..df59d54 100644 --- a/src/lib/geminiNexus/types.ts +++ b/src/lib/geminiNexus/types.ts @@ -275,6 +275,8 @@ export interface GeminiRequest { systemInstruction?: { parts: { text: string }[] }; /** Generation config */ generationConfig?: GenerationConfig; + /** Function calling mode, emitted by the request mapper */ + toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } }; } export interface GeminiInternalRequest { diff --git a/src/server/modules/proxy/rate-limit-tracker.ts b/src/server/modules/proxy/rate-limit-tracker.ts index ceefe6c..5de4554 100644 --- a/src/server/modules/proxy/rate-limit-tracker.ts +++ b/src/server/modules/proxy/rate-limit-tracker.ts @@ -94,6 +94,153 @@ function mapGoogleReasonToTrackerReason(reason: string): RateLimitReason | null return null; } +function getErrorDetails(parsedBody: ParsedGoogleErrorBody | null): GoogleErrorDetail[] { + const details = parsedBody?.error?.details; + return Array.isArray(details) ? details : []; +} + +function detectReasonFromDetails(parsedBody: ParsedGoogleErrorBody | null): RateLimitReason | null { + for (const detail of getErrorDetails(parsedBody)) { + if (!isString(detail.reason) || isEmpty(detail.reason.trim())) { + continue; + } + const mappedReason = mapGoogleReasonToTrackerReason(detail.reason); + if (mappedReason) { + return mappedReason; + } + } + return null; +} + +function detectReasonFromStatusField( + parsedBody: ParsedGoogleErrorBody | null, + body: string | undefined, +): RateLimitReason | null { + const statusFromBody = parsedBody?.error?.status?.trim().toUpperCase(); + if (statusFromBody === 'RESOURCE_EXHAUSTED') { + return RateLimitReason.QuotaExhausted; + } + if (statusFromBody === 'UNAVAILABLE') { + const loweredBody = toLowerText(body); + if (loweredBody.includes('no capacity available') || loweredBody.includes('model capacity')) { + return RateLimitReason.ModelCapacityExhausted; + } + } + return null; +} + +/** Last resort for 429s: guess from free-form error text. */ +function detectReasonFromText(body: string | undefined): RateLimitReason { + const lowerBody = toLowerText(body); + if ( + lowerBody.includes('per minute') || + lowerBody.includes('rate limit') || + lowerBody.includes('rate_limit') || + lowerBody.includes('too many requests') + ) { + return RateLimitReason.RateLimitExceeded; + } + if ( + lowerBody.includes('model capacity exhausted') || + lowerBody.includes('no capacity available') + ) { + return RateLimitReason.ModelCapacityExhausted; + } + if (lowerBody.includes('quota') || lowerBody.includes('exhausted')) { + return RateLimitReason.QuotaExhausted; + } + return RateLimitReason.Unknown; +} + +/** Seconds from a Retry-After header (delta-seconds or HTTP-date), or null when unusable. */ +function parseRetryAfterHeader(rawHeader: string | undefined): number | null { + const headerRetryRaw = rawHeader?.trim() ?? ''; + if (headerRetryRaw === '') { + return null; + } + + const headerRetry = Number(headerRetryRaw); + if (Number.isFinite(headerRetry) && headerRetry > 0) { + return Math.max(2, Math.ceil(headerRetry)); + } + + const headerRetryAt = Date.parse(headerRetryRaw); + if (Number.isFinite(headerRetryAt)) { + const retryAfterSec = Math.ceil((headerRetryAt - Date.now()) / 1000); + if (retryAfterSec > 0) { + return Math.max(2, retryAfterSec); + } + } + return null; +} + +function resolveDefaultRetrySeconds(params: { + reason: RateLimitReason; + status: number; + failureCount: number; + backoffSteps: number[]; +}): number { + switch (params.reason) { + case RateLimitReason.QuotaExhausted: { + const steps = params.backoffSteps.length > 0 ? params.backoffSteps : [60, 300, 1800, 7200]; + const index = Math.max(0, params.failureCount - 1); + return steps[Math.min(index, steps.length - 1)]; + } + case RateLimitReason.RateLimitExceeded: + return 5; + case RateLimitReason.ModelCapacityExhausted: + if (params.failureCount <= 1) { + return 5; + } + return params.failureCount === 2 ? 10 : 15; + case RateLimitReason.ServerError: + return params.status === 404 ? 5 : 8; + default: + return 60; + } +} + +function nonBlankString(value: unknown): string | null { + return isString(value) && !isEmpty(value.trim()) ? value : null; +} + +function parseRetryDelayFromDetails(parsedBody: ParsedGoogleErrorBody | null): number | null { + for (const detail of getErrorDetails(parsedBody)) { + const candidates = [ + detail.retryDelay, + detail.metadata?.retryDelay, + detail.metadata?.quotaResetDelay, + ]; + for (const candidate of candidates) { + const delayText = nonBlankString(candidate); + const parsedDelay = delayText === null ? null : parseDurationToSeconds(delayText); + if (parsedDelay !== null) { + return parsedDelay; + } + } + } + return null; +} + +function parseRetryDelayFromText(body: string | undefined): number | null { + const lowerBody = toLowerText(body); + const minSecMatch = lowerBody.match(/try again in\s+(\d+)m\s*(\d+)s/); + if (minSecMatch) { + return Number(minSecMatch[1]) * 60 + Number(minSecMatch[2]); + } + + const secMatch = lowerBody.match(/(?:try again in|backoff for|wait)\s*(\d+)s/); + if (secMatch) { + return Number(secMatch[1]); + } + + const resetMatch = lowerBody.match(/quota will reset in\s*(\d+)\s*second/); + if (resetMatch) { + return Number(resetMatch[1]); + } + return null; +} + export class RateLimitTracker { private readonly lockoutByKey = new Map(); private readonly failureCounts = new Map(); @@ -244,52 +391,15 @@ export class RateLimitTracker { private detectRateLimitReason(status: number, body: string | undefined): RateLimitReason { const parsedBody = tryParseGoogleErrorBody(body); - const details = Array.isArray(parsedBody?.error?.details) ? parsedBody?.error?.details : []; - for (const detail of details) { - if (!isString(detail.reason) || isEmpty(detail.reason.trim())) { - continue; - } - const mappedReason = mapGoogleReasonToTrackerReason(detail.reason); - if (mappedReason) { - return mappedReason; - } + const structuredReason = + detectReasonFromDetails(parsedBody) ?? detectReasonFromStatusField(parsedBody, body); + if (structuredReason) { + return structuredReason; } - - const statusFromBody = parsedBody?.error?.status?.trim().toUpperCase(); - if (statusFromBody === 'RESOURCE_EXHAUSTED') { - return RateLimitReason.QuotaExhausted; - } - if (statusFromBody === 'UNAVAILABLE') { - const loweredBody = toLowerText(body); - if (loweredBody.includes('no capacity available') || loweredBody.includes('model capacity')) { - return RateLimitReason.ModelCapacityExhausted; - } - } - if (status !== 429) { return RateLimitReason.ServerError; } - - const lowerBody = toLowerText(body); - if ( - lowerBody.includes('per minute') || - lowerBody.includes('rate limit') || - lowerBody.includes('rate_limit') || - lowerBody.includes('too many requests') - ) { - return RateLimitReason.RateLimitExceeded; - } - if ( - lowerBody.includes('model capacity exhausted') || - lowerBody.includes('no capacity available') - ) { - return RateLimitReason.ModelCapacityExhausted; - } - if (lowerBody.includes('quota') || lowerBody.includes('exhausted')) { - return RateLimitReason.QuotaExhausted; - } - - return RateLimitReason.Unknown; + return detectReasonFromText(body); } private computeRetryAfterSeconds(params: { @@ -300,20 +410,9 @@ export class RateLimitTracker { accountId: string; backoffSteps: number[]; }): number { - const headerRetryRaw = params.retryAfter?.trim() ?? ''; - if (headerRetryRaw !== '') { - const headerRetry = Number(headerRetryRaw); - if (!Number.isNaN(headerRetry) && Number.isFinite(headerRetry) && headerRetry > 0) { - return Math.max(2, Math.ceil(headerRetry)); - } - - const headerRetryAt = Date.parse(headerRetryRaw); - if (Number.isFinite(headerRetryAt)) { - const retryAfterSec = Math.ceil((headerRetryAt - Date.now()) / 1000); - if (retryAfterSec > 0) { - return Math.max(2, retryAfterSec); - } - } + const headerRetry = parseRetryAfterHeader(params.retryAfter); + if (headerRetry !== null) { + return headerRetry; } const bodyRetry = this.parseRetryAfterSecondsFromBody(params.body); @@ -325,32 +424,12 @@ export class RateLimitTracker { params.reason !== RateLimitReason.ServerError ? this.incrementFailureCount(params.accountId) : 1; - - if (params.reason === RateLimitReason.QuotaExhausted) { - const steps = params.backoffSteps.length > 0 ? params.backoffSteps : [60, 300, 1800, 7200]; - const index = Math.max(0, failureCount - 1); - return steps[Math.min(index, steps.length - 1)]; - } - - if (params.reason === RateLimitReason.RateLimitExceeded) { - return 5; - } - - if (params.reason === RateLimitReason.ModelCapacityExhausted) { - if (failureCount <= 1) { - return 5; - } - if (failureCount === 2) { - return 10; - } - return 15; - } - - if (params.reason === RateLimitReason.ServerError) { - return params.status === 404 ? 5 : 8; - } - - return 60; + return resolveDefaultRetrySeconds({ + reason: params.reason, + status: params.status, + failureCount, + backoffSteps: params.backoffSteps, + }); } private parseRetryAfterSecondsFromBody(body: string | undefined): number | null { @@ -358,31 +437,9 @@ export class RateLimitTracker { return null; } const parsedBody = tryParseGoogleErrorBody(body); - const details = Array.isArray(parsedBody?.error?.details) ? parsedBody.error.details : []; - for (const detail of details) { - if (isString(detail.retryDelay) && !isEmpty(detail.retryDelay.trim())) { - const parsedDelay = parseDurationToSeconds(detail.retryDelay); - if (parsedDelay !== null) { - return parsedDelay; - } - } - - if (isString(detail.metadata?.retryDelay) && !isEmpty(detail.metadata.retryDelay.trim())) { - const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay); - if (parsedDelay !== null) { - return parsedDelay; - } - } - - if ( - isString(detail.metadata?.quotaResetDelay) && - !isEmpty(detail.metadata.quotaResetDelay.trim()) - ) { - const parsedDelay = parseDurationToSeconds(detail.metadata.quotaResetDelay); - if (parsedDelay !== null) { - return parsedDelay; - } - } + const detailDelay = parseRetryDelayFromDetails(parsedBody); + if (detailDelay !== null) { + return detailDelay; } const retryAfter = parsedBody?.error?.retry_after; @@ -390,23 +447,7 @@ export class RateLimitTracker { return Math.ceil(retryAfter); } - const lowerBody = toLowerText(body); - const minSecMatch = lowerBody.match(/try again in\s+(\d+)m\s*(\d+)s/); - if (minSecMatch) { - return Number(minSecMatch[1]) * 60 + Number(minSecMatch[2]); - } - - const secMatch = lowerBody.match(/(?:try again in|backoff for|wait)\s*(\d+)s/); - if (secMatch) { - return Number(secMatch[1]); - } - - const resetMatch = lowerBody.match(/quota will reset in\s*(\d+)\s*second/); - if (resetMatch) { - return Number(resetMatch[1]); - } - - return null; + return parseRetryDelayFromText(body); } private incrementFailureCount(accountId: string): number { diff --git a/src/tests/unit/claude-request-mapper.test.ts b/src/tests/unit/claude-request-mapper.test.ts new file mode 100644 index 0000000..de95160 --- /dev/null +++ b/src/tests/unit/claude-request-mapper.test.ts @@ -0,0 +1,550 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { transformClaudeRequestIn } from '../../lib/geminiNexus/ClaudeRequestMapper'; +import { SignatureStore } from '../../lib/geminiNexus/SignatureStore'; +import type { ClaudeRequest, Message } from '../../lib/geminiNexus/types'; + +const FALLBACK_SIGNATURE = 'skip_thought_signature_validator'; + +function makeRequest(overrides: Record = {}): ClaudeRequest { + return { + model: 'gemini-3-flash', + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: 100, + ...overrides, + } as unknown as ClaudeRequest; +} + +function asMessages(messages: unknown[]): Message[] { + return messages as Message[]; +} + +function contentsOf(messages: unknown[], overrides: Record = {}) { + return transformClaudeRequestIn( + makeRequest({ messages: asMessages(messages), thinking: { type: 'disabled' }, ...overrides }), + 'project', + ).request.contents; +} + +function generationConfigOf(overrides: Record = {}) { + return transformClaudeRequestIn(makeRequest(overrides), 'project').request.generationConfig; +} + +describe('transformClaudeRequestIn - contents', () => { + beforeEach(() => { + SignatureStore.clear(); + }); + + it('maps a plain string message to a user text part', () => { + expect(contentsOf([{ role: 'user', content: 'Hello' }])).toEqual([ + { role: 'user', parts: [{ text: 'Hello' }] }, + ]); + }); + + it('maps assistant to model and drops empty-string content', () => { + const contents = contentsOf([ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: '' }, + { role: 'assistant', content: 'Answer' }, + ]); + expect(contents.map((c) => c.role)).toEqual(['user', 'model']); + expect(contents[1].parts).toEqual([{ text: 'Answer' }]); + }); + + it('skips blank text blocks and the "(no content)" placeholder', () => { + const contents = contentsOf([ + { + role: 'user', + content: [ + { type: 'text', text: ' ' }, + { type: 'text', text: '(no content)' }, + { type: 'text', text: ' kept ' }, + ], + }, + ]); + expect(contents).toEqual([{ role: 'user', parts: [{ text: 'kept' }] }]); + }); + + it('prefixes function results and named messages, treating function role as user', () => { + const contents = contentsOf([ + { role: 'function', name: 'lookup', content: 'result' }, + { role: 'user', name: 'bob', content: 'hey' }, + ]); + expect(contents[0]).toEqual({ + role: 'user', + parts: [{ text: 'Function result (lookup):\nresult' }], + }); + expect(contents[1].parts).toEqual([{ text: '[bob]: hey' }]); + }); + + it('keeps base64 images and ignores non-base64 image sources', () => { + const contents = contentsOf([ + { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + { type: 'image', source: { type: 'url', url: 'https://example.com/a.png' } }, + ], + }, + ]); + expect(contents).toEqual([ + { role: 'user', parts: [{ inlineData: { mimeType: 'image/png', data: 'AAAA' } }] }, + ]); + }); + + it('uses the block signature for thinking blocks and remembers it for tool calls', () => { + const contents = contentsOf([ + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'hmm', signature: 'sig-from-block' }, + { type: 'tool_use', id: 'call-1', name: 'search', input: { q: 'x' } }, + ], + }, + ]); + const parts = contents[0].parts as any[]; + expect(parts[0]).toMatchObject({ + text: 'hmm', + thought: true, + thoughtSignature: 'sig-from-block', + }); + expect(parts[1].functionCall).toEqual({ name: 'search', args: { q: 'x' }, id: 'call-1' }); + expect(parts[1].thoughtSignature).toBe('sig-from-block'); + }); + + it('falls back to the global signature, then to the validator bypass, for thinking blocks', () => { + const messages = [{ role: 'assistant', content: [{ type: 'thinking', thinking: 'hmm' }] }]; + expect((contentsOf(messages)[0].parts[0] as any).thoughtSignature).toBe(FALLBACK_SIGNATURE); + + SignatureStore.store('global-signature'); + expect((contentsOf(messages)[0].parts[0] as any).thoughtSignature).toBe('global-signature'); + }); + + it('prefers the tool_use own signature over the global one', () => { + SignatureStore.store('global-signature-longer'); + const contents = contentsOf([ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 't', name: 'n', input: {}, signature: 'own' }], + }, + ]); + expect((contents[0].parts[0] as any).thoughtSignature).toBe('own'); + }); + + it('uses the validator bypass for tool_use when no signature is available', () => { + const contents = contentsOf([ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 't', name: 'n', input: {} }], + }, + ]); + expect((contents[0].parts[0] as any).thoughtSignature).toBe(FALLBACK_SIGNATURE); + }); + + it('resolves tool_result names from earlier tool_use ids, falling back to the id', () => { + const contents = contentsOf([ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call-1', name: 'search', input: {} }], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call-1', content: 'found' }, + { type: 'tool_result', tool_use_id: 'unknown-id', content: 'orphan' }, + ], + }, + ]); + const parts = contents[1].parts as any[]; + expect(parts[0].functionResponse).toEqual({ + name: 'search', + response: { result: 'found' }, + id: 'call-1', + }); + expect(parts[1].functionResponse.name).toBe('unknown-id'); + }); + + it('joins text items of array tool_result content and ignores other item types', () => { + const contents = contentsOf([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'x', + content: [ + { type: 'text', text: 'a' }, + { type: 'image', source: {} }, + { type: 'text', text: 'b' }, + ], + }, + ], + }, + ]); + expect((contents[0].parts[0] as any).functionResponse.response).toEqual({ result: 'a\nb' }); + }); + + it('uses default messages for empty tool results, distinguishing errors', () => { + const contents = contentsOf([ + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'ok', content: ' ' }, + { type: 'tool_result', tool_use_id: 'bad', content: '', is_error: true }, + { type: 'tool_result', tool_use_id: 'boom', content: 'exploded', is_error: true }, + ], + }, + ]); + const parts = contents[0].parts as any[]; + expect(parts[0].functionResponse.response).toEqual({ + result: 'Command executed successfully.', + }); + expect(parts[1].functionResponse.response).toEqual({ + error: 'Tool execution failed with no output.', + }); + expect(parts[2].functionResponse.response).toEqual({ error: 'exploded' }); + expect(parts[0].thoughtSignature).toBe(FALLBACK_SIGNATURE); + }); + + it('keeps redacted thinking as a marked thought part', () => { + const contents = contentsOf([ + { role: 'assistant', content: [{ type: 'redacted_thinking', data: 'abc' }] }, + ]); + expect(contents[0].parts).toEqual([{ text: '[Redacted Thinking: abc]', thought: true }]); + }); + + it('drops messages that produce no parts', () => { + const contents = contentsOf([ + { role: 'user', content: [{ type: 'text', text: ' ' }] }, + { role: 'user', content: 'real' }, + ]); + expect(contents).toHaveLength(1); + }); + + it('prepends a dummy thought to a trailing model turn without thinking when thinking is on', () => { + const contents = contentsOf( + [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Partial' }, + ], + { thinking: { type: 'enabled' } }, + ); + expect(contents[1].parts[0]).toEqual({ text: 'Thinking...', thought: true }); + expect(contents[1].parts[1]).toEqual({ text: 'Partial' }); + }); + + it('does not add a dummy thought when the model turn already has a thought', () => { + const contents = contentsOf( + [ + { role: 'user', content: 'Hi' }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'real', signature: 's' }, + { type: 'text', text: 'Partial' }, + ], + }, + ], + { thinking: { type: 'enabled' } }, + ); + expect(contents[1].parts).toHaveLength(2); + expect((contents[1].parts[0] as any).text).toBe('real'); + }); + + it('does not add a dummy thought when thinking is off or the model turn is not last', () => { + const off = contentsOf( + [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Partial' }, + ], + { thinking: { type: 'disabled' } }, + ); + expect(off[1].parts).toEqual([{ text: 'Partial' }]); + + const notLast = contentsOf( + [ + { role: 'assistant', content: 'First' }, + { role: 'user', content: 'Hi' }, + ], + { thinking: { type: 'enabled' } }, + ); + expect(notLast[0].parts).toEqual([{ text: 'First' }]); + }); +}); + +describe('transformClaudeRequestIn - generationConfig', () => { + beforeEach(() => { + SignatureStore.clear(); + }); + + const DEFAULT_STOP_SEQUENCES = [ + '<|user|>', + '<|endoftext|>', + '<|end_of_turn|>', + '[DONE]', + '\n\nHuman:', + ]; + + it('native path: forwards sampling options and merges default stop sequences', () => { + const config = generationConfigOf({ + temperature: 0.2, + top_p: 0.5, + top_k: 10, + max_tokens: 321, + stop_sequences: ['STOP', '[DONE]'], + thinking: { type: 'disabled' }, + }); + expect(config).toMatchObject({ temperature: 0.2, topP: 0.5, topK: 10, maxOutputTokens: 321 }); + expect(config?.stopSequences).toEqual([ + 'STOP', + '[DONE]', + '<|user|>', + '<|endoftext|>', + '<|end_of_turn|>', + '\n\nHuman:', + ]); + expect(config?.thinkingConfig).toBeUndefined(); + }); + + it('native path: uses only the default stop sequences and omits unset options', () => { + const config = generationConfigOf({ max_tokens: undefined, thinking: { type: 'disabled' } }); + expect(config?.stopSequences).toEqual(DEFAULT_STOP_SEQUENCES); + expect(config).not.toHaveProperty('temperature'); + expect(config).not.toHaveProperty('topP'); + expect(config).not.toHaveProperty('topK'); + expect(config).not.toHaveProperty('maxOutputTokens'); + }); + + it('max_completion_tokens takes precedence over max_tokens', () => { + const config = generationConfigOf({ + max_completion_tokens: 55, + max_tokens: 99, + thinking: { type: 'disabled' }, + }); + expect(config?.maxOutputTokens).toBe(55); + }); + + it('openai path: applies temperature/topP defaults and model max output tokens', () => { + const config = generationConfigOf({ + metadata: { source: 'openai' }, + max_tokens: undefined, + thinking: { type: 'disabled' }, + }); + expect(config?.temperature).toBe(1.0); + expect(config?.topP).toBe(0.95); + expect(config?.maxOutputTokens).toBeGreaterThan(0); + expect(config).not.toHaveProperty('stopSequences'); + expect(config).not.toHaveProperty('topK'); + }); + + it('openai path: keeps explicit values and only custom stop sequences', () => { + const config = generationConfigOf({ + metadata: { source: 'OpenAI' }, + temperature: 0, + top_p: 0.1, + max_tokens: 42, + stop_sequences: ['END'], + thinking: { type: 'disabled' }, + }); + expect(config).toMatchObject({ + temperature: 0, + topP: 0.1, + maxOutputTokens: 42, + stopSequences: ['END'], + }); + }); + + it('thinking: explicit budget is capped for flash models and kept otherwise', () => { + const flash = generationConfigOf({ thinking: { type: 'enabled', budget_tokens: 50000 } }); + expect(flash?.thinkingConfig).toEqual({ includeThoughts: true, thinkingBudget: 24576 }); + + const flashSmall = generationConfigOf({ thinking: { type: 'enabled', budget_tokens: 1000 } }); + expect(flashSmall?.thinkingConfig?.thinkingBudget).toBe(1000); + + const pro = generationConfigOf({ + model: 'gemini-3.1-pro-high', + thinking: { type: 'enabled', budget_tokens: 50000 }, + }); + expect(pro?.thinkingConfig?.thinkingBudget).toBe(50000); + }); + + it('thinking: without explicit budget uses the model default budget', () => { + const config = generationConfigOf({ thinking: { type: 'enabled' } }); + expect(config?.thinkingConfig?.includeThoughts).toBe(true); + expect(typeof config?.thinkingConfig?.thinkingBudget).toBe('number'); + }); + + it('thinking: adaptive uses a fixed budget on non-Claude models', () => { + const config = generationConfigOf({ thinking: { type: 'adaptive', effort: 'low' } }); + expect(config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingBudget: 24576 }); + }); + + it('thinking: enabled by default for gemini-3-flash and off when explicitly disabled', () => { + expect(generationConfigOf()?.thinkingConfig?.includeThoughts).toBe(true); + expect(generationConfigOf({ thinking: { type: 'disabled' } })?.thinkingConfig).toBeUndefined(); + }); + + it('response_format: json_object sets the mime type only', () => { + const config = generationConfigOf({ response_format: { type: 'json_object' } }); + expect(config?.responseMimeType).toBe('application/json'); + expect(config).not.toHaveProperty('responseSchema'); + }); + + it('response_format: json_schema sets the mime type; incomplete or text sets nothing', () => { + const withSchema = generationConfigOf({ + response_format: { + type: 'json_schema', + json_schema: { type: 'object', properties: { a: { type: 'string' } } }, + }, + }); + expect(withSchema?.responseMimeType).toBe('application/json'); + // Known limitation: cleanJsonSchema() mutates in place and returns void, so the schema + // assigned to responseSchema is undefined and dropped from the request. + expect(withSchema?.responseSchema).toBeUndefined(); + + const missingSchema = generationConfigOf({ response_format: { type: 'json_schema' } }); + expect(missingSchema).not.toHaveProperty('responseMimeType'); + + const text = generationConfigOf({ response_format: { type: 'text' } }); + expect(text).not.toHaveProperty('responseMimeType'); + }); +}); + +describe('transformClaudeRequestIn - request assembly', () => { + beforeEach(() => { + SignatureStore.clear(); + }); + + const FUNCTION_TOOL = { + name: 'lookup', + description: 'Look something up', + input_schema: { type: 'object', properties: { q: { type: 'string' } } }, + }; + + function transform(overrides: Record = {}, projectId?: string) { + return transformClaudeRequestIn(makeRequest(overrides), projectId); + } + + it('defaults to an agent request with safety settings and a trimmed project id', () => { + const body = transform({}, ' my-project '); + expect(body.requestType).toBe('agent'); + expect(body.project).toBe('my-project'); + expect(body.model).toBe('gemini-3-flash'); + expect(body.requestId).toMatch(/^agent-/); + expect(body.request.safetySettings).toHaveLength(5); + expect(body.request.safetySettings?.every((s) => s.threshold === 'OFF')).toBe(true); + expect(body.request).not.toHaveProperty('tools'); + expect(body.request).not.toHaveProperty('toolConfig'); + }); + + it('omits project and sessionId when absent and forwards metadata.user_id as sessionId', () => { + const bare = transform(); + expect(bare).not.toHaveProperty('project'); + expect(bare).not.toHaveProperty('sessionId'); + + const blankProject = transform({}, ' '); + expect(blankProject).not.toHaveProperty('project'); + + const withSession = transform({ metadata: { user_id: 'session-1' } }); + expect(withSession.sessionId).toBe('session-1'); + }); + + it('uses a caller supplied user agent after trimming', () => { + const body = transformClaudeRequestIn(makeRequest(), 'p', ' custom-agent/1.0 '); + expect(body.userAgent).toBe('custom-agent/1.0'); + expect(transformClaudeRequestIn(makeRequest(), 'p', ' ').userAgent).toBeTruthy(); + }); + + it('system prompt: string and text blocks become parts; empty system is omitted', () => { + expect(transform({ system: 'Be brief' }).request.systemInstruction?.parts).toEqual([ + { text: 'Be brief' }, + ]); + expect( + transform({ + system: [ + { type: 'text', text: 'one' }, + { type: 'other', text: 'skipped' }, + { type: 'text', text: 'two' }, + ], + }).request.systemInstruction?.parts, + ).toEqual([{ text: 'one' }, { text: 'two' }]); + expect(transform().request).not.toHaveProperty('systemInstruction'); + }); + + it('declares function tools and defaults tool_choice to AUTO', () => { + const body = transform({ tools: [FUNCTION_TOOL] }); + expect(body.request.tools?.[0].functionDeclarations?.[0].name).toBe('lookup'); + expect(body.request.toolConfig).toEqual({ functionCallingConfig: { mode: 'AUTO' } }); + }); + + it('tool_choice: auto stays AUTO, none disables, named function forces ANY', () => { + expect(transform({ tools: [FUNCTION_TOOL], tool_choice: 'auto' }).request.toolConfig).toEqual({ + functionCallingConfig: { mode: 'AUTO' }, + }); + expect(transform({ tools: [FUNCTION_TOOL], tool_choice: 'none' }).request.toolConfig).toEqual({ + functionCallingConfig: { mode: 'NONE' }, + }); + expect( + transform({ + tools: [FUNCTION_TOOL], + tool_choice: { type: 'function', function: { name: 'lookup' } }, + }).request.toolConfig, + ).toEqual({ functionCallingConfig: { mode: 'ANY', allowedFunctionNames: ['lookup'] } }); + }); + + it('tool_choice: a function choice without a name falls back to AUTO', () => { + const body = transform({ + tools: [FUNCTION_TOOL], + tool_choice: { type: 'function', function: { name: '' } }, + }); + expect(body.request.toolConfig).toEqual({ functionCallingConfig: { mode: 'AUTO' } }); + }); + + it('tool_choice without tools: only none produces a toolConfig', () => { + expect(transform({ tool_choice: 'none' }).request.toolConfig).toEqual({ + functionCallingConfig: { mode: 'NONE' }, + }); + expect(transform({ tool_choice: 'auto' }).request).not.toHaveProperty('toolConfig'); + expect( + transform({ tool_choice: { type: 'function', function: { name: 'lookup' } } }).request, + ).not.toHaveProperty('toolConfig'); + }); + + it('web search tool switches to a web_search request on gemini-3-flash with googleSearch', () => { + const body = transform({ + model: 'gemini-3.1-pro-high', + tools: [{ type: 'web_search_20250305', name: 'web_search' }], + }); + expect(body.requestType).toBe('web_search'); + expect(body.model).toBe('gemini-3-flash'); + expect(body.request.tools).toEqual([{ googleSearch: {} }]); + }); + + it('-online suffix enables networking; function declarations suppress googleSearch', () => { + const online = transform({ model: 'gemini-3-flash-online' }); + expect(online.requestType).toBe('web_search'); + expect(online.model).toBe('gemini-3-flash'); + expect(online.request.tools).toEqual([{ googleSearch: {} }]); + + const withFunctions = transform({ model: 'gemini-3-flash-online', tools: [FUNCTION_TOOL] }); + expect(withFunctions.requestType).toBe('web_search'); + expect(withFunctions.request.tools?.every((t) => !t.googleSearch)).toBe(true); + }); + + it('image models: strip tools, system prompt and thinking, and attach imageConfig', () => { + const body = transform({ + model: 'gemini-3-pro-image-16x9-4k', + system: 'ignored', + tools: [FUNCTION_TOOL], + response_format: { type: 'json_object' }, + }); + expect(body.requestType).toBe('image_gen'); + expect(body.request).not.toHaveProperty('tools'); + expect(body.request).not.toHaveProperty('systemInstruction'); + expect(body.request.generationConfig?.imageConfig).toEqual({ + aspectRatio: '16:9', + imageSize: '4K', + }); + expect(body.request.generationConfig).not.toHaveProperty('thinkingConfig'); + expect(body.request.generationConfig).not.toHaveProperty('responseMimeType'); + }); +}); diff --git a/src/tests/unit/model-mapping.test.ts b/src/tests/unit/model-mapping.test.ts new file mode 100644 index 0000000..954e5f8 --- /dev/null +++ b/src/tests/unit/model-mapping.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { + getDynamicForwardingTarget, + resolveModelRoute, + updateDynamicForwardingRules, +} from '../../lib/geminiNexus/ModelMapping'; + +describe('resolveModelRoute', () => { + const NO_MAPPING: Record = {}; + + function route( + model: string, + options: { + custom?: Record; + openai?: Record; + anthropic?: Record; + } = {}, + ) { + return resolveModelRoute( + model, + options.custom ?? NO_MAPPING, + options.openai ?? NO_MAPPING, + options.anthropic ?? NO_MAPPING, + ); + } + + describe('dynamic forwarding', () => { + it('forwards a deprecated model to a supported replacement', () => { + updateDynamicForwardingRules('route-test-old-model', 'gemini-3-flash'); + expect(getDynamicForwardingTarget('route-test-old-model')).toBe('gemini-3-flash'); + expect(route('route-test-old-model', { custom: { 'route-test-old-model': 'custom' } })).toBe( + 'gemini-3-flash', + ); + }); + + it('ignores a forwarding rule whose target is not a supported model', () => { + updateDynamicForwardingRules('route-test-unsupported', 'not-a-supported-model'); + expect(route('route-test-unsupported')).toBe('route-test-unsupported'); + }); + + it('ignores empty or non-string rules', () => { + updateDynamicForwardingRules(' ', 'gemini-3-flash'); + updateDynamicForwardingRules('route-test-empty-target', ' '); + expect(getDynamicForwardingTarget('route-test-empty-target')).toBeUndefined(); + }); + }); + + describe('custom exact mapping', () => { + it('wins over family mappings and the built-in table', () => { + expect( + route('gpt-4o', { + custom: { 'gpt-4o': 'my-model' }, + openai: { 'gpt-4o-series': 'other' }, + }), + ).toBe('my-model'); + }); + + it('is case sensitive and does not apply to other names', () => { + expect(route('GPT-4O', { custom: { 'gpt-4o': 'my-model' } })).toBe('GPT-4O'); + }); + }); + + describe('OpenAI families', () => { + it('maps the GPT-4 series (classic, o1-, o3-) when configured', () => { + const openai = { 'gpt-4-series': 'gpt4-target' }; + expect(route('gpt-4', { openai })).toBe('gpt4-target'); + expect(route('gpt-4-0613', { openai })).toBe('gpt4-target'); + expect(route('o1-preview', { openai })).toBe('gpt4-target'); + expect(route('O3-mini', { openai })).toBe('gpt4-target'); + }); + + it('does not treat gpt-4o, mini or turbo variants as the GPT-4 series', () => { + const openai = { 'gpt-4-series': 'gpt4-target' }; + expect(route('gpt-4o', { openai })).toBe('gemini-3-flash'); + expect(route('gpt-4-turbo', { openai })).toBe('gemini-3-flash'); + }); + + it('maps the GPT-4o / 3.5 / mini / turbo series when configured', () => { + const openai = { 'gpt-4o-series': 'mini-target' }; + expect(route('gpt-4o', { openai })).toBe('mini-target'); + expect(route('gpt-3.5-turbo', { openai })).toBe('mini-target'); + expect(route('gpt-4-turbo', { openai })).toBe('mini-target'); + expect(route('gpt-4o-mini', { openai })).toBe('mini-target'); + expect(route('some-mini-model', { openai })).toBe('mini-target'); + }); + + it('does not treat gemini names containing "mini" as the GPT-4o series', () => { + const openai = { 'gpt-4o-series': 'mini-target' }; + expect(route('gemini-3-flash', { openai })).toBe('gemini-3-flash'); + }); + + it('maps GPT-5 to gpt-5-series, then falls back to gpt-4-series, then to the default', () => { + expect(route('gpt-5.2', { openai: { 'gpt-5-series': 'g5', 'gpt-4-series': 'g4' } })).toBe( + 'g5', + ); + expect(route('gpt-5', { openai: { 'gpt-4-series': 'g4' } })).toBe('g4'); + expect(route('gpt-5', { openai: { 'gpt-4o-series': 'mini' } })).toBe('gpt-5'); + }); + + it('falls through to the built-in table when the family key is not configured', () => { + expect(route('gpt-4')).toBe('gemini-3-flash'); + expect(route('gpt-4', { openai: { 'gpt-4o-series': 'mini' } })).toBe('gemini-3-flash'); + }); + }); + + describe('Anthropic families', () => { + it('picks the 4.5, 3.5 or default family key from the model name', () => { + const anthropic = { + 'claude-4.5-series': 'family-45', + 'claude-3.5-series': 'family-35', + 'claude-default': 'family-default', + }; + expect(route('claude-sonnet-4-5-20250929', { anthropic })).toBe('family-45'); + expect(route('claude-sonnet-4.5', { anthropic })).toBe('family-45'); + expect(route('claude-3-5-sonnet-20241022', { anthropic })).toBe('family-35'); + expect(route('claude-3.5-haiku', { anthropic })).toBe('family-35'); + expect(route('claude-opus-4-6', { anthropic })).toBe('family-default'); + }); + + it('falls back to a legacy exact mapping when the family key is missing', () => { + expect(route('claude-opus-4-6', { anthropic: { 'claude-opus-4-6': 'legacy-target' } })).toBe( + 'legacy-target', + ); + }); + + it('prefers the family key over the legacy exact mapping', () => { + expect( + route('claude-opus-4-6', { + anthropic: { 'claude-default': 'family-default', 'claude-opus-4-6': 'legacy-target' }, + }), + ).toBe('family-default'); + }); + + it('is not applied to names that do not start with claude-', () => { + expect(route('my-claude-model', { anthropic: { 'claude-default': 'family-default' } })).toBe( + 'my-claude-model', + ); + }); + + it('falls through to the built-in table when nothing is configured', () => { + expect(route('claude-sonnet-4-5')).toBe('claude-sonnet-4-6-thinking'); + }); + }); + + describe('system default mapping', () => { + it('maps known aliases and passes unknown names through', () => { + expect(route('gemini-2.5-pro')).toBe('gemini-3.1-pro-high'); + expect(route('totally-unknown-model')).toBe('totally-unknown-model'); + }); + }); +}); diff --git a/src/tests/unit/rate-limit-reason.test.ts b/src/tests/unit/rate-limit-reason.test.ts new file mode 100644 index 0000000..1ac1f02 --- /dev/null +++ b/src/tests/unit/rate-limit-reason.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'vitest'; +import { RateLimitReason, RateLimitTracker } from '../../server/modules/proxy/rate-limit-tracker'; + +function track(params: { status: number; body?: string; retryAfter?: string }) { + return new RateLimitTracker().trackFromUpstreamError({ + accountId: 'acc', + backoffSteps: [], + ...params, + }); +} + +function reasonFor(status: number, body?: string): RateLimitReason | undefined { + return track({ status, body })?.reason; +} + +function googleBody(error: Record): string { + return JSON.stringify({ error }); +} + +describe('RateLimitTracker.trackFromUpstreamError - reason detection', () => { + it('ignores statuses that are not rate-limit related', () => { + expect(track({ status: 200 })).toBeNull(); + expect(track({ status: 400 })).toBeNull(); + expect(track({ status: 401 })).toBeNull(); + }); + + describe('structured Google error details', () => { + it.each([ + ['QUOTA_EXHAUSTED', RateLimitReason.QuotaExhausted], + ['RATE_LIMIT_EXCEEDED', RateLimitReason.RateLimitExceeded], + ['MODEL_CAPACITY_EXHAUSTED', RateLimitReason.ModelCapacityExhausted], + [' quota_exhausted ', RateLimitReason.QuotaExhausted], + ])('maps detail reason %s', (reason, expected) => { + const body = googleBody({ details: [{ reason }] }); + expect(reasonFor(429, body)).toBe(expected); + expect(reasonFor(503, body)).toBe(expected); + }); + + it('skips blank, non-string and unknown reasons and uses the first mapped one', () => { + const body = googleBody({ + details: [ + { reason: ' ' }, + { reason: 42 }, + {}, + { reason: 'SOMETHING_ELSE' }, + { reason: 'RATE_LIMIT_EXCEEDED' }, + { reason: 'QUOTA_EXHAUSTED' }, + ], + }); + expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded); + }); + + it('prefers detail reasons over the status field and the raw text', () => { + const body = googleBody({ + status: 'RESOURCE_EXHAUSTED', + message: 'quota exceeded', + details: [{ reason: 'RATE_LIMIT_EXCEEDED' }], + }); + expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded); + }); + }); + + describe('error status field', () => { + it('treats RESOURCE_EXHAUSTED as quota exhaustion for any tracked status', () => { + const body = googleBody({ status: ' resource_exhausted ' }); + expect(reasonFor(429, body)).toBe(RateLimitReason.QuotaExhausted); + expect(reasonFor(503, body)).toBe(RateLimitReason.QuotaExhausted); + }); + + it('treats UNAVAILABLE with capacity wording as model capacity exhaustion', () => { + expect( + reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'No capacity available' })), + ).toBe(RateLimitReason.ModelCapacityExhausted); + expect( + reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'model capacity reached' })), + ).toBe(RateLimitReason.ModelCapacityExhausted); + }); + + it('treats UNAVAILABLE without capacity wording as a server error (non-429)', () => { + expect(reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'try later' }))).toBe( + RateLimitReason.ServerError, + ); + }); + }); + + describe('status fallback', () => { + it.each([404, 500, 503, 529])('classifies a plain %i as a server error', (status) => { + expect(reasonFor(status, 'boom')).toBe(RateLimitReason.ServerError); + expect(reasonFor(status, undefined)).toBe(RateLimitReason.ServerError); + }); + + it('does not apply 429 text heuristics to other statuses', () => { + expect(reasonFor(500, 'rate limit exceeded, quota exhausted')).toBe( + RateLimitReason.ServerError, + ); + }); + }); + + describe('429 text heuristics', () => { + it.each(['Requests per minute exceeded', 'RATE LIMIT hit', 'rate_limit', 'Too Many Requests'])( + 'classifies %s as a rate limit', + (body) => { + expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded); + }, + ); + + it.each(['Model capacity exhausted', 'No capacity available right now'])( + 'classifies %s as model capacity', + (body) => { + expect(reasonFor(429, body)).toBe(RateLimitReason.ModelCapacityExhausted); + }, + ); + + it.each(['Quota reached', 'resources exhausted'])('classifies %s as quota', (body) => { + expect(reasonFor(429, body)).toBe(RateLimitReason.QuotaExhausted); + }); + + it('checks rate-limit wording before capacity and quota wording', () => { + expect(reasonFor(429, 'too many requests: quota exhausted, no capacity available')).toBe( + RateLimitReason.RateLimitExceeded, + ); + expect(reasonFor(429, 'no capacity available, quota exhausted')).toBe( + RateLimitReason.ModelCapacityExhausted, + ); + }); + + it('is unknown when nothing matches or the body is missing', () => { + expect(reasonFor(429, 'something odd')).toBe(RateLimitReason.Unknown); + expect(reasonFor(429, undefined)).toBe(RateLimitReason.Unknown); + expect(reasonFor(429, '{not json')).toBe(RateLimitReason.Unknown); + }); + }); +}); + +describe('RateLimitTracker.trackFromUpstreamError - default retry delays', () => { + const STEPS = [10, 20, 30]; + + it('uses configured backoff steps for quota exhaustion and clamps at the last one', () => { + const tracker = new RateLimitTracker(); + const delays = [1, 2, 3, 4].map( + () => + tracker.trackFromUpstreamError({ + accountId: 'acc', + status: 429, + body: 'quota exhausted', + backoffSteps: STEPS, + })?.retryAfterSec, + ); + expect(delays).toEqual([10, 20, 30, 30]); + }); + + it('falls back to built-in quota steps when none are configured', () => { + const tracker = new RateLimitTracker(); + const first = tracker.trackFromUpstreamError({ + accountId: 'acc', + status: 429, + body: 'quota exhausted', + backoffSteps: [], + }); + expect(first?.retryAfterSec).toBe(60); + }); + + it('uses a fixed 5 seconds for rate limits', () => { + expect(track({ status: 429, body: 'rate limit' })?.retryAfterSec).toBe(5); + }); + + it('escalates model capacity delays 5, 10, 15 per account', () => { + const tracker = new RateLimitTracker(); + const delays = [1, 2, 3, 4].map( + () => + tracker.trackFromUpstreamError({ + accountId: 'acc', + status: 429, + body: 'no capacity available', + backoffSteps: [], + })?.retryAfterSec, + ); + expect(delays).toEqual([5, 10, 15, 15]); + }); + + it('uses 5 seconds for 404 and 8 seconds for other server errors without escalating', () => { + const tracker = new RateLimitTracker(); + const first = tracker.trackFromUpstreamError({ accountId: 'a', status: 404, backoffSteps: [] }); + const second = tracker.trackFromUpstreamError({ + accountId: 'a', + status: 500, + backoffSteps: [], + }); + const third = tracker.trackFromUpstreamError({ accountId: 'a', status: 500, backoffSteps: [] }); + expect([first?.retryAfterSec, second?.retryAfterSec, third?.retryAfterSec]).toEqual([5, 8, 8]); + }); + + it('uses 60 seconds for unknown reasons', () => { + expect(track({ status: 429, body: 'something odd' })?.retryAfterSec).toBe(60); + }); + + it('prefers a numeric Retry-After header, with a 2 second floor and rounding up', () => { + expect(track({ status: 429, retryAfter: '30' })?.retryAfterSec).toBe(30); + expect(track({ status: 429, retryAfter: '1' })?.retryAfterSec).toBe(2); + expect(track({ status: 429, retryAfter: '2.1' })?.retryAfterSec).toBe(3); + }); + + it('parses an HTTP-date Retry-After header and ignores dates in the past', () => { + const future = new Date(Date.now() + 45_000).toUTCString(); + const delay = track({ status: 429, retryAfter: future, body: 'rate limit' })?.retryAfterSec; + expect(delay).toBeGreaterThanOrEqual(43); + expect(delay).toBeLessThanOrEqual(46); + + const past = new Date(Date.now() - 60_000).toUTCString(); + expect(track({ status: 429, retryAfter: past, body: 'rate limit' })?.retryAfterSec).toBe(5); + }); + + it('ignores zero, negative, blank and garbage Retry-After headers', () => { + expect(track({ status: 429, retryAfter: '0', body: 'rate limit' })?.retryAfterSec).toBe(5); + expect(track({ status: 429, retryAfter: '-5', body: 'rate limit' })?.retryAfterSec).toBe(5); + expect(track({ status: 429, retryAfter: ' ', body: 'rate limit' })?.retryAfterSec).toBe(5); + expect(track({ status: 429, retryAfter: 'soon', body: 'rate limit' })?.retryAfterSec).toBe(5); + }); + + it('prefers the header over delays found in the body', () => { + const body = googleBody({ details: [{ retryDelay: '99s' }] }); + expect(track({ status: 429, retryAfter: '7', body })?.retryAfterSec).toBe(7); + }); + + it.each([ + ['retryDelay detail', googleBody({ details: [{ retryDelay: '12s' }] }), 12], + [ + 'metadata.retryDelay detail', + googleBody({ details: [{ metadata: { retryDelay: '13s' } }] }), + 13, + ], + [ + 'metadata.quotaResetDelay detail', + googleBody({ details: [{ metadata: { quotaResetDelay: '1m1s' } }] }), + 61, + ], + ['retry_after number', googleBody({ retry_after: 14.2 }), 15], + ['try again in minutes and seconds', 'Please try again in 2m 5s', 125], + ['try again in seconds', 'Please try again in 9s.', 9], + ['backoff for', 'backoff for 11s', 11], + ['quota reset message', 'Your quota will reset in 17 seconds', 17], + ])('uses the body hint (%s)', (_label, body, expected) => { + expect(track({ status: 429, body })?.retryAfterSec).toBe(expected); + }); + + it('skips unparseable body delays and falls through to the default', () => { + const body = googleBody({ details: [{ retryDelay: 'later' }], retry_after: 0 }); + expect(track({ status: 429, body })?.retryAfterSec).toBe(60); + }); + + it('does not escalate failure counters when a header or body hint is used', () => { + const tracker = new RateLimitTracker(); + tracker.trackFromUpstreamError({ + accountId: 'acc', + status: 429, + body: 'quota exhausted', + retryAfter: '30', + backoffSteps: STEPS, + }); + const next = tracker.trackFromUpstreamError({ + accountId: 'acc', + status: 429, + body: 'quota exhausted', + backoffSteps: STEPS, + }); + expect(next?.retryAfterSec).toBe(10); + }); + + it('locks quota exhaustion per model and other reasons per account', () => { + const tracker = new RateLimitTracker(); + tracker.trackFromUpstreamError({ + accountId: 'acc', + status: 429, + body: 'quota exhausted', + model: 'm1', + backoffSteps: [], + }); + expect(tracker.isRateLimited('acc', 'm1')).toBe(true); + expect(tracker.isRateLimited('acc', 'm2')).toBe(false); + expect(tracker.isRateLimited('acc')).toBe(false); + + tracker.trackFromUpstreamError({ + accountId: 'acc', + status: 429, + body: 'rate limit', + model: 'm1', + backoffSteps: [], + }); + expect(tracker.isRateLimited('acc', 'm2')).toBe(true); + }); +});