feat: 添加多维度计费系统和视频任务管理功能

计费系统:
- 新增 BillingRule 和 DimensionCollector 数据模型
- 实现 FormulaEngine 安全表达式求值引擎 (AST 白名单)
- 支持 dimension/matrix/tiered/constant 多种维度映射
- BillingRuleService 支持 Provider Model -> GlobalModel 规则回退
- CLI task_type 在计费域自动映射为 chat

视频任务增强:
- 添加 request_metadata 字段记录候选 key 和计费规则快照
- 后台轮询支持并发控制 (Semaphore + 独立 session)
- 任务终态自动写入 Usage 记录并计算成本
- 新增视频任务管理 API 和前端界面

其他改进:
- UsageService 新增 record_usage_with_custom_cost 方法
- StandardizedUsage 支持 dimensions 字段 (兼容 extra)
- 配置新增 BILLING_REQUIRE_RULE 和 BILLING_STRICT_MODE
This commit is contained in:
fawney19
2026-01-31 19:11:25 +08:00
parent dc4bb25cc2
commit 97b15afe7c
30 changed files with 4356 additions and 237 deletions

View File

@@ -9,6 +9,7 @@
"""
from __future__ import annotations
from typing import Any
from src.services.billing.models import (

View File

@@ -0,0 +1,371 @@
"""
DimensionCollector 运行时维度采集
特性(与 .plans/humming-seeking-marble.md 对齐):
- (api_format, task_type) 作用域
- 同一维度支持多条 collectorpriority 回退)
- 支持 transform_expression与 billing expression 共用 AST 安全规范)
- computed 维度支持依赖拓扑排序,并对环依赖做保护性降级
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from typing import Any, Literal
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import DimensionCollector
from src.services.billing.formula_engine import (
ExpressionEvaluationError,
SafeExpressionEvaluator,
UnsafeExpressionError,
extract_variable_names,
)
ValueType = Literal["float", "int", "string"]
def _normalize_api_format(api_format: str | None) -> str:
return (api_format or "").upper()
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)
@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[DimensionCollector],
inp: DimensionCollectInput,
) -> dict[str, Any]:
dims: dict[str, Any] = dict(inp.base_dimensions or {})
# dimension_name -> collectors (priority desc)
grouped: dict[str, list[DimensionCollector]] = {}
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[DimensionCollector],
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=%s, id=%s): %s",
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[DimensionCollector],
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[DimensionCollector]],
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=%s, id=%s)",
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: %s", remaining)
ordered.extend(remaining)
return ordered
class DimensionCollectorService:
"""DB + runtime 的封装:读取 collectors 并执行采集。"""
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[DimensionCollector]:
api = _normalize_api_format(api_format)
task = _normalize_task_type(task_type)
api_variants = list({api, api.lower()})
if task == "cli":
# CLI → chat按维度回退维度存在 cli collector 则用 cli否则用 chat
cli_collectors = (
self.db.query(DimensionCollector)
.filter(
DimensionCollector.api_format.in_(api_variants),
DimensionCollector.task_type == "cli",
DimensionCollector.is_enabled == True, # noqa: E712
)
.all()
)
chat_collectors = (
self.db.query(DimensionCollector)
.filter(
DimensionCollector.api_format.in_(api_variants),
DimensionCollector.task_type == "chat",
DimensionCollector.is_enabled == True, # noqa: E712
)
.all()
)
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 (
self.db.query(DimensionCollector)
.filter(
DimensionCollector.api_format.in_(api_variants),
DimensionCollector.task_type == task,
DimensionCollector.is_enabled == True, # noqa: E712
)
.all()
)
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)
return self._runtime.collect(
collectors=collectors,
inp=DimensionCollectInput(
request=request,
response=response,
metadata=metadata,
base_dimensions=base_dimensions,
),
)

View File

