mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-10 01:54:49 +08:00
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:
@@ -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<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
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
||||
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]
|
||||
fn non_gpt5_uses_max_tokens() {
|
||||
// Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.
|
||||
|
||||
Reference in New Issue
Block a user