From 9541fbe76aa6f60a6585915c278f5ecc46f04c4c Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Thu, 5 Nov 2026 07:45:00 +0000 Subject: [PATCH] [Rust Frontend] Support namespace tools in the Responses API Show each function of a Responses `namespace` tool to the model as `__`, like the Python frontend, and report calls of that flat name as a `function_call` with the function name and `namespace`. Named tool choices resolve by the flat name or a function name unique to one namespace, and `function_call` history items with a namespace replay under the flat name. Co-authored-by: Claude Opus 5.5 Signed-off-by: Bugen Zhao --- .../src/server/src/routes/openai/responses.rs | 22 +- .../src/routes/openai/responses/convert.rs | 104 ++++++- .../src/routes/openai/responses/streaming.rs | 29 +- .../src/routes/openai/responses/tools.rs | 272 ++++++++++++++++++ .../src/routes/openai/responses/types.rs | 29 ++ rust/src/server/src/routes/tests/responses.rs | 90 ++++++ 6 files changed, 507 insertions(+), 39 deletions(-) create mode 100644 rust/src/server/src/routes/openai/responses/tools.rs diff --git a/rust/src/server/src/routes/openai/responses.rs b/rust/src/server/src/routes/openai/responses.rs index 9035409670d3..e32f36f0cf30 100644 --- a/rust/src/server/src/routes/openai/responses.rs +++ b/rust/src/server/src/routes/openai/responses.rs @@ -15,6 +15,7 @@ pub mod types; mod convert; mod streaming; +mod tools; mod validate; use std::convert::Infallible; @@ -87,24 +88,20 @@ pub async fn create_responses( created_at, ); + let items = OutputItemStreamer::new(prepared.include_reasoning, prepared.tool_names); if stream_requested { let event_stream = responses_event_stream( chat_stream, response, - prepared.include_reasoning, + items, api_server_options.enable_per_request_metrics, ); let sse_stream = responses_sse_stream(event_stream).instrument(request_span); sse_response(sse_stream, api_server_options.sse_keep_alive_interval) } else { - match collect_responses( - chat_stream, - response, - prepared.include_reasoning, - &api_server_options, - ) - .instrument(request_span) - .await + match collect_responses(chat_stream, response, items, &api_server_options) + .instrument(request_span) + .await { Ok(response) => Json(response).into_response(), Err(error) => error.into_response(), @@ -141,14 +138,13 @@ fn response_not_found(response_id: &str) -> ApiError { async fn collect_responses( mut stream: ChatEventStream, response: ResponsesResponse, - include_reasoning: bool, + mut items: OutputItemStreamer, ApiServerOptions { enable_log_requests, enable_per_request_metrics, .. }: &ApiServerOptions, ) -> Result { - let mut items = OutputItemStreamer::new(include_reasoning); let terminal = loop { let Some(event) = stream.next().await else { return Err(server_error!( @@ -296,12 +292,10 @@ fn failed_response( async fn responses_event_stream( mut stream: impl ChatEventStreamTrait + Unpin, response: ResponsesResponse, - include_reasoning: bool, + mut items: OutputItemStreamer, enable_per_request_metrics: bool, mut y: TryYielder, ) -> Result<(), Infallible> { - let mut items = OutputItemStreamer::new(include_reasoning); - let initial = Arc::new(response); y.yield_ok(ResponseStreamEvent::Created { response: Arc::clone(&initial), diff --git a/rust/src/server/src/routes/openai/responses/convert.rs b/rust/src/server/src/routes/openai/responses/convert.rs index 9727345ef9bc..c60a765e6610 100644 --- a/rust/src/server/src/routes/openai/responses/convert.rs +++ b/rust/src/server/src/routes/openai/responses/convert.rs @@ -16,12 +16,13 @@ use thiserror_ext::AsReport as _; use tracing::warn; use vllm_chat::{ AssistantContentBlock, AssistantToolCall, ChatContent, ChatContentPart, ChatMessage, - ChatOptions, ChatRequest, ChatTool, ChatToolChoice, Error as ChatError, GenerationPromptMode, + ChatOptions, ChatRequest, ChatToolChoice, Error as ChatError, GenerationPromptMode, ResolvedToolContext, }; use vllm_text::output::TextDecodeOptions; use vllm_text::{EffectiveSampling, SamplingParams}; +use super::tools::{ResponsesToolNames, ToolIdentity}; use super::types::{ InputTokensDetails, OutputTokensDetails, ResponseInputContentPart, ResponseInputItem, ResponseInputReasoning, ResponseInputRole, ResponseItemStatus, ResponseMessageContent, @@ -70,6 +71,8 @@ pub(crate) struct PreparedResponsesRequest { pub meta: ResponseMeta, /// Whether reasoning items appear in the output. pub include_reasoning: bool, + /// Maps called tool names back to namespaced functions in the output. + pub tool_names: ResponsesToolNames, pub chat_request: ChatRequest, } @@ -148,11 +151,9 @@ pub(crate) fn prepare_responses_request( GenerationPromptMode::StartNewAssistant }; - let converted_tools = tools - .iter() - .map(|ResponsesTool::Function(function)| ChatTool::from(function.clone())) - .collect::>(); - let requested_tool_choice = normalize_tool_choice(tool_choice, converted_tools.is_empty())?; + let (converted_tools, tool_names) = ResponsesToolNames::convert(&tools); + let requested_tool_choice = + normalize_tool_choice(tool_choice, &tools, &tool_names, converted_tools.is_empty())?; let tool_context = ResolvedToolContext::new( &messages, converted_tools, @@ -302,6 +303,7 @@ pub(crate) fn prepare_responses_request( request_id, meta, include_reasoning, + tool_names, chat_request, }) } @@ -329,10 +331,12 @@ fn response_prompt_truncation( /// Resolve the requested tool choice, mirroring the Python /// `check_tool_usage` validator: without tools, `auto` is normalized to /// `none` and named function choices are errors; with tools, named choices -/// must exist (enforced by -/// [`ResolvedToolContext`]). +/// must exist and resolve to the name the model sees (see +/// [`ResponsesToolNames::resolve_choice`]). fn normalize_tool_choice( tool_choice: Option, + tools: &[ResponsesTool], + tool_names: &ResponsesToolNames, tools_empty: bool, ) -> Result, ApiError> { let Some(tool_choice) = tool_choice else { @@ -350,7 +354,9 @@ fn normalize_tool_choice( "Tool choice 'function' not found in 'tools' parameter." ); } - ChatToolChoice::Function { name } + ChatToolChoice::Function { + name: tool_names.resolve_choice(name, tools)?, + } } ResponseToolChoice::Object(ResponseToolChoiceObject::AllowedTools { tools, .. }) => { bail_invalid_request!( @@ -430,9 +436,18 @@ fn convert_input( } }, ResponseInputItem::FunctionCall(call) => { + let name = match &call.namespace { + Some(namespace) => ToolIdentity { + namespace: Some(namespace), + name: &call.name, + } + .flat_name() + .into_owned(), + None => call.name, + }; assistant.push_block(AssistantContentBlock::ToolCall(AssistantToolCall { id: call.call_id, - name: call.name, + name, arguments: call.arguments, })); } @@ -776,32 +791,91 @@ fn should_continue_final_message(items: &[ResponseInputItem]) -> bool { #[cfg(test)] mod tests { use serde_json::{Value, json}; - use vllm_chat::ChatTokenUsage; + use vllm_chat::{ChatTokenUsage, ChatTool}; use vllm_llm::TokenUsage; use super::*; + use crate::model_names::ServedModelNames; #[test] fn auto_tool_choice_without_tools_normalizes_to_none() { let tool_choice = serde_json::from_value(json!("auto")).unwrap(); assert_eq!( - normalize_tool_choice(Some(tool_choice), true).unwrap(), + normalize_tool_choice(Some(tool_choice), &[], &ResponsesToolNames::default(), true) + .unwrap(), Some(ChatToolChoice::None) ); } #[test] fn responses_function_tool_defaults_parameters_to_null() { - let ResponsesTool::Function(function) = serde_json::from_value(json!({ + let Ok(ResponsesTool::Function(function)) = serde_json::from_value(json!({ "type": "function", "name": "lookup", - })) - .expect("deserialize function tool"); + })) else { + panic!("deserialize function tool"); + }; assert_eq!(ChatTool::from(function).parameters, Value::Null); } + #[test] + fn namespace_tools_reach_the_model_by_their_flat_names() { + let request: ResponsesRequest = serde_json::from_value(json!({ + "input": [ + {"role": "user", "content": "Who is Ada?"}, + { + "type": "function_call", + "call_id": "call_1", + "name": "get_contact", + "namespace": "crm", + "arguments": "{\"name\":\"Ada\"}" + }, + {"type": "function_call_output", "call_id": "call_1", "output": "found"} + ], + "tools": [{ + "type": "namespace", + "name": "crm", + "description": "CRM tools.", + "tools": [{"type": "function", "name": "get_contact"}] + }], + "tool_choice": {"type": "function", "name": "get_contact"} + })) + .expect("deserialize request"); + let served = LoraModelResolution { + model_names: ServedModelNames::new(vec!["test-model".to_string()]), + lora_request: None, + }; + + let prepared = prepare_responses_request(request, &served, Default::default()) + .expect("prepare request"); + + let tool_context = &prepared.chat_request.tool_context; + let names = tool_context.initial_tools.iter().map(|tool| tool.name.as_str()); + assert_eq!(names.collect::>(), ["crm__get_contact"]); + let flat_choice = ChatToolChoice::Function { + name: "crm__get_contact".to_string(), + }; + assert_eq!(tool_context.tool_choice, flat_choice); + assert_eq!( + prepared.meta.tool_choice, + ResponseToolChoice::Object(ResponseToolChoiceObject::Function(FunctionChoice { + name: "crm__get_contact".to_string(), + })) + ); + let ChatMessage::Assistant { content } = &prepared.chat_request.messages[1] else { + panic!("assistant turn: {:?}", prepared.chat_request.messages); + }; + assert!( + matches!( + content.as_slice(), + [AssistantContentBlock::ToolCall(call)] if call.name == "crm__get_contact" + ), + "{content:?}" + ); + } + #[test] fn usage_preserves_reasoning_and_prompt_cache_token_counts() { let usage = build_usage(&ChatTokenUsage { diff --git a/rust/src/server/src/routes/openai/responses/streaming.rs b/rust/src/server/src/routes/openai/responses/streaming.rs index 71c24c34fb2e..abe59c5c6559 100644 --- a/rust/src/server/src/routes/openai/responses/streaming.rs +++ b/rust/src/server/src/routes/openai/responses/streaming.rs @@ -19,6 +19,7 @@ use vllm_chat::{ AssistantBlockKind, AssistantContentBlock, AssistantToolCall, ChatEvent, FinishReason, }; +use super::tools::{ResponsesToolNames, ToolIdentity}; use super::types::{ AssistantRole, ResponseItemStatus, ResponseOutputContentPart, ResponseOutputItem, ResponsesResponse, TextPart, @@ -175,6 +176,8 @@ pub(crate) struct OutputItemStreamer { current: Option, /// Whether reasoning items appear in the stream and final output. include_reasoning: bool, + /// Reports calls of flat namespaced tool names with their namespace. + tool_names: ResponsesToolNames, /// Items completed so far, in output order. completed_items: Vec, /// Whether the last completed item still owes its `output_item.done` @@ -196,16 +199,18 @@ enum OpenItem { FunctionCall { item_id: String, call_id: String, - name: String, + /// The name the model called, flat for a namespaced function. + called_name: String, arguments: String, }, } impl OutputItemStreamer { - pub(crate) fn new(include_reasoning: bool) -> Self { + pub(crate) fn new(include_reasoning: bool, tool_names: ResponsesToolNames) -> Self { Self { current: None, include_reasoning, + tool_names, completed_items: Vec::new(), last_item_done_pending: false, } @@ -373,13 +378,15 @@ impl OutputItemStreamer { self.current = Some(OpenItem::FunctionCall { item_id: item_id.clone(), call_id: call_id.clone(), - name: name.to_string(), + called_name: name.to_string(), arguments: String::new(), }); + let ToolIdentity { namespace, name } = self.tool_names.decode(name); let item = ResponseOutputItem::FunctionCall { id: item_id, call_id, name: name.to_string(), + namespace: namespace.map(str::to_string), arguments: String::new(), status: Some(ResponseItemStatus::InProgress), }; @@ -501,23 +508,25 @@ impl OutputItemStreamer { OpenItem::FunctionCall { item_id, call_id, - name, + called_name, arguments, } => { - let (call_id, name, arguments) = match final_call { + let (call_id, called_name, arguments) = match final_call { Some(call) => (call.id.clone(), call.name.clone(), call.arguments.clone()), - None => (call_id, name, arguments), + None => (call_id, called_name, arguments), }; + let ToolIdentity { namespace, name } = self.tool_names.decode(&called_name); let events = vec![ResponseStreamEvent::FunctionCallArgumentsDone { item_id: item_id.clone(), output_index, - name: name.clone(), + name: name.to_string(), arguments: arguments.clone(), }]; let item = ResponseOutputItem::FunctionCall { id: item_id, call_id, - name, + name: name.to_string(), + namespace: namespace.map(str::to_string), arguments, status: Some(ResponseItemStatus::Completed), }; @@ -578,7 +587,7 @@ mod tests { #[test] fn empty_function_call_emits_arguments_done() { - let mut streamer = OutputItemStreamer::new(true); + let mut streamer = OutputItemStreamer::new(true, ResponsesToolNames::default()); streamer.on_event(&ChatEvent::ToolCallStart { index: 0, id: "call_1".to_string(), @@ -606,7 +615,7 @@ mod tests { #[test] fn empty_reasoning_block_is_not_an_output_item() { - let mut streamer = OutputItemStreamer::new(true); + let mut streamer = OutputItemStreamer::new(true, ResponsesToolNames::default()); for event in [ ChatEvent::BlockStart { index: 0, diff --git a/rust/src/server/src/routes/openai/responses/tools.rs b/rust/src/server/src/routes/openai/responses/tools.rs new file mode 100644 index 000000000000..459967fc2c1f --- /dev/null +++ b/rust/src/server/src/routes/openai/responses/tools.rs @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +//! Function tools of a Responses request, including `namespace` tools. +//! +//! Mirrors the Python frontend (`get_function_tools` and +//! `build_responses_tool_call_name_map` in `vllm/tool_parsers/utils.py`, +//! `_resolve_named_tool_choice` in `responses/protocol.py`): the model sees +//! each namespaced function as one flat `__` function tool, +//! and a call of that flat name is reported with the function name and its +//! namespace. The flat spelling is defined only by [`ToolIdentity::flat_name`]. + +use std::borrow::Cow; +use std::collections::HashMap; + +use itertools::Itertools as _; +use vllm_chat::ChatTool; + +use super::types::{NamespacedTool, ResponsesTool}; +use crate::error::{ApiError, bail_invalid_request}; + +const NAMESPACE_TOOL_SEPARATOR: &str = "__"; + +/// A function tool as the client names it: a namespace tool's function +/// carries its namespace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ToolIdentity<'a> { + pub namespace: Option<&'a str>, + pub name: &'a str, +} + +impl<'a> ToolIdentity<'a> { + /// The name the model sees: `__`, or the bare name. + pub(super) fn flat_name(self) -> Cow<'a, str> { + match self.namespace { + Some(namespace) => Cow::Owned(format!( + "{namespace}{NAMESPACE_TOOL_SEPARATOR}{}", + self.name + )), + None => Cow::Borrowed(self.name), + } + } +} + +/// Request-local map from the flat names of namespaced functions back to +/// their identities, for reporting calls and resolving `tool_choice`. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct ResponsesToolNames { + /// Flat name of each namespaced function -> byte length of its namespace. + /// Like Python, a later function with the same flat name wins. + namespaced: HashMap, +} + +impl ResponsesToolNames { + /// Convert the request tools to the function tools the model is shown, in + /// request order, together with the names to map calls back. + pub(super) fn convert(tools: &[ResponsesTool]) -> (Vec, Self) { + let mut chat_tools = Vec::with_capacity(tools.len()); + let mut namespaced = HashMap::new(); + for tool in tools { + match tool { + ResponsesTool::Function(function) => { + chat_tools.push(ChatTool::from(function.clone())); + } + ResponsesTool::Namespace(namespace) => { + for NamespacedTool::Function(function) in &namespace.tools { + let identity = ToolIdentity { + namespace: Some(&namespace.name), + name: &function.name, + }; + let flat_name = identity.flat_name().into_owned(); + namespaced.insert(flat_name.clone(), namespace.name.len()); + chat_tools.push(ChatTool { + name: flat_name, + ..ChatTool::from(function.clone()) + }); + } + } + } + } + (chat_tools, Self { namespaced }) + } + + /// The identity of a called tool name. Names of other tools pass through + /// without a namespace. + pub(super) fn decode<'a>(&self, called_name: &'a str) -> ToolIdentity<'a> { + match self.namespaced.get(called_name) { + Some(&namespace_len) => ToolIdentity { + namespace: Some(&called_name[..namespace_len]), + name: &called_name[namespace_len + NAMESPACE_TOOL_SEPARATOR.len()..], + }, + None => ToolIdentity { + namespace: None, + name: called_name, + }, + } + } + + /// Resolve a named tool choice to the name the model sees. + /// + /// An exact tool name (including a flat `__`) wins; + /// otherwise a function name defined in exactly one namespace selects + /// that function. + pub(super) fn resolve_choice( + &self, + name: String, + tools: &[ResponsesTool], + ) -> Result { + let is_exact = self.namespaced.contains_key(&name) + || tools.iter().any( + |tool| matches!(tool, ResponsesTool::Function(function) if function.name == name), + ); + if is_exact { + return Ok(name); + } + let mut candidates = tools + .iter() + .filter_map(|tool| match tool { + ResponsesTool::Namespace(namespace) => Some(namespace), + ResponsesTool::Function(_) => None, + }) + .flat_map(|namespace| { + namespace + .tools + .iter() + .filter(|NamespacedTool::Function(function)| function.name == name) + .map(|_| { + ToolIdentity { + namespace: Some(&namespace.name), + name: &name, + } + .flat_name() + .into_owned() + }) + }) + .collect_vec(); + match candidates.len() { + 0 => bail_invalid_request!( + param = "tool_choice", + "Tool choice 'function' not found in 'tools' parameter." + ), + 1 => Ok(candidates.pop().expect("one candidate")), + _ => bail_invalid_request!( + param = "tool_choice", + "Tool choice '{name}' is ambiguous: it is defined in more than one namespace ({}). \ + Select it by its full '__' name.", + candidates.iter().join(", ") + ), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use thiserror_ext::AsReport as _; + + use super::*; + + fn request_tools(tools: serde_json::Value) -> Vec { + serde_json::from_value(tools).expect("deserialize tools") + } + + fn namespace(name: &str, functions: &[&str]) -> serde_json::Value { + json!({ + "type": "namespace", + "name": name, + "description": "Grouped tools.", + "tools": functions + .iter() + .map(|function| json!({"type": "function", "name": function})) + .collect::>(), + }) + } + + #[test] + fn namespaced_functions_are_shown_flat_in_request_order() { + let tools = request_tools(json!([ + {"type": "function", "name": "lookup", "description": "Top level."}, + { + "type": "namespace", + "name": "crm", + "description": "CRM tools.", + "tools": [{ + "type": "function", + "name": "get_contact", + "description": "Get a contact.", + "parameters": {"type": "object"}, + "strict": true + }] + } + ])); + + let (shown, _) = ResponsesToolNames::convert(&tools); + + let names = shown.iter().map(|tool| tool.name.as_str()).collect::>(); + assert_eq!(names, ["lookup", "crm__get_contact"]); + assert_eq!(shown[1].description.as_deref(), Some("Get a contact.")); + assert_eq!(shown[1].parameters, json!({"type": "object"})); + assert_eq!(shown[1].strict, Some(true)); + } + + #[test] + fn custom_tools_inside_a_namespace_are_rejected() { + let error = serde_json::from_value::>(json!([{ + "type": "namespace", + "name": "crm", + "description": "CRM tools.", + "tools": [{"type": "custom", "name": "raw"}] + }])) + .expect_err("custom tool inside a namespace"); + + let report = error.to_report_string(); + assert!(report.contains("unknown variant `custom`"), "{report}"); + } + + #[test] + fn called_flat_names_resolve_to_the_namespaced_function() { + let (_, names) = ResponsesToolNames::convert(&request_tools(json!([ + {"type": "function", "name": "lookup"}, + namespace("mcp__computer_use", &["get_app_state"]), + ]))); + + assert_eq!( + names.decode("mcp__computer_use__get_app_state"), + ToolIdentity { + namespace: Some("mcp__computer_use"), + name: "get_app_state", + } + ); + let bare = |name| ToolIdentity { + namespace: None, + name, + }; + assert_eq!(names.decode("lookup"), bare("lookup")); + assert_eq!(names.decode("get_app_state"), bare("get_app_state")); + } + + #[test] + fn named_tool_choice_resolves_exact_then_unique_function_names() { + let tools = request_tools(json!([ + {"type": "function", "name": "lookup"}, + namespace("crm", &["get_contact", "lookup", "search"]), + namespace("docs", &["search"]), + ])); + let (_, names) = ResponsesToolNames::convert(&tools); + let resolve = |name: &str| names.resolve_choice(name.to_string(), &tools); + + assert_eq!(resolve("lookup").unwrap(), "lookup"); + assert_eq!(resolve("crm__lookup").unwrap(), "crm__lookup"); + assert_eq!(resolve("get_contact").unwrap(), "crm__get_contact"); + assert_eq!(resolve("docs__search").unwrap(), "docs__search"); + + let invalid_tool_choice = |name: &str| { + let error = resolve(name).expect_err("invalid tool choice"); + let ApiError::InvalidRequest { message, param } = error else { + panic!("unexpected error: {error:?}"); + }; + assert_eq!(param, Some("tool_choice")); + message + }; + assert_eq!( + invalid_tool_choice("search"), + "Tool choice 'search' is ambiguous: it is defined in more than one namespace \ + (crm__search, docs__search). Select it by its full '__' name." + ); + assert_eq!( + invalid_tool_choice("missing"), + "Tool choice 'function' not found in 'tools' parameter." + ); + } +} diff --git a/rust/src/server/src/routes/openai/responses/types.rs b/rust/src/server/src/routes/openai/responses/types.rs index 75087a55ceaf..7dfc73d3ad82 100644 --- a/rust/src/server/src/routes/openai/responses/types.rs +++ b/rust/src/server/src/routes/openai/responses/types.rs @@ -165,6 +165,28 @@ pub enum ServiceTier { #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponsesTool { Function(Function), + /// Function tools grouped under a namespace. The model sees each one as + /// `__`; see `tools.rs`. + Namespace(NamespaceTool), +} + +/// Responses API `namespace` tool. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NamespaceTool { + /// The namespace name used in tool calls. + pub name: String, + /// A description of the namespace. Only echoed: like Python, the model + /// sees the flattened function tools alone. + pub description: String, + pub tools: Vec, +} + +/// One tool inside a [`NamespaceTool`]. Custom tools are rejected during +/// deserialization, as they are at the top level. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum NamespacedTool { + Function(Function), } /// Responses API `tool_choice` field. @@ -436,6 +458,9 @@ pub enum ResponseInputContentPart { pub struct ResponseInputFunctionCall { pub call_id: String, pub name: String, + /// Namespace of a function from a `namespace` tool. + #[serde(default)] + pub namespace: Option, pub arguments: String, #[serde(default)] pub id: Option, @@ -547,7 +572,11 @@ pub enum ResponseOutputItem { FunctionCall { id: String, call_id: String, + /// The function name, without its namespace. name: String, + /// Namespace of a function from a `namespace` tool. + #[serde(default)] + namespace: Option, arguments: String, #[serde(default)] status: Option, diff --git a/rust/src/server/src/routes/tests/responses.rs b/rust/src/server/src/routes/tests/responses.rs index d390d326ed09..f61549d29454 100644 --- a/rust/src/server/src/routes/tests/responses.rs +++ b/rust/src/server/src/routes/tests/responses.rs @@ -1117,6 +1117,96 @@ async fn responses_streaming_emits_function_call_events() { assert_eq!(arguments, "{\"city\":\"Paris\"}"); } +fn computer_use_namespace_tool() -> serde_json::Value { + json!({ + "type": "namespace", + "name": "mcp__computer_use", + "description": "Computer control tools.", + "tools": [{ + "type": "function", + "name": "get_app_state", + "description": "Get the current state of a desktop application.", + "parameters": { + "type": "object", + "properties": {"app": {"type": "string"}}, + "required": ["app"] + } + }] + }) +} + +/// The model calls a namespaced function by its flat `__`. +fn namespaced_tool_call_output_specs() -> Vec<(Vec, Option)> { + vec![ + ( + bytes_to_token_ids(b"\n{\"name\":\"mcp__computer_use__get_app_state\", "), + None, + ), + ( + bytes_to_token_ids(b"\"arguments\":{\"app\":\"Chrome\"}}\n"), + Some(EngineCoreFinishReason::Stop), + ), + ] +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn responses_namespace_tool_call_reports_function_name_and_namespace() { + for stream in [false, true] { + let (app, engine_task) = test_app_with_backend_and_stream_output_specs( + Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B")), + namespaced_tool_call_output_specs(), + ) + .await; + let request = json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "input": "Inspect Google Chrome.", + "tools": [computer_use_namespace_tool()], + "stream": stream, + }); + let response = responses_call(&app, request).await; + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let text = String::from_utf8(body.to_vec()).expect("utf8 body"); + + let json = if stream { + let payloads = sse_json_payloads(&text); + let event = |event_type: &str| { + payloads + .iter() + .find(|payload| payload["type"] == event_type) + .unwrap_or_else(|| panic!("{event_type} event: {text}")) + }; + let added = &event("response.output_item.added")["item"]; + assert_eq!(added["name"], "get_app_state", "{text}"); + assert_eq!(added["namespace"], "mcp__computer_use", "{text}"); + assert_eq!( + event("response.function_call_arguments.done")["name"], + "get_app_state", + "{text}" + ); + let done = &event("response.output_item.done")["item"]; + assert_eq!(done["name"], "get_app_state", "{text}"); + assert_eq!(done["namespace"], "mcp__computer_use", "{text}"); + payloads.last().expect("terminal event")["response"].clone() + } else { + serde_json::from_str(&text).expect("decode json") + }; + + let call = json["output"] + .as_array() + .expect("output items") + .iter() + .find(|item| item["type"] == "function_call") + .unwrap_or_else(|| panic!("function call item: {json}")); + assert_eq!(call["name"], "get_app_state", "{json}"); + assert_eq!(call["namespace"], "mcp__computer_use", "{json}"); + assert_eq!(call["arguments"], "{\"app\":\"Chrome\"}", "{json}"); + assert_eq!(json["tools"], json!([computer_use_namespace_tool()])); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn responses_store_dependent_features_are_rejected() {