AI-admin: full nøkkeladministrasjon fra grensesnittet

- 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 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 06:49:59 +01:00
parent 6c186ce9cc
commit b082edc2bd
4 changed files with 334 additions and 20 deletions

View file

@ -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;

View file

@ -18,7 +18,8 @@ export const POST: RequestHandler = async ({ locals, url }) => {
a.alias AS model_name, a.alias AS model_name,
p.litellm_model, p.litellm_model,
p.api_key_env, p.api_key_env,
p.extra_params p.extra_params,
k.key_value
FROM ai_model_aliases a FROM ai_model_aliases a
JOIN ai_model_providers p ON p.alias_id = a.id JOIN ai_model_providers p ON p.alias_id = a.id
JOIN ai_api_keys k ON k.env_name = p.api_key_env 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 += ` - model_name: "${row.model_name}"\n`;
yaml += ` litellm_params:\n`; yaml += ` litellm_params:\n`;
yaml += ` model: "${row.litellm_model}"\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 // Flett inn extra_params som ekstra nøkler under litellm_params
if (row.extra_params && typeof row.extra_params === 'object') { if (row.extra_params && typeof row.extra_params === 'object') {
for (const [key, value] of Object.entries(row.extra_params)) { for (const [key, value] of Object.entries(row.extra_params)) {

View file

@ -7,7 +7,7 @@ export const GET: RequestHandler = async ({ locals }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
const rows = await sql` 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 FROM ai_api_keys
ORDER BY label ORDER BY label
`; `;
@ -15,30 +15,90 @@ export const GET: RequestHandler = async ({ locals }) => {
const keys = rows.map((row) => ({ const keys = rows.map((row) => ({
name: row.env_name, name: row.env_name,
label: row.label, 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 is_enabled: row.is_enabled
})); }));
return json({ keys }); 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 }) => { export const PATCH: RequestHandler = async ({ locals, request }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
const body = await request.json(); const body = await request.json();
const { env_name, is_enabled } = body; const { env_name, is_enabled, label, key_value } = body;
if (!env_name || typeof is_enabled !== 'boolean') { if (!env_name) error(400, 'env_name kreves');
error(400, 'env_name og is_enabled 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` const [row] = await sql`
UPDATE ai_api_keys 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} 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'); if (!row) error(404, 'Nøkkel ikke funnet');
return json(row); return json({ ok: true });
}; };

View file

@ -58,6 +58,7 @@
name: string; name: string;
label: string; label: string;
configured: boolean; configured: boolean;
has_db_value: boolean;
is_enabled: boolean; is_enabled: boolean;
} }
@ -83,6 +84,9 @@
// API-nøkler // API-nøkler
let apiKeys = $state<ApiKey[]>([]); let apiKeys = $state<ApiKey[]>([]);
let keysLoaded = $state(false); let keysLoaded = $state(false);
let expandedKey = $state<string | null>(null);
let keyValueInput = $state('');
let newKey = $state({ env_name: '', label: '', key_value: '' });
// Modellkatalog // Modellkatalog
let catalogModels = $state<CatalogModel[]>([]); let catalogModels = $state<CatalogModel[]>([]);
@ -158,13 +162,91 @@
body: JSON.stringify({ env_name: key.name, is_enabled: !key.is_enabled }) body: JSON.stringify({ env_name: key.name, is_enabled: !key.is_enabled })
}); });
if (!res.ok) throw new Error('Feil ved lagring'); 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]; apiKeys = [...apiKeys];
} catch { } catch {
errorMsg = 'Kunne ikke oppdatere nøkkel-status'; 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 // Modellkatalog
async function loadCatalog() { async function loadCatalog() {
catalogLoading = true; catalogLoading = true;
@ -580,7 +662,7 @@
<span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span> <span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span>
</div> </div>
<!-- API-nøkler: klikk for å aktivere/deaktivere --> <!-- API-nøkler -->
{#if keysLoaded} {#if keysLoaded}
<div class="key-pills"> <div class="key-pills">
{#each apiKeys as key} {#each apiKeys as key}
@ -592,7 +674,7 @@
onclick={() => toggleKey(key)} onclick={() => toggleKey(key)}
title={key.configured title={key.configured
? (key.is_enabled ? `${key.label} aktiv — klikk for å deaktivere` : `${key.label} deaktivert — klikk for å aktivere`) ? (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} {key.label}
{#if !key.configured} {#if !key.configured}
@ -604,6 +686,65 @@
{/if} {/if}
</button> </button>
{/each} {/each}
<button
class="key-pill key-pill--add"
onclick={() => { expandedKey = expandedKey === '__new__' ? null : '__new__'; }}
>+ Ny nøkkel</button>
</div>
<!-- Utvidet nøkkeladministrasjon -->
{#if expandedKey === '__new__'}
<div class="key-manage-row">
<input type="text" placeholder="ENV_NAVN (f.eks. DEEPSEEK_API_KEY)" bind:value={newKey.env_name} class="key-input" />
<input type="text" placeholder="Visningsnavn" bind:value={newKey.label} class="key-input key-input--short" />
<input type="password" placeholder="API-nøkkel (valgfritt)" bind:value={newKey.key_value} class="key-input" />
<button class="add-btn" onclick={addKey}>Legg til</button>
<button class="toggle-btn" onclick={() => { expandedKey = null; }}>Avbryt</button>
</div>
{/if}
<!-- Nøkkeldetaljer -->
<div class="key-details">
{#each apiKeys as key}
<div class="key-detail-row">
<button
class="key-detail-toggle"
onclick={() => { expandedKey = expandedKey === key.name ? null : key.name; keyValueInput = ''; }}
>
<span class="key-detail-label">{key.label}</span>
<span class="key-detail-env">{key.name}</span>
<span class="key-detail-source">
{#if key.has_db_value}
nøkkel i DB
{:else if key.configured}
nøkkel fra env
{:else}
ikke konfigurert
{/if}
</span>
</button>
{#if expandedKey === key.name}
<div class="key-detail-edit">
<input
type="password"
placeholder="Lim inn ny nøkkelverdi..."
bind:value={keyValueInput}
class="key-input"
/>
<div class="key-detail-actions">
<button class="add-btn" onclick={() => saveKeyValue(key)}>Lagre nøkkel</button>
{#if key.has_db_value}
<button class="toggle-btn" onclick={() => clearKeyValue(key)}>Fjern DB-verdi</button>
{/if}
<button class="delete-btn" onclick={() => deleteKey(key)}>Slett</button>
</div>
{#if saved === key.name}
<span class="status-saved">OK</span>
{/if}
</div>
{/if}
</div>
{/each}
</div> </div>
{/if} {/if}
@ -816,10 +957,9 @@
{/if} {/if}
</div> </div>
<select bind:value={newProvider.api_key_env}> <select bind:value={newProvider.api_key_env}>
<option value="GEMINI_API_KEY">GEMINI_API_KEY</option> {#each apiKeys as k}
<option value="OPENROUTER_API_KEY">OPENROUTER_API_KEY</option> <option value={k.name}>{k.label} ({k.name})</option>
<option value="ANTHROPIC_API_KEY">ANTHROPIC_API_KEY</option> {/each}
<option value="XAI_API_KEY">XAI_API_KEY</option>
</select> </select>
<button class="add-btn" onclick={() => addProvider(alias.id)}>Legg til</button> <button class="add-btn" onclick={() => addProvider(alias.id)}>Legg til</button>
</div> </div>
@ -1078,8 +1218,100 @@
background: #3b1219; background: #3b1219;
border: 1px solid #6b2028; border: 1px solid #6b2028;
color: #f87171; 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 { section {