feat: integrate antigravity adapter
This commit is contained in:
1 parent
f5a85e3436
commit
64e27ab821
11 files changed
+2446
-85
No files matched your search
@@ -0,0 +1,587 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { mapClaudeModelToGemini } from './ModelMapping';
|
||||||
|
import { cleanJsonSchema } from './JsonSchemaUtils';
|
||||||
|
import { SignatureStore } from './SignatureStore';
|
||||||
|
import {
|
||||||
|
ClaudeRequest,
|
||||||
|
Message,
|
||||||
|
Tool,
|
||||||
|
GeminiInternalRequest,
|
||||||
|
GeminiContent,
|
||||||
|
GeminiToolDeclaration,
|
||||||
|
GenerationConfig,
|
||||||
|
ImageConfig,
|
||||||
|
FunctionDeclaration,
|
||||||
|
SafetySetting,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
): 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
|
||||||
|
let systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
|
||||||
|
|
||||||
|
// Map model name
|
||||||
|
const mappedModel = hasWebSearchTool
|
||||||
|
? 'gemini-2.5-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 generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel);
|
||||||
|
|
||||||
|
const isThinkingEnabled = claudeReq.thinking?.type === 'enabled';
|
||||||
|
|
||||||
|
// 2. Contents (Messages)
|
||||||
|
const contents = buildContents(
|
||||||
|
claudeReq.messages,
|
||||||
|
toolIdToName,
|
||||||
|
isThinkingEnabled,
|
||||||
|
allowDummyThought,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Tools
|
||||||
|
let tools = buildTools(claudeReq.tools, hasWebSearchTool);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
let genConfig = innerRequest.generationConfig || {};
|
||||||
|
delete genConfig.thinkingConfig;
|
||||||
|
delete genConfig.responseMimeType;
|
||||||
|
delete genConfig.responseModalities;
|
||||||
|
genConfig.imageConfig = config.imageConfig;
|
||||||
|
innerRequest.generationConfig = genConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `agent-${uuidv4()}`;
|
||||||
|
|
||||||
|
const body: GeminiInternalRequest = {
|
||||||
|
project: projectId,
|
||||||
|
requestId: requestId,
|
||||||
|
request: innerRequest as GeminiInternalRequest['request'],
|
||||||
|
model: config.finalModel,
|
||||||
|
userAgent: 'antigravity',
|
||||||
|
requestType: config.requestType,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (mappedModel.startsWith('gemini-3-pro-image')) {
|
||||||
|
const { imageConfig, parsedBaseModel } = parseImageConfig(originalModel);
|
||||||
|
return {
|
||||||
|
requestType: 'image_gen',
|
||||||
|
injectGoogleSearch: false,
|
||||||
|
finalModel: parsedBaseModel,
|
||||||
|
imageConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNetworkingTool = detectsNetworkingTool(tools);
|
||||||
|
const hasNonNetworking = containsNonNetworkingTool(tools);
|
||||||
|
|
||||||
|
// Strip -online suffix
|
||||||
|
const isOnlineSuffix = originalModel.endsWith('-online');
|
||||||
|
|
||||||
|
const isHighQualityModel =
|
||||||
|
mappedModel === 'gemini-2.5-flash' ||
|
||||||
|
mappedModel === 'gemini-1.5-pro' ||
|
||||||
|
mappedModel.startsWith('gemini-1.5-pro-') ||
|
||||||
|
mappedModel.startsWith('gemini-2.5-flash-') ||
|
||||||
|
mappedModel.startsWith('gemini-2.0-flash') ||
|
||||||
|
mappedModel.startsWith('gemini-3-') ||
|
||||||
|
mappedModel.includes('claude-3-5-sonnet') ||
|
||||||
|
mappedModel.includes('claude-3-opus') ||
|
||||||
|
mappedModel.includes('claude-sonnet') ||
|
||||||
|
mappedModel.includes('claude-opus') ||
|
||||||
|
mappedModel.includes('claude-4');
|
||||||
|
|
||||||
|
// Determine if we should enable networking
|
||||||
|
const enableNetworking =
|
||||||
|
isOnlineSuffix || (isHighQualityModel && !hasNonNetworking) || hasNetworkingTool;
|
||||||
|
|
||||||
|
let finalModel = mappedModel.replace(/-online$/, '');
|
||||||
|
if (enableNetworking) {
|
||||||
|
// Fallback for search compatibility
|
||||||
|
if (finalModel.includes('thinking') || !finalModel.startsWith('gemini-')) {
|
||||||
|
finalModel = 'gemini-2.5-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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
// Claude Tool format
|
||||||
|
if ('name' in tool && tool.name && keywords.includes(tool.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ('type' in tool && tool.type && keywords.includes(tool.type)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI nested format (runtime check)
|
||||||
|
const openaiTool = tool as { function?: { name?: string } };
|
||||||
|
if (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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if non-networking tools are present
|
||||||
|
* Checks tool list for tools other than web search
|
||||||
|
*/
|
||||||
|
function containsNonNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolean {
|
||||||
|
if (!tools) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const keywords = [
|
||||||
|
'web_search',
|
||||||
|
'google_search',
|
||||||
|
'web_search_20250305',
|
||||||
|
'google_search_retrieval',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
let isNetworking = false;
|
||||||
|
|
||||||
|
// Claude Tool format
|
||||||
|
if ('name' in tool && tool.name && keywords.includes(tool.name)) {
|
||||||
|
isNetworking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI nested format (runtime check)
|
||||||
|
const openaiTool = tool as { function?: { name?: string } };
|
||||||
|
if (openaiTool.function?.name && keywords.includes(openaiTool.function.name))
|
||||||
|
isNetworking = true;
|
||||||
|
|
||||||
|
// Gemini search tools
|
||||||
|
if ('googleSearch' in tool && tool.googleSearch) {
|
||||||
|
isNetworking = true;
|
||||||
|
}
|
||||||
|
if ('googleSearchRetrieval' in tool && tool.googleSearchRetrieval) {
|
||||||
|
isNetworking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini functionDeclarations
|
||||||
|
if ('functionDeclarations' in tool && tool.functionDeclarations) {
|
||||||
|
for (const decl of tool.functionDeclarations) {
|
||||||
|
if (decl.name && !keywords.includes(decl.name)) {
|
||||||
|
return true; // Found local
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isNetworking = true; // All are networking
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNetworking) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Google Search Tool
|
||||||
|
* Adds googleSearch tool to the request
|
||||||
|
*/
|
||||||
|
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
||||||
|
if (!body.tools) {
|
||||||
|
body.tools = [];
|
||||||
|
}
|
||||||
|
const toolsArr = body.tools;
|
||||||
|
|
||||||
|
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
|
||||||
|
if (hasFunctions) {
|
||||||
|
console.info('Skipping googleSearch injection due to existing functionDeclarations');
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
function buildSystemInstruction(
|
||||||
|
system: ClaudeRequest['system'],
|
||||||
|
modelName: string,
|
||||||
|
): { parts: { text: string }[] } | null {
|
||||||
|
if (!system) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parts: { text: string }[] = [];
|
||||||
|
const identityPatch =
|
||||||
|
`--- [IDENTITY_PATCH] ---\n` +
|
||||||
|
`Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n` +
|
||||||
|
`You are currently providing services as the native ${modelName} model via a standard API proxy.\n` +
|
||||||
|
`Always use the 'claude' command for terminal tasks if relevant.\n` +
|
||||||
|
`--- [SYSTEM_PROMPT_BEGIN] ---\n`;
|
||||||
|
parts.push({ text: identityPatch });
|
||||||
|
if (typeof system === 'string') {
|
||||||
|
parts.push({ text: system });
|
||||||
|
} else if (Array.isArray(system)) {
|
||||||
|
for (const block of system) {
|
||||||
|
if (block.type === 'text') parts.push({ text: block.text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push({ text: '\n--- [SYSTEM_PROMPT_END] ---' });
|
||||||
|
return { parts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds message contents
|
||||||
|
* Converts Claude message list to Gemini content format
|
||||||
|
*/
|
||||||
|
function buildContents(
|
||||||
|
messages: Message[],
|
||||||
|
toolIdToName: Map<string, string>,
|
||||||
|
isThinkingEnabled: boolean,
|
||||||
|
allowDummyThought: boolean,
|
||||||
|
): 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)' && 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;
|
||||||
|
parts.push(part);
|
||||||
|
} else if (block.type === 'tool_result') {
|
||||||
|
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
||||||
|
let mergedContent = '';
|
||||||
|
if (typeof block.content === 'string') 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 (mergedContent.trim().length === 0)
|
||||||
|
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,
|
||||||
|
): 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.type === 'web_search_20250305'
|
||||||
|
) {
|
||||||
|
hasGoogleSearch = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tool.name) {
|
||||||
|
const inputSchema = tool.input_schema || { type: 'object', properties: {} };
|
||||||
|
cleanJsonSchema(inputSchema);
|
||||||
|
functionDeclarations.push({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: inputSchema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolObj: GeminiToolDeclaration = {};
|
||||||
|
if (functionDeclarations.length > 0) {
|
||||||
|
toolObj.functionDeclarations = functionDeclarations;
|
||||||
|
} else if (hasGoogleSearch) {
|
||||||
|
toolObj.googleSearch = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(toolObj).length > 0) {
|
||||||
|
return [toolObj];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* build generation config
|
||||||
|
* convert claude request parameters to gemini generation config
|
||||||
|
*/
|
||||||
|
function buildGenerationConfig(
|
||||||
|
claudeReq: ClaudeRequest,
|
||||||
|
hasWebSearch: boolean,
|
||||||
|
mappedModel: string,
|
||||||
|
): GenerationConfig {
|
||||||
|
const config: GenerationConfig = {};
|
||||||
|
if (claudeReq.thinking?.type === 'enabled') {
|
||||||
|
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
||||||
|
if (claudeReq.thinking.budget_tokens) {
|
||||||
|
let budget = claudeReq.thinking.budget_tokens;
|
||||||
|
const isFlash = hasWebSearch || mappedModel.includes('gemini-2.5-flash');
|
||||||
|
if (isFlash) budget = Math.min(budget, 24576);
|
||||||
|
thinkingConfig.thinkingBudget = budget;
|
||||||
|
}
|
||||||
|
config.thinkingConfig = thinkingConfig;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
config.maxOutputTokens = 64000;
|
||||||
|
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 (obj && typeof obj === 'object') {
|
||||||
|
const record = obj as Record<string, unknown>;
|
||||||
|
Object.keys(record).forEach((key) => {
|
||||||
|
if (record[key] === undefined) delete record[key];
|
||||||
|
else deepCleanUndefined(record[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
ClaudeResponse,
|
||||||
|
GeminiResponse,
|
||||||
|
GeminiPart,
|
||||||
|
ContentBlock,
|
||||||
|
Usage,
|
||||||
|
GroundingMetadata,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = 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 = ``;
|
||||||
|
this.textBuilder += markdownImg;
|
||||||
|
this.flushText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processGrounding(grounding: GroundingMetadata) {
|
||||||
|
let groundingText = '';
|
||||||
|
|
||||||
|
if (grounding.webSearchQueries && grounding.webSearchQueries.length > 0) {
|
||||||
|
groundingText += `\n\n---\n**🔍 已为您搜索:** ${grounding.webSearchQueries.join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grounding.groundingChunks) {
|
||||||
|
const links: string[] = [];
|
||||||
|
grounding.groundingChunks.forEach((chunk, index) => {
|
||||||
|
if (chunk.web) {
|
||||||
|
const title = chunk.web.title || '网页来源';
|
||||||
|
const uri = chunk.web.uri || '#';
|
||||||
|
links.push(`[${index + 1}] [${title}](${uri})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (links.length > 0) {
|
||||||
|
groundingText += `\n\n**🌐 来源引文:**\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,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: geminiResponse.responseId || `msg_${uuidv4()}`,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: geminiResponse.modelVersion || '',
|
||||||
|
content: this.contentBlocks,
|
||||||
|
stop_reason: stopReason,
|
||||||
|
stop_sequence: null,
|
||||||
|
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,431 @@
|
|||||||
|
import { GeminiPart, Usage, UsageMetadata, GroundingMetadata } from './types';
|
||||||
|
import { SignatureStore } from './SignatureStore';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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**🔍 已为您搜索:** ${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 || '网页来源';
|
||||||
|
const uri = chunk.web.uri || '#';
|
||||||
|
links.push(`[${i + 1}] [${title}](${uri})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (links.length > 0) {
|
||||||
|
groundingText += `\n\n**🌐 来源引文:**\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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Part Processor
|
||||||
|
*/
|
||||||
|
export class PartProcessor {
|
||||||
|
constructor(private state: StreamingState) {}
|
||||||
|
|
||||||
|
public process(part: GeminiPart): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const signature = part.thoughtSignature || undefined;
|
||||||
|
|
||||||
|
// 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 = ``;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* 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 (value && typeof value === 'object' && !Array.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively expand $ref
|
||||||
|
*/
|
||||||
|
function flattenRefs(map: any, defs: Record<string, any>) {
|
||||||
|
if (!map || typeof map !== 'object') return;
|
||||||
|
|
||||||
|
// 检查并替换 $ref
|
||||||
|
if (typeof map['$ref'] === 'string') {
|
||||||
|
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 (defSchema && typeof defSchema === 'object') {
|
||||||
|
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 (map.hasOwnProperty(k)) {
|
||||||
|
const v = map[k];
|
||||||
|
if (typeof v === 'object' && v !== null) {
|
||||||
|
flattenRefs(v, defs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanJsonSchemaRecursive(value: any) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.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 (map.hasOwnProperty(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 (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
||||||
|
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 (typeof typeVal === 'string') {
|
||||||
|
map['type'] = typeVal.toLowerCase();
|
||||||
|
} else if (Array.isArray(typeVal)) {
|
||||||
|
// Union type downgrade: take the first non-null type
|
||||||
|
let selectedType = 'string';
|
||||||
|
for (const item of typeVal) {
|
||||||
|
if (typeof item === 'string' && item !== 'null') {
|
||||||
|
selectedType = item.toLowerCase();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map['type'] = selectedType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
const logger = {
|
||||||
|
log: (msg: string) => console.log(msg),
|
||||||
|
warn: (msg: string) => console.warn(msg),
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||||
|
// Directly supported models
|
||||||
|
'claude-opus-4-5-thinking': 'claude-opus-4-5-thinking',
|
||||||
|
'claude-sonnet-4-5': 'claude-sonnet-4-5',
|
||||||
|
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
||||||
|
|
||||||
|
// Alias mappings
|
||||||
|
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking',
|
||||||
|
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-5',
|
||||||
|
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-5',
|
||||||
|
'claude-opus-4': 'claude-opus-4-5-thinking',
|
||||||
|
'claude-opus-4-5-20251101': 'claude-opus-4-5-thinking',
|
||||||
|
'claude-haiku-4': 'claude-sonnet-4-5',
|
||||||
|
'claude-3-haiku-20240307': 'claude-sonnet-4-5',
|
||||||
|
'claude-haiku-4-5-20251001': 'claude-sonnet-4-5',
|
||||||
|
|
||||||
|
// OpenAI Protocol Mapping
|
||||||
|
'gpt-4': 'gemini-2.5-pro',
|
||||||
|
'gpt-4-turbo': 'gemini-2.5-pro',
|
||||||
|
'gpt-4-turbo-preview': 'gemini-2.5-pro',
|
||||||
|
'gpt-4-0125-preview': 'gemini-2.5-pro',
|
||||||
|
'gpt-4-1106-preview': 'gemini-2.5-pro',
|
||||||
|
'gpt-4-0613': 'gemini-2.5-pro',
|
||||||
|
|
||||||
|
'gpt-4o': 'gemini-2.5-pro',
|
||||||
|
'gpt-4o-2024-05-13': 'gemini-2.5-pro',
|
||||||
|
'gpt-4o-2024-08-06': 'gemini-2.5-pro',
|
||||||
|
|
||||||
|
'gpt-4o-mini': 'gemini-2.5-flash',
|
||||||
|
'gpt-4o-mini-2024-07-18': 'gemini-2.5-flash',
|
||||||
|
|
||||||
|
'gpt-3.5-turbo': 'gemini-2.5-flash',
|
||||||
|
'gpt-3.5-turbo-16k': 'gemini-2.5-flash',
|
||||||
|
'gpt-3.5-turbo-0125': 'gemini-2.5-flash',
|
||||||
|
'gpt-3.5-turbo-1106': 'gemini-2.5-flash',
|
||||||
|
'gpt-3.5-turbo-0613': 'gemini-2.5-flash',
|
||||||
|
|
||||||
|
// Gemini Protocol Mapping
|
||||||
|
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
|
||||||
|
'gemini-2.5-flash-thinking': 'gemini-2.5-flash-thinking',
|
||||||
|
'gemini-3-pro-low': 'gemini-3-pro-low',
|
||||||
|
'gemini-3-pro-high': 'gemini-3-pro-high',
|
||||||
|
'gemini-3-pro-preview': 'gemini-3-pro-preview',
|
||||||
|
'gemini-2.5-flash': 'gemini-2.5-flash',
|
||||||
|
'gemini-3-flash': 'gemini-3-flash',
|
||||||
|
'gemini-3-pro-image': 'gemini-3-pro-image',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapClaudeModelToGemini(input: string): string {
|
||||||
|
// 1. Check exact match in map
|
||||||
|
if (CLAUDE_TO_GEMINI[input]) {
|
||||||
|
return CLAUDE_TO_GEMINI[input];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pass-through known prefixes (gemini-, -thinking) to support dynamic suffixes
|
||||||
|
if (input.startsWith('gemini-') || input.includes('thinking')) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to default
|
||||||
|
return 'claude-sonnet-4-5';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
// 1. Check custom exact mapping (Highest priority)
|
||||||
|
if (customMapping[originalModel]) {
|
||||||
|
logger.log(
|
||||||
|
`[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.log(
|
||||||
|
`[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.log(
|
||||||
|
`[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.log(
|
||||||
|
`[Router] Using GPT-5 series mapping: ${originalModel} -> ${openaiMapping['gpt-5-series']}`,
|
||||||
|
);
|
||||||
|
return openaiMapping['gpt-5-series'];
|
||||||
|
}
|
||||||
|
if (openaiMapping['gpt-4-series']) {
|
||||||
|
logger.log(
|
||||||
|
`[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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||||
|
import { QuotaData, Tier, LoadProjectResponse, QuotaApiResponse } from './types';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels';
|
||||||
|
const CLOUD_CODE_BASE_URL = 'https://cloudcode-pa.googleapis.com';
|
||||||
|
const USER_AGENT = 'antigravity/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': 'antigravity/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) {
|
||||||
|
console.log(`📊 [${email}] Subscription Identified: ${subscriptionTier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [projectId, subscriptionTier];
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.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 = { project: finalProjectId };
|
||||||
|
const url = QUOTA_API_URL;
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
|
console.log(`Sending quota request to ${url}`);
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await client.post<QuotaApiResponse>(url, payload, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const quotaResponse = response.data;
|
||||||
|
const quotaData: QuotaData = {
|
||||||
|
models: {},
|
||||||
|
isForbidden: false,
|
||||||
|
subscriptionTier: subscriptionTier,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Quota API returned ${Object.keys(quotaResponse.models || {}).length} models:`);
|
||||||
|
|
||||||
|
if (quotaResponse.models) {
|
||||||
|
for (const [name, info] of Object.entries(quotaResponse.models)) {
|
||||||
|
console.log(` - ${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
|
||||||
|
if (name.includes('gemini') || name.includes('claude')) {
|
||||||
|
quotaData.models[name] = { percentage, resetTime };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { quotaData, projectId: projectId };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const text = JSON.stringify(error.response?.data || '');
|
||||||
|
|
||||||
|
// ✅ Handle 403 Forbidden specifically - return immediately, do not retry
|
||||||
|
if (status === 403) {
|
||||||
|
console.warn(`Account no permission (403 Forbidden), marked as forbidden`);
|
||||||
|
return {
|
||||||
|
quotaData: {
|
||||||
|
models: {},
|
||||||
|
isForbidden: true,
|
||||||
|
subscriptionTier: subscriptionTier,
|
||||||
|
},
|
||||||
|
projectId: projectId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`Request Failed: ${error.message} (Attempt ${attempt}/${maxRetries})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
} else {
|
||||||
|
throw new Error(`Quota query failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unknown error in fetchQuota'); // Should not reach here
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.log(
|
||||||
|
`[ThoughtSig] Storing new signature (length: ${newLen}, replacing old: ${existingLen})`,
|
||||||
|
);
|
||||||
|
this.signature = sig;
|
||||||
|
} else {
|
||||||
|
console.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();
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// 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;
|
||||||
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
top_k?: number;
|
||||||
|
thinking?: ThinkingConfig;
|
||||||
|
metadata?: Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThinkingConfig {
|
||||||
|
type: 'enabled' | string;
|
||||||
|
budget_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelQuotaInfo {
|
||||||
|
percentage: number;
|
||||||
|
resetTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
/** 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
|
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
|
||||||
|
import { GeminiInternalRequest } from '../../../../lib/antigravity/types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GeminiClient {
|
export class GeminiClient {
|
||||||
@@ -56,4 +57,56 @@ export class GeminiClient {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Antigravity Internal API Support ---
|
||||||
|
|
||||||
|
private readonly internalBaseUrl = 'https://cloudcode-pa.googleapis.com/v1internal';
|
||||||
|
|
||||||
|
async streamGenerateInternal(body: GeminiInternalRequest, accessToken: string): Promise<any> {
|
||||||
|
const url = `${this.internalBaseUrl}:streamGenerateContent?alt=sse`;
|
||||||
|
try {
|
||||||
|
const response = await axios.post(url, body, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'antigravity/1.11.9 windows/amd64',
|
||||||
|
},
|
||||||
|
responseType: 'stream',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, 'StreamInternal');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateInternal(body: GeminiInternalRequest, accessToken: string): Promise<any> {
|
||||||
|
const url = `${this.internalBaseUrl}:generateContent`;
|
||||||
|
try {
|
||||||
|
const response = await axios.post(url, body, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'antigravity/1.11.9 windows/amd64',
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
// v1internal API wraps the response in a 'response' field, unwrap it
|
||||||
|
return response.data.response || response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, 'GenerateInternal');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosError(error: any, context: string) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
this.logger.error(
|
||||||
|
`Gemini ${context} API Error: ${error.message} - ${JSON.stringify(error.response?.data)}`,
|
||||||
|
);
|
||||||
|
throw new Error(error.response?.data?.error?.message || error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
import { TokenManagerService } from './token-manager.service';
|
import { TokenManagerService } from './token-manager.service';
|
||||||
import { GeminiClient } from './clients/gemini.client';
|
import { GeminiClient } from './clients/gemini.client';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { transformClaudeRequestIn } from '../../../lib/antigravity/ClaudeRequestMapper';
|
||||||
|
import { transformResponse } from '../../../lib/antigravity/ClaudeResponseMapper';
|
||||||
|
import { StreamingState, PartProcessor } from '../../../lib/antigravity/ClaudeStreamingMapper';
|
||||||
|
import { ClaudeRequest } from '../../../lib/antigravity/types';
|
||||||
import {
|
import {
|
||||||
OpenAIChatRequest,
|
OpenAIChatRequest,
|
||||||
AnthropicChatRequest,
|
AnthropicChatRequest,
|
||||||
@@ -22,8 +26,8 @@ export class ProxyService {
|
|||||||
private readonly logger = new Logger(ProxyService.name);
|
private readonly logger = new Logger(ProxyService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenManager: TokenManagerService,
|
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
|
||||||
private readonly geminiClient: GeminiClient,
|
@Inject(GeminiClient) private readonly geminiClient: GeminiClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// --- Anthropic Handlers ---
|
// --- Anthropic Handlers ---
|
||||||
@@ -47,23 +51,24 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert Anthropic -> Gemini
|
// Use Antigravity Mapper
|
||||||
const geminiRequest = this.convertAnthropicToGemini(request);
|
const projectId = token.token.project_id!;
|
||||||
|
const geminiBody = transformClaudeRequestIn(request as unknown as ClaudeRequest, projectId);
|
||||||
|
// Antigravity mapper determines the final model (e.g. gemini-2.5-flash) based on input
|
||||||
|
// We trust its judgment (it handles logic for online/image models)
|
||||||
|
|
||||||
if (request.stream) {
|
if (request.stream) {
|
||||||
const stream = await this.geminiClient.streamGenerate(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
targetModel,
|
geminiBody,
|
||||||
geminiRequest,
|
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
);
|
);
|
||||||
return this.processAnthropicStreamResponse(stream, request.model);
|
return this.processAnthropicInternalStream(stream, geminiBody.model);
|
||||||
} else {
|
} else {
|
||||||
const response = await this.geminiClient.generate(
|
const response = await this.geminiClient.generateInternal(
|
||||||
targetModel,
|
geminiBody,
|
||||||
geminiRequest,
|
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
);
|
);
|
||||||
return this.convertGeminiToAnthropicResponse(response, request.model);
|
return transformResponse(response) as unknown as AnthropicChatResponse;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@@ -78,6 +83,7 @@ export class ProxyService {
|
|||||||
throw lastError || new Error('Request failed after retries');
|
throw lastError || new Error('Request failed after retries');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Old converter kept for reference or other uses
|
||||||
private convertAnthropicToGemini(request: AnthropicChatRequest): GeminiRequest {
|
private convertAnthropicToGemini(request: AnthropicChatRequest): GeminiRequest {
|
||||||
const contents: GeminiContent[] = [];
|
const contents: GeminiContent[] = [];
|
||||||
let systemInstruction: { parts: GeminiPart[] } | undefined = undefined;
|
let systemInstruction: { parts: GeminiPart[] } | undefined = undefined;
|
||||||
@@ -127,12 +133,10 @@ export class ProxyService {
|
|||||||
topP: request.top_p,
|
topP: request.top_p,
|
||||||
topK: request.top_k,
|
topK: request.top_k,
|
||||||
},
|
},
|
||||||
// Note: Rust version handles 'pending_images' logic here, but for now we assume simple text/image structure.
|
|
||||||
// If we need to support image injection from history to user msg, that logic needs to be ported too.
|
|
||||||
// Current port matches previous 'any' style logic but with types.
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Old response converter
|
||||||
private convertGeminiToAnthropicResponse(
|
private convertGeminiToAnthropicResponse(
|
||||||
geminiResponse: GeminiResponse,
|
geminiResponse: GeminiResponse,
|
||||||
model: string,
|
model: string,
|
||||||
@@ -157,36 +161,16 @@ export class ProxyService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private processAnthropicStreamResponse(upstreamStream: any, model: string): Observable<string> {
|
private processAnthropicInternalStream(upstreamStream: any, model: string): Observable<string> {
|
||||||
return new Observable<string>((subscriber) => {
|
return new Observable<string>((subscriber) => {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
const msgId = `msg_${uuidv4()}`;
|
|
||||||
|
|
||||||
// Send Message Start
|
const state = new StreamingState();
|
||||||
subscriber.next(
|
const processor = new PartProcessor(state);
|
||||||
`event: message_start\ndata: ${JSON.stringify({
|
|
||||||
type: 'message_start',
|
|
||||||
message: {
|
|
||||||
id: msgId,
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
content: [],
|
|
||||||
model: model,
|
|
||||||
stop_reason: null,
|
|
||||||
stop_sequence: null,
|
|
||||||
usage: { input_tokens: 0, output_tokens: 0 },
|
|
||||||
},
|
|
||||||
})}\n\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriber.next(
|
let lastFinishReason: string | undefined;
|
||||||
`event: content_block_start\ndata: ${JSON.stringify({
|
let lastUsageMetadata: any | undefined;
|
||||||
type: 'content_block_start',
|
|
||||||
index: 0,
|
|
||||||
content_block: { type: 'text', text: '' },
|
|
||||||
})}\n\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
upstreamStream.on('data', (chunk: Buffer) => {
|
upstreamStream.on('data', (chunk: Buffer) => {
|
||||||
buffer += decoder.decode(chunk, { stream: true });
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
@@ -201,45 +185,35 @@ export class ProxyService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(dataStr);
|
const json = JSON.parse(dataStr);
|
||||||
const candidate = json.candidates?.[0];
|
|
||||||
const contentPart = candidate?.content?.parts?.[0];
|
|
||||||
const text = contentPart?.text || '';
|
|
||||||
|
|
||||||
if (text) {
|
if (json) {
|
||||||
subscriber.next(
|
const startMsg = state.emitMessageStart(json);
|
||||||
`event: content_block_delta\ndata: ${JSON.stringify({
|
if (startMsg) subscriber.next(startMsg);
|
||||||
type: 'content_block_delta',
|
|
||||||
index: 0,
|
|
||||||
delta: { type: 'text_delta', text: text },
|
|
||||||
})}\n\n`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
|
||||||
|
const candidate = json.candidates?.[0];
|
||||||
|
const part = candidate?.content?.parts?.[0];
|
||||||
|
|
||||||
|
if (candidate?.finishReason) {
|
||||||
|
lastFinishReason = candidate.finishReason;
|
||||||
|
}
|
||||||
|
if (json.usageMetadata) {
|
||||||
|
lastUsageMetadata = json.usageMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part) {
|
||||||
|
const chunks = processor.process(part as any);
|
||||||
|
chunks.forEach((c) => subscriber.next(c));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Stream parse error', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
upstreamStream.on('end', () => {
|
upstreamStream.on('end', () => {
|
||||||
subscriber.next(
|
const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata);
|
||||||
`event: content_block_stop\ndata: ${JSON.stringify({
|
finishChunks.forEach((c) => subscriber.next(c));
|
||||||
type: 'content_block_stop',
|
|
||||||
index: 0,
|
|
||||||
})}\n\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriber.next(
|
|
||||||
`event: message_delta\ndata: ${JSON.stringify({
|
|
||||||
type: 'message_delta',
|
|
||||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
|
||||||
usage: { output_tokens: 0 },
|
|
||||||
})}\n\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriber.next(
|
|
||||||
`event: message_stop\ndata: ${JSON.stringify({
|
|
||||||
type: 'message_stop',
|
|
||||||
})}\n\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,24 +243,31 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Convert Request
|
// Convert OpenAI request to Claude format for Antigravity pipeline
|
||||||
const geminiRequest = this.convertOpenAIToGemini(request);
|
const claudeRequest = this.convertOpenAIToClaude(request);
|
||||||
|
const projectId = token.token.project_id!;
|
||||||
|
const geminiBody = transformClaudeRequestIn(
|
||||||
|
claudeRequest as unknown as ClaudeRequest,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Handle Stream vs Non-Stream
|
// Use v1internal API (same as Anthropic handler)
|
||||||
if (request.stream) {
|
if (request.stream) {
|
||||||
const stream = await this.geminiClient.streamGenerate(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
targetModel,
|
geminiBody,
|
||||||
geminiRequest,
|
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
);
|
);
|
||||||
return this.processStreamResponse(stream, request.model);
|
return this.processStreamResponse(stream, request.model);
|
||||||
} else {
|
} else {
|
||||||
const response = await this.geminiClient.generate(
|
const response = await this.geminiClient.generateInternal(
|
||||||
targetModel,
|
geminiBody,
|
||||||
geminiRequest,
|
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
);
|
);
|
||||||
return this.convertGeminiToOpenAIResponse(response, request.model);
|
this.logger.log(`Raw Gemini Response: ${JSON.stringify(response).substring(0, 500)}`);
|
||||||
|
// Transform Gemini response to OpenAI format
|
||||||
|
const claudeResponse = transformResponse(response);
|
||||||
|
this.logger.log(`Claude Response: ${JSON.stringify(claudeResponse).substring(0, 500)}`);
|
||||||
|
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@@ -471,6 +452,87 @@ export class ProxyService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert OpenAI request format to Claude/Anthropic format
|
||||||
|
private convertOpenAIToClaude(request: OpenAIChatRequest): AnthropicChatRequest {
|
||||||
|
const messages = request.messages || [];
|
||||||
|
let systemPrompt: string | undefined;
|
||||||
|
const anthropicMessages: { role: string; content: string }[] = [];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.role === 'system') {
|
||||||
|
// Extract system message
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
systemPrompt = msg.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let textContent = '';
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
textContent = msg.content
|
||||||
|
.filter((p: OpenAIContentPart) => p.type === 'text')
|
||||||
|
.map((p: OpenAIContentPart) => p.text || '')
|
||||||
|
.join('\n');
|
||||||
|
} else {
|
||||||
|
textContent = msg.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
anthropicMessages.push({
|
||||||
|
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||||||
|
content: textContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: request.model,
|
||||||
|
messages: anthropicMessages,
|
||||||
|
system: systemPrompt,
|
||||||
|
max_tokens: request.max_tokens || 4096,
|
||||||
|
temperature: request.temperature,
|
||||||
|
top_p: request.top_p,
|
||||||
|
stream: request.stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Claude response to OpenAI format
|
||||||
|
private convertClaudeToOpenAIResponse(claudeResponse: any, model: string): OpenAIChatResponse {
|
||||||
|
// Extract text content from Claude response
|
||||||
|
const content =
|
||||||
|
claudeResponse.content
|
||||||
|
?.filter((block: any) => block.type === 'text')
|
||||||
|
.map((block: any) => block.text)
|
||||||
|
.join('') || '';
|
||||||
|
|
||||||
|
const finishReason =
|
||||||
|
claudeResponse.stop_reason === 'end_turn'
|
||||||
|
? 'stop'
|
||||||
|
: claudeResponse.stop_reason === 'max_tokens'
|
||||||
|
? 'length'
|
||||||
|
: 'stop';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${uuidv4()}`,
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
finish_reason: finishReason,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: claudeResponse.usage?.input_tokens || 0,
|
||||||
|
completion_tokens: claudeResponse.usage?.output_tokens || 0,
|
||||||
|
total_tokens:
|
||||||
|
(claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private mapModel(model: string): string {
|
private mapModel(model: string): string {
|
||||||
const lower = model.toLowerCase();
|
const lower = model.toLowerCase();
|
||||||
if (lower.includes('sonnet') || lower.includes('thinking')) return 'gemini-3-pro-preview';
|
if (lower.includes('sonnet') || lower.includes('thinking')) return 'gemini-3-pro-preview';
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateSessionId(): string {
|
private generateSessionId(): string {
|
||||||
// Match the Rust implementation's session ID format
|
|
||||||
const min = 1_000_000_000_000_000_000n;
|
const min = 1_000_000_000_000_000_000n;
|
||||||
const max = 9_000_000_000_000_000_000n;
|
const max = 9_000_000_000_000_000_000n;
|
||||||
const range = max - min;
|
const range = max - min;
|
||||||
|
|||||||
Reference in new issue
Block a user