use serde::{Deserialize, Serialize};
#[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 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";
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 CATALOG: &[CatalogModel] = &[
CatalogModel {
name: DEFAULT_MODEL,
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",
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,
},
];
pub fn all() -> &'static [CatalogModel] {
CATALOG
}
pub fn find(name: &str) -> Option<&'static CatalogModel> {
CATALOG.iter().find(|m| m.name == name)
}
pub fn default_model() -> &'static CatalogModel {
find(DEFAULT_MODEL).expect("default 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!(!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() {
for model in all() {
assert!(!model.name.is_empty());
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 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("llama-prompt-guard-2-86m").expect("present");
assert!(gated.gated);
assert!(gated.files.iter().all(|f| f.sha256.is_none()));
}
}