//! Offline model catalog, capabilities, and deterministic resolution //! (`SMH-SPEC-SPEC0001`, Providers). //! //! The reviewed catalog ships as checked-in JSON. Resolution is pure data //! navigation with no network: aliases recurse, groups expand to ordered //! failover plans, and cycles or dangling references fail at load with the //! field path that caused them. use serde::{Deserialize, Serialize}; use smith::error::{ProviderFault, Result, SmithError}; use smith::provider::{ProviderParams, ProviderRequest}; use std::collections::HashMap; /// Which adapter family serves a model. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] pub enum ProviderKind { /// OpenAI-compatible chat completions. OpenAiCompatible, /// Anthropic Messages. Anthropic, /// Google Gemini. Google, /// Plugin-provided. Plugin, } impl ProviderKind { /// Stable lowercase file name for this provider. #[must_use] pub const fn slug(self) -> &'static str { match self { Self::OpenAiCompatible => "openai", Self::Anthropic => "anthropic", Self::Google => "google", Self::Plugin => "plugin", } } /// Parse a provider from its slug. #[must_use] pub fn from_slug(slug: &str) -> Option { match slug { "openai" => Some(Self::OpenAiCompatible), "anthropic" => Some(Self::Anthropic), "google" => Some(Self::Google), "plugin" => Some(Self::Plugin), _ => None, } } } /// What a model explicitly supports, used to validate requests. #[expect( clippy::struct_excessive_bools, reason = "capabilities are independent advertised switches" )] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct ModelCapabilities { /// Context window in tokens. pub context_window: u64, /// Output limit in tokens. pub max_output_tokens: u64, /// Accepts image inputs. pub input_images: bool, /// Accepts tool definitions. pub tool_use: bool, /// Supports thinking output. pub thinking: bool, /// Supports strict or grammar-constrained tool input. pub strict_tool_input: bool, /// Supports provider-side prompt caching. pub cache_control: bool, } /// One catalog model entry. #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct ModelEntry { /// Model identifier used in requests. pub id: String, /// Adapter family. pub provider: ProviderKind, /// Advertised capabilities. pub capabilities: ModelCapabilities, } /// A reviewed offline catalog plus its resolution tables. #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct Catalog { /// Model entries by id. pub models: HashMap, /// Alias to model id or group name. pub aliases: HashMap, /// Group name to ordered member model ids. pub groups: HashMap>, } /// The reviewed offline catalog, checked in as data. const BUILTIN: &str = include_str!("catalog.json"); impl Catalog { /// Load the built-in reviewed catalog, validating all references. /// /// # Errors /// /// Returns a [`SmithError::Config`] naming the offending field path when /// the data references a missing model, alias, or group, or contains a /// direct alias cycle. pub fn builtin() -> Result { Self::from_json(BUILTIN) } /// Parse and validate catalog JSON. /// /// # Errors /// /// Same failures as [`Catalog::builtin`]. pub fn from_json(json: &str) -> Result { let catalog: Self = serde_json::from_str(json).map_err(|e| invalid("catalog", &e.to_string()))?; // Deterministic diagnostics: validation walks aliases in sorted order. let mut alias_names: Vec<&String> = catalog.aliases.keys().collect(); alias_names.sort(); for alias in &alias_names { let target = &catalog.aliases[*alias]; if !catalog.models.contains_key(target) && !catalog.groups.contains_key(target) && !catalog.aliases.contains_key(target) { return Err(invalid( &format!("catalog.aliases.{alias}"), &format!("target '{target}' is neither a model nor a group"), )); } } for (group, members) in &catalog.groups { for (position, member) in members.iter().enumerate() { if !catalog.models.contains_key(member) { return Err(invalid( &format!("catalog.groups.{group}[{position}]"), &format!("member '{member}' is not a model"), )); } } } for alias in &alias_names { catalog.resolve_alias(alias)?; } Ok(catalog) } fn resolve_alias(&self, alias: &str) -> Result { let mut chain = vec![alias.to_string()]; let mut current = alias; while let Some(next) = self.aliases.get(current) { if chain.iter().any(|step| step == next) { chain.push(next.clone()); return Err(invalid( &format!("catalog.aliases.{alias}"), &format!("alias cycle: {}", chain.join(" -> ")), )); } chain.push(next.clone()); current = next; } Ok(current.to_string()) } /// Resolve a request target into an ordered failover plan. /// /// A model id resolves to itself; an alias resolves to its referent; a /// group resolves to its members in declared order. Resolution performs /// no network access. /// /// # Errors /// /// Returns a [`SmithError::Config`] naming the target when it is unknown /// or cyclic. pub fn resolve_plan(&self, requested: &str) -> Result> { let resolved = self.resolve_alias(requested)?; let ids: Vec<&String> = self .groups .get(&resolved) .map_or_else(|| vec![&resolved], |members| members.iter().collect()); ids.into_iter() .map(|id| { self.models.get(id).ok_or_else(|| { invalid( &format!("catalog.resolve.{requested}"), &format!("'{id}' is not a model"), ) }) }) .collect() } /// Validate that a request uses only capabilities the model advertises. /// /// # Errors /// /// Returns a [`SmithError::Provider`] with fault /// [`ProviderFault::Invalid`] naming the offending request field when the /// model does not advertise what the request asks for. pub fn validate_request(&self, model: &str, request: &ProviderRequest) -> Result<()> { let entry = self.resolve_plan(model)?.first().copied().ok_or_else(|| { invalid(&format!("catalog.resolve.{model}"), "empty resolution plan") })?; let caps = &entry.capabilities; if !request.tools.is_empty() && !caps.tool_use { return Err(invalid_request( "tools", &format!("{} does not advertise tool use", entry.id), )); } let ProviderParams { max_output_tokens, temperature, thinking, } = request.params; if let Some(requested) = max_output_tokens && requested > caps.max_output_tokens { return Err(invalid_request( "params.max_output_tokens", &format!( "{requested} exceeds the advertised limit of {}", caps.max_output_tokens ), )); } if let Some(requested) = temperature && requested > 2 { return Err(invalid_request( "params.temperature", "temperature above 2 is not a valid sampling value", )); } if thinking && !caps.thinking { return Err(invalid_request( "params.thinking", &format!("{} does not advertise thinking", entry.id), )); } Ok(()) } } fn invalid(field: &str, message: &str) -> SmithError { SmithError::Config { field: field.to_string(), message: message.to_string(), } } fn invalid_request(field: &str, message: &str) -> SmithError { SmithError::Provider { fault: ProviderFault::Invalid { field: Some(field.to_string()), message: message.to_string(), }, } } #[cfg(test)] mod tests { use super::*; use smith::message::{Message, Role}; use smith::provider::ProviderParams; use smith::tool::ToolMetadata; const TEST_CATALOG: &str = r#"{ "models": { "mock-smith": { "id": "mock-smith", "provider": "OpenAiCompatible", "capabilities": { "context_window": 8192, "max_output_tokens": 1024, "input_images": false, "tool_use": true, "thinking": false, "strict_tool_input": false, "cache_control": false } }, "thinker": { "id": "thinker", "provider": "Anthropic", "capabilities": { "context_window": 200000, "max_output_tokens": 8192, "input_images": true, "tool_use": true, "thinking": true, "strict_tool_input": false, "cache_control": true } }, "no-tools": { "id": "no-tools", "provider": "Google", "capabilities": { "context_window": 8192, "max_output_tokens": 1024, "input_images": false, "tool_use": false, "thinking": false, "strict_tool_input": false, "cache_control": false } } }, "aliases": { "smart": "thinker" }, "groups": { "default": ["thinker", "mock-smith"] } }"#; #[expect( clippy::unwrap_used, reason = "catalog fixtures are valid by construction" )] fn catalog() -> Catalog { Catalog::from_json(TEST_CATALOG).unwrap() } #[test] fn builtin_catalog_loads_and_resolves() { let catalog = Catalog::builtin().unwrap(); let _plan = catalog.resolve_plan("default"); // Every builtin id resolves to itself. for id in catalog.models.keys() { assert!(catalog.resolve_plan(id).is_ok(), "id {id}"); } } #[test] fn alias_resolves_to_model_and_group_expands_in_order() { let catalog = catalog(); let plan = catalog.resolve_plan("smart").unwrap(); assert_eq!(plan.len(), 1); assert_eq!(plan[0].id, "thinker"); let plan = catalog.resolve_plan("default").unwrap(); assert_eq!( plan.iter() .map(|entry| entry.id.as_str()) .collect::>(), vec!["thinker", "mock-smith"] ); } #[test] fn unknown_targets_and_cycles_fail_with_field_paths() { let catalog = catalog(); let err = catalog.resolve_plan("nope").unwrap_err(); assert_eq!(err.code(), "CONFIG"); let cyclic = r#"{ "models": {}, "aliases": { "a": "b", "b": "a" }, "groups": {} }"#; let err = Catalog::from_json(cyclic).unwrap_err(); assert_eq!( err.to_string(), "config [catalog.aliases.a]: alias cycle: a -> b -> a" ); let dangling = r#"{ "models": {}, "aliases": { "smart": "ghost" }, "groups": {} }"#; let err = Catalog::from_json(dangling).unwrap_err(); assert!(err.to_string().contains("catalog.aliases.smart")); } #[test] fn requests_cannot_use_unadvertised_capabilities() { let catalog = catalog(); let tools = vec![ToolMetadata::new("read", "Read a file")]; let mut request = ProviderRequest::new("mock-smith", vec![Message::with_text(Role::User, "hi")]) .with_tools(tools); // mock-smith advertises tools: allowed. assert!(catalog.validate_request("mock-smith", &request).is_ok()); let tools = vec![ToolMetadata::new("read", "Read a file")]; let _ = tools; // Thinking not advertised: rejected with the field path. request.params = ProviderParams { thinking: true, ..ProviderParams::default() }; let err = catalog .validate_request("mock-smith", &request) .unwrap_err(); assert_eq!(err.code(), "PROVIDER_INVALID"); assert!(err.to_string().contains("params.thinking")); // Output limit exceeded: rejected. request.params = ProviderParams { max_output_tokens: Some(999_999), thinking: false, ..ProviderParams::default() }; assert!(catalog.validate_request("mock-smith", &request).is_err()); // thinker advertises thinking but not an unlimited budget: also rejected. assert!(catalog.validate_request("thinker", &request).is_err()); request.params = ProviderParams { max_output_tokens: Some(4096), thinking: true, ..ProviderParams::default() }; assert!(catalog.validate_request("thinker", &request).is_ok()); } #[test] fn provider_slugs_round_trip() { for (kind, slug) in [ (ProviderKind::OpenAiCompatible, "openai"), (ProviderKind::Anthropic, "anthropic"), (ProviderKind::Google, "google"), (ProviderKind::Plugin, "plugin"), ] { assert_eq!(kind.slug(), slug); assert_eq!(ProviderKind::from_slug(slug), Some(kind)); } assert_eq!(ProviderKind::from_slug("xyzzy"), None); } #[test] fn validation_holds_exactly_at_each_limit() { let catalog = catalog(); let params = |max_output_tokens, temperature| ProviderParams { max_output_tokens, temperature, thinking: false, }; let none = ProviderParams::default(); // (model, with tools, params, rejected field) let cases = [ ("mock-smith", false, params(Some(1024), None), None), ( "mock-smith", false, params(Some(1025), None), Some("params.max_output_tokens"), ), ("mock-smith", false, params(None, Some(0)), None), ("mock-smith", false, params(None, Some(2)), None), ( "mock-smith", false, params(None, Some(3)), Some("params.temperature"), ), ("no-tools", false, none, None), ("no-tools", true, none, Some("tools")), ]; for (model, with_tools, params, rejected) in cases { let mut request = ProviderRequest::new(model, vec![Message::with_text(Role::User, "hi")]); if with_tools { request = request.with_tools(vec![ToolMetadata::new("read", "Read a file")]); } request.params = params; let field = match catalog.validate_request(model, &request) { Ok(()) => None, Err(SmithError::Provider { fault: ProviderFault::Invalid { field, .. }, }) => field, Err(other) => Some(other.to_string()), }; assert_eq!( field.as_deref(), rejected, "{model} tools={with_tools} {params:?}" ); } } }