# pi-live-pricing

> Correct LLM prices in [pi](https://github.com/earendil-works/pi) — without throwing away the metadata pi already gets right.

[![pi](https://img.shields.io/badge/pi-0.84%2B-blueviolet?style=flat)](https://github.com/earendil-works/pi)
[![license](https://img.shields.io/badge/license-GPL--3.0--or--later-green)](LICENSE)
[![node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org)
[![CI](https://github.com/AdrianRodriguezM/pi-live-pricing/actions/workflows/ci.yml/badge.svg)](https://github.com/AdrianRodriguezM/pi-live-pricing/actions/workflows/ci.yml)
[![tests](https://img.shields.io/badge/tests-144-brightgreen)](test/run.mjs)

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](#the-problem)
- [Why merging, not replacing](#why-merging-not-replacing)
- [Install](#install)
- [Usage](#usage)
- [Configuration](#configuration)
- [How it works](#how-it-works)
- [Prior art](#prior-art)
- [Writing an adapter](#writing-an-adapter)
- [Known limitations](#known-limitations)
- [Development](#development)
- [License](#license)

---

## 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](#how-it-works), and getting it wrong is what made models flicker in and
out of the picker between launches.

## Install

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

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

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

Then `/reload`.

For local development, point pi at your checkout:

```bash
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](https://github.com/olixis/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](https://pi.dev/packages/@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](https://github.com/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.

```ts
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

```bash
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:

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

CI publishes to npm from the tag via [npm trusted
publishing](https://docs.npmjs.com/trusted-publishers) — 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](LICENSE) for the full text.
