AI-admin: global av/på-styring av API-nøkler fra grensesnittet

- Ny tabell ai_api_keys med is_enabled per nøkkel (GEMINI, OPENROUTER, etc.)
- Nøkkel-pills i toppen er nå klikkbare toggles (grønn=på, grå=av, rød=mangler)
- Config-generering filtrerer ut providers med deaktivert nøkkel
- Provider-rader viser visuelt når nøkkelen er slått av (rød kant + dimmet)
- Gjeldende modell per alias respekterer nøkkel-status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 06:43:33 +01:00
parent f98675a72e
commit 6c186ce9cc
4 changed files with 132 additions and 17 deletions

View file

@ -0,0 +1,21 @@
-- 0011_api_keys_toggle.sql
-- Global av/på-styring av API-nøkler.
-- Når en nøkkel er deaktivert, hoppes alle providers med den nøkkelen over
-- ved config-generering. Gir kontroll over direkte vs. OpenRouter-ruting.
BEGIN;
CREATE TABLE ai_api_keys (
env_name TEXT PRIMARY KEY, -- f.eks. "GEMINI_API_KEY"
label TEXT NOT NULL, -- visningsnavn, f.eks. "Gemini"
is_enabled BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO ai_api_keys (env_name, label, is_enabled) VALUES
('GEMINI_API_KEY', 'Gemini', false),
('OPENROUTER_API_KEY', 'OpenRouter', true),
('ANTHROPIC_API_KEY', 'Anthropic', false),
('XAI_API_KEY', 'xAI', false);
COMMIT;

View file

@ -12,7 +12,7 @@ import { execSync } from 'node:child_process';
export const POST: RequestHandler = async ({ locals, url }) => { export const POST: RequestHandler = async ({ locals, url }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
// Hent aktive aliaser med aktive providers // Hent aktive aliaser med aktive providers, filtrer bort deaktiverte nøkler
const rows = await sql` const rows = await sql`
SELECT SELECT
a.alias AS model_name, a.alias AS model_name,
@ -21,12 +21,13 @@ export const POST: RequestHandler = async ({ locals, url }) => {
p.extra_params p.extra_params
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
WHERE a.is_active = true AND p.is_active = true JOIN ai_api_keys k ON k.env_name = p.api_key_env
WHERE a.is_active = true AND p.is_active = true AND k.is_enabled = true
ORDER BY a.alias, p.priority ASC ORDER BY a.alias, p.priority ASC
`; `;
if (rows.length === 0) { if (rows.length === 0) {
error(400, 'Ingen aktive modellkonfigurasjoner funnet'); error(400, 'Ingen aktive modellkonfigurasjoner funnet (sjekk at minst én API-nøkkel er aktivert)');
} }
// Bygg YAML manuelt (unngå avhengighet) // Bygg YAML manuelt (unngå avhengighet)

View file

@ -1,16 +1,44 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { sql } from '$lib/server/db';
const KEY_NAMES = ['GEMINI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY', 'XAI_API_KEY'];
export const GET: RequestHandler = async ({ locals }) => { export const GET: RequestHandler = async ({ locals }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
const keys = KEY_NAMES.map((name) => ({ const rows = await sql`
name, SELECT env_name, label, is_enabled
configured: !!env[name] FROM ai_api_keys
ORDER BY label
`;
const keys = rows.map((row) => ({
name: row.env_name,
label: row.label,
configured: !!env[row.env_name as keyof typeof env],
is_enabled: row.is_enabled
})); }));
return json({ keys }); return json({ keys });
}; };
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 [row] = await sql`
UPDATE ai_api_keys
SET is_enabled = ${is_enabled}, updated_at = now()
WHERE env_name = ${env_name}
RETURNING env_name, label, is_enabled
`;
if (!row) error(404, 'Nøkkel ikke funnet');
return json(row);
};

View file

@ -56,7 +56,9 @@
interface ApiKey { interface ApiKey {
name: string; name: string;
label: string;
configured: boolean; configured: boolean;
is_enabled: boolean;
} }
let aliases = $state<Alias[]>(data.aliases as Alias[]); let aliases = $state<Alias[]>(data.aliases as Alias[]);
@ -147,6 +149,22 @@
} }
loadKeys(); loadKeys();
async function toggleKey(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, is_enabled: !key.is_enabled })
});
if (!res.ok) throw new Error('Feil ved lagring');
key.is_enabled = !key.is_enabled;
apiKeys = [...apiKeys];
} catch {
errorMsg = 'Kunne ikke oppdatere nøkkel-status';
}
}
// Modellkatalog // Modellkatalog
async function loadCatalog() { async function loadCatalog() {
catalogLoading = true; catalogLoading = true;
@ -545,6 +563,12 @@
} }
} }
let enabledKeys = $derived(new Set(apiKeys.filter(k => k.is_enabled).map(k => k.name)));
function isKeyEnabled(envName: string): boolean {
return enabledKeys.has(envName);
}
let sortedAliases = $derived([...aliases].sort((a, b) => a.alias.localeCompare(b.alias, 'nb'))); let sortedAliases = $derived([...aliases].sort((a, b) => a.alias.localeCompare(b.alias, 'nb')));
let sortedUsage = $derived([...usage].sort((a, b) => b.total_tokens - a.total_tokens)); let sortedUsage = $derived([...usage].sort((a, b) => b.total_tokens - a.total_tokens));
let totalTokens = $derived(usage.reduce((s, u) => s + u.total_tokens, 0)); let totalTokens = $derived(usage.reduce((s, u) => s + u.total_tokens, 0));
@ -556,14 +580,29 @@
<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økkel-pills --> <!-- API-nøkler: klikk for å aktivere/deaktivere -->
{#if keysLoaded} {#if keysLoaded}
<div class="key-pills"> <div class="key-pills">
{#each apiKeys as key} {#each apiKeys as key}
<span class="key-pill" class:key-pill--ok={key.configured} class:key-pill--missing={!key.configured}> <button
{key.name.replace('_API_KEY', '')} class="key-pill"
{key.configured ? '\u2713' : '\u2717'} class:key-pill--on={key.is_enabled && key.configured}
</span> class:key-pill--off={!key.is_enabled && key.configured}
class:key-pill--missing={!key.configured}
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}
{#if !key.configured}
&#x2717;
{:else if key.is_enabled}
&#x2713;
{:else}
&#x25CB;
{/if}
</button>
{/each} {/each}
</div> </div>
{/if} {/if}
@ -667,7 +706,7 @@
{#each sortedAliases as alias (alias.id)} {#each sortedAliases as alias (alias.id)}
{@const ap = providersForAlias(alias.id)} {@const ap = providersForAlias(alias.id)}
{@const primaryModel = ap.find(p => p.is_active)?.litellm_model} {@const primaryModel = ap.find(p => p.is_active && isKeyEnabled(p.api_key_env))?.litellm_model}
{#if editingAlias === alias.id} {#if editingAlias === alias.id}
<div class="table-row alias-edit-row"> <div class="table-row alias-edit-row">
<input type="text" class="alias-edit-input" bind:value={editAliasName} placeholder="Alias-navn" /> <input type="text" class="alias-edit-input" bind:value={editAliasName} placeholder="Alias-navn" />
@ -715,10 +754,11 @@
{#if expandedAlias === alias.id} {#if expandedAlias === alias.id}
<div class="provider-list-alias"> <div class="provider-list-alias">
{#each ap as provider (provider.id)} {#each ap as provider (provider.id)}
<div class="provider-row-alias" class:provider-row--inactive={!provider.is_active}> {@const keyDisabled = !isKeyEnabled(provider.api_key_env)}
<div class="provider-row-alias" class:provider-row--inactive={!provider.is_active} class:provider-row--key-off={keyDisabled}>
<span class="col-pri">#{provider.priority}</span> <span class="col-pri">#{provider.priority}</span>
<span class="col-model">{provider.litellm_model}</span> <span class="col-model">{provider.litellm_model}</span>
<span class="col-key">{provider.api_key_env}</span> <span class="col-key" class:col-key--disabled={keyDisabled}>{provider.api_key_env}{#if keyDisabled} (av){/if}</span>
<span class="col-extra-pills"> <span class="col-extra-pills">
<button <button
class="extra-pill" class="extra-pill"
@ -1014,18 +1054,32 @@
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
border-radius: 9999px; border-radius: 9999px;
letter-spacing: 0.03em; letter-spacing: 0.03em;
cursor: pointer;
transition: opacity 0.15s;
} }
.key-pill--ok { .key-pill:hover {
opacity: 0.8;
}
.key-pill--on {
background: #0d3320; background: #0d3320;
border: 1px solid #166534; border: 1px solid #166534;
color: #4ade80; color: #4ade80;
} }
.key-pill--off {
background: #1e1e2e;
border: 1px solid #3b3b52;
color: #8b92a5;
}
.key-pill--missing { .key-pill--missing {
background: #3b1219; background: #3b1219;
border: 1px solid #6b2028; border: 1px solid #6b2028;
color: #f87171; color: #f87171;
cursor: not-allowed;
opacity: 0.6;
} }
section { section {
@ -1307,6 +1361,17 @@
opacity: 0.5; opacity: 0.5;
} }
.provider-row--key-off {
opacity: 0.4;
border-left: 2px solid #6b2028;
padding-left: 0.5rem;
}
.col-key--disabled {
color: #f87171;
font-style: italic;
}
.provider-row--add { .provider-row--add {
padding-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid #2d3148; border-top: 1px solid #2d3148;