pi-live-pricing

Live, always-correct LLM pricing for the pi coding agent. Merges live catalog prices onto pi's curated model metadata instead of replacing it.

Packages

Package details

extension

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

$ pi install npm:pi-live-pricing
Package
pi-live-pricing
Version
1.1.1
Published
Aug 28, 2026
Downloads
564/mo · 31/wk
Author
adrianrodriguez10
License
GPL-3.0-or-later
Types
extension
Size
92.2 KB
Dependencies
0 dependencies · 2 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

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

README

pi-live-pricing

Correct LLM prices in pi — without throwing away the metadata pi already gets right.

pi license node CI tests

A pi extension that keeps OpenRouter pricing live, by merging the upstream catalog onto the one pi composed instead of replacing it. Prices come from the API; context windows, output limits, modalities, thinking-level maps, compatibility flags — and anything you declared in models.json — stay exactly as pi had them.


Contents


The problem

pi ships a static OpenRouter catalog, regenerated at release time and then frozen. OpenRouter is a marketplace of 400+ models repriced continuously, so the two drift apart immediately.

Measured against the live API on 2026-08-24, with a built-in catalog generated 10 days earlier (2026-08-14):

count
models in pi's built-in OpenRouter catalog 346
wrong input/output price 42
wrong cache read/write price 37
wrong or missing volume tiers 58
models on OpenRouter that pi does not list at all 79

A sample of the input/output drift:

model pi built-in ($/1M in/out) live API ($/1M in/out)
deepseek/deepseek-v4-pro 1.168 / 2.336 0.519 / 1.038
deepseek/deepseek-v4-pro-0813 0.435 / 0.87 1.122 / 3.366
deepseek/deepseek-v4-flash 0.14 / 0.28 0.0587 / 0.1173
deepseek/deepseek-chat-v3.1 0.25 / 0.95 0.55 / 1.65
google/gemma-4-26b-a4b-it 0.12 / 0.4 0.07 / 0.34

Note that drift runs both ways — some models are billed at more than twice what pi reports, others at less than half. Every one of them corrupts cost tracking on every turn.

This is specifically an OpenRouter problem. Cross-checking pi's direct providers (Anthropic, OpenAI, Google, Groq, xAI, DeepSeek, Mistral, Cerebras, z.ai) against an independent catalog found only 6 discrepancies out of 239 models — which is why this extension does not touch them.

Why merging, not replacing

The obvious fix is to replace pi's catalog with the live one. That trades one class of wrongness for another, because the public /models endpoint does not expose everything a model needs:

field live API pi's curated catalog
price ✅ correct ❌ stale
compat.cacheControlFormat ❌ absent ✅ set on all 346
thinkingLevelMap ~ partial ✅ hand-tuned on 55
maxTokens ~ missing on 50 models ✅ hand-checked
models pi has never seen ✅ 79 of them ❌ absent

So pi-live-pricing takes prices from the feed and everything else from pi:

pi's curated catalog (346)          live OpenRouter feed (422)
  anthropic/claude-sonnet-5           anthropic/claude-sonnet-5
    cost      2 / 10  (stale) ✗         cost      2 / 10  ✓ ──┐
    maxTokens 128000        ✓ ──┐       maxTokens (ignored)   │
    input     [text, image] ✓ ──┤       input     (ignored)   │
    compat    {cacheControl} ✓ ─┤                             │
    thinkingLevelMap        ✓ ──┤                             │
                                ▼                             ▼
                        merged result (425)
                          cost      ← live feed
                          everything else ← pi
                          + 79 models pi does not ship

Models the feed does not mention are kept, not dropped — a model can vanish from the feed while you still have it selected. The merge can only correct prices and add models; it can never shrink your catalog.

The left-hand side of that diagram is pi's live catalog, captured at session start — its built-in models, whatever its catalog server has shipped since your pi release was cut, and your own models.json entries. Not the frozen copy bundled with the installed release. That distinction is the whole of rule 2, and getting it wrong is what made models flicker in and out of the picker between launches.

Install

pi install git:github.com/AdrianRodriguezM/pi-live-pricing

Or add it to ~/.pi/agent/settings.json:

{
  "packages": ["git:github.com/AdrianRodriguezM/pi-live-pricing@main"]
}

Then /reload.

For local development, point pi at your checkout:

pi -e /path/to/pi-live-pricing

Security: pi extensions run with full system access. Review the source before installing. This one makes no network calls at import time; it fetches exactly two documented public catalog URLs, and nothing else.

Usage

Nothing to configure. On session start the extension syncs in the background (non-blocking, TTL-limited), and your existing openrouter/* model selections start reporting correct costs — no migration, no renamed provider.

command what it does
/pricing-refresh Force an immediate re-sync, bypassing the TTL
/pricing-status Show catalog sizes, cache age and merge stats

/pricing-status prints something like:

openrouter: 425 models, 3m ago, 92 repriced, 79 new
deepinfra-live: 101 models, 3m ago

Configuration

variable default meaning
PI_LIVE_PRICING_TTL_MS 21600000 (6h) How long a fetched catalog is reused before re-fetching
OPENROUTER_PRICING_TTL_MS Legacy alias for the above
DEEPINFRA_API_KEY Required for the DeepInfra provider (see limitations)

OpenRouter needs no extra configuration: it reuses whatever credential pi already has for the built-in provider, including OAuth via /login.

How it works

src/
├── index.ts              Registers providers, commands, session_start sync
├── types.ts              CatalogAdapter / LiveModel contracts
├── sync.ts               Base capture, TTL, ETag, ownership, degradation
├── merge.ts              Curated catalog + live prices
└── adapters/
    ├── openrouter.ts     Merge mode  — overrides a provider pi ships
    └── deepinfra.ts      Standalone  — registers a provider pi lacks

Everything here follows from one fact about pi's provider composer, which is blunter than its published type suggests:

getModels() = applyExtension(applyModelsJson(base, models.json), us)

applyExtension does not merge. Whatever array refreshModels returns becomes the provider's catalog, shadowing pi's own remote catalog and every model you declared in models.json. Three rules follow.

  • Rule 1 — returning an array is a claim to own the whole catalog. [] does not mean "no change"; it means "this provider has no models" and pi wipes the list. A stale array is no better: it silently deletes any model you added or pi shipped since the snapshot was taken. The only truthful answer when there are no live prices is undefined, which the composer skips. Every failure path — offline, rate-limited, malformed, 304 on a cold cache — degrades to undefined, never to a frozen catalog.
  • Rule 2 — merge onto what pi actually composed. Because the returned array replaces everything, it must already contain the remote catalog and your models.json models. getBuiltinModels() knows about neither. So the real base is captured from the live registry once per process, before the extension has ever contributed. Rule 1 is what makes that capture trustworthy: with nothing contributed on the cold paths, the first list observed is guaranteed to be pi's own. The shipped catalog remains only as a floor.
  • Rule 3 — a provider's storage slot belongs to whoever owns the provider. Standalone providers are ours, so ctx.publish({ persist }) is correct there. Merge-mode providers are not: that slot is where pi's built-in withRemoteCatalog keeps its downloaded catalog and restores from it on every start. Writing the raw feed there erased it — our entries carry no lastModified, the field its restore gates on — while its writes made our own restore bail. Two caches, one key, mutual destruction. Merge mode now caches in memory only and leaves the slot untouched.
  • No polling, no background threads. The network is touched when pi refreshes models, at most once per TTL window.
  • Best-effort. An error never propagates into pi.
  • Conditional requests. For standalone providers the stored ETag is sent as If-None-Match. OpenRouter currently serves cache-control: max-age=300 through Cloudflare without an ETag, so the TTL is what actually prevents refetching; the 304 path exists for adapters whose upstream does emit one.

Prior art

Several pi packages touch OpenRouter. This one is narrower and deliberately composable with them:

package focus overlap
pi-openrouter-plus Real-time catalog sync, provider/quantization variants, endpoint health, credit balance Both sync the catalog. It is broader; it replaces rather than merges, and price correctness is not its thesis.
@robhowley/pi-openrouter Account visibility, spend overlay, user-scoped catalog sync Both sync models. Its cost tracking is post-hoc from usage logs rather than corrected prices.
kematzy/pi-model-prices Interactive price browser for OpenRouter and OpenCode Zen Complementary: a viewer, it does not register a provider.

If you want endpoint health and variant routing, use pi-openrouter-plus. If you want your existing openrouter/* models to bill accurately without changing anything, use this.

Writing an adapter

Everything provider-specific lives in one small interface; the TTL, ETag, persistence and fallback machinery is reused unchanged.

export const myAdapter: CatalogAdapter = {
  providerId: "my-provider",
  providerName: "My Provider",
  baseUrl: "https://api.example.com/v1",
  api: "openai-completions",

  // Merge mode: return pi's curated catalog to patch prices onto.
  // Omit entirely for a provider pi does not ship.
  baseModels: () => getBuiltinModels("my-provider"),

  async fetch(signal, ifNoneMatch) {
    const res = await fetch(CATALOG_URL, { signal, headers: { ... } });
    if (res.status === 304) return null;
    return { models: parse(await res.json()), fetchedAt: Date.now(), etag: ... };
  },
};

The two shipped adapters are deliberately dissimilar, so the abstraction is tested rather than assumed:

OpenRouter DeepInfra
mode merge onto built-in standalone
price unit USD/token, as strings cents/token, as numbers
cache price absolute rate multiplier on the input rate
max_tokens field output cap context window
volume tiers yes no

Known limitations

  • DeepInfra is only registered when DEEPINFRA_API_KEY is set. pi skips the networked refresh phase for any provider whose credential does not resolve, so without a key the provider would sit in the model picker permanently empty. Its models would be unusable without a key anyway, but it does mean the prices are not browsable beforehand.
  • DeepInfra output limits are conservative. The API does not publish a maximum output length, so it is capped at min(context, 16384). Raise it per model via models.json modelOverrides if you need the full budget.
  • Merge mode re-syncs once per pi process. Its cache is in-memory by design: the only persistence pi offers a provider is the built-in's own catalog slot, and sharing it corrupts both catalogs (rule 3). One background fetch per launch, never blocking, never delaying startup.
  • Merge mode needs a session to have started. The curated base is captured from session_start (and again on /pricing-refresh). An embedder that never starts a session gets pi's own prices rather than live ones — the safe degradation, not a wrong one. /pricing-status says base not captured when this is why.
  • pi 0.84.0 or newer is required for full fidelity. Older builds discard the options and the result of the extension-facing ModelRegistry.refresh(), so /pricing-refresh cannot name a provider whose sync failed (it still reports the catalog state, and forces the re-sync through a local flag). Standalone snapshots also stay in memory there, since context.publish() did not exist yet.
  • Direct providers are untouched by design. Anthropic, OpenAI and friends drift far less (6 of 239 models), and overriding pi's curated prices with a third-party aggregator's numbers would be a net loss in accuracy.

Development

npm install
npm run check      # tsc --noEmit, strict
npm test           # 151 assertions, no network
npm run test:live  # hits the real catalogs
npm run fixtures   # regenerate test/fixtures from upstream

Releasing is a tag. Bump version in package.json, commit, then:

git tag v1.2.3 && git push origin main --tags

CI publishes to npm from the tag via npm trusted publishing — no NPM_TOKEN secret lives in this repository; the credential is minted from GitHub's OIDC token for the duration of the job, and the release carries a provenance attestation. The job refuses to run if the tag disagrees with package.json.

Two suites, for two different failure modes.

npm test is deterministic and never touches the network — it fails only if the code regressed. Parser coverage runs against test/fixtures/, which holds entries captured verbatim from the real catalogs and chosen to exercise every branch the mappers have: volume tiers, free models, image modality, mandatory and optional reasoning, missing output caps, cache pricing, deprecated and private entries, non-token billing. Hand-written fixtures test what you imagine an API returns; these test what it actually returns.

npm run test:live is the tripwire for the one thing fixtures cannot catch: a provider changing its schema. A fixture keeps passing forever against a payload shape that no longer exists, so CI runs the live suite weekly and opens an issue when upstream moves. A failure there means the API changed, not that the code broke — update the mapper, then npm run fixtures.

CI also verifies the published tarball: that the pi manifest points at files files actually ships, and that LICENSE and README are present. That class of mistake is invisible locally and only surfaces on someone else's install.

License

Copyright © 2026 Adrián Rodríguez Mesa

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See LICENSE for the full text.