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
+45 -7
View File
@@ -60,18 +60,56 @@ def test_job_log_endpoint_serves_log_and_404s(job_env, monkeypatch):
assert client.get("/jobs/nope/log").status_code == 404
def test_model_override_set_and_cleared_around_run(job_env, monkeypatch):
from backend import llm
def test_model_overrides_passed_to_classic_runner(job_env, monkeypatch):
"""Classic mode: per-run picks travel as run_pipeline kwargs (the runner
sets and clears llm.set_model_overrides itself)."""
seen = {}
def fake_runner(pdf_path, **kwargs):
seen["override"] = llm._model_override
seen.update(kwargs)
return {"source": "set.pdf", "summary": {}}
monkeypatch.setattr("backend.jobs.run_pipeline", fake_runner)
jobs.create_job(str(job_env / "set.pdf"), "set.pdf",
pipeline_mode="classic", model="openai/gpt-4o")
pipeline_mode="classic",
vision_model="openai/gpt-4o", text_model="openai/gpt-4o-mini")
assert seen["override"] == "openai/gpt-4o"
assert llm._model_override is None # cleared after the run
assert seen["vision_model"] == "openai/gpt-4o"
assert seen["text_model"] == "openai/gpt-4o-mini"
def test_model_overrides_set_and_cleared_around_agent_run(job_env, monkeypatch):
"""Agent mode: the agent runner has no override params, so jobs.py sets
them module-level for the duration of the run."""
from backend import llm
seen = {}
def fake_agent_runner(pdf_path, **kwargs):
seen["vision"] = llm._vision_model_override
seen["text"] = llm._text_model_override
return {"source": "set.pdf", "summary": {}}
monkeypatch.setattr("backend.jobs.run_agent_pipeline", fake_agent_runner)
jobs.create_job(str(job_env / "set.pdf"), "set.pdf",
pipeline_mode="agent",
vision_model="openai/gpt-4o", text_model="openai/gpt-4o-mini")
assert seen["vision"] == "openai/gpt-4o"
assert seen["text"] == "openai/gpt-4o-mini"
assert llm._vision_model_override is None # cleared after the run
assert llm._text_model_override is None
def test_failed_run_logs_traceback(job_env, monkeypatch):
"""A crashed job must leave the traceback in job.log, not just str(e)."""
def boom(pdf_path, **kwargs):
raise RuntimeError("kaboom-stage-failure")
monkeypatch.setattr("backend.jobs.run_pipeline", boom)
job_id = jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="classic")
assert jobs._jobs[job_id]["status"] == "error"
content = (job_env / job_id / "job.log").read_text()
assert "Traceback (most recent call last)" in content
assert "RuntimeError: kaboom-stage-failure" in content
+27 -3
View File
@@ -11,12 +11,23 @@ _PAYLOAD = {
"name": "GPT-4o",
"pricing": {"prompt": "0.0000025", "completion": "0.00001"},
"context_length": 128000,
"architecture": {"input_modalities": ["text", "image"],
"output_modalities": ["text"]},
},
{
"id": "google/gemini-2.5-pro",
"name": "Gemini 2.5 Pro",
"pricing": {"prompt": "0.00000125", "completion": "0.00001"},
"context_length": 1000000,
"architecture": {"modality": "text+image->text"},
},
{
"id": "meta-llama/llama-3.1-70b-instruct",
"name": "Llama 3.1 70B Instruct",
"pricing": {"prompt": "0.0000005", "completion": "0.0000008"},
"context_length": 131072,
"architecture": {"input_modalities": ["text"],
"output_modalities": ["text"]},
},
]
}
@@ -34,14 +45,27 @@ def test_models_endpoint_normalizes_pricing(monkeypatch):
response = client.get("/models")
assert response.status_code == 200
body = response.json()
assert body["default"] == config.MODEL
assert body["default_text"] == config.TEXT_MODEL
by_id = {m["id"]: m for m in body["models"]}
assert body["defaults"] == {"vision": config.MODEL, "text": config.TEXT_MODEL}
by_id = {m["id"]: m for m in body["text"]}
assert by_id["openai/gpt-4o"]["prompt_usd_per_mtok"] == 2.5
assert by_id["openai/gpt-4o"]["completion_usd_per_mtok"] == 10.0
assert by_id["openai/gpt-4o"]["context_length"] == 128000
def test_models_endpoint_splits_vision_and_text(monkeypatch):
_reset_cache()
monkeypatch.setattr(models, "_fetch_openrouter_models", lambda: _PAYLOAD["data"])
client = TestClient(app)
body = client.get("/models").json()
vision_ids = {m["id"] for m in body["vision"]}
text_ids = {m["id"] for m in body["text"]}
# Both modality shapes (structured and legacy string) are recognized.
assert vision_ids == {"openai/gpt-4o", "google/gemini-2.5-pro"}
# Text list is the full catalog; vision models appear in both.
assert text_ids == {"openai/gpt-4o", "google/gemini-2.5-pro",
"meta-llama/llama-3.1-70b-instruct"}
def test_models_endpoint_caches(monkeypatch):
_reset_cache()
calls = []
+33 -15
View File
@@ -1,26 +1,44 @@
from backend import config
from backend.llm import _resolve_backend, set_model_override
from backend.llm import _resolve_backend, set_model_overrides, set_text_backend
def test_override_wins_for_vision_and_text():
set_model_override("openai/gpt-4o")
try:
assert _resolve_backend(has_images=True, model_override=None)["model"] == "openai/gpt-4o"
assert _resolve_backend(has_images=False, model_override=None)["model"] == "openai/gpt-4o"
finally:
set_model_override(None)
def teardown_function():
set_model_overrides(None, None)
set_text_backend(False)
def test_vision_override_wins_for_vision_only():
set_model_overrides(vision="openai/gpt-4o", text=None)
assert _resolve_backend(has_images=True, model_override=None)["model"] == "openai/gpt-4o"
assert _resolve_backend(has_images=False, model_override=None)["model"] == config.TEXT_MODEL
def test_text_override_wins_for_text_only():
set_model_overrides(vision=None, text="anthropic/claude-sonnet-4")
assert _resolve_backend(has_images=False, model_override=None)["model"] == "anthropic/claude-sonnet-4"
assert _resolve_backend(has_images=True, model_override=None)["model"] == config.MODEL
def test_override_beats_per_call_model_arg():
set_model_override("openai/gpt-4o")
try:
# Agents pass their AGENT_*_MODEL per call; the user's job pick wins.
assert _resolve_backend(has_images=False, model_override="other/model")["model"] == "openai/gpt-4o"
finally:
set_model_override(None)
set_model_overrides(vision="openai/gpt-4o", text="openai/gpt-4o-mini")
# Agents pass their AGENT_*_MODEL per call; the user's job pick wins.
assert _resolve_backend(has_images=True, model_override="other/model")["model"] == "openai/gpt-4o"
assert _resolve_backend(has_images=False, model_override="other/model")["model"] == "openai/gpt-4o-mini"
def test_no_override_keeps_defaults():
set_model_override(None)
set_model_overrides(None, None)
assert _resolve_backend(has_images=True, model_override=None)["model"] == config.MODEL
assert _resolve_backend(has_images=False, model_override=None)["model"] == config.TEXT_MODEL
def test_ui_picks_never_name_the_local_model(monkeypatch):
"""Hybrid runs keep LOCAL_TEXT_MODEL; OpenRouter picks must not leak into
the local endpoint (a vLLM server won't serve OpenRouter model ids)."""
monkeypatch.setattr(config, "LOCAL_BASE_URL", "http://localhost:8000/v1")
monkeypatch.setattr(config, "LOCAL_TEXT_MODEL", "qwen/local-instruct")
set_text_backend(True)
set_model_overrides(vision="openai/gpt-4o", text="anthropic/claude-sonnet-4")
be = _resolve_backend(has_images=False, model_override=None)
assert be["local"] is True
assert be["model"] == "qwen/local-instruct"