From 3022090365efdeb77e653ed509f2a1af2edd4846 Mon Sep 17 00:00:00 2001 From: Cloud370 Date: Sun, 26 Apr 2026 20:21:38 +0800 Subject: [PATCH] fix(anthropic): drop empty Read.pages in responses-to-anthropic tool input --- .../pkg/apicompat/anthropic_responses_test.go | 77 +++++++++++++++++++ .../pkg/apicompat/responses_to_anthropic.go | 64 ++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index c35b51b6..facfe572 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -258,6 +258,48 @@ func TestResponsesToAnthropic_ToolUse(t *testing.T) { assert.Equal(t, "tool_use", anth.Content[1].Type) assert.Equal(t, "call_1", anth.Content[1].ID) assert.Equal(t, "get_weather", anth.Content[1].Name) + assert.JSONEq(t, `{"city":"NYC"}`, string(anth.Content[1].Input)) +} + +func TestResponsesToAnthropic_ReadToolDropsEmptyPages(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_read", + Model: "gpt-5.5", + Status: "completed", + Output: []ResponsesOutput{ + { + Type: "function_call", + CallID: "call_read", + Name: "Read", + Arguments: `{"file_path":"/tmp/demo.py","limit":2000,"offset":0,"pages":""}`, + }, + }, + } + + anth := ResponsesToAnthropic(resp, "claude-opus-4-6") + require.Len(t, anth.Content, 1) + assert.Equal(t, "tool_use", anth.Content[0].Type) + assert.JSONEq(t, `{"file_path":"/tmp/demo.py","limit":2000,"offset":0}`, string(anth.Content[0].Input)) +} + +func TestResponsesToAnthropic_PreservesEmptyStringsForOtherTools(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_other", + Model: "gpt-5.5", + Status: "completed", + Output: []ResponsesOutput{ + { + Type: "function_call", + CallID: "call_other", + Name: "Search", + Arguments: `{"query":""}`, + }, + }, + } + + anth := ResponsesToAnthropic(resp, "claude-opus-4-6") + require.Len(t, anth.Content, 1) + assert.JSONEq(t, `{"query":""}`, string(anth.Content[0].Input)) } func TestResponsesToAnthropic_Reasoning(t *testing.T) { @@ -472,6 +514,41 @@ func TestStreamingToolCall(t *testing.T) { assert.Equal(t, "tool_use", events[0].Delta.StopReason) } +func TestStreamingReadToolDropsEmptyPages(t *testing.T) { + state := NewResponsesEventToAnthropicState() + + ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.created", + Response: &ResponsesResponse{ID: "resp_read_stream", Model: "gpt-5.5"}, + }, state) + + events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", CallID: "call_read", Name: "Read"}, + }, state) + require.Len(t, events, 1) + assert.Equal(t, "content_block_start", events[0].Type) + + events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.delta", + OutputIndex: 0, + Delta: `{"file_path":"/tmp/demo.py","limit":2000,"offset":0,"pages":""}`, + }, state) + assert.Len(t, events, 0) + + events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.done", + OutputIndex: 0, + Arguments: `{"file_path":"/tmp/demo.py","limit":2000,"offset":0,"pages":""}`, + }, state) + require.Len(t, events, 2) + assert.Equal(t, "content_block_delta", events[0].Type) + assert.Equal(t, "input_json_delta", events[0].Delta.Type) + assert.JSONEq(t, `{"file_path":"/tmp/demo.py","limit":2000,"offset":0}`, events[0].Delta.PartialJSON) + assert.Equal(t, "content_block_stop", events[1].Type) +} + func TestStreamingReasoning(t *testing.T) { state := NewResponsesEventToAnthropicState() diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic.go b/backend/internal/pkg/apicompat/responses_to_anthropic.go index 40bed302..489ed238 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic.go @@ -52,7 +52,7 @@ func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicRespo Type: "tool_use", ID: fromResponsesCallID(item.CallID), Name: item.Name, - Input: json.RawMessage(item.Arguments), + Input: sanitizeAnthropicToolUseInput(item.Name, item.Arguments), }) case "web_search_call": toolUseID := "srvtoolu_" + item.ID @@ -129,6 +129,28 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom } } +func sanitizeAnthropicToolUseInput(name string, raw string) json.RawMessage { + if name != "Read" || raw == "" { + return json.RawMessage(raw) + } + + var input map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &input); err != nil { + return json.RawMessage(raw) + } + + if pages, ok := input["pages"]; !ok || string(pages) != `""` { + return json.RawMessage(raw) + } + + delete(input, "pages") + sanitized, err := json.Marshal(input) + if err != nil { + return json.RawMessage(raw) + } + return sanitized +} + // --------------------------------------------------------------------------- // Streaming: ResponsesStreamEvent → []AnthropicStreamEvent (stateful converter) // --------------------------------------------------------------------------- @@ -142,6 +164,8 @@ type ResponsesEventToAnthropicState struct { ContentBlockIndex int ContentBlockOpen bool CurrentBlockType string // "text" | "thinking" | "tool_use" + CurrentToolName string + CurrentToolArgs string // OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index. OutputIndexToBlockIdx map[int]int @@ -181,7 +205,7 @@ func ResponsesEventToAnthropicEvents( case "response.function_call_arguments.delta": return resToAnthHandleFuncArgsDelta(evt, state) case "response.function_call_arguments.done": - return resToAnthHandleBlockDone(state) + return resToAnthHandleFuncArgsDone(evt, state) case "response.output_item.done": return resToAnthHandleOutputItemDone(evt, state) case "response.reasoning_summary_text.delta": @@ -278,6 +302,8 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE state.OutputIndexToBlockIdx[evt.OutputIndex] = idx state.ContentBlockOpen = true state.CurrentBlockType = "tool_use" + state.CurrentToolName = evt.Item.Name + state.CurrentToolArgs = "" events = append(events, AnthropicStreamEvent{ Type: "content_block_start", @@ -358,6 +384,11 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve return nil } + if state.CurrentBlockType == "tool_use" && state.CurrentToolName == "Read" { + state.CurrentToolArgs += evt.Delta + return nil + } + blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex] if !ok { return nil @@ -373,6 +404,33 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve }} } +func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent { + if state.CurrentBlockType != "tool_use" || state.CurrentToolName != "Read" { + return resToAnthHandleBlockDone(state) + } + + raw := evt.Arguments + if raw == "" { + raw = state.CurrentToolArgs + } + sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, raw) + if len(sanitized) == 0 { + return closeCurrentBlock(state) + } + + idx := state.ContentBlockIndex + events := []AnthropicStreamEvent{{ + Type: "content_block_delta", + Index: &idx, + Delta: &AnthropicDelta{ + Type: "input_json_delta", + PartialJSON: string(sanitized), + }, + }} + events = append(events, closeCurrentBlock(state)...) + return events +} + func resToAnthHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent { if evt.Delta == "" { return nil @@ -524,6 +582,8 @@ func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamE idx := state.ContentBlockIndex state.ContentBlockOpen = false state.ContentBlockIndex++ + state.CurrentToolName = "" + state.CurrentToolArgs = "" return []AnthropicStreamEvent{{ Type: "content_block_stop", Index: &idx,