Skip to content

Commit 771f128

Browse files
committed
fix(providers/anthropic): preserve web search error replay
1 parent d097d04 commit 771f128

3 files changed

Lines changed: 284 additions & 17 deletions

File tree

‎providers/anthropic/anthropic.go‎

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,8 +1027,9 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
10271027
continue
10281028
}
10291029
if result.ProviderExecuted {
1030-
// Reconstruct web_search_tool_result block
1031-
// with encrypted_content for round-tripping.
1030+
// Reconstruct web_search_tool_result blocks,
1031+
// including encrypted content and errors, for
1032+
// round-tripping.
10321033
searchMeta := &WebSearchResultMetadata{}
10331034
if webMeta, ok := result.ProviderOptions[Name]; ok {
10341035
if typed, ok := webMeta.(*WebSearchResultMetadata); ok {
@@ -1124,24 +1125,37 @@ func decodeToolCallInputAny(toolCall fantasy.ToolCallPart) (any, *fantasy.CallWa
11241125
// buildWebSearchToolResultBlock constructs an Anthropic
11251126
// web_search_tool_result content block from structured metadata.
11261127
func buildWebSearchToolResultBlock(toolCallID string, searchMeta *WebSearchResultMetadata) anthropic.ContentBlockParamUnion {
1127-
resultBlocks := make([]anthropic.WebSearchResultBlockParam, 0, len(searchMeta.Results))
1128-
for _, r := range searchMeta.Results {
1129-
block := anthropic.WebSearchResultBlockParam{
1130-
URL: r.URL,
1131-
Title: r.Title,
1132-
EncryptedContent: r.EncryptedContent,
1128+
var content anthropic.WebSearchToolResultBlockParamContentUnion
1129+
switch {
1130+
case searchMeta != nil && len(searchMeta.Results) > 0:
1131+
resultBlocks := make([]anthropic.WebSearchResultBlockParam, 0, len(searchMeta.Results))
1132+
for _, r := range searchMeta.Results {
1133+
block := anthropic.WebSearchResultBlockParam{
1134+
URL: r.URL,
1135+
Title: r.Title,
1136+
EncryptedContent: r.EncryptedContent,
1137+
}
1138+
if r.PageAge != "" {
1139+
block.PageAge = param.NewOpt(r.PageAge)
1140+
}
1141+
resultBlocks = append(resultBlocks, block)
1142+
}
1143+
content = anthropic.WebSearchToolResultBlockParamContentUnion{
1144+
OfWebSearchToolResultBlockItem: resultBlocks,
11331145
}
1134-
if r.PageAge != "" {
1135-
block.PageAge = param.NewOpt(r.PageAge)
1146+
case searchMeta != nil && searchMeta.ErrorCode != "":
1147+
content = anthropic.NewWebSearchToolRequestError(
1148+
anthropic.WebSearchToolResultErrorCode(searchMeta.ErrorCode),
1149+
)
1150+
default:
1151+
content = anthropic.WebSearchToolResultBlockParamContentUnion{
1152+
OfWebSearchToolResultBlockItem: []anthropic.WebSearchResultBlockParam{},
11361153
}
1137-
resultBlocks = append(resultBlocks, block)
11381154
}
11391155
return anthropic.ContentBlockParamUnion{
11401156
OfWebSearchToolResult: &anthropic.WebSearchToolResultBlockParam{
11411157
ToolUseID: toolCallID,
1142-
Content: anthropic.WebSearchToolResultBlockParamContentUnion{
1143-
OfWebSearchToolResultBlockItem: resultBlocks,
1144-
},
1158+
Content: content,
11451159
},
11461160
}
11471161
}
@@ -1271,6 +1285,12 @@ func (a languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas
12711285
Results: metadataResults,
12721286
},
12731287
}
1288+
} else if webSearchResult.Content.ErrorCode != "" {
1289+
toolResult.ProviderMetadata = fantasy.ProviderMetadata{
1290+
Name: &WebSearchResultMetadata{
1291+
ErrorCode: string(webSearchResult.Content.ErrorCode),
1292+
},
1293+
}
12741294
}
12751295
content = append(content, toolResult)
12761296
}
@@ -1451,6 +1471,12 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
14511471
Results: metadataResults,
14521472
},
14531473
}
1474+
} else if contentBlock.Content.ErrorCode != "" {
1475+
providerMeta = fantasy.ProviderMetadata{
1476+
Name: &WebSearchResultMetadata{
1477+
ErrorCode: string(contentBlock.Content.ErrorCode),
1478+
},
1479+
}
14541480
}
14551481
if !yield(fantasy.StreamPart{
14561482
Type: fantasy.StreamPartTypeToolResult,

‎providers/anthropic/anthropic_test.go‎

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,17 @@ func awaitAnthropicCall(t *testing.T, calls <-chan anthropicCall) anthropicCall
670670
}
671671
}
672672

673+
func requireWebSearchResultMetadata(t *testing.T, metadata fantasy.ProviderMetadata) *WebSearchResultMetadata {
674+
t.Helper()
675+
676+
providerOption, ok := fantasy.ProviderOptions(metadata)[Name]
677+
require.True(t, ok, "provider metadata should contain anthropic key")
678+
result, ok := providerOption.(*WebSearchResultMetadata)
679+
require.True(t, ok, "provider metadata should be *WebSearchResultMetadata")
680+
require.NotNil(t, result)
681+
return result
682+
}
683+
673684
func assertNoAnthropicCall(t *testing.T, calls <-chan anthropicCall) {
674685
t.Helper()
675686

@@ -786,6 +797,103 @@ func mockAnthropicWebSearchResponse() map[string]any {
786797
}
787798
}
788799

800+
func mockAnthropicWebSearchErrorResponse() map[string]any {
801+
return map[string]any{
802+
"id": "msg_01WebSearchError",
803+
"type": "message",
804+
"role": "assistant",
805+
"model": "claude-sonnet-4-20250514",
806+
"content": []any{
807+
map[string]any{
808+
"type": "server_tool_use",
809+
"id": "srvtoolu_err",
810+
"name": "web_search",
811+
"input": map[string]any{"query": "latest AI news"},
812+
"caller": map[string]any{"type": "direct"},
813+
},
814+
map[string]any{
815+
"type": "web_search_tool_result",
816+
"tool_use_id": "srvtoolu_err",
817+
"caller": map[string]any{"type": "direct"},
818+
"content": map[string]any{
819+
"type": "web_search_tool_result_error",
820+
"error_code": "max_uses_exceeded",
821+
},
822+
},
823+
map[string]any{
824+
"type": "text",
825+
"text": "I was unable to search.",
826+
},
827+
},
828+
"stop_reason": "end_turn",
829+
"stop_sequence": nil,
830+
"usage": map[string]any{
831+
"input_tokens": 100,
832+
"output_tokens": 20,
833+
"cache_creation_input_tokens": 0,
834+
"cache_read_input_tokens": 0,
835+
"server_tool_use": map[string]any{
836+
"web_search_requests": 1,
837+
},
838+
},
839+
}
840+
}
841+
842+
func TestToPrompt_WebSearchProviderExecutedErrorRoundTrip(t *testing.T) {
843+
t.Parallel()
844+
845+
prompt := fantasy.Prompt{
846+
{
847+
Role: fantasy.MessageRoleUser,
848+
Content: []fantasy.MessagePart{
849+
fantasy.TextPart{Text: "Search for the latest AI news"},
850+
},
851+
},
852+
{
853+
Role: fantasy.MessageRoleAssistant,
854+
Content: []fantasy.MessagePart{
855+
fantasy.ToolCallPart{
856+
ToolCallID: "srvtoolu_err",
857+
ToolName: "web_search",
858+
Input: `{"query":"latest AI news"}`,
859+
ProviderExecuted: true,
860+
},
861+
fantasy.ToolResultPart{
862+
ToolCallID: "srvtoolu_err",
863+
ProviderExecuted: true,
864+
ProviderOptions: fantasy.ProviderOptions{
865+
Name: &WebSearchResultMetadata{ErrorCode: "max_uses_exceeded"},
866+
},
867+
},
868+
fantasy.TextPart{Text: "I was unable to search."},
869+
},
870+
},
871+
}
872+
873+
_, messages, warnings := toPrompt(prompt, true)
874+
require.Empty(t, warnings)
875+
require.Len(t, messages, 2)
876+
877+
assistantMsg := messages[1]
878+
require.Len(t, assistantMsg.Content, 3)
879+
require.NotNil(t, assistantMsg.Content[0].OfServerToolUse)
880+
require.Equal(t, "srvtoolu_err", assistantMsg.Content[0].OfServerToolUse.ID)
881+
882+
webResult := assistantMsg.Content[1]
883+
require.NotNil(t, webResult.OfWebSearchToolResult)
884+
require.Equal(t, "srvtoolu_err", webResult.OfWebSearchToolResult.ToolUseID)
885+
require.Nil(t, webResult.OfWebSearchToolResult.Content.OfWebSearchToolResultBlockItem)
886+
require.NotNil(t, webResult.OfWebSearchToolResult.Content.OfRequestWebSearchToolResultError)
887+
require.Equal(
888+
t,
889+
anthropic.WebSearchToolResultErrorCodeMaxUsesExceeded,
890+
webResult.OfWebSearchToolResult.Content.OfRequestWebSearchToolResultError.ErrorCode,
891+
)
892+
893+
require.NotNil(t, assistantMsg.Content[2].OfText)
894+
require.Equal(t, "I was unable to search.", assistantMsg.Content[2].OfText.Text)
895+
}
896+
789897
func TestToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) {
790898
t.Parallel()
791899

@@ -965,6 +1073,66 @@ func TestGenerate_WebSearchResponse(t *testing.T) {
9651073
)
9661074
}
9671075

