import { Injectable, Logger, Inject } from '@nestjs/common'; import { isEmpty, isNil, isNumber, isPlainObject, isString } from 'lodash-es'; import { TokenManagerService } from './token-manager.service'; import { GeminiClient } from './clients/gemini.client'; import { TokenUsageService } from '../usage/token-usage.service'; import { v4 as uuidv4 } from 'uuid'; import { Observable } from 'rxjs'; import { transformClaudeRequestIn } from '../../../lib/geminiNexus/ClaudeRequestMapper'; import { transformResponse } from '../../../lib/geminiNexus/ClaudeResponseMapper'; import { StreamingState, PartProcessor } from '../../../lib/geminiNexus/ClaudeStreamingMapper'; import { ClaudeRequest, ClaudeResponse, GeminiInternalRequest, GeminiPart as InternalGeminiPart, } from '../../../lib/geminiNexus/types'; import { calculateRetryDelay, sleep } from '../../../lib/geminiNexus/retry-utils'; import { normalizeObjectJsonSchema } from '../../../lib/geminiNexus/JsonSchemaUtils'; import { classifyStreamError } from '../../../lib/geminiNexus/stream-error-utils'; import { OpenAIChatRequest, AnthropicChatRequest, GeminiResponse, GeminiRequest, AnthropicChatResponse, OpenAIChatResponse, AnthropicContent, } from './interfaces/request-interfaces'; import { getServerConfig } from '../../server-config'; import { normalizeGeminiModelAlias, resolveModelRoute, } from '../../../lib/geminiNexus/ModelMapping'; import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/ModelSpecs'; import { resolveRequestUserAgent } from './request-user-agent'; import { UpstreamRequestError } from './clients/upstream-error'; import { trafficLogger } from '../../../utils/traffic-logger'; import { encode } from 'gpt-tokenizer'; export interface ProxyRequestContext { requestId?: string; accountId?: string; retryCount?: number; model?: string; cacheStatus?: 'HIT' | 'MISS'; } export interface ProxyConfigOverride { accountId?: string; schedulingMode?: 'cache-first' | 'balance' | 'performance-first'; maxRetries?: number; timeout?: number; } @Injectable() export class ProxyService { private readonly logger = new Logger(ProxyService.name); private readonly responseCache = new Map< string, { response: OpenAIChatResponse; expiry: number } >(); private readonly CACHE_TTL_MS = 60000; // 1 minute constructor( @Inject(TokenManagerService) private readonly tokenManager: TokenManagerService, @Inject(GeminiClient) private readonly geminiClient: GeminiClient, @Inject(TokenUsageService) private readonly tokenUsageService: TokenUsageService, ) {} private recordUsage( accountId: string, model: string, usage: | { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined | null, requestType: string, isEstimated?: boolean, ): void { if (!usage) { return; } const prompt = usage.promptTokenCount ?? 0; const completion = usage.candidatesTokenCount ?? 0; const total = usage.totalTokenCount ?? prompt + completion; this.tokenUsageService.recordUsage({ accountId, model, promptTokens: prompt, completionTokens: completion, totalTokens: total, timestamp: Date.now(), requestType, isEstimated, }); } private estimateTokens(text: string): number { try { return encode(text).length; } catch { return Math.ceil(text.length / 4); } } private extractUsageFromMetadata( meta: unknown, ): | { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined { if (!meta || typeof meta !== 'object') return undefined; const m = meta as Record; return { promptTokenCount: typeof m.promptTokenCount === 'number' ? m.promptTokenCount : undefined, candidatesTokenCount: typeof m.candidatesTokenCount === 'number' ? m.candidatesTokenCount : undefined, totalTokenCount: typeof m.totalTokenCount === 'number' ? m.totalTokenCount : undefined, }; } // --- Anthropic Handlers --- async handleAnthropicMessages( request: AnthropicChatRequest, context?: ProxyRequestContext, configOverride?: ProxyConfigOverride, ): Promise> { const sessionKey = this.extractAnthropicSessionKey(request); const targetModel = this.resolveTargetModel(request.model); const extraHeaders = this.createModelSpecificHeaders(request.model); this.logger.log( `Anthropic request received: model=${request.model}, mappedModel=${targetModel}, stream=${request.stream}`, ); // Retry loop let lastError: unknown = null; const maxRetries = 3; const attemptedAccountIds = new Set(); for (let i = 0; i < maxRetries; i++) { if (i > 0) { const delay = calculateRetryDelay(i - 1); this.logger.log(`Anthropic retry ${i + 1}/${maxRetries}, backoff=${delay}ms (jittered)`); await sleep(delay); } const token = await this.tokenManager.getNextToken({ sessionKey, excludeAccountIds: Array.from(attemptedAccountIds), model: targetModel, accountId: configOverride?.accountId, }); if (!token) { throw new Error('No available accounts'); } attemptedAccountIds.add(token.id); if (context) { context.accountId = token.id; context.retryCount = i; context.model = targetModel; } const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount( token.id, targetModel, ); try { const projectId = token.token.project_id ?? ''; const requestUserAgent = await resolveRequestUserAgent(); const geminiBody = transformClaudeRequestIn( this.toClaudeRequest(request), projectId, requestUserAgent, ); geminiBody.model = effectiveTargetModel; this.applyInternalGenerationConstraints(geminiBody, effectiveTargetModel, token.id); if (request.stream) { const stream = await this.geminiClient.streamGenerateInternal( geminiBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); return this.processAnthropicInternalStream(stream, geminiBody.model, token.id); } else { const response = await this.generateInternalWithStreamFallback( geminiBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); return this.toAnthropicChatResponse(transformResponse(response)); } } catch (error) { if (error instanceof Error && this.isProjectContextError(error.message)) { this.logger.warn( `Anthropic request hit project context issue, retrying without project: ${error.message}`, ); try { const requestUserAgent = await resolveRequestUserAgent(); const fallbackBody = transformClaudeRequestIn( this.toClaudeRequest(request), '', requestUserAgent, ); fallbackBody.model = effectiveTargetModel; this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id); if (request.stream) { const stream = await this.geminiClient.streamGenerateInternal( fallbackBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); return this.processAnthropicInternalStream(stream, fallbackBody.model, token.id); } else { const response = await this.generateInternalWithStreamFallback( fallbackBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); return this.toAnthropicChatResponse(transformResponse(response)); } } catch (fallbackErr) { lastError = fallbackErr; } } if (error instanceof Error && this.isQuotaExhaustedError(error.message)) { this.logger.warn( `Anthropic request hit quota exhaustion on mapped model, retrying with fallback model gemini-3-flash: ${error.message}`, ); try { const downgradedRequest: ClaudeRequest = { ...this.toClaudeRequest(request), model: 'gemini-3-flash', }; const requestUserAgent = await resolveRequestUserAgent(); const downgradedBody = transformClaudeRequestIn( downgradedRequest, token.token.project_id ?? '', requestUserAgent, ); this.applyInternalGenerationConstraints(downgradedBody, 'gemini-3-flash', token.id); if (request.stream) { const stream = await this.geminiClient.streamGenerateInternal( downgradedBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); return this.processAnthropicInternalStream(stream, downgradedBody.model, token.id); } else { const response = await this.generateInternalWithStreamFallback( downgradedBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); this.recordUsage(token.id, 'gemini-3-flash', response.usageMetadata, 'anthropic'); const transformed = this.toAnthropicChatResponse(transformResponse(response)); return { ...transformed, model: request.model, }; } } catch (downgradeErr) { lastError = downgradeErr; } } lastError = error; await this.applyUpstreamPenalty(token.id, effectiveTargetModel, error); const errorMessage = error instanceof Error ? error.message : String(error); const penaltyDecision = this.classifyUpstreamFailure(errorMessage); if (!penaltyDecision.retry) { throw error; } } } throw lastError || new Error('Request failed after retries'); } private processAnthropicInternalStream( upstreamStream: NodeJS.ReadableStream, _model: string, accountId: string, ): Observable { return new Observable((subscriber) => { const decoder = new TextDecoder(); let buffer = ''; const state = new StreamingState(); const processor = new PartProcessor(state); let lastFinishReason: string | undefined; let lastUsageMetadata: Record | undefined; let accumulatedText = ''; let receivedData = false; upstreamStream.on('data', (chunk: Buffer) => { receivedData = true; // Mark that we got data buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data: ')) continue; const dataStr = trimmed.slice(6); if (dataStr === '[DONE]') continue; try { const json = JSON.parse(dataStr); if (json) { const startMsg = state.emitMessageStart(json); if (startMsg) subscriber.next(startMsg); } const responsePayload = json.response ?? json; const candidate = responsePayload.candidates?.[0]; const part = candidate?.content?.parts?.[0]; if (candidate?.finishReason) { lastFinishReason = candidate.finishReason; } if (responsePayload.usageMetadata) { lastUsageMetadata = responsePayload.usageMetadata; } if (this.isGeminiPart(part)) { const chunks = processor.process(part); chunks.forEach((c) => subscriber.next(c)); if (part.text) { accumulatedText += part.text; } } // Reset error state on successful parse state.resetErrorState(); } catch (e) { this.logger.error('Stream parse error', e); const errorChunks = state.handleParseError(dataStr); errorChunks.forEach((c) => subscriber.next(c)); } } }); upstreamStream.on('end', () => { if (!receivedData) { this.logger.warn('Empty response stream detected'); subscriber.error(new Error('Empty response stream')); return; } const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata); finishChunks.forEach((c) => subscriber.next(c)); const usage = this.extractUsageFromMetadata(lastUsageMetadata); if (usage) { this.recordUsage(accountId, _model, usage, 'anthropic'); } else if (accumulatedText) { const estimated = this.estimateTokens(accumulatedText); this.recordUsage( accountId, _model, { promptTokenCount: 0, candidatesTokenCount: estimated, totalTokenCount: estimated, }, 'anthropic', true, ); } subscriber.complete(); }); upstreamStream.on('error', (err: unknown) => { const cleanError = err instanceof Error ? err : new Error(String(err)); const { type } = classifyStreamError(cleanError); this.logger.error(`Stream error: ${type} - ${cleanError.message}`); subscriber.error(cleanError); }); }); } // --- OpenAI / Universal Handlers --- async handleGeminiGenerateContent( model: string, request: GeminiRequest, ): Promise { const normalizedModel = this.normalizeGeminiModel(model); const targetModel = this.resolveTargetModel(normalizedModel); const extraHeaders = this.createModelSpecificHeaders(normalizedModel); this.logger.log( `Gemini generate request received: model=${normalizedModel}, mappedModel=${targetModel}`, ); let lastError: unknown = null; const maxRetries = 3; const attemptedAccountIds = new Set(); for (let i = 0; i < maxRetries; i++) { if (i > 0) { const delay = calculateRetryDelay(i - 1); this.logger.log(`Gemini retry attempt ${i + 1}/${maxRetries}, waiting ${delay}ms`); await sleep(delay); } const token = await this.tokenManager.getNextToken({ excludeAccountIds: Array.from(attemptedAccountIds), model: targetModel, }); if (!token) { throw new Error('No available accounts (all exhausted or rate limited)'); } attemptedAccountIds.add(token.id); const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount( token.id, targetModel, ); try { const requestUserAgent = await resolveRequestUserAgent(); const internalBody = this.createGeminiInternalRequest( effectiveTargetModel, request, token.token.project_id ?? '', 'generate-content', requestUserAgent, ); this.applyInternalGenerationConstraints(internalBody, effectiveTargetModel, token.id); const response = await this.generateInternalWithStreamFallback( internalBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini'); return this.normalizeGeminiGenerateResponse(response); } catch (err) { if (err instanceof Error && this.isProjectContextError(err.message)) { this.logger.warn( `Gemini request hit project context issue, retrying without project: ${err.message}`, ); try { const requestUserAgent = await resolveRequestUserAgent(); const fallbackBody = this.createGeminiInternalRequest( effectiveTargetModel, request, '', 'generate-content', requestUserAgent, ); this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id); const response = await this.generateInternalWithStreamFallback( fallbackBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini'); return this.normalizeGeminiGenerateResponse(response); } catch (fallbackErr) { lastError = fallbackErr; } } else { lastError = err; } await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError); const errorMessage = lastError instanceof Error ? lastError.message : String(lastError); const penaltyDecision = this.classifyUpstreamFailure(errorMessage); if (!penaltyDecision.retry) { throw lastError; } } } throw lastError || new Error('Gemini request failed after retries'); } async handleGeminiStreamGenerateContent( model: string, request: GeminiRequest, ): Promise> { const normalizedModel = this.normalizeGeminiModel(model); const targetModel = this.resolveTargetModel(normalizedModel); const extraHeaders = this.createModelSpecificHeaders(normalizedModel); this.logger.log( `Gemini stream request received: model=${normalizedModel}, mappedModel=${targetModel}`, ); let lastError: unknown = null; const maxRetries = 3; const attemptedAccountIds = new Set(); for (let i = 0; i < maxRetries; i++) { if (i > 0) { const delay = calculateRetryDelay(i - 1); this.logger.log(`Gemini stream retry attempt ${i + 1}/${maxRetries}, waiting ${delay}ms`); await sleep(delay); } const token = await this.tokenManager.getNextToken({ excludeAccountIds: Array.from(attemptedAccountIds), model: targetModel, }); if (!token) { throw new Error('No available accounts (all exhausted or rate limited)'); } attemptedAccountIds.add(token.id); const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount( token.id, targetModel, ); try { const requestUserAgent = await resolveRequestUserAgent(); const internalBody = this.createGeminiInternalRequest( effectiveTargetModel, request, token.token.project_id ?? '', 'generate-content', requestUserAgent, ); this.applyInternalGenerationConstraints(internalBody, effectiveTargetModel, token.id); const stream = await this.geminiClient.streamGenerateInternal( internalBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); return this.passthroughSseStream(stream, token.id, effectiveTargetModel); } catch (err) { if (err instanceof Error && this.isProjectContextError(err.message)) { this.logger.warn( `Gemini stream request hit project context issue, retrying without project: ${err.message}`, ); try { const requestUserAgent = await resolveRequestUserAgent(); const fallbackBody = this.createGeminiInternalRequest( effectiveTargetModel, request, '', 'generate-content', requestUserAgent, ); this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id); const stream = await this.geminiClient.streamGenerateInternal( fallbackBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); return this.passthroughSseStream(stream, token.id, effectiveTargetModel); } catch (fallbackErr) { lastError = fallbackErr; } } else { lastError = err; } await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError); const errorMessage = lastError instanceof Error ? lastError.message : String(lastError); const penaltyDecision = this.classifyUpstreamFailure(errorMessage); if (!penaltyDecision.retry) { throw lastError; } } } throw lastError || new Error('Gemini stream request failed after retries'); } private passthroughSseStream( upstreamStream: NodeJS.ReadableStream, accountId: string, model: string, ): Observable { return new Observable((subscriber) => { const decoder = new TextDecoder(); let buffer = ''; let receivedData = false; let lastUsageMetadata: Record | undefined; let accumulatedText = ''; upstreamStream.on('data', (chunk: Buffer) => { receivedData = true; buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data: ')) continue; const dataStr = trimmed.slice(6); if (dataStr === '[DONE]') continue; try { const parsed = JSON.parse(dataStr); const responsePayload = parsed?.response ?? parsed; if (responsePayload?.usageMetadata) { lastUsageMetadata = responsePayload.usageMetadata; } const text = responsePayload?.candidates?.[0]?.content?.parts?.[0]?.text; if (typeof text === 'string') { accumulatedText += text; } } catch { /* ignore */ } } subscriber.next(chunk.toString()); }); upstreamStream.on('end', () => { if (!receivedData) { subscriber.error(new Error('Empty response stream')); return; } const usage = this.extractUsageFromMetadata(lastUsageMetadata); if (usage) { this.recordUsage(accountId, model, usage, 'gemini'); } else if (accumulatedText) { const estimated = this.estimateTokens(accumulatedText); this.recordUsage( accountId, model, { promptTokenCount: 0, candidatesTokenCount: estimated, totalTokenCount: estimated, }, 'gemini', true, ); } subscriber.complete(); }); upstreamStream.on('error', (err: unknown) => { const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err)); subscriber.error(cleanError); }); }); } private normalizeGeminiModel(model: string): string { return model.replace(/^models\//i, ''); } private normalizeModelIdentifier(model: string): string { return model.replace(/^models\//i, '').trim(); } private resolveThinkingLevelBudget(level: string): number | undefined { const normalized = level.trim().toUpperCase(); if (normalized === 'NONE') { return 0; } if (normalized === 'LOW') { return 4096; } if (normalized === 'MEDIUM') { return 8192; } if (normalized === 'HIGH') { return 24576; } return undefined; } private getModelOutputCap(accountId: string, model: string): number { const normalizedModel = this.normalizeModelIdentifier(model); const dynamicCap = this.tokenManager.getModelOutputLimitForAccount(accountId, normalizedModel); if (isNumber(dynamicCap) && Number.isFinite(dynamicCap) && dynamicCap > 0) { return Math.floor(dynamicCap); } return getMaxOutputTokens(normalizedModel); } private getModelThinkingBudget(accountId: string, model: string): number { const normalizedModel = this.normalizeModelIdentifier(model); const dynamicBudget = this.tokenManager.getModelThinkingBudgetForAccount( accountId, normalizedModel, ); if (isNumber(dynamicBudget) && Number.isFinite(dynamicBudget) && dynamicBudget >= 0) { return Math.floor(dynamicBudget); } return getThinkingBudget(normalizedModel); } private applyInternalGenerationConstraints( body: GeminiInternalRequest, model: string, accountId: string, ): void { const generationConfig = body.request.generationConfig; if (!generationConfig) { return; } const outputCap = this.getModelOutputCap(accountId, model); const thinkingBudgetCap = this.getModelThinkingBudget(accountId, model); const normalizedModel = this.normalizeModelIdentifier(model).toLowerCase(); const isClaudeModel = normalizedModel.includes('claude'); const thinkingConfig = generationConfig.thinkingConfig as | ({ thinkingLevel?: string; thinkingBudget?: number } & Record) | undefined; const adaptiveSentinel = thinkingConfig && (isString(thinkingConfig.thinkingLevel) || thinkingConfig.thinkingBudget === -1 || thinkingConfig.thinkingBudget === 32768); if (thinkingConfig) { if (!isClaudeModel && isString(thinkingConfig.thinkingLevel)) { const converted = this.resolveThinkingLevelBudget(thinkingConfig.thinkingLevel); if (converted !== undefined) { thinkingConfig.thinkingBudget = converted; } delete thinkingConfig.thinkingLevel; } if (isNumber(thinkingConfig.thinkingBudget) && thinkingConfig.thinkingBudget < 0) { thinkingConfig.thinkingBudget = Math.min(thinkingBudgetCap, 24576); } if ( isNumber(thinkingConfig.thinkingBudget) && Number.isFinite(thinkingConfig.thinkingBudget) ) { thinkingConfig.thinkingBudget = Math.min( Math.floor(thinkingConfig.thinkingBudget), Math.max(0, outputCap - 1), thinkingBudgetCap, ); if (adaptiveSentinel) { if ( generationConfig.maxOutputTokens === undefined || generationConfig.maxOutputTokens < 131072 ) { generationConfig.maxOutputTokens = 131072; } } else if ( generationConfig.maxOutputTokens === undefined || generationConfig.maxOutputTokens <= thinkingConfig.thinkingBudget ) { const hasExplicitMax = generationConfig.maxOutputTokens !== undefined; const overhead = hasExplicitMax ? 8192 : 32768; const minRequired = Math.min(outputCap, thinkingConfig.thinkingBudget + overhead); generationConfig.maxOutputTokens = minRequired; } } } if ( isNumber(generationConfig.maxOutputTokens) && Number.isFinite(generationConfig.maxOutputTokens) ) { generationConfig.maxOutputTokens = Math.min( Math.floor(generationConfig.maxOutputTokens), outputCap, ); } } private createGeminiInternalRequest( model: string, request: GeminiRequest, projectId: string | undefined, requestType: string, requestUserAgent: string, ): GeminiInternalRequest { const normalizedProjectId = projectId?.trim(); const internalRequest: GeminiInternalRequest = { requestId: uuidv4(), request: this.toInternalGeminiRequest(request), model, userAgent: requestUserAgent, requestType, }; if (normalizedProjectId) { internalRequest.project = normalizedProjectId; } return internalRequest; } private normalizeGeminiGenerateResponse(response: GeminiResponse): GeminiResponse { const candidates = Array.isArray(response.candidates) ? response.candidates.map((candidate, index) => ({ content: candidate?.content, finishReason: candidate?.finishReason, index: isNumber(candidate?.index) ? candidate.index : index, })) : []; const normalized: GeminiResponse = { candidates, }; const usage = response.usageMetadata; if (usage) { const usageMetadata: NonNullable = {}; if (usage.promptTokenCount !== undefined) { usageMetadata.promptTokenCount = usage.promptTokenCount; } if (usage.candidatesTokenCount !== undefined) { usageMetadata.candidatesTokenCount = usage.candidatesTokenCount; } if (usage.totalTokenCount !== undefined) { usageMetadata.totalTokenCount = usage.totalTokenCount; } if (usage.promptTokensDetails !== undefined) { usageMetadata.promptTokensDetails = usage.promptTokensDetails; } if (usage.candidatesTokensDetails !== undefined) { usageMetadata.candidatesTokensDetails = usage.candidatesTokensDetails; } if (usage.trafficType !== undefined) { usageMetadata.trafficType = usage.trafficType; } if (!isEmpty(usageMetadata)) { normalized.usageMetadata = usageMetadata; } } return normalized; } async handleChatCompletions( request: OpenAIChatRequest, context?: ProxyRequestContext, configOverride?: ProxyConfigOverride, _requestId?: string, ): Promise> { const targetModel = this.resolveTargetModel(request.model); const extraHeaders = this.createModelSpecificHeaders(request.model); this.logger.log( `Anthropic request received: model=${request.model}, mappedModel=${targetModel}, stream=${request.stream}`, ); const sessionKey = this.extractOpenAISessionKey(request); const requestId = uuidv4(); // Cache lookup for non-streaming requests if (!request.stream) { const cacheKey = this.buildCacheKey(request); const cached = this.getCachedResponse(cacheKey); if (cached) { if (context) { context.cacheStatus = 'HIT'; context.accountId = 'cache'; context.model = request.model; context.retryCount = 0; } return cached; } } // Retry loop let lastError: unknown = null; const maxRetries = configOverride?.maxRetries ?? 3; const attemptedAccountIds = new Set(); for (let i = 0; i < maxRetries; i++) { if (i > 0) { const delay = calculateRetryDelay(i - 1); this.logger.log( `OpenAI-compatible retry ${i + 1}/${maxRetries}, backoff=${delay}ms (jittered)`, ); await sleep(delay); } // 1. Get Token const token = await this.tokenManager.getNextToken({ sessionKey, excludeAccountIds: Array.from(attemptedAccountIds), model: targetModel, }); if (!token) { throw new Error('No available accounts (all exhausted or rate limited)'); } attemptedAccountIds.add(token.id); const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount( token.id, targetModel, ); if (context) { context.accountId = token.id; context.retryCount = i; context.model = effectiveTargetModel; context.requestId = requestId; } try { const claudeRequest = this.convertOpenAIToClaude(request); const projectId = token.token.project_id ?? ''; const requestUserAgent = await resolveRequestUserAgent(); const geminiBody = transformClaudeRequestIn(claudeRequest, projectId, requestUserAgent); geminiBody.model = effectiveTargetModel; this.applyInternalGenerationConstraints(geminiBody, effectiveTargetModel, token.id); // Use v1internal API (same as Anthropic handler) if (request.stream) { try { trafficLogger.logUpstream( requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel }, ); const stream = await this.geminiClient.streamGenerateInternal( geminiBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); return this.processStreamResponse( stream, request.model, requestId, token.id, effectiveTargetModel, ); } catch (streamError) { this.logger.warn( `Stream path failed for model=${request.model}; falling back to non-stream generation: ${ streamError instanceof Error ? streamError.message : String(streamError) }`, ); trafficLogger.logUpstream( requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel }, ); const response = await this.generateInternalWithStreamFallback( geminiBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); trafficLogger.logUpstreamResponse( requestId || 'unknown', '/v1/chat/completions', 200, response, 0, ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); this.logger.log( `Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`, ); const claudeResponse = transformResponse(response); const openaiResponse = this.convertClaudeToOpenAIResponse( claudeResponse, request.model, ); return this.createSyntheticOpenAIStream(openaiResponse); } } else { const response = await this.generateInternalWithStreamFallback( geminiBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); this.logger.log( `Upstream response snippet (non-stream): ${JSON.stringify(response).substring(0, 500)}`, ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); // Transform Gemini response to OpenAI format const claudeResponse = transformResponse(response); this.logger.log( `Transformed Claude response snippet: ${JSON.stringify(claudeResponse).substring(0, 500)}`, ); const openaiResponse = this.convertClaudeToOpenAIResponse(claudeResponse, request.model); const transformedResponse = this.applyResponseFormatTransformation( openaiResponse, request, ); this.setCachedResponse(this.buildCacheKey(request), transformedResponse); if (context) { context.cacheStatus = 'MISS'; } return transformedResponse; } } catch (err) { if (err instanceof Error && this.isProjectContextError(err.message)) { this.logger.warn( `OpenAI compatibility request hit project context issue, retrying without project: ${err.message}`, ); try { const claudeRequest = this.convertOpenAIToClaude(request); const requestUserAgent = await resolveRequestUserAgent(); const fallbackBody = transformClaudeRequestIn(claudeRequest, '', requestUserAgent); fallbackBody.model = effectiveTargetModel; this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id); if (request.stream) { trafficLogger.logUpstream( requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel }, ); const stream = await this.geminiClient.streamGenerateInternal( fallbackBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); return this.processStreamResponse( stream, request.model, requestId, token.id, effectiveTargetModel, ); } trafficLogger.logUpstream( requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel }, ); const response = await this.generateInternalWithStreamFallback( fallbackBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); trafficLogger.logUpstreamResponse( requestId || 'unknown', '/v1/chat/completions', 200, response, 0, ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); const claudeResponse = transformResponse(response); const openaiResponse = this.convertClaudeToOpenAIResponse( claudeResponse, request.model, ); const transformedResponse = this.applyResponseFormatTransformation( openaiResponse, request, ); this.setCachedResponse(this.buildCacheKey(request), transformedResponse); if (context) { context.cacheStatus = 'MISS'; } return transformedResponse; } catch (fallbackErr) { lastError = fallbackErr; } } else { lastError = err; } await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError); const errorMessage = lastError instanceof Error ? lastError.message : String(lastError); const penaltyDecision = this.classifyUpstreamFailure(errorMessage); if (!penaltyDecision.retry) { throw lastError; } } } throw lastError || new Error('Request failed after retries'); } private async generateInternalWithStreamFallback( body: GeminiInternalRequest, accessToken: string, upstreamProxyUrl?: string, extraHeaders?: Record, ): Promise { const direct = await this.geminiClient.generateInternal( body, accessToken, upstreamProxyUrl, extraHeaders, ); if (this.hasUsableGeminiCandidate(direct)) { return direct; } this.logger.warn('Empty non-stream response detected, falling back to stream aggregation.'); const stream = await this.geminiClient.streamGenerateInternal( body, accessToken, upstreamProxyUrl, extraHeaders, ); return this.collectGeminiStreamAsResponse(stream); } private hasUsableGeminiCandidate(response: GeminiResponse): boolean { const candidates = response?.candidates; if (!Array.isArray(candidates) || candidates.length === 0) { return false; } const first = candidates[0]; const parts = first?.content?.parts; return Array.isArray(parts) && parts.length > 0; } private collectGeminiStreamAsResponse( upstreamStream: NodeJS.ReadableStream, ): Promise { return new Promise((resolve, reject) => { const decoder = new TextDecoder(); let buffer = ''; let receivedData = false; const mergedParts: InternalGeminiPart[] = []; let finishReason: string | undefined; let usageMetadata: GeminiResponse['usageMetadata']; upstreamStream.on('data', (chunk: Buffer) => { receivedData = true; buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data: ')) { continue; } const dataStr = trimmed.slice(6); if (dataStr === '[DONE]') { continue; } try { const parsed = JSON.parse(dataStr); const candidate = parsed?.candidates?.[0]; const parts = candidate?.content?.parts; if (Array.isArray(parts)) { mergedParts.push( ...parts.filter((part): part is InternalGeminiPart => this.isGeminiPart(part)), ); } if (candidate?.finishReason) { finishReason = candidate.finishReason; } if (parsed?.usageMetadata) { usageMetadata = parsed.usageMetadata; } } catch { // Ignore malformed chunks and continue collecting valid parts. } } }); upstreamStream.on('end', () => { if (!receivedData) { reject(new Error('Empty response stream')); return; } resolve({ candidates: [ { content: { role: 'model', parts: mergedParts, }, finishReason, }, ], usageMetadata, }); }); upstreamStream.on('error', (error: unknown) => { reject(error instanceof Error ? error : new Error(String(error))); }); }); } // Handle SSE Stream conversion private processStreamResponse( upstreamStream: NodeJS.ReadableStream, model: string, requestId: string | undefined, accountId: string, effectiveTargetModel: string, ): Observable { return new Observable((subscriber) => { const decoder = new TextDecoder(); let buffer = ''; let hasEmittedChunk = false; let hasSentRoleChunk = false; let hasSentDone = false; let toolCallIndex = 0; let lastUsageMetadata: Record | undefined; let accumulatedText = ''; const streamId = `chatcmpl-${uuidv4()}`; const created = Math.floor(Date.now() / 1000); const pushRoleChunk = (): void => { if (hasSentRoleChunk) return; hasSentRoleChunk = true; const roleChunk = { id: streamId, object: 'chat.completion.chunk', created, model, choices: [ { index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null, }, ], }; subscriber.next(`data: ${JSON.stringify(roleChunk)}\n\n`); }; const pushChunk = (payload: Record): void => { hasEmittedChunk = true; subscriber.next(`data: ${JSON.stringify(payload)}\n\n`); }; upstreamStream.on('data', (chunk: Buffer) => { buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data: ')) continue; const dataStr = trimmed.slice(6); if (dataStr === '[DONE]') continue; try { const json = JSON.parse(dataStr); trafficLogger.logUpstreamResponse( requestId || 'unknown', '/v1/chat/completions', 200, json, 0, { rawLine: trimmed }, ); // v1internal API wraps response in {"response": {"candidates": [...]}} const responsePayload = json.response ?? json; if (responsePayload.usageMetadata) { lastUsageMetadata = responsePayload.usageMetadata; } const candidate = responsePayload.candidates?.[0]; const parts = candidate?.content?.parts || []; for (const part of parts) { if (part.thought && part.text) { accumulatedText += part.text; // Reasoning/thinking parts: emit via reasoning_content delta field // so clients that support it can display thinking separately. const reasoningChunk = { id: streamId, object: 'chat.completion.chunk', created: created, model: model, choices: [ { index: 0, delta: { reasoning_content: part.text }, finish_reason: null, }, ], }; pushRoleChunk(); pushChunk(reasoningChunk); continue; } if (part.functionCall) { pushRoleChunk(); const currentToolIndex = toolCallIndex++; const toolCallId = part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`; const argsJson = JSON.stringify(part.functionCall.args || {}); // First delta: id, type, name, empty arguments pushChunk({ id: streamId, object: 'chat.completion.chunk', created: created, model: model, choices: [ { index: 0, delta: { tool_calls: [ { index: currentToolIndex, id: toolCallId, type: 'function', function: { name: part.functionCall.name, arguments: '', }, }, ], }, finish_reason: null, }, ], }); // Subsequent deltas: only arguments (split if large to respect SSE framing) const ARG_CHUNK_SIZE = 1000; for (let i = 0; i < argsJson.length; i += ARG_CHUNK_SIZE) { const slice = argsJson.slice(i, i + ARG_CHUNK_SIZE); pushChunk({ id: streamId, object: 'chat.completion.chunk', created: created, model: model, choices: [ { index: 0, delta: { tool_calls: [ { index: currentToolIndex, function: { arguments: slice, }, }, ], }, finish_reason: null, }, ], }); } continue; } if (part.inlineData) { const mimeType = part.inlineData.mimeType || 'image/jpeg'; const data = part.inlineData.data || ''; const imageMarkdown = `\n\n![Generated Image](data:${mimeType};base64,${data})\n\n`; const imageChunk = { id: streamId, object: 'chat.completion.chunk', created: created, model: model, choices: [ { index: 0, delta: { content: imageMarkdown }, finish_reason: null, }, ], }; pushRoleChunk(); pushChunk(imageChunk); continue; } if (part.text) { accumulatedText += part.text; pushRoleChunk(); const contentChunk = { id: streamId, object: 'chat.completion.chunk', created: created, model: model, choices: [ { index: 0, delta: { content: part.text }, finish_reason: null, }, ], }; pushChunk(contentChunk); } } if (candidate?.finishReason) { // Per the OpenAI spec, finish_reason must be "tool_calls" when the // response contains function calls. Gemini always sends "STOP" even // for tool call responses, so we infer from toolCallIndex. const mappedFinishReason = toolCallIndex > 0 ? 'tool_calls' : this.mapGeminiFinishReasonToOpenAIFinishReason(candidate.finishReason); const finishChunk = { id: streamId, object: 'chat.completion.chunk', created: created, model: model, choices: [ { index: 0, delta: {}, finish_reason: mappedFinishReason, }, ], }; pushChunk(finishChunk); subscriber.next('data: [DONE]\n\n'); hasSentDone = true; } } catch { // ignore parse errors on individual SSE lines } } }); upstreamStream.on('end', () => { if (!hasEmittedChunk) { pushChunk({ id: streamId, object: 'chat.completion.chunk', created, model, choices: [ { index: 0, delta: { content: '' }, finish_reason: null, }, ], }); } if (!hasSentDone) { subscriber.next('data: [DONE]\n\n'); hasSentDone = true; } const usage = this.extractUsageFromMetadata(lastUsageMetadata); if (usage) { this.recordUsage(accountId, effectiveTargetModel, usage, 'openai'); } else if (accumulatedText) { const estimated = this.estimateTokens(accumulatedText); this.recordUsage( accountId, effectiveTargetModel, { promptTokenCount: 0, candidatesTokenCount: estimated, totalTokenCount: estimated, }, 'openai', true, ); } subscriber.complete(); }); upstreamStream.on('error', (err: unknown) => { // Convert to clean Error to avoid circular reference issues (socket objects) const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err)); this.logger.error(`OpenAI-compatible stream error: ${cleanError.message}`); subscriber.error(cleanError); }); }); } private createSyntheticOpenAIStream(response: OpenAIChatResponse): Observable { return new Observable((subscriber) => { const streamId = response.id || `chatcmpl-${uuidv4()}`; const created = response.created || Math.floor(Date.now() / 1000); const model = response.model; const choice = response.choices?.[0]; const finishReason = choice?.finish_reason ?? 'stop'; const toolCalls = choice?.message?.tool_calls; const content = choice?.message && isString(choice.message.content) ? choice.message.content : ''; const chunkSize = 80; // Emit tool_call deltas first when the fallback non-streaming response contains tool calls. // Without this, tool calls are silently dropped when the primary stream path fails. if (toolCalls && toolCalls.length > 0) { for (let tcIndex = 0; tcIndex < toolCalls.length; tcIndex++) { const tc = toolCalls[tcIndex]; const tcChunk = { id: streamId, object: 'chat.completion.chunk', created, model, choices: [ { index: 0, delta: { tool_calls: [ { index: tcIndex, id: tc.id, type: tc.type, function: tc.function, }, ], }, finish_reason: null, }, ], }; subscriber.next(`data: ${JSON.stringify(tcChunk)}\n\n`); } } if (content.length === 0) { const finishChunk = { id: streamId, object: 'chat.completion.chunk', created, model, choices: [ { index: 0, delta: {}, finish_reason: finishReason, }, ], usage: response.usage, }; subscriber.next(`data: ${JSON.stringify(finishChunk)}\n\n`); subscriber.next('data: [DONE]\n\n'); subscriber.complete(); return; } for (let index = 0; index < content.length; index += chunkSize) { const piece = content.slice(index, index + chunkSize); const isLast = index + chunkSize >= content.length; const chunk = { id: streamId, object: 'chat.completion.chunk', created, model, choices: [ { index: 0, delta: { content: piece }, finish_reason: isLast ? finishReason : null, }, ], usage: isLast ? response.usage : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, }; subscriber.next(`data: ${JSON.stringify(chunk)}\n\n`); } subscriber.next('data: [DONE]\n\n'); subscriber.complete(); }); } private toClaudeRequest(request: AnthropicChatRequest): ClaudeRequest { return { model: request.model, messages: request.messages.map((message) => ({ role: message.role, content: message.content, })), system: request.system, tools: request.tools?.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.input_schema, type: tool.type, })), stream: request.stream, max_tokens: request.max_tokens, stop_sequences: request.stop_sequences, temperature: request.temperature, top_p: request.top_p, top_k: request.top_k, thinking: request.thinking, metadata: request.metadata, }; } private toAnthropicChatResponse(response: ClaudeResponse): AnthropicChatResponse { return { id: response.id, type: response.type, role: response.role, model: response.model, content: response.content, stop_reason: response.stop_reason, stop_sequence: response.stop_sequence, usage: { input_tokens: response.usage?.input_tokens ?? 0, output_tokens: response.usage?.output_tokens ?? 0, cache_creation_input_tokens: response.usage?.cache_creation_input_tokens, cache_read_input_tokens: response.usage?.cache_read_input_tokens, }, }; } private toInternalGeminiRequest(request: GeminiRequest): GeminiInternalRequest['request'] { return { contents: request.contents, generationConfig: request.generationConfig, systemInstruction: request.systemInstruction ? { parts: request.systemInstruction.parts .filter((part): part is { text: string } => isString(part.text)) .map((part) => ({ text: part.text })), } : undefined, }; } // Convert OpenAI request format to Claude/Anthropic format private convertOpenAIToClaude(request: OpenAIChatRequest): ClaudeRequest { const messages = request.messages || []; const systemPromptParts: string[] = []; const anthropicMessages: ClaudeRequest['messages'] = []; for (const msg of messages) { // 'developer' is OpenAI's newer equivalent of 'system' if (msg.role === 'system' || msg.role === 'developer') { const systemText = this.extractOpenAITextContent(msg.content); if (systemText) { systemPromptParts.push(systemText); } continue; } if (msg.role === 'tool') { const toolResultText = this.extractOpenAITextContent(msg.content) || ''; anthropicMessages.push({ role: 'user', content: [ { type: 'tool_result', tool_use_id: msg.tool_call_id || msg.name || `tool-result-${uuidv4()}`, content: toolResultText, is_error: false, }, ], }); continue; } const contentBlocks = this.convertOpenAIPartsToAnthropicContent(msg.content); if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) { for (const toolCall of msg.tool_calls) { contentBlocks.push({ type: 'tool_use', id: toolCall.id, name: toolCall.function.name, input: this.parseOpenAIFunctionArguments(toolCall.function.arguments), }); } } anthropicMessages.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: contentBlocks.length > 0 ? contentBlocks : '', }); } if ( request.response_format?.type === 'json_object' || request.response_format?.type === 'json_schema' ) { systemPromptParts.push( 'You must format your entire response as a single valid JSON object. Do not include any markdown formatting, explanations, or text outside the JSON structure.', ); } const systemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join('\n') : undefined; // Map OpenAI reasoning_effort to Claude thinking config const reasoningEffort = String(request.extra?.reasoning_effort ?? '').toLowerCase(); let thinking: ClaudeRequest['thinking'] | undefined; if (reasoningEffort === 'none') { // Explicitly disable thinking when client requests no reasoning thinking = { type: 'disabled' }; } else if ( reasoningEffort === 'low' || reasoningEffort === 'medium' || reasoningEffort === 'high' ) { thinking = { type: 'adaptive', effort: reasoningEffort }; } return { model: request.model, messages: anthropicMessages, system: systemPrompt, tools: this.convertOpenAIToolsToAnthropicTools(request.tools), max_tokens: request.max_tokens, temperature: request.temperature, top_p: request.top_p, stream: request.stream, ...(thinking ? { thinking } : {}), metadata: { ...(request.extra ?? {}), source: 'openai', }, }; } private convertOpenAIPartsToAnthropicContent( content: OpenAIChatRequest['messages'][number]['content'], ): AnthropicContent[] { if (content == null) { return []; } if (isString(content)) { return content.trim() ? [{ type: 'text', text: content }] : []; } // Guard against non-array content (e.g. an object or number) if (!Array.isArray(content)) { return [{ type: 'text', text: String(content) }]; } const blocks: AnthropicContent[] = []; for (const part of content) { if (part.type === 'text' && part.text) { blocks.push({ type: 'text', text: part.text }); continue; } if (part.type === 'image_url' && part.image_url?.url) { const url = part.image_url.url; const dataUri = url.match(/^data:(?[^;]+);base64,(?.+)$/); if (dataUri?.groups?.mime && dataUri.groups.data) { blocks.push({ type: 'image', source: { type: 'base64', media_type: dataUri.groups.mime, data: dataUri.groups.data, }, }); } else { blocks.push({ type: 'text', text: `[image_url] ${url}` }); } } } return blocks; } private extractOpenAITextContent( content: OpenAIChatRequest['messages'][number]['content'], ): string { if (content == null) { return ''; } if (isString(content)) { return content; } if (!Array.isArray(content)) { return String(content); } return content .filter((part) => part.type === 'text') .map((part) => part.text || '') .join('\n'); } private parseOpenAIFunctionArguments(argumentsString: string): Record { if (isEmpty(argumentsString.trim())) { return {}; } try { const parsed = JSON.parse(argumentsString); if (isPlainObject(parsed)) { return parsed as Record; } return { value: parsed }; } catch { return { raw: argumentsString }; } } private convertOpenAIToolsToAnthropicTools( tools: OpenAIChatRequest['tools'], ): AnthropicChatRequest['tools'] { if (!tools || tools.length === 0) { return undefined; } const result: NonNullable = []; const searchToolTypes = new Set([ 'web_search_20250305', 'google_search', 'google_search_retrieval', 'builtin_web_search', ]); for (const tool of tools) { if (!tool) { continue; } const toolType = isString(tool.type) ? tool.type.toLowerCase() : ''; const functionName = isString(tool.function?.name) ? tool.function.name : ''; const normalizedFunctionName = functionName.toLowerCase(); const isSearchTool = searchToolTypes.has(toolType) || searchToolTypes.has(normalizedFunctionName); if (isSearchTool) { result.push({ name: functionName || 'builtin_web_search', type: 'web_search_20250305', input_schema: { type: 'object', properties: {}, }, }); continue; } if (!tool.function || !functionName) { continue; } const inputSchema = normalizeObjectJsonSchema(tool.function.parameters); result.push({ name: functionName, description: tool.function.description, input_schema: inputSchema, }); } return result.length > 0 ? result : undefined; } private mapGeminiFinishReasonToOpenAIFinishReason(finishReason?: string): string | null { if (!finishReason) { return null; } const normalized = finishReason.toUpperCase(); if (normalized === 'STOP') { return 'stop'; } if (normalized === 'MAX_TOKENS') { return 'length'; } if ( normalized === 'SAFETY' || normalized === 'RECITATION' || normalized === 'OTHER' || normalized === 'IMAGE_SAFETY' || normalized === 'PROHIBITED_CONTENT' || normalized === 'SPII' ) { return 'content_filter'; } return finishReason.toLowerCase(); } private mapAnthropicStopReasonToOpenAIFinishReason(stopReason?: string | null): string | null { if (!stopReason) { return null; } if (stopReason === 'end_turn') { return 'stop'; } if (stopReason === 'max_tokens') { return 'length'; } if (stopReason === 'tool_use') { return 'tool_calls'; } return stopReason; } private normalizeToolCallArguments(input: unknown): string { if (isString(input)) { return input; } if (isNil(input)) { return '{}'; } try { return JSON.stringify(input); } catch { return '{}'; } } // Convert Claude response to OpenAI format private convertClaudeToOpenAIResponse( claudeResponse: ClaudeResponse, model: string, ): OpenAIChatResponse { const contentBlocks = Array.isArray(claudeResponse?.content) ? claudeResponse.content : []; const textContent = contentBlocks .filter( ( block, ): block is Extract => block?.type === 'text', ) .map((block) => block.text || '') .join(''); const reasoningContent = contentBlocks .filter( ( block, ): block is Extract< ClaudeResponse['content'][number], { type: 'thinking'; thinking: string } > => block?.type === 'thinking', ) .map((block) => block.thinking || '') .join(''); const toolCalls = contentBlocks .filter( ( block, ): block is Extract< ClaudeResponse['content'][number], { type: 'tool_use'; id: string; name: string; input: Record } > => block?.type === 'tool_use', ) .map((block, index: number) => ({ id: block.id || `tool-call-${index}`, type: 'function' as const, function: { name: block.name || 'unknown_tool', arguments: this.normalizeToolCallArguments(block.input), }, })); let finalContent = textContent; if (reasoningContent) { finalContent = `\n${reasoningContent}\n\n\n${textContent}`; } return { id: `chatcmpl-${uuidv4()}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: model, system_fingerprint: 'fp_gemininexus', choices: [ { index: 0, message: { role: 'assistant', // When tool_calls are present, content must be null per OpenAI spec. content: toolCalls.length > 0 ? null : finalContent || '', tool_calls: toolCalls.length > 0 ? toolCalls : undefined, reasoning_content: reasoningContent || undefined, }, finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason( claudeResponse.stop_reason, ), }, ], usage: { prompt_tokens: claudeResponse.usage?.input_tokens || 0, completion_tokens: claudeResponse.usage?.output_tokens || 0, total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0), }, }; } private resolveTargetModel(model: string): string { const normalizedModel = model.replace(/^models\//i, '').trim(); const config = getServerConfig(); const configuredMapping = { ...(config?.custom_mapping ?? {}), ...(config?.anthropic_mapping ?? {}), }; const customExactMapping: Record = {}; const wildcardMapping: Array<{ pattern: RegExp; target: string; }> = []; for (const [key, target] of Object.entries(configuredMapping)) { if (!key || !target) { continue; } if (key.includes('*')) { const escaped = key.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); wildcardMapping.push({ pattern: new RegExp(`^${escaped}$`, 'i'), target, }); continue; } customExactMapping[key] = target; } for (const wildcardRule of wildcardMapping) { if (wildcardRule.pattern.test(normalizedModel)) { return wildcardRule.target; } } const routedModel = resolveModelRoute(normalizedModel, customExactMapping, {}, {}); return normalizeGeminiModelAlias(routedModel); } private async applyUpstreamPenalty( accountId: string, model: string, error: unknown, ): Promise { this.tokenManager.recordParityError(); if (error instanceof UpstreamRequestError) { const status = error.status; if (status === 401) { this.logger.warn( `Upstream returned 401 for account ${accountId}; attempting token refresh`, ); const refreshed = await this.tokenManager.forceRefreshToken(accountId); if (refreshed) { this.logger.log(`Token refreshed successfully for account ${accountId}; retry permitted`); return; } this.logger.warn(`Token refresh failed for account ${accountId}; marking as forbidden`); this.tokenManager.markAsForbidden(accountId); return; } if (status === 403) { this.tokenManager.markAsForbidden(accountId); return; } await this.tokenManager.markFromUpstreamError({ accountIdOrEmail: accountId, status, retryAfter: error.headers?.retryAfter, body: error.body, model, }); return; } if (!(error instanceof Error)) { return; } this.logger.warn(`Upstream request failed for account ${accountId}: ${error.message}`); const penaltyDecision = this.classifyUpstreamFailure(error.message); if (!penaltyDecision.retry) { return; } if (penaltyDecision.markAsForbidden) { this.tokenManager.markAsForbidden(accountId); return; } if (penaltyDecision.markAsRateLimited) { this.tokenManager.markAsRateLimited(accountId); } } private classifyUpstreamFailure(errorMessage: string): { retry: boolean; markAsForbidden: boolean; markAsRateLimited: boolean; } { const normalizedErrorMessage = errorMessage.toLowerCase(); const isForbidden = normalizedErrorMessage.includes('401') || normalizedErrorMessage.includes('unauthorized') || normalizedErrorMessage.includes('invalid_grant') || normalizedErrorMessage.includes('403') || normalizedErrorMessage.includes('permission_denied') || normalizedErrorMessage.includes('forbidden'); if (isForbidden) { return { retry: true, markAsForbidden: true, markAsRateLimited: false, }; } const isRateLimitedSignal = normalizedErrorMessage.includes('429') || normalizedErrorMessage.includes('resource_exhausted') || normalizedErrorMessage.includes('quota') || normalizedErrorMessage.includes('rate_limit') || normalizedErrorMessage.includes('rate limit'); const shouldRetryByStatus = normalizedErrorMessage.includes('408') || normalizedErrorMessage.includes('429') || normalizedErrorMessage.includes('500') || normalizedErrorMessage.includes('502') || normalizedErrorMessage.includes('503') || normalizedErrorMessage.includes('504'); const shouldRetryByKeyword = normalizedErrorMessage.includes('resource_exhausted') || normalizedErrorMessage.includes('quota') || normalizedErrorMessage.includes('rate_limit') || normalizedErrorMessage.includes('timeout') || normalizedErrorMessage.includes('socket hang up') || normalizedErrorMessage.includes('empty response stream') || normalizedErrorMessage.includes('connection reset'); if (shouldRetryByStatus || shouldRetryByKeyword) { return { retry: true, markAsForbidden: false, markAsRateLimited: isRateLimitedSignal, }; } return { retry: false, markAsForbidden: false, markAsRateLimited: false, }; } private createModelSpecificHeaders(model: string | undefined): Record { if (!model) { return {}; } if (model.toLowerCase().includes('claude')) { return { 'anthropic-beta': 'claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14', }; } return {}; } private isProjectLicenseError(errorMessage: string): boolean { const msg = errorMessage.toLowerCase(); return ( msg.includes('#3501') || (msg.includes('google cloud project') && msg.includes('code assist license')) ); } private isProjectNotFoundError(errorMessage: string): boolean { const msg = errorMessage.toLowerCase(); return ( msg.includes('invalid project resource name projects/') || (msg.includes('resource projects/') && msg.includes('could not be found')) || (msg.includes('project') && msg.includes('not found')) ); } private isProjectContextError(errorMessage: string): boolean { return this.isProjectLicenseError(errorMessage) || this.isProjectNotFoundError(errorMessage); } private isQuotaExhaustedError(errorMessage: string): boolean { const msg = errorMessage.toLowerCase(); return ( msg.includes('resource has been exhausted') || msg.includes('resource_exhausted') || msg.includes('quota') ); } private applyResponseFormatTransformation( response: OpenAIChatResponse, request: OpenAIChatRequest, ): OpenAIChatResponse { if (!request.response_format?.type) { return response; } const type = request.response_format.type; if (type !== 'json_object' && type !== 'json_schema') { return response; } const content = response.choices?.[0]?.message?.content; if (!isString(content)) { return response; } // Try to extract JSON from markdown code blocks let jsonText = content; const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)\s*```/); if (codeBlockMatch?.[1]) { jsonText = codeBlockMatch[1].trim(); } try { // Validate it's parseable JSON JSON.parse(jsonText); // Replace the content with the extracted JSON string const newResponse = { ...response }; newResponse.choices = [...response.choices]; newResponse.choices[0] = { ...response.choices[0] }; newResponse.choices[0].message = { ...response.choices[0].message }; newResponse.choices[0].message.content = jsonText; return newResponse; } catch { // If not valid JSON, return original unchanged return response; } } private buildCacheKey(request: OpenAIChatRequest): string { const keyBody = { model: request.model, messages: request.messages, temperature: request.temperature, top_p: request.top_p, max_tokens: request.max_tokens, tools: request.tools, response_format: request.response_format, }; return JSON.stringify(keyBody); } private getCachedResponse(key: string): OpenAIChatResponse | undefined { const entry = this.responseCache.get(key); if (!entry) { return undefined; } if (Date.now() > entry.expiry) { this.responseCache.delete(key); return undefined; } return entry.response; } private setCachedResponse(key: string, response: OpenAIChatResponse): void { this.responseCache.set(key, { response, expiry: Date.now() + this.CACHE_TTL_MS }); } private extractAnthropicSessionKey(request: AnthropicChatRequest): string | undefined { const metadata = request.metadata; const sessionCandidate = metadata?.session_id ?? metadata?.sessionId ?? metadata?.user_id ?? metadata?.userId; if (!isString(sessionCandidate) || isEmpty(sessionCandidate.trim())) { return undefined; } return `anthropic:${sessionCandidate.trim()}`; } private extractOpenAISessionKey(request: OpenAIChatRequest): string | undefined { const extra = request.extra; const sessionCandidate = extra?.session_id ?? extra?.sessionId ?? extra?.user_id ?? extra?.userId; if (!isString(sessionCandidate) || isEmpty(sessionCandidate.trim())) { return undefined; } return `openai:${sessionCandidate.trim()}`; } private isGeminiPart(value: unknown): value is InternalGeminiPart { return isPlainObject(value); } }