Files
tkmind_go/crates/goose/src/security/classification_client.rs
T

252 lines
7.6 KiB
Rust

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use url::Url;
/// Request format following HuggingFace Inference Text Classification API specification
#[derive(Debug, Serialize)]
struct ClassificationRequest {
inputs: String,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Clone)]
struct ClassificationLabel {
label: String,
score: f32,
}
type ClassificationResponse = Vec<Vec<ClassificationLabel>>;
#[derive(Debug, Deserialize, Clone)]
pub struct ModelEndpointInfo {
pub endpoint: String,
pub model_type: Option<String>,
#[serde(flatten)]
pub extra_params: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ModelMappingConfig {
#[serde(flatten)]
pub models: HashMap<String, ModelEndpointInfo>,
}
#[derive(Debug)]
pub struct ClassificationClient {
endpoint_url: String,
client: reqwest::Client,
auth_token: Option<String>,
extra_params: Option<HashMap<String, serde_json::Value>>,
}
impl ClassificationClient {
pub fn new(
endpoint_url: String,
timeout_ms: Option<u64>,
auth_token: Option<String>,
extra_params: Option<HashMap<String, serde_json::Value>>,
) -> Result<Self> {
let timeout = Duration::from_millis(timeout_ms.unwrap_or(5000));
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.context("Failed to create HTTP client")?;
Ok(Self {
endpoint_url,
client,
auth_token,
extra_params,
})
}
pub fn from_model_name(model_name: &str, timeout_ms: Option<u64>) -> Result<Self> {
let mapping_json = std::env::var("SECURITY_ML_MODEL_MAPPING")
.context("SECURITY_ML_MODEL_MAPPING environment variable not set")?;
let mapping = serde_json::from_str::<ModelMappingConfig>(&mapping_json)
.context("Failed to parse SECURITY_ML_MODEL_MAPPING JSON")?;
let model_info = mapping.models.get(model_name).context(format!(
"Model '{}' not found in SECURITY_ML_MODEL_MAPPING",
model_name
))?;
tracing::debug!(
model_name = %model_name,
endpoint = %model_info.endpoint,
extra_params = ?model_info.extra_params,
"Creating classification client from model mapping"
);
Self::new(
model_info.endpoint.clone(),
timeout_ms,
None,
Some(model_info.extra_params.clone()),
)
}
pub fn from_model_type(model_type: &str, timeout_ms: Option<u64>) -> Result<Self> {
let mapping = serde_json::from_str::<ModelMappingConfig>(
&std::env::var("SECURITY_ML_MODEL_MAPPING")
.context("SECURITY_ML_MODEL_MAPPING environment variable not set")?,
)
.context("Failed to parse SECURITY_ML_MODEL_MAPPING JSON")?;
let (_, model_info) = mapping
.models
.iter()
.find(|(_, info)| info.model_type.as_deref() == Some(model_type))
.context(format!(
"No model with type '{}' found in SECURITY_ML_MODEL_MAPPING",
model_type
))?;
Self::new(
model_info.endpoint.clone(),
timeout_ms,
None,
Some(model_info.extra_params.clone()),
)
}
pub fn from_endpoint(
endpoint_url: String,
timeout_ms: Option<u64>,
auth_token: Option<String>,
) -> Result<Self> {
let endpoint_url = endpoint_url.trim().to_string();
Url::parse(&endpoint_url)
.context("Invalid endpoint URL format. Must be a valid HTTP/HTTPS URL")?;
let auth_token = auth_token
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
tracing::debug!(
endpoint = %endpoint_url,
has_token = auth_token.is_some(),
"Creating classification client from endpoint"
);
Self::new(endpoint_url, timeout_ms, auth_token, None)
}
pub async fn classify(&self, text: &str) -> Result<f32> {
let parameters = self
.extra_params
.as_ref()
.map(serde_json::to_value)
.transpose()?;
let request = ClassificationRequest {
inputs: text.to_string(),
parameters,
};
let mut request_builder = self.client.post(&self.endpoint_url).json(&request);
if let Some(token) = &self.auth_token {
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
}
let response = request_builder
.send()
.await
.context("Failed to send classification request")?;
let status = response.status();
let response = if !status.is_success() {
let error_body = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"Classification API returned error status {}: {}",
status,
error_body
));
} else {
response
};
let classification_response: ClassificationResponse = response
.json()
.await
.context("Failed to parse classification response")?;
let batch_result = classification_response
.first()
.context("Classification API returned empty response")?;
let sum: f32 = batch_result.iter().map(|l| l.score).sum();
let is_probabilities = batch_result
.iter()
.all(|label| label.score >= 0.0 && label.score <= 1.0)
&& (sum - 1.0).abs() < 0.1;
let normalized_results: Vec<ClassificationLabel> = if is_probabilities {
batch_result.to_vec()
} else {
self.apply_softmax(batch_result)?
};
let top_label = normalized_results
.iter()
.max_by(|a, b| {
a.score
.partial_cmp(&b.score)
.unwrap_or(std::cmp::Ordering::Equal)
})
.context("Classification API returned no labels")?;
let injection_score = match top_label.label.as_str() {
"INJECTION" | "LABEL_1" => top_label.score,
"SAFE" | "LABEL_0" => 1.0 - top_label.score,
_ => {
tracing::warn!(
label = %top_label.label,
score = %top_label.score,
"Unknown classification label, defaulting to safe"
);
0.0
}
};
Ok(injection_score)
}
fn apply_softmax(&self, labels: &[ClassificationLabel]) -> Result<Vec<ClassificationLabel>> {
if labels.is_empty() {
return Ok(Vec::new());
}
let max_score = labels
.iter()
.map(|l| l.score)
.fold(f32::NEG_INFINITY, f32::max);
let exp_scores: Vec<f32> = labels.iter().map(|l| (l.score - max_score).exp()).collect();
let sum_exp: f32 = exp_scores.iter().sum();
if sum_exp == 0.0 || !sum_exp.is_finite() {
anyhow::bail!("Softmax normalization failed: invalid sum");
}
let normalized: Vec<ClassificationLabel> = labels
.iter()
.zip(exp_scores.iter())
.map(|(label, &exp_score)| ClassificationLabel {
label: label.label.clone(),
score: exp_score / sum_exp,
})
.collect();
Ok(normalized)
}
}