2026-01-28 09:54:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Usage 事件定义与序列化工具(用于 Redis Streams)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from __future__ import annotations
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import time
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from enum import Enum
|
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
|
|
|
|
import msgpack
|
|
|
|
|
|
from msgpack.exceptions import OutOfData
|
|
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
USAGE_EVENT_VERSION = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UsageEventType(str, Enum):
|
|
|
|
|
|
STREAMING = "streaming"
|
|
|
|
|
|
COMPLETED = "completed"
|
|
|
|
|
|
FAILED = "failed"
|
|
|
|
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def now_ms() -> int:
|
|
|
|
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sanitize_value(value: Any) -> Any:
|
|
|
|
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
|
|
|
|
return value
|
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
|
return {str(k): _sanitize_value(v) for k, v in value.items()}
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
return [_sanitize_value(item) for item in value]
|
|
|
|
|
|
return str(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def sanitize_payload(data: dict[str, Any]) -> dict[str, Any]:
|
2026-01-28 09:54:18 +08:00
|
|
|
|
return {str(k): _sanitize_value(v) for k, v in data.items()}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
def _decode_payload(raw: Any) -> dict[str, Any]:
|
|
|
|
|
|
"""兼容解码:优先 msgpack,回退旧 JSON。
|
|
|
|
|
|
|
|
|
|
|
|
统一将输入归一化为 bytes 后走单一解码路径:msgpack → JSON fallback。
|
|
|
|
|
|
str 输入来自 decode_responses=True + surrogateescape 的 Redis 客户端,
|
|
|
|
|
|
通过 surrogateescape 可无损还原回原始 bytes。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(raw, str):
|
|
|
|
|
|
raw = raw.encode("utf-8", errors="surrogateescape")
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(raw, (bytes, bytearray, memoryview)):
|
|
|
|
|
|
raise ValueError("Invalid payload field in usage event")
|
|
|
|
|
|
|
|
|
|
|
|
payload_bytes = bytes(raw)
|
|
|
|
|
|
|
|
|
|
|
|
# 新格式:msgpack
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = msgpack.unpackb(payload_bytes, raw=False)
|
|
|
|
|
|
except (ValueError, OutOfData, TypeError):
|
|
|
|
|
|
# 兼容旧格式:JSON bytes(含 surrogateescape 还原后的纯 UTF-8 JSON)
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc:
|
|
|
|
|
|
raise ValueError("Invalid payload field in usage event") from exc
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
|
raise ValueError("Invalid payload field in usage event")
|
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-28 09:54:18 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class UsageEvent:
|
|
|
|
|
|
event_type: UsageEventType
|
|
|
|
|
|
request_id: str
|
|
|
|
|
|
timestamp_ms: int
|
2026-01-30 03:10:21 +08:00
|
|
|
|
data: dict[str, Any]
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
def to_stream_fields(self) -> dict[str, bytes]:
|
|
|
|
|
|
"""序列化为 Redis Stream 字段。
|
|
|
|
|
|
|
|
|
|
|
|
该函数返回 bytes payload,要求读写 usage queue 的 Redis 客户端使用
|
|
|
|
|
|
decode_responses=True,并以 surrogateescape 做 UTF-8 编解码,
|
|
|
|
|
|
以保证 bytes <-> str 往返无损。
|
|
|
|
|
|
"""
|
2026-01-28 09:54:18 +08:00
|
|
|
|
payload = {
|
|
|
|
|
|
"v": USAGE_EVENT_VERSION,
|
|
|
|
|
|
"type": self.event_type.value,
|
|
|
|
|
|
"request_id": self.request_id,
|
|
|
|
|
|
"timestamp_ms": self.timestamp_ms,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
# 兜底清洗,避免 metadata 中混入非 JSON 类型导致队列写入失败。
|
2026-01-28 09:54:18 +08:00
|
|
|
|
"data": sanitize_payload(self.data),
|
|
|
|
|
|
}
|
2026-03-14 11:59:07 +08:00
|
|
|
|
return {"payload": msgpack.packb(payload, use_bin_type=True)}
|
2026-01-28 09:54:18 +08:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def from_stream_fields(cls, fields: dict[str, Any]) -> UsageEvent:
|
2026-01-28 09:54:18 +08:00
|
|
|
|
raw = fields.get("payload")
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
raise ValueError("Missing payload field in usage event")
|
2026-03-14 11:59:07 +08:00
|
|
|
|
payload = _decode_payload(raw)
|
2026-01-28 09:54:18 +08:00
|
|
|
|
event_type = UsageEventType(payload["type"])
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
event_type=event_type,
|
|
|
|
|
|
request_id=payload["request_id"],
|
|
|
|
|
|
timestamp_ms=int(payload.get("timestamp_ms", 0)),
|
|
|
|
|
|
data=payload.get("data", {}) or {},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_usage_event(
|
|
|
|
|
|
*,
|
|
|
|
|
|
event_type: UsageEventType,
|
|
|
|
|
|
request_id: str,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
data: dict[str, Any],
|
|
|
|
|
|
timestamp_ms: int | None = None,
|
2026-01-28 09:54:18 +08:00
|
|
|
|
) -> UsageEvent:
|
|
|
|
|
|
return UsageEvent(
|
|
|
|
|
|
event_type=event_type,
|
|
|
|
|
|
request_id=request_id,
|
|
|
|
|
|
timestamp_ms=timestamp_ms or now_ms(),
|
|
|
|
|
|
data=data,
|
|
|
|
|
|
)
|