//! Resilient parsing for `[model.]` TOML overrides. //! //! A model entry must survive a bad field: warn and skip the field, never //! drop the model (managed configs must not lose catalog entries). //! //! Every table is deserialized through `serde_ignored`, so unknown fields //! warn on every path and [`ConfigModelOverride`] stays the single source of //! truth for the field set. When the whole-table parse fails, fields that //! fail to parse on their own are pruned (one warning each) and the table is //! parsed again. Non-table values are dropped with a warning. //! //! Warnings are retained on `Config::model_override_warnings` and surfaced by //! `kigi inspect`. use indexmap::IndexMap; use serde::Serialize; use super::config::ConfigModelOverride; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "kebab-case")] pub enum ModelOverrideWarningKind { /// Field name not recognized; field ignored. UnknownField, /// Value failed to parse; field skipped. InvalidValue, /// Legacy alias given alongside its canonical key; alias skipped. DuplicateAlias, /// Entry value is not a TOML table; entry dropped. NotATable, /// Entry failed to parse even after skipping invalid fields; the model /// keeps an empty override. UnparseableEntry, } /// One skipped field or dropped entry from `[model.*]` parsing. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "camelCase")] pub struct ModelOverrideWarning { /// `None` when the warning is about the `[model]` section itself. #[serde(skip_serializing_if = "Option::is_none")] pub model_key: Option, /// `None` for warnings about the entry as a whole. #[serde(skip_serializing_if = "Option::is_none")] pub field: Option, pub kind: ModelOverrideWarningKind, pub reason: String, } pub(crate) struct ParsedModelOverrides { pub models: IndexMap, pub warnings: Vec, } /// Parses every `[model.]` entry in `raw_config`, returning the overrides /// and a warning for each skipped field or dropped entry. pub(crate) fn parse_model_overrides(raw_config: &toml::Value) -> ParsedModelOverrides { let mut models = IndexMap::new(); let mut warnings = Vec::new(); let Some(section) = raw_config.get("model") else { return ParsedModelOverrides { models, warnings }; }; let Some(table) = section.as_table() else { warnings.push(ModelOverrideWarning { model_key: None, field: None, kind: ModelOverrideWarningKind::NotATable, reason: format!( "`model` must be a table of [model.] entries, got {}; all model overrides ignored", section.type_str() ), }); return ParsedModelOverrides { models, warnings }; }; for (model_key, value) in table { let Some(entry_table) = value.as_table() else { warnings.push(ModelOverrideWarning { model_key: Some(model_key.clone()), field: None, kind: ModelOverrideWarningKind::NotATable, reason: format!( "expected a table like [model.\"{model_key}\"], got {}; entry dropped", value.type_str() ), }); continue; }; let (entry, entry_warnings) = parse_model_override_table(model_key, entry_table.clone()); warnings.extend(entry_warnings); models.insert(model_key.clone(), entry); } ParsedModelOverrides { models, warnings } } /// Logs the warnings when they differ from the previous parse, so a /// persistently broken config logs once per process instead of once per parse. pub(crate) fn log_model_override_warnings(warnings: &[ModelOverrideWarning]) { use std::hash::{Hash as _, Hasher as _}; use std::sync::atomic::{AtomicU64, Ordering}; static LAST_LOGGED: AtomicU64 = AtomicU64::new(0); // 0 means "no warnings"; real hashes are clamped to nonzero. let hash = if warnings.is_empty() { 0 } else { let mut hasher = std::collections::hash_map::DefaultHasher::new(); warnings.hash(&mut hasher); hasher.finish().max(1) }; if LAST_LOGGED.swap(hash, Ordering::Relaxed) == hash { return; } for warning in warnings { tracing::warn!( model = warning.model_key.as_deref().unwrap_or("(section)"), field = warning.field.as_deref().unwrap_or("(entry)"), kind = ?warning.kind, reason = %warning.reason, "model_override: skipped invalid config" ); } if !warnings.is_empty() { tracing::warn!( warnings = warnings.len(), "model_override: parsed with warnings; run `kigi inspect` for details" ); } } fn parse_model_override_table( model_key: &str, mut table: toml::map::Map, ) -> (ConfigModelOverride, Vec) { let mut warnings = Vec::new(); dedupe_aliases(model_key, &mut table, &mut warnings); // Unknown-field warnings come from whichever parse produces the returned // entry, so both paths report them identically. match deserialize_with_unknown_fields(table.clone()) { Ok((entry, unknown)) => { warnings.extend(unknown_field_warnings(model_key, unknown)); (entry, warnings) } Err(_) => { prune_invalid_fields(model_key, &mut table, &mut warnings); match deserialize_with_unknown_fields(table) { Ok((entry, unknown)) => { warnings.extend(unknown_field_warnings(model_key, unknown)); (entry, warnings) } Err(error) => { // Reachable only when fields conflict jointly, e.g. an // alias pair missing from `ALIASES`. Keep the model // rather than dropping it. warnings.push(ModelOverrideWarning { model_key: Some(model_key.to_owned()), field: None, kind: ModelOverrideWarningKind::UnparseableEntry, reason: format!( "failed to parse after skipping invalid fields ({error}); using empty override" ), }); (ConfigModelOverride::default(), warnings) } } } } } /// `(canonical, legacy)` key pairs that serde rejects as duplicate fields /// when both appear in one table. Keep in sync with the `#[serde(alias)]` /// attributes on [`ConfigModelOverride`]. const ALIASES: &[(&str, &str)] = &[("compactions_remaining", "send_compactions_remaining")]; /// Removes one key of each [`ALIASES`] pair that appears twice in `table`. /// The canonical key wins; when its value doesn't parse, the legacy key is /// kept instead. fn dedupe_aliases( model_key: &str, table: &mut toml::map::Map, warnings: &mut Vec, ) { for &(canonical, legacy) in ALIASES { if !(table.contains_key(canonical) && table.contains_key(legacy)) { continue; } match field_parse_error(canonical, &table[canonical]) { None => { table.remove(legacy); warnings.push(ModelOverrideWarning { model_key: Some(model_key.to_owned()), field: Some(legacy.to_owned()), kind: ModelOverrideWarningKind::DuplicateAlias, reason: format!("legacy alias of {canonical}; skipped in favor of {canonical}"), }); } Some(error) => { table.remove(canonical); warnings.push(ModelOverrideWarning { model_key: Some(model_key.to_owned()), field: Some(canonical.to_owned()), kind: ModelOverrideWarningKind::InvalidValue, reason: format!("{error}; skipped in favor of {legacy}"), }); } } } } /// Deserializes `table`, also returning the unknown field names that serde /// would otherwise silently discard. fn deserialize_with_unknown_fields( table: toml::map::Map, ) -> Result<(ConfigModelOverride, Vec), toml::de::Error> { let mut unknown = Vec::new(); let entry = serde_ignored::deserialize(toml::Value::Table(table), |path| { unknown.push(path.to_string()); })?; Ok((entry, unknown)) } fn unknown_field_warnings(model_key: &str, unknown: Vec) -> Vec { unknown .into_iter() .map(|field| ModelOverrideWarning { model_key: Some(model_key.to_owned()), field: Some(field), kind: ModelOverrideWarningKind::UnknownField, reason: "unknown field".to_owned(), }) .collect() } /// Removes each field that fails to parse on its own, one warning per field. /// Unknown fields stay; the follow-up parse reports them. fn prune_invalid_fields( model_key: &str, table: &mut toml::map::Map, warnings: &mut Vec, ) { table.retain(|field, value| match field_parse_error(field, value) { None => true, Some(error) => { warnings.push(ModelOverrideWarning { model_key: Some(model_key.to_owned()), field: Some(field.to_owned()), kind: ModelOverrideWarningKind::InvalidValue, reason: error.to_string(), }); false } }); } /// Parses `field` in isolation, returning the error if it fails. fn field_parse_error(field: &str, value: &toml::Value) -> Option { let mut singleton = toml::map::Map::new(); singleton.insert(field.to_owned(), value.clone()); toml::Value::Table(singleton) .try_into::() .err() } #[cfg(test)] mod tests { use super::*; use crate::sampling::ApiBackend; use kigi_sampling_types::{ CompactionAtTokens, CompactionsRemaining, ReasoningEffort, ReasoningEffortOption, }; fn parse_cfg(toml_str: &str) -> crate::agent::config::Config { let raw: toml::Value = toml::from_str(toml_str).unwrap(); crate::agent::config::Config::new_from_toml_cfg(&raw).expect("config should parse") } fn parse_raw( toml_str: &str, ) -> ( IndexMap, Vec, ) { let raw: toml::Value = toml::from_str(toml_str).unwrap(); let ParsedModelOverrides { models, warnings } = parse_model_overrides(&raw); (models, warnings) } #[test] fn duplicate_compactions_keys_keeps_model() { let cfg = parse_cfg( r#" [model."kigi-4.5"] model = "kigi-4.5" env_key = "ANTHROPIC_AUTH_TOKEN" compactions_remaining = 1 send_compactions_remaining = true "#, ); let model = cfg .config_models .get("kigi-4.5") .expect("kigi-4.5 must remain in catalog"); assert_eq!( model.compactions_remaining, Some(CompactionsRemaining::Fixed(1)) ); assert!(cfg.model_override_warnings.iter().any(|w| { w.kind == ModelOverrideWarningKind::DuplicateAlias && w.field.as_deref() == Some("send_compactions_remaining") })); let resolved = crate::agent::config::resolve_model_list(&cfg, None, &Default::default()); assert!(resolved.contains_key("kigi-4.5")); } #[test] fn legacy_alias_alone_parses_without_warning() { let cfg = parse_cfg( r#" [model."kigi-4.5"] model = "kigi-4.5" send_compactions_remaining = 2 "#, ); let model = cfg.config_models.get("kigi-4.5").unwrap(); assert_eq!( model.compactions_remaining, Some(CompactionsRemaining::Fixed(2)) ); assert!(cfg.model_override_warnings.is_empty()); } #[test] fn invalid_reasoning_effort_skips_field_keeps_model() { let cfg = parse_cfg( r#" [model."kigi-4.5"] model = "kigi-4.5" env_key = "ANTHROPIC_AUTH_TOKEN" reasoning_effort = "not-a-level" "#, ); let model = cfg .config_models .get("kigi-4.5") .expect("kigi-4.5 must remain in catalog"); assert_eq!(model.model.as_deref(), Some("kigi-4.5")); assert!(model.reasoning_effort.is_none()); assert!(cfg.model_override_warnings.iter().any(|w| { w.kind == ModelOverrideWarningKind::InvalidValue && w.field.as_deref() == Some("reasoning_effort") })); } #[test] fn unknown_field_warns_but_keeps_known_fields() { let (models, warnings) = parse_raw( r#" [model."kigi-4.5"] model = "kigi-4.5" env_key = "TOKEN" future_field = 1 "#, ); let entry = models.get("kigi-4.5").unwrap(); assert_eq!(entry.model.as_deref(), Some("kigi-4.5")); assert_eq!( entry.env_key.as_ref().and_then(|k| k.primary()), Some("TOKEN") ); assert_eq!( warnings, vec![ModelOverrideWarning { model_key: Some("kigi-4.5".to_owned()), field: Some("future_field".to_owned()), kind: ModelOverrideWarningKind::UnknownField, reason: "unknown field".to_owned(), }] ); } /// An unknown field warns the same whether or not another field fails to /// parse. #[test] fn unknown_field_warning_is_path_independent() { let unknown_of = |toml_str: &str| { let (_, warnings) = parse_raw(toml_str); warnings .into_iter() .filter(|w| w.kind == ModelOverrideWarningKind::UnknownField) .collect::>() }; let fast = unknown_of( r#" [model.m] temprature = 0.5 "#, ); let slow = unknown_of( r#" [model.m] temprature = 0.5 reasoning_effort = "not-a-level" "#, ); assert_eq!(fast, slow); assert_eq!(fast.len(), 1); assert_eq!(fast[0].field.as_deref(), Some("temprature")); } #[test] fn prune_skips_invalid_fields_and_keeps_the_rest() { // A valid nested table survives an invalid sibling. let (models, warnings) = parse_raw( r#" [model.m] temperature = "hot" [model.m.extra_headers] x-team = "codegen" "#, ); let entry = models.get("m").unwrap(); assert_eq!( entry.extra_headers.get("x-team").map(String::as_str), Some("codegen") ); assert!(entry.temperature.is_none()); assert!(warnings.iter().any(|w| { w.kind == ModelOverrideWarningKind::InvalidValue && w.field.as_deref() == Some("temperature") })); // All fields invalid: the model stays, with an empty override. let (models, warnings) = parse_raw( r#" [model.m] temperature = "hot" max_retries = "many" "#, ); let entry = models.get("m").expect("model must remain in catalog"); assert!(entry.temperature.is_none()); assert!(entry.max_retries.is_none()); assert_eq!(warnings.len(), 2); assert!( warnings .iter() .all(|w| w.kind == ModelOverrideWarningKind::InvalidValue) ); } #[test] fn invalid_canonical_key_falls_back_to_legacy_alias() { let (models, warnings) = parse_raw( r#" [model.m] compactions_remaining = "bad" send_compactions_remaining = 2 "#, ); let entry = models.get("m").unwrap(); assert_eq!( entry.compactions_remaining, Some(CompactionsRemaining::Fixed(2)) ); assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].kind, ModelOverrideWarningKind::InvalidValue); assert_eq!(warnings[0].field.as_deref(), Some("compactions_remaining")); } #[test] fn non_table_model_section_warns_and_is_ignored() { let (models, warnings) = parse_raw(r#"model = "kigi-4""#); assert!(models.is_empty()); assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].kind, ModelOverrideWarningKind::NotATable); assert_eq!(warnings[0].model_key, None); assert_eq!(warnings[0].field, None); } #[test] fn non_table_entry_is_dropped_with_warning() { let (models, warnings) = parse_raw( r#" [model] oops = 5 "#, ); assert!(models.is_empty(), "a scalar cannot define a model"); assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].kind, ModelOverrideWarningKind::NotATable); assert_eq!(warnings[0].model_key.as_deref(), Some("oops")); assert_eq!(warnings[0].field, None); } /// Exhaustive literal (no `..`): a new struct field is a compile error /// here until the drift-guard tests cover it. fn fully_populated_override() -> ConfigModelOverride { ConfigModelOverride { model: Some("m".into()), base_url: Some("https://example.com".into()), name: Some("Model M".into()), description: Some("desc".into()), api_key: Some("key".into()), env_key: Some(crate::agent::config::EnvKeys::single("ENV_KEY")), api_base_url: Some("https://api.example.com".into()), max_completion_tokens: Some(1024), temperature: Some(0.5), top_p: Some(0.9), api_backend: Some(ApiBackend::Messages), extra_headers: [("x-team".to_owned(), "codegen".to_owned())] .into_iter() .collect(), context_window: Some(200_000), auto_compact_threshold_percent: Some(80), system_prompt_label: Some("label".into()), use_concise: Some(true), agent_type: Some("agent".into()), inference_idle_timeout_secs: Some(60), max_retries: Some(3), hidden: Some(false), supported_in_api: Some(true), reasoning_effort: Some(ReasoningEffort::High), supports_reasoning_effort: Some(true), reasoning_efforts: vec![ReasoningEffortOption { id: "deep".to_string(), value: ReasoningEffort::High, label: "Deep".to_string(), description: Some("Deep reasoning".to_string()), default: true, }], capabilities: vec![kigi_models::ModelCapability::Thinking], supports_backend_search: Some(false), compactions_remaining: Some(CompactionsRemaining::Fixed(1)), compaction_at_tokens: Some(CompactionAtTokens::Fixed(100_000)), show_model_fingerprint: Some(true), stream_tool_calls: Some(false), } } fn parse_single_entry( entry: toml::map::Map, ) -> ( IndexMap, Vec, ) { let mut model_table = toml::map::Map::new(); model_table.insert("m".to_owned(), toml::Value::Table(entry)); let mut root = toml::map::Map::new(); root.insert("model".to_owned(), toml::Value::Table(model_table)); let ParsedModelOverrides { models, warnings } = parse_model_overrides(&toml::Value::Table(root)); (models, warnings) } #[test] fn fully_populated_override_round_trips_without_warnings() { let serialized = toml::Value::try_from(fully_populated_override()).unwrap(); let (models, warnings) = parse_single_entry(serialized.as_table().unwrap().clone()); assert_eq!(warnings, Vec::new(), "no field may be skipped or unknown"); let reparsed = toml::Value::try_from(models.get("m").unwrap()).unwrap(); assert_eq!(reparsed, serialized, "round-trip must be lossless"); } /// Drift guard: every `#[serde(alias)]` on [`ConfigModelOverride`] must /// have a matching `ALIASES` pair, and vice versa. An unregistered alias /// would send both-keys configs to the empty-override fallback. #[test] fn every_struct_alias_is_registered_in_aliases() { let source = include_str!("config.rs"); let start = source .find("pub struct ConfigModelOverride {") .expect("ConfigModelOverride definition in config.rs"); let block = &source[start..]; let block = &block[..block.find("\n}").expect("struct end")]; let mut found = Vec::new(); let mut rest = block; while let Some(pos) = rest.find("#[serde(alias = \"") { let after = &rest[pos + "#[serde(alias = \"".len()..]; let legacy = &after[..after.find('"').expect("closing quote")]; let field = &after[after.find("pub ").expect("field after alias") + 4..]; let canonical = &field[..field.find(':').expect("field type colon")]; found.push((canonical.to_owned(), legacy.to_owned())); rest = after; } assert_eq!( block.matches("alias").count(), found.len(), "an alias on ConfigModelOverride was not recognized; write it as \ `#[serde(alias = \"...\")]` on its own line, or update this scan" ); found.sort(); let mut registered: Vec<(String, String)> = ALIASES .iter() .map(|&(c, l)| (c.to_owned(), l.to_owned())) .collect(); registered.sort(); assert_eq!( found, registered, "#[serde(alias)] attributes on ConfigModelOverride and ALIASES must match" ); } /// Drift guard for `ALIASES`, in both directions: every pair must be a /// real serde alias (a both-keys table fails a plain parse), and the /// parser must resolve it to the canonical key with a single warning. #[test] fn every_aliases_pair_is_a_real_serde_alias_and_dedupes() { let reference = toml::Value::try_from(fully_populated_override()).unwrap(); for &(canonical, legacy) in ALIASES { let value = reference .get(canonical) .unwrap_or_else(|| panic!("{canonical} missing from fully_populated_override")); let mut entry = toml::map::Map::new(); entry.insert(canonical.to_owned(), value.clone()); entry.insert(legacy.to_owned(), value.clone()); assert!( toml::Value::Table(entry.clone()) .try_into::() .is_err(), "{canonical}/{legacy} is not a serde alias pair; remove it from ALIASES" ); let (models, warnings) = parse_single_entry(entry); let parsed = toml::Value::try_from(models.get("m").unwrap()).unwrap(); assert_eq!( parsed.get(canonical), Some(value), "canonical value must be retained" ); assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].kind, ModelOverrideWarningKind::DuplicateAlias); assert_eq!(warnings[0].field.as_deref(), Some(legacy)); } } }