test(twin-openai): accept tool call histories

This commit is contained in:
Bryan Helmkamp 2026-07-23 20:11:21 -04:00
parent cdf1dce4f8
commit 7c771fc5d7
No known key found for this signature in database
2 changed files with 140 additions and 15 deletions

View file

@ -625,15 +625,34 @@ impl ChatCompletionsRequest {
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChatMessage {
pub role: String,
pub content: Value,
pub role: String,
pub content: Option<Value>,
pub reasoning_content: Option<String>,
pub tool_call_id: Option<String>,
pub tool_calls: Option<Vec<ChatMessageToolCall>>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChatMessageToolCall {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub function: ChatMessageToolCallFunction,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChatMessageToolCallFunction {
pub name: String,
pub arguments: String,
}
impl ChatMessage {
fn extract_texts(&self) -> Vec<String> {
match &self.content {
Value::String(text) => vec![normalize_whitespace(text)],
Value::Array(parts) => parts
match self.content.as_ref() {
Some(Value::String(text)) => vec![normalize_whitespace(text)],
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|part| {
part.get("text")
@ -647,13 +666,21 @@ impl ChatMessage {
fn contains_reasoning_content(&self) -> bool {
self.role == "assistant"
&& self.content.as_array().is_some_and(|parts| {
parts.iter().any(|part| {
part.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "reasoning")
})
})
&& (self
.reasoning_content
.as_deref()
.is_some_and(|reasoning| !reasoning.trim().is_empty())
|| self
.content
.as_ref()
.and_then(Value::as_array)
.is_some_and(|parts| {
parts.iter().any(|part| {
part.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "reasoning")
})
}))
}
}
@ -680,14 +707,24 @@ fn validate_chat_message(message: &ChatMessage) -> Result<(), OpenAiError> {
));
}
match &message.content {
Value::String(_) => Ok(()),
Value::Array(parts) if !parts.is_empty() => {
validate_chat_tool_fields(message)?;
match message.content.as_ref() {
Some(Value::String(_)) => Ok(()),
Some(Value::Array(parts)) if !parts.is_empty() => {
for part in parts {
validate_chat_message_part(part, &message.role)?;
}
Ok(())
}
None if message.role == "assistant"
&& message
.tool_calls
.as_ref()
.is_some_and(|tool_calls| !tool_calls.is_empty()) =>
{
Ok(())
}
_ => Err(OpenAiError::invalid_request(
"messages",
"unsupported message content shape",
@ -695,6 +732,56 @@ fn validate_chat_message(message: &ChatMessage) -> Result<(), OpenAiError> {
}
}
fn validate_chat_tool_fields(message: &ChatMessage) -> Result<(), OpenAiError> {
if message.role == "tool" {
if message
.tool_call_id
.as_deref()
.is_none_or(|tool_call_id| tool_call_id.trim().is_empty())
{
return Err(OpenAiError::invalid_request(
"messages",
"tool messages require a tool_call_id",
));
}
} else if message.tool_call_id.is_some() {
return Err(OpenAiError::invalid_request(
"messages",
"tool_call_id is only supported on tool messages",
));
}
let Some(tool_calls) = &message.tool_calls else {
return Ok(());
};
if message.role != "assistant" {
return Err(OpenAiError::invalid_request(
"messages",
"tool_calls are only supported on assistant messages",
));
}
if tool_calls.is_empty() {
return Err(OpenAiError::invalid_request(
"messages",
"tool_calls must not be empty",
));
}
for tool_call in tool_calls {
if tool_call.id.trim().is_empty()
|| tool_call.kind != "function"
|| tool_call.function.name.trim().is_empty()
|| tool_call.function.arguments.trim().is_empty()
{
return Err(OpenAiError::invalid_request(
"messages",
"invalid assistant tool call",
));
}
}
Ok(())
}
fn validate_chat_message_part(part: &Value, role: &str) -> Result<(), OpenAiError> {
let Some(object) = part.as_object() else {
return Err(OpenAiError::invalid_request(

View file

@ -90,6 +90,44 @@ async fn chat_completions_accepts_supported_openai_compatible_fields() {
);
}
#[tokio::test]
async fn chat_completions_accepts_tool_call_history() {
let server = common::spawn_server().await.expect("server should start");
let response = server
.post_chat(json!({
"model": "gpt-test",
"messages": [
{ "role": "user", "content": "replace old with new" },
{
"role": "assistant",
"tool_calls": [{
"id": "call_edit",
"type": "function",
"function": {
"name": "edit_file",
"arguments": "{\"old\":\"old\",\"new\":\"new\"}"
}
}]
},
{
"role": "tool",
"content": "Updated data.txt",
"tool_call_id": "call_edit"
}
],
"stream": false
}))
.await;
assert_eq!(response.status(), 200);
let body = response.json::<serde_json::Value>().await.expect("json");
assert_eq!(
body["choices"][0]["message"]["content"],
"deterministic: replace old with new"
);
}
#[tokio::test]
async fn chat_completions_supports_scripted_tool_call_and_json_schema() {
let server = common::spawn_server().await.expect("server should start");