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

@@ -0,0 +1,105 @@
from src.models.database import DimensionCollector
from src.services.billing.dimension_collector_service import (
DimensionCollectInput,
DimensionCollectorRuntime,
)
class TestDimensionCollectorRuntime:
def test_priority_fallback(self) -> None:
runtime = DimensionCollectorRuntime()
collectors = [
DimensionCollector(
api_format="OPENAI",
task_type="chat",
dimension_name="input_tokens",
source_type="response",
source_path="usage.prompt_tokens",
value_type="int",
priority=10,
is_enabled=True,
),
DimensionCollector(
api_format="OPENAI",
task_type="chat",
dimension_name="input_tokens",
source_type="response",
source_path="usageMetadata.promptTokenCount",
value_type="int",
priority=5,
is_enabled=True,
),
]
dims = runtime.collect(
collectors=collectors,
inp=DimensionCollectInput(
response={"usageMetadata": {"promptTokenCount": 123}},
),
)
assert dims["input_tokens"] == 123
def test_transform_expression_value(self) -> None:
runtime = DimensionCollectorRuntime()
collectors = [
DimensionCollector(
api_format="GEMINI",
task_type="video",
dimension_name="file_size_mb",
source_type="metadata",
source_path="result.file_size_bytes",
transform_expression="value / 1024 / 1024",
value_type="float",
priority=0,
is_enabled=True,
)
]
dims = runtime.collect(
collectors=collectors,
inp=DimensionCollectInput(metadata={"result": {"file_size_bytes": 1048576}}),
)
assert abs(dims["file_size_mb"] - 1.0) < 1e-9
def test_computed_dimension(self) -> None:
runtime = DimensionCollectorRuntime()
collectors = [
DimensionCollector(
api_format="CLAUDE",
task_type="chat",
dimension_name="input_tokens",
source_type="request",
source_path="usage.input_tokens",
value_type="int",
priority=0,
is_enabled=True,
),
DimensionCollector(
api_format="CLAUDE",
task_type="chat",
dimension_name="cache_read_tokens",
source_type="request",
source_path="usage.cache_read_tokens",
value_type="int",
priority=0,
is_enabled=True,
),
DimensionCollector(
api_format="CLAUDE",
task_type="chat",
dimension_name="total_input_tokens",
source_type="computed",
source_path=None,
transform_expression="input_tokens + cache_read_tokens",
value_type="int",
priority=0,
is_enabled=True,
),
]
dims = runtime.collect(
collectors=collectors,
inp=DimensionCollectInput(
request={"usage": {"input_tokens": 100, "cache_read_tokens": 20}}
),
)
assert dims["input_tokens"] == 100
assert dims["cache_read_tokens"] == 20
assert dims["total_input_tokens"] == 120

View File

@@ -0,0 +1,169 @@
import pytest
from src.services.billing.formula_engine import (
BillingIncompleteError,
FormulaEngine,
UnsafeExpressionError,
)
class TestSafeExpression:
def test_reject_attribute_access(self) -> None:
engine = FormulaEngine()
with pytest.raises(UnsafeExpressionError):
engine.evaluate(
expression="(1).__class__",
variables={},
dimensions={},
dimension_mappings={},
strict_mode=True, # 确保抛出异常,便于断言类型
)
def test_reject_import(self) -> None:
engine = FormulaEngine()
with pytest.raises(UnsafeExpressionError):
engine.evaluate(
expression="__import__('os').system('echo hacked')",
variables={},
dimensions={},
dimension_mappings={},
strict_mode=True,
)
class TestFormulaEngine:
def test_basic_video_formula(self) -> None:
engine = FormulaEngine()
result = engine.evaluate(
expression="(base_price + duration_seconds * price_per_second) * resolution_multiplier",
variables={"base_price": 0.05, "price_per_second": 0.02},
dimensions={"duration_seconds": 10, "resolution": "720p"},
dimension_mappings={
"duration_seconds": {
"source": "dimension",
"key": "duration_seconds",
"required": True,
},
"resolution_multiplier": {
"source": "matrix",
"key": "resolution",
"map": {"720p": 1.0, "1080p": 1.5},
"default": 1.0,
},
},
)
assert result.status == "complete"
assert result.missing_required == []
assert abs(result.cost - 0.25) < 1e-9
def test_required_dimension_missing_non_strict(self) -> None:
engine = FormulaEngine()
result = engine.evaluate(
expression="base_price + duration_seconds * price_per_second",
variables={"base_price": 0.05, "price_per_second": 0.02},
dimensions={"duration_seconds": None},
dimension_mappings={
"duration_seconds": {
"source": "dimension",
"key": "duration_seconds",
"required": True,
},
},
strict_mode=False,
)
assert result.status == "incomplete"
assert result.cost == 0.0
assert result.missing_required == ["duration_seconds"]
def test_required_dimension_missing_strict_raises(self) -> None:
engine = FormulaEngine()
with pytest.raises(BillingIncompleteError) as exc:
engine.evaluate(
expression="base_price + duration_seconds * price_per_second",
variables={"base_price": 0.05, "price_per_second": 0.02},
dimensions={"duration_seconds": None},
dimension_mappings={
"duration_seconds": {
"source": "dimension",
"key": "duration_seconds",
"required": True,
},
},
strict_mode=True,
)
assert exc.value.missing_required == ["duration_seconds"]
def test_allow_zero(self) -> None:
engine = FormulaEngine()
# allow_zero=false默认0 视为缺失
result = engine.evaluate(
expression="duration_seconds * 1",
variables={},
dimensions={"duration_seconds": 0},
dimension_mappings={
"duration_seconds": {
"source": "dimension",
"key": "duration_seconds",
"required": True,
},
},
)
assert result.status == "incomplete"
assert result.missing_required == ["duration_seconds"]
# allow_zero=true0 合法
result = engine.evaluate(
expression="duration_seconds * 1",
variables={},
dimensions={"duration_seconds": 0},
dimension_mappings={
"duration_seconds": {
"source": "dimension",
"key": "duration_seconds",
"required": True,
"allow_zero": True,
},
},
)
assert result.status == "complete"
assert result.cost == 0.0
def test_tiered_mapping(self) -> None:
engine = FormulaEngine()
result = engine.evaluate(
expression="input_price",
variables={},
dimensions={"total_input_tokens": 100_000},
dimension_mappings={
"input_price": {
"source": "tiered",
"tier_key": "total_input_tokens",
"tiers": [
{"up_to": 128_000, "value": 3.0},
{"up_to": None, "value": 1.5},
],
}
},
)
assert result.status == "complete"
assert result.cost == 3.0
result = engine.evaluate(
expression="input_price",
variables={},
dimensions={"total_input_tokens": 200_000},
dimension_mappings={
"input_price": {
"source": "tiered",
"tier_key": "total_input_tokens",
"tiers": [
{"up_to": 128_000, "value": 3.0},
{"up_to": None, "value": 1.5},
],
}
},
)
assert result.status == "complete"
assert result.cost == 1.5