refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,57 @@
"""
计费模块
提供配置驱动的计费计算,支持不同厂商的差异化计费模式:
- Claude: input + output + cache_creation + cache_read
- OpenAI: input + output + cache_read (无缓存创建费用)
- 豆包: input + output + cache_read + cache_storage (缓存按时计费)
- 按次计费: per_request
使用方式:
from src.services.billing import BillingCalculator, UsageMapper, StandardizedUsage
# 1. 将原始 usage 映射为标准格式
usage = UsageMapper.map(raw_usage, api_format="openai:chat")
# 2. 使用计费计算器计算费用
calculator = BillingCalculator(template="openai")
result = calculator.calculate(usage, prices)
# 3. 获取费用明细
print(result.total_cost)
print(result.costs) # {"input": 0.01, "output": 0.02, ...}
"""
from src.services.billing.calculator import BillingCalculator, calculate_request_cost
from src.services.billing.models import (
BillingDimension,
BillingUnit,
CostBreakdown,
StandardizedUsage,
)
from src.services.billing.schema import BillingSnapshot, CostResult
from src.services.billing.service import BillingService
from src.services.billing.templates import BILLING_TEMPLATE_REGISTRY, BillingTemplates
from src.services.billing.usage_mapper import UsageMapper, map_usage, map_usage_from_response
__all__ = [
# 数据模型
"BillingDimension",
"BillingUnit",
"CostBreakdown",
"StandardizedUsage",
# 模板
"BillingTemplates",
"BILLING_TEMPLATE_REGISTRY",
# 计算器
"BillingCalculator",
"calculate_request_cost",
# 统一入口
"BillingService",
"BillingSnapshot",
"CostResult",
# 映射器
"UsageMapper",
"map_usage",
"map_usage_from_response",
]

View File

@@ -0,0 +1,130 @@
"""
Billing in-process cache.
This module provides a small TTL cache for billing rule lookups and other
high-read, low-churn billing configuration objects.
Important:
- Keep cached values *session-agnostic*. Avoid caching SQLAlchemy ORM objects
bound to a specific Session; prefer plain dataclasses / dicts.
"""
from __future__ import annotations
import time
from typing import Any
class BillingCache:
"""
Simple TTL + LRU cache.
- TTL: 300s (5 minutes)
- Max entries per cache: 2048 (evict oldest on overflow)
"""
TTL_SECONDS = 300
MAX_ENTRIES = 2048
_rule_cache: dict[str, tuple[Any, float]] = {}
_collector_cache: dict[str, tuple[Any, float]] = {}
_default_rule_cache: dict[str, tuple[Any, float]] = {}
# ----------------------------
# Rule cache
# ----------------------------
@classmethod
def get_rule(cls, cache_key: str) -> Any | None:
return cls._get(cls._rule_cache, cache_key)
@classmethod
def set_rule(cls, cache_key: str, value: Any) -> None:
cls._set(cls._rule_cache, cache_key, value)
# ----------------------------
# Default-rule cache
# ----------------------------
@classmethod
def get_default_rule(cls, cache_key: str) -> Any | None:
return cls._get(cls._default_rule_cache, cache_key)
@classmethod
def set_default_rule(cls, cache_key: str, value: Any) -> None:
cls._set(cls._default_rule_cache, cache_key, value)
# ----------------------------
# Collector cache (reserved)
# ----------------------------
@classmethod
def get_collectors(cls, cache_key: str) -> Any | None:
return cls._get(cls._collector_cache, cache_key)
@classmethod
def set_collectors(cls, cache_key: str, value: Any) -> None:
cls._set(cls._collector_cache, cache_key, value)
# ----------------------------
# Invalidation
# ----------------------------
@classmethod
def invalidate_all(cls) -> None:
cls._rule_cache.clear()
cls._collector_cache.clear()
cls._default_rule_cache.clear()
@classmethod
def invalidate_model(cls, model_name: str) -> None:
"""
Invalidate cache entries referencing a model name.
Note:
- This is best-effort string matching (cache key format must include model_name).
"""
cls._invalidate_by_substring(cls._rule_cache, model_name)
cls._invalidate_by_substring(cls._default_rule_cache, model_name)
# ----------------------------
# Internal helpers
# ----------------------------
@classmethod
def _get(cls, cache: dict[str, tuple[Any, float]], key: str) -> Any | None:
item = cache.get(key)
if item is None:
return None
value, ts = item
if time.time() - ts < cls.TTL_SECONDS:
return value
# expired
try:
del cache[key]
except KeyError:
pass
return None
@classmethod
def _set(cls, cache: dict[str, tuple[Any, float]], key: str, value: Any) -> None:
"""Set with LRU eviction when cache exceeds MAX_ENTRIES."""
now = time.time()
cache[key] = (value, now)
# Evict oldest entries if over limit
if len(cache) > cls.MAX_ENTRIES:
cls._evict_oldest(cache, cls.MAX_ENTRIES // 4)
@classmethod
def _evict_oldest(cls, cache: dict[str, tuple[Any, float]], count: int) -> None:
"""Evict the oldest `count` entries from cache."""
if not cache or count <= 0:
return
# Sort by timestamp (oldest first) and remove
sorted_keys = sorted(cache.keys(), key=lambda k: cache[k][1])
for k in sorted_keys[:count]:
cache.pop(k, None)
@staticmethod
def _invalidate_by_substring(cache: dict[str, tuple[Any, float]], needle: str) -> None:
if not needle:
return
keys = [k for k in cache.keys() if needle in k]
for k in keys:
cache.pop(k, None)

View File

@@ -0,0 +1,339 @@
"""
计费计算器
配置驱动的计费计算,支持:
- 固定价格计费
- 阶梯计费
- 多种计费模板
- 自定义计费维度
"""
from __future__ import annotations
from typing import Any
from src.services.billing.models import (
BillingDimension,
CostBreakdown,
StandardizedUsage,
)
from src.services.billing.templates import (
BillingTemplates,
get_template,
)
class BillingCalculator:
"""
配置驱动的计费计算器
支持多种计费模式:
- 使用预定义模板claude, openai, doubao 等)
- 自定义计费维度
- 阶梯计费
示例:
# 使用模板
calculator = BillingCalculator(template="openai")
# 自定义维度
calculator = BillingCalculator(dimensions=[
BillingDimension(name="input", usage_field="input_tokens", price_field="input_price_per_1m"),
BillingDimension(name="output", usage_field="output_tokens", price_field="output_price_per_1m"),
])
# 计算费用
usage = StandardizedUsage(input_tokens=1000, output_tokens=500)
prices = {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
result = calculator.calculate(usage, prices)
"""
def __init__(
self,
dimensions: list[BillingDimension] | None = None,
template: str | None = None,
):
"""
初始化计费计算器
Args:
dimensions: 自定义计费维度列表(优先级高于模板)
template: 使用预定义模板名称 ("claude", "openai", "doubao", "per_request" 等)
"""
if dimensions:
self.dimensions = dimensions
elif template:
self.dimensions = get_template(template)
else:
# 默认使用 Claude 模板(向后兼容)
self.dimensions = BillingTemplates.CLAUDE_STANDARD
self.template_name = template
def calculate(
self,
usage: StandardizedUsage,
prices: dict[str, float],
tiered_pricing: dict[str, Any] | None = None,
cache_ttl_minutes: int | None = None,
total_input_context: int | None = None,
) -> CostBreakdown:
"""
计算费用
Args:
usage: 标准化的 usage 数据
prices: 价格配置 {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0, ...}
tiered_pricing: 阶梯计费配置(可选)
cache_ttl_minutes: 缓存 TTL 分钟数(用于 TTL 差异化定价)
total_input_context: 总输入上下文(用于阶梯判定,可选)
如果提供,将使用该值进行阶梯判定;否则使用默认计算逻辑
Returns:
费用明细 (CostBreakdown)
"""
result = CostBreakdown()
# 处理阶梯计费
effective_prices = prices.copy()
if tiered_pricing and tiered_pricing.get("tiers"):
tier, tier_index = self._get_tier(usage, tiered_pricing, total_input_context)
if tier:
result.tier_index = tier_index
# 阶梯价格覆盖默认价格
for key, value in tier.items():
if key not in ("up_to", "cache_ttl_pricing") and value is not None:
effective_prices[key] = value
# 处理 TTL 差异化定价
if cache_ttl_minutes is not None:
ttl_price = self._get_cache_read_price_for_ttl(tier, cache_ttl_minutes)
if ttl_price is not None:
effective_prices["cache_read_price_per_1m"] = ttl_price
# 记录使用的价格
result.effective_prices = effective_prices.copy()
# 计算各维度费用
total = 0.0
for dim in self.dimensions:
usage_value = usage.get(dim.usage_field, 0)
price = effective_prices.get(dim.price_field, dim.default_price)
if usage_value and price:
cost = dim.calculate(usage_value, price)
result.costs[dim.name] = cost
total += cost
result.total_cost = total
return result
def _get_tier(
self,
usage: StandardizedUsage,
tiered_pricing: dict[str, Any],
total_input_context: int | None = None,
) -> tuple[dict[str, Any] | None, int | None]:
"""
确定价格阶梯
Args:
usage: usage 数据
tiered_pricing: 阶梯配置 {"tiers": [...]}
total_input_context: 预计算的总输入上下文(可选)
Returns:
(匹配的阶梯配置, 阶梯索引)
"""
tiers = tiered_pricing.get("tiers", [])
if not tiers:
return None, None
# 使用传入的 total_input_context或者默认计算
if total_input_context is None:
total_input_context = self._compute_total_input_context(usage)
for i, tier in enumerate(tiers):
up_to = tier.get("up_to")
# up_to 为 None 表示无上限(最后一个阶梯)
if up_to is None or total_input_context <= up_to:
return tier, i
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
return tiers[-1], len(tiers) - 1
def _compute_total_input_context(self, usage: StandardizedUsage) -> int:
"""
计算总输入上下文(用于阶梯计费判定)
默认: input_tokens + cache_read_tokens
Args:
usage: usage 数据
Returns:
总输入 token 数
"""
return usage.input_tokens + usage.cache_read_tokens
def _get_cache_read_price_for_ttl(
self,
tier: dict[str, Any],
cache_ttl_minutes: int,
) -> float | None:
"""
根据缓存 TTL 获取缓存读取价格
某些厂商(如 Claude对不同 TTL 的缓存有不同定价。
Args:
tier: 当前阶梯配置
cache_ttl_minutes: 缓存时长(分钟)
Returns:
缓存读取价格,如果没有 TTL 差异化配置返回 None
"""
ttl_pricing = tier.get("cache_ttl_pricing")
if not ttl_pricing:
return None
# 找到匹配或最接近的 TTL 价格
for ttl_config in ttl_pricing:
ttl_limit = ttl_config.get("ttl_minutes", 0)
if cache_ttl_minutes <= ttl_limit:
price = ttl_config.get("cache_read_price_per_1m")
return float(price) if price is not None else None
# 超过所有配置的 TTL使用最后一个
if ttl_pricing:
price = ttl_pricing[-1].get("cache_read_price_per_1m")
return float(price) if price is not None else None
return None
@classmethod
def from_config(cls, config: dict[str, Any]) -> BillingCalculator:
"""
从配置创建计费计算器
Config 格式:
{
"template": "claude", # 或 "openai", "doubao", "per_request"
# 或者自定义维度:
"dimensions": [
{"name": "input", "usage_field": "input_tokens", "price_field": "input_price_per_1m"},
...
]
}
Args:
config: 配置字典
Returns:
BillingCalculator 实例
"""
if "dimensions" in config:
dimensions = [BillingDimension.from_dict(d) for d in config["dimensions"]]
return cls(dimensions=dimensions)
return cls(template=config.get("template", "claude"))
def get_dimension_names(self) -> list[str]:
"""获取所有计费维度名称"""
return [dim.name for dim in self.dimensions]
def get_required_price_fields(self) -> list[str]:
"""获取所需的价格字段名称"""
return [dim.price_field for dim in self.dimensions]
def get_required_usage_fields(self) -> list[str]:
"""获取所需的 usage 字段名称"""
return [dim.usage_field for dim in self.dimensions]
def calculate_request_cost(
input_tokens: int,
output_tokens: int,
cache_creation_input_tokens: int,
cache_read_input_tokens: int,
input_price_per_1m: float,
output_price_per_1m: float,
cache_creation_price_per_1m: float | None,
cache_read_price_per_1m: float | None,
price_per_request: float | None,
tiered_pricing: dict[str, Any] | None = None,
cache_ttl_minutes: int | None = None,
total_input_context: int | None = None,
billing_template: str = "claude",
) -> dict[str, Any]:
"""
计算请求成本的便捷函数
封装了 BillingCalculator 的调用逻辑,返回兼容旧格式的字典。
Args:
input_tokens: 输入 token 数
output_tokens: 输出 token 数
cache_creation_input_tokens: 缓存创建 token 数
cache_read_input_tokens: 缓存读取 token 数
input_price_per_1m: 输入价格(每 1M tokens
output_price_per_1m: 输出价格(每 1M tokens
cache_creation_price_per_1m: 缓存创建价格(每 1M tokens
cache_read_price_per_1m: 缓存读取价格(每 1M tokens
price_per_request: 按次计费价格
tiered_pricing: 阶梯计费配置
cache_ttl_minutes: 缓存时长(分钟)
total_input_context: 总输入上下文(用于阶梯判定)
billing_template: 计费模板名称
Returns:
包含各项成本的字典:
{
"input_cost": float,
"output_cost": float,
"cache_creation_cost": float,
"cache_read_cost": float,
"cache_cost": float,
"request_cost": float,
"total_cost": float,
"tier_index": int | None,
}
"""
# 构建标准化 usage
usage = StandardizedUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_tokens=cache_creation_input_tokens,
cache_read_tokens=cache_read_input_tokens,
request_count=1,
)
# 构建价格配置
prices: dict[str, float] = {
"input_price_per_1m": input_price_per_1m,
"output_price_per_1m": output_price_per_1m,
}
if cache_creation_price_per_1m is not None:
prices["cache_creation_price_per_1m"] = cache_creation_price_per_1m
if cache_read_price_per_1m is not None:
prices["cache_read_price_per_1m"] = cache_read_price_per_1m
if price_per_request is not None:
prices["price_per_request"] = price_per_request
# 使用 BillingCalculator 计算
calculator = BillingCalculator(template=billing_template)
result = calculator.calculate(
usage, prices, tiered_pricing, cache_ttl_minutes, total_input_context
)
# 返回兼容旧格式的字典
return {
"input_cost": result.input_cost,
"output_cost": result.output_cost,
"cache_creation_cost": result.cache_creation_cost,
"cache_read_cost": result.cache_read_cost,
"cache_cost": result.cache_cost,
"request_cost": result.request_cost,
"total_cost": result.total_cost,
"tier_index": result.tier_index,
}

View File

@@ -0,0 +1,31 @@
"""
Collector definitions (config-file mode).
Goal:
- Developers define dimension collectors in code, grouped by api_format.
- Adding support for a new api_format should only require adding a new file here.
- No DB seeding required.
Each module should export:
- COLLECTORS: list[dict[str, Any]]
Each dict supports keys (aligned with DimensionCollector):
- api_format: "openai:chat" (canonical family:kind)
- task_type: "chat" | "cli" | "video" | "image" | "audio"
- dimension_name: string
- source_type: "request" | "response" | "metadata" | "computed"
- source_path: string | None
- value_type: "float" | "int" | "string"
- transform_expression: string | None
- default_value: string | None
- priority: int
- is_enabled: bool
"""
from __future__ import annotations
from typing import Any
# This package is discovered dynamically by `src.services.billing.presets`.
COLLECTORS: list[dict[str, Any]] = []

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Any
# Anthropic / Claude messages
COLLECTORS: list[dict[str, Any]] = [
{
"api_format": "claude:chat",
"task_type": "chat",
"dimension_name": "input_tokens",
"source_type": "response",
"source_path": "usage.input_tokens",
"value_type": "int",
"priority": 10,
"is_enabled": True,
},
{
"api_format": "claude:chat",
"task_type": "chat",
"dimension_name": "output_tokens",
"source_type": "response",
"source_path": "usage.output_tokens",
"value_type": "int",
"priority": 10,
"is_enabled": True,
},
]

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Any
# Gemini generateContent
COLLECTORS: list[dict[str, Any]] = [
{
"api_format": "gemini:chat",
"task_type": "chat",
"dimension_name": "input_tokens",
"source_type": "response",
"source_path": "usageMetadata.promptTokenCount",
"value_type": "int",
"priority": 10,
"is_enabled": True,
},
{
"api_format": "gemini:chat",
"task_type": "chat",
"dimension_name": "output_tokens",
"source_type": "response",
"source_path": "usageMetadata.candidatesTokenCount",
"value_type": "int",
"priority": 10,
"is_enabled": True,
},
]

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Any
# OpenAI chat completions
COLLECTORS: list[dict[str, Any]] = [
{
"api_format": "openai:chat",
"task_type": "chat",
"dimension_name": "input_tokens",
"source_type": "response",
"source_path": "usage.prompt_tokens",
"value_type": "int",
"priority": 10,
"is_enabled": True,
},
{
"api_format": "openai:chat",
"task_type": "chat",
"dimension_name": "output_tokens",
"source_type": "response",
"source_path": "usage.completion_tokens",
"value_type": "int",
"priority": 10,
"is_enabled": True,
},
]

View File

@@ -0,0 +1,116 @@
from __future__ import annotations
from typing import Any
# Async video finalize flow: extra dims from metadata (base_dimensions already provided by caller).
#
# Note:
# - DimensionCollectorService has a "video -> base api_format fallback" that may query
# base api_format collectors when api_format is "openai:video"/"gemini:video" etc.
COLLECTORS: list[dict[str, Any]] = [
# Prefer size as "resolution key" (e.g. 1024x1792), fallback to resolution label (e.g. 720p/4k).
{
"api_format": "openai:chat",
"task_type": "video",
"dimension_name": "video_resolution_key",
"source_type": "metadata",
"source_path": "task.size",
"value_type": "string",
"priority": 10,
"is_enabled": True,
},
{
"api_format": "openai:chat",
"task_type": "video",
"dimension_name": "video_resolution_key",
"source_type": "metadata",
"source_path": "task.resolution",
"value_type": "string",
"priority": 0,
"is_enabled": True,
},
{
"api_format": "openai:chat",
"task_type": "video",
"dimension_name": "video_size_bytes",
"source_type": "metadata",
"source_path": "task.video_size_bytes",
"value_type": "int",
"priority": 0,
"is_enabled": True,
},
# 实际视频时长(秒),优先使用从 provider 响应中提取的实际时长
{
"api_format": "openai:chat",
"task_type": "video",
"dimension_name": "video_duration_seconds",
"source_type": "metadata",
"source_path": "task.video_duration_seconds",
"value_type": "float",
"priority": 10,
"is_enabled": True,
},
# 回退到请求的 duration_seconds如果没有实际时长
{
"api_format": "openai:chat",
"task_type": "video",
"dimension_name": "video_duration_seconds",
"source_type": "metadata",
"source_path": "task.duration_seconds",
"value_type": "int",
"priority": 0,
"is_enabled": True,
},
{
"api_format": "gemini:chat",
"task_type": "video",
"dimension_name": "video_resolution_key",
"source_type": "metadata",
"source_path": "task.size",
"value_type": "string",
"priority": 10,
"is_enabled": True,
},
{
"api_format": "gemini:chat",
"task_type": "video",
"dimension_name": "video_resolution_key",
"source_type": "metadata",
"source_path": "task.resolution",
"value_type": "string",
"priority": 0,
"is_enabled": True,
},
{
"api_format": "gemini:chat",
"task_type": "video",
"dimension_name": "video_size_bytes",
"source_type": "metadata",
"source_path": "task.video_size_bytes",
"value_type": "int",
"priority": 0,
"is_enabled": True,
},
# 实际视频时长(秒),优先使用从 provider 响应中提取的实际时长
{
"api_format": "gemini:chat",
"task_type": "video",
"dimension_name": "video_duration_seconds",
"source_type": "metadata",
"source_path": "task.video_duration_seconds",
"value_type": "float",
"priority": 10,
"is_enabled": True,
},
# 回退到请求的 duration_seconds如果没有实际时长
{
"api_format": "gemini:chat",
"task_type": "video",
"dimension_name": "video_duration_seconds",
"source_type": "metadata",
"source_path": "task.duration_seconds",
"value_type": "int",
"priority": 0,
"is_enabled": True,
},
]

View File

@@ -0,0 +1,266 @@
"""
Default billing rules (runtime-generated).
Goal:
- Keep backward compatibility with existing GlobalModel/Model pricing config
(tiered_pricing + price_per_request)
- Provide a virtual BillingRule when no explicit BillingRule is configured in DB.
This module MUST NOT write to DB.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from src.models.database import GlobalModel, Model
@dataclass(frozen=True)
class VirtualBillingRule:
"""A rule object compatible with BillingRule fields, generated at runtime."""
id: str
name: str
task_type: str
expression: str
variables: dict[str, Any]
dimension_mappings: dict[str, Any]
is_virtual: bool = True
def _as_float(value: Any, *, default: float = 0.0) -> float:
try:
if value is None:
return default
# avoid bool being treated as int
if isinstance(value, bool):
return default
return float(value)
except Exception:
return default
def _get_tiers(tiered_pricing: dict | None) -> list[dict[str, Any]]:
if not isinstance(tiered_pricing, dict):
return []
tiers = tiered_pricing.get("tiers")
if not isinstance(tiers, list):
return []
return [t for t in tiers if isinstance(t, dict)]
class DefaultBillingRuleGenerator:
"""
Build a virtual BillingRule from GlobalModel/Model pricing fields.
Pricing sources:
- Tiered pricing: Model.tiered_pricing overrides GlobalModel.default_tiered_pricing
- Per-request price: Model.price_per_request overrides GlobalModel.default_price_per_request
"""
@staticmethod
def generate_for_model(
*,
global_model: GlobalModel,
model: Model | None = None,
task_type: str = "chat",
) -> VirtualBillingRule:
tiered_pricing = (
model.get_effective_tiered_pricing()
if model is not None
else global_model.default_tiered_pricing
)
tiers = _get_tiers(tiered_pricing)
# Base prices (used as defaults if tier_key missing)
first_tier = tiers[0] if tiers else {}
base_input_price = _as_float(first_tier.get("input_price_per_1m"), default=0.0)
base_output_price = _as_float(first_tier.get("output_price_per_1m"), default=0.0)
# Cache prices: keep legacy behavior (derive from input price when missing)
base_cache_creation_price = _as_float(
first_tier.get("cache_creation_price_per_1m"),
default=base_input_price * 1.25,
)
base_cache_read_price = _as_float(
first_tier.get("cache_read_price_per_1m"),
default=base_input_price * 0.1,
)
# Per-request price
if model is not None:
request_price = model.get_effective_price_per_request()
else:
request_price = global_model.default_price_per_request
base_request_price = _as_float(request_price, default=0.0)
# Expression uses per-1M prices and token counts.
# v2-friendly expression: total cost is sum of component costs.
expression = (
"input_cost + output_cost + cache_creation_cost + cache_read_cost + request_cost"
)
variables: dict[str, Any] = {
"input_price_per_1m": base_input_price,
"output_price_per_1m": base_output_price,
"cache_creation_price_per_1m": base_cache_creation_price,
"cache_read_price_per_1m": base_cache_read_price,
"price_per_request": base_request_price,
}
dimension_mappings: dict[str, Any] = {
# Raw dimensions
"input_tokens": {
"source": "dimension",
"key": "input_tokens",
"required": False,
"allow_zero": True,
"default": 0,
},
"output_tokens": {
"source": "dimension",
"key": "output_tokens",
"required": False,
"allow_zero": True,
"default": 0,
},
"cache_creation_tokens": {
"source": "dimension",
"key": "cache_creation_tokens",
"required": False,
"allow_zero": True,
"default": 0,
},
"cache_read_tokens": {
"source": "dimension",
"key": "cache_read_tokens",
"required": False,
"allow_zero": True,
"default": 0,
},
"request_count": {
"source": "dimension",
"key": "request_count",
"required": False,
"allow_zero": True,
"default": 1,
},
# Component costs (computed)
"input_cost": {
"source": "computed",
"expression": "input_tokens * input_price_per_1m / 1000000",
"required": False,
"default": 0,
},
"output_cost": {
"source": "computed",
"expression": "output_tokens * output_price_per_1m / 1000000",
"required": False,
"default": 0,
},
"cache_creation_cost": {
"source": "computed",
"expression": "cache_creation_tokens * cache_creation_price_per_1m / 1000000",
"required": False,
"default": 0,
},
"cache_read_cost": {
"source": "computed",
"expression": "cache_read_tokens * cache_read_price_per_1m / 1000000",
"required": False,
"default": 0,
},
"request_cost": {
"source": "computed",
"expression": "request_count * price_per_request",
"required": False,
"default": 0,
},
}
# Tiered pricing: resolve effective prices based on total_input_context
# (legacy definition: input_tokens + cache_read_tokens)
if tiers:
# Build tier lists with legacy cache fallbacks per tier.
def _tier_value(
t: dict[str, Any], key: str, *, default_multiplier: float | None = None
) -> float:
if key in t and t.get(key) is not None:
return _as_float(t.get(key), default=0.0)
if default_multiplier is not None:
input_price = _as_float(t.get("input_price_per_1m"), default=0.0)
return input_price * default_multiplier
return 0.0
def _tiers_for(
key: str,
*,
default_multiplier: float | None = None,
include_cache_ttl_pricing: bool = False,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for t in tiers:
item: dict[str, Any] = {
"up_to": t.get("up_to"),
"value": _tier_value(t, key, default_multiplier=default_multiplier),
}
if include_cache_ttl_pricing and isinstance(t.get("cache_ttl_pricing"), list):
# Preserve raw ttl pricing list for FormulaEngine tiered resolver.
item["cache_ttl_pricing"] = t.get("cache_ttl_pricing")
out.append(item)
return out
tier_key = "total_input_context"
dimension_mappings["input_price_per_1m"] = {
"source": "tiered",
"tier_key": tier_key,
"allow_zero": True,
"tiers": _tiers_for("input_price_per_1m"),
"default": base_input_price,
}
dimension_mappings["output_price_per_1m"] = {
"source": "tiered",
"tier_key": tier_key,
"allow_zero": True,
"tiers": _tiers_for("output_price_per_1m"),
"default": base_output_price,
}
dimension_mappings["cache_creation_price_per_1m"] = {
"source": "tiered",
"tier_key": tier_key,
"allow_zero": True,
# TTL override supported when dims include cache_ttl_minutes
"ttl_key": "cache_ttl_minutes",
"ttl_value_key": "cache_creation_price_per_1m",
"tiers": _tiers_for(
"cache_creation_price_per_1m",
default_multiplier=1.25,
include_cache_ttl_pricing=True,
),
"default": base_cache_creation_price,
}
dimension_mappings["cache_read_price_per_1m"] = {
"source": "tiered",
"tier_key": tier_key,
"allow_zero": True,
# TTL override supported when dims include cache_ttl_minutes
"ttl_key": "cache_ttl_minutes",
"ttl_value_key": "cache_read_price_per_1m",
"tiers": _tiers_for(
"cache_read_price_per_1m",
default_multiplier=0.1,
include_cache_ttl_pricing=True,
),
"default": base_cache_read_price,
}
return VirtualBillingRule(
id="__default__",
name=f"Default rule for {getattr(global_model, 'name', 'unknown')}",
task_type=task_type,
expression=expression,
variables=variables,
dimension_mappings=dimension_mappings,
)

View File

@@ -0,0 +1,465 @@
"""
DimensionCollector 运行时维度采集
特性(与 .plans/humming-seeking-marble.md 对齐):
- (api_format, task_type) 作用域
- 同一维度支持多条 collectorpriority 回退)
- 支持 transform_expression与 billing expression 共用 AST 安全规范)
- computed 维度支持依赖拓扑排序,并对环依赖做保护性降级
"""
from __future__ import annotations
import re
from collections import deque
from dataclasses import dataclass
from typing import Any, Literal, Protocol
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import DimensionCollector
from src.services.billing.cache import BillingCache
from src.services.billing.formula_engine import (
ExpressionEvaluationError,
SafeExpressionEvaluator,
UnsafeExpressionError,
extract_variable_names,
)
from src.services.billing.presets import CORE_PRESET_PACK
ValueType = Literal["float", "int", "string"]
class CollectorLike(Protocol):
api_format: str
task_type: str
dimension_name: str
source_type: str
source_path: str | None
value_type: str
transform_expression: str | None
default_value: str | None
priority: int
is_enabled: bool
def _normalize_api_format(api_format: str | None) -> str:
if not api_format:
return ""
from src.core.api_format.signature import normalize_signature_key
return normalize_signature_key(api_format)
def _normalize_task_type(task_type: str | None) -> str:
return (task_type or "").lower()
def _get_nested_value(data: Any, path: str) -> Any:
"""
简单 JSON path
- a.b.c
- 列表索引用数字items.0.id
"""
if data is None or path is None or path == "":
return None
value: Any = data
for key in path.split("."):
if isinstance(value, dict):
value = value.get(key)
elif isinstance(value, list):
if not key.isdigit():
return None
idx = int(key)
if idx < 0 or idx >= len(value):
return None
value = value[idx]
else:
return None
if value is None:
return None
return value
def _cast_value(value: Any, value_type: ValueType) -> Any:
if value_type == "string":
return "" if value is None else str(value)
if value_type == "int":
if value is None:
return 0
if isinstance(value, bool):
raise ValueError("bool is not a valid int dimension value")
return int(float(value))
# float
if value is None:
return 0.0
if isinstance(value, bool):
raise ValueError("bool is not a valid float dimension value")
return float(value)
def _type_default(value_type: ValueType) -> Any:
return "" if value_type == "string" else (0 if value_type == "int" else 0.0)
_WXH_PATTERN = re.compile(r"^(\d+)x(\d+)$")
def _normalize_resolution_key(raw: str) -> str:
"""
Normalize resolution key:
- lowercase, remove spaces, × → x
- For WxH format, sort dimensions so smaller comes first (1080x720 → 720x1080)
"""
k = (raw or "").strip().lower().replace(" ", "").replace("×", "x")
match = _WXH_PATTERN.match(k)
if match:
a, b = int(match.group(1)), int(match.group(2))
k = f"{a}x{b}" if a <= b else f"{b}x{a}"
return k
@dataclass(frozen=True)
class DimensionCollectInput:
request: dict[str, Any] | None = None
response: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
base_dimensions: dict[str, Any] | None = None
class DimensionCollectorRuntime:
"""纯运行时逻辑(不依赖 DB便于测试与复用。"""
def __init__(self) -> None:
self._evaluator = SafeExpressionEvaluator()
def collect(
self,
*,
collectors: list[CollectorLike],
inp: DimensionCollectInput,
) -> dict[str, Any]:
dims: dict[str, Any] = dict(inp.base_dimensions or {})
# dimension_name -> collectors (priority desc)
grouped: dict[str, list[CollectorLike]] = {}
for c in collectors:
grouped.setdefault(c.dimension_name, []).append(c)
for name in grouped:
grouped[name].sort(key=lambda x: (x.priority or 0), reverse=True)
# 1) 先收集非 computed
computed_only: set[str] = set()
for dim_name, cs in grouped.items():
non_computed = [c for c in cs if (c.source_type or "").lower() != "computed"]
if not non_computed:
computed_only.add(dim_name)
continue
value = self._resolve_dimension(dim_name, non_computed, dims, inp)
dims[dim_name] = value
# 2) computed 维度拓扑排序
ordered = self._toposort_computed(grouped, computed_only)
for dim_name in ordered:
cs = [
c for c in grouped.get(dim_name, []) if (c.source_type or "").lower() == "computed"
]
if not cs:
continue
cs.sort(key=lambda x: (x.priority or 0), reverse=True)
value = self._resolve_computed_dimension(dim_name, cs, dims)
dims[dim_name] = value
return dims
def _resolve_dimension(
self,
dim_name: str,
collectors: list[CollectorLike],
dims: dict[str, Any],
inp: DimensionCollectInput,
) -> Any:
fallback_default: str | None = None
fallback_value_type: ValueType | None = None
value_type: ValueType = (
(collectors[0].value_type or "float").lower() # type: ignore[assignment]
if collectors
else "float"
)
for c in collectors:
value_type = (c.value_type or "float").lower() # type: ignore[assignment]
if c.default_value is not None and fallback_default is None:
fallback_default = c.default_value
fallback_value_type = value_type
src = (c.source_type or "").lower()
path = c.source_path or ""
if src == "request":
raw = _get_nested_value(inp.request or {}, path)
elif src == "response":
raw = _get_nested_value(inp.response or {}, path)
elif src == "metadata":
raw = _get_nested_value(inp.metadata or {}, path)
else:
# 未知 source跳过尝试
continue
if raw is None:
continue
try:
value: Any = raw
if c.transform_expression:
# transform_expression 仅允许使用 value
value = self._evaluator.eval_number(c.transform_expression, {"value": value})
casted = _cast_value(value, value_type)
return casted
except (ValueError, UnsafeExpressionError, ExpressionEvaluationError, Exception) as exc:
# 注意:这里选择“不中断,尝试下一优先级”
logger.debug(
"Dimension collector failed (dim={}, id={}): {}",
dim_name,
getattr(c, "id", None),
str(exc),
)
continue
# 兜底default_value仅允许配置一条但这里不依赖 DB 校验)
if fallback_default is not None:
try:
return _cast_value(fallback_default, fallback_value_type or value_type)
except Exception:
return _type_default(fallback_value_type or value_type)
return _type_default(value_type)
def _resolve_computed_dimension(
self,
dim_name: str,
collectors: list[CollectorLike],
dims: dict[str, Any],
) -> Any:
fallback_default: str | None = None
fallback_value_type: ValueType | None = None
value_type: ValueType = (
(collectors[0].value_type or "float").lower() # type: ignore[assignment]
if collectors
else "float"
)
for c in collectors:
value_type = (c.value_type or "float").lower() # type: ignore[assignment]
if c.default_value is not None and fallback_default is None:
fallback_default = c.default_value
fallback_value_type = value_type
expr = c.transform_expression
if not expr:
continue
try:
value = self._evaluator.eval_number(expr, dims)
casted = _cast_value(value, value_type)
return casted
except (ValueError, ExpressionEvaluationError, UnsafeExpressionError, Exception):
continue
if fallback_default is not None:
try:
return _cast_value(fallback_default, fallback_value_type or value_type)
except Exception:
return _type_default(fallback_value_type or value_type)
return _type_default(value_type)
def _toposort_computed(
self,
grouped: dict[str, list[CollectorLike]],
computed_only: set[str],
) -> list[str]:
# 建图dependency -> dim
allowed_func_names = set(self._evaluator.ALLOWED_FUNCS.keys())
deps: dict[str, set[str]] = {d: set() for d in computed_only}
for dim_name in computed_only:
for c in grouped.get(dim_name, []):
if (c.source_type or "").lower() != "computed" or not c.transform_expression:
continue
try:
names = extract_variable_names(c.transform_expression)
except UnsafeExpressionError:
# 配置错误:按无依赖处理,避免阻塞
logger.error(
"Invalid computed transform_expression (dim={}, id={})",
dim_name,
getattr(c, "id", None),
)
names = set()
names.discard("value")
names -= allowed_func_names
# 仅关心依赖的 computed 维度(非 computed 会在前一步收集)
deps[dim_name] |= {n for n in names if n in computed_only and n != dim_name}
# Kahn
in_degree: dict[str, int] = {d: 0 for d in computed_only}
forward: dict[str, set[str]] = {d: set() for d in computed_only}
for dim_name, dim_deps in deps.items():
for dep in dim_deps:
forward[dep].add(dim_name)
in_degree[dim_name] += 1
queue = deque(sorted(d for d, deg in in_degree.items() if deg == 0))
ordered: list[str] = []
while queue:
node = queue.popleft()
ordered.append(node)
for nxt in sorted(forward.get(node, set())):
in_degree[nxt] -= 1
if in_degree[nxt] == 0:
queue.append(nxt)
if len(ordered) != len(computed_only):
# 有环依赖:保护性降级(按名称补齐),避免阻塞整条计费链路
remaining = sorted(list(computed_only - set(ordered)))
logger.error("Computed dimension cycle detected: {}", remaining)
ordered.extend(remaining)
return ordered
class DimensionCollectorService:
"""运行时读取 collectors 并执行采集code-only"""
def __init__(self, db: Session):
self.db = db
self._runtime = DimensionCollectorRuntime()
def list_enabled_collectors(
self,
*,
api_format: str | None,
task_type: str | None,
) -> list[CollectorLike]:
api = _normalize_api_format(api_format)
task = _normalize_task_type(task_type)
if not api or not task:
return []
# Code-defined collectors cache.
cache_key = f"code:{api}:{task}"
cached = BillingCache.get_collectors(cache_key)
if cached is not None:
return cached
built = self._list_builtin_collectors(api_format=api_format, task_type=task_type)
BillingCache.set_collectors(cache_key, built)
return built
def _list_builtin_collectors(
self,
*,
api_format: str | None,
task_type: str | None,
) -> list[DimensionCollector]:
"""
Built-in (code) collectors.
Developers ship a curated set of collectors in code (config-file mode).
"""
api = _normalize_api_format(api_format)
task = _normalize_task_type(task_type)
if not api or not task:
return []
def _preset_query(api_keys: list[str], task_t: str) -> list[DimensionCollector]:
out: list[DimensionCollector] = []
allowed = {k for k in api_keys if k}
for p in CORE_PRESET_PACK.collectors:
if not p.is_enabled:
continue
if _normalize_api_format(p.api_format) not in allowed:
continue
if _normalize_task_type(p.task_type) != task_t:
continue
out.append(
DimensionCollector(
api_format=_normalize_api_format(p.api_format),
task_type=_normalize_task_type(p.task_type),
dimension_name=p.dimension_name,
source_type=p.source_type,
source_path=p.source_path,
value_type=p.value_type,
transform_expression=p.transform_expression,
default_value=p.default_value,
priority=int(p.priority or 0),
is_enabled=True,
)
)
return out
api_variants = list({api, api.lower()})
if task == "video":
from src.core.api_format.signature import parse_signature_key
base_api = api
try:
sig = parse_signature_key(api)
if sig.endpoint_kind.value == "video":
base_api = f"{sig.api_family.value}:chat"
except Exception:
base_api = api
base_variants = list({base_api, base_api.lower()})
video_collectors = _preset_query(api_variants, "video")
base_collectors = _preset_query(base_variants, "video")
video_dims: set[str] = {c.dimension_name for c in video_collectors}
result: list[DimensionCollector] = list(video_collectors)
for c in base_collectors:
if c.dimension_name not in video_dims:
result.append(c)
return result
if task == "cli":
cli_collectors = _preset_query(api_variants, "cli")
chat_collectors = _preset_query(api_variants, "chat")
cli_dims: set[str] = {c.dimension_name for c in cli_collectors}
result: list[DimensionCollector] = list(cli_collectors)
for c in chat_collectors:
if c.dimension_name not in cli_dims:
result.append(c)
return result
return _preset_query(api_variants, task)
def collect_dimensions(
self,
*,
api_format: str | None,
task_type: str | None,
request: dict[str, Any] | None = None,
response: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
base_dimensions: dict[str, Any] | None = None,
) -> dict[str, Any]:
collectors = self.list_enabled_collectors(api_format=api_format, task_type=task_type)
dims = self._runtime.collect(
collectors=collectors,
inp=DimensionCollectInput(
request=request,
response=response,
metadata=metadata,
base_dimensions=base_dimensions,
),
)
# Post-process: normalize video_resolution_key (e.g., 1080x720 → 720x1080)
if "video_resolution_key" in dims:
raw = dims["video_resolution_key"]
if isinstance(raw, str) and raw:
dims["video_resolution_key"] = _normalize_resolution_key(raw)
return dims

View File

@@ -0,0 +1,779 @@
"""
FormulaEngine - 配置驱动的安全计费表达式引擎
目标:
- 支持 billing_rules.expression 的安全求值AST 白名单)
- 支持 dimension_mappingsdimension/matrix/tiered/constant
- 支持 required/allow_zero 机制,避免维度缺失导致静默少收
注意:该模块不直接依赖数据库;规则查找、维度采集在上层服务完成。
"""
from __future__ import annotations
import ast
from dataclasses import dataclass, field
from decimal import Decimal
from functools import lru_cache
from typing import Any, Iterable, Literal
from src.core.logger import logger
from src.services.billing.precision import DECIMAL_CONTEXT_PRECISION, to_decimal
class UnsafeExpressionError(ValueError):
"""表达式包含不安全/不支持的 AST 结构。"""
class ExpressionEvaluationError(RuntimeError):
"""表达式在安全求值阶段失败(如 NameError/ZeroDivision"""
class BillingIncompleteError(RuntimeError):
"""required 维度缺失且 strict_mode=true 时抛出,用于上层拒绝请求/标记任务失败。"""
def __init__(self, message: str, *, missing_required: list[str]):
super().__init__(message)
self.missing_required = missing_required
@dataclass(frozen=True)
class FormulaEvaluationResult:
status: Literal["complete", "incomplete"]
cost: Decimal
resolved_dimensions: dict[str, Any]
resolved_variables: dict[str, Any]
cost_breakdown: dict[str, Decimal] = field(default_factory=dict)
tier_index: int | None = None
tier_info: dict[str, Any] | None = None
missing_required: list[str] = field(default_factory=list)
error: str | None = None
_ALLOWED_BINOPS = (
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.Pow,
ast.FloorDiv,
ast.Mod,
)
_ALLOWED_UNARYOPS = (ast.UAdd, ast.USub)
_ALLOWED_OP_NODES = _ALLOWED_BINOPS + _ALLOWED_UNARYOPS
# Allowed function names used in expressions.
_ALLOWED_FUNC_NAMES = frozenset(("min", "max", "abs", "round", "int", "float"))
def _iter_ast_nodes(node: ast.AST) -> Iterable[ast.AST]:
yield node
for child in ast.iter_child_nodes(node):
yield from _iter_ast_nodes(child)
@lru_cache(maxsize=256)
def _validate_expression_cached(expression: str) -> ast.Expression:
"""
Parse + validate an expression and cache the resulting AST.
This is a hot path (called for every billing evaluation and many collector transforms),
so we cache validated ASTs to avoid repeated ast.parse + whitelist scans.
"""
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError as exc:
raise UnsafeExpressionError(f"Invalid expression syntax: {exc}") from exc
for node in _iter_ast_nodes(tree):
if isinstance(node, ast.Expression):
continue
# 运算符节点本身也会出现在 iter_child_nodes 中
if isinstance(node, _ALLOWED_OP_NODES):
continue
if isinstance(node, ast.Constant):
# 仅允许数字常量bool 是 int 子类,需要显式排除)
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
raise UnsafeExpressionError("Only int/float constants are allowed")
continue
if isinstance(node, ast.BinOp):
if not isinstance(node.op, _ALLOWED_BINOPS):
raise UnsafeExpressionError(f"Operator not allowed: {type(node.op).__name__}")
continue
if isinstance(node, ast.UnaryOp):
if not isinstance(node.op, _ALLOWED_UNARYOPS):
raise UnsafeExpressionError(f"Unary operator not allowed: {type(node.op).__name__}")
continue
if isinstance(node, ast.Name):
# 防御:拒绝双下划线变量名
if node.id.startswith("__"):
raise UnsafeExpressionError("Dunder names are not allowed")
continue
if isinstance(node, ast.Load):
continue
if isinstance(node, ast.keyword):
continue
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name):
raise UnsafeExpressionError("Only direct function calls are allowed")
func_name = node.func.id
if func_name not in _ALLOWED_FUNC_NAMES:
raise UnsafeExpressionError(f"Function not allowed: {func_name}")
if any(k.arg is None for k in node.keywords):
raise UnsafeExpressionError("**kwargs is not allowed")
continue
# 明确禁止的/不需要的节点类型(属性访问、下标、推导式、比较等)
if isinstance(
node,
(
ast.Attribute,
ast.Subscript,
ast.Compare,
ast.BoolOp,
ast.IfExp,
ast.Lambda,
ast.Dict,
ast.List,
ast.Tuple,
ast.Set,
ast.ListComp,
ast.SetComp,
ast.DictComp,
ast.GeneratorExp,
ast.Await,
ast.Yield,
ast.YieldFrom,
),
):
raise UnsafeExpressionError(f"AST node not allowed: {type(node).__name__}")
raise UnsafeExpressionError(f"AST node not allowed: {type(node).__name__}")
assert isinstance(tree, ast.Expression)
return tree
def extract_variable_names(expression: str) -> set[str]:
"""提取表达式中出现的变量名(不含函数名)。"""
tree = _validate_expression_cached(expression)
names: set[str] = set()
for node in _iter_ast_nodes(tree):
if isinstance(node, ast.Name):
names.add(node.id)
if isinstance(node, ast.Call):
# Call 的函数名会以 ast.Name 出现,需要从结果中过滤掉
if isinstance(node.func, ast.Name):
names.discard(node.func.id)
return names
class SafeExpressionEvaluator:
"""AST 白名单 + 无 builtins 的安全求值器。"""
def __init__(self) -> None:
# Decimal-friendly allowed functions (return Decimal)
self.ALLOWED_FUNCS: dict[str, Any] = {
"min": self._min,
"max": self._max,
"abs": self._abs,
"round": self._round,
"int": self._int,
"float": self._float,
}
@staticmethod
def _min(*args: Any) -> Decimal:
return min(to_decimal(a) for a in args)
@staticmethod
def _max(*args: Any) -> Decimal:
return max(to_decimal(a) for a in args)
@staticmethod
def _abs(x: Any) -> Decimal:
return abs(to_decimal(x))
@staticmethod
def _round(x: Any, ndigits: Any = 0) -> Decimal:
# Round Decimal returns Decimal; coerce ndigits to int safely.
try:
n = int(ndigits)
except Exception:
n = 0
return round(to_decimal(x), n)
@staticmethod
def _int(x: Any) -> Decimal:
return to_decimal(int(to_decimal(x)))
@staticmethod
def _float(x: Any) -> Decimal:
# Keep numeric chain in Decimal even if caller used float()
return to_decimal(float(to_decimal(x)))
def validate(self, expression: str) -> ast.Expression:
return _validate_expression_cached(expression)
def eval_decimal(self, expression: str, variables: dict[str, Any]) -> Decimal:
"""
Evaluate expression into Decimal.
We avoid Python eval() here to ensure:
- float literals don't leak binary float arithmetic
- all arithmetic stays within Decimal
"""
tree = self.validate(expression)
try:
with _decimal_context(DECIMAL_CONTEXT_PRECISION):
return _eval_decimal(tree.body, variables or {}, self.ALLOWED_FUNCS)
except NameError:
raise
except ExpressionEvaluationError:
raise
except Exception as exc:
raise ExpressionEvaluationError(str(exc)) from exc
def eval_number(self, expression: str, variables: dict[str, Any]) -> float:
"""Backward-compatible float evaluation (used by DimensionCollector transforms)."""
value = self.eval_decimal(expression, variables)
try:
return float(value)
except Exception as exc:
raise ExpressionEvaluationError(f"Expression result is not numeric: {value!r}") from exc
class _decimal_context:
def __init__(self, prec: int):
self.prec = prec
def __enter__(self) -> None:
from decimal import getcontext
self._ctx = getcontext().copy()
getcontext().prec = self.prec
def __exit__(self, exc_type: type | None, exc: BaseException | None, tb: Any) -> None:
from decimal import setcontext
# Restore full context to avoid leaking settings.
setcontext(self._ctx)
def _eval_decimal(node: ast.AST, variables: dict[str, Any], funcs: dict[str, Any]) -> Decimal:
if isinstance(node, ast.Constant):
return to_decimal(node.value)
if isinstance(node, ast.Name):
if node.id not in variables:
raise NameError(node.id)
return to_decimal(variables[node.id])
if isinstance(node, ast.UnaryOp):
v = _eval_decimal(node.operand, variables, funcs)
if isinstance(node.op, ast.UAdd):
return v
if isinstance(node.op, ast.USub):
return -v
raise ExpressionEvaluationError(f"Unary operator not allowed: {type(node.op).__name__}")
if isinstance(node, ast.BinOp):
left = _eval_decimal(node.left, variables, funcs)
right = _eval_decimal(node.right, variables, funcs)
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
if isinstance(node.op, ast.Div):
return left / right
if isinstance(node.op, ast.FloorDiv):
return left // right
if isinstance(node.op, ast.Mod):
return left % right
if isinstance(node.op, ast.Pow):
# Decimal power is only well-defined for integer exponents here.
try:
exp_int = int(right)
if to_decimal(exp_int) != right:
raise ValueError("non-integer exponent")
except Exception as exc:
raise ExpressionEvaluationError("Pow only supports integer exponents") from exc
return left**exp_int
raise ExpressionEvaluationError(f"Operator not allowed: {type(node.op).__name__}")
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name):
raise ExpressionEvaluationError("Only direct function calls are allowed")
func_name = node.func.id
func = funcs.get(func_name)
if func is None:
raise ExpressionEvaluationError(f"Function not allowed: {func_name}")
args = [_eval_decimal(a, variables, funcs) for a in node.args]
kwargs = {
kw.arg: _eval_decimal(kw.value, variables, funcs) for kw in node.keywords if kw.arg
}
try:
result = func(*args, **kwargs)
except Exception as exc:
raise ExpressionEvaluationError(str(exc)) from exc
return to_decimal(result)
raise ExpressionEvaluationError(f"AST node not allowed: {type(node).__name__}")
class FormulaEngine:
"""计费表达式引擎:解析 dimension_mappings 并进行安全求值。"""
def __init__(self) -> None:
self._evaluator = SafeExpressionEvaluator()
def evaluate(
self,
*,
expression: str,
variables: dict[str, Any] | None,
dimensions: dict[str, Any] | None,
dimension_mappings: dict[str, dict[str, Any]] | None,
strict_mode: bool = False,
) -> FormulaEvaluationResult:
dims = dimensions or {}
mappings = dimension_mappings or {}
resolved: dict[str, Any] = dict(variables or {})
missing_required: list[str] = []
tier_index: int | None = None
tier_info: dict[str, Any] | None = None
computed: dict[str, dict[str, Any]] = {}
# 1) Resolve non-computed mappings first
for var_name, mapping in mappings.items():
source = (mapping.get("source") or "constant").lower()
if source == "computed":
computed[var_name] = mapping
continue
# Explicit constant mapping is fallback-only when variable already exists.
if source == "constant" and var_name in resolved:
continue
value, is_missing, tier_meta = self._resolve_mapping(var_name, mapping, dims)
if tier_meta and tier_index is None:
tier_index = tier_meta.get("tier_index")
tier_info = tier_meta.get("tier_info")
if is_missing:
missing_required.append(var_name)
continue
resolved[var_name] = value
# 2) Resolve computed mappings (iterative dependency resolution)
if computed:
unresolved = dict(computed)
for _ in range(max(4, len(unresolved) + 1)):
progressed = False
for var_name, mapping in list(unresolved.items()):
if var_name in resolved:
unresolved.pop(var_name, None)
continue
value, status = self._try_resolve_computed(var_name, mapping, dims, resolved)
if status == "pending":
continue
unresolved.pop(var_name, None)
if status == "missing_required":
missing_required.append(var_name)
continue
if status == "error":
# 求值异常但非 required使用 default 值继续
resolved[var_name] = value
progressed = True
continue
resolved[var_name] = value
progressed = True
if not progressed:
break
# any remaining unresolved computed vars
for var_name, mapping in unresolved.items():
required = bool(mapping.get("required", False))
default = mapping.get("default", 0)
logger.warning(
"[FormulaEngine] computed 维度 '{}' 在迭代后仍未解析, required={}",
var_name,
required,
)
if required:
missing_required.append(var_name)
else:
resolved[var_name] = default
# required 维度缺失:直接标记 incomplete并由 strict_mode 决定是否抛错)
if missing_required:
if strict_mode:
raise BillingIncompleteError(
f"Missing required dimensions: {missing_required}",
missing_required=missing_required,
)
return FormulaEvaluationResult(
status="incomplete",
cost=Decimal("0"),
resolved_dimensions=dims,
resolved_variables=resolved,
missing_required=missing_required,
tier_index=tier_index,
tier_info=tier_info,
)
# 3) Evaluate total cost
try:
cost = self._evaluator.eval_decimal(expression, resolved)
if cost < 0:
return FormulaEvaluationResult(
status="incomplete",
cost=Decimal("0"),
resolved_dimensions=dims,
resolved_variables=resolved,
missing_required=[],
tier_index=tier_index,
tier_info=tier_info,
error="negative_cost",
)
breakdown = self._extract_cost_breakdown(resolved)
return FormulaEvaluationResult(
status="complete",
cost=cost,
resolved_dimensions=dims,
resolved_variables=resolved,
cost_breakdown=breakdown,
tier_index=tier_index,
tier_info=tier_info,
missing_required=[],
)
except NameError as exc:
# expression references missing vars
if strict_mode:
raise ExpressionEvaluationError(f"Missing variable: {exc}") from exc
return FormulaEvaluationResult(
status="incomplete",
cost=Decimal("0"),
resolved_dimensions=dims,
resolved_variables=resolved,
missing_required=[],
tier_index=tier_index,
tier_info=tier_info,
error=f"missing_variable:{exc}",
)
except (UnsafeExpressionError, ExpressionEvaluationError) as exc:
if strict_mode:
raise
return FormulaEvaluationResult(
status="incomplete",
cost=Decimal("0"),
resolved_dimensions=dims,
resolved_variables=resolved,
missing_required=[],
tier_index=tier_index,
tier_info=tier_info,
error=str(exc),
)
def _extract_cost_breakdown(self, resolved: dict[str, Any]) -> dict[str, Decimal]:
breakdown: dict[str, Decimal] = {}
for k, v in resolved.items():
if not k.endswith("_cost"):
continue
try:
breakdown[k] = to_decimal(v)
except Exception:
continue
return breakdown
def _try_resolve_computed(
self,
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
resolved: dict[str, Any],
) -> tuple[Any, Literal["ok", "pending", "missing_required"]]:
"""
Try resolve a computed mapping.
Returns:
(value, status)
- ok: value computed
- pending: missing dependencies, retry later
- missing_required: required=true and cannot resolve
"""
required = bool(mapping.get("required", False))
default = mapping.get("default", 0)
expr = mapping.get("expression") or mapping.get("transform_expression")
if not expr:
return (None, "missing_required") if required else (default, "ok")
# Computed vars can reference both resolved variables and raw dims.
env: dict[str, Any] = {}
env.update(dims)
env.update(resolved)
try:
value = self._evaluator.eval_decimal(str(expr), env)
return value, "ok"
except NameError:
# dependency not ready yet
return (None, "pending") if required else (default, "pending")
except Exception as exc:
logger.warning(
"[FormulaEngine] computed 维度 '{}' 求值异常: {}, expression={!r}",
var_name,
exc,
expr,
)
return (None, "missing_required") if required else (default, "error")
def _resolve_mapping(
self,
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""
Returns:
(value, is_missing_required, tier_meta)
说明:
- is_missing_required 仅在 required=true 且缺失时为 True
- required=false 的缺失会使用 default 或 0 兜底,并返回 is_missing_required=False
"""
source = (mapping.get("source") or "constant").lower()
if source == "constant":
return self._resolve_constant(mapping)
if source == "dimension":
return self._resolve_dimension(var_name, mapping, dims)
if source == "matrix":
return self._resolve_matrix(var_name, mapping, dims)
if source == "tiered":
return self._resolve_tiered(var_name, mapping, dims)
# 未知 source视为配置错误但不直接中断计费返回 default
return mapping.get("default", 0), False, None
@staticmethod
def _resolve_constant(
mapping: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""constant 默认行为:由 variables 提供dimension_mappings 显式 constant 时仅做兜底"""
return mapping.get("default", 0), False, None
@staticmethod
def _resolve_dimension(
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""解析 dimension source从 dims 中取值并尝试转换为 Decimal"""
required = bool(mapping.get("required", False))
allow_zero = bool(mapping.get("allow_zero", False))
default = mapping.get("default", 0)
def _missing() -> tuple[Any, bool]:
if required:
return None, True
return default, False
key = mapping.get("key") or var_name
raw = dims.get(key)
if raw is None:
v, m = _missing()
return v, m, None
if isinstance(raw, str):
if raw == "":
v, m = _missing()
return v, m, None
try:
num = to_decimal(raw)
if num == 0 and not allow_zero:
v, m = _missing()
return v, m, None
return num, False, None
except Exception:
return raw, False, None
if isinstance(raw, (int, float, Decimal)):
num = to_decimal(raw)
if num == 0 and not allow_zero:
v, m = _missing()
return v, m, None
return num, False, None
try:
num = to_decimal(raw)
if num == 0 and not allow_zero:
v, m = _missing()
return v, m, None
return num, False, None
except Exception:
v, m = _missing()
return v, m, None
@staticmethod
def _resolve_matrix(
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""解析 matrix source从 map 中按 key 查找值"""
required = bool(mapping.get("required", False))
default = mapping.get("default", 0)
def _missing() -> tuple[Any, bool]:
if required:
return None, True
return default, False
key = mapping.get("key") or var_name
raw = dims.get(key)
if raw is None or raw == "":
v, m = _missing()
return v, m, None
raw_key = str(raw)
matrix = mapping.get("map") or {}
if raw_key in matrix:
try:
return to_decimal(matrix[raw_key]), False, None
except Exception:
return matrix[raw_key], False, None
if required:
return None, True, None
return default, False, None
def _resolve_tiered(
self,
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""解析 tiered source按阶梯匹配值"""
required = bool(mapping.get("required", False))
allow_zero = bool(mapping.get("allow_zero", False))
default = mapping.get("default", 0)
def _missing() -> tuple[Any, bool]:
if required:
return None, True
return default, False
tier_key = mapping.get("tier_key")
if not tier_key:
v, m = _missing()
return v, m, None
raw_tier_value = dims.get(tier_key)
if raw_tier_value is None:
v, m = _missing()
return v, m, None
try:
tier_value = to_decimal(raw_tier_value)
except Exception:
v, m = _missing()
return v, m, None
if tier_value == 0 and not allow_zero:
v, m = _missing()
return v, m, None
# Optional TTL override (legacy: Claude cache pricing)
ttl_key = mapping.get("ttl_key")
ttl_value_key = mapping.get("ttl_value_key")
ttl_minutes: Decimal | None = None
if ttl_key and ttl_value_key and dims.get(ttl_key) is not None:
try:
ttl_minutes = to_decimal(dims.get(ttl_key))
except Exception:
ttl_minutes = None
tiers = mapping.get("tiers") or []
# tiers: [{up_to: 128000, value: 2.5}, {up_to: null, value: 1.25}]
for idx, tier in enumerate(tiers):
up_to = tier.get("up_to")
if up_to is None:
value = to_decimal(tier.get("value", default))
if (
ttl_minutes is not None
and ttl_value_key
and isinstance(tier.get("cache_ttl_pricing"), list)
):
value = self._resolve_ttl_pricing(
tier.get("cache_ttl_pricing") or [],
ttl_minutes,
str(ttl_value_key),
fallback=value,
)
return value, False, {"tier_index": idx, "tier_info": dict(tier)}
try:
if tier_value <= to_decimal(up_to):
value = to_decimal(tier.get("value", default))
if (
ttl_minutes is not None
and ttl_value_key
and isinstance(tier.get("cache_ttl_pricing"), list)
):
value = self._resolve_ttl_pricing(
tier.get("cache_ttl_pricing") or [],
ttl_minutes,
str(ttl_value_key),
fallback=value,
)
return value, False, {"tier_index": idx, "tier_info": dict(tier)}
except Exception:
# up_to 配置异常:忽略并继续
continue
# 无匹配:使用最后一个或 default
if tiers:
last = tiers[-1]
value = to_decimal(last.get("value", default))
if (
ttl_minutes is not None
and ttl_value_key
and isinstance(last.get("cache_ttl_pricing"), list)
):
value = self._resolve_ttl_pricing(
last.get("cache_ttl_pricing") or [],
ttl_minutes,
str(ttl_value_key),
fallback=value,
)
return value, False, {"tier_index": len(tiers) - 1, "tier_info": dict(last)}
return default, False, None
def _resolve_ttl_pricing(
self,
ttl_pricing: list[Any],
ttl_minutes: Decimal,
ttl_value_key: str,
*,
fallback: Decimal,
) -> Decimal:
"""
Resolve TTL-dependent pricing (legacy: cache_ttl_pricing).
Rules:
- pick the first entry whose ttl_minutes >= requested ttl
- otherwise pick the last entry
- if missing/invalid, fallback to base value
"""
try:
entries = [e for e in ttl_pricing if isinstance(e, dict)]
if not entries:
return fallback
def _ttl_key(e: dict[str, Any]) -> Decimal:
return to_decimal(e.get("ttl_minutes") or 0)
entries_sorted = sorted(entries, key=_ttl_key)
chosen: dict[str, Any] = entries_sorted[-1]
for e in entries_sorted:
try:
if ttl_minutes <= to_decimal(e.get("ttl_minutes") or 0):
chosen = e
break
except Exception:
continue
v = chosen.get(ttl_value_key)
if v is None:
return fallback
return to_decimal(v)
except Exception:
return fallback

View File

@@ -0,0 +1,342 @@
"""
计费模块数据模型
定义计费相关的核心数据结构:
- BillingUnit: 计费单位枚举
- BillingDimension: 计费维度定义
- StandardizedUsage: 标准化的 usage 数据
- CostBreakdown: 计费明细结果
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class BillingUnit(str, Enum):
"""计费单位"""
PER_1M_TOKENS = "per_1m_tokens" # 每百万 token
PER_1M_TOKENS_HOUR = "per_1m_tokens_hour" # 每百万 token 每小时(豆包缓存存储)
PER_REQUEST = "per_request" # 每次请求
FIXED = "fixed" # 固定费用
@dataclass
class BillingDimension:
"""
计费维度定义
每个维度描述一种计费方式,例如:
- 输入 token 计费
- 输出 token 计费
- 缓存读取计费
- 按次计费
"""
name: str # 维度名称,如 "input", "output", "cache_read"
usage_field: str # 从 usage 中取值的字段名
price_field: str # 价格配置中的字段名
unit: BillingUnit = BillingUnit.PER_1M_TOKENS # 计费单位
default_price: float = 0.0 # 默认价格(当价格配置中没有时使用)
def calculate(self, usage_value: float, price: float) -> float:
"""
计算该维度的费用
Args:
usage_value: 使用量数值
price: 单价
Returns:
计算后的费用
"""
if usage_value <= 0 or price <= 0:
return 0.0
if self.unit == BillingUnit.PER_1M_TOKENS:
return (usage_value / 1_000_000) * price
elif self.unit == BillingUnit.PER_1M_TOKENS_HOUR:
# 缓存存储按 token 数 * 小时数计费
return (usage_value / 1_000_000) * price
elif self.unit == BillingUnit.PER_REQUEST:
return usage_value * price
elif self.unit == BillingUnit.FIXED:
return price
return 0.0
def to_dict(self) -> dict[str, Any]:
"""转换为字典(用于序列化)"""
return {
"name": self.name,
"usage_field": self.usage_field,
"price_field": self.price_field,
"unit": self.unit.value,
"default_price": self.default_price,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> BillingDimension:
"""从字典创建实例"""
return cls(
name=data["name"],
usage_field=data["usage_field"],
price_field=data["price_field"],
unit=BillingUnit(data.get("unit", "per_1m_tokens")),
default_price=data.get("default_price", 0.0),
)
@dataclass(init=False)
class StandardizedUsage:
"""
标准化的 Usage 数据
将不同 API 格式的 usage 统一为标准格式,便于计费计算。
"""
# 基础 token 计数
input_tokens: int = 0
output_tokens: int = 0
# 缓存相关
cache_creation_tokens: int = 0 # Claude: 缓存创建
cache_read_tokens: int = 0 # Claude/OpenAI/豆包: 缓存读取/命中
# 特殊 token 类型
reasoning_tokens: int = 0 # o1/豆包: 推理 token通常包含在 output 中,单独记录用于分析)
# 时间相关(用于按时计费)
cache_storage_token_hours: float = 0.0 # 豆包: 缓存存储 token*小时
# 请求计数(用于按次计费)
request_count: int = 1
# 任意维度存储(用于多维度计费;数值/字符串均可)
# 兼容旧字段名extra 作为 dimensions 的别名
dimensions: dict[str, Any] = field(default_factory=dict)
def __init__(
self,
*,
input_tokens: int = 0,
output_tokens: int = 0,
cache_creation_tokens: int = 0,
cache_read_tokens: int = 0,
reasoning_tokens: int = 0,
cache_storage_token_hours: float = 0.0,
request_count: int = 1,
dimensions: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
) -> None:
# 基础字段
self.input_tokens = input_tokens
self.output_tokens = output_tokens
self.cache_creation_tokens = cache_creation_tokens
self.cache_read_tokens = cache_read_tokens
self.reasoning_tokens = reasoning_tokens
self.cache_storage_token_hours = cache_storage_token_hours
self.request_count = request_count
# 兼容:支持 extra 与 dimensions 同时传入dimensions 优先级更高)
merged: dict[str, Any] = {}
if isinstance(extra, dict):
merged.update(extra)
if isinstance(dimensions, dict):
merged.update(dimensions)
self.dimensions = merged
@property
def extra(self) -> dict[str, Any]:
"""向后兼容:旧代码使用 usage.extra 访问扩展维度。"""
return self.dimensions
@extra.setter
def extra(self, value: dict[str, Any]) -> None:
"""向后兼容:允许旧代码写入 usage.extra。"""
self.dimensions = value or {}
def get(self, field_name: str, default: Any = 0) -> Any:
"""
通用字段获取
支持获取标准字段和扩展字段。
Args:
field_name: 字段名
default: 默认值
Returns:
字段值
"""
# 兼容旧字段名
if field_name == "extra":
return self.dimensions
if hasattr(self, field_name) and field_name not in {"dimensions"}:
return getattr(self, field_name)
return self.dimensions.get(field_name, default)
def set(self, field_name: str, value: Any) -> None:
"""
通用字段设置
Args:
field_name: 字段名
value: 字段值
"""
# 兼容旧字段名
if field_name == "extra":
self.dimensions = value or {}
return
if hasattr(self, field_name) and field_name not in {"dimensions"}:
setattr(self, field_name, value)
return
self.dimensions[field_name] = value
def to_dict(self) -> dict[str, Any]:
"""转换为字典"""
result: dict[str, Any] = {
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cache_creation_tokens": self.cache_creation_tokens,
"cache_read_tokens": self.cache_read_tokens,
"reasoning_tokens": self.reasoning_tokens,
"cache_storage_token_hours": self.cache_storage_token_hours,
"request_count": self.request_count,
}
if self.dimensions:
# 新字段名
result["dimensions"] = self.dimensions
# 旧字段名(兼容)
result["extra"] = self.dimensions
return result
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StandardizedUsage:
"""从字典创建实例"""
# 兼容:支持 extra / dimensions 两种键名
extra = data.pop("extra", {}) if "extra" in data else {}
dimensions = data.pop("dimensions", {}) if "dimensions" in data else {}
merged_dimensions: dict[str, Any] = {}
if isinstance(extra, dict):
merged_dimensions.update(extra)
if isinstance(dimensions, dict):
merged_dimensions.update(dimensions)
# 只取已知字段
known_fields = {
"input_tokens",
"output_tokens",
"cache_creation_tokens",
"cache_read_tokens",
"reasoning_tokens",
"cache_storage_token_hours",
"request_count",
}
filtered = {k: v for k, v in data.items() if k in known_fields}
return cls(**filtered, dimensions=merged_dimensions)
@dataclass
class CostBreakdown:
"""
计费明细结果
包含各维度的费用和总费用。
"""
# 各维度费用 {"input": 0.01, "output": 0.02, "cache_read": 0.001, ...}
costs: dict[str, float] = field(default_factory=dict)
# 总费用
total_cost: float = 0.0
# 命中的阶梯索引(如果使用阶梯计费)
tier_index: int | None = None
# 货币单位
currency: str = "USD"
# 使用的价格(用于记录和审计)
effective_prices: dict[str, float] = field(default_factory=dict)
# =========================================================================
# 兼容旧接口的属性(便于渐进式迁移)
# =========================================================================
@property
def input_cost(self) -> float:
"""输入费用"""
return self.costs.get("input", 0.0)
@property
def output_cost(self) -> float:
"""输出费用"""
return self.costs.get("output", 0.0)
@property
def cache_creation_cost(self) -> float:
"""缓存创建费用"""
return self.costs.get("cache_creation", 0.0)
@property
def cache_read_cost(self) -> float:
"""缓存读取费用"""
return self.costs.get("cache_read", 0.0)
@property
def cache_cost(self) -> float:
"""总缓存费用(创建 + 读取)"""
return self.cache_creation_cost + self.cache_read_cost
@property
def request_cost(self) -> float:
"""按次计费费用"""
return self.costs.get("request", 0.0)
@property
def cache_storage_cost(self) -> float:
"""缓存存储费用(豆包等)"""
return self.costs.get("cache_storage", 0.0)
def to_dict(self) -> dict[str, Any]:
"""转换为字典"""
return {
"costs": self.costs,
"total_cost": self.total_cost,
"tier_index": self.tier_index,
"currency": self.currency,
"effective_prices": self.effective_prices,
# 兼容字段
"input_cost": self.input_cost,
"output_cost": self.output_cost,
"cache_creation_cost": self.cache_creation_cost,
"cache_read_cost": self.cache_read_cost,
"cache_cost": self.cache_cost,
"request_cost": self.request_cost,
}
def to_legacy_tuple(self) -> tuple:
"""
转换为旧接口的元组格式
Returns:
(input_cost, output_cost, cache_creation_cost, cache_read_cost,
cache_cost, request_cost, total_cost, tier_index)
"""
return (
self.input_cost,
self.output_cost,
self.cache_creation_cost,
self.cache_read_cost,
self.cache_cost,
self.request_cost,
self.total_cost,
self.tier_index,
)

View File

@@ -0,0 +1,54 @@
"""
Precision helpers for billing calculations.
We standardize money arithmetic with `Decimal` and quantize to stable precisions.
Notes:
- `Decimal` context precision (`DECIMAL_CONTEXT_PRECISION`) is **significant digits**,
not "decimal places".
- We keep these as constants (not runtime-configurable) to avoid drift between
environments during billing reconciliation.
"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal, localcontext
# Decimal context precision (significant digits)
DECIMAL_CONTEXT_PRECISION = 28
# Money precisions
BILLING_STORAGE_PRECISION = 8 # persisted to DB / metadata
BILLING_DISPLAY_PRECISION = 6 # UI display
def to_decimal(value: float | int | str | Decimal | None) -> Decimal:
"""Convert values to Decimal safely (float via str to avoid binary artifacts)."""
if value is None:
return Decimal("0")
if isinstance(value, Decimal):
return value
return Decimal(str(value))
def to_money_decimal(value: float | int | str | Decimal | None) -> Decimal:
"""Convert values to Decimal and quantize to billing storage precision."""
return quantize_cost(to_decimal(value))
def quantize_decimal(value: Decimal, *, precision: int) -> Decimal:
"""Quantize a Decimal to the given number of decimal places (ROUND_HALF_UP)."""
quantizer = Decimal(10) ** -precision
with localcontext() as ctx:
ctx.prec = DECIMAL_CONTEXT_PRECISION
return value.quantize(quantizer, rounding=ROUND_HALF_UP)
def quantize_cost(value: Decimal) -> Decimal:
"""Quantize to storage precision."""
return quantize_decimal(value, precision=BILLING_STORAGE_PRECISION)
def quantize_display(value: Decimal) -> Decimal:
"""Quantize to display precision."""
return quantize_decimal(value, precision=BILLING_DISPLAY_PRECISION)

View File

@@ -0,0 +1,254 @@
"""
Billing presets (developer-provided defaults).
Why:
- Asking end-users to configure DimensionCollectors / BillingRules from scratch is too complex.
- We ship a curated set of "known-good" collector presets per api_format/task_type
and provide an Admin API to apply them into DB (merge or overwrite).
Notes:
- BillingRule presets are intentionally NOT materialized here, because the unified
billing architecture already provides a runtime default rule generator that stays
in-sync with Model/GlobalModel pricing. Persisting those prices into BillingRule
rows would become stale when model pricing changes.
"""
from __future__ import annotations
import importlib
import pkgutil
from dataclasses import dataclass
from typing import Any, Literal
from sqlalchemy.orm import Session
from src.core.api_format.signature import normalize_signature_key
from src.models.database import DimensionCollector
PresetApplyMode = Literal["merge", "overwrite"]
def _norm_api(api_format: str) -> str:
return normalize_signature_key(api_format or "")
def _norm_task(task_type: str) -> str:
return (task_type or "").strip().lower()
@dataclass(frozen=True)
class CollectorPreset:
api_format: str
task_type: str
dimension_name: str
source_type: str
source_path: str | None = None
value_type: str = "float" # float/int/string
transform_expression: str | None = None
default_value: str | None = None
priority: int = 0
is_enabled: bool = True
@dataclass(frozen=True)
class PresetPack:
name: str
version: str
description: str
collectors: list[CollectorPreset]
def _discover_collectors() -> list[CollectorPreset]:
"""
Config-file mode: discover collectors from `src.services.billing.collector_defs`.
Developers add a new file under that package; no central registry edits required.
"""
out: list[CollectorPreset] = []
try:
pkg = importlib.import_module("src.services.billing.collector_defs")
pkg_path = getattr(pkg, "__path__", None)
if not pkg_path:
return out
except Exception:
return out
for mod in pkgutil.iter_modules(pkg_path):
if mod.ispkg:
continue
mod_name = f"src.services.billing.collector_defs.{mod.name}"
try:
m = importlib.import_module(mod_name)
except Exception:
continue
items = getattr(m, "COLLECTORS", None)
if not isinstance(items, list):
continue
for raw in items:
if not isinstance(raw, dict):
continue
try:
out.append(
CollectorPreset(
api_format=str(raw.get("api_format") or "").strip(),
task_type=str(raw.get("task_type") or "").strip().lower(),
dimension_name=str(raw.get("dimension_name") or "").strip(),
source_type=str(raw.get("source_type") or "").strip().lower(),
source_path=raw.get("source_path"),
value_type=str(raw.get("value_type") or "float").strip().lower(),
transform_expression=raw.get("transform_expression"),
default_value=raw.get("default_value"),
priority=int(raw.get("priority") or 0),
is_enabled=bool(raw.get("is_enabled", True)),
)
)
except Exception:
continue
return out
CORE_PRESET_PACK = PresetPack(
name="aether-core",
version="1.0",
description="Aether built-in dimension collectors for common api_formats/task_types.",
collectors=_discover_collectors(),
)
def list_preset_packs() -> list[PresetPack]:
return [CORE_PRESET_PACK]
@dataclass(frozen=True)
class PresetApplyResult:
preset: str
mode: PresetApplyMode
created: int
updated: int
skipped: int
errors: list[str]
def to_dict(self) -> dict[str, Any]:
return {
"preset": self.preset,
"mode": self.mode,
"created": self.created,
"updated": self.updated,
"skipped": self.skipped,
"errors": list(self.errors),
}
class BillingPresetService:
@staticmethod
def apply_preset(
db: Session,
*,
preset_name: str,
mode: PresetApplyMode = "merge",
) -> PresetApplyResult:
preset_name = (preset_name or "").strip()
packs = {p.name: p for p in list_preset_packs()}
pack = packs.get(preset_name)
if pack is None:
available = ", ".join(sorted(packs.keys()))
return PresetApplyResult(
preset=preset_name,
mode=mode,
created=0,
updated=0,
skipped=0,
errors=[f"Unknown preset: {preset_name!r}. Available: {available}"],
)
created = 0
updated = 0
skipped = 0
errors: list[str] = []
for item in pack.collectors:
api_format = _norm_api(item.api_format)
task_type = _norm_task(item.task_type)
dim = (item.dimension_name or "").strip()
if not api_format or not task_type or not dim:
skipped += 1
continue
try:
existing = (
db.query(DimensionCollector)
.filter(
DimensionCollector.api_format == api_format,
DimensionCollector.task_type == task_type,
DimensionCollector.dimension_name == dim,
DimensionCollector.priority == int(item.priority or 0),
DimensionCollector.is_enabled == True, # noqa: E712
)
.first()
)
except Exception as exc:
errors.append(
f"Failed to query collector: api_format={api_format} task_type={task_type} dim={dim}: {exc}"
)
continue
if existing is not None:
if mode == "overwrite":
try:
existing.source_type = (item.source_type or "").strip().lower()
existing.source_path = item.source_path
existing.value_type = (item.value_type or "float").strip().lower()
existing.transform_expression = item.transform_expression
existing.default_value = item.default_value
existing.is_enabled = bool(item.is_enabled)
updated += 1
except Exception as exc:
errors.append(
f"Failed to update collector {getattr(existing, 'id', None)}: {exc}"
)
else:
skipped += 1
continue
try:
c = DimensionCollector(
api_format=api_format,
task_type=task_type,
dimension_name=dim,
source_type=(item.source_type or "").strip().lower(),
source_path=item.source_path,
value_type=(item.value_type or "float").strip().lower(),
transform_expression=item.transform_expression,
default_value=item.default_value,
priority=int(item.priority or 0),
is_enabled=bool(item.is_enabled),
)
db.add(c)
created += 1
except Exception as exc:
errors.append(
f"Failed to create collector: api_format={api_format} task_type={task_type} dim={dim}: {exc}"
)
try:
db.commit()
except Exception as exc:
db.rollback()
errors.append(f"DB commit failed: {exc}")
return PresetApplyResult(
preset=pack.name,
mode=mode,
created=created,
updated=updated,
skipped=skipped,
errors=errors,
)

View File

@@ -0,0 +1,10 @@
"""
Billing rule definitions (config-file mode).
Each module should export:
- TEMPLATES: list[CodeBillingRuleTemplate]
Design goal:
- Add a new billing mode by adding a new file here.
- No central registry edits required.
"""

View File

@@ -0,0 +1,195 @@
"""
Universal billing template.
This is the single unified billing template for all task types.
Formula: total = (input_cost + output_cost + cache_creation_cost + cache_read_cost) + request_cost + video_cost
Each component can be 0 if not applicable for the specific task type.
"""
from __future__ import annotations
import re
from src.services.billing.default_rules import DefaultBillingRuleGenerator, VirtualBillingRule
from src.services.billing.rule_templates import CodeBillingRuleTemplate, RuleTemplateContext
def _get_nested(obj: object | None, path: str) -> object | None:
if not isinstance(obj, dict):
return None
cur: object = obj
for part in (path or "").split("."):
if not part:
continue
if not isinstance(cur, dict):
return None
cur = cur.get(part) # type: ignore[assignment]
return cur
def _as_float(v: object | None) -> float | None:
try:
if v is None:
return None
if isinstance(v, bool):
return None
return float(v)
except Exception:
return None
_WXH_PATTERN = re.compile(r"^(\d+)x(\d+)$")
def _normalize_resolution_key(raw: str) -> str:
"""
Normalize resolution key:
- lowercase, remove spaces, × → x
- For WxH format, sort dimensions so smaller comes first (1080x720 → 720x1080)
"""
k = (raw or "").strip().lower().replace(" ", "").replace("×", "x")
match = _WXH_PATTERN.match(k)
if match:
a, b = int(match.group(1)), int(match.group(2))
k = f"{a}x{b}" if a <= b else f"{b}x{a}"
return k
def _effective_unit_price(ctx: RuleTemplateContext) -> float:
"""Get video price per second from config."""
if ctx.model is not None:
v = _as_float(
_get_nested(getattr(ctx.model, "config", None), "billing.video.price_per_second")
)
if v is not None:
return v
v = _as_float(
_get_nested(getattr(ctx.global_model, "config", None), "billing.video.price_per_second")
)
if v is not None:
return v
return 0.0
def _effective_resolution_price_per_second(ctx: RuleTemplateContext) -> dict[str, float]:
"""
Resolution (or size) -> price_per_second.
"""
for conf in (
getattr(ctx.model, "config", None) if ctx.model is not None else None,
getattr(ctx.global_model, "config", None),
):
raw = _get_nested(conf, "billing.video.price_per_second_by_resolution")
if not isinstance(raw, dict):
continue
out: dict[str, float] = {}
for k, v in raw.items():
fk = _normalize_resolution_key(str(k))
fv = _as_float(v)
if not fk:
continue
if fv is None:
continue
out[fk] = fv
if out:
return out
# Backward-compat: resolution multipliers
base = _effective_unit_price(ctx)
if base and base > 0:
for conf in (
getattr(ctx.model, "config", None) if ctx.model is not None else None,
getattr(ctx.global_model, "config", None),
):
raw = _get_nested(conf, "billing.video.resolution_multipliers")
if not isinstance(raw, dict):
continue
out2: dict[str, float] = {}
for k, v in raw.items():
fk = _normalize_resolution_key(str(k))
mv = _as_float(v)
if not fk:
continue
if mv is None:
continue
out2[fk] = float(base) * float(mv)
if out2:
return out2
return {}
def build_universal(ctx: RuleTemplateContext) -> VirtualBillingRule:
"""
Build the universal billing rule.
Formula:
total = (input_cost + output_cost + cache_creation_cost + cache_read_cost) + request_cost + video_cost
Each component defaults to 0 if not configured or not applicable.
"""
# Base rule: token + per-request
base = DefaultBillingRuleGenerator.generate_for_model(
global_model=ctx.global_model,
model=ctx.model,
task_type=ctx.task_type,
)
unit_price = _effective_unit_price(ctx)
resolution_price_map = _effective_resolution_price_per_second(ctx)
variables = dict(base.variables or {})
dimension_mappings = dict(base.dimension_mappings or {})
# Video duration dimension
dimension_mappings["duration_seconds"] = {
"source": "dimension",
"key": "duration_seconds",
"required": False,
"allow_zero": True,
"default": 0,
}
# Video price per second (resolved from resolution map or fallback to unit price)
dimension_mappings["video_price_per_second"] = {
"source": "matrix",
"key": "video_resolution_key",
"required": False,
"default": unit_price,
"map": resolution_price_map,
}
# Video cost component
dimension_mappings["video_cost"] = {
"source": "computed",
"required": False,
"default": 0,
"expression": "duration_seconds * video_price_per_second",
}
# Universal formula: token costs + request cost + video cost
# base.expression = "input_cost + output_cost + cache_creation_cost + cache_read_cost + request_cost"
expression = f"({base.expression}) + video_cost"
return VirtualBillingRule(
id="__default__",
name="Universal Billing Rule",
task_type=ctx.task_type,
expression=expression,
variables=variables,
dimension_mappings=dimension_mappings,
is_virtual=True,
)
TEMPLATES = [
CodeBillingRuleTemplate(
name="universal",
description="Universal billing: (input + output + cache) + request + video. All components default to 0 if not applicable.",
task_types={"chat", "cli", "video", "image", "audio"},
priority=100, # Highest priority - used for all task types
build=build_universal,
)
]

View File

@@ -0,0 +1,133 @@
"""
BillingRule 查找逻辑
查找顺序(与 .plans/humming-seeking-marble.md 一致):
1) 读取 GlobalModel/Model 价格配置 → 2) 使用代码内置计费模板生成规则config-file mode
注意:
- CLI 在计费域等同于 chatbilling_rules.task_type 不含 "cli"
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal, Protocol
from sqlalchemy.orm import Session
from src.config.settings import config
from src.models.database import GlobalModel, Model
from src.services.billing.cache import BillingCache
from src.services.billing.default_rules import DefaultBillingRuleGenerator, VirtualBillingRule
from src.services.billing.rule_templates import CodeBillingRuleTemplateService
TaskType = Literal["chat", "cli", "video", "image", "audio"]
BillingRuleScope = Literal["model", "global", "default"]
class BillingRuleLike(Protocol):
id: str
name: str
expression: str
variables: dict[str, Any]
dimension_mappings: dict[str, Any]
def effective_rule_task_type(task_type: str) -> str:
"""CLI 在计费规则域里恒等于 chat。"""
t = (task_type or "").lower()
return "chat" if t == "cli" else t
@dataclass(frozen=True)
class BillingRuleLookupResult:
rule: BillingRuleLike
scope: BillingRuleScope
effective_task_type: str
class BillingRuleService:
@staticmethod
def find_rule(
db: Session,
*,
provider_id: str | None,
model_name: str,
task_type: str,
) -> BillingRuleLookupResult | None:
effective_task = effective_rule_task_type(task_type)
# Normalize provider_id for cache key to avoid duplicate entries (None vs "").
pid = provider_id or ""
# Cache must include runtime knobs that affect fallback behavior.
cache_key = (
f"{pid}:{model_name}:{effective_task}:require={int(config.billing_require_rule)}"
)
cached = BillingCache.get_rule(cache_key)
if cached is not None:
return cached
global_model = (
db.query(GlobalModel)
.filter(
GlobalModel.name == model_name,
GlobalModel.is_active == True, # noqa: E712
)
.first()
)
if not global_model:
return None
model_obj: Model | None = None
# Provider Model用于覆盖价格配置
if provider_id:
model_obj = (
db.query(Model)
.filter(
Model.provider_id == provider_id,
Model.global_model_id == global_model.id,
Model.is_active == True, # noqa: E712
)
.first()
)
# Code templates (config-file mode)
code_rule = CodeBillingRuleTemplateService.resolve_rule(
global_model=global_model,
model=model_obj,
provider_id=provider_id,
model_name=model_name,
task_type=effective_task,
)
if code_rule is not None:
result = BillingRuleLookupResult(
rule=code_rule,
scope="default",
effective_task_type=effective_task,
)
BillingCache.set_rule(cache_key, result)
return result
# Runtime default rule (backward compatible)
#
# - Always applies to chat-domain billing (cli is normalized to chat).
# - For video/image/audio:
# - When BILLING_REQUIRE_RULE=true, caller expects an explicit BillingRule (missing -> no_rule/error).
# - When BILLING_REQUIRE_RULE=false, fallback to default rule to preserve legacy pricing semantics
# (avoid silent $0 billing due to missing rule).
if effective_task == "chat" or not config.billing_require_rule:
default_rule = DefaultBillingRuleGenerator.generate_for_model(
global_model=global_model,
model=model_obj,
task_type=effective_task,
)
result = BillingRuleLookupResult(
rule=default_rule,
scope="default",
effective_task_type=effective_task,
)
BillingCache.set_rule(cache_key, result)
return result
return None

View File

@@ -0,0 +1,129 @@
"""
Billing rule templates (config-file mode).
Goal:
- Developers define billing rules in code as templates ("模式一/二/三/四/五 ...").
- Adding a new billing template should only require adding a new `*.py` file under
`src.services.billing.rule_defs` (no DB / no UI).
How it works:
- Each module under `rule_defs/` exports `TEMPLATES: list[CodeBillingRuleTemplate]`.
- We dynamically discover all templates at runtime and pick the best one by:
- task_type match
- optional match(ctx) predicate
- highest priority wins
"""
from __future__ import annotations
import importlib
import pkgutil
from dataclasses import dataclass
from typing import Callable, Iterable
from src.models.database import GlobalModel, Model
from src.services.billing.default_rules import VirtualBillingRule
@dataclass(frozen=True)
class RuleTemplateContext:
global_model: GlobalModel
model: Model | None
provider_id: str | None
model_name: str
task_type: str
MatchFn = Callable[[RuleTemplateContext], bool]
BuildFn = Callable[[RuleTemplateContext], VirtualBillingRule]
@dataclass(frozen=True)
class CodeBillingRuleTemplate:
"""
A code-defined billing template.
Notes:
- `task_types` are billing-domain task types ("cli" is normalized to "chat" by rule_service).
- `build()` must return a VirtualBillingRule-like object (VirtualBillingRule is used here).
"""
name: str
description: str
task_types: set[str]
priority: int = 0
match: MatchFn | None = None
build: BuildFn | None = None
def supports(self, task_type: str) -> bool:
return (task_type or "").lower() in {t.lower() for t in (self.task_types or set())}
def _iter_modules() -> Iterable[str]:
try:
pkg = importlib.import_module("src.services.billing.rule_defs")
pkg_path = getattr(pkg, "__path__", None)
if not pkg_path:
return []
except Exception:
return []
out: list[str] = []
for mod in pkgutil.iter_modules(pkg_path):
if mod.ispkg:
continue
out.append(f"src.services.billing.rule_defs.{mod.name}")
return out
def discover_rule_templates() -> list[CodeBillingRuleTemplate]:
templates: list[CodeBillingRuleTemplate] = []
for mod_name in _iter_modules():
try:
m = importlib.import_module(mod_name)
except Exception:
continue
items = getattr(m, "TEMPLATES", None)
if not isinstance(items, list):
continue
for t in items:
if isinstance(t, CodeBillingRuleTemplate):
templates.append(t)
# higher priority first, stable within same module import order
templates.sort(key=lambda x: int(getattr(x, "priority", 0) or 0), reverse=True)
return templates
class CodeBillingRuleTemplateService:
@staticmethod
def resolve_rule(
*,
global_model: GlobalModel,
model: Model | None,
provider_id: str | None,
model_name: str,
task_type: str,
) -> VirtualBillingRule | None:
ctx = RuleTemplateContext(
global_model=global_model,
model=model,
provider_id=provider_id,
model_name=model_name,
task_type=(task_type or "").lower(),
)
for t in discover_rule_templates():
if not t.supports(ctx.task_type):
continue
if t.match is not None:
try:
if not bool(t.match(ctx)):
continue
except Exception:
continue
if t.build is None:
continue
try:
return t.build(ctx)
except Exception:
continue
return None

View File

@@ -0,0 +1,112 @@
"""
Billing schema (stable contracts)
These dataclasses are meant to be stored in `Usage.request_metadata` / `Task.request_metadata`
for auditability. They are internal-only and MUST NOT be exposed to end users without sanitizing.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
BILLING_SNAPSHOT_SCHEMA_VERSION = "2.0"
BillingSnapshotStatus = Literal["complete", "incomplete", "no_rule", "legacy"]
@dataclass(frozen=True)
class BillingSnapshot:
"""
Stable billing snapshot for audit.
v2.0 semantics:
- resolved_dimensions: final dimension values used (tokens, request_count, etc.)
- resolved_variables: final variables used (prices, tier-resolved values, etc.)
- cost_breakdown: itemized costs (quantized)
- total_cost: quantized total cost (equals sum(cost_breakdown) when breakdown present)
Backward compatibility:
- dimensions_used aliases resolved_dimensions
- cost aliases total_cost
"""
schema_version: str = BILLING_SNAPSHOT_SCHEMA_VERSION
# Rule info (optional for legacy/no_rule)
rule_id: str | None = None
rule_name: str | None = None
scope: str | None = None
# Rule expression (internal, do not expose to clients)
expression: str | None = None
# v2: resolved inputs
resolved_dimensions: dict[str, Any] = field(default_factory=dict)
resolved_variables: dict[str, Any] = field(default_factory=dict)
# v2: breakdown and totals
cost_breakdown: dict[str, float] = field(default_factory=dict)
total_cost: float = 0.0
# Tier info (optional)
tier_index: int | None = None
tier_info: dict[str, Any] | None = None
# Missing dims
missing_required: list[str] = field(default_factory=list)
# Result status
status: BillingSnapshotStatus = "no_rule"
# Audit
calculated_at: str = "" # ISO 8601
engine_version: str = "2.0"
# ---------------------------------------------------------------------
# Backward-compatible aliases (v1 fields)
# ---------------------------------------------------------------------
@property
def dimensions_used(self) -> dict[str, Any]:
return self.resolved_dimensions
@property
def cost(self) -> float:
return self.total_cost
def to_dict(self) -> dict[str, Any]:
"""
Serialize snapshot.
Includes both v2 keys and v1-compatible keys for safer rollouts.
"""
return {
"schema_version": self.schema_version,
"rule_id": self.rule_id,
"rule_name": self.rule_name,
"scope": self.scope,
"expression": self.expression,
# v2
"resolved_dimensions": self.resolved_dimensions,
"resolved_variables": self.resolved_variables,
"cost_breakdown": self.cost_breakdown,
"total_cost": self.total_cost,
"tier_index": self.tier_index,
"tier_info": self.tier_info,
"missing_required": self.missing_required,
"status": self.status,
"calculated_at": self.calculated_at,
"engine_version": self.engine_version,
# v1 compat
"dimensions_used": self.resolved_dimensions,
"cost": self.total_cost,
}
@dataclass(frozen=True)
class CostResult:
"""Billing calculation output."""
cost: float
status: BillingSnapshotStatus
snapshot: BillingSnapshot

View File

@@ -0,0 +1,244 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.logger import logger
from src.services.billing.dimension_collector_service import DimensionCollectorService
from src.services.billing.formula_engine import BillingIncompleteError, FormulaEngine
from src.services.billing.precision import quantize_cost, to_decimal
from src.services.billing.rule_service import BillingRuleService
from .schema import BILLING_SNAPSHOT_SCHEMA_VERSION, BillingSnapshot, CostResult
class BillingService:
"""
BillingService (pure-ish application helper for billing domain).
Notes:
- This service **does not** write Usage rows.
- It may read billing rules & collectors from DB.
"""
# FormulaEngine is stateless and safe to share within a process.
_shared_formula_engine: FormulaEngine | None = None
def __init__(self, db: Session):
self.db = db
self._formula_engine = self._get_formula_engine()
# Lazy-init: most call sites already provide dimensions (hot path).
self._dimension_collector: DimensionCollectorService | None = None
@classmethod
def _get_formula_engine(cls) -> FormulaEngine:
if cls._shared_formula_engine is None:
cls._shared_formula_engine = FormulaEngine()
return cls._shared_formula_engine
def _get_dimension_collector(self) -> DimensionCollectorService:
if self._dimension_collector is None:
self._dimension_collector = DimensionCollectorService(self.db)
return self._dimension_collector
def collect_dimensions(
self,
*,
api_format: str | None,
task_type: str | None,
request: dict[str, Any] | None = None,
response: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
base_dimensions: dict[str, Any] | None = None,
) -> dict[str, Any]:
return self._get_dimension_collector().collect_dimensions(
api_format=api_format,
task_type=task_type,
request=request,
response=response,
metadata=metadata,
base_dimensions=base_dimensions,
)
def calculate(
self,
*,
task_type: str,
model: str,
provider_id: str,
dimensions: dict[str, Any],
strict_mode: bool | None = None,
) -> CostResult:
"""
Calculate cost for a task.
Returns:
CostResult (includes BillingSnapshot)
Raises:
BillingIncompleteError: when strict_mode=True and required dims missing.
"""
strict = config.billing_strict_mode if strict_mode is None else bool(strict_mode)
# Normalize & enrich dimensions (do not mutate caller dict)
dims: dict[str, Any] = dict(dimensions or {})
# Compatibility aliases (legacy fields in some call sites)
if "cache_creation_tokens" not in dims and "cache_creation_input_tokens" in dims:
dims["cache_creation_tokens"] = dims.get("cache_creation_input_tokens")
if "cache_read_tokens" not in dims and "cache_read_input_tokens" in dims:
dims["cache_read_tokens"] = dims.get("cache_read_input_tokens")
# Default request_count=1 for per-request billing
if "request_count" not in dims:
dims["request_count"] = 1
# total_input_context is the tier-key for legacy tiered pricing:
# default: input_tokens + cache_creation_tokens + cache_read_tokens
#
# NOTE:
# Some adapters (e.g. Claude) include cache_creation tokens in the tier context.
# Making this the default avoids per-callsite inconsistency.
if "total_input_context" not in dims:
try:
input_tokens_i = int(float(dims.get("input_tokens") or 0))
except Exception:
input_tokens_i = 0
try:
cache_creation_tokens_i = int(float(dims.get("cache_creation_tokens") or 0))
except Exception:
cache_creation_tokens_i = 0
try:
cache_read_tokens_i = int(float(dims.get("cache_read_tokens") or 0))
except Exception:
cache_read_tokens_i = 0
dims["total_input_context"] = (
input_tokens_i + cache_creation_tokens_i + cache_read_tokens_i
)
lookup = BillingRuleService.find_rule(
self.db,
provider_id=provider_id,
model_name=model,
task_type=task_type,
)
if lookup and lookup.rule and lookup.rule.expression:
rule = lookup.rule
result = self._formula_engine.evaluate(
expression=rule.expression,
variables=rule.variables or {},
dimensions=dims,
dimension_mappings=rule.dimension_mappings or {},
strict_mode=strict,
)
# ------------------------------------------------------------
# Quantize: component costs first, then total = sum(components)
# ------------------------------------------------------------
breakdown_dec: dict[str, Decimal] = {
k: to_decimal(v) for k, v in (result.cost_breakdown or {}).items()
}
breakdown_quantized: dict[str, Decimal] = {
k: quantize_cost(v) for k, v in breakdown_dec.items()
}
total_dec = (
quantize_cost(sum(breakdown_quantized.values(), Decimal("0")))
if breakdown_quantized
else quantize_cost(to_decimal(result.cost))
)
cost_breakdown = {k: float(v) for k, v in breakdown_quantized.items()}
total_cost = float(total_dec) if result.status == "complete" else 0.0
# Filter resolved_variables for JSON safety + semantics clarity:
# - remove dims (they live in resolved_dimensions)
# - remove *_cost (they live in cost_breakdown)
resolved_vars: dict[str, Any] = {}
for k, v in (result.resolved_variables or {}).items():
if k in (result.resolved_dimensions or {}):
continue
if k.endswith("_cost"):
continue
if isinstance(v, Decimal):
resolved_vars[k] = str(v)
else:
resolved_vars[k] = v
snapshot = BillingSnapshot(
schema_version=BILLING_SNAPSHOT_SCHEMA_VERSION,
rule_id=str(rule.id),
rule_name=str(rule.name),
scope=str(getattr(lookup, "scope", None) or ""),
expression=str(rule.expression),
resolved_dimensions=result.resolved_dimensions or dims,
resolved_variables=resolved_vars,
cost_breakdown=cost_breakdown,
total_cost=total_cost,
tier_index=result.tier_index,
tier_info=result.tier_info,
missing_required=result.missing_required,
status=result.status,
calculated_at=datetime.now(timezone.utc).isoformat(),
)
return CostResult(cost=total_cost, status=result.status, snapshot=snapshot)
logger.warning(
"No billing rule for task (task_type={}, model={}, provider_id={})",
task_type,
model,
provider_id,
)
snapshot = BillingSnapshot(
schema_version=BILLING_SNAPSHOT_SCHEMA_VERSION,
rule_id=None,
rule_name=None,
scope=None,
expression=None,
resolved_dimensions=dims,
resolved_variables={},
cost_breakdown={},
total_cost=0.0,
missing_required=[],
status="no_rule",
calculated_at=datetime.now(timezone.utc).isoformat(),
)
return CostResult(cost=0.0, status="no_rule", snapshot=snapshot)
def calculate_from_response(
self,
*,
task_type: str,
model: str,
provider_id: str,
api_format: str | None,
request: dict[str, Any] | None = None,
response: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
base_dimensions: dict[str, Any] | None = None,
strict_mode: bool | None = None,
) -> CostResult:
"""
Convenience wrapper:
- collect dimensions from request/response/metadata
- run billing calculation
"""
dimensions = self.collect_dimensions(
api_format=api_format,
task_type=task_type,
request=request,
response=response,
metadata=metadata,
base_dimensions=base_dimensions,
)
return self.calculate(
task_type=task_type,
model=model,
provider_id=provider_id,
dimensions=dimensions,
strict_mode=strict_mode,
)

View File

@@ -0,0 +1,239 @@
"""
预定义计费模板
提供常见厂商的计费配置模板,避免重复配置:
- CLAUDE_STANDARD: Claude/Anthropic 标准计费
- OPENAI_STANDARD: OpenAI 标准计费
- DOUBAO_STANDARD: 豆包计费(含缓存存储)
- GEMINI_STANDARD: Gemini 标准计费
- PER_REQUEST: 按次计费
"""
from src.services.billing.models import BillingDimension, BillingUnit
class BillingTemplates:
"""预定义的计费模板"""
# =========================================================================
# Claude/Anthropic 标准计费
# - 输入 token
# - 输出 token
# - 缓存创建(创建时收费,约 1.25x 输入价格)
# - 缓存读取(约 0.1x 输入价格)
# - 按次计费(可选,配置 price_per_request 时生效)
# =========================================================================
CLAUDE_STANDARD: list[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="cache_creation",
usage_field="cache_creation_tokens",
price_field="cache_creation_price_per_1m",
),
BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
# OpenAI 标准计费
# - 输入 token
# - 输出 token
# - 缓存读取(部分模型支持,无缓存创建费用)
# - 按次计费(可选,配置 price_per_request 时生效)
# =========================================================================
OPENAI_STANDARD: list[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
# 豆包计费
# - 推理输入 (input_tokens)
# - 推理输出 (output_tokens)
# - 缓存命中 (cache_read_tokens) - 类似 Claude 的缓存读取
# - 缓存存储 (cache_storage_token_hours) - 按 token 数 * 存储时长计费
# - 按次计费(可选,配置 price_per_request 时生效)
#
# 注意:豆包的缓存创建是免费的,但存储需要按时付费
# =========================================================================
DOUBAO_STANDARD: list[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
BillingDimension(
name="cache_storage",
usage_field="cache_storage_token_hours",
price_field="cache_storage_price_per_1m_hour",
unit=BillingUnit.PER_1M_TOKENS_HOUR,
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
# Gemini 标准计费
# - 输入 token
# - 输出 token
# - 缓存读取
# - 按次计费(用于图片生成等模型,需配置 price_per_request
# =========================================================================
GEMINI_STANDARD: list[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
# 按次计费
# - 适用于某些图片生成模型、特殊 API 等
# - 仅按请求次数计费,不按 token 计费
# =========================================================================
PER_REQUEST: list[BillingDimension] = [
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
# 混合计费(按次 + 按 token
# - 某些模型既有固定费用又有 token 费用
# =========================================================================
HYBRID_STANDARD: list[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
# 模板注册表
# =========================================================================
BILLING_TEMPLATE_REGISTRY: dict[str, list[BillingDimension]] = {
# 按厂商名称
"claude": BillingTemplates.CLAUDE_STANDARD,
"anthropic": BillingTemplates.CLAUDE_STANDARD,
"openai": BillingTemplates.OPENAI_STANDARD,
"doubao": BillingTemplates.DOUBAO_STANDARD,
"bytedance": BillingTemplates.DOUBAO_STANDARD,
"gemini": BillingTemplates.GEMINI_STANDARD,
"google": BillingTemplates.GEMINI_STANDARD,
# 按计费模式
"per_request": BillingTemplates.PER_REQUEST,
"hybrid": BillingTemplates.HYBRID_STANDARD,
# 默认
"default": BillingTemplates.CLAUDE_STANDARD,
}
def get_template(name: str | None) -> list[BillingDimension]:
"""
获取计费模板
Args:
name: 模板名称(不区分大小写)
Returns:
计费维度列表
"""
if not name:
return BILLING_TEMPLATE_REGISTRY["default"]
template = BILLING_TEMPLATE_REGISTRY.get(name.lower())
if template is None:
available = ", ".join(sorted(BILLING_TEMPLATE_REGISTRY.keys()))
raise ValueError(f"Unknown billing template: {name!r}. Available: {available}")
return template
def list_templates() -> list[str]:
"""列出所有可用的模板名称"""
return list(BILLING_TEMPLATE_REGISTRY.keys())

View File

@@ -0,0 +1,47 @@
"""
计费相关 token 归一化工具。
"""
from __future__ import annotations
from src.core.api_format.enums import ApiFamily
from src.core.api_format.signature import parse_signature_key
def _get_api_family(api_format: str | None) -> ApiFamily | None:
"""解析 api_format 字符串,返回对应的 ApiFamily 枚举。"""
if not api_format:
return None
text = str(api_format).strip()
if not text:
return None
sig = parse_signature_key(text)
return sig.api_family
def normalize_input_tokens_for_billing(
api_format: str | None,
input_tokens: int,
cache_read_tokens: int,
) -> int:
"""
归一化 `input_tokens`,使其在计费中表示"非缓存输入 token"
计费口径:`input_tokens`=非缓存输入 token`cache_read_tokens`=缓存命中 token折扣/免费维度)。
- Claude 系:保持上游口径(不扣除),因为 Claude API 的 input_tokens 本身就不包含缓存部分。
- OpenAI 系:`input_tokens` 包含缓存命中部分,需要扣除 `cache_read_tokens`。
- Gemini 系:`promptTokenCount` 包含 `cachedContentTokenCount`,需要扣除。
"""
if input_tokens <= 0:
return 0 if input_tokens == 0 else input_tokens
if cache_read_tokens <= 0:
return input_tokens
api_family = _get_api_family(api_format)
if api_family == ApiFamily.CLAUDE:
return input_tokens
if api_family in (ApiFamily.OPENAI, ApiFamily.GEMINI):
return max(input_tokens - cache_read_tokens, 0)
# 未知格式,保守处理,不扣除
return input_tokens

View File

@@ -0,0 +1,251 @@
"""
Usage 字段映射器
将不同 API 格式的原始 usage 数据映射为标准化格式。
支持的格式:
- openai:*: OpenAI compatible (Chat/CLI)
- claude:*: Anthropic Messages (Chat/CLI)
- gemini:*: Google Gemini (Chat/CLI)
"""
from typing import Any
from src.services.billing.models import StandardizedUsage
class UsageMapper:
"""
Usage 字段映射器
将不同 API 格式的 usage 统一映射为 StandardizedUsage。
示例:
# OpenAI 格式
raw_usage = {
"prompt_tokens": 100,
"completion_tokens": 50,
"prompt_tokens_details": {"cached_tokens": 20},
"completion_tokens_details": {"reasoning_tokens": 10}
}
usage = UsageMapper.map(raw_usage, "OPENAI")
# Claude 格式
raw_usage = {
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 30,
"cache_read_input_tokens": 20
}
usage = UsageMapper.map(raw_usage, "CLAUDE")
"""
# =========================================================================
# 字段映射配置
# 格式: "source_path" -> "target_field"
# source_path 支持点号分隔的嵌套路径
# =========================================================================
# OpenAI 格式字段映射
OPENAI_MAPPING: dict[str, str] = {
"prompt_tokens": "input_tokens",
"completion_tokens": "output_tokens",
"prompt_tokens_details.cached_tokens": "cache_read_tokens",
"completion_tokens_details.reasoning_tokens": "reasoning_tokens",
}
# Claude 格式字段映射
CLAUDE_MAPPING: dict[str, str] = {
"input_tokens": "input_tokens",
"output_tokens": "output_tokens",
"cache_creation_input_tokens": "cache_creation_tokens",
"cache_read_input_tokens": "cache_read_tokens",
}
# Gemini 格式字段映射
GEMINI_MAPPING: dict[str, str] = {
"promptTokenCount": "input_tokens",
"candidatesTokenCount": "output_tokens",
"cachedContentTokenCount": "cache_read_tokens",
# Gemini 的 usageMetadata 格式
"usageMetadata.promptTokenCount": "input_tokens",
"usageMetadata.candidatesTokenCount": "output_tokens",
"usageMetadata.cachedContentTokenCount": "cache_read_tokens",
}
@classmethod
def map(
cls,
raw_usage: dict[str, Any],
api_format: str,
extra_mapping: dict[str, str] | None = None,
) -> StandardizedUsage:
"""
将原始 usage 映射为标准化格式
Args:
raw_usage: 原始 usage 字典
api_format: API 格式 ("OPENAI", "CLAUDE", "GEMINI" 等)
extra_mapping: 额外的字段映射(用于自定义扩展)
Returns:
标准化的 usage 对象
"""
if not raw_usage:
return StandardizedUsage()
# 获取对应格式的字段映射
mapping = cls._get_mapping(api_format)
# 合并额外映射
if extra_mapping:
mapping = {**mapping, **extra_mapping}
result = StandardizedUsage()
# 执行映射
for source_path, target_field in mapping.items():
value = cls._get_nested_value(raw_usage, source_path)
if value is not None:
result.set(target_field, value)
return result
@classmethod
def map_from_response(
cls,
response: dict[str, Any],
api_format: str,
) -> StandardizedUsage:
"""
从完整响应中提取并映射 usage
不同 API 格式的 usage 位置可能不同:
- OpenAI: response["usage"]
- Claude: response["usage"] 或 message_delta 中
- Gemini: response["usageMetadata"]
Args:
response: 完整的 API 响应
api_format: API 格式
Returns:
标准化的 usage 对象
"""
format_norm = (api_format or "").strip().lower()
api_family = format_norm.split(":", 1)[0] if ":" in format_norm else format_norm
# 提取 usage 部分
usage_data: dict[str, Any] = {}
if api_family == "gemini":
# Gemini: usageMetadata
usage_data = response.get("usageMetadata", {})
if not usage_data:
# 尝试从 candidates 中获取
candidates = response.get("candidates", [])
if candidates:
usage_data = candidates[0].get("usageMetadata", {})
else:
# OpenAI/Claude: usage
usage_data = response.get("usage", {})
return cls.map(usage_data, api_format)
@classmethod
def _get_mapping(cls, api_format: str) -> dict[str, str]:
"""获取对应格式的字段映射"""
format_norm = (api_format or "").strip().lower()
api_family = format_norm.split(":", 1)[0] if ":" in format_norm else format_norm
if api_family == "openai":
return cls.OPENAI_MAPPING
if api_family == "gemini":
return cls.GEMINI_MAPPING
# 默认 Claude也覆盖未知/空值)
return cls.CLAUDE_MAPPING
@classmethod
def _get_nested_value(cls, data: dict[str, Any], path: str) -> Any:
"""
获取嵌套字段值
支持点号分隔的路径,如 "prompt_tokens_details.cached_tokens"
Args:
data: 数据字典
path: 字段路径
Returns:
字段值,不存在则返回 None
"""
if not data or not path:
return None
keys = path.split(".")
value: Any = data
for key in keys:
if isinstance(value, dict):
value = value.get(key)
if value is None:
return None
else:
return None
return value
@classmethod
def register_format(cls, format_name: str, mapping: dict[str, str]) -> None:
"""
注册新的格式映射
Args:
format_name: 格式名称(会自动转为大写)
mapping: 字段映射
"""
cls.FORMAT_MAPPINGS[format_name.upper()] = mapping
@classmethod
def get_supported_formats(cls) -> list:
"""获取所有支持的格式"""
return list(cls.FORMAT_MAPPINGS.keys())
# =========================================================================
# 便捷函数
# =========================================================================
def map_usage(
raw_usage: dict[str, Any],
api_format: str,
) -> StandardizedUsage:
"""
便捷函数:将原始 usage 映射为标准化格式
Args:
raw_usage: 原始 usage 字典
api_format: API 格式
Returns:
StandardizedUsage 对象
"""
return UsageMapper.map(raw_usage, api_format)
def map_usage_from_response(
response: dict[str, Any],
api_format: str,
) -> StandardizedUsage:
"""
便捷函数:从响应中提取并映射 usage
Args:
response: API 响应
api_format: API 格式
Returns:
StandardizedUsage 对象
"""
return UsageMapper.map_from_response(response, api_format)