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_response_format_to_openai_responses, canonical_tool_is_openai_custom,
canonical_tool_use_to_openai_responses_item, is_claude_messages_request, canonical_tool_use_to_openai_responses_item, is_claude_messages_request,
is_claude_system_instruction, is_claude_thinking_block, is_claude_tool_result, is_claude_system_instruction, is_claude_thinking_block, is_claude_tool_result,
is_openai_thinking_block, media_data_or_url, namespace_extension_object, is_openai_responses_input_message, is_openai_thinking_block, media_data_or_url,
openai_content_text, openai_extensions, openai_response_format_to_canonical, namespace_extension_object, openai_content_text, openai_extensions,
openai_responses_extension, openai_responses_generation_config, openai_response_format_to_canonical, openai_responses_extension,
openai_responses_input_to_canonical_messages, openai_responses_tool_choice_to_canonical, openai_responses_generation_config, openai_responses_input_to_canonical_messages,
openai_responses_tools_to_canonical, openai_tool_choice_raw_to_responses, openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole, openai_tool_choice_raw_to_responses, CanonicalContentBlock, CanonicalInstruction,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, 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 { let role = match message.role {
CanonicalRole::Assistant => "assistant", CanonicalRole::Assistant => "assistant",
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user", 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, CanonicalRole::System | CanonicalRole::Developer => continue,
}; };
let mut content = Vec::new(); let mut content = Vec::new();
@@ -806,14 +813,6 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
tool.extensions tool.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE) .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(); return raw.clone();
} }
@@ -10,11 +10,12 @@ use crate::{
protocol::canonical::{ protocol::canonical::{
canonical_content_block_to_openai_responses_part, canonical_extension_object_mut, canonical_content_block_to_openai_responses_part, canonical_extension_object_mut,
canonical_tool_use_to_openai_responses_item, canonical_usage_to_openai_responses_usage, 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, flush_openai_responses_message_item, is_openai_responses_raw_block,
openai_responses_extensions, openai_responses_output_to_canonical_blocks, is_openai_thinking_block, namespace_extension_object, openai_responses_extensions,
openai_usage_to_canonical, CanonicalContentBlock, CanonicalResponse, openai_responses_output_to_canonical_blocks, openai_usage_to_canonical,
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason, CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, 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 { .. } => {} CanonicalContentBlock::Unknown { .. } => {}
} }
} }
@@ -356,9 +356,38 @@ fn validate_response_conversion(
} }
validate_source_response_stop_enums(source, target, body)?; 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) 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( fn validate_known_standard_request_root_fields(
source: FormatId, source: FormatId,
target: 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] #[test]
fn pure_openai_chat_to_claude_blocks_target_unsupported_generation_field() { fn pure_openai_chat_to_claude_blocks_target_unsupported_generation_field() {
let body = json!({ 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] #[test]
fn pure_claude_response_same_format_preserves_unknown_stop_reason() { fn pure_claude_response_same_format_preserves_unknown_stop_reason() {
let body = json!({ 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(), reasoning_states.entry(output_index).or_default(),
item, item,
), ),
"function_call" => { "function_call" | "custom_tool_call" => {
merge_openai_responses_tool_item( merge_openai_responses_tool_item(
tool_states.entry(output_index).or_default(), tool_states.entry(output_index).or_default(),
item, 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) = let Some(output_index) =
resolve_openai_responses_tool_output_index(event_object, &item_output_indexes) resolve_openai_responses_tool_output_index(event_object, &item_output_indexes)
else { else {
@@ -2004,18 +2004,43 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
if delta.is_empty() { if delta.is_empty() {
continue; continue;
} }
tool_states let state = tool_states.entry(output_index).or_default();
.entry(output_index) if event_object
.or_default() .get("type")
.arguments .and_then(Value::as_str)
.push_str(delta); .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( register_openai_responses_tool_event_aliases(
&mut item_output_indexes, &mut item_output_indexes,
event_object, event_object,
output_index, output_index,
); );
} }
"response.function_call_arguments.done" => { "response.function_call_arguments.done" | "response.custom_tool_call_input.done" => {
let Some(output_index) = let Some(output_index) =
resolve_openai_responses_tool_output_index(event_object, &item_output_indexes) resolve_openai_responses_tool_output_index(event_object, &item_output_indexes)
else { else {
@@ -2032,14 +2057,44 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
item, 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 let arguments = event_object
.get("arguments") .get("arguments")
.or_else(|| event_object.get("input"))
.and_then(Value::as_str) .and_then(Value::as_str)
.or_else(|| { .or_else(|| {
event_object event_object
.get("item") .get("item")
.and_then(Value::as_object) .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) .and_then(Value::as_str)
}) })
.unwrap_or_default(); .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(), reasoning_states.entry(output_index).or_default(),
item, item,
), ),
"function_call" => { "function_call" | "custom_tool_call" => {
merge_openai_responses_tool_item( merge_openai_responses_tool_item(
tool_states.entry(output_index).or_default(), tool_states.entry(output_index).or_default(),
item, item,
@@ -2644,6 +2699,12 @@ fn materialize_openai_responses_tool_item(
state: OpenAIResponsesSyncToolState, state: OpenAIResponsesSyncToolState,
) -> Value { ) -> Value {
let mut item = state.item; 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 generated_id = format!("call_auto_{output_index}");
let call_id = item let call_id = item
.get("call_id") .get("call_id")
@@ -2660,10 +2721,7 @@ fn materialize_openai_responses_tool_item(
}) })
.unwrap_or(generated_id.clone()); .unwrap_or(generated_id.clone());
item.insert( item.insert("type".to_string(), Value::String(item_type.clone()));
"type".to_string(),
Value::String("function_call".to_string()),
);
item.entry("id".to_string()) item.entry("id".to_string())
.or_insert_with(|| Value::String(call_id.clone())); .or_insert_with(|| Value::String(call_id.clone()));
item.insert("call_id".to_string(), Value::String(call_id)); 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()) item.entry("status".to_string())
.or_insert_with(|| Value::String("completed".to_string())); .or_insert_with(|| Value::String("completed".to_string()));
if !state.arguments.is_empty() { 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 { } 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())); .or_insert_with(|| Value::String(String::new()));
} }
Value::Object(item) Value::Object(item)
@@ -4289,6 +4357,27 @@ mod tests {
assert_eq!(result["output"][0]["arguments"], r#"{"location": "Tokyo"}"#); 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] #[test]
fn aggregates_modern_reasoning_text_and_response_done_alias() { fn aggregates_modern_reasoning_text_and_response_done_alias() {
let body = concat!( 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_OUTPUT_AUDIO_SOURCE_MARKER: &str = "openai_output_audio";
const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_tool_result"; 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_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]"; const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[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) { if matches!(role, CanonicalRole::System | CanonicalRole::Developer) {
let text = openai_content_text(item_object.get("content")); let text = openai_content_text(item_object.get("content"));
if !text.trim().is_empty() { 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 { messages.push(CanonicalMessage {
role, role,
content: vec![CanonicalContentBlock::Text { content: vec![CanonicalContentBlock::Text {
text, text,
extensions: BTreeMap::new(), extensions: BTreeMap::new(),
}], }],
extensions: openai_responses_extensions( extensions,
item_object,
&["type", "role", "content"],
),
}); });
} }
pending_reasoning = None; pending_reasoning = None;
@@ -2235,7 +2239,7 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
blocks.push(CanonicalContentBlock::Unknown { blocks.push(CanonicalContentBlock::Unknown {
raw_type: String::new(), raw_type: String::new(),
payload: item.clone(), payload: item.clone(),
extensions: BTreeMap::new(), extensions: openai_responses_raw_extensions(BTreeMap::new()),
}); });
continue; continue;
}; };
@@ -2306,7 +2310,7 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
blocks.push(CanonicalContentBlock::Unknown { blocks.push(CanonicalContentBlock::Unknown {
raw_type: item_type, raw_type: item_type,
payload: item.clone(), 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 { _ => blocks.push(CanonicalContentBlock::Unknown {
raw_type: item_type, raw_type: item_type,
payload: item.clone(), 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 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> { fn openai_thinking_extensions(mut extensions: BTreeMap<String, Value>) -> BTreeMap<String, Value> {
canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert( canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(), "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 { pub(crate) fn is_claude_thinking_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions extensions
.get(AETHER_EXTENSION_NAMESPACE) .get(AETHER_EXTENSION_NAMESPACE)
@@ -3245,6 +3266,22 @@ fn is_claude_raw_block(extensions: &BTreeMap<String, Value>) -> bool {
== Some(CLAUDE_RAW_SOURCE_MARKER) == 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 { pub(crate) fn is_openai_thinking_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions extensions
.get(AETHER_EXTENSION_NAMESPACE) .get(AETHER_EXTENSION_NAMESPACE)
@@ -4542,6 +4579,26 @@ pub(crate) fn openai_responses_tools_to_canonical(
tool.clone(), 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) Some(canonical)