fix(formats): preserve Responses namespace tools through Chat

This commit is contained in:
ZheFox
2026-08-26 00:43:50 +08:00
parent ffca7e0402
commit e2b003af24
9 changed files with 2586 additions and 99 deletions
@@ -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<CanonicalRequest> {
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
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<Value> {
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<CanonicalRequest> {
let request = body_json.as_object()?;
let mut canonical = CanonicalRequest {
@@ -135,7 +148,10 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
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<Value> {
fn canonical_tool_choice_to_openai_for_request(
canonical: &CanonicalRequest,
namespace_tool_aliases: &NamespaceToolAliases,
) -> Option<Value> {
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<Value> {
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::<Vec<_>>();
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<String, Value>, keys: &[&str]) -> bool {
object.len() == keys.len() && object.keys().all(|key| keys.contains(&key.as_str()))
}
fn valid_named_choice(choice: &Map<String, Value>) -> 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<String, Value>,
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<String, Value>) -> 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<String, Value>,
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::<std::collections::BTreeSet<_>>();
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"
));
}
}
@@ -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<Option<String>, ()> {
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<String>,
arguments: String,
output_index: Option<usize>,
web_search: bool,
@@ -1915,6 +1990,7 @@ pub struct OpenAIResponsesClientEmitter {
opaque_output_items: BTreeMap<usize, Value>,
opaque_output_indexes: BTreeMap<String, usize>,
completed_history_response: Option<Value>,
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();
@@ -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;
@@ -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<String>,
pub parameters: Option<Value>,
pub strict: Option<Value>,
}
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<NamespaceChatTool>,
by_identity: BTreeMap<(String, String), String>,
by_chat_name: BTreeMap<String, (String, String)>,
namespace_tool_indices: BTreeSet<usize>,
invalid_namespace_tool_indices: BTreeSet<usize>,
}
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::<String, usize>::new();
let mut ordinary_chat_names = BTreeSet::<String>::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<usize>>::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::<BTreeMap<_, _>>();
let mut preferred_counts = BTreeMap::<String, usize>::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<Item = &NamespaceChatTool> {
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<Item = &'a NamespaceChatTool> + '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<String, Value>> {
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<Vec<NamespaceChatTool>> {
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<String, Value>, 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>) -> 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::<String>();
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::<BTreeSet<_>>();
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));
}
}
}
@@ -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<String, Value>) -> 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();
@@ -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<String, Value> {
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<String, Value>,
aliases: &NamespaceToolAliases,
) -> Result<std::collections::BTreeMap<String, Value>, 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!({
@@ -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<Self> {
fn for_api_format(client_api_format: &str, report_context: &Value) -> Option<Self> {
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<String>, 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 = [
@@ -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<CanonicalStreamFrame>,
client_api_format: &str,
provider_actual_service_tier: Option<&str>,
report_context: &Value,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<u8>) -> 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!({