feat: 性能监控基础设施、解密缓存及计费简化

- 新增 PerfRecorder 性能记录工具,支持采样率与慢请求日志
- 在请求管道中埋点:auth、body_read、json_parse、context_build、authorize、handle
- 流处理器增加 parse/conversion 耗时追踪与 perf_metrics 落库
- 解密服务添加 LRU 缓存,降低高频解密 CPU 开销
- 格式转换分层开关设计:全局 OFF 时回退到端点配置,而非一刀切拒绝
- 移除 shadow billing 模块,统一使用新计费引擎
- 新增 Codex 网关请求适配器(store=false、role 映射、include 补齐)
- endpoint 创建接口支持 body_rules 参数
This commit is contained in:
fawney19
2026-02-05 14:22:11 +08:00
parent e72e5370c4
commit ed2ff5c1d7
25 changed files with 836 additions and 963 deletions

View File

@@ -31,7 +31,6 @@ from src.services.billing.models import (
)
from src.services.billing.schema import BillingSnapshot, CostResult
from src.services.billing.service import BillingService
from src.services.billing.shadow import ShadowBillingService
from src.services.billing.templates import BILLING_TEMPLATE_REGISTRY, BillingTemplates
from src.services.billing.usage_mapper import UsageMapper, map_usage, map_usage_from_response
@@ -47,11 +46,10 @@ __all__ = [
# 计算器
"BillingCalculator",
"calculate_request_cost",
# 统一入口Phase2
# 统一入口
"BillingService",
"BillingSnapshot",
"CostResult",
"ShadowBillingService",
# 映射器
"UsageMapper",
"map_usage",

View File

@@ -1,323 +0,0 @@
"""
Shadow billing (reconciliation period).
This module runs the new billing engine alongside the legacy billing outcome.
Truth vs Shadow is kept strictly separated:
- truth_breakdown: the values written into Usage rows (the "billable truth")
- shadow_snapshot: new engine snapshot stored only in request_metadata.billing_shadow
Runtime switch:
- config.billing_engine: legacy | shadow | new_with_fallback | new
- config.billing_engine_overrides: JSON mapping of "provider/model" patterns -> mode
"""
from __future__ import annotations
import fnmatch
import json
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Literal
from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.logger import logger
from src.core.metrics import (
billing_diff_exceeds_threshold_total,
billing_fallback_total,
billing_invariant_violation_total,
billing_requests_total,
)
from src.services.billing.schema import BillingSnapshot
from src.services.billing.service import BillingService
EngineMode = Literal["legacy", "shadow", "new_with_fallback", "new"]
TruthEngine = Literal["legacy", "new"]
@lru_cache(maxsize=32)
def _compile_engine_overrides(overrides_raw: str) -> tuple[dict[str, str], list[tuple[str, str]]]:
"""
Parse and normalize engine overrides.
Cached to avoid json.loads + dict walk on every request.
"""
try:
overrides = json.loads(overrides_raw or "{}")
except Exception:
overrides = {}
exact: dict[str, str] = {}
patterns: list[tuple[str, str]] = []
if isinstance(overrides, dict):
for pattern, mode in overrides.items():
p = str(pattern)
m = str(mode).strip().lower()
# fnmatch supports *, ?, and [] character classes.
if any(ch in p for ch in ("*", "?", "[")):
patterns.append((p, m))
else:
exact[p] = m
return exact, patterns
@lru_cache(maxsize=4096)
def _resolve_engine_mode_cached(key: str, base_mode: str, overrides_raw: str) -> str:
exact, patterns = _compile_engine_overrides(overrides_raw)
if key in exact:
return exact[key]
for pattern, mode in patterns:
try:
if fnmatch.fnmatch(key, pattern):
return mode
except Exception:
continue
return base_mode
def resolve_engine_mode(provider: str, model: str) -> EngineMode:
"""Resolve engine mode with overrides (pure function, no DB)."""
base_mode = (config.billing_engine or "legacy").strip().lower()
overrides_raw = getattr(config, "billing_engine_overrides", "{}") or "{}"
key = f"{provider}/{model}"
return _resolve_engine_mode_cached(key, base_mode, overrides_raw) # type: ignore[return-value]
@dataclass(frozen=True)
class CostBreakdown:
"""Cost breakdown written into Usage rows (truth)."""
input_cost: float
output_cost: float
cache_creation_cost: float
cache_read_cost: float
request_cost: float
total_cost: float
@property
def cache_cost(self) -> float:
return float(self.cache_creation_cost) + float(self.cache_read_cost)
def validate(self) -> bool:
"""
Invariant: total_cost == sum(components) (within tiny tolerance).
For new engine we quantize and sum components deterministically, so this should be exact.
For legacy floats, we allow a tiny epsilon.
"""
computed_total = (
float(self.input_cost)
+ float(self.output_cost)
+ float(self.cache_creation_cost)
+ float(self.cache_read_cost)
+ float(self.request_cost)
)
return abs(computed_total - float(self.total_cost)) < 1e-8
@dataclass(frozen=True)
class ShadowBillingResult:
# billable truth (written to Usage table)
truth_breakdown: CostBreakdown
# shadow snapshot (written to request_metadata.billing_shadow only)
shadow_snapshot: BillingSnapshot | None
# reconciliation information (diffs etc.)
comparison: dict[str, Any]
# policy vs actual
engine_mode: EngineMode = "legacy"
truth_engine: TruthEngine = "legacy"
was_fallback: bool = False
class ShadowBillingService:
"""
Shadow billing orchestrator.
This service does NOT write DB rows. Callers decide how to persist truth and shadow data.
"""
def __init__(self, db: Session) -> None:
self.db = db
# Lazy init: many call sites only need resolve_engine_mode(), and legacy mode
# should not pay the cost of constructing BillingService.
self._new_billing: BillingService | None = None
def _get_new_billing(self) -> BillingService:
if self._new_billing is None:
self._new_billing = BillingService(self.db)
return self._new_billing
def get_engine_mode(self, provider: str, model: str) -> EngineMode:
return resolve_engine_mode(provider, model)
def calculate_with_shadow(
self,
*,
provider: str,
provider_id: str | None,
model: str,
task_type: str,
api_format: str | None,
input_tokens: int,
output_tokens: int,
cache_creation_input_tokens: int = 0,
cache_read_input_tokens: int = 0,
cache_ttl_minutes: int | None = None,
legacy_truth: CostBreakdown,
is_failed_request: bool,
) -> ShadowBillingResult:
"""
Compute shadow billing outcome given the legacy truth.
Notes:
- When engine_mode is legacy, we skip new engine calculation.
- When engine_mode is shadow, we compute new engine snapshot and compare, but keep truth legacy.
- new/new_with_fallback are supported for later phases; callers can choose to honor truth_engine.
"""
engine_mode = resolve_engine_mode(provider, model)
# Default response (legacy only)
if engine_mode == "legacy":
billing_requests_total.labels(engine_mode=engine_mode, truth_engine="legacy").inc()
return ShadowBillingResult(
truth_breakdown=legacy_truth,
shadow_snapshot=None,
comparison={"engine_mode": engine_mode},
engine_mode=engine_mode,
truth_engine="legacy",
was_fallback=False,
)
# Build dimensions for new engine
request_count = 0 if is_failed_request else 1
dimensions: dict[str, Any] = {
"input_tokens": int(input_tokens or 0),
"output_tokens": int(output_tokens or 0),
"cache_creation_input_tokens": int(cache_creation_input_tokens or 0),
"cache_read_input_tokens": int(cache_read_input_tokens or 0),
"request_count": int(request_count),
}
if cache_ttl_minutes is not None:
dimensions["cache_ttl_minutes"] = int(cache_ttl_minutes)
# Normalize task_type
tt = (task_type or "").lower()
if tt not in {"chat", "cli", "video", "image", "audio"}:
tt = "chat"
new_result = self._get_new_billing().calculate(
task_type=tt,
model=model,
provider_id=provider_id or "",
dimensions=dimensions,
strict_mode=None,
)
shadow_snapshot = new_result.snapshot
new_breakdown = CostBreakdown(
input_cost=float(shadow_snapshot.cost_breakdown.get("input_cost", 0.0)),
output_cost=float(shadow_snapshot.cost_breakdown.get("output_cost", 0.0)),
cache_creation_cost=float(
shadow_snapshot.cost_breakdown.get("cache_creation_cost", 0.0)
),
cache_read_cost=float(shadow_snapshot.cost_breakdown.get("cache_read_cost", 0.0)),
request_cost=float(shadow_snapshot.cost_breakdown.get("request_cost", 0.0)),
total_cost=float(shadow_snapshot.total_cost),
)
diff = abs(float(new_breakdown.total_cost) - float(legacy_truth.total_cost))
diff_pct = (
(diff / float(legacy_truth.total_cost) * 100.0) if legacy_truth.total_cost > 0 else 0.0
)
comparison = {
"engine_mode": engine_mode,
"old_total": legacy_truth.total_cost,
"new_total": new_breakdown.total_cost,
"diff_usd": diff,
"diff_pct": diff_pct,
"breakdown_diff": {
"input_cost": new_breakdown.input_cost - legacy_truth.input_cost,
"output_cost": new_breakdown.output_cost - legacy_truth.output_cost,
"cache_creation_cost": new_breakdown.cache_creation_cost
- legacy_truth.cache_creation_cost,
"cache_read_cost": new_breakdown.cache_read_cost - legacy_truth.cache_read_cost,
"request_cost": new_breakdown.request_cost - legacy_truth.request_cost,
},
}
# Diff logging / metrics
threshold = float(getattr(config, "billing_diff_threshold_usd", 0.0001) or 0.0001)
if diff > threshold:
billing_diff_exceeds_threshold_total.labels(engine_mode=engine_mode).inc()
log_level = (
(getattr(config, "billing_shadow_log_level", "INFO") or "INFO").strip().lower()
)
log_fn = getattr(logger, log_level, logger.info)
log_fn(
"Billing diff detected: provider={}, model={}, old={:.8f}, new={:.8f}, diff={:.8f} ({:.4f}%), mode={}",
provider,
model,
legacy_truth.total_cost,
new_breakdown.total_cost,
diff,
diff_pct,
engine_mode,
)
# Invariant monitoring (should be 0)
truth_engine: TruthEngine = "legacy"
was_fallback = False
if engine_mode == "shadow":
truth_engine = "legacy"
truth = legacy_truth
elif engine_mode == "new":
truth_engine = "new"
truth = new_breakdown
elif engine_mode == "new_with_fallback":
# new is truth unless diff is too large
fallback_threshold = threshold * 10.0
if diff > fallback_threshold:
truth_engine = "legacy"
truth = legacy_truth
was_fallback = True
billing_fallback_total.inc()
else:
truth_engine = "new"
truth = new_breakdown
else:
# Unknown value -> behave like legacy
truth_engine = "legacy"
truth = legacy_truth
billing_requests_total.labels(engine_mode=engine_mode, truth_engine=truth_engine).inc()
if not truth.validate():
billing_invariant_violation_total.labels(
engine_mode=engine_mode, truth_engine=truth_engine
).inc()
logger.warning(
"Billing invariant violation: provider={}, model={}, engine_mode={}, truth_engine={}, truth_total={}",
provider,
model,
engine_mode,
truth_engine,
truth.total_cost,
)
return ShadowBillingResult(
truth_breakdown=truth,
shadow_snapshot=(
shadow_snapshot if engine_mode in {"shadow", "new_with_fallback", "new"} else None
),
comparison=comparison,
engine_mode=engine_mode,
truth_engine=truth_engine,
was_fallback=was_fallback,
)

View File

@@ -0,0 +1,111 @@
"""
Codex provider request patching helpers.
Codex (OpenAI-compatible) gateways may reject or behave unexpectedly with some parameters in
OpenAI CLI / Responses-style requests. These helpers apply a minimal, safe transformation:
- Force `store=false` (avoid persistence features not supported by some gateways).
- Ensure `instructions` exists (Codex expects it in some deployments).
- Convert `role=system` messages to `role=developer` (Codex may not accept `system`).
- Drop request parameters known to be rejected by Codex gateways.
- Ensure `include` contains "reasoning.encrypted_content" for parity with CLI behavior.
"""
from __future__ import annotations
from typing import Any
_REJECTED_PARAMS: frozenset[str] = frozenset(
{
"max_output_tokens",
"max_completion_tokens",
"max_tokens",
"temperature",
"top_p",
"service_tier",
}
)
_REQUIRED_INCLUDE_ITEM = "reasoning.encrypted_content"
def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str, Any]:
"""
Patch an OpenAI CLI (Responses API style) request body for Codex gateways.
This function never mutates the input object.
"""
out: dict[str, Any] = dict(request_body)
for k in _REJECTED_PARAMS:
out.pop(k, None)
# Codex gateways often reject/ignore persistence; be explicit.
out["store"] = False
# Ensure instructions exists (some gateways require it even if empty).
instructions = out.get("instructions")
if not isinstance(instructions, str):
out["instructions"] = "You are a helpful coding assistant."
# Convert "system" role to "developer" (Codex behavior).
input_items = out.get("input")
if isinstance(input_items, list):
patched_items: list[Any] = []
for item in input_items:
if isinstance(item, dict):
patched = dict(item)
if patched.get("role") == "system":
patched["role"] = "developer"
patched_items.append(patched)
else:
patched_items.append(item)
out["input"] = patched_items
# Ensure required include item exists.
include = out.get("include")
if include is None:
out["include"] = [_REQUIRED_INCLUDE_ITEM]
elif isinstance(include, str):
out["include"] = (
[include] if include == _REQUIRED_INCLUDE_ITEM else [include, _REQUIRED_INCLUDE_ITEM]
)
elif isinstance(include, (list, tuple, set)):
include_list = list(include)
if _REQUIRED_INCLUDE_ITEM not in include_list:
include_list.append(_REQUIRED_INCLUDE_ITEM)
out["include"] = include_list
else:
# Unknown type; overwrite to keep behavior deterministic.
out["include"] = [_REQUIRED_INCLUDE_ITEM]
return out
def maybe_patch_request_for_codex(
*,
provider_type: str | None,
provider_api_format: str | None,
request_body: Any,
) -> Any:
"""
Conditionally patch request body for Codex gateways.
No-op for:
- Non-Codex providers
- Non OpenAI CLI / Responses-style endpoints
- Non-dict request bodies
"""
if (provider_type or "").lower() != "codex":
return request_body
if (provider_api_format or "").lower() != "openai:cli":
return request_body
if not isinstance(request_body, dict):
return request_body
return patch_openai_cli_request_for_codex(request_body)
__all__ = [
"maybe_patch_request_for_codex",
"patch_openai_cli_request_for_codex",
]

