Files
6krrt/poller.py
adlee-was-taken 8ae5c2f3f6 fix: knobs that validated and did nothing
The previous commit made config strict so a WRONG key fails at load. That
guards typos; it says nothing about keys that are spelled correctly, parse
cleanly, and are read by nobody. Four of those:

`context.per_model_overrides` was the worst, because config.yaml shipped a
worked example for it -- follow that example and the model's effective window
does not move. It is now typed (ContextOverride, not a bare dict, so a typo
INSIDE an override is an error too) and read by
poller.ModelRow.effective_context_window. Both keys fall back independently,
compared with `is not None` rather than `or`: an override of 0 reserve tokens
must mean zero, and present-but-falsy defaults are a trap evals/tasks.yaml
deliberately tests models on.

`logging.log_path` named "router.log", which nothing ever wrote -- the
dispatcher prints to stderr and systemd hands that to the journal. Deleted,
with a note in config.yaml saying where the logs actually are.

`tiers` is documented as labels "for logging/dashboards" and reached neither.
/health is the dashboard surface, so it reports them now.

`objective.quality_tolerance: 0` is a fifth of the same family, arriving from
the other direction: the validator accepts [0, 1) and `band()` divided by it,
so a legitimate setting -- "never trade quality for cost" -- turned every
request into a 500 on a config that had loaded cleanly. 0 now means no band:
strict quality ordering, cost breaking only exact ties.

And `python config.py`, the setup step documented in README.md and CLAUDE.md,
printed cfg.weights -- replaced by cfg.objective some commits ago. It said
"Config loaded OK" and then died with AttributeError, which is a bad look for
the one command whose entire job is to prove the config is fine. The summary
is now a function so a test pins the attribute names against the next rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
2026-08-22 20:57:50 -04:00

