Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions lib/llm/src/model_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,47 @@ fn extract_hf_special_tokens(hf: &HfTokenizer) -> Vec<String> {
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<Value>` 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<Option<serde_json::Value>, 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<serde_json::Value>,
text_config: Option<ArchMaxContextTextConfig>,
}

#[derive(Deserialize)]
struct ArchMaxContextTextConfig {
#[serde(default, deserialize_with = "deserialize_present_json_value")]
max_position_embeddings: Option<serde_json::Value>,
}

/// Resolve the static architectural context limit from local HF metadata.
///
/// `config.json` is authoritative when it declares `max_position_embeddings`;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Value> 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(())
}

Expand Down
Loading