View File

@@ -410,171 +410,6 @@ class UsageService:
db, provider_api_key_id, provider_id, api_format
)
@classmethod
async def _calculate_costs(
cls,
db: Session,
provider: str,
model: str,
input_tokens: int,
output_tokens: int,
cache_creation_input_tokens: int,
cache_read_input_tokens: int,
api_format: str | None,
cache_ttl_minutes: int | None,
use_tiered_pricing: bool,
is_failed_request: bool,
) -> tuple[
float,
float,
float,
float,
float,
float,
float,
float,
float,
float | None,
float | None,
float | None,
int | None,
]:
"""计算所有成本相关数据
Returns:
(input_price, output_price, cache_creation_price, cache_read_price, request_price,
input_cost, output_cost, cache_creation_cost, cache_read_cost, cache_cost,
request_cost, total_cost, tier_index)
"""
import asyncio
service = ModelCostService(db)
# 并行获取模型价格、按次计费价格;阶梯计费时额外获取 tiered 配置
price_task = service.get_model_price_async(provider, model)
request_price_task = service.get_request_price_async(provider, model)
tiered_pricing: dict | None = None
if use_tiered_pricing:
tiered_pricing_task = service.get_tiered_pricing_async(provider, model)
(input_price, output_price), request_price, tiered_pricing = await asyncio.gather(
price_task, request_price_task, tiered_pricing_task
)
else:
(input_price, output_price), request_price = await asyncio.gather(
price_task, request_price_task
)
# 缓存价格依赖 input_price需要串行获取
cache_creation_price, cache_read_price = await service.get_cache_prices_async(
provider, model, input_price
)
effective_request_price = None if is_failed_request else request_price
# 初始化成本变量
input_cost = 0.0
output_cost = 0.0
cache_creation_cost = 0.0
cache_read_cost = 0.0
cache_cost = 0.0
request_cost = 0.0
total_cost = 0.0
tier_index = None
if use_tiered_pricing:
# 使用与 ModelCostService.compute_cost_with_strategy_async 一致的 adapter 逻辑,
# 但复用本方法已获取的价格/配置,避免重复 I/O。
adapter = None
if api_format:
from src.api.handlers.base.chat_adapter_base import get_adapter_instance
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_instance
adapter = get_adapter_instance(api_format)
if adapter is None:
adapter = get_cli_adapter_instance(api_format)
if adapter:
result = adapter.compute_cost(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
input_price_per_1m=input_price,
output_price_per_1m=output_price,
cache_creation_price_per_1m=cache_creation_price,
cache_read_price_per_1m=cache_read_price,
price_per_request=effective_request_price,
tiered_pricing=tiered_pricing,
cache_ttl_minutes=cache_ttl_minutes,
)
input_cost = result["input_cost"]
output_cost = result["output_cost"]
cache_creation_cost = result["cache_creation_cost"]
cache_read_cost = result["cache_read_cost"]
cache_cost = result["cache_cost"]
request_cost = result["request_cost"]
total_cost = result["total_cost"]
tier_index = result.get("tier_index")
else:
(
input_cost,
output_cost,
cache_creation_cost,
cache_read_cost,
cache_cost,
request_cost,
total_cost,
tier_index,
) = ModelCostService.compute_cost_with_tiered_pricing(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
tiered_pricing=tiered_pricing,
cache_ttl_minutes=cache_ttl_minutes,
price_per_request=effective_request_price,
fallback_input_price_per_1m=input_price,
fallback_output_price_per_1m=output_price,
fallback_cache_creation_price_per_1m=cache_creation_price,
fallback_cache_read_price_per_1m=cache_read_price,
)
else:
(
input_cost,
output_cost,
cache_creation_cost,
cache_read_cost,
cache_cost,
request_cost,
total_cost,
) = cls.calculate_cost(
input_tokens=input_tokens,
output_tokens=output_tokens,
input_price_per_1m=input_price,
output_price_per_1m=output_price,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
cache_creation_price_per_1m=cache_creation_price,
cache_read_price_per_1m=cache_read_price,
price_per_request=effective_request_price,
)
return (
input_price,
output_price,
cache_creation_price,
cache_read_price,
request_price,
input_cost,
output_cost,
cache_creation_cost,
cache_read_cost,
cache_cost,
request_cost,
total_cost,
tier_index,
)
@staticmethod
def _update_existing_usage(
existing_usage: Usage,
@@ -780,8 +615,8 @@ class UsageService:
_METADATA_KEEP_KEYS: frozenset[str] = frozenset(
{
"billing_snapshot",
"billing_shadow",
"billing_updated_at",
"perf",
"_metadata_truncated",
}
)
@@ -871,163 +706,65 @@ class UsageService:
metadata = dict(params.metadata or {})
is_failed_request = params.status_code >= 400 or params.error_message is not None
# Resolve engine mode early to avoid unnecessary legacy computations.
from src.services.billing.shadow import resolve_engine_mode
engine_mode = resolve_engine_mode(params.provider, params.model)
# 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"
# Defaults (filled by either legacy or new path)
input_price: float = 0.0
output_price: float = 0.0
cache_creation_price: float | None = None
cache_read_price: float | None = None
request_price: float | None = None
# 使用新计费系统计算费用
from src.services.billing.service import BillingService
input_cost: float = 0.0
output_cost: float = 0.0
cache_creation_cost: float = 0.0
cache_read_cost: float = 0.0
cache_cost: float = 0.0
request_cost: float = 0.0
total_cost: float = 0.0
request_count = 0 if is_failed_request else 1
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,
}
if params.cache_ttl_minutes is not None:
dims["cache_ttl_minutes"] = params.cache_ttl_minutes
# If tiered pricing is disabled, force first tier by using tier-key=0.
if not params.use_tiered_pricing:
dims["total_input_context"] = 0
# ------------------------------------------------------------------
# NEW: new engine as truth (no reconciliation)
# ------------------------------------------------------------------
if engine_mode == "new":
from src.services.billing.service import BillingService
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
request_count = 0 if is_failed_request else 1
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,
}
if params.cache_ttl_minutes is not None:
dims["cache_ttl_minutes"] = params.cache_ttl_minutes
# If tiered pricing is disabled, force first tier by using tier-key=0.
if not params.use_tiered_pricing:
dims["total_input_context"] = 0
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)
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
rv = snap.resolved_variables or {}
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:
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)
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 for new engine (pruned later by _sanitize_request_metadata)
metadata["billing_snapshot"] = snap.to_dict()
# ------------------------------------------------------------------
# LEGACY truth (legacy or shadow or new_with_fallback)
# ------------------------------------------------------------------
else:
(
input_price,
output_price,
cache_creation_price,
cache_read_price,
request_price,
input_cost,
output_cost,
cache_creation_cost,
cache_read_cost,
cache_cost,
request_cost,
total_cost,
_tier_index,
) = await cls._calculate_costs(
db=params.db,
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,
api_format=billing_api_format,
cache_ttl_minutes=params.cache_ttl_minutes,
use_tiered_pricing=params.use_tiered_pricing,
is_failed_request=is_failed_request,
)
# Shadow mode: compute new snapshot and store in metadata.billing_shadow only.
if engine_mode == "shadow":
try:
from src.services.billing.shadow import CostBreakdown as ShadowCostBreakdown
from src.services.billing.shadow import (
ShadowBillingService,
)
legacy_truth = ShadowCostBreakdown(
input_cost=input_cost,
output_cost=output_cost,
cache_creation_cost=cache_creation_cost,
cache_read_cost=cache_read_cost,
request_cost=request_cost,
total_cost=total_cost,
)
shadow = ShadowBillingService(params.db)
shadow_result = shadow.calculate_with_shadow(
provider=params.provider,
provider_id=params.provider_id,
model=params.model,
task_type=billing_task_type,
api_format=billing_api_format,
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,
cache_ttl_minutes=params.cache_ttl_minutes,
legacy_truth=legacy_truth,
is_failed_request=is_failed_request,
)
if shadow_result.shadow_snapshot is not None:
metadata["billing_shadow"] = {
"engine_mode": shadow_result.engine_mode,
"truth_engine": shadow_result.truth_engine,
"was_fallback": shadow_result.was_fallback,
"comparison": shadow_result.comparison,
"snapshot": shadow_result.shadow_snapshot.to_dict(),
}
except Exception as exc:
logger.debug("Shadow billing skipped/failed: {}", str(exc))
# Audit snapshot (pruned later by _sanitize_request_metadata)
metadata["billing_snapshot"] = snap.to_dict()
# Best-effort prune metadata to reduce DB/memory pressure.
metadata = cls._sanitize_request_metadata(metadata)

View File

@@ -34,15 +34,20 @@ class DbTelemetryWriter(TelemetryWriter):
# MessageTelemetry 不支持的参数,需要过滤掉
# - request_type: MessageTelemetry 内部固定为 "chat",无需外部传入
# - metadata: MessageTelemetry 不支持额外元数据字段
_IGNORED_KWARGS = frozenset({"request_type", "metadata"})
# - metadata: 由本 writer 映射到 request_metadata用于落库追踪信息
_IGNORED_KWARGS = frozenset({"request_type"})
def __init__(self, telemetry: MessageTelemetry) -> None:
self._telemetry = telemetry
def _filter_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""过滤掉 MessageTelemetry 不支持的参数"""
return {k: v for k, v in kwargs.items() if k not in self._IGNORED_KWARGS}
out = {k: v for k, v in kwargs.items() if k not in self._IGNORED_KWARGS}
# 兼容 stream 侧传入的 metadata 字段:映射到 MessageTelemetry 的 request_metadata
if "metadata" in out and "request_metadata" not in out:
out["request_metadata"] = out.get("metadata")
out.pop("metadata", None)
return out
async def record_success(self, **kwargs: Any) -> None:
await self._telemetry.record_success(**self._filter_kwargs(kwargs))