初始化提交

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

+17
View File
@@ -0,0 +1,17 @@
from app.ai_provider.core.billing import PricingFactory, PricingInfo, PricingRegistry
from app.ai_provider.core.registry import (
ModelRegistry,
ProviderRegistry,
RegistryFactory,
RegistryHolder,
)
__all__ = [
"ModelRegistry",
"ProviderRegistry",
"RegistryFactory",
"RegistryHolder",
"PricingInfo",
"PricingRegistry",
"PricingFactory",
]
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from pydantic import BaseModel
from app.ai_provider.types.chat import TokenUsage
@dataclass
class CostBreakdown:
input_cost: float = 0.0
output_cost: float = 0.0
total: float = 0.0
currency: str = "CNY"
class CostCalculator:
"""根据价格信息和 token 数量计算成本。
保持厂商原始单位 —— 不做跨币种换算。
- 普通 input token 按 ``input_per_1m`` 计费。
- 缓存命中的 prompt token 优先按 ``input_cache_read_per_1m`` 计费
(若有配置),剩余按普通 input 费率。
- 若未配置 cache 费率,所有 prompt token 统一按 input_per_1m 计费。
"""
@staticmethod
def calculate(
pricing: PricingInfo | None,
prompt_tokens: int,
completion_tokens: int,
prompt_tokens_cached: int = 0,
) -> CostBreakdown:
if pricing is None:
return CostBreakdown(total=0.0)
# 缓存 token:优先走缓存费率
cached = min(prompt_tokens_cached, prompt_tokens)
uncached = prompt_tokens - cached
input_cost = 0.0
if uncached > 0 and pricing.input_per_1m is not None:
input_cost += (uncached / 1_000_000) * pricing.input_per_1m
if cached > 0 and pricing.input_cache_read_per_1m is not None:
input_cost += (cached / 1_000_000) * pricing.input_cache_read_per_1m
elif cached > 0 and pricing.input_per_1m is not None:
# 无缓存费率时统一按普通 input 计
input_cost += (cached / 1_000_000) * pricing.input_per_1m
output_cost = 0.0
if pricing.output_per_1m is not None and completion_tokens > 0:
output_cost = (completion_tokens / 1_000_000) * pricing.output_per_1m
return CostBreakdown(
input_cost=round(input_cost, 8),
output_cost=round(output_cost, 8),
total=round(input_cost + output_cost, 8),
currency=pricing.currency,
)
@dataclass
class UsageRecord:
model_name: str
provider: str
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
currency: str = "CNY"
cost: float = 0.0
timestamp: datetime = field(default_factory=datetime.now)
endpoint_name: str | None = None
status: str = "success"
error_message: str | None = None
class BillingTracker:
"""记录用量并按模型计算成本。"""
def __init__(self) -> None:
self._records: list[UsageRecord] = []
def record(
self,
model_name: str,
provider: str,
usage: TokenUsage | None,
pricing: PricingInfo | None = None,
endpoint_name: str | None = None,
status: str = "success",
error_message: str | None = None,
) -> UsageRecord:
prompt_tokens = usage.prompt_tokens if usage else 0
completion_tokens = usage.completion_tokens if usage else 0
total = usage.total_tokens if usage else 0
cached = usage.prompt_tokens_cached if usage else 0
cost_info = CostCalculator.calculate(pricing, prompt_tokens, completion_tokens, cached)
record = UsageRecord(
model_name=model_name,
provider=provider,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total,
currency=cost_info.currency,
cost=cost_info.total,
endpoint_name=endpoint_name,
status=status,
error_message=error_message,
)
self._records.append(record)
return record
def get_records(
self,
since: datetime | None = None,
model_name: str | None = None,
) -> list[UsageRecord]:
records = self._records
if since:
records = [r for r in records if r.timestamp >= since]
if model_name:
records = [r for r in records if r.model_name == model_name]
return records
def total_cost(self, currency: str | None = None) -> float:
if currency:
return sum(r.cost for r in self._records if r.currency == currency)
return sum(r.cost for r in self._records)
class BudgetController:
"""强制执行每日和每月预算限制。"""
def __init__(
self,
daily_budget_cny: float = 0.0,
monthly_budget_cny: float = 0.0,
) -> None:
self.daily_budget_cny = daily_budget_cny
self.monthly_budget_cny = monthly_budget_cny
self._daily_cost_cny: float = 0.0
self._monthly_cost_cny: float = 0.0
self._current_date: date = date.today()
def add_cost_cny(self, cost: float) -> None:
self._daily_cost_cny += cost
self._monthly_cost_cny += cost
def allow_request(self) -> bool:
return not (
(self.daily_budget_cny > 0 and self._daily_cost_cny >= self.daily_budget_cny)
or (self.monthly_budget_cny > 0 and self._monthly_cost_cny >= self.monthly_budget_cny)
)
def reset_daily(self) -> None:
self._daily_cost_cny = 0.0
self._current_date = date.today()
def reset_monthly(self) -> None:
self._monthly_cost_cny = 0.0
class BillingFactory:
"""计费组件工厂。"""
@staticmethod
def create_tracker() -> BillingTracker:
return BillingTracker()
@staticmethod
def create_budget_controller(
daily_budget_cny: float = 0.0,
monthly_budget_cny: float = 0.0,
) -> BudgetController:
return BudgetController(
daily_budget_cny=daily_budget_cny,
monthly_budget_cny=monthly_budget_cny,
)
# ── 定价信息 ──────────────────────────────────────────────────────────
class PricingInfo(BaseModel):
"""模型定价信息。保持厂商原始单位,不转换不混合。
各厂商按各自币种/单位记录,展示时原生显示。
"""
currency: str = "CNY"
input_per_1m: float | None = None
output_per_1m: float | None = None
input_cache_read_per_1m: float | None = None
class PricingRegistry:
"""模型定价注册表。以模型逻辑名为键查找定价。
与 ModelRegistry 解耦 —— 改价不影响模型注册,无定价的模型
也可以正常使用(不计费)。
"""
def __init__(self, pricing_map: dict[str, PricingInfo] | None = None) -> None:
self._pricing: dict[str, PricingInfo] = dict(pricing_map or {})
def get(self, model_name: str) -> PricingInfo | None:
"""获取指定模型的定价信息。无定价时返回 None。"""
return self._pricing.get(model_name)
def list_all(self) -> dict[str, PricingInfo]:
"""返回全部定价数据的副本。"""
return dict(self._pricing)
class PricingFactory:
"""定价注册表工厂。"""
@staticmethod
def from_dict(data: dict[str, dict]) -> PricingRegistry:
"""从 YAML 解析后的 dict 构建 PricingRegistry。
YAML 按厂商嵌套组织(自文档化),工厂将其摊平为按模型逻辑名索引:
```yaml
pricing:
deepseek:
deepseek-v4-flash:
currency: CNY
input_per_1m: 1.0
anthropic:
claude-sonnet:
currency: USD
input_per_1m: 3.0
```
"""
pricing_map: dict[str, PricingInfo] = {}
for _provider_name, models_data in data.items():
if not isinstance(models_data, dict):
continue
for model_name, raw in models_data.items():
if raw is None:
continue
# 跳过所有字段均为 null 的条目(如 mimo-v2.5-asr)
values = [v for v in raw.values() if not isinstance(v, bool)]
if all(v is None for v in values):
continue
pricing_map[model_name] = PricingInfo(**raw)
return PricingRegistry(pricing_map)
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
import random
from app.ai_provider.config.models import ModelConfig, ProviderConfig
from app.ai_provider.core.billing import PricingFactory, PricingRegistry
from app.ai_provider.types.errors import AIProviderError
class ModelRegistry:
"""内存模型索引。支持逻辑名和别名查找。"""
def __init__(
self,
models: list[ModelConfig],
aliases: dict[str, str] | None = None,
) -> None:
self._models: dict[str, ModelConfig] = {}
self._aliases: dict[str, str] = dict(aliases or {})
self._default: str | None = None
self._fallback: str | None = None
self._capability_index: dict[str, list[ModelConfig]] = {}
for m in models:
self._models[m.name] = m
if "default" in m.tags and self._default is None:
self._default = m.name
if "fallback" in m.tags and self._fallback is None:
self._fallback = m.name
caps = m.resolved_capabilities()
for cap, supported in caps.items():
if supported:
self._capability_index.setdefault(cap, []).append(m)
# 索引 tags(如 tts / asr / voice_clone)——这些不在
# resolved_capabilities() 中,但 AudioService 等模块依赖它们查找模型。
for tag in m.tags:
self._capability_index.setdefault(tag, []).append(m)
if self._default is None and models:
self._default = models[0].name
def get(self, name: str) -> ModelConfig:
model = self._models.get(name)
if model is None:
raise AIProviderError(
message=f"Model '{name}' not found in registry",
code="MODEL_NOT_FOUND",
status_code=404,
)
return model
def resolve(self, name_or_alias: str | None = None) -> ModelConfig:
"""解析逻辑名或别名为 ModelConfig。
无参数时返回默认模型。
"""
if name_or_alias is None:
return self._models[self.get_default_name()]
if name_or_alias in self._models:
return self._models[name_or_alias]
if name_or_alias in self._aliases:
logical = self._aliases[name_or_alias]
return self._models[logical]
raise AIProviderError(
message=f"Model or alias '{name_or_alias}' not found",
code="MODEL_NOT_FOUND",
status_code=404,
)
def resolve_name(self, name_or_alias: str) -> str:
"""解析为规范逻辑名(不返回完整配置)。"""
if name_or_alias in self._models:
return name_or_alias
if name_or_alias in self._aliases:
return self._aliases[name_or_alias]
raise AIProviderError(
message=f"Model or alias '{name_or_alias}' not found",
code="MODEL_NOT_FOUND",
status_code=404,
)
def get_default_name(self) -> str:
if self._default is None:
raise AIProviderError(
message="No default model configured",
code="NO_DEFAULT_MODEL",
status_code=500,
)
return self._default
def get_fallback_name(self) -> str | None:
return self._fallback
def contains(self, name: str) -> bool:
return name in self._models
def list_all(self) -> list[ModelConfig]:
return list(self._models.values())
def list_by_capability(self, capability: str) -> list[ModelConfig]:
return list(self._capability_index.get(capability, []))
def resolve_by_capability(self, capability: str) -> str:
candidates = self._capability_index.get(capability)
if not candidates:
raise AIProviderError(
message=f"No active models with capability '{capability}'",
code="CAPABILITY_NOT_FOUND",
status_code=404,
)
weights = [max(c.weight, 1) for c in candidates]
chosen = random.choices(candidates, weights=weights, k=1)[0]
return chosen.name
class ProviderRegistry:
"""内存提供商配置索引。"""
def __init__(self, providers: dict[str, ProviderConfig]) -> None:
self._providers = dict(providers)
def get(self, name: str) -> ProviderConfig:
prov = self._providers.get(name)
if prov is None:
raise AIProviderError(
message=f"Provider '{name}' not configured",
code="PROVIDER_NOT_FOUND",
status_code=404,
)
return prov
def list_all(self) -> dict[str, ProviderConfig]:
return dict(self._providers)
class RegistryFactory:
"""工厂:从配置列表创建注册表。"""
@staticmethod
def create(
models: list[ModelConfig],
providers: dict[str, ProviderConfig],
aliases: dict[str, str] | None = None,
pricing_data: dict[str, dict] | None = None,
) -> RegistryHolder:
return RegistryHolder(
model_registry=ModelRegistry(models, aliases=aliases),
provider_registry=ProviderRegistry(providers),
pricing_registry=PricingFactory.from_dict(pricing_data or {}),
)
class RegistryHolder:
"""同时持有所有注册表。"""
def __init__(
self,
model_registry: ModelRegistry,
provider_registry: ProviderRegistry,
pricing_registry: PricingRegistry | None = None,
) -> None:
self.model_registry = model_registry
self.provider_registry = provider_registry
self.pricing_registry = pricing_registry or PricingRegistry()
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import time
from enum import Enum
class CircuitBreakerState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
"""标准断路器,冷却后自动半开。"""
def __init__(
self,
name: str,
failure_threshold: int = 5,
cooldown_seconds: float = 30.0,
) -> None:
self.name = name
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self._state = CircuitBreakerState.CLOSED
self._failure_count = 0
self._last_failure_time: float = 0.0
@property
def state(self) -> CircuitBreakerState:
if (
self._state == CircuitBreakerState.OPEN
and time.monotonic() - self._last_failure_time >= self.cooldown_seconds
):
self._state = CircuitBreakerState.HALF_OPEN
return self._state
def record_success(self) -> None:
self._failure_count = 0
self._state = CircuitBreakerState.CLOSED
def record_failure(self) -> None:
self._failure_count += 1
self._last_failure_time = time.monotonic()
if self._failure_count >= self.failure_threshold:
self._state = CircuitBreakerState.OPEN
def allow_request(self) -> bool:
return self.state != CircuitBreakerState.OPEN
class RetryStrategy:
"""指数退避重试。"""
def __init__(
self,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
multiplier: float = 2.0,
) -> None:
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.multiplier = multiplier
def should_retry(self, attempt_index: int) -> bool:
return attempt_index < self.max_retries
def get_delay(self, attempt_index: int) -> float:
delay = self.base_delay * (self.multiplier**attempt_index)
return min(delay, self.max_delay)
class DowngradeAction:
"""降级链中的一个步骤。"""
def __init__(self, type: str, target: str) -> None: # noqa: A002
self.type = type # "fallback_endpoint", "fallback_model", "cache", "degrade_quality"
self.target = target
class DowngradeChain:
"""当模型/端点失败时的降级动作链。"""
def __init__(self, actions: list[DowngradeAction]) -> None:
self._actions = actions
self._index = 0
def next_action(self) -> tuple[str, str] | None:
if self._index >= len(self._actions):
return None
action = self._actions[self._index]
return (action.type, action.target)
def advance(self) -> None:
self._index += 1
def is_exhausted(self) -> bool:
return self._index >= len(self._actions)
def reset(self) -> None:
self._index = 0
class ResilienceFactory:
"""弹性组件工厂。"""
@staticmethod
def create_circuit_breaker(
name: str,
failure_threshold: int = 5,
cooldown_seconds: float = 30.0,
) -> CircuitBreaker:
return CircuitBreaker(
name=name,
failure_threshold=failure_threshold,
cooldown_seconds=cooldown_seconds,
)
@staticmethod
def create_retry_strategy(
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
multiplier: float = 2.0,
) -> RetryStrategy:
return RetryStrategy(
max_retries=max_retries,
base_delay=base_delay,
max_delay=max_delay,
multiplier=multiplier,
)