xt8 commited on
Commit
2cd8f11
·
verified ·
1 Parent(s): a7d61bb

Update main.ts

Browse files
Files changed (1) hide show
  1. main.ts +375 -304
main.ts CHANGED
@@ -1,19 +1,17 @@
1
  import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
2
- import { decode } from "https://deno.land/std@0.208.0/encoding/base64.ts";
3
 
4
  // --- 常量定义 ---
5
- const MAX_DOCUMENT_SIZE_MB = 20;
6
  const MAX_DOCUMENT_SIZE_BYTES = MAX_DOCUMENT_SIZE_MB * 1024 * 1024;
7
- const MODELS_CACHE_DURATION = 60000;
8
 
9
- // --- 接口定义 ---
10
  interface OpenAIMessage {
11
  role: "system" | "user" | "assistant";
12
  content: string | Array<{
13
  type: string;
14
  text?: string;
15
  image_url?: { url: string };
16
- document?: { url: string; type: string };
17
  }>;
18
  }
19
 
@@ -25,12 +23,6 @@ interface OpenAIRequest {
25
  stream?: boolean;
26
  }
27
 
28
- interface OpenAITTSRequest {
29
- model: string;
30
- input: string;
31
- voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'shimmer' | 'nova' | string;
32
- }
33
-
34
  class GoogleAIService {
35
  public apiKeys: string[];
36
  public currentKeyIndex = 0;
@@ -58,63 +50,7 @@ class GoogleAIService {
58
  this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length;
59
  return key;
60
  }
61
-
62
- private _buildContents(messages: OpenAIMessage[]) {
63
- return messages.map(msg => {
64
- if (typeof msg.content === "string") {
65
- return { role: msg.role === "assistant" ? "model" : "user", parts: [{ text: msg.content }] };
66
- } else {
67
- const messageParts = msg.content.map(part => {
68
- if (part.type === "text") {
69
- return { text: part.text };
70
- } else if (part.type === "image_url" && part.image_url) {
71
- const imageData = part.image_url.url;
72
- if (imageData.startsWith("data:image/")) {
73
- const { mimeType, data } = this.extractImageData(imageData);
74
- return { inlineData: { mimeType, data } };
75
- } else {
76
- return { fileData: { mimeType: "image/jpeg", fileUri: imageData } };
77
- }
78
- }
79
- return { text: "" };
80
- });
81
- return { role: msg.role === "assistant" ? "model" : "user", parts: messageParts };
82
- }
83
- });
84
- }
85
-
86
- async generateContentStream(messages: OpenAIMessage[], modelName: string): Promise<ReadableStream<Uint8Array>> {
87
- const apiKey = this.getNextApiKey();
88
- const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
89
- const contents = this._buildContents(messages);
90
-
91
- const requestBody = {
92
- contents,
93
- generationConfig: { temperature: 0.7, maxOutputTokens: 8192 }
94
- };
95
-
96
- const streamUrl = `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:streamGenerateContent?key=${apiKey}&alt=sse`;
97
-
98
- const response = await fetch(streamUrl, {
99
- method: "POST",
100
- headers: { "Content-Type": "application/json" },
101
- body: JSON.stringify(requestBody)
102
- });
103
-
104
- if (!response.ok) {
105
- const errorText = await response.text();
106
- console.error(`Google AI Stream API error: ${response.status} - ${errorText}`);
107
- throw new Error(`Google AI Stream API error: ${response.status} - ${errorText}`);
108
- }
109
 
110
- if (!response.body) {
111
- throw new Error("The response body from the Google AI Stream API is null.");
112
- }
113
-
114
- return response.body;
115
- }
116
-
117
- // (所有其他 GoogleAIService 方法保持不变, 这里为了简洁省略,请保留您文件中的这些方法)
118
  async fetchOfficialModels(): Promise<any[]> {
119
  const now = Date.now();
120
  if (this.cachedModels.length > 0 && (now - this.modelsLastFetch) < MODELS_CACHE_DURATION) {
@@ -148,68 +84,20 @@ class GoogleAIService {
148
  return this.getFallbackModels();
149
  }
150
  }
 
151
  private getFallbackModels(): any[] {
152
  return [
153
  { 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 },
154
  { 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 },
155
- { 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"] },
156
- { name: "models/gemini-2.5-flash-preview-tts", displayName: "Gemini 2.5 Flash TTS", description: "Advanced model for generating high-quality speech from text.", supportedGenerationMethods: ["generateContent"] },
157
  ];
158
  }
 
159
  public isVisionModel = (modelName: string): boolean => modelName.toLowerCase().includes('vision') || modelName.toLowerCase().includes('pro');
160
  public isImageGenerationModel = (modelName: string): boolean => modelName.includes('image-generation') || modelName === 'gemini-2.0-flash-preview-image-generation';
161
  public isImageEditingModel = (modelName: string): boolean => modelName.includes('image-generation') || modelName === 'gemini-2.0-flash-preview-image-generation';
162
  public isDocumentModel = (modelName: string): boolean => modelName.toLowerCase().includes('gemini-1.5') || modelName.toLowerCase().includes('pro') || modelName.toLowerCase().includes('flash');
163
- public isTTSModel = (modelName: string): boolean => modelName.toLowerCase().includes('tts');
164
- async generateSpeech(text: string, modelName: string, voiceName: string): Promise<string> {
165
- const apiKey = this.getNextApiKey();
166
- const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
167
-
168
- console.log(`Generating speech with model: ${fullModelName}, voice: ${voiceName}`);
169
-
170
- const requestBody = {
171
- contents: [{
172
- parts: [{ "text": text }]
173
- }],
174
- generationConfig: {
175
- responseModalities: ["AUDIO"],
176
- speechConfig: {
177
- voiceConfig: {
178
- prebuiltVoiceConfig: {
179
- voiceName: voiceName
180
- }
181
- }
182
- }
183
- },
184
- model: fullModelName,
185
- };
186
-
187
- const response = await fetch(
188
- `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`,
189
- {
190
- method: "POST",
191
- headers: { "Content-Type": "application/json" },
192
- body: JSON.stringify(requestBody),
193
- }
194
- );
195
 
196
- if (!response.ok) {
197
- const errorBody = await response.json().catch(() => response.text());
198
- const errorMessage = errorBody?.error?.message || JSON.stringify(errorBody);
199
- console.error(`Google TTS API Error: ${response.status} - ${errorMessage}`);
200
- throw new Error(`Google TTS API request failed with status ${response.status}: ${errorMessage}`);
201
- }
202
-
203
- const data = await response.json();
204
- const audioData = data.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
205
-
206
- if (!audioData) {
207
- console.error("Invalid TTS response from Google AI:", JSON.stringify(data));
208
- throw new Error("No audio data received from Google AI TTS service.");
209
- }
210
-
211
- return audioData;
212
- }
213
  private getDocumentType(url: string): string {
214
  const lowerUrl = url.toLowerCase();
215
  if (lowerUrl.startsWith('data:application/pdf') || lowerUrl.includes('.pdf')) return 'pdf';
@@ -219,6 +107,10 @@ class GoogleAIService {
219
  if (lowerUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document') || lowerUrl.includes('.docx')) return 'docx';
220
  return 'unknown';
221
  }
 
 
 
 
222
  private extractDocumentData(documentUrl: string): { mimeType: string; data: string; text?: string; docType: string } {
223
  const docType = this.getDocumentType(documentUrl);
224
 
@@ -226,6 +118,8 @@ class GoogleAIService {
226
  if (documentUrl.startsWith("http")) {
227
  throw new Error("Document URL downloads are not supported. Please provide base64 encoded data URLs.");
228
  }
 
 
229
  throw new Error("Document must be provided as a standard base64 data URL (e.g., 'data:application/pdf;base64,...').");
230
  }
231
 
@@ -235,6 +129,8 @@ class GoogleAIService {
235
  }
236
  const [mimeInfo, base64Data] = parts;
237
 
 
 
238
  const approxSizeInBytes = base64Data.length * 0.75;
239
  if (approxSizeInBytes > MAX_DOCUMENT_SIZE_BYTES) {
240
  throw new Error(`Document size (${(approxSizeInBytes / 1024 / 1024).toFixed(2)}MB) exceeds the ${MAX_DOCUMENT_SIZE_MB}MB limit.`);
@@ -252,9 +148,11 @@ class GoogleAIService {
252
  }
253
  }
254
 
 
255
  const finalMimeType = docType === 'pdf' ? 'application/pdf' : mimeType;
256
  return { mimeType: finalMimeType, data: base64Data, docType };
257
  }
 
258
  private extractImageData(imageUrl: string): { mimeType: string; data: string } {
259
  if (imageUrl.startsWith("data:image/")) {
260
  const [mimeInfo, base64Data] = imageUrl.split(",");
@@ -266,6 +164,7 @@ class GoogleAIService {
266
  return { mimeType: "image/jpeg", data: imageUrl };
267
  }
268
  }
 
269
  async generateContentWithDocument(messages: OpenAIMessage[], modelName: string): Promise<string> {
270
  const apiKey = this.getNextApiKey();
271
  const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
@@ -273,57 +172,260 @@ class GoogleAIService {
273
 
274
  console.log(`Processing document with model: ${documentModel}`);
275
 
276
- const contents = this._buildContents(messages.slice(0, -1));
277
- const lastMessage = messages[messages.length - 1];
278
-
279
- // Complex last message handling
280
- if (typeof lastMessage.content === "string") {
281
- contents.push({ role: "user", parts: [{text: lastMessage.content}] });
282
- } else {
283
- const docParts = [];
284
- const textParts = [];
285
- for (const part of lastMessage.content) {
286
- if (part.type === "document" && part.document) {
287
- const docData = this.extractDocumentData(part.document.url);
288
- docParts.push({ inlineData: { mimeType: docData.mimeType, data: docData.data } });
289
- } else if (part.type === "text") {
290
- textParts.push({text: part.text});
291
- }
292
  }
293
- contents.push({ role: "user", parts: [...docParts, ...textParts] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  }
295
 
296
- const requestBody = { contents, generationConfig: { temperature: 0.7, maxOutputTokens: 8192 } };
297
- const response = await fetch( `https://generativelanguage.googleapis.com/v1beta/${documentModel}:generateContent?key=${apiKey}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) } );
 
 
 
 
 
 
 
 
 
 
 
298
 
299
  if (!response.ok) {
300
- const errorBody = await response.json().catch(() => response.text());
301
- const errorMessage = errorBody?.error?.message || JSON.stringify(errorBody);
302
- throw new Error(`Google API request failed: ${errorMessage}`);
 
303
  }
 
304
  const data = await response.json();
305
- return data.candidates?.[0]?.content?.parts?.[0]?.text || "No response text.";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  }
307
- async generateContent(messages: OpenAIMessage[], modelName: string): Promise<string> {
 
 
 
 
 
 
 
308
  const apiKey = this.getNextApiKey();
309
  const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
310
- const contents = this._buildContents(messages);
311
- const requestBody = { contents, generationConfig: { temperature: 0.7, maxOutputTokens: 8192 } };
312
 
313
- const response = await fetch( `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) } );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
- if (!response.ok) { throw new Error(`Google AI API error: ${await response.text()}`); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
  const data = await response.json();
318
- if (data.promptFeedback?.blockReason) {
319
- throw new Error(`Request blocked by Google: ${data.promptFeedback.blockReason}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  }
321
- const candidate = data.candidates?.[0];
322
- if (!candidate) { throw new Error("No response generated from Google AI."); }
323
- if (candidate.finishReason !== "STOP") {
324
- console.warn(`Stream finished with reason: ${candidate.finishReason}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  }
326
- return candidate.content?.parts?.[0]?.text || "";
327
  }
328
  }
329
 
@@ -336,62 +438,11 @@ class OpenAICompatibleServer {
336
  this.authKey = Deno.env.get("AUTH_KEY") || "";
337
  }
338
 
339
- private _writeString(view: DataView, offset: number, str: string) {
340
- for (let i = 0; i < str.length; i++) {
341
- view.setUint8(offset + i, str.charCodeAt(i));
342
- }
343
- }
344
-
345
- private _createWavFile(pcmData: Uint8Array): Uint8Array {
346
- const numChannels = 1, sampleRate = 24000, bitsPerSample = 16, dataSize = pcmData.length;
347
- const headerSize = 44, buffer = new ArrayBuffer(headerSize + dataSize), view = new DataView(buffer);
348
-
349
- this._writeString(view, 0, "RIFF");
350
- view.setUint32(4, 36 + dataSize, true);
351
- this._writeString(view, 8, "WAVE");
352
- this._writeString(view, 12, "fmt ");
353
- view.setUint32(16, 16, true);
354
- view.setUint16(20, 1, true);
355
- view.setUint16(22, numChannels, true);
356
- view.setUint32(24, sampleRate, true);
357
- view.setUint32(28, sampleRate * numChannels * (bitsPerSample / 8), true);
358
- view.setUint16(32, numChannels * (bitsPerSample / 8), true);
359
- view.setUint16(34, bitsPerSample, true);
360
- this._writeString(view, 36, "data");
361
- view.setUint32(40, dataSize, true);
362
-
363
- const wavBytes = new Uint8Array(buffer);
364
- wavBytes.set(pcmData, headerSize);
365
- return wavBytes;
366
- }
367
-
368
  private authenticate(request: Request): boolean {
369
  if (!this.authKey) return true;
370
  const authHeader = request.headers.get("Authorization");
371
  return authHeader ? authHeader.replace("Bearer ", "") === this.authKey : false;
372
  }
373
-
374
- private async handleAudioSpeech(request: Request): Promise<Response> {
375
- try {
376
- const body: OpenAITTSRequest = await request.json();
377
- const modelMap: { [key: string]: string } = { 'tts-1': 'gemini-2.5-flash-preview-tts', 'tts-1-hd': 'gemini-2.5-flash-preview-tts' };
378
- const geminiModel = modelMap[body.model] || (this.googleAI.isTTSModel(body.model) ? body.model : 'gemini-2.5-flash-preview-tts');
379
- const voiceMap: { [key: string]: string } = { 'alloy': 'Krew', 'echo': 'Kore', 'fable': 'Chiron', 'onyx': 'Calypso', 'nova': 'Cria', 'shimmer': 'Estrella' };
380
- const geminiVoice = voiceMap[body.voice] || 'Kore';
381
-
382
- if (!body.input) throw new Error("The 'input' field is required for TTS requests.");
383
-
384
- const audioBase64 = await this.googleAI.generateSpeech(body.input, geminiModel, geminiVoice);
385
- const pcmBytes = decode(audioBase64);
386
- const wavBytes = this._createWavFile(pcmBytes);
387
-
388
- return new Response(wavBytes, { headers: { "Content-Type": "audio/wav" } });
389
- } catch (error) {
390
- console.error("Error in audio speech generation:", error.message);
391
- const status = error.message.includes("required") ? 400 : 500;
392
- return new Response(JSON.stringify({ error: { message: error.message, type: status === 400 ? "invalid_request_error" : "api_error", code: "tts_failed" } }), { status, headers: { "Content-Type": "application/json" } });
393
- }
394
- }
395
 
396
  private isDocumentContent(url?: string): boolean {
397
  if (!url) return false;
@@ -405,87 +456,55 @@ class OpenAICompatibleServer {
405
  try {
406
  const body: OpenAIRequest = await request.json();
407
  const requestedModel = body.model || "gemini-1.5-pro";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
 
409
- if (body.stream) {
410
- // --- [增强版] 真·流式处理 ---
411
- const geminiStream = await this.googleAI.generateContentStream(body.messages, requestedModel);
412
-
413
- const streamId = `chatcmpl-${Date.now()}`;
414
- const creationTime = Math.floor(Date.now() / 1000);
415
-
416
- let buffer = "";
417
- const textDecoder = new TextDecoder();
418
-
419
- const transformStream = new TransformStream({
420
- transform(chunk, controller) {
421
- buffer += textDecoder.decode(chunk);
422
- const lines = buffer.split('\n');
423
- buffer = lines.pop() || ""; // 保留不完整的行到下一次处理
424
-
425
- for (const line of lines) {
426
- if (!line.startsWith('data: ')) continue;
427
- const jsonData = line.substring(6);
428
- if (!jsonData) continue;
429
-
430
- try {
431
- const geminiData = JSON.parse(jsonData);
432
-
433
- // --- 核心逻辑:检查内容和终止原因 ---
434
- const candidate = geminiData.candidates?.[0];
435
- if (!candidate) continue;
436
-
437
- // 1. 提取文本内容
438
- const text = candidate.content?.parts?.[0]?.text;
439
- if (text) {
440
- const openAIChunk = {
441
- id: streamId, object: 'chat.completion.chunk', created: creationTime, model: requestedModel,
442
- choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
443
- };
444
- controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(openAIChunk)}\n\n`));
445
- }
446
-
447
- // 2. 检查终止原因
448
- const finishReason = candidate.finishReason;
449
- if (finishReason && finishReason !== "STOP") {
450
- const reasonText = `\n\n[STREAM INTERRUPTED BY GOOGLE: ${finishReason}]`;
451
- const openAIChunk = {
452
- id: streamId, object: 'chat.completion.chunk', created: creationTime, model: requestedModel,
453
- choices: [{ index: 0, delta: { content: reasonText }, finish_reason: finishReason.toLowerCase() }],
454
- };
455
- controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(openAIChunk)}\n\n`));
456
- }
457
-
458
- } catch (e) {
459
- console.warn("Could not parse a chunk from Gemini stream:", e);
460
- }
461
- }
462
- },
463
- flush(controller) {
464
- // 流结束时,发送最终的 [DONE] 标志
465
- const finalChunk = {
466
- id: streamId, object: 'chat.completion.chunk', created: creationTime, model: requestedModel,
467
- choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
468
- };
469
- controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
470
- controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
471
  }
472
  });
473
-
474
- return new Response(geminiStream.pipeThrough(transformStream), {
475
- headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" }
476
- });
477
-
 
 
 
 
 
 
 
 
 
 
478
  } else {
479
- // --- 非流式处理 ---
480
- const hasDocument = body.messages.some(msg => Array.isArray(msg.content) && msg.content.some(part => part.type === "document" || this.isDocumentContent(part.document?.url)));
481
- let responseText;
482
-
483
- if (hasDocument) {
484
- responseText = await this.googleAI.generateContentWithDocument(body.messages, requestedModel);
485
- } else {
486
- responseText = await this.googleAI.generateContent(body.messages, requestedModel);
487
- }
488
 
 
 
 
 
 
 
489
  const responsePayload = {
490
  id: `chatcmpl-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: requestedModel,
491
  choices: [{ index: 0, message: { role: "assistant", content: responseText }, finish_reason: "stop" }],
@@ -494,27 +513,60 @@ class OpenAICompatibleServer {
494
  return new Response(JSON.stringify(responsePayload), { headers: { "Content-Type": "application/json" } });
495
  }
496
  } catch (error) {
497
- console.error("Error in chat completions:", error.message, error.stack);
498
  const status = error.message.includes("exceeds the limit") || error.message.includes("Invalid") ? 400 : 500;
499
- return new Response(JSON.stringify({ error: { message: error.message, type: status === 400 ? "invalid_request_error" : "api_error", code: null } }), { status, headers: { "Content-Type": "application/json" } });
 
 
 
 
 
 
 
 
 
500
  }
501
  }
502
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  private async handleModels(): Promise<Response> {
504
  try {
505
  const googleModels = await this.googleAI.fetchOfficialModels();
506
- const openAIFormattedModels = googleModels.map(model => {
507
- const modelId = model.name.replace('models/', '');
508
- return { id: modelId, object: "model", created: Math.floor(Date.now() / 1000), owned_by: "google", description: model.description || model.displayName, maxTokens: model.inputTokenLimit || model.maxTokens };
509
- });
510
-
511
- if (openAIFormattedModels.some(m => this.googleAI.isTTSModel(m.id))) {
512
- if (!openAIFormattedModels.some(m => m.id === 'tts-1')) {
513
- openAIFormattedModels.push({ id: 'tts-1', object: "model", created: Math.floor(Date.now() / 1000), owned_by: "google", description: "Text-to-speech model, mapped to gemini-2.5-flash-preview-tts", maxTokens: 4096 });
514
- }
515
- }
516
-
517
- const models = { object: "list", data: openAIFormattedModels };
518
  return new Response(JSON.stringify(models), { headers: { "Content-Type": "application/json" } });
519
  } catch (error) {
520
  console.error("Error fetching models:", error);
@@ -523,23 +575,34 @@ class OpenAICompatibleServer {
523
  }
524
 
525
  private async handleStatus(): Promise<Response> {
526
- const status = { status: "healthy", timestamp: new Date().toISOString(), version: "2.5.0", api_keys_loaded: this.googleAI.apiKeys.length, models_in_cache: this.googleAI.cachedModels.length, models_last_fetched: this.googleAI.modelsLastFetch > 0 ? new Date(this.googleAI.modelsLastFetch).toISOString() : "never" };
 
 
 
 
 
527
  return new Response(JSON.stringify(status), { headers: { "Content-Type": "application/json" } });
528
  }
529
 
530
  async handleRequest(request: Request): Promise<Response> {
531
- const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization" };
532
- if (request.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
 
 
 
 
 
 
 
533
 
534
  const url = new URL(request.url);
535
  let response: Response;
536
 
 
537
  if (url.pathname === "/health" || url.pathname === "/status") {
538
  response = await this.handleStatus();
539
  } else if (!this.authenticate(request)) {
540
  response = new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { status: 401 });
541
- } else if (url.pathname === "/v1/audio/speech" && request.method === "POST") {
542
- response = await this.handleAudioSpeech(request);
543
  } else if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
544
  response = await this.handleChatCompletions(request);
545
  } else if (url.pathname === "/v1/models" && request.method === "GET") {
@@ -548,18 +611,24 @@ class OpenAICompatibleServer {
548
  response = new Response("Not Found", { status: 404 });
549
  }
550
 
 
551
  const finalHeaders = new Headers(response.headers);
552
- Object.entries(corsHeaders).forEach(([key, value]) => finalHeaders.set(key, value));
 
 
 
553
  return new Response(response.body, { status: response.status, headers: finalHeaders });
554
  }
555
  }
556
 
557
  // --- 服务器启动 ---
558
  const server = new OpenAICompatibleServer();
559
- console.log("🚀 OpenAI Compatible Server with Google AI starting on port 7860...");
 
560
  console.log(`✅ Loaded ${server.googleAI.apiKeys.length} API key(s).`);
561
  console.log(`📄 Max document size set to ${MAX_DOCUMENT_SIZE_MB}MB.`);
562
 
 
563
  server.googleAI.fetchOfficialModels().then(models => {
564
  console.log(`✅ Successfully fetched ${models.length} models from Google AI.`);
565
  }).catch(error => {
@@ -568,8 +637,10 @@ server.googleAI.fetchOfficialModels().then(models => {
568
 
569
  console.log("\n🔗 Endpoints:");
570
  console.log(" POST /v1/chat/completions");
571
- console.log(" POST /v1/audio/speech");
572
  console.log(" GET /v1/models");
573
  console.log(" GET /status");
574
 
575
- await serve((request: Request) => server.handleRequest(request), { port: 7860 });
 
 
 
 
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
 
 
23
  stream?: boolean;
24
  }
25
 
 
 
 
 
 
 
26
  class GoogleAIService {
27
  public apiKeys: string[];
28
  public currentKeyIndex = 0;
 
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) {
 
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';
 
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
 
 
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
 
 
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.`);
 
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(",");
 
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}`;
 
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
 
 
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;
 
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" }],
 
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);
 
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") {
 
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 => {
 
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: 7860 }
646
+ );