diff --git a/cost_control/ai_diag.py b/cost_control/ai_diag.py index 950c8df..8641e07 100644 --- a/cost_control/ai_diag.py +++ b/cost_control/ai_diag.py @@ -267,6 +267,28 @@ async def _collect_diag_data(self) -> dict[str, Any]: limits = self.get_budgets() limits_cost = self.get_budgets_cost() + # 预算 cost 维度:限额与花费均换算到主货币口径(与 /budgets、/alerts 一致) + from .config import get_budgets_cost_currency + from .cost import compute_cost_grouped_in_main + from .exchange_rates import convert, get_main_currency, get_rates + + _main_cur = get_main_currency(getattr(self, "cfg", None)) + _rates = get_rates(getattr(self, "cfg", None)) + _bcc = get_budgets_cost_currency(getattr(self, "cfg", None)) + _pricing = self.get_pricing() + day_cost_c = compute_cost_grouped_in_main( + await self.query_usage_grouped(by="provider_model", start=d_start), + _pricing, + _main_cur, + _rates, + ) + month_cost_c = compute_cost_grouped_in_main( + await self.query_usage_grouped(by="provider_model", start=m_start), + _pricing, + _main_cur, + _rates, + ) + dims: list[dict[str, Any]] = [] dim_labels = { "global_daily": "每日全局", @@ -276,20 +298,32 @@ async def _collect_diag_data(self) -> dict[str, Any]: "per_model_daily": "每模型·每日", } dim_used = {"global_daily": day_total, "global_monthly": month_total} + dim_used_c = {"global_daily": day_cost_c, "global_monthly": month_cost_c} for d in _DIM_ORDER: lt = int(limits.get(d, 0) or 0) - lc = float(limits_cost.get(d, 0) or 0) - used = dim_used.get(d, 0) - if lt > 0 or lc > 0: - ratio = round(used * 100.0 / lt, 1) if lt > 0 else 0 + lc_raw = float(limits_cost.get(d, 0) or 0) + used_t = dim_used.get(d, 0) + used_c = dim_used_c.get(d, 0) + if lt > 0 or lc_raw > 0: + d_cur = str(_bcc.get(d, "") or "") or _main_cur + lc = ( + round(convert(lc_raw, d_cur, _main_cur, _rates), 6) + if lc_raw > 0 and d_cur != _main_cur + else lc_raw + ) + ratio_t = round(used_t * 100.0 / lt, 1) if lt > 0 else 0 + ratio_c = round(used_c * 100.0 / lc, 1) if lc > 0 else 0 dims.append( { "dimension": dim_labels.get(d, d), "token_limit": lt, - "token_used": used, - "token_ratio": ratio, + "token_used": used_t, + "token_ratio": ratio_t, "cost_limit": lc, - "exceeded": used >= lt if lt > 0 else False, + "cost_used": round(used_c, 6), + "cost_ratio": ratio_c, + "exceeded": (used_t >= lt if lt > 0 else False) + or (used_c >= lc if lc > 0 else False), } ) data["budgets"] = {"dimensions": dims} diff --git a/cost_control/analytics.py b/cost_control/analytics.py index 0b1bf92..8cf09a4 100644 --- a/cost_control/analytics.py +++ b/cost_control/analytics.py @@ -19,7 +19,7 @@ from .budget import day_window_start, resolve_tz from .cache_diag import hit_rate from .config import get_config -from .cost import compute_cost_in_main, compute_row_cost, compute_row_cost_in_main +from .cost import compute_cost_in_main, compute_row_cost_in_main def report_window_start( @@ -82,11 +82,6 @@ def compare_windows( return cur_start, cur_end, prev_start, prev_end -def _row_cost(row: dict[str, Any], pricing: dict[str, Any]) -> float: - """按 (provider_id, model) 解析定价算单行成本(纯函数辅助)。无定价返回 0.0。""" - return compute_row_cost(row, pricing) - - def _row_cost_in_main( row: dict[str, Any], pricing: dict[str, Any], diff --git a/cost_control/budget.py b/cost_control/budget.py index cde19a4..1e14334 100644 --- a/cost_control/budget.py +++ b/cost_control/budget.py @@ -33,7 +33,6 @@ from .attributor import _str_tokens from .config import enabled_overrides, get_config -from .cost import compute_row_cost def resolve_tz(context: Any) -> ZoneInfo: @@ -132,24 +131,6 @@ def check_dimensions_dual( return {"exceeded": False, "dim": None, "metric": None, "limit": 0.0, "used": 0.0} -def _groups_cost( - groups: list[dict[str, Any]], - pricing: dict[str, Any], -) -> float: - """把 ``query_usage_grouped(by="provider_model")`` 结果按行求和花费(纯函数)。 - - 每行含 ``provider_id`` / ``provider_model`` / ``count`` / token 字段,委托 - :func:`cost_control.cost.compute_row_cost`(按 provider_id 匹配用户定价、按 mode 计费)。 - """ - total = 0.0 - for g in groups or []: - try: - total += compute_row_cost(g, pricing) - except Exception: - continue - return round(total, 6) - - def total_tokens(usage: dict[str, Any]) -> int: """把三类 token 聚合为总数(纯函数)。""" return ( @@ -327,7 +308,7 @@ async def _override_used( provider_id: str | None, d_start: datetime, pricing: dict[str, Any], - main_cur: str = "$", + main_cur: str = "USD", rates: dict[str, float] | None = None, ) -> float: """按 override target 聚合当前周期的 ``metric`` 用量(token 数 / 主货币花费)。 @@ -679,7 +660,10 @@ def _format_message(self, result: dict[str, Any]) -> str: cur = str(result.get("currency") or "") or get_main_currency(getattr(self, "cfg", None)) sym = currency_to_symbol(cur) - return f"⏸ 已超出花费预算({dim}):{sym}{float(used or 0):.4f} / {sym}{float(limit or 0):.2f}" + return ( + f"⏸ 已超出花费预算({dim}):{sym}{float(used or 0):.4f} / " + f"{sym}{float(limit or 0):.2f}" + ) # token 维度的 used/limit 来自 float() 包装,转 int 避免显示 "150.0"。 return f"⏸ 已超出预算({dim}):用 {int(used or 0)} / 限 {int(limit or 0)} token" diff --git a/cost_control/commands.py b/cost_control/commands.py index a22d55b..73b56e5 100644 --- a/cost_control/commands.py +++ b/cost_control/commands.py @@ -22,9 +22,9 @@ from .attributor import ESTIMATION_NOTE from .budget import _DIM_ORDER, day_window_start, resolve_tz -from .config import get_config +from .config import get_budgets_cost_currency, get_config from .cost import compute_row_cost_in_main -from .exchange_rates import currency_to_symbol, get_main_currency, get_rates +from .exchange_rates import convert, currency_to_symbol, get_main_currency, get_rates # 插件主模块路径(``main.py``)。AstrBot 的 ``star_map`` 以 ``Main.__module__`` # 为键,而 ``update_command_permission`` 等管理接口通过 ``handler.__module__`` @@ -111,7 +111,10 @@ async def cmd_budget(self, event: AstrMessageEvent): """``/budget``:查询预算配置与当前超限状态。""" try: umo = self._umo(event) - sym = currency_to_symbol(get_main_currency(getattr(self, "cfg", None))) + main_cur = get_main_currency(getattr(self, "cfg", None)) + rates = get_rates(getattr(self, "cfg", None)) + bcc = get_budgets_cost_currency(getattr(self, "cfg", None)) + sym = currency_to_symbol(main_cur) budgets = self.get_budgets() budgets_cost = self.get_budgets_cost() overrides = self.get_budget_overrides(getattr(self, "cfg", None)) @@ -126,7 +129,13 @@ async def cmd_budget(self, event: AstrMessageEvent): if t > 0: parts.append(f"token {t}") if c > 0: - parts.append(f"花费 {sym}{c:.2f}") + dim_cur = str(bcc.get(dim, "") or "") or main_cur + c_main = ( + round(convert(c, dim_cur, main_cur, rates), 6) + if dim_cur != main_cur + else c + ) + parts.append(f"花费 {sym}{c_main:.2f}") lines.append(f" {dim}: " + " / ".join(parts)) any_cfg = True if not any_cfg: @@ -138,7 +147,14 @@ async def cmd_budget(self, event: AstrMessageEvent): if ov.get("token_limit", 0) > 0: parts.append(f"token {ov['token_limit']}") if ov.get("cost_limit", 0) > 0: - parts.append(f"花费 {sym}{ov['cost_limit']:.2f}") + ov_cur = str(ov.get("cost_currency") or "") or main_cur + ov_c = float(ov.get("cost_limit") or 0) + ov_c_main = ( + round(convert(ov_c, ov_cur, main_cur, rates), 6) + if ov_cur != main_cur + else ov_c + ) + parts.append(f"花费 {sym}{ov_c_main:.2f}") lines.append( f" · {ov.get('target_type')}:{ov.get('target_value')} " f"({'/'.join(parts) or '不限'}) " diff --git a/cost_control/cost.py b/cost_control/cost.py index a024a36..1eb4ada 100644 --- a/cost_control/cost.py +++ b/cost_control/cost.py @@ -436,21 +436,3 @@ def get_rates(self) -> dict[str, float]: from .config import get_rates return get_rates(getattr(self, "cfg", None)) - - async def compute_cost( - self, - usage: dict[str, Any], - provider_id: str | None, - model: str | None, - ) -> float: - """按生效定价把单条 usage 换算为 USD 成本。 - - Args: - usage: 聚合用量 dict。 - provider_id: Provider ID(用户定价匹配)。 - model: 模型名(默认表匹配)。 - - Returns: - USD 成本(float)。 - """ - return compute_cost_value(usage, provider_id, model, self.get_pricing()) diff --git a/cost_control/web_api.py b/cost_control/web_api.py index c293bd6..cec4e3e 100644 --- a/cost_control/web_api.py +++ b/cost_control/web_api.py @@ -283,7 +283,7 @@ def _parse_iso(s: str | None, *, is_end: bool = False) -> datetime | None: def _supplement_to_dict( s: Any, pricing: dict[str, Any] | None = None, - main_cur: str = "$", + main_cur: str = "USD", rates: dict[str, float] | None = None, ) -> dict[str, Any]: """把 ``CostSupplement`` 行序列化为 JSON 友好 dict。 @@ -499,6 +499,9 @@ async def api_alerts(self, **kwargs: Any) -> dict[str, Any]: + int(r.get("token_output", 0) or 0) ) if unpriced_count > 0: + from .exchange_rates import currency_to_symbol, get_main_currency + + _unpriced_sym = currency_to_symbol(get_main_currency(getattr(self, "cfg", None))) alerts.append( { "level": "warn", @@ -506,7 +509,8 @@ async def api_alerts(self, **kwargs: Any) -> dict[str, Any]: "title": "存在未定价模型", "detail": ( f"检测到 {unpriced_count} 个模型未配置定价" - f"(涉及 {unpriced_tokens} token 用量),其成本被计为 $0," + f"(涉及 {unpriced_tokens} token 用量),其成本被计为 " + f"{_unpriced_sym}0," "导致成本统计偏低。请前往定价页为对应 provider 设置单价。" ), "tab": "pricing", @@ -559,7 +563,8 @@ async def api_alerts(self, **kwargs: Any) -> dict[str, Any]: # 将各维度 cost 限额换算到主货币(budgets_cost_currency 可能设了独立货币) from .config import get_budgets_cost_currency - from .exchange_rates import convert as _conv_alert, get_main_currency, get_rates + from .exchange_rates import convert as _conv_alert + from .exchange_rates import get_main_currency, get_rates _alert_bcc = get_budgets_cost_currency(cfg) _alert_main = get_main_currency(cfg) @@ -641,7 +646,6 @@ async def api_compare(self, **kwargs: Any) -> dict[str, Any]: from .analytics import compare_windows from .budget import total_tokens - from .cost import compute_cost_grouped window = self._param("window", "daily") or "daily" now = datetime.now(UTC) @@ -804,8 +808,6 @@ async def api_records_aggregate(self, **kwargs: Any) -> dict[str, Any]: (可选)、``start`` / ``end``(ISO)。返回每组的 token 三类、条数、成本、占比。 """ try: - from .cost import compute_row_cost - by = self._param("by", "model") or "model" if by not in ("model", "provider", "umo"): by = "model" diff --git a/pages/dashboard/index.html b/pages/dashboard/index.html index 9972385..e5346d7 100644 --- a/pages/dashboard/index.html +++ b/pages/dashboard/index.html @@ -13,6 +13,6 @@ - + diff --git a/tests/test_budget.py b/tests/test_budget.py index db8626e..f2f74ee 100644 --- a/tests/test_budget.py +++ b/tests/test_budget.py @@ -8,7 +8,6 @@ from cost_control.budget import ( BudgetMixin, - _groups_cost, check_dimensions, check_dimensions_dual, day_window_start, @@ -22,7 +21,6 @@ truncate_contexts, ) from cost_control.config import ( - DEFAULT_PRICING, enabled_fallback_providers, enabled_overrides, normalize_budget_override, @@ -366,46 +364,6 @@ def test_check_dimensions_dual_zero_limits_skipped(): assert r["exceeded"] is False -def test_groups_cost_multi_model(): - pricing = {"defaults": DEFAULT_PRICING, "user": {}} - groups = [ - { - "provider_id": None, - "provider_model": "gpt-4o", - "token_input_other": 1_000_000, - "token_input_cached": 0, - "token_output": 0, - }, - { - "provider_id": None, - "provider_model": "gpt-4o-mini", - "token_input_other": 1_000_000, - "token_input_cached": 0, - "token_output": 0, - }, - ] - # gpt-4o 1M input = $2.5;gpt-4o-mini 1M input = $0.15 → 合计 $2.65 - assert abs(_groups_cost(groups, pricing) - 2.65) < 1e-6 - - -def test_groups_cost_unpriced_zero(): - pricing = {"defaults": DEFAULT_PRICING, "user": {}} - groups = [ - { - "provider_id": None, - "provider_model": "nonexistent-xyz", - "token_input_other": 1_000_000, - "token_input_cached": 0, - "token_output": 0, - }, - ] - assert _groups_cost(groups, pricing) == 0.0 - - -def test_groups_cost_empty(): - assert _groups_cost([], {"defaults": DEFAULT_PRICING, "user": {}}) == 0.0 - - class _BudgetStub(BudgetMixin): """仅带 config 的 BudgetMixin 实例(测 get_budgets_cost,不触 DB/context)。"""