Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.24.0] - 2026-06-20

### Added

- **`model overview --live` — a live fleet dashboard.** `overview` was a static
description; `--live` now probes the running deployment and reports the five
"what is it doing right now" views: **online** (per-backend health), **offered**
(served + candidate models, task families, the endpoint list), **busy**
(in-flight / queued requests), **usage** (cumulative prompt/generation tokens and
finished requests by reason), and **endpoints**. It is read-only and HTTP-only —
it works against a local deployment or a `model tunnel` hostname alike, and
degrades gracefully when a backend or its metrics is unreachable.
- **Gateway `GET /status`** — a model-gear-native JSON aggregate. The fleet's
backends are internal-only, so the gateway fans out to each one's `/health` +
`/metrics` and returns `{object: "model-gear.fleet_status", default_model,
busy: {running, waiting}, backends: [...], endpoints: [...]}`. This is the source
`model overview --live` reads in the fleet (a bare single-model server is read
directly from its `/metrics` + `/health`).
- **`model_gear._metrics`** — a small stdlib-only helper that parses vLLM's
Prometheus `/metrics` (running/waiting, prompt/generation tokens,
`request_success_total` by finish reason, KV-cache usage) and best-effort HTTP
probes that never raise.

### Changed

### Fixed

## [0.23.0] - 2026-06-20

### Added
Expand Down
18 changes: 17 additions & 1 deletion docs/gateway-fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,16 @@ A pure-stdlib (`http.server` + `http.client`, no third-party deps) reverse proxy
`/v1/embeddings` (the embedding gear), `/v1/rerank` + `/v1/score` (the reranker
gear), `/v1/models` (OpenAI-standard, lists the loaded backend(s)),
`/v1/models/supported` (the full supported-model catalog — every gear you can
change to, each flagged `loaded` / `default`), `/health` (gateway liveness).
change to, each flagged `loaded` / `default`), `/health` (gateway liveness), and
`/status` (the live fleet aggregate — see below).
See [Supported catalog vs. warm backends](#supported-catalog-vs-warm-backends)
for what `/v1/models` and `/v1/models/supported` each mean.
- **`GET /status`** — a model-gear-native (non-OpenAI) JSON aggregate the gateway
fans out to each backend's `/health` + `/metrics` and returns as
`{object: "model-gear.fleet_status", default_model, busy: {running, waiting},
backends: [{name, task, served_name, health, metrics}], endpoints}`. The backends
are internal-only, so the gateway is the only thing that can see them — this is
the source for `model overview --live`.

The gateway image is built from the scaffolded `Dockerfile.gateway`
(`pip install model-gear==${MODEL_GEAR_VERSION}`, as a non-root user); `model init
Expand Down Expand Up @@ -128,6 +135,7 @@ Mnemonic: the catalog is *what's on the menu (and which dishes we've cooked)*;
model init --fleet --apply # scaffold compose + .env + Dockerfile.gateway
model fleet up --apply # docker compose up -d --build, wait for gateway /health
model fleet status # each container's state + gateway /health + /v1/models
model overview --live # live dashboard: online / offered / busy + usage + endpoints
model fleet down --apply # docker compose down
```

Expand All @@ -137,6 +145,14 @@ model fleet down --apply # docker compose down
backend(s) (`/v1/models`); for the full set you can switch to, use
`model overview --list` / `/v1/models/supported` (see above).

`model overview --live` is the read-only **live dashboard**: it reads the gateway
`/status` (or, against a bare single-model server, that server's `/metrics` +
`/health`) and prints what is **online** (per-backend health), **offered**
(models, task families, endpoints), **busy** (in-flight / queued requests), and
cumulative **usage** (prompt/generation tokens, finished requests by reason). HTTP-only, so it
works against a local deployment or a `model tunnel` hostname alike; it degrades
gracefully when a backend or its metrics is unreachable.

**`model switch` does not drive the fleet** — it rewrites the single-model
`VLLM_*` keys. Change the fleet primary by editing the fleet `.env`
(`PRIMARY_MODEL` and its `PRIMARY_SERVED_NAME` / `PRIMARY_GPU_MEM_UTIL`
Expand Down
128 changes: 128 additions & 0 deletions model_gear/_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Parse vLLM Prometheus ``/metrics`` + probe a backend's live state (stdlib only).

Shared by the gateway's ``/status`` fan-out and ``model overview --live``. The
parser is pure; the probes are best-effort and **never raise** — an unreachable
backend folds into a structured result so the live view degrades gracefully
instead of erroring. vLLM serves ``/metrics`` and ``/health`` unauthenticated, so
no API key is needed for either.
"""

from __future__ import annotations

import json
import urllib.error
import urllib.request

# The handful of vLLM series the live view reports. "busy" = running/waiting now;
# "usage" = cumulative tokens + finished requests by reason. Summed across the
# engine/model labels vLLM attaches (a single backend may expose >1 engine).
_RUNNING = "vllm:num_requests_running"
_WAITING = "vllm:num_requests_waiting"
_KV = "vllm:gpu_cache_usage_perc"
_PROMPT_TOK = "vllm:prompt_tokens_total"
_GEN_TOK = "vllm:generation_tokens_total"
_SUCCESS = "vllm:request_success_total"


def _label(label_block: str, key: str) -> str | None:
"""Extract ``key="value"`` from a Prometheus ``{...}`` label block (best-effort)."""
needle = f'{key}="'
start = label_block.find(needle)
if start < 0:
return None
start += len(needle)
end = label_block.find('"', start)
return label_block[start:end] if end > start else None


def parse_metrics(text: str) -> dict:

Check failure on line 38 in model_gear/_metrics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 22 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=agentculture_model-gear&issues=AZ7j9QkD4ZKJmrimZks6&open=AZ7j9QkD4ZKJmrimZks6&pullRequest=52
"""Reduce a vLLM ``/metrics`` exposition to the live-view numbers.

Returns ints for counts/tokens and a ``by_finish_reason`` map; ``kv_cache_usage``
(0..1) is included only when the gauge is present. Unknown/malformed lines are
skipped, so a partial scrape still yields what it can.
"""
running = waiting = prompt_tok = gen_tok = 0.0
kv: float | None = None
by_reason: dict[str, float] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
try:
left, value = line.rsplit(" ", 1)
val = float(value)
except ValueError:
continue
brace = left.find("{")
name = left[:brace] if brace >= 0 else left
labels = left[brace:] if brace >= 0 else ""
if name == _RUNNING:
running += val
elif name == _WAITING:
waiting += val
elif name == _KV:
kv = val if kv is None else max(kv, val)
elif name == _PROMPT_TOK:
prompt_tok += val
elif name == _GEN_TOK:
gen_tok += val
elif name == _SUCCESS:
reason = _label(labels, "finished_reason") or "?"
by_reason[reason] = by_reason.get(reason, 0.0) + val
out = {
"running": int(running),
"waiting": int(waiting),
"prompt_tokens": int(prompt_tok),
"generation_tokens": int(gen_tok),
"requests_succeeded": int(sum(by_reason.values())),
"by_finish_reason": {k: int(v) for k, v in by_reason.items() if v},
}
if kv is not None:
out["kv_cache_usage"] = round(kv, 3)
return out
Comment thread
OriNachum marked this conversation as resolved.


def http_get_text(url: str, *, timeout: float = 3.0) -> str | None:
"""Best-effort GET → body text, or ``None`` if unreachable / non-2xx. Never raises."""
try:
with urllib.request.urlopen(
url, timeout=timeout
) as r: # nosec B310 - http(s) only, fixed scheme
if 200 <= r.status < 300:
return r.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, OSError, ValueError):

Check warning on line 94 in model_gear/_metrics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant Exception class; it derives from another which is already caught.

See more on https://sonarcloud.io/project/issues?id=agentculture_model-gear&issues=AZ7j9QkD4ZKJmrimZks7&open=AZ7j9QkD4ZKJmrimZks7&pullRequest=52
return None
return None


def http_get_json(url: str, *, timeout: float = 3.0) -> dict | None:
"""Best-effort GET → parsed JSON dict, or ``None`` (unreachable / non-dict). Never raises."""
text = http_get_text(url, timeout=timeout)
if text is None:
return None
try:
data = json.loads(text)
except (ValueError, TypeError):
return None
return data if isinstance(data, dict) else None


def health_ok(base_url: str, *, timeout: float = 3.0) -> bool:
"""True when ``<base_url>/health`` returns 2xx."""
return http_get_text(base_url.rstrip("/") + "/health", timeout=timeout) is not None


def probe_backend(base_url: str, *, timeout: float = 3.0) -> dict:
"""Live ``{health, metrics}`` for one backend base URL (best-effort, never raises).

``health`` is ``"ok"`` / ``"unreachable"``; ``metrics`` is the parsed dict, or
``None`` when ``/metrics`` is unreachable (an engine can be loading or down).
"""
base = base_url.rstrip("/")
healthy = health_ok(base, timeout=timeout)
raw = http_get_text(base + "/metrics", timeout=timeout)
return {
"health": "ok" if healthy else "unreachable",
"metrics": parse_metrics(raw) if raw is not None else None,
}
41 changes: 39 additions & 2 deletions model_gear/cli/_commands/overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
from pathlib import Path

from model_gear.catalog import supported_models
from model_gear.cli import _live, _runtime_ops
from model_gear.cli._commands.whoami import report
from model_gear.cli._errors import ModelGearError
from model_gear.cli._output import emit_result
from model_gear.runtime import _compose, _env

_VERBS = [
"init [TARGET] — scaffold a deployment dir (--fleet for the gateway; dry-run; --apply)",
Expand All @@ -32,7 +35,7 @@
"logs — read-only: list/tail the durable vLLM logs that survive restart (issue #50)",
"assess — correctness probes against the served model",
"benchmark — decode throughput + prefill latency",
"overview — this snapshot (--current / --list to filter)",
"overview — this snapshot (--current / --list to filter; --live for the running fleet)",
"whoami — tool, machine, served model, container health",
"explain <path> — markdown docs for a topic",
"doctor — diagnose docker / compose / .env / health",
Expand Down Expand Up @@ -174,12 +177,36 @@
emit_result(render_text(subject, sections), json_mode=False)


def _served_name(args: argparse.Namespace) -> str | None:
"""Configured served-model name from ``.env`` (best-effort; None if unscaffolded).

Resolved independently of the probed port so ``overview --live --port N`` still
labels the model from the deployment's ``.env``.
"""
try:
deploy_dir = _compose.resolve_deployment_dir(getattr(args, "compose_dir", None))
except ModelGearError:
return None
env_path = deploy_dir / _compose.ENV_FILE
return (
_env.read_env(env_path, "VLLM_SERVED_NAME") or _env.read_env(env_path, "VLLM_MODEL") or None
)


def cmd_overview(args: argparse.Namespace) -> int:

Check failure on line 196 in model_gear/cli/_commands/overview.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to not always return the same value.

See more on https://sonarcloud.io/project/issues?id=agentculture_model-gear&issues=AZ7j9Qha4ZKJmrimZks5&open=AZ7j9Qha4ZKJmrimZks5&pullRequest=52
json_mode = bool(getattr(args, "json", False))
if getattr(args, "live", False):
# Live dashboard: probe the running deployment (gateway /status or a single
# vLLM /metrics) for online/offered/busy/usage/endpoints. HTTP-only.
port, _ = _runtime_ops.resolve_port_soft(args)
sections = _live.live_sections(port, _served_name(args))
emit_overview("model-gear (live)", sections, json_mode=json_mode)
return 0
sections = tool_sections(
current=bool(getattr(args, "current", False)),
listing=bool(getattr(args, "list", False)),
)
emit_overview("model-gear", sections, json_mode=bool(getattr(args, "json", False)))
emit_overview("model-gear", sections, json_mode=json_mode)
return 0


Expand All @@ -201,5 +228,15 @@
action="store_true",
help="Show only the supported-model catalog (the gears you can switch to).",
)
p.add_argument(
"--live",
action="store_true",
help="Live dashboard: what is online / offered / busy + usage + endpoints "
"(probes the running deployment).",
)
p.add_argument("--port", type=int, help="Host port to probe with --live (default: VLLM_PORT).")
p.add_argument(
"--compose-dir", help="Deployment dir (default: $MODEL_GEAR_DIR or ~/.model-gear)."
)
p.add_argument("--json", action="store_true", help="Emit structured JSON.")
p.set_defaults(func=cmd_overview)
Loading
Loading