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
+1 -1
View File
@@ -70,7 +70,7 @@ Every stage is idempotent and resumable: kill it mid-run, restart, lose nothing.
## Requirements
- macOS or Linux, Python 3.12+, [uv](https://docs.astral.sh/uv/)
- A vision model for extraction — an [Anthropic API key](https://console.anthropic.com/) by default, or any OpenAI-compatible endpoint via `config.toml` (`vision_provider = "openai-compatible"` + `vision_base_url`): OpenAI, OpenRouter, or a free local runtime like [Ollama](https://ollama.com/) with a vision-capable model. Local models read spines noticeably worse than frontier ones — expect a longer proofread pass on the Titles page, not a broken pipeline.
- A vision model for extraction — an [Anthropic API key](https://console.anthropic.com/) by default, or any OpenAI-compatible endpoint: OpenAI, OpenRouter, or a free local runtime like [Ollama](https://ollama.com/) with a vision-capable model. `config.toml` carries a `[vision.<provider>]` block for each; `vision_provider` picks one. Local models read spines noticeably worse than frontier ones — expect a longer proofread pass on the Titles page, not a broken pipeline.
- A BoardGameGeek account **and a registered BGG application** — as of BGG's [2025 API policy](https://boardgamegeek.com/using_the_xml_api), the XML API requires a Bearer token from a registered app. Register a free non-commercial application at [boardgamegeek.com/applications](https://boardgamegeek.com/applications) (approval can take a week or more, so **apply on day one**), then create a token. Each user needs their own; tokens must not be shared.
## Quick start
+14 -7
View File
@@ -3,12 +3,19 @@
photos_dir = "photos"
data_dir = "data"
model = "claude-sonnet-5"
rate_limit_seconds = 2.0
# Vision backend. The default ("anthropic") reads ANTHROPIC_API_KEY.
# "openai-compatible" covers OpenAI, OpenRouter, and local runtimes:
# vision_provider = "openai-compatible"
# vision_base_url = "http://localhost:11434/v1" # e.g. Ollama
# model = "qwen2.5vl" # a vision-capable model
# vision_key_env = "" # local: no key needed
# Which vision backend reads your shelf photos. Both recipes below stay
# on file; this line picks one.
vision_provider = "anthropic"
[vision.anthropic]
# reads ANTHROPIC_API_KEY from the environment
model = "claude-sonnet-5"
[vision."openai-compatible"]
# OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio, vLLM).
# key_env names the env var holding the key; "" = endpoint needs none.
base_url = "http://localhost:11434/v1"
model = "qwen2.5vl:7b"
key_env = ""
+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):
+44
View File
@@ -52,3 +52,47 @@ def test_unknown_toml_keys_warn(tmp_path, monkeypatch):
def test_explicit_missing_config_errors_instead_of_silent_defaults(tmp_path):
with pytest.raises(FileNotFoundError, match="does not exist"):
load_config(tmp_path / "nope.toml")
def test_vision_blocks_apply_only_for_the_active_provider(tmp_path):
p = tmp_path / "config.toml"
p.write_text(
"""
vision_provider = "openai-compatible"
[vision.anthropic]
model = "claude-sonnet-5"
[vision."openai-compatible"]
base_url = "http://localhost:11434/v1"
model = "qwen2.5vl:7b"
key_env = ""
"""
)
cfg = load_config(p)
assert cfg.model == "qwen2.5vl:7b" # the active block's model wins
assert cfg.vision_base_url == "http://localhost:11434/v1"
assert cfg.vision_key_env == ""
# flip the provider: the other block applies, this one is inert
p.write_text(p.read_text().replace('"openai-compatible"\n', '"anthropic"\n', 1))
cfg = load_config(p)
assert cfg.model == "claude-sonnet-5"
assert cfg.vision_base_url == "" # untouched default
def test_vision_block_typos_warn(tmp_path):
p = tmp_path / "config.toml"
p.write_text(
"""
[vision.anthropic]
modle = "oops"
[vision.anthorpic]
model = "claude-sonnet-5"
"""
)
with (
pytest.warns(UserWarning, match="modle"),
pytest.warns(UserWarning, match="anthorpic"),
):
load_config(p)
+9
View File
@@ -120,6 +120,15 @@ def test_parse_drops_malformed_entries():
assert len(unidentified) == 1 # empty {} and the bare string are dropped
def test_parse_repairs_trailing_commas():
# the almost-JSON local vision models emit
titles, unidentified, dropped = parse_vision_response(
'{"titles": [{"title_raw": "Catan",},], "unidentified": [],}'
)
assert [t["title_raw"] for t in titles] == ["Catan"]
assert unidentified == [] and dropped == 0
def test_parse_raises_on_garbage():
with pytest.raises(ValueError):
parse_vision_response("I couldn't see any games clearly.")