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:
1 parent
e85be9d6f1
commit
64179e7383
5 files changed
+447
-20
No files matched your search
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user