Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

All notable, user-facing changes to polyrouter, newest first. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow the
frontend package's [semver](https://semver.org/) (the release version).

Per-package detail lives in `packages/*/CHANGELOG.md` (generated by changesets);
each release links its GitHub page, which carries the full narrative and
install/upgrade commands.

**Maintenance:** every user-facing change adds a changeset AND a one-line entry
under **[Unreleased]**. At release time, [Unreleased] is retitled to the new
version + date (matching the `chore(release)` commit), and a fresh [Unreleased]
heading is started.

## [Unreleased]

## [0.31.0] — 2026-11-10

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.31.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.30.0...v0.31.0)

### Added

- **A subscription provider's Terms risk notice can be dismissed, once per provider.** The notice on the card gains a `×`; the server records the acknowledgment for that provider, so it stays gone on every browser, and the card shows nothing in its place. The add-provider dialog still states each plan's terms risk, and changing a provider's kind away from subscription and back brings the notice back. Notices about conditions that end once fixed keep no dismiss. New: `POST /api/providers/:id/terms-risk/acknowledge` and `termsRiskAcknowledgedAt` on the provider view; migration 0047 adds `provider.terms_risk_acknowledged_at`.

### Fixed

- **Tall dialogs scroll at desktop width.** A dialog taller than the window, such as Edit provider on a subscription plan, ran off the bottom of the screen with no way to reach Save; dialogs are now held to the window's height and scroll inside themselves, as they already did on a phone.
- **The dashboard no longer scrolls off a blank page.** Scrolling over the top bar on Overview or Requests could lift the whole dashboard and leave the bottom of the window blank, because screen-reader-only text in the request tables made the page taller than the window; that text now stays inside the scrolling content, so the page itself never scrolls.

### Upgrade notes

- One migration runs on boot (`0047`): a nullable `provider.terms_risk_acknowledged_at` column. Every existing provider starts unacknowledged, so each subscription card keeps its Terms risk notice until you dismiss it. No data is rewritten and there is no new configuration. Pull `ghcr.io/izzoa/polyrouter:0.31.0` (or `:latest`) and restart.

## [0.30.0] — 2026-11-09

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.30.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.29.0...v0.30.0)

### Added

- **Subscription cards graph each plan's usage over its window, read about every 15 minutes even with no traffic.** Under the usage line, each limit window (5-hour, weekly, …) gets a small 0–100% chart over its span with the reset marked; a window with no reading yet shows an empty track labelled `no reading yet`, never a line at 0%. One instance at a time reads every subscription plan's usage endpoint about every 15 minutes, sharing the 5-minute cooldown with Refresh usage, and keeps a 35-day history. Claude Pro / Max and ChatGPT Plus / Pro gain usage endpoints and a Refresh usage button: they are undocumented account endpoints read with polyrouter's own User-Agent, and both providers restrict automated use of a consumer plan, so Edit gains **Check plan usage on a schedule** (on by default) to stop the unattended reads for one provider. Every usage endpoint now ships enabled; a broken one is withdrawn. Usage stays display only. New: `GET /api/providers/:id/plan-usage/history`, and `checkPlanUsage` on the provider view and `PATCH /api/providers/:id`.

### Changed

- **Overview states the value served on subscription beside spend, and its headline strip ends level.** When the range holds subscription traffic, a line under the spend figure reads `$3.12 served on subscription, at API rates · excluded from spend` (Costs' words), and the Requests row states its share (`all on subscription plans` / `12 on subscription plans`); with none, neither appears. The other figures on Overview and Costs now sit as rows beside spend with their figures on one column, so the strip has no blank band; Costs keeps every figure and word.
- **Kimi Code and Z.ai offer the identity-imitation switch when you connect them, not only in Edit afterwards.** The plan's detail in Add provider → Subscription shows the same switch, off, behind the same confirmation, and Connect sends your choice, so the new provider's first Test already uses it. `POST /api/providers/key-presets/connect` accepts an optional `imitateClientIdentity` (as the OAuth sign-in start does; refused for a plan that declares no identity), and the key-preset list gains `clientIdentityLabel`. No migration and no new configuration.

### Upgrade notes

- One migration runs on boot (`0046`): a new table, `plan_usage_sample`, and a `provider.check_plan_usage` column (default on for every existing row). No data is rewritten, there is no backfill (the history starts at upgrade), and no new environment variable is required.
- After the upgrade, polyrouter reads every subscription plan's usage endpoint about every 15 minutes, Claude and ChatGPT included. To keep a provider to reads you press, turn off **Check plan usage on a schedule** in its Edit dialog.

## [0.29.0] — 2026-11-09

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.29.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.28.1...v0.29.0)

### Changed

- **Add provider → Subscription lists every plan in one list with a detail pane, and the four coding-plan presets are now listed and connectable on every install.** The dialog widens to show Claude, ChatGPT, OpenCode Go, MiniMax M Plan, Kimi Code and Z.ai GLM Coding Plan side by side with the chosen plan's terms risk, region, key and identity note; Sign in and Connect act from that detail, and on a phone the list and the detail are two steps. The coding plans have not had a live run yet: a wrong pinned detail shows as a typed error at Test or Sync, never as a request to another host. The key-preset list gains `declaresIdentity`; no migration and no new configuration.

## [0.28.1] — 2026-11-07

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.28.1) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.28.0...v0.28.1)

### Fixed

- **Tool schemas with `null` keywords no longer make strict OpenAI-compatible providers reject the request.** A client that sends a null-valued standard keyword in a tool schema (Warp sends `"required": null` on its `finish` and `transfer_control_to_user` tools) got HTTP 400 from backends such as XiaomiMiMo, then fell back. The router now removes those nulls before forwarding, in every protocol and the batch path. Your request body is not modified, and if every tool's schema held only such nulls, the structural router no longer counts them as schema demand.

## [0.28.0] — 2026-11-07

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.28.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.27.1...v0.28.0)

### Added

- **Subscription cards can show how much of the plan is used, and a Refresh usage button for key plans.** One neutral line gives each window's used percent and reset time, says when a plan is billing extra usage (Claude usage credits, ChatGPT credits, the OpenCode Go balance), states a missing figure as `unknown` rather than `0%`, and ends with `as of <time>`, the oldest figure it shows. Claude, ChatGPT and OpenCode Go read it from replies polyrouter already receives, at no extra upstream call; the MiniMax, Kimi Code and Z.ai (and OpenCode Go) cards add **Refresh usage**, an attended read of the plan's own usage endpoint, shared across instances so it runs at most once every five minutes per provider. It is display only: no alert, no routing change and no background polling, and every usage endpoint stays off until its live check passes. The provider view gains `planUsage` and `planUsageRefresh`, and there is one new endpoint, `POST /api/providers/:id/plan-usage/refresh`.
- **A coding plan that runs out of allowance now pauses its provider instead of tripping the circuit breaker.** For the Z.ai, MiniMax M Plan and Kimi Code key presets (their international hosts, plus Kimi Code's China host), a recognised plan quota, risk-control or inactive-plan error pauses that provider until the reset time it stated (or a default, between 1 minute and 7 days); chains skip it without contacting it and fall back, every instance shares the pause, and no `provider_down` is raised. Its card shows the reason and the time in one amber line with a **Resume now** button (a risk-control pause asks first), and the request inspector's route trace marks the skipped member `skipped — plan paused`. A recognised plan exhaustion answers with the code `plan_quota_exhausted` or `plan_risk_control` where it used to be `rate_limited`; the status is still HTTP 429. OpenCode Go and the Z.ai and MiniMax China hosts keep the generic error mapping until a real exhaustion response is captured.
- **OpenCode Go, MiniMax M Plan, Kimi Code and Z.ai GLM Coding Plan connect with an API key from cards on Add provider → Subscription.** Each card states that plan's terms risk and links its terms (Z.ai needs an explicit acknowledgement), the endpoint is pinned so there is no URL to type, and Edit lets you rename or rotate the key. A region is offered only once its live verification passes, so no card is offered until a region passes it.
- **Subscription presets may now declare a client identity, sent only when you turn on a per-provider "identity imitation" switch.** The switch is off by default, in any deployment mode, and turning it on shows a warning and asks you to confirm, because imitating a client likely violates the provider's terms. Claude and ChatGPT declare no identity and are unchanged.
- **On a wide screen the request inspector docks beside the Requests list and steps from request to request, and the Requests and Batches tables drop columns by priority before they stack.** At 1440px and wider, a request opened on the Requests page shows as a 480px panel at the right edge instead of a drawer over the list: the list stays usable beside it and shows time, model, status and cost, and Escape or Close closes the panel. New Previous and Next controls (and ArrowUp / ArrowDown on a request row while docked) move to the neighbouring request, and screen readers hear each step once, where it stops. Below 1440px the inspector stays the modal drawer, or the sheet on a phone. As space narrows, both tables move their lowest-priority columns (provider, tier, and decided-by or results-until) to a second line, then the next ones, and become stacked records only below 640px of table width. No API, routing behavior or stored data changed.

### Changed

- **polyrouter now identifies itself as `User-Agent: polyrouter/<version>` on every upstream call, instead of the HTTP library's default.** This covers your providers (existing Claude and ChatGPT subscription connections included), OAuth token endpoints, the pricing source and Apprise, and a calling agent's own User-Agent is never forwarded. No API, routing behavior or stored data changed.
- **The request inspector draws each request's recorded route as a trace, and its price snapshot states when it was taken.** The trace runs from the agent (or `(no agent)`, never a raw id) through the router's decision to each attempt the request made, in order: a failed attempt names its failure and HTTP status, a provider that was never contacted reads `skipped — circuit open (provider not contacted)`, a cascade shows its superseded cheap call with its quality score and cost, and one outcome ends the route with its latency; only a served request lights its branch. `Price snapshot` replaces Usage & cost: each token class reads count × unit price, a source row says where the price came from, and the total reads exactly as the request table's cost cell does; its caption says the price was recorded with the request (or at batch submission), replacing a footnote that said it was snapshotted at request time. The error card moves to the top, the served tier gets its own row under Decision, and the separate Fallback trail and Timing boxes are gone. No API, routing behavior or stored data changed.
- **Figures name their state in words: estimates read `· est.` (a price) or `· est. usage` (token counts) instead of `~`, and unpriced, free, below-precision and stale figures say what they are.** An unpriced request reads `unpriced` instead of `$0.0000`, a free one `$0 free`, a cost under a hundredth of a cent `<$0.0001`, and a Limits card whose progress is out of date `as of 14:02 · stale`. Loading figures show a skeleton instead of a dash or a zero, a share with nothing to divide reads `not applicable`, a failed analytics read says it `couldn’t be loaded.` and offers Retry instead of showing old figures as current, and the Batches reserved ceiling reads `≤ $3.00`. No API, routing behavior or stored data changed.
- **Destructive actions are red: a red-outlined trigger and a red commit in a dashboard dialog, replacing the amber button and the browser's native prompts.** Delete, Remove, Purge, Disable and Cancel batch now ask in one dashboard dialog that names what is lost, puts Cancel first, stays busy while the request runs and reports a failure inside itself. The inspector's payload Delete asks first too. No API, routing behavior or stored data changed.
- **The dashboard adopts one set of design tokens, and a first visit follows the OS theme (visual change).** Every page renders differently at desktop width: contrast-safe indigo text and visible field edges, one 24 / 32px control-height family, five rem-based type sizes, a 4px spacing scale and four radii, one selected look, pressed and busy states on every button, a sidebar grouped under Observe, Configure and Admin, a readout strip on Overview and Costs, and a Live lamp that blinks per event. A first-time visitor whose OS is dark now gets the dark theme, resolved before the first paint; a theme you chose is still remembered. No API, routing behavior or stored data changed.

### Fixed

- **On a touch screen wider than a phone, the inspector's Close button and the other icon-only controls are now 44px wide.** Under a coarse pointer above 768px the drawer's Close button, the Routing page's delete `×` and the tier chain's drag handle kept their narrow width (about 29, 15 and 14px) and only gained their 44px height; they now get the same 44px width the phone layout gives them, so Close no longer sits beside the 44px Previous / Next as the one small target.
- **The Settings body-capture card no longer claims "metadata only" before it knows.** While its status was loading, or when the read failed, the card said bodies are never persisted and showed a green Metadata-only badge, even on an instance that captures them; it now claims nothing until the status loads.

### Upgrade notes

- Two migrations run on boot (`0044`, `0045`): the `provider` table gains `imitate_client_identity` (default off) and `key_preset` (null for every existing row), plus two check constraints. No data is rewritten, and no new environment variable is required.
- Every upstream call now carries `User-Agent: polyrouter/<version>`; existing Claude and ChatGPT connections keep working and send it too.
- The four key-plan cards (OpenCode Go, MiniMax M Plan, Kimi Code, Z.ai) ship with every region disabled until its live verification passes, so a fresh install lists none of them yet.

## [0.27.1] — 2026-11-01

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.27.1) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.27.0...v0.27.1)

