fix(openai): declare web_search when OAuth history replays web_search_call

ChatGPT's Codex endpoint now rejects requests whose input replays a hosted
web_search_call item unless tools declares web_search, failing the stream
with "response protection is unavailable". Codex local context compaction
sends the full history with tools:[], so every compaction after a web search
fails and the session gets stuck in a reconnect loop.

For OAuth transform, passthrough and WebSocket paths, append a cached-only
web_search tool when the history contains web_search_call and no web_search
tool is declared. When the caller offered no tools, pin tool_choice to
"none" so the injected tool cannot be invoked. The /responses/compact wire
shape is left untouched.

Fixes #7927
This commit is contained in:
IrisRainbowNeko
2026-10-09 11:03:13 +08:00
committed by shaw
parent d0392edf06
commit dab3b87ea2
5 changed files with 375 additions and 0 deletions
@@ -347,6 +347,12 @@ func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuth
result.Modified = true
}
// Runs after input filtering so it sees the final replayed history. The
// compact endpoint has its own wire shape and is left untouched.
if !opts.IsCompact && ensureOpenAIOAuthWebSearchToolForHistory(reqBody) {
result.Modified = true
}
return result
}
@@ -1494,6 +1494,12 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
normalized = next
changed = true
}
webSearchBody, webSearchChanged, err := ensureOpenAIOAuthWebSearchToolForHistoryBody(normalized)
if err != nil {
return body, false, fmt.Errorf("normalize websocket body: %w", err)
}
normalized = webSearchBody
changed = changed || webSearchChanged
}
needsOrphanCleanup := account != nil && account.IsOpenAIOAuthLike() &&
gjson.GetBytes(normalized, "input").IsArray()
@@ -1628,6 +1634,15 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo
}
}
if !compact {
webSearchBody, webSearchChanged, err := ensureOpenAIOAuthWebSearchToolForHistoryBody(normalized)
if err != nil {
return body, false, fmt.Errorf("normalize passthrough body: %w", err)
}
normalized = webSearchBody
changed = changed || webSearchChanged
}
if compact {
if store := gjson.GetBytes(normalized, "store"); store.Exists() {
next, err := sjson.DeleteBytes(normalized, "store")
@@ -0,0 +1,184 @@
package service
import (
"bytes"
"fmt"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// The ChatGPT internal Codex endpoint rejects a request whose input replays a
// hosted web_search_call item unless the request also declares the web_search
// tool; the stream then fails with "response protection is unavailable".
// Codex's local context compaction sends the full history with tools:[], so
// every compaction after a web search fails (#7927).
//
// Declare a cached-only web_search tool for such requests. When the caller
// declared no tools at all, also pin tool_choice to "none" so the injected
// tool cannot be invoked and the request keeps its no-tools semantics.
const openAIWebSearchCallItemType = "web_search_call"
var openAIWebSearchHistoryTool = map[string]any{
"type": "web_search",
"external_web_access": false,
}
func isOpenAIWebSearchToolType(toolType string) bool {
return strings.HasPrefix(strings.TrimSpace(toolType), "web_search")
}
func openAIInputHasWebSearchCall(rawInput any) bool {
input, ok := rawInput.([]any)
if !ok {
return false
}
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if ok && strings.TrimSpace(firstNonEmptyString(item["type"])) == openAIWebSearchCallItemType {
return true
}
}
return false
}
func openAIToolsContainWebSearch(rawTools any) bool {
tools, ok := rawTools.([]any)
if !ok {
return false
}
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if ok && isOpenAIWebSearchToolType(firstNonEmptyString(tool["type"])) {
return true
}
}
return false
}
func openAIInputAdditionalToolsContainWebSearch(rawInput any) bool {
input, ok := rawInput.([]any)
if !ok {
return false
}
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
continue
}
if openAIToolsContainWebSearch(item["tools"]) {
return true
}
}
return false
}
// shouldPinOpenAIWebSearchHistoryToolChoice reports whether tool_choice may be
// forced to "none": only when the caller offered no tools, so the choice
// cannot meaningfully be anything else.
func shouldPinOpenAIWebSearchHistoryToolChoice(choice any) bool {
switch typed := choice.(type) {
case nil:
return true
case string:
normalized := strings.ToLower(strings.TrimSpace(typed))
return normalized == "" || normalized == "auto" || normalized == "none"
default:
return false
}
}
// ensureOpenAIOAuthWebSearchToolForHistory is the map variant used by the
// Codex OAuth transform.
func ensureOpenAIOAuthWebSearchToolForHistory(reqBody map[string]any) bool {
if reqBody == nil || !openAIInputHasWebSearchCall(reqBody["input"]) {
return false
}
if openAIToolsContainWebSearch(reqBody["tools"]) || openAIInputAdditionalToolsContainWebSearch(reqBody["input"]) {
return false
}
tools, _ := reqBody["tools"].([]any)
callerDeclaredTools := len(tools) > 0
reqBody["tools"] = append(tools, cloneOpenAIWebSearchHistoryTool())
if !callerDeclaredTools {
if choice, exists := reqBody["tool_choice"]; !exists || shouldPinOpenAIWebSearchHistoryToolChoice(choice) {
reqBody["tool_choice"] = "none"
}
}
return true
}
// ensureOpenAIOAuthWebSearchToolForHistoryBody is the raw-body variant used by
// the passthrough and WebSocket paths; it avoids decoding the whole body.
func ensureOpenAIOAuthWebSearchToolForHistoryBody(body []byte) ([]byte, bool, error) {
if len(body) == 0 || !bytes.Contains(body, []byte(openAIWebSearchCallItemType)) {
return body, false, nil
}
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, false, nil
}
hasWebSearchCall := false
for _, item := range input.Array() {
itemType := strings.TrimSpace(item.Get("type").String())
if itemType == openAIWebSearchCallItemType {
hasWebSearchCall = true
continue
}
if itemType == "additional_tools" && gjsonToolsContainWebSearch(item.Get("tools")) {
return body, false, nil
}
}
if !hasWebSearchCall {
return body, false, nil
}
tools := gjson.GetBytes(body, "tools")
if gjsonToolsContainWebSearch(tools) {
return body, false, nil
}
callerDeclaredTools := tools.IsArray() && len(tools.Array()) > 0
var (
next []byte
err error
)
if callerDeclaredTools {
next, err = sjson.SetBytes(body, "tools.-1", cloneOpenAIWebSearchHistoryTool())
} else {
next, err = sjson.SetBytes(body, "tools", []any{cloneOpenAIWebSearchHistoryTool()})
}
if err != nil {
return body, false, fmt.Errorf("declare web_search tool for web_search_call history: %w", err)
}
if !callerDeclaredTools {
choice := gjson.GetBytes(next, "tool_choice")
if !choice.Exists() || choice.Type == gjson.Null || (choice.Type == gjson.String && shouldPinOpenAIWebSearchHistoryToolChoice(choice.String())) {
next, err = sjson.SetBytes(next, "tool_choice", "none")
if err != nil {
return body, false, fmt.Errorf("pin tool_choice for web_search_call history: %w", err)
}
}
}
return next, true, nil
}
func gjsonToolsContainWebSearch(tools gjson.Result) bool {
if !tools.IsArray() {
return false
}
for _, tool := range tools.Array() {
if isOpenAIWebSearchToolType(tool.Get("type").String()) {
return true
}
}
return false
}
func cloneOpenAIWebSearchHistoryTool() map[string]any {
tool := make(map[string]any, len(openAIWebSearchHistoryTool))
for key, value := range openAIWebSearchHistoryTool {
tool[key] = value
}
return tool
}
@@ -0,0 +1,50 @@
//go:build unit
package service
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestForward_OAuthWebSearchHistoryDeclaresTool drives the Codex local
// compaction shape (web_search_call history, tools:[]) through Forward and
// asserts the body reaching chatgpt.com declares web_search (#7927).
func TestForward_OAuthWebSearchHistoryDeclaresTool(t *testing.T) {
for _, passthrough := range []bool{false, true} {
name := "transform"
if passthrough {
name = "passthrough"
}
t.Run(name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
s := newAstraOAuthSetup(t, passthrough)
inner := `{"id":"resp_test","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
s.upstream.resp = &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
}
result, err := s.svc.Forward(context.Background(), s.c, s.account, []byte(openAIWebSearchHistoryCompactionBody))
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, s.upstream.lastReq)
require.Equal(t, astraProCodexResponsesURL, s.upstream.lastReq.URL.String())
forwarded := s.upstream.lastBody
require.Equal(t, "web_search_call", gjson.GetBytes(forwarded, "input.1.type").String())
tools := gjson.GetBytes(forwarded, "tools").Array()
require.Len(t, tools, 1)
require.Equal(t, "web_search", tools[0].Get("type").String())
require.Equal(t, "none", gjson.GetBytes(forwarded, "tool_choice").String())
})
}
}
@@ -0,0 +1,120 @@
package service
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// Codex local compaction replays hosted web_search_call history with tools:[];
// ChatGPT then fails with "response protection is unavailable" (#7927).
const openAIWebSearchHistoryCompactionBody = `{"model":"gpt-5.5","instructions":"x","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"look it up"}]},{"type":"web_search_call","id":"ws_123","status":"completed","action":{"type":"search","query":"kwin inputmethod"}},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"found"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"summarize"}]}],"tools":[],"tool_choice":"auto","parallel_tool_calls":false,"stream":true,"store":false}`
func TestOAuthWebSearchHistoryToolAcrossPaths(t *testing.T) {
tests := []struct {
name string
normalize func([]byte) ([]byte, bool, error)
inject bool
}{
{"transformed OAuth", func(b []byte) ([]byte, bool, error) {
var req map[string]any
if err := json.Unmarshal(b, &req); err != nil {
return nil, false, err
}
result := applyCodexOAuthTransform(req, true, false)
out, err := json.Marshal(req)
return out, result.Modified, err
}, true},
{"transformed OAuth compact", func(b []byte) ([]byte, bool, error) {
var req map[string]any
if err := json.Unmarshal(b, &req); err != nil {
return nil, false, err
}
result := applyCodexOAuthTransform(req, true, true)
out, err := json.Marshal(req)
return out, result.Modified, err
}, false},
{"OAuth passthrough", func(b []byte) ([]byte, bool, error) { return normalizeOpenAIPassthroughOAuthBody(b, false) }, true},
{"OAuth passthrough compact", func(b []byte) ([]byte, bool, error) { return normalizeOpenAIPassthroughOAuthBody(b, true) }, false},
{"OAuth websocket", func(b []byte) ([]byte, bool, error) {
return normalizeOpenAIResponsesWebSocketCompatibilityBody(b, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false)
}, true},
{"API key websocket", func(b []byte) ([]byte, bool, error) {
return normalizeOpenAIResponsesWebSocketCompatibilityBody(b, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, _, err := tt.normalize([]byte(openAIWebSearchHistoryCompactionBody))
require.NoError(t, err)
require.Equal(t, "web_search_call", gjson.GetBytes(out, "input.1.type").String())
if !tt.inject {
require.False(t, gjsonToolsContainWebSearch(gjson.GetBytes(out, "tools")))
return
}
tools := gjson.GetBytes(out, "tools").Array()
require.Len(t, tools, 1)
require.Equal(t, "web_search", tools[0].Get("type").String())
require.False(t, tools[0].Get("external_web_access").Bool())
require.True(t, tools[0].Get("external_web_access").Exists())
require.Equal(t, "none", gjson.GetBytes(out, "tool_choice").String())
})
}
}
func TestEnsureOpenAIOAuthWebSearchToolForHistoryBody(t *testing.T) {
const history = `[{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"q"}}]`
tests := []struct {
name string
body string
changed bool
toolCount int
toolChoice string
}{
{"tools absent", `{"input":` + history + `}`, true, 1, "none"},
{"tools null", `{"input":` + history + `,"tools":null,"tool_choice":null}`, true, 1, "none"},
{"explicit none kept", `{"input":` + history + `,"tools":[],"tool_choice":"none"}`, true, 1, "none"},
{"required not rewritten", `{"input":` + history + `,"tools":[],"tool_choice":"required"}`, true, 1, "required"},
{"caller tools keep choice", `{"input":` + history + `,"tools":[{"type":"function","name":"echo"}],"tool_choice":"auto"}`, true, 2, "auto"},
{"web_search already declared", `{"input":` + history + `,"tools":[{"type":"web_search"}]}`, false, 1, ""},
{"web_search_preview declared", `{"input":` + history + `,"tools":[{"type":"web_search_preview"}]}`, false, 1, ""},
{"declared via additional_tools", `{"input":[{"type":"additional_tools","tools":[{"type":"web_search"}]},{"type":"web_search_call","id":"ws_1","status":"completed"}]}`, false, 0, ""},
{"no web_search_call item", `{"input":[{"type":"message","role":"user","content":"web_search_call"}],"tools":[]}`, false, 0, ""},
{"string input", `{"input":"web_search_call"}`, false, 0, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, changed, err := ensureOpenAIOAuthWebSearchToolForHistoryBody([]byte(tt.body))
require.NoError(t, err)
require.Equal(t, tt.changed, changed)
if !tt.changed {
require.JSONEq(t, tt.body, string(out))
return
}
tools := gjson.GetBytes(out, "tools").Array()
require.Len(t, tools, tt.toolCount)
require.Equal(t, "web_search", tools[len(tools)-1].Get("type").String())
require.Equal(t, tt.toolChoice, gjson.GetBytes(out, "tool_choice").String())
// The map variant used by the Codex transform must agree.
var req map[string]any
require.NoError(t, json.Unmarshal([]byte(tt.body), &req))
require.True(t, ensureOpenAIOAuthWebSearchToolForHistory(req))
mapOut, err := json.Marshal(req)
require.NoError(t, err)
require.JSONEq(t, string(out), string(mapOut))
})
}
}
func TestEnsureOpenAIOAuthWebSearchToolForHistoryDoesNotShareToolMap(t *testing.T) {
first := map[string]any{"input": []any{map[string]any{"type": "web_search_call"}}}
second := map[string]any{"input": []any{map[string]any{"type": "web_search_call"}}}
require.True(t, ensureOpenAIOAuthWebSearchToolForHistory(first))
require.True(t, ensureOpenAIOAuthWebSearchToolForHistory(second))
first["tools"].([]any)[0].(map[string]any)["external_web_access"] = true
require.Equal(t, false, second["tools"].([]any)[0].(map[string]any)["external_web_access"])
require.Equal(t, false, openAIWebSearchHistoryTool["external_web_access"])
}