2026-01-28 09:54:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Telemetry writer abstraction for stream usage.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
import json
|
|
|
|
|
|
from abc import ABC, abstractmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from typing import Any
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
from src.clients.redis_client import get_usage_queue_redis_client as get_redis_client
|
2026-01-28 09:54:18 +08:00
|
|
|
|
from src.config.settings import config
|
|
|
|
|
|
from src.core.logger import logger
|
|
|
|
|
|
from src.services.usage.events import UsageEventType, build_usage_event
|
2026-02-16 11:00:48 +08:00
|
|
|
|
from src.services.usage.telemetry import MessageTelemetry
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TelemetryWriter(ABC):
|
refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块
Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整
测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
|
|
|
|
def supports_background_submission(self) -> bool:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
@abstractmethod
|
|
|
|
|
|
async def record_success(self, **kwargs: Any) -> None:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
|
async def record_failure(self, **kwargs: Any) -> None:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
|
async def record_cancelled(self, **kwargs: Any) -> None:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DbTelemetryWriter(TelemetryWriter):
|
|
|
|
|
|
"""通过 MessageTelemetry 写入数据库的 Writer"""
|
|
|
|
|
|
|
|
|
|
|
|
# MessageTelemetry 不支持的参数,需要过滤掉
|
|
|
|
|
|
# - request_type: MessageTelemetry 内部固定为 "chat",无需外部传入
|
2026-02-05 14:22:11 +08:00
|
|
|
|
# - metadata: 由本 writer 映射到 request_metadata(用于落库追踪信息)
|
|
|
|
|
|
_IGNORED_KWARGS = frozenset({"request_type"})
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, telemetry: MessageTelemetry) -> None:
|
|
|
|
|
|
self._telemetry = telemetry
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _filter_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
2026-01-28 09:54:18 +08:00
|
|
|
|
"""过滤掉 MessageTelemetry 不支持的参数"""
|
2026-02-05 14:22:11 +08:00
|
|
|
|
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
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
|
|
|
|
|
async def record_success(self, **kwargs: Any) -> None:
|
|
|
|
|
|
await self._telemetry.record_success(**self._filter_kwargs(kwargs))
|
|
|
|
|
|
|
|
|
|
|
|
async def record_failure(self, **kwargs: Any) -> None:
|
|
|
|
|
|
await self._telemetry.record_failure(**self._filter_kwargs(kwargs))
|
|
|
|
|
|
|
|
|
|
|
|
async def record_cancelled(self, **kwargs: Any) -> None:
|
|
|
|
|
|
await self._telemetry.record_cancelled(**self._filter_kwargs(kwargs))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QueueTelemetryWriter(TelemetryWriter):
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
request_id: str,
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
api_key_id: str,
|
2026-02-03 18:48:39 +08:00
|
|
|
|
log_level: str = "basic",
|
|
|
|
|
|
sensitive_headers: list[str] | None = None,
|
|
|
|
|
|
max_request_body_size: int = 0,
|
|
|
|
|
|
max_response_body_size: int = 0,
|
2026-01-28 09:54:18 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
self.request_id = request_id
|
|
|
|
|
|
self.user_id = user_id
|
|
|
|
|
|
self.api_key_id = api_key_id
|
2026-02-03 18:48:39 +08:00
|
|
|
|
self.log_level = (log_level or "basic").strip().lower()
|
|
|
|
|
|
self._sensitive_headers = sensitive_headers or [
|
|
|
|
|
|
"authorization",
|
|
|
|
|
|
"x-api-key",
|
|
|
|
|
|
"api-key",
|
|
|
|
|
|
"cookie",
|
|
|
|
|
|
"set-cookie",
|
|
|
|
|
|
]
|
|
|
|
|
|
self._max_request_body_size = int(max_request_body_size or 0)
|
|
|
|
|
|
self._max_response_body_size = int(max_response_body_size or 0)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def include_headers(self) -> bool:
|
|
|
|
|
|
return self.log_level in {"headers", "full"}
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def include_bodies(self) -> bool:
|
|
|
|
|
|
return self.log_level == "full"
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块
Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整
测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
|
|
|
|
def supports_background_submission(self) -> bool:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
async def record_success(self, **kwargs: Any) -> None:
|
|
|
|
|
|
await self._publish_event(UsageEventType.COMPLETED, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
async def record_failure(self, **kwargs: Any) -> None:
|
|
|
|
|
|
await self._publish_event(UsageEventType.FAILED, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
async def record_cancelled(self, **kwargs: Any) -> None:
|
|
|
|
|
|
await self._publish_event(UsageEventType.CANCELLED, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
async def _publish_event(self, event_type: UsageEventType, **kwargs: Any) -> None:
|
|
|
|
|
|
redis_client = await get_redis_client(require_redis=False)
|
|
|
|
|
|
if not redis_client:
|
|
|
|
|
|
raise RuntimeError("Redis unavailable for usage queue")
|
|
|
|
|
|
|
|
|
|
|
|
data = self._build_event_data(**kwargs)
|
|
|
|
|
|
event = build_usage_event(
|
|
|
|
|
|
event_type=event_type,
|
|
|
|
|
|
request_id=self.request_id,
|
|
|
|
|
|
data=data,
|
|
|
|
|
|
)
|
|
|
|
|
|
maxlen = config.usage_queue_stream_maxlen
|
|
|
|
|
|
try:
|
|
|
|
|
|
if maxlen > 0:
|
|
|
|
|
|
await redis_client.xadd(
|
|
|
|
|
|
config.usage_queue_stream_key,
|
|
|
|
|
|
event.to_stream_fields(),
|
|
|
|
|
|
maxlen=maxlen,
|
|
|
|
|
|
approximate=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
await redis_client.xadd(config.usage_queue_stream_key, event.to_stream_fields())
|
|
|
|
|
|
except Exception as exc:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
logger.error("[usage-queue] XADD failed: {}", exc)
|
2026-01-28 09:54:18 +08:00
|
|
|
|
raise
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
def _mask_headers(self, headers: Any) -> Any:
|
|
|
|
|
|
"""Mask sensitive headers before putting them into Redis."""
|
|
|
|
|
|
if not isinstance(headers, dict) or not headers:
|
|
|
|
|
|
return headers
|
|
|
|
|
|
sensitive = {h.lower() for h in self._sensitive_headers if isinstance(h, str) and h}
|
|
|
|
|
|
if not sensitive:
|
|
|
|
|
|
return headers
|
|
|
|
|
|
out: dict[str, Any] = {}
|
|
|
|
|
|
for k, v in headers.items():
|
|
|
|
|
|
key = str(k)
|
|
|
|
|
|
if key.lower() in sensitive:
|
|
|
|
|
|
s = str(v)
|
|
|
|
|
|
if len(s) > 8:
|
|
|
|
|
|
out[key] = s[:4] + "****" + s[-4:]
|
|
|
|
|
|
else:
|
|
|
|
|
|
out[key] = "****"
|
|
|
|
|
|
else:
|
|
|
|
|
|
out[key] = v
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
def _truncate_body(self, value: Any, *, max_size: int, is_request: bool) -> Any:
|
|
|
|
|
|
"""Best-effort truncate body based on SystemConfigService max_*_body_size."""
|
2026-01-28 09:54:18 +08:00
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
2026-02-03 18:48:39 +08:00
|
|
|
|
limit = int(max_size or 0)
|
|
|
|
|
|
if limit <= 0:
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
body_str = json.dumps(value) if isinstance(value, (dict, list)) else str(value)
|
|
|
|
|
|
if len(body_str) <= limit:
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
# Match SystemConfigService.truncate_body contract.
|
|
|
|
|
|
if isinstance(value, (dict, list)):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"_truncated": True,
|
|
|
|
|
|
"_original_size": len(body_str),
|
|
|
|
|
|
"_content": body_str[:limit],
|
|
|
|
|
|
}
|
|
|
|
|
|
kind = "request" if is_request else "response"
|
|
|
|
|
|
return (
|
|
|
|
|
|
body_str[:limit]
|
|
|
|
|
|
+ f"\n... (truncated {kind} body, original size: {len(body_str)} bytes)"
|
|
|
|
|
|
)
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _build_event_data(self, **kwargs: Any) -> dict[str, Any]:
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# 必需字段
|
2026-01-30 03:10:21 +08:00
|
|
|
|
data: dict[str, Any] = {
|
2026-01-28 09:54:18 +08:00
|
|
|
|
"request_id": self.request_id,
|
|
|
|
|
|
"user_id": self.user_id,
|
|
|
|
|
|
"api_key_id": self.api_key_id,
|
|
|
|
|
|
}
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# 可选字段 - 只添加非 None/非默认值,减少 payload 大小
|
|
|
|
|
|
# 注意:消费者端需要处理缺失字段的默认值
|
|
|
|
|
|
if kwargs.get("provider"):
|
|
|
|
|
|
data["provider"] = kwargs["provider"]
|
|
|
|
|
|
if kwargs.get("model"):
|
|
|
|
|
|
data["model"] = kwargs["model"]
|
|
|
|
|
|
if kwargs.get("target_model"):
|
|
|
|
|
|
data["target_model"] = kwargs["target_model"]
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# Token 计数 - 0 是常见值,但仍需传递
|
|
|
|
|
|
input_tokens = kwargs.get("input_tokens", 0)
|
|
|
|
|
|
output_tokens = kwargs.get("output_tokens", 0)
|
|
|
|
|
|
if input_tokens:
|
|
|
|
|
|
data["input_tokens"] = input_tokens
|
|
|
|
|
|
if output_tokens:
|
|
|
|
|
|
data["output_tokens"] = output_tokens
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# 缓存 token(cache_creation_tokens -> cache_creation_input_tokens 映射)
|
|
|
|
|
|
cache_creation = kwargs.get("cache_creation_tokens", 0)
|
|
|
|
|
|
cache_read = kwargs.get("cache_read_tokens", 0)
|
|
|
|
|
|
if cache_creation:
|
|
|
|
|
|
data["cache_creation_input_tokens"] = cache_creation
|
|
|
|
|
|
if cache_read:
|
|
|
|
|
|
data["cache_read_input_tokens"] = cache_read
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
# 缓存 5m/1h 细分
|
|
|
|
|
|
cache_creation_5m = kwargs.get("cache_creation_tokens_5m", 0)
|
|
|
|
|
|
cache_creation_1h = kwargs.get("cache_creation_tokens_1h", 0)
|
|
|
|
|
|
if cache_creation_5m:
|
|
|
|
|
|
data["cache_creation_input_tokens_5m"] = cache_creation_5m
|
|
|
|
|
|
if cache_creation_1h:
|
|
|
|
|
|
data["cache_creation_input_tokens_1h"] = cache_creation_1h
|
|
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# 时间指标
|
|
|
|
|
|
if kwargs.get("response_time_ms") is not None:
|
|
|
|
|
|
data["response_time_ms"] = kwargs["response_time_ms"]
|
|
|
|
|
|
if kwargs.get("first_byte_time_ms") is not None:
|
|
|
|
|
|
data["first_byte_time_ms"] = kwargs["first_byte_time_ms"]
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# 状态信息
|
|
|
|
|
|
status_code = kwargs.get("status_code", 200)
|
|
|
|
|
|
if status_code != 200:
|
|
|
|
|
|
data["status_code"] = status_code
|
|
|
|
|
|
if kwargs.get("error_message"):
|
|
|
|
|
|
data["error_message"] = kwargs["error_message"]
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# 格式信息
|
|
|
|
|
|
request_type = kwargs.get("request_type", "chat")
|
|
|
|
|
|
if request_type != "chat":
|
|
|
|
|
|
data["request_type"] = request_type
|
|
|
|
|
|
if kwargs.get("api_format"):
|
|
|
|
|
|
data["api_format"] = kwargs["api_format"]
|
2026-02-21 13:17:11 +08:00
|
|
|
|
if kwargs.get("api_family"):
|
|
|
|
|
|
data["api_family"] = kwargs["api_family"]
|
|
|
|
|
|
if kwargs.get("endpoint_kind"):
|
|
|
|
|
|
data["endpoint_kind"] = kwargs["endpoint_kind"]
|
2026-01-28 09:54:18 +08:00
|
|
|
|
if kwargs.get("endpoint_api_format"):
|
|
|
|
|
|
data["endpoint_api_format"] = kwargs["endpoint_api_format"]
|
|
|
|
|
|
if kwargs.get("has_format_conversion"):
|
|
|
|
|
|
data["has_format_conversion"] = True
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# 流式标记 - 默认 True,只记录 False
|
|
|
|
|
|
if not kwargs.get("is_stream", True):
|
|
|
|
|
|
data["is_stream"] = False
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# Provider 追踪
|
|
|
|
|
|
if kwargs.get("provider_id"):
|
|
|
|
|
|
data["provider_id"] = kwargs["provider_id"]
|
|
|
|
|
|
if kwargs.get("provider_endpoint_id"):
|
|
|
|
|
|
data["provider_endpoint_id"] = kwargs["provider_endpoint_id"]
|
|
|
|
|
|
if kwargs.get("provider_api_key_id"):
|
|
|
|
|
|
data["provider_api_key_id"] = kwargs["provider_api_key_id"]
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
# 元数据
|
|
|
|
|
|
if kwargs.get("metadata"):
|
|
|
|
|
|
data["metadata"] = kwargs["metadata"]
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
# Optional: Headers (masked)
|
|
|
|
|
|
if self.include_headers:
|
2026-02-21 21:16:35 +08:00
|
|
|
|
for _hdr_key in (
|
|
|
|
|
|
"request_headers",
|
|
|
|
|
|
"provider_request_headers",
|
|
|
|
|
|
"response_headers",
|
|
|
|
|
|
"client_response_headers",
|
|
|
|
|
|
):
|
|
|
|
|
|
if kwargs.get(_hdr_key) is not None:
|
|
|
|
|
|
data[_hdr_key] = self._mask_headers(kwargs[_hdr_key])
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
# Optional: Bodies (truncated)
|
|
|
|
|
|
if self.include_bodies:
|
|
|
|
|
|
request_body = self._truncate_body(
|
|
|
|
|
|
kwargs.get("request_body"),
|
|
|
|
|
|
max_size=self._max_request_body_size,
|
|
|
|
|
|
is_request=True,
|
|
|
|
|
|
)
|
2026-02-21 01:56:28 +08:00
|
|
|
|
provider_request_body = self._truncate_body(
|
|
|
|
|
|
kwargs.get("provider_request_body"),
|
|
|
|
|
|
max_size=self._max_request_body_size,
|
|
|
|
|
|
is_request=True,
|
|
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
response_body = self._truncate_body(
|
|
|
|
|
|
kwargs.get("response_body"),
|
|
|
|
|
|
max_size=self._max_response_body_size,
|
|
|
|
|
|
is_request=False,
|
|
|
|
|
|
)
|
2026-02-21 01:56:28 +08:00
|
|
|
|
client_response_body = self._truncate_body(
|
|
|
|
|
|
kwargs.get("client_response_body"),
|
|
|
|
|
|
max_size=self._max_response_body_size,
|
|
|
|
|
|
is_request=False,
|
|
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if request_body is not None:
|
2026-01-28 09:54:18 +08:00
|
|
|
|
data["request_body"] = request_body
|
2026-02-21 01:56:28 +08:00
|
|
|
|
if provider_request_body is not None:
|
|
|
|
|
|
data["provider_request_body"] = provider_request_body
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if response_body is not None:
|
2026-01-28 09:54:18 +08:00
|
|
|
|
data["response_body"] = response_body
|
2026-02-21 01:56:28 +08:00
|
|
|
|
if client_response_body is not None:
|
|
|
|
|
|
data["client_response_body"] = client_response_body
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
|
|
|
|
|
return data
|