Initial Commit

This commit is contained in:
Draculabo committed 2025-12-23 01:38:53 +08:00
commit d0417339c1
158 files changed
+31368

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
export enum ResponseType {
None = 0,
Thinking = 1,
Text = 2,
Tool = 3,
}
export interface StreamEvent {
event: string;
data: string;
}
export class ClaudeStreamConverter {
public responseIndex: number = 0;
public currentType: ResponseType = ResponseType.None;
public hasContent: boolean = false;
constructor() {}
processChunk(jsonChunk: any): StreamEvent[] {
const events: StreamEvent[] = [];
// 0. Handle Prompt Feedback (Blocked requests)
if (!jsonChunk.candidates && !jsonChunk.choices) {
if (jsonChunk.promptFeedback) {
console.warn(
`(Anthropic) Received promptFeedback (likely blocked): ${JSON.stringify(jsonChunk.promptFeedback)}`,
);
}
return events;
}
const choices = jsonChunk.choices;
if (!choices || choices.length === 0) return events;
const choice = choices[0];
const delta = choice.delta;
const deltaContent = delta.content || delta.text || ''; // Support both content and text fields just in case
const isThought = delta.thought === true || !!delta.thinking; // Check for thinking flag or content
const thinkingContent = delta.thinking || '';
const thoughtSignature = delta.thoughtSignature;
// If we have content of any kind
if (deltaContent || isThought || thoughtSignature || thinkingContent) {
this.hasContent = true;
}
// 1. Handle Thinking
// If it is explicitly marked as thought, or has thinking content
if (isThought || thoughtSignature || thinkingContent) {
// Close Text if open
if (this.currentType === ResponseType.Text) {
events.push(
this.createEvent('content_block_stop', {
type: 'content_block_stop',
index: this.responseIndex,
}),
);
this.responseIndex++;
this.currentType = ResponseType.None;
}
// Open Thinking if not open
if (this.currentType === ResponseType.None) {
events.push(
this.createEvent('content_block_start', {
type: 'content_block_start',
index: this.responseIndex,
content_block: { type: 'thinking', thinking: '' }, // Anthropic Beta expects 'thinking' block
}),
);
this.currentType = ResponseType.Thinking;
}
if (thinkingContent) {
events.push(
this.createEvent('content_block_delta', {
type: 'content_block_delta',
index: this.responseIndex,
delta: { type: 'thinking_delta', thinking: thinkingContent },
}),
);
}
}
// 2. Handle Regular Text
else if (deltaContent) {
// Close Thinking if open
if (this.currentType === ResponseType.Thinking) {
events.push(
this.createEvent('content_block_stop', {
type: 'content_block_stop',
index: this.responseIndex,
}),
);
this.responseIndex++;
this.currentType = ResponseType.None;
}
// Open Text if not open
if (this.currentType === ResponseType.None) {
events.push(
this.createEvent('content_block_start', {
type: 'content_block_start',
index: this.responseIndex,
content_block: { type: 'text', text: '' },
}),
);
this.currentType = ResponseType.Text;
}
events.push(
this.createEvent('content_block_delta', {
type: 'content_block_delta',
index: this.responseIndex,
delta: { type: 'text_delta', text: deltaContent },
}),
);
}
// 3. Handle Stop Reason
if (choice.finish_reason) {
// Close any open block
if (this.currentType !== ResponseType.None) {
events.push(
this.createEvent('content_block_stop', {
type: 'content_block_stop',
index: this.responseIndex,
}),
);
this.responseIndex++;
this.currentType = ResponseType.None;
}
let stopReason = 'end_turn';
const r = choice.finish_reason;
if (r === 'length' || r === 'MAX_TOKENS') stopReason = 'max_tokens';
else if (r === 'tool_calls') stopReason = 'tool_use';
// Safety mappings
else if (r === 'SAFETY' || r === 'RECITATION') stopReason = 'end_turn'; // Fallback
events.push(
this.createEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: 0 },
}),
);
events.push(
this.createEvent('message_stop', {
type: 'message_stop',
}),
);
}
return events;
}
static createMessageStart(msgId: string, model: string): StreamEvent {
return {
event: 'message_start',
data: JSON.stringify({
type: 'message_start',
message: {
id: msgId,
type: 'message',
role: 'assistant',
model: model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
}),
};
}
createEvent(name: string, data: any): StreamEvent {
return {
event: name,
data: JSON.stringify(data),
};
}
}
+207
View File
@@ -0,0 +1,207 @@
import {
OpenAIChatRequest,
OpenAIMessage,
GeminiContent,
GeminiPart,
GeminiInlineData,
AnthropicChatRequest,
AnthropicMessage,
AnthropicContent,
ContentPart,
} from './types';
import { logger } from '../../utils/logger';
// Helper to extract text from content
function getTextFromContent(content: string | ContentPart[]): string {
if (typeof content === 'string') return content;
return content
.map((p) => {
if (p.type === 'text') return p.text || '';
return '';
})
.join('');
}
function getPreview(content: string | ContentPart[]): string {
const text = getTextFromContent(content);
return text.length > 200 ? text.substring(0, 200) + '...' : text;
}
export const Converter = {
/**
* Convert OpenAI messages to Gemini contents
*/
convertOpenAIToGeminiContents(messages: OpenAIMessage[]): GeminiContent[] {
const contents: GeminiContent[] = [];
const pendingImages: GeminiInlineData[] = [];
// Regex for markdown images: ![alt](data:image/png;base64,...)
// Group 1: mime, Group 2: data
const reMarkdown =
/!\[.*?\]\(data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)\)/g;
// Regex for explicit data URLs
const reDataUrl = /data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)/;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
// logger.info(`Msg[${i}][${msg.role}] content=${getPreview(msg.content)}`);
const role = msg.role === 'assistant' ? 'model' : msg.role === 'system' ? 'user' : msg.role;
const parts: GeminiPart[] = [];
// 1. Inject Pending Images from Assistant history to User
if (role === 'user' && pendingImages.length > 0) {
logger.info(`Injecting ${pendingImages.length} pending images to User message`);
while (pendingImages.length > 0) {
const img = pendingImages.shift();
if (img) parts.push({ inlineData: img });
}
}
// 2. Parse Content
if (typeof msg.content === 'string') {
const text = msg.content;
let lastEnd = 0;
let match;
// Reset regex state
reMarkdown.lastIndex = 0;
while ((match = reMarkdown.exec(text)) !== null) {
const matchStart = match.index;
const matchEnd = reMarkdown.lastIndex;
if (matchStart > lastEnd) {
const textPart = text.substring(lastEnd, matchStart);
if (textPart) parts.push({ text: textPart });
}
const mimeType = match[1];
const data = match[2].replace(/\s/g, '');
const inlineData = { mimeType, data };
if (role === 'model') {
pendingImages.push(inlineData);
} else {
parts.push({ inlineData });
}
lastEnd = matchEnd;
}
if (lastEnd < text.length) {
const textPart = text.substring(lastEnd);
if (textPart) parts.push({ text: textPart });
}
} else if (Array.isArray(msg.content)) {
// Array format
for (const part of msg.content) {
if (part.type === 'text') {
if (part.text) parts.push({ text: part.text });
} else if (part.type === 'image_url' && part.image_url) {
const url = part.image_url.url;
const validMatch = url.match(reDataUrl);
if (validMatch) {
const mimeType = validMatch[1];
const data = validMatch[2].replace(/\s/g, '');
const inlineData = { mimeType, data };
if (role === 'model') {
pendingImages.push(inlineData);
} else {
parts.push({ inlineData });
}
} else {
logger.warn(`Ignoring unsupported image URL format`);
}
}
}
}
// 3. Fallbacks
if (role === 'model' && parts.length === 0 && pendingImages.length > 0) {
// Was likely just an image in assistant history, we moved it to pending.
// But model message cannot be empty?
// Actually the loop above pushes for 'model' into pendingImages ONLY if it matches helper logic.
// If the loops finishes and we have parts, good.
// If empty and we put stuff in pendingImages, we need a placeholder?
parts.push({ text: '[Image Generated]' });
}
if (parts.length === 0) {
parts.push({ text: '' });
}
contents.push({ role, parts });
}
// Merge User Messages
return this.mergeConsecutiveUserMessages(contents);
},
convertAnthropicToGeminiContents(
request: AnthropicChatRequest,
signatureMap?: Map<string, string>,
): GeminiContent[] {
const contents: GeminiContent[] = [];
for (const msg of request.messages) {
const role = msg.role === 'assistant' ? 'model' : 'user';
const parts: GeminiPart[] = [];
const msgContent = Array.isArray(msg.content)
? msg.content
: [{ type: 'text', text: msg.content } as AnthropicContent];
for (const content of msgContent) {
if (content.type === 'text' && content.text) {
parts.push({ text: content.text });
} else if (content.type === 'image' && content.source && content.source.type === 'base64') {
parts.push({
inlineData: {
mimeType: content.source.media_type,
data: content.source.data,
},
});
} else if (content.type === 'thinking') {
// Ignore thinking input
}
}
// Thought Signature Injection (for Assistant)
if (role === 'model' && signatureMap) {
const latest = signatureMap.get('latest');
if (latest) {
parts.push({ thoughtSignature: latest });
}
}
contents.push({ role, parts });
}
return this.mergeConsecutiveUserMessages(contents);
},
mergeConsecutiveUserMessages(contents: GeminiContent[]): GeminiContent[] {
let i = 1;
while (i < contents.length) {
if (contents[i].role === 'user' && contents[i - 1].role === 'user') {
const prev = contents[i - 1];
const curr = contents[i];
// Add separator if needed
const prevLast = prev.parts[prev.parts.length - 1];
const currFirst = curr.parts[0];
if (prevLast && prevLast.text !== undefined && currFirst && currFirst.text !== undefined) {
prev.parts.push({ text: '\n\n' });
}
prev.parts.push(...curr.parts);
contents.splice(i, 1);
} else {
i++;
}
}
return contents;
},
};
+229
View File
@@ -0,0 +1,229 @@
import { v4 as uuidv4 } from 'uuid';
import { OpenAIChatRequest, AnthropicChatRequest, GeminiContent } from './types';
import { Converter } from './converter';
import { logger } from '../../utils/logger';
import { ProxyAgent } from 'undici';
import { UpstreamProxyConfig } from '../../types/config';
// Type definitions for fetch (since we are in Node)
// Electron main process has 'fetch' globally available in newer versions (Node 18+),
// but we might need to handle types.
declare const fetch: any;
export class GeminiClient {
private timeoutMs: number;
private upstreamProxy?: UpstreamProxyConfig;
constructor(timeoutSeconds: number = 60, upstreamProxy?: UpstreamProxyConfig) {
this.timeoutMs = timeoutSeconds * 1000;
this.upstreamProxy = upstreamProxy;
}
private getFetchOptions() {
if (this.upstreamProxy?.enabled && this.upstreamProxy.url) {
return {
dispatcher: new ProxyAgent(this.upstreamProxy.url),
};
}
return {};
}
async streamGenerateAnthropic(
request: AnthropicChatRequest,
accessToken: string,
projectId: string,
sessionId: string,
signatureMap?: Map<string, string>,
modelMapping?: Record<string, string>,
): Promise<any> {
// Returns a ReadableStream or similar
const url =
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse';
const contents = Converter.convertAnthropicToGeminiContents(request, signatureMap);
// ... (System prompt handling remains same)
const systemInstruction = request.system
? {
role: 'user',
parts: [
{
text:
typeof request.system === 'string'
? request.system
: JSON.stringify(request.system),
},
],
}
: { role: 'user', parts: [{ text: '' }] };
const generationConfig: any = {
temperature: request.temperature ?? 1.0,
topP: request.top_p ?? 0.95,
maxOutputTokens: request.max_tokens ?? 16384,
candidateCount: 1,
};
if (request.model.includes('sonnet') || request.model.includes('thinking')) {
generationConfig.thinkingConfig = {
includeThoughts: true,
thinkingBudget: 8191,
};
}
let upstreamModel = request.model;
// 1. User Custom Mapping (Fuzzy match)
if (modelMapping) {
for (const [key, value] of Object.entries(modelMapping)) {
if (request.model.includes(key)) {
upstreamModel = value;
break;
}
}
}
// 2. Built-in Fallback Logic
const lowerName = upstreamModel.toLowerCase();
if (!upstreamModel.startsWith('gemini')) {
// Only map if not already a gemini model (roughly)
if (lowerName.includes('sonnet') || lowerName.includes('thinking')) {
upstreamModel = 'gemini-3-pro-preview';
} else if (lowerName.includes('haiku')) {
upstreamModel = 'gemini-2.0-flash-exp';
} else if (lowerName.includes('opus')) {
upstreamModel = 'gemini-3-pro-preview';
} else if (lowerName.includes('claude')) {
upstreamModel = 'gemini-2.5-flash-thinking';
} else if (lowerName === 'gemini-3-pro-high' || lowerName === 'gemini-3-pro-low') {
upstreamModel = 'gemini-3-pro-preview';
} else if (lowerName === 'gemini-3-flash') {
upstreamModel = 'gemini-3-flash-preview';
}
}
const requestBody = {
project: projectId,
requestId: uuidv4(),
model: upstreamModel,
userAgent: 'antigravity',
request: {
contents,
systemInstruction,
generationConfig,
sessionId,
},
};
logger.debug(
`(Gateway) Anthropic Request: ${request.model} -> ${upstreamModel} | Proj: ${projectId}`,
);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
'User-Agent': 'claude-cli/1.0.83 (external, cli)',
'X-App': 'cli',
'Anthropic-Beta': 'claude-code-20250219,interleaved-thinking-2025-05-14',
'X-Stainless-Lang': 'js',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
...this.getFetchOptions(),
});
clearTimeout(timeout);
if (!response.ok) {
const text = await response.text();
throw new Error(`Upstream Error ${response.status}: ${text}`);
}
return response.body; // Node.js Readable Web Stream
} catch (e) {
clearTimeout(timeout);
throw e;
}
}
async generate(
request: OpenAIChatRequest,
accessToken: string,
projectId: string,
sessionId: string,
): Promise<any> {
const url = 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent';
// Split System
const systemMessages = request.messages.filter((m) => m.role === 'system');
const chatMessages = request.messages.filter((m) => m.role !== 'system');
const systemText = systemMessages
.map((m) => {
if (typeof m.content === 'string') return m.content;
return '';
})
.join('\n');
const contents = Converter.convertOpenAIToGeminiContents(chatMessages);
const generationConfig = {
temperature: request.temperature ?? 1.0,
topP: request.top_p ?? 0.95,
maxOutputTokens: request.max_tokens ?? 8096,
candidateCount: 1,
};
const requestBody = {
project: projectId,
requestId: uuidv4(),
model: request.model,
userAgent: 'antigravity',
request: {
contents,
systemInstruction: {
role: 'user',
parts: [{ text: systemText }],
},
generationConfig,
toolConfig: { functionCallingConfig: { mode: 'VALIDATED' } },
sessionId,
},
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
'User-Agent': 'antigravity/1.11.3 windows/amd64',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
...this.getFetchOptions(),
});
clearTimeout(timeout);
if (!response.ok) {
const text = await response.text();
throw new Error(`Upstream Error ${response.status}: ${text}`);
}
return await response.json();
} catch (e) {
clearTimeout(timeout);
throw e;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Gateway IPC Handlers
* Provides ORPC handlers for controlling the API Gateway service
*/
import { gatewayInstance } from './server';
import { ConfigManager } from '../config/manager';
import { v4 as uuidv4 } from 'uuid';
/**
* Start the gateway server
*/
export const startGateway = async (port: number): Promise<boolean> => {
try {
if (gatewayInstance.isRunning()) {
await gatewayInstance.stop();
}
await gatewayInstance.start(port);
return true;
} catch (e) {
console.error('Failed to start gateway:', e);
return false;
}
};
/**
* Stop the gateway server
*/
export const stopGateway = async (): Promise<boolean> => {
try {
await gatewayInstance.stop();
return true;
} catch (e) {
console.error('Failed to stop gateway:', e);
return false;
}
};
/**
* Get gateway status
*/
export const getGatewayStatus = async () => {
return gatewayInstance.getStatus();
};
/**
* Generate a new API key
*/
export const generateApiKey = async (): Promise<string> => {
const newKey = `sk-${uuidv4().replace(/-/g, '')}`;
// Save to config
const config = ConfigManager.loadConfig();
config.proxy.api_key = newKey;
ConfigManager.saveConfig(config);
return newKey;
};
+36
View File
@@ -0,0 +1,36 @@
/**
* Gateway ORPC Router
* Provides routes for controlling the API Gateway service
*/
import { os } from '@orpc/server';
import { z } from 'zod';
import { startGateway, stopGateway, getGatewayStatus, generateApiKey } from './handlers';
export const gatewayRouter = os.prefix('/gateway').router({
start: os
.input(z.object({ port: z.number().min(1024).max(65535) }))
.handler(async ({ input }) => {
const success = await startGateway(input.port);
if (!success) {
throw new Error('Failed to start gateway');
}
return { success };
}),
stop: os.handler(async () => {
const success = await stopGateway();
if (!success) {
throw new Error('Failed to stop gateway');
}
return { success };
}),
status: os.handler(async () => {
return getGatewayStatus();
}),
generateKey: os.handler(async () => {
const newKey = await generateApiKey();
return { api_key: newKey };
}),
});
+267
View File
@@ -0,0 +1,267 @@
import http from 'http';
import { TokenManager } from './tokenManager';
import { GeminiClient } from './geminiClient';
import { ClaudeStreamConverter } from './claudeConverter';
import { logger } from '../../utils/logger';
import { Readable } from 'stream';
import { ConfigManager } from '../config/manager';
import { v4 as uuidv4 } from 'uuid';
const PORT = 3000; // Default, make configurable?
export class GatewayServer {
private server: http.Server | null = null;
private tokenManager: TokenManager;
private client: GeminiClient;
private signatureMap: Map<string, string> = new Map();
private currentPort: number = 0;
constructor() {
this.tokenManager = new TokenManager();
this.client = new GeminiClient(60);
}
async start(port: number = PORT) {
// Load config and accounts
const config = ConfigManager.loadConfig();
await this.tokenManager.loadAccounts();
logger.info(`Gateway: Loaded ${await this.tokenManager.loadAccounts()} accounts`);
// Initialize Client with config
// Default to 60s if not set, pass upstream proxy config
this.client = new GeminiClient(
config.proxy?.request_timeout || 60,
config.proxy?.upstream_proxy,
);
this.server = http.createServer(async (req, res) => {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
const url = new URL(req.url || '/', `http://${req.headers.host}`);
try {
if (url.pathname === '/v1/messages' && req.method === 'POST') {
await this.handleAnthropicMessages(req, res);
} else if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {
// await this.handleChatCompletions(req, res);
res.writeHead(501);
res.end('Not Implemented Yet');
} else if (url.pathname === '/healthz') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} else {
res.writeHead(404);
res.end('Not Found');
}
} catch (e) {
logger.error('Gateway: Request Error', e);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(e) }));
}
}
});
this.server.on('error', (e: any) => {
if (e.code === 'EADDRINUSE') {
logger.error(`Gateway: Port ${port} is already in use.`);
} else {
logger.error('Gateway: Server error', e);
}
});
this.server.listen(port, () => {
this.currentPort = port;
logger.info(`Gateway: Server listening on http://127.0.0.1:${port}`);
});
}
private async handleAnthropicMessages(req: http.IncomingMessage, res: http.ServerResponse) {
const body = await this.readBody(req);
const request = JSON.parse(body);
logger.info(`(Anthropic) Request ${request.model}`);
// Retry Loop
const maxRetries = 3; // or tokenManager count
let attempts = 0;
while (attempts < maxRetries) {
attempts++;
const token = await this.tokenManager.getToken();
if (!token) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({ error: { type: 'overloaded_error', message: 'No accounts available' } }),
);
return;
}
logger.info(`(Anthropic) Using account ${token.email} (Attempt ${attempts})`);
try {
const config = ConfigManager.loadConfig();
const mapping = config.proxy?.anthropic_mapping || {};
const stream = await this.client.streamGenerateAnthropic(
request,
token.access_token,
token.project_id || 'unknown-project',
token.session_id,
this.signatureMap,
mapping,
);
// Handle SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const msgId = `msg_${uuidv4()}`;
const startEvent = ClaudeStreamConverter.createMessageStart(msgId, request.model);
res.write(`event: ${startEvent.event}\ndata: ${startEvent.data}\n\n`);
const converter = new ClaudeStreamConverter();
// Read the stream
// Node fetch response.body is a ReadableStream (web stream) or IncomingMessage (Node stream) depending on fetch polyfill
// Assuming it's an async iterable or we can get a reader.
// In Node 18+ native fetch, body is a Web stream.
for await (const chunk of stream) {
// chunk is Buffer or Uint8Array
const text = Buffer.from(chunk).toString('utf-8');
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
// Logic from server.rs: process_chunk -> get events
const events = converter.processChunk(json);
for (const event of events) {
res.write(`event: ${event.event}\ndata: ${event.data}\n\n`);
}
} catch (e) {
logger.warn('Gateway: Failed to parse Gemini chunk', e);
}
}
}
}
res.end();
return; // Success
} catch (e: any) {
logger.error(`(Anthropic) Request failed: ${e.message}`);
// Check retry
if (this.shouldRetry(e.message) && attempts < maxRetries) {
logger.warn(`(Anthropic) Retrying...`);
continue;
}
// Final error
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { type: 'api_error', message: e.message } }));
}
return;
}
}
}
private shouldRetry(errorMsg: string): boolean {
// Simple heuristic
if (
errorMsg.includes('429') ||
errorMsg.includes('503') ||
errorMsg.includes('500') ||
errorMsg.includes('403')
)
return true;
return false;
}
private readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
// ===== Lifecycle & Status Methods =====
/**
* Stop the gateway server
*/
stop(): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.server) {
resolve();
return;
}
this.server.close((err) => {
if (err) {
logger.error('Gateway: Failed to stop server', err);
reject(err);
} else {
logger.info('Gateway: Server stopped');
this.server = null;
this.currentPort = 0;
resolve();
}
});
});
}
/**
* Check if the server is running
*/
isRunning(): boolean {
return this.server !== null && this.server.listening;
}
/**
* Get current gateway status
*/
async getStatus(): Promise<{
running: boolean;
port: number;
base_url: string;
active_accounts: number;
}> {
const accountCount = await this.getAccountCount();
return {
running: this.isRunning(),
port: this.currentPort,
base_url: this.isRunning() ? `http://localhost:${this.currentPort}` : '',
active_accounts: accountCount,
};
}
/**
* Get the number of loaded accounts
*/
async getAccountCount(): Promise<number> {
return this.tokenManager.loadAccounts();
}
}
// Singleton instance for global access
export const gatewayInstance = new GatewayServer();
+141
View File
@@ -0,0 +1,141 @@
import { ProxyToken } from './types';
import { logger } from '../../utils/logger';
import { GoogleAPIService } from '../../services/GoogleAPIService';
import { CloudAccountRepo } from '../database/cloudHandler';
import { CloudAccount } from '../../types/cloudAccount';
export class TokenManager {
private tokens: Map<string, ProxyToken> = new Map();
private currentIndex: number = 0;
constructor() {}
async loadAccounts(): Promise<number> {
// Load from SQLite DB
// Fix: Use getAccounts() instead of list()
const accounts = await CloudAccountRepo.getAccounts();
let count = 0;
for (const account of accounts) {
try {
const token = this.convertAccountToToken(account);
if (token) {
this.tokens.set(token.account_id, token);
count++;
}
} catch (e) {
logger.warn(`Gateway: Failed to load account ${account.email}`, e);
}
}
return count;
}
private convertAccountToToken(account: CloudAccount): ProxyToken | null {
// Fix: account.token instead of account.token_data
if (!account.token) return null;
return {
account_id: account.id,
email: account.email,
access_token: account.token.access_token,
refresh_token: account.token.refresh_token,
expires_in: account.token.expires_in,
timestamp: account.token.expiry_timestamp,
account_path: '', // Not used with DB
project_id: account.token.project_id || undefined,
session_id: account.token.session_id || this.generateSessionId(),
};
}
generateSessionId(): string {
const min = 1_000_000_000_000_000_000n;
const max = 9_000_000_000_000_000_000n;
const range = max - min;
const rand = BigInt(Math.floor(Math.random() * Number(range)));
return (-(min + rand)).toString();
}
async getToken(): Promise<ProxyToken | null> {
if (this.tokens.size === 0) {
await this.loadAccounts();
}
if (this.tokens.size === 0) return null;
const keys = Array.from(this.tokens.keys());
this.currentIndex = (this.currentIndex + 1) % keys.length;
const key = keys[this.currentIndex];
const token = this.tokens.get(key);
if (!token) return null;
const now = Math.floor(Date.now() / 1000);
if (now >= token.timestamp - 300) {
logger.info(`Gateway: Token for ${token.email} expiring, refreshing...`);
try {
const newTokens = await GoogleAPIService.refreshAccessToken(token.refresh_token);
// Update Token Object
token.access_token = newTokens.access_token;
token.expires_in = newTokens.expires_in;
token.timestamp = Math.floor(Date.now() / 1000) + newTokens.expires_in;
// Update DB
await this.saveRefreshedToken(token.account_id, token);
this.tokens.set(token.account_id, token);
logger.info(`Gateway: Token refreshed for ${token.email}`);
} catch (e) {
logger.error(`Gateway: Failed to refresh token for ${token.email}`, e);
}
}
if (!token.project_id) {
try {
const mockId = `cloud-code-${Math.floor(Math.random() * 100000)}`;
token.project_id = mockId;
await this.saveProjectId(token.account_id, mockId);
} catch (e) {
logger.warn('Gateway: Failed to resolve project ID', e);
}
}
return token;
}
async saveRefreshedToken(accountId: string, token: ProxyToken) {
try {
const acc = await CloudAccountRepo.getAccount(accountId);
if (acc && acc.token) {
// Update token fields
const newToken = {
...acc.token,
access_token: token.access_token,
expires_in: token.expires_in,
expiry_timestamp: token.timestamp,
};
// Use updateToken which expects the whole token object (or partial?)
// CloudHandler.updateToken expects JSON object that it stringifies.
await CloudAccountRepo.updateToken(accountId, newToken);
}
} catch (e) {
logger.error(`Gateway: Failed to save refreshed token to DB`, e);
}
}
async saveProjectId(accountId: string, projectId: string) {
try {
// CloudAccountRepo.getAccount is async
const acc = await CloudAccountRepo.getAccount(accountId);
if (acc && acc.token) {
const newToken = {
...acc.token,
project_id: projectId,
};
await CloudAccountRepo.updateToken(accountId, newToken);
}
} catch (e) {
logger.error(`Gateway: Failed to save project ID to DB`, e);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
export interface ProxyToken {
account_id: string;
access_token: string;
refresh_token: string;
expires_in: number;
timestamp: number;
email: string;
account_path: string;
project_id?: string;
session_id: string;
}
// OpenAI Types
export interface OpenAIMessage {
role: string;
content: string | ContentPart[];
name?: string;
}
export interface ContentPart {
type: 'text' | 'image_url';
text?: string;
image_url?: { url: string };
}
export interface OpenAIChatRequest {
model: string;
messages: OpenAIMessage[];
temperature?: number;
top_p?: number;
max_tokens?: number;
stream?: boolean;
size?: string;
quality?: string;
user?: string;
[key: string]: any; // Extra params
}
// Anthropic Types
export interface AnthropicContent {
type: 'text' | 'image' | 'thinking';
text?: string; // for text or thinking
thinking?: string;
signature?: string;
source?: {
type: 'base64';
media_type: string;
data: string;
};
}
export interface AnthropicMessage {
role: string;
content: AnthropicContent[] | string; // Can be string in some clients
thought_signature?: string;
}
export interface AnthropicChatRequest {
model: string;
messages: AnthropicMessage[];
system?: string | any[]; // System prompt
max_tokens?: number;
metadata?: any;
stop_sequences?: string[];
stream?: boolean;
temperature?: number;
top_p?: number;
top_k?: number;
}
// Gemini Types
export interface GeminiInlineData {
mimeType: string;
data: string;
}
export interface GeminiPart {
text?: string;
inlineData?: GeminiInlineData;
thoughtSignature?: string;
}
export interface GeminiContent {
role: string;
parts: GeminiPart[];
}