diff --git a/crates/service/src/gateway/request/official_responses_http.rs b/crates/service/src/gateway/request/official_responses_http.rs index 5d5587743..c1c3ea2ac 100644 --- a/crates/service/src/gateway/request/official_responses_http.rs +++ b/crates/service/src/gateway/request/official_responses_http.rs @@ -348,6 +348,45 @@ fn ensure_tools_list(path: &str, obj: &mut Map) -> bool { } } +// Historical hosted search calls need a matching tool declaration even when +// Codex sends no tools during context compaction. If there were no tools, +// preserve that intent by disabling new tool calls. +fn ensure_historical_web_search_tool(path: &str, obj: &mut Map) -> bool { + if !is_standard_responses_path(path) { + return false; + } + let has_search_history = obj + .get("input") + .and_then(Value::as_array) + .is_some_and(|input| { + input + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some("web_search_call")) + }); + if !has_search_history { + return false; + } + let Some(tools) = obj.get_mut("tools").and_then(Value::as_array_mut) else { + return false; + }; + if tools.iter().any(|tool| { + tool.get("type") + .and_then(Value::as_str) + .is_some_and(|kind| kind.starts_with("web_search")) + }) { + return false; + } + let had_no_tools = tools.is_empty(); + tools.push(serde_json::json!({ + "type": "web_search", + "external_web_access": false + })); + if had_no_tools { + obj.insert("tool_choice".to_string(), Value::String("none".to_string())); + } + true +} + fn ensure_parallel_tool_calls_bool(path: &str, obj: &mut Map) -> bool { if !is_responses_path(path) { return false; @@ -651,6 +690,11 @@ pub(crate) fn apply_codex_http_request_rules( result.changed = true; } } + // Native Codex skips compatibility defaults, but still needs a matching + // hosted-tool declaration when replaying search history during compaction. + if ensure_historical_web_search_tool(path, obj) { + result.changed = true; + } if !is_compact_path(path) { let had_stream_passthrough = obj.contains_key("stream_passthrough"); if use_codex_compat_rewrite { diff --git a/crates/service/src/gateway/request/tests/request_rewrite_tests.rs b/crates/service/src/gateway/request/tests/request_rewrite_tests.rs index b77b0fb2b..4002c86b2 100644 --- a/crates/service/src/gateway/request/tests/request_rewrite_tests.rs +++ b/crates/service/src/gateway/request/tests/request_rewrite_tests.rs @@ -1747,6 +1747,138 @@ fn responses_defaults_tool_choice_and_reasoning_include_for_codex_backend() { })); } +#[test] +fn responses_historical_web_search_also_repairs_native_codex_requests() { + let _guard = crate::test_env_guard(); + let body = json!({ + "model": "gpt-6.1-sol", + "input": [{"type": "web_search_call", "id": "ws_old", "status": "completed"}], + "tools": [], + "tool_choice": "auto", + "stream": false, + "store": true + }); + let out = apply_request_overrides_with_service_tier_and_prompt_cache_key_scope( + "/v1/responses", + serde_json::to_vec(&body).expect("serialize request"), + None, + None, + None, + Some("https://chatgpt.com/backend-api/codex"), + None, + false, + ); + let value: serde_json::Value = serde_json::from_slice(&out).expect("parse request"); + assert_eq!(value["input"], body["input"]); + assert_eq!( + value["tools"], + json!([{"type": "web_search", "external_web_access": false}]) + ); + assert_eq!(value["tool_choice"], "none"); + assert_eq!(value["stream"], false); + assert_eq!(value["store"], true); +} + +#[test] +fn responses_declares_historical_web_search_without_enabling_new_searches() { + let _guard = crate::test_env_guard(); + let body = json!({ + "model": "gpt-6.1-sol", + "input": [ + {"type": "web_search_call", "id": "ws_old", "status": "completed"}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "result"}]} + ], + "tools": [], + "tool_choice": "auto" + }); + let out = apply_codex_compat_request_overrides( + "/v1/responses", + serde_json::to_vec(&body).expect("serialize request"), + None, + None, + Some("https://chatgpt.com/backend-api/codex"), + ); + let value: serde_json::Value = serde_json::from_slice(&out).expect("parse request"); + assert_eq!(value["input"], body["input"]); + assert_eq!( + value["tools"], + json!([{"type": "web_search", "external_web_access": false}]) + ); + assert_eq!(value["tool_choice"], "none"); +} + +#[test] +fn responses_historical_web_search_preserves_other_tools_and_existing_declaration() { + let _guard = crate::test_env_guard(); + let history = json!([{"type": "web_search_call", "id": "ws_old"}]); + let function = json!({"type": "function", "name": "ping", "parameters": {"type": "object"}}); + for tools in [ + json!([function]), + json!([{"type": "web_search", "external_web_access": true}]), + ] { + let body = json!({ + "model": "gpt-6.1-sol", + "input": history, + "tools": tools, + "tool_choice": "auto" + }); + let out = apply_codex_compat_request_overrides( + "/v1/responses", + serde_json::to_vec(&body).expect("serialize request"), + None, + None, + Some("https://chatgpt.com/backend-api/codex"), + ); + let value: serde_json::Value = serde_json::from_slice(&out).expect("parse request"); + assert_eq!(value["tool_choice"], "auto"); + let expected_len = if tools[0]["type"] == "function" { 2 } else { 1 }; + assert_eq!(value["tools"].as_array().unwrap().len(), expected_len); + assert_eq!(value["tools"][0], tools[0]); + } +} + +#[test] +fn responses_historical_web_search_fix_is_codex_backend_only() { + let _guard = crate::test_env_guard(); + let body = json!({ + "model": "gpt-6.1-sol", + "input": [{"type": "web_search_call", "id": "ws_old"}], + "tools": [] + }); + for (path, upstream) in [ + ("/v1/responses", "https://example.org/v1"), + ( + "/v1/responses/compact", + "https://chatgpt.com/backend-api/codex", + ), + ] { + let out = apply_codex_compat_request_overrides( + path, + serde_json::to_vec(&body).expect("serialize request"), + None, + None, + Some(upstream), + ); + let value: serde_json::Value = serde_json::from_slice(&out).expect("parse request"); + assert_eq!(value["tools"], json!([])); + } + let no_history = json!({ + "model": "gpt-6.1-sol", + "input": [{"role": "user", "content": "hello"}], + "tools": [] + }); + let out = apply_codex_compat_request_overrides( + "/v1/responses", + serde_json::to_vec(&no_history).expect("serialize request"), + None, + None, + Some("https://chatgpt.com/backend-api/codex"), + ); + let value: serde_json::Value = serde_json::from_slice(&out).expect("parse request"); + assert_eq!(value["tools"], json!([])); + assert_eq!(value["tool_choice"], "auto"); +} + #[test] fn responses_preserve_specific_function_tool_choice_object() { let _guard = crate::test_env_guard();