misonL commited on
Commit
84e2f9a
·
1 Parent(s): 51b37db

Refactor: Extract Gemini proxy logic into dedicated modules

Browse files

Move core functionalities such as Gemini client management, session handling, image proxying, and API format conversion from `app/api/gemini_proxy.py` into new, dedicated modules:
- `app/core/`: For core logic like client and session management.
- `app/services/`: For specific services like image proxying.
- `app/utils/`: For utility functions like API format conversion.

This refactoring significantly improves code organization, maintainability, and separation of concerns, reducing the complexity of the main API endpoint file.

app/api/gemini_proxy.py CHANGED
@@ -1,190 +1,39 @@
1
  # app/api/gemini_proxy.py
2
 
3
  import os # 导入 os 模块用于读取环境变量
4
- import itertools # 导入 itertools 模块用于循环 cookie
5
  import time
6
  import base64
7
- import re
8
  import logging
9
  import asyncio
10
- import uuid # 导入 uuid 模块用于生成会话 ID
11
  import tempfile # 导入 tempfile 模块用于创建临时文件
12
  from fastapi import APIRouter, Request, HTTPException, Depends, status
13
- from cachetools import LRUCache # 导入 LRU 缓存
14
  from fastapi.responses import JSONResponse
15
  from typing import Dict, Any, List, Optional
16
- from fastapi.responses import StreamingResponse # 导入 StreamingResponse
17
 
18
  # 导入自定义模块
19
  from app.api.auth import (
20
  get_user_api_key,
21
  get_admin_api_key,
22
- get_auth_token, # 更改为 get_auth_token
23
  ) # 从 auth 模块导入认证依赖
24
  from app.api.metrics import (
25
  update_metrics_on_request,
26
  update_metrics_on_response,
27
  get_current_metrics,
28
  ) # 从 metrics 模块导入指标和记录获取函数
29
- from gemini_webapi import GeminiClient, ModelOutput
30
- import gemini_webapi.exceptions # 导入 gemini_webapi 的异常模块
31
- import httpx # 导入 httpx 模块
32
-
 
 
 
 
 
 
33
 
34
  logger = logging.getLogger(__name__)
35
 
36
- # 定义需要跳过后端下载的域名,这些域名的图片将通过代理加载
37
- # 定义需要跳过后端下载的域名,这些域名的图片将通过代理加载
38
- SKIP_DOWNLOAD_DOMAINS = ["googleusercontent.com", "blogger.googleusercontent.com", "lh3.googleusercontent.com"]
39
-
40
- # 新的环境变量读取方式
41
- GEMINI_PSID_COOKIES_STR = os.getenv("GEMINI_PSID_COOKIES", "")
42
- GEMINI_PSIDTS_COOKIES_STR = os.getenv("GEMINI_PSIDTS_COOKIES", "")
43
-
44
- raw_psid_cookies = [c.strip() for c in GEMINI_PSID_COOKIES_STR.split(",") if c.strip()]
45
- raw_psidts_cookies = [
46
- c.strip() for c in GEMINI_PSIDTS_COOKIES_STR.split(",") if c.strip()
47
- ]
48
-
49
- gemini_cookie_pairs = []
50
- if raw_psid_cookies and raw_psidts_cookies:
51
- if len(raw_psid_cookies) == len(raw_psidts_cookies):
52
- valid_pairs = []
53
- for sid, sidts in zip(raw_psid_cookies, raw_psidts_cookies):
54
- if sid and sidts: # Both must be non-empty
55
- valid_pairs.append((sid, sidts))
56
- else:
57
- logger.warning(
58
- f"检测到无效的 Cookie 对 (一个或两个值为空): SID='{sid}', SIDTS='{sidts}'. 此对将被忽略。"
59
- )
60
-
61
- gemini_cookie_pairs = valid_pairs
62
- if gemini_cookie_pairs:
63
- logger.info(
64
- f"成功加载并验证 {len(gemini_cookie_pairs)} 组 Gemini Cookie 对。"
65
- )
66
- else:
67
- logger.warning(
68
- "所有提供的 Cookie 对均无效 (一个或两个值为空) 或 GEMINI_PSID_COOKIES / GEMINI_PSIDTS_COOKIES 列表长度匹配但内容无效。"
69
- )
70
- else:
71
- logger.warning(
72
- "GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量中的 Cookie 数量不匹配。请检查配置。"
73
- )
74
- else:
75
- if not raw_psid_cookies and not raw_psidts_cookies:
76
- logger.warning(
77
- "GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量均未设置或为空。Gemini API 调用可能会失败。"
78
- )
79
- elif not raw_psid_cookies:
80
- logger.warning(
81
- "GEMINI_PSID_COOKIES 环境变量未设置或为空,但 GEMINI_PSIDTS_COOKIES 可能已设置。Cookie 对不完整或无效。"
82
- )
83
- else: # not raw_psidts_cookies
84
- logger.warning(
85
- "GEMINI_PSIDTS_COOKIES 环境变量未设置或为空,但 GEMINI_PSID_COOKIES 可能已设置。Cookie 对不完整或无效。"
86
- )
87
-
88
- # 全局变量,用于存储 cookie 轮询器和客户端实例
89
- cookie_cycler = itertools.cycle(gemini_cookie_pairs) if gemini_cookie_pairs else None
90
- gemini_clients: Dict[str, GeminiClient] = {} # 存储 {auth_token: GeminiClient实例}
91
-
92
- # 每个认证令牌的最大会话数
93
- MAX_SESSIONS_PER_TOKEN = 5
94
-
95
- # 会话数据结构
96
- class Session:
97
- def __init__(self, thread_id, name=None):
98
- self.thread_id = thread_id
99
- self.name = name or f"会话-{time.strftime('%m%d')}-{str(uuid.uuid4())[:4]}"
100
- self.created_at = time.time()
101
- self.last_active = time.time()
102
- self._chat_instance = None # 新增:存储 Gemini Chat 实例
103
-
104
- # 全局变量,用于存储每个认证令牌的聊天会话
105
- chat_sessions: Dict[str, Dict[str, Session]] = {}
106
-
107
- def cleanup_expired_sessions(auth_token: str):
108
- """清理过期会话(24小时未活动)和超出限制的会话"""
109
- now = time.time()
110
- sessions = chat_sessions.get(auth_token, {})
111
-
112
- # 清理超过24小时的会话
113
- expired_threads = [
114
- tid for tid, session in sessions.items()
115
- if now - session.last_active > 86400
116
- ]
117
- for tid in expired_threads:
118
- del sessions[tid]
119
-
120
- # 清理超出数量限制的旧会话
121
- if len(sessions) > MAX_SESSIONS_PER_TOKEN:
122
- oldest = sorted(sessions.items(), key=lambda x: x[1].last_active)[0]
123
- del sessions[oldest[0]]
124
-
125
- return sessions
126
-
127
- def reload_gemini_cookies():
128
- """
129
- 重新加载 Gemini Cookies 环境变量并重新初始化 cookie 轮询器。
130
- 此函数应在需要刷新 cookies 时调用。
131
- """
132
- global gemini_cookie_pairs, cookie_cycler, gemini_clients # 声明为全局变量以便修改
133
-
134
- logger.info("开始重新加载 Gemini Cookies...")
135
-
136
- # 重新读取环境变量
137
- new_psid_cookies_str = os.getenv("GEMINI_PSID_COOKIES", "")
138
- new_psidts_cookies_str = os.getenv("GEMINI_PSIDTS_COOKIES", "")
139
-
140
- new_raw_psid_cookies = [c.strip() for c in new_psid_cookies_str.split(",") if c.strip()]
141
- new_raw_psidts_cookies = [c.strip() for c in new_psidts_cookies_str.split(",") if c.strip()]
142
-
143
- new_gemini_cookie_pairs = []
144
- if new_raw_psid_cookies and new_raw_psidts_cookies:
145
- if len(new_raw_psid_cookies) == len(new_raw_psidts_cookies):
146
- valid_pairs = []
147
- for sid, sidts in zip(new_raw_psid_cookies, new_raw_psidts_cookies):
148
- if sid and sidts:
149
- valid_pairs.append((sid, sidts))
150
- else:
151
- logger.warning(
152
- f"重新加载时检测到无效的 Cookie 对 (一个或两个值为空): SID='{sid}', SIDTS='{sidts}'. 此对将被忽略。"
153
- )
154
- new_gemini_cookie_pairs = valid_pairs
155
- if new_gemini_cookie_pairs:
156
- logger.info(
157
- f"成功重新加载并验证 {len(new_gemini_cookie_pairs)} 组 Gemini Cookie 对。"
158
- )
159
- else:
160
- logger.warning(
161
- "重新加载时所有提供的 Cookie 对均无效或列表长度匹配但内容无效。"
162
- )
163
- else:
164
- logger.warning(
165
- "重新加载时 GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量中的 Cookie 数量不匹配。请检查配置。"
166
- )
167
- else:
168
- logger.warning(
169
- "重新加载时 GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量均未设置或为空。Gemini API 调用可能会失败。"
170
- )
171
-
172
- gemini_cookie_pairs = new_gemini_cookie_pairs
173
- cookie_cycler = itertools.cycle(gemini_cookie_pairs) if gemini_cookie_pairs else None
174
-
175
- # 清空所有现有的 GeminiClient 实例,强制下次请求时重新初始化
176
- gemini_clients.clear()
177
- logger.info("所有现有 GeminiClient 实例已清空,将在下次请求时重新初始化。")
178
- logger.info("Gemini Cookies 重新加载完成。")
179
-
180
- # 在应用启动时调用一次,确保初始加载
181
- reload_gemini_cookies()
182
-
183
- DEFAULT_IMAGE_MIME_TYPE = "image/jpeg" # 默认图片MIME类型
184
-
185
- # 创建 APIRouter 实例
186
- router = APIRouter()
187
-
188
  # 创建 APIRouter 实例
189
  router = APIRouter()
190
 
@@ -200,377 +49,43 @@ async def refresh_gemini_cookies_endpoint(admin_token: str = Depends(get_admin_a
200
  reload_gemini_cookies()
201
  return JSONResponse(content={"status": "ok", "message": "Gemini Cookies 已重新加载,所有客户端实例已清空。"})
202
 
203
-
204
- def parse_gemini_cookies(cookie_string: str) -> tuple[Optional[str], Optional[str]]:
205
- """
206
- 解析 Gemini Cookie 字符串,提取 __Secure-1PSID 和 __Secure-1PSIDTS。
207
- Args:
208
- cookie_string: 包含 Gemini Cookies 的字符串,例如 "cookie1=value1; __Secure-1PSID=sid_value; cookie2=value2; __Secure-1PSIDTS=sidts_value"
209
- Returns:
210
- 包含 (sid, sidts) 的元组,如果未找到则为 (None, None)。
211
- """
212
- sid = None
213
- sidts = None
214
- cookies = cookie_string.split(";")
215
- for cookie in cookies:
216
- parts = cookie.strip().split("=", 1)
217
- if len(parts) == 2:
218
- name, value = parts
219
- if name == "__Secure-1PSID":
220
- sid = value
221
- elif name == "__Secure-1PSIDTS":
222
- sidts = value
223
- return sid, sidts
224
-
225
-
226
- def get_next_gemini_cookie_tuple() -> Optional[tuple[str, str]]:
227
- """获取下一组可用的 Gemini Cookie (SID, SIDTS) 元组。"""
228
- if not cookie_cycler:
229
- # Extensive logging during gemini_cookie_pairs creation should cover most scenarios.
230
- # This check is a final safeguard if cookie_cycler is None (no valid cookies loaded).
231
- return None
232
-
233
- try:
234
- # Directly from cycler, which contains pre-validated (sid, sidts) pairs
235
- return next(cookie_cycler)
236
- except StopIteration:
237
- # This case implies gemini_cookie_pairs was empty, leading to cookie_cycler being None,
238
- # which should be caught by the 'if not cookie_cycler:' check above.
239
- # If somehow StopIteration is raised here with a non-None cookie_cycler, it's an unexpected state.
240
- logger.error(
241
- "Gemini Cookie 轮询器意外耗尽或无法获取下一个元素。这可能表明初始化的 gemini_cookie_pairs 为空或所有元素均无效,尽管 cookie_cycler 不是 None。"
242
- )
243
- return None
244
- except Exception as e:
245
- logger.error(f"获取下一组 Gemini Cookie 时发生意外错误: {e}", exc_info=True)
246
- return None
247
-
248
-
249
- async def format_gemini_response_to_openai(gemini_output: ModelOutput) -> Dict[str, Any]:
250
- """
251
- 将 gemini-webapi 的 ModelOutput 格式转换为 OpenAI API 的聊天完成响应格式。
252
- 这里只实现核心字段的转换。
253
- """
254
- openai_response = {
255
- "id": "chatcmpl-placeholder", # 占位符
256
- "object": "chat.completion",
257
- "created": int(time.time()),
258
- "model": "gemini-pro", # 假设都使用 gemini-pro
259
- "choices": [],
260
- "usage": { # 占位符,gemini-webapi 可能不提供详细 usage
261
- "prompt_tokens": 0,
262
- "completion_tokens": 0,
263
- "total_tokens": 0,
264
- },
265
- }
266
-
267
- content_parts = []
268
- image_data_urls = [] # 用于存储 Base64 编码的图片 Data URL 列表
269
-
270
- # 定义需要跳过后端下载的域名
271
- # 这里的 SKIP_DOWNLOAD_DOMAINS 应该用于决定是否直接转换为代理 URL,而不是尝试下载
272
- # 统一处理所有 googleusercontent.com 和 lh3.googleusercontent.com 的图片通过代理
273
- PROXY_IMAGE_DOMAINS = ["googleusercontent.com", "blogger.googleusercontent.com", "lh3.googleusercontent.com"]
274
-
275
- if gemini_output:
276
- if gemini_output.text:
277
- original_text = gemini_output.text
278
- logger.info(f"Gemini 原始文本内容: {original_text[:200]}...") # 记录原始文本内容,截取前200字符
279
-
280
- # 提取 Markdown 格式的图片链接和纯 URL 格式的图片链接
281
- markdown_image_regex = r"!\[.*?\]\((https?:\/\/[^\s)]+)\)"
282
- direct_image_url_regex = r"(https?:\/\/(?:lh3\.)?googleusercontent\.com\/(?:image_generation_content|gg-dl|image_collection\/image_retrieval)\/[^\s]+)"
283
-
284
- # 存储所有从文本中提取的图片 URL
285
- all_extracted_urls_from_text = []
286
-
287
- # 第一次匹配:Markdown 格式的图片链接
288
- temp_text_after_markdown_extraction = original_text
289
- markdown_matches = re.findall(markdown_image_regex, temp_text_after_markdown_extraction)
290
- for url in markdown_matches:
291
- all_extracted_urls_from_text.append(url)
292
- temp_text_after_markdown_extraction = re.sub(markdown_image_regex, '', temp_text_after_markdown_extraction)
293
-
294
- # 第二次匹配:纯 URL 格式的图片链接
295
- direct_url_matches = re.findall(direct_image_url_regex, temp_text_after_markdown_extraction)
296
- for url in direct_url_matches:
297
- all_extracted_urls_from_text.append(url)
298
- temp_text_after_direct_url_extraction = re.sub(direct_image_url_regex, '', temp_text_after_markdown_extraction)
299
-
300
- content_parts.append(temp_text_after_direct_url_extraction.strip()) # 将处理后的文本添加到内容部分
301
-
302
- # 处理从文本中提取的图片
303
- if all_extracted_urls_from_text:
304
- logger.info(f"从文本中检测到图片 URL: {all_extracted_urls_from_text}")
305
- async with httpx.AsyncClient(follow_redirects=True, trust_env=True, max_redirects=100) as client: # 增加最大重定向次数
306
- headers = {
307
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36",
308
- "Referer": "https://gemini.google.com/",
309
- }
310
- for i, image_url in enumerate(all_extracted_urls_from_text):
311
- # 判断是否属于需要代理的域名
312
- should_proxy = any(domain in image_url for domain in PROXY_IMAGE_DOMAINS)
313
-
314
- if should_proxy:
315
- logger.warning(f"图片 URL 属于代理域名 ({image_url}),将通过代理加载。")
316
- proxied_url = f"/v1/image_proxy?image_url={image_url}"
317
- image_data_urls.append(proxied_url)
318
- continue # 跳过直接下载逻辑
319
-
320
- # 否则,尝试直接下载并转换为 Base64
321
- try:
322
- logger.info(f"尝试直接下载文本中的图片: {image_url}")
323
- response = await client.get(image_url, timeout=60, headers=headers)
324
- response.raise_for_status()
325
- image_bytes = response.content
326
-
327
- mime_type = response.headers.get("Content-Type", "application/octet-stream").split(';')[0].strip()
328
- if not mime_type.startswith("image/"):
329
- ext = image_url.split('.')[-1].lower()
330
- if ext in ["jpg", "jpeg"]: mime_type = "image/jpeg"
331
- elif ext == "png": mime_type = "image/png"
332
- elif ext == "gif": mime_type = "image/gif"
333
- elif ext == "webp": mime_type = "image/webp"
334
- else:
335
- logger.warning(f"文本中的图片 {image_url} 的 MIME 类型未知或非图片类型: {mime_type},尝试使用 image/jpeg 作为默认。")
336
- mime_type = "image/jpeg"
337
-
338
- base64_image = base64.b64encode(image_bytes).decode("utf-8")
339
- data_url = f"data:{mime_type};base64,{base64_image}"
340
- image_data_urls.append(data_url)
341
- logger.info(f"成功下载并转换文本中的图片 {i+1} ({image_url}) 为 Base64 Data URL (MIME: {mime_type})")
342
- except httpx.RequestError as e:
343
- logger.error(f"直接下载文本中的图片失败 (RequestError): URL: {image_url}, 类型: {type(e).__name__}, 错误: {e}", exc_info=True)
344
- image_data_urls.append("") # 下载失败时,返回空字符串
345
- except httpx.HTTPStatusError as e:
346
- logger.error(f"直接下载文本中的图片失败 (HTTPStatusError): URL: {image_url}, 状态码: {e.response.status_code}, 响应文本: {e.response.text}, 错误: {e}", exc_info=True)
347
- image_data_urls.append("") # 下载失败时,返回空字符串
348
- except Exception as e:
349
- logger.error(f"处理文本中的图片时发生未知错误: URL: {image_url}, 类型: {type(e).__name__}, 错误: {e}", exc_info=True)
350
- image_data_urls.append("") # 下载失败时,返回空字符串
351
-
352
- # 处理 gemini_output.images,如果存在图片,将其下载并转换为 Base64 Data URL
353
- if gemini_output.images:
354
- logger.info(f"检测到 Gemini 返回图片列表: {[img.url for img in gemini_output.images if img.url]}")
355
- async with httpx.AsyncClient(follow_redirects=True, trust_env=True, max_redirects=100) as client: # 增加最大重定向次数
356
- headers = {
357
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36",
358
- "Referer": "https://gemini.google.com/",
359
- }
360
- for i, image in enumerate(gemini_output.images):
361
- if image.url:
362
- # 判断是否属于需要代理的域名
363
- should_proxy = any(domain in image.url for domain in PROXY_IMAGE_DOMAINS)
364
-
365
- if should_proxy:
366
- logger.warning(f"图片 URL 属于代理域名 (来自gemini_output.images) ({image.url}),将通过代理加载。")
367
- proxied_url = f"/v1/image_proxy?image_url={image.url}"
368
- image_data_urls.append(proxied_url)
369
- continue # 跳过直接下载逻辑
370
-
371
- # 否则,尝试直接下载并转换为 Base64
372
- try:
373
- logger.info(f"尝试直接下载图片 (来自gemini_output.images): {image.url}")
374
- response = await client.get(image.url, timeout=60, headers=headers)
375
- response.raise_for_status()
376
- image_bytes = response.content
377
-
378
- mime_type = response.headers.get("Content-Type", "application/octet-stream").split(';')[0].strip()
379
- if not mime_type.startswith("image/"):
380
- ext = image.url.split('.')[-1].lower()
381
- if ext in ["jpg", "jpeg"]: mime_type = "image/jpeg"
382
- elif ext == "png": mime_type = "image/png"
383
- elif ext == "gif": mime_type = "image/gif"
384
- elif ext == "webp": mime_type = "image/webp"
385
- else:
386
- logger.warning(f"图片 {image.url} 的 MIME 类型未知或非图片类型: {mime_type},尝试使用 image/jpeg 作为默认。")
387
- mime_type = "image/jpeg"
388
-
389
- base64_image = base64.b64encode(image_bytes).decode("utf-8")
390
- data_url = f"data:{mime_type};base64,{base64_image}"
391
- image_data_urls.append(data_url)
392
- logger.info(f"成功下载并转换图片 {i+1} (来自gemini_output.images) ({image.url}) 为 Base64 Data URL (MIME: {mime_type})")
393
- except httpx.RequestError as e:
394
- logger.error(f"直接下载图片失败 (RequestError, 来自gemini_output.images): URL: {image.url}, 类型: {type(e).__name__}, 错误: {e}", exc_info=True)
395
- image_data_urls.append("") # 下载失败时,返回空字符串
396
- except httpx.HTTPStatusError as e:
397
- logger.error(f"直接下载图片失败 (HTTPStatusError, 来自gemini_output.images): URL: {image.url}, 状态码: {e.response.status_code}, 响应文本: {e.response.text}, 错误: {e}", exc_info=True)
398
- image_data_urls.append("") # 下载失败时,返回空字符串
399
- except Exception as e:
400
- logger.error(f"处理图片时发生未知错误 (来自gemini_output.images): URL: {image.url}, 类型: {type(e).__name__}, 错误: {e}", exc_info=True)
401
- image_data_urls.append("") # 下载失败时,返回空字符串
402
- else:
403
- logger.info("Gemini 返回的 ModelOutput 中没有检测到 'images' 字段或其为空。")
404
-
405
- # 将所有内容部分合并
406
- content = "\n\n".join(content_parts).strip()
407
- logger.info(f"最终合并的文本内容: {content[:200]}...") # 记录最终合并的文本内容
408
- logger.info(f"最终 Base64 图片 Data URL 数量: {len(image_data_urls)}") # 记录最终图片数量
409
-
410
- if not content: # 如果没有文本内容
411
- if image_data_urls: # 但有图片 URL
412
- logger.warning("Gemini API 返回了图片但没有文本内容。")
413
- content = "无响应文本" # 提示改为“无响应文本”
414
- finish_reason = "stop" # 仍然是正常停止
415
- else: # 既没有文本内容也没有图片 URL
416
- logger.warning("Gemini API 返回了空文本响应且没有图片。")
417
- content = "Gemini API 返回了空响应或内容处理失败"
418
- finish_reason = "length"
419
- else:
420
- finish_reason = "stop"
421
-
422
- message_content = {"role": "assistant", "content": content}
423
- if image_data_urls: # 如果有 Base64 图片 Data URL,添加到 message 中
424
- message_content["image_urls"] = image_data_urls # 仍然使用 image_urls 字段名,前端无需修改
425
-
426
- openai_response["choices"] = [
427
- {
428
- "index": 0,
429
- "message": message_content,
430
- "finish_reason": finish_reason,
431
- }
432
- ]
433
-
434
- return openai_response
435
-
436
-
437
  @router.get("/image_proxy", summary="图片代理接口")
438
- async def image_proxy(image_url: str, request: Request):
439
  """
440
  代理图片下载,解决前端跨域或防盗链问题。
441
  """
442
- logger.info(f"接收到图片代理请求: {image_url}")
443
-
444
- # 获取下一组Gemini Cookie
445
- cookie_tuple = get_next_gemini_cookie_tuple()
446
- cookies = {}
447
- if cookie_tuple:
448
- sid, sidts = cookie_tuple
449
- cookies = {
450
- "__Secure-1PSID": sid,
451
- "__Secure-1PSIDTS": sidts
452
- }
453
- logger.info(f"使用Cookie代理图片下载: sid={sid[:5]}..., sidts={sidts[:5]}...")
454
-
455
- # 设置更安全的请求头
456
- headers = {
457
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
458
- "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
459
- "Accept-Language": "en-US,en;q=0.9",
460
- "Referer": "https://gemini.google.com/",
461
- "Sec-Fetch-Dest": "image",
462
- "Sec-Fetch-Mode": "no-cors",
463
- "Sec-Fetch-Site": "same-origin",
464
- }
465
-
466
- async with httpx.AsyncClient(
467
- follow_redirects=True,
468
- trust_env=False, # 禁用环境代理
469
- max_redirects=5, # 减少最大重定向次数
470
- cookies=cookies,
471
- http2=True # 启用HTTP/2
472
- ) as client:
473
- try:
474
- # 添加详细日志
475
- logger.debug(f"开始代理下载图片: {image_url}")
476
- response = await client.get(image_url, timeout=30, headers=headers)
477
- response.raise_for_status()
478
-
479
- # 记录响应详情
480
- logger.info(f"图片代理成功: URL={image_url}, 状态码={response.status_code}, 类型={response.headers.get('Content-Type')}")
481
-
482
- # 返回流式响应
483
- return StreamingResponse(
484
- content=response.iter_bytes(),
485
- media_type=response.headers.get("Content-Type", "image/jpeg"),
486
- headers={
487
- "Cache-Control": "public, max-age=86400", # 缓存一天
488
- "Access-Control-Allow-Origin": "*"
489
- }
490
- )
491
- except httpx.HTTPStatusError as e:
492
- logger.error(f"代理下载图片失败: URL: {image_url}, 状态码: {e.response.status_code}")
493
- # 返回错误响应
494
- return JSONResponse(
495
- status_code=e.response.status_code,
496
- content={"error": f"代理下载失败: {e.response.status_code}"}
497
- )
498
- except httpx.TooManyRedirects:
499
- logger.error(f"代理下载图片重定向过多: URL: {image_url}")
500
- return JSONResponse(
501
- status_code=400,
502
- content={"error": "图片重定向过多,无法下载"}
503
- )
504
- except (httpx.RequestError, Exception) as e:
505
- logger.error(f"代理下载图片失败: URL: {image_url}, 错误: {type(e).__name__} - {e}")
506
- # 返回一个透明的 1x1 像素 GIF 图片作为占位符
507
- transparent_gif_base64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
508
- transparent_gif_bytes = base64.b64decode(transparent_gif_base64)
509
- from fastapi.responses import Response # 导入 Response 类
510
- return Response(
511
- content=transparent_gif_bytes,
512
- media_type="image/gif",
513
- headers={
514
- "Cache-Control": "public, max-age=60", # 短时间缓存
515
- "Access-Control-Allow-Origin": "*" # 允许跨域
516
- }
517
- )
518
 
519
-
520
- def convert_openai_messages_to_gemini_history(
521
- messages: List[Dict[str, Any]],
522
- ) -> List[Any]:
523
- """
524
- 尝试将 OpenAI API 的 messages 格式转换为 gemini-webapi 的历史格式。
525
- 由于 gemini_webapi 库的 send_message 方法不直接接受历史,
526
- 此函数将提取所有消息内容并合并为一个字符串。
527
- """
528
- history_text = ""
529
- for message in messages:
530
- role = message.get("role")
531
- content = message.get("content")
532
- if role and content:
533
- history_text += f"{role}: {content}\n"
534
- logger.info(f"将消息历史合并为文本: {history_text[:200]}...")
535
- return history_text
536
-
537
-
538
- # OpenAI 兼容接口 (核心功能待实现)
539
  @router.post("/sessions", summary="创建新会话")
540
- async def create_session(auth_token: str = Depends(get_auth_token)):
541
  """创建新会话并返回会话ID"""
542
  # 清理过期会话
543
- sessions = cleanup_expired_sessions(auth_token)
544
 
 
 
 
 
 
 
545
  # 创建新会话
546
- thread_id = str(uuid.uuid4())
547
- sessions[thread_id] = Session(thread_id)
548
- chat_sessions[auth_token] = sessions
549
 
550
  return JSONResponse({
551
- "thread_id": thread_id,
552
- "name": sessions[thread_id].name
553
  })
554
 
555
  @router.get("/sessions", summary="获取会话列表")
556
- async def list_sessions(auth_token: str = Depends(get_auth_token)):
557
  """获取当前认证令牌下的会话列表"""
558
- sessions = cleanup_expired_sessions(auth_token)
559
- return JSONResponse([
560
- {
561
- "thread_id": tid,
562
- "name": session.name,
563
- "created_at": session.created_at,
564
- "last_active": session.last_active
565
- } for tid, session in sessions.items()
566
- ])
567
 
568
  @router.delete("/sessions/{thread_id}", summary="删除会话")
569
- async def delete_session(thread_id: str, auth_token: str = Depends(get_auth_token)):
570
  """删除指定会话"""
571
- sessions = chat_sessions.get(auth_token, {})
572
- if thread_id in sessions:
573
- del sessions[thread_id]
574
  return JSONResponse({"status": "deleted"})
575
  raise HTTPException(status_code=404, detail="Session not found")
576
 
@@ -588,52 +103,13 @@ async def create_chat_completion(request: Request, auth_token: str = Depends(get
588
  update_metrics_on_request(auth_token, client_host) # 使用 auth_token
589
 
590
  try:
591
- # 从请求体中获取 Gemini cookies 和 OpenAI 格式的请求
592
  request_body = await request.json()
593
- # 从环境变量或轮询器中获取 Gemini cookies
594
- cookie_tuple = get_next_gemini_cookie_tuple()
595
- if not cookie_tuple:
596
- logger.error("未配置有效的 Gemini Cookies (GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES)。请检查环境变量。")
597
- raise HTTPException(
598
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
599
- detail="服务端未配置有效的 Gemini Cookies,无法连接到 Gemini API。",
600
- )
601
- sid, sidts = cookie_tuple
602
-
603
- # --- 完善 GeminiClient 的初始化和管理逻辑 ---
604
- # 每个认证令牌对应一个 GeminiClient 实例,该实例内部会管理会话
605
- # 客户端的 cookie 轮询逻辑应该在 GeminiClient 内部处理,或者在创建客户端时就确定
606
- # 这里简化为每个认证令牌维护一个 GeminiClient 实例,并尝试复用
607
- client = gemini_clients.get(auth_token) # 使用 auth_token
608
-
609
- if client:
610
- logger.info(f"认证令牌 {auth_token} 复用现有 GeminiClient 实例") # 使用 auth_token
611
- # 理论上,如果客户端已经存在,其内部的 cookie 应该已经通过轮询器设置
612
- # 如果需要强制刷新 cookie,可以在这里重新 init,但会增加开销
613
- # 暂时不重新 init,依赖于初始化的 cookie_cycler
614
- pass
615
- else:
616
- logger.info(f"认证令牌 {auth_token} 创建新的 GeminiClient 实例") # 使用 auth_token
617
- try:
618
- # 创建新的 GeminiClient 实例,使用从轮询器获取的 cookie
619
- client = GeminiClient(sid, sidts)
620
- await client.init(timeout=30) # 设置初始化超时时间
621
- gemini_clients[auth_token] = client # 存储新的客户端实例,使用 auth_token
622
- logger.info(f"认证令牌 {auth_token} 成功初始化并存储 GeminiClient") # 使用 auth_token
623
- except Exception as client_init_error:
624
- logger.error(
625
- f"认证令牌 {auth_token} 初始化 GeminiClient 失败: {client_init_error}", # 使用 auth_token
626
- exc_info=True,
627
- )
628
- raise HTTPException(
629
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
630
- detail=f"初始化 Gemini 客户端失败: {client_init_error}。请检查 GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 是否有效。",
631
- )
632
- # --- 客户端管理逻辑结束 ---
633
 
634
  # 解析 OpenAI 格式的请求体
635
- # 提取 OpenAI 兼容的参数
636
- # 提取 OpenAI 兼容的参数
637
  model_name = request_body.get("model", "gemini-2.5-flash") # 默认模型改为 gemini-2.5-flash
638
 
639
  # 模型名称映射
@@ -648,7 +124,6 @@ async def create_chat_completion(request: Request, auth_token: str = Depends(get
648
  temperature = request_body.get("temperature")
649
  max_tokens = request_body.get("max_tokens")
650
  top_p = request_body.get("top_p") # 提取 top_p 参数
651
- # frequency_penalty, presence_penalty 等参数在 gemini-webapi 文档中未明确提及,暂不映射。
652
 
653
  thread_id = request_body.get("thread_id") # 提取会话 ID
654
 
@@ -657,55 +132,12 @@ async def create_chat_completion(request: Request, auth_token: str = Depends(get
657
  status_code=status.HTTP_400_BAD_REQUEST, detail="缺少 messages 参数"
658
  )
659
 
660
- # --- 多轮对话管理 ---
661
  # 获取或创建用户对应的会话
662
- sessions = chat_sessions.get(auth_token, {})
663
- if not sessions:
664
- sessions = {}
665
- chat_sessions[auth_token] = sessions
666
-
667
- # 如果没有提供 thread_id,生成一个新的
668
- if not thread_id:
669
- thread_id = str(uuid.uuid4())
670
- sessions[thread_id] = Session(thread_id)
671
- logger.info(f"认证令牌 {auth_token} 生成新会话: {thread_id}")
672
-
673
- # 获取现有会话
674
- session = sessions.get(thread_id)
675
- chat = None # 确保 chat 变量在所有路径中都被初始化
676
-
677
- # 确保 session 存在且 _chat_instance 已初始化
678
- if not session or not session._chat_instance: # 检查 session 是否存在或 _chat_instance 是否为 None
679
- logger.info(f"认证令牌 {auth_token} 会话 {thread_id} 不存在或聊天实例未初始化,创建新会话/初始化聊天实例")
680
- if not session: # 如果 session 也不存在,则创建
681
- session = Session(thread_id)
682
- sessions[thread_id] = session
683
-
684
- # 无论 session 是否新创建,都初始化 chat 实例
685
- chat = client.start_chat(model=model_name)
686
- session._chat_instance = chat
687
- logger.info(f"认证令牌 {auth_token} 会话 {thread_id} 聊天实例初始化成功")
688
- else:
689
- chat = session._chat_instance
690
- session.last_active = time.time()
691
- logger.info(f"认证令牌 {auth_token} 复用现有会话 {thread_id}")
692
-
693
- # 如果客户端在复用会话时仍发送了历史消息,记录一个警告,因为我们将使用会话中已��的历史。
694
- # 更复杂的逻辑可以尝试合并或验证历史,但目前简化处理。
695
- # 这里的逻辑是,如果会话已存在,我们假设其历史是完整的,不再处理请求中的历史消息。
696
- # 如果需要支持客户端在复用会话时更新历史,需要更复杂的逻辑。
697
- if messages[:-1]: # 如果除了最后一条消息外还有其他消息,说明客户端尝试发送历史
698
- logger.warning(
699
- f"认证令牌 {auth_token} 会话 {thread_id}: 复用现有会话,但请求中包含历史消息。将忽略请求中的历史,使用会话的当前历史。" # 使用 auth_token
700
- )
701
 
702
  # 构建完整的对话历史文本
703
- history_text = ""
704
- for message in messages:
705
- role = message.get("role")
706
- content = message.get("content")
707
- if role and content:
708
- history_text += f"{role}: {content}\n"
709
 
710
  # 获取最后一条用户消息作为当前输入
711
  current_user_message = messages[-1]
@@ -727,7 +159,6 @@ async def create_chat_completion(request: Request, auth_token: str = Depends(get
727
  item_type = item.get("type")
728
  if item_type == "text":
729
  current_message_text += item.get("text", "") + "\n"
730
- # 注意:这里不再处理 image_url,因为前端会单独发送 base64 数据
731
  current_message_text = current_message_text.strip()
732
  else:
733
  raise HTTPException(
@@ -743,9 +174,6 @@ async def create_chat_completion(request: Request, auth_token: str = Depends(get
743
  if image_data_b64 and image_mime_type:
744
  try:
745
  image_bytes = base64.b64decode(image_data_b64)
746
- # 创建临时文件
747
- # tempfile.NamedTemporaryFile 默认以二进制模式打开,并返回文件对象
748
- # delete=False 确保文件在关闭后不会立即删除,我们手动删除
749
  with tempfile.NamedTemporaryFile(delete=False, suffix=f".{image_mime_type.split('/')[-1]}") as temp_file:
750
  temp_file.write(image_bytes)
751
  temp_file_path = temp_file.name
@@ -758,7 +186,7 @@ async def create_chat_completion(request: Request, auth_token: str = Depends(get
758
  )
759
 
760
  # 将历史消息和当前输入合并
761
- prompt_text = history_text + f"user: {user_input_content}"
762
 
763
  # 调用 gemini-webapi 发送消息,并添加重试逻辑
764
  gemini_response = None
@@ -787,81 +215,49 @@ async def create_chat_completion(request: Request, auth_token: str = Depends(get
787
  )
788
  # 如果调用成功,跳出重试循环
789
  break
790
- except (gemini_webapi.exceptions.TimeoutError, httpx.ReadTimeout) as e:
791
- # 捕获超时错误,进行重试
792
  if attempt < max_retries - 1:
793
  logger.warning(
794
- f"认证令牌 {auth_token} 会话 {thread_id} 调用 Gemini API 超时 (尝试 {attempt + 1}/{max_retries}),将在 {retry_delay} 秒后重试: {e}" # 使用 auth_token
795
  )
796
  await asyncio.sleep(retry_delay)
797
  else:
798
- # 重试次数耗尽,记录错误并抛出异常
799
  logger.error(
800
- f"认证令牌 {auth_token} 会话 {thread_id} 调用 Gemini API 失败,重试次数耗尽: {e}", # 使用 auth_token
801
  exc_info=True,
802
  )
803
- # 发生异常时,考虑移除当前会话,避免无效会话残留
804
- if (
805
- thread_id
806
- and auth_token in chat_sessions # 使用 auth_token
807
- and thread_id in chat_sessions[auth_token] # 使用 auth_token
808
- ):
809
- del chat_sessions[auth_token][thread_id] # 使用 auth_token
810
- logger.info(f"认证令牌 {auth_token} 会话 {thread_id} 因错误已移除") # 使用 auth_token
811
  raise HTTPException(
812
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
813
  detail=f"Gemini API 调用失败,重试次数耗尽: {e}",
814
  )
815
- except Exception as gemini_error:
816
- # 捕获其他类型的异常,记录错误并抛出
817
- logger.error(
818
- f"认证令牌 {auth_token} 会话 {thread_id} 调用 Gemini API 失败: {gemini_error}", # 使用 auth_token
819
- exc_info=True,
820
- )
821
- # 发生异常时,考虑移除当前会话,避免无效会话残留
822
- if (
823
- thread_id
824
- and auth_token in chat_sessions # 极客学编程
825
- and thread_id in chat_sessions[auth_token] # 使用 auth_token
826
- ):
827
- del chat_sessions[auth_token][thread_id] # 使用 auth_token
828
- logger.info(f"认证令牌 {auth_token} 会话 {thread_id} 因错误已移除") # 使用 auth_token
829
- raise HTTPException(
830
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
831
- detail=f"Gemini API 调用失败: {gemini_error}",
832
- )
833
 
834
  if not gemini_response:
835
- # 如果循环结束但 gemini_response 仍然是 None (理论上不会发生除非所重试都捕获异常但没有抛出)
836
- logger.error(f"认证令牌 {auth_token} 会话 {thread_id} 调用 Gemini API 未返回响应,尽管没有捕获到异常。") # 使用 auth_token
837
  raise HTTPException(
838
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
839
  detail="Gemini API 返回空响应",
840
  )
841
 
842
  # 将 gemini-webapi 响应转换为 OpenAI 格式
843
- openai_response = await format_gemini_response_to_openai(gemini_response) # 添加 await
844
 
845
  # 记录成功调用
846
  end_time = time.time()
847
  response_time = end_time - start_time
848
- update_metrics_on_response(auth_token, "success", response_time, client_host, request_body, openai_response) # 使用 auth_token
849
 
850
  # 返回 OpenAI 格式的响应
851
- # 在响应中包含 thread_id,方便客户端继续同一会话
852
- openai_response["thread_id"] = thread_id
853
  return JSONResponse(content=openai_response)
854
 
855
  except HTTPException as e:
856
- # 认证失败等 HTTPException 直接抛出
857
- # 确保 request_body 在这里可用
858
- update_metrics_on_response(auth_token, "failed", time.time() - start_time, client_host, request_body, {"detail": e.detail}) # 使用 auth_token
859
  raise e
860
  except Exception as e:
861
- # 记录其他异常
862
  logger.error(f"处理请求时发生错误: {e}", exc_info=True)
863
- # 确保 request_body 在这里可用
864
- update_metrics_on_response(auth_token, "failed", time.time() - start_time, client_host, request_body, {"detail": str(e)}) # 使用 auth_token
865
  raise HTTPException(
866
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
867
  detail=f"内部服务器错误: {e}",
 
1
  # app/api/gemini_proxy.py
2
 
3
  import os # 导入 os 模块用于读取环境变量
 
4
  import time
5
  import base64
 
6
  import logging
7
  import asyncio
 
8
  import tempfile # 导入 tempfile 模块用于创建临时文件
9
  from fastapi import APIRouter, Request, HTTPException, Depends, status
 
10
  from fastapi.responses import JSONResponse
11
  from typing import Dict, Any, List, Optional
 
12
 
13
  # 导入自定义模块
14
  from app.api.auth import (
15
  get_user_api_key,
16
  get_admin_api_key,
17
+ get_auth_token,
18
  ) # 从 auth 模块导入认证依赖
19
  from app.api.metrics import (
20
  update_metrics_on_request,
21
  update_metrics_on_response,
22
  get_current_metrics,
23
  ) # 从 metrics 模块导入指标和记录获取函数
24
+ from app.core.gemini_client_manager import get_gemini_client, reload_gemini_cookies # 从 core 模块导入 Gemini 客户端管理器
25
+ from app.core.session_manager import (
26
+ Session,
27
+ get_or_create_session,
28
+ delete_session_by_id,
29
+ list_all_sessions,
30
+ cleanup_expired_sessions,
31
+ ) # 从 core 模块导入会话管理器
32
+ from app.services.image_proxy_service import proxy_image_request # 从 services 模块导入图片代理服务
33
+ from app.utils.gemini_converter import format_gemini_response_to_openai, convert_openai_messages_to_gemini_history # 从 utils 模块导入格式转换工具
34
 
35
  logger = logging.getLogger(__name__)
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  # 创建 APIRouter 实例
38
  router = APIRouter()
39
 
 
49
  reload_gemini_cookies()
50
  return JSONResponse(content={"status": "ok", "message": "Gemini Cookies 已重新加载,所有客户端实例已清空。"})
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  @router.get("/image_proxy", summary="图片代理接口")
53
+ async def image_proxy_endpoint(image_url: str, request: Request):
54
  """
55
  代理图片下载,解决前端跨域或防盗链问题。
56
  """
57
+ return await proxy_image_request(image_url, request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  @router.post("/sessions", summary="创建新会话")
60
+ async def create_session_endpoint(auth_token: str = Depends(get_auth_token)):
61
  """创建新会话并返回会话ID"""
62
  # 清理过期会话
63
+ cleanup_expired_sessions(auth_token)
64
 
65
+ # 获取 Gemini 客户端实例
66
+ try:
67
+ client = await get_gemini_client(auth_token)
68
+ except Exception as e:
69
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
70
+
71
  # 创建新会话
72
+ session = get_or_create_session(auth_token, None, client) # thread_id 为 None 表示创建新会话
 
 
73
 
74
  return JSONResponse({
75
+ "thread_id": session.thread_id,
76
+ "name": session.name
77
  })
78
 
79
  @router.get("/sessions", summary="获取会话列表")
80
+ async def list_sessions_endpoint(auth_token: str = Depends(get_auth_token)):
81
  """获取当前认证令牌下的会话列表"""
82
+ sessions_list = list_all_sessions(auth_token)
83
+ return JSONResponse(sessions_list)
 
 
 
 
 
 
 
84
 
85
  @router.delete("/sessions/{thread_id}", summary="删除会话")
86
+ async def delete_session_endpoint(thread_id: str, auth_token: str = Depends(get_auth_token)):
87
  """删除指定会话"""
88
+ if delete_session_by_id(auth_token, thread_id):
 
 
89
  return JSONResponse({"status": "deleted"})
90
  raise HTTPException(status_code=404, detail="Session not found")
91
 
 
103
  update_metrics_on_request(auth_token, client_host) # 使用 auth_token
104
 
105
  try:
106
+ # 从请求体中获取 OpenAI 格式的请求
107
  request_body = await request.json()
108
+
109
+ # 获取 Gemini 客户端实例
110
+ client = await get_gemini_client(auth_token)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  # 解析 OpenAI 格式的请求体
 
 
113
  model_name = request_body.get("model", "gemini-2.5-flash") # 默认模型改为 gemini-2.5-flash
114
 
115
  # 模型名称映射
 
124
  temperature = request_body.get("temperature")
125
  max_tokens = request_body.get("max_tokens")
126
  top_p = request_body.get("top_p") # 提取 top_p 参数
 
127
 
128
  thread_id = request_body.get("thread_id") # 提取会话 ID
129
 
 
132
  status_code=status.HTTP_400_BAD_REQUEST, detail="缺少 messages 参数"
133
  )
134
 
 
135
  # 获取或创建用户对应的会话
136
+ session = get_or_create_session(auth_token, thread_id, client)
137
+ chat = session._chat_instance # 获取聊天实例
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  # 构建完整的对话历史文本
140
+ history_text = convert_openai_messages_to_gemini_history(messages[:-1]) # 排除最后一条消息作为历史
 
 
 
 
 
141
 
142
  # 获取最后一条用户消息作为当前输入
143
  current_user_message = messages[-1]
 
159
  item_type = item.get("type")
160
  if item_type == "text":
161
  current_message_text += item.get("text", "") + "\n"
 
162
  current_message_text = current_message_text.strip()
163
  else:
164
  raise HTTPException(
 
174
  if image_data_b64 and image_mime_type:
175
  try:
176
  image_bytes = base64.b64decode(image_data_b64)
 
 
 
177
  with tempfile.NamedTemporaryFile(delete=False, suffix=f".{image_mime_type.split('/')[-1]}") as temp_file:
178
  temp_file.write(image_bytes)
179
  temp_file_path = temp_file.name
 
186
  )
187
 
188
  # 将历史消息和当前输入合并
189
+ prompt_text = history_text + f"user: {current_message_text}" # 使用 current_message_text
190
 
191
  # 调用 gemini-webapi 发送消息,并添加重试逻辑
192
  gemini_response = None
 
215
  )
216
  # 如果调用成功,跳出重试循环
217
  break
218
+ except (Exception) as e: # 捕获所有异常,包括超时
 
219
  if attempt < max_retries - 1:
220
  logger.warning(
221
+ f"认证令牌 {auth_token} 会话 {session.thread_id} 调用 Gemini API 失败 (尝试 {attempt + 1}/{max_retries}),将在 {retry_delay} 秒后重试: {e}"
222
  )
223
  await asyncio.sleep(retry_delay)
224
  else:
 
225
  logger.error(
226
+ f"认证令牌 {auth_token} 会话 {session.thread_id} 调用 Gemini API 失败,重试次数耗尽: {e}",
227
  exc_info=True,
228
  )
229
+ # 发生异常时,移除当前会话,避免无效会话残留
230
+ delete_session_by_id(auth_token, session.thread_id)
 
 
 
 
 
 
231
  raise HTTPException(
232
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
233
  detail=f"Gemini API 调用失败,重试次数耗尽: {e}",
234
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
  if not gemini_response:
237
+ logger.error(f"认证令牌 {auth_token} 会话 {session.thread_id} 调用 Gemini API 未返回响应尽管没有捕获异常。")
 
238
  raise HTTPException(
239
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
240
  detail="Gemini API 返回空响应",
241
  )
242
 
243
  # 将 gemini-webapi 响应转换为 OpenAI 格式
244
+ openai_response = await format_gemini_response_to_openai(gemini_response)
245
 
246
  # 记录成功调用
247
  end_time = time.time()
248
  response_time = end_time - start_time
249
+ update_metrics_on_response(auth_token, "success", response_time, client_host, request_body, openai_response)
250
 
251
  # 返回 OpenAI 格式的响应
252
+ openai_response["thread_id"] = session.thread_id
 
253
  return JSONResponse(content=openai_response)
254
 
255
  except HTTPException as e:
256
+ update_metrics_on_response(auth_token, "failed", time.time() - start_time, client_host, request_body, {"detail": e.detail})
 
 
257
  raise e
258
  except Exception as e:
 
259
  logger.error(f"处理请求时发生错误: {e}", exc_info=True)
260
+ update_metrics_on_response(auth_token, "failed", time.time() - start_time, client_host, request_body, {"detail": str(e)})
 
261
  raise HTTPException(
262
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
263
  detail=f"内部服务器错误: {e}",
app/core/gemini_client_manager.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/gemini_client_manager.py
2
+
3
+ import os # 导入 os 模块用于读取环境变量
4
+ import itertools # 导入 itertools 模块用于循环 cookie
5
+ import logging
6
+ from typing import Dict, Any, List, Optional, Tuple
7
+ from gemini_webapi import GeminiClient
8
+ import gemini_webapi.exceptions # 导入 gemini_webapi 的异常模块
9
+ import asyncio # 导入 asyncio 模块用于异步操作
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # 新的环境变量读取方式
14
+ GEMINI_PSID_COOKIES_STR = os.getenv("GEMINI_PSID_COOKIES", "")
15
+ GEMINI_PSIDTS_COOKIES_STR = os.getenv("GEMINI_PSIDTS_COOKIES", "")
16
+
17
+ raw_psid_cookies = [c.strip() for c in GEMINI_PSID_COOKIES_STR.split(",") if c.strip()]
18
+ raw_psidts_cookies = [
19
+ c.strip() for c in GEMINI_PSIDTS_COOKIES_STR.split(",") if c.strip()
20
+ ]
21
+
22
+ gemini_cookie_pairs: List[Tuple[str, str]] = []
23
+ if raw_psid_cookies and raw_psidts_cookies:
24
+ if len(raw_psid_cookies) == len(raw_psidts_cookies):
25
+ valid_pairs = []
26
+ for sid, sidts in zip(raw_psid_cookies, raw_psidts_cookies):
27
+ if sid and sidts: # Both must be non-empty
28
+ valid_pairs.append((sid, sidts))
29
+ else:
30
+ logger.warning(
31
+ f"检测到无效的 Cookie 对 (一个或两个值为空): SID='{sid}', SIDTS='{sidts}'. 此对将被忽略。"
32
+ )
33
+
34
+ gemini_cookie_pairs = valid_pairs
35
+ if gemini_cookie_pairs:
36
+ logger.info(
37
+ f"成功加载并验证 {len(gemini_cookie_pairs)} 组 Gemini Cookie 对。"
38
+ )
39
+ else:
40
+ logger.warning(
41
+ "所有提供的 Cookie 对均无效 (一个或两个值为空) 或 GEMINI_PSID_COOKIES / GEMINI_PSIDTS_COOKIES 列表长度匹配但内容无效。"
42
+ )
43
+ else:
44
+ logger.warning(
45
+ "GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量中的 Cookie 数量不匹配。请检查配置。"
46
+ )
47
+ else:
48
+ if not raw_psid_cookies and not raw_psidts_cookies:
49
+ logger.warning(
50
+ "GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量均未设置或为空。Gemini API 调用可能会失败。"
51
+ )
52
+ elif not raw_psid_cookies:
53
+ logger.warning(
54
+ "GEMINI_PSID_COOKIES 环境变量未设置或为空,但 GEMINI_PSIDTS_COOKIES 可能已设置。Cookie 对不完整或无效。"
55
+ )
56
+ else: # not raw_psidts_cookies
57
+ logger.warning(
58
+ "GEMINI_PSIDTS_COOKIES 环境变量未设置或为空,但 GEMINI_PSID_COOKIES 可能已设置。Cookie 对不完整或无效。"
59
+ )
60
+
61
+ # 全局变量,用于存储 cookie 轮询器和客户端实例
62
+ cookie_cycler = itertools.cycle(gemini_cookie_pairs) if gemini_cookie_pairs else None
63
+ gemini_clients: Dict[str, GeminiClient] = {} # 存储 {auth_token: GeminiClient实例}
64
+
65
+ def reload_gemini_cookies():
66
+ """
67
+ 重新加载 Gemini Cookies 环境变量并重新初始化 cookie 轮询器。
68
+ 此函数应在需要刷新 cookies 时调用。
69
+ """
70
+ global gemini_cookie_pairs, cookie_cycler, gemini_clients # 声明为全局变量以便修改
71
+
72
+ logger.info("开始重新加载 Gemini Cookies...")
73
+
74
+ # 重新读取环境变量
75
+ new_psid_cookies_str = os.getenv("GEMINI_PSID_COOKIES", "")
76
+ new_psidts_cookies_str = os.getenv("GEMINI_PSIDTS_COOKIES", "")
77
+
78
+ new_raw_psid_cookies = [c.strip() for c in new_psid_cookies_str.split(",") if c.strip()]
79
+ new_raw_psidts_cookies = [c.strip() for c in new_psidts_cookies_str.split(",") if c.strip()]
80
+
81
+ new_gemini_cookie_pairs = []
82
+ if new_raw_psid_cookies and new_raw_psidts_cookies:
83
+ if len(new_raw_psid_cookies) == len(new_raw_psidts_cookies):
84
+ valid_pairs = []
85
+ for sid, sidts in zip(new_raw_psid_cookies, new_raw_psidts_cookies):
86
+ if sid and sidts:
87
+ valid_pairs.append((sid, sidts))
88
+ else:
89
+ logger.warning(
90
+ f"重新加载时检测到无效的 Cookie 对 (一个或两个值为空): SID='{sid}', SIDTS='{sidts}'. 此对将被忽略。"
91
+ )
92
+ new_gemini_cookie_pairs = valid_pairs
93
+ if new_gemini_cookie_pairs:
94
+ logger.info(
95
+ f"成功重新加载并验证 {len(new_gemini_cookie_pairs)} 组 Gemini Cookie 对。"
96
+ )
97
+ else:
98
+ logger.warning(
99
+ "重新加载时所有提供的 Cookie 对均无效或列表长度匹配但内容无效。"
100
+ )
101
+ else:
102
+ logger.warning(
103
+ "重新加载时 GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量中的 Cookie 数量不匹配。请检查配置。"
104
+ )
105
+ else:
106
+ logger.warning(
107
+ "重新加载时 GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量均未设置或为空。Gemini API 调用可能会失败。"
108
+ )
109
+
110
+ gemini_cookie_pairs = new_gemini_cookie_pairs
111
+ cookie_cycler = itertools.cycle(gemini_cookie_pairs) if gemini_cookie_pairs else None
112
+
113
+ # 清空所有现有的 GeminiClient 实例,强制下次请求时重新初始化
114
+ gemini_clients.clear()
115
+ logger.info("所有现有 GeminiClient 实例已清空,将在下次请求时重新初始化。")
116
+ logger.info("Gemini Cookies 重新加载完成。")
117
+
118
+ def get_next_gemini_cookie_tuple() -> Optional[Tuple[str, str]]:
119
+ """获取下一组可用的 Gemini Cookie (SID, SIDTS) 元组。"""
120
+ if not cookie_cycler:
121
+ logger.error(
122
+ "Gemini Cookie 轮询器未初始化或为空。请检查 GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 环境变量。"
123
+ )
124
+ return None
125
+
126
+ try:
127
+ return next(cookie_cycler)
128
+ except StopIteration:
129
+ logger.error(
130
+ "Gemini Cookie 轮询器意外耗尽。这可能表明初始化的 gemini_cookie_pairs 为空或所有元素均无效。"
131
+ )
132
+ return None
133
+ except Exception as e:
134
+ logger.error(f"获取下一组 Gemini Cookie 时发生意外错误: {e}", exc_info=True)
135
+ return None
136
+
137
+ async def get_gemini_client(auth_token: str) -> GeminiClient:
138
+ """
139
+ 获取或初始化与给定认证令牌关联的 GeminiClient 实例。
140
+ 如果客户端不存在,则创建一个新的。
141
+ """
142
+ client = gemini_clients.get(auth_token)
143
+
144
+ if client:
145
+ logger.info(f"认证令牌 {auth_token} 复用现有 GeminiClient 实例。")
146
+ return client
147
+ else:
148
+ logger.info(f"认证令牌 {auth_token} 创建新的 GeminiClient 实例。")
149
+ cookie_tuple = get_next_gemini_cookie_tuple()
150
+ if not cookie_tuple:
151
+ logger.error("未配置有效的 Gemini Cookies。请检查环境变量。")
152
+ raise Exception("服务端未配置有效的 Gemini Cookies,无法连接到 Gemini API。")
153
+ sid, sidts = cookie_tuple
154
+
155
+ try:
156
+ client = GeminiClient(sid, sidts)
157
+ await client.init(timeout=30) # 设置初始化超时时间
158
+ gemini_clients[auth_token] = client # 存储新的客户端实例
159
+ logger.info(f"认证令牌 {auth_token} 成功初始化并存储 GeminiClient。")
160
+ return client
161
+ except Exception as client_init_error:
162
+ logger.error(
163
+ f"认证令牌 {auth_token} 初始化 GeminiClient 失败: {client_init_error}",
164
+ exc_info=True,
165
+ )
166
+ raise Exception(
167
+ f"初始化 Gemini 客户端失败: {client_init_error}。请检查 GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES 是否有效。"
168
+ )
169
+
170
+ # 在应用启动时调用一次,确保初始加载
171
+ reload_gemini_cookies()
app/core/session_manager.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/session_manager.py
2
+
3
+ import time
4
+ import uuid # 导入 uuid 模块用于生成会话 ID
5
+ import logging
6
+ from typing import Dict, Any, List, Optional
7
+ from gemini_webapi import GeminiClient, ChatSession # 导入 ChatSession 类型
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # 每个认证令牌的最大会话数
12
+ MAX_SESSIONS_PER_TOKEN = 5
13
+
14
+ # 会话数据结构
15
+ class Session:
16
+ def __init__(self, thread_id: str, name: Optional[str] = None):
17
+ self.thread_id = thread_id
18
+ self.name = name or f"会话-{time.strftime('%m%d')}-{str(uuid.uuid4())[:4]}"
19
+ self.created_at = time.time()
20
+ self.last_active = time.time()
21
+ self._chat_instance: Optional[ChatSession] = None # 存储 Gemini Chat 实例
22
+
23
+ # 全局变量,用于存储每个认证令牌的聊天会话
24
+ chat_sessions: Dict[str, Dict[str, Session]] = {}
25
+
26
+ def cleanup_expired_sessions(auth_token: str):
27
+ """清理过期会话(24小时未活动)和超出限制的会话"""
28
+ now = time.time()
29
+ sessions = chat_sessions.get(auth_token, {})
30
+
31
+ # 清理超过24小时的会话
32
+ expired_threads = [
33
+ tid for tid, session in sessions.items()
34
+ if now - session.last_active > 86400
35
+ ]
36
+ for tid in expired_threads:
37
+ logger.info(f"清理过期会话: {tid}")
38
+ del sessions[tid]
39
+
40
+ # 清理超出数量限制的旧会话
41
+ if len(sessions) > MAX_SESSIONS_PER_TOKEN:
42
+ # 按照最后活跃时间排序,删除最旧的会话
43
+ oldest_sessions = sorted(sessions.items(), key=lambda x: x[1].last_active)
44
+ for i in range(len(sessions) - MAX_SESSIONS_PER_TOKEN):
45
+ tid_to_delete = oldest_sessions[i][0]
46
+ logger.info(f"清理超出限制的会话: {tid_to_delete}")
47
+ del sessions[tid_to_delete]
48
+
49
+ chat_sessions[auth_token] = sessions # 确保更新全局字典
50
+ return sessions
51
+
52
+ def get_or_create_session(auth_token: str, thread_id: Optional[str], client: GeminiClient) -> Session:
53
+ """
54
+ 获取或创建一个新的聊天会话。
55
+ 如果 thread_id 为 None,则创建一个新会话。
56
+ 如果会话存在但其 _chat_instance 为 None,则重新初始化 _chat_instance。
57
+ """
58
+ sessions = chat_sessions.get(auth_token, {})
59
+ if not sessions:
60
+ sessions = {}
61
+ chat_sessions[auth_token] = sessions
62
+
63
+ session = None
64
+ if thread_id:
65
+ session = sessions.get(thread_id)
66
+
67
+ if not session:
68
+ thread_id = str(uuid.uuid4())
69
+ session = Session(thread_id)
70
+ sessions[thread_id] = session
71
+ logger.info(f"认证令牌 {auth_token} 生成新会话: {thread_id}")
72
+
73
+ # 确保 chat 实例已初始化
74
+ if not session._chat_instance:
75
+ logger.info(f"认证令牌 {auth_token} 会话 {session.thread_id} 聊天实例未初始化,正在创建...")
76
+ session._chat_instance = client.start_chat() # 使用 client 启动聊天
77
+ logger.info(f"认证令牌 {auth_token} 会话 {session.thread_id} 聊天实例初始化成功。")
78
+
79
+ session.last_active = time.time() # 更新最后活跃时间
80
+ return session
81
+
82
+ def get_session(auth_token: str, thread_id: str) -> Optional[Session]:
83
+ """根据认证令牌和会话ID获取会话。"""
84
+ sessions = chat_sessions.get(auth_token)
85
+ if sessions:
86
+ session = sessions.get(thread_id)
87
+ if session:
88
+ session.last_active = time.time() # 更新最后活跃时间
89
+ return session
90
+ return None
91
+
92
+ def delete_session_by_id(auth_token: str, thread_id: str) -> bool:
93
+ """根据认证令牌和会话ID删除会话。"""
94
+ sessions = chat_sessions.get(auth_token)
95
+ if sessions and thread_id in sessions:
96
+ del sessions[thread_id]
97
+ logger.info(f"认证令牌 {auth_token} 会话 {thread_id} 已删除。")
98
+ return True
99
+ return False
100
+
101
+ def list_all_sessions(auth_token: str) -> List[Dict[str, Any]]:
102
+ """列出指定认证令牌下的所有会话。"""
103
+ sessions = cleanup_expired_sessions(auth_token) # 清理并获取最新会话列表
104
+ return [
105
+ {
106
+ "thread_id": tid,
107
+ "name": session.name,
108
+ "created_at": session.created_at,
109
+ "last_active": session.last_active
110
+ } for tid, session in sessions.items()
111
+ ]
app/services/image_proxy_service.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/services/image_proxy_service.py
2
+
3
+ import logging
4
+ import base64
5
+ import httpx
6
+ from fastapi import Request, HTTPException
7
+ from fastapi.responses import StreamingResponse, JSONResponse, Response # 导入 Response 类
8
+ from typing import Optional, Tuple
9
+
10
+ # 导入自定义模块
11
+ from app.core.gemini_client_manager import get_next_gemini_cookie_tuple # 从 core 模块导入获取 cookie 的函数
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # 定义需要跳过后端下载的域名,这些域名的图片将通过代理加载
16
+ SKIP_DOWNLOAD_DOMAINS = ["googleusercontent.com", "blogger.googleusercontent.com", "lh3.googleusercontent.com"]
17
+
18
+ async def proxy_image_request(image_url: str, request: Request):
19
+ """
20
+ 代理图片下载,解决前端跨域或防盗链问题。
21
+ """
22
+ logger.info(f"接收到图片代理请求: {image_url}")
23
+
24
+ # 获取下一组Gemini Cookie
25
+ cookie_tuple: Optional[Tuple[str, str]] = get_next_gemini_cookie_tuple()
26
+ cookies = {}
27
+ if cookie_tuple:
28
+ sid, sidts = cookie_tuple
29
+ cookies = {
30
+ "__Secure-1PSID": sid,
31
+ "__Secure-1PSIDTS": sidts
32
+ }
33
+ logger.info(f"使用Cookie代理图片下载: sid={sid[:5]}..., sidts={sidts[:5]}...")
34
+
35
+ # 设置更安全的请求头
36
+ headers = {
37
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
38
+ "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
39
+ "Accept-Language": "en-US,en;q=0.9",
40
+ "Referer": "https://gemini.google.com/",
41
+ "Sec-Fetch-Dest": "image",
42
+ "Sec-Fetch-Mode": "no-cors",
43
+ "Sec-Fetch-Site": "same-origin",
44
+ }
45
+
46
+ async with httpx.AsyncClient(
47
+ follow_redirects=True,
48
+ trust_env=False, # 禁用环境代理
49
+ max_redirects=5, # 减少最大重定向次数
50
+ cookies=cookies,
51
+ http2=True # 启用HTTP/2
52
+ ) as client:
53
+ try:
54
+ # 添加详细日志
55
+ logger.debug(f"开始代理下载图片: {image_url}")
56
+ response = await client.get(image_url, timeout=30, headers=headers)
57
+ response.raise_for_status()
58
+
59
+ # 记录响应详情
60
+ logger.info(f"图片代理成功: URL={image_url}, 状态码={response.status_code}, 类型={response.headers.get('Content-Type')}")
61
+
62
+ # 返回流式响应
63
+ return StreamingResponse(
64
+ content=response.iter_bytes(),
65
+ media_type=response.headers.get("Content-Type", "image/jpeg"),
66
+ headers={
67
+ "Cache-Control": "public, max-age=86400", # 缓存一天
68
+ "Access-Control-Allow-Origin": "*"
69
+ }
70
+ )
71
+ except httpx.HTTPStatusError as e:
72
+ logger.error(f"代理下载图片失败: URL: {image_url}, 状态码: {e.response.status_code}")
73
+ # 返回错误响应
74
+ return JSONResponse(
75
+ status_code=e.response.status_code,
76
+ content={"error": f"代理下载失败: {e.response.status_code}"}
77
+ )
78
+ except httpx.TooManyRedirects:
79
+ logger.error(f"代理下载图片重定向过多: URL: {image_url}")
80
+ return JSONResponse(
81
+ status_code=400,
82
+ content={"error": "图片重定向过多,无法下载"}
83
+ )
84
+ except (httpx.RequestError, Exception) as e:
85
+ logger.error(f"代理下载图片失败: URL: {image_url}, 错误: {type(e).__name__} - {e}")
86
+ # 返回一个透明的 1x1 像素 GIF 图片作为占位符
87
+ transparent_gif_base64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
88
+ transparent_gif_bytes = base64.b64decode(transparent_gif_base64)
89
+ return Response(
90
+ content=transparent_gif_bytes,
91
+ media_type="image/gif",
92
+ headers={
93
+ "Cache-Control": "public, max-age=60", # 短时间缓存
94
+ "Access-Control-Allow-Origin": "*" # 允许跨域
95
+ }
96
+ )
app/utils/gemini_converter.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/utils/gemini_converter.py
2
+
3
+ import time
4
+ import base64
5
+ import re
6
+ import logging
7
+ import httpx
8
+ from typing import Dict, Any, List, Optional
9
+ from gemini_webapi import ModelOutput
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # 定义需要跳过后端下载的域名,这些域名的图片将通过代理加载
14
+ PROXY_IMAGE_DOMAINS = ["googleusercontent.com", "blogger.googleusercontent.com", "lh3.googleusercontent.com"]
15
+
16
+ async def format_gemini_response_to_openai(gemini_output: ModelOutput) -> Dict[str, Any]:
17
+ """
18
+ 将 gemini-webapi 的 ModelOutput 格式转换为 OpenAI API 的聊天完成响应格式。
19
+ 这里只实现核心字段的转换。
20
+ """
21
+ openai_response = {
22
+ "id": "chatcmpl-placeholder", # 占位符
23
+ "object": "chat.completion",
24
+ "created": int(time.time()),
25
+ "model": "gemini-pro", # 假设都使用 gemini-pro
26
+ "choices": [],
27
+ "usage": { # 占位符,gemini-webapi 可能不提供详细 usage
28
+ "prompt_tokens": 0,
29
+ "completion_tokens": 0,
30
+ "total_tokens": 0,
31
+ },
32
+ }
33
+
34
+ content_parts = []
35
+ image_data_urls = [] # 用于存储 Base64 编码的图片 Data URL 列表
36
+
37
+ if gemini_output:
38
+ if gemini_output.text:
39
+ original_text = gemini_output.text
40
+ logger.info(f"Gemini 原始文本内容: {original_text[:200]}...") # 记录原始文本内容,截取前200字符
41
+
42
+ # 提取 Markdown 格式的图片链接和纯 URL 格式的图片链接
43
+ markdown_image_regex = r"!\[.*?\]\((https?:\/\/[^\s)]+)\)"
44
+ direct_image_url_regex = r"(https?:\/\/(?:lh3\.)?googleusercontent\.com\/(?:image_generation_content|gg-dl|image_collection\/image_retrieval)\/[^\s]+)"
45
+
46
+ # 存储所有从文本中提取的图片 URL
47
+ all_extracted_urls_from_text = []
48
+
49
+ # 第一次匹配:Markdown 格式的图片链接
50
+ temp_text_after_markdown_extraction = original_text
51
+ markdown_matches = re.findall(markdown_image_regex, temp_text_after_markdown_extraction)
52
+ for url in markdown_matches:
53
+ all_extracted_urls_from_text.append(url)
54
+ temp_text_after_markdown_extraction = re.sub(markdown_image_regex, '', temp_text_after_markdown_extraction)
55
+
56
+ # 第二次匹配:纯 URL 格式的图片链接
57
+ direct_url_matches = re.findall(direct_image_url_regex, temp_text_after_markdown_extraction)
58
+ for url in direct_url_matches:
59
+ all_extracted_urls_from_text.append(url)
60
+ temp_text_after_direct_url_extraction = re.sub(direct_image_url_regex, '', temp_text_after_markdown_extraction)
61
+
62
+ content_parts.append(temp_text_after_direct_url_extraction.strip()) # 将处理后的文本添加到内容部分
63
+
64
+ # 处理从文本中提取的图片
65
+ if all_extracted_urls_from_text:
66
+ logger.info(f"从文本中检测到图片 URL: {all_extracted_urls_from_text}")
67
+ async with httpx.AsyncClient(follow_redirects=True, trust_env=True, max_redirects=100) as client: # 增加最大重定向次数
68
+ headers = {
69
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36",
70
+ "Referer": "https://gemini.google.com/",
71
+ }
72
+ for i, image_url in enumerate(all_extracted_urls_from_text):
73
+ # 判断是否属于需要代理的域名
74
+ should_proxy = any(domain in image_url for domain in PROXY_IMAGE_DOMAINS)
75
+
76
+ if should_proxy:
77
+ logger.warning(f"图片 URL 属于代理域名 ({image_url}),将通过代理加载。")
78
+ proxied_url = f"/v1/image_proxy?image_url={image_url}"
79
+ image_data_urls.append(proxied_url)
80
+ continue # 跳过直接下载逻辑
81
+
82
+ # 否则,尝试直接下载并转换为 Base64
83
+ try:
84
+ logger.info(f"尝试直接下载文本中的图片: {image_url}")
85
+ response = await client.get(image_url, timeout=60, headers=headers)
86
+ response.raise_for_status()
87
+ image_bytes = response.content
88
+
89
+ mime_type = response.headers.get("Content-Type", "application/octet-stream").split(';')[0].strip()
90
+ if not mime_type.startswith("image/"):
91
+ ext = image_url.split('.')[-1].lower()
92
+ if ext in ["jpg", "jpeg"]: mime_type = "image/jpeg"
93
+ elif ext == "png": mime_type = "image/png"
94
+ elif ext == "gif": mime_type = "image/gif"
95
+ elif ext == "webp": mime_type = "image/webp"
96
+ else:
97
+ logger.warning(f"文本中的图片 {image_url} 的 MIME 类型未知或非图片类型: {mime_type},尝试使用 image/jpeg 作为默认。")
98
+ mime_type = "image/jpeg"
99
+
100
+ base64_image = base64.b64encode(image_bytes).decode("utf-8")
101
+ data_url = f"data:{mime_type};base64,{base64_image}"
102
+ image_data_urls.append(data_url)
103
+ logger.info(f"成功下载并转换文本中的图片 {i+1} ({image_url}) 为 Base64 Data URL (MIME: {mime_type})")
104
+ except httpx.RequestError as e:
105
+ logger.error(f"直接下载文本中的图片失败 (RequestError): URL: {image_url}, 类型: {type(e).__name__}, 错误: {e}", exc_info=True)
106
+ image_data_urls.append("") # 下载失败时,返回空字符串
107
+ except httpx.HTTPStatusError as e:
108
+ logger.error(f"直接下载文本中的图片失败 (HTTPStatusError): URL: {image_url}, 状态码: {e.response.status_code}, 响应文本: {e.response.text}, 错误: {e}", exc_info=True)
109
+ image_data_urls.append("") # 下载失败时,返回空字符串
110
+ except Exception as e:
111
+ logger.error(f"处理文本中的图片时发生未知错误: URL: {image_url}, 类型: {type(e).__name__}, 错误: {e}", exc_info=True)
112
+ image_data_urls.append("") # 下载失败时,返回空字符串
113
+
114
+ # 处理 gemini_output.images,如果存在图片,将其下载并转换为 Base64 Data URL
115
+ if gemini_output.images:
116
+ logger.info(f"检测到 Gemini 返回图片列表: {[img.url for img in gemini_output.images if img.url]}")
117
+ async with httpx.AsyncClient(follow_redirects=True, trust_env=True, max_redirects=100) as client: # 增加最大重定向次数
118
+ headers = {
119
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36",
120
+ "Referer": "https://gemini.google.com/",
121
+ }
122
+ for i, image in enumerate(gemini_output.images):
123
+ if image.url:
124
+ # 判断是否属于需要代理的域名
125
+ should_proxy = any(domain in image.url for domain in PROXY_IMAGE_DOMAINS)
126
+
127
+ if should_proxy:
128
+ logger.warning(f"图片 URL 属于代理域名 (来自gemini_output.images) ({image.url}),将通过代理加载。")
129
+ proxied_url = f"/v1/image_proxy?image_url={image.url}"
130
+ image_data_urls.append(proxied_url)
131
+ continue # 跳过直接下载逻辑
132
+
133
+ # 否则,尝试直接下载并转换为 Base64
134
+ try:
135
+ logger.info(f"尝试直接下载图片 (来自gemini_output.images): {image.url}")
136
+ response = await client.get(image.url, timeout=60, headers=headers)
137
+ response.raise_for_status()
138
+ image_bytes = response.content
139
+
140
+ mime_type = response.headers.get("Content-Type", "application/octet-stream").split(';')[0].strip()
141
+ if not mime_type.startswith("image/"):
142
+ ext = image.url.split('.')[-1].lower()
143
+ if ext in ["jpg", "jpeg"]: mime_type = "image/jpeg"
144
+ elif ext == "png": mime_type = "image/png"
145
+ elif ext == "gif": mime_type = "image/gif"
146
+ elif ext == "webp": mime_type = "image/webp"
147
+ else:
148
+ logger.warning(f"图片 {image.url} 的 MIME 类型未知或非图片类型: {mime_type},尝试使用 image/jpeg 作为默认。")
149
+ mime_type = "image/jpeg"
150
+
151
+ base64_image = base64.b64encode(image_bytes).decode("utf-8")
152
+ data_url = f"data:{mime_type};base64,{base64_image}"
153
+ image_data_urls.append(data_url)
154
+ logger.info(f"成功下载并转换图片 {i+1} (来自gemini_output.images) ({image.url}) 为 Base64 Data URL (MIME: {mime_type})")
155
+ except httpx.RequestError as e:
156
+ logger.error(f"直接下载图片失败 (RequestError, 来自gemini_output.images): URL: {image.url}, 类型: {type(e).__name__}, 错误: {e}", exc_info=True)
157
+ image_data_urls.append("") # 下载失败时,返回空字符串
158
+ except httpx.HTTPStatusError as e:
159
+ logger.error(f"直接下载图片失败 (HTTPStatusError, 来自gemini_output.images): URL: {image.url}, 状态码: {e.response.status_code}, 响应文本: {e.response.text}, 错误: {e}", exc_info=True)
160
+ image_data_urls.append("") # 下载失败时,返回空字符串
161
+ except Exception as e:
162
+ logger.error(f"处理图片时发生未知错误 (来自gemini_output.images): URL: {image.url}, 类型: {type(e).__name__}, 错误: {e}", exc_info=True)
163
+ image_data_urls.append("") # 下载失败时,返回空字符串
164
+ else:
165
+ logger.info("Gemini 返回的 ModelOutput 中没有检测到 'images' 字段或其为空。")
166
+
167
+ # 将所有内容部分合并
168
+ content = "\n\n".join(content_parts).strip()
169
+ logger.info(f"最终合并的文本内容: {content[:200]}...") # 记录最终合并的文本内容
170
+ logger.info(f"最终 Base64 图片 Data URL 数量: {len(image_data_urls)}") # 记录最终图片数量
171
+
172
+ if not content: # 如果没有文本内容
173
+ if image_data_urls: # 但有图片 URL
174
+ logger.warning("Gemini API 返回了图片但没有文本内容。")
175
+ content = "无响应文本" # 提示改为“无响应文本”
176
+ finish_reason = "stop" # 仍然是正常停止
177
+ else: # 既没有文本内容也没有图片 URL
178
+ logger.warning("Gemini API 返回了空文本响应且没有图片。")
179
+ content = "Gemini API 返回了空响应或内容处理失败"
180
+ finish_reason = "length"
181
+ else:
182
+ finish_reason = "stop"
183
+
184
+ message_content = {"role": "assistant", "content": content}
185
+ if image_data_urls: # 如果有 Base64 图片 Data URL,添加到 message 中
186
+ message_content["image_urls"] = image_data_urls # 仍然使用 image_urls 字段名,前端无需修改
187
+
188
+ openai_response["choices"] = [
189
+ {
190
+ "index": 0,
191
+ "message": message_content,
192
+ "finish_reason": finish_reason,
193
+ }
194
+ ]
195
+
196
+ return openai_response
197
+
198
+ def convert_openai_messages_to_gemini_history(
199
+ messages: List[Dict[str, Any]],
200
+ ) -> str: # 返回类型改为 str
201
+ """
202
+ 尝试将 OpenAI API 的 messages 格式转换为 gemini-webapi 的历史格式。
203
+ 由于 gemini_webapi 库的 send_message 方法不直接接受历史,
204
+ 此函数将提取所有消息内容并合并为一个字符串。
205
+ """
206
+ history_text = ""
207
+ for message in messages:
208
+ role = message.get("role")
209
+ content = message.get("content")
210
+ if role and content:
211
+ history_text += f"{role}: {content}\n"
212
+ logger.info(f"将消息历史合并为文本: {history_text[:200]}...")
213
+ return history_text