mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
fix(ai-formats): support OpenAI Responses custom tool/raw passthrough
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user