Fullfører oppgave 15.4: AI Gateway admin-UI

Implementerer /admin/ai med fire faner:
- Modeller & fallback: oversikt over aliaser med fallback-kjeder,
  toggle aktiv/inaktiv, legg til/fjern modeller, endre prioritet
- Ruting: jobbtype → modellalias mapping med dropdown-endring
- Forbruk: aggregert tokenforbruk per samling/alias/jobbtype
- API-nøkler: viser hvilke env-variabler som er satt (aldri verdier)

Backend (maskinrommet/src/ai_admin.rs):
- GET /admin/ai — full oversikt med aliaser, providers, ruting, forbruk
- GET /admin/ai/usage — forbruk med filtre (dager, samling)
- POST-endepunkter for CRUD på aliaser, providers og ruting-regler

PG er single source of truth. API-nøkler lagres kun som env-variabler,
admin-panelet viser bare om de er satt eller mangler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 03:51:45 +00:00
parent 6accce99e6
commit 3fbe42f207
6 changed files with 1321 additions and 2 deletions

View file

@ -805,3 +805,143 @@ export function cancelJob(
): Promise<{ success: boolean }> {
return post(accessToken, '/intentions/cancel_job', { job_id: jobId });
}
// =============================================================================
// AI Gateway-konfigurasjon (oppgave 15.4)
// =============================================================================
export interface AiModelAlias {
id: string;
alias: string;
description: string | null;
is_active: boolean;
created_at: string;
}
export interface AiModelProvider {
id: string;
alias_id: string;
provider: string;
model: string;
api_key_env: string;
priority: number;
is_active: boolean;
}
export interface AiJobRouting {
job_type: string;
alias: string;
description: string | null;
}
export interface AiUsageSummary {
collection_node_id: string | null;
collection_title: string | null;
model_alias: string;
job_type: string | null;
total_prompt_tokens: number;
total_completion_tokens: number;
total_tokens: number;
estimated_cost: number;
call_count: number;
}
export interface ApiKeyStatus {
env_var: string;
is_set: boolean;
}
export interface AiOverviewResponse {
aliases: AiModelAlias[];
providers: AiModelProvider[];
routing: AiJobRouting[];
usage: AiUsageSummary[];
api_key_status: ApiKeyStatus[];
}
/** Hent AI Gateway-oversikt (aliaser, providers, ruting, forbruk). */
export async function fetchAiOverview(accessToken: string): Promise<AiOverviewResponse> {
const res = await fetch(`${BASE_URL}/admin/ai`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`ai overview failed (${res.status}): ${body}`);
}
return res.json();
}
/** Hent AI-forbruksoversikt med filtre. */
export async function fetchAiUsage(
accessToken: string,
params: { days?: number; collection_id?: string } = {}
): Promise<AiUsageSummary[]> {
const sp = new URLSearchParams();
if (params.days) sp.set('days', String(params.days));
if (params.collection_id) sp.set('collection_id', params.collection_id);
const qs = sp.toString();
const res = await fetch(`${BASE_URL}/admin/ai/usage${qs ? `?${qs}` : ''}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`ai usage failed (${res.status}): ${body}`);
}
return res.json();
}
/** Oppdater modellalias (beskrivelse, aktiv-status). */
export function updateAiAlias(
accessToken: string,
data: { id: string; description: string | null; is_active: boolean }
): Promise<{ success: boolean }> {
return post(accessToken, '/admin/ai/update_alias', data);
}
/** Opprett nytt modellalias. */
export function createAiAlias(
accessToken: string,
data: { alias: string; description: string | null }
): Promise<{ id: string; success: boolean }> {
return post(accessToken, '/admin/ai/create_alias', data);
}
/** Oppdater provider (prioritet, aktiv-status). */
export function updateAiProvider(
accessToken: string,
data: { id: string; priority?: number; is_active?: boolean }
): Promise<{ success: boolean }> {
return post(accessToken, '/admin/ai/update_provider', data);
}
/** Legg til ny provider på et alias. */
export function createAiProvider(
accessToken: string,
data: { alias_id: string; provider: string; model: string; api_key_env: string; priority: number }
): Promise<{ id: string; success: boolean }> {
return post(accessToken, '/admin/ai/create_provider', data);
}
/** Slett en provider. */
export function deleteAiProvider(
accessToken: string,
id: string
): Promise<{ success: boolean }> {
return post(accessToken, '/admin/ai/delete_provider', { id });
}
/** Oppdater eller opprett rutingregel (jobbtype → alias). */
export function updateAiRouting(
accessToken: string,
data: { job_type: string; alias: string; description: string | null }
): Promise<{ success: boolean }> {
return post(accessToken, '/admin/ai/update_routing', data);
}
/** Slett en rutingregel. */
export function deleteAiRouting(
accessToken: string,
jobType: string
): Promise<{ success: boolean }> {
return post(accessToken, '/admin/ai/delete_routing', { job_type: jobType });
}

View file

@ -100,6 +100,9 @@
<a href="/admin/jobs" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
Jobbkø
</a>
<a href="/admin/ai" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
AI Gateway
</a>
</nav>
</div>
</header>

View file

@ -0,0 +1,693 @@
<script lang="ts">
/**
* Admin — AI Gateway-konfigurasjon (oppgave 15.4)
*
* Modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype,
* fallback-kjeder, forbruksoversikt per samling.
* Ref: docs/infra/ai_gateway.md
*/
import { page } from '$app/stores';
import {
fetchAiOverview,
fetchAiUsage,
updateAiAlias,
createAiAlias,
updateAiProvider,
createAiProvider,
deleteAiProvider,
updateAiRouting,
deleteAiRouting,
type AiOverviewResponse,
type AiModelAlias,
type AiModelProvider,
type AiJobRouting,
type AiUsageSummary,
type ApiKeyStatus
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let data = $state<AiOverviewResponse | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let actionLoading = $state<string | null>(null);
// Tabs
let activeTab = $state<'aliases' | 'routing' | 'usage' | 'keys'>('aliases');
// Forbruksperiode
let usageDays = $state(30);
// Nytt alias-skjema
let showNewAlias = $state(false);
let newAliasName = $state('');
let newAliasDesc = $state('');
// Ny provider-skjema
let showNewProvider = $state<string | null>(null); // alias_id
let newProviderName = $state('');
let newProviderModel = $state('');
let newProviderKeyEnv = $state('');
let newProviderPriority = $state(10);
// Ny ruting-skjema
let showNewRouting = $state(false);
let newRoutingJobType = $state('');
let newRoutingAlias = $state('');
let newRoutingDesc = $state('');
// Poll hvert 10. sekund (AI-config endres sjelden)
$effect(() => {
if (!accessToken) return;
loadData();
const interval = setInterval(loadData, 10000);
return () => clearInterval(interval);
});
async function loadData() {
if (!accessToken) return;
try {
data = await fetchAiOverview(accessToken);
error = null;
} catch (e) {
error = String(e);
}
}
// Hjelpefunksjoner
function providersForAlias(alias: AiModelAlias): AiModelProvider[] {
if (!data) return [];
return data.providers
.filter((p) => p.alias_id === alias.id)
.sort((a, b) => a.priority - b.priority);
}
function keyStatus(envVar: string): boolean {
if (!data) return false;
return data.api_key_status.find((k) => k.env_var === envVar)?.is_set ?? false;
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
function formatCost(cost: number): string {
if (cost < 0.01) return `$${cost.toFixed(4)}`;
return `$${cost.toFixed(2)}`;
}
// Handlinger
async function toggleAlias(alias: AiModelAlias) {
if (!accessToken) return;
actionLoading = `alias-${alias.id}`;
try {
await updateAiAlias(accessToken, {
id: alias.id,
description: alias.description,
is_active: !alias.is_active
});
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
async function toggleProvider(provider: AiModelProvider) {
if (!accessToken) return;
actionLoading = `provider-${provider.id}`;
try {
await updateAiProvider(accessToken, {
id: provider.id,
is_active: !provider.is_active
});
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
async function changeProviderPriority(provider: AiModelProvider, newPriority: number) {
if (!accessToken) return;
actionLoading = `priority-${provider.id}`;
try {
await updateAiProvider(accessToken, {
id: provider.id,
priority: newPriority
});
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
async function removeProvider(provider: AiModelProvider) {
if (!accessToken) return;
actionLoading = `delete-${provider.id}`;
try {
await deleteAiProvider(accessToken, provider.id);
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
async function handleCreateAlias() {
if (!accessToken || !newAliasName.trim()) return;
actionLoading = 'create-alias';
try {
await createAiAlias(accessToken, {
alias: newAliasName.trim(),
description: newAliasDesc.trim() || null
});
newAliasName = '';
newAliasDesc = '';
showNewAlias = false;
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
async function handleCreateProvider() {
if (!accessToken || !showNewProvider || !newProviderModel.trim()) return;
actionLoading = 'create-provider';
try {
await createAiProvider(accessToken, {
alias_id: showNewProvider,
provider: newProviderName.trim(),
model: newProviderModel.trim(),
api_key_env: newProviderKeyEnv.trim(),
priority: newProviderPriority
});
newProviderName = '';
newProviderModel = '';
newProviderKeyEnv = '';
newProviderPriority = 10;
showNewProvider = null;
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
async function handleUpdateRouting(routing: AiJobRouting, newAlias: string) {
if (!accessToken) return;
actionLoading = `routing-${routing.job_type}`;
try {
await updateAiRouting(accessToken, {
job_type: routing.job_type,
alias: newAlias,
description: routing.description
});
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
async function handleCreateRouting() {
if (!accessToken || !newRoutingJobType.trim() || !newRoutingAlias.trim()) return;
actionLoading = 'create-routing';
try {
await updateAiRouting(accessToken, {
job_type: newRoutingJobType.trim(),
alias: newRoutingAlias.trim(),
description: newRoutingDesc.trim() || null
});
newRoutingJobType = '';
newRoutingAlias = '';
newRoutingDesc = '';
showNewRouting = false;
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
async function handleDeleteRouting(jobType: string) {
if (!accessToken) return;
actionLoading = `delete-routing-${jobType}`;
try {
await deleteAiRouting(accessToken, jobType);
await loadData();
} catch (e) {
error = String(e);
}
actionLoading = null;
}
</script>
<div class="min-h-screen bg-gray-50">
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/admin" class="text-sm text-gray-500 hover:text-gray-700">Admin</a>
<span class="text-gray-300">/</span>
<h1 class="text-lg font-semibold text-gray-900">AI Gateway</h1>
</div>
</div>
</header>
<main class="mx-auto max-w-5xl px-4 py-6">
{#if !accessToken}
<p class="text-sm text-gray-400">Logg inn for tilgang.</p>
{:else if !data}
<p class="text-sm text-gray-400">Laster AI-konfigurasjon...</p>
{:else}
<!-- Feilmelding -->
{#if error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
{/if}
<!-- Tabs -->
<div class="mb-6 flex gap-1 rounded-lg bg-gray-100 p-1">
{#each [
{ key: 'aliases', label: 'Modeller & fallback' },
{ key: 'routing', label: 'Ruting' },
{ key: 'usage', label: 'Forbruk' },
{ key: 'keys', label: 'API-nøkler' }
] as tab}
<button
class="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {activeTab === tab.key ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-800'}"
onclick={() => (activeTab = tab.key as typeof activeTab)}
>
{tab.label}
</button>
{/each}
</div>
<!-- === TAB: Modeller & fallback === -->
{#if activeTab === 'aliases'}
<div class="space-y-4">
{#each data.aliases as alias}
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
<!-- Alias header -->
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-3">
<div class="flex items-center gap-3">
<span
class="h-2 w-2 rounded-full {alias.is_active ? 'bg-green-500' : 'bg-gray-300'}"
></span>
<span class="font-mono text-sm font-semibold text-gray-900"
>{alias.alias}</span
>
{#if alias.description}
<span class="text-xs text-gray-500">{alias.description}</span>
{/if}
</div>
<button
class="rounded px-2 py-1 text-xs {alias.is_active ? 'text-amber-600 hover:bg-amber-50' : 'text-green-600 hover:bg-green-50'}"
disabled={actionLoading === `alias-${alias.id}`}
onclick={() => toggleAlias(alias)}
>
{alias.is_active ? 'Deaktiver' : 'Aktiver'}
</button>
</div>
<!-- Providers (fallback-kjede) -->
<div class="px-5 py-3">
<div class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-500">
Fallback-kjede (prioritet)
</div>
<div class="space-y-2">
{#each providersForAlias(alias) as provider, idx}
<div
class="flex items-center gap-3 rounded-md border px-3 py-2 text-sm {provider.is_active ? 'border-gray-200 bg-gray-50' : 'border-gray-100 bg-gray-50/50 opacity-60'}"
>
<span
class="flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-xs font-medium text-gray-600"
>{provider.priority}</span
>
<div class="flex-1">
<span class="font-mono text-xs text-gray-800">{provider.model}</span>
<span class="ml-2 text-xs text-gray-400">({provider.provider})</span>
</div>
<span
class="inline-flex items-center gap-1 text-xs {keyStatus(provider.api_key_env) ? 'text-green-600' : 'text-red-500'}"
>
<span
class="h-1.5 w-1.5 rounded-full {keyStatus(provider.api_key_env) ? 'bg-green-500' : 'bg-red-400'}"
></span>
{provider.api_key_env}
</span>
<!-- Prioritet-knapper -->
<div class="flex gap-1">
{#if idx > 0}
<button
class="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-200"
title="Flytt opp"
onclick={() => changeProviderPriority(provider, provider.priority - 1)}
>
&uarr;
</button>
{/if}
{#if idx < providersForAlias(alias).length - 1}
<button
class="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-200"
title="Flytt ned"
onclick={() => changeProviderPriority(provider, provider.priority + 1)}
>
&darr;
</button>
{/if}
</div>
<button
class="rounded px-2 py-0.5 text-xs {provider.is_active ? 'text-amber-600 hover:bg-amber-50' : 'text-green-600 hover:bg-green-50'}"
onclick={() => toggleProvider(provider)}
disabled={actionLoading === `provider-${provider.id}`}
>
{provider.is_active ? 'Av' : 'På'}
</button>
<button
class="rounded px-2 py-0.5 text-xs text-red-500 hover:bg-red-50"
onclick={() => removeProvider(provider)}
disabled={actionLoading === `delete-${provider.id}`}
>
Slett
</button>
</div>
{/each}
</div>
<!-- Legg til provider -->
{#if showNewProvider === alias.id}
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<input
bind:value={newProviderName}
placeholder="Leverandør (f.eks. gemini)"
class="rounded border border-gray-300 px-2 py-1 text-sm"
/>
<input
bind:value={newProviderModel}
placeholder="Modell (f.eks. gemini/gemini-2.5-flash)"
class="rounded border border-gray-300 px-2 py-1 text-sm"
/>
<input
bind:value={newProviderKeyEnv}
placeholder="Env-variabel (f.eks. GEMINI_API_KEY)"
class="rounded border border-gray-300 px-2 py-1 text-sm"
/>
<input
bind:value={newProviderPriority}
type="number"
min="1"
max="99"
placeholder="Prioritet"
class="rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div class="mt-2 flex gap-2">
<button
class="rounded-lg bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
onclick={handleCreateProvider}
disabled={actionLoading === 'create-provider' || !newProviderModel.trim()}
>
Legg til
</button>
<button
class="rounded-lg px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
onclick={() => (showNewProvider = null)}
>
Avbryt
</button>
</div>
</div>
{:else}
<button
class="mt-2 text-xs text-blue-600 hover:text-blue-800"
onclick={() => (showNewProvider = alias.id)}
>
+ Legg til modell
</button>
{/if}
</div>
</section>
{/each}
<!-- Nytt alias -->
{#if showNewAlias}
<section class="rounded-lg border border-blue-200 bg-blue-50 p-5 shadow-sm">
<h3 class="mb-3 text-sm font-semibold text-gray-800">Nytt modellalias</h3>
<div class="flex gap-3">
<input
bind:value={newAliasName}
placeholder="Alias (f.eks. sidelinja/lokal)"
class="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm"
/>
<input
bind:value={newAliasDesc}
placeholder="Beskrivelse"
class="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm"
/>
</div>
<div class="mt-3 flex gap-2">
<button
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
onclick={handleCreateAlias}
disabled={actionLoading === 'create-alias' || !newAliasName.trim()}
>
Opprett
</button>
<button
class="rounded-lg px-4 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
onclick={() => (showNewAlias = false)}
>
Avbryt
</button>
</div>
</section>
{:else}
<button
class="rounded-lg border border-dashed border-gray-300 px-4 py-2 text-sm text-gray-500 hover:border-gray-400 hover:text-gray-700"
onclick={() => (showNewAlias = true)}
>
+ Nytt modellalias
</button>
{/if}
</div>
<!-- === TAB: Ruting === -->
{:else if activeTab === 'routing'}
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="border-b border-gray-100 px-5 py-3">
<h2 class="text-sm font-semibold text-gray-800">Jobbtype → Modellalias</h2>
<p class="mt-1 text-xs text-gray-500">
Hvilken modellalias brukes for hvilken jobbtype i jobbkøen.
</p>
</div>
<div class="divide-y divide-gray-100">
{#each data.routing as routing}
<div class="flex items-center gap-3 px-5 py-3">
<div class="flex-1">
<span class="font-mono text-sm text-gray-800">{routing.job_type}</span>
{#if routing.description}
<span class="ml-2 text-xs text-gray-400">{routing.description}</span>
{/if}
</div>
<select
class="rounded border border-gray-300 px-2 py-1 text-sm"
value={routing.alias}
onchange={(e) => handleUpdateRouting(routing, (e.target as HTMLSelectElement).value)}
disabled={actionLoading === `routing-${routing.job_type}`}
>
{#each data.aliases as alias}
<option value={alias.alias}>{alias.alias}</option>
{/each}
</select>
<button
class="rounded px-2 py-1 text-xs text-red-500 hover:bg-red-50"
onclick={() => handleDeleteRouting(routing.job_type)}
disabled={actionLoading === `delete-routing-${routing.job_type}`}
>
Slett
</button>
</div>
{/each}
{#if data.routing.length === 0}
<p class="px-5 py-4 text-sm text-gray-400">Ingen ruting-regler konfigurert.</p>
{/if}
</div>
<!-- Ny ruting -->
{#if showNewRouting}
<div class="border-t border-blue-200 bg-blue-50 px-5 py-3">
<div class="flex gap-2">
<input
bind:value={newRoutingJobType}
placeholder="Jobbtype (f.eks. ai_text_process)"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<select
bind:value={newRoutingAlias}
class="rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value="">Velg alias...</option>
{#each data.aliases as alias}
<option value={alias.alias}>{alias.alias}</option>
{/each}
</select>
<input
bind:value={newRoutingDesc}
placeholder="Beskrivelse (valgfri)"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div class="mt-2 flex gap-2">
<button
class="rounded-lg bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
onclick={handleCreateRouting}
disabled={actionLoading === 'create-routing' ||
!newRoutingJobType.trim() ||
!newRoutingAlias.trim()}
>
Legg til
</button>
<button
class="rounded-lg px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
onclick={() => (showNewRouting = false)}
>
Avbryt
</button>
</div>
</div>
{:else}
<div class="border-t border-gray-100 px-5 py-3">
<button
class="text-xs text-blue-600 hover:text-blue-800"
onclick={() => (showNewRouting = true)}
>
+ Ny rutingregel
</button>
</div>
{/if}
</section>
<!-- === TAB: Forbruk === -->
{:else if activeTab === 'usage'}
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-3">
<h2 class="text-sm font-semibold text-gray-800">Forbruksoversikt</h2>
<select
class="rounded border border-gray-300 px-2 py-1 text-sm"
bind:value={usageDays}
>
<option value={7}>Siste 7 dager</option>
<option value={30}>Siste 30 dager</option>
<option value={90}>Siste 90 dager</option>
</select>
</div>
{#if data.usage.length === 0}
<p class="px-5 py-8 text-center text-sm text-gray-400">Ingen AI-forbruk registrert.</p>
{:else}
<!-- Totaler -->
{@const totalTokens = data.usage.reduce((s, u) => s + u.total_tokens, 0)}
{@const totalCost = data.usage.reduce((s, u) => s + u.estimated_cost, 0)}
{@const totalCalls = data.usage.reduce((s, u) => s + u.call_count, 0)}
<div class="grid grid-cols-3 gap-3 border-b border-gray-100 px-5 py-4">
<div>
<div class="text-xs text-gray-500">Totalt tokens</div>
<div class="text-lg font-semibold text-gray-900">{formatTokens(totalTokens)}</div>
</div>
<div>
<div class="text-xs text-gray-500">Estimert kostnad</div>
<div class="text-lg font-semibold text-gray-900">{formatCost(totalCost)}</div>
</div>
<div>
<div class="text-xs text-gray-500">Antall kall</div>
<div class="text-lg font-semibold text-gray-900">{totalCalls}</div>
</div>
</div>
<!-- Tabell -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 bg-gray-50 text-left text-xs text-gray-500">
<th class="px-5 py-2">Samling</th>
<th class="px-3 py-2">Alias</th>
<th class="px-3 py-2">Jobbtype</th>
<th class="px-3 py-2 text-right">Tokens</th>
<th class="px-3 py-2 text-right">Kostnad</th>
<th class="px-3 py-2 text-right">Kall</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{#each data.usage as row}
<tr class="hover:bg-gray-50">
<td class="px-5 py-2 text-gray-700">
{row.collection_title ?? '(ingen samling)'}
</td>
<td class="px-3 py-2 font-mono text-xs text-gray-600">
{row.model_alias}
</td>
<td class="px-3 py-2 font-mono text-xs text-gray-600">
{row.job_type ?? '—'}
</td>
<td class="px-3 py-2 text-right text-gray-700">
{formatTokens(row.total_tokens)}
</td>
<td class="px-3 py-2 text-right text-gray-700">
{formatCost(row.estimated_cost)}
</td>
<td class="px-3 py-2 text-right text-gray-700">{row.call_count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<!-- === TAB: API-nøkler === -->
{:else if activeTab === 'keys'}
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="border-b border-gray-100 px-5 py-3">
<h2 class="text-sm font-semibold text-gray-800">API-nøkler</h2>
<p class="mt-1 text-xs text-gray-500">
Nøklene er lagret som miljøvariabler på serveren. Verdier vises aldri i admin-panelet.
</p>
</div>
<div class="divide-y divide-gray-100">
{#each data.api_key_status as key}
<div class="flex items-center gap-3 px-5 py-3">
<span
class="h-2 w-2 rounded-full {key.is_set ? 'bg-green-500' : 'bg-red-400'}"
></span>
<span class="font-mono text-sm text-gray-800">{key.env_var}</span>
<span class="text-xs {key.is_set ? 'text-green-600' : 'text-red-500'}">
{key.is_set ? 'Satt' : 'Mangler'}
</span>
</div>
{/each}
{#if data.api_key_status.length === 0}
<p class="px-5 py-4 text-sm text-gray-400">Ingen API-nøkler konfigurert.</p>
{/if}
</div>
<div class="border-t border-gray-100 px-5 py-3">
<p class="text-xs text-gray-400">
For å endre API-nøkler, oppdater <code class="rounded bg-gray-100 px-1">/srv/synops/.env</code> og restart maskinrommet.
</p>
</div>
</section>
{/if}
{/if}
</main>
</div>

View file

@ -0,0 +1,473 @@
// AI Gateway-administrasjon (oppgave 15.4)
//
// Admin-API for modelloversikt, ruting-regler, fallback-kjeder og forbruksoversikt.
// PG er single source of truth — LiteLLM er stateløs proxy.
//
// Ref: docs/infra/ai_gateway.md
use axum::{extract::State, http::StatusCode, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::AppState;
// =============================================================================
// Datatyper
// =============================================================================
#[derive(Serialize, sqlx::FromRow)]
pub struct AiModelAlias {
pub id: Uuid,
pub alias: String,
pub description: Option<String>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct AiModelProvider {
pub id: Uuid,
pub alias_id: Uuid,
pub provider: String,
pub model: String,
pub api_key_env: String,
pub priority: i16,
pub is_active: bool,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct AiJobRouting {
pub job_type: String,
pub alias: String,
pub description: Option<String>,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct AiUsageSummary {
pub collection_node_id: Option<Uuid>,
pub collection_title: Option<String>,
pub model_alias: String,
pub job_type: Option<String>,
pub total_prompt_tokens: i64,
pub total_completion_tokens: i64,
pub total_tokens: i64,
pub estimated_cost: f64,
pub call_count: i64,
}
#[derive(Serialize)]
pub struct ApiKeyStatus {
pub env_var: String,
pub is_set: bool,
}
#[derive(Serialize)]
pub struct AiOverviewResponse {
pub aliases: Vec<AiModelAlias>,
pub providers: Vec<AiModelProvider>,
pub routing: Vec<AiJobRouting>,
pub usage: Vec<AiUsageSummary>,
pub api_key_status: Vec<ApiKeyStatus>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() }))
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() }))
}
// =============================================================================
// GET /admin/ai — oversikt
// =============================================================================
pub async fn ai_overview(
State(state): State<AppState>,
_user: AuthUser,
) -> Result<Json<AiOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
let aliases = sqlx::query_as::<_, AiModelAlias>(
"SELECT id, alias, description, is_active, created_at FROM ai_model_aliases ORDER BY alias"
)
.fetch_all(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved henting av aliaser: {e}")))?;
let providers = sqlx::query_as::<_, AiModelProvider>(
"SELECT id, alias_id, provider, model, api_key_env, priority, is_active
FROM ai_model_providers ORDER BY alias_id, priority"
)
.fetch_all(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved henting av providers: {e}")))?;
let routing = sqlx::query_as::<_, AiJobRouting>(
"SELECT job_type, alias, description FROM ai_job_routing ORDER BY job_type"
)
.fetch_all(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved henting av ruting: {e}")))?;
let usage = fetch_usage_summary(&state.db, 30).await
.map_err(|e| internal_error(&format!("Feil ved henting av forbruk: {e}")))?;
// Sjekk hvilke API-nøkler som er satt i miljøet
let env_vars: Vec<String> = providers.iter()
.map(|p| p.api_key_env.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let mut api_key_status: Vec<ApiKeyStatus> = env_vars.into_iter().map(|env_var| {
let is_set = std::env::var(&env_var).map(|v| !v.is_empty()).unwrap_or(false);
ApiKeyStatus { env_var, is_set }
}).collect();
api_key_status.sort_by(|a, b| a.env_var.cmp(&b.env_var));
Ok(Json(AiOverviewResponse {
aliases,
providers,
routing,
usage,
api_key_status,
}))
}
async fn fetch_usage_summary(db: &PgPool, days: i32) -> Result<Vec<AiUsageSummary>, sqlx::Error> {
sqlx::query_as::<_, AiUsageSummary>(
r#"
SELECT
u.collection_node_id,
n.title AS collection_title,
u.model_alias,
u.job_type,
COALESCE(SUM(u.prompt_tokens), 0)::BIGINT AS total_prompt_tokens,
COALESCE(SUM(u.completion_tokens), 0)::BIGINT AS total_completion_tokens,
COALESCE(SUM(u.total_tokens), 0)::BIGINT AS total_tokens,
COALESCE(SUM(u.estimated_cost)::FLOAT8, 0.0) AS estimated_cost,
COUNT(*)::BIGINT AS call_count
FROM ai_usage_log u
LEFT JOIN nodes n ON n.id = u.collection_node_id
WHERE u.created_at >= now() - make_interval(days := $1)
GROUP BY u.collection_node_id, n.title, u.model_alias, u.job_type
ORDER BY total_tokens DESC
"#,
)
.bind(days)
.fetch_all(db)
.await
}
// =============================================================================
// POST /admin/ai/update_alias — oppdater alias
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateAliasRequest {
pub id: Uuid,
pub description: Option<String>,
pub is_active: bool,
}
pub async fn update_alias(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<UpdateAliasRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let rows = sqlx::query(
"UPDATE ai_model_aliases SET description = $1, is_active = $2 WHERE id = $3"
)
.bind(&req.description)
.bind(req.is_active)
.bind(req.id)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved oppdatering av alias: {e}")))?;
if rows.rows_affected() == 0 {
return Err(bad_request("Alias ikke funnet"));
}
tracing::info!(alias_id = %req.id, user = %_user.node_id, "Admin: alias oppdatert");
Ok(Json(serde_json::json!({ "success": true })))
}
// =============================================================================
// POST /admin/ai/create_alias — opprett nytt alias
// =============================================================================
#[derive(Deserialize)]
pub struct CreateAliasRequest {
pub alias: String,
pub description: Option<String>,
}
pub async fn create_alias(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<CreateAliasRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
if req.alias.trim().is_empty() {
return Err(bad_request("Alias-navn kan ikke være tomt"));
}
let id = sqlx::query_scalar::<_, Uuid>(
"INSERT INTO ai_model_aliases (alias, description) VALUES ($1, $2) RETURNING id"
)
.bind(&req.alias)
.bind(&req.description)
.fetch_one(&state.db)
.await
.map_err(|e| {
if e.to_string().contains("unique") || e.to_string().contains("duplicate") {
bad_request(&format!("Alias '{}' finnes allerede", req.alias))
} else {
internal_error(&format!("Feil ved opprettelse av alias: {e}"))
}
})?;
tracing::info!(alias = %req.alias, user = %_user.node_id, "Admin: alias opprettet");
Ok(Json(serde_json::json!({ "id": id, "success": true })))
}
// =============================================================================
// POST /admin/ai/update_provider — oppdater provider
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateProviderRequest {
pub id: Uuid,
pub priority: Option<i16>,
pub is_active: Option<bool>,
}
pub async fn update_provider(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<UpdateProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
if let Some(priority) = req.priority {
sqlx::query("UPDATE ai_model_providers SET priority = $1 WHERE id = $2")
.bind(priority)
.bind(req.id)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved oppdatering av provider priority: {e}")))?;
}
if let Some(is_active) = req.is_active {
sqlx::query("UPDATE ai_model_providers SET is_active = $1 WHERE id = $2")
.bind(is_active)
.bind(req.id)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved oppdatering av provider status: {e}")))?;
}
tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider oppdatert");
Ok(Json(serde_json::json!({ "success": true })))
}
// =============================================================================
// POST /admin/ai/create_provider — legg til ny provider
// =============================================================================
#[derive(Deserialize)]
pub struct CreateProviderRequest {
pub alias_id: Uuid,
pub provider: String,
pub model: String,
pub api_key_env: String,
pub priority: i16,
}
pub async fn create_provider(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<CreateProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
if req.model.trim().is_empty() {
return Err(bad_request("Modellnavn kan ikke være tomt"));
}
let id = sqlx::query_scalar::<_, Uuid>(
"INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
VALUES ($1, $2, $3, $4, $5) RETURNING id"
)
.bind(req.alias_id)
.bind(&req.provider)
.bind(&req.model)
.bind(&req.api_key_env)
.bind(req.priority)
.fetch_one(&state.db)
.await
.map_err(|e| {
if e.to_string().contains("unique") || e.to_string().contains("duplicate") {
bad_request("Denne modellen finnes allerede for dette aliaset")
} else if e.to_string().contains("foreign key") {
bad_request("Alias-ID finnes ikke")
} else {
internal_error(&format!("Feil ved opprettelse av provider: {e}"))
}
})?;
tracing::info!(model = %req.model, alias_id = %req.alias_id, user = %_user.node_id, "Admin: provider opprettet");
Ok(Json(serde_json::json!({ "id": id, "success": true })))
}
// =============================================================================
// POST /admin/ai/delete_provider — fjern provider
// =============================================================================
#[derive(Deserialize)]
pub struct DeleteProviderRequest {
pub id: Uuid,
}
pub async fn delete_provider(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<DeleteProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let rows = sqlx::query("DELETE FROM ai_model_providers WHERE id = $1")
.bind(req.id)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved sletting av provider: {e}")))?;
if rows.rows_affected() == 0 {
return Err(bad_request("Provider ikke funnet"));
}
tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider slettet");
Ok(Json(serde_json::json!({ "success": true })))
}
// =============================================================================
// POST /admin/ai/update_routing — oppdater eller opprett rutingregel
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateRoutingRequest {
pub job_type: String,
pub alias: String,
pub description: Option<String>,
}
pub async fn update_routing(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<UpdateRoutingRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
if req.job_type.trim().is_empty() || req.alias.trim().is_empty() {
return Err(bad_request("Jobbtype og alias kan ikke være tomme"));
}
sqlx::query(
"INSERT INTO ai_job_routing (job_type, alias, description)
VALUES ($1, $2, $3)
ON CONFLICT (job_type) DO UPDATE SET alias = $2, description = $3"
)
.bind(&req.job_type)
.bind(&req.alias)
.bind(&req.description)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved oppdatering av ruting: {e}")))?;
tracing::info!(job_type = %req.job_type, alias = %req.alias, user = %_user.node_id, "Admin: ruting oppdatert");
Ok(Json(serde_json::json!({ "success": true })))
}
// =============================================================================
// POST /admin/ai/delete_routing — slett rutingregel
// =============================================================================
#[derive(Deserialize)]
pub struct DeleteRoutingRequest {
pub job_type: String,
}
pub async fn delete_routing(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<DeleteRoutingRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let rows = sqlx::query("DELETE FROM ai_job_routing WHERE job_type = $1")
.bind(&req.job_type)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved sletting av ruting: {e}")))?;
if rows.rows_affected() == 0 {
return Err(bad_request("Rutingregel ikke funnet"));
}
tracing::info!(job_type = %req.job_type, user = %_user.node_id, "Admin: ruting slettet");
Ok(Json(serde_json::json!({ "success": true })))
}
// =============================================================================
// GET /admin/ai/usage — detaljert forbruksoversikt med filtre
// =============================================================================
#[derive(Deserialize)]
pub struct UsageQueryParams {
pub days: Option<i32>,
pub collection_id: Option<Uuid>,
}
pub async fn ai_usage(
State(state): State<AppState>,
_user: AuthUser,
axum::extract::Query(params): axum::extract::Query<UsageQueryParams>,
) -> Result<Json<Vec<AiUsageSummary>>, (StatusCode, Json<ErrorResponse>)> {
let days = params.days.unwrap_or(30).min(365);
let usage = if let Some(collection_id) = params.collection_id {
fetch_usage_for_collection(&state.db, collection_id, days).await
} else {
fetch_usage_summary(&state.db, days).await
};
usage
.map(Json)
.map_err(|e| internal_error(&format!("Feil ved henting av forbruk: {e}")))
}
async fn fetch_usage_for_collection(
db: &PgPool,
collection_id: Uuid,
days: i32,
) -> Result<Vec<AiUsageSummary>, sqlx::Error> {
sqlx::query_as::<_, AiUsageSummary>(
r#"
SELECT
u.collection_node_id,
n.title AS collection_title,
u.model_alias,
u.job_type,
COALESCE(SUM(u.prompt_tokens), 0)::BIGINT AS total_prompt_tokens,
COALESCE(SUM(u.completion_tokens), 0)::BIGINT AS total_completion_tokens,
COALESCE(SUM(u.total_tokens), 0)::BIGINT AS total_tokens,
COALESCE(SUM(u.estimated_cost)::FLOAT8, 0.0) AS estimated_cost,
COUNT(*)::BIGINT AS call_count
FROM ai_usage_log u
LEFT JOIN nodes n ON n.id = u.collection_node_id
WHERE u.created_at >= now() - make_interval(days := $1)
AND u.collection_node_id = $2
GROUP BY u.collection_node_id, n.title, u.model_alias, u.job_type
ORDER BY total_tokens DESC
"#,
)
.bind(days)
.bind(collection_id)
.fetch_all(db)
.await
}

View file

@ -1,4 +1,5 @@
pub mod agent;
pub mod ai_admin;
pub mod ai_edges;
pub mod audio;
mod auth;
@ -203,6 +204,16 @@ async fn main() {
.route("/admin/jobs", get(intentions::list_jobs))
.route("/intentions/retry_job", post(intentions::retry_job))
.route("/intentions/cancel_job", post(intentions::cancel_job))
// AI Gateway-konfigurasjon (oppgave 15.4)
.route("/admin/ai", get(ai_admin::ai_overview))
.route("/admin/ai/usage", get(ai_admin::ai_usage))
.route("/admin/ai/update_alias", post(ai_admin::update_alias))
.route("/admin/ai/create_alias", post(ai_admin::create_alias))
.route("/admin/ai/update_provider", post(ai_admin::update_provider))
.route("/admin/ai/create_provider", post(ai_admin::create_provider))
.route("/admin/ai/delete_provider", post(ai_admin::delete_provider))
.route("/admin/ai/update_routing", post(ai_admin::update_routing))
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
.route("/query/audio_info", get(intentions::audio_info))
.route("/pub/{slug}/feed.xml", get(rss::generate_feed))
.route("/pub/{slug}", get(publishing::serve_index))

View file

@ -166,8 +166,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 15.1 Systemvarsler: varslingsnode (`node_kind='system_announcement'`) med type (info/warning/critical), nedtelling og utløp. Frontend viser banner/toast for alle aktive klienter via STDB. Ref: `docs/concepts/adminpanelet.md`.
- [x] 15.2 Graceful shutdown: admin setter vedlikeholdstidspunkt → nedtelling i frontend → nye LiveKit-rom blokkeres → jobbkø stopper → vent på aktive jobber → restart. Vis aktive sesjoner før bekreftelse.
- [x] 15.3 Jobbkø-oversikt: admin-UI for aktive, ventende og feilede jobber. Filtrer på type/samling/status. Manuell retry og avbryt.
- [~] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`.
> Påbegynt: 2026-03-18T03:42
- [x] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`.
- [ ] 15.5 Ressursstyring: prioritetsregler mellom jobbtyper, ressursgrenser per worker, ressurs-governor for automatisk nedprioritering under aktive LiveKit-sesjoner, disk-status med varsling.
- [ ] 15.6 Serverhelse-dashboard: tjeneste-status (PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit), metrikker (CPU, minne, disk), backup-status, logg-tilgang.
- [ ] 15.7 Ressursforbruk-logging: `resource_usage_log`-tabell i PG. Maskinrommet logger AI-tokens (inn/ut, modellnivå), Whisper-tid (sek), TTS-tegn, CAS-lagring (bytes), LiveKit-tid (deltaker-min). Båndbredde via Caddy-logg-parsing. Ref: `docs/features/ressursforbruk.md`.