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
121
static/app.js
121
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() {
|
|||
`<option value="${id}" ${s.vision_model === id ? 'selected' : ''}>
|
||||
${esc(info.label)} — ~$${info.per_grade.toFixed(3)}/grade</option>`).join('');
|
||||
|
||||
const locked = s.settings_locked;
|
||||
const isAdmin = s.is_admin;
|
||||
|
||||
$('#settings-modal').innerHTML = `
|
||||
<div class="panel-head">
|
||||
|
|
@ -615,39 +618,56 @@ async function openSettings() {
|
|||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Your API key</h3>
|
||||
<h3>Your API keys</h3>
|
||||
<div class="fields">
|
||||
<div class="field wide">
|
||||
<label>Anthropic API key (this browser only)</label>
|
||||
<input class="input" id="set-my-key" type="password" value="${esc(myApiKey() || '')}"
|
||||
placeholder="sk-ant-...">
|
||||
<span class="suffix">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.'}</span>
|
||||
<label>Anthropic key (this browser only)</label>
|
||||
<input class="input" id="set-my-key-anthropic" type="password"
|
||||
value="${esc(myApiKey('anthropic') || '')}" placeholder="sk-ant-...">
|
||||
<span class="suffix">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.</span>
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label>OpenAI key (this browser only)</label>
|
||||
<input class="input" id="set-my-key-openai" type="password"
|
||||
value="${esc(myApiKey('openai') || '')}" placeholder="sk-...">
|
||||
<span class="suffix">Used when the selected model is GPT-5.6 Sol. Same deal — this
|
||||
browser only. Get one at platform.openai.com.</span>
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<span class="suffix">${
|
||||
(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.'
|
||||
}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Server settings${locked ? ' <span class="pill pill-raw">locked</span>' : ''}</h3>
|
||||
${locked ? `<div class="note">This server is shared, so its settings are read-only.
|
||||
Use your own key above.</div>` : `
|
||||
<h3>Server settings${isAdmin ? '' : ' <span class="pill pill-raw">locked</span>'}</h3>
|
||||
${!isAdmin ? `<div class="note">Only the admin can change server settings on this
|
||||
instance. Use your own key above.</div>` : `
|
||||
<div class="fields">
|
||||
<div class="field wide">
|
||||
<label>Server API key (used when a visitor has none)</label>
|
||||
<input class="input" id="set-api-key" type="password"
|
||||
placeholder="${s.server_key_configured ? '•••••••• already set — type to replace' : 'sk-ant-...'}">
|
||||
<span class="suffix">Never sent back to the browser once saved. Leave blank to keep
|
||||
the current one.</span>
|
||||
<label>Server Anthropic key (used when a visitor has none, and the model is Claude)</label>
|
||||
<input class="input" id="set-api-key-anthropic" type="password"
|
||||
placeholder="${s.anthropic_key_configured ? '•••••••• already set — type to replace' : 'sk-ant-...'}">
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label>Server OpenAI key (used when a visitor has none, and the model is GPT-5.6 Sol)</label>
|
||||
<input class="input" id="set-api-key-openai" type="password"
|
||||
placeholder="${s.openai_key_configured ? '•••••••• already set — type to replace' : 'sk-...'}">
|
||||
<span class="suffix">Neither key is ever sent back to a browser once saved. Leave a
|
||||
field blank to keep whatever's already stored for it.</span>
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label>Vision model</label>
|
||||
<select id="set-model">${modelOptions}</select>
|
||||
<span class="suffix">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.</span>
|
||||
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.</span>
|
||||
</div>
|
||||
</div>`}
|
||||
</div>
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue