mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -345,31 +345,45 @@ def wrap_v1internal_request(
|
||||
1. 移除 model(移到顶层)
|
||||
2. 移除 safetySettings(v1internal 不支持)
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user