feat: implement protocol parity and harden upstream handling (#88)
* feat: implement protocol parity and harden upstream handling * docs(agents): add lodash-es utility method and named import guideline
This commit is contained in:
23 files changed
+4408
-654
No files matched your search
@@ -123,6 +123,8 @@ Use `npm` for all commands:
|
|||||||
- Tools/config: camelCase or kebab-case
|
- Tools/config: camelCase or kebab-case
|
||||||
- **Import paths**: Use `@/` alias for `src/`.
|
- **Import paths**: Use `@/` alias for `src/`.
|
||||||
- **Type safety**: Avoid `any`; enforce end-to-end type safety with Zod + TypeScript.
|
- **Type safety**: Avoid `any`; enforce end-to-end type safety with Zod + TypeScript.
|
||||||
|
- **Utility methods**: Prefer `lodash-es` over native JavaScript utilities for array/object/string transformations to improve consistency and maintainability.
|
||||||
|
- Use named imports (for example, `import { get, groupBy, uniqBy } from 'lodash-es'`), and avoid full-package imports.
|
||||||
- **Component design**:
|
- **Component design**:
|
||||||
- Prefer Radix UI Primitives.
|
- Prefer Radix UI Primitives.
|
||||||
- Use Tailwind utility classes; avoid CSS Modules unless necessary.
|
- Use Tailwind utility classes; avoid CSS Modules unless necessary.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function transformClaudeRequestIn(
|
|||||||
const toolIdToName = new Map<string, string>();
|
const toolIdToName = new Map<string, string>();
|
||||||
|
|
||||||
// 1. System Instruction
|
// 1. System Instruction
|
||||||
const systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
|
const systemInstruction = buildSystemInstruction(claudeReq.system);
|
||||||
|
|
||||||
// Map model name
|
// Map model name
|
||||||
const mappedModel = hasWebSearchTool
|
const mappedModel = hasWebSearchTool
|
||||||
@@ -200,32 +200,24 @@ function resolveRequestConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasNetworkingTool = detectsNetworkingTool(tools);
|
const hasNetworkingTool = detectsNetworkingTool(tools);
|
||||||
const hasNonNetworking = containsNonNetworkingTool(tools);
|
|
||||||
|
|
||||||
// Strip -online suffix
|
// Strip -online suffix
|
||||||
const isOnlineSuffix = originalModel.endsWith('-online');
|
const isOnlineSuffix = originalModel.endsWith('-online');
|
||||||
|
|
||||||
const isHighQualityModel =
|
const enableNetworking = isOnlineSuffix || hasNetworkingTool;
|
||||||
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$/, '');
|
let finalModel = mappedModel.replace(/-online$/, '');
|
||||||
|
|
||||||
|
if (finalModel === 'gemini-3-pro-preview') {
|
||||||
|
finalModel = 'gemini-3-pro-high';
|
||||||
|
} else if (finalModel === 'gemini-3-pro-image-preview') {
|
||||||
|
finalModel = 'gemini-3-pro-image';
|
||||||
|
} else if (finalModel === 'gemini-3-flash-preview') {
|
||||||
|
finalModel = 'gemini-3-flash';
|
||||||
|
}
|
||||||
|
|
||||||
if (enableNetworking) {
|
if (enableNetworking) {
|
||||||
// Fallback for search compatibility
|
if (finalModel !== 'gemini-2.5-flash') {
|
||||||
if (finalModel.includes('thinking') || !finalModel.startsWith('gemini-')) {
|
|
||||||
finalModel = 'gemini-2.5-flash';
|
finalModel = 'gemini-2.5-flash';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,59 +306,6 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea
|
|||||||
return false;
|
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
|
* Inject Google Search Tool
|
||||||
* Adds googleSearch tool to the request
|
* Adds googleSearch tool to the request
|
||||||
@@ -390,41 +329,39 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds system instruction
|
* Builds system instruction
|
||||||
* Converts Claude system prompts to Gemini format, with Antigravity Identity Injection
|
* Converts Claude system prompts to Gemini format with a default assistant identity directive.
|
||||||
*/
|
*/
|
||||||
function buildSystemInstruction(
|
function buildSystemInstruction(
|
||||||
system: ClaudeRequest['system'],
|
system: ClaudeRequest['system'],
|
||||||
modelName: string,
|
|
||||||
): { parts: { text: string }[] } | null {
|
): { parts: { text: string }[] } | null {
|
||||||
const antigravity_identity =
|
const assistantIdentityDirective =
|
||||||
'--- [IDENTITY_PATCH] ---\n' +
|
|
||||||
'Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n' +
|
|
||||||
'You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\n' +
|
'You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\n' +
|
||||||
'You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\n' +
|
'You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\n' +
|
||||||
'**Absolute paths only**\n' +
|
'**Absolute paths only**\n' +
|
||||||
'**Proactiveness**';
|
'**Proactiveness**';
|
||||||
|
const identityMarker = 'You are Antigravity';
|
||||||
|
|
||||||
const parts: { text: string }[] = [];
|
const parts: { text: string }[] = [];
|
||||||
|
|
||||||
// Check if user already provided Antigravity identity
|
let hasIdentityDirective = false;
|
||||||
let userHasAntigravity = false;
|
|
||||||
|
|
||||||
if (system) {
|
if (system) {
|
||||||
if (typeof system === 'string') {
|
if (typeof system === 'string') {
|
||||||
if (system.includes('You are Antigravity')) userHasAntigravity = true;
|
if (system.includes(identityMarker)) {
|
||||||
|
hasIdentityDirective = true;
|
||||||
|
}
|
||||||
} else if (Array.isArray(system)) {
|
} else if (Array.isArray(system)) {
|
||||||
for (const block of system) {
|
for (const block of system) {
|
||||||
if (block.type === 'text' && block.text.includes('You are Antigravity')) {
|
if (block.type === 'text' && block.text.includes(identityMarker)) {
|
||||||
userHasAntigravity = true;
|
hasIdentityDirective = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject if missing
|
if (!hasIdentityDirective) {
|
||||||
if (!userHasAntigravity) {
|
parts.push({ text: assistantIdentityDirective });
|
||||||
parts.push({ text: antigravity_identity });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (system) {
|
if (system) {
|
||||||
@@ -437,10 +374,6 @@ function buildSystemInstruction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userHasAntigravity) {
|
|
||||||
parts.push({ text: '\n--- [SYSTEM_PROMPT_END] ---' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we pushed at least something
|
// If we pushed at least something
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
return { parts };
|
return { parts };
|
||||||
@@ -635,7 +568,22 @@ function buildGenerationConfig(
|
|||||||
hasWebSearch: boolean,
|
hasWebSearch: boolean,
|
||||||
mappedModel: string,
|
mappedModel: string,
|
||||||
): GenerationConfig {
|
): GenerationConfig {
|
||||||
|
const source = String(claudeReq.metadata?.source || '').toLowerCase();
|
||||||
|
const isOpenAIPath = source === 'openai';
|
||||||
const config: GenerationConfig = {};
|
const config: GenerationConfig = {};
|
||||||
|
|
||||||
|
if (isOpenAIPath) {
|
||||||
|
config.temperature = claudeReq.temperature ?? 1.0;
|
||||||
|
config.topP = claudeReq.top_p ?? 0.95;
|
||||||
|
if (claudeReq.max_tokens !== undefined) {
|
||||||
|
config.maxOutputTokens = claudeReq.max_tokens;
|
||||||
|
}
|
||||||
|
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||||
|
config.stopSequences = claudeReq.stop_sequences;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
if (claudeReq.thinking?.type === 'enabled') {
|
if (claudeReq.thinking?.type === 'enabled') {
|
||||||
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
||||||
if (claudeReq.thinking.budget_tokens) {
|
if (claudeReq.thinking.budget_tokens) {
|
||||||
@@ -655,7 +603,9 @@ function buildGenerationConfig(
|
|||||||
if (claudeReq.top_k !== undefined) {
|
if (claudeReq.top_k !== undefined) {
|
||||||
config.topK = claudeReq.top_k;
|
config.topK = claudeReq.top_k;
|
||||||
}
|
}
|
||||||
config.maxOutputTokens = 64000;
|
if (claudeReq.max_tokens !== undefined) {
|
||||||
|
config.maxOutputTokens = claudeReq.max_tokens;
|
||||||
|
}
|
||||||
config.stopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
|
config.stopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ class NonStreamingProcessor {
|
|||||||
const usage: Usage = {
|
const usage: Usage = {
|
||||||
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
|
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
|
||||||
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
|
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -235,7 +237,6 @@ class NonStreamingProcessor {
|
|||||||
model: geminiResponse.modelVersion || '',
|
model: geminiResponse.modelVersion || '',
|
||||||
content: this.contentBlocks,
|
content: this.contentBlocks,
|
||||||
stop_reason: stopReason,
|
stop_reason: stopReason,
|
||||||
stop_sequence: null,
|
|
||||||
usage: usage,
|
usage: usage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { logger } from '../../utils/logger';
|
|||||||
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||||
// Directly supported models
|
// Directly supported models
|
||||||
'claude-opus-4-5-thinking': 'claude-opus-4-5-thinking',
|
'claude-opus-4-5-thinking': 'claude-opus-4-5-thinking',
|
||||||
|
'claude-opus-4-6-thinking': 'claude-opus-4-6-thinking',
|
||||||
'claude-sonnet-4-5': 'claude-sonnet-4-5',
|
'claude-sonnet-4-5': 'claude-sonnet-4-5',
|
||||||
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
||||||
|
|
||||||
@@ -12,21 +13,23 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
|||||||
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-5',
|
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-5',
|
||||||
'claude-opus-4': 'claude-opus-4-5-thinking',
|
'claude-opus-4': 'claude-opus-4-5-thinking',
|
||||||
'claude-opus-4-5-20251101': 'claude-opus-4-5-thinking',
|
'claude-opus-4-5-20251101': 'claude-opus-4-5-thinking',
|
||||||
|
'claude-opus-4-6': 'claude-opus-4-6-thinking',
|
||||||
|
'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking',
|
||||||
'claude-haiku-4': 'claude-sonnet-4-5',
|
'claude-haiku-4': 'claude-sonnet-4-5',
|
||||||
'claude-3-haiku-20240307': 'claude-sonnet-4-5',
|
'claude-3-haiku-20240307': 'claude-sonnet-4-5',
|
||||||
'claude-haiku-4-5-20251001': 'claude-sonnet-4-5',
|
'claude-haiku-4-5-20251001': 'claude-sonnet-4-5',
|
||||||
|
|
||||||
// OpenAI Protocol Mapping
|
// OpenAI Protocol Mapping
|
||||||
'gpt-4': 'gemini-2.5-pro',
|
'gpt-4': 'gemini-2.5-flash',
|
||||||
'gpt-4-turbo': 'gemini-2.5-pro',
|
'gpt-4-turbo': 'gemini-2.5-flash',
|
||||||
'gpt-4-turbo-preview': 'gemini-2.5-pro',
|
'gpt-4-turbo-preview': 'gemini-2.5-flash',
|
||||||
'gpt-4-0125-preview': 'gemini-2.5-pro',
|
'gpt-4-0125-preview': 'gemini-2.5-flash',
|
||||||
'gpt-4-1106-preview': 'gemini-2.5-pro',
|
'gpt-4-1106-preview': 'gemini-2.5-flash',
|
||||||
'gpt-4-0613': 'gemini-2.5-pro',
|
'gpt-4-0613': 'gemini-2.5-flash',
|
||||||
|
|
||||||
'gpt-4o': 'gemini-2.5-pro',
|
'gpt-4o': 'gemini-2.5-flash',
|
||||||
'gpt-4o-2024-05-13': 'gemini-2.5-pro',
|
'gpt-4o-2024-05-13': 'gemini-2.5-flash',
|
||||||
'gpt-4o-2024-08-06': 'gemini-2.5-pro',
|
'gpt-4o-2024-08-06': 'gemini-2.5-flash',
|
||||||
|
|
||||||
'gpt-4o-mini': 'gemini-2.5-flash',
|
'gpt-4o-mini': 'gemini-2.5-flash',
|
||||||
'gpt-4o-mini-2024-07-18': 'gemini-2.5-flash',
|
'gpt-4o-mini-2024-07-18': 'gemini-2.5-flash',
|
||||||
@@ -38,29 +41,68 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
|||||||
'gpt-3.5-turbo-0613': 'gemini-2.5-flash',
|
'gpt-3.5-turbo-0613': 'gemini-2.5-flash',
|
||||||
|
|
||||||
// Gemini Protocol Mapping
|
// Gemini Protocol Mapping
|
||||||
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
|
'gemini-2.5-flash-lite': 'gemini-2.5-flash',
|
||||||
'gemini-2.5-flash-thinking': 'gemini-2.5-flash-thinking',
|
'gemini-2.5-flash-thinking': 'gemini-2.5-flash-thinking',
|
||||||
'gemini-3-pro-low': 'gemini-3-pro-low',
|
'gemini-3-pro-low': 'gemini-3-pro-preview',
|
||||||
'gemini-3-pro-high': 'gemini-3-pro-high',
|
'gemini-3-pro-high': 'gemini-3-pro-preview',
|
||||||
'gemini-3-pro-preview': 'gemini-3-pro-preview',
|
'gemini-3-pro-preview': 'gemini-3-pro-preview',
|
||||||
|
'gemini-3-pro': 'gemini-3-pro-preview',
|
||||||
'gemini-2.5-flash': 'gemini-2.5-flash',
|
'gemini-2.5-flash': 'gemini-2.5-flash',
|
||||||
'gemini-3-flash': 'gemini-3-flash',
|
'gemini-3-flash': 'gemini-3-flash',
|
||||||
'gemini-3-pro-image': 'gemini-3-pro-image',
|
'gemini-3-pro-image': 'gemini-3-pro-image',
|
||||||
|
'internal-background-task': 'gemini-2.5-flash',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
|
||||||
|
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
|
||||||
|
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
|
||||||
|
const EXTRA_DYNAMIC_MODELS = [
|
||||||
|
'gemini-2.0-flash-exp',
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
'gemini-3-flash',
|
||||||
|
'gemini-3-pro-high',
|
||||||
|
'gemini-3-pro-low',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MODEL_LIST_CREATED_AT = 1770652800;
|
||||||
|
|
||||||
|
export const MODEL_LIST_OWNER = 'antigravity';
|
||||||
|
|
||||||
|
export function getSupportedModels(): string[] {
|
||||||
|
return Object.keys(CLAUDE_TO_GEMINI);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllDynamicModels(customMapping: Record<string, string> = {}): string[] {
|
||||||
|
const modelIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const modelId of getSupportedModels()) {
|
||||||
|
modelIds.add(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const customModelId of Object.keys(customMapping)) {
|
||||||
|
modelIds.add(customModelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const resolution of DYNAMIC_IMAGE_RESOLUTIONS) {
|
||||||
|
for (const ratio of DYNAMIC_IMAGE_RATIOS) {
|
||||||
|
modelIds.add(`${DYNAMIC_IMAGE_BASE_MODEL}${resolution}${ratio}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const modelId of EXTRA_DYNAMIC_MODELS) {
|
||||||
|
modelIds.add(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...modelIds].sort();
|
||||||
|
}
|
||||||
|
|
||||||
export function mapClaudeModelToGemini(input: string): string {
|
export function mapClaudeModelToGemini(input: string): string {
|
||||||
// 1. Check exact match in map
|
const mappedModel = CLAUDE_TO_GEMINI[input];
|
||||||
if (CLAUDE_TO_GEMINI[input]) {
|
if (mappedModel) {
|
||||||
return CLAUDE_TO_GEMINI[input];
|
return mappedModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Pass-through known prefixes (gemini-, -thinking) to support dynamic suffixes
|
return input;
|
||||||
if (input.startsWith('gemini-') || input.includes('thinking')) {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fallback to default
|
|
||||||
return 'claude-sonnet-4-5';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface ClaudeRequest {
|
|||||||
tools?: Tool[];
|
tools?: Tool[];
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
|
stop_sequences?: string[];
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
top_k?: number;
|
top_k?: number;
|
||||||
@@ -141,6 +142,8 @@ export interface Tool {
|
|||||||
|
|
||||||
export interface Metadata {
|
export interface Metadata {
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
|
source?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -336,13 +339,15 @@ export interface ClaudeResponse {
|
|||||||
model: string;
|
model: string;
|
||||||
content: ContentBlock[];
|
content: ContentBlock[];
|
||||||
stop_reason: string;
|
stop_reason: string;
|
||||||
stop_sequence: string | null;
|
stop_sequence?: string | null;
|
||||||
usage: Usage;
|
usage: Usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Usage {
|
export interface Usage {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
|
cache_creation_input_tokens?: number;
|
||||||
|
cache_read_input_tokens?: number;
|
||||||
/** Server tool usage stats */
|
/** Server tool usage stats */
|
||||||
server_tool_use?: ServerToolUse;
|
server_tool_use?: ServerToolUse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ i18n
|
|||||||
claudeKeyword: 'Claude Model (Keyword)',
|
claudeKeyword: 'Claude Model (Keyword)',
|
||||||
targetGemini: 'Target Gemini Model',
|
targetGemini: 'Target Gemini Model',
|
||||||
addPlaceholderKey: 'e.g. op-3',
|
addPlaceholderKey: 'e.g. op-3',
|
||||||
addPlaceholderValue: 'e.g. gemini-2.0-flash-exp',
|
addPlaceholderValue: 'e.g. gemini-2.5-flash',
|
||||||
noMappings: 'No custom mappings defined.',
|
noMappings: 'No custom mappings defined.',
|
||||||
mapsTo: 'Maps to',
|
mapsTo: 'Maps to',
|
||||||
default: 'Default',
|
default: 'Default',
|
||||||
@@ -474,7 +474,7 @@ i18n
|
|||||||
claudeKeyword: 'Claude 模型 (关键词)',
|
claudeKeyword: 'Claude 模型 (关键词)',
|
||||||
targetGemini: '目标 Gemini 模型',
|
targetGemini: '目标 Gemini 模型',
|
||||||
addPlaceholderKey: '例如 op-3',
|
addPlaceholderKey: '例如 op-3',
|
||||||
addPlaceholderValue: '例如 gemini-2.0-flash-exp',
|
addPlaceholderValue: '例如 gemini-2.5-flash',
|
||||||
noMappings: '暂无自定义映射。',
|
noMappings: '暂无自定义映射。',
|
||||||
mapsTo: '映射到',
|
mapsTo: '映射到',
|
||||||
default: '默认',
|
default: '默认',
|
||||||
@@ -816,7 +816,7 @@ i18n
|
|||||||
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
||||||
targetGemini: 'Целевая модель Gemini',
|
targetGemini: 'Целевая модель Gemini',
|
||||||
addPlaceholderKey: 'например, op-3',
|
addPlaceholderKey: 'например, op-3',
|
||||||
addPlaceholderValue: 'например, gemini-2.0-flash-exp',
|
addPlaceholderValue: 'например, gemini-2.5-flash',
|
||||||
noMappings: 'Нет пользовательских сопоставлений.',
|
noMappings: 'Нет пользовательских сопоставлений.',
|
||||||
mapsTo: 'Мапится в',
|
mapsTo: 'Мапится в',
|
||||||
default: 'По умолчанию',
|
default: 'По умолчанию',
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { getServerConfig } from '../server-config';
|
||||||
|
import { extractApiKeyToken, hasConfiguredApiKey, RequestHeaders } from './api-key-auth.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const config = getServerConfig();
|
||||||
|
const apiKey = config?.api_key;
|
||||||
|
|
||||||
|
if (!hasConfiguredApiKey(apiKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const headers = request.headers as RequestHeaders;
|
||||||
|
const clientToken = extractApiKeyToken(headers);
|
||||||
|
|
||||||
|
if (clientToken && clientToken === apiKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnauthorizedException('API key validation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type RequestHeaderValue = string | string[] | undefined;
|
||||||
|
|
||||||
|
export type RequestHeaders = Record<string, RequestHeaderValue>;
|
||||||
|
|
||||||
|
export function hasConfiguredApiKey(apiKey: string | undefined): apiKey is string {
|
||||||
|
return typeof apiKey === 'string' && apiKey.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractApiKeyToken(headers: RequestHeaders): string | null {
|
||||||
|
const authorizationHeader = readHeaderValue(headers['authorization']);
|
||||||
|
if (authorizationHeader) {
|
||||||
|
const [scheme, token, ...rest] = authorizationHeader.trim().split(/\s+/);
|
||||||
|
if (rest.length === 0 && token && scheme?.toLowerCase() === 'bearer') {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const xApiKeyHeader = readHeaderValue(headers['x-api-key']);
|
||||||
|
if (xApiKeyHeader) {
|
||||||
|
return xApiKeyHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xGoogApiKeyHeader = readHeaderValue(headers['x-goog-api-key']);
|
||||||
|
if (xGoogApiKeyHeader) {
|
||||||
|
return xGoogApiKeyHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHeaderValue(headerValue: RequestHeaderValue): string | null {
|
||||||
|
if (typeof headerValue === 'string') {
|
||||||
|
const trimmedValue = headerValue.trim();
|
||||||
|
return trimmedValue !== '' ? trimmedValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(headerValue)) {
|
||||||
|
for (const value of headerValue) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (trimmedValue !== '') {
|
||||||
|
return trimmedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import axios from 'axios';
|
import axios, { AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import { isNil } from 'lodash-es';
|
||||||
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
|
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
|
||||||
import { GeminiInternalRequest } from '../../../../lib/antigravity/types';
|
import { GeminiInternalRequest } from '../../../../lib/antigravity/types';
|
||||||
|
import { getServerConfig } from '../../../server-config';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GeminiClient {
|
export class GeminiClient {
|
||||||
private readonly logger = new Logger(GeminiClient.name);
|
private readonly logger = new Logger(GeminiClient.name);
|
||||||
|
private readonly defaultRequestUserAgent = 'antigravity/1.11.9 windows/amd64';
|
||||||
// Default to v1beta for most features
|
// Default to v1beta for most features
|
||||||
private readonly baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
private readonly baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
||||||
|
private readonly defaultInternalBaseUrls = [
|
||||||
|
'https://cloudcode-pa.googleapis.com/v1internal',
|
||||||
|
'https://daily-cloudcode-pa.googleapis.com/v1internal',
|
||||||
|
];
|
||||||
|
|
||||||
async streamGenerate(model: string, content: GeminiRequest, accessToken: string): Promise<any> {
|
async streamGenerate(
|
||||||
|
model: string,
|
||||||
|
content: GeminiRequest,
|
||||||
|
accessToken: string,
|
||||||
|
upstreamProxyUrl?: string,
|
||||||
|
): Promise<NodeJS.ReadableStream> {
|
||||||
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
|
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
|
||||||
|
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(url, content, {
|
const response = await axios.post(url, content, {
|
||||||
@@ -20,15 +33,15 @@ export class GeminiClient {
|
|||||||
},
|
},
|
||||||
responseType: 'stream',
|
responseType: 'stream',
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
|
proxy: axiosProxy,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
this.logger.error(`Gemini Stream API Error: ${error.message}`);
|
this.logger.error(`Gemini stream request failed: ${error.message}`);
|
||||||
throw new Error(error.response?.data?.error?.message || error.message);
|
throw new Error(error.response?.data?.error?.message || error.message);
|
||||||
}
|
}
|
||||||
// Re-throw as clean Error to avoid circular reference issues
|
this.throwAsCleanError(error);
|
||||||
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +49,10 @@ export class GeminiClient {
|
|||||||
model: string,
|
model: string,
|
||||||
content: GeminiRequest,
|
content: GeminiRequest,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
upstreamProxyUrl?: string,
|
||||||
): Promise<GeminiResponse> {
|
): Promise<GeminiResponse> {
|
||||||
const url = `${this.baseUrl}/models/${model}:generateContent`;
|
const url = `${this.baseUrl}/models/${model}:generateContent`;
|
||||||
|
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<GeminiResponse>(url, content, {
|
const response = await axios.post<GeminiResponse>(url, content, {
|
||||||
@@ -46,68 +61,221 @@ export class GeminiClient {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
timeout: 60000, // 60s timeout
|
timeout: 60000, // 60s timeout
|
||||||
|
proxy: axiosProxy,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Gemini API Error: ${error.message} - ${this.safeStringify(error.response?.data)}`,
|
`Gemini request failed: ${error.message} - ${this.safeStringify(error.response?.data)}`,
|
||||||
);
|
);
|
||||||
throw new Error(error.response?.data?.error?.message || error.message);
|
throw new Error(error.response?.data?.error?.message || error.message);
|
||||||
}
|
}
|
||||||
// Re-throw as clean Error to avoid circular reference issues
|
this.throwAsCleanError(error);
|
||||||
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Antigravity Internal API Support ---
|
// --- Internal Gateway API Support ---
|
||||||
|
|
||||||
private readonly internalBaseUrl = 'https://cloudcode-pa.googleapis.com/v1internal';
|
async streamGenerateInternal(
|
||||||
|
body: GeminiInternalRequest,
|
||||||
async streamGenerateInternal(body: GeminiInternalRequest, accessToken: string): Promise<any> {
|
accessToken: string,
|
||||||
const url = `${this.internalBaseUrl}:streamGenerateContent?alt=sse`;
|
upstreamProxyUrl?: string,
|
||||||
try {
|
extraHeaders?: Record<string, string>,
|
||||||
const response = await axios.post(url, body, {
|
): Promise<NodeJS.ReadableStream> {
|
||||||
headers: {
|
const response = await this.requestWithEndpointFailover<NodeJS.ReadableStream>(
|
||||||
Authorization: `Bearer ${accessToken}`,
|
':streamGenerateContent?alt=sse',
|
||||||
'Content-Type': 'application/json',
|
body,
|
||||||
'User-Agent': 'antigravity/1.11.9 windows/amd64',
|
accessToken,
|
||||||
},
|
upstreamProxyUrl,
|
||||||
|
{
|
||||||
responseType: 'stream',
|
responseType: 'stream',
|
||||||
timeout: 60000,
|
},
|
||||||
});
|
'stream-generate',
|
||||||
return response.data;
|
extraHeaders,
|
||||||
} catch (error) {
|
);
|
||||||
this.handleAxiosError(error, 'StreamInternal');
|
|
||||||
}
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateInternal(body: GeminiInternalRequest, accessToken: string): Promise<any> {
|
async generateInternal(
|
||||||
const url = `${this.internalBaseUrl}:generateContent`;
|
body: GeminiInternalRequest,
|
||||||
try {
|
accessToken: string,
|
||||||
const response = await axios.post(url, body, {
|
upstreamProxyUrl?: string,
|
||||||
headers: {
|
extraHeaders?: Record<string, string>,
|
||||||
Authorization: `Bearer ${accessToken}`,
|
): Promise<GeminiResponse> {
|
||||||
'Content-Type': 'application/json',
|
const response = await this.requestWithEndpointFailover<
|
||||||
'User-Agent': 'antigravity/1.11.9 windows/amd64',
|
GeminiResponse | { response: GeminiResponse }
|
||||||
},
|
>(
|
||||||
timeout: 60000,
|
':generateContent',
|
||||||
});
|
body,
|
||||||
// v1internal API wraps the response in a 'response' field, unwrap it
|
accessToken,
|
||||||
return response.data.response || response.data;
|
upstreamProxyUrl,
|
||||||
} catch (error) {
|
{},
|
||||||
this.handleAxiosError(error, 'GenerateInternal');
|
'generate-content',
|
||||||
|
extraHeaders,
|
||||||
|
);
|
||||||
|
const payload = response.data;
|
||||||
|
if (payload && typeof payload === 'object' && 'response' in payload) {
|
||||||
|
return payload.response;
|
||||||
}
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAxiosError(error: any, context: string) {
|
private getInternalBaseUrls(): string[] {
|
||||||
|
const fromEnv =
|
||||||
|
process.env.PROXY_INTERNAL_BASE_URLS ?? process.env.ANTIGRAVITY_INTERNAL_BASE_URLS;
|
||||||
|
const configuredBaseUrls = fromEnv
|
||||||
|
?.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (configuredBaseUrls && configuredBaseUrls.length > 0) {
|
||||||
|
return configuredBaseUrls.map((url) => url.replace(/\/+$/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.defaultInternalBaseUrls.map((url) => url.replace(/\/+$/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInternalTimeoutMs(): number {
|
||||||
|
const config = getServerConfig();
|
||||||
|
const timeoutSeconds = config?.request_timeout ?? 60;
|
||||||
|
return Math.max(1, timeoutSeconds) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSwitchEndpointOnError(error: unknown): boolean {
|
||||||
|
if (!axios.isAxiosError(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.response) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error.response.status;
|
||||||
|
|
||||||
|
// Permanent auth errors should fail fast for current token.
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return status === 408 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestWithEndpointFailover<T>(
|
||||||
|
path: string,
|
||||||
|
body: GeminiInternalRequest,
|
||||||
|
accessToken: string,
|
||||||
|
upstreamProxyUrl: string | undefined,
|
||||||
|
config: AxiosRequestConfig,
|
||||||
|
operation: string,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
const baseUrls = this.getInternalBaseUrls();
|
||||||
|
const timeout = this.getInternalTimeoutMs();
|
||||||
|
const requestUserAgent = process.env.PROXY_REQUEST_USER_AGENT ?? this.defaultRequestUserAgent;
|
||||||
|
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (let index = 0; index < baseUrls.length; index++) {
|
||||||
|
const baseUrl = baseUrls[index];
|
||||||
|
const url = `${baseUrl}${path}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await axios.post<T>(url, body, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': requestUserAgent,
|
||||||
|
...(extraHeaders ?? {}),
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
proxy: axiosProxy,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const hasNextEndpoint = index < baseUrls.length - 1;
|
||||||
|
|
||||||
|
if (!hasNextEndpoint || !this.shouldSwitchEndpointOnError(error)) {
|
||||||
|
this.handleAxiosError(error, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`[${operation}] request failed at ${baseUrl}; trying next endpoint (${index + 2}/${
|
||||||
|
baseUrls.length
|
||||||
|
}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleAxiosError(lastError, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosError(error: unknown, operation: string): never {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
|
const responseData = error.response?.data;
|
||||||
|
const upstreamMessage = this.extractAxiosErrorMessage(responseData);
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Gemini ${context} API Error: ${error.message} - ${this.safeStringify(error.response?.data)}`,
|
`[${operation}] upstream request error: ${error.message} - ${this.safeStringify(responseData)}`,
|
||||||
);
|
);
|
||||||
throw new Error(error.response?.data?.error?.message || error.message);
|
throw new Error(upstreamMessage || error.message);
|
||||||
}
|
}
|
||||||
// Re-throw as clean Error to avoid circular reference issues
|
this.throwAsCleanError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAxiosErrorMessage(responseData: unknown): string | null {
|
||||||
|
if (!responseData || typeof responseData !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorRecord = (responseData as { error?: unknown }).error;
|
||||||
|
if (!errorRecord || typeof errorRecord !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (errorRecord as { message?: unknown }).message;
|
||||||
|
return typeof message === 'string' && message.trim() !== '' ? message : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAxiosProxy(upstreamProxyUrl?: string): AxiosProxyConfig | false | undefined {
|
||||||
|
const config = getServerConfig();
|
||||||
|
const configuredProxyUrl =
|
||||||
|
upstreamProxyUrl ||
|
||||||
|
(config?.upstream_proxy?.enabled && config.upstream_proxy.url
|
||||||
|
? config.upstream_proxy.url
|
||||||
|
: '');
|
||||||
|
|
||||||
|
if (!configuredProxyUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(configuredProxyUrl);
|
||||||
|
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
||||||
|
|
||||||
|
const proxyConfig: AxiosProxyConfig = {
|
||||||
|
protocol: parsed.protocol.replace(':', ''),
|
||||||
|
host: parsed.hostname,
|
||||||
|
port,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
proxyConfig.auth = {
|
||||||
|
username: decodeURIComponent(parsed.username),
|
||||||
|
password: decodeURIComponent(parsed.password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyConfig;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Upstream proxy URL is invalid: ${configuredProxyUrl}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwAsCleanError(error: unknown): never {
|
||||||
|
// Re-throw as clean Error to avoid circular reference issues.
|
||||||
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
|
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +283,7 @@ export class GeminiClient {
|
|||||||
* Safely stringify an object, handling circular references
|
* Safely stringify an object, handling circular references
|
||||||
*/
|
*/
|
||||||
private safeStringify(obj: unknown): string {
|
private safeStringify(obj: unknown): string {
|
||||||
if (obj === undefined || obj === null) {
|
if (isNil(obj)) {
|
||||||
return String(obj);
|
return String(obj);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { Body, Controller, Get, HttpStatus, Inject, Param, Post, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { ProxyGuard } from './proxy.guard';
|
||||||
|
import { ProxyService } from './proxy.service';
|
||||||
|
import { GeminiRequest, GeminiResponse } from './interfaces/request-interfaces';
|
||||||
|
import { getServerConfig } from '../../server-config';
|
||||||
|
import { getAllDynamicModels } from '../../../lib/antigravity/ModelMapping';
|
||||||
|
|
||||||
|
type GeminiModelMetadata = {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
inputTokenLimit: number;
|
||||||
|
outputTokenLimit: number;
|
||||||
|
supportedGenerationMethods: string[];
|
||||||
|
temperature: number;
|
||||||
|
topK: number;
|
||||||
|
topP: number;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Controller('v1beta')
|
||||||
|
@UseGuards(ProxyGuard)
|
||||||
|
export class GeminiController {
|
||||||
|
constructor(@Inject(ProxyService) private readonly proxyService: ProxyService) {}
|
||||||
|
|
||||||
|
@Get('models')
|
||||||
|
listModels(@Res() res: FastifyReply) {
|
||||||
|
const models = this.buildGeminiModelList();
|
||||||
|
res.status(HttpStatus.OK).send({
|
||||||
|
models,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('models/:model')
|
||||||
|
getModel(@Param('model') model: string, @Res() res: FastifyReply) {
|
||||||
|
const targetName = model.startsWith('models/') ? model : `models/${model}`;
|
||||||
|
const matched = this.buildGeminiModelList().find((item) => item.name === targetName);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
res.status(HttpStatus.OK).send({
|
||||||
|
name: matched.name,
|
||||||
|
displayName: matched.displayName,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(HttpStatus.OK).send({
|
||||||
|
name: targetName,
|
||||||
|
displayName: targetName.replace(/^models\//, ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('models/:modelAction')
|
||||||
|
async modelAction(
|
||||||
|
@Param('modelAction') modelAction: string,
|
||||||
|
@Body() body: GeminiRequest,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
) {
|
||||||
|
const parsed = this.parseCombinedModelAction(modelAction);
|
||||||
|
if (!parsed) {
|
||||||
|
res.status(HttpStatus.BAD_REQUEST).send({
|
||||||
|
error: {
|
||||||
|
code: HttpStatus.BAD_REQUEST,
|
||||||
|
message: 'Model action format is invalid',
|
||||||
|
status: 'INVALID_ARGUMENT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dispatchAction(parsed.model, parsed.action, body, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('models/:model/countTokens')
|
||||||
|
async countTokens(
|
||||||
|
@Param('model') model: string,
|
||||||
|
@Body() body: GeminiRequest,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
) {
|
||||||
|
await this.dispatchAction(`models/${model}`, 'countTokens', body, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatchAction(
|
||||||
|
model: string,
|
||||||
|
action: string,
|
||||||
|
body: GeminiRequest,
|
||||||
|
res: FastifyReply,
|
||||||
|
): Promise<void> {
|
||||||
|
if (action === 'countTokens') {
|
||||||
|
res.status(HttpStatus.OK).send({
|
||||||
|
totalTokens: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'streamGenerateContent') {
|
||||||
|
const stream = await this.proxyService.handleGeminiStreamGenerateContent(model, body);
|
||||||
|
if (stream instanceof Observable) {
|
||||||
|
this.sendObservableSse(res, stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'generateContent') {
|
||||||
|
const result = await this.proxyService.handleGeminiGenerateContent(model, body);
|
||||||
|
res.status(HttpStatus.OK).send(this.normalizeGeminiGenerateResponse(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(HttpStatus.BAD_REQUEST).send({
|
||||||
|
error: {
|
||||||
|
code: HttpStatus.BAD_REQUEST,
|
||||||
|
message: `Unsupported model action: ${action}`,
|
||||||
|
status: 'INVALID_ARGUMENT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
||||||
|
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
|
||||||
|
error: {
|
||||||
|
code: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
message,
|
||||||
|
status: 'INTERNAL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCombinedModelAction(
|
||||||
|
modelAction: string,
|
||||||
|
): {
|
||||||
|
model: string;
|
||||||
|
action: string;
|
||||||
|
} | null {
|
||||||
|
const colonIndex = modelAction.lastIndexOf(':');
|
||||||
|
if (colonIndex <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = modelAction.slice(0, colonIndex).trim();
|
||||||
|
const action = modelAction.slice(colonIndex + 1).trim();
|
||||||
|
if (!model || !action) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixedModel = model.startsWith('models/') ? model : `models/${model}`;
|
||||||
|
return {
|
||||||
|
model: prefixedModel,
|
||||||
|
action,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGeminiModelList(): GeminiModelMetadata[] {
|
||||||
|
const config = getServerConfig();
|
||||||
|
const dynamicModelIds = getAllDynamicModels(config?.custom_mapping ?? {});
|
||||||
|
|
||||||
|
return dynamicModelIds.map((id) => this.toGeminiModelMetadata(`models/${id}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toGeminiModelMetadata(modelName: string): GeminiModelMetadata {
|
||||||
|
const displayName = modelName.replace(/^models\//, '');
|
||||||
|
return {
|
||||||
|
name: modelName,
|
||||||
|
displayName,
|
||||||
|
description: '',
|
||||||
|
inputTokenLimit: 128000,
|
||||||
|
outputTokenLimit: 8192,
|
||||||
|
supportedGenerationMethods: ['generateContent', 'countTokens'],
|
||||||
|
temperature: 1,
|
||||||
|
topK: 64,
|
||||||
|
topP: 0.95,
|
||||||
|
version: '001',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeGeminiGenerateResponse(response: GeminiResponse): GeminiResponse {
|
||||||
|
const candidates = (response.candidates ?? []).map((candidate, index) => ({
|
||||||
|
content: candidate.content,
|
||||||
|
finishReason: candidate.finishReason,
|
||||||
|
index: typeof candidate.index === 'number' ? candidate.index : index,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const normalized: GeminiResponse = {
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.usageMetadata) {
|
||||||
|
const usageMetadata = this.normalizeGeminiUsageMetadata(response.usageMetadata);
|
||||||
|
if (!isEmpty(usageMetadata)) {
|
||||||
|
normalized.usageMetadata = usageMetadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeGeminiUsageMetadata(
|
||||||
|
usageMetadata: GeminiResponse['usageMetadata'],
|
||||||
|
): NonNullable<GeminiResponse['usageMetadata']> {
|
||||||
|
const normalized: NonNullable<GeminiResponse['usageMetadata']> = {};
|
||||||
|
if (usageMetadata?.promptTokenCount !== undefined) {
|
||||||
|
normalized.promptTokenCount = usageMetadata.promptTokenCount;
|
||||||
|
}
|
||||||
|
if (usageMetadata?.candidatesTokenCount !== undefined) {
|
||||||
|
normalized.candidatesTokenCount = usageMetadata.candidatesTokenCount;
|
||||||
|
}
|
||||||
|
if (usageMetadata?.totalTokenCount !== undefined) {
|
||||||
|
normalized.totalTokenCount = usageMetadata.totalTokenCount;
|
||||||
|
}
|
||||||
|
if (usageMetadata?.thoughtsTokenCount !== undefined) {
|
||||||
|
normalized.thoughtsTokenCount = usageMetadata.thoughtsTokenCount;
|
||||||
|
}
|
||||||
|
if (usageMetadata?.promptTokensDetails !== undefined) {
|
||||||
|
normalized.promptTokensDetails = usageMetadata.promptTokensDetails;
|
||||||
|
}
|
||||||
|
if (usageMetadata?.candidatesTokensDetails !== undefined) {
|
||||||
|
normalized.candidatesTokensDetails = usageMetadata.candidatesTokensDetails;
|
||||||
|
}
|
||||||
|
if (usageMetadata?.trafficType !== undefined) {
|
||||||
|
normalized.trafficType = usageMetadata.trafficType;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendObservableSse(res: FastifyReply, stream: Observable<unknown>): void {
|
||||||
|
if (
|
||||||
|
!res.raw ||
|
||||||
|
typeof res.raw.writeHead !== 'function' ||
|
||||||
|
typeof res.raw.write !== 'function'
|
||||||
|
) {
|
||||||
|
res.header('Content-Type', 'text/event-stream');
|
||||||
|
res.header('Cache-Control', 'no-cache');
|
||||||
|
res.header('Connection', 'keep-alive');
|
||||||
|
res.send(stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasHijack(res)) {
|
||||||
|
res.hijack();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.raw.writeHead(HttpStatus.OK, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = stream.subscribe({
|
||||||
|
next: (chunk) => {
|
||||||
|
if (res.raw.writableEnded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = typeof chunk === 'string' ? chunk : String(chunk ?? '');
|
||||||
|
res.raw.write(payload);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
if (res.raw.writableEnded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
res.raw.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
type: 'server_error',
|
||||||
|
},
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
res.raw.end();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
if (!res.raw.writableEnded) {
|
||||||
|
res.raw.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.raw.on('close', () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasHijack(reply: FastifyReply): reply is FastifyReply & { hijack: () => void } {
|
||||||
|
return typeof (reply as { hijack?: unknown }).hijack === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -7,13 +7,18 @@ export interface OpenAIChatRequest {
|
|||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
size?: string;
|
size?: string;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
extra?: Record<string, any>;
|
tools?: OpenAITool[];
|
||||||
|
tool_choice?: string | { type: string; function?: { name: string } };
|
||||||
|
response_format?: { type?: string };
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAIMessage {
|
export interface OpenAIMessage {
|
||||||
role: string;
|
role: string;
|
||||||
content: string | OpenAIContentPart[];
|
content: string | OpenAIContentPart[];
|
||||||
name?: string;
|
name?: string;
|
||||||
|
tool_calls?: OpenAIToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAIContentPart {
|
export interface OpenAIContentPart {
|
||||||
@@ -24,12 +29,32 @@ export interface OpenAIContentPart {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenAITool {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIToolCall {
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnthropicChatRequest {
|
export interface AnthropicChatRequest {
|
||||||
model: string;
|
model: string;
|
||||||
messages: AnthropicMessage[];
|
messages: AnthropicMessage[];
|
||||||
system?: string | AnthropicSystemBlock[];
|
system?: string | AnthropicSystemBlock[];
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
metadata?: any;
|
tools?: AnthropicTool[];
|
||||||
|
thinking?: AnthropicThinkingConfig;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
stop_sequences?: string[];
|
stop_sequences?: string[];
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
@@ -37,6 +62,18 @@ export interface AnthropicChatRequest {
|
|||||||
top_k?: number;
|
top_k?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnthropicTool {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
input_schema?: Record<string, unknown>;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnthropicThinkingConfig {
|
||||||
|
type: 'enabled' | string;
|
||||||
|
budget_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnthropicMessage {
|
export interface AnthropicMessage {
|
||||||
role: string;
|
role: string;
|
||||||
content: string | AnthropicContent[];
|
content: string | AnthropicContent[];
|
||||||
@@ -50,7 +87,21 @@ export interface AnthropicSystemBlock {
|
|||||||
export type AnthropicContent =
|
export type AnthropicContent =
|
||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
| { type: 'thinking'; thinking: string; signature?: string }
|
| { type: 'thinking'; thinking: string; signature?: string }
|
||||||
| { type: 'image'; source: AnthropicImageSource };
|
| { type: 'image'; source: AnthropicImageSource }
|
||||||
|
| {
|
||||||
|
type: 'tool_use';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
signature?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tool_result';
|
||||||
|
tool_use_id: string;
|
||||||
|
content: string | AnthropicContent[];
|
||||||
|
is_error?: boolean;
|
||||||
|
}
|
||||||
|
| { type: 'redacted_thinking'; data: string };
|
||||||
|
|
||||||
export interface AnthropicImageSource {
|
export interface AnthropicImageSource {
|
||||||
type: 'base64';
|
type: 'base64';
|
||||||
@@ -102,6 +153,16 @@ export interface GeminiUsageMetadata {
|
|||||||
promptTokenCount?: number;
|
promptTokenCount?: number;
|
||||||
candidatesTokenCount?: number;
|
candidatesTokenCount?: number;
|
||||||
totalTokenCount?: number;
|
totalTokenCount?: number;
|
||||||
|
thoughtsTokenCount?: number;
|
||||||
|
promptTokensDetails?: Array<{
|
||||||
|
modality?: string;
|
||||||
|
tokenCount?: number;
|
||||||
|
}>;
|
||||||
|
candidatesTokensDetails?: Array<{
|
||||||
|
modality?: string;
|
||||||
|
tokenCount?: number;
|
||||||
|
}>;
|
||||||
|
trafficType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAIChatResponse {
|
export interface OpenAIChatResponse {
|
||||||
@@ -121,9 +182,11 @@ export interface OpenAIChoice {
|
|||||||
index: number;
|
index: number;
|
||||||
message: {
|
message: {
|
||||||
role: string;
|
role: string;
|
||||||
content: string;
|
content: string | null;
|
||||||
|
tool_calls?: OpenAIToolCall[];
|
||||||
|
reasoning_content?: string;
|
||||||
};
|
};
|
||||||
finish_reason: string;
|
finish_reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnthropicChatResponse {
|
export interface AnthropicChatResponse {
|
||||||
@@ -133,9 +196,11 @@ export interface AnthropicChatResponse {
|
|||||||
model: string;
|
model: string;
|
||||||
content: AnthropicContent[];
|
content: AnthropicContent[];
|
||||||
stop_reason: string | null;
|
stop_reason: string | null;
|
||||||
stop_sequence: string | null;
|
stop_sequence?: string | null;
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
|
cache_creation_input_tokens?: number;
|
||||||
|
cache_read_input_tokens?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2,56 +2,37 @@ import {
|
|||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
|
||||||
Logger,
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { getServerConfig } from '../../server-config';
|
import { getServerConfig } from '../../server-config';
|
||||||
|
import { extractApiKeyToken, hasConfiguredApiKey, RequestHeaders } from '../../guards/api-key-auth.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProxyGuard implements CanActivate {
|
export class ProxyGuard implements CanActivate {
|
||||||
private readonly logger = new Logger(ProxyGuard.name);
|
private readonly logger = new Logger(ProxyGuard.name);
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const config = getServerConfig();
|
const config = getServerConfig();
|
||||||
|
|
||||||
// 1. Check for API Key in config
|
// 1. Check for API Key in config
|
||||||
const apiKey = config?.api_key;
|
const apiKey = config?.api_key;
|
||||||
|
|
||||||
|
const headers = request.headers as RequestHeaders;
|
||||||
|
const clientToken = extractApiKeyToken(headers);
|
||||||
|
|
||||||
// 2. Bypass if no api_key set (Open Mode) or config missing
|
// 2. Bypass if no api_key set (Open Mode) or config missing
|
||||||
if (!apiKey || apiKey.trim() === '') {
|
if (!hasConfiguredApiKey(apiKey)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const headers = request.headers;
|
|
||||||
|
|
||||||
// 3. Extract Token from Headers
|
|
||||||
let clientToken: string | undefined;
|
|
||||||
|
|
||||||
// A. standard 'authorization: Bearer <token>'
|
|
||||||
if (headers['authorization']) {
|
|
||||||
const parts = headers['authorization'].split(' ');
|
|
||||||
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
|
|
||||||
clientToken = parts[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// B. 'x-api-key' (Anthropic)
|
|
||||||
if (!clientToken && headers['x-api-key']) {
|
|
||||||
clientToken = headers['x-api-key'] as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// C. 'x-goog-api-key' (Gemini)
|
|
||||||
if (!clientToken && headers['x-goog-api-key']) {
|
|
||||||
clientToken = headers['x-goog-api-key'] as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Validate
|
|
||||||
if (clientToken === apiKey) {
|
if (clientToken === apiKey) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
this.logger.warn(`Rejected unauthorized request from ${request.ip}`);
|
||||||
|
|
||||||
this.logger.warn(`Blocked unauthorized access from ${request.ip}`);
|
throw new UnauthorizedException('API key validation failed');
|
||||||
throw new UnauthorizedException('Invalid API Key');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,13 @@ import { ProxyController } from './proxy.controller';
|
|||||||
import { ProxyService } from './proxy.service';
|
import { ProxyService } from './proxy.service';
|
||||||
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 { GeminiController } from './gemini.controller';
|
||||||
|
import { ProxyGuard } from './proxy.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [ProxyController],
|
imports: [],
|
||||||
providers: [ProxyService, TokenManagerService, GeminiClient],
|
controllers: [ProxyController, GeminiController],
|
||||||
|
providers: [ProxyService, TokenManagerService, GeminiClient, ProxyGuard],
|
||||||
|
exports: [TokenManagerService],
|
||||||
})
|
})
|
||||||
export class ProxyModule {}
|
export class ProxyModule {}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -8,20 +8,26 @@ interface TokenData {
|
|||||||
account_id: string;
|
account_id: string;
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
expiry_timestamp: number;
|
expiry_timestamp: number;
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
|
upstream_proxy_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenManagerService implements OnModuleInit {
|
export class TokenManagerService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(TokenManagerService.name);
|
private readonly logger = new Logger(TokenManagerService.name);
|
||||||
private currentIndex = 0;
|
private currentIndex = 0;
|
||||||
|
private readonly stickySessionTtlMs = 10 * 60 * 1000;
|
||||||
|
private readonly rateLimitCooldownMs = 5 * 60 * 1000;
|
||||||
|
private readonly forbiddenCooldownMs = 30 * 60 * 1000;
|
||||||
// In-memory cache of tokens with additional data
|
// In-memory cache of tokens with additional data
|
||||||
private tokens: Map<string, TokenData> = new Map();
|
private tokens: Map<string, TokenData> = new Map();
|
||||||
// Cooldown map for rate-limited accounts
|
// Cooldown map for rate-limited accounts
|
||||||
private cooldowns: Map<string, number> = new Map();
|
private cooldowns: Map<string, number> = new Map();
|
||||||
|
private sessionBindings: Map<string, { accountId: string; expiresAt: number }> = new Map();
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
// Load accounts on module initialization
|
// Load accounts on module initialization
|
||||||
@@ -41,10 +47,10 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Loaded ${count} accounts`);
|
this.logger.log(`Initialized token pool with ${count} accounts`);
|
||||||
return count;
|
return count;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Failed to load accounts', e);
|
this.logger.error('Unable to load accounts', e);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,10 +63,12 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
email: account.email,
|
email: account.email,
|
||||||
access_token: account.token.access_token,
|
access_token: account.token.access_token,
|
||||||
refresh_token: account.token.refresh_token,
|
refresh_token: account.token.refresh_token,
|
||||||
|
token_type: account.token.token_type || 'Bearer',
|
||||||
expires_in: account.token.expires_in,
|
expires_in: account.token.expires_in,
|
||||||
expiry_timestamp: account.token.expiry_timestamp,
|
expiry_timestamp: account.token.expiry_timestamp,
|
||||||
project_id: account.token.project_id || undefined,
|
project_id: account.token.project_id || undefined,
|
||||||
session_id: account.token.session_id || this.generateSessionId(),
|
session_id: account.token.session_id || this.generateSessionId(),
|
||||||
|
upstream_proxy_url: account.token.upstream_proxy_url || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +80,10 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
return (-(min + rand)).toString();
|
return (-(min + rand)).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextToken(): Promise<CloudAccount | null> {
|
async getNextToken(options?: {
|
||||||
|
sessionKey?: string;
|
||||||
|
excludeAccountIds?: string[];
|
||||||
|
}): Promise<CloudAccount | null> {
|
||||||
try {
|
try {
|
||||||
// Reload if empty
|
// Reload if empty
|
||||||
if (this.tokens.size === 0) {
|
if (this.tokens.size === 0) {
|
||||||
@@ -82,25 +93,81 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const nowSeconds = Math.floor(now / 1000);
|
const nowSeconds = Math.floor(now / 1000);
|
||||||
|
const sessionKey = options?.sessionKey?.trim();
|
||||||
|
const excludedAccountIds = new Set(options?.excludeAccountIds ?? []);
|
||||||
|
|
||||||
|
this.clearExpiredSessionBindings(now);
|
||||||
|
|
||||||
|
const fullTokenPool = Array.from(this.tokens.entries());
|
||||||
|
const excludedFilteredTokens = fullTokenPool.filter(
|
||||||
|
([accountId]) => !excludedAccountIds.has(accountId),
|
||||||
|
);
|
||||||
|
const allTokens = excludedFilteredTokens.length > 0 ? excludedFilteredTokens : fullTokenPool;
|
||||||
|
if (excludedFilteredTokens.length === 0 && excludedAccountIds.size > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Retry exclusions removed all accounts; temporarily reusing excluded accounts',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out accounts in cooldown
|
// Filter out accounts in cooldown
|
||||||
const validTokens = Array.from(this.tokens.entries()).filter(([email, _]) => {
|
const validTokens = allTokens.filter(([accountId]) => {
|
||||||
const cooldownUntil = this.cooldowns.get(email);
|
if (excludedAccountIds.has(accountId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownUntil = this.cooldowns.get(accountId);
|
||||||
return !cooldownUntil || cooldownUntil <= now;
|
return !cooldownUntil || cooldownUntil <= now;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (validTokens.length === 0) {
|
const candidateTokens = validTokens.length > 0 ? validTokens : allTokens;
|
||||||
this.logger.warn('All accounts are in cooldown');
|
if (candidateTokens.length === 0) {
|
||||||
|
this.logger.warn('No account available after applying exclusions');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (validTokens.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
'All accounts are currently in cooldown; temporarily bypassing cooldown to keep service available',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionKey) {
|
||||||
|
const stickyBinding = this.sessionBindings.get(sessionKey);
|
||||||
|
if (stickyBinding && stickyBinding.expiresAt > now) {
|
||||||
|
const stickyMatch = candidateTokens.find(
|
||||||
|
([accountId]) => accountId === stickyBinding.accountId,
|
||||||
|
);
|
||||||
|
if (stickyMatch) {
|
||||||
|
const [stickyAccountId, stickyTokenData] = stickyMatch;
|
||||||
|
return this.finalizeSelectedToken(
|
||||||
|
stickyAccountId,
|
||||||
|
stickyTokenData,
|
||||||
|
nowSeconds,
|
||||||
|
sessionKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Round robin selection
|
// Round robin selection
|
||||||
const [accountId, tokenData] = validTokens[this.currentIndex % validTokens.length];
|
const [accountId, tokenData] = candidateTokens[this.currentIndex % candidateTokens.length];
|
||||||
this.currentIndex++;
|
this.currentIndex++;
|
||||||
|
return this.finalizeSelectedToken(accountId, tokenData, nowSeconds, sessionKey);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Unable to acquire token', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finalizeSelectedToken(
|
||||||
|
accountId: string,
|
||||||
|
tokenData: TokenData,
|
||||||
|
nowSeconds: number,
|
||||||
|
sessionKey?: string,
|
||||||
|
): Promise<CloudAccount | null> {
|
||||||
|
try {
|
||||||
// Check if token needs refresh (expires in < 5 minutes)
|
// Check if token needs refresh (expires in < 5 minutes)
|
||||||
if (nowSeconds >= tokenData.expiry_timestamp - 300) {
|
if (nowSeconds >= tokenData.expiry_timestamp - 300) {
|
||||||
this.logger.log(`Token for ${tokenData.email} expiring soon, refreshing...`);
|
this.logger.log(`Token for ${tokenData.email} is close to expiry; refreshing...`);
|
||||||
try {
|
try {
|
||||||
const newTokens = await GoogleAPIService.refreshAccessToken(tokenData.refresh_token);
|
const newTokens = await GoogleAPIService.refreshAccessToken(tokenData.refresh_token);
|
||||||
|
|
||||||
@@ -113,51 +180,96 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
await this.saveRefreshedToken(accountId, tokenData);
|
await this.saveRefreshedToken(accountId, tokenData);
|
||||||
this.tokens.set(accountId, tokenData);
|
this.tokens.set(accountId, tokenData);
|
||||||
|
|
||||||
this.logger.log(`Token refreshed for ${tokenData.email}`);
|
this.logger.log(`Refreshed token for ${tokenData.email}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(`Failed to refresh token for ${tokenData.email}`, e);
|
this.logger.error(`Unable to refresh token for ${tokenData.email}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve project ID if missing (mock for now, like original)
|
if (
|
||||||
if (!tokenData.project_id) {
|
typeof tokenData.project_id !== 'string' ||
|
||||||
const mockId = `cloud-code-${Math.floor(Math.random() * 100000)}`;
|
tokenData.project_id.trim() === '' ||
|
||||||
tokenData.project_id = mockId;
|
/^cloud-code-\d+$/i.test(tokenData.project_id.trim())
|
||||||
await this.saveProjectId(accountId, mockId);
|
) {
|
||||||
|
tokenData.project_id = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Selected account: ${tokenData.email}`);
|
this.logger.log(`Using account: ${tokenData.email}`);
|
||||||
|
if (sessionKey) {
|
||||||
|
this.sessionBindings.set(sessionKey, {
|
||||||
|
accountId,
|
||||||
|
expiresAt: Date.now() + this.stickySessionTtlMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Return in CloudAccount format for compatibility
|
// Return in CloudAccount format for compatibility
|
||||||
return {
|
const timestamp = Date.now();
|
||||||
|
const cloudAccount: CloudAccount = {
|
||||||
id: accountId,
|
id: accountId,
|
||||||
|
provider: 'google',
|
||||||
email: tokenData.email,
|
email: tokenData.email,
|
||||||
token: {
|
token: {
|
||||||
access_token: tokenData.access_token,
|
access_token: tokenData.access_token,
|
||||||
refresh_token: tokenData.refresh_token,
|
refresh_token: tokenData.refresh_token,
|
||||||
|
token_type: tokenData.token_type,
|
||||||
expires_in: tokenData.expires_in,
|
expires_in: tokenData.expires_in,
|
||||||
expiry_timestamp: tokenData.expiry_timestamp,
|
expiry_timestamp: tokenData.expiry_timestamp,
|
||||||
project_id: tokenData.project_id,
|
project_id: tokenData.project_id,
|
||||||
session_id: tokenData.session_id,
|
session_id: tokenData.session_id,
|
||||||
|
upstream_proxy_url: tokenData.upstream_proxy_url,
|
||||||
},
|
},
|
||||||
} as CloudAccount;
|
created_at: timestamp,
|
||||||
|
last_used: timestamp,
|
||||||
|
};
|
||||||
|
return cloudAccount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to get token', error);
|
this.logger.error('Unable to finalize selected token', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
markAsRateLimited(email: string) {
|
markAsRateLimited(accountIdOrEmail: string) {
|
||||||
// Cooldown for 5 minutes
|
this.markInCooldown(accountIdOrEmail, 'rate limited', this.rateLimitCooldownMs);
|
||||||
const until = Date.now() + 5 * 60 * 1000;
|
|
||||||
this.cooldowns.set(email, until);
|
|
||||||
this.logger.warn(
|
|
||||||
`Account ${email} marked as rate limited until ${new Date(until).toISOString()}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetCooldown(email: string) {
|
markAsForbidden(accountIdOrEmail: string) {
|
||||||
this.cooldowns.delete(email);
|
this.markInCooldown(accountIdOrEmail, 'forbidden', this.forbiddenCooldownMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAccountId(accountIdOrEmail: string): string | null {
|
||||||
|
if (this.tokens.has(accountIdOrEmail)) {
|
||||||
|
return accountIdOrEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [accountId, tokenData] of this.tokens.entries()) {
|
||||||
|
if (tokenData.email === accountIdOrEmail) {
|
||||||
|
return accountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearExpiredSessionBindings(now: number): void {
|
||||||
|
for (const [sessionKey, binding] of this.sessionBindings.entries()) {
|
||||||
|
if (binding.expiresAt <= now) {
|
||||||
|
this.sessionBindings.delete(sessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markInCooldown(
|
||||||
|
accountIdOrEmail: string,
|
||||||
|
reason: 'rate limited' | 'forbidden',
|
||||||
|
durationMs: number,
|
||||||
|
): void {
|
||||||
|
const accountId = this.resolveAccountId(accountIdOrEmail) ?? accountIdOrEmail;
|
||||||
|
const cooldownUntil = Date.now() + durationMs;
|
||||||
|
|
||||||
|
this.cooldowns.set(accountId, cooldownUntil);
|
||||||
|
this.logger.warn(
|
||||||
|
`Account ${accountIdOrEmail} (cooldown key: ${accountId}) set to ${reason} until ${new Date(cooldownUntil).toISOString()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveRefreshedToken(accountId: string, tokenData: TokenData) {
|
private async saveRefreshedToken(accountId: string, tokenData: TokenData) {
|
||||||
@@ -173,22 +285,7 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
await CloudAccountRepo.updateToken(accountId, newToken);
|
await CloudAccountRepo.updateToken(accountId, newToken);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Failed to save refreshed token to DB', e);
|
this.logger.error('Unable to persist refreshed token to DB', e);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveProjectId(accountId: string, projectId: string) {
|
|
||||||
try {
|
|
||||||
const acc = await CloudAccountRepo.getAccount(accountId);
|
|
||||||
if (acc && acc.token) {
|
|
||||||
const newToken = {
|
|
||||||
...acc.token,
|
|
||||||
project_id: projectId,
|
|
||||||
};
|
|
||||||
await CloudAccountRepo.updateToken(accountId, newToken);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.error('Failed to save project ID to DB', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { GeminiController } from '../../server/modules/proxy/gemini.controller';
|
||||||
|
|
||||||
|
function createReplyMock() {
|
||||||
|
const reply: Record<string, any> = {};
|
||||||
|
reply.status = vi.fn(() => reply);
|
||||||
|
reply.header = vi.fn(() => reply);
|
||||||
|
reply.send = vi.fn(() => reply);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GeminiController Integration', () => {
|
||||||
|
it('supports list and get model endpoints', () => {
|
||||||
|
const proxyService = {};
|
||||||
|
const controller = new GeminiController(proxyService as any);
|
||||||
|
const replyList = createReplyMock();
|
||||||
|
const replyGet = createReplyMock();
|
||||||
|
|
||||||
|
controller.listModels(replyList as any);
|
||||||
|
controller.getModel('gemini-2.5-flash', replyGet as any);
|
||||||
|
|
||||||
|
expect(replyList.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(replyList.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
models: expect.any(Array),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(replyList.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
models: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'models/gemini-2.5-flash',
|
||||||
|
description: '',
|
||||||
|
inputTokenLimit: 128000,
|
||||||
|
outputTokenLimit: 8192,
|
||||||
|
supportedGenerationMethods: ['generateContent', 'countTokens'],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(replyGet.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(replyGet.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'models/gemini-2.5-flash',
|
||||||
|
displayName: 'gemini-2.5-flash',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles generateContent action from colon endpoint format', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleGeminiGenerateContent: vi.fn().mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { role: 'model', parts: [{ text: 'hello' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
avgLogprobs: -0.1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 1,
|
||||||
|
candidatesTokenCount: 1,
|
||||||
|
totalTokenCount: 2,
|
||||||
|
},
|
||||||
|
createTime: '2026-02-10T10:00:00.000Z',
|
||||||
|
modelVersion: 'gemini-2.5-flash-latest',
|
||||||
|
responseId: 'resp_123',
|
||||||
|
}),
|
||||||
|
handleGeminiStreamGenerateContent: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new GeminiController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.modelAction(
|
||||||
|
'models/gemini-2.5-flash:generateContent',
|
||||||
|
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledWith(
|
||||||
|
'models/gemini-2.5-flash',
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { role: 'model', parts: [{ text: 'hello' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 1,
|
||||||
|
candidatesTokenCount: 1,
|
||||||
|
totalTokenCount: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles streamGenerateContent action and emits SSE headers', async () => {
|
||||||
|
const stream = of('data: {"ok":true}\n\n');
|
||||||
|
const proxyService = {
|
||||||
|
handleGeminiGenerateContent: vi.fn(),
|
||||||
|
handleGeminiStreamGenerateContent: vi.fn().mockResolvedValue(stream),
|
||||||
|
};
|
||||||
|
const controller = new GeminiController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.modelAction(
|
||||||
|
'gemini-2.5-flash:streamGenerateContent',
|
||||||
|
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'text/event-stream');
|
||||||
|
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
||||||
|
expect(reply.header).toHaveBeenCalledWith('Connection', 'keep-alive');
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports countTokens action', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleGeminiGenerateContent: vi.fn(),
|
||||||
|
handleGeminiStreamGenerateContent: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new GeminiController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.countTokens(
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
{ contents: [{ role: 'user', parts: [{ text: 'abcd efgh' }] }] } as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({ totalTokens: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns bad request for invalid combined endpoint action', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleGeminiGenerateContent: vi.fn(),
|
||||||
|
handleGeminiStreamGenerateContent: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new GeminiController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.modelAction(
|
||||||
|
'models/gemini-2.5-flash-generateContent',
|
||||||
|
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
status: 'INVALID_ARGUMENT',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||||
import { logger } from '../../utils/logger';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -12,6 +11,10 @@ vi.mock('../../utils/paths', async () => {
|
|||||||
|
|
||||||
describe('Logger Utilities', () => {
|
describe('Logger Utilities', () => {
|
||||||
const testLogDir = path.join(process.cwd(), 'temp_test_logs');
|
const testLogDir = path.join(process.cwd(), 'temp_test_logs');
|
||||||
|
let logger: {
|
||||||
|
info: (message: string, ...args: unknown[]) => void;
|
||||||
|
error: (message: string, ...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const getLatestLogFile = () => {
|
const getLatestLogFile = () => {
|
||||||
const files = fs
|
const files = fs
|
||||||
@@ -27,7 +30,7 @@ describe('Logger Utilities', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const waitForLogContains = async (text: string) => {
|
const waitForLogContains = async (text: string) => {
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
const filePath = getLatestLogFile();
|
const filePath = getLatestLogFile();
|
||||||
if (filePath && fs.existsSync(filePath)) {
|
if (filePath && fs.existsSync(filePath)) {
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
@@ -41,30 +44,22 @@ describe('Logger Utilities', () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (fs.existsSync(testLogDir)) {
|
||||||
|
fs.rmSync(testLogDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(testLogDir, { recursive: true });
|
||||||
|
const loggerModule = await import('../../utils/logger');
|
||||||
|
logger = loggerModule.logger;
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
try {
|
|
||||||
if (fs.existsSync(testLogDir)) {
|
|
||||||
fs.rmSync(testLogDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
fs.mkdirSync(testLogDir, { recursive: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('beforeEach: setup testLogDir failed', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
try {
|
|
||||||
if (fs.existsSync(testLogDir)) {
|
|
||||||
fs.rmSync(testLogDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('afterEach: cleanup testLogDir failed', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ProxyController } from '../../server/modules/proxy/proxy.controller';
|
||||||
|
|
||||||
|
function createReplyMock() {
|
||||||
|
const reply: Record<string, any> = {};
|
||||||
|
reply.status = vi.fn(() => reply);
|
||||||
|
reply.header = vi.fn(() => reply);
|
||||||
|
reply.send = vi.fn(() => reply);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProxyController Integration', () => {
|
||||||
|
it('supports models listing endpoints', () => {
|
||||||
|
const proxyService = {};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
|
||||||
|
const allModels = controller.getModels();
|
||||||
|
const claudeModels = controller.getClaudeModels();
|
||||||
|
|
||||||
|
expect(allModels.object).toBe('list');
|
||||||
|
expect(Array.isArray(allModels.data)).toBe(true);
|
||||||
|
expect(allModels.data.length).toBeGreaterThan(0);
|
||||||
|
expect(allModels.data.every((model) => model.owned_by === 'antigravity')).toBe(true);
|
||||||
|
expect(allModels.data.every((model) => model.created === 1770652800)).toBe(true);
|
||||||
|
|
||||||
|
expect(claudeModels).toEqual(allModels);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes Claude OpenAI requests to protocol parity path', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
handleAnthropicMessages: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.chatCompletions(
|
||||||
|
{
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
} as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proxyService.handleChatCompletions).toHaveBeenCalledOnce();
|
||||||
|
expect(proxyService.handleAnthropicMessages).not.toHaveBeenCalled();
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns stream response with SSE headers for parity stream path', async () => {
|
||||||
|
const stream = of('data: {"ok":true}\n\n');
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi.fn().mockResolvedValue(stream),
|
||||||
|
handleAnthropicMessages: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.chatCompletions(
|
||||||
|
{
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
stream: true,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
} as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'text/event-stream');
|
||||||
|
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
||||||
|
expect(reply.header).toHaveBeenCalledWith('Connection', 'keep-alive');
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports OpenAI completions compatibility endpoint', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||||
|
id: 'chatcmpl_test',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1700000000,
|
||||||
|
model: 'gpt-4o',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'hello from assistant',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 10,
|
||||||
|
completion_tokens: 5,
|
||||||
|
total_tokens: 15,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
handleAnthropicMessages: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.completions(
|
||||||
|
{
|
||||||
|
model: 'gpt-4o',
|
||||||
|
prompt: 'hello world',
|
||||||
|
stream: false,
|
||||||
|
},
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proxyService.handleChatCompletions).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: 'gpt-4o',
|
||||||
|
messages: [{ role: 'user', content: 'hello world' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
object: 'text_completion',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
choices: [
|
||||||
|
expect.objectContaining({
|
||||||
|
text: 'hello from assistant',
|
||||||
|
logprobs: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports OpenAI responses compatibility endpoint with normalized input', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||||
|
id: 'chatcmpl_resp',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1700000001,
|
||||||
|
model: 'gpt-4o',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
message: {
|
||||||
|
content: 'normalized response',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 10,
|
||||||
|
completion_tokens: 6,
|
||||||
|
total_tokens: 16,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.responses(
|
||||||
|
{
|
||||||
|
model: 'gpt-4o',
|
||||||
|
instructions: 'Follow the tool protocol',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function_call',
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'search_docs',
|
||||||
|
arguments: '{"query":"token"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'call_1',
|
||||||
|
output: { content: 'result: ok' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const callArg = proxyService.handleChatCompletions.mock.calls[0][0];
|
||||||
|
expect(callArg.messages[0]).toEqual({
|
||||||
|
role: 'system',
|
||||||
|
content: 'Follow the tool protocol',
|
||||||
|
});
|
||||||
|
expect(callArg.messages.some((message: { role: string }) => message.role === 'assistant')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(callArg.messages.some((message: { role: string }) => message.role === 'tool')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
object: 'text_completion',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
choices: [
|
||||||
|
expect.objectContaining({
|
||||||
|
text: 'normalized response',
|
||||||
|
logprobs: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
usage: expect.objectContaining({
|
||||||
|
prompt_tokens: 10,
|
||||||
|
completion_tokens: 6,
|
||||||
|
total_tokens: 16,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports image generations endpoint', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.imageGenerations(
|
||||||
|
{
|
||||||
|
model: 'gemini-3-pro-image',
|
||||||
|
prompt: 'draw a cat',
|
||||||
|
},
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: [
|
||||||
|
expect.objectContaining({
|
||||||
|
b64_json: 'AAAABBBB',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps image generation upstream quota errors to 429', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi.fn().mockRejectedValue(new Error('429 quota exceeded')),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.imageGenerations(
|
||||||
|
{
|
||||||
|
model: 'gemini-3-pro-image',
|
||||||
|
prompt: 'draw a dog',
|
||||||
|
},
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to Gemini image generation when chat path hits project context error', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(
|
||||||
|
new Error(
|
||||||
|
'You are currently configured to use a Google Cloud Project but lack a Gemini Code Assist license. (#3501)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
handleGeminiGenerateContent: vi.fn().mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
inlineData: {
|
||||||
|
mimeType: 'image/png',
|
||||||
|
data: 'FALLBACKIMG',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.imageGenerations(
|
||||||
|
{
|
||||||
|
model: 'gemini-3-pro-image',
|
||||||
|
prompt: 'draw a fox',
|
||||||
|
},
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proxyService.handleChatCompletions).toHaveBeenCalledOnce();
|
||||||
|
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledOnce();
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: [
|
||||||
|
expect.objectContaining({
|
||||||
|
b64_json: 'FALLBACKIMG',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports image edits endpoint with supplementary image payload', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.imageEdits(
|
||||||
|
{
|
||||||
|
model: 'gemini-3-pro-image',
|
||||||
|
prompt: 'make it brighter',
|
||||||
|
image: 'data:image/png;base64,IMGBASE64',
|
||||||
|
reference_images: ['data:image/jpeg;base64,REFBASE64'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': 'multipart/form-data; boundary=----parity',
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = proxyService.handleChatCompletions.mock.calls[0][0];
|
||||||
|
expect(Array.isArray(request.messages[0].content)).toBe(true);
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects image edits request without multipart boundary', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleChatCompletions: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.imageEdits(
|
||||||
|
{
|
||||||
|
model: 'gemini-3-pro-image',
|
||||||
|
prompt: 'make it brighter',
|
||||||
|
image: 'data:image/png;base64,IMGBASE64',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proxyService.handleChatCompletions).not.toHaveBeenCalled();
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith('Invalid `boundary` for `multipart/form-data` request');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports audio transcriptions endpoint', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleGeminiGenerateContent: vi.fn().mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'transcribed text' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.audioTranscriptions(
|
||||||
|
{
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
file: 'data:audio/mpeg;base64,QUJDRA==',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': 'multipart/form-data; boundary=----parity',
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledOnce();
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({ text: 'transcribed text' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects audio transcription request without multipart boundary', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleGeminiGenerateContent: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.audioTranscriptions(
|
||||||
|
{
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
file: 'data:audio/mpeg;base64,QUJDRA==',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proxyService.handleGeminiGenerateContent).not.toHaveBeenCalled();
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith('Invalid `boundary` for `multipart/form-data` request');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports model detect endpoint', () => {
|
||||||
|
const proxyService = {
|
||||||
|
detectModelCapabilities: vi.fn().mockReturnValue({
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
mapped_model: 'gemini-3-pro-preview',
|
||||||
|
type: 'generate-content',
|
||||||
|
features: { has_web_search: false, is_image_gen: false },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
controller.detectModel({ model: 'claude-sonnet-4-5' }, reply as any);
|
||||||
|
|
||||||
|
expect(proxyService.detectModelCapabilities).toHaveBeenCalledWith('claude-sonnet-4-5');
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports Anthropic messages endpoint', async () => {
|
||||||
|
const proxyService = {
|
||||||
|
handleAnthropicMessages: vi.fn().mockResolvedValue({
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new ProxyController(proxyService as any);
|
||||||
|
const reply = createReplyMock();
|
||||||
|
|
||||||
|
await controller.anthropicMessages(
|
||||||
|
{
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
} as any,
|
||||||
|
reply as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proxyService.handleAnthropicMessages).toHaveBeenCalledOnce();
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||||
|
|
||||||
|
const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn(), markAsForbidden: vi.fn() };
|
||||||
|
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||||
|
|
||||||
|
class TestableProxyService extends ProxyService {
|
||||||
|
constructor() {
|
||||||
|
super(mockTokenManager as any, mockGeminiClient as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toAnthropic(request: any): any {
|
||||||
|
return (this as any).convertOpenAIToClaude(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toOpenAI(response: any, model: string): any {
|
||||||
|
return (this as any).convertClaudeToOpenAIResponse(response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public streamToOpenAI(upstreamStream: any, model: string): Observable<string> {
|
||||||
|
return (this as any).processStreamResponse(upstreamStream, model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFixture<T>(relativePath: string): T {
|
||||||
|
const fullPath = path.join(process.cwd(), 'src/tests/fixtures/proxy-parity', relativePath);
|
||||||
|
return JSON.parse(readFileSync(fullPath, 'utf-8')) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Proxy Parity Fixtures', () => {
|
||||||
|
it('maps OpenAI request fixture to expected Anthropic request semantics', () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const input = readFixture<any>('request/openai.chat-tools.input.json');
|
||||||
|
const expected = readFixture<any>('request/openai.chat-tools.expected.json');
|
||||||
|
|
||||||
|
const actual = service.toAnthropic(input);
|
||||||
|
|
||||||
|
expect(actual.model).toBe(expected.model);
|
||||||
|
expect(actual.system).toBe(expected.system);
|
||||||
|
expect(actual.temperature).toBe(expected.temperature);
|
||||||
|
expect(actual.max_tokens).toBe(expected.max_tokens);
|
||||||
|
expect(actual.tools?.[0]?.name).toBe(expected.tools[0].name);
|
||||||
|
expect(actual.messages[0]).toEqual(expected.messages[0]);
|
||||||
|
expect(actual.messages[1].content[1]).toEqual(expected.messages[1].content[1]);
|
||||||
|
expect(actual.messages[2].content[0]).toEqual(expected.messages[2].content[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Anthropic response fixture to expected OpenAI response semantics', () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const input = readFixture<any>('response/anthropic.tool-use.input.json');
|
||||||
|
const expected = readFixture<any>('response/anthropic.tool-use.expected.json');
|
||||||
|
|
||||||
|
const actual = service.toOpenAI(input, expected.model);
|
||||||
|
|
||||||
|
expect(actual.model).toBe(expected.model);
|
||||||
|
expect(actual.choices[0].message.content).toBe(expected.message.content);
|
||||||
|
expect(actual.choices[0].message.reasoning_content).toBe(expected.message.reasoning_content);
|
||||||
|
expect(actual.choices[0].message.tool_calls?.[0]).toEqual(expected.message.tool_calls[0]);
|
||||||
|
expect(actual.choices[0].finish_reason).toBe(expected.finish_reason);
|
||||||
|
expect(actual.usage).toEqual(expected.usage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps upstream stream fixture into expected OpenAI SSE semantics', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const input = readFixture<any>('stream/openai-from-gemini.input.json');
|
||||||
|
const expected = readFixture<{ contains: string[] }>('stream/openai-from-gemini.expected.json');
|
||||||
|
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
const outputChunks: string[] = [];
|
||||||
|
|
||||||
|
const promise = new Promise<void>((resolve, reject) => {
|
||||||
|
service.streamToOpenAI(stream, input.model).subscribe({
|
||||||
|
next: (chunk) => outputChunks.push(chunk),
|
||||||
|
error: reject,
|
||||||
|
complete: resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.emit('data', Buffer.from(`data: ${JSON.stringify(input.upstream)}\n`));
|
||||||
|
stream.emit('end');
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const output = outputChunks.join('');
|
||||||
|
for (const token of expected.contains) {
|
||||||
|
expect(output).toContain(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn() };
|
const mockTokenManager = {
|
||||||
|
getNextToken: vi.fn(),
|
||||||
|
markAsRateLimited: vi.fn(),
|
||||||
|
markAsForbidden: vi.fn(),
|
||||||
|
};
|
||||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||||
|
|
||||||
// Subclass to access private method
|
// Subclass to access private method
|
||||||
@@ -17,9 +21,77 @@ class TestableProxyService extends ProxyService {
|
|||||||
// Access private method using type assertion
|
// Access private method using type assertion
|
||||||
return (this as any).processAnthropicInternalStream(stream, model);
|
return (this as any).processAnthropicInternalStream(stream, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public testPassthroughStream(stream: any): Observable<string> {
|
||||||
|
return (this as any).passthroughSseStream(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public testModelHeaders(model: string): Record<string, string> {
|
||||||
|
return (this as any).buildModelSpecificHeaders(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToken(id: string = 'acc-1') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
email: `${id}@test.com`,
|
||||||
|
token: {
|
||||||
|
access_token: 'token',
|
||||||
|
refresh_token: 'refresh',
|
||||||
|
expires_in: 3600,
|
||||||
|
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
project_id: 'project-1',
|
||||||
|
session_id: 'session-1',
|
||||||
|
upstream_proxy_url: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ProxyService Empty Stream Retry Logic', () => {
|
describe('ProxyService Empty Stream Retry Logic', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies retry matrix consistently', () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const classify = (message: string) => (service as any).classifyUpstreamError(message);
|
||||||
|
|
||||||
|
expect(classify('401 unauthorized token')).toEqual({
|
||||||
|
retry: true,
|
||||||
|
markAsForbidden: true,
|
||||||
|
markAsRateLimited: false,
|
||||||
|
});
|
||||||
|
expect(classify('403 permission_denied')).toEqual({
|
||||||
|
retry: true,
|
||||||
|
markAsForbidden: true,
|
||||||
|
markAsRateLimited: false,
|
||||||
|
});
|
||||||
|
expect(classify('429 quota exceeded')).toEqual({
|
||||||
|
retry: true,
|
||||||
|
markAsForbidden: false,
|
||||||
|
markAsRateLimited: true,
|
||||||
|
});
|
||||||
|
expect(classify('500 internal error')).toEqual({
|
||||||
|
retry: true,
|
||||||
|
markAsForbidden: false,
|
||||||
|
markAsRateLimited: false,
|
||||||
|
});
|
||||||
|
expect(classify('400 invalid argument')).toEqual({
|
||||||
|
retry: false,
|
||||||
|
markAsForbidden: false,
|
||||||
|
markAsRateLimited: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds Claude-specific beta headers consistently', () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const claudeHeaders = service.testModelHeaders('claude-sonnet-4-5');
|
||||||
|
const geminiHeaders = service.testModelHeaders('gemini-2.5-flash');
|
||||||
|
|
||||||
|
expect(claudeHeaders['anthropic-beta']).toContain('claude-code-20250219');
|
||||||
|
expect(geminiHeaders).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it('should emit error when stream ends without data', async () => {
|
it('should emit error when stream ends without data', async () => {
|
||||||
const service = new TestableProxyService();
|
const service = new TestableProxyService();
|
||||||
const stream = new EventEmitter();
|
const stream = new EventEmitter();
|
||||||
@@ -89,4 +161,454 @@ describe('ProxyService Empty Stream Retry Logic', () => {
|
|||||||
// Actually our mapper might produce "message_start", "content_block_start" etc.
|
// Actually our mapper might produce "message_start", "content_block_start" etc.
|
||||||
// We just care that it didn't error with "Empty response stream"
|
// We just care that it didn't error with "Empty response stream"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to stream aggregation when non-stream response is empty', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
|
||||||
|
mockGeminiClient.generateInternal.mockResolvedValueOnce({ candidates: [] });
|
||||||
|
mockGeminiClient.streamGenerateInternal.mockResolvedValueOnce(stream);
|
||||||
|
|
||||||
|
const promise = (service as any).generateInternalWithStreamFallback(
|
||||||
|
{ model: 'gemini-2.5-flash' },
|
||||||
|
'token',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: 'fallback text' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: { totalTokenCount: 5 },
|
||||||
|
});
|
||||||
|
stream.emit('data', Buffer.from(`data: ${payload}\n\n`));
|
||||||
|
stream.emit('end');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(mockGeminiClient.streamGenerateInternal).toHaveBeenCalledOnce();
|
||||||
|
expect(result.candidates[0].content.parts[0].text).toBe('fallback text');
|
||||||
|
expect(result.candidates[0].finishReason).toBe('STOP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects Claude beta headers when handling Gemini-compatible Claude models', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
mockTokenManager.getNextToken.mockResolvedValue(createToken());
|
||||||
|
mockGeminiClient.generateInternal.mockResolvedValue({
|
||||||
|
candidates: [{ content: { parts: [{ text: 'ok' }] } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.handleGeminiGenerateContent('models/claude-sonnet-4-5', {
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const headers = mockGeminiClient.generateInternal.mock.calls[0][3];
|
||||||
|
expect(headers['anthropic-beta']).toContain('claude-code-20250219');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates malformed partial chunks and still completes Anthropic stream', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
const resultObservable = service.testProcessStream(stream);
|
||||||
|
|
||||||
|
let completed = false;
|
||||||
|
let errored = false;
|
||||||
|
|
||||||
|
const done = new Promise<void>((resolve) => {
|
||||||
|
resultObservable.subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: () => {
|
||||||
|
errored = true;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
completed = true;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
stream.emit('data', Buffer.from('data: {"invalid_json":\n\n'));
|
||||||
|
const validPayload = JSON.stringify({
|
||||||
|
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||||
|
});
|
||||||
|
stream.emit('data', Buffer.from(`data: ${validPayload}\n\n`));
|
||||||
|
stream.emit('end');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
await done;
|
||||||
|
|
||||||
|
expect(errored).toBe(false);
|
||||||
|
expect(completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises error for empty Gemini passthrough stream', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
|
||||||
|
const observable = service.testPassthroughStream(stream);
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
|
const done = new Promise<void>((resolve) => {
|
||||||
|
observable.subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: (error: Error) => {
|
||||||
|
errorMessage = error.message;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
complete: () => resolve(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => stream.emit('end'), 10);
|
||||||
|
await done;
|
||||||
|
|
||||||
|
expect(errorMessage).toBe('Empty response stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates Anthropic stream interruption errors', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
const observable = service.testProcessStream(stream);
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
|
const done = new Promise<void>((resolve) => {
|
||||||
|
observable.subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: (error: Error) => {
|
||||||
|
errorMessage = error.message;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
complete: () => resolve(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => stream.emit('error', new Error('upstream interrupted')), 10);
|
||||||
|
await done;
|
||||||
|
|
||||||
|
expect(errorMessage).toBe('upstream interrupted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates Gemini passthrough interruption errors', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
const observable = service.testPassthroughStream(stream);
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
|
const done = new Promise<void>((resolve) => {
|
||||||
|
observable.subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: (error: Error) => {
|
||||||
|
errorMessage = error.message;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
complete: () => resolve(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => stream.emit('error', new Error('connection reset by peer')), 10);
|
||||||
|
await done;
|
||||||
|
|
||||||
|
expect(errorMessage).toBe('connection reset by peer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries OpenAI flow with the same error classification matrix', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const token1 = createToken('acc-1');
|
||||||
|
const token2 = createToken('acc-2');
|
||||||
|
mockTokenManager.getNextToken.mockResolvedValueOnce(token1).mockResolvedValueOnce(token2);
|
||||||
|
mockGeminiClient.generateInternal
|
||||||
|
.mockRejectedValueOnce(new Error('429 quota exceeded'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||||
|
usageMetadata: { totalTokenCount: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.handleChatCompletions({
|
||||||
|
model: 'gpt-4o',
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockTokenManager.markAsRateLimited).toHaveBeenCalledWith('acc-1');
|
||||||
|
expect((result as any).choices?.[0]?.message?.content).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries Anthropic flow with the same error classification matrix', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const token1 = createToken('acc-1');
|
||||||
|
const token2 = createToken('acc-2');
|
||||||
|
mockTokenManager.getNextToken.mockResolvedValueOnce(token1).mockResolvedValueOnce(token2);
|
||||||
|
mockGeminiClient.generateInternal
|
||||||
|
.mockRejectedValueOnce(new Error('429 rate limit exceeded'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||||
|
usageMetadata: { totalTokenCount: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.handleAnthropicMessages({
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
stream: false,
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockTokenManager.markAsRateLimited).toHaveBeenCalledWith('acc-1');
|
||||||
|
expect((result as any).type).toBe('message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries Gemini flow with the same error classification matrix', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const token1 = createToken('acc-1');
|
||||||
|
const token2 = createToken('acc-2');
|
||||||
|
mockTokenManager.getNextToken.mockResolvedValueOnce(token1).mockResolvedValueOnce(token2);
|
||||||
|
mockGeminiClient.generateInternal
|
||||||
|
.mockRejectedValueOnce(new Error('429 quota exceeded'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||||
|
usageMetadata: { totalTokenCount: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockTokenManager.markAsRateLimited).toHaveBeenCalledWith('acc-1');
|
||||||
|
expect((result as any).candidates?.[0]?.content?.parts?.[0]?.text).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include sessionId in Gemini internal generate payload', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||||
|
mockGeminiClient.generateInternal.mockResolvedValue({
|
||||||
|
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||||
|
usageMetadata: { totalTokenCount: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const internalPayload = mockGeminiClient.generateInternal.mock.calls[0][0];
|
||||||
|
expect(internalPayload).not.toHaveProperty('sessionId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips non-parity Gemini usage metadata fields', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||||
|
mockGeminiClient.generateInternal.mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { role: 'model', parts: [{ text: 'ok' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 1,
|
||||||
|
candidatesTokenCount: 2,
|
||||||
|
totalTokenCount: 3,
|
||||||
|
thoughtsTokenCount: 4,
|
||||||
|
},
|
||||||
|
responseId: 'resp_123',
|
||||||
|
createTime: '2026-02-10T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect((result as any).usageMetadata).toEqual({
|
||||||
|
promptTokenCount: 1,
|
||||||
|
candidatesTokenCount: 2,
|
||||||
|
totalTokenCount: 3,
|
||||||
|
});
|
||||||
|
expect((result as any).usageMetadata.thoughtsTokenCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries Gemini generate-content without project when project context is invalid', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||||
|
mockGeminiClient.generateInternal
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new Error(
|
||||||
|
'You are currently configured to use a Google Cloud Project but lack a Gemini Code Assist license. (#3501)',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||||
|
usageMetadata: { totalTokenCount: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockGeminiClient.generateInternal).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockGeminiClient.generateInternal.mock.calls[0][0].project).toBe('project-1');
|
||||||
|
expect(mockGeminiClient.generateInternal.mock.calls[1][0].project).toBe('');
|
||||||
|
expect((result as any).candidates?.[0]?.content?.parts?.[0]?.text).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses generate-content requestType for Gemini stream internal payload', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||||
|
mockGeminiClient.streamGenerateInternal.mockResolvedValue(new EventEmitter());
|
||||||
|
|
||||||
|
await service.handleGeminiStreamGenerateContent('models/gemini-2.5-flash', {
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const internalPayload = mockGeminiClient.streamGenerateInternal.mock.calls[0][0];
|
||||||
|
expect(internalPayload.requestType).toBe('generate-content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ProxyService Protocol Parity Fixtures', () => {
|
||||||
|
it('maps OpenAI request to Anthropic request with tools and tool result', () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
|
||||||
|
const openaiRequest = {
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
stream: false,
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: 512,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'search_docs',
|
||||||
|
description: 'Search docs',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'You are a precise assistant.' },
|
||||||
|
{ role: 'user', content: [{ type: 'text', text: 'Find API key docs' }] },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Calling search tool',
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'search_docs',
|
||||||
|
arguments: '{"query":"api key"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: 'call_1',
|
||||||
|
name: 'search_docs',
|
||||||
|
content: 'Found 3 results',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const anthropicRequest = (service as any).convertOpenAIToClaude(openaiRequest);
|
||||||
|
|
||||||
|
expect(anthropicRequest.system).toContain('You are a precise assistant.');
|
||||||
|
expect(anthropicRequest.tools?.[0]?.name).toBe('search_docs');
|
||||||
|
expect(anthropicRequest.messages.length).toBe(3);
|
||||||
|
|
||||||
|
const assistantMessage = anthropicRequest.messages[1];
|
||||||
|
expect(Array.isArray(assistantMessage.content)).toBe(true);
|
||||||
|
expect(assistantMessage.content.some((block: any) => block.type === 'tool_use')).toBe(true);
|
||||||
|
|
||||||
|
const toolResultMessage = anthropicRequest.messages[2];
|
||||||
|
expect(toolResultMessage.role).toBe('user');
|
||||||
|
expect(Array.isArray(toolResultMessage.content)).toBe(true);
|
||||||
|
expect(toolResultMessage.content[0].type).toBe('tool_result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Anthropic response to OpenAI response with reasoning and tool_calls', () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
|
||||||
|
const anthropicResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'thinking', thinking: 'Need to call tool first.' },
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-1',
|
||||||
|
name: 'search_docs',
|
||||||
|
input: { query: 'api key' },
|
||||||
|
},
|
||||||
|
{ type: 'text', text: 'Here are the docs.' },
|
||||||
|
],
|
||||||
|
stop_reason: 'tool_use',
|
||||||
|
usage: {
|
||||||
|
input_tokens: 20,
|
||||||
|
output_tokens: 30,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const openaiResponse = (service as any).convertClaudeToOpenAIResponse(
|
||||||
|
anthropicResponse,
|
||||||
|
'gpt-4o-mini',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(openaiResponse.model).toBe('gpt-4o-mini');
|
||||||
|
expect(openaiResponse.choices[0].message.role).toBe('assistant');
|
||||||
|
expect(openaiResponse.choices[0].message.reasoning_content).toContain('Need to call tool');
|
||||||
|
expect(openaiResponse.choices[0].message.tool_calls?.length).toBe(1);
|
||||||
|
expect(openaiResponse.choices[0].finish_reason).toBe('tool_calls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts internal SSE stream into OpenAI SSE chunks', async () => {
|
||||||
|
const service = new TestableProxyService();
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
const observable = (service as any).processStreamResponse(stream, 'gpt-4o-mini');
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
observable.subscribe({
|
||||||
|
next: (chunk: string) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
},
|
||||||
|
error: reject,
|
||||||
|
complete: resolve,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ thought: true, text: 'reasoning text' },
|
||||||
|
{ functionCall: { id: 'fc1', name: 'search_docs', args: { query: 'api key' } } },
|
||||||
|
{ text: 'final answer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.emit('data', Buffer.from(`data: ${payload}\n`));
|
||||||
|
stream.emit('end');
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = chunks.join('');
|
||||||
|
expect(output).toContain('"reasoning_content":"reasoning text"');
|
||||||
|
expect(output).toContain('"tool_calls"');
|
||||||
|
expect(output).toContain('"content":"final answer"');
|
||||||
|
expect(output).toContain('data: [DONE]');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -10,6 +10,7 @@ export interface CloudTokenData {
|
|||||||
email?: string;
|
email?: string;
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
|
upstream_proxy_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudQuotaData {
|
export interface CloudQuotaData {
|
||||||
@@ -48,6 +49,7 @@ export const CloudTokenDataSchema = z.object({
|
|||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
project_id: z.string().optional(),
|
project_id: z.string().optional(),
|
||||||
session_id: z.string().optional(),
|
session_id: z.string().optional(),
|
||||||
|
upstream_proxy_url: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CloudQuotaDataSchema = z.object({
|
export const CloudQuotaDataSchema = z.object({
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const ProxyConfigSchema = z.object({
|
|||||||
port: z.number(), // 监听端口
|
port: z.number(), // 监听端口
|
||||||
api_key: z.string(), // API 密钥 (Also used for Admin Mode auth)
|
api_key: z.string(), // API 密钥 (Also used for Admin Mode auth)
|
||||||
auto_start: z.boolean(), // 是否自动启动
|
auto_start: z.boolean(), // 是否自动启动
|
||||||
|
backend_canary_enabled: z.boolean().default(true),
|
||||||
|
custom_mapping: z.record(z.string(), z.string()).default({}),
|
||||||
anthropic_mapping: z.record(z.string(), z.string()), // 映射表
|
anthropic_mapping: z.record(z.string(), z.string()), // 映射表
|
||||||
request_timeout: z.number().default(120), // 超时秒数
|
request_timeout: z.number().default(120), // 超时秒数
|
||||||
upstream_proxy: UpstreamProxyConfigSchema,
|
upstream_proxy: UpstreamProxyConfigSchema,
|
||||||
@@ -49,6 +51,8 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|||||||
port: 8045,
|
port: 8045,
|
||||||
api_key: '', // Generated dynamically if default needed
|
api_key: '', // Generated dynamically if default needed
|
||||||
auto_start: false,
|
auto_start: false,
|
||||||
|
backend_canary_enabled: true,
|
||||||
|
custom_mapping: {},
|
||||||
anthropic_mapping: {},
|
anthropic_mapping: {},
|
||||||
request_timeout: 120,
|
request_timeout: 120,
|
||||||
upstream_proxy: {
|
upstream_proxy: {
|
||||||
|
|||||||
Reference in new issue
Block a user