From 12c0550ae7d4da400c6aa2fd6c4a06114de2617d Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 04:19:52 +0800 Subject: [PATCH] gateway: a signed thinking block with no text goes to Messages with its empty thinking, not without the field (#1447, Edward4226) The second refusal in #1447: OpenCode Go's Claude models answer a thinking block with a signature and no text, {"type":"thinking","signature":"abc123"}, with 400 "messages.1.content.0. thinking.thinking: Field required". Messages requires the field, but aBlock's Thinking was omitempty, so buildAnthropic dropped it whenever a signed part had no text: Claude Code's own thinking when its display is omitted ("thinking": "" with a real signature), and Gemini's signed parts, translated to an Anthropic upstream. aBlock now always writes "thinking" on a thinking block (MarshalJSON); every other block keeps leaving its empty fields out. Only aBlock had this: the Anthropic reply encoders already write the field as a map. A separate change from the prefill fix (af1e477d): a different field and a different refusal. TestSignedThinkingWithNoTextKeepsTheField fails on the old code ("thinking block = map[signature:abc123 type:thinking]") and passes now. Run: gofmt -l (clean), go vet -tags nogui ./internal/gateway on darwin, GOOS=linux and GOOS=windows, go test -tags nogui ./internal/gateway ./internal/provider ./internal/plugin under a scratch HOME (all ok). Not run for real: the owner's OpenCode Go subscription is inactive (403). Which request produced the reporter's messages.25 error from Codex isn't known: Responses input carries no signatures, so it wasn't this path for a Codex client's own turns. --- internal/gateway/anthropic.go | 16 +++++++++++ internal/gateway/thinking_block_test.go | 38 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 internal/gateway/thinking_block_test.go 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) + } +}