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.endpoint_models import (
EndpointAPIKeyUpdate,
)
from src.services.cache.provider_cache import ProviderCacheService
from src.services.model.upstream_fetcher import merge_upstream_metadata
from src.utils.auth_utils import require_admin
router = APIRouter(tags=["Provider Keys"])
@@ -467,6 +468,29 @@ class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
"无法解密认证配置,可能是加密密钥已更改。请重新添加该密钥。"
)
# OAuth 类型:返回 access_token + refresh_token
if auth_type == "oauth":
try:
decrypted_key = crypto_service.decrypt(key.api_key)
except Exception as e:
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
raise InvalidRequestException(
"无法解密 API Key可能是加密密钥已更改。请重新添加该密钥。"
)
result: dict[str, Any] = {"auth_type": "oauth", "api_key": decrypted_key}
encrypted_auth_config = getattr(key, "auth_config", None)
if encrypted_auth_config:
try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
auth_config = json.loads(decrypted_config)
refresh_token = auth_config.get("refresh_token")
if refresh_token:
result["refresh_token"] = refresh_token
except Exception as e:
logger.error(f"解密 auth_config 失败: ID={self.key_id}, Error={e}")
logger.info(f"[REVEAL] 查看 OAuth Key: ID={self.key_id}, Name={key.name}")
return result
# API Key 类型返回 api_key
try:
decrypted_key = crypto_service.decrypt(key.api_key)
@@ -1178,13 +1202,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
if key.id in metadata_updates:
updates = metadata_updates[key.id]
if isinstance(updates, 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 = dict(current) if isinstance(current, dict) else {}
merged.update(updates)
key.upstream_metadata = merged
key.upstream_metadata = merge_upstream_metadata(
key.upstream_metadata, updates
)
db.add(key)
# 提交数据库更改

View File

@@ -35,7 +35,8 @@ from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey
from src.models.database import Provider, ProviderAPIKey, User
from src.utils.auth_utils import require_admin
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
@@ -189,7 +190,7 @@ def _parse_callback_params(callback_url: str) -> dict[str, str]:
@router.get("/supported-types")
async def supported_types() -> list[dict[str, Any]]:
async def supported_types(_: User = Depends(require_admin)) -> list[dict[str, Any]]:
# 不返回 client_secret
result: list[dict[str, Any]] = []
for provider_type, template in FIXED_PROVIDERS.items():
@@ -216,6 +217,7 @@ async def start_oauth(
key_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> StartOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
@@ -295,6 +297,7 @@ async def complete_oauth(
payload: CompleteOAuthRequest,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CompleteOAuthResponse:
redis = await get_redis_client(require_redis=True)
assert redis is not None
@@ -428,6 +431,7 @@ async def refresh_oauth(
key_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CompleteOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
@@ -586,6 +590,7 @@ async def start_provider_oauth(
provider_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> StartOAuthResponse:
"""基于 Provider 启动 OAuth不需要预先创建 key"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
@@ -659,6 +664,7 @@ async def complete_provider_oauth(
payload: ProviderCompleteOAuthRequest,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProviderCompleteOAuthResponse:
"""完成 Provider OAuth 并创建 key。"""
redis = await get_redis_client(require_redis=True)
@@ -799,3 +805,155 @@ async def complete_provider_oauth(
has_refresh_token=bool(refresh_token),
email=auth_config.get("email"),
)
# ==============================================================================
# Import Refresh Token (从导出文件导入)
# ==============================================================================
class ImportRefreshTokenRequest(BaseModel):
refresh_token: str = Field(..., min_length=1, description="Refresh Token")
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
@router.post(
"/providers/{provider_id}/import-refresh-token",
response_model=ProviderCompleteOAuthResponse,
)
async def import_refresh_token(
provider_id: str,
payload: ImportRefreshTokenRequest,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProviderCompleteOAuthResponse:
"""通过 Refresh Token 导入 OAuth 账号。
使用导出的 Refresh Token 换取 Access Token 并创建新的 OAuth Key。
"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
# 用 refresh_token 换取 access_token
refresh_token = payload.refresh_token.strip()
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
proxy_config = getattr(provider, "proxy", None)
resp = await post_oauth_token(
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=30.0,
)
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(error_body.get("error_description") or error_body.get("error"))
except Exception:
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
raise InvalidRequestException(f"Refresh Token 验证失败: {error_reason}")
token = resp.json()
access_token = str(token.get("access_token") or "")
new_refresh_token = str(token.get("refresh_token") or "") or refresh_token
expires_in = token.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
if not access_token:
raise InvalidRequestException("token refresh 返回缺少 access_token")
# 构建 auth_config
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token.get("token_type"),
"refresh_token": new_refresh_token or None,
"expires_at": expires_at,
"scope": token.get("scope"),
"updated_at": int(time.time()),
}
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token,
access_token=access_token,
proxy_config=proxy_config,
)
# 确定账号名称
name = (payload.name or "").strip()
if not name:
name = auth_config.get("email") or f"账号_{int(time.time())}"
# 从 Provider 的 endpoints 中提取所有 api_format 作为 Key 的支持格式
api_formats = [ep.api_format for ep in provider.endpoints if ep.api_format and ep.is_active]
# 创建 key
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
new_key = ProviderAPIKeyModel(
provider_id=provider_id,
name=name,
api_key=crypto_service.encrypt(access_token),
auth_type="oauth",
auth_config=crypto_service.encrypt(json.dumps(auth_config)),
api_formats=api_formats,
is_active=True,
)
db.add(new_key)
db.commit()
db.refresh(new_key)
return ProviderCompleteOAuthResponse(
key_id=str(new_key.id),
provider_type=provider_type,
expires_at=expires_at,
has_refresh_token=bool(new_refresh_token),
email=auth_config.get("email"),
)

View File

@@ -240,6 +240,19 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
total_models = model_stats.total or 0
active_models = int(model_stats.active or 0)
# 活跃模型关联的全局模型 ID 列表
global_model_ids = [
row[0]
for row in db.query(Model.global_model_id)
.filter(
Model.provider_id == provider.id,
Model.is_active == True,
Model.global_model_id.isnot(None),
)
.distinct()
.all()
]
api_formats = [e.api_format for e in endpoints]
# 优化: 一次性加载 Provider 的 keys避免 N+1 查询
@@ -328,6 +341,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
active_keys=active_keys,
total_models=total_models,
active_models=active_models,
global_model_ids=global_model_ids,
avg_health_score=avg_health_score,
unhealthy_endpoints=unhealthy_endpoints,
api_formats=api_formats,

View File

@@ -409,6 +409,32 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
"""
return request_body
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None,
) -> dict[str, Any]:
"""
格式转换完成后、envelope 之前的模型感知后处理钩子 - 子类可覆盖
用于根据目标模型的特性对请求体做最终调整,例如:
- 图像生成模型需要移除不兼容的 tools/system_instruction 并注入 imageConfig
- 特定模型需要注入/移除某些字段
此方法在流式和非流式路径中均会被调用,且 mapped_model 已确定。
Args:
request_body: 已完成格式转换的请求体
mapped_model: 映射后的目标模型名
provider_api_format: Provider 侧 API 格式标识
Returns:
调整后的请求体
"""
return request_body
def _set_model_after_conversion(
self,
request_body: dict[str, Any],
@@ -827,6 +853,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
target_variant=same_format_variant,
)
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=str(provider_api_format) if provider_api_format else None,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(
@@ -1440,6 +1473,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
target_variant=same_format_variant,
)
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=str(provider_api_format) if provider_api_format else None,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(

View File

@@ -368,6 +368,32 @@ class CliMessageHandlerBase(BaseMessageHandler):
"""
return request_body
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None,
) -> dict[str, Any]:
"""
格式转换完成后、envelope 之前的模型感知后处理钩子 - 子类可覆盖
用于根据目标模型的特性对请求体做最终调整,例如:
- 图像生成模型需要移除不兼容的 tools/system_instruction 并注入 imageConfig
- 特定模型需要注入/移除某些字段
此方法在流式和非流式路径中均会被调用,且 mapped_model 已确定。
Args:
request_body: 已完成格式转换的请求体
mapped_model: 映射后的目标模型名
provider_api_format: Provider 侧 API 格式标识
Returns:
调整后的请求体
"""
return request_body
@staticmethod
def _get_format_metadata(format_id: str) -> "EndpointDefinition | None":
"""获取 endpoint 元数据(解析失败返回 None"""
@@ -801,6 +827,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_variant=target_variant,
)
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=provider_api_format,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(
@@ -2812,6 +2845,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_variant=target_variant,
)
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=provider_api_format,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(

View File

@@ -708,6 +708,7 @@ class StreamProcessor:
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
)
line = ""
buffer = b"" # 标记已消费,避免 finally 中重复处理
if line:
# 需要格式转换时,跳过记录原始数据
_process_line_with_perf(line, skip_record=True)
@@ -782,8 +783,26 @@ class StreamProcessor:
logger.warning(
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
)
buffer = b"" # 标记已消费,避免下方重复处理
# 处理剩余事件
# flush 残留的字节 buffer异常中断时 buffer 可能仍有未解析的数据,
# 如包含 usage 的 message_delta/response.completed 事件)
# 正常结束时 buffer 已在上方被消费为空,此处为 no-op
if buffer:
try:
remaining = decoder.decode(buffer, True)
for line in remaining.split("\n"):
stripped = line.rstrip("\r\n")
if stripped:
events = sse_parser.feed_line(stripped)
for event in events:
self.handle_sse_event(
ctx, event.get("event"), event.get("data") or ""
)
except Exception:
pass # best-effort: 不应因 flush 失败影响后续流程
# flush SSE parser 内部累积的未完成事件
for event in sse_parser.flush():
self.handle_sse_event(ctx, event.get("event"), event.get("data") or "")

View File

@@ -8,6 +8,7 @@
"""
import asyncio
import json
import time
from typing import Any
@@ -104,6 +105,19 @@ class StreamTelemetryRecorder:
if writer is None:
return
actual_request_body = ctx.provider_request_body or original_request_body
# 兜底估算:流未正常完成且 token 均为 0 时,从请求体粗略估算
# 覆盖 Chat Handler 路径CLI Handler 在更早的位置已做估算,
# 若已估算过则 token > 0此处条件不会触发
if (
ctx.is_success()
and not ctx.has_completion
and ctx.data_count > 0
and ctx.input_tokens == 0
and ctx.output_tokens == 0
):
self._estimate_tokens_for_incomplete_stream(ctx, actual_request_body)
should_log_body = SystemConfigService.should_log_body(bg_db)
include_bodies = (
writer.include_bodies
@@ -536,6 +550,59 @@ class StreamTelemetryRecorder:
return "cancelled"
return "failed"
@staticmethod
def _estimate_tokens_for_incomplete_stream(
ctx: StreamContext,
request_body: dict[str, Any],
) -> None:
"""
流未正常完成(无 response.completed且 token 均为 0 时的兜底估算。
从已收集的输出文本和请求体粗略估算 token 数,确保 usage 记录不为 0。
估算采用 ~4 字符/token 的保守比例。
"""
# 输出 tokens从已收集的文本估算
collected = ctx.collected_text
if collected:
ctx.output_tokens = max(1, len(collected) // 4)
# 输入 tokens从请求体文本内容估算
try:
total_input_len = 0
instructions = request_body.get("instructions")
if isinstance(instructions, str):
total_input_len += len(instructions)
# OpenAI Responses API 使用 input 字段Claude 使用 messages
input_items = request_body.get("input") or request_body.get("messages") or []
if isinstance(input_items, list):
for item in input_items:
if isinstance(item, str):
total_input_len += len(item)
elif isinstance(item, dict):
content = item.get("content", "")
if isinstance(content, str):
total_input_len += len(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict):
text = block.get("text", "")
if isinstance(text, str):
total_input_len += len(text)
if total_input_len > 0:
ctx.input_tokens = max(1, total_input_len // 4)
else:
# fallback: 整个请求体 JSON 大小
body_str = json.dumps(request_body, ensure_ascii=False)
ctx.input_tokens = max(1, len(body_str) // 4)
except Exception:
pass
if ctx.input_tokens > 0 or ctx.output_tokens > 0:
logger.warning(
f"[{ctx.request_id}] 流未正常完成 (has_completion=False, data_count={ctx.data_count}), "
f"使用估算 tokens: in={ctx.input_tokens}, out={ctx.output_tokens}"
)
def _build_db_writer(self, bg_db: Session) -> DbTelemetryWriter | None:
user = bg_db.query(User).filter(User.id == self.user_id).first()
api_key_obj = bg_db.query(ApiKey).filter(ApiKey.id == self.api_key_id).first()

View File

@@ -157,6 +157,22 @@ class GeminiChatHandler(ChatHandlerBase):
"cache_read_input_tokens": usage.get("cached_tokens", 0),
}
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None, # noqa: ARG002
) -> dict[str, Any]:
from src.api.handlers.gemini.image_gen import (
adapt_request_for_image_gen,
is_image_gen_model,
)
if not is_image_gen_model(mapped_model):
return request_body
return adapt_request_for_image_gen(request_body)
def _normalize_response(self, response: dict) -> dict:
"""
规范化 Gemini 响应

View File

@@ -0,0 +1,45 @@
"""
Gemini 图像生成模型请求适配
- 图像生成模型不支持 tools / system_instruction需要移除
- responseModalities / responseMimeType 与 imageConfig 冲突,需要移除
"""
from typing import Any
def is_image_gen_model(model: str | None) -> bool:
"""判断是否为图像生成模型(模式匹配,覆盖 gemini-*-image / imagen-* 系列)"""
if not model:
return False
m = model.lower()
return "image" in m and ("gemini" in m or "imagen" in m)
def adapt_request_for_image_gen(body: dict[str, Any]) -> dict[str, Any]:
"""为图像生成模型清理不兼容字段"""
# 移除图像生成不支持的顶层字段
for key in ("tools", "tool_config", "toolConfig", "system_instruction", "systemInstruction"):
if key in body:
body.pop(key)
# 处理 generationConfig
gc_key = "generationConfig" if "generationConfig" in body else "generation_config"
gc = body.get(gc_key)
if not isinstance(gc, dict):
gc = {}
body[gc_key] = gc
# 移除与图像生成冲突的字段
for key in (
"responseMimeType",
"response_mime_type",
"responseModalities",
"response_modalities",
):
gc.pop(key, None)
# 设置输出模态
gc["responseModalities"] = ["TEXT", "IMAGE"]
return body

View File

@@ -78,6 +78,22 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
result.pop("model", None)
return result
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None, # noqa: ARG002
) -> dict[str, Any]:
from src.api.handlers.gemini.image_gen import (
adapt_request_for_image_gen,
is_image_gen_model,
)
if not is_image_gen_model(mapped_model):
return request_body
return adapt_request_for_image_gen(request_body)
def get_model_for_url(
self,
request_body: dict[str, Any],

View File

@@ -759,6 +759,7 @@ class ProviderWithEndpointsSummary(BaseModel):
# Model 统计
total_models: int = Field(default=0, description="总模型数量")
active_models: int = Field(default=0, description="活跃模型数量")
global_model_ids: list[str] = Field(default=[], description="活跃模型关联的全局模型 ID 列表")
# API 格式列表
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")

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