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:
fawney19
2026-03-12 17:11:14 +08:00
parent 8d69f72e2a
commit e9678ea899
11 changed files with 553 additions and 145 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)