// 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 synops/low"); "synops/low".to_string() } Err(e) => { tracing::error!(job_type, error = %e, "Feil ved oppslag i ai_job_routing — bruker fallback"); "synops/low".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}"))) } // ============================================================================= // 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, 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, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, pub providers: Vec, } #[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, _admin: AdminUser, ) -> Result>, (StatusCode, Json)> { 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 = aliases.iter().map(|a| { let alias_providers: Vec = 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, 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 }