style(design): apply Minimal Dark Mode design system\n\n- Switched to complete Dark Mode (OLED-friendly)\n- Established standard color palette matching modern technical aesthetics\n- Changed font to Plus Jakarta Sans for increased legibility\n- Enforced dark mode as default in App.tsx\n- Fixed several React cascading render (setState in useEffect) warnings

This commit is contained in:
Gemini Nexus Bot committed 2026-04-28 03:49:34 -03:00
1 parent b80531f8a8
commit bac7b5591e
738 files changed
+138513 -138123

No files matched your search

+448 -448
View File
@@ -1,448 +1,448 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios';
import { isEmpty, isFunction, isNil, isObjectLike, isString } from 'lodash-es';
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
import { GeminiInternalRequest } from '../../../../lib/geminiNexus/types';
import { getServerConfig } from '../../../server-config';
import { resolveRequestUserAgent } from '../request-user-agent';
import { UpstreamRequestError } from './upstream-error';
@Injectable()
export class GeminiClient {
private readonly logger = new Logger(GeminiClient.name);
// Default to v1beta for most features
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,
upstreamProxyUrl?: string,
): Promise<NodeJS.ReadableStream> {
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
try {
const response = await axios.post(url, content, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
responseType: 'stream',
timeout: 60000,
proxy: axiosProxy,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(`Gemini stream request failed: ${error.message}`);
throw new UpstreamRequestError({
message: error.response?.data?.error?.message || error.message,
status: error.response?.status,
headers: {
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
},
body: this.describeAxiosErrorData(error.response?.data),
});
}
this.throwAsCleanError(error);
}
}
async generate(
model: string,
content: GeminiRequest,
accessToken: string,
upstreamProxyUrl?: string,
): Promise<GeminiResponse> {
const url = `${this.baseUrl}/models/${model}:generateContent`;
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
try {
const response = await axios.post<GeminiResponse>(url, content, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
timeout: 60000, // 60s timeout
proxy: axiosProxy,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Gemini request failed: ${error.message} - ${this.safeStringify(error.response?.data)}`,
);
throw new UpstreamRequestError({
message: error.response?.data?.error?.message || error.message,
status: error.response?.status,
headers: {
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
},
body: this.describeAxiosErrorData(error.response?.data),
});
}
this.throwAsCleanError(error);
}
}
// --- Internal Gateway API Support ---
async streamGenerateInternal(
body: GeminiInternalRequest,
accessToken: string,
upstreamProxyUrl?: string,
extraHeaders?: Record<string, string>,
): Promise<NodeJS.ReadableStream> {
const response = await this.executeRequestWithEndpointFailover<NodeJS.ReadableStream>(
':streamGenerateContent?alt=sse',
body,
accessToken,
upstreamProxyUrl,
{
responseType: 'stream',
},
'stream-generate',
extraHeaders,
);
return response.data;
}
async generateInternal(
body: GeminiInternalRequest,
accessToken: string,
upstreamProxyUrl?: string,
extraHeaders?: Record<string, string>,
): Promise<GeminiResponse> {
const response = await this.executeRequestWithEndpointFailover<
GeminiResponse | { response: GeminiResponse }
>(
':generateContent',
body,
accessToken,
upstreamProxyUrl,
{},
'generate-content',
extraHeaders,
);
const payload = response.data;
if (isObjectLike(payload) && 'response' in payload) {
return (payload as { response: GeminiResponse }).response;
}
return payload as GeminiResponse;
}
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 shouldFailoverToNextEndpoint(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 executeRequestWithEndpointFailover<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 = await resolveRequestUserAgent();
const axiosProxy = this.resolveUpstreamAxiosProxy(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.shouldFailoverToNextEndpoint(error)) {
await this.throwUpstreamRequestError(error, operation);
}
this.logger.warn(
`[${operation}] request failed at ${baseUrl}; trying next endpoint (${index + 2}/${
baseUrls.length
}).`,
);
}
}
await this.throwUpstreamRequestError(lastError, operation);
throw new Error(`[${operation}] unexpected control flow after upstream error handling`);
}
private async throwUpstreamRequestError(error: unknown, operation: string): Promise<never> {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
const upstreamMessage = await this.extractAxiosErrorMessage(responseData);
this.logger.error(
`[${operation}] upstream request error: ${error.message} - ${this.describeAxiosErrorData(
responseData,
)}`,
);
throw new UpstreamRequestError({
message: upstreamMessage || error.message,
status: error.response?.status,
headers: {
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
},
body: this.describeAxiosErrorData(responseData),
});
}
this.throwAsCleanError(error);
}
private async extractAxiosErrorMessage(responseData: unknown): Promise<string | null> {
const fromObject = this.extractAxiosErrorMessageFromObject(responseData);
if (fromObject) {
return fromObject;
}
if (isString(responseData)) {
return this.extractAxiosErrorMessageFromText(responseData);
}
if (Buffer.isBuffer(responseData)) {
return this.extractAxiosErrorMessageFromText(responseData.toString('utf-8'));
}
if (this.isReadableStream(responseData)) {
const streamText = await this.readStreamAsText(responseData);
return streamText ? this.extractAxiosErrorMessageFromText(streamText) : null;
}
return null;
}
private extractAxiosErrorMessageFromObject(responseData: unknown): string | null {
if (!isObjectLike(responseData) || this.isReadableStream(responseData)) {
return null;
}
const errorRecord = (responseData as { error?: unknown }).error;
if (isObjectLike(errorRecord)) {
const message = (errorRecord as { message?: unknown }).message;
if (isString(message) && !isEmpty(message.trim())) {
return message.trim();
}
}
const message = (responseData as { message?: unknown }).message;
if (isString(message) && !isEmpty(message.trim())) {
return message.trim();
}
return null;
}
private extractAxiosErrorMessageFromText(rawText: string): string | null {
const text = rawText.trim();
if (!text) {
return null;
}
const sseLines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('data:'));
for (const line of sseLines) {
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') {
continue;
}
const parsed = this.tryParseJson(payload);
const message = this.extractAxiosErrorMessageFromObject(parsed);
if (message) {
return message;
}
}
const parsed = this.tryParseJson(text);
const fromJson = this.extractAxiosErrorMessageFromObject(parsed);
if (fromJson) {
return fromJson;
}
return null;
}
private tryParseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
private isReadableStream(value: unknown): value is NodeJS.ReadableStream {
return isObjectLike(value) && isFunction((value as { pipe?: unknown }).pipe);
}
private async readStreamAsText(stream: NodeJS.ReadableStream): Promise<string | null> {
return new Promise((resolve) => {
let buffer = '';
const maxChars = 512 * 1024;
stream.on('data', (chunk: Buffer | string) => {
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk);
if (buffer.length >= maxChars) {
return;
}
buffer += text;
if (buffer.length > maxChars) {
buffer = buffer.slice(0, maxChars);
}
});
stream.on('end', () => resolve(buffer));
stream.on('error', () => resolve(null));
});
}
private describeAxiosErrorData(responseData: unknown): string {
if (this.isReadableStream(responseData)) {
return '[stream]';
}
return this.safeStringify(responseData);
}
private resolveUpstreamAxiosProxy(
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 extractRetryAfterHeader(headers: unknown): string | undefined {
if (!isObjectLike(headers)) {
return undefined;
}
const retryAfter = (headers as Record<string, unknown>)['retry-after'];
if (isString(retryAfter) && !isEmpty(retryAfter.trim())) {
return retryAfter.trim();
}
if (Array.isArray(retryAfter) && retryAfter.length > 0) {
const first = retryAfter[0];
if (isString(first) && !isEmpty(first.trim())) {
return first.trim();
}
}
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));
}
/**
* Safely stringify an object, handling circular references
*/
private safeStringify(obj: unknown): string {
if (isNil(obj)) {
return String(obj);
}
try {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (isObjectLike(value)) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
});
} catch {
return '[Unserializable]';
}
}
}
import { Injectable, Logger } from '@nestjs/common';
import axios, { AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios';
import { isEmpty, isFunction, isNil, isObjectLike, isString } from 'lodash-es';
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
import { GeminiInternalRequest } from '../../../../lib/geminiNexus/types';
import { getServerConfig } from '../../../server-config';
import { resolveRequestUserAgent } from '../request-user-agent';
import { UpstreamRequestError } from './upstream-error';
@Injectable()
export class GeminiClient {
private readonly logger = new Logger(GeminiClient.name);
// Default to v1beta for most features
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,
upstreamProxyUrl?: string,
): Promise<NodeJS.ReadableStream> {
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
try {
const response = await axios.post(url, content, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
responseType: 'stream',
timeout: 60000,
proxy: axiosProxy,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(`Gemini stream request failed: ${error.message}`);
throw new UpstreamRequestError({
message: error.response?.data?.error?.message || error.message,
status: error.response?.status,
headers: {
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
},
body: this.describeAxiosErrorData(error.response?.data),
});
}
this.throwAsCleanError(error);
}
}
async generate(
model: string,
content: GeminiRequest,
accessToken: string,
upstreamProxyUrl?: string,
): Promise<GeminiResponse> {
const url = `${this.baseUrl}/models/${model}:generateContent`;
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
try {
const response = await axios.post<GeminiResponse>(url, content, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
timeout: 60000, // 60s timeout
proxy: axiosProxy,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Gemini request failed: ${error.message} - ${this.safeStringify(error.response?.data)}`,
);
throw new UpstreamRequestError({
message: error.response?.data?.error?.message || error.message,
status: error.response?.status,
headers: {
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
},
body: this.describeAxiosErrorData(error.response?.data),
});
}
this.throwAsCleanError(error);
}
}
// --- Internal Gateway API Support ---
async streamGenerateInternal(
body: GeminiInternalRequest,
accessToken: string,
upstreamProxyUrl?: string,
extraHeaders?: Record<string, string>,
): Promise<NodeJS.ReadableStream> {
const response = await this.executeRequestWithEndpointFailover<NodeJS.ReadableStream>(
':streamGenerateContent?alt=sse',
body,
accessToken,
upstreamProxyUrl,
{
responseType: 'stream',
},
'stream-generate',
extraHeaders,
);
return response.data;
}
async generateInternal(
body: GeminiInternalRequest,
accessToken: string,
upstreamProxyUrl?: string,
extraHeaders?: Record<string, string>,
): Promise<GeminiResponse> {
const response = await this.executeRequestWithEndpointFailover<
GeminiResponse | { response: GeminiResponse }
>(
':generateContent',
body,
accessToken,
upstreamProxyUrl,
{},
'generate-content',
extraHeaders,
);
const payload = response.data;
if (isObjectLike(payload) && 'response' in payload) {
return (payload as { response: GeminiResponse }).response;
}
return payload as GeminiResponse;
}
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 shouldFailoverToNextEndpoint(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 executeRequestWithEndpointFailover<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 = await resolveRequestUserAgent();
const axiosProxy = this.resolveUpstreamAxiosProxy(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.shouldFailoverToNextEndpoint(error)) {
await this.throwUpstreamRequestError(error, operation);
}
this.logger.warn(
`[${operation}] request failed at ${baseUrl}; trying next endpoint (${index + 2}/${
baseUrls.length
}).`,
);
}
}
await this.throwUpstreamRequestError(lastError, operation);
throw new Error(`[${operation}] unexpected control flow after upstream error handling`);
}
private async throwUpstreamRequestError(error: unknown, operation: string): Promise<never> {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
const upstreamMessage = await this.extractAxiosErrorMessage(responseData);
this.logger.error(
`[${operation}] upstream request error: ${error.message} - ${this.describeAxiosErrorData(
responseData,
)}`,
);
throw new UpstreamRequestError({
message: upstreamMessage || error.message,
status: error.response?.status,
headers: {
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
},
body: this.describeAxiosErrorData(responseData),
});
}
this.throwAsCleanError(error);
}
private async extractAxiosErrorMessage(responseData: unknown): Promise<string | null> {
const fromObject = this.extractAxiosErrorMessageFromObject(responseData);
if (fromObject) {
return fromObject;
}
if (isString(responseData)) {
return this.extractAxiosErrorMessageFromText(responseData);
}
if (Buffer.isBuffer(responseData)) {
return this.extractAxiosErrorMessageFromText(responseData.toString('utf-8'));
}
if (this.isReadableStream(responseData)) {
const streamText = await this.readStreamAsText(responseData);
return streamText ? this.extractAxiosErrorMessageFromText(streamText) : null;
}
return null;
}
private extractAxiosErrorMessageFromObject(responseData: unknown): string | null {
if (!isObjectLike(responseData) || this.isReadableStream(responseData)) {
return null;
}
const errorRecord = (responseData as { error?: unknown }).error;
if (isObjectLike(errorRecord)) {
const message = (errorRecord as { message?: unknown }).message;
if (isString(message) && !isEmpty(message.trim())) {
return message.trim();
}
}
const message = (responseData as { message?: unknown }).message;
if (isString(message) && !isEmpty(message.trim())) {
return message.trim();
}
return null;
}
private extractAxiosErrorMessageFromText(rawText: string): string | null {
const text = rawText.trim();
if (!text) {
return null;
}
const sseLines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('data:'));
for (const line of sseLines) {
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') {
continue;
}
const parsed = this.tryParseJson(payload);
const message = this.extractAxiosErrorMessageFromObject(parsed);
if (message) {
return message;
}
}
const parsed = this.tryParseJson(text);
const fromJson = this.extractAxiosErrorMessageFromObject(parsed);
if (fromJson) {
return fromJson;
}
return null;
}
private tryParseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
private isReadableStream(value: unknown): value is NodeJS.ReadableStream {
return isObjectLike(value) && isFunction((value as { pipe?: unknown }).pipe);
}
private async readStreamAsText(stream: NodeJS.ReadableStream): Promise<string | null> {
return new Promise((resolve) => {
let buffer = '';
const maxChars = 512 * 1024;
stream.on('data', (chunk: Buffer | string) => {
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk);
if (buffer.length >= maxChars) {
return;
}
buffer += text;
if (buffer.length > maxChars) {
buffer = buffer.slice(0, maxChars);
}
});
stream.on('end', () => resolve(buffer));
stream.on('error', () => resolve(null));
});
}
private describeAxiosErrorData(responseData: unknown): string {
if (this.isReadableStream(responseData)) {
return '[stream]';
}
return this.safeStringify(responseData);
}
private resolveUpstreamAxiosProxy(
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 extractRetryAfterHeader(headers: unknown): string | undefined {
if (!isObjectLike(headers)) {
return undefined;
}
const retryAfter = (headers as Record<string, unknown>)['retry-after'];
if (isString(retryAfter) && !isEmpty(retryAfter.trim())) {
return retryAfter.trim();
}
if (Array.isArray(retryAfter) && retryAfter.length > 0) {
const first = retryAfter[0];
if (isString(first) && !isEmpty(first.trim())) {
return first.trim();
}
}
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));
}
/**
* Safely stringify an object, handling circular references
*/
private safeStringify(obj: unknown): string {
if (isNil(obj)) {
return String(obj);
}
try {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (isObjectLike(value)) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
});
} catch {
return '[Unserializable]';
}
}
}
@@ -1,39 +1,39 @@
import { isNumber, isObjectLike, isString } from 'lodash-es';
export interface UpstreamErrorHeaders {
retryAfter?: string;
}
export class UpstreamRequestError extends Error {
readonly status?: number;
readonly headers?: UpstreamErrorHeaders;
readonly body?: string;
constructor(params: {
message: string;
status?: number;
headers?: UpstreamErrorHeaders;
body?: string;
}) {
super(params.message);
this.name = 'UpstreamRequestError';
if (params.status && !isNumber(params.status)) {
throw new TypeError('status must be a number');
}
if (params.headers && !isObjectLike(params.headers)) {
throw new TypeError('headers must be an object');
}
this.status = params.status;
this.headers = params.headers;
// Sanitize and limit body size
if (isString(params.body)) {
const sanitized = params.body.replace(/<[^>]*>?/gm, '');
this.body = sanitized.substring(0, 1000);
} else {
this.body = undefined;
}
}
}
import { isNumber, isObjectLike, isString } from 'lodash-es';
export interface UpstreamErrorHeaders {
retryAfter?: string;
}
export class UpstreamRequestError extends Error {
readonly status?: number;
readonly headers?: UpstreamErrorHeaders;
readonly body?: string;
constructor(params: {
message: string;
status?: number;
headers?: UpstreamErrorHeaders;
body?: string;
}) {
super(params.message);
this.name = 'UpstreamRequestError';
if (params.status && !isNumber(params.status)) {
throw new TypeError('status must be a number');
}
if (params.headers && !isObjectLike(params.headers)) {
throw new TypeError('headers must be an object');
}
this.status = params.status;
this.headers = params.headers;
// Sanitize and limit body size
if (isString(params.body)) {
const sanitized = params.body.replace(/<[^>]*>?/gm, '');
this.body = sanitized.substring(0, 1000);
} else {
this.body = undefined;
}
}
}
+303 -303
View File
@@ -1,303 +1,303 @@
import {
Body,
Controller,
Get,
HttpStatus,
Inject,
Param,
Post,
Res,
UseGuards,
Optional,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { isEmpty, isFunction, isNumber, isString } from 'lodash-es';
import { Observable } from 'rxjs';
import { ProxyGuard } from './proxy.guard';
import { ProxyService } from './proxy.service';
import type { GeminiRequest, GeminiResponse } from './interfaces/request-interfaces';
import { getServerConfig } from '../../server-config';
import { getAllDynamicModels } from '../../../lib/geminiNexus/ModelMapping';
import { TokenManagerService } from './token-manager.service';
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,
@Optional() @Inject(TokenManagerService) private readonly tokenManager?: TokenManagerService,
) {}
@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.parseModelActionToken(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.handleModelActionDispatch(parsed.model, parsed.action, body, res);
}
@Post('models/:model/countTokens')
async countTokens(
@Param('model') model: string,
@Body() body: GeminiRequest,
@Res() res: FastifyReply,
) {
await this.handleModelActionDispatch(`models/${model}`, 'countTokens', body, res);
}
private async handleModelActionDispatch(
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.writeObservableSseResponse(res, stream);
return;
}
}
if (action === 'generateContent') {
const result = await this.proxyService.handleGeminiGenerateContent(model, body);
res.status(HttpStatus.OK).send(this.buildNormalizedGeminiGenerateResponse(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 parseModelActionToken(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 ?? {},
this.tokenManager?.getAllCollectedModels(),
);
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 buildNormalizedGeminiGenerateResponse(response: GeminiResponse): GeminiResponse {
const candidates = (response.candidates ?? []).map((candidate, index) => ({
content: candidate.content,
finishReason: candidate.finishReason,
index: isNumber(candidate.index) ? 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 writeObservableSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
if (!res.raw || !isFunction(res.raw.writeHead) || !isFunction(res.raw.write)) {
res.header('Content-Type', 'text/event-stream');
res.header('Cache-Control', 'no-cache');
res.header('Connection', 'keep-alive');
res.send(stream);
return;
}
if (this.supportsReplyHijack(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 = isString(chunk) ? 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 supportsReplyHijack(reply: FastifyReply): reply is FastifyReply & { hijack: () => void } {
return isFunction((reply as { hijack?: unknown }).hijack);
}
}
import {
Body,
Controller,
Get,
HttpStatus,
Inject,
Param,
Post,
Res,
UseGuards,
Optional,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { isEmpty, isFunction, isNumber, isString } from 'lodash-es';
import { Observable } from 'rxjs';
import { ProxyGuard } from './proxy.guard';
import { ProxyService } from './proxy.service';
import type { GeminiRequest, GeminiResponse } from './interfaces/request-interfaces';
import { getServerConfig } from '../../server-config';
import { getAllDynamicModels } from '../../../lib/geminiNexus/ModelMapping';
import { TokenManagerService } from './token-manager.service';
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,
@Optional() @Inject(TokenManagerService) private readonly tokenManager?: TokenManagerService,
) {}
@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.parseModelActionToken(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.handleModelActionDispatch(parsed.model, parsed.action, body, res);
}
@Post('models/:model/countTokens')
async countTokens(
@Param('model') model: string,
@Body() body: GeminiRequest,
@Res() res: FastifyReply,
) {
await this.handleModelActionDispatch(`models/${model}`, 'countTokens', body, res);
}
private async handleModelActionDispatch(
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.writeObservableSseResponse(res, stream);
return;
}
}
if (action === 'generateContent') {
const result = await this.proxyService.handleGeminiGenerateContent(model, body);
res.status(HttpStatus.OK).send(this.buildNormalizedGeminiGenerateResponse(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 parseModelActionToken(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 ?? {},
this.tokenManager?.getAllCollectedModels(),
);
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 buildNormalizedGeminiGenerateResponse(response: GeminiResponse): GeminiResponse {
const candidates = (response.candidates ?? []).map((candidate, index) => ({
content: candidate.content,
finishReason: candidate.finishReason,
index: isNumber(candidate.index) ? 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 writeObservableSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
if (!res.raw || !isFunction(res.raw.writeHead) || !isFunction(res.raw.write)) {
res.header('Content-Type', 'text/event-stream');
res.header('Cache-Control', 'no-cache');
res.header('Connection', 'keep-alive');
res.send(stream);
return;
}
if (this.supportsReplyHijack(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 = isString(chunk) ? 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 supportsReplyHijack(reply: FastifyReply): reply is FastifyReply & { hijack: () => void } {
return isFunction((reply as { hijack?: unknown }).hijack);
}
}
@@ -1,216 +1,216 @@
export interface OpenAIChatRequest {
model: string;
messages: OpenAIMessage[];
temperature?: number;
top_p?: number;
max_tokens?: number;
max_completion_tokens?: number;
stream?: boolean;
stream_options?: { include_usage?: boolean };
size?: string;
quality?: string;
tools?: OpenAITool[];
tool_choice?: string | { type: string; function?: { name: string } };
response_format?: { type?: string };
seed?: number;
presence_penalty?: number;
frequency_penalty?: number;
n?: number;
logprobs?: boolean;
extra?: Record<string, unknown>;
}
export interface OpenAIMessage {
role: string;
content: string | OpenAIContentPart[];
name?: string;
tool_calls?: OpenAIToolCall[];
tool_call_id?: string;
}
export interface OpenAIContentPart {
type: 'text' | 'image_url';
text?: string;
image_url?: {
url: string;
};
}
export interface OpenAITool {
type: string;
function?: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
[key: string]: unknown;
}
export interface OpenAIToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
export interface AnthropicChatRequest {
model: string;
messages: AnthropicMessage[];
system?: string | AnthropicSystemBlock[];
max_tokens?: number;
tools?: AnthropicTool[];
thinking?: AnthropicThinkingConfig;
metadata?: Record<string, unknown>;
stop_sequences?: string[];
stream?: boolean;
temperature?: number;
top_p?: 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 {
role: string;
content: string | AnthropicContent[];
}
export interface AnthropicSystemBlock {
type: string;
text: string;
}
export type AnthropicContent =
| { type: 'text'; text: string }
| { type: 'thinking'; thinking: string; signature?: string }
| { 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 {
type: 'base64';
media_type: string;
data: string;
}
export interface GeminiContent {
role: string;
parts: GeminiPart[];
}
export interface GeminiPart {
text?: string;
inlineData?: GeminiInlineData;
thoughtSignature?: string;
}
export interface GeminiInlineData {
mimeType: string;
data: string;
}
export interface GeminiRequest {
contents: GeminiContent[];
systemInstruction?: { parts: GeminiPart[] };
generationConfig?: GeminiGenerationConfig;
}
export interface GeminiGenerationConfig {
temperature?: number;
maxOutputTokens?: number;
topP?: number;
topK?: number;
}
export interface GeminiResponse {
candidates?: GeminiCandidate[];
usageMetadata?: GeminiUsageMetadata;
}
export interface GeminiCandidate {
content?: GeminiContent;
finishReason?: string;
index?: number;
}
export interface GeminiUsageMetadata {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
thoughtsTokenCount?: number;
promptTokensDetails?: Array<{
modality?: string;
tokenCount?: number;
}>;
candidatesTokensDetails?: Array<{
modality?: string;
tokenCount?: number;
}>;
trafficType?: string;
}
export interface OpenAIChatResponse {
id: string;
object: string;
created: number;
model: string;
choices: OpenAIChoice[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
/** Optional identifier for the model's configuration snapshot. Included for OpenAI-spec compliance. */
system_fingerprint?: string;
}
export interface OpenAIChoice {
index: number;
message: {
role: string;
content: string | null;
tool_calls?: OpenAIToolCall[];
reasoning_content?: string;
};
finish_reason: string | null;
}
export interface AnthropicChatResponse {
id: string;
type: string;
role: string;
model: string;
content: AnthropicContent[];
stop_reason: string | null;
stop_sequence?: string | null;
usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
}
export interface OpenAIChatRequest {
model: string;
messages: OpenAIMessage[];
temperature?: number;
top_p?: number;
max_tokens?: number;
max_completion_tokens?: number;
stream?: boolean;
stream_options?: { include_usage?: boolean };
size?: string;
quality?: string;
tools?: OpenAITool[];
tool_choice?: string | { type: string; function?: { name: string } };
response_format?: { type?: string };
seed?: number;
presence_penalty?: number;
frequency_penalty?: number;
n?: number;
logprobs?: boolean;
extra?: Record<string, unknown>;
}
export interface OpenAIMessage {
role: string;
content: string | OpenAIContentPart[];
name?: string;
tool_calls?: OpenAIToolCall[];
tool_call_id?: string;
}
export interface OpenAIContentPart {
type: 'text' | 'image_url';
text?: string;
image_url?: {
url: string;
};
}
export interface OpenAITool {
type: string;
function?: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
[key: string]: unknown;
}
export interface OpenAIToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
export interface AnthropicChatRequest {
model: string;
messages: AnthropicMessage[];
system?: string | AnthropicSystemBlock[];
max_tokens?: number;
tools?: AnthropicTool[];
thinking?: AnthropicThinkingConfig;
metadata?: Record<string, unknown>;
stop_sequences?: string[];
stream?: boolean;
temperature?: number;
top_p?: 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 {
role: string;
content: string | AnthropicContent[];
}
export interface AnthropicSystemBlock {
type: string;
text: string;
}
export type AnthropicContent =
| { type: 'text'; text: string }
| { type: 'thinking'; thinking: string; signature?: string }
| { 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 {
type: 'base64';
media_type: string;
data: string;
}
export interface GeminiContent {
role: string;
parts: GeminiPart[];
}
export interface GeminiPart {
text?: string;
inlineData?: GeminiInlineData;
thoughtSignature?: string;
}
export interface GeminiInlineData {
mimeType: string;
data: string;
}
export interface GeminiRequest {
contents: GeminiContent[];
systemInstruction?: { parts: GeminiPart[] };
generationConfig?: GeminiGenerationConfig;
}
export interface GeminiGenerationConfig {
temperature?: number;
maxOutputTokens?: number;
topP?: number;
topK?: number;
}
export interface GeminiResponse {
candidates?: GeminiCandidate[];
usageMetadata?: GeminiUsageMetadata;
}
export interface GeminiCandidate {
content?: GeminiContent;
finishReason?: string;
index?: number;
}
export interface GeminiUsageMetadata {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
thoughtsTokenCount?: number;
promptTokensDetails?: Array<{
modality?: string;
tokenCount?: number;
}>;
candidatesTokensDetails?: Array<{
modality?: string;
tokenCount?: number;
}>;
trafficType?: string;
}
export interface OpenAIChatResponse {
id: string;
object: string;
created: number;
model: string;
choices: OpenAIChoice[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
/** Optional identifier for the model's configuration snapshot. Included for OpenAI-spec compliance. */
system_fingerprint?: string;
}
export interface OpenAIChoice {
index: number;
message: {
role: string;
content: string | null;
tool_calls?: OpenAIToolCall[];
reasoning_content?: string;
};
finish_reason: string | null;
}
export interface AnthropicChatResponse {
id: string;
type: string;
role: string;
model: string;
content: AnthropicContent[];
stop_reason: string | null;
stop_sequence?: string | null;
usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
}
@@ -1,21 +1,21 @@
import { Injectable } from '@nestjs/common';
import { Subject, Observable } from 'rxjs';
export interface ProxyEvent {
type: 'account_switch' | 'rate_limit' | 'error' | 'metric' | 'heartbeat';
timestamp: number;
data: unknown;
}
@Injectable()
export class ProxyEventBus {
private readonly subject = new Subject<ProxyEvent>();
emit(event: ProxyEvent): void {
this.subject.next(event);
}
listen(): Observable<ProxyEvent> {
return this.subject.asObservable();
}
}
import { Injectable } from '@nestjs/common';
import { Subject, Observable } from 'rxjs';
export interface ProxyEvent {
type: 'account_switch' | 'rate_limit' | 'error' | 'metric' | 'heartbeat';
timestamp: number;
data: unknown;
}
@Injectable()
export class ProxyEventBus {
private readonly subject = new Subject<ProxyEvent>();
emit(event: ProxyEvent): void {
this.subject.next(event);
}
listen(): Observable<ProxyEvent> {
return this.subject.asObservable();
}
}
@@ -1,126 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
import { networkInterfaces } from 'os';
import { getServerConfig } from '../../server-config';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
export type SupportedIde = 'cursor' | 'vscode' | 'claude-code' | 'jetbrains' | 'opencide';
export interface IdeConfigResult {
ide: SupportedIde;
format: 'json' | 'shell' | 'env' | 'instructions';
content: Record<string, string> | string;
}
@Injectable()
export class ProxyIdeConfigService {
private readonly logger = new Logger(ProxyIdeConfigService.name);
constructor() {
registerProxyAdvancedService('proxyIdeConfig', this);
}
/**
* Return the first non-internal IPv4 address found on the machine.
* Falls back to '127.0.0.1' if no external interface is available.
*/
getLocalIp(): string {
const interfaces = networkInterfaces();
for (const iface of Object.values(interfaces)) {
if (!iface) {
continue;
}
for (const entry of iface) {
if (entry.family === 'IPv4' && !entry.internal) {
return entry.address;
}
}
}
return '127.0.0.1';
}
/**
* Generate a ready-to-paste IDE configuration for the given IDE.
*/
generateIdeConfig(
ide: SupportedIde,
port: number,
apiKey: string,
localIp: string,
): IdeConfigResult {
const baseUrl = `http://${localIp}:${port}/v1`;
switch (ide) {
case 'cursor': {
return {
ide,
format: 'json',
content: {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: baseUrl,
},
};
}
case 'vscode': {
return {
ide,
format: 'json',
content: {
'openai.url': baseUrl,
'openai.apiKey': apiKey,
},
};
}
case 'claude-code': {
return {
ide,
format: 'shell',
content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`,
};
}
case 'jetbrains': {
return {
ide,
format: 'instructions',
content: [
'JetBrains AI Assistant Setup:',
'1. Open Settings (File > Settings or Ctrl+Alt+S).',
'2. Navigate to Languages & Frameworks > AI Assistant.',
'3. Under "Custom OpenAI Endpoint", set:',
` - Base URL: ${baseUrl}`,
` - API Key: ${apiKey}`,
'4. Click "Apply" and restart the IDE if prompted.',
].join('\n'),
};
}
case 'opencide': {
return {
ide,
format: 'env',
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
};
}
default: {
// Exhaustive fallback to satisfy TypeScript
const exhaustiveCheck: never = ide;
this.logger.warn(`Unsupported IDE requested: ${String(exhaustiveCheck)}`);
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
}
}
}
/**
* Convenience helper that reads the current server config and local IP,
* then delegates to generateIdeConfig.
*/
generateConfigForCurrentServer(ide: SupportedIde): IdeConfigResult | null {
const config = getServerConfig();
if (!config) {
this.logger.warn('Server config not available; cannot generate IDE config');
return null;
}
const port = config.port;
const apiKey = config.api_key;
const localIp = this.getLocalIp();
return this.generateIdeConfig(ide, port, apiKey, localIp);
}
}
import { Injectable, Logger } from '@nestjs/common';
import { networkInterfaces } from 'os';
import { getServerConfig } from '../../server-config';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
export type SupportedIde = 'cursor' | 'vscode' | 'claude-code' | 'jetbrains' | 'opencide';
export interface IdeConfigResult {
ide: SupportedIde;
format: 'json' | 'shell' | 'env' | 'instructions';
content: Record<string, string> | string;
}
@Injectable()
export class ProxyIdeConfigService {
private readonly logger = new Logger(ProxyIdeConfigService.name);
constructor() {
registerProxyAdvancedService('proxyIdeConfig', this);
}
/**
* Return the first non-internal IPv4 address found on the machine.
* Falls back to '127.0.0.1' if no external interface is available.
*/
getLocalIp(): string {
const interfaces = networkInterfaces();
for (const iface of Object.values(interfaces)) {
if (!iface) {
continue;
}
for (const entry of iface) {
if (entry.family === 'IPv4' && !entry.internal) {
return entry.address;
}
}
}
return '127.0.0.1';
}
/**
* Generate a ready-to-paste IDE configuration for the given IDE.
*/
generateIdeConfig(
ide: SupportedIde,
port: number,
apiKey: string,
localIp: string,
): IdeConfigResult {
const baseUrl = `http://${localIp}:${port}/v1`;
switch (ide) {
case 'cursor': {
return {
ide,
format: 'json',
content: {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: baseUrl,
},
};
}
case 'vscode': {
return {
ide,
format: 'json',
content: {
'openai.url': baseUrl,
'openai.apiKey': apiKey,
},
};
}
case 'claude-code': {
return {
ide,
format: 'shell',
content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`,
};
}
case 'jetbrains': {
return {
ide,
format: 'instructions',
content: [
'JetBrains AI Assistant Setup:',
'1. Open Settings (File > Settings or Ctrl+Alt+S).',
'2. Navigate to Languages & Frameworks > AI Assistant.',
'3. Under "Custom OpenAI Endpoint", set:',
` - Base URL: ${baseUrl}`,
` - API Key: ${apiKey}`,
'4. Click "Apply" and restart the IDE if prompted.',
].join('\n'),
};
}
case 'opencide': {
return {
ide,
format: 'env',
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
};
}
default: {
// Exhaustive fallback to satisfy TypeScript
const exhaustiveCheck: never = ide;
this.logger.warn(`Unsupported IDE requested: ${String(exhaustiveCheck)}`);
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
}
}
}
/**
* Convenience helper that reads the current server config and local IP,
* then delegates to generateIdeConfig.
*/
generateConfigForCurrentServer(ide: SupportedIde): IdeConfigResult | null {
const config = getServerConfig();
if (!config) {
this.logger.warn('Server config not available; cannot generate IDE config');
return null;
}
const port = config.port;
const apiKey = config.api_key;
const localIp = this.getLocalIp();
return this.generateIdeConfig(ide, port, apiKey, localIp);
}
}
+113 -113
View File
@@ -1,113 +1,113 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
import { ProxyMetricsRepo } from '../../../ipc/database/proxyMetricsHandler';
interface RequestRecord {
timestamp: number;
duration: number;
status: number;
endpoint: string;
}
const HISTORICAL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const CONNECTION_SNAPSHOT_INTERVAL_MS = 30_000; // 30 seconds
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const RECORDS_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@Injectable()
export class ProxyMetricsService implements OnModuleInit {
private readonly maxRecords = 100;
private records: RequestRecord[] = [];
private activeConnectionsCount = 0;
private tokenEvents: Array<{ timestamp: number; tokens: number }> = [];
constructor() {
registerProxyAdvancedService('proxyMetrics', this);
}
onModuleInit(): void {
this.cleanupOldRecords();
setInterval(() => this.cleanupOldRecords(), CLEANUP_INTERVAL_MS);
this.recordConnectionSnapshot();
setInterval(() => this.recordConnectionSnapshot(), CONNECTION_SNAPSHOT_INTERVAL_MS);
}
incrementActiveConnections(): void {
this.activeConnectionsCount++;
}
decrementActiveConnections(): void {
this.activeConnectionsCount = Math.max(0, this.activeConnectionsCount - 1);
}
recordRequest(record: RequestRecord): void {
this.records.push(record);
if (this.records.length > this.maxRecords) {
this.records.shift();
}
ProxyMetricsRepo.recordRequest({
endpoint: record.endpoint,
statusCode: record.status,
durationMs: record.duration,
timestamp: record.timestamp,
accountId: null,
tokensPrompt: null,
tokensCompletion: null,
errorMessage: null,
});
}
recordTokens(tokens: number): void {
if (tokens <= 0) {
return;
}
this.tokenEvents.push({ timestamp: Date.now(), tokens });
this.trimTokenEvents();
}
getMetrics(): {
totalRequests: number;
requestsPerMinute: number;
avgLatency: number;
errorRate: number;
activeConnections: number;
tokenThroughput: number;
cacheHitRate: number;
} {
const aggregates = ProxyMetricsRepo.getHistoricalAggregates(HISTORICAL_WINDOW_MS);
this.trimTokenEvents();
const tokenThroughput = this.tokenEvents.reduce((sum, e) => sum + e.tokens, 0);
return {
totalRequests: aggregates.totalRequests,
requestsPerMinute: Math.round(aggregates.requestsPerMinute * 100) / 100,
avgLatency: Math.round(aggregates.avgLatencyMs * 100) / 100,
errorRate: Math.round(aggregates.errorRatePercent * 100) / 100,
activeConnections: Math.round(aggregates.avgActiveConnections * 100) / 100,
tokenThroughput,
cacheHitRate: 0,
};
}
cleanupOldRecords(): void {
const cutoff = Date.now() - RECORDS_TTL_MS;
ProxyMetricsRepo.deleteOldRecords(cutoff);
}
private recordConnectionSnapshot(): void {
ProxyMetricsRepo.recordConnectionSnapshot(this.activeConnectionsCount);
}
private trimTokenEvents(): void {
const cutoff = Date.now() - 60_000;
const index = this.tokenEvents.findIndex((e) => e.timestamp >= cutoff);
if (index > 0) {
this.tokenEvents = this.tokenEvents.slice(index);
} else if (index === -1 && this.tokenEvents.length > 0) {
this.tokenEvents = [];
}
}
}
import { Injectable, OnModuleInit } from '@nestjs/common';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
import { ProxyMetricsRepo } from '../../../ipc/database/proxyMetricsHandler';
interface RequestRecord {
timestamp: number;
duration: number;
status: number;
endpoint: string;
}
const HISTORICAL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const CONNECTION_SNAPSHOT_INTERVAL_MS = 30_000; // 30 seconds
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const RECORDS_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@Injectable()
export class ProxyMetricsService implements OnModuleInit {
private readonly maxRecords = 100;
private records: RequestRecord[] = [];
private activeConnectionsCount = 0;
private tokenEvents: Array<{ timestamp: number; tokens: number }> = [];
constructor() {
registerProxyAdvancedService('proxyMetrics', this);
}
onModuleInit(): void {
this.cleanupOldRecords();
setInterval(() => this.cleanupOldRecords(), CLEANUP_INTERVAL_MS);
this.recordConnectionSnapshot();
setInterval(() => this.recordConnectionSnapshot(), CONNECTION_SNAPSHOT_INTERVAL_MS);
}
incrementActiveConnections(): void {
this.activeConnectionsCount++;
}
decrementActiveConnections(): void {
this.activeConnectionsCount = Math.max(0, this.activeConnectionsCount - 1);
}
recordRequest(record: RequestRecord): void {
this.records.push(record);
if (this.records.length > this.maxRecords) {
this.records.shift();
}
ProxyMetricsRepo.recordRequest({
endpoint: record.endpoint,
statusCode: record.status,
durationMs: record.duration,
timestamp: record.timestamp,
accountId: null,
tokensPrompt: null,
tokensCompletion: null,
errorMessage: null,
});
}
recordTokens(tokens: number): void {
if (tokens <= 0) {
return;
}
this.tokenEvents.push({ timestamp: Date.now(), tokens });
this.trimTokenEvents();
}
getMetrics(): {
totalRequests: number;
requestsPerMinute: number;
avgLatency: number;
errorRate: number;
activeConnections: number;
tokenThroughput: number;
cacheHitRate: number;
} {
const aggregates = ProxyMetricsRepo.getHistoricalAggregates(HISTORICAL_WINDOW_MS);
this.trimTokenEvents();
const tokenThroughput = this.tokenEvents.reduce((sum, e) => sum + e.tokens, 0);
return {
totalRequests: aggregates.totalRequests,
requestsPerMinute: Math.round(aggregates.requestsPerMinute * 100) / 100,
avgLatency: Math.round(aggregates.avgLatencyMs * 100) / 100,
errorRate: Math.round(aggregates.errorRatePercent * 100) / 100,
activeConnections: Math.round(aggregates.avgActiveConnections * 100) / 100,
tokenThroughput,
cacheHitRate: 0,
};
}
cleanupOldRecords(): void {
const cutoff = Date.now() - RECORDS_TTL_MS;
ProxyMetricsRepo.deleteOldRecords(cutoff);
}
private recordConnectionSnapshot(): void {
ProxyMetricsRepo.recordConnectionSnapshot(this.activeConnectionsCount);
}
private trimTokenEvents(): void {
const cutoff = Date.now() - 60_000;
const index = this.tokenEvents.findIndex((e) => e.timestamp >= cutoff);
if (index > 0) {
this.tokenEvents = this.tokenEvents.slice(index);
} else if (index === -1 && this.tokenEvents.length > 0) {
this.tokenEvents = [];
}
}
}
+298 -298
View File
@@ -1,298 +1,298 @@
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
import { ProxyService } from './proxy.service';
import {
OpenAIChatRequest,
AnthropicChatRequest,
GeminiRequest,
} from './interfaces/request-interfaces';
export interface ReplayEntry {
requestId: string;
timestamp: number;
endpoint: string;
method: string;
body: unknown;
headers: Record<string, unknown>;
accountId?: string;
model?: string;
responseSnapshot?: unknown;
}
@Injectable()
export class ProxyReplayService {
private readonly logger = new Logger(ProxyReplayService.name);
private readonly buffer: ReplayEntry[] = [];
private readonly maxSize = 20;
constructor(
@Inject(ProxyService) private readonly proxyService: ProxyService,
) {
registerProxyAdvancedService('proxyReplay', this);
}
recordRequest(entry: ReplayEntry): void {
if (this.buffer.length >= this.maxSize) {
this.buffer.shift();
}
this.buffer.push(entry);
this.logger.debug(`Recorded request ${entry.requestId} for ${entry.endpoint}`);
}
getRecentRequests(limit?: number): ReplayEntry[] {
const recent = [...this.buffer].reverse();
if (limit && limit > 0) {
return recent.slice(0, limit);
}
return recent;
}
async replayRequest(requestId: string): Promise<{ original: ReplayEntry; newResponse: unknown }> {
const entry = this.buffer.find((e) => e.requestId === requestId);
if (!entry) {
throw new NotFoundException(`Request ${requestId} not found in replay buffer`);
}
this.logger.log(`Replaying request ${requestId} for ${entry.endpoint}`);
const newResponse = await this.executeReplay(entry);
return { original: entry, newResponse };
}
private async executeReplay(entry: ReplayEntry): Promise<unknown> {
switch (entry.endpoint) {
case '/v1/chat/completions':
case '/v1/messages/chat/completions': {
const body = entry.body as OpenAIChatRequest;
return this.proxyService.handleChatCompletions(body);
}
case '/v1/completions': {
const originalBody = entry.body as {
model?: string;
prompt?: string | string[];
max_tokens?: number;
temperature?: number;
top_p?: number;
stream?: boolean;
};
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-flash',
messages: [
{
role: 'user',
content: Array.isArray(originalBody.prompt)
? originalBody.prompt.join('\n')
: (originalBody.prompt ?? ''),
},
],
max_tokens: originalBody.max_tokens,
temperature: originalBody.temperature,
top_p: originalBody.top_p,
stream: originalBody.stream,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/messages': {
const body = entry.body as AnthropicChatRequest;
return this.proxyService.handleAnthropicMessages(body);
}
case '/v1/responses': {
const originalBody = entry.body as {
model?: string;
instructions?: string;
input?: unknown;
tools?: OpenAIChatRequest['tools'];
max_output_tokens?: number;
temperature?: number;
top_p?: number;
stream?: boolean;
};
const messages: OpenAIChatRequest['messages'] = [];
if (
typeof originalBody.instructions === 'string' &&
originalBody.instructions.trim().length > 0
) {
messages.push({ role: 'system', content: originalBody.instructions });
}
const input = originalBody.input;
if (typeof input === 'string') {
messages.push({ role: 'user', content: input });
} else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === 'string') {
messages.push({ role: 'user', content: item });
} else if (item && typeof item === 'object') {
const obj = item as Record<string, unknown>;
if (obj.type === 'message' && typeof obj.content === 'string') {
messages.push({
role: typeof obj.role === 'string' ? obj.role : 'user',
content: obj.content,
});
} else if (obj.type === 'message') {
messages.push({
role: typeof obj.role === 'string' ? obj.role : 'user',
content: JSON.stringify(obj.content),
});
}
}
}
}
if (messages.length === 0) {
messages.push({ role: 'user', content: '' });
}
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-flash',
messages,
tools: originalBody.tools,
max_tokens: originalBody.max_output_tokens,
temperature: originalBody.temperature,
top_p: originalBody.top_p,
stream: originalBody.stream,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/images/generations': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
size?: string;
quality?: string;
};
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-pro-image',
messages: [{ role: 'user', content: originalBody.prompt ?? '' }],
stream: false,
size: originalBody.size,
quality: originalBody.quality,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/images/edits': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
size?: string;
quality?: string;
image?: string | { data?: string; mimeType?: string };
reference_images?: Array<string | { data?: string; mimeType?: string }>;
mask?: string | { data?: string; mimeType?: string };
};
const imageParts = this.collectImageContentParts([
originalBody.image,
originalBody.mask,
...(originalBody.reference_images ?? []),
]);
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-pro-image',
messages: [
{
role: 'user',
content:
imageParts.length > 0
? [
{
type: 'text' as const,
text:
originalBody.prompt ??
'Please edit this image based on the provided instruction.',
},
...imageParts,
]
: (originalBody.prompt ??
'Please edit this image based on the provided instruction.'),
},
],
stream: false,
size: originalBody.size,
quality: originalBody.quality,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/audio/transcriptions': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
file?: string | { data?: string; mimeType?: string };
audio?: string | { data?: string; mimeType?: string };
};
const inlineAudio = this.resolveInlineData(originalBody.file ?? originalBody.audio);
if (!inlineAudio) {
throw new BadRequestException("Missing 'file' or 'audio' input for replay");
}
return this.proxyService.handleGeminiGenerateContent(
originalBody.model ?? 'gemini-3-flash',
{
contents: [
{
role: 'user',
parts: [
{
text:
originalBody.prompt ??
'Please transcribe the provided speech audio accurately.',
},
{ inlineData: inlineAudio },
],
},
],
} as GeminiRequest,
);
}
default:
throw new BadRequestException(`Replay not supported for endpoint ${entry.endpoint}`);
}
}
private collectImageContentParts(
entries: Array<string | { data?: string; mimeType?: string } | undefined>,
): Array<{ type: 'image_url'; image_url: { url: string } }> {
const parts: Array<{ type: 'image_url'; image_url: { url: string } }> = [];
for (const entry of entries) {
const inlineData = this.resolveInlineData(entry);
if (!inlineData) {
continue;
}
parts.push({
type: 'image_url',
image_url: {
url: `data:${inlineData.mimeType};base64,${inlineData.data}`,
},
});
}
return parts;
}
private resolveInlineData(input: unknown): { mimeType: string; data: string } | null {
if (!input) {
return null;
}
if (typeof input === 'string') {
const dataUri = input.match(/^data:(?<mime>[^;]+);base64,(?<data>[A-Za-z0-9+/=]+)$/);
if (dataUri?.groups?.mime && dataUri.groups.data) {
return {
mimeType: dataUri.groups.mime,
data: dataUri.groups.data,
};
}
const cleaned = input.replace(/\s+/g, '');
if (cleaned.length > 0) {
return {
mimeType: 'audio/mpeg',
data: cleaned,
};
}
return null;
}
const inputRecord = input as Record<string, unknown>;
const data = typeof inputRecord.data === 'string' ? inputRecord.data : undefined;
if (!data) {
return null;
}
return {
mimeType: typeof inputRecord.mimeType === 'string' ? inputRecord.mimeType : 'audio/mpeg',
data,
};
}
}
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
import { ProxyService } from './proxy.service';
import {
OpenAIChatRequest,
AnthropicChatRequest,
GeminiRequest,
} from './interfaces/request-interfaces';
export interface ReplayEntry {
requestId: string;
timestamp: number;
endpoint: string;
method: string;
body: unknown;
headers: Record<string, unknown>;
accountId?: string;
model?: string;
responseSnapshot?: unknown;
}
@Injectable()
export class ProxyReplayService {
private readonly logger = new Logger(ProxyReplayService.name);
private readonly buffer: ReplayEntry[] = [];
private readonly maxSize = 20;
constructor(
@Inject(ProxyService) private readonly proxyService: ProxyService,
) {
registerProxyAdvancedService('proxyReplay', this);
}
recordRequest(entry: ReplayEntry): void {
if (this.buffer.length >= this.maxSize) {
this.buffer.shift();
}
this.buffer.push(entry);
this.logger.debug(`Recorded request ${entry.requestId} for ${entry.endpoint}`);
}
getRecentRequests(limit?: number): ReplayEntry[] {
const recent = [...this.buffer].reverse();
if (limit && limit > 0) {
return recent.slice(0, limit);
}
return recent;
}
async replayRequest(requestId: string): Promise<{ original: ReplayEntry; newResponse: unknown }> {
const entry = this.buffer.find((e) => e.requestId === requestId);
if (!entry) {
throw new NotFoundException(`Request ${requestId} not found in replay buffer`);
}
this.logger.log(`Replaying request ${requestId} for ${entry.endpoint}`);
const newResponse = await this.executeReplay(entry);
return { original: entry, newResponse };
}
private async executeReplay(entry: ReplayEntry): Promise<unknown> {
switch (entry.endpoint) {
case '/v1/chat/completions':
case '/v1/messages/chat/completions': {
const body = entry.body as OpenAIChatRequest;
return this.proxyService.handleChatCompletions(body);
}
case '/v1/completions': {
const originalBody = entry.body as {
model?: string;
prompt?: string | string[];
max_tokens?: number;
temperature?: number;
top_p?: number;
stream?: boolean;
};
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-flash',
messages: [
{
role: 'user',
content: Array.isArray(originalBody.prompt)
? originalBody.prompt.join('\n')
: (originalBody.prompt ?? ''),
},
],
max_tokens: originalBody.max_tokens,
temperature: originalBody.temperature,
top_p: originalBody.top_p,
stream: originalBody.stream,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/messages': {
const body = entry.body as AnthropicChatRequest;
return this.proxyService.handleAnthropicMessages(body);
}
case '/v1/responses': {
const originalBody = entry.body as {
model?: string;
instructions?: string;
input?: unknown;
tools?: OpenAIChatRequest['tools'];
max_output_tokens?: number;
temperature?: number;
top_p?: number;
stream?: boolean;
};
const messages: OpenAIChatRequest['messages'] = [];
if (
typeof originalBody.instructions === 'string' &&
originalBody.instructions.trim().length > 0
) {
messages.push({ role: 'system', content: originalBody.instructions });
}
const input = originalBody.input;
if (typeof input === 'string') {
messages.push({ role: 'user', content: input });
} else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === 'string') {
messages.push({ role: 'user', content: item });
} else if (item && typeof item === 'object') {
const obj = item as Record<string, unknown>;
if (obj.type === 'message' && typeof obj.content === 'string') {
messages.push({
role: typeof obj.role === 'string' ? obj.role : 'user',
content: obj.content,
});
} else if (obj.type === 'message') {
messages.push({
role: typeof obj.role === 'string' ? obj.role : 'user',
content: JSON.stringify(obj.content),
});
}
}
}
}
if (messages.length === 0) {
messages.push({ role: 'user', content: '' });
}
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-flash',
messages,
tools: originalBody.tools,
max_tokens: originalBody.max_output_tokens,
temperature: originalBody.temperature,
top_p: originalBody.top_p,
stream: originalBody.stream,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/images/generations': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
size?: string;
quality?: string;
};
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-pro-image',
messages: [{ role: 'user', content: originalBody.prompt ?? '' }],
stream: false,
size: originalBody.size,
quality: originalBody.quality,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/images/edits': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
size?: string;
quality?: string;
image?: string | { data?: string; mimeType?: string };
reference_images?: Array<string | { data?: string; mimeType?: string }>;
mask?: string | { data?: string; mimeType?: string };
};
const imageParts = this.collectImageContentParts([
originalBody.image,
originalBody.mask,
...(originalBody.reference_images ?? []),
]);
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-pro-image',
messages: [
{
role: 'user',
content:
imageParts.length > 0
? [
{
type: 'text' as const,
text:
originalBody.prompt ??
'Please edit this image based on the provided instruction.',
},
...imageParts,
]
: (originalBody.prompt ??
'Please edit this image based on the provided instruction.'),
},
],
stream: false,
size: originalBody.size,
quality: originalBody.quality,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/audio/transcriptions': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
file?: string | { data?: string; mimeType?: string };
audio?: string | { data?: string; mimeType?: string };
};
const inlineAudio = this.resolveInlineData(originalBody.file ?? originalBody.audio);
if (!inlineAudio) {
throw new BadRequestException("Missing 'file' or 'audio' input for replay");
}
return this.proxyService.handleGeminiGenerateContent(
originalBody.model ?? 'gemini-3-flash',
{
contents: [
{
role: 'user',
parts: [
{
text:
originalBody.prompt ??
'Please transcribe the provided speech audio accurately.',
},
{ inlineData: inlineAudio },
],
},
],
} as GeminiRequest,
);
}
default:
throw new BadRequestException(`Replay not supported for endpoint ${entry.endpoint}`);
}
}
private collectImageContentParts(
entries: Array<string | { data?: string; mimeType?: string } | undefined>,
): Array<{ type: 'image_url'; image_url: { url: string } }> {
const parts: Array<{ type: 'image_url'; image_url: { url: string } }> = [];
for (const entry of entries) {
const inlineData = this.resolveInlineData(entry);
if (!inlineData) {
continue;
}
parts.push({
type: 'image_url',
image_url: {
url: `data:${inlineData.mimeType};base64,${inlineData.data}`,
},
});
}
return parts;
}
private resolveInlineData(input: unknown): { mimeType: string; data: string } | null {
if (!input) {
return null;
}
if (typeof input === 'string') {
const dataUri = input.match(/^data:(?<mime>[^;]+);base64,(?<data>[A-Za-z0-9+/=]+)$/);
if (dataUri?.groups?.mime && dataUri.groups.data) {
return {
mimeType: dataUri.groups.mime,
data: dataUri.groups.data,
};
}
const cleaned = input.replace(/\s+/g, '');
if (cleaned.length > 0) {
return {
mimeType: 'audio/mpeg',
data: cleaned,
};
}
return null;
}
const inputRecord = input as Record<string, unknown>;
const data = typeof inputRecord.data === 'string' ? inputRecord.data : undefined;
if (!data) {
return null;
}
return {
mimeType: typeof inputRecord.mimeType === 'string' ? inputRecord.mimeType : 'audio/mpeg',
data,
};
}
}
@@ -1,56 +1,56 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { FastifyRequest } from 'fastify';
import { isString } from 'lodash-es';
@Injectable()
export class ProxyTransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<FastifyRequest>();
const body = request.body as Record<string, unknown>;
if (body && typeof body === 'object') {
// Normalize model field to string
if (body.model !== undefined && !isString(body.model)) {
body.model = String(body.model);
}
// Ensure messages array exists and elements have required fields
if (Array.isArray(body.messages)) {
body.messages = body.messages.map((msg: Record<string, unknown>) => ({
role: isString(msg.role) ? msg.role : 'user',
content: msg.content !== undefined ? msg.content : '',
...(msg.tool_calls !== undefined ? { tool_calls: msg.tool_calls } : {}),
...(msg.tool_call_id !== undefined ? { tool_call_id: msg.tool_call_id } : {}),
}));
}
// Normalize temperature to number if present
if (body.temperature !== undefined && typeof body.temperature !== 'number') {
const parsed = parseFloat(String(body.temperature));
if (!isNaN(parsed)) {
body.temperature = parsed;
}
}
// Normalize top_p to number if present
if (body.top_p !== undefined && typeof body.top_p !== 'number') {
const parsed = parseFloat(String(body.top_p));
if (!isNaN(parsed)) {
body.top_p = parsed;
}
}
// Normalize max_tokens to number if present
if (body.max_tokens !== undefined && typeof body.max_tokens !== 'number') {
const parsed = parseInt(String(body.max_tokens), 10);
if (!isNaN(parsed)) {
body.max_tokens = parsed;
}
}
}
return next.handle();
}
}
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { FastifyRequest } from 'fastify';
import { isString } from 'lodash-es';
@Injectable()
export class ProxyTransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<FastifyRequest>();
const body = request.body as Record<string, unknown>;
if (body && typeof body === 'object') {
// Normalize model field to string
if (body.model !== undefined && !isString(body.model)) {
body.model = String(body.model);
}
// Ensure messages array exists and elements have required fields
if (Array.isArray(body.messages)) {
body.messages = body.messages.map((msg: Record<string, unknown>) => ({
role: isString(msg.role) ? msg.role : 'user',
content: msg.content !== undefined ? msg.content : '',
...(msg.tool_calls !== undefined ? { tool_calls: msg.tool_calls } : {}),
...(msg.tool_call_id !== undefined ? { tool_call_id: msg.tool_call_id } : {}),
}));
}
// Normalize temperature to number if present
if (body.temperature !== undefined && typeof body.temperature !== 'number') {
const parsed = parseFloat(String(body.temperature));
if (!isNaN(parsed)) {
body.temperature = parsed;
}
}
// Normalize top_p to number if present
if (body.top_p !== undefined && typeof body.top_p !== 'number') {
const parsed = parseFloat(String(body.top_p));
if (!isNaN(parsed)) {
body.top_p = parsed;
}
}
// Normalize max_tokens to number if present
if (body.max_tokens !== undefined && typeof body.max_tokens !== 'number') {
const parsed = parseInt(String(body.max_tokens), 10);
if (!isNaN(parsed)) {
body.max_tokens = parsed;
}
}
}
return next.handle();
}
}
File diff suppressed because it is too large. Load diff
+42 -42
View File
@@ -1,42 +1,42 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { getServerConfig } from '../../server-config';
import {
extractApiKeyToken,
hasConfiguredApiKey,
RequestHeaders,
} from '../../guards/api-key-auth.util';
@Injectable()
export class ProxyGuard implements CanActivate {
private readonly logger = new Logger(ProxyGuard.name);
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const config = getServerConfig();
// 1. Check for API Key in config
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
if (!hasConfiguredApiKey(apiKey)) {
return true;
}
if (clientToken === apiKey) {
return true;
}
this.logger.warn(`Rejected unauthorized request from ${request.ip}`);
throw new UnauthorizedException('API key validation failed');
}
}
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { getServerConfig } from '../../server-config';
import {
extractApiKeyToken,
hasConfiguredApiKey,
RequestHeaders,
} from '../../guards/api-key-auth.util';
@Injectable()
export class ProxyGuard implements CanActivate {
private readonly logger = new Logger(ProxyGuard.name);
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const config = getServerConfig();
// 1. Check for API Key in config
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
if (!hasConfiguredApiKey(apiKey)) {
return true;
}
if (clientToken === apiKey) {
return true;
}
this.logger.warn(`Rejected unauthorized request from ${request.ip}`);
throw new UnauthorizedException('API key validation failed');
}
}
+32 -32
View File
@@ -1,32 +1,32 @@
import { Module } from '@nestjs/common';
import { ProxyController } from './proxy.controller';
import { ProxyService } from './proxy.service';
import { TokenManagerService } from './token-manager.service';
import { GeminiClient } from './clients/gemini.client';
import { GeminiController } from './gemini.controller';
import { ProxyGuard } from './proxy.guard';
import { TokenUsageService } from '../usage/token-usage.service';
import { ProxyReplayService } from './proxy-replay.service';
import { ProxyMetricsService } from './proxy-metrics.service';
import { ProxyIdeConfigService } from './proxy-ide-config.service';
import { ProxyEventBus } from './proxy-event-bus.service';
import { ProxyTransformInterceptor } from './proxy-transform.interceptor';
@Module({
imports: [],
controllers: [ProxyController, GeminiController],
providers: [
ProxyService,
TokenManagerService,
GeminiClient,
ProxyGuard,
TokenUsageService,
ProxyReplayService,
ProxyMetricsService,
ProxyIdeConfigService,
ProxyEventBus,
ProxyTransformInterceptor,
],
exports: [TokenManagerService],
})
export class ProxyModule {}
import { Module } from '@nestjs/common';
import { ProxyController } from './proxy.controller';
import { ProxyService } from './proxy.service';
import { TokenManagerService } from './token-manager.service';
import { GeminiClient } from './clients/gemini.client';
import { GeminiController } from './gemini.controller';
import { ProxyGuard } from './proxy.guard';
import { TokenUsageService } from '../usage/token-usage.service';
import { ProxyReplayService } from './proxy-replay.service';
import { ProxyMetricsService } from './proxy-metrics.service';
import { ProxyIdeConfigService } from './proxy-ide-config.service';
import { ProxyEventBus } from './proxy-event-bus.service';
import { ProxyTransformInterceptor } from './proxy-transform.interceptor';
@Module({
imports: [],
controllers: [ProxyController, GeminiController],
providers: [
ProxyService,
TokenManagerService,
GeminiClient,
ProxyGuard,
TokenUsageService,
ProxyReplayService,
ProxyMetricsService,
ProxyIdeConfigService,
ProxyEventBus,
ProxyTransformInterceptor,
],
exports: [TokenManagerService],
})
export class ProxyModule {}
File diff suppressed because it is too large. Load diff
+431 -431
View File
@@ -1,431 +1,431 @@
import { isEmpty, isNumber, isObjectLike, isString } from 'lodash-es';
export enum RateLimitReason {
QuotaExhausted = 'quota_exhausted',
RateLimitExceeded = 'rate_limit_exceeded',
ModelCapacityExhausted = 'model_capacity_exhausted',
ServerError = 'server_error',
Unknown = 'unknown',
}
interface RateLimitInfo {
resetTimeMs: number;
retryAfterSec: number;
reason: RateLimitReason;
model?: string;
}
type FailureCountEntry = {
count: number;
lastFailureMs: number;
};
interface GoogleErrorDetail {
reason?: string;
retryDelay?: string;
metadata?: {
quotaResetDelay?: string;
retryDelay?: string;
};
}
interface ParsedGoogleErrorBody {
error?: {
status?: string;
details?: GoogleErrorDetail[];
retry_after?: number;
};
}
const FAILURE_COUNT_EXPIRY_MS = 60 * 60 * 1000;
function toLowerText(value: string | undefined): string {
return (value ?? '').toLowerCase();
}
function parseDurationToSeconds(text: string): number | null {
const match = text.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?/i);
if (!match) {
return null;
}
const hours = Number(match[1] ?? 0);
const minutes = Number(match[2] ?? 0);
const seconds = Number(match[3] ?? 0);
const milliseconds = Number(match[4] ?? 0);
const totalSeconds =
hours * 3600 + minutes * 60 + Math.ceil(seconds) + Math.ceil(milliseconds / 1000);
if (totalSeconds <= 0) {
return null;
}
return totalSeconds;
}
function tryParseGoogleErrorBody(body: string | undefined): ParsedGoogleErrorBody | null {
const trimmed = (body ?? '').trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as ParsedGoogleErrorBody;
if (!isObjectLike(parsed)) {
return null;
}
return parsed;
} catch {
return null;
}
}
function mapGoogleReasonToTrackerReason(reason: string): RateLimitReason | null {
const normalizedReason = reason.trim().toUpperCase();
if (normalizedReason === 'QUOTA_EXHAUSTED') {
return RateLimitReason.QuotaExhausted;
}
if (normalizedReason === 'RATE_LIMIT_EXCEEDED') {
return RateLimitReason.RateLimitExceeded;
}
if (normalizedReason === 'MODEL_CAPACITY_EXHAUSTED') {
return RateLimitReason.ModelCapacityExhausted;
}
return null;
}
export class RateLimitTracker {
private readonly lockoutByKey = new Map<string, RateLimitInfo>();
private readonly failureCounts = new Map<string, FailureCountEntry>();
private buildLockoutKey(accountId: string, model?: string): string {
if (!isEmpty(model?.trim() ?? '')) {
return `${accountId}:${model}`;
}
return accountId;
}
getRemainingWaitSeconds(accountId: string, model?: string): number {
const now = Date.now();
const globalLock = this.lockoutByKey.get(accountId);
if (globalLock && globalLock.resetTimeMs > now) {
return Math.max(0, Math.ceil((globalLock.resetTimeMs - now) / 1000));
}
if (model) {
const modelKey = this.buildLockoutKey(accountId, model);
const modelLock = this.lockoutByKey.get(modelKey);
if (modelLock && modelLock.resetTimeMs > now) {
return Math.max(0, Math.ceil((modelLock.resetTimeMs - now) / 1000));
}
}
return 0;
}
getRemainingWaitSec(accountId: string, model?: string): number {
return this.getRemainingWaitSeconds(accountId, model);
}
isRateLimited(accountId: string, model?: string): boolean {
return this.getRemainingWaitSeconds(accountId, model) > 0;
}
setLockoutUntilIso(
accountId: string,
resetTimeIso: string,
reason: RateLimitReason,
model?: string,
): boolean {
const timestamp = Date.parse(resetTimeIso);
if (!Number.isFinite(timestamp)) {
return false;
}
this.setLockoutUntil(accountId, timestamp, reason, model);
return true;
}
private setLockoutUntil(
accountId: string,
resetTimeMs: number,
reason: RateLimitReason,
model?: string,
): void {
const retryAfterSec = Math.max(2, Math.ceil((resetTimeMs - Date.now()) / 1000));
const key = !isEmpty(model?.trim() ?? '') ? this.buildLockoutKey(accountId, model) : accountId;
this.lockoutByKey.set(key, {
resetTimeMs,
retryAfterSec,
reason,
model,
});
}
clear(accountId: string): boolean {
return this.lockoutByKey.delete(accountId);
}
clearAll(): number {
const size = this.lockoutByKey.size;
this.lockoutByKey.clear();
return size;
}
cleanupExpired(): number {
const now = Date.now();
let deleted = 0;
for (const [key, info] of this.lockoutByKey.entries()) {
if (info.resetTimeMs <= now) {
this.lockoutByKey.delete(key);
deleted++;
}
}
for (const [key, entry] of this.failureCounts.entries()) {
if (entry.lastFailureMs + FAILURE_COUNT_EXPIRY_MS <= now) {
this.failureCounts.delete(key);
deleted++;
}
}
return deleted;
}
markSuccess(accountId: string): void {
this.failureCounts.delete(accountId);
this.lockoutByKey.delete(accountId);
}
trackFromUpstreamError(params: {
accountId: string;
status?: number;
retryAfter?: string;
body?: string;
model?: string;
backoffSteps: number[];
}): RateLimitInfo | null {
const status = params.status ?? 0;
if (![404, 429, 500, 503, 529].includes(status)) {
return null;
}
const reason = this.detectRateLimitReason(status, params.body);
const retryAfterSec = this.computeRetryAfterSeconds({
reason,
status,
retryAfter: params.retryAfter,
body: params.body,
accountId: params.accountId,
backoffSteps: params.backoffSteps,
});
const info: RateLimitInfo = {
reason,
retryAfterSec,
resetTimeMs: Date.now() + retryAfterSec * 1000,
model: params.model,
};
const useModelKey = reason === RateLimitReason.QuotaExhausted && Boolean(params.model);
const key = useModelKey
? this.buildLockoutKey(params.accountId, params.model)
: params.accountId;
this.lockoutByKey.set(key, info);
return info;
}
parseAndMarkFromError(params: {
accountId: string;
status?: number;
retryAfter?: string;
body?: string;
model?: string;
backoffSteps: number[];
}): RateLimitInfo | null {
return this.trackFromUpstreamError(params);
}
private detectRateLimitReason(status: number, body: string | undefined): RateLimitReason {
const parsedBody = tryParseGoogleErrorBody(body);
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody?.error?.details : [];
for (const detail of details) {
if (!isString(detail.reason) || isEmpty(detail.reason.trim())) {
continue;
}
const mappedReason = mapGoogleReasonToTrackerReason(detail.reason);
if (mappedReason) {
return mappedReason;
}
}
const statusFromBody = parsedBody?.error?.status?.trim().toUpperCase();
if (statusFromBody === 'RESOURCE_EXHAUSTED') {
return RateLimitReason.QuotaExhausted;
}
if (statusFromBody === 'UNAVAILABLE') {
const loweredBody = toLowerText(body);
if (loweredBody.includes('no capacity available') || loweredBody.includes('model capacity')) {
return RateLimitReason.ModelCapacityExhausted;
}
}
if (status !== 429) {
return RateLimitReason.ServerError;
}
const lowerBody = toLowerText(body);
if (
lowerBody.includes('per minute') ||
lowerBody.includes('rate limit') ||
lowerBody.includes('rate_limit') ||
lowerBody.includes('too many requests')
) {
return RateLimitReason.RateLimitExceeded;
}
if (
lowerBody.includes('model capacity exhausted') ||
lowerBody.includes('no capacity available')
) {
return RateLimitReason.ModelCapacityExhausted;
}
if (lowerBody.includes('quota') || lowerBody.includes('exhausted')) {
return RateLimitReason.QuotaExhausted;
}
return RateLimitReason.Unknown;
}
private computeRetryAfterSeconds(params: {
reason: RateLimitReason;
status: number;
retryAfter?: string;
body?: string;
accountId: string;
backoffSteps: number[];
}): number {
const headerRetryRaw = params.retryAfter?.trim() ?? '';
if (headerRetryRaw !== '') {
const headerRetry = Number(headerRetryRaw);
if (!Number.isNaN(headerRetry) && Number.isFinite(headerRetry) && headerRetry > 0) {
return Math.max(2, Math.ceil(headerRetry));
}
const headerRetryAt = Date.parse(headerRetryRaw);
if (Number.isFinite(headerRetryAt)) {
const retryAfterSec = Math.ceil((headerRetryAt - Date.now()) / 1000);
if (retryAfterSec > 0) {
return Math.max(2, retryAfterSec);
}
}
}
const bodyRetry = this.parseRetryAfterSecondsFromBody(params.body);
if (bodyRetry !== null) {
return Math.max(2, Math.ceil(bodyRetry));
}
const failureCount =
params.reason !== RateLimitReason.ServerError
? this.incrementFailureCount(params.accountId)
: 1;
if (params.reason === RateLimitReason.QuotaExhausted) {
const steps = params.backoffSteps.length > 0 ? params.backoffSteps : [60, 300, 1800, 7200];
const index = Math.max(0, failureCount - 1);
return steps[Math.min(index, steps.length - 1)];
}
if (params.reason === RateLimitReason.RateLimitExceeded) {
return 5;
}
if (params.reason === RateLimitReason.ModelCapacityExhausted) {
if (failureCount <= 1) {
return 5;
}
if (failureCount === 2) {
return 10;
}
return 15;
}
if (params.reason === RateLimitReason.ServerError) {
return params.status === 404 ? 5 : 8;
}
return 60;
}
private parseRetryAfterSecondsFromBody(body: string | undefined): number | null {
if (!body) {
return null;
}
const parsedBody = tryParseGoogleErrorBody(body);
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody.error.details : [];
for (const detail of details) {
if (isString(detail.retryDelay) && !isEmpty(detail.retryDelay.trim())) {
const parsedDelay = parseDurationToSeconds(detail.retryDelay);
if (parsedDelay !== null) {
return parsedDelay;
}
}
if (isString(detail.metadata?.retryDelay) && !isEmpty(detail.metadata.retryDelay.trim())) {
const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay);
if (parsedDelay !== null) {
return parsedDelay;
}
}
if (
isString(detail.metadata?.quotaResetDelay) &&
!isEmpty(detail.metadata.quotaResetDelay.trim())
) {
const parsedDelay = parseDurationToSeconds(detail.metadata.quotaResetDelay);
if (parsedDelay !== null) {
return parsedDelay;
}
}
}
const retryAfter = parsedBody?.error?.retry_after;
if (isNumber(retryAfter) && retryAfter > 0) {
return Math.ceil(retryAfter);
}
const lowerBody = toLowerText(body);
const minSecMatch = lowerBody.match(/try again in\s+(\d+)m\s*(\d+)s/);
if (minSecMatch) {
return Number(minSecMatch[1]) * 60 + Number(minSecMatch[2]);
}
const secMatch = lowerBody.match(/(?:try again in|backoff for|wait)\s*(\d+)s/);
if (secMatch) {
return Number(secMatch[1]);
}
const resetMatch = lowerBody.match(/quota will reset in\s*(\d+)\s*second/);
if (resetMatch) {
return Number(resetMatch[1]);
}
return null;
}
private incrementFailureCount(accountId: string): number {
const now = Date.now();
const entry = this.failureCounts.get(accountId);
if (!entry) {
this.failureCounts.set(accountId, { count: 1, lastFailureMs: now });
return 1;
}
if (now - entry.lastFailureMs > FAILURE_COUNT_EXPIRY_MS) {
this.failureCounts.set(accountId, { count: 1, lastFailureMs: now });
return 1;
}
const nextCount = entry.count + 1;
this.failureCounts.set(accountId, { count: nextCount, lastFailureMs: now });
return nextCount;
}
}
import { isEmpty, isNumber, isObjectLike, isString } from 'lodash-es';
export enum RateLimitReason {
QuotaExhausted = 'quota_exhausted',
RateLimitExceeded = 'rate_limit_exceeded',
ModelCapacityExhausted = 'model_capacity_exhausted',
ServerError = 'server_error',
Unknown = 'unknown',
}
interface RateLimitInfo {
resetTimeMs: number;
retryAfterSec: number;
reason: RateLimitReason;
model?: string;
}
type FailureCountEntry = {
count: number;
lastFailureMs: number;
};
interface GoogleErrorDetail {
reason?: string;
retryDelay?: string;
metadata?: {
quotaResetDelay?: string;
retryDelay?: string;
};
}
interface ParsedGoogleErrorBody {
error?: {
status?: string;
details?: GoogleErrorDetail[];
retry_after?: number;
};
}
const FAILURE_COUNT_EXPIRY_MS = 60 * 60 * 1000;
function toLowerText(value: string | undefined): string {
return (value ?? '').toLowerCase();
}
function parseDurationToSeconds(text: string): number | null {
const match = text.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?/i);
if (!match) {
return null;
}
const hours = Number(match[1] ?? 0);
const minutes = Number(match[2] ?? 0);
const seconds = Number(match[3] ?? 0);
const milliseconds = Number(match[4] ?? 0);
const totalSeconds =
hours * 3600 + minutes * 60 + Math.ceil(seconds) + Math.ceil(milliseconds / 1000);
if (totalSeconds <= 0) {
return null;
}
return totalSeconds;
}
function tryParseGoogleErrorBody(body: string | undefined): ParsedGoogleErrorBody | null {
const trimmed = (body ?? '').trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as ParsedGoogleErrorBody;
if (!isObjectLike(parsed)) {
return null;
}
return parsed;
} catch {
return null;
}
}
function mapGoogleReasonToTrackerReason(reason: string): RateLimitReason | null {
const normalizedReason = reason.trim().toUpperCase();
if (normalizedReason === 'QUOTA_EXHAUSTED') {
return RateLimitReason.QuotaExhausted;
}
if (normalizedReason === 'RATE_LIMIT_EXCEEDED') {
return RateLimitReason.RateLimitExceeded;
}
if (normalizedReason === 'MODEL_CAPACITY_EXHAUSTED') {
return RateLimitReason.ModelCapacityExhausted;
}
return null;
}
export class RateLimitTracker {
private readonly lockoutByKey = new Map<string, RateLimitInfo>();
private readonly failureCounts = new Map<string, FailureCountEntry>();
private buildLockoutKey(accountId: string, model?: string): string {
if (!isEmpty(model?.trim() ?? '')) {
return `${accountId}:${model}`;
}
return accountId;
}
getRemainingWaitSeconds(accountId: string, model?: string): number {
const now = Date.now();
const globalLock = this.lockoutByKey.get(accountId);
if (globalLock && globalLock.resetTimeMs > now) {
return Math.max(0, Math.ceil((globalLock.resetTimeMs - now) / 1000));
}
if (model) {
const modelKey = this.buildLockoutKey(accountId, model);
const modelLock = this.lockoutByKey.get(modelKey);
if (modelLock && modelLock.resetTimeMs > now) {
return Math.max(0, Math.ceil((modelLock.resetTimeMs - now) / 1000));
}
}
return 0;
}
getRemainingWaitSec(accountId: string, model?: string): number {
return this.getRemainingWaitSeconds(accountId, model);
}
isRateLimited(accountId: string, model?: string): boolean {
return this.getRemainingWaitSeconds(accountId, model) > 0;
}
setLockoutUntilIso(
accountId: string,
resetTimeIso: string,
reason: RateLimitReason,
model?: string,
): boolean {
const timestamp = Date.parse(resetTimeIso);
if (!Number.isFinite(timestamp)) {
return false;
}
this.setLockoutUntil(accountId, timestamp, reason, model);
return true;
}
private setLockoutUntil(
accountId: string,
resetTimeMs: number,
reason: RateLimitReason,
model?: string,
): void {
const retryAfterSec = Math.max(2, Math.ceil((resetTimeMs - Date.now()) / 1000));
const key = !isEmpty(model?.trim() ?? '') ? this.buildLockoutKey(accountId, model) : accountId;
this.lockoutByKey.set(key, {
resetTimeMs,
retryAfterSec,
reason,
model,
});
}
clear(accountId: string): boolean {
return this.lockoutByKey.delete(accountId);
}
clearAll(): number {
const size = this.lockoutByKey.size;
this.lockoutByKey.clear();
return size;
}
cleanupExpired(): number {
const now = Date.now();
let deleted = 0;
for (const [key, info] of this.lockoutByKey.entries()) {
if (info.resetTimeMs <= now) {
this.lockoutByKey.delete(key);
deleted++;
}
}
for (const [key, entry] of this.failureCounts.entries()) {
if (entry.lastFailureMs + FAILURE_COUNT_EXPIRY_MS <= now) {
this.failureCounts.delete(key);
deleted++;
}
}
return deleted;
}
markSuccess(accountId: string): void {
this.failureCounts.delete(accountId);
this.lockoutByKey.delete(accountId);
}
trackFromUpstreamError(params: {
accountId: string;
status?: number;
retryAfter?: string;
body?: string;
model?: string;
backoffSteps: number[];
}): RateLimitInfo | null {
const status = params.status ?? 0;
if (![404, 429, 500, 503, 529].includes(status)) {
return null;
}
const reason = this.detectRateLimitReason(status, params.body);
const retryAfterSec = this.computeRetryAfterSeconds({
reason,
status,
retryAfter: params.retryAfter,
body: params.body,
accountId: params.accountId,
backoffSteps: params.backoffSteps,
});
const info: RateLimitInfo = {
reason,
retryAfterSec,
resetTimeMs: Date.now() + retryAfterSec * 1000,
model: params.model,
};
const useModelKey = reason === RateLimitReason.QuotaExhausted && Boolean(params.model);
const key = useModelKey
? this.buildLockoutKey(params.accountId, params.model)
: params.accountId;
this.lockoutByKey.set(key, info);
return info;
}
parseAndMarkFromError(params: {
accountId: string;
status?: number;
retryAfter?: string;
body?: string;
model?: string;
backoffSteps: number[];
}): RateLimitInfo | null {
return this.trackFromUpstreamError(params);
}
private detectRateLimitReason(status: number, body: string | undefined): RateLimitReason {
const parsedBody = tryParseGoogleErrorBody(body);
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody?.error?.details : [];
for (const detail of details) {
if (!isString(detail.reason) || isEmpty(detail.reason.trim())) {
continue;
}
const mappedReason = mapGoogleReasonToTrackerReason(detail.reason);
if (mappedReason) {
return mappedReason;
}
}
const statusFromBody = parsedBody?.error?.status?.trim().toUpperCase();
if (statusFromBody === 'RESOURCE_EXHAUSTED') {
return RateLimitReason.QuotaExhausted;
}
if (statusFromBody === 'UNAVAILABLE') {
const loweredBody = toLowerText(body);
if (loweredBody.includes('no capacity available') || loweredBody.includes('model capacity')) {
return RateLimitReason.ModelCapacityExhausted;
}
}
if (status !== 429) {
return RateLimitReason.ServerError;
}
const lowerBody = toLowerText(body);
if (
lowerBody.includes('per minute') ||
lowerBody.includes('rate limit') ||
lowerBody.includes('rate_limit') ||
lowerBody.includes('too many requests')
) {
return RateLimitReason.RateLimitExceeded;
}
if (
lowerBody.includes('model capacity exhausted') ||
lowerBody.includes('no capacity available')
) {
return RateLimitReason.ModelCapacityExhausted;
}
if (lowerBody.includes('quota') || lowerBody.includes('exhausted')) {
return RateLimitReason.QuotaExhausted;
}
return RateLimitReason.Unknown;
}
private computeRetryAfterSeconds(params: {
reason: RateLimitReason;
status: number;
retryAfter?: string;
body?: string;
accountId: string;
backoffSteps: number[];
}): number {
const headerRetryRaw = params.retryAfter?.trim() ?? '';
if (headerRetryRaw !== '') {
const headerRetry = Number(headerRetryRaw);
if (!Number.isNaN(headerRetry) && Number.isFinite(headerRetry) && headerRetry > 0) {
return Math.max(2, Math.ceil(headerRetry));
}
const headerRetryAt = Date.parse(headerRetryRaw);
if (Number.isFinite(headerRetryAt)) {
const retryAfterSec = Math.ceil((headerRetryAt - Date.now()) / 1000);
if (retryAfterSec > 0) {
return Math.max(2, retryAfterSec);
}
}
}
const bodyRetry = this.parseRetryAfterSecondsFromBody(params.body);
if (bodyRetry !== null) {
return Math.max(2, Math.ceil(bodyRetry));
}
const failureCount =
params.reason !== RateLimitReason.ServerError
? this.incrementFailureCount(params.accountId)
: 1;
if (params.reason === RateLimitReason.QuotaExhausted) {
const steps = params.backoffSteps.length > 0 ? params.backoffSteps : [60, 300, 1800, 7200];
const index = Math.max(0, failureCount - 1);
return steps[Math.min(index, steps.length - 1)];
}
if (params.reason === RateLimitReason.RateLimitExceeded) {
return 5;
}
if (params.reason === RateLimitReason.ModelCapacityExhausted) {
if (failureCount <= 1) {
return 5;
}
if (failureCount === 2) {
return 10;
}
return 15;
}
if (params.reason === RateLimitReason.ServerError) {
return params.status === 404 ? 5 : 8;
}
return 60;
}
private parseRetryAfterSecondsFromBody(body: string | undefined): number | null {
if (!body) {
return null;
}
const parsedBody = tryParseGoogleErrorBody(body);
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody.error.details : [];
for (const detail of details) {
if (isString(detail.retryDelay) && !isEmpty(detail.retryDelay.trim())) {
const parsedDelay = parseDurationToSeconds(detail.retryDelay);
if (parsedDelay !== null) {
return parsedDelay;
}
}
if (isString(detail.metadata?.retryDelay) && !isEmpty(detail.metadata.retryDelay.trim())) {
const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay);
if (parsedDelay !== null) {
return parsedDelay;
}
}
if (
isString(detail.metadata?.quotaResetDelay) &&
!isEmpty(detail.metadata.quotaResetDelay.trim())
) {
const parsedDelay = parseDurationToSeconds(detail.metadata.quotaResetDelay);
if (parsedDelay !== null) {
return parsedDelay;
}
}
}
const retryAfter = parsedBody?.error?.retry_after;
if (isNumber(retryAfter) && retryAfter > 0) {
return Math.ceil(retryAfter);
}
const lowerBody = toLowerText(body);
const minSecMatch = lowerBody.match(/try again in\s+(\d+)m\s*(\d+)s/);
if (minSecMatch) {
return Number(minSecMatch[1]) * 60 + Number(minSecMatch[2]);
}
const secMatch = lowerBody.match(/(?:try again in|backoff for|wait)\s*(\d+)s/);
if (secMatch) {
return Number(secMatch[1]);
}
const resetMatch = lowerBody.match(/quota will reset in\s*(\d+)\s*second/);
if (resetMatch) {
return Number(resetMatch[1]);
}
return null;
}
private incrementFailureCount(accountId: string): number {
const now = Date.now();
const entry = this.failureCounts.get(accountId);
if (!entry) {
this.failureCounts.set(accountId, { count: 1, lastFailureMs: now });
return 1;
}
if (now - entry.lastFailureMs > FAILURE_COUNT_EXPIRY_MS) {
this.failureCounts.set(accountId, { count: 1, lastFailureMs: now });
return 1;
}
const nextCount = entry.count + 1;
this.failureCounts.set(accountId, { count: nextCount, lastFailureMs: now });
return nextCount;
}
}
+224 -224
View File
@@ -1,224 +1,224 @@
import axios from 'axios';
import { isString } from 'lodash-es';
import { version } from '../../../../package.json';
import { getGeminiNexusVersion } from '../../../utils/geminiNexusVersion';
import { logger } from '../../../utils/logger';
const REMOTE_VERSION_URL = 'https://antigravity-auto-updater-974169037036.us-central1.run.app';
const CHANGELOG_URL = 'https://antigravity.google/changelog';
export const FALLBACK_VERSION = version;
const DEFAULT_REMOTE_TIMEOUT_MS = 2500;
const VERSION_REGEX = /\d+\.\d+\.\d+/g;
type UserAgentSource = 'local' | 'remote' | 'changelog' | 'fallback';
interface UserAgentResolution {
source: UserAgentSource;
userAgent: string;
version: string;
}
let cachedUserAgentResolution: UserAgentResolution | null = null;
let pendingUserAgentResolution: Promise<UserAgentResolution> | null = null;
function normalizeNonEmptyString(value: string | null | undefined): string | null {
if (!isString(value)) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return trimmed;
}
function getPlatformTag(): string {
switch (process.platform) {
case 'win32':
return 'windows';
case 'darwin':
return 'darwin';
default:
return 'linux';
}
}
function getArchTag(): string {
if (process.arch === 'x64') {
return 'amd64';
}
if (process.arch === 'arm64') {
return 'arm64';
}
return process.arch;
}
export function buildUserAgent(version: string): string {
return `antigravity/${version} ${getPlatformTag()}/${getArchTag()}`;
}
function compareSemverVersions(left: string, right: string): number {
const leftParts = left.split('.').map((part) => Number.parseInt(part, 10));
const rightParts = right.split('.').map((part) => Number.parseInt(part, 10));
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const leftValue = leftParts[index] ?? 0;
const rightValue = rightParts[index] ?? 0;
if (leftValue > rightValue) {
return 1;
}
if (leftValue < rightValue) {
return -1;
}
}
return 0;
}
function extractHighestSemver(text: string): string | null {
const matches = text.match(VERSION_REGEX);
if (!matches || matches.length === 0) {
return null;
}
let best = matches[0];
for (const candidate of matches) {
if (compareSemverVersions(candidate, best) > 0) {
best = candidate;
}
}
return best;
}
function shouldSkipRemoteVersionLookup(): boolean {
return process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
}
export function resolveLocalInstalledVersion(): string | null {
let localVersionString: string;
try {
localVersionString = getGeminiNexusVersion().shortVersion;
const rawVersion = normalizeNonEmptyString(localVersionString);
if (!rawVersion) {
return null;
}
} catch {
return null;
}
return extractHighestSemver(localVersionString);
}
async function fetchTextPayload(url: string): Promise<string | null> {
try {
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
const response = await axios.get<string>(url, {
timeout: DEFAULT_REMOTE_TIMEOUT_MS,
responseType: 'text',
headers: {
'User-Agent': buildUserAgent(discoveryVersion),
},
});
if (isString(response.data)) {
return response.data;
}
return JSON.stringify(response.data);
} catch {
return null;
}
}
async function resolveRemoteVersion(): Promise<{
source: Extract<UserAgentSource, 'remote' | 'changelog'>;
version: string;
} | null> {
if (shouldSkipRemoteVersionLookup()) {
return null;
}
const remotePayload = await fetchTextPayload(REMOTE_VERSION_URL);
if (remotePayload) {
const parsed = extractHighestSemver(remotePayload);
if (parsed) {
return {
source: 'remote',
version: parsed,
};
}
}
const changelogPayload = await fetchTextPayload(CHANGELOG_URL);
if (!changelogPayload) {
return null;
}
const parsed = extractHighestSemver(changelogPayload);
if (!parsed) {
return null;
}
return {
source: 'changelog',
version: parsed,
};
}
async function resolveDefaultUserAgentResolution(): Promise<UserAgentResolution> {
let bestVersion = FALLBACK_VERSION;
let bestSource: UserAgentSource = 'fallback';
const localVersion = resolveLocalInstalledVersion();
if (localVersion && compareSemverVersions(localVersion, bestVersion) > 0) {
bestVersion = localVersion;
bestSource = 'local';
}
const remoteVersion = await resolveRemoteVersion();
if (remoteVersion && compareSemverVersions(remoteVersion.version, bestVersion) > 0) {
bestVersion = remoteVersion.version;
bestSource = remoteVersion.source;
}
return {
source: bestSource,
version: bestVersion,
userAgent: buildUserAgent(bestVersion),
};
}
async function getResolvedDefaultRequestUserAgent(): Promise<string> {
if (cachedUserAgentResolution) {
return cachedUserAgentResolution.userAgent;
}
if (pendingUserAgentResolution) {
return (await pendingUserAgentResolution).userAgent;
}
pendingUserAgentResolution = resolveDefaultUserAgentResolution()
.then((resolved) => {
cachedUserAgentResolution = resolved;
logger.info(
`Default request User-Agent resolved (source=${resolved.source}, version=${resolved.version}): ${resolved.userAgent}`,
);
return resolved;
})
.finally(() => {
pendingUserAgentResolution = null;
});
return (await pendingUserAgentResolution).userAgent;
}
export async function resolveRequestUserAgent(): Promise<string> {
return await getResolvedDefaultRequestUserAgent();
}
import axios from 'axios';
import { isString } from 'lodash-es';
import { version } from '../../../../package.json';
import { getGeminiNexusVersion } from '../../../utils/geminiNexusVersion';
import { logger } from '../../../utils/logger';
const REMOTE_VERSION_URL = 'https://antigravity-auto-updater-974169037036.us-central1.run.app';
const CHANGELOG_URL = 'https://antigravity.google/changelog';
export const FALLBACK_VERSION = version;
const DEFAULT_REMOTE_TIMEOUT_MS = 2500;
const VERSION_REGEX = /\d+\.\d+\.\d+/g;
type UserAgentSource = 'local' | 'remote' | 'changelog' | 'fallback';
interface UserAgentResolution {
source: UserAgentSource;
userAgent: string;
version: string;
}
let cachedUserAgentResolution: UserAgentResolution | null = null;
let pendingUserAgentResolution: Promise<UserAgentResolution> | null = null;
function normalizeNonEmptyString(value: string | null | undefined): string | null {
if (!isString(value)) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return trimmed;
}
function getPlatformTag(): string {
switch (process.platform) {
case 'win32':
return 'windows';
case 'darwin':
return 'darwin';
default:
return 'linux';
}
}
function getArchTag(): string {
if (process.arch === 'x64') {
return 'amd64';
}
if (process.arch === 'arm64') {
return 'arm64';
}
return process.arch;
}
export function buildUserAgent(version: string): string {
return `antigravity/${version} ${getPlatformTag()}/${getArchTag()}`;
}
function compareSemverVersions(left: string, right: string): number {
const leftParts = left.split('.').map((part) => Number.parseInt(part, 10));
const rightParts = right.split('.').map((part) => Number.parseInt(part, 10));
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const leftValue = leftParts[index] ?? 0;
const rightValue = rightParts[index] ?? 0;
if (leftValue > rightValue) {
return 1;
}
if (leftValue < rightValue) {
return -1;
}
}
return 0;
}
function extractHighestSemver(text: string): string | null {
const matches = text.match(VERSION_REGEX);
if (!matches || matches.length === 0) {
return null;
}
let best = matches[0];
for (const candidate of matches) {
if (compareSemverVersions(candidate, best) > 0) {
best = candidate;
}
}
return best;
}
function shouldSkipRemoteVersionLookup(): boolean {
return process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
}
export function resolveLocalInstalledVersion(): string | null {
let localVersionString: string;
try {
localVersionString = getGeminiNexusVersion().shortVersion;
const rawVersion = normalizeNonEmptyString(localVersionString);
if (!rawVersion) {
return null;
}
} catch {
return null;
}
return extractHighestSemver(localVersionString);
}
async function fetchTextPayload(url: string): Promise<string | null> {
try {
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
const response = await axios.get<string>(url, {
timeout: DEFAULT_REMOTE_TIMEOUT_MS,
responseType: 'text',
headers: {
'User-Agent': buildUserAgent(discoveryVersion),
},
});
if (isString(response.data)) {
return response.data;
}
return JSON.stringify(response.data);
} catch {
return null;
}
}
async function resolveRemoteVersion(): Promise<{
source: Extract<UserAgentSource, 'remote' | 'changelog'>;
version: string;
} | null> {
if (shouldSkipRemoteVersionLookup()) {
return null;
}
const remotePayload = await fetchTextPayload(REMOTE_VERSION_URL);
if (remotePayload) {
const parsed = extractHighestSemver(remotePayload);
if (parsed) {
return {
source: 'remote',
version: parsed,
};
}
}
const changelogPayload = await fetchTextPayload(CHANGELOG_URL);
if (!changelogPayload) {
return null;
}
const parsed = extractHighestSemver(changelogPayload);
if (!parsed) {
return null;
}
return {
source: 'changelog',
version: parsed,
};
}
async function resolveDefaultUserAgentResolution(): Promise<UserAgentResolution> {
let bestVersion = FALLBACK_VERSION;
let bestSource: UserAgentSource = 'fallback';
const localVersion = resolveLocalInstalledVersion();
if (localVersion && compareSemverVersions(localVersion, bestVersion) > 0) {
bestVersion = localVersion;
bestSource = 'local';
}
const remoteVersion = await resolveRemoteVersion();
if (remoteVersion && compareSemverVersions(remoteVersion.version, bestVersion) > 0) {
bestVersion = remoteVersion.version;
bestSource = remoteVersion.source;
}
return {
source: bestSource,
version: bestVersion,
userAgent: buildUserAgent(bestVersion),
};
}
async function getResolvedDefaultRequestUserAgent(): Promise<string> {
if (cachedUserAgentResolution) {
return cachedUserAgentResolution.userAgent;
}
if (pendingUserAgentResolution) {
return (await pendingUserAgentResolution).userAgent;
}
pendingUserAgentResolution = resolveDefaultUserAgentResolution()
.then((resolved) => {
cachedUserAgentResolution = resolved;
logger.info(
`Default request User-Agent resolved (source=${resolved.source}, version=${resolved.version}): ${resolved.userAgent}`,
);
return resolved;
})
.finally(() => {
pendingUserAgentResolution = null;
});
return (await pendingUserAgentResolution).userAgent;
}
export async function resolveRequestUserAgent(): Promise<string> {
return await getResolvedDefaultRequestUserAgent();
}
File diff suppressed because it is too large. Load diff
+49 -49
View File
@@ -1,49 +1,49 @@
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import { TokenUsageRepo, RecordUsageParams } from '../../../ipc/database/usageHandler';
const RecordUsageInputSchema = z.object({
accountId: z.string().min(1),
model: z.string().min(1),
promptTokens: z.number().min(0),
completionTokens: z.number().min(0),
totalTokens: z.number().min(0),
timestamp: z.number(),
requestType: z.enum(['openai', 'anthropic', 'gemini']).optional(),
isEstimated: z.boolean().optional(),
});
@Injectable()
export class TokenUsageService {
recordUsage(params: RecordUsageParams): void {
RecordUsageInputSchema.parse(params);
// Fire-and-forget: don't block the proxy request cycle
setImmediate(() => {
try {
TokenUsageRepo.recordUsage(params);
} catch {
// Silently ignore: usage recording should not break proxy requests
}
});
}
getUsageByHour(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByHour(accountId, start, end);
}
getUsageByDay(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByDay(accountId, start, end);
}
getUsageByWeek(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByWeek(accountId, start, end);
}
getUsageByMonth(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByMonth(accountId, start, end);
}
getUsageByModel(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByModel(accountId, start, end);
}
}
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import { TokenUsageRepo, RecordUsageParams } from '../../../ipc/database/usageHandler';
const RecordUsageInputSchema = z.object({
accountId: z.string().min(1),
model: z.string().min(1),
promptTokens: z.number().min(0),
completionTokens: z.number().min(0),
totalTokens: z.number().min(0),
timestamp: z.number(),
requestType: z.enum(['openai', 'anthropic', 'gemini']).optional(),
isEstimated: z.boolean().optional(),
});
@Injectable()
export class TokenUsageService {
recordUsage(params: RecordUsageParams): void {
RecordUsageInputSchema.parse(params);
// Fire-and-forget: don't block the proxy request cycle
setImmediate(() => {
try {
TokenUsageRepo.recordUsage(params);
} catch {
// Silently ignore: usage recording should not break proxy requests
}
});
}
getUsageByHour(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByHour(accountId, start, end);
}
getUsageByDay(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByDay(accountId, start, end);
}
getUsageByWeek(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByWeek(accountId, start, end);
}
getUsageByMonth(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByMonth(accountId, start, end);
}
getUsageByModel(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByModel(accountId, start, end);
}
}