use anyhow::{Context, Result};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const CATALOG_MODELS_SUBDIR: &str = "models/catalog";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelConfig {
pub name: String,
pub task: String,
pub repo: String,
pub filename: String,
pub input_size: [u32; 2],
pub preprocessing: PreprocessingConfig,
pub num_classes: usize,
pub labels_file: String,
#[serde(default)]
pub custom_labels: CustomLabelsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreprocessingConfig {
pub mean: [f32; 3],
pub std: [f32; 3],
pub channel_order: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CustomLabelsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub additional_classes: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ModelSource {
#[default]
Catalog,
Registry,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NodeModelConfig {
#[serde(default)]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<Provider>,
#[serde(default)]
pub path: String,
#[serde(default)]
pub source: ModelSource,
#[serde(default)]
pub version: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence_threshold: Option<f32>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Openai,
Claude,
#[default]
Local,
}
crate::enum_display_via_str!(Provider {
Openai => "openai",
Claude => "claude",
Local => "local",
});
pub fn load_model_config(model_name: &str) -> Result<ModelConfig> {
let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
.join(model_name)
.join("config.json");
if !config_path.exists() {
anyhow::bail!(
"Model config not found for '{}' at {}. Run: mecha10 model pull {}",
model_name,
config_path.display(),
model_name
);
}
let content = std::fs::read_to_string(&config_path)
.context(format!("Failed to read model config: {}", config_path.display()))?;
let config: ModelConfig = serde_json::from_str(&content)
.context(format!("Failed to parse model config.json: {}", config_path.display()))?;
Ok(config)
}
pub fn load_labels(model_name: &str) -> Result<Vec<String>> {
let labels_path = PathBuf::from(CATALOG_MODELS_SUBDIR).join(model_name).join("labels.txt");
if !labels_path.exists() {
anyhow::bail!(
"Labels file not found for model '{}' at {}. Run: mecha10 model pull {}",
model_name,
labels_path.display(),
model_name
);
}
tracing::info!("📋 Loading labels from: {}", labels_path.display());
let content = std::fs::read_to_string(&labels_path)
.context(format!("Failed to read labels file: {}", labels_path.display()))?;
let mut labels: Vec<String> = content.lines().map(|s| s.trim().to_string()).collect();
let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
.join(model_name)
.join("config.json");
if config_path.exists() {
if let Ok(config_content) = std::fs::read_to_string(&config_path) {
if let Ok(config) = serde_json::from_str::<ModelConfig>(&config_content) {
if config.custom_labels.enabled {
let num_custom = config.custom_labels.additional_classes.len();
labels.extend(config.custom_labels.additional_classes);
tracing::info!("✅ Added {} custom labels (total: {} labels)", num_custom, labels.len());
}
}
}
}
tracing::info!("✅ Loaded {} labels", labels.len());
Ok(labels)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_config_serialization() {
let config = ModelConfig {
name: "test-model".to_string(),
task: "image-classification".to_string(),
repo: "test/repo".to_string(),
filename: "model.onnx".to_string(),
input_size: [224, 224],
preprocessing: PreprocessingConfig {
mean: [0.485, 0.456, 0.406],
std: [0.229, 0.224, 0.225],
channel_order: "RGB".to_string(),
},
num_classes: 1000,
labels_file: "labels.txt".to_string(),
custom_labels: CustomLabelsConfig::default(),
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: ModelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.name, deserialized.name);
assert_eq!(config.input_size, deserialized.input_size);
assert_eq!(config.preprocessing.mean, deserialized.preprocessing.mean);
}
#[test]
fn catalog_paths_match_model_services_catalog_write_location() {
assert_eq!(CATALOG_MODELS_SUBDIR, "models/catalog");
let model_name = "mobilenet-v2";
let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
.join(model_name)
.join("config.json");
let labels_path = PathBuf::from(CATALOG_MODELS_SUBDIR).join(model_name).join("labels.txt");
assert_eq!(config_path, PathBuf::from("models/catalog/mobilenet-v2/config.json"));
assert_eq!(labels_path, PathBuf::from("models/catalog/mobilenet-v2/labels.txt"));
}
}