From 5f1502e70b9d5dd7284d61aa43c66f752eef5e21 Mon Sep 17 00:00:00 2001 From: Hassaan Saleem Date: Fri, 17 Jul 2026 13:41:13 +0000 Subject: [PATCH 1/2] Support newer Claude Code request fields for Grok Claude Code >= 2.1 sends four request shapes the Grok translator rejects, which makes current Claude Code unusable against the Grok provider: 400 unsupported Grok request field: diagnostics 400 unsupported system block 400 unsupported tool field: eager_input_streaming 400 tool result supports text children only Each is accepted and dropped rather than forwarded, matching how cache_control is already handled (verified, then not sent upstream): - diagnostics: request diagnostics with no Grok equivalent. - cache_control.scope: Claude Code now sends `scope: "global"` alongside type/ttl. valid_cache_control only permitted type/ttl, so any block carrying it failed as "unsupported system block" - the block itself was fine, only the scope key was unrecognised. - eager_input_streaming: a per-tool streaming hint with no Grok equivalent. - tool_reference children inside tool_result: carry no text for the model, so they are skipped while the text children are preserved. Unknown fields are still rejected: an unrecognised cache_control scope, unknown top-level/tool/tool_result keys and non-text tool_result children all continue to fail loudly, and the existing rejection tests are unchanged. Field shapes were taken from a captured Claude Code 2.1.197 request. --- src/providers/grok/translate/request.rs | 133 ++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 11 deletions(-) diff --git a/src/providers/grok/translate/request.rs b/src/providers/grok/translate/request.rs index d706c6e..44081be 100644 --- a/src/providers/grok/translate/request.rs +++ b/src/providers/grok/translate/request.rs @@ -231,6 +231,9 @@ fn reject_unknown_top_level(req: &MessagesRequest) -> anyhow::Result<()> { "tools", "tool_choice", "context_management", + // Claude Code >= 2.1 attaches request diagnostics; Grok has no + // equivalent, so it is accepted and dropped rather than forwarded. + "diagnostics", "metadata", "output_config", "thinking", @@ -290,7 +293,17 @@ fn parse_tools(value: Option<&Value>) -> anyhow::Result>> { .as_object() .ok_or_else(|| anyhow::anyhow!("tool must be an object"))?; for key in obj.keys() { - if !["name", "description", "input_schema", "cache_control"].contains(&key.as_str()) { + // `eager_input_streaming` is a Claude Code >= 2.1 streaming hint with no + // Grok equivalent; accept and drop it rather than reject the request. + if ![ + "name", + "description", + "input_schema", + "cache_control", + "eager_input_streaming", + ] + .contains(&key.as_str()) + { anyhow::bail!("unsupported tool field: {key}"); } } @@ -477,12 +490,18 @@ fn parse_message( .ok_or_else(|| anyhow::anyhow!("tool result content is required"))?; let output = match value { Value::String(text) => text.clone(), - Value::Array(parts) => parts - .iter() - .map(|part| { + Value::Array(parts) => { + let mut texts = Vec::new(); + for part in parts { let part = part.as_object().ok_or_else(|| { anyhow::anyhow!("tool result child must be an object") })?; + // Claude Code >= 2.1 can attach `tool_reference` children + // alongside the textual output. They carry no text for the + // model, and Grok has no equivalent, so drop them. + if part.get("type").and_then(Value::as_str) == Some("tool_reference") { + continue; + } if part.get("type").and_then(Value::as_str) != Some("text") || part.keys().any(|key| { !["type", "text", "cache_control"].contains(&key.as_str()) @@ -491,12 +510,14 @@ fn parse_message( { anyhow::bail!("tool result supports text children only"); } - part.get("text") - .and_then(Value::as_str) - .ok_or_else(|| anyhow::anyhow!("tool result text is invalid")) - }) - .collect::>>()? - .join(""), + texts.push( + part.get("text").and_then(Value::as_str).ok_or_else(|| { + anyhow::anyhow!("tool result text is invalid") + })?, + ); + } + texts.join("") + } _ => anyhow::bail!("tool result supports text only"), }; out.push(GrokInputItem::FunctionCallOutput { @@ -516,11 +537,19 @@ fn valid_cache_control(value: Option<&Value>) -> bool { let Some(object) = value.as_object() else { return false; }; - object.keys().all(|key| key == "type" || key == "ttl") + object + .keys() + .all(|key| key == "type" || key == "ttl" || key == "scope") && object.get("type").and_then(Value::as_str) == Some("ephemeral") && object .get("ttl") .is_none_or(|ttl| matches!(ttl.as_str(), Some("5m") | Some("1h"))) + // Claude Code >= 2.1 sends `scope: "global"` on cache_control. Grok does + // not support prompt caching, so cache_control is verified and dropped; + // accepting the scope key keeps those requests translatable. + && object + .get("scope") + .is_none_or(|scope| matches!(scope.as_str(), Some("global"))) } fn flush_message(role: &str, content: &mut Vec, out: &mut Vec) { @@ -668,6 +697,88 @@ mod tests { assert_eq!(translated.input.len(), 1); } + #[test] + fn grok_translation_accepts_claude_code_diagnostics_without_forwarding_it() { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "messages":[{"role":"user","content":"hello"}], + "diagnostics":{"request_id":"abc"} + })) + .unwrap(); + let translated = + serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap(); + assert!(!translated.to_string().contains("diagnostics")); + } + + #[test] + fn grok_translation_accepts_cache_control_scope_without_forwarding_it() { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "system":[{"type":"text","text":"rules","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global"}}], + "messages":[{"role":"user","content":"hello"}] + })) + .unwrap(); + let translated = + serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap(); + assert!( + translated["instructions"] + .as_str() + .unwrap() + .starts_with("rules") + ); + assert!(!translated.to_string().contains("cache_control")); + } + + #[test] + fn grok_translation_rejects_unknown_cache_control_scope() { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral","scope":"session"}}]}] + })) + .unwrap(); + assert!(translate_request(&request, "grok-4.5".into()).is_err()); + } + + #[test] + fn grok_translation_accepts_claude_code_eager_input_streaming_without_forwarding_it() { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "messages":[{"role":"user","content":"hello"}], + "tools":[{ + "name":"lookup", + "description":"d", + "input_schema":{"type":"object"}, + "eager_input_streaming":true + }] + })) + .unwrap(); + let translated = + serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap(); + assert!( + translated["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| { tool["type"] == "function" && tool["name"] == "lookup" }) + ); + assert!(!translated.to_string().contains("eager_input_streaming")); + } + + #[test] + fn grok_translation_drops_tool_reference_children_in_tool_results() { + let request = request_with_blocks(serde_json::json!([ + {"type":"tool_result","tool_use_id":"call_1","content":[ + {"type":"text","text":"ok"}, + {"type":"tool_reference","name":"lookup"} + ]} + ])); + let translated = + serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap(); + let rendered = translated.to_string(); + assert!(!rendered.contains("tool_reference")); + assert!(rendered.contains("ok")); + } + #[test] fn grok_translation_rejects_unknown_fields() { let request: MessagesRequest = serde_json::from_value( From 164ea1d57a30002cc6536f43b22769a20d50a41b Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 18 Jul 2026 22:00:25 +0300 Subject: [PATCH 2/2] grok: validate accepted anthropic fields The Grok translator needs to ignore several Anthropic request fields that have no Grok equivalent, but accepting their names without validating their shapes weakens the translator's fail-loud behavior. Validate cache diagnostics, eager tool input streaming, and tool reference children before dropping them. Align the regression fixtures with Anthropic's published wire format by using previous_message_id and tool_name, and cover malformed values and unknown nested fields. --- src/providers/grok/translate/request.rs | 97 +++++++++++++++++++++---- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/src/providers/grok/translate/request.rs b/src/providers/grok/translate/request.rs index 44081be..82a026f 100644 --- a/src/providers/grok/translate/request.rs +++ b/src/providers/grok/translate/request.rs @@ -231,8 +231,6 @@ fn reject_unknown_top_level(req: &MessagesRequest) -> anyhow::Result<()> { "tools", "tool_choice", "context_management", - // Claude Code >= 2.1 attaches request diagnostics; Grok has no - // equivalent, so it is accepted and dropped rather than forwarded. "diagnostics", "metadata", "output_config", @@ -248,9 +246,26 @@ fn reject_unknown_top_level(req: &MessagesRequest) -> anyhow::Result<()> { anyhow::bail!("unsupported Grok request field: {key}"); } } + if !valid_diagnostics(req.extra.get("diagnostics")) { + anyhow::bail!("unsupported diagnostics"); + } Ok(()) } +fn valid_diagnostics(value: Option<&Value>) -> bool { + let Some(value) = value else { return true }; + let Some(object) = value.as_object() else { + return value.is_null(); + }; + object.keys().all(|key| key == "previous_message_id") + && object.get("previous_message_id").is_none_or(|id| { + id.is_null() + || id + .as_str() + .is_some_and(|previous_message_id| !previous_message_id.is_empty()) + }) +} + fn parse_system(value: Option<&Value>) -> anyhow::Result> { let Some(value) = value else { return Ok(None) }; match value { @@ -293,8 +308,6 @@ fn parse_tools(value: Option<&Value>) -> anyhow::Result>> { .as_object() .ok_or_else(|| anyhow::anyhow!("tool must be an object"))?; for key in obj.keys() { - // `eager_input_streaming` is a Claude Code >= 2.1 streaming hint with no - // Grok equivalent; accept and drop it rather than reject the request. if ![ "name", "description", @@ -310,6 +323,12 @@ fn parse_tools(value: Option<&Value>) -> anyhow::Result>> { if !valid_cache_control(obj.get("cache_control")) { anyhow::bail!("unsupported tool cache_control"); } + if obj + .get("eager_input_streaming") + .is_some_and(|value| !value.is_null() && !value.is_boolean()) + { + anyhow::bail!("tool eager_input_streaming must be boolean"); + } let name = obj .get("name") .and_then(Value::as_str) @@ -496,10 +515,17 @@ fn parse_message( let part = part.as_object().ok_or_else(|| { anyhow::anyhow!("tool result child must be an object") })?; - // Claude Code >= 2.1 can attach `tool_reference` children - // alongside the textual output. They carry no text for the - // model, and Grok has no equivalent, so drop them. if part.get("type").and_then(Value::as_str) == Some("tool_reference") { + if part.keys().any(|key| { + !["type", "tool_name", "cache_control"].contains(&key.as_str()) + }) || part + .get("tool_name") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + || !valid_cache_control(part.get("cache_control")) + { + anyhow::bail!("unsupported tool_reference child"); + } continue; } if part.get("type").and_then(Value::as_str) != Some("text") @@ -544,9 +570,6 @@ fn valid_cache_control(value: Option<&Value>) -> bool { && object .get("ttl") .is_none_or(|ttl| matches!(ttl.as_str(), Some("5m") | Some("1h"))) - // Claude Code >= 2.1 sends `scope: "global"` on cache_control. Grok does - // not support prompt caching, so cache_control is verified and dropped; - // accepting the scope key keeps those requests translatable. && object .get("scope") .is_none_or(|scope| matches!(scope.as_str(), Some("global"))) @@ -698,11 +721,11 @@ mod tests { } #[test] - fn grok_translation_accepts_claude_code_diagnostics_without_forwarding_it() { + fn grok_translation_accepts_cache_diagnostics_without_forwarding_it() { let request: MessagesRequest = serde_json::from_value(serde_json::json!({ "model":"grok-4.5", "messages":[{"role":"user","content":"hello"}], - "diagnostics":{"request_id":"abc"} + "diagnostics":{"previous_message_id":"msg_previous"} })) .unwrap(); let translated = @@ -710,6 +733,24 @@ mod tests { assert!(!translated.to_string().contains("diagnostics")); } + #[test] + fn grok_translation_rejects_malformed_cache_diagnostics() { + for diagnostics in [ + serde_json::json!(true), + serde_json::json!({"previous_message_id": 1}), + serde_json::json!({"previous_message_id": ""}), + serde_json::json!({"previous_message_id": null, "unknown": true}), + ] { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "messages":[{"role":"user","content":"hello"}], + "diagnostics": diagnostics + })) + .unwrap(); + assert!(translate_request(&request, "grok-4.5".into()).is_err()); + } + } + #[test] fn grok_translation_accepts_cache_control_scope_without_forwarding_it() { let request: MessagesRequest = serde_json::from_value(serde_json::json!({ @@ -764,12 +805,27 @@ mod tests { assert!(!translated.to_string().contains("eager_input_streaming")); } + #[test] + fn grok_translation_rejects_malformed_eager_input_streaming() { + let request: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model":"grok-4.5", + "messages":[{"role":"user","content":"hello"}], + "tools":[{ + "name":"lookup", + "input_schema":{"type":"object"}, + "eager_input_streaming":"true" + }] + })) + .unwrap(); + assert!(translate_request(&request, "grok-4.5".into()).is_err()); + } + #[test] fn grok_translation_drops_tool_reference_children_in_tool_results() { let request = request_with_blocks(serde_json::json!([ {"type":"tool_result","tool_use_id":"call_1","content":[ {"type":"text","text":"ok"}, - {"type":"tool_reference","name":"lookup"} + {"type":"tool_reference","tool_name":"lookup"} ]} ])); let translated = @@ -779,6 +835,21 @@ mod tests { assert!(rendered.contains("ok")); } + #[test] + fn grok_translation_rejects_malformed_tool_reference_children() { + for child in [ + serde_json::json!({"type":"tool_reference","name":"lookup"}), + serde_json::json!({"type":"tool_reference","tool_name":""}), + serde_json::json!({"type":"tool_reference","tool_name":"lookup","unknown":true}), + serde_json::json!({"type":"tool_reference","tool_name":"lookup","cache_control":{"type":"persistent"}}), + ] { + let request = request_with_blocks(serde_json::json!([ + {"type":"tool_result","tool_use_id":"call_1","content":[child]} + ])); + assert!(translate_request(&request, "grok-4.5".into()).is_err()); + } + } + #[test] fn grok_translation_rejects_unknown_fields() { let request: MessagesRequest = serde_json::from_value(