tokenkeeper-ai 0.2.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ """tokenkeeper — AI API 成本监控与限流守护者。
2
+
3
+ Quickstart(5 行接入)::
4
+
5
+ from tokenkeeper import guard
6
+
7
+ guard.install(project="my-app", user="alice")
8
+
9
+ # 你原来的代码一行不改——所有 openai 调用自动记账 + 限额保护
10
+
11
+ 启动看板::
12
+
13
+ $ tokenkeeper dashboard
14
+ # → http://localhost:8501
15
+
16
+ 直接查询账本::
17
+
18
+ from tokenkeeper import ledger
19
+
20
+ calls = ledger.query(since=time.time() - 86400) # 最近 24 小时
21
+ total_usd, total_cny = ledger.total_cost(since=time.time() - 86400)
22
+
23
+ 详细文档见各子模块:
24
+ - :mod:`tokenkeeper.pricing` — 模型价格表与成本计算
25
+ - :mod:`tokenkeeper.ledger` — SQLite 账本
26
+ - :mod:`tokenkeeper.guard` — 限额熔断
27
+ - :mod:`tokenkeeper.core` — 拦截核心
28
+ - :mod:`tokenkeeper.integrations.openai_compat` — OpenAI 兼容协议
29
+
30
+ 设计原则(docs/PROJECT_PLAN.md):
31
+ - 零侵入接入(monkey-patch)
32
+ - 数据自产(dogfooding)
33
+ - 本地优先(SQLite + 本地文件)
34
+ - 国内友好(覆盖国产模型)
35
+ - MIT 开源
36
+
37
+ 异常层级:
38
+ - :class:`tokenkeeper.ledger.LedgerError` — 账本相关错误
39
+ - :class:`tokenkeeper.pricing.PricingConfigError` — 价格配置错误
40
+ - :class:`tokenkeeper.guard.BudgetExceededError` — 预算超限
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ # 版本号遵循语义化版本(semver.org)
46
+ __version__ = "0.1.0"
47
+ __author__ = "tokenkeeper contributors"
48
+
49
+ __all__ = [
50
+ "__version__",
51
+ # 高级 API(用户直接用)
52
+ "guard",
53
+ "ledger",
54
+ "dashboard",
55
+ # 核心数据类(用户可能用)
56
+ "CallRecord",
57
+ "ModelPricing",
58
+ "CostBreakdown",
59
+ # 异常
60
+ "LedgerError",
61
+ "PricingConfigError",
62
+ "BudgetExceededError",
63
+ ]
64
+
65
+
66
+ # ====================================================================
67
+ # 延迟加载(避免循环引用 + 加快 import 速度)
68
+ # ====================================================================
69
+
70
+ # 价格表(无依赖,第一个加载)
71
+ from . import pricing # noqa: E402
72
+ from .pricing import ( # noqa: E402,F401
73
+ ModelPricing,
74
+ CostBreakdown,
75
+ PricingConfigError,
76
+ calculate_cost,
77
+ get_pricing,
78
+ list_models,
79
+ register_custom_pricing,
80
+ USD_TO_CNY,
81
+ PRICING_LAST_UPDATED,
82
+ )
83
+
84
+
85
+ # 账本(依赖 sqlite3)
86
+ from . import ledger # noqa: E402
87
+ from .ledger import ( # noqa: E402,F401
88
+ CallRecord,
89
+ Ledger,
90
+ LedgerError,
91
+ )
92
+
93
+
94
+ # guard 模块自身定义 Budget / Guard / GuardDecision / BudgetExceededError
95
+ # 顶级 guard 单例在 core.py
96
+ from . import guard as _guard_module # noqa: E402,F401
97
+ from .guard import ( # noqa: E402,F401
98
+ Budget,
99
+ Guard,
100
+ GuardDecision,
101
+ BudgetExceededError,
102
+ BUDGET_CACHE_TTL_SECONDS,
103
+ )
104
+
105
+ # 顶级 guard 单例(覆盖 guard 模块名)
106
+ from .core import guard # noqa: E402,F401
107
+
108
+
109
+ # 拦截核心(依赖 guard)
110
+ # 注意:core 暴露顶级 API(guard.install 实际指向 core.install)
111
+ from . import core # noqa: E402,F401
112
+
113
+
114
+ # 集成(可选,依赖 core)
115
+ # 注意:导入这个会自动 patch OpenAI SDK
116
+ # 所以默认不导入,用户需要时显式 from tokenkeeper.integrations import openai_compat
117
+
118
+
119
+ # 看板(可选,依赖 streamlit)
120
+ # 注意:依赖 streamlit,未安装时会报错
121
+ # 用户运行 `tokenkeeper dashboard` 时才会触发
122
+
123
+
124
+ __all__ = sorted(set(__all__ + pricing.__all__ + ledger.__all__ + _guard_module.__all__ + core.__all__))
125
+
126
+
127
+ # 模块信息(方便调试)
128
+ def _info() -> dict:
129
+ """返回 tokenkeeper 运行时信息(用于调试和健康检查)。"""
130
+ return {
131
+ "version": __version__,
132
+ "pricing_models": len(pricing.list_models()),
133
+ "pricing_last_updated": pricing.PRICING_LAST_UPDATED,
134
+ "pricing_overrides": len(pricing._CUSTOM_PRICING),
135
+ "exchange_rate_usd_cny": pricing.get_exchange_rate(),
136
+ "modules_loaded": ["pricing", "ledger", "guard", "core"],
137
+ }
138
+
139
+
140
+ # 启动横幅(导入时打印)
141
+ def _banner() -> str:
142
+ """生成 tokenkeeper 启动横幅(ASCII art)。"""
143
+ return f"""
144
+ ╭─────────────────────────────────────────╮
145
+ │ 🪶 tokenkeeper v{__version__:<10s} │
146
+ │ AI API 成本监控与限流守护者 │
147
+ │ {len(pricing.list_models())} 个内置模型 | 价格更新 {pricing.PRICING_LAST_UPDATED} │
148
+ ╰─────────────────────────────────────────╯
149
+ """
150
+
151
+
152
+ if __name__ == "__main__":
153
+ # 模块自检
154
+ import json
155
+ print(_banner())
156
+ print("运行时信息:")
157
+ print(json.dumps(_info(), ensure_ascii=False, indent=2))
tokenkeeper/cli.py ADDED
@@ -0,0 +1,84 @@
1
+ """tokenkeeper 命令行入口。
2
+
3
+ 命令:
4
+ - tokenkeeper dashboard 启动 Streamlit 看板
5
+ - tokenkeeper version 显示版本
6
+ - tokenkeeper info 显示运行时信息
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+ from . import __version__, _info
15
+
16
+
17
+ def main() -> None:
18
+ """tokenkeeper CLI 主入口。"""
19
+ parser = argparse.ArgumentParser(
20
+ prog="tokenkeeper",
21
+ description="AI API 成本监控与限流守护者",
22
+ )
23
+ subparsers = parser.add_subparsers(dest="command", help="子命令")
24
+
25
+ # version
26
+ subparsers.add_parser("version", help="显示版本")
27
+
28
+ # info
29
+ subparsers.add_parser("info", help="显示运行时信息")
30
+
31
+ # dashboard
32
+ dash_parser = subparsers.add_parser("dashboard", help="启动 Streamlit 看板")
33
+ dash_parser.add_argument(
34
+ "--port", type=int, default=8501,
35
+ help="端口(默认 8501)",
36
+ )
37
+ dash_parser.add_argument(
38
+ "--db", default="./tokenkeeper.db",
39
+ help="SQLite DB 路径(默认 ./tokenkeeper.db)",
40
+ )
41
+
42
+ args = parser.parse_args()
43
+
44
+ if args.command == "version":
45
+ print(f"tokenkeeper {__version__}")
46
+ elif args.command == "info":
47
+ import json
48
+ info = _info()
49
+ info["db_default"] = "./tokenkeeper.db"
50
+ print(json.dumps(info, ensure_ascii=False, indent=2))
51
+ elif args.command == "dashboard":
52
+ run_dashboard(port=args.port, db=args.db)
53
+ else:
54
+ parser.print_help()
55
+ sys.exit(1)
56
+
57
+
58
+ def run_dashboard(port: int, db: str) -> None:
59
+ """启动 Streamlit 看板。"""
60
+ try:
61
+ import streamlit.web.cli as stcli
62
+ except ImportError:
63
+ print(
64
+ "❌ streamlit 未安装。\n"
65
+ "请运行: pip install tokenkeeper[dashboard]"
66
+ )
67
+ sys.exit(1)
68
+
69
+ import os
70
+ dashboard_path = os.path.join(
71
+ os.path.dirname(__file__),
72
+ "dashboard",
73
+ "app.py",
74
+ )
75
+ if not os.path.exists(dashboard_path):
76
+ print(f"❌ 看板文件不存在: {dashboard_path}")
77
+ sys.exit(1)
78
+
79
+ sys.argv = ["streamlit", "run", dashboard_path, "--server.port", str(port)]
80
+ stcli.main()
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
tokenkeeper/core.py ADDED
@@ -0,0 +1,360 @@
1
+ """tokenkeeper.core — 拦截核心与顶级 guard API。
2
+
3
+ 模块职责(架构师定稿,2026-06-23):
4
+ 1. 提供顶级 ``guard`` API(``guard.install()`` / ``guard.uninstall()``)
5
+ 2. 协调 ledger + guard + 各种 integrations
6
+ 3. 维护"已 patch 的 SDK"状态,防止重复 patch
7
+ 4. 提供上下文管理器(``guard.temporarily_disabled()``)
8
+
9
+ 公开 API(__all__):
10
+ - guard (顶级单例)
11
+ - GuardAPI (类)
12
+
13
+ 设计:
14
+ - ``guard`` 是全局单例,第一次 ``from tokenkeeper import guard`` 时创建
15
+ - ``guard.install()`` 一次性初始化 ledger + 注册拦截器
16
+ - ``guard.uninstall()`` 恢复原始 SDK 方法
17
+ - 重复 install() 是幂等的(不会重复 patch)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import threading
24
+ from contextlib import contextmanager
25
+ from pathlib import Path
26
+ from typing import Iterator, Optional
27
+
28
+ from .guard import Budget, BudgetExceededError, Guard, GuardDecision
29
+ from .ledger import Ledger
30
+
31
+ __all__ = [
32
+ "guard",
33
+ "GuardAPI",
34
+ "Budget",
35
+ "BudgetExceededError",
36
+ "GuardDecision",
37
+ ]
38
+
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # ====================================================================
44
+ # 顶级 guard API
45
+ # ====================================================================
46
+
47
+
48
+ class GuardAPI:
49
+ """tokenkeeper 顶级 API(用户用 ``from tokenkeeper import guard``)。
50
+
51
+ 这是单例(顶层只有一个 guard 实例)。线程安全。
52
+
53
+ Examples:
54
+ >>> from tokenkeeper import guard
55
+ >>> guard.install(project="my-app", db_path="./tk.db")
56
+ >>> guard.set_budget(daily_limit_usd=10.0, action="block")
57
+ >>> # 业务代码 0 改动,openai 调用自动记账 + 限额
58
+ >>> guard.uninstall()
59
+ """
60
+
61
+ def __init__(self) -> None:
62
+ self._lock = threading.Lock()
63
+ self._ledger: Optional[Ledger] = None
64
+ self._guard: Optional[Guard] = None
65
+ self._installed: bool = False
66
+ self._project: str = "default"
67
+ self._user: str = "default"
68
+ self._patched_sdks: list[str] = [] # 跟踪已 patch 的 SDK
69
+
70
+ # ------------------------------------------------------------------
71
+ # 安装 / 卸载
72
+ # ------------------------------------------------------------------
73
+
74
+ def install(
75
+ self,
76
+ db_path: str | Path = "./tokenkeeper.db",
77
+ project: str = "default",
78
+ user: str = "default",
79
+ auto_patch_openai: bool = True,
80
+ ) -> None:
81
+ """安装 tokenkeeper。
82
+
83
+ - 创建/打开 ledger
84
+ - 创建 guard
85
+ - (可选)自动 patch OpenAI SDK
86
+
87
+ 重复调用是幂等的。
88
+
89
+ Args:
90
+ db_path: SQLite 文件路径
91
+ project: 项目标识
92
+ user: 用户标识
93
+ auto_patch_openai: 是否自动 patch OpenAI SDK(默认 True)
94
+ """
95
+ with self._lock:
96
+ if self._installed:
97
+ logger.debug("guard.install() 重复调用,已安装,跳过")
98
+ return
99
+
100
+ # 1. 创建 ledger
101
+ self._ledger = Ledger(db_path)
102
+ self._project = project
103
+ self._user = user
104
+
105
+ # 2. 创建 guard
106
+ self._guard = Guard(self._ledger)
107
+
108
+ # 3. (可选)自动 patch OpenAI SDK
109
+ if auto_patch_openai:
110
+ try:
111
+ self._patch_openai()
112
+ except ImportError:
113
+ logger.warning(
114
+ "openai 包未安装,跳过 patch。"
115
+ "用户调用 openai 时不会被 tokenkeeper 拦截。"
116
+ )
117
+ except Exception as e:
118
+ logger.error("patch SDK 失败,但 tokenkeeper 仍可使用: %s", e)
119
+
120
+ # 确保即使 patch 失败,tokenkeeper 也算安装成功
121
+ try:
122
+ self._installed = True
123
+ logger.info(
124
+ "tokenkeeper 已安装: db=%s project=%s user=%s",
125
+ db_path, project, user,
126
+ )
127
+ except Exception as e:
128
+ logger.error("设置 _installed 失败: %s", e)
129
+ raise
130
+ raise
131
+
132
+ def uninstall(self) -> None:
133
+ """卸载 tokenkeeper,恢复原始 SDK。"""
134
+ with self._lock:
135
+ if not self._installed:
136
+ return
137
+
138
+ # 恢复 SDK
139
+ for sdk_name in self._patched_sdks:
140
+ try:
141
+ self._unpatch_sdk(sdk_name)
142
+ except Exception as e:
143
+ logger.error("unpatch %s 失败: %s", sdk_name, e)
144
+ self._patched_sdks.clear()
145
+
146
+ # 关闭 ledger
147
+ if self._ledger is not None:
148
+ self._ledger.close()
149
+
150
+ self._ledger = None
151
+ self._guard = None
152
+ self._installed = False
153
+ logger.info("tokenkeeper 已卸载")
154
+
155
+ @contextmanager
156
+ def temporarily_disabled(self) -> Iterator[None]:
157
+ """上下文管理器:临时关闭拦截(用于性能关键路径)。
158
+
159
+ Examples:
160
+ >>> with guard.temporarily_disabled():
161
+ ... # 这里的 openai 调用不会被记账
162
+ ... client.chat.completions.create(...)
163
+ """
164
+ was_installed = self._installed
165
+ if was_installed:
166
+ self.uninstall()
167
+ try:
168
+ yield
169
+ finally:
170
+ if was_installed:
171
+ self.install(
172
+ db_path=str(self._ledger.db_path) if self._ledger else "./tokenkeeper.db",
173
+ project=self._project,
174
+ user=self._user,
175
+ )
176
+
177
+ # ------------------------------------------------------------------
178
+ # 预算管理(代理到内部 _guard)
179
+ # ------------------------------------------------------------------
180
+
181
+ def set_budget(
182
+ self,
183
+ daily_limit_usd: Optional[float] = None,
184
+ monthly_limit_usd: Optional[float] = None,
185
+ per_call_limit_usd: Optional[float] = None,
186
+ action: str = "block",
187
+ scope: str = "global",
188
+ scope_key: Optional[str] = None,
189
+ ) -> None:
190
+ """设置预算(快捷方法)。
191
+
192
+ Args:
193
+ daily_limit_usd: 日预算
194
+ monthly_limit_usd: 月预算
195
+ per_call_limit_usd: 单次预算
196
+ action: 超限动作("block" / "warn")
197
+ scope: 范围("global" / "project" / "user")
198
+ scope_key: scope 为 project/user 时必填
199
+ """
200
+ if not self._installed:
201
+ raise RuntimeError("请先调用 guard.install()")
202
+ budget = Budget(
203
+ scope=scope,
204
+ scope_key=scope_key,
205
+ daily_limit_usd=daily_limit_usd,
206
+ monthly_limit_usd=monthly_limit_usd,
207
+ per_call_limit_usd=per_call_limit_usd,
208
+ action=action,
209
+ )
210
+ self._guard.set_budget(budget)
211
+
212
+ def clear_budgets(self) -> None:
213
+ """清空所有预算。"""
214
+ if not self._installed:
215
+ return
216
+ self._guard.clear_budgets()
217
+
218
+ # ------------------------------------------------------------------
219
+ # 直接记账(用户手动调用)
220
+ # ------------------------------------------------------------------
221
+
222
+ def record(
223
+ self,
224
+ model: str,
225
+ prompt_tokens: int = 0,
226
+ completion_tokens: int = 0,
227
+ cost_usd: float = 0.0,
228
+ cost_cny: float = 0.0,
229
+ latency_ms: float = 0.0,
230
+ status: str = "success",
231
+ error: Optional[str] = None,
232
+ provider: str = "unknown",
233
+ ) -> Optional[int]:
234
+ """手动记录一次调用(用于非 OpenAI SDK 的场景)。
235
+
236
+ Returns:
237
+ 新插入的 rowid,失败 None
238
+ """
239
+ if not self._installed or self._ledger is None:
240
+ logger.error("guard 未安装,无法记录")
241
+ return None
242
+
243
+ from .ledger import CallRecord
244
+ import time
245
+
246
+ record = CallRecord(
247
+ timestamp=time.time(),
248
+ project=self._project,
249
+ user=self._user,
250
+ provider=provider,
251
+ model=model,
252
+ prompt_tokens=prompt_tokens,
253
+ completion_tokens=completion_tokens,
254
+ cost_usd=cost_usd,
255
+ cost_cny=cost_cny,
256
+ latency_ms=latency_ms,
257
+ status=status,
258
+ error=error,
259
+ )
260
+ return self._ledger.record(record)
261
+
262
+ # ------------------------------------------------------------------
263
+ # 查询(代理到 ledger)
264
+ # ------------------------------------------------------------------
265
+
266
+ def query(self, **kwargs):
267
+ """查询调用记录(代理到 ledger)。"""
268
+ if not self._installed or self._ledger is None:
269
+ return []
270
+ return self._ledger.query(**kwargs)
271
+
272
+ def summary(self, **kwargs):
273
+ """汇总(代理到 ledger)。"""
274
+ if not self._installed or self._ledger is None:
275
+ return []
276
+ return self._ledger.summary(**kwargs)
277
+
278
+ def total_cost(self, **kwargs) -> tuple[float, float]:
279
+ """总成本(代理到 ledger)。"""
280
+ if not self._installed or self._ledger is None:
281
+ return 0.0, 0.0
282
+ return self._ledger.total_cost(**kwargs)
283
+
284
+ # ------------------------------------------------------------------
285
+ # SDK Patch 管理
286
+ # ------------------------------------------------------------------
287
+
288
+ def _patch_openai(self) -> bool:
289
+ """patch OpenAI 和 Anthropic SDK(延迟 import)。
290
+
291
+ Returns:
292
+ bool: 是否成功 patch 至少一个 SDK
293
+ """
294
+ success = False
295
+
296
+ try:
297
+ # OpenAI 兼容协议
298
+ try:
299
+ from .integrations.openai_compat import install as install_openai
300
+ install_openai(self)
301
+ self._patched_sdks.append("openai")
302
+ logger.info("OpenAI SDK 已 patch")
303
+ success = True
304
+ except ImportError as e:
305
+ logger.warning("无法 import openai_compat: %s", e)
306
+ except Exception as e:
307
+ logger.error("patch OpenAI 失败: %s", e)
308
+
309
+ # Anthropic 原生 SDK
310
+ try:
311
+ from .integrations.anthropic import install as install_anthropic
312
+ install_anthropic(self)
313
+ self._patched_sdks.append("anthropic")
314
+ logger.info("Anthropic SDK 已 patch")
315
+ success = True
316
+ except ImportError as e:
317
+ logger.warning("无法 import anthropic: %s", e)
318
+ except Exception as e:
319
+ logger.error("patch Anthropic 失败: %s", e)
320
+ # 不影响 _installed 状态,只是 Anthropic 不记账
321
+ except Exception as e:
322
+ logger.error("patch SDK 过程中发生未预期错误: %s", e)
323
+
324
+ return success
325
+ def _unpatch_sdk(self, sdk_name: str) -> None:
326
+ """unpatch SDK。"""
327
+ if sdk_name == "openai":
328
+ try:
329
+ from .integrations.openai_compat import uninstall as uninstall_openai
330
+ uninstall_openai()
331
+ logger.info("OpenAI SDK 已 unpatch")
332
+ except ImportError:
333
+ pass
334
+ elif sdk_name == "anthropic":
335
+ try:
336
+ from .integrations.anthropic import uninstall as uninstall_anthropic
337
+ uninstall_anthropic()
338
+ logger.info("Anthropic SDK 已 unpatch")
339
+ except ImportError:
340
+ pass
341
+
342
+ # ------------------------------------------------------------------
343
+ # 状态查询
344
+ # ------------------------------------------------------------------
345
+
346
+ def is_installed(self) -> bool:
347
+ """是否已安装。"""
348
+ return self._installed
349
+
350
+ def ledger(self) -> Optional[Ledger]:
351
+ """获取内部 ledger 实例(高级用户用)。"""
352
+ return self._ledger
353
+
354
+ def guard_instance(self) -> Optional[Guard]:
355
+ """获取内部 guard 实例(高级用户用)。"""
356
+ return self._guard
357
+
358
+
359
+ # 全局单例
360
+ guard = GuardAPI()