File size: 6,065 Bytes
fa1140b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | """Token 用量存储与聚合(SQLite,单文件 logs/usage.db)。
每次 /v1 请求拿到 usage 后记一行(时间戳 + 路径 + 模型 + token 数)。
面板按 24h / 1d / 3d / 7d / 30d 时间窗聚合:分桶时间序列 + 分模型汇总 + 总计。
- 纯标准库 sqlite3,无外部依赖;WAL 模式并发友好,进程内 threading 锁串行写。
- 查询失败/库损坏不阻断主流程(记 usage 是旁路,绝不影响 API 响应)。
"""
from __future__ import annotations
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any
_LOCK = threading.Lock()
_CONN: sqlite3.Connection | None = None
_DB_PATH: Path | None = None
# 时间窗 → 秒数 + 分桶粒度(秒)。粒度决定折线图点数(点数 = 窗口/粒度)。
_WINDOWS: dict[str, tuple[int, int]] = {
"24h": (24 * 3600, 3600), # 24 点(每小时)
"1d": (24 * 3600, 3600), # 同 24h(别名)
"3d": (3 * 24 * 3600, 3 * 3600), # 24 点(每 3 小时)
"7d": (7 * 24 * 3600, 6 * 3600), # 28 点(每 6 小时)
"30d": (30 * 24 * 3600, 24 * 3600), # 30 点(每天)
}
def init(db_path: str | Path) -> None:
"""初始化数据库连接与表结构(应用启动时调一次)。"""
global _CONN, _DB_PATH
with _LOCK:
_DB_PATH = Path(db_path)
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
_CONN = sqlite3.connect(str(_DB_PATH), check_same_thread=False)
_CONN.execute("PRAGMA journal_mode=WAL")
_CONN.execute("PRAGMA synchronous=NORMAL")
_CONN.execute(
"""CREATE TABLE IF NOT EXISTS usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
path TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0
)"""
)
_CONN.execute("CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage(ts)")
_CONN.commit()
def _int(v: Any) -> int:
try:
return int(v or 0)
except (TypeError, ValueError):
return 0
def record(usage: dict[str, Any] | None, *, path: str = "", model: str = "") -> None:
"""记录一次用量(旁路,异常静默)。usage 支持 OpenAI/Anthropic 两种字段名。"""
if _CONN is None or not usage:
return
inp = _int(usage.get("input_tokens") or usage.get("prompt_tokens"))
out = _int(usage.get("output_tokens") or usage.get("completion_tokens"))
tot = _int(usage.get("total_tokens")) or (inp + out)
if not (inp or out or tot):
return
try:
with _LOCK:
_CONN.execute(
"INSERT INTO usage(ts, path, model, input_tokens, output_tokens, total_tokens) "
"VALUES(?,?,?,?,?,?)",
(time.time(), path or "", model or "", inp, out, tot),
)
_CONN.commit()
except Exception: # noqa: BLE001
pass
def aggregate(window: str = "24h") -> dict[str, Any]:
"""按时间窗聚合:返回分桶时间序列 + 分模型汇总 + 总计。
返回 ``{window, since, buckets:[{t, input, output, total, requests}],
by_model:[{model, input, output, total, requests}], total:{...}}``。
"""
span, gran = _WINDOWS.get(window, _WINDOWS["24h"])
now = time.time()
since = now - span
empty = {
"window": window, "since": since, "granularity": gran,
"buckets": [], "by_model": [], "requests": 0,
"total": {"input": 0, "output": 0, "total": 0, "requests": 0},
}
if _CONN is None:
return empty
try:
with _LOCK:
rows = _CONN.execute(
"SELECT ts, model, input_tokens, output_tokens, total_tokens "
"FROM usage WHERE ts >= ? ORDER BY ts",
(since,),
).fetchall()
except Exception: # noqa: BLE001
return empty
# 时间分桶(对齐到 gran 边界,保证前端 x 轴均匀)
n_buckets = max(1, int(span // gran))
start = now - n_buckets * gran
buckets = [
{"t": start + i * gran, "input": 0, "output": 0, "total": 0, "requests": 0}
for i in range(n_buckets)
]
by_model: dict[str, dict[str, int]] = {}
tot_in = tot_out = tot_tot = tot_req = 0
for ts, model, inp, out, tot in rows:
idx = int((ts - start) // gran)
if 0 <= idx < n_buckets:
b = buckets[idx]
b["input"] += inp; b["output"] += out; b["total"] += tot; b["requests"] += 1
m = by_model.setdefault(model or "(unknown)",
{"input": 0, "output": 0, "total": 0, "requests": 0})
m["input"] += inp; m["output"] += out; m["total"] += tot; m["requests"] += 1
tot_in += inp; tot_out += out; tot_tot += tot; tot_req += 1
by_model_list = sorted(
[{"model": k, **v} for k, v in by_model.items()],
key=lambda x: x["total"], reverse=True,
)
return {
"window": window, "since": since, "granularity": gran,
"buckets": buckets, "by_model": by_model_list, "requests": tot_req,
"total": {"input": tot_in, "output": tot_out, "total": tot_tot, "requests": tot_req},
}
def purge_older_than(seconds: float) -> int:
"""删除超过保留期的旧记录,返回删除行数(可选维护,默认不调用)。"""
if _CONN is None:
return 0
try:
with _LOCK:
cur = _CONN.execute("DELETE FROM usage WHERE ts < ?", (time.time() - seconds,))
_CONN.commit()
return cur.rowcount
except Exception: # noqa: BLE001
return 0
def close() -> None:
"""关闭连接(测试/关停用)。"""
global _CONN
with _LOCK:
if _CONN is not None:
try:
_CONN.close()
except Exception: # noqa: BLE001
pass
_CONN = None
|