diff --git a/migrations/0011_api_keys_toggle.sql b/migrations/0011_api_keys_toggle.sql new file mode 100644 index 0000000..b62db76 --- /dev/null +++ b/migrations/0011_api_keys_toggle.sql @@ -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; 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 bc7dd3c..cc8a199 100644 --- a/web/src/routes/api/admin/ai/generate-config/+server.ts +++ b/web/src/routes/api/admin/ai/generate-config/+server.ts @@ -12,7 +12,7 @@ import { execSync } from 'node:child_process'; export const POST: RequestHandler = async ({ locals, url }) => { 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` SELECT a.alias AS model_name, @@ -21,12 +21,13 @@ export const POST: RequestHandler = async ({ locals, url }) => { p.extra_params FROM ai_model_aliases a 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 `; 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) diff --git a/web/src/routes/api/admin/ai/keys/+server.ts b/web/src/routes/api/admin/ai/keys/+server.ts index 9fce48d..c25c0b2 100644 --- a/web/src/routes/api/admin/ai/keys/+server.ts +++ b/web/src/routes/api/admin/ai/keys/+server.ts @@ -1,16 +1,44 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { env } from '$env/dynamic/private'; - -const KEY_NAMES = ['GEMINI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY', 'XAI_API_KEY']; +import { sql } from '$lib/server/db'; export const GET: RequestHandler = async ({ locals }) => { if (!locals.workspace || !locals.user) error(401); - const keys = KEY_NAMES.map((name) => ({ - name, - configured: !!env[name] + const rows = await sql` + SELECT env_name, label, is_enabled + 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 }); }; + +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); +}; diff --git a/web/src/routes/server-admin/ai/+page.svelte b/web/src/routes/server-admin/ai/+page.svelte index 4003da5..cfefe90 100644 --- a/web/src/routes/server-admin/ai/+page.svelte +++ b/web/src/routes/server-admin/ai/+page.svelte @@ -56,7 +56,9 @@ interface ApiKey { name: string; + label: string; configured: boolean; + is_enabled: boolean; } let aliases = $state(data.aliases as Alias[]); @@ -147,6 +149,22 @@ } 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 async function loadCatalog() { 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 sortedUsage = $derived([...usage].sort((a, b) => b.total_tokens - a.total_tokens)); let totalTokens = $derived(usage.reduce((s, u) => s + u.total_tokens, 0)); @@ -556,14 +580,29 @@ {aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d) - + {#if keysLoaded}
{#each apiKeys as key} - - {key.name.replace('_API_KEY', '')} - {key.configured ? '\u2713' : '\u2717'} - + {/each}
{/if} @@ -667,7 +706,7 @@ {#each sortedAliases as alias (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}
@@ -715,10 +754,11 @@ {#if expandedAlias === alias.id}
{#each ap as provider (provider.id)} -
+ {@const keyDisabled = !isKeyEnabled(provider.api_key_env)} +
#{provider.priority} {provider.litellm_model} - {provider.api_key_env} + {provider.api_key_env}{#if keyDisabled} (av){/if}