AI-administrasjon: modellstyring, tokenregnskap, admin-panel

- Migrasjon 0007: ai_model_aliases, ai_model_providers, ai_job_routing, ai_usage_log
- Worker: token-logging fra AI Gateway-respons til ai_usage_log
- Config-generering: POST /api/admin/ai/generate-config bygger config.yaml fra PG
- Admin-panel /admin/ai: aliaser, leverandører, jobbruting, tokenforbruk
- CRUD API for aliaser, providers og routing
- Workspace-forbruk API: GET /api/ai/usage?days=30

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 03:03:12 +01:00
parent a72e3d88f3
commit 832ea7117a
18 changed files with 1135 additions and 15 deletions

View file

@ -54,7 +54,7 @@ services:
networks: networks:
- sidelinja-dev - sidelinja-dev
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/database/ping || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:3000/v1/ping || exit 1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@ -68,8 +68,9 @@ services:
environment: environment:
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY} LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
GEMINI_API_KEY: ${GEMINI_API_KEY} GEMINI_API_KEY: ${GEMINI_API_KEY}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
volumes: volumes:
- ./config/litellm/config.yaml:/etc/litellm/config.yaml:ro - ./config/litellm:/etc/litellm
ports: ports:
- "127.0.0.1:4000:4000" - "127.0.0.1:4000:4000"
networks: networks:

View file

@ -0,0 +1,78 @@
-- 0007_ai_config.sql
-- AI-administrasjon: modellaliaser, leverandører, jobbruting og tokenlogging.
-- PG som source of truth for LiteLLM config-generering.
BEGIN;
-- === Modellaliaser (globale, ikke per workspace) ===
CREATE TABLE ai_model_aliases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alias TEXT NOT NULL UNIQUE, -- f.eks. "sidelinja/rutine"
description TEXT, -- kort beskrivelse av aliaset
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- === Leverandør-modeller per alias med prioritet ===
CREATE TABLE ai_model_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alias_id UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE CASCADE,
priority INT NOT NULL DEFAULT 1, -- lavere = høyere prioritet i LiteLLM
litellm_model TEXT NOT NULL, -- f.eks. "gemini/gemini-2.5-flash-lite"
api_key_env TEXT NOT NULL, -- f.eks. "GEMINI_API_KEY"
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (alias_id, priority)
);
-- === Jobbtype → alias mapping ===
CREATE TABLE ai_job_routing (
job_type TEXT PRIMARY KEY, -- f.eks. "ai_text_process"
alias_id UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE RESTRICT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- === Per-kall token-logging (workspace-scopet) ===
CREATE TABLE ai_usage_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
job_id UUID REFERENCES job_queue(id) ON DELETE SET NULL,
job_type TEXT NOT NULL,
model_alias TEXT NOT NULL, -- alias brukt (snapshot)
model_actual TEXT, -- faktisk modell fra respons
prompt_tokens INT NOT NULL DEFAULT 0,
completion_tokens INT NOT NULL DEFAULT 0,
total_tokens INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ai_usage_log_workspace ON ai_usage_log (workspace_id, created_at DESC);
CREATE INDEX idx_ai_usage_log_alias ON ai_usage_log (model_alias, created_at DESC);
-- === Seed-data: matcher nåværende config.yaml ===
-- Aliaser
INSERT INTO ai_model_aliases (alias, description) VALUES
('sidelinja/rutine', 'Billig, høyt volum — tekstrensing, faktauthenting, oversettelse'),
('sidelinja/resonering', 'Presis, lav volum — kompleks analyse, research');
-- Leverandører for sidelinja/rutine
INSERT INTO ai_model_providers (alias_id, priority, litellm_model, api_key_env) VALUES
((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/rutine'), 1, 'gemini/gemini-2.5-flash-lite', 'GEMINI_API_KEY'),
((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/rutine'), 2, 'gemini/gemini-2.5-flash', 'GEMINI_API_KEY'),
((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/rutine'), 3, 'openrouter/google/gemini-2.5-flash-preview', 'OPENROUTER_API_KEY');
-- Leverandører for sidelinja/resonering
INSERT INTO ai_model_providers (alias_id, priority, litellm_model, api_key_env) VALUES
((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/resonering'), 1, 'openrouter/anthropic/claude-sonnet-4', 'OPENROUTER_API_KEY'),
((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/resonering'), 2, 'gemini/gemini-2.5-flash', 'GEMINI_API_KEY');
-- Jobbruting
INSERT INTO ai_job_routing (job_type, alias_id, description) VALUES
('ai_text_process', (SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/rutine'), 'Tekstrensing og AI-behandling via ✨-knappen');
COMMIT;

18
web/package-lock.json generated
View file

@ -25,6 +25,7 @@
"vite": "^8.0.0" "vite": "^8.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.5.0",
"svelte-check": "^4.4.5" "svelte-check": "^4.4.5"
} }
}, },
@ -1435,6 +1436,16 @@
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -2777,6 +2788,13 @@
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/url-polyfill": { "node_modules/url-polyfill": {
"version": "1.1.14", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",

View file

@ -27,6 +27,7 @@
"vite": "^8.0.0" "vite": "^8.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.5.0",
"svelte-check": "^4.4.5" "svelte-check": "^4.4.5"
} }
} }

View file

@ -0,0 +1,41 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { sql } from '$lib/server/db';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.workspace) error(404);
const aliases = await sql`
SELECT id, alias, description, is_active, created_at
FROM ai_model_aliases
ORDER BY alias
`;
const providers = await sql`
SELECT id, alias_id, priority, litellm_model, api_key_env, is_active
FROM ai_model_providers
ORDER BY alias_id, priority
`;
const routing = await sql`
SELECT r.job_type, r.alias_id, r.description, a.alias
FROM ai_job_routing r
JOIN ai_model_aliases a ON a.id = r.alias_id
ORDER BY r.job_type
`;
const usage = await sql`
SELECT
model_alias,
count(*)::int AS call_count,
sum(prompt_tokens)::int AS prompt_tokens,
sum(completion_tokens)::int AS completion_tokens,
sum(total_tokens)::int AS total_tokens
FROM ai_usage_log
WHERE created_at > now() - interval '30 days'
GROUP BY model_alias
ORDER BY total_tokens DESC
`;
return { aliases, providers, routing, usage };
};

View file

@ -0,0 +1,668 @@
<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;
}
interface Route {
job_type: string;
alias_id: string;
alias: string;
description: string | null;
}
interface UsageRow {
model_alias: string;
call_count: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
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 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 expandedAlias = $state<string | null>(null);
// Ny provider-form
let newProvider = $state<{ alias_id: string; litellm_model: string; api_key_env: string }>({
alias_id: '',
litellm_model: '',
api_key_env: 'GEMINI_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);
}
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;
}
}
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: 'GEMINI_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);
} catch {
errorMsg = 'Kunne ikke slette provider';
}
}
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';
}
}
async function generateConfig() {
configMsg = '';
errorMsg = '';
try {
const res = await fetch('/api/admin/ai/generate-config', { 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';
}
}
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>
{#if errorMsg}
<div class="error-msg">{errorMsg}</div>
{/if}
<!-- Seksjon 1: 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 aliases as alias (alias.id)}
{@const ap = providersForAlias(alias.id)}
<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}
</span>
<span class="col-desc">{alias.description ?? '—'}</span>
<span class="col-providers">{ap.length}</span>
<span class="col-active">
<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 expandedAlias === alias.id}
<div class="provider-list">
{#each ap as provider (provider.id)}
<div class="provider-row" class:provider-row--inactive={!provider.is_active}>
<span class="col-pri">#{provider.priority}</span>
<span class="col-model">{provider.litellm_model}</span>
<span class="col-key">{provider.api_key_env}</span>
<span class="col-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>
{/each}
<div class="provider-row provider-row--add">
<span class="col-pri"></span>
<input
type="text"
placeholder="gemini/modell-navn"
bind:value={newProvider.litellm_model}
/>
<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>
</select>
<button class="add-btn" onclick={() => addProvider(alias.id)}>Legg til</button>
</div>
</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 2: 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 ?? '—'}</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 3: Tokenforbruk -->
<section>
<h3>Tokenforbruk (siste 30 dager)</h3>
{#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">
<span class="col-alias">Modellalias</span>
<span class="col-num">Kall</span>
<span class="col-num">Prompt</span>
<span class="col-num">Completion</span>
<span class="col-num">Totalt</span>
</div>
{#each usage as row}
<div class="table-row">
<span class="col-alias">{row.model_alias}</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>
</div>
{/each}
</div>
{/if}
</section>
<!-- Seksjon 4: Konfigurasjon -->
<section>
<h3>Konfigurasjon</h3>
<div class="config-box">
<button class="generate-btn" onclick={generateConfig}>Generer config.yaml</button>
{#if configMsg}
<span class="config-msg">{configMsg}</span>
{/if}
<p class="hint">Genererer LiteLLM config.yaml fra databasen. AI Gateway (LiteLLM) må restartes for å lese ny config.</p>
</div>
</section>
</div>
<style>
.admin-ai {
max-width: 960px;
}
.header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 1.5rem;
}
h2 {
font-size: 1.4rem;
}
h3 {
font-size: 1.1rem;
margin-bottom: 0.75rem;
}
.header-stats {
font-size: 0.8rem;
color: #8b92a5;
}
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;
}
.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;
}
.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;
}
.col-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.col-jobtype {
font-family: monospace;
}
.col-pri {
color: #8b92a5;
font-size: 0.75rem;
width: 30px;
}
.col-model {
font-family: monospace;
font-size: 0.75rem;
}
.col-key {
font-size: 0.75rem;
color: #8b92a5;
}
/* Provider sub-list */
.provider-list {
background: #0f1117;
padding: 0.5rem 0.75rem 0.5rem 2rem;
border-bottom: 1px solid #2d3148;
}
.provider-row {
display: grid;
grid-template-columns: 30px 2fr 1.5fr 60px 60px 40px;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
font-size: 0.8rem;
}
.provider-row--inactive {
opacity: 0.5;
}
.provider-row--add {
padding-top: 0.5rem;
border-top: 1px solid #2d3148;
margin-top: 0.25rem;
}
/* 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;
}
.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 {
background: #264b7a;
}
/* Forms */
.add-form {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
input[type='text'],
input[type='number'] {
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;
}
</style>

View file

@ -0,0 +1,19 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** POST — opprett nytt alias */
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const { alias, description } = await request.json();
if (!alias || typeof alias !== 'string') error(400, 'alias er påkrevd');
const [row] = await sql`
INSERT INTO ai_model_aliases (alias, description)
VALUES (${alias}, ${description ?? null})
RETURNING id, alias, description, is_active, created_at
`;
return json(row, { status: 201 });
};

