diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 08aba3f..36c44d1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -805,3 +805,143 @@ export function cancelJob( ): Promise<{ success: boolean }> { return post(accessToken, '/intentions/cancel_job', { job_id: jobId }); } + +// ============================================================================= +// AI Gateway-konfigurasjon (oppgave 15.4) +// ============================================================================= + +export interface AiModelAlias { + id: string; + alias: string; + description: string | null; + is_active: boolean; + created_at: string; +} + +export interface AiModelProvider { + id: string; + alias_id: string; + provider: string; + model: string; + api_key_env: string; + priority: number; + is_active: boolean; +} + +export interface AiJobRouting { + job_type: string; + alias: string; + description: string | null; +} + +export interface AiUsageSummary { + collection_node_id: string | null; + collection_title: string | null; + model_alias: string; + job_type: string | null; + total_prompt_tokens: number; + total_completion_tokens: number; + total_tokens: number; + estimated_cost: number; + call_count: number; +} + +export interface ApiKeyStatus { + env_var: string; + is_set: boolean; +} + +export interface AiOverviewResponse { + aliases: AiModelAlias[]; + providers: AiModelProvider[]; + routing: AiJobRouting[]; + usage: AiUsageSummary[]; + api_key_status: ApiKeyStatus[]; +} + +/** Hent AI Gateway-oversikt (aliaser, providers, ruting, forbruk). */ +export async function fetchAiOverview(accessToken: string): Promise { + const res = await fetch(`${BASE_URL}/admin/ai`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`ai overview failed (${res.status}): ${body}`); + } + return res.json(); +} + +/** Hent AI-forbruksoversikt med filtre. */ +export async function fetchAiUsage( + accessToken: string, + params: { days?: number; collection_id?: string } = {} +): Promise { + const sp = new URLSearchParams(); + if (params.days) sp.set('days', String(params.days)); + if (params.collection_id) sp.set('collection_id', params.collection_id); + const qs = sp.toString(); + const res = await fetch(`${BASE_URL}/admin/ai/usage${qs ? `?${qs}` : ''}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`ai usage failed (${res.status}): ${body}`); + } + return res.json(); +} + +/** Oppdater modellalias (beskrivelse, aktiv-status). */ +export function updateAiAlias( + accessToken: string, + data: { id: string; description: string | null; is_active: boolean } +): Promise<{ success: boolean }> { + return post(accessToken, '/admin/ai/update_alias', data); +} + +/** Opprett nytt modellalias. */ +export function createAiAlias( + accessToken: string, + data: { alias: string; description: string | null } +): Promise<{ id: string; success: boolean }> { + return post(accessToken, '/admin/ai/create_alias', data); +} + +/** Oppdater provider (prioritet, aktiv-status). */ +export function updateAiProvider( + accessToken: string, + data: { id: string; priority?: number; is_active?: boolean } +): Promise<{ success: boolean }> { + return post(accessToken, '/admin/ai/update_provider', data); +} + +/** Legg til ny provider på et alias. */ +export function createAiProvider( + accessToken: string, + data: { alias_id: string; provider: string; model: string; api_key_env: string; priority: number } +): Promise<{ id: string; success: boolean }> { + return post(accessToken, '/admin/ai/create_provider', data); +} + +/** Slett en provider. */ +export function deleteAiProvider( + accessToken: string, + id: string +): Promise<{ success: boolean }> { + return post(accessToken, '/admin/ai/delete_provider', { id }); +} + +/** Oppdater eller opprett rutingregel (jobbtype → alias). */ +export function updateAiRouting( + accessToken: string, + data: { job_type: string; alias: string; description: string | null } +): Promise<{ success: boolean }> { + return post(accessToken, '/admin/ai/update_routing', data); +} + +/** Slett en rutingregel. */ +export function deleteAiRouting( + accessToken: string, + jobType: string +): Promise<{ success: boolean }> { + return post(accessToken, '/admin/ai/delete_routing', { job_type: jobType }); +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index eee3ba1..62eeab8 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -100,6 +100,9 @@ Jobbkø + + AI Gateway + diff --git a/frontend/src/routes/admin/ai/+page.svelte b/frontend/src/routes/admin/ai/+page.svelte new file mode 100644 index 0000000..22e1dec --- /dev/null +++ b/frontend/src/routes/admin/ai/+page.svelte @@ -0,0 +1,693 @@ + + +
+
+
+
+ Admin + / +

AI Gateway

+
+
+
+ +
+ {#if !accessToken} +

Logg inn for tilgang.

+ {:else if !data} +

Laster AI-konfigurasjon...

+ {:else} + + {#if error} +
+ {error} +
+ {/if} + + +
+ {#each [ + { key: 'aliases', label: 'Modeller & fallback' }, + { key: 'routing', label: 'Ruting' }, + { key: 'usage', label: 'Forbruk' }, + { key: 'keys', label: 'API-nøkler' } + ] as tab} + + {/each} +
+ + + {#if activeTab === 'aliases'} +
+ {#each data.aliases as alias} +
+ +
+
+ + {alias.alias} + {#if alias.description} + — {alias.description} + {/if} +
+ +
+ + +
+
+ Fallback-kjede (prioritet) +
+
+ {#each providersForAlias(alias) as provider, idx} +
+ {provider.priority} +
+ {provider.model} + ({provider.provider}) +
+ + + {provider.api_key_env} + + + +
+ {#if idx > 0} + + {/if} + {#if idx < providersForAlias(alias).length - 1} + + {/if} +
+ + + +
+ {/each} +
+ + + {#if showNewProvider === alias.id} +
+
+ + + + +
+
+ + +
+
+ {:else} + + {/if} +
+
+ {/each} + + + {#if showNewAlias} +
+

Nytt modellalias

+
+ + +
+
+ + +
+
+ {:else} + + {/if} +
+ + + {:else if activeTab === 'routing'} +
+
+

Jobbtype → Modellalias

+

+ Hvilken modellalias brukes for hvilken jobbtype i jobbkøen. +

+
+
+ {#each data.routing as routing} +
+
+ {routing.job_type} + {#if routing.description} + {routing.description} + {/if} +
+ + +
+ {/each} + + {#if data.routing.length === 0} +

Ingen ruting-regler konfigurert.

+ {/if} +
+ + + {#if showNewRouting} +
+
+ + + +
+
+ + +
+
+ {:else} +
+ +
+ {/if} +
+ + + {:else if activeTab === 'usage'} +
+
+

Forbruksoversikt

+ +
+ + {#if data.usage.length === 0} +

Ingen AI-forbruk registrert.

+ {:else} + + {@const totalTokens = data.usage.reduce((s, u) => s + u.total_tokens, 0)} + {@const totalCost = data.usage.reduce((s, u) => s + u.estimated_cost, 0)} + {@const totalCalls = data.usage.reduce((s, u) => s + u.call_count, 0)} +
+
+
Totalt tokens
+
{formatTokens(totalTokens)}
+
+
+
Estimert kostnad
+
{formatCost(totalCost)}
+
+
+
Antall kall
+
{totalCalls}
+
+
+ + +
+ + + + + + + + + + + + + {#each data.usage as row} + + + + + + + + + {/each} + +
SamlingAliasJobbtypeTokensKostnadKall
+ {row.collection_title ?? '(ingen samling)'} + + {row.model_alias} + + {row.job_type ?? '—'} + + {formatTokens(row.total_tokens)} + + {formatCost(row.estimated_cost)} + {row.call_count}
+
+ {/if} +
+ + + {:else if activeTab === 'keys'} +
+
+

API-nøkler

+

+ Nøklene er lagret som miljøvariabler på serveren. Verdier vises aldri i admin-panelet. +

+
+
+ {#each data.api_key_status as key} +
+ + {key.env_var} + + {key.is_set ? 'Satt' : 'Mangler'} + +
+ {/each} + + {#if data.api_key_status.length === 0} +

Ingen API-nøkler konfigurert.

+ {/if} +
+
+

+ For å endre API-nøkler, oppdater /srv/synops/.env og restart maskinrommet. +

+
+
+ {/if} + {/if} +
+
diff --git a/maskinrommet/src/ai_admin.rs b/maskinrommet/src/ai_admin.rs new file mode 100644 index 0000000..2be1e3e --- /dev/null +++ b/maskinrommet/src/ai_admin.rs @@ -0,0 +1,473 @@ +// AI Gateway-administrasjon (oppgave 15.4) +// +// Admin-API for modelloversikt, ruting-regler, fallback-kjeder og forbruksoversikt. +// PG er single source of truth — LiteLLM er stateløs proxy. +// +// Ref: docs/infra/ai_gateway.md + +use axum::{extract::State, http::StatusCode, Json}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::AppState; + +// ============================================================================= +// Datatyper +// ============================================================================= + +#[derive(Serialize, sqlx::FromRow)] +pub struct AiModelAlias { + pub id: Uuid, + pub alias: String, + pub description: Option, + pub is_active: bool, + pub created_at: DateTime, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct AiModelProvider { + pub id: Uuid, + pub alias_id: Uuid, + pub provider: String, + pub model: String, + pub api_key_env: String, + pub priority: i16, + pub is_active: bool, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct AiJobRouting { + pub job_type: String, + pub alias: String, + pub description: Option, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct AiUsageSummary { + pub collection_node_id: Option, + pub collection_title: Option, + pub model_alias: String, + pub job_type: Option, + pub total_prompt_tokens: i64, + pub total_completion_tokens: i64, + pub total_tokens: i64, + pub estimated_cost: f64, + pub call_count: i64, +} + +#[derive(Serialize)] +pub struct ApiKeyStatus { + pub env_var: String, + pub is_set: bool, +} + +#[derive(Serialize)] +pub struct AiOverviewResponse { + pub aliases: Vec, + pub providers: Vec, + pub routing: Vec, + pub usage: Vec, + pub api_key_status: Vec, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +fn bad_request(msg: &str) -> (StatusCode, Json) { + (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() })) +} + +fn internal_error(msg: &str) -> (StatusCode, Json) { + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() })) +} + +// ============================================================================= +// GET /admin/ai — oversikt +// ============================================================================= + +pub async fn ai_overview( + State(state): State, + _user: AuthUser, +) -> Result, (StatusCode, Json)> { + let aliases = sqlx::query_as::<_, AiModelAlias>( + "SELECT id, alias, description, is_active, created_at FROM ai_model_aliases ORDER BY alias" + ) + .fetch_all(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved henting av aliaser: {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 alias_id, priority" + ) + .fetch_all(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved henting av providers: {e}")))?; + + let routing = sqlx::query_as::<_, AiJobRouting>( + "SELECT job_type, alias, description FROM ai_job_routing ORDER BY job_type" + ) + .fetch_all(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved henting av ruting: {e}")))?; + + let usage = fetch_usage_summary(&state.db, 30).await + .map_err(|e| internal_error(&format!("Feil ved henting av forbruk: {e}")))?; + + // Sjekk hvilke API-nøkler som er satt i miljøet + let env_vars: Vec = providers.iter() + .map(|p| p.api_key_env.clone()) + .collect::>() + .into_iter() + .collect(); + let mut api_key_status: Vec = env_vars.into_iter().map(|env_var| { + let is_set = std::env::var(&env_var).map(|v| !v.is_empty()).unwrap_or(false); + ApiKeyStatus { env_var, is_set } + }).collect(); + api_key_status.sort_by(|a, b| a.env_var.cmp(&b.env_var)); + + Ok(Json(AiOverviewResponse { + aliases, + providers, + routing, + usage, + api_key_status, + })) +} + +async fn fetch_usage_summary(db: &PgPool, days: i32) -> Result, sqlx::Error> { + sqlx::query_as::<_, AiUsageSummary>( + r#" + SELECT + u.collection_node_id, + n.title AS collection_title, + u.model_alias, + u.job_type, + COALESCE(SUM(u.prompt_tokens), 0)::BIGINT AS total_prompt_tokens, + COALESCE(SUM(u.completion_tokens), 0)::BIGINT AS total_completion_tokens, + COALESCE(SUM(u.total_tokens), 0)::BIGINT AS total_tokens, + COALESCE(SUM(u.estimated_cost)::FLOAT8, 0.0) AS estimated_cost, + COUNT(*)::BIGINT AS call_count + FROM ai_usage_log u + LEFT JOIN nodes n ON n.id = u.collection_node_id + WHERE u.created_at >= now() - make_interval(days := $1) + GROUP BY u.collection_node_id, n.title, u.model_alias, u.job_type + ORDER BY total_tokens DESC + "#, + ) + .bind(days) + .fetch_all(db) + .await +} + +// ============================================================================= +// POST /admin/ai/update_alias — oppdater alias +// ============================================================================= + +#[derive(Deserialize)] +pub struct UpdateAliasRequest { + pub id: Uuid, + pub description: Option, + pub is_active: bool, +} + +pub async fn update_alias( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rows = sqlx::query( + "UPDATE ai_model_aliases SET description = $1, is_active = $2 WHERE id = $3" + ) + .bind(&req.description) + .bind(req.is_active) + .bind(req.id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved oppdatering av alias: {e}")))?; + + if rows.rows_affected() == 0 { + return Err(bad_request("Alias ikke funnet")); + } + + tracing::info!(alias_id = %req.id, user = %_user.node_id, "Admin: alias oppdatert"); + Ok(Json(serde_json::json!({ "success": true }))) +} + +// ============================================================================= +// POST /admin/ai/create_alias — opprett nytt alias +// ============================================================================= + +#[derive(Deserialize)] +pub struct CreateAliasRequest { + pub alias: String, + pub description: Option, +} + +pub async fn create_alias( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if req.alias.trim().is_empty() { + return Err(bad_request("Alias-navn kan ikke være tomt")); + } + + let id = sqlx::query_scalar::<_, Uuid>( + "INSERT INTO ai_model_aliases (alias, description) VALUES ($1, $2) RETURNING id" + ) + .bind(&req.alias) + .bind(&req.description) + .fetch_one(&state.db) + .await + .map_err(|e| { + if e.to_string().contains("unique") || e.to_string().contains("duplicate") { + bad_request(&format!("Alias '{}' finnes allerede", req.alias)) + } else { + internal_error(&format!("Feil ved opprettelse av alias: {e}")) + } + })?; + + tracing::info!(alias = %req.alias, user = %_user.node_id, "Admin: alias opprettet"); + Ok(Json(serde_json::json!({ "id": id, "success": true }))) +} + +// ============================================================================= +// POST /admin/ai/update_provider — oppdater provider +// ============================================================================= + +#[derive(Deserialize)] +pub struct UpdateProviderRequest { + pub id: Uuid, + pub priority: Option, + pub is_active: Option, +} + +pub async fn update_provider( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if let Some(priority) = req.priority { + sqlx::query("UPDATE ai_model_providers SET priority = $1 WHERE id = $2") + .bind(priority) + .bind(req.id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved oppdatering av provider priority: {e}")))?; + } + if let Some(is_active) = req.is_active { + sqlx::query("UPDATE ai_model_providers SET is_active = $1 WHERE id = $2") + .bind(is_active) + .bind(req.id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved oppdatering av provider status: {e}")))?; + } + + tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider oppdatert"); + Ok(Json(serde_json::json!({ "success": true }))) +} + +// ============================================================================= +// POST /admin/ai/create_provider — legg til ny provider +// ============================================================================= + +#[derive(Deserialize)] +pub struct CreateProviderRequest { + pub alias_id: Uuid, + pub provider: String, + pub model: String, + pub api_key_env: String, + pub priority: i16, +} + +pub async fn create_provider( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if req.model.trim().is_empty() { + return Err(bad_request("Modellnavn kan ikke være tomt")); + } + + let id = sqlx::query_scalar::<_, Uuid>( + "INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority) + VALUES ($1, $2, $3, $4, $5) RETURNING id" + ) + .bind(req.alias_id) + .bind(&req.provider) + .bind(&req.model) + .bind(&req.api_key_env) + .bind(req.priority) + .fetch_one(&state.db) + .await + .map_err(|e| { + if e.to_string().contains("unique") || e.to_string().contains("duplicate") { + bad_request("Denne modellen finnes allerede for dette aliaset") + } else if e.to_string().contains("foreign key") { + bad_request("Alias-ID finnes ikke") + } else { + internal_error(&format!("Feil ved opprettelse av provider: {e}")) + } + })?; + + tracing::info!(model = %req.model, alias_id = %req.alias_id, user = %_user.node_id, "Admin: provider opprettet"); + Ok(Json(serde_json::json!({ "id": id, "success": true }))) +} + +// ============================================================================= +// POST /admin/ai/delete_provider — fjern provider +// ============================================================================= + +#[derive(Deserialize)] +pub struct DeleteProviderRequest { + pub id: Uuid, +} + +pub async fn delete_provider( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rows = sqlx::query("DELETE FROM ai_model_providers WHERE id = $1") + .bind(req.id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved sletting av provider: {e}")))?; + + if rows.rows_affected() == 0 { + return Err(bad_request("Provider ikke funnet")); + } + + tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider slettet"); + Ok(Json(serde_json::json!({ "success": true }))) +} + +// ============================================================================= +// POST /admin/ai/update_routing — oppdater eller opprett rutingregel +// ============================================================================= + +#[derive(Deserialize)] +pub struct UpdateRoutingRequest { + pub job_type: String, + pub alias: String, + pub description: Option, +} + +pub async fn update_routing( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if req.job_type.trim().is_empty() || req.alias.trim().is_empty() { + return Err(bad_request("Jobbtype og alias kan ikke være tomme")); + } + + sqlx::query( + "INSERT INTO ai_job_routing (job_type, alias, description) + VALUES ($1, $2, $3) + ON CONFLICT (job_type) DO UPDATE SET alias = $2, description = $3" + ) + .bind(&req.job_type) + .bind(&req.alias) + .bind(&req.description) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved oppdatering av ruting: {e}")))?; + + tracing::info!(job_type = %req.job_type, alias = %req.alias, user = %_user.node_id, "Admin: ruting oppdatert"); + Ok(Json(serde_json::json!({ "success": true }))) +} + +// ============================================================================= +// POST /admin/ai/delete_routing — slett rutingregel +// ============================================================================= + +#[derive(Deserialize)] +pub struct DeleteRoutingRequest { + pub job_type: String, +} + +pub async fn delete_routing( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rows = sqlx::query("DELETE FROM ai_job_routing WHERE job_type = $1") + .bind(&req.job_type) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved sletting av ruting: {e}")))?; + + if rows.rows_affected() == 0 { + return Err(bad_request("Rutingregel ikke funnet")); + } + + tracing::info!(job_type = %req.job_type, user = %_user.node_id, "Admin: ruting slettet"); + Ok(Json(serde_json::json!({ "success": true }))) +} + +// ============================================================================= +// GET /admin/ai/usage — detaljert forbruksoversikt med filtre +// ============================================================================= + +#[derive(Deserialize)] +pub struct UsageQueryParams { + pub days: Option, + pub collection_id: Option, +} + +pub async fn ai_usage( + State(state): State, + _user: AuthUser, + axum::extract::Query(params): axum::extract::Query, +) -> Result>, (StatusCode, Json)> { + let days = params.days.unwrap_or(30).min(365); + + let usage = if let Some(collection_id) = params.collection_id { + fetch_usage_for_collection(&state.db, collection_id, days).await + } else { + fetch_usage_summary(&state.db, days).await + }; + + usage + .map(Json) + .map_err(|e| internal_error(&format!("Feil ved henting av forbruk: {e}"))) +} + +async fn fetch_usage_for_collection( + db: &PgPool, + collection_id: Uuid, + days: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, AiUsageSummary>( + r#" + SELECT + u.collection_node_id, + n.title AS collection_title, + u.model_alias, + u.job_type, + COALESCE(SUM(u.prompt_tokens), 0)::BIGINT AS total_prompt_tokens, + COALESCE(SUM(u.completion_tokens), 0)::BIGINT AS total_completion_tokens, + COALESCE(SUM(u.total_tokens), 0)::BIGINT AS total_tokens, + COALESCE(SUM(u.estimated_cost)::FLOAT8, 0.0) AS estimated_cost, + COUNT(*)::BIGINT AS call_count + FROM ai_usage_log u + LEFT JOIN nodes n ON n.id = u.collection_node_id + WHERE u.created_at >= now() - make_interval(days := $1) + AND u.collection_node_id = $2 + GROUP BY u.collection_node_id, n.title, u.model_alias, u.job_type + ORDER BY total_tokens DESC + "#, + ) + .bind(days) + .bind(collection_id) + .fetch_all(db) + .await +} diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index cb1f185..d89c32d 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -1,4 +1,5 @@ pub mod agent; +pub mod ai_admin; pub mod ai_edges; pub mod audio; mod auth; @@ -203,6 +204,16 @@ async fn main() { .route("/admin/jobs", get(intentions::list_jobs)) .route("/intentions/retry_job", post(intentions::retry_job)) .route("/intentions/cancel_job", post(intentions::cancel_job)) + // AI Gateway-konfigurasjon (oppgave 15.4) + .route("/admin/ai", get(ai_admin::ai_overview)) + .route("/admin/ai/usage", get(ai_admin::ai_usage)) + .route("/admin/ai/update_alias", post(ai_admin::update_alias)) + .route("/admin/ai/create_alias", post(ai_admin::create_alias)) + .route("/admin/ai/update_provider", post(ai_admin::update_provider)) + .route("/admin/ai/create_provider", post(ai_admin::create_provider)) + .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("/query/audio_info", get(intentions::audio_info)) .route("/pub/{slug}/feed.xml", get(rss::generate_feed)) .route("/pub/{slug}", get(publishing::serve_index)) diff --git a/tasks.md b/tasks.md index 730c600..f05ee80 100644 --- a/tasks.md +++ b/tasks.md @@ -166,8 +166,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 15.1 Systemvarsler: varslingsnode (`node_kind='system_announcement'`) med type (info/warning/critical), nedtelling og utløp. Frontend viser banner/toast for alle aktive klienter via STDB. Ref: `docs/concepts/adminpanelet.md`. - [x] 15.2 Graceful shutdown: admin setter vedlikeholdstidspunkt → nedtelling i frontend → nye LiveKit-rom blokkeres → jobbkø stopper → vent på aktive jobber → restart. Vis aktive sesjoner før bekreftelse. - [x] 15.3 Jobbkø-oversikt: admin-UI for aktive, ventende og feilede jobber. Filtrer på type/samling/status. Manuell retry og avbryt. -- [~] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`. - > Påbegynt: 2026-03-18T03:42 +- [x] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`. - [ ] 15.5 Ressursstyring: prioritetsregler mellom jobbtyper, ressursgrenser per worker, ressurs-governor for automatisk nedprioritering under aktive LiveKit-sesjoner, disk-status med varsling. - [ ] 15.6 Serverhelse-dashboard: tjeneste-status (PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit), metrikker (CPU, minne, disk), backup-status, logg-tilgang. - [ ] 15.7 Ressursforbruk-logging: `resource_usage_log`-tabell i PG. Maskinrommet logger AI-tokens (inn/ut, modellnivå), Whisper-tid (sek), TTS-tegn, CAS-lagring (bytes), LiveKit-tid (deltaker-min). Båndbredde via Caddy-logg-parsing. Ref: `docs/features/ressursforbruk.md`.