use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModelPurpose {
InjectionClassification,
Embedding,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModelFormat {
Onnx,
Safetensors,
}
impl ModelFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::Onnx => "onnx",
Self::Safetensors => "safetensors",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct CatalogFile {
pub remote: &'static str,
pub dest: &'static str,
pub sha256: Option<&'static str>,
pub size: Option<u64>,
}
#[derive(Clone, Copy, Debug)]
pub struct CatalogModel {
pub name: &'static str,
pub purpose: ModelPurpose,
pub display_name: &'static str,
pub description: &'static str,
pub repo: &'static str,
pub base_url: &'static str,
pub license_id: &'static str,
pub license_url: &'static str,
pub gated: bool,
pub format: ModelFormat,
pub files: &'static [CatalogFile],
}
impl CatalogModel {
pub fn url_for(&self, file: &CatalogFile) -> String {
format!("{}/{}", self.base_url.trim_end_matches('/'), file.remote)
}
pub fn total_size(&self) -> Option<u64> {
self.files.iter().map(|f| f.size).sum()
}
}
pub const DEFAULT_MODEL: &str = "deberta-v3-prompt-injection-v2";
pub const DEFAULT_EMBEDDING_MODEL: &str = "minilm-l6-v2";
const DEBERTA_FILES: &[CatalogFile] = &[
CatalogFile {
remote: "onnx/model.onnx",
dest: "model.onnx",
sha256: Some("f0ea7f239f765aedbde7c9e163a7cb38a79c5b8853d3f76db5152172047b228c"),
size: Some(738_563_188),
},
CatalogFile {
remote: "onnx/tokenizer.json",
dest: "tokenizer.json",
sha256: None,
size: Some(8_648_886),
},
CatalogFile {
remote: "onnx/config.json",
dest: "config.json",
sha256: None,
size: Some(1_014),
},
];
const PROMPT_GUARD_2_86M_FILES: &[CatalogFile] = &[
CatalogFile {
remote: "model.onnx",
dest: "model.onnx",
sha256: None,
size: None,
},
CatalogFile {
remote: "tokenizer.json",
dest: "tokenizer.json",
sha256: None,
size: None,
},
CatalogFile {
remote: "config.json",
dest: "config.json",
sha256: None,
size: None,
},
];
const MINILM_L6_V2_FILES: &[CatalogFile] = &[
CatalogFile {
remote: "onnx/model_quantized.onnx",
dest: "model.onnx",
sha256: Some("afdb6f1a0e45b715d0bb9b11772f032c399babd23bfc31fed1c170afc848bdb1"),
size: Some(22_972_370),
},
CatalogFile {
remote: "tokenizer.json",
dest: "tokenizer.json",
sha256: None,
size: Some(711_661),
},
CatalogFile {
remote: "config.json",
dest: "config.json",
sha256: None,
size: Some(650),
},
];
const CATALOG: &[CatalogModel] = &[
CatalogModel {
name: DEFAULT_MODEL,
purpose: ModelPurpose::InjectionClassification,
display_name: "ProtectAI DeBERTa-v3 prompt-injection v2",
description: "Apache-2.0, ungated. Recommended default — no account or license gate.",
repo: "protectai/deberta-v3-base-prompt-injection-v2",
base_url:
"https://huggingface.co/protectai/deberta-v3-base-prompt-injection-v2/resolve/main",
license_id: "Apache-2.0",
license_url: "https://huggingface.co/protectai/deberta-v3-base-prompt-injection-v2",
gated: false,
format: ModelFormat::Onnx,
files: DEBERTA_FILES,
},
CatalogModel {
name: "llama-prompt-guard-2-86m",
purpose: ModelPurpose::InjectionClassification,
display_name: "Meta Llama Prompt Guard 2 (86M, ONNX)",
description: "Higher recall, but GATED — needs Hugging Face access + HF_TOKEN.",
repo: "gravitee-io/Llama-Prompt-Guard-2-86M-onnx",
base_url: "https://huggingface.co/gravitee-io/Llama-Prompt-Guard-2-86M-onnx/resolve/main",
license_id: "Llama-Community",
license_url: "https://huggingface.co/meta-llama/Llama-Prompt-Guard-2-86M",
gated: true,
format: ModelFormat::Onnx,
files: PROMPT_GUARD_2_86M_FILES,
},
CatalogModel {
name: DEFAULT_EMBEDDING_MODEL,
purpose: ModelPurpose::Embedding,
display_name: "all-MiniLM-L6-v2 (quantized ONNX)",
description:
"Apache-2.0, ungated. Opt-in local encoder — fetched on install, never bundled.",
repo: "Xenova/all-MiniLM-L6-v2",
base_url: "https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main",
license_id: "Apache-2.0",
license_url: "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2",
gated: false,
format: ModelFormat::Onnx,
files: MINILM_L6_V2_FILES,
},
];
pub fn for_purpose(purpose: ModelPurpose) -> impl Iterator<Item = &'static CatalogModel> {
CATALOG.iter().filter(move |model| model.purpose == purpose)
}
pub fn find(purpose: ModelPurpose, name: &str) -> Option<&'static CatalogModel> {
for_purpose(purpose).find(|model| model.name == name)
}
pub fn find_by_name(name: &str) -> Option<&'static CatalogModel> {
CATALOG.iter().find(|model| model.name == name)
}
pub fn all() -> &'static [CatalogModel] {
CATALOG
}
pub fn default_model() -> &'static CatalogModel {
find(ModelPurpose::InjectionClassification, DEFAULT_MODEL)
.expect("default guard model is always present in the catalog")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_model_is_ungated_and_present() {
let model = default_model();
assert_eq!(model.name, DEFAULT_MODEL);
assert_eq!(model.purpose, ModelPurpose::InjectionClassification);
assert!(!model.gated, "the recommended default must be ungated");
assert_eq!(model.license_id, "Apache-2.0");
}
#[test]
fn default_model_matches_runtime_default_selector() {
assert_eq!(DEFAULT_MODEL, harn_vm::config::DEFAULT_GUARD_MODEL);
}
#[test]
fn catalog_entries_are_well_formed() {
let mut names = std::collections::HashSet::new();
for model in CATALOG {
assert!(!model.name.is_empty());
assert!(
names.insert(model.name),
"{} is duplicated in the shared install namespace",
model.name
);
assert!(!model.files.is_empty(), "{} has no files", model.name);
assert!(
model.base_url.starts_with("https://"),
"{} base_url must be https",
model.name
);
for file in model.files {
if let Some(sha) = file.sha256 {
assert_eq!(sha.len(), 64, "{}/{} sha len", model.name, file.dest);
assert!(
sha.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"{}/{} sha must be lowercase hex",
model.name,
file.dest
);
}
}
}
}
#[test]
fn listing_and_lookup_are_scoped_by_purpose() {
let injection_models: Vec<_> = for_purpose(ModelPurpose::InjectionClassification).collect();
assert_eq!(
injection_models.len(),
CATALOG
.iter()
.filter(|model| model.purpose == ModelPurpose::InjectionClassification)
.count()
);
assert!(injection_models
.iter()
.all(|model| model.purpose == ModelPurpose::InjectionClassification));
let embedding = find(ModelPurpose::Embedding, DEFAULT_EMBEDDING_MODEL)
.expect("opt-in local encoder is cataloged");
assert_eq!(embedding.purpose, ModelPurpose::Embedding);
assert!(!embedding.gated);
assert_eq!(embedding.license_id, "Apache-2.0");
assert!(embedding
.files
.iter()
.any(|file| { file.dest.ends_with(".onnx") && file.sha256.is_some() }));
assert!(find(ModelPurpose::Embedding, DEFAULT_MODEL).is_none());
assert!(find(ModelPurpose::InjectionClassification, DEFAULT_MODEL).is_some());
assert_eq!(
find_by_name(DEFAULT_EMBEDDING_MODEL).map(|model| model.purpose),
Some(ModelPurpose::Embedding)
);
}
#[test]
fn url_for_joins_base_and_remote() {
let model = default_model();
let file = &model.files[0];
assert_eq!(
model.url_for(file),
"https://huggingface.co/protectai/deberta-v3-base-prompt-injection-v2/resolve/main/onnx/model.onnx"
);
}
#[test]
fn gated_model_is_opt_in_only() {
let gated = find(
ModelPurpose::InjectionClassification,
"llama-prompt-guard-2-86m",
)
.expect("present");
assert!(gated.gated);
assert!(gated.files.iter().all(|f| f.sha256.is_none()));
}
}