xt8 commited on
Commit
80a6dd4
·
verified ·
1 Parent(s): a1f8f9c

Update main.ts

Browse files
Files changed (1) hide show
  1. main.ts +208 -508
main.ts CHANGED
@@ -24,13 +24,12 @@ interface OpenAIRequest {
24
  stream?: boolean;
25
  }
26
 
27
- // [新增] OpenAI TTS 请求接口定义
28
  interface OpenAITTSRequest {
29
- model: string; // e.g., 'tts-1', 'tts-1-hd'
30
- input: string; // The text to synthesize
31
  voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
32
- response_format?: 'mp3' | 'opus' | 'aac' | 'flac'; // Google TTS returns MP3, so we'll ignore others for now
33
- speed?: number; // Not directly supported by Gemini TTS, will be ignored
34
  }
35
 
36
 
@@ -63,66 +62,38 @@ class GoogleAIService {
63
  }
64
 
65
  // --- [新增] TTS 功能 ---
66
-
67
- /**
68
- * 映射 OpenAI 的语音名称到 Google Gemini TTS 的预置语音名称。
69
- * 参考: https://ai.google.dev/gemini-api/docs/text-to-speech#supported_voices
70
- */
71
  private getGoogleVoice(openAIVoice: string): string {
72
  const voiceMap: { [key: string]: string } = {
73
- 'alloy': 'Kore', // A good default, versatile voice
74
- 'echo': 'Sal', // Another male voice option
75
- 'fable': 'Polly', // Female, narrative style
76
- 'onyx': 'Onyx', // Deep, male voice
77
- 'nova': 'Sparkle', // Energetic female voice
78
- 'shimmer': 'Luna', // Gentle female voice
79
- // Fallback to a default if the voice is not in the map
80
  'default': 'Kore'
81
  };
82
  return voiceMap[openAIVoice] || voiceMap['default'];
83
  }
84
 
