Merge pull request #276 from widwei/fix/anthropic-thinking-carrier

fix(anthropic): pass back thinking block for assistant turns without reasoning
This commit is contained in:
leokun
2026-08-07 17:43:00 +08:00
committed by GitHub
2 changed files with 192 additions and 7 deletions
+28 -7
View File
@@ -1134,10 +1134,20 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
}
}
// anthropicThinkingCarrier 记录请求内最近一个有 reasoning+signature 的 assistant 轮次。
// thinking 模式下上游要求每个 assistant 轮次都回传 thinking 块;当某轮次(如 DeepSeek
// adaptive thinking 跳过思考的 tool-call 轮次)没有 reasoning 时,用 carrier 的
// thinking+signature 兜底,避免上游 "thinking must be passed back" 400。
type anthropicThinkingCarrier struct {
reasoning string
signature string
}
func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, relocateImages bool) ([]string, []anthropicMessage, error) {
systemParts := make([]string, 0, len(input))
messages := make([]anthropicMessage, 0, len(input))
pendingToolResults := make([]map[string]any, 0, 2)
var thinkingCarrier *anthropicThinkingCarrier
flushToolResults := func() {
if len(pendingToolResults) == 0 {
return
@@ -1175,7 +1185,15 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, r
})
case "user", "assistant":
flushToolResults()
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled)
if thinkingEnabled && role == "assistant" {
if reasoning := strings.TrimSpace(message.ReasoningContent); reasoning != "" {
thinkingCarrier = &anthropicThinkingCarrier{
reasoning: reasoning,
signature: anthropicThinkingSignature(message),
}
}
}
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled, thinkingCarrier)
if err != nil {
return nil, nil, err
}
@@ -1284,7 +1302,7 @@ func isAnthropicImageBlock(block map[string]any) bool {
return strings.TrimSpace(anthropicStringField(block, "type")) == "image"
}
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]map[string]any, error) {
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool, carrier *anthropicThinkingCarrier) ([]map[string]any, error) {
blocks, err := anthropicContentBlocks(message)
if err != nil {
return nil, err
@@ -1293,11 +1311,17 @@ func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]ma
return blocks, nil
}
reasoning := strings.TrimSpace(message.ReasoningContent)
signature := anthropicThinkingSignature(message)
if reasoning == "" && carrier != nil {
reasoning = carrier.reasoning
signature = carrier.signature
}
thinkingBlock := map[string]any{
"type": "thinking",
"thinking": message.ReasoningContent,
"thinking": reasoning,
}
if signature := anthropicThinkingSignature(message); signature != "" {
if signature != "" {
thinkingBlock["signature"] = signature
}
return append([]map[string]any{thinkingBlock}, blocks...), nil
@@ -1381,9 +1405,6 @@ func shouldIncludeAnthropicThinkingBlock(message Message, thinkingEnabled bool)
if strings.TrimSpace(message.Role) != "assistant" {
return false
}
if strings.TrimSpace(message.ReasoningContent) == "" {
return false
}
return true
}
@@ -0,0 +1,164 @@
package modeladapter
import (
"strings"
"testing"
)
// TestNormalizeAnthropicProviderMessagesThinkingCarrier 验证 thinking 模式下,
// 缺少 reasoning 的 assistant 轮次(如 DeepSeek adaptive thinking 跳过思考的
// tool-call 轮次)会用请求内最近一个 carrier 的 thinking+signature 兜底,
// 保证每个 assistant 轮次都有 thinking 块,避免上游 "thinking must be passed
// back to the API" 400。
func TestNormalizeAnthropicProviderMessagesThinkingCarrier(t *testing.T) {
carrierToolCall := []ToolCallDescriptor{{
ID: "call-2",
Type: "function",
Function: ToolCallFunctionShape{
Name: "read",
Arguments: `{}`,
},
}}
input := []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"},
{Role: "user", Content: "tool result 1"},
{Role: "assistant", ToolCalls: carrierToolCall}, // 无 reasoning → 用 carrier
{Role: "user", Content: "tool result 2"},
{Role: "assistant", Content: "done", ReasoningContent: "R2", ReasoningSignature: "S2"},
}
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if len(messages) != 6 {
t.Fatalf("expected 6 messages, got %d", len(messages))
}
// 第 2 条(有 reasoning)应保留自己的 thinking。
assertAnthropicThinkingBlock(t, messages[1], "R1", "S1")
// 第 4 条(无 reasoning 的 tool-call 轮次)应复用 carrier 的 thinking+signature。
assertAnthropicThinkingBlock(t, messages[3], "R1", "S1")
// 第 5 条应为 tool_result 消息(合并路径不适用时,tool-call 轮次独立成消息)。
if role := messages[4].Role; role != "user" {
t.Fatalf("expected messages[4] role=user, got %s", role)
}
// 第 6 条有自己的 thinking。
assertAnthropicThinkingBlock(t, messages[5], "R2", "S2")
// tool-call 轮次应包含 tool_use 块。
hasToolUse := false
for _, block := range messages[3].Content {
if strings.TrimSpace(anthropicStringField(block, "type")) == "tool_use" {
hasToolUse = true
}
}
if !hasToolUse {
t.Fatal("expected tool_use block on the carrier-fallback assistant message")
}
}
// TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn 验证请求内第一条
// assistant 轮次就缺 reasoning 且无 carrier 时,兜底输出空 thinking 块。
func TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn(t *testing.T) {
input := []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "ok"},
}
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if len(messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(messages))
}
if got := anthropicStringField(messages[1].Content[0], "type"); got != "thinking" {
t.Fatalf("expected first block type=thinking, got %s", got)
}
if got := anthropicStringField(messages[1].Content[0], "thinking"); got != "" {
t.Fatalf("expected empty fallback thinking, got %q", got)
}
}
// TestNormalizeAnthropicProviderMessagesThinkingDisabled 验证 thinking 关闭时
// 不输出任何 thinking 块(回归保护)。
func TestNormalizeAnthropicProviderMessagesThinkingDisabled(t *testing.T) {
input := []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "ok", ReasoningContent: "R1", ReasoningSignature: "S1"},
{Role: "assistant", ToolCalls: []ToolCallDescriptor{{
ID: "call-2",
Type: "function",
Function: ToolCallFunctionShape{
Name: "read",
Arguments: `{}`,
},
}}},
}
_, messages, err := normalizeAnthropicProviderMessages(input, false, false)
if err != nil {
t.Fatalf("normalize: %v", err)
}
for index, message := range messages {
for _, block := range message.Content {
if blockType := anthropicStringField(block, "type"); blockType == "thinking" {
t.Fatalf("unexpected thinking block at messages[%d]", index)
}
}
}
}
// TestNormalizeAnthropicProviderMessagesThinkingMerge 验证有 reasoning 的纯
// tool-call 轮次仍按既有逻辑合并进上一条 assistant 消息(thinking 去重,无回归)。
func TestNormalizeAnthropicProviderMessagesThinkingMerge(t *testing.T) {
input := []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"},
{Role: "assistant", ToolCalls: []ToolCallDescriptor{{
ID: "call-2",
Type: "function",
Function: ToolCallFunctionShape{
Name: "read",
Arguments: `{}`,
},
}}, ReasoningContent: "R1", ReasoningSignature: "S1"},
}
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if len(messages) != 2 {
t.Fatalf("expected 2 messages (tool-call merged), got %d", len(messages))
}
assertAnthropicThinkingBlock(t, messages[1], "R1", "S1")
hasToolUse := false
for _, block := range messages[1].Content {
if blockType := anthropicStringField(block, "type"); blockType == "tool_use" {
hasToolUse = true
}
}
if !hasToolUse {
t.Fatal("expected merged tool_use block on messages[1]")
}
}
func assertAnthropicThinkingBlock(t *testing.T, message anthropicMessage, wantThinking string, wantSignature string) {
t.Helper()
if len(message.Content) == 0 {
t.Fatalf("expected non-empty content for %s message", message.Role)
}
first := message.Content[0]
if blockType := anthropicStringField(first, "type"); blockType != "thinking" {
t.Fatalf("expected first block type=thinking, got %s", blockType)
}
if got := anthropicStringField(first, "thinking"); got != wantThinking {
t.Fatalf("expected thinking=%q, got %q", wantThinking, got)
}
if got := anthropicStringField(first, "signature"); got != wantSignature {
t.Fatalf("expected signature=%q, got %q", wantSignature, got)
}
}