fix(anthropic): drop empty Read.pages in responses-to-anthropic tool input
This commit is contained in:
@@ -258,6 +258,48 @@ func TestResponsesToAnthropic_ToolUse(t *testing.T) {
|
|||||||
assert.Equal(t, "tool_use", anth.Content[1].Type)
|
assert.Equal(t, "tool_use", anth.Content[1].Type)
|
||||||
assert.Equal(t, "call_1", anth.Content[1].ID)
|
assert.Equal(t, "call_1", anth.Content[1].ID)
|
||||||
assert.Equal(t, "get_weather", anth.Content[1].Name)
|
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) {
|
func TestResponsesToAnthropic_Reasoning(t *testing.T) {
|
||||||
@@ -472,6 +514,41 @@ func TestStreamingToolCall(t *testing.T) {
|
|||||||
assert.Equal(t, "tool_use", events[0].Delta.StopReason)
|
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) {
|
func TestStreamingReasoning(t *testing.T) {
|
||||||
state := NewResponsesEventToAnthropicState()
|
state := NewResponsesEventToAnthropicState()
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicRespo
|
|||||||
Type: "tool_use",
|
Type: "tool_use",
|
||||||
ID: fromResponsesCallID(item.CallID),
|
ID: fromResponsesCallID(item.CallID),
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
Input: json.RawMessage(item.Arguments),
|
Input: sanitizeAnthropicToolUseInput(item.Name, item.Arguments),
|
||||||
})
|
})
|
||||||
case "web_search_call":
|
case "web_search_call":
|
||||||
toolUseID := "srvtoolu_" + item.ID
|
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)
|
// Streaming: ResponsesStreamEvent → []AnthropicStreamEvent (stateful converter)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -142,6 +164,8 @@ type ResponsesEventToAnthropicState struct {
|
|||||||
ContentBlockIndex int
|
ContentBlockIndex int
|
||||||
ContentBlockOpen bool
|
ContentBlockOpen bool
|
||||||
CurrentBlockType string // "text" | "thinking" | "tool_use"
|
CurrentBlockType string // "text" | "thinking" | "tool_use"
|
||||||
|
CurrentToolName string
|
||||||
|
CurrentToolArgs string
|
||||||
|
|
||||||
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
|
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
|
||||||
OutputIndexToBlockIdx map[int]int
|
OutputIndexToBlockIdx map[int]int
|
||||||
@@ -181,7 +205,7 @@ func ResponsesEventToAnthropicEvents(
|
|||||||
case "response.function_call_arguments.delta":
|
case "response.function_call_arguments.delta":
|
||||||
return resToAnthHandleFuncArgsDelta(evt, state)
|
return resToAnthHandleFuncArgsDelta(evt, state)
|
||||||
case "response.function_call_arguments.done":
|
case "response.function_call_arguments.done":
|
||||||
return resToAnthHandleBlockDone(state)
|
return resToAnthHandleFuncArgsDone(evt, state)
|
||||||
case "response.output_item.done":
|
case "response.output_item.done":
|
||||||
return resToAnthHandleOutputItemDone(evt, state)
|
return resToAnthHandleOutputItemDone(evt, state)
|
||||||
case "response.reasoning_summary_text.delta":
|
case "response.reasoning_summary_text.delta":
|
||||||
@@ -278,6 +302,8 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE
|
|||||||
state.OutputIndexToBlockIdx[evt.OutputIndex] = idx
|
state.OutputIndexToBlockIdx[evt.OutputIndex] = idx
|
||||||
state.ContentBlockOpen = true
|
state.ContentBlockOpen = true
|
||||||
state.CurrentBlockType = "tool_use"
|
state.CurrentBlockType = "tool_use"
|
||||||
|
state.CurrentToolName = evt.Item.Name
|
||||||
|
state.CurrentToolArgs = ""
|
||||||
|
|
||||||
events = append(events, AnthropicStreamEvent{
|
events = append(events, AnthropicStreamEvent{
|
||||||
Type: "content_block_start",
|
Type: "content_block_start",
|
||||||
@@ -358,6 +384,11 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if state.CurrentBlockType == "tool_use" && state.CurrentToolName == "Read" {
|
||||||
|
state.CurrentToolArgs += evt.Delta
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
|
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
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 {
|
func resToAnthHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
||||||
if evt.Delta == "" {
|
if evt.Delta == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -524,6 +582,8 @@ func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamE
|
|||||||
idx := state.ContentBlockIndex
|
idx := state.ContentBlockIndex
|
||||||
state.ContentBlockOpen = false
|
state.ContentBlockOpen = false
|
||||||
state.ContentBlockIndex++
|
state.ContentBlockIndex++
|
||||||
|
state.CurrentToolName = ""
|
||||||
|
state.CurrentToolArgs = ""
|
||||||
return []AnthropicStreamEvent{{
|
return []AnthropicStreamEvent{{
|
||||||
Type: "content_block_stop",
|
Type: "content_block_stop",
|
||||||
Index: &idx,
|
Index: &idx,
|
||||||
|
|||||||
Reference in New Issue
Block a user