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:
parent
f98675a72e
commit
6c186ce9cc
4 changed files with 132 additions and 17 deletions
21
migrations/0011_api_keys_toggle.sql
Normal file
21
migrations/0011_api_keys_toggle.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@
|
|||
|
||||
interface ApiKey {
|
||||
name: string;
|
||||
label: string;
|
||||
configured: boolean;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
let aliases = $state<Alias[]>(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 @@
|
|||
<span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span>
|
||||
</div>
|
||||
|
||||
<!-- API-nøkkel-pills -->
|
||||
<!-- API-nøkler: klikk for å aktivere/deaktivere -->
|
||||
{#if keysLoaded}
|
||||
<div class="key-pills">
|
||||
{#each apiKeys as key}
|
||||
<span class="key-pill" class:key-pill--ok={key.configured} class:key-pill--missing={!key.configured}>
|
||||
{key.name.replace('_API_KEY', '')}
|
||||
{key.configured ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
<button
|
||||
class="key-pill"
|
||||
class:key-pill--on={key.is_enabled && key.configured}
|
||||
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}
|
||||
✗
|
||||
{:else if key.is_enabled}
|
||||
✓
|
||||
{:else}
|
||||
○
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/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}
|
||||
<div class="table-row alias-edit-row">
|
||||
<input type="text" class="alias-edit-input" bind:value={editAliasName} placeholder="Alias-navn" />
|
||||
|
|
@ -715,10 +754,11 @@
|
|||
{#if expandedAlias === alias.id}
|
||||
<div class="provider-list-alias">
|
||||
{#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-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">
|
||||
<button
|
||||
class="extra-pill"
|
||||
|
|
@ -1014,18 +1054,32 @@
|
|||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.key-pill--ok {
|
||||
.key-pill:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.key-pill--on {
|
||||
background: #0d3320;
|
||||
border: 1px solid #166534;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.key-pill--off {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #3b3b52;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.key-pill--missing {
|
||||
background: #3b1219;
|
||||
border: 1px solid #6b2028;
|
||||
color: #f87171;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
section {
|
||||
|
|
@ -1307,6 +1361,17 @@
|
|||
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 {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #2d3148;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue