Build and Push Container / build-and-push (push) Successful in 38s
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
129 lines
4.9 KiB
Python
129 lines
4.9 KiB
Python
"""Test fixtures: a fake upstream (httpx-shaped) so no sockets are involved.
|
|
|
|
The app only ever calls `client.get(...)`, so a stub with an async `get` is a
|
|
complete substitute for httpx.AsyncClient — which keeps the suite network-free,
|
|
deterministic, and able to simulate outages exactly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app as app_module
|
|
|
|
GUFO_FIXTURE = """# HELP llamacpp:requests_processing how many requests are processing
|
|
# TYPE llamacpp:requests_processing gauge
|
|
llama:requests_processing 99
|
|
llamacpp:requests_processing 2
|
|
# TYPE llamacpp:requests_deferred gauge
|
|
llamacpp:requests_deferred 1
|
|
# TYPE llamacpp:kv_cache_usage_ratio gauge
|
|
llamacpp:kv_cache_usage_ratio 0.42
|
|
# TYPE llamacpp:tokens_prompt_total counter
|
|
llamacpp:tokens_prompt_total{model="turbo"} 12345
|
|
# TYPE llamacpp:tokens_generated_total counter
|
|
llamacpp:tokens_generated_total{model="turbo"} 6789
|
|
# TYPE llamacpp:prompt_tokens_per_second gauge
|
|
llamacpp:prompt_tokens_per_second 1900.5
|
|
# TYPE llamacpp:decode_tokens_per_second gauge
|
|
llamacpp:decode_tokens_per_second 31.5
|
|
"""
|
|
|
|
VLLM_FIXTURE = """# TYPE vllm:num_requests_running gauge
|
|
vllm:num_requests_running{model_name="juggernaut"} 3
|
|
# TYPE vllm:num_requests_waiting gauge
|
|
vllm:num_requests_waiting{model_name="juggernaut"} 5
|
|
# TYPE vllm:num_requests_waiting_by_reason gauge
|
|
vllm:num_requests_waiting_by_reason{model_name="juggernaut",reason="capacity"} 5
|
|
vllm:num_requests_waiting_by_reason{model_name="juggernaut",reason="defragmentation"} 0
|
|
# TYPE vllm:gpu_cache_usage gauge
|
|
vllm:gpu_cache_usage{model_name="juggernaut"} 0.61
|
|
# TYPE vllm:prompt_tokens_total counter
|
|
vllm:prompt_tokens_total{model_name="juggernaut"} 1000000
|
|
# TYPE vllm:generation_tokens_total counter
|
|
vllm:generation_tokens_total{model_name="juggernaut"} 250000
|
|
# TYPE vllm:time_to_first_token_seconds histogram
|
|
vllm:time_to_first_token_seconds_bucket{le="0.1",model_name="juggernaut"} 2
|
|
vllm:time_to_first_token_seconds_bucket{le="1",model_name="juggernaut"} 25
|
|
vllm:time_to_first_token_seconds_bucket{le="+Inf",model_name="juggernaut"} 30
|
|
vllm:time_to_first_token_seconds_sum{model_name="juggernaut"} 30.0
|
|
vllm:time_to_first_token_seconds_count{model_name="juggernaut"} 30
|
|
# TYPE vllm:time_per_output_token_seconds histogram
|
|
vllm:time_per_output_token_seconds_bucket{le="0.01",model_name="juggernaut"} 1500
|
|
vllm:time_per_output_token_seconds_bucket{le="+Inf",model_name="juggernaut"} 3000
|
|
vllm:time_per_output_token_seconds_sum{model_name="juggernaut"} 2.4
|
|
vllm:time_per_output_token_seconds_count{model_name="juggernaut"} 3000
|
|
"""
|
|
|
|
SLOTS_FIXTURE = (
|
|
'{"slots":[{"id":0,"state":1,"id_task":12,"n_prompt_tokens":48,'
|
|
'"n_prompt_tokens_cache":40,"n_prompt_tokens_processed":48,'
|
|
'"next_token":[{"id":29871,"text":" ","prob":0.98,"n_decoded":3,"n_remain":127}],'
|
|
'"n_remain":128}]}'
|
|
)
|
|
|
|
|
|
def response(
|
|
status: int = 200,
|
|
content: bytes = b"",
|
|
content_type: str = "text/plain; version=0.0.4",
|
|
) -> httpx.Response:
|
|
# A real httpx.Response rather than a mock: .text / .content / .reason_phrase
|
|
# then behave exactly as app.fetch() expects. Attaching a request is mandatory
|
|
# before a body is read.
|
|
return httpx.Response(
|
|
status,
|
|
content=content,
|
|
headers={"content-type": content_type},
|
|
request=httpx.Request("GET", "http://upstream.test"),
|
|
)
|
|
|
|
|
|
class FakeUpstream:
|
|
"""Stands in for httpx.AsyncClient and records what was requested."""
|
|
|
|
def __init__(self) -> None:
|
|
self.routes: dict[str, object] = {}
|
|
self.calls: list[tuple[str, dict[str, str]]] = []
|
|
|
|
def route(self, url: str, result: object) -> None:
|
|
self.routes[url] = result
|
|
|
|
async def get(self, url: str, headers: dict[str, str] | None = None) -> httpx.Response:
|
|
self.calls.append((url, dict(headers or {})))
|
|
result = self.routes.get(url)
|
|
if isinstance(result, Exception):
|
|
raise result
|
|
if result is None:
|
|
return response(404, b'{"detail":"Not Found"}', "application/json")
|
|
return result # type: ignore[return-value]
|
|
|
|
@property
|
|
def requested_urls(self) -> list[str]:
|
|
return [url for url, _headers in self.calls]
|
|
|
|
@property
|
|
def methods(self) -> set[str]:
|
|
# the only recorded verb is GET; anything else would not reach the stub
|
|
return {"GET"} if self.calls else set()
|
|
|
|
|
|
@pytest.fixture
|
|
def upstream(monkeypatch) -> FakeUpstream:
|
|
fake = FakeUpstream()
|
|
monkeypatch.setattr(app_module, "GUFO_BASE_URL", "http://gufo.test")
|
|
monkeypatch.setattr(app_module, "VLLM_BASE_URL", "http://vllm.test")
|
|
monkeypatch.setattr(app_module, "GUFO_API_KEY", "")
|
|
monkeypatch.setattr(app_module, "VLLM_API_KEY", "")
|
|
return fake
|
|
|
|
|
|
@pytest.fixture
|
|
def client(upstream: FakeUpstream):
|
|
"""TestClient without `with` => lifespan skipped, so we inject the stub client."""
|
|
test_client = TestClient(app_module.app)
|
|
app_module.app.state.client = upstream
|
|
return test_client
|