refactor: reduce cyclomatic complexity of request mapping and rate-limit parsing

Pin the current behavior with characterization tests (claude-request-mapper,
model-mapping, rate-limit-reason; 91 cases, all green before the refactor),
then split buildContents, buildGenerationConfig, transformClaudeRequestIn,
resolveModelRoute, detectRateLimitReason and computeRetryAfterSeconds into
small helpers. Also declare the emitted toolConfig on GeminiRequest.
This commit is contained in:
evandro committed 2026-10-10 04:20:22 -03:00
1 parent cf5b669133
commit 880b799535
7 files changed
+1630 -449

No files matched your search

+378 -253
View File
@@ -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<string, string>;
}
type ContentBlock = Exclude<Message['content'], string | undefined>[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;
}
+93 -72
View File
@@ -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, string>,
): 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, string>,
): 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);
}
+2
View File
@@ -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 {
+165 -124
View File
@@ -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<string, RateLimitInfo>();
private readonly failureCounts = new Map<string, FailureCountEntry>();
@@ -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 {
@@ -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<string, unknown> = {}): 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<string, unknown> = {}) {
return transformClaudeRequestIn(
makeRequest({ messages: asMessages(messages), thinking: { type: 'disabled' }, ...overrides }),
'project',
).request.contents;
}
function generationConfigOf(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}, 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');
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest';
import {
getDynamicForwardingTarget,
resolveModelRoute,
updateDynamicForwardingRules,
} from '../../lib/geminiNexus/ModelMapping';
describe('resolveModelRoute', () => {
const NO_MAPPING: Record<string, string> = {};
function route(
model: string,
options: {
custom?: Record<string, string>;
openai?: Record<string, string>;
anthropic?: Record<string, string>;
} = {},
) {
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');
});
});
});
+291
View File
@@ -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, unknown>): 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);
});
});