Vision backends: any OpenAI-compatible endpoint, including local models

The Anthropic key was the last hard gate for other users. extract's
VisionFn seam gains a second factory speaking the chat-completions
format — OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio,
llama.cpp, vLLM) via config.toml: vision_provider, vision_base_url,
and vision_key_env ("" = keyless local endpoint, no Authorization
header sent). Anthropic stays the default. load_config rejects unknown
providers loudly, the pipeline page's credentials warning follows the
configured provider (a keyless local endpoint warns about nothing),
and config.toml + README document the local-model trade honestly:
weaker spine reading means a longer proofread pass, which the shaky-
read workflow absorbs.

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:26:30 -04:00
co-authored by Claude Fable 5
parent 0cdbf74a02
commit 0358079da4
6 changed files with 173 additions and 3 deletions
+17
View File
@@ -22,6 +22,9 @@ STUB_CACHE_MARKER_NAME = "STUB_FIXTURES.marker"
STUB_DATA_MARKER_NAME = "STUB_DATA.marker"
VISION_PROVIDERS = ("anthropic", "openai-compatible")
@dataclass(frozen=True)
class Config:
bgg_username: str = ""
@@ -29,6 +32,11 @@ class Config:
data_dir: Path = Path("data")
model: str = "claude-sonnet-5"
rate_limit_seconds: float = 2.0
# "anthropic" (default) or "openai-compatible" — the latter covers
# OpenAI, OpenRouter, and local runtimes (Ollama, LM Studio, vLLM)
vision_provider: str = "anthropic"
vision_base_url: str = "" # e.g. http://localhost:11434/v1 for Ollama
vision_key_env: str = "OPENAI_API_KEY" # "" = endpoint needs no key
@property
def cache_dir(self) -> Path:
@@ -131,6 +139,9 @@ def load_config(path: Path | None = None) -> Config:
"data_dir": Path,
"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()):
@@ -141,6 +152,12 @@ def load_config(path: Path | None = None) -> Config:
stacklevel=2,
)
cfg = replace(cfg, **updates)
if cfg.vision_provider not in VISION_PROVIDERS:
# a typo here would surface as a confusing extract failure later
raise ValueError(
f"{p}: vision_provider must be one of {', '.join(VISION_PROVIDERS)}"
f" (got {cfg.vision_provider!r})"
)
if username := os.environ.get("BGG_USERNAME"):
cfg = replace(cfg, bgg_username=username)
return cfg
+57 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import base64
import io
import json
import os
import re
from collections.abc import Callable
from pathlib import Path
@@ -176,6 +177,61 @@ def default_vision(model: str) -> VisionFn:
return vision
def openai_compatible_vision(model: str, base_url: str, key_env: str) -> VisionFn:
"""Any endpoint speaking the OpenAI chat-completions format: OpenAI
itself, OpenRouter, or a local runtime (Ollama, LM Studio, llama.cpp,
vLLM — pass its /v1 base URL). A key is optional: local runtimes run
without one, so an empty key_env just sends no Authorization header."""
import httpx
headers = {}
if key_env and (key := os.environ.get(key_env)):
headers["Authorization"] = f"Bearer {key}"
def vision(image_b64: str, media_type: str) -> str:
response = httpx.post(
base_url.rstrip("/") + "/chat/completions",
headers=headers,
json={
"model": model,
"max_tokens": 4000,
"messages": [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{image_b64}"
},
},
{"type": "text", "text": VISION_PROMPT},
],
}
],
},
timeout=300.0, # local models on modest hardware are slow
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"] or ""
return vision
def vision_for(cfg: Config) -> VisionFn:
"""The configured vision backend (config.toml: vision_provider)."""
if cfg.vision_provider == "openai-compatible":
if not cfg.vision_base_url:
raise ValueError(
"vision_provider 'openai-compatible' needs vision_base_url "
"in config.toml (e.g. http://localhost:11434/v1 for Ollama)"
)
return openai_compatible_vision(
cfg.model, cfg.vision_base_url, cfg.vision_key_env
)
return default_vision(cfg.model)
def extract_photo(photo: Path, vision: VisionFn) -> dict:
"""One photo -> {"titles": [...], "unidentified": [...]} (the raw-cache
file format)."""
@@ -536,7 +592,7 @@ def run_extract(
raw_dir = cfg.extract_raw_dir
raw_dir.mkdir(parents=True, exist_ok=True)
vision = vision or default_vision(cfg.model)
vision = vision or vision_for(cfg)
failed: list[str] = []
consecutive: tuple[str, int] = ("", 0)
+9 -1
View File
@@ -758,7 +758,15 @@ def create_app(
"env": {
key: bool(os.environ.get(key))
for key in (
"ANTHROPIC_API_KEY",
# the vision key follows the configured provider;
# a keyless local endpoint warns about nothing
*(
["ANTHROPIC_API_KEY"]
if cfg.vision_provider == "anthropic"
else [cfg.vision_key_env]
if cfg.vision_key_env
else []
),
"BGG_API_TOKEN",
"BGG_USERNAME",
"BGG_PASSWORD",