From 30c1b9a4bafc8d496100fe0cf4ab2288803d54ff Mon Sep 17 00:00:00 2001 From: tejas ghatte Date: Mon, 6 Jul 2026 15:50:19 +0530 Subject: [PATCH] fix: web fetch fixes --- .../anthropic/passthroughstream_test.go | 186 +++++++++- .../anthropic/requestbuilder_test.go | 1 + core/providers/anthropic/responses.go | 332 ++++++++++++------ core/providers/anthropic/types.go | 38 +- core/providers/anthropic/utils.go | 2 + core/schemas/responses.go | 39 +- core/schemas/utils.go | 26 ++ framework/streaming/responses.go | 26 ++ framework/streaming/responses_test.go | 34 ++ 9 files changed, 555 insertions(+), 129 deletions(-) diff --git a/core/providers/anthropic/passthroughstream_test.go b/core/providers/anthropic/passthroughstream_test.go index 479fd408106..e523ede75ff 100644 --- a/core/providers/anthropic/passthroughstream_test.go +++ b/core/providers/anthropic/passthroughstream_test.go @@ -178,10 +178,10 @@ func runAnthropicPassthrough(t *testing.T, raws []string, applyFix bool) ([]ptFr ctx := schemas.NewBifrostContext(nil, time.Time{}) // This harness always models the passthrough path (raw frames interleaved), so // mark the reverse converter accordingly — mirroring the transport, which calls - // SetResponsesStreamPassthrough when shouldUsePassthrough is true. This drives the - // server-tool result-block index bumps that keep converted frames in lockstep with - // the raw ones; the all-normalized path (TestAnthropicConverterOnly_*) leaves it - // unset so indices stay contiguous. + // SetResponsesStreamPassthrough when shouldUsePassthrough is true. This drives + // the server-tool result-block index bumps that keep converted frames in + // lockstep with the raw ones; the all-normalized path (TestAnthropicConverterOnly_*) + // leaves it unset so indices stay contiguous. SetResponsesStreamPassthrough(ctx) state := newAdvisorStreamState() var out []ptFrame @@ -378,11 +378,11 @@ func TestAnthropicConverterOnlyStream_WellFormed(t *testing.T) { // TestAnthropicConverterOnly_IndicesContiguous guards the all-normalized path // (OpenAI-via-Anthropic, curl — never Claude Code): with no interleaved raw frames, // SetResponsesStreamPassthrough is NOT called, so the converter must emit contiguous -// content_block indices 0,1,2,… A gap (an index consumed for a dropped server-tool +// content_block indices 0,1,2,… A gap (an index consumed for a hidden server-tool // result block but no block emitted) is what a strict Anthropic SDK client sees as a -// missing block; native Anthropic never produces one. web_fetch and zero-result -// web_search are the two cases that drop a result block — anti-vacuous: before the -// passthrough gating they emit [0,1,3]. +// missing block; native Anthropic never produces one. Zero-result web_search is the +// case that still collapses a result block — anti-vacuous: before the passthrough +// gating it emitted [0,1,3]. func TestAnthropicConverterOnly_IndicesContiguous(t *testing.T) { scenarios := map[string][]string{ "web_fetch": ptConcat([]string{ptMsgStart()}, ptThinking(0), ptWebFetch(1, "srv_F"), ptText(3), ptMsgEnd()), @@ -425,3 +425,173 @@ func TestAnthropicConverterOnly_IndicesContiguous(t *testing.T) { }) } } + +func TestAnthropicWebFetchResultRoundTrip(t *testing.T) { + toolID := "srvtoolu_fetch" + result := &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeWebFetchToolResult, + ToolUseID: &toolID, + Content: &AnthropicContent{ContentObj: &AnthropicContentBlock{ + Type: "web_fetch_result", + URL: schemas.Ptr("https://example.com/article"), + RetrievedAt: schemas.Ptr("2026-07-06T09:00:28Z"), + Content: &AnthropicContent{ContentObj: &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeDocument, + Title: schemas.Ptr("Example Article"), + Source: &AnthropicBlockSource{SourceObj: &AnthropicSource{ + Type: "text", + MediaType: schemas.Ptr("text/plain"), + Data: schemas.Ptr("Full text content"), + }}, + Citations: &AnthropicCitations{Config: &schemas.Citations{Enabled: schemas.Ptr(true)}}, + }}, + }}, + } + + carry := convertAnthropicWebFetchResultToBifrost(result) + if carry == nil || carry.Document == nil || carry.Document.Source == nil { + t.Fatalf("expected typed web_fetch result payload, got %#v", carry) + } + if got := *carry.Document.Source.Data; got != "Full text content" { + t.Fatalf("unexpected carried document data: %q", got) + } + + msg := &schemas.ResponsesMessage{ + ID: &toolID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeWebFetchCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: &toolID, + Action: &schemas.ResponsesToolMessageActionStruct{ + ResponsesWebFetchToolCallAction: &schemas.ResponsesWebFetchToolCallAction{ + Type: "fetch", + URL: "https://example.com/article", + }, + }, + ResponsesWebFetchCall: carry, + }, + } + + blocks := convertBifrostWebFetchCallToAnthropicBlocks(msg) + if len(blocks) != 2 { + t.Fatalf("expected server_tool_use + web_fetch_tool_result, got %d blocks: %#v", len(blocks), blocks) + } + if blocks[1].Type != AnthropicContentBlockTypeWebFetchToolResult { + t.Fatalf("expected web_fetch_tool_result, got %q", blocks[1].Type) + } + inner := getAnthropicContentObject(blocks[1].Content) + if inner == nil || inner.URL == nil || *inner.URL != "https://example.com/article" { + t.Fatalf("expected rebuilt fetch result URL, got %#v", inner) + } + doc := getAnthropicContentObject(inner.Content) + if doc == nil || doc.Source == nil || doc.Source.SourceObj == nil || doc.Source.SourceObj.Data == nil { + t.Fatalf("expected rebuilt document source, got %#v", doc) + } + if got := *doc.Source.SourceObj.Data; got != "Full text content" { + t.Fatalf("unexpected rebuilt document data: %q", got) + } +} + +func TestAnthropicWebFetchTextResultRoundTrip(t *testing.T) { + toolID := "srvtoolu_fetch_text" + result := &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeWebFetchToolResult, + ToolUseID: &toolID, + Content: &AnthropicContent{ContentObj: &AnthropicContentBlock{ + Type: "web_fetch_result", + URL: schemas.Ptr("https://example.com/plain"), + Content: &AnthropicContent{ContentObj: &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeText, + Text: schemas.Ptr("plain fetched text"), + }}, + }}, + } + + carry := convertAnthropicWebFetchResultToBifrost(result) + if carry == nil || carry.Document == nil || carry.Document.Text == nil { + t.Fatalf("expected typed text web_fetch result payload, got %#v", carry) + } + if got := *carry.Document.Text; got != "plain fetched text" { + t.Fatalf("unexpected carried text: %q", got) + } + + msg := &schemas.ResponsesMessage{ + ID: &toolID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeWebFetchCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: &toolID, + ResponsesWebFetchCall: carry, + }, + } + + blocks := convertBifrostWebFetchCallToAnthropicBlocks(msg) + if len(blocks) != 2 { + t.Fatalf("expected server_tool_use + web_fetch_tool_result, got %d blocks: %#v", len(blocks), blocks) + } + inner := getAnthropicContentObject(blocks[1].Content) + if inner == nil || inner.Content == nil { + t.Fatalf("expected rebuilt fetch result content, got %#v", inner) + } + textBlock := getAnthropicContentObject(inner.Content) + if textBlock == nil || textBlock.Text == nil || *textBlock.Text != "plain fetched text" { + t.Fatalf("expected rebuilt text content, got %#v", textBlock) + } +} + +func TestAnthropicWebFetchPassthroughNoResultConsumesHiddenIndex(t *testing.T) { + ctx := schemas.NewBifrostContext(nil, time.Time{}) + SetResponsesStreamPassthrough(ctx) + + toolID := "srvtoolu_fetch_missing_result" + textID := "msg_after_fetch" + addFetch := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + OutputIndex: schemas.Ptr(0), + Item: &schemas.ResponsesMessage{ + ID: &toolID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeWebFetchCall), + Status: schemas.Ptr("in_progress"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: &toolID, + Action: &schemas.ResponsesToolMessageActionStruct{ + ResponsesWebFetchToolCallAction: &schemas.ResponsesWebFetchToolCallAction{ + Type: "fetch", + URL: "https://example.com", + }, + }, + }, + }, + } + doneFetch := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + OutputIndex: schemas.Ptr(0), + Item: &schemas.ResponsesMessage{ + ID: &toolID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeWebFetchCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: &toolID, + }, + }, + } + addText := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + OutputIndex: schemas.Ptr(1), + Item: &schemas.ResponsesMessage{ + ID: &textID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("after")}, + }, + } + + _ = ToAnthropicResponsesStreamResponse(ctx, addFetch) + _ = ToAnthropicResponsesStreamResponse(ctx, doneFetch) + events := ToAnthropicResponsesStreamResponse(ctx, addText) + if len(events) == 0 || events[0].Index == nil { + t.Fatalf("expected text start event with index, got %#v", events) + } + if got := *events[0].Index; got != 2 { + t.Fatalf("expected hidden web_fetch result index to be consumed before next block; got index %d", got) + } +} diff --git a/core/providers/anthropic/requestbuilder_test.go b/core/providers/anthropic/requestbuilder_test.go index 33b0f1e677e..71b209096fc 100644 --- a/core/providers/anthropic/requestbuilder_test.go +++ b/core/providers/anthropic/requestbuilder_test.go @@ -577,6 +577,7 @@ func TestDoesWebSearchOrFetchAutoInjectCodeExecution(t *testing.T) { {string(AnthropicToolTypeWebFetch20250910), false}, {string(AnthropicToolTypeWebFetch20260209), true}, {string(AnthropicToolTypeWebFetch20260309), true}, + {string(AnthropicToolTypeWebFetch20260318), true}, {"web_search_unknown", true}, {"web_fetch_unknown", true}, {"unknown_type", true}, diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index 7736ec744e6..132b809947b 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -36,7 +36,7 @@ type AnthropicResponsesStreamState struct { WebFetchToolID *string // Tool ID of active web fetch WebFetchOutputIndex *int // Output index for this fetch WebFetchURL *string // URL captured from the server_tool_use input - WebFetchResult *AnthropicContentBlock // Result block when it arrives (content dropped, presence gates output_item.done) + WebFetchResult *AnthropicContentBlock // Result block when it arrives // Advisor tool accumulation AdvisorToolID *string // Tool ID of active advisor call @@ -181,11 +181,10 @@ type anthropicToResponsesStreamState struct { // passthrough is true when this reverse conversion runs on the Claude Code // passthrough path, where verbatim raw upstream frames are interleaved with the // converter's own. Only then must the converter consume a content-block index for - // server-tool result blocks it drops (web_fetch, zero-result web_search) — to stay - // in lockstep with the upstream indices carried by the interleaved raw frames. On - // the all-normalized path (OpenAI-via-Anthropic, curl) every frame is converter- - // built, so consuming the index would skip a number — which native Anthropic never - // does. Set by the transport via SetResponsesStreamPassthrough. + // server-tool result blocks it still collapses (currently resultless web_search) + // to stay in lockstep with the upstream indices carried by interleaved raw frames. + // On the all-normalized path (OpenAI-via-Anthropic, curl), every frame is + // converter-built, so consuming the index would skip a number. passthrough bool // codeExecToolNameByItem remembers each code_interpreter_call's Anthropic @@ -271,9 +270,8 @@ func getOrCreateAnthropicToResponsesStreamState(ctx *schemas.BifrostContext) *an // SetResponsesStreamPassthrough marks this request's Anthropic reverse stream // conversion as running on the Claude Code passthrough path (raw upstream frames -// interleaved with converted ones). The transport calls it; the converter reads -// state.passthrough to decide whether to consume server-tool result-block indices -// it does not emit. See the passthrough field. +// interleaved with converted ones). The converter reads state.passthrough only for +// collapsed server-tool result blocks that must still consume an upstream index. func SetResponsesStreamPassthrough(ctx *schemas.BifrostContext) { getOrCreateAnthropicToResponsesStreamState(ctx).passthrough = true } @@ -1544,11 +1542,9 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, }, nil, false } - // End of a web_fetch_tool_result block — emit the web_fetch_call done. - // The fetched content has no Responses representation (dropped, as in the - // non-streaming path); this emits a completed web_fetch_call carrying the - // request URL so the reverse converter can close the server_tool_use block - // at a consistent index (mirrors web_search minus the result block). + // End of a web_fetch_tool_result block — emit the web_fetch_call done with + // the typed result payload so Anthropic-compatible reverse conversion can + // faithfully rebuild the server_tool_use + web_fetch_tool_result pair. if state.WebFetchResult != nil && state.WebFetchToolID != nil { toolMsg := &schemas.ResponsesToolMessage{CallID: state.WebFetchToolID} if state.WebFetchURL != nil { @@ -1559,6 +1555,7 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, }, } } + toolMsg.ResponsesWebFetchCall = convertAnthropicWebFetchResultToBifrost(state.WebFetchResult) item := &schemas.ResponsesMessage{ ID: state.WebFetchToolID, Type: schemas.Ptr(schemas.ResponsesMessageTypeWebFetchCall), @@ -2281,31 +2278,13 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp *bifrostResp.Item.Type == schemas.ResponsesMessageTypeWebFetchCall { // Web fetch call - emit content_block_start with server_tool_use. The - // request URL is delivered whole in content_block_start (no input_json_delta), - // matching native. The paired web_fetch_tool_result block is not synthesized - // (its content has no Responses representation); output_item.done just closes - // this server_tool_use block. + // paired web_fetch_tool_result block is emitted at output_item.done when + // the typed result payload is available. streamResp.Type = AnthropicStreamEventTypeContentBlockStart streamResp.Index = blockIdx - contentBlock := &AnthropicContentBlock{ - Type: AnthropicContentBlockTypeServerToolUse, - ID: bifrostResp.Item.ID, - Name: schemas.Ptr(string(AnthropicToolNameWebFetch)), - Input: json.RawMessage("{}"), + if blocks := convertBifrostWebFetchCallToAnthropicBlocks(bifrostResp.Item); len(blocks) > 0 { + streamResp.ContentBlock = &blocks[0] } - if tm := bifrostResp.Item.ResponsesToolMessage; tm != nil && tm.Action != nil && - tm.Action.ResponsesWebFetchToolCallAction != nil && tm.Action.ResponsesWebFetchToolCallAction.URL != "" { - if inputBytes, err := providerUtils.MarshalSorted(map[string]interface{}{"url": tm.Action.ResponsesWebFetchToolCallAction.URL}); err == nil { - contentBlock.Input = json.RawMessage(inputBytes) - } - } - if tm := bifrostResp.Item.ResponsesToolMessage; tm != nil && tm.Caller != nil { - contentBlock.Caller = &AnthropicToolCaller{ - Type: AnthropicToolCallerType(tm.Caller.Type), - ToolID: tm.Caller.ToolID, - } - } - streamResp.ContentBlock = contentBlock } else { // Text or other content blocks - emit content_block_start streamResp.Type = AnthropicStreamEventTypeContentBlockStart @@ -2607,6 +2586,13 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp } } } + return []*AnthropicStreamEvent{ + streamResp, + { + Type: AnthropicStreamEventTypeContentBlockStop, + Index: streamResp.Index, + }, + } } else if bifrostResp.Item != nil && bifrostResp.Item.Type != nil && *bifrostResp.Item.Type == schemas.ResponsesMessageTypeWebSearchCall { @@ -2667,13 +2653,10 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp ) } else if state.passthrough { // A resultless or error web_search (no sources, max_uses_exceeded, - // web_search_tool_result_error, …) still consumed a content-block index - // upstream (the web_search_tool_result block), so allocate (and discard) - // one here to keep the counter in lockstep with upstream indices — else - // the following block collides with the verbatim raw frames on the - // passthrough path. Mirrors the web_fetch handling below. Passthrough - // only: on the all-normalized path there are no raw frames to align with, - // so skipping this keeps indices contiguous (matching native Anthropic). + // web_search_tool_result_error, etc.) still consumed a content-block + // index upstream. On the passthrough path, later non-server-tool frames + // may be forwarded raw, so consume that hidden index to keep the + // converter's next allocated index in lockstep with upstream. _ = state.allocBlockIndex("") } @@ -2682,24 +2665,28 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp bifrostResp.Item.Type != nil && *bifrostResp.Item.Type == schemas.ResponsesMessageTypeWebFetchCall { - // Web fetch call complete - close the server_tool_use block. No paired - // web_fetch_tool_result block is emitted (its content has no Responses - // representation), mirroring the non-streaming reverse path. + // Web fetch call complete - close the server_tool_use block, then emit + // the paired web_fetch_tool_result block when present. state := getOrCreateAnthropicToResponsesStreamState(ctx) serverIdx := state.blockIndexFor(reverseStreamItemKey(bifrostResp)) - // Passthrough only: the upstream result block still consumed a content-block - // index, so allocate (and discard) one here to keep the counter in lockstep - // with the interleaved raw frames — otherwise later blocks (e.g. the - // assistant text) collide with them. On the all-normalized path there are no - // raw frames to align with, so skipping this keeps indices contiguous - // (matching native Anthropic). - if state.passthrough { - _ = state.allocBlockIndex("") - } - return []*AnthropicStreamEvent{{ + events := []*AnthropicStreamEvent{{ Type: AnthropicStreamEventTypeContentBlockStop, Index: serverIdx, }} + if blocks := convertBifrostWebFetchCallToAnthropicBlocks(bifrostResp.Item); len(blocks) > 1 { + resultIdx := state.allocBlockIndex("") + resultBlock := blocks[1] + events = append(events, + &AnthropicStreamEvent{Type: AnthropicStreamEventTypeContentBlockStart, Index: resultIdx, ContentBlock: &resultBlock}, + &AnthropicStreamEvent{Type: AnthropicStreamEventTypeContentBlockStop, Index: resultIdx}, + ) + } else if state.passthrough { + // Older/partial replay items can lack the typed web_fetch result even + // though upstream consumed a result-block index. Keep later raw + // passthrough frames aligned with the converter's allocator. + _ = state.allocBlockIndex("") + } + return events } else if bifrostResp.Item != nil && bifrostResp.Item.Type != nil && *bifrostResp.Item.Type == schemas.ResponsesMessageTypeCodeInterpreterCall { @@ -4410,49 +4397,26 @@ func ConvertBifrostMessagesToAnthropicMessages(ctx *schemas.BifrostContext, bifr case schemas.ResponsesMessageTypeWebFetchCall: flushPendingToolResults() - - if currentAssistantMessage == nil { - currentAssistantMessage = &AnthropicMessage{ - Role: AnthropicMessageRoleAssistant, - } - } - - if len(pendingReasoningContentBlocks) > 0 { - copied := make([]AnthropicContentBlock, len(pendingReasoningContentBlocks)) - copy(copied, pendingReasoningContentBlocks) - pendingToolCalls = append(copied, pendingToolCalls...) - pendingReasoningContentBlocks = nil - } - - serverToolUseBlock := AnthropicContentBlock{ - Type: AnthropicContentBlockTypeServerToolUse, - Name: schemas.Ptr(string(AnthropicToolNameWebFetch)), - } - if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Caller != nil { - serverToolUseBlock.Caller = &AnthropicToolCaller{ - Type: AnthropicToolCallerType(msg.ResponsesToolMessage.Caller.Type), - ToolID: msg.ResponsesToolMessage.Caller.ToolID, + webFetchBlocks := convertBifrostWebFetchCallToAnthropicBlocks(&msg) + if len(webFetchBlocks) > 0 { + if currentAssistantMessage == nil { + currentAssistantMessage = &AnthropicMessage{ + Role: AnthropicMessageRoleAssistant, + } } - } - if msg.ID != nil { - serverToolUseBlock.ID = msg.ID - } - if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Action != nil && - msg.ResponsesToolMessage.Action.ResponsesWebFetchToolCallAction != nil { - inputBytes, err := providerUtils.MarshalSorted(map[string]interface{}{ - "url": msg.ResponsesToolMessage.Action.ResponsesWebFetchToolCallAction.URL, - }) - if err == nil { - serverToolUseBlock.Input = json.RawMessage(inputBytes) + if len(pendingReasoningContentBlocks) > 0 { + copied := make([]AnthropicContentBlock, len(pendingReasoningContentBlocks)) + copy(copied, pendingReasoningContentBlocks) + pendingToolCalls = append(copied, pendingToolCalls...) + pendingReasoningContentBlocks = nil } - } - pendingToolCalls = append(pendingToolCalls, serverToolUseBlock) - - if serverToolUseBlock.ID != nil { - if currentToolCallIDs == nil { - currentToolCallIDs = make(map[string]bool) + pendingToolCalls = append(pendingToolCalls, webFetchBlocks...) + if webFetchBlocks[0].ID != nil { + if currentToolCallIDs == nil { + currentToolCallIDs = make(map[string]bool) + } + currentToolCallIDs[*webFetchBlocks[0].ID] = true } - currentToolCallIDs[*serverToolUseBlock.ID] = true } // Handle other tool call types that are not natively supported by Anthropic @@ -5178,7 +5142,7 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex bifrostMsg := schemas.ResponsesMessage{ Type: schemas.Ptr(schemas.ResponsesMessageTypeWebFetchCall), Status: schemas.Ptr("completed"), - ResponsesToolMessage: &schemas.ResponsesToolMessage{}, + ResponsesToolMessage: &schemas.ResponsesToolMessage{CallID: block.ID}, } if block.Caller != nil { @@ -5273,7 +5237,9 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex } case AnthropicContentBlockTypeWebFetchToolResult: - // Web fetch results are handled server-side by Anthropic, skip + if block.ToolUseID != nil { + attachAnthropicWebFetchResult(bifrostMessages, *block.ToolUseID, block) + } case AnthropicContentBlockTypeWebSearchToolResultError: // Handle web search errors — find matching web_search_call and mark as failed @@ -5863,6 +5829,162 @@ func convertBifrostWebSearchCallToAnthropicBlocks(msg *schemas.ResponsesMessage) return blocks } +func getAnthropicContentObject(content *AnthropicContent) *AnthropicContentBlock { + if content == nil { + return nil + } + if content.ContentObj != nil { + return content.ContentObj + } + if len(content.ContentBlocks) > 0 { + return &content.ContentBlocks[0] + } + return nil +} + +func convertAnthropicWebFetchResultToBifrost(block *AnthropicContentBlock) *schemas.ResponsesWebFetchCall { + if block == nil { + return nil + } + result := &schemas.ResponsesWebFetchCall{} + inner := getAnthropicContentObject(block.Content) + if inner == nil { + return result + } + + result.ResultType = string(inner.Type) + result.URL = inner.URL + result.RetrievedAt = inner.RetrievedAt + result.ErrorCode = inner.ErrorCode + + doc := getAnthropicContentObject(inner.Content) + if doc != nil { + result.Document = &schemas.ResponsesWebFetchDocument{ + Type: string(doc.Type), + Text: doc.Text, + Title: doc.Title, + Context: doc.Context, + } + if doc.Citations != nil && doc.Citations.Config != nil { + result.Document.Citations = doc.Citations.Config + } + if doc.Source != nil && doc.Source.SourceObj != nil { + src := doc.Source.SourceObj + result.Document.Source = &schemas.ResponsesWebFetchSource{ + Type: src.Type, + MediaType: src.MediaType, + Data: src.Data, + URL: src.URL, + FileID: src.FileID, + } + } + } + + return result +} + +func attachAnthropicWebFetchResult(messages []schemas.ResponsesMessage, toolUseID string, block AnthropicContentBlock) { + for i := len(messages) - 1; i >= 0; i-- { + msg := &messages[i] + if msg.Type == nil || *msg.Type != schemas.ResponsesMessageTypeWebFetchCall { + continue + } + if msg.ID == nil || *msg.ID != toolUseID { + continue + } + if msg.ResponsesToolMessage == nil { + msg.ResponsesToolMessage = &schemas.ResponsesToolMessage{} + } + msg.ResponsesToolMessage.CallID = &toolUseID + msg.ResponsesToolMessage.ResponsesWebFetchCall = convertAnthropicWebFetchResultToBifrost(&block) + break + } +} + +func convertBifrostWebFetchCallToAnthropicBlocks(msg *schemas.ResponsesMessage) []AnthropicContentBlock { + if msg == nil || msg.ResponsesToolMessage == nil { + return nil + } + tm := msg.ResponsesToolMessage + + var toolUseID *string + if tm.CallID != nil { + toolUseID = tm.CallID + } else { + toolUseID = msg.ID + } + + var caller *AnthropicToolCaller + if tm.Caller != nil { + caller = &AnthropicToolCaller{Type: AnthropicToolCallerType(tm.Caller.Type), ToolID: tm.Caller.ToolID} + } + + serverToolUseBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeServerToolUse, + ID: toolUseID, + Name: schemas.Ptr(string(AnthropicToolNameWebFetch)), + Caller: caller, + Input: json.RawMessage("{}"), + } + if tm.Action != nil && tm.Action.ResponsesWebFetchToolCallAction != nil && + tm.Action.ResponsesWebFetchToolCallAction.URL != "" { + if inputBytes, err := providerUtils.MarshalSorted(map[string]interface{}{"url": tm.Action.ResponsesWebFetchToolCallAction.URL}); err == nil { + serverToolUseBlock.Input = json.RawMessage(inputBytes) + } + } + + blocks := []AnthropicContentBlock{serverToolUseBlock} + if tm.ResponsesWebFetchCall == nil { + return blocks + } + + wf := tm.ResponsesWebFetchCall + resultType := wf.ResultType + if resultType == "" { + resultType = "web_fetch_result" + } + inner := AnthropicContentBlock{ + Type: AnthropicContentBlockType(resultType), + URL: wf.URL, + RetrievedAt: wf.RetrievedAt, + ErrorCode: wf.ErrorCode, + } + if wf.Document != nil { + doc := &AnthropicContentBlock{ + Type: AnthropicContentBlockType(wf.Document.Type), + Text: wf.Document.Text, + Title: wf.Document.Title, + Context: wf.Document.Context, + } + if doc.Type == "" { + doc.Type = AnthropicContentBlockTypeDocument + } + if wf.Document.Citations != nil { + doc.Citations = &AnthropicCitations{Config: wf.Document.Citations} + } + if wf.Document.Source != nil { + src := wf.Document.Source + doc.Source = &AnthropicBlockSource{SourceObj: &AnthropicSource{ + Type: src.Type, + MediaType: src.MediaType, + Data: src.Data, + URL: src.URL, + FileID: src.FileID, + }} + } + inner.Content = &AnthropicContent{ContentObj: doc} + } + + blocks = append(blocks, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeWebFetchToolResult, + ToolUseID: toolUseID, + Caller: caller, + Content: &AnthropicContent{ContentObj: &inner}, + }) + + return blocks +} + // convertBifrostAdvisorCallToAnthropicBlocks rebuilds the advisor server_tool_use // block and its paired advisor_tool_result block from a neutral advisor_call. // Anthropic requires both blocks to appear together in the assistant message. @@ -6353,8 +6475,10 @@ func convertAnthropicToolToBifrost(tool *AnthropicTool) *schemas.ResponsesTool { } if tool.AnthropicToolWebFetch != nil { bifrostTool.ResponsesToolWebFetch = &schemas.ResponsesToolWebFetch{ - MaxUses: tool.AnthropicToolWebFetch.MaxUses, - MaxContentTokens: tool.AnthropicToolWebFetch.MaxContentTokens, + MaxUses: tool.AnthropicToolWebFetch.MaxUses, + MaxContentTokens: tool.AnthropicToolWebFetch.MaxContentTokens, + UseCache: tool.AnthropicToolWebFetch.UseCache, + ResponseInclusion: tool.AnthropicToolWebFetch.ResponseInclusion, } if len(tool.AnthropicToolWebFetch.AllowedDomains) > 0 || len(tool.AnthropicToolWebFetch.BlockedDomains) > 0 { bifrostTool.ResponsesToolWebFetch.Filters = &schemas.ResponsesToolWebSearchFilters{ @@ -6768,6 +6892,12 @@ func convertBifrostToolToAnthropic(model string, tool *schemas.ResponsesTool, pr (strings.Contains(model, "4.6") || strings.Contains(model, "4-6")) { webFetchType = AnthropicToolTypeWebFetch20260309 } + if tool.ResponsesToolWebFetch != nil && tool.ResponsesToolWebFetch.ResponseInclusion != nil { + webFetchType = AnthropicToolTypeWebFetch20260318 + } else if tool.ResponsesToolWebFetch != nil && tool.ResponsesToolWebFetch.UseCache != nil && + webFetchType == AnthropicToolTypeWebFetch20250910 { + webFetchType = AnthropicToolTypeWebFetch20260309 + } anthropicTool := &AnthropicTool{ Type: schemas.Ptr(webFetchType), Name: string(AnthropicToolNameWebFetch), @@ -6776,6 +6906,8 @@ func convertBifrostToolToAnthropic(model string, tool *schemas.ResponsesTool, pr if tool.ResponsesToolWebFetch != nil { anthropicTool.AnthropicToolWebFetch.MaxUses = tool.ResponsesToolWebFetch.MaxUses anthropicTool.AnthropicToolWebFetch.MaxContentTokens = tool.ResponsesToolWebFetch.MaxContentTokens + anthropicTool.AnthropicToolWebFetch.UseCache = tool.ResponsesToolWebFetch.UseCache + anthropicTool.AnthropicToolWebFetch.ResponseInclusion = tool.ResponsesToolWebFetch.ResponseInclusion if tool.ResponsesToolWebFetch.Filters != nil { anthropicTool.AnthropicToolWebFetch.AllowedDomains = tool.ResponsesToolWebFetch.Filters.AllowedDomains anthropicTool.AnthropicToolWebFetch.BlockedDomains = tool.ResponsesToolWebFetch.Filters.BlockedDomains diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go index e9ed67a2221..c7cb25f18c2 100644 --- a/core/providers/anthropic/types.go +++ b/core/providers/anthropic/types.go @@ -200,7 +200,7 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ WebSearchNova: true, // nova_grounding — Responses path only CodeExecNova: true, // nova_code_interpreter — Responses path only ComputerUse: true, Bash: true, Memory: true, TextEditor: true, ToolSearch: true, - ContainerBasic: true, + ContainerBasic: true, StructuredOutputs: true, // documented on Bedrock per A overview matrix Compaction: true, // compact-2026-01-12 per B-header ContextEditing: true, // context-management-2025-06-27 per B-header (bundles memory) @@ -394,8 +394,8 @@ type AnthropicMessageRequest struct { ServiceTier *string `json:"service_tier,omitempty"` // "auto" or "standard_only" InferenceGeo *string `json:"inference_geo,omitempty"` // the geographic region for inference processing. If not specified, the workspace's default_inference_geo is used. ContextManagement *ContextManagement `json:"context_management,omitempty"` - Container *AnthropicContainer `json:"container,omitempty"` // string id OR object with skills[]; skills require skills-2025-10-02 beta - Diagnostics *AnthropicDiagnostics `json:"diagnostics,omitempty"` // cache diagnostics opt-in; requires cache-diagnosis-2026-04-07 beta (Anthropic API only) + Container *AnthropicContainer `json:"container,omitempty"` // string id OR object with skills[]; skills require skills-2025-10-02 beta + Diagnostics *AnthropicDiagnostics `json:"diagnostics,omitempty"` // cache diagnostics opt-in; requires cache-diagnosis-2026-04-07 beta (Anthropic API only) // Extra params for advanced use cases ExtraParams map[string]interface{} `json:"-"` @@ -939,12 +939,12 @@ const ( // code_execution inner result-content discriminators (the "content" object on // a *_code_execution_tool_result block; ContentObj.Type carries these). - AnthropicContentBlockTypeCodeExecutionResult AnthropicContentBlockType = "code_execution_result" // legacy Python (code_execution) - AnthropicContentBlockTypeEncryptedCodeExecutionResult AnthropicContentBlockType = "encrypted_code_execution_result" // code_execution with encrypted stdout - AnthropicContentBlockTypeBashCodeExecutionResult AnthropicContentBlockType = "bash_code_execution_result" // bash_code_execution - AnthropicContentBlockTypeTextEditorCodeExecutionResult AnthropicContentBlockType = "text_editor_code_execution_result" // text_editor_code_execution - AnthropicContentBlockTypeCodeExecutionToolResultError AnthropicContentBlockType = "code_execution_tool_result_error" // legacy Python error - AnthropicContentBlockTypeBashCodeExecutionToolResultError AnthropicContentBlockType = "bash_code_execution_tool_result_error" // bash error + AnthropicContentBlockTypeCodeExecutionResult AnthropicContentBlockType = "code_execution_result" // legacy Python (code_execution) + AnthropicContentBlockTypeEncryptedCodeExecutionResult AnthropicContentBlockType = "encrypted_code_execution_result" // code_execution with encrypted stdout + AnthropicContentBlockTypeBashCodeExecutionResult AnthropicContentBlockType = "bash_code_execution_result" // bash_code_execution + AnthropicContentBlockTypeTextEditorCodeExecutionResult AnthropicContentBlockType = "text_editor_code_execution_result" // text_editor_code_execution + AnthropicContentBlockTypeCodeExecutionToolResultError AnthropicContentBlockType = "code_execution_tool_result_error" // legacy Python error + AnthropicContentBlockTypeBashCodeExecutionToolResultError AnthropicContentBlockType = "bash_code_execution_tool_result_error" // bash error AnthropicContentBlockTypeTextEditorCodeExecutionResultError AnthropicContentBlockType = "text_editor_code_execution_tool_result_error" // code_execution file-output blocks (inside a result's "content" array; carry file_id). AnthropicContentBlockTypeCodeExecutionOutput AnthropicContentBlockType = "code_execution_output" // legacy Python output file @@ -1256,6 +1256,7 @@ const ( AnthropicToolTypeWebFetch20250910 AnthropicToolType = "web_fetch_20250910" AnthropicToolTypeWebFetch20260209 AnthropicToolType = "web_fetch_20260209" // Dynamic filtering AnthropicToolTypeWebFetch20260309 AnthropicToolType = "web_fetch_20260309" + AnthropicToolTypeWebFetch20260318 AnthropicToolType = "web_fetch_20260318" // Memory (client-side) AnthropicToolTypeMemory20250818 AnthropicToolType = "memory_20250818" @@ -1289,9 +1290,9 @@ const ( AnthropicToolNameBashCodeExecution AnthropicToolName = "bash_code_execution" AnthropicToolNameTextEditorCodeExecution AnthropicToolName = "text_editor_code_execution" AnthropicToolNameMemory AnthropicToolName = "memory" - AnthropicToolNameToolSearchBM25 AnthropicToolName = "tool_search_tool_bm25" - AnthropicToolNameToolSearchRegex AnthropicToolName = "tool_search_tool_regex" - AnthropicToolNameAdvisor AnthropicToolName = "advisor" + AnthropicToolNameToolSearchBM25 AnthropicToolName = "tool_search_tool_bm25" + AnthropicToolNameToolSearchRegex AnthropicToolName = "tool_search_tool_regex" + AnthropicToolNameAdvisor AnthropicToolName = "advisor" ) type AnthropicToolComputerUse struct { @@ -1317,12 +1318,13 @@ type AnthropicToolWebSearch struct { } type AnthropicToolWebFetch struct { - MaxUses *int `json:"max_uses,omitempty"` - AllowedDomains []string `json:"allowed_domains,omitempty"` - BlockedDomains []string `json:"blocked_domains,omitempty"` - MaxContentTokens *int `json:"max_content_tokens,omitempty"` - Citations *AnthropicCitations `json:"citations,omitempty"` // {enabled: bool} — toggles citation emission on fetched documents - UseCache *bool `json:"use_cache,omitempty"` // web_fetch_20260309+ only — enables server-side page cache + MaxUses *int `json:"max_uses,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty"` + BlockedDomains []string `json:"blocked_domains,omitempty"` + MaxContentTokens *int `json:"max_content_tokens,omitempty"` + Citations *AnthropicCitations `json:"citations,omitempty"` // {enabled: bool} — toggles citation emission on fetched documents + UseCache *bool `json:"use_cache,omitempty"` // web_fetch_20260309+ only — enables server-side page cache + ResponseInclusion *string `json:"response_inclusion,omitempty"` // web_fetch_20260318+ only — "full" | "excluded" } // AnthropicToolTextEditor holds fields specific to the text_editor tool diff --git a/core/providers/anthropic/utils.go b/core/providers/anthropic/utils.go index 9b84aac6a2d..6cd0f52e7e6 100644 --- a/core/providers/anthropic/utils.go +++ b/core/providers/anthropic/utils.go @@ -1341,6 +1341,8 @@ func doesWebSearchOrFetchAutoInjectCodeExecution(toolType string) bool { return true case string(AnthropicToolTypeWebFetch20260309): return true + case string(AnthropicToolTypeWebFetch20260318): + return true case string(AnthropicToolTypeWebFetch20250910): return false case string(AnthropicToolTypeWebFetch20260209): diff --git a/core/schemas/responses.go b/core/schemas/responses.go index fc486ba33b8..7119f7b748a 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -1364,6 +1364,9 @@ type ResponsesToolMessage struct { // Anthropic advisor-specific (advisor_call): carries the advisor_tool_result payload *ResponsesAdvisorCall + // Anthropic web-fetch-specific (web_fetch_call): carries the web_fetch_tool_result payload + *ResponsesWebFetchCall + // Anthropic code-execution-specific (code_interpreter_call): carries the // server_tool_use input + *_code_execution_tool_result payload that the // neutral ResponsesCodeInterpreterToolCall cannot represent. @@ -1380,6 +1383,34 @@ type ResponsesAdvisorCall struct { StopReason *string `json:"advisor_stop_reason,omitempty"` // present when max_tokens is set on the tool } +// ResponsesWebFetchCall carries the Anthropic web_fetch_tool_result payload +// alongside a web_fetch_call. Anthropic-only; the request URL lives on +// ResponsesWebFetchToolCallAction. +type ResponsesWebFetchCall struct { + ResultType string `json:"web_fetch_result_type,omitempty"` // "web_fetch_result" | "web_fetch_tool_result_error" + URL *string `json:"web_fetch_result_url,omitempty"` + RetrievedAt *string `json:"web_fetch_retrieved_at,omitempty"` + Document *ResponsesWebFetchDocument `json:"web_fetch_document,omitempty"` + ErrorCode *string `json:"web_fetch_error_code,omitempty"` +} + +type ResponsesWebFetchDocument struct { + Type string `json:"type,omitempty"` // "document" + Text *string `json:"text,omitempty"` + Title *string `json:"title,omitempty"` + Source *ResponsesWebFetchSource `json:"source,omitempty"` + Citations *Citations `json:"citations,omitempty"` + Context *string `json:"context,omitempty"` +} + +type ResponsesWebFetchSource struct { + Type string `json:"type,omitempty"` // "text" | "base64" | "url" | "file" + MediaType *string `json:"media_type,omitempty"` + Data *string `json:"data,omitempty"` + URL *string `json:"url,omitempty"` + FileID *string `json:"file_id,omitempty"` +} + // ResponsesToolCaller is the neutral form of Anthropic's "caller" union on // server_tool_use / *_tool_result blocks. It links a tool call to the agentic // caller that produced it (e.g. programmatic tool calling from inside the code @@ -2886,9 +2917,11 @@ type ResponsesToolToolSearch struct { // ResponsesToolWebFetch represents a web fetch tool type ResponsesToolWebFetch struct { - MaxUses *int `json:"max_uses,omitempty"` - Filters *ResponsesToolWebSearchFilters `json:"filters,omitempty"` - MaxContentTokens *int `json:"max_content_tokens,omitempty"` + MaxUses *int `json:"max_uses,omitempty"` + Filters *ResponsesToolWebSearchFilters `json:"filters,omitempty"` + MaxContentTokens *int `json:"max_content_tokens,omitempty"` + UseCache *bool `json:"use_cache,omitempty"` + ResponseInclusion *string `json:"response_inclusion,omitempty"` // "full" | "excluded" (web_fetch_20260318+) } // ResponsesToolAdvisorCaching toggles advisor-side prompt caching. diff --git a/core/schemas/utils.go b/core/schemas/utils.go index 601db21dcaf..66cb8c07304 100644 --- a/core/schemas/utils.go +++ b/core/schemas/utils.go @@ -1381,6 +1381,15 @@ func DeepCopyResponsesMessage(original ResponsesMessage) ResponsesMessage { } } + if original.ResponsesToolMessage.Caller != nil { + copyCaller := *original.ResponsesToolMessage.Caller + if original.ResponsesToolMessage.Caller.ToolID != nil { + copyToolID := *original.ResponsesToolMessage.Caller.ToolID + copyCaller.ToolID = ©ToolID + } + copy.ResponsesToolMessage.Caller = ©Caller + } + // Deep copy embedded tool call structs (simplified version - add more as needed) if original.ResponsesToolMessage.ResponsesFileSearchToolCall != nil { copyToolCall := *original.ResponsesToolMessage.ResponsesFileSearchToolCall @@ -1394,6 +1403,23 @@ func DeepCopyResponsesMessage(original ResponsesMessage) ResponsesMessage { copy.ResponsesToolMessage.ResponsesFileSearchToolCall = ©ToolCall } + if original.ResponsesToolMessage.ResponsesWebFetchCall != nil { + copyCall := *original.ResponsesToolMessage.ResponsesWebFetchCall + if original.ResponsesToolMessage.ResponsesWebFetchCall.Document != nil { + docCopy := *original.ResponsesToolMessage.ResponsesWebFetchCall.Document + if original.ResponsesToolMessage.ResponsesWebFetchCall.Document.Source != nil { + srcCopy := *original.ResponsesToolMessage.ResponsesWebFetchCall.Document.Source + docCopy.Source = &srcCopy + } + if original.ResponsesToolMessage.ResponsesWebFetchCall.Document.Citations != nil { + citationsCopy := *original.ResponsesToolMessage.ResponsesWebFetchCall.Document.Citations + docCopy.Citations = &citationsCopy + } + copyCall.Document = &docCopy + } + copy.ResponsesToolMessage.ResponsesWebFetchCall = ©Call + } + // Add other embedded tool calls as needed... } diff --git a/framework/streaming/responses.go b/framework/streaming/responses.go index c7bb8c7a6fa..e78ecebdfff 100644 --- a/framework/streaming/responses.go +++ b/framework/streaming/responses.go @@ -347,6 +347,15 @@ func deepCopyResponsesMessage(original schemas.ResponsesMessage) schemas.Respons } } + if original.ResponsesToolMessage.Caller != nil { + copyCaller := *original.ResponsesToolMessage.Caller + if original.ResponsesToolMessage.Caller.ToolID != nil { + copyToolID := *original.ResponsesToolMessage.Caller.ToolID + copyCaller.ToolID = ©ToolID + } + copy.ResponsesToolMessage.Caller = ©Caller + } + // Deep copy embedded tool call structs if original.ResponsesToolMessage.ResponsesFileSearchToolCall != nil { copyToolCall := *original.ResponsesToolMessage.ResponsesFileSearchToolCall @@ -400,6 +409,23 @@ func deepCopyResponsesMessage(original schemas.ResponsesMessage) schemas.Respons copy.ResponsesToolMessage.ResponsesComputerToolCallOutput = ©Output } + if original.ResponsesToolMessage.ResponsesWebFetchCall != nil { + copyCall := *original.ResponsesToolMessage.ResponsesWebFetchCall + if original.ResponsesToolMessage.ResponsesWebFetchCall.Document != nil { + docCopy := *original.ResponsesToolMessage.ResponsesWebFetchCall.Document + if original.ResponsesToolMessage.ResponsesWebFetchCall.Document.Source != nil { + srcCopy := *original.ResponsesToolMessage.ResponsesWebFetchCall.Document.Source + docCopy.Source = &srcCopy + } + if original.ResponsesToolMessage.ResponsesWebFetchCall.Document.Citations != nil { + citationsCopy := *original.ResponsesToolMessage.ResponsesWebFetchCall.Document.Citations + docCopy.Citations = &citationsCopy + } + copyCall.Document = &docCopy + } + copy.ResponsesToolMessage.ResponsesWebFetchCall = ©Call + } + if original.ResponsesToolMessage.ResponsesCodeInterpreterToolCall != nil { copyToolCall := *original.ResponsesToolMessage.ResponsesCodeInterpreterToolCall // Deep copy Outputs slice diff --git a/framework/streaming/responses_test.go b/framework/streaming/responses_test.go index 3685436d646..c48c88ecb8e 100644 --- a/framework/streaming/responses_test.go +++ b/framework/streaming/responses_test.go @@ -97,6 +97,40 @@ func TestBuildResponsesMessageRoutesParallelToolArgs(t *testing.T) { } } +func TestDeepCopyResponsesStreamResponseCopiesToolCaller(t *testing.T) { + original := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + Item: &schemas.ResponsesMessage{ + ID: schemas.Ptr("srvtoolu_fetch"), + Type: schemas.Ptr(schemas.ResponsesMessageTypeWebFetchCall), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: schemas.Ptr("srvtoolu_fetch"), + Caller: &schemas.ResponsesToolCaller{ + Type: "code_execution_20260120", + ToolID: schemas.Ptr("srvtoolu_code"), + }, + }, + }, + } + + copied := deepCopyResponsesStreamResponse(original) + if copied == nil || copied.Item == nil || copied.Item.ResponsesToolMessage == nil || copied.Item.ResponsesToolMessage.Caller == nil { + t.Fatalf("expected caller to be copied, got %#v", copied) + } + if copied.Item.ResponsesToolMessage.Caller == original.Item.ResponsesToolMessage.Caller { + t.Fatal("caller pointer was aliased") + } + if got := copied.Item.ResponsesToolMessage.Caller.Type; got != "code_execution_20260120" { + t.Fatalf("caller type = %q", got) + } + if copied.Item.ResponsesToolMessage.Caller.ToolID == nil || *copied.Item.ResponsesToolMessage.Caller.ToolID != "srvtoolu_code" { + t.Fatalf("caller tool id not preserved: %#v", copied.Item.ResponsesToolMessage.Caller) + } + if copied.Item.ResponsesToolMessage.Caller.ToolID == original.Item.ResponsesToolMessage.Caller.ToolID { + t.Fatal("caller tool id pointer was aliased") + } +} + // TestBuildResponsesMessageAccumulatesReasoningSummary verifies reasoning // summary deltas (no content index) concatenate into a single summary entry. func TestBuildResponsesMessageAccumulatesReasoningSummary(t *testing.T) {