diff --git a/internal/gateway/anthropic.go b/internal/gateway/anthropic.go index d7b9750f..fc086b38 100644 --- a/internal/gateway/anthropic.go +++ b/internal/gateway/anthropic.go @@ -44,6 +44,22 @@ type aBlock struct { CacheControl map[string]string `json:"cache_control,omitempty"` } +// MarshalJSON writes a thinking block's text even when it is empty. +// Messages requires the field: a signed block with no text (Claude Code's +// thinking when its display is omitted) sent without it is refused, +// "messages.N.content.0.thinking.thinking: Field required" (#1447). Every +// other block keeps its omitempty fields. +func (b aBlock) MarshalJSON() ([]byte, error) { + type plain aBlock + if b.Type != "thinking" { + return json.Marshal(plain(b)) + } + return json.Marshal(struct { + plain + Thinking string `json:"thinking"` + }{plain(b), b.Thinking}) +} + // ephemeral marks a prompt-cache breakpoint. var ephemeral = map[string]string{"type": "ephemeral"} diff --git a/internal/gateway/thinking_block_test.go b/internal/gateway/thinking_block_test.go new file mode 100644 index 00000000..05529a9d --- /dev/null +++ b/internal/gateway/thinking_block_test.go @@ -0,0 +1,38 @@ +package gateway + +import ( + "encoding/json" + "strings" + "testing" +) + +// A signed thinking block with no text goes with "thinking": "", the +// field Messages requires. Without it OpenCode Go's Claude models answer +// "messages.1.content.0.thinking.thinking: Field required" (#1447). +func TestSignedThinkingWithNoTextKeepsTheField(t *testing.T) { + r := &Request{Messages: []Message{ + {Role: "user", Parts: []Part{{Kind: Text, Text: "hi"}}}, + {Role: "assistant", Parts: []Part{{Kind: Thinking, Signature: "abc123"}, {Kind: Text, Text: "hello"}}}, + {Role: "user", Parts: []Part{{Kind: Text, Text: "go on"}}}, + }} + var out struct { + Messages []struct { + Content []map[string]any `json:"content"` + } `json:"messages"` + } + body := buildAnthropic(r, "claude-haiku-5-5") + if err := json.Unmarshal(body, &out); err != nil { + t.Fatal(err) + } + think := out.Messages[1].Content[0] + if v, ok := think["thinking"]; !ok || v != "" || think["signature"] != "abc123" { + t.Fatalf("thinking block = %v: a signed block with no text must carry \"thinking\": \"\"", think) + } + // Other blocks keep leaving their empty fields out. + if text := out.Messages[1].Content[1]; len(text) != 2 || text["text"] != "hello" { + t.Fatalf("text block = %v", text) + } + if strings.Count(string(body), `"thinking":`) != 1 { + t.Fatalf("only the thinking block names thinking: %s", body) + } +}