View file

@ -0,0 +1,39 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** PATCH — oppdater alias */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const body = await request.json();
const updates: Record<string, unknown> = {};
if ('description' in body) updates.description = body.description;
if ('is_active' in body) updates.is_active = body.is_active;
const [row] = await sql`
UPDATE ai_model_aliases SET
description = COALESCE(${body.description ?? null}, description),
is_active = COALESCE(${body.is_active ?? null}, is_active),
updated_at = now()
WHERE id = ${params.id}::uuid
RETURNING id, alias, description, is_active
`;
if (!row) error(404, 'Alias ikke funnet');
return json(row);
};
/** DELETE — slett alias (feiler hvis brukt i routing) */
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401);
try {
await sql`DELETE FROM ai_model_aliases WHERE id = ${params.id}::uuid`;
} catch (e: any) {
if (e.code === '23503') error(409, 'Aliaset er i bruk av jobbruting og kan ikke slettes');
throw e;
}
return json({ ok: true });
};

View file

@ -0,0 +1,54 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* POST /api/admin/ai/generate-config Generer LiteLLM config.yaml fra PG.
*/
export const POST: RequestHandler = async ({ locals }) => {
if (!locals.workspace || !locals.user) error(401);
// Hent aktive aliaser med aktive providers
const rows = await sql`
SELECT
a.alias AS model_name,
p.litellm_model,
p.api_key_env
FROM ai_model_aliases a
JOIN ai_model_providers p ON p.alias_id = a.id
WHERE a.is_active = true AND p.is_active = true
ORDER BY a.alias, p.priority ASC
`;
if (rows.length === 0) {
error(400, 'Ingen aktive modellkonfigurasjoner funnet');
}
// Bygg YAML manuelt (unngå avhengighet)
let yaml = 'model_list:\n';
for (const row of rows) {
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`;
}
yaml += '\nrouter_settings:\n';
yaml += ' routing_strategy: "simple-shuffle"\n';
yaml += ' num_retries: 2\n';
yaml += ' timeout: 60\n';
yaml += '\ngeneral_settings:\n';
yaml += ' master_key: "os.environ/LITELLM_MASTER_KEY"\n';
// Skriv til config-fil
const configPath = join(process.cwd(), 'config', 'litellm', 'config.yaml');
writeFileSync(configPath, yaml, 'utf-8');
return json({
ok: true,
message: 'Config generert. Restart ai-gateway for å aktivere.',
model_count: rows.length
});
};

View file

@ -0,0 +1,21 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** POST — opprett ny provider for et alias */
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const { alias_id, priority, litellm_model, api_key_env } = await request.json();
if (!alias_id || !litellm_model || !api_key_env) {
error(400, 'alias_id, litellm_model og api_key_env er påkrevd');
}
const [row] = await sql`
INSERT INTO ai_model_providers (alias_id, priority, litellm_model, api_key_env)
VALUES (${alias_id}::uuid, ${priority ?? 99}, ${litellm_model}, ${api_key_env})
RETURNING id, alias_id, priority, litellm_model, api_key_env, is_active
`;
return json(row, { status: 201 });
};

View file

@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** PATCH — oppdater provider */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const body = await request.json();
const [row] = await sql`
UPDATE ai_model_providers SET
priority = COALESCE(${body.priority ?? null}, priority),
litellm_model = COALESCE(${body.litellm_model ?? null}, litellm_model),
api_key_env = COALESCE(${body.api_key_env ?? null}, api_key_env),
is_active = COALESCE(${body.is_active ?? null}, is_active),
updated_at = now()
WHERE id = ${params.id}::uuid
RETURNING id, alias_id, priority, litellm_model, api_key_env, is_active
`;
if (!row) error(404, 'Provider ikke funnet');
return json(row);
};
/** DELETE — slett provider */
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401);
await sql`DELETE FROM ai_model_providers WHERE id = ${params.id}::uuid`;
return json({ ok: true });
};

View file

@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** POST — opprett ny jobbruting */
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const { job_type, alias_id, description } = await request.json();
if (!job_type || !alias_id) error(400, 'job_type og alias_id er påkrevd');
const [row] = await sql`
INSERT INTO ai_job_routing (job_type, alias_id, description)
VALUES (${job_type}, ${alias_id}::uuid, ${description ?? null})
ON CONFLICT (job_type) DO UPDATE SET
alias_id = EXCLUDED.alias_id,
description = EXCLUDED.description,
updated_at = now()
RETURNING job_type, alias_id, description
`;
return json(row, { status: 201 });
};

View file

@ -0,0 +1,30 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** PATCH — oppdater ruting for jobbtype */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const body = await request.json();
const [row] = await sql`
UPDATE ai_job_routing SET
alias_id = COALESCE(${body.alias_id ?? null}::uuid, alias_id),
description = COALESCE(${body.description ?? null}, description),
updated_at = now()
WHERE job_type = ${params.jobType}
RETURNING job_type, alias_id, description
`;
if (!row) error(404, 'Jobbtype ikke funnet');
return json(row);
};
/** DELETE — slett ruting */
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401);
await sql`DELETE FROM ai_job_routing WHERE job_type = ${params.jobType}`;
return json({ ok: true });
};

View file

@ -0,0 +1,31 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/**
* GET /api/ai/usage?days=30 Aggregert tokenforbruk for workspace.
*/
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const days = Math.min(Math.max(parseInt(url.searchParams.get('days') ?? '7'), 1), 365);
const breakdown = await sql`
SELECT
model_alias,
job_type,
count(*)::int AS call_count,
sum(prompt_tokens)::int AS prompt_tokens,
sum(completion_tokens)::int AS completion_tokens,
sum(total_tokens)::int AS total_tokens
FROM ai_usage_log
WHERE workspace_id = ${locals.workspace.id}
AND created_at > now() - make_interval(days => ${days})
GROUP BY model_alias, job_type
ORDER BY total_tokens DESC
`;
const total_tokens = breakdown.reduce((s: number, r: any) => s + r.total_tokens, 0);
return json({ total_tokens, breakdown });
};

View file

@ -5,6 +5,15 @@ use sqlx::{PgPool, Row};
use tracing::{info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
/// Respons fra AI Gateway med innhold og tokenforbruk.
struct AiResponse {
content: String,
prompt_tokens: i32,
completion_tokens: i32,
total_tokens: i32,
model_actual: Option<String>,
}
/// Handler for AI-behandling av tekst i editoren. /// Handler for AI-behandling av tekst i editoren.
/// ///
/// Payload: /// Payload:
@ -21,6 +30,7 @@ use uuid::Uuid;
/// 3. Send til AI Gateway med riktig prompt /// 3. Send til AI Gateway med riktig prompt
/// 4. Oppdater messages.body med AI-resultatet /// 4. Oppdater messages.body med AI-resultatet
/// 5. Sett metadata.ai_processed = true /// 5. Sett metadata.ai_processed = true
/// 6. Logg tokenforbruk til ai_usage_log
pub struct AiTextProcessHandler { pub struct AiTextProcessHandler {
http: reqwest::Client, http: reqwest::Client,
ai_gateway_url: String, ai_gateway_url: String,
@ -41,6 +51,7 @@ impl JobHandler for AiTextProcessHandler {
&self, &self,
pool: &PgPool, pool: &PgPool,
workspace_id: &Uuid, workspace_id: &Uuid,
job_id: &Uuid,
payload: &Value, payload: &Value,
) -> anyhow::Result<Option<Value>> { ) -> anyhow::Result<Option<Value>> {
let message_id: Uuid = payload let message_id: Uuid = payload
@ -94,7 +105,20 @@ impl JobHandler for AiTextProcessHandler {
return Ok(Some(json!({ "skipped": true, "reason": "tom melding" }))); return Ok(Some(json!({ "skipped": true, "reason": "tom melding" })));
} }
// 2. Lagre original som revisjon // 2. Sett ai_processing-flagg så frontend kan vise spinner
sqlx::query(
r#"
UPDATE messages
SET metadata = COALESCE(metadata, '{}'::jsonb) || '{"ai_processing": true}'::jsonb
WHERE id = $1
"#,
)
.bind(message_id)
.execute(pool)
.await
.context("Feil ved setting av ai_processing-flagg")?;
// 3. Lagre original som revisjon (etter at vi har satt processing-flagg)
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO message_revisions (id, message_id, body) INSERT INTO message_revisions (id, message_id, body)
@ -107,19 +131,37 @@ impl JobHandler for AiTextProcessHandler {
.await .await
.context("Feil ved lagring av revisjon")?; .context("Feil ved lagring av revisjon")?;
// 3. Bygg system-prompt basert på action // 4. Bygg system-prompt basert på action
let system_prompt = match prompt_override { let system_prompt = match prompt_override {
Some(custom) => custom.to_string(), Some(custom) => custom.to_string(),
None => get_system_prompt(action), None => get_system_prompt(action),
}; };
// 4. Send til AI Gateway // 5. Send til AI Gateway
let ai_response = self let ai_resp = self
.call_ai_gateway(&system_prompt, &plain_text, model) .call_ai_gateway(&system_prompt, &plain_text, model)
.await .await
.context("AI Gateway-kall feilet")?; .context("AI Gateway-kall feilet")?;
// 5. Oppdater meldingens body med AI-resultat // 6. Logg tokenforbruk til ai_usage_log
sqlx::query(
r#"
INSERT INTO ai_usage_log (workspace_id, job_id, job_type, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens)
VALUES ($1, $2, 'ai_text_process', $3, $4, $5, $6, $7)
"#,
)
.bind(workspace_id)
.bind(job_id)
.bind(model)
.bind(&ai_resp.model_actual)
.bind(ai_resp.prompt_tokens)
.bind(ai_resp.completion_tokens)
.bind(ai_resp.total_tokens)
.execute(pool)
.await
.context("Feil ved logging av tokenforbruk")?;
// 7. Oppdater meldingens body med AI-resultat, fjern ai_processing
let metadata = json!({ let metadata = json!({
"ai_processed": true, "ai_processed": true,
"ai_action": action "ai_action": action
@ -130,11 +172,11 @@ impl JobHandler for AiTextProcessHandler {
UPDATE messages UPDATE messages
SET body = $1, SET body = $1,
edited_at = now(), edited_at = now(),
metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb metadata = (COALESCE(metadata, '{}'::jsonb) - 'ai_processing') || $2::jsonb
WHERE id = $3 WHERE id = $3
"#, "#,
) )
.bind(&ai_response) .bind(&ai_resp.content)
.bind(metadata) .bind(metadata)
.bind(message_id) .bind(message_id)
.execute(pool) .execute(pool)
@ -145,7 +187,8 @@ impl JobHandler for AiTextProcessHandler {
message_id = %message_id, message_id = %message_id,
action = action, action = action,
original_len = original_body.len(), original_len = original_body.len(),
result_len = ai_response.len(), result_len = ai_resp.content.len(),
tokens = ai_resp.total_tokens,
"AI-behandling fullført" "AI-behandling fullført"
); );
@ -153,7 +196,12 @@ impl JobHandler for AiTextProcessHandler {
"message_id": message_id.to_string(), "message_id": message_id.to_string(),
"action": action, "action": action,
"original_length": original_body.len(), "original_length": original_body.len(),
"result_length": ai_response.len() "result_length": ai_resp.content.len(),
"tokens": {
"prompt": ai_resp.prompt_tokens,
"completion": ai_resp.completion_tokens,
"total": ai_resp.total_tokens
}
}))) })))
} }
} }
@ -164,7 +212,7 @@ impl AiTextProcessHandler {
system_prompt: &str, system_prompt: &str,
user_text: &str, user_text: &str,
model: &str, model: &str,
) -> anyhow::Result<String> { ) -> anyhow::Result<AiResponse> {
let request_body = json!({ let request_body = json!({
"model": model, "model": model,
"messages": [ "messages": [
@ -195,10 +243,24 @@ impl AiTextProcessHandler {
.await .await
.context("Kunne ikke parse AI Gateway-respons")?; .context("Kunne ikke parse AI Gateway-respons")?;
json["choices"][0]["message"]["content"] let content = json["choices"][0]["message"]["content"]
.as_str() .as_str()
.map(|s| s.to_string()) .map(|s| s.to_string())
.ok_or_else(|| anyhow!("Ingen content i AI Gateway-respons")) .ok_or_else(|| anyhow!("Ingen content i AI Gateway-respons"))?;
let usage = &json["usage"];
let prompt_tokens = usage["prompt_tokens"].as_i64().unwrap_or(0) as i32;
let completion_tokens = usage["completion_tokens"].as_i64().unwrap_or(0) as i32;
let total_tokens = usage["total_tokens"].as_i64().unwrap_or(0) as i32;
let model_actual = json["model"].as_str().map(|s| s.to_string());
Ok(AiResponse {
content,
prompt_tokens,
completion_tokens,
total_tokens,
model_actual,
})
} }
} }

View file

@ -14,6 +14,7 @@ impl JobHandler for EchoHandler {
&self, &self,
_pool: &PgPool, _pool: &PgPool,
workspace_id: &Uuid, workspace_id: &Uuid,
_job_id: &Uuid,
payload: &Value, payload: &Value,
) -> anyhow::Result<Option<Value>> { ) -> anyhow::Result<Option<Value>> {
info!(workspace_id = %workspace_id, "Echo-handler kjører"); info!(workspace_id = %workspace_id, "Echo-handler kjører");

View file

@ -14,6 +14,7 @@ pub trait JobHandler: Send + Sync {
&self, &self,
pool: &PgPool, pool: &PgPool,
workspace_id: &Uuid, workspace_id: &Uuid,
job_id: &Uuid,
payload: &Value, payload: &Value,
) -> anyhow::Result<Option<Value>>; ) -> anyhow::Result<Option<Value>>;
} }

View file

@ -103,7 +103,7 @@ async fn process_job(pool: &PgPool, registry: &HandlerRegistry, job: Job) {
let handler = registry.get(&job.job_type); let handler = registry.get(&job.job_type);
let result = match handler { let result = match handler {
Some(handler) => handler.handle(pool, &job.workspace_id, &job.payload).await, Some(handler) => handler.handle(pool, &job.workspace_id, &job.id, &job.payload).await,
None => { None => {
warn!(job_type = %job.job_type, "Ukjent jobbtype — ingen handler registrert"); warn!(job_type = %job.job_type, "Ukjent jobbtype — ingen handler registrert");
Err(anyhow::anyhow!("Ukjent jobbtype: {}", job.job_type)) Err(anyhow::anyhow!("Ukjent jobbtype: {}", job.job_type))