Tre fikser funnet under validering: 1. SIKKERHET: Admin-endepunkter manglet autorisasjonssjekk. Alle /admin/*-endepunkter brukte kun AuthUser (autentisert), ikke admin-rolle. Ny AdminUser-extractor sjekker owner/admin-edge til samling — returnerer 403 Forbidden for ikke-admins. Berører: maintenance, jobs, resources, health, ai, usage. 2. Race condition i toggle_effect: les-modifiser-skriv uten transaksjon på active_effects JSON. Erstattet med atomisk PG jsonb-operasjon. 3. Manglende updated_by i set_gain, set_mute, set_mixer_role, toggle_effect. Nå spores hvem som endret mixer-tilstanden.
473 lines
16 KiB
Rust
473 lines
16 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() }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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}")))
|
|
}
|
|
|
|
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
|
|
}
|