Files
llm-monitor/tests/test_rates.py
T
ducoterra 48bc92b9af
Build and Push Container / build-and-push (push) Successful in 38s
initial import: read-only gufo + vLLM dashboard
Single-file FastAPI proxy plus a no-CDN vanilla-JS page for watching a gufo
server and a vLLM server side by side: one Prometheus parser (class Prom) with
per-engine field maps, measured Δcounter/Δt rates for engines that export no
rate gauge, and strict read-only GET-only routing. Engines are opt-in via
*_BASE_URL — unset means no panel, no poll, and a 404 {"configured": false}
without dialling.

Includes the container image and CI so the dashboard can be deployed as an
image rather than a checkout:

- Containerfile: uv-locked two-stage build on python:3.14-slim, non-root
  (uid 10001), binds 8090 (8000 is vLLM's port in this fleet), healthcheck on
  /api/config so a dead engine never marks the dashboard unhealthy. No frontend
  build stage — static/index.html is bundler-free by design and is copied as-is.
- .gitea/workflows/build-and-push.yml: publishes
  gitea.reeseapps.com/Experimental/llm-monitor on main + release tags.

114 tests, no network and no real engines required.

LLM-Generated: true
2026-10-16 12:49:45 -04:00

300 lines
13 KiB
Python

"""Derived throughput: Δcounter/Δt and the lifetime-average fallback.
The deployed vLLM exports no rate gauge of any kind (verified: zero series with a
throughput/rate name), so prompt tok/s can only be *computed*. These tests pin the
precedence — native gauge > measured Δcounter > estimate > n/a — and the counter-reset
handling, because a naive delta shows up as a giant negative spike after an engine
restart.
"""
from __future__ import annotations
import pytest
from app import RateEstimator, parse_gufo_metrics, parse_vllm_metrics
# gufo's generation-speed gauge is ONE request's last decode pass; the counter is every
# slot's tokens. 30 tok/s on the gauge with 60 tokens landing per second = two slots.
GUFO_SCRAPE = """
llamacpp:requests_processing 2
llamacpp:tokens_predicted_total 1000
llamacpp:predicted_tokens_seconds 30
"""
COUNTERS = """
vllm:num_requests_running{engine="0"} 2
vllm:prompt_tokens_total{engine="0"} 1000
vllm:generation_tokens_total{engine="0"} 400
vllm:inter_token_latency_seconds_sum 0.04
vllm:inter_token_latency_seconds_count 2
"""
def test_first_scrape_has_nothing_to_diff_against() -> None:
rates = RateEstimator()
assert rates.rate("k", 100.0, now=10.0) == (None, None)
def test_second_scrape_measures_the_delta() -> None:
rates = RateEstimator()
rates.rate("k", 100.0, now=10.0)
rate, window = rates.rate("k", 130.0, now=11.0)
assert rate == pytest.approx(30.0)
assert window == pytest.approx(1.0)
def test_counter_reset_yields_none_not_a_negative_spike() -> None:
rates = RateEstimator()
rates.rate("k", 5000.0, now=10.0)
assert rates.rate("k", 12.0, now=11.0) == (None, None) # engine restarted
# and it recovers on the following scrape
assert rates.rate("k", 22.0, now=12.0)[0] == pytest.approx(10.0)
def test_absent_counter_and_zero_window_are_none() -> None:
rates = RateEstimator()
assert rates.rate("k", None, now=1.0) == (None, None)
rates.rate("k", 1.0, now=5.0)
assert rates.rate("k", 2.0, now=5.0) == (None, None) # dt == 0
assert rates.rate("other", 1.0, now=6.0) == (None, None) # unknown key
def test_rate_smooths_over_the_span_once_enough_samples_exist() -> None:
# a bare 1s delta jitters (prompts arrive in bursts); the span averages it out
rates = RateEstimator(span=10.0)
rates.rate("k", 0.0, now=0.0) # outside the span by t=12
rates.rate("k", 100.0, now=5.0)
rate, window = rates.rate("k", 400.0, now=12.0)
assert window == pytest.approx(7.0) # measured from the oldest in-span sample
assert rate == pytest.approx(300.0 / 7.0)
def test_only_two_in_window_samples_still_produce_a_shorter_rate() -> None:
rates = RateEstimator(span=10.0)
rates.rate("k", 0.0, now=0.0)
rates.rate("k", 100.0, now=20.0) # first sample is long gone
rate, window = rates.rate("k", 150.0, now=21.0)
assert window == pytest.approx(1.0)
assert rate == pytest.approx(50.0)
def test_history_is_bounded() -> None:
rates = RateEstimator(span=1.0, max_samples=8)
for i in range(50):
rates.rate("k", float(i), now=float(i))
assert len(rates._samples["k"]) <= 8
def test_keys_are_independent() -> None:
rates = RateEstimator()
rates.rate("a", 0.0, now=0.0)
rates.rate("b", 0.0, now=0.0)
assert rates.rate("a", 10.0, now=1.0)[0] == pytest.approx(10.0)
assert rates.rate("b", 4.0, now=1.0)[0] == pytest.approx(4.0)
# --------------------------------------------------------------------------- #
# gufo aggregate decode rate: the counter adds slots, the gauge cannot
# --------------------------------------------------------------------------- #
def test_gufo_decode_rate_adds_every_slot_generating_at_once() -> None:
"""The whole point: two slots at 30 tok/s must read 60, not 30.
gufo's `predicted_tokens_seconds` is a single unlabeled gauge holding one request's
last decode pass, so it can never sum concurrency. `tokens_predicted_total` advances
once per token from every slot, so Δ/Δt is the server-aggregate.
"""
rates = RateEstimator()
parse_gufo_metrics(GUFO_SCRAPE, rates, now=100.0)
m = parse_gufo_metrics(GUFO_SCRAPE.replace("1000", "1060"), rates, now=101.0)
d = m["decode_tokens_per_second"]
assert d["value"] == pytest.approx(60.0)
assert d["derived"] is True
assert d["window"] == pytest.approx(1.0)
assert d["source"] == "computed:Δllamacpp:tokens_predicted_total/Δt"
# the single-request gauge stays exposed, untouched, as the native reference
assert m["decode_tokens_per_second_native"]["value"] == pytest.approx(30.0)
def test_gufo_first_scrape_falls_back_to_the_gauge_not_na() -> None:
# nothing to diff against yet, but the scrape can still answer
m = parse_gufo_metrics(GUFO_SCRAPE, RateEstimator(), now=10.0)
assert m["decode_tokens_per_second"] == {
"value": 30.0,
"source": "llamacpp:predicted_tokens_seconds",
"derived": False,
}
def test_gufo_idle_reads_zero_not_the_stale_gauge() -> None:
# an engine that generated nothing in the window is at 0 tok/s; the gauge would
# happily keep showing the last session's speed forever
rates = RateEstimator()
parse_gufo_metrics(GUFO_SCRAPE, rates, now=0.0)
m = parse_gufo_metrics(GUFO_SCRAPE, rates, now=1.0)
assert m["decode_tokens_per_second"]["value"] == 0.0
assert m["decode_tokens_per_second_native"]["value"] == pytest.approx(30.0)
def test_gufo_counter_reset_falls_back_instead_of_going_negative() -> None:
rates = RateEstimator()
parse_gufo_metrics(GUFO_SCRAPE, rates, now=0.0)
# engine restarted: the counter dropped. Never -995 tok/s.
m = parse_gufo_metrics(GUFO_SCRAPE.replace("1000", "5"), rates, now=1.0)
assert m["decode_tokens_per_second"]["value"] == pytest.approx(30.0)
assert m["decode_tokens_per_second"]["derived"] is False
# and it recovers on the following scrape
m = parse_gufo_metrics(GUFO_SCRAPE.replace("1000", "25"), rates, now=2.0)
assert m["decode_tokens_per_second"]["value"] == pytest.approx(20.0)
def test_gufo_alias_rename_starts_a_fresh_window_instead_of_a_fake_rate() -> None:
# the estimator keys off the RESOLVED series name, so a renamed counter cannot diff
# against a different series' history (and a flapping alias cannot invent throughput)
rates = RateEstimator()
parse_gufo_metrics("llamacpp:tokens_generated_total 1000\n", rates, now=0.0)
m = parse_gufo_metrics("llamacpp:tokens_predicted_total 1000\n", rates, now=1.0)
assert m["decode_tokens_per_second"]["value"] is None
def test_gufo_prompt_rate_is_measured_and_adds_slots_too() -> None:
"""Prefill gets the same treatment as decode, so a cross-engine prompt sum is additive.
gufo's `prompt_tokens_seconds` is one request's last prefill pass; the counter is every
slot's newly-computed prompt tokens.
"""
text = "llamacpp:prompt_tokens_total 1000\nllamacpp:prompt_tokens_seconds 700\n"
rates = RateEstimator()
parse_gufo_metrics(text, rates, now=0.0)
m = parse_gufo_metrics(text.replace("1000", "3400"), rates, now=2.0)
p = m["prompt_tokens_per_second"]
assert p["value"] == pytest.approx(1200.0)
assert p["source"] == "computed:Δllamacpp:prompt_tokens_total/Δt"
assert p["derived"] is True and p["window"] == pytest.approx(2.0)
# the one-request gauge stays exposed next to it
assert m["prompt_tokens_per_second_native"]["value"] == pytest.approx(700.0)
def test_gufo_prompt_first_scrape_falls_back_to_the_gauge() -> None:
text = "llamacpp:prompt_tokens_total 1000\nllamacpp:prompt_tokens_seconds 700\n"
m = parse_gufo_metrics(text, RateEstimator(), now=5.0)
assert m["prompt_tokens_per_second"] == {
"value": 700.0,
"source": "llamacpp:prompt_tokens_seconds",
"derived": False,
}
def test_gufo_prompt_and_decode_keep_separate_windows() -> None:
# one estimator, but the two counters must not read each other's history
rates = RateEstimator()
base = "llamacpp:prompt_tokens_total 1000\nllamacpp:tokens_predicted_total 500\n"
parse_gufo_metrics(base, rates, now=0.0)
m = parse_gufo_metrics(base.replace("1000", "3000").replace("500", "550"), rates, now=2.0)
assert m["prompt_tokens_per_second"]["value"] == pytest.approx(1000.0)
assert m["decode_tokens_per_second"]["value"] == pytest.approx(25.0)
def test_gufo_prompt_idle_reads_zero_not_the_stale_gauge() -> None:
text = "llamacpp:prompt_tokens_total 1000\nllamacpp:prompt_tokens_seconds 700\n"
rates = RateEstimator()
parse_gufo_metrics(text, rates, now=0.0)
m = parse_gufo_metrics(text, rates, now=1.0)
assert m["prompt_tokens_per_second"]["value"] == 0.0
assert m["prompt_tokens_per_second_native"]["value"] == pytest.approx(700.0)
def test_gufo_parse_without_rates_stays_pure() -> None:
assert parse_gufo_metrics(GUFO_SCRAPE) == parse_gufo_metrics(GUFO_SCRAPE)
assert parse_gufo_metrics(GUFO_SCRAPE)["decode_tokens_per_second"]["derived"] is False
def test_both_engines_share_one_estimator_without_cross_reading() -> None:
# RATES is one object for both engines; the series names keep the histories apart
rates = RateEstimator()
parse_gufo_metrics(GUFO_SCRAPE, rates, now=0.0)
parse_vllm_metrics(COUNTERS, rates, now=0.0)
g = parse_gufo_metrics(GUFO_SCRAPE.replace("1000", "1040"), rates, now=1.0)
v = parse_vllm_metrics(COUNTERS.replace("400", "410"), rates, now=1.0)
assert g["decode_tokens_per_second"]["value"] == pytest.approx(40.0)
assert v["decode_tokens_per_second"]["source"] == "computed:Δvllm:generation_tokens_total/Δt"
assert v["decode_tokens_per_second"]["value"] == pytest.approx(10.0)
# --------------------------------------------------------------------------- #
# parser integration
# --------------------------------------------------------------------------- #
def test_prompt_and_decode_use_measured_deltas_when_rates_are_supplied() -> None:
rates = RateEstimator()
parse_vllm_metrics(COUNTERS, rates, now=100.0) # seeds the previous sample
m = parse_vllm_metrics(
COUNTERS.replace("1000", "1500").replace("400", "450"), rates, now=101.0
)
p = m["prompt_tokens_per_second"]
d = m["decode_tokens_per_second"]
assert p["derived"] is True and p["window"] == pytest.approx(1.0)
assert p["value"] == pytest.approx(500.0)
assert p["source"] == "computed:Δvllm:prompt_tokens_total/Δt"
assert d["value"] == pytest.approx(50.0)
assert d["source"] == "computed:Δvllm:generation_tokens_total/Δt"
def test_first_scrape_falls_back_to_the_lifetime_average() -> None:
# no previous sample to diff against, but prefill time + prompt length still answer
text = (
"vllm:prompt_tokens_total 1000\n"
"vllm:request_prompt_tokens_sum 8000\nvllm:request_prompt_tokens_count 2\n"
"vllm:request_prefill_time_seconds_sum 1.6\nvllm:request_prefill_time_seconds_count 2\n"
)
m = parse_vllm_metrics(text, RateEstimator(), now=10.0)
p = m["prompt_tokens_per_second"]
assert p["value"] == pytest.approx(5000.0) # 4000 tok mean / 0.8 s mean
assert p["source"] == "computed:mean(request_prompt_tokens)/mean(request_prefill_time_seconds)"
assert m["prefill_tokens_per_second_lifetime"]["value"] == pytest.approx(5000.0)
def test_measured_delta_beats_the_lifetime_average() -> None:
rates = RateEstimator()
text = (
"vllm:prompt_tokens_total 1000\n"
"vllm:request_prompt_tokens_sum 8000\nvllm:request_prompt_tokens_count 2\n"
"vllm:request_prefill_time_seconds_sum 1.6\nvllm:request_prefill_time_seconds_count 2\n"
)
parse_vllm_metrics(text, rates, now=0.0)
m = parse_vllm_metrics(text.replace("1000", "1200"), rates, now=1.0)
assert m["prompt_tokens_per_second"]["source"].startswith("computed:Δ")
assert m["prompt_tokens_per_second"]["value"] == pytest.approx(200.0)
def test_native_gauge_still_wins_over_any_derivation() -> None:
m = parse_vllm_metrics(
"vllm:prompt_throughput 1234.5\nvllm:prompt_tokens_total 10\n",
RateEstimator(),
now=1.0,
)
assert m["prompt_tokens_per_second"] == {
"value": 1234.5,
"source": "vllm:prompt_throughput",
"derived": False,
}
def test_without_rates_the_parse_stays_pure() -> None:
# parse(text) alone must not depend on wall-clock or hidden state
a = parse_vllm_metrics(COUNTERS)
b = parse_vllm_metrics(COUNTERS)
assert a == b
assert a["prompt_tokens_per_second"]["value"] is None # no prefill series here
assert a["prefill_tokens_per_second_lifetime"]["value"] is None
# decode still answers via TPOT, which is stateless
assert a["decode_tokens_per_second"]["source"] == "computed:running/tpot"
def test_prompt_tok_s_is_never_a_silent_zero() -> None:
"""The whole point: an engine that IS working must not render n/a because it lacks
a gauge, and an idle engine must show 0 rather than a stale estimate."""
rates = RateEstimator()
text = "vllm:prompt_tokens_total 500\n"
parse_vllm_metrics(text, rates, now=0.0)
m = parse_vllm_metrics(text, rates, now=1.0)
assert m["prompt_tokens_per_second"]["value"] == 0.0 # genuinely idle, not unknown