From c548c5d49fec6de9c1619d95b5f96600844db754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Mon, 2 Feb 2026 14:04:29 +0900 Subject: [PATCH] Fixes Claude API thinking block requirement Addresses a Claude API requirement where assistant messages with tool use must have a thinking block when thinking is enabled. This commit injects an empty thinking block into assistant messages that include tool use but lack a thinking block. This ensures compatibility with the Claude API when the thinking feature is enabled. --- internal/thinking/provider/claude/apply.go | 81 +++++++- .../thinking/provider/claude/apply_test.go | 187 ++++++++++++++++++ 2 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 internal/thinking/provider/claude/apply_test.go diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index 3c74d514..3faf4786 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -83,6 +83,10 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * // Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint) result = a.normalizeClaudeBudget(result, config.Budget, modelInfo) + + // When thinking is enabled, Claude API requires assistant messages with tool_use + // to have a thinking block. Inject empty thinking block if missing. + result = injectThinkingBlockForToolUse(result) return result, nil } @@ -149,18 +153,85 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, body = []byte(`{}`) } + var result []byte switch config.Mode { case thinking.ModeNone: - result, _ := sjson.SetBytes(body, "thinking.type", "disabled") + result, _ = sjson.SetBytes(body, "thinking.type", "disabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") return result, nil case thinking.ModeAuto: - result, _ := sjson.SetBytes(body, "thinking.type", "enabled") + result, _ = sjson.SetBytes(body, "thinking.type", "enabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") - return result, nil default: - result, _ := sjson.SetBytes(body, "thinking.type", "enabled") + result, _ = sjson.SetBytes(body, "thinking.type", "enabled") result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget) - return result, nil } + + // When thinking is enabled, Claude API requires assistant messages with tool_use + // to have a thinking block. Inject empty thinking block if missing. + result = injectThinkingBlockForToolUse(result) + return result, nil +} + +// injectThinkingBlockForToolUse adds empty thinking block to assistant messages +// that have tool_use but no thinking block. This is required by Claude API when +// thinking is enabled. +func injectThinkingBlockForToolUse(body []byte) []byte { + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body + } + + messageArray := messages.Array() + modified := false + newMessages := "[]" + + for _, msg := range messageArray { + role := msg.Get("role").String() + if role != "assistant" { + newMessages, _ = sjson.SetRaw(newMessages, "-1", msg.Raw) + continue + } + + content := msg.Get("content") + if !content.IsArray() { + newMessages, _ = sjson.SetRaw(newMessages, "-1", msg.Raw) + continue + } + + contentArray := content.Array() + hasToolUse := false + hasThinking := false + + for _, part := range contentArray { + partType := part.Get("type").String() + if partType == "tool_use" { + hasToolUse = true + } + if partType == "thinking" { + hasThinking = true + } + } + + if hasToolUse && !hasThinking { + // Inject empty thinking block at the beginning of content + newContent := "[]" + newContent, _ = sjson.SetRaw(newContent, "-1", `{"type":"thinking","thinking":""}`) + for _, part := range contentArray { + newContent, _ = sjson.SetRaw(newContent, "-1", part.Raw) + } + msgJSON := msg.Raw + msgJSON, _ = sjson.SetRaw(msgJSON, "content", newContent) + newMessages, _ = sjson.SetRaw(newMessages, "-1", msgJSON) + modified = true + continue + } + + newMessages, _ = sjson.SetRaw(newMessages, "-1", msg.Raw) + } + + if modified { + body, _ = sjson.SetRawBytes(body, "messages", []byte(newMessages)) + } + return body } diff --git a/internal/thinking/provider/claude/apply_test.go b/internal/thinking/provider/claude/apply_test.go new file mode 100644 index 00000000..dc7916e8 --- /dev/null +++ b/internal/thinking/provider/claude/apply_test.go @@ -0,0 +1,187 @@ +package claude + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/tidwall/gjson" +) + +func TestInjectThinkingBlockForToolUse(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "assistant with tool_use but no thinking - should inject thinking", + input: `{ + "model": "kimi-k2.5", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me use a tool"}, + {"type": "tool_use", "id": "tool_1", "name": "test_tool", "input": {}} + ] + } + ] + }`, + expected: "thinking", + }, + { + name: "assistant with tool_use and thinking - should not modify", + input: `{ + "model": "kimi-k2.5", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "I need to use a tool"}, + {"type": "tool_use", "id": "tool_1", "name": "test_tool", "input": {}} + ] + } + ] + }`, + expected: "thinking", + }, + { + name: "user message with tool_use - should not modify", + input: `{ + "model": "kimi-k2.5", + "messages": [ + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "tool_1", "content": "result"} + ] + } + ] + }`, + expected: "", + }, + { + name: "assistant without tool_use - should not modify", + input: `{ + "model": "kimi-k2.5", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Hello!"} + ] + } + ] + }`, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := injectThinkingBlockForToolUse([]byte(tt.input)) + + // Check if thinking block exists in assistant messages with tool_use + messages := gjson.GetBytes(result, "messages") + if !messages.IsArray() { + t.Fatal("messages is not an array") + } + + for _, msg := range messages.Array() { + if msg.Get("role").String() == "assistant" { + content := msg.Get("content") + if !content.IsArray() { + continue + } + + hasToolUse := false + hasThinking := false + for _, part := range content.Array() { + partType := part.Get("type").String() + if partType == "tool_use" { + hasToolUse = true + } + if partType == "thinking" { + hasThinking = true + } + } + + if hasToolUse && tt.expected == "thinking" && !hasThinking { + t.Errorf("Expected thinking block in assistant message with tool_use, but not found") + } + } + } + }) + } +} + +func TestApplyCompatibleClaude(t *testing.T) { + tests := []struct { + name string + input string + config thinking.ThinkingConfig + expectThinking bool + }{ + { + name: "thinking enabled with tool_use - should inject thinking block", + input: `{ + "model": "kimi-k2.5", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "tool_1", "name": "test_tool", "input": {}} + ] + } + ] + }`, + config: thinking.ThinkingConfig{ + Mode: thinking.ModeBudget, + Budget: 4000, + }, + expectThinking: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := applyCompatibleClaude([]byte(tt.input), tt.config) + if err != nil { + t.Fatalf("applyCompatibleClaude failed: %v", err) + } + + // Check if thinking.type is enabled + thinkingType := gjson.GetBytes(result, "thinking.type").String() + if thinkingType != "enabled" { + t.Errorf("Expected thinking.type=enabled, got %s", thinkingType) + } + + // Check if thinking block is injected + messages := gjson.GetBytes(result, "messages") + if !messages.IsArray() { + t.Fatal("messages is not an array") + } + + for _, msg := range messages.Array() { + if msg.Get("role").String() == "assistant" { + content := msg.Get("content") + if !content.IsArray() { + continue + } + + hasThinking := false + for _, part := range content.Array() { + if part.Get("type").String() == "thinking" { + hasThinking = true + break + } + } + + if tt.expectThinking && !hasThinking { + t.Errorf("Expected thinking block in assistant message, but not found. Result: %s", string(result)) + } + } + } + }) + } +}