252 lines
7.6 KiB
Rust
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)
|
|
}
|
|
}
|