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.
This commit is contained in:
YeonGyu-Kim
2026-04-09 21:39:52 +09:00
parent de916152cb
commit fd7aade5b5

View File

@@ -255,6 +255,19 @@ impl OpenAiCompatClient {
static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0); static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Returns a random additive jitter in `[0, base]` to decorrelate retries /// Returns a random additive jitter in `[0, base]` to decorrelate retries
/// Deserialize a JSON field as a `Vec<T>`, 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<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
}
/// from multiple concurrent clients. Entropy is drawn from the nanosecond /// from multiple concurrent clients. Entropy is drawn from the nanosecond
/// wall clock mixed with a monotonic counter and run through a splitmix64 /// wall clock mixed with a monotonic counter and run through a splitmix64
/// finalizer; adequate for retry jitter (no cryptographic requirement). /// finalizer; adequate for retry jitter (no cryptographic requirement).
@@ -673,7 +686,7 @@ struct ChunkChoice {
struct ChunkDelta { struct ChunkDelta {
#[serde(default)] #[serde(default)]
content: Option<String>, content: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>, tool_calls: Vec<DeltaToolCall>,
} }
@@ -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<String>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<super::DeltaToolCall>,
}
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] #[test]
fn non_gpt5_uses_max_tokens() { fn non_gpt5_uses_max_tokens() {
// Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected. // Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.