diff --git a/src/providers/grok/translate/request.rs b/src/providers/grok/translate/request.rs index d706c6e..82a026f 100644 --- a/src/providers/grok/translate/request.rs +++ b/src/providers/grok/translate/request.rs @@ -231,6 +231,7 @@ fn reject_unknown_top_level(req: &MessagesRequest) -> anyhow::Result<()> { "tools", "tool_choice", "context_management", + "diagnostics", "metadata", "output_config", "thinking", @@ -245,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 { @@ -290,13 +308,27 @@ 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()) { + if ![ + "name", + "description", + "input_schema", + "cache_control", + "eager_input_streaming", + ] + .contains(&key.as_str()) + { anyhow::bail!("unsupported tool field: {key}"); } } 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) @@ -477,12 +509,25 @@ 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") })?; + 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") || part.keys().any(|key| { !["type", "text", "cache_control"].contains(&key.as_str()) @@ -491,12 +536,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 +563,16 @@ 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"))) + && 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 +720,136 @@ mod tests { assert_eq!(translated.input.len(), 1); } + #[test] + 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":{"previous_message_id":"msg_previous"} + })) + .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_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!({ + "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_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","tool_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_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(