File size: 14,339 Bytes
31ebcce
 
 
 
 
 
 
9e398a7
31ebcce
 
 
 
 
 
 
 
84e2f9a
31ebcce
 
 
 
 
9e398a7
84e2f9a
 
 
 
6f74760
84e2f9a
 
 
 
 
 
31ebcce
 
 
1ce5f62
 
 
 
 
 
 
 
 
 
 
 
 
 
31ebcce
8ff2b24
84e2f9a
8ff2b24
 
 
84e2f9a
8ff2b24
68a7dce
84e2f9a
68a7dce
 
84e2f9a
68a7dce
84e2f9a
 
 
 
 
 
68a7dce
84e2f9a
68a7dce
 
84e2f9a
 
68a7dce
 
 
84e2f9a
68a7dce
6f74760
84e2f9a
68a7dce
6f74760
 
 
236e8f1
6f74760
a1f14b7
 
 
 
 
 
 
 
6f74760
a43b63a
6f74760
a43b63a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1f14b7
 
a43b63a
6f74760
 
 
 
 
 
68a7dce
84e2f9a
68a7dce
84e2f9a
68a7dce
 
 
31ebcce
68a7dce
31ebcce
 
 
 
 
77f880d
3471e90
31ebcce
 
059b2ab
31ebcce
 
84e2f9a
31ebcce
84e2f9a
 
 
31ebcce
 
b32d3e2
 
 
 
 
 
 
 
 
 
31ebcce
 
 
 
 
 
 
 
 
 
 
 
68a7dce
84e2f9a
 
31ebcce
 
 
 
 
 
51b37db
31ebcce
 
 
 
1e56053
 
31ebcce
1e56053
31ebcce
1e56053
 
31ebcce
 
 
1e56053
 
 
 
 
 
 
 
 
31ebcce
 
 
 
 
 
9e398a7
 
 
 
ae7e3d6
 
 
 
 
 
 
 
 
 
 
 
 
 
9e398a7
ae7e3d6
 
 
 
9e398a7
ae7e3d6
 
 
 
 
 
 
 
 
 
51b37db
 
ae7e3d6
 
1e56053
ae7e3d6
 
 
1e56053
ae7e3d6
 
 
1e56053
ae7e3d6
 
84e2f9a
ae7e3d6
 
 
23366d7
84e2f9a
23366d7
 
84e2f9a
 
23366d7
 
ae7e3d6
23366d7
31ebcce
ae7e3d6
84e2f9a
ae7e3d6
 
 
 
9e398a7
ae7e3d6
84e2f9a
9e398a7
ae7e3d6
 
 
84e2f9a
9e398a7
ae7e3d6
84e2f9a
ae7e3d6
9e398a7
 
84e2f9a
9e398a7
 
5268411
 
31ebcce
 
5268411
31ebcce
ae7e3d6
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# app/api/gemini_proxy.py

import os  # 导入 os 模块用于读取环境变量
import time
import base64
import logging
import asyncio
import tempfile # 导入 tempfile 模块用于创建临时文件
from fastapi import APIRouter, Request, HTTPException, Depends, status
from fastapi.responses import JSONResponse
from typing import Dict, Any, List, Optional

# 导入自定义模块
from app.api.auth import (
    get_user_api_key,
    get_admin_api_key,
    get_auth_token,
)  # 从 auth 模块导入认证依赖
from app.api.metrics import (
    update_metrics_on_request,
    update_metrics_on_response,
    get_current_metrics,
)  # 从 metrics 模块导入指标和记录获取函数
from app.core.gemini_client_manager import get_gemini_client, reload_gemini_cookies # 从 core 模块导入 Gemini 客户端管理器
from app.core.session_manager import (
    Session,
    get_or_create_session,
    get_session,
    delete_session_by_id,
    list_all_sessions,
    cleanup_expired_sessions,
) # 从 core 模块导入会话管理器
from app.services.image_proxy_service import proxy_image_request # 从 services 模块导入图片代理服务
from app.utils.gemini_converter import format_gemini_response_to_openai, convert_openai_messages_to_gemini_history # 从 utils 模块导入格式转换工具

logger = logging.getLogger(__name__)

# 创建 APIRouter 实例
router = APIRouter()

# 定义一个管理员接口来刷新 Gemini Cookies
@router.post("/refresh_cookies", summary="刷新 Gemini Cookies (管理员权限)")
async def refresh_gemini_cookies_endpoint(admin_token: str = Depends(get_admin_api_key)):
    """
    管理员接口,用于重新加载 Gemini Cookies。
    调用此接口将从环境变量中重新读取 GEMINI_PSID_COOKIES 和 GEMINI_PSIDTS_COOKIES,
    并清空所有活跃的 GeminiClient 实例,强制它们在下次请求时重新初始化。
    """
    logger.info(f"管理员令牌 '{admin_token}' 请求刷新 Gemini Cookies。")
    reload_gemini_cookies()
    return JSONResponse(content={"status": "ok", "message": "Gemini Cookies 已重新加载,所有客户端实例已清空。"})

