# Changelog

## 0.7.1

Pass pi's abort signal into the catalog fetch. `refreshModels` receives a signal and
pi documents that provider callbacks must forward it to blocking I/O; without that,
a cancelled refresh surfaced as an unhandled `AbortError` and pi failed to start.
The plugin's own timeout still applies — whichever fires first wins.

## 0.7.0

### Catalog refresh now goes through pi's own mechanism

The plugin previously owned a private cache file and refreshed only at startup.
It now implements `refreshModels`, the hook pi calls for its built-in providers,
so airpx participates in `pi update --models` and in any runtime refresh exactly
like `github-copilot`, `openai` or `openrouter` — whose catalogs live in
`models-store.json` with an etag apiece.

Catalog metadata is handed back through pi's generation-checked
`context.publish({ persist })` instead of being written to a private file.

**A refresh never empties the picker.** `refreshModels` REPLACES the registered
model list, so a failed refresh falls back to pi's own persisted snapshot
(`context.stored`), then to the legacy cache, and only returns an empty list when
no catalog is known at all. Returning `[]` on a transient network error is exactly
what emptied opencode's Copilot provider, so it is asserted against.

## 0.6.1

### Conditional catalog fetch, and policy that survives an offline start

- **ETag / 304.** The catalog request now sends `If-None-Match`, mirroring what pi
  already does for its built-in providers (`models-store.json` keeps an etag per
  provider). An unchanged catalog costs a 304 instead of re-downloading every model
  on each start. Requires a backend that serves an ETag; older ones just 200 as before.
- **Picker policy no longer skipped on a cache-only start.** The cache now stores the
  raw catalog rows alongside the mapped models, because policy (`picker_enabled` /
  `policy.state`) lives on fields `PiModel` does not carry. Previously a start that
  could not reach the proxy — or a 304 — left the policy unapplied.

## 0.6.0

### The server now decides which models a client offers by default

Modelled on GitHub Copilot's `/models` contract (`model_picker_enabled` +
`policy.state`). The proxy publishes the decision, this plugin filters on the fly:

- a newly published model appears in pi's Ctrl+P scope with **no client edit**;
- a model the server marks `deprecated`/`disabled` disappears from that scope
  everywhere, again with no client edit.

`enabledModels` in `~/.pi/agent/settings.json` is kept in sync for you, under strict
limits: only `airpx/...` entries are ever touched, non-airpx entries are preserved
verbatim, and a *missing* `enabledModels` is left missing (absent means "everything is
in scope" — writing a list there would silently narrow your picker). Opt out with
`LLM_PROXY_NO_PICKER_SYNC=1`. Changes apply on the next start, since pi resolves the
scope once per session.

**Hidden is not removed.** A deprecated model is still registered and fully
addressable — it is simply not offered by default.

Two rules are copied from real failures rather than taste:

- **Fail-open.** When GitHub started returning `model_picker_enabled: false` for every
  model, opencode's Copilot provider surfaced zero models. Here a missing or
  unrecognised signal always means visible; only an explicit negative hides.
- **No client-side exclusion list.** `github/copilot-cli` hid `gemini-2.5-pro` from its
  picker via a hardcoded id set regardless of what the API returned. This plugin keeps
  no such list — the server is the only source of the decision.

Requires a backend serving `picker_enabled` / `policy`; older backends are unaffected
(everything stays visible).

## 0.5.0

### Per-model thinking levels

pi's `/model` picker now offers exactly the reasoning-effort levels each model
actually accepts, instead of the same generic `off..high` ladder for everything.
`grok-4.6`, for example, gains its `xhigh` tier.

Until now this plugin deliberately emitted no `thinkingLevelMap` at all, because
inventing levels the upstream does not advertise would silently send unsupported
effort values. That constraint has not been relaxed — the proxy now *declares* a
validated ladder per model (`reasoning_levels`), so a declared level is no longer
an invention.

Nothing else changes:

- models the server has not annotated (`reasoning_levels_declared: false`),
  non-reasoning models, and older backends that omit the fields keep pi's default
  ladder exactly as before;
- a declared ladder identical to the default is not emitted, to avoid a redundant
  map that can only drift.

Levels the model rejects are marked unsupported rather than omitted, so pi hides
them. This is not cosmetic: `grok-4.6` returns `400 Invalid reasoning effort` for
`max`, so offering that level would break the model the moment it was selected.

## 0.4.0

### ⚠️ BREAKING — the model id `opus-5` is now `claude-opus-5`

The plugin no longer renames catalog ids. If your `~/.pi/agent/settings.json`
pins `airpx/opus-5` in `defaultModel` or `enabledModels`, pi will warn
`No models match pattern "airpx/opus-5"` until you change it to
`airpx/claude-opus-5`.

Nothing is lost: `opus-5` still exists upstream as an alias and stays callable
by any client that addresses it directly — it is simply no longer the name pi
displays, because the airpx catalog and landing page call the model
`claude-opus-5`.

### Removed: client-side copies of server-owned data

Every table below duplicated something the proxy already ships, and one had
already drifted into a user-visible bug (pi listed `opus-5` while the airpx
landing page listed `claude-opus-5` — the same model under two names).

- **`ID_OVERRIDES`** — duplicated an alias the server ships (`opus-5` →
  `claude-opus-5`, alongside `opus-5.0`, `opus-5(400k)`, `or/opus-5`). The
  plugin dropped the upstream alias row, then re-created that exact name by
  renaming the canonical row.
- **`DISPLAY_NAME_OVERRIDES`** — `claude-opus-5` repeated the upstream
  `display_name` verbatim; `kimi-for-coding` is not in the public catalog at
  all (dead entry). No public row ships without a `display_name`.
- **`_PROVIDER_GROUPS`** — a 26-name copy of the server's provider registry,
  stale *and* incomplete: 7 names no longer existed upstream, while 3 live
  groups (`github_copilot`, `perplexity`, `xiaomi_mimo`) were missing and
  leaked into `/model` as "models". Its documented "no pricing AND no
  `supported_parameters`" fallback was inert — group rows ship 6
  `supported_parameters`, so it matched 0 of 19 groups.
- **`PROVIDER = "airpx"`** declared in two files. The id doubles as the
  `auth.json` key, so a drift would make `/login` write a credential
  `resolveKey()` cannot find.
- **`model: "claude-opus-4-8"`** hardcoded in the `/login` validation probe — a
  catalog id that rots silently when that model is retired. It was never
  needed: the proxy authenticates before reading the body and treats `model` as
  optional.

### Changed: `is_primary` decides which rows load

"Show each model once" is server policy, so the proxy now ships `is_primary`
(exactly one row per underlying model) and `isAlias()` honours it.

The old rule — drop every row carrying `alias_of` — diverged from the airpx
landing page's own copy on an **orphan alias**: an alias whose canonical row is
filtered out of the response by the public set or a per-key allowlist. The site
showed such a model while pi silently lost it. Latent today (no orphan aliases
in the public list), fixed before it could bite.

Falls back to the previous `alias_of` rule when the field is absent, so an older
proxy keeps working.

### Verification

- `isPseudoRow` is now derived from row metadata instead of a name list:
  matched 22/22 group rows with 0 false positives across 219 real models on the
  live catalog. Covers the anonymous path, where the proxy redacts
  `providers`/`owned_by` and only 3 of the 5 signals are present.
- `mapCatalog` over the live anonymous `/v1/models` selects a byte-identical set
  to the ids rendered on the airpx.cc landing page (26/26, verified via
  Playwright DOM), and `pi --list-models` agrees.
- Parity re-checked against a synthetic orphan alias: pi and the landing page
  now select the same rows and both keep it.
- 39/39 unit tests, `tsc` clean.

## 0.2.1

Keep the last-good catalog when upstream returns zero models.

## 0.2.0

`opus-5` alias, no profile aliases, `recommended_api` support.

## 0.1.3

Use the Responses API for GPT catalog models; let the airpx proxy handle the
`developer` role; first-run onboarding hint; real `/login` key validation.
