diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 371fc586f..de7f2ca6c 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -246,6 +246,9 @@ impl TranslatingLlmClient { // which keeps the caller's original `model`; force the resolved model so // the upstream always sees the target id. set_json_model(&mut body, model); + if matches!(backend, Backend::OpenAiResponses(_)) { + sanitize_openai_responses_provider_body(&mut body); + } // Strip before `merge_extra_body` so a target can reinstate either field // deliberately via `extra_body`. if matches!(backend, Backend::Anthropic(_)) { @@ -754,6 +757,124 @@ fn set_json_model(body: &mut Value, model: &str) { } } +const CODEX_NAMESPACE_SEPARATOR: &str = "__"; + +// Codex extends Responses with namespace containers and namespaced function +// calls. OpenAI-compatible providers expect a flat Responses tool namespace. +fn sanitize_openai_responses_provider_body(body: &mut Value) { + let Value::Object(object) = body else { + return; + }; + sanitize_openai_responses_input_for_provider(object.get_mut("input")); + sanitize_openai_responses_tools_for_provider(object.get_mut("tools")); + sanitize_openai_responses_tool_choice_for_provider(object.get_mut("tool_choice")); +} + +fn sanitize_openai_responses_input_for_provider(input: Option<&mut Value>) { + let Some(Value::Array(items)) = input else { + return; + }; + for item in items { + let Some(object) = item.as_object_mut() else { + continue; + }; + if object.get("type").and_then(Value::as_str) == Some("function_call") { + qualify_responses_function_name(object); + } + } +} + +fn sanitize_openai_responses_tools_for_provider(tools: Option<&mut Value>) { + let Some(Value::Array(tools)) = tools else { + return; + }; + let mut flat_tools = Vec::with_capacity(tools.len()); + for tool in std::mem::take(tools) { + push_sanitized_openai_responses_tool(&mut flat_tools, tool); + } + *tools = flat_tools; +} + +fn push_sanitized_openai_responses_tool(out: &mut Vec, tool: Value) { + let Value::Object(mut object) = tool else { + out.push(tool); + return; + }; + if object.get("type").and_then(Value::as_str) == Some("namespace") { + let namespace = object + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let Some(Value::Array(children)) = object.remove("tools") else { + out.push(Value::Object(object)); + return; + }; + for child in children { + push_sanitized_namespaced_tool(out, &namespace, child); + } + return; + } + ensure_responses_function_tool_description(&mut object); + out.push(Value::Object(object)); +} + +fn push_sanitized_namespaced_tool(out: &mut Vec, namespace: &str, tool: Value) { + let Value::Object(mut object) = tool else { + out.push(tool); + return; + }; + if object.get("type").and_then(Value::as_str) == Some("function") { + qualify_responses_function_name_with_namespace(&mut object, namespace); + ensure_responses_function_tool_description(&mut object); + } + out.push(Value::Object(object)); +} + +fn sanitize_openai_responses_tool_choice_for_provider(tool_choice: Option<&mut Value>) { + let Some(Value::Object(object)) = tool_choice else { + return; + }; + if object.get("type").and_then(Value::as_str) == Some("function") { + qualify_responses_function_name(object); + } +} + +fn qualify_responses_function_name(object: &mut Map) { + let namespace = object + .remove("namespace") + .and_then(|value| value.as_str().map(ToOwned::to_owned)); + let Some(namespace) = namespace.as_deref() else { + return; + }; + qualify_responses_function_name_with_namespace(object, namespace); +} + +fn qualify_responses_function_name_with_namespace( + object: &mut Map, + namespace: &str, +) { + if namespace.is_empty() { + return; + } + let Some(name) = object.get("name").and_then(Value::as_str) else { + return; + }; + let prefix = format!("{namespace}{CODEX_NAMESPACE_SEPARATOR}"); + if name.starts_with(&prefix) { + return; + } + object.insert("name".to_string(), Value::String(format!("{prefix}{name}"))); +} + +fn ensure_responses_function_tool_description(object: &mut Map) { + if object.get("type").and_then(Value::as_str) == Some("function") + && !matches!(object.get("description"), Some(Value::String(_))) + { + object.insert("description".to_string(), Value::String(String::new())); + } +} + // Drops fields accepted by OpenAI-like APIs but rejected by Anthropic Messages. // // A router can serve earlier turns of a session from an OpenAI-format target and @@ -957,6 +1078,14 @@ mod tests { )] } + fn responses_map(base_url: &str) -> Vec { + vec![ModelConfig::new( + "gpt", + Backend::OpenAiResponses(config(base_url)), + None, + )] + } + fn chat_map_with_extra_body( base_url: &str, extra_body: BTreeMap, @@ -2190,6 +2319,99 @@ mod tests { Ok(()) } + #[tokio::test] + async fn openai_responses_backend_flattens_codex_namespaces_before_upstream() + -> std::result::Result<(), Box> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "resp_1", + "model": "gpt", + "object": "response", + "created_at": 0, + "status": "completed", + "output": [{ + "type": "function_call", + "call_id": "call_1", + "name": "mcp__open_websearch__search", + "arguments": "{\"q\":\"rust\"}" + }], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2} + }))) + .mount(&server) + .await; + + let client = TranslatingLlmClient::new(&responses_map(&format!("{}/v1", server.uri())))?; + let parameters = json!({"type": "object", "properties": {"q": {"type": "string"}}}); + let raw = json!({ + "model": "client-facing", + "input": [{ + "type": "function_call", + "call_id": "call_0", + "name": "search", + "namespace": "mcp__open_websearch", + "arguments": "{}" + }], + "tool_choice": { + "type": "function", + "name": "search", + "namespace": "mcp__open_websearch" + }, + "tools": [{ + "type": "namespace", + "name": "mcp__open_websearch", + "tools": [{ + "type": "function", + "name": "search", + "parameters": parameters.clone() + }] + }] + }); + + let RawResponse::Buffered(body) = client + .call_rewrite_model_raw( + raw, + None, + Some(&ModelId::from("gpt")), + WireFormat::OpenAiResponses, + ) + .await? + else { + panic!("expected a buffered response"); + }; + + let received = server + .received_requests() + .await + .ok_or("request recording should be enabled")?; + let request_body: Value = serde_json::from_slice(&received[0].body)?; + assert_eq!(request_body["model"], "gpt"); + assert_eq!( + request_body["tools"], + json!([{ + "type": "function", + "name": "mcp__open_websearch__search", + "description": "", + "parameters": parameters + }]) + ); + assert_eq!( + request_body["tool_choice"], + json!({"type": "function", "name": "mcp__open_websearch__search"}) + ); + assert_eq!( + request_body["input"][0]["name"], + "mcp__open_websearch__search" + ); + assert!(request_body["input"][0].get("namespace").is_none()); + + assert_eq!(body["output"][0]["type"], "function_call"); + assert_eq!(body["output"][0]["name"], "search"); + assert_eq!(body["output"][0]["namespace"], "mcp__open_websearch"); + Ok(()) + } + // Raw path, streaming: an inbound `stream: true` request yields an unframed stream // of OpenAI Chat chunk objects whose deltas reassemble the completion. #[tokio::test] diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 03e11f15b..ff3d71cdf 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -653,10 +653,17 @@ fn decode_responses_reasoning_item(item: &Map) -> Vec Some(json!({ - "type": "reasoning", - "content": [{"type": "reasoning_text", "text": text}], - "summary": [], - })), + details, + } => encode_responses_reasoning_input(text, details), ContentBlock::ToolCall(call) => { // A Responses client dispatches on name plus namespace, so undo the // qualification this request applied for a flat upstream. @@ -1125,6 +1131,37 @@ fn encode_responses_special_input( } } +// Encodes reasoning in the shape accepted for Responses input history. Response +// output items may carry `content`, but replayed input items must avoid it. +fn encode_responses_reasoning_input(text: &str, details: &[Value]) -> Option { + for detail in details { + let Some(detail) = detail.as_object() else { + continue; + }; + if detail.get("type").and_then(Value::as_str) != Some("reasoning") { + continue; + } + let mut item = Map::new(); + item.insert("type".to_string(), Value::String("reasoning".to_string())); + for field in ["id", "summary", "encrypted_content"] { + if let Some(value) = detail.get(field) { + item.insert(field.to_string(), value.clone()); + } + } + item.entry("summary".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + return Some(Value::Object(item)); + } + + if text.is_empty() { + return None; + } + Some(json!({ + "type": "reasoning", + "summary": [{"type": "summary_text", "text": text}], + })) +} + // Maps normalized roles back to Responses role strings. fn role_to_responses(role: Role) -> &'static str { match role { diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index d27b21cd9..d7169c8a0 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1436,13 +1436,108 @@ fn responses_reasoning_items_round_trip_through_decode_and_encode() -> TestResul ] ); assert_eq!( - input[1]["content"], - json!([{"type": "reasoning_text", "text": "Simple ls."}]) + input[1]["summary"], + json!([{"type": "summary_text", "text": "Simple ls."}]) ); + assert!(input[1].get("content").is_none()); assert_eq!(input[2]["call_id"], "call-ls"); Ok(()) } +// Verifies Codex-style encrypted reasoning remains replayable after a prompt +// mutation drops exact replay, without synthesizing invalid reasoning content. +#[test] +fn responses_encrypted_reasoning_replays_without_input_content() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "switchyard", + "input": [ + {"type": "message", "role": "user", "content": "Inspect"}, + { + "type": "reasoning", + "id": "rs_prior", + "summary": [], + "encrypted_content": "opaque-encrypted-reasoning" + }, + { + "type": "function_call", + "name": "exec_command", + "call_id": "call-1", + "arguments": "{\"cmd\":\"pwd\"}" + }, + {"type": "function_call_output", "call_id": "call-1", "output": "/app"} + ] + }); + + let policy = TranslationPolicy::default(); + let mut request = engine + .decode_request(WireFormat::OpenAiResponses, &body, &policy)? + .request; + prepare_request_for_target( + &mut request, + &"openai/openai/gpt-5.6-sol".into(), + Some("[router-guidance] Continue from the current state."), + ); + + let output = engine + .encode_request(WireFormat::OpenAiResponses, &request, &policy)? + .body; + + assert_eq!(output["model"], "openai/openai/gpt-5.6-sol"); + assert_eq!( + output["instructions"], + "[router-guidance] Continue from the current state." + ); + let input = output["input"].as_array().ok_or("input is not an array")?; + let reasoning = input + .iter() + .find(|item| item["type"] == "reasoning") + .ok_or("reasoning item was not replayed")?; + assert_eq!(reasoning["id"], "rs_prior"); + assert_eq!(reasoning["summary"], json!([])); + assert_eq!(reasoning["encrypted_content"], "opaque-encrypted-reasoning"); + assert!(reasoning.get("content").is_none()); + Ok(()) +} + +// Verifies an empty non-encrypted reasoning item is omitted instead of being +// replayed as an empty assistant message. +#[test] +fn responses_empty_reasoning_without_encrypted_content_is_omitted() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + preservation: switchyard_translation::PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + let body = json!({ + "model": "gpt-5", + "input": [ + {"type": "message", "role": "user", "content": "Inspect"}, + {"type": "reasoning", "summary": []}, + {"type": "message", "role": "user", "content": "Continue"} + ] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &policy, + )? + .body; + + let input = output["input"].as_array().ok_or("input is not an array")?; + let item_types = input + .iter() + .map(|item| item["type"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(item_types, vec!["message", "message"]); + assert_eq!(input[0]["role"], "user"); + assert_eq!(input[1]["role"], "user"); + Ok(()) +} + // Verifies Responses JSON schema text format maps to Chat response_format shape. #[test] fn responses_json_schema_text_format_maps_to_chat_response_format() -> TestResult {