//! Admin CRUD endpoints for AI Providers, Models, Versions, and Pricing. //! //! All write operations use `Session::no_op()` (system caller) which passes //! the `require_system_caller` check in the service layer. use actix_web::{HttpRequest, HttpResponse, Result, web}; use service::agent::provider::{CreateProviderRequest as SvcCreateProvider, UpdateProviderRequest as SvcUpdateProvider}; use service::agent::model::{CreateModelRequest as SvcCreateModel, UpdateModelRequest as SvcUpdateModel}; use service::agent::model_version::{CreateModelVersionRequest as SvcCreateVersion, UpdateModelVersionRequest as SvcUpdateVersion}; use service::agent::model_pricing::UpdateModelPricingRequest as SvcUpdatePricing; use service::AppService; use uuid::Uuid; use crate::error::ApiError; use crate::ApiResponse; use service::error::AppError; /// Validate the `x-admin-api-key` header against ADMIN_API_SHARED_KEY env var. fn validate_admin_key(req: &HttpRequest) -> Result<(), ApiError> { let expected = std::env::var("ADMIN_API_SHARED_KEY").ok(); let Some(expected) = expected else { return Err(ApiError(AppError::InternalServerError( "ADMIN_API_SHARED_KEY not configured".into(), ))); }; let provided = req .headers() .get("x-admin-api-key") .and_then(|v| v.to_str().ok()) .ok_or(ApiError(AppError::Unauthorized))?; if provided != expected { return Err(ApiError(AppError::Unauthorized)); } Ok(()) } // ─── Provider CRUD ───────────────────────────────────────────────────────────── #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AdminCreateProvider { pub name: String, pub display_name: String, pub website: Option, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AdminUpdateProvider { pub display_name: Option, pub website: Option, pub status: Option, } #[utoipa::path( post, path = "/api/admin/ai/providers", request_body = AdminCreateProvider, responses( (status = 200, description = "Provider created"), (status = 401), (status = 400), ), tag = "Admin" )] pub async fn admin_provider_create( req: HttpRequest, service: web::Data, body: web::Json, ) -> Result { validate_admin_key(&req)?; let session = session::Session::no_op(); let svc_body = SvcCreateProvider { name: body.name.clone(), display_name: body.display_name.clone(), website: body.website.clone(), }; let resp = service.agent_provider_create(svc_body, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } #[utoipa::path( patch, path = "/api/admin/ai/providers/{id}", params(("id" = Uuid, Path)), request_body = AdminUpdateProvider, responses( (status = 200, description = "Provider updated"), (status = 401), (status = 404), ), tag = "Admin" )] pub async fn admin_provider_update( req: HttpRequest, service: web::Data, path: web::Path, body: web::Json, ) -> Result { validate_admin_key(&req)?; let id = path.into_inner(); let session = session::Session::no_op(); let svc_body = SvcUpdateProvider { display_name: body.display_name.clone(), website: body.website.clone(), status: body.status.clone(), }; let resp = service.agent_provider_update(id, svc_body, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } #[utoipa::path( delete, path = "/api/admin/ai/providers/{id}", params(("id" = Uuid, Path)), responses( (status = 200), (status = 401), (status = 404), ), tag = "Admin" )] pub async fn admin_provider_delete( req: HttpRequest, service: web::Data, path: web::Path, ) -> Result { validate_admin_key(&req)?; let id = path.into_inner(); let session = session::Session::no_op(); service.agent_provider_delete(id, &session).await?; Ok(ApiResponse::ok(serde_json::json!({ "deleted": true })).to_response()) } // ─── Model CRUD ──────────────────────────────────────────────────────────────── #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AdminCreateModel { pub provider_id: Uuid, pub name: String, pub modality: String, pub capability: String, pub context_length: i64, #[serde(default)] pub max_output_tokens: Option, #[serde(default)] pub training_cutoff: Option>, #[serde(default)] pub is_open_source: bool, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AdminUpdateModel { pub display_name: Option, pub modality: Option, pub capability: Option, pub context_length: Option, pub max_output_tokens: Option, pub training_cutoff: Option>, #[serde(default)] pub is_open_source: Option, pub status: Option, } #[utoipa::path( post, path = "/api/admin/ai/models", request_body = AdminCreateModel, responses( (status = 200, description = "Model created"), (status = 401), (status = 400), ), tag = "Admin" )] pub async fn admin_model_create( req: HttpRequest, service: web::Data, body: web::Json, ) -> Result { validate_admin_key(&req)?; let session = session::Session::no_op(); let svc_body = SvcCreateModel { provider_id: body.provider_id, name: body.name.clone(), modality: body.modality.clone(), capability: body.capability.clone(), context_length: body.context_length, max_output_tokens: body.max_output_tokens, training_cutoff: body.training_cutoff, is_open_source: body.is_open_source, }; let resp = service.agent_model_create(svc_body, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } #[utoipa::path( patch, path = "/api/admin/ai/models/{id}", params(("id" = Uuid, Path)), request_body = AdminUpdateModel, responses( (status = 200, description = "Model updated"), (status = 401), (status = 404), ), tag = "Admin" )] pub async fn admin_model_update( req: HttpRequest, service: web::Data, path: web::Path, body: web::Json, ) -> Result { validate_admin_key(&req)?; let id = path.into_inner(); let session = session::Session::no_op(); let svc_body = SvcUpdateModel { display_name: body.display_name.clone(), modality: body.modality.clone(), capability: body.capability.clone(), context_length: body.context_length, max_output_tokens: body.max_output_tokens, training_cutoff: body.training_cutoff, is_open_source: body.is_open_source, status: body.status.clone(), }; let resp = service.agent_model_update(id, svc_body, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } #[utoipa::path( delete, path = "/api/admin/ai/models/{id}", params(("id" = Uuid, Path)), responses( (status = 200), (status = 401), (status = 404), ), tag = "Admin" )] pub async fn admin_model_delete( req: HttpRequest, service: web::Data, path: web::Path, ) -> Result { validate_admin_key(&req)?; let id = path.into_inner(); let session = session::Session::no_op(); service.agent_model_delete(id, &session).await?; Ok(ApiResponse::ok(serde_json::json!({ "deleted": true })).to_response()) } // ─── Model Version CRUD ──────────────────────────────────────────────────────── #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AdminCreateVersion { pub model_id: Uuid, pub version: String, #[serde(default)] pub release_date: Option>, #[serde(default)] pub change_log: Option, #[serde(default)] pub is_default: bool, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AdminUpdateVersion { pub version: Option, pub release_date: Option>, pub change_log: Option, #[serde(default)] pub is_default: Option, pub status: Option, } #[utoipa::path( post, path = "/api/admin/ai/versions", request_body = AdminCreateVersion, responses( (status = 200, description = "Version created"), (status = 401), (status = 400), ), tag = "Admin" )] pub async fn admin_version_create( req: HttpRequest, service: web::Data, body: web::Json, ) -> Result { validate_admin_key(&req)?; let session = session::Session::no_op(); let svc_body = SvcCreateVersion { model_id: body.model_id, version: body.version.clone(), release_date: body.release_date, change_log: body.change_log.clone(), is_default: body.is_default, }; let resp = service.agent_model_version_create(svc_body, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } #[utoipa::path( patch, path = "/api/admin/ai/versions/{id}", params(("id" = Uuid, Path)), request_body = AdminUpdateVersion, responses( (status = 200), (status = 401), (status = 404), ), tag = "Admin" )] pub async fn admin_version_update( req: HttpRequest, service: web::Data, path: web::Path, body: web::Json, ) -> Result { validate_admin_key(&req)?; let id = path.into_inner(); let session = session::Session::no_op(); let svc_body = SvcUpdateVersion { version: body.version.clone(), release_date: body.release_date, change_log: body.change_log.clone(), is_default: body.is_default, status: body.status.clone(), }; let resp = service.agent_model_version_update(id, svc_body, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } #[utoipa::path( delete, path = "/api/admin/ai/versions/{id}", params(("id" = Uuid, Path)), responses( (status = 200), (status = 401), (status = 404), ), tag = "Admin" )] pub async fn admin_version_delete( req: HttpRequest, service: web::Data, path: web::Path, ) -> Result { validate_admin_key(&req)?; let id = path.into_inner(); let session = session::Session::no_op(); service.agent_model_version_delete(id, &session).await?; Ok(ApiResponse::ok(serde_json::json!({ "deleted": true })).to_response()) } // ─── Model Pricing CRUD ─────────────────────────────────────────────────────── #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AdminCreatePricing { pub model_version_id: Uuid, pub input_price_per_1k_tokens: String, pub output_price_per_1k_tokens: String, pub currency: String, pub effective_from: chrono::DateTime, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AdminUpdatePricing { pub input_price_per_1k_tokens: Option, pub output_price_per_1k_tokens: Option, pub currency: Option, pub effective_from: Option>, } #[utoipa::path( patch, path = "/api/admin/ai/pricing/{id}", params(("id" = i64, Path)), request_body = AdminUpdatePricing, responses( (status = 200), (status = 401), (status = 404), ), tag = "Admin" )] pub async fn admin_pricing_update( req: HttpRequest, service: web::Data, path: web::Path, body: web::Json, ) -> Result { validate_admin_key(&req)?; let id = path.into_inner(); let session = session::Session::no_op(); let svc_body = SvcUpdatePricing { input_price_per_1k_tokens: body.input_price_per_1k_tokens.clone(), output_price_per_1k_tokens: body.output_price_per_1k_tokens.clone(), currency: body.currency.clone(), effective_from: body.effective_from, }; let resp = service.agent_model_pricing_update(id, svc_body, &session).await?; Ok(ApiResponse::ok(resp).to_response()) }