85
- /**
86
- * [新增] 调用 Google Gemini TTS API 生成语音。
87
- * @param input - 要转换为语音的文本。
88
- * @param model - 请求的模型(在Google端,我们硬编码为TTS模型)。
89
- * @param voice - OpenAI 格式的语音名称。
90
- * @returns 返回包含音频数据的 ArrayBuffer。
91
- */
92
  async generateSpeech(input: string, model: string, voice: string): Promise<ArrayBuffer> {
93
  const apiKey = this.getNextApiKey();
94
  const googleVoice = this.getGoogleVoice(voice);
95
- // 根据 curl 命令,模型是固定的 TTS 模型
96
  const ttsModel = "gemini-2.5-flash-preview-tts";
97
 
98
  console.log(`Generating speech with model: ${ttsModel}, voice: ${googleVoice} (mapped from OpenAI's '${voice}')`);
99
 
100
  const requestBody = {
101
- "contents": [{
102
- "parts":[{
103
- "text": input
104
- }]
105
- }],
106
  "generationConfig": {
107
  "responseModalities": ["AUDIO"],
108
- "speechConfig": {
109
- "voiceConfig": {
110
- "prebuiltVoiceConfig": {
111
- "voiceName": googleVoice
112
- }
113
- }
114
- }
115
  },
116
  "model": ttsModel,
117
  };
118
 
119
  const response = await fetch(
120
  `https://generativelanguage.googleapis.com/v1beta/models/${ttsModel}:generateContent?key=${apiKey}`,
121
- {
122
- method: "POST",
123
- headers: { "Content-Type": "application/json" },
124
- body: JSON.stringify(requestBody),
125
- }
126
  );
127
 
128
  if (!response.ok) {
@@ -133,14 +104,11 @@ class GoogleAIService {
133
  }
134
 
135
  const data = await response.json();
136
-
137
- // 提取 base64 编码的音频数据
138
  const audioContentBase64 = data.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
139
  if (!audioContentBase64) {
140
  throw new Error("No audio data returned from Google API. The response might be blocked or empty.");
141
  }
142
 
143
- // 将 base64 字符串解码为二进制数据 (ArrayBuffer)
144
  const binaryString = atob(audioContentBase64);
145
  const len = binaryString.length;
146
  const bytes = new Uint8Array(len);
@@ -150,9 +118,9 @@ class GoogleAIService {
150
  return bytes.buffer;
151
  }
152
 
153
- // --- 现有代码保持不变 ---
154
 
155
- async fetchOfficialModels(): Promise<any[]> {
156
  const now = Date.now();
157
  if (this.cachedModels.length > 0 && (now - this.modelsLastFetch) < MODELS_CACHE_DURATION) {
158
  return this.cachedModels;
@@ -186,12 +154,11 @@ class GoogleAIService {
186
  }
187
  }
188
 
189
- private getFallbackModels(): any[] {
190
  return [
191
  { 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 },
192
  { 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 },
193
  { 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"] },
194
- // [新增] 在模型列表中添加TTS模型,使其在 /v1/models 接口可见
195
  { name: "models/gemini-2.5-flash-preview-tts", displayName: "Gemini 2.5 Flash TTS", description: "Text-to-speech model for generating high-quality audio.", supportedGenerationMethods: ["generateContent"], id: "gemini-2.5-flash-preview-tts" }
196
  ];
197
  }
@@ -201,115 +168,71 @@ class GoogleAIService {
201
  public isImageEditingModel = (modelName: string): boolean => modelName.includes('image-generation') || modelName === 'gemini-2.0-flash-preview-image-generation';
202
  public isDocumentModel = (modelName: string): boolean => modelName.toLowerCase().includes('gemini-1.5') || modelName.toLowerCase().includes('pro') || modelName.toLowerCase().includes('flash');
203
 
204
- private getDocumentType(url: string): string {
205
- const lowerUrl = url.toLowerCase();
206
- if (lowerUrl.startsWith('data:application/pdf') || lowerUrl.includes('.pdf')) return 'pdf';
207
- if (lowerUrl.startsWith('data:text/plain') || lowerUrl.includes('.txt')) return 'txt';
208
- if (lowerUrl.startsWith('data:text/markdown') || lowerUrl.includes('.md')) return 'md';
209
- if (lowerUrl.startsWith('data:application/msword') || lowerUrl.includes('.doc')) return 'doc';
210
- if (lowerUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document') || lowerUrl.includes('.docx')) return 'docx';
211
- return 'unknown';
212
- }
213
-
214
- private extractDocumentData(documentUrl: string): { mimeType: string; data: string; text?: string; docType: string } {
215
- const docType = this.getDocumentType(documentUrl);
216
 
217
- if (!documentUrl.startsWith("data:")) {
218
- if (documentUrl.startsWith("http")) {
219
- throw new Error("Document URL downloads are not supported. Please provide base64 encoded data URLs.");
220
- }
221
- throw new Error("Document must be provided as a standard base64 data URL (e.g., 'data:application/pdf;base64,...').");
222
- }
223
-
224
- const parts = documentUrl.split(",");
225
- if (parts.length !== 2) {
226
- throw new Error("Invalid data URL format for document. Expected 'data:[mime];base64,[data]'.");
227
- }
228
- const [mimeInfo, base64Data] = parts;
229
 
230
- const approxSizeInBytes = base64Data.length * 0.75;
231
- if (approxSizeInBytes > MAX_DOCUMENT_SIZE_BYTES) {
232
- throw new Error(`Document size (${(approxSizeInBytes / 1024 / 1024).toFixed(2)}MB) exceeds the ${MAX_DOCUMENT_SIZE_MB}MB limit.`);
233
- }
234
-
235
- const mimeType = mimeInfo.split(":")[1]?.split(";")[0] || 'application/octet-stream';
236
-
237
- if (docType === 'txt' || docType === 'md') {
238
- try {
239
- const textContent = atob(base64Data);
240
- return { mimeType, data: base64Data, text: textContent, docType };
241
- } catch (error) {
242
- console.error(`Failed to decode base64 content for ${docType}:`, error);
243
- throw new Error(`Invalid base64 encoding for ${docType} document.`);
244
- }
245
- }
246
-
247
- const finalMimeType = docType === 'pdf' ? 'application/pdf' : mimeType;
248
- return { mimeType: finalMimeType, data: base64Data, docType };
249
- }
250
-
251
- private extractImageData(imageUrl: string): { mimeType: string; data: string } {
252
- if (imageUrl.startsWith("data:image/")) {
253
- const [mimeInfo, base64Data] = imageUrl.split(",");
254
- const mimeType = mimeInfo.split(":")[1].split(";")[0];
255
- return { mimeType, data: base64Data };
256
- } else if (imageUrl.startsWith("http")) {
257
- throw new Error("URL images are not supported yet. Please provide base64 encoded images.");
258
- } else {
259
- return { mimeType: "image/jpeg", data: imageUrl };
260
- }
261
  }
262
 
263
- async generateContentWithDocument(messages: OpenAIMessage[], modelName: string): Promise<string> {
 
 
 
 
 
 
 
 
 
264
  const apiKey = this.getNextApiKey();
265
  const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
266
- const documentModel = this.isDocumentModel(fullModelName) ? fullModelName : 'models/gemini-1.5-pro-latest';
267
-
268
- console.log(`Processing document with model: ${documentModel}`);
269
 
270
- let contents;
271
- try {
272
- contents = messages.map(msg => {
273
  if (typeof msg.content === "string") {
274
  return { role: msg.role === "assistant" ? "model" : "user", parts: [{ text: msg.content }] };
275
- }
276
-
277
- const messageParts = msg.content.map(part => {
278
- if (part.type === "text") return { text: part.text };
279
-
280
- if (part.type === "image_url" && part.image_url) {
281
- const { mimeType, data } = this.extractImageData(part.image_url.url);
282
- return { inlineData: { mimeType, data } };
283
- }
284
-
285
- if (part.type === "document" && part.document) {
286
- const docData = this.extractDocumentData(part.document.url);
287
- console.log(`Processing document: ${docData.docType}, mime: ${docData.mimeType}, size: ${(docData.data.length * 0.75 / 1024).toFixed(2)} KB`);
288
-
289
- if (docData.docType === 'txt' || docData.docType === 'md') {
290
- const prefix = docData.docType === 'md' ? 'Markdown document content:\n' : 'Text document content:\n';
291
- return { text: `${prefix}${docData.text}` };
292
- }
293
- if (docData.docType === 'pdf') {
294
- return { inlineData: { mimeType: docData.mimeType, data: docData.data } };
295
  }
296
- return { text: `[Document type '${docData.docType}' is not supported for direct processing. Please convert to PDF, TXT, or MD.]` };
297
- }
298
- return { text: "" };
299
- });
300
- return { role: msg.role === "assistant" ? "model" : "user", parts: messageParts.filter(p => p.text || p.inlineData) };
301
- });
302
- } catch (error) {
303
- throw error;
304
- }
305
 
306
  const requestBody = {
307
  contents,
308
  generationConfig: { temperature: 0.7, maxOutputTokens: 8192 }
309
  };
310
-
 
311
  const response = await fetch(
312
- `https://generativelanguage.googleapis.com/v1beta/${documentModel}:generateContent?key=${apiKey}`,
313
  {
314
  method: "POST",
315
  headers: { "Content-Type": "application/json" },
@@ -317,212 +240,58 @@ class GoogleAIService {
317
  }
318
  );
319
 
320
- if (!response.ok) {
321
- const errorBody = await response.json().catch(() => response.text());
322
- const errorMessage = errorBody?.error?.message || JSON.stringify(errorBody);
323
- console.error(`Google API Error: ${response.status} - ${errorMessage}`);
324
- throw new Error(`Google API request failed with status ${response.status}: ${errorMessage}`);
325
- }
326
-
327
- const data = await response.json();
328
- const promptFeedback = data.promptFeedback;
329
- if (promptFeedback && promptFeedback.blockReason) {
330
- const reason = promptFeedback.blockReason;
331
- const safetyRatings = promptFeedback.safetyRatings?.map((r: any) => `${r.category}: ${r.probability}`).join(', ') || 'N/A';
332
- throw new Error(`Request blocked by Google API. Reason: ${reason}. Safety Ratings: [${safetyRatings}]`);
333
- }
334
-
335
- if (!data.candidates || data.candidates.length === 0) {
336
- throw new Error("No response generated for document content. The content might be empty or unreadable.");
337
- }
338
-
339
- const candidate = data.candidates[0];
340
- if (candidate.finishReason === "SAFETY") {
341
- throw new Error("Response blocked due to safety filters. Check content for sensitive topics.");
342
- }
343
- if (candidate.finishReason === "RECITATION") {
344
- throw new Error("Response blocked due to recitation policy. The model's output was too similar to a copyrighted source.");
345
- }
346
-
347
- return candidate.content?.parts[0]?.text || "Document processed, but no text response was generated.";
348
- }
349
-
350
- async generateContent(messages: OpenAIMessage[], modelName: string, enableSearch: boolean = false): Promise<string> {
351
- const hasDocument = messages.some(msg => Array.isArray(msg.content) && msg.content.some(part => part.type === "document"));
352
- if (hasDocument) {
353
- return await this.generateContentWithDocument(messages, modelName);
354
  }
355
 
356
- const apiKey = this.getNextApiKey();
357
- const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
 
 
358
 
359
- const contents = messages.map(msg => {
360
- if (typeof msg.content === "string") {
361
- return { role: msg.role === "assistant" ? "model" : "user", parts: [{ text: msg.content }] };
362
- } else {
363
- const messageParts = msg.content.map(part => {
364
- if (part.type === "text") {
365
- return { text: part.text };
366
- } else if (part.type === "image_url" && part.image_url) {
367
- const imageData = part.image_url.url;
368
- if (imageData.startsWith("data:image/")) {
369
- const { mimeType, data } = this.extractImageData(imageData);
370
- return { inlineData: { mimeType, data } };
371
- } else {
372
- return { fileData: { mimeType: "image/jpeg", fileUri: imageData } };
373
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  }
375
- return { text: "" };
376
- });
377
- return { role: msg.role === "assistant" ? "model" : "user", parts: messageParts };
378
  }
379
- });
380
-
381
- const requestBody: any = {
382
- contents,
383
- generationConfig: { temperature: 0.7, maxOutputTokens: 4096 }
384
- };
385
- if (enableSearch) {
386
- requestBody.tools = [{ googleSearchRetrieval: {} }];
387
- }
388
-
389
- const response = await fetch(
390
- `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`,
391
- { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }
392
- );
393
-
394
- if (!response.ok) {
395
- const errorText = await response.text();
396
- throw new Error(`Google AI API error: ${response.status} - ${errorText}`);
397
- }
398
- const data = await response.json();
399
- if (!data.candidates || data.candidates.length === 0) {
400
- throw new Error("No response generated from Google AI");
401
- }
402
- const candidate = data.candidates[0];
403
- if (candidate.finishReason === "SAFETY") {
404
- throw new Error("Response blocked due to safety filters");
405
  }
406
- return candidate.content?.parts[0]?.text || "No response generated";
407
  }
408
 
409
- 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 }> {
410
- const apiKey = this.getNextApiKey();
411
- const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
412
- const requestParts: any[] = [{ text: prompt }];
413
-
414
- if (inputImage) {
415
- requestParts.push({ inline_data: { mime_type: inputImage.mimeType, data: inputImage.data } });
416
- console.log(`Editing image with model: ${fullModelName}`);
417
- } else {
418
- console.log(`Generating image with model: ${fullModelName}`);
419
- }
420
-
421
- const requestBody = {
422
- contents: [{ parts: requestParts }],
423
- generationConfig: { responseModalities: ["TEXT", "IMAGE"], temperature: 0.7 }
424
- };
425
-
426
- const response = await fetch(
427
- `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`,
428
- { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }
429
- );
430
-
431
- if (!response.ok) {
432
- const errorText = await response.text();
433
- throw new Error(`Image ${inputImage ? 'editing' : 'generation'} failed: ${response.status} - ${errorText}`);
434
- }
435
- const data = await response.json();
436
- if (!data.candidates || data.candidates.length === 0) {
437
- throw new Error(`No ${inputImage ? 'edited' : 'generated'} image returned`);
438
- }
439
-
440
- const candidate = data.candidates[0];
441
- if (candidate.finishReason === "SAFETY") {
442
- throw new Error(`Image ${inputImage ? 'editing' : 'generation'} blocked due to safety filters`);
443
- }
444
-
445
- const responseParts = candidate.content?.parts || [];
446
- let textResponse = "";
447
- let imageBase64 = "";
448
-
449
- for (const part of responseParts) {
450
- if (part.text) textResponse += part.text;
451
- if (part.inlineData?.data) imageBase64 = part.inlineData.data;
452
- if (part.inline_data?.data) imageBase64 = part.inline_data.data;
453
- }
454
-
455
- const result: { text?: string; imageBase64?: string; imageUrl?: string } = {};
456
- if (textResponse) result.text = textResponse;
457
- if (imageBase64) {
458
- result.imageBase64 = imageBase64;
459
- result.imageUrl = `data:image/png;base64,${imageBase64}`;
460
- }
461
- return result;
462
- }
463
-
464
- async generateContentWithGrounding(messages: OpenAIMessage[], modelName: string): Promise<string> {
465
- const apiKey = this.getNextApiKey();
466
- const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
467
- const contents = messages.map(msg => ({ role: msg.role === 'assistant' ? 'model' : 'user', parts: [{ text: typeof msg.content === 'string' ? msg.content : '' }] }));
468
-
469
- const requestBody = {
470
- contents,
471
- tools: [{ googleSearch: {} }],
472
- generationConfig: { temperature: 0.7, maxOutputTokens: 4096 }
473
- };
474
-
475
- const response = await fetch(
476
- `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`,
477
- { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }
478
- );
479
-
480
- if (!response.ok) {
481
- console.warn(`Google Search API failed: ${response.status}, trying alternative.`);
482
- return await this.generateContentWithSearchPrompt(messages, modelName);
483
- }
484
-
485
- const data = await response.json();
486
- if (!data.candidates || data.candidates.length === 0) {
487
- return await this.generateContentWithSearchPrompt(messages, modelName);
488
- }
489
-
490
- const candidate = data.candidates[0];
491
- if (candidate.finishReason === "SAFETY") {
492
- throw new Error("Response blocked due to safety filters");
493
- }
494
- return candidate.content?.parts[0]?.text || "No response generated";
495
- }
496
-
497
- async generateContentWithSearchPrompt(messages: OpenAIMessage[], modelName: string): Promise<string> {
498
- const enhancedMessages = [...messages];
499
- const lastMessage = enhancedMessages[enhancedMessages.length - 1];
500
- if (typeof lastMessage.content === "string") {
501
- lastMessage.content = `Please provide the most current and accurate information available about: ${lastMessage.content}.`;
502
- }
503
- return await this.generateContent(enhancedMessages, modelName, false);
504
- }
505
-
506
- async generateOrEditImage(prompt: string, modelName: string, inputImages?: any[]): Promise<string> {
507
- if (this.isImageGenerationModel(modelName)) {
508
- try {
509
- let inputImage: { mimeType: string; data: string } | undefined;
510
- if (inputImages && inputImages.length > 0) {
511
- inputImage = this.extractImageData(inputImages[0].url);
512
- }
513
- const result = await this.generateOrEditImageWithGemini(prompt, modelName, inputImage);
514
- let response = "";
515
- if (result.text) response += result.text + "\\\\n\\\\n";
516
- if (result.imageUrl) response += `${inputImage ? 'Edited' : 'Generated'} image:\\\\n${result.imageUrl}`;
517
- return response || `Image processing complete.`;
518
- } catch (error) {
519
- return `Image processing failed: ${error.message}`;
520
- }
521
- }
522
- return `Model ${modelName} does not support image generation. Use a model like gemini-2.0-flash-preview-image-generation.`;
523
- }
524
  }
525
 
 
526
  class OpenAICompatibleServer {
527
  private googleAI: GoogleAIService;
528
  private authKey: string;
@@ -532,55 +301,10 @@ class OpenAICompatibleServer {
532
  this.authKey = Deno.env.get("AUTH_KEY") || "";
533
  }
534
 
535
- private authenticate(request: Request): boolean {
536
- if (!this.authKey) return true;
537
- const authHeader = request.headers.get("Authorization");
538
- return authHeader ? authHeader.replace("Bearer ", "") === this.authKey : false;
539
- }
540
 
541
- private isDocumentContent(url?: string): boolean {
542
- if (!url) return false;
543
- const lowerUrl = url.toLowerCase();
544
- return lowerUrl.includes('.pdf') || lowerUrl.startsWith('data:application/pdf') ||
545
- lowerUrl.includes('.txt') || lowerUrl.startsWith('data:text/plain') ||
546
- lowerUrl.includes('.md') || lowerUrl.startsWith('data:text/markdown');
547
- }
548
-
549
- /**
550
- * [新增] 处理 OpenAI 兼容的 TTS 请求
551
- */
552
- private async handleAudioSpeech(request: Request): Promise<Response> {
553
- try {
554
- const body: OpenAITTSRequest = await request.json();
555
-
556
- if (!body.input || !body.voice || !body.model) {
557
- return new Response(JSON.stringify({ error: { message: "Missing required fields: input, voice, and model.", type: "invalid_request_error" } }), { status: 400 });
558
- }
559
-
560
- const audioBuffer = await this.googleAI.generateSpeech(body.input, body.model, body.voice);
561
-
562
- // Google Gemini TTS API 生成的是 MP3 格式的音频。
563
- return new Response(audioBuffer, {
564
- headers: {
565
- "Content-Type": "audio/mpeg",
566
- "Access-Control-Allow-Origin": "*",
567
- }
568
- });
569
- } catch (error) {
570
- console.error("Error in audio speech generation:", error.message);
571
- return new Response(
572
- JSON.stringify({
573
- error: {
574
- message: error.message,
575
- type: "api_error",
576
- code: null
577
- }
578
- }),
579
- { status: 500, headers: { "Content-Type": "application/json" } }
580
- );
581
- }
582
- }
583
-
584
  private async handleChatCompletions(request: Request): Promise<Response> {
585
  try {
586
  const body: OpenAIRequest = await request.json();
@@ -588,142 +312,120 @@ class OpenAICompatibleServer {
588
  const stream = body.stream || false;
589
  console.log(`Request for model: ${requestedModel}, stream: ${stream}`);
590
 
591
- const lastMessage = body.messages[body.messages.length - 1];
592
- const content = typeof lastMessage.content === "string"
593
- ? lastMessage.content
594
- : (Array.isArray(lastMessage.content) ? lastMessage.content.map(p => p.text || "").join(" ") : "");
595
-
596
- const hasDocument = body.messages.some(msg =>
597
- Array.isArray(msg.content) &&
598
- msg.content.some(part => part.type === "document" || this.isDocumentContent(part.document?.url))
599
- );
600
-
601
- const hasImages = body.messages.some(msg => Array.isArray(msg.content) && msg.content.some(part => part.type === "image_url"));
602
-
603
- let inputImages: any[] = [];
604
- if (hasImages) {
605
- body.messages.forEach(msg => {
606
- if (Array.isArray(msg.content)) {
607
- msg.content.forEach(part => {
608
- if (part.type === "image_url" && part.image_url) inputImages.push({ url: part.image_url.url });
609
- });
610
- }
611
- });
612
- }
613
-
614
- let responseText: string;
615
-
616
- if (hasDocument) {
617
- responseText = await this.googleAI.generateContentWithDocument(body.messages, requestedModel);
618
- } else if (this.googleAI.isImageEditingModel(requestedModel) && hasImages) {
619
- responseText = await this.googleAI.generateOrEditImage(content, requestedModel, inputImages);
620
- } else if (this.googleAI.isImageGenerationModel(requestedModel)) {
621
- responseText = await this.googleAI.generateOrEditImage(content, requestedModel);
622
- } else if (content.toLowerCase().startsWith("/search:")) {
623
- const query = content.substring(8).trim();
624
- const searchMessages = [{ ...lastMessage, content: query }];
625
- responseText = await this.googleAI.generateContentWithGrounding(searchMessages, requestedModel);
626
- } else {
627
- responseText = await this.googleAI.generateContent(body.messages, requestedModel, false);
628
- }
629
-
630
  if (stream) {
631
- const streamResponse = await this.streamStringAsOpenAIResponse(responseText, requestedModel);
632
- return new Response(streamResponse, {
 
 
633
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*" }
634
  });
635
  } else {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  const responsePayload = {
637
- id: `chatcmpl-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: requestedModel,
638
- choices: [{ index: 0, message: { role: "assistant", content: responseText }, finish_reason: "stop" }],
639
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
640
  };
641
  return new Response(JSON.stringify(responsePayload), { headers: { "Content-Type": "application/json" } });
642
  }
643
  } catch (error) {
644
- console.error("Error in chat completions:", error.message);
645
- const status = error.message.includes("exceeds the limit") || error.message.includes("Invalid") ? 400 : 500;
646
- return new Response(
647
- JSON.stringify({
648
- error: {
649
- message: error.message,
650
- type: status === 400 ? "invalid_request_error" : "api_error",
651
- code: null
652
- }
653
- }),
654
- { status, headers: { "Content-Type": "application/json" } }
655
- );
656
  }
657
  }
658
 
659
- private async streamStringAsOpenAIResponse(content: string, modelName: string): Promise<ReadableStream<Uint8Array>> {
 
 
 
660
  const encoder = new TextEncoder();
661
  const streamId = `chatcmpl-${Date.now()}`;
662
  const creationTime = Math.floor(Date.now() / 1000);
663
- let contentQueue = content.split('');
664
 
665
  return new ReadableStream({
666
- start(controller) {
667
- const initialChunk = { id: streamId, object: 'chat.completion.chunk', created: creationTime, model: modelName, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] };
668
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialChunk)}\n\n`));
669
- },
670
- pull(controller) {
671
- if (contentQueue.length === 0) {
672
- const finalChunk = { id: streamId, object: 'chat.completion.chunk', created: creationTime, model: modelName, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
673
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
674
- controller.enqueue(encoder.encode('data: [DONE]\n\n'));
675
- controller.close();
676
- return;
 
 
 
 
 
 
 
 
 
677
  }
678
- const char = contentQueue.shift();
679
- const chunk = { id: streamId, object: 'chat.completion.chunk', created: creationTime, model: modelName, choices: [{ index: 0, delta: { content: char }, finish_reason: null }] };
680
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
681
  }
 
 
 
 
 
 
 
682
  });
683
  }
684
 
685
- private async handleModels(): Promise<Response> {
686
- try {
687
- const googleModels = await this.googleAI.fetchOfficialModels();
688
- const fallbackModels = this.googleAI['getFallbackModels'](); // Access private method for a complete list
689
-
690
- const allModels = [...googleModels, ...fallbackModels];
691
- const uniqueModelMap = new Map();
692
- allModels.forEach(model => {
693
- const modelId = model.id || model.name.replace('models/', '');
694
- if (!uniqueModelMap.has(modelId)) {
695
- uniqueModelMap.set(modelId, {
696
- id: modelId,
697
- object: "model",
698
- created: Math.floor(Date.now() / 1000),
699
- owned_by: "google",
700
- description: model.description || model.displayName,
701
- maxTokens: model.inputTokenLimit || model.maxTokens
702
- });
703
- }
704
- });
705
-
706
- const models = {
707
- object: "list",
708
- data: Array.from(uniqueModelMap.values()),
709
- };
710
-
711
- return new Response(JSON.stringify(models), { headers: { "Content-Type": "application/json" } });
712
- } catch (error) {
713
- console.error("Error fetching models:", error);
714
- return new Response(JSON.stringify({ error: { message: "Failed to fetch models." } }), { status: 500 });
715
- }
716
- }
717
-
718
- private async handleStatus(): Promise<Response> {
719
- const status = {
720
- status: "healthy", timestamp: new Date().toISOString(), version: "2.5.0",
721
- api_keys_loaded: this.googleAI.apiKeys.length,
722
- models_in_cache: this.googleAI.cachedModels.length,
723
- models_last_fetched: this.googleAI.modelsLastFetch > 0 ? new Date(this.googleAI.modelsLastFetch).toISOString() : "never"
724
- };
725
- return new Response(JSON.stringify(status), { headers: { "Content-Type": "application/json" } });
726
- }
727
 
728
  async handleRequest(request: Request): Promise<Response> {
729
  const corsHeaders = {
@@ -739,12 +441,11 @@ class OpenAICompatibleServer {
739
  const url = new URL(request.url);
740
  let response: Response;
741
 
742
- // Handle routes
743
  if (url.pathname === "/health" || url.pathname === "/status") {
744
  response = await this.handleStatus();
745
  } else if (!this.authenticate(request)) {
746
  response = new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { status: 401 });
747
- // [修改] 添加 TTS 路由
748
  } else if (url.pathname === "/v1/audio/speech" && request.method === "POST") {
749
  response = await this.handleAudioSpeech(request);
750
  } else if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
@@ -755,7 +456,7 @@ class OpenAICompatibleServer {
755
  response = new Response("Not Found", { status: 404 });
756
  }
757
 
758
- // Add CORS headers to all responses
759
  const finalHeaders = new Headers(response.headers);
760
  for (const [key, value] of Object.entries(corsHeaders)) {
761
  finalHeaders.set(key, value);
@@ -769,11 +470,11 @@ class OpenAICompatibleServer {
769
  const server = new OpenAICompatibleServer();
770
 
771
  console.log("🚀 OpenAI Compatible Server with Google AI starting on port 8000...");
772
- console.log(`✅ Loaded ${server.googleAI.apiKeys.length} API key(s).`);
773
  console.log(`📄 Max document size set to ${MAX_DOCUMENT_SIZE_MB}MB.`);
774
 
775
- // Pre-fetch models at startup
776
- server.googleAI.fetchOfficialModels().then(models => {
777
  console.log(`✅ Successfully fetched ${models.length} models from Google AI.`);
778
  }).catch(error => {
779
  console.warn(`⚠️ Could not pre-fetch models: ${error.message}. Will use fallbacks or fetch on first request.`);
@@ -781,11 +482,10 @@ server.googleAI.fetchOfficialModels().then(models => {
781
 
782
  console.log("\n🔗 Endpoints:");
783
  console.log(" POST /v1/chat/completions");
784
- console.log(" POST /v1/audio/speech <-- [NEW] OpenAI TTS compatible endpoint"); // [修改] 更新启动日志
785
  console.log(" GET /v1/models");
786
  console.log(" GET /status");
787
 
788
- // [修改] 端口从 7860 改为 8000,与日志一致。您可以根据需要改回 7860。
789
  await serve(
790
  (request: Request) => server.handleRequest(request),
791
  { port: 7860 }
 
24
  stream?: boolean;
25
  }
26
 
 
27
  interface OpenAITTSRequest {
28
+ model: string;
29
+ input: string;
30
  voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
31
+ response_format?: 'mp3' | 'opus' | 'aac' | 'flac';
32
+ speed?: number;
33
  }
34
 
35
 
 
62
  }
63
 
64
  // --- [新增] TTS 功能 ---
 
 
 
 
 
65
  private getGoogleVoice(openAIVoice: string): string {
66
  const voiceMap: { [key: string]: string } = {
67
+ 'alloy': 'Kore',
68
+ 'echo': 'Sal',
69
+ 'fable': 'Polly',
70
+ 'onyx': 'Onyx',
71
+ 'nova': 'Sparkle',
72
+ 'shimmer': 'Luna',
 
73
  'default': 'Kore'
74
  };
75
  return voiceMap[openAIVoice] || voiceMap['default'];
76
  }
77
 
 
 
 
 
 
 
 
78
  async generateSpeech(input: string, model: string, voice: string): Promise<ArrayBuffer> {
79
  const apiKey = this.getNextApiKey();
80
  const googleVoice = this.getGoogleVoice(voice);
 
81
  const ttsModel = "gemini-2.5-flash-preview-tts";
82
 
83
  console.log(`Generating speech with model: ${ttsModel}, voice: ${googleVoice} (mapped from OpenAI's '${voice}')`);
84
 
85
  const requestBody = {
86
+ "contents": [{"parts":[{"text": input}]}],
 
 
 
 
87
  "generationConfig": {
88
  "responseModalities": ["AUDIO"],
89
+ "speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": googleVoice}}}
 
 
 
 
 
 
90
  },
91
  "model": ttsModel,
92
  };
93
 
94
  const response = await fetch(
95
  `https://generativelanguage.googleapis.com/v1beta/models/${ttsModel}:generateContent?key=${apiKey}`,
96
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }
 
 
 
 
97
  );
98
 
99
  if (!response.ok) {
 
104
  }
105
 
106
  const data = await response.json();
 
 
107
  const audioContentBase64 = data.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
108
  if (!audioContentBase64) {
109
  throw new Error("No audio data returned from Google API. The response might be blocked or empty.");
110
  }
111
 
 
112
  const binaryString = atob(audioContentBase64);
113
  const len = binaryString.length;
114
  const bytes = new Uint8Array(len);
 
118
  return bytes.buffer;
119
  }
120
 
121
+ // --- 模型处理等现有代码保持不变 ---
122
 
123
+ async fetchOfficialModels(): Promise<any[]> { /* ... 保持不变 ... */
124
  const now = Date.now();
125
  if (this.cachedModels.length > 0 && (now - this.modelsLastFetch) < MODELS_CACHE_DURATION) {
126
  return this.cachedModels;
 
154
  }
155
  }
156
 
157
+ private getFallbackModels(): any[] { /* ... 保持不变 ... */
158
  return [
159
  { 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 },
160
  { 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 },
161
  { 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"] },
 
162
  { name: "models/gemini-2.5-flash-preview-tts", displayName: "Gemini 2.5 Flash TTS", description: "Text-to-speech model for generating high-quality audio.", supportedGenerationMethods: ["generateContent"], id: "gemini-2.5-flash-preview-tts" }
163
  ];
164
  }
 
168
  public isImageEditingModel = (modelName: string): boolean => modelName.includes('image-generation') || modelName === 'gemini-2.0-flash-preview-image-generation';
169
  public isDocumentModel = (modelName: string): boolean => modelName.toLowerCase().includes('gemini-1.5') || modelName.toLowerCase().includes('pro') || modelName.toLowerCase().includes('flash');
170
 
171
+ // ... 省略 extractDocumentData, extractImageData 等辅助函数,它们保持不变 ...
172
+ private getDocumentType(url: string): string { /* ... 保持不变 ... */ return ''; }
173
+ private extractDocumentData(documentUrl: string): { mimeType: string; data: string; text?: string; docType: string } { /* ... 保持不变 ... */ return { mimeType: '', data: '', docType: ''}; }
174
+ private extractImageData(imageUrl: string): { mimeType: string; data: string } { /* ... 保持不变 ... */ return {mimeType: '', data: ''}; }
 
 
 
 
 
 
 
 
175
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
+ // --- 内容生成函数 (非流式) ---
178
+ // ... generateContentWithDocument, generateContent 等保持不变 ...
179
+ async generateContentWithDocument(messages: OpenAIMessage[], modelName: string): Promise<string> { /* ... 保持不变 ... */ return ''; }
180
+ async generateContent(messages: OpenAIMessage[], modelName: string, enableSearch: boolean = false): Promise<string> { /* ... 保持不变 ... */
181
+ // 这部分逻辑保持原样,用于非流式请求
182
+ const apiKey = this.getNextApiKey();
183
+ const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
184
+ const contents = messages.map(msg => {
185
+ // ... 消息转换逻辑 ...
186
+ });
187
+ const requestBody: any = { contents, generationConfig: { /*...*/ } };
188
+ const response = await fetch(
189
+ `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:generateContent?key=${apiKey}`,
190
+ { method: "POST", body: JSON.stringify(requestBody), headers: { "Content-Type": "application/json" } }
191
+ );
192
+ // ... 错误处理和结果解析 ...
193
+ const data = await response.json();
194
+ return data.candidates?.[0]?.content?.parts[0]?.text || "No response generated";
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  }
196
 
197
+
198
+ // --- [新增] 真正的流式内容生成函数 ---
199
+ /**
200
+ * 使用 Google 的 streamGenerateContent 端点进行真正的流式内容生成。
201
+ * 这个函数是一个异步生成器,会不断 yield 从 API 收到的文本块。
202
+ * @param messages OpenAI 格式的消息
203
+ * @param modelName 请求的模型名称
204
+ * @yields {string} 文本块
205
+ */
206
+ async * streamGenerateContent(messages: OpenAIMessage[], modelName: string): AsyncGenerator<string> {
207
  const apiKey = this.getNextApiKey();
208
  const fullModelName = modelName.startsWith('models/') ? modelName : `models/${modelName}`;
 
 
 
209
 
210
+ // 注意:文档/图片处理的逻辑需要与非流式版本保持一致
211
+ const contents = messages.map(msg => {
 
212
  if (typeof msg.content === "string") {
213
  return { role: msg.role === "assistant" ? "model" : "user", parts: [{ text: msg.content }] };
214
+ } else {
215
+ const messageParts = msg.content.map(part => {
216
+ if (part.type === "text") return { text: part.text };
217
+ if (part.type === "image_url" && part.image_url) {
218
+ const { mimeType, data } = this.extractImageData(part.image_url.url);
219
+ return { inlineData: { mimeType, data } };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  }
221
+ // 简单处理,可以根据需要扩展
222
+ return { text: "" };
223
+ });
224
+ return { role: msg.role === "assistant" ? "model" : "user", parts: messageParts.filter(p => p.text || p.inlineData) };
225
+ }
226
+ });
 
 
 
227
 
228
  const requestBody = {
229
  contents,
230
  generationConfig: { temperature: 0.7, maxOutputTokens: 8192 }
231
  };
232
+
233
+ // [关键] 使用 :streamGenerateContent 端点
234
  const response = await fetch(
235
+ `https://generativelanguage.googleapis.com/v1beta/${fullModelName}:streamGenerateContent?key=${apiKey}`,
236
  {
237
  method: "POST",
238
  headers: { "Content-Type": "application/json" },
 
240
  }
241
  );
242
 
243
+ if (!response.ok || !response.body) {
244
+ const errorText = await response.text();
245
+ throw new Error(`Google AI API streaming error: ${response.status} - ${errorText}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  }
247
 
248
+ // [关键] 读取并解析流式响应
249
+ const reader = response.body.getReader();
250
+ const decoder = new TextDecoder();
251
+ let buffer = "";
252
 
253
+ while (true) {
254
+ const { done, value } = await reader.read();
255
+ if (done) break;
256
+
257
+ buffer += decoder.decode(value, { stream: true });
258
+
259
+ // Google 的流式响应可能会在一个数据包里包含多个JSON对象,它们以 "data: " 开头
260
+ // 我们需要处理这种情况
261
+ while (buffer.includes('\n')) {
262
+ const endOfLine = buffer.indexOf('\n');
263
+ const line = buffer.substring(0, endOfLine).trim();
264
+ buffer = buffer.substring(endOfLine + 1);
265
+
266
+ if (line.startsWith('data: ')) {
267
+ try {
268
+ const jsonStr = line.substring(6); // 去掉 'data: '
269
+ const chunk = JSON.parse(jsonStr);
270
+
271
+ if (chunk.error) {
272
+ throw new Error(`Google API Error in stream: ${chunk.error.message}`);
273
+ }
274
+
275
+ const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text;
276
+ if (text) {
277
+ yield text;
278
+ }
279
+ } catch (e) {
280
+ console.warn("Could not parse stream chunk:", line, e.message);
281
+ }
282
  }
 
 
 
283
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  }
 
285
  }
286
 
287
+ // ... 其他辅助函数如 generateOrEditImage, generateContentWithGrounding 保持不变 ...
288
+ 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 }> { /* ... */ return {};}
289
+ async generateContentWithGrounding(messages: OpenAIMessage[], modelName: string): Promise<string> { /* ... */ return ''; }
290
+ async generateContentWithSearchPrompt(messages: OpenAIMessage[], modelName: string): Promise<string> { /* ... */ return ''; }
291
+ async generateOrEditImage(prompt: string, modelName: string, inputImages?: any[]): Promise<string> { /* ... */ return ''; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  }
293
 
294
+
295
  class OpenAICompatibleServer {
296
  private googleAI: GoogleAIService;
297
  private authKey: string;
 
301
  this.authKey = Deno.env.get("AUTH_KEY") || "";
302
  }
303
 
304
+ private authenticate(request: Request): boolean { /* ... 保持不变 ... */ return true; }
305
+ private isDocumentContent(url?: string): boolean { /* ... 保持不变 ... */ return false; }
306
+ private async handleAudioSpeech(request: Request): Promise<Response> { /* ... 保持不变 ... */ return new Response(); }
 
 
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  private async handleChatCompletions(request: Request): Promise<Response> {
309
  try {
310
  const body: OpenAIRequest = await request.json();
 
312
  const stream = body.stream || false;
313
  console.log(`Request for model: ${requestedModel}, stream: ${stream}`);
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  if (stream) {
316
+ // [修改] 调用新的流式处理逻辑
317
+ const googleStream = this.googleAI.streamGenerateContent(body.messages, requestedModel);
318
+ const openAIStream = this.streamGoogleResponseAsOpenAI(googleStream, requestedModel);
319
+ return new Response(openAIStream, {
320
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*" }
321
  });
322
  } else {
323
+ // [保持不变] 非流式逻辑
324
+ const lastMessage = body.messages[body.messages.length - 1];
325
+ const content = typeof lastMessage.content === "string"
326
+ ? lastMessage.content
327
+ : (Array.isArray(lastMessage.content) ? lastMessage.content.map(p => p.text || "").join(" ") : "");
328
+
329
+ const hasDocument = body.messages.some(msg =>
330
+ Array.isArray(msg.content) &&
331
+ msg.content.some(part => part.type === "document" || this.isDocumentContent(part.document?.url))
332
+ );
333
+
334
+ const hasImages = body.messages.some(msg => Array.isArray(msg.content) && msg.content.some(part => part.type === "image_url"));
335
+
336
+ let inputImages: any[] = [];
337
+ if (hasImages) {
338
+ body.messages.forEach(msg => {
339
+ if (Array.isArray(msg.content)) {
340
+ msg.content.forEach(part => {
341
+ if (part.type === "image_url" && part.image_url) inputImages.push({ url: part.image_url.url });
342
+ });
343
+ }
344
+ });
345
+ }
346
+
347
+ let responseText: string;
348
+
349
+ if (hasDocument) {
350
+ responseText = await this.googleAI.generateContentWithDocument(body.messages, requestedModel);
351
+ } else if (this.googleAI.isImageEditingModel(requestedModel) && hasImages) {
352
+ responseText = await this.googleAI.generateOrEditImage(content, requestedModel, inputImages);
353
+ } else if (this.googleAI.isImageGenerationModel(requestedModel)) {
354
+ responseText = await this.googleAI.generateOrEditImage(content, requestedModel);
355
+ } else if (content.toLowerCase().startsWith("/search:")) {
356
+ const query = content.substring(8).trim();
357
+ const searchMessages = [{ ...lastMessage, content: query }];
358
+ responseText = await this.googleAI.generateContentWithGrounding(searchMessages, requestedModel);
359
+ } else {
360
+ responseText = await this.googleAI.generateContent(body.messages, requestedModel, false);
361
+ }
362
+
363
  const responsePayload = {
364
+ id: `chatcmpl-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: requestedModel,
365
+ choices: [{ index: 0, message: { role: "assistant", content: responseText }, finish_reason: "stop" }],
366
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
367
  };
368
  return new Response(JSON.stringify(responsePayload), { headers: { "Content-Type": "application/json" } });
369
  }
370
  } catch (error) {
371
+ console.error("Error in chat completions:", error.message);
372
+ const status = error.message.includes("exceeds the limit") || error.message.includes("Invalid") ? 400 : 500;
373
+ return new Response(
374
+ JSON.stringify({
375
+ error: {
376
+ message: error.message,
377
+ type: status === 400 ? "invalid_request_error" : "api_error",
378
+ code: null
379
+ }
380
+ }),
381
+ { status, headers: { "Content-Type": "application/json" } }
382
+ );
383
  }
384
  }
385
 
386
+ // [删除] 旧的伪流式函数 streamStringAsOpenAIResponse
387
+
388
+ // [新增] 真正的流式响应转换函数
389
+ private streamGoogleResponseAsOpenAI(googleStream: AsyncGenerator<string>, modelName: string): ReadableStream<Uint8Array> {
390
  const encoder = new TextEncoder();
391
  const streamId = `chatcmpl-${Date.now()}`;
392
  const creationTime = Math.floor(Date.now() / 1000);
 
393
 
394
  return new ReadableStream({
395
+ async start(controller) {
396
+ // 首先发送一个空的 assistant role 块,这是 OpenAI 的惯例
397
+ const initialChunk = { id: streamId, object: 'chat.completion.chunk', created: creationTime, model: modelName, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] };
398
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialChunk)}\n\n`));
399
+
400
+ // 迭代从 Google API 收到的文本块
401
+ for await (const textChunk of googleStream) {
402
+ if (textChunk) {
403
+ const chunk = {
404
+ id: streamId,
405
+ object: 'chat.completion.chunk',
406
+ created: creationTime,
407
+ model: modelName,
408
+ choices: [{
409
+ index: 0,
410
+ delta: { content: textChunk }, // 将收到的文本块放入 delta.content
411
+ finish_reason: null
412
+ }]
413
+ };
414
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
415
  }
 
 
 
416
  }
417
+
418
+ // 所有数据块发送完毕后,发送结束信号
419
+ const finalChunk = { id: streamId, object: 'chat.completion.chunk', created: creationTime, model: modelName, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
420
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
421
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
422
+ controller.close();
423
+ }
424
  });
425
  }
426
 
427
+ private async handleModels(): Promise<Response> { /* ... 保持不变 ... */ return new Response(); }
428
+ private async handleStatus(): Promise<Response> { /* ... 保持不变 ... */ return new Response(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
 
430
  async handleRequest(request: Request): Promise<Response> {
431
  const corsHeaders = {
 
441
  const url = new URL(request.url);
442
  let response: Response;
443
 
444
+ // 路由处理
445
  if (url.pathname === "/health" || url.pathname === "/status") {
446
  response = await this.handleStatus();
447
  } else if (!this.authenticate(request)) {
448
  response = new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { status: 401 });
 
449
  } else if (url.pathname === "/v1/audio/speech" && request.method === "POST") {
450
  response = await this.handleAudioSpeech(request);
451
  } else if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
 
456
  response = new Response("Not Found", { status: 404 });
457
  }
458
 
459
+ // 为所有响应添加CORS
460
  const finalHeaders = new Headers(response.headers);
461
  for (const [key, value] of Object.entries(corsHeaders)) {
462
  finalHeaders.set(key, value);
 
470
  const server = new OpenAICompatibleServer();
471
 
472
  console.log("🚀 OpenAI Compatible Server with Google AI starting on port 8000...");
473
+ console.log(`✅ Loaded ${server['googleAI'].apiKeys.length} API key(s).`);
474
  console.log(`📄 Max document size set to ${MAX_DOCUMENT_SIZE_MB}MB.`);
475
 
476
+ // 启动时预取模型
477
+ server['googleAI'].fetchOfficialModels().then(models => {
478
  console.log(`✅ Successfully fetched ${models.length} models from Google AI.`);
479
  }).catch(error => {
480
  console.warn(`⚠️ Could not pre-fetch models: ${error.message}. Will use fallbacks or fetch on first request.`);
 
482
 
483
  console.log("\n🔗 Endpoints:");
484
  console.log(" POST /v1/chat/completions");
485
+ console.log(" POST /v1/audio/speech");
486
  console.log(" GET /v1/models");
487
  console.log(" GET /status");
488
 
 
489
  await serve(
490
  (request: Request) => server.handleRequest(request),
491
  { port: 7860 }