diff --git "a/gemini.py" "b/gemini.py" new file mode 100644--- /dev/null +++ "b/gemini.py" @@ -0,0 +1,2825 @@ +"""Business Gemini OpenAPI 兼容服务 +整合JWT获取和聊天功能,提供OpenAPI接口 +支持多账号轮训 +支持图片输出(OpenAI格式) +""" + +import json +import time +import hmac +import hashlib +import base64 +import uuid +import threading +import os +import re +import shutil +import mimetypes +import requests +from pathlib import Path +from datetime import datetime, timedelta, timezone +from dataclasses import dataclass, field +from typing import List, Optional, Dict, Any +import builtins +import secrets +from flask import Flask, request, Response, jsonify, send_from_directory, abort +from flask_cors import CORS +from functools import wraps +from werkzeug.security import generate_password_hash, check_password_hash + +# 禁用SSL警告 +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +# 配置 +CONFIG_FILE = Path(__file__).parent / "business_gemini_session.json" + +# 图片缓存配置 +IMAGE_CACHE_DIR = Path(__file__).parent / "image" +IMAGE_CACHE_HOURS = 1 # 图片缓存时间(小时) +IMAGE_CACHE_DIR.mkdir(exist_ok=True) + +# API endpoints +BASE_URL = "https://biz-discoveryengine.googleapis.com/v1alpha/locations/global" +CREATE_SESSION_URL = f"{BASE_URL}/widgetCreateSession" +STREAM_ASSIST_URL = f"{BASE_URL}/widgetStreamAssist" +LIST_FILE_METADATA_URL = f"{BASE_URL}/widgetListSessionFileMetadata" +ADD_CONTEXT_FILE_URL = f"{BASE_URL}/widgetAddContextFile" +GETOXSRF_URL = "https://business.gemini.google/auth/getoxsrf" + +# 账号错误冷却时间(秒) +AUTH_ERROR_COOLDOWN_SECONDS = 900 # 凭证错误,15分钟 +RATE_LIMIT_COOLDOWN_SECONDS = 300 # 触发限额,5分钟 +GENERIC_ERROR_COOLDOWN_SECONDS = 120 # 其他错误的短暂冷却 +LOG_LEVELS = {"DEBUG": 10, "INFO": 20, "ERROR": 40} +DEFAULT_LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() +CURRENT_LOG_LEVEL_NAME = DEFAULT_LOG_LEVEL if DEFAULT_LOG_LEVEL in LOG_LEVELS else "INFO" +CURRENT_LOG_LEVEL = LOG_LEVELS[CURRENT_LOG_LEVEL_NAME] +ADMIN_SECRET_KEY = None +API_TOKENS = set() + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None + +# Flask应用 +app = Flask(__name__, static_folder='.') +CORS(app) + + +def _infer_log_level(text: str) -> str: + t = text.strip() + if t.startswith("[DEBUG]"): + return "DEBUG" + if t.startswith("[ERROR]") or t.startswith("[!]"): + return "ERROR" + return "INFO" + + +_original_print = builtins.print + + +def filtered_print(*args, **kwargs): + """简单的日志过滤,根据全局日志级别屏蔽低级别输出""" + level = kwargs.pop("_level", None) + sep = kwargs.get("sep", " ") + text = sep.join(str(a) for a in args) + level_name = (level or _infer_log_level(text)).upper() + if LOG_LEVELS.get(level_name, LOG_LEVELS["INFO"]) >= CURRENT_LOG_LEVEL: + _original_print(*args, **kwargs) + + +builtins.print = filtered_print + + +def set_log_level(level: str, persist: bool = False): + """设置全局日志级别""" + global CURRENT_LOG_LEVEL_NAME, CURRENT_LOG_LEVEL + lvl = (level or "").upper() + if lvl not in LOG_LEVELS: + raise ValueError(f"无效日志级别: {level}") + CURRENT_LOG_LEVEL_NAME = lvl + CURRENT_LOG_LEVEL = LOG_LEVELS[lvl] + if persist and globals().get("account_manager") and account_manager.config is not None: + account_manager.config["log_level"] = lvl + account_manager.save_config() + _original_print(f"[LOG] 当前日志级别: {CURRENT_LOG_LEVEL_NAME}") + + +class AccountError(Exception): + """基础账号异常""" + + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code + + +class AccountAuthError(AccountError): + """凭证/权限相关异常""" + + +class AccountRateLimitError(AccountError): + """配额或限流异常""" + + +class AccountRequestError(AccountError): + """其他请求异常""" + + +class NoAvailableAccount(AccountError): + """无可用账号异常""" + + +def get_admin_secret_key() -> str: + """获取/初始化后台密钥""" + global ADMIN_SECRET_KEY + if ADMIN_SECRET_KEY: + return ADMIN_SECRET_KEY + if account_manager.config is None: + ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "change_me_secret") + return ADMIN_SECRET_KEY + secret = account_manager.config.get("admin_secret_key") or os.getenv("ADMIN_SECRET_KEY") + if not secret: + secret = secrets.token_urlsafe(32) + account_manager.config["admin_secret_key"] = secret + account_manager.save_config() + ADMIN_SECRET_KEY = secret + return ADMIN_SECRET_KEY + + +def load_api_tokens(): + """从配置加载用户访问token""" + global API_TOKENS + API_TOKENS = set() + if not account_manager.config: + return + tokens = account_manager.config.get("api_tokens") or account_manager.config.get("api_token") + if isinstance(tokens, str): + if tokens.strip(): # 过滤空字符串 + API_TOKENS.add(tokens) + elif isinstance(tokens, list): + for t in tokens: + if isinstance(t, str) and t.strip(): # 过滤空字符串 + API_TOKENS.add(t) + + +def persist_api_tokens(): + if account_manager.config is None: + account_manager.config = {} + # 过滤空 token + account_manager.config["api_tokens"] = [t for t in API_TOKENS if t.strip()] + account_manager.save_config() + + +def get_admin_password_hash() -> Optional[str]: + if account_manager.config: + return account_manager.config.get("admin_password_hash") + return None + + +def set_admin_password(password: str): + if not password: + raise ValueError("密码不能为空") + if account_manager.config is None: + account_manager.config = {} + account_manager.config["admin_password_hash"] = generate_password_hash(password) + account_manager.save_config() + + +def is_valid_api_token(token: str) -> bool: + if not token: + return False + if verify_admin_token(token): + return True + return token in API_TOKENS + + +def require_api_auth(func): + """开放接口需要 api_token 或 admin token""" + @wraps(func) + def wrapper(*args, **kwargs): + token = ( + request.headers.get("X-API-Token") + or request.headers.get("Authorization", "").replace("Bearer ", "") + or request.cookies.get("admin_token") + ) + if not is_valid_api_token(token): + return jsonify({"error": "未授权"}), 401 + return func(*args, **kwargs) + return wrapper + + +def create_admin_token(exp_seconds: int = 86400) -> str: + payload = { + "exp": time.time() + exp_seconds, + "ts": int(time.time()) + } + payload_b = json.dumps(payload, separators=(",", ":")).encode() + b64 = base64.urlsafe_b64encode(payload_b).decode().rstrip("=") + secret = get_admin_secret_key().encode() + signature = hmac.new(secret, b64.encode(), hashlib.sha256).hexdigest() + return f"{b64}.{signature}" + + +def verify_admin_token(token: str) -> bool: + if not token: + return False + try: + b64, sig = token.split(".", 1) + except ValueError: + return False + expected_sig = hmac.new(get_admin_secret_key().encode(), b64.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected_sig, sig): + return False + padding = '=' * (-len(b64) % 4) + try: + payload = json.loads(base64.urlsafe_b64decode(b64 + padding).decode()) + except Exception: + return False + if payload.get("exp", 0) < time.time(): + return False + return True + + +def require_admin(func): + @wraps(func) + def wrapper(*args, **kwargs): + token = ( + request.headers.get("X-Admin-Token") + or request.headers.get("Authorization", "").replace("Bearer ", "") + or request.cookies.get("admin_token") + ) + if not verify_admin_token(token): + return jsonify({"error": "未授权"}), 401 + return func(*args, **kwargs) + return wrapper + + +def seconds_until_next_pt_midnight(now_ts: Optional[float] = None) -> int: + """计算距离下一个 PT 午夜的秒数,用于配额冷却""" + now_utc = datetime.now(timezone.utc) if now_ts is None else datetime.fromtimestamp(now_ts, tz=timezone.utc) + if ZoneInfo: + pt_tz = ZoneInfo("America/Los_Angeles") + now_pt = now_utc.astimezone(pt_tz) + else: + # 兼容旧版本 Python 的简易回退(不考虑夏令时) + now_pt = now_utc - timedelta(hours=8) + + tomorrow = (now_pt + timedelta(days=1)).date() + midnight_pt = datetime.combine(tomorrow, datetime.min.time(), tzinfo=now_pt.tzinfo) + delta = (midnight_pt - now_pt).total_seconds() + return max(0, int(delta)) + + +class AccountManager: + """多账号管理器,支持轮训策略""" + + def __init__(self): + self.config = None + self.accounts = [] # 账号列表 + self.current_index = 0 # 当前轮训索引 + self.account_states = {} # 账号状态: {index: {jwt, jwt_time, session, available, cooldown_until, cooldown_reason}} + self.lock = threading.Lock() + self.auth_error_cooldown = AUTH_ERROR_COOLDOWN_SECONDS + self.rate_limit_cooldown = RATE_LIMIT_COOLDOWN_SECONDS + self.generic_error_cooldown = GENERIC_ERROR_COOLDOWN_SECONDS + + def load_config(self): + """加载配置""" + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + self.config = json.load(f) + if "log_level" in self.config: + try: + set_log_level(self.config.get("log_level"), persist=False) + except Exception: + pass + if "admin_secret_key" in self.config: + global ADMIN_SECRET_KEY + ADMIN_SECRET_KEY = self.config.get("admin_secret_key") + load_api_tokens() + self.accounts = self.config.get("accounts", []) + # 初始化账号状态 + for i, acc in enumerate(self.accounts): + available = acc.get("available", True) # 默认可用 + self.account_states[i] = { + "jwt": None, + "jwt_time": 0, + "session": None, + "available": available, + "cooldown_until": acc.get("cooldown_until"), + "cooldown_reason": acc.get("unavailable_reason") or acc.get("cooldown_reason") or "" + } + return self.config + + def save_config(self): + """保存配置到文件""" + if self.config and CONFIG_FILE.exists(): + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(self.config, f, indent=4, ensure_ascii=False) + + def mark_account_unavailable(self, index: int, reason: str = ""): + """标记账号不可用""" + with self.lock: + if 0 <= index < len(self.accounts): + self.accounts[index]["available"] = False + self.accounts[index]["unavailable_reason"] = reason + self.accounts[index]["unavailable_time"] = datetime.now().isoformat() + self.account_states[index]["available"] = False + self.save_config() + print(f"[!] 账号 {index} 已标记为不可用: {reason}") + + def mark_account_cooldown(self, index: int, reason: str = "", cooldown_seconds: Optional[int] = None): + """临时拉黑账号(冷却),在冷却时间内不会被选择""" + if cooldown_seconds is None: + cooldown_seconds = self.generic_error_cooldown + + with self.lock: + if 0 <= index < len(self.accounts): + now_ts = time.time() + new_until = now_ts + cooldown_seconds + state = self.account_states.setdefault(index, {}) + current_until = state.get("cooldown_until") or 0 + # 如果已有更长的冷却,则不重复更新 + if current_until > now_ts and current_until >= new_until: + return + + until = max(new_until, current_until) + state["cooldown_until"] = until + state["cooldown_reason"] = reason + state["jwt"] = None + state["jwt_time"] = 0 + state["session"] = None + + # 在配置中记录冷却信息,便于前端展示 + self.accounts[index]["cooldown_until"] = until + self.accounts[index]["unavailable_reason"] = reason + self.accounts[index]["unavailable_time"] = datetime.now().isoformat() + + self.save_config() + print(f"[!] 账号 {index} 进入冷却 {cooldown_seconds} 秒: {reason}") + + def _is_in_cooldown(self, index: int, now_ts: Optional[float] = None) -> bool: + """检查账号是否处于冷却期""" + now_ts = now_ts or time.time() + state = self.account_states.get(index, {}) + cooldown_until = state.get("cooldown_until") + if not cooldown_until: + return False + return now_ts < cooldown_until + + def get_next_cooldown_info(self) -> Optional[dict]: + """获取最近即将结束冷却的账号信息""" + now_ts = time.time() + candidates = [] + for idx, state in self.account_states.items(): + cooldown_until = state.get("cooldown_until") + if cooldown_until and cooldown_until > now_ts and state.get("available", True): + candidates.append((cooldown_until, idx)) + if not candidates: + return None + cooldown_until, idx = min(candidates, key=lambda x: x[0]) + return {"index": idx, "cooldown_until": cooldown_until} + + def is_account_available(self, index: int) -> bool: + """计算账号当前是否可用(考虑冷却和手动禁用)""" + state = self.account_states.get(index, {}) + if not state.get("available", True): + return False + return not self._is_in_cooldown(index) + + def get_available_accounts(self): + """获取可用账号列表""" + now_ts = time.time() + available_accounts = [] + for i, acc in enumerate(self.accounts): + state = self.account_states.get(i, {}) + if not state.get("available", True): + continue + if self._is_in_cooldown(i, now_ts): + continue + available_accounts.append((i, acc)) + return available_accounts + + def get_next_account(self): + """轮训获取下一个可用账号""" + with self.lock: + available = self.get_available_accounts() + if not available: + cooldown_info = self.get_next_cooldown_info() + if cooldown_info: + remaining = int(max(0, cooldown_info["cooldown_until"] - time.time())) + raise NoAvailableAccount(f"没有可用的账号(最近冷却账号 {cooldown_info['index']},约 {remaining} 秒后可重试)") + raise NoAvailableAccount("没有可用的账号") + + # 轮训选择 + self.current_index = self.current_index % len(available) + idx, account = available[self.current_index] + self.current_index = (self.current_index + 1) % len(available) + return idx, account + + def get_account_count(self): + """获取账号数量统计""" + total = len(self.accounts) + available = len(self.get_available_accounts()) + return total, available + + +# 全局账号管理器 +account_manager = AccountManager() + + +class FileManager: + """文件管理器 - 管理上传文件的映射关系(OpenAI file_id <-> Gemini fileId)""" + + def __init__(self): + self.files: Dict[str, Dict] = {} # openai_file_id -> {gemini_file_id, session_name, filename, mime_type, size, created_at} + + def add_file(self, openai_file_id: str, gemini_file_id: str, session_name: str, + filename: str, mime_type: str, size: int) -> Dict: + """添加文件映射""" + file_info = { + "id": openai_file_id, + "gemini_file_id": gemini_file_id, + "session_name": session_name, + "filename": filename, + "mime_type": mime_type, + "bytes": size, + "created_at": int(time.time()), + "purpose": "assistants", + "object": "file" + } + self.files[openai_file_id] = file_info + return file_info + + def get_file(self, openai_file_id: str) -> Optional[Dict]: + """获取文件信息""" + return self.files.get(openai_file_id) + + def get_gemini_file_id(self, openai_file_id: str) -> Optional[str]: + """获取 Gemini 文件ID""" + file_info = self.files.get(openai_file_id) + return file_info.get("gemini_file_id") if file_info else None + + def delete_file(self, openai_file_id: str) -> bool: + """删除文件映射""" + if openai_file_id in self.files: + del self.files[openai_file_id] + return True + return False + + def list_files(self) -> List[Dict]: + """列出所有文件""" + return list(self.files.values()) + + def get_session_for_file(self, openai_file_id: str) -> Optional[str]: + """获取文件关联的会话名称""" + file_info = self.files.get(openai_file_id) + return file_info.get("session_name") if file_info else None + + +# 全局文件管理器 +file_manager = FileManager() + + +def check_proxy(proxy: str) -> bool: + """检测代理是否可用""" + if not proxy: + return False + try: + proxies = {"http": proxy, "https": proxy} + resp = requests.get("https://www.google.com", proxies=proxies, + verify=False, timeout=10) + return resp.status_code == 200 + except: + return False + + +def get_proxy() -> Optional[str]: + """获取代理配置,根据proxy_enabled开关决定是否返回代理地址 + + Returns: + 代理地址字符串,如果禁用代理则返回None + """ + if account_manager.config is None: + return None + if not account_manager.config.get("proxy_enabled", False): + return None + return account_manager.config.get("proxy") + + +def url_safe_b64encode(data: bytes) -> str: + """URL安全的Base64编码,不带padding""" + return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=') + + +def kq_encode(s: str) -> str: + """模拟JS的kQ函数""" + byte_arr = bytearray() + for char in s: + val = ord(char) + if val > 255: + byte_arr.append(val & 255) + byte_arr.append(val >> 8) + else: + byte_arr.append(val) + return url_safe_b64encode(bytes(byte_arr)) + + +def decode_xsrf_token(xsrf_token: str) -> bytes: + """将 xsrfToken 解码为字节数组(用于HMAC签名)""" + padding = 4 - len(xsrf_token) % 4 + if padding != 4: + xsrf_token += '=' * padding + return base64.urlsafe_b64decode(xsrf_token) + + +def create_jwt(key_bytes: bytes, key_id: str, csesidx: str) -> str: + """创建JWT token""" + now = int(time.time()) + + header = { + "alg": "HS256", + "typ": "JWT", + "kid": key_id + } + + payload = { + "iss": "https://business.gemini.google", + "aud": "https://biz-discoveryengine.googleapis.com", + "sub": f"csesidx/{csesidx}", + "iat": now, + "exp": now + 300, + "nbf": now + } + + header_b64 = kq_encode(json.dumps(header, separators=(',', ':'))) + payload_b64 = kq_encode(json.dumps(payload, separators=(',', ':'))) + message = f"{header_b64}.{payload_b64}" + + signature = hmac.new(key_bytes, message.encode('utf-8'), hashlib.sha256).digest() + signature_b64 = url_safe_b64encode(signature) + + return f"{message}.{signature_b64}" + + +def get_jwt_for_account(account: dict, proxy: str) -> str: + """为指定账号获取JWT - 支持新的Google认证流程""" + secure_c_ses = account.get("secure_c_ses") + host_c_oses = account.get("host_c_oses") + csesidx = account.get("csesidx") + + if not secure_c_ses or not csesidx: + raise ValueError("缺少 secure_c_ses 或 csesidx") + + url = f"{GETOXSRF_URL}?csesidx={csesidx}" + proxies = {"http": proxy, "https": proxy} if proxy else None + + # 构建 Cookie 字符串,包含额外的 Cookie(用于新认证流程) + cookie_parts = [f'__Secure-C_SES={secure_c_ses}', f'__Host-C_OSES={host_c_oses}'] + extra_cookies = account.get("extra_cookies", {}) + if extra_cookies: + for name, value in extra_cookies.items(): + if name and value: + cookie_parts.append(f'{name}={value}') + cookie_str = "; ".join(cookie_parts) + + headers = { + "accept": "*/*", + "user-agent": account.get('user_agent', 'Mozilla/5.0'), + "cookie": cookie_str, + "origin": "https://business.gemini.google", + "referer": "https://business.gemini.google/", + } + + try: + # 第一步:请求 getoxsrf(不跟随重定向) + resp = requests.get(url, headers=headers, proxies=proxies, verify=False, timeout=30, allow_redirects=False) + except requests.RequestException as e: + raise AccountRequestError(f"获取JWT 请求失败: {e}") from e + + # 如果是302重定向到refreshcookies,需要处理新的认证流程 + if resp.status_code == 302: + location = resp.headers.get("Location", "") + if "refreshcookies" in location: + print(f"[DEBUG] 检测到新认证流程,重定向到: {location[:100]}...") + try: + # 第二步:请求 refreshcookies + resp2 = requests.get(location, headers=headers, proxies=proxies, verify=False, timeout=30) + if resp2.status_code != 200: + raise AccountAuthError(f"refreshcookies 请求失败: {resp2.status_code}") + + text2 = resp2.text + if text2.startswith(")]}'\\n") or text2.startswith(")]}'"): + text2 = text2[4:].strip() + + data2 = json.loads(text2) + setocookie_url_b64 = data2.get("setocookieUrl") + if not setocookie_url_b64: + raise AccountAuthError(f"refreshcookies 响应缺少 setocookieUrl: {data2}") + + # 解码 setocookieUrl + padding = 4 - len(setocookie_url_b64) % 4 + if padding != 4: + setocookie_url_b64 += '=' * padding + setocookie_url = base64.urlsafe_b64decode(setocookie_url_b64).decode('utf-8') + print(f"[DEBUG] setocookie URL: {setocookie_url[:100]}...") + + # 第三步:请求 setocookie(不跟随重定向) + resp3 = requests.get(setocookie_url, headers=headers, proxies=proxies, verify=False, timeout=30, allow_redirects=False) + + # setocookie 可能返回302重定向回 getoxsrf,也可能直接返回结果 + if resp3.status_code == 302: + final_location = resp3.headers.get("Location", "") + if "getoxsrf" in final_location: + # 最终请求 getoxsrf + resp = requests.get(final_location, headers=headers, proxies=proxies, verify=False, timeout=30) + else: + raise AccountAuthError(f"setocookie 重定向到未知地址: {final_location}") + elif resp3.status_code == 200: + resp = resp3 + else: + raise AccountAuthError(f"setocookie 请求失败: {resp3.status_code} - {resp3.text[:200]}") + + except json.JSONDecodeError as e: + raise AccountAuthError(f"解析refreshcookies响应失败: {e}") from e + except Exception as e: + if isinstance(e, (AccountAuthError, AccountRequestError)): + raise + raise AccountAuthError(f"新认证流程失败: {e}") from e + else: + raise AccountAuthError(f"获取JWT 302重定向到未知地址: {location}") + + if resp.status_code != 200: + raise_for_account_response(resp, "获取JWT") + + # 处理Google安全前缀 + text = resp.text + if text.startswith(")]}'\\n") or text.startswith(")]}'"): + text = text[4:].strip() + + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise AccountAuthError(f"解析JWT响应失败: {e}") from e + + key_id = data.get("keyId") + xsrf_token = data.get("xsrfToken") + if not key_id or not xsrf_token: + raise AccountAuthError(f"JWT 响应缺少 keyId/xsrfToken: {data}") + + print(f"账号: {account.get('csesidx')} 账号可用! key_id: {key_id}") + + key_bytes = decode_xsrf_token(xsrf_token) + + return create_jwt(key_bytes, key_id, csesidx) + + +def build_cookie_string(account: dict) -> str: + """从账号配置构建完整的 Cookie 字符串""" + if not account: + return "" + + cookie_parts = [] + + # 主要 Cookie + if account.get("secure_c_ses"): + cookie_parts.append(f'__Secure-C_SES={account["secure_c_ses"]}') + if account.get("host_c_oses"): + cookie_parts.append(f'__Host-C_OSES={account["host_c_oses"]}') + + # 额外的 Cookie + extra_cookies = account.get("extra_cookies", {}) + if extra_cookies: + for name, value in extra_cookies.items(): + if name and value: + cookie_parts.append(f'{name}={value}') + + return "; ".join(cookie_parts) + + +def get_headers(jwt: str, account: dict = None) -> dict: + """获取请求头,可选包含 Cookie""" + headers = { + "accept": "*/*", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", + "authorization": f"Bearer {jwt}", + "content-type": "application/json", + "origin": "https://business.gemini.google", + "referer": "https://business.gemini.google/", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", + "x-server-timeout": "1800", + } + + # 如果提供了账号配置,添加 Cookie + if account: + cookie_str = build_cookie_string(account) + if cookie_str: + headers["cookie"] = cookie_str + + return headers + + +def raise_for_account_response(resp: requests.Response, action: str): + """根据响应状态抛出对应的账号异常""" + status = resp.status_code + body_preview = resp.text[:500] if resp.text else "" + lower_body = body_preview.lower() + + if status in (401, 403): + raise AccountAuthError(f"{action} 认证失败: {status} - {body_preview}", status) + if status == 429 or "quota" in lower_body or "exceed" in lower_body or "limit" in lower_body: + raise AccountRateLimitError(f"{action} 触发限额: {status} - {body_preview}", status) + + raise AccountRequestError(f"{action} 请求失败: {status} - {body_preview}", status) + + +def ensure_jwt_for_account(account_idx: int, account: dict): + """确保指定账号的JWT有效,必要时刷新""" + print(f"[DEBUG][ensure_jwt_for_account] 开始 - 账号索引: {account_idx}, CSESIDX: {account.get('csesidx')}") + start_time = time.time() + with account_manager.lock: + state = account_manager.account_states[account_idx] + jwt_age = time.time() - state["jwt_time"] if state["jwt"] else float('inf') + print(f"[DEBUG][ensure_jwt_for_account] JWT状态 - 存在: {state['jwt'] is not None}, 年龄: {jwt_age:.2f}秒") + if state["jwt"] is None or jwt_age > 240: + print(f"[DEBUG][ensure_jwt_for_account] 需要刷新JWT...") + proxy = get_proxy() + try: + refresh_start = time.time() + state["jwt"] = get_jwt_for_account(account, proxy) + state["jwt_time"] = time.time() + print(f"[DEBUG][ensure_jwt_for_account] JWT刷新成功 - 耗时: {time.time() - refresh_start:.2f}秒") + except Exception as e: + print(f"[DEBUG][ensure_jwt_for_account] JWT刷新失败: {e}") + raise + else: + print(f"[DEBUG][ensure_jwt_for_account] 使用缓存JWT") + print(f"[DEBUG][ensure_jwt_for_account] 完成 - 总耗时: {time.time() - start_time:.2f}秒") + return state["jwt"] + + +def create_chat_session(jwt: str, team_id: str, proxy: str, account: dict = None) -> str: + """创建会话,返回session ID""" + print(f"[DEBUG][create_chat_session] 开始 - team_id: {team_id}") + start_time = time.time() + session_id = uuid.uuid4().hex[:12] + print(f"[DEBUG][create_chat_session] 生成session_id: {session_id}") + body = { + "configId": team_id, + "additionalParams": {"token": "-"}, + "createSessionRequest": { + "session": {"name": session_id, "displayName": session_id} + } + } + + proxies = {"http": proxy, "https": proxy} if proxy else None + print(f"[DEBUG][create_chat_session] 发送请求到: {CREATE_SESSION_URL}") + print(f"[DEBUG][create_chat_session] 使用代理: {proxy}") + + request_start = time.time() + try: + resp = requests.post( + CREATE_SESSION_URL, + headers=get_headers(jwt, account), + json=body, + proxies=proxies, + verify=False, + timeout=30 + ) + except requests.RequestException as e: + raise AccountRequestError(f"创建会话请求失败: {e}") from e + print(f"[DEBUG][create_chat_session] 请求完成 - 状态码: {resp.status_code}, 耗时: {time.time() - request_start:.2f}秒") + + if resp.status_code != 200: + print(f"[DEBUG][create_chat_session] 请求失败 - 响应: {resp.text[:500]}") + if resp.status_code == 401: + print(f"[DEBUG][create_chat_session] 401错误 - 可能是team_id填错了") + raise_for_account_response(resp, "创建会话") + + data = resp.json() + session_name = data.get("session", {}).get("name") + print(f"[DEBUG][create_chat_session] 完成 - session_name: {session_name}, 总耗时: {time.time() - start_time:.2f}秒") + return session_name + + +def ensure_session_for_account(account_idx: int, account: dict): + """确保指定账号的会话有效""" + print(f"[DEBUG][ensure_session_for_account] 开始 - 账号索引: {account_idx}") + start_time = time.time() + + jwt_start = time.time() + jwt = ensure_jwt_for_account(account_idx, account) + print(f"[DEBUG][ensure_session_for_account] JWT获取完成 - 耗时: {time.time() - jwt_start:.2f}秒") + + with account_manager.lock: + state = account_manager.account_states[account_idx] + print(f"[DEBUG][ensure_session_for_account] 当前session状态: {state['session'] is not None}") + if state["session"] is None: + print(f"[DEBUG][ensure_session_for_account] 需要创建新session...") + proxy = get_proxy() + team_id = account.get("team_id") + session_start = time.time() + state["session"] = create_chat_session(jwt, team_id, proxy, account) + print(f"[DEBUG][ensure_session_for_account] Session创建完成 - 耗时: {time.time() - session_start:.2f}秒") + else: + print(f"[DEBUG][ensure_session_for_account] 使用缓存session: {state['session']}") + + print(f"[DEBUG][ensure_session_for_account] 完成 - 总耗时: {time.time() - start_time:.2f}秒") + return state["session"], jwt, account.get("team_id") + + +# ==================== 文件上传功能 ==================== + +def upload_file_to_gemini(jwt: str, session_name: str, team_id: str, + file_content: bytes, filename: str, mime_type: str, + proxy: str = None) -> str: + """ + 上传文件到 Gemini,返回 Gemini 的 fileId + + Args: + jwt: JWT 认证令牌 + session_name: 会话名称 + team_id: 团队ID + file_content: 文件内容(字节) + filename: 文件名 + mime_type: MIME 类型 + proxy: 代理地址 + + Returns: + str: Gemini 返回的 fileId + """ + start_time = time.time() + print(f"[DEBUG][upload_file_to_gemini] 开始上传文件: {filename}, MIME类型: {mime_type}, 文件大小: {len(file_content)} bytes") + + encode_start = time.time() + file_contents_b64 = base64.b64encode(file_content).decode('utf-8') + print(f"[DEBUG][upload_file_to_gemini] Base64编码完成 - 耗时: {time.time() - encode_start:.2f}秒, 编码后大小: {len(file_contents_b64)} chars") + + body = { + "addContextFileRequest": { + "fileContents": file_contents_b64, + "fileName": filename, + "mimeType": mime_type, + "name": session_name + }, + "additionalParams": {"token": "-"}, + "configId": team_id + } + + proxies = {"http": proxy, "https": proxy} if proxy else None + print(f"[DEBUG][upload_file_to_gemini] 准备发送请求到: {ADD_CONTEXT_FILE_URL}") + print(f"[DEBUG][upload_file_to_gemini] 使用代理: {proxy if proxy else '无'}") + + request_start = time.time() + try: + resp = requests.post( + ADD_CONTEXT_FILE_URL, + headers=get_headers(jwt), + json=body, + proxies=proxies, + verify=False, + timeout=60 + ) + except requests.RequestException as e: + raise AccountRequestError(f"文件上传请求失败: {e}") from e + print(f"[DEBUG][upload_file_to_gemini] 请求完成 - 耗时: {time.time() - request_start:.2f}秒, 状态码: {resp.status_code}") + + if resp.status_code != 200: + print(f"[DEBUG][upload_file_to_gemini] 上传失败 - 响应内容: {resp.text[:500]}") + raise_for_account_response(resp, "文件上传") + + parse_start = time.time() + data = resp.json() + file_id = data.get("addContextFileResponse", {}).get("fileId") + print(f"[DEBUG][upload_file_to_gemini] 解析响应完成 - 耗时: {time.time() - parse_start:.2f}秒") + + if not file_id: + print(f"[DEBUG][upload_file_to_gemini] 响应中未找到fileId - 响应数据: {data}") + raise ValueError(f"响应中未找到 fileId: {data}") + + print(f"[DEBUG][upload_file_to_gemini] 上传成功 - fileId: {file_id}, 总耗时: {time.time() - start_time:.2f}秒") + return file_id + + +# ==================== 图片处理功能 ==================== + +@dataclass +class ChatImage: + """表示生成的图片""" + url: Optional[str] = None + base64_data: Optional[str] = None + mime_type: str = "image/png" + local_path: Optional[str] = None + file_id: Optional[str] = None + file_name: Optional[str] = None + + +@dataclass +class ChatResponse: + """聊天响应,包含文本和图片""" + text: str = "" + images: List[ChatImage] = field(default_factory=list) + thoughts: List[str] = field(default_factory=list) + + +def cleanup_expired_images(): + """清理过期的缓存图片""" + if not IMAGE_CACHE_DIR.exists(): + return + + now = time.time() + max_age_seconds = IMAGE_CACHE_HOURS * 3600 + + for filepath in IMAGE_CACHE_DIR.iterdir(): + if filepath.is_file(): + try: + file_age = now - filepath.stat().st_mtime + if file_age > max_age_seconds: + filepath.unlink() + print(f"[图片缓存] 已删除过期图片: {filepath.name}") + except Exception as e: + print(f"[图片缓存] 删除失败: {filepath.name}, 错误: {e}") + + +def save_image_to_cache(image_data: bytes, mime_type: str = "image/png", filename: Optional[str] = None) -> str: + """保存图片到缓存目录,返回文件名""" + IMAGE_CACHE_DIR.mkdir(exist_ok=True) + + # 确定文件扩展名 + ext_map = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + } + ext = ext_map.get(mime_type, ".png") + + if filename: + # 确保有正确的扩展名 + if not any(filename.endswith(e) for e in ext_map.values()): + filename = f"{filename}{ext}" + else: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"gemini_{timestamp}_{uuid.uuid4().hex[:8]}{ext}" + + filepath = IMAGE_CACHE_DIR / filename + with open(filepath, "wb") as f: + f.write(image_data) + + return filename + + +def parse_base64_data_url(data_url: str) -> Optional[Dict]: + """解析 base64 data URL,返回 {type, mime_type, data} 或 None""" + if not data_url or not data_url.startswith("data:"): + return None + + # base64格式: data:image/png;base64,xxxxx + match = re.match(r"data:([^;]+);base64,(.+)", data_url) + if match: + return { + "type": "base64", + "mime_type": match.group(1), + "data": match.group(2) + } + return None + + +def extract_images_from_files_array(files: List[Dict]) -> List[Dict]: + """从 files 数组中提取图片(支持内联 base64 格式) + + 支持格式: + { + "data": "data:image/png;base64,xxxxx", + "type": "image", + "detail": "high" # 可选 + } + + 返回: 图片列表 [{type: 'base64', mime_type: ..., data: ...}] + """ + images = [] + for file_item in files: + if not isinstance(file_item, dict): + continue + + file_type = file_item.get("type", "") + + # 只处理图片类型 + if file_type != "image": + continue + + data = file_item.get("data", "") + if data: + parsed = parse_base64_data_url(data) + if parsed: + images.append(parsed) + + return images + + +def extract_images_from_openai_content(content: Any) -> tuple[str, List[Dict]]: + """从OpenAI格式的content中提取文本和图片 + + 返回: (文本内容, 图片列表[{type: 'base64'|'url', data: ...}]) + """ + if isinstance(content, str): + return content, [] + + if not isinstance(content, list): + return str(content), [] + + text_parts = [] + images = [] + + for item in content: + if not isinstance(item, dict): + continue + + item_type = item.get("type", "") + + if item_type == "text": + text_parts.append(item.get("text", "")) + + elif item_type == "image_url": + image_url_obj = item.get("image_url", {}) + if isinstance(image_url_obj, str): + url = image_url_obj + else: + url = image_url_obj.get("url", "") + + parsed = parse_base64_data_url(url) + if parsed: + images.append(parsed) + elif url: + # 普通URL + images.append({ + "type": "url", + "url": url + }) + + # 支持直接的 image 类型(带 data 字段) + elif item_type == "image" and item.get("data"): + parsed = parse_base64_data_url(item.get("data")) + if parsed: + images.append(parsed) + + return "\n".join(text_parts), images + + +def download_image_from_url(url: str, proxy: Optional[str] = None) -> tuple[bytes, str]: + """从URL下载图片,返回(图片数据, mime_type)""" + proxies = {"http": proxy, "https": proxy} if proxy else None + resp = requests.get(url, proxies=proxies, verify=False, timeout=60) + resp.raise_for_status() + + content_type = resp.headers.get("Content-Type", "image/png") + # 提取主mime类型 + mime_type = content_type.split(";")[0].strip() + + return resp.content, mime_type + + +def get_session_file_metadata(jwt: str, session_name: str, team_id: str, proxy: Optional[str] = None) -> Dict: + """获取会话中的文件元数据(AI生成的图片)""" + body = { + "configId": team_id, + "additionalParams": {"token": "-"}, + "listSessionFileMetadataRequest": { + "name": session_name, + "filter": "file_origin_type = AI_GENERATED" + } + } + + proxies = {"http": proxy, "https": proxy} if proxy else None + resp = requests.post( + LIST_FILE_METADATA_URL, + headers=get_headers(jwt), + json=body, + proxies=proxies, + verify=False, + timeout=30 + ) + + if resp.status_code != 200: + print(f"[图片] 获取文件元数据失败: {resp.status_code}") + return {} + + data = resp.json() + # 返回 fileId -> metadata 的映射 + result = {} + file_metadata_list = data.get("listSessionFileMetadataResponse", {}).get("fileMetadata", []) + for meta in file_metadata_list: + file_id = meta.get("fileId") + if file_id: + result[file_id] = meta + return result + + +def build_download_url(session_name: str, file_id: str) -> str: + """构造正确的下载URL""" + return f"https://biz-discoveryengine.googleapis.com/v1alpha/{session_name}:downloadFile?fileId={file_id}&alt=media" + + +def download_file_with_jwt(jwt: str, session_name: str, file_id: str, proxy: Optional[str] = None) -> bytes: + """使用JWT认证下载文件""" + url = build_download_url(session_name, file_id) + proxies = {"http": proxy, "https": proxy} if proxy else None + + resp = requests.get( + url, + headers=get_headers(jwt), + proxies=proxies, + verify=False, + timeout=120, + allow_redirects=True + ) + + resp.raise_for_status() + content = resp.content + + # 检测是否为base64编码的内容 + try: + text_content = content.decode("utf-8", errors="ignore").strip() + if text_content.startswith("iVBORw0KGgo") or text_content.startswith("/9j/"): + # 是base64编码,需要解码 + return base64.b64decode(text_content) + except Exception: + pass + + return content + + +def upload_inline_image_to_gemini(jwt: str, session_name: str, team_id: str, + image_data: Dict, proxy: str = None) -> Optional[str]: + """上传内联图片到 Gemini,返回 fileId""" + try: + ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"} + + if image_data.get("type") == "base64": + mime_type = image_data.get("mime_type", "image/png") + file_content = base64.b64decode(image_data.get("data", "")) + ext = ext_map.get(mime_type, ".png") + filename = f"inline_{uuid.uuid4().hex[:8]}{ext}" + elif image_data.get("type") == "url": + file_content, mime_type = download_image_from_url(image_data.get("url"), proxy) + ext = ext_map.get(mime_type, ".png") + filename = f"url_{uuid.uuid4().hex[:8]}{ext}" + else: + return None + + return upload_file_to_gemini(jwt, session_name, team_id, file_content, filename, mime_type, proxy) + except AccountError: + # 让账号相关错误向上抛出,以便触发冷却 + raise + except Exception: + return None + + +def stream_chat_with_images(jwt: str, sess_name: str, message: str, + proxy: str, team_id: str, file_ids: List[str] = None, + account: dict = None, model_id: str = "gemini-3-pro-preview", + include_thought: bool = False) -> ChatResponse: + """发送消息并流式接收响应 + + Args: + model_id: 使用的模型ID,如 gemini-3-pro-preview, gemini-2.5-flash 等 + include_thought: 是否包含思考过程 + """ + query_parts = [{"text": message}] + request_file_ids = file_ids if file_ids else [] + + body = { + "configId": team_id, + "additionalParams": {"token": "-"}, + "streamAssistRequest": { + "session": sess_name, + "query": {"parts": query_parts}, + "filter": "", + "fileIds": request_file_ids, + "answerGenerationMode": "NORMAL", + "assistGenerationConfig":{ + "modelId": model_id + }, + "toolsSpec": { + "webGroundingSpec": {}, + "toolRegistry": "default_tool_registry", + "imageGenerationSpec": {}, + "videoGenerationSpec": {} + }, + "languageCode": "zh-CN", + "userMetadata": {"timeZone": "Etc/GMT-8"}, + "assistSkippingMode": "REQUEST_ASSIST" + } + } + + proxies = {"http": proxy, "https": proxy} if proxy else None + + try: + resp = requests.post( + STREAM_ASSIST_URL, + headers=get_headers(jwt, account), + json=body, + proxies=proxies, + verify=False, + timeout=120, + stream=True + ) + except requests.RequestException as e: + raise AccountRequestError(f"聊天请求失败: {e}") from e + + if resp.status_code != 200: + raise_for_account_response(resp, "聊天请求") + + # 收集完整响应 + full_response = "" + for line in resp.iter_lines(): + if line: + full_response += line.decode('utf-8') + "\n" + + # 解析响应 + result = ChatResponse() + texts = [] + file_ids = [] # 收集需要下载的文件 {fileId, mimeType} + current_session = None + + try: + data_list = json.loads(full_response) + for data in data_list: + sar = data.get("streamAssistResponse") + if not sar: + continue + + # 获取session信息 + session_info = sar.get("sessionInfo", {}) + if session_info.get("session"): + current_session = session_info["session"] + + # 检查顶层的generatedImages + for gen_img in sar.get("generatedImages", []): + parse_generated_image(gen_img, result, proxy) + + answer = sar.get("answer") or {} + + # 检查answer级别的generatedImages + for gen_img in answer.get("generatedImages", []): + parse_generated_image(gen_img, result, proxy) + + for reply in answer.get("replies", []): + # 检查reply级别的generatedImages + for gen_img in reply.get("generatedImages", []): + parse_generated_image(gen_img, result, proxy) + + gc = reply.get("groundedContent", {}) + content = gc.get("content", {}) + text = content.get("text", "") + thought = content.get("thought", False) + + # 检查file字段(图片生成的关键) + file_info = content.get("file") + if file_info and file_info.get("fileId"): + file_ids.append({ + "fileId": file_info["fileId"], + "mimeType": file_info.get("mimeType", "image/png"), + "fileName": file_info.get("name") + }) + + # 解析图片数据 + parse_image_from_content(content, result, proxy) + parse_image_from_content(gc, result, proxy) + + # 检查attachments + for att in reply.get("attachments", []) + gc.get("attachments", []) + content.get("attachments", []): + parse_attachment(att, result, proxy) + + # 收集文本内容 + if text: + if thought: + # 思考过程用特殊格式标记 + if include_thought: + texts.append(f"\n{text}\n\n") + else: + texts.append(text) + + # 处理通过fileId引用的图片 + if file_ids and current_session: + try: + file_metadata = get_session_file_metadata(jwt, current_session, team_id, proxy) + for finfo in file_ids: + fid = finfo["fileId"] + mime = finfo["mimeType"] + fname = finfo.get("fileName") + meta = file_metadata.get(fid) + + if meta: + fname = fname or meta.get("name") + session_path = meta.get("session") or current_session + else: + session_path = current_session + + try: + image_data = download_file_with_jwt(jwt, session_path, fid, proxy) + filename = None + local_path = None + b64_data = base64.b64encode(image_data).decode("utf-8") + + # 仅在 URL 模式下缓存到本地以便通过 /image/ 访问 + if not is_base64_output_mode(): + filename = save_image_to_cache(image_data, mime, fname) + local_path = str(IMAGE_CACHE_DIR / filename) + + img = ChatImage( + file_id=fid, + file_name=filename, + mime_type=mime, + local_path=local_path, + base64_data=b64_data, + ) + result.images.append(img) + if filename: + print(f"[图片] 已保存: {filename}") + except Exception as e: + print(f"[图片] 下载失败 (fileId={fid}): {e}") + except Exception as e: + print(f"[图片] 获取文件元数据失败: {e}") + + except json.JSONDecodeError: + pass + + result.text = "".join(texts) + return result + + +def stream_chat_realtime(jwt: str, sess_name: str, message: str, + proxy: str, team_id: str, file_ids: List[str] = None, + account: dict = None, account_csesidx: str = None, + model_name: str = "gemini-enterprise", + model_id: str = "gemini-3-pro-preview", + include_thought: bool = False): + """真正的流式聊天 - 逐块返回响应 + + 这是一个生成器函数,在接收到 Gemini 每个数据块时立即 yield OpenAI 格式的 SSE 事件。 + 使用正则表达式直接从原始数据中提取文本,实现真正的实时流式输出。 + + Args: + model_id: 使用的模型ID,如 gemini-3-pro-preview, gemini-2.5-flash 等 + include_thought: 是否包含思考过程 + + Yields: + str: SSE 格式的数据行,如 "data: {...}\n\n" + """ + import re + + query_parts = [{"text": message}] + request_file_ids = file_ids if file_ids else [] + + body = { + "configId": team_id, + "additionalParams": {"token": "-"}, + "streamAssistRequest": { + "session": sess_name, + "query": {"parts": query_parts}, + "filter": "", + "fileIds": request_file_ids, + "answerGenerationMode": "NORMAL", + "assistGenerationConfig":{ + "modelId": model_id + }, + "toolsSpec": { + "webGroundingSpec": {}, + "toolRegistry": "default_tool_registry", + "imageGenerationSpec": {}, + "videoGenerationSpec": {} + }, + "languageCode": "zh-CN", + "userMetadata": {"timeZone": "Etc/GMT-8"}, + "assistSkippingMode": "REQUEST_ASSIST" + } + } + + proxies = {"http": proxy, "https": proxy} if proxy else None + chunk_id = f"chatcmpl-{uuid.uuid4().hex[:8]}" + + try: + resp = requests.post( + STREAM_ASSIST_URL, + headers=get_headers(jwt, account), + json=body, + proxies=proxies, + verify=False, + timeout=120, + stream=True + ) + except requests.RequestException as e: + error_chunk = { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model_name, + "account_csesidx": account_csesidx, + "choices": [{ + "index": 0, + "delta": {"content": f"[请求错误: {e}]"}, + "finish_reason": None + }] + } + yield f"data: {json.dumps(error_chunk, ensure_ascii=False)}\n\n" + yield "data: [DONE]\n\n" + return + + if resp.status_code != 200: + error_text = resp.text[:200] if resp.text else f"HTTP {resp.status_code}" + error_chunk = { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model_name, + "account_csesidx": account_csesidx, + "choices": [{ + "index": 0, + "delta": {"content": f"[API错误: {error_text}]"}, + "finish_reason": None + }] + } + yield f"data: {json.dumps(error_chunk, ensure_ascii=False)}\n\n" + yield "data: [DONE]\n\n" + return + + # 使用正则表达式直接提取文本内容 + # Gemini 响应中文本格式: "text":"内容" 或 "text": "内容" + import re + buffer = "" + last_extracted_len = 0 # 已提取的文本总长度 + + # 匹配 "text":"..." 内容的正则,支持转义字符 + text_pattern = re.compile(r'"text"\s*:\s*"((?:[^"\\]|\\.)*)"') + + # 思考过程的格式特征:粗体标题,如 **Some Title**\n + # 这类内容通常是思考过程,可以直接过滤 + thought_header_pattern = re.compile(r'^\*\*[A-Z][^*]*\*\*\\n$') + + # 已输出的文本内容(用于去重) + output_texts = [] + + # 使用 iter_lines 接收数据(通常更快) + for line in resp.iter_lines(decode_unicode=True): + if line: + if isinstance(line, bytes): + line = line.decode('utf-8', errors='replace') + buffer += line + + # 查找所有 "text":"..." 匹配 + for match in text_pattern.finditer(buffer): + text_content = match.group(1) + + # 跳过已输出的内容 + if text_content in output_texts: + continue + output_texts.append(text_content) + + # 解码 JSON 转义字符 + try: + decoded_text = json.loads(f'"{text_content}"') + except: + decoded_text = text_content + + # 检查是否是思考过程(根据格式判断) + # 思考过程特征:短文本 + 粗体标题格式 + is_thought = ( + len(decoded_text) < 100 and + decoded_text.startswith('**') and + decoded_text.rstrip().endswith('**') + ) + + # 跳过思考过程内容(除非用户明确要求) + if is_thought: + if include_thought: + output_text = f"\n{decoded_text}\n\n" + else: + continue # 跳过思考内容 + else: + output_text = decoded_text + + # 立即输出,不做额外的字符分割 + # SSE 本身会处理流式传输 + chunk = { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model_name, + "account_csesidx": account_csesidx, + "choices": [{ + "index": 0, + "delta": {"content": output_text}, + "finish_reason": None + }] + } + yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" + + # 发送结束标记 + end_chunk = { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model_name, + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": "stop" + }] + } + yield f"data: {json.dumps(end_chunk, ensure_ascii=False)}\n\n" + yield "data: [DONE]\n\n" + + +def parse_generated_image(gen_img: Dict, result: ChatResponse, proxy: Optional[str] = None): + """解析generatedImages中的图片""" + image_data = gen_img.get("image") + if not image_data: + return + + # 检查base64数据 + b64_data = image_data.get("bytesBase64Encoded") + if b64_data: + try: + mime_type = image_data.get("mimeType", "image/png") + filename = None + local_path = None + + # 仅在 URL 模式下落盘缓存 + if not is_base64_output_mode(): + decoded = base64.b64decode(b64_data) + filename = save_image_to_cache(decoded, mime_type) + local_path = str(IMAGE_CACHE_DIR / filename) + + img = ChatImage( + base64_data=b64_data, + mime_type=mime_type, + file_name=filename, + local_path=local_path, + ) + result.images.append(img) + if filename: + print(f"[图片] 已保存: {filename}") + except Exception as e: + print(f"[图片] 解析base64失败: {e}") + + +def parse_image_from_content(content: Dict, result: ChatResponse, proxy: Optional[str] = None): + """从content中解析图片""" + # 检查inlineData + inline_data = content.get("inlineData") + if inline_data: + b64_data = inline_data.get("data") + if b64_data: + try: + mime_type = inline_data.get("mimeType", "image/png") + filename = None + local_path = None + + if not is_base64_output_mode(): + decoded = base64.b64decode(b64_data) + filename = save_image_to_cache(decoded, mime_type) + local_path = str(IMAGE_CACHE_DIR / filename) + + img = ChatImage( + base64_data=b64_data, + mime_type=mime_type, + file_name=filename, + local_path=local_path, + ) + result.images.append(img) + if filename: + print(f"[图片] 已保存: {filename}") + except Exception as e: + print(f"[图片] 解析inlineData失败: {e}") + + +def parse_attachment(att: Dict, result: ChatResponse, proxy: Optional[str] = None): + """解析attachment中的图片""" + # 检查是否是图片类型 + mime_type = att.get("mimeType", "") + if not mime_type.startswith("image/"): + return + + # 检查base64数据 + b64_data = att.get("data") or att.get("bytesBase64Encoded") + if b64_data: + try: + filename = None + local_path = None + + if not is_base64_output_mode(): + decoded = base64.b64decode(b64_data) + filename = att.get("name") or None + filename = save_image_to_cache(decoded, mime_type, filename) + local_path = str(IMAGE_CACHE_DIR / filename) + + img = ChatImage( + base64_data=b64_data, + mime_type=mime_type, + file_name=filename, + local_path=local_path, + ) + result.images.append(img) + if filename: + print(f"[图片] 已保存: {filename}") + except Exception as e: + print(f"[图片] 解析attachment失败: {e}") + + +# ==================== OpenAPI 接口 ==================== + +@app.route('/v1/models', methods=['GET']) +@require_api_auth +def list_models(): + """获取模型列表""" + models_config = account_manager.config.get("models", []) + models_data = [] + + for model in models_config: + models_data.append({ + "id": model.get("id", "gemini-enterprise"), + "object": "model", + "created": int(time.time()), + "owned_by": "google", + "permission": [], + "root": model.get("id", "gemini-enterprise"), + "parent": None + }) + + # 如果没有配置模型,返回默认模型 + if not models_data: + models_data.append({ + "id": "gemini-enterprise", + "object": "model", + "created": int(time.time()), + "owned_by": "google", + "permission": [], + "root": "gemini-enterprise", + "parent": None + }) + + return jsonify({"object": "list", "data": models_data}) + + +@app.route('/v1/files', methods=['POST']) +@require_api_auth +def upload_file(): + """OpenAI 兼容的文件上传接口""" + import traceback + request_start_time = time.time() + print(f"\n{'='*60}") + print(f"[文件上传] ===== 接口调用开始 =====") + print(f"[文件上传] 请求时间: {time.strftime('%Y-%m-%d %H:%M:%S')}") + + try: + # 检查是否有文件 + step_start = time.time() + print(f"[文件上传] 步骤1: 检查请求中的文件...") + if 'file' not in request.files: + print(f"[文件上传] 错误: 请求中没有文件") + return jsonify({"error": {"message": "No file provided", "type": "invalid_request_error"}}), 400 + + file = request.files['file'] + if file.filename == '': + print(f"[文件上传] 错误: 文件名为空") + return jsonify({"error": {"message": "No file selected", "type": "invalid_request_error"}}), 400 + print(f"[文件上传] 步骤1完成: 文件名={file.filename}, 耗时={time.time()-step_start:.3f}秒") + + # 获取文件内容和MIME类型 + step_start = time.time() + print(f"[文件上传] 步骤2: 读取文件内容...") + file_content = file.read() + mime_type = file.content_type or mimetypes.guess_type(file.filename)[0] or 'application/octet-stream' + print(f"[文件上传] 步骤2完成: 文件大小={len(file_content)}字节, MIME类型={mime_type}, 耗时={time.time()-step_start:.3f}秒") + + # 获取账号信息 + available_accounts = account_manager.get_available_accounts() + if not available_accounts: + next_cd = account_manager.get_next_cooldown_info() + wait_msg = "" + if next_cd: + wait_msg = f"(最近冷却账号 {next_cd['index']},约 {int(next_cd['cooldown_until']-time.time())} 秒后可重试)" + return jsonify({"error": {"message": f"没有可用的账号{wait_msg}", "type": "rate_limit"}}), 429 + + max_retries = len(available_accounts) + last_error = None + gemini_file_id = None + print(f"[文件上传] 步骤3: 开始尝试上传, 最大重试次数={max_retries}") + + for retry_idx in range(max_retries): + retry_start = time.time() + print(f"\n[文件上传] --- 第{retry_idx+1}次尝试 ---") + account_idx = None + try: + # 获取账号 + step_start = time.time() + print(f"[文件上传] 步骤3.{retry_idx+1}.1: 获取下一个可用账号...") + account_idx, account = account_manager.get_next_account() + print(f"[文件上传] 步骤3.{retry_idx+1}.1完成: 账号索引={account_idx}, CSESIDX={account.get('csesidx')}, 耗时={time.time()-step_start:.3f}秒") + + # 确保会话有效 + step_start = time.time() + print(f"[文件上传] 步骤3.{retry_idx+1}.2: 确保会话有效(JWT+Session)...") + session, jwt, team_id = ensure_session_for_account(account_idx, account) + print(f"[文件上传] 步骤3.{retry_idx+1}.2完成: session={session}, team_id={team_id}, 耗时={time.time()-step_start:.3f}秒") + + proxy = get_proxy() + print(f"[文件上传] 代理设置: {proxy}") + + # 上传文件到 Gemini + step_start = time.time() + print(f"[文件上传] 步骤3.{retry_idx+1}.3: 上传文件到Gemini...") + gemini_file_id = upload_file_to_gemini(jwt, session, team_id, file_content, file.filename, mime_type, proxy) + print(f"[文件上传] 步骤3.{retry_idx+1}.3完成: gemini_file_id={gemini_file_id}, 耗时={time.time()-step_start:.3f}秒") + + if gemini_file_id: + # 生成 OpenAI 格式的 file_id + step_start = time.time() + print(f"[文件上传] 步骤4: 生成OpenAI格式响应...") + openai_file_id = f"file-{uuid.uuid4().hex[:24]}" + + # 保存映射关系 + file_manager.add_file( + openai_file_id=openai_file_id, + gemini_file_id=gemini_file_id, + session_name=session, + filename=file.filename, + mime_type=mime_type, + size=len(file_content) + ) + print(f"[文件上传] 步骤4完成: openai_file_id={openai_file_id}, 耗时={time.time()-step_start:.3f}秒") + + total_time = time.time() - request_start_time + print(f"\n[文件上传] ===== 上传成功 =====") + print(f"[文件上传] 总耗时: {total_time:.3f}秒") + print(f"{'='*60}\n") + + # 返回 OpenAI 格式响应 + return jsonify({ + "id": openai_file_id, + "object": "file", + "bytes": len(file_content), + "created_at": int(time.time()), + "filename": file.filename, + "purpose": request.form.get('purpose', 'assistants') + }) + else: + print(f"[文件上传] 警告: gemini_file_id为空") + + except AccountRateLimitError as e: + last_error = e + if account_idx is not None: + pt_wait = seconds_until_next_pt_midnight() + cooldown_seconds = max(account_manager.rate_limit_cooldown, pt_wait) + account_manager.mark_account_cooldown(account_idx, str(e), cooldown_seconds) + print(f"[文件上传] 第{retry_idx+1}次尝试失败(限额): {e}") + print(f"[文件上传] 本次尝试耗时: {time.time()-retry_start:.3f}秒") + continue + except AccountAuthError as e: + last_error = e + if account_idx is not None: + account_manager.mark_account_cooldown(account_idx, str(e), account_manager.auth_error_cooldown) + print(f"[文件上传] 第{retry_idx+1}次尝试失败(凭证): {e}") + print(f"[文件上传] 本次尝试耗时: {time.time()-retry_start:.3f}秒") + continue + except AccountRequestError as e: + last_error = e + if account_idx is not None: + account_manager.mark_account_cooldown(account_idx, str(e), account_manager.generic_error_cooldown) + print(f"[文件上传] 第{retry_idx+1}次尝试失败(请求异常): {e}") + print(f"[文件上传] 本次尝试耗时: {time.time()-retry_start:.3f}秒") + continue + except NoAvailableAccount as e: + last_error = e + print(f"[文件上传] 无可用账号: {e}") + break + except Exception as e: + last_error = e + print(f"[文件上传] 第{retry_idx+1}次尝试失败: {type(e).__name__}: {e}") + print(f"[文件上传] 堆栈跟踪:\n{traceback.format_exc()}") + print(f"[文件上传] 本次尝试耗时: {time.time()-retry_start:.3f}秒") + if account_idx is None: + break + continue + + total_time = time.time() - request_start_time + print(f"\n[文件上传] ===== 所有重试均失败 =====") + error_message = last_error or "没有可用的账号" + print(f"[文件上传] 最后错误: {error_message}") + print(f"[文件上传] 总耗时: {total_time:.3f}秒") + print(f"{'='*60}\n") + status_code = 429 if isinstance(last_error, (AccountRateLimitError, NoAvailableAccount)) else 500 + err_type = "rate_limit" if status_code == 429 else "api_error" + return jsonify({"error": {"message": f"文件上传失败: {error_message}", "type": err_type}}), status_code + + except Exception as e: + total_time = time.time() - request_start_time + print(f"\n[文件上传] ===== 发生异常 =====") + print(f"[文件上传] 错误类型: {type(e).__name__}") + print(f"[文件上传] 错误信息: {e}") + print(f"[文件上传] 堆栈跟踪:\n{traceback.format_exc()}") + print(f"[文件上传] 总耗时: {total_time:.3f}秒") + print(f"{'='*60}\n") + return jsonify({"error": {"message": str(e), "type": "api_error"}}), 500 + + +@app.route('/v1/files', methods=['GET']) +@require_api_auth +def list_files(): + """获取已上传文件列表""" + files = file_manager.list_files() + return jsonify({ + "object": "list", + "data": [{ + "id": f["openai_file_id"], + "object": "file", + "bytes": f.get("size", 0), + "created_at": f.get("created_at", int(time.time())), + "filename": f.get("filename", ""), + "purpose": "assistants" + } for f in files] + }) + + +@app.route('/v1/files/', methods=['GET']) +@require_api_auth +def get_file(file_id): + """获取文件信息""" + file_info = file_manager.get_file(file_id) + if not file_info: + return jsonify({"error": {"message": "File not found", "type": "invalid_request_error"}}), 404 + + return jsonify({ + "id": file_info["openai_file_id"], + "object": "file", + "bytes": file_info.get("size", 0), + "created_at": file_info.get("created_at", int(time.time())), + "filename": file_info.get("filename", ""), + "purpose": "assistants" + }) + + +@app.route('/v1/files/', methods=['DELETE']) +@require_api_auth +def delete_file(file_id): + """删除文件""" + if file_manager.delete_file(file_id): + return jsonify({ + "id": file_id, + "object": "file", + "deleted": True + }) + return jsonify({"error": {"message": "File not found", "type": "invalid_request_error"}}), 404 + + +@app.route('/v1/chat/completions', methods=['POST']) +@require_api_auth +def chat_completions(): + """聊天对话接口(支持图片输入输出)""" + try: + # 每次请求时清理过期图片 + cleanup_expired_images() + + data = request.json + messages = data.get('messages', []) + prompts = data.get('prompts', []) # 支持替代格式 + stream = data.get('stream', False) + requested_model = data.get('model', 'gemini-enterprise') # 保存请求的模型名称 + include_thought = data.get('include_thought', False) # 是否包含思考过程 + + # 模型ID映射:将请求的模型名称映射为 Gemini 支持的模型ID + # 从配置中获取模型映射,格式:{"id": "模型名", "name": "显示名称"} + models_config = account_manager.config.get("models", []) + models_mapping = {m.get("id", ""): m.get("id", "") for m in models_config} # id -> id 映射 + # 如果请求的模型在配置中存在,使用配置的ID;否则直接使用请求的模型名 + model_id = models_mapping.get(requested_model, requested_model) + + # 提取用户消息、图片和文件ID + user_message = "" + input_images = [] + input_file_ids = [] # OpenAI file_id 列表 + + # 处理标准 OpenAI messages 格式 + for msg in messages: + if msg.get('role') == 'user': + content = msg.get('content', '') + text, images = extract_images_from_openai_content(content) + if text: + user_message = text + input_images.extend(images) + + # 提取文件ID(支持多种格式) + if isinstance(content, list): + for item in content: + if isinstance(item, dict): + # 格式1: {"type": "file", "file_id": "xxx"} + if item.get('type') == 'file' and item.get('file_id'): + input_file_ids.append(item['file_id']) + # 格式2: {"type": "file", "file": {"file_id": "xxx"}} + elif item.get('type') == 'file' and isinstance(item.get('file'), dict): + file_obj = item['file'] + # 支持 file_id 或 id 两种字段名 + fid = file_obj.get('file_id') or file_obj.get('id') + if fid: + input_file_ids.append(fid) + + # 处理替代 prompts 格式(支持内联 base64 图片) + # 格式: {"prompts": [{"role": "user", "text": "...", "files": [{"data": "data:image...", "type": "image"}]}]} + for prompt in prompts: + if prompt.get('role') == 'user': + # 提取文本 + prompt_text = prompt.get('text', '') + if prompt_text and not user_message: + user_message = prompt_text + elif prompt_text: + user_message = prompt_text # 使用最新的用户消息 + + # 提取内联 files 数组中的图片 + files_array = prompt.get('files', []) + if files_array: + images_from_files = extract_images_from_files_array(files_array) + input_images.extend(images_from_files) + + # 将 OpenAI file_id 转换为 Gemini fileId + gemini_file_ids = [] + for fid in input_file_ids: + gemini_fid = file_manager.get_gemini_file_id(fid) + if gemini_fid: + gemini_file_ids.append(gemini_fid) + + if not user_message and not input_images and not gemini_file_ids: + return jsonify({"error": "No user message found"}), 400 + + # 检查是否指定了特定账号 + specified_account_id = data.get('account_id') + + # ========== 流式模式处理 ========== + if stream: + # 获取账号信息 + if specified_account_id is not None: + # 使用指定的账号 + accounts = account_manager.accounts + if specified_account_id < 0 or specified_account_id >= len(accounts): + return jsonify({"error": f"无效的账号ID: {specified_account_id}"}), 400 + account = accounts[specified_account_id] + if not account.get('enabled', True): + return jsonify({"error": f"账号 {specified_account_id} 已禁用"}), 400 + cooldown_until = account.get('cooldown_until', 0) + if cooldown_until > time.time(): + return jsonify({"error": f"账号 {specified_account_id} 正在冷却中,请稍后重试"}), 429 + account_idx = specified_account_id + else: + # 轮训获取账号 + available_accounts = account_manager.get_available_accounts() + if not available_accounts: + next_cd = account_manager.get_next_cooldown_info() + wait_msg = "" + if next_cd: + wait_msg = f"(最近冷却账号 {next_cd['index']},约 {int(next_cd['cooldown_until']-time.time())} 秒后可重试)" + return jsonify({"error": f"没有可用的账号{wait_msg}"}), 429 + account_idx, account = account_manager.get_next_account() + + try: + session, jwt, team_id = ensure_session_for_account(account_idx, account) + proxy = get_proxy() + + # 上传内联图片获取 fileId + for img in input_images: + uploaded_file_id = upload_inline_image_to_gemini(jwt, session, team_id, img, proxy) + if uploaded_file_id: + gemini_file_ids.append(uploaded_file_id) + + # 获取账号标识 + used_account_csesidx = account.get('csesidx', f'账号{account_idx}') + + # 真正的流式响应 + def generate_stream(): + for chunk in stream_chat_realtime( + jwt, session, user_message, proxy, team_id, + gemini_file_ids, account, used_account_csesidx, + model_name=requested_model, + model_id=model_id, + include_thought=include_thought + ): + yield chunk + + # 创建响应并设置必要的头部禁用缓冲 + response = Response(generate_stream(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response.headers['X-Accel-Buffering'] = 'no' # 禁用 nginx 缓冲 + response.headers['Connection'] = 'keep-alive' + return response + + except (AccountRateLimitError, AccountAuthError, AccountRequestError) as e: + account_manager.mark_account_cooldown(account_idx, str(e), account_manager.generic_error_cooldown) + return jsonify({"error": f"账号请求失败: {e}"}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + # ========== 非流式模式处理 ========== + if specified_account_id is not None: + # 使用指定的账号 + accounts = account_manager.accounts + if specified_account_id < 0 or specified_account_id >= len(accounts): + return jsonify({"error": f"无效的账号ID: {specified_account_id}"}), 400 + account = accounts[specified_account_id] + if not account.get('enabled', True): + return jsonify({"error": f"账号 {specified_account_id} 已禁用"}), 400 + # 检查是否在冷却中 + cooldown_until = account.get('cooldown_until', 0) + if cooldown_until > time.time(): + return jsonify({"error": f"账号 {specified_account_id} 正在冷却中��请稍后重试"}), 429 + + max_retries = 1 + last_error = None + chat_response = None + account_idx = specified_account_id + try: + session, jwt, team_id = ensure_session_for_account(account_idx, account) + proxy = get_proxy() + + for img in input_images: + uploaded_file_id = upload_inline_image_to_gemini(jwt, session, team_id, img, proxy) + if uploaded_file_id: + gemini_file_ids.append(uploaded_file_id) + + chat_response = stream_chat_with_images(jwt, session, user_message, proxy, team_id, gemini_file_ids, account, model_id=model_id, include_thought=include_thought) + except (AccountRateLimitError, AccountAuthError, AccountRequestError) as e: + last_error = e + account_manager.mark_account_cooldown(account_idx, str(e), account_manager.generic_error_cooldown) + except Exception as e: + last_error = e + else: + # 轮训获取账号 + available_accounts = account_manager.get_available_accounts() + if not available_accounts: + next_cd = account_manager.get_next_cooldown_info() + wait_msg = "" + if next_cd: + wait_msg = f"(最近冷却账号 {next_cd['index']},约 {int(next_cd['cooldown_until']-time.time())} 秒后可重试)" + return jsonify({"error": f"没有可用的账号{wait_msg}"}), 429 + + max_retries = len(available_accounts) + last_error = None + chat_response = None + + for retry_idx in range(max_retries): + account_idx = None + try: + account_idx, account = account_manager.get_next_account() + session, jwt, team_id = ensure_session_for_account(account_idx, account) + proxy = get_proxy() + + # 上传内联图片获取 fileId + for img in input_images: + uploaded_file_id = upload_inline_image_to_gemini(jwt, session, team_id, img, proxy) + if uploaded_file_id: + gemini_file_ids.append(uploaded_file_id) + + chat_response = stream_chat_with_images(jwt, session, user_message, proxy, team_id, gemini_file_ids, account, model_id=model_id, include_thought=include_thought) + break + except AccountRateLimitError as e: + last_error = e + if account_idx is not None: + pt_wait = seconds_until_next_pt_midnight() + cooldown_seconds = max(account_manager.rate_limit_cooldown, pt_wait) + account_manager.mark_account_cooldown(account_idx, str(e), cooldown_seconds) + print(f"[聊天] 第{retry_idx+1}次尝试失败(限额): {e}") + continue + except AccountAuthError as e: + last_error = e + if account_idx is not None: + account_manager.mark_account_cooldown(account_idx, str(e), account_manager.auth_error_cooldown) + print(f"[聊天] 第{retry_idx+1}次尝试失败(凭证): {e}") + continue + except AccountRequestError as e: + last_error = e + if account_idx is not None: + account_manager.mark_account_cooldown(account_idx, str(e), account_manager.generic_error_cooldown) + print(f"[聊天] 第{retry_idx+1}次尝试失败(请求异常): {e}") + continue + except Exception as e: + last_error = e + print(f"[聊天] 第{retry_idx+1}次尝试失败: {type(e).__name__}: {e}") + if account_idx is None: + break + continue + + if chat_response is None: + error_message = last_error or "没有可用的账号" + status_code = 429 if isinstance(last_error, (AccountRateLimitError, NoAvailableAccount)) else 500 + return jsonify({"error": f"所有账号请求失败: {error_message}"}), status_code + + # 获取使用的账号csesidx + used_account_csesidx = None + if account_idx is not None and account_idx < len(account_manager.accounts): + used_account = account_manager.accounts[account_idx] + used_account_csesidx = used_account.get('csesidx', f'账号{account_idx}') + + # 构建响应内容(包含图片) + response_content = build_openai_response_content(chat_response, request.host_url) + + # 非流式响应 + response = { + "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", + "object": "chat.completion", + "created": int(time.time()), + "model": requested_model, + "account_csesidx": used_account_csesidx, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": response_content + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": len(user_message), + "completion_tokens": len(chat_response.text), + "total_tokens": len(user_message) + len(chat_response.text) + } + } + return jsonify(response) + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + + +def get_image_base_url(fallback_host_url: str) -> str: + """获取图片基础URL + + 优先使用配置文件中的 image_base_url,否则使用请求的 host_url + """ + configured_url = account_manager.config.get("image_base_url", "").strip() + if configured_url: + # 确保以 / 结尾 + if not configured_url.endswith("/"): + configured_url += "/" + return configured_url + return fallback_host_url + + +def is_base64_output_mode() -> bool: + try: + if account_manager.config: + mode = account_manager.config.get("image_output_mode") or "url" + if isinstance(mode, str) and mode.lower() == "base64": + return True + except Exception: + pass + return False + + +def build_openai_response_content(chat_response: ChatResponse, host_url: str) -> str: + """构建OpenAI格式的响应内容 + + 返回纯文本,如果有图片可根据配置选择: + - url: 在文本末尾追加图片URL(默认行为) + - base64: 在文本末尾追加 data:image/...;base64,... + """ + result_text = chat_response.text + + if not chat_response.images: + return result_text + + # 从配置读取图片输出模式,默认 url + image_mode = "base64" if is_base64_output_mode() else "url" + + image_lines = [] + + if image_mode == "base64": + # 优先使用已有的base64数据(使用 Markdown 图片语法,方便前端渲染) + for img in chat_response.images: + if img.base64_data: + mime = img.mime_type or "image/png" + image_lines.append(f"![image](data:{mime};base64,{img.base64_data})") + + # 若部分图片没有base64数据,降级为URL形式,同样用 Markdown 图片语法 + base_url = get_image_base_url(host_url) + for img in chat_response.images: + if not img.base64_data and img.file_name: + image_lines.append(f"![image]({base_url}image/{img.file_name})") + else: + # 传统URL模式 + base_url = get_image_base_url(host_url) + for img in chat_response.images: + if img.file_name: + image_lines.append(f"{base_url}image/{img.file_name}") + + if image_lines: + if result_text: + result_text += "\n\n" + result_text += "\n".join(image_lines) + + return result_text + + +# ==================== 图片服务接口 ==================== + +@app.route('/image/') +def serve_image(filename): + """提供缓存图片的访问""" + # 安全检查:防止路径遍历 + if '..' in filename or filename.startswith('/'): + abort(404) + + filepath = IMAGE_CACHE_DIR / filename + if not filepath.exists(): + abort(404) + + # 确定Content-Type + ext = filepath.suffix.lower() + mime_types = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + } + mime_type = mime_types.get(ext, 'application/octet-stream') + + return send_from_directory(IMAGE_CACHE_DIR, filename, mimetype=mime_type) + + +@app.route('/health', methods=['GET']) +def health_check(): + """健康检查""" + return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()}) + + +@app.route('/api/status', methods=['GET']) +@require_admin +def system_status(): + """获取系统状态""" + total, available = account_manager.get_account_count() + proxy_url = account_manager.config.get("proxy") + proxy_enabled = account_manager.config.get("proxy_enabled", False) + effective_proxy = get_proxy() # 实际使用的代理(考虑开关状态) + + return jsonify({ + "status": "ok", + "timestamp": datetime.now().isoformat(), + "accounts": { + "total": total, + "available": available + }, + "proxy": { + "url": proxy_url, + "enabled": proxy_enabled, + "effective": effective_proxy, # 实际生效的代理地址 + "available": check_proxy(effective_proxy) if effective_proxy else False + }, + "models": account_manager.config.get("models", []) + }) + + +# ==================== 管理接口 ==================== + +@app.route('/') +def index(): + """返回管理页面""" + return send_from_directory('.', 'index.html') + +@app.route('/chat_history.html') +@require_admin +def chat_history(): + """返回聊天记录页面""" + return send_from_directory('.', 'chat_history.html') + +@app.route('/api/accounts', methods=['GET']) +@require_admin +def get_accounts(): + """获取账号列表""" + accounts_data = [] + now_ts = time.time() + for i, acc in enumerate(account_manager.accounts): + state = account_manager.account_states.get(i, {}) + cooldown_until = state.get("cooldown_until") + cooldown_active = bool(cooldown_until and cooldown_until > now_ts) + effective_available = state.get("available", True) and not cooldown_active + + # 返回完整值用于编辑,前端显示时再截断 + accounts_data.append({ + "id": i, + "team_id": acc.get("team_id", ""), + "secure_c_ses": acc.get("secure_c_ses", ""), + "host_c_oses": acc.get("host_c_oses", ""), + "csesidx": acc.get("csesidx", ""), + "user_agent": acc.get("user_agent", ""), + "available": effective_available, + "unavailable_reason": acc.get("unavailable_reason", ""), + "cooldown_until": cooldown_until if cooldown_active else None, + "cooldown_reason": state.get("cooldown_reason", ""), + "has_jwt": state.get("jwt") is not None + }) + return jsonify({"accounts": accounts_data}) + + +@app.route('/api/accounts', methods=['POST']) +@require_admin +def add_account(): + """添加账号""" + data = request.json + # 去重:基于 csesidx 或 team_id 检查 + new_csesidx = data.get("csesidx", "") + new_team_id = data.get("team_id", "") + for acc in account_manager.accounts: + if new_csesidx and acc.get("csesidx") == new_csesidx: + return jsonify({"error": "账号已存在(同 csesidx)"}), 400 + if new_team_id and acc.get("team_id") == new_team_id and new_csesidx == acc.get("csesidx"): + return jsonify({"error": "账号已存在(同 team_id + csesidx)"}), 400 + + new_account = { + "team_id": data.get("team_id", ""), + "secure_c_ses": data.get("secure_c_ses", ""), + "host_c_oses": data.get("host_c_oses", ""), + "csesidx": data.get("csesidx", ""), + "user_agent": data.get("user_agent", "Mozilla/5.0"), + "available": True + } + + account_manager.accounts.append(new_account) + idx = len(account_manager.accounts) - 1 + account_manager.account_states[idx] = { + "jwt": None, + "jwt_time": 0, + "session": None, + "available": True, + "cooldown_until": None, + "cooldown_reason": "" + } + account_manager.config["accounts"] = account_manager.accounts + account_manager.save_config() + + return jsonify({"success": True, "id": idx}) + + +@app.route('/api/accounts/', methods=['PUT']) +@require_admin +def update_account(account_id): + """更新账号""" + if account_id < 0 or account_id >= len(account_manager.accounts): + return jsonify({"error": "账号不存在"}), 404 + + data = request.json + acc = account_manager.accounts[account_id] + + if "team_id" in data: + acc["team_id"] = data["team_id"] + if "secure_c_ses" in data: + acc["secure_c_ses"] = data["secure_c_ses"] + if "host_c_oses" in data: + acc["host_c_oses"] = data["host_c_oses"] + if "csesidx" in data: + acc["csesidx"] = data["csesidx"] + if "user_agent" in data: + acc["user_agent"] = data["user_agent"] + + # 同步更新config中的accounts + account_manager.config["accounts"] = account_manager.accounts + account_manager.save_config() + return jsonify({"success": True}) + + +@app.route('/api/accounts/', methods=['DELETE']) +@require_admin +def delete_account(account_id): + """删除账号""" + if account_id < 0 or account_id >= len(account_manager.accounts): + return jsonify({"error": "账号不存在"}), 404 + + account_manager.accounts.pop(account_id) + # 重建状态映射 + new_states = {} + for i in range(len(account_manager.accounts)): + if i < account_id: + new_states[i] = account_manager.account_states.get(i, {}) + else: + new_states[i] = account_manager.account_states.get(i + 1, {}) + account_manager.account_states = new_states + account_manager.config["accounts"] = account_manager.accounts + account_manager.save_config() + + return jsonify({"success": True}) + + +@app.route('/api/accounts//toggle', methods=['POST']) +@require_admin +def toggle_account(account_id): + """切换账号状态""" + if account_id < 0 or account_id >= len(account_manager.accounts): + return jsonify({"error": "账号不存在"}), 404 + + state = account_manager.account_states.get(account_id, {}) + current = state.get("available", True) + state["available"] = not current + account_manager.accounts[account_id]["available"] = not current + + if not current: + # 重新启用时清除错误信息 + account_manager.accounts[account_id].pop("unavailable_reason", None) + account_manager.accounts[account_id].pop("unavailable_time", None) + state.pop("cooldown_until", None) + state.pop("cooldown_reason", None) + account_manager.accounts[account_id].pop("cooldown_until", None) + + account_manager.save_config() + return jsonify({"success": True, "available": not current}) + + +@app.route('/api/accounts//refresh-cookie', methods=['POST']) +@require_admin +def refresh_account_cookies(account_id): + """刷新账号的secure_c_ses、host_c_oses和csesidx""" + if account_id < 0 or account_id >= len(account_manager.accounts): + return jsonify({"error": "账号不存在"}), 404 + + data = request.json + acc = account_manager.accounts[account_id] + + # 更新Cookie字段 + if "secure_c_ses" in data: + acc["secure_c_ses"] = data["secure_c_ses"] + if "host_c_oses" in data: + acc["host_c_oses"] = data["host_c_oses"] + if "csesidx" in data and data["csesidx"]: + acc["csesidx"] = data["csesidx"] + + # 清除JWT缓存,强制重新获取 + state = account_manager.account_states.get(account_id, {}) + state["jwt"] = None + state["jwt_time"] = 0 + account_manager.account_states[account_id] = state + + account_manager.config["accounts"] = account_manager.accounts + account_manager.save_config() + + return jsonify({"success": True, "message": "Cookie已刷新"}) + + +@app.route('/api/accounts//test', methods=['GET']) +@require_admin +def test_account(account_id): + """测试账号JWT获取""" + if account_id < 0 or account_id >= len(account_manager.accounts): + return jsonify({"error": "账号不存在"}), 404 + + account = account_manager.accounts[account_id] + proxy = account_manager.config.get("proxy") + + try: + jwt = get_jwt_for_account(account, proxy) + return jsonify({"success": True, "message": "JWT获取成功"}) + except AccountRateLimitError as e: + pt_wait = seconds_until_next_pt_midnight() + cooldown_seconds = max(account_manager.rate_limit_cooldown, pt_wait) + account_manager.mark_account_cooldown(account_id, str(e), cooldown_seconds) + return jsonify({"success": False, "message": str(e), "cooldown": cooldown_seconds}) + except AccountAuthError as e: + account_manager.mark_account_cooldown(account_id, str(e), account_manager.auth_error_cooldown) + return jsonify({"success": False, "message": str(e), "cooldown": account_manager.auth_error_cooldown}) + except AccountRequestError as e: + account_manager.mark_account_cooldown(account_id, str(e), account_manager.generic_error_cooldown) + return jsonify({"success": False, "message": str(e), "cooldown": account_manager.generic_error_cooldown}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}) + + +@app.route('/api/models', methods=['GET']) +@require_admin +def get_models_config(): + """获取模型配置""" + models = account_manager.config.get("models", []) + return jsonify({"models": models}) + + +@app.route('/api/models', methods=['POST']) +@require_admin +def add_model(): + """添加模型""" + data = request.json + new_model = { + "id": data.get("id", ""), + "name": data.get("name", ""), + "description": data.get("description", ""), + "context_length": data.get("context_length", 32768), + "max_tokens": data.get("max_tokens", 8192), + "enabled": data.get("enabled", True) + } + + if "models" not in account_manager.config: + account_manager.config["models"] = [] + + account_manager.config["models"].append(new_model) + account_manager.save_config() + + return jsonify({"success": True}) + + +@app.route('/api/models/', methods=['PUT']) +@require_admin +def update_model(model_id): + """更新模型""" + models = account_manager.config.get("models", []) + for model in models: + if model.get("id") == model_id: + data = request.json + if "name" in data: + model["name"] = data["name"] + if "description" in data: + model["description"] = data["description"] + if "context_length" in data: + model["context_length"] = data["context_length"] + if "max_tokens" in data: + model["max_tokens"] = data["max_tokens"] + if "enabled" in data: + model["enabled"] = data["enabled"] + account_manager.save_config() + return jsonify({"success": True}) + + return jsonify({"error": "模型不存在"}), 404 + + +@app.route('/api/models/', methods=['DELETE']) +@require_admin +def delete_model(model_id): + """删除模型""" + models = account_manager.config.get("models", []) + for i, model in enumerate(models): + if model.get("id") == model_id: + models.pop(i) + account_manager.save_config() + return jsonify({"success": True}) + + return jsonify({"error": "模型不存在"}), 404 + + +@app.route('/api/config', methods=['GET']) +@require_admin +def get_config(): + """获取完整配置""" + return jsonify(account_manager.config) + + +@app.route('/api/config', methods=['PUT']) +@require_admin +def update_config(): + """更新配置""" + data = request.json or {} + if "proxy" in data: + account_manager.config["proxy"] = data["proxy"] + if "log_level" in data: + try: + set_log_level(data["log_level"], persist=True) + except Exception as e: + return jsonify({"error": str(e)}), 400 + if "image_output_mode" in data: + mode = data["image_output_mode"] + if isinstance(mode, str) and mode.lower() in ("url", "base64"): + account_manager.config["image_output_mode"] = mode.lower() + account_manager.save_config() + return jsonify({"success": True}) + + +@app.route('/api/logging', methods=['GET', 'POST']) +@require_admin +def logging_config(): + """获取或设置日志级别""" + if request.method == 'GET': + return jsonify({ + "level": CURRENT_LOG_LEVEL_NAME, + "levels": list(LOG_LEVELS.keys()) + }) + + data = request.json or {} + level = data.get("level", "").upper() + if level not in LOG_LEVELS: + return jsonify({"error": "无效日志级别"}), 400 + + set_log_level(level, persist=True) + return jsonify({"success": True, "level": CURRENT_LOG_LEVEL_NAME}) + + +@app.route('/api/auth/login', methods=['POST']) +def admin_login(): + """后台登录,返回 token。若尚未设置密码,则首次设置。""" + data = request.json or {} + password = data.get("password", "") + if not password: + return jsonify({"error": "密码不能为空"}), 400 + + stored_hash = get_admin_password_hash() + if stored_hash: + if not check_password_hash(stored_hash, password): + return jsonify({"error": "密码错误"}), 401 + else: + # 首次设置密码 + set_admin_password(password) + + token = create_admin_token() + resp = jsonify({"token": token, "level": CURRENT_LOG_LEVEL_NAME}) + resp.set_cookie( + "admin_token", + token, + max_age=86400, + httponly=True, + secure=False, + samesite="Lax", + path="/" + ) + return resp + + +@app.route('/api/tokens', methods=['GET', 'POST']) +@require_admin +def manage_tokens(): + """获取或创建API访问Token""" + if request.method == 'GET': + return jsonify({"tokens": list(API_TOKENS)}) + + data = request.json or {} + token = data.get("token") + if not token: + token = secrets.token_urlsafe(32) + if not isinstance(token, str) or len(token) < 8: + return jsonify({"error": "Token格式不合法"}), 400 + if token in API_TOKENS: + return jsonify({"error": "Token已存在"}), 400 + + API_TOKENS.add(token) + persist_api_tokens() + return jsonify({"success": True, "token": token}) + + +@app.route('/api/tokens/', methods=['DELETE']) +@require_admin +def delete_token(token): + """删除指定API Token""" + if token in API_TOKENS: + API_TOKENS.remove(token) + persist_api_tokens() + return jsonify({"success": True}) + return jsonify({"error": "Token不存在"}), 404 + + +@app.route('/api/config/import', methods=['POST']) +@require_admin +def import_config(): + """导入配置""" + try: + data = request.json + account_manager.config = data + if data.get("log_level"): + try: + set_log_level(data.get("log_level"), persist=False) + except Exception: + pass + if data.get("admin_secret_key"): + global ADMIN_SECRET_KEY + ADMIN_SECRET_KEY = data.get("admin_secret_key") + else: + get_admin_secret_key() + load_api_tokens() + account_manager.accounts = data.get("accounts", []) + # 重建账号状态 + account_manager.account_states = {} + for i, acc in enumerate(account_manager.accounts): + available = acc.get("available", True) + account_manager.account_states[i] = { + "jwt": None, + "jwt_time": 0, + "session": None, + "available": available, + "cooldown_until": acc.get("cooldown_until"), + "cooldown_reason": acc.get("unavailable_reason") or acc.get("cooldown_reason") or "" + } + account_manager.save_config() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"error": str(e)}), 400 + + +@app.route('/api/proxy/test', methods=['POST']) +@require_admin +def test_proxy(): + """测试代理""" + data = request.json + proxy_url = data.get("proxy") or account_manager.config.get("proxy") + + if not proxy_url: + return jsonify({"success": False, "message": "未配置代理地址"}) + + available = check_proxy(proxy_url) + return jsonify({ + "success": available, + "message": "代理可用" if available else "代理不可用或连接超时" + }) + + +@app.route('/api/proxy/status', methods=['GET']) +@require_admin +def get_proxy_status(): + """获取代理状态""" + proxy = account_manager.config.get("proxy") + if not proxy: + return jsonify({"enabled": False, "url": None, "available": False}) + + available = check_proxy(proxy) + return jsonify({ + "enabled": True, + "url": proxy, + "available": available + }) + + +@app.route('/api/config/export', methods=['GET']) +@require_admin +def export_config(): + """导出配置""" + return jsonify(account_manager.config) + + +def print_startup_info(): + """打印启动信息""" + print("="*60) + print("Business Gemini OpenAPI 服务 (多账号轮训版)") + print("支持图片输入输出 (OpenAI格式)") + print("="*60) + + # 检测配置文件是否存在,不存在则从 .example 复制初始化 + example_file = Path(__file__).parent / "business_gemini_session.json.example" + if not CONFIG_FILE.exists(): + if example_file.exists(): + shutil.copy(example_file, CONFIG_FILE) + print(f"\n[初始化] 配置文件不存在,已从 {example_file.name} 复制创建") + else: + print(f"\n[警告] 配置文件和示例文件都不存在,请创建 {CONFIG_FILE.name}") + + # 加载配置 + account_manager.load_config() + get_admin_secret_key() + + # 代理信息 + proxy = account_manager.config.get("proxy") + print(f"\n[代理配置]") + print(f" 地址: {proxy or '未配置'}") + if proxy: + proxy_available = check_proxy(proxy) + print(f" 状态: {'✓ 可用' if proxy_available else '✗ 不可用'}") + + # 图片缓存信息 + print(f"\n[图片缓存]") + print(f" 目录: {IMAGE_CACHE_DIR}") + print(f" 缓存时间: {IMAGE_CACHE_HOURS} 小时") + + # 账号信息 + total, available = account_manager.get_account_count() + print(f"\n[账号配置]") + print(f" 总数量: {total}") + print(f" 可用数量: {available}") + + for i, acc in enumerate(account_manager.accounts): + state = account_manager.account_states.get(i, {}) + is_available = account_manager.is_account_available(i) + status = "✓" if is_available else "✗" + team_id = acc.get("team_id", "未知") + "..." + cooldown_until = state.get("cooldown_until") + extra = "" + if cooldown_until and cooldown_until > time.time(): + remaining = int(cooldown_until - time.time()) + extra = f" (冷却中 ~{remaining}s)" + print(f" [{i}] {status} team_id: {team_id}{extra}") + + # 模型信息 + models = account_manager.config.get("models", []) + print(f"\n[模型配置]") + if models: + for model in models: + print(f" - {model.get('id')}: {model.get('name', '')}") + else: + print(" - gemini-enterprise (默认)") + + print(f"\n[接口列表]") + print(" GET /v1/models - 获取模型列表") + print(" POST /v1/chat/completions - 聊天对话 (支持图片)") + print(" GET /v1/status - 系统状态") + print(" GET /health - 健康检查") + print(" GET /image/ - 获取缓存图片") + print("\n" + "="*60) + print("启动服务...") + + +if __name__ == '__main__': + print_startup_info() + + if not account_manager.accounts: + print("[!] 警告: 没有配置任何账号") + + app.run(host='0.0.0.0', port=8000, debug=False)