diff --git a/lib/llm/src/model_card.rs b/lib/llm/src/model_card.rs index a69e6a9ce71a..39e93edbb627 100644 --- a/lib/llm/src/model_card.rs +++ b/lib/llm/src/model_card.rs @@ -50,6 +50,47 @@ fn extract_hf_special_tokens(hf: &HfTokenizer) -> Vec { out } +/// serde `deserialize_with` that maps an explicitly-present value -- *including +/// an explicit JSON `null`* -- to `Some`. Paired with `#[serde(default)]` (which +/// supplies `None` only when the key is absent), this distinguishes "field +/// missing" from "field present but null/invalid". A plain `Option` would +/// collapse an explicit `null` into `None`, letting a malformed present +/// `max_position_embeddings` silently fall through to the next source instead of +/// surfacing the documented deserialization error. +fn deserialize_present_json_value<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + serde_json::Value::deserialize(deserializer).map(Some) +} + +/// Minimal projection of `config.json`, holding only the fields consulted when +/// deriving the architectural context length. Every other entry is skipped by +/// serde. This matters because some HF configs (e.g. Nemotron-H) serialize +/// fields such as `time_step_limit` with non-finite literals (`Infinity`, +/// `NaN`) that are valid JSON5 but not strict JSON; deserializing the whole +/// document into `serde_json::Value` would reject them, whereas projecting into +/// this struct via the JSON5 parser never materializes those unread fields. +/// +/// The two consulted fields are captured as raw `serde_json::Value` (rather than +/// `u32`) so the original per-field error messages are preserved when a value is +/// present but not a valid integer. `deserialize_present_json_value` keeps an +/// explicit `null` distinguishable from an absent key (see its docs). +#[derive(Deserialize)] +struct ArchMaxContextConfig { + #[serde(default, deserialize_with = "deserialize_present_json_value")] + max_position_embeddings: Option, + text_config: Option, +} + +#[derive(Deserialize)] +struct ArchMaxContextTextConfig { + #[serde(default, deserialize_with = "deserialize_present_json_value")] + max_position_embeddings: Option, +} + /// Resolve the static architectural context limit from local HF metadata. /// /// `config.json` is authoritative when it declares `max_position_embeddings`; @@ -81,20 +122,24 @@ fn architectural_max_context_length_from_repo(local_path: &Path) -> anyhow::Resu let Some(config_json) = config_json else { return Ok(tokenizer_context_length().filter(|context_length| *context_length > 0)); }; - let config: serde_json::Value = serde_json::from_str(&config_json) + // Parse with the JSON5 parser (a lenient superset of JSON) into a minimal + // projection, mirroring `HFConfig::from_json_file`. This tolerates non-finite + // literals (`Infinity`, `NaN`) that HF configs may emit in fields we don't + // read, which strict `serde_json` would reject. + let config: ArchMaxContextConfig = json_five::from_str(&config_json) .with_context(|| format!("Failed to parse JSON from file: {}", config_path.display()))?; - let context_length = match config.get("max_position_embeddings") { + let context_length = match config.max_position_embeddings { Some(value) => Some( - serde_json::from_value(value.clone()) + serde_json::from_value(value) .context("Failed to deserialize max_position_embeddings")?, ), None => match config - .get("text_config") - .and_then(|text_config| text_config.get("max_position_embeddings")) + .text_config + .and_then(|text_config| text_config.max_position_embeddings) { Some(value) => Some( - serde_json::from_value(value.clone()) + serde_json::from_value(value) .context("Failed to deserialize text_config.max_position_embeddings")?, ), None => tokenizer_context_length(), @@ -2681,6 +2726,50 @@ mod ownership_tests { "{err:?}" ); + // An explicit null is a present-but-malformed value: it must error, not + // silently fall through to text_config/tokenizer (regression guard for + // the Option projection collapsing null into None). + std::fs::write( + dir.path().join("config.json"), + r#"{"max_position_embeddings": null}"#, + )?; + let err = architectural_max_context_length_from_repo(dir.path()).unwrap_err(); + assert!( + err.to_string() + .contains("Failed to deserialize max_position_embeddings"), + "{err:?}" + ); + + std::fs::write( + dir.path().join("config.json"), + r#"{"text_config": {"max_position_embeddings": null}}"#, + )?; + let err = architectural_max_context_length_from_repo(dir.path()).unwrap_err(); + assert!( + err.to_string() + .contains("Failed to deserialize text_config.max_position_embeddings"), + "{err:?}" + ); + + Ok(()) + } + + #[test] + fn architectural_context_tolerates_non_finite_literals() -> anyhow::Result<()> { + // Some HF configs (e.g. Nemotron-H) serialize fields such as + // `time_step_limit` with the bare literal `Infinity`, which is valid + // JSON5 but not strict JSON. Deriving max_position_embeddings must not + // choke on such literals in fields we never read. + let dir = tempfile::tempdir()?; + std::fs::write( + dir.path().join("config.json"), + r#"{"max_position_embeddings": 262144, "time_step_limit": [0.0, Infinity]}"#, + )?; + assert_eq!( + architectural_max_context_length_from_repo(dir.path())?, + Some(262144) + ); + Ok(()) }