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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user