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:
1 parent
b80531f8a8
commit
bac7b5591e
738 files changed
+138513
-138123
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
+2254
-2254
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user