### Fixed

- **A batch's budget reservation is released from the same period it was added to.** A batch submitted at a UTC day, week or month boundary, or while the app and database clocks disagreed, could reserve in one period and release from the other. Until the next reconcile, that blocked synchronous traffic in one period and under-counted another batch's reservation in the other.

## [0.27.0] — 2026-11-01

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.27.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.26.0...v0.27.0)

### Changed

- **The Auto routing tab separates what auto did from what you set.** A Pipeline panel reads out each stage's status now, its count, share and bar for the range, and its history when a stage was not running; a Setup panel holds the three layer switches, the Targets matrix (now Claim, Strong and Cheap, with no Detected column) and two drawers for self-calibration and semantic learning. Estimated net savings moved to the Cascade row and is shown to the cent, and every row's action lines up. No routing behavior or API changed.

## [0.26.0] — 2026-10-31

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.26.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.25.0...v0.26.0)

### Added

- **Retired models are removed automatically.** A model a provider has not listed for seven days is deleted on the next sync, unless a tier chain or rule uses it (those stay flagged "no longer offered"). On by default for every provider and switchable per provider in its edit dialog; the sync result and toast report how many were removed. **Upgrade:** the first sync after upgrading deletes existing unreferenced "no longer offered" models, and a deleted `custom`/`local` model loses its typed-in price. One migration.

### Changed

- **Settings uses the width of the screen.** The page is no longer capped at 760px: the Instance summary runs across the top, Prompt & response bodies sits beside Notifications and the Pricing catalog, and the agent overrides flow in columns at content width instead of one full-width bar each. Panels stack in the same order at phone width. No setting or behavior changed.
- **The Routing page follows the proxy's order.** Two tabs replace the Auto / Tuning / Rules rail: Tiers & headers (chains and header rules) and Auto routing, a numbered six-stage pipeline with per-stage counts whose details hold the former Auto performance, self-calibration, and semantic learning cards. A Targets matrix shows every band and workload target at once, measured figures get loading, failure (with Retry), and zero-traffic states, and `GET /api/analytics/auto` adds disjoint per-stage `exits`.

### Fixed

- **Requests no provider ran no longer book a cost.** A request skipped by open circuits, refused locally, or rejected by the provider records `$0` and reads "not billed" instead of an estimated cost, so a retry storm against a down provider can no longer trip a `block` budget. Budgets and alerts meter it as zero, the Costs mix counts it apart from free requests, and existing rows are not rewritten.
- The request inspector no longer prints floating-point artifacts in unit prices (`$0.09999999999999999 / 1M`).

### Upgrade notes

- **One migration** (`0043`, adds `provider.auto_prune_unlisted`) runs on boot. Pull `ghcr.io/izzoa/polyrouter:0.26.0` (or `:latest`) and restart.
- **The first sync after upgrading deletes retired models.** Every model already marked "no longer offered" that no tier chain or rule uses is removed on that provider's next sync (a manual Sync, or the next daily catalog refresh). A deleted `custom`/`local` model loses any price you typed in for it, and a stale `:batch` twin takes its base model's batch rate with it. Models a tier or rule still uses are never deleted. If you would rather keep them, turn off **Remove retired models automatically** in each provider's edit dialog before the first sync.
- Request history and recorded cost are untouched. Routing behavior is unchanged.

## [0.25.0] — 2026-10-09

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.25.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.24.0...v0.25.0)

### Added

- **Budget progress in Limits.** Each budget shows cumulative recorded spend against its UTC period ceiling, usage percentage, remaining allowance or overage, separate pending batches, and freshness and cost provenance.

### Upgrade notes

- No migrations. Pull `ghcr.io/izzoa/polyrouter:0.25.0` (or `:latest`) and restart. Progress covers the whole current UTC calendar period, including activity before a budget was created. Budget enforcement, reservation reconciliation, and notification delivery retain their existing behavior.

## [0.24.0] — 2026-09-28

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.24.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.23.0...v0.24.0)

### Added

- **Stream keepalives.** A streaming response that has sent nothing for ~15s (`PROXY_STREAM_HEARTBEAT_MS`) gets a protocol-native keepalive, and one with no first token after ~20s (`PROXY_STREAM_EARLY_COMMIT_MS`) starts its `200` + SSE headers early — so Cloudflare and other proxies no longer drop slow or thinking streams. Keepalives are never model output; fallback and cascade still run until the first token.

### Changed

- **The running row follows a cascade escalation** on the Requests page, switching to the strong model as soon as the cascade escalates.
- **Invariant 3 is reworded**: the model (not the HTTP response) commits at the first token; a transport preamble with no model output may come first.

### Fixed

- Long streams behind a CDN were cut off at about two minutes and recorded as "Cancelled".
- A client disconnect or shutdown while waiting for a first token now ends the response at once; streams send `X-Accel-Buffering: no`.

### Upgrade notes

- A streaming request that fails **after** the ~20s early start now gets its error as an in-stream event (same type and message) instead of an HTTP status. Fast failures are unchanged. Set `PROXY_STREAM_EARLY_COMMIT_MS=0` to keep HTTP statuses for every failure before the first token, and `PROXY_STREAM_HEARTBEAT_MS=0` to turn keepalives off.

## [0.23.0] — 2026-09-24

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.23.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.22.1...v0.23.0)

### Added

- **Retired models are flagged, and removable.** A model a provider's listing stops offering is marked "no longer offered" on the Providers page, in the model picker, and on every tier entry and rule that routes to it (routing is unchanged); Remove deletes it after stating the tier entries and rules it affects.
- **Every provider's model list refreshes itself about once a day** — API-key, custom, local, and subscription alike — in a budgeted background job that only lists models (no chat, no Test) and never changes a provider's status.

### Changed

- **Subscription models come from the provider.** The ChatGPT preset lists the backend's own catalog instead of a built-in list, and Test for every subscription is the model listing — it names no model, so a retirement can't fail it.

### Fixed

- **ChatGPT Test failed on a healthy sign-in** ("invalid request to provider") after OpenAI retired `gpt-5.4-mini`, the model Test used.
- A model's provider-listed context window and vision claim were only written on its first sync; they are now rewritten on every sync.

### Upgrade notes

- One additive migration (`0041`) runs on boot: a nullable `unlisted_since` column on `model`. No backfill — every existing model counts as offered until its provider's next listing.
- A new background job re-lists every provider's models about once a day, spread over hourly ticks (40 providers per tick). The first ticks after upgrading work through your providers round-robin; it only calls model listings, and never changes a provider's status. A ChatGPT subscription's next listing adds the current models (GPT-6 Astra/Sol/Luna) and marks `gpt-5.4-mini` / `gpt-5.4` "no longer offered" — click **Sync models** to see it immediately.
- Anything routed to a model marked "no longer offered" keeps working exactly as before (a rejection falls through your chain); move those routes to a current model, then **Remove** the old one.

## [0.22.1] — 2026-09-24

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.22.1) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.22.0...v0.22.1)

### Fixed

- **Reconnect opens the sign-in steps, not the add-provider form.** Clicking Reconnect before any other provider dialog showed the generic add form (the wizard waited on a preset list only "Add provider" loads), leaving no way to renew the sign-in; it now opens a dedicated "Reconnect <name>" view with the two sign-in steps, a starting state, and a Try-again on failure.

## [0.22.0] — 2026-09-24

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.22.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.21.0...v0.22.0)

### Added

- **Provider health from live traffic.** A provider card now shows the most recent of its last check (Test, Sync, credential refresh, reconnect, edit) and what live traffic observed — failing when its circuit breaker opens, healthy when a served request recovers it — with the reason and how long ago, so a failing provider is visible without clicking Test; stale observations can never overwrite newer ones, and writes happen only on a transition, off the request path.
- **Background OAuth renewal.** A 15-minute sweep renews sign-ins near expiry and re-verifies each grant about daily (budgeted per sweep), so a revoked sign-in turns into "reconnect" on its own; a live 401 triggers one background renewal, and Test repairs a rejected-but-unexpired token or reports reconnect. The sweep calls only token endpoints, never a model API.

### Changed

- **Reconnect is always available** on every OAuth card and in the Edit dialog — it renews the provider in place (models and routing kept) and runs one Test afterwards; the card's contradictory "Last action failed" / "Connected" pair is now one status line plus a neutral token-lifetime line.

### Fixed

- **A revoked OAuth sign-in is durably recorded** — the refresh path wrote "reauthorize required" inside a transaction it then rolled back, so every request re-dialled the dead grant.
- **A bundled-preset Sync no longer marks a provider healthy** without contacting it (ChatGPT).
- **In-flight requests with a dead credential can no longer reopen a just-reconnected provider's breaker** — breaker generations are no longer reused after a reset or an idle expiry.

### Upgrade notes

- One additive migration (`0040`) runs on boot: nullable health columns on `provider` plus a partial index. There is no backfill; an existing provider keeps its `status` and shows it without a source until its next check or traffic transition.
- The first OAuth refresh sweep after upgrading finds no grant marked verified, so it spreads its liveness checks over several 15-minute ticks (25 per tick) rather than dialling every identity provider at once. It calls only token endpoints.

## [0.21.0] — 2026-09-22

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.21.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.20.0...v0.21.0)

### Changed

- **`/v1/models` stops claiming every model is incapable of everything.** The listing advertised `supports_tools: false`, `supports_vision: false` and `supports_reasoning: false` for **every model in existence**, and never emitted `context_window` — because the columns it read had no writer anywhere in the codebase, so every read returned the column default as though it were an answer. Each flag is now `true`, `false`, or **absent**, where absent means no source has stated it: a rendered `false` asserts a model is *known* to lack a capability, which is a different and stronger claim than having no information. Capability resolves from the global pricing catalog — where the provider contracts always placed it — through a ladder of the exact catalog key, the aggregator native-family row, then the provider's own claim, with anything below the exact key marked as an estimate. The same defect made `GET /api/models?supportsTools=true` return an empty list for every tenant; that filter now matches resolved values, and matches neither `true` nor `false` for a model whose capability is unknown. Clients reading the previous universally-`false` value will see flags appear, disappear, and turn true.
- **The model row no longer carries capability columns.** `context_window` and the three `supports_*` columns were dropped from `model`; they had no writer in any sync, CRUD or migration path, so every row held only its default. Reverting an image past this release requires re-adding those four columns first — nothing is lost, because nothing was ever stored in them.

### Added

- **`context_window` and an estimate marker on `/v1/models`.** A model the catalog describes now reports its window, and an entry whose capability resolved below the exact catalog key carries `capabilities_estimated: true` — the same honesty the price figures already carry. The flags stay flat booleans, because the consumers of this envelope are OpenAI- and Anthropic-SDK clients that read flat fields.
- **Provider-listed capability claims.** `sync-models` captures what an aggregator's own `/models` response states about its models — modalities, supported parameters, context length — under the same freshness and endpoint-change rules the listed price already follows: written on every sync, cleared when a later response says nothing, and discarded when the provider's endpoint moves. It is the last tier of the description ladder, always marked as an estimate, and never routing evidence. It is also the only source that can describe a model the global catalog has never heard of, which is most of an aggregator tenant's catalog.
- **Capability-aware chain ordering.** `auto` could classify a request as vision, route it to a text-only model, and recover only by burning an upstream call on the rejection. A chain member the catalog **states** cannot serve the request — an image to a model known to accept none, tools to a model known to support none — is now deferred to the end of the walk. It is never dropped: an all-short chain still dispatches in configured order, because polyrouter never refuses a request on the strength of its own routing intelligence. Unknown never defers, a client-named model is never reordered, and where a request demands no capability the plan is byte-identical to the previous output-cap plan. Capability is ordered ahead of output capacity, because a clamped answer is a real answer truncated honestly while a capability rejection is no answer at all.

## [0.20.0] — 2026-09-21

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.20.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.19.0...v0.20.0)

### Added

- **`GET /v1/models` now describes what it advertises, and answers the Anthropic SDK in its own shape.** The listing returned ids and nothing else, so an agent could see *that* a model was routable but not its context window, its capabilities, or what it costs — figures the router already holds and already shows the dashboard. Real model ids now carry all of it, resolved through the same shared resolver the dashboard and the cost path use and marked when the figure is an estimate; `auto` and tier keys deliberately carry none, because a virtual id names a *set* of models and any single window or price for one is a fiction a client would truncate against. Unknown values are absent rather than null. Every entry also gains the `created` field the OpenAI schema requires. Because `anthropic.models.list()` hits the same URL as the OpenAI SDK, the envelope is now chosen by the `anthropic-version` header — never by the credential header, which either SDK may send; a caller without that header gets the OpenAI shape byte-for-byte as before. A new `GET /v1/models/{id}` serves every id the listing advertises, including ids containing `:` and `/` in either spelling, and 404s anything the listing hides.

## [0.19.0] — 2026-09-18

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.19.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.18.2...v0.19.0)

### Added

- **Threshold calibration now works per agent, under the identical standard.** Calibration was tenant-wide — one pair, calibrated from every agent's rows pooled together — so the agent contributing most of the traffic set the thresholds every other agent inherited; on a measured instance one agent carried 84.6% of the decided ambiguous band. Each agent can now earn its own pair from its own evidence, on the **same** floor, statistic, step, drift cap, gap, hysteresis and cooldown. An agent that has not earned one inherits its tenant's, which is a correct outcome rather than a failure to act. Resolution runs instance → tenant → agent, degrading to the level above at each hop, with drift bounded twice so the two levels cannot compound — and no new hot-path read, because the pair rides a projection the auth guard already performs. The Routing page lists every agent with its pair, anchor and evidence, names the inheriting ones as inheriting rather than uncalibrated, offers a per-agent revert, and discloses a tenant pair no longer informed by the traffic it governs. No per-agent enable: the tenant's toggle stays the single consent boundary.

- **The Auto-performance view now shows the calibrator's actual evidence, per agent.** The threshold calibrator consumes a narrow population — decided ambiguous cascade rows, threshold-source, current-epoch, inside one of two 0.05-wide edge zones — and nothing showed it. Signal quality answers "is this agent's score a point mass"; it does not answer "does this agent have decidable edge evidence, and at what failure rate". A new **Calibration evidence** section reports the second, computed from the calibrator's **own predicates** (factored and shared, so the view cannot drift from what the sweep counts — a test asserts the two agree exactly). Read-time only: no calibrator behaviour changes, no new stored state, no migration.
- **Each edge is reported in one of four states, and none of them promises a move.** `absent` (no decided rows), `reset` (rows exist but a threshold event restarted the stream — shown with both counts, so an epoch bump never reads as an absence), `accumulating` (below the acting floor, shown against it) and `sufficient`. Because an edge at 100 samples and a 30% failure rate sits in the hysteresis dead zone and will never move, the failure rate is shown against its decision rate — a count against a floor alone would imply that reaching the floor is enough. The floor and both rates come from the response, never hardcoded, so SQ-2's change to the floor cannot silently falsify the display.
- **Figures cover the calibration window, not the page's range selector, and say so.** The calibrator reads a fixed rolling 14 days; the page's range is caller-chosen from an hour to a year. Reporting the caller's range would agree with the calibrator on any test fixture and diverge in production the moment someone picked "24 hours".
- **A dead-middle agent is explained rather than shown as two zeros.** An agent whose scores sit between the edge zones contributes nothing to either edge — correct system behaviour that reads as a broken calibrator when left to inference. The section names it, with the thresholds and edge width that produce it.


- **Every request surface now says which agent it came from.** `request_log.agent_id` has always been stored and indexed, and the listing has always returned it — nothing used it, so answering "which agent is accruing these requests" meant opening the inspector one row at a time. The requests table now marks each change of agent with a labelled group boundary (keyed on the agent **id**, so two agents sharing a name still separate), the Requests page gains an agent filter that composes with the routing and mode filters, and the Overview page gains an agent strip naming each agent active in the range with its request count and a click-through to its filtered rows. No schema change and no migration.
- **`GET /api/analytics/requests` accepts an `agentId` filter.** Applied inside the existing owner scope, never instead of it, and matching the **recorded** value: a deleted agent's history still returns (labelled null, because its name no longer resolves), and an id belonging to another tenant returns only the caller's own rows that happen to carry it — never another tenant's. An empty `agentId` is a 400 rather than a silently-ignored filter.
- **`GET /api/analytics/breakdown` accepts `metric=requests`.** The endpoint ranks by spend at a default limit of 10, so an agent issuing high volume against free, local or fully-cached routes accrues near-zero spend and is truncated out of a request-volume view entirely. The Overview strip asks for this ranking with an explicit limit.

### Changed

- **A provider 400 no longer abandons the fallback chain.** `bad_request` was the one kind for which fallback was disabled, on the reasoning that a malformed request would be rejected identically by every member. That does not hold in a router: a 400 is the status under which providers report **per-model capability limits** — an exceeded context window, unsupported tools, an unsupported response format — and those describe the model *polyrouter chose*, not a defect in what the caller sent. A tier whose primary refuses now walks to the next member and serves. Naming a concrete model is unaffected: it resolves to a single-element chain, so there is no next member and the wire response is identical. Under cascade, a cheap-leg 400 now escalates to the strong tier — typically a different provider — instead of being surfaced. This also closes the output-cap dead end, where an unknown-cap member's rejection stopped the walk before the clamped tail was consulted.
- **A refused request now records the provider's classification, while its message stays withheld.** The reported incident was undiagnosable: the row read `bad_request · HTTP 400` with `[validation message withheld]`, and nothing said *why*. The structured classification values the error body carries (`type`, `code`, and named aggregator metadata keys) are now retained on the row and on each attempt, behind three gates — a named source, an identifier shape, and a credential scrub over every suffix join of the set. `error_message` is withheld exactly as before: a *message* is prose that routinely quotes the prompt, while a *classification label* is an identifier that carries the diagnosis. The request inspector shows it as a `classification` row.
- **A request the chain RECOVERED now keeps its failure trail.** `attempt_failures` is written on `fallback` rows, not only `error` rows. Without this the fix above would erase its own evidence — the common outcome of a walked chain is a *successful* request whose earlier member refused, and that trail is the only record of why the chain moved.
- **An oversized upstream response is now its own error kind.** A buffered provider body past the 10 MiB transport ceiling reports `oversized_response` instead of `bad_request`, returns **502 `upstream_oversized`** instead of 400, and settles the provider breaker **neutral** rather than as a health success. The kind exists so the byte bound's guarantee — never fall back, never trip — is carried by a kind that owns it: it previously rode `bad_request`, and would have been silently deleted by the change above, letting one hostile endpoint be drained once per chain member.

### Fixed

- **A calibrated tenant could be frozen out of calibration permanently.** The minimum threshold gap (0.1) is exactly twice the edge width (2 x 0.05), so the two rails built on them were tangent and disagreed at the tie: a pair landing on exactly the minimum gap passed the writer's gap check, was stored, and was then treated as degenerate by the halt rule — leaving a tenant whose calibrator would never move again, whose pair no move could widen, and which no hygiene pass would retire. Admission and the hot-path re-validation now share one rule requiring the gap to clear **both** bounds. A pair like that can no longer be written, and one already stored reads as inert: routing falls back to the instance defaults, the halt clears, and the next run rebases it. No migration, and no threshold constant changed.
- **The calibration-evidence view showed everything except the number that decides.** The calibrator acts on the tenant TOTAL (`calibrationStats` has no agent dimension); the view rendered only per-agent rows. Three agents each reading "30 of 50 needed" said no move was possible while the tenant sat at 90 and moved. The total is now rendered first, labelled as the figure the calibrator evaluates.
- **A decided row with no structural score vanished from every count.** No database constraint makes the structural band, score and source travel together, so a NULL score on an ambiguous row is legal — and in SQL three-valued logic `NOT (NULL >= x)` is UNKNOWN, which a conditional count discards. Such a row fell out of the high edge, the low edge *and* the middle, reporting its agent as having no decided rows at all. The middle bucket now references the two zone expressions instead of restating them (they could drift apart) and places a null-score row explicitly.
- **A tenant that had never had a threshold event was told one reset its evidence.** Rows predating epoch tracking carry no epoch, so they count in the window and not the current epoch — the same shape a real reset produces. The view now distinguishes the two by whether an event ever occurred. The counts are unchanged: excluding those rows is exactly what the calibrator does, and agreeing with the calibrator is the property this view exists to have.
- **A halted calibrator reported healthy evidence.** When a tenant's edge zones touch, or its instance pair is narrower than the minimum gap, the calibrator skips it entirely — the view kept showing counts against a floor. It now reports the halt, derived from the calibrator's own predicate rather than a second copy of it, and also discloses when calibration is simply switched off.
- **A rate below its decision bound could print as the bound.** An integer percent rendered 64.6% as "65%" beside the words "inside the dead zone", which read as a broken check rather than a rate under a 65% bound. Rates now render at one decimal.
- **The per-agent list and its label lookup were unbounded.** Both are now capped with the truncation disclosed — and the tenant total is accumulated over every agent *before* truncation, so the one figure that must be exact is never taken over the visible subset.

### Upgrade notes

- **Three migrations, all additive; no new environment variable.** One adds `error_markers` to `request_log`; one rebuilds the `batch_job` error-kind CHECK constraint to admit the new kind; one adds the per-agent calibration columns — a calibrated pair and epoch on `agent`, `structural_scope` on `request_log`, `membership_generation` on `routing_settings` and `agent_id` on `threshold_calibration_event`. Existing rows are untouched and never backfilled — rows predating capture read as unknown, and every existing agent starts out inheriting its tenant's thresholds, exactly as before. The new `request_log` constraint is added `NOT VALID`, so boot does not scan that table however large it has grown.
- **One wire-visible status change.** A provider response over the transport ceiling now returns `502 upstream_oversized` where it returned `400 invalid_request_error`. If you match on that 400 to detect it, match the 502 instead. Every other envelope is unchanged, including a named concrete model's 400.
- **A tier request that used to fail fast may now take longer and succeed.** A malformed request costs one upstream call per configured chain member instead of one — latency and your provider's rate-limit budget, **not** recorded spend: a failed pre-commit member writes no billable row, so an exhausted chain still records exactly one request-log row.

## [0.18.2] — 2026-09-07

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.18.2) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.18.1...v0.18.2)

### Fixed

- **The batch reservation switch appeared on models the provider will not batch.** Batch capability was read from the *provider* and inherited by all of its models — right for OpenAI and Anthropic, where the batch endpoint covers the account, but wrong for OpenRouter, which sells batch as a per-model SKU that only part of its catalog carries. The switch is now shown only where that model itself is batchable, and `PUT /api/routing/tiers/:id/entries` refuses the same reservations by the same rule, so the dashboard and the API cannot disagree. Reservations you have already stored are untouched and can still be un-reserved. Separately, a `supportsVision` / `supportsTools` filter on the model list could hide a model's batch twin and with it the evidence that the model is batchable.
- **A tier's chain rows did not line up.** The price, the reservation switch and the row actions landed at a different position on every row, depending on which optional pieces that row happened to carry. The chain is now one grid whose columns each row adopts, so every kind of content starts at the same place down the whole tier.

### Upgrade notes

- **Nothing to do — no migration, no new environment variable.** One behaviour narrows: an aggregator model its provider publishes no batch tier for can no longer be reserved for batch, from the dashboard or from `PUT /api/routing/tiers/:id/entries`. Reservations already stored are left exactly as they are, the switch stays available to un-reserve them, and the row says why it can no longer be honoured. As with a provider that loses its batch API, a chain holding such an entry cannot be reordered until that entry is un-reserved.

## [0.18.1] — 2026-09-07

### Fixed

- **No runtime change — this release exists so the released tag passes its own CI.** v0.18.0's tag pointed at a commit whose `npm run typecheck` failed: a data-plane spec file used two types without importing them. Jest does not type-check, and the production build excludes spec files, so both accepted it while a full type-check did not. Nothing shipped differs from v0.18.0, and the `0.18.0` image is unaffected — if you are already running it, there is nothing to gain by upgrading.

### Upgrade notes

- **Nothing to do.** `ghcr.io/izzoa/polyrouter:0.18.1` is behaviourally identical to `0.18.0`. See the [0.18.0] notes for the batch-reservation changes this line actually carries.

## [0.18.0] — 2026-09-07

### Added

- **The batch reservation explains itself.** The control now carries help text — announced with the switch, revealed on hover or keyboard focus, and always visible on narrow screens — saying that the entry is held for batch work, that results can take up to 24 hours and arrive only when the whole batch finishes with nothing streaming, and what that model's batch rate is beside its synchronous one. The rate is the model's own resolved batch price: where none can be resolved the text says so, rather than implying the synchronous price or a discount.
- **A routing-chain entry can be reserved for batch work.** Each tier entry carries a mode: leave it alone and nothing changes; set it to **batch only** and a synchronous walk skips that entry while a batch submission claims it — so one chain can serve cheap interactive traffic and hold an expensive model for overnight runs. The Routing page gains a per-entry switch (always available to un-reserve, even if the provider later changes), and OpenRouter's `:batch` twins appear in the model picker as a shortcut that reserves the model they price, without the twin id ever becoming a routing target. A batch resolves to **exactly one candidate** — the lowest-position reserved entry, else position 0 — and is refused rather than re-pointed at a later member, because a different member can be a different provider at a different price.

### Fixed

- **A batch naming a Claude Pro / Max subscription provider was accepted, then rejected upstream *after* taking a budget reservation** — and since the poller releases nothing on failure, that reservation stayed held for the rest of its window while the job never finished. Batch is now refused up front for any subscription provider, decided by provider **kind** rather than by protocol, so it holds for every subscription preset rather than only the one whose protocol happened to differ. Jobs already accepted keep draining to completion, settling, and serving their results.
- **A tier whose every member was reserved for batch reported itself as unconfigured to a batch submission.** Batch resolution no longer applies the synchronous exclusion to itself.

### Upgrade notes

- **One migration runs on boot** (`0033`): `routing_entry.mode`, `NOT NULL DEFAULT 'any'` with a CHECK. Additive, no backfill — every existing entry becomes `any`, which carries no restriction, so chains resolve exactly as they did before. There is no down-migration; roll back by restoring a pre-upgrade dump.
- **Nothing routes differently until you set a mode.** With no entry reserved, a synchronous walk excludes nothing new and a batch resolves to position 0 — byte-identical to 0.17.0.
- **If you submit batches to a Claude Pro / Max subscription provider, those submissions now fail immediately** with `batch_not_supported` instead of being accepted and rejected upstream. This is a repair, not a new restriction: the old path took a budget reservation first and the poller never released it, so the reservation stayed held for the rest of its window while the job never reached a terminal state. Jobs already accepted keep draining to completion, settling, and serving their results. Use an API-key provider for batch work.
- **If you pinned a routing target to an OpenRouter `:batch` twin**, nothing changes — they are still not routing targets. They now appear in the model picker as a shortcut that reserves the model they price.
- **No new configuration.** No env knobs, no API changes on the agent plane.

## [0.17.0] — 2026-09-05

### Added

- **Batch inference.** `POST /v1/batches` submits a JSONL batch of ordinary chat/messages requests on the same agent key, routed like any other request and handed to the provider's own batch API (OpenRouter, Anthropic, OpenAI); `GET /v1/batches/{id}` tracks it, `/results` streams the outcomes through without storing them, and `DELETE` cancels. Submitting reserves a spend ceiling rather than charging it — the dashboard says which of the two every number is — and settlement replaces the reservation with real, snapshotted per-item cost, durably and in chunks, so a restart mid-settlement resumes instead of double-charging. Batch items are priced at the provider's batch rate and recorded with an explicit price mode, so batch and sync spend separate in analytics. New **Batches** page, a Mode filter on Requests, and a link from a request's inspector to the job it came from.

### Changed

