// 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::AdminUser; 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() })) } // ============================================================================= // Modelloppslag fra ai_job_routing — brukes av jobbkø-dispatchers // ============================================================================= /// Slå opp modellalias fra ai_job_routing for en gitt jobbtype/kontekst. /// Returnerer None hvis ingen regel er konfigurert. pub async fn resolve_routing(db: &PgPool, job_type: &str) -> Result, sqlx::Error> { let row: Option<(String,)> = sqlx::query_as("SELECT alias FROM ai_job_routing WHERE job_type = $1") .bind(job_type) .fetch_optional(db) .await?; Ok(row.map(|(alias,)| alias)) } /// Slå opp modellalias med fallback til standard-alias. /// Brukes av dispatchers som trenger en modell uansett. pub async fn resolve_routing_or_default(db: &PgPool, job_type: &str) -> String { match resolve_routing(db, job_type).await { Ok(Some(alias)) => { tracing::debug!(job_type, alias = %alias, "Modell fra ai_job_routing"); alias } Ok(None) => { tracing::warn!(job_type, "Ingen rutingregel — bruker sidelinja/rutine"); "sidelinja/rutine".to_string() } Err(e) => { tracing::error!(job_type, error = %e, "Feil ved oppslag i ai_job_routing — bruker fallback"); "sidelinja/rutine".to_string() } } } // ============================================================================= // GET /admin/ai — oversikt // ============================================================================= pub async fn ai_overview( State(state): State, _admin: AdminUser, ) -> 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, _admin: AdminUser, 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 = %_admin.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, _admin: AdminUser, 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 = %_admin.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, _admin: AdminUser, 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 = %_admin.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, _admin: AdminUser, 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 = %_admin.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, _admin: AdminUser, 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 = %_admin.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, _admin: AdminUser, 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 = %_admin.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, _admin: AdminUser, 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 = %_admin.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, _admin: AdminUser, 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 }