mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(admin): 优先级脏检查、base_url 校验、usage detail 延迟加载及导入数据验证
- 前端优先级管理: 保存时对比原始快照,仅提交实际变更的 provider/key 优先级, 并限制并发请求数(SAVE_CONCURRENCY=6),避免无效 API 调用 - handler_adapter_base: _normalize_test_base_url 改为 _validate_test_base_url, 移除对 dict 类型 base_url 的兼容,严格要求字符串输入 - provider_query: 新增 _require_test_endpoint_base_url,在测试链路提前校验 endpoint.base_url 类型和非空 - system.py: 导入 endpoint 时通过 ProviderEndpointCreate 模型校验数据, 拒绝非法 base_url 类型(如 dict) - usage detail: 使用 defer() 延迟加载 body 列,通过 SQL CASE 表达式在 数据库端计算 has_*_body 标记,减少不必要的大字段传输 - provider routes: 新建 provider 时 priority=0 边界处理,clamp 并 shift
This commit is contained in:
@@ -83,6 +83,25 @@ def _get_adapter_for_format(api_format: str) -> Any:
|
||||
return get_adapter_class(api_format) or get_cli_adapter_class(api_format)
|
||||
|
||||
|
||||
def _require_test_endpoint_base_url(endpoint: Any) -> str:
|
||||
"""校验测试链路里的 endpoint.base_url。"""
|
||||
base_url = getattr(endpoint, "base_url", None)
|
||||
if not isinstance(base_url, str):
|
||||
endpoint_id = str(getattr(endpoint, "id", "") or "unknown")
|
||||
api_format = str(getattr(endpoint, "api_format", "") or "unknown")
|
||||
raise ValueError(
|
||||
f"Endpoint {endpoint_id} ({api_format}) has invalid base_url type: "
|
||||
f"expected str, got {type(base_url).__name__}"
|
||||
)
|
||||
|
||||
normalized = base_url.strip()
|
||||
if not normalized:
|
||||
endpoint_id = str(getattr(endpoint, "id", "") or "unknown")
|
||||
api_format = str(getattr(endpoint, "api_format", "") or "unknown")
|
||||
raise ValueError(f"Endpoint {endpoint_id} ({api_format}) has empty base_url")
|
||||
return normalized
|
||||
|
||||
|
||||
def _antigravity_sort_keys(api_keys: list[Any]) -> list[Any]:
|
||||
"""按 tier/可用性对 Antigravity Key 降序排列。
|
||||
|
||||
@@ -917,7 +936,7 @@ async def test_model(
|
||||
endpoint_config = {
|
||||
"api_key": api_key_value,
|
||||
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
|
||||
"base_url": endpoint.base_url,
|
||||
"base_url": _require_test_endpoint_base_url(endpoint),
|
||||
"api_format": endpoint.api_format,
|
||||
"extra_headers": extra_headers if extra_headers else None,
|
||||
"timeout": TimeoutDefaults.HTTP_REQUEST,
|
||||
@@ -1601,7 +1620,7 @@ async def _execute_test_check(
|
||||
async def _run_check(stream: bool) -> dict[str, Any]:
|
||||
return await adapter_class.check_endpoint(
|
||||
None,
|
||||
endpoint.base_url,
|
||||
_require_test_endpoint_base_url(endpoint),
|
||||
api_key_value,
|
||||
{
|
||||
**request_payload,
|
||||
|
||||
@@ -76,13 +76,17 @@ def _resolve_new_provider_priority(
|
||||
"""Resolve insertion priority for a newly created provider.
|
||||
|
||||
Returns ``(priority, needs_shift)``. When the caller explicitly specifies
|
||||
a priority we need to shift existing rows; when auto-topping we simply pick
|
||||
``min - 1`` so no shift is required.
|
||||
a priority we need to shift existing rows. For auto-top insertion we prefer
|
||||
``min - 1`` when that still stays non-negative; otherwise we clamp to ``0``
|
||||
and shift existing rows down to preserve ordering.
|
||||
"""
|
||||
if requested_priority is not None:
|
||||
return int(requested_priority), True
|
||||
if current_min_priority is not None:
|
||||
return int(current_min_priority) - 1, False
|
||||
current_min = int(current_min_priority)
|
||||
if current_min <= 0:
|
||||
return 0, True
|
||||
return current_min - 1, False
|
||||
return 100, False
|
||||
|
||||
|
||||
|
||||
@@ -1255,6 +1255,41 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
return sorted(endpoint_formats)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _normalize_import_endpoint_payload(
|
||||
provider_id: str,
|
||||
ep_data: dict[str, Any],
|
||||
existing_ep: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""校验并规范化导入的 Endpoint 数据。"""
|
||||
from src.models.endpoint_models import ProviderEndpointCreate
|
||||
|
||||
payload = {
|
||||
"provider_id": provider_id,
|
||||
"api_format": ep_data.get("api_format", getattr(existing_ep, "api_format", None)),
|
||||
"base_url": ep_data.get("base_url", getattr(existing_ep, "base_url", None)),
|
||||
"custom_path": ep_data.get("custom_path", getattr(existing_ep, "custom_path", None)),
|
||||
"header_rules": ep_data.get("header_rules", getattr(existing_ep, "header_rules", None)),
|
||||
"body_rules": ep_data.get("body_rules", getattr(existing_ep, "body_rules", None)),
|
||||
"max_retries": ep_data.get("max_retries", getattr(existing_ep, "max_retries", 2)),
|
||||
"config": ep_data.get("config", getattr(existing_ep, "config", None)),
|
||||
"proxy": ep_data.get("proxy", getattr(existing_ep, "proxy", None)),
|
||||
"format_acceptance_config": ep_data.get(
|
||||
"format_acceptance_config",
|
||||
getattr(existing_ep, "format_acceptance_config", None),
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
validated = ProviderEndpointCreate.model_validate(payload)
|
||||
except Exception as exc:
|
||||
api_format = payload.get("api_format") or "unknown"
|
||||
raise InvalidRequestException(
|
||||
f"导入 Endpoint 失败: provider_id={provider_id}, api_format={api_format}, error={exc}"
|
||||
) from exc
|
||||
|
||||
return validated.model_dump(mode="python")
|
||||
|
||||
def _encrypt_provider_config(self, config: dict, crypto_service: Any) -> dict:
|
||||
"""加密 Provider config 中的 provider_ops credentials"""
|
||||
if not config:
|
||||
@@ -1579,18 +1614,23 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
f"Endpoint '{ep_format}' 已存在于 Provider '{prov_data['name']}'"
|
||||
)
|
||||
elif merge_mode == "overwrite":
|
||||
existing_ep.base_url = ep_data.get("base_url", existing_ep.base_url)
|
||||
existing_ep.header_rules = ep_data.get("header_rules")
|
||||
existing_ep.body_rules = ep_data.get("body_rules")
|
||||
existing_ep.max_retries = ep_data.get("max_retries", 2)
|
||||
normalized_ep = self._normalize_import_endpoint_payload(
|
||||
provider_id,
|
||||
{**ep_data, "api_format": ep_format},
|
||||
existing_ep=existing_ep,
|
||||
)
|
||||
existing_ep.base_url = normalized_ep["base_url"]
|
||||
existing_ep.header_rules = normalized_ep.get("header_rules")
|
||||
existing_ep.body_rules = normalized_ep.get("body_rules")
|
||||
existing_ep.max_retries = normalized_ep.get("max_retries", 2)
|
||||
existing_ep.is_active = ep_data.get("is_active", True)
|
||||
existing_ep.custom_path = ep_data.get("custom_path")
|
||||
existing_ep.config = ep_data.get("config")
|
||||
existing_ep.format_acceptance_config = ep_data.get(
|
||||
existing_ep.custom_path = normalized_ep.get("custom_path")
|
||||
existing_ep.config = normalized_ep.get("config")
|
||||
existing_ep.format_acceptance_config = normalized_ep.get(
|
||||
"format_acceptance_config"
|
||||
)
|
||||
existing_ep.proxy = self._remap_proxy_node_id(
|
||||
ep_data.get("proxy"), proxy_node_id_map
|
||||
normalized_ep.get("proxy"), proxy_node_id_map
|
||||
)
|
||||
sig = parse_signature_key(ep_format)
|
||||
existing_ep.api_format = sig.key # 使用归一化后的格式
|
||||
@@ -1599,6 +1639,10 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
existing_ep.updated_at = datetime.now(timezone.utc)
|
||||
stats["endpoints"]["updated"] += 1
|
||||
else:
|
||||
normalized_ep = self._normalize_import_endpoint_payload(
|
||||
provider_id,
|
||||
{**ep_data, "api_format": ep_format},
|
||||
)
|
||||
sig = parse_signature_key(ep_format)
|
||||
api_family = sig.api_family.value
|
||||
endpoint_kind = sig.endpoint_kind.value
|
||||
@@ -1608,16 +1652,16 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
api_format=sig.key, # 使用归一化后的格式
|
||||
api_family=api_family,
|
||||
endpoint_kind=endpoint_kind,
|
||||
base_url=ep_data["base_url"],
|
||||
header_rules=ep_data.get("header_rules"),
|
||||
body_rules=ep_data.get("body_rules"),
|
||||
max_retries=ep_data.get("max_retries", 2),
|
||||
base_url=normalized_ep["base_url"],
|
||||
header_rules=normalized_ep.get("header_rules"),
|
||||
body_rules=normalized_ep.get("body_rules"),
|
||||
max_retries=normalized_ep.get("max_retries", 2),
|
||||
is_active=ep_data.get("is_active", True),
|
||||
custom_path=ep_data.get("custom_path"),
|
||||
config=ep_data.get("config"),
|
||||
format_acceptance_config=ep_data.get("format_acceptance_config"),
|
||||
custom_path=normalized_ep.get("custom_path"),
|
||||
config=normalized_ep.get("config"),
|
||||
format_acceptance_config=normalized_ep.get("format_acceptance_config"),
|
||||
proxy=self._remap_proxy_node_id(
|
||||
ep_data.get("proxy"), proxy_node_id_map
|
||||
normalized_ep.get("proxy"), proxy_node_id_map
|
||||
),
|
||||
)
|
||||
db.add(new_ep)
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
@@ -886,8 +886,8 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
count_query = count_query.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||
|
||||
# -- 构建数据查询(完整 JOIN) --
|
||||
usage_model_version = Usage.request_metadata["model_version"].as_string().label(
|
||||
"model_version"
|
||||
usage_model_version = (
|
||||
Usage.request_metadata["model_version"].as_string().label("model_version")
|
||||
)
|
||||
|
||||
query = (
|
||||
@@ -1246,16 +1246,77 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
||||
usage_id: str
|
||||
include_bodies: bool = True
|
||||
|
||||
def _build_usage_detail_query(self, db: Session) -> Any:
|
||||
query = db.query(
|
||||
Usage,
|
||||
case(
|
||||
(
|
||||
(Usage.request_body.isnot(None)) | (Usage.request_body_compressed.isnot(None)),
|
||||
True,
|
||||
),
|
||||
else_=False,
|
||||
).label("has_request_body"),
|
||||
case(
|
||||
(
|
||||
(Usage.provider_request_body.isnot(None))
|
||||
| (Usage.provider_request_body_compressed.isnot(None)),
|
||||
True,
|
||||
),
|
||||
else_=False,
|
||||
).label("has_provider_request_body"),
|
||||
case(
|
||||
(
|
||||
(Usage.response_body.isnot(None))
|
||||
| (Usage.response_body_compressed.isnot(None)),
|
||||
True,
|
||||
),
|
||||
else_=False,
|
||||
).label("has_response_body"),
|
||||
case(
|
||||
(
|
||||
(Usage.client_response_body.isnot(None))
|
||||
| (Usage.client_response_body_compressed.isnot(None)),
|
||||
True,
|
||||
),
|
||||
else_=False,
|
||||
).label("has_client_response_body"),
|
||||
)
|
||||
|
||||
if not self.include_bodies:
|
||||
query = query.options(
|
||||
defer(Usage.request_body),
|
||||
defer(Usage.provider_request_body),
|
||||
defer(Usage.response_body),
|
||||
defer(Usage.client_response_body),
|
||||
defer(Usage.request_body_compressed),
|
||||
defer(Usage.provider_request_body_compressed),
|
||||
defer(Usage.response_body_compressed),
|
||||
defer(Usage.client_response_body_compressed),
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def _load_usage_detail_row(self, db: Session) -> Any:
|
||||
usage_row = self._build_usage_detail_query(db).filter(Usage.id == self.usage_id).first()
|
||||
if usage_row:
|
||||
return usage_row
|
||||
return self._build_usage_detail_query(db).filter(Usage.request_id == self.usage_id).first()
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
# 先通过主键 id 查找,如果找不到再尝试通过 request_id 查找
|
||||
usage_record = db.query(Usage).filter(Usage.id == self.usage_id).first()
|
||||
if not usage_record:
|
||||
# 兼容通过 request_id 查找(用于异步任务等场景)
|
||||
usage_record = db.query(Usage).filter(Usage.request_id == self.usage_id).first()
|
||||
if not usage_record:
|
||||
usage_row = self._load_usage_detail_row(db)
|
||||
if not usage_row:
|
||||
raise HTTPException(status_code=404, detail="Usage record not found")
|
||||
|
||||
(
|
||||
usage_record,
|
||||
has_request_body,
|
||||
has_provider_request_body,
|
||||
has_response_body,
|
||||
has_client_response_body,
|
||||
) = usage_row
|
||||
|
||||
user = db.query(User).filter(User.id == usage_record.user_id).first()
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == usage_record.api_key_id).first()
|
||||
|
||||
@@ -1270,23 +1331,6 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
||||
# 提取视频/图像/音频计费信息
|
||||
video_billing_info = self._extract_video_billing_info(usage_record)
|
||||
|
||||
has_request_body = bool(
|
||||
usage_record.request_body is not None
|
||||
or usage_record.request_body_compressed is not None
|
||||
)
|
||||
has_provider_request_body = bool(
|
||||
usage_record.provider_request_body is not None
|
||||
or usage_record.provider_request_body_compressed is not None
|
||||
)
|
||||
has_response_body = bool(
|
||||
usage_record.response_body is not None
|
||||
or usage_record.response_body_compressed is not None
|
||||
)
|
||||
has_client_response_body = bool(
|
||||
usage_record.client_response_body is not None
|
||||
or usage_record.client_response_body_compressed is not None
|
||||
)
|
||||
|
||||
request_body = usage_record.get_request_body() if self.include_bodies else None
|
||||
provider_request_body = (
|
||||
usage_record.get_provider_request_body() if self.include_bodies else None
|
||||
|
||||
@@ -322,20 +322,15 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
return build_test_request_body(cls.FORMAT_ID, request_data)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_test_base_url(base_url: Any) -> str:
|
||||
"""归一化 test-model 场景传入的 base_url。"""
|
||||
if isinstance(base_url, str):
|
||||
normalized = base_url.strip()
|
||||
if normalized:
|
||||
return normalized
|
||||
elif isinstance(base_url, dict):
|
||||
for key in ("base_url", "url"):
|
||||
value = base_url.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
logger.debug("[check_endpoint] 兼容字典形式的 base_url 输入: key={}", key)
|
||||
return value.strip()
|
||||
def _validate_test_base_url(base_url: Any) -> str:
|
||||
"""校验 test-model 场景传入的 base_url。"""
|
||||
if not isinstance(base_url, str):
|
||||
raise TypeError(f"base_url must be a non-empty string, got {type(base_url).__name__}")
|
||||
|
||||
raise TypeError("base_url must be a non-empty string or a dict containing 'base_url'/'url'")
|
||||
normalized = base_url.strip()
|
||||
if not normalized:
|
||||
raise ValueError("base_url must be a non-empty string")
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
async def check_endpoint(
|
||||
@@ -375,7 +370,7 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
from src.core.api_format.headers import HeaderBuilder
|
||||
from src.core.provider_types import ProviderType
|
||||
|
||||
normalized_base_url = cls._normalize_test_base_url(base_url)
|
||||
validated_base_url = cls._validate_test_base_url(base_url)
|
||||
is_antigravity = provider_type == ProviderType.ANTIGRAVITY
|
||||
is_gemini_cli = provider_type == ProviderType.GEMINI_CLI
|
||||
is_vertex = provider_type == ProviderType.VERTEX_AI
|
||||
@@ -393,9 +388,9 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
_kiro_cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {})
|
||||
region = _kiro_cfg.effective_api_region()
|
||||
effective_base_url = (
|
||||
normalized_base_url.replace("{region}", region)
|
||||
if "{region}" in normalized_base_url
|
||||
else normalized_base_url
|
||||
validated_base_url.replace("{region}", region)
|
||||
if "{region}" in validated_base_url
|
||||
else validated_base_url
|
||||
)
|
||||
url = f"{str(effective_base_url).rstrip('/')}{KIRO_GENERATE_ASSISTANT_PATH}"
|
||||
elif is_antigravity:
|
||||
@@ -408,13 +403,13 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
)
|
||||
|
||||
ordered_urls = url_availability.get_ordered_urls(prefer_daily=True)
|
||||
effective_base_url = ordered_urls[0] if ordered_urls else base_url
|
||||
effective_base_url = ordered_urls[0] if ordered_urls else validated_base_url
|
||||
path = V1INTERNAL_PATH_TEMPLATE.format(action="generateContent")
|
||||
url = f"{str(effective_base_url).rstrip('/')}{path}"
|
||||
elif is_gemini_cli:
|
||||
from src.services.provider.adapters.gemini_cli.constants import V1INTERNAL_PATH_TEMPLATE
|
||||
|
||||
effective_base_url = base_url
|
||||
effective_base_url = validated_base_url
|
||||
path = V1INTERNAL_PATH_TEMPLATE.format(action="generateContent")
|
||||
url = f"{str(effective_base_url).rstrip('/')}{path}"
|
||||
elif is_vertex and provider_endpoint is not None and provider_api_key is not None:
|
||||
@@ -441,7 +436,7 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
)
|
||||
else:
|
||||
url = cls.build_endpoint_url(
|
||||
normalized_base_url,
|
||||
validated_base_url,
|
||||
request_data,
|
||||
model_name,
|
||||
provider_type=provider_type,
|
||||
@@ -449,7 +444,7 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
|
||||
# ---- Headers ----
|
||||
cli_extra = cls.get_cli_extra_headers(
|
||||
base_url=normalized_base_url,
|
||||
base_url=validated_base_url,
|
||||
provider_type=provider_type,
|
||||
)
|
||||
merged_extra = dict(extra_headers) if extra_headers else {}
|
||||
@@ -507,7 +502,7 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
# ---- Body ----
|
||||
body = cls.build_request_body(
|
||||
request_data,
|
||||
base_url=normalized_base_url,
|
||||
base_url=validated_base_url,
|
||||
provider_type=provider_type,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user