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
This commit is contained in:
Luis Pater
2026-06-16 03:29:44 +08:00
parent e40599831a
commit 844b855974
2 changed files with 59 additions and 0 deletions
@@ -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
@@ -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_")