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

@@ -7,7 +7,7 @@ from typing import Any
import pytest
from src.api.admin.usage.routes import AdminUsageRecordsAdapter
from src.api.admin.usage.routes import AdminUsageDetailAdapter, AdminUsageRecordsAdapter
class _FakeQuery:
@@ -16,9 +16,11 @@ class _FakeQuery:
*,
scalar_result: int | None = None,
all_result: list[Any] | None = None,
first_result: Any = None,
) -> None:
self.scalar_result = scalar_result
self.all_result = all_result or []
self.first_result = first_result
self.options_args: tuple[Any, ...] = ()
def outerjoin(self, *args: Any, **kwargs: Any) -> _FakeQuery:
@@ -49,6 +51,9 @@ class _FakeQuery:
def all(self) -> list[Any]:
return self.all_result
def first(self) -> Any:
return self.first_result
class _FakeDb:
def __init__(self, queries: list[_FakeQuery]) -> None:
@@ -141,3 +146,130 @@ async def test_admin_usage_records_returns_model_version_without_request_metadat
usage_load_only = data_query.options_args[0]
usage_paths = {str(option.path) for option in usage_load_only.context}
assert "ORM Path[Mapper[Usage(usage)] -> Usage.request_metadata]" not in usage_paths
@pytest.mark.asyncio
async def test_admin_usage_detail_defers_large_body_columns_when_bodies_excluded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_get_tiered_pricing_info(
self: AdminUsageDetailAdapter,
db: Any,
usage_record: Any,
) -> None:
return None
monkeypatch.setattr(
AdminUsageDetailAdapter,
"_get_tiered_pricing_info",
_fake_get_tiered_pricing_info,
)
monkeypatch.setattr(
AdminUsageDetailAdapter,
"_extract_video_billing_info",
lambda self, usage_record: None,
)
class _UsageRecord:
id = "usage-1"
request_id = "req-1"
user_id = "user-1"
api_key_id = "key-1"
provider_name = "openai"
api_format = "openai:cli"
model = "gpt-5.4"
target_model = None
input_tokens = 10
output_tokens = 20
total_tokens = 30
cache_creation_input_tokens = 0
cache_read_input_tokens = 0
cache_creation_input_tokens_5m = 0
cache_creation_input_tokens_1h = 0
input_cost_usd = Decimal("0.001")
output_cost_usd = Decimal("0.002")
total_cost_usd = Decimal("0.003")
cache_creation_cost_usd = Decimal("0")
cache_read_cost_usd = Decimal("0")
request_cost_usd = Decimal("0")
input_price_per_1m = Decimal("0.1")
output_price_per_1m = Decimal("0.2")
cache_creation_price_per_1m = None
cache_read_price_per_1m = None
price_per_request = None
request_type = "chat"
is_stream = True
status_code = 200
error_message = None
status = "completed"
response_time_ms = 1200
first_byte_time_ms = 200
created_at = datetime(2026, 3, 12, 7, 0, tzinfo=timezone.utc)
request_headers = {"x-test": "1"}
provider_request_headers = {"authorization": "***"}
response_headers = {"content-type": "text/event-stream"}
client_response_headers = {"content-type": "text/event-stream"}
request_metadata = {"trace_id": "trace-1"}
def get_request_body(self) -> Any:
raise AssertionError("request body should not be loaded")
def get_provider_request_body(self) -> Any:
raise AssertionError("provider request body should not be loaded")
def get_response_body(self) -> Any:
raise AssertionError("response body should not be loaded")
def get_client_response_body(self) -> Any:
raise AssertionError("client response body should not be loaded")
class _ApiKeyRecord:
id = "key-1"
name = "Primary"
def get_display_key(self) -> str:
return "sk-test"
usage_query = _FakeQuery(
first_result=(_UsageRecord(), True, True, True, True),
)
user_query = _FakeQuery(
first_result=SimpleNamespace(id="user-1", username="tester", email="u@example.com"),
)
api_key_query = _FakeQuery(first_result=_ApiKeyRecord())
db = _FakeDb([usage_query, user_query, api_key_query])
context = SimpleNamespace(
db=db,
user=SimpleNamespace(id="admin-1"),
add_audit_metadata=lambda **_: None,
)
adapter = AdminUsageDetailAdapter(usage_id="usage-1", include_bodies=False)
result = await adapter.handle(context) # type: ignore[arg-type]
assert result["request_body"] is None
assert result["provider_request_body"] is None
assert result["response_body"] is None
assert result["client_response_body"] is None
assert result["has_request_body"] is True
assert result["has_provider_request_body"] is True
assert result["has_response_body"] is True
assert result["has_client_response_body"] is True
deferred_paths = {
str(context.path)
for option in usage_query.options_args
for context in getattr(option, "context", ())
}
assert "ORM Path[Mapper[Usage(usage)] -> Usage.request_body]" in deferred_paths
assert "ORM Path[Mapper[Usage(usage)] -> Usage.provider_request_body]" in deferred_paths
assert "ORM Path[Mapper[Usage(usage)] -> Usage.response_body]" in deferred_paths
assert "ORM Path[Mapper[Usage(usage)] -> Usage.client_response_body]" in deferred_paths
assert "ORM Path[Mapper[Usage(usage)] -> Usage.request_body_compressed]" in deferred_paths
assert (
"ORM Path[Mapper[Usage(usage)] -> Usage.provider_request_body_compressed]" in deferred_paths
)
assert "ORM Path[Mapper[Usage(usage)] -> Usage.response_body_compressed]" in deferred_paths
assert (
"ORM Path[Mapper[Usage(usage)] -> Usage.client_response_body_compressed]" in deferred_paths
)

View File

@@ -48,6 +48,14 @@ def test_new_provider_priority_defaults_to_current_top() -> None:
assert needs_shift is False
def test_new_provider_priority_clamps_at_zero_when_already_topmost() -> None:
priority, needs_shift = _resolve_new_provider_priority(
current_min_priority=0, requested_priority=None
)
assert priority == 0
assert needs_shift is True
def test_new_provider_priority_defaults_to_100_when_empty() -> None:
priority, needs_shift = _resolve_new_provider_priority(
current_min_priority=None, requested_priority=None

View File

@@ -1,4 +1,7 @@
import pytest
from src.api.admin.system import AdminExportConfigAdapter, AdminImportConfigAdapter
from src.core.exceptions import InvalidRequestException
from src.services.provider_ops.types import SENSITIVE_CREDENTIAL_FIELDS
@@ -114,3 +117,27 @@ def test_import_provider_config_encrypts_refresh_token() -> None:
assert result["provider_ops"]["connector"]["credentials"]["refresh_token"] == "enc:rt-1"
assert result["provider_ops"]["connector"]["credentials"]["api_key"] == "enc:key-1"
assert config["provider_ops"]["connector"]["credentials"]["refresh_token"] == "rt-1"
def test_import_endpoint_payload_rejects_dict_base_url() -> None:
with pytest.raises(InvalidRequestException, match="导入 Endpoint 失败"):
AdminImportConfigAdapter._normalize_import_endpoint_payload(
"provider-1",
{
"api_format": "claude:chat",
"base_url": {"url": "https://api.anthropic.com"},
},
)
def test_import_endpoint_payload_normalizes_base_url() -> None:
result = AdminImportConfigAdapter._normalize_import_endpoint_payload(
"provider-1",
{
"api_format": "claude:chat",
"base_url": "https://api.anthropic.com/",
},
)
assert result["base_url"] == "https://api.anthropic.com"
assert result["api_format"] == "claude:chat"

View File

@@ -4,59 +4,22 @@ from src.api.handlers.claude.adapter import ClaudeChatAdapter
@pytest.mark.asyncio
async def test_check_endpoint_accepts_base_url_dict(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}
async def test_check_endpoint_rejects_base_url_dict() -> None:
with pytest.raises(TypeError, match="base_url must be a non-empty string"):
await ClaudeChatAdapter.check_endpoint(
client=None, # type: ignore[arg-type]
base_url={"base_url": "https://api.anthropic.com"}, # type: ignore[arg-type]
api_key="test-key",
request_data={
"model": "claude-sonnet-4-5-20250929",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 32,
"stream": False,
},
)
async def fake_run_endpoint_check(**kwargs):
captured.update(kwargs)
return {"status_code": 200, "headers": {}, "response_time_ms": 1, "request_id": "test"}
monkeypatch.setattr(
"src.api.handlers.base.endpoint_checker.run_endpoint_check",
fake_run_endpoint_check,
def test_validate_test_base_url_trims_whitespace() -> None:
assert ClaudeChatAdapter._validate_test_base_url(" https://api.anthropic.com/v1 ") == (
"https://api.anthropic.com/v1"
)
await ClaudeChatAdapter.check_endpoint(
client=None,
base_url={"base_url": "https://api.anthropic.com"},
api_key="test-key",
request_data={
"model": "claude-sonnet-4-5-20250929",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 32,
"stream": False,
},
)
assert captured["url"] == "https://api.anthropic.com/v1/messages"
assert isinstance(captured["json_body"], dict)
@pytest.mark.asyncio
async def test_check_endpoint_accepts_url_key_in_base_url_dict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def fake_run_endpoint_check(**kwargs):
captured.update(kwargs)
return {"status_code": 200, "headers": {}, "response_time_ms": 1, "request_id": "test"}
monkeypatch.setattr(
"src.api.handlers.base.endpoint_checker.run_endpoint_check",
fake_run_endpoint_check,
)
await ClaudeChatAdapter.check_endpoint(
client=None,
base_url={"url": "https://api.anthropic.com/v1"},
api_key="test-key",
request_data={
"model": "claude-sonnet-4-5-20250929",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 32,
"stream": False,
},
)
assert captured["url"] == "https://api.anthropic.com/v1/messages"

View File

@@ -9,6 +9,7 @@ from src.api.admin.provider_query import (
_build_test_attempts_from_candidate_keys,
_filter_test_candidates_by_endpoint,
_flatten_test_candidates_for_concurrency,
_require_test_endpoint_base_url,
_resolve_test_effective_model,
)
from src.services.scheduling.schemas import PoolCandidate
@@ -148,3 +149,18 @@ def test_test_model_failover_request_validates_concurrency_range() -> None:
model_name="gpt-4o-mini",
concurrency=0,
)
def test_require_test_endpoint_base_url_rejects_non_string() -> None:
endpoint = SimpleNamespace(id="ep-bad", api_format="claude:chat", base_url={"url": "https://x"})
with pytest.raises(ValueError, match="invalid base_url type"):
_require_test_endpoint_base_url(endpoint)
def test_require_test_endpoint_base_url_trims_whitespace() -> None:
endpoint = SimpleNamespace(
id="ep-ok", api_format="claude:chat", base_url=" https://api.anthropic.com "
)
assert _require_test_endpoint_base_url(endpoint) == "https://api.anthropic.com"