mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +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:
19
.env.example
19
.env.example
@@ -50,22 +50,3 @@ ADMIN_PASSWORD=admin123456
|
||||
# required 维度缺失时是否拒绝请求/标记任务失败(默认 false:cost=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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
使用 Fernet(AES-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()
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
138
src/utils/perf.py
Normal file
138
src/utils/perf.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user