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

@@ -50,22 +50,3 @@ ADMIN_PASSWORD=admin123456
# required 维度缺失时是否拒绝请求/标记任务失败(默认 falsecost=0 + 标记 incomplete
# BILLING_STRICT_MODE=false
#
# 计费引擎切换(迁移期/影子对账/灰度)
# - legacy: 仅旧系统(默认)
# - shadow: 旧系统为真值 + 新系统影子计算(写入 request_metadata.billing_shadow
# - new_with_fallback: 新系统为真值,差异过大时回退旧系统
# - new: 仅新系统
# BILLING_ENGINE=new
#
# 按 provider/model 粒度覆盖JSON 字符串)
# 示例: {"anthropic/*": "shadow", "openai/gpt-4*": "new"}
# BILLING_ENGINE_OVERRIDES={}
#
# 影子对账差异阈值(美元)
# BILLING_DIFF_THRESHOLD_USD=0.0001
#
# 影子差异日志级别DEBUG/INFO/WARNING/ERROR
# BILLING_SHADOW_LOG_LEVEL=INFO
#
# 是否启用差异告警(预留扩展)
# BILLING_DIFF_ALERT_ENABLED=false

View File

@@ -320,6 +320,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
base_url=self.endpoint_data.base_url,
custom_path=self.endpoint_data.custom_path,
header_rules=self.endpoint_data.header_rules,
body_rules=self.endpoint_data.body_rules,
max_retries=self.endpoint_data.max_retries,
is_active=True,
config=self.endpoint_data.config,

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ApiKey, ManagementToken, User
from src.utils.perf import PerfRecorder
from src.utils.request_utils import get_client_ip
@@ -55,9 +56,32 @@ class ApiRequestContext:
if not self.raw_body:
raise HTTPException(status_code=400, detail="请求体不能为空")
perf_metrics = getattr(self.request.state, "perf_metrics", None)
perf_sampled = isinstance(perf_metrics, dict) and bool(perf_metrics)
parse_start = PerfRecorder.start(force=perf_sampled)
def _record_parse_duration(duration: float | None) -> None:
if duration is None:
return
if not isinstance(perf_metrics, dict):
return
perf_metrics.setdefault("pipeline", {})["json_parse_ms"] = int(duration * 1000)
try:
self.json_body = json.loads(self.raw_body.decode("utf-8"))
parse_duration = PerfRecorder.stop(
parse_start,
"pipeline_json_parse",
labels={"mode": self.mode},
)
_record_parse_duration(parse_duration)
except json.JSONDecodeError as exc:
parse_duration = PerfRecorder.stop(
parse_start,
"pipeline_json_parse",
labels={"mode": self.mode},
)
_record_parse_duration(parse_duration)
logger.warning(f"解析JSON失败: {exc}")
raise HTTPException(status_code=400, detail="请求体必须是合法的JSON") from exc
@@ -112,6 +136,10 @@ class ApiRequestContext:
path_params=path_params or {},
)
perf_metrics = getattr(request.state, "perf_metrics", None)
if isinstance(perf_metrics, dict) and perf_metrics:
context.extra["perf"] = perf_metrics
# 便于插件/日志引用
request.state.request_id = request_id
if user:

View File

@@ -15,6 +15,7 @@ from src.models.database import ApiKey, AuditEventType, User
from src.services.auth.service import AuthService
from src.services.system.audit import AuditService
from src.services.usage.service import UsageService
from src.utils.perf import PerfRecorder
if TYPE_CHECKING:
from src.models.database import ManagementToken
@@ -55,6 +56,28 @@ class ApiRequestPipeline:
api_format_hint: str | None = None,
path_params: dict[str, Any] | None = None,
) -> Any:
perf_labels = {
"mode": getattr(mode, "value", str(mode)),
"adapter": adapter.name,
}
perf_sampled = PerfRecorder.should_store_sample()
if perf_sampled:
setattr(http_request.state, "perf_sampled", True)
setattr(
http_request.state,
"perf_metrics",
{"pipeline": {}, "sample_rate": getattr(config, "perf_store_sample_rate", 1.0)},
)
def _record_perf_metric(key: str, duration: float | None) -> None:
if duration is None:
return
perf_metrics = getattr(http_request.state, "perf_metrics", None)
if not isinstance(perf_metrics, dict):
return
bucket = perf_metrics.setdefault("pipeline", {})
bucket[key] = int(duration * 1000)
# 高频轮询端点抑制 debug 日志
is_quiet = http_request.url.path in QUIET_POLLING_PATHS
if not is_quiet:
@@ -66,26 +89,31 @@ class ApiRequestPipeline:
adapter.mode,
http_request.url.path,
)
if mode == ApiMode.ADMIN:
user, management_token = await self._authenticate_admin(http_request, db)
api_key = None
elif mode == ApiMode.USER:
user, management_token = await self._authenticate_user(http_request, db)
api_key = None
elif mode == ApiMode.PUBLIC:
user = None
api_key = None
management_token = None
elif mode == ApiMode.MANAGEMENT:
user, management_token = await self._authenticate_management(http_request, db)
api_key = None
else:
if not is_quiet:
logger.debug("[Pipeline] 调用 _authenticate_client")
user, api_key = self._authenticate_client(http_request, db, adapter, quiet=is_quiet)
management_token = None
if not is_quiet:
logger.debug("[Pipeline] 认证完成 | user={}", user.username if user else None)
auth_start = PerfRecorder.start(force=perf_sampled)
try:
if mode == ApiMode.ADMIN:
user, management_token = await self._authenticate_admin(http_request, db)
api_key = None
elif mode == ApiMode.USER:
user, management_token = await self._authenticate_user(http_request, db)
api_key = None
elif mode == ApiMode.PUBLIC:
user = None
api_key = None
management_token = None
elif mode == ApiMode.MANAGEMENT:
user, management_token = await self._authenticate_management(http_request, db)
api_key = None
else:
if not is_quiet:
logger.debug("[Pipeline] 调用 _authenticate_client")
user, api_key = self._authenticate_client(http_request, db, adapter, quiet=is_quiet)
management_token = None
if not is_quiet:
logger.debug("[Pipeline] 认证完成 | user={}", user.username if user else None)
finally:
auth_duration = PerfRecorder.stop(auth_start, "pipeline_auth", labels=perf_labels)
_record_perf_metric("auth_ms", auth_duration)
raw_body = None
if http_request.method in {"POST", "PUT", "PATCH"}:
@@ -93,9 +121,25 @@ class ApiRequestPipeline:
import asyncio
# 添加超时防止卡死
raw_body = await asyncio.wait_for(
http_request.body(), timeout=config.request_body_timeout
)
body_start = PerfRecorder.start(force=perf_sampled)
body_size = 0
try:
raw_body = await asyncio.wait_for(
http_request.body(), timeout=config.request_body_timeout
)
body_size = len(raw_body) if raw_body is not None else 0
finally:
body_duration = PerfRecorder.stop(
body_start,
"pipeline_body_read",
labels=perf_labels,
log_hint=f"size={body_size}",
)
_record_perf_metric("body_read_ms", body_duration)
if perf_sampled:
perf_metrics = getattr(http_request.state, "perf_metrics", None)
if isinstance(perf_metrics, dict):
perf_metrics.setdefault("pipeline", {})["body_bytes"] = int(body_size)
if not is_quiet:
logger.debug(
"[Pipeline] Raw body读取完成 | size={} bytes",
@@ -112,6 +156,7 @@ class ApiRequestPipeline:
if not is_quiet:
logger.debug("[Pipeline] 非写请求跳过读取Body | method={}", http_request.method)
context_start = PerfRecorder.start(force=perf_sampled)
context = ApiRequestContext.build(
request=http_request,
db=db,
@@ -122,6 +167,10 @@ class ApiRequestPipeline:
api_format_hint=api_format_hint,
path_params=path_params,
)
context_duration = PerfRecorder.stop(
context_start, "pipeline_context_build", labels=perf_labels
)
_record_perf_metric("context_build_ms", context_duration)
# 存储 management_token 到 context用于权限检查
if management_token:
context.management_token = management_token
@@ -145,16 +194,28 @@ class ApiRequestPipeline:
context.user,
)
# authorize 可能是异步的,需要检查并 await
authorize_result = adapter.authorize(context)
if hasattr(authorize_result, "__await__"):
await authorize_result
authorize_start = PerfRecorder.start(force=perf_sampled)
try:
authorize_result = adapter.authorize(context)
if hasattr(authorize_result, "__await__"):
await authorize_result
finally:
authorize_duration = PerfRecorder.stop(
authorize_start, "pipeline_authorize", labels=perf_labels
)
_record_perf_metric("authorize_ms", authorize_duration)
try:
handle_start = PerfRecorder.start(force=perf_sampled)
response = await adapter.handle(context)
handle_duration = PerfRecorder.stop(handle_start, "pipeline_handle", labels=perf_labels)
_record_perf_metric("handle_ms", handle_duration)
status_code = getattr(response, "status_code", None)
self._record_audit_event(context, adapter, success=True, status_code=status_code)
return response
except HTTPException as exc:
handle_duration = PerfRecorder.stop(handle_start, "pipeline_handle", labels=perf_labels)
_record_perf_metric("handle_ms", handle_duration)
err_detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
self._record_audit_event(
context,
@@ -165,6 +226,8 @@ class ApiRequestPipeline:
)
raise
except Exception as exc:
handle_duration = PerfRecorder.stop(handle_start, "pipeline_handle", labels=perf_labels)
_record_perf_metric("handle_ms", handle_duration)
self._record_audit_event(
context,
adapter,

View File

@@ -125,7 +125,16 @@ class MessageTelemetry:
target_model: str | None = None,
# Provider 响应元数据(如 Gemini 的 modelVersion
response_metadata: dict[str, Any] | None = None,
# 请求元数据(用于性能与调试记录)
request_metadata: dict[str, Any] | None = None,
) -> float:
metadata = response_metadata
if request_metadata:
merged = dict(request_metadata)
if response_metadata:
merged.setdefault("response", response_metadata)
metadata = merged
usage = await UsageService.record_usage(
db=self.db,
user=self.user,
@@ -157,8 +166,8 @@ class MessageTelemetry:
provider_api_key_id=provider_api_key_id,
# 模型映射信息
target_model=target_model,
# Provider 响应元数据
metadata=response_metadata,
# Provider 响应元数据/请求元数据
metadata=metadata,
)
total_cost = float(getattr(usage, "total_cost_usd", 0.0) or 0.0)
@@ -207,6 +216,8 @@ class MessageTelemetry:
has_format_conversion: bool = False,
# 模型映射信息
target_model: str | None = None,
# 请求元数据(用于性能与调试记录)
request_metadata: dict[str, Any] | None = None,
) -> None:
"""
记录失败请求
@@ -257,6 +268,8 @@ class MessageTelemetry:
request_id=self.request_id,
# 模型映射信息
target_model=target_model,
# 请求元数据
metadata=request_metadata,
)
async def record_cancelled(
@@ -283,6 +296,8 @@ class MessageTelemetry:
endpoint_api_format: str | None = None,
has_format_conversion: bool = False,
target_model: str | None = None,
# 请求元数据(用于性能与调试记录)
request_metadata: dict[str, Any] | None = None,
) -> None:
"""
记录客户端取消的请求
@@ -318,6 +333,7 @@ class MessageTelemetry:
response_body=response_body or {},
request_id=self.request_id,
target_model=target_model,
metadata=request_metadata,
)
@@ -375,6 +391,7 @@ class BaseMessageHandler:
start_time: float,
allowed_api_formats: list[str] | None = None,
adapter_detector: AdapterDetectorType | None = None,
perf_metrics: dict[str, Any] | None = None,
) -> None:
self.db = db
self.user = user
@@ -387,6 +404,7 @@ class BaseMessageHandler:
self.allowed_api_formats = allowed_api_formats or ["claude:chat"]
self.primary_api_format = normalize_endpoint_signature(self.allowed_api_formats[0])
self.adapter_detector = adapter_detector
self.perf_metrics = perf_metrics
redis_client = get_redis_client_sync()
self.redis = redis_client
@@ -395,6 +413,11 @@ class BaseMessageHandler:
def elapsed_ms(self) -> int:
return int((time.time() - self.start_time) * 1000)
def _build_request_metadata(self, http_request: Request | None = None) -> dict[str, Any] | None:
if not isinstance(self.perf_metrics, dict) or not self.perf_metrics:
return None
return {"perf": self.perf_metrics}
def _resolve_capability_requirements(
self,
model_name: str,

View File

@@ -189,6 +189,7 @@ class ChatAdapterBase(ApiAdapter):
client_ip=client_ip,
user_agent=user_agent,
start_time=start_time,
perf_metrics=context.extra.get("perf"),
)
# 处理请求
@@ -269,6 +270,7 @@ class ChatAdapterBase(ApiAdapter):
client_ip: str,
user_agent: str,
start_time: float,
perf_metrics: dict[str, Any] | None = None,
) -> Any:
"""创建 Handler 实例 - 子类可覆盖"""
return self.HANDLER_CLASS(
@@ -281,6 +283,7 @@ class ChatAdapterBase(ApiAdapter):
start_time=start_time,
allowed_api_formats=self.allowed_api_formats,
adapter_detector=self.detect_capability_requirements,
perf_metrics=perf_metrics,
)
def _merge_path_params(
@@ -679,6 +682,7 @@ class ChatAdapterBase(ApiAdapter):
if header_rules:
# 获取认证头名称,防止被规则覆盖
from src.core.api_format import get_auth_config_for_endpoint
auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
protected_keys = {auth_header.lower(), "content-type"}

View File

@@ -245,6 +245,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
adapter_detector: None | (
Callable[[dict[str, str], dict[str, Any] | None], dict[str, bool]]
) = None,
perf_metrics: dict[str, Any] | None = None,
):
allowed = allowed_api_formats or [self.FORMAT_ID]
super().__init__(
@@ -257,6 +258,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
start_time=start_time,
allowed_api_formats=allowed,
adapter_detector=adapter_detector,
perf_metrics=perf_metrics,
)
self._parser: ResponseParser | None = None
self._request_builder = PassthroughRequestBuilder()
@@ -554,6 +556,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
)
# 仅在 FULL 级别才需要保留 parsed_chunks避免长流式响应导致的内存占用
ctx.record_parsed_chunks = SystemConfigService.should_log_body(self.db)
request_metadata = self._build_request_metadata()
if request_metadata and isinstance(request_metadata.get("perf"), dict):
ctx.perf_sampled = True
ctx.perf_metrics.update(request_metadata["perf"])
# 创建更新状态的回调闭包(可以访问 ctx
def update_streaming_status() -> None:
@@ -1318,6 +1324,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
client_response_headers = filter_proxy_response_headers(response_headers)
client_response_headers["content-type"] = "application/json"
request_metadata = self._build_request_metadata()
total_cost = await self.telemetry.record_success(
provider=provider_name,
model=model,
@@ -1343,6 +1350,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_api_key_id=key_id,
# 模型映射信息
target_model=mapped_model_result,
request_metadata=request_metadata,
)
logger.debug(f"{self.FORMAT_ID} 非流式响应完成")
@@ -1365,6 +1373,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 记录实际发送给 Provider 的请求体,便于排查问题根因
response_time_ms = self.elapsed_ms()
actual_request_body = provider_request_body or original_request_body
request_metadata = self._build_request_metadata()
await self.telemetry.record_failure(
provider=provider_name or "unknown",
model=model,
@@ -1374,6 +1383,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body=actual_request_body,
error_message=str(e),
is_stream=False,
request_metadata=request_metadata,
)
client_format = (client_api_format_for_error or "").upper()
provider_format = (provider_api_format_for_error or client_format).upper()
@@ -1388,6 +1398,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
except UpstreamClientException as e:
response_time_ms = self.elapsed_ms()
actual_request_body = provider_request_body or original_request_body
request_metadata = self._build_request_metadata()
await self.telemetry.record_failure(
provider=provider_name or "unknown",
model=model,
@@ -1405,6 +1416,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
target_model=mapped_model_result,
request_metadata=request_metadata,
)
client_format = (client_api_format_for_error or "").upper()
provider_format = (provider_api_format_for_error or client_format).upper()
@@ -1436,6 +1448,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
elif isinstance(e, httpx.HTTPStatusError) and hasattr(e, "response"):
error_response_headers = dict(e.response.headers)
request_metadata = self._build_request_metadata()
await self.telemetry.record_failure(
provider=provider_name or "unknown",
model=model,
@@ -1455,6 +1468,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
has_format_conversion=needs_conversion_for_error,
# 模型映射信息
target_model=mapped_model_result,
request_metadata=request_metadata,
)
raise

View File

@@ -192,6 +192,7 @@ class CliAdapterBase(ApiAdapter):
start_time=start_time,
allowed_api_formats=self.allowed_api_formats,
adapter_detector=self.detect_capability_requirements,
perf_metrics=context.extra.get("perf"),
)
# 处理请求
@@ -661,6 +662,7 @@ class CliAdapterBase(ApiAdapter):
if header_rules:
# 获取认证头名称,防止被规则覆盖
from src.core.api_format import get_auth_config_for_endpoint
auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
protected_keys = {auth_header.lower(), "content-type"}

View File

@@ -213,6 +213,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
adapter_detector: None | (
Callable[[dict[str, str], dict[str, Any] | None], dict[str, bool]]
) = None,
perf_metrics: dict[str, Any] | None = None,
):
allowed = allowed_api_formats or [self.FORMAT_ID]
super().__init__(
@@ -225,6 +226,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
start_time=start_time,
allowed_api_formats=allowed,
adapter_detector=adapter_detector,
perf_metrics=perf_metrics,
)
self._parser: ResponseParser | None = None
self._request_builder = PassthroughRequestBuilder()
@@ -561,6 +563,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
# 仅在 FULL 级别才需要保留 parsed_chunks避免长流式响应导致的内存占用
ctx.record_parsed_chunks = SystemConfigService.should_log_body(self.db)
request_metadata = self._build_request_metadata(http_request)
if request_metadata and isinstance(request_metadata.get("perf"), dict):
ctx.perf_sampled = True
ctx.perf_metrics.update(request_metadata["perf"])
# 定义请求函数
async def stream_request_func(
@@ -1972,6 +1978,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
if ctx.is_client_disconnected():
# 客户端取消:记录为 cancelled不算系统失败
request_metadata = {"perf": ctx.perf_metrics} if ctx.perf_metrics else None
await bg_telemetry.record_cancelled(
provider=ctx.provider_name or "unknown",
model=ctx.model,
@@ -1993,6 +2000,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
target_model=ctx.mapped_model,
request_metadata=request_metadata,
)
logger.debug(f"{self.FORMAT_ID} 流式响应被客户端取消")
logger.info(
@@ -2001,6 +2009,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
else:
# 服务端/上游异常:记录为失败
request_metadata = {"perf": ctx.perf_metrics} if ctx.perf_metrics else None
await bg_telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=ctx.model,
@@ -2025,6 +2034,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
has_format_conversion=ctx.needs_conversion,
# 模型映射信息
target_model=ctx.mapped_model,
request_metadata=request_metadata,
)
logger.debug(f"{self.FORMAT_ID} 流式响应中断")
logger.info(
@@ -2050,6 +2060,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
f"provider={ctx.provider_name}, model={ctx.model}, "
f"in={ctx.input_tokens}, out={ctx.output_tokens}"
)
request_metadata = {"perf": ctx.perf_metrics} if ctx.perf_metrics else None
total_cost = await bg_telemetry.record_success(
provider=ctx.provider_name,
model=ctx.model,
@@ -2079,6 +2090,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_model=ctx.mapped_model,
# Provider 响应元数据(如 Gemini 的 modelVersion
response_metadata=ctx.response_metadata if ctx.response_metadata else None,
request_metadata=request_metadata,
)
logger.debug(f"[{ctx.request_id}] Usage 记录完成: cost=${total_cost:.6f}")
# 简洁的请求完成摘要(两行格式)
@@ -2210,6 +2222,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 失败时返回给客户端的是 JSON 错误响应
client_response_headers = {"content-type": "application/json"}
request_metadata = {"perf": ctx.perf_metrics} if ctx.perf_metrics else None
await self.telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=ctx.model,
@@ -2228,6 +2241,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
has_format_conversion=ctx.needs_conversion,
# 模型映射信息
target_model=ctx.mapped_model,
request_metadata=request_metadata,
)
# _update_usage_to_streaming 方法已移至基类 BaseMessageHandler
@@ -2551,6 +2565,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
client_response_headers = filter_proxy_response_headers(response_headers)
client_response_headers["content-type"] = "application/json"
request_metadata = self._build_request_metadata()
total_cost = await self.telemetry.record_success(
provider=provider_name,
model=model,
@@ -2579,6 +2594,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_model=mapped_model_result,
# Provider 响应元数据(如 Gemini 的 modelVersion
response_metadata=response_metadata_result if response_metadata_result else None,
request_metadata=request_metadata,
)
logger.info(f"{self.FORMAT_ID} 非流式响应处理完成")
@@ -2595,6 +2611,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 记录实际发送给 Provider 的请求体,便于排查问题根因
response_time_ms = int((time.time() - sync_start_time) * 1000)
actual_request_body = provider_request_body or original_request_body
request_metadata = self._build_request_metadata()
await self.telemetry.record_failure(
provider=provider_name or "unknown",
model=model,
@@ -2605,6 +2622,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
error_message=str(e),
is_stream=False,
api_format=api_format,
request_metadata=request_metadata,
)
raise
@@ -2629,6 +2647,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
elif isinstance(e, httpx.HTTPStatusError) and hasattr(e, "response"):
error_response_headers = dict(e.response.headers)
request_metadata = self._build_request_metadata()
await self.telemetry.record_failure(
provider=provider_name or "unknown",
model=model,
@@ -2648,6 +2667,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
has_format_conversion=needs_conversion,
# 模型映射信息
target_model=mapped_model_result,
request_metadata=request_metadata,
)
raise

View File

@@ -94,6 +94,10 @@ class StreamContext:
# 是否记录 parsed_chunks可用于降低高并发/长流式响应的内存占用)
record_parsed_chunks: bool = True
# 性能采集(可选)
perf_sampled: bool = False
perf_metrics: dict[str, Any] = field(default_factory=dict)
# 流式格式转换状态(跨 chunk 追踪)
stream_conversion_state: StreamState | None = None

View File

@@ -14,6 +14,7 @@ from __future__ import annotations
import asyncio
import codecs
import json
import time
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass
from typing import Any
@@ -43,6 +44,7 @@ from src.core.exceptions import (
)
from src.core.logger import logger
from src.models.database import Provider, ProviderEndpoint
from src.utils.perf import PerfRecorder
from src.utils.sse_parser import SSEEventParser
from src.utils.timeout import read_first_chunk_with_ttfb_timeout
@@ -413,16 +415,20 @@ class StreamProcessor:
buffer = b""
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
metrics_enabled = PerfRecorder.enabled()
perf_capture = metrics_enabled or ctx.perf_sampled
parse_time = 0.0
convert_time = 0.0
_api_format_str = str(ctx.api_format or "")
client_format = (ctx.client_api_format or _api_format_str).strip().lower()
provider_format = (ctx.provider_api_format or _api_format_str).strip().lower()
client_family = (
client_format.split(":", 1)[0] if ":" in client_format else client_format
)
) or "unknown"
provider_family = (
provider_format.split(":", 1)[0] if ":" in provider_format else provider_format
)
) or "unknown"
# 使用 handler 层预计算的 needs_conversion由 candidate 决定)
needs_conversion = ctx.needs_conversion
@@ -446,6 +452,15 @@ class StreamProcessor:
self.on_streaming_start()
streaming_started = True
def _process_line_with_perf(line: str, *, skip_record: bool = False) -> None:
nonlocal parse_time
if perf_capture:
t0 = time.perf_counter()
self._process_line(ctx, sse_parser, line, skip_record=skip_record)
parse_time += time.perf_counter() - t0
return
self._process_line(ctx, sse_parser, line, skip_record=skip_record)
def _build_stream_error_payload(message: str) -> dict:
if client_family == "openai":
return {
@@ -477,103 +492,112 @@ class StreamProcessor:
message_id=ctx.response_id or ctx.request_id or "",
)
skip_next_blank_line = False
empty_yield_count = 0 # 空转计数(防护异常情况)
openai_done_sent = (
False # 统一为 OpenAI 客户端补齐 [DONE](避免不同 Provider 行为差异)
)
# 转换状态变量(在 needs_conversion 块内统一初始化,确保作用域正确)
skip_next_blank_line = False
empty_yield_count = 0 # 空转计数(防护异常情况)
openai_done_sent = (
False # 统一为 OpenAI 客户端补齐 [DONE](避免不同 Provider 行为差异)
)
def _emit_converted_line(normalized_line: str) -> list[bytes]:
nonlocal skip_next_blank_line, openai_done_sent
def _emit_converted_line(normalized_line: str) -> list[bytes]:
nonlocal skip_next_blank_line, openai_done_sent, convert_time
# 空行:事件分隔符(避免重复输出)
if normalized_line == "":
if skip_next_blank_line:
skip_next_blank_line = False
return []
return [b"\n"]
# 丢弃 Provider 的 event 行,避免泄漏/污染目标格式
if normalized_line.startswith("event:"):
# 空行:事件分隔符(避免重复输出)
if normalized_line == "":
if skip_next_blank_line:
skip_next_blank_line = False
return []
return [b"\n"]
# OpenAI done 信号(仅用于 OpenAI 客户端)
if (
normalized_line.startswith("data:")
and normalized_line[5:].strip() == "[DONE]"
):
skip_next_blank_line = True
if client_family == "openai":
openai_done_sent = True
return [b"data: [DONE]\n\n"]
return []
# 默认只处理 SSE 的 data 行;但 Gemini 上游可能返回 JSON-array/chunks无 data 前缀)
is_data_line = normalized_line.startswith("data:")
if not is_data_line:
if provider_family != "gemini":
return []
data_content = normalized_line.strip()
else:
data_content = normalized_line[5:].strip()
# Gemini 可能包含 JSON 数组包装符,直接忽略
if data_content in ("", "[", "]", ","):
return []
# JSON-array/chunks 可能带前后逗号(对象分隔符),做一次保守清理
data_content = data_content.lstrip(",").rstrip(",").strip()
if data_content in ("", "[", "]", ","):
return []
try:
data_obj = json.loads(data_content)
except json.JSONDecodeError:
# 跨格式转换时JSON 解析失败应跳过而不是透传(避免泄漏 Provider 格式)
logger.warning(
f"[{self.request_id}] JSON 解析失败,跳过该行: {data_content[:100]}"
)
return []
if not isinstance(data_obj, dict):
return []
try:
converted_events = registry.convert_stream_chunk(
data_obj,
provider_format,
client_format,
state=ctx.stream_conversion_state,
)
except Exception as conv_err:
# 首字节后无法 failover输出目标格式错误事件并终止流
# 使用 502 表示上游返回了非预期格式Bad Gateway
ctx.status_code = 502
ctx.error_message = "format_conversion_failed"
# 日志记录完整错误(内部排查),客户端只返回脱敏消息
logger.warning(f"[{self.request_id}] 流式格式转换失败: {conv_err}")
payload = _build_stream_error_payload("响应格式转换失败,请稍后重试")
error_bytes = (
f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode()
)
done_bytes = b"data: [DONE]\n\n" if client_family == "openai" else b""
if done_bytes:
openai_done_sent = True
return [error_bytes, done_bytes]
# 丢弃 Provider 的 event 行,避免泄漏/污染目标格式
if normalized_line.startswith("event:"):
return []
# OpenAI done 信号(仅用于 OpenAI 客户端)
if (
normalized_line.startswith("data:")
and normalized_line[5:].strip() == "[DONE]"
):
skip_next_blank_line = True
out: list[bytes] = []
if client_family == "openai":
openai_done_sent = True
return [b"data: [DONE]\n\n"]
return []
for evt in converted_events:
# 记录转换后的数据到 parsed_chunks这是客户端实际收到的格式
if isinstance(evt, dict):
ctx.data_count += 1
if ctx.record_parsed_chunks:
ctx.parsed_chunks.append(evt)
# 默认只处理 SSE 的 data 行;但 Gemini 上游可能返回 JSON-array/chunks无 data 前缀)
is_data_line = normalized_line.startswith("data:")
if not is_data_line:
if provider_family != "gemini":
return []
data_content = normalized_line.strip()
else:
data_content = normalized_line[5:].strip()
# 统一使用 SSE 格式输出Gemini streamGenerateContent 也使用 SSE
# 参考: https://ai.google.dev/api/generate-content
out.append(f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode())
return out
# Gemini 可能包含 JSON 数组包装符,直接忽略
if data_content in ("", "[", "]", ","):
return []
# JSON-array/chunks 可能带前后逗号(对象分隔符),做一次保守清理
data_content = data_content.lstrip(",").rstrip(",").strip()
if data_content in ("", "[", "]", ","):
return []
convert_start = time.perf_counter() if perf_capture else None
try:
data_obj = json.loads(data_content)
except json.JSONDecodeError:
if perf_capture and convert_start is not None:
convert_time += time.perf_counter() - convert_start
# 跨格式转换时JSON 解析失败应跳过而不是透传(避免泄漏 Provider 格式)
logger.warning(
f"[{self.request_id}] JSON 解析失败,跳过该行: {data_content[:100]}"
)
return []
if not isinstance(data_obj, dict):
return []
try:
converted_events = registry.convert_stream_chunk(
data_obj,
provider_format,
client_format,
state=ctx.stream_conversion_state,
)
except Exception as conv_err:
# 首字节后无法 failover输出目标格式错误事件并终止流
# 使用 502 表示上游返回了非预期格式Bad Gateway
ctx.status_code = 502
ctx.error_message = "format_conversion_failed"
# 日志记录完整错误(内部排查),客户端只返回脱敏消息
logger.warning(f"[{self.request_id}] 流式格式转换失败: {conv_err}")
payload = _build_stream_error_payload("响应格式转换失败,请稍后重试")
error_bytes = (
f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode()
)
done_bytes = b"data: [DONE]\n\n" if client_family == "openai" else b""
if done_bytes:
openai_done_sent = True
if perf_capture and convert_start is not None:
convert_time += time.perf_counter() - convert_start
return [error_bytes, done_bytes]
if perf_capture and convert_start is not None:
convert_time += time.perf_counter() - convert_start
skip_next_blank_line = True
out: list[bytes] = []
for evt in converted_events:
# 记录转换后的数据到 parsed_chunks这是客户端实际收到的格式
if isinstance(evt, dict):
ctx.data_count += 1
if ctx.record_parsed_chunks:
ctx.parsed_chunks.append(evt)
# 统一使用 SSE 格式输出Gemini streamGenerateContent 也使用 SSE
# 参考: https://ai.google.dev/api/generate-content
out.append(f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode())
return out
# 统一处理 prefetched + iterator
if prefetched_chunks:
@@ -591,7 +615,7 @@ class StreamProcessor:
if line:
# 需要格式转换时,跳过记录原始数据(由 _emit_converted_line 记录转换后的数据)
self._process_line(ctx, sse_parser, line, skip_record=True)
_process_line_with_perf(line, skip_record=True)
normalized_line = line.rstrip("\r\n") if line else ""
out_chunks = _emit_converted_line(normalized_line)
if not out_chunks:
@@ -625,7 +649,7 @@ class StreamProcessor:
if line:
# 需要格式转换时,跳过记录原始数据(由 _emit_converted_line 记录转换后的数据)
self._process_line(ctx, sse_parser, line, skip_record=True)
_process_line_with_perf(line, skip_record=True)
normalized_line = line.rstrip("\r\n") if line else ""
out_chunks = _emit_converted_line(normalized_line)
if not out_chunks:
@@ -655,7 +679,7 @@ class StreamProcessor:
line = ""
if line:
# 需要格式转换时,跳过记录原始数据
self._process_line(ctx, sse_parser, line, skip_record=True)
_process_line_with_perf(line, skip_record=True)
normalized_line = line.rstrip("\r\n")
out_chunks = _emit_converted_line(normalized_line)
for out in out_chunks:
@@ -684,7 +708,7 @@ class StreamProcessor:
try:
# 使用增量解码器,可以正确处理跨 chunk 的多字节字符
line = decoder.decode(line_bytes + b"\n", False)
self._process_line(ctx, sse_parser, line)
_process_line_with_perf(line)
except Exception as e:
# 解码失败,记录警告但继续处理
logger.warning(
@@ -708,7 +732,7 @@ class StreamProcessor:
try:
# 使用增量解码器,可以正确处理跨 chunk 的多字节字符
line = decoder.decode(line_bytes + b"\n", False)
self._process_line(ctx, sse_parser, line)
_process_line_with_perf(line)
except Exception as e:
# 解码失败,记录警告但继续处理
logger.warning(
@@ -722,7 +746,7 @@ class StreamProcessor:
try:
# 使用 final=True 处理最后的不完整字符
line = decoder.decode(buffer, True)
self._process_line(ctx, sse_parser, line)
_process_line_with_perf(line)
except Exception as e:
logger.warning(
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
@@ -735,6 +759,33 @@ class StreamProcessor:
except GeneratorExit:
raise
finally:
if metrics_enabled:
labels = {
"format": client_family or "unknown",
"provider": str(ctx.provider_name or "unknown"),
"conversion": "true" if ctx.needs_conversion else "false",
}
if parse_time > 0:
PerfRecorder.record_timing("stream_parse", parse_time, labels=labels)
if convert_time > 0:
PerfRecorder.record_timing("stream_conversion", convert_time, labels=labels)
if ctx.chunk_count:
PerfRecorder.record_counter(
"stream_chunks_total", ctx.chunk_count, labels=labels
)
if ctx.data_count:
PerfRecorder.record_counter(
"stream_data_events_total", ctx.data_count, labels=labels
)
if ctx.perf_sampled:
if parse_time > 0:
ctx.perf_metrics["stream_parse_ms"] = int(parse_time * 1000)
if convert_time > 0:
ctx.perf_metrics["stream_conversion_ms"] = int(convert_time * 1000)
if ctx.chunk_count:
ctx.perf_metrics["stream_chunks"] = int(ctx.chunk_count)
if ctx.data_count:
ctx.perf_metrics["stream_data_events"] = int(ctx.data_count)
await self._cleanup(response_ctx, http_client)
def _process_line(

View File

@@ -184,6 +184,9 @@ class StreamTelemetryRecorder:
"content-type": "text/event-stream",
}
)
metadata: dict[str, Any] = {"stream": True, "content_length": ctx.data_count}
if ctx.perf_metrics:
metadata["perf"] = ctx.perf_metrics
await writer.record_success(
provider=ctx.provider_name or "unknown",
@@ -208,7 +211,7 @@ class StreamTelemetryRecorder:
provider_api_key_id=ctx.key_id,
target_model=ctx.mapped_model,
request_type="chat",
metadata={"stream": True, "content_length": ctx.data_count},
metadata=metadata,
endpoint_api_format=ctx.provider_api_format,
has_format_conversion=ctx.needs_conversion,
)
@@ -230,6 +233,9 @@ class StreamTelemetryRecorder:
client_response_headers = ctx.client_response_headers or {
"content-type": "application/json"
}
metadata: dict[str, Any] = {"stream": True, "content_length": ctx.data_count}
if ctx.perf_metrics:
metadata["perf"] = ctx.perf_metrics
await writer.record_failure(
provider=ctx.provider_name or "unknown",
@@ -251,7 +257,7 @@ class StreamTelemetryRecorder:
client_response_headers=client_response_headers,
target_model=ctx.mapped_model,
request_type="chat",
metadata={"stream": True, "content_length": ctx.data_count},
metadata=metadata,
endpoint_api_format=ctx.provider_api_format,
has_format_conversion=ctx.needs_conversion,
)
@@ -274,6 +280,9 @@ class StreamTelemetryRecorder:
client_response_headers = ctx.client_response_headers or {
"content-type": "application/json"
}
metadata: dict[str, Any] = {"stream": True, "content_length": ctx.data_count}
if ctx.perf_metrics:
metadata["perf"] = ctx.perf_metrics
await writer.record_cancelled(
provider=ctx.provider_name or "unknown",
@@ -295,7 +304,7 @@ class StreamTelemetryRecorder:
client_response_headers=client_response_headers,
target_model=ctx.mapped_model,
request_type="chat",
metadata={"stream": True, "content_length": ctx.data_count},
metadata=metadata,
endpoint_api_format=ctx.provider_api_format,
has_format_conversion=ctx.needs_conversion,
)

View File

@@ -219,6 +219,30 @@ class Config:
# 默认 60 秒,防止客户端发送不完整请求导致连接卡死
self.request_body_timeout = float(os.getenv("REQUEST_BODY_TIMEOUT", "60.0"))
# 性能检测配置
# PERF_METRICS_ENABLED: 是否启用性能指标上报(监控插件)
# PERF_LOG_SLOW_MS: 慢请求日志阈值毫秒0 表示关闭
# PERF_SAMPLE_RATE: 采样率 (0-1),降低高频指标开销
self.perf_metrics_enabled = os.getenv("PERF_METRICS_ENABLED", "false").lower() == "true"
self.perf_log_slow_ms = int(os.getenv("PERF_LOG_SLOW_MS", "0"))
self.perf_sample_rate = float(os.getenv("PERF_SAMPLE_RATE", "1.0"))
# PERF_STORE_ENABLED: 是否将性能指标写入 Usage.request_metadata
# PERF_STORE_SAMPLE_RATE: 存储采样率 (0-1),用于降低写入压力
self.perf_store_enabled = os.getenv("PERF_STORE_ENABLED", "false").lower() == "true"
self.perf_store_sample_rate = float(os.getenv("PERF_STORE_SAMPLE_RATE", "1.0"))
# 解密缓存配置降低高频解密带来的CPU开销
# CRYPTO_DECRYPT_CACHE_ENABLED: 是否启用解密结果缓存
# CRYPTO_DECRYPT_CACHE_SIZE: 最大缓存条目数
# CRYPTO_DECRYPT_CACHE_TTL_SECONDS: 缓存TTL
self.crypto_decrypt_cache_enabled = (
os.getenv("CRYPTO_DECRYPT_CACHE_ENABLED", "true").lower() == "true"
)
self.crypto_decrypt_cache_size = int(os.getenv("CRYPTO_DECRYPT_CACHE_SIZE", "256"))
self.crypto_decrypt_cache_ttl_seconds = float(
os.getenv("CRYPTO_DECRYPT_CACHE_TTL_SECONDS", "60.0")
)
# 内部请求 User-Agent 配置(用于查询上游模型列表等)
# 可通过环境变量覆盖默认值,模拟对应 CLI 客户端
self.internal_user_agent_claude_cli = os.getenv(
@@ -241,26 +265,6 @@ class Config:
self.billing_require_rule = os.getenv("BILLING_REQUIRE_RULE", "false").lower() == "true"
self.billing_strict_mode = os.getenv("BILLING_STRICT_MODE", "false").lower() == "true"
# 计费迁移运行时开关(用于灰度/影子计费/快速止血)
# BILLING_ENGINE:
# - legacy: 仅旧系统(当前默认)
# - shadow: 旧系统为真值 + 新系统影子计算(对账期)
# - new_with_fallback: 新系统为真值,差异过大时回退旧系统
# - new: 仅新系统
# Default to "new" per unified billing architecture.
self.billing_engine = os.getenv("BILLING_ENGINE", "new").strip().lower()
# 按 provider/model 粒度覆盖JSON 字符串)
# 示例: {"anthropic/*": "shadow", "openai/gpt-4*": "new"}
self.billing_engine_overrides = os.getenv("BILLING_ENGINE_OVERRIDES", "{}")
# 影子计费差异阈值(美元)
self.billing_diff_threshold_usd = float(os.getenv("BILLING_DIFF_THRESHOLD_USD", "0.0001"))
# 差异日志级别DEBUG/INFO/WARNING/ERROR
self.billing_shadow_log_level = os.getenv("BILLING_SHADOW_LOG_LEVEL", "INFO").strip()
# 是否启用差异告警(预留扩展)
self.billing_diff_alert_enabled = (
os.getenv("BILLING_DIFF_ALERT_ENABLED", "false").lower() == "true"
)
# Usage.request_metadata 体积控制(用于降低 DB/CPU/内存压力)
# USAGE_METADATA_MAX_BYTES:
# - 0: unlimited (backward compatible)

View File

@@ -78,10 +78,18 @@ def is_format_compatible(
if provider_key == client_key:
return True, False, None
# 2. 格式不同 -> 需要检查格式转换开关
# 如果总开关为 False直接拒绝禁用任何跨格式转换
if not effective_conversion_enabled:
return False, False, "格式转换已禁用enable_format_conversion=false"
# 2. 格式不同 -> 需要检查格式转换开关(分层开关)
#
# 设计语义(与模块顶部注释一致):
# - 全局开关 ON -> 强制允许跨格式(通常 caller 会传 skip_endpoint_check=True
# - 全局开关 OFF -> 不再“一刀切”拒绝,而是回退到 provider/endpoint 开关:
# - provider 开关 ON -> 强制允许skip_endpoint_check=True跳过端点检查
# - provider 开关 OFF -> 由端点 format_acceptance_config 决定skip_endpoint_check=False
#
# 说明:
# - effective_conversion_enabled 表示“全局默认允许”,不是“全局总闸/kill switch”
# - 当它为 False 时我们仍然会继续执行后续检查provider/endpoint
# 兼容“按 Provider/Endpoint 精细化开启转换”的场景。
# 3. 如果全局或提供商开关为 ON跳过端点配置检查
if not skip_endpoint_check:

View File

@@ -12,12 +12,16 @@ from __future__ import annotations
import base64
import hashlib
import threading
import time
from collections import OrderedDict
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from src.core.logger import logger
from src.utils.perf import PerfRecorder
from ..config import config
from ..core.exceptions import DecryptionException
@@ -31,8 +35,8 @@ class CryptoService:
使用 FernetAES-128-CBC + HMAC-SHA256确保数据机密性和完整性。
"""
_instance = None
_cipher = None
_instance: CryptoService | None = None
_cipher: Fernet | None = None
_key_source: str = "unknown" # 记录密钥来源,用于调试
# 应用级 salt基于应用名称生成比硬编码更安全
@@ -70,6 +74,21 @@ class CryptoService:
self._cipher = Fernet(key)
logger.info(f"加密服务初始化成功 (key_source={self._key_source})")
# 解密缓存配置(使用实例变量,避免测试场景下缓存跨实例持久化)
self._decrypt_cache: OrderedDict[str, tuple[str, float]] = OrderedDict()
self._decrypt_cache_lock = threading.Lock()
self._decrypt_cache_enabled = bool(getattr(config, "crypto_decrypt_cache_enabled", False))
self._decrypt_cache_size = int(getattr(config, "crypto_decrypt_cache_size", 0) or 0)
self._decrypt_cache_ttl_seconds = float(
getattr(config, "crypto_decrypt_cache_ttl_seconds", 0.0) or 0.0
)
if self._decrypt_cache_enabled and self._decrypt_cache_size > 0:
logger.info(
"解密缓存已启用 (size={}, ttl={}s)",
self._decrypt_cache_size,
self._decrypt_cache_ttl_seconds,
)
def _derive_fernet_key(self, encryption_key: str) -> bytes:
"""
从密码/密钥派生 Fernet 兼容的密钥
@@ -138,11 +157,22 @@ class CryptoService:
if not ciphertext:
return ciphertext
cached = self._get_cached_decrypt(ciphertext)
if cached is not None:
PerfRecorder.record_counter("crypto_decrypt_cache_hits_total", 1)
return cached
PerfRecorder.record_counter("crypto_decrypt_cache_misses_total", 1)
start = PerfRecorder.start()
try:
encrypted = base64.urlsafe_b64decode(ciphertext.encode())
decrypted = self._cipher.decrypt(encrypted)
return decrypted.decode()
plaintext = decrypted.decode()
self._set_cached_decrypt(ciphertext, plaintext)
PerfRecorder.stop(start, "crypto_decrypt")
return plaintext
except Exception as e:
PerfRecorder.stop(start, "crypto_decrypt")
if not silent:
logger.error(f"Decryption failed: {e}")
# 抛出自定义异常,方便在上层通过类型判断是否需要打印堆栈
@@ -163,6 +193,47 @@ class CryptoService:
"""
return hashlib.sha256(api_key.encode()).hexdigest()
def _cache_key(self, ciphertext: str) -> str:
"""生成缓存 key使用密文 hash避免内存中保留完整密文"""
return hashlib.sha256(ciphertext.encode()).hexdigest()[:32]
def _get_cached_decrypt(self, ciphertext: str) -> str | None:
if not self._decrypt_cache_enabled:
return None
if not ciphertext:
return None
if self._decrypt_cache_size <= 0:
return None
cache_key = self._cache_key(ciphertext)
with self._decrypt_cache_lock:
entry = self._decrypt_cache.get(cache_key)
if not entry:
return None
value, expires_at = entry
if expires_at <= time.time():
self._decrypt_cache.pop(cache_key, None)
return None
# 维护 LRU 顺序
self._decrypt_cache.move_to_end(cache_key)
return value
def _set_cached_decrypt(self, ciphertext: str, plaintext: str) -> None:
if not self._decrypt_cache_enabled:
return
if not ciphertext:
return
if self._decrypt_cache_size <= 0:
return
if self._decrypt_cache_ttl_seconds <= 0:
return
cache_key = self._cache_key(ciphertext)
expires_at = time.time() + self._decrypt_cache_ttl_seconds
with self._decrypt_cache_lock:
self._decrypt_cache[cache_key] = (plaintext, expires_at)
self._decrypt_cache.move_to_end(cache_key)
while len(self._decrypt_cache) > self._decrypt_cache_size:
self._decrypt_cache.popitem(last=False)
# 创建全局加密服务实例
crypto_service = CryptoService()

View File

@@ -80,28 +80,3 @@ format_conversion_duration_seconds = Histogram(
["direction", "source_format", "target_format"],
buckets=[0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
)
# ==================== Billing migration / shadow billing ====================
billing_requests_total = Counter(
"billing_requests_total",
"Total number of billing calculations",
["engine_mode", "truth_engine"], # low-cardinality labels
)
billing_fallback_total = Counter(
"billing_fallback_total",
"Total number of billing fallbacks to legacy engine",
)
billing_diff_exceeds_threshold_total = Counter(
"billing_diff_exceeds_threshold_total",
"Total number of shadow billing diffs exceeding threshold",
["engine_mode"],
)
billing_invariant_violation_total = Counter(
"billing_invariant_violation_total",
"Total number of billing invariant violations (sum(breakdown)!=total)",
["engine_mode", "truth_engine"],
)

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))

138
src/utils/perf.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
import asyncio
import random
import time
from typing import Any
from src.config.settings import config
from src.core.logger import logger
class PerfRecorder:
"""轻量性能记录器(可选启用)"""
@staticmethod
def enabled() -> bool:
return bool(config.perf_metrics_enabled or config.perf_log_slow_ms > 0)
@staticmethod
def start(force: bool = False) -> float | None:
if not force and not PerfRecorder.enabled():
return None
return time.perf_counter()
@staticmethod
def stop(
start: float | None,
name: str,
labels: dict[str, str] | None = None,
*,
sample_rate: float | None = None,
log_hint: str | None = None,
) -> float | None:
if start is None:
return None
duration = time.perf_counter() - start
PerfRecorder.record_timing(
name,
duration,
labels=labels,
sample_rate=sample_rate,
log_hint=log_hint,
)
return duration
@staticmethod
def record_timing(
name: str,
duration: float,
labels: dict[str, str] | None = None,
*,
sample_rate: float | None = None,
log_hint: str | None = None,
) -> None:
if not PerfRecorder.enabled():
return
if not PerfRecorder._should_sample(sample_rate):
return
duration_ms = duration * 1000.0
if config.perf_log_slow_ms > 0 and duration_ms >= float(config.perf_log_slow_ms):
hint = f" | {log_hint}" if log_hint else ""
logger.info("[PERF] {} took {:.2f}ms{}", name, duration_ms, hint)
if not config.perf_metrics_enabled:
return
plugin = PerfRecorder._get_monitor_plugin()
if not plugin:
return
PerfRecorder._create_task(plugin.timing(PerfRecorder._metric_name(name), duration, labels))
@staticmethod
def record_counter(
name: str,
value: float = 1,
labels: dict[str, str] | None = None,
*,
sample_rate: float | None = None,
) -> None:
if not config.perf_metrics_enabled:
return
if not PerfRecorder._should_sample(sample_rate):
return
plugin = PerfRecorder._get_monitor_plugin()
if not plugin:
return
PerfRecorder._create_task(
plugin.increment(PerfRecorder._metric_name(name), value=value, labels=labels)
)
@staticmethod
def should_store() -> bool:
return bool(getattr(config, "perf_store_enabled", False))
@staticmethod
def should_store_sample() -> bool:
if not PerfRecorder.should_store():
return False
rate = float(getattr(config, "perf_store_sample_rate", 1.0))
return PerfRecorder._should_sample(rate)
@staticmethod
def _should_sample(sample_rate: float | None) -> bool:
rate = float(sample_rate if sample_rate is not None else config.perf_sample_rate)
if rate >= 1:
return True
if rate <= 0:
return False
return random.random() < rate
@staticmethod
def _get_monitor_plugin() -> Any | None:
# Lazy import to avoid circular deps during app startup.
try:
from src.plugins.manager import get_plugin_manager
except Exception:
return None
try:
plugin = get_plugin_manager().get_plugin("monitor")
except Exception:
return None
if plugin and getattr(plugin, "enabled", True):
return plugin
return None
@staticmethod
def _metric_name(name: str) -> str:
return name if name.startswith("perf_") else f"perf_{name}"
@staticmethod
def _create_task(coro: Any) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(coro)

View File

@@ -45,19 +45,38 @@ def test_cli_format_convertible_when_converter_supports_full() -> None:
assert reason is None
def test_global_switch_disabled_blocks_conversion() -> None:
"""全局开关关闭时阻止转换"""
def test_global_switch_disabled_falls_back_to_endpoint() -> None:
"""全局开关关闭时回退到端点配置(分层开关设计)"""
registry = MagicMock()
registry.can_convert_full.return_value = True
# 全局 OFF + 端点 enabled -> 允许(端点覆盖全局默认)
ok, needs_conv, reason = is_format_compatible(
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
effective_conversion_enabled=False,
registry=registry,
)
assert ok is True
assert needs_conv is True
assert reason is None
def test_global_switch_disabled_blocks_when_endpoint_not_configured() -> None:
"""全局开关关闭 + 端点未配置 -> 阻止转换"""
ok, needs_conv, reason = is_format_compatible(
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config=None,
is_stream=False,
effective_conversion_enabled=False,
registry=MagicMock(),
)
assert ok is False
assert needs_conv is False
assert reason and "格式转换已禁用" in reason
assert reason and "未配置" in reason
def test_endpoint_config_none_blocks_conversion() -> None:
@@ -212,8 +231,8 @@ def test_gemini_cli_to_gemini_no_conversion_needed() -> None:
assert reason is None
def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None:
"""透传格式CLAUDE_CLI -> CLAUDE也受全局开关限制"""
def test_claude_cli_to_claude_allowed_when_endpoint_enabled() -> None:
"""透传格式CLAUDE_CLI -> CLAUDE全局 OFF 时回退到端点配置"""
ok, needs_conv, reason = is_format_compatible(
"claude:cli",
"claude:chat",
@@ -222,8 +241,10 @@ def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None:
effective_conversion_enabled=False,
registry=MagicMock(),
)
assert ok is False
assert reason and "格式转换已禁用" in reason
# 全局 OFF + 端点 enabled -> 允许(同族透传无需转换)
assert ok is True
assert needs_conv is False
assert reason is None
def test_claude_cli_to_claude_blocked_when_endpoint_not_configured() -> None:
@@ -312,8 +333,8 @@ def test_openai_cli_to_openai_fails_without_converter() -> None:
assert reason and "转换器" in reason
def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
"""同族转换OPENAI/OPENAI_CLI也受全局开关限制"""
def test_openai_cli_to_openai_allowed_when_endpoint_enabled() -> None:
"""同族转换OPENAI/OPENAI_CLI全局 OFF 时回退到端点配置"""
registry = MagicMock()
registry.can_convert_full.return_value = True
@@ -325,9 +346,10 @@ def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
effective_conversion_enabled=False, # 全局开关关闭
registry=registry,
)
assert ok is False
assert needs_conv is False
assert reason and "格式转换已禁用" in reason
# 全局 OFF + 端点 enabled -> 允许(需要转换)
assert ok is True
assert needs_conv is True
assert reason is None
def test_openai_cli_to_openai_blocked_when_endpoint_disabled() -> None:

View File

@@ -1,112 +0,0 @@
from unittest.mock import MagicMock
import pytest
from src.config.settings import config
from src.services.billing.schema import BillingSnapshot, CostResult
from src.services.billing.shadow import CostBreakdown, ShadowBillingService
class TestShadowBillingServiceModeResolution:
def test_get_engine_mode_exact_and_wildcard(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "billing_engine", "legacy", raising=False)
monkeypatch.setattr(
config,
"billing_engine_overrides",
'{"anthropic/*": "shadow", "openai/gpt-4o": "new"}',
raising=False,
)
svc = ShadowBillingService(MagicMock())
assert svc.get_engine_mode("openai", "gpt-4o") == "new"
assert svc.get_engine_mode("anthropic", "claude-3-5-sonnet") == "shadow"
assert svc.get_engine_mode("other", "x") == "legacy"
class TestShadowBillingServiceExecution:
def test_legacy_mode_skips_new_engine(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "billing_engine", "legacy", raising=False)
monkeypatch.setattr(config, "billing_engine_overrides", "{}", raising=False)
svc = ShadowBillingService(MagicMock())
# Guard: if new engine calculate gets called, fail.
svc._new_billing = MagicMock()
svc._new_billing.calculate.side_effect = AssertionError(
"new engine should not run in legacy mode"
)
legacy_truth = CostBreakdown(
input_cost=0.1,
output_cost=0.2,
cache_creation_cost=0.0,
cache_read_cost=0.0,
request_cost=0.0,
total_cost=0.3,
)
res = svc.calculate_with_shadow(
provider="openai",
provider_id="p-1",
model="gpt-4o",
task_type="chat",
api_format="openai:chat",
input_tokens=1,
output_tokens=1,
legacy_truth=legacy_truth,
is_failed_request=False,
)
assert res.engine_mode == "legacy"
assert res.truth_engine == "legacy"
assert res.shadow_snapshot is None
assert res.truth_breakdown.total_cost == 0.3
def test_shadow_mode_returns_snapshot_and_keeps_legacy_truth(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(config, "billing_engine", "shadow", raising=False)
monkeypatch.setattr(config, "billing_engine_overrides", "{}", raising=False)
monkeypatch.setattr(config, "billing_diff_threshold_usd", 0.0001, raising=False)
svc = ShadowBillingService(MagicMock())
# Stub new engine output
snapshot = BillingSnapshot(
resolved_dimensions={"input_tokens": 1},
resolved_variables={"input_price_per_1m": "3.0"},
cost_breakdown={"input_cost": 0.003},
total_cost=0.003,
status="complete",
calculated_at="2026-02-02T00:00:00Z",
)
svc._new_billing = MagicMock()
svc._new_billing.calculate.return_value = CostResult(
cost=0.003, status="complete", snapshot=snapshot
)
legacy_truth = CostBreakdown(
input_cost=0.004,
output_cost=0.0,
cache_creation_cost=0.0,
cache_read_cost=0.0,
request_cost=0.0,
total_cost=0.004,
)
res = svc.calculate_with_shadow(
provider="openai",
provider_id="p-1",
model="gpt-4o",
task_type="chat",
api_format="openai:chat",
input_tokens=1,
output_tokens=0,
legacy_truth=legacy_truth,
is_failed_request=False,
)
assert res.engine_mode == "shadow"
assert res.truth_engine == "legacy"
assert res.shadow_snapshot is not None
assert res.truth_breakdown.total_cost == 0.004
assert "diff_usd" in res.comparison

View File

@@ -67,7 +67,11 @@ async def test_build_candidates_allows_cross_format_when_endpoint_accepts_and_ov
@pytest.mark.asyncio
async def test_build_candidates_blocks_cross_format_when_master_switch_off() -> None:
async def test_build_candidates_allows_cross_format_when_global_off_but_endpoint_enabled() -> None:
"""
分层开关设计:全局 OFF 时回退到端点配置
- 全局 OFF + 端点 enabled=True -> 允许(端点覆盖全局默认)
"""
register_default_normalizers()
scheduler = CacheAwareScheduler()
@@ -85,6 +89,40 @@ async def test_build_candidates_blocks_cross_format_when_master_switch_off() ->
]
provider.api_keys = [_mock_key("k1", ["openai:chat"])]
candidates = await scheduler._build_candidates(
db=MagicMock(),
providers=[provider],
client_format="claude:chat",
model_name="dummy-model",
affinity_key=None,
global_conversion_enabled=False, # 全局开关关闭,但端点配置允许
)
# 新设计:全局 OFF 时回退到端点配置,端点 enabled=True 则允许
assert len(candidates) == 1
assert candidates[0].needs_conversion is True
assert candidates[0].provider_api_format == "openai:chat"
@pytest.mark.asyncio
async def test_build_candidates_blocks_cross_format_when_global_off_and_endpoint_not_configured() -> (
None
):
"""
分层开关设计:全局 OFF + 端点未配置 -> 阻止
"""
register_default_normalizers()
scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign]
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign]
provider = MagicMock()
provider.name = "p1"
provider.enable_format_conversion = False
provider.endpoints = [_mock_endpoint("openai:chat", None)] # 端点未配置格式接受策略
provider.api_keys = [_mock_key("k1", ["openai:chat"])]
candidates = await scheduler._build_candidates(
db=MagicMock(),
providers=[provider],
@@ -94,6 +132,7 @@ async def test_build_candidates_blocks_cross_format_when_master_switch_off() ->
global_conversion_enabled=False, # 全局开关关闭
)
# 全局 OFF + 端点未配置 -> 阻止
assert candidates == []