Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions crates/service/src/gateway/request/official_responses_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,45 @@ fn ensure_tools_list(path: &str, obj: &mut Map<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> bool {
if !is_responses_path(path) {
return false;
Expand Down Expand Up @@ -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 {
Expand Down
132 changes: 132 additions & 0 deletions crates/service/src/gateway/request/tests/request_rewrite_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading