mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 04:40:52 +08:00
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.
This commit is contained in:
@@ -83,6 +83,10 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
|
|||||||
|
|
||||||
// Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint)
|
// Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint)
|
||||||
result = a.normalizeClaudeBudget(result, config.Budget, modelInfo)
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,18 +153,85 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte,
|
|||||||
body = []byte(`{}`)
|
body = []byte(`{}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var result []byte
|
||||||
switch config.Mode {
|
switch config.Mode {
|
||||||
case thinking.ModeNone:
|
case thinking.ModeNone:
|
||||||
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
result, _ = sjson.SetBytes(body, "thinking.type", "disabled")
|
||||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||||
return result, nil
|
return result, nil
|
||||||
case thinking.ModeAuto:
|
case thinking.ModeAuto:
|
||||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
result, _ = sjson.SetBytes(body, "thinking.type", "enabled")
|
||||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||||
return result, nil
|
|
||||||
default:
|
default:
|
||||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
result, _ = sjson.SetBytes(body, "thinking.type", "enabled")
|
||||||
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
187
internal/thinking/provider/claude/apply_test.go
Normal file
187
internal/thinking/provider/claude/apply_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user