fix(ai-formats): support OpenAI Responses custom tool/raw passthrough

This commit is contained in:
zhefox
2026-06-17 04:24:56 +08:00
parent 16a4fd3687
commit 0f92ef664d
5 changed files with 336 additions and 44 deletions
@@ -11,14 +11,15 @@ use crate::{
canonical_response_format_to_openai_responses, canonical_tool_is_openai_custom,
canonical_tool_use_to_openai_responses_item, is_claude_messages_request,
is_claude_system_instruction, is_claude_thinking_block, is_claude_tool_result,
is_openai_thinking_block, media_data_or_url, namespace_extension_object,
openai_content_text, openai_extensions, openai_response_format_to_canonical,
openai_responses_extension, openai_responses_generation_config,
openai_responses_input_to_canonical_messages, openai_responses_tool_choice_to_canonical,
openai_responses_tools_to_canonical, openai_tool_choice_raw_to_responses,
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
is_openai_responses_input_message, is_openai_thinking_block, media_data_or_url,
namespace_extension_object, openai_content_text, openai_extensions,
openai_response_format_to_canonical, openai_responses_extension,
openai_responses_generation_config, openai_responses_input_to_canonical_messages,
openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
openai_tool_choice_raw_to_responses, CanonicalContentBlock, CanonicalInstruction,
CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice,
CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
@@ -307,6 +308,12 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
let role = match message.role {
CanonicalRole::Assistant => "assistant",
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
CanonicalRole::System if is_openai_responses_input_message(&message.extensions) => {
"system"
}
CanonicalRole::Developer if is_openai_responses_input_message(&message.extensions) => {
"developer"
}
CanonicalRole::System | CanonicalRole::Developer => continue,
};
let mut content = Vec::new();
@@ -806,14 +813,6 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
tool.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.filter(|value| {
value
.get("type")
.and_then(Value::as_str)
.is_some_and(|tool_type| {
tool_type == "custom" || tool_type.starts_with("web_search")
})
})
{
return raw.clone();
}
@@ -10,11 +10,12 @@ use crate::{
protocol::canonical::{
canonical_content_block_to_openai_responses_part, canonical_extension_object_mut,
canonical_tool_use_to_openai_responses_item, canonical_usage_to_openai_responses_usage,
flush_openai_responses_message_item, is_openai_thinking_block, namespace_extension_object,
openai_responses_extensions, openai_responses_output_to_canonical_blocks,
openai_usage_to_canonical, CanonicalContentBlock, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
flush_openai_responses_message_item, is_openai_responses_raw_block,
is_openai_thinking_block, namespace_extension_object, openai_responses_extensions,
openai_responses_output_to_canonical_blocks, openai_usage_to_canonical,
CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
CanonicalStopReason, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
@@ -273,6 +274,19 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
}
}
}
CanonicalContentBlock::Unknown {
payload,
extensions,
..
} if is_openai_responses_raw_block(extensions) => {
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
output.push(payload.clone());
}
CanonicalContentBlock::Unknown { .. } => {}
}
}
@@ -356,9 +356,38 @@ fn validate_response_conversion(
}
validate_source_response_stop_enums(source, target, body)?;
validate_response_content_has_no_unknown_blocks(source, target, response)?;
validate_canonical_response_stop_reasons(source, target, response)
}
fn validate_response_content_has_no_unknown_blocks(
source: FormatId,
target: FormatId,
response: &CanonicalResponse,
) -> Result<(), FormatError> {
for block in response.content.iter().chain(
response
.outputs
.iter()
.flat_map(|output| output.content.iter()),
) {
if let CanonicalContentBlock::Unknown { raw_type, .. } = block {
if raw_type == "refusal" {
continue;
}
return Err(FormatError::LossyConversionBlocked {
source_format: source.as_str().to_string(),
target_format: target.as_str().to_string(),
field: "output[].type".to_string(),
reason: format!(
"target format has no lossless mapping for unknown source output item type {raw_type:?}"
),
});
}
}
Ok(())
}
fn validate_known_standard_request_root_fields(
source: FormatId,
target: FormatId,
@@ -3130,6 +3159,44 @@ mod tests {
}
}
#[test]
fn pure_openai_responses_request_same_format_preserves_raw_tools_and_roles() {
let body = json!({
"model": "gpt-source",
"input": [
{
"type": "message",
"role": "developer",
"content": [{"type": "input_text", "text": "Use policy"}]
},
{"role": "user", "content": "hello"}
],
"tools": [
{
"type": "file_search",
"vector_store_ids": ["vs_123"],
"max_num_results": 3
},
{
"type": "mcp",
"server_label": "docs",
"server_url": "https://example.com/mcp"
}
]
});
let converted = convert_request_pure("openai:responses", "openai:responses", &body)
.expect("same-format Responses request should preserve official raw fields")
.value;
assert_eq!(converted["input"][0]["role"], "developer");
assert_eq!(converted["input"][0]["content"][0]["text"], "Use policy");
assert_eq!(converted["tools"][0]["type"], "file_search");
assert_eq!(converted["tools"][0]["vector_store_ids"][0], "vs_123");
assert_eq!(converted["tools"][1]["type"], "mcp");
assert_eq!(converted["tools"][1]["server_label"], "docs");
}
#[test]
fn pure_openai_chat_to_claude_blocks_target_unsupported_generation_field() {
let body = json!({
@@ -3453,6 +3520,72 @@ mod tests {
);
}
#[test]
fn pure_openai_responses_response_same_format_preserves_raw_output_items() {
let body = json!({
"id": "resp_raw_items",
"object": "response",
"model": "gpt-source",
"status": "completed",
"output": [
{
"type": "file_search_call",
"id": "fs_123",
"status": "completed",
"queries": ["rust"],
"results": [{"file_id": "file_123", "text": "Rust"}]
},
{
"type": "code_interpreter_call",
"id": "ci_123",
"status": "completed",
"code": "print('hi')",
"outputs": []
},
{
"type": "message",
"id": "msg_123",
"role": "assistant",
"content": [{"type": "output_text", "text": "done"}]
}
]
});
let converted = convert_response_pure("openai:responses", "openai:responses", &body)
.expect("same-format Responses response should preserve raw output items")
.value;
assert_eq!(converted["output"][0]["type"], "file_search_call");
assert_eq!(converted["output"][0]["results"][0]["file_id"], "file_123");
assert_eq!(converted["output"][1]["type"], "code_interpreter_call");
assert_eq!(converted["output"][2]["content"][0]["text"], "done");
}
#[test]
fn pure_openai_responses_response_cross_format_blocks_raw_output_items() {
let body = json!({
"id": "resp_raw_items",
"object": "response",
"model": "gpt-source",
"status": "completed",
"output": [{
"type": "mcp_call",
"id": "mcp_123",
"status": "completed",
"name": "lookup"
}]
});
let error = convert_response_pure("openai:responses", "openai:chat", &body)
.expect_err("cross-format raw Responses output items should fail closed");
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field == "output[].type"
));
}
#[test]
fn pure_claude_response_same_format_preserves_unknown_stop_reason() {
let body = json!({
@@ -1974,7 +1974,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
reasoning_states.entry(output_index).or_default(),
item,
),
"function_call" => {
"function_call" | "custom_tool_call" => {
merge_openai_responses_tool_item(
tool_states.entry(output_index).or_default(),
item,
@@ -1991,7 +1991,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
_ => {}
}
}
"response.function_call_arguments.delta" => {
"response.function_call_arguments.delta" | "response.custom_tool_call_input.delta" => {
let Some(output_index) =
resolve_openai_responses_tool_output_index(event_object, &item_output_indexes)
else {
@@ -2004,18 +2004,43 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
if delta.is_empty() {
continue;
}
tool_states
.entry(output_index)
.or_default()
.arguments
.push_str(delta);
let state = tool_states.entry(output_index).or_default();
if event_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.starts_with("response.custom_tool_call_input."))
{
state
.item
.entry("type".to_string())
.or_insert_with(|| Value::String("custom_tool_call".to_string()));
if let Some(name) = event_object.get("name").and_then(Value::as_str) {
state
.item
.entry("name".to_string())
.or_insert_with(|| Value::String(name.to_string()));
}
if let Some(item_id) = event_object.get("item_id").and_then(Value::as_str) {
state
.item
.entry("id".to_string())
.or_insert_with(|| Value::String(item_id.to_string()));
}
if let Some(call_id) = event_object.get("call_id").and_then(Value::as_str) {
state
.item
.entry("call_id".to_string())
.or_insert_with(|| Value::String(call_id.to_string()));
}
}
state.arguments.push_str(delta);
register_openai_responses_tool_event_aliases(
&mut item_output_indexes,
event_object,
output_index,
);
}
"response.function_call_arguments.done" => {
"response.function_call_arguments.done" | "response.custom_tool_call_input.done" => {
let Some(output_index) =
resolve_openai_responses_tool_output_index(event_object, &item_output_indexes)
else {
@@ -2032,14 +2057,44 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
item,
);
}
if event_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.starts_with("response.custom_tool_call_input."))
{
let state = tool_states.entry(output_index).or_default();
state
.item
.entry("type".to_string())
.or_insert_with(|| Value::String("custom_tool_call".to_string()));
if let Some(name) = event_object.get("name").and_then(Value::as_str) {
state
.item
.entry("name".to_string())
.or_insert_with(|| Value::String(name.to_string()));
}
if let Some(item_id) = event_object.get("item_id").and_then(Value::as_str) {
state
.item
.entry("id".to_string())
.or_insert_with(|| Value::String(item_id.to_string()));
}
if let Some(call_id) = event_object.get("call_id").and_then(Value::as_str) {
state
.item
.entry("call_id".to_string())
.or_insert_with(|| Value::String(call_id.to_string()));
}
}
let arguments = event_object
.get("arguments")
.or_else(|| event_object.get("input"))
.and_then(Value::as_str)
.or_else(|| {
event_object
.get("item")
.and_then(Value::as_object)
.and_then(|item| item.get("arguments"))
.and_then(|item| item.get("arguments").or_else(|| item.get("input")))
.and_then(Value::as_str)
})
.unwrap_or_default();
@@ -2076,7 +2131,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
reasoning_states.entry(output_index).or_default(),
item,
),
"function_call" => {
"function_call" | "custom_tool_call" => {
merge_openai_responses_tool_item(
tool_states.entry(output_index).or_default(),
item,
@@ -2644,6 +2699,12 @@ fn materialize_openai_responses_tool_item(
state: OpenAIResponsesSyncToolState,
) -> Value {
let mut item = state.item;
let item_type = item
.get("type")
.and_then(Value::as_str)
.filter(|value| *value == "custom_tool_call")
.unwrap_or("function_call")
.to_string();
let generated_id = format!("call_auto_{output_index}");
let call_id = item
.get("call_id")
@@ -2660,10 +2721,7 @@ fn materialize_openai_responses_tool_item(
})
.unwrap_or(generated_id.clone());
item.insert(
"type".to_string(),
Value::String("function_call".to_string()),
);
item.insert("type".to_string(), Value::String(item_type.clone()));
item.entry("id".to_string())
.or_insert_with(|| Value::String(call_id.clone()));
item.insert("call_id".to_string(), Value::String(call_id));
@@ -2672,9 +2730,19 @@ fn materialize_openai_responses_tool_item(
item.entry("status".to_string())
.or_insert_with(|| Value::String("completed".to_string()));
if !state.arguments.is_empty() {
item.insert("arguments".to_string(), Value::String(state.arguments));
let argument_key = if item_type == "custom_tool_call" {
"input"
} else {
"arguments"
};
item.insert(argument_key.to_string(), Value::String(state.arguments));
} else {
item.entry("arguments".to_string())
let argument_key = if item_type == "custom_tool_call" {
"input"
} else {
"arguments"
};
item.entry(argument_key.to_string())
.or_insert_with(|| Value::String(String::new()));
}
Value::Object(item)
@@ -4289,6 +4357,27 @@ mod tests {
assert_eq!(result["output"][0]["arguments"], r#"{"location": "Tokyo"}"#);
}
#[test]
fn custom_tool_call_input_events_materialize_custom_tool_call() {
let body = concat!(
"event: response.custom_tool_call_input.delta\n",
"data: {\"type\":\"response.custom_tool_call_input.delta\",\"output_index\":0,\"item_id\":\"ctc_123\",\"name\":\"code_exec\",\"delta\":\"print\"}\n\n",
"event: response.custom_tool_call_input.done\n",
"data: {\"type\":\"response.custom_tool_call_input.done\",\"output_index\":0,\"item_id\":\"ctc_123\",\"call_id\":\"call_custom_123\",\"name\":\"code_exec\",\"input\":\"print('hi')\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_custom_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[]}}\n\n",
);
let result = aggregate_openai_responses_stream_sync_response(body.as_bytes())
.expect("custom tool stream should aggregate into a sync body");
assert_eq!(result["output"][0]["type"], "custom_tool_call");
assert_eq!(result["output"][0]["call_id"], "call_custom_123");
assert_eq!(result["output"][0]["name"], "code_exec");
assert_eq!(result["output"][0]["input"], "print('hi')");
assert!(result["output"][0].get("arguments").is_none());
}
#[test]
fn aggregates_modern_reasoning_text_and_response_done_alias() {
let body = concat!(
@@ -22,6 +22,8 @@ const OPENAI_CUSTOM_TOOL_CALL_SOURCE_MARKER: &str = "openai_custom_tool_call";
const OPENAI_OUTPUT_AUDIO_SOURCE_MARKER: &str = "openai_output_audio";
const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_tool_result";
const OPENAI_RESPONSES_TOOL_RESULT_SOURCE_MARKER: &str = "openai_responses_tool_result";
const OPENAI_RESPONSES_INPUT_MESSAGE_SOURCE_MARKER: &str = "openai_responses_input_message";
const OPENAI_RESPONSES_RAW_SOURCE_MARKER: &str = "openai_responses_raw";
const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -1879,16 +1881,18 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
if matches!(role, CanonicalRole::System | CanonicalRole::Developer) {
let text = openai_content_text(item_object.get("content"));
if !text.trim().is_empty() {
let mut extensions = openai_responses_extensions(
item_object,
&["type", "role", "content"],
);
mark_openai_responses_input_message(&mut extensions);
messages.push(CanonicalMessage {
role,
content: vec![CanonicalContentBlock::Text {
text,
extensions: BTreeMap::new(),
}],
extensions: openai_responses_extensions(
item_object,
&["type", "role", "content"],
),
extensions,
});
}
pending_reasoning = None;
@@ -2235,7 +2239,7 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
blocks.push(CanonicalContentBlock::Unknown {
raw_type: String::new(),
payload: item.clone(),
extensions: BTreeMap::new(),
extensions: openai_responses_raw_extensions(BTreeMap::new()),
});
continue;
};
@@ -2306,7 +2310,7 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
blocks.push(CanonicalContentBlock::Unknown {
raw_type: item_type,
payload: item.clone(),
extensions: BTreeMap::new(),
extensions: openai_responses_raw_extensions(BTreeMap::new()),
});
}
}
@@ -2466,7 +2470,7 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
_ => blocks.push(CanonicalContentBlock::Unknown {
raw_type: item_type,
payload: item.clone(),
extensions: BTreeMap::new(),
extensions: openai_responses_raw_extensions(BTreeMap::new()),
}),
}
}
@@ -3207,6 +3211,16 @@ fn claude_raw_extensions(mut extensions: BTreeMap<String, Value>) -> BTreeMap<St
extensions
}
fn openai_responses_raw_extensions(
mut extensions: BTreeMap<String, Value>,
) -> BTreeMap<String, Value> {
canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(),
Value::String(OPENAI_RESPONSES_RAW_SOURCE_MARKER.to_string()),
);
extensions
}
fn openai_thinking_extensions(mut extensions: BTreeMap<String, Value>) -> BTreeMap<String, Value> {
canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(),
@@ -3229,6 +3243,13 @@ fn mark_openai_output_audio(extensions: &mut BTreeMap<String, Value>) {
);
}
fn mark_openai_responses_input_message(extensions: &mut BTreeMap<String, Value>) {
canonical_extension_object_mut(extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(),
Value::String(OPENAI_RESPONSES_INPUT_MESSAGE_SOURCE_MARKER.to_string()),
);
}
pub(crate) fn is_claude_thinking_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
@@ -3245,6 +3266,22 @@ fn is_claude_raw_block(extensions: &BTreeMap<String, Value>) -> bool {
== Some(CLAUDE_RAW_SOURCE_MARKER)
}
pub(crate) fn is_openai_responses_raw_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
== Some(OPENAI_RESPONSES_RAW_SOURCE_MARKER)
}
pub(crate) fn is_openai_responses_input_message(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
== Some(OPENAI_RESPONSES_INPUT_MESSAGE_SOURCE_MARKER)
}
pub(crate) fn is_openai_thinking_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
@@ -4542,6 +4579,26 @@ pub(crate) fn openai_responses_tools_to_canonical(
tool.clone(),
)]),
});
} else {
let name = tool_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(tool_type.as_str());
canonical.push(CanonicalToolDefinition {
name: name.to_string(),
description: tool_object
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: None,
strict: None,
extensions: BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
tool.clone(),
)]),
});
}
}
Some(canonical)