Skip to content

Commit 47e0d20

Browse files
committed
fix(proxy): 修复 Chat Completions 工具翻译导致 AI 无法调用工具
1. tool_result 翻译: Anthropic user 消息中的 tool_result content blocks 现在正确提取为独立的 role:"tool" 消息 (含 tool_call_id), 而非错误地转为纯文本 user 消息。修复后多轮工具调用链路正常工作。 2. tool_choice Object 格式: 正确处理 Anthropic 的 {"type":"any/auto/none/tool"} 对象格式,{"type":"any"} 现在正确映射为 "required" 而非 "auto"。 3. 新增 11 个单元测试覆盖修复场景,总测试数 341 全部通过。
1 parent fd86b63 commit 47e0d20

3 files changed

Lines changed: 240 additions & 35 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "claudex"
3-
version = "0.2.3"
3+
version = "0.2.4"
44
edition = "2021"
55
description = "Multi-instance Claude Code manager with intelligent translation proxy"
66
license = "MIT"

src/proxy/translate/chat_completions.rs

Lines changed: 238 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,65 @@ pub fn anthropic_to_openai(
3535
if let Some(msgs) = anthropic.get("messages").and_then(|m| m.as_array()) {
3636
for msg in msgs {
3737
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
38-
let content = convert_content_to_openai(msg.get("content"));
3938

4039
match role {
41-
"tool" => {
42-
// Anthropic tool_result → OpenAI tool message
43-
let tool_use_id = msg
44-
.get("tool_use_id")
45-
.and_then(|v| v.as_str())
46-
.unwrap_or("");
47-
messages.push(json!({
48-
"role": "tool",
49-
"tool_call_id": tool_use_id,
50-
"content": content_to_string(&content),
51-
}));
40+
"user" => {
41+
// Anthropic user messages may contain tool_result blocks alongside text.
42+
// tool_result blocks must be extracted into separate "role: tool" messages,
43+
// while non-tool_result blocks remain in a user message.
44+
if let Some(content_arr) = msg.get("content").and_then(|c| c.as_array()) {
45+
let has_tool_result = content_arr
46+
.iter()
47+
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result"));
48+
49+
if has_tool_result {
50+
let mut user_parts = Vec::new();
51+
for block in content_arr {
52+
match block.get("type").and_then(|t| t.as_str()) {
53+
Some("tool_result") => {
54+
let call_id = block
55+
.get("tool_use_id")
56+
.and_then(|v| v.as_str())
57+
.unwrap_or("");
58+
let result_text = extract_tool_result_content(block);
59+
messages.push(json!({
60+
"role": "tool",
61+
"tool_call_id": call_id,
62+
"content": result_text,
63+
}));
64+
}
65+
Some("text") => {
66+
if let Some(text) =
67+
block.get("text").and_then(|t| t.as_str())
68+
{
69+
if !text.is_empty() {
70+
user_parts.push(text.to_string());
71+
}
72+
}
73+
}
74+
_ => {}
75+
}
76+
}
77+
if !user_parts.is_empty() {
78+
messages.push(json!({
79+
"role": "user",
80+
"content": user_parts.join("\n"),
81+
}));
82+
}
83+
} else {
84+
let content = convert_content_to_openai(msg.get("content"));
85+
messages.push(json!({
86+
"role": "user",
87+
"content": content,
88+
}));
89+
}
90+
} else {
91+
let content = convert_content_to_openai(msg.get("content"));
92+
messages.push(json!({
93+
"role": "user",
94+
"content": content,
95+
}));
96+
}
5297
}
5398
"assistant" => {
5499
let mut assistant_msg = json!({"role": "assistant"});
@@ -94,12 +139,14 @@ pub fn anthropic_to_openai(
94139
assistant_msg["tool_calls"] = json!(tool_calls);
95140
}
96141
} else {
142+
let content = convert_content_to_openai(msg.get("content"));
97143
assistant_msg["content"] = content;
98144
}
99145

100146
messages.push(assistant_msg);
101147
}
102148
_ => {
149+
let content = convert_content_to_openai(msg.get("content"));
103150
messages.push(json!({
104151
"role": role,
105152
"content": content,
@@ -299,13 +346,9 @@ fn convert_content_to_openai(content: Option<&Value>) -> Value {
299346
}
300347
}))
301348
}
302-
Some("tool_result") => {
303-
let result_content = part.get("content");
304-
Some(json!({
305-
"type": "text",
306-
"text": content_to_string(&convert_content_to_openai(result_content)),
307-
}))
308-
}
349+
// tool_result blocks are handled at the message level,
350+
// not inside convert_content_to_openai
351+
Some("tool_result") => None,
309352
_ => None,
310353
}
311354
})
@@ -322,6 +365,19 @@ fn convert_content_to_openai(content: Option<&Value>) -> Value {
322365
}
323366
}
324367

368+
fn extract_tool_result_content(block: &Value) -> String {
369+
let content = block.get("content");
370+
match content {
371+
Some(Value::String(s)) => s.clone(),
372+
Some(Value::Array(parts)) => parts
373+
.iter()
374+
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
375+
.collect::<Vec<_>>()
376+
.join("\n"),
377+
_ => String::new(),
378+
}
379+
}
380+
325381
fn content_to_string(content: &Value) -> String {
326382
match content {
327383
Value::String(s) => s.clone(),
@@ -336,18 +392,26 @@ fn content_to_string(content: &Value) -> String {
336392

337393
fn convert_tool_choice(tc: &Value, _tool_name_map: &ToolNameMap) -> Value {
338394
match tc {
395+
// Anthropic string shorthand (legacy/convenience)
339396
Value::String(s) => match s.as_str() {
340397
"auto" => json!("auto"),
341398
"any" => json!("required"),
342399
"none" => json!("none"),
343400
_ => json!("auto"),
344401
},
402+
// Anthropic Object format: {"type": "auto|any|none|tool", "name": "..."}
345403
Value::Object(obj) => {
346-
if let Some(name) = obj.get("name").and_then(|n| n.as_str()) {
347-
let truncated = truncate_tool_name(name);
348-
json!({"type": "function", "function": {"name": truncated}})
349-
} else {
350-
json!("auto")
404+
let tc_type = obj.get("type").and_then(|t| t.as_str()).unwrap_or("auto");
405+
match tc_type {
406+
"auto" => json!("auto"),
407+
"any" => json!("required"),
408+
"none" => json!("none"),
409+
"tool" => {
410+
let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or("");
411+
let truncated = truncate_tool_name(name);
412+
json!({"type": "function", "function": {"name": truncated}})
413+
}
414+
_ => json!("auto"),
351415
}
352416
}
353417
_ => json!("auto"),
@@ -456,18 +520,134 @@ mod tests {
456520
}
457521

458522
#[test]
459-
fn test_tool_result_message() {
523+
fn test_tool_result_in_user_message() {
524+
// Anthropic format: tool_result blocks are inside user messages
525+
let req = json!({
526+
"messages": [{
527+
"role": "user",
528+
"content": [
529+
{"type": "tool_result", "tool_use_id": "call_1", "content": "search result here"}
530+
]
531+
}]
532+
});
533+
let result = a2o(&req, "m");
534+
let msg = &result["messages"][0];
535+
assert_eq!(msg["role"], "tool");
536+
assert_eq!(msg["tool_call_id"], "call_1");
537+
assert_eq!(msg["content"], "search result here");
538+
}
539+
540+
#[test]
541+
fn test_tool_result_with_array_content() {
460542
let req = json!({
461543
"messages": [{
462-
"role": "tool",
463-
"tool_use_id": "call_1",
464-
"content": "search result here"
544+
"role": "user",
545+
"content": [
546+
{"type": "tool_result", "tool_use_id": "call_1", "content": [
547+
{"type": "text", "text": "line 1"},
548+
{"type": "text", "text": "line 2"}
549+
]}
550+
]
465551
}]
466552
});
467553
let result = a2o(&req, "m");
468554
let msg = &result["messages"][0];
469555
assert_eq!(msg["role"], "tool");
470556
assert_eq!(msg["tool_call_id"], "call_1");
557+
assert_eq!(msg["content"], "line 1\nline 2");
558+
}
559+
560+
#[test]
561+
fn test_multiple_tool_results_in_one_user_message() {
562+
let req = json!({
563+
"messages": [{
564+
"role": "user",
565+
"content": [
566+
{"type": "tool_result", "tool_use_id": "call_1", "content": "result 1"},
567+
{"type": "tool_result", "tool_use_id": "call_2", "content": "result 2"}
568+
]
569+
}]
570+
});
571+
let result = a2o(&req, "m");
572+
let msgs = result["messages"].as_array().unwrap();
573+
assert_eq!(msgs.len(), 2);
574+
assert_eq!(msgs[0]["role"], "tool");
575+
assert_eq!(msgs[0]["tool_call_id"], "call_1");
576+
assert_eq!(msgs[0]["content"], "result 1");
577+
assert_eq!(msgs[1]["role"], "tool");
578+
assert_eq!(msgs[1]["tool_call_id"], "call_2");
579+
assert_eq!(msgs[1]["content"], "result 2");
580+
}
581+
582+
#[test]
583+
fn test_tool_result_mixed_with_text() {
584+
// User message with both tool_result and text blocks
585+
let req = json!({
586+
"messages": [{
587+
"role": "user",
588+
"content": [
589+
{"type": "tool_result", "tool_use_id": "call_1", "content": "result here"},
590+
{"type": "text", "text": "Now do something else"}
591+
]
592+
}]
593+
});
594+
let result = a2o(&req, "m");
595+
let msgs = result["messages"].as_array().unwrap();
596+
assert_eq!(msgs.len(), 2);
597+
// tool_result becomes role: tool
598+
assert_eq!(msgs[0]["role"], "tool");
599+
assert_eq!(msgs[0]["tool_call_id"], "call_1");
600+
// remaining text becomes user message
601+
assert_eq!(msgs[1]["role"], "user");
602+
assert_eq!(msgs[1]["content"], "Now do something else");
603+
}
604+
605+
#[test]
606+
fn test_tool_result_empty_content() {
607+
let req = json!({
608+
"messages": [{
609+
"role": "user",
610+
"content": [
611+
{"type": "tool_result", "tool_use_id": "call_1"}
612+
]
613+
}]
614+
});
615+
let result = a2o(&req, "m");
616+
let msg = &result["messages"][0];
617+
assert_eq!(msg["role"], "tool");
618+
assert_eq!(msg["content"], "");
619+
}
620+
621+
#[test]
622+
fn test_full_tool_use_roundtrip() {
623+
// Simulate a complete tool use conversation
624+
let req = json!({
625+
"messages": [
626+
{"role": "user", "content": "Search for Rust tutorials"},
627+
{"role": "assistant", "content": [
628+
{"type": "text", "text": "Let me search for that."},
629+
{"type": "tool_use", "id": "toolu_1", "name": "search", "input": {"q": "Rust tutorials"}}
630+
]},
631+
{"role": "user", "content": [
632+
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "Found 10 results"}
633+
]}
634+
]
635+
});
636+
let result = a2o(&req, "m");
637+
let msgs = result["messages"].as_array().unwrap();
638+
assert_eq!(msgs.len(), 3);
639+
// user message
640+
assert_eq!(msgs[0]["role"], "user");
641+
assert_eq!(msgs[0]["content"], "Search for Rust tutorials");
642+
// assistant with tool call
643+
assert_eq!(msgs[1]["role"], "assistant");
644+
assert_eq!(msgs[1]["content"], "Let me search for that.");
645+
assert_eq!(msgs[1]["tool_calls"][0]["id"], "toolu_1");
646+
assert_eq!(msgs[1]["tool_calls"][0]["function"]["name"], "search");
647+
// tool result
648+
assert_eq!(msgs[2]["role"], "tool");
649+
assert_eq!(msgs[2]["tool_call_id"], "toolu_1");
650+
assert_eq!(msgs[2]["content"], "Found 10 results");
471651
}
472652

473653
#[test]
@@ -488,30 +668,55 @@ mod tests {
488668
assert!(tool["function"]["parameters"]["properties"]["city"].is_object());
489669
}
490670

671+
// --- tool_choice: string shorthand ---
672+
491673
#[test]
492-
fn test_tool_choice_auto() {
674+
fn test_tool_choice_string_auto() {
493675
let req = json!({"messages": [], "tool_choice": "auto"});
494676
let result = a2o(&req, "m");
495677
assert_eq!(result["tool_choice"], "auto");
496678
}
497679

498680
#[test]
499-
fn test_tool_choice_any() {
681+
fn test_tool_choice_string_any() {
500682
let req = json!({"messages": [], "tool_choice": "any"});
501683
let result = a2o(&req, "m");
502684
assert_eq!(result["tool_choice"], "required");
503685
}
504686

505687
#[test]
506-
fn test_tool_choice_none() {
688+
fn test_tool_choice_string_none() {
507689
let req = json!({"messages": [], "tool_choice": "none"});
508690
let result = a2o(&req, "m");
509691
assert_eq!(result["tool_choice"], "none");
510692
}
511693

694+
// --- tool_choice: Anthropic Object format (actual Claude Code format) ---
695+
696+
#[test]
697+
fn test_tool_choice_object_auto() {
698+
let req = json!({"messages": [], "tool_choice": {"type": "auto"}});
699+
let result = a2o(&req, "m");
700+
assert_eq!(result["tool_choice"], "auto");
701+
}
702+
703+
#[test]
704+
fn test_tool_choice_object_any() {
705+
let req = json!({"messages": [], "tool_choice": {"type": "any"}});
706+
let result = a2o(&req, "m");
707+
assert_eq!(result["tool_choice"], "required");
708+
}
709+
710+
#[test]
711+
fn test_tool_choice_object_none() {
712+
let req = json!({"messages": [], "tool_choice": {"type": "none"}});
713+
let result = a2o(&req, "m");
714+
assert_eq!(result["tool_choice"], "none");
715+
}
716+
512717
#[test]
513-
fn test_tool_choice_specific() {
514-
let req = json!({"messages": [], "tool_choice": {"name": "my_tool"}});
718+
fn test_tool_choice_object_specific_tool() {
719+
let req = json!({"messages": [], "tool_choice": {"type": "tool", "name": "my_tool"}});
515720
let result = a2o(&req, "m");
516721
assert_eq!(result["tool_choice"]["type"], "function");
517722
assert_eq!(result["tool_choice"]["function"]["name"], "my_tool");

0 commit comments

Comments
 (0)