Admin AI-ruting: fire nivåer med test-prompt og kostnadsestimat
- Ny «Nivåer»-fane i /admin/ai med synops/low, medium, high, extreme - Per-nivå: fallback-kjede, provider-administrasjon, kostnadsestimat - Test-knapp sender prompt gjennom LiteLLM og viser respons, latens, tokens, kostnad - Backend: POST /admin/ai/test_prompt + GET /admin/ai/tier_costs - Migration 033: oppretter de fire synops/* aliasene med providers
This commit is contained in:
parent
6e753a73d4
commit
b4ede32713
5 changed files with 581 additions and 10 deletions
|
|
@ -948,6 +948,54 @@ export function deleteAiRouting(
|
||||||
return post(accessToken, '/admin/ai/delete_routing', { job_type: jobType });
|
return post(accessToken, '/admin/ai/delete_routing', { job_type: jobType });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test-prompt respons. */
|
||||||
|
export interface AiTestPromptResponse {
|
||||||
|
success: boolean;
|
||||||
|
response_text: string;
|
||||||
|
model_used: string | null;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
latency_ms: number;
|
||||||
|
estimated_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send test-prompt til et alias. */
|
||||||
|
export function testAiPrompt(
|
||||||
|
accessToken: string,
|
||||||
|
alias: string
|
||||||
|
): Promise<AiTestPromptResponse> {
|
||||||
|
return post(accessToken, '/admin/ai/test_prompt', { alias });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kostnadsinfo per provider. */
|
||||||
|
export interface ProviderCostInfo {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
priority: number;
|
||||||
|
input_cost_per_mtok: number;
|
||||||
|
output_cost_per_mtok: number;
|
||||||
|
estimated_cost_1k_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kostnadsinfo per tier. */
|
||||||
|
export interface TierCostInfo {
|
||||||
|
alias: string;
|
||||||
|
description: string | null;
|
||||||
|
providers: ProviderCostInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hent kostnadsestimat per tier. */
|
||||||
|
export async function fetchAiTierCosts(accessToken: string): Promise<TierCostInfo[]> {
|
||||||
|
const res = await fetch(`${BASE_URL}/admin/ai/tier_costs`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`tier costs failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Serverhelse-dashboard (oppgave 15.6)
|
// Serverhelse-dashboard (oppgave 15.6)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Admin — AI Gateway-konfigurasjon (oppgave 15.4)
|
* Admin — AI Gateway-konfigurasjon (oppgave 15.4 + 061)
|
||||||
*
|
*
|
||||||
* Modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype,
|
* Modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype,
|
||||||
* fallback-kjeder, forbruksoversikt per samling.
|
* fallback-kjeder, forbruksoversikt per samling, nivå-konfigurasjon.
|
||||||
* Ref: docs/infra/ai_gateway.md
|
* Ref: docs/infra/ai_gateway.md, docs/oppdrag/admin-komplett.md
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import {
|
import {
|
||||||
fetchAiOverview,
|
fetchAiOverview,
|
||||||
fetchAiUsage,
|
fetchAiUsage,
|
||||||
|
fetchAiTierCosts,
|
||||||
|
testAiPrompt,
|
||||||
updateAiAlias,
|
updateAiAlias,
|
||||||
createAiAlias,
|
createAiAlias,
|
||||||
updateAiProvider,
|
updateAiProvider,
|
||||||
|
|
@ -22,19 +24,22 @@
|
||||||
type AiModelProvider,
|
type AiModelProvider,
|
||||||
type AiJobRouting,
|
type AiJobRouting,
|
||||||
type AiUsageSummary,
|
type AiUsageSummary,
|
||||||
type ApiKeyStatus
|
type ApiKeyStatus,
|
||||||
|
type TierCostInfo,
|
||||||
|
type AiTestPromptResponse
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
|
||||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
const accessToken = $derived(session?.accessToken as string | undefined);
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||||
|
|
||||||
let data = $state<AiOverviewResponse | null>(null);
|
let data = $state<AiOverviewResponse | null>(null);
|
||||||
|
let tierCosts = $state<TierCostInfo[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let actionLoading = $state<string | null>(null);
|
let actionLoading = $state<string | null>(null);
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
let activeTab = $state<'aliases' | 'routing' | 'usage' | 'keys'>('aliases');
|
let activeTab = $state<'tiers' | 'aliases' | 'routing' | 'usage' | 'keys'>('tiers');
|
||||||
|
|
||||||
// Forbruksperiode
|
// Forbruksperiode
|
||||||
let usageDays = $state(30);
|
let usageDays = $state(30);
|
||||||
|
|
@ -57,6 +62,32 @@
|
||||||
let newRoutingAlias = $state('');
|
let newRoutingAlias = $state('');
|
||||||
let newRoutingDesc = $state('');
|
let newRoutingDesc = $state('');
|
||||||
|
|
||||||
|
// Test-prompt state
|
||||||
|
let testResults = $state<Record<string, AiTestPromptResponse | null>>({});
|
||||||
|
let testLoading = $state<Record<string, boolean>>({});
|
||||||
|
let testErrors = $state<Record<string, string | null>>({});
|
||||||
|
|
||||||
|
// Tier ordering
|
||||||
|
const TIER_ORDER = ['synops/low', 'synops/medium', 'synops/high', 'synops/extreme'];
|
||||||
|
const TIER_LABELS: Record<string, string> = {
|
||||||
|
'synops/low': 'Low',
|
||||||
|
'synops/medium': 'Medium',
|
||||||
|
'synops/high': 'High',
|
||||||
|
'synops/extreme': 'Extreme'
|
||||||
|
};
|
||||||
|
const TIER_COLORS: Record<string, string> = {
|
||||||
|
'synops/low': 'bg-emerald-500',
|
||||||
|
'synops/medium': 'bg-blue-500',
|
||||||
|
'synops/high': 'bg-amber-500',
|
||||||
|
'synops/extreme': 'bg-red-500'
|
||||||
|
};
|
||||||
|
const TIER_BORDERS: Record<string, string> = {
|
||||||
|
'synops/low': 'border-emerald-200',
|
||||||
|
'synops/medium': 'border-blue-200',
|
||||||
|
'synops/high': 'border-amber-200',
|
||||||
|
'synops/extreme': 'border-red-200'
|
||||||
|
};
|
||||||
|
|
||||||
// Kjente AI-kontekster med kategorier og beskrivelser
|
// Kjente AI-kontekster med kategorier og beskrivelser
|
||||||
const KNOWN_CONTEXTS: { category: string; contexts: { job_type: string; description: string }[] }[] = [
|
const KNOWN_CONTEXTS: { category: string; contexts: { job_type: string; description: string }[] }[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -106,7 +137,7 @@
|
||||||
return data.routing.filter((r) => !allKnownJobTypes.includes(r.job_type));
|
return data.routing.filter((r) => !allKnownJobTypes.includes(r.job_type));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll hvert 10. sekund (AI-config endres sjelden)
|
// Poll hvert 10. sekund
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
loadData();
|
loadData();
|
||||||
|
|
@ -117,7 +148,12 @@
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
try {
|
try {
|
||||||
data = await fetchAiOverview(accessToken);
|
const [overview, costs] = await Promise.all([
|
||||||
|
fetchAiOverview(accessToken),
|
||||||
|
fetchAiTierCosts(accessToken)
|
||||||
|
]);
|
||||||
|
data = overview;
|
||||||
|
tierCosts = costs;
|
||||||
error = null;
|
error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
|
|
@ -148,6 +184,16 @@
|
||||||
return `$${cost.toFixed(2)}`;
|
return `$${cost.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tierAlias(tierName: string): AiModelAlias | undefined {
|
||||||
|
return data?.aliases.find((a) => a.alias === tierName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antall routing-regler som peker til dette aliaset
|
||||||
|
function routingCountForAlias(alias: string): number {
|
||||||
|
if (!data) return 0;
|
||||||
|
return data.routing.filter((r) => r.alias === alias).length;
|
||||||
|
}
|
||||||
|
|
||||||
// Handlinger
|
// Handlinger
|
||||||
async function toggleAlias(alias: AiModelAlias) {
|
async function toggleAlias(alias: AiModelAlias) {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
|
|
@ -295,6 +341,19 @@
|
||||||
}
|
}
|
||||||
actionLoading = null;
|
actionLoading = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTestPrompt(alias: string) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
testLoading[alias] = true;
|
||||||
|
testErrors[alias] = null;
|
||||||
|
testResults[alias] = null;
|
||||||
|
try {
|
||||||
|
testResults[alias] = await testAiPrompt(accessToken, alias);
|
||||||
|
} catch (e) {
|
||||||
|
testErrors[alias] = String(e);
|
||||||
|
}
|
||||||
|
testLoading[alias] = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
|
@ -322,15 +381,16 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="mb-6 flex gap-1 rounded-lg bg-gray-100 p-1">
|
<div class="mb-6 flex gap-1 overflow-x-auto rounded-lg bg-gray-100 p-1">
|
||||||
{#each [
|
{#each [
|
||||||
|
{ key: 'tiers', label: 'Nivåer' },
|
||||||
{ key: 'aliases', label: 'Modeller & fallback' },
|
{ key: 'aliases', label: 'Modeller & fallback' },
|
||||||
{ key: 'routing', label: 'Ruting' },
|
{ key: 'routing', label: 'Ruting' },
|
||||||
{ key: 'usage', label: 'Forbruk' },
|
{ key: 'usage', label: 'Forbruk' },
|
||||||
{ key: 'keys', label: 'API-nøkler' }
|
{ key: 'keys', label: 'API-nøkler' }
|
||||||
] as tab}
|
] as tab}
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {activeTab === tab.key ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-800'}"
|
class="flex-1 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors {activeTab === tab.key ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-800'}"
|
||||||
onclick={() => (activeTab = tab.key as typeof activeTab)}
|
onclick={() => (activeTab = tab.key as typeof activeTab)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|
@ -338,8 +398,222 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- === TAB: Nivåer === -->
|
||||||
|
{#if activeTab === 'tiers'}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white px-5 py-3 shadow-sm">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-800">Synops AI-nivåer</h2>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Fire kvalitetsnivåer med fallback-kjeder. Jobbtyper rutes til et nivå i Ruting-fanen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each TIER_ORDER as tierName}
|
||||||
|
{@const alias = tierAlias(tierName)}
|
||||||
|
{@const costs = tierCosts.find((t) => t.alias === tierName)}
|
||||||
|
{@const providers = alias ? providersForAlias(alias) : []}
|
||||||
|
{@const routingCount = routingCountForAlias(tierName)}
|
||||||
|
{@const result = testResults[tierName]}
|
||||||
|
{@const testing = testLoading[tierName]}
|
||||||
|
{@const testError = testErrors[tierName]}
|
||||||
|
<section class="overflow-hidden rounded-lg border bg-white shadow-sm {TIER_BORDERS[tierName] ?? 'border-gray-200'}">
|
||||||
|
<!-- Tier header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="h-3 w-3 rounded-full {TIER_COLORS[tierName] ?? 'bg-gray-400'}"></span>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-sm font-semibold text-gray-900">{tierName}</span>
|
||||||
|
{#if alias}
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-[10px] font-medium {alias.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}">
|
||||||
|
{alias.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="rounded bg-red-100 px-1.5 py-0.5 text-[10px] text-red-600">
|
||||||
|
Ikke opprettet
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if alias?.description}
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500">{alias.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-400">{routingCount} ruting{routingCount !== 1 ? 'er' : ''}</span>
|
||||||
|
{#if alias}
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-1 text-xs {alias.is_active ? 'text-amber-600 hover:bg-amber-50' : 'text-green-600 hover:bg-green-50'}"
|
||||||
|
disabled={actionLoading === `alias-${alias.id}`}
|
||||||
|
onclick={() => toggleAlias(alias)}
|
||||||
|
>
|
||||||
|
{alias.is_active ? 'Deaktiver' : 'Aktiver'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-0 divide-y divide-gray-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
|
||||||
|
<!-- Fallback-kjede -->
|
||||||
|
<div class="px-5 py-3">
|
||||||
|
<div class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||||
|
Fallback-kjede
|
||||||
|
</div>
|
||||||
|
{#if providers.length === 0}
|
||||||
|
<p class="text-xs text-gray-400 italic">Ingen providers konfigurert</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{#each providers as provider, idx}
|
||||||
|
<div class="flex items-center gap-2 text-sm {!provider.is_active ? 'opacity-50' : ''}">
|
||||||
|
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-gray-200 text-[10px] font-medium text-gray-600">
|
||||||
|
{provider.priority}
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span class="font-mono text-xs text-gray-800">{provider.model}</span>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center gap-1 text-[10px] {keyStatus(provider.api_key_env) ? 'text-green-600' : 'text-red-500'}">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full {keyStatus(provider.api_key_env) ? 'bg-green-500' : 'bg-red-400'}"></span>
|
||||||
|
</span>
|
||||||
|
<!-- Prioritet-knapper -->
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
{#if idx > 0}
|
||||||
|
<button
|
||||||
|
class="rounded px-1 py-0.5 text-[10px] text-gray-400 hover:bg-gray-100"
|
||||||
|
onclick={() => changeProviderPriority(provider, provider.priority - 1)}
|
||||||
|
>↑</button>
|
||||||
|
{/if}
|
||||||
|
{#if idx < providers.length - 1}
|
||||||
|
<button
|
||||||
|
class="rounded px-1 py-0.5 text-[10px] text-gray-400 hover:bg-gray-100"
|
||||||
|
onclick={() => changeProviderPriority(provider, provider.priority + 1)}
|
||||||
|
>↓</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded px-1 py-0.5 text-[10px] {provider.is_active ? 'text-amber-600 hover:bg-amber-50' : 'text-green-600 hover:bg-green-50'}"
|
||||||
|
onclick={() => toggleProvider(provider)}
|
||||||
|
>{provider.is_active ? 'Av' : 'På'}</button>
|
||||||
|
<button
|
||||||
|
class="rounded px-1 py-0.5 text-[10px] text-red-500 hover:bg-red-50"
|
||||||
|
onclick={() => removeProvider(provider)}
|
||||||
|
>Slett</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Legg til provider -->
|
||||||
|
{#if alias && showNewProvider === alias.id}
|
||||||
|
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-2">
|
||||||
|
<div class="grid grid-cols-2 gap-1.5">
|
||||||
|
<input
|
||||||
|
bind:value={newProviderName}
|
||||||
|
placeholder="Leverandør"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newProviderModel}
|
||||||
|
placeholder="Modell"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newProviderKeyEnv}
|
||||||
|
placeholder="Env-variabel"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newProviderPriority}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
placeholder="Prioritet"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 flex gap-1.5">
|
||||||
|
<button
|
||||||
|
class="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
onclick={handleCreateProvider}
|
||||||
|
disabled={actionLoading === 'create-provider' || !newProviderModel.trim()}
|
||||||
|
>Legg til</button>
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => (showNewProvider = null)}
|
||||||
|
>Avbryt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if alias}
|
||||||
|
<button
|
||||||
|
class="mt-2 text-[10px] text-blue-600 hover:text-blue-800"
|
||||||
|
onclick={() => (showNewProvider = alias.id)}
|
||||||
|
>+ Legg til modell</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kostnadsestimat + test -->
|
||||||
|
<div class="px-5 py-3">
|
||||||
|
<div class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||||
|
Kostnadsestimat
|
||||||
|
</div>
|
||||||
|
{#if costs && costs.providers.length > 0}
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each costs.providers as cp}
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="font-mono text-gray-600">{cp.model.split('/').pop()}</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-400" title="Input/output per million tokens">
|
||||||
|
${cp.input_cost_per_mtok}/{cp.output_cost_per_mtok}
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-gray-700" title="Estimert kostnad per 1000 tokens (70% input, 30% output)">
|
||||||
|
~{formatCost(cp.estimated_cost_1k_tokens)}/1k
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-gray-400 italic">Ingen data</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Test-knapp -->
|
||||||
|
<div class="mt-3 border-t border-gray-100 pt-3">
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||||
|
disabled={testing || !alias}
|
||||||
|
onclick={() => handleTestPrompt(tierName)}
|
||||||
|
>
|
||||||
|
{#if testing}
|
||||||
|
Tester...
|
||||||
|
{:else}
|
||||||
|
Test-prompt
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if result}
|
||||||
|
<div class="mt-2 rounded-md border border-green-200 bg-green-50 p-2">
|
||||||
|
<p class="text-xs text-green-800">{result.response_text}</p>
|
||||||
|
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-green-600">
|
||||||
|
<span>Modell: {result.model_used ?? '?'}</span>
|
||||||
|
<span>{result.latency_ms}ms</span>
|
||||||
|
<span>{result.prompt_tokens}+{result.completion_tokens} tokens</span>
|
||||||
|
<span>{formatCost(result.estimated_cost)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if testError}
|
||||||
|
<div class="mt-2 rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700">
|
||||||
|
{testError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- === TAB: Modeller & fallback === -->
|
<!-- === TAB: Modeller & fallback === -->
|
||||||
{#if activeTab === 'aliases'}
|
{:else if activeTab === 'aliases'}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each data.aliases as alias}
|
{#each data.aliases as alias}
|
||||||
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
|
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
|
|
|
||||||
|
|
@ -475,6 +475,192 @@ pub async fn ai_usage(
|
||||||
.map_err(|e| internal_error(&format!("Feil ved henting av forbruk: {e}")))
|
.map_err(|e| internal_error(&format!("Feil ved henting av forbruk: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/ai/test_prompt — test et alias med en enkel prompt
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TestPromptRequest {
|
||||||
|
pub alias: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TestPromptResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub response_text: String,
|
||||||
|
pub model_used: Option<String>,
|
||||||
|
pub prompt_tokens: i64,
|
||||||
|
pub completion_tokens: i64,
|
||||||
|
pub latency_ms: u64,
|
||||||
|
pub estimated_cost: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kjente modellpriser per million tokens (input, output).
|
||||||
|
/// Brukes for kostnadsestimat i admin-panelet.
|
||||||
|
fn model_cost_per_million(model: &str) -> (f64, f64) {
|
||||||
|
match model {
|
||||||
|
m if m.contains("gemini-2.5-flash-lite") => (0.0, 0.0), // gratis tier
|
||||||
|
m if m.contains("gemini-2.5-flash") => (0.15, 0.60),
|
||||||
|
m if m.contains("grok-4-1-fast-non-reasoning") => (0.60, 3.00),
|
||||||
|
m if m.contains("claude-sonnet-4") => (3.00, 15.00),
|
||||||
|
m if m.contains("claude-opus") => (15.00, 75.00),
|
||||||
|
m if m.contains("gpt-4o") => (2.50, 10.00),
|
||||||
|
m if m.contains("gpt-4o-mini") => (0.15, 0.60),
|
||||||
|
_ => (1.00, 3.00), // konservativt estimat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn estimate_cost(model: &str, prompt_tokens: i64, completion_tokens: i64) -> f64 {
|
||||||
|
let (input_price, output_price) = model_cost_per_million(model);
|
||||||
|
(prompt_tokens as f64 * input_price + completion_tokens as f64 * output_price) / 1_000_000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test_prompt(
|
||||||
|
State(_state): State<AppState>,
|
||||||
|
_admin: AdminUser,
|
||||||
|
Json(req): Json<TestPromptRequest>,
|
||||||
|
) -> Result<Json<TestPromptResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
if req.alias.trim().is_empty() {
|
||||||
|
return Err(bad_request("Alias kan ikke være tomt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let gateway_url = std::env::var("AI_GATEWAY_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:4000".to_string());
|
||||||
|
let api_key = std::env::var("LITELLM_MASTER_KEY")
|
||||||
|
.map_err(|_| internal_error("LITELLM_MASTER_KEY ikke satt"))?;
|
||||||
|
|
||||||
|
let request_body = serde_json::json!({
|
||||||
|
"model": req.alias,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Du er en hjelpsom assistent. Svar kort og konsist."},
|
||||||
|
{"role": "user", "content": "Si «Hei fra Synops!» og beskriv deg selv i én setning."}
|
||||||
|
],
|
||||||
|
"temperature": 0.3
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{gateway_url}/v1/chat/completions"))
|
||||||
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&request_body)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("AI Gateway-kall feilet: {e}")))?;
|
||||||
|
|
||||||
|
let latency_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(internal_error(&format!("AI Gateway returnerte {status}: {body}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.map_err(|e| internal_error(&format!("Kunne ikke parse respons: {e}")))?;
|
||||||
|
|
||||||
|
let response_text = body["choices"][0]["message"]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("(ingen respons)")
|
||||||
|
.to_string();
|
||||||
|
let model_used = body["model"].as_str().map(|s| s.to_string());
|
||||||
|
let prompt_tokens = body["usage"]["prompt_tokens"].as_i64().unwrap_or(0);
|
||||||
|
let completion_tokens = body["usage"]["completion_tokens"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
|
let cost_model = model_used.as_deref().unwrap_or(&req.alias);
|
||||||
|
let estimated_cost = estimate_cost(cost_model, prompt_tokens, completion_tokens);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
alias = %req.alias,
|
||||||
|
model = ?model_used,
|
||||||
|
latency_ms,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
user = %_admin.node_id,
|
||||||
|
"Admin: test-prompt kjørt"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(TestPromptResponse {
|
||||||
|
success: true,
|
||||||
|
response_text,
|
||||||
|
model_used,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
latency_ms,
|
||||||
|
estimated_cost,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /admin/ai/tier_costs — kostnadsestimat per nivå
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TierCostInfo {
|
||||||
|
pub alias: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub providers: Vec<ProviderCostInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ProviderCostInfo {
|
||||||
|
pub model: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub priority: i16,
|
||||||
|
pub input_cost_per_mtok: f64,
|
||||||
|
pub output_cost_per_mtok: f64,
|
||||||
|
pub estimated_cost_1k_tokens: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tier_costs(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_admin: AdminUser,
|
||||||
|
) -> Result<Json<Vec<TierCostInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let aliases = sqlx::query_as::<_, AiModelAlias>(
|
||||||
|
"SELECT id, alias, description, is_active, created_at FROM ai_model_aliases
|
||||||
|
WHERE alias LIKE 'synops/%' ORDER BY alias"
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil: {e}")))?;
|
||||||
|
|
||||||
|
let providers = sqlx::query_as::<_, AiModelProvider>(
|
||||||
|
"SELECT id, alias_id, provider, model, api_key_env, priority, is_active
|
||||||
|
FROM ai_model_providers ORDER BY priority"
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil: {e}")))?;
|
||||||
|
|
||||||
|
let tiers: Vec<TierCostInfo> = aliases.iter().map(|a| {
|
||||||
|
let alias_providers: Vec<ProviderCostInfo> = providers.iter()
|
||||||
|
.filter(|p| p.alias_id == a.id && p.is_active)
|
||||||
|
.map(|p| {
|
||||||
|
let (input_cost, output_cost) = model_cost_per_million(&p.model);
|
||||||
|
ProviderCostInfo {
|
||||||
|
model: p.model.clone(),
|
||||||
|
provider: p.provider.clone(),
|
||||||
|
priority: p.priority,
|
||||||
|
input_cost_per_mtok: input_cost,
|
||||||
|
output_cost_per_mtok: output_cost,
|
||||||
|
// Estimat: 700 input + 300 output tokens per 1k
|
||||||
|
estimated_cost_1k_tokens: (700.0 * input_cost + 300.0 * output_cost) / 1_000_000.0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
TierCostInfo {
|
||||||
|
alias: a.alias.clone(),
|
||||||
|
description: a.description.clone(),
|
||||||
|
providers: alias_providers,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(tiers))
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_usage_for_collection(
|
async fn fetch_usage_for_collection(
|
||||||
db: &PgPool,
|
db: &PgPool,
|
||||||
collection_id: Uuid,
|
collection_id: Uuid,
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,8 @@ async fn main() {
|
||||||
.route("/admin/ai/delete_provider", post(ai_admin::delete_provider))
|
.route("/admin/ai/delete_provider", post(ai_admin::delete_provider))
|
||||||
.route("/admin/ai/update_routing", post(ai_admin::update_routing))
|
.route("/admin/ai/update_routing", post(ai_admin::update_routing))
|
||||||
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
|
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
|
||||||
|
.route("/admin/ai/test_prompt", post(ai_admin::test_prompt))
|
||||||
|
.route("/admin/ai/tier_costs", get(ai_admin::tier_costs))
|
||||||
// Forbruksoversikt (oppgave 15.8)
|
// Forbruksoversikt (oppgave 15.8)
|
||||||
.route("/admin/usage", get(usage_overview::usage_overview))
|
.route("/admin/usage", get(usage_overview::usage_overview))
|
||||||
// Podcast-statistikk (oppgave 30.4)
|
// Podcast-statistikk (oppgave 30.4)
|
||||||
|
|
|
||||||
61
migrations/033_ai_tier_aliases.sql
Normal file
61
migrations/033_ai_tier_aliases.sql
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
-- 033_ai_tier_aliases.sql — Opprett synops/* tier-aliaser for AI-ruting
|
||||||
|
--
|
||||||
|
-- Fire standardnivåer: synops/low, synops/medium, synops/high, synops/extreme
|
||||||
|
-- Hver tier har en fallback-kjede av providers.
|
||||||
|
--
|
||||||
|
-- Ref: docs/oppdrag/admin-komplett.md § AI-ruting
|
||||||
|
|
||||||
|
-- Opprett de fire tier-aliasene (separate inserts for ON CONFLICT)
|
||||||
|
INSERT INTO ai_model_aliases (alias, description) VALUES
|
||||||
|
('synops/low', 'Billig, høyt volum — tekstvasking, metadata, klassifisering')
|
||||||
|
ON CONFLICT (alias) DO NOTHING;
|
||||||
|
INSERT INTO ai_model_aliases (alias, description) VALUES
|
||||||
|
('synops/medium', 'Balansert — oppsummering, research, standard AI-oppgaver')
|
||||||
|
ON CONFLICT (alias) DO NOTHING;
|
||||||
|
INSERT INTO ai_model_aliases (alias, description) VALUES
|
||||||
|
('synops/high', 'Høy kvalitet — chat, kreativ skriving, analyse')
|
||||||
|
ON CONFLICT (alias) DO NOTHING;
|
||||||
|
INSERT INTO ai_model_aliases (alias, description) VALUES
|
||||||
|
('synops/extreme', 'Beste tilgjengelige — kritiske avgjørelser, kompleks resonnering')
|
||||||
|
ON CONFLICT (alias) DO NOTHING;
|
||||||
|
|
||||||
|
-- synops/low: billige, raske modeller
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'gemini', 'gemini/gemini-2.5-flash-lite', 'GEMINI_API_KEY', 1
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/low';
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'xai', 'xai/grok-4-1-fast-non-reasoning', 'XAI_API_KEY', 2
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/low';
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'openrouter', 'openrouter/google/gemini-2.5-flash', 'OPENROUTER_API_KEY', 3
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/low';
|
||||||
|
|
||||||
|
-- synops/medium: gode allround-modeller
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'gemini', 'gemini/gemini-2.5-flash', 'GEMINI_API_KEY', 1
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/medium';
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'anthropic', 'anthropic/claude-sonnet-4-20250514', 'ANTHROPIC_API_KEY', 2
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/medium';
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'openrouter', 'openrouter/anthropic/claude-sonnet-4', 'OPENROUTER_API_KEY', 3
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/medium';
|
||||||
|
|
||||||
|
-- synops/high: høykvalitetsmodeller
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'anthropic', 'anthropic/claude-sonnet-4-20250514', 'ANTHROPIC_API_KEY', 1
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/high';
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'openrouter', 'openrouter/anthropic/claude-sonnet-4', 'OPENROUTER_API_KEY', 2
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/high';
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'openrouter', 'openrouter/google/gemini-2.5-flash', 'OPENROUTER_API_KEY', 3
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/high';
|
||||||
|
|
||||||
|
-- synops/extreme: beste modeller for kritiske oppgaver
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'anthropic', 'anthropic/claude-sonnet-4-20250514', 'ANTHROPIC_API_KEY', 1
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/extreme';
|
||||||
|
INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
SELECT id, 'openrouter', 'openrouter/anthropic/claude-sonnet-4', 'OPENROUTER_API_KEY', 2
|
||||||
|
FROM ai_model_aliases WHERE alias = 'synops/extreme';
|
||||||
Loading…
Add table
Reference in a new issue