初始化提交
This commit is contained in:
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
|
||||
Reference in new issue
Block a user