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.
This commit is contained in:
yetone committed 2026-10-10 04:19:56 +08:00
1 parent 916c1cc7ea
commit 12c0550ae7
2 files changed
+54

No files matched your search

+16
View File
@@ -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"}
+38
View File
@@ -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)
}
}