From 64179e7383a3223207b7718f16128532082525a6 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 05:34:09 +0800 Subject: [PATCH] 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. --- internal/gateway/anthropic.go | 107 +++++++- internal/gateway/claude_subscription.go | 26 +- internal/gateway/continuation.go | 2 +- internal/gateway/ir.go | 84 +++++- .../gateway/reasoning_state_anthropic_test.go | 248 ++++++++++++++++++ 5 files changed, 447 insertions(+), 20 deletions(-) create mode 100644 internal/gateway/reasoning_state_anthropic_test.go diff --git a/internal/gateway/anthropic.go b/internal/gateway/anthropic.go index 0e26fd53..6f2e7d6e 100644 --- a/internal/gateway/anthropic.go +++ b/internal/gateway/anthropic.go @@ -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 == "" { diff --git a/internal/gateway/claude_subscription.go b/internal/gateway/claude_subscription.go index d3649601..10eb496f 100644 --- a/internal/gateway/claude_subscription.go +++ b/internal/gateway/claude_subscription.go @@ -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 { diff --git a/internal/gateway/continuation.go b/internal/gateway/continuation.go index 06cdee17..c6623d05 100644 --- a/internal/gateway/continuation.go +++ b/internal/gateway/continuation.go @@ -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 == "" { diff --git a/internal/gateway/ir.go b/internal/gateway/ir.go index de9ce286..30330113 100644 --- a/internal/gateway/ir.go +++ b/internal/gateway/ir.go @@ -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 { diff --git a/internal/gateway/reasoning_state_anthropic_test.go b/internal/gateway/reasoning_state_anthropic_test.go new file mode 100644 index 00000000..77bb93e1 --- /dev/null +++ b/internal/gateway/reasoning_state_anthropic_test.go @@ -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) + } +}