mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
57
_deprecated_py_src/services/billing/__init__.py
Normal file
57
_deprecated_py_src/services/billing/__init__.py
Normal 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",
|
||||
]
|
||||
130
_deprecated_py_src/services/billing/cache.py
Normal file
130
_deprecated_py_src/services/billing/cache.py
Normal 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)
|
||||
339
_deprecated_py_src/services/billing/calculator.py
Normal file
339
_deprecated_py_src/services/billing/calculator.py
Normal 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,
|
||||
}
|
||||
@@ -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]] = []
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
266
_deprecated_py_src/services/billing/default_rules.py
Normal file
266
_deprecated_py_src/services/billing/default_rules.py
Normal 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,
|
||||
)
|
||||
@@ -0,0 +1,465 @@
|
||||
"""
|
||||
DimensionCollector 运行时维度采集
|
||||
|
||||
特性(与 .plans/humming-seeking-marble.md 对齐):
|
||||
- (api_format, task_type) 作用域
|
||||
- 同一维度支持多条 collector(priority 回退)
|
||||
- 支持 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
|
||||
779
_deprecated_py_src/services/billing/formula_engine.py
Normal file
779
_deprecated_py_src/services/billing/formula_engine.py
Normal file
@@ -0,0 +1,779 @@
|
||||
"""
|
||||
FormulaEngine - 配置驱动的安全计费表达式引擎
|
||||
|
||||
目标:
|
||||
- 支持 billing_rules.expression 的安全求值(AST 白名单)
|
||||
- 支持 dimension_mappings(dimension/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
|
||||
342
_deprecated_py_src/services/billing/models.py
Normal file
342
_deprecated_py_src/services/billing/models.py
Normal 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,
|
||||
)
|
||||
54
_deprecated_py_src/services/billing/precision.py
Normal file
54
_deprecated_py_src/services/billing/precision.py
Normal 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)
|
||||
254
_deprecated_py_src/services/billing/presets.py
Normal file
254
_deprecated_py_src/services/billing/presets.py
Normal 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,
|
||||
)
|
||||
10
_deprecated_py_src/services/billing/rule_defs/__init__.py
Normal file
10
_deprecated_py_src/services/billing/rule_defs/__init__.py
Normal 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.
|
||||
"""
|
||||
195
_deprecated_py_src/services/billing/rule_defs/universal.py
Normal file
195
_deprecated_py_src/services/billing/rule_defs/universal.py
Normal 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,
|
||||
)
|
||||
]
|
||||
133
_deprecated_py_src/services/billing/rule_service.py
Normal file
133
_deprecated_py_src/services/billing/rule_service.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
BillingRule 查找逻辑
|
||||
|
||||
查找顺序(与 .plans/humming-seeking-marble.md 一致):
|
||||
1) 读取 GlobalModel/Model 价格配置 → 2) 使用代码内置计费模板生成规则(config-file mode)
|
||||
|
||||
注意:
|
||||
- CLI 在计费域等同于 chat:billing_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
|
||||
129
_deprecated_py_src/services/billing/rule_templates.py
Normal file
129
_deprecated_py_src/services/billing/rule_templates.py
Normal 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
|
||||
112
_deprecated_py_src/services/billing/schema.py
Normal file
112
_deprecated_py_src/services/billing/schema.py
Normal 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
|
||||
244
_deprecated_py_src/services/billing/service.py
Normal file
244
_deprecated_py_src/services/billing/service.py
Normal 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,
|
||||
)
|
||||
239
_deprecated_py_src/services/billing/templates.py
Normal file
239
_deprecated_py_src/services/billing/templates.py
Normal 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())
|
||||
47
_deprecated_py_src/services/billing/token_normalization.py
Normal file
47
_deprecated_py_src/services/billing/token_normalization.py
Normal 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
|
||||
251
_deprecated_py_src/services/billing/usage_mapper.py
Normal file
251
_deprecated_py_src/services/billing/usage_mapper.py
Normal 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)
|
||||
Reference in New Issue
Block a user