feat: migrate gateway to nestjs
This commit is contained in:
1 parent
c4a0fa773f
commit
e7087bc4b1
18 files changed
+2182
-1161
No files matched your search
@@ -1,185 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
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: 
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
@@ -1,229 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -1,21 +1,20 @@
|
||||
/**
|
||||
* Gateway IPC Handlers
|
||||
* Provides ORPC handlers for controlling the API Gateway service
|
||||
* Provides ORPC handlers for controlling the API Gateway service (NestJS version)
|
||||
*/
|
||||
import { gatewayInstance } from './server';
|
||||
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Start the gateway server
|
||||
* Start the gateway server (NestJS)
|
||||
*/
|
||||
export const startGateway = async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
if (gatewayInstance.isRunning()) {
|
||||
await gatewayInstance.stop();
|
||||
}
|
||||
await gatewayInstance.start(port);
|
||||
return true;
|
||||
// Stop if already running
|
||||
await stopNestServer();
|
||||
// Start NestJS server
|
||||
return await bootstrapNestServer(port);
|
||||
} catch (e) {
|
||||
console.error('Failed to start gateway:', e);
|
||||
return false;
|
||||
@@ -23,12 +22,11 @@ export const startGateway = async (port: number): Promise<boolean> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the gateway server
|
||||
* Stop the gateway server (NestJS)
|
||||
*/
|
||||
export const stopGateway = async (): Promise<boolean> => {
|
||||
try {
|
||||
await gatewayInstance.stop();
|
||||
return true;
|
||||
return await stopNestServer();
|
||||
} catch (e) {
|
||||
console.error('Failed to stop gateway:', e);
|
||||
return false;
|
||||
@@ -36,10 +34,10 @@ export const stopGateway = async (): Promise<boolean> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get gateway status
|
||||
* Get gateway status (NestJS)
|
||||
*/
|
||||
export const getGatewayStatus = async () => {
|
||||
return gatewayInstance.getStatus();
|
||||
return getNestServerStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
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();
|
||||
@@ -1,141 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
Reference in new issue
Block a user