Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
8e189ab
fix(store): backfill_cost_amounts 固化原始计费货币而非误标 USD
Rain-0x01-39 Aug 26, 2026
1bb9a4f
fix(cost): query_user_cost_total 按主货币口径返回并消除调用方双重换算
Rain-0x01-39 Aug 26, 2026
fdd8fb0
fix(commands): /cost 用主货币口径聚合成本而非混合货币直接相加
Rain-0x01-39 Aug 26, 2026
ea3360a
fix(web_api): api_budgets 限额换算到主货币口径,消除 ratio/exceeded 错判
Rain-0x01-39 Aug 26, 2026
a95efdd
Merge upstream/main: 同步 OpenAI Responses 缓存 token 修复与定价文档更新
Rain-0x01-39 Aug 26, 2026
59267b5
fix(store): 迁移修正旧版 backfill 误标 USD 的历史行货币标记
Rain-0x01-39 Aug 26, 2026
64c0475
fix(budget): override 超限返回 currency 改为主货币
Rain-0x01-39 Aug 26, 2026
f3ebf97
fix(web_api): _supplement_to_dict 无固化金额回退路径换算到主货币
Rain-0x01-39 Aug 26, 2026
17e5be7
fix(frontend): 预算页展示统一主货币口径并消费换算后限额
Rain-0x01-39 Aug 26, 2026
20c566d
Merge upstream/main: 同步定价草稿默认主货币与双 ID 移除,重建前端产物
Rain-0x01-39 Aug 26, 2026
8e531b3
chore: 添加 .gitattributes 规范化构建产物换行并折叠 PR diff
Rain-0x01-39 Aug 26, 2026
541dc8f
fix: 修复剩余货币口径问题(M2/M4/L2/L3/L4)
Rain-0x01-39 Aug 27, 2026
f0c929c
refactor: 清理原始口径死代码与死导入(L1)
Rain-0x01-39 Aug 27, 2026
6e20aa1
Merge upstream/main: 接入 #9 已合并的多货币修复,解决 commands.py 导入冲突
Rain-0x01-39 Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 41 additions & 7 deletions cost_control/ai_diag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "每日全局",
Expand All @@ -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}
Expand Down
7 changes: 1 addition & 6 deletions cost_control/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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],
Expand Down
26 changes: 5 additions & 21 deletions cost_control/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 数 / 主货币花费)。
Expand Down Expand Up @@ -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"

Expand Down
26 changes: 21 additions & 5 deletions cost_control/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__``
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand All @@ -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 '不限'}) "
Expand Down
18 changes: 0 additions & 18 deletions cost_control/cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
14 changes: 8 additions & 6 deletions cost_control/web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down Expand Up @@ -499,14 +499,18 @@ 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",
"code": "unpriced_models",
"title": "存在未定价模型",
"detail": (
f"检测到 {unpriced_count} 个模型未配置定价"
f"(涉及 {unpriced_tokens} token 用量),其成本被计为 $0,"
f"(涉及 {unpriced_tokens} token 用量),其成本被计为 "
f"{_unpriced_sym}0,"
"导致成本统计偏低。请前往定价页为对应 provider 设置单价。"
),
"tab": "pricing",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion pages/dashboard/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 0 additions & 42 deletions tests/test_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from cost_control.budget import (
BudgetMixin,
_groups_cost,
check_dimensions,
check_dimensions_dual,
day_window_start,
Expand All @@ -22,7 +21,6 @@
truncate_contexts,
)
from cost_control.config import (
DEFAULT_PRICING,
enabled_fallback_providers,
enabled_overrides,
normalize_budget_override,
Expand Down Expand Up @@ -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)。"""

Expand Down