Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/tinyinference/src/providers/openai/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResponsesOutput>,
#[serde(default)]
pub(super) output_text: Option<String>,
Expand All @@ -200,10 +200,10 @@ pub(super) struct ResponsesOutput {
/// Item kind: `message`, `reasoning`, `function_call`, …
#[serde(rename = "type", default)]
pub(super) kind: Option<String>,
#[serde(default)]
#[serde(default, deserialize_with = "super::types::deserialize_null_as_empty")]
pub(super) content: Vec<ResponsesContent>,
/// Reasoning summary parts, on a `reasoning` item.
#[serde(default)]
#[serde(default, deserialize_with = "super::types::deserialize_null_as_empty")]
pub(super) summary: Vec<ResponsesContent>,
/// The opaque reasoning payload that survives `store: false`.
///
Expand Down
49 changes: 49 additions & 0 deletions crates/tinyinference/src/providers/openai/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
32 changes: 27 additions & 5 deletions crates/tinyinference/src/providers/openai/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ pub struct ChatCompletionChunk {
#[serde(default)]
pub id: Option<String>,
/// Per-choice incremental deltas; the first choice is used.
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_as_empty")]
pub choices: Vec<ChunkChoiceWire>,
/// Cumulative usage, sent on the final chunk when `include_usage` is set.
#[serde(default)]
Expand Down Expand Up @@ -117,7 +117,7 @@ pub struct ChunkDeltaWire {
#[serde(default)]
pub reasoning: Option<Value>,
/// Incremental tool-call fragments, correlated by `index`.
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_as_empty")]
pub tool_calls: Vec<ToolCallChunkWire>,
}

Expand Down Expand Up @@ -155,6 +155,28 @@ pub struct FunctionChunkWire {
pub arguments: Option<String>,
}

/// 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<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
}

/// Normalizes a tool-call `function.arguments` payload to the stringified-JSON
/// form the provider expects.
///
Expand Down Expand Up @@ -321,7 +343,7 @@ pub struct ChatCompletionResponse {
#[serde(default)]
pub id: Option<String>,
/// Candidate completions; the first is used.
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_as_empty")]
pub choices: Vec<ChoiceWire>,
/// Token usage, when reported.
#[serde(default)]
Expand Down Expand Up @@ -352,7 +374,7 @@ pub struct ResponseMessageWire {
#[serde(default)]
pub reasoning: Option<Value>,
/// Tool calls requested by the model.
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_as_empty")]
pub tool_calls: Vec<ToolCallWire>,
}

Expand Down Expand Up @@ -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<ModelListing>,
}
Loading