初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,67 @@
import pytest
from app.ai_provider.core.billing import (
BudgetController,
CostCalculator,
PricingInfo,
UsageRecord,
)
class TestCostCalculator:
def test_openai_pricing(self):
pricing = PricingInfo(currency="USD", input_per_1m=5.0, output_per_1m=15.0)
cost = CostCalculator.calculate(pricing, 1000, 500)
assert cost.input_cost == 0.005 # 1000 / 1M * 5.0
assert cost.output_cost == 0.0075 # 500 / 1M * 15.0
assert cost.total == 0.0125
assert cost.currency == "USD"
def test_no_output_pricing(self):
pricing = PricingInfo(currency="CNY", input_per_1m=1.0, output_per_1m=None)
cost = CostCalculator.calculate(pricing, 1000, 0)
assert cost.input_cost == 0.001
assert cost.output_cost == 0.0
def test_no_pricing(self):
cost = CostCalculator.calculate(None, 1000, 500)
assert cost.total == 0.0 # gracefully handles missing pricing
class TestUsageRecord:
def test_create_record(self):
record = UsageRecord(
model_name="deepseek-v4-flash",
provider="deepseek",
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
currency="CNY",
cost=0.0002,
)
assert record.total_tokens == 150
class TestBudgetController:
@pytest.fixture
def controller(self):
return BudgetController(
daily_budget_cny=10.0,
monthly_budget_cny=200.0,
)
def test_within_budget(self, controller):
controller.add_cost_cny(1.0)
assert controller.allow_request()
def test_exceed_daily_budget(self, controller):
controller.add_cost_cny(11.0)
assert not controller.allow_request()
def test_reset_daily(self):
controller = BudgetController(daily_budget_cny=10.0, monthly_budget_cny=200.0)
controller.add_cost_cny(11.0)
assert not controller.allow_request()
# Simulate next day
controller.reset_daily()
assert controller.allow_request()
@@ -0,0 +1,225 @@
from __future__ import annotations
import pytest
from app.ai_provider.config.models import ModelConfig, ProviderConfig
from app.ai_provider.core.registry import (
ModelRegistry,
ProviderRegistry,
RegistryFactory,
RegistryHolder,
)
from app.ai_provider.types.errors import AIProviderError
class TestModelRegistry:
@pytest.fixture
def sample_models(self):
return [
ModelConfig(
name="fast-model",
provider="deepseek",
model="deepseek-v4-flash",
format="openai",
tags=["default"],
weight=100,
),
ModelConfig(
name="pro-model",
provider="deepseek",
model="deepseek-v4-pro",
format="openai",
tags=["fallback"],
weight=50,
),
ModelConfig(
name="vision-model",
provider="anthropic",
model="claude-sonnet-4-6",
format="anthropic",
supports_vision=True,
),
]
def test_get_model(self, sample_models):
reg = ModelRegistry(sample_models)
model = reg.get("fast-model")
assert model.name == "fast-model"
def test_get_nonexistent(self, sample_models):
reg = ModelRegistry(sample_models)
with pytest.raises(AIProviderError):
reg.get("nonexistent")
def test_default_model(self, sample_models):
reg = ModelRegistry(sample_models)
assert reg.get_default_name() == "fast-model"
def test_fallback_model(self, sample_models):
reg = ModelRegistry(sample_models)
assert reg.get_fallback_name() == "pro-model"
def test_resolve_by_alias(self, sample_models):
reg = ModelRegistry(sample_models, aliases={"deepseek:deepseek-v4-flash": "fast-model"})
model = reg.resolve("deepseek:deepseek-v4-flash")
assert model.name == "fast-model"
def test_resolve_by_logical_name(self, sample_models):
reg = ModelRegistry(sample_models)
model = reg.resolve("fast-model")
assert model.name == "fast-model"
def test_list_by_capability(self, sample_models):
reg = ModelRegistry(sample_models)
models = reg.list_by_capability("vision")
assert len(models) == 1
assert models[0].name == "vision-model"
def test_resolve_by_capability(self, sample_models):
reg = ModelRegistry(sample_models)
name = reg.resolve_by_capability("vision")
assert name == "vision-model"
def test_weighted_random_resolve(self, sample_models):
reg = ModelRegistry(sample_models)
name = reg.resolve()
assert name.name == "fast-model"
def test_contains_returns_true_for_registered(self, sample_models):
reg = ModelRegistry(sample_models)
assert reg.contains("fast-model") is True
def test_contains_returns_false_for_missing(self, sample_models):
reg = ModelRegistry(sample_models)
assert reg.contains("phantom-model") is False
def test_list_all_returns_all_models(self, sample_models):
reg = ModelRegistry(sample_models)
assert len(reg.list_all()) == 3
class TestProviderRegistry:
@pytest.fixture
def sample_providers(self):
return {
"deepseek": ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
),
"anthropic": ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
),
}
def test_get_provider(self, sample_providers):
reg = ProviderRegistry(sample_providers)
prov = reg.get("deepseek")
assert prov.format == "openai"
def test_get_nonexistent(self, sample_providers):
reg = ProviderRegistry(sample_providers)
with pytest.raises(AIProviderError):
reg.get("nonexistent")
def test_list_all(self, sample_providers):
reg = ProviderRegistry(sample_providers)
provs = reg.list_all()
assert set(provs.keys()) == {"deepseek", "anthropic"}
class TestRegistryFactory:
def test_from_configs(self, tmp_path):
models_yaml = tmp_path / "models.yaml"
models_yaml.write_text(
"""
version: 1
models:
- name: test-model
provider: deepseek
model: deepseek-v4-flash
format: openai
"""
)
providers_yaml = tmp_path / "providers.yaml"
providers_yaml.write_text(
"""
version: 1
providers:
deepseek:
format: openai
base_url_env: DEEPSEEK_BASE_URL
api_key_env: DEEPSEEK_API_KEY
"""
)
from app.ai_provider.config.loader import load_models_config, load_providers_config
models = load_models_config(models_yaml)
providers = load_providers_config(providers_yaml)
registry = RegistryFactory.create(models, providers)
assert registry.model_registry.get("test-model") is not None
assert registry.provider_registry.get("deepseek") is not None
def test_create_with_aliases(self, tmp_path):
models_yaml = tmp_path / "models.yaml"
models_yaml.write_text(
"""
version: 1
models:
- name: fast-model
provider: deepseek
model: deepseek-v4-flash
format: openai
tags: ["default"]
"""
)
providers_yaml = tmp_path / "providers.yaml"
providers_yaml.write_text(
"""
version: 1
providers:
deepseek:
format: openai
base_url_env: DEEPSEEK_BASE_URL
api_key_env: DEEPSEEK_API_KEY
"""
)
from app.ai_provider.config.loader import load_models_config, load_providers_config
models = load_models_config(models_yaml)
providers = load_providers_config(providers_yaml)
registry = RegistryFactory.create(
models, providers, aliases={"deepseek:deepseek-v4-flash": "fast-model"}
)
assert registry.model_registry.resolve("deepseek:deepseek-v4-flash").name == "fast-model"
class TestRegistryHolder:
def test_holds_both_registries(self):
model_reg = ModelRegistry(
[
ModelConfig(
name="m1",
provider="deepseek",
model="deepseek-v4-flash",
format="openai",
)
]
)
prov_reg = ProviderRegistry(
{
"deepseek": ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
)
}
)
holder = RegistryHolder(model_reg, prov_reg)
assert holder.model_registry.get("m1") is not None
assert holder.provider_registry.get("deepseek") is not None
@@ -0,0 +1,109 @@
# backend/tests/ai_provider/core/test_resilience.py
import asyncio
import pytest
from app.ai_provider.core.resilience import (
CircuitBreaker,
CircuitBreakerState,
DowngradeAction,
DowngradeChain,
ResilienceFactory,
RetryStrategy,
)
class TestCircuitBreaker:
@pytest.fixture
def breaker(self):
return CircuitBreaker(
name="test-breaker",
failure_threshold=3,
cooldown_seconds=1.0,
)
def test_initial_state(self, breaker):
assert breaker.state == CircuitBreakerState.CLOSED
def test_trip_after_threshold(self, breaker):
breaker.record_failure()
assert breaker.state == CircuitBreakerState.CLOSED
breaker.record_failure()
assert breaker.state == CircuitBreakerState.CLOSED
breaker.record_failure()
assert breaker.state == CircuitBreakerState.OPEN
def test_reset_on_success(self, breaker):
breaker.record_failure()
breaker.record_failure()
breaker.record_success()
assert breaker.state == CircuitBreakerState.CLOSED
def test_half_open_after_cooldown(self):
breaker = CircuitBreaker(name="test", failure_threshold=1, cooldown_seconds=0.01)
breaker.record_failure()
assert breaker.state == CircuitBreakerState.OPEN
asyncio.run(asyncio.sleep(0.02))
assert breaker.state == CircuitBreakerState.HALF_OPEN
def test_trip_from_half_open(self):
breaker = CircuitBreaker(name="test", failure_threshold=1, cooldown_seconds=0.01)
breaker.record_failure()
asyncio.run(asyncio.sleep(0.02))
assert breaker.state == CircuitBreakerState.HALF_OPEN
breaker.record_failure()
assert breaker.state == CircuitBreakerState.OPEN
class TestRetryStrategy:
@pytest.fixture
def strategy(self):
return RetryStrategy(max_retries=3, base_delay=0.01, max_delay=0.1)
def test_should_retry(self, strategy):
assert strategy.should_retry(0)
assert strategy.should_retry(1)
assert strategy.should_retry(2)
assert not strategy.should_retry(3)
def test_delay_exponential(self, strategy):
d0 = strategy.get_delay(0)
d1 = strategy.get_delay(1)
d2 = strategy.get_delay(2)
assert d1 > d0
assert d2 > d1
class TestDowngradeChain:
def test_fallback_chain(self):
chain = DowngradeChain(
[
DowngradeAction(type="fallback_endpoint", target="backup-1"),
DowngradeAction(type="fallback_endpoint", target="backup-2"),
DowngradeAction(type="fallback_model", target="cheap-model"),
]
)
assert chain.next_action() == ("fallback_endpoint", "backup-1")
chain.advance()
assert chain.next_action() == ("fallback_endpoint", "backup-2")
chain.advance()
assert chain.next_action() == ("fallback_model", "cheap-model")
chain.advance()
assert chain.is_exhausted()
class TestResilienceFactory:
def test_create_breaker(self):
breaker = ResilienceFactory.create_circuit_breaker(
name="test",
failure_threshold=5,
cooldown_seconds=30,
)
assert breaker.failure_threshold == 5
def test_create_retry(self):
strategy = ResilienceFactory.create_retry_strategy(
max_retries=3,
base_delay=1.0,
)
assert strategy.max_retries == 3