/* * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only */ //! Calling the operator's model (AI spam classification spec, AI-5 to AI-11, //! AI-21 to AI-25). The rules live in `inbuxa_features::ai`; this makes the //! HTTP request. The wire types are the OpenAI-compatible chat completions //! shapes local model servers speak. use crate::Server; use inbuxa_features::ai::{ gate::{Gate, Refused, Transition}, limits::{self, AiLimits}, locality, request::{self, Kind, MAX_RESPONSE_BYTES}, }; use registry::schema::{ enums::AiModelType, prelude::ObjectType, structs::AiModel, }; use serde::{Deserialize, Serialize}; use std::time::{Duration, Instant}; use store::registry::RegistryQuery; use trc::AiEvent; use types::id::Id; /// A chat completions request. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ChatCompletionRequest { pub model: String, pub messages: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, #[serde(default)] pub stream: bool, } /// One chat message. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Message { pub role: String, pub content: String, } /// A chat completions response. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ChatCompletionResponse { pub created: i64, pub object: String, pub id: String, pub model: String, pub choices: Vec, } /// One choice in a response. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ChatCompletionChoice { pub index: u32, pub finish_reason: String, pub message: Message, } /// Why a call produced no answer. Every one leaves mail flowing (AI-9). #[derive(Debug, Clone, PartialEq, Eq)] pub enum Failure { Refused(Refused), Timeout, Http(String), Status(u16), BadAnswer, } /// One call to make. pub struct Call<'x> { pub model_id: Id, pub model: &'x AiModel, /// Set for an account's own script (AI-24, AI-25). pub account_id: Option, pub system: Option<&'x str>, pub user: &'x str, pub temperature: f64, pub max_tokens: u32, pub timeout: Duration, } fn kind(model: &AiModel) -> Kind { match model.model_type { AiModelType::Chat => Kind::Chat, AiModelType::Text => Kind::Text, } } impl Server { /// The fork's limits, as stored now. pub async fn ai_limits(&self) -> AiLimits { limits::get(&self.core.storage.data) .await .unwrap_or_default() } /// A model by its id. pub async fn ai_model_by_id(&self, id: Id) -> Option { self.registry().object::(id).await.ok().flatten() } /// A model by name, or failing that by id (AI-20). pub async fn ai_model_by_name(&self, name: &str) -> Option<(Id, AiModel)> { let ids = self .registry() .query::>(RegistryQuery::new(ObjectType::AiModel)) .await .ok()?; let mut by_id = None; for id in ids { if let Some(model) = self.ai_model_by_id(id).await { if model.name == name { return Some((id, model)); } if id.to_string() == name { by_id = Some((id, model)); } } } by_id } /// Makes one call. The answer, or why there is none; either way the /// outcome is logged, with no message content and no secret (AI-5). pub async fn ai_call(&self, call: Call<'_>) -> Result { let limits = self.ai_limits().await; let gate = Gate::global(); let permit = match gate.try_start(call.model_id.id(), call.account_id, limits.gate()) { Ok(permit) => permit, Err(refused) => { trc::event!( Ai(AiEvent::ApiError), Details = call.model.name.clone(), AccountId = call.account_id, Reason = format!("{refused:?}"), ); return Err(Failure::Refused(refused)); } }; let started = Instant::now(); let result = tokio::time::timeout(call.timeout, self.ai_request(&call)).await; let result = match result { Ok(result) => result, Err(_) => Err(Failure::Timeout), }; let transition = permit.finish(result.is_ok(), limits.failure_backoff.into_inner()); match &transition { Some(Transition::Paused) => trc::event!( Ai(AiEvent::ApiError), Details = call.model.name.clone(), Reason = format!( "Paused for {}s after repeated failures", limits.failure_backoff.into_inner().as_secs() ), ), Some(Transition::Resumed) => trc::event!( Ai(AiEvent::LlmResponse), Details = call.model.name.clone(), Reason = "Resumed after a pause", ), None => {} } match &result { Ok(answer) => trc::event!( Ai(AiEvent::LlmResponse), Details = call.model.name.clone(), AccountId = call.account_id, Elapsed = started.elapsed(), Result = request::cut(answer, 1024), ), Err(failure) => trc::event!( Ai(AiEvent::ApiError), Details = call.model.name.clone(), AccountId = call.account_id, Elapsed = started.elapsed(), Code = match failure { Failure::Status(code) => *code as u64, _ => 0, }, Reason = format!("{failure:?}"), ), } result } async fn ai_request(&self, call: &Call<'_>) -> Result { let model = call.model; let kind = kind(model); let body = request::body( kind, &model.model, call.system, call.user, call.temperature, call.max_tokens, ); // Secrets are read now, from their source (AI-8) let headers = model .http_auth .build_headers(model.http_headers.clone(), Some("application/json")) .await .map_err(Failure::Http)?; let client = utils::http::http_client_builder(model.allow_invalid_certs) // A redirect would send content to a host nobody named (AI-8) .redirect(reqwest::redirect::Policy::none()) .connect_timeout(call.timeout) .timeout(call.timeout) .default_headers(headers) .build() .map_err(|err| Failure::Http(err.to_string()))?; let mut response = client .post(&model.url) .body(body.to_string()) .send() .await .map_err(|err| { if err.is_timeout() { Failure::Timeout } else { Failure::Http(err.without_url().to_string()) } })?; let status = response.status().as_u16(); if status != 200 { return Err(Failure::Status(status)); } let mut bytes = Vec::new(); while let Some(chunk) = response .chunk() .await .map_err(|err| Failure::Http(err.without_url().to_string()))? { bytes.extend_from_slice(&chunk); if bytes.len() > MAX_RESPONSE_BYTES { return Err(Failure::BadAnswer); } } request::answer(kind, &bytes).ok_or(Failure::BadAnswer) } /// AI-2: warns when a model's endpoint isn't on this network. pub async fn ai_warn_if_remote(&self, model: &AiModel) { warn_if_remote(model).await } } /// AI-2: warns when a model's endpoint isn't on this network. Names are /// resolved; any address outside the local ranges counts. pub async fn warn_if_remote(model: &AiModel) { let local = match locality::classify(&model.url) { Some(local) => local, None => { let host = locality::host(&model.url).unwrap_or_default().to_string(); match tokio::net::lookup_host((host.as_str(), 443)).await { Ok(addrs) => { let addrs = addrs.map(|a| a.ip()).collect::>(); !addrs.is_empty() && addrs.into_iter().all(locality::is_local_ip) } Err(_) => false, } } }; if !local { trc::event!( Registry(trc::RegistryEvent::BuildWarning), Details = locality::warning(&model.name, &model.url), ); } } /// The most of a script's prompt sent (AI-23). const MAX_PROMPT_BYTES: usize = 32 * 1024; /// The most of an answer a script gets back (AI-22). const MAX_SCRIPT_ANSWER_BYTES: usize = 8 * 1024; /// The longest an account's own script waits (AI-23). const ACCOUNT_SCRIPT_CEILING: Duration = Duration::from_secs(60); /// `llm_prompt(model, prompt, temperature)` (AI-20 to AI-25). The answer as /// plain text, or `None`, which the script sees as `false`. pub async fn sieve_prompt( ctx: crate::scripts::plugins::PluginContext<'_>, ) -> Option { use registry::schema::enums::Permission; use sieve::runtime::Variable; let server = ctx.server; let name = ctx.arguments.first()?.to_string(); let prompt = ctx.arguments.get(1)?.to_string(); let temperature = match ctx.arguments.get(2) { Some(Variable::Float(t)) => Some(*t), Some(Variable::Integer(t)) => Some(*t as f64), _ => None, }; // AI-23: trusted system scripts always; an account's own with interactAi let account_id = match ctx.access_token { Some(token) if !token.has_permission(Permission::InteractAi) => { trc::event!( Ai(AiEvent::ApiError), SpanId = ctx.session_id, AccountId = token.account_id(), Reason = "The account may not call AI models", ); return None; } Some(token) => Some(token.account_id()), None => None, }; let Some((model_id, model)) = server.ai_model_by_name(name.as_ref()).await else { trc::event!( Ai(AiEvent::ApiError), SpanId = ctx.session_id, AccountId = account_id, Reason = format!("No AI model named {name:?}"), ); return None; }; let limits = server.ai_limits().await; let timeout = match account_id { Some(_) => model.timeout.into_inner().min(ACCOUNT_SCRIPT_CEILING), None => model .timeout .into_inner() .min(limits.spam_call_ceiling.into_inner()), }; let prompt = request::cut(&prompt, MAX_PROMPT_BYTES); let answer = server .ai_call(Call { model_id, model: &model, account_id, system: None, user: &prompt, temperature: temperature.unwrap_or_else(|| model.temperature.into_inner()), max_tokens: request::PROMPT_MAX_TOKENS, timeout, }) .await .ok()?; // Plain data: never evaluated (AI-22) Some(request::cut(answer.trim(), MAX_SCRIPT_ANSWER_BYTES)) }