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:
parent
6c186ce9cc
commit
b082edc2bd
4 changed files with 334 additions and 20 deletions
17
migrations/0012_api_keys_values.sql
Normal file
17
migrations/0012_api_keys_values.sql
Normal 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;
|
||||
|
|
@ -18,7 +18,8 @@ export const POST: RequestHandler = async ({ locals, url }) => {
|
|||
a.alias AS model_name,
|
||||
p.litellm_model,
|
||||
p.api_key_env,
|
||||
p.extra_params
|
||||
p.extra_params,
|
||||
k.key_value
|
||||
FROM ai_model_aliases a
|
||||
JOIN ai_model_providers p ON p.alias_id = a.id
|
||||
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 += ` litellm_params:\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
|
||||
if (row.extra_params && typeof row.extra_params === 'object') {
|
||||
for (const [key, value] of Object.entries(row.extra_params)) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||
if (!locals.workspace || !locals.user) error(401);
|
||||
|
||||
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
|
||||
ORDER BY label
|
||||
`;
|
||||
|
|
@ -15,30 +15,90 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||
const keys = rows.map((row) => ({
|
||||
name: row.env_name,
|
||||
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
|
||||
}));
|
||||
|
||||
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 }) => {
|
||||
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 { env_name, is_enabled, label, key_value } = body;
|
||||
if (!env_name) error(400, 'env_name 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`
|
||||
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}
|
||||
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');
|
||||
|
||||
return json(row);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
name: string;
|
||||
label: string;
|
||||
configured: boolean;
|
||||
has_db_value: boolean;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +84,9 @@
|
|||
// API-nøkler
|
||||
let apiKeys = $state<ApiKey[]>([]);
|
||||
let keysLoaded = $state(false);
|
||||
let expandedKey = $state<string | null>(null);
|
||||
let keyValueInput = $state('');
|
||||
let newKey = $state({ env_name: '', label: '', key_value: '' });
|
||||
|
||||
// Modellkatalog
|
||||
let catalogModels = $state<CatalogModel[]>([]);
|
||||
|
|
@ -158,13 +162,91 @@
|
|||
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;
|
||||
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];
|
||||
} catch {
|
||||
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
|
||||
async function loadCatalog() {
|
||||
catalogLoading = true;
|
||||
|
|
@ -580,7 +662,7 @@
|
|||
<span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span>
|
||||
</div>
|
||||
|
||||
<!-- API-nøkler: klikk for å aktivere/deaktivere -->
|
||||
<!-- API-nøkler -->
|
||||
{#if keysLoaded}
|
||||
<div class="key-pills">
|
||||
{#each apiKeys as key}
|
||||
|
|
@ -592,7 +674,7 @@
|
|||
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} — mangler nøkkelverdi`}
|
||||
>
|
||||
{key.label}
|
||||
{#if !key.configured}
|
||||
|
|
@ -604,6 +686,65 @@
|
|||
{/if}
|
||||
</button>
|
||||
{/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>
|
||||
{/if}
|
||||
|
||||
|
|
@ -816,10 +957,9 @@
|
|||
{/if}
|
||||
</div>
|
||||
<select bind:value={newProvider.api_key_env}>
|
||||
<option value="GEMINI_API_KEY">GEMINI_API_KEY</option>
|
||||
<option value="OPENROUTER_API_KEY">OPENROUTER_API_KEY</option>
|
||||
<option value="ANTHROPIC_API_KEY">ANTHROPIC_API_KEY</option>
|
||||
<option value="XAI_API_KEY">XAI_API_KEY</option>
|
||||
{#each apiKeys as k}
|
||||
<option value={k.name}>{k.label} ({k.name})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="add-btn" onclick={() => addProvider(alias.id)}>Legg til</button>
|
||||
</div>
|
||||
|
|
@ -1078,8 +1218,100 @@
|
|||
background: #3b1219;
|
||||
border: 1px solid #6b2028;
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue