diff --git a/Dockerfile b/Dockerfile
index 296c4b2..c32b9a1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,7 +6,7 @@ FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
# Only optional deps (see README) — the app itself is stdlib only.
-RUN pip install --no-cache-dir anthropic pillow
+RUN pip install --no-cache-dir anthropic openai pillow
WORKDIR /app
COPY app.py cardimage.py store.py vision.py ./
diff --git a/README.md b/README.md
index 54f7f9a..af17c07 100644
--- a/README.md
+++ b/README.md
@@ -101,41 +101,51 @@ graded — so regrading a card keeps its photos for another full window.
### Optional packages
-Two packages unlock real functionality. Each is checked for at runtime, so
+Three packages unlock real functionality. Each is checked for at runtime, so
the app runs without them — you just lose that capability.
```bash
-python3 -m pip install --user anthropic # required — this is what does the grading
+python3 -m pip install --user anthropic # Claude models — Sonnet 5, Haiku 4.5, Opus 5
+python3 -m pip install --user openai # OpenAI models — GPT-5.6 Sol
python3 -m pip install --user pillow # the corner/edge/surface measurement pipeline
```
-Without `anthropic`, grading simply doesn't work — add a key in Settings once
-it's installed. Without `pillow`, grading still runs, but on the full photo
-alone: no measured centering, no measured edge whitening, no magnified
-close-ups. Corners and edges will come back `cannot_assess` far more often,
-because a plain full-card photo genuinely doesn't show enough detail in those
-regions for a model to judge them from. Install `pillow` — it's the
-difference between an answer and a shrug.
+You only need whichever provider package matches the model you actually use
+— install both if you want the option to switch. Without `pillow`, grading
+still runs, but on the full photo alone: no measured centering, no measured
+edge whitening, no magnified close-ups. Corners and edges will come back
+`cannot_assess` far more often, because a plain full-card photo genuinely
+doesn't show enough detail in those regions for a model to judge them from.
+Install `pillow` — it's the difference between an answer and a shrug.
-### API key
+### API keys
-Get one at [console.anthropic.com](https://console.anthropic.com), then paste
-it into Settings in the app. Grading costs a few cents per card (Sonnet 5,
-~$0.04-0.06 depending on how many photos you send) — nothing is charged until
-you click "Estimate the grade."
+Get an Anthropic key at [console.anthropic.com](https://console.anthropic.com)
+and/or an OpenAI key at [platform.openai.com](https://platform.openai.com),
+then paste whichever you use into Settings in the app — each provider has its
+own field, since the keys aren't interchangeable and you may want to hold
+both. Grading costs a few cents per card either way (Sonnet 5 and GPT-5.6 Sol
+are both currently ~$0.04-0.06 depending on how many photos you send) —
+nothing is charged until you click "Estimate the grade."
## Why Sonnet 5, not a cheaper model
-Settings lets you switch to Haiku 4.5 (about a third of the cost) or Opus 5
-(more expensive, marginally more careful). Sonnet is the default for a
-tested reason, not a hunch: run head-to-head against Haiku on the same
-photos, Haiku inverted a PSA centering-tolerance comparison — read 59/41 as
-*exceeding* the tolerance for a 9, when 59/41 is actually well inside it —
-and the error alone dragged its estimate three grade levels below Sonnet's.
-Haiku also lacks extended thinking entirely, which matters for exactly the
-judgment calls this task is full of: is this a scratch or a print texture,
-a reflection or real edge wear, a print line or a crease. If cost matters
-enough to switch, that's a real, deliberate tradeoff — not a free lunch.
+Settings lets you switch to Haiku 4.5 (about a third of the cost), Opus 5
+(more expensive, marginally more careful), or GPT-5.6 Sol (OpenAI, similar
+price to Sonnet). Sonnet is the default for a tested reason, not a hunch: run
+head-to-head against Haiku on the same photos, Haiku inverted a PSA
+centering-tolerance comparison — read 59/41 as *exceeding* the tolerance for
+a 9, when 59/41 is actually well inside it — and the error alone dragged its
+estimate three grade levels below Sonnet's. Haiku also lacks extended
+thinking entirely, which matters for exactly the judgment calls this task is
+full of: is this a scratch or a print texture, a reflection or real edge
+wear, a print line or a crease. If cost matters enough to switch, that's a
+real, deliberate tradeoff — not a free lunch.
+
+GPT-5.6 Sol hasn't been run through the same head-to-head testing — it's
+wired up and usable, but nobody has checked it against real graded cards the
+way Haiku was checked here. Treat its results with the scrutiny that implies
+until someone does that comparison.
## The workflow
@@ -224,12 +234,14 @@ includes a `Dockerfile`, `docker-compose.yml`, and an nginx config
-d card-grader.yourdomain.com
docker restart nginx
```
-5. **Set your Anthropic API key** by opening `https://card-grader.yourdomain.com`
- yourself first, going to Settings, and pasting it in — this only works
- *before* `CARD_GRADER_LOCK=1` takes effect, so either set the key first
- and then bring the lock up, or temporarily comment out that line in
- `docker-compose.yml`, restart, set the key, uncomment it, and restart
- again.
+5. **Set your API key(s)** by opening `https://card-grader.yourdomain.com`
+ yourself first, going to Settings, and pasting them in. If you're using
+ `CARD_GRADER_ADMIN_USER`, this just works — settings stay writable for
+ that one username indefinitely. If you're using the blanket
+ `CARD_GRADER_LOCK=1` instead, that only works *before* the lock takes
+ effect, so either set the key first and then bring the lock up, or
+ temporarily comment out that line in `docker-compose.yml`, restart, set
+ the key, uncomment it, and restart again.
If Unraid already has an nginx-based reverse proxy set up (Nginx Proxy
Manager, SWAG, etc.), skip step 4 and just add a proxy host there pointing
@@ -268,11 +280,25 @@ key is being used**. Two things in the app deal with this:
CARD_GRADER_LOCK=1 python3 app.py
```
- which refuses all settings writes. Without it, any visitor could overwrite
- your stored API key or switch the model to the most expensive one. Grading
- still works normally; only the settings write is blocked.
+ which refuses all settings writes from everyone. Without it, any visitor
+ could overwrite your stored API key or switch the model to the most
+ expensive one. Grading still works normally; only the settings write is
+ blocked.
-Your stored API key is never sent to any browser regardless — the settings
+ If your reverse proxy does per-user basic auth (as opposed to one shared
+ password for everyone), you can name a single admin instead of locking
+ settings out entirely:
+
+ ```bash
+ CARD_GRADER_ADMIN_USER=yourname python3 app.py
+ ```
+
+ Only that username may write settings; everyone else gets the same
+ read-only view `CARD_GRADER_LOCK` gives everyone. Either way, everyone can
+ still set their *own* personal key (point 1 above) — that never touches
+ server settings at all.
+
+Your stored API keys are never sent to any browser regardless — the settings
endpoint returns only a boolean saying whether one is configured.
Everyone shares one `grades.db`, so the History list is communal. That's
diff --git a/app.py b/app.py
index a7f9f8f..793a37d 100644
--- a/app.py
+++ b/app.py
@@ -76,6 +76,20 @@ TEMPLATED_STATIC = {"index.html", "manifest.json", "sw.js", "app.js"}
# normally; only the settings write is refused.
SETTINGS_LOCKED = os.environ.get("CARD_GRADER_LOCK", "").strip() not in ("", "0")
+# When set, only this ONE username (from the reverse proxy's basic auth, see
+# Handler._username) may write server settings — everyone else sees the same
+# read-only view CARD_GRADER_LOCK produces, regardless of CARD_GRADER_LOCK's
+# own value. This is strictly narrower than the blanket lock: it names one
+# person rather than locking out or opening up to everyone at once. Blank
+# (the default) falls back to CARD_GRADER_LOCK's all-or-nothing behaviour.
+ADMIN_USERNAME = os.environ.get("CARD_GRADER_ADMIN_USER", "").strip()
+
+
+def _settings_writable(username):
+ if ADMIN_USERNAME:
+ return username == ADMIN_USERNAME
+ return not SETTINGS_LOCKED
+
CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
@@ -119,21 +133,27 @@ def _decode_uploaded_images(raw_images):
return images, None
-def _public_settings():
+def _public_settings(username):
"""Settings safe to hand to a browser.
- The stored API key never leaves the server. Once this is reachable by
- anyone but you — which is the whole point of putting it behind a tunnel
- for friends — returning the raw key would hand every visitor the ability
- to spend your Anthropic credits anywhere they like. They get a boolean
+ Stored API keys never leave the server. Once this is reachable by anyone
+ but you — which is the whole point of putting it behind a tunnel for
+ friends — returning the raw key would hand every visitor the ability to
+ spend your credits anywhere they like. They get a boolean per provider
saying whether one is configured, which is all the UI needs.
"""
s = store.get_settings()
+ writable = _settings_writable(username)
return {
"vision_model": s.get("vision_model"),
"vision_effort": s.get("vision_effort"),
- "server_key_configured": bool(s.get("anthropic_api_key")),
- "settings_locked": SETTINGS_LOCKED,
+ "anthropic_key_configured": bool(s.get("anthropic_api_key")),
+ "openai_key_configured": bool(s.get("openai_api_key")),
+ "is_admin": writable,
+ # Kept for the frontend's existing "locked" UI treatment — now means
+ # "not writable by YOU", whatever the reason, rather than a single
+ # global flag.
+ "settings_locked": not writable,
}
@@ -308,7 +328,7 @@ class Handler(BaseHTTPRequestHandler):
if route.startswith("/static/"):
return self._static(route[len("/static/"):])
if route == "/api/settings":
- return self._json(_public_settings())
+ return self._json(_public_settings(self._username()))
if route == "/api/vision-models":
return self._json(vision.price_guide())
if route == "/api/usage":
@@ -337,12 +357,13 @@ class Handler(BaseHTTPRequestHandler):
if body is None:
return # _body already sent 413 and closed
if route == "/api/settings":
- if SETTINGS_LOCKED:
+ username = self._username()
+ if not _settings_writable(username):
return self._error(
"Settings are locked on this server. Use your own API "
"key in this browser instead.", 403)
store.save_settings(body)
- return self._json(_public_settings())
+ return self._json(_public_settings(username))
if route == "/api/grade":
return self._grade_card(body)
if route.startswith("/api/history/") and route.endswith("/regrade"):
@@ -399,11 +420,17 @@ class Handler(BaseHTTPRequestHandler):
wording.
"""
model = body.get("model") if body.get("model") in vision.MODELS else settings.get("vision_model")
+ # Which stored server key applies depends on which model this grade
+ # actually runs on — Sonnet needs the Anthropic key, GPT-5.6 Sol
+ # needs the OpenAI one, and they are never interchangeable.
+ provider = vision.MODELS.get(model, {}).get("provider", "anthropic")
+ server_key = settings.get("openai_api_key" if provider == "openai" else "anthropic_api_key")
# A key sent with the request wins over the server's own. That is what
# makes this shareable: hand a friend the URL and they can bring their
- # own Anthropic key rather than spending yours. Never stored — it
- # lives in their browser and is used for this one call.
+ # own key (matching whichever provider `model` needs) rather than
+ # spending yours. Never stored — it lives in their browser and is
+ # used for this one call.
caller_key = (body.get("api_key") or "").strip() or None
print("[grade] calling vision model={} on {} image(s){}…".format(
@@ -411,7 +438,7 @@ class Handler(BaseHTTPRequestHandler):
t0 = time.time()
result = vision.grade_card(
images,
- caller_key or settings.get("anthropic_api_key") or None,
+ caller_key or server_key or None,
model=model,
effort=settings.get("vision_effort"),
)
diff --git a/docker-compose.yml b/docker-compose.yml
index e94a807..6e1fddb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -27,10 +27,18 @@ services:
volumes:
- ./data:/data
environment:
- # Refuses settings writes from anyone but the host, so a visitor
- # can't overwrite the API key or switch to a pricier model. Set to 0
- # temporarily (on the live server only, not here) while setting the
- # API key via the app's own Settings screen, then back to 1.
+ # Only this ONE username (from NPM's basic auth, forwarded upstream)
+ # may write server settings — model choice, both provider API keys.
+ # Everyone else gets the same read-only view CARD_GRADER_LOCK used to
+ # give everyone; they can still set their OWN personal key, which
+ # never touches server settings at all. Set with the app's Settings
+ # screen unlocked for ninja_hippo only, so paste keys in there, not
+ # here.
+ - CARD_GRADER_ADMIN_USER=ninja_hippo
+ # CARD_GRADER_LOCK is now redundant with CARD_GRADER_ADMIN_USER set
+ # (the admin check takes priority) — kept only as the fallback for
+ # anyone who unsets the admin var and wants the old all-or-nothing
+ # behaviour back.
- CARD_GRADER_LOCK=1
# This app is reverse-proxied at hippofam.com/cards, not the domain
# root — see app.py's BASE_PATH handling.
diff --git a/static/app.js b/static/app.js
index b4997db..53010db 100644
--- a/static/app.js
+++ b/static/app.js
@@ -295,7 +295,10 @@ async function runGrade() {
images: pickedToPayload(gradeState.files),
label: ($('#grade-label').value || '').trim() || null,
};
- const key = myApiKey();
+ // A fresh grade always runs on the server's configured default model
+ // (there's no per-grade model picker), so that's whose provider the
+ // personal key needs to match.
+ const key = myApiKeyForModel(state.settings.vision_model);
if (key) body.api_key = key;
gradeState.result = await api('/api/grade', { method: 'POST', body });
status.hidden = true;
@@ -606,7 +609,7 @@ async function openSettings() {
``).join('');
- const locked = s.settings_locked;
+ const isAdmin = s.is_admin;
$('#settings-modal').innerHTML = `
@@ -615,39 +618,56 @@ async function openSettings() {
-
Your API key
+
Your API keys
-
-
- Stored only in this browser and sent with your own grading
- requests — never saved on the server. Get one at console.anthropic.com.
- ${s.server_key_configured
- ? 'This server already has a key set up, so you can leave this blank and use that instead — but then the owner pays for your grades.'
- : 'This server has no key of its own, so you need one here to grade anything.'}
+
+
+ Used when the selected model is a Claude model. Stored only in
+ this browser, never saved on the server. Get one at console.anthropic.com.
+
+
+
+
+ Used when the selected model is GPT-5.6 Sol. Same deal — this
+ browser only. Get one at platform.openai.com.
+
+
+ ${
+ (s.anthropic_key_configured || s.openai_key_configured)
+ ? 'This server already has a key set up for at least one provider, so you can leave the matching field above blank and use that instead — but then the owner pays for your grades.'
+ : 'This server has no key of its own yet, so you need one above to grade anything.'
+ }
-
Server settings${locked ? ' locked' : ''}
- ${locked ? `
This server is shared, so its settings are read-only.
- Use your own key above.
` : `
+
Server settings${isAdmin ? '' : ' locked'}
+ ${!isAdmin ? `
Only the admin can change server settings on this
+ instance. Use your own key above.
` : `
-
-
- Never sent back to the browser once saved. Leave blank to keep
- the current one.
+
+
+
+
+
+
+ Neither key is ever sent back to a browser once saved. Leave a
+ field blank to keep whatever's already stored for it.
Sonnet 5 is the default for a tested reason — run head-to-head
against Haiku on the same cards, Haiku misread a PSA centering tolerance and landed
- three grades off. Drop to Haiku only if cost matters more than accuracy to you.
+ three grades off. GPT-5.6 Sol hasn't been run against real cards here yet, so treat
+ its results with more scrutiny until it has.
`}
@@ -660,33 +680,60 @@ async function openSettings() {
}
/* A personal key lives in localStorage, never on the server — that's what
- lets someone use a shared instance without spending the owner's credits. */
-function myApiKey() {
- try { return localStorage.getItem('cardgrader_api_key') || ''; } catch (_) { return ''; }
-}
-function setMyApiKey(value) {
+ lets someone use a shared instance without spending the owner's credits.
+ Keyed per provider since a Claude key and an OpenAI key aren't
+ interchangeable and someone may reasonably hold both. */
+function myApiKey(provider) {
try {
- if (value) localStorage.setItem('cardgrader_api_key', value);
- else localStorage.removeItem('cardgrader_api_key');
+ if (provider === 'anthropic') {
+ // One-time migration: friends who set a key before OpenAI support
+ // existed had it under the old unprefixed name. Move it once rather
+ // than losing it.
+ const legacy = localStorage.getItem('cardgrader_api_key');
+ if (legacy && !localStorage.getItem('cardgrader_api_key_anthropic')) {
+ localStorage.setItem('cardgrader_api_key_anthropic', legacy);
+ localStorage.removeItem('cardgrader_api_key');
+ }
+ }
+ return localStorage.getItem(`cardgrader_api_key_${provider}`) || '';
+ } catch (_) { return ''; }
+}
+function setMyApiKey(provider, value) {
+ try {
+ const key = `cardgrader_api_key_${provider}`;
+ if (value) localStorage.setItem(key, value);
+ else localStorage.removeItem(key);
} catch (_) { /* private browsing — the field just won't persist */ }
}
+// Which of the two personal keys applies to whatever model is actually
+// going to run — the currently configured default, unless a specific grade
+// requests a different one (nothing does yet, but the lookup is already
+// provider-aware for when it does).
+function myApiKeyForModel(modelId) {
+ const provider = (state.modelGuide[modelId] || {}).provider || 'anthropic';
+ return myApiKey(provider);
+}
+
function closeSettings() {
$('#settings-modal').hidden = true;
$('#modal-scrim').hidden = true;
}
async function saveSettings() {
- setMyApiKey($('#set-my-key').value.trim());
+ setMyApiKey('anthropic', $('#set-my-key-anthropic').value.trim());
+ setMyApiKey('openai', $('#set-my-key-openai').value.trim());
// Only push server settings when this instance allows it, and only send a
// key when one was actually typed — an empty box means "leave it alone",
// not "erase it".
- const serverKeyField = $('#set-api-key');
- if (serverKeyField) {
- const payload = { vision_model: $('#set-model').value };
- const typed = serverKeyField.value.trim();
- if (typed) payload.anthropic_api_key = typed;
+ const modelField = $('#set-model');
+ if (modelField) {
+ const payload = { vision_model: modelField.value };
+ const typedAnthropic = $('#set-api-key-anthropic').value.trim();
+ const typedOpenai = $('#set-api-key-openai').value.trim();
+ if (typedAnthropic) payload.anthropic_api_key = typedAnthropic;
+ if (typedOpenai) payload.openai_api_key = typedOpenai;
state.settings = await api('/api/settings', { method: 'POST', body: payload });
}
closeSettings();
@@ -717,8 +764,12 @@ async function load() {
updateModelCostHint();
await loadHistory();
loadUsage().catch(() => {});
- if (!state.settings.server_key_configured && !myApiKey()) {
- banner('Add your Anthropic API key in Settings before grading a card.');
+ const activeProvider = (state.modelGuide[state.settings.vision_model] || {}).provider || 'anthropic';
+ const serverHasKey = activeProvider === 'openai'
+ ? state.settings.openai_key_configured : state.settings.anthropic_key_configured;
+ if (!serverHasKey && !myApiKey(activeProvider)) {
+ banner(`Add your ${activeProvider === 'openai' ? 'OpenAI' : 'Anthropic'} API key in `
+ + 'Settings before grading a card.');
}
}
diff --git a/store.py b/store.py
index 7abda43..2098a00 100644
--- a/store.py
+++ b/store.py
@@ -77,6 +77,7 @@ CREATE INDEX IF NOT EXISTS idx_events_created ON grade_events(created_at DESC);
DEFAULT_SETTINGS = {
"anthropic_api_key": "",
+ "openai_api_key": "",
"vision_model": "claude-sonnet-5",
"vision_effort": "low",
}
diff --git a/vision.py b/vision.py
index 3571b70..e93693c 100644
--- a/vision.py
+++ b/vision.py
@@ -1,4 +1,8 @@
-"""Estimate a trading card's PSA grade from photographs, using Claude's vision.
+"""Estimate a trading card's PSA grade from photographs, using a vision model.
+Supports Anthropic (Claude) and OpenAI as interchangeable providers — pick
+one per grade via MODELS below; the prompt, schema and cardimage.py
+measurements are identical either way, only _call_anthropic/_call_openai
+differ.
This is the one part of the app that costs money per use, and the one part
that can be confidently wrong — a model eyeballing a photo can miss real wear
@@ -25,6 +29,11 @@ try:
except ImportError: # keeps the rest of the app importable without the SDK
anthropic = None
+try:
+ import openai
+except ImportError:
+ openai = None
+
# Per-model capabilities. These differ in ways that are 400 errors, not
# preferences, so the request is built from this table rather than assuming a
# single shape:
@@ -38,6 +47,7 @@ except ImportError: # keeps the rest of the app importable without the SDK
MODELS = {
"claude-haiku-4-5": {
"label": "Haiku 4.5 — cheapest",
+ "provider": "anthropic",
"effort": False, # sending output_config.effort is a 400
"adaptive_thinking": False,
"fallbacks": False,
@@ -46,6 +56,7 @@ MODELS = {
},
"claude-sonnet-5": {
"label": "Sonnet 5 — balanced",
+ "provider": "anthropic",
"effort": True,
"adaptive_thinking": True,
"fallbacks": False,
@@ -58,12 +69,29 @@ MODELS = {
},
"claude-opus-5": {
"label": "Opus 5 — most accurate",
+ "provider": "anthropic",
"effort": True,
"adaptive_thinking": True,
"fallbacks": True,
"in_per_mtok": 5.00, "out_per_mtok": 25.00,
"approx_image_tokens": 4800,
},
+ "gpt-5.6-sol": {
+ "label": "GPT-5.6 Sol (OpenAI) — balanced",
+ "provider": "openai",
+ # No effort/thinking control wired up for this provider yet — every
+ # call runs at whatever this model's default reasoning depth is.
+ "effort": False,
+ "adaptive_thinking": False,
+ "fallbacks": False,
+ "in_per_mtok": 2.00, "out_per_mtok": 10.00,
+ # A rough estimate, unlike the Anthropic figures (which were true'd
+ # up against real usage — see the README's grading-cost note). Only
+ # affects the ADVERTISED per-grade estimate in Settings; the actual
+ # billed cost always comes from the real usage this API call
+ # reports, never from this number.
+ "approx_image_tokens": 1500,
+ },
}
# Grading rewards the extra reasoning a thinking-capable model does — telling
@@ -91,12 +119,14 @@ class VisionError(Exception):
def available():
- """Is the feature usable right now?"""
- return anthropic is not None and bool(_api_key())
+ """Is the feature usable right now, on at least one provider?"""
+ return ((anthropic is not None and bool(_env_api_key("anthropic")))
+ or (openai is not None and bool(_env_api_key("openai"))))
-def _api_key():
- return os.environ.get("ANTHROPIC_API_KEY", "").strip()
+def _env_api_key(provider):
+ var = "ANTHROPIC_API_KEY" if provider == "anthropic" else "OPENAI_API_KEY"
+ return os.environ.get(var, "").strip()
def media_type(filename, image_bytes=None):
@@ -115,35 +145,12 @@ def media_type(filename, image_bytes=None):
return SUPPORTED_MEDIA.get(ext)
-def _call_vision(images, system, schema, prompt, api_key=None, model=None,
- effort=None, max_tokens=None, labels=None):
- """Shared plumbing for every vision call: auth, request shape per model
- capability, and error/refusal handling. Returns (parsed_json, usage_dict).
+def _build_content(images, labels, image_block):
+ """Shared across providers: captions + encoded images, in request order.
- Raises VisionError with a human-readable message on any failure — callers
- surface it in the UI rather than half-committing anything.
+ `image_block(mime, b64)` returns the provider-specific dict for one
+ image — the two SDKs disagree on that shape, nothing else here differs.
"""
- if anthropic is None:
- raise VisionError(
- "The anthropic package isn't installed. Run: "
- "python3 -m pip install --user anthropic"
- )
- if not images:
- raise VisionError("No images to analyze.")
-
- key = (api_key or _api_key()) or None
- if not key:
- raise VisionError(
- "No Anthropic API key set. Add one in Settings, or export "
- "ANTHROPIC_API_KEY before starting the app."
- )
-
- model = model if model in MODELS else DEFAULT_MODEL
- caps = MODELS[model]
- effort = effort or DEFAULT_EFFORT
-
- client = anthropic.Anthropic(api_key=key)
-
content = []
for index, (image_bytes, filename) in enumerate(images):
mime = media_type(filename, image_bytes)
@@ -159,13 +166,58 @@ def _call_vision(images, system, schema, prompt, api_key=None, model=None,
if labels and index < len(labels) and labels[index]:
content.append({"type": "text", "text": labels[index]})
encoded = base64.standard_b64encode(image_bytes).decode("utf-8")
- content.append({"type": "image",
- "source": {"type": "base64", "media_type": mime, "data": encoded}})
+ content.append(image_block(mime, encoded))
+ return content
+
+
+def _call_vision(images, system, schema, prompt, api_key=None, model=None,
+ effort=None, max_tokens=None, labels=None):
+ """Dispatch to the right provider's implementation.
+
+ Raises VisionError with a human-readable message on any failure — callers
+ surface it in the UI rather than half-committing anything. Everything
+ provider-specific (request shape, auth, refusal/truncation handling,
+ usage-field names) lives in _call_anthropic / _call_openai below; this
+ only picks which one runs.
+ """
+ if not images:
+ raise VisionError("No images to analyze.")
+
+ model = model if model in MODELS else DEFAULT_MODEL
+ caps = MODELS[model]
+ provider = caps.get("provider", "anthropic")
+
+ key = (api_key or _env_api_key(provider)) or None
+ if not key:
+ raise VisionError(
+ "No {} API key set. Add one in Settings, or export {} before "
+ "starting the app.".format(
+ "Anthropic" if provider == "anthropic" else "OpenAI",
+ "ANTHROPIC_API_KEY" if provider == "anthropic" else "OPENAI_API_KEY"))
+
+ if provider == "openai":
+ return _call_openai(images, system, schema, prompt, key, model,
+ max_tokens or MAX_TOKENS, labels)
+ return _call_anthropic(images, system, schema, prompt, key, model, caps,
+ effort or DEFAULT_EFFORT, max_tokens or MAX_TOKENS, labels)
+
+
+def _call_anthropic(images, system, schema, prompt, key, model, caps, effort,
+ max_tokens, labels):
+ if anthropic is None:
+ raise VisionError(
+ "The anthropic package isn't installed. Run: "
+ "python3 -m pip install --user anthropic"
+ )
+ client = anthropic.Anthropic(api_key=key)
+
+ content = _build_content(images, labels, lambda mime, b64: {
+ "type": "image", "source": {"type": "base64", "media_type": mime, "data": b64}})
content.append({"type": "text", "text": prompt})
params = {
"model": model,
- "max_tokens": max_tokens or MAX_TOKENS,
+ "max_tokens": max_tokens,
"system": system,
"output_config": {"format": {"type": "json_schema", "schema": schema}},
"messages": [{"role": "user", "content": content}],
@@ -229,6 +281,69 @@ def _call_vision(images, system, schema, prompt, api_key=None, model=None,
}
+def _call_openai(images, system, schema, prompt, key, model, max_tokens, labels):
+ if openai is None:
+ raise VisionError(
+ "The openai package isn't installed. Run: "
+ "python3 -m pip install --user openai"
+ )
+ client = openai.OpenAI(api_key=key)
+
+ content = _build_content(images, labels, lambda mime, b64: {
+ "type": "image_url", "image_url": {"url": "data:{};base64,{}".format(mime, b64)}})
+ content.append({"type": "text", "text": prompt})
+
+ try:
+ response = client.chat.completions.create(
+ model=model,
+ messages=[{"role": "system", "content": system},
+ {"role": "user", "content": content}],
+ response_format={
+ "type": "json_schema",
+ "json_schema": {"name": "psa_grade_estimate", "strict": True, "schema": schema},
+ },
+ # Newer reasoning-capable models reject the older `max_tokens`
+ # name outright; this is the one Chat Completions accepts now.
+ max_completion_tokens=max_tokens,
+ )
+ except openai.AuthenticationError:
+ raise VisionError("OpenAI rejected the API key. Check it in Settings.")
+ except openai.PermissionDeniedError:
+ raise VisionError("That API key doesn't have access to {}.".format(model))
+ except openai.RateLimitError:
+ raise VisionError("OpenAI is rate-limiting you. Wait a moment and retry.")
+ except openai.BadRequestError as exc:
+ raise VisionError("OpenAI rejected the request: {}".format(exc))
+ except openai.APIConnectionError:
+ raise VisionError("Couldn't reach OpenAI. Check your connection.")
+ except openai.APIStatusError as exc:
+ raise VisionError("OpenAI error {}: {}".format(exc.status_code, exc))
+
+ choice = response.choices[0]
+ # content_filter is OpenAI's refusal equivalent; length is a truncation,
+ # same distinction Anthropic's stop_reason makes, different vocabulary.
+ if choice.finish_reason == "content_filter":
+ raise VisionError("OpenAI declined to analyze this image. Try a different photo.")
+ if choice.finish_reason == "length":
+ raise VisionError("The response was cut off. Try fewer/simpler images at a time.")
+
+ text = choice.message.content if choice.message else None
+ if not text:
+ raise VisionError("OpenAI returned no readable result for this image.")
+
+ try:
+ parsed = json.loads(text)
+ except ValueError:
+ raise VisionError("OpenAI's response wasn't valid JSON.")
+
+ usage = response.usage
+ return parsed, {
+ "input_tokens": getattr(usage, "prompt_tokens", None),
+ "output_tokens": getattr(usage, "completion_tokens", None),
+ "model": response.model,
+ }
+
+
GRADING_SYSTEM = """You estimate what PSA grade a trading card would likely \
receive, from photograph(s) of it. The card may be Pokemon, another trading \
card game, or a sports card — PSA grades all of them on the same four \
@@ -865,6 +980,7 @@ def price_guide():
rate_in, rate_out = current_rates(caps)
guide[model_id] = {
"label": caps["label"],
+ "provider": caps.get("provider", "anthropic"),
"supports_effort": caps["effort"],
"per_grade": round(est_in * rate_in / 1_000_000
+ est_out * rate_out / 1_000_000, 4),