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:
parent
a72e3d88f3
commit
832ea7117a
18 changed files with 1135 additions and 15 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
78
migrations/0007_ai_config.sql
Normal file
78
migrations/0007_ai_config.sql
Normal 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
18
web/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
web/src/routes/admin/ai/+page.server.ts
Normal file
41
web/src/routes/admin/ai/+page.server.ts
Normal 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 };
|
||||||
|
};
|
||||||
668
web/src/routes/admin/ai/+page.svelte
Normal file
668
web/src/routes/admin/ai/+page.svelte
Normal 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>
|
||||||
19
web/src/routes/api/admin/ai/aliases/+server.ts
Normal file
19
web/src/routes/api/admin/ai/aliases/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
39
web/src/routes/api/admin/ai/aliases/[id]/+server.ts
Normal file
39
web/src/routes/api/admin/ai/aliases/[id]/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
54
web/src/routes/api/admin/ai/generate-config/+server.ts
Normal file
54
web/src/routes/api/admin/ai/generate-config/+server.ts
Normal 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
|
||||||
|
});
|
||||||
|
};
|
||||||
21
web/src/routes/api/admin/ai/providers/+server.ts
Normal file
21
web/src/routes/api/admin/ai/providers/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
32
web/src/routes/api/admin/ai/providers/[id]/+server.ts
Normal file
32
web/src/routes/api/admin/ai/providers/[id]/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
23
web/src/routes/api/admin/ai/routing/+server.ts
Normal file
23
web/src/routes/api/admin/ai/routing/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
30
web/src/routes/api/admin/ai/routing/[jobType]/+server.ts
Normal file
30
web/src/routes/api/admin/ai/routing/[jobType]/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
31
web/src/routes/api/ai/usage/+server.ts
Normal file
31
web/src/routes/api/ai/usage/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue