The classifier sends only the subject and text, between unforgeable markers after the operator's prompt, to an OpenAI-compatible endpoint the operator configured; nothing is preset. Its answer maps to an LLM_ tag whose score is clamped (+5.0, -1.0 by default) and can never discard or reject on its own; X-Spam-LLM is sanitized, encoded and folded, and a planted one is removed. Failures, timeouts past the ceiling, a full slot or a paused model leave mail flowing untagged. llm_prompt answers trusted scripts, and accounts holding interactAi within an hourly limit. Redirects aren't followed and no content or secret is logged. The limits live in inbuxa:AiLimits. Acceptance tests 1 and 3 to 21; test 2 as the re-enabled shared llm case, whose setup no longer waits on a rules file from a developer's own path; test 22 written as the ignored ai_compat.
356 lines
12 KiB
Rust
356 lines
12 KiB
Rust
/*
|
|
* 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<Message>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub temperature: Option<f64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_tokens: Option<u32>,
|
|
#[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<ChatCompletionChoice>,
|
|
}
|
|
|
|
/// 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<u32>,
|
|
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<AiModel> {
|
|
self.registry().object::<AiModel>(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::<Vec<Id>>(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<String, Failure> {
|
|
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<String, Failure> {
|
|
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::<Vec<_>>();
|
|
!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<String> {
|
|
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))
|
|
}
|