From fd7aade5b53b32079660ef0d91e3e1ef6714b63e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 9 Apr 2026 21:39:52 +0900 Subject: [PATCH] fix(api): tolerate null tool_calls in OpenAI-compat stream delta chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some OpenAI-compatible providers emit 'tool_calls: null' in streaming delta chunks instead of omitting the field or using an empty array: "delta": {"content":"","function_call":null,"tool_calls":null} serde's #[serde(default)] only handles absent keys — an explicit null value still fails deserialization with: 'invalid type: null, expected a sequence' Fix: replace #[serde(default)] with a custom deserializer helper deserialize_null_as_empty_vec() that maps null -> Vec::default(), keeping the existing absent-key default behaviour. Regression test added: delta_with_null_tool_calls_deserializes_as_empty_vec uses the exact provider response shape from gaebal-gajae's repro (2026-04-09). 112 api lib tests pass. Fmt clean. Companion to gaebal-gajae's local 448cf2c — independently reproduced and landed on main. --- .../crates/api/src/providers/openai_compat.rs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) 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.