Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions core/providers/anthropic/requestbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 7 additions & 4 deletions core/providers/anthropic/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
}
}
Expand All @@ -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
Expand Down
107 changes: 107 additions & 0 deletions core/providers/anthropic/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
})
}
}
4 changes: 4 additions & 0 deletions core/schemas/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading