fix(proxy): mark converted function tools as non-strict for Responses (#7717)

Anthropic tool schemas treat `required` as an arbitrary subset of
`properties`, but the Responses backend strictifies a function tool when
`strict` is omitted: optional attributes land in `required` and the model
is forced to emit fields the client deliberately left optional (#7713 -
Claude Code Agent always emitting the optional `isolation` and losing
non-worktree teammates).

Set `strict: false` explicitly on the converted function tool in
anthropic_to_responses. The hosted web-search branch is untouched, and
`parameters` still passes through clean_schema unchanged, so the
client-side optionality semantics are preserved end to end. This branch
serves both plain Responses providers and the Codex OAuth backend.

Validation: with the production hunk reverted, the new test asserting
strict=false fails (field absent); restored, the transform_responses
suite and the full --lib run are green.

Co-authored-by: SailingLoong <309827397+SailingLoong@users.noreply.github.com>
This commit is contained in:
Sailing LoongandSailingLoong authored and GitHub committed 2026-10-08 22:40:50 +08:00
1 parent 3af55c3973
commit 02759c67a1
1 file changed
+48
@@ -1905,6 +1905,12 @@ pub fn anthropic_to_responses(
}
let mut response_tool = json!({
"type": "function",
// Anthropic 工具 schema 允许可选参数(required 是任意子集),而
// Responses 后端把省略的 `strict` 按 true 处理:可选属性会被
// 严格化进 required,模型被迫输出它本应能省略的字段(#7713 中
// Claude Code Agent 的可选 isolation 被强制输出即此形态)。
// 显式 false 保留客户端 schema 的可选语义。
"strict": false,
"name": tool.get("name").and_then(Value::as_str).unwrap_or(""),
});
// 同 transform.rs:缺失的 description 省略而非输出 null,
@@ -3341,6 +3347,8 @@ mod tests {
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
// 省略 strict 会被 Responses 后端按 true 严格化,可选参数被强制输出(#7713)
assert_eq!(result["tools"][0]["strict"], json!(false));
assert_eq!(result["tools"][0]["name"], "get_weather");
assert!(result["tools"][0].get("parameters").is_some());
assert_eq!(result["tools"][0]["parameters"]["type"], json!("object"));
@@ -3352,6 +3360,46 @@ mod tests {
assert!(result["tools"][0].get("input_schema").is_none());
}
#[test]
fn test_anthropic_to_responses_marks_function_tools_not_strict() {
// #7713:可选参数(required 是任意子集)必须保留可选语义。
// 省略 strict 时 Responses 后端按 true 严格化,模型被迫输出 isolation。
let input = json!({
"model": "gpt-5.6-luna",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Call the agent"}],
"tools": [{
"name": "Agent",
"description": "Launch a sub-agent",
"input_schema": {
"type": "object",
"properties": {
"description": {"type": "string"},
"prompt": {"type": "string"},
"isolation": {"type": "string", "enum": ["worktree", "remote"]}
},
"required": ["description", "prompt"]
}
}]
});
let plain = anthropic_to_responses(input.clone(), None, false, false).unwrap();
assert_eq!(plain["tools"][0]["strict"], json!(false));
// required 保持为原有子集,strict 语义交由后端保留
assert_eq!(
plain["tools"][0]["parameters"]["required"],
json!(["description", "prompt"])
);
// 同一转换分支也服务 Codex OAuth 后端
let oauth = anthropic_to_responses(input, None, true, false).unwrap();
assert_eq!(oauth["tools"][0]["strict"], json!(false));
assert_eq!(
oauth["tools"][0]["parameters"]["required"],
json!(["description", "prompt"])
);
}
#[test]
fn test_anthropic_to_responses_omits_missing_tool_description() {
let input = json!({