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()); } }