From the repository, docs/contract/dashboard-v1/README.md
Dashboard API contract v1
These files are the truth for /v1/dashboard/*. Every response the server sends validates against its schema here, and both sides check that. The backend test suite validates real responses, and the dashboard's mock engine (dashboard/mock/) validates its fixtures and every mock response (npm run test:contract in dashboard/). A change to a field bumps contract_version and gets a line in the change list below.
| file | endpoint |
|---|---|
summary.schema.json |
GET /v1/dashboard/summary?tz= |
usage.schema.json |
GET /v1/dashboard/usage?from=&to=&bucket=day\|hour&tz=&model=&client= |
requests.schema.json |
GET /v1/dashboard/requests?limit=&before=&model=&client=&finish= |
system.schema.json |
GET /v1/dashboard/system |
logs-line.schema.json |
the data: payload of one event: log on GET /v1/dashboard/logs?follow=1 |
logs-json.schema.json |
GET /v1/dashboard/logs?follow=0 |
gap.schema.json |
the data: payload of one event: gap (lines a slow subscriber missed) |
session.schema.json |
GET /v1/dashboard/session (200 body) |
| (none: Prometheus text) | GET /v1/dashboard/metrics, the /metrics page under the dashboard's access rule |
live.schema.json |
GET /v1/dashboard/live?follow=0, and the data: of one event: live on follow=1 (events after the first omit history) |
error.schema.json |
every 401, 400, 404 and 429 body |
tandem-eval.schema.json |
not an endpoint: the report tools/tandem_eval.py writes (~/pub-results/tandem-eval/<night>/report.json) and keeps on the box for the Conductor view's Compare screen (POST /v1/conductor/evals, listed by GET /v1/conductor/evals) |
Each *.example.json is a valid instance with consistent numbers (tandem-eval.example.json is made up, not a measurement; its harness says so). The request example is one finished request: 1,959 prompt tokens of which 1,536 came from the cache, 412 completion tokens of which 230 were reasoning, and a TTFT of 2,240.1 ms (0.4 ms in the queue, 2,239.7 ms of prompt).
The schemas use the JSON Schema 2020-12 subset that the backend's standard-library validator supports: type, required, properties, additionalProperties, items, enum, nullable through type arrays, and minimum. additionalProperties is false everywhere except flags.args and caches. Those two are the engine's own objects, the parsed command line and the /v1/cache/stats body.
Rules the schemas cannot express
Both sides test these instead.
usage.bucketsis dense: one bucket per local day (or hour) of the range, zero-filled, withnullpercentiles.startis RFC 3339 with the zone's own offset, so its first ten characters are the local date.- Percentiles are exact over the rows in range. Requests without a decode (errors, refusals, response-cache replays) are left out of the speed percentiles and counted in the token sums.
top_daysis the top 10 days bytotal_tokens, for day buckets only.bucket=hourallows at most 31 days and answers 400 above that.- The log stream sends
event: logwithid: <seq>, a: pingcomment every 15 s, andevent: gapwith{"dropped": N}for a slow subscriber. It resumes fromLast-Event-IDorsinceand takes at most 4 subscribers. - The live stream sends
event: liveafter every sample, the first one withhistory(up to 300 one-second samples). It takes at most 4 subscribers and drops a closed reader within a second. While a request runs and a stream is open it sendsQSE_LIVE_HZevents a second (default 4), otherwise one a second, and a: pingevery 15 s. The server encodes each tick once for all open streams.
Changes
- v1.0, first version: nine schemas, every one except
live. A log line'ssourceis an open set of bracket tags, so the schema types it as a string.engine.drafterandengine.reasoning_effortarenullfor an engine without a drafter or without a default effort, andledger.retention_daysisnullwhen the usage ledger is off. - v1.0,
liveadded: the requests in flight and the totals, once a second. A new endpoint and no changed field, socontract_versionstayed 1.0. live1.1, additive:seq(also the SSEid:), anengineblock (idle,busy,waiting_for_client,draining,starting, with KV, memory and store figures), a per-requestactivity(state, label, prefill progress, decode figures, tool, reasoning, client, continues, stop) andtimeline(the last 16 transitions),recent(the last 20 finished requests of the last 15 minutes, each with its stop sentence) andsampler(the tick's own cost).client.kindgainsopencodehere and inusageandrequests. No 1.0 field changed. With--live-activity offthe new fields arenull.tests/fixtures/live-1.1-box.jsonholds real messages recorded from a running engine.live1.1, VIS-40, additive: the live rates without the warm-up.windowsreports the settings (QSE_LIVE_DECODE_WARMUP_TOKENS50,QSE_LIVE_PREFILL_WARMUP_CHUNKS1,QSE_LIVE_NOW_WINDOW_S2,QSE_LIVE_MOSTLY_CACHED_PCT10,QSE_LIVE_MOSTLY_CACHED_TOKENS4096).nowgainsprefill_live_tpsandprefill_live_count(the running prefills' rate after their first chunk, over the chunk marks of the last 2 s),decode_live_tpsanddecode_warming(decode from each request's 50th token). A sample gainsdecode_live_tpsandprefill_live_tps, a requestdecode_live_now_tps,decode_live_avg_tps,decode_warmupandmostly_cached, andactivity.prefillgainstps_live,tps_live_avgandwarmup. Every earlier field keeps its meaning:now.prefill_tpsis still the last finished request's end-to-end rate, andtimings,usage, the ledger and/metricsdo not change. The new fields are optional in the schema, so an older server's messages still validate, and the dashboard falls back to the figures it had.live1.2, SRV-115, additive:activity.conductor, the session monitor on a request it watches (mode,acts,hidden_tokens,gave_up,supervisingwhile a hidden forward runs and for a second after it, theactionit runs, the newest decisionlastand actionlast_actionwith how long ago), null for any other request. The activity'sstatekeeps its enum; itslabelsays "Supervising: E1 continuing an empty turn (...)" meanwhile. A 1.1 reader ignores the field; the schema accepts both versions, so 1.1 messages still validate.live1.2,summaryandrequests, VIS-44, additive:activity.conductorgainsshadow(the request's shadow decisions that were not a pass) and itslastandlast_actiongainscore,tierandat_tokens(the reasoning tokens at the decision), which the Live banner prints. The liveengineblock and the summary'sliveblock gainconductor: {mode}, the session monitor's mode (off,shadow,on;--conductor rawreadsoff), for the header's indicator; a server without a monitor policy sends neither. Arequestsrow gainsconductor(mode,acts,shadow,hidden_tokens,acted) from the ledger's columns and the journal, null for a request the conductor did not run on or a ledger without the journal. Every new field is optional in the schemas, so earlier messages still validate and the version numbers stay.session1.1: the body gainscontract_versionandlogin.loginisfalsewhen the server runs with the dashboard login off (--dashboard-login off, the default); thenexpires_atisnull, there is no sign-in or sign-out (POSTandDELETEanswer 404), and the page opens without its token screen. With the login on, the body is the 1.0 body plus the two fields. The other endpoints stay at their versions.session1.1, additive: the body gainspassword_set, whether an admin password is set on the server. With none set,loginisfalsewhatever--dashboard-loginsays,POSTandDELETEanswer 404, and every admin route is open. The field is optional in the schema, so an older server's body still validates.GET /v1/dashboard/metricsadded: the same page as/metrics, under the dashboard's access rule, so the Performance view's 5-minute figures work with the login off./metricskeeps its own rule. A new endpoint and no changed field.tandem-eval1.0, OPS-43: the eval harness's report. Per run the gates and the minute of each PASS, tokens, the cached share, empty stops, the continuations the watch-only driver would have sent, the engine's requests and the conductor's acts; per suite and arm therowsthe Compare screen reads (armisrawortandem,indicativebelow n = 10); per suite thecomparison(pass rate not lower, minutes to PASS not higher); the raw engine's never-worse row for the build and theverdict.- 1.3, the proxy (P1), additive,
summary,usage,requests,systemandlive:contract_versionmay be1.3. Arequestsrow gainsbackend({id, kind, label}, null for the engine's own request),user(the proxy key's label),measured_at(engineorproxy),backend_model,usage_source(backend,none),cache_write_tokens,cost_micros,proxy_ms,retries,fallback_fromandupstream_status; a relayed row has nullprompt_ms,prefill_tps,blocks, draft counts andcache_source, and null token fields withoutusage.endpointgainsresponsesandembeddings,finish_reasongainscontent_filter,client.kindgainshermes.usagetakesbackend=(an id, orenginefor the engine's own rows) anduser=, echoes them infilterswhen set, and gainsdimensions.backendsanddimensions.users; its totals, buckets and the summary's windows gaincost_micros(null when no row had a price).summary.livegainsbackends[](state, in flight, latency, cost today) andsystemgainsproxy(embeddedorstandalone, its backends); on the standalone proxy (python -m proxy, no engine)system.engine.model,max_len,reasoning_format,cachesand the queue limits are null. A live request row gainsbackend. The Backends screen's own API,/v1/config/*, is described inproxy/config_api.pyand docs/proxy.md and has no schema here. system, OPS-47, additive:memory.limits, the engine's memory limits as the residency plan shows them (one row per serve.env key with its value, source, whether a change applies at the next request or needs a restart, and what it holds now; the memory policy's report; the last session switch; the parked session).gpu_max_allocated_bytesstays the allocator's peak since the process started, across the resets the engine now does before each prefill to measure it. Optional, so an older server's messages still validate.system1.3, P1.5, additive:hosts, the machines behind the backends (proxy/hostcollect.py). The first entry is always the server's own machine (source: local), with the server's process (backendengineorproxy) and every backend whose URL is on it; then one entry per remote collector (python -m proxy.hostcollecton the backend's host) or node_exporter and DCGM pair a backend'shostsetting names. Each host gives memory (unifiedon a GB10, where the GPU has no memory figure of its own), CPU, GPUs, disks, filesystems, network rates andprocesses: the process behind each backend's port and its children, with RSS, CPU in cores, threads, open files, uptime, the redacted command line and the GPU memory (gpu_memorysays whether it was measured).processesisnullfor an exporter, which has no per-process figures. The collector samples only when the view asks, at most every 5 s (the process scan every 15 s), and reports its own cost incost. Optional: an older server sends nohosts, and--host-collector offleaves it out.summaryandsystem1.3, P2 and P3, additive: a backend's status row (summary.live.backends[],system.proxy.backends[]) gainsengine, the backend's own engine read at layer L1 for the kindsvllmandsglang(proxy/engines.py), null for every other kind. It holds the state of the engine's/metricspage (ok,offfor a 404, which is SGLang without--enable-metrics,error,unknown), its age, the engine's version from/version(vLLM) or/server_info(SGLang) with what the latter says about speculation, cache reports and the reasoning parser, the release the name table was checked against (verified), andfiguresover the pages of the last 5 minutes: running and waiting requests, KV use, the decode and prompt rates, the prefix-cache hit rate, the cached share of the prompt tokens, TTFT p50, the queue, prefill, decode and end-to-end p50 and p90, preemptions, finished requests and speculation (vLLM from its counters: acceptance, tokens per step, acceptance per draft position; SGLang from its own window gauges). A figure the pages cannot give is null.scrape_age_sis filled for these kinds. The same families are on/metricsasqse_backend_*with abackendlabel. Optional, so earlier messages still validate.summary,systemandrequests1.3, P6, additive: the plugins inside vLLM and SGLang (plugins/,proxy/collect_api.py). A backend'sengineblock gainsplugin(null before the first batch): its state (l2while batches arrive,l1_onlyafter 60 s without one), the age of the last batch,pushorpull, the plugin's and the engine's versions, how many ledger rows got the engine's numbers (joinedby request id,approximateby time and token counts), what waited 10 minutes for its other half (unmatched) or was never asked for (untagged), the newest scheduler sample and a 5-minute window (preemptions, KV evictions, the prefix-cache hit rate, speculation).engine.layerisL2while the plugin posts. Arequestsrow gainsl2(null unless the plugin's numbers reached it):join,preemptions,engine_finish,corrupted; itsqueue_ms,prompt_ms,decode_ms,decode_tps,prefill_tpsandcached_tokensare then the engine's own.plugin_age_sis filled. Optional, so earlier messages still validate.