Skip to content

Releases: izzoa/polyrouter

v0.12.0 — account-switch data fixes, a Requests page that stays true, and a dashboard that works on a phone

Choose a tag to compare

@izzoa izzoa released this 07 Aug 05:04

Highlights

  • Fixed: another account's data could stay on screen after switching accounts. If more
    than one person signs in through the same browser, this is the reason to upgrade. Signing
    out and back in as a different user could leave the previous account's request rows, its
    spend totals and cost breakdowns, and — the severe half — its captured prompt and
    response text
    in the inspector, the one category of content the system otherwise refuses
    to store at all. A response begun under the previous account could also still commit under
    the next. 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 held onto locally, so loading the new build fixes it completely. The
    guard now lives in the shared slice runner and at the identity boundary itself, so every
    current loader is covered at once and every future one by default — a per-loader fix has
    to be remembered each time, which is how the gap opened.
  • The Requests page stops going quietly stale. It froze its time window at load and
    nothing ever triggered another read, so it silently stopped being true the moment you
    opened it — and the new live band above it made that worse, reading as "this page is
    current" while the list below was not. Page 1 now refreshes on the same floored cadence
    the Overview uses, through the same shared budget, so a burst of settling requests cannot
    become a query storm. Once you have clicked "Load more" the list is left alone and a
    "N new" pill appears instead — discarding pages you explicitly asked for in order to
    show fresher rows is the worse trade. A probe that fails says so rather than reading as
    "nothing new".
  • A finished request stops claiming to be running. A settled request lingers for a few
    seconds while its durable row is written, and the whole time it said Running with a
    pulsing dot. It now says Finishing, without the live indicator. This was a defect on
    the Overview card too, masked there by a handoff that usually completes quickly.
  • Live in-flight rows on the Requests page. The band of currently-running requests that
    the Overview has always had now appears above the request list as well, with the same
    behaviour and the same degraded-path polling when the event stream is unavailable.
  • The Overview breakdown panel switches between spend and tokens. The by-model,
    by-provider and by-agent breakdown can now be read in tokens instead of dollars, which is
    the figure that actually explains a cost — a model that is cheap per token can still
    dominate a bill on volume. GET /api/analytics/breakdown gains an optional metric
    parameter defaulting to spend, so existing callers are unaffected.
  • The dashboard works on a phone. Every page reflows at phone widths rather than
    demanding a horizontal scroll; overlays and dialogs are reachable sheets that account for
    the on-screen keyboard and the browser's own chrome instead of being pushed off-screen by
    it; and routing rules can be reordered by touch, which previously required a mouse.

Upgrade notes

  • No migrations and no schema change — a drop-in upgrade from 0.11.0.
  • Two token figures deliberately change value. 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. Existing callers see no change; its rows now additionally carry the four token
    components and an estimatedTokens figure.
  • Nothing to configure. The account-switch fixes, the Requests-page freshness behaviour
    and the responsive layout all take effect on the new build with no setting to change.

Full package changelogs live in packages/*/CHANGELOG.md.

v0.11.0 — branded notification emails that link back into the dashboard

Choose a tag to compare

@izzoa izzoa released this 06 Aug 03:02

Highlights

  • Notification emails are branded, and they link back into the dashboard. Every message
    now ships as multipart/alternative: a text-only client sees exactly the wording it saw
    before, and an HTML client sees a laid-out message carrying the event plus a link straight
    to the page that explains it — a provider alert opens Providers, a budget alert opens
    Limits, a spend summary opens Costs. Invite and password-reset emails share the same
    layout.
  • That layout is deliberately asset-free. A text wordmark; no images, no web fonts,
    nothing hosted anywhere else. A self-hosted instance that isn't publicly reachable renders
    the mail identically to one that is, and opening it triggers no remote fetches — which
    also means no tracking pixel, by construction rather than by promise.
  • Chat channels carry severity. An Apprise target now receives a per-event severity, so
    a provider-down or budget-block notification is visually distinct from an informational
    summary instead of arriving as undifferentiated text, with the page link on its own line.
  • Dashboard pages are addressable by URL. Each page has a #/<page> fragment, so pages
    are bookmarkable, the browser's Back and Forward buttons move along the page axis, and a
    link from outside the product can open a specific page. An unrecognized fragment falls
    back to the default page as before. Authorization is now enforced on the route itself:
    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. The accept-invite link
    flow is untouched; its token fragment is never parsed as a page nor written to history.
  • Auto performance names the agent whose L1 signal has gone flat. When a stable agent's
    structural score collapses to a near-constant — one two-decimal score bucket covering
    ≥ 50% of its ambiguous-band requests — the Routing page now names the agent, its modal
    score, and the share, with guidance that fits the instance: pin the agent to a tier, or
    enable and configure L2 · Semantic, the layer that evaluates exactly that ambiguous slice.
  • Insufficient evidence never reads as healthy. Below a 50-request floor no verdict is
    rendered at all, and a neutral coverage line discloses how many agents went unassessed —
    so a quiet instance looks unassessed rather than fine. GET /api/analytics/auto gains a
    per-agent signalQuality block. This is read-time aggregation only: no routing behaviour,
    hot-path, or schema change.

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
    back to the dashboard. Set it to the address your users actually reach the dashboard at,
    then restart. On the default (http://localhost:3001), or on any loopback value, links
    are omitted deliberately rather than sending a 127.0.0.1 URL that would be dead in a
    recipient's inbox — or worse, on a phone, resolve to the phone. A LAN or .local address
    works and is often the right one for a self-hosted instance. The value is read at boot, so
    a restart is required after changing it.
  • Nothing to do for the other two. URL-addressable pages and the Auto-performance signal
    card need no configuration and change no stored data.

Full package changelogs live in packages/*/CHANGELOG.md.

v0.10.0 — subscription usage stops counting as money spent

Choose a tag to compare

@izzoa izzoa released this 29 Jul 02:25

Highlights

  • Subscription usage is no longer counted as money spent. A request served by a Claude
    Pro/Max provider was priced at Anthropic's API list rate and recorded as ordinary cost —
    but that traffic is already paid for by a flat monthly fee, so its marginal cost is zero.
    Three things followed: the Costs page drew it as "paid", every spend figure overstated what
    you owed, and — the one that could actually bite — a block budget could refuse requests
    once that notional value crossed its threshold, with nothing saying so. ChatGPT Plus/Pro
    traffic is usually unaffected: chatgpt.com is not in the pricing catalog's host map, so
    those requests record no cost at all.
  • The number is kept, not discarded. Each ledger row now snapshots the kind of provider
    that served it, and spend splits into what you owe versus what your subscription served. The
    Costs headline reports money owed and says it excludes subscription; the subscription figure
    sits beside it as "served on subscription", so the previous combined total is still
    reconstructable — and simply absent when a range has none. That figure is exactly what tells
    you whether a subscription is paying for itself.
  • The split reaches every figure, not just the headline. The timeseries and the
    model/provider/agent breakdowns exclude it too, so the chart can't disagree with the number
    above it. The distribution bar gains a fourth segment for prepaid traffic — a second
    intensity of the existing accent, adjacent to the paid one, so it still reads as one block
    subdivided — and every category now shows its count next to its percentage.
  • Budgets declare what they count. A budget carries a metering basis: money spent, or
    money spent plus subscription usage priced at API rates. Metering notional value is a crude
    proxy for a flat-rate plan's capacity, but it is the only usage throttle polyrouter has, so
    it stays available as an explicit choice in the Limits form rather than being removed.
    Budget alerts state which basis they metered.
  • Scrolling the sidebar no longer drags the whole dashboard off the top of the window, and
    its lower items — the account menu included — stay reachable on a short viewport.

Upgrade notes

  • A migration runs on boot. It is additive: two nullable columns on the cost ledgers with
    no backfill, and a metering-basis column on budgets.
  • Budget enforcement does not change. Every existing budget is backfilled to the
    notional basis and meters exactly what it metered before. Switching a budget to count only
    money spent is opt-in, per budget, in the Limits form. New budgets default to money spent.
  • Your Spend figure will drop if you use a subscription provider — it now reports money
    owed. The subscription value is shown beside it, not lost.
  • Historical requests cannot be classified. Rows recorded before this release have no
    provider-kind snapshot, and inferring one from today's providers is exactly the rewrite the
    snapshot exists to prevent. They keep counting toward spend and are reported as their own
    unclassified component. Expect a visible step in historical figures at the upgrade boundary;
    it ages out of the range naturally.
  • No new environment variables, no config changes.

Full package changelogs live in packages/*/CHANGELOG.md.

v0.9.3 — tier-chain reordering that sticks, and a keyboard path for it

Choose a tag to compare

@izzoa izzoa released this 26 Jul 12:29

Highlights

  • Dragging models to reorder a tier chain now lands where you dropped it. The chain is
    the fallback policy — position 0 is the primary every auto/tier-routed request hits first
    — so a reorder that silently reverted left you believing you had configured one routing
    policy while the proxy ran another. Two independent defects caused it. Drag state was keyed
    by a list index that was re-read after the reorder had already been applied, so it tracked
    the drop target rather than the row you grabbed; for an adjacent swap that became a stable
    oscillator, flipping the order back and forth at the browser's drag-event rate with the
    pointer held perfectly still, and which order survived depended on where that oscillation
    happened to be when you released. Separately, a chain write's response or a routing refresh
    arriving mid-drag repainted the whole chain from server state and threw the reorder away —
    reachable by dragging within a round-trip of any other chain edit, or simply by leaving the
    Routing page and coming back while a refresh was still in flight.
  • Reordering no longer flickers. A reorder now commits only once the pointer crosses the
    target row's midpoint, so jitter on a row boundary stops re-triggering it, and the row hover
    highlight no longer chases rows as they move under a stationary pointer.
  • The drop is properly accepted. It was resolving as a cancelled drag, which made the
    browser animate the row snapping back to where it started — reading as "it didn't take" even
    when the order had persisted. dragstart also sets drag data now, which browsers require in
    order to begin an HTML5 drag at all.
  • New: reorder a chain from the keyboard. The ⋮⋮ handle is a real button — focus it and
    press Alt+ / Alt+ to move an entry, with focus
    following the row and the new position announced to screen readers. Previously Make
    primary
    was the only keyboard route and could only reach position 0, so there was no way to
    order one fallback against another without a mouse.
  • 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 — with nothing in the logs to explain
    why. Unset behaviour is unchanged.

Upgrade notes

  • No migration, no schema change, no new environment variable. The reorder work is
    frontend-only and the persisted contract is unchanged (an ordered modelIds replace,
    ≤ 5 models, position 0 = primary).
  • If you were setting any CALIBRATION_* or EVENTS_* variable in .env and quietly getting
    defaults, those values take effect on your next docker compose up. Review them before
    restarting if you had tuned around the defaults. SEMANTIC_* is deliberately unaffected —
    those belong to docker-compose.semantic.yml and the -semantic image.
  • Hardening that ships alongside: the tier writer is now identity-generation tagged, so a chain
    write or tier deletion that completes after a sign-out can no longer write into the next
    session's state or surface the previous session's error.

Full package changelogs live in packages/*/CHANGELOG.md.

v0.9.2

Choose a tag to compare

@izzoa izzoa released this 25 Jul 04:34

A release-infrastructure release: no functional change to polyrouter itself. It exists because
publishing a release had become unreliable, and because the fix deserved to be proven on a
version with nothing at stake.

Highlights

Release images are built on native runners per architecture. The linux/arm64 half of
every image used to be cross-built under QEMU emulation, which is a nondeterministic-failure
generator — and it broke two of the last three releases. On v0.9.1 it crashed with
qemu: uncaught target signal 4 (Illegal instruction) - core dumped 68 seconds into npm ci
and then wedged: the build never failed, it emitted progress heartbeats for another 88
minutes until the job timeout killed it. A retry on byte-identical inputs passed with zero
crashes.

That is how v0.9.1 shipped half a release — the -semantic variant published while the
baseline image did not, leaving latest serving 0.9.0 for about two hours.

Each architecture now builds on its own native runner, pushes an untagged digest, and a merge
job assembles the manifest list. Tagging happens once, at the end, so no architecture can
clobber another's latest, and -semantic is now ordered strictly after the baseline — the
v0.9.1 failure mode is structurally impossible rather than unlikely.

Measured, all caches cold: the arm64 leg that wedged for 88 minutes finishes in 1m45s,
and the entire release — both variants, four builds, two merges — takes 3m40s, against
20m42s on the best previous emulated run. The -semantic arm64 leg is now faster than its
amd64 sibling doing identical work.

The -semantic smoke test got stricter for free. It loads the baked model and runs a real
ORT inference on both arches before publishing. Under emulation the arm64 pass was validating
QEMU's softfloat; it now exercises ORT's actual arm64 kernel dispatch on real hardware.

Fixed

  • org.opencontainers.image.licenses annotations reported the deprecated AGPL-3.0 while
    the matching label correctly said AGPL-3.0-only. A custom label does not propagate to
    annotations in docker/metadata-action; both now agree.

What did not change

The published contract is identical: same tags, same two platforms (linux/amd64 +
linux/arm64), same plain manifest list with no attestation entries, same labels and index
annotations. Image contents are unchanged from 0.9.1 apart from the licence annotation — no
application code was touched in this release.

Upgrade notes

Nothing to do. This changes how images are built, not what they contain.

docker compose pull && docker compose up -d

Pinning a digest instead of a tag? 0.9.2 is a fresh build, so its digest differs from
0.9.1 even though the application code is the same.

v0.9.1 — legible green status text

Choose a tag to compare

@izzoa izzoa released this 24 Jul 23:01

Highlights

  • Small green status text is legible again in light mode. Labels like "OK · served",
    the Topbar Live chip, provider status, free-price and free-spend markers, invite
    "accepted" and budget "enabled" shared the same green as the status dots — 2.7:1
    against white, below the WCAG AA 4.5:1 floor for text. They now use a darkened green
    (5.4:1 on panels, 4.8:1 on the green chip background). Dots, bars and chip fills keep
    the original colour, so the only visible change is that the text is readable. Dark mode
    already passed at 8.1:1 and is untouched.

Also in this release: the self-host installer's idempotency test passes again (its stub
had not kept up with the installer's safer download-then-extract behaviour), and CI now
documents that the skip-ci token is honoured in commit bodies as well as subjects — the
cause of two missed CI runs around v0.9.0. Both are repo hygiene with no runtime effect,
but they are why v0.9.0's tag build could not go green and this one does.

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.
  • Everything in the v0.9.0 notes
    still applies if you are coming from 0.8.x — in particular, do not buffer
    /api/events
    if you front polyrouter with a reverse proxy.

Full package changelogs live in packages/*/CHANGELOG.md.

v0.9.0 — live dashboard updates, in-progress requests, and a quieter poller

Choose a tag to compare

@izzoa izzoa released this 24 Jul 22:09

Highlights

  • The dashboard is live now, not polling-with-extra-steps. A single owner-scoped
    SSE endpoint streams in-flight presence and analytics-staleness nudges, so a request
    shows up the moment 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 core: if the
    stream can't be established, is refused, drops, or is buffered by a proxy, the
    dashboard falls back to its normal refresh and says Polling instead of Live
    so a blocked deployment is visible rather than silently frozen.
  • In-progress requests appear on the Overview. The "Recent requests" card now shows
    what's 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.
  • Per-page icons. Each nav page has a line-icon in the left rail, and the same icon
    carries into the page header when you select it — greyscale, following the
    single-accent theme.
  • A much quieter dashboard. It stops polling entirely while its tab is hidden and
    refreshes immediately on return; an idle, visible Overview drops from ~40 to ~28
    requests/min, and with a connected stream an idle instance costs essentially nothing.
  • Push can never cost more than the polling it supplements. Nudges are coalesced
    server-side and share one refresh budget with the analytics poll, so a burst of
    thousands of settled requests cannot turn into a query storm.
  • Fixed: live rows can no longer be corrupted by overlapping or cross-account
    responses.
    Pollers are single-flight, so a slow response can't be applied out of
    order and falsely mark a running request as finished; and live-view state is 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.
    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 that path (nginx: proxy_buffering off;). If the stream is blocked
    nothing breaks — the dashboard shows Polling and keeps refreshing.
  • New optional env, all defaulted: EVENTS_ENABLED (set false to disable the
    stream entirely and stay on polling) and EVENTS_HEARTBEAT_MS, plus reconciliation,
    per-owner-cap, queue-bound and coalesce-window knobs. Boot fails fast on an
    out-of-range combination rather than silently loosening a bound.
  • Still one app replica only — the event stream fans out in-process; multi-instance
    fanout is a documented graduation, not a supported topology.

Full package changelogs live in packages/*/CHANGELOG.md.

v0.8.1 — provider-listed price fallback, x-polyrouter-tier value in the inspector, dated timestamps

Choose a tag to compare

@izzoa izzoa released this 22 Jul 17:47

Highlights

  • Recorded cost falls back to a provider's listed price. A model the LiteLLM catalog (exact + native-family) doesn't cover used to record unpriced, even when its provider — e.g. OpenRouter — reported a per-token price at sync time. It now records that captured listed price instead. LiteLLM always wins: the listed price is a last-resort fallback, never overrides the catalog, is snapshotted immutably, and is clearly marked as an estimate everywhere (provider-listed · estimate, · est., the request-table ~, budget-alert provenance, and the weekly-summary caveat) — never presented as an authoritative cost. A 0/0 listed price that isn't asserted free (token rates zero but a per-request/image charge) stays unpriced rather than showing a false "$0 free".
  • The x-polyrouter-tier value shows in the request inspector. A request routed by a tier-header remap rule (e.g. x-polyrouter-tier: shopping) now renders x-polyrouter-tier: shopping in the DECISION section instead of the header name alone. Rules on other headers still record the name only (a configured value there could be a credential).
  • The request inspector header carries the date. Timestamps now show date and time (e.g. 2026-07-22 10:01:58), unambiguous across days.

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 (see the first highlight). Historical rows are unchanged — cost is immutable. Run a pricing refresh to prefer authoritative LiteLLM rates where available.

