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),