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:
parent
9d5c68b093
commit
034761e145
7 changed files with 351 additions and 122 deletions
186
vision.py
186
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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue