From 1eca6d23b3d70936d31da902cb49c9c493340bf2 Mon Sep 17 00:00:00 2001 From: Alex Kugel Date: Fri, 28 Aug 2026 18:05:42 +0100 Subject: [PATCH] fix(grok): stop rejecting hosted web search domain and location filters Claude Code can declare Anthropic hosted web search with allowed_domains, blocked_domains, or user_location. Grok's hosted web_search has no equivalent fields, so the proxy returned HTTP 400 without calling Grok. Claude Code surfaces that 400 to the model as an error tool_result, and the model retries the same search without the filter. The retry is accepted and runs unrestricted, so the rejection costs an extra round and does not preserve the caller's constraint. CCP_SEARCH_CONSTRAINTS selects the behaviour: soft (default) drop the fields and turn each one into an instruction in the upstream request's instructions field warning drop the fields, log a warning, send the search unrestricted hard keep the HTTP 400 Measured over 10 live searches with three allowed domains, soft returned 161 in-domain sources and 0 off-domain. The same query with no domain list returned 147 in-domain and 210 off-domain. Grok applies the instruction by adding site: filters to its own queries. soft is a bias, not enforcement. max_uses stays dropped, as before. --- docs/src/content/docs/providers/grok.md | 6 +- .../compatibility-and-limitations.md | 5 +- .../content/docs/reference/configuration.md | 1 + src/config.rs | 34 ++ src/providers/grok/translate/request.rs | 301 +++++++++++++++--- 5 files changed, 293 insertions(+), 54 deletions(-) diff --git a/docs/src/content/docs/providers/grok.md b/docs/src/content/docs/providers/grok.md index b01414ce..3c3b3fc3 100644 --- a/docs/src/content/docs/providers/grok.md +++ b/docs/src/content/docs/providers/grok.md @@ -40,7 +40,9 @@ Search reaches Grok-native tools when the caller asks for it: - Anthropic's `web_search_20250305` declaration maps to Grok hosted web search. The Grok CLI endpoint accepts the minimal declaration without domain or - location constraints. + location constraints. `CCP_SEARCH_CONSTRAINTS` selects what happens when + Claude Code still sends those fields: `soft` (default) copies constraints into a + prompt hint, `warning` drops them and logs, `hard` returns 400. - A caller-managed search tool remains a function tool for the caller to run. - An X or Twitter query is additionally offered hosted `x_search`, which the model can use or ignore alongside the caller's tools. @@ -83,6 +85,8 @@ Traffic captures redact Anthropic image data and upstream image data URLs. - `CCP_GROK_TOOL_IMAGE` selects `omit`, `reattach`, `inline`, or `reject`. - `CCP_GROK_HOSTED_SEARCH` enables hosted search replacement and forcing. - `CCP_GROK_SEARCH_BLOCKS` selects `text` or `native` hosted-search reporting. +- `CCP_SEARCH_CONSTRAINTS` selects `soft`, `warning`, or `hard` for Anthropic + hosted-search domain and location fields Grok cannot enforce. See [Configuration](/reference/configuration/) for defaults. diff --git a/docs/src/content/docs/reference/compatibility-and-limitations.md b/docs/src/content/docs/reference/compatibility-and-limitations.md index 2dcd30b2..b7a1a1af 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -69,8 +69,9 @@ claude-code-proxy targets Claude Code's practical Anthropic API usage rather tha - Model availability varies by account and region. - Hosted general web search and X search are translated with citations and usage. - Hosted web search omits `max_uses` because the Grok CLI endpoint exposes no - equivalent cap. Non-null domain filters and user location are rejected because - dropping them would weaken the caller's requested search scope. + equivalent cap. Non-null domain filters and user location follow + `CCP_SEARCH_CONSTRAINTS` (`soft` default, `warning`, or `hard`) because the + endpoint cannot enforce them. - The implemented multimodal path does not claim general image or video compatibility. ## Cursor Agent diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index f5936f24..60c0c395 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -70,6 +70,7 @@ All keys are optional. An unreadable file, malformed JSON, or incompatible field | `CCP_LOG_VERBOSE` | `log.verbose` | `false` | Preserves full string fields in structured logs when present, regardless of its value. | | `CCP_TRAFFIC_LOG` | none | `false` | Enables full request captures for `1`, `true`, or `yes`. | | `XDG_STATE_HOME` | none | `~/.local/state` | State base on macOS and Linux. | +| `CCP_SEARCH_CONSTRAINTS` | none | `soft` | How to treat Anthropic hosted-search options a provider cannot enforce (`allowed_domains`, `blocked_domains`, `user_location`). `soft` drops them and copies constraints into a prompt hint. `warning` drops them and logs. `hard` returns 400. First provider: Grok. Codex maps domain filters natively and ignores this setting. | `CCP_CONFIG_DIR` affects `config.json` and file-backed provider auth. It does not relocate the state directory. diff --git a/src/config.rs b/src/config.rs index 15c0b072..89b871fe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -507,6 +507,40 @@ pub fn grok_search_blocks() -> GrokSearchBlocks { parse_grok_search_blocks(std::env::var("CCP_GROK_SEARCH_BLOCKS").ok().as_deref()) } +// --------------------------------------------------------------------------- +// Hosted-search constraints that a provider cannot enforce +// (CCP_SEARCH_CONSTRAINTS) +// --------------------------------------------------------------------------- + +/// How the proxy treats Anthropic hosted-search options that the upstream +/// provider cannot enforce (`allowed_domains`, `blocked_domains`, +/// `user_location`). +/// +/// Applies to providers that lack those fields. First provider: Grok. Codex +/// maps domain filters natively and does not use this policy. +/// +/// `Soft` is the default: drop the fields and copy constraints into a prompt hint. +/// `Warning` drops them, logs, and continues with no hint. `Hard` is the +/// legacy 400. Unknown values fall back to `Soft`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchConstraints { + Soft, + Warning, + Hard, +} + +pub fn parse_search_constraints(raw: Option<&str>) -> SearchConstraints { + match raw.map(str::trim) { + Some("hard") => SearchConstraints::Hard, + Some("warning") => SearchConstraints::Warning, + _ => SearchConstraints::Soft, + } +} + +pub fn search_constraints() -> SearchConstraints { + parse_search_constraints(std::env::var("CCP_SEARCH_CONSTRAINTS").ok().as_deref()) +} + struct ResolvedOpenCodeConfig { api_key: Option, api_key_source: Option<&'static str>, diff --git a/src/providers/grok/translate/request.rs b/src/providers/grok/translate/request.rs index ecfbaabf..36802c11 100644 --- a/src/providers/grok/translate/request.rs +++ b/src/providers/grok/translate/request.rs @@ -4,7 +4,7 @@ use serde::Serialize; use serde_json::Value; use crate::anthropic::schema::{Message, MessagesRequest}; -use crate::config::GrokToolImageMode; +use crate::config::{GrokToolImageMode, SearchConstraints}; use crate::providers::translate_shared::{ ImageSource, image_source_to_url, parallel_tool_calls, read_effort_with_allowed, }; @@ -157,7 +157,13 @@ pub fn translate_request_with_mode( model: String, image_mode: GrokToolImageMode, ) -> anyhow::Result { - translate_request_with_options(req, model, image_mode, crate::config::grok_hosted_search()) + translate_request_with_options( + req, + model, + image_mode, + crate::config::grok_hosted_search(), + crate::config::search_constraints(), + ) } /// `hosted_search` selects how xAI's hosted search tools reach the model. When @@ -165,15 +171,24 @@ pub fn translate_request_with_mode( /// preserves every caller tool. When enabled, hosted tools replace caller /// search tools and explicit search turns require a tool call. Tests pass the /// policy directly so their behavior is independent of process configuration. +/// +/// `constraints` selects what happens when Anthropic hosted-search options +/// (`allowed_domains`, `blocked_domains`, `user_location`) are present and +/// Grok cannot enforce them. pub fn translate_request_with_options( req: &MessagesRequest, model: String, image_mode: GrokToolImageMode, hosted_search: bool, + constraints: SearchConstraints, ) -> anyhow::Result { reject_unknown_top_level(req)?; let mut instructions = parse_system(req.extra.get("system"))?; - let mut tools = parse_tools(req.extra.get("tools"), hosted_search)?; + let (mut tools, constraint_hint) = + parse_tools(req.extra.get("tools"), hosted_search, constraints)?; + if let Some(hint) = constraint_hint { + append_guidance(&mut instructions, &hint); + } let hosted_web_search = tools .as_ref() .is_some_and(|tools| tools.iter().any(|tool| tool.kind == "web_search")); @@ -336,6 +351,58 @@ fn append_guidance(instructions: &mut Option, guidance: &str) { }); } +const HOSTED_SEARCH_CONSTRAINT_FIELDS: [&str; 3] = + ["allowed_domains", "blocked_domains", "user_location"]; + +fn unsupported_hosted_search_message(fields: &[&str]) -> String { + format!( + "Grok hosted web search does not support {}", + fields.join(", ") + ) +} + +fn non_null_constraint_fields(obj: &serde_json::Map) -> Vec<&'static str> { + HOSTED_SEARCH_CONSTRAINT_FIELDS + .into_iter() + .filter(|field| obj.get(*field).is_some_and(|value| !value.is_null())) + .collect() +} + +/// Render the caller's value verbatim. A plain string carries no brackets of +/// its own, so it is wrapped in braces to separate the value from the sentence +/// period. Arrays and objects already delimit themselves. +fn format_constraint_value(value: &Value) -> String { + match value { + Value::String(text) => format!("{{{text}}}"), + other => other.to_string(), + } +} + +/// The directive each constraint becomes. Grok cannot enforce these fields, so +/// the instruction states the rule rather than describing the proxy's own +/// limitation. +fn constraint_directive(field: &str) -> &'static str { + match field { + "blocked_domains" => "You are not allowed to search", + "user_location" => "You must search as", + _ => "You are only allowed to search", + } +} + +fn constraint_hint_line(obj: &serde_json::Map) -> Option { + let mut parts = Vec::new(); + for field in HOSTED_SEARCH_CONSTRAINT_FIELDS { + if let Some(value) = obj.get(field).filter(|value| !value.is_null()) { + parts.push(format!( + "{} {field}={}.", + constraint_directive(field), + format_constraint_value(value) + )); + } + } + (!parts.is_empty()).then(|| parts.join(" ")) +} + fn latest_user_text(req: &MessagesRequest) -> Option { let message = req .messages @@ -489,13 +556,17 @@ fn parse_system(value: Option<&Value>) -> anyhow::Result> { fn parse_tools( value: Option<&Value>, hosted_search: bool, -) -> anyhow::Result>> { - let Some(value) = value else { return Ok(None) }; + constraints: SearchConstraints, +) -> anyhow::Result<(Option>, Option)> { + let Some(value) = value else { + return Ok((None, None)); + }; let tools = value .as_array() .ok_or_else(|| anyhow::anyhow!("tools must be an array"))?; let mut names = HashSet::new(); let mut out = Vec::new(); + let mut constraint_hint = None; for tool in tools { let obj = tool .as_object() @@ -550,15 +621,25 @@ fn parse_tools( if kind != "web_search_20250305" || name != "web_search" { anyhow::bail!("unsupported tool type: {kind}"); } - for field in ["allowed_domains", "blocked_domains", "user_location"] { - if obj.get(field).is_some_and(|value| !value.is_null()) { - anyhow::bail!("Grok hosted web search does not support {field}"); + let dropped = non_null_constraint_fields(obj); + if !dropped.is_empty() { + match constraints { + SearchConstraints::Hard => { + anyhow::bail!("{}", unsupported_hosted_search_message(&dropped)); + } + SearchConstraints::Warning => { + crate::logging::create_logger("grok") + .warn(&unsupported_hosted_search_message(&dropped), None); + } + SearchConstraints::Soft => { + constraint_hint = constraint_hint_line(obj); + } } } out.push(GrokTool::hosted_named("web_search", name)); continue; } - for field in ["allowed_domains", "blocked_domains", "user_location"] { + for field in HOSTED_SEARCH_CONSTRAINT_FIELDS { if obj.contains_key(field) { anyhow::bail!("unsupported tool field: {field}"); } @@ -587,7 +668,7 @@ fn parse_tools( parameters, )); } - Ok(Some(out)) + Ok((Some(out), constraint_hint)) } fn parse_tool_choice( @@ -1258,6 +1339,15 @@ mod tests { req: &MessagesRequest, model: &str, hosted_search: bool, + ) -> serde_json::Value { + translate_search_with_constraints(req, model, hosted_search, SearchConstraints::Soft) + } + + fn translate_search_with_constraints( + req: &MessagesRequest, + model: &str, + hosted_search: bool, + constraints: SearchConstraints, ) -> serde_json::Value { serde_json::to_value( translate_request_with_options( @@ -1265,12 +1355,27 @@ mod tests { model.into(), crate::config::GrokToolImageMode::Omit, hosted_search, + constraints, ) .unwrap(), ) .unwrap() } + fn translate_options( + req: &MessagesRequest, + hosted_search: bool, + constraints: SearchConstraints, + ) -> anyhow::Result { + translate_request_with_options( + req, + "grok-4.5".into(), + crate::config::GrokToolImageMode::Omit, + hosted_search, + constraints, + ) + } + fn translate_hosted(req: &MessagesRequest, model: &str) -> serde_json::Value { translate_search(req, model, true) } @@ -1817,19 +1922,136 @@ mod tests { "tools":[{"type":"web_search_20250305","name":"web_search",(field):value}] })) .unwrap(); - let error = translate_request_with_options( - &request, - "grok-4.5".into(), - crate::config::GrokToolImageMode::Omit, - false, - ) - .unwrap_err() - .to_string(); + let error = translate_options(&request, false, SearchConstraints::Hard) + .unwrap_err() + .to_string(); assert_eq!( error, format!("Grok hosted web search does not support {field}") ); } + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "messages":[{"role":"user","content":"find it"}], + "tools":[{ + "type":"web_search_20250305", + "name":"web_search", + "allowed_domains":["example.com"], + "user_location":{"type":"approximate","country":"GB"} + }] + })) + .unwrap(); + let error = translate_options(&request, false, SearchConstraints::Hard) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "Grok hosted web search does not support allowed_domains, user_location" + ); + } + + #[test] + fn grok_translation_softens_hosted_web_search_constraints_into_a_prompt_hint() { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "system":"rules", + "messages":[{"role":"user","content":"find it"}], + "tools":[{ + "type":"web_search_20250305", + "name":"web_search", + "allowed_domains":["example.com","docs.example.com"], + "blocked_domains":["spam.example"], + "user_location":{"type":"approximate","country":"GB"} + }] + })) + .unwrap(); + let translated = + translate_search_with_constraints(&request, "grok-4.5", false, SearchConstraints::Soft); + assert_eq!( + translated["tools"], + serde_json::json!([{"type":"web_search"}]) + ); + let instructions = translated["instructions"].as_str().unwrap(); + assert_eq!( + instructions, + concat!( + "rules\n\n", + r#"You are only allowed to search allowed_domains=["example.com","docs.example.com"]. "#, + r#"You are not allowed to search blocked_domains=["spam.example"]. "#, + r#"You must search as user_location={"country":"GB","type":"approximate"}."#, + ) + ); + assert!(!translated["tools"].to_string().contains("allowed_domains")); + } + + #[test] + fn grok_translation_wraps_a_plain_string_constraint_value_in_braces() { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "messages":[{"role":"user","content":"find it"}], + "tools":[{ + "type":"web_search_20250305", + "name":"web_search", + "user_location":"London, GB" + }] + })) + .unwrap(); + let translated = + translate_search_with_constraints(&request, "grok-4.5", false, SearchConstraints::Soft); + assert_eq!( + translated["instructions"], + "You must search as user_location={London, GB}." + ); + } + + #[test] + fn grok_translation_warns_and_drops_hosted_web_search_constraints() { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "system":"rules", + "messages":[{"role":"user","content":"find it"}], + "tools":[{ + "type":"web_search_20250305", + "name":"web_search", + "allowed_domains":["example.com"] + }] + })) + .unwrap(); + let _stderr = crate::logging::suppress_stderr(); + let translated = translate_search_with_constraints( + &request, + "grok-4.5", + false, + SearchConstraints::Warning, + ); + assert_eq!( + translated["tools"], + serde_json::json!([{"type":"web_search"}]) + ); + assert_eq!(translated["instructions"], "rules"); + assert!(!translated.to_string().contains("allowed_domains")); + } + + #[test] + fn search_constraints_parse_flag_values() { + use crate::config::parse_search_constraints; + assert_eq!(parse_search_constraints(None), SearchConstraints::Soft); + assert_eq!( + parse_search_constraints(Some("soft")), + SearchConstraints::Soft + ); + assert_eq!( + parse_search_constraints(Some("warning")), + SearchConstraints::Warning + ); + assert_eq!( + parse_search_constraints(Some("hard")), + SearchConstraints::Hard + ); + assert_eq!( + parse_search_constraints(Some("bogus")), + SearchConstraints::Soft + ); } #[test] @@ -1861,14 +2083,9 @@ mod tests { "tools":[{"type":"code_execution_20260120","name":"code_execution"}] })) .unwrap(); - let error = translate_request_with_options( - &request, - "grok-4.5".into(), - crate::config::GrokToolImageMode::Omit, - false, - ) - .unwrap_err() - .to_string(); + let error = translate_options(&request, false, SearchConstraints::Soft) + .unwrap_err() + .to_string(); assert_eq!(error, "unsupported tool type: code_execution_20260120"); } @@ -1885,14 +2102,9 @@ mod tests { "tools":[{"type":kind,"name":name}] })) .unwrap(); - let error = translate_request_with_options( - &request, - "grok-4.5".into(), - crate::config::GrokToolImageMode::Omit, - false, - ) - .unwrap_err() - .to_string(); + let error = translate_options(&request, false, SearchConstraints::Hard) + .unwrap_err() + .to_string(); assert_eq!(error, format!("unsupported tool type: {kind}")); } } @@ -1905,14 +2117,9 @@ mod tests { "tools":[{"name":"WebSearch","input_schema":{"type":"object"},"invented":1}] })) .unwrap(); - let error = translate_request_with_options( - &request, - "grok-4.5".into(), - crate::config::GrokToolImageMode::Omit, - false, - ) - .unwrap_err() - .to_string(); + let error = translate_options(&request, false, SearchConstraints::Soft) + .unwrap_err() + .to_string(); assert_eq!(error, "unsupported tool field: invented"); } @@ -1932,15 +2139,7 @@ mod tests { "tools":[tool] })) .unwrap(); - assert!( - translate_request_with_options( - &request, - "grok-4.5".into(), - crate::config::GrokToolImageMode::Omit, - false, - ) - .is_err() - ); + assert!(translate_options(&request, false, SearchConstraints::Soft).is_err()); } }