diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index c664365..7d4d3ec 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -255,6 +255,19 @@ impl OpenAiCompatClient { static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0); /// Returns a random additive jitter in `[0, base]` to decorrelate retries +/// Deserialize a JSON field as a `Vec`, treating an explicit `null` value +/// the same as a missing field (i.e. as an empty vector). +/// Some OpenAI-compatible providers emit `"tool_calls": null` instead of +/// omitting the field or using `[]`, which serde's `#[serde(default)]` alone +/// does not tolerate — `default` only handles absent keys, not null values. +fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + /// from multiple concurrent clients. Entropy is drawn from the nanosecond /// wall clock mixed with a monotonic counter and run through a splitmix64 /// finalizer; adequate for retry jitter (no cryptographic requirement). @@ -673,7 +686,7 @@ struct ChunkChoice { struct ChunkDelta { #[serde(default)] content: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] tool_calls: Vec, } @@ -1484,6 +1497,35 @@ mod tests { ); } + /// Regression test: some OpenAI-compatible providers emit `"tool_calls": null` + /// in stream delta chunks instead of omitting the field or using `[]`. + /// Before the fix this produced: `invalid type: null, expected a sequence`. + #[test] + fn delta_with_null_tool_calls_deserializes_as_empty_vec() { + // Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09) + let json = r#"{ + "content": "", + "function_call": null, + "refusal": null, + "role": "assistant", + "tool_calls": null + }"#; + + use super::deserialize_null_as_empty_vec; + #[derive(serde::Deserialize, Debug)] + struct Delta { + content: Option, + #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] + tool_calls: Vec, + } + let delta: Delta = serde_json::from_str(json) + .expect("delta with tool_calls:null must deserialize without error"); + assert!( + delta.tool_calls.is_empty(), + "tool_calls:null must produce an empty vec, not an error" + ); + } + #[test] fn non_gpt5_uses_max_tokens() { // Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.