@router.get("/image_proxy", summary="图片代理接口")
async def image_proxy_endpoint(image_url: str, request: Request):
    """
    代理图片下载,解决前端跨域或防盗链问题。
    """
    return await proxy_image_request(image_url, request)

@router.post("/sessions", summary="创建新会话")
async def create_session_endpoint(auth_token: str = Depends(get_auth_token)):
    """创建新会话并返回会话ID"""
    # 清理过期会话
    cleanup_expired_sessions(auth_token)
    
    # 获取 Gemini 客户端实例
    try:
        client = await get_gemini_client(auth_token)
    except Exception as e:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))

    # 创建新会话
    session = get_or_create_session(auth_token, None, client) # thread_id 为 None 表示创建新会话
    
    return JSONResponse({
        "thread_id": session.thread_id,
        "name": session.name
    })

@router.get("/sessions", summary="获取会话列表")
async def list_sessions_endpoint(auth_token: str = Depends(get_auth_token)):
    """获取当前认证令牌下的会话列表"""
    sessions_list = list_all_sessions(auth_token) # 清理并获取最新会话列表
    return JSONResponse(sessions_list)

@router.get("/sessions/{thread_id}/history", summary="获取会话历史")
async def get_session_history(thread_id: str, auth_token: str = Depends(get_auth_token)):
    """获取指定会话的历史消息"""
    
    session = get_session(auth_token, thread_id)
    
    if not session:
        logger.warning(f"认证令牌 {auth_token} 请求会话历史,但会话 {thread_id} 未找到。")
        raise HTTPException(status_code=404, detail="会话未找到")
    
    if not session._chat_instance:
        logger.warning(f"认证令牌 {auth_token} 请求会话历史,会话 {thread_id} 存在但聊天实例未初始化。")
        raise HTTPException(status_code=404, detail="会话聊天实例未初始化")
    
    # 获取会话历史
    history = []
    if hasattr(session._chat_instance, 'history') and session._chat_instance.history:
        # gemini_webapi 的 history 是一个包含 Part 对象的列表
        # 需要将其转换为 OpenAI 兼容的 messages 格式
        for i, part in enumerate(session._chat_instance.history):
            role = "user" if i % 2 == 0 else "model" # 假设交替角色
            content = ""
            image_urls = []

            if hasattr(part, 'text'):
                content = part.text
            
            # 如果有图片,这里需要更复杂的逻辑来处理,目前 gemini_webapi 的 history 不直接暴露图片 URL
            # 对于图片,我们可能需要从原始请求中存储或重新生成,或者在前端处理

            history.append({
                "role": role,
                "parts": [{"text": content}] # OpenAI 兼容的 parts 格式
            })
        logger.debug(f"认证令牌 {auth_token} 会话 {thread_id} 历史消息数量: {len(history)}")
    else:
        logger.warning(f"认证令牌 {auth_token} 会话 {thread_id} 的聊天实例没有 'history' 属性或历史为空。")
    
    return JSONResponse({
        "thread_id": thread_id,
        "history": history
    })

@router.delete("/sessions/{thread_id}", summary="删除会话")
async def delete_session_endpoint(thread_id: str, auth_token: str = Depends(get_auth_token)):
    """删除指定会话"""
    if delete_session_by_id(auth_token, thread_id):
        return JSONResponse({"status": "deleted"})
    raise HTTPException(status_code=404, detail="Session not found")

