AI spam classification: the model's opinion as one bounded spam signal, and the llm_prompt Sieve function (AI-1 to AI-28)
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.
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Rebuilt features that sit on the server itself, at the paths the shared
|
||||
//! tests name. The rules live in `inbuxa_features`.
|
||||
|
||||
pub mod llm;
|
||||
Reference in New Issue
Block a user