321 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Pricing/catalog poller for the local LLM router.
Fetches the NeuralWatt model catalog (unauthenticated public endpoint),
normalizes it into the `models` table in router.db, and flags rows that have
gone stale.
Run manually:
python poller.py
Run on a schedule (cron example, every 2 hours):
0 */2 * * * /usr/bin/python3 /path/to/poller.py >> /var/log/router-poller.log 2>&1
Does NOT touch energy data — energy is only available per-completion, not from
a models list, so the dispatcher writes `energy_observations` instead.
NeuralWatt is the only provider. The `provider` column and the (model_id,
provider) primary key are kept so a second provider can be added without a
migration.
"""
from __future__ import annotations
import sqlite3
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
import requests
from config import RouterConfig, load_config
NEURALWATT_MODELS_URL = "https://api.neuralwatt.com/v1/models"
REQUEST_TIMEOUT = 20 # seconds
# Serving-class suffixes. NeuralWatt ships one base model as several catalog
# rows that differ only by these tokens, and they combine freely — hence ids
# like 'glm-5.2-short-fast-flex'. They are stripped from the end of the id one
# segment at a time so a base name that merely *contains* a lookalike token is
# never misread (e.g. 'deepseek-v4-flash' is not a '-fast' row).
SUFFIX_FLEX = "flex"
SUFFIX_FAST = "fast"
SUFFIX_SHORT = "short"
SERVING_SUFFIXES = frozenset({SUFFIX_FLEX, SUFFIX_FAST, SUFFIX_SHORT})
def parse_serving_class(model_id: str) -> tuple[str, str, str]:
"""Derive (latency_class, reasoning_mode, context_variant) from a model id.
Returns the schema defaults ('standard', 'default', 'full') for a base
model. Suffixes are matched as whole '-'-delimited segments only.
"""
segments = model_id.lower().split("-")
found = set()
while len(segments) > 1 and segments[-1] in SERVING_SUFFIXES:
found.add(segments.pop())
return (
"flex" if SUFFIX_FLEX in found else "standard",
"reduced" if SUFFIX_FAST in found else "default",
"short" if SUFFIX_SHORT in found else "full",
)
def parse_base_model_id(model_id: str) -> str:
"""Reduce a catalog id to the model family underneath it.
``glm-5.2-short-fast-flex`` and ``glm-5.2`` are the same weights served
differently, and ``deepseek-ai/DeepSeek-V4-Flash`` is the HF-style
duplicate of ``deepseek-v4-flash``. Proficiency is a property of the
weights, not of the queue they sit in, so scores are keyed on this and
every serving variant inherits from its family. Leaderboard priors work
the same way — no benchmark rates a ``-flex`` row separately.
Note this deliberately collapses ``-fast`` too, even though reasoning
being off does change answer quality. The eval runner scores ``-fast``
variants separately and overrides the inherited value; the family is the
fallback, not the final word.
"""
namespace_stripped = model_id.rsplit("/", 1)[-1]
segments = namespace_stripped.lower().split("-")
while len(segments) > 1 and segments[-1] in SERVING_SUFFIXES:
segments.pop()
return "-".join(segments)
def parse_access_level(display_name: Optional[str], description: Optional[str]) -> str:
"""Derive an access level from the catalog's prose.
NeuralWatt exposes no structured gating field — restricted models are only
marked in free text ("Private preview (grant-gated)", "(Canary)"). Routing
to one earns a 403 at dispatch, so this is parsed defensively: anything
that looks gated is treated as gated.
"""
blob = f"{display_name or ''} {description or ''}".lower()
if "grant-gated" in blob or "private preview" in blob:
return "preview"
if "canary" in blob:
return "canary"
return "public"
@dataclass
class ModelRow:
model_id: str
provider: str
base_model_id: str
display_name: Optional[str]
cost_per_1m_prompt: Optional[float]
cost_per_1m_completion: Optional[float]
cost_per_1m_prompt_cached: Optional[float]
context_window: Optional[int]
max_output_tokens: Optional[int]
supports_tools: bool
supports_json_mode: bool
supports_vision: bool
supports_reasoning: bool
reasoning_default_enabled: bool
latency_class: str
reasoning_mode: str
context_variant: str
access_level: str
pricing_tbd: bool
deprecated: bool
def effective_context_window(self, cfg: RouterConfig) -> Optional[int]:
"""Usable context, after the safety factor and an output reserve.
``context.per_model_overrides`` wins where it is set, which is what it
is for: the global factor is a guess that has to hold for the whole
catalog, while a row someone has actually measured deserves its own
number.
Compared with ``is not None`` rather than ``or``, so an override of 0
reserve tokens means zero rather than silently falling through to the
default. Present-but-falsy is a trap this project's own eval set tests
models on; the router should not walk into it.
"""
if not self.context_window:
return None
override = cfg.context.per_model_overrides.get(self.model_id)
factor = cfg.context.safety_factor
if override is not None and override.safety_factor is not None:
factor = override.safety_factor
# 11 of 19 catalog rows report no max_output_tokens, so the configured
# reserve carries most of the catalog.
if override is not None and override.output_reserve_tokens is not None:
reserve = override.output_reserve_tokens
else:
reserve = self.max_output_tokens or cfg.context.default_output_reserve_tokens
usable = int(self.context_window * factor) - reserve
return max(usable, 0)
def fetch_neuralwatt() -> list[ModelRow]:
resp = requests.get(NEURALWATT_MODELS_URL, timeout=REQUEST_TIMEOUT)
resp.raise_for_status()
payload = resp.json()
rows = []
for m in payload.get("data", []):
model_id = m.get("id")
meta = m.get("metadata", {}) or {}
pricing = meta.get("pricing", {}) or {}
caps = meta.get("capabilities", {}) or {}
limits = meta.get("limits", {}) or {}
reasoning = meta.get("reasoning") or {}
supports_reasoning = bool(caps.get("reasoning"))
# capabilities.reasoning only means "the API accepts a reasoning
# param" and is true for nearly the whole catalog. default_enabled is
# the discriminating signal; a few models (the kimi-k2.7-code family)
# expose no reasoning block at all, so fall back to the capability.
default_enabled = reasoning.get("default_enabled")
if default_enabled is None:
default_enabled = supports_reasoning
latency_class, reasoning_mode, context_variant = parse_serving_class(model_id)
rows.append(
ModelRow(
model_id=model_id,
provider="neuralwatt",
base_model_id=parse_base_model_id(model_id),
display_name=meta.get("display_name"),
cost_per_1m_prompt=pricing.get("input_per_million"),
cost_per_1m_completion=pricing.get("output_per_million"),
cost_per_1m_prompt_cached=pricing.get("cached_input_per_million"),
context_window=limits.get("max_context_length")
or m.get("max_model_len"),
max_output_tokens=limits.get("max_output_tokens"),
supports_tools=bool(caps.get("tools")),
supports_json_mode=bool(caps.get("json_mode")),
supports_vision=bool(caps.get("vision")),
supports_reasoning=supports_reasoning,
reasoning_default_enabled=bool(default_enabled),
latency_class=latency_class,
reasoning_mode=reasoning_mode,
context_variant=context_variant,
access_level=parse_access_level(
meta.get("display_name"), meta.get("description")
),
pricing_tbd=bool(pricing.get("pricing_tbd")),
deprecated=bool(meta.get("deprecated")),
)
)
return rows
def upsert(conn: sqlite3.Connection, rows: list[ModelRow], cfg: RouterConfig) -> None:
now = datetime.now(timezone.utc).isoformat()
for r in rows:
availability = "deprecated" if r.deprecated else "active"
conn.execute(
"""
INSERT INTO models (
model_id, provider, base_model_id, display_name,
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
context_window, effective_context_window, max_output_tokens,
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
reasoning_default_enabled, latency_class, reasoning_mode,
context_variant, access_level,
pricing_tbd, deprecated, availability, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(model_id, provider) DO UPDATE SET
base_model_id = excluded.base_model_id,
display_name = excluded.display_name,
cost_per_1m_prompt = excluded.cost_per_1m_prompt,
cost_per_1m_completion = excluded.cost_per_1m_completion,
cost_per_1m_prompt_cached = excluded.cost_per_1m_prompt_cached,
context_window = excluded.context_window,
effective_context_window = excluded.effective_context_window,
max_output_tokens = excluded.max_output_tokens,
supports_tools = excluded.supports_tools,
supports_json_mode = excluded.supports_json_mode,
supports_vision = excluded.supports_vision,
supports_reasoning = excluded.supports_reasoning,
reasoning_default_enabled = excluded.reasoning_default_enabled,
latency_class = excluded.latency_class,
reasoning_mode = excluded.reasoning_mode,
context_variant = excluded.context_variant,
access_level = excluded.access_level,
pricing_tbd = excluded.pricing_tbd,
deprecated = excluded.deprecated,
availability = excluded.availability,
last_updated = excluded.last_updated
""",
(
r.model_id,
r.provider,
r.base_model_id,
r.display_name,
r.cost_per_1m_prompt,
r.cost_per_1m_completion,
r.cost_per_1m_prompt_cached,
r.context_window,
r.effective_context_window(cfg),
r.max_output_tokens,
int(r.supports_tools),
int(r.supports_json_mode),
int(r.supports_vision),
int(r.supports_reasoning),
int(r.reasoning_default_enabled),
r.latency_class,
r.reasoning_mode,
r.context_variant,
r.access_level,
int(r.pricing_tbd),
int(r.deprecated),
availability,
now,
),
)
conn.commit()
def mark_stale(conn: sqlite3.Connection, cfg: RouterConfig) -> None:
"""Flag rows that weren't touched by this poll run as stale, rather than
silently leaving old data looking current."""
conn.execute(
"""
UPDATE models
SET availability = 'stale'
WHERE availability = 'active'
AND julianday('now') - julianday(last_updated) > ?
""",
(cfg.freshness.stale_after_days,),
)
conn.commit()
def main() -> int:
cfg = load_config("config.yaml")
conn = sqlite3.connect(cfg.database.path)
conn.execute("PRAGMA foreign_keys = ON")
try:
rows = fetch_neuralwatt()
except requests.RequestException as e:
print(f"[neuralwatt] FAILED: {e}", file=sys.stderr)
conn.close()
return 1
upsert(conn, rows, cfg)
print(f"[neuralwatt] upserted {len(rows)} models")
mark_stale(conn, cfg)
conn.close()
print(f"done, {len(rows)} rows upserted total")
return 0
if __name__ == "__main__":
raise SystemExit(main())