use serde_json::Value;
use crate::client::{CompletionResult, ToolCall};
use crate::{Error, Result};
const EMPTY_REPLY: &str = "empty model reply";
const EMPTY_REPLY_REASONING_IGNORED: &str =
"empty model reply: reasoning content was present but ignored";
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub(crate) struct NormalizedTurn {
pub(crate) outcome: CompletionResult,
pub(crate) finish_reason: Option<String>,
pub(crate) reasoning_content: Option<String>,
}
pub(crate) trait CompletionNormalizer: Send + Sync {
fn normalize(&self, body: &Value) -> Result<NormalizedTurn>;
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct OpenAiChatNormalizer;
pub(crate) struct TurnContext<'a> {
pub(crate) message: &'a Value,
pub(crate) finish_reason: Option<String>,
pub(crate) reasoning_content: Option<String>,
}
pub(crate) fn turn_context(body: &Value) -> Result<TurnContext<'_>> {
let choices = match body.get("choices") {
None => return Err(Error::MalformedResponse("no choices in response".into())),
Some(Value::Array(choices)) => choices,
Some(_) => {
return Err(Error::MalformedResponse(
"`choices` was present but not an array".into(),
));
}
};
let choice = choices
.first()
.ok_or_else(|| Error::MalformedResponse("response had zero choices".into()))?;
if !choice.is_object() {
return Err(Error::MalformedResponse(
"`choices[0]` was not an object".into(),
));
}
let finish_reason = match choice.get("finish_reason") {
None | Some(Value::Null) => None,
Some(Value::String(reason)) => Some(reason.clone()),
Some(_) => {
return Err(Error::MalformedResponse(
"`finish_reason` was present but not a string".into(),
));
}
};
let message = choice
.get("message")
.ok_or_else(|| Error::MalformedResponse("choice had no message".into()))?;
if !message.is_object() {
return Err(Error::MalformedResponse(
"`message` was present but not an object".into(),
));
}
let reasoning_content = extract_reasoning(message)?;
Ok(TurnContext {
message,
finish_reason,
reasoning_content,
})
}
pub(crate) fn empty_reply_error(reasoning_present: bool) -> Error {
Error::EmptyModelReply {
detail: if reasoning_present {
EMPTY_REPLY_REASONING_IGNORED
} else {
EMPTY_REPLY
},
}
}
impl CompletionNormalizer for OpenAiChatNormalizer {
fn normalize(&self, body: &Value) -> Result<NormalizedTurn> {
let TurnContext {
message,
finish_reason,
reasoning_content,
} = turn_context(body)?;
let tool_calls = match message.get("tool_calls") {
None | Some(Value::Null) => None,
Some(Value::Array(calls)) => Some(calls),
Some(_) => {
return Err(Error::MalformedResponse(
"`tool_calls` was present but not an array".into(),
));
}
};
if let Some(raw_calls) = tool_calls.filter(|calls| !calls.is_empty()) {
let calls = parse_openai_tool_calls(raw_calls)?;
return Ok(NormalizedTurn {
outcome: CompletionResult::ToolCalls(calls),
finish_reason,
reasoning_content,
});
}
let content = match message.get("content") {
None | Some(Value::Null) => None,
Some(Value::String(text)) => Some(text.as_str()),
Some(_) => {
return Err(Error::MalformedResponse(
"`content` was present but not a string".into(),
));
}
};
if let Some(text) = content.filter(|text| !text.trim().is_empty()) {
return Ok(NormalizedTurn {
outcome: CompletionResult::Text(text.to_string()),
finish_reason,
reasoning_content,
});
}
Err(empty_reply_error(reasoning_content.is_some()))
}
}
pub(crate) fn parse_openai_tool_calls(raw_calls: &[Value]) -> Result<Vec<ToolCall>> {
let mut calls = Vec::with_capacity(raw_calls.len());
let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for raw in raw_calls {
if !raw.is_object() {
return Err(Error::MalformedResponse(
"tool call was not an object".into(),
));
}
match raw.get("type") {
Some(Value::String(kind)) if kind == "function" => {}
_ => {
return Err(Error::MalformedResponse(
"tool call `type` must be the string \"function\"".into(),
));
}
}
let id = raw
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| Error::MalformedResponse("tool call had no string id".into()))?;
if id.trim().is_empty() {
return Err(Error::MalformedResponse("tool call id was blank".into()));
}
if !seen_ids.insert(id) {
return Err(Error::MalformedResponse(format!(
"duplicate tool call id {id:?} within one turn"
)));
}
let function = raw
.get("function")
.ok_or_else(|| Error::MalformedResponse("tool call had no function".into()))?;
if !function.is_object() {
return Err(Error::MalformedResponse(
"tool call `function` was not an object".into(),
));
}
let name = function
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| Error::MalformedResponse("tool call had no string name".into()))?;
if name.trim().is_empty() {
return Err(Error::MalformedResponse("tool call name was blank".into()));
}
let arguments = match function.get("arguments") {
Some(Value::String(raw_args)) => {
let decoded = serde_json::from_str::<Value>(raw_args).map_err(|error| {
Error::MalformedResponse(format!(
"tool call arguments were not valid JSON: {error}"
))
})?;
if !decoded.is_object() {
return Err(Error::MalformedResponse(
"tool call arguments did not decode to a JSON object".into(),
));
}
decoded
}
None | Some(Value::Null) => {
return Err(Error::MalformedResponse(
"tool call arguments were missing".into(),
));
}
Some(_) => {
return Err(Error::MalformedResponse(
"tool call arguments were not a JSON-encoded string".into(),
));
}
};
calls.push(ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments,
});
}
Ok(calls)
}
pub(crate) fn extract_reasoning(message: &Value) -> Result<Option<String>> {
for key in ["reasoning_content", "reasoning", "thinking"] {
match message.get(key) {
None | Some(Value::Null) => {}
Some(Value::String(text)) => {
if !text.trim().is_empty() {
return Ok(Some(text.clone()));
}
}
Some(_) => {
return Err(Error::MalformedResponse(format!(
"`{key}` reasoning field was present but not a string"
)));
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
fn normalize(body: &Value) -> Result<NormalizedTurn> {
OpenAiChatNormalizer.normalize(body)
}
#[test]
fn answer_and_reasoning_keeps_side_channel() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": "answer",
"reasoning_content": "scratch work"
},
"finish_reason": "stop"
}]
});
let turn = normalize(&body).unwrap();
assert_eq!(turn.finish_reason.as_deref(), Some("stop"));
assert_eq!(turn.reasoning_content.as_deref(), Some("scratch work"));
match turn.outcome {
CompletionResult::Text(text) => assert_eq!(text, "answer"),
CompletionResult::ToolCalls(_) => panic!("expected text, got tool calls"),
}
}
#[test]
fn tools_with_empty_content_succeed() {
let body = serde_json::json!({
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "web_search",
"arguments": "{\"query\":\"rust\",\"count\":3}"
}
}]
},
"finish_reason": "tool_calls"
}]
});
let turn = normalize(&body).unwrap();
assert_eq!(turn.finish_reason.as_deref(), Some("tool_calls"));
match turn.outcome {
CompletionResult::ToolCalls(calls) => {
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "web_search");
assert_eq!(
calls[0].arguments,
serde_json::json!({ "query": "rust", "count": 3 })
);
}
CompletionResult::Text(text) => panic!("expected tool calls, got text: {text}"),
}
}
#[test]
fn tools_with_null_content_succeed() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_2",
"type": "function",
"function": { "name": "web_fetch", "arguments": "{\"url\":\"https://example.com\"}" }
}]
}
}]
});
let turn = normalize(&body).unwrap();
match turn.outcome {
CompletionResult::ToolCalls(calls) => {
assert_eq!(
calls[0].arguments,
serde_json::json!({ "url": "https://example.com" })
);
}
CompletionResult::Text(text) => panic!("expected tool calls, got text: {text}"),
}
}
#[test]
fn malformed_tool_arguments_are_rejected_not_coerced() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_bad",
"type": "function",
"function": { "name": "web_fetch", "arguments": "not json" }
}]
}
}]
});
assert!(
matches!(normalize(&body), Err(Error::MalformedResponse(_))),
"invalid-JSON tool arguments must be rejected, never coerced to a string"
);
}
#[test]
fn non_string_tool_arguments_are_rejected() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_obj",
"type": "function",
"function": { "name": "web_fetch", "arguments": { "url": "x" } }
}]
}
}]
});
assert!(matches!(normalize(&body), Err(Error::MalformedResponse(_))));
}
#[test]
fn absent_tool_arguments_are_rejected() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_none",
"type": "function",
"function": { "name": "ping" }
}]
}
}]
});
assert!(
matches!(normalize(&body), Err(Error::MalformedResponse(_))),
"missing tool arguments must be rejected, not coerced to null"
);
}
#[test]
fn non_object_decoded_arguments_are_rejected() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_arr",
"type": "function",
"function": { "name": "ping", "arguments": "[1,2,3]" }
}]
}
}]
});
assert!(
matches!(normalize(&body), Err(Error::MalformedResponse(_))),
"arguments that decode to a non-object must be rejected"
);
}
#[test]
fn blank_tool_call_id_is_rejected() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": " ",
"type": "function",
"function": { "name": "ping", "arguments": "{}" }
}]
}
}]
});
assert!(matches!(normalize(&body), Err(Error::MalformedResponse(_))));
}
#[test]
fn duplicate_tool_call_ids_are_rejected() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{ "id": "dup", "type": "function", "function": { "name": "a", "arguments": "{}" } },
{ "id": "dup", "type": "function", "function": { "name": "b", "arguments": "{}" } }
]
}
}]
});
assert!(matches!(normalize(&body), Err(Error::MalformedResponse(_))));
}
#[test]
fn wrong_type_type_field_is_rejected() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_x",
"type": "not_function",
"function": { "name": "ping", "arguments": "{}" }
}]
}
}]
});
assert!(matches!(normalize(&body), Err(Error::MalformedResponse(_))));
}
#[test]
fn missing_or_null_type_field_is_rejected() {
for type_field in [None, Some(serde_json::Value::Null)] {
let mut call = serde_json::json!({
"id": "call_x",
"function": { "name": "ping", "arguments": "{}" }
});
if let Some(value) = type_field {
call["type"] = value;
}
let body = serde_json::json!({
"choices": [{
"message": { "role": "assistant", "content": null, "tool_calls": [call] }
}]
});
assert!(matches!(normalize(&body), Err(Error::MalformedResponse(_))));
}
}
#[test]
fn wrong_typed_top_level_fields_are_malformed() {
assert!(matches!(
normalize(&serde_json::json!({ "choices": {} })),
Err(Error::MalformedResponse(_))
));
assert!(matches!(
normalize(&serde_json::json!({ "choices": [{ "message": 7 }] })),
Err(Error::MalformedResponse(_))
));
assert!(matches!(
normalize(&serde_json::json!({
"choices": [{ "message": { "content": "hi" }, "finish_reason": 3 }]
})),
Err(Error::MalformedResponse(_))
));
assert!(matches!(
normalize(&serde_json::json!({
"choices": [{ "message": { "content": [] } }]
})),
Err(Error::MalformedResponse(_))
));
assert!(matches!(
normalize(&serde_json::json!({
"choices": [{ "message": { "content": null, "tool_calls": {} } }]
})),
Err(Error::MalformedResponse(_))
));
assert!(matches!(
normalize(&serde_json::json!({
"choices": [{ "message": { "content": "hi", "reasoning_content": 5 } }]
})),
Err(Error::MalformedResponse(_))
));
}
#[test]
fn whitespace_only_content_is_empty_reply() {
let body = serde_json::json!({
"choices": [{ "message": { "content": " \n\t " } }]
});
assert!(matches!(
normalize(&body),
Err(Error::EmptyModelReply { .. })
));
}
#[test]
fn empty_content_with_reasoning_is_error() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": "",
"reasoning_content": "only thinking"
},
"finish_reason": "stop"
}]
});
match normalize(&body) {
Err(Error::EmptyModelReply { detail }) => {
assert_eq!(detail, EMPTY_REPLY_REASONING_IGNORED);
}
other => panic!("expected EmptyModelReply, got {other:?}"),
}
}
#[test]
fn empty_string_content_without_tools_is_error() {
let body = serde_json::json!({
"choices": [{
"message": { "role": "assistant", "content": "" }
}]
});
match normalize(&body) {
Err(Error::EmptyModelReply { detail }) => assert_eq!(detail, EMPTY_REPLY),
other => panic!("expected EmptyModelReply, got {other:?}"),
}
}
#[test]
fn null_content_without_tools_is_error() {
let body = serde_json::json!({
"choices": [{
"message": { "role": "assistant", "content": null }
}]
});
match normalize(&body) {
Err(Error::EmptyModelReply { detail }) => assert_eq!(detail, EMPTY_REPLY),
other => panic!("expected EmptyModelReply, got {other:?}"),
}
}
#[test]
fn synonym_reasoning_field_is_side_channel() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": "answer",
"reasoning": "via synonym"
}
}]
});
let turn = normalize(&body).unwrap();
assert_eq!(turn.reasoning_content.as_deref(), Some("via synonym"));
match turn.outcome {
CompletionResult::Text(text) => assert_eq!(text, "answer"),
CompletionResult::ToolCalls(_) => panic!("expected text, got tool calls"),
}
}
#[test]
fn empty_reasoning_synonym_falls_through() {
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": "answer",
"reasoning_content": "",
"thinking": "from thinking"
}
}]
});
let turn = normalize(&body).unwrap();
assert_eq!(turn.reasoning_content.as_deref(), Some("from thinking"));
}
#[test]
fn missing_content_and_tools_is_empty_model_reply() {
let body = serde_json::json!({
"choices": [{ "message": { "role": "assistant" } }]
});
assert!(matches!(
normalize(&body),
Err(Error::EmptyModelReply { .. })
));
}
#[test]
fn no_choices_is_malformed() {
let body = serde_json::json!({ "choices": [] });
assert!(matches!(normalize(&body), Err(Error::MalformedResponse(_))));
}
#[test]
fn tool_code_fence_stays_text_in_openai_normalizer() {
let content = "```tool_code\nsearch(query=\"C++ Alliance founder\")\n```";
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": content
},
"finish_reason": "stop"
}]
});
let turn = normalize(&body).unwrap();
match turn.outcome {
CompletionResult::Text(text) => assert_eq!(text, content),
CompletionResult::ToolCalls(_) => {
panic!("OpenAI normalizer must not parse content fences")
}
}
}
#[test]
fn fenced_json_tool_calls_stays_text_in_openai_normalizer() {
let content = "```json\n{\"tool_calls\":[{\"id\":\"1\",\"type\":\"function\",\"function\":{\"name\":\"fetch\",\"arguments\":\"{\\\"url\\\":\\\"https://example.com\\\"}\"}}]}\n```";
let body = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": content
}
}]
});
let turn = normalize(&body).unwrap();
match turn.outcome {
CompletionResult::Text(text) => assert_eq!(text, content),
CompletionResult::ToolCalls(_) => {
panic!("OpenAI normalizer must not parse content fences")
}
}
}
}