- **Batch-priced model variants (e.g. OpenRouter's `:batch` twins) are detected and no longer routable** — they are shown as the batch rate of the model they price instead of as separate models, excluded from `GET /v1/models` and from routing target pickers, and naming one explicitly returns a clear 400 naming the base model to use. A tier chain containing one now serves from its routable members without spending a failed upstream attempt. No pricing or recorded-cost behaviour changes.

### Upgrade notes

- **Four migrations run on boot** (`0029`–`0032`): a `model.variant` column, the batch price pair on `model_price`, the `batch_job` table, and `price_mode`/`batch_id` on `request_log`. All additive — no backfill step, and existing rows are classified by an idempotent boot pass. Roll back by restoring a pre-upgrade database dump; there is no down-migration.
- **Batch is on by default and needs no configuration.** Five optional knobs: `BATCH_ENABLED` (default true; setting it `false` refuses *new* submissions only — the poller, reads, results and cancel keep serving so in-flight jobs drain rather than strand), `BATCH_MAX_ITEMS` (50 000), `BATCH_MAX_BODY_BYTES` (its own bound, separate from `PROXY_MAX_BODY_BYTES`), `BATCH_POLL_INTERVAL_MS` (15 000) and `BATCH_WINDOW_MARGIN_MS` (1 h).
- **Batch requires a provider that has one.** Adapters ship for OpenRouter, Anthropic and OpenAI; a custom or local provider offers no batch seam and says so at submit time rather than failing later.
- **If you pinned a routing target to an OpenRouter `:batch` model id, it will now be refused at config-write time** with a 400 naming the base model to use. Nothing routed to those ids successfully before — they cannot serve a synchronous request — so this converts a silent runtime failure into a clear one. A tier chain containing one now serves from its routable members without spending a failed upstream attempt.
- **No pricing or recorded-cost behaviour changes for existing traffic.** Historical cost is still computed against the prices snapshotted at request time.

## [0.16.5] — 2026-08-31

### Fixed

- **No runtime change — this release exists so the released tag passes its own CI.** v0.16.4's tag pointed at a commit whose type-check failed on a test file: a two-column `it.each` table read by a one-parameter callback, which jest and the production build both accept (the build config excludes spec files) but a full type-check does not. No shipped code differs from v0.16.4; if you are already running it, there is nothing to gain by upgrading.

### Upgrade notes

- **Nothing to do.** `ghcr.io/izzoa/polyrouter:0.16.5` is behaviourally identical to `0.16.4`. See the [0.16.4] notes for the error-taxonomy changes this line actually carries.

## [0.16.4] — 2026-08-31

### Fixed

- **An out-of-credit or permission-denied provider now falls back instead of failing the request.** `classifyResponse` named nine statuses and mapped **every other 4xx** to `bad_request` — the only kind for which fallback is disabled — so "I do not recognize this status" was answered with the most destructive value in the taxonomy. A live HTTP 402 (empty credit balance) therefore abandoned its fallback chain unwalked, left the breaker closed so the next request hit the same dry account, and returned the agent a `400 invalid_request_error`: the one class a well-behaved client never retries. The 4xx map is now explicit — 402 falls back **and** trips (a dry account rejects everything until a human acts), 405/415 are provider misconfiguration, 410 is body-refined exactly as 404 already was, 451 deliberately **stops** the walk, and any unrecognized 4xx falls back while staying strictly breaker-neutral.
- **401 and 403 no longer share an error kind.** Every provider protocol polyrouter targets returns 401 for an invalid credential and 403 for a permission decision — an unsupported region, a model the key may not call, an org policy. Reading a 403 as an authentication failure treated a per-model denial as provider-wide and opened the circuit breaker, so a single moderation-flagged prompt could take a healthy provider offline for every agent on the instance.
- **A streamed error's classification now reaches the router.** A credit, policy, or permission marker that appeared only in the wire `code` was lost when the proxy re-derived the kind from the outward error type, misrouting in-band failures on the streaming, breaker, and buffered-Responses paths alike.

### Upgrade notes

- **Drop-in: no migrations, no new or changed environment keys.** Pull `ghcr.io/izzoa/polyrouter:0.16.4` and restart.
- **Clients see new status codes for cases that used to be a blanket 400.** An exhausted-credit chain now returns `502` (`upstream_credits`), a permission denial `403`, a content-policy refusal `400` with code `content_filter`, and a legal denial `451`. If you match on `400 invalid_request_error` to detect upstream failure, match on the status instead — the Anthropic envelope renders no `code`, so every new kind is distinguishable by status and message in both protocol shapes.
- **Some requests now cost more before failing.** A chain whose members all sit on one out-of-credit provider dispatches to each member instead of stopping at the first. That is bounded by your configured chain and cascade length (up to 5 per leg), not by the breaker — which opens on a threshold of failures, not the first one — and later requests skip the provider entirely once it trips.
- **Five new values appear in `error_kind`:** `insufficient_funds`, `permission`, `content_policy`, `policy_block`, `upstream_rejected`. Historical rows keep the kinds they were written with and are never backfilled. Each withholds the provider's own message under a marker naming its reason — the kind carries the diagnosis, because no status whose body semantics providers do not guarantee is trusted to be free of prompt content.

## [0.16.3] — 2026-08-29

### Fixed

- **A semantic source that loses its boot build recovers on its own.** v0.16.2 made a failed centroid build rare; it did not make one recoverable — a spent budget left Layer 2 (and research/writing detection) dead until a restart that was a coin flip on the same contention. A retryable failure now arms three slots at +1m/+5m/+15m inside one latched generation, while a degenerate result never retries (its inputs are fixed, so a repeat is near-certain and would bury the real error). Because inference blocks the event loop, the first two slots run only when the model is embed-quiet and abandon if traffic resumes; **the last runs regardless**, so recovery is guaranteed rather than dependent on a quiet window ever arriving. Each slot's outcome is distinguishable in the log, and an exhausted generation says so. No new environment key, no API field, no migration, and no change for an instance whose centroids build at boot.

### Upgrade notes

- **Drop-in: no migrations, no new or changed environment keys, and no behaviour change at all for an instance whose centroids build at boot** — it arms no timers, runs no rebuilds, and logs nothing new. Pull `ghcr.io/izzoa/polyrouter:0.16.3` and restart.
- **New log lines appear only after a failed build.** `semantic <source> recovery slot N: …` reports each scheduled slot's outcome — `closed unrun` (the model was not embed-quiet), `abandoned` (traffic resumed mid-rebuild), or `failed` — and an exhausted generation says so explicitly rather than going quiet. If you see recovery succeed, `semanticAvailable` flips to true with no restart.
- **Recovery is per-process, by design.** The missing state lives in one process's memory, so each degraded replica heals itself; a queued job could be picked up by a healthy replica and do nothing. This shortens per-replica divergence but does not eliminate it — an exhausted generation leaves it until a restart.
- **The costs are disclosed, not zero.** Up to three phase executions per failed source (one unconditional). Because inference runs synchronously on the event loop, a dispatched anchor slice delays whatever is behind it, and a rebuild holding shared admission can make a concurrent request skip Layer 2 for the duration — including a band classification refused during a workload rebuild. No request ever waits on a rebuild or fails because of one.

## [0.16.2] — 2026-08-29

### Fixed

- **Layer 2 survives a busy boot.** The 210 bundled band + workload anchors were embedded under `SEMANTIC_TIMEOUT_MS` — the rail that exists so no *live request* stalls — even though no request waits on a boot embed and per-anchor cost on ordinary hardware sits in the same range as that 50ms bound. The same image and config could therefore come up with L2 ready one day and `semantic classifier UNAVAILABLE — … (embed exceeded 50ms bound)` the next, losing research/writing detection with it and staying dead until restart. Boot-path embedding now has its own bound, each anchor phase is capped by a total budget (so a wedged embedder costs the capability, never the boot), the failure names the budget rather than blaming the bundle, and each phase logs its elapsed time so headroom is visible before it becomes an outage. `SEMANTIC_TIMEOUT_MS` keeps its meaning and 50ms default; the two seams share one admission gate so `SEMANTIC_CONCURRENCY` still bounds in-flight native work across both.
- **The L2 hint stops naming a setting that is already correct.** `GET /api/routing/auto-layers` gains `semanticEmbedderReady` (additive; `semanticAvailable` and the `PUT` shape unchanged), which splits "no model bundle" from "bundle loaded, centroids failed". Both the Automatic-routing L2 row and the Workload-targets row used to say "no ready model; check `SEMANTIC_MODEL_PATH`" in either case — with the embedder loaded they now point at the centroid build and the boot log instead.

### Upgrade notes

- **Drop-in: no migrations, no new or changed environment keys.** `SEMANTIC_TIMEOUT_MS` keeps its meaning and its 50ms default — the anchor build simply no longer consults it. If you raised it to work around a classifier that would not come up, you can put it back where you want it for request latency; nothing forces the change.
- **Two boot lines are new on the `-semantic` image**, and both are worth reading once: `semantic classifier ready: … built=<elapsed>ms/20000ms` and the same for the workload source. That ratio is your headroom. On reference hardware it is ~703ms and ~1460ms against 20000ms; if yours is within ~2x of the budget, the host is close to the line that used to lose the layer outright.
- **A failed anchor build now names its cause.** `… exceeded its 20000ms boot budget` is a host-speed fault (the bundle is fine); the older `did not build/validate` wording is now reserved for anchors that genuinely do not separate under the model.
- `GET /api/routing/auto-layers` gains `semanticEmbedderReady`. Additive — `semanticAvailable` and the `PUT` shape are unchanged, and a client that does not know the field degrades to the previous, more generic copy.

## [0.16.1] — 2026-08-28

### Fixed

- **Auto performance survives a realistically-priced basis.** `GET /api/analytics/auto` bound the `auto_high` counterfactual's per-1M rates into `integer_column * $n`, so Postgres typed them as `integer` and rejected the first fractional rate (`22P02 invalid input syntax for type integer: "1.4"`) — failing the entire request, not just the savings block, for any basis model not priced in whole dollars per 1M tokens. Rates are now `double precision` (matching the float math that wrote the costs they are subtracted from); results for integer rates are unchanged.
- **A failed background job says why.** The scheduler `failed` handlers logged only the error's message — for a wrapped Drizzle query error, that is the SQL, while the real reason (`ENOTFOUND`, a dropped connection, a SQLSTATE) sat unread in `cause`. All seven queues (budget eval, notify delivery, weekly summary, calibration, body purge, semantic learning, pricing refresh) now log the cause chain, clipped to one line.

## [0.16.0] — 2026-08-23

### Changed

- **The Routing page's right rail is sectioned.** A three-section segmented control (**Auto** — automatic-routing layers, Band targets, Workload targets · **Tuning** — Auto performance, Self-calibration, Semantic learning · **Rules** — header rules) replaces the single eight-card scroll, with tier chains and the Add-tier header visible in every section. The rail opens on Auto and remembers the section for the session; Tuning is offered only while the structural layer is available, and losing it falls back to Auto (restoring the choice if it returns) while keeping keyboard focus on the rail. The cards that fetch on mount now ignore a superseded response, so switching sections cannot overwrite newer rows, surface a stale error over loaded rows, or resurrect learned state a revert just cleared.

## [0.15.1] — 2026-08-23

### Fixed

- **Workload-mix rows no longer overflow at phone width.** The Auto-performance "Workload mix" rows (Epic W) pushed their spend labels past the content pane at the 320px reflow width, reddening the responsive browser gate on the v0.15.0 commit. The v0.15.0 image build succeeded, so both published image variants carry this regression until this release; the rows now wrap below the narrow threshold while keeping every figure (share, requests, routed count, spend, and the `unpriced` / `N unpriced` qualifiers) visible and unclipped, with desktop rendering unchanged.

## [0.15.0] — 2026-08-23

### Added

- **Workload-scoped bands (Epic W, W-4).** `auto_high` / `auto_low` rules may carry a `workload_class` SCOPE (migration 0028 replaces the W-2 pairing CHECK with a three-way scope CHECK): for a request whose deciding workload class has a scoped band rule, Layer-1/Layer-2 band resolution and the cascade plan use that class's pair (each band falls back to the generic rule independently; an unusable scoped target makes the band unroutable for the class — no silent substitution), the Workload-target claim still runs first, reasons gain ` scope=<class>` (cascade reasons included), a cascade whose SELECTED cheap leg was scoped contributes no learning evidence, the savings basis is pinned to the generic strong target (`savings.basis.scoped` discloses scoped rules), and the Band-targets card gains a per-workload bands block with scope-isolated set/clear/cleanup.
- **Semantic workloads (Epic W, W-3).** With the flag-gated semantic module loaded, a structural-`none` `auto` request is embedded once and compared to bundled per-class anchors; `research` / `writing` are recorded (`workload_source = semantic`, a `semantic/…` revision) and route through the existing Workload targets when the class beats every other by `SEMANTIC_WORKLOAD_MARGIN` (0.05) and clears `SEMANTIC_WORKLOAD_MIN_SIM` (0.20). The structural source keeps precedence, the semantic source never emits structural classes, the same vector serves Layer 2 (never a second embed; a failed embed skips Layer 2 too), every fault degrades to the unclaimed flow, and telemetry follows the atomic commit. `GET /api/routing/auto-layers` adds `semanticWorkloadAvailable` / `semanticWorkload`; the Workload-targets card's reserved rows go live on that flag (naming the missing half otherwise). No migration.
- **Workload targets (Epic W, W-2).** `auto_workload` rules bind a detected workload class (`code` / `vision` / `structured`; `research` / `writing` reserved) to a tier or model; a matching `auto` request is claimed ahead of band targets, L2, and the cascade (`decision_layer = workload`), everything else stays byte-identical and degrade-safe. Migration `0027` (`routing_rule.workload_class` + CHECKs), rule-CRUD pairing validation, `workloadMix.classes[].routed` + `layer=workload` filter in analytics, a **Workload targets** card with routed counts/disclosures on the Routing page, and a `routed` mark in the inspector.
- **Workload telemetry (Epic W, W-1).** `auto` requests record a workload class — `code` / `vision` / `structured` / `none` — from the existing Layer-1 feature vector (the classification itself is telemetry — it routes only through a configured Workload target, see the W-2 entry above). Four columns on parent request-log rows (migration 0026), a workload chip in the inspector, `workloadMix` on `GET /api/analytics/auto` (per-class requests + reported-basis spend on both ledgers, with unpriced/coverage/revision disclosures), and a "Workload mix" block on the Auto-performance card. New optional `ROUTING_WORKLOAD_THRESHOLDS`.

### Fixed

- **Band-targets `unverified` is raise-only.** A definitively rejected band write whose follow-up reconcile also failed could clear the section-level *unverified* state set by a concurrent landed-but-unverified write on the other band, re-enabling actions against a stale rules snapshot; the flag now only rises from actions and only an authoritative rules re-list that commits (the section's reconcile or a full routing reload) clears it (the same rule the Workload-targets card shipped with), pinned by an ordered race test.

### Upgrade notes

- **Three additive migrations** run automatically on boot: `0026` (the workload quad on parent request-log rows), `0027` (nullable `routing_rule.workload_class` + its CHECKs) and `0028` (the W-2 class/match-type pairing CHECK becomes the three-way scope CHECK, added `NOT VALID` — existing rows are never rewritten). Nothing routes differently until you configure a Workload target or a class-scoped band; `auto` requests simply start recording their workload class.
- The semantic workload source (`research` / `writing`) needs the optional semantic module (`SEMANTIC_MODEL_PATH` + `semantic` in `ROUTING_AUTO_LAYERS`); the baseline image is unchanged. New optional knobs: `ROUTING_WORKLOAD_THRESHOLDS` (structural code detection) and `SEMANTIC_WORKLOAD_MARGIN` / `SEMANTIC_WORKLOAD_MIN_SIM` (semantic rails) — both documented in the README's `.env` reference.

## [0.14.0] — 2026-08-21

### Added

- **Per-attempt fallback forensics + a patient breaker probe.** Breaker-skipped chain members record `skip@model` (never a fake `unavailable`), error rows persist structured per-attempt failure metadata (`attempt_failures`, both cascade legs, no free text) rendered as the inspector's "Fallback trail", and the half-open probe runs/leases at doubled patience so a slow-but-healthy provider can actually close its breaker.

### Upgrade notes

- **One additive migration** (`0025`: nullable `request_log.attempt_failures` jsonb) — runs automatically on boot; historical rows stay null and render exactly as before, never backfilled.
- **Breaker recovery behavior changes deliberately:** a provider opened by timeouts now gets a recovery probe with its first-byte/idle bounds doubled (capped at 1 h) and a matching lease, so heavy-workload providers can close their breaker instead of skip-looping. A genuinely hung provider still times out (at most twice as late, on the probe path only). No new env knobs; per-provider `first_byte_timeout_ms` overrides remain the tuning surface for chains slower than 2× their bound.

## [0.13.1] — 2026-08-20

### Added

- **Auto-layers view splits the semantic capability signal.** `GET /api/routing/auto-layers` now also reports `semanticFlagEnabled` (`semantic ∈ ROUTING_AUTO_LAYERS`) and `semanticClassifierReady` (embedder + centroids), with `semanticAvailable` preserved as their conjunction — additive fields, PUT shape unchanged.

### Fixed

- **One PORT-aware healthcheck across both image variants.** The baseline and `-semantic` images now declare the identical exec-form Node probe against `/api/health` on the configured `PORT` (default 3001), with no `wget`/`curl` dependency — a custom `PORT` no longer needs a probe override, and the documented Node probe form runs unchanged on both variants (utility-based overrides like `wget` stay outside the contract: the old baseline `wget` probe had no binary to run on the `-semantic` image's Debian-slim base). The release pipeline asserts both variants match one canonical probe.
- **The dashboard's unavailable-L2 hint names the actual missing half.** A `-semantic` image with the model loaded but `semantic` absent from `ROUTING_AUTO_LAYERS` is now told to add the flag — not to "set `SEMANTIC_MODEL_PATH`", which it already had; the model-half copy directs to verify the bundle/boot logs and never claims the variable is unset.

## [0.13.0] — 2026-08-19

### Added

- **Output-cap guardrails.** Router-chosen chains now defer members whose known `max_output_tokens` (LiteLLM-catalog-ingested) can't satisfy the request's ask, and clamp per-member (honest `length` finish) when nothing can — instead of dying on a guaranteed provider 400. Deferrals/clamps are recorded in the routing reason (`output_cap_deferred` / `output_cap_clamped`); capacity outranks a subscription member's quota-first position across stages (disclosed, never within a stage). Explicit models untouched; unknown caps and lookup failures degrade to today's behavior; the synthesized Anthropic `max_tokens` default is capped to the model's known limit.

## [0.12.3] — 2026-08-11

### Fixed

- **Page subtitles in the top bar now sit on the title's baseline.** "Overview · last 24
  hours" and every other page header rendered the subtitle a few pixels low: it was
  baseline-aligned against the icon+title group, and a flex group exports its first item's
  baseline — the icon's, which has no text baseline, so its bottom edge was used instead.
- **The OpenClaw connection snippet described a config file OpenClaw cannot read.** The block
  shown by Agents → New (and returned by the agent-create API) was TOML at
  `~/.openclaw/config.toml` with an `[llm]` table — a format OpenClaw has never had. Its only
  config is JSON5 at `~/.openclaw/openclaw.json`, so following the snippet failed silently.
  It now emits the real shape: a `models.providers` entry for the router plus an
  `agents.defaults.model` default of `polyrouter/auto`.

## [0.12.2] — 2026-08-07

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.12.2) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.12.1...v0.12.2)

### Fixed

- **The Overview's model breakdown had no spend/tokens switch.** The toggle added in 0.12.0
  reached only the Costs page, although the panel is the same panel and the preference is
  shared — so the Overview was permanently captioned "Spend by model" with no way to ask it
  the token question. It now offers the switch, and its heading, empty state and units follow
  the selection. Flipping it on either page changes both.
- **The Overview could briefly caption a token chart as spend.** Arriving there after
  switching to tokens on Costs rendered the token-ranked models under "Spend by model" — right
  values, wrong models, bars not in descending order — until the refetch landed.
- **Token counts wrapped to two lines in the requests table.** At real magnitudes
  "87.2k in / 1.5k out" is wider than the column, so most rows rendered as two lines once
  actual traffic arrived. Tokens now read `87.2k↑ 1.5k↓` — the same information in two thirds
  of the width. The in/out wording is kept for screen readers, since an arrow announces as
  "up arrow". The Tokens figure on the Overview gets the same treatment; it was wrapping at
  phone widths for the same reason.

### Upgrade notes

- **No migrations and no schema change** — a drop-in upgrade from 0.12.1, nothing to configure.
- **Both fixes are client-side**; loading the new build is the whole upgrade.
- **The spend/tokens preference is now shared between the Overview and Costs.** It was already
  one setting internally — only Costs could change it. Setting it on either page now affects
  both, which is what it always meant.

## [0.12.1] — 2026-08-07

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.12.1) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.12.0...v0.12.1)

### Fixed

- **Five controls were below the minimum hit target at desktop width.** The setup guide's
  dismiss button, the three setup step buttons and a routing dropdown all rendered at 22px,
  under the 24px floor required at every width (WCAG 2.5.8 AA). The stylesheet applied that
  floor only to controls carrying a component class, so anything styled inline went without it
  above the narrow breakpoint — and nothing tested the floor there. Controls now get it by
  being controls.
- **Interface symbols rendered differently on every operating system.** A dozen small marks —
  copy, close, chevrons, the drag handle, the escalation arrow, chart legend keys and the
  status dot — were text characters the bundled fonts do not contain, so each was drawn with
  whatever symbol font the viewer's machine supplied. They are now inline vector icons from a
  single registry, or styled elements where the mark is only a coloured shape. Nothing extra is
  fetched.
- **An escalated request was never announced to screen readers.** Escalation was indicated by a
  decorative arrow and nothing else, so assistive technology had no way to convey it. A
  completed setup step had the same problem, carrying its state in a checkmark and a green ring
  alone. Both now have accessible text.
- **The dashboard could fail to load entirely on hosts reporting an unusual locale.** Some
  environments — containers, kiosk browsers, minimal Linux images — report a locale tag the
  browser's own formatting APIs reject, such as `en-US@posix`. The charting library builds a
  number formatter from that raw value as it loads, so the whole dashboard failed before
  rendering anything: a blank white page, with the only clue in a developer console. The
  reported locale is now checked and, when unusable, replaced with the one the browser itself
  resolved for that machine. A host reporting a normal locale is untouched and formats exactly
  as before.
- **A dashboard that cannot start now says so, instead of showing a blank page.** An empty
  page is indistinguishable from a crashed server, an interrupted deploy, or a misconfigured
  proxy, so it sent self-hosters looking in the wrong place entirely.

### Upgrade notes

- **No migrations and no schema change** — a drop-in upgrade from 0.12.0, and no configuration
  to change.
- **Three fixes, all client-side.** Loading the new build is the whole upgrade: the locale fix
  and the boot-failure message live in the page shell, and the icon and hit-target work is
  markup and CSS. Nothing is stored, and nothing about routing, cost recording or the proxy
  changes.
- **Two figures look slightly different, deliberately.** The request list's token column now
  reads `100 in / 40 out` rather than `100 → 40`, and a routing breadcrumb reads "Settings,
  Notifications". Same values, wording chosen so the characters come from the bundled fonts.

## [0.12.0] — 2026-08-06

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.12.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.11.0...v0.12.0)

### Fixed

- **One account's spend figures could remain visible after switching to another.** The same
  defect as below, on the Observe pages: summary, timeseries and cost breakdowns were guarded
  only against stale *range* replies, a guard that cannot see an account change. Signing out
  and in as a different user left the previous account's spend totals, counts and breakdowns
  on screen, and an in-flight response could still commit. The boundary now invalidates and
  clears them and resets their loading state; the guard lives in the shared slice runner, so
  every such loader is covered at once.
- **One account's request data could remain visible after switching to another.** Signing out
  and in as a different user in the same browser session left the previous account's request
  list, cursor and frozen window in place, and a load already in flight could still commit
  afterwards. The inspector's selection and **cached payloads** were not cleared either —
  where body capture is enabled, that is the request and response text itself. All of it is
  now cleared at the account boundary, which also resets the page's loading state so a
  mid-load switch cannot latch a spinner. No server-side isolation failure was involved:
  every response was correctly scoped to whoever asked, and the defect was entirely in what
  the client retained.

### Fixed

- **The body-capture confirmation dialogs are real dialogs.** "Capture prompt & response
  bodies?" and "Turn capture off" carried `role="dialog" aria-modal="true"` while implementing
  none of the behaviour it promises: focus could Tab straight out into the page behind, and
  Escape did not dismiss them — while assistive technology was told the rest of the page was
  hidden. Both now trap focus, close on Escape, and restore focus to the control that opened
  them.

### Changed

- **The account menu closes on Tab**, letting focus continue into the page, which is how
  menus are expected to behave; previously Tab moved focus through the page behind while the
  menu stayed open. Underneath both entries, overlay layering is now decided in one place —
  which surface takes Escape, which traps Tab, and which paints on top all derive from one
  ordering, so they can no longer disagree.

### Added

- **The Requests page no longer silently goes stale.** It froze its time window at load and
  never fetched again, so a request completing while you watched could never appear — and the
  in-flight band arguably made that worse, since live rows above a frozen list read as
  current. It now refreshes on the Overview page's cadence through the shared refresh budget,
  so a burst of traffic cannot become a burst of queries. Once you have clicked "Load more",
  it stops refreshing (that would discard the pages you asked for) and instead tells you how
  many newer requests exist, offering to load them; a failed check says so rather than
  implying the list is current.
- **A finished request stops saying it is still running.** A request whose record is being
  written now reads "Finishing" and stops pulsing, instead of claiming to be in progress for
  up to eight seconds. This was wrong on the Overview card too — just harder to notice, since
  the handoff there usually completes in well under a second.
- **The Requests page shows requests as they run.** Running requests now appear above the
  completed rows, from the same live set the Overview card reads — the page previously showed
  only requests that had finished *before you opened it*, since its window freezes at load.
  The band is deduped against the rows that page is showing (a just-settled request lingers
  while its durable row is written, and arriving mid-handoff re-freezes the window to include
  it — otherwise the same request renders twice), and respects the filters it honestly can:
  Explicit/Auto select on the routing decision, known at admission; Fallbacks/Escalated depend
  on how a request ends, so the band empties rather than guessing. The paginated list, its
  cursor and "Load more" are untouched. Also fixes a latent ordering bug in the shared live
  state, where a snapshot still in flight could land after newer state and overwrite it.
- **Costs breakdowns can be ranked by tokens, not just spend — and "tokens" now means what
  you were billed for.** The three Costs panels share a spend/tokens selector; switching it
  refetches rather than re-sorts, because the API returns a top-N and re-ordering the rows
  already on screen would have shown "top by tokens" while silently dropping any provider
  that leads on tokens and trails on spend. Two corrections come with it, and both move
  numbers: token totals now sum **both cost ledgers** (an escalated cascade attempt consumes
  tokens that are metered and billed — spend counted them, tokens did not), and they now
  include **cached tokens** (`input_tokens` is recorded as *uncached* input, so input+output
  omitted a cached workload's largest component). Summary, timeseries and breakdowns move
  together, so no two token figures can disagree. **The Overview's token headline reads
  higher for the same range** — that is the fix, not a display change. Nothing that decides
  anything reads these figures: budgets, alerts and routing use their own spend-only paths.
  `GET /api/analytics/breakdown` gains an optional `metric` parameter (defaults to `spend`)
  and returns the four token components plus `estimatedTokens`.
- **Tier chains can be reordered by touch, and the chain row is readable on a phone.**
  Reordering was wired to HTML5 drag-and-drop, which browsers never fire for touch — so the
  Routing page rendered on a phone and could not do its job. Each row now carries explicit
  move-up/move-down controls, disabled at the chain's ends, sharing the mover with the
  keyboard path so the three transports cannot drift (a test reorders by drag, by keyboard
  and by tap and asserts an identical result). They appear below 768px or wherever *any*
  pointer is coarse — a laptop with a mouse and a touchscreen reports a fine pointer while
  being exactly the device that cannot drag. Measuring the row also turned up that it was
  already broken: at 320px it had 194px of space and put 253px in it, so the model
  identifier computed to **zero width** with the price label painted over it and the row
  action clipped — none of which registered as document overflow. The row now separates
  information from actions and wraps. Desktop rendering is unchanged.
- **The dashboard's overlays work on a phone.** Phase 1 adapted the pages but left the
  drawers and dialogs, so a request opened a 440px inspector on a 390px screen (120px
  off-screen at 320px), and the provider form put its Save button 310px past the bottom of a
  fixed backdrop where no scroll could reach it. Below 768px the inspector, all six modal
  kinds and both confirmation dialogs are now bottom sheets that scroll internally — the same
  DOM restyled, so dismissal, focus and layer ordering are unchanged. The on-screen keyboard
  is handled for the first time (sheets and the model picker measure the visible viewport,
  and pinch-zoom is not mistaken for a keyboard), safe-area insets keep actions clear of the
  home indicator, and the toast now spans the screen instead of shrink-wrapping into a
  160×103px block. Desktop rendering is unchanged, pinned against geometry captured before
  the work began.
- **The dashboard works on a phone.** Every page previously rendered a desktop layout at any
  width — the sidebar alone took 53% of a phone screen and the requests table needed ~1020px
  to stay legible. Below 768px the sidebar is now an icon rail that expands into a labelled
  navigation panel (carrying the account menu and setup guide), multi-column layouts drop to
  fewer columns, control rows wrap rather than overflow, and gutters tighten. Tables adapt to
  the width **they** get rather than the viewport's — at a 1025px window the requests table
  actually receives ~765px — so each of the four tables reflows to stacked records at its own
  measured threshold, keeping every field, every row action, and, for a request row, the same
  single control with the same accessible name and the same link into the inspector.
  Interactive controls now meet a 24px minimum target at every width and 44px below the narrow
  threshold or on a touch pointer, so a tablet gets comfortable targets too. **Desktop
  rendering is unchanged** apart from three controls that shipped below the 24px accessibility
  minimum and grew to meet it; that parity is pinned by a browser test measured against the
  released v0.11.0 build. Detail drawers and dialogs keep their current geometry for now.

### Upgrade notes

- **No migrations and no schema change** — a drop-in upgrade from 0.11.0.
- **Two fixes in this release concern data visible across an account switch.** If more than
  one person signs in to the same browser on a shared machine, upgrade rather than defer:
  the previous account's request rows, its captured prompt/response payloads (where body
  capture is enabled), and its spend figures could remain on screen after switching. No
  server-side isolation failure was involved — every API response was correctly scoped to
  whoever asked — and nothing was ever sent to the wrong principal. The defect was in what
  the browser retained locally, so it is fixed entirely by loading the new build.
- **Two token figures change value, deliberately.** Token totals now count both cost ledgers
  (an escalated cascade attempt burns tokens you were billed for) and include cached tokens
  (`input_tokens` is recorded as *uncached* input). The Overview headline will therefore read
  higher than it did for the same range, more so the more caching and cascade escalation your
  traffic does. Historical cost is untouched: no price snapshot is recomputed and no recorded
  cost changes.
- **`GET /api/analytics/breakdown` gains an optional `metric` parameter**, defaulting to
  `spend`, so existing callers are unaffected. Its rows now also carry the four token
  components and an `estimatedTokens` figure.

## [0.11.0] — 2026-08-05

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.11.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.10.0...v0.11.0)

### Added

- **Branded HTML notification emails with links back into the dashboard.** Every
  notification now ships as `multipart/alternative` — a text-only client sees exactly
  the previous wording, an HTML client sees a laid-out message with a link to the
  relevant page (provider alerts open Providers, budget alerts open Limits, spend
  summaries open Costs). Invite and password-reset emails share the layout. The design
  is deliberately asset-free — a text wordmark, no images, web fonts, or externally
  hosted anything — so it renders on an instance that isn't publicly reachable and
  triggers no remote fetches. **To get the links, set `APP_URL` to the address your
  users actually reach the dashboard at, then restart** — they appear only from a
  routable origin, so with the default (`http://localhost:3001`) they are omitted
  rather than sending a `127.0.0.1` link that would be dead in a recipient's inbox,
  and setting `APP_URL` to a loopback value explicitly does the same, deliberately
  (a LAN or `.local` address works and is often right for a self-hosted instance).
  Chat channels (Apprise) additionally carry a per-event severity, so a
  provider-down or budget-block notification is visually distinct from an
  informational summary.

- **Dashboard pages are addressable by URL.** Every page now has a `#/<page>`
  fragment: pages are bookmarkable, Back/Forward move along the page axis, and an
  external link can open a specific page. Unrecognized fragments fall back to the
  default page. The admin-only Users area cannot be reached by URL as a non-admin —
  the requested page is held until the session resolves, then admitted only if
  permitted — and the accept-invite token fragment is never parsed as a page or
  written to history.

- **Per-agent L1 signal quality on the Auto-performance card.** A stable agent whose
  structural score collapses to a near-constant (one two-decimal score bucket covering
  ≥ 50% of its ambiguous requests, over a ≥ 50-request evidence floor) is now named on
  the Routing page with its modal score and share, plus guidance that fits the
  instance: pin the agent to a tier, or enable/configure L2 · Semantic — the layer that
  evaluates exactly that ambiguous slice. Agents below the evidence floor are disclosed
  in a neutral coverage line rather than silently looking healthy. The
  `/api/analytics/auto` response gains a per-agent `signalQuality` block.

### Upgrade notes

- **No migrations and no schema change** — a drop-in upgrade from 0.10.0.
- **`APP_URL` now also gates the links in notification emails.** The variable is not new
  and its auth behaviour is unchanged, but it now decides whether a notification carries
  a link to the dashboard: set it to the address your users actually reach the dashboard
  at, then restart. On the default (`http://localhost:3001`), or any loopback value, links
  are deliberately omitted rather than sending a `127.0.0.1` URL that is dead in a
  recipient's inbox. A LAN or `.local` address works and is often right for a self-hosted
  instance. The value is read at boot, so a restart is required after changing it.

## [0.10.0] — 2026-07-29

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.10.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.9.3...v0.10.0)

### Changed

- **Subscription usage is no longer counted as money spent.** Requests served by a Claude
  Pro/Max provider were priced at Anthropic's API list rate and treated as ordinary cost —
  overstating spend, drawing prepaid traffic as "paid" on the Costs page, and letting a
  `block` budget refuse requests over money that was never spent. Each ledger row now
  snapshots its serving provider's kind, and every spend figure — headline, chart and
  breakdowns — excludes the prepaid component, reporting it separately as value served on
  subscription. **Your Spend figure will drop** if you use a subscription provider.
- **Budgets now declare what they count.** Existing budgets are migrated to `notional` and
  meter exactly what they metered before, so no enforcement changes on upgrade; new budgets
  default to counting money spent. Notional metering is a crude proxy for a flat-rate plan's
  capacity, but it is the only usage throttle available today, so it stays as an explicit
  choice in the Limits form rather than being removed. Budget alerts state which basis they
  metered.

### Fixed

- **The dashboard no longer slides off the top of the window, and the sidebar's lower items
  stay reachable on a short viewport.** The sidebar owned no overflow of its own, so on a
  viewport shorter than its content (~520px) the spill enlarged the app shell's scroll
  range — leaving the shell translatable (moving every pane at once, topbar included) and
  the account menu clipped out of reach. The sidebar now scrolls internally, and the shell
  is `overflow: clip` so it cannot be moved by any mechanism. Vertical scroll chaining is
  contained without disabling horizontal swipe-back navigation, and the shell is sized in
  `dvh` so a mobile URL bar can't make it taller than the visible viewport either.

## [0.9.3] — 2026-07-26

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.9.3) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.9.2...v0.9.3)

### Added

- **Keyboard reordering for tier chains.** The `⋮⋮` handle is a real button —
  `Alt`+`Arrow Up`/`Down` moves an entry, focus follows it, and the new position is
  announced. `Make primary` was previously the only keyboard path and could only reach
  position 0.

### Fixed

- **Dragging models to reorder a tier chain no longer flickers, and the dropped order
  sticks.** Drag state was keyed by a list index re-read after the reorder had been applied,
  so it tracked the wrong row — for an adjacent swap it oscillated at the browser's
  drag-event rate with the pointer held still. Separately, a chain write's response or a
  routing refresh arriving mid-drag repainted the chain from server state and discarded the
  reorder. Also: reorders now commit past the row midpoint, the drop is accepted instead of
  animating a snap-back, and drag data is set so Firefox will start the drag at all.
- **`CALIBRATION_*` and `EVENTS_*` set in `.env` now reach the container.** The shipped
  `docker-compose.yml` is an explicit `environment:` allow-list and neither namespace was on
  it, so all thirteen knobs silently kept their defaults. Unset behaviour is unchanged.

## [0.9.2] — 2026-07-25

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.9.2) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.9.1...v0.9.2)

### Changed

- **Release images are built on native runners per architecture instead of emulating arm64.**
  The `linux/arm64` half was cross-built under QEMU, which broke two of the last three
  releases — crashing inside `npm ci` and then wedging for 88 minutes until the job timeout,
  which is how v0.9.1 came to publish its `-semantic` variant while the baseline was missing
  and `latest` stayed on 0.9.0. Each arch now builds natively and a merge job assembles the
  manifest list, so tagging happens once at the end. Same tags, same two platforms, no
  attestation entries; the whole release now takes 3m40s instead of 20m42s.

### Fixed

- **`org.opencontainers.image.licenses` annotations reported the deprecated `AGPL-3.0`** while
  the matching label correctly said `AGPL-3.0-only`. Both now agree.

## [0.9.1] — 2026-07-24

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.9.1) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.9.0...v0.9.1)

### Fixed

- **Small green status text is now legible in light mode.** Labels like "OK · served",
  "Live", "free", "accepted" and "enabled" used the same green as the status dots — only
  2.7:1 against white, below the WCAG AA 4.5:1 floor for text. They now use a darkened
  green (5.4:1); dots, bars and chip fills keep the original colour, so the only visible
  change is that the text is readable. Dark mode already passed and is unchanged.

### Upgrade notes

- **Nothing to do** — no migrations, no config, no behaviour change beyond the colour of
  small green labels. A drop-in upgrade from 0.9.0.

## [0.9.0] — 2026-07-24

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.9.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.8.1...v0.9.0)

### Added

- **Per-page icons in the dashboard.** Each nav page (Overview, Requests, Costs,
  …) now has a line-icon in the left-rail nav, and the same icon carries into the
  page header when you select it. Icons are greyscale and follow the single-accent
  theme (they tint to the accent on the active item); the Setup card keeps its
  progress ring (its icon shows in the header only).
- **In-progress requests on the Overview.** The "Recent requests" card now shows
  requests that are still running — model, provider, tier and a live-ticking
  latency — above the completed rows, instead of leaving them invisible until they
  finish. Presence is tracked in Redis, metadata-only and per-owner; if Redis is
  unavailable the card simply shows no live rows and nothing else changes.
- **Live push updates in the dashboard.** A single owner-scoped SSE endpoint
  (`GET /api/events`) streams in-flight presence and analytics-staleness nudges, so a
  running request appears as soon as it starts and hands off to its completed row on an
  explicit event instead of being inferred from a later poll. Polling remains the
  reliable fallback — if the stream is unavailable, refused, dropped, or buffered by a
  reverse proxy the dashboard keeps working and shows **Polling** instead of **Live**.
  Nudges share one refresh budget with the analytics poll, so push is never more
  expensive than the polling it supplements. New env `EVENTS_ENABLED` /
  `EVENTS_HEARTBEAT_MS`; **reverse proxies must not buffer `/api/events`**.

### Changed

- **The dashboard stops polling while its tab is hidden** and refreshes immediately
  on return. A backgrounded tab now costs nothing instead of ~40 requests/min; an
  idle, visible Overview drops to 28/min (the live in-flight poll relaxes to 5 s
  while nothing is in flight and snaps back to 2.5 s on the first live row, so the
  settle handoff stays at full speed).

### Fixed

- **Live in-flight rows can no longer be corrupted by overlapping or cross-account
  responses.** Pollers are single-flight, so a slow response can never be applied
  out of order and falsely mark a running request as finished; and live-view state
  is now cleared and invalidated on any account change — including a mid-session
  session expiry — so one account's rows can never appear under another's.

### Upgrade notes

- **No migrations, no required config changes** — a drop-in upgrade.
- **If you front polyrouter with a reverse proxy, do not buffer `/api/events`.** The
  dashboard's live updates ride Server-Sent Events on that one path. polyrouter already
  sends `X-Accel-Buffering: no` / `Cache-Control: no-transform` and heartbeats every
  25s, but nginx/Traefik/Cloudflare may still need response buffering disabled for it
  (nginx: `proxy_buffering off;`). If the stream is blocked nothing breaks — the
  dashboard shows **Polling** instead of **Live** and keeps refreshing normally.
- **New optional env, all defaulted:** `EVENTS_ENABLED` (set `false` to turn the stream
  off entirely and stay on polling) and `EVENTS_HEARTBEAT_MS`, plus reconciliation /
  per-owner-cap / queue-bound / coalesce-window knobs. Boot fails fast on an
  out-of-range combination rather than silently loosening a bound.
- **The dashboard is quieter by default.** It stops polling while its tab is hidden and
  refreshes immediately on return; an idle, visible Overview drops from ~40 to ~28
  requests/min, and a connected stream makes an idle instance cost essentially nothing.
  Still **one app replica only** — the event stream fans out in-process.

## [0.8.1] — 2026-07-22

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.8.1) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.8.0...v0.8.1)

### Changed

- The request inspector header now shows the **date** alongside the time (e.g.
  `2026-07-22 10:01:58`), so a request's timestamp is unambiguous across days.
- **Recorded cost now falls back to a provider's listed price** when the bundled/
  LiteLLM catalog (exact + native-family) doesn't cover a model: a model whose
  provider (e.g. OpenRouter) reported a per-token price at sync time records that
  captured `listed` price instead of `unpriced`. LiteLLM always wins (listed is
  last-resort, never overrides the catalog); the estimate is snapshotted immutably
  and marked everywhere (`provider-listed · estimate`, `· est.`), never shown as an
  authoritative cost.

### Fixed

- The request inspector's DECISION section now shows the **value** a client sent
  with `x-polyrouter-tier` when the request matched a tier-header remap rule (e.g.
  `x-polyrouter-tier: shopping`) — previously only the header name was shown. The
  value recorded is the matched rule's owned config value (never raw client
  bytes); rules on any other header still record the name only, since a configured
  value there could be a credential.

### Upgrade notes

- No migrations, no new env, no config changes — a drop-in upgrade.
- **Recorded cost for uncatalogued provider-listed models changes** from
  `unpriced` to a marked estimate. If your provider (e.g. OpenRouter) lists a
  price for a model the LiteLLM catalog doesn't cover, new requests now record
  that listed price — clearly flagged `provider-listed · estimate` (never an
  authoritative cost, and never overriding the catalog). Historical rows are
  unchanged (cost is immutable). Run a pricing refresh to prefer authoritative
  LiteLLM rates where available.

## [0.8.0] — 2026-07-21

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.8.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.7.0...v0.8.0)

### Added

- Per-provider **max-tokens field** for OpenAI-compatible providers: a
  `maxTokensSpelling` setting (`auto` | `max_completion_tokens` | `max_tokens`,
  default `auto`) chooses which wire field the output-token cap is sent under.
  `auto` is kind-derived — `local` providers emit `max_tokens`, every other kind
  emits `max_completion_tokens` (required by OpenAI o-series / reasoning models).
  Inbound still accepts either spelling; outbound emits exactly one. Set via the
  provider create/update API (a dashboard control is a follow-up).
- Optional Layer-2 semantic-embedder runtime (`SEMANTIC_MODEL_PATH` + a local
  model bundle): bounded local ONNX embedding with boot warmup, fail-fast on
  broken bundles, a content-derived model revision, and an ORT- and
  model-free baseline image (CI-asserted). `ROUTING_AUTO_LAYERS` becomes a
  validated token list and accepts an inert `semantic`; the auto-layers API
  reports `semanticAvailable`. Routing consumption arrives with the
  semantic-routing change.
- Layer-2 semantic routing: a Layer-1-ambiguous `auto` request is embedded and
  classified against bundled anchor centroids — a confident band routes via the
  existing `auto_high`/`auto_low` targets with `decision_layer='semantic'`, and
  anything ambiguous or faulted degrades to cascade/default unchanged. Four
  nullable telemetry columns (band/score/source/revision) with all-or-none DB
  checks, the ordered L1→L2 reason trail on default and cascade rows, a per-
  tenant `semantic` layer toggle (semantic⇒structural), and a
  `decision_layer=semantic` analytics filter. Opt-in, off unless an embedder is
  loaded; no prompt text or vectors are ever logged.
- Layer-2 **learning loop**: per-tenant learned centroids that track each tenant's
  own outcome-labeled traffic (opt-in, default OFF). Cascade outcomes weakly label
  the L2-ambiguous requests (quality-passed → `low`, quality-gate escalation →
  `high`); embeddings accumulate in bounded volatile memory and flush to Redis only
  as a ≥ `MIN_COHORT` sum — never a single raw embedding. A daily sweep folds fresh
  evidence into learned centroids under rails (min samples, capped EMA, spherical
  drift clamp toward bundled, cooldown, evidence-revision match), crash-atomically
  across Redis + Postgres (rotate → stage → CAS + scalars-only audit → promote).
  Learned supersedes bundled only under read-time gates; any failure or Redis fault
  falls back to bundled, never skip. One-action revert; a status view (counts /
  source / generation / last-applied). No vector ever reaches Postgres, a log, a
  metric, or an API response.
- Layer-2 semantic **dashboard**: the permanently-locked "L2 · Semantic" stub
  becomes a real driven toggle — honest available/unavailable copy naming
  `SEMANTIC_MODEL_PATH`, no dead control. A **learning card** (shown when L2 is
  effective) carries the opt-in learning toggle, fresh-sample / source / last-
  applied status, the numeric audit history, and a confirmed one-click revert —
  honest under degradation (a stale learned centroid shows `bundled` with the
  reason). Auto-performance gains the semantic slice (evaluated, routed-per-band,
  the four-way outcome split, bundled/learned source) with residual-cascade
  labeling so pre/post-enable cascade figures stay comparable, plus a
  `semantic_source` provenance chip in the request inspector. A
  **batteries-included `-semantic` image variant** ships beside the baseline:
  same Dockerfile (glibc base, exact-pinned ONNX runtime, reference MiniLM model
  pre-baked and checksum-pinned at build time, `SEMANTIC_MODEL_PATH` preset), a
  compose override with bring-your-own-model support, and a dual-arch release
  smoke test — the baseline image stays ORT- and model-free.

### Fixed

- Local / legacy OpenAI-compatible endpoints that accept only `max_tokens` no
  longer silently lose the caller's output-token cap — the proxy now sends the
  field each endpoint accepts (see the max-tokens field setting above). Existing
  `local` providers switch to `max_tokens` on upgrade; all others are unchanged.

### Upgrade notes

- **Migrations run on boot** (`0020` semantic telemetry columns on `request_log`;
  `0022` the `semantic_learning_event` table). No manual step — but back up
  before upgrading a production database, as always.
- **Layer-2 semantic routing is opt-in and off by default.** The baseline image
  is unchanged (no ONNX runtime, no model). To turn it on, either run the new
  **`ghcr.io/izzoa/polyrouter:0.8.0-semantic`** image (batteries-included: ORT +
  reference model pre-baked, `SEMANTIC_MODEL_PATH` preset) or install the
  `onnxruntime-node@1.27.0` peer and point `SEMANTIC_MODEL_PATH` at a model
  bundle. Add `semantic` to `ROUTING_AUTO_LAYERS` to expose the capability;
  tenants still opt in per-account. New knobs: `SEMANTIC_*` and
  `SEMANTIC_LEARNING_*` (all defaulted; learning is off until a tenant enables
  it). Nothing is fetched at runtime; no prompt text or embedding vector is ever
  logged or persisted.
- **`local` providers now emit `max_tokens`** (not `max_completion_tokens`) —
  a fix for endpoints that reject the newer spelling. Override per provider via
  the max-tokens field if your local gateway expects the other spelling.

## [0.7.0] — 2026-07-21

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.7.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.6.0...v0.7.0)

### Added

- Per-provider upstream-timeout overrides (1s–1h, provider form → Advanced),
  resolved per fallback-chain attempt, with the instance defaults served by
  `GET /api/providers/timeout-defaults` for honest inherit display.

### Fixed

- Long-running research-class models (o3-deep-research and kin) survive
  end-to-end: the hidden undici 300s headers/body ceiling is gone (dispatcher
  timeouts now derive above polyrouter's typed bounds), and upstream bytes —
  SSE keepalive comments included — count as stream liveness, re-arming the
  stall watchdog and the breaker's half-open probe lease. True silence still
  fails typed at exactly the configured bound.
- The ChatGPT/Responses adapter's buffered path gained its own byte-re-armed
  idle guard (it previously had no typed post-headers bound).
- The body-capture card's buttons use the design system's real classes —
  "Purge all bodies" no longer renders as bare oversized text.

## [0.6.0] — 2026-07-20

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.6.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.5.1...v0.6.0)

### Added

- **Pricing stays current by itself** — a daily automatic LiteLLM catalog
  refresh, on by default for self-hosted (`PRICING_REFRESH_SCHED_ENABLED=false`
  opts out), plus a Settings **Pricing catalog** panel with freshness, a
  "never refreshed" callout, and Refresh-now. New prices apply to new requests
  only; recorded costs never change.
- **Opt-in prompt & response body capture — off by default.** Three-way mode
  (Off / Errors & escalations only / All) behind an explicit consent confirm;
  per-agent overrides that are inert while the global mode is off; 256 KiB/
  direction caps with honest truncation; media stripped to size markers;
  encryption at rest always; 30-day retention (infinite only as an explicit
  choice) with purge-all, per-request delete, and keep-or-purge on disable —
  race-proof against in-flight writes. The inspector gains a lazily-fetched
  Payload section. Cloud instances never capture.
- **The inspector shows which header chose the route** — a dedicated `header`
  row for header-routed requests (`x-polyrouter-tier: heavy`, or a custom
  rule's header name only — configured rule values can be credentials and are
  never persisted).

### Changed

- **`x-polyrouter-tier` beats every routing mechanism except the `model`
  field** — the tier header is one coherent resolution phase (value remaps,
  then direct tier lookup) evaluated before rules on any other header; a
  matched-but-broken tier ask surfaces a clear error instead of silently
  rerouting.

### Fixed

- The Auto performance card refreshes on every Routing-page visit instead of
  freezing at its first fetch.

### Upgrade notes

- Migrations run on boot: `0016` (routing-header columns), `0017` (body-capture
  tables + per-agent override column). No backfill.
- Body capture requires `MODE=selfhosted` and an explicit enable in Settings;
  `BODY_CAPTURE_MAX_BYTES` (default 262144) tunes the per-direction cap.

## [0.5.1] — 2026-07-20

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.5.1) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.5.0...v0.5.1)

### Fixed

- The Band-targets picker stays pinned to its placeholder instead of jumping to
  the first option after a retarget.

## [0.5.0] — 2026-07-20

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.5.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.4.0...v0.5.0)

### Added

- **Band targets are dashboard-configurable** — a Routing-page section closes
  the last setup step that required hand-made API calls: truthful
  effective-rule display in the proxy's exact order, tier chain previews,
  atomic retargeting, shadowed-duplicate disclosure with one-click cleanup,
  degraded-target warnings, and a "cascade needs both bands usable" note.

### Changed

- Mutations are snapshot-scoped with automatic re-list reconciliation
  (latest-wins); a write whose verification failed says so plainly instead of
  asserting stale state.

## [0.4.0] — 2026-07-19

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.4.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.3.0...v0.4.0)

### Added

- **`model: auto` honors declared complexity** — OpenAI `reasoning_effort`,
  Anthropic `thinking` budgets, and `output_config.effort` feed Layer-1;
  undeclared requests classify byte-identically to before.
- **Decision telemetry on every evaluated request** (`structural_band` /
  `structural_score` / `structural_band_source`), including ambiguous and
  unroutable fall-throughs.
- **The Auto-performance view** — band mix over time, the four-way cascade
  outcome split, unroutable diagnostics, and an estimated net-savings figure
  with visible coverage (never a fabricated $0).
- **Rail-bounded self-calibration (opt-in per tenant)** — conservative,
  audited, revertible narrowing of the ambiguous band from your own outcomes;
  escalations record WHY (`quality_gate` vs `cheap_error`).
- **Request error detail** — failed requests record the terminal provider
  error (kind, status, sanitized message, provider request id) into the
  inspector.
- **Native-family price fallback** with explicit estimate provenance.

### Changed

- A sharper cascade quality gate: unparseable structured output escalates;
  length-truncation grades 0.5; tool-calling turns are exempt.

### Upgrade notes

- Migrations `0011`–`0014` run on boot. Six new `CALIBRATION_*` env keys
  (defaults safe; opt-in per tenant). Threshold envs are boot-validated to at
  most 4 decimal places.

## [0.3.0] — 2026-07-18

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.3.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.2.0...v0.3.0)

### Added

- **Subscription OAuth** — connect Claude Pro/Max and ChatGPT Plus/Pro with a
  guided wizard (PKCE exchange, encrypted token storage, rotation-safe
  auto-refresh, one-click Reauthorize); both presets verified live.
- **ChatGPT Responses protocol** (`openai_responses`) — streaming-only
  upstream served transparently to buffered clients; `store: false` on every
  call; reasoning items never persisted.
- **Provider-listed prices** captured at sync as display-only estimates with
  clear provenance (recorded cost stays on the bundled catalog).
- **Edit providers** from the dashboard with a write-only credential contract.
- OpenRouter app-attribution headers.

### Upgrade notes

- Migrations `0009`–`0010` run on boot. Rotating `PROVIDER_CREDENTIAL_KEY`
  invalidates stored credentials (OAuth providers ask to be reauthorized).
- Reusing flat-rate consumer subscriptions programmatically may violate
  provider ToS — pair them with a pay-per-token fallback (surfaced in the UI).

## [0.2.0] — 2026-07-17

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.2.0) ·
[Compare](https://github.com/izzoa/polyrouter/compare/v0.1.0...v0.2.0)

### Added

- **User administration** — atomic first-signup-wins bootstrap (registration
  then closes to invite-only), hash-stored single-use invites delivered by
  SMTP or copyable link, a Users admin page (promote/demote, disable/enable,
  delete, revoke invites, live registration toggle), and a sidebar account
  menu. Disabling a user cuts sessions AND their agent keys in one
  transaction; the last enabled admin is undeletable.
- **Hermes Agent harness** — paste-ready `~/.hermes/config.yaml` snippet on
  agent create/rotate.

### Upgrade notes

- Migration `0008` seeds existing instances to `invite_only` — public sign-up
  closes on upgrade until an admin reopens it.

## [0.1.0] — 2026-07-17

[Release](https://github.com/izzoa/polyrouter/releases/tag/v0.1.0)

First release: a self-hostable LLM router/gateway — one OpenAI- and
Anthropic-compatible endpoint with explicit-first routing and opt-in smart
layers (structural + cascade), commit-safe streaming fallbacks, golden-file-
tested cross-protocol translation, immutable per-request cost snapshots,
Redis-atomic budgets, async SMTP/Apprise notifications, a SolidJS dashboard
with a routing-decision inspector, encrypted credentials, HMAC agent keys,
SSRF-guarded egress, central tenant isolation, and single-container packaging
with Prometheus metrics + optional OpenTelemetry. AGPL-3.0-only.

[Unreleased]: https://github.com/izzoa/polyrouter/compare/v0.31.0...HEAD
[0.31.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.31.0
[0.30.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.30.0
[0.29.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.29.0
[0.28.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.28.1
[0.28.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.28.0
[0.27.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.27.1
[0.27.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.27.0
[0.26.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.26.0
[0.25.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.25.0
[0.24.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.24.0
[0.23.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.23.0
[0.22.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.22.1
[0.22.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.22.0
[0.21.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.21.0
[0.20.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.20.0
[0.19.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.19.0
[0.18.2]: https://github.com/izzoa/polyrouter/releases/tag/v0.18.2
[0.18.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.18.1
[0.18.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.18.0
[0.17.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.17.0
[0.16.5]: https://github.com/izzoa/polyrouter/releases/tag/v0.16.5
[0.16.4]: https://github.com/izzoa/polyrouter/releases/tag/v0.16.4
[0.16.3]: https://github.com/izzoa/polyrouter/releases/tag/v0.16.3
[0.16.2]: https://github.com/izzoa/polyrouter/releases/tag/v0.16.2
[0.16.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.16.1
[0.16.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.16.0
[0.15.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.15.1
[0.15.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.15.0
[0.14.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.14.0
[0.13.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.13.1
[0.13.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.13.0
[0.12.3]: https://github.com/izzoa/polyrouter/releases/tag/v0.12.3
[0.12.2]: https://github.com/izzoa/polyrouter/releases/tag/v0.12.2
[0.12.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.12.1
[0.12.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.12.0
[0.11.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.11.0
[0.10.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.10.0
[0.9.3]: https://github.com/izzoa/polyrouter/releases/tag/v0.9.3
[0.9.2]: https://github.com/izzoa/polyrouter/releases/tag/v0.9.2
[0.9.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.9.1
[0.9.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.9.0
[0.8.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.8.1
[0.8.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.8.0
[0.7.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.7.0
[0.6.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.6.0
[0.5.1]: https://github.com/izzoa/polyrouter/releases/tag/v0.5.1
[0.5.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.5.0
[0.4.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.4.0
[0.3.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.3.0
[0.2.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.2.0
[0.1.0]: https://github.com/izzoa/polyrouter/releases/tag/v0.1.0