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:
vegard 2026-03-19 23:24:23 +00:00
parent 6e753a73d4
commit b4ede32713
5 changed files with 581 additions and 10 deletions

View file

@ -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)
// ============================================================================= // =============================================================================

View file

@ -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)}
>&uarr;</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)}
>&darr;</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">

View file

@ -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,

View file

@ -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)

View 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';