From b082edc2bdea29084494074d4c757d448cabdb8c Mon Sep 17 00:00:00 2001 From: vegard Date: Mon, 16 Mar 2026 06:49:59 +0100 Subject: [PATCH] =?UTF-8?q?AI-admin:=20full=20n=C3=B8kkeladministrasjon=20?= =?UTF-8?q?fra=20grensesnittet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nøkkelverdier kan lagres i DB (key_value) og brukes direkte i config - Ny nøkkel-seksjon: legg til, rediger, slett API-nøkler fra UI - Config-generering bruker DB-verdi hvis satt, ellers env-referanse - Dynamisk api_key_env-dropdown basert på registrerte nøkler - Gemini omdøpt til Google, OpenAI lagt til - Slett-beskyttelse: kan ikke fjerne nøkkel som er i bruk av providers Co-Authored-By: Claude Opus 4.6 --- migrations/0012_api_keys_values.sql | 17 ++ .../api/admin/ai/generate-config/+server.ts | 9 +- web/src/routes/api/admin/ai/keys/+server.ts | 78 +++++- web/src/routes/server-admin/ai/+page.svelte | 250 +++++++++++++++++- 4 files changed, 334 insertions(+), 20 deletions(-) create mode 100644 migrations/0012_api_keys_values.sql diff --git a/migrations/0012_api_keys_values.sql b/migrations/0012_api_keys_values.sql new file mode 100644 index 0000000..5d994e6 --- /dev/null +++ b/migrations/0012_api_keys_values.sql @@ -0,0 +1,17 @@ +-- 0012_api_keys_values.sql +-- Lagre API-nøkkelverdier i DB slik at de kan administreres fra grensesnittet. +-- Når key_value er satt, brukes den direkte i config i stedet for env-referanse. + +BEGIN; + +ALTER TABLE ai_api_keys ADD COLUMN key_value TEXT; + +-- Rename Gemini → Google +UPDATE ai_api_keys SET label = 'Google' WHERE env_name = 'GEMINI_API_KEY'; + +-- Legg til OpenAI +INSERT INTO ai_api_keys (env_name, label, is_enabled) VALUES + ('OPENAI_API_KEY', 'OpenAI', false) +ON CONFLICT (env_name) DO NOTHING; + +COMMIT; diff --git a/web/src/routes/api/admin/ai/generate-config/+server.ts b/web/src/routes/api/admin/ai/generate-config/+server.ts index cc8a199..f028d52 100644 --- a/web/src/routes/api/admin/ai/generate-config/+server.ts +++ b/web/src/routes/api/admin/ai/generate-config/+server.ts @@ -18,7 +18,8 @@ export const POST: RequestHandler = async ({ locals, url }) => { a.alias AS model_name, p.litellm_model, p.api_key_env, - p.extra_params + p.extra_params, + k.key_value FROM ai_model_aliases a JOIN ai_model_providers p ON p.alias_id = a.id JOIN ai_api_keys k ON k.env_name = p.api_key_env @@ -36,7 +37,11 @@ export const POST: RequestHandler = async ({ locals, url }) => { yaml += ` - model_name: "${row.model_name}"\n`; yaml += ` litellm_params:\n`; yaml += ` model: "${row.litellm_model}"\n`; - yaml += ` api_key: "os.environ/${row.api_key_env}"\n`; + if (row.key_value) { + yaml += ` api_key: "${row.key_value}"\n`; + } else { + yaml += ` api_key: "os.environ/${row.api_key_env}"\n`; + } // Flett inn extra_params som ekstra nøkler under litellm_params if (row.extra_params && typeof row.extra_params === 'object') { for (const [key, value] of Object.entries(row.extra_params)) { diff --git a/web/src/routes/api/admin/ai/keys/+server.ts b/web/src/routes/api/admin/ai/keys/+server.ts index c25c0b2..81b7ae2 100644 --- a/web/src/routes/api/admin/ai/keys/+server.ts +++ b/web/src/routes/api/admin/ai/keys/+server.ts @@ -7,7 +7,7 @@ export const GET: RequestHandler = async ({ locals }) => { if (!locals.workspace || !locals.user) error(401); const rows = await sql` - SELECT env_name, label, is_enabled + SELECT env_name, label, is_enabled, key_value IS NOT NULL AS has_value FROM ai_api_keys ORDER BY label `; @@ -15,30 +15,90 @@ export const GET: RequestHandler = async ({ locals }) => { const keys = rows.map((row) => ({ name: row.env_name, label: row.label, - configured: !!env[row.env_name as keyof typeof env], + configured: row.has_value || !!env[row.env_name as keyof typeof env], + has_db_value: row.has_value, is_enabled: row.is_enabled })); return json({ keys }); }; +export const POST: RequestHandler = async ({ locals, request }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + const { env_name, label, key_value } = body; + if (!env_name || !label) error(400, 'env_name og label kreves'); + + const [row] = await sql` + INSERT INTO ai_api_keys (env_name, label, is_enabled, key_value) + VALUES (${env_name}, ${label}, ${!!key_value}, ${key_value || null}) + ON CONFLICT (env_name) DO UPDATE SET + label = EXCLUDED.label, + key_value = COALESCE(EXCLUDED.key_value, ai_api_keys.key_value), + updated_at = now() + RETURNING env_name, label, is_enabled, key_value IS NOT NULL AS has_value + `; + + return json({ + name: row.env_name, + label: row.label, + configured: row.has_value || !!env[row.env_name as keyof typeof env], + has_db_value: row.has_value, + is_enabled: row.is_enabled + }); +}; + export const PATCH: RequestHandler = async ({ locals, request }) => { if (!locals.workspace || !locals.user) error(401); const body = await request.json(); - const { env_name, is_enabled } = body; - if (!env_name || typeof is_enabled !== 'boolean') { - error(400, 'env_name og is_enabled kreves'); - } + const { env_name, is_enabled, label, key_value } = body; + if (!env_name) error(400, 'env_name kreves'); + + // Hent nåværende rad + const [current] = await sql`SELECT * FROM ai_api_keys WHERE env_name = ${env_name}`; + if (!current) error(404, 'Nøkkel ikke funnet'); + + const newEnabled = typeof is_enabled === 'boolean' ? is_enabled : current.is_enabled; + const newLabel = typeof label === 'string' ? label : current.label; + const newKeyValue = key_value !== undefined ? (key_value || null) : current.key_value; const [row] = await sql` UPDATE ai_api_keys - SET is_enabled = ${is_enabled}, updated_at = now() + SET is_enabled = ${newEnabled}, label = ${newLabel}, key_value = ${newKeyValue}, updated_at = now() WHERE env_name = ${env_name} - RETURNING env_name, label, is_enabled + RETURNING env_name, label, is_enabled, key_value IS NOT NULL AS has_value `; + return json({ + name: row.env_name, + label: row.label, + configured: row.has_value || !!env[row.env_name as keyof typeof env], + has_db_value: row.has_value, + is_enabled: row.is_enabled + }); +}; + +export const DELETE: RequestHandler = async ({ locals, request }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + const { env_name } = body; + if (!env_name) error(400, 'env_name kreves'); + + // Sjekk at ingen providers bruker denne nøkkelen + const [usage] = await sql` + SELECT count(*)::int AS cnt FROM ai_model_providers WHERE api_key_env = ${env_name} + `; + if (usage.cnt > 0) { + error(400, `Kan ikke slette — ${usage.cnt} provider(e) bruker denne nøkkelen`); + } + + const [row] = await sql` + DELETE FROM ai_api_keys WHERE env_name = ${env_name} RETURNING env_name + `; if (!row) error(404, 'Nøkkel ikke funnet'); - return json(row); + return json({ ok: true }); }; diff --git a/web/src/routes/server-admin/ai/+page.svelte b/web/src/routes/server-admin/ai/+page.svelte index cfefe90..3b1ca96 100644 --- a/web/src/routes/server-admin/ai/+page.svelte +++ b/web/src/routes/server-admin/ai/+page.svelte @@ -58,6 +58,7 @@ name: string; label: string; configured: boolean; + has_db_value: boolean; is_enabled: boolean; } @@ -83,6 +84,9 @@ // API-nøkler let apiKeys = $state([]); let keysLoaded = $state(false); + let expandedKey = $state(null); + let keyValueInput = $state(''); + let newKey = $state({ env_name: '', label: '', key_value: '' }); // Modellkatalog let catalogModels = $state([]); @@ -158,13 +162,91 @@ body: JSON.stringify({ env_name: key.name, is_enabled: !key.is_enabled }) }); if (!res.ok) throw new Error('Feil ved lagring'); - key.is_enabled = !key.is_enabled; + const updated = await res.json(); + Object.assign(key, { is_enabled: updated.is_enabled, configured: updated.configured, has_db_value: updated.has_db_value }); apiKeys = [...apiKeys]; } catch { errorMsg = 'Kunne ikke oppdatere nøkkel-status'; } } + async function saveKeyValue(key: ApiKey) { + errorMsg = ''; + try { + const res = await fetch('/api/admin/ai/keys', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ env_name: key.name, key_value: keyValueInput }) + }); + if (!res.ok) throw new Error('Feil ved lagring'); + const updated = await res.json(); + Object.assign(key, { configured: updated.configured, has_db_value: updated.has_db_value, is_enabled: updated.is_enabled }); + apiKeys = [...apiKeys]; + keyValueInput = ''; + expandedKey = null; + markSaved(key.name); + } catch { + errorMsg = 'Kunne ikke lagre nøkkelverdi'; + } + } + + async function clearKeyValue(key: ApiKey) { + errorMsg = ''; + try { + const res = await fetch('/api/admin/ai/keys', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ env_name: key.name, key_value: '' }) + }); + if (!res.ok) throw new Error('Feil'); + const updated = await res.json(); + Object.assign(key, { configured: updated.configured, has_db_value: updated.has_db_value }); + apiKeys = [...apiKeys]; + markSaved(key.name); + } catch { + errorMsg = 'Kunne ikke fjerne nøkkelverdi'; + } + } + + async function addKey() { + errorMsg = ''; + if (!newKey.env_name || !newKey.label) return; + try { + const res = await fetch('/api/admin/ai/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newKey) + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || 'Feil'); + } + const row = await res.json(); + apiKeys = [...apiKeys, row].sort((a, b) => a.label.localeCompare(b.label)); + newKey = { env_name: '', label: '', key_value: '' }; + } catch (e: any) { + errorMsg = e.message || 'Kunne ikke legge til nøkkel'; + } + } + + async function deleteKey(key: ApiKey) { + errorMsg = ''; + try { + const res = await fetch('/api/admin/ai/keys', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ env_name: key.name }) + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || 'Feil'); + } + apiKeys = apiKeys.filter(k => k.name !== key.name); + } catch (e: any) { + errorMsg = e.message || 'Kunne ikke slette nøkkel'; + } + } + // Modellkatalog async function loadCatalog() { catalogLoading = true; @@ -580,7 +662,7 @@ {aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d) - + {#if keysLoaded}
{#each apiKeys as key} @@ -592,7 +674,7 @@ onclick={() => toggleKey(key)} title={key.configured ? (key.is_enabled ? `${key.label} aktiv — klikk for å deaktivere` : `${key.label} deaktivert — klikk for å aktivere`) - : `${key.label} ikke konfigurert (mangler ${key.name})`} + : `${key.label} — mangler nøkkelverdi`} > {key.label} {#if !key.configured} @@ -604,6 +686,65 @@ {/if} {/each} + +
+ + + {#if expandedKey === '__new__'} +
+ + + + + +
+ {/if} + + +
+ {#each apiKeys as key} +
+ + {#if expandedKey === key.name} +
+ +
+ + {#if key.has_db_value} + + {/if} + +
+ {#if saved === key.name} + OK + {/if} +
+ {/if} +
+ {/each}
{/if} @@ -816,10 +957,9 @@ {/if} @@ -1078,8 +1218,100 @@ background: #3b1219; border: 1px solid #6b2028; color: #f87171; - cursor: not-allowed; - opacity: 0.6; + } + + .key-pill--add { + background: transparent; + border: 1px dashed #3b3b52; + color: #8b92a5; + } + + .key-pill--add:hover { + border-color: #8b92a5; + color: #cdd6f4; + } + + .key-manage-row { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + align-items: center; + flex-wrap: wrap; + } + + .key-input { + background: #181825; + border: 1px solid #2d3148; + color: #cdd6f4; + padding: 0.3rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + flex: 1; + min-width: 150px; + } + + .key-input--short { + flex: 0.5; + min-width: 100px; + } + + .key-details { + display: flex; + flex-direction: column; + gap: 1px; + margin-bottom: 1.5rem; + } + + .key-detail-row { + background: #1e1e2e; + border-radius: 4px; + } + + .key-detail-toggle { + display: flex; + gap: 1rem; + align-items: center; + width: 100%; + padding: 0.4rem 0.6rem; + background: none; + border: none; + color: #cdd6f4; + font-size: 0.8rem; + cursor: pointer; + text-align: left; + } + + .key-detail-toggle:hover { + background: #262640; + } + + .key-detail-label { + font-weight: 600; + min-width: 80px; + } + + .key-detail-env { + color: #8b92a5; + font-family: monospace; + font-size: 0.75rem; + flex: 1; + } + + .key-detail-source { + font-size: 0.7rem; + color: #6c7086; + } + + .key-detail-edit { + padding: 0.5rem 0.6rem; + border-top: 1px solid #2d3148; + } + + .key-detail-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.4rem; + align-items: center; } section {