mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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.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)
|
||||
|
||||
# 提交数据库更改
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 响应
|
||||
|
||||
45
src/api/handlers/gemini/image_gen.py
Normal file
45
src/api/handlers/gemini/image_gen.py
Normal 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
|
||||
@@ -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],
|
||||
|
||||
@@ -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 格式列表")
|
||||
|
||||
@@ -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