From 844b85597481c7b0e2191b0056c3a8f7db8f266b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 16 Jun 2026 03:29:44 +0800 Subject: [PATCH] feat(executor): sanitize web search tool domains to meet Anthropic requirements - Added `sanitizeClaudeWebSearchDomains` to remove empty `allowed_domains` and `blocked_domains` fields for built-in web_search tools, addressing ambiguity errors from Anthropic. - Integrated domain sanitization into the Claude message preparation pipeline. - Added test cases to validate correct handling of empty and non-empty domain fields across various tool types. Closes: #2681 --- internal/runtime/executor/claude_executor.go | 29 ++++++++++++++++++ .../runtime/executor/claude_executor_test.go | 30 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index dd5933a9..f32e2578 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -48,9 +48,38 @@ const claudeToolPrefix = "" func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string) []byte { sanitized, report := sigcompat.SanitizeClaudeMessagesForClaudeUpstream(body, baseModel) logClaudeSignatureSanitizeReport(ctx, baseModel, report) + sanitized = sanitizeClaudeWebSearchDomains(sanitized) return sanitized } +// sanitizeClaudeWebSearchDomains removes empty allowed_domains/blocked_domains +// arrays from built-in web_search tools. Some clients (e.g. litellm) emit an +// empty array instead of omitting the field, and Anthropic rejects it with +// "Empty list of domains is ambiguous. Provide at least one domain or null.". +// Deleting the key is equivalent to leaving it unset. +func sanitizeClaudeWebSearchDomains(body []byte) []byte { + tools := gjson.GetBytes(body, "tools") + if !tools.Exists() || !tools.IsArray() { + return body + } + tools.ForEach(func(index, tool gjson.Result) bool { + if !strings.HasPrefix(tool.Get("type").String(), "web_search_") { + return true + } + for _, field := range []string{"allowed_domains", "blocked_domains"} { + value := tool.Get(field) + if value.Exists() && value.IsArray() && len(value.Array()) == 0 { + path := fmt.Sprintf("tools.%d.%s", index.Int(), field) + if updated, errDelete := sjson.DeleteBytes(body, path); errDelete == nil { + body = updated + } + } + } + return true + }) + return body +} + func logClaudeSignatureSanitizeReport(ctx context.Context, baseModel string, report sigcompat.SignatureSanitizeReport) { if report.DroppedBlocks == 0 && report.DroppedSignatures == 0 && report.ReplacedSignatures == 0 { return diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 5221aacd..be4a9719 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -636,6 +636,36 @@ func TestApplyClaudeToolPrefix_WithToolReference(t *testing.T) { } } +func TestSanitizeClaudeWebSearchDomains(t *testing.T) { + // Mirrors the litellm payload from issue #2681: a non-empty allowed_domains + // alongside an empty blocked_domains, which Anthropic rejects as ambiguous. + input := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search","allowed_domains":["anthropic.com"],"blocked_domains":[],"max_uses":8}]}`) + out := sanitizeClaudeWebSearchDomains(input) + + if gjson.GetBytes(out, "tools.0.blocked_domains").Exists() { + t.Fatalf("empty blocked_domains should be removed: %s", string(out)) + } + if got := gjson.GetBytes(out, "tools.0.allowed_domains").Array(); len(got) != 1 || got[0].String() != "anthropic.com" { + t.Fatalf("non-empty allowed_domains should be preserved: %s", string(out)) + } + if got := gjson.GetBytes(out, "tools.0.max_uses").Int(); got != 8 { + t.Fatalf("max_uses should be preserved: got %d", got) + } +} + +func TestSanitizeClaudeWebSearchDomains_LeavesNonBuiltinAndNonEmpty(t *testing.T) { + // Empty arrays on non-web_search tools must be left untouched. + input := []byte(`{"tools":[{"type":"custom","name":"x","blocked_domains":[]},{"type":"web_search_20250305","name":"web_search","blocked_domains":["evil.com"]}]}`) + out := sanitizeClaudeWebSearchDomains(input) + + if !gjson.GetBytes(out, "tools.0.blocked_domains").Exists() { + t.Fatalf("non-web_search tool fields should be untouched: %s", string(out)) + } + if got := gjson.GetBytes(out, "tools.1.blocked_domains").Array(); len(got) != 1 || got[0].String() != "evil.com" { + t.Fatalf("non-empty blocked_domains should be preserved: %s", string(out)) + } +} + func TestApplyClaudeToolPrefix_SkipsBuiltinTools(t *testing.T) { input := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"},{"name":"my_custom_tool","input_schema":{"type":"object"}}]}`) out := applyClaudeToolPrefix(input, "proxy_")