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:
1 parent
cf5b669133
commit
880b799535
7 files changed
+1630
-449
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user