Add OpenAI (GPT-5.6 Sol) as a second vision provider; gate server settings to a named admin

vision.py now dispatches per-model to _call_anthropic or _call_openai --
same prompt, same schema, same cardimage.py measurements either way, only
the request/response shape differs. Confirmed the existing GRADING_SCHEMA
already satisfies OpenAI's strict-mode requirement (every property listed
in required, additionalProperties:false at every level) with no changes.

Settings gained a second axis: which server key applies now depends on the
selected model's provider, and friends' personal keys are stored per
provider (with a one-time migration from the old single-key localStorage
slot) since a Claude key and an OpenAI key aren't interchangeable.

CARD_GRADER_ADMIN_USER names one username (read from the proxy's forwarded
basic-auth header) who alone may write server settings; everyone else keeps
the same read-only view CARD_GRADER_LOCK used to give everyone, while still
being able to set their own personal key. Deployed here as ninja_hippo.
CARD_GRADER_LOCK remains the fallback when no admin is named.
This commit is contained in:
Barely Removable 2026-08-23 07:39:03 -07:00
parent 9d5c68b093
commit 034761e145
7 changed files with 351 additions and 122 deletions

186
vision.py
View file

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