fix(codex): reconcile replayed web_search_call history with tool declarations

- Inject a web_search tool declaration for standard Codex requests when input replays web_search_call items without a web_search tool so upstream response protection remains available during compaction.
- Strip replayed web_search_call items from Responses Lite requests where top-level hosted tool declarations are omitted.

Closes: #6770
This commit is contained in:
Luis Pater
2026-10-19 19:00:41 +08:00
parent 8df4e42ab9
commit e7d4c7316f
6 changed files with 361 additions and 0 deletions

View File

@@ -404,6 +404,81 @@ func normalizeCodexInstructions(body []byte, nativeRequest ...bool) []byte {
var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`)
var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`)
var webSearchToolJSON = []byte(`{"type":"web_search"}`)
var webSearchToolArrayJSON = []byte(`[{"type":"web_search"}]`)
func isCodexWebSearchToolType(toolType string) bool {
switch strings.TrimSpace(toolType) {
case "web_search", "web_search_preview", "web_search_preview_2025_03_11":
return true
default:
return false
}
}
// ensureWebSearchToolForHistory reconciles replayed web_search_call input items
// with the request's tool declarations so upstream Codex does not fail with
// "response protection is unavailable". Standard Responses requests receive a
// {"type":"web_search"} tool declaration when missing, while Responses Lite
// requests (which omit top-level hosted tools) strip the replayed search items.
func ensureWebSearchToolForHistory(body []byte, headers http.Header) []byte {
if !bytes.Contains(body, []byte("web_search_call")) {
return body
}
input := util.GetGJSONBytesNoCopy(body, "input")
if !input.IsArray() {
return body
}
hasWebSearchCall := false
input.ForEach(func(_, item gjson.Result) bool {
if strings.TrimSpace(item.Get("type").String()) == "web_search_call" {
hasWebSearchCall = true
return false
}
return true
})
if !hasWebSearchCall {
return body
}
tools := util.GetGJSONBytesNoCopy(body, "tools")
if tools.IsArray() {
for _, t := range tools.Array() {
if isCodexWebSearchToolType(t.Get("type").String()) {
return body
}
}
}
if util.IsCodexResponsesLiteRequest(body, headers) {
items := input.Array()
rebuilt := make([]string, 0, len(items))
for _, item := range items {
if strings.TrimSpace(item.Get("type").String()) == "web_search_call" {
continue
}
rebuilt = append(rebuilt, item.Raw)
}
updated, errSet := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(rebuilt, ",")+"]"))
if errSet != nil {
return body
}
return updated
}
if !tools.Exists() || !tools.IsArray() {
updated, errSet := sjson.SetRawBytes(body, "tools", webSearchToolArrayJSON)
if errSet != nil {
return body
}
return updated
}
updated, errSet := sjson.SetRawBytes(body, "tools.-1", webSearchToolJSON)
if errSet != nil {
return body
}
return updated
}
func isCodexFreePlanAuth(auth *cliproxyauth.Auth) bool {
if auth == nil || auth.Attributes == nil {