@router.post("/chat/completions", summary="OpenAI 兼容聊天完成接口")
async def create_chat_completion(request: Request, auth_token: str = Depends(get_auth_token)):
    """
    接收 OpenAI 格式的聊天完成请求,并代理到 Gemini。
    """
    start_time = time.time()
    client_host = request.client.host  # 获取客户端 IP
    request_body = {} # 初始化 request_body,以防在 try 块外部访问
    temp_file_path = None # 确保 temp_file_path 总是被初始化

    # 更新请求指标
    update_metrics_on_request(auth_token, client_host) # 使用 auth_token

    try:
        # 从请求体中获取 OpenAI 格式的请求
        request_body = await request.json()
        
        # 获取 Gemini 客户端实例
        client = await get_gemini_client(auth_token)

        # 解析 OpenAI 格式的请求体
        model_name = request_body.get("model", "gemini-2.5-flash")  # 默认模型改为 gemini-2.5-flash
        
        # 模型名称映射
        model_mapping = {
            "gemini-2.5-flash-preview-05-20": "gemini-2.5-flash",
            "gemini-pro": "gemini-2.5-pro", # 将 gemini-pro 映射到 gemini-2.5-pro
            # 可以根据需要添加更多映射
        }
        model_name = model_mapping.get(model_name, model_name) # 应用映射

        messages = request_body.get("messages")
        temperature = request_body.get("temperature")
        max_tokens = request_body.get("max_tokens")
        top_p = request_body.get("top_p")  # 提取 top_p 参数

        thread_id = request_body.get("thread_id")  # 提取会话 ID

        if not messages:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST, detail="缺少 messages 参数"
            )

        # 获取或创建用户对应的会话
        session = get_or_create_session(auth_token, thread_id, client)
        chat = session._chat_instance # 获取聊天实例

        # 获取最后一条用户消息作为当前输入
        current_user_message = messages[-1]
        if current_user_message.get("role") != "user":
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="最后一条消息必须是用户消息"
            )

        user_input_content = current_user_message.get("content")

        # 将当前用户消息内容转换为 gemini-webapi 的文本格式或多模态列表
        gemini_input_content: Any
        if isinstance(user_input_content, str):
            gemini_input_content = user_input_content
        elif isinstance(user_input_content, list):
            # 处理多模态内容列表
            gemini_input_content = []
            for item in user_input_content:
                item_type = item.get("type")
                if item_type == "text":
                    gemini_input_content.append(item.get("text", ""))
                # 注意:这里不再处理 image_url,因为前端会单独发送 base64 数据
            # 如果只有文本,合并为字符串;否则保持列表
            if len(gemini_input_content) == 1 and isinstance(gemini_input_content[0], str):
                gemini_input_content = gemini_input_content[0].strip()
            else:
                gemini_input_content = [part.strip() for part in gemini_input_content if part.strip()]
                if not gemini_input_content: # 如果列表为空,则设为None
                    gemini_input_content = None
        else:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="用户消息内容必须是字符串或列表",
            )

        # 获取图片数据和 MIME 类型
        image_data_b64 = request_body.get("image_data")
        image_mime_type = request_body.get("image_mime_type")
        
        # 如果有图片数据,将其保存为临时文件
        if image_data_b64 and image_mime_type:
            try:
                image_bytes = base64.b64decode(image_data_b64)
                with tempfile.NamedTemporaryFile(delete=False, suffix=f".{image_mime_type.split('/')[-1]}") as temp_file:
                    temp_file.write(image_bytes)
                    temp_file_path = temp_file.name
                logger.info(f"临时图片文件已保存: {temp_file_path}")
            except Exception as e:
                logger.error(f"解码或保存图片失败: {e}", exc_info=True)
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    detail=f"图片数据处理失败: {e}",
                )

        # 调用 gemini-webapi 发送消息,并添加重试逻辑
        gemini_response = None
        max_retries = 3  # 最大重试次数
        retry_delay = 5  # 重试间隔(秒)

        for attempt in range(max_retries):
            try:
                # 映射 OpenAI 参数到 gemini-webapi
                send_params = {}
                if temperature is not None:
                    send_params["temperature"] = temperature
                if max_tokens is not None:
                    send_params["max_tokens"] = max_tokens
                if top_p is not None:
                    send_params["top_p"] = top_p

                # 根据是否有图片数据,调用不同的 send_message 方式
                if temp_file_path:
                    gemini_response = await chat.send_message(
                        prompt=gemini_input_content, files=[temp_file_path], **send_params
                    )
                else:
                    gemini_response = await chat.send_message(
                        prompt=gemini_input_content, **send_params
                    )
                # 如果调用成功,跳出重试循环
                break
            except Exception as e: # 捕获所有异常,包括超时
                if attempt < max_retries - 1:
                    logger.warning(
                        f"认证令牌 {auth_token} 会话 {session.thread_id} 调用 Gemini API 失败 (尝试 {attempt + 1}/{max_retries}),将在 {retry_delay} 秒后重试: {e}"
                    )
                    await asyncio.sleep(retry_delay)
                else:
                    logger.error(
                        f"认证令牌 {auth_token} 会话 {session.thread_id} 调用 Gemini API 失败,重试次数耗尽: {e}",
                        exc_info=True,
                    )
                    # 发生异常时,移除当前会话,避免无效会话残留
                    delete_session_by_id(auth_token, session.thread_id)
                    raise HTTPException(
                        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                        detail=f"Gemini API 调用失败,重试次数耗尽: {e}",
                    )

        if not gemini_response:
            logger.error(f"认证令牌 {auth_token} 会话 {session.thread_id} 调用 Gemini API 未返回响应,尽管没有捕获到异常。")
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail="Gemini API 返回空响应",
            )

        # 将 gemini-webapi 响应转换为 OpenAI 格式
        openai_response = await format_gemini_response_to_openai(gemini_response)

        # 记录成功调用
        end_time = time.time()
        response_time = end_time - start_time
        update_metrics_on_response(auth_token, "success", response_time, client_host, request_body, openai_response)

        # 返回 OpenAI 格式的响应
        openai_response["thread_id"] = session.thread_id
        return JSONResponse(content=openai_response)

    except HTTPException as e:
        update_metrics_on_response(auth_token, "failed", time.time() - start_time, client_host, request_body, {"detail": e.detail})
        raise e
    except Exception as e:
        logger.error(f"处理请求时发生错误。", exc_info=True)
        update_metrics_on_response(auth_token, "failed", time.time() - start_time, client_host, request_body, {"detail": "内部服务器错误"})
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="内部服务器错误,请联系管理员。",
        )
    finally:
        # 确保删除临时文件
        if temp_file_path and os.path.exists(temp_file_path):
            os.remove(temp_file_path)
            logger.info(f"临时图片文件已删除: {temp_file_path}")