mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
111
src/services/provider/codex.py
Normal file
111
src/services/provider/codex.py
Normal 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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user