use crate::format::Provider;
use super::{
RecordedFixture, RecordedMatch, RecordedResponse, RecordedToolCall, RECORDED_PRIORITY,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OpenAiEndpoint {
Chat,
Completions,
Embeddings,
}
impl OpenAiEndpoint {
pub(crate) fn from_path(path: &str) -> Self {
match path {
"/v1/completions" => Self::Completions,
"/v1/embeddings" => Self::Embeddings,
_ => Self::Chat,
}
}
}
pub(crate) fn extract_for(
provider: Provider,
endpoint: OpenAiEndpoint,
body: &serde_json::Value,
model: &str,
user_message: &str,
) -> Option<RecordedFixture> {
match provider {
Provider::OpenAI => match endpoint {
OpenAiEndpoint::Chat => extract_openai(body, model, user_message),
OpenAiEndpoint::Completions => extract_completions(body, model, user_message),
OpenAiEndpoint::Embeddings => extract_embeddings(body, model, user_message),
},
Provider::Anthropic => extract_anthropic(body, model, user_message),
Provider::Gemini => extract_gemini(body, model, user_message),
Provider::Responses => extract_responses(body, model, user_message),
}
}
pub(crate) fn extract_openai(
body: &serde_json::Value,
model: &str,
user_message: &str,
) -> Option<RecordedFixture> {
let choice = body.get("choices")?.get(0)?;
let message = choice.get("message")?;
let mut tool_calls = Vec::new();
if let Some(calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
for call in calls {
let function = call.get("function");
let Some(name) = function
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
else {
continue;
};
tool_calls.push(RecordedToolCall {
name: name.to_string(),
arguments: string_args_or_warn(name, function.and_then(|f| f.get("arguments"))),
});
}
}
let content = message
.get("content")
.and_then(|c| c.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
let finish_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(str::to_string);
finish(
Provider::OpenAI,
model,
user_message,
content,
tool_calls,
None,
finish_reason,
)
}
pub(crate) fn extract_anthropic(
body: &serde_json::Value,
model: &str,
user_message: &str,
) -> Option<RecordedFixture> {
let blocks = body.get("content")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for block in blocks {
match block.get("type").and_then(|t| t.as_str()) {
Some("text") => {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
text.push_str(t);
}
}
Some("tool_use") => {
let Some(name) = block.get("name").and_then(|n| n.as_str()) else {
continue;
};
tool_calls.push(RecordedToolCall {
name: name.to_string(),
arguments: block
.get("input")
.and_then(parse_args)
.unwrap_or_else(|| serde_json::json!({})),
});
}
_ => {} }
}
let stop_reason = body
.get("stop_reason")
.and_then(|r| r.as_str())
.map(str::to_string);
finish(
Provider::Anthropic,
model,
user_message,
Some(text).filter(|t| !t.is_empty()),
tool_calls,
stop_reason,
None,
)
}
pub(crate) fn extract_gemini(
body: &serde_json::Value,
model: &str,
user_message: &str,
) -> Option<RecordedFixture> {
let candidate = body.get("candidates")?.get(0)?;
let parts = candidate.get("content")?.get("parts")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
accumulate_gemini_parts(parts, &mut text, &mut tool_calls);
let finish_reason = candidate
.get("finishReason")
.and_then(|r| r.as_str())
.map(str::to_string);
finish(
Provider::Gemini,
model,
user_message,
Some(text).filter(|t| !t.is_empty()),
tool_calls,
None,
finish_reason,
)
}
pub(crate) fn extract_responses(
body: &serde_json::Value,
model: &str,
user_message: &str,
) -> Option<RecordedFixture> {
match body.get("status") {
None => {}
Some(s) if s.as_str() == Some("completed") => {}
Some(_) => return None,
}
let output = body.get("output")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for item in output {
match item.get("type").and_then(|t| t.as_str()) {
Some("message") => {
let Some(content) = item.get("content").and_then(|c| c.as_array()) else {
continue;
};
for part in content {
if part.get("type").and_then(|t| t.as_str()) == Some("output_text") {
if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
text.push_str(t);
}
}
}
}
Some("function_call") => {
let Some(name) = item.get("name").and_then(|n| n.as_str()) else {
continue;
};
tool_calls.push(RecordedToolCall {
name: name.to_string(),
arguments: string_args_or_warn(name, item.get("arguments")),
});
}
_ => {} }
}
finish(
Provider::Responses,
model,
user_message,
Some(text).filter(|t| !t.is_empty()),
tool_calls,
None,
None,
)
}
pub(crate) fn extract_completions(
body: &serde_json::Value,
model: &str,
user_message: &str,
) -> Option<RecordedFixture> {
let choice = body.get("choices")?.get(0)?;
let content = choice
.get("text")
.and_then(|t| t.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
let finish_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(str::to_string);
finish(
Provider::OpenAI,
model,
user_message,
content,
Vec::new(),
None,
finish_reason,
)
}
pub(crate) fn extract_embeddings(
body: &serde_json::Value,
model: &str,
user_message: &str,
) -> Option<RecordedFixture> {
let data = body.get("data")?.as_array()?;
if data.len() != 1 {
return None;
}
let values = data[0].get("embedding")?.as_array()?;
let embedding = values
.iter()
.map(serde_json::Value::as_f64)
.collect::<Option<Vec<f64>>>()?;
let mut rec = base(Provider::OpenAI, model, user_message);
rec.response.embedding = Some(embedding);
Some(rec)
}
pub(super) fn accumulate_gemini_parts(
parts: &[serde_json::Value],
text: &mut String,
tool_calls: &mut Vec<RecordedToolCall>,
) {
for part in parts {
if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
text.push_str(t);
}
if let Some(call) = part.get("functionCall") {
let Some(name) = call.get("name").and_then(|n| n.as_str()) else {
continue;
};
tool_calls.push(RecordedToolCall {
name: name.to_string(),
arguments: call
.get("args")
.and_then(parse_args)
.unwrap_or_else(|| serde_json::json!({})),
});
}
}
}
pub(super) fn string_args_or_warn(
name: &str,
args: Option<&serde_json::Value>,
) -> serde_json::Value {
let Some(v) = args else {
return serde_json::json!({});
};
match parse_args(v) {
Some(parsed) => parsed,
None => {
if v.is_string() {
eprintln!(
"[llmposter] record mode: tool call '{}' had unparseable arguments — \
recorded as {{}}",
name
);
}
serde_json::json!({})
}
}
}
pub(super) fn finish(
provider: Provider,
model: &str,
user_message: &str,
content: Option<String>,
tool_calls: Vec<RecordedToolCall>,
stop_reason: Option<String>,
finish_reason: Option<String>,
) -> Option<RecordedFixture> {
let mut rec = base(provider, model, user_message);
if !tool_calls.is_empty() {
rec.response.tool_calls = Some(tool_calls);
} else if content.is_some() {
rec.response.content = content;
} else {
return None;
}
rec.response.stop_reason = stop_reason;
rec.response.finish_reason = finish_reason;
Some(rec)
}
fn base(provider: Provider, model: &str, user_message: &str) -> RecordedFixture {
RecordedFixture {
match_rule: RecordedMatch {
user_message: user_message.to_string(),
model: model.to_string(),
},
provider: provider.as_str(),
priority: RECORDED_PRIORITY,
response: RecordedResponse::default(),
}
}
pub(super) fn parse_args(v: &serde_json::Value) -> Option<serde_json::Value> {
match v {
serde_json::Value::String(s) => serde_json::from_str::<serde_json::Value>(s)
.ok()
.filter(|parsed| parsed.is_object()),
serde_json::Value::Object(_) => Some(v.clone()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn should_extract_openai_text_response() {
let body = json!({
"id": "chatcmpl-AIdRnXqrjJXgTom1yzM6ZUX4A9CqB",
"object": "chat.completion",
"created": 1728933352,
"model": "gpt-4o-2024-08-06",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "hello!",
"refusal": null
},
"logprobs": null,
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 19,
"completion_tokens": 10,
"total_tokens": 29,
"completion_tokens_details": { "reasoning_tokens": 0 }
},
"system_fingerprint": "fp_6b68a8204b"
});
let rec = extract_openai(&body, "gpt-4o", "say hello").unwrap();
assert_eq!(rec.provider, "openai");
assert_eq!(rec.match_rule.model, "gpt-4o");
assert_eq!(rec.match_rule.user_message, "say hello");
assert_eq!(rec.response.content.as_deref(), Some("hello!"));
assert_eq!(rec.response.finish_reason.as_deref(), Some("stop"));
assert!(rec.response.tool_calls.is_none());
}
#[test]
fn should_extract_openai_tool_calls_with_string_arguments() {
let body = json!({
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1699896916,
"model": "gpt-4o-2024-08-06",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"SF\"}"
}
}]
},
"logprobs": null,
"finish_reason": "tool_calls"
}],
"usage": { "prompt_tokens": 82, "completion_tokens": 17, "total_tokens": 99 }
});
let rec = extract_openai(&body, "gpt-4o", "weather?").unwrap();
let calls = rec.response.tool_calls.as_ref().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].arguments["city"], "SF");
assert!(
rec.response.content.is_none(),
"tool calls win; content stays None"
);
}
#[test]
fn should_extract_anthropic_skipping_thinking_blocks_and_preferring_tools() {
let body = json!({
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [
{
"type": "thinking",
"thinking": "The user wants a lookup; I should call the tool.",
"signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pk"
},
{ "type": "text", "text": "Let me check." },
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "lookup",
"input": { "q": "x" }
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": { "input_tokens": 599, "output_tokens": 152 }
});
let rec = extract_anthropic(&body, "claude-sonnet-4-6", "look up x").unwrap();
assert_eq!(rec.provider, "anthropic");
let calls = rec.response.tool_calls.as_ref().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "lookup");
assert_eq!(calls[0].arguments["q"], "x");
assert!(rec.response.content.is_none(), "tools win over text");
assert_eq!(rec.response.stop_reason.as_deref(), Some("tool_use"));
}
#[test]
fn should_extract_anthropic_text_only() {
let body = json!({
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [
{ "type": "text", "text": "part one " },
{ "type": "text", "text": "part two" }
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": { "input_tokens": 10, "output_tokens": 25 }
});
let rec = extract_anthropic(&body, "claude-sonnet-4-6", "two parts").unwrap();
assert_eq!(rec.response.content.as_deref(), Some("part one part two"));
assert!(rec.response.tool_calls.is_none());
assert_eq!(rec.response.stop_reason.as_deref(), Some("end_turn"));
}
#[test]
fn should_extract_gemini_with_extra_real_fields() {
let body = json!({
"candidates": [{
"content": {
"parts": [{ "text": "answer" }],
"role": "model"
},
"finishReason": "STOP",
"avgLogprobs": -0.003405,
"index": 0
}],
"usageMetadata": {
"promptTokenCount": 4,
"candidatesTokenCount": 5,
"totalTokenCount": 9,
"promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 4 }]
},
"modelVersion": "gemini-2.5-flash",
"responseId": "wp5rZ_KNGpyO2PgPn5uD8Ac"
});
let rec = extract_gemini(&body, "gemini-2.5-flash", "ask").unwrap();
assert_eq!(rec.provider, "gemini");
assert_eq!(rec.response.content.as_deref(), Some("answer"));
assert!(rec.response.tool_calls.is_none());
assert_eq!(
rec.response.finish_reason.as_deref(),
Some("STOP"),
"candidates[0].finishReason is recorded like every other provider's terminal reason"
);
}
#[test]
fn should_extract_gemini_function_call() {
let body = json!({
"candidates": [{
"content": {
"parts": [{
"functionCall": {
"name": "get_weather",
"args": { "city": "SF" }
}
}],
"role": "model"
},
"finishReason": "STOP",
"index": 0
}],
"usageMetadata": { "promptTokenCount": 8, "totalTokenCount": 12 }
});
let rec = extract_gemini(&body, "gemini-2.5-flash", "weather?").unwrap();
let calls = rec.response.tool_calls.as_ref().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].arguments["city"], "SF");
assert!(rec.response.content.is_none());
assert_eq!(rec.response.finish_reason.as_deref(), Some("STOP"));
}
#[test]
fn should_extract_responses_api_text_and_function_call() {
let text_body = json!({
"id": "resp_67ccd2bed1ec8190b14f964abc054267",
"object": "response",
"created_at": 1741476542,
"status": "completed",
"model": "gpt-4o-2024-08-06",
"output": [
{
"type": "reasoning",
"id": "rs_67ccd2bf17f0819081ff3bb2cf6508e6",
"summary": []
},
{
"type": "message",
"id": "msg_67ccd2bf17f0819081ff3bb2cf6508e6",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "resp text",
"annotations": []
}]
}
]
});
let rec = extract_responses(&text_body, "gpt-4o", "respond").unwrap();
assert_eq!(rec.provider, "responses");
assert_eq!(rec.response.content.as_deref(), Some("resp text"));
assert!(rec.response.tool_calls.is_none());
let tool_body = json!({
"id": "resp_67ca09c5efe0819096d0511c92b8c890",
"object": "response",
"created_at": 1741294021,
"status": "completed",
"model": "gpt-4o-2024-08-06",
"output": [{
"type": "function_call",
"id": "fc_67ca09c6bedc8190a7abfec07b1a1332",
"call_id": "call_unLAR8MvFNptuiZK6K6HCy5k",
"name": "get_weather",
"arguments": "{\"city\":\"SF\"}",
"status": "completed"
}]
});
let rec = extract_responses(&tool_body, "gpt-4o", "weather?").unwrap();
let calls = rec.response.tool_calls.as_ref().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
assert_eq!(
calls[0].arguments["city"], "SF",
"string args are PARSED to an object"
);
assert!(rec.response.content.is_none());
}
#[test]
fn should_not_record_incomplete_responses_status() {
let incomplete = json!({
"id": "resp_67ccd2bed1ec8190b14f964abc054267",
"object": "response",
"created_at": 1741476542,
"status": "incomplete",
"incomplete_details": { "reason": "max_output_tokens" },
"model": "gpt-4o-2024-08-06",
"output": [{
"type": "message",
"id": "msg_1",
"status": "incomplete",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "truncated par",
"annotations": []
}]
}]
});
assert!(
extract_responses(&incomplete, "gpt-4o", "long ask").is_none(),
"incomplete status must not record"
);
let no_status = json!({
"object": "response",
"output": [{
"type": "message",
"id": "msg_2",
"role": "assistant",
"content": [{ "type": "output_text", "text": "ok" }]
}]
});
let rec = extract_responses(&no_status, "gpt-4o", "ask").unwrap();
assert_eq!(rec.response.content.as_deref(), Some("ok"));
}
#[test]
fn should_extract_completions_text() {
let body = json!({
"id": "cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7",
"object": "text_completion",
"created": 1589478378,
"model": "davinci-002",
"choices": [{
"text": " legacy completion",
"index": 0,
"logprobs": null,
"finish_reason": "length"
}],
"usage": { "prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12 }
});
let rec = extract_completions(&body, "davinci-002", "legacy prompt").unwrap();
assert_eq!(rec.provider, "openai");
assert_eq!(
rec.response.content.as_deref(),
Some(" legacy completion"),
"content preserved verbatim, including leading whitespace"
);
assert_eq!(rec.response.finish_reason.as_deref(), Some("length"));
}
#[test]
fn should_record_empty_args_when_tool_arguments_unparseable() {
let body = json!({
"id": "chatcmpl-badargs",
"object": "chat.completion",
"created": 1699896916,
"model": "gpt-4o-2024-08-06",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_badargs",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "not valid json {"
}
}]
},
"logprobs": null,
"finish_reason": "tool_calls"
}]
});
let rec = extract_openai(&body, "gpt-4o", "weather?").unwrap();
let calls = rec.response.tool_calls.as_ref().unwrap();
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].arguments, json!({}));
let gemini_body = json!({
"candidates": [{
"content": {
"parts": [{ "functionCall": { "name": "refresh" } }],
"role": "model"
},
"finishReason": "STOP",
"index": 0
}]
});
let rec = extract_gemini(&gemini_body, "gemini-2.5-flash", "refresh it").unwrap();
let calls = rec.response.tool_calls.as_ref().unwrap();
assert_eq!(calls[0].name, "refresh");
assert_eq!(calls[0].arguments, json!({}));
}
#[test]
fn should_return_none_for_unextractable_real_api_variants() {
let openai_parts = json!({
"object": "chat.completion",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": [{ "type": "text", "text": "chunked" }]
},
"finish_reason": "stop"
}]
});
assert!(extract_openai(&openai_parts, "m", "u").is_none());
let thinking_only = json!({
"type": "message",
"role": "assistant",
"content": [{
"type": "thinking",
"thinking": "hmm, tricky",
"signature": "sig"
}],
"stop_reason": "end_turn"
});
assert!(extract_anthropic(&thinking_only, "m", "u").is_none());
let safety_blocked = json!({
"candidates": [{
"finishReason": "SAFETY",
"safetyRatings": [{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"probability": "HIGH"
}],
"index": 0
}]
});
assert!(extract_gemini(&safety_blocked, "m", "u").is_none());
let empty_message = json!({
"object": "response",
"output": [{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": []
}]
});
assert!(extract_responses(&empty_message, "m", "u").is_none());
}
#[test]
fn should_extract_single_embedding_and_skip_multi() {
let single = json!({
"object": "list",
"data": [{
"object": "embedding",
"index": 0,
"embedding": [0.1, -0.2, 0.3]
}],
"model": "text-embedding-3-small",
"usage": { "prompt_tokens": 2, "total_tokens": 2 }
});
let rec = extract_embeddings(&single, "text-embedding-3-small", "some text").unwrap();
assert_eq!(rec.provider, "openai");
assert_eq!(rec.match_rule.model, "text-embedding-3-small");
assert_eq!(rec.match_rule.user_message, "some text");
assert_eq!(
rec.response.embedding.as_ref().unwrap(),
&vec![0.1, -0.2, 0.3]
);
assert!(rec.response.content.is_none());
assert!(rec.response.tool_calls.is_none());
let multi = json!({ "data": [{ "embedding": [0.1] }, { "embedding": [0.2] }] });
assert!(extract_embeddings(&multi, "m", "q").is_none());
}
#[test]
fn should_return_none_for_malformed_embedding_data() {
assert!(extract_embeddings(&json!({ "object": "list" }), "m", "q").is_none());
assert!(extract_embeddings(&json!({ "data": [] }), "m", "q").is_none());
assert!(extract_embeddings(&json!({ "data": [{ "index": 0 }] }), "m", "q").is_none());
let base64 = json!({ "data": [{ "embedding": "AACAPwAAAEA=" }] });
assert!(extract_embeddings(&base64, "m", "q").is_none());
let mixed = json!({ "data": [{ "embedding": [0.1, "x"] }] });
assert!(extract_embeddings(&mixed, "m", "q").is_none());
}
#[test]
fn should_map_paths_to_openai_endpoints() {
assert_eq!(
OpenAiEndpoint::from_path("/v1/completions"),
OpenAiEndpoint::Completions
);
assert_eq!(
OpenAiEndpoint::from_path("/v1/embeddings"),
OpenAiEndpoint::Embeddings
);
assert_eq!(
OpenAiEndpoint::from_path("/v1/chat/completions"),
OpenAiEndpoint::Chat
);
assert_eq!(
OpenAiEndpoint::from_path("/v1/messages"),
OpenAiEndpoint::Chat
);
}
#[test]
fn should_return_none_when_nothing_extractable() {
let empty_choices = json!({ "object": "chat.completion", "choices": [] });
assert!(extract_openai(&empty_choices, "m", "u").is_none());
assert!(extract_completions(&empty_choices, "m", "u").is_none());
let empty_content = json!({ "type": "message", "content": [] });
assert!(extract_anthropic(&empty_content, "m", "u").is_none());
let empty_candidates = json!({ "candidates": [] });
assert!(extract_gemini(&empty_candidates, "m", "u").is_none());
let empty_output = json!({ "object": "response", "output": [] });
assert!(extract_responses(&empty_output, "m", "u").is_none());
}
#[test]
fn should_skip_openai_tool_call_without_function_name() {
let body = json!({
"choices": [{
"message": {
"content": "txt",
"tool_calls": [
{ "function": { "arguments": "{}" } },
{ "id": "call_nofunction" }
]
},
"finish_reason": "stop"
}]
});
let rec = extract_openai(&body, "m", "u").unwrap();
assert!(rec.response.tool_calls.is_none());
assert_eq!(rec.response.content.as_deref(), Some("txt"));
}
#[test]
fn should_skip_anthropic_tool_use_without_name() {
let body = json!({
"content": [
{ "type": "tool_use", "input": { "a": 1 } },
{ "type": "text", "text": "t" }
],
"stop_reason": "end_turn"
});
let rec = extract_anthropic(&body, "m", "u").unwrap();
assert!(rec.response.tool_calls.is_none());
assert_eq!(rec.response.content.as_deref(), Some("t"));
}
#[test]
fn should_skip_gemini_function_call_without_name() {
let body = json!({
"candidates": [{
"content": {
"parts": [
{ "functionCall": { "args": { "a": 1 } } },
{ "text": "t" }
]
}
}]
});
let rec = extract_gemini(&body, "m", "u").unwrap();
assert!(rec.response.tool_calls.is_none());
assert_eq!(rec.response.content.as_deref(), Some("t"));
}
#[test]
fn should_skip_responses_items_with_malformed_content() {
let body = json!({
"output": [
{ "type": "message", "content": "not-an-array" },
{ "type": "message", "content": [
{ "type": "output_text" },
{ "type": "refusal", "refusal": "no" }
]},
{ "type": "function_call", "arguments": "{}" },
{ "type": "message", "content": [
{ "type": "output_text", "text": "ok" }
]}
]
});
let rec = extract_responses(&body, "m", "u").unwrap();
assert!(rec.response.tool_calls.is_none());
assert_eq!(rec.response.content.as_deref(), Some("ok"));
}
#[test]
fn should_default_absent_args_to_empty_object_silently() {
assert_eq!(string_args_or_warn("f", None), json!({}));
}
#[test]
fn should_reject_non_string_non_object_args() {
assert_eq!(parse_args(&json!(42)), None);
assert_eq!(parse_args(&json!(["not", "an", "object"])), None);
assert_eq!(parse_args(&json!(true)), None);
}
}