"""Token 预算追踪器 — 从 ai_run_logs 聚合日/月 token 消耗。 每次 AI 调用前检查预算,超限时拒绝请求。 日预算默认 100,000 tokens,月预算默认 2,000,000 tokens。 聚合数据通过构造函数注入的 callable 获取(解耦 AIRunLogService), callable 签名:() -> int,分别返回当日/当月已消耗 token 数。 """ from __future__ import annotations from dataclasses import dataclass from typing import Callable, Protocol class UsageProvider(Protocol): """Token 用量数据提供者协议。""" def get_daily_usage(self) -> int: """返回当日已消耗 token 数。""" ... def get_monthly_usage(self) -> int: """返回当月已消耗 token 数。""" ... @dataclass class BudgetStatus: """预算检查结果。""" allowed: bool daily_used: int monthly_used: int reason: str | None = None # "daily_exceeded" / "monthly_exceeded" / None class BudgetTracker: """Token 预算追踪器,从 ai_run_logs 聚合。 支持两种注入方式: 1. 传入 UsageProvider 实例(如 AIRunLogService) 2. 传入两个 callable:get_daily_usage / get_monthly_usage """ def __init__( self, daily_limit: int = 100_000, monthly_limit: int = 2_000_000, *, get_daily_usage: Callable[[], int] | None = None, get_monthly_usage: Callable[[], int] | None = None, usage_provider: UsageProvider | None = None, ) -> None: self.daily_limit = daily_limit self.monthly_limit = monthly_limit # 优先使用 usage_provider,其次使用独立 callable if usage_provider is not None: self._get_daily_usage = usage_provider.get_daily_usage self._get_monthly_usage = usage_provider.get_monthly_usage elif get_daily_usage is not None and get_monthly_usage is not None: self._get_daily_usage = get_daily_usage self._get_monthly_usage = get_monthly_usage else: raise ValueError( "必须提供 usage_provider 或同时提供 " "get_daily_usage 和 get_monthly_usage callable" ) def check_budget(self) -> BudgetStatus: """检查当前预算状态。 先检查日预算,再检查月预算。 任一超限即返回 allowed=False 并附带原因。 """ daily_used = self._get_daily_usage() monthly_used = self._get_monthly_usage() if daily_used >= self.daily_limit: return BudgetStatus( allowed=False, daily_used=daily_used, monthly_used=monthly_used, reason="daily_exceeded", ) if monthly_used >= self.monthly_limit: return BudgetStatus( allowed=False, daily_used=daily_used, monthly_used=monthly_used, reason="monthly_exceeded", ) return BudgetStatus( allowed=True, daily_used=daily_used, monthly_used=monthly_used, reason=None, )