Skip to content

Commit fd7aade

Browse files
committed
fix(api): tolerate null tool_calls in OpenAI-compat stream delta chunks
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.
1 parent de91615 commit fd7aade

1 file changed

Lines changed: 43 additions & 1 deletion

File tree

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,19 @@ impl OpenAiCompatClient {
255255
static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
256256

257257
/// Returns a random additive jitter in `[0, base]` to decorrelate retries
258+
/// Deserialize a JSON field as a `Vec<T>`, treating an explicit `null` value
259+
/// the same as a missing field (i.e. as an empty vector).
260+
/// Some OpenAI-compatible providers emit `"tool_calls": null` instead of
261+
/// omitting the field or using `[]`, which serde's `#[serde(default)]` alone
262+
/// does not tolerate — `default` only handles absent keys, not null values.
263+
fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
264+
where
265+
D: serde::Deserializer<'de>,
266+
T: serde::Deserialize<'de>,
267+
{
268+
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
269+
}
270+
258271
/// from multiple concurrent clients. Entropy is drawn from the nanosecond
259272
/// wall clock mixed with a monotonic counter and run through a splitmix64
260273
/// finalizer; adequate for retry jitter (no cryptographic requirement).
@@ -673,7 +686,7 @@ struct ChunkChoice {
673686
struct ChunkDelta {
674687
#[serde(default)]
675688
content: Option<String>,
676-
#[serde(default)]
689+
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
677690
tool_calls: Vec<DeltaToolCall>,
678691
}
679692

@@ -1484,6 +1497,35 @@ mod tests {
14841497
);
14851498
}
14861499

1500+
/// Regression test: some OpenAI-compatible providers emit `"tool_calls": null`
1501+
/// in stream delta chunks instead of omitting the field or using `[]`.
1502+
/// Before the fix this produced: `invalid type: null, expected a sequence`.
1503+
#[test]
1504+
fn delta_with_null_tool_calls_deserializes_as_empty_vec() {
1505+
// Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
1506+
let json = r#"{
1507+
"content": "",
1508+
"function_call": null,
1509+
"refusal": null,
1510+
"role": "assistant",
1511+
"tool_calls": null
1512+
}"#;
1513+
1514+
use super::deserialize_null_as_empty_vec;
1515+
#[derive(serde::Deserialize, Debug)]
1516+
struct Delta {
1517+
content: Option<String>,
1518+
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
1519+
tool_calls: Vec<super::DeltaToolCall>,
1520+
}
1521+
let delta: Delta = serde_json::from_str(json)
1522+
.expect("delta with tool_calls:null must deserialize without error");
1523+
assert!(
1524+
delta.tool_calls.is_empty(),
1525+
"tool_calls:null must produce an empty vec, not an error"
1526+
);
1527+
}
1528+
14871529
#[test]
14881530
fn non_gpt5_uses_max_tokens() {
14891531
// Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.

0 commit comments

Comments
 (0)