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 });
|
||||
}
|
||||
|
||||
/** 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)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
<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,
|
||||
* fallback-kjeder, forbruksoversikt per samling.
|
||||
* Ref: docs/infra/ai_gateway.md
|
||||
* fallback-kjeder, forbruksoversikt per samling, nivå-konfigurasjon.
|
||||
* Ref: docs/infra/ai_gateway.md, docs/oppdrag/admin-komplett.md
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import {
|
||||
fetchAiOverview,
|
||||
fetchAiUsage,
|
||||
fetchAiTierCosts,
|
||||
testAiPrompt,
|
||||
updateAiAlias,
|
||||
createAiAlias,
|
||||
updateAiProvider,
|
||||
|
|
@ -22,19 +24,22 @@
|
|||
type AiModelProvider,
|
||||
type AiJobRouting,
|
||||
type AiUsageSummary,
|
||||
type ApiKeyStatus
|
||||
type ApiKeyStatus,
|
||||
type TierCostInfo,
|
||||
type AiTestPromptResponse
|
||||
} from '$lib/api';
|
||||
|
||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||
|
||||
let data = $state<AiOverviewResponse | null>(null);
|
||||
let tierCosts = $state<TierCostInfo[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let actionLoading = $state<string | null>(null);
|
||||
|
||||
// Tabs
|
||||
let activeTab = $state<'aliases' | 'routing' | 'usage' | 'keys'>('aliases');
|
||||
let activeTab = $state<'tiers' | 'aliases' | 'routing' | 'usage' | 'keys'>('tiers');
|
||||
|
||||
// Forbruksperiode
|
||||
let usageDays = $state(30);
|
||||
|
|
@ -57,6 +62,32 @@
|
|||
let newRoutingAlias = $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
|
||||
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));
|
||||
}
|
||||
|
||||
// Poll hvert 10. sekund (AI-config endres sjelden)
|
||||
// Poll hvert 10. sekund
|
||||
$effect(() => {
|
||||
if (!accessToken) return;
|
||||
loadData();
|
||||
|
|
@ -117,7 +148,12 @@
|
|||
async function loadData() {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
data = await fetchAiOverview(accessToken);
|
||||
const [overview, costs] = await Promise.all([
|
||||
fetchAiOverview(accessToken),
|
||||
fetchAiTierCosts(accessToken)
|
||||
]);
|
||||
data = overview;
|
||||
tierCosts = costs;
|
||||
error = null;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
|
|
@ -148,6 +184,16 @@
|
|||
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
|
||||
async function toggleAlias(alias: AiModelAlias) {
|
||||
if (!accessToken) return;
|
||||
|
|
@ -295,6 +341,19 @@
|
|||
}
|
||||
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>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
|
|
@ -322,15 +381,16 @@
|
|||
{/if}
|
||||
|
||||
<!-- 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 [
|
||||
{ key: 'tiers', label: 'Nivåer' },
|
||||
{ key: 'aliases', label: 'Modeller & fallback' },
|
||||
{ key: 'routing', label: 'Ruting' },
|
||||
{ key: 'usage', label: 'Forbruk' },
|
||||
{ key: 'keys', label: 'API-nøkler' }
|
||||
] as tab}
|
||||
<button
|
||||
class="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {activeTab === tab.key ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-800'}"
|
||||
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)}
|
||||
>
|
||||
{tab.label}
|
||||
|
|
@ -338,8 +398,222 @@
|
|||
{/each}
|
||||
</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 === -->
|
||||
{#if activeTab === 'aliases'}
|
||||
{:else if activeTab === 'aliases'}
|
||||
<div class="space-y-4">
|
||||
{#each data.aliases as alias}
|
||||
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
|
|
|
|||
|
|
@ -475,6 +475,192 @@ pub async fn ai_usage(
|
|||
.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(
|
||||
db: &PgPool,
|
||||
collection_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -257,6 +257,8 @@ async fn main() {
|
|||
.route("/admin/ai/delete_provider", post(ai_admin::delete_provider))
|
||||
.route("/admin/ai/update_routing", post(ai_admin::update_routing))
|
||||
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
|
||||
.route("/admin/ai/test_prompt", post(ai_admin::test_prompt))
|
||||
.route("/admin/ai/tier_costs", get(ai_admin::tier_costs))
|
||||
// Forbruksoversikt (oppgave 15.8)
|
||||
.route("/admin/usage", get(usage_overview::usage_overview))
|
||||
// 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