mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-19 05:47:48 +00:00
docs: document admin statistics, new endpoints and the redesigned dashboard
The README covers the admin.stats switches and the Redis cost per request with and without them. It lists the always-on counters, key names and TTLs, and every new or changed admin endpoint with its limits and config redaction rules. It describes the seven dashboard pages, the filter bar and the hash schema. It notes that a failed failover candidate no longer counts toward a model's requests. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
@@ -568,6 +568,14 @@ gates only the background probe sweep.
|
|||||||
| Field | Type | Default | Semantics |
|
| Field | Type | Default | Semantics |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `enabled` | `bool` | `false` | `false`: the `/admin*` routes are not registered at all — a request to any of them falls through to the plugin's existing 404/`passthroughUnknown` handling like any other unrecognized path. `true`: the read-only dashboard per [Admin](#admin) below. |
|
| `enabled` | `bool` | `false` | `false`: the `/admin*` routes are not registered at all — a request to any of them falls through to the plugin's existing 404/`passthroughUnknown` handling like any other unrecognized path. `true`: the read-only dashboard per [Admin](#admin) below. |
|
||||||
|
| `stats` | `*AdminStatsConfig` | `nil` | Opts into two counter families that are NOT part of the always-on-with-admin set — see [Statistics: always-on vs opt-in](#statistics-always-on-vs-opt-in). `AdminStatsConfig` has no `enabled` field of its own; the block is either present (with each family opted in individually below) or omitted entirely. Omitted (`nil`, the zero value — not an empty `{}` block): both opt-in families stay off, byte-identical to a deployment predating this feature. |
|
||||||
|
|
||||||
|
### `AdminStatsConfig` (`admin.stats`)
|
||||||
|
|
||||||
|
| Field | Type | Default | Semantics |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `userModel` | `bool` | `false` | Per-(user, model) usage breakdown (the `umodel` counter family). Cardinality grows with users × models actually used, unlike every always-on family — an operator opts in explicitly. Gates `GET /admin/api/usage/totals?kind=usermodel\|modeluser` (`404` when off) and the Spend page's attribution drill-down past group→user. |
|
||||||
|
| `latency` | `bool` | `false` | Per-bucket upstream latency histograms (the `ld00`..`ld13`/`lt00`..`lt13` counter families), riding the existing per-request write batch. Gates the percentile fields on `GET /admin/api/performance` (present only when on; `attempts`/`failures` are always present regardless). |
|
||||||
|
|
||||||
### `MetricsConfig`
|
### `MetricsConfig`
|
||||||
|
|
||||||
@@ -1277,7 +1285,19 @@ never a second candidate to try.
|
|||||||
request against `requestsPerMinute`/`requestsPerDay` — never once per
|
request against `requestsPerMinute`/`requestsPerDay` — never once per
|
||||||
attempt. Per-provider attempt/failure counters (the admin dashboard's
|
attempt. Per-provider attempt/failure counters (the admin dashboard's
|
||||||
success-rate badge) attribute every attempt to the provider that
|
success-rate badge) attribute every attempt to the provider that
|
||||||
actually made it.
|
actually made it. **Fixed (admin dashboard redesign):** the per-model
|
||||||
|
request counter (`GET /admin/api/usage/models`, `/usage/series?scope=
|
||||||
|
model:...`) used to increment for EVERY candidate a failover chain ran,
|
||||||
|
including one that failed before writing anything — a 3-candidate chain
|
||||||
|
where only the last succeeded used to inflate that model's served-request
|
||||||
|
count by 2 phantom attempts. It now only counts a candidate that either
|
||||||
|
actually served something (a genuine response, even a zero-usage
|
||||||
|
streaming one) or reported nonzero usage (a mid-stream failure that
|
||||||
|
still delivered content) — a candidate that failed with nothing to show
|
||||||
|
for it no longer touches the model counter at all (`routes_unified.go`).
|
||||||
|
Token/cost counters were never affected by this defect: they were
|
||||||
|
already gated on nonzero usage independently. A failed candidate is
|
||||||
|
still visible on its own terms — via `fover`/`attempt`/`fail`, below.
|
||||||
- **Caching**: a cacheable response produced by a failover provider is
|
- **Caching**: a cacheable response produced by a failover provider is
|
||||||
stored under that provider's own cache key, never the first candidate's.
|
stored under that provider's own cache key, never the first candidate's.
|
||||||
Each candidate's own cache entry is checked immediately before that
|
Each candidate's own cache entry is checked immediately before that
|
||||||
@@ -1528,8 +1548,9 @@ one.
|
|||||||
discovery health independently — a latency optimization, not a
|
discovery health independently — a latency optimization, not a
|
||||||
correctness guarantee. Three replicas disagreeing briefly after a
|
correctness guarantee. Three replicas disagreeing briefly after a
|
||||||
provider starts failing is expected, not a bug.
|
provider starts failing is expected, not a bug.
|
||||||
- **Dashboard**: the Providers tab shows a provider's current state and,
|
- **Dashboard**: the Models page's provider health panel shows a
|
||||||
while open, when it will next be probed — see [Admin](#admin).
|
provider's current state and, while open, when it will next be
|
||||||
|
probed — see [Admin](#admin).
|
||||||
|
|
||||||
## Unified vs. passthrough
|
## Unified vs. passthrough
|
||||||
|
|
||||||
@@ -1817,17 +1838,36 @@ or in CI.
|
|||||||
are all incremented together on one rejection, so the `total` row
|
are all incremented together on one rejection, so the `total` row
|
||||||
answers "how many rejections today, across everyone" directly),
|
answers "how many rejections today, across everyone" directly),
|
||||||
`tokensInPerDay`/`tokensOutPerDay`, `tokensInPerMonth`/
|
`tokensInPerDay`/`tokensOutPerDay`, `tokensInPerMonth`/
|
||||||
`tokensOutPerMonth`, `costPerDayMicroUsd`, `costPerMonthMicroUsd` —
|
`tokensOutPerMonth`, `costPerDayMicroUsd`, `costPerMonthMicroUsd`,
|
||||||
|
`lastSeen` (unix seconds of this scope's most recent admitted request;
|
||||||
|
`0` when never seen or unknown — see [Statistics: always-on vs
|
||||||
|
opt-in](#statistics-always-on-vs-opt-in) for the write side) —
|
||||||
alongside their configured limits, plus one extra `total` row: the
|
alongside their configured limits, plus one extra `total` row: the
|
||||||
synthetic all-traffic scope (see [Limits and accounting](
|
synthetic all-traffic scope (see [Limits and accounting](
|
||||||
#limits-and-accounting)), limits always `null`. A group row also carries
|
#limits-and-accounting)), limits always `null`. A group row also carries
|
||||||
its configured `providers`/`models`/`mcpServers`/`agents` access lists,
|
its configured `providers`/`models`/`mcpServers`/`agents` access lists,
|
||||||
omitted when unrestricted. The dashboard's Providers and Usage views
|
omitted when unrestricted. `overview` also carries `features`
|
||||||
poll both every 5 seconds while open.
|
(`{"userModelStats","latencyStats","cacheStats","lastSeen","failover"}`,
|
||||||
|
every value a `bool` — which of the new statistics families this
|
||||||
|
Gateway currently exposes; `GET /admin/api/config` and every
|
||||||
|
`stats_read.go` endpoint's own feature-gated `404` echo the identical
|
||||||
|
struct, so the dashboard gates a whole page section on one flag without
|
||||||
|
guessing at a second endpoint's availability) and `pricing`
|
||||||
|
(`{"override","builtin","litellm","free","unpriced"}`, every value an
|
||||||
|
`int` — the live model catalog's row count by billing price source,
|
||||||
|
summing to the catalog's total model count; a one-glance "how much of
|
||||||
|
my fleet is actually priced" figure for the dashboard's Home page,
|
||||||
|
without a second round trip to `GET /admin/api/catalog`). Polled every
|
||||||
|
5 seconds by the dashboard's shared background store (`stores/
|
||||||
|
dashboard.ts`) for as long as the app is open — a single poll behind
|
||||||
|
every page, not scoped to whichever one is showing (the redesign
|
||||||
|
renamed the old flat "Providers"/"Usage" tabs — see
|
||||||
|
[Dashboard](#dashboard)).
|
||||||
- **`GET /admin/api/usage/history`** returns a bucketed series for one
|
- **`GET /admin/api/usage/history`** returns a bucketed series for one
|
||||||
scope/metric/window — the data source for the dashboard's Charts view
|
scope/metric/window. Predates the admin dashboard redesign; kept for
|
||||||
(per-user/per-group/per-model/total, stacked tokens-in/tokens-out, with
|
API stability, but the dashboard itself no longer calls it — `GET
|
||||||
a 24h/30d/12mo window switcher). Query parameters:
|
/admin/api/usage/series` (below) superseded it as the redesigned
|
||||||
|
dashboard's own multi-scope time-series source. Query parameters:
|
||||||
- `scope`: `user:{id}`, `group:{id}`, `model:{provider}/{model}`, or the
|
- `scope`: `user:{id}`, `group:{id}`, `model:{provider}/{model}`, or the
|
||||||
literal `total`.
|
literal `total`.
|
||||||
- `metric`: `req`, `tokin`, `tokout`, or `cost`.
|
- `metric`: `req`, `tokin`, `tokout`, or `cost`.
|
||||||
@@ -1843,11 +1883,9 @@ or in CI.
|
|||||||
being unreachable is `503` (a chart must never read an outage as "zero
|
being unreachable is `503` (a chart must never read an outage as "zero
|
||||||
usage"). Response shape:
|
usage"). Response shape:
|
||||||
`{"scope","metric","window","points":[{"bucket":"2026082114","value":123},...]}`.
|
`{"scope","metric","window","points":[{"bucket":"2026082114","value":123},...]}`.
|
||||||
The Charts view fetches this once per selection change, plus a 30s
|
|
||||||
auto-refresh of the current selection.
|
|
||||||
- **`GET /admin/api/usage/models`** ranks models by usage — the data
|
- **`GET /admin/api/usage/models`** ranks models by usage — the data
|
||||||
source for the Charts view's "Models" tab, and for the model entries in
|
source for the Spend page's model breakdown and the Models page's
|
||||||
its scope picker. Query parameters:
|
catalog table. Query parameters:
|
||||||
- `metric`: `req`, `tokin`, `tokout`, or `cost`.
|
- `metric`: `req`, `tokin`, `tokout`, or `cost`.
|
||||||
- `window`: `hour`, `day`, or `month`.
|
- `window`: `hour`, `day`, or `month`.
|
||||||
- `limit` (optional): 1-100, default 20.
|
- `limit` (optional): 1-100, default 20.
|
||||||
@@ -1865,22 +1903,16 @@ or in CI.
|
|||||||
like `provider/model`) never pays a catalog-sized read for ids the
|
like `provider/model`) never pays a catalog-sized read for ids the
|
||||||
prefix already excludes, and `limit` caps the FILTERED set's own
|
prefix already excludes, and `limit` caps the FILTERED set's own
|
||||||
top-N rather than silently returning fewer than `limit` matches. Up
|
top-N rather than silently returning fewer than `limit` matches. Up
|
||||||
to 256 bytes; longer is `400`. Empty (the default) ranks the whole
|
to 256 bytes; longer is `400`. Empty (the default, and what the
|
||||||
catalog, this endpoint's original, pre-`prefix` behavior. A prefix
|
redesigned dashboard always sends — it filters client-side instead,
|
||||||
that matches nothing is not an error, just an empty `models` array —
|
below) ranks the whole catalog. A prefix that matches nothing is not
|
||||||
the identical shape an ordinary, unfiltered ranking already answers
|
an error, just an empty `models` array. The Models page's own search
|
||||||
when nothing was used yet. The dashboard's Models tab sends this
|
box (`q`) and provider narrowing instead filter the already-fetched,
|
||||||
parameter on the ranking fetch itself whenever a provider filter is
|
unfiltered response client-side (`lib/model-filter.ts`, joined
|
||||||
active (a provider's own header link on the Providers tab, or a
|
against `GET /admin/api/catalog` and `GET /admin/api/performance` in
|
||||||
`#charts?tab=models&filter=...` hash — either a link the dashboard
|
`ModelCatalogTable.vue`) — a harmless server round trip either way at
|
||||||
built itself, or one hand-edited to add `filter=`) — narrowing
|
the fleet sizes this plugin targets, and simpler than keeping a
|
||||||
server-side BEFORE `limit` applies, so a fleet-wide top-N ranking can
|
second server-side query in sync with the global scope filter.
|
||||||
no longer omit, or only partially show, one provider's own models.
|
|
||||||
The scope picker's own
|
|
||||||
model list (a separate, unfiltered fetch) never sends it. A
|
|
||||||
client-side re-filter (`lib/model-filter.ts`) still runs afterward too
|
|
||||||
— a harmless no-op once the server has already narrowed the set, kept
|
|
||||||
as the one guard against a response that predates this parameter.
|
|
||||||
- `detail` (optional): `1` or `true` adds `requests`, `tokensIn`,
|
- `detail` (optional): `1` or `true` adds `requests`, `tokensIn`,
|
||||||
`tokensOut`, `costMicroUsd` and `free` to every entry, and includes a
|
`tokensOut`, `costMicroUsd` and `free` to every entry, and includes a
|
||||||
model when ANY of those four totals is non-zero, not only the ranked
|
model when ANY of those four totals is non-zero, not only the ranked
|
||||||
@@ -1890,20 +1922,46 @@ or in CI.
|
|||||||
entry marks the model free, or when its resolved price is known and
|
entry marks the model free, or when its resolved price is known and
|
||||||
both per-1M prices are `0`. Empty, `0` or `false` (the default) keeps
|
both per-1M prices are `0`. Empty, `0` or `false` (the default) keeps
|
||||||
the response exactly as described below; any other value is `400`.
|
the response exactly as described below; any other value is `400`.
|
||||||
The dashboard's Models tab always sends `detail=1`, ranks by requests
|
Detail mode (admin dashboard redesign) also adds `priceSource`
|
||||||
by default, and lists every column in a table under the chart, with
|
(`billingPriceSource`'s own `"override"`/`"builtin"`/`"litellm"`/
|
||||||
a "free" badge instead of `$0`.
|
`"free"`/`"unpriced"` classification — the same vocabulary `GET
|
||||||
|
/admin/api/catalog`'s `priceSource` uses) and `r402` (how many
|
||||||
|
requests for this model were refused as unpriced under a caller's
|
||||||
|
cost budget over the requested span) — `r402` is ALWAYS present in
|
||||||
|
detail mode, regardless of whether the response cache is configured
|
||||||
|
(it has nothing to do with caching), and is read over the SAME
|
||||||
|
`window` the request asked for: model-scoped `r402` counters exist at
|
||||||
|
both `hour` and `day`, so `window=hour`/`window=day` read it exactly;
|
||||||
|
`window=month` has no `r402` bucket of its own and converts to the
|
||||||
|
exact covering `day` buckets instead (calendar-aligned, so this is
|
||||||
|
exact, not an approximation — bounded by `day`'s own retention).
|
||||||
|
`cacheHits` and `cacheSavedMicroUsd` are added too, but only when
|
||||||
|
`features.cacheStats` (the response cache is configured) AND `window`
|
||||||
|
is `day` or `month` — omitted for `window=hour` as well as when the
|
||||||
|
cache is unconfigured: a model's cache counters only ever have a
|
||||||
|
`day` bucket (never `hour`), so an hour-window value cannot be
|
||||||
|
recovered without also counting hours outside the requested range: no
|
||||||
|
exact answer exists, so none is given. `window=month` converts to the
|
||||||
|
covering `day` buckets, the same way `r402` does. The dashboard's
|
||||||
|
Models tab always sends `detail=1`, ranks by requests by default, and
|
||||||
|
lists every column in a table under the chart, with a "free" badge
|
||||||
|
instead of `$0`.
|
||||||
|
- `offset` (optional): shifts the span this many whole window-steps
|
||||||
|
back from now, for a period-over-period comparison read — `0`
|
||||||
|
(default) is this endpoint's original, pre-`offset` behavior
|
||||||
|
unchanged; the resolved value is echoed back as `offset` (an integer
|
||||||
|
from `0` to the window's own max minus `span`, else `400`).
|
||||||
|
|
||||||
Response shape:
|
Response shape:
|
||||||
`{"metric","window","span":1,"models":[{"id":"uni/qwen3-next","value":4100},...]}`
|
`{"metric","window","span":1,"offset":0,"models":[{"id":"uni/qwen3-next","value":4100},...]}`
|
||||||
(`span` echoes the resolved value — always present, even when the
|
(`span`/`offset` echo the resolved values — always present, even when
|
||||||
request carried no `?span=` of its own), sorted by `value` descending,
|
the request carried no `?span=`/`?offset=` of its own), sorted by
|
||||||
ties broken on `id` ascending so a ranking stays stable between polls.
|
`value` descending, ties broken on `id` ascending so a ranking stays
|
||||||
**Only models with non-zero usage are returned** — a catalog runs to
|
stable between polls. **Only models with non-zero usage are returned**
|
||||||
hundreds of models, so an empty `models` array means "nothing used in
|
— a catalog runs to hundreds of models, so an empty `models` array means
|
||||||
this window", never "nothing configured". Validation mirrors
|
"nothing used in this window", never "nothing configured". Validation
|
||||||
`usage/history`: a bad `metric`/`window`/`limit`/`span`/`prefix` is
|
mirrors `usage/history`: a bad `metric`/`window`/`limit`/`span`/
|
||||||
`400`, an unreachable store is `503`.
|
`prefix`/`offset` is `400`, an unreachable store is `503`.
|
||||||
|
|
||||||
Each `id` is the canonical `provider/model` of the provider that
|
Each `id` is the canonical `provider/model` of the provider that
|
||||||
actually **served** the request, so a request that failed over is
|
actually **served** the request, so a request that failed over is
|
||||||
@@ -1916,6 +1974,160 @@ or in CI.
|
|||||||
`/v1/audio/speech`, `/v1/audio/transcriptions`) write the per-model
|
`/v1/audio/speech`, `/v1/audio/transcriptions`) write the per-model
|
||||||
request counter on a successful (2xx) response too — never tokens or
|
request counter on a successful (2xx) response too — never tokens or
|
||||||
cost, since media calls are not token- or cost-accounted.
|
cost, since media calls are not token- or cost-accounted.
|
||||||
|
- **`GET /admin/api/usage/series`** (admin dashboard redesign) is the
|
||||||
|
redesigned dashboard's own time-series source — one or more scopes'
|
||||||
|
bucketed history for a single metric/window/span/offset, in one
|
||||||
|
batched store read, replacing per-scope charting with a single request
|
||||||
|
the Spend/Consumers/Models/Reliability pages all share. Query
|
||||||
|
parameters:
|
||||||
|
- `scope` (repeated, 1-100): `total`, `user:{id}`, `group:{id}`,
|
||||||
|
`model:{canonical}`, `provider:{name}`, or `provmodel:{canonical}`.
|
||||||
|
An unrecognized scope, or one naming an entity that does not exist,
|
||||||
|
is skipped and listed in the response's `unknown` array rather than
|
||||||
|
failing the whole request; absent entirely is `400`, more than 100 is
|
||||||
|
`400`.
|
||||||
|
- `metric`: which metric is valid depends on the scope kind —
|
||||||
|
`user`/`group`/`total` accept `req`/`tokin`/`tokout`/`cost` (any
|
||||||
|
window) plus `rej` (hour/day only); `total` additionally accepts
|
||||||
|
`chit`/`cmiss`/`csave`/`r402` (hour/day only); `model` accepts
|
||||||
|
`req`/`tokin`/`tokout`/`cost` (any window) plus `chit`/`csave`/`r402`
|
||||||
|
(day only); `provider` accepts `attempt`/`fail`/`timeout`/`fover`
|
||||||
|
(hour/day); `provmodel` accepts `attempt`/`fail` (hour/day). A
|
||||||
|
metric outside its scope kind's own set is `400 "unknown metric for
|
||||||
|
scope kind"`.
|
||||||
|
- `window`: `hour`, `day`, or `month`.
|
||||||
|
- `span`/`offset`: the same convention `usage/models` uses above.
|
||||||
|
|
||||||
|
Response shape: `{"metric","window","span","offset","buckets":[...],
|
||||||
|
"series":[{"scope","points":[...]}],"unknown":[...]}` — `buckets` is
|
||||||
|
oldest-first bucket labels shared by every series; `unknown` is always
|
||||||
|
an array, never `null`. A key-count estimate over `adminMaxKeysPerRequest`
|
||||||
|
(below) is `400` before any store read; an unreachable store is `503`.
|
||||||
|
- **`GET /admin/api/usage/totals`** (admin dashboard redesign) ranks
|
||||||
|
entities of one kind by their totals over a span — the Home page's
|
||||||
|
top-users/top-models cards, and the Spend page's group→user attribution
|
||||||
|
drill-down. Query parameters:
|
||||||
|
- `kind`: `user`, `group`, `usermodel`, `modeluser`, or `targetcaller`.
|
||||||
|
- `window` (default `day`): `user`/`group` accept `hour`/`day`/`month`;
|
||||||
|
`usermodel`/`modeluser`/`targetcaller` accept only `day`/`month`.
|
||||||
|
- `span` (default `1`), `offset` (default `0`): the same convention
|
||||||
|
`usage/models` uses above.
|
||||||
|
- `metrics` (optional, comma list): defaults to every metric `kind`
|
||||||
|
allows — `req`/`tokin`/`tokout`/`cost` for `user`/`group`/`usermodel`/
|
||||||
|
`modeluser`, `req` only for `targetcaller` (target requests carry no
|
||||||
|
token/cost dimension — see [MCP and A2A](#mcp-and-a2a)); an unknown or
|
||||||
|
duplicate entry is `400`.
|
||||||
|
- `limit` (optional, default 200, 1-1000): rows beyond it are dropped,
|
||||||
|
with `truncated: true` on the response.
|
||||||
|
- `group` (optional, `kind=user` only): narrows to one group's members.
|
||||||
|
- `user` (required for `kind=usermodel`), `model` (required for
|
||||||
|
`kind=modeluser`), `target` (optional for `kind=targetcaller`, either
|
||||||
|
`mcp/{name}` or `agent/{name}` — omitted ranks every configured
|
||||||
|
target × every user).
|
||||||
|
- `usermodel`/`modeluser` answer `404 "per-user-model statistics are
|
||||||
|
not enabled (admin.stats.userModel)"` when that stat is off — checked
|
||||||
|
only AFTER every other parameter is already known valid, so a
|
||||||
|
malformed request always reports what is actually wrong with it
|
||||||
|
rather than a blanket "feature disabled". A `usermodel` read narrows
|
||||||
|
the model catalog down to models with ANY recent usage first (a
|
||||||
|
single-metric read over covering month buckets), before ever reading
|
||||||
|
a per-user counter for one — the catalog can run to hundreds of
|
||||||
|
models, while a real user typically used only a handful.
|
||||||
|
|
||||||
|
Response shape: `{"kind","window","span","offset","metrics":[...],
|
||||||
|
"rows":[{"id","values":{"req":123,...}}],"truncated"}` — an all-zero
|
||||||
|
row (across every requested metric) is dropped before ranking; rows
|
||||||
|
sort by the first requested metric descending, ties by `id` ascending.
|
||||||
|
`id` is a plain name for `user`/`group`, a canonical `provider/model`
|
||||||
|
id for `usermodel`, a user name for `modeluser`, or
|
||||||
|
`"mcp/{target}/{caller}"`/`"agent/{target}/{caller}"` for
|
||||||
|
`targetcaller`. A key-count estimate over `adminMaxKeysPerRequest` is
|
||||||
|
`400`; an unreachable store is `503`.
|
||||||
|
- **`GET /admin/api/performance`** (admin dashboard redesign) reports
|
||||||
|
provider/model availability and, when `admin.stats.latency` is on,
|
||||||
|
latency percentiles — the Models page's per-row p50/p95 columns and the
|
||||||
|
Reliability page's fleet-wide provider panel. Query parameters:
|
||||||
|
- `kind`: `provider` or `model`.
|
||||||
|
- `window`: `hour` or `day` only (a latency percentile over a month is
|
||||||
|
not actionable enough to justify the extra retention this endpoint
|
||||||
|
would need).
|
||||||
|
- `span`/`offset`: the same convention `usage/models` uses above.
|
||||||
|
- `id` (repeated, optional, up to 50): which entities to report.
|
||||||
|
Omitted defaults to every configured provider (`kind=provider`) or
|
||||||
|
the top 50 models by request count over the span (`kind=model`).
|
||||||
|
- `series` (optional, `0` or `1`): `1` returns bucket-by-bucket figures
|
||||||
|
for exactly one `id` instead of one summed row per `id` — `400` when
|
||||||
|
`series=1` and `id` does not name exactly one entity.
|
||||||
|
|
||||||
|
`rows` is always present in the response, never `null` — one row per
|
||||||
|
`id` in the default (`series=0`) mode, or an empty array in `series=1`
|
||||||
|
mode (whose own figures land in `buckets`/`points` instead).
|
||||||
|
`attempts`/`failures` (and, for `kind=provider`, `timeouts`/
|
||||||
|
`failovers`) are always present; `count` is also always present (`0`
|
||||||
|
means no recorded sample, whether because `admin.stats.latency` is off
|
||||||
|
or the entity simply has none in the span — its JSON field carries no
|
||||||
|
`omitempty`). `p50Ms`/`p95Ms`/`p99Ms`/`ttfbP50Ms`/`ttfbP95Ms` are the
|
||||||
|
ones present only when `admin.stats.latency` is on AND that entity has
|
||||||
|
at least one recorded sample in the span (a `nil` percentile is "no
|
||||||
|
data", never a fabricated `0`) — computed
|
||||||
|
server-side by linear interpolation within a latency histogram bucket
|
||||||
|
(the same 13 bounds `latencyBucketBounds`, [Metrics](#metrics), uses,
|
||||||
|
0.1s to 600s, plus one overflow bucket); an observation that landed in
|
||||||
|
the overflow bucket reports `overflow: true` with its percentile
|
||||||
|
clamped to `600000` (the largest bound in milliseconds — a true value
|
||||||
|
past that point is unknown by construction). `latencyEnabled` on the
|
||||||
|
response echoes whether `admin.stats.latency` is on, so the dashboard
|
||||||
|
can render "latency not enabled" once rather than per row. A key-count
|
||||||
|
estimate over `adminMaxKeysPerRequest` is `400`; an unreachable store
|
||||||
|
is `503`.
|
||||||
|
- **`GET /admin/api/catalog`** (admin dashboard redesign, on-demand —
|
||||||
|
the Models and Reliability pages' own 5-minute-stale data source, not
|
||||||
|
polled with `overview`) returns every configured provider's full model
|
||||||
|
catalog: id, context window, per-1M-token prices, `priceSource`
|
||||||
|
(`billingPriceSource`'s own `"override"`/`"builtin"`/`"litellm"`/
|
||||||
|
`"free"`/`"unpriced"` classification — the actual billing resolution a
|
||||||
|
real request gets, distinct from the display metadata `overview`'s
|
||||||
|
`modelMeta` already carries: a model can have a discovery-captured
|
||||||
|
context window with no configured price at all), `displayFree` (a
|
||||||
|
`":free"`-suffixed model id with no configured price — billing
|
||||||
|
unchanged, display-only), and its configured aliases — plus the same
|
||||||
|
alias table `overview` carries. Needs no counterStore round trip at
|
||||||
|
all: every field is either static configuration or the registry's own
|
||||||
|
discovery state, read from the identical snapshot `overview` reads.
|
||||||
|
- **`GET /admin/api/consumers`** (admin dashboard redesign, on-demand)
|
||||||
|
returns every active user and every configured group for the Consumers
|
||||||
|
page's directory and access-matrix views. A user row's `source` is
|
||||||
|
`"inline"` or `"file"` (which config block actually defined this user —
|
||||||
|
see [Users file format](#users-file-format)); `providers`/`models` are
|
||||||
|
that user's own personal grant, when configured (see [Model
|
||||||
|
routing](#model-routing)); `admin` is that user's own `admin: true`
|
||||||
|
flag. A group row carries its configured access lists (`providers`/
|
||||||
|
`models`/`mcpServers`/`agents`/`passthroughPaths`) AS CONFIGURED, plus
|
||||||
|
two RESOLVED views against the live catalog: `allowedProviders` (every
|
||||||
|
currently catalogued provider name this group can reach at all, never
|
||||||
|
`null`) and `modelAccess` (per provider, `{"allowed","total"}` model
|
||||||
|
counts — computed via the identical `allowsProvider`/
|
||||||
|
`allowsProviderModel` check a real request's own authorization uses, so
|
||||||
|
this view can never disagree with what the proxy actually enforces).
|
||||||
|
`usersFile` names the configured users-file PATH only — its contents
|
||||||
|
are never read or exposed here (see [Users file format](
|
||||||
|
#users-file-format)).
|
||||||
|
- **`GET /admin/api/config`** (admin dashboard redesign, on-demand) is
|
||||||
|
the Config page's own read-only source: the live configuration,
|
||||||
|
redacted, plus the same `warnings` array `overview` carries and the
|
||||||
|
same `features` flags every stats endpoint echoes. `config` is `g.cfg`
|
||||||
|
round-tripped through JSON and walked recursively — every `apiKey`/
|
||||||
|
`password` JSON key redacted (an `"env:NAME"`/`"file:/path"` indirection
|
||||||
|
is kept verbatim, since it names WHERE the secret is, not the secret
|
||||||
|
itself; any literal value is replaced with `"[redacted]"`), every
|
||||||
|
`baseUrl`/`url` JSON key passed through the same userinfo/query-string
|
||||||
|
stripping `overview`'s own provider `baseUrl` already applies. A
|
||||||
|
redaction failure (not expected on any real running Gateway) degrades
|
||||||
|
to an empty `config` map with a warning appended, never a `500`. The
|
||||||
|
Config page renders this as read-only YAML (`lib/yaml-emit.ts`) — see
|
||||||
|
[Dashboard](#dashboard) below for the change-helper that turns an
|
||||||
|
intended edit into a paste-ready config snippet; this endpoint itself
|
||||||
|
has no write counterpart of any kind.
|
||||||
- **`GET /admin/api/events`** (F3, v0.3 dashboard task) returns this
|
- **`GET /admin/api/events`** (F3, v0.3 dashboard task) returns this
|
||||||
deployment's most recent operational events — rate-limit/budget
|
deployment's most recent operational events — rate-limit/budget
|
||||||
rejections and upstream/timeout/unpriced/capacity signals — for the
|
rejections and upstream/timeout/unpriced/capacity signals — for the
|
||||||
@@ -1990,11 +2202,13 @@ or in CI.
|
|||||||
- **What's exposed**: provider names, types, base URLs (with any
|
- **What's exposed**: provider names, types, base URLs (with any
|
||||||
userinfo/query string stripped before it's ever echoed), model counts,
|
userinfo/query string stripped before it's ever echoed), model counts,
|
||||||
discovery status, group/user names, membership, limits, MCP/agent
|
discovery status, group/user names, membership, limits, MCP/agent
|
||||||
target names/URLs/access, and live usage counters. **Never exposed**:
|
target names/URLs/access, live usage counters, and — via `GET
|
||||||
API keys (not even digests), provider keys, the Redis password, or
|
/admin/api/config` — the redacted config tree itself. **Never
|
||||||
users-file path contents — every secret-bearing field is redacted from
|
exposed**: API keys (not even digests), provider keys, the Redis
|
||||||
every response.
|
password, or users-file path contents — every secret-bearing field is
|
||||||
- **Admin traffic is never counted**: none of the six `/admin/api/*`
|
redacted from every response (see `GET /admin/api/config`'s own
|
||||||
|
redaction rules above).
|
||||||
|
- **Admin traffic is never counted**: none of the twelve `/admin/api/*`
|
||||||
JSON routes call `checkAndCount` — admin polling never moves any
|
JSON routes call `checkAndCount` — admin polling never moves any
|
||||||
user's or group's `requestsPerMinute`/`requestsPerDay` counters, and
|
user's or group's `requestsPerMinute`/`requestsPerDay` counters, and
|
||||||
usage statistics reflect real LLM traffic only (operator directive: an
|
usage statistics reflect real LLM traffic only (operator directive: an
|
||||||
@@ -2002,18 +2216,18 @@ or in CI.
|
|||||||
never itself distort the numbers it displays). The direct consequence:
|
never itself distort the numbers it displays). The direct consequence:
|
||||||
an admin's own `requestsPerMinute`/`requestsPerDay` limit, if
|
an admin's own `requestsPerMinute`/`requestsPerDay` limit, if
|
||||||
configured, is never enforced against admin-route traffic either — an
|
configured, is never enforced against admin-route traffic either — an
|
||||||
admin key holder can poll any of the six routes as fast as they like.
|
admin key holder can poll any of the twelve routes as fast as they
|
||||||
This is an accepted trade-off, not an oversight: these are admin-gated,
|
like. This is an accepted trade-off, not an oversight: these are
|
||||||
cheap reads, and an admin holder polling aggressively is a
|
admin-gated, cheap reads, and an admin holder polling aggressively is a
|
||||||
self-inflicted, not a shared, resource cost. `GET /admin` and
|
self-inflicted, not a shared, resource cost. `GET /admin` and
|
||||||
`GET /admin/assets/*` count nothing either, for the simpler reason that
|
`GET /admin/assets/*` count nothing either, for the simpler reason that
|
||||||
they are unauthenticated — there is no identified user to count a
|
they are unauthenticated — there is no identified user to count a
|
||||||
request against.
|
request against.
|
||||||
- **Response headers**: all six JSON routes set
|
- **Response headers**: all twelve JSON routes set
|
||||||
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every
|
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every
|
||||||
hashed asset sets `X-Content-Type-Options: nosniff` and its own
|
hashed asset sets `X-Content-Type-Options: nosniff` and its own
|
||||||
immutable `Cache-Control` (above). Every one of these routes — `GET
|
immutable `Cache-Control` (above). Every one of these routes — `GET
|
||||||
/admin`, every hashed asset, and the six JSON routes — shares one
|
/admin`, every hashed asset, and the twelve JSON routes — shares one
|
||||||
`Content-Security-Policy` header (`default-src 'none'; script-src
|
`Content-Security-Policy` header (`default-src 'none'; script-src
|
||||||
'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:;
|
'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:;
|
||||||
frame-ancestors 'none'; base-uri 'none'; form-action 'none'`) — no
|
frame-ancestors 'none'; base-uri 'none'; form-action 'none'`) — no
|
||||||
@@ -2028,89 +2242,217 @@ or in CI.
|
|||||||
for asset responses to be the exception. The dashboard loads no
|
for asset responses to be the exception. The dashboard loads no
|
||||||
external asset of any kind and works in an air-gapped cluster.
|
external asset of any kind and works in an air-gapped cluster.
|
||||||
|
|
||||||
### Dashboard (v0.3 additions)
|
### Statistics: always-on vs opt-in
|
||||||
|
|
||||||
- **Usage-vs-limit bars**: the Usage tab's Users/Groups tables, and a
|
The admin dashboard redesign adds several new counter families on top of
|
||||||
group's own detail grid, render a compact meter beside every numeric
|
the ones [Limits and accounting](#limits-and-accounting) already
|
||||||
column that has a configured limit — request-rate, tokens (in+out
|
describes. Every one of them is gated on `admin.enabled` — none is
|
||||||
combined, since a `tokensPerDay`/`tokensPerMonth` limit enforces
|
written at all with the admin dashboard off — and each is either
|
||||||
against their sum), and cost. A bar reads amber at 80% of its limit
|
**always-on-with-admin** (zero/near-zero cost, rides an EXISTING
|
||||||
and red at 100% or beyond (a scope's usage window can roll forward past
|
`counterStore` round trip, no config needed beyond `admin.enabled: true`)
|
||||||
its limit before the next request is actually rejected, so "at or over
|
or **opt-in** (`admin.stats.userModel`/`admin.stats.latency`, both
|
||||||
100%" is a real, reachable state, not a ceiling); a scope with that
|
default `false`, each its own cardinality/cost trade-off an operator
|
||||||
limit left unconfigured renders no bar at all, never a fabricated 0%,
|
chooses explicitly).
|
||||||
and a `storeDown` scope masks every bar the same way it already masks
|
|
||||||
its raw numbers.
|
- **Always-on-with-admin**: last-seen, hourly provider attempt/fail/
|
||||||
- **Cost forecast**: a card under the Usage tab's Total row projects
|
timeout, failover (`fover`), rejections/hour, unpriced refusals
|
||||||
fleet-wide month-end spend by linearly extrapolating
|
(`r402`), target × caller totals, and cache hit/miss/saved counters
|
||||||
`total.costPerMonthMicroUsd` against how far the current UTC calendar
|
(the last three additionally need the response cache itself
|
||||||
month has elapsed — nothing is shown for the first 2% of the month
|
configured — `features.cacheStats` on `GET /admin/api/overview`/
|
||||||
(`MIN_PROJECTION_FRACTION`, `lib/forecast.ts` — about 13.4h in February
|
`/admin/api/config` reports whether it is).
|
||||||
to 14.9h in a 31-day month; too little elapsed to extrapolate
|
- **Opt-in**: `admin.stats.userModel` (per-(user, model) usage
|
||||||
meaningfully from), and the projected figure reads red, with a
|
breakdown) and `admin.stats.latency` (per-bucket upstream latency
|
||||||
tooltip, once it would cross a configured `costPerMonthUSD` limit.
|
histograms) — see [`AdminStatsConfig`](#adminstatsconfig-adminstats)
|
||||||
- **Rejected/day**: a `rejected/day` column sits between `req/day` and
|
above.
|
||||||
the token columns in the Users/Groups tables (and the Total row),
|
|
||||||
reading `usage`'s own `rejectionsPerDay` field, tinted when non-zero.
|
**Redis cost — measured, not estimated** (`stats_write_test.go`, a real
|
||||||
- **CSV export**: an "Export CSV" button on the Usage tab's Users and
|
`Gateway` driven through one single-group chat request, counting calls to
|
||||||
Groups table headers, and on the Charts tab's Models ranking, downloads
|
its `counterStore`; a `redisStore` call always pipelines its whole batch
|
||||||
the CURRENTLY filtered-and-sorted rows as an RFC 4180 CSV file (comma
|
into ONE network round trip regardless of how many entries it carries, so
|
||||||
quoting, CRLF line endings) via a `Blob` + `<a download>` — no server
|
counting calls is exactly counting round trips):
|
||||||
round trip. If the browser rejects that (an environment with no
|
|
||||||
`Blob`/`URL` API), it falls back to copying the CSV text to the
|
| Configuration | `counterStore` round trips per request |
|
||||||
clipboard, or selecting it for a manual copy, instead of silently
|
|---|---|
|
||||||
losing the export.
|
| `admin.enabled: false` | 3 (2 sync — `checkAndCount`, `accountWith` — + 1 async — `recordProviderAttempt`) — `l.statsAdmin` (`adminEnabled(cfg)`, `newConfiguredLimiter`) is false, so no "always-on-with-admin" family, last-seen included, ever writes (`TestRoundTrips_SingleGroupChat_Defaults_TwoSyncOneAsync`, `stats_write_test.go`) |
|
||||||
- **Click-through links to Charts**: a user/group id in the Usage tab's
|
| `admin.enabled: true` (`admin.stats.userModel`/`latency` on or off — **both default off, and neither changes this row**: `l.statsAdmin` is `adminEnabled(cfg)` alone, so last-seen and the rest of the always-on family are gated on `admin.enabled` by itself), cache miss or no cache, this scope's last-seen already written within `lastSeenGateThrottle` by this replica | 3 — every new counter family except last-seen rides one of the two existing sync batches above, never a new round trip; `userModel`/`latency`, when on, add entries to those SAME batches, never a new call |
|
||||||
tables, and a model id beside its `ModelChip` on the Providers tab, are
|
| Same as above, but this scope's last-seen is DUE (its first admitted request ever, or `lastSeenGateThrottle` — 60s — has elapsed since this replica's last write for it) | 4 — adds `recordLastSeen`'s own async write (P9 fix, moved off `checkAndCount`'s own batch — see [Limits and accounting](#limits-and-accounting)). This is the common case for a request from a SCOPE'S FIRST admitted request under a fresh limiter, `admin.enabled: true` regardless of the opt-in stats (`TestRoundTrips_SingleGroupChat_AdminEnabledStatsOff_FourRoundTripsWhenLastSeenDue`, `TestRoundTrips_SingleGroupChat_AllStatsOn_NoNewSyncRoundTrips`, `stats_write_test.go`) |
|
||||||
each a link that jumps straight to the Charts tab with that exact scope
|
| Cache hit (`cache.enabled: true`, `admin.enabled: true`), last-seen already throttled | 2 (`checkAndCount` + `recordCacheHit`'s async write) — `accountWith`/`recordProviderAttempt` never run at all, since the hit branch returns before either is reached |
|
||||||
already selected (switching off the scope-less Models ranking first, if
|
| Cache hit, last-seen due | 3 — adds `recordLastSeen`'s own async write, same condition as the row above |
|
||||||
that was showing); a provider's own header on the Providers tab links
|
|
||||||
to the Charts tab's Models ranking, pre-filtered to that provider's own
|
Two exceptions to "round trip count never changes with load": a
|
||||||
model ids.
|
response-cache hit trades away the two calls a miss would have paid
|
||||||
- **Events tab**: the new `GET /admin/api/events`-backed tab (above) —
|
(`accountWith`, `recordProviderAttempt`) for one (`recordCacheHit`) — a
|
||||||
a searchable-by-user/group, filterable-by-kind table, newest first,
|
net DECREASE versus a miss, not an increase — and a `402` refusal under a
|
||||||
captioned with whether it is currently showing the fleet-wide feed or
|
cost budget adds one FURTHER async round trip of its own
|
||||||
just this replica's own (with a warning banner when it fell back to the
|
(`recordUnpriced402` via `countAsync`, `events.go`) on top of whatever
|
||||||
latter because Redis was configured but unreachable).
|
`checkAndCount` already paid to admit the request.
|
||||||
- **Polling paused in hidden tabs**: every poll this dashboard runs
|
|
||||||
(Providers/Usage/Targets every 5s for the app's whole session, the
|
With every stat on, the EXISTING `checkAndCount`/`accountWith` round
|
||||||
Charts tab's own 30s auto-refresh while open, and the Events tab's own
|
trips each carry more `counterIncr` entries per batch: a failover
|
||||||
5s poll while it is the active tab) stops the instant the browser tab
|
candidate that also misses its own cache lookup can add up to 5 entries
|
||||||
itself is backgrounded (`document.hidden`), and catches up with an
|
to `accountWith`'s own batch (`cmiss`: up to 3 — 2 total + 1 model day —
|
||||||
immediate fetch — not a stale wait-out-the-interval — the instant it is
|
plus `fover`: 2), never a new round trip for either. Last-seen no longer
|
||||||
looked at again. A Redis-backed admin panel left open in a background
|
rides `checkAndCount`'s admission batch at all (P9 fix): it is its own
|
||||||
tab costs nothing while nobody can see it.
|
fire-and-forget `SET`, one per `user`/`group` scope in the request (never
|
||||||
- **URL hash state**: the current tab and its own selection round-trip
|
the synthetic `total` scope, and never more than one per scope per
|
||||||
through `location.hash`, so a link (or a reload) restores exactly what
|
`lastSeenGateThrottle` per replica) — which is why it appears as its own
|
||||||
was showing — `#charts?window=<hour|day|month>&tab=<requests|tokens|cost|models>&metric=<req|tokin|tokout|cost>&scope=<total|user:{id}|group:{id}|model:{id}>&filter=<model id prefix>`,
|
table row above instead of as extra entries on an existing one.
|
||||||
`#usage?q=<search text>`, `#events?kind=<kind>&user=<search text>`. An
|
|
||||||
omitted field means "whatever it already defaulted to"; an unrecognized
|
**Key vocabulary** (`llmgw:{kind}:{id}:{metric}:{window}:{bucket}`,
|
||||||
tab, or an invalid value for a field that has a fixed set of valid
|
[Limits and accounting](#limits-and-accounting)'s own key format, except
|
||||||
ones, is dropped rather than resetting every other, still-valid field
|
last-seen, below, which carries no window/bucket at all):
|
||||||
alongside it. Not every name in this hash stays in the address bar:
|
|
||||||
Charts' `scope` and `filter` are exactly the values `stores/history.ts`
|
| Vocabulary | Meaning | TTL |
|
||||||
sends as `scope=` and `prefix=` query parameters to this panel's own
|
|---|---|---|
|
||||||
admin API (`GET /admin/api/usage/history`, `GET /admin/api/usage/models`)
|
| `umodel` (kind) | Per-(user, model) breakdown scope (`admin.stats.userModel`) — id is `strconv.Itoa(len(user)) + ":" + user + canonical` (length-prefixed so a user name containing any character can never collide with the canonical model id immediately after it) | 35d (day) / 400d (month) |
|
||||||
to fetch the selection they name, so a user/group id or model-id prefix
|
| `tcaller` (kind) | Per-(target, caller) breakdown scope — id is `"{mcp\|agent}/{target}/{caller}"` | 35d (day) / 400d (month) |
|
||||||
placed in the hash does reach a server — this panel's own, over the
|
| `llmgw:seen:{kind}:{id}` | Last-seen absolute key (below) — fixed key, no window/bucket, `kind` is `"user"` or `"group"` | 400d |
|
||||||
same authenticated connection every other request on this page already
|
| `timeout` (metric) | Provider-level "did not answer within budget" counter (`isDeadlineExceeded(err) \|\| matchesSentinel(err, errProviderTimeout)`) | 48h (hour) / 35d (day) |
|
||||||
uses, never a third party. Usage's `q` and Events' `kind`/`user` are the
|
| `fover` (metric) | Failover counter — `prov/{from}`, incremented for every candidate after the first, keyed by the PREVIOUS candidate's provider name | 48h (hour) / 35d (day) |
|
||||||
opposite: pure client-side filters applied to data already fetched
|
| `r402` (metric) | Unpriced-refusal counter — `total/all` and `model/{canonical}` | 48h (hour) / 35d (day) |
|
||||||
(`lib/events-filter.ts`'s `filterEvents`, and the Usage tables' own
|
| `rej` hour (metric+window) | Rejection counter, now also at hour granularity (day already existed) — same scopes as the existing `rej`/day | 48h |
|
||||||
search), so those three specifically never appear in any request this
|
| `chit` / `csave` / `cmiss` (metric) | Cache hit / saved-µUSD / miss counters — `total/all` gets hour AND day; `model/{canonical}` gets day only, for all three | 48h (hour) / 35d (day) |
|
||||||
page issues.
|
| `ld00`..`ld13` (metric) | Per-bucket upstream call-duration histogram (`admin.stats.latency`) — 13 configured bounds (`latencyBucketBounds`, [Metrics](#metrics), 0.1s–600s) plus one overflow bucket, `%02d`-padded | 48h (hour) / 35d (day) |
|
||||||
- **Config warnings banner**: `GET /admin/api/overview`'s `warnings`
|
| `lt00`..`lt13` (metric) | Per-bucket upstream time-to-first-byte histogram (`admin.stats.latency`, only when the candidate reported a TTFB) | 48h (hour) / 35d (day) |
|
||||||
array (above) renders as a collapsible banner under the header —
|
|
||||||
nothing at all when the array is empty — naming the count, and, when
|
**Last-seen** (`llmgw:seen:{kind}:{id}`, DECISIONS Q6) is written as an
|
||||||
expanded, every warning's own exact text plus how many more were
|
ABSOLUTE `SET key <unix seconds> EX <ttl>` — not an `INCRBY` — via its own
|
||||||
dropped past the 50-entry cap, if any.
|
fire-and-forget async write (`recordLastSeen`, `countAsync`), for every
|
||||||
- **Replica labels**: the header's own "last updated ..." status line
|
`user`/`group` scope in the request (never the synthetic `total` scope: a
|
||||||
appends `· replica {overview.replica}` after a successful poll, and the
|
fleet-wide "when was anyone last seen" has no meaning). Called ONLY once
|
||||||
Providers tab's per-replica latency/provenance caption names that exact
|
`checkAndCount` has already reported the request admitted, never for one
|
||||||
replica id too — both so a reader looking at per-process figures
|
it refuses: `lastSeen` genuinely tracks a scope's most recent ADMITTED
|
||||||
(latency and provenance are in-process, not fleet-wide — see
|
request, matching `GET /admin/api/usage`'s own field doc below — an
|
||||||
[Limits and accounting](#limits-and-accounting)) knows not just that
|
earlier version appended this to `checkAndCount`'s own pre-admission
|
||||||
the number is replica-scoped, but which replica it came from.
|
batch, which moved a scope's last-seen timestamp forward even for a
|
||||||
|
request that turned out to be rejected. **Throttled to once per 60
|
||||||
|
seconds per (scope, replica)**: each replica tracks, in an in-process map
|
||||||
|
starting at a 4,096-entry rotation threshold (rotating to a fresh
|
||||||
|
generation past that, with the previous generation still consulted on
|
||||||
|
lookup so an active scope's own throttle survives the rotation), the
|
||||||
|
last instant IT wrote a last-seen SET for that scope, and skips the
|
||||||
|
write entirely if less than 60s has passed — so a busy scope's last-seen
|
||||||
|
key gets at most one `SET` per minute per replica, for up to roughly
|
||||||
|
8,192 concurrently active `user`+`group` scopes (2x the base threshold,
|
||||||
|
current and previous generations both full). Above that, the rotation
|
||||||
|
threshold itself DOUBLES each time a generation refills within one
|
||||||
|
throttle window of the previous rotation — direct evidence the live
|
||||||
|
scope population outgrew it — capped at 65,536 (`lastSeenGateMapCapMax`,
|
||||||
|
`limits.go`), so the throttle keeps holding for populations up to
|
||||||
|
roughly 131,072 concurrently active scopes (2x the ceiling) before it
|
||||||
|
degrades back to the same fixed-size rotate/discard behaviour a
|
||||||
|
below-ceiling gate always had; it never grows further, and never
|
||||||
|
shrinks. This still bounds one replica's last-seen throttle memory to at
|
||||||
|
most two generations' worth of entries (int64 key + value, tens of MB at
|
||||||
|
the ceiling) rather than one entry per distinct scope ever seen — a
|
||||||
|
displaced or never-revisited scope's entry is simply dropped on the next
|
||||||
|
rotation, matching `rejectionCounter`'s own trade-off. `GET
|
||||||
|
/admin/api/usage`'s own `lastSeen` field (unix seconds, `0` for
|
||||||
|
never/unknown) is this key, read back.
|
||||||
|
|
||||||
|
**`adminMaxKeysPerRequest` (64000)** is the hard cap on how many
|
||||||
|
`counterStore` keys any single stats-read endpoint (`usage/models`,
|
||||||
|
`usage/series`, `usage/totals`, `performance`) may read in one request —
|
||||||
|
a caller whose scope/span/offset/metric combination would exceed it gets
|
||||||
|
`400 "range too large; narrow span or filter"` before any store read is
|
||||||
|
attempted, rather than an unbounded pipeline against the shared store.
|
||||||
|
|
||||||
|
**404 when a feature is off**: `GET /admin/api/usage/totals?kind=
|
||||||
|
usermodel` or `?kind=modeluser` answers `404 "per-user-model statistics
|
||||||
|
are not enabled (admin.stats.userModel)"` when that stat is off — checked
|
||||||
|
only AFTER every other query parameter is already known valid, so a
|
||||||
|
malformed request always reports what is actually wrong with it, never a
|
||||||
|
blanket "feature disabled" for an unrelated mistake. Every other endpoint
|
||||||
|
degrades instead of erroring: `admin.stats.latency` off simply omits the
|
||||||
|
percentile fields from `GET /admin/api/performance` (`latencyEnabled:
|
||||||
|
false` on the response says so); `features.cacheStats` off simply omits
|
||||||
|
`cacheHits`/`cacheSavedMicroUsd` from `GET /admin/api/usage/models`'
|
||||||
|
detail mode.
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
The dashboard was redesigned around a left sidebar of seven pages
|
||||||
|
(`webui/src/lib/pages.ts`) replacing the earlier flat-tab shell
|
||||||
|
(`ChartsView.vue`/`UsageChart.vue`/`UsageView.vue`/`ProvidersView.vue`,
|
||||||
|
and `composables/useTabHash.ts`, all deleted): a request only needs to
|
||||||
|
answer one question ("what does this page show"), instead of a tab whose
|
||||||
|
own content changed shape depending on a second `tab=` sub-parameter.
|
||||||
|
|
||||||
|
- **Sidebar pages** (`SidebarNav.vue`, horizontal-scrolling on narrow
|
||||||
|
screens): **Home** (spend today/MTD vs. budget, projected month-end,
|
||||||
|
req/min, fleet error rate, open breakers, top 5 users/models, latest
|
||||||
|
events, config warnings + unpriced-model count), **Spend** (stacked
|
||||||
|
time-series with optional dashed period-over-period comparison,
|
||||||
|
burn-down vs. budget with a projected run-out date, group→user→model
|
||||||
|
attribution drill-down, a pricing-health table surfacing unpriced
|
||||||
|
served models first, and a cost-avoided card for free-tier usage
|
||||||
|
against a reference paid model), **Consumers** (the user/group
|
||||||
|
directory — reusing the usage table/bars this section already
|
||||||
|
documented — an access matrix of group × provider/MCP/agent, and a
|
||||||
|
per-user detail view with its own timeline and recent errors),
|
||||||
|
**Models** (the model catalog joined with usage and p50/p95/error-rate
|
||||||
|
performance, and the provider health panel moved from the old
|
||||||
|
Providers tab), **Reliability** (per-provider error-rate/failover/
|
||||||
|
timeout/402 time series, plus the events feed), **Config** (the
|
||||||
|
redacted config tree and the change helper, below), and **MCP &
|
||||||
|
Agents** (unchanged target list, now with a per-caller breakdown card).
|
||||||
|
- **Global filter bar** (`GlobalFilterBar.vue`, under the header, applies
|
||||||
|
to every page except MCP & Agents): a `range` preset — `24h`, `48h`,
|
||||||
|
`7d` (default), `14d`, `30d`, `3mo`, `6mo`, or `12mo`, each a fixed
|
||||||
|
`(window, span)` pair (`lib/range.ts`) matching `GET /admin/api/usage/
|
||||||
|
series`'s own `window`/`span` parameters exactly — a `cmp` toggle
|
||||||
|
("vs previous period", `none` by default), and a `scope` filter (`all`,
|
||||||
|
`group:{id}`, `user:{id}`, or `provider:{name}`). **Comparison
|
||||||
|
availability depends on the range**: a period-over-period comparison
|
||||||
|
reads the SAME span twice (the current period and the one immediately
|
||||||
|
before it), so it needs `2 × span` buckets total against a window's own
|
||||||
|
retention ceiling (`historyMaxSpan` — 48 hourly / 35 daily / 13 monthly
|
||||||
|
buckets) — the `48h` preset (span 48 of 48), `30d` (span 30 of 35 —
|
||||||
|
`2×30=60` already exceeds it), and `12mo` (span 12 of 13) cannot be
|
||||||
|
compared (the toggle disables itself with a "retention limit" reason);
|
||||||
|
the other five ranges can.
|
||||||
|
- **Hash schema**: `#<page>?range=7d&cmp=prev&scope=group:friends&<page
|
||||||
|
params>` — global filters and page-specific params share one
|
||||||
|
`location.hash`, so a link or a reload restores exactly what was
|
||||||
|
showing; a default value is always OMITTED from the hash rather than
|
||||||
|
written out explicitly. Per-page params: Spend `by=provider|model|group`,
|
||||||
|
`metric=cost|req|tokin|tokout`, `ref=<canonical>` (cost-avoided
|
||||||
|
reference model), `drill=group:x|user:x` (attribution breadcrumb);
|
||||||
|
Consumers `q`, `view=directory|matrix`, `user=<name>`; Models `q`,
|
||||||
|
`model=<canonical>`; Reliability `provider`, `kind`, `user`; Config
|
||||||
|
`helper=limits|grant|pricing|model|user`; MCP & Agents `q`,
|
||||||
|
`target=mcp/x`. **Legacy mapping** (`LEGACY_PAGE_MAP`,
|
||||||
|
`lib/hash-state.ts`): a bookmark or shared link built against the old
|
||||||
|
shell still lands somewhere sensible — `#providers`→`models`,
|
||||||
|
`#usage`→`consumers`, `#charts`→`spend`, `#events`→`reliability`;
|
||||||
|
`#targets` keeps its own name (the one page that did not move). A
|
||||||
|
legacy hash's own query params never carry over — none of the old
|
||||||
|
names (`#charts`'s `tab=models&metric=cost`, for instance) map cleanly
|
||||||
|
onto the new page's own param names, so a legacy link lands on a clean
|
||||||
|
instance of its mapped page rather than misapplying a stale param.
|
||||||
|
- **Config page's change helper** (`ChangeHelper.vue`) is five forms —
|
||||||
|
limits, an access grant, pricing, model metadata, and a new user — each
|
||||||
|
producing a paste-ready YAML or `users.json` snippet
|
||||||
|
(`lib/snippets.ts`/`lib/yaml-emit.ts`) in the exact shape the
|
||||||
|
production config repo actually uses (nested at
|
||||||
|
`spec.plugin.llmgateway.<section>` for YAML; `users.json`'s own fixed
|
||||||
|
key order for a user line). **It emits snippets only — there is no
|
||||||
|
write API of any kind.** `GET /admin/api/config` (above) is, and stays,
|
||||||
|
read-only; an operator copies the generated snippet into their own
|
||||||
|
GitOps-managed config and applies it the normal way. Client-side
|
||||||
|
validation (`isValidName`, `isValidNonNegative`, and a personal-grant
|
||||||
|
rejection mirroring `auth.go`'s own "models without providers" check)
|
||||||
|
catches a malformed value before it is ever rendered into a snippet,
|
||||||
|
but nothing here ever mutates a running Gateway.
|
||||||
|
- **Still true from the earlier dashboard**: usage-vs-limit bars (amber
|
||||||
|
at 80% of a configured limit, red at 100%+), the cost forecast card
|
||||||
|
(`lib/forecast.ts`, nothing shown for the first 2% of the UTC calendar
|
||||||
|
month), CSV export (RFC 4180, client-side, clipboard fallback with no
|
||||||
|
`Blob`/`URL` API), the config-warnings banner, replica labels on
|
||||||
|
per-process figures, and polling pausing the instant a browser tab is
|
||||||
|
backgrounded (`lib/polling.ts`, `document.hidden`) with an immediate
|
||||||
|
catch-up fetch when it is looked at again — all carried over into their
|
||||||
|
new pages rather than dropped.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user