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

53
app.py
View file

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