初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user