feat: complete rebranding to Gemini Nexus with improved UI, proxies and documentation

This commit is contained in:
Gemini Nexus Bot committed 2026-04-22 00:00:56 -03:00
1 parent e524a340cb
commit 433dccbaa9
108 files changed
+1004 -1139

No files matched your search

+723
View File
@@ -0,0 +1,723 @@
import { v4 as uuidv4 } from 'uuid';
import { isEmpty, isPlainObject, isString } from 'lodash-es';
import { mapClaudeModelToGemini, normalizeGeminiModelAlias } from './ModelMapping';
import { getMaxOutputTokens, getThinkingBudget } from './ModelSpecs';
import { cleanJsonSchema, normalizeObjectJsonSchema } from './JsonSchemaUtils';
import { SignatureStore } from './SignatureStore';
import { logger } from '../../utils/logger';
import {
ClaudeRequest,
Message,
Tool,
GeminiInternalRequest,
GeminiContent,
GeminiToolDeclaration,
GenerationConfig,
ImageConfig,
FunctionDeclaration,
SafetySetting,
} from './types';
import {
buildUserAgent,
FALLBACK_VERSION,
resolveLocalInstalledVersion,
} from '@/server/modules/proxy/request-user-agent';
/**
* Request Configuration
* Contains request type, model, and image generation configuration
*/
interface RequestConfig {
/** Request type: 'agent', 'web_search', 'image_gen' */
requestType: string;
/** Whether to inject Google Search tool */
injectGoogleSearch: boolean;
/** Final model name to use */
finalModel: string;
/** Image generation config (only for image generation requests) */
imageConfig: ImageConfig | null;
}
// --- Main Logic ---
// --- Main Logic ---
/**
* Transforms Claude request into Gemini internal request format
* @param claudeReq Claude API request
* @param projectId Gemini Project ID
* @returns Gemini internal request format
*/
export function transformClaudeRequestIn(
claudeReq: ClaudeRequest,
projectId?: string,
userAgent?: string,
): GeminiInternalRequest {
// Check for networking tools (server tool or built-in tool)
const hasWebSearchTool = detectsNetworkingTool(claudeReq.tools);
// Map to store tool_use id -> name mapping
const toolIdToName = new Map<string, string>();
// 1. System Instruction
const systemInstruction = buildSystemInstruction(claudeReq.system);
// Map model name
const mappedModel = hasWebSearchTool ? 'gemini-3-flash' : mapClaudeModelToGemini(claudeReq.model);
// Convert Claude tools to Tool array for networking detection
const toolsVal: Tool[] | undefined = claudeReq.tools
? (JSON.parse(JSON.stringify(claudeReq.tools)) as Tool[])
: undefined;
// Resolve grounding config
const config = resolveRequestConfig(claudeReq.model, mappedModel, toolsVal);
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;
if (isThinkingEnabled) {
const globalSig = SignatureStore.get();
const hasFunctionCalls = claudeReq.messages.some((m) => {
if (Array.isArray(m.content)) {
return m.content.some((b) => b.type === 'tool_use');
}
return false;
});
if (hasFunctionCalls && !hasValidSignatureForFunctionCalls(claudeReq.messages, globalSig)) {
if (!isGeminiFlashModel(config.finalModel)) {
isThinkingEnabled = false;
}
}
}
const generationConfig = buildGenerationConfig(
claudeReq,
hasWebSearchTool,
config.finalModel,
isThinkingEnabled,
);
// Update thinking config based on the final decision
if (!isThinkingEnabled && generationConfig.thinkingConfig) {
delete generationConfig.thinkingConfig;
}
// 2. Contents (Messages)
const contents = buildContents(
claudeReq.messages,
toolIdToName,
isThinkingEnabled,
allowDummyThought,
config.finalModel,
);
// 3. Tools
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 } };
} = {
contents,
safetySettings,
};
deepCleanUndefined(innerRequest);
if (systemInstruction) {
innerRequest.systemInstruction = systemInstruction;
}
if (generationConfig && Object.keys(generationConfig).length > 0) {
innerRequest.generationConfig = generationConfig;
}
if (tools) {
innerRequest.tools = tools;
innerRequest.toolConfig = { functionCallingConfig: { mode: 'VALIDATED' } };
}
// Inject googleSearch tool if needed (and not already done by buildTools)
if (config.injectGoogleSearch && !hasWebSearchTool) {
injectGoogleSearchTool(innerRequest, config.finalModel);
}
// 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;
}
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;
}
/**
* Resolves request configuration
* Determines request type and whether to inject search tools based on model name and tools
*/
function resolveRequestConfig(
originalModel: string,
mappedModel: string,
tools?: Tool[],
): RequestConfig {
// 1. Image Generation Check
if (isGeminiImageModel(mappedModel)) {
const { imageConfig, parsedBaseModel } = parseImageConfig(originalModel);
return {
requestType: 'image_gen',
injectGoogleSearch: false,
finalModel: parsedBaseModel,
imageConfig,
};
}
const hasNetworkingTool = detectsNetworkingTool(tools);
// Strip -online suffix
const isOnlineSuffix = originalModel.endsWith('-online');
const enableNetworking = isOnlineSuffix || hasNetworkingTool;
let finalModel = mappedModel.replace(/-online$/, '');
finalModel = normalizeGeminiModelAlias(finalModel);
if (enableNetworking) {
if (finalModel !== 'gemini-3-flash') {
finalModel = 'gemini-3-flash';
}
}
return {
requestType: enableNetworking ? 'web_search' : 'agent',
injectGoogleSearch: enableNetworking,
finalModel,
imageConfig: null,
};
}
/**
* Parses image generation configuration
* Extracts aspect ratio and resolution settings from model name
*/
function parseImageConfig(modelName: string): {
imageConfig: ImageConfig;
parsedBaseModel: string;
} {
let aspectRatio = '1:1';
if (modelName.includes('-16x9')) aspectRatio = '16:9';
else if (modelName.includes('-9x16')) aspectRatio = '9:16';
else if (modelName.includes('-4x3')) aspectRatio = '4:3';
else if (modelName.includes('-3x4')) aspectRatio = '3:4';
else if (modelName.includes('-1x1')) aspectRatio = '1:1';
const isHd = modelName.includes('-4k') || modelName.includes('-hd');
const config: ImageConfig = { aspectRatio };
if (isHd) {
config.imageSize = '4K';
}
return { imageConfig: config, parsedBaseModel: 'gemini-3-pro-image' };
}
function isGeminiImageModel(modelName: string): boolean {
const normalized = modelName.toLowerCase();
return (
normalized.startsWith('gemini-3-pro-image') || normalized.startsWith('gemini-3.1-pro-image')
);
}
function isGeminiFlashModel(modelName: string): boolean {
const normalized = modelName.toLowerCase();
return normalized.includes('gemini-3-flash') || normalized.includes('gemini-3.1-flash');
}
function shouldEnableThinkingByDefault(mappedModel: string, originalModel: string): boolean {
const mappedLower = mappedModel.toLowerCase();
const originalLower = originalModel.toLowerCase();
return (
originalLower.includes('claude-opus-4-5') ||
originalLower.includes('claude-opus-4-6') ||
mappedLower.includes('-thinking') ||
mappedLower.includes('gemini-3.1-pro') ||
mappedLower.includes('gemini-3-flash') ||
mappedLower.includes('gemini-3.1-flash')
);
}
function isClaudeModel(modelName: string): boolean {
return modelName.toLowerCase().includes('claude');
}
function resolveAdaptiveThinkingLevel(claudeReq: ClaudeRequest): 'low' | 'medium' | 'high' {
const effort = String(claudeReq.thinking?.effort ?? '').toLowerCase();
if (effort === 'low') {
return 'low';
}
if (effort === 'medium') {
return 'medium';
}
return 'high';
}
function toToolSchema(schema: unknown): Record<string, unknown> {
return normalizeObjectJsonSchema(schema);
}
/**
* Detects if networking tools are present
* Checks tool list for web search related tools
* Supports Claude Tool and Gemini GeminiToolDeclaration formats
*/
function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolean {
if (!tools) {
return false;
}
const keywords = [
'web_search',
'google_search',
'web_search_20250305',
'google_search_retrieval',
'builtin_web_search',
];
for (const tool of tools) {
// Claude Tool format
const toolName = (tool as { name?: unknown }).name;
if (isString(toolName) && keywords.includes(toolName)) {
return true;
}
const toolType = (tool as { type?: unknown }).type;
if (isString(toolType) && keywords.includes(toolType)) {
return true;
}
// OpenAI nested format (runtime check)
const openaiTool = tool as { function?: { name?: string } };
if (isString(openaiTool.function?.name) && keywords.includes(openaiTool.function.name)) {
return true;
}
// Gemini GeminiToolDeclaration format
if ('functionDeclarations' in tool && tool.functionDeclarations) {
for (const decl of tool.functionDeclarations) {
if (decl.name && keywords.includes(decl.name)) {
return true;
}
}
}
// Gemini search tools
if ('googleSearch' in tool && tool.googleSearch) {
return true;
}
if ('googleSearchRetrieval' in tool && tool.googleSearchRetrieval) {
return true;
}
}
return false;
}
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }, mappedModel?: string) {
if (!body.tools) {
body.tools = [];
}
const toolsArr = body.tools;
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
if (hasFunctions) {
logger.info(
`[Claude-Request] Skipping googleSearch injection for ${mappedModel ?? 'unknown-model'} because functionDeclarations are present (v1internal incompatible)`,
);
return;
}
// Remove existing to avoid duplicates
body.tools = toolsArr.filter((t) => !t.googleSearch && !t.googleSearchRetrieval);
body.tools.push({ googleSearch: {} });
}
/**
* Builds system instruction
* Converts Claude system prompts to Gemini format with a default assistant identity directive.
*/
function buildSystemInstruction(
system: ClaudeRequest['system'],
): { parts: { text: string }[] } | null {
const assistantIdentityDirective =
'You are Gemini Nexus, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\n' +
'You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\n' +
'**Absolute paths only**\n' +
'**Proactiveness**';
const identityMarker = 'You are Gemini Nexus';
const parts: { text: string }[] = [];
let hasIdentityDirective = false;
if (system) {
if (isString(system)) {
if (system.includes(identityMarker)) {
hasIdentityDirective = true;
}
} else if (Array.isArray(system)) {
for (const block of system) {
if (block.type === 'text' && block.text.includes(identityMarker)) {
hasIdentityDirective = true;
break;
}
}
}
}
if (!hasIdentityDirective) {
parts.push({ text: assistantIdentityDirective });
}
if (system) {
if (isString(system)) {
parts.push({ text: system });
} else if (Array.isArray(system)) {
for (const block of system) {
if (block.type === 'text') parts.push({ text: block.text });
}
}
}
// If we pushed at least something
if (parts.length > 0) {
return { parts };
}
return null;
}
/**
* Minimum length for a valid thought_signature
*/
const MIN_SIGNATURE_LENGTH = 10;
/**
* Check if we have any valid signature available for function calls
* @param messages Messages from ClaudeRequest
* @param globalSig Global signature from SignatureStore
* @returns True if any valid signature is available for function calls
*/
function hasValidSignatureForFunctionCalls(
messages: Message[],
globalSig: string | null | undefined,
): boolean {
// 1. Check global store
if (globalSig && globalSig.length >= MIN_SIGNATURE_LENGTH) {
return true;
}
// 2. Check if any message has a thinking block with valid signature
// Traverse in reverse to find recent signatures
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === 'assistant') {
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (
block.type === 'thinking' &&
block.signature &&
block.signature.length >= MIN_SIGNATURE_LENGTH
) {
return true;
}
}
}
}
}
return false;
}
/**
* Builds message contents
* Converts Claude message list to Gemini content format
*/
function buildContents(
messages: Message[],
toolIdToName: Map<string, string>,
isThinkingEnabled: boolean,
allowDummyThought: boolean,
mappedModel: string,
): GeminiContent[] {
const contents: GeminiContent[] = [];
let lastThoughtSignature: string | null = null;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const role = msg.role === 'assistant' ? 'model' : msg.role;
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()))
parts.push({ text: block.text.trim() });
} 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;
}
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);
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
if (finalSig) {
part.thoughtSignature = finalSig;
} else if (isThinkingEnabled && isGeminiFlashModel(mappedModel)) {
part.thoughtSignature = '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: { result: mergedContent },
id: block.tool_use_id,
},
};
if (lastThoughtSignature) part.thoughtSignature = lastThoughtSignature;
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 });
}
if (parts.length > 0) contents.push({ role, parts });
}
return contents;
}
/**
* build tools
* convert claude tools to gemini function declarations
*/
function buildTools(
tools: Tool[] | undefined,
hasWebSearch: boolean,
mappedModel: string,
): GeminiToolDeclaration[] | null {
if (!tools) {
return null;
}
const functionDeclarations: FunctionDeclaration[] = [];
let hasGoogleSearch = hasWebSearch;
for (const tool of tools) {
if (
tool.name === 'web_search' ||
tool.name === 'google_search' ||
tool.name === 'builtin_web_search' ||
tool.type === 'web_search_20250305' ||
tool.type === 'builtin_web_search'
) {
hasGoogleSearch = true;
continue;
}
if (tool.name) {
const inputSchema = toToolSchema(tool.input_schema);
functionDeclarations.push({
name: tool.name,
description: tool.description,
parameters: inputSchema,
});
}
}
const toolList: GeminiToolDeclaration[] = [];
if (functionDeclarations.length > 0) {
toolList.push({ functionDeclarations });
if (hasGoogleSearch) {
logger.info(
`[Claude-Request] Skipping googleSearch injection for ${mappedModel} because functionDeclarations are present (v1internal incompatible)`,
);
}
} else if (hasGoogleSearch) {
toolList.push({ googleSearch: {} });
}
if (toolList.length > 0) {
return toolList;
}
return null;
}
/**
* build generation config
* convert claude request parameters to gemini generation config
*/
function buildGenerationConfig(
claudeReq: ClaudeRequest,
hasWebSearch: boolean,
mappedModel: string,
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;
};
if (isOpenAIPath) {
config.temperature = claudeReq.temperature ?? 1.0;
config.topP = claudeReq.top_p ?? 0.95;
if (claudeReq.max_tokens !== undefined) {
config.maxOutputTokens = claudeReq.max_tokens;
} else {
config.maxOutputTokens = getMaxOutputTokens(mappedModel);
}
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
config.stopSequences = claudeReq.stop_sequences;
}
if (isThinkingEnabled) {
config.thinkingConfig = buildThinkingConfig();
}
return config;
}
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 (claudeReq.max_tokens !== undefined) {
config.maxOutputTokens = claudeReq.max_tokens;
}
config.stopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
return config;
}
/**
* deep clean undefined values
* recursively delete all properties with undefined values
* @param obj
*/
function deepCleanUndefined(obj: unknown): void {
if (Array.isArray(obj)) {
obj.forEach(deepCleanUndefined);
} else if (isPlainObject(obj)) {
const record = obj as Record<string, unknown>;
Object.keys(record).forEach((key) => {
if (record[key] === undefined) delete record[key];
else deepCleanUndefined(record[key]);
});
}
}
+251
View File
@@ -0,0 +1,251 @@
import { v4 as uuidv4 } from 'uuid';
import {
ClaudeResponse,
GeminiResponse,
GeminiPart,
ContentBlock,
Usage,
GroundingMetadata,
} from './types';
import { decodeSignature } from './signature-utils';
/**
* Non-streaming response processor (Gemini -> Claude)
*
*/
class NonStreamingProcessor {
private contentBlocks: ContentBlock[] = [];
private textBuilder: string = '';
private thinkingBuilder: string = '';
private thinkingSignature: string | null = null;
private trailingSignature: string | null = null;
private hasToolCall: boolean = false;
constructor() {}
public process(geminiResponse: GeminiResponse): ClaudeResponse {
const candidate = geminiResponse.candidates?.[0];
const parts = candidate?.content?.parts || [];
// 1. Process all parts
for (const part of parts) {
this.processPart(part);
}
// 2. Process grounding (web search)
if (candidate?.groundingMetadata) {
this.processGrounding(candidate.groundingMetadata);
}
// 3. Flush remaining content
this.flushThinking();
this.flushText();
// 4. Handle trailingSignature
if (this.trailingSignature) {
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: this.trailingSignature,
});
this.trailingSignature = null; // Consumed
}
// 5. Build response
return this.buildResponse(geminiResponse);
}
private processPart(part: GeminiPart) {
const signature = decodeSignature(part.thoughtSignature) || null;
// 1. Handle FunctionCall
if (part.functionCall) {
this.flushThinking();
this.flushText();
// Handle trailing signature logic
if (this.trailingSignature) {
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: this.trailingSignature,
});
this.trailingSignature = null;
}
this.hasToolCall = true;
const fc = part.functionCall;
const toolId = fc.id || `${fc.name}-${uuidv4()}`;
const toolUse: ContentBlock = {
type: 'tool_use',
id: toolId,
name: fc.name,
input: fc.args || {},
signature: signature || undefined,
};
this.contentBlocks.push(toolUse);
return;
}
// 2. Handle Text / Thinking
if (part.text !== undefined) {
const text = part.text;
if (part.thought) {
// Thinking Part
this.flushText();
// Handle trailing signature before thinking
if (this.trailingSignature) {
this.flushThinking(); // Ensure previous thinking is flushed
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: this.trailingSignature,
});
this.trailingSignature = null;
}
this.thinkingBuilder += text;
if (signature) {
this.thinkingSignature = signature;
}
} else {
// Normal Text
if (text === '') {
// Empty text with signature -> store as trailing
if (signature) {
this.trailingSignature = signature;
}
return;
}
this.flushThinking();
// Handle trailing signature
if (this.trailingSignature) {
this.flushText();
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: this.trailingSignature,
});
this.trailingSignature = null;
}
this.textBuilder += text;
// Non-empty text with signature -> flush immediately empty thinking block with sig
if (signature) {
this.flushText();
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: signature,
});
}
}
}
// 3. Handle InlineData (Image)
if (part.inlineData) {
this.flushThinking();
const { mimeType, data } = part.inlineData;
if (data) {
const markdownImg = `![image](data:${mimeType};base64,${data})`;
this.textBuilder += markdownImg;
this.flushText();
}
}
}
private processGrounding(grounding: GroundingMetadata) {
let groundingText = '';
if (grounding.webSearchQueries && grounding.webSearchQueries.length > 0) {
groundingText += `\n\n---\n**🔍 Searched for you:** ${grounding.webSearchQueries.join(', ')}`;
}
if (grounding.groundingChunks) {
const links: string[] = [];
grounding.groundingChunks.forEach((chunk, index) => {
if (chunk.web) {
const title = chunk.web.title || 'Web source';
const uri = chunk.web.uri || '#';
links.push(`[${index + 1}] [${title}](${uri})`);
}
});
if (links.length > 0) {
groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n');
}
}
if (groundingText) {
this.flushThinking();
this.flushText();
this.textBuilder += groundingText;
this.flushText();
}
}
private flushText() {
if (!this.textBuilder) return;
this.contentBlocks.push({
type: 'text',
text: this.textBuilder,
});
this.textBuilder = '';
}
private flushThinking() {
if (!this.thinkingBuilder && !this.thinkingSignature) return;
this.contentBlocks.push({
type: 'thinking',
thinking: this.thinkingBuilder,
signature: this.thinkingSignature || undefined,
});
this.thinkingBuilder = '';
this.thinkingSignature = null;
}
private buildResponse(geminiResponse: GeminiResponse): ClaudeResponse {
const finishReason = geminiResponse.candidates?.[0]?.finishReason;
let stopReason = 'end_turn';
if (this.hasToolCall) {
stopReason = 'tool_use';
} else if (finishReason === 'MAX_TOKENS') {
stopReason = 'max_tokens';
}
const usage: Usage = {
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
};
return {
id: geminiResponse.responseId || `msg_${uuidv4()}`,
type: 'message',
role: 'assistant',
model: geminiResponse.modelVersion || '',
content: this.contentBlocks,
stop_reason: stopReason,
usage: usage,
};
}
}
/**
* Public API: Transform Gemini Response to Claude Response
*/
export function transformResponse(geminiResponse: GeminiResponse): ClaudeResponse {
const processor = new NonStreamingProcessor();
return processor.process(geminiResponse);
}
@@ -0,0 +1,485 @@
import { GeminiPart, Usage, UsageMetadata } from './types';
import { SignatureStore } from './SignatureStore';
import { decodeSignature } from './signature-utils';
import { logger } from '../../utils/logger';
type BlockType = 'None' | 'Text' | 'Thinking' | 'Function';
interface SignatureManager {
pending: string | null;
}
class SignatureManagerImpl implements SignatureManager {
pending: string | null = null;
store(signature?: string) {
if (signature) {
this.pending = signature;
}
}
consume(): string | null {
const s = this.pending;
this.pending = null;
return s;
}
hasPending(): boolean {
return this.pending !== null;
}
}
/**
* Streaming State Machine
*/
export class StreamingState {
private blockType: BlockType = 'None';
public blockIndex: number = 0;
public messageStartSent: boolean = false;
public messageStopSent: boolean = false;
private usedTool: boolean = false;
private signatures: SignatureManagerImpl = new SignatureManagerImpl();
public trailingSignature: string | null = null;
// Web Search / Grounding buffers
public webSearchQuery: string | null = null;
public groundingChunks: any[] | null = null;
private parseErrorCount: number = 0;
constructor() {}
public emit(eventType: string, data: any): string {
return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
}
public emitMessageStart(rawJson: any): string {
if (this.messageStartSent) return '';
const usageMeta = rawJson.usageMetadata;
const usage: Usage | undefined = usageMeta
? {
input_tokens: usageMeta.promptTokenCount || 0,
output_tokens: usageMeta.candidatesTokenCount || 0,
}
: undefined;
const message = {
id: rawJson.responseId || 'msg_unknown',
type: 'message',
role: 'assistant',
content: [],
model: rawJson.modelVersion || '',
stop_reason: null,
stop_sequence: null,
usage: usage,
};
this.messageStartSent = true;
return this.emit('message_start', {
type: 'message_start',
message: message,
});
}
public startBlock(blockType: BlockType, contentBlock: any): string[] {
const chunks: string[] = [];
if (this.blockType !== 'None') {
chunks.push(...this.endBlock());
}
chunks.push(
this.emit('content_block_start', {
type: 'content_block_start',
index: this.blockIndex,
content_block: contentBlock,
}),
);
this.blockType = blockType;
return chunks;
}
public endBlock(): string[] {
if (this.blockType === 'None') {
return [];
}
const chunks: string[] = [];
// Send stored signature when Thinking block ends
if (this.blockType === 'Thinking' && this.signatures.hasPending()) {
const sig = this.signatures.consume();
if (sig) {
// emit_delta "signature_delta"
chunks.push(this.emitDelta('signature_delta', { signature: sig }));
}
}
chunks.push(
this.emit('content_block_stop', {
type: 'content_block_stop',
index: this.blockIndex,
}),
);
this.blockIndex++;
this.blockType = 'None';
return chunks;
}
public emitDelta(deltaType: string, deltaContent: any): string {
const delta = { type: deltaType, ...deltaContent };
return this.emit('content_block_delta', {
type: 'content_block_delta',
index: this.blockIndex,
delta: delta,
});
}
public emitFinish(finishReason?: string, usageMetadata?: UsageMetadata): string[] {
const chunks: string[] = [];
// Close last block
chunks.push(...this.endBlock());
// Process trailing signature (PDF 776-778 logic)
if (this.trailingSignature) {
const sig = this.trailingSignature;
this.trailingSignature = null;
chunks.push(
this.emit('content_block_start', {
type: 'content_block_start',
index: this.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.emitDelta('signature_delta', { signature: sig }));
chunks.push(
this.emit('content_block_stop', {
type: 'content_block_stop',
index: this.blockIndex,
}),
);
this.blockIndex++;
}
// Process grounding (web search) -> convert to Markdown text block
let groundingText = '';
if (this.webSearchQuery) {
groundingText += `\n\n---\n**🔍 Searched for you:** ${this.webSearchQuery}`;
}
if (this.groundingChunks && this.groundingChunks.length > 0) {
const links: string[] = [];
this.groundingChunks.forEach((chunk, i) => {
if (chunk.web) {
const title = chunk.web.title || 'Web source';
const uri = chunk.web.uri || '#';
links.push(`[${i + 1}] [${title}](${uri})`);
}
});
if (links.length > 0) {
groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n');
}
}
if (groundingText) {
chunks.push(
this.emit('content_block_start', {
type: 'content_block_start',
index: this.blockIndex,
content_block: { type: 'text', text: '' },
}),
);
chunks.push(this.emitDelta('text_delta', { text: groundingText }));
chunks.push(
this.emit('content_block_stop', { type: 'content_block_stop', index: this.blockIndex }),
);
this.blockIndex++;
}
// Determine stop reason
let stopReason = 'end_turn';
if (this.usedTool) {
stopReason = 'tool_use';
} else if (finishReason === 'MAX_TOKENS') {
stopReason = 'max_tokens';
}
const usage: Usage = usageMetadata
? {
input_tokens: usageMetadata.promptTokenCount || 0,
output_tokens: usageMetadata.candidatesTokenCount || 0,
}
: { input_tokens: 0, output_tokens: 0 };
chunks.push(
this.emit('message_delta', {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: usage,
}),
);
if (!this.messageStopSent) {
chunks.push(`event: message_stop\ndata: {"type":"message_stop"}\n\n`);
this.messageStopSent = true;
}
return chunks;
}
public markToolUsed() {
this.usedTool = true;
}
public currentBlockType(): BlockType {
return this.blockType;
}
public storeSignature(signature?: string) {
this.signatures.store(signature);
}
public handleParseError(rawData: string): string[] {
const chunks: string[] = [];
this.parseErrorCount++;
logger.warn(
`[SSE-Parser] Parse error #${this.parseErrorCount}. Raw data length: ${rawData.length}`,
);
// Safely close current block
if (this.blockType !== 'None') {
chunks.push(...this.endBlock());
}
// Emit error event if too many errors
if (this.parseErrorCount > 3) {
logger.error(
`[SSE-Parser] High error rate (${this.parseErrorCount} errors). Stream may be corrupted.`,
);
chunks.push(
this.emit('error', {
type: 'error',
error: {
type: 'network_error',
message: 'Unstable network connection. Please check your network or proxy settings.',
code: 'stream_decode_error',
details: {
error_count: this.parseErrorCount,
suggestion: 'Check network connection',
},
},
}),
);
}
return chunks;
}
/**
* Reset error state (call after recovery)
*/
public resetErrorState(): void {
this.parseErrorCount = 0;
}
/**
* Get current error count (for monitoring)
*/
public getErrorCount(): number {
return this.parseErrorCount;
}
}
/**
* Part Processor
*/
export class PartProcessor {
constructor(private state: StreamingState) {}
public process(part: GeminiPart): string[] {
const chunks: string[] = [];
const signature = decodeSignature(part.thoughtSignature);
// 1. Handle FunctionCall
if (part.functionCall) {
// Handle trailing signature logic
if (this.state.trailingSignature) {
chunks.push(...this.state.endBlock());
const trailingSig = this.state.trailingSignature;
this.state.trailingSignature = null;
chunks.push(
this.state.emit('content_block_start', {
type: 'content_block_start',
index: this.state.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.state.emitDelta('signature_delta', { signature: trailingSig }));
chunks.push(...this.state.endBlock());
}
chunks.push(...this.processFunctionCall(part.functionCall, signature));
return chunks;
}
// 2. Handle Text
if (part.text !== undefined) {
if (part.thought) {
chunks.push(...this.processThinking(part.text, signature));
} else {
chunks.push(...this.processText(part.text, signature));
}
}
// 3. InlineData (Image)
if (part.inlineData) {
const { mimeType, data } = part.inlineData;
if (data) {
const markdownImg = `![image](data:${mimeType};base64,${data})`;
chunks.push(...this.processText(markdownImg, undefined));
}
}
return chunks;
}
private processThinking(text: string, signature?: string): string[] {
const chunks: string[] = [];
// Handle trailing signature
if (this.state.trailingSignature) {
chunks.push(...this.state.endBlock());
const trailingSig = this.state.trailingSignature;
this.state.trailingSignature = null;
chunks.push(
this.state.emit('content_block_start', {
type: 'content_block_start',
index: this.state.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.state.emitDelta('signature_delta', { signature: trailingSig }));
chunks.push(...this.state.endBlock());
}
if (this.state.currentBlockType() !== 'Thinking') {
chunks.push(...this.state.startBlock('Thinking', { type: 'thinking', thinking: '' }));
}
if (text) {
chunks.push(this.state.emitDelta('thinking_delta', { thinking: text }));
}
this.state.storeSignature(signature);
return chunks;
}
private processText(text: string, signature?: string): string[] {
const chunks: string[] = [];
// Empty text with signature -> store trailing
if (!text) {
if (signature) {
this.state.trailingSignature = signature;
}
return chunks;
}
// Handle trailing signature
if (this.state.trailingSignature) {
chunks.push(...this.state.endBlock());
const trailingSig = this.state.trailingSignature;
this.state.trailingSignature = null;
chunks.push(
this.state.emit('content_block_start', {
type: 'content_block_start',
index: this.state.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.state.emitDelta('signature_delta', { signature: trailingSig }));
chunks.push(...this.state.endBlock());
}
// Non-empty text with signature -> flush immediately
if (signature) {
// Start text block
chunks.push(...this.state.startBlock('Text', { type: 'text', text: '' }));
chunks.push(this.state.emitDelta('text_delta', { text: text }));
chunks.push(...this.state.endBlock());
// Empty thinking block for signature
chunks.push(
this.state.emit('content_block_start', {
type: 'content_block_start',
index: this.state.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.state.emitDelta('signature_delta', { signature: signature }));
chunks.push(...this.state.endBlock());
return chunks;
}
// Normal text
if (this.state.currentBlockType() !== 'Text') {
chunks.push(...this.state.startBlock('Text', { type: 'text', text: '' }));
}
chunks.push(this.state.emitDelta('text_delta', { text: text }));
return chunks;
}
private processFunctionCall(
fc: { name: string; args: any; id?: string },
signature?: string,
): string[] {
const chunks: string[] = [];
this.state.markToolUsed();
const toolId = fc.id || `${fc.name}-${Math.random().toString(36).substr(2, 9)}`;
const toolUse: any = {
type: 'tool_use',
id: toolId,
name: fc.name,
input: {}, // Empty, args sent via delta
};
if (signature) {
toolUse.signature = signature;
// Store signature to global storage for replay in subsequent requests
SignatureStore.store(signature);
}
chunks.push(...this.state.startBlock('Function', toolUse));
// input_json_delta
if (fc.args) {
const jsonStr = JSON.stringify(fc.args);
chunks.push(this.state.emitDelta('input_json_delta', { partial_json: jsonStr }));
}
chunks.push(...this.state.endBlock());
return chunks;
}
}
+208
View File
@@ -0,0 +1,208 @@
import { isArray, isBoolean, isNumber, isObjectLike, isString } from 'lodash-es';
/**
* Recursively cleans JSON Schema to meet Gemini interface requirements
*
* 1. [New] Flatten $ref and $defs: Replace references with actual definitions to solve Gemini's lack of $ref support
* 2. Remove unsupported fields: $schema, additionalProperties, format, default, uniqueItems, validation fields
* 3. Handle Union types: ["string", "null"] -> "string"
* 4. Convert type field values to lowercase (Gemini v1internal requirement)
* 5. Remove numeric validation fields: multipleOf, exclusiveMinimum, exclusiveMaximum, etc.
*/
export function cleanJsonSchema(value: any) {
// 0. Preprocessing: Expand $ref (Schema Flattening)
if (isObjectLike(value) && !isArray(value)) {
const defs: Record<string, any> = {};
// Extract $defs or definitions
if (value['$defs']) {
Object.assign(defs, value['$defs']);
delete value['$defs'];
}
if (value['definitions']) {
Object.assign(defs, value['definitions']);
delete value['definitions'];
}
if (Object.keys(defs).length > 0) {
// Recursively replace references
flattenRefs(value, defs);
}
}
// Recursive cleaning
cleanJsonSchemaRecursive(value);
}
export function normalizeObjectJsonSchema(schema: unknown): Record<string, unknown> {
const fallbackSchema: Record<string, unknown> = { type: 'object', properties: {} };
if (!isObjectLike(schema) || isArray(schema)) {
return fallbackSchema;
}
const normalizedSchema = JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
cleanJsonSchema(normalizedSchema);
if (!isString(normalizedSchema.type)) {
normalizedSchema.type = 'object';
}
if (
normalizedSchema.type === 'object' &&
(!normalizedSchema.properties ||
!isObjectLike(normalizedSchema.properties) ||
isArray(normalizedSchema.properties))
) {
normalizedSchema.properties = {};
}
return normalizedSchema;
}
/**
* Recursively expand $ref
*/
function flattenRefs(map: any, defs: Record<string, any>) {
if (!isObjectLike(map)) return;
// Check and replace $ref
if (isString(map['$ref'])) {
const refPath = map['$ref'];
// Parse reference name (e.g. #/$defs/MyType -> MyType)
const parts = refPath.split('/');
const refName = parts[parts.length - 1] || refPath;
if (defs[refName]) {
const defSchema = defs[refName];
// $ref nodes should not have other properties, remove $ref directly
delete map['$ref'];
if (isObjectLike(defSchema)) {
for (const [k, v] of Object.entries(defSchema)) {
// Only insert if the key does not exist in current map (avoid overwrite)
if (map[k] === undefined) {
// Clone deep to avoid reference issues
map[k] = JSON.parse(JSON.stringify(v));
}
}
// Recursively process $refs in the newly merged content
flattenRefs(map, defs);
}
}
}
// Recursively process all children
for (const k in map) {
if (Object.prototype.hasOwnProperty.call(map, k)) {
const v = map[k];
if (isObjectLike(v)) {
flattenRefs(v, defs);
}
}
}
}
function cleanJsonSchemaRecursive(value: any) {
if (!isObjectLike(value)) {
return;
}
if (isArray(value)) {
// Array: Recursively process each element
for (const v of value) {
cleanJsonSchemaRecursive(v);
}
} else {
const map = value;
// 1. Recursively process all children first to ensure nested structures are cleaned
for (const k in map) {
if (Object.prototype.hasOwnProperty.call(map, k)) {
cleanJsonSchemaRecursive(map[k]);
}
}
// 2. Collect and process validation fields (Migration logic: Downgrade constraints to Hints in description)
const constraints: string[] = [];
// Validation fields blacklist for migration
const validationFields = [
['pattern', 'pattern'],
['minLength', 'minLen'],
['maxLength', 'maxLen'],
['minimum', 'min'],
['maximum', 'max'],
['minItems', 'minItems'],
['maxItems', 'maxItems'],
['exclusiveMinimum', 'exclMin'],
['exclusiveMaximum', 'exclMax'],
['multipleOf', 'multipleOf'],
['format', 'format'],
];
for (const [field, label] of validationFields) {
if (map[field] !== undefined) {
const val = map[field];
// Only migrate if value is primitive type
if (isString(val) || isNumber(val) || isBoolean(val)) {
constraints.push(`${label}: ${val}`);
delete map[field];
} else {
// If not expected type, leave as is (JS pass by reference, just don't delete)
}
}
}
// 3. Append constraint info to description
if (constraints.length > 0) {
const suffix = ` [Constraint: ${constraints.join(', ')}]`;
map['description'] = (map['description'] || '') + suffix;
}
// 4. Physically remove "hard" blacklist items that interfere with generation
const hardRemoveFields = [
'$schema',
'additionalProperties',
'enumCaseInsensitive',
'enumNormalizeWhitespace',
'uniqueItems',
'default',
'const',
'examples',
// Advanced logic fields common in MCP tools but unsupported by Gemini
'propertyNames',
'anyOf',
'oneOf',
'allOf',
'not',
'if',
'then',
'else',
'dependencies',
'dependentSchemas',
'dependentRequired',
'cache_control', // Fixes 400 error triggered by cache_control mentioned by user
];
for (const field of hardRemoveFields) {
delete map[field];
}
// 5. Handle type field (Gemini requires single lowercase string)
if (map['type']) {
const typeVal = map['type'];
if (isString(typeVal)) {
map['type'] = typeVal.toLowerCase();
} else if (isArray(typeVal)) {
// Union type downgrade: take the first non-null type
let selectedType = 'string';
for (const item of typeVal) {
if (isString(item) && item !== 'null') {
selectedType = item.toLowerCase();
break;
}
}
map['type'] = selectedType;
}
}
}
}
+301
View File
@@ -0,0 +1,301 @@
import { isEmpty, isString } from 'lodash-es';
import { logger } from '../../utils/logger';
const PUBLIC_SUPPORTED_MODELS = [
'gemini-3.1-pro-high',
'gemini-3.1-pro-low',
'gemini-3-flash',
'claude-sonnet-4-6-thinking',
'claude-opus-4-6-thinking',
] as const;
const CLAUDE_TO_GEMINI: Record<string, string> = {
// Directly supported models
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6-thinking',
'claude-opus-4-6-thinking': 'claude-opus-4-6-thinking',
'gemini-3.1-pro-low': 'gemini-3.1-pro-low',
'gemini-3.1-pro-high': 'gemini-3.1-pro-high',
'gemini-3-flash': 'gemini-3-flash',
// Alias mappings
'claude-sonnet-4-6': 'claude-sonnet-4-6-thinking',
'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking',
'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking',
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-6-thinking',
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-6-thinking',
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-6-thinking',
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-6-thinking',
'claude-opus-4': 'claude-opus-4-6-thinking',
'claude-opus-4-5-thinking': 'claude-opus-4-6-thinking',
'claude-opus-4-5-20251101': 'claude-opus-4-6-thinking',
'claude-opus-4-6': 'claude-opus-4-6-thinking',
'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking',
'claude-haiku-4': 'claude-sonnet-4-6-thinking',
'claude-3-haiku-20240307': 'claude-sonnet-4-6-thinking',
'claude-haiku-4-5-20251001': 'claude-sonnet-4-6-thinking',
// OpenAI Protocol Mapping
'gpt-4': 'gemini-3-flash',
'gpt-4-turbo': 'gemini-3-flash',
'gpt-4-turbo-preview': 'gemini-3-flash',
'gpt-4-0125-preview': 'gemini-3-flash',
'gpt-4-1106-preview': 'gemini-3-flash',
'gpt-4-0613': 'gemini-3-flash',
'gpt-4o': 'gemini-3-flash',
'gpt-4o-2024-05-13': 'gemini-3-flash',
'gpt-4o-2024-08-06': 'gemini-3-flash',
'gpt-4o-mini': 'gemini-3-flash',
'gpt-4o-mini-2024-07-18': 'gemini-3-flash',
'gpt-3.5-turbo': 'gemini-3-flash',
'gpt-3.5-turbo-16k': 'gemini-3-flash',
'gpt-3.5-turbo-0125': 'gemini-3-flash',
'gpt-3.5-turbo-1106': 'gemini-3-flash',
'gpt-3.5-turbo-0613': 'gemini-3-flash',
// Gemini Protocol Mapping
'gemini-2.5-flash-lite': 'gemini-3-flash',
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
'gemini-3.1-pro': 'gemini-3.1-pro-high',
'gemini-3.0-pro': 'gemini-3.1-pro-high',
'gemini-3-pro': 'gemini-3-pro-preview',
'gemini-3-pro-preview': 'gemini-3-pro-preview',
'gemini-3-pro-low': 'gemini-3-pro-low',
'gemini-3-pro-high': 'gemini-3-pro-high',
'gemini-2.5-flash': 'gemini-3-flash',
'gemini-2.5-pro': 'gemini-3.1-pro-high',
'gemini-2.0-flash': 'gemini-3-flash',
'gemini-2.0-flash-online': 'gemini-3-flash',
'gemini-3-pro-image': 'gemini-3-pro-image',
'internal-background-task': 'gemini-3-flash',
};
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3.1-pro-high', 'gemini-3.1-pro-low'];
const DYNAMIC_MODEL_FORWARDING_RULES = new Map<string, string>();
export const MODEL_LIST_CREATED_AT = 1770652800;
export const MODEL_LIST_OWNER = 'geminiNexus';
const GEMINI_MODEL_ALIASES: Record<string, string> = {
'gemini-3-pro-image-preview': 'gemini-3-pro-image',
'gemini-3-flash-preview': 'gemini-3-flash',
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
'gemini-3.1-pro': 'gemini-3.1-pro-high',
};
export function getSupportedModels(): string[] {
return [...PUBLIC_SUPPORTED_MODELS];
}
export function updateDynamicForwardingRules(oldModel: string, newModel: string): void {
if (!isString(oldModel) || !isString(newModel)) {
return;
}
const normalizedOld = oldModel.trim();
const normalizedNew = newModel.trim();
if (!normalizedOld || !normalizedNew) {
return;
}
if (!DYNAMIC_MODEL_FORWARDING_RULES.has(normalizedOld)) {
logger.info(
`[Router] Registered dynamic forwarding rule: ${normalizedOld} -> ${normalizedNew}`,
);
}
DYNAMIC_MODEL_FORWARDING_RULES.set(normalizedOld, normalizedNew);
}
export function getDynamicForwardingTarget(modelId: string): string | undefined {
return DYNAMIC_MODEL_FORWARDING_RULES.get(modelId);
}
export function getAllDynamicModels(
customMapping: Record<string, string> = {},
dynamicModelIds?: Iterable<string>,
): string[] {
const modelIds = new Set<string>();
for (const modelId of getSupportedModels()) {
modelIds.add(modelId);
}
for (const customModelId of Object.keys(customMapping)) {
modelIds.add(customModelId);
}
for (const resolution of DYNAMIC_IMAGE_RESOLUTIONS) {
for (const ratio of DYNAMIC_IMAGE_RATIOS) {
modelIds.add(`${DYNAMIC_IMAGE_BASE_MODEL}${resolution}${ratio}`);
}
}
for (const modelId of EXTRA_DYNAMIC_MODELS) {
modelIds.add(modelId);
}
if (dynamicModelIds) {
for (const dynamicModelId of dynamicModelIds) {
if (isString(dynamicModelId) && !isEmpty(dynamicModelId.trim())) {
modelIds.add(dynamicModelId.trim());
}
}
}
return [...modelIds].filter((id) => !shouldHideDeprecatedModelFromList(id)).sort();
}
export function mapClaudeModelToGemini(input: string): string {
if (!isString(input) || isEmpty(input)) {
return '';
}
const mappedModel = CLAUDE_TO_GEMINI[input];
if (mappedModel) {
return mappedModel;
}
return input;
}
export function normalizeGeminiModelAlias(modelId: string): string {
const normalizedModelId = modelId.trim().toLowerCase();
return GEMINI_MODEL_ALIASES[normalizedModelId] ?? modelId;
}
/**
* Core Model Routing Engine
* Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin)
*/
export function resolveModelRoute(
originalModel: string,
customMapping: Record<string, string>,
openaiMapping: Record<string, string>,
anthropicMapping: Record<string, string>,
): string {
const dynamicForwarded = getDynamicForwardingTarget(originalModel);
if (dynamicForwarded && getSupportedModels().includes(dynamicForwarded)) {
logger.info(
`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`,
);
return dynamicForwarded;
}
// 1. Check custom exact mapping (Highest priority)
if (customMapping[originalModel]) {
logger.info(
`[Router] Using custom exact mapping: ${originalModel} -> ${customMapping[originalModel]}`,
);
return customMapping[originalModel];
}
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'];
}
}
// 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
return mapClaudeModelToGemini(originalModel);
}
function shouldHideDeprecatedModelFromList(modelId: string): boolean {
const normalized = modelId.toLowerCase();
if (/^gemini-1(\.|$|-)/.test(normalized) || /^gemini-2(\.|$|-)/.test(normalized)) {
return true;
}
if (normalized === 'gemini-3.1-pro' || normalized === 'gemini-3.1-pro-preview') {
return true;
}
const isLegacyGeminiPro =
/^gemini-3(\.0)?-pro($|-)/.test(normalized) && !normalized.startsWith('gemini-3-pro-image');
if (isLegacyGeminiPro) {
return true;
}
if (
normalized.includes('claude-sonnet-4-5') ||
normalized.includes('claude-opus-4-5') ||
normalized.includes('claude-haiku-4-5')
) {
return true;
}
return false;
}
+41
View File
@@ -0,0 +1,41 @@
import modelSpecsJson from './model-specs';
import { isNumber } from 'lodash-es';
type ModelSpec = {
max_output_tokens?: number;
thinking_budget?: number;
is_thinking?: boolean;
};
type SpecsConfig = {
models: Record<string, ModelSpec>;
aliases: Record<string, string>;
};
const DEFAULT_MAX_OUTPUT_TOKENS = 65535;
const DEFAULT_THINKING_BUDGET = 24576;
const SPECS = modelSpecsJson as SpecsConfig;
export function resolveModelAlias(modelId: string): string {
const normalized = modelId.trim();
return SPECS.aliases[normalized] ?? normalized;
}
export function getMaxOutputTokens(modelId: string): number {
const resolved = resolveModelAlias(modelId);
const fromSpec = SPECS.models[resolved]?.max_output_tokens;
if (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec > 0) {
return Math.floor(fromSpec);
}
return DEFAULT_MAX_OUTPUT_TOKENS;
}
export function getThinkingBudget(modelId: string): number {
const resolved = resolveModelAlias(modelId);
const fromSpec = SPECS.models[resolved]?.thinking_budget;
if (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec >= 0) {
return Math.floor(fromSpec);
}
return DEFAULT_THINKING_BUDGET;
}
+194
View File
@@ -0,0 +1,194 @@
import axios, { AxiosInstance } from 'axios';
import { isNumber } from 'lodash-es';
import { QuotaData, LoadProjectResponse, QuotaApiResponse } from './types';
import { logger } from '../../utils/logger';
// Constants
const QUOTA_API_ENDPOINTS = [
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
] as const;
const CLOUD_CODE_BASE_URL = 'https://cloudcode-pa.googleapis.com';
const USER_AGENT = 'geminiNexus/1.11.3 Darwin/arm64'; // Keeping the same UA as source
// Service Class
export class QuotaService {
private static createClient(timeoutSecs: number = 15): AxiosInstance {
return axios.create({
timeout: timeoutSecs * 1000,
headers: {
'User-Agent': USER_AGENT,
'Content-Type': 'application/json',
},
});
}
/**
* Fetch Project ID and Subscription Type
*/
private static async fetchProjectId(
accessToken: string,
email: string,
): Promise<[string | undefined, string | undefined]> {
const client = this.createClient();
const meta = { metadata: { ideType: 'ANTIGRAVITY' } };
try {
const res = await client.post<LoadProjectResponse>(
`${CLOUD_CODE_BASE_URL}/v1internal:loadCodeAssist`,
meta,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'geminiNexus/windows/amd64',
},
},
);
if (res.status >= 200 && res.status < 300) {
const data = res.data;
const projectId = data.cloudaicompanionProject;
// Core logic: Preferentially get subscription ID from paid_tier
const subscriptionTier = data.paidTier?.id || data.currentTier?.id;
if (subscriptionTier) {
logger.info(`📊 [${email}] Subscription Identified: ${subscriptionTier}`);
}
return [projectId, subscriptionTier];
} else {
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
}
} catch (error: any) {
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`);
}
return [undefined, undefined];
}
/**
* Unified entry point for querying account quota
*/
public static async fetchQuota(accessToken: string, email: string) {
return this.fetchQuotaInner(accessToken, email);
}
/**
* Logic for querying account quota (Inner)
*/
private static async fetchQuotaInner(
accessToken: string,
email: string,
): Promise<{ quotaData: QuotaData; projectId?: string }> {
// 1. Get Project ID and Subscription Type
const [projectId, subscriptionTier] = await this.fetchProjectId(accessToken, email);
const finalProjectId = projectId;
const client = this.createClient();
const payload = finalProjectId ? { project: finalProjectId } : {};
let lastError: Error | null = null;
for (let endpointIndex = 0; endpointIndex < QUOTA_API_ENDPOINTS.length; endpointIndex++) {
const endpoint = QUOTA_API_ENDPOINTS[endpointIndex];
const hasNextEndpoint = endpointIndex + 1 < QUOTA_API_ENDPOINTS.length;
logger.info(`Sending quota request to ${endpoint}`);
try {
const response = await client.post<QuotaApiResponse>(endpoint, payload, {
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': USER_AGENT,
},
});
const quotaResponse = response.data;
const quotaData: QuotaData = {
models: {},
isForbidden: false,
subscriptionTier: subscriptionTier,
};
logger.info(`Quota API returned ${Object.keys(quotaResponse.models || {}).length} models:`);
if (quotaResponse.models) {
for (const [name, info] of Object.entries(quotaResponse.models)) {
logger.info(` - ${name}`);
if (info.quotaInfo) {
const fraction = info.quotaInfo.remainingFraction ?? 0;
const percentage = Math.floor(fraction * 100);
const resetTime = info.quotaInfo.resetTime || '';
// Only save models we care about, filtering out old versions (< 3.0)
const isGemini = name.includes('gemini');
const isClaude = name.includes('claude');
const isOldGemini = /gemini-[12](\.|$|-)/.test(name);
if ((isGemini || isClaude) && !isOldGemini) {
quotaData.models[name] = { percentage, resetTime };
}
}
}
}
if (endpointIndex > 0) {
logger.info(`Quota API fallback succeeded at endpoint #${endpointIndex + 1}`);
}
return { quotaData, projectId };
} catch (error: any) {
let shouldFallback = true;
if (axios.isAxiosError(error)) {
const status = error.response?.status;
let text = '';
try {
text = JSON.stringify(error.response?.data || '');
} catch {
text = '[Unable to serialize response data]';
}
// ✅ Handle 403 Forbidden specifically - return immediately, do not retry
if (status === 403) {
logger.warn(`Account no permission (403 Forbidden), marked as forbidden`);
return {
quotaData: {
models: {},
isForbidden: true,
subscriptionTier: subscriptionTier,
},
projectId,
};
}
if (hasNextEndpoint && (status === 429 || (isNumber(status) && status >= 500))) {
logger.warn(
`Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
);
lastError = new Error(`HTTP ${status} - ${text}`);
await new Promise((r) => setTimeout(r, 1000));
continue;
}
logger.warn(`API Error: ${status} - ${text}`);
lastError = new Error(`HTTP ${status} - ${text}`);
shouldFallback = !isNumber(status);
} else {
logger.warn(`Request Failed at ${endpoint}: ${error.message}`);
lastError = error instanceof Error ? error : new Error(String(error));
}
if (hasNextEndpoint && shouldFallback) {
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
await new Promise((r) => setTimeout(r, 1000));
} else {
throw lastError ?? new Error(`Quota query failed: ${error.message}`);
}
}
}
throw lastError ?? new Error('Unknown error in fetchQuota');
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Global thought_signature storage shared by all endpoints
* Used to capture and replay signatures for Gemini 3+ function calls when clients don't pass them back.
*/
import { logger } from '../../utils/logger';
class SignatureStoreImpl {
private static instance: SignatureStoreImpl;
private signature: string | null = null;
private constructor() {}
public static getInstance(): SignatureStoreImpl {
if (!SignatureStoreImpl.instance) {
SignatureStoreImpl.instance = new SignatureStoreImpl();
}
return SignatureStoreImpl.instance;
}
/**
* Store thought_signature to global storage.
* Only stores if the new signature is longer than the existing one,
* to avoid short/partial signatures overwriting valid ones.
*/
public store(sig: string) {
if (!sig) return;
const existingLen = this.signature ? this.signature.length : 0;
const newLen = sig.length;
if (newLen > existingLen) {
logger.info(
`[ThoughtSig] Storing new signature (length: ${newLen}, replacing old: ${existingLen})`,
);
this.signature = sig;
} else {
logger.debug(
`[ThoughtSig] Skipping shorter signature (new length: ${newLen}, existing: ${existingLen})`,
);
}
}
/**
* Get the stored thought_signature without clearing it.
*/
public get(): string | null {
return this.signature;
}
/**
* Get and clear the stored thought_signature.
*/
public take(): string | null {
const sig = this.signature;
this.signature = null;
return sig;
}
/**
* Clear the stored thought_signature.
*/
public clear() {
this.signature = null;
}
}
export const SignatureStore = SignatureStoreImpl.getInstance();
+52
View File
@@ -0,0 +1,52 @@
{
"models": {
"gemini-3-flash": {
"max_output_tokens": 65536,
"thinking_budget": 32768,
"is_thinking": true
},
"gemini-3.1-pro-low": {
"max_output_tokens": 65536,
"thinking_budget": 32768,
"is_thinking": true
},
"gemini-3.1-pro-high": {
"max_output_tokens": 65536,
"thinking_budget": 49152,
"is_thinking": true
},
"gemini-3-pro-image": {
"max_output_tokens": 65536,
"thinking_budget": 24576,
"is_thinking": true
},
"claude-sonnet-4-6-thinking": {
"max_output_tokens": 64000,
"thinking_budget": 32768,
"is_thinking": true
},
"claude-opus-4-6-thinking": {
"max_output_tokens": 64000,
"thinking_budget": 32768,
"is_thinking": true
},
"gpt-oss-120b-medium": {
"max_output_tokens": 32768,
"thinking_budget": 0,
"is_thinking": false
}
},
"aliases": {
"gpt-4o": "gemini-3-flash",
"gpt-3.5-turbo": "gemini-3-flash",
"gemini-2.0-flash": "gemini-3-flash",
"gemini-2.5-flash": "gemini-3-flash",
"gemini-3.1-pro": "gemini-3.1-pro-high",
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
"claude-sonnet-4-6": "claude-sonnet-4-6-thinking",
"claude-sonnet-4-5": "claude-sonnet-4-6-thinking",
"claude-3-5-sonnet": "claude-sonnet-4-6-thinking",
"claude-3-7-sonnet": "claude-sonnet-4-6-thinking",
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking"
}
}
+54
View File
@@ -0,0 +1,54 @@
const modelSpecs = {
models: {
'gemini-3-flash': {
max_output_tokens: 65536,
thinking_budget: 32768,
is_thinking: true,
},
'gemini-3.1-pro-low': {
max_output_tokens: 65536,
thinking_budget: 32768,
is_thinking: true,
},
'gemini-3.1-pro-high': {
max_output_tokens: 65536,
thinking_budget: 49152,
is_thinking: true,
},
'gemini-3-pro-image': {
max_output_tokens: 65536,
thinking_budget: 24576,
is_thinking: true,
},
'claude-sonnet-4-6-thinking': {
max_output_tokens: 64000,
thinking_budget: 32768,
is_thinking: true,
},
'claude-opus-4-6-thinking': {
max_output_tokens: 64000,
thinking_budget: 32768,
is_thinking: true,
},
'gpt-oss-120b-medium': {
max_output_tokens: 32768,
thinking_budget: 0,
is_thinking: false,
},
},
aliases: {
'gpt-4o': 'gemini-3-flash',
'gpt-3.5-turbo': 'gemini-3-flash',
'gemini-2.0-flash': 'gemini-3-flash',
'gemini-2.5-flash': 'gemini-3-flash',
'gemini-3.1-pro': 'gemini-3.1-pro-high',
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
'claude-sonnet-4-6': 'claude-sonnet-4-6-thinking',
'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking',
'claude-3-5-sonnet': 'claude-sonnet-4-6-thinking',
'claude-3-7-sonnet': 'claude-sonnet-4-6-thinking',
'claude-opus-4-5-thinking': 'claude-opus-4-6-thinking',
},
} as const;
export default modelSpecs;
+49
View File
@@ -0,0 +1,49 @@
/** Jitter factor: ±20% */
const JITTER_FACTOR = 0.2;
/** Maximum retry delay in milliseconds (30 seconds) */
const MAX_DELAY_MS = 30000;
/**
* Apply jitter to a delay value to prevent thundering herd
* Returns delay ± JITTER_FACTOR (e.g., 1000ms ± 20% = 800-1200ms)
*
* @param delayMs - Base delay in milliseconds
* @returns Jittered delay (minimum 1ms)
*/
export function applyJitter(delayMs: number): number {
if (delayMs <= 0) {
return 1;
}
const jitterRange = delayMs * JITTER_FACTOR;
const jitter = (Math.random() * 2 - 1) * jitterRange;
return Math.max(1, Math.round(delayMs + jitter));
}
/**
* Calculate retry delay with exponential backoff and jitter
*
* @param attempt - Current attempt number (0-indexed)
* @param baseMs - Base delay for first retry (default 1000ms)
* @returns Delay in milliseconds with jitter applied
*/
export function calculateRetryDelay(attempt: number, baseMs: number = 1000): number {
// Exponential backoff: 1s, 2s, 4s, 8s, ...
const exponentialDelay = baseMs * Math.pow(2, attempt);
// Cap at maximum delay
const cappedDelay = Math.min(exponentialDelay, MAX_DELAY_MS);
// Apply jitter
return applyJitter(cappedDelay);
}
/**
* Sleep for specified milliseconds
*
* @param ms - Milliseconds to sleep
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+32
View File
@@ -0,0 +1,32 @@
import { isEmpty } from 'lodash-es';
/**
* Decode Base64-encoded signature if present
* Gemini sends Base64, Claude expects raw string
*
* @param signature - The signature string (may be Base64 or raw)
* @returns Decoded signature or undefined if input is empty
*/
export function decodeSignature(signature: string | undefined | null): string | undefined {
if (isEmpty(signature)) {
return undefined;
}
try {
const decoded = Buffer.from(signature!, 'base64').toString('utf-8');
// Validate: Check if it looks like valid UTF-8 text
// If decoding produces replacement characters, it wasn't valid Base64
if (decoded && !decoded.includes('\ufffd') && decoded.length > 0) {
// Additional check: re-encode and compare to detect false positives
const reEncoded = Buffer.from(decoded).toString('base64');
if (reEncoded === signature) {
return decoded;
}
}
} catch {
// Not valid Base64, return as-is
}
// Return original if not Base64
return signature!;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Stream Error Utilities
* Classifies network errors and formats user-friendly messages
*
* Problem: Technical error messages confuse users
* Solution: Classify errors and provide localized, actionable messages
*/
export interface ClassifiedError {
type: string;
message: string;
}
/**
* Classify a stream error and return user-friendly message
*
* @param error - The error object
* @returns Classified error with type and localized message
*/
export function classifyStreamError(error: Error): ClassifiedError {
const msg = error.message.toLowerCase();
if (msg.includes('timeout') || msg.includes('timedout')) {
return {
type: 'timeout_error',
message: 'Request timed out. Please check your network connection or try again later.',
};
}
if (msg.includes('econnrefused') || msg.includes('connect')) {
return {
type: 'connection_error',
message: 'Unable to connect to server. Please check your network.',
};
}
if (msg.includes('enotfound') || msg.includes('network') || msg.includes('dns')) {
return {
type: 'network_error',
message: 'Network connection failed. Please check your network settings.',
};
}
if (msg.includes('socket') || msg.includes('aborted') || msg.includes('reset')) {
return {
type: 'stream_error',
message: 'Data transfer interrupted. Please try again later.',
};
}
return {
type: 'unknown_error',
message: `Unknown error occurred: ${error.message.substring(0, 100)}`,
};
}
/**
* Format error as SSE event for Claude protocol
*
* @param errorType - Error type string
* @param message - User-friendly message
* @returns SSE-formatted error event string
*/
export function formatErrorForSSE(errorType: string, message: string): string {
const errorEvent = {
type: 'error',
error: {
type: errorType,
message: message,
code: 'stream_error',
},
};
return `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Thinking Block Utilities
* Handles validation and graceful degradation of thinking blocks
*
* Problem: Invalid signatures cause thinking blocks to be dropped, losing content
* Solution: Validate and convert invalid blocks to text instead of dropping
*/
import { logger } from '../../utils/logger';
/** Minimum length for a valid thought signature */
const MIN_SIGNATURE_LENGTH = 10;
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
}
export interface TextBlock {
type: 'text';
text: string;
}
export type ContentBlock = ThinkingBlock | TextBlock | { type: string; [key: string]: any };
/**
* Check if a thinking block has a valid signature
*
* Rules:
* - Empty thinking + any signature = valid (trailing signature case)
* - Non-empty thinking + signature >= MIN_SIGNATURE_LENGTH = valid
* - Non-empty thinking + no/short signature = invalid
* - Non-thinking blocks = always valid
*
* @param block - Content block to validate
* @returns true if block is valid or not a thinking block
*/
export function hasValidSignature(block: ContentBlock): boolean {
if (block.type !== 'thinking') {
return true; // Non-thinking blocks are always valid
}
const thinkingBlock = block as ThinkingBlock;
// Empty thinking + any signature = valid (trailing signature)
if (!thinkingBlock.thinking || thinkingBlock.thinking.length === 0) {
return (
thinkingBlock.signature !== undefined &&
thinkingBlock.signature !== null &&
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
);
}
// Non-empty thinking needs a long enough signature
return (
thinkingBlock.signature !== undefined &&
thinkingBlock.signature !== null &&
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
);
}
/**
* Gracefully degrade an invalid thinking block
*
* - Valid blocks: return as-is
* - Invalid with content: convert to text block
* - Invalid without content: return null (should be dropped)
*
* @param block - Content block to process
* @returns Processed block or null if should be dropped
*/
export function gracefulDegradeThinking(block: ContentBlock): ContentBlock | null {
if (block.type !== 'thinking') {
return block;
}
const thinkingBlock = block as ThinkingBlock;
if (hasValidSignature(block)) {
return block;
}
// Invalid signature - check if we have content to preserve
if (thinkingBlock.thinking && thinkingBlock.thinking.length > 0) {
// Convert to text block to preserve content
logger.info(
`[Thinking-Utils] Converting invalid thinking block to text. Content length: ${thinkingBlock.thinking.length}`,
);
return {
type: 'text',
text: thinkingBlock.thinking,
};
}
// Empty content + invalid signature = drop
logger.debug('[Thinking-Utils] Dropping empty thinking block with invalid signature');
return null;
}
/**
* Process an array of content blocks, applying graceful degradation
*
* @param blocks - Array of content blocks
* @returns Processed array with invalid thinking blocks handled
*/
export function processThinkingBlocks(blocks: ContentBlock[]): ContentBlock[] {
const result: ContentBlock[] = [];
for (const block of blocks) {
const processed = gracefulDegradeThinking(block);
if (processed !== null) {
result.push(processed);
}
}
return result;
}
+442
View File
@@ -0,0 +1,442 @@
// ============================================================================
// Common Types (Shared types used across multiple interfaces)
// ============================================================================
/**
* Cache Control Configuration
* Controls caching behavior of content blocks
*/
export interface CacheControl {
/** Cache type, typically 'ephemeral' for temporary caching */
type: 'ephemeral' | string;
/** Cache Time-To-Live (seconds) */
ttl?: number;
}
/**
* JSON Schema Definition
* Type definition for tool input parameters, following JSON Schema specification
*/
export interface JsonSchema {
/** Data type: object, string, number, boolean, array, null */
type?: string;
/** Object properties definition */
properties?: Record<string, JsonSchema>;
/** List of required properties */
required?: string[];
/** Schema for array items */
items?: JsonSchema;
/** Property description */
description?: string;
/** List of enum values */
enum?: (string | number | boolean)[];
/** Default value */
default?: unknown;
/** Reference to another schema definition */
$ref?: string;
/** Collection of schema definitions */
$defs?: Record<string, JsonSchema>;
/** Whether additional properties are allowed */
additionalProperties?: boolean | JsonSchema;
/** Allow other standard JSON Schema fields */
[key: string]: unknown;
}
// ============================================================================
// Claude API Types (Claude Request/Response related types)
// ============================================================================
export interface ClaudeRequest {
model: string;
messages: Message[];
system?: SystemPrompt;
tools?: Tool[];
stream?: boolean;
max_tokens?: number;
stop_sequences?: string[];
temperature?: number;
top_p?: number;
top_k?: number;
thinking?: ThinkingConfig;
metadata?: Metadata;
}
export interface ThinkingConfig {
type: 'enabled' | string;
budget_tokens?: number;
effort?: 'low' | 'medium' | 'high' | 'max' | string;
}
export type SystemPrompt = string | SystemBlock[];
export interface SystemBlock {
type: string;
text: string;
}
export interface Message {
role: string;
content: string | ContentBlock[];
}
export type ContentBlock =
| TextBlock
| ThinkingBlock
| ImageBlock
| ToolUseBlock
| ToolResultBlock
| RedactedThinkingBlock;
export interface TextBlock {
type: 'text';
text: string;
}
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
/** Cache control configuration */
cache_control?: CacheControl;
}
export interface ImageBlock {
type: 'image';
source: {
type: 'base64';
media_type: string;
data: string;
};
}
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
/** Tool input parameters as flexible JSON values */
input: Record<string, unknown>;
signature?: string;
/** Cache control configuration */
cache_control?: CacheControl;
}
export interface ToolResultBlock {
type: 'tool_result';
tool_use_id: string;
content: string | ContentBlock[]; // Supports text or nested blocks
is_error?: boolean;
}
export interface RedactedThinkingBlock {
type: 'redacted_thinking';
data: string;
}
export interface Tool {
name: string;
description?: string;
/** JSON Schema definition for tool input parameters */
input_schema?: JsonSchema;
/** Server tool type, e.g. 'web_search_20250305' */
type?: string;
}
export interface Metadata {
user_id?: string;
source?: string;
[key: string]: unknown;
}
// ============================================================================
// Gemini API Types (Gemini Request/Response related types)
// ============================================================================
/**
* Gemini Safety Settings
* Controls content filtering thresholds
*/
export interface SafetySetting {
/** Harm category */
category: string;
/** Threshold: OFF, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH */
threshold: string;
}
/**
* Gemini Generation Config
* Parameters controlling model generation behavior
*/
export interface GenerationConfig {
/** Temperature, controls randomness (0-2) */
temperature?: number;
/** Top-P sampling parameter */
topP?: number;
/** Top-K sampling parameter */
topK?: number;
/** Maximum output tokens */
maxOutputTokens?: number;
/** List of stop sequences */
stopSequences?: string[];
/** Thinking mode configuration */
thinkingConfig?: ThinkingGeminiConfig;
/** Response MIME type */
responseMimeType?: string;
/** Response modalities */
responseModalities?: string[];
/** Image generation configuration */
imageConfig?: ImageConfig;
}
/**
* Gemini Thinking Mode Configuration
*/
export interface ThinkingGeminiConfig {
/** Whether to include thoughts */
includeThoughts: boolean;
/** Thinking token budget */
thinkingBudget?: number;
/** Thinking level for Claude-native adaptive modes */
thinkingLevel?: 'low' | 'medium' | 'high' | string;
}
/**
* Image Generation Configuration
*/
export interface ImageConfig {
/** Aspect ratio: 1:1, 16:9, 9:16, 4:3, 3:4 */
aspectRatio: string;
/** Image size: 4K */
imageSize?: string;
}
/**
* Gemini Tool Declaration
* Can include function declarations or search tools
*/
export interface GeminiToolDeclaration {
/** List of function declarations */
functionDeclarations?: FunctionDeclaration[];
/** Google Search Tool */
googleSearch?: Record<string, never>;
/** Google Search Retrieval Tool */
googleSearchRetrieval?: Record<string, never>;
}
/**
* Function Declaration
* Defines functions callable by the model
*/
export interface FunctionDeclaration {
/** Function name */
name: string;
/** Function description */
description?: string;
/** JSON Schema for parameters */
parameters?: JsonSchema;
}
/**
* Gemini Function Call
*/
export interface FunctionCall {
/** Function name */
name: string;
/** Function arguments */
args: Record<string, unknown>;
/** Call ID */
id?: string;
}
/**
* Gemini Function Response
*/
export interface FunctionResponse {
/** Function name */
name: string;
/** Function response result */
response: Record<string, unknown>;
/** Call ID */
id?: string;
}
export interface GeminiRequest {
contents: GeminiContent[];
/** List of tool declarations */
tools?: GeminiToolDeclaration[];
/** Safety settings */
safetySettings?: SafetySetting[];
/** System instruction */
systemInstruction?: { parts: { text: string }[] };
/** Generation config */
generationConfig?: GenerationConfig;
}
export interface GeminiInternalRequest {
project?: string;
requestId: string;
request: GeminiRequest;
model: string;
userAgent: string;
requestType?: string;
sessionId?: string;
}
export interface GeminiContent {
role: string;
parts: GeminiPart[];
}
export interface GeminiPart {
text?: string;
/** Whether content is 'thought' */
thought?: boolean;
/** Thought signature */
thoughtSignature?: string;
/** Function call */
functionCall?: FunctionCall;
/** Function response */
functionResponse?: FunctionResponse;
/** Inline data (images, etc.) */
inlineData?: {
mimeType: string;
data: string;
};
}
// --- Quota Models ---
export interface QuotaData {
models: Record<string, ModelQuotaInfo>;
isForbidden: boolean;
subscriptionTier?: string;
model_forwarding_rules?: Record<string, string>;
is_forbidden?: boolean;
subscription_tier?: string;
}
export interface ModelQuotaInfo {
percentage: number;
resetTime: string;
display_name?: string;
supports_images?: boolean;
supports_thinking?: boolean;
thinking_budget?: number;
recommended?: boolean;
max_tokens?: number;
max_output_tokens?: number;
supported_mime_types?: Record<string, boolean>;
}
export interface LoadProjectResponse {
cloudaicompanionProject?: string;
currentTier?: Tier;
paidTier?: Tier;
}
export interface Tier {
id?: string;
quotaTier?: string;
name?: string;
slug?: string;
}
export interface QuotaApiResponse {
models: Record<
string,
{
quotaInfo?: { remainingFraction?: number; resetTime?: string };
displayName?: string;
supportsImages?: boolean;
supportsThinking?: boolean;
thinkingBudget?: number;
recommended?: boolean;
maxTokens?: number;
maxOutputTokens?: number;
supportedMimeTypes?: Record<string, boolean>;
}
>;
deprecatedModelIds?: Record<string, { newModelId?: string }>;
}
// --- Response Models ---
export interface ClaudeResponse {
id: string;
type: string;
role: string;
model: string;
content: ContentBlock[];
stop_reason: string;
stop_sequence?: string | null;
usage: Usage;
}
export interface Usage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
/** Server tool usage stats */
server_tool_use?: ServerToolUse;
}
/** Server tool usage stats */
export interface ServerToolUse {
/** Number of web search requests */
web_search_requests?: number;
}
export interface GeminiResponse {
candidates?: Candidate[];
usageMetadata?: UsageMetadata;
modelVersion?: string;
responseId?: string;
}
export interface Candidate {
content?: GeminiContent;
finishReason?: string;
index?: number;
groundingMetadata?: GroundingMetadata;
}
export interface UsageMetadata {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
}
export interface GroundingMetadata {
/** List of web search queries */
webSearchQueries?: string[];
/** Grounding chunks */
groundingChunks?: GroundingChunk[];
/** Grounding supports */
groundingSupports?: GroundingSupport[];
/** Search entry point */
searchEntryPoint?: SearchEntryPoint;
}
/** Grounding support info */
export interface GroundingSupport {
/** Text segment */
segment?: {
startIndex?: number;
endIndex?: number;
};
/** Associated grounding chunk indices */
groundingChunkIndices?: number[];
}
/** Search entry point */
export interface SearchEntryPoint {
/** Rendered HTML content */
renderedContent?: string;
}
export interface GroundingChunk {
web?: {
uri?: string;
title?: string;
};
}