pi-spend-guard0.9.1
Hard budget enforcement for pi: warn, auto-downgrade to a cheaper model, and block requests before you overspend.
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-spend-guard
pi 的硬性预算守卫。 超支前先警告、自动降级到便宜模型、必要时直接拦截请求。
pi 已经能告诉你花了多少。但没有任何东西阻止你花掉它。
问题在哪
pi 生态里不缺「显示花费」的扩展——状态栏、可观测性插件、用量面板。它们全是只读的。当 agent 在凌晨三点陷入循环、连续跑掉四十次工具调用时,一个状态栏不是控制手段。
pi-spend-guard 是控制手段。它读取 provider 真实上报的成本,然后逐级升级应对:
| 花费达到 | 会发生什么 |
|---|---|
warn |
通知你 |
downgrade |
本次运行切换到更便宜的模型 |
block |
请求在发出前就被中止 |
三个档位可分别配置,作用于三个维度:session(本次会话)、daily(今日)、monthly(本月)。
和「会话级花费上限」的区别
只做会话级上限是最常见的做法:一个限额,超了就中止。这留下两个缺口。
- 一次会话不等于一个计费周期。 一天里五十次小会话,每一次都没超会话上限,
但月度花费可能已经失控。本扩展从持久化账本里计算
daily和monthly预算, 与session并行生效。 - 中止是最后手段,不是第一手段。 跨过软阈值时切换到更便宜的模型,让任务 继续完成,而不是直接停掉。目前没有别的会话上限方案这么做。
此外,模型没有价格数据时会明确警告——这是唯一会让所有预算静默失效的故障模式。
从 npm 安装
pi install npm:pi-spend-guard
npm 版本可能落后于仓库:暂存发布需要人工 2FA 审批,所以 npm 上的版本可能早于下文描述的功能。
pi install git:github.com/xinghe-labs/pi-spend-guard 始终是最新代码,/budget 会如实报告
当前运行的副本实际在做什么。
不改动 settings 直接试:
pi -e npm:pi-spend-guard
从 git 安装(不需要 npm 账号)
本扩展没有运行时依赖,所以可以直接从仓库安装:
pi install git:github.com/xinghe-labs/pi-spend-guard
两种方式行为完全一致,唯一区别是曝光:只有发布到 npm 的包才会出现在 pi 扩展目录 里。
快速开始
装上就生效,默认阈值如下:
| 维度 | warn | downgrade | block |
|---|---|---|---|
| session | $2 | $5 | $10 |
| daily | $10 | $25 | $50 |
| monthly | $100 | $250 | $500 |
随时查看当前状态:
/budget
session $3.42 daily $11.80 monthly $63.10
Budget warning: $11.80 of $10.00 (daily)
enabled: true block: true retention: 120d
session warn $2 downgrade $5 block $10
daily warn $10 downgrade $25 block $50
monthly warn $100 downgrade $250 block $500
downgrade: cheapest available
验证过的行为
拦截不是一句承诺,是可以直接观察的。把 block 设为 0:
$ pi -ne -e ./src/extension.ts --budget-config ./examples/block-all.json -p "Reply with exactly: ok"
Request aborted
同一条命令换成正常预算,输出就是 ok。
预检估算会在请求发出之前拦住那个会越线的请求。下面这个例子里,模型在 pi 目录中
完全没有价格——只有守卫自己的 pricing 表能给它定价——而且此时花费还是 $0:
$ PI_CODING_AGENT_DIR=/tmp/verify pi -ne -e ./src/extension.ts --budget-config /tmp/with-pricing.json -p "hi"
Request aborted
$ PI_CODING_AGENT_DIR=/tmp/verify pi -ne -e ./src/extension.ts --budget-config /tmp/no-pricing.json -p "hi"
Connection error.
同一个模型、同一套阈值、同样 $0 已花。唯一的差别就是那张 pricing 表,而这正是
「守卫真的在拦」和「守卫什么也没做」的分界。
命令
| 命令 | 作用 |
|---|---|
/budget |
查看花费、当前判定、定价状态、生效配置 |
/budget set daily.block=20 |
修改阈值并持久化 |
/budget set session.warn=none |
关掉单个档位 |
/budget set downgrade.model=deepseek-chat |
指定降级目标模型 |
/budget set estimate.enabled=false |
关闭预检估算 |
/budget set estimate.outputTokens=4096 |
估算时假定更长的回复 |
/budget on / /budget off |
开关拦截 |
/budget reset |
清空持久化账本 |
/budget models |
按模型统计花费,从高到低,并标出定价来源 |
/budget explain |
显示当前模型在各定价层各自的价格,以及最终采用了哪一层 |
配置
配置文件位于 <agent 目录>/pi-spend-guard/config.json,通常是
~/.pi/agent/pi-spend-guard/config.json。完整示例见 examples/config.json。
{
"enabled": true,
"limits": {
"session": { "warn": 2, "downgrade": 5, "block": 10 },
"daily": { "warn": 10, "downgrade": 25, "block": 50 },
"monthly": { "warn": 100, "downgrade": 250, "block": 500 }
},
"downgrade": {
"enabled": true,
"model": null,
"preferProviders": ["deepseek"]
},
"block": { "enabled": true },
"estimate": { "enabled": true, "outputTokens": 2048 },
"pricing": {
"my-provider/*": { "input": 0.5, "output": 1.5 }
},
"notify": { "onWarn": true, "onDowngrade": true, "onBlock": true },
"retentionDays": 120
}
要点:
- 省略某个维度 = 继承默认值;写成
null= 完全关闭该维度。 - 省略单个档位 = 继承默认值;写成
null= 只关闭该档位。 0是有效阈值,会立即触发。block.enabled: false保留警告但永不中止,适合先试跑一段时间。downgrade.model优先于preferProviders;两者都不设则自动选当前可用的最便宜模型。estimate控制预检估算。它需要价格才能工作,没有价格时自动退化为「只按已花金额」判定。pricing是守卫自己的费率表,用于 pi 没有价格的模型。
命令行参数:--budget-config <path> 指定另一个配置文件;--no-budget 让本次运行完全不拦截。
定价:守卫怎么知道一个模型多少钱
成本按四步解析,可信度从高到低:
- provider 上报的金额。
Usage.cost.total反映了真实折扣、请求级阶梯价、以及 provider 侧的缓存定价,这些本地查表无从得知。 - 你的
pricing表,写在守卫自己的配置里。 - pi 的模型目录——
~/.pi/agent/models.json里模型定义上的cost。 - 公开价目表——默认是 models.dev,后台拉取并缓存在本地。
之所以需要第 2–4 步,是因为第 1 步经常什么都不报,而第 2、3 步在很多环境里同样是空的: 本地网关、自建代理、转售商、以及任何 pi 没有内置价格的模型。第 4 步让守卫零配置即可 工作——装上就能给它正在跑的模型定价。
provider 上报的 $0 不会被直接采信。实测某转售网关对它实际计费的请求也上报
cost.total = 0:只要有费率可用,就按费率计价;而既上报 $0 又无任何费率时,记为
unpriced,绝不会当成免费。
{
"rates": { "remote": { "enabled": true, "ttlHours": 24, "url": "https://models.dev/api.json" } },
"pricing": {
"workbuddy/global:glm-5.3": { "input": 0.27, "output": 1.1 },
"workbuddy/*": { "input": 0.5, "output": 1.5 },
"deepseek-chat": { "input": 0.28, "output": 0.42 }
}
}
费率单位是美元 / 百万 token。键的匹配顺序是从最具体到最宽松:
provider/model → provider/* → 裸 model。裸 * 是最后手段:只有在模型目录与
公开价目表都定不了价时才查它——一条给所有 provider 的所有模型定价的规则,不该压过眼前
这个模型的真实价格。一个完全定不了价的条目
(费率全为 0)会被跳过,而不是让它遮蔽更宽松的键。省略 cacheRead /
cacheWrite 时回退到 input 费率;需要按 token 阈值阶梯计费的模型可以写
tiers:
{
"pricing": {
"anthropic/claude-sonnet": {
"input": 3,
"output": 15,
"cacheRead": 0.3,
"tiers": [{ "inputTokensAbove": 200000, "input": 6, "output": 22.5 }]
}
}
}
守卫会替你去读 models.dev。session 启动时先从磁盘加载缓存
(离线、瞬时),缓存超过 rates.remote.ttlHours 后在后台刷新。启动从不等待,provider
请求也从不等待——守卫绝不会因为网络而阻塞请求。唯一存在等待的地方是记账:当一次调用
结束、而此刻还没有任何来源能给这个模型定价时,message_end 最多等 1.5 秒等在途的
价目表,因为「未定价」的记录是永久性的。超过这个界限就记为 unpriced,从下一次调用起
使用已落地的价目表。匹配刻意保守——先精确匹配 provider/model,
再退到裸 model id,所以 global:glm-5.3 这种网关前缀也能解析出来。当同一个 id 被多个
非第一方 provider 发布时,模型保持 unpriced,而不是套用某个转售商的价格:错的价格会
永久性地算错每一个预算,而缺失的价格会被如实报告。把
"rates": { "remote": { "enabled": false } } 设为关闭即可让守卫完全离线。
每条估算记录都会存下定价来源、命中的键(rateKey)、实际使用的费率(ratesUsed),以及
(对公开价目表而言)该快照的抓取时间。/budget explain 会打印当前模型的完整链条,/budget models 会按模型显示命中的键。数字看着不对时,就从这里查。
未定价的记录不是永久的:价目表稍后到达(或下一个 session)时,那些未知的记录会按已存的 用量重新定价并重新落盘。这也是下面那个「记账等待」只是锦上添花、而非必需的原因。
价目表是守卫唯一会拉取的外部数据,因此按不可信输入处理:只接受 https(回环地址可用
http)、跨源重定向一律拒绝、响应体有大小上限、超过 $10,000/Mtok 的费率会被拒绝、整体
不可信的载荷会被整份丢弃而不是读一半。每条估算记录还会存下定价来源 rateSource,让优先级
链条事后可审计。
网关改过名的模型,用别名解决。 本地网关可以给模型起任意名字,而改过名的模型在价目表里 查不到——守卫不会去猜。一行配置即可永久解决:
{
"rates": {
"aliases": { "workbuddy/global:deepseek-v4.1-flash": "deepseek-flash" }
}
}
键的匹配顺序与 pricing 表一致(最具体优先),所以一条 "my-gateway/*" 就能重定向整个
provider。值可以是裸的公开 id,也可以写成 provider/id 连 provider 一起钉住。/budget explain 会显示实际跟随了哪个别名。
你也可以手动抄价——models.dev 及其
api.json 收录了数百个 provider 的价格。请取模型的
第一方条目(provider zai、deepseek、moonshotai 等),而不是转售商的,并务必
显式写上 cacheWrite:配置表在缺省时会把 cacheWrite 回退到 input 费率,而像 Kimi
K3(缓存写 $3/Mtok,与 input 相同碰巧无害)或 GLM(缓存写真的是 $0,缺省会被多计 50 倍)
这样的模型,省略这个字段可能错上几个数量级。
也可以把费率写回它本该待的地方——pi 模型目录:
{
"providers": {
"my-provider": {
"models": [
{
"id": "my-model",
"cost": { "input": 0.5, "output": 1.5, "cacheRead": 0.05, "cacheWrite": 0.5 }
}
]
}
}
}
如果一个请求哪里都定不了价,守卫不会假装它是免费的。 这条记录会带上
"unpriced": true 落盘,session 开始时会有一句警告直接点出模型名和该加的配置键,
/budget 也会报告账本里有多少条无价记录。「价格未知」和「真的是 0」是两回事,
账本会把它们分开。
预检估算
只看「已花金额」的判定,只能在钱花掉之后才拦住请求。开启 estimate(默认开启)后,
守卫还会预估即将发出的这个请求本身要花多少,如果这个预估值会越线,就直接在发出前拦住。
可直接运行的示例见 examples/estimate.json:
$ pi -ne -e ./src/extension.ts --budget-config ./examples/estimate.json -p "hi"
Request aborted
一分钱都没花——请求被拦下时 session spend 还是 $0。拦截文案会区分这两种情况,
不会把「还没花」说成「已经花超」:
Budget: this request (~$0.0033) would take session spend to $0.0033, past the $0.001 limit — blocked before sending
预估是缓存感知且随会话自适应的。输入端:上下文大小里有多少会命中缓存,按最近一次
assistant 用量中 input 与 cacheRead 的实际占比推算——长会话大部分上下文走缓存(通常比
未缓存便宜一个数量级),按全量未缓存估算会把跑得最久的会话误拦得最狠。输出端:取本会话
最近几次真实回复长度的平均值,而不是固定的 estimate.outputTokens(默认 2048,只在
会话尚无任何输出时兜底)——固定值对短回复会话偏高、对长回复会话偏低。
想要纯粹的「只按已花金额」判定,把 estimate.enabled 设为 false;想让兜底的输出假设
更保守就调大 estimate.outputTokens。
估算需要价格。上下文大小或费率缺失时,它会退化为只按已花金额判定,而不是凭空编一个数字。
工作原理
before_agent_start ──► 判定 ──► 降级(pi.setModel)
before_provider_request ──► 预估成本 ──► 判定 ──► 拦截(ctx.abort)
message_end ──► 解析成本 ──► 合并并立即写入 ledger.json
session_start ──► 加载配置与账本,模型无价格时警告
五个刻意的设计决定:
session 花费每次从 session branch 重建,不在内存里累加。
每次判定都遍历 ctx.sessionManager.getBranch(),同时累加 message 条目里的 assistant usage 和 usage 条目(cache warming 之类)。因此 fork、/reload、恢复会话得到的数字完全一致——内存计数器会漂移。而漏掉非 message 的 usage 条目会导致静默少算。
provider 上报的成本永远优先于本地价目表。
Usage.cost.total 反映了真实折扣、请求级阶梯价、以及 provider 侧的缓存定价,这些本地查表无从得知。价目表只是兜底,且兜底结果会标记 "estimated": true,让你分得清「实测成本」和「猜的成本」。
拦截发生在 before_provider_request。
这个钩子在每一次 provider 调用前触发,包括单次 agent run 内部的每一轮后续请求,而不只是开头一次。降级单独放在 before_agent_start,保证便宜的模型对整轮运行生效。
并发会话共享一份账本且互不丢记录。
每次写入都会在独占锁(原子 mkdir、有界重试)下重新读文件并合并,每次预算判定——包括
会 abort 的那一次——判定前也会重新读。同时开两个 pi 窗口,两边都存得下来,daily/monthly
预算看到的也是整台机器的花费,而不是某个会话的过时快照。保留期与记录上限作用于合并后
的文件(在锁内),账本不会无界增长。记录在 message_end 就落盘,崩溃不会抹掉刚发生的
花费。/budget reset 是唯一刻意的例外:它覆盖而不是合并,因为用户要的就是清空。
拦截是预防性的,而不只是事后记账。
发送之前,守卫会预估这个请求本身的成本并加到所有维度上。会越线的请求会在花费仍然低于
阈值时就被拦住,而且文案会如实说明——写「would take session spend to $X」,而不是谎称
你已经花超了。预估是失败安全的:拿不到上下文大小或费率时它返回 0,退回按已花金额判定,
而不是猜一个可能误拦好请求的数字。
局限
直说,因为一个悄悄失效的预算守卫比没有更糟:
- 哪里都定不了价的模型无法计量。 provider 上报的成本、
pricing表、模型目录、公开价目表按这个顺序依次查。四者都没声音时,守卫会在 session 开始时说明,把该次调用标成"unpriced": true,并在/budget里报告——它绝不会把「价格未知」当成$0然后宣称预算已生效。 - 预检估算终究是估算。 它会随会话自适应(缓存占比、近期回复长度),但缓存过期的下一条请求仍按过期的缓存占比估算而偏低,
cacheWrite一律按 0 计——两类偏差都由事后记录的真实成本在下一次判定时纠正。它是护栏,不是对单个请求的硬上限。 - daily / monthly 从扩展首次装上时开始累计。 它们来自持久化账本,只认得「装了这个扩展之后跑过的会话」。session 花费没有这个缺口——它从 branch 重建。
- 公开价目表是标价,不是你的账单。 它来自网络(除非用
rates.remote.enabled: false或--no-rates关掉),可能滞后于调价,并按ttlHours缓存。表里查不到的模型保持 unpriced;被多个非第一方 provider 发布的 id 同样如此——那里任何选择都是猜。这类模型请用pricing条目钉住。 - 自动查价是唯一的联网行为。 session 启动时在后台发一次 GET(默认每 24 小时一次),不发任何数据,只取公开价目表;离线源的价已经能覆盖当前模型时不会发请求。抓取被当作不可信输入处理:只允许 https、跨源重定向拒绝、响应体限 32MB、费率上限 $10,000/Mtok。
- 只统计流经 pi 的花费,其他工具的用量看不到。
- 账本是本地 JSON 文件。没有同步、没有多机汇总、没有任何云端。
开发
npm install --ignore-scripts
npm test # 243 个测试,不联网、不调用模型
npm run typecheck
src/policy.ts、src/ledger.ts、src/config.ts、src/pricing.ts、src/store.ts 是纯函数且零依赖,所以可以脱离 agent 做穷尽测试。test/extension.test.ts 用一个假的 ExtensionAPI 驱动真实的扩展工厂,因此拦截路径在不花一分钱的情况下做到了端到端覆盖。
| 文件 | 职责 |
|---|---|
src/extension.ts |
pi 集成:hooks、flags、/budget 命令 |
src/policy.ts |
阈值判定、严重度比较、模型选择 |
src/pricing.ts |
费率解析:配置表 → 模型目录,通配符与阶梯价 |
src/ledger.ts |
成本运算、时间窗口、按价目表估算 |
src/config.ts |
配置规范化与命令行覆盖 |
src/store.ts |
原子、防御性的 JSON 持久化 |
License
MIT