pi-spend-guard

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

Packages

Package details

extension

Install pi-spend-guard from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-spend-guard
Package
pi-spend-guard
Version
0.9.1
Published
Oct 17, 2026
Downloads
984/mo · 602/wk
Author
xinghe-labs
License
MIT
Types
extension
Size
202.7 KB
Dependencies
0 dependencies · 3 peers
Pi manifest JSON
{
  "extensions": [
    "./src/extension.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-spend-guard

npm License: MIT CI

Hard budget enforcement for pi. Warn, automatically downgrade to a cheaper model, and block requests before you overspend.

English | 简体中文

pi already tells you what you spent. Nothing stops you from spending it.


The problem

There is no shortage of pi extensions that display cost — status bars, observability plugins, dashboards. They are all read-only. When a runaway agent loops through 40 tool calls at 3am, a status bar is not a control.

pi-spend-guard is the control. It reads real provider-reported cost and escalates:

Spend reaches What happens
warn You get a notification
downgrade The session switches to a cheaper model for the rest of the run
block The request is aborted before it is sent

Each threshold is configured independently per scope: session, daily, and monthly.

How this differs from a session spend cap

A session-only cap is the common approach: one limit, abort when it is hit. That leaves two gaps.

  • A session is not a billing period. Fifty small sessions in a day can each stay under a per-session cap while the month quietly runs away. This extension enforces daily and monthly budgets from a persisted ledger, alongside session.
  • Aborting is the last resort, not the first. Crossing a soft threshold switches to a cheaper model so the work can finish instead of stopping. No session cap does this.

It also warns when a model has no price data — the one failure mode that makes every budget silently useless.

Install from npm

pi install npm:pi-spend-guard

The npm release can lag the repository: staged publishes need a manual 2FA approval, so the version on npm may predate the features described below. pi install git:github.com/xinghe-labs/pi-spend-guard is always current, and /budget reports what the running copy actually does.

Try it without touching your settings:

pi -e npm:pi-spend-guard

From git (no npm account needed)

The extension has no runtime dependencies, so it installs straight from the repository:

pi install git:github.com/xinghe-labs/pi-spend-guard

Git installs behave identically. The only difference is visibility: only npm packages are listed in the pi package gallery.

Quick start

That is it. Defaults are active immediately:

Scope warn downgrade block
session $2 $5 $10
daily $10 $25 $50
monthly $100 $250 $500

Check where you stand at any time:

/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

Verified behaviour

Blocking is not a claim, it is observable. With block: 0:

$ pi -ne -e ./src/extension.ts --budget-config ./examples/block-all.json -p "Reply with exactly: ok"
Request aborted

The same command with a normal budget returns ok.

It works with no configuration. On a local gateway whose models carry no price anywhere, a config with only thresholds still blocks, because the published list prices the model. Turning that lookup off with the same thresholds makes the guard inert again — the A/B that shows the feature is what does the work:

$ pi -ne -e ./src/extension.ts --budget-config /tmp/limits-only.json -p "hi"
Request aborted
$ pi -ne -e ./src/extension.ts --budget-config /tmp/limits-norates.json -p "hi"
<the request goes through>

Pre-flight estimation blocks the request that would cross the line, before it is sent. Here the model has no price in the pi catalog at all — only the guard's own pricing table can price it — and nothing has been spent yet:

$ 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.

Same model, same thresholds, same $0 spent. The only difference is the pricing table, and it is the difference between a guard that enforces and one that does nothing.

Commands

Command Purpose
/budget Show spend, verdict, pricing status, and effective config
/budget set daily.block=20 Change a threshold and persist it
/budget set session.warn=none Disable a single threshold
/budget set downgrade.model=deepseek-chat Pin the fallback model
/budget set estimate.enabled=false Turn off pre-flight projection
/budget set estimate.outputTokens=4096 Assume a longer reply when projecting
/budget on / /budget off Toggle enforcement
/budget reset Clear the persisted ledger
/budget models Per-model spend, highest first, with the source that priced it
/budget explain What every pricing tier would charge for the active model, and which one wins

Configuration

Config lives at <agent dir>/pi-spend-guard/config.json — normally ~/.pi/agent/pi-spend-guard/config.json. See examples/config.json for a fully commented starting point.

{
  "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
}

Notes:

  • Omit a scope to inherit its defaults; set it to null to disable that scope entirely.
  • Omit a single threshold to inherit it; set it to null to disable just that one.
  • 0 is a real threshold and fires immediately.
  • block.enabled: false keeps the warning but never aborts — useful for a trial run.
  • downgrade.model wins over preferProviders. With neither set, the cheapest available model is chosen.
  • estimate controls pre-flight projection. It needs a price to work with, and degrades to spend-only enforcement when there is none.
  • pricing is the guard's own rate table, for models pi has no price for.

CLI flags: --budget-config <path> points at a different config file; --no-budget disables enforcement for one run; --no-rates skips the automatic price-list lookup so the run stays fully offline.

Pricing: how the guard learns what a model costs

Cost is resolved in four steps, most trustworthy first:

  1. What the provider reported. Usage.cost.total reflects real discounts, request-wide pricing tiers, and provider-side cache pricing that nothing local can know.
  2. Your pricing table, in the guard's own config.
  3. The pi model catalog — the cost block on the model definition. This is usually ~/.pi/agent/models.json, but pi also ships cost data for the providers it knows, and the guard cannot tell a hand-written cost from a shipped one.
  4. A published price list — models.dev by default, fetched in the background and cached on disk.

Steps 2–4 exist because step 1 often reports nothing, and steps 2–3 are often empty too: local gateways, self-hosted proxies, resellers, and any model pi does not ship a price for. Step 4 is what makes the guard work with no configuration at all — install it and it can price what it is running.

A reported $0 is not trusted on its own. Verified against a real reseller gateway that reports cost.total = 0 for calls it bills: with rates available the call is priced from them instead. A $0 with no rate anywhere is recorded as unpriced, never as free.

{
  "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 }
  }
}

Rates are USD per million tokens. Keys match most-specific-first: provider/model, then provider/*, then a bare model. A bare * is a last resort: it is consulted only after the catalog and the published list, because a rule that prices every model of every provider must not outrank a real price for the model in front of you. An entry that cannot price anything (all rates zero) is skipped rather than allowed to shadow a broader key. cacheRead and cacheWrite fall back to the input rate when omitted, and a request-level tiers array is supported for models that charge more above a token threshold:

{
  "pricing": {
    "anthropic/claude-sonnet": {
      "input": 3,
      "output": 15,
      "cacheRead": 0.3,
      "tiers": [{ "inputTokensAbove": 200000, "input": 6, "output": 22.5 }]
    }
  }
}

The guard reads models.dev for you. At session start it loads the cached list from disk (offline, instant) and refreshes it in the background once the cache is older than rates.remote.ttlHours. Session start never waits, and neither does a provider request — the guard never blocks on the network. The one place a wait exists is accounting: when a call finishes and nothing can price the model yet, message_end waits up to 1.5 s for the in-flight list before recording it, because an unpriced record is permanent. Beyond that bound the call is recorded as unpriced and the list is used from the next call on. Matching is deliberately conservative — an exact provider/model, then the bare model id so a gateway prefix like global:glm-5.3 still resolves. When several providers publish the same id and none of them is first-party, the model stays unpriced rather than being assigned a reseller's guess: a wrong price silently miscounts every budget, while a missing one is reported. Set "rates": { "remote": { "enabled": false } } to make the guard fully offline.

The list is the only thing the guard fetches, so it is treated as untrusted input: https only (plus http on loopback), a redirect to another origin is refused, the response is size-capped, rates above $10,000/Mtok are rejected, and a payload that is mostly implausible is discarded whole rather than half-read. Each estimated record also stores the rateSource that priced it, so the precedence chain is auditable after the fact.

If your gateway renames models, alias them. A local gateway can call a model anything, and a renamed model has no published price — which the guard will not guess at. One line fixes it permanently:

{
  "rates": {
    "aliases": { "workbuddy/global:deepseek-v4.1-flash": "deepseek-flash" }
  }
}

Keys match most-specific-first, exactly like the pricing table, so a single "my-gateway/*" rule can redirect a whole provider. The value may be a bare published id or provider/id to pin the provider too. /budget explain shows which alias was followed.

You can also copy rates by hand — models.dev and its api.json list costs for hundreds of providers. Take the first-party entry for a model (provider zai, deepseek, moonshotai, …), not a reseller's, and copy cache_write explicitly: the config table falls back cacheWrite to the input rate when omitted, and for models like Kimi K3 that charges $3/Mtok cache-write versus $3 input, or GLM whose cache-write is genuinely $0, an omitted field can be wrong by orders of magnitude.

Alternatively, put the rates in the pi catalog where they belong:

{
  "providers": {
    "my-provider": {
      "models": [
        {
          "id": "my-model",
          "cost": { "input": 0.5, "output": 1.5, "cacheRead": 0.05, "cacheWrite": 0.5 }
        }
      ]
    }
  }
}

Every estimated record stores the rateSource that priced it, the rateKey that matched, the ratesUsed themselves, and — for the published list — when that snapshot was fetched. /budget explain prints the whole chain for the active model, and /budget models shows the key per model. When a number looks wrong, that is where to look.

An unpriced record is not permanent: when a list arrives later (or on the next session), records that were unknown are re-priced from their stored usage and re-persisted. That is why the accounting wait below is a nicety rather than a requirement.

If nothing can price a call, the guard does not pretend it was free. The record is stored with "unpriced": true, a session-start warning names the exact model and the config key to add, and /budget reports how many unpriced calls are on record. An unknown price and a genuine zero are different things, and the ledger keeps them apart.

Pre-flight estimation

Spend-only checks can only stop a request after the money is gone. With estimate enabled (the default) the guard also projects what the request about to be sent will cost, and blocks it if that projection crosses a threshold — before it reaches the provider. See examples/estimate.json for a runnable demo:

$ pi -ne -e ./src/extension.ts --budget-config ./examples/estimate.json -p "hi"
Request aborted

Nothing was spent — session spend was $0 when the request was stopped. The block message distinguishes the two cases, so it never claims you have already spent money you have not:

Budget: this request (~$0.0033) would take session spend to $0.0033, past the $0.001 limit — blocked before sending

The projection uses the current context size as the input tokens and estimate.outputTokens (default 2048) as the output tokens. That assumption is deliberately conservative: maxTokens is the model's ceiling, not the likely reply, and using it would block requests that would have cost far less. Set estimate.enabled to false for pure spend-only enforcement, or raise estimate.outputTokens to be more cautious.

Estimation needs a price. When the input size or the rates are unknown it degrades to spend-only enforcement rather than inventing a number.

How it works

before_agent_start ──► evaluate ──► downgrade (pi.setModel)
before_provider_request ──► project cost ──► evaluate ──► block (ctx.abort)
message_end ──► resolve cost ──► merge + write ledger.json (immediately)
session_start ──► load config + ledger, warn if the model has no price

Five deliberate design decisions:

Session spend is rebuilt from the session branch, not accumulated in memory. Every evaluation walks ctx.sessionManager.getBranch() and sums both message entries (assistant usage) and usage entries (cache warming and friends). Forks, /reload, and resuming a session therefore all produce the same number — an in-memory counter would drift. Skipping the non-message usage entries would silently under-count.

Provider-reported cost always wins over the catalog. Usage.cost.total reflects real discounts, request-wide pricing tiers, and provider-side cache pricing that a local lookup cannot know about. Catalog rates are a fallback only, and the resulting record is flagged "estimated": true so you can tell measured cost from guessed cost.

Enforcement happens at before_provider_request. That hook fires before every provider call, including each follow-up turn inside one agent run — not just once at the start. before_agent_start is used separately for downgrades, so a cheaper model applies to the whole run.

Concurrent sessions share one ledger without losing records. Every write re-reads the file and merges under an exclusive lock (atomic mkdir, bounded retry), and every enforcement decision — including the one that aborts — re-reads before evaluating. Two pi windows running at once therefore both survive, and daily/monthly budgets see the whole machine's spend rather than one session's stale snapshot. Retention and the record cap apply to the merged file inside the lock, so the ledger cannot grow without bound. Records land on disk at message_end, so a crash cannot erase the spend a session just made. /budget reset is the one deliberate exception: it overwrites instead of merging, because the user asked for the ledger to be erased.

Enforcement is preventive, not just accounting. Before sending, the guard projects the request's own cost and adds it to every scope. A request that would cross a limit is stopped while spend is still under it, and the message says so — would take session spend to $X rather than claiming exhaustion that has not happened. The projection fails safe: with no context size or no rates it returns 0 and enforcement falls back to money already spent, instead of guessing a number that could block good requests.

Limitations

Stated plainly, because a budget guard that quietly does nothing is worse than none:

  • A model with no price anywhere cannot be measured. Provider-reported cost, the pricing table, the catalog, and the published list are consulted in that order. When all four are silent the guard says so at session start, flags the call "unpriced": true, and reports it in /budget — it never counts an unknown price as $0 and calls the budget enforced.
  • Pre-flight estimation is an estimate. It assumes estimate.outputTokens of output; a long reply can cost more than projected and be caught on the next call rather than this one. It is a guard rail, not a hard cap on a single request.
  • Daily and monthly totals start accumulating when the extension is first installed. They come from the persisted ledger, which only knows about sessions that ran with the extension loaded. Session spend has no such gap — it is rebuilt from the branch.
  • The published price list is a list price, not your invoice. It is fetched from the network (unless disabled), can lag a price change, and is cached for ttlHours. A model whose id the list does not know stays unpriced; so does an id published by several non-first-party providers, where any pick would be a guess. Pin those with a pricing entry.
  • Only spend that flows through pi is counted. Usage in other tools is invisible.
  • The ledger is a local JSON file. There is no sync, no multi-machine aggregation, and no cloud anything.

Development

npm install --ignore-scripts
npm test          # 243 tests, no network, no model calls
npm run typecheck

The core logic in src/policy.ts, src/ledger.ts, src/config.ts, and src/store.ts is pure and dependency-free, so it is tested exhaustively without an agent. test/extension.test.ts drives the real extension factory through a fake ExtensionAPI, which is why the enforcement paths are covered end to end without spending a cent.

File Responsibility
src/extension.ts pi integration: hooks, flags, /budget command
src/policy.ts Threshold evaluation, verdict severity, model selection
src/ledger.ts Cost arithmetic, time windows, estimation from catalog rates
src/config.ts Config normalization and CLI overrides
src/store.ts Atomic, defensive JSON persistence

License

MIT