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:
2026-09-19 00:41:00 -07:00
parent cba48cf03b
commit 9490fc4677
39 changed files with 2945 additions and 28 deletions
+160
View File
@@ -0,0 +1,160 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:AiLimits`, the fork's limits on model calls ("Added by
//! inbuxa-server" in the spec). Stored as JSON under `A` + `l` in the fork's
//! subspace; unset fields read as the defaults.
use registry::types::duration::Duration;
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AiLimits {
pub spam_max_added: f64,
pub spam_max_subtracted: f64,
pub spam_call_ceiling: Duration,
pub max_concurrent_calls: u64,
pub max_content_bytes: u64,
pub failure_backoff: Duration,
pub user_calls_per_hour: u64,
}
impl Default for AiLimits {
fn default() -> Self {
AiLimits {
spam_max_added: 5.0,
spam_max_subtracted: 1.0,
spam_call_ceiling: Duration::from_millis(20_000),
max_concurrent_calls: 4,
max_content_bytes: 16_384,
failure_backoff: Duration::from_millis(60_000),
user_calls_per_hour: 60,
}
}
}
/// The properties `inbuxa:AiLimits` has, as they appear over JMAP.
pub const PROPERTIES: &[&str] = &[
"spamMaxAdded",
"spamMaxSubtracted",
"spamCallCeiling",
"maxConcurrentCalls",
"maxContentBytes",
"failureBackoff",
"userCallsPerHour",
];
impl AiLimits {
/// The gate's limits.
pub fn gate(&self) -> crate::ai::gate::Limits {
crate::ai::gate::Limits {
max_concurrent: self.max_concurrent_calls as usize,
backoff: self.failure_backoff.into_inner(),
account_calls_per_hour: self.user_calls_per_hour.min(u32::MAX as u64) as u32,
}
}
/// What's wrong with these values, naming the property.
pub fn check(&self) -> Result<(), (&'static str, String)> {
for (name, value) in [
("spamMaxAdded", self.spam_max_added),
("spamMaxSubtracted", self.spam_max_subtracted),
] {
if !value.is_finite() || value < 0.0 || value > 1000.0 {
return Err((name, "must be a number from 0 to 1000".into()));
}
}
if self.spam_call_ceiling.into_inner().as_millis() < 100
|| self.spam_call_ceiling.into_inner().as_secs() > 600
{
return Err(("spamCallCeiling", "must be from 100ms to 10 minutes".into()));
}
if !(1..=1024).contains(&self.max_concurrent_calls) {
return Err(("maxConcurrentCalls", "must be from 1 to 1024".into()));
}
if !(256..=1024 * 1024).contains(&self.max_content_bytes) {
return Err(("maxContentBytes", "must be from 256 bytes to 1 MiB".into()));
}
if self.failure_backoff.into_inner().as_secs() > 86_400 {
return Err(("failureBackoff", "must be at most a day".into()));
}
Ok(())
}
}
fn key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Al".to_vec(),
})
}
struct Json(AiLimits);
impl Deserialize for Json {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.caused_by(trc::location!())
.reason(err)
})
}
}
/// The limits in force.
pub async fn get(data: &Store) -> trc::Result<AiLimits> {
Ok(data
.get_value::<Json>(ValueKey::from(key()))
.await
.caused_by(trc::location!())?
.map(|Json(limits)| limits)
.unwrap_or_default())
}
/// Stores new limits.
pub async fn set(data: &Store, limits: &AiLimits) -> trc::Result<()> {
let bytes = serde_json::to_vec(limits).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?;
let mut batch = BatchBuilder::new();
batch.set(key(), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_and_partial_json() {
let limits = AiLimits::default();
assert!(limits.check().is_ok());
let partial: AiLimits = serde_json::from_str(r#"{"maxConcurrentCalls": 1}"#).unwrap();
assert_eq!(partial.max_concurrent_calls, 1);
assert_eq!(partial.user_calls_per_hour, 60);
let json = serde_json::to_value(&limits).unwrap();
for property in PROPERTIES {
assert!(json.get(property).is_some(), "{property}");
}
assert_eq!(json["spamCallCeiling"], 20_000);
let bad = AiLimits {
max_concurrent_calls: 0,
..Default::default()
};
assert_eq!(bad.check().unwrap_err().0, "maxConcurrentCalls");
}
}