diff --git a/src/lib/antigravity/ClaudeRequestMapper.ts b/src/lib/antigravity/ClaudeRequestMapper.ts new file mode 100644 index 0000000..1e80535 --- /dev/null +++ b/src/lib/antigravity/ClaudeRequestMapper.ts @@ -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(); + + // 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, + 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; + Object.keys(record).forEach((key) => { + if (record[key] === undefined) delete record[key]; + else deepCleanUndefined(record[key]); + }); + } +} diff --git a/src/lib/antigravity/ClaudeResponseMapper.ts b/src/lib/antigravity/ClaudeResponseMapper.ts new file mode 100644 index 0000000..2ea985d --- /dev/null +++ b/src/lib/antigravity/ClaudeResponseMapper.ts @@ -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 = `![image](data:${mimeType};base64,${data})`; + this.textBuilder += markdownImg; + this.flushText(); + } + } + } + + private processGrounding(grounding: GroundingMetadata) { + let groundingText = ''; + + if (grounding.webSearchQueries && grounding.webSearchQueries.length > 0) { + groundingText += `\n\n---\n**🔍 已为您搜索:** ${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); +} diff --git a/src/lib/antigravity/ClaudeStreamingMapper.ts b/src/lib/antigravity/ClaudeStreamingMapper.ts new file mode 100644 index 0000000..a313366 --- /dev/null +++ b/src/lib/antigravity/ClaudeStreamingMapper.ts @@ -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 = `![image](data:${mimeType};base64,${data})`; + chunks.push(...this.processText(markdownImg, undefined)); + } + } + + return chunks; + } + + private processThinking(text: string, signature?: string): string[] { + const chunks: string[] = []; + + // Handle trailing signature + if (this.state.trailingSignature) { + chunks.push(...this.state.endBlock()); + const trailingSig = this.state.trailingSignature; + this.state.trailingSignature = null; + + chunks.push( + this.state.emit('content_block_start', { + type: 'content_block_start', + index: this.state.blockIndex, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' })); + chunks.push(this.state.emitDelta('signature_delta', { signature: trailingSig })); + chunks.push(...this.state.endBlock()); + } + + if (this.state.currentBlockType() !== 'Thinking') { + chunks.push(...this.state.startBlock('Thinking', { type: 'thinking', thinking: '' })); + } + + if (text) { + chunks.push(this.state.emitDelta('thinking_delta', { thinking: text })); + } + + this.state.storeSignature(signature); + + return chunks; + } + + private processText(text: string, signature?: string): string[] { + const chunks: string[] = []; + + // Empty text with signature -> store trailing + if (!text) { + if (signature) { + this.state.trailingSignature = signature; + } + return chunks; + } + + // Handle trailing signature + if (this.state.trailingSignature) { + chunks.push(...this.state.endBlock()); + const trailingSig = this.state.trailingSignature; + this.state.trailingSignature = null; + + chunks.push( + this.state.emit('content_block_start', { + type: 'content_block_start', + index: this.state.blockIndex, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' })); + chunks.push(this.state.emitDelta('signature_delta', { signature: trailingSig })); + chunks.push(...this.state.endBlock()); + } + + // Non-empty text with signature -> flush immediately + if (signature) { + // Start text block + chunks.push(...this.state.startBlock('Text', { type: 'text', text: '' })); + chunks.push(this.state.emitDelta('text_delta', { text: text })); + chunks.push(...this.state.endBlock()); + + // Empty thinking block for signature + chunks.push( + this.state.emit('content_block_start', { + type: 'content_block_start', + index: this.state.blockIndex, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' })); + chunks.push(this.state.emitDelta('signature_delta', { signature: signature })); + chunks.push(...this.state.endBlock()); + + return chunks; + } + + // Normal text + if (this.state.currentBlockType() !== 'Text') { + chunks.push(...this.state.startBlock('Text', { type: 'text', text: '' })); + } + chunks.push(this.state.emitDelta('text_delta', { text: text })); + + return chunks; + } + + private processFunctionCall( + fc: { name: string; args: any; id?: string }, + signature?: string, + ): string[] { + const chunks: string[] = []; + + this.state.markToolUsed(); + + const toolId = fc.id || `${fc.name}-${Math.random().toString(36).substr(2, 9)}`; + + const toolUse: any = { + type: 'tool_use', + id: toolId, + name: fc.name, + input: {}, // Empty, args sent via delta + }; + + if (signature) { + toolUse.signature = signature; + // Store signature to global storage for replay in subsequent requests + SignatureStore.store(signature); + } + + chunks.push(...this.state.startBlock('Function', toolUse)); + + // input_json_delta + if (fc.args) { + const jsonStr = JSON.stringify(fc.args); + chunks.push(this.state.emitDelta('input_json_delta', { partial_json: jsonStr })); + } + + chunks.push(...this.state.endBlock()); + + return chunks; + } +} diff --git a/src/lib/antigravity/JsonSchemaUtils.ts b/src/lib/antigravity/JsonSchemaUtils.ts new file mode 100644 index 0000000..778cc86 --- /dev/null +++ b/src/lib/antigravity/JsonSchemaUtils.ts @@ -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 = {}; + + // 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) { + 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; + } + } + } +} diff --git a/src/lib/antigravity/ModelMapping.ts b/src/lib/antigravity/ModelMapping.ts new file mode 100644 index 0000000..196f9bc --- /dev/null +++ b/src/lib/antigravity/ModelMapping.ts @@ -0,0 +1,164 @@ +const logger = { + log: (msg: string) => console.log(msg), + warn: (msg: string) => console.warn(msg), +}; + +const CLAUDE_TO_GEMINI: Record = { + // 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, + openaiMapping: Record, + anthropicMapping: Record, +): 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); +} diff --git a/src/lib/antigravity/QuotaService.ts b/src/lib/antigravity/QuotaService.ts new file mode 100644 index 0000000..467fd41 --- /dev/null +++ b/src/lib/antigravity/QuotaService.ts @@ -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( + `${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(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 + } +} diff --git a/src/lib/antigravity/SignatureStore.ts b/src/lib/antigravity/SignatureStore.ts new file mode 100644 index 0000000..3cc20a9 --- /dev/null +++ b/src/lib/antigravity/SignatureStore.ts @@ -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(); diff --git a/src/lib/antigravity/types.ts b/src/lib/antigravity/types.ts new file mode 100644 index 0000000..3d771fa --- /dev/null +++ b/src/lib/antigravity/types.ts @@ -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; + /** 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; + /** 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; + 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; + /** Google Search Retrieval Tool */ + googleSearchRetrieval?: Record; +} + +/** + * 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; + /** Call ID */ + id?: string; +} + +/** + * Gemini Function Response + */ +export interface FunctionResponse { + /** Function name */ + name: string; + /** Function response result */ + response: Record; + /** 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; + 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; +} + +// --- 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; + }; +} diff --git a/src/server/modules/proxy/clients/gemini.client.ts b/src/server/modules/proxy/clients/gemini.client.ts index e2bae94..c5b6721 100644 --- a/src/server/modules/proxy/clients/gemini.client.ts +++ b/src/server/modules/proxy/clients/gemini.client.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import axios from 'axios'; import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces'; +import { GeminiInternalRequest } from '../../../../lib/antigravity/types'; @Injectable() export class GeminiClient { @@ -56,4 +57,56 @@ export class GeminiClient { throw error; } } + + // --- Antigravity Internal API Support --- + + private readonly internalBaseUrl = 'https://cloudcode-pa.googleapis.com/v1internal'; + + async streamGenerateInternal(body: GeminiInternalRequest, accessToken: string): Promise { + 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 { + 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; + } } diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index d00f62b..6cdc19c 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -1,8 +1,12 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject } from '@nestjs/common'; import { TokenManagerService } from './token-manager.service'; import { GeminiClient } from './clients/gemini.client'; import { v4 as uuidv4 } from 'uuid'; 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 { OpenAIChatRequest, AnthropicChatRequest, @@ -22,8 +26,8 @@ export class ProxyService { private readonly logger = new Logger(ProxyService.name); constructor( - private readonly tokenManager: TokenManagerService, - private readonly geminiClient: GeminiClient, + @Inject(TokenManagerService) private readonly tokenManager: TokenManagerService, + @Inject(GeminiClient) private readonly geminiClient: GeminiClient, ) {} // --- Anthropic Handlers --- @@ -47,23 +51,24 @@ export class ProxyService { } try { - // Convert Anthropic -> Gemini - const geminiRequest = this.convertAnthropicToGemini(request); + // Use Antigravity Mapper + 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) { - const stream = await this.geminiClient.streamGenerate( - targetModel, - geminiRequest, + const stream = await this.geminiClient.streamGenerateInternal( + geminiBody, token.token.access_token, ); - return this.processAnthropicStreamResponse(stream, request.model); + return this.processAnthropicInternalStream(stream, geminiBody.model); } else { - const response = await this.geminiClient.generate( - targetModel, - geminiRequest, + const response = await this.geminiClient.generateInternal( + geminiBody, token.token.access_token, ); - return this.convertGeminiToAnthropicResponse(response, request.model); + return transformResponse(response) as unknown as AnthropicChatResponse; } } catch (error) { lastError = error; @@ -78,6 +83,7 @@ export class ProxyService { throw lastError || new Error('Request failed after retries'); } + // Old converter kept for reference or other uses private convertAnthropicToGemini(request: AnthropicChatRequest): GeminiRequest { const contents: GeminiContent[] = []; let systemInstruction: { parts: GeminiPart[] } | undefined = undefined; @@ -127,12 +133,10 @@ export class ProxyService { topP: request.top_p, 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( geminiResponse: GeminiResponse, model: string, @@ -157,36 +161,16 @@ export class ProxyService { }; } - private processAnthropicStreamResponse(upstreamStream: any, model: string): Observable { + private processAnthropicInternalStream(upstreamStream: any, model: string): Observable { return new Observable((subscriber) => { const decoder = new TextDecoder(); let buffer = ''; - const msgId = `msg_${uuidv4()}`; - // Send Message Start - subscriber.next( - `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`, - ); + const state = new StreamingState(); + const processor = new PartProcessor(state); - subscriber.next( - `event: content_block_start\ndata: ${JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'text', text: '' }, - })}\n\n`, - ); + let lastFinishReason: string | undefined; + let lastUsageMetadata: any | undefined; upstreamStream.on('data', (chunk: Buffer) => { buffer += decoder.decode(chunk, { stream: true }); @@ -201,45 +185,35 @@ export class ProxyService { try { const json = JSON.parse(dataStr); - const candidate = json.candidates?.[0]; - const contentPart = candidate?.content?.parts?.[0]; - const text = contentPart?.text || ''; - if (text) { - subscriber.next( - `event: content_block_delta\ndata: ${JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'text_delta', text: text }, - })}\n\n`, - ); + if (json) { + const startMsg = state.emitMessageStart(json); + if (startMsg) subscriber.next(startMsg); } - } 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', () => { - subscriber.next( - `event: content_block_stop\ndata: ${JSON.stringify({ - 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`, - ); - + const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata); + finishChunks.forEach((c) => subscriber.next(c)); subscriber.complete(); }); @@ -269,24 +243,31 @@ export class ProxyService { } try { - // 2. Convert Request - const geminiRequest = this.convertOpenAIToGemini(request); + // Convert OpenAI request to Claude format for Antigravity pipeline + 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) { - const stream = await this.geminiClient.streamGenerate( - targetModel, - geminiRequest, + const stream = await this.geminiClient.streamGenerateInternal( + geminiBody, token.token.access_token, ); return this.processStreamResponse(stream, request.model); } else { - const response = await this.geminiClient.generate( - targetModel, - geminiRequest, + const response = await this.geminiClient.generateInternal( + geminiBody, 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) { 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 { const lower = model.toLowerCase(); if (lower.includes('sonnet') || lower.includes('thinking')) return 'gemini-3-pro-preview'; diff --git a/src/server/modules/proxy/token-manager.service.ts b/src/server/modules/proxy/token-manager.service.ts index be05966..002efdc 100644 --- a/src/server/modules/proxy/token-manager.service.ts +++ b/src/server/modules/proxy/token-manager.service.ts @@ -65,7 +65,6 @@ export class TokenManagerService implements OnModuleInit { } private generateSessionId(): string { - // Match the Rust implementation's session ID format const min = 1_000_000_000_000_000_000n; const max = 9_000_000_000_000_000_000n; const range = max - min;