From 2bbe76710152b45e101b18cd39c031391d5c6b19 Mon Sep 17 00:00:00 2001 From: Sahil Choudhary Date: Tue, 14 Jul 2026 16:16:41 +0530 Subject: [PATCH 01/11] add support for clickhouse for enterprise logstore tables --- framework/logstore/clickhouseextension.go | 84 ++++++++++++++++++++++ framework/logstore/clickhousestore_test.go | 79 ++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 framework/logstore/clickhouseextension.go diff --git a/framework/logstore/clickhouseextension.go b/framework/logstore/clickhouseextension.go new file mode 100644 index 00000000000..1a6ccb7c464 --- /dev/null +++ b/framework/logstore/clickhouseextension.go @@ -0,0 +1,84 @@ +package logstore + +import ( + "context" + "fmt" + "regexp" + "strings" +) + +var clickHouseExtensionTableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +var clickHouseReservedTableNames = map[string]struct{}{ + strings.ToLower((AsyncJob{}).TableName()): {}, + strings.ToLower((Log{}).TableName()): {}, + strings.ToLower((MCPToolLog{}).TableName()): {}, +} + +// ClickHouseExtensionTableOptions defines the schema shape for an +// extension-owned table. All DDL fragments are trusted, code-owned values and +// must never be populated from configuration or user input. +type ClickHouseExtensionTableOptions struct { + Table string + PartitionBy string + OrderBy string + TTL string + SkipIndexes []string +} + +type clickHouseSchemaStore interface { + EnsureClickHouseTable(ctx context.Context, model any, opts ClickHouseExtensionTableOptions) error +} + +var ( + _ clickHouseSchemaStore = (*ClickHouseLogStore)(nil) + _ clickHouseSchemaStore = (*HybridLogStore)(nil) +) + +// EnsureClickHouseTable creates an extension table and reconciles newly added +// model columns. It preserves the configured cluster and replication settings. +func (s *ClickHouseLogStore) EnsureClickHouseTable(ctx context.Context, model any, opts ClickHouseExtensionTableOptions) error { + if s == nil || s.RDBLogStore == nil || s.db == nil { + return fmt.Errorf("clickhouse: logstore is not initialized") + } + if err := validateClickHouseExtensionTableOptions(opts); err != nil { + return err + } + + tableOpts := chTableOpts{ + table: opts.Table, + partitionBy: opts.PartitionBy, + orderBy: opts.OrderBy, + ttl: opts.TTL, + skipIndexes: append([]string(nil), opts.SkipIndexes...), + } + if err := clickhouseCreateTable(ctx, s.db, model, tableOpts, s.cluster); err != nil { + return fmt.Errorf("clickhouse: create extension table %s: %w", opts.Table, err) + } + return clickhouseReconcileColumns(ctx, s.db, model, opts.Table, s.cluster, s.logger) +} + +// EnsureClickHouseTable delegates extension-table schema management to a +// ClickHouse logstore wrapped by hybrid object storage. +func (h *HybridLogStore) EnsureClickHouseTable(ctx context.Context, model any, opts ClickHouseExtensionTableOptions) error { + schemaStore, ok := h.inner.(clickHouseSchemaStore) + if !ok { + return fmt.Errorf("logstore does not support ClickHouse extension tables") + } + return schemaStore.EnsureClickHouseTable(ctx, model, opts) +} + +// validateClickHouseExtensionTableOptions validates identifiers and invariants +// that can be checked without attempting to parse ClickHouse SQL expressions. +func validateClickHouseExtensionTableOptions(opts ClickHouseExtensionTableOptions) error { + if !clickHouseExtensionTableNamePattern.MatchString(opts.Table) { + return fmt.Errorf("clickhouse: invalid extension table name %q", opts.Table) + } + if _, reserved := clickHouseReservedTableNames[strings.ToLower(opts.Table)]; reserved { + return fmt.Errorf("clickhouse: extension table name %q is reserved", opts.Table) + } + if strings.TrimSpace(opts.OrderBy) == "" { + return fmt.Errorf("clickhouse: extension table order by is required") + } + return nil +} diff --git a/framework/logstore/clickhousestore_test.go b/framework/logstore/clickhousestore_test.go index b62099cf02e..32ac90f8626 100644 --- a/framework/logstore/clickhousestore_test.go +++ b/framework/logstore/clickhousestore_test.go @@ -68,6 +68,85 @@ func chTestLog(id string, ts time.Time) *Log { } } +type clickHouseExtensionTestRow struct { + ID string + Value string + CreatedAt time.Time +} + +func (clickHouseExtensionTestRow) TableName() string { return "extension_test_events" } + +func TestClickHouseEnsureExtensionTable(t *testing.T) { + store := trySetupClickHouseStore(t) + ctx := context.Background() + require.Error(t, store.EnsureClickHouseTable(ctx, &clickHouseExtensionTestRow{}, ClickHouseExtensionTableOptions{ + Table: "invalid`; DROP TABLE logs", OrderBy: "id", + })) + require.NoError(t, store.db.Exec("DROP TABLE IF EXISTS extension_test_events").Error) + t.Cleanup(func() { + _ = store.db.Exec("DROP TABLE IF EXISTS extension_test_events").Error + }) + + opts := ClickHouseExtensionTableOptions{ + Table: "extension_test_events", + PartitionBy: "toYYYYMM(created_at)", + OrderBy: "(created_at, id)", + TTL: "toDateTime(created_at) + INTERVAL 30 DAY", + SkipIndexes: []string{"INDEX idx_extension_value lower(value) TYPE bloom_filter(0.01) GRANULARITY 1"}, + } + require.NoError(t, store.EnsureClickHouseTable(ctx, &clickHouseExtensionTestRow{}, opts)) + hybrid := &HybridLogStore{inner: store} + require.NoError(t, hybrid.EnsureClickHouseTable(ctx, &clickHouseExtensionTestRow{}, opts)) + + row := clickHouseExtensionTestRow{ID: "event-1", Value: "matched", CreatedAt: time.Now().UTC()} + require.NoError(t, store.db.WithContext(ctx).Create(&row).Error) + + var count int64 + require.NoError(t, store.db.WithContext(ctx).Model(&clickHouseExtensionTestRow{}).Where("value = ?", "matched").Count(&count).Error) + assert.Equal(t, int64(1), count) + + var createQuery string + require.NoError(t, store.db.WithContext(ctx). + Raw("SELECT create_table_query FROM system.tables WHERE database = currentDatabase() AND name = ?", opts.Table). + Scan(&createQuery).Error) + assert.Contains(t, createQuery, "ReplacingMergeTree") + assert.Contains(t, createQuery, "TTL") + assert.Contains(t, createQuery, "idx_extension_value") +} + +func TestHybridEnsureClickHouseExtensionTableRejectsUnsupportedInner(t *testing.T) { + hybrid := &HybridLogStore{inner: &RDBLogStore{}} + err := hybrid.EnsureClickHouseTable(context.Background(), &clickHouseExtensionTestRow{}, ClickHouseExtensionTableOptions{ + Table: "extension_events", OrderBy: "id", + }) + require.EqualError(t, err, "logstore does not support ClickHouse extension tables") +} + +func TestValidateClickHouseExtensionTableOptions(t *testing.T) { + valid := ClickHouseExtensionTableOptions{Table: "extension_events", OrderBy: "(created_at, id)"} + require.NoError(t, validateClickHouseExtensionTableOptions(valid)) + invalidName := valid + invalidName.Table = "invalid`; DROP TABLE logs" + require.ErrorContains(t, validateClickHouseExtensionTableOptions(invalidName), "invalid extension table name") + + for _, table := range []string{"logs", "mcp_tool_logs", "async_jobs", "LOGS"} { + opts := valid + opts.Table = table + require.ErrorContains(t, validateClickHouseExtensionTableOptions(opts), "reserved") + } + + missingOrderBy := valid + missingOrderBy.OrderBy = " " + require.ErrorContains(t, validateClickHouseExtensionTableOptions(missingOrderBy), "order by is required") + + clickHouseSyntax := valid + clickHouseSyntax.TTL = "toDateTime(created_at) + INTERVAL 30 DAY TO VOLUME 'cold'" + clickHouseSyntax.SkipIndexes = []string{ + "INDEX idx_lower_value lower(value) TYPE bloom_filter(0.01) GRANULARITY 1", + } + require.NoError(t, validateClickHouseExtensionTableOptions(clickHouseSyntax)) +} + // chCountRows counts logical rows visible for an id; with the connection-level // final=1 setting, ReplacingMergeTree duplicates must collapse to one. func chCountRows(t *testing.T, db *gorm.DB, table, id string) int64 { From dd5df2bb8db2a957b3b1120b65fe749948c9f0d6 Mon Sep 17 00:00:00 2001 From: Akshay Deo Date: Tue, 14 Jul 2026 22:07:29 -0700 Subject: [PATCH 02/11] Revert "add support for clickhouse for enterprise logstore tables" (#5225) --- framework/logstore/clickhouseextension.go | 84 ---------------------- framework/logstore/clickhousestore_test.go | 79 -------------------- 2 files changed, 163 deletions(-) delete mode 100644 framework/logstore/clickhouseextension.go diff --git a/framework/logstore/clickhouseextension.go b/framework/logstore/clickhouseextension.go deleted file mode 100644 index 1a6ccb7c464..00000000000 --- a/framework/logstore/clickhouseextension.go +++ /dev/null @@ -1,84 +0,0 @@ -package logstore - -import ( - "context" - "fmt" - "regexp" - "strings" -) - -var clickHouseExtensionTableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -var clickHouseReservedTableNames = map[string]struct{}{ - strings.ToLower((AsyncJob{}).TableName()): {}, - strings.ToLower((Log{}).TableName()): {}, - strings.ToLower((MCPToolLog{}).TableName()): {}, -} - -// ClickHouseExtensionTableOptions defines the schema shape for an -// extension-owned table. All DDL fragments are trusted, code-owned values and -// must never be populated from configuration or user input. -type ClickHouseExtensionTableOptions struct { - Table string - PartitionBy string - OrderBy string - TTL string - SkipIndexes []string -} - -type clickHouseSchemaStore interface { - EnsureClickHouseTable(ctx context.Context, model any, opts ClickHouseExtensionTableOptions) error -} - -var ( - _ clickHouseSchemaStore = (*ClickHouseLogStore)(nil) - _ clickHouseSchemaStore = (*HybridLogStore)(nil) -) - -// EnsureClickHouseTable creates an extension table and reconciles newly added -// model columns. It preserves the configured cluster and replication settings. -func (s *ClickHouseLogStore) EnsureClickHouseTable(ctx context.Context, model any, opts ClickHouseExtensionTableOptions) error { - if s == nil || s.RDBLogStore == nil || s.db == nil { - return fmt.Errorf("clickhouse: logstore is not initialized") - } - if err := validateClickHouseExtensionTableOptions(opts); err != nil { - return err - } - - tableOpts := chTableOpts{ - table: opts.Table, - partitionBy: opts.PartitionBy, - orderBy: opts.OrderBy, - ttl: opts.TTL, - skipIndexes: append([]string(nil), opts.SkipIndexes...), - } - if err := clickhouseCreateTable(ctx, s.db, model, tableOpts, s.cluster); err != nil { - return fmt.Errorf("clickhouse: create extension table %s: %w", opts.Table, err) - } - return clickhouseReconcileColumns(ctx, s.db, model, opts.Table, s.cluster, s.logger) -} - -// EnsureClickHouseTable delegates extension-table schema management to a -// ClickHouse logstore wrapped by hybrid object storage. -func (h *HybridLogStore) EnsureClickHouseTable(ctx context.Context, model any, opts ClickHouseExtensionTableOptions) error { - schemaStore, ok := h.inner.(clickHouseSchemaStore) - if !ok { - return fmt.Errorf("logstore does not support ClickHouse extension tables") - } - return schemaStore.EnsureClickHouseTable(ctx, model, opts) -} - -// validateClickHouseExtensionTableOptions validates identifiers and invariants -// that can be checked without attempting to parse ClickHouse SQL expressions. -func validateClickHouseExtensionTableOptions(opts ClickHouseExtensionTableOptions) error { - if !clickHouseExtensionTableNamePattern.MatchString(opts.Table) { - return fmt.Errorf("clickhouse: invalid extension table name %q", opts.Table) - } - if _, reserved := clickHouseReservedTableNames[strings.ToLower(opts.Table)]; reserved { - return fmt.Errorf("clickhouse: extension table name %q is reserved", opts.Table) - } - if strings.TrimSpace(opts.OrderBy) == "" { - return fmt.Errorf("clickhouse: extension table order by is required") - } - return nil -} diff --git a/framework/logstore/clickhousestore_test.go b/framework/logstore/clickhousestore_test.go index 32ac90f8626..b62099cf02e 100644 --- a/framework/logstore/clickhousestore_test.go +++ b/framework/logstore/clickhousestore_test.go @@ -68,85 +68,6 @@ func chTestLog(id string, ts time.Time) *Log { } } -type clickHouseExtensionTestRow struct { - ID string - Value string - CreatedAt time.Time -} - -func (clickHouseExtensionTestRow) TableName() string { return "extension_test_events" } - -func TestClickHouseEnsureExtensionTable(t *testing.T) { - store := trySetupClickHouseStore(t) - ctx := context.Background() - require.Error(t, store.EnsureClickHouseTable(ctx, &clickHouseExtensionTestRow{}, ClickHouseExtensionTableOptions{ - Table: "invalid`; DROP TABLE logs", OrderBy: "id", - })) - require.NoError(t, store.db.Exec("DROP TABLE IF EXISTS extension_test_events").Error) - t.Cleanup(func() { - _ = store.db.Exec("DROP TABLE IF EXISTS extension_test_events").Error - }) - - opts := ClickHouseExtensionTableOptions{ - Table: "extension_test_events", - PartitionBy: "toYYYYMM(created_at)", - OrderBy: "(created_at, id)", - TTL: "toDateTime(created_at) + INTERVAL 30 DAY", - SkipIndexes: []string{"INDEX idx_extension_value lower(value) TYPE bloom_filter(0.01) GRANULARITY 1"}, - } - require.NoError(t, store.EnsureClickHouseTable(ctx, &clickHouseExtensionTestRow{}, opts)) - hybrid := &HybridLogStore{inner: store} - require.NoError(t, hybrid.EnsureClickHouseTable(ctx, &clickHouseExtensionTestRow{}, opts)) - - row := clickHouseExtensionTestRow{ID: "event-1", Value: "matched", CreatedAt: time.Now().UTC()} - require.NoError(t, store.db.WithContext(ctx).Create(&row).Error) - - var count int64 - require.NoError(t, store.db.WithContext(ctx).Model(&clickHouseExtensionTestRow{}).Where("value = ?", "matched").Count(&count).Error) - assert.Equal(t, int64(1), count) - - var createQuery string - require.NoError(t, store.db.WithContext(ctx). - Raw("SELECT create_table_query FROM system.tables WHERE database = currentDatabase() AND name = ?", opts.Table). - Scan(&createQuery).Error) - assert.Contains(t, createQuery, "ReplacingMergeTree") - assert.Contains(t, createQuery, "TTL") - assert.Contains(t, createQuery, "idx_extension_value") -} - -func TestHybridEnsureClickHouseExtensionTableRejectsUnsupportedInner(t *testing.T) { - hybrid := &HybridLogStore{inner: &RDBLogStore{}} - err := hybrid.EnsureClickHouseTable(context.Background(), &clickHouseExtensionTestRow{}, ClickHouseExtensionTableOptions{ - Table: "extension_events", OrderBy: "id", - }) - require.EqualError(t, err, "logstore does not support ClickHouse extension tables") -} - -func TestValidateClickHouseExtensionTableOptions(t *testing.T) { - valid := ClickHouseExtensionTableOptions{Table: "extension_events", OrderBy: "(created_at, id)"} - require.NoError(t, validateClickHouseExtensionTableOptions(valid)) - invalidName := valid - invalidName.Table = "invalid`; DROP TABLE logs" - require.ErrorContains(t, validateClickHouseExtensionTableOptions(invalidName), "invalid extension table name") - - for _, table := range []string{"logs", "mcp_tool_logs", "async_jobs", "LOGS"} { - opts := valid - opts.Table = table - require.ErrorContains(t, validateClickHouseExtensionTableOptions(opts), "reserved") - } - - missingOrderBy := valid - missingOrderBy.OrderBy = " " - require.ErrorContains(t, validateClickHouseExtensionTableOptions(missingOrderBy), "order by is required") - - clickHouseSyntax := valid - clickHouseSyntax.TTL = "toDateTime(created_at) + INTERVAL 30 DAY TO VOLUME 'cold'" - clickHouseSyntax.SkipIndexes = []string{ - "INDEX idx_lower_value lower(value) TYPE bloom_filter(0.01) GRANULARITY 1", - } - require.NoError(t, validateClickHouseExtensionTableOptions(clickHouseSyntax)) -} - // chCountRows counts logical rows visible for an id; with the connection-level // final=1 setting, ReplacingMergeTree duplicates must collapse to one. func chCountRows(t *testing.T, db *gorm.DB, table, id string) int64 { From 1056a5c978a4a7f60761bf3960b0564f6681e211 Mon Sep 17 00:00:00 2001 From: Shaik-Sirajuddin Date: Sat, 4 Jul 2026 17:51:13 +0530 Subject: [PATCH 03/11] fix: address 3 Anthropic-specific schema-compatibility bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #4780: tool_search_tool_result was silently dropped on /v1/responses (streaming, non-streaming ingest, and Anthropic egress/replay). Calls are keyed by tool_use ID rather than a single slot, since Claude can emit multiple tool_search calls before any of their results arrive; caller provenance (code-execution-spawned searches) round-trips correctly. - #3233: tools[].strict is now dropped only for Anthropic-family providers that don't support it (Vertex), and kept for native Anthropic. - #3802: added a regression test confirming reasoning_content survives assistant tool-call turns with extended thinking (already fixed by #3584). - web_search_call: same bug class as #4780 — missing from response.completed and vulnerable to the same multi-call concurrency bug; fixed identically. --- .../anthropic/passthrough_usage_test.go | 6 +- .../anthropic/reasoningtoolcall_test.go | 90 ++ core/providers/anthropic/responses.go | 780 ++++++++++++------ .../anthropic/toolsearch_roundtrip_test.go | 225 +++++ core/providers/anthropic/toolsearch_test.go | 460 ++++------- core/providers/anthropic/types.go | 2 +- core/providers/anthropic/utils_test.go | 39 + .../anthropic/validatechattools_test.go | 16 +- .../anthropic/websearch_outputitems_test.go | 274 ++++++ core/schemas/responses.go | 21 +- 10 files changed, 1347 insertions(+), 566 deletions(-) create mode 100644 core/providers/anthropic/reasoningtoolcall_test.go create mode 100644 core/providers/anthropic/toolsearch_roundtrip_test.go create mode 100644 core/providers/anthropic/websearch_outputitems_test.go diff --git a/core/providers/anthropic/passthrough_usage_test.go b/core/providers/anthropic/passthrough_usage_test.go index 065cc33f86a..5a4b55097d2 100644 --- a/core/providers/anthropic/passthrough_usage_test.go +++ b/core/providers/anthropic/passthrough_usage_test.go @@ -74,9 +74,9 @@ func TestExtractAnthropicPassthroughUsage(t *testing.T) { }, }, { - name: "messages zero usage -> nil", - path: "/v1/messages", - body: `{"usage":{"input_tokens":0,"output_tokens":0}}`, + name: "messages zero usage -> nil", + path: "/v1/messages", + body: `{"usage":{"input_tokens":0,"output_tokens":0}}`, check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { if u != nil { t.Fatalf("expected nil, got %+v", u) diff --git a/core/providers/anthropic/reasoningtoolcall_test.go b/core/providers/anthropic/reasoningtoolcall_test.go new file mode 100644 index 00000000000..3fd64a3200b --- /dev/null +++ b/core/providers/anthropic/reasoningtoolcall_test.go @@ -0,0 +1,90 @@ +package anthropic + +import ( + "testing" + "time" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" +) + +// rawExtendedThinkingToolCallResponse mirrors issue #3802: an Anthropic Messages API +// response with extended thinking enabled, where the assistant turn contains both a +// thinking block and a tool_use block. The bug report claimed reasoning_content is +// dropped when this turn is routed onward to a custom Anthropic-base provider (Kimi) +// that requires it to be echoed back on the next turn — reproduced here by chaining +// Bifrost's real Anthropic ingest conversion into the real Responses->Chat bridge that +// such a custom OpenAI-compatible provider would consume. +const rawExtendedThinkingToolCallResponse = `{ + "model": "claude-opus-4-8", + "id": "msg_01ExtendedThinking", + "type": "message", + "role": "assistant", + "content": [ + { "type": "thinking", "thinking": "I should look up the current weather before answering.", "signature": "sig_abc123" }, + { "type": "tool_use", "id": "toolu_01Weather", "name": "get_weather", "input": {"location": "SF"} } + ], + "stop_reason": "tool_use", + "usage": { "input_tokens": 120, "output_tokens": 45 } +}` + +// TestExtendedThinkingToolCallTurn_ReasoningSurvivesResponsesToChatBridge is the +// end-to-end regression test for #3802. It reproduces the full reported pipeline: +// Anthropic response (thinking + tool_use in the same turn) -> Bifrost's Responses-shaped +// intermediate (ToBifrostResponsesResponse) -> the Responses->Chat fallback bridge +// (schemas.ToChatMessages) that a custom Anthropic-base provider like Kimi would receive +// its history through. If reasoning is dropped anywhere in this chain, the assistant +// tool-call message that reaches Kimi on the next turn would be missing reasoning_content. +func TestExtendedThinkingToolCallTurn_ReasoningSurvivesResponsesToChatBridge(t *testing.T) { + var resp AnthropicMessageResponse + if err := sonic.Unmarshal([]byte(rawExtendedThinkingToolCallResponse), &resp); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + + ctx := schemas.NewBifrostContext(nil, time.Time{}) + + bifrostResp := resp.ToBifrostResponsesResponse(ctx) + if bifrostResp == nil { + t.Fatal("ToBifrostResponsesResponse returned nil") + } + + var sawReasoning, sawFunctionCall bool + for _, out := range bifrostResp.Output { + if out.Type == nil { + continue + } + switch *out.Type { + case schemas.ResponsesMessageTypeReasoning: + sawReasoning = true + case schemas.ResponsesMessageTypeFunctionCall: + sawFunctionCall = true + } + } + if !sawReasoning { + t.Fatal("expected a reasoning output item from the Anthropic thinking block, got none") + } + if !sawFunctionCall { + t.Fatal("expected a function_call output item from the Anthropic tool_use block, got none") + } + + // Feed the Responses-shaped output through the same Responses->Chat bridge a + // custom OpenAI-compatible provider (Kimi) would use to build its wire history. + chatMessages := schemas.ToChatMessages(bifrostResp.Output) + + var found bool + for _, cm := range chatMessages { + if cm.ChatAssistantMessage == nil || len(cm.ChatAssistantMessage.ToolCalls) == 0 { + continue + } + found = true + if cm.ChatAssistantMessage.Reasoning == nil { + t.Fatal("reasoning_content dropped on the assistant tool-call turn (#3802) — Reasoning is nil") + } + if *cm.ChatAssistantMessage.Reasoning == "" { + t.Fatal("reasoning_content dropped on the assistant tool-call turn (#3802) — Reasoning is empty") + } + } + if !found { + t.Fatal("expected an assistant message with tool calls in the bridged chat messages") + } +} diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index 5ac422919a2..b6ac767f50d 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -25,12 +25,16 @@ type AnthropicResponsesStreamState struct { // Computer tool accumulation ComputerToolID *string - // Web search tool accumulation (minimal fields) - WebSearchToolID *string // Tool ID of active web search - WebSearchOutputIndex *int // Output index for this search - WebSearchResult *AnthropicContentBlock // Result block when it arrives - WebSearchQuery *string // Query captured from the (pre-populated) server_tool_use input - WebSearchCaller *AnthropicToolCaller // Programmatic-tool-calling caller, if the search was spawned from code execution + // Web search tool accumulation. Keyed by tool_use ID (not a single slot) + // because a single assistant turn can contain multiple concurrent web_search + // calls whose call and result content blocks are NOT necessarily interleaved + // 1:1 — the same concurrency hazard fixed for tool_search (see #4780 and + // ToolSearchOutputIndices below): a single-slot design silently drops every + // result except the last, leaving earlier calls stuck "in_progress" forever. + WebSearchPending map[string]*pendingWebSearch // tool_use ID -> pending call (query/caller/output index) + WebSearchResults map[string]*AnthropicContentBlock // tool_use ID -> result block once it arrives + WebSearchIndexToToolUseID map[int]string // content index of the result block -> tool_use ID + WebSearchCallIndices map[int]string // content index of the call block -> tool_use ID (for the input_json fallback-capture path on content_block_stop) // Web fetch tool accumulation WebFetchToolID *string // Tool ID of active web fetch @@ -38,17 +42,27 @@ type AnthropicResponsesStreamState struct { WebFetchURL *string // URL captured from the server_tool_use input WebFetchResult *AnthropicContentBlock // Result block when it arrives + // Tool search tool accumulation (tool_search_tool_bm25 / tool_search_tool_regex). + // Keyed by tool_use ID (not a single slot) because a single assistant turn can + // contain multiple tool_search calls whose call and result content blocks are + // NOT necessarily interleaved 1:1 — confirmed live: Claude can emit several + // tool_search_tool_bm25 calls back-to-back before any of their + // tool_search_tool_result blocks arrive. A single-slot design (like the + // simpler web_fetch/advisor accumulation above) silently drops every result + // except the last, leaving earlier calls stuck "in_progress" forever. + ToolSearchOutputIndices map[string]int // tool_use ID -> output index + ToolSearchResults map[string]*AnthropicContentBlock // tool_use ID -> result block once it arrives + ToolSearchIndexToToolUseID map[int]string // content index of the result block -> tool_use ID (content_block_stop carries no payload, only the index) + ToolSearchToolNames map[string]*string // tool_use ID -> sub-tool name (tool_search_tool_bm25/_regex); needed at finalize since the completed item is rebuilt fresh + ToolSearchInputs map[string]string // tool_use ID -> verbatim server_tool_use input JSON, for Anthropic-egress replay + ToolSearchCallIndices map[int]string // content index of the call block -> tool_use ID (for capturing input_json on content_block_stop) + ToolSearchCallers map[string]*AnthropicToolCaller // tool_use ID -> caller (set when the search was spawned from inside code execution), mirrors WebSearchPending.Caller + // Advisor tool accumulation AdvisorToolID *string // Tool ID of active advisor call AdvisorOutputIndex *int // Output index for this advisor call AdvisorResult *AnthropicContentBlock // advisor_tool_result block when it arrives - // Tool search (server-side tool_search) accumulation - ToolSearchToolID *string // server_tool_use ID of active tool_search - ToolSearchToolName *string // tool name (tool_search_tool_regex|bm25) — kept so the done item matches the added item - ToolSearchOutputIndex *int // Output index for this tool_search call - ToolSearchResult *AnthropicContentBlock // tool_search_tool_result block (carries tool_references) when it arrives - // Code execution tool accumulation (bash / text_editor / python sub-tools) CodeExecToolID *string // server_tool_use id of the active code-execution call CodeExecToolName *string // sub-tool name (bash_code_execution, etc.) @@ -83,6 +97,14 @@ type AnthropicResponsesStreamState struct { SeenRealToolCall bool // True when any non-SO tool_use/server_tool_use/mcp_tool_use content block was started } +// pendingWebSearch tracks one in-flight web_search server_tool_use call, keyed by +// tool_use ID in AnthropicResponsesStreamState.WebSearchPending. +type pendingWebSearch struct { + OutputIndex int + Query *string + Caller *AnthropicToolCaller +} + type anthropicInputJSONBufferKind string const ( @@ -355,22 +377,24 @@ func AcquireAnthropicResponsesStreamState() *AnthropicResponsesStreamState { } // Reset other fields state.ComputerToolID = nil - state.WebSearchToolID = nil - state.WebSearchOutputIndex = nil - state.WebSearchResult = nil - state.WebSearchQuery = nil - state.WebSearchCaller = nil + state.WebSearchPending = nil + state.WebSearchResults = nil + state.WebSearchIndexToToolUseID = nil + state.WebSearchCallIndices = nil state.WebFetchToolID = nil state.WebFetchOutputIndex = nil state.WebFetchURL = nil state.WebFetchResult = nil + state.ToolSearchOutputIndices = nil + state.ToolSearchResults = nil + state.ToolSearchIndexToToolUseID = nil + state.ToolSearchToolNames = nil + state.ToolSearchInputs = nil + state.ToolSearchCallIndices = nil + state.ToolSearchCallers = nil state.AdvisorToolID = nil state.AdvisorOutputIndex = nil state.AdvisorResult = nil - state.ToolSearchToolID = nil - state.ToolSearchToolName = nil - state.ToolSearchOutputIndex = nil - state.ToolSearchResult = nil state.CodeExecToolID = nil state.CodeExecToolName = nil state.CodeExecOutputIndex = nil @@ -405,22 +429,24 @@ func (state *AnthropicResponsesStreamState) flush() { state.InputJSONBuffers = nil state.InputJSONPurposes = nil state.ComputerToolID = nil - state.WebSearchToolID = nil - state.WebSearchOutputIndex = nil - state.WebSearchResult = nil - state.WebSearchQuery = nil - state.WebSearchCaller = nil + state.WebSearchPending = nil + state.WebSearchResults = nil + state.WebSearchIndexToToolUseID = nil + state.WebSearchCallIndices = nil state.WebFetchToolID = nil state.WebFetchOutputIndex = nil state.WebFetchURL = nil state.WebFetchResult = nil + state.ToolSearchOutputIndices = nil + state.ToolSearchResults = nil + state.ToolSearchIndexToToolUseID = nil + state.ToolSearchToolNames = nil + state.ToolSearchInputs = nil + state.ToolSearchCallIndices = nil + state.ToolSearchCallers = nil state.AdvisorToolID = nil state.AdvisorOutputIndex = nil state.AdvisorResult = nil - state.ToolSearchToolID = nil - state.ToolSearchToolName = nil - state.ToolSearchOutputIndex = nil - state.ToolSearchResult = nil state.CodeExecToolID = nil state.CodeExecToolName = nil state.CodeExecOutputIndex = nil @@ -615,30 +641,38 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, // input, not streamed). Keep a buffer anyway so unusual input_json_delta // events are swallowed and can be used as a fallback on block stop. state.beginInputJSONBuffer(chunk.Index, anthropicInputJSONBufferWebSearch) - state.WebSearchToolID = chunk.ContentBlock.ID - state.WebSearchOutputIndex = schemas.Ptr(outputIndex) - state.WebSearchQuery = nil + + pending := &pendingWebSearch{OutputIndex: outputIndex, Caller: chunk.ContentBlock.Caller} if q := providerUtils.GetJSONField(chunk.ContentBlock.Input, "query"); q.Exists() && q.Type == gjson.String { - state.WebSearchQuery = schemas.Ptr(q.Str) + pending.Query = schemas.Ptr(q.Str) + } + if state.WebSearchPending == nil { + state.WebSearchPending = make(map[string]*pendingWebSearch) + } + state.WebSearchPending[*chunk.ContentBlock.ID] = pending + if chunk.Index != nil { + if state.WebSearchCallIndices == nil { + state.WebSearchCallIndices = make(map[int]string) + } + state.WebSearchCallIndices[*chunk.Index] = *chunk.ContentBlock.ID } - state.WebSearchCaller = chunk.ContentBlock.Caller // Store item ID state.ItemIDs[outputIndex] = *chunk.ContentBlock.ID wsAction := &schemas.ResponsesWebSearchToolCallAction{Type: "search"} - if state.WebSearchQuery != nil { - wsAction.Query = state.WebSearchQuery - wsAction.Queries = []string{*state.WebSearchQuery} + if pending.Query != nil { + wsAction.Query = pending.Query + wsAction.Queries = []string{*pending.Query} } toolMsg := &schemas.ResponsesToolMessage{ CallID: chunk.ContentBlock.ID, Action: &schemas.ResponsesToolMessageActionStruct{ResponsesWebSearchToolCallAction: wsAction}, } - if state.WebSearchCaller != nil { + if pending.Caller != nil { toolMsg.Caller = &schemas.ResponsesToolCaller{ - Type: string(state.WebSearchCaller.Type), - ToolID: state.WebSearchCaller.ToolID, + Type: string(pending.Caller.Type), + ToolID: pending.Caller.ToolID, } } @@ -650,6 +684,15 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, ResponsesToolMessage: toolMsg, } + // Persist into OutputItems so response.completed includes the call + // even if the result never arrives (mirrors advisor/code_exec/tool_search + // handling; web_search itself was previously missing this despite + // sibling comments claiming otherwise). + clonedItem := *item + clonedToolMsg := *item.ResponsesToolMessage + clonedItem.ResponsesToolMessage = &clonedToolMsg + state.OutputItems[outputIndex] = &clonedItem + var responses []*schemas.BifrostResponsesStreamResponse // Emit output_item.added @@ -689,21 +732,29 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, state.ContentIndexToBlockType[*chunk.Index] = AnthropicContentBlockTypeWebSearchToolResult } - // Check if this matches our active web search - if state.WebSearchToolID != nil && *state.WebSearchToolID == *chunk.ContentBlock.ToolUseID { + toolUseID := *chunk.ContentBlock.ToolUseID + // Check if this matches one of our pending web searches + if pending, ok := state.WebSearchPending[toolUseID]; ok { // Store the result block (arrives complete with all sources) - state.WebSearchResult = chunk.ContentBlock + if state.WebSearchResults == nil { + state.WebSearchResults = make(map[string]*AnthropicContentBlock) + } + state.WebSearchResults[toolUseID] = chunk.ContentBlock if chunk.Index != nil { delete(state.ContentIndexToBlockType, *chunk.Index) + if state.WebSearchIndexToToolUseID == nil { + state.WebSearchIndexToToolUseID = make(map[int]string) + } + state.WebSearchIndexToToolUseID[*chunk.Index] = toolUseID } // Emit web_search_call.completed return []*schemas.BifrostResponsesStreamResponse{{ Type: schemas.ResponsesStreamResponseTypeWebSearchCallCompleted, SequenceNumber: sequenceNumber, - OutputIndex: state.WebSearchOutputIndex, + OutputIndex: schemas.Ptr(pending.OutputIndex), ItemID: chunk.ContentBlock.ToolUseID, }}, nil, false } @@ -712,68 +763,6 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, return nil, nil, false } - // Handle tool_search server_tool_use (server-side tool_search query block). - // Anthropic runs the search server-side and returns a tool_search_tool_result - // carrying tool_references to the discovered (deferred) tools; the model then - // emits a normal tool_use to call one. Mirrors the web_search query path. - if chunk.ContentBlock.Type == AnthropicContentBlockTypeServerToolUse && - chunk.ContentBlock.Name != nil && - (*chunk.ContentBlock.Name == string(AnthropicToolNameToolSearchRegex) || - *chunk.ContentBlock.Name == string(AnthropicToolNameToolSearchBM25)) && - chunk.ContentBlock.ID != nil { - - state.SeenRealToolCall = true - // Suppress the query input_json deltas via the shared input-buffer path; - // the search is run server-side, so we only care about the result block. - state.beginInputJSONBuffer(chunk.Index, anthropicInputJSONBufferToolSearch) - state.ToolSearchToolID = chunk.ContentBlock.ID - state.ToolSearchToolName = chunk.ContentBlock.Name - state.ToolSearchOutputIndex = schemas.Ptr(outputIndex) - state.ItemIDs[outputIndex] = *chunk.ContentBlock.ID - // Mark block type so content_block_stop doesn't emit a generic message done - if chunk.Index != nil { - state.ContentIndexToBlockType[*chunk.Index] = AnthropicContentBlockTypeServerToolUse - state.TextContentIndices[*chunk.Index] = false - } - - // Emit output_item.added for the tool_search_call (completed at result block-stop) - item := &schemas.ResponsesMessage{ - ID: chunk.ContentBlock.ID, - Type: schemas.Ptr(schemas.ResponsesMessageTypeToolSearchCall), - Status: schemas.Ptr("in_progress"), - ResponsesToolMessage: &schemas.ResponsesToolMessage{ - CallID: chunk.ContentBlock.ID, - Name: chunk.ContentBlock.Name, - }, - } - return []*schemas.BifrostResponsesStreamResponse{{ - Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, - SequenceNumber: sequenceNumber, - OutputIndex: schemas.Ptr(outputIndex), - ContentIndex: chunk.Index, - Item: item, - }}, nil, false - } - - // Handle tool_search_tool_result block (the discovered tool_references arrive). - // Store it; the tool_search_call output_item.done (carrying tool_references) is - // emitted on this block's content_block_stop. Mirrors web_search_tool_result. - if chunk.ContentBlock.Type == AnthropicContentBlockTypeToolSearchToolResult && - chunk.ContentBlock.ToolUseID != nil { - - if chunk.Index != nil { - state.ContentIndexToBlockType[*chunk.Index] = AnthropicContentBlockTypeToolSearchToolResult - } - - if state.ToolSearchToolID != nil && *state.ToolSearchToolID == *chunk.ContentBlock.ToolUseID { - // Store the result block (arrives complete with all tool_references) - state.ToolSearchResult = chunk.ContentBlock - } - - // Defer the done to content_block_stop (don't drop the block) - return nil, nil, false - } - // Handle web_fetch server_tool_use (fetch block) if chunk.ContentBlock.Type == AnthropicContentBlockTypeServerToolUse && chunk.ContentBlock.Name != nil && @@ -982,6 +971,94 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, return nil, nil, false } + // Handle tool_search server_tool_use (the call — Bifrost advertises + // ToolSearch: true, but the result was previously dropped; see #4780). + if chunk.ContentBlock.Type == AnthropicContentBlockTypeServerToolUse && + chunk.ContentBlock.Name != nil && + isAnthropicToolSearchToolName(*chunk.ContentBlock.Name) && + chunk.ContentBlock.ID != nil { + + state.SeenRealToolCall = true + state.beginInputJSONBuffer(chunk.Index, anthropicInputJSONBufferToolSearch) + if state.ToolSearchOutputIndices == nil { + state.ToolSearchOutputIndices = make(map[string]int) + } + state.ToolSearchOutputIndices[*chunk.ContentBlock.ID] = outputIndex + state.ItemIDs[outputIndex] = *chunk.ContentBlock.ID + if state.ToolSearchToolNames == nil { + state.ToolSearchToolNames = make(map[string]*string) + } + state.ToolSearchToolNames[*chunk.ContentBlock.ID] = chunk.ContentBlock.Name + if chunk.ContentBlock.Caller != nil { + if state.ToolSearchCallers == nil { + state.ToolSearchCallers = make(map[string]*AnthropicToolCaller) + } + state.ToolSearchCallers[*chunk.ContentBlock.ID] = chunk.ContentBlock.Caller + } + if chunk.Index != nil { + if state.ToolSearchCallIndices == nil { + state.ToolSearchCallIndices = make(map[int]string) + } + state.ToolSearchCallIndices[*chunk.Index] = *chunk.ContentBlock.ID + } + + toolMsg := &schemas.ResponsesToolMessage{ + CallID: chunk.ContentBlock.ID, + Name: chunk.ContentBlock.Name, + } + if chunk.ContentBlock.Caller != nil { + toolMsg.Caller = &schemas.ResponsesToolCaller{ + Type: string(chunk.ContentBlock.Caller.Type), + ToolID: chunk.ContentBlock.Caller.ToolID, + } + } + item := &schemas.ResponsesMessage{ + ID: chunk.ContentBlock.ID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeAnthropicToolSearchCall), + Status: schemas.Ptr("in_progress"), + ResponsesToolMessage: toolMsg, + } + + // Persist into OutputItems so response.completed includes the call even + // if the result never arrives (mirrors function_call/advisor handling). + clonedItem := *item + clonedToolMsg := *item.ResponsesToolMessage + clonedItem.ResponsesToolMessage = &clonedToolMsg + state.OutputItems[outputIndex] = &clonedItem + + return []*schemas.BifrostResponsesStreamResponse{ + { + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: item, + }, + }, nil, false + } + + // Handle tool_search_tool_result block (arrives complete with all tool_references). + // Store it; the output_item.done is emitted on its content_block_stop. + if chunk.ContentBlock.Type == AnthropicContentBlockTypeToolSearchToolResult && + chunk.ContentBlock.ToolUseID != nil { + + toolUseID := *chunk.ContentBlock.ToolUseID + if _, ok := state.ToolSearchOutputIndices[toolUseID]; ok { + if chunk.Index != nil { + state.ContentIndexToBlockType[*chunk.Index] = AnthropicContentBlockTypeToolSearchToolResult + if state.ToolSearchIndexToToolUseID == nil { + state.ToolSearchIndexToToolUseID = make(map[int]string) + } + state.ToolSearchIndexToToolUseID[*chunk.Index] = toolUseID + } + if state.ToolSearchResults == nil { + state.ToolSearchResults = make(map[string]*AnthropicContentBlock) + } + state.ToolSearchResults[toolUseID] = chunk.ContentBlock + } + return nil, nil, false + } + switch chunk.ContentBlock.Type { case AnthropicContentBlockTypeCompaction: // Compaction block - track it but don't emit yet (summary arrives in delta) @@ -1518,12 +1595,17 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, }, nil, false case anthropicInputJSONBufferWebSearch: - if state.WebSearchToolID == nil { + if chunk.Index == nil { + return nil, nil, false + } + toolUseID, ok := state.WebSearchCallIndices[*chunk.Index] + if !ok { return nil, nil, false } - if state.WebSearchQuery == nil && inputJSON != "" { + pending := state.WebSearchPending[toolUseID] + if pending != nil && pending.Query == nil && inputJSON != "" { if q := providerUtils.GetJSONField([]byte(inputJSON), "query"); q.Exists() && q.Type == gjson.String { - state.WebSearchQuery = schemas.Ptr(q.Str) + pending.Query = schemas.Ptr(q.Str) } } return nil, nil, false @@ -1542,6 +1624,17 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, case anthropicInputJSONBufferAdvisor: return nil, nil, false + case anthropicInputJSONBufferToolSearch: + if chunk.Index != nil && inputJSON != "" { + if toolUseID, ok := state.ToolSearchCallIndices[*chunk.Index]; ok { + if state.ToolSearchInputs == nil { + state.ToolSearchInputs = make(map[string]string) + } + state.ToolSearchInputs[toolUseID] = inputJSON + } + } + return nil, nil, false + case anthropicInputJSONBufferCodeExec: if state.CodeExecToolID == nil { return nil, nil, false @@ -1594,93 +1687,204 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, }, ) return responses, nil, false - - case anthropicInputJSONBufferToolSearch: - // tool_search server_tool_use query block ended — the search runs - // server-side, so just wait for the tool_search_tool_result block. - return nil, nil, false } } - // Check if this is the end of a web_search_tool_result block - if state.WebSearchResult != nil && state.WebSearchToolID != nil { + // Check if this is the end of a web_search_tool_result block — looked up by + // content index (not a single slot), since a turn can contain multiple + // concurrent web_search calls whose results don't necessarily arrive in a + // strict per-call interleaved order (same hazard as tool_search, #4780). + if chunk.Index != nil { + if toolUseID, ok := state.WebSearchIndexToToolUseID[*chunk.Index]; ok { + pending := state.WebSearchPending[toolUseID] + result := state.WebSearchResults[toolUseID] + + // Use the query captured from the server_tool_use input at block start + var query string + var queries []string + if pending != nil && pending.Query != nil { + query = *pending.Query + queries = []string{query} + } + + // Extract sources from the result block + var sources []schemas.ResponsesWebSearchToolCallActionSearchSource + if result != nil && result.Content != nil && len(result.Content.ContentBlocks) > 0 { + for _, resultBlock := range result.Content.ContentBlocks { + if resultBlock.Type == AnthropicContentBlockTypeWebSearchResult && resultBlock.URL != nil { + sources = append(sources, schemas.ResponsesWebSearchToolCallActionSearchSource{ + Type: "url", + URL: *resultBlock.URL, + Title: resultBlock.Title, + EncryptedContent: resultBlock.EncryptedContent, + PageAge: resultBlock.PageAge, + }) + } + } + } - // Use the query captured from the server_tool_use input at block start - var query string - var queries []string - if state.WebSearchQuery != nil { - query = *state.WebSearchQuery - queries = []string{query} - } + // Create complete web_search_call item with action including query and sources + statusCompleted := "completed" + action := &schemas.ResponsesWebSearchToolCallAction{ + Type: "search", + Sources: sources, + } + // Only set query fields if query is not empty + if query != "" { + action.Query = &query + action.Queries = queries + } - // Extract sources from the result block - var sources []schemas.ResponsesWebSearchToolCallActionSearchSource - if state.WebSearchResult.Content != nil && len(state.WebSearchResult.Content.ContentBlocks) > 0 { - for _, resultBlock := range state.WebSearchResult.Content.ContentBlocks { - if resultBlock.Type == AnthropicContentBlockTypeWebSearchResult && resultBlock.URL != nil { - sources = append(sources, schemas.ResponsesWebSearchToolCallActionSearchSource{ - Type: "url", - URL: *resultBlock.URL, - Title: resultBlock.Title, - EncryptedContent: resultBlock.EncryptedContent, - PageAge: resultBlock.PageAge, - }) + callIDCopy := toolUseID + toolMsg := &schemas.ResponsesToolMessage{ + CallID: &callIDCopy, + Action: &schemas.ResponsesToolMessageActionStruct{ResponsesWebSearchToolCallAction: action}, + } + if pending != nil && pending.Caller != nil { + toolMsg.Caller = &schemas.ResponsesToolCaller{ + Type: string(pending.Caller.Type), + ToolID: pending.Caller.ToolID, + } + } + item := &schemas.ResponsesMessage{ + ID: &callIDCopy, + Type: schemas.Ptr(schemas.ResponsesMessageTypeWebSearchCall), + Status: &statusCompleted, + ResponsesToolMessage: toolMsg, + } + + var outputIdx *int + if pending != nil { + outputIdx = schemas.Ptr(pending.OutputIndex) + // Persist the completed item into OutputItems so response.completed + // reflects the final status/action instead of the stale in_progress + // item stored at call-start (see #4780 for the original bug class). + cloned := *item + clonedToolMsg := *item.ResponsesToolMessage + cloned.ResponsesToolMessage = &clonedToolMsg + state.OutputItems[pending.OutputIndex] = &cloned + } + + // Clear this call's web search state + delete(state.WebSearchPending, toolUseID) + delete(state.WebSearchResults, toolUseID) + delete(state.WebSearchIndexToToolUseID, *chunk.Index) + delete(state.ContentIndexToBlockType, *chunk.Index) + for idx, id := range state.WebSearchCallIndices { + if id == toolUseID { + delete(state.WebSearchCallIndices, idx) + break } } - } - // Create complete web_search_call item with action including query and sources - statusCompleted := "completed" - action := &schemas.ResponsesWebSearchToolCallAction{ - Type: "search", - Sources: sources, - } - // Only set query fields if query is not empty - if query != "" { - action.Query = &query - action.Queries = queries + // Return output_item.done for the web_search_call (not the result block) + return []*schemas.BifrostResponsesStreamResponse{ + { + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber, + OutputIndex: outputIdx, + ContentIndex: chunk.Index, + Item: item, + }, + }, nil, false } + } - toolMsg := &schemas.ResponsesToolMessage{ - CallID: state.WebSearchToolID, - Action: &schemas.ResponsesToolMessageActionStruct{ResponsesWebSearchToolCallAction: action}, - } - if state.WebSearchCaller != nil { - toolMsg.Caller = &schemas.ResponsesToolCaller{ - Type: string(state.WebSearchCaller.Type), - ToolID: state.WebSearchCaller.ToolID, + // End of a tool_search_tool_result block — emit the tool_search_call done with + // the discovered tool references surfaced as the call's output (previously + // dropped entirely; see #4780). Looked up by content index (not a single + // slot) since a turn can contain multiple tool_search calls whose results + // don't necessarily arrive in a strict per-call interleaved order. + if chunk.Index != nil { + if toolUseID, ok := state.ToolSearchIndexToToolUseID[*chunk.Index]; ok { + result := state.ToolSearchResults[toolUseID] + outputIndexForCall, hasOutputIndex := state.ToolSearchOutputIndices[toolUseID] + + var toolNames []string + if result != nil && result.ToolReferences != nil { + for _, ref := range result.ToolReferences { + if ref.ToolName != nil { + toolNames = append(toolNames, *ref.ToolName) + } else if ref.Name != nil { + toolNames = append(toolNames, *ref.Name) + } + } + } + if toolNames == nil { + toolNames = []string{} + } + outputBytes, marshalErr := sonic.Marshal(toolNames) + outputStr := "[]" + if marshalErr == nil { + outputStr = string(outputBytes) } - } - item := &schemas.ResponsesMessage{ - ID: state.WebSearchToolID, - Type: schemas.Ptr(schemas.ResponsesMessageTypeWebSearchCall), - Status: &statusCompleted, - ResponsesToolMessage: toolMsg, - } - - outputIdx := state.WebSearchOutputIndex - - // Clear all web search state - state.WebSearchToolID = nil - state.WebSearchOutputIndex = nil - state.WebSearchResult = nil - state.WebSearchQuery = nil - state.WebSearchCaller = nil - if chunk.Index != nil { + statusCompleted := "completed" + callIDCopy := toolUseID + toolMsg := &schemas.ResponsesToolMessage{ + CallID: &callIDCopy, + Name: state.ToolSearchToolNames[toolUseID], + Output: &schemas.ResponsesToolMessageOutputStruct{ + ResponsesToolCallOutputStr: &outputStr, + }, + } + // Preserve the verbatim call input (e.g. the search query) so a later + // turn that replays this item back to Anthropic (egress) can rebuild + // an equivalent server_tool_use block — reuses the same generic + // Arguments field function_call uses, no new schema type needed. + if input, ok := state.ToolSearchInputs[toolUseID]; ok { + toolMsg.Arguments = &input + } + if caller := state.ToolSearchCallers[toolUseID]; caller != nil { + toolMsg.Caller = &schemas.ResponsesToolCaller{ + Type: string(caller.Type), + ToolID: caller.ToolID, + } + } + item := &schemas.ResponsesMessage{ + ID: &callIDCopy, + Type: schemas.Ptr(schemas.ResponsesMessageTypeAnthropicToolSearchCall), + Status: &statusCompleted, + ResponsesToolMessage: toolMsg, + } + + var outputIdx *int + if hasOutputIndex { + outputIdx = &outputIndexForCall + // Persist the completed item into OutputItems so response.completed + // includes it (mirrors advisor/code-exec handling) — previously missing, + // so tool_search results never surfaced in the final response even + // though the individual stream events fired correctly. + cloned := *item + clonedToolMsg := *item.ResponsesToolMessage + cloned.ResponsesToolMessage = &clonedToolMsg + state.OutputItems[outputIndexForCall] = &cloned + } + + delete(state.ToolSearchOutputIndices, toolUseID) + delete(state.ToolSearchResults, toolUseID) + delete(state.ToolSearchIndexToToolUseID, *chunk.Index) + delete(state.ToolSearchToolNames, toolUseID) + delete(state.ToolSearchInputs, toolUseID) + delete(state.ToolSearchCallers, toolUseID) delete(state.ContentIndexToBlockType, *chunk.Index) - } + for idx, id := range state.ToolSearchCallIndices { + if id == toolUseID { + delete(state.ToolSearchCallIndices, idx) + break + } + } - // Return output_item.done for the web_search_call (not the result block) - return []*schemas.BifrostResponsesStreamResponse{ - { - Type: schemas.ResponsesStreamResponseTypeOutputItemDone, - SequenceNumber: sequenceNumber, - OutputIndex: outputIdx, - ContentIndex: chunk.Index, - Item: item, - }, - }, nil, false + return []*schemas.BifrostResponsesStreamResponse{ + { + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber, + OutputIndex: outputIdx, + ContentIndex: chunk.Index, + Item: item, + }, + }, nil, false + } } // End of a web_fetch_tool_result block — emit the web_fetch_call done with @@ -1777,54 +1981,6 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, }}, nil, false } - // End of a tool_search_tool_result block — emit the tool_search_call done - // carrying the discovered tool references (the deferred tools the search found). - // Mirrors the web_search_call done; the model's subsequent tool_use (calling one - // of those tools) is forwarded by the generic tool_use path. - if state.ToolSearchResult != nil && state.ToolSearchToolID != nil { - var toolRefs []string - for _, ref := range state.ToolSearchResult.ToolReferences { - if ref.ToolName != nil { - toolRefs = append(toolRefs, *ref.ToolName) - } else if ref.Name != nil { - toolRefs = append(toolRefs, *ref.Name) - } - } - item := &schemas.ResponsesMessage{ - ID: state.ToolSearchToolID, - Type: schemas.Ptr(schemas.ResponsesMessageTypeToolSearchCall), - Status: schemas.Ptr("completed"), - ResponsesToolMessage: &schemas.ResponsesToolMessage{ - CallID: state.ToolSearchToolID, - Name: state.ToolSearchToolName, - ResponsesToolSearchCall: &schemas.ResponsesToolSearchCall{ToolReferences: toolRefs}, - }, - } - outputIdx := state.ToolSearchOutputIndex - // Persist into OutputItems so message_stop includes the tool_search_call - // in response.completed (mirrors web_search_call / advisor_call handling). - if outputIdx != nil { - cloned := *item - clonedToolMsg := *item.ResponsesToolMessage - cloned.ResponsesToolMessage = &clonedToolMsg - state.OutputItems[*outputIdx] = &cloned - } - state.ToolSearchToolID = nil - state.ToolSearchToolName = nil - state.ToolSearchOutputIndex = nil - state.ToolSearchResult = nil - if chunk.Index != nil { - delete(state.ContentIndexToBlockType, *chunk.Index) - } - return []*schemas.BifrostResponsesStreamResponse{{ - Type: schemas.ResponsesStreamResponseTypeOutputItemDone, - SequenceNumber: sequenceNumber, - OutputIndex: outputIdx, - ContentIndex: chunk.Index, - Item: item, - }}, nil, false - } - // End of a code-execution result block — emit the code_interpreter_call done. if state.CodeExecResult != nil && state.CodeExecToolID != nil { // Rebuild the server_tool_use block from accumulated state so the @@ -1887,8 +2043,8 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, if blockType, exists := state.ContentIndexToBlockType[*chunk.Index]; exists { if blockType == AnthropicContentBlockTypeWebSearchToolResult || blockType == AnthropicContentBlockTypeWebFetchToolResult || - blockType == AnthropicContentBlockTypeAdvisorToolResult || blockType == AnthropicContentBlockTypeToolSearchToolResult || + blockType == AnthropicContentBlockTypeAdvisorToolResult || blockType == AnthropicContentBlockTypeServerToolUse || blockType == AnthropicContentBlockTypeCodeExecutionToolResult || blockType == AnthropicContentBlockTypeBashCodeExecutionToolResult || @@ -4569,10 +4725,11 @@ func ConvertBifrostMessagesToAnthropicMessages(ctx *schemas.BifrostContext, bifr } } - case schemas.ResponsesMessageTypeToolSearchCall: + case schemas.ResponsesMessageTypeAnthropicToolSearchCall: // tool_search calls, like web search/advisor, emit a server_tool_use + // result pair (carrying the discovered tool_references) that lives inside // the assistant message, so a follow-up turn keeps the search context. + // (see #4780 — this is the egress/replay counterpart of the ingest fix). flushPendingToolResults() toolSearchBlocks := convertBifrostToolSearchCallToAnthropicBlocks(&msg) if len(toolSearchBlocks) > 0 { @@ -5443,6 +5600,39 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex if isOutputMessage { bifrostMessages = append(bifrostMessages, buildBifrostCodeExecutionCall(block)) } + } else if block.Name != nil && isAnthropicToolSearchToolName(*block.Name) { + // tool_search_tool_bm25 / tool_search_tool_regex server_tool_use — the + // paired tool_search_tool_result is attached below (see #4780; this is + // the non-streaming counterpart of the streaming fix). + // ID and CallID intentionally hold independent string copies (not the + // same *string as block.ID) so a downstream mutation of one can never + // silently alias the other. + var msgID, callID *string + if block.ID != nil { + idCopy, callIDCopy := *block.ID, *block.ID + msgID, callID = &idCopy, &callIDCopy + } + bifrostMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeAnthropicToolSearchCall), + ID: msgID, + Status: schemas.Ptr("in_progress"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + Name: block.Name, + CallID: callID, + }, + } + if len(block.Input) > 0 { + bifrostMsg.ResponsesToolMessage.Arguments = schemas.Ptr(string(block.Input)) + } + if block.Caller != nil { + bifrostMsg.ResponsesToolMessage.Caller = &schemas.ResponsesToolCaller{ + Type: string(block.Caller.Type), + ToolID: block.Caller.ToolID, + } + } + if isOutputMessage { + bifrostMessages = append(bifrostMessages, bifrostMsg) + } } case AnthropicContentBlockTypeCodeExecutionToolResult, @@ -5492,6 +5682,43 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex attachWebSearchSourcesToCall(bifrostMessages, *block.ToolUseID, block, true) } + case AnthropicContentBlockTypeToolSearchToolResult: + // Attach the discovered tool references onto the matching + // tool_search_tool_call (see #4780). + if block.ToolUseID != nil { + for i := len(bifrostMessages) - 1; i >= 0; i-- { + msg := &bifrostMessages[i] + if msg.Type == nil || *msg.Type != schemas.ResponsesMessageTypeAnthropicToolSearchCall { + continue + } + if msg.ResponsesToolMessage == nil || msg.ResponsesToolMessage.CallID == nil || + *msg.ResponsesToolMessage.CallID != *block.ToolUseID { + continue + } + var toolNames []string + for _, ref := range block.ToolReferences { + if ref.ToolName != nil { + toolNames = append(toolNames, *ref.ToolName) + } else if ref.Name != nil { + toolNames = append(toolNames, *ref.Name) + } + } + if toolNames == nil { + toolNames = []string{} + } + outputBytes, marshalErr := sonic.Marshal(toolNames) + outputStr := "[]" + if marshalErr == nil { + outputStr = string(outputBytes) + } + msg.Status = schemas.Ptr("completed") + msg.ResponsesToolMessage.Output = &schemas.ResponsesToolMessageOutputStruct{ + ResponsesToolCallOutputStr: &outputStr, + } + break + } + } + case AnthropicContentBlockTypeWebFetchToolResult: if block.ToolUseID != nil { attachAnthropicWebFetchResult(bifrostMessages, *block.ToolUseID, block) @@ -6296,10 +6523,11 @@ func convertBifrostAdvisorCallToAnthropicBlocks(msg *schemas.ResponsesMessage) [ // convertBifrostToolSearchCallToAnthropicBlocks rebuilds the tool_search // server_tool_use block and its paired tool_search_tool_result block (carrying -// the discovered tool_references) from a neutral tool_search_call. Anthropic -// requires the server_tool_use to be followed by its result block in the -// assistant message, so a follow-up turn that references a discovered tool keeps -// the search context. Mirrors convertBifrostWebSearchCall/AdvisorCall. +// the discovered tool_references) from a neutral tool_search_call/tool_search_tool_call +// item. Anthropic requires the server_tool_use to be followed by its result +// block in the assistant message, so a later turn can replay this call back +// to Anthropic and keep the search context (egress counterpart of the ingest +// fix for #4780). Mirrors convertBifrostWebSearchCall/AdvisorCall. func convertBifrostToolSearchCallToAnthropicBlocks(msg *schemas.ResponsesMessage) []AnthropicContentBlock { if msg.ResponsesToolMessage == nil { return nil @@ -6320,32 +6548,53 @@ func convertBifrostToolSearchCallToAnthropicBlocks(msg *schemas.ResponsesMessage return nil } - // 1. server_tool_use block. Preserve the search variant name (regex/bm25); - // the query is not retained on the neutral item, so send an empty input. - name := string(AnthropicToolNameToolSearchRegex) - if msg.ResponsesToolMessage.Name != nil && *msg.ResponsesToolMessage.Name != "" { - name = *msg.ResponsesToolMessage.Name + toolName := msg.ResponsesToolMessage.Name + if toolName == nil { + toolName = schemas.Ptr(string(AnthropicToolNameToolSearchBM25)) } - serverToolUseBlock := AnthropicContentBlock{ - Type: AnthropicContentBlockTypeServerToolUse, - ID: toolUseID, - Name: schemas.Ptr(name), - Input: json.RawMessage("{}"), + + // The caller (set when this search was spawned from inside the code execution + // sandbox) must be re-emitted on both the server_tool_use and the result + // block, mirroring convertBifrostWebSearchCallToAnthropicBlocks. + var caller *AnthropicToolCaller + if c := msg.ResponsesToolMessage.Caller; c != nil { + caller = &AnthropicToolCaller{Type: AnthropicToolCallerType(c.Type), ToolID: c.ToolID} } - // 2. tool_search_tool_result block reconstructed from the discovered tool names. - var toolReferences []AnthropicContentBlock - if msg.ResponsesToolMessage.ResponsesToolSearchCall != nil { - for _, toolName := range msg.ResponsesToolMessage.ResponsesToolSearchCall.ToolReferences { - toolReferences = append(toolReferences, AnthropicContentBlock{ - Type: AnthropicContentBlockTypeToolReference, - ToolName: schemas.Ptr(toolName), - }) - } + // 1. server_tool_use block — reuse the verbatim input JSON captured at + // ingest time (the search query) if present, otherwise an empty object + // (Anthropic requires input to always be present on tool_use blocks). + serverToolUseBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeServerToolUse, + ID: toolUseID, + Name: toolName, + Caller: caller, + } + if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" { + serverToolUseBlock.Input = json.RawMessage(*msg.ResponsesToolMessage.Arguments) + } else { + serverToolUseBlock.Input = json.RawMessage("{}") + } + + // 2. tool_search_tool_result block carrying the discovered tool names as + // tool_reference blocks. Only the name survives the round trip (that's all + // Output carries) — best-effort, matching what was persisted. + var toolNames []string + if msg.ResponsesToolMessage.Output != nil && msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + _ = sonic.Unmarshal([]byte(*msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr), &toolNames) + } + toolReferences := make([]AnthropicContentBlock, 0, len(toolNames)) + for _, name := range toolNames { + n := name + toolReferences = append(toolReferences, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolReference, + ToolName: &n, + }) } resultBlock := AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolSearchToolResult, ToolUseID: toolUseID, + Caller: caller, ToolReferences: toolReferences, } @@ -6364,6 +6613,18 @@ func isAnthropicCodeExecutionToolName(name string) bool { } } +// isAnthropicToolSearchToolName reports whether name is one of the tool_search +// server-tool names (tool_search_tool_bm25 / tool_search_tool_regex), regardless of +// which versioned tool type (e.g. _20251119) declared it in the request. +func isAnthropicToolSearchToolName(name string) bool { + switch AnthropicToolName(name) { + case AnthropicToolNameToolSearchBM25, AnthropicToolNameToolSearchRegex: + return true + default: + return false + } +} + // anthropicCodeExecResultBlockType maps a code-execution sub-tool name to its // outer *_tool_result block type. func anthropicCodeExecResultBlockType(toolName string) AnthropicContentBlockType { @@ -7306,9 +7567,14 @@ func convertBifrostToolToAnthropic(model string, tool *schemas.ResponsesTool, pr anthropicTool.Description = tool.Description } - // Convert parameters and strict from ToolFunction + // Convert parameters and strict from ToolFunction. Drop strict on targets that don't + // support structured outputs (e.g. Vertex) — mirrors the chat-completions path's gate + // in stripUnsupportedAnthropicFields (utils.go); the Responses path lacked it (#3233). if tool.ResponsesToolFunction != nil { anthropicTool.Strict = tool.ResponsesToolFunction.Strict + if features, ok := ProviderFeatures[provider]; anthropicTool.Strict != nil && (!ok || !features.StructuredOutputs) { + anthropicTool.Strict = nil + } } if tool.ResponsesToolFunction != nil && tool.ResponsesToolFunction.Parameters != nil { anthropicTool.InputSchema = tool.ResponsesToolFunction.Parameters diff --git a/core/providers/anthropic/toolsearch_roundtrip_test.go b/core/providers/anthropic/toolsearch_roundtrip_test.go new file mode 100644 index 00000000000..91359706f7f --- /dev/null +++ b/core/providers/anthropic/toolsearch_roundtrip_test.go @@ -0,0 +1,225 @@ +package anthropic + +import ( + "strings" + "testing" + "time" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" +) + +// rawToolSearchNonStreamingResponse mirrors a non-streaming Anthropic Messages API +// response containing a tool_search call and its paired result. Regression fixture +// for the non-streaming counterpart of #4780 (the streaming path was fixed first; +// this proves the non-streaming ingest path recognizes tool_search too, and that a +// later turn replaying the item back to Anthropic reconstructs an equivalent pair — +// found missing by an independent review of the streaming-only fix). +const rawToolSearchNonStreamingResponse = `{ + "model": "claude-opus-4-8", + "id": "msg_01ToolSearchNonStreaming", + "type": "message", + "role": "assistant", + "content": [ + { "type": "server_tool_use", "id": "srvtoolu_ns1", "name": "tool_search_tool_bm25", "input": {"query": "weather"} }, + { "type": "tool_search_tool_result", "tool_use_id": "srvtoolu_ns1", "tool_references": [{"type": "tool_reference", "tool_name": "get_weather"}, {"type": "tool_reference", "tool_name": "get_forecast"}] }, + { "type": "text", "text": "I found the get_weather tool." } + ], + "stop_reason": "end_turn", + "usage": { "input_tokens": 20, "output_tokens": 10 } +}` + +// TestToolSearch_NonStreamingIngestAndEgressRoundTrip verifies that a non-streaming +// Anthropic response containing a tool_search call+result (a) ingests into a +// tool_search_tool_call Bifrost item carrying the discovered tool names and the +// original query, and (b) that item converts back into an equivalent +// server_tool_use + tool_search_tool_result pair when replayed to Anthropic on a +// later turn (egress). +func TestToolSearch_NonStreamingIngestAndEgressRoundTrip(t *testing.T) { + var resp AnthropicMessageResponse + if err := sonic.Unmarshal([]byte(rawToolSearchNonStreamingResponse), &resp); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + + ctx := schemas.NewBifrostContext(nil, time.Time{}) + bifrostResp := resp.ToBifrostResponsesResponse(ctx) + if bifrostResp == nil { + t.Fatal("ToBifrostResponsesResponse returned nil") + } + + var toolSearchItem *schemas.ResponsesMessage + for i := range bifrostResp.Output { + out := &bifrostResp.Output[i] + if out.Type != nil && *out.Type == schemas.ResponsesMessageTypeAnthropicToolSearchCall { + toolSearchItem = out + } + } + if toolSearchItem == nil { + t.Fatal("expected a tool_search_tool_call output item, got none") + } + if toolSearchItem.Status == nil || *toolSearchItem.Status != "completed" { + t.Errorf("ingested item status = %v, want completed", toolSearchItem.Status) + } + if toolSearchItem.ResponsesToolMessage == nil || toolSearchItem.ResponsesToolMessage.Output == nil || + toolSearchItem.ResponsesToolMessage.Output.ResponsesToolCallOutputStr == nil { + t.Fatal("ingested item missing Output") + } + out := *toolSearchItem.ResponsesToolMessage.Output.ResponsesToolCallOutputStr + if !strings.Contains(out, "get_weather") || !strings.Contains(out, "get_forecast") { + t.Errorf("ingested output = %q, want it to contain both discovered tool names", out) + } + if toolSearchItem.ResponsesToolMessage.Arguments == nil || !strings.Contains(*toolSearchItem.ResponsesToolMessage.Arguments, "weather") { + t.Errorf("ingested item did not preserve the original call input (query), got Arguments = %v", toolSearchItem.ResponsesToolMessage.Arguments) + } + + // Simulate the real multi-turn path: Bifrost serializes this item back to the + // client as JSON, the client stores it and resends it verbatim on the next + // turn, and Bifrost decodes it via ResponsesMessage's custom UnmarshalJSON. + // This matters because a DIFFERENT type string ("tool_search_call", used by + // Codex/OpenAI's own client-executed tool_search meta-tool) is intercepted by + // isToolSearchItem and preserved as raw bytes rather than populating + // ResponsesToolMessage at all — confirming our distinct "tool_search_tool_call" + // type does NOT fall into that trap and egress still sees a populated item. + rawItem, err := sonic.Marshal(toolSearchItem) + if err != nil { + t.Fatalf("marshal ingested item: %v", err) + } + var decodedItem schemas.ResponsesMessage + if err := sonic.Unmarshal(rawItem, &decodedItem); err != nil { + t.Fatalf("unmarshal ingested item: %v", err) + } + if decodedItem.ResponsesToolMessage == nil { + t.Fatal("decoded item lost its ResponsesToolMessage — the type string collided with Codex's raw tool_search preservation path") + } + + // Egress: replay the JSON-round-tripped item back to Anthropic as if it were + // prior-turn history. + anthropicMessages, _ := ConvertBifrostMessagesToAnthropicMessages(ctx, []schemas.ResponsesMessage{decodedItem}, true, schemas.Anthropic, "claude-opus-4-8") + if len(anthropicMessages) == 0 { + t.Fatal("expected at least one reconstructed Anthropic message, got none") + } + + var sawServerToolUse, sawResult bool + for _, m := range anthropicMessages { + for _, block := range m.Content.ContentBlocks { + switch block.Type { + case AnthropicContentBlockTypeServerToolUse: + if block.Name == nil || *block.Name != "tool_search_tool_bm25" { + t.Errorf("reconstructed server_tool_use name = %v, want tool_search_tool_bm25", block.Name) + } + if block.ID == nil || *block.ID != "srvtoolu_ns1" { + t.Errorf("reconstructed server_tool_use id = %v, want srvtoolu_ns1", block.ID) + } + sawServerToolUse = true + case AnthropicContentBlockTypeToolSearchToolResult: + if block.ToolUseID == nil || *block.ToolUseID != "srvtoolu_ns1" { + t.Errorf("reconstructed tool_search_tool_result tool_use_id = %v, want srvtoolu_ns1", block.ToolUseID) + } + var names []string + for _, ref := range block.ToolReferences { + if ref.ToolName != nil { + names = append(names, *ref.ToolName) + } + } + if !containsStr(names, "get_weather") || !containsStr(names, "get_forecast") { + t.Errorf("reconstructed tool_references = %v, want both discovered tool names", names) + } + sawResult = true + } + } + } + if !sawServerToolUse { + t.Error("expected a reconstructed server_tool_use block, got none — the call is not round-trippable") + } + if !sawResult { + t.Error("expected a reconstructed tool_search_tool_result block, got none — the result is not round-trippable") + } +} + +// rawToolSearchWithCallerResponse is like rawToolSearchNonStreamingResponse but +// the tool_search call carries a "caller" (set when the search was spawned +// from inside a code execution sandbox — programmatic tool calling). +// Regression fixture: web_search preserves caller both ways, tool_search +// initially did not (found in a second-round Codex review). +const rawToolSearchWithCallerResponse = `{ + "model": "claude-opus-4-8", + "id": "msg_01ToolSearchCaller", + "type": "message", + "role": "assistant", + "content": [ + { "type": "server_tool_use", "id": "srvtoolu_caller1", "name": "tool_search_tool_bm25", "input": {"query": "weather"}, "caller": {"type": "code_execution_20250825", "tool_id": "srvtoolu_codeexec1"} }, + { "type": "tool_search_tool_result", "tool_use_id": "srvtoolu_caller1", "tool_references": [{"type": "tool_reference", "tool_name": "get_weather"}] } + ], + "stop_reason": "end_turn", + "usage": { "input_tokens": 20, "output_tokens": 10 } +}` + +// TestToolSearch_CallerPreservedIngestAndEgress verifies the "caller" (set when +// a tool_search call is spawned from inside code execution) survives both +// non-streaming ingest and egress replay, matching web_search's existing +// caller-preservation behavior. +func TestToolSearch_CallerPreservedIngestAndEgress(t *testing.T) { + var resp AnthropicMessageResponse + if err := sonic.Unmarshal([]byte(rawToolSearchWithCallerResponse), &resp); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + + ctx := schemas.NewBifrostContext(nil, time.Time{}) + bifrostResp := resp.ToBifrostResponsesResponse(ctx) + if bifrostResp == nil { + t.Fatal("ToBifrostResponsesResponse returned nil") + } + + var toolSearchItem *schemas.ResponsesMessage + for i := range bifrostResp.Output { + out := &bifrostResp.Output[i] + if out.Type != nil && *out.Type == schemas.ResponsesMessageTypeAnthropicToolSearchCall { + toolSearchItem = out + } + } + if toolSearchItem == nil { + t.Fatal("expected a tool_search_tool_call output item, got none") + } + if toolSearchItem.ResponsesToolMessage == nil || toolSearchItem.ResponsesToolMessage.Caller == nil { + t.Fatal("ingested item did not preserve caller") + } + if toolSearchItem.ResponsesToolMessage.Caller.ToolID == nil || *toolSearchItem.ResponsesToolMessage.Caller.ToolID != "srvtoolu_codeexec1" { + t.Errorf("ingested caller.ToolID = %v, want srvtoolu_codeexec1", toolSearchItem.ResponsesToolMessage.Caller.ToolID) + } + + anthropicMessages, _ := ConvertBifrostMessagesToAnthropicMessages(ctx, []schemas.ResponsesMessage{*toolSearchItem}, true, schemas.Anthropic, "claude-opus-4-8") + if len(anthropicMessages) == 0 { + t.Fatal("expected at least one reconstructed Anthropic message, got none") + } + + var sawCallerOnCall, sawCallerOnResult bool + for _, m := range anthropicMessages { + for _, block := range m.Content.ContentBlocks { + switch block.Type { + case AnthropicContentBlockTypeServerToolUse: + if block.Caller != nil && block.Caller.ToolID != nil && *block.Caller.ToolID == "srvtoolu_codeexec1" { + sawCallerOnCall = true + } + case AnthropicContentBlockTypeToolSearchToolResult: + if block.Caller != nil && block.Caller.ToolID != nil && *block.Caller.ToolID == "srvtoolu_codeexec1" { + sawCallerOnResult = true + } + } + } + } + if !sawCallerOnCall { + t.Error("reconstructed server_tool_use block is missing caller") + } + if !sawCallerOnResult { + t.Error("reconstructed tool_search_tool_result block is missing caller") + } +} + +func containsStr(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} diff --git a/core/providers/anthropic/toolsearch_test.go b/core/providers/anthropic/toolsearch_test.go index 047c38e721d..2811d045b9c 100644 --- a/core/providers/anthropic/toolsearch_test.go +++ b/core/providers/anthropic/toolsearch_test.go @@ -1,335 +1,223 @@ package anthropic import ( - "context" "strings" "testing" + "time" schemas "github.com/maximhq/bifrost/core/schemas" -) - -// These tests cover the Anthropic server-side tool_search streaming path -// (server_tool_use(tool_search) -> tool_search_tool_result(tool_references) -> -// tool_use(discovered tool)). Without the handler this provider drops the -// tool_references and emits orphan function_call argument deltas; with it the -// discovered tool references are forwarded and the follow-up tool_use is intact. -const ( - tsServerToolUseID = "srvtoolu_ts_1" - tsDiscoveredTool = "OpenMeteoMCP-weather_forecast" - tsDiscoveredCallID = "toolu_weather_1" + "github.com/bytedance/sonic" ) -// newToolSearchTestState builds a stream state primed past message_start so a -// test can feed content_block_* chunks directly. -func newToolSearchTestState() *AnthropicResponsesStreamState { - return &AnthropicResponsesStreamState{ - ContentIndexToOutputIndex: make(map[int]int), - ContentIndexToBlockType: make(map[int]AnthropicContentBlockType), - ToolArgumentBuffers: make(map[int]string), - MCPCallOutputIndices: make(map[int]bool), - ItemIDs: make(map[int]string), - OutputItems: make(map[int]*schemas.ResponsesMessage), - ReasoningSignatures: make(map[int]string), - TextContentIndices: make(map[int]bool), - ReasoningContentIndices: make(map[int]bool), - CompactionContentIndices: make(map[int]*schemas.CacheControl), - TextBuffers: make(map[int]*strings.Builder), - CurrentOutputIndex: 0, - MessageID: schemas.Ptr("msg_ts_test"), - Model: schemas.Ptr("claude-sonnet-4-6"), - CreatedAt: 1234567890, - HasEmittedCreated: true, - HasEmittedInProgress: true, - } -} - -// toolSearchStreamChunks builds a realistic server-side tool_search Anthropic -// stream for the given tool-search variant: server_tool_use(toolName) -> -// tool_search_tool_result(tool_references to the discovered tool) -> -// tool_use(discovered tool). When withStop is set, a terminal message_stop is -// appended so the converter emits response.completed. -func toolSearchStreamChunks(toolName string, withStop bool) []*AnthropicStreamEvent { - q := `{"query":"weather"}` - args := `{"location":"Tokyo"}` - chunks := []*AnthropicStreamEvent{ - // idx0: server_tool_use() + its query deltas - {Type: AnthropicStreamEventTypeContentBlockStart, Index: schemas.Ptr(0), ContentBlock: &AnthropicContentBlock{ - Type: AnthropicContentBlockTypeServerToolUse, - ID: schemas.Ptr(tsServerToolUseID), - Name: schemas.Ptr(toolName), - }}, - {Type: AnthropicStreamEventTypeContentBlockDelta, Index: schemas.Ptr(0), Delta: &AnthropicStreamDelta{ - Type: AnthropicStreamDeltaTypeInputJSON, PartialJSON: &q, - }}, - {Type: AnthropicStreamEventTypeContentBlockStop, Index: schemas.Ptr(0)}, - - // idx1: tool_search_tool_result carrying tool_references to the discovered tool - {Type: AnthropicStreamEventTypeContentBlockStart, Index: schemas.Ptr(1), ContentBlock: &AnthropicContentBlock{ - Type: AnthropicContentBlockTypeToolSearchToolResult, - ToolUseID: schemas.Ptr(tsServerToolUseID), - ToolReferences: []AnthropicContentBlock{ - {Type: AnthropicContentBlockTypeToolReference, ToolName: schemas.Ptr(tsDiscoveredTool)}, - }, - }}, - {Type: AnthropicStreamEventTypeContentBlockStop, Index: schemas.Ptr(1)}, - - // idx2: tool_use that calls the discovered tool (the client must forward this) - {Type: AnthropicStreamEventTypeContentBlockStart, Index: schemas.Ptr(2), ContentBlock: &AnthropicContentBlock{ - Type: AnthropicContentBlockTypeToolUse, - ID: schemas.Ptr(tsDiscoveredCallID), - Name: schemas.Ptr(tsDiscoveredTool), - }}, - {Type: AnthropicStreamEventTypeContentBlockDelta, Index: schemas.Ptr(2), Delta: &AnthropicStreamDelta{ - Type: AnthropicStreamDeltaTypeInputJSON, PartialJSON: &args, - }}, - {Type: AnthropicStreamEventTypeContentBlockStop, Index: schemas.Ptr(2)}, - } - if withStop { - stopReason := AnthropicStopReasonToolUse - chunks = append(chunks, - &AnthropicStreamEvent{Type: AnthropicStreamEventTypeMessageDelta, Delta: &AnthropicStreamDelta{StopReason: &stopReason}}, - &AnthropicStreamEvent{Type: AnthropicStreamEventTypeMessageStop}, - ) - } - return chunks +// toolSearchStreamEvents mirrors a Claude tool_search_tool_bm25 turn: the model +// invokes the server-run tool_search tool, Anthropic returns the discovered tool +// references in a tool_search_tool_result block, then the model answers using one +// of the discovered tools. Regression fixture for #4780: this result was +// previously dropped entirely on the /v1/responses streaming path. +var toolSearchStreamEvents = []string{ + `{"type":"message_start","message":{"model":"claude-opus-4-8","id":"msg_ts1","type":"message","role":"assistant","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_ts1","name":"tool_search_tool_bm25","input":{}}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\": \"weather"}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_ts1","tool_references":[{"type":"tool_reference","tool_name":"get_weather"},{"type":"tool_reference","tool_name":"get_forecast"}]}}`, + `{"type":"content_block_stop","index":1}`, + `{"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"I found the get_weather tool."}}`, + `{"type":"content_block_stop","index":2}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}`, + `{"type":"message_stop"}`, } -func driveToolSearch(t *testing.T, chunks []*AnthropicStreamEvent) []*schemas.BifrostResponsesStreamResponse { - t.Helper() - state := newToolSearchTestState() - var all []*schemas.BifrostResponsesStreamResponse +// TestToolSearch_Stream verifies the streaming converter surfaces the +// tool_search_tool_result on the /v1/responses path instead of dropping it: an +// output_item.added (tool_search_call, in_progress) is emitted for the +// server_tool_use, and an output_item.done (tool_search_call, completed) carrying +// the discovered tool names is emitted once the result block closes. +func TestToolSearch_Stream(t *testing.T) { + ctx := schemas.NewBifrostContext(nil, time.Time{}) + state := AcquireAnthropicResponsesStreamState() + defer ReleaseAnthropicResponsesStreamState(state) + + var emitted []*schemas.BifrostResponsesStreamResponse seq := 0 - for i, c := range chunks { - resps, berr, _ := c.ToBifrostResponsesStream(context.Background(), seq, state) - if berr != nil { - t.Fatalf("chunk %d returned error: %v", i, berr) + for _, raw := range toolSearchStreamEvents { + var chunk AnthropicStreamEvent + if err := sonic.Unmarshal([]byte(raw), &chunk); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + responses, bErr, _ := chunk.ToBifrostResponsesStream(ctx, seq, state) + if bErr != nil { + t.Fatalf("ToBifrostResponsesStream error: %v", bErr) + } + for _, r := range responses { + seq++ + emitted = append(emitted, r) } - all = append(all, resps...) - seq += len(resps) } - return all -} -// TestToolSearch_ForwardsToolReferences asserts the discovered tool references -// from tool_search_tool_result survive into a tool_search_call item (carrying -// the tool name), instead of being dropped. Runs both tool_search variants. -// Fails on the unpatched provider, which emits no tool_search_call. -func TestToolSearch_ForwardsToolReferences(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - toolName string - }{ - {"regex", string(AnthropicToolNameToolSearchRegex)}, - {"bm25", string(AnthropicToolNameToolSearchBM25)}, - } { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - all := driveToolSearch(t, toolSearchStreamChunks(tc.toolName, false)) - - var done *schemas.ResponsesMessage - for _, r := range all { - if r.Type == schemas.ResponsesStreamResponseTypeOutputItemDone && - r.Item != nil && r.Item.Type != nil && - *r.Item.Type == schemas.ResponsesMessageTypeToolSearchCall { - done = r.Item + var sawAdded, sawDone bool + for _, e := range emitted { + switch e.Type { + case schemas.ResponsesStreamResponseTypeOutputItemAdded: + if e.Item != nil && e.Item.Type != nil && *e.Item.Type == schemas.ResponsesMessageTypeAnthropicToolSearchCall { + sawAdded = true + if e.Item.Status == nil || *e.Item.Status != "in_progress" { + t.Errorf("added item status = %v, want in_progress", e.Item.Status) } } - if done == nil { - t.Fatal("no tool_search_call output_item.done emitted — tool_search_tool_result was dropped") + case schemas.ResponsesStreamResponseTypeOutputItemDone: + if e.Item == nil || e.Item.Type == nil || *e.Item.Type != schemas.ResponsesMessageTypeAnthropicToolSearchCall { + continue } - if done.ResponsesToolMessage == nil || done.ResponsesToolMessage.ResponsesToolSearchCall == nil { - t.Fatal("tool_search_call item carries no ResponsesToolSearchCall payload") + sawDone = true + if e.Item.Status == nil || *e.Item.Status != "completed" { + t.Errorf("done item status = %v, want completed", e.Item.Status) } - refs := done.ResponsesToolMessage.ResponsesToolSearchCall.ToolReferences - if len(refs) != 1 || refs[0] != tsDiscoveredTool { - t.Fatalf("tool_references = %v, want [%q]", refs, tsDiscoveredTool) + tm := e.Item.ResponsesToolMessage + if tm == nil || tm.Output == nil || tm.Output.ResponsesToolCallOutputStr == nil { + t.Fatal("output_item.done tool_search_call missing Output") } - // done item must carry the tool name, matching the added item (advisor parity) - if done.ResponsesToolMessage.Name == nil || *done.ResponsesToolMessage.Name != tc.toolName { - t.Fatalf("tool_search_call done Name = %v, want %q", done.ResponsesToolMessage.Name, tc.toolName) + out := *tm.Output.ResponsesToolCallOutputStr + if !strings.Contains(out, "get_weather") || !strings.Contains(out, "get_forecast") { + t.Errorf("output = %q, want it to contain both discovered tool names", out) } - }) + } } -} -// TestToolSearch_ForwardsDiscoveredToolUse asserts the follow-up tool_use that -// calls the discovered tool is forwarded as a function_call (added + done). -func TestToolSearch_ForwardsDiscoveredToolUse(t *testing.T) { - t.Parallel() - all := driveToolSearch(t, toolSearchStreamChunks(string(AnthropicToolNameToolSearchRegex), false)) + if !sawAdded { + t.Error("expected an output_item.added for the tool_search_call, got none — the call was dropped") + } + if !sawDone { + t.Error("expected an output_item.done for the tool_search_call carrying discovered tool references, got none — the result was dropped (#4780)") + } - var sawAdded, sawDone bool - for _, r := range all { - if r.Item == nil || r.Item.Type == nil || *r.Item.Type != schemas.ResponsesMessageTypeFunctionCall { + // response.completed's Output array is built solely from state.OutputItems + // (see AnthropicStreamEventTypeMessageStop) — verify the tool_search_call + // actually persisted there, not just in the individual stream events. + var sawInCompleted bool + for _, r := range emitted { + if r.Type != schemas.ResponsesStreamResponseTypeCompleted || r.Response == nil { continue } - if r.Item.ResponsesToolMessage == nil || r.Item.ResponsesToolMessage.Name == nil || - *r.Item.ResponsesToolMessage.Name != tsDiscoveredTool { - continue - } - switch r.Type { - case schemas.ResponsesStreamResponseTypeOutputItemAdded: - sawAdded = true - case schemas.ResponsesStreamResponseTypeOutputItemDone: - sawDone = true + for _, out := range r.Response.Output { + if out.Type != nil && *out.Type == schemas.ResponsesMessageTypeAnthropicToolSearchCall { + sawInCompleted = true + } } } - if !sawAdded || !sawDone { - t.Fatalf("discovered tool_use not forwarded as function_call (added=%v done=%v)", sawAdded, sawDone) + if !sawInCompleted { + t.Error("expected the tool_search_call to be present in response.completed's Output array, got none — it would be missing from the final response") } } -// TestToolSearch_NoOrphanFunctionCallArgs asserts every function_call argument -// delta/done is preceded by an output_item.added for the same item. The unpatched -// provider emits orphan tool-search query argument deltas (args with no parent -// item), which desync the client stream parser — this guards against that. -func TestToolSearch_NoOrphanFunctionCallArgs(t *testing.T) { - t.Parallel() - all := driveToolSearch(t, toolSearchStreamChunks(string(AnthropicToolNameToolSearchRegex), false)) - - added := map[string]bool{} - for _, r := range all { - switch r.Type { - case schemas.ResponsesStreamResponseTypeOutputItemAdded: - if r.Item != nil && r.Item.ID != nil { - added[*r.Item.ID] = true - } - case schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta, - schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDone: - if r.ItemID == nil { - t.Fatalf("function_call args event %q has no ItemID (orphan)", r.Type) - } - if !added[*r.ItemID] { - t.Fatalf("orphan function_call args for item %q — no preceding output_item.added", *r.ItemID) - } - } - } +// multiToolSearchStreamEvents reproduces a real event sequence observed against the +// live Anthropic API: the model issues three tool_search_tool_bm25 calls whose call +// blocks are ALL emitted before any of their result blocks arrive — the call/result +// blocks are not interleaved 1:1 per call. A single-slot state design (tracking only +// "the current" tool_use ID) drops every result except the last, leaving earlier +// calls stuck "in_progress" forever. +var multiToolSearchStreamEvents = []string{ + `{"type":"message_start","message":{"model":"claude-opus-4-8","id":"msg_multi1","type":"message","role":"assistant","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_a","name":"tool_search_tool_bm25","input":{}}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"server_tool_use","id":"srvtoolu_b","name":"tool_search_tool_bm25","input":{}}}`, + `{"type":"content_block_stop","index":1}`, + `{"type":"content_block_start","index":2,"content_block":{"type":"server_tool_use","id":"srvtoolu_c","name":"tool_search_tool_bm25","input":{}}}`, + `{"type":"content_block_stop","index":2}`, + `{"type":"content_block_start","index":3,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_a","tool_references":[{"type":"tool_reference","tool_name":"tool_a"}]}}`, + `{"type":"content_block_stop","index":3}`, + `{"type":"content_block_start","index":4,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_b","tool_references":[{"type":"tool_reference","tool_name":"tool_b"}]}}`, + `{"type":"content_block_stop","index":4}`, + `{"type":"content_block_start","index":5,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_c","tool_references":[{"type":"tool_reference","tool_name":"tool_c"}]}}`, + `{"type":"content_block_stop","index":5}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}`, + `{"type":"message_stop"}`, } -// TestToolSearch_CompletedResponseIncludesToolSearchCall asserts the terminal -// response.completed Output carries the tool_search_call (with its tool_references), -// guarding the OutputItems persistence that the streamed done events alone don't cover. -func TestToolSearch_CompletedResponseIncludesToolSearchCall(t *testing.T) { - t.Parallel() - all := driveToolSearch(t, toolSearchStreamChunks(string(AnthropicToolNameToolSearchRegex), true)) +// TestToolSearch_MultipleCallsInOneTurn is a regression test for a bug found while +// manually verifying #4780 against the live Anthropic API: a single-slot +// (non-map-keyed) state design silently drops every tool_search result except the +// last when a turn contains multiple calls whose blocks aren't interleaved 1:1. +func TestToolSearch_MultipleCallsInOneTurn(t *testing.T) { + ctx := schemas.NewBifrostContext(nil, time.Time{}) + state := AcquireAnthropicResponsesStreamState() + defer ReleaseAnthropicResponsesStreamState(state) - var completed *schemas.BifrostResponsesResponse - for _, r := range all { - if r.Type == schemas.ResponsesStreamResponseTypeCompleted && r.Response != nil { - completed = r.Response + var emitted []*schemas.BifrostResponsesStreamResponse + seq := 0 + for _, raw := range multiToolSearchStreamEvents { + var chunk AnthropicStreamEvent + if err := sonic.Unmarshal([]byte(raw), &chunk); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + responses, bErr, _ := chunk.ToBifrostResponsesStream(ctx, seq, state) + if bErr != nil { + t.Fatalf("ToBifrostResponsesStream error: %v", bErr) + } + for _, r := range responses { + seq++ + emitted = append(emitted, r) } } - if completed == nil { - t.Fatal("no response.completed emitted") - } - var foundRefs []string - var foundTool bool - for i := range completed.Output { - item := completed.Output[i] - if item.Type == nil { + + completedOutputs := map[string]string{} // call_id -> output + for _, e := range emitted { + if e.Type != schemas.ResponsesStreamResponseTypeOutputItemDone { continue } - switch *item.Type { - case schemas.ResponsesMessageTypeToolSearchCall: - if item.ResponsesToolMessage != nil && item.ResponsesToolMessage.ResponsesToolSearchCall != nil { - foundRefs = item.ResponsesToolMessage.ResponsesToolSearchCall.ToolReferences - } - case schemas.ResponsesMessageTypeFunctionCall: - if item.ResponsesToolMessage != nil && item.ResponsesToolMessage.Name != nil && - *item.ResponsesToolMessage.Name == tsDiscoveredTool { - foundTool = true - } + if e.Item == nil || e.Item.Type == nil || *e.Item.Type != schemas.ResponsesMessageTypeAnthropicToolSearchCall { + continue } + if e.Item.ResponsesToolMessage == nil || e.Item.ResponsesToolMessage.CallID == nil { + continue + } + if e.Item.Status == nil || *e.Item.Status != "completed" { + t.Errorf("call %s: status = %v, want completed", *e.Item.ResponsesToolMessage.CallID, e.Item.Status) + continue + } + if e.Item.ResponsesToolMessage.Output == nil || e.Item.ResponsesToolMessage.Output.ResponsesToolCallOutputStr == nil { + t.Errorf("call %s: missing Output", *e.Item.ResponsesToolMessage.CallID) + continue + } + completedOutputs[*e.Item.ResponsesToolMessage.CallID] = *e.Item.ResponsesToolMessage.Output.ResponsesToolCallOutputStr } - if len(foundRefs) != 1 || foundRefs[0] != tsDiscoveredTool { - t.Fatalf("response.completed tool_search_call tool_references = %v, want [%q]", foundRefs, tsDiscoveredTool) - } - if !foundTool { - t.Fatal("response.completed Output missing the discovered tool function_call") - } -} -// TestToolSearch_ReverseRebuildsAnthropicBlocks asserts the Bifrost→Anthropic -// request builder rebuilds a tool_search_call into the paired -// server_tool_use(tool_search) + tool_search_tool_result(tool_references) blocks, -// so a follow-up turn keeps the search context (parity with web_search/advisor). -// Without the reverse case the item hits `default: continue` and is dropped. -func TestToolSearch_ReverseRebuildsAnthropicBlocks(t *testing.T) { - t.Parallel() - ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) - defer cancel() - - history := []schemas.ResponsesMessage{ - { - ID: schemas.Ptr(tsServerToolUseID), - Type: schemas.Ptr(schemas.ResponsesMessageTypeToolSearchCall), - ResponsesToolMessage: &schemas.ResponsesToolMessage{ - CallID: schemas.Ptr(tsServerToolUseID), - Name: schemas.Ptr(string(AnthropicToolNameToolSearchRegex)), - ResponsesToolSearchCall: &schemas.ResponsesToolSearchCall{ToolReferences: []string{tsDiscoveredTool}}, - }, - }, - { - ID: schemas.Ptr(tsDiscoveredCallID), - Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), - ResponsesToolMessage: &schemas.ResponsesToolMessage{ - CallID: schemas.Ptr(tsDiscoveredCallID), Name: schemas.Ptr(tsDiscoveredTool), Arguments: schemas.Ptr(`{"location":"Tokyo"}`), - }, - }, + for callID, wantTool := range map[string]string{ + "srvtoolu_a": "tool_a", + "srvtoolu_b": "tool_b", + "srvtoolu_c": "tool_c", + } { + out, ok := completedOutputs[callID] + if !ok { + t.Errorf("call %s: never completed — stuck in_progress forever (all-calls-before-results ordering not handled)", callID) + continue + } + if !strings.Contains(out, wantTool) { + t.Errorf("call %s: output = %q, want it to contain %q", callID, out, wantTool) + } } - msgs, _ := ConvertBifrostMessagesToAnthropicMessages(ctx, history, true, schemas.Anthropic, "claude-sonnet-4-6") - - var serverToolUse, resultBlock *AnthropicContentBlock - for mi := range msgs { - for bi := range msgs[mi].Content.ContentBlocks { - b := &msgs[mi].Content.ContentBlocks[bi] - switch b.Type { - case AnthropicContentBlockTypeServerToolUse: - if b.Name != nil && *b.Name == string(AnthropicToolNameToolSearchRegex) { - serverToolUse = b - } - case AnthropicContentBlockTypeToolSearchToolResult: - resultBlock = b + // Every one of the three calls must also appear completed in response.completed's + // Output array — not just in the individual output_item.done stream events. + completedInFinal := map[string]bool{} + for _, r := range emitted { + if r.Type != schemas.ResponsesStreamResponseTypeCompleted || r.Response == nil { + continue + } + for _, out := range r.Response.Output { + if out.Type == nil || *out.Type != schemas.ResponsesMessageTypeAnthropicToolSearchCall { + continue + } + if out.Status != nil && *out.Status == "completed" && out.ResponsesToolMessage != nil && out.ResponsesToolMessage.CallID != nil { + completedInFinal[*out.ResponsesToolMessage.CallID] = true } } } - - if serverToolUse == nil { - t.Fatal("reverse path dropped tool_search_call — no server_tool_use(tool_search) block rebuilt") - } - if serverToolUse.ID == nil || *serverToolUse.ID != tsServerToolUseID { - t.Fatalf("server_tool_use ID = %v, want %q", serverToolUse.ID, tsServerToolUseID) - } - if resultBlock == nil { - t.Fatal("no tool_search_tool_result block rebuilt") - } - if resultBlock.ToolUseID == nil || *resultBlock.ToolUseID != tsServerToolUseID { - t.Fatalf("tool_search_tool_result tool_use_id = %v, want %q", resultBlock.ToolUseID, tsServerToolUseID) - } - if len(resultBlock.ToolReferences) != 1 || resultBlock.ToolReferences[0].ToolName == nil || - *resultBlock.ToolReferences[0].ToolName != tsDiscoveredTool { - t.Fatalf("rebuilt tool_references = %+v, want one ref to %q", resultBlock.ToolReferences, tsDiscoveredTool) - } -} - -// A JSON-decoded tool_search_call input item has an initialized ResponsesToolMessage -// (arguments surfaced) but no CallID/ID, so there is no valid tool-use id to build -// server_tool_use / tool_search_tool_result blocks — the reverse path must skip it, -// not emit a nil-id pair Anthropic would reject. -func TestToolSearch_ReverseSkipsWhenNoToolUseID(t *testing.T) { - t.Parallel() - msg := schemas.ResponsesMessage{ - Type: schemas.Ptr(schemas.ResponsesMessageTypeToolSearchCall), - ResponsesToolMessage: &schemas.ResponsesToolMessage{ - Name: schemas.Ptr(string(AnthropicToolNameToolSearchRegex)), - ResponsesToolSearchCall: &schemas.ResponsesToolSearchCall{ToolReferences: []string{tsDiscoveredTool}}, - }, - } - if blocks := convertBifrostToolSearchCallToAnthropicBlocks(&msg); blocks != nil { - t.Fatalf("expected nil (no tool-use id), got %+v", blocks) + for _, callID := range []string{"srvtoolu_a", "srvtoolu_b", "srvtoolu_c"} { + if !completedInFinal[callID] { + t.Errorf("call %s: not present as completed in response.completed's Output array", callID) + } } } diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go index 91b8ea897be..05cb68ebac4 100644 --- a/core/providers/anthropic/types.go +++ b/core/providers/anthropic/types.go @@ -1861,4 +1861,4 @@ func parseAnthropicFileTimestamp(timestamp string) int64 { // AnthropicCountTokensResponse models the payload returned by Anthropic's count tokens endpoint. type AnthropicCountTokensResponse struct { InputTokens int `json:"input_tokens"` -} \ No newline at end of file +} diff --git a/core/providers/anthropic/utils_test.go b/core/providers/anthropic/utils_test.go index ae393a86e71..5f2ffc62cf8 100644 --- a/core/providers/anthropic/utils_test.go +++ b/core/providers/anthropic/utils_test.go @@ -3714,3 +3714,42 @@ func TestResponsesStream_TerminalChunkCarriesServedModifiers(t *testing.T) { t.Fatal("terminal billed chunk missing cache-creation tokens") } } + +// TestConvertBifrostToolToAnthropic_DropsStrictWhenUnsupported is a regression test for +// #3233: the Responses API tool-conversion path (convertBifrostToolToAnthropic) forwarded +// tools[].strict unconditionally, unlike the chat-completions path (stripUnsupportedAnthropicFields, +// gated on features.StructuredOutputs). Vertex has StructuredOutputs=false, so a strict tool +// routed to Anthropic-on-Vertex via the Responses API produced Anthropic's +// "tools.0.custom.strict: Extra inputs are not permitted" error. +func TestConvertBifrostToolToAnthropic_DropsStrictWhenUnsupported(t *testing.T) { + tool := &schemas.ResponsesTool{ + Type: schemas.ResponsesToolTypeFunction, + Name: schemas.Ptr("get_weather"), + ResponsesToolFunction: &schemas.ResponsesToolFunction{ + Strict: schemas.Ptr(true), + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + }, + }, + } + + t.Run("Vertex (StructuredOutputs unsupported) drops strict", func(t *testing.T) { + got := convertBifrostToolToAnthropic("claude-opus-4-8", tool, schemas.Vertex, false) + if got == nil { + t.Fatal("expected a converted tool, got nil") + } + if got.Strict != nil { + t.Errorf("Strict = %v, want nil (Vertex does not support structured outputs)", *got.Strict) + } + }) + + t.Run("Anthropic (StructuredOutputs supported) keeps strict", func(t *testing.T) { + got := convertBifrostToolToAnthropic("claude-opus-4-8", tool, schemas.Anthropic, false) + if got == nil { + t.Fatal("expected a converted tool, got nil") + } + if got.Strict == nil || !*got.Strict { + t.Errorf("Strict = %v, want true (Anthropic supports structured outputs)", got.Strict) + } + }) +} diff --git a/core/providers/anthropic/validatechattools_test.go b/core/providers/anthropic/validatechattools_test.go index d9f0c8a2df5..68ede140aec 100644 --- a/core/providers/anthropic/validatechattools_test.go +++ b/core/providers/anthropic/validatechattools_test.go @@ -19,12 +19,12 @@ func TestValidateChatToolsForProvider(t *testing.T) { } cases := []struct { - name string - provider schemas.ModelProvider - input []schemas.ChatTool - wantKeep int - wantDropped []string - assertNotes string + name string + provider schemas.ModelProvider + input []schemas.ChatTool + wantKeep int + wantDropped []string + assertNotes string }{ { name: "function tools always survive on any provider", @@ -41,8 +41,8 @@ func TestValidateChatToolsForProvider(t *testing.T) { assertNotes: "Bedrock has WebSearch=false per Table 20 (AWS user guide beta-header list + Anthropic overview)", }, { - name: "bedrock drops web_fetch + code_execution + mcp_toolset", - provider: schemas.Bedrock, + name: "bedrock drops web_fetch + code_execution + mcp_toolset", + provider: schemas.Bedrock, input: []schemas.ChatTool{ serverTool("web_fetch_20260309", "web_fetch"), serverTool("code_execution_20250825", "code_execution"), diff --git a/core/providers/anthropic/websearch_outputitems_test.go b/core/providers/anthropic/websearch_outputitems_test.go new file mode 100644 index 00000000000..e1242ca193e --- /dev/null +++ b/core/providers/anthropic/websearch_outputitems_test.go @@ -0,0 +1,274 @@ +package anthropic + +import ( + "testing" + "time" + + schemas "github.com/maximhq/bifrost/core/schemas" + + "github.com/bytedance/sonic" +) + +// webSearchStreamEvents mirrors a Claude web_search turn: server_tool_use -> +// web_search_tool_result -> text answer. Regression fixture for the same bug +// class as #4780 (tool_search): the web_search_call item was built and emitted +// correctly on the wire, but never persisted into state.OutputItems, so it was +// silently missing from response.completed's Output array. +var webSearchStreamEvents = []string{ + `{"type":"message_start","message":{"model":"claude-opus-4-8","id":"msg_ws1","type":"message","role":"assistant","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_ws1","name":"web_search","input":{"query":"anthropic founding year"}}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_ws1","content":[{"type":"web_search_result","url":"https://example.com/anthropic","title":"Anthropic"}]}}`, + `{"type":"content_block_stop","index":1}`, + `{"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"Anthropic was founded in 2021."}}`, + `{"type":"content_block_stop","index":2}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}`, + `{"type":"message_stop"}`, +} + +// TestWebSearch_PersistedInOutputItems verifies that web_search_call is present +// in response.completed's Output array, not just in the individual streaming +// events — mirrors TestToolSearch_Stream's response.completed assertion. +func TestWebSearch_PersistedInOutputItems(t *testing.T) { + ctx := schemas.NewBifrostContext(nil, time.Time{}) + state := AcquireAnthropicResponsesStreamState() + defer ReleaseAnthropicResponsesStreamState(state) + + var emitted []*schemas.BifrostResponsesStreamResponse + seq := 0 + for _, raw := range webSearchStreamEvents { + var chunk AnthropicStreamEvent + if err := sonic.Unmarshal([]byte(raw), &chunk); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + responses, bErr, _ := chunk.ToBifrostResponsesStream(ctx, seq, state) + if bErr != nil { + t.Fatalf("ToBifrostResponsesStream error: %v", bErr) + } + for _, r := range responses { + seq++ + emitted = append(emitted, r) + } + } + + var sawDone bool + for _, e := range emitted { + if e.Type != schemas.ResponsesStreamResponseTypeOutputItemDone { + continue + } + if e.Item == nil || e.Item.Type == nil || *e.Item.Type != schemas.ResponsesMessageTypeWebSearchCall { + continue + } + sawDone = true + if e.Item.Status == nil || *e.Item.Status != "completed" { + t.Errorf("done item status = %v, want completed", e.Item.Status) + } + } + if !sawDone { + t.Fatal("expected an output_item.done for the web_search_call, got none") + } + + var sawInCompleted bool + for _, r := range emitted { + if r.Type != schemas.ResponsesStreamResponseTypeCompleted || r.Response == nil { + continue + } + for _, out := range r.Response.Output { + if out.Type != nil && *out.Type == schemas.ResponsesMessageTypeWebSearchCall { + sawInCompleted = true + if out.Status == nil || *out.Status != "completed" { + t.Errorf("web_search_call in response.completed has status = %v, want completed", out.Status) + } + // The persisted item must carry the finalized action (query + sources), + // not just a bare type/status — a partial-persistence regression could + // otherwise leave a "completed" item with an empty action. + tm := out.ResponsesToolMessage + if tm == nil || tm.Action == nil || tm.Action.ResponsesWebSearchToolCallAction == nil { + t.Fatal("web_search_call in response.completed is missing its Action") + } + action := tm.Action.ResponsesWebSearchToolCallAction + if action.Query == nil || *action.Query != "anthropic founding year" { + t.Errorf("persisted action.Query = %v, want %q", action.Query, "anthropic founding year") + } + if len(action.Sources) != 1 || action.Sources[0].URL != "https://example.com/anthropic" { + t.Errorf("persisted action.Sources = %v, want one source with the result URL", action.Sources) + } + } + } + } + if !sawInCompleted { + t.Error("expected the web_search_call to be present in response.completed's Output array, got none — it would be missing from the final response") + } +} + +// multiWebSearchStreamEvents reproduces the same all-calls-before-results ordering +// hazard fixed for tool_search (#4780): three web_search calls are all streamed +// before any of their results arrive. +var multiWebSearchStreamEvents = []string{ + `{"type":"message_start","message":{"model":"claude-opus-4-8","id":"msg_multiws1","type":"message","role":"assistant","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_wsa","name":"web_search","input":{"query":"query a"}}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"server_tool_use","id":"srvtoolu_wsb","name":"web_search","input":{"query":"query b"}}}`, + `{"type":"content_block_stop","index":1}`, + `{"type":"content_block_start","index":2,"content_block":{"type":"server_tool_use","id":"srvtoolu_wsc","name":"web_search","input":{"query":"query c"}}}`, + `{"type":"content_block_stop","index":2}`, + `{"type":"content_block_start","index":3,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_wsa","content":[{"type":"web_search_result","url":"https://example.com/a","title":"A"}]}}`, + `{"type":"content_block_stop","index":3}`, + `{"type":"content_block_start","index":4,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_wsb","content":[{"type":"web_search_result","url":"https://example.com/b","title":"B"}]}}`, + `{"type":"content_block_stop","index":4}`, + `{"type":"content_block_start","index":5,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_wsc","content":[{"type":"web_search_result","url":"https://example.com/c","title":"C"}]}}`, + `{"type":"content_block_stop","index":5}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}`, + `{"type":"message_stop"}`, +} + +// TestWebSearch_MultipleCallsInOneTurn is a regression test for the same +// single-slot overwrite bug class fixed for tool_search: a turn with multiple +// web_search calls, all streamed before any result arrives, must not silently +// drop all but the last call. +func TestWebSearch_MultipleCallsInOneTurn(t *testing.T) { + ctx := schemas.NewBifrostContext(nil, time.Time{}) + state := AcquireAnthropicResponsesStreamState() + defer ReleaseAnthropicResponsesStreamState(state) + + var emitted []*schemas.BifrostResponsesStreamResponse + seq := 0 + for _, raw := range multiWebSearchStreamEvents { + var chunk AnthropicStreamEvent + if err := sonic.Unmarshal([]byte(raw), &chunk); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + responses, bErr, _ := chunk.ToBifrostResponsesStream(ctx, seq, state) + if bErr != nil { + t.Fatalf("ToBifrostResponsesStream error: %v", bErr) + } + for _, r := range responses { + seq++ + emitted = append(emitted, r) + } + } + + completedQueries := map[string]string{} // call_id -> query + for _, e := range emitted { + if e.Type != schemas.ResponsesStreamResponseTypeOutputItemDone { + continue + } + if e.Item == nil || e.Item.Type == nil || *e.Item.Type != schemas.ResponsesMessageTypeWebSearchCall { + continue + } + if e.Item.ResponsesToolMessage == nil || e.Item.ResponsesToolMessage.CallID == nil { + continue + } + if e.Item.Status == nil || *e.Item.Status != "completed" { + t.Errorf("call %s: status = %v, want completed", *e.Item.ResponsesToolMessage.CallID, e.Item.Status) + continue + } + action := e.Item.ResponsesToolMessage.Action + if action == nil || action.ResponsesWebSearchToolCallAction == nil || action.ResponsesWebSearchToolCallAction.Query == nil { + t.Errorf("call %s: missing query in completed action", *e.Item.ResponsesToolMessage.CallID) + continue + } + completedQueries[*e.Item.ResponsesToolMessage.CallID] = *action.ResponsesWebSearchToolCallAction.Query + } + + for callID, wantQuery := range map[string]string{ + "srvtoolu_wsa": "query a", + "srvtoolu_wsb": "query b", + "srvtoolu_wsc": "query c", + } { + got, ok := completedQueries[callID] + if !ok { + t.Errorf("call %s: never completed — stuck in_progress forever (all-calls-before-results ordering not handled)", callID) + continue + } + if got != wantQuery { + t.Errorf("call %s: query = %q, want %q", callID, got, wantQuery) + } + } + + // Every one of the three calls must also appear completed in response.completed's + // Output array — not just in the individual output_item.done stream events. + completedInFinal := map[string]bool{} + for _, r := range emitted { + if r.Type != schemas.ResponsesStreamResponseTypeCompleted || r.Response == nil { + continue + } + for _, out := range r.Response.Output { + if out.Type == nil || *out.Type != schemas.ResponsesMessageTypeWebSearchCall { + continue + } + if out.Status != nil && *out.Status == "completed" && out.ResponsesToolMessage != nil && out.ResponsesToolMessage.CallID != nil { + completedInFinal[*out.ResponsesToolMessage.CallID] = true + } + } + } + for _, callID := range []string{"srvtoolu_wsa", "srvtoolu_wsb", "srvtoolu_wsc"} { + if !completedInFinal[callID] { + t.Errorf("call %s: not present as completed in response.completed's Output array", callID) + } + } +} + +// streamedQueryWebSearchStreamEvents reproduces the rare case where the +// server_tool_use block arrives with empty input and the query streams in via +// input_json_delta events instead — exercises the WebSearchCallIndices +// fallback-capture path on content_block_stop, not just the common +// pre-populated-input case covered by webSearchStreamEvents above. +var streamedQueryWebSearchStreamEvents = []string{ + `{"type":"message_start","message":{"model":"claude-opus-4-8","id":"msg_ws2","type":"message","role":"assistant","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_ws2","name":"web_search","input":{}}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\": \"streamed"}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" query\"}"}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_ws2","content":[{"type":"web_search_result","url":"https://example.com/streamed","title":"Streamed"}]}}`, + `{"type":"content_block_stop","index":1}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}`, + `{"type":"message_stop"}`, +} + +// TestWebSearch_QueryCapturedFromStreamedInputJSON verifies the fallback path: +// when a web_search server_tool_use block arrives with empty input and the +// query only appears via input_json_delta events, WebSearchCallIndices lets +// content_block_stop resolve the right pendingWebSearch entry and capture the +// query — this is the branch TestWebSearch_MultipleCallsInOneTurn (which uses +// pre-populated input) does not exercise. +func TestWebSearch_QueryCapturedFromStreamedInputJSON(t *testing.T) { + ctx := schemas.NewBifrostContext(nil, time.Time{}) + state := AcquireAnthropicResponsesStreamState() + defer ReleaseAnthropicResponsesStreamState(state) + + var emitted []*schemas.BifrostResponsesStreamResponse + seq := 0 + for _, raw := range streamedQueryWebSearchStreamEvents { + var chunk AnthropicStreamEvent + if err := sonic.Unmarshal([]byte(raw), &chunk); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + responses, bErr, _ := chunk.ToBifrostResponsesStream(ctx, seq, state) + if bErr != nil { + t.Fatalf("ToBifrostResponsesStream error: %v", bErr) + } + for _, r := range responses { + seq++ + emitted = append(emitted, r) + } + } + + var gotQuery *string + for _, e := range emitted { + if e.Type != schemas.ResponsesStreamResponseTypeOutputItemDone { + continue + } + if e.Item == nil || e.Item.Type == nil || *e.Item.Type != schemas.ResponsesMessageTypeWebSearchCall { + continue + } + if e.Item.ResponsesToolMessage != nil && e.Item.ResponsesToolMessage.Action != nil && + e.Item.ResponsesToolMessage.Action.ResponsesWebSearchToolCallAction != nil { + gotQuery = e.Item.ResponsesToolMessage.Action.ResponsesWebSearchToolCallAction.Query + } + } + if gotQuery == nil || *gotQuery != "streamed query" { + t.Errorf("query = %v, want %q — the streamed-input_json fallback capture did not run", gotQuery, "streamed query") + } +} diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 72250528a6f..02f115ba7aa 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -1088,6 +1088,16 @@ const ( ResponsesMessageTypeToolSearchOutput ResponsesMessageType = "tool_search_output" ResponsesMessageTypeAdditionalTools ResponsesMessageType = "additional_tools" ResponsesMessageTypeAdvisorCall ResponsesMessageType = "advisor_call" // Anthropic advisor server tool (server_tool_use + advisor_tool_result) + + // ResponsesMessageTypeAnthropicToolSearchCall is Anthropic's server-run tool_search + // (tool_search_tool_bm25 / tool_search_tool_regex + tool_search_tool_result). + // Deliberately distinct from ResponsesMessageTypeToolSearchCall/ToolSearchOutput + // above, which are Codex/OpenAI's client-executed tool_search meta-tool (different + // shape: arguments is a search query object, execution:"client", no server-side + // results). Reusing that type string would collide with isToolSearchItem's raw-byte + // preservation path and misrepresent Anthropic's server-completed search as a + // client-side call to any Responses API consumer. + ResponsesMessageTypeAnthropicToolSearchCall ResponsesMessageType = "tool_search_tool_call" ) // ResponsesMessage is a union type that can contain different types of input items @@ -1423,9 +1433,6 @@ type ResponsesToolMessage struct { // Anthropic advisor-specific (advisor_call): carries the advisor_tool_result payload *ResponsesAdvisorCall - // Anthropic tool_search-specific (tool_search_call): carries the discovered tool references - *ResponsesToolSearchCall - // Anthropic web-fetch-specific (web_fetch_call): carries the web_fetch_tool_result payload *ResponsesWebFetchCall @@ -1445,14 +1452,6 @@ type ResponsesAdvisorCall struct { StopReason *string `json:"advisor_stop_reason,omitempty"` // present when max_tokens is set on the tool } -// ResponsesToolSearchCall carries the payload of an Anthropic server-side -// tool_search (server_tool_use + tool_search_tool_result). ToolReferences holds -// the names of the deferred tools the search discovered (from the result block's -// tool_references); the model then emits a normal tool_use to call one of them. -type ResponsesToolSearchCall struct { - ToolReferences []string `json:"tool_references,omitempty"` // names of discovered (deferred) tools -} - // ResponsesWebFetchCall carries the Anthropic web_fetch_tool_result payload // alongside a web_fetch_call. Anthropic-only; the request URL lives on // ResponsesWebFetchToolCallAction. From b0c853094ecf90ad1e40e4859342bb5163fc3975 Mon Sep 17 00:00:00 2001 From: Shaik-Sirajuddin Date: Mon, 6 Jul 2026 13:39:29 +0530 Subject: [PATCH 04/11] feat: bridge Anthropic tool_search across OpenAI-shaped Responses API sessions Adds a namespace-based bridge so a caller declaring tool_search via the OpenAI Responses API surface can reach Anthropic's native tool_search_tool_bm25/ _regex sub-tools, and converts completed Anthropic-origin tool_search results into OpenAI's native tool_search_call/tool_search_output shape when a conversation's backend switches mid-session. Also fixes two schema compatibility bugs found while proving this live: tool_search_tool_result's nested content.tool_references shape was modeled flat and silently dropped every discovered tool, and defer_loading was incorrectly stripped as Anthropic-only when it's also required by OpenAI's own tool_search feature. Co-Authored-By: Claude Sonnet 5 --- core/providers/anthropic/responses.go | 75 +++- ...earch_namespace_bridge_integration_test.go | 205 ++++++++++ .../anthropic/toolsearch_roundtrip_test.go | 6 +- core/providers/anthropic/toolsearch_test.go | 8 +- core/providers/anthropic/types.go | 22 +- core/providers/openai/responses.go | 35 ++ .../openai/responses_marshal_test.go | 17 +- .../openai/tool_search_anthropic_bridge.go | 88 +++++ .../tool_search_anthropic_bridge_test.go | 163 ++++++++ ...h_namespace_bridge_provider_switch_test.go | 83 ++++ .../tool_search_native_declaration_test.go | 70 ++++ core/providers/openai/types.go | 27 +- core/schemas/tool_search_namespace_bridge.go | 327 +++++++++++++++ .../tool_search_namespace_bridge_test.go | 373 ++++++++++++++++++ core/schemas/tool_search_openai_native.go | 114 ++++++ 15 files changed, 1576 insertions(+), 37 deletions(-) create mode 100644 core/providers/anthropic/tool_search_namespace_bridge_integration_test.go create mode 100644 core/providers/openai/tool_search_anthropic_bridge.go create mode 100644 core/providers/openai/tool_search_anthropic_bridge_test.go create mode 100644 core/providers/openai/tool_search_namespace_bridge_provider_switch_test.go create mode 100644 core/providers/openai/tool_search_native_declaration_test.go create mode 100644 core/schemas/tool_search_namespace_bridge.go create mode 100644 core/schemas/tool_search_namespace_bridge_test.go create mode 100644 core/schemas/tool_search_openai_native.go diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index b6ac767f50d..8c4a728acf0 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -1801,13 +1801,11 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, outputIndexForCall, hasOutputIndex := state.ToolSearchOutputIndices[toolUseID] var toolNames []string - if result != nil && result.ToolReferences != nil { - for _, ref := range result.ToolReferences { - if ref.ToolName != nil { - toolNames = append(toolNames, *ref.ToolName) - } else if ref.Name != nil { - toolNames = append(toolNames, *ref.Name) - } + for _, ref := range toolSearchResultReferences(result) { + if ref.ToolName != nil { + toolNames = append(toolNames, *ref.ToolName) + } else if ref.Name != nil { + toolNames = append(toolNames, *ref.Name) } } if toolNames == nil { @@ -3810,7 +3808,14 @@ func ToAnthropicResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schema // Convert tools if bifrostReq.Params.Tools != nil { - anthropicTools, mcpServers := convertBifrostToolsToAnthropic(capModel, bifrostReq.Params.Tools, bifrostReq.Provider) + // Resolve Bifrost's own caller-facing tool_search bridge (a + // "namespace" tool declaration reserved for surfacing Anthropic's + // bm25/regex algorithm choice to an OpenAI-Responses-shaped + // caller) into the two native tool_search declarations Anthropic + // expects, before the per-tool egress switch below. No-op (same + // slice returned) unless the reserved bridge namespace is present. + toolsForAnthropic, _ := schemas.ExpandToolSearchBridgeDeclaration(bifrostReq.Params.Tools) + anthropicTools, mcpServers := convertBifrostToolsToAnthropic(capModel, toolsForAnthropic, bifrostReq.Provider) if len(anthropicTools) > 0 { if anthropicReq.Tools == nil { anthropicReq.Tools = anthropicTools @@ -3833,7 +3838,13 @@ func ToAnthropicResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schema } if bifrostReq.Input != nil { - anthropicMessages, systemContent := ConvertBifrostMessagesToAnthropicMessages(ctx, bifrostReq.Input, true, bifrostReq.Provider, capModel) + // Merge any namespace-tagged tool_search bridge call/output pairs + // (Bifrost's own caller-facing convention) back into the neutral + // tool_search_tool_call item the egress switch below already knows + // how to render onto Anthropic's server_tool_use + tool_search_tool_result + // shape. No-op unless the reserved bridge namespace is present. + inputForAnthropic := schemas.ExpandToolSearchBridgeItems(bifrostReq.Input) + anthropicMessages, systemContent := ConvertBifrostMessagesToAnthropicMessages(ctx, inputForAnthropic, true, bifrostReq.Provider, capModel) // Set system message if present if systemContent != nil { @@ -5696,7 +5707,7 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex continue } var toolNames []string - for _, ref := range block.ToolReferences { + for _, ref := range toolSearchResultReferences(&block) { if ref.ToolName != nil { toolNames = append(toolNames, *ref.ToolName) } else if ref.Name != nil { @@ -6592,15 +6603,51 @@ func convertBifrostToolSearchCallToAnthropicBlocks(msg *schemas.ResponsesMessage }) } resultBlock := AnthropicContentBlock{ - Type: AnthropicContentBlockTypeToolSearchToolResult, - ToolUseID: toolUseID, - Caller: caller, - ToolReferences: toolReferences, + Type: AnthropicContentBlockTypeToolSearchToolResult, + ToolUseID: toolUseID, + Caller: caller, + Content: buildToolSearchResultContent(toolReferences), } return []AnthropicContentBlock{serverToolUseBlock, resultBlock} } +// toolSearchResultReferences extracts the discovered tool_references from a +// tool_search_tool_result block, handling Anthropic's nesting: unlike every +// other *_tool_result block type, tool_search_tool_result carries its result +// fields one level deeper, under content.tool_references (content.type == +// "tool_search_tool_search_result"), not directly on the block. Checks +// Content.ContentObj first (the egress/construction shape), then +// Content.ContentBlocks[0] (the post-unmarshal shape, since AnthropicContent's +// UnmarshalJSON decodes a bare JSON object into a one-element ContentBlocks +// slice rather than ContentObj). Returns nil if block or its content is nil, +// or if the search matched nothing (Anthropic returns an empty array, not a +// missing field, for a genuine no-match result). +func toolSearchResultReferences(block *AnthropicContentBlock) []AnthropicContentBlock { + if block == nil || block.Content == nil { + return nil + } + if inner := block.Content.ContentObj; inner != nil { + return inner.ToolReferences + } + if len(block.Content.ContentBlocks) > 0 { + return block.Content.ContentBlocks[0].ToolReferences + } + return nil +} + +// buildToolSearchResultContent wraps discovered tool references in the +// content.tool_search_tool_search_result nesting Anthropic's wire format +// requires for tool_search_tool_result (see toolSearchResultReferences). +func buildToolSearchResultContent(toolReferences []AnthropicContentBlock) *AnthropicContent { + return &AnthropicContent{ + ContentObj: &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolSearchToolSearchResult, + ToolReferences: toolReferences, + }, + } +} + // isAnthropicCodeExecutionToolName reports whether name is one of the code // execution sub-tools (code_execution_20250825+ surfaces bash + text_editor; // code_execution is the legacy Python sub-tool). diff --git a/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go b/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go new file mode 100644 index 00000000000..634ca797fd3 --- /dev/null +++ b/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go @@ -0,0 +1,205 @@ +package anthropic + +import ( + "strings" + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestToolSearchNamespaceBridge_DeclarationEgress verifies that an OpenAI +// Responses-shaped caller declaring Bifrost's reserved tool_search bridge +// namespace produces real Anthropic tool_search_tool_bm25/_regex tool +// declarations when the request is egressed to an Anthropic backend -- +// exercising the wiring added to ToAnthropicResponsesRequest. +func TestToolSearchNamespaceBridge_DeclarationEgress(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-opus-4-8", + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr("get_weather")}, + { + Type: schemas.ResponsesToolTypeNamespace, + Name: schemas.Ptr(schemas.ToolSearchBridgeNamespaceID), + ResponsesToolNamespace: &schemas.ResponsesToolNamespace{ + Tools: []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr(schemas.ToolSearchBridgeFuncBM25)}, + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr(schemas.ToolSearchBridgeFuncRegex)}, + }, + }, + }, + }, + }, + } + + anthropicReq, err := ToAnthropicResponsesRequest(nil, bifrostReq) + if err != nil { + t.Fatalf("ToAnthropicResponsesRequest: %v", err) + } + + var sawBM25, sawRegex, sawFunction, sawNamespaceLeak bool + for _, tool := range anthropicReq.Tools { + switch { + case tool.Type != nil && strings.Contains(string(*tool.Type), "tool_search_tool_bm25"): + sawBM25 = true + case tool.Type != nil && strings.Contains(string(*tool.Type), "tool_search_tool_regex"): + sawRegex = true + case tool.Name == "get_weather": + sawFunction = true + } + // The synthetic bridge namespace must never leak onto the Anthropic + // wire -- Anthropic has no "namespace" tool concept at all. + if tool.Type != nil && strings.Contains(string(*tool.Type), "namespace") { + sawNamespaceLeak = true + } + } + + if !sawBM25 { + t.Errorf("expected a native tool_search_tool_bm25 declaration, got tools=%+v", anthropicReq.Tools) + } + if !sawRegex { + t.Errorf("expected a native tool_search_tool_regex declaration, got tools=%+v", anthropicReq.Tools) + } + if !sawFunction { + t.Errorf("unrelated function tool must survive, got tools=%+v", anthropicReq.Tools) + } + if sawNamespaceLeak { + t.Errorf("bridge namespace type string must never reach the Anthropic wire, got tools=%+v", anthropicReq.Tools) + } +} + +// TestToolSearchNamespaceBridge_ItemEgress verifies that a namespace-tagged +// function_call/function_call_output pair (Bifrost's own caller-facing +// representation of a completed tool_search bridge call) in prior-turn +// history egresses to Anthropic's native server_tool_use + +// tool_search_tool_result block pair. +func TestToolSearchNamespaceBridge_ItemEgress(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-opus-4-8", + Input: []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("find a weather tool")}, + }, + { + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + ID: schemas.Ptr("srvtoolu_bridge1"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: schemas.Ptr("srvtoolu_bridge1"), + Name: schemas.Ptr(schemas.ToolSearchBridgeFuncRegex), + Namespace: schemas.Ptr(schemas.ToolSearchBridgeNamespaceID), + Arguments: schemas.Ptr(`{"query":"^GET .*"}`), + }, + }, + { + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: schemas.Ptr("srvtoolu_bridge1"), + Namespace: schemas.Ptr(schemas.ToolSearchBridgeNamespaceID), + Output: &schemas.ResponsesToolMessageOutputStruct{ + ResponsesToolCallOutputStr: schemas.Ptr(`["get_request"]`), + }, + }, + }, + }, + } + + ctx := schemas.NewBifrostContext(nil, time.Time{}) + anthropicReq, err := ToAnthropicResponsesRequest(ctx, bifrostReq) + if err != nil { + t.Fatalf("ToAnthropicResponsesRequest: %v", err) + } + + var sawServerToolUse, sawResult bool + for _, msg := range anthropicReq.Messages { + for _, block := range msg.Content.ContentBlocks { + switch block.Type { + case AnthropicContentBlockTypeServerToolUse: + if block.Name == nil || *block.Name != "tool_search_tool_regex" { + t.Errorf("expected regex sub-tool name preserved through the bridge, got %v", block.Name) + } + if block.ID == nil || *block.ID != "srvtoolu_bridge1" { + t.Errorf("expected call id preserved, got %v", block.ID) + } + sawServerToolUse = true + case AnthropicContentBlockTypeToolSearchToolResult: + if block.ToolUseID == nil || *block.ToolUseID != "srvtoolu_bridge1" { + t.Errorf("expected tool_use_id preserved, got %v", block.ToolUseID) + } + var names []string + for _, ref := range toolSearchResultReferences(&block) { + if ref.ToolName != nil { + names = append(names, *ref.ToolName) + } + } + if len(names) != 1 || names[0] != "get_request" { + t.Errorf("expected discovered tool name to survive the bridge, got %v", names) + } + sawResult = true + } + } + } + if !sawServerToolUse { + t.Error("expected a reconstructed server_tool_use block from the namespace bridge call, got none") + } + if !sawResult { + t.Error("expected a reconstructed tool_search_tool_result block from the namespace bridge output, got none") + } +} + +// TestToolSearchNamespaceBridge_ProviderSwitchNoAnthropicLeakage is the +// explicit provider-switch regression: the same bifrostReq.Params.Tools slice +// (still in its original namespace-bridge shape -- the Anthropic-egress +// expansion in ToAnthropicResponsesRequest operates on a derived copy and +// must never mutate the shared request) must contain zero Anthropic-native +// type strings when that same slice is what a caller/second dispatch attempt +// would see if routed to OpenAI instead. +func TestToolSearchNamespaceBridge_ProviderSwitchNoAnthropicLeakage(t *testing.T) { + tools := []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr("get_weather")}, + { + Type: schemas.ResponsesToolTypeNamespace, + Name: schemas.Ptr(schemas.ToolSearchBridgeNamespaceID), + ResponsesToolNamespace: &schemas.ResponsesToolNamespace{ + Tools: []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr(schemas.ToolSearchBridgeFuncBM25)}, + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr(schemas.ToolSearchBridgeFuncRegex)}, + }, + }, + }, + } + + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-opus-4-8", + Params: &schemas.ResponsesParameters{Tools: tools}, + } + + // Dispatch to Anthropic once (this is what mutates/derives a copy inside + // ToAnthropicResponsesRequest). + if _, err := ToAnthropicResponsesRequest(nil, bifrostReq); err != nil { + t.Fatalf("ToAnthropicResponsesRequest: %v", err) + } + + // The original slice referenced by bifrostReq.Params.Tools -- which a + // fallback/second attempt against a different backend (e.g. OpenAI) would + // read from -- must be completely unaffected: still exactly 2 entries, + // still the bridge namespace shape, no tool_search_tool_bm25/_regex + // entries spliced in. + if len(bifrostReq.Params.Tools) != 2 { + t.Fatalf("Anthropic egress must not mutate the shared request's tools slice, got %d entries: %+v", + len(bifrostReq.Params.Tools), bifrostReq.Params.Tools) + } + for _, tool := range bifrostReq.Params.Tools { + if tool.Type == schemas.ResponsesToolTypeToolSearch { + t.Fatalf("Anthropic-native tool_search declaration leaked back into the shared request meant for other backends: %+v", tool) + } + } + if bifrostReq.Params.Tools[1].Name == nil || !schemas.IsToolSearchBridgeNamespace(bifrostReq.Params.Tools[1].Name) { + t.Fatalf("bridge namespace declaration must still be present, untouched, for a subsequent OpenAI-backend dispatch: %+v", + bifrostReq.Params.Tools[1]) + } +} diff --git a/core/providers/anthropic/toolsearch_roundtrip_test.go b/core/providers/anthropic/toolsearch_roundtrip_test.go index 91359706f7f..e0a1281f7e8 100644 --- a/core/providers/anthropic/toolsearch_roundtrip_test.go +++ b/core/providers/anthropic/toolsearch_roundtrip_test.go @@ -22,7 +22,7 @@ const rawToolSearchNonStreamingResponse = `{ "role": "assistant", "content": [ { "type": "server_tool_use", "id": "srvtoolu_ns1", "name": "tool_search_tool_bm25", "input": {"query": "weather"} }, - { "type": "tool_search_tool_result", "tool_use_id": "srvtoolu_ns1", "tool_references": [{"type": "tool_reference", "tool_name": "get_weather"}, {"type": "tool_reference", "tool_name": "get_forecast"}] }, + { "type": "tool_search_tool_result", "tool_use_id": "srvtoolu_ns1", "content": {"type": "tool_search_tool_search_result", "tool_references": [{"type": "tool_reference", "tool_name": "get_weather"}, {"type": "tool_reference", "tool_name": "get_forecast"}]} }, { "type": "text", "text": "I found the get_weather tool." } ], "stop_reason": "end_turn", @@ -116,7 +116,7 @@ func TestToolSearch_NonStreamingIngestAndEgressRoundTrip(t *testing.T) { t.Errorf("reconstructed tool_search_tool_result tool_use_id = %v, want srvtoolu_ns1", block.ToolUseID) } var names []string - for _, ref := range block.ToolReferences { + for _, ref := range toolSearchResultReferences(&block) { if ref.ToolName != nil { names = append(names, *ref.ToolName) } @@ -148,7 +148,7 @@ const rawToolSearchWithCallerResponse = `{ "role": "assistant", "content": [ { "type": "server_tool_use", "id": "srvtoolu_caller1", "name": "tool_search_tool_bm25", "input": {"query": "weather"}, "caller": {"type": "code_execution_20250825", "tool_id": "srvtoolu_codeexec1"} }, - { "type": "tool_search_tool_result", "tool_use_id": "srvtoolu_caller1", "tool_references": [{"type": "tool_reference", "tool_name": "get_weather"}] } + { "type": "tool_search_tool_result", "tool_use_id": "srvtoolu_caller1", "content": {"type": "tool_search_tool_search_result", "tool_references": [{"type": "tool_reference", "tool_name": "get_weather"}]} } ], "stop_reason": "end_turn", "usage": { "input_tokens": 20, "output_tokens": 10 } diff --git a/core/providers/anthropic/toolsearch_test.go b/core/providers/anthropic/toolsearch_test.go index 2811d045b9c..b24ec97b4fd 100644 --- a/core/providers/anthropic/toolsearch_test.go +++ b/core/providers/anthropic/toolsearch_test.go @@ -21,7 +21,7 @@ var toolSearchStreamEvents = []string{ `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\": \"weather"}}`, `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}}`, `{"type":"content_block_stop","index":0}`, - `{"type":"content_block_start","index":1,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_ts1","tool_references":[{"type":"tool_reference","tool_name":"get_weather"},{"type":"tool_reference","tool_name":"get_forecast"}]}}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_ts1","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":"get_weather"},{"type":"tool_reference","tool_name":"get_forecast"}]}}}`, `{"type":"content_block_stop","index":1}`, `{"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}}`, `{"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"I found the get_weather tool."}}`, @@ -126,11 +126,11 @@ var multiToolSearchStreamEvents = []string{ `{"type":"content_block_stop","index":1}`, `{"type":"content_block_start","index":2,"content_block":{"type":"server_tool_use","id":"srvtoolu_c","name":"tool_search_tool_bm25","input":{}}}`, `{"type":"content_block_stop","index":2}`, - `{"type":"content_block_start","index":3,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_a","tool_references":[{"type":"tool_reference","tool_name":"tool_a"}]}}`, + `{"type":"content_block_start","index":3,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_a","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":"tool_a"}]}}}`, `{"type":"content_block_stop","index":3}`, - `{"type":"content_block_start","index":4,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_b","tool_references":[{"type":"tool_reference","tool_name":"tool_b"}]}}`, + `{"type":"content_block_start","index":4,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_b","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":"tool_b"}]}}}`, `{"type":"content_block_stop","index":4}`, - `{"type":"content_block_start","index":5,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_c","tool_references":[{"type":"tool_reference","tool_name":"tool_c"}]}}`, + `{"type":"content_block_start","index":5,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_c","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":"tool_c"}]}}}`, `{"type":"content_block_stop","index":5}`, `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}`, `{"type":"message_stop"}`, diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go index 05cb68ebac4..d6fa460ab4a 100644 --- a/core/providers/anthropic/types.go +++ b/core/providers/anthropic/types.go @@ -928,7 +928,16 @@ const ( AnthropicContentBlockTypeBashCodeExecutionToolResult AnthropicContentBlockType = "bash_code_execution_tool_result" AnthropicContentBlockTypeTextEditorCodeExecutionToolResult AnthropicContentBlockType = "text_editor_code_execution_tool_result" AnthropicContentBlockTypeToolSearchToolResult AnthropicContentBlockType = "tool_search_tool_result" - AnthropicContentBlockTypeToolReference AnthropicContentBlockType = "tool_reference" + // AnthropicContentBlockTypeToolSearchToolSearchResult is the discriminator + // on the object nested under tool_search_tool_result.content — Anthropic + // nests tool_references one level deeper than every other *_tool_result + // block (which puts result fields directly on the block or in a plain + // content array). See https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool. + AnthropicContentBlockTypeToolSearchToolSearchResult AnthropicContentBlockType = "tool_search_tool_search_result" + // AnthropicContentBlockTypeToolSearchToolResultError is the nested + // content.type for a failed tool_search_tool_result (carries ErrorCode/ErrorMessage). + AnthropicContentBlockTypeToolSearchToolResultError AnthropicContentBlockType = "tool_search_tool_result_error" + AnthropicContentBlockTypeToolReference AnthropicContentBlockType = "tool_reference" AnthropicContentBlockTypeContainerUpload AnthropicContentBlockType = "container_upload" AnthropicContentBlockTypeAdvisorToolResult AnthropicContentBlockType = "advisor_tool_result" AnthropicContentBlockTypeMCPToolUse AnthropicContentBlockType = "mcp_tool_use" @@ -1028,7 +1037,16 @@ type AnthropicContentBlock struct { Lines []string `json:"lines,omitempty"` // str_replace_result ErrorMessage *string `json:"error_message,omitempty"` // text_editor error variant - // tool_search_tool_result success variant + // tool_search_tool_result success variant. IMPORTANT: on the wire this + // field lives on the NESTED object under tool_search_tool_result.content + // (content.type == "tool_search_tool_search_result"), not on the outer + // tool_search_tool_result block itself — unlike every other *_tool_result + // block type in this struct, which puts its result fields directly on the + // block. Use the outer block's existing Content (*AnthropicContent) field + // to reach it: Content.ContentObj.ToolReferences (egress) or + // Content.ContentBlocks[0].ToolReferences (post-unmarshal, since a bare + // object decodes into ContentBlocks — see AnthropicContent.UnmarshalJSON). + // Use toolSearchResultReferences(block) to read this safely from either shape. ToolReferences []AnthropicContentBlock `json:"tool_references,omitempty"` // tool_search_tool_search_result (array of tool_reference blocks) // tool_reference block — tool_name field on the block itself diff --git a/core/providers/openai/responses.go b/core/providers/openai/responses.go index fb97c8c3c4c..ec4e6b75845 100644 --- a/core/providers/openai/responses.go +++ b/core/providers/openai/responses.go @@ -49,6 +49,20 @@ func ToOpenAIResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.B // OpenAI also doesn't support compaction content blocks, so we need to convert them to text blocks messages = make([]schemas.ResponsesMessage, 0, len(bifrostReq.Input)) for _, message := range bifrostReq.Input { + // Anthropic-origin tool_search_tool_call history items (a completed + // tool_search_tool_bm25/_regex round trip) are not valid on OpenAI's + // wire -- convert to OpenAI's own native tool_search_call + + // tool_search_output pair before this item ever reaches the rest of + // the loop below. See convertAnthropicToolSearchCallToOpenAINative. + if message.Type != nil && *message.Type == schemas.ResponsesMessageTypeAnthropicToolSearchCall { + var requestTools []schemas.ResponsesTool + if bifrostReq.Params != nil { + requestTools = bifrostReq.Params.Tools + } + messages = append(messages, convertAnthropicToolSearchCallToOpenAINative(message, requestTools)...) + continue + } + // First, check if message has compaction content blocks and convert them to text if message.Content != nil && len(message.Content.ContentBlocks) > 0 { hasCompaction := false @@ -360,6 +374,7 @@ func (resp *OpenAIResponsesRequest) filterUnsupportedTools() { // Filter tools to only include supported types filteredTools := make([]schemas.ResponsesTool, 0, len(resp.Tools)) + seenToolSearch := false for _, tool := range resp.Tools { // OpenRouter exposes server-side tools under the "openrouter:" namespace // (web_search, web_fetch, datetime, image_generation, apply_patch, subagent, ...). @@ -419,6 +434,26 @@ func (resp *OpenAIResponsesRequest) filterUnsupportedTools() { newTool.ResponsesToolWebSearch = newWebSearch filteredTools = append(filteredTools, newTool) + } else if tool.Type == schemas.ResponsesToolTypeToolSearch { + // OpenAI's real tool_search declaration is a single, unnamed, + // algorithm-agnostic tool ({"type":"tool_search",...}) -- + // unlike Anthropic, which has two distinctly-named sub-tools + // (tool_search_tool_bm25/_regex, carried in the neutral + // Name field). Forwarding Name verbatim produces + // {"type":"tool_search","name":"tool_search_tool_bm25"}, + // which a real OpenAI-compatible backend rejects outright + // ("Unknown parameter: 'tools[N].name'") -- confirmed live. + // Collapse: strip Name, and since bm25+regex both collapse + // to the same generic declaration, keep only the first + // occurrence so a caller declaring both doesn't produce two + // duplicate {"type":"tool_search"} entries. + if seenToolSearch { + continue + } + seenToolSearch = true + newTool := tool + newTool.Name = nil + filteredTools = append(filteredTools, newTool) } else { filteredTools = append(filteredTools, tool) } diff --git a/core/providers/openai/responses_marshal_test.go b/core/providers/openai/responses_marshal_test.go index 7af020f57f3..a4572ef43fe 100644 --- a/core/providers/openai/responses_marshal_test.go +++ b/core/providers/openai/responses_marshal_test.go @@ -755,10 +755,13 @@ func TestOpenAIResponsesRequestInput_MarshalJSON_FunctionCallOutputPreservesNonT } // TestOpenAIResponsesRequest_MarshalJSON_StripsAnthropicToolFlags ensures the -// Responses serializer drops the four Anthropic-native tool flags -// (defer_loading, allowed_callers, input_examples, eager_input_streaming) -// along with CacheControl before forwarding to OpenAI — mirroring the Chat -// path's behavior so Anthropic-flavored tools cannot 400 OpenAI via Responses. +// Responses serializer drops the Anthropic-only tool flags (allowed_callers, +// input_examples, eager_input_streaming) along with CacheControl before +// forwarding to OpenAI — mirroring the Chat path's behavior so Anthropic-flavored +// tools cannot 400 OpenAI via Responses. defer_loading is preserved: it is also +// a genuine, non-beta OpenAI/Codex tool_search feature, not Anthropic-only — +// stripping it unconditionally previously broke every OpenAI-bound tool_search +// request ("requires at least one deferred tool"), found live. func TestOpenAIResponsesRequest_MarshalJSON_StripsAnthropicToolFlags(t *testing.T) { req := &OpenAIResponsesRequest{ Model: "gpt-4o", @@ -804,11 +807,15 @@ func TestOpenAIResponsesRequest_MarshalJSON_StripsAnthropicToolFlags(t *testing. raw := string(jsonBytes) // None of the Anthropic-only tool keys must survive on the wire. - for _, key := range []string{`"cache_control"`, `"defer_loading"`, `"allowed_callers"`, `"input_examples"`, `"eager_input_streaming"`, `"code_execution_version"`} { + for _, key := range []string{`"cache_control"`, `"allowed_callers"`, `"input_examples"`, `"eager_input_streaming"`, `"code_execution_version"`} { if strings.Contains(raw, key) { t.Errorf("OpenAI Responses serializer must strip %s; raw=%s", key, raw) } } + // defer_loading is shared with OpenAI's own tool_search feature and must survive. + if !strings.Contains(raw, `"defer_loading":true`) { + t.Errorf("OpenAI Responses serializer must preserve defer_loading (shared OpenAI feature); raw=%s", raw) + } // Function tool identity should be preserved. if !strings.Contains(raw, `"name":"lookup"`) { t.Errorf("tool identity lost after strip; raw=%s", raw) diff --git a/core/providers/openai/tool_search_anthropic_bridge.go b/core/providers/openai/tool_search_anthropic_bridge.go new file mode 100644 index 00000000000..f9b68baf9b1 --- /dev/null +++ b/core/providers/openai/tool_search_anthropic_bridge.go @@ -0,0 +1,88 @@ +package openai + +import ( + "github.com/bytedance/sonic" + + "github.com/maximhq/bifrost/core/schemas" +) + +// convertAnthropicToolSearchCallToOpenAINative converts a completed +// Anthropic-origin tool_search_tool_call neutral item (produced by ingesting +// a real Anthropic tool_search_tool_bm25/_regex round trip) into OpenAI's own +// native tool_search_call + tool_search_output item pair, for replay when a +// conversation's backend switches to OpenAI. Case #4 in the cross-provider +// tool_search mapping doc: +// memory/anthropicschema/gen/fix-execution/expanded-coverage/tool-search-cross-provider-mapping.md +// +// requestTools is the CURRENT request's tools[] (bifrostReq.Params.Tools) -- +// needed to backfill full function definitions (description/parameters/ +// defer_loading) for each discovered tool, since Anthropic's +// tool_search_tool_result only ever carries a bare tool name (documented +// limitation L2). A discovered name not found in requestTools degrades to a +// name-only definition rather than fabricating fields. +// +// Returns the item unchanged (wrapped in a single-element slice) if it isn't +// an Anthropic tool_search_tool_call item, or if building the native pair +// fails -- never drops an item silently. +func convertAnthropicToolSearchCallToOpenAINative(msg schemas.ResponsesMessage, requestTools []schemas.ResponsesTool) []schemas.ResponsesMessage { + if msg.Type == nil || *msg.Type != schemas.ResponsesMessageTypeAnthropicToolSearchCall || msg.ResponsesToolMessage == nil { + return []schemas.ResponsesMessage{msg} + } + + callID := "" + if msg.ResponsesToolMessage.CallID != nil { + callID = *msg.ResponsesToolMessage.CallID + } else if msg.ID != nil { + callID = *msg.ID + } + + argsJSON := "{}" + if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" { + argsJSON = *msg.ResponsesToolMessage.Arguments + } + + callItem, err := schemas.NewOpenAIToolSearchCallItem(callID, argsJSON) + if err != nil { + return []schemas.ResponsesMessage{msg} + } + + var discoveredNames []string + if msg.ResponsesToolMessage.Output != nil && msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + _ = sonic.Unmarshal([]byte(*msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr), &discoveredNames) + } + + discovered := make([]schemas.OpenAIToolSearchDiscoveredTool, 0, len(discoveredNames)) + for _, name := range discoveredNames { + d := schemas.OpenAIToolSearchDiscoveredTool{Name: name} + if def := findResponsesToolByName(requestTools, name); def != nil { + d.Description = def.Description + d.DeferLoading = def.DeferLoading + if def.ResponsesToolFunction != nil { + d.Parameters = def.ResponsesToolFunction.Parameters + } + } + discovered = append(discovered, d) + } + + outputItem, err := schemas.NewOpenAIToolSearchOutputItem(callID, discovered) + if err != nil { + return []schemas.ResponsesMessage{msg} + } + + return []schemas.ResponsesMessage{callItem, outputItem} +} + +// findResponsesToolByName looks up a declared tool by name in the current +// request's tools[] -- the same-request lookup mechanism backing the L2 +// backfill (no cross-request state needed: Anthropic requires the tool to +// already be declared in this exact request for tool_search to find it at +// all, so its full definition is always in-memory for the duration of the +// request that discovered it). +func findResponsesToolByName(tools []schemas.ResponsesTool, name string) *schemas.ResponsesTool { + for i := range tools { + if tools[i].Name != nil && *tools[i].Name == name { + return &tools[i] + } + } + return nil +} diff --git a/core/providers/openai/tool_search_anthropic_bridge_test.go b/core/providers/openai/tool_search_anthropic_bridge_test.go new file mode 100644 index 00000000000..d085839c701 --- /dev/null +++ b/core/providers/openai/tool_search_anthropic_bridge_test.go @@ -0,0 +1,163 @@ +package openai + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestToolSearchNamespaceBridge_AnthropicOriginReplayedToOpenAI is the case-#4 +// regression test: an Anthropic-origin, already-completed tool_search_tool_call +// history item (produced by a prior turn against Anthropic) must convert to +// OpenAI's own native tool_search_call/tool_search_output shape when the +// conversation's backend switches to OpenAI -- reproducing the live failure +// (400 invalid_value: 'tool_search_tool_call') found by hand against a real +// OpenAI-compatible backend, and proving the fix resolves it. +func TestToolSearchNamespaceBridge_AnthropicOriginReplayedToOpenAI(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.OpenAI, + Model: "gpt-5.4-mini", + Input: []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("find weather tool")}, + }, + { + ID: schemas.Ptr("srvtoolu_1"), + Type: schemas.Ptr(schemas.ResponsesMessageTypeAnthropicToolSearchCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: schemas.Ptr("srvtoolu_1"), + Name: schemas.Ptr("tool_search_tool_bm25"), + Arguments: schemas.Ptr(`{"query":"weather lookup"}`), + Output: &schemas.ResponsesToolMessageOutputStruct{ + ResponsesToolCallOutputStr: schemas.Ptr(`["get_weather"]`), + }, + }, + }, + }, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{ + { + Type: schemas.ResponsesToolTypeFunction, + Name: schemas.Ptr("get_weather"), + Description: schemas.Ptr("Get the current weather for a city"), + DeferLoading: schemas.Ptr(true), + ResponsesToolFunction: &schemas.ResponsesToolFunction{ + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: schemas.NewOrderedMapFromPairs( + schemas.KV("city", map[string]interface{}{"type": "string"}), + ), + Required: []string{"city"}, + }, + }, + }, + }, + }, + } + + openAIReq := ToOpenAIResponsesRequest(nil, bifrostReq) + if openAIReq == nil { + t.Fatal("ToOpenAIResponsesRequest returned nil") + } + + wire, err := openAIReq.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(wire) + + // The Anthropic-flavored type must never reach the OpenAI-bound wire -- + // this is exactly what the real backend rejected with a 400. + if strings.Contains(raw, "tool_search_tool_call") { + t.Fatalf("Anthropic-native tool_search_tool_call leaked into the OpenAI-bound request: %s", raw) + } + + if got := gjson.Get(raw, "input.1.type").String(); got != "tool_search_call" { + t.Fatalf("input[1].type = %q, want tool_search_call; raw=%s", got, raw) + } + if !gjson.Get(raw, "input.1.arguments").IsObject() { + t.Fatalf("tool_search_call.arguments must be a JSON object, got: %s", gjson.Get(raw, "input.1.arguments").Raw) + } + if got := gjson.Get(raw, "input.1.arguments.query").String(); got != "weather lookup" { + t.Errorf("arguments.query = %q, want %q", got, "weather lookup") + } + if got := gjson.Get(raw, "input.1.execution").String(); got != "client" { + t.Errorf("tool_search_call.execution = %q, want client", got) + } + + if got := gjson.Get(raw, "input.2.type").String(); got != "tool_search_output" { + t.Fatalf("input[2].type = %q, want tool_search_output; raw=%s", got, raw) + } + if got := gjson.Get(raw, "input.2.call_id").String(); got != "srvtoolu_1" { + t.Errorf("tool_search_output.call_id = %q, want srvtoolu_1", got) + } + toolsArr := gjson.Get(raw, "input.2.tools") + if !toolsArr.IsArray() || len(toolsArr.Array()) != 1 { + t.Fatalf("expected exactly 1 discovered tool in tools[], got: %s", toolsArr.Raw) + } + discovered := toolsArr.Array()[0] + if got := discovered.Get("name").String(); got != "get_weather" { + t.Errorf("discovered tool name = %q, want get_weather", got) + } + // L2 backfill: description/parameters/defer_loading must be recovered + // from the request's own tools[] declaration, not left name-only. + if got := discovered.Get("description").String(); got != "Get the current weather for a city" { + t.Errorf("discovered tool description not backfilled from request tools[], got %q", got) + } + if !discovered.Get("parameters").Exists() { + t.Error("discovered tool parameters not backfilled from request tools[]") + } + if !discovered.Get("defer_loading").Bool() { + t.Error("discovered tool defer_loading not backfilled as true from request tools[]") + } +} + +// TestToolSearchNamespaceBridge_AnthropicOriginReplay_UnknownToolDegradesNameOnly +// verifies a discovered tool name that ISN'T found in the current request's +// tools[] degrades to a name-only definition instead of erroring or +// fabricating fields (defensive case per the original design doc; shouldn't +// happen under Anthropic's own contract, but must not crash if it does). +func TestToolSearchNamespaceBridge_AnthropicOriginReplay_UnknownToolDegradesNameOnly(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.OpenAI, + Model: "gpt-5.4-mini", + Input: []schemas.ResponsesMessage{ + { + Type: schemas.Ptr(schemas.ResponsesMessageTypeAnthropicToolSearchCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: schemas.Ptr("srvtoolu_2"), + Name: schemas.Ptr("tool_search_tool_regex"), + Arguments: schemas.Ptr(`{"pattern":".*weather.*"}`), + Output: &schemas.ResponsesToolMessageOutputStruct{ + ResponsesToolCallOutputStr: schemas.Ptr(`["mystery_tool"]`), + }, + }, + }, + }, + Params: &schemas.ResponsesParameters{Tools: nil}, + } + + openAIReq := ToOpenAIResponsesRequest(nil, bifrostReq) + wire, err := openAIReq.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(wire) + + discovered := gjson.Get(raw, "input.1.tools.0") + if got := discovered.Get("name").String(); got != "mystery_tool" { + t.Fatalf("discovered tool name = %q, want mystery_tool; raw=%s", got, raw) + } + if discovered.Get("description").Exists() { + t.Error("unknown tool must not fabricate a description") + } + if discovered.Get("parameters").Exists() { + t.Error("unknown tool must not fabricate parameters") + } +} diff --git a/core/providers/openai/tool_search_namespace_bridge_provider_switch_test.go b/core/providers/openai/tool_search_namespace_bridge_provider_switch_test.go new file mode 100644 index 00000000000..d53b45e3c19 --- /dev/null +++ b/core/providers/openai/tool_search_namespace_bridge_provider_switch_test.go @@ -0,0 +1,83 @@ +package openai + +import ( + "strings" + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestToolSearchNamespaceBridge_SwitchToOpenAIBackend is the explicit +// provider-switch check: the same session payload (tools[] still carrying +// Bifrost's reserved tool_search bridge namespace, exactly as an +// OpenAI-Responses-shaped caller would send it) is now routed to an OpenAI +// backend instead of Anthropic. The tools[] OpenAI actually receives must: +// 1. Contain zero Anthropic-native tokens (tool_search_tool_bm25/_regex, +// server_tool_use, tool_search_tool_result) -- nothing Anthropic-specific +// should ever reach an OpenAI-bound request. +// 2. Still contain the namespace/function_search declarations verbatim -- +// OpenAI already natively accepts "namespace" and "function" tool types +// (filterUnsupportedTools keeps both), so no conversion is even needed on +// this path; the model literally never sees anything Anthropic-flavored. +func TestToolSearchNamespaceBridge_SwitchToOpenAIBackend(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.OpenAI, + Model: "gpt-5.2", + Input: []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("find a weather tool")}, + }, + }, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr("get_weather")}, + { + Type: schemas.ResponsesToolTypeNamespace, + Name: schemas.Ptr(schemas.ToolSearchBridgeNamespaceID), + ResponsesToolNamespace: &schemas.ResponsesToolNamespace{ + Tools: []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr(schemas.ToolSearchBridgeFuncBM25)}, + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr(schemas.ToolSearchBridgeFuncRegex)}, + }, + }, + }, + }, + }, + } + + ctx := schemas.NewBifrostContext(nil, time.Time{}) + openAIReq := ToOpenAIResponsesRequest(ctx, bifrostReq) + if openAIReq == nil { + t.Fatal("ToOpenAIResponsesRequest returned nil") + } + + wire, err := openAIReq.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(wire) + + // (1) No Anthropic-native leakage whatsoever. + for _, forbidden := range []string{ + "tool_search_tool_bm25", "tool_search_tool_regex", + "server_tool_use", "tool_search_tool_result", + } { + if strings.Contains(raw, forbidden) { + t.Errorf("Anthropic-native token %q leaked into the OpenAI-bound request: %s", forbidden, raw) + } + } + + // (2) The bridge namespace shape passes straight through, untouched -- + // it's already valid native OpenAI wire format. + for _, want := range []string{ + `"type":"namespace"`, schemas.ToolSearchBridgeNamespaceID, + schemas.ToolSearchBridgeFuncBM25, schemas.ToolSearchBridgeFuncRegex, + "get_weather", + } { + if !strings.Contains(raw, want) { + t.Errorf("expected %q to survive untouched in the OpenAI-bound request, got: %s", want, raw) + } + } +} diff --git a/core/providers/openai/tool_search_native_declaration_test.go b/core/providers/openai/tool_search_native_declaration_test.go new file mode 100644 index 00000000000..d63f5a11f0d --- /dev/null +++ b/core/providers/openai/tool_search_native_declaration_test.go @@ -0,0 +1,70 @@ +package openai + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestToolSearchNativeDeclaration_StripsNameAndDedupesForOpenAI reproduces a +// live failure: a caller declaring tool_search the way Anthropic's own +// tool_search collapses to on ingest -- two neutral entries, +// {"type":"tool_search","name":"tool_search_tool_bm25"} and +// {"type":"tool_search","name":"tool_search_tool_regex"} (see +// core/providers/anthropic/responses.go:6685-6690) -- must not forward the +// "name" field to a real OpenAI-compatible backend, which rejects it +// ("Unknown parameter: 'tools[N].name'"), and must not emit two duplicate +// {"type":"tool_search"} declarations. +func TestToolSearchNativeDeclaration_StripsNameAndDedupesForOpenAI(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.OpenAI, + Model: "gpt-5.4-mini", + Input: []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("hi")}, + }, + }, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr("get_weather")}, + {Type: schemas.ResponsesToolTypeToolSearch, Name: schemas.Ptr("tool_search_tool_bm25")}, + {Type: schemas.ResponsesToolTypeToolSearch, Name: schemas.Ptr("tool_search_tool_regex")}, + }, + }, + } + + openAIReq := ToOpenAIResponsesRequest(nil, bifrostReq) + if openAIReq == nil { + t.Fatal("ToOpenAIResponsesRequest returned nil") + } + wire, err := openAIReq.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(wire) + + if strings.Contains(raw, "tool_search_tool_bm25") || strings.Contains(raw, "tool_search_tool_regex") { + t.Fatalf("Anthropic sub-tool name leaked into the OpenAI-bound tool_search declaration: %s", raw) + } + + toolSearchCount := 0 + for _, tool := range gjson.Get(raw, "tools").Array() { + if tool.Get("type").String() == "tool_search" { + toolSearchCount++ + if tool.Get("name").Exists() { + t.Errorf("tool_search declaration must not carry a name field for OpenAI, got: %s", tool.Raw) + } + } + } + if toolSearchCount != 1 { + t.Fatalf("expected exactly 1 collapsed tool_search declaration, got %d: %s", toolSearchCount, gjson.Get(raw, "tools").Raw) + } + + if !strings.Contains(raw, `"name":"get_weather"`) { + t.Errorf("unrelated function tool must survive untouched, got: %s", raw) + } +} diff --git a/core/providers/openai/types.go b/core/providers/openai/types.go index e03ee915b1e..5d740ad4d39 100644 --- a/core/providers/openai/types.go +++ b/core/providers/openai/types.go @@ -642,13 +642,20 @@ func hasAnthropicOnlyToolFlags(t schemas.ChatTool) bool { } // hasAnthropicOnlyResponsesToolFlags is the ResponsesTool-typed parallel of -// hasAnthropicOnlyToolFlags. The four flags were promoted onto ResponsesTool -// in core/schemas/responses.go for the Anthropic-via-Responses path; the -// OpenAI Responses serializer must strip them so they don't leak to OpenAI -// and trigger a 400 on unknown fields. +// hasAnthropicOnlyToolFlags. Three of these flags exist only in Anthropic's +// advanced-tool-use bundle and must be stripped so they don't leak to OpenAI +// and trigger a 400 on unknown fields: AllowedCallers (Anthropic's +// code_execution_20250825/_20260120 programmatic-tool-calling caller +// vocabulary), InputExamples (Anthropic's tool-examples-2025-10-29 beta), +// EagerInputStreaming (Anthropic's fine-grained-tool-streaming-2025-05-14 +// beta). DeferLoading is deliberately NOT included here — it is also a +// genuine, non-beta OpenAI/Codex feature (required on every tool declared +// alongside tool_search; see https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool +// for Anthropic's side and OpenAI's own tool_search docs for the mirror). +// Stripping it unconditionally previously broke every OpenAI-bound +// tool_search request with "requires at least one deferred tool" — found live. func hasAnthropicOnlyResponsesToolFlags(t schemas.ResponsesTool) bool { - return t.DeferLoading != nil || - len(t.AllowedCallers) > 0 || + return len(t.AllowedCallers) > 0 || len(t.InputExamples) > 0 || t.EagerInputStreaming != nil || (t.ResponsesToolCodeInterpreter != nil && t.ResponsesToolCodeInterpreter.Version != nil) @@ -856,8 +863,9 @@ func (resp *OpenAIResponsesRequest) MarshalJSON() ([]byte, error) { // OpenAI's Responses Tool union doesn't include them — forwarding // would 400 on the discriminator. // (b) Strip CacheControl (Anthropic-only schema field). - // (c) Strip the four Anthropic-native per-tool flags (DeferLoading, - // AllowedCallers, InputExamples, EagerInputStreaming). + // (c) Strip the Anthropic-native per-tool flags (AllowedCallers, + // InputExamples, EagerInputStreaming). DeferLoading is preserved -- + // see hasAnthropicOnlyResponsesToolFlags's doc comment. var processedTools []schemas.ResponsesTool if len(resp.Tools) > 0 { needsReshape := false @@ -883,7 +891,8 @@ func (resp *OpenAIResponsesRequest) MarshalJSON() ([]byte, error) { } toolCopy := tool toolCopy.CacheControl = nil - toolCopy.DeferLoading = nil + // DeferLoading is intentionally preserved -- see + // hasAnthropicOnlyResponsesToolFlags's doc comment. toolCopy.AllowedCallers = nil toolCopy.InputExamples = nil toolCopy.EagerInputStreaming = nil diff --git a/core/schemas/tool_search_namespace_bridge.go b/core/schemas/tool_search_namespace_bridge.go new file mode 100644 index 00000000000..22b38738ec2 --- /dev/null +++ b/core/schemas/tool_search_namespace_bridge.go @@ -0,0 +1,327 @@ +package schemas + +import "strings" + +// ToolSearchBridgeNamespaceID is Bifrost's own reserved namespace identifier +// for the synthetic tool_search bridge shown to callers on the OpenAI +// Responses API surface when the actual backend implements tool_search +// natively in a different shape (e.g. Anthropic's tool_search_tool_bm25/ +// tool_search_tool_regex, which are server-executed and have no OpenAI +// equivalent on the wire). It is never a genuine user-declared namespace name +// — callers must not declare a namespace tool with this exact identifier. +// +// This exists because Anthropic's tool_search algorithm choice (bm25 vs +// regex) has no analogue in OpenAI's own generic "tool_search" tool type +// (single algorithm, chosen by the harness, not selectable on the wire). +// Modelling both algorithms as two grouped functions under one namespace tool +// lets the caller address them explicitly instead of Bifrost silently +// defaulting to bm25. +const ToolSearchBridgeNamespaceID = "bifrost_tool_search_bridge" + +// Grouped function names exposed under ToolSearchBridgeNamespaceID. +const ( + ToolSearchBridgeFuncBM25 = "tool_search_bm25" + ToolSearchBridgeFuncRegex = "tool_search_regex" +) + +// Description text for the bridge namespace and its grouped sub-tools. +// OpenAI's real namespace tool type requires a non-empty "description" on +// BOTH the namespace declaration itself AND every grouped sub-tool entry — +// confirmed live: a bare {"type":"namespace","name":"...","tools":[...]} +// with no descriptions is rejected outright with +// "Missing required parameter: 'tools[0].description'" (and, once that's +// fixed, "...'tools[0].tools[0].type'" if the sub-tool also lacks +// "type":"function"). BuildToolSearchBridgeNamespaceDeclaration is the +// canonical, spec-complete constructor — prefer it over hand-rolling the +// shape to avoid reintroducing either 400. +const ( + ToolSearchBridgeNamespaceDescription = "Search hidden/deferred tools by keyword (bm25) or regex pattern." + ToolSearchBridgeFuncBM25Description = "Keyword search over deferred tools." + ToolSearchBridgeFuncRegexDescription = "Regex pattern search over deferred tools." +) + +// BuildToolSearchBridgeNamespaceDeclaration returns the canonical, +// spec-complete namespace tool declaration for the tool_search bridge — +// {"type":"namespace","name":"bifrost_tool_search_bridge","description":..., +// "tools":[{"type":"function","name":"tool_search_bm25","description":...}, +// {"type":"function","name":"tool_search_regex","description":...}]} — +// valid against a real OpenAI-compatible backend's required-field checks. +// Callers constructing this declaration (rather than receiving it from +// CollapseToolSearchDeclarationsToBridgeNamespace) should use this instead of +// hand-rolling the shape. +func BuildToolSearchBridgeNamespaceDeclaration() ResponsesTool { + return ResponsesTool{ + Type: ResponsesToolTypeNamespace, + Name: Ptr(ToolSearchBridgeNamespaceID), + Description: Ptr(ToolSearchBridgeNamespaceDescription), + ResponsesToolNamespace: &ResponsesToolNamespace{ + Tools: []ResponsesTool{ + {Type: ResponsesToolTypeFunction, Name: Ptr(ToolSearchBridgeFuncBM25), Description: Ptr(ToolSearchBridgeFuncBM25Description)}, + {Type: ResponsesToolTypeFunction, Name: Ptr(ToolSearchBridgeFuncRegex), Description: Ptr(ToolSearchBridgeFuncRegexDescription)}, + }, + }, + } +} + +// Anthropic's native tool_search sub-tool type/name strings. Duplicated here +// (rather than imported from core/providers/anthropic) because this file is +// provider-agnostic — core/schemas must not depend on a specific provider +// package — and these two literal strings are Anthropic's stable public API +// names, unlikely to change independently of this file. +const ( + anthropicToolSearchNameBM25 = "tool_search_tool_bm25" + anthropicToolSearchNameRegex = "tool_search_tool_regex" +) + +// IsToolSearchBridgeNamespace reports whether name is Bifrost's reserved +// tool_search bridge namespace identifier, as opposed to a genuine +// user-declared namespace tool that happens to share the "namespace" type. +func IsToolSearchBridgeNamespace(name *string) bool { + return name != nil && *name == ToolSearchBridgeNamespaceID +} + +// bridgeFuncIsRegex centralizes the bm25-vs-regex resolution so every +// converter in this file (and, ideally, the pre-existing Anthropic egress +// heuristic at core/providers/anthropic/responses.go which independently does +// strings.Contains(Name,"regex")) agrees on the same rule. +func bridgeFuncIsRegex(name string) bool { + return strings.Contains(name, "regex") +} + +// anthropicToolSearchNameForBridgeFunc maps a grouped bridge function name +// ("tool_search_bm25"/"tool_search_regex") to Anthropic's native sub-tool +// name. Defaults to bm25 for any unrecognized function name, matching the +// existing default at the Anthropic declaration-egress path. +func anthropicToolSearchNameForBridgeFunc(funcName string) string { + if bridgeFuncIsRegex(funcName) { + return anthropicToolSearchNameRegex + } + return anthropicToolSearchNameBM25 +} + +// bridgeFuncForAnthropicToolSearchName is the reverse of +// anthropicToolSearchNameForBridgeFunc. +func bridgeFuncForAnthropicToolSearchName(anthropicName string) string { + if bridgeFuncIsRegex(anthropicName) { + return ToolSearchBridgeFuncRegex + } + return ToolSearchBridgeFuncBM25 +} + +// ExpandToolSearchBridgeDeclaration turns the caller-facing namespace +// declaration for the tool_search bridge into the two neutral tool_search +// declarations (bm25 + regex) that the rest of the pipeline (Anthropic +// declaration egress at responses.go:7069-7079) already knows how to render +// onto a backend. Returns the tools unchanged (same slice) and false when no +// bridge namespace entry is present. +func ExpandToolSearchBridgeDeclaration(tools []ResponsesTool) ([]ResponsesTool, bool) { + idx := -1 + for i := range tools { + if tools[i].Type == ResponsesToolTypeNamespace && IsToolSearchBridgeNamespace(tools[i].Name) { + idx = i + break + } + } + if idx == -1 { + return tools, false + } + + out := make([]ResponsesTool, 0, len(tools)+1) + out = append(out, tools[:idx]...) + out = append(out, + ResponsesTool{Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameBM25)}, + ResponsesTool{Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameRegex)}, + ) + out = append(out, tools[idx+1:]...) + return out, true +} + +// CollapseToolSearchDeclarationsToBridgeNamespace is the reverse of +// ExpandToolSearchBridgeDeclaration: given a tools[] list containing the +// neutral tool_search declaration(s), render them back into the single +// caller-facing namespace declaration. Used when Bifrost serializes a +// tools[] list back out to an OpenAI-Responses-shaped caller whose backend is +// not itself OpenAI (e.g. Anthropic). Tools not of type tool_search pass +// through unchanged; if no tool_search entries are present, returns the input +// unchanged. +func CollapseToolSearchDeclarationsToBridgeNamespace(tools []ResponsesTool) []ResponsesTool { + var sawBM25, sawRegex bool + out := make([]ResponsesTool, 0, len(tools)) + for _, t := range tools { + if t.Type == ResponsesToolTypeToolSearch { + if t.Name != nil && bridgeFuncIsRegex(*t.Name) { + sawRegex = true + } else { + sawBM25 = true + } + continue + } + out = append(out, t) + } + if !sawBM25 && !sawRegex { + return tools + } + + // Build off the canonical, spec-complete declaration (both + // description fields and each sub-tool's "type":"function" present — + // see BuildToolSearchBridgeNamespaceDeclaration's doc comment for the + // two live 400s a hand-rolled, incomplete shape triggers), then drop + // whichever sub-tool wasn't actually seen. + canonical := BuildToolSearchBridgeNamespaceDeclaration() + grouped := make([]ResponsesTool, 0, 2) + for _, sub := range canonical.ResponsesToolNamespace.Tools { + if sub.Name == nil { + continue + } + if (*sub.Name == ToolSearchBridgeFuncBM25 && sawBM25) || (*sub.Name == ToolSearchBridgeFuncRegex && sawRegex) { + grouped = append(grouped, sub) + } + } + canonical.ResponsesToolNamespace = &ResponsesToolNamespace{Tools: grouped} + out = append(out, canonical) + return out +} + +// isToolSearchBridgeFunctionCall reports whether msg is a function_call item +// tagged with the reserved bridge namespace (as opposed to a genuine +// namespace-grouped user function call, which will never carry this specific +// reserved ID). +func isToolSearchBridgeFunctionCall(msg *ResponsesMessage) bool { + return msg.Type != nil && *msg.Type == ResponsesMessageTypeFunctionCall && + msg.ResponsesToolMessage != nil && IsToolSearchBridgeNamespace(msg.ResponsesToolMessage.Namespace) +} + +func isToolSearchBridgeFunctionCallOutput(msg *ResponsesMessage) bool { + return msg.Type != nil && *msg.Type == ResponsesMessageTypeFunctionCallOutput && + msg.ResponsesToolMessage != nil && IsToolSearchBridgeNamespace(msg.ResponsesToolMessage.Namespace) +} + +// ExpandToolSearchBridgeItems scans messages for namespace-tagged +// function_call/function_call_output pairs belonging to the tool_search +// bridge (matched by CallID) and merges each pair into a single neutral +// tool_search_tool_call item (schemas.ResponsesMessageTypeAnthropicToolSearchCall), +// exactly the shape the Anthropic-egress converter +// (convertBifrostToolSearchCallToAnthropicBlocks) already knows how to +// render. Non-bridge items (including genuine user namespace calls, which +// never carry ToolSearchBridgeNamespaceID) pass through unchanged. +// +// If a function_call's matching function_call_output has not arrived yet +// (call still in flight), the call is expanded on its own with +// Status "in_progress" and no Output — mirroring the Anthropic ingest +// behavior for an unpaired server_tool_use. +func ExpandToolSearchBridgeItems(messages []ResponsesMessage) []ResponsesMessage { + hasBridgeItem := false + for i := range messages { + if isToolSearchBridgeFunctionCall(&messages[i]) || isToolSearchBridgeFunctionCallOutput(&messages[i]) { + hasBridgeItem = true + break + } + } + if !hasBridgeItem { + return messages + } + + // Index outputs by call_id for O(1) pairing lookup. + outputByCallID := make(map[string]*ResponsesMessage) + for i := range messages { + if isToolSearchBridgeFunctionCallOutput(&messages[i]) && messages[i].ResponsesToolMessage.CallID != nil { + outputByCallID[*messages[i].ResponsesToolMessage.CallID] = &messages[i] + } + } + + out := make([]ResponsesMessage, 0, len(messages)) + for i := range messages { + msg := messages[i] + switch { + case isToolSearchBridgeFunctionCall(&msg): + out = append(out, mergeToolSearchBridgeCall(msg, outputByCallID)) + case isToolSearchBridgeFunctionCallOutput(&msg): + // Dropped: folded into the merged call item above. Skip emitting + // it a second time as a standalone item. + continue + default: + out = append(out, msg) + } + } + return out +} + +// mergeToolSearchBridgeCall builds the neutral tool_search_tool_call item +// from a bridge function_call, folding in the matching function_call_output +// (if present) via outputByCallID. +func mergeToolSearchBridgeCall(call ResponsesMessage, outputByCallID map[string]*ResponsesMessage) ResponsesMessage { + subFuncName := "" + if call.ResponsesToolMessage.Name != nil { + subFuncName = *call.ResponsesToolMessage.Name + } + anthropicName := anthropicToolSearchNameForBridgeFunc(subFuncName) + + merged := ResponsesMessage{ + Type: Ptr(ResponsesMessageTypeAnthropicToolSearchCall), + ID: call.ID, + Status: Ptr("in_progress"), + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: call.ResponsesToolMessage.CallID, + Name: Ptr(anthropicName), + Arguments: call.ResponsesToolMessage.Arguments, + }, + } + + var callID string + if call.ResponsesToolMessage.CallID != nil { + callID = *call.ResponsesToolMessage.CallID + } + if output, ok := outputByCallID[callID]; ok && output.ResponsesToolMessage != nil { + merged.Status = Ptr("completed") + merged.ResponsesToolMessage.Output = output.ResponsesToolMessage.Output + } + + return merged +} + +// CollapseToolSearchItemToNamespacePair is the reverse of +// ExpandToolSearchBridgeItems for a single item: it renders one neutral +// tool_search_tool_call item back into the caller-facing +// function_call/function_call_output pair tagged with +// ToolSearchBridgeNamespaceID. Returns nil if msg is not a tool_search_tool_call +// item. Returns a single-element slice (just the call, status "in_progress") +// if the call has no Output yet. +func CollapseToolSearchItemToNamespacePair(msg ResponsesMessage) []ResponsesMessage { + if msg.Type == nil || *msg.Type != ResponsesMessageTypeAnthropicToolSearchCall || msg.ResponsesToolMessage == nil { + return nil + } + + anthropicName := "" + if msg.ResponsesToolMessage.Name != nil { + anthropicName = *msg.ResponsesToolMessage.Name + } + bridgeFuncName := bridgeFuncForAnthropicToolSearchName(anthropicName) + + call := ResponsesMessage{ + Type: Ptr(ResponsesMessageTypeFunctionCall), + ID: msg.ID, + Status: Ptr("completed"), + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: msg.ResponsesToolMessage.CallID, + Name: Ptr(bridgeFuncName), + Namespace: Ptr(ToolSearchBridgeNamespaceID), + Arguments: msg.ResponsesToolMessage.Arguments, + }, + } + + if msg.ResponsesToolMessage.Output == nil { + return []ResponsesMessage{call} + } + + output := ResponsesMessage{ + Type: Ptr(ResponsesMessageTypeFunctionCallOutput), + Status: Ptr("completed"), + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: msg.ResponsesToolMessage.CallID, + Namespace: Ptr(ToolSearchBridgeNamespaceID), + Output: msg.ResponsesToolMessage.Output, + }, + } + + return []ResponsesMessage{call, output} +} diff --git a/core/schemas/tool_search_namespace_bridge_test.go b/core/schemas/tool_search_namespace_bridge_test.go new file mode 100644 index 00000000000..365150d6ebf --- /dev/null +++ b/core/schemas/tool_search_namespace_bridge_test.go @@ -0,0 +1,373 @@ +package schemas + +import ( + "testing" +) + +func TestExpandToolSearchBridgeDeclaration(t *testing.T) { + tools := []ResponsesTool{ + {Type: ResponsesToolTypeFunction, Name: Ptr("get_weather")}, + {Type: ResponsesToolTypeNamespace, Name: Ptr(ToolSearchBridgeNamespaceID), ResponsesToolNamespace: &ResponsesToolNamespace{ + Tools: []ResponsesTool{ + {Type: ResponsesToolTypeFunction, Name: Ptr(ToolSearchBridgeFuncBM25)}, + {Type: ResponsesToolTypeFunction, Name: Ptr(ToolSearchBridgeFuncRegex)}, + }, + }}, + } + + out, expanded := ExpandToolSearchBridgeDeclaration(tools) + if !expanded { + t.Fatal("expected bridge namespace to be detected and expanded") + } + if len(out) != 3 { + t.Fatalf("expected 3 tools (1 kept + 2 expanded), got %d: %+v", len(out), out) + } + if out[0].Name == nil || *out[0].Name != "get_weather" { + t.Errorf("unrelated tool must survive untouched, got %+v", out[0]) + } + + var sawBM25, sawRegex bool + for _, tool := range out[1:] { + if tool.Type != ResponsesToolTypeToolSearch { + t.Errorf("expected expanded entries to be type tool_search, got %s", tool.Type) + } + if tool.Name == nil { + t.Fatal("expanded tool_search entry missing Name") + } + switch *tool.Name { + case anthropicToolSearchNameBM25: + sawBM25 = true + case anthropicToolSearchNameRegex: + sawRegex = true + } + } + if !sawBM25 || !sawRegex { + t.Errorf("expected both bm25 and regex sub-tools, got %+v", out[1:]) + } +} + +func TestExpandToolSearchBridgeDeclaration_NoBridgePresent(t *testing.T) { + tools := []ResponsesTool{ + {Type: ResponsesToolTypeFunction, Name: Ptr("get_weather")}, + // A genuine user namespace tool -- must NOT be touched, even though + // it shares the "namespace" type, because its Name isn't the + // reserved bridge ID. + {Type: ResponsesToolTypeNamespace, Name: Ptr("my_own_toolgroup"), ResponsesToolNamespace: &ResponsesToolNamespace{ + Tools: []ResponsesTool{{Type: ResponsesToolTypeFunction, Name: Ptr("do_thing")}}, + }}, + } + out, expanded := ExpandToolSearchBridgeDeclaration(tools) + if expanded { + t.Fatal("must not treat a genuine user namespace tool as the bridge") + } + if len(out) != 2 || out[1].Name == nil || *out[1].Name != "my_own_toolgroup" { + t.Errorf("genuine namespace tool must survive unchanged, got %+v", out) + } +} + +func TestCollapseToolSearchDeclarationsToBridgeNamespace(t *testing.T) { + tools := []ResponsesTool{ + {Type: ResponsesToolTypeFunction, Name: Ptr("get_weather")}, + {Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameBM25)}, + {Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameRegex)}, + } + out := CollapseToolSearchDeclarationsToBridgeNamespace(tools) + if len(out) != 2 { + t.Fatalf("expected 2 tools (1 kept + 1 collapsed namespace), got %d: %+v", len(out), out) + } + ns := out[1] + if ns.Type != ResponsesToolTypeNamespace || ns.Name == nil || !IsToolSearchBridgeNamespace(ns.Name) { + t.Fatalf("expected collapsed bridge namespace tool, got %+v", ns) + } + if ns.ResponsesToolNamespace == nil || len(ns.ResponsesToolNamespace.Tools) != 2 { + t.Fatalf("expected 2 grouped functions under the bridge namespace, got %+v", ns.ResponsesToolNamespace) + } +} + +// TestCollapseToolSearchDeclarationsToBridgeNamespace_IsSpecComplete guards +// against regressing to the bare shape that a real OpenAI-compatible backend +// rejects outright: "Missing required parameter: 'tools[0].description'" +// (namespace-level) and "...'tools[0].tools[0].type'" (sub-tool level), +// confirmed live. The collapsed declaration must always carry a description +// on itself and on every grouped sub-tool, and every sub-tool must be typed +// "function". +func TestCollapseToolSearchDeclarationsToBridgeNamespace_IsSpecComplete(t *testing.T) { + tools := []ResponsesTool{ + {Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameBM25)}, + {Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameRegex)}, + } + out := CollapseToolSearchDeclarationsToBridgeNamespace(tools) + if len(out) != 1 { + t.Fatalf("expected 1 collapsed namespace tool, got %d: %+v", len(out), out) + } + ns := out[0] + if ns.Description == nil || *ns.Description == "" { + t.Fatal("collapsed namespace declaration must carry a non-empty description") + } + if ns.ResponsesToolNamespace == nil || len(ns.ResponsesToolNamespace.Tools) != 2 { + t.Fatalf("expected 2 grouped sub-tools, got %+v", ns.ResponsesToolNamespace) + } + for _, sub := range ns.ResponsesToolNamespace.Tools { + if sub.Type != ResponsesToolTypeFunction { + t.Errorf("grouped sub-tool %v must be typed function, got %q", sub.Name, sub.Type) + } + if sub.Description == nil || *sub.Description == "" { + t.Errorf("grouped sub-tool %v must carry a non-empty description", sub.Name) + } + } +} + +// TestCollapseToolSearchDeclarationsToBridgeNamespace_OnlyBM25Seen verifies +// the collapsed namespace omits the regex sub-tool when only bm25 was ever +// declared, while still keeping the surviving sub-tool spec-complete. +func TestCollapseToolSearchDeclarationsToBridgeNamespace_OnlyBM25Seen(t *testing.T) { + tools := []ResponsesTool{ + {Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameBM25)}, + } + out := CollapseToolSearchDeclarationsToBridgeNamespace(tools) + ns := out[len(out)-1] + if len(ns.ResponsesToolNamespace.Tools) != 1 { + t.Fatalf("expected exactly 1 grouped sub-tool (bm25 only), got %+v", ns.ResponsesToolNamespace.Tools) + } + sub := ns.ResponsesToolNamespace.Tools[0] + if sub.Name == nil || *sub.Name != ToolSearchBridgeFuncBM25 { + t.Fatalf("expected the surviving sub-tool to be bm25, got %+v", sub) + } + if sub.Description == nil || *sub.Description == "" { + t.Error("surviving sub-tool must still carry a non-empty description") + } +} + +// TestBuildToolSearchBridgeNamespaceDeclaration_IsSpecComplete is the direct +// unit test for the canonical constructor itself. +func TestBuildToolSearchBridgeNamespaceDeclaration_IsSpecComplete(t *testing.T) { + ns := BuildToolSearchBridgeNamespaceDeclaration() + if ns.Type != ResponsesToolTypeNamespace { + t.Fatalf("expected type namespace, got %q", ns.Type) + } + if !IsToolSearchBridgeNamespace(ns.Name) { + t.Fatalf("expected the reserved bridge namespace name, got %v", ns.Name) + } + if ns.Description == nil || *ns.Description == "" { + t.Fatal("expected a non-empty top-level description") + } + if ns.ResponsesToolNamespace == nil || len(ns.ResponsesToolNamespace.Tools) != 2 { + t.Fatalf("expected 2 grouped sub-tools, got %+v", ns.ResponsesToolNamespace) + } + for _, sub := range ns.ResponsesToolNamespace.Tools { + if sub.Type != ResponsesToolTypeFunction { + t.Errorf("sub-tool %v must be typed function, got %q", sub.Name, sub.Type) + } + if sub.Description == nil || *sub.Description == "" { + t.Errorf("sub-tool %v must carry a non-empty description", sub.Name) + } + } +} + +func TestToolSearchBridgeDeclarationRoundTrip(t *testing.T) { + original := []ResponsesTool{ + {Type: ResponsesToolTypeFunction, Name: Ptr("get_weather")}, + {Type: ResponsesToolTypeNamespace, Name: Ptr(ToolSearchBridgeNamespaceID), ResponsesToolNamespace: &ResponsesToolNamespace{ + Tools: []ResponsesTool{ + {Type: ResponsesToolTypeFunction, Name: Ptr(ToolSearchBridgeFuncBM25)}, + {Type: ResponsesToolTypeFunction, Name: Ptr(ToolSearchBridgeFuncRegex)}, + }, + }}, + } + expanded, ok := ExpandToolSearchBridgeDeclaration(original) + if !ok { + t.Fatal("expected expansion") + } + collapsed := CollapseToolSearchDeclarationsToBridgeNamespace(expanded) + if len(collapsed) != len(original) { + t.Fatalf("round trip changed tool count: got %d, want %d", len(collapsed), len(original)) + } + if collapsed[1].Name == nil || !IsToolSearchBridgeNamespace(collapsed[1].Name) { + t.Errorf("round trip did not restore the bridge namespace, got %+v", collapsed[1]) + } +} + +func TestExpandToolSearchBridgeItems_BM25Completed(t *testing.T) { + messages := []ResponsesMessage{ + {Role: Ptr(ResponsesInputMessageRoleUser), Content: &ResponsesMessageContent{ContentStr: Ptr("find a weather tool")}}, + {Type: Ptr(ResponsesMessageTypeFunctionCall), ID: Ptr("fc_1"), ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("call_1"), Name: Ptr(ToolSearchBridgeFuncBM25), Namespace: Ptr(ToolSearchBridgeNamespaceID), + Arguments: Ptr(`{"query":"weather"}`), + }}, + {Type: Ptr(ResponsesMessageTypeFunctionCallOutput), ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("call_1"), Namespace: Ptr(ToolSearchBridgeNamespaceID), + Output: &ResponsesToolMessageOutputStruct{ResponsesToolCallOutputStr: Ptr(`["get_weather","get_forecast"]`)}, + }}, + } + + out := ExpandToolSearchBridgeItems(messages) + if len(out) != 2 { + t.Fatalf("expected call+output pair merged into 1 item (plus the user message) = 2 total, got %d: %+v", len(out), out) + } + merged := out[1] + if merged.Type == nil || *merged.Type != ResponsesMessageTypeAnthropicToolSearchCall { + t.Fatalf("expected merged item type tool_search_tool_call, got %v", merged.Type) + } + if merged.Status == nil || *merged.Status != "completed" { + t.Errorf("expected status completed once output is folded in, got %v", merged.Status) + } + if merged.ResponsesToolMessage.Name == nil || *merged.ResponsesToolMessage.Name != anthropicToolSearchNameBM25 { + t.Errorf("expected resolved anthropic name %s, got %v", anthropicToolSearchNameBM25, merged.ResponsesToolMessage.Name) + } + if merged.ResponsesToolMessage.CallID == nil || *merged.ResponsesToolMessage.CallID != "call_1" { + t.Errorf("call id lost in merge: %v", merged.ResponsesToolMessage.CallID) + } + if merged.ResponsesToolMessage.Output == nil || merged.ResponsesToolMessage.Output.ResponsesToolCallOutputStr == nil || + *merged.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != `["get_weather","get_forecast"]` { + t.Errorf("output not folded into merged item: %+v", merged.ResponsesToolMessage.Output) + } +} + +func TestExpandToolSearchBridgeItems_RegexResolvesCorrectly(t *testing.T) { + messages := []ResponsesMessage{ + {Type: Ptr(ResponsesMessageTypeFunctionCall), ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("call_2"), Name: Ptr(ToolSearchBridgeFuncRegex), Namespace: Ptr(ToolSearchBridgeNamespaceID), + Arguments: Ptr(`{"query":"^GET .*"}`), + }}, + } + out := ExpandToolSearchBridgeItems(messages) + if len(out) != 1 { + t.Fatalf("expected 1 merged (unpaired) item, got %d", len(out)) + } + if out[0].Status == nil || *out[0].Status != "in_progress" { + t.Errorf("unpaired call must stay in_progress, got %v", out[0].Status) + } + if out[0].ResponsesToolMessage.Name == nil || *out[0].ResponsesToolMessage.Name != anthropicToolSearchNameRegex { + t.Errorf("expected regex resolved, got %v -- bm25-default must not silently win for an explicit regex call", out[0].ResponsesToolMessage.Name) + } +} + +// TestExpandToolSearchBridgeItems_GenuineNamespaceUnaffected is the critical +// regression guard: a real, user-declared namespace-grouped function call +// (unrelated to tool_search) must pass through completely untouched, proving +// the reserved sentinel actually discriminates instead of matching on shape +// alone. +func TestExpandToolSearchBridgeItems_GenuineNamespaceUnaffected(t *testing.T) { + messages := []ResponsesMessage{ + {Type: Ptr(ResponsesMessageTypeFunctionCall), ID: Ptr("fc_real"), ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("call_real"), Name: Ptr("create_event"), Namespace: Ptr("calendar"), + Arguments: Ptr(`{"title":"standup"}`), + }}, + {Type: Ptr(ResponsesMessageTypeFunctionCallOutput), ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("call_real"), Namespace: Ptr("calendar"), + Output: &ResponsesToolMessageOutputStruct{ResponsesToolCallOutputStr: Ptr(`"created"`)}, + }}, + } + out := ExpandToolSearchBridgeItems(messages) + if len(out) != 2 { + t.Fatalf("genuine namespace call+output must both survive untouched, got %d items: %+v", len(out), out) + } + if out[0].Type == nil || *out[0].Type != ResponsesMessageTypeFunctionCall { + t.Errorf("genuine namespace call must not be reinterpreted as tool_search_tool_call, got %v", out[0].Type) + } + if out[1].Type == nil || *out[1].Type != ResponsesMessageTypeFunctionCallOutput { + t.Errorf("genuine namespace output must not be dropped/merged, got %v", out[1].Type) + } +} + +func TestCollapseToolSearchItemToNamespacePair(t *testing.T) { + msg := ResponsesMessage{ + ID: Ptr("srvtoolu_1"), + Type: Ptr(ResponsesMessageTypeAnthropicToolSearchCall), + Status: Ptr("completed"), + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("srvtoolu_1"), + Name: Ptr(anthropicToolSearchNameRegex), + Arguments: Ptr(`{"query":"^GET .*"}`), + Output: &ResponsesToolMessageOutputStruct{ResponsesToolCallOutputStr: Ptr(`["get_request"]`)}, + }, + } + + pair := CollapseToolSearchItemToNamespacePair(msg) + if len(pair) != 2 { + t.Fatalf("expected [call, output] pair, got %d items", len(pair)) + } + call, output := pair[0], pair[1] + if call.Type == nil || *call.Type != ResponsesMessageTypeFunctionCall { + t.Errorf("expected function_call, got %v", call.Type) + } + if call.ResponsesToolMessage.Namespace == nil || !IsToolSearchBridgeNamespace(call.ResponsesToolMessage.Namespace) { + t.Errorf("call must carry the reserved bridge namespace, got %v", call.ResponsesToolMessage.Namespace) + } + if call.ResponsesToolMessage.Name == nil || *call.ResponsesToolMessage.Name != ToolSearchBridgeFuncRegex { + t.Errorf("expected grouped function name %s (regex preserved), got %v", ToolSearchBridgeFuncRegex, call.ResponsesToolMessage.Name) + } + if output.Type == nil || *output.Type != ResponsesMessageTypeFunctionCallOutput { + t.Errorf("expected function_call_output, got %v", output.Type) + } + if output.ResponsesToolMessage.CallID == nil || *output.ResponsesToolMessage.CallID != "srvtoolu_1" { + t.Errorf("output call_id must match the call, got %v", output.ResponsesToolMessage.CallID) + } +} + +// TestToolSearchBridgeItemRoundTrip proves expand(collapse(x)) == x for the +// fields that matter (algorithm choice, call id, arguments, output), +// end-to-end through both directions. +func TestToolSearchBridgeItemRoundTrip(t *testing.T) { + original := ResponsesMessage{ + ID: Ptr("srvtoolu_rt"), + Type: Ptr(ResponsesMessageTypeAnthropicToolSearchCall), + Status: Ptr("completed"), + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("srvtoolu_rt"), + Name: Ptr(anthropicToolSearchNameBM25), + Arguments: Ptr(`{"query":"weather"}`), + Output: &ResponsesToolMessageOutputStruct{ResponsesToolCallOutputStr: Ptr(`["get_weather"]`)}, + }, + } + + pair := CollapseToolSearchItemToNamespacePair(original) + merged := ExpandToolSearchBridgeItems(pair) + if len(merged) != 1 { + t.Fatalf("expected round trip to re-merge into 1 item, got %d: %+v", len(merged), merged) + } + got := merged[0] + if got.ResponsesToolMessage.Name == nil || *got.ResponsesToolMessage.Name != anthropicToolSearchNameBM25 { + t.Errorf("algorithm lost in round trip: got %v, want %s", got.ResponsesToolMessage.Name, anthropicToolSearchNameBM25) + } + if got.ResponsesToolMessage.Arguments == nil || *got.ResponsesToolMessage.Arguments != `{"query":"weather"}` { + t.Errorf("arguments lost in round trip: got %v", got.ResponsesToolMessage.Arguments) + } + if got.ResponsesToolMessage.Output == nil || got.ResponsesToolMessage.Output.ResponsesToolCallOutputStr == nil || + *got.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != `["get_weather"]` { + t.Errorf("output lost in round trip: got %+v", got.ResponsesToolMessage.Output) + } +} + +// TestToolSearchBridgeJSONIsDeterministic guards against prompt-cache +// breakage: marshaling the same collapsed namespace pair twice must produce +// byte-identical JSON, since Bifrost's egress relies on MarshalSorted-style +// deterministic key ordering everywhere else for this exact reason. +func TestToolSearchBridgeJSONIsDeterministic(t *testing.T) { + msg := ResponsesMessage{ + ID: Ptr("srvtoolu_cache"), + Type: Ptr(ResponsesMessageTypeAnthropicToolSearchCall), + Status: Ptr("completed"), + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("srvtoolu_cache"), + Name: Ptr(anthropicToolSearchNameBM25), + Arguments: Ptr(`{"query":"weather"}`), + Output: &ResponsesToolMessageOutputStruct{ResponsesToolCallOutputStr: Ptr(`["get_weather","get_forecast"]`)}, + }, + } + + pair1 := CollapseToolSearchItemToNamespacePair(msg) + pair2 := CollapseToolSearchItemToNamespacePair(msg) + + b1, err := Marshal(pair1) + if err != nil { + t.Fatalf("marshal pair1: %v", err) + } + b2, err := Marshal(pair2) + if err != nil { + t.Fatalf("marshal pair2: %v", err) + } + if string(b1) != string(b2) { + t.Fatalf("non-deterministic JSON across identical inputs would break prompt caching:\n%s\nvs\n%s", b1, b2) + } +} diff --git a/core/schemas/tool_search_openai_native.go b/core/schemas/tool_search_openai_native.go new file mode 100644 index 00000000000..a1f77499c4e --- /dev/null +++ b/core/schemas/tool_search_openai_native.go @@ -0,0 +1,114 @@ +package schemas + +import "encoding/json" + +// This file provides constructors for building OpenAI/Codex's native +// tool_search_call / tool_search_output items programmatically, for +// providers that need to REPLAY a completed tool_search result (e.g. one +// that actually executed on Anthropic's servers) into OpenAI's own wire +// vocabulary when a conversation's backend switches. See the cross-provider +// tool_search mapping doc: +// memory/anthropicschema/gen/fix-execution/expanded-coverage/tool-search-cross-provider-mapping.md +// +// These must go through ResponsesMessage's rawToolSearch preservation path +// (only settable from within this package) rather than the struct's normal +// exported fields: tool_search_call.arguments is a JSON OBJECT on the wire +// (unlike function_call's JSON string) — building it via the plain +// ResponsesToolMessage.Arguments *string field would incorrectly re-encode +// it as a string when MarshalJSON runs the default path. + +// openAIToolSearchCallWire is the wire shape OpenAI expects for a +// tool_search_call item — see isToolSearchItem's doc comment for why +// arguments is an object here, unlike function_call. +type openAIToolSearchCallWire struct { + Type ResponsesMessageType `json:"type"` + CallID string `json:"call_id"` + Execution string `json:"execution"` + Arguments json.RawMessage `json:"arguments"` +} + +// NewOpenAIToolSearchCallItem builds a ResponsesMessage carrying a +// pre-serialized OpenAI-native tool_search_call item. argumentsJSON must be +// a JSON object (e.g. `{"query":"weather"}`); pass "{}" if unknown. +func NewOpenAIToolSearchCallItem(callID string, argumentsJSON string) (ResponsesMessage, error) { + if argumentsJSON == "" { + argumentsJSON = "{}" + } + wire := openAIToolSearchCallWire{ + Type: ResponsesMessageTypeToolSearchCall, + CallID: callID, + Execution: "client", + Arguments: json.RawMessage(argumentsJSON), + } + raw, err := MarshalSorted(wire) + if err != nil { + return ResponsesMessage{}, err + } + return ResponsesMessage{ + Type: Ptr(ResponsesMessageTypeToolSearchCall), + rawToolSearch: raw, + }, nil +} + +// OpenAIToolSearchDiscoveredTool is one entry of a tool_search_output's +// tools[] array — OpenAI's own client-executed tool_search returns the full +// function definition for every newly-discovered tool (name, description, +// parameters, defer_loading), unlike Anthropic's tool_search_tool_result +// which only ever carries a bare name. Callers bridging an Anthropic-origin +// result into this shape must backfill Description/Parameters/DeferLoading +// from the tool's original declaration (e.g. the current request's tools[]) +// — leave them nil if unavailable rather than fabricating values. +type OpenAIToolSearchDiscoveredTool struct { + Name string + Description *string + Parameters *ToolFunctionParameters + DeferLoading *bool +} + +type openAIToolSearchFunctionDefWire struct { + Type string `json:"type"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Parameters *ToolFunctionParameters `json:"parameters,omitempty"` + DeferLoading *bool `json:"defer_loading,omitempty"` +} + +type openAIToolSearchOutputWire struct { + Type ResponsesMessageType `json:"type"` + CallID string `json:"call_id"` + Status string `json:"status"` + Execution string `json:"execution"` + Tools []openAIToolSearchFunctionDefWire `json:"tools"` +} + +// NewOpenAIToolSearchOutputItem builds a ResponsesMessage carrying a +// pre-serialized OpenAI-native tool_search_output item listing the +// discovered tools. Always emits "tools": [] (never omitted/null) when +// discovered is empty, matching a genuine no-match OpenAI result. +func NewOpenAIToolSearchOutputItem(callID string, discovered []OpenAIToolSearchDiscoveredTool) (ResponsesMessage, error) { + tools := make([]openAIToolSearchFunctionDefWire, 0, len(discovered)) + for _, d := range discovered { + tools = append(tools, openAIToolSearchFunctionDefWire{ + Type: "function", + Name: d.Name, + Description: d.Description, + Parameters: d.Parameters, + DeferLoading: d.DeferLoading, + }) + } + wire := openAIToolSearchOutputWire{ + Type: ResponsesMessageTypeToolSearchOutput, + CallID: callID, + Status: "completed", + Execution: "client", + Tools: tools, + } + raw, err := MarshalSorted(wire) + if err != nil { + return ResponsesMessage{}, err + } + return ResponsesMessage{ + Type: Ptr(ResponsesMessageTypeToolSearchOutput), + rawToolSearch: raw, + }, nil +} From 88d20cfb5755cc620917e4d4f1283b55513abd00 Mon Sep 17 00:00:00 2001 From: Shaik-Sirajuddin Date: Mon, 6 Jul 2026 13:50:00 +0530 Subject: [PATCH 05/11] fix: only expand the tool_search sub-tools the caller actually declared ExpandToolSearchBridgeDeclaration was unconditionally injecting both bm25 and regex Anthropic declarations regardless of which sub-tools were present under the bridge namespace, silently widening a caller's request scope (e.g. a client replaying a previously-collapsed bm25-only namespace would unexpectedly gain regex access). Now only expands the sub-tools actually declared, defaulting to bm25 if none are recognized. Found by an automated codex review pass on this branch. Co-Authored-By: Claude Sonnet 5 --- core/schemas/tool_search_namespace_bridge.go | 50 +++++++++++++++---- .../tool_search_namespace_bridge_test.go | 28 +++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/core/schemas/tool_search_namespace_bridge.go b/core/schemas/tool_search_namespace_bridge.go index 22b38738ec2..e6c513cb73f 100644 --- a/core/schemas/tool_search_namespace_bridge.go +++ b/core/schemas/tool_search_namespace_bridge.go @@ -109,11 +109,17 @@ func bridgeFuncForAnthropicToolSearchName(anthropicName string) string { } // ExpandToolSearchBridgeDeclaration turns the caller-facing namespace -// declaration for the tool_search bridge into the two neutral tool_search -// declarations (bm25 + regex) that the rest of the pipeline (Anthropic -// declaration egress at responses.go:7069-7079) already knows how to render -// onto a backend. Returns the tools unchanged (same slice) and false when no -// bridge namespace entry is present. +// declaration for the tool_search bridge into the neutral tool_search +// declaration(s) that the rest of the pipeline (Anthropic declaration egress +// at responses.go:7069-7079) already knows how to render onto a backend. +// Only expands the sub-tools actually present under the namespace's +// grouped Tools[] — a caller that re-declares a previously-collapsed +// bm25-only namespace (see CollapseToolSearchDeclarationsToBridgeNamespace) +// must not have the request silently widened to include regex too. +// Returns the tools unchanged (same slice) and false when no bridge +// namespace entry is present, or when the namespace has no recognized +// sub-tools (falls back to bm25 only, matching the existing Anthropic +// egress default for an unrecognized/absent algorithm hint). func ExpandToolSearchBridgeDeclaration(tools []ResponsesTool) ([]ResponsesTool, bool) { idx := -1 for i := range tools { @@ -126,12 +132,36 @@ func ExpandToolSearchBridgeDeclaration(tools []ResponsesTool) ([]ResponsesTool, return tools, false } - out := make([]ResponsesTool, 0, len(tools)+1) + var sawBM25, sawRegex bool + if ns := tools[idx].ResponsesToolNamespace; ns != nil { + for _, sub := range ns.Tools { + if sub.Name == nil { + continue + } + if bridgeFuncIsRegex(*sub.Name) { + sawRegex = true + } else { + sawBM25 = true + } + } + } + if !sawBM25 && !sawRegex { + // No recognized sub-tools declared under the namespace — default to + // bm25, matching the existing Anthropic egress default. + sawBM25 = true + } + + expanded := make([]ResponsesTool, 0, 2) + if sawBM25 { + expanded = append(expanded, ResponsesTool{Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameBM25)}) + } + if sawRegex { + expanded = append(expanded, ResponsesTool{Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameRegex)}) + } + + out := make([]ResponsesTool, 0, len(tools)+len(expanded)) out = append(out, tools[:idx]...) - out = append(out, - ResponsesTool{Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameBM25)}, - ResponsesTool{Type: ResponsesToolTypeToolSearch, Name: Ptr(anthropicToolSearchNameRegex)}, - ) + out = append(out, expanded...) out = append(out, tools[idx+1:]...) return out, true } diff --git a/core/schemas/tool_search_namespace_bridge_test.go b/core/schemas/tool_search_namespace_bridge_test.go index 365150d6ebf..08a1f4d02bb 100644 --- a/core/schemas/tool_search_namespace_bridge_test.go +++ b/core/schemas/tool_search_namespace_bridge_test.go @@ -46,6 +46,34 @@ func TestExpandToolSearchBridgeDeclaration(t *testing.T) { } } +// TestExpandToolSearchBridgeDeclaration_OnlyBM25Declared guards against +// silently widening a request's tool surface: a caller that only declares +// the bm25 sub-tool under the bridge namespace (e.g. because it's replaying +// a previously-collapsed bm25-only namespace, see +// CollapseToolSearchDeclarationsToBridgeNamespace_OnlyBM25Seen) must expand +// to bm25 only -- Anthropic must never receive an uninvited regex +// declaration. +func TestExpandToolSearchBridgeDeclaration_OnlyBM25Declared(t *testing.T) { + tools := []ResponsesTool{ + {Type: ResponsesToolTypeNamespace, Name: Ptr(ToolSearchBridgeNamespaceID), ResponsesToolNamespace: &ResponsesToolNamespace{ + Tools: []ResponsesTool{ + {Type: ResponsesToolTypeFunction, Name: Ptr(ToolSearchBridgeFuncBM25)}, + }, + }}, + } + + out, expanded := ExpandToolSearchBridgeDeclaration(tools) + if !expanded { + t.Fatal("expected bridge namespace to be detected and expanded") + } + if len(out) != 1 { + t.Fatalf("expected exactly 1 expanded tool_search entry (bm25 only), got %d: %+v", len(out), out) + } + if out[0].Type != ResponsesToolTypeToolSearch || out[0].Name == nil || *out[0].Name != anthropicToolSearchNameBM25 { + t.Errorf("expected a single bm25 tool_search entry, got %+v", out[0]) + } +} + func TestExpandToolSearchBridgeDeclaration_NoBridgePresent(t *testing.T) { tools := []ResponsesTool{ {Type: ResponsesToolTypeFunction, Name: Ptr("get_weather")}, From fae0ae0b111182c5418b245693355aa5ae8bddd8 Mon Sep 17 00:00:00 2001 From: Shaik-Sirajuddin Date: Mon, 6 Jul 2026 14:11:21 +0530 Subject: [PATCH 06/11] feat: wire tool_search namespace bridge response egress and cross-provider replay Completes the tool_search bridge's response side: a completed tool_search_tool_bm25/_regex call is now collapsed back into the caller-facing namespace-disguised function_call/function_call_output pair (tagged with the reserved bifrost_tool_search_bridge namespace) instead of leaking the internal neutral tool_search_tool_call hub type onto the wire, gated by a new context flag set on ingest when the caller actually declared the bridge namespace. Also normalizes namespace-tagged bridge pairs back to the neutral hub item in the OpenAI request builder, so a bridge pair produced by this new egress collapse can still be correctly converted to OpenAI's native tool_search shape on a subsequent backend switch -- without this, the pair's Anthropic-native call ID reaches a real OpenAI-compatible backend unconverted and gets rejected. Co-Authored-By: Claude Sonnet 5 --- core/providers/anthropic/responses.go | 23 ++- ...earch_namespace_bridge_integration_test.go | 156 ++++++++++++++++++ core/providers/openai/responses.go | 16 +- core/schemas/bifrost.go | 1 + 4 files changed, 193 insertions(+), 3 deletions(-) diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index 8c4a728acf0..6e2bcb5fa3d 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -3814,7 +3814,10 @@ func ToAnthropicResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schema // caller) into the two native tool_search declarations Anthropic // expects, before the per-tool egress switch below. No-op (same // slice returned) unless the reserved bridge namespace is present. - toolsForAnthropic, _ := schemas.ExpandToolSearchBridgeDeclaration(bifrostReq.Params.Tools) + toolsForAnthropic, bridgeActive := schemas.ExpandToolSearchBridgeDeclaration(bifrostReq.Params.Tools) + if bridgeActive && ctx != nil { + ctx.SetValue(schemas.BifrostContextKeyToolSearchBridgeActive, true) + } anthropicTools, mcpServers := convertBifrostToolsToAnthropic(capModel, toolsForAnthropic, bifrostReq.Provider) if len(anthropicTools) > 0 { if anthropicReq.Tools == nil { @@ -4031,6 +4034,24 @@ func (response *AnthropicMessageResponse) ToBifrostResponsesResponse(ctx *schema } } + // If the caller declared tool_search via Bifrost's own namespace bridge + // (see schemas.ExpandToolSearchBridgeDeclaration, set on ingest above), + // collapse any completed tool_search_tool_call item back into the + // caller-facing function_call/function_call_output pair tagged with the + // reserved bridge namespace, instead of leaking the internal neutral hub + // type onto the wire. + if bridgeActive, ok := ctx.Value(schemas.BifrostContextKeyToolSearchBridgeActive).(bool); ok && bridgeActive && len(bifrostResp.Output) > 0 { + collapsed := make([]schemas.ResponsesMessage, 0, len(bifrostResp.Output)) + for _, msg := range bifrostResp.Output { + if pair := schemas.CollapseToolSearchItemToNamespacePair(msg); pair != nil { + collapsed = append(collapsed, pair...) + continue + } + collapsed = append(collapsed, msg) + } + bifrostResp.Output = collapsed + } + bifrostResp.Model = response.Model if response.StopReason != "" { diff --git a/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go b/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go index 634ca797fd3..ea8971e09a2 100644 --- a/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go +++ b/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go @@ -1,6 +1,7 @@ package anthropic import ( + "encoding/json" "strings" "testing" "time" @@ -203,3 +204,158 @@ func TestToolSearchNamespaceBridge_ProviderSwitchNoAnthropicLeakage(t *testing.T bifrostReq.Params.Tools[1]) } } + +// TestToolSearchNamespaceBridge_ResponseEgress is the full round trip: a +// caller declares tool_search via the namespace bridge (ingest sets +// schemas.BifrostContextKeyToolSearchBridgeActive on the context), Anthropic +// completes a real tool_search_tool_bm25 call, and the response converted +// back via ToBifrostResponsesResponse must show the caller the +// namespace-disguised function_call/function_call_output pair -- not the +// internal neutral tool_search_tool_call hub type -- so a strict +// OpenAI-Responses-spec client (which has no concept of +// "tool_search_tool_call") can parse it. +func TestToolSearchNamespaceBridge_ResponseEgress(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-opus-4-8", + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{ + { + Type: schemas.ResponsesToolTypeNamespace, + Name: schemas.Ptr(schemas.ToolSearchBridgeNamespaceID), + ResponsesToolNamespace: &schemas.ResponsesToolNamespace{ + Tools: []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr(schemas.ToolSearchBridgeFuncBM25)}, + }, + }, + }, + }, + }, + } + + ctx := schemas.NewBifrostContext(nil, time.Time{}) + if _, err := ToAnthropicResponsesRequest(ctx, bifrostReq); err != nil { + t.Fatalf("ToAnthropicResponsesRequest: %v", err) + } + + anthropicResp := &AnthropicMessageResponse{ + ID: "msg_1", + Model: "claude-opus-4-8", + Content: []AnthropicContentBlock{ + { + Type: AnthropicContentBlockTypeServerToolUse, + ID: schemas.Ptr("srvtoolu_bm25_1"), + Name: schemas.Ptr("tool_search_tool_bm25"), + Input: json.RawMessage(`{"query":"weather lookup"}`), + }, + { + Type: AnthropicContentBlockTypeToolSearchToolResult, + ToolUseID: schemas.Ptr("srvtoolu_bm25_1"), + Content: &AnthropicContent{ + ContentObj: &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolSearchToolSearchResult, + ToolReferences: []AnthropicContentBlock{ + {ToolName: schemas.Ptr("get_weather")}, + }, + }, + }, + }, + }, + } + + bifrostResp := anthropicResp.ToBifrostResponsesResponse(ctx) + if bifrostResp == nil { + t.Fatal("ToBifrostResponsesResponse returned nil") + } + + var sawFunctionCall, sawFunctionCallOutput, sawRawHubLeak bool + for _, msg := range bifrostResp.Output { + if msg.Type == nil { + continue + } + switch *msg.Type { + case schemas.ResponsesMessageTypeAnthropicToolSearchCall: + sawRawHubLeak = true + case schemas.ResponsesMessageTypeFunctionCall: + if msg.ResponsesToolMessage == nil || !schemas.IsToolSearchBridgeNamespace(msg.ResponsesToolMessage.Namespace) { + t.Errorf("expected function_call tagged with the bridge namespace, got %+v", msg.ResponsesToolMessage) + } + if msg.ResponsesToolMessage.Name == nil || *msg.ResponsesToolMessage.Name != schemas.ToolSearchBridgeFuncBM25 { + t.Errorf("expected the bridge function name tool_search_bm25, got %+v", msg.ResponsesToolMessage.Name) + } + sawFunctionCall = true + case schemas.ResponsesMessageTypeFunctionCallOutput: + if msg.ResponsesToolMessage == nil || !schemas.IsToolSearchBridgeNamespace(msg.ResponsesToolMessage.Namespace) { + t.Errorf("expected function_call_output tagged with the bridge namespace, got %+v", msg.ResponsesToolMessage) + } + sawFunctionCallOutput = true + } + } + + if sawRawHubLeak { + t.Error("internal tool_search_tool_call hub type must not reach the caller when the bridge is active") + } + if !sawFunctionCall { + t.Errorf("expected a namespace-tagged function_call item in the response, got %+v", bifrostResp.Output) + } + if !sawFunctionCallOutput { + t.Errorf("expected a namespace-tagged function_call_output item in the response, got %+v", bifrostResp.Output) + } +} + +// TestToolSearchNamespaceBridge_ResponseEgress_InactiveWhenNotDeclared +// verifies the collapse only fires when the caller actually used the bridge +// namespace on ingest -- a caller declaring tool_search natively (not via the +// bridge) must keep seeing the raw neutral hub type unchanged. +func TestToolSearchNamespaceBridge_ResponseEgress_InactiveWhenNotDeclared(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-opus-4-8", + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{ + {Type: schemas.ResponsesToolTypeToolSearch, Name: schemas.Ptr("tool_search_tool_bm25")}, + }, + }, + } + + ctx := schemas.NewBifrostContext(nil, time.Time{}) + if _, err := ToAnthropicResponsesRequest(ctx, bifrostReq); err != nil { + t.Fatalf("ToAnthropicResponsesRequest: %v", err) + } + + anthropicResp := &AnthropicMessageResponse{ + ID: "msg_1", + Model: "claude-opus-4-8", + Content: []AnthropicContentBlock{ + { + Type: AnthropicContentBlockTypeServerToolUse, + ID: schemas.Ptr("srvtoolu_bm25_1"), + Name: schemas.Ptr("tool_search_tool_bm25"), + Input: json.RawMessage(`{"query":"weather lookup"}`), + }, + { + Type: AnthropicContentBlockTypeToolSearchToolResult, + ToolUseID: schemas.Ptr("srvtoolu_bm25_1"), + Content: &AnthropicContent{ + ContentObj: &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolSearchToolSearchResult, + ToolReferences: []AnthropicContentBlock{ + {ToolName: schemas.Ptr("get_weather")}, + }, + }, + }, + }, + }, + } + + bifrostResp := anthropicResp.ToBifrostResponsesResponse(ctx) + var sawRawHub bool + for _, msg := range bifrostResp.Output { + if msg.Type != nil && *msg.Type == schemas.ResponsesMessageTypeAnthropicToolSearchCall { + sawRawHub = true + } + } + if !sawRawHub { + t.Errorf("expected the raw neutral tool_search_tool_call item when the bridge wasn't declared, got %+v", bifrostResp.Output) + } +} diff --git a/core/providers/openai/responses.go b/core/providers/openai/responses.go index ec4e6b75845..fa6b15dac6f 100644 --- a/core/providers/openai/responses.go +++ b/core/providers/openai/responses.go @@ -44,11 +44,23 @@ func ToOpenAIResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.B // Canonical model for capability gating only; wire model is untouched. capModel := schemas.ResolveCanonicalModel(ctx, bifrostReq.Model) + // Normalize any namespace-tagged tool_search bridge call/output pairs + // (Bifrost's own caller-facing convention, produced when a prior turn's + // response was collapsed for an Anthropic backend -- see the egress + // collapse in AnthropicMessageResponse.ToBifrostResponsesResponse) back + // into the neutral tool_search_tool_call hub item before the loop below. + // Without this, a bridge pair replayed straight through to OpenAI is + // indistinguishable from a real function call and carries an + // Anthropic-native call ID, which a real OpenAI-compatible backend + // rejects ("Expected an ID that begins with 'fc'") -- found live. No-op + // unless a bridge item is present. + input := schemas.ExpandToolSearchBridgeItems(bifrostReq.Input) + var messages []schemas.ResponsesMessage // OpenAI models (except for gpt-oss) do not support reasoning content blocks, so we need to convert them to summaries, if there are any // OpenAI also doesn't support compaction content blocks, so we need to convert them to text blocks - messages = make([]schemas.ResponsesMessage, 0, len(bifrostReq.Input)) - for _, message := range bifrostReq.Input { + messages = make([]schemas.ResponsesMessage, 0, len(input)) + for _, message := range input { // Anthropic-origin tool_search_tool_call history items (a completed // tool_search_tool_bm25/_regex round trip) are not valid on OpenAI's // wire -- convert to OpenAI's own native tool_search_call + diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index c27c72744f1..1a2f7eb5d42 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -274,6 +274,7 @@ const ( BifrostMCPAgentOriginalRequestID BifrostContextKey = "bifrost-mcp-agent-original-request-id" // string (to store the original request ID for MCP agent mode) BifrostContextKeyParentMCPRequestID BifrostContextKey = "bf-parent-mcp-request-id" // string (parent request ID for nested tool calls from executeCode) BifrostContextKeyStructuredOutputToolName BifrostContextKey = "bifrost-structured-output-tool-name" // string (to store the name of the structured output tool (set by bifrost)) + BifrostContextKeyToolSearchBridgeActive BifrostContextKey = "bifrost-tool-search-bridge-active" // bool (set by bifrost when the caller declared the tool_search namespace bridge on ingest, so matching response egress can collapse tool_search_tool_call items back into the namespace-disguised shape) BifrostContextKeyUserAgent BifrostContextKey = "bifrost-user-agent" // string (set by bifrost) BifrostContextKeySkipBudgetAndRateLimits BifrostContextKey = "bifrost-skip-budget-and-rate-limits" // bool (set by bifrost for read-only requests like list models that don't consume quota) BifrostContextKeySkipVirtualKeyUsageTracking BifrostContextKey = "bifrost-skip-virtual-key-usage-tracking" // bool (set by governance callers to skip VK usage while preserving VK auth/attribution) From 2b7b2f5d40a9b043b3b552e3dfc617d3f8b893e7 Mon Sep 17 00:00:00 2001 From: Shaik-Sirajuddin Date: Mon, 6 Jul 2026 14:21:09 +0530 Subject: [PATCH 07/11] fix: preserve incomplete tool_search state across the namespace bridge Three related state-corruption bugs found by an automated codex review pass: - ExpandToolSearchBridgeItems silently dropped a function_call_output with no matching call in the same message slice (e.g. trimmed/paginated history), losing the discovered-tool result. - CollapseToolSearchItemToNamespacePair hardcoded the collapsed function_call's status to "completed" even when the source item was still in_progress, hiding a still-running search from the caller. - convertAnthropicToolSearchCallToOpenAINative always fabricated a completed, empty-result tool_search_output when replaying an Anthropic tool_search call to OpenAI, even if the source call had no Output yet -- corrupting conversation state on a mid-search backend switch. Co-Authored-By: Claude Sonnet 5 --- .../openai/tool_search_anthropic_bridge.go | 10 +++- .../tool_search_anthropic_bridge_test.go | 41 ++++++++++++++ core/schemas/tool_search_namespace_bridge.go | 39 +++++++++++--- .../tool_search_namespace_bridge_test.go | 53 +++++++++++++++++++ 4 files changed, 136 insertions(+), 7 deletions(-) diff --git a/core/providers/openai/tool_search_anthropic_bridge.go b/core/providers/openai/tool_search_anthropic_bridge.go index f9b68baf9b1..72771ebc946 100644 --- a/core/providers/openai/tool_search_anthropic_bridge.go +++ b/core/providers/openai/tool_search_anthropic_bridge.go @@ -46,8 +46,16 @@ func convertAnthropicToolSearchCallToOpenAINative(msg schemas.ResponsesMessage, return []schemas.ResponsesMessage{msg} } + // The source call hasn't completed yet (no Output) -- emit only the call + // half. Fabricating a "completed, empty result" tool_search_output here + // would hide the real result once it actually arrives, corrupting + // conversation state on a backend switch mid-search. + if msg.ResponsesToolMessage.Output == nil { + return []schemas.ResponsesMessage{callItem} + } + var discoveredNames []string - if msg.ResponsesToolMessage.Output != nil && msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + if msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { _ = sonic.Unmarshal([]byte(*msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr), &discoveredNames) } diff --git a/core/providers/openai/tool_search_anthropic_bridge_test.go b/core/providers/openai/tool_search_anthropic_bridge_test.go index d085839c701..1ace72de4d5 100644 --- a/core/providers/openai/tool_search_anthropic_bridge_test.go +++ b/core/providers/openai/tool_search_anthropic_bridge_test.go @@ -161,3 +161,44 @@ func TestToolSearchNamespaceBridge_AnthropicOriginReplay_UnknownToolDegradesName t.Error("unknown tool must not fabricate parameters") } } + +// TestToolSearchNamespaceBridge_AnthropicOriginReplay_InProgressOmitsOutput +// guards against fabricating a completed, empty-result tool_search_output +// for a search that is still running on the Anthropic side (no Output yet). +// Replaying it to OpenAI must emit only the call half -- inventing a +// completed output here would hide the real result once it arrives, +// corrupting conversation state on a backend switch mid-search. Found by an +// automated codex review pass. +func TestToolSearchNamespaceBridge_AnthropicOriginReplay_InProgressOmitsOutput(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.OpenAI, + Model: "gpt-5.4-mini", + Input: []schemas.ResponsesMessage{ + { + Type: schemas.Ptr(schemas.ResponsesMessageTypeAnthropicToolSearchCall), + Status: schemas.Ptr("in_progress"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: schemas.Ptr("srvtoolu_3"), + Name: schemas.Ptr("tool_search_tool_bm25"), + Arguments: schemas.Ptr(`{"query":"weather lookup"}`), + // No Output -- the search hasn't completed yet. + }, + }, + }, + Params: &schemas.ResponsesParameters{Tools: nil}, + } + + openAIReq := ToOpenAIResponsesRequest(nil, bifrostReq) + wire, err := openAIReq.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(wire) + + if strings.Contains(raw, "tool_search_output") { + t.Fatalf("must not fabricate a tool_search_output for an unfinished search, got: %s", raw) + } + if got := gjson.Get(raw, "input.0.type").String(); got != "tool_search_call" { + t.Fatalf("expected only the tool_search_call half, got input[0].type=%q; raw=%s", got, raw) + } +} diff --git a/core/schemas/tool_search_namespace_bridge.go b/core/schemas/tool_search_namespace_bridge.go index e6c513cb73f..ccd14b36a7f 100644 --- a/core/schemas/tool_search_namespace_bridge.go +++ b/core/schemas/tool_search_namespace_bridge.go @@ -238,7 +238,10 @@ func isToolSearchBridgeFunctionCallOutput(msg *ResponsesMessage) bool { // If a function_call's matching function_call_output has not arrived yet // (call still in flight), the call is expanded on its own with // Status "in_progress" and no Output — mirroring the Anthropic ingest -// behavior for an unpaired server_tool_use. +// behavior for an unpaired server_tool_use. Conversely, an output item with +// no matching call in this slice (e.g. a caller trimmed/paginated history to +// only the tail of a pair) is passed through unchanged rather than silently +// dropped — losing the discovered-tool result would otherwise be invisible. func ExpandToolSearchBridgeItems(messages []ResponsesMessage) []ResponsesMessage { hasBridgeItem := false for i := range messages { @@ -251,12 +254,18 @@ func ExpandToolSearchBridgeItems(messages []ResponsesMessage) []ResponsesMessage return messages } - // Index outputs by call_id for O(1) pairing lookup. + // Index outputs by call_id for O(1) pairing lookup, and track which + // call_ids have a matching call present so an orphaned output (no call + // in this slice) is never silently dropped. outputByCallID := make(map[string]*ResponsesMessage) + callIDsWithCall := make(map[string]bool) for i := range messages { if isToolSearchBridgeFunctionCallOutput(&messages[i]) && messages[i].ResponsesToolMessage.CallID != nil { outputByCallID[*messages[i].ResponsesToolMessage.CallID] = &messages[i] } + if isToolSearchBridgeFunctionCall(&messages[i]) && messages[i].ResponsesToolMessage.CallID != nil { + callIDsWithCall[*messages[i].ResponsesToolMessage.CallID] = true + } } out := make([]ResponsesMessage, 0, len(messages)) @@ -266,9 +275,18 @@ func ExpandToolSearchBridgeItems(messages []ResponsesMessage) []ResponsesMessage case isToolSearchBridgeFunctionCall(&msg): out = append(out, mergeToolSearchBridgeCall(msg, outputByCallID)) case isToolSearchBridgeFunctionCallOutput(&msg): - // Dropped: folded into the merged call item above. Skip emitting - // it a second time as a standalone item. - continue + var callID string + if msg.ResponsesToolMessage.CallID != nil { + callID = *msg.ResponsesToolMessage.CallID + } + if callIDsWithCall[callID] { + // Folded into the merged call item above. Skip emitting it a + // second time as a standalone item. + continue + } + // No matching call in this slice -- preserve as-is rather than + // dropping the discovered-tool result. + out = append(out, msg) default: out = append(out, msg) } @@ -327,10 +345,19 @@ func CollapseToolSearchItemToNamespacePair(msg ResponsesMessage) []ResponsesMess } bridgeFuncName := bridgeFuncForAnthropicToolSearchName(anthropicName) + // Status must reflect the source item's actual completion state -- an + // in-progress Anthropic tool_search_tool_call (no Output yet) must not + // be presented to the caller as "completed"; that would hide a + // still-running search from a client polling/replaying history. + callStatus := "in_progress" + if msg.ResponsesToolMessage.Output != nil { + callStatus = "completed" + } + call := ResponsesMessage{ Type: Ptr(ResponsesMessageTypeFunctionCall), ID: msg.ID, - Status: Ptr("completed"), + Status: Ptr(callStatus), ResponsesToolMessage: &ResponsesToolMessage{ CallID: msg.ResponsesToolMessage.CallID, Name: Ptr(bridgeFuncName), diff --git a/core/schemas/tool_search_namespace_bridge_test.go b/core/schemas/tool_search_namespace_bridge_test.go index 08a1f4d02bb..5cf077fd2e2 100644 --- a/core/schemas/tool_search_namespace_bridge_test.go +++ b/core/schemas/tool_search_namespace_bridge_test.go @@ -298,6 +298,31 @@ func TestExpandToolSearchBridgeItems_GenuineNamespaceUnaffected(t *testing.T) { } } +// TestExpandToolSearchBridgeItems_OrphanedOutputPreserved guards against +// silently dropping a discovered-tool result: if a caller's history has been +// trimmed/paginated to only the function_call_output half of a bridge pair +// (no matching call in this slice), it must be preserved unchanged rather +// than vanishing. Found by an automated codex review pass. +func TestExpandToolSearchBridgeItems_OrphanedOutputPreserved(t *testing.T) { + messages := []ResponsesMessage{ + {Type: Ptr(ResponsesMessageTypeFunctionCallOutput), ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("call_orphan"), Namespace: Ptr(ToolSearchBridgeNamespaceID), + Output: &ResponsesToolMessageOutputStruct{ResponsesToolCallOutputStr: Ptr(`["get_weather"]`)}, + }}, + } + out := ExpandToolSearchBridgeItems(messages) + if len(out) != 1 { + t.Fatalf("orphaned output must be preserved, not dropped, got %d items: %+v", len(out), out) + } + if out[0].Type == nil || *out[0].Type != ResponsesMessageTypeFunctionCallOutput { + t.Errorf("expected the orphaned output to survive as function_call_output, got %v", out[0].Type) + } + if out[0].ResponsesToolMessage.Output == nil || out[0].ResponsesToolMessage.Output.ResponsesToolCallOutputStr == nil || + *out[0].ResponsesToolMessage.Output.ResponsesToolCallOutputStr != `["get_weather"]` { + t.Errorf("discovered-tool result lost from orphaned output: %+v", out[0].ResponsesToolMessage.Output) + } +} + func TestCollapseToolSearchItemToNamespacePair(t *testing.T) { msg := ResponsesMessage{ ID: Ptr("srvtoolu_1"), @@ -333,6 +358,34 @@ func TestCollapseToolSearchItemToNamespacePair(t *testing.T) { } } +// TestCollapseToolSearchItemToNamespacePair_InProgressStaysInProgress guards +// against presenting an unfinished Anthropic tool_search call as completed: +// the collapsed function_call's Status must mirror the source item's actual +// completion state (no Output yet == still in_progress), not be hardcoded to +// "completed". Found by an automated codex review pass. +func TestCollapseToolSearchItemToNamespacePair_InProgressStaysInProgress(t *testing.T) { + msg := ResponsesMessage{ + ID: Ptr("srvtoolu_2"), + Type: Ptr(ResponsesMessageTypeAnthropicToolSearchCall), + Status: Ptr("in_progress"), + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: Ptr("srvtoolu_2"), + Name: Ptr(anthropicToolSearchNameBM25), + Arguments: Ptr(`{"query":"weather"}`), + // No Output -- the search hasn't completed yet. + }, + } + + pair := CollapseToolSearchItemToNamespacePair(msg) + if len(pair) != 1 { + t.Fatalf("expected a single-element slice (call only, no output) for an unfinished search, got %d items: %+v", len(pair), pair) + } + call := pair[0] + if call.Status == nil || *call.Status != "in_progress" { + t.Errorf("unfinished search must collapse to status in_progress, not fabricate completed, got %v", call.Status) + } +} + // TestToolSearchBridgeItemRoundTrip proves expand(collapse(x)) == x for the // fields that matter (algorithm choice, call id, arguments, output), // end-to-end through both directions. From 0c97b753c9ef33ac0ff66d125ba4c317d8a5b4f9 Mon Sep 17 00:00:00 2001 From: Shaik-Sirajuddin Date: Mon, 6 Jul 2026 14:30:23 +0530 Subject: [PATCH 08/11] fix: address remaining upstream PR review nitpicks - Validate tool_search_call arguments are a JSON object before wrapping as raw bytes, instead of silently passing malformed input through to the wire (coderabbitai). - Use schemas.ResponsesToolTypeFunction instead of a hardcoded "function" string literal in the tool_search_output wire encoding (coderabbitai). - Replace a hand-rolled containsStr test helper with the stdlib slices.Contains, removing the duplication risk instead of just relocating it (greptile). The other two flagged issues (CollapseToolSearchItemToNamespacePair's status handling, and an ID/CallID pointer-sharing concern in responses.go) were already resolved by prior commits on this branch; verified against current code before skipping. Co-Authored-By: Claude Sonnet 5 --- .../anthropic/toolsearch_roundtrip_test.go | 12 ++----- core/schemas/tool_search_openai_native.go | 17 +++++++-- .../schemas/tool_search_openai_native_test.go | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 core/schemas/tool_search_openai_native_test.go diff --git a/core/providers/anthropic/toolsearch_roundtrip_test.go b/core/providers/anthropic/toolsearch_roundtrip_test.go index e0a1281f7e8..745ad230cc0 100644 --- a/core/providers/anthropic/toolsearch_roundtrip_test.go +++ b/core/providers/anthropic/toolsearch_roundtrip_test.go @@ -1,6 +1,7 @@ package anthropic import ( + "slices" "strings" "testing" "time" @@ -121,7 +122,7 @@ func TestToolSearch_NonStreamingIngestAndEgressRoundTrip(t *testing.T) { names = append(names, *ref.ToolName) } } - if !containsStr(names, "get_weather") || !containsStr(names, "get_forecast") { + if !slices.Contains(names, "get_weather") || !slices.Contains(names, "get_forecast") { t.Errorf("reconstructed tool_references = %v, want both discovered tool names", names) } sawResult = true @@ -214,12 +215,3 @@ func TestToolSearch_CallerPreservedIngestAndEgress(t *testing.T) { t.Error("reconstructed tool_search_tool_result block is missing caller") } } - -func containsStr(list []string, want string) bool { - for _, v := range list { - if v == want { - return true - } - } - return false -} diff --git a/core/schemas/tool_search_openai_native.go b/core/schemas/tool_search_openai_native.go index a1f77499c4e..58e86e6785b 100644 --- a/core/schemas/tool_search_openai_native.go +++ b/core/schemas/tool_search_openai_native.go @@ -1,6 +1,10 @@ package schemas -import "encoding/json" +import ( + "bytes" + "encoding/json" + "fmt" +) // This file provides constructors for building OpenAI/Codex's native // tool_search_call / tool_search_output items programmatically, for @@ -34,6 +38,15 @@ func NewOpenAIToolSearchCallItem(callID string, argumentsJSON string) (Responses if argumentsJSON == "" { argumentsJSON = "{}" } + // arguments must be a JSON object on the wire (see the file doc comment) -- + // validate here so malformed/non-object input (e.g. forwarded verbatim + // from an upstream provider) surfaces as a clear error at construction + // time instead of silently reaching an OpenAI-compatible backend as + // invalid raw JSON. + trimmed := bytes.TrimSpace([]byte(argumentsJSON)) + if len(trimmed) == 0 || trimmed[0] != '{' || !json.Valid(trimmed) { + return ResponsesMessage{}, fmt.Errorf("tool_search_call arguments must be a JSON object, got: %s", argumentsJSON) + } wire := openAIToolSearchCallWire{ Type: ResponsesMessageTypeToolSearchCall, CallID: callID, @@ -89,7 +102,7 @@ func NewOpenAIToolSearchOutputItem(callID string, discovered []OpenAIToolSearchD tools := make([]openAIToolSearchFunctionDefWire, 0, len(discovered)) for _, d := range discovered { tools = append(tools, openAIToolSearchFunctionDefWire{ - Type: "function", + Type: string(ResponsesToolTypeFunction), Name: d.Name, Description: d.Description, Parameters: d.Parameters, diff --git a/core/schemas/tool_search_openai_native_test.go b/core/schemas/tool_search_openai_native_test.go new file mode 100644 index 00000000000..5451f41d210 --- /dev/null +++ b/core/schemas/tool_search_openai_native_test.go @@ -0,0 +1,36 @@ +package schemas + +import "testing" + +// TestNewOpenAIToolSearchCallItem_RejectsNonObjectArguments guards the JSON +// object requirement documented on NewOpenAIToolSearchCallItem: OpenAI's +// tool_search_call.arguments must be a JSON object on the wire, unlike +// function_call's JSON string. Malformed/non-object input (e.g. forwarded +// verbatim from an upstream provider) must fail fast with a clear error +// instead of reaching an OpenAI-compatible backend as invalid raw JSON. +// Found by an automated codeRabbit review pass. +func TestNewOpenAIToolSearchCallItem_RejectsNonObjectArguments(t *testing.T) { + cases := []struct { + name string + args string + wantErr bool + }{ + {"empty defaults to empty object", "", false}, + {"valid empty object", "{}", false}, + {"valid populated object", `{"query":"weather"}`, false}, + {"bare string rejected", `"weather"`, true}, + {"array rejected", `["weather"]`, true}, + {"malformed json rejected", `{query:weather}`, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewOpenAIToolSearchCallItem("call_1", tc.args) + if tc.wantErr && err == nil { + t.Errorf("expected an error for arguments %q, got nil", tc.args) + } + if !tc.wantErr && err != nil { + t.Errorf("expected no error for arguments %q, got %v", tc.args, err) + } + }) + } +} From f9063fa29a19ce9c442877d7a83f7cdac08e862d Mon Sep 17 00:00:00 2001 From: Akshay Deo Date: Sun, 12 Jul 2026 18:06:50 -0700 Subject: [PATCH 09/11] dds new harness skill and updates based on merged PRs (#5126) Signed-off-by: Akshay Deo From edb163f4322fcba302337eb9d3b5bf9b2c6b0e43 Mon Sep 17 00:00:00 2001 From: Shaik-Sirajuddin Date: Mon, 13 Jul 2026 09:36:20 +0530 Subject: [PATCH 10/11] fix: don't silently drop discovered tool_search results on unmarshal failure CodeRabbit (PR #4908): sonic.Unmarshal's error was discarded when parsing discovered tool names, so a malformed payload silently produced a "completed, zero results" tool_search_output instead of falling back to the original item like the function's other error paths. --- core/providers/openai/tool_search_anthropic_bridge.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/providers/openai/tool_search_anthropic_bridge.go b/core/providers/openai/tool_search_anthropic_bridge.go index 72771ebc946..caee90de179 100644 --- a/core/providers/openai/tool_search_anthropic_bridge.go +++ b/core/providers/openai/tool_search_anthropic_bridge.go @@ -56,7 +56,12 @@ func convertAnthropicToolSearchCallToOpenAINative(msg schemas.ResponsesMessage, var discoveredNames []string if msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { - _ = sonic.Unmarshal([]byte(*msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr), &discoveredNames) + if err := sonic.Unmarshal([]byte(*msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr), &discoveredNames); err != nil { + // Malformed/unexpected output shape -- fall back to the original + // item rather than fabricating a "completed, zero results" + // tool_search_output, matching the two error paths below. + return []schemas.ResponsesMessage{msg} + } } discovered := make([]schemas.OpenAIToolSearchDiscoveredTool, 0, len(discoveredNames)) From 5672dff688d0d55ec52ea936c2dfc0f670e485ce Mon Sep 17 00:00:00 2001 From: Shaik-Sirajuddin Date: Wed, 15 Jul 2026 11:15:50 +0530 Subject: [PATCH 11/11] fix: rename rawToolSearch to rawPreserved after upstream field rename, gofmt cleanup Upstream (#5103, adds addition_tools support) renamed ResponsesMessage's private rawToolSearch field to rawPreserved when generalizing raw-byte preservation beyond tool_search. tool_search_openai_native.go (this branch) predates that rename and still referenced the old name post-rebase. --- ...ool_search_namespace_bridge_integration_test.go | 12 ++++++------ core/providers/anthropic/types.go | 14 +++++++------- core/schemas/responses.go | 2 +- core/schemas/tool_search_openai_native.go | 12 ++++++------ 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go b/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go index ea8971e09a2..cf71d5b0cfc 100644 --- a/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go +++ b/core/providers/anthropic/tool_search_namespace_bridge_integration_test.go @@ -243,9 +243,9 @@ func TestToolSearchNamespaceBridge_ResponseEgress(t *testing.T) { Model: "claude-opus-4-8", Content: []AnthropicContentBlock{ { - Type: AnthropicContentBlockTypeServerToolUse, - ID: schemas.Ptr("srvtoolu_bm25_1"), - Name: schemas.Ptr("tool_search_tool_bm25"), + Type: AnthropicContentBlockTypeServerToolUse, + ID: schemas.Ptr("srvtoolu_bm25_1"), + Name: schemas.Ptr("tool_search_tool_bm25"), Input: json.RawMessage(`{"query":"weather lookup"}`), }, { @@ -328,9 +328,9 @@ func TestToolSearchNamespaceBridge_ResponseEgress_InactiveWhenNotDeclared(t *tes Model: "claude-opus-4-8", Content: []AnthropicContentBlock{ { - Type: AnthropicContentBlockTypeServerToolUse, - ID: schemas.Ptr("srvtoolu_bm25_1"), - Name: schemas.Ptr("tool_search_tool_bm25"), + Type: AnthropicContentBlockTypeServerToolUse, + ID: schemas.Ptr("srvtoolu_bm25_1"), + Name: schemas.Ptr("tool_search_tool_bm25"), Input: json.RawMessage(`{"query":"weather lookup"}`), }, { diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go index d6fa460ab4a..644bf47a8cc 100644 --- a/core/providers/anthropic/types.go +++ b/core/providers/anthropic/types.go @@ -938,13 +938,13 @@ const ( // content.type for a failed tool_search_tool_result (carries ErrorCode/ErrorMessage). AnthropicContentBlockTypeToolSearchToolResultError AnthropicContentBlockType = "tool_search_tool_result_error" AnthropicContentBlockTypeToolReference AnthropicContentBlockType = "tool_reference" - AnthropicContentBlockTypeContainerUpload AnthropicContentBlockType = "container_upload" - AnthropicContentBlockTypeAdvisorToolResult AnthropicContentBlockType = "advisor_tool_result" - AnthropicContentBlockTypeMCPToolUse AnthropicContentBlockType = "mcp_tool_use" - AnthropicContentBlockTypeMCPToolResult AnthropicContentBlockType = "mcp_tool_result" - AnthropicContentBlockTypeThinking AnthropicContentBlockType = "thinking" - AnthropicContentBlockTypeRedactedThinking AnthropicContentBlockType = "redacted_thinking" - AnthropicContentBlockTypeCompaction AnthropicContentBlockType = "compaction" + AnthropicContentBlockTypeContainerUpload AnthropicContentBlockType = "container_upload" + AnthropicContentBlockTypeAdvisorToolResult AnthropicContentBlockType = "advisor_tool_result" + AnthropicContentBlockTypeMCPToolUse AnthropicContentBlockType = "mcp_tool_use" + AnthropicContentBlockTypeMCPToolResult AnthropicContentBlockType = "mcp_tool_result" + AnthropicContentBlockTypeThinking AnthropicContentBlockType = "thinking" + AnthropicContentBlockTypeRedactedThinking AnthropicContentBlockType = "redacted_thinking" + AnthropicContentBlockTypeCompaction AnthropicContentBlockType = "compaction" // code_execution inner result-content discriminators (the "content" object on // a *_code_execution_tool_result block; ContentObj.Type carries these). diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 02f115ba7aa..ea1197047c3 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -1094,7 +1094,7 @@ const ( // Deliberately distinct from ResponsesMessageTypeToolSearchCall/ToolSearchOutput // above, which are Codex/OpenAI's client-executed tool_search meta-tool (different // shape: arguments is a search query object, execution:"client", no server-side - // results). Reusing that type string would collide with isToolSearchItem's raw-byte + // results). Reusing that type string would collide with isRawPreservedItem's raw-byte // preservation path and misrepresent Anthropic's server-completed search as a // client-side call to any Responses API consumer. ResponsesMessageTypeAnthropicToolSearchCall ResponsesMessageType = "tool_search_tool_call" diff --git a/core/schemas/tool_search_openai_native.go b/core/schemas/tool_search_openai_native.go index 58e86e6785b..5faa657db10 100644 --- a/core/schemas/tool_search_openai_native.go +++ b/core/schemas/tool_search_openai_native.go @@ -14,7 +14,7 @@ import ( // tool_search mapping doc: // memory/anthropicschema/gen/fix-execution/expanded-coverage/tool-search-cross-provider-mapping.md // -// These must go through ResponsesMessage's rawToolSearch preservation path +// These must go through ResponsesMessage's rawPreserved preservation path // (only settable from within this package) rather than the struct's normal // exported fields: tool_search_call.arguments is a JSON OBJECT on the wire // (unlike function_call's JSON string) — building it via the plain @@ -22,7 +22,7 @@ import ( // it as a string when MarshalJSON runs the default path. // openAIToolSearchCallWire is the wire shape OpenAI expects for a -// tool_search_call item — see isToolSearchItem's doc comment for why +// tool_search_call item — see isRawPreservedItem's doc comment for why // arguments is an object here, unlike function_call. type openAIToolSearchCallWire struct { Type ResponsesMessageType `json:"type"` @@ -58,8 +58,8 @@ func NewOpenAIToolSearchCallItem(callID string, argumentsJSON string) (Responses return ResponsesMessage{}, err } return ResponsesMessage{ - Type: Ptr(ResponsesMessageTypeToolSearchCall), - rawToolSearch: raw, + Type: Ptr(ResponsesMessageTypeToolSearchCall), + rawPreserved: raw, }, nil } @@ -121,7 +121,7 @@ func NewOpenAIToolSearchOutputItem(callID string, discovered []OpenAIToolSearchD return ResponsesMessage{}, err } return ResponsesMessage{ - Type: Ptr(ResponsesMessageTypeToolSearchOutput), - rawToolSearch: raw, + Type: Ptr(ResponsesMessageTypeToolSearchOutput), + rawPreserved: raw, }, nil }