refactor: 抽取统一计费模块,支持配置驱动的多厂商计费

- 新增 src/services/billing/ 模块,包含计费计算器、模板和使用量映射
- 将 ChatAdapterBase 和 CliAdapterBase 中的计费逻辑重构为调用 billing 模块
- 为每个 adapter 添加 BILLING_TEMPLATE 类属性,指定计费模板
- 支持 Claude/OpenAI/Gemini 三种计费模板,支持阶梯计费和缓存 TTL 定价
- 新增 tests/services/billing/ 单元测试
This commit is contained in:
fawney19
2026-01-05 16:48:59 +08:00
parent 465da6f818
commit 35e29d46bd
15 changed files with 1649 additions and 224 deletions

View File

View File

@@ -0,0 +1,440 @@
"""
Billing 模块测试
测试计费模块的核心功能:
- BillingCalculator 计费计算
- 计费模板
- 阶梯计费
- calculate_request_cost 便捷函数
"""
import pytest
from src.services.billing import (
BillingCalculator,
BillingDimension,
BillingTemplates,
BillingUnit,
CostBreakdown,
StandardizedUsage,
calculate_request_cost,
)
from src.services.billing.templates import get_template, list_templates
class TestBillingDimension:
"""测试计费维度"""
def test_calculate_per_1m_tokens(self) -> None:
"""测试 per_1m_tokens 计费"""
dim = BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
)
# 1000 tokens * $3 / 1M = $0.003
cost = dim.calculate(1000, 3.0)
assert abs(cost - 0.003) < 0.0001
def test_calculate_per_request(self) -> None:
"""测试按次计费"""
dim = BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
)
# 按次计费cost = request_count * price
cost = dim.calculate(1, 0.05)
assert cost == 0.05
# 多次请求应按次数计费
cost = dim.calculate(3, 0.05)
assert abs(cost - 0.15) < 0.0001
def test_calculate_zero_usage(self) -> None:
"""测试零用量"""
dim = BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
)
cost = dim.calculate(0, 3.0)
assert cost == 0.0
def test_calculate_zero_price(self) -> None:
"""测试零价格"""
dim = BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
)
cost = dim.calculate(1000, 0.0)
assert cost == 0.0
def test_to_dict_and_from_dict(self) -> None:
"""测试序列化和反序列化"""
dim = BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
unit=BillingUnit.PER_1M_TOKENS,
default_price=0.3,
)
d = dim.to_dict()
restored = BillingDimension.from_dict(d)
assert restored.name == dim.name
assert restored.usage_field == dim.usage_field
assert restored.price_field == dim.price_field
assert restored.unit == dim.unit
assert restored.default_price == dim.default_price
class TestStandardizedUsage:
"""测试标准化 Usage"""
def test_basic_usage(self) -> None:
"""测试基础 usage"""
usage = StandardizedUsage(
input_tokens=1000,
output_tokens=500,
)
assert usage.input_tokens == 1000
assert usage.output_tokens == 500
assert usage.cache_creation_tokens == 0
assert usage.cache_read_tokens == 0
def test_get_field(self) -> None:
"""测试字段获取"""
usage = StandardizedUsage(
input_tokens=1000,
output_tokens=500,
)
assert usage.get("input_tokens") == 1000
assert usage.get("nonexistent", 0) == 0
def test_extra_fields(self) -> None:
"""测试扩展字段"""
usage = StandardizedUsage(
input_tokens=1000,
output_tokens=500,
extra={"custom_field": 123},
)
assert usage.get("custom_field") == 123
def test_to_dict(self) -> None:
"""测试转换为字典"""
usage = StandardizedUsage(
input_tokens=1000,
output_tokens=500,
cache_creation_tokens=100,
)
d = usage.to_dict()
assert d["input_tokens"] == 1000
assert d["output_tokens"] == 500
assert d["cache_creation_tokens"] == 100
class TestCostBreakdown:
"""测试费用明细"""
def test_basic_breakdown(self) -> None:
"""测试基础费用明细"""
breakdown = CostBreakdown(
costs={"input": 0.003, "output": 0.0075},
total_cost=0.0105,
)
assert breakdown.input_cost == 0.003
assert breakdown.output_cost == 0.0075
assert breakdown.total_cost == 0.0105
def test_cache_cost_calculation(self) -> None:
"""测试缓存费用汇总"""
breakdown = CostBreakdown(
costs={
"input": 0.003,
"output": 0.0075,
"cache_creation": 0.001,
"cache_read": 0.0005,
},
total_cost=0.012,
)
# cache_cost = cache_creation + cache_read
assert abs(breakdown.cache_cost - 0.0015) < 0.0001
def test_to_dict(self) -> None:
"""测试转换为字典"""
breakdown = CostBreakdown(
costs={"input": 0.003, "output": 0.0075},
total_cost=0.0105,
tier_index=1,
)
d = breakdown.to_dict()
assert d["total_cost"] == 0.0105
assert d["tier_index"] == 1
assert d["input_cost"] == 0.003
class TestBillingTemplates:
"""测试计费模板"""
def test_claude_template(self) -> None:
"""测试 Claude 模板"""
template = BillingTemplates.CLAUDE_STANDARD
dim_names = [d.name for d in template]
assert "input" in dim_names
assert "output" in dim_names
assert "cache_creation" in dim_names
assert "cache_read" in dim_names
def test_openai_template(self) -> None:
"""测试 OpenAI 模板"""
template = BillingTemplates.OPENAI_STANDARD
dim_names = [d.name for d in template]
assert "input" in dim_names
assert "output" in dim_names
assert "cache_read" in dim_names
# OpenAI 没有缓存创建费用
assert "cache_creation" not in dim_names
def test_gemini_template(self) -> None:
"""测试 Gemini 模板"""
template = BillingTemplates.GEMINI_STANDARD
dim_names = [d.name for d in template]
assert "input" in dim_names
assert "output" in dim_names
assert "cache_read" in dim_names
def test_per_request_template(self) -> None:
"""测试按次计费模板"""
template = BillingTemplates.PER_REQUEST
assert len(template) == 1
assert template[0].name == "request"
assert template[0].unit == BillingUnit.PER_REQUEST
def test_get_template(self) -> None:
"""测试获取模板"""
template = get_template("claude")
assert template == BillingTemplates.CLAUDE_STANDARD
template = get_template("openai")
assert template == BillingTemplates.OPENAI_STANDARD
# 不区分大小写
template = get_template("CLAUDE")
assert template == BillingTemplates.CLAUDE_STANDARD
with pytest.raises(ValueError, match="Unknown billing template"):
get_template("unknown_template")
def test_list_templates(self) -> None:
"""测试列出模板"""
templates = list_templates()
assert "claude" in templates
assert "openai" in templates
assert "gemini" in templates
assert "per_request" in templates
class TestBillingCalculator:
"""测试计费计算器"""
def test_basic_calculation(self) -> None:
"""测试基础计费计算"""
calculator = BillingCalculator(template="claude")
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)
# 1000 * 3 / 1M = 0.003
assert abs(result.input_cost - 0.003) < 0.0001
# 500 * 15 / 1M = 0.0075
assert abs(result.output_cost - 0.0075) < 0.0001
# Total = 0.0105
assert abs(result.total_cost - 0.0105) < 0.0001
def test_calculation_with_cache(self) -> None:
"""测试带缓存的计费计算"""
calculator = BillingCalculator(template="claude")
usage = StandardizedUsage(
input_tokens=1000,
output_tokens=500,
cache_creation_tokens=200,
cache_read_tokens=300,
)
prices = {
"input_price_per_1m": 3.0,
"output_price_per_1m": 15.0,
"cache_creation_price_per_1m": 3.75,
"cache_read_price_per_1m": 0.3,
}
result = calculator.calculate(usage, prices)
# cache_creation: 200 * 3.75 / 1M = 0.00075
assert abs(result.cache_creation_cost - 0.00075) < 0.0001
# cache_read: 300 * 0.3 / 1M = 0.00009
assert abs(result.cache_read_cost - 0.00009) < 0.0001
def test_tiered_pricing(self) -> None:
"""测试阶梯计费"""
calculator = BillingCalculator(template="claude")
usage = StandardizedUsage(input_tokens=250000, output_tokens=10000)
# 大于 200k 进入第二阶梯
tiered_pricing = {
"tiers": [
{"up_to": 200000, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0},
{"up_to": None, "input_price_per_1m": 1.5, "output_price_per_1m": 7.5},
]
}
prices = {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
result = calculator.calculate(usage, prices, tiered_pricing)
# 应该使用第二阶梯价格
assert result.tier_index == 1
# 250000 * 1.5 / 1M = 0.375
assert abs(result.input_cost - 0.375) < 0.0001
def test_openai_no_cache_creation(self) -> None:
"""测试 OpenAI 模板没有缓存创建费用"""
calculator = BillingCalculator(template="openai")
usage = StandardizedUsage(
input_tokens=1000,
output_tokens=500,
cache_creation_tokens=200, # 这个不应该计费
cache_read_tokens=300,
)
prices = {
"input_price_per_1m": 3.0,
"output_price_per_1m": 15.0,
"cache_creation_price_per_1m": 3.75,
"cache_read_price_per_1m": 0.3,
}
result = calculator.calculate(usage, prices)
# OpenAI 模板不包含 cache_creation 维度
assert result.cache_creation_cost == 0.0
# 但 cache_read 应该计费
assert result.cache_read_cost > 0
def test_from_config(self) -> None:
"""测试从配置创建计算器"""
config = {"template": "openai"}
calculator = BillingCalculator.from_config(config)
assert calculator.template_name == "openai"
class TestCalculateRequestCost:
"""测试便捷函数"""
def test_basic_usage(self) -> None:
"""测试基础用法"""
result = calculate_request_cost(
input_tokens=1000,
output_tokens=500,
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
input_price_per_1m=3.0,
output_price_per_1m=15.0,
cache_creation_price_per_1m=None,
cache_read_price_per_1m=None,
price_per_request=None,
billing_template="claude",
)
assert "input_cost" in result
assert "output_cost" in result
assert "total_cost" in result
assert abs(result["input_cost"] - 0.003) < 0.0001
assert abs(result["output_cost"] - 0.0075) < 0.0001
def test_with_cache(self) -> None:
"""测试带缓存"""
result = calculate_request_cost(
input_tokens=1000,
output_tokens=500,
cache_creation_input_tokens=200,
cache_read_input_tokens=300,
input_price_per_1m=3.0,
output_price_per_1m=15.0,
cache_creation_price_per_1m=3.75,
cache_read_price_per_1m=0.3,
price_per_request=None,
billing_template="claude",
)
assert result["cache_creation_cost"] > 0
assert result["cache_read_cost"] > 0
assert result["cache_cost"] == result["cache_creation_cost"] + result["cache_read_cost"]
def test_different_templates(self) -> None:
"""测试不同模板"""
prices = {
"input_tokens": 1000,
"output_tokens": 500,
"cache_creation_input_tokens": 200,
"cache_read_input_tokens": 300,
"input_price_per_1m": 3.0,
"output_price_per_1m": 15.0,
"cache_creation_price_per_1m": 3.75,
"cache_read_price_per_1m": 0.3,
"price_per_request": None,
}
# Claude 模板有 cache_creation
result_claude = calculate_request_cost(**prices, billing_template="claude")
assert result_claude["cache_creation_cost"] > 0
# OpenAI 模板没有 cache_creation
result_openai = calculate_request_cost(**prices, billing_template="openai")
assert result_openai["cache_creation_cost"] == 0
def test_tiered_pricing_with_total_context(self) -> None:
"""测试使用自定义 total_input_context 的阶梯计费"""
tiered_pricing = {
"tiers": [
{"up_to": 200000, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0},
{"up_to": None, "input_price_per_1m": 1.5, "output_price_per_1m": 7.5},
]
}
# 传入预计算的 total_input_context
result = calculate_request_cost(
input_tokens=1000,
output_tokens=500,
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
input_price_per_1m=3.0,
output_price_per_1m=15.0,
cache_creation_price_per_1m=None,
cache_read_price_per_1m=None,
price_per_request=None,
tiered_pricing=tiered_pricing,
total_input_context=250000, # 预计算的值,超过 200k
billing_template="claude",
)
# 应该使用第二阶梯价格
assert result["tier_index"] == 1