server/web/src/routes/server-admin/ai/+page.svelte
vegard c3d81b97fe AI-admin: tving refresh ved katalog-lasting for å unngå stale cache
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:18:12 +01:00

2218 lines
54 KiB
Svelte

<script lang="ts">
import type { PageData } from './$types';
let { data } = $props<{ data: PageData }>();
interface Alias {
id: string;
alias: string;
description: string | null;
is_active: boolean;
}
interface Provider {
id: string;
alias_id: string;
priority: number;
litellm_model: string;
api_key_env: string;
is_active: boolean;
extra_params: Record<string, unknown> | null;
}
interface Route {
job_type: string;
alias_id: string;
alias: string;
description: string | null;
}
interface Prompt {
action: string;
system_prompt: string;
description: string | null;
label: string | null;
icon: string | null;
sort_order: number;
updated_at: string;
}
interface UsageRow {
model_alias: string;
model_actual: string | null;
action: string | null;
call_count: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
interface CatalogModel {
id: string;
name: string;
provider: string;
litellm_prefix: string;
api_key_env: string;
context_length: number;
prompt_price_per_m: number;
completion_price_per_m: number;
modality: string;
max_completion: number | null;
}
interface ApiKey {
name: string;
label: string;
configured: boolean;
has_db_value: boolean;
is_enabled: boolean;
}
let aliases = $state<Alias[]>(data.aliases as Alias[]);
let providers = $state<Provider[]>(data.providers as Provider[]);
let routing = $state<Route[]>(data.routing as Route[]);
let prompts = $state<Prompt[]>(data.prompts as Prompt[]);
let usage = $state<UsageRow[]>(data.usage as UsageRow[]);
let saving = $state<string | null>(null);
let saved = $state<string | null>(null);
let errorMsg = $state('');
let configMsg = $state('');
let editingPrompt = $state<string | null>(null);
let editPromptText = $state('');
let editPromptLabel = $state('');
let editPromptIcon = $state('');
let expandedAlias = $state<string | null>(null);
// Alias-redigering
let editingAlias = $state<string | null>(null);
let editAliasName = $state('');
let editAliasDesc = $state('');
// 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[]>([]);
let catalogLoading = $state(false);
let catalogLoaded = $state(false);
let catalogSearch = $state('');
let expandedProviders = $state<Set<string>>(new Set());
let addingFromCatalog = $state<string | null>(null);
let catalogAddAlias = $state('');
// Katalog-picker i add-provider-form
let showCatalogPicker = $state(false);
let catalogPickerSearch = $state('');
// Extra params redigering
let editingExtraParams = $state<string | null>(null);
let editExtraParamsText = $state('');
// Ny provider-form
let newProvider = $state<{ alias_id: string; litellm_model: string; api_key_env: string }>({
alias_id: '',
litellm_model: '',
api_key_env: 'OPENROUTER_API_KEY'
});
// Ny alias-form
let newAlias = $state({ alias: '', description: '' });
// Ny ruting-form
let newRoute = $state({ job_type: '', alias_id: '', description: '' });
function providersForAlias(aliasId: string): Provider[] {
return providers.filter((p) => p.alias_id === aliasId).sort((a, b) => a.priority - b.priority);
}
function markSaved(id: string) {
saved = id;
setTimeout(() => {
if (saved === id) saved = null;
}, 2000);
}
function formatCtx(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(0)}K`;
return String(n);
}
function formatPrice(n: number | null | undefined): string {
if (n == null || n < 0) return '\u2014';
if (n === 0) return '\u2014';
return `$${n.toFixed(2)}`;
}
// API-nøkler
async function loadKeys() {
try {
const res = await fetch('/api/admin/ai/keys');
if (res.ok) {
const data = await res.json();
apiKeys = data.keys;
keysLoaded = true;
}
} catch { /* stille */ }
}
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');
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;
errorMsg = '';
try {
const res = await fetch('/api/admin/ai/models?refresh=1');
if (!res.ok) throw new Error('Kunne ikke hente modellkatalog');
catalogModels = await res.json();
catalogLoaded = true;
} catch (e: any) {
errorMsg = e.message;
} finally {
catalogLoading = false;
}
}
// Grupper etter api_key_env + provider (f.eks. "google via GEMINI_API_KEY" vs "google via OPENROUTER_API_KEY")
let groupedByProvider = $derived.by(() => {
const search = catalogSearch.toLowerCase();
const filtered = search
? catalogModels.filter(
(m) =>
m.name.toLowerCase().includes(search) ||
m.id.toLowerCase().includes(search) ||
m.provider.toLowerCase().includes(search) ||
m.api_key_env.toLowerCase().includes(search)
)
: catalogModels;
const map = new Map<string, CatalogModel[]>();
for (const m of filtered) {
// Grupper per API-nøkkel, med provider som undergruppe
const groupKey = m.api_key_env === 'OPENROUTER_API_KEY'
? `${m.provider} (OpenRouter)`
: m.provider;
const list = map.get(groupKey) ?? [];
list.push(m);
map.set(groupKey, list);
}
// Sorter: pris synkende først (ukjent/-1 sist), deretter navn synkende
for (const [, models] of map) {
models.sort((a, b) => {
const aPrice = a.completion_price_per_m;
const bPrice = b.completion_price_per_m;
const aHasPrice = aPrice > 0;
const bHasPrice = bPrice > 0;
if (aHasPrice !== bHasPrice) return aHasPrice ? -1 : 1;
if (aPrice !== bPrice) return bPrice - aPrice;
return b.name.localeCompare(a.name);
});
}
// Direkte API-nøkler først, deretter OpenRouter-grupper
return [...map.entries()].sort(([a], [b]) => {
const aOr = a.includes('OpenRouter');
const bOr = b.includes('OpenRouter');
if (aOr !== bOr) return aOr ? 1 : -1;
return a.localeCompare(b);
});
});
let catalogPickerFiltered = $derived.by(() => {
const search = catalogPickerSearch.toLowerCase();
if (!search) return catalogModels.slice(0, 20);
return catalogModels
.filter(
(m) =>
m.name.toLowerCase().includes(search) ||
m.id.toLowerCase().includes(search)
)
.slice(0, 20);
});
function toggleCatalogProvider(provider: string) {
if (expandedProviders.has(provider)) {
expandedProviders.delete(provider);
} else {
expandedProviders.add(provider);
}
expandedProviders = new Set(expandedProviders);
}
function litellmModelId(model: CatalogModel): string {
return `${model.litellm_prefix}${model.id}`;
}
async function addFromCatalog(model: CatalogModel, aliasId: string) {
errorMsg = '';
const maxPri = Math.max(0, ...providersForAlias(aliasId).map((p) => p.priority));
try {
const res = await fetch('/api/admin/ai/providers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
alias_id: aliasId,
priority: maxPri + 1,
litellm_model: litellmModelId(model),
api_key_env: model.api_key_env
})
});
if (!res.ok) throw new Error('Feil ved opprettelse');
const row = await res.json();
providers = [...providers, row];
addingFromCatalog = null;
catalogAddAlias = '';
} catch {
errorMsg = 'Kunne ikke legge til provider fra katalog';
}
}
function selectFromPicker(model: CatalogModel) {
newProvider.litellm_model = litellmModelId(model);
newProvider.api_key_env = model.api_key_env;
showCatalogPicker = false;
catalogPickerSearch = '';
}
async function toggleAlias(alias: Alias) {
saving = alias.id;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/aliases/${alias.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !alias.is_active })
});
if (!res.ok) throw new Error('Feil ved lagring');
const updated = await res.json();
alias.is_active = updated.is_active;
markSaved(alias.id);
} catch {
errorMsg = 'Kunne ikke oppdatere alias';
} finally {
saving = null;
}
}
function startEditAlias(alias: Alias) {
editingAlias = alias.id;
editAliasName = alias.alias;
editAliasDesc = alias.description ?? '';
}
function cancelEditAlias() {
editingAlias = null;
editAliasName = '';
editAliasDesc = '';
}
async function saveAlias(alias: Alias) {
saving = alias.id;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/aliases/${alias.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias: editAliasName, description: editAliasDesc || null })
});
if (!res.ok) throw new Error('Feil ved lagring');
const updated = await res.json();
alias.alias = updated.alias;
alias.description = updated.description;
editingAlias = null;
markSaved(alias.id);
} catch {
errorMsg = 'Kunne ikke oppdatere alias';
} finally {
saving = null;
}
}
function estimateCost(row: UsageRow): number | null {
if (!catalogLoaded || !row.model_actual) return null;
// model_actual kan være "xai/grok-..." eller "google/gemma-..." — match mot id eller litellm_prefix+id
const actual = row.model_actual;
const model = catalogModels.find((m) =>
m.id === actual || `${m.litellm_prefix}${m.id}` === actual || `${m.provider}/${m.id}` === actual
);
if (!model || model.prompt_price_per_m < 0 || model.completion_price_per_m < 0) return null;
return (
(row.prompt_tokens / 1_000_000) * model.prompt_price_per_m +
(row.completion_tokens / 1_000_000) * model.completion_price_per_m
);
}
let totalEstimatedCost = $derived.by(() => {
if (!catalogLoaded) return null;
let total = 0;
let hasAny = false;
for (const row of usage) {
const cost = estimateCost(row);
if (cost !== null) {
total += cost;
hasAny = true;
}
}
return hasAny ? total : null;
});
function hasWebPlugin(provider: Provider): boolean {
const plugins = provider.extra_params?.plugins;
return Array.isArray(plugins) && plugins.some((p: any) => p.id === 'web');
}
async function toggleWebPlugin(provider: Provider) {
const has = hasWebPlugin(provider);
let newParams: Record<string, unknown> | null;
if (has) {
const { plugins, ...rest } = provider.extra_params ?? {};
newParams = Object.keys(rest).length > 0 ? rest : null;
} else {
newParams = { ...(provider.extra_params ?? {}), plugins: [{ id: 'web' }] };
}
saving = provider.id;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extra_params: newParams })
});
if (!res.ok) throw new Error('Feil');
const updated = await res.json();
provider.extra_params = updated.extra_params;
markSaved(provider.id);
} catch {
errorMsg = 'Kunne ikke oppdatere web-plugin';
} finally {
saving = null;
}
}
function startEditExtraParams(provider: Provider) {
editingExtraParams = provider.id;
editExtraParamsText = provider.extra_params ? JSON.stringify(provider.extra_params, null, 2) : '';
}
async function saveExtraParams(provider: Provider) {
saving = provider.id;
errorMsg = '';
try {
const parsed = editExtraParamsText.trim() ? JSON.parse(editExtraParamsText) : null;
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extra_params: parsed })
});
if (!res.ok) throw new Error('Feil');
const updated = await res.json();
provider.extra_params = updated.extra_params;
editingExtraParams = null;
markSaved(provider.id);
} catch (e: any) {
errorMsg = e.message === 'Feil' ? 'Kunne ikke lagre params' : `Ugyldig JSON: ${e.message}`;
} finally {
saving = null;
}
}
async function swapProviders(providerA: Provider, providerB: Provider) {
saving = providerA.id;
errorMsg = '';
try {
const res = await fetch('/api/admin/ai/providers/swap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_a: providerA.id, id_b: providerB.id })
});
if (!res.ok) throw new Error('Feil ved bytte');
const updated: Provider[] = await res.json();
// Oppdater providers-listen med nye prioriteter
for (const u of updated) {
const existing = providers.find(p => p.id === u.id);
if (existing) existing.priority = u.priority;
}
providers = [...providers];
markSaved(providerA.id);
} catch {
errorMsg = 'Kunne ikke bytte rekkefølge';
} finally {
saving = null;
}
}
async function toggleProvider(provider: Provider) {
saving = provider.id;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !provider.is_active })
});
if (!res.ok) throw new Error('Feil ved lagring');
const updated = await res.json();
provider.is_active = updated.is_active;
markSaved(provider.id);
} catch {
errorMsg = 'Kunne ikke oppdatere provider';
} finally {
saving = null;
}
}
async function addProvider(aliasId: string) {
errorMsg = '';
const maxPri = Math.max(0, ...providersForAlias(aliasId).map((p) => p.priority));
try {
const res = await fetch('/api/admin/ai/providers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
alias_id: aliasId,
priority: maxPri + 1,
litellm_model: newProvider.litellm_model,
api_key_env: newProvider.api_key_env
})
});
if (!res.ok) throw new Error('Feil ved opprettelse');
const row = await res.json();
providers = [...providers, row];
newProvider = { alias_id: '', litellm_model: '', api_key_env: 'OPENROUTER_API_KEY' };
} catch {
errorMsg = 'Kunne ikke legge til provider';
}
}
async function deleteProvider(provider: Provider) {
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Feil');
providers = providers.filter((p) => p.id !== provider.id);
// Renummerer prioriteter for gjenværende providers under samme alias
await renumberPriorities(provider.alias_id);
} catch {
errorMsg = 'Kunne ikke slette provider';
}
}
async function renumberPriorities(aliasId: string) {
const ap = providersForAlias(aliasId);
let changed = false;
for (let i = 0; i < ap.length; i++) {
if (ap[i].priority !== i + 1) {
ap[i].priority = i + 1;
changed = true;
}
}
if (!changed) return;
try {
await fetch('/api/admin/ai/providers/renumber', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ap.map(p => ({ id: p.id, priority: p.priority })))
});
providers = [...providers];
} catch { /* stille */ }
}
async function addAlias() {
errorMsg = '';
if (!newAlias.alias) return;
try {
const res = await fetch('/api/admin/ai/aliases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAlias)
});
if (!res.ok) throw new Error('Feil');
const row = await res.json();
aliases = [...aliases, row];
newAlias = { alias: '', description: '' };
} catch {
errorMsg = 'Kunne ikke opprette alias';
}
}
async function updateRouting(route: Route, aliasId: string) {
saving = route.job_type;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/routing/${route.job_type}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias_id: aliasId })
});
if (!res.ok) throw new Error('Feil');
const updated = await res.json();
route.alias_id = updated.alias_id;
route.alias = aliases.find((a) => a.id === updated.alias_id)?.alias ?? route.alias;
markSaved(route.job_type);
} catch {
errorMsg = 'Kunne ikke oppdatere ruting';
} finally {
saving = null;
}
}
async function addRoute() {
errorMsg = '';
if (!newRoute.job_type || !newRoute.alias_id) return;
try {
const res = await fetch('/api/admin/ai/routing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newRoute)
});
if (!res.ok) throw new Error('Feil');
const row = await res.json();
row.alias = aliases.find((a: Alias) => a.id === row.alias_id)?.alias ?? '';
routing = [...routing, row];
newRoute = { job_type: '', alias_id: '', description: '' };
} catch {
errorMsg = 'Kunne ikke legge til ruting';
}
}
function startEditPrompt(prompt: Prompt) {
editingPrompt = prompt.action;
editPromptText = prompt.system_prompt;
editPromptLabel = prompt.label ?? '';
editPromptIcon = prompt.icon ?? '';
}
function cancelEditPrompt() {
editingPrompt = null;
editPromptText = '';
editPromptLabel = '';
editPromptIcon = '';
}
async function savePrompt(prompt: Prompt) {
saving = prompt.action;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/prompts/${prompt.action}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_prompt: editPromptText,
label: editPromptLabel || null,
icon: editPromptIcon || null
})
});
if (!res.ok) throw new Error('Feil ved lagring');
const updated = await res.json();
prompt.system_prompt = updated.system_prompt;
prompt.label = updated.label;
prompt.icon = updated.icon;
prompt.updated_at = updated.updated_at;
editingPrompt = null;
editPromptText = '';
editPromptLabel = '';
editPromptIcon = '';
markSaved(prompt.action);
} catch {
errorMsg = 'Kunne ikke lagre prompt';
} finally {
saving = null;
}
}
let configLoading = $state(false);
async function generateConfig(restart = false) {
configMsg = '';
errorMsg = '';
configLoading = true;
try {
const url = restart
? '/api/admin/ai/generate-config?restart=true'
: '/api/admin/ai/generate-config';
const res = await fetch(url, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Feil');
configMsg = `${data.message} (${data.model_count} modeller)`;
} catch (e: any) {
errorMsg = e.message || 'Feil ved config-generering';
} finally {
configLoading = false;
}
}
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));
</script>
<div class="admin-ai">
<div class="header">
<h2>AI-administrasjon</h2>
<span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span>
</div>
<!-- API-nøkler -->
{#if keysLoaded}
<div class="key-pills">
{#each apiKeys as key}
<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} mangler nøkkelverdi`}
>
{key.label}
{#if !key.configured}
&#x2717;
{:else if key.is_enabled}
&#x2713;
{:else}
&#x25CB;
{/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}
{#if errorMsg}
<div class="error-msg">{errorMsg}</div>
{/if}
<!-- Seksjon 1: Modellkatalog -->
<section>
<div class="catalog-header">
<h3>Modellkatalog</h3>
<div class="catalog-actions">
{#if catalogLoaded}
<input
type="text"
class="catalog-search"
placeholder="Søk modeller..."
bind:value={catalogSearch}
/>
<button class="toggle-btn" onclick={() => { catalogLoaded = false; catalogModels = []; }}>Skjul</button>
{/if}
<button
class="toggle-btn"
onclick={loadCatalog}
disabled={catalogLoading}
>
{catalogLoading ? 'Laster...' : catalogLoaded ? 'Oppdater' : 'Last inn katalog'}
</button>
</div>
</div>
{#if catalogLoaded}
{#if groupedByProvider.length === 0}
<p class="hint">Ingen modeller matcher søket.</p>
{:else}
{#each groupedByProvider as [providerName, models] (providerName)}
<div class="provider-group">
<button class="provider-header" onclick={() => toggleCatalogProvider(providerName)}>
<span class="provider-chevron">{expandedProviders.has(providerName) ? '\u25BC' : '\u25B6'}</span>
<span class="provider-name">{providerName}</span>
<span class="provider-stats">{models.length} modeller</span>
</button>
{#if expandedProviders.has(providerName)}
<div class="catalog-list">
<div class="catalog-row catalog-row--header">
<span class="cat-col-name">Modell</span>
<span class="cat-col-ctx">Kontekst</span>
<span class="cat-col-price">Prompt/M</span>
<span class="cat-col-price">Kompl./M</span>
<span class="cat-col-add"></span>
</div>
{#each models as model (model.id)}
<div class="catalog-row">
<span class="cat-col-name" title={model.id}>{model.name}</span>
<span class="cat-col-ctx">{formatCtx(model.context_length)}</span>
<span class="cat-col-price">{formatPrice(model.prompt_price_per_m)}</span>
<span class="cat-col-price">{formatPrice(model.completion_price_per_m)}</span>
<span class="cat-col-add">
<button
class="toggle-btn"
onclick={() => { addingFromCatalog = addingFromCatalog === model.id ? null : model.id; catalogAddAlias = ''; }}
>{addingFromCatalog === model.id ? '&#x2717;' : 'Legg til &rarr;'}</button>
</span>
</div>
{#if addingFromCatalog === model.id}
<div class="catalog-add-row">
<span class="catalog-add-label">Legg til <strong>{model.name}</strong> via {model.api_key_env}:</span>
<select bind:value={catalogAddAlias}>
<option value="">Velg alias...</option>
{#each aliases as a}
<option value={a.id}>{a.alias}</option>
{/each}
</select>
<button
class="add-btn"
disabled={!catalogAddAlias}
onclick={() => addFromCatalog(model, catalogAddAlias)}
>Legg til</button>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/each}
{/if}
{/if}
</section>
<!-- Seksjon 2: Modellaliaser -->
<section>
<h3>Modellaliaser</h3>
<div class="table-list">
<div class="table-row table-row--header">
<span class="col-alias">Alias</span>
<span class="col-desc">Beskrivelse</span>
<span class="col-providers">Leverandører</span>
<span class="col-active">Aktiv</span>
<span class="col-status"></span>
</div>
{#each sortedAliases as alias (alias.id)}
{@const ap = providersForAlias(alias.id)}
{@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" />
<input type="text" class="alias-edit-input" bind:value={editAliasDesc} placeholder="Beskrivelse" />
<span></span>
<span class="alias-edit-actions">
<button class="add-btn" onclick={() => saveAlias(alias)}>Lagre</button>
<button class="toggle-btn" onclick={cancelEditAlias}>Avbryt</button>
</span>
<span class="col-status">
{#if saving === alias.id}
<span class="status-saving">...</span>
{/if}
</span>
</div>
{:else}
<div class="table-row" class:table-row--inactive={!alias.is_active}>
<span
class="col-alias clickable"
onclick={() => (expandedAlias = expandedAlias === alias.id ? null : alias.id)}
>
{alias.alias}
{#if primaryModel}
<span class="alias-model">{primaryModel.replace('openrouter/', '')}</span>
{/if}
</span>
<span class="col-desc">{alias.description ?? '\u2014'}</span>
<span class="col-providers">{ap.length}</span>
<span class="col-active alias-action-group">
<button class="toggle-btn" onclick={() => startEditAlias(alias)}>Rediger</button>
<button class="toggle-btn" onclick={() => toggleAlias(alias)}>
{alias.is_active ? 'På' : 'Av'}
</button>
</span>
<span class="col-status">
{#if saving === alias.id}
<span class="status-saving">...</span>
{:else if saved === alias.id}
<span class="status-saved">OK</span>
{/if}
</span>
</div>
{/if}
{#if expandedAlias === alias.id}
<div class="provider-list-alias">
{#each ap as provider, idx (provider.id)}
{@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">
<span class="pri-arrows">
<button
class="pri-btn"
disabled={idx === 0}
onclick={() => swapProviders(provider, ap[idx - 1])}
title="Flytt opp"
>&#x25B2;</button>
<button
class="pri-btn"
disabled={idx === ap.length - 1}
onclick={() => swapProviders(provider, ap[idx + 1])}
title="Flytt ned"
>&#x25BC;</button>
</span>
#{provider.priority}
</span>
<span class="col-model">{provider.litellm_model}</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"
class:extra-pill--on={hasWebPlugin(provider)}
onclick={() => toggleWebPlugin(provider)}
title="Web-søk (OpenRouter plugin)"
>web {hasWebPlugin(provider) ? '\u2713' : '\u2717'}</button>
{#if provider.extra_params && Object.keys(provider.extra_params).length > (hasWebPlugin(provider) ? 1 : 0)}
<span class="extra-pill extra-pill--custom" title={JSON.stringify(provider.extra_params)}>+params</span>
{/if}
<button class="extra-edit-btn" onclick={() => startEditExtraParams(provider)} title="Rediger extra_params JSON">&hellip;</button>
</span>
<span class="col-active">
<button class="toggle-btn" onclick={() => toggleProvider(provider)}>
{provider.is_active ? 'På' : 'Av'}
</button>
</span>
<button class="delete-btn" onclick={() => deleteProvider(provider)}>Slett</button>
<span class="col-status">
{#if saving === provider.id}
<span class="status-saving">...</span>
{:else if saved === provider.id}
<span class="status-saved">OK</span>
{/if}
</span>
</div>
{#if editingExtraParams === provider.id}
<div class="extra-params-editor">
<textarea
bind:value={editExtraParamsText}
rows="4"
placeholder={'{"plugins": [{"id": "web"}]}'}
></textarea>
<div class="extra-params-actions">
<button class="add-btn" onclick={() => saveExtraParams(provider)}>Lagre</button>
<button class="toggle-btn" onclick={() => { editingExtraParams = null; }}>Avbryt</button>
</div>
</div>
{/if}
{/each}
<div class="provider-row-alias provider-row--add">
<span class="col-pri"></span>
<div class="add-model-input">
<input
type="text"
placeholder="gemini/modell-navn"
bind:value={newProvider.litellm_model}
/>
{#if catalogLoaded}
<button
class="toggle-btn catalog-pick-btn"
onclick={() => { showCatalogPicker = !showCatalogPicker; catalogPickerSearch = ''; }}
>Katalog</button>
{/if}
</div>
<select bind:value={newProvider.api_key_env}>
{#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>
{#if showCatalogPicker && catalogLoaded}
<div class="catalog-picker">
<input
type="text"
class="catalog-picker-search"
placeholder="Søk i katalog..."
bind:value={catalogPickerSearch}
/>
<div class="catalog-picker-list">
{#each catalogPickerFiltered as model (model.id)}
<button
class="catalog-picker-item"
onclick={() => selectFromPicker(model)}
>
<span class="picker-name">{model.name}</span>
<span class="picker-meta">{formatCtx(model.context_length)} / {formatPrice(model.completion_price_per_m)}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
{/each}
</div>
<div class="add-form">
<input type="text" placeholder="sidelinja/nytt-alias" bind:value={newAlias.alias} />
<input type="text" placeholder="Beskrivelse" bind:value={newAlias.description} />
<button class="add-btn" onclick={addAlias}>Nytt alias</button>
</div>
</section>
<!-- Seksjon 3: Jobbruting -->
<section>
<h3>Jobbruting</h3>
<div class="table-list">
<div class="table-row table-row--header">
<span class="col-jobtype">Jobbtype</span>
<span class="col-alias">Modellalias</span>
<span class="col-desc">Beskrivelse</span>
<span class="col-status"></span>
</div>
{#each routing as route (route.job_type)}
<div class="table-row">
<span class="col-jobtype">{route.job_type}</span>
<span class="col-alias">
<select
value={route.alias_id}
onchange={(e) =>
updateRouting(route, (e.target as HTMLSelectElement).value)}
>
{#each aliases as a}
<option value={a.id}>{a.alias}</option>
{/each}
</select>
</span>
<span class="col-desc">{route.description ?? '\u2014'}</span>
<span class="col-status">
{#if saving === route.job_type}
<span class="status-saving">...</span>
{:else if saved === route.job_type}
<span class="status-saved">OK</span>
{/if}
</span>
</div>
{/each}
</div>
<div class="add-form">
<input type="text" placeholder="jobbtype" bind:value={newRoute.job_type} />
<select bind:value={newRoute.alias_id}>
<option value="">Velg alias...</option>
{#each aliases as a}
<option value={a.id}>{a.alias}</option>
{/each}
</select>
<input type="text" placeholder="Beskrivelse" bind:value={newRoute.description} />
<button class="add-btn" onclick={addRoute}>Legg til</button>
</div>
</section>
<!-- Seksjon 4: System-prompts -->
<section>
<h3>System-prompts</h3>
<div class="table-list">
<div class="table-row table-row--header prompt-row">
<span class="col-action">Action</span>
<span class="col-label">Visningsnavn</span>
<span class="col-desc">Beskrivelse</span>
<span class="col-chars">Tegn</span>
<span class="col-updated">Oppdatert</span>
<span class="col-edit"></span>
</div>
{#each prompts as prompt (prompt.action)}
<div class="table-row prompt-row">
<span class="col-action">{prompt.action}</span>
<span class="col-label">{prompt.icon ?? ''} {prompt.label ?? '\u2014'}</span>
<span class="col-desc">{prompt.description ?? '\u2014'}</span>
<span class="col-chars">{prompt.system_prompt.length}</span>
<span class="col-updated">{new Date(prompt.updated_at).toLocaleDateString('nb-NO')}</span>
<span class="col-edit">
{#if saving === prompt.action}
<span class="status-saving">...</span>
{:else if saved === prompt.action}
<span class="status-saved">OK</span>
{:else}
<button class="toggle-btn" onclick={() => startEditPrompt(prompt)}>Rediger</button>
{/if}
</span>
</div>
{#if editingPrompt === prompt.action}
<div class="prompt-editor">
<div class="prompt-editor-meta">
<label>
<span class="prompt-meta-label">Ikon</span>
<input type="text" bind:value={editPromptIcon} placeholder="🧹" class="prompt-meta-input prompt-meta-input--icon" />
</label>
<label>
<span class="prompt-meta-label">Visningsnavn</span>
<input type="text" bind:value={editPromptLabel} placeholder="Vask tekst" class="prompt-meta-input" />
</label>
</div>
<textarea
bind:value={editPromptText}
rows="12"
></textarea>
<div class="prompt-editor-footer">
<span class="prompt-char-count">{editPromptText.length} tegn</span>
<div class="prompt-editor-actions">
<button class="toggle-btn" onclick={cancelEditPrompt}>Avbryt</button>
<button class="add-btn" onclick={() => savePrompt(prompt)}>Lagre</button>
</div>
</div>
</div>
{/if}
{/each}
</div>
</section>
<!-- Seksjon 5: Tokenforbruk -->
<section>
<div class="catalog-header">
<h3>Tokenforbruk (siste 30 dager)</h3>
{#if totalEstimatedCost !== null}
<span class="usage-total-cost">Estimert totalkostnad: ${totalEstimatedCost.toFixed(2)}</span>
{/if}
</div>
{#if usage.length === 0}
<p class="hint">Ingen AI-kall registrert ennå.</p>
{:else}
<div class="table-list">
<div class="table-row table-row--header usage-row">
<span>Alias</span>
<span>Modell</span>
<span>Prompt</span>
<span class="col-num">Kall</span>
<span class="col-num">Prompt-tok.</span>
<span class="col-num">Kompl.</span>
<span class="col-num">Totalt</span>
<span class="col-num">Est. $</span>
</div>
{#each sortedUsage as row}
{@const cost = estimateCost(row)}
<div class="table-row usage-row">
<span class="col-alias">{row.model_alias}</span>
<span class="col-model-actual">{row.model_actual ?? '\u2014'}</span>
<span class="col-action-usage">{row.action ?? '\u2014'}</span>
<span class="col-num">{row.call_count}</span>
<span class="col-num">{row.prompt_tokens.toLocaleString('nb-NO')}</span>
<span class="col-num">{row.completion_tokens.toLocaleString('nb-NO')}</span>
<span class="col-num">{row.total_tokens.toLocaleString('nb-NO')}</span>
<span class="col-num">{cost !== null ? `$${cost.toFixed(2)}` : '\u2014'}</span>
</div>
{/each}
</div>
{#if !catalogLoaded}
<p class="hint">Last inn modellkatalogen for å se estimerte kostnader.</p>
{/if}
{/if}
</section>
<!-- Seksjon 6: Konfigurasjon -->
<section>
<h3>Konfigurasjon</h3>
<div class="config-box">
<button class="generate-btn" onclick={() => generateConfig(false)} disabled={configLoading}>
Generer config.yaml
</button>
<button class="generate-btn generate-btn--restart" onclick={() => generateConfig(true)} disabled={configLoading}>
{configLoading ? 'Jobber...' : 'Generer + restart gateway'}
</button>
{#if configMsg}
<span class="config-msg">{configMsg}</span>
{/if}
<p class="hint">Genererer LiteLLM config.yaml fra databasen. «Generer + restart» aktiverer endringene umiddelbart.</p>
</div>
</section>
</div>
<style>
.admin-ai {
max-width: 960px;
}
.header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.75rem;
}
h2 {
font-size: 1.4rem;
}
h3 {
font-size: 1.1rem;
margin-bottom: 0.75rem;
}
.header-stats {
font-size: 0.8rem;
color: #8b92a5;
}
/* API-nøkkel-pills */
.key-pills {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.key-pill {
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
letter-spacing: 0.03em;
cursor: pointer;
transition: opacity 0.15s;
}
.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;
}
.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 {
margin-bottom: 2rem;
}
.error-msg {
background: #3b1219;
border: 1px solid #6b2028;
color: #f87171;
padding: 0.5rem 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
/* Modellkatalog */
.catalog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.catalog-header h3 {
margin-bottom: 0;
}
.catalog-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.catalog-search {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.3rem 0.5rem;
font-size: 0.8rem;
width: 200px;
}
.catalog-search:focus {
outline: none;
border-color: #3b82f6;
}
.provider-group {
margin-bottom: 0.5rem;
}
.provider-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: #1a1d2e;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
cursor: pointer;
font-size: 0.85rem;
text-align: left;
}
.provider-header:hover {
border-color: #3b82f6;
}
.provider-chevron {
font-size: 0.6rem;
color: #8b92a5;
width: 1em;
}
.provider-name {
font-weight: 600;
flex: 1;
}
.provider-stats {
font-size: 0.75rem;
color: #8b92a5;
}
.catalog-list {
display: flex;
flex-direction: column;
gap: 1px;
background: #2d3148;
border: 1px solid #2d3148;
border-top: none;
border-radius: 0 0 6px 6px;
overflow: hidden;
}
.catalog-row {
display: grid;
grid-template-columns: 3fr 70px 80px 80px 100px;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: #161822;
font-size: 0.8rem;
}
.catalog-row--header {
background: #1a1d2e;
font-weight: 600;
color: #8b92a5;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cat-col-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cat-col-ctx {
text-align: right;
font-variant-numeric: tabular-nums;
color: #8b92a5;
font-size: 0.75rem;
}
.cat-col-price {
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
}
.cat-col-add {
text-align: right;
}
.cat-col-add select {
font-size: 0.7rem;
padding: 0.15rem 0.25rem;
}
.catalog-add-row {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.75rem;
background: #1a1d2e;
border-left: 3px solid #4ade80;
font-size: 0.8rem;
}
.catalog-add-row select {
font-size: 0.75rem;
padding: 0.2rem 0.3rem;
}
.catalog-add-label {
color: #8b92a5;
white-space: nowrap;
font-size: 0.75rem;
}
/* Alias table */
.table-list {
display: flex;
flex-direction: column;
gap: 1px;
background: #2d3148;
border: 1px solid #2d3148;
border-radius: 6px;
overflow: hidden;
}
.table-row {
display: grid;
grid-template-columns: 2fr 2fr 1fr 60px 40px;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #161822;
font-size: 0.8rem;
}
.table-row--header {
background: #1a1d2e;
font-weight: 600;
color: #8b92a5;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table-row--inactive {
opacity: 0.5;
}
.col-alias {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alias-model {
display: block;
font-size: 0.75em;
color: #8b92a5;
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clickable {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.col-desc {
color: #8b92a5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-providers, .col-active, .col-status {
text-align: center;
}
/* Alias edit */
.alias-edit-row {
grid-template-columns: 2fr 2fr 1fr auto 40px;
}
.alias-edit-input {
width: 100%;
}
.alias-edit-actions {
display: flex;
gap: 0.35rem;
white-space: nowrap;
}
.alias-action-group {
display: flex;
gap: 0.25rem;
justify-content: center;
}
.col-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.col-jobtype {
font-family: monospace;
}
.col-pri {
color: #8b92a5;
font-size: 0.75rem;
width: 60px;
display: flex;
align-items: center;
gap: 0.25rem;
}
.pri-arrows {
display: flex;
flex-direction: column;
gap: 0;
}
.pri-btn {
background: none;
border: none;
color: #6c7086;
font-size: 0.55rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.pri-btn:hover:not(:disabled) {
color: #cdd6f4;
}
.pri-btn:disabled {
opacity: 0.2;
cursor: default;
}
.col-model {
font-family: monospace;
font-size: 0.75rem;
}
.col-key {
font-size: 0.75rem;
color: #8b92a5;
}
/* Provider sub-list (alias section) */
.provider-list-alias {
background: #0f1117;
padding: 0.5rem 0.75rem 0.5rem 2rem;
border-bottom: 1px solid #2d3148;
}
.provider-row-alias {
display: grid;
grid-template-columns: 30px 2fr 1fr auto 50px 50px 40px;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
font-size: 0.8rem;
}
.provider-row--inactive {
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;
margin-top: 0.25rem;
}
.add-model-input {
display: flex;
gap: 0.25rem;
align-items: center;
}
.add-model-input input {
flex: 1;
min-width: 0;
}
.catalog-pick-btn {
white-space: nowrap;
flex-shrink: 0;
}
/* Catalog picker dropdown */
.catalog-picker {
background: #161822;
border: 1px solid #3b82f6;
border-radius: 6px;
padding: 0.5rem;
margin-top: 0.5rem;
}
.catalog-picker-search {
width: 100%;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.3rem 0.5rem;
font-size: 0.8rem;
margin-bottom: 0.35rem;
}
.catalog-picker-search:focus {
outline: none;
border-color: #3b82f6;
}
.catalog-picker-list {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
}
.catalog-picker-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0.5rem;
background: #0f1117;
border: none;
color: #e1e4e8;
font-size: 0.75rem;
cursor: pointer;
text-align: left;
border-radius: 3px;
}
.catalog-picker-item:hover {
background: #1a1d2e;
}
.picker-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.picker-meta {
color: #8b92a5;
font-size: 0.7rem;
flex-shrink: 0;
margin-left: 0.5rem;
}
/* Extra params */
.col-extra-pills {
display: flex;
gap: 0.25rem;
align-items: center;
}
.extra-pill {
font-size: 0.6rem;
padding: 0.1rem 0.4rem;
border-radius: 9999px;
border: 1px solid #2d3148;
background: #1a1d2e;
color: #8b92a5;
cursor: pointer;
white-space: nowrap;
}
.extra-pill:hover {
border-color: #3b82f6;
}
.extra-pill--on {
background: #0d3320;
border-color: #166534;
color: #4ade80;
}
.extra-pill--custom {
background: #2d1f4e;
border-color: #6d4aaa;
color: #c4b5fd;
cursor: default;
}
.extra-edit-btn {
background: none;
border: none;
color: #8b92a5;
font-size: 0.75rem;
cursor: pointer;
padding: 0 0.2rem;
}
.extra-edit-btn:hover {
color: #e1e4e8;
}
.extra-params-editor {
background: #161822;
border: 1px solid #3b82f6;
border-radius: 6px;
padding: 0.5rem;
margin: 0.35rem 0;
}
.extra-params-editor textarea {
width: 100%;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.4rem;
font-size: 0.75rem;
font-family: monospace;
resize: vertical;
}
.extra-params-editor textarea:focus {
outline: none;
border-color: #3b82f6;
}
.extra-params-actions {
display: flex;
gap: 0.35rem;
margin-top: 0.35rem;
}
/* Buttons */
.toggle-btn {
background: #1a1d2e;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
cursor: pointer;
}
.toggle-btn:hover {
border-color: #3b82f6;
}
.toggle-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn {
background: none;
border: none;
color: #6b2028;
font-size: 0.7rem;
cursor: pointer;
padding: 0.15rem 0.35rem;
}
.delete-btn:hover {
color: #f87171;
}
.add-btn {
background: #1a1d2e;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
cursor: pointer;
}
.add-btn:hover {
border-color: #3b82f6;
}
.generate-btn {
background: #1e3a5f;
border: 1px solid #3b82f6;
border-radius: 6px;
color: #e1e4e8;
padding: 0.5rem 1rem;
font-size: 0.85rem;
cursor: pointer;
}
.generate-btn:hover:not(:disabled) {
background: #264b7a;
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.generate-btn--restart {
background: #1e4f3a;
border-color: #22c55e;
}
.generate-btn--restart:hover:not(:disabled) {
background: #276b4f;
}
/* Forms */
.add-form {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
input[type='text'] {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.3rem 0.5rem;
font-size: 0.8rem;
flex: 1;
}
select {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.3rem 0.5rem;
font-size: 0.8rem;
}
input:focus,
select:focus {
outline: none;
border-color: #3b82f6;
}
.status-saving {
color: #8b92a5;
}
.status-saved {
color: #4ade80;
}
.config-box {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.config-msg {
color: #4ade80;
font-size: 0.85rem;
}
.hint {
color: #8b92a5;
font-size: 0.8rem;
font-style: italic;
}
/* Usage/tokenforbruk */
.usage-row {
grid-template-columns: 1fr 2fr 1fr 60px 80px 80px 80px 60px;
}
.col-action-usage {
font-size: 0.75rem;
color: #8b92a5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-model-actual {
font-family: monospace;
font-size: 0.7rem;
color: #8b92a5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-total-cost {
font-size: 0.85rem;
color: #4ade80;
font-weight: 600;
}
/* Prompt-seksjon */
.prompt-row {
grid-template-columns: 1.2fr 1.2fr 2fr 60px 80px 70px;
}
.col-label {
font-size: 0.85rem;
}
.col-action {
font-family: monospace;
}
.col-chars {
text-align: right;
font-variant-numeric: tabular-nums;
color: #8b92a5;
font-size: 0.75rem;
}
.col-updated {
color: #8b92a5;
font-size: 0.75rem;
}
.col-edit {
text-align: center;
}
.prompt-editor {
background: #0f1117;
padding: 0.75rem;
border-bottom: 1px solid #2d3148;
}
.prompt-editor-meta {
display: flex;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.prompt-editor-meta label {
display: flex;
align-items: center;
gap: 0.35rem;
}
.prompt-meta-label {
font-size: 0.75rem;
color: #8b92a5;
}
.prompt-meta-input {
background: #161822;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.25rem 0.4rem;
font-size: 0.8rem;
width: 160px;
}
.prompt-meta-input--icon {
width: 50px;
text-align: center;
}
.prompt-meta-input:focus {
outline: none;
border-color: #3b82f6;
}
.prompt-editor textarea {
width: 100%;
background: #161822;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.5rem;
font-size: 0.8rem;
font-family: monospace;
line-height: 1.5;
resize: vertical;
}
.prompt-editor textarea:focus {
outline: none;
border-color: #3b82f6;
}
.prompt-editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.prompt-char-count {
color: #8b92a5;
font-size: 0.75rem;
}
.prompt-editor-actions {
display: flex;
gap: 0.5rem;
}
</style>