feat: add Gemini Files API proxy with key capability support

- Add gemini_files_api capability for key-level Files API support
- Implement Gemini Files API proxy endpoints (upload/list/get/delete)
- Add file-to-key mapping cache for correct key routing
- Support preferred_key_ids to prioritize keys that uploaded files
- Enhance logging for file mapping diagnostics
This commit is contained in:
fawney19
2026-01-30 11:37:34 +08:00
parent 32b293ef3e
commit c33cd07ea9
12 changed files with 856 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
"""
Gemini Files API - 文件与 Key 绑定缓存
用于在上传文件后记录 file_id -> provider_key_id
并在后续 generateContent 请求中优先使用同一 Key。
"""
from typing import Any, Dict, Optional, Set
from src.core.cache_service import CacheService
FILE_MAPPING_TTL_SECONDS = 60 * 60 * 48 # 48小时
FILE_MAPPING_CACHE_PREFIX = "gemini_files:key"
def _normalize_file_name(file_name: str) -> str:
name = (file_name or "").strip()
if not name:
return ""
return name if name.startswith("files/") else f"files/{name}"
def build_file_mapping_key(file_name: str) -> str:
normalized = _normalize_file_name(file_name)
return f"{FILE_MAPPING_CACHE_PREFIX}:{normalized}" if normalized else ""
async def store_file_key_mapping(file_name: str, key_id: str) -> None:
cache_key = build_file_mapping_key(file_name)
if not cache_key or not key_id:
return
await CacheService.set(cache_key, str(key_id), ttl_seconds=FILE_MAPPING_TTL_SECONDS)
async def get_file_key_mapping(file_name: str) -> Optional[str]:
cache_key = build_file_mapping_key(file_name)
if not cache_key:
return None
value = await CacheService.get(cache_key)
if value:
return str(value)
return None
async def delete_file_key_mapping(file_name: str) -> None:
cache_key = build_file_mapping_key(file_name)
if cache_key:
await CacheService.delete(cache_key)
def _extract_file_name_from_uri(file_uri: str) -> Optional[str]:
"""
从 fileUri 提取 files/xxx 名称。
支持两种格式:
- 完整 URL: https://generativelanguage.googleapis.com/v1beta/files/abc123
- 短格式: files/abc123
"""
if not file_uri:
return None
# 完整 URL 格式
if "/files/" in file_uri:
idx = file_uri.rfind("/files/")
return file_uri[idx + 1:] # 提取 files/xxx 部分
# 短格式
if file_uri.startswith("files/"):
return file_uri
return None
def extract_file_names_from_request(payload: Optional[Dict[str, Any]]) -> Set[str]:
"""
从 Gemini 请求体中提取 fileUri 使用到的 files/xxx 名称集合。
"""
results: Set[str] = set()
def walk(node: Any) -> None:
if isinstance(node, dict):
file_data = node.get("fileData") or node.get("file_data")
if isinstance(file_data, dict):
file_uri = file_data.get("fileUri") or file_data.get("file_uri")
if isinstance(file_uri, str):
file_name = _extract_file_name_from_uri(file_uri)
if file_name:
results.add(file_name)
for value in node.values():
walk(value)
elif isinstance(node, list):
for item in node:
walk(item)
if payload:
walk(payload)
return results

View File

@@ -52,6 +52,7 @@ class CandidateResolver:
request_id: Optional[str] = None,
is_stream: bool = False,
capability_requirements: Optional[Dict[str, bool]] = None,
preferred_key_ids: Optional[list[str]] = None,
) -> Tuple[List[ProviderCandidate], str]:
"""
获取所有可用候选
@@ -64,6 +65,7 @@ class CandidateResolver:
request_id: 请求 ID用于日志
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
capability_requirements: 能力需求(用于过滤不满足能力要求的 Key
preferred_key_ids: 优先使用的 Provider Key ID 列表(匹配则置顶)
Returns:
(所有候选组合的列表, global_model_id)
@@ -107,6 +109,28 @@ class CandidateResolver:
logger.debug(f" [{request_id}] 获取到 {len(all_candidates)} 个候选组合")
if preferred_key_ids:
preferred_set = {str(kid) for kid in preferred_key_ids if kid}
if preferred_set:
preferred_candidates = [
c for c in all_candidates if c.key and str(c.key.id) in preferred_set
]
other_candidates = [
c for c in all_candidates if not (c.key and str(c.key.id) in preferred_set)
]
if preferred_candidates:
matched_key_ids = [str(c.key.id) for c in preferred_candidates if c.key]
logger.debug(
f" [{request_id}] 优先候选命中: {len(preferred_candidates)}"
f"(key_ids={matched_key_ids[:3]}{'...' if len(matched_key_ids) > 3 else ''})"
)
else:
logger.debug(
f" [{request_id}] 优先候选未命中: 请求的 key_ids={list(preferred_set)[:3]} "
"不在可用候选中,将使用普通优先级"
)
all_candidates = preferred_candidates + other_candidates
# 如果没有解析到 global_model_id使用原始 model_name 作为后备
return all_candidates, global_model_id or model_name

View File

@@ -176,6 +176,7 @@ class FallbackOrchestrator:
request_id: Optional[str] = None,
is_stream: bool = False,
capability_requirements: Optional[Dict[str, bool]] = None,
preferred_key_ids: Optional[list[str]] = None,
) -> Tuple[List[ProviderCandidate], str]:
"""
收集所有可用的 Provider/Endpoint/Key 候选组合
@@ -190,6 +191,7 @@ class FallbackOrchestrator:
request_id: 请求 ID用于日志
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
capability_requirements: 能力需求(用于过滤不满足能力要求的 Key
preferred_key_ids: 优先使用的 Provider Key ID 列表(匹配则置顶)
Returns:
(所有候选组合的列表, global_model_id)
@@ -206,6 +208,7 @@ class FallbackOrchestrator:
request_id=request_id,
is_stream=is_stream,
capability_requirements=capability_requirements,
preferred_key_ids=preferred_key_ids,
)
def _create_candidate_records(
@@ -988,6 +991,7 @@ class FallbackOrchestrator:
request_id: Optional[str] = None,
is_stream: bool = False,
capability_requirements: Optional[Dict[str, bool]] = None,
preferred_key_ids: Optional[list[str]] = None,
request_body_ref: Optional[Dict[str, Any]] = None,
) -> Tuple[Any, str, Optional[str], Optional[str], Optional[str], Optional[str]]:
"""
@@ -1001,6 +1005,7 @@ class FallbackOrchestrator:
request_id: 请求 ID用于日志
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
capability_requirements: 能力需求(用于过滤不满足能力要求的 Key
preferred_key_ids: 优先使用的 Provider Key ID 列表(匹配则置顶)
request_body_ref: 请求体引用容器(用于 Thinking 签名错误重试)
Returns:
@@ -1036,6 +1041,7 @@ class FallbackOrchestrator:
request_id=request_id,
is_stream=is_stream,
capability_requirements=capability_requirements,
preferred_key_ids=preferred_key_ids,
)
# 2. 批量创建候选记录