mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
fix(formats): preserve Responses namespace tools through Chat
This commit is contained in:
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user