| |
| |
| |
| import os |
| import re |
| import time |
| import logging |
| from threading import Lock |
| from typing import Dict, Any, Optional, List, Tuple, Set, Union |
| import copy |
| from collections import defaultdict |
| import asyncio |
| from sqlalchemy.orm import Session |
| from sqlalchemy.ext.asyncio import AsyncSession |
|
|
| |
| from app.core.tracking import ( |
| usage_data, usage_lock, |
| key_scores_cache, cache_lock, cache_last_updated, update_cache_timestamp, |
| RPM_WINDOW_SECONDS, TPM_WINDOW_SECONDS, CACHE_REFRESH_INTERVAL_SECONDS |
| ) |
|
|
| |
| from datetime import datetime, timezone |
| import pytz |
|
|
| |
| from app.core.database import utils as db_utils |
| from app.core.database.models import UserKeyAssociation, ApiKey |
| |
| from app import config |
|
|
| |
| logger = logging.getLogger("my_logger") |
|
|
| class APIKeyManager: |
| """ |
| 管理 Gemini API 密钥的核心类。 |
| 负责加载、存储、选择和管理 API Key 的状态。 |
| 主要功能包括: |
| - 根据配置从环境变量或数据库加载 Keys。 |
| - 提供 Key 的轮询和基于策略的最佳 Key 选择。 |
| - 处理无效或达到限制的 Key (每日配额耗尽、临时不可用)。 |
| - 支持粘性会话 (优先使用用户上次使用的 Key)。 |
| - 支持缓存关联 (优先使用与缓存内容关联的 Key)。 |
| - 跟踪 Key 选择过程中的决策原因。 |
| - 提供内存模式下的 Key 管理方法 (用于测试或特定场景)。 |
| """ |
| |
|
|
| def __init__(self): |
| """ |
| 初始化 APIKeyManager 实例。 |
| - 初始化存储 API Key 字符串的列表 (api_keys)。 |
| - 初始化存储 Key 配置信息的字典 (key_configs)。 |
| - 创建线程锁 (keys_lock) 以保护对 Key 列表和配置的并发访问。 |
| - 初始化用于跟踪当前请求已尝试 Key 的集合 (tried_keys_for_request)。 |
| - 初始化存储每日配额耗尽 Key 的字典 (daily_exhausted_keys)。 |
| - 初始化存储临时不可用 Key 及其过期时间戳的字典 (temporary_issue_keys)。 |
| - 获取当前日期字符串 (_today_date_str),用于每日配额检查。 |
| - 初始化用于粘性会话的用户-Key 映射 (user_key_map,目前未使用,逻辑在数据库中)。 |
| - 初始化用于记录 Key 选择原因的列表 (key_selection_records) 和对应的锁 (records_lock)。 |
| """ |
| self.api_keys: List[str] = [] |
| self.key_configs: Dict[str, Dict[str, Any]] = {} |
|
|
| self.keys_lock = Lock() |
| self.tried_keys_for_request: Set[str] = set() |
| self.daily_exhausted_keys: Dict[str, str] = {} |
| self.temporary_issue_keys: Dict[str, float] = {} |
| self._today_date_str = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d') |
| |
| self.key_selection_records: List[Dict[str, Any]] = [] |
| self.records_lock = Lock() |
| self.session_hidden_web_ui_keys: Set[str] = set() |
| self.session_env_key_configs: Dict[str, Dict[str, Any]] = {} |
|
|
| async def reload_keys(self, db: Optional[AsyncSession] = None): |
| """ |
| 根据配置的 KEY_STORAGE_MODE (内存或数据库) 异步重新加载 API Key 列表和配置。 |
| 此方法会清除当前的内存状态 (api_keys, key_configs),并从指定源重新加载。 |
| 通常在应用启动时或需要刷新 Key 列表时调用。 |
| |
| Args: |
| db (Optional[AsyncSession]): 数据库模式下需要传入 SQLAlchemy 异步数据库会话。 |
| 内存模式下不需要。 |
| """ |
| logger.info(f"开始重新加载 API Keys (模式: {config.KEY_STORAGE_MODE})...") |
| new_api_keys: List[str] = [] |
| new_key_configs: Dict[str, Dict[str, Any]] = {} |
|
|
| if config.KEY_STORAGE_MODE == 'memory': |
| logger.info("内存模式:从环境变量 GEMINI_API_KEYS 重新加载...") |
| raw_keys = config.GEMINI_API_KEYS or "" |
| |
| env_keys = [k.strip() for k in raw_keys.split(',') if k.strip()] |
| |
| for key in env_keys: |
| new_api_keys.append(key) |
| new_key_configs[key] = { |
| 'description': "从环境变量加载", |
| 'is_active': True, |
| 'expires_at': None, |
| 'enable_context_completion': True, |
| 'user_id': None |
| } |
| logger.info(f"内存模式:重新加载了 {len(new_api_keys)} 个 Key。") |
|
|
| elif config.KEY_STORAGE_MODE == 'database': |
| logger.info("数据库模式:从数据库重新加载 API Keys...") |
| if not db: |
| logger.error("数据库模式下重新加载 Key 需要数据库会话,但未提供。") |
| return |
|
|
| try: |
| |
| db_api_key_objects: List[ApiKey] = await db_utils.get_all_api_keys_from_db(db) |
| |
| for key_obj in db_api_key_objects: |
| |
| if key_obj.is_active: |
| new_api_keys.append(key_obj.key_string) |
| |
| new_key_configs[key_obj.key_string] = { |
| 'description': key_obj.description, |
| 'is_active': key_obj.is_active, |
| 'expires_at': key_obj.expires_at, |
| 'enable_context_completion': key_obj.enable_context_completion, |
| 'user_id': key_obj.user_id |
| } |
| |
| logger.info(f"数据库模式:重新加载了 {len(db_api_key_objects)} 个 Key 配置,其中 {len(new_api_keys)} 个为活动状态。") |
| except Exception as e: |
| logger.error(f"从数据库重新加载 API Key 失败: {e}", exc_info=True) |
| |
| |
| return |
| else: |
| logger.error(f"未知的 KEY_STORAGE_MODE: {config.KEY_STORAGE_MODE}。无法重新加载 API Key。") |
| return |
|
|
| |
| |
| with self.keys_lock: |
| self.api_keys = new_api_keys |
| self.key_configs = new_key_configs |
| |
| |
| |
| logger.info("APIKeyManager 状态已更新。") |
|
|
|
|
| def get_key_config(self, api_key: str) -> Optional[Dict[str, Any]]: |
| """ |
| 获取指定 API Key 的配置信息。 |
| |
| Args: |
| api_key (str): 要查询的 API Key 字符串。 |
| |
| Returns: |
| Optional[Dict[str, Any]]: 包含 Key 配置的字典,如果 Key 不存在则返回 None。 |
| 返回的是配置的深拷贝,防止外部修改影响内部状态。 |
| """ |
| with self.keys_lock: |
| config_data = self.key_configs.get(api_key) |
| |
| return copy.deepcopy(config_data) if config_data else None |
|
|
| |
| |
|
|
| def get_next_key(self) -> Optional[str]: |
| """ |
| (简单轮询,可能已废弃) 获取下一个可用的 API 密钥。 |
| 此方法实现了一个简单的轮询逻辑,但可能不如 select_best_key 智能。 |
| 它会跳过当前请求已尝试、每日耗尽或临时不可用的 Key。 |
| |
| Returns: |
| Optional[str]: 下一个可用的 API Key 字符串,如果没有可用的 Key 则返回 None。 |
| """ |
| logger.warning("调用了简单的 get_next_key (轮询) 方法,可能已被 select_best_key 替代。") |
| with self.keys_lock: |
| if not self.api_keys: |
| return None |
| |
| available_keys = [ |
| k for k in self.api_keys |
| if k not in self.tried_keys_for_request |
| and not self._is_key_daily_exhausted_nolock(k) |
| and not self.is_key_temporarily_unavailable(k) |
| ] |
| if not available_keys: |
| return None |
| |
| key_to_use = available_keys[0] |
| return key_to_use |
|
|
| async def select_best_key(self, model_name: str, model_limits: Dict[str, Any], estimated_input_tokens: int, |
| user_id: Optional[str] = None, enable_sticky_session: bool = False, request_id: Optional[str] = None, |
| cached_content_id: Optional[str] = None, |
| db: Optional[AsyncSession] = None |
| ) -> Tuple[Optional[str], int]: |
| """ |
| 基于多种策略异步选择最佳的 API 密钥用于当前请求。 |
| 选择策略优先级: |
| 1. 缓存关联 Key (如果启用原生缓存且命中缓存) |
| 2. 用户上次使用 Key (如果启用粘性会话) |
| 3. 基于评分和最近最少使用的轮转选择 (回退策略) |
| |
| Args: |
| model_name (str): 请求的目标模型名称。 |
| model_limits (Dict[str, Any]): 该模型的速率限制配置。 |
| estimated_input_tokens (int): 本次请求估算的输入 Token 数量,用于预检查 TPM/TPD 限制。 |
| user_id (Optional[str]): 发起请求的用户 ID,用于粘性会话和缓存关联。 |
| enable_sticky_session (bool): 是否启用粘性会话策略。 |
| request_id (Optional[str]): 当前请求的唯一 ID,用于日志跟踪。 |
| cached_content_id (Optional[str]): 如果缓存命中,传递缓存内容的 ID,用于缓存关联 Key 查找。 |
| db (Optional[AsyncSession]): 数据库模式下需要传入 SQLAlchemy 异步数据库会话。 |
| |
| Returns: |
| Tuple[Optional[str], int]: |
| - 第一个元素:选定的最佳 API Key 字符串,如果找不到合适的 Key 则为 None。 |
| - 第二个元素:选定 Key 当前可用的输入 Token 容量估算值 (基于 TPM 限制)。 |
| 如果 Key 没有 TPM 限制或无法估算,可能返回 float('inf') 或 0。 |
| """ |
| |
| with self.keys_lock: |
| |
| |
| from app.core import tracking |
| with tracking.cache_tracking_lock: |
| tracking.key_selection_total_attempts += 1 |
|
|
| selected_key = None |
| available_input_tokens = 0 |
|
|
| |
| |
| self._today_date_str = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d') |
| |
| daily_exhausted = {k for k, date_str in self.daily_exhausted_keys.items() if date_str == self._today_date_str} |
| |
| tried_keys = self.tried_keys_for_request.copy() |
| |
| current_active_keys = self.api_keys[:] |
| |
| temporarily_unavailable_keys = {k for k in current_active_keys if self.is_key_temporarily_unavailable(k)} |
|
|
| |
| |
| if config.KEY_STORAGE_MODE == 'database' and config.ENABLE_NATIVE_CACHING and cached_content_id and db: |
| logger.debug(f"请求 {request_id} - 策略 1: 尝试缓存关联 Key (Cache ID: {cached_content_id})") |
| try: |
| |
| associated_key_id = await db_utils.get_key_id_by_cached_content_id(db, cached_content_id) |
| if associated_key_id: |
| |
| associated_key_str = await db_utils.get_key_string_by_id(db, associated_key_id) |
| if associated_key_str: |
| logger.debug(f"请求 {request_id} - 找到与缓存 {cached_content_id} 关联的 Key: {associated_key_str[:8]}...") |
| reason_prefix = "Cache Assoc." |
| |
| if associated_key_str not in current_active_keys: |
| reason = f"{reason_prefix} - Key not active/found in manager" |
| logger.warning(f"请求 {request_id} - {reason}") |
| self.record_selection_reason(associated_key_str, reason, request_id) |
| elif associated_key_str in tried_keys: |
| reason = f"{reason_prefix} - Key already tried" |
| logger.warning(f"请求 {request_id} - {reason}") |
| self.record_selection_reason(associated_key_str, reason, request_id) |
| elif associated_key_str in daily_exhausted: |
| reason = f"{reason_prefix} - Daily Quota Exhausted" |
| logger.warning(f"请求 {request_id} - {reason}") |
| self.record_selection_reason(associated_key_str, reason, request_id) |
| elif associated_key_str in temporarily_unavailable_keys: |
| reason = f"{reason_prefix} - Temporarily Unavailable" |
| logger.warning(f"请求 {request_id} - {reason}") |
| self.record_selection_reason(associated_key_str, reason, request_id) |
| else: |
| logger.debug(f"请求 {request_id} - 关联 Key {associated_key_str[:8]}... 可用,进行 Token 预检查...") |
| with usage_lock: |
| key_usage = usage_data.get(associated_key_str, {}).get(model_name, {}) |
| tpm_input_limit = model_limits.get("tpm_input") |
| tpm_input_used = key_usage.get("tpm_input_count", 0) |
| potential_tpm_input = tpm_input_used + estimated_input_tokens |
| |
| if tpm_input_limit is None or tpm_input_limit <= 0 or potential_tpm_input <= tpm_input_limit: |
| |
| selected_key = associated_key_str |
| |
| available_input_tokens = max(0, tpm_input_limit - tpm_input_used) if tpm_input_limit is not None and tpm_input_limit > 0 else float('inf') |
| reason = f"{reason_prefix} - Successful Selection" |
| logger.info(f"请求 {request_id} - {reason}: {selected_key[:8]}...。可用输入 Token: {available_input_tokens}") |
| self.record_selection_reason(selected_key, reason, request_id) |
| |
| with tracking.cache_tracking_lock: |
| tracking.key_selection_successful_selections += 1 |
| self.tried_keys_for_request.add(selected_key) |
| return selected_key, available_input_tokens |
| else: |
| reason = f"{reason_prefix} - Token Precheck Failed" |
| logger.warning(f"请求 {request_id} - {reason}: {associated_key_str[:8]}... 潜在总输入 Token: {potential_tpm_input}, 限制: {tpm_input_limit}") |
| self.record_selection_reason(associated_key_str, reason, request_id) |
| else: |
| reason = f"{reason_prefix} - Key string not found for ID {associated_key_id}" |
| logger.warning(f"请求 {request_id} - {reason}") |
| self.record_selection_reason(f"ID:{associated_key_id}", reason, request_id) |
| else: |
| reason = f"{reason_prefix} - No associated Key ID found" |
| logger.debug(f"请求 {request_id} - {reason}") |
| self.record_selection_reason("N/A", reason, request_id) |
| except Exception as e: |
| logger.error(f"请求 {request_id} - 查找缓存关联 Key 时出错: {e}", exc_info=True) |
| self.record_selection_reason("N/A", "Cache Assoc. - DB Error", request_id) |
| elif config.KEY_STORAGE_MODE == 'database' and config.ENABLE_NATIVE_CACHING and not db: |
| logger.warning(f"请求 {request_id} - 数据库模式下原生缓存已启用但未提供 db session,无法查找缓存关联 Key。") |
| self.record_selection_reason("N/A", "Cache Assoc. - DB Session Missing", request_id) |
| else: |
| logger.debug(f"请求 {request_id} - 跳过缓存关联 Key 查找 (非数据库模式或原生缓存禁用或无 Cache ID)。") |
| self.record_selection_reason("N/A", "Cache Assoc. - Skipped", request_id) |
|
|
|
|
| |
| user_association_reason = "User Assoc. - Skipped" |
| |
| if config.KEY_STORAGE_MODE == 'database' and selected_key is None and user_id and enable_sticky_session and db: |
| logger.debug(f"请求 {request_id} - 策略 2: 尝试用户上次使用 Key (User ID: {user_id})") |
| try: |
| |
| last_used_key_id = await db_utils.get_user_last_used_key_id(db, user_id) |
| if last_used_key_id: |
| |
| last_used_key_str = await db_utils.get_key_string_by_id(db, last_used_key_id) |
| if last_used_key_str: |
| logger.debug(f"请求 {request_id} - 用户 {user_id} 上次使用 Key: {last_used_key_str[:8]}...") |
| reason_prefix = "User Assoc." |
| |
| if last_used_key_str not in current_active_keys: |
| user_association_reason = f"{reason_prefix} - Key not active/found in manager" |
| logger.warning(f"请求 {request_id} - {user_association_reason}") |
| self.record_selection_reason(last_used_key_str, user_association_reason, request_id) |
| elif last_used_key_str in tried_keys: |
| user_association_reason = f"{reason_prefix} - Key already tried" |
| logger.warning(f"请求 {request_id} - {user_association_reason}") |
| self.record_selection_reason(last_used_key_str, user_association_reason, request_id) |
| elif last_used_key_str in daily_exhausted: |
| user_association_reason = f"{reason_prefix} - Daily Quota Exhausted" |
| logger.warning(f"请求 {request_id} - {user_association_reason}") |
| self.record_selection_reason(last_used_key_str, user_association_reason, request_id) |
| elif last_used_key_str in temporarily_unavailable_keys: |
| user_association_reason = f"{reason_prefix} - Temporarily Unavailable" |
| logger.warning(f"请求 {request_id} - {user_association_reason}") |
| self.record_selection_reason(last_used_key_str, user_association_reason, request_id) |
| else: |
| logger.debug(f"请求 {request_id} - 上次使用 Key {last_used_key_str[:8]}... 可用,进行 Token 预检查...") |
| with usage_lock: |
| key_usage = usage_data.get(last_used_key_str, {}).get(model_name, {}) |
| tpm_input_limit = model_limits.get("tpm_input") |
| tpm_input_used = key_usage.get("tpm_input_count", 0) |
| potential_tpm_input = tpm_input_used + estimated_input_tokens |
| |
| if tpm_input_limit is None or tpm_input_limit <= 0 or potential_tpm_input <= tpm_input_limit: |
| |
| selected_key = last_used_key_str |
| |
| available_input_tokens = max(0, tpm_input_limit - tpm_input_used) if tpm_input_limit is not None and tpm_input_limit > 0 else float('inf') |
| user_association_reason = f"{reason_prefix} - Successful Selection" |
| logger.info(f"请求 {request_id} - {user_association_reason}: {selected_key[:8]}...。可用输入 Token: {available_input_tokens}") |
| self.record_selection_reason(selected_key, user_association_reason, request_id) |
| |
| with tracking.cache_tracking_lock: |
| tracking.key_selection_successful_selections += 1 |
| self.tried_keys_for_request.add(selected_key) |
| return selected_key, available_input_tokens |
| else: |
| user_association_reason = f"{reason_prefix} - Token Precheck Failed" |
| logger.warning(f"请求 {request_id} - {user_association_reason}: {last_used_key_str[:8]}... 潜在总输入 Token: {potential_tpm_input}, 限制: {tpm_input_limit}") |
| self.record_selection_reason(last_used_key_str, user_association_reason, request_id) |
| else: |
| user_association_reason = f"{reason_prefix} - Key string not found for ID {last_used_key_id}" |
| logger.warning(f"请求 {request_id} - {user_association_reason}") |
| self.record_selection_reason(f"ID:{last_used_key_id}", user_association_reason, request_id) |
| else: |
| user_association_reason = f"{reason_prefix} - No last used Key found" |
| logger.debug(f"请求 {request_id} - {user_association_reason}") |
| self.record_selection_reason("N/A", user_association_reason, request_id) |
| except Exception as e: |
| logger.error(f"请求 {request_id} - 查找用户上次使用 Key 时出错: {e}", exc_info=True) |
| user_association_reason = "User Assoc. - DB Error" |
| self.record_selection_reason("N/A", user_association_reason, request_id) |
| elif selected_key is None: |
| if config.KEY_STORAGE_MODE != 'database': user_association_reason = "User Assoc. - Skipped (Not DB Mode)" |
| elif not user_id: user_association_reason = "User Assoc. - User ID Missing" |
| elif not enable_sticky_session: user_association_reason = "User Assoc. - Sticky Session Disabled" |
| elif not db: user_association_reason = "User Assoc. - DB Session Missing" |
| logger.debug(f"请求 {request_id} - 跳过用户关联 Key 查找 ({user_association_reason})。") |
| self.record_selection_reason("N/A", user_association_reason, request_id) |
|
|
|
|
| |
| if selected_key is None: |
| logger.debug(f"请求 {request_id} - 策略 3: 执行评分和轮转选择。") |
| |
| with cache_lock: |
| now = time.time() |
| |
| if now - cache_last_updated.get(model_name, 0) > CACHE_REFRESH_INTERVAL_SECONDS: |
| logger.info(f"请求 {request_id} - 模型 '{model_name}' 的 Key 分数缓存已过期,正在异步刷新...") |
| try: |
| |
| asyncio.create_task(self._async_update_key_scores(model_name, model_limits)) |
| except RuntimeError: |
| logger.warning(f"请求 {request_id} - 不在异步事件循环中,无法启动异步刷新任务。依赖后台任务或下次调用刷新。") |
| update_cache_timestamp(model_name) |
| scores = key_scores_cache.get(model_name, {}) |
|
|
| if not scores: |
| logger.warning(f"请求 {request_id} - 模型 '{model_name}' 没有可用的 Key 分数缓存数据。") |
| self.record_selection_reason("N/A", "Score Selection - No Key Score Cache Data", request_id) |
| |
| with tracking.cache_tracking_lock: |
| tracking.key_selection_failed_selections += 1 |
| tracking.key_selection_failure_reasons["Score Selection - No Key Score Cache Data"] += 1 |
| else: |
| |
| available_scores = {} |
| reason_prefix = "Score Selection" |
| |
| for k, v in scores.items(): |
| |
| if k not in current_active_keys: |
| self.record_selection_reason(k, f"{reason_prefix} - Key not active in manager", request_id) |
| continue |
| |
| if k in tried_keys: |
| self.record_selection_reason(k, f"{reason_prefix} - Key already tried", request_id) |
| continue |
| |
| if k in daily_exhausted: |
| self.record_selection_reason(k, f"{reason_prefix} - Daily Quota Exhausted", request_id) |
| continue |
| |
| if k in temporarily_unavailable_keys: |
| self.record_selection_reason(k, f"{reason_prefix} - Temporarily Unavailable", request_id) |
| continue |
| |
| available_scores[k] = v |
|
|
| if not available_scores: |
| logger.warning(f"请求 {request_id} - 模型 '{model_name}' 的所有可用 Key(根据缓存)均已尝试、当天耗尽或临时不可用。") |
| self.record_selection_reason("N/A", f"{reason_prefix} - All available keys tried/exhausted/unavailable", request_id) |
| |
| with tracking.cache_tracking_lock: |
| tracking.key_selection_failed_selections += 1 |
| tracking.key_selection_failure_reasons[f"{reason_prefix} - All available keys tried/exhausted/unavailable"] += 1 |
| else: |
| |
| |
| sorted_keys_by_score = sorted(available_scores.items(), key=lambda item: item[1], reverse=True) |
| |
| rotation_threshold = 0.95 |
| best_score = sorted_keys_by_score[0][1] if sorted_keys_by_score else 0 |
| |
| keys_in_rotation_range = [(k, score) for k, score in sorted_keys_by_score if score >= best_score * rotation_threshold] |
|
|
| |
| with usage_lock: |
| |
| sorted_keys_for_rotation = sorted( |
| keys_in_rotation_range, |
| key=lambda item: usage_data.get(item[0], {}).get(model_name, {}).get('last_used_timestamp', 0.0) |
| ) |
| |
| for candidate_key, candidate_score in sorted_keys_for_rotation: |
| key_usage = usage_data.get(candidate_key, {}).get(model_name, {}) |
| tpm_input_limit = model_limits.get("tpm_input") |
| tpm_input_used = key_usage.get("tpm_input_count", 0) |
| potential_tpm_input = tpm_input_used + estimated_input_tokens |
| |
| if tpm_input_limit is None or tpm_input_limit <= 0 or potential_tpm_input <= tpm_input_limit: |
| |
| selected_key = candidate_key |
| |
| available_input_tokens = max(0, tpm_input_limit - tpm_input_used) if tpm_input_limit is not None and tpm_input_limit > 0 else float('inf') |
| reason = f"{reason_prefix} - Successful Selection (Score: {candidate_score:.4f})" |
| logger.info(f"请求 {request_id} - {reason}: {selected_key[:8]}...。可用输入 Token: {available_input_tokens}") |
| self.record_selection_reason(selected_key, reason, request_id) |
| |
| with tracking.cache_tracking_lock: |
| tracking.key_selection_successful_selections += 1 |
| break |
| else: |
| reason = f"{reason_prefix} - Token Precheck Failed" |
| logger.warning(f"请求 {request_id} - {reason}: {candidate_key[:8]}... 潜在总输入 Token: {potential_tpm_input}, 限制: {tpm_input_limit}") |
| self.record_selection_reason(candidate_key, reason, request_id) |
| continue |
|
|
| |
| if selected_key: |
| |
| self.tried_keys_for_request.add(selected_key) |
| return selected_key, available_input_tokens |
| else: |
| final_reason = "Final Failure - No suitable key found after all strategies" |
| logger.error(f"请求 {request_id} - {final_reason}") |
| self.record_selection_reason("N/A", final_reason, request_id) |
| |
| with tracking.cache_tracking_lock: |
| tracking.key_selection_failed_selections += 1 |
| tracking.key_selection_failure_reasons[final_reason] += 1 |
| return None, 0 |
|
|
| def _is_key_daily_exhausted_nolock(self, api_key: str) -> bool: |
| """ |
| (内部方法) 检查 API 密钥是否已达到每日配额限制。 |
| 此方法不获取 keys_lock,调用者需要确保已持有锁。 |
| |
| Args: |
| api_key (str): 要检查的 API Key 字符串。 |
| |
| Returns: |
| bool: 如果 Key 已达到当日配额限制,返回 True;否则返回 False。 |
| """ |
| |
| return self.daily_exhausted_keys.get(api_key) == self._today_date_str |
|
|
| def mark_key_daily_exhausted(self, api_key: str): |
| """ |
| 将指定的 API 密钥标记为当天已耗尽配额。 |
| 记录当前日期到 daily_exhausted_keys 字典。 |
| |
| Args: |
| api_key (str): 要标记的 API Key 字符串。 |
| """ |
| with self.keys_lock: |
| self.daily_exhausted_keys[api_key] = self._today_date_str |
| logger.warning(f"API Key {api_key[:10]}... 已达到每日配额限制。") |
|
|
| def is_key_temporarily_unavailable(self, api_key: str) -> bool: |
| """ |
| 检查指定的 API 密钥当前是否因临时问题(例如,短暂的 API 错误)而不可用。 |
| 同时会清理掉已经过期的临时不可用标记。 |
| |
| Args: |
| api_key (str): 要检查的 API Key 字符串。 |
| |
| Returns: |
| bool: 如果 Key 当前处于临时不可用状态,返回 True;否则返回 False。 |
| """ |
| with self.keys_lock: |
| |
| expiration_timestamp = self.temporary_issue_keys.get(api_key) |
| |
| if expiration_timestamp and expiration_timestamp < time.time(): |
| |
| self.temporary_issue_keys.pop(api_key, None) |
| logger.info(f"API Key {api_key[:10]}... 的临时问题标记已过期,恢复可用。") |
| return False |
| |
| return expiration_timestamp is not None |
|
|
| def mark_key_temporarily_unavailable(self, api_key: str, duration_seconds: int = 60): |
| """ |
| 将指定的 API 密钥标记为临时不可用一段时间。 |
| 这通常在遇到可重试的 API 错误(如 5xx)时调用。 |
| |
| Args: |
| api_key (str): 要标记的 API Key 字符串。 |
| duration_seconds (int, optional): 临时不可用的持续时间(秒)。默认为 60 秒。 |
| """ |
| with self.keys_lock: |
| |
| self.temporary_issue_keys[api_key] = time.time() + duration_seconds |
| logger.warning(f"API Key {api_key[:10]}... 临时不可用 {duration_seconds} 秒。") |
|
|
| def record_selection_reason(self, key: str, reason: str, request_id: Optional[str] = None): |
| """ |
| 记录在 Key 选择过程中,某个 Key 被选中或被跳过的原因。 |
| 用于调试和分析 Key 选择策略的效果。 |
| |
| Args: |
| key (str): 相关的 API Key 字符串 (或 "N/A" 表示未涉及特定 Key)。 |
| reason (str): 选择或跳过的具体原因。 |
| request_id (Optional[str]): 与此记录关联的请求 ID。 |
| """ |
| with self.records_lock: |
| |
| self.key_selection_records.append( |
| { |
| "key": key, |
| "reason": reason, |
| "request_id": request_id, |
| "timestamp": datetime.now(pytz.timezone('Asia/Shanghai')).isoformat() |
| } |
| ) |
|
|
| def get_active_keys_count(self) -> int: |
| """ |
| 返回当前内存中加载的活动 API 密钥的数量。 |
| |
| Returns: |
| int: 活动 API 密钥的数量。 |
| """ |
| with self.keys_lock: |
| return len(self.api_keys) |
|
|
| async def _async_update_key_scores(self, model_name: str, model_limits: Dict[str, Any]): |
| """ |
| (内部异步方法) 异步更新指定模型的 Key 分数缓存。 |
| 此方法应在后台任务中调用,以避免阻塞主请求处理。 |
| 目前依赖于 db_utils.get_key_scores 的实现。 |
| |
| Args: |
| model_name (str): 需要更新分数的模型名称。 |
| model_limits (Dict[str, Any]): 该模型的限制配置 (可能在评分计算中使用)。 |
| """ |
| try: |
| |
| |
| new_scores = await db_utils.get_key_scores(model_name) |
| with cache_lock: |
| key_scores_cache[model_name] = new_scores |
| logger.info(f"模型 '{model_name}' 的 Key 分数缓存已成功更新。") |
| except Exception as e: |
| logger.error(f"更新模型 '{model_name}' 的 Key 分数缓存时出错: {e}", exc_info=True) |
|
|
| def get_and_clear_all_selection_records(self) -> List[Dict[str, Any]]: |
| """ |
| 获取当前存储的所有 Key 选择记录,并清空记录列表。 |
| 通常用于定期导出或分析选择日志。 |
| |
| Returns: |
| List[Dict[str, Any]]: 包含所有 Key 选择记录的列表。 |
| """ |
| with self.records_lock: |
| records = copy.deepcopy(self.key_selection_records) |
| self.key_selection_records.clear() |
| return records |
|
|
| |
|
|
| async def get_api_key_for_cached_content( |
| self, |
| db: AsyncSession, |
| cached_content_identifier: Union[int, str] |
| ) -> Optional[Dict[str, Any]]: |
| """ |
| 根据缓存内容的标识符 (数据库主键 ID 或 Gemini cache ID/content_id) |
| 异步查询并返回创建该缓存时所使用的 API Key 的详细信息。 |
| |
| Args: |
| db (AsyncSession): SQLAlchemy 异步数据库会话。 |
| cached_content_identifier (Union[int, str]): |
| - 如果是整数,则假定为 CachedContent 表的主键 ID。 |
| - 如果是字符串,则假定为 CachedContent 表的 content_id (例如 Gemini 的 cache name)。 |
| |
| Returns: |
| Optional[Dict[str, Any]]: 如果找到关联的 API Key,则返回其配置信息字典 |
| (与 self.key_configs 中的结构类似,但包含 key_string);否则返回 None。 |
| 返回的是配置的深拷贝。 |
| """ |
| if not db: |
| logger.error("get_api_key_for_cached_content: 数据库会话 (db) 未提供。") |
| return None |
| if cached_content_identifier is None: |
| logger.warning("get_api_key_for_cached_content: cached_content_identifier 为 None。") |
| return None |
|
|
| from app.core.database.models import CachedContent, ApiKey |
| from sqlalchemy.future import select |
|
|
| api_key_id_to_find: Optional[int] = None |
| |
| try: |
| if isinstance(cached_content_identifier, int): |
| |
| stmt = select(CachedContent.key_id).where(CachedContent.id == cached_content_identifier) |
| result = await db.execute(stmt) |
| api_key_id_to_find = result.scalar_one_or_none() |
| if not api_key_id_to_find: |
| logger.info(f"未找到 CachedContent 记录 (ID: {cached_content_identifier}) 或其没有关联的 key_id。") |
| |
| elif isinstance(cached_content_identifier, str): |
| |
| stmt = select(CachedContent.key_id).where(CachedContent.content_id == cached_content_identifier) |
| result = await db.execute(stmt) |
| api_key_id_to_find = result.scalar_one_or_none() |
| if not api_key_id_to_find: |
| logger.info(f"未找到 CachedContent 记录 (content_id: {cached_content_identifier}) 或其没有关联的 key_id。") |
| else: |
| logger.warning(f"无效的 cached_content_identifier 类型: {type(cached_content_identifier)}") |
| return None |
|
|
| if not api_key_id_to_find: |
| |
| return None |
|
|
| |
| |
| if config.KEY_STORAGE_MODE == 'database': |
| stmt_api_key = select(ApiKey).where(ApiKey.id == api_key_id_to_find) |
| result_api_key = await db.execute(stmt_api_key) |
| api_key_record: Optional[ApiKey] = result_api_key.scalar_one_or_none() |
|
|
| if api_key_record: |
| key_config_from_db = { |
| 'key_string': api_key_record.key_string, |
| 'description': api_key_record.description, |
| 'is_active': api_key_record.is_active, |
| 'expires_at': api_key_record.expires_at.isoformat() if api_key_record.expires_at else None, |
| 'enable_context_completion': api_key_record.enable_context_completion, |
| 'user_id': api_key_record.user_id, |
| 'id': api_key_record.id |
| } |
| logger.info(f"成功为缓存标识符 {cached_content_identifier} (api_key_id: {api_key_id_to_find}) 找到关联的 API Key (来自数据库): {api_key_record.key_string[:8]}...") |
| return copy.deepcopy(key_config_from_db) |
| else: |
| logger.warning(f"数据库模式:找到了 api_key_id {api_key_id_to_find} 但在 ApiKeys 表中未找到对应记录。") |
| return None |
| |
| elif config.KEY_STORAGE_MODE == 'memory': |
| |
| |
| |
| |
| |
| with self.keys_lock: |
| for key_str, conf in self.key_configs.items(): |
| |
| |
| |
| |
| |
| |
|
|
| |
| pass |
|
|
| logger.warning( |
| f"在 KEY_STORAGE_MODE='memory' 时,通过整数 api_key_id ({api_key_id_to_find}) " |
| f"从内存 key_configs 查找 API Key 详细信息的功能受限或不支持。" |
| ) |
| return None |
|
|
| except Exception as e: |
| logger.error(f"查询关联 API Key 时发生错误 (标识符: {cached_content_identifier}): {e}", exc_info=True) |
| return None |
|
|
| return None |
|
|
| |
| |
| |
|
|
| def add_key_memory(self, key_string: str, config_data: Dict[str, Any]) -> bool: |
| """ |
| (内存模式) 添加一个新的 API Key 到内存中的 `api_keys` 列表和 `key_configs` 字典。 |
| |
| Args: |
| key_string (str): 要添加的 Key 字符串。 |
| config_data (Dict[str, Any]): 与该 Key 关联的配置数据。 |
| |
| Returns: |
| bool: 如果成功添加返回 True,如果 Key 已存在则返回 False。 |
| """ |
| with self.keys_lock: |
| if key_string in self.api_keys: |
| logger.warning(f"内存模式:尝试添加已存在的 Key: {key_string[:8]}...") |
| return False |
| self.api_keys.append(key_string) |
| |
| updated_config_data = { |
| "description": config_data.get("description"), |
| "is_active": config_data.get("is_active", True), |
| "expires_at": config_data.get("expires_at"), |
| "enable_context_completion": config_data.get("enable_context_completion", True), |
| "user_id": config_data.get("user_id"), |
| "created_at": config_data.get("created_at", datetime.now(timezone.utc).isoformat()), |
| "_ui_generated": True |
| } |
| self.key_configs[key_string] = updated_config_data |
| logger.info(f"内存模式:成功添加临时 Key (UI生成): {key_string[:8]}...") |
| return True |
|
|
| def update_key_memory(self, key_string: str, updates: Dict[str, Any]) -> bool: |
| """ |
| (内存模式) 更新内存中指定 API Key 的配置信息。 |
| |
| Args: |
| key_string (str): 要更新的 Key 字符串。 |
| updates (Dict[str, Any]): 包含要更新的配置字段和新值的字典。 |
| |
| Returns: |
| bool: 如果成功更新返回 True,如果 Key 不存在则返回 False。 |
| """ |
| with self.keys_lock: |
| if key_string not in self.key_configs: |
| logger.warning(f"内存模式:尝试更新不存在的 Key: {key_string[:8]}...") |
| return False |
| |
| allowed_updates = {k: v for k, v in updates.items() if k != 'key_string'} |
| |
| self.key_configs[key_string].update(allowed_updates) |
| logger.info(f"内存模式:成功更新临时 Key {key_string[:8]}... 的配置: {allowed_updates}") |
| return True |
|
|
| def delete_key_memory(self, key_string: str) -> bool: |
| """ |
| (内存模式) 从内存中删除指定的 API Key 及其配置。 |
| |
| Args: |
| key_string (str): 要删除的 Key 字符串。 |
| |
| Returns: |
| bool: 如果成功删除(或 Key 原本就不存在于配置中)返回 True,否则返回 False。 |
| """ |
| with self.keys_lock: |
| key_existed_in_list = False |
| try: |
| if key_string in self.api_keys: |
| self.api_keys.remove(key_string) |
| key_existed_in_list = True |
| except ValueError: |
| logger.warning(f"内存模式:尝试从 api_keys 列表删除 Key {key_string[:8]}... 时出错 (可能已不存在)。") |
|
|
| |
| config_removed = self.key_configs.pop(key_string, None) is not None |
|
|
| |
| |
| |
|
|
| if key_existed_in_list or config_removed: |
| logger.info(f"内存模式:成功删除临时 Key: {key_string[:8]}... (从列表: {key_existed_in_list}, 从配置: {config_removed})") |
| return True |
| else: |
| logger.warning(f"内存模式:尝试删除不存在的 Key: {key_string[:8]}...") |
| return False |
|
|