Merge main: dual model dropdowns + richer job logs, adapted for agent-mode.
Docker Release / build-and-push (push) Successful in 1m0s
Docker Release / release (push) Skipped

- llm.py: set_model_overrides(vision, text) replaces the single job override;
  UI picks still beat per-call agent model args, but never name the hybrid
  local model (avoids main's hybrid footgun); local->cloud fallback uses the
  text pick.
- jobs.py: timestamped line-split tee (job_log.py), in-memory log + log_tail
  polls, full log on terminal states (done/error/needs_review/finalization_error),
  log-only disk recovery, error email links to the run log, and failed runs now
  append the full traceback to job.log. Keeps pipeline_mode, job.json, and the
  review gate.
- models.py: vision/text split via architecture modalities, pricing kept;
  /models returns {vision, text, defaults}; /check takes vision_model/text_model
  (replacing model); /health adds text_model. models_catalog.py dropped.
- UI: two priced dropdowns (OpenRouter compute only) + live run-log panel.
- Tests updated for dual overrides and the /models shape; new coverage for
  traceback capture and local-model immunity.
This commit is contained in:
2026-08-02 09:55:09 -05:00
11 changed files with 699 additions and 150 deletions
+27 -2
View File
@@ -3,11 +3,13 @@ models.py - Fetch the available OpenRouter model list with pricing (cached).
The /models endpoint is public (no API key needed). Results are normalized to
per-1M-token USD costs for display and cached in memory for an hour; callers
degrade gracefully when OpenRouter is unreachable.
degrade gracefully when OpenRouter is unreachable. Each entry also carries a
vision flag (accepts image input) so the UI can offer separate vision/text
model dropdowns.
"""
import time
from typing import List, Optional
from typing import List, Optional, Tuple
import httpx
@@ -25,6 +27,18 @@ def _per_mtok(rate) -> float:
return 0.0
def _is_vision(item: dict) -> bool:
"""True when the model accepts image input and produces text output."""
arch = item.get("architecture") or {}
inputs = arch.get("input_modalities") or []
outputs = arch.get("output_modalities") or []
# Legacy string form: "text+image->text"
modality = (arch.get("modality") or "").lower()
has_image_in = ("image" in inputs) or ("image" in modality.split("->")[0])
has_text_out = ("text" in outputs) or ("->text" in modality) or (not outputs and not modality)
return has_image_in and has_text_out
def _fetch_openrouter_models() -> Optional[List[dict]]:
"""Raw GET of the OpenRouter model list; None on any failure."""
try:
@@ -55,6 +69,7 @@ def fetch_models(force: bool = False) -> Optional[List[dict]]:
"prompt_usd_per_mtok": _per_mtok((item.get("pricing") or {}).get("prompt")),
"completion_usd_per_mtok": _per_mtok((item.get("pricing") or {}).get("completion")),
"context_length": item.get("context_length"),
"vision": _is_vision(item),
}
for item in data
if item.get("id")
@@ -63,3 +78,13 @@ def fetch_models(force: bool = False) -> Optional[List[dict]]:
_cache["models"] = models
_cache["at"] = time.time()
return models
def split_vision_text(models: List[dict]) -> Tuple[List[dict], List[dict]]:
"""Partition the normalized catalog into (vision, text) lists for the UI.
Every catalog model takes text in/out, so vision models appear in both
lists (same dicts, pricing included).
"""
vision = [m for m in models if m.get("vision")]
return vision, list(models)