Files
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

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