@@ -0,0 +1,368 @@
"""
FormulaEngine - 配置驱动的安全计费表达式引擎
目标:
- 支持 billing_rules.expression 的安全求值AST 白名单)
- 支持 dimension_mappingsdimension/matrix/tiered/constant
- 支持 required/allow_zero 机制,避免维度缺失导致静默少收
注意:该模块不直接依赖数据库;规则查找、维度采集在上层服务完成。
"""
from __future__ import annotations
import ast
from dataclasses import dataclass
from typing import Any, Iterable, Literal
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: float
resolved_values: dict[str, Any]
missing_required: list[str]
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
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)
def extract_variable_names(expression: str) -> set[str]:
"""提取表达式中出现的变量名(不含函数名)。"""
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError as exc:
raise UnsafeExpressionError(f"Invalid expression syntax: {exc}") from exc
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 的安全求值器。"""
ALLOWED_FUNCS: dict[str, Any] = {
"min": min,
"max": max,
"abs": abs,
"round": round,
"int": int,
"float": float,
}
def validate(self, expression: str) -> ast.Expression:
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 self.ALLOWED_FUNCS:
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 eval_number(self, expression: str, variables: dict[str, Any]) -> float:
tree = self.validate(expression)
safe_globals = {"__builtins__": {}}
safe_locals = dict(self.ALLOWED_FUNCS)
safe_locals.update(variables or {})
try:
compiled = compile(tree, "<billing_expr>", "eval")
value = eval(compiled, safe_globals, safe_locals) # noqa: S307 - validated AST
except Exception as exc:
raise ExpressionEvaluationError(str(exc)) from exc
try:
return float(value)
except Exception as exc:
raise ExpressionEvaluationError(f"Expression result is not numeric: {value!r}") from exc
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] = []
# 先解析 dimension_mappings产出 expression 变量表
for var_name, mapping in mappings.items():
source = (mapping.get("source") or "constant").lower()
# 显式 constant 映射属于“兜底行为”:如果 variables 已经提供该变量,则不覆盖。
if source == "constant" and var_name in resolved:
continue
value, is_missing = self._resolve_mapping(var_name, mapping, dims)
if is_missing:
missing_required.append(var_name)
continue
resolved[var_name] = value
# 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=0.0,
resolved_values=resolved,
missing_required=missing_required,
)
try:
cost = self._evaluator.eval_number(expression, resolved)
if cost < 0:
# 防御:不允许负数成本(通常表示配置错误)
return FormulaEvaluationResult(
status="incomplete",
cost=0.0,
resolved_values=resolved,
missing_required=[],
error="negative_cost",
)
return FormulaEvaluationResult(
status="complete",
cost=cost,
resolved_values=resolved,
missing_required=[],
)
except (UnsafeExpressionError, ExpressionEvaluationError) as exc:
if strict_mode:
raise
return FormulaEvaluationResult(
status="incomplete",
cost=0.0,
resolved_values=resolved,
missing_required=[],
error=str(exc),
)
def _resolve_mapping(
self,
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
) -> tuple[Any, bool]:
"""
Returns:
(value, is_missing_required)
说明:
- is_missing_required 仅在 required=true 且缺失时为 True
- required=false 的缺失会使用 default 或 0 兜底,并返回 is_missing_required=False
"""
source = (mapping.get("source") or "constant").lower()
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
if source == "constant":
# constant 默认行为:由 variables 提供dimension_mappings 显式 constant 时仅做兜底
return default, False
if source == "dimension":
key = mapping.get("key") or var_name
raw = dims.get(key)
if raw is None:
return _missing()
if isinstance(raw, str):
if raw == "":
return _missing()
# 尝试将字符串解析为数字,否则按字符串返回(供上层自行决定)
try:
num = float(raw)
if num == 0 and not allow_zero:
return _missing()
return num, False
except Exception:
return raw, False
if isinstance(raw, (int, float)):
if float(raw) == 0 and not allow_zero:
return _missing()
return raw, False
# 其他类型:尽量转为 float否则视为缺失
try:
num = float(raw)
if num == 0 and not allow_zero:
return _missing()
return num, False
except Exception:
return _missing()
if source == "matrix":
key = mapping.get("key") or var_name
raw = dims.get(key)
if raw is None or raw == "":
return _missing()
raw_key = str(raw)
matrix = mapping.get("map") or {}
if raw_key in matrix:
return matrix[raw_key], False
# matrix 未命中:若 required=true 则仍视为缺失;否则使用 default
if required:
return None, True
return default, False
if source == "tiered":
tier_key = mapping.get("tier_key")
if not tier_key:
return _missing()
raw_tier_value = dims.get(tier_key)
if raw_tier_value is None:
return _missing()
try:
tier_value = float(raw_tier_value)
except Exception:
return _missing()
if tier_value == 0 and not allow_zero:
return _missing()
tiers = mapping.get("tiers") or []
# tiers: [{up_to: 128000, value: 2.5}, {up_to: null, value: 1.25}]
for tier in tiers:
up_to = tier.get("up_to")
if up_to is None:
return tier.get("value", default), False
try:
if tier_value <= float(up_to):
return tier.get("value", default), False
except Exception:
# up_to 配置异常:忽略并继续
continue
# 无匹配:使用最后一个或 default
if tiers:
return tiers[-1].get("value", default), False
return default, False
# 未知 source视为配置错误但不直接中断计费返回 default
return default, False

View File

@@ -9,6 +9,7 @@
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
@@ -89,7 +90,7 @@ class BillingDimension:
)
@dataclass
@dataclass(init=False)
class StandardizedUsage:
"""
标准化的 Usage 数据
@@ -114,8 +115,49 @@ class StandardizedUsage:
# 请求计数(用于按次计费)
request_count: int = 1
# 扩展字段(未来可能需要的额外维度
extra: dict[str, Any] = field(default_factory=dict)
# 任意维度存储(用于多维度计费;数值/字符串均可
# 兼容旧字段名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:
"""
@@ -130,12 +172,14 @@ class StandardizedUsage:
Returns:
字段值
"""
if hasattr(self, field_name):
value = getattr(self, field_name)
# 对于 extra 字段,不直接返回
if field_name != "extra":
return value
return self.extra.get(field_name, default)
# 兼容旧字段名
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:
"""
@@ -145,10 +189,16 @@ class StandardizedUsage:
field_name: 字段名
value: 字段值
"""
if hasattr(self, field_name) and field_name != "extra":
# 兼容旧字段名
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)
else:
self.extra[field_name] = value
return
self.dimensions[field_name] = value
def to_dict(self) -> dict[str, Any]:
"""转换为字典"""
@@ -161,14 +211,24 @@ class StandardizedUsage:
"cache_storage_token_hours": self.cache_storage_token_hours,
"request_count": self.request_count,
}
if self.extra:
result["extra"] = self.extra
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",
@@ -180,7 +240,7 @@ class StandardizedUsage:
"request_count",
}
filtered = {k: v for k, v in data.items() if k in known_fields}
return cls(**filtered, extra=extra)
return cls(**filtered, dimensions=merged_dimensions)
@dataclass

View File

@@ -0,0 +1,101 @@
"""
BillingRule 查找逻辑
查找顺序(与 .plans/humming-seeking-marble.md 一致):
1) ModelProvider 级)→ 2) GlobalModel默认
注意:
- CLI 在计费域等同于 chatbilling_rules.task_type 不含 "cli"
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from sqlalchemy.orm import Session
from src.models.database import BillingRule, GlobalModel, Model
TaskType = Literal["chat", "cli", "video", "image", "audio"]
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: BillingRule
scope: Literal["model", "global"]
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)
global_model = (
db.query(GlobalModel)
.filter(
GlobalModel.name == model_name,
GlobalModel.is_active == True, # noqa: E712
)
.first()
)
if not global_model:
return None
# 1) 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()
)
if model_obj:
rule = (
db.query(BillingRule)
.filter(
BillingRule.model_id == model_obj.id,
BillingRule.task_type == effective_task,
BillingRule.is_enabled == True, # noqa: E712
)
.first()
)
if rule:
return BillingRuleLookupResult(
rule=rule,
scope="model",
effective_task_type=effective_task,
)
# 2) GlobalModel 默认规则
rule = (
db.query(BillingRule)
.filter(
BillingRule.global_model_id == global_model.id,
BillingRule.task_type == effective_task,
BillingRule.is_enabled == True, # noqa: E712
)
.first()
)
if rule:
return BillingRuleLookupResult(
rule=rule, scope="global", effective_task_type=effective_task
)
return None

View File

@@ -9,7 +9,6 @@
- PER_REQUEST: 按次计费
"""
from src.services.billing.models import BillingDimension, BillingUnit