1076+
func TestGenerate_WebSearchErrorPreservesErrorCode(t *testing.T) {
1077+
t.Parallel()
1078+
1079+
server, calls := newAnthropicJSONServer(mockAnthropicWebSearchErrorResponse())
1080+
defer server.Close()
1081+
1082+
provider, err := New(
1083+
WithAPIKey("test-api-key"),
1084+
WithBaseURL(server.URL),
1085+
)
1086+
require.NoError(t, err)
1087+
1088+
model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
1089+
require.NoError(t, err)
1090+
1091+
resp, err := model.Generate(context.Background(), fantasy.Call{
1092+
Prompt: testPrompt(),
1093+
Tools: []fantasy.Tool{
1094+
WebSearchTool(nil),
1095+
},
1096+
})
1097+
require.NoError(t, err)
1098+
1099+
call := awaitAnthropicCall(t, calls)
1100+
require.Equal(t, "POST", call.method)
1101+
require.Equal(t, "/v1/messages", call.path)
1102+
1103+
var (
1104+
toolCalls []fantasy.ToolCallContent
1105+
sources []fantasy.SourceContent
1106+
toolResults []fantasy.ToolResultContent
1107+
texts []fantasy.TextContent
1108+
)
1109+
for _, c := range resp.Content {
1110+
switch v := c.(type) {
1111+
case fantasy.ToolCallContent:
1112+
toolCalls = append(toolCalls, v)
1113+
case fantasy.SourceContent:
1114+
sources = append(sources, v)
1115+
case fantasy.ToolResultContent:
1116+
toolResults = append(toolResults, v)
1117+
case fantasy.TextContent:
1118+
texts = append(texts, v)
1119+
}
1120+
}
1121+
1122+
require.Len(t, toolCalls, 1)
1123+
require.True(t, toolCalls[0].ProviderExecuted)
1124+
require.Equal(t, "srvtoolu_err", toolCalls[0].ToolCallID)
1125+
require.Empty(t, sources)
1126+
require.Len(t, toolResults, 1)
1127+
require.True(t, toolResults[0].ProviderExecuted)
1128+
require.Equal(t, "srvtoolu_err", toolResults[0].ToolCallID)
1129+
webMeta := requireWebSearchResultMetadata(t, toolResults[0].ProviderMetadata)
1130+
require.Equal(t, "max_uses_exceeded", webMeta.ErrorCode)
1131+
require.Empty(t, webMeta.Results)
1132+
require.Len(t, texts, 1)
1133+
require.Equal(t, "I was unable to search.", texts[0].Text)
1134+
}
1135+
9681136
func TestGenerate_WebSearchToolInRequest(t *testing.T) {
9691137
t.Parallel()
9701138

@@ -1436,6 +1604,75 @@ func TestStream_WebSearchResponse(t *testing.T) {
14361604
require.Equal(t, "Here are the results.", textDeltas[0].Delta)
14371605
}
14381606

1607+
func TestStream_WebSearchErrorPreservesErrorCode(t *testing.T) {
1608+
t.Parallel()
1609+
1610+
chunks := []string{
1611+
"event: message_start\n",
1612+
`data: {"type":"message_start","message":{"id":"msg_01WebSearchError","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"usage":{"input_tokens":100,"output_tokens":0}}}` + "\n\n",
1613+
"event: content_block_start\n",
1614+
`data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_err","name":"web_search","input":{}}}` + "\n\n",
1615+
"event: content_block_stop\n",
1616+
`data: {"type":"content_block_stop","index":0}` + "\n\n",
1617+
"event: content_block_start\n",
1618+
`data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_err","content":{"type":"web_search_tool_result_error","error_code":"max_uses_exceeded"}}}` + "\n\n",
1619+
"event: content_block_stop\n",
1620+
`data: {"type":"content_block_stop","index":1}` + "\n\n",
1621+
"event: message_stop\n",
1622+
`data: {"type":"message_stop"}` + "\n\n",
1623+
}
1624+
1625+
server, calls := newAnthropicStreamingServer(chunks)
1626+
defer server.Close()
1627+
1628+
provider, err := New(
1629+
WithAPIKey("test-api-key"),
1630+
WithBaseURL(server.URL),
1631+
)
1632+
require.NoError(t, err)
1633+
1634+
model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
1635+
require.NoError(t, err)
1636+
1637+
stream, err := model.Stream(context.Background(), fantasy.Call{
1638+
Prompt: testPrompt(),
1639+
Tools: []fantasy.Tool{
1640+
WebSearchTool(nil),
1641+
},
1642+
})
1643+
require.NoError(t, err)
1644+
1645+
var parts []fantasy.StreamPart
1646+
stream(func(part fantasy.StreamPart) bool {
1647+
parts = append(parts, part)
1648+
return true
1649+
})
1650+
1651+
_ = awaitAnthropicCall(t, calls)
1652+
1653+
var (
1654+
toolResults []fantasy.StreamPart
1655+
sourceParts []fantasy.StreamPart
1656+
)
1657+
for _, p := range parts {
1658+
switch p.Type {
1659+
case fantasy.StreamPartTypeToolResult:
1660+
toolResults = append(toolResults, p)
1661+
case fantasy.StreamPartTypeSource:
1662+
sourceParts = append(sourceParts, p)
1663+
}
1664+
}
1665+
1666+
require.Len(t, toolResults, 1)
1667+
require.Empty(t, sourceParts)
1668+
require.True(t, toolResults[0].ProviderExecuted)
1669+
require.Equal(t, "srvtoolu_err", toolResults[0].ID)
1670+
require.Equal(t, "web_search", toolResults[0].ToolCallName)
1671+
webMeta := requireWebSearchResultMetadata(t, toolResults[0].ProviderMetadata)
1672+
require.Equal(t, "max_uses_exceeded", webMeta.ErrorCode)
1673+
require.Empty(t, webMeta.Results)
1674+
}
1675+
14391676
func TestGenerate_ToolChoiceNone(t *testing.T) {
14401677
t.Parallel()
14411678

‎providers/anthropic/provider_options.go‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,14 @@ type WebSearchResultItem struct {
159159
}
160160

161161
// WebSearchResultMetadata stores web search results from Anthropic's
162-
// server-executed web_search tool. The structured data (especially
163-
// EncryptedContent) must be preserved for multi-turn conversations.
162+
// server-executed web_search tool. The structured data, especially
163+
// encrypted content and error codes, must be preserved for multi-turn
164+
// conversations.
164165
type WebSearchResultMetadata struct {
165-
Results []WebSearchResultItem `json:"results"`
166+
Results []WebSearchResultItem `json:"results,omitempty"`
167+
// ErrorCode is the Anthropic web_search_tool_result_error.error_code.
168+
// At most one of Results or ErrorCode should be non-empty.
169+
ErrorCode string `json:"error_code,omitempty"`
166170
}
167171

168172
// Options implements the ProviderOptions interface.

0 commit comments

Comments
 (0)