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
Generated
+1056
-24
File diff suppressed because it is too large.
Load diff
+10
-1
@@ -36,7 +36,6 @@
|
|||||||
"@electron-forge/shared-types": "^7.8.1",
|
"@electron-forge/shared-types": "^7.8.1",
|
||||||
"@electron/fuses": "~1.8.0",
|
"@electron/fuses": "~1.8.0",
|
||||||
"@electron/rebuild": "^3.7.1",
|
"@electron/rebuild": "^3.7.1",
|
||||||
"node-addon-api": "^8.3.1",
|
|
||||||
"@eslint/compat": "^1.4.1",
|
"@eslint/compat": "^1.4.1",
|
||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.0",
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
@@ -63,6 +62,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^27.1.0",
|
"jsdom": "^27.1.0",
|
||||||
|
"node-addon-api": "^8.3.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
@@ -74,6 +74,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||||
|
"@nestjs/common": "^11.1.10",
|
||||||
|
"@nestjs/core": "^11.1.10",
|
||||||
|
"@nestjs/platform-fastify": "^11.1.10",
|
||||||
"@orpc/client": "^1.10.4",
|
"@orpc/client": "^1.10.4",
|
||||||
"@orpc/server": "^1.11.3",
|
"@orpc/server": "^1.11.3",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
@@ -95,11 +98,15 @@
|
|||||||
"@tanstack/react-router": "^1.139.3",
|
"@tanstack/react-router": "^1.139.3",
|
||||||
"@tanstack/react-router-devtools": "^1.135.0",
|
"@tanstack/react-router-devtools": "^1.135.0",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
|
"fastify": "^5.6.2",
|
||||||
"happy-dom": "^20.0.10",
|
"happy-dom": "^20.0.10",
|
||||||
"i18next": "^25.6.3",
|
"i18next": "^25.6.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
@@ -108,6 +115,8 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-i18next": "^16.2.4",
|
"react-i18next": "^16.2.4",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"undici": "^7.16.0",
|
"undici": "^7.16.0",
|
||||||
|
|||||||
@@ -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
|
* 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 { ConfigManager } from '../config/manager';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the gateway server
|
* Start the gateway server (NestJS)
|
||||||
*/
|
*/
|
||||||
export const startGateway = async (port: number): Promise<boolean> => {
|
export const startGateway = async (port: number): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
if (gatewayInstance.isRunning()) {
|
// Stop if already running
|
||||||
await gatewayInstance.stop();
|
await stopNestServer();
|
||||||
}
|
// Start NestJS server
|
||||||
await gatewayInstance.start(port);
|
return await bootstrapNestServer(port);
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to start gateway:', e);
|
console.error('Failed to start gateway:', e);
|
||||||
return false;
|
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> => {
|
export const stopGateway = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await gatewayInstance.stop();
|
return await stopNestServer();
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to stop gateway:', e);
|
console.error('Failed to stop gateway:', e);
|
||||||
return false;
|
return false;
|
||||||
@@ -36,10 +34,10 @@ export const stopGateway = async (): Promise<boolean> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get gateway status
|
* Get gateway status (NestJS)
|
||||||
*/
|
*/
|
||||||
export const getGatewayStatus = async () => {
|
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[];
|
|
||||||
}
|
|
||||||
+9
-8
@@ -13,7 +13,7 @@ import { CloudMonitorService } from './services/CloudMonitorService';
|
|||||||
|
|
||||||
// Static Imports to fix Bundle Resolution Errors
|
// Static Imports to fix Bundle Resolution Errors
|
||||||
import { AuthServer } from './ipc/cloud/authServer';
|
import { AuthServer } from './ipc/cloud/authServer';
|
||||||
import { gatewayInstance } from './ipc/gateway/server';
|
import { bootstrapNestServer, stopNestServer } from './server/main';
|
||||||
import { initTray, setTrayLanguage } from './ipc/tray/handler';
|
import { initTray, setTrayLanguage } from './ipc/tray/handler';
|
||||||
import { rpcHandler } from './ipc/handler';
|
import { rpcHandler } from './ipc/handler';
|
||||||
import { ConfigManager } from './ipc/config/manager';
|
import { ConfigManager } from './ipc/config/manager';
|
||||||
@@ -218,12 +218,12 @@ app
|
|||||||
// Start OAuth Server
|
// Start OAuth Server
|
||||||
AuthServer.start();
|
AuthServer.start();
|
||||||
|
|
||||||
// Gateway Server - use singleton, auto-start if enabled
|
// Gateway Server (NestJS) - auto-start if enabled
|
||||||
const config = ConfigManager.loadConfig();
|
const config = ConfigManager.loadConfig();
|
||||||
if (config.proxy?.auto_start) {
|
if (config.proxy?.auto_start) {
|
||||||
const port = config.proxy?.port || 8045;
|
const port = config.proxy?.port || 8045;
|
||||||
gatewayInstance.start(port);
|
await bootstrapNestServer(port);
|
||||||
logger.info(`Gateway: Auto-started on port ${port}`);
|
logger.info(`NestJS Proxy: Auto-started on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabled = CloudAccountRepo.getSetting('auto_switch_enabled', false);
|
const enabled = CloudAccountRepo.getSetting('auto_switch_enabled', false);
|
||||||
@@ -235,7 +235,7 @@ app
|
|||||||
logger.error('Startup: Failed to initialize services', e);
|
logger.error('Startup: Failed to initialize services', e);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
logger.info('Step: Startup Complete');
|
logger.info('Step: Startup Complete');
|
||||||
if (globalMainWindow) {
|
if (globalMainWindow) {
|
||||||
initTray(globalMainWindow);
|
initTray(globalMainWindow);
|
||||||
@@ -249,9 +249,10 @@ app
|
|||||||
//osX only
|
//osX only
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
logger.info('Window all closed event triggered');
|
logger.info('Window all closed event triggered');
|
||||||
// if (process.platform !== 'darwin') {
|
stopNestServer(); // Stop server
|
||||||
// app.quit();
|
if (process.platform !== 'darwin') {
|
||||||
// }
|
app.quit();
|
||||||
|
}
|
||||||
// Keep app running for tray
|
// Keep app running for tray
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProxyModule } from './modules/proxy/proxy.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ProxyModule],
|
||||||
|
controllers: [],
|
||||||
|
providers: [],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { TokenManagerService } from './modules/proxy/token-manager.service';
|
||||||
|
|
||||||
|
let app: NestFastifyApplication | null = null;
|
||||||
|
let currentPort: number = 0;
|
||||||
|
|
||||||
|
export async function bootstrapNestServer(port: number = 8045): Promise<boolean> {
|
||||||
|
if (app) {
|
||||||
|
logger.info('NestJS server already running.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
app.enableCors();
|
||||||
|
|
||||||
|
await app.listen(port, '0.0.0.0');
|
||||||
|
currentPort = port;
|
||||||
|
logger.info(`NestJS Proxy Server running on http://localhost:${port}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start NestJS server', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopNestServer(): Promise<boolean> {
|
||||||
|
if (app) {
|
||||||
|
try {
|
||||||
|
await app.close();
|
||||||
|
app = null;
|
||||||
|
currentPort = 0;
|
||||||
|
logger.info('NestJS server stopped.');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to stop NestJS server', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNestServerRunning(): boolean {
|
||||||
|
return app !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNestServerStatus(): Promise<{
|
||||||
|
running: boolean;
|
||||||
|
port: number;
|
||||||
|
base_url: string;
|
||||||
|
active_accounts: number;
|
||||||
|
}> {
|
||||||
|
const running = isNestServerRunning();
|
||||||
|
let activeAccounts = 0;
|
||||||
|
|
||||||
|
if (app) {
|
||||||
|
try {
|
||||||
|
const tokenManager = app.get(TokenManagerService);
|
||||||
|
activeAccounts = tokenManager.getAccountCount();
|
||||||
|
} catch (e) {
|
||||||
|
// TokenManager might not be available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
running,
|
||||||
|
port: currentPort,
|
||||||
|
base_url: running ? `http://localhost:${currentPort}` : '',
|
||||||
|
active_accounts: activeAccounts,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
async streamGenerate(model: string, content: GeminiRequest, accessToken: string): Promise<any> {
|
||||||
|
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(url, content, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
responseType: 'stream',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
this.logger.error(`Gemini Stream API Error: ${error.message}`);
|
||||||
|
throw new Error(error.response?.data?.error?.message || error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generate(
|
||||||
|
model: string,
|
||||||
|
content: GeminiRequest,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<GeminiResponse> {
|
||||||
|
const url = `${this.baseUrl}/models/${model}:generateContent`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<GeminiResponse>(url, content, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 60000, // 60s timeout
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
this.logger.error(
|
||||||
|
`Gemini API Error: ${error.message} - ${JSON.stringify(error.response?.data)}`,
|
||||||
|
);
|
||||||
|
throw new Error(error.response?.data?.error?.message || error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
export interface OpenAIChatRequest {
|
||||||
|
model: string;
|
||||||
|
messages: OpenAIMessage[];
|
||||||
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
stream?: boolean;
|
||||||
|
size?: string;
|
||||||
|
quality?: string;
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIMessage {
|
||||||
|
role: string;
|
||||||
|
content: string | OpenAIContentPart[];
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIContentPart {
|
||||||
|
type: 'text' | 'image_url';
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnthropicChatRequest {
|
||||||
|
model: string;
|
||||||
|
messages: AnthropicMessage[];
|
||||||
|
system?: string | AnthropicSystemBlock[];
|
||||||
|
max_tokens?: number;
|
||||||
|
metadata?: any;
|
||||||
|
stop_sequences?: string[];
|
||||||
|
stream?: boolean;
|
||||||
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
top_k?: 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 };
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIChatResponse {
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
created: number;
|
||||||
|
model: string;
|
||||||
|
choices: OpenAIChoice[];
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIChoice {
|
||||||
|
index: number;
|
||||||
|
message: {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
finish_reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Controller, Get, Post, Body, Res, HttpStatus } from '@nestjs/common';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { ProxyService } from './proxy.service';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { OpenAIChatRequest, AnthropicChatRequest } from './interfaces/request-interfaces';
|
||||||
|
|
||||||
|
@Controller('v1')
|
||||||
|
export class ProxyController {
|
||||||
|
constructor(private readonly proxyService: ProxyService) {}
|
||||||
|
|
||||||
|
@Get('models')
|
||||||
|
getModels() {
|
||||||
|
return {
|
||||||
|
object: 'list',
|
||||||
|
data: [
|
||||||
|
// Gemini Native
|
||||||
|
{
|
||||||
|
id: 'gemini-2.5-flash-thinking',
|
||||||
|
object: 'model',
|
||||||
|
created: 1734336000,
|
||||||
|
owned_by: 'google',
|
||||||
|
},
|
||||||
|
{ id: 'gemini-2.5-flash', object: 'model', created: 1734336000, owned_by: 'google' },
|
||||||
|
{ id: 'gemini-2.5-pro', object: 'model', created: 1734336000, owned_by: 'google' },
|
||||||
|
{ id: 'gemini-3-flash', object: 'model', created: 1734336000, owned_by: 'google' },
|
||||||
|
|
||||||
|
// Claude Native (Mapped)
|
||||||
|
{ id: 'claude-sonnet-4-5', object: 'model', created: 1734336000, owned_by: 'anthropic' },
|
||||||
|
{
|
||||||
|
id: 'claude-sonnet-4-5-thinking',
|
||||||
|
object: 'model',
|
||||||
|
created: 1734336000,
|
||||||
|
owned_by: 'anthropic',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Image Models
|
||||||
|
{ id: 'gemini-3-pro-image', object: 'model', created: 1734336000, owned_by: 'google' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('chat/completions')
|
||||||
|
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const result = await this.proxyService.handleChatCompletions(body);
|
||||||
|
|
||||||
|
if (body.stream && result instanceof Observable) {
|
||||||
|
res.header('Content-Type', 'text/event-stream');
|
||||||
|
res.header('Cache-Control', 'no-cache');
|
||||||
|
res.header('Connection', 'keep-alive');
|
||||||
|
res.send(result);
|
||||||
|
} else {
|
||||||
|
res.status(HttpStatus.OK).send(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
||||||
|
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
|
||||||
|
error: {
|
||||||
|
message: message,
|
||||||
|
type: 'server_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages')
|
||||||
|
async anthropicMessages(@Body() body: AnthropicChatRequest, @Res() res: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const result = await this.proxyService.handleAnthropicMessages(body);
|
||||||
|
|
||||||
|
if (body.stream && result instanceof Observable) {
|
||||||
|
res.header('Content-Type', 'text/event-stream');
|
||||||
|
res.header('Cache-Control', 'no-cache');
|
||||||
|
res.header('Connection', 'keep-alive');
|
||||||
|
res.send(result);
|
||||||
|
} else {
|
||||||
|
res.status(HttpStatus.OK).send(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
||||||
|
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'api_error',
|
||||||
|
message: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProxyController],
|
||||||
|
providers: [ProxyService, TokenManagerService, GeminiClient],
|
||||||
|
})
|
||||||
|
export class ProxyModule {}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TokenManagerService } from './token-manager.service';
|
||||||
|
import { GeminiClient } from './clients/gemini.client';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
OpenAIChatRequest,
|
||||||
|
AnthropicChatRequest,
|
||||||
|
GeminiContent,
|
||||||
|
GeminiPart,
|
||||||
|
AnthropicSystemBlock,
|
||||||
|
OpenAIContentPart,
|
||||||
|
GeminiResponse,
|
||||||
|
GeminiRequest,
|
||||||
|
AnthropicChatResponse,
|
||||||
|
OpenAIChatResponse,
|
||||||
|
GeminiCandidate,
|
||||||
|
} from './interfaces/request-interfaces';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProxyService {
|
||||||
|
private readonly logger = new Logger(ProxyService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tokenManager: TokenManagerService,
|
||||||
|
private readonly geminiClient: GeminiClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// --- Anthropic Handlers ---
|
||||||
|
|
||||||
|
async handleAnthropicMessages(
|
||||||
|
request: AnthropicChatRequest,
|
||||||
|
): Promise<AnthropicChatResponse | Observable<string>> {
|
||||||
|
const targetModel = this.mapModel(request.model);
|
||||||
|
this.logger.log(
|
||||||
|
`Received Anthropic request for model: ${request.model} (Mapped: ${targetModel}, Stream: ${request.stream})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retry loop
|
||||||
|
let lastError: unknown = null;
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
const token = await this.tokenManager.getNextToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('No available accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert Anthropic -> Gemini
|
||||||
|
const geminiRequest = this.convertAnthropicToGemini(request);
|
||||||
|
|
||||||
|
if (request.stream) {
|
||||||
|
const stream = await this.geminiClient.streamGenerate(
|
||||||
|
targetModel,
|
||||||
|
geminiRequest,
|
||||||
|
token.token.access_token,
|
||||||
|
);
|
||||||
|
return this.processAnthropicStreamResponse(stream, request.model);
|
||||||
|
} else {
|
||||||
|
const response = await this.geminiClient.generate(
|
||||||
|
targetModel,
|
||||||
|
geminiRequest,
|
||||||
|
token.token.access_token,
|
||||||
|
);
|
||||||
|
return this.convertGeminiToAnthropicResponse(response, request.model);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (error instanceof Error) {
|
||||||
|
this.logger.warn(`Anthropic Request failed: ${error.message}`);
|
||||||
|
if (this.shouldRetry(error.message)) {
|
||||||
|
this.tokenManager.markAsRateLimited(token.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || new Error('Request failed after retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertAnthropicToGemini(request: AnthropicChatRequest): GeminiRequest {
|
||||||
|
const contents: GeminiContent[] = [];
|
||||||
|
let systemInstruction: { parts: GeminiPart[] } | undefined = undefined;
|
||||||
|
|
||||||
|
if (request.system) {
|
||||||
|
let systemText = '';
|
||||||
|
if (typeof request.system === 'string') {
|
||||||
|
systemText = request.system;
|
||||||
|
} else if (Array.isArray(request.system)) {
|
||||||
|
systemText = request.system
|
||||||
|
.filter((b: AnthropicSystemBlock) => b.type === 'text')
|
||||||
|
.map((b: AnthropicSystemBlock) => b.text)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
systemInstruction = {
|
||||||
|
parts: [{ text: systemText }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anthropic 'messages' -> Gemini 'contents'
|
||||||
|
for (const msg of request.messages) {
|
||||||
|
const role = msg.role === 'user' ? 'user' : 'model';
|
||||||
|
// Handle content arrays or strings
|
||||||
|
let textPart = '';
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
textPart = msg.content
|
||||||
|
.filter((c) => c.type === 'text')
|
||||||
|
.map((c) => (c as { type: 'text'; text: string }).text)
|
||||||
|
.join('\n');
|
||||||
|
} else {
|
||||||
|
textPart = msg.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
contents.push({
|
||||||
|
role,
|
||||||
|
parts: [{ text: textPart }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents,
|
||||||
|
systemInstruction,
|
||||||
|
generationConfig: {
|
||||||
|
temperature: request.temperature,
|
||||||
|
maxOutputTokens: request.max_tokens,
|
||||||
|
topP: request.top_p,
|
||||||
|
topK: request.top_k,
|
||||||
|
},
|
||||||
|
// Note: Rust version handles 'pending_images' logic here, but for now we assume simple text/image structure.
|
||||||
|
// If we need to support image injection from history to user msg, that logic needs to be ported too.
|
||||||
|
// Current port matches previous 'any' style logic but with types.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertGeminiToAnthropicResponse(
|
||||||
|
geminiResponse: GeminiResponse,
|
||||||
|
model: string,
|
||||||
|
): AnthropicChatResponse {
|
||||||
|
const candidate = geminiResponse.candidates?.[0];
|
||||||
|
const processed = this.processInlineData(candidate);
|
||||||
|
const parts = processed?.content?.parts || [];
|
||||||
|
const contentText = parts.map((p) => p.text || '').join('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `msg_${uuidv4()}`,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: model,
|
||||||
|
content: [{ type: 'text', text: contentText }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
|
||||||
|
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private processAnthropicStreamResponse(upstreamStream: any, model: string): Observable<string> {
|
||||||
|
return new Observable<string>((subscriber) => {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
const msgId = `msg_${uuidv4()}`;
|
||||||
|
|
||||||
|
// Send Message Start
|
||||||
|
subscriber.next(
|
||||||
|
`event: message_start\ndata: ${JSON.stringify({
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: msgId,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [],
|
||||||
|
model: model,
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriber.next(
|
||||||
|
`event: content_block_start\ndata: ${JSON.stringify({
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: 'text', text: '' },
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
upstreamStream.on('data', (chunk: Buffer) => {
|
||||||
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith('data: ')) continue;
|
||||||
|
const dataStr = trimmed.slice(6);
|
||||||
|
if (dataStr === '[DONE]') continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(dataStr);
|
||||||
|
const candidate = json.candidates?.[0];
|
||||||
|
const contentPart = candidate?.content?.parts?.[0];
|
||||||
|
const text = contentPart?.text || '';
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
subscriber.next(
|
||||||
|
`event: content_block_delta\ndata: ${JSON.stringify({
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: { type: 'text_delta', text: text },
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamStream.on('end', () => {
|
||||||
|
subscriber.next(
|
||||||
|
`event: content_block_stop\ndata: ${JSON.stringify({
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: 0,
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriber.next(
|
||||||
|
`event: message_delta\ndata: ${JSON.stringify({
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||||
|
usage: { output_tokens: 0 },
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriber.next(
|
||||||
|
`event: message_stop\ndata: ${JSON.stringify({
|
||||||
|
type: 'message_stop',
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriber.complete();
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamStream.on('error', (err: any) => subscriber.error(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenAI / Universal Handlers ---
|
||||||
|
|
||||||
|
async handleChatCompletions(
|
||||||
|
request: OpenAIChatRequest,
|
||||||
|
): Promise<OpenAIChatResponse | Observable<string>> {
|
||||||
|
const targetModel = this.mapModel(request.model);
|
||||||
|
this.logger.log(
|
||||||
|
`Received request for model: ${request.model} (Mapped: ${targetModel}, Stream: ${request.stream})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retry loop for account selection
|
||||||
|
let lastError: unknown = null;
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
// 1. Get Token
|
||||||
|
const token = await this.tokenManager.getNextToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('No available accounts (all exhausted or rate limited)');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Convert Request
|
||||||
|
const geminiRequest = this.convertOpenAIToGemini(request);
|
||||||
|
|
||||||
|
// 3. Handle Stream vs Non-Stream
|
||||||
|
if (request.stream) {
|
||||||
|
const stream = await this.geminiClient.streamGenerate(
|
||||||
|
targetModel,
|
||||||
|
geminiRequest,
|
||||||
|
token.token.access_token,
|
||||||
|
);
|
||||||
|
return this.processStreamResponse(stream, request.model);
|
||||||
|
} else {
|
||||||
|
const response = await this.geminiClient.generate(
|
||||||
|
targetModel,
|
||||||
|
geminiRequest,
|
||||||
|
token.token.access_token,
|
||||||
|
);
|
||||||
|
return this.convertGeminiToOpenAIResponse(response, request.model);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (error instanceof Error) {
|
||||||
|
this.logger.warn(`Request failed with account ${token.email}: ${error.message}`);
|
||||||
|
|
||||||
|
// Check for rate limit / auth errors
|
||||||
|
if (this.shouldRetry(error.message)) {
|
||||||
|
this.tokenManager.markAsRateLimited(token.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || new Error('Request failed after retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SSE Stream conversion
|
||||||
|
private processStreamResponse(upstreamStream: any, model: string): Observable<string> {
|
||||||
|
return new Observable<string>((subscriber) => {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
upstreamStream.on('data', (chunk: Buffer) => {
|
||||||
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith('data: ')) continue;
|
||||||
|
|
||||||
|
const dataStr = trimmed.slice(6);
|
||||||
|
if (dataStr === '[DONE]') continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(dataStr);
|
||||||
|
const candidate = json.candidates?.[0];
|
||||||
|
const contentPart = candidate?.content?.parts?.[0];
|
||||||
|
const text = contentPart?.text || '';
|
||||||
|
|
||||||
|
const openAICheck = {
|
||||||
|
id: `chatcmpl-${uuidv4()}`,
|
||||||
|
object: 'chat.completion.chunk',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: { content: text },
|
||||||
|
finish_reason: candidate?.finishReason ? 'stop' : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
subscriber.next(`data: ${JSON.stringify(openAICheck)}\n\n`);
|
||||||
|
|
||||||
|
if (candidate?.finishReason) {
|
||||||
|
subscriber.next('data: [DONE]\n\n');
|
||||||
|
subscriber.complete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamStream.on('end', () => {
|
||||||
|
subscriber.complete();
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamStream.on('error', (err: any) => {
|
||||||
|
subscriber.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertOpenAIToGemini(request: OpenAIChatRequest): GeminiRequest {
|
||||||
|
const contents: GeminiContent[] = [];
|
||||||
|
let systemInstruction: { parts: GeminiPart[] } | undefined = undefined;
|
||||||
|
|
||||||
|
// Extract system message
|
||||||
|
const messages = request.messages || [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.role === 'system') {
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
systemInstruction = {
|
||||||
|
parts: [{ text: msg.content }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let textPart = '';
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
// OpenAI multimodal array
|
||||||
|
textPart = msg.content
|
||||||
|
.filter((p: OpenAIContentPart) => p.type === 'text')
|
||||||
|
.map((p: OpenAIContentPart) => p.text || '')
|
||||||
|
.join('\n');
|
||||||
|
} else {
|
||||||
|
textPart = msg.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
contents.push({
|
||||||
|
role: msg.role === 'user' ? 'user' : 'model',
|
||||||
|
parts: [{ text: textPart }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents,
|
||||||
|
systemInstruction,
|
||||||
|
generationConfig: {
|
||||||
|
temperature: request.temperature,
|
||||||
|
maxOutputTokens: request.max_tokens,
|
||||||
|
topP: request.top_p,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertGeminiToOpenAIResponse(
|
||||||
|
geminiResponse: GeminiResponse,
|
||||||
|
model: string,
|
||||||
|
): OpenAIChatResponse {
|
||||||
|
const candidate = geminiResponse.candidates?.[0];
|
||||||
|
if (!candidate) throw new Error('No candidates in response');
|
||||||
|
|
||||||
|
// Process response for images
|
||||||
|
const processedCandidate = this.processInlineData(candidate);
|
||||||
|
|
||||||
|
const parts = processedCandidate?.content?.parts || [];
|
||||||
|
const content = parts.map((p) => p.text || '').join('');
|
||||||
|
|
||||||
|
const finishReason =
|
||||||
|
candidate?.finishReason === 'STOP'
|
||||||
|
? 'stop'
|
||||||
|
: candidate?.finishReason?.toLowerCase() || 'stop';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${uuidv4()}`,
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
finish_reason: finishReason,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
|
||||||
|
completion_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
|
||||||
|
total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Gemini inlineData (images) to Markdown
|
||||||
|
private processInlineData(candidate: GeminiCandidate | undefined): GeminiCandidate | undefined {
|
||||||
|
if (!candidate?.content?.parts) return candidate;
|
||||||
|
|
||||||
|
const newParts = candidate.content.parts.map((part) => {
|
||||||
|
if (part.inlineData) {
|
||||||
|
const mimeType = part.inlineData.mimeType || 'image/jpeg';
|
||||||
|
const data = part.inlineData.data || '';
|
||||||
|
return {
|
||||||
|
text: `\n\n\n\n`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
content: {
|
||||||
|
...candidate.content,
|
||||||
|
parts: newParts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapModel(model: string): string {
|
||||||
|
const lower = model.toLowerCase();
|
||||||
|
if (lower.includes('sonnet') || lower.includes('thinking')) return 'gemini-3-pro-preview';
|
||||||
|
if (lower.includes('haiku')) return 'gemini-2.0-flash-exp';
|
||||||
|
if (lower.includes('opus')) return 'gemini-3-pro-preview';
|
||||||
|
if (lower.includes('claude')) return 'gemini-2.5-flash-thinking';
|
||||||
|
if (lower === 'gemini-3-pro-high' || lower === 'gemini-3-pro-low')
|
||||||
|
return 'gemini-3-pro-preview';
|
||||||
|
if (lower === 'gemini-3-flash') return 'gemini-3-flash-preview';
|
||||||
|
|
||||||
|
// Default to original if no mapping found
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldRetry(errorMessage: string): boolean {
|
||||||
|
const msg = errorMessage.toLowerCase();
|
||||||
|
if (
|
||||||
|
msg.includes('404') ||
|
||||||
|
msg.includes('not_found') ||
|
||||||
|
msg.includes('403') ||
|
||||||
|
msg.includes('permission_denied')
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
msg.includes('429') ||
|
||||||
|
msg.includes('resource_exhausted') ||
|
||||||
|
msg.includes('quota') ||
|
||||||
|
msg.includes('rate_limit')
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { CloudAccountRepo } from '../../../ipc/database/cloudHandler';
|
||||||
|
import { CloudAccount } from '../../../types/cloudAccount';
|
||||||
|
import { GoogleAPIService } from '../../../services/GoogleAPIService';
|
||||||
|
|
||||||
|
interface TokenData {
|
||||||
|
email: string;
|
||||||
|
account_id: string;
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
expiry_timestamp: number;
|
||||||
|
project_id?: string;
|
||||||
|
session_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TokenManagerService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(TokenManagerService.name);
|
||||||
|
private currentIndex = 0;
|
||||||
|
// In-memory cache of tokens with additional data
|
||||||
|
private tokens: Map<string, TokenData> = new Map();
|
||||||
|
// Cooldown map for rate-limited accounts
|
||||||
|
private cooldowns: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// Load accounts on module initialization
|
||||||
|
await this.loadAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAccounts(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const accounts = await CloudAccountRepo.getAccounts();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const tokenData = this.convertAccountToToken(account);
|
||||||
|
if (tokenData) {
|
||||||
|
this.tokens.set(account.id, tokenData);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Loaded ${count} accounts`);
|
||||||
|
return count;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Failed to load accounts', e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertAccountToToken(account: CloudAccount): TokenData | null {
|
||||||
|
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,
|
||||||
|
expiry_timestamp: account.token.expiry_timestamp,
|
||||||
|
project_id: account.token.project_id || undefined,
|
||||||
|
session_id: account.token.session_id || this.generateSessionId(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSessionId(): string {
|
||||||
|
// Match the Rust implementation's session ID format
|
||||||
|
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 getNextToken(): Promise<CloudAccount | null> {
|
||||||
|
try {
|
||||||
|
// Reload if empty
|
||||||
|
if (this.tokens.size === 0) {
|
||||||
|
await this.loadAccounts();
|
||||||
|
}
|
||||||
|
if (this.tokens.size === 0) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const nowSeconds = Math.floor(now / 1000);
|
||||||
|
|
||||||
|
// Filter out accounts in cooldown
|
||||||
|
const validTokens = Array.from(this.tokens.entries()).filter(([email, _]) => {
|
||||||
|
const cooldownUntil = this.cooldowns.get(email);
|
||||||
|
return !cooldownUntil || cooldownUntil <= now;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validTokens.length === 0) {
|
||||||
|
this.logger.warn('All accounts are in cooldown');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round robin selection
|
||||||
|
const [accountId, tokenData] = validTokens[this.currentIndex % validTokens.length];
|
||||||
|
this.currentIndex++;
|
||||||
|
|
||||||
|
// Check if token needs refresh (expires in < 5 minutes)
|
||||||
|
if (nowSeconds >= tokenData.expiry_timestamp - 300) {
|
||||||
|
this.logger.log(`Token for ${tokenData.email} expiring soon, refreshing...`);
|
||||||
|
try {
|
||||||
|
const newTokens = await GoogleAPIService.refreshAccessToken(tokenData.refresh_token);
|
||||||
|
|
||||||
|
// Update token data
|
||||||
|
tokenData.access_token = newTokens.access_token;
|
||||||
|
tokenData.expires_in = newTokens.expires_in;
|
||||||
|
tokenData.expiry_timestamp = nowSeconds + newTokens.expires_in;
|
||||||
|
|
||||||
|
// Save to DB
|
||||||
|
await this.saveRefreshedToken(accountId, tokenData);
|
||||||
|
this.tokens.set(accountId, tokenData);
|
||||||
|
|
||||||
|
this.logger.log(`Token refreshed for ${tokenData.email}`);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Failed to refresh token for ${tokenData.email}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve project ID if missing (mock for now, like original)
|
||||||
|
if (!tokenData.project_id) {
|
||||||
|
const mockId = `cloud-code-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
tokenData.project_id = mockId;
|
||||||
|
await this.saveProjectId(accountId, mockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Selected account: ${tokenData.email}`);
|
||||||
|
|
||||||
|
// Return in CloudAccount format for compatibility
|
||||||
|
return {
|
||||||
|
id: accountId,
|
||||||
|
email: tokenData.email,
|
||||||
|
token: {
|
||||||
|
access_token: tokenData.access_token,
|
||||||
|
refresh_token: tokenData.refresh_token,
|
||||||
|
expires_in: tokenData.expires_in,
|
||||||
|
expiry_timestamp: tokenData.expiry_timestamp,
|
||||||
|
project_id: tokenData.project_id,
|
||||||
|
session_id: tokenData.session_id,
|
||||||
|
},
|
||||||
|
} as CloudAccount;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get token', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markAsRateLimited(email: string) {
|
||||||
|
// Cooldown for 5 minutes
|
||||||
|
const until = Date.now() + 5 * 60 * 1000;
|
||||||
|
this.cooldowns.set(email, until);
|
||||||
|
this.logger.warn(
|
||||||
|
`Account ${email} marked as rate limited until ${new Date(until).toISOString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCooldown(email: string) {
|
||||||
|
this.cooldowns.delete(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveRefreshedToken(accountId: string, tokenData: TokenData) {
|
||||||
|
try {
|
||||||
|
const acc = await CloudAccountRepo.getAccount(accountId);
|
||||||
|
if (acc && acc.token) {
|
||||||
|
const newToken = {
|
||||||
|
...acc.token,
|
||||||
|
access_token: tokenData.access_token,
|
||||||
|
expires_in: tokenData.expires_in,
|
||||||
|
expiry_timestamp: tokenData.expiry_timestamp,
|
||||||
|
};
|
||||||
|
await CloudAccountRepo.updateToken(accountId, newToken);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Failed to save refreshed token to DB', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveProjectId(accountId: string, projectId: string) {
|
||||||
|
try {
|
||||||
|
const acc = await CloudAccountRepo.getAccount(accountId);
|
||||||
|
if (acc && acc.token) {
|
||||||
|
const newToken = {
|
||||||
|
...acc.token,
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
await CloudAccountRepo.updateToken(accountId, newToken);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Failed to save project ID to DB', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of loaded accounts (for status)
|
||||||
|
*/
|
||||||
|
getAccountCount(): number {
|
||||||
|
return this.tokens.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user