synops/maskinrommet/src/ai_admin.rs
vegard b4ede32713 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
2026-03-19 23:24:23 +00:00

693 lines
24 KiB
Rust

// 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<String>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
}
#[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<String>,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct AiUsageSummary {
pub collection_node_id: Option<Uuid>,
pub collection_title: Option<String>,
pub model_alias: String,
pub job_type: Option<String>,
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<AiModelAlias>,
pub providers: Vec<AiModelProvider>,
pub routing: Vec<AiJobRouting>,
pub usage: Vec<AiUsageSummary>,
pub api_key_status: Vec<ApiKeyStatus>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() }))
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(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<Option<String>, 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<AppState>,
_admin: AdminUser,
) -> Result<Json<AiOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
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<String> = providers.iter()
.map(|p| p.api_key_env.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let mut api_key_status: Vec<ApiKeyStatus> = 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<Vec<AiUsageSummary>, 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<String>,
pub is_active: bool,
}
pub async fn update_alias(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<UpdateAliasRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
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<String>,
}
pub async fn create_alias(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<CreateAliasRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
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<i16>,
pub is_active: Option<bool>,
}
pub async fn update_provider(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<UpdateProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
Json(req): Json<CreateProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
Json(req): Json<DeleteProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
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<String>,
}
pub async fn update_routing(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<UpdateRoutingRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
Json(req): Json<DeleteRoutingRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
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<i32>,
pub collection_id: Option<Uuid>,
}
pub async fn ai_usage(
State(state): State<AppState>,
_admin: AdminUser,
axum::extract::Query(params): axum::extract::Query<UsageQueryParams>,
) -> Result<Json<Vec<AiUsageSummary>>, (StatusCode, Json<ErrorResponse>)> {
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<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,
days: i32,
) -> Result<Vec<AiUsageSummary>, 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
}