omp packages

pi-spend-guard0.9.1

Hard budget enforcement for pi: warn, auto-downgrade to a cheaper model, and block requests before you overspend.

xinghe-labs Published 7 days ago MIT ~382 downloads/mo

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.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

pi-spend-guard

npm License: MIT CI

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 让本次运行完全不拦截。

定价:守卫怎么知道一个模型多少钱

成本按四步解析,可信度从高到低:

  1. provider 上报的金额。 Usage.cost.total 反映了真实折扣、请求级阶梯价、以及 provider 侧的缓存定价,这些本地查表无从得知。
  2. 你的 pricing 表,写在守卫自己的配置里。
  3. pi 的模型目录——~/.pi/agent/models.json 里模型定义上的 cost。
  4. 公开价目表——默认是 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