Full package changelogs live in packages/*/CHANGELOG.md.

v0.8.0 — Layer-2 semantic routing, learning loop, dashboard + -semantic image

Choose a tag to compare

@izzoa izzoa released this 22 Jul 01:41

Highlights

  • Layer-2 semantic routing (opt-in, flag-gated). A local ONNX embedding classifier resolves what the structural layer (L1) finds ambiguous: the request is embedded (~5–20 ms, CPU) and scored against bundled anchor centroids, and a confident high/low verdict routes via the existing auto_high/auto_low targets. Anything ambiguous or faulted degrades to cascade/default unchanged — the smart path never fails a request.
  • Per-tenant learning loop. Learned centroids track each tenant's own cascade outcomes (opt-in, default OFF). Evidence accumulates only as ≥ cohort-size aggregates in Redis — never a single raw embedding — and a daily sweep folds it under strict rails (capped EMA, spherical drift clamp toward the bundled anchors, cooldown, revision match), crash-atomically across Redis + Postgres. Learned supersedes bundled only behind read-time gates; any fault falls back to bundled. One-click Revert to bundled.
  • Dashboard surfaces. The old permanently-locked "L2 · Semantic" stub is now a real driven toggle with honest available/unavailable copy; a learning card shows the toggle, fresh-sample/source/last-applied status, the numeric audit history, and a confirmed revert; Auto-performance gains the semantic slice (evaluated, routed-per-band, four-way outcome split, bundled/learned source) with residual-cascade labeling; the request inspector carries a semantic_source chip.
  • Batteries-included -semantic image. ghcr.io/izzoa/polyrouter:0.8.0-semantic ships the ONNX runtime and the reference all-MiniLM-L6-v2 model (Apache-2.0) pre-baked with SEMANTIC_MODEL_PATH preset — L2 is available on first boot, nothing fetched at runtime. The baseline image stays ORT- and model-free.
  • max_tokens fix. Local / legacy OpenAI-compatible endpoints that accept only max_tokens no longer lose the caller's output-token cap.

Upgrade notes

  • Migrations run on boot (0020 semantic telemetry columns; 0022 the semantic_learning_event table). Back up the database first, as always.
  • Semantic routing is off by default; the baseline image is unchanged. To enable it, either run the 0.8.0-semantic image (ORT + model pre-baked) or install onnxruntime-node@1.27.0 and set SEMANTIC_MODEL_PATH to a model bundle. Add semantic to ROUTING_AUTO_LAYERS to expose the capability; tenants opt in per-account. New SEMANTIC_* / SEMANTIC_LEARNING_* knobs are all defaulted (learning stays off until a tenant enables it). No prompt text or embedding vector is ever logged or persisted.
  • local providers now emit max_tokens (not max_completion_tokens). Override per provider via the new max-tokens field if your gateway expects the other spelling.

Full package changelogs live in packages/*/CHANGELOG.md.

v0.7.0 — long-running research models survive end-to-end

Choose a tag to compare

@izzoa izzoa released this 21 Jul 09:58

Highlights

  • Research-class models (o3-deep-research and kin) now work end-to-end. The hidden undici 300s headers/body ceiling is gone — the SSRF-guarded dispatcher's timeouts are now derived above polyrouter's own typed bounds, so raising a timeout knob actually holds, and timeouts stay typed and correctly breaker-classified instead of surfacing as opaque socket errors.
  • Per-provider patience overrides — set first-response and between-chunks timeouts (1s–1h) on a single provider from the provider form's Advanced section. Blank fields inherit the instance defaults (shown honestly from the server via GET /api/providers/timeout-defaults); overrides resolve per fallback-chain attempt, so a raised research provider never slackens hang detection for anyone else in the chain.
  • Keepalives count as liveness — upstream bytes (OpenRouter's : OPENROUTER PROCESSING comments included) re-arm the streaming stall watchdog and renew the circuit breaker's half-open probe lease, so a streamed deep-research call with long silent thinking gaps is no longer aborted as stalled. True silence still fails typed at exactly the configured bound.
  • The ChatGPT/Responses buffered path gained its own byte-re-armed idle guard — it previously had no typed post-headers bound at all.
  • Body-capture card buttons fixed — "Purge all bodies" no longer renders as bare oversized text; the consent/disable dialogs follow the established primary/cancel/ghost idioms.

Upgrade notes

  • No migrations in this release; drop-in upgrade.
  • Long-call operator guidance: raise the slow provider's patience in its Advanced section, prefer streaming, and size your client SDK's own timeout — that one bound the router cannot lift.
  • Per-provider timeout fields are optional and null by default; existing providers keep inheriting the instance defaults until you set them.

Full package changelogs live in packages/*/CHANGELOG.md.