Per-provider vision blocks; parser repairs local models' almost-JSON

config.toml now carries a [vision.<provider>] block per backend —
model/base_url/key_env — with vision_provider picking the active one,
so the committed file documents every recipe and switching is a
one-line flip. Only the active block applies; typo'd block names and
keys warn like every other config mistake.

First real Ollama run (qwen2.5vl:7b) surfaced what local models emit:
almost-JSON with trailing commas. parse_vision_response now makes one
cheap repair pass before declaring a response unusable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 18:39:27 -04:00
co-authored by Claude Fable 5
parent 0358079da4
commit 118503e4e0
6 changed files with 99 additions and 12 deletions
+25 -3
View File
@@ -140,17 +140,39 @@ def load_config(path: Path | None = None) -> Config:
"model": str,
"rate_limit_seconds": float,
"vision_provider": str,
"vision_base_url": str,
"vision_key_env": str,
}
updates = {key: caster(raw[key]) for key, caster in known.items() if key in raw}
if unknown := sorted(raw.keys() - known.keys()):
if unknown := sorted(raw.keys() - known.keys() - {"vision"}):
# a typo'd key silently falling back to defaults is a debugging
# trap ("No photos found in photos/") — say so up front
warnings.warn(
f"{p}: ignoring unknown key(s): {', '.join(unknown)}",
stacklevel=2,
)
# [vision.<provider>] blocks: every provider's recipe can live in
# the committed file; only the ACTIVE provider's block applies
vision_blocks = raw.get("vision") or {}
if stray := sorted(vision_blocks.keys() - set(VISION_PROVIDERS)):
warnings.warn(
f"{p}: ignoring [vision.*] block(s) for unknown provider(s): "
f"{', '.join(stray)}",
stacklevel=2,
)
provider = updates.get("vision_provider", cfg.vision_provider)
block = vision_blocks.get(provider) or {}
block_known = {
"model": "model",
"base_url": "vision_base_url",
"key_env": "vision_key_env",
}
if odd := sorted(block.keys() - block_known.keys()):
warnings.warn(
f"{p}: [vision.{provider}]: ignoring unknown key(s): {', '.join(odd)}",
stacklevel=2,
)
for key, field in block_known.items():
if key in block:
updates[field] = str(block[key])
cfg = replace(cfg, **updates)
if cfg.vision_provider not in VISION_PROVIDERS:
# a typo here would surface as a confusing extract failure later
+6 -1
View File
@@ -126,7 +126,12 @@ def parse_vision_response(text: str) -> tuple[list[dict], list[dict], int]:
if end == -1:
raise ValueError(f"unterminated JSON in vision response: {text[:200]!r}")
cleaned = cleaned[start : end + 1]
data = json.loads(cleaned)
try:
data = json.loads(cleaned)
except json.JSONDecodeError:
# local models emit almost-JSON (trailing commas, mostly): one
# cheap repair pass before declaring the response unusable
data = json.loads(re.sub(r",\s*([}\]])", r"\1", cleaned))
if isinstance(data, list):
titles_raw, unidentified_raw = data, []
elif isinstance(data, dict):