docs: document local vision fallback and the monitoring stack #5

Merged
alee merged 1 commits from neuralwatt-router-service into main 2026-08-24 01:03:59 +00:00
2 changed files with 169 additions and 2 deletions

View File

@@ -242,10 +242,43 @@ rather than from months of history.
StreamingResponse's generator is resumed in a fresh copy of the caller's StreamingResponse's generator is resumed in a fresh copy of the caller's
context, so the ContextVar cannot reach it — the path all agent traffic context, so the ContextVar cannot reach it — the path all agent traffic
takes. Level from `logging.level`, overridden by `LLM_ROUTER_LOG_LEVEL`. takes. Level from `logging.level`, overridden by `LLM_ROUTER_LOG_LEVEL`.
- `metrics.py` / `GET /metrics` — read-only observability aggregation. Keeps
the dashboard helpers outside `dispatcher.py` to avoid an import cycle:
`dispatcher` imports `metrics` for `/health` and `/metrics`, so `metrics`
takes `(conn, cfg)` arguments and never imports `dispatcher`. Returns quota
burn against `plan_kwh_per_period`, scoring coverage warnings, recent
`route_decisions`, per-model aggregates over `energy_observations`,
verification verdict mix, and top proficiency by category.
- `tui.py` — Textual terminal dashboard over `GET /metrics`. A foreground
entrypoint (`python tui.py`), not a service. `textual` is imported only
here, so the router's dispatch path has no UI dependency.
- `router_cli.py` — one-shot `/route` probe. Posts a task to the running router
and prints the decision tree, or emits raw JSON with `--json`. Spends no
quota because it only routes.
- `tests/` — 356 tests across 19 files, all passing, all offline. Verified on - `tests/` — 356 tests across 19 files, all passing, all offline. Verified on
Python 3.10 and 3.14; nothing declares `requires-python`, so 3.10 is the Python 3.10 and 3.14; nothing declares `requires-python`, so 3.10 is the
tested floor rather than a promised one. tested floor rather than a promised one.
### Monitoring: route_decisions persistence
`route_decisions` is the newest observability table (not a scoring input). It
records one row per routing decision — `route` \| `dispatch` \| `chat` |
`passthrough` \| `local_vision` — with category, tier, selected model, runners
up, estimated cost/proficiency, classifier latency, rejection reason, and
request feature flags (`tools`, `images`, `json_mode`, `streamed`). It stores
only a hashed `session_key`; `session_dir`, prompts, and answers are excluded,
and a test enforces that the write path never stores conversation text.
The write is gated by `logging.log_route_decisions` and is **best-effort**: a
failed write is logged and swallowed, because a decision record is worth
having but never worth failing or slowing a request for. The table is created
from code at module load (`_ensure_route_decisions_table`) and again on every
write (`ensure_route_decisions` inside `persist_route_decision`), mirroring
the `proficiency_store.ensure_columns` migration pattern: `schema.sql` is
`CREATE TABLE IF NOT EXISTS`, but a live `router.db` predating this table
needs the code-side migration. Both the module-load hook and the write-path
guarantee are idempotent and leave existing rows intact.
### Request-side capability gates ### Request-side capability gates
The router treats some capabilities as hard filters, read directly from the The router treats some capabilities as hard filters, read directly from the

138
README.md
View File

@@ -224,6 +224,9 @@ restarts on boot shouldn't change its dependency tree underneath itself.
| **`leaderboard.py`** | Imports `leaderboards.yaml` priors into `proficiency`; `--check` reports gaps | Yes (DB) | | **`leaderboard.py`** | Imports `leaderboards.yaml` priors into `proficiency`; `--check` reports gaps | Yes (DB) |
| **`iteration.py`** | Retry budget per tier, and matching the retry to the failure kind | Pure | | **`iteration.py`** | Retry budget per tier, and matching the retry to the failure kind | Pure |
| **`config.py`** | YAML loader + Pydantic validators (blend weights sum to 1, valid tiers, endpoints separately addressable) | Yes (file) | | **`config.py`** | YAML loader + Pydantic validators (blend weights sum to 1, valid tiers, endpoints separately addressable) | Yes (file) |
| **`metrics.py`** | Read-only aggregations for `/health` and `GET /metrics`: quota burn, coverage, recent decisions, per-model totals, verdict mix, top proficiency | Yes (DB) |
| **`tui.py`** | Textual terminal dashboard over `GET /metrics`; foreground tool, not a service | Yes (network) |
| **`router_cli.py`** | One-shot routing probe: POSTs to `/route` and prints the decision tree | Yes (network) |
## Decision Table Schema (SQLite) ## Decision Table Schema (SQLite)
@@ -358,8 +361,8 @@ rows, so repeated sweeps accumulate into a median-across-time.
| `id` | INTEGER | Autoincrement | | `id` | INTEGER | Autoincrement |
| `model_id` / `provider` | TEXT | | | `model_id` / `provider` | TEXT | |
| `task_category` | TEXT | Optional (prose answers may lack a category) | | `task_category` | TEXT | Optional (prose answers may lack a category) |
| `kind` | TEXT | `structural` \| `local_llm` | | `kind` | TEXT | `structural` \| `local_llm` \| `client_outcome` |
| `verdict` | TEXT | `ok` \| `truncated` \| `malformed` \| `unverifiable` | | `verdict` | TEXT | `ok` \| `truncated` \| `malformed` \| `unverifiable` \| `succeeded` \| `failed` |
| `detail` | TEXT | Human-readable reason | | `detail` | TEXT | Human-readable reason |
| `completion_tokens` | INTEGER | Wasted answer cost, for payoff sum | | `completion_tokens` | INTEGER | Wasted answer cost, for payoff sum |
| `observed_at` | TEXT | ISO8601 | | `observed_at` | TEXT | ISO8601 |
@@ -371,6 +374,44 @@ applies a 0.0 sample per failure to `proficiency`, and marks them
`applied_at` for idempotency. `unverifiable` is recorded but not treated as a `applied_at` for idempotency. `unverifiable` is recorded but not treated as a
failure — it means the checker had nothing to say, not that the model failed. failure — it means the checker had nothing to say, not that the model failed.
### `route_decisions` — routing observability
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER | Autoincrement |
| `observed_at` | TEXT | ISO8601, UTC |
| `kind` | TEXT | `route` \| `dispatch` \| `chat` \| `passthrough` \| `local_vision` |
| `task_category` | TEXT | |
| `task_tier` | INTEGER | 13 |
| `required_context_tokens` | INTEGER | |
| `confidence` | REAL | Classifier confidence |
| `classifier_ms` | INTEGER | Classification latency |
| `classification_source` | TEXT | `classifier` \| `override` \| `fallback` |
| `latency_tolerance` | TEXT | `interactive` \| `batch` |
| `candidates_considered` | INTEGER | How many survived hard filters |
| `selected_model` | TEXT | Null when no model was selected |
| `selected_provider` | TEXT | `neuralwatt` or `local` (vision fallback) |
| `runner_up_models` | TEXT | JSON array of up to 3 runner-up candidates |
| `est_cost_usd` | REAL | Estimated cost of the selected model |
| `est_proficiency` | REAL | Estimated proficiency for the task category |
| `rejected_reason` | TEXT | Active filters when nothing was selected |
| `session_key` | TEXT | Hashed session fingerprint ONLY |
| `tools` | INTEGER | 0/1 — request carried a `tools` array |
| `images` | INTEGER | 0/1 — request carried image parts |
| `json_mode` | INTEGER | 0/1 — request required JSON mode |
| `streamed` | INTEGER | 0/1 — response was streamed |
`route_decisions` stores one row per routing decision so "how is routing
performing" is answerable: which model was picked, for what category/tier,
how long classification took, and — when nothing was selected — which hard
filter shut it out. It is an observability table: nothing in routing reads it.
It stores only a hashed session fingerprint in `session_key`; `session_dir`,
prompts, and answers are deliberately excluded. A test enforces that the write
path does not store prompt or answer text. The write is gated by
`logging.log_route_decisions` and is **best-effort**: a failed write is logged
at warning and swallowed so monitoring cannot slow or fail a request.
## Weighted Scoring ## Weighted Scoring
Six hard filters are applied **before** scoring (not weighted — outright disqualification): Six hard filters are applied **before** scoring (not weighted — outright disqualification):
@@ -408,6 +449,56 @@ settled with `POST /outcome` data rather than a 3-task benchmark score. Set it
to `0.5` to turn it on. The vision and JSON-mode gates ship **on**, because a to `0.5` to turn it on. The vision and JSON-mode gates ship **on**, because a
wrong guess is a guaranteed 400. wrong guess is a guaranteed 400.
### Local Vision Fallback
Cloud vision is not universal in the catalog, and the most economical coding
rows do not declare `supports_vision`. Routing an image request through them
would earn a provider-side 400, so when `routing.require_vision` is true only
rows with `supports_vision = 1` survive the hard filters. If **no** cloud
candidate survives, the router can fall back to a local vision model instead
of returning 422.
`local_vision:` in `config.yaml` controls this path:
| Key | Default | Purpose |
|---|---|---|
| `enabled` | `true` | Whether the fallback runs at all |
| `base_url` | `http://localhost:11434/v1` | OpenAI-compatible Ollama endpoint |
| `api_key_env` | `null` | Env var holding an API key, if the endpoint needs one |
| `model` | `qwen3-vl:4b` | Vision model on that Ollama |
| `timeout_seconds` | `60` | Request timeout |
| `max_images` | `4` | Refuse requests with more image parts |
| `max_image_bytes` | `9437184` (9 MiB) | Refuse requests whose image payload exceeds this |
The fallback is **enabled by default** both in `config.yaml` and in
`LocalVisionConfig`, so omitting the section still turns it on. Disable it
explicitly (`enabled: false`) on a host with no local Ollama or one that has
not pulled the vision model.
When enabled and no cloud candidate is selected, `_run_local_vision` sends the
original message list — with `image_url` parts intact — to the configured
Ollama model. The local answer then **replaces** the cloud completion:
`_local_vision_response` returns a normal OpenAI-shaped response, including a
stream-wrapped version when the client asked for `stream: true`. It does *not*
inject a caption into a cloud call, because the streaming proxy cannot rewrite
bytes mid-stream.
Security and budget guards:
- **Only inline `data:` URIs are accepted.** A remote `http(s)` image URL is
declined, because pointing a local model at an arbitrary URL would let an
unauthenticated caller make the router fetch internal resources (SSRF).
- Image count and total payload size are bounded by `max_images` and
`max_image_bytes` before the local call is made.
- If the local call fails for any reason, the request falls through to the
ordinary `422 No model satisfies the hard filters` rather than returning a
silent empty response.
Pull the model on whichever Ollama the fallback points at:
```bash
ollama pull qwen3-vl:4b
```
Config is strict (`extra="forbid"`): a misspelled or misplaced key fails at Config is strict (`extra="forbid"`): a misspelled or misplaced key fails at
load instead of being silently ignored. load instead of being silently ignored.
@@ -503,11 +594,25 @@ allowance.
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/health` | Catalog/reachability status, scoring coverage, warnings | | `GET` | `/health` | Catalog/reachability status, scoring coverage, warnings |
| `GET` | `/metrics` | Aggregated observability JSON: quota burn, coverage, recent decisions, per-model totals, verdict mix, top proficiency; loopback-only, no auth |
| `POST` | `/route` | Classify task, rank candidates, return selected model — **no provider call, no cost** | | `POST` | `/route` | Classify task, rank candidates, return selected model — **no provider call, no cost** |
| `POST` | `/dispatch` | Same as `/route`, plus complete the provider call, stream response, log observation | | `POST` | `/dispatch` | Same as `/route`, plus complete the provider call, stream response, log observation |
| `GET` | `/v1/models` | OpenAI-compatible model list (router virtual models + catalog) | | `GET` | `/v1/models` | OpenAI-compatible model list (router virtual models + catalog) |
| `POST` | `/v1/chat/completions` | OpenAI-compatible completions — routes then proxies, **streaming supported** | | `POST` | `/v1/chat/completions` | OpenAI-compatible completions — routes then proxies, **streaming supported** |
`/metrics` returns a single JSON object with these top-level keys:
- `quota` — kWh metered in the last 30 days against `objective.plan_kwh_per_period`
- `coverage` — routable-model counts with energy/proficiency data plus warnings
- `recent_decisions` — last 50 rows from `route_decisions`
- `per_model` — per-model aggregates over the last 30 days of `energy_observations`
- `verdict_mix` — counts by verification verdict over the last 7 days
- `top_proficiency` — top models by `blended_score` for `coding_general`
- `generated_at` — ISO8601 timestamp
It exposes no conversation text, prompts, or `session_dir`; it is bound to
loopback and unauthenticated exactly like `/health`.
Input to `/route` and `/dispatch` can include `task_category`, `task_tier`, Input to `/route` and `/dispatch` can include `task_category`, `task_tier`,
and `required_context_tokens` overrides — these skip the classifier, useful and `required_context_tokens` overrides — these skip the classifier, useful
for testing routing without the classifier in the loop. for testing routing without the classifier in the loop.
@@ -566,8 +671,37 @@ curl -s -X POST localhost:8080/v1/chat/completions -H 'content-type: application
# JSON-mode request via response_format # JSON-mode request via response_format
curl -s -X POST localhost:8080/v1/chat/completions -H 'content-type: application/json' \ curl -s -X POST localhost:8080/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"auto","messages":[{"role":"user","content":"Return a JSON object with field answer"}],"response_format":{"type":"json_object"}}' -d '{"model":"auto","messages":[{"role":"user","content":"Return a JSON object with field answer"}],"response_format":{"type":"json_object"}}'
# Monitoring: aggregate router health
curl -s localhost:8080/metrics | python -m json.tool
# Terminal dashboard foreground tool (requires `textual`; runs until you press q)
python tui.py
# One-shot routing probe with no spend
python router_cli.py "Refactor this Django view into service objects"
python router_cli.py "Summarize this diff" --category summarization --tier 2
``` ```
### Monitoring
Three foreground tools read the running router without spending quota:
- **`GET /metrics`** — JSON summary of quota, coverage, recent routing
decisions, per-model usage, verdict mix, and top proficiency. No auth;
loopback only.
- **`tui.py`** — Textual terminal dashboard that polls `/metrics` every few
seconds. Run it in a terminal with the service already up. It is a separate
entrypoint, not a systemd unit.
- **`router_cli.py "<task>"`** — POSTs to `/route` once and prints the full
decision tree, including candidates, selected model, estimated cost, and
proficiency. Use `--category`, `--tier`, and `--context` to override the
classifier deterministically, or `--json` for raw output.
`textual` is pinned in `requirements.txt` solely for `tui.py`. It is imported
only by that module; the FastAPI service dispatch path never touches it, so
the router itself has no UI dependency.
## Logging and Traceability ## Logging and Traceability
One `route` line per request says what was decided; one `dispatch` line says One `route` line per request says what was decided; one `dispatch` line says