Keep historical Responses tool names distinct from namespace aliases

This commit is contained in:
Yufeng He 2026-10-20 22:29:15 +08:00
parent 935817c66f
commit 32ca266005
3 changed files with 157 additions and 2 deletions

View File

@ -35,8 +35,9 @@
//! output is placed right after that output. For V4, a namespace tool is named
//! `{namespace}__{name}`, or `{namespace}{name}` when the namespace ends with
//! or the name starts with an underscore, and `apply_patch` uses the V4
//! description. The caller supplies model inference, tool execution, and HTTP
//! transport.
//! description. Flattened namespace names also avoid top-level names found in
//! historical calls, including calls to tools no longer declared in the request.
//! The caller supplies model inference, tool execution, and HTTP transport.
//!
//! [`WebSearchBehavior::Reject`]: crate::request::WebSearchBehavior::Reject
//! [`ModelVersion::DeepseekV41Flash`]:

View File

@ -83,6 +83,21 @@ impl ResponsesToolSet {
model_version,
response_options: ResponseOptions::default(),
};
if let Some(ResponsesInput::Items(items)) = input {
converted
.top_level_names
.extend(items.iter().filter_map(|item| match item {
ResponsesInputItem::Typed(ResponsesTypedInputItem::FunctionCall(call))
if call.namespace.is_none() =>
{
Some(call.name.clone())
}
ResponsesInputItem::Typed(ResponsesTypedInputItem::CustomToolCall(call)) => {
Some(call.name.clone())
}
_ => None,
}));
}
for (item_index, tools) in groups {
let start = converted.client_tools.len();
for (index, tool) in tools.into_iter().enumerate() {

View File

@ -0,0 +1,139 @@
use std::collections::HashMap;
use deepseek_recipe::openai::ResponsesRequest;
use deepseek_recipe::request::{ConversionOptions, ProtocolRequest};
use deepseek_recipe_core::messages::InputMessage;
use serde_json::{Value, json};
fn convert_history(
model: &str,
bare_name: &str,
reversed: bool,
declared: bool,
) -> (HashMap<String, String>, Vec<String>) {
let mut calls = vec![
json!({"type": "function_call", "call_id": "namespaced", "namespace": "ns", "name": "run", "arguments": "{}"}),
json!({"type": "function_call", "call_id": "bare", "name": bare_name, "arguments": "{}"}),
];
if reversed {
calls.reverse();
}
calls.extend([
json!({"type": "function_call_output", "call_id": "namespaced", "output": "namespace result"}),
json!({"type": "function_call_output", "call_id": "bare", "output": "top-level result"}),
json!({"role": "user", "content": "continue"}),
]);
let tools: Vec<Value> = if declared {
vec![
json!({"type": "namespace", "name": "ns", "tools": [{"type": "function", "name": "run"}]}),
]
} else {
Vec::new()
};
let request: ResponsesRequest = serde_json::from_value(json!({
"model": model,
"reasoning": {"effort": "none"},
"input": calls,
"tools": tools,
}))
.unwrap();
let converted = request.convert(ConversionOptions::default()).unwrap();
let mut names = HashMap::new();
for message in &converted.conversation.messages {
if let InputMessage::Assistant {
tool_calls: Some(calls),
..
} = message
{
for call in calls {
names.insert(call.id.clone(), call.name.clone());
}
}
}
if declared {
let mapped = converted
.response_options
.namespace_tool_map
.as_ref()
.unwrap();
assert_eq!(
mapped.get(&names["namespaced"]),
Some(&("ns".to_owned(), "run".to_owned()))
);
}
let declarations = converted
.conversation
.tools
.iter()
.map(|tool| tool.name.clone())
.collect();
(names, declarations)
}
#[test]
fn a_historical_custom_call_reserves_its_original_name() {
let request: ResponsesRequest = serde_json::from_value(json!({
"model": "DeepSeek-V4-Pro",
"reasoning": {"effort": "none"},
"tools": [{"type": "namespace", "name": "apply_", "tools": [{"type": "function", "name": "patch"}]}],
"input": [
{"type": "custom_tool_call", "call_id": "old_patch", "name": "apply_patch", "input": "old patch"},
{"type": "custom_tool_call_output", "call_id": "old_patch", "output": "done"},
{"role": "user", "content": "continue"}
]
})).unwrap();
let converted = request.convert(ConversionOptions::default()).unwrap();
assert_ne!(converted.conversation.tools[0].name, "apply_patch");
let historical = converted
.conversation
.messages
.iter()
.find_map(|message| {
if let InputMessage::Assistant {
tool_calls: Some(calls),
..
} = message
{
calls.first()
} else {
None
}
})
.unwrap();
assert_eq!(historical.name, "apply_patch");
}
#[test]
fn undeclared_historical_namespace_does_not_alias_a_bare_tool() {
for (model, bare) in [
("DeepSeek-V4-Pro", "ns__run"),
("DeepSeek-V4.1-Flash", "ns::run"),
] {
for reversed in [false, true] {
let (names, _) = convert_history(model, bare, reversed, false);
assert_eq!(names["bare"], bare);
assert_ne!(
names["namespaced"], names["bare"],
"{model}, reversed={reversed}"
);
}
}
}
#[test]
fn current_namespace_reserves_historical_bare_names_before_flattening() {
for (model, bare) in [
("DeepSeek-V4-Pro", "ns__run"),
("DeepSeek-V4.1-Flash", "ns::run"),
] {
for reversed in [false, true] {
let (names, declarations) = convert_history(model, bare, reversed, true);
assert_eq!(names["bare"], bare);
assert_ne!(
names["namespaced"], names["bare"],
"{model}, reversed={reversed}"
);
assert_eq!(declarations, vec![names["namespaced"].clone()]);
}
}
}