mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
refactor: 将 adapter 层的计费/模型抓取/行为变体能力下沉到 core.api_format 注册表
- 新增 core/api_format/capabilities.py,统一注册计费模板、模型抓取、 total_input_context 计算和 provider behavior variant - 新增 core/usage_tokens.py,抽取 cache token 解析逻辑到 core 层 - handler adapter 移除各自的 compute_total_input_context / fetch_models / BILLING_TEMPLATE 覆盖,改为委托 core 注册表解析 - provider/behavior.py 改为薄封装,底层委托 core registry - 新增 tests/test_architecture_import_rules.py 架构导入约束测试 - 新增 tests/services/api_format/test_capabilities.py 能力注册表测试 Closes #207 Co-authored-by: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
@@ -8,11 +8,11 @@ import pytest
|
||||
from src.api.handlers.base.utils import (
|
||||
build_json_response_for_client,
|
||||
build_sse_headers,
|
||||
extract_cache_creation_tokens,
|
||||
filter_proxy_response_headers,
|
||||
resolve_client_accept_encoding,
|
||||
resolve_client_content_encoding,
|
||||
)
|
||||
from src.core.usage_tokens import extract_cache_creation_tokens
|
||||
|
||||
|
||||
class TestExtractCacheCreationTokens:
|
||||
@@ -60,7 +60,7 @@ class TestExtractCacheCreationTokens:
|
||||
|
||||
def test_empty_usage(self) -> None:
|
||||
"""测试空字典"""
|
||||
usage = {}
|
||||
usage: dict[str, int] = {}
|
||||
assert extract_cache_creation_tokens(usage) == 0
|
||||
|
||||
def test_all_zeros(self) -> None:
|
||||
|
||||
109
tests/services/api_format/test_capabilities.py
Normal file
109
tests/services/api_format/test_capabilities.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.core.api_format.capabilities import (
|
||||
compute_total_input_context_for_api_format,
|
||||
fetch_models_for_api_format,
|
||||
get_provider_default_body_rules,
|
||||
register_provider_default_body_rules,
|
||||
register_provider_format_behavior,
|
||||
resolve_billing_template_for_api_format,
|
||||
)
|
||||
from src.core.api_format.metadata import get_default_body_rules_for_endpoint
|
||||
from src.services.provider.behavior import get_provider_behavior
|
||||
|
||||
|
||||
class _DummyResp:
|
||||
def __init__(self, status_code: int, payload: object, text: str = "") -> None:
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
self.text = text
|
||||
|
||||
def json(self) -> object:
|
||||
return self.payload
|
||||
|
||||
|
||||
def test_api_format_capability_billing_mapping() -> None:
|
||||
assert resolve_billing_template_for_api_format("openai:cli") == "openai"
|
||||
assert resolve_billing_template_for_api_format("claude:chat") == "claude"
|
||||
assert resolve_billing_template_for_api_format("gemini:cli") == "gemini"
|
||||
|
||||
|
||||
def test_provider_default_body_rules_use_unified_registry() -> None:
|
||||
provider_type = "unit_test_provider_rules"
|
||||
expected = [{"action": "drop", "path": "foo"}]
|
||||
register_provider_default_body_rules(provider_type, "openai:cli", expected)
|
||||
|
||||
rules = get_provider_default_body_rules(provider_type, "openai:cli")
|
||||
assert rules == expected
|
||||
assert (
|
||||
get_default_body_rules_for_endpoint("openai:cli", provider_type=provider_type) == expected
|
||||
)
|
||||
|
||||
|
||||
def test_provider_behavior_variants_use_unified_registry() -> None:
|
||||
provider_type = "unit_test_variant_provider"
|
||||
register_provider_format_behavior(
|
||||
provider_type,
|
||||
same_format_variant="same-unit",
|
||||
cross_format_variant="cross-unit",
|
||||
)
|
||||
|
||||
behavior = get_provider_behavior(provider_type=provider_type, endpoint_sig="openai:cli")
|
||||
assert behavior.same_format_variant == "same-unit"
|
||||
assert behavior.cross_format_variant == "cross-unit"
|
||||
|
||||
|
||||
def test_api_format_capability_total_input_context() -> None:
|
||||
assert compute_total_input_context_for_api_format("openai:chat", 100, 20, 30) == 120
|
||||
assert compute_total_input_context_for_api_format("claude:chat", 100, 20, 30) == 150
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_uses_registered_claude_strategy() -> None:
|
||||
client = SimpleNamespace(
|
||||
get=AsyncMock(
|
||||
side_effect=[
|
||||
_DummyResp(
|
||||
status_code=200,
|
||||
payload={
|
||||
"data": [{"id": "m1"}, {"id": "m2"}],
|
||||
"has_more": True,
|
||||
"last_id": "m2",
|
||||
},
|
||||
),
|
||||
_DummyResp(
|
||||
status_code=200,
|
||||
payload={
|
||||
"data": [{"id": "m3"}],
|
||||
"has_more": False,
|
||||
"last_id": "m3",
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
models, err = await fetch_models_for_api_format(
|
||||
client, # type: ignore[arg-type]
|
||||
api_format="claude:chat",
|
||||
base_url="https://api.anthropic.com",
|
||||
api_key="k",
|
||||
)
|
||||
|
||||
assert err is None
|
||||
assert [m.get("id") for m in models] == ["m1", "m2", "m3"]
|
||||
assert all(m.get("api_format") == "claude:chat" for m in models)
|
||||
assert client.get.call_count == 2
|
||||
_, kwargs2 = client.get.call_args_list[1]
|
||||
assert kwargs2.get("params", {}).get("after_id") == "m2"
|
||||
@@ -178,11 +178,13 @@ def test_clear_oauth_invalid_response_invalidates_caches(
|
||||
sys.modules, "src.services.cache.provider_cache", fake_provider_cache_module
|
||||
)
|
||||
|
||||
fake_models_service_module = types.ModuleType("src.api.base.models_service")
|
||||
fake_models_service_module = types.ModuleType("src.services.cache.model_list_cache")
|
||||
setattr(
|
||||
fake_models_service_module, "invalidate_models_list_cache", _fake_invalidate_models_cache
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "src.api.base.models_service", fake_models_service_module)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "src.services.cache.model_list_cache", fake_models_service_module
|
||||
)
|
||||
|
||||
key = SimpleNamespace(
|
||||
oauth_invalid_at=datetime.now(timezone.utc),
|
||||
|
||||
78
tests/test_architecture_import_rules.py
Normal file
78
tests/test_architecture_import_rules.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Violation:
|
||||
file: Path
|
||||
line: int
|
||||
imported: str
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _iter_python_files(root: Path) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in root.rglob("*.py"):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def _scan_imports(py_file: Path) -> list[tuple[int, str]]:
|
||||
"""返回 (lineno, module_name) 列表。"""
|
||||
text = py_file.read_text(encoding="utf-8").lstrip("\ufeff")
|
||||
tree = ast.parse(text, filename=str(py_file))
|
||||
|
||||
imports: list[tuple[int, str]] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
imports.append((int(getattr(node, "lineno", 0) or 0), str(alias.name)))
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.level:
|
||||
continue
|
||||
if node.module:
|
||||
imports.append((int(getattr(node, "lineno", 0) or 0), str(node.module)))
|
||||
return imports
|
||||
|
||||
|
||||
def _scan_forbidden_imports(*, scope_dir: Path, forbidden_prefix: str) -> list[_Violation]:
|
||||
violations: list[_Violation] = []
|
||||
for py_file in _iter_python_files(scope_dir):
|
||||
for line, imported in _scan_imports(py_file):
|
||||
if imported == forbidden_prefix or imported.startswith(f"{forbidden_prefix}."):
|
||||
violations.append(_Violation(file=py_file, line=line, imported=imported))
|
||||
return violations
|
||||
|
||||
|
||||
def _format_violations(title: str, violations: list[_Violation]) -> str:
|
||||
lines = [title, ""]
|
||||
for v in sorted(violations, key=lambda x: (str(x.file), x.line, x.imported)):
|
||||
rel = v.file.resolve().relative_to(_repo_root())
|
||||
lines.append(f"- {rel}:{v.line} -> {v.imported}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def test_services_should_not_import_api() -> None:
|
||||
repo = _repo_root()
|
||||
violations = _scan_forbidden_imports(
|
||||
scope_dir=repo / "src" / "services",
|
||||
forbidden_prefix="src.api",
|
||||
)
|
||||
assert not violations, _format_violations("services 层禁止 import api 层:", violations)
|
||||
|
||||
|
||||
def test_core_should_not_import_services() -> None:
|
||||
repo = _repo_root()
|
||||
violations = _scan_forbidden_imports(
|
||||
scope_dir=repo / "src" / "core",
|
||||
forbidden_prefix="src.services",
|
||||
)
|
||||
assert not violations, _format_violations("core 层禁止 import services 层:", violations)
|
||||
Reference in New Issue
Block a user