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
53
app.py
53
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"),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue