mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
169
tests/services/billing/test_formula_engine.py
Normal file
169
tests/services/billing/test_formula_engine.py
Normal 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=true:0 合法
|
||||
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
|
||||
Reference in New Issue
Block a user