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 }) => {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
✗
|
||||||
|
{:else if key.is_enabled}
|
||||||
|
✓
|
||||||
|
{:else}
|
||||||
|
○
|
||||||
|
{/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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue