feat: OAuth 导入导出、提供商筛选、Gemini 图像生成支持与流式处理增强

- OAuth: 支持通过 Refresh Token 导入账号(文件拖拽/粘贴),OAuth Key 可导出为 JSON
- OAuth: 所有 OAuth 端点添加 require_admin 鉴权
- 提供商管理: 新增状态/API格式/模型三级筛选,后端返回 global_model_ids
- Gemini: 新增图像生成模型适配(finalize_provider_request 钩子 + envelope 跳过不兼容字段)
- 流式处理: buffer 残留数据 flush 与 token 兜底估算
- 上游元数据: 提取 merge_upstream_metadata,配额耗尽模型保留与深度合并
- Antigravity 配额: 无 quotaInfo 时视为耗尽,移除 Other 兜底分组
- README: 新增升级备份与回滚指南
This commit is contained in:
fawney19
2026-02-06 21:52:22 +08:00
parent 62dae22a2c
commit 8b6a5d3824
23 changed files with 1129 additions and 101 deletions

View File

@@ -30,6 +30,7 @@ from src.models.database import Provider, ProviderAPIKey
from src.services.model.upstream_fetcher import (
UpstreamModelsFetchContext,
fetch_models_for_key,
merge_upstream_metadata,
)
from src.services.provider.oauth_token import resolve_oauth_access_token
from src.services.system.scheduler import get_scheduler
@@ -491,13 +492,9 @@ class ModelFetchScheduler:
# 最佳努力:保存上游元数据(如 Antigravity 配额信息)
if upstream_metadata and isinstance(upstream_metadata, dict):
# NOTE: upstream_metadata is a plain JSON column (not MutableDict),
# so in-place mutation won't be persisted reliably. Always assign
# a new dict object to mark the column as dirty.
current = key.upstream_metadata
merged: dict[str, Any] = dict(current) if isinstance(current, dict) else {}
merged.update(upstream_metadata)
key.upstream_metadata = merged
key.upstream_metadata = merge_upstream_metadata(
key.upstream_metadata, upstream_metadata
)
# 去重获取模型 ID 列表
fetched_model_ids: set[str] = set()

View File

@@ -82,6 +82,50 @@ async def fetch_models_for_key(
return await fetcher(ctx, timeout_seconds)
def merge_upstream_metadata(
current: dict[str, Any] | None,
incoming: dict[str, Any],
) -> dict[str, Any]:
"""合并上游元数据,对 quota_by_model 做模型级深度合并。
上游 API 在配额耗尽后可能不再返回该模型的 quotaInfo因此需要
1. 保留旧数据中已有的 reset_time当新数据缺少时
2. 保留旧数据中存在但新数据中缺失的模型条目(标记为 100% 已用)
"""
merged: dict[str, Any] = dict(current) if isinstance(current, dict) else {}
for ns_key, ns_val in incoming.items():
old_ns = merged.get(ns_key)
if (
isinstance(ns_val, dict)
and isinstance(old_ns, dict)
and "quota_by_model" in ns_val
and "quota_by_model" in old_ns
):
old_qbm = old_ns["quota_by_model"]
new_qbm = ns_val["quota_by_model"]
if isinstance(old_qbm, dict) and isinstance(new_qbm, dict):
# 保留新数据中已有模型的旧 reset_time
for model_id, new_info in new_qbm.items():
if not isinstance(new_info, dict):
continue
old_info = old_qbm.get(model_id)
if (
isinstance(old_info, dict)
and "reset_time" in old_info
and "reset_time" not in new_info
):
new_info["reset_time"] = old_info["reset_time"]
# 保留新数据中缺失但旧数据中存在的模型(配额耗尽后上游可能不返回)
for model_id, old_info in old_qbm.items():
if model_id not in new_qbm and isinstance(old_info, dict):
exhausted = dict(old_info)
exhausted["remaining_fraction"] = 0.0
exhausted["used_percent"] = 100.0
new_qbm[model_id] = exhausted
merged[ns_key] = ns_val
return merged
# Provider-specific fetchers are registered by plugin.register_all()
# (called from envelope.py bootstrap)

View File

@@ -345,31 +345,45 @@ def wrap_v1internal_request(
1. 移除 model移到顶层
2. 移除 safetySettingsv1internal 不支持)
3. 深度清理 [undefined] 字符串
4. Claude model tool ID 注入
5. Thinking budget 处理(自动注入 + Auto Cap
6. 工具声明清洗(schema 清理 + 字段重命名
7. System Instruction 注入
4. Claude model tool ID 注入(图像生成模型跳过)
5. Thinking budget 处理
6. 工具声明清洗(图像生成模型跳过
7. System Instruction 注入(图像生成模型跳过)
8. 注入 sessionId对齐 CLIProxyAPI
9. 构建 v1internal 信封
"""
from src.api.handlers.gemini.image_gen import is_image_gen_model
inner_request = dict(gemini_request)
inner_request.pop("model", None)
inner_request.pop("safetySettings", None)
is_image_gen = is_image_gen_model(model)
# 1. 深度清理 [undefined]
_deep_clean_undefined(inner_request)
# 2. Claude tool ID 注入
_inject_claude_tool_ids_request(inner_request, model)
if not is_image_gen:
# 2. Claude tool ID 注入
_inject_claude_tool_ids_request(inner_request, model)
# 3. Thinking budget 处理
_process_thinking_budget(inner_request, model)
# 4. 工具声明清洗
_clean_tool_declarations(inner_request)
if not is_image_gen:
# 4. 工具声明清洗
_clean_tool_declarations(inner_request)
# 5. System Instruction 注入
_inject_system_instruction(inner_request)
# 5. System Instruction 注入
_inject_system_instruction(inner_request)
else:
# 图像生成模型:对齐 AM wrapper.rs移除不兼容字段
inner_request.pop("tools", None)
inner_request.pop("toolConfig", None)
inner_request.pop("tool_config", None)
inner_request.pop("systemInstruction", None)
inner_request.pop("system_instruction", None)
request_type = "image_gen"
# 6. 注入 sessionId对齐 CLIProxyAPI/sub2api
if "sessionId" not in inner_request:

View File

@@ -255,6 +255,11 @@ async def fetch_models_antigravity(
quota_info = model_data.get("quotaInfo")
if not isinstance(quota_info, dict):
# 没有 quotaInfo 视为配额耗尽
quota_by_model[model_id] = {
"remaining_fraction": 0.0,
"used_percent": 100.0,
}
continue
remaining = quota_info.get("remainingFraction")
@@ -268,6 +273,14 @@ async def fetch_models_antigravity(
remaining_fraction = None
if remaining_fraction is None:
# remainingFraction 缺失视为配额耗尽
payload: dict[str, Any] = {
"remaining_fraction": 0.0,
"used_percent": 100.0,
}
if isinstance(reset_time, str) and reset_time.strip():
payload["reset_time"] = reset_time.strip()
quota_by_model[model_id] = payload
continue
used_percent = (1.0 - remaining_fraction) * 100.0