diff --git a/core/providers/anthropic/requestbuilder.go b/core/providers/anthropic/requestbuilder.go index 877014c21d..9d52c38428 100644 --- a/core/providers/anthropic/requestbuilder.go +++ b/core/providers/anthropic/requestbuilder.go @@ -171,11 +171,6 @@ func BuildAnthropicResponsesRequestBody(ctx *schemas.BifrostContext, request *sc return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) } - jsonBody, err = StripEmptyThinkingBlocks(jsonBody) - if err != nil { - return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) - } - if cfg.RemapToolVersions { // request.Model is the alias-resolved model id; pass it so // computer-use / text-editor / bash tools get normalized to the @@ -302,6 +297,11 @@ func BuildAnthropicResponsesRequestBody(ctx *schemas.BifrostContext, request *sc } } + jsonBody, err = StripEmptyThinkingBlocks(jsonBody) + if err != nil { + return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) + } + jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "fallbacks") if err != nil { return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) diff --git a/core/providers/anthropic/utils.go b/core/providers/anthropic/utils.go index 1f9c4e1b10..7c8dfd12e4 100644 --- a/core/providers/anthropic/utils.go +++ b/core/providers/anthropic/utils.go @@ -1229,8 +1229,11 @@ func doesWebSearchOrFetchAutoInjectCodeExecution(toolType string) bool { return true } -// StripEmptyThinkingBlocks removes thinking content blocks where -// "thinking" is an empty string. Anthropic rejects such blocks with a 400. +// StripEmptyThinkingBlocks removes thinking content blocks that would be +// rejected by Anthropic: those with an empty "thinking" field, or those +// with an empty "signature" field. An empty signature means the block came +// from a non-Anthropic upstream (OpenAI never emits signatures; Anthropic +// always does), so it is unsafe to replay to Anthropic. func StripEmptyThinkingBlocks(jsonBody []byte) ([]byte, error) { messagesResult := providerUtils.GetJSONField(jsonBody, "messages") if !messagesResult.Exists() || !messagesResult.IsArray() { @@ -1244,7 +1247,8 @@ func StripEmptyThinkingBlocks(jsonBody []byte) ([]byte, error) { } var toStrip []int for ci, block := range contentResult.Array() { - if block.Get("type").String() == "thinking" && block.Get("thinking").String() == "" { + if block.Get("type").String() == "thinking" && + (block.Get("thinking").String() == "" || block.Get("signature").String() == "") { toStrip = append(toStrip, ci) } } @@ -1254,7 +1258,6 @@ func StripEmptyThinkingBlocks(jsonBody []byte) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to strip empty thinking block at %s: %w", path, err) } - } } return jsonBody, nil diff --git a/core/providers/anthropic/utils_test.go b/core/providers/anthropic/utils_test.go index 340711d803..af6c20150a 100644 --- a/core/providers/anthropic/utils_test.go +++ b/core/providers/anthropic/utils_test.go @@ -2999,3 +2999,110 @@ func TestBudgetTokensMaxEffortCapsBelowMaxTokens(t *testing.T) { }) } } + +func TestStripEmptyThinkingBlocks(t *testing.T) { + tests := []struct { + name string + input string + wantUnchanged bool + wantMsgConts []int // expected content-array length per message; -1 = string content, skip + }{ + { + name: "strips block with empty thinking and empty signature", + input: `{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":""}]}]}`, + wantMsgConts: []int{0}, + }, + { + name: "strips block with non-empty thinking but empty signature (OpenAI/Gemini cross-provider replay)", + input: `{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"I need to solve this step by step","signature":""}]}]}`, + wantMsgConts: []int{0}, + }, + { + name: "keeps valid Anthropic block with non-empty thinking and signature", + input: `{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"I am reasoning about the answer","signature":"abc123"}]}]}`, + wantMsgConts: []int{1}, + }, + { + // Blocks where thinking="" are also stripped — Anthropic rejects them with + // "each thinking block must contain thinking", even if the signature is valid. + name: "strips block with empty thinking even if signature is non-empty", + input: `{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"abc123"}]}]}`, + wantMsgConts: []int{0}, + }, + { + name: "no thinking blocks, body returned unchanged", + input: `{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + wantUnchanged: true, + wantMsgConts: []int{1}, + }, + { + name: "mixed: strips invalid, keeps valid thinking and text blocks", + input: `{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"some reasoning","signature":""},{"type":"thinking","thinking":"valid","signature":"sig1"},{"type":"text","text":"answer"}]}]}`, + wantMsgConts: []int{2}, + }, + { + name: "redacted_thinking type is not affected", + input: `{"messages":[{"role":"assistant","content":[{"type":"redacted_thinking","data":"opaque"}]}]}`, + wantUnchanged: true, + wantMsgConts: []int{1}, + }, + { + name: "multiple messages: strips invalid in first, keeps valid in second", + input: `{"messages":[` + + `{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]},` + + `{"role":"assistant","content":[{"type":"thinking","thinking":"valid","signature":"sig1"},{"type":"text","text":"hi"}]}` + + `]}`, + wantMsgConts: []int{0, 2}, + }, + { + name: "no messages field, body returned unchanged", + input: `{"model":"claude-opus-4-8","max_tokens":1024}`, + wantUnchanged: true, + }, + { + name: "string content (not array) is skipped without error", + input: `{"messages":[{"role":"user","content":"hello world"}]}`, + wantMsgConts: []int{-1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := StripEmptyThinkingBlocks([]byte(tt.input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantUnchanged && string(out) != tt.input { + t.Errorf("expected body unchanged\ngot: %s\nwant: %s", string(out), tt.input) + } + if tt.wantMsgConts == nil { + return + } + + var result struct { + Messages []struct { + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if jsonErr := json.Unmarshal(out, &result); jsonErr != nil { + t.Fatalf("output is not valid JSON: %v", jsonErr) + } + for mi, wantLen := range tt.wantMsgConts { + if mi >= len(result.Messages) { + t.Fatalf("message index %d out of range (%d messages in output)", mi, len(result.Messages)) + } + if wantLen == -1 { + continue + } + var blocks []json.RawMessage + if jsonErr := json.Unmarshal(result.Messages[mi].Content, &blocks); jsonErr != nil { + t.Fatalf("messages[%d].content is not a JSON array: %v", mi, jsonErr) + } + if len(blocks) != wantLen { + t.Errorf("messages[%d] content block count: got %d, want %d\noutput: %s", + mi, len(blocks), wantLen, string(out)) + } + } + }) + } +} diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 93daa3989b..006c21567e 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -2634,6 +2634,10 @@ func (resp *BifrostResponsesStreamResponse) WithDefaults() *BifrostResponsesStre // Copy nested response (applies defaults) result.Response = resp.Response.WithDefaults() + // OpenAI Responses API requires usage=null on response.created; final usage is on response.completed only + if resp.Type == ResponsesStreamResponseTypeCreated && result.Response != nil { + result.Response.Usage = nil + } // Copy all streaming-specific fields result.OutputIndex = resp.OutputIndex