diff --git a/crates/tinyinference/src/providers/openai/responses.rs b/crates/tinyinference/src/providers/openai/responses.rs index 782c394..8492997 100644 --- a/crates/tinyinference/src/providers/openai/responses.rs +++ b/crates/tinyinference/src/providers/openai/responses.rs @@ -187,7 +187,7 @@ pub(super) struct ResponsesContentPart { #[derive(Debug, Deserialize)] pub(super) struct ResponsesResponse { - #[serde(default)] + #[serde(default, deserialize_with = "super::types::deserialize_null_as_empty")] pub(super) output: Vec, #[serde(default)] pub(super) output_text: Option, @@ -200,10 +200,10 @@ pub(super) struct ResponsesOutput { /// Item kind: `message`, `reasoning`, `function_call`, … #[serde(rename = "type", default)] pub(super) kind: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::types::deserialize_null_as_empty")] pub(super) content: Vec, /// Reasoning summary parts, on a `reasoning` item. - #[serde(default)] + #[serde(default, deserialize_with = "super::types::deserialize_null_as_empty")] pub(super) summary: Vec, /// The opaque reasoning payload that survives `store: false`. /// diff --git a/crates/tinyinference/src/providers/openai/test.rs b/crates/tinyinference/src/providers/openai/test.rs index 921efeb..a8772f5 100644 --- a/crates/tinyinference/src/providers/openai/test.rs +++ b/crates/tinyinference/src/providers/openai/test.rs @@ -2222,3 +2222,52 @@ fn degrade_for_400_unions_with_existing_baseline_degrade() { }) ); } + +#[test] +fn a_null_tool_calls_array_is_read_as_no_tool_calls() { + // Mistral-family endpoints spell "the model did not call a tool" as an + // explicit `null` rather than by omitting the key. `#[serde(default)]` + // covers only the omission, so this body used to fail the whole decode + // with `invalid type: null, expected a sequence` — a plain prose answer + // surfacing as a transport fault. + let body = json!({ + "id": "chatcmpl-null", + "choices": [ + { + "index": 0, + "message": { "role": "assistant", "content": "Hi!", "tool_calls": null }, + "finish_reason": "stop" + } + ] + }); + + let response = parse_response(body).expect("a null tool_calls must not fail the call"); + assert_eq!(response.text(), "Hi!"); + assert!(response.tool_calls().is_empty()); +} + +#[test] +fn a_null_choices_array_is_read_as_no_choices() { + // The same spelling, one level up. What matters is *which* error comes + // back: "no choices" is a provider fact the caller can act on, where a + // serde failure would name the transport for a body it read perfectly. + let error = parse_response(json!({ "id": "chatcmpl-null", "choices": null })) + .expect_err("an empty candidate list is still an error"); + assert!( + matches!(error, crate::Error::Model(ref message) if message.contains("no choices")), + "expected a model error naming the empty candidate list, got {error:?}" + ); +} + +#[test] +fn a_null_tool_calls_delta_is_read_as_no_fragments() { + // And on the streaming path, where the same providers repeat it on every + // chunk. + let chunk: ChatCompletionChunk = serde_json::from_value(json!({ + "id": "chatcmpl-null", + "choices": [{ "index": 0, "delta": { "content": "Hi", "tool_calls": null } }] + })) + .expect("a null tool_calls must not fail the chunk"); + assert!(chunk.choices[0].delta.tool_calls.is_empty()); + assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("Hi")); +} diff --git a/crates/tinyinference/src/providers/openai/types.rs b/crates/tinyinference/src/providers/openai/types.rs index 9205241..606c8bb 100644 --- a/crates/tinyinference/src/providers/openai/types.rs +++ b/crates/tinyinference/src/providers/openai/types.rs @@ -82,7 +82,7 @@ pub struct ChatCompletionChunk { #[serde(default)] pub id: Option, /// Per-choice incremental deltas; the first choice is used. - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_null_as_empty")] pub choices: Vec, /// Cumulative usage, sent on the final chunk when `include_usage` is set. #[serde(default)] @@ -117,7 +117,7 @@ pub struct ChunkDeltaWire { #[serde(default)] pub reasoning: Option, /// Incremental tool-call fragments, correlated by `index`. - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_null_as_empty")] pub tool_calls: Vec, } @@ -155,6 +155,28 @@ pub struct FunctionChunkWire { pub arguments: Option, } +/// Deserializes a sequence that a provider may send as an explicit `null`. +/// +/// `#[serde(default)]` covers an **absent** key and nothing else: a key present +/// with the value `null` still reaches the `Vec` visitor and fails the whole +/// response with `invalid type: null, expected a sequence`. Several +/// OpenAI-compatible servers spell "no tool calls" that way — Mistral-family +/// endpoints send `"tool_calls": null` on every plain-text completion — so a +/// model that simply answered in prose looked like a transport fault, and a +/// role sitting on such a rung could not complete a single turn. +/// +/// Same intent as [`deserialize_arguments`]: one provider's unexpected spelling +/// of "nothing" must not fail the decode of everything around it. +pub(super) fn deserialize_null_as_empty<'de, D, T>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + /// Normalizes a tool-call `function.arguments` payload to the stringified-JSON /// form the provider expects. /// @@ -321,7 +343,7 @@ pub struct ChatCompletionResponse { #[serde(default)] pub id: Option, /// Candidate completions; the first is used. - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_null_as_empty")] pub choices: Vec, /// Token usage, when reported. #[serde(default)] @@ -352,7 +374,7 @@ pub struct ResponseMessageWire { #[serde(default)] pub reasoning: Option, /// Tool calls requested by the model. - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_null_as_empty")] pub tool_calls: Vec, } @@ -423,6 +445,6 @@ pub struct ModelListing { #[derive(Clone, Debug, Default, Deserialize)] pub struct ModelListWire { /// The advertised models. - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_null_as_empty")] pub data: Vec, }