xt8 commited on
Commit
da8ebae
·
verified ·
1 Parent(s): 752acd3

Create main.ts

Browse files
Files changed (1) hide show
  1. main.ts +646 -0
main.ts ADDED
@@ -0,0 +1,646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
2
+
3
+ // --- 常量定义 ---
4
+ const MAX_DOCUMENT_SIZE_MB = 20; // 设置最大文档大小限制(单位:MB)
5
+ const MAX_DOCUMENT_SIZE_BYTES = MAX_DOCUMENT_SIZE_MB * 1024 * 1024;
6
+ const MODELS_CACHE_DURATION = 60000; // 1分钟模型缓存
7
+
8
+ interface OpenAIMessage {
9
+ role: "system" | "user" | "assistant";
10
+ content: string | Array<{
11
+ type: string;
12
+ text?: string;
13
+ image_url?: { url: string };
14
+ document?: { url: string; type: string }; // 支持多种文档类型
15
+ }>;
16
+ }
17
+
18
+ interface OpenAIRequest {
19
+ model: string;
20
+ messages: OpenAIMessage[];
21
+ max_tokens?: number;
22
+ temperature?: number;
23
+ stream?: boolean;
24
+ }
25
+
26
+ class GoogleAIService {
27
+ public apiKeys: string[];
28
+ public currentKeyIndex = 0;
29
+ public cachedModels: any[] = [];
30
+ public modelsLastFetch = 0;
31
+
32
+ constructor() {
33
+ this.apiKeys = [];
34
+ let i = 1;
35
+ while (true) {
36
+ const key = Deno.env.get(`GOOGLE_AI_KEY_${i}`) ||
37
+ (i === 1 ? Deno.env.get("GOOGLE_AI_KEY") : null);
38
+ if (!key) break;
39
+ this.apiKeys.push(key);
40
+ i++;
41
+ }
42
+
43
+ if (this.apiKeys.length === 0) {
44
+ throw new Error("No Google AI API keys found in environment variables (e.g., GOOGLE_AI_KEY_1, GOOGLE_AI_KEY)");
45
+ }
46
+ }
47
+
48
+ private getNextApiKey(): string {
49
+ const key = this.apiKeys[this.currentKeyIndex];
50
+ this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length;
51
+ return key;
52
+ }
53
+
54
+ async fetchOfficialModels(): Promise<any[]> {
55
+ const now = Date.now();
56
+ if (this.cachedModels.length > 0 && (now - this.modelsLastFetch) < MODELS_CACHE_DURATION) {
57
+ return this.cachedModels;
58
+ }
59
+
60
+ const apiKey = this.getNextApiKey();
61
+ try {
62
+ const response = await fetch(
63
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`,
64
+ { method: "GET", headers: { "Content-Type": "application/json" } }
65
+ );
66
+
67
+ if (!response.ok) {
68
+ console.warn(`Failed to fetch models from Google AI: ${response.status}. Using fallback models.`);
69
+ return this.getFallbackModels();
70
+ }
71
+
72
+ const data = await response.json();
73
+ if (data.models && Array.isArray(data.models)) {
74
+ this.cachedModels = data.models.filter((model: any) =>
75
+ model.supportedGenerationMethods?.includes('generateContent')
76
+ );
77
+ this.modelsLastFetch = now;
78
+ console.log(`Fetched ${this.cachedModels.length} models from Google AI`);
79
+ return this.cachedModels;
80
+ }
81
+ return this.getFallbackModels();
82
+ } catch (error) {
83
+ console.warn("Error fetching models from Google AI:", error.message, ". Using fallback models.");
84
+ return this.getFallbackModels();
85
+ }
86
+ }
87
+
88
+ private getFallbackModels(): any[] {
89
+ return [
90
+ { name: "models/gemini-1.5-pro", displayName: "Gemini 1.5 Pro", description: "Mid-size multimodal model that supports up to 1 million tokens, images, and documents (PDF, TXT, MD)", supportedGenerationMethods: ["generateContent"], maxTokens: 1000000, supportsDocuments: true },
91
+ { name: "models/gemini-1.5-flash", displayName: "Gemini 1.5 Flash", description: "Fast and versatile multimodal model for diverse tasks, supports images and documents (PDF, TXT, MD)", supportedGenerationMethods: ["generateContent"], maxTokens: 1000000, supportsDocuments: true },
92
+ { name: "models/gemini-2.0-flash-preview-image-generation", displayName: "Gemini 2.0 Flash Image Generation", description: "Advanced model for generating and editing high-quality images with text and image outputs", supportedGenerationMethods: ["generateContent"], maxTokens: 100000, capabilities: ["text", "image_generation", "image_editing"] }
93
+ ];
94
+ }
95
+
96
+ public isVisionModel = (modelName: string): boolean => modelName.toLowerCase().includes('vision') || modelName.toLowerCase().includes('pro');
97
+ public isImageGenerationModel = (modelName: string): boolean => modelName.includes('image-generation') || modelName === 'gemini-2.0-flash-preview-image-generation';
98
+ public isImageEditingModel = (modelName: string): boolean => modelName.includes('image-generation') || modelName === 'gemini-2.0-flash-preview-image-generation';
99
+ public isDocumentModel = (modelName: string): boolean => modelName.toLowerCase().includes('gemini-1.5') || modelName.toLowerCase().includes('pro') || modelName.toLowerCase().includes('flash');
100
+
101
+ private getDocumentType(url: string): string {
102
+ const lowerUrl = url.toLowerCase();
103
+ if (lowerUrl.startsWith('data:application/pdf') || lowerUrl.includes('.pdf')) return 'pdf';
104
+ if (lowerUrl.startsWith('data:text/plain') || lowerUrl.includes('.txt')) return 'txt';
105
+ if (lowerUrl.startsWith('data:text/markdown') || lowerUrl.includes('.md')) return 'md';
106
+ if (lowerUrl.startsWith('data:application/msword') || lowerUrl.includes('.doc')) return 'doc';
107
+ if (lowerUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document') || lowerUrl.includes('.docx')) return 'docx';
108
+ return 'unknown';
109
+ }
110
+
111
+ /**
112
+ * [关键改进] 提取并验证文档数据,增加大小检查和更稳健的解析
113
+ */
114
+ private extractDocumentData(documentUrl: string): { mimeType: string; data: string; text?: string; docType: string } {
115
+ const docType = this.getDocumentType(documentUrl);
116
+
117
+ if (!documentUrl.startsWith("data:")) {
118
+ if (documentUrl.startsWith("http")) {
119
+ throw new Error("Document URL downloads are not supported. Please provide base64 encoded data URLs.");
120
+ }
121
+ // 如果不是data url或http url,则假定为纯base64数据,但这是一种不推荐的格式
122
+ // 为了健壮性,我们强制要求使用标准的 data URL
123
+ throw new Error("Document must be provided as a standard base64 data URL (e.g., 'data:application/pdf;base64,...').");
124
+ }
125
+
126
+ const parts = documentUrl.split(",");
127
+ if (parts.length !== 2) {
128
+ throw new Error("Invalid data URL format for document. Expected 'data:[mime];base64,[data]'.");
129
+ }
130
+ const [mimeInfo, base64Data] = parts;
131
+
132
+ // **改进1: 检查文件大小**
133
+ // Base64 字符串的长度约是原始数据的 4/3。
134
+ const approxSizeInBytes = base64Data.length * 0.75;
135
+ if (approxSizeInBytes > MAX_DOCUMENT_SIZE_BYTES) {
136
+ throw new Error(`Document size (${(approxSizeInBytes / 1024 / 1024).toFixed(2)}MB) exceeds the ${MAX_DOCUMENT_SIZE_MB}MB limit.`);
137
+ }
138
+
139
+ const mimeType = mimeInfo.split(":")[1]?.split(";")[0] || 'application/octet-stream';
140
+
141
+ if (docType === 'txt' || docType === 'md') {
142
+ try {
143
+ const textContent = atob(base64Data);
144
+ return { mimeType, data: base64Data, text: textContent, docType };
145
+ } catch (error) {
146
+ console.error(`Failed to decode base64 content for ${docType}:`, error);
147
+ throw new Error(`Invalid base64 encoding for ${docType} document.`);
148
+ }
149
+ }
150
+
151
+ // 自动识别PDF的MIME类型
152
+ const finalMimeType = docType === 'pdf' ? 'application/pdf' : mimeType;
153
+ return { mimeType: finalMimeType, data: base64Data, docType };
154
+ }
155
+
156
+ private extractImageData(imageUrl: string): { mimeType: string; data: string } {
157
+ if (imageUrl.startsWith("data:image/")) {
158
+ const [mimeInfo, base64Data] = imageUrl.split(",");
159
+ const mimeType = mimeInfo.split(":")[1].split(";")[0];
160
+ return { mimeType, data: base64Data };
161
+ } else if (imageUrl.startsWith("http")) {
162
+ throw new Error("URL images are not supported yet. Please provide base64 encoded images.");
163
+ } else {
164
+ return { mimeType: "image/jpeg", data: imageUrl };
165
+ }
166
+ }
167
+
168
+ async generateContentWithDocument(messages: OpenAIMessage[], modelName: string): Promise<string> {
169
+ const apiKey = this.getNextApiKey();
170
+ const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
171
+ const documentModel = this.isDocumentModel(fullModelName) ? fullModelName : 'models/gemini-1.5-pro-latest';
172
+
173
+ console.log(`Processing document with model: ${documentModel}`);
174
+
175
+ let contents;
176
+ try {
177
+ contents = messages.map(msg => {
178
+ if (typeof msg.content === "string") {
179
+ return { role: msg.role === "assistant" ? "model" : "user", parts: [{ text: msg.content }] };
180
+ }
181
+
182
+ const messageParts = msg.content.map(part => {
183
+ if (part.type === "text") return { text: part.text };
184
+
185
+ if (part.type === "image_url" && part.image_url) {
186
+ const { mimeType, data } = this.extractImageData(part.image_url.url);
187
+ return { inlineData: { mimeType, data } };
188
+ }
189
+
190
+ if (part.type === "document" && part.document) {
191
+ const docData = this.extractDocumentData(part.document.url);
192
+ console.log(`Processing document: ${docData.docType}, mime: ${docData.mimeType}, size: ${(docData.data.length * 0.75 / 1024).toFixed(2)} KB`);
193
+
194
+ if (docData.docType === 'txt' || docData.docType === 'md') {
195
+ const prefix = docData.docType === 'md' ? 'Markdown document content:\n' : 'Text document content:\n';
196
+ return { text: `${prefix}${docData.text}` };
197
+ }
198
+ if (docData.docType === 'pdf') {
199
+ return { inlineData: { mimeType: docData.mimeType, data: docData.data } };
200
+ }
201
+ return { text: `[Document type '${docData.docType}' is not supported for direct processing. Please convert to PDF, TXT, or MD.]` };
202
+ }
203
+ return { text: "" };
204
+ });
205
+ return { role: msg.role === "assistant" ? "model" : "user", parts: messageParts.filter(p => p.text || p.inlineData) };
206
+ });
207
+ } catch (error) {
208
+ throw error;
209
+ }
210
+
211
+ const requestBody = {
212
+ contents,
213
+ generationConfig: { temperature: 0.7, maxOutputTokens: 8192 }
214
+ };
215
+
216
+ const response = await fetch(
217
+ `https://generativelanguage.googleapis.com/v1beta/${documentModel}:generateContent?key=${apiKey}`,
218
+ {
219
+ method: "POST",
220
+ headers: { "Content-Type": "application/json" },
221
+ body: JSON.stringify(requestBody),
222
+ }
223
+ );
224
+
225
+ if (!response.ok) {
226
+ const errorBody = await response.json().catch(() => response.text());
227
+ const errorMessage = errorBody?.error?.message || JSON.stringify(errorBody);
228
+ console.error(`Google API Error: ${response.status} - ${errorMessage}`);
229
+ throw new Error(`Google API request failed with status ${response.status}: ${errorMessage}`);
230
+ }
231
+
232
+ const data = await response.json();
233
+ const promptFeedback = data.promptFeedback;
234
+ if (promptFeedback && promptFeedback.blockReason) {
235
+ const reason = promptFeedback.blockReason;
236
+ const safetyRatings = promptFeedback.safetyRatings?.map((r: any) => `${r.category}: ${r.probability}`).join(', ') || 'N/A';
237
+ throw new Error(`Request blocked by Google API. Reason: ${reason}. Safety Ratings: [${safetyRatings}]`);
238
+ }
239
+
240
+ if (!data.candidates || data.candidates.length === 0) {
241
+ throw new Error("No response generated for document content. The content might be empty or unreadable.");
242
+ }
243
+
244
+ const candidate = data.candidates[0];
245
+ if (candidate.finishReason === "SAFETY") {
246
+ throw new Error("Response blocked due to safety filters. Check content for sensitive topics.");
247
+ }
248
+ if (candidate.finishReason === "RECITATION") {
249
+ throw new Error("Response blocked due to recitation policy. The model's output was too similar to a copyrighted source.");
250
+ }
251
+
252
+ return candidate.content?.parts[0]?.text || "Document processed, but no text response was generated.";
253
+ }
254
+
255
+ // The rest of the original methods from the user's code
256
+ async generateContent(messages: OpenAIMessage[], modelName: string, enableSearch: boolean = false): Promise<string> {
257
+ const hasDocument = messages.some(msg => Array.isArray(msg.content) && msg.content.some(part => part.type === "document"));
258
+ if (hasDocument) {
259
+ return await this.generateContentWithDocument(messages, modelName);
260
+ }
261
+
262
+ const apiKey = this.getNextApiKey();
263
+ const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
264
+
265
+ const contents = messages.map(msg => {
266
+ if (typeof msg.content === "string") {
267
+ return { role: msg.role === "assistant" ? "model" : "user", parts: [{ text: msg.content }] };
268
+ } else {
269
+ const messageParts = msg.content.map(part => {
270
+ if (part.type === "text") {
271
+ return { text: part.text };
272
+ } else if (part.type === "image_url" && part.image_url) {
273
+ const imageData = part.image_url.url;
274
+ if (imageData.startsWith("data:image/")) {
275
+ const { mimeType, data } = this.extractImageData(imageData);
276
+ return { inlineData: { mimeType, data } };
277
+ } else {
278
+ return { fileData: { mimeType: "image/jpeg", fileUri: imageData } };
279
+ }
280
+ }
281
+ return { text: "" };
282
+ });
283
+ return { role: msg.role === "assistant" ? "model" : "user", parts: messageParts };
284
+ }
285
+ });
286
+
287
+ const requestBody: any = {
288
+ contents,
289
+ generationConfig: { temperature: 0.7, maxOutputTokens: 4096 }
290
+ };
291
+ if (enableSearch) {
292
+ requestBody.tools = [{ googleSearchRetrieval: {} }];
293
+ }
294
+
295
+ const response = await fetch(
296
+ `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`,
297
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }
298
+ );
299
+
300
+ if (!response.ok) {
301
+ const errorText = await response.text();
302
+ throw new Error(`Google AI API error: ${response.status} - ${errorText}`);
303
+ }
304
+ const data = await response.json();
305
+ if (!data.candidates || data.candidates.length === 0) {
306
+ throw new Error("No response generated from Google AI");
307
+ }
308
+ const candidate = data.candidates[0];
309
+ if (candidate.finishReason === "SAFETY") {
310
+ throw new Error("Response blocked due to safety filters");
311
+ }
312
+ return candidate.content?.parts[0]?.text || "No response generated";
313
+ }
314
+
315
+ async generateOrEditImageWithGemini(prompt: string, modelName: string = "gemini-2.0-flash-preview-image-generation", inputImage?: { mimeType: string; data: string }): Promise<{ text?: string; imageBase64?: string; imageUrl?: string }> {
316
+ const apiKey = this.getNextApiKey();
317
+ const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
318
+ const requestParts: any[] = [{ text: prompt }];
319
+
320
+ if (inputImage) {
321
+ requestParts.push({ inline_data: { mime_type: inputImage.mimeType, data: inputImage.data } });
322
+ console.log(`Editing image with model: ${fullModelName}`);
323
+ } else {
324
+ console.log(`Generating image with model: ${fullModelName}`);
325
+ }
326
+
327
+ const requestBody = {
328
+ contents: [{ parts: requestParts }],
329
+ generationConfig: { responseModalities: ["TEXT", "IMAGE"], temperature: 0.7 }
330
+ };
331
+
332
+ const response = await fetch(
333
+ `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`,
334
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }
335
+ );
336
+
337
+ if (!response.ok) {
338
+ const errorText = await response.text();
339
+ throw new Error(`Image ${inputImage ? 'editing' : 'generation'} failed: ${response.status} - ${errorText}`);
340
+ }
341
+ const data = await response.json();
342
+ if (!data.candidates || data.candidates.length === 0) {
343
+ throw new Error(`No ${inputImage ? 'edited' : 'generated'} image returned`);
344
+ }
345
+
346
+ const candidate = data.candidates[0];
347
+ if (candidate.finishReason === "SAFETY") {
348
+ throw new Error(`Image ${inputImage ? 'editing' : 'generation'} blocked due to safety filters`);
349
+ }
350
+
351
+ const responseParts = candidate.content?.parts || [];
352
+ let textResponse = "";
353
+ let imageBase64 = "";
354
+
355
+ for (const part of responseParts) {
356
+ if (part.text) textResponse += part.text;
357
+ if (part.inlineData?.data) imageBase64 = part.inlineData.data;
358
+ if (part.inline_data?.data) imageBase64 = part.inline_data.data;
359
+ }
360
+
361
+ const result: { text?: string; imageBase64?: string; imageUrl?: string } = {};
362
+ if (textResponse) result.text = textResponse;
363
+ if (imageBase64) {
364
+ result.imageBase64 = imageBase64;
365
+ result.imageUrl = `data:image/png;base64,${imageBase64}`;
366
+ }
367
+ return result;
368
+ }
369
+
370
+ async generateContentWithGrounding(messages: OpenAIMessage[], modelName: string): Promise<string> {
371
+ const apiKey = this.getNextApiKey();
372
+ const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
373
+ const contents = messages.map(msg => ({ role: msg.role === 'assistant' ? 'model' : 'user', parts: [{ text: typeof msg.content === 'string' ? msg.content : '' }] }));
374
+
375
+ const requestBody = {
376
+ contents,
377
+ tools: [{ googleSearch: {} }],
378
+ generationConfig: { temperature: 0.7, maxOutputTokens: 4096 }
379
+ };
380
+
381
+ const response = await fetch(
382
+ `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`,
383
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }
384
+ );
385
+
386
+ if (!response.ok) {
387
+ console.warn(`Google Search API failed: ${response.status}, trying alternative.`);
388
+ return await this.generateContentWithSearchPrompt(messages, modelName);
389
+ }
390
+
391
+ const data = await response.json();
392
+ if (!data.candidates || data.candidates.length === 0) {
393
+ return await this.generateContentWithSearchPrompt(messages, modelName);
394
+ }
395
+
396
+ const candidate = data.candidates[0];
397
+ if (candidate.finishReason === "SAFETY") {
398
+ throw new Error("Response blocked due to safety filters");
399
+ }
400
+ return candidate.content?.parts[0]?.text || "No response generated";
401
+ }
402
+
403
+ async generateContentWithSearchPrompt(messages: OpenAIMessage[], modelName: string): Promise<string> {
404
+ const enhancedMessages = [...messages];
405
+ const lastMessage = enhancedMessages[enhancedMessages.length - 1];
406
+ if (typeof lastMessage.content === "string") {
407
+ lastMessage.content = `Please provide the most current and accurate information available about: ${lastMessage.content}.`;
408
+ }
409
+ return await this.generateContent(enhancedMessages, modelName, false);
410
+ }
411
+
412
+ async generateOrEditImage(prompt: string, modelName: string, inputImages?: any[]): Promise<string> {
413
+ if (this.isImageGenerationModel(modelName)) {
414
+ try {
415
+ let inputImage: { mimeType: string; data: string } | undefined;
416
+ if (inputImages && inputImages.length > 0) {
417
+ inputImage = this.extractImageData(inputImages[0].url);
418
+ }
419
+ const result = await this.generateOrEditImageWithGemini(prompt, modelName, inputImage);
420
+ let response = "";
421
+ if (result.text) response += result.text + "\\\\n\\\\n";
422
+ if (result.imageUrl) response += `${inputImage ? 'Edited' : 'Generated'} image:\\\\n${result.imageUrl}`;
423
+ return response || `Image processing complete.`;
424
+ } catch (error) {
425
+ return `Image processing failed: ${error.message}`;
426
+ }
427
+ }
428
+ return `Model ${modelName} does not support image generation. Use a model like gemini-2.0-flash-preview-image-generation.`;
429
+ }
430
+ }
431
+
432
+ class OpenAICompatibleServer {
433
+ private googleAI: GoogleAIService;
434
+ private authKey: string;
435
+
436
+ constructor() {
437
+ this.googleAI = new GoogleAIService();
438
+ this.authKey = Deno.env.get("AUTH_KEY") || "";
439
+ }
440
+
441
+ private authenticate(request: Request): boolean {
442
+ if (!this.authKey) return true;
443
+ const authHeader = request.headers.get("Authorization");
444
+ return authHeader ? authHeader.replace("Bearer ", "") === this.authKey : false;
445
+ }
446
+
447
+ private isDocumentContent(url?: string): boolean {
448
+ if (!url) return false;
449
+ const lowerUrl = url.toLowerCase();
450
+ return lowerUrl.includes('.pdf') || lowerUrl.startsWith('data:application/pdf') ||
451
+ lowerUrl.includes('.txt') || lowerUrl.startsWith('data:text/plain') ||
452
+ lowerUrl.includes('.md') || lowerUrl.startsWith('data:text/markdown');
453
+ }
454
+
455
+ private async handleChatCompletions(request: Request): Promise<Response> {
456
+ try {
457
+ const body: OpenAIRequest = await request.json();
458
+ const requestedModel = body.model || "gemini-1.5-pro";
459
+ const stream = body.stream || false;
460
+ console.log(`Request for model: ${requestedModel}, stream: ${stream}`);
461
+
462
+ const lastMessage = body.messages[body.messages.length - 1];
463
+ const content = typeof lastMessage.content === "string"
464
+ ? lastMessage.content
465
+ : (Array.isArray(lastMessage.content) ? lastMessage.content.map(p => p.text || "").join(" ") : "");
466
+
467
+ const hasDocument = body.messages.some(msg =>
468
+ Array.isArray(msg.content) &&
469
+ msg.content.some(part => part.type === "document" || this.isDocumentContent(part.document?.url))
470
+ );
471
+
472
+ const hasImages = body.messages.some(msg => Array.isArray(msg.content) && msg.content.some(part => part.type === "image_url"));
473
+
474
+ let inputImages: any[] = [];
475
+ if (hasImages) {
476
+ body.messages.forEach(msg => {
477
+ if (Array.isArray(msg.content)) {
478
+ msg.content.forEach(part => {
479
+ if (part.type === "image_url" && part.image_url) inputImages.push({ url: part.image_url.url });
480
+ });
481
+ }
482
+ });
483
+ }
484
+
485
+ let responseText: string;
486
+
487
+ // Routing logic based on keywords and content types
488
+ if (hasDocument) {
489
+ responseText = await this.googleAI.generateContentWithDocument(body.messages, requestedModel);
490
+ } else if (this.googleAI.isImageEditingModel(requestedModel) && hasImages) {
491
+ responseText = await this.googleAI.generateOrEditImage(content, requestedModel, inputImages);
492
+ } else if (this.googleAI.isImageGenerationModel(requestedModel)) {
493
+ responseText = await this.googleAI.generateOrEditImage(content, requestedModel);
494
+ } else if (content.toLowerCase().startsWith("/search:")) {
495
+ const query = content.substring(8).trim();
496
+ const searchMessages = [{ ...lastMessage, content: query }];
497
+ responseText = await this.googleAI.generateContentWithGrounding(searchMessages, requestedModel);
498
+ } else {
499
+ responseText = await this.googleAI.generateContent(body.messages, requestedModel, false);
500
+ }
501
+
502
+ if (stream) {
503
+ const streamResponse = await this.streamStringAsOpenAIResponse(responseText, requestedModel);
504
+ return new Response(streamResponse, {
505
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*" }
506
+ });
507
+ } else {
508
+ const responsePayload = {
509
+ id: `chatcmpl-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: requestedModel,
510
+ choices: [{ index: 0, message: { role: "assistant", content: responseText }, finish_reason: "stop" }],
511
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
512
+ };
513
+ return new Response(JSON.stringify(responsePayload), { headers: { "Content-Type": "application/json" } });
514
+ }
515
+ } catch (error) {
516
+ console.error("Error in chat completions:", error.message);
517
+ const status = error.message.includes("exceeds the limit") || error.message.includes("Invalid") ? 400 : 500;
518
+ return new Response(
519
+ JSON.stringify({
520
+ error: {
521
+ message: error.message,
522
+ type: status === 400 ? "invalid_request_error" : "api_error",
523
+ code: null
524
+ }
525
+ }),
526
+ { status, headers: { "Content-Type": "application/json" } }
527
+ );
528
+ }
529
+ }
530
+
531
+ private async streamStringAsOpenAIResponse(content: string, modelName: string): Promise<ReadableStream<Uint8Array>> {
532
+ const encoder = new TextEncoder();
533
+ const streamId = `chatcmpl-${Date.now()}`;
534
+ const creationTime = Math.floor(Date.now() / 1000);
535
+ let contentQueue = content.split('');
536
+
537
+ return new ReadableStream({
538
+ start(controller) {
539
+ const initialChunk = { id: streamId, object: 'chat.completion.chunk', created: creationTime, model: modelName, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] };
540
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialChunk)}\n\n`));
541
+ },
542
+ pull(controller) {
543
+ if (contentQueue.length === 0) {
544
+ const finalChunk = { id: streamId, object: 'chat.completion.chunk', created: creationTime, model: modelName, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
545
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
546
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
547
+ controller.close();
548
+ return;
549
+ }
550
+ const char = contentQueue.shift();
551
+ const chunk = { id: streamId, object: 'chat.completion.chunk', created: creationTime, model: modelName, choices: [{ index: 0, delta: { content: char }, finish_reason: null }] };
552
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
553
+ }
554
+ });
555
+ }
556
+
557
+ private async handleModels(): Promise<Response> {
558
+ try {
559
+ const googleModels = await this.googleAI.fetchOfficialModels();
560
+ const models = {
561
+ object: "list",
562
+ data: googleModels.map(model => {
563
+ const modelId = model.name.replace('models/', '');
564
+ return {
565
+ id: modelId, object: "model", created: Math.floor(Date.now() / 1000), owned_by: "google",
566
+ description: model.description || model.displayName, maxTokens: model.inputTokenLimit || model.maxTokens
567
+ };
568
+ })
569
+ };
570
+ return new Response(JSON.stringify(models), { headers: { "Content-Type": "application/json" } });
571
+ } catch (error) {
572
+ console.error("Error fetching models:", error);
573
+ return new Response(JSON.stringify({ error: { message: "Failed to fetch models." } }), { status: 500 });
574
+ }
575
+ }
576
+
577
+ private async handleStatus(): Promise<Response> {
578
+ const status = {
579
+ status: "healthy", timestamp: new Date().toISOString(), version: "2.5.0",
580
+ api_keys_loaded: this.googleAI.apiKeys.length,
581
+ models_in_cache: this.googleAI.cachedModels.length,
582
+ models_last_fetched: this.googleAI.modelsLastFetch > 0 ? new Date(this.googleAI.modelsLastFetch).toISOString() : "never"
583
+ };
584
+ return new Response(JSON.stringify(status), { headers: { "Content-Type": "application/json" } });
585
+ }
586
+
587
+ async handleRequest(request: Request): Promise<Response> {
588
+ const corsHeaders = {
589
+ "Access-Control-Allow-Origin": "*",
590
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
591
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
592
+ };
593
+
594
+ if (request.method === "OPTIONS") {
595
+ return new Response(null, { headers: corsHeaders });
596
+ }
597
+
598
+ const url = new URL(request.url);
599
+ let response: Response;
600
+
601
+ // Handle routes
602
+ if (url.pathname === "/health" || url.pathname === "/status") {
603
+ response = await this.handleStatus();
604
+ } else if (!this.authenticate(request)) {
605
+ response = new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { status: 401 });
606
+ } else if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
607
+ response = await this.handleChatCompletions(request);
608
+ } else if (url.pathname === "/v1/models" && request.method === "GET") {
609
+ response = await this.handleModels();
610
+ } else {
611
+ response = new Response("Not Found", { status: 404 });
612
+ }
613
+
614
+ // Add CORS headers to all responses
615
+ const finalHeaders = new Headers(response.headers);
616
+ for (const [key, value] of Object.entries(corsHeaders)) {
617
+ finalHeaders.set(key, value);
618
+ }
619
+
620
+ return new Response(response.body, { status: response.status, headers: finalHeaders });
621
+ }
622
+ }
623
+
624
+ // --- 服务器启动 ---
625
+ const server = new OpenAICompatibleServer();
626
+
627
+ console.log("🚀 OpenAI Compatible Server with Google AI starting on port 8000...");
628
+ console.log(`✅ Loaded ${server.googleAI.apiKeys.length} API key(s).`);
629
+ console.log(`📄 Max document size set to ${MAX_DOCUMENT_SIZE_MB}MB.`);
630
+
631
+ // Pre-fetch models at startup
632
+ server.googleAI.fetchOfficialModels().then(models => {
633
+ console.log(`✅ Successfully fetched ${models.length} models from Google AI.`);
634
+ }).catch(error => {
635
+ console.warn(`⚠️ Could not pre-fetch models: ${error.message}. Will use fallbacks or fetch on first request.`);
636
+ });
637
+
638
+ console.log("\n🔗 Endpoints:");
639
+ console.log(" POST /v1/chat/completions");
640
+ console.log(" GET /v1/models");
641
+ console.log(" GET /status");
642
+
643
+ await serve(
644
+ (request: Request) => server.handleRequest(request),
645
+ { port: 8000 }
646
+ );