From e2b003af24581a3b07a69826a91b22071c7d8d12 Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:43:50 +0800 Subject: [PATCH] fix(formats): preserve Responses namespace tools through Chat --- .../src/formats/openai/chat/request.rs | 741 +++++++++++++++++- .../formats/src/formats/openai/chat/stream.rs | 344 ++++++-- .../formats/src/formats/openai/mod.rs | 1 + .../formats/src/formats/openai/namespace.rs | 605 ++++++++++++++ .../src/formats/openai/responses/response.rs | 87 +- .../aether-ai/formats/src/formats/registry.rs | 645 ++++++++++++++- .../shared/stream_core/format_matrix.rs | 158 +++- .../src/formats/shared/sync_to_stream.rs | 92 ++- docs/api/format-field-coverage-matrix.md | 12 +- 9 files changed, 2586 insertions(+), 99 deletions(-) create mode 100644 crates/aether-ai/formats/src/formats/openai/namespace.rs diff --git a/crates/aether-ai/formats/src/formats/openai/chat/request.rs b/crates/aether-ai/formats/src/formats/openai/chat/request.rs index d2d809f43..b408933f4 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/request.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/request.rs @@ -1,7 +1,7 @@ use serde_json::{json, Map, Value}; use crate::{ - formats::context::FormatContext, + formats::{context::FormatContext, openai::namespace::NamespaceToolAliases}, protocol::canonical::{ canonical_extension_object_mut, canonical_message_to_openai_chat_messages, canonical_response_format_to_openai, canonical_tool_choice_to_openai, @@ -11,8 +11,8 @@ use crate::{ openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical, openai_tool_choice_raw_to_chat, openai_tool_choice_to_canonical, openai_tools_to_canonical, write_openai_generation_config, CanonicalContentBlock, CanonicalInstruction, - CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice, - CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE, + CanonicalMessage, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, + CanonicalToolChoice, CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, }, }; @@ -22,14 +22,27 @@ pub fn from(body: &Value, _ctx: &FormatContext) -> Option { } pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option { - if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(request) { - return None; - } - let mut body = to_raw(request); + let mut body = to_raw(request)?; force_stream_options(&mut body, ctx.upstream_is_stream); Some(body) } +pub(crate) fn to_raw(canonical: &CanonicalRequest) -> Option { + let namespace_tool_aliases = NamespaceToolAliases::from_canonical_tools(&canonical.tools); + if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(canonical) + || canonical_request_has_unrepresentable_namespace_tools_for_openai_chat( + canonical, + &namespace_tool_aliases, + ) + { + return None; + } + Some(to_raw_with_namespace_aliases( + canonical, + &namespace_tool_aliases, + )) +} + pub fn from_raw(body_json: &Value) -> Option { let request = body_json.as_object()?; let mut canonical = CanonicalRequest { @@ -135,7 +148,10 @@ pub fn from_raw(body_json: &Value) -> Option { Some(canonical) } -pub fn to_raw(canonical: &CanonicalRequest) -> Value { +fn to_raw_with_namespace_aliases( + canonical: &CanonicalRequest, + namespace_tool_aliases: &NamespaceToolAliases, +) -> Value { let mut output = serde_json::Map::new(); if !canonical.model.trim().is_empty() { output.insert("model".to_string(), Value::String(canonical.model.clone())); @@ -155,24 +171,31 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value { } } for message in &canonical.messages { - messages.extend(canonical_message_to_openai_chat_messages(message)); + let mut message = message.clone(); + rewrite_namespaced_tool_uses_for_openai_chat(&mut message, namespace_tool_aliases); + messages.extend(canonical_message_to_openai_chat_messages(&message)); } output.insert("messages".to_string(), Value::Array(messages)); write_openai_generation_config(&mut output, &canonical.generation); if !canonical.tools.is_empty() { - output.insert( - "tools".to_string(), - Value::Array( - canonical - .tools - .iter() - .map(canonical_tool_to_openai) - .collect(), - ), - ); + let mut tools = Vec::new(); + for (tool_index, tool) in canonical.tools.iter().enumerate() { + if namespace_tool_aliases.is_representable_namespace_tool(tool_index) { + tools.extend( + namespace_tool_aliases + .tools_for_source(tool_index) + .map(|tool| tool.to_openai_chat_tool()), + ); + } else { + tools.push(canonical_tool_to_openai(tool)); + } + } + output.insert("tools".to_string(), Value::Array(tools)); } - if let Some(tool_choice) = canonical_tool_choice_to_openai_for_request(canonical) { + if let Some(tool_choice) = + canonical_tool_choice_to_openai_for_request(canonical, namespace_tool_aliases) + { output.insert("tool_choice".to_string(), tool_choice); } if let Some(value) = canonical.parallel_tool_calls { @@ -224,12 +247,53 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value { Value::Object(output) } -fn canonical_tool_choice_to_openai_for_request(canonical: &CanonicalRequest) -> Option { +fn canonical_tool_choice_to_openai_for_request( + canonical: &CanonicalRequest, + namespace_tool_aliases: &NamespaceToolAliases, +) -> Option { canonical .tool_choice .as_ref() - .map(|tool_choice| canonical_tool_choice_to_openai_for_tools(tool_choice, &canonical.tools)) - .or_else(|| raw_tool_choice_extension(canonical).map(openai_tool_choice_raw_to_chat)) + .map(|tool_choice| { + if let CanonicalToolChoice::Tool { name } = tool_choice { + if let NamespaceNameResolution::Alias(alias) = + resolve_namespace_child_name(name, &canonical.tools, namespace_tool_aliases) + { + return json!({ + "type": "function", + "function": { "name": alias }, + }); + } + } + canonical_tool_choice_to_openai_for_tools(tool_choice, &canonical.tools) + }) + .or_else(|| { + raw_tool_choice_extension(canonical).and_then(|raw| { + raw_tool_choice_to_openai_chat_for_request( + raw, + &canonical.tools, + namespace_tool_aliases, + ) + }) + }) +} + +fn raw_tool_choice_to_openai_chat_for_request( + raw: &Value, + tools: &[CanonicalToolDefinition], + aliases: &NamespaceToolAliases, +) -> Option { + let mut choice = openai_tool_choice_raw_to_chat(raw); + rewrite_namespace_tool_choice_names(&mut choice, tools, aliases).then_some(choice) +} + +pub(crate) fn raw_tool_choice_extension_is_representable_for_openai_chat( + canonical: &CanonicalRequest, +) -> bool { + let aliases = NamespaceToolAliases::from_canonical_tools(&canonical.tools); + raw_tool_choice_extension(canonical).is_some_and(|raw| { + raw_tool_choice_to_openai_chat_for_request(raw, &canonical.tools, &aliases).is_some() + }) } fn canonical_tool_choice_to_openai_for_tools( @@ -251,6 +315,293 @@ fn canonical_tool_choice_to_openai_for_tools( } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NamespaceNameResolution<'a> { + Unchanged, + Alias(&'a str), + Ambiguous, +} + +fn resolve_namespace_child_name<'a>( + name: &'a str, + tools: &[CanonicalToolDefinition], + aliases: &'a NamespaceToolAliases, +) -> NamespaceNameResolution<'a> { + let namespace_children = aliases.namespace_children_named(name).collect::>(); + let ordinary_matches = tools + .iter() + .enumerate() + .filter(|(index, tool)| !aliases.is_namespace_tool(*index) && tool.name == name) + .count(); + let namespace_parent_matches = tools + .iter() + .enumerate() + .filter(|(index, tool)| aliases.is_namespace_tool(*index) && tool.name == name) + .count(); + + match ( + namespace_children.as_slice(), + ordinary_matches, + namespace_parent_matches, + ) { + ([], 0, 0) | ([], 1, _) => NamespaceNameResolution::Unchanged, + ([], 0, _) => NamespaceNameResolution::Ambiguous, + ([child], 0, _) => NamespaceNameResolution::Alias(child.chat_name.as_str()), + _ => NamespaceNameResolution::Ambiguous, + } +} + +fn rewrite_namespace_tool_choice_names( + choice: &mut Value, + tools: &[CanonicalToolDefinition], + aliases: &NamespaceToolAliases, +) -> bool { + if let Some(choice) = choice.as_str() { + return matches!(choice, "none" | "auto" | "required"); + } + let Some(choice) = choice.as_object_mut() else { + return false; + }; + let choice_type = choice + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + match choice_type { + "function" => { + if !object_has_exact_keys(choice, &["type", "function"]) { + return false; + } + choice + .get_mut("function") + .and_then(Value::as_object_mut) + .is_some_and(|function| { + object_has_exact_keys(function, &["name"]) + && rewrite_named_function_choice(function, tools, aliases) + }) + } + "custom" => { + object_has_exact_keys(choice, &["type", "custom"]) + && choice + .get("custom") + .and_then(Value::as_object) + .is_some_and(|custom| { + object_has_exact_keys(custom, &["name"]) && valid_named_choice(custom) + }) + } + "allowed_tools" => { + if !object_has_exact_keys(choice, &["type", "allowed_tools"]) { + return false; + } + let Some(allowed) = choice + .get_mut("allowed_tools") + .and_then(Value::as_object_mut) + else { + return false; + }; + if !object_has_exact_keys(allowed, &["mode", "tools"]) + || !allowed + .get("mode") + .and_then(Value::as_str) + .is_some_and(|mode| matches!(mode, "auto" | "required")) + { + return false; + } + let Some(allowed_tools) = allowed.get_mut("tools").and_then(Value::as_array_mut) else { + return false; + }; + allowed_tools.iter_mut().all(|tool| { + let Some(tool) = tool.as_object_mut() else { + return false; + }; + match tool.get("type").and_then(Value::as_str) { + Some("function") => { + object_has_exact_keys(tool, &["type", "function"]) + && tool + .get_mut("function") + .and_then(Value::as_object_mut) + .is_some_and(|function| { + object_has_exact_keys(function, &["name"]) + && rewrite_named_function_choice(function, tools, aliases) + }) + } + Some("custom") => { + object_has_exact_keys(tool, &["type", "custom"]) + && tool + .get("custom") + .and_then(Value::as_object) + .is_some_and(|custom| { + object_has_exact_keys(custom, &["name"]) + && valid_named_choice(custom) + }) + } + _ => false, + } + }) + } + _ => false, + } +} + +fn object_has_exact_keys(object: &Map, keys: &[&str]) -> bool { + object.len() == keys.len() && object.keys().all(|key| keys.contains(&key.as_str())) +} + +fn valid_named_choice(choice: &Map) -> bool { + choice + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|name| !name.is_empty()) +} + +fn rewrite_named_function_choice( + function: &mut Map, + tools: &[CanonicalToolDefinition], + aliases: &NamespaceToolAliases, +) -> bool { + let Some(name) = function + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + else { + return false; + }; + match resolve_namespace_child_name(&name, tools, aliases) { + NamespaceNameResolution::Unchanged => true, + NamespaceNameResolution::Alias(alias) => { + function.insert("name".to_string(), Value::String(alias.to_string())); + true + } + NamespaceNameResolution::Ambiguous => false, + } +} + +fn rewrite_namespaced_tool_uses_for_openai_chat( + message: &mut CanonicalMessage, + aliases: &NamespaceToolAliases, +) { + for block in &mut message.content { + let CanonicalContentBlock::ToolUse { + name, extensions, .. + } = block + else { + continue; + }; + let NamespaceField::Name(namespace) = namespace_field(extensions) else { + continue; + }; + if let Some(alias) = aliases.chat_name(namespace, name) { + *name = alias.to_string(); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NamespaceField<'a> { + Absent, + Name(&'a str), + Invalid, +} + +fn namespace_field(extensions: &std::collections::BTreeMap) -> NamespaceField<'_> { + let Some(responses) = openai_responses_extension(extensions).and_then(Value::as_object) else { + return NamespaceField::Absent; + }; + let Some(namespace) = responses.get("namespace") else { + return NamespaceField::Absent; + }; + namespace + .as_str() + .map(str::trim) + .filter(|namespace| !namespace.is_empty()) + .map(NamespaceField::Name) + .unwrap_or(NamespaceField::Invalid) +} + +fn canonical_request_has_unrepresentable_namespace_tools_for_openai_chat( + request: &CanonicalRequest, + aliases: &NamespaceToolAliases, +) -> bool { + if aliases.has_invalid_namespace_tools() { + return true; + } + + if let Some(CanonicalToolChoice::Tool { name }) = &request.tool_choice { + if resolve_namespace_child_name(name, &request.tools, aliases) + == NamespaceNameResolution::Ambiguous + { + return true; + } + } else if let Some(raw) = raw_tool_choice_extension(request) { + let mut choice = openai_tool_choice_raw_to_chat(raw); + if !rewrite_namespace_tool_choice_names(&mut choice, &request.tools, aliases) { + return true; + } + } + + request.messages.iter().any(|message| { + message.content.iter().any(|block| { + let CanonicalContentBlock::ToolUse { + name, extensions, .. + } = block + else { + return false; + }; + match namespace_field(extensions) { + NamespaceField::Absent => false, + NamespaceField::Invalid => true, + NamespaceField::Name(namespace) => { + aliases.chat_name(namespace, name).is_none() + || !namespace_tool_use_sidecars_are_chat_representable( + extensions, namespace, + ) + } + } + }) + }) +} + +fn namespace_tool_use_sidecars_are_chat_representable( + extensions: &std::collections::BTreeMap, + expected_namespace: &str, +) -> bool { + [ + OPENAI_RESPONSES_EXTENSION_NAMESPACE, + OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, + ] + .into_iter() + .filter_map(|provider_namespace| extensions.get(provider_namespace)) + .all(|provider_fields| { + let Some(provider_fields) = provider_fields.as_object() else { + return false; + }; + if !provider_fields + .keys() + .all(|key| matches!(key.as_str(), "namespace" | "item_id" | "status")) + { + return false; + } + let namespace_is_consistent = provider_fields.get("namespace").is_none_or(|namespace| { + namespace + .as_str() + .map(str::trim) + .is_some_and(|namespace| namespace == expected_namespace) + }); + let item_id_is_valid = provider_fields.get("item_id").is_none_or(|item_id| { + item_id + .as_str() + .map(str::trim) + .is_some_and(|item_id| !item_id.is_empty()) + }); + let status_is_discardable = provider_fields + .get("status") + .is_none_or(|status| status.as_str() == Some("completed")); + namespace_is_consistent && item_id_is_valid && status_is_discardable + }) +} + fn raw_tool_choice_extension(canonical: &CanonicalRequest) -> Option<&Value> { canonical .extensions @@ -415,3 +766,347 @@ fn force_stream_options(body: &mut Value, upstream_is_stream: bool) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::formats::openai::responses; + + #[test] + fn responses_namespace_expands_definition_and_maps_history_and_named_choice() { + let body = json!({ + "model": "gpt-source", + "input": [ + { + "type": "function_call", + "id": "fc_report", + "call_id": "call_report", + "namespace": "mcp__reports", + "name": "write_report", + "arguments": "{\"report_path\":\"reports/finding.md\"}" + }, + { + "type": "function_call_output", + "call_id": "call_report", + "output": "created" + } + ], + "tools": [{ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "description": "Create a report", + "parameters": { + "type": "object", + "properties": {"report_path": {"type": "string"}}, + "required": ["report_path"], + "additionalProperties": false + }, + "strict": true + }] + }], + "tool_choice": {"type": "function", "name": "write_report"} + }); + let canonical = responses::request::from_raw(&body).expect("Responses request"); + let chat = to(&canonical, &FormatContext::default()).expect("Chat request"); + + assert_eq!(chat["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(chat["tools"][0]["function"]["name"], "write_report"); + assert_eq!( + chat["tools"][0]["function"]["parameters"], + body["tools"][0]["tools"][0]["parameters"] + ); + assert_eq!(chat["tools"][0]["function"]["strict"], true); + assert_eq!(chat["tool_choice"]["function"]["name"], "write_report"); + let historical_call = chat["messages"] + .as_array() + .and_then(|messages| { + messages + .iter() + .find(|message| message.get("tool_calls").is_some()) + }) + .expect("historical tool call"); + assert_eq!( + historical_call["tool_calls"][0]["function"]["name"], + "write_report" + ); + } + + #[test] + fn responses_namespace_named_choice_fails_closed_when_child_name_is_ambiguous() { + let body = json!({ + "model": "gpt-source", + "input": "write it", + "tools": [ + { + "type": "namespace", + "name": "first", + "description": "First tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + }, + { + "type": "namespace", + "name": "second", + "description": "Second tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + } + ], + "tool_choice": {"type": "function", "name": "write_report"} + }); + let canonical = responses::request::from_raw(&body).expect("Responses request"); + + assert!(to(&canonical, &FormatContext::default()).is_none()); + } + + #[test] + fn responses_namespace_named_choice_allows_parent_and_child_to_share_a_name() { + let body = json!({ + "model": "gpt-source", + "input": "write it", + "tools": [{ + "type": "namespace", + "name": "reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "reports", + "parameters": {"type": "object"} + }] + }], + "tool_choice": {"type": "function", "name": "reports"} + }); + let canonical = responses::request::from_raw(&body).expect("Responses request"); + let chat = to(&canonical, &FormatContext::default()).expect("Chat request"); + + assert_eq!(chat["tools"][0]["function"]["name"], "reports"); + assert_eq!(chat["tool_choice"]["function"]["name"], "reports"); + } + + #[test] + fn responses_namespace_allowed_tools_choice_uses_the_expanded_alias() { + let long_name = format!("write_report_{}", "x".repeat(80)); + let body = json!({ + "model": "gpt-source", + "input": "write it", + "tools": [{ + "type": "namespace", + "name": "reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": long_name, + "parameters": {"type": "object"} + }] + }], + "tool_choice": { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": long_name}] + } + }); + let canonical = responses::request::from_raw(&body).expect("Responses request"); + let chat = to(&canonical, &FormatContext::default()).expect("Chat request"); + let definition_alias = chat["tools"][0]["function"]["name"] + .as_str() + .expect("definition alias"); + let choice_alias = chat["tool_choice"]["allowed_tools"]["tools"][0]["function"]["name"] + .as_str() + .expect("choice alias"); + + assert_eq!(choice_alias, definition_alias); + assert!(definition_alias.len() <= 64); + } + + #[test] + fn responses_namespace_tool_choices_fail_closed_when_the_shape_is_malformed() { + let choices = [ + json!({"type": "function"}), + json!({ + "type": "allowed_tools", + "tools": [{"type": "function", "name": "write_report"}] + }), + json!({"type": "allowed_tools", "mode": "required"}), + json!({ + "type": "allowed_tools", + "mode": "required", + "tools": [42] + }), + json!({ + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "future_tool", "name": "write_report"}] + }), + ]; + + for tool_choice in choices { + let body = json!({ + "model": "gpt-source", + "input": "write it", + "tools": [{ + "type": "namespace", + "name": "reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + }], + "tool_choice": tool_choice + }); + let canonical = responses::request::from_raw(&body).expect("Responses request"); + + assert!(to_raw(&canonical).is_none()); + assert!(to(&canonical, &FormatContext::default()).is_none()); + } + } + + #[test] + fn responses_namespace_alias_avoids_ordinary_function_name_collisions() { + let body = json!({ + "model": "gpt-source", + "input": [{ + "type": "function_call", + "call_id": "call_report", + "namespace": "reports", + "name": "write_report", + "arguments": "{}" + }], + "tools": [ + { + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }, + { + "type": "function", + "name": "reports__write_report", + "parameters": {"type": "object"} + }, + { + "type": "namespace", + "name": "reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + } + ] + }); + let canonical = responses::request::from_raw(&body).expect("Responses request"); + let chat = to(&canonical, &FormatContext::default()).expect("Chat request"); + let names = chat["tools"] + .as_array() + .expect("Chat tools") + .iter() + .map(|tool| { + tool["function"]["name"] + .as_str() + .expect("Chat function name") + }) + .collect::>(); + let namespace_alias = chat["tools"][2]["function"]["name"] + .as_str() + .expect("namespace alias"); + + assert_eq!(names.len(), 3); + assert!(names.contains("write_report")); + assert!(names.contains("reports__write_report")); + assert!(namespace_alias.starts_with("aether_ns_")); + assert!(names.iter().all(|name| { + name.len() <= 64 + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + })); + assert_eq!( + chat["messages"][0]["tool_calls"][0]["function"]["name"], + namespace_alias + ); + + let mut ambiguous = body; + ambiguous["tool_choice"] = json!({"type": "function", "name": "write_report"}); + let canonical = responses::request::from_raw(&ambiguous).expect("Responses request"); + assert!(to(&canonical, &FormatContext::default()).is_none()); + } + + #[test] + fn responses_namespace_history_fails_closed_when_identity_is_unknown() { + let body = json!({ + "model": "gpt-source", + "input": [{ + "type": "function_call", + "call_id": "call_report", + "namespace": "unknown_namespace", + "name": "write_report", + "arguments": "{}" + }], + "tools": [{ + "type": "namespace", + "name": "reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + }] + }); + let canonical = responses::request::from_raw(&body).expect("Responses request"); + + assert!(to(&canonical, &FormatContext::default()).is_none()); + } + + #[test] + fn namespace_history_sidecars_require_exact_keys_and_consistent_namespaces() { + let valid = std::collections::BTreeMap::from([( + OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), + json!({ + "namespace": "reports", + "item_id": "fc_report", + "status": "completed" + }), + )]); + assert!(namespace_tool_use_sidecars_are_chat_representable( + &valid, "reports" + )); + + let mut conflicting = valid.clone(); + conflicting.insert( + OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE.to_string(), + json!({ + "namespace": "other_reports", + "item_id": "fc_report", + "status": "completed" + }), + ); + assert!(!namespace_tool_use_sidecars_are_chat_representable( + &conflicting, + "reports" + )); + + let unknown = std::collections::BTreeMap::from([( + OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), + json!({ + "namespace": "reports", + "caller": "future-semantic-owner" + }), + )]); + assert!(!namespace_tool_use_sidecars_are_chat_representable( + &unknown, "reports" + )); + } +} diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index 946efdf32..bb2365e0e 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{json, Map, Value}; +use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::responses::{ openai_responses_synthetic_reasoning_item_id, response::{ @@ -683,6 +684,52 @@ impl OpenAIResponsesProviderState { } } + fn resolve_function_call_chat_name( + report_context: &Value, + namespace: Option<&Value>, + incoming_name: Option<&str>, + existing_chat_name: Option<&str>, + ) -> Result, ()> { + let incoming_name = incoming_name.map(str::trim).filter(|name| !name.is_empty()); + let existing_chat_name = existing_chat_name + .map(str::trim) + .filter(|name| !name.is_empty()); + let aliases = NamespaceToolAliases::from_report_context(report_context); + + match namespace { + Some(Value::String(namespace)) if !namespace.trim().is_empty() => { + if let Some(child_name) = incoming_name { + return aliases + .chat_name(namespace.trim(), child_name) + .map(|chat_name| Some(chat_name.to_string())) + .ok_or(()); + } + existing_chat_name + .filter(|chat_name| { + aliases + .responses_name(chat_name) + .is_some_and(|(existing_namespace, _)| { + existing_namespace == namespace.trim() + }) + }) + .map(|chat_name| Some(chat_name.to_string())) + .ok_or(()) + } + Some(_) => Err(()), + None => { + if let (Some(child_name), Some(chat_name)) = (incoming_name, existing_chat_name) { + if aliases + .responses_name(chat_name) + .is_some_and(|(_, existing_child_name)| existing_child_name == child_name) + { + return Ok(Some(chat_name.to_string())); + } + } + Ok(incoming_name.or(existing_chat_name).map(ToOwned::to_owned)) + } + } + } + fn emit_tool_call_item( &mut self, report_context: &Value, @@ -698,7 +745,15 @@ impl OpenAIResponsesProviderState { // sync Responses aggregator retains the original item verbatim. Treat these // sidecars as recognized while continuing to fail closed for semantic fields // (for example `caller`) that the canonical tool-call events cannot represent. - const EXECUTION_FIELDS: &[&str] = &["type", "id", "call_id", "status", "name", "arguments"]; + const EXECUTION_FIELDS: &[&str] = &[ + "type", + "id", + "call_id", + "status", + "namespace", + "name", + "arguments", + ]; let has_chat_metadata_passthrough = item.contains_key("internal_chat_message_metadata_passthrough"); let chat_metadata_target_supported = report_context @@ -727,17 +782,28 @@ impl OpenAIResponsesProviderState { .and_then(Value::as_str) .map(ToOwned::to_owned); let index = self.tool_index_for_key(key, output_index); + let existing_chat_name = self.tool_calls.get(&index).map(|state| state.name.as_str()); + let incoming_chat_name = match Self::resolve_function_call_chat_name( + report_context, + item.get("namespace"), + item.get("name").and_then(Value::as_str), + existing_chat_name, + ) { + Ok(name) => name, + Err(()) => { + out.push(self.unknown_frame(report_context, Value::Object(item.clone()))); + return; + } + }; let state = self.tool_calls.entry(index).or_default(); state.call_id = item .get("call_id") .and_then(Value::as_str) .unwrap_or(state.call_id.as_str()) .to_string(); - state.name = item - .get("name") - .and_then(Value::as_str) - .unwrap_or(state.name.as_str()) - .to_string(); + if let Some(name) = incoming_chat_name { + state.name = name; + } let completed_arguments = item .get("arguments") .and_then(Value::as_str) @@ -1590,13 +1656,12 @@ impl OpenAIResponsesProviderState { self.emit_ready_function_call(report_context, &mut out, index); } "response.function_call_arguments.done" => { + let nested_item = value.get("item").and_then(Value::as_object); let arguments = value .get("arguments") .and_then(Value::as_str) .or_else(|| { - value - .get("item") - .and_then(Value::as_object) + nested_item .and_then(|item| item.get("arguments")) .and_then(Value::as_str) }) @@ -1609,9 +1674,7 @@ impl OpenAIResponsesProviderState { .and_then(Value::as_str) .map(ToOwned::to_owned) .or_else(|| { - value - .get("item") - .and_then(Value::as_object) + nested_item .and_then(|item| item.get("call_id").or_else(|| item.get("id"))) .and_then(Value::as_str) .map(ToOwned::to_owned) @@ -1621,31 +1684,42 @@ impl OpenAIResponsesProviderState { .and_then(Value::as_u64) .map(|value| value as usize); let index = self.tool_index_for_key(key, output_index); + let incoming_name = value.get("name").and_then(Value::as_str).or_else(|| { + nested_item + .and_then(|item| item.get("name")) + .and_then(Value::as_str) + }); + let namespace = value + .get("namespace") + .or_else(|| nested_item.and_then(|item| item.get("namespace"))); + let existing_chat_name = + self.tool_calls.get(&index).map(|state| state.name.as_str()); + let incoming_chat_name = match Self::resolve_function_call_chat_name( + report_context, + namespace, + incoming_name, + existing_chat_name, + ) { + Ok(name) => name, + Err(()) => { + out.push(self.unknown_frame(report_context, value.clone())); + return Ok(out); + } + }; let state = self.tool_calls.entry(index).or_default(); state.call_id = value .get("call_id") .and_then(Value::as_str) .or_else(|| { - value - .get("item") - .and_then(Value::as_object) + nested_item .and_then(|item| item.get("call_id")) .and_then(Value::as_str) }) .unwrap_or(state.call_id.as_str()) .to_string(); - state.name = value - .get("name") - .and_then(Value::as_str) - .or_else(|| { - value - .get("item") - .and_then(Value::as_object) - .and_then(|item| item.get("name")) - .and_then(Value::as_str) - }) - .unwrap_or(state.name.as_str()) - .to_string(); + if let Some(name) = incoming_chat_name { + state.name = name; + } Self::merge_tool_call_arguments(state, arguments); self.emit_ready_function_call(report_context, &mut out, index); } @@ -1856,6 +1930,7 @@ pub struct OpenAIChatClientEmitter { struct OpenAIResponsesClientToolState { call_id: String, name: String, + namespace: Option, arguments: String, output_index: Option, web_search: bool, @@ -1915,6 +1990,7 @@ pub struct OpenAIResponsesClientEmitter { opaque_output_items: BTreeMap, opaque_output_indexes: BTreeMap, completed_history_response: Option, + namespace_tool_aliases: NamespaceToolAliases, } impl OpenAIChatClientEmitter { @@ -2212,6 +2288,13 @@ impl OpenAIChatClientEmitter { } impl OpenAIResponsesClientEmitter { + pub(crate) fn with_report_context(report_context: &Value) -> Self { + Self { + namespace_tool_aliases: NamespaceToolAliases::from_report_context(report_context), + ..Self::default() + } + } + pub(crate) fn set_actual_service_tier(&mut self, value: Option<&str>) { if value.is_some_and(|value| { self.actual_service_tier @@ -2678,20 +2761,26 @@ impl OpenAIResponsesClientEmitter { "arguments": state.arguments.as_str(), }), )?); + let mut completed_item = json!({ + "type": "function_call", + "id": item_id.clone(), + "call_id": call_id, + "name": name, + "arguments": state.arguments.as_str(), + "status": "completed", + }); + if let (Some(namespace), Some(item)) = + (state.namespace.clone(), completed_item.as_object_mut()) + { + item.insert("namespace".to_string(), Value::String(namespace)); + } out.extend(self.encode_response_event( "response.output_item.done", json!({ "type": "response.output_item.done", "response_id": self.response_id(), "output_index": output_index, - "item": { - "type": "function_call", - "id": item_id.clone(), - "call_id": call_id, - "name": name, - "arguments": state.arguments.as_str(), - "status": "completed", - } + "item": completed_item, }), )?); } @@ -2851,21 +2940,24 @@ impl OpenAIResponsesClientEmitter { )); continue; } - ordered_output.push(( - output_index, - json!({ - "type": "function_call", - "id": item_id.clone(), - "call_id": call_id, - "name": if state.name.is_empty() { - "unknown".to_string() - } else { - state.name.clone() - }, - "arguments": state.arguments.clone(), - "status": "completed", - }), - )); + let mut item = json!({ + "type": "function_call", + "id": item_id.clone(), + "call_id": call_id, + "name": if state.name.is_empty() { + "unknown".to_string() + } else { + state.name.clone() + }, + "arguments": state.arguments.clone(), + "status": "completed", + }); + if let (Some(namespace), Some(item)) = + (state.namespace.clone(), item.as_object_mut()) + { + item.insert("namespace".to_string(), Value::String(namespace)); + } + ordered_output.push((output_index, item)); } } for (index, state) in &self.tool_results { @@ -3150,13 +3242,21 @@ impl OpenAIResponsesClientEmitter { let output_index = self.ensure_tool_output_index(index); let response_id = self.response_id().to_string(); let item_id = self.tool_call_item_id(index); + let namespaced_tool = self.namespace_tool_aliases.responses_name(&name); + let emitted_name = namespaced_tool + .map(|(_, child_name)| child_name.to_string()) + .unwrap_or_else(|| name.clone()); + let emitted_namespace = namespaced_tool.map(|(namespace, _)| namespace.to_string()); + let is_namespaced_tool = namespaced_tool.is_some(); let state = self.tool_calls.entry(index).or_default(); state.call_id = call_id.clone(); - state.name = name.clone(); - state.web_search = is_responses_web_search_tool(&name); + state.name = emitted_name; + state.namespace = emitted_namespace; + state.web_search = !is_namespaced_tool && is_responses_web_search_tool(&name); let emitted_call_id = state.call_id.clone(); let emitted_name = state.name.clone(); - let item = if state.web_search { + let emitted_namespace = state.namespace.clone(); + let mut item = if state.web_search { json!({ "type": "web_search_call", "id": item_id, @@ -3176,6 +3276,9 @@ impl OpenAIResponsesClientEmitter { "status": "in_progress", }) }; + if let (Some(namespace), Some(item)) = (emitted_namespace, item.as_object_mut()) { + item.insert("namespace".to_string(), Value::String(namespace)); + } out.extend(self.encode_response_event( "response.output_item.added", json!({ @@ -4662,6 +4765,141 @@ mod tests { assert!(!sse.contains("\\\"pages\\\":\\\"\\\"")); } + #[test] + fn openai_responses_provider_state_resolves_done_only_namespace_identity() { + let report_context = json!({ + "original_request_body": { + "tools": [ + { + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }, + { + "type": "namespace", + "name": "reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + } + ] + } + }); + let expected_alias = NamespaceToolAliases::from_report_context(&report_context) + .chat_name("reports", "write_report") + .expect("namespace alias") + .to_string(); + let mut state = OpenAIResponsesProviderState::default(); + + let frames = state + .push_line( + &report_context, + data_line(json!({ + "type": "response.function_call_arguments.done", + "response_id": "resp_done_only_namespace", + "output_index": 0, + "item_id": "fc_done_only_namespace", + "call_id": "call_done_only_namespace", + "namespace": "reports", + "name": "write_report", + "arguments": "{}" + })), + ) + .expect("done-only namespace call should parse"); + + assert!(frames.iter().any(|frame| matches!( + frame.event, + CanonicalStreamEvent::ToolCallStart { + ref call_id, + ref name, + .. + } if call_id == "call_done_only_namespace" && name == &expected_alias + ))); + } + + #[test] + fn openai_responses_provider_state_keeps_namespace_alias_until_delayed_call_id() { + let report_context = json!({ + "original_request_body": { + "tools": [ + { + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }, + { + "type": "namespace", + "name": "reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + } + ] + } + }); + let expected_alias = NamespaceToolAliases::from_report_context(&report_context) + .chat_name("reports", "write_report") + .expect("namespace alias") + .to_string(); + let mut state = OpenAIResponsesProviderState::default(); + + let added = state + .push_line( + &report_context, + data_line(json!({ + "type": "response.output_item.added", + "response_id": "resp_delayed_namespace", + "output_index": 0, + "item": { + "type": "function_call", + "id": "fc_delayed_namespace", + "namespace": "reports", + "name": "write_report", + "arguments": "" + } + })), + ) + .expect("namespace item should parse"); + assert!(!added + .iter() + .any(|frame| matches!(frame.event, CanonicalStreamEvent::ToolCallStart { .. }))); + + let done = state + .push_line( + &report_context, + data_line(json!({ + "type": "response.function_call_arguments.done", + "response_id": "resp_delayed_namespace", + "output_index": 0, + "item_id": "fc_delayed_namespace", + "call_id": "call_delayed_namespace", + "name": "write_report", + "arguments": "{\"path\":\"reports/finding.md\"}" + })), + ) + .expect("delayed namespace call identity should parse"); + + assert!(done.iter().any(|frame| matches!( + frame.event, + CanonicalStreamEvent::ToolCallStart { + ref call_id, + ref name, + .. + } if call_id == "call_delayed_namespace" && name == &expected_alias + ))); + assert!(!done.iter().any(|frame| matches!( + frame.event, + CanonicalStreamEvent::ToolCallStart { ref name, .. } + if name == "write_report" && name != &expected_alias + ))); + } + #[test] fn openai_responses_provider_state_waits_for_call_id_distinct_from_item_id() { let mut state = OpenAIResponsesProviderState::default(); diff --git a/crates/aether-ai/formats/src/formats/openai/mod.rs b/crates/aether-ai/formats/src/formats/openai/mod.rs index ba6e87780..4ba8aabc3 100644 --- a/crates/aether-ai/formats/src/formats/openai/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/mod.rs @@ -1,6 +1,7 @@ pub mod chat; pub mod embedding; pub mod image; +pub(crate) mod namespace; pub mod prompt_cache; pub mod reasoning; pub mod request_contract; diff --git a/crates/aether-ai/formats/src/formats/openai/namespace.rs b/crates/aether-ai/formats/src/formats/openai/namespace.rs new file mode 100644 index 000000000..fa25c532a --- /dev/null +++ b/crates/aether-ai/formats/src/formats/openai/namespace.rs @@ -0,0 +1,605 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::protocol::canonical::{ + openai_responses_tools_to_canonical, CanonicalToolDefinition, + OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, +}; + +const OPENAI_CHAT_TOOL_NAME_MAX_LEN: usize = 64; +const HASHED_ALIAS_PREFIX: &str = "aether_ns_"; +const HASH_HEX_LEN: usize = 32; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct NamespaceChatTool { + pub source_tool_index: usize, + pub source_child_index: usize, + pub namespace: String, + pub name: String, + pub chat_name: String, + pub description: Option, + pub parameters: Option, + pub strict: Option, +} + +impl NamespaceChatTool { + pub(crate) fn to_openai_chat_tool(&self) -> Value { + let mut function = Map::new(); + function.insert("name".to_string(), Value::String(self.chat_name.clone())); + if let Some(description) = &self.description { + function.insert( + "description".to_string(), + Value::String(description.clone()), + ); + } + if let Some(parameters) = &self.parameters { + function.insert("parameters".to_string(), parameters.clone()); + } + if let Some(strict) = &self.strict { + function.insert("strict".to_string(), strict.clone()); + } + json!({ + "type": "function", + "function": Value::Object(function), + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct NamespaceToolAliases { + tools: Vec, + by_identity: BTreeMap<(String, String), String>, + by_chat_name: BTreeMap, + namespace_tool_indices: BTreeSet, + invalid_namespace_tool_indices: BTreeSet, +} + +impl NamespaceToolAliases { + pub(crate) fn from_canonical_tools(tools: &[CanonicalToolDefinition]) -> Self { + let mut result = Self::default(); + let mut parsed = Vec::new(); + let mut name_counts = BTreeMap::::new(); + let mut ordinary_chat_names = BTreeSet::::new(); + + for (tool_index, tool) in tools.iter().enumerate() { + if canonical_tool_is_responses_namespace(tool) { + result.namespace_tool_indices.insert(tool_index); + match parse_namespace_tool(tool_index, tool) { + Some(children) => { + for child in &children { + *name_counts.entry(child.name.clone()).or_default() += 1; + } + parsed.extend(children); + } + None => { + result.invalid_namespace_tool_indices.insert(tool_index); + } + } + } else { + *name_counts.entry(tool.name.clone()).or_default() += 1; + ordinary_chat_names.insert(tool.name.clone()); + } + } + + let mut sources_by_identity = BTreeMap::<(String, String), BTreeSet>::new(); + for child in &parsed { + let identity = (child.namespace.clone(), child.name.clone()); + sources_by_identity + .entry(identity) + .or_default() + .insert(child.source_tool_index); + } + for sources in sources_by_identity + .values() + .filter(|sources| sources.len() > 1) + { + result + .invalid_namespace_tool_indices + .extend(sources.iter().copied()); + } + parsed.retain(|child| { + !result + .invalid_namespace_tool_indices + .contains(&child.source_tool_index) + }); + + let preferred_names = parsed + .iter() + .map(|child| { + let name_is_globally_unique = name_counts.get(&child.name).copied() == Some(1); + let preferred = if name_is_globally_unique + && is_valid_chat_tool_name(&child.name) + && !ordinary_chat_names.contains(&child.name) + { + Some(child.name.clone()) + } else { + let readable = format!("{}__{}", child.namespace, child.name); + (is_valid_chat_tool_name(&readable) && !ordinary_chat_names.contains(&readable)) + .then_some(readable) + }; + ((child.namespace.clone(), child.name.clone()), preferred) + }) + .collect::>(); + let mut preferred_counts = BTreeMap::::new(); + for preferred in preferred_names.values().flatten() { + *preferred_counts.entry(preferred.clone()).or_default() += 1; + } + + let mut aliases_by_identity = BTreeMap::<(String, String), String>::new(); + let mut used_chat_names = ordinary_chat_names; + for (identity, preferred) in &preferred_names { + if let Some(preferred) = preferred + .as_ref() + .filter(|name| preferred_counts.get(*name).copied() == Some(1)) + { + aliases_by_identity.insert(identity.clone(), preferred.clone()); + used_chat_names.insert(preferred.clone()); + } + } + for identity in preferred_names.keys() { + if aliases_by_identity.contains_key(identity) { + continue; + } + let chat_name = allocate_hashed_alias(&identity.0, &identity.1, &used_chat_names); + used_chat_names.insert(chat_name.clone()); + aliases_by_identity.insert(identity.clone(), chat_name); + } + + for mut child in parsed { + let identity = (child.namespace.clone(), child.name.clone()); + let chat_name = aliases_by_identity + .get(&identity) + .expect("every valid namespace child receives an alias") + .clone(); + child.chat_name = chat_name.clone(); + result + .by_identity + .insert(identity.clone(), chat_name.clone()); + result.by_chat_name.insert(chat_name, identity); + result.tools.push(child); + } + + result + } + + pub(crate) fn from_report_context(report_context: &Value) -> Self { + let Some(tools) = report_context + .get("original_request_body") + .and_then(|request| request.get("tools")) + else { + return Self::default(); + }; + let Some(canonical) = openai_responses_tools_to_canonical(Some(tools)) else { + return Self::default(); + }; + Self::from_canonical_tools(&canonical) + } + + pub(crate) fn chat_name(&self, namespace: &str, child_name: &str) -> Option<&str> { + self.by_identity + .get(&(namespace.to_string(), child_name.to_string())) + .map(String::as_str) + } + + pub(crate) fn responses_name(&self, chat_name: &str) -> Option<(&str, &str)> { + self.by_chat_name + .get(chat_name) + .map(|(namespace, child_name)| (namespace.as_str(), child_name.as_str())) + } + + pub(crate) fn tools_for_source( + &self, + source_tool_index: usize, + ) -> impl Iterator { + self.tools + .iter() + .filter(move |tool| tool.source_tool_index == source_tool_index) + } + + pub(crate) fn is_namespace_tool(&self, source_tool_index: usize) -> bool { + self.namespace_tool_indices.contains(&source_tool_index) + } + + pub(crate) fn is_representable_namespace_tool(&self, source_tool_index: usize) -> bool { + self.is_namespace_tool(source_tool_index) + && !self + .invalid_namespace_tool_indices + .contains(&source_tool_index) + && self + .tools + .iter() + .any(|tool| tool.source_tool_index == source_tool_index) + } + + pub(crate) fn has_invalid_namespace_tools(&self) -> bool { + !self.invalid_namespace_tool_indices.is_empty() + } + + pub(crate) fn namespace_children_named<'a>( + &'a self, + child_name: &'a str, + ) -> impl Iterator + 'a { + self.tools + .iter() + .filter(move |tool| tool.name == child_name) + } +} + +pub(crate) fn canonical_tool_is_responses_namespace(tool: &CanonicalToolDefinition) -> bool { + raw_responses_tool(tool).is_some_and(|raw| { + raw.get("type") + .and_then(Value::as_str) + .is_some_and(|tool_type| tool_type.eq_ignore_ascii_case("namespace")) + }) +} + +fn raw_responses_tool(tool: &CanonicalToolDefinition) -> Option<&Map> { + tool.extensions + .get(OPENAI_RESPONSES_EXTENSION_NAMESPACE) + .or_else(|| { + tool.extensions + .get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE) + }) + .and_then(Value::as_object) +} + +fn parse_namespace_tool( + source_tool_index: usize, + tool: &CanonicalToolDefinition, +) -> Option> { + let raw = raw_responses_tool(tool)?; + if !object_has_only_keys(raw, &["type", "name", "description", "tools"]) { + return None; + } + let namespace = non_empty_string(raw.get("name"))?.to_string(); + if !matches!(raw.get("description"), Some(Value::String(_))) { + return None; + } + let children = raw.get("tools")?.as_array()?; + if children.is_empty() { + return None; + } + + let mut names = BTreeSet::new(); + let mut parsed = Vec::with_capacity(children.len()); + for (source_child_index, child) in children.iter().enumerate() { + let child = child.as_object()?; + if child.get("type").and_then(Value::as_str) != Some("function") { + return None; + } + if !object_has_only_keys( + child, + &["type", "name", "description", "parameters", "strict"], + ) { + return None; + } + let function = child; + let name = non_empty_string(function.get("name"))?.to_string(); + if !names.insert(name.clone()) { + return None; + } + // Responses permits an omitted or null parameter schema, while Chat + // Completions only permits an omitted schema or an object. Treat null + // as the omitted form instead of forwarding an invalid + // `parameters: null` Chat tool definition. + let parameters = match function.get("parameters") { + Some(parameters @ Value::Object(_)) => Some(parameters.clone()), + Some(Value::Null) | None => None, + Some(_) => return None, + }; + let strict = match function.get("strict") { + Some(strict @ (Value::Bool(_) | Value::Null)) => Some(strict.clone()), + Some(_) => return None, + None => None, + }; + let description = match function.get("description") { + Some(Value::String(description)) => Some(description.clone()), + Some(Value::Null) | None => None, + Some(_) => return None, + }; + parsed.push(NamespaceChatTool { + source_tool_index, + source_child_index, + namespace: namespace.clone(), + name, + chat_name: String::new(), + description, + parameters, + strict, + }); + } + Some(parsed) +} + +fn object_has_only_keys(object: &Map, allowed: &[&str]) -> bool { + object.keys().all(|key| allowed.contains(&key.as_str())) +} + +fn non_empty_string(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn is_valid_chat_tool_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= OPENAI_CHAT_TOOL_NAME_MAX_LEN + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn allocate_hashed_alias(namespace: &str, child_name: &str, used: &BTreeSet) -> String { + for nonce in 0_u64.. { + let candidate = hashed_alias(namespace, child_name, nonce); + if !used.contains(&candidate) { + return candidate; + } + } + unreachable!("u64 alias nonce space cannot be exhausted") +} + +fn sanitize_chat_name_component(value: &str) -> String { + value + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') { + char::from(byte) + } else { + '_' + } + }) + .collect() +} + +fn hashed_alias(namespace: &str, child_name: &str, nonce: u64) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"aether-openai-namespace-tool\0"); + hasher.update(namespace.as_bytes()); + hasher.update(b"\0"); + hasher.update(child_name.as_bytes()); + hasher.update(b"\0"); + hasher.update(nonce.to_le_bytes()); + let digest = hasher.finalize(); + let digest_hex = digest + .iter() + .take(HASH_HEX_LEN / 2) + .map(|byte| format!("{byte:02x}")) + .collect::(); + let semantic_budget = + OPENAI_CHAT_TOOL_NAME_MAX_LEN - HASHED_ALIAS_PREFIX.len() - 1 - digest_hex.len(); + let mut semantic = sanitize_chat_name_component(child_name); + semantic.truncate(semantic_budget); + if semantic.is_empty() { + semantic.push_str("tool"); + semantic.truncate(semantic_budget); + } + format!("{HASHED_ALIAS_PREFIX}{semantic}_{digest_hex}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::canonical::openai_responses_tools_to_canonical; + + fn aliases(tools: Value) -> NamespaceToolAliases { + let canonical = openai_responses_tools_to_canonical(Some(&tools)) + .expect("Responses tools should parse"); + NamespaceToolAliases::from_canonical_tools(&canonical) + } + + #[test] + fn namespace_aliases_keep_unique_child_names_and_reverse_them() { + let aliases = aliases(json!([{ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "vulnerability_report", + "parameters": {"type": "object", "properties": {}} + }] + }])); + + assert_eq!( + aliases.chat_name("mcp__reports", "vulnerability_report"), + Some("vulnerability_report") + ); + assert_eq!( + aliases.responses_name("vulnerability_report"), + Some(("mcp__reports", "vulnerability_report")) + ); + } + + #[test] + fn namespace_aliases_are_unique_bounded_and_prefix_safe() { + let long_namespace = format!("namespace__{}", "n".repeat(120)); + let long_child = format!("aether_ns__{}", "c".repeat(120)); + let tools = json!([ + { + "type": "function", + "name": "shared", + "parameters": {"type": "object"} + }, + { + "type": "namespace", + "name": "first__namespace", + "description": "First namespace", + "tools": [{ + "type": "function", + "name": "shared", + "parameters": {"type": "object"} + }] + }, + { + "type": "namespace", + "name": "second__namespace", + "description": "Second namespace", + "tools": [{ + "type": "function", + "name": "shared", + "parameters": {"type": "object"} + }] + }, + { + "type": "namespace", + "name": long_namespace, + "description": "Long namespace", + "tools": [{ + "type": "function", + "name": long_child, + "parameters": {"type": "object"} + }] + } + ]); + let aliases = aliases(tools); + let names = aliases + .tools + .iter() + .map(|tool| tool.chat_name.as_str()) + .collect::>(); + + assert_eq!(names.len(), 3); + assert!(!names.contains("shared")); + assert!(names.iter().all(|name| is_valid_chat_tool_name(name))); + assert!(names.iter().all(|name| name.len() <= 64)); + for tool in &aliases.tools { + assert_eq!( + aliases.responses_name(&tool.chat_name), + Some((tool.namespace.as_str(), tool.name.as_str())) + ); + } + } + + #[test] + fn namespace_aliases_are_stable_across_tool_order_and_readable_collisions() { + let first = json!([ + { + "type": "function", + "name": "b__c", + "parameters": {"type": "object"} + }, + { + "type": "function", + "name": "c", + "parameters": {"type": "object"} + }, + { + "type": "namespace", + "name": "a", + "description": "A", + "tools": [{ + "type": "function", + "name": "b__c", + "parameters": {"type": "object"} + }] + }, + { + "type": "namespace", + "name": "a__b", + "description": "AB", + "tools": [{ + "type": "function", + "name": "c", + "parameters": {"type": "object"} + }] + } + ]); + let second = json!([ + first[3].clone(), + first[2].clone(), + first[1].clone(), + first[0].clone() + ]); + let first = aliases(first); + let second = aliases(second); + + for identity in [("a", "b__c"), ("a__b", "c")] { + let first_alias = first + .chat_name(identity.0, identity.1) + .expect("first alias"); + let second_alias = second + .chat_name(identity.0, identity.1) + .expect("second alias"); + assert_eq!(first_alias, second_alias); + assert!(first_alias.starts_with(HASHED_ALIAS_PREFIX)); + } + } + + #[test] + fn namespace_children_preserve_nullable_fields_without_inheriting_parent_description() { + let aliases = aliases(json!([{ + "type": "namespace", + "name": "reports", + "description": "Parent description", + "tools": [{ + "type": "function", + "name": "write_report", + "description": null, + "parameters": null, + "strict": null + }] + }])); + let chat_tool = aliases + .tools_for_source(0) + .next() + .expect("namespace child") + .to_openai_chat_tool(); + + assert!(chat_tool["function"].get("description").is_none()); + assert!(chat_tool["function"].get("parameters").is_none()); + assert_eq!(chat_tool["function"]["strict"], Value::Null); + } + + #[test] + fn malformed_namespace_is_not_representable() { + for raw in [ + json!({"type": "namespace", "name": "broken"}), + json!({ + "type": "namespace", + "name": "broken", + "tools": [{"type": "function", "name": "missing_parent_description"}] + }), + json!({ + "type": "namespace", + "name": "broken", + "description": "Broken", + "tools": {} + }), + json!({ + "type": "namespace", + "name": "broken", + "description": "Broken", + "tools": [{"type": "function"}] + }), + json!({ + "type": "namespace", + "name": "broken", + "description": "Broken", + "tools": [{"type": "custom", "name": "raw"}] + }), + json!({ + "type": "namespace", + "name": "broken", + "description": "Broken", + "tools": [{"name": "missing_type"}] + }), + json!({ + "type": "namespace", + "name": "broken", + "description": "Broken", + "tools": [{ + "type": "function", + "function": {"name": "nested"} + }] + }), + ] { + let aliases = aliases(json!([raw])); + assert!(aliases.has_invalid_namespace_tools()); + assert!(!aliases.is_representable_namespace_tool(0)); + } + } +} diff --git a/crates/aether-ai/formats/src/formats/openai/responses/response.rs b/crates/aether-ai/formats/src/formats/openai/responses/response.rs index f884815ab..7703cdefd 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/response.rs @@ -11,7 +11,7 @@ use super::{ }; use crate::{ - formats::context::FormatContext, + formats::{context::FormatContext, openai::namespace::NamespaceToolAliases}, 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, @@ -114,6 +114,7 @@ fn openai_responses_incomplete_stop_reason(body: &Map) -> Canonic } pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bool) -> Value { + let namespace_tool_aliases = NamespaceToolAliases::from_report_context(report_context); let mut response = Map::new(); let response_id = canonical.id.replace("chatcmpl", "resp"); response.insert("id".to_string(), Value::String(response_id.clone())); @@ -239,7 +240,8 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo &response_id, &mut message_index, ); - if is_responses_web_search_tool(name) { + let namespaced_tool = namespace_tool_aliases.responses_name(name); + if namespaced_tool.is_none() && is_responses_web_search_tool(name) { output.push(json!({ "type": "web_search_call", "id": id, @@ -250,9 +252,24 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo }, })); } else { - output.push(canonical_tool_use_to_openai_responses_item( - id, name, input, extensions, - )); + let response_name = namespaced_tool + .map(|(_, child_name)| child_name) + .unwrap_or(name.as_str()); + let mut item = canonical_tool_use_to_openai_responses_item( + id, + response_name, + input, + extensions, + ); + if let Some((namespace, _)) = namespaced_tool { + if let Some(item) = item.as_object_mut() { + item.insert( + "namespace".to_string(), + Value::String(namespace.to_string()), + ); + } + } + output.push(item); } } CanonicalContentBlock::ToolResult { @@ -596,6 +613,66 @@ mod tests { assert!(body["completed_at"].as_i64().is_some()); } + #[test] + fn responses_response_builder_restores_namespaced_chat_tool_identity() { + let report_context = json!({ + "original_request_body": { + "tools": [ + { + "type": "function", + "name": "vulnerability_report", + "description": "ordinary function", + "parameters": {"type": "object", "properties": {}} + }, + { + "type": "namespace", + "name": "mcp__vulnerability_report", + "description": "reporting tools", + "tools": [{ + "type": "function", + "name": "vulnerability_report", + "description": "write the confirmed report", + "parameters": { + "type": "object", + "properties": {"report_path": {"type": "string"}}, + "required": ["report_path"] + }, + "strict": true + }] + } + ] + } + }); + let aliases = NamespaceToolAliases::from_report_context(&report_context); + let chat_name = aliases + .chat_name("mcp__vulnerability_report", "vulnerability_report") + .expect("namespace child should have a Chat alias") + .to_string(); + assert_ne!(chat_name, "vulnerability_report"); + + let response = CanonicalResponse { + id: "chatcmpl_namespace".to_string(), + model: "qwen".to_string(), + content: vec![CanonicalContentBlock::ToolUse { + id: "call_report_1".to_string(), + name: chat_name, + input: json!({"report_path": "reports/sql-001-c1.md"}), + extensions: BTreeMap::new(), + }], + outputs: Vec::new(), + stop_reason: Some(CanonicalStopReason::ToolUse), + usage: None, + extensions: BTreeMap::new(), + }; + + let body = to_raw(&response, &report_context, false); + let item = &body["output"][0]; + assert_eq!(item["type"], "function_call"); + assert_eq!(item["name"], "vulnerability_report"); + assert_eq!(item["namespace"], "mcp__vulnerability_report"); + assert_eq!(item["call_id"], "call_report_1"); + } + #[test] fn responses_response_builder_emits_modern_output_text_and_preserves_source_fields() { let mut extensions = BTreeMap::new(); diff --git a/crates/aether-ai/formats/src/formats/registry.rs b/crates/aether-ai/formats/src/formats/registry.rs index bffce5342..42e16c170 100644 --- a/crates/aether-ai/formats/src/formats/registry.rs +++ b/crates/aether-ai/formats/src/formats/registry.rs @@ -1,5 +1,6 @@ use serde_json::{Map, Value}; +use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::{ aliyun, claude::messages as claude_messages, @@ -180,7 +181,7 @@ fn validate_runtime_request_conversion( source: FormatId, target: FormatId, body: &Value, - _request: &CanonicalRequest, + request: &CanonicalRequest, mapped_model: Option<&str>, ) -> Result<(), FormatError> { validate_openai_cross_format_store(source, target, body)?; @@ -188,6 +189,7 @@ fn validate_runtime_request_conversion( validate_openai_reasoning_effort(source, target, body, mapped_model)?; validate_openai_responses_cross_format_input(source, target, body)?; validate_openai_responses_runtime_reasoning(source, target, body)?; + validate_openai_responses_namespace_tools_to_chat(source, target, request)?; if matches!(source, FormatId::OpenAiChat) && matches!( target, @@ -1277,8 +1279,11 @@ fn validate_cross_format_request_extensions( target: FormatId, request: &CanonicalRequest, ) -> Result<(), FormatError> { + validate_openai_responses_namespace_tools_to_chat(source, target, request)?; validate_request_content_has_no_unknown_blocks(source, target, request)?; - validate_request_extension_namespace(source, target, "request", &request.extensions)?; + let namespace_aliases = NamespaceToolAliases::from_canonical_tools(&request.tools); + let request_extensions = mapped_namespace_request_extensions(source, target, request); + validate_request_extension_namespace(source, target, "request", &request_extensions)?; for instruction in &request.instructions { validate_request_extension_namespace( source, @@ -1296,7 +1301,6 @@ fn validate_cross_format_request_extensions( | CanonicalContentBlock::Image { extensions, .. } | CanonicalContentBlock::File { extensions, .. } | CanonicalContentBlock::Audio { extensions, .. } - | CanonicalContentBlock::ToolUse { extensions, .. } | CanonicalContentBlock::ToolResult { extensions, .. } | CanonicalContentBlock::Unknown { extensions, .. } => { validate_request_extension_namespace( @@ -1306,10 +1310,37 @@ fn validate_cross_format_request_extensions( extensions, )?; } + CanonicalContentBlock::ToolUse { + name, extensions, .. + } => { + let extensions = mapped_namespace_tool_use_extensions( + source, + target, + name, + extensions, + &namespace_aliases, + )?; + validate_request_extension_namespace( + source, + target, + "messages[].content[]", + &extensions, + )?; + } } } } - for tool in &request.tools { + for (tool_index, tool) in request.tools.iter().enumerate() { + if matches!( + source, + FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact + ) && target == FormatId::OpenAiChat + && namespace_aliases.is_representable_namespace_tool(tool_index) + { + // The namespace adapter has already audited the complete raw parent and every child. + // Do not broadly allow these provider-extension keys for unrelated tool types. + continue; + } validate_request_extension_namespace(source, target, "tools[]", &tool.extensions)?; } if let Some(thinking) = &request.thinking { @@ -1326,6 +1357,100 @@ fn validate_cross_format_request_extensions( Ok(()) } +fn mapped_namespace_request_extensions( + source: FormatId, + target: FormatId, + request: &CanonicalRequest, +) -> std::collections::BTreeMap { + let mut mapped = request.extensions.clone(); + if !matches!( + source, + FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact + ) || target != FormatId::OpenAiChat + || !openai_chat::request::raw_tool_choice_extension_is_representable_for_openai_chat( + request, + ) + { + return mapped; + } + + for provider_namespace in ["openai_responses", "openai_cli"] { + let should_remove_namespace = mapped + .get_mut(provider_namespace) + .and_then(Value::as_object_mut) + .is_some_and(|fields| { + fields.remove("tool_choice"); + fields.is_empty() + }); + if should_remove_namespace { + mapped.remove(provider_namespace); + } + } + mapped +} + +fn mapped_namespace_tool_use_extensions( + source: FormatId, + target: FormatId, + name: &str, + extensions: &std::collections::BTreeMap, + aliases: &NamespaceToolAliases, +) -> Result, FormatError> { + if !matches!( + source, + FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact + ) || target != FormatId::OpenAiChat + { + return Ok(extensions.clone()); + } + + let mut mapped = extensions.clone(); + for provider_namespace in ["openai_responses", "openai_cli"] { + let Some(provider_fields) = mapped + .get_mut(provider_namespace) + .and_then(Value::as_object_mut) + else { + continue; + }; + let Some(namespace) = provider_fields.get("namespace") else { + continue; + }; + let Some(namespace) = namespace + .as_str() + .map(str::trim) + .filter(|namespace| !namespace.is_empty()) + else { + return Err(FormatError::LossyConversionBlocked { + source_format: source.as_str().to_string(), + target_format: target.as_str().to_string(), + field: format!("messages[].content[].{provider_namespace}.namespace"), + reason: "Responses namespace tool call has an invalid namespace identity" + .to_string(), + }); + }; + if aliases.chat_name(namespace, name).is_none() { + return Err(FormatError::LossyConversionBlocked { + source_format: source.as_str().to_string(), + target_format: target.as_str().to_string(), + field: format!("messages[].content[].{provider_namespace}.namespace"), + reason: "Responses namespace tool call does not match an expanded namespace child" + .to_string(), + }); + } + provider_fields.remove("namespace"); + // Responses item IDs are distinct from executable call IDs, but Chat + // has only the latter. A completed history item is fully represented + // by the assistant tool call itself, so these transport/completion + // sidecars can be discarded after the namespace identity is proven. + provider_fields.remove("item_id"); + if provider_fields.get("status").and_then(Value::as_str) == Some("completed") { + provider_fields.remove("status"); + } + } + mapped.retain(|_, value| value.as_object().is_none_or(|object| !object.is_empty())); + Ok(mapped) +} + fn validate_request_content_has_no_unknown_blocks( source: FormatId, target: FormatId, @@ -2383,6 +2508,11 @@ fn validate_openai_responses_to_chat( body: &Value, request: &CanonicalRequest, ) -> Result<(), FormatError> { + validate_openai_responses_namespace_tools_to_chat( + FormatId::OpenAiResponses, + FormatId::OpenAiChat, + request, + )?; let Some(object) = body.as_object() else { return Ok(()); }; @@ -2428,7 +2558,7 @@ fn validate_openai_responses_to_chat( .unwrap_or("function") .trim() .to_ascii_lowercase(); - if tool_type != "function" { + if !matches!(tool_type.as_str(), "function" | "namespace") { return Err(FormatError::LossyConversionBlocked { source_format: FormatId::OpenAiResponses.as_str().to_string(), target_format: FormatId::OpenAiChat.as_str().to_string(), @@ -2448,6 +2578,36 @@ fn validate_openai_responses_to_chat( Ok(()) } +fn validate_openai_responses_namespace_tools_to_chat( + source: FormatId, + target: FormatId, + request: &CanonicalRequest, +) -> Result<(), FormatError> { + if !matches!( + source, + FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact + ) || target != FormatId::OpenAiChat + { + return Ok(()); + } + + let aliases = NamespaceToolAliases::from_canonical_tools(&request.tools); + for (tool_index, _) in request.tools.iter().enumerate() { + if aliases.is_namespace_tool(tool_index) + && !aliases.is_representable_namespace_tool(tool_index) + { + return Err(FormatError::LossyConversionBlocked { + source_format: source.as_str().to_string(), + target_format: target.as_str().to_string(), + field: format!("tools[{tool_index}]"), + reason: "Responses namespace tool cannot be losslessly expanded into OpenAI Chat function tools" + .to_string(), + }); + } + } + Ok(()) +} + fn validate_claude_cross_format_request(body: &Value, target: FormatId) -> Result<(), FormatError> { if claude_request_contains_provider_cache_control(body) { return Err(FormatError::LossyConversionBlocked { @@ -3210,7 +3370,7 @@ mod tests { use serde_json::json; use super::{ - convert_request, convert_request_pure, convert_request_pure_with_context, + convert_request, convert_request_pure, convert_request_pure_with_context, convert_response, convert_response_pure, FormatContext, FormatError, }; use crate::formats::id::FormatId; @@ -3320,6 +3480,479 @@ mod tests { assert_eq!(converted["tools"][0]["function"]["strict"], false); } + #[test] + fn responses_namespace_tool_is_audited_consistently_for_pure_and_runtime_chat_conversion() { + let body = json!({ + "model": "gpt-source", + "input": [{"role": "user", "content": "validate the finding"}], + "tools": [{ + "type": "namespace", + "name": "mcp__vulnerability_report", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "vulnerability_report", + "description": "Create the confirmed vulnerability report", + "parameters": { + "type": "object", + "properties": { + "report_path": {"type": "string"}, + "markdown": {"type": "string"} + }, + "required": ["report_path", "markdown"], + "additionalProperties": false + }, + "strict": true + }] + }], + "tool_choice": "auto" + }); + + let pure = convert_request_pure("openai:responses", "openai:chat", &body) + .expect("representable namespace should pass pure validation") + .value; + let runtime = convert_request( + "openai:responses", + "openai:chat", + &body, + &FormatContext::default(), + ) + .expect("representable namespace should pass runtime validation"); + + assert_eq!(pure, runtime); + assert_eq!(pure["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(pure["tools"][0]["function"]["name"], "vulnerability_report"); + assert_eq!( + pure["tools"][0]["function"]["description"], + "Create the confirmed vulnerability report" + ); + assert_eq!( + pure["tools"][0]["function"]["parameters"], + body["tools"][0]["tools"][0]["parameters"] + ); + assert_eq!(pure["tools"][0]["function"]["strict"], true); + assert_eq!(pure["tool_choice"], "auto"); + } + + #[test] + fn responses_namespace_allowed_tools_and_nullable_schema_match_for_pure_and_runtime() { + let long_name = format!("write_report_{}", "x".repeat(80)); + let body = json!({ + "model": "gpt-source", + "input": "validate the finding", + "tools": [{ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": long_name, + "description": null, + "parameters": null, + "strict": null + }] + }], + "tool_choice": { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": long_name}] + } + }); + + let pure = convert_request_pure("openai:responses", "openai:chat", &body) + .expect("allowed namespace tool choice should pass pure validation") + .value; + let runtime = convert_request( + "openai:responses", + "openai:chat", + &body, + &FormatContext::default(), + ) + .expect("allowed namespace tool choice should pass runtime validation"); + + assert_eq!(pure, runtime); + let definition = &pure["tools"][0]["function"]; + let definition_alias = definition["name"].as_str().expect("definition alias"); + let choice_alias = pure["tool_choice"]["allowed_tools"]["tools"][0]["function"]["name"] + .as_str() + .expect("choice alias"); + assert_eq!(choice_alias, definition_alias); + assert!(definition_alias.len() <= 64); + assert!(definition.get("description").is_none()); + assert!(definition.get("parameters").is_none()); + assert!(definition["strict"].is_null()); + } + + #[test] + fn responses_namespace_allowed_tools_fails_closed_when_child_name_is_ambiguous() { + let body = json!({ + "model": "gpt-source", + "input": "validate the finding", + "tools": [ + { + "type": "namespace", + "name": "first", + "description": "First tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + }, + { + "type": "namespace", + "name": "second", + "description": "Second tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + } + ], + "tool_choice": { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": "write_report"}] + } + }); + + assert!(convert_request_pure("openai:responses", "openai:chat", &body).is_err()); + assert!(convert_request( + "openai:responses", + "openai:chat", + &body, + &FormatContext::default(), + ) + .is_err()); + } + + #[test] + fn runtime_chat_response_restores_namespaced_tool_identity_from_request_context() { + let original_request = json!({ + "model": "qwen", + "input": "write the report", + "tools": [ + { + "type": "function", + "name": "vulnerability_report", + "parameters": {"type": "object"} + }, + { + "type": "namespace", + "name": "mcp__vulnerability_report", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "vulnerability_report", + "parameters": { + "type": "object", + "properties": {"report_path": {"type": "string"}}, + "required": ["report_path"] + }, + "strict": true + }] + } + ] + }); + let chat_request = convert_request( + "openai:responses", + "openai:chat", + &original_request, + &FormatContext::default(), + ) + .expect("namespace request should convert to Chat"); + let alias = chat_request["tools"][1]["function"]["name"] + .as_str() + .expect("namespace child should have a Chat alias"); + assert_ne!(alias, "vulnerability_report"); + + let provider_response = json!({ + "id": "chatcmpl_namespace_sync", + "object": "chat.completion", + "model": "qwen", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_report", + "type": "function", + "function": { + "name": alias, + "arguments": "{\"report_path\":\"reports/sql-001.md\"}" + } + }] + }, + "finish_reason": "tool_calls" + }] + }); + let context = FormatContext::default().with_report_context(json!({ + "provider_api_format": "openai:chat", + "client_api_format": "openai:responses", + "original_request_body": original_request + })); + + let response = convert_response( + "openai:chat", + "openai:responses", + &provider_response, + &context, + ) + .expect("Chat response should convert back to Responses"); + let call = &response["output"][0]; + assert_eq!(response["object"], "response"); + assert_eq!(call["type"], "function_call"); + assert_eq!(call["name"], "vulnerability_report"); + assert_eq!(call["namespace"], "mcp__vulnerability_report"); + assert_eq!(call["call_id"], "call_report"); + } + + #[test] + fn responses_namespace_history_uses_the_same_chat_alias_as_the_definition() { + let body = json!({ + "model": "gpt-source", + "input": [ + { + "type": "function_call", + "id": "fc_report", + "call_id": "call_report", + "namespace": "mcp__reports", + "name": "write_report", + "arguments": "{\"report_path\":\"reports/finding.md\"}", + "status": "completed" + }, + { + "type": "function_call_output", + "call_id": "call_report", + "output": "created" + } + ], + "tools": [ + { + "type": "function", + "name": "write_report", + "description": "An ordinary function", + "parameters": {"type": "object"} + }, + { + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "description": "Create a report file", + "parameters": { + "type": "object", + "properties": {"report_path": {"type": "string"}}, + "required": ["report_path"] + } + }] + } + ], + "tool_choice": "auto" + }); + + let pure = convert_request_pure("openai:responses", "openai:chat", &body) + .expect("namespace history should pass pure validation") + .value; + let runtime = convert_request( + "openai:responses", + "openai:chat", + &body, + &FormatContext::default(), + ) + .expect("namespace history should pass runtime validation"); + + assert_eq!(pure, runtime); + let alias = pure["tools"][1]["function"]["name"] + .as_str() + .expect("namespace alias"); + assert_ne!(alias, "write_report"); + assert_eq!( + pure["messages"][0]["tool_calls"][0]["function"]["name"], + alias + ); + assert_eq!(pure["messages"][0]["tool_calls"][0]["id"], "call_report"); + assert!(pure["messages"][0]["tool_calls"][0].get("status").is_none()); + assert_eq!(pure["messages"][1]["tool_call_id"], "call_report"); + } + + #[test] + fn responses_namespace_history_rejects_non_completed_status_for_chat() { + let body = json!({ + "model": "gpt-source", + "input": [{ + "type": "function_call", + "id": "fc_report", + "call_id": "call_report", + "namespace": "mcp__reports", + "name": "write_report", + "arguments": "{}", + "status": "in_progress" + }], + "tools": [{ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + }] + }); + + assert!(convert_request_pure("openai:responses", "openai:chat", &body).is_err()); + assert!(convert_request( + "openai:responses", + "openai:chat", + &body, + &FormatContext::default(), + ) + .is_err()); + } + + #[test] + fn responses_namespace_history_rejects_unknown_sidecars_for_pure_and_runtime() { + let body = json!({ + "model": "gpt-source", + "input": [{ + "type": "function_call", + "id": "fc_report", + "call_id": "call_report", + "namespace": "mcp__reports", + "name": "write_report", + "arguments": "{}", + "status": "completed", + "future_call_semantics": {"owner": "future-semantic-owner"} + }], + "tools": [{ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object"} + }] + }] + }); + + assert!(convert_request_pure("openai:responses", "openai:chat", &body).is_err()); + assert!(convert_request( + "openai:responses", + "openai:chat", + &body, + &FormatContext::default(), + ) + .is_err()); + } + + #[test] + fn responses_namespace_same_format_preserves_the_raw_contract() { + let body = json!({ + "model": "gpt-source", + "input": "look up the customer", + "tools": [{ + "type": "namespace", + "name": "crm", + "description": "CRM tools", + "tools": [{ + "type": "function", + "name": "get_customer", + "description": "Fetch a customer", + "defer_loading": true, + "parameters": {"type": "object"}, + "output_schema": {"type": "object"} + }] + }] + }); + + let converted = convert_request_pure("openai:responses", "openai:responses", &body) + .expect("same-format namespace request should remain transparent") + .value; + + assert_eq!(converted["tools"], body["tools"]); + assert_eq!(converted["tools"].as_array().map(Vec::len), Some(1)); + } + + #[test] + fn responses_namespace_tool_validation_fails_closed_for_unrepresentable_children() { + let cases = [ + json!({ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "custom", + "name": "write_report", + "description": "custom child" + }] + }), + json!({ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object", "properties": {}}, + "future_child_field": {"cannot": "preserve"} + }] + }), + json!({ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "future_namespace_field": true, + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": {"type": "object", "properties": {}} + }] + }), + json!({ + "type": "namespace", + "name": "mcp__reports", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "write_report", + "parameters": "not-a-schema-object" + }] + }), + ]; + + for namespace_tool in cases { + let body = json!({ + "model": "gpt-source", + "input": "hello", + "tools": [namespace_tool] + }); + + let pure_error = convert_request_pure("openai:responses", "openai:chat", &body) + .expect_err("pure conversion must reject an unrepresentable namespace"); + let runtime_error = convert_request( + "openai:responses", + "openai:chat", + &body, + &FormatContext::default(), + ) + .expect_err("runtime conversion must reject an unrepresentable namespace"); + + for error in [pure_error, runtime_error] { + assert!(matches!( + error, + FormatError::LossyConversionBlocked { ref field, .. } + if field == "tools[0]" + )); + } + } + } + #[test] fn pure_openai_chat_to_responses_maps_tool_call_ids_to_call_ids() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index 601750dee..2f009554c 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -107,7 +107,8 @@ impl StreamingStandardFormatMatrix { let client_api_format = client_api_format_for_context(report_context); self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str()); - self.client = ClientStreamEmitter::for_api_format(client_api_format.as_str()); + self.client = + ClientStreamEmitter::for_api_format(client_api_format.as_str(), report_context); } fn emit_frames( @@ -530,11 +531,13 @@ fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage } impl ClientStreamEmitter { - fn for_api_format(client_api_format: &str) -> Option { + fn for_api_format(client_api_format: &str, report_context: &Value) -> Option { Some(match FormatId::parse(client_api_format)? { FormatId::OpenAiChat => Self::OpenAIChat(OpenAIChatClientEmitter::default()), FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => { - Self::OpenAIResponses(Box::default()) + Self::OpenAIResponses(Box::new(OpenAIResponsesClientEmitter::with_report_context( + report_context, + ))) } FormatId::ClaudeMessages => Self::Claude(ClaudeClientEmitter::default()), FormatId::GeminiGenerateContent => Self::Gemini(GeminiClientEmitter::default()), @@ -752,7 +755,9 @@ fn parse_gemini_error(payload: &Value) -> Option<(String, Option, LocalC #[cfg(test)] mod tests { use super::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver}; - use crate::formats::{context::FormatContext, registry::convert_request}; + use crate::formats::{ + context::FormatContext, openai::namespace::NamespaceToolAliases, registry::convert_request, + }; use serde_json::{json, Value}; fn report_context(provider_api_format: &str, client_api_format: &str) -> Value { @@ -957,6 +962,151 @@ mod tests { ); } + #[test] + fn streamed_chat_namespace_tool_call_restores_responses_identity() { + let report_context = json!({ + "provider_api_format": "openai:chat", + "client_api_format": "openai:responses", + "mapped_model": "qwen", + "needs_conversion": true, + "original_request_body": { + "model": "qwen", + "input": [{"role": "user", "content": "write the report"}], + "tools": [ + { + "type": "function", + "name": "vulnerability_report", + "parameters": {"type": "object", "properties": {}} + }, + { + "type": "namespace", + "name": "mcp__vulnerability_report", + "description": "reporting tools", + "tools": [{ + "type": "function", + "name": "vulnerability_report", + "description": "write the confirmed report", + "parameters": { + "type": "object", + "properties": {"report_path": {"type": "string"}}, + "required": ["report_path"] + }, + "strict": true + }] + } + ] + } + }); + let aliases = NamespaceToolAliases::from_report_context(&report_context); + let chat_name = aliases + .chat_name("mcp__vulnerability_report", "vulnerability_report") + .expect("namespace child should have a Chat alias") + .to_string(); + assert_ne!(chat_name, "vulnerability_report"); + + let mut matrix = StreamingStandardFormatMatrix::default(); + let mut output = Vec::new(); + output.extend( + matrix + .transform_line( + &report_context, + data_line(json!({ + "id": "chatcmpl_namespace_stream_1", + "model": "qwen", + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call_namespace_stream_1", + "type": "function", + "function": { + "name": chat_name, + "arguments": "{\"report_path\":" + } + }] + }, + "finish_reason": Value::Null + }] + })), + ) + .expect("tool start should convert"), + ); + output.extend( + matrix + .transform_line( + &report_context, + data_line(json!({ + "id": "chatcmpl_namespace_stream_1", + "model": "qwen", + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "function": {"arguments": "\"reports/sql-001.md\"}"} + }] + }, + "finish_reason": Value::Null + }] + })), + ) + .expect("tool arguments should convert"), + ); + output.extend( + matrix + .transform_line( + &report_context, + data_line(json!({ + "id": "chatcmpl_namespace_stream_1", + "model": "qwen", + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": "tool_calls" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 4, + "total_tokens": 14 + } + })), + ) + .expect("tool finish should convert"), + ); + + let events = json_data_events(&output); + let added = events + .iter() + .find(|event| event["type"] == "response.output_item.added") + .expect("function-call item should start"); + assert_eq!(added["item"]["name"], "vulnerability_report"); + assert_eq!(added["item"]["namespace"], "mcp__vulnerability_report"); + let done = events + .iter() + .find(|event| event["type"] == "response.output_item.done") + .expect("function-call item should complete"); + assert_eq!(done["item"]["name"], "vulnerability_report"); + assert_eq!(done["item"]["namespace"], "mcp__vulnerability_report"); + let completed = events + .iter() + .find(|event| event["type"] == "response.completed") + .expect("response should complete"); + let function_call = completed["response"]["output"] + .as_array() + .expect("response output") + .iter() + .find(|item| item["type"] == "function_call") + .expect("completed function call"); + assert_eq!(function_call["name"], "vulnerability_report"); + assert_eq!(function_call["namespace"], "mcp__vulnerability_report"); + + let persisted = matrix + .take_response_history_record() + .expect("completed stream should expose response history"); + assert!(persisted.payload.contains("mcp__vulnerability_report")); + } + #[test] fn transforms_provider_errors_to_openai_chat_error_bodies() { let cases = [ diff --git a/crates/aether-ai/formats/src/formats/shared/sync_to_stream.rs b/crates/aether-ai/formats/src/formats/shared/sync_to_stream.rs index 111cf6e50..9d2a38224 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_to_stream.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_to_stream.rs @@ -118,12 +118,14 @@ pub fn maybe_bridge_standard_sync_json_to_stream( openai_responses_terminal_event_type(&openai_responses_response) .unwrap_or("response.completed"), provider_actual_service_tier.as_deref(), + &bridge_context, )? } else { emit_client_stream_from_canonical_frames( canonical_frames, client_api_format.as_str(), provider_actual_service_tier.as_deref(), + &bridge_context, )? }; @@ -167,6 +169,7 @@ fn bridge_openai_responses_same_family_sync_json_to_stream( response, terminal_event_type, provider_actual_service_tier_from_sync_response(response, provider_api_format).as_deref(), + report_context, )?; Ok(Some(SyncToStreamBridgeOutcome { @@ -1046,6 +1049,7 @@ fn emit_client_stream_from_canonical_frames( canonical_frames: Vec, client_api_format: &str, provider_actual_service_tier: Option<&str>, + report_context: &Value, ) -> Result, AiSurfaceFinalizeError> { match client_api_format { "openai:chat" => { @@ -1054,7 +1058,7 @@ fn emit_client_stream_from_canonical_frames( emit_with_openai_chat_emitter(&mut emitter, canonical_frames) } "openai:responses" | "openai:responses:compact" => { - let mut emitter = OpenAIResponsesClientEmitter::default(); + let mut emitter = OpenAIResponsesClientEmitter::with_report_context(report_context); emitter.set_actual_service_tier(provider_actual_service_tier); emit_with_openai_responses_emitter(&mut emitter, canonical_frames) } @@ -1115,8 +1119,9 @@ fn emit_openai_responses_stream_with_authoritative_terminal( authoritative_response: &Value, terminal_event_type: &'static str, provider_actual_service_tier: Option<&str>, + report_context: &Value, ) -> Result, AiSurfaceFinalizeError> { - let mut emitter = OpenAIResponsesClientEmitter::default(); + let mut emitter = OpenAIResponsesClientEmitter::with_report_context(report_context); emitter.set_actual_service_tier(provider_actual_service_tier); let mut output = Vec::new(); for frame in canonical_frames { @@ -1316,6 +1321,7 @@ mod tests { use serde_json::{json, Value}; use super::{maybe_bridge_standard_sync_json_to_stream, standardized_usage_from_openai_usage}; + use crate::formats::openai::namespace::NamespaceToolAliases; fn utf8(bytes: Vec) -> String { String::from_utf8(bytes).expect("utf8 should decode") @@ -1380,6 +1386,88 @@ mod tests { assert!(history_record.payload.contains("resp_sync_history_1")); } + #[test] + fn chat_sync_bridge_restores_namespaced_responses_tool_identity() { + let report_context = json!({ + "provider_api_format": "openai:chat", + "client_api_format": "openai:responses", + "needs_conversion": true, + "original_request_body": { + "model": "qwen", + "input": "write the report", + "tools": [ + { + "type": "function", + "name": "vulnerability_report", + "parameters": {"type": "object"} + }, + { + "type": "namespace", + "name": "mcp__vulnerability_report", + "description": "Reporting tools", + "tools": [{ + "type": "function", + "name": "vulnerability_report", + "parameters": { + "type": "object", + "properties": {"report_path": {"type": "string"}} + } + }] + } + ] + } + }); + let aliases = NamespaceToolAliases::from_report_context(&report_context); + let chat_name = aliases + .chat_name("mcp__vulnerability_report", "vulnerability_report") + .expect("namespace alias"); + let outcome = maybe_bridge_standard_sync_json_to_stream( + &json!({ + "id": "chatcmpl_namespace_sync_1", + "object": "chat.completion", + "model": "qwen", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_namespace_sync_1", + "type": "function", + "function": { + "name": chat_name, + "arguments": "{\"report_path\":\"reports/finding.md\"}" + } + }] + }, + "finish_reason": "tool_calls" + }] + }), + "openai:chat", + "openai:responses", + Some(&report_context), + ) + .expect("bridge should succeed") + .expect("bridge should produce SSE"); + + let body = utf8(outcome.sse_body); + let events = json_sse_events(&body); + let done = events + .iter() + .find(|event| event["type"] == "response.output_item.done") + .expect("function call should complete"); + assert_eq!(done["item"]["name"], "vulnerability_report"); + assert_eq!(done["item"]["namespace"], "mcp__vulnerability_report"); + let completed = events + .iter() + .find(|event| event["type"] == "response.completed") + .expect("response should complete"); + assert_eq!( + completed["response"]["output"][0]["namespace"], + "mcp__vulnerability_report" + ); + } + #[test] fn openai_sync_usage_derives_missing_input_tokens_from_total() { let usage = standardized_usage_from_openai_usage(&json!({ diff --git a/docs/api/format-field-coverage-matrix.md b/docs/api/format-field-coverage-matrix.md index 84e862ddd..2c2103097 100644 --- a/docs/api/format-field-coverage-matrix.md +++ b/docs/api/format-field-coverage-matrix.md @@ -384,7 +384,7 @@ Statuses used in this matrix: `native`, `mapped`, `mapped/lossy-blocked`, `exten | OpenAI | `CreateResponse` | `temperature` | 否 | `number \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | | OpenAI | `CreateResponse` | `text` | 否 | `ResponseTextParam` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | | OpenAI | `CreateResponse` | `tool_choice` | 否 | `ToolChoiceParam` | openai:responses standard | native | mapped | mapped | tool choice enum/name style maps provider-specifically | -| OpenAI | `CreateResponse` | `tools` | 否 | `ToolsArray` | openai:responses standard | native | mapped | mapped | function tools map; custom/built-in tools fail closed unless target supports equivalent | +| OpenAI | `CreateResponse` | `tools` | 否 | `ToolsArray` | openai:responses standard | native | mapped | mapped | function tools map; function-only namespaces expand to reversible Chat aliases with child description/schema/strict preserved; ambiguous or unsupported namespace/custom/built-in tools fail closed unless the target supports an equivalent | | OpenAI | `CreateResponse` | `top_logprobs` | 否 | `integer \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | | OpenAI | `CreateResponse` | `top_p` | 否 | `number \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | | OpenAI | `CreateResponse` | `truncation` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | @@ -528,7 +528,7 @@ Statuses used in this matrix: `native`, `mapped`, `mapped/lossy-blocked`, `exten | OpenAI | `FunctionToolCall` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | | OpenAI | `FunctionToolCall` | `id` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | | OpenAI | `FunctionToolCall` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCall` | `namespace` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | +| OpenAI | `FunctionToolCall` | `namespace` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | Responses→Chat history and Chat→Responses sync/stream responses use the reversible alias derived from the original namespace definition; missing, ambiguous, or unsupported mappings fail closed | | OpenAI | `FunctionToolCall` | `status` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | | OpenAI | `FunctionToolCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | | OpenAI | `FunctionToolCallOutput` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | @@ -752,10 +752,10 @@ Statuses used in this matrix: `native`, `mapped`, `mapped/lossy-blocked`, `exten | OpenAI | `MoveParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | | OpenAI | `MoveParam` | `x` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | | OpenAI | `MoveParam` | `y` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `NamespaceToolParam` | `description` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `NamespaceToolParam` | `name` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `NamespaceToolParam` | `tools` | 是 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `NamespaceToolParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | +| OpenAI | `NamespaceToolParam` | `description` | 是 | `string` | openai:responses standard | native | extension-preserved | mapped/lossy-blocked | Responses→Chat expands function-only namespaces into reversible aliases and preserves each child definition; unsupported children or unknown fields fail closed | +| OpenAI | `NamespaceToolParam` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | mapped/lossy-blocked | namespace identity is retained in the request alias map and restored on sync/stream Responses function calls; missing or ambiguous mappings fail closed | +| OpenAI | `NamespaceToolParam` | `tools` | 是 | `array` | openai:responses standard | native | extension-preserved | mapped/lossy-blocked | function children expand to Chat function definitions with description/schema/strict preserved; custom children and unrepresentable fields fail closed | +| OpenAI | `NamespaceToolParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | mapped/lossy-blocked | namespace containers map only through the audited reversible Responses→Chat adapter; other cross-format targets fail closed | | OpenAI | `OutputMessage` | `content` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | | OpenAI | `OutputMessage` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | | OpenAI | `OutputMessage` | `phase` | 否 | `MessagePhase \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed |