gateway: Anthropic's redacted_thinking and a thinking block signed with no text go through translation to Messages as they came, each block apart (#1445, XYenon)

What changed: the IR carries a vendor's sealed reasoning (Part.Sealed,
SealedBy) and says where a thinking block begins (KThinkStart) and where a
sealed one is (KSealed). parseAnthropic keeps redacted_thinking raw and
buildAnthropic writes it back raw. decodeAnthropic, the Anthropic encoder,
the collector and renderAnthropic keep each thinking block apart, with its
own signature, including one signed with no text (aBlock still writes
"thinking": "", 12c0550a). Claude's subscription (Claude Code's stream,
whole and streamed) emits the same events. A resumed reply suppresses the
new kinds with the rest of the reasoning.

Why: on a same-protocol translated path (a non-streaming request to a
stream-only account, web search on the agent's own API, a plugin,
failover), the old code dropped redacted_thinking, dropped a signature
that came with no text, and merged two adjacent thinking blocks into one
with both signatures concatenated. Anthropic refuses the next turn for
each of these.

Run: the new tests in reasoning_state_anthropic_test.go (fixtures are
Anthropic's documented block and stream shapes; no captured bytes exist)
fail on the old code and pass now. A sealed block isn't handed to a
Responses client. gofmt -l; go vet -tags nogui on darwin, GOOS=linux and
GOOS=windows; go test -tags nogui ./internal/gateway/ with a scratch HOME
and pinned GOMODCACHE/GOCACHE: ok. Not tried with a real account.
This commit is contained in:
yetone committed 2026-10-10 13:29:45 +08:00
1 parent e85be9d6f1
commit 64179e7383
5 files changed
+447 -20

No files matched your search

+93 -14
View File
@@ -42,6 +42,9 @@ type aBlock struct {
Signature string `json:"signature,omitempty"`
// a prompt-cache breakpoint: the prompt up to here is cached
CacheControl map[string]string `json:"cache_control,omitempty"`
// sealed: a redacted_thinking block as Anthropic wrote it, which goes
// back as it came (Part.Sealed, #1445)
sealed json.RawMessage
}
// MarshalJSON writes a thinking block's text even when it is empty.
@@ -51,6 +54,9 @@ type aBlock struct {
// other block keeps its omitempty fields.
func (b aBlock) MarshalJSON() ([]byte, error) {
type plain aBlock
if len(b.sealed) > 0 {
return b.sealed, nil
}
if b.Type != "thinking" {
return json.Marshal(plain(b))
}
@@ -131,7 +137,8 @@ func parseAnthropic(body []byte) (*Request, error) {
if err := json.Unmarshal(m.Content, &blocks); err != nil {
return nil, fmt.Errorf("invalid message content: %v", err)
}
for _, b := range blocks {
var raws []json.RawMessage // each block as the client sent it
for i, b := range blocks {
switch b.Type {
case "text":
msg.Parts = append(msg.Parts, Part{Kind: Text, Text: b.Text})
@@ -146,6 +153,16 @@ func parseAnthropic(body []byte) (*Request, error) {
msg.Parts = append(msg.Parts, Part{Kind: ToolResult, CallID: anthropicInID(b.ToolUseID), Text: out, Images: images, IsError: b.IsError})
case "thinking":
msg.Parts = append(msg.Parts, Part{Kind: Thinking, Text: b.Thinking, Signature: b.Signature})
case "redacted_thinking":
// Claude's reasoning its safety systems sealed: it
// goes back to Messages whole, and is nothing to
// another API (#1445)
if raws == nil {
_ = json.Unmarshal(m.Content, &raws)
}
if i < len(raws) {
msg.Parts = append(msg.Parts, Part{Kind: Thinking, Sealed: raws[i], SealedBy: sealAnthropic})
}
}
}
}
@@ -652,7 +669,9 @@ func buildAnthropic(r *Request, model string) []byte {
}
results = append(results, aBlock{Type: "tool_result", ToolUseID: p.CallID, Content: c, IsError: p.IsError})
case Thinking:
if p.Signature != "" {
if p.sealedBy(sealAnthropic) {
rest = append(rest, aBlock{Type: "redacted_thinking", sealed: p.Sealed})
} else if p.Signature != "" {
rest = append(rest, aBlock{Type: "thinking", Thinking: p.Text, Signature: p.Signature})
}
}
@@ -672,7 +691,7 @@ func buildAnthropic(r *Request, model string) []byte {
if n := len(msgs); n > 0 {
c := msgs[n-1].Content
for i := len(c) - 1; i >= 0; i-- {
if c[i].Type != "thinking" {
if c[i].Type != "thinking" && c[i].Type != "redacted_thinking" {
c[i].CacheControl = ephemeral
break
}
@@ -847,6 +866,9 @@ func decodeAnthropic(data string, emit func(Event)) error {
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
// thinking
Thinking string `json:"thinking"`
Signature string `json:"signature"`
} `json:"content_block"`
Delta struct {
Type string `json:"type"`
@@ -878,6 +900,24 @@ func decodeAnthropic(data string, emit func(Event)) error {
if ev.ContentBlock.Text != "" {
emit(Event{Kind: KText, Text: ev.ContentBlock.Text})
}
case "thinking":
// a block of its own, even signed with no text in it, or
// right after another (#1445)
emit(Event{Kind: KThinkStart})
if ev.ContentBlock.Thinking != "" {
emit(Event{Kind: KThink, Text: ev.ContentBlock.Thinking})
}
if ev.ContentBlock.Signature != "" {
emit(Event{Kind: KSig, Text: ev.ContentBlock.Signature})
}
case "redacted_thinking":
// sealed whole: it goes on as it came, to Messages only
var whole struct {
Block json.RawMessage `json:"content_block"`
}
if json.Unmarshal([]byte(data), &whole) == nil && len(whole.Block) > 0 {
emit(Event{Kind: KSealed, Name: sealAnthropic, Text: string(whole.Block)})
}
}
case "content_block_delta":
switch ev.Delta.Type {
@@ -973,12 +1013,15 @@ func stopToAnthropic(s string) string {
// anthropicEncoder writes events as an Anthropic event stream.
type anthropicEncoder struct {
id string
w *sseWriter
model string
index int
open Kind // kind of the open content block, "" when none
args bool // the open tool block got arguments
id string
w *sseWriter
model string
index int
open Kind // kind of the open content block, "" when none
args bool // the open tool block got arguments
// fresh: a thinking block began upstream (KThinkStart), to be opened
// apart from the one open with what it says first
fresh bool
started bool
col collector
}
@@ -1077,12 +1120,22 @@ func (e *anthropicEncoder) openBlock(k Kind, block map[string]any) {
return
}
e.close()
e.args = false
e.args, e.fresh = false, false
block["type"] = map[Kind]string{Text: "text", Thinking: "thinking", ToolCall: "tool_use"}[k]
e.w.event("content_block_start", map[string]any{"type": "content_block_start", "index": e.index, "content_block": block})
e.open = k
}
// thinking makes the open block a thinking block: the one open, unless a
// new one has begun since (KThinkStart).
func (e *anthropicEncoder) thinking() {
if e.fresh {
e.close()
e.fresh = false
}
e.openBlock(Thinking, map[string]any{"thinking": ""})
}
func (e *anthropicEncoder) delta(d map[string]any) {
e.w.event("content_block_delta", map[string]any{"type": "content_block_delta", "index": e.index, "delta": d})
}
@@ -1100,16 +1153,32 @@ func (e *anthropicEncoder) event(ev Event) {
}
e.openBlock(Text, map[string]any{"text": ""})
e.delta(map[string]any{"type": "text_delta", "text": ev.Text})
case KThinkStart:
// opened with what it says first: one that says nothing isn't
e.fresh = true
e.col.add(ev)
return
case KThink:
if ev.Text == "" {
return
}
e.openBlock(Thinking, map[string]any{"thinking": ""})
e.thinking()
e.delta(map[string]any{"type": "thinking_delta", "thinking": ev.Text})
case KSig:
if e.open == Thinking {
// a block signed with no text goes as it came, signed (#1445)
if e.open == Thinking || e.fresh {
e.thinking()
e.delta(map[string]any{"type": "signature_delta", "signature": ev.Text})
}
case KSealed:
if ev.Name == sealAnthropic {
// a redacted_thinking block, whole as Anthropic wrote it
e.close()
e.fresh = false
e.w.event("content_block_start", map[string]any{"type": "content_block_start", "index": e.index, "content_block": json.RawMessage(ev.Text)})
e.w.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": e.index})
e.index++
}
case KToolStart:
id := ev.ID
if id == "" {
@@ -1171,13 +1240,23 @@ func (e *anthropicEncoder) finish() {
// renderAnthropic is the non-streaming reply.
func renderAnthropic(res Result, model string) []byte {
content := []map[string]any{}
content := []any{}
for _, p := range res.Parts {
switch p.Kind {
case Text:
if p.Text == "" && p.Signature != "" {
continue // only Gemini's signature, which is Gemini's
}
content = append(content, map[string]any{"type": "text", "text": p.Text})
case Thinking:
content = append(content, map[string]any{"type": "thinking", "thinking": p.Text, "signature": p.Signature})
switch {
case p.sealedBy(sealAnthropic):
content = append(content, p.Sealed)
case p.Text == "" && p.Signature == "":
// another API's sealed reasoning, nothing to Messages
default:
content = append(content, map[string]any{"type": "thinking", "thinking": p.Text, "signature": p.Signature})
}
case ToolCall:
id := p.ID
if id == "" {
+25 -1
View File
@@ -2105,11 +2105,23 @@ func (r *subscriptionRun) readOutput(rd io.Reader) {
unstreamed, blocks = whole.ID, 0
}
unstreamedStop = whole.StopReason
for _, c := range whole.Content {
var raws struct {
Content []json.RawMessage `json:"content"`
}
for i, c := range whole.Content {
index := blocks
blocks++
switch c.Type {
case "redacted_thinking":
// sealed whole, as Anthropic wrote it (#1445)
if raws.Content == nil {
_ = json.Unmarshal(envelope.Message, &raws)
}
if i < len(raws.Content) {
r.emit(Event{Kind: KSealed, Name: sealAnthropic, Text: string(raws.Content[i])})
}
case "thinking":
r.emit(Event{Kind: KThinkStart})
if c.Thinking != "" {
r.emit(Event{Kind: KThink, Text: c.Thinking})
}
@@ -2173,6 +2185,18 @@ func (r *subscriptionRun) readOutput(rd io.Reader) {
}
case "text":
text(e.ContentBlock.Text)
case "thinking":
// a block of its own, signed with no text or not (#1445)
r.emit(Event{Kind: KThinkStart})
case "redacted_thinking":
var raw struct {
Event struct {
Block json.RawMessage `json:"content_block"`
} `json:"event"`
}
if json.Unmarshal(b, &raw) == nil && len(raw.Event.Block) > 0 {
r.emit(Event{Kind: KSealed, Name: sealAnthropic, Text: string(raw.Event.Block)})
}
}
case "content_block_delta":
switch e.Delta.Type {
+1 -1
View File
@@ -195,7 +195,7 @@ func (c *continuation) request(orig *Request) *Request {
func (c *continuation) emit(enc streamEncoder, ev Event) {
if c.resume {
switch ev.Kind {
case KThink, KSig:
case KThink, KSig, KThinkStart, KSealed:
return
case KText:
if ev.Text = c.unecho(ev.Text); ev.Text == "" {
+80 -4
View File
@@ -48,13 +48,35 @@ type Part struct {
Images []Part // the images the tool returned beside its text
Standalone map[string]any // native Responses notification with no call ID
// thinking
// thinking: its signature (Anthropic's, or a Gemini thought's). On a
// tool call, and on text, Gemini's thoughtSignature: a tool call's
// comes only from Google, as gemini_signature.go carries it, and
// text's only from a Gemini API (#1445)
Signature string
// thinking an upstream sealed, as its API wrote it: Anthropic's
// redacted_thinking block, a Responses reasoning item with its id,
// summary and encrypted_content. Only that API's builder, encoder and
// renderer write it, byte for byte; to any other it is reasoning with
// no text, or Text's (#1445). SealedBy is the API (sealAnthropic,
// sealResponses).
Sealed json.RawMessage
SealedBy string
// web_search: Text is the query
Hits []Hit
}
// The APIs a Part's Sealed reasoning is in.
const (
sealAnthropic = "anthropic"
sealResponses = "responses"
)
// sealedBy reports whether p is reasoning api sealed.
func (p Part) sealedBy(api string) bool {
return p.Kind == Thinking && p.SealedBy == api && len(p.Sealed) > 0
}
// Hit is a page a web search found.
type Hit struct {
Title string `json:"title"`
@@ -231,6 +253,16 @@ const (
KError // Text
KSearch // Text (the query), Hits: a web search run for the model
KImage // Name (media type), Text (base64): an image the model made
// KThinkStart: a new block of reasoning begins (Anthropic's thinking
// block, a Responses reasoning item, ID its id): what follows is not
// the one before's, even with nothing between them
KThinkStart
// KSealed: Text is reasoning the upstream sealed, whole as its API
// wrote it, Name that API (Part.Sealed): Anthropic's redacted_thinking
// block, a Responses reasoning item as it was done
KSealed
// KTextSig: Text is Gemini's thoughtSignature on the text before it
KTextSig
)
// Event is one thing a streaming reply said.
@@ -345,7 +377,9 @@ type Result struct {
type collector struct {
res Result
args strings.Builder // arguments of the open tool call
err string
// fresh: a KThinkStart said the next reasoning is a block of its own
fresh bool
err string
// the error's status and kind, as its event gave them
errStatus int
errCode string
@@ -358,6 +392,18 @@ func (c *collector) last(k Kind) *Part {
return nil
}
// open is the reasoning the next of it goes on: the last part, when it is
// reasoning not sealed whole, and no new block has begun since.
func (c *collector) open() *Part {
if c.fresh {
return nil
}
if p := c.last(Thinking); p != nil && len(p.Sealed) == 0 {
return p
}
return nil
}
func (c *collector) closeTool() {
if p := c.last(ToolCall); p != nil && p.Args == nil {
s := strings.TrimSpace(c.args.String())
@@ -384,16 +430,40 @@ func (c *collector) add(ev Event) {
c.closeTool()
c.res.Parts = append(c.res.Parts, Part{Kind: Text, Text: ev.Text})
}
case KThinkStart:
c.fresh = true
return
case KThink:
if p := c.last(Thinking); p != nil {
if p := c.open(); p != nil {
p.Text += ev.Text
} else {
c.closeTool()
c.res.Parts = append(c.res.Parts, Part{Kind: Thinking, Text: ev.Text})
}
case KSig:
if p := c.last(Thinking); p != nil {
// a block signed with no text in it (Claude's thinking when its
// display is omitted) is a block all the same (#1445)
if p := c.open(); p != nil {
p.Signature += ev.Text
} else if c.fresh {
c.closeTool()
c.res.Parts = append(c.res.Parts, Part{Kind: Thinking, Signature: ev.Text})
}
case KSealed:
// a Responses item's summary came before it as reasoning of its
// own: the item is that reasoning, sealed
if p := c.open(); p != nil && ev.Name == sealResponses && p.Signature == "" {
p.Sealed, p.SealedBy = json.RawMessage(ev.Text), ev.Name
} else {
c.closeTool()
c.res.Parts = append(c.res.Parts, Part{Kind: Thinking, Sealed: json.RawMessage(ev.Text), SealedBy: ev.Name})
}
case KTextSig:
if p := c.last(Text); p != nil && p.Signature == "" {
p.Signature = ev.Text
} else {
c.closeTool()
c.res.Parts = append(c.res.Parts, Part{Kind: Text, Signature: ev.Text})
}
case KToolStart:
c.closeTool()
@@ -415,6 +485,12 @@ func (c *collector) add(ev Event) {
c.closeTool()
c.res.Parts = append(c.res.Parts, Part{Kind: Image, MediaType: ev.Name, Data: ev.Text})
}
switch ev.Kind {
case KStart, KUsage, KError:
default:
// what began has had its first content, or something else came
c.fresh = false
}
}
func (c *collector) finish() Result {
@@ -0,0 +1,248 @@
package gateway
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// Reasoning state that goes from a client to an upstream of the same API
// through magpie's translation (a non-streaming request to a stream-only
// account, web search on the client's own API, a plugin, failover) comes
// back as the vendor wrote it (#1445, XYenon). The fixtures are the
// vendors' documented shapes.
// redactedBlock is a redacted_thinking block as Anthropic's extended
// thinking docs give it.
const redactedBlock = `{"type":"redacted_thinking","data":"EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpPkNRj2YfWXGmKDxH4mPnZ5sQ7vB5URj2pabUKZOJV4Dw=="}`
// translateStream runs an upstream's stream (its data lines) through the
// decoder of to and the encoder of from, as forwardTranslated's reply is.
func translateStream(t *testing.T, to, from provider.Protocol, lines ...string) []map[string]any {
t.Helper()
rec := httptest.NewRecorder()
enc := makeEncoder(from, newSSEWriter(rec), &Request{Model: "m", Stream: true})
dec := decoder(to)
for _, l := range lines {
if err := dec(l, enc.event); err != nil {
t.Fatal(err)
}
}
enc.finish()
return sseData(t, rec.Body.Bytes())
}
// translateWhole runs an upstream's stream through the decoder of to and
// the collector, rendered for a from client that didn't stream.
func translateWhole(t *testing.T, to, from provider.Protocol, lines ...string) map[string]any {
t.Helper()
var c collector
dec := decoder(to)
for _, l := range lines {
if err := dec(l, c.add); err != nil {
t.Fatal(err)
}
}
var out map[string]any
b := render(from, c.finish(), &Request{Model: "m"})
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("%v: %s", err, b)
}
return out
}
// jsonIs says whether a value is the JSON want is, compacted.
func jsonIs(t *testing.T, got any, want string) bool {
t.Helper()
g, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
var w bytes.Buffer
if err := json.Compact(&w, []byte(want)); err != nil {
t.Fatal(err)
}
// both through one decode, so key order is the same
var gv, wv any
json.Unmarshal(g, &gv)
json.Unmarshal(w.Bytes(), &wv)
a, _ := json.Marshal(gv)
b, _ := json.Marshal(wv)
return bytes.Equal(a, b)
}
// A Messages request's redacted_thinking goes back to Messages as it came,
// beside a thinking block signed with no text (display omitted) and one
// with text, each its own block. The old parse dropped the redacted block.
func TestAnthropicSealedThinkingGoesBackAsItCame(t *testing.T) {
body := `{"model":"claude-opus-4-5","max_tokens":1024,"thinking":{"type":"enabled","budget_tokens":2048},"messages":[
{"role":"user","content":"What's the weather in Paris?"},
{"role":"assistant","content":[
{"type":"thinking","thinking":"","signature":"EqQBCkYIBxgCKkDomitted"},
{"type":"thinking","thinking":"Let me check the weather.","signature":"ErUBCkYIBxgCIkB2signed"},
` + redactedBlock + `,
{"type":"tool_use","id":"toolu_01A09q90qw90lq917835lq9","name":"get_weather","input":{"location":"Paris"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01A09q90qw90lq917835lq9","content":"15 degrees"}]}]}`
req, err := parse(provider.Anthropic, []byte(body))
if err != nil {
t.Fatal(err)
}
out, err := build(provider.Anthropic, req, "claude-opus-4-5", "api.anthropic.com", false)
if err != nil {
t.Fatal(err)
}
var q struct {
Messages []struct {
Content []json.RawMessage `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(out, &q); err != nil || len(q.Messages) != 3 {
t.Fatalf("%v: %s", err, out)
}
got := q.Messages[1].Content
want := []string{
`{"type":"thinking","thinking":"","signature":"EqQBCkYIBxgCKkDomitted"}`,
`{"type":"thinking","thinking":"Let me check the weather.","signature":"ErUBCkYIBxgCIkB2signed"}`,
redactedBlock,
`{"type":"tool_use","id":"toolu_01A09q90qw90lq917835lq9","name":"get_weather","input":{"location":"Paris"}}`,
}
if len(got) != len(want) {
t.Fatalf("assistant blocks %d, want %d:\n%s", len(got), len(want), out)
}
for i := range want {
if !jsonIs(t, got[i], want[i]) {
t.Fatalf("block %d:\n got %s\nwant %s", i, got[i], want[i])
}
}
// the redacted block byte for byte
if string(got[2]) != redactedBlock {
t.Fatalf("redacted block changed:\n got %s\nwant %s", got[2], redactedBlock)
}
}
// anthropicSealedStream is a Messages stream as the streaming docs give
// it: a thinking block signed with no text, one right after it with text,
// a redacted_thinking block, then the answer.
var anthropicSealedStream = []string{
`{"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","model":"claude-opus-4-5","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}`,
`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}`,
`{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqQBCkYIBxgCKkDomitted"}}`,
`{"type":"content_block_stop","index":0}`,
`{"type":"content_block_start","index":1,"content_block":{"type":"thinking","thinking":"","signature":""}}`,
`{"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"Let me check the weather."}}`,
`{"type":"content_block_delta","index":1,"delta":{"type":"signature_delta","signature":"ErUBCkYIBxgCIkB2signed"}}`,
`{"type":"content_block_stop","index":1}`,
`{"type":"content_block_start","index":2,"content_block":` + redactedBlock + `}`,
`{"type":"content_block_stop","index":2}`,
`{"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}}`,
`{"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"It's 15 degrees."}}`,
`{"type":"content_block_stop","index":3}`,
`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":30}}`,
`{"type":"message_stop"}`,
}
// A Messages stream through translation keeps each thinking block apart,
// signed as it was, and the redacted one whole. The old code merged the
// two thinking blocks' signatures into one and dropped the redacted block.
func TestAnthropicStreamKeepsSealedThinking(t *testing.T) {
evs := translateStream(t, provider.Anthropic, provider.Anthropic, anthropicSealedStream...)
type block struct {
raw any
thinking string
signature string
}
var blocks []*block
for _, ev := range evs {
switch ev["type"] {
case "content_block_start":
cb := ev["content_block"].(map[string]any)
b := &block{raw: cb}
b.thinking, _ = cb["thinking"].(string)
blocks = append(blocks, b)
case "content_block_delta":
d := ev["delta"].(map[string]any)
b := blocks[len(blocks)-1]
if s, ok := d["thinking"].(string); ok {
b.thinking += s
}
if s, ok := d["signature"].(string); ok {
b.signature += s
}
}
}
if len(blocks) != 4 {
t.Fatalf("%d blocks, want 4: %v", len(blocks), evs)
}
if blocks[0].thinking != "" || blocks[0].signature != "EqQBCkYIBxgCKkDomitted" {
t.Fatalf("signed block with no text: %+v", *blocks[0])
}
if blocks[1].thinking != "Let me check the weather." || blocks[1].signature != "ErUBCkYIBxgCIkB2signed" {
t.Fatalf("signed block: %+v", *blocks[1])
}
if !jsonIs(t, blocks[2].raw, redactedBlock) {
t.Fatalf("redacted block: %v", blocks[2].raw)
}
}
// The same stream for a client that didn't stream: the reply's content
// has the three blocks as the stream gave them.
func TestAnthropicWholeReplyKeepsSealedThinking(t *testing.T) {
out := translateWhole(t, provider.Anthropic, provider.Anthropic, anthropicSealedStream...)
content, _ := out["content"].([]any)
want := []string{
`{"type":"thinking","thinking":"","signature":"EqQBCkYIBxgCKkDomitted"}`,
`{"type":"thinking","thinking":"Let me check the weather.","signature":"ErUBCkYIBxgCIkB2signed"}`,
redactedBlock,
`{"type":"text","text":"It's 15 degrees."}`,
}
if len(content) != len(want) {
t.Fatalf("content %d blocks, want %d: %v", len(content), len(want), content)
}
for i := range want {
if !jsonIs(t, content[i], want[i]) {
t.Fatalf("block %d: %v, want %s", i, content[i], want[i])
}
}
}
// A sealed block is nothing to another API's client: a Responses client
// isn't handed Anthropic's redacted data.
func TestAnthropicSealedThinkingStaysWithMessages(t *testing.T) {
out := translateWhole(t, provider.Anthropic, provider.Responses, anthropicSealedStream...)
b, _ := json.Marshal(out)
if bytes.Contains(b, []byte("EmwKAhgBEgy3va3pzix")) || bytes.Contains(b, []byte("EqQBCkYIBxgCKkDomitted")) {
t.Fatalf("Anthropic's sealed thinking reached a Responses client: %s", b)
}
evs := translateStream(t, provider.Anthropic, provider.Responses, anthropicSealedStream...)
b, _ = json.Marshal(evs)
if bytes.Contains(b, []byte("EmwKAhgBEgy3va3pzix")) {
t.Fatalf("Anthropic's redacted thinking reached a Responses stream: %s", b)
}
}
// Claude's subscription (Claude Code's stream) is the same sibling: a
// redacted block and two thinking blocks reach the client apart.
func TestClaudeSubscriptionKeepsSealedThinking(t *testing.T) {
lines := []string{cutStart}
for _, l := range anthropicSealedStream[1:] {
lines = append(lines, `{"type":"stream_event","event":`+l+`}`)
}
lines = append(lines, `{"type":"result","subtype":"success","is_error":false,"result":"It's 15 degrees."}`)
text, _, _, _, raw, _, _ := claudeReply(t, &subscriptionRun{}, lines, true)
if text != "It's 15 degrees." {
t.Fatalf("reply %q:\n%s", text, raw)
}
var starts []string
for _, ev := range sseData(t, []byte(raw)) {
if ev["type"] == "content_block_start" {
b, _ := json.Marshal(ev["content_block"])
starts = append(starts, string(b))
}
}
if len(starts) != 4 || !jsonIs(t, json.RawMessage(starts[2]), redactedBlock) {
t.Fatalf("blocks %v:\n%s", starts, raw)
}
}