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);
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user