2026-02-14 20:06:38 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from src.core.api_format.signature import normalize_signature_key
|
|
|
|
|
|
from src.services.billing.token_normalization import normalize_input_tokens_for_billing
|
|
|
|
|
|
from src.services.usage._recording_helpers import (
|
|
|
|
|
|
build_usage_params,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
deserialize_body_if_json,
|
2026-02-14 20:06:38 +08:00
|
|
|
|
sanitize_request_metadata,
|
|
|
|
|
|
)
|
|
|
|
|
|
from src.services.usage._types import UsageCostInfo, UsageRecordParams
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UsageBillingIntegrationMixin:
|
|
|
|
|
|
"""计费集成方法 -- 准备用量记录的共享逻辑"""
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
async def _prepare_usage_record(
|
|
|
|
|
|
cls,
|
|
|
|
|
|
params: UsageRecordParams,
|
|
|
|
|
|
) -> tuple[dict[str, Any], float]:
|
|
|
|
|
|
"""准备用量记录的共享逻辑
|
|
|
|
|
|
|
|
|
|
|
|
此方法提取了 record_usage 和 record_usage_async 的公共处理逻辑:
|
|
|
|
|
|
- 获取费率倍数
|
|
|
|
|
|
- 计算成本
|
|
|
|
|
|
- 构建 Usage 参数
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
params: 用量记录参数数据类
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(usage_params 字典, total_cost 总成本)
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 计费口径以 Provider 为准(优先 endpoint_api_format)
|
|
|
|
|
|
billing_api_format: str | None = None
|
|
|
|
|
|
if params.endpoint_api_format:
|
|
|
|
|
|
try:
|
|
|
|
|
|
billing_api_format = normalize_signature_key(str(params.endpoint_api_format))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
billing_api_format = None
|
|
|
|
|
|
if billing_api_format is None and params.api_format:
|
|
|
|
|
|
try:
|
|
|
|
|
|
billing_api_format = normalize_signature_key(str(params.api_format))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
billing_api_format = None
|
|
|
|
|
|
|
|
|
|
|
|
input_tokens_for_billing = normalize_input_tokens_for_billing(
|
|
|
|
|
|
billing_api_format,
|
|
|
|
|
|
params.input_tokens,
|
|
|
|
|
|
params.cache_read_input_tokens,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取费率倍数和是否免费套餐(传递 api_format 支持按格式配置的倍率)
|
|
|
|
|
|
actual_rate_multiplier, is_free_tier = await cls._get_rate_multiplier_and_free_tier(
|
|
|
|
|
|
params.db, params.provider_api_key_id, params.provider_id, billing_api_format
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
metadata = dict(params.metadata or {})
|
|
|
|
|
|
is_failed_request = params.status_code >= 400 or params.error_message is not None
|
|
|
|
|
|
|
|
|
|
|
|
# Helper: compute billing task_type (billing domain)
|
|
|
|
|
|
billing_task_type = (params.request_type or "").lower()
|
|
|
|
|
|
if billing_task_type not in {"chat", "cli", "video", "image", "audio"}:
|
|
|
|
|
|
billing_task_type = "chat"
|
|
|
|
|
|
|
|
|
|
|
|
# 使用新计费系统计算费用
|
|
|
|
|
|
from src.services.billing.service import BillingService
|
|
|
|
|
|
|
|
|
|
|
|
request_count = 0 if is_failed_request else 1
|
2026-03-06 00:14:10 +08:00
|
|
|
|
has_cache_tokens = bool(
|
|
|
|
|
|
params.cache_creation_input_tokens > 0 or params.cache_read_input_tokens > 0
|
|
|
|
|
|
)
|
|
|
|
|
|
effective_cache_ttl_minutes = params.cache_ttl_minutes
|
|
|
|
|
|
|
|
|
|
|
|
# 主链路很多场景不会显式传 cache_ttl_minutes,这里补全以确保 1h/5m TTL 差异化计价生效。
|
|
|
|
|
|
if effective_cache_ttl_minutes is None and has_cache_tokens and params.provider_api_key_id:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.models.database import ProviderAPIKey
|
|
|
|
|
|
|
|
|
|
|
|
key_ttl = (
|
|
|
|
|
|
params.db.query(ProviderAPIKey.cache_ttl_minutes)
|
|
|
|
|
|
.filter(ProviderAPIKey.id == params.provider_api_key_id)
|
|
|
|
|
|
.scalar()
|
|
|
|
|
|
)
|
|
|
|
|
|
if key_ttl is not None:
|
|
|
|
|
|
key_ttl_int = int(key_ttl)
|
|
|
|
|
|
if key_ttl_int >= 0:
|
|
|
|
|
|
effective_cache_ttl_minutes = key_ttl_int
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# Best-effort fallback below.
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# 无法从 key 获取时,尽量从 5m/1h 细分回推(主要覆盖 Claude cache_creation)。
|
|
|
|
|
|
if effective_cache_ttl_minutes is None and has_cache_tokens:
|
|
|
|
|
|
t5m = int(params.cache_creation_input_tokens_5m or 0)
|
|
|
|
|
|
t1h = int(params.cache_creation_input_tokens_1h or 0)
|
|
|
|
|
|
if t1h > 0 and t5m == 0:
|
|
|
|
|
|
effective_cache_ttl_minutes = 60
|
|
|
|
|
|
elif t5m > 0 and t1h == 0:
|
|
|
|
|
|
effective_cache_ttl_minutes = 5
|
|
|
|
|
|
elif t1h > 0:
|
|
|
|
|
|
# 混合场景优先按长 TTL 计,避免 1h 缓存被按 5m 误计。
|
|
|
|
|
|
effective_cache_ttl_minutes = 60
|
|
|
|
|
|
|
2026-02-14 20:06:38 +08:00
|
|
|
|
dims: dict[str, Any] = {
|
|
|
|
|
|
"input_tokens": input_tokens_for_billing,
|
|
|
|
|
|
"output_tokens": params.output_tokens,
|
|
|
|
|
|
"cache_creation_input_tokens": params.cache_creation_input_tokens,
|
|
|
|
|
|
"cache_read_input_tokens": params.cache_read_input_tokens,
|
|
|
|
|
|
"request_count": request_count,
|
|
|
|
|
|
}
|
2026-03-06 00:14:10 +08:00
|
|
|
|
if effective_cache_ttl_minutes is not None:
|
|
|
|
|
|
dims["cache_ttl_minutes"] = effective_cache_ttl_minutes
|
2026-02-14 20:06:38 +08:00
|
|
|
|
# If tiered pricing is disabled, force first tier by using tier-key=0.
|
|
|
|
|
|
if not params.use_tiered_pricing:
|
|
|
|
|
|
dims["total_input_context"] = 0
|
|
|
|
|
|
|
|
|
|
|
|
billing = BillingService(params.db)
|
|
|
|
|
|
result = billing.calculate(
|
|
|
|
|
|
task_type=billing_task_type,
|
|
|
|
|
|
model=params.model,
|
|
|
|
|
|
provider_id=params.provider_id or "",
|
|
|
|
|
|
dimensions=dims,
|
|
|
|
|
|
strict_mode=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
snap = result.snapshot
|
|
|
|
|
|
|
|
|
|
|
|
breakdown = snap.cost_breakdown or {}
|
|
|
|
|
|
input_cost = float(breakdown.get("input_cost", 0.0))
|
|
|
|
|
|
output_cost = float(breakdown.get("output_cost", 0.0))
|
|
|
|
|
|
cache_creation_cost = float(breakdown.get("cache_creation_cost", 0.0))
|
|
|
|
|
|
cache_read_cost = float(breakdown.get("cache_read_cost", 0.0))
|
|
|
|
|
|
request_cost = float(breakdown.get("request_cost", 0.0))
|
|
|
|
|
|
cache_cost = cache_creation_cost + cache_read_cost
|
|
|
|
|
|
total_cost = float(snap.total_cost or 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
rv = snap.resolved_variables or {}
|
|
|
|
|
|
|
|
|
|
|
|
def _as_float(v: Any, d: float | None) -> float | None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if v is None:
|
|
|
|
|
|
return d
|
|
|
|
|
|
return float(v)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
input_price = _as_float(rv.get("input_price_per_1m"), 0.0) or 0.0
|
|
|
|
|
|
output_price = _as_float(rv.get("output_price_per_1m"), 0.0) or 0.0
|
|
|
|
|
|
cache_creation_price = _as_float(rv.get("cache_creation_price_per_1m"), None)
|
|
|
|
|
|
cache_read_price = _as_float(rv.get("cache_read_price_per_1m"), None)
|
|
|
|
|
|
request_price = _as_float(rv.get("price_per_request"), None)
|
|
|
|
|
|
|
|
|
|
|
|
# Audit snapshot (pruned later by sanitize_request_metadata)
|
|
|
|
|
|
metadata["billing_snapshot"] = snap.to_dict()
|
|
|
|
|
|
|
|
|
|
|
|
# Best-effort prune metadata to reduce DB/memory pressure.
|
|
|
|
|
|
metadata = sanitize_request_metadata(metadata)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建 Usage 参数
|
2026-03-14 11:59:07 +08:00
|
|
|
|
request_body = deserialize_body_if_json(params.request_body)
|
|
|
|
|
|
provider_request_body = deserialize_body_if_json(params.provider_request_body)
|
|
|
|
|
|
response_body = deserialize_body_if_json(params.response_body)
|
|
|
|
|
|
client_response_body = deserialize_body_if_json(params.client_response_body)
|
2026-02-14 20:06:38 +08:00
|
|
|
|
usage_params = build_usage_params(
|
|
|
|
|
|
db=params.db,
|
|
|
|
|
|
user=params.user,
|
|
|
|
|
|
api_key=params.api_key,
|
|
|
|
|
|
provider=params.provider,
|
|
|
|
|
|
model=params.model,
|
|
|
|
|
|
input_tokens=input_tokens_for_billing,
|
|
|
|
|
|
output_tokens=params.output_tokens,
|
|
|
|
|
|
cache_creation_input_tokens=params.cache_creation_input_tokens,
|
|
|
|
|
|
cache_read_input_tokens=params.cache_read_input_tokens,
|
2026-02-28 11:44:08 +08:00
|
|
|
|
cache_creation_input_tokens_5m=params.cache_creation_input_tokens_5m,
|
|
|
|
|
|
cache_creation_input_tokens_1h=params.cache_creation_input_tokens_1h,
|
2026-02-14 20:06:38 +08:00
|
|
|
|
request_type=params.request_type,
|
|
|
|
|
|
api_format=params.api_format,
|
2026-02-21 13:17:11 +08:00
|
|
|
|
api_family=params.api_family,
|
|
|
|
|
|
endpoint_kind=params.endpoint_kind,
|
2026-02-14 20:06:38 +08:00
|
|
|
|
endpoint_api_format=params.endpoint_api_format,
|
|
|
|
|
|
has_format_conversion=params.has_format_conversion,
|
|
|
|
|
|
is_stream=params.is_stream,
|
|
|
|
|
|
response_time_ms=params.response_time_ms,
|
|
|
|
|
|
first_byte_time_ms=params.first_byte_time_ms,
|
|
|
|
|
|
status_code=params.status_code,
|
|
|
|
|
|
error_message=params.error_message,
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
request_headers=params.request_headers,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
request_body=request_body,
|
2026-02-14 20:06:38 +08:00
|
|
|
|
provider_request_headers=params.provider_request_headers,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
provider_request_body=provider_request_body,
|
2026-02-14 20:06:38 +08:00
|
|
|
|
response_headers=params.response_headers,
|
|
|
|
|
|
client_response_headers=params.client_response_headers,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
response_body=response_body,
|
|
|
|
|
|
client_response_body=client_response_body,
|
2026-02-14 20:06:38 +08:00
|
|
|
|
request_id=params.request_id,
|
|
|
|
|
|
provider_id=params.provider_id,
|
|
|
|
|
|
provider_endpoint_id=params.provider_endpoint_id,
|
|
|
|
|
|
provider_api_key_id=params.provider_api_key_id,
|
|
|
|
|
|
status=params.status,
|
|
|
|
|
|
target_model=params.target_model,
|
|
|
|
|
|
cost=UsageCostInfo(
|
|
|
|
|
|
input_cost=input_cost,
|
|
|
|
|
|
output_cost=output_cost,
|
|
|
|
|
|
cache_creation_cost=cache_creation_cost,
|
|
|
|
|
|
cache_read_cost=cache_read_cost,
|
|
|
|
|
|
cache_cost=cache_cost,
|
|
|
|
|
|
request_cost=request_cost,
|
|
|
|
|
|
total_cost=total_cost,
|
|
|
|
|
|
input_price=input_price,
|
|
|
|
|
|
output_price=output_price,
|
|
|
|
|
|
cache_creation_price=cache_creation_price,
|
|
|
|
|
|
cache_read_price=cache_read_price,
|
|
|
|
|
|
request_price=request_price,
|
|
|
|
|
|
actual_rate_multiplier=actual_rate_multiplier,
|
|
|
|
|
|
is_free_tier=is_free_tier,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return usage_params, total_cost
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
async def _prepare_usage_records_batch(
|
|
|
|
|
|
cls,
|
|
|
|
|
|
params_list: list[UsageRecordParams],
|
|
|
|
|
|
) -> list[tuple[dict[str, Any], float, Exception | None]]:
|
|
|
|
|
|
"""批量并行准备用量记录(性能优化)
|
|
|
|
|
|
|
|
|
|
|
|
并行调用 _prepare_usage_record,提高批量处理效率。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
params_list: 用量记录参数列表
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
列表,每项为 (usage_params, total_cost, exception)
|
|
|
|
|
|
如果处理成功,exception 为 None
|
|
|
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
async def prepare_single(
|
|
|
|
|
|
params: UsageRecordParams,
|
|
|
|
|
|
) -> tuple[dict[str, Any], float, Exception | None]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
usage_params, total_cost = await cls._prepare_usage_record(params)
|
|
|
|
|
|
return (usage_params, total_cost, None)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return ({}, 0.0, e)
|
|
|
|
|
|
|
|
|
|
|
|
if not params_list:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
# 避免一次性创建过多 task(并且 _prepare_usage_record 内部也可能包含并行调用)
|
|
|
|
|
|
# 这里采用分批 gather 来限制并发量。
|
|
|
|
|
|
chunk_size = 50
|
|
|
|
|
|
results: list[tuple[dict[str, Any], float, Exception | None]] = []
|
|
|
|
|
|
for i in range(0, len(params_list), chunk_size):
|
|
|
|
|
|
chunk = params_list[i : i + chunk_size]
|
|
|
|
|
|
chunk_results = await asyncio.gather(*(prepare_single(p) for p in chunk))
|
|
|
|
|
|
results.extend(chunk_results)
|
|
|
|
|
|
return results
|