The ChatGPT backend answers HTTP 200 and only `event: error` "response
protection is unavailable" to any request whose input replays one of its
own web_search_call items while the request doesn't declare the web_search
tool. Probed on a real ChatGPT Pro account (gpt-6.1-sol, 2026-10-09) with
a real searched turn: refused with tools [], with function tools only and
with a compaction_trigger; answered once {"type":"web_search",
"external_web_access":false} is declared. A Responses Lite request refuses
a top-level web_search (400) but takes it in an additional_tools item. So
xjdata's case (Codex compacting a custom provider's conversation itself, a
plain /responses with tools [] that magpie's codexCompact never sees) failed
every time once the session had searched, and congee949's thread
descriptions too.
What changed:
- provider.DeclareSearch / declareSearch: when the input holds a
web_search_call and nothing declares search, add cached-only web_search to
the tools, or for Lite (header, reasoning.context all_turns, or an
additional_tools item present) to the additional_tools item (prepended
when there is none). With no tools offered, tool_choice becomes "none" so
the model can't search; a forced choice is kept. Otherwise the body is
unchanged (bytes untouched when no web_search_call is in it).
- codexBody (every ChatGPT account route: serveAgent, codexCompact, warmup,
magpie's codex/* models) calls it; codexUpstreamOn calls it for Codex's
own sign-in relayed to the backend's /responses.
- Not changed: siwc, API-key passthrough to api.openai.com, the legacy
/responses/compact endpoint (no evidence they are refused so; not probed).
- docs/subsystems/gateway-routing.md names the cause and the fix.
Tests: internal/gateway/search_history_test.go replays the backend's real
items from that probe against a fake that refuses as the backend did.
TestSearchedConversationCompactsOnAnAccount, ...FunctionTools and
...OnCodexSignIn fail on the old code with "event: error"; ...CompactionTrigger
fails with "asked 2 times, not once". TestUnsearchedConversationUnchanged
and provider TestDeclareSearch cover what's left alone.
Run: gofmt -l (clean); go vet, GOOS=linux go vet -tags nogui, GOOS=windows
go vet -tags nogui on internal/provider and internal/gateway; HOME=<temp>
go test -tags nogui ./internal/provider ./internal/gateway (ok). Real
account, sandbox magpie on 127.0.0.1:3491 under a scratch HOME, replaying
the real searched history: old binary -> /v1/responses codex/gpt-6.1-sol
text compaction with tools [] and /backend-api/codex/responses (standard
and Lite compaction_trigger) all "response protection is unavailable";
new binary -> all response.completed, Lite with a compaction item.
Codex CLI 0.161.0 end to end was not done: on a custom provider it
offered no web_search tool, so no search could get into its history.
300 lines
13 KiB
Go
300 lines
13 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/yetone/magpie/internal/codexcat"
|
|
)
|
|
|
|
func TestCodexSignAndBody(t *testing.T) {
|
|
signIn(t)
|
|
p, _ := find(All(), "codex")
|
|
req, _ := http.NewRequest("POST", p.Responses+"/responses", nil)
|
|
if err := p.Sign(context.Background(), req, Responses, []byte(`{"prompt_cache_key":"thread-1"}`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := req.Header
|
|
if !strings.HasPrefix(h.Get("Authorization"), "Bearer h.") || h.Get("chatgpt-account-id") != "acct-1" ||
|
|
h.Get("originator") != "codex_cli_rs" || !strings.HasPrefix(h.Get("User-Agent"), "codex_cli_rs/0.") ||
|
|
h.Get("OpenAI-Beta") != "responses=experimental" || h.Get("session_id") != "thread-1" || h.Get("conversation_id") != "thread-1" {
|
|
t.Fatalf("headers: %v", h)
|
|
}
|
|
out := p.Prepare([]byte(`{"model":"gpt-5.5","input":"hi","max_output_tokens":5,"temperature":0.1,"stream":false,"store":true,"reasoning":{"effort":"low"}}`))
|
|
var m map[string]any
|
|
json.Unmarshal(out, &m)
|
|
if _, ok := m["max_output_tokens"]; ok || m["temperature"] != nil || m["stream"] != true || m["store"] != false ||
|
|
m["tool_choice"] != "auto" || m["parallel_tool_calls"] != true {
|
|
t.Fatalf("body: %s", out)
|
|
}
|
|
if r := m["reasoning"].(map[string]any); r["effort"] != "low" || r["summary"] != "auto" {
|
|
t.Fatalf("reasoning: %s", out)
|
|
}
|
|
if inc, _ := m["include"].([]any); len(inc) != 1 || inc[0] != "reasoning.encrypted_content" {
|
|
t.Fatalf("include: %s", out)
|
|
}
|
|
if in := m["input"].([]any)[0].(map[string]any); in["role"] != "user" || in["content"].([]any)[0].(map[string]any)["text"] != "hi" {
|
|
t.Fatalf("input: %s", out)
|
|
}
|
|
}
|
|
|
|
// The client's instructions ride first in the input and Codex's take their
|
|
// place; input items lose their ids, stored references and system role, and
|
|
// a tool output without its call becomes a note.
|
|
func TestCodexBodyAsCodexSendsIt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("CODEX_HOME", dir)
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
os.WriteFile(filepath.Join(dir, "models_cache.json"), []byte(`{"models":[{"slug":"gpt-5.5","model_messages":{"instructions_template":"You are Codex, a coding agent.\nMore."}}]}`), 0o644)
|
|
|
|
out := codexBody([]byte(`{"model":"gpt-5.5","instructions":"You are Pi.","reasoning":{"effort":"ultra"},"input":[
|
|
{"type":"message","role":"system","content":"sys"},
|
|
{"type":"item_reference","id":"msg_1"},
|
|
{"type":"message","id":"msg_2","role":"user","content":"hi"},
|
|
{"type":"function_call","id":"fc_1","call_id":"c1","name":"ls","arguments":"{}"},
|
|
{"type":"function_call_output","call_id":"c1","output":"a"},
|
|
{"type":"function_call_output","call_id":"c9","name":"cat","output":"b"}]}`))
|
|
var m struct {
|
|
Instructions string `json:"instructions"`
|
|
Reasoning map[string]any `json:"reasoning"`
|
|
Input []map[string]any `json:"input"`
|
|
}
|
|
if err := json.Unmarshal(out, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m.Instructions != "You are Codex, a coding agent.\nMore." || m.Reasoning["effort"] != "max" {
|
|
t.Fatalf("%s", out)
|
|
}
|
|
var got []string
|
|
for _, it := range m.Input {
|
|
if _, ok := it["id"]; ok {
|
|
t.Fatalf("id kept: %s", out)
|
|
}
|
|
s, _ := it["type"].(string)
|
|
if r, _ := it["role"].(string); r != "" {
|
|
s += ":" + r
|
|
}
|
|
got = append(got, s)
|
|
}
|
|
if strings.Join(got, ",") != "message:developer,message:developer,message:user,function_call,function_call_output,message:assistant" {
|
|
t.Fatalf("input: %v\n%s", got, out)
|
|
}
|
|
if m.Input[0]["content"].([]any)[0].(map[string]any)["text"] != "You are Pi." ||
|
|
!strings.Contains(m.Input[5]["content"].(string), "[Previous cat result; call_id=c9]: b") {
|
|
t.Fatalf("%s", out)
|
|
}
|
|
|
|
// Codex's own instructions, from Codex itself, stay as they are
|
|
out = codexBody([]byte(`{"model":"gpt-5.5","instructions":"You are Codex, a coding agent.\nPersonality.","input":[]}`))
|
|
json.Unmarshal(out, &m)
|
|
if m.Instructions != "You are Codex, a coding agent.\nPersonality." || len(m.Input) != 0 {
|
|
t.Fatalf("%s", out)
|
|
}
|
|
}
|
|
|
|
// A conversation whose client names none gets a key of its own, the same on
|
|
// every turn; a client's own key stays.
|
|
func TestCodexPromptCacheKey(t *testing.T) {
|
|
t.Setenv("CODEX_HOME", t.TempDir())
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
key := func(body string) string {
|
|
var m struct {
|
|
Key string `json:"prompt_cache_key"`
|
|
}
|
|
json.Unmarshal(codexBody([]byte(body)), &m)
|
|
return m.Key
|
|
}
|
|
first := key(`{"model":"gpt-5.5","instructions":"You are Pi.","input":[{"type":"message","role":"user","content":"hi"}]}`)
|
|
later := key(`{"model":"gpt-5.5","instructions":"You are Pi.","input":[{"type":"message","role":"user","content":"hi"},
|
|
{"type":"function_call","call_id":"c1","name":"ls","arguments":"{}"},{"type":"function_call_output","call_id":"c1","output":"a"}]}`)
|
|
other := key(`{"model":"gpt-5.5","instructions":"You are Pi.","input":[{"type":"message","role":"user","content":"bye"}]}`)
|
|
if first == "" || first != later || first == other || len(first) != 36 {
|
|
t.Fatalf("first %q, later %q, other %q", first, later, other)
|
|
}
|
|
if k := key(`{"model":"gpt-5.5","prompt_cache_key":"thread-1","input":"hi"}`); k != "thread-1" {
|
|
t.Fatalf("own key: %q", k)
|
|
}
|
|
if k := key(`{"model":"gpt-5.5","input":[]}`); k != "" {
|
|
t.Fatalf("no conversation: %q", k)
|
|
}
|
|
}
|
|
|
|
func TestCodexUserAgent(t *testing.T) {
|
|
ua := codexUserAgent("0.159.0")
|
|
if !strings.HasPrefix(ua, "codex_cli_rs/0.159.0 (") || !strings.Contains(ua, " (") || !strings.Contains(ua, "; ") {
|
|
t.Fatalf("%q", ua)
|
|
}
|
|
}
|
|
|
|
// Instructions fetched with the account's model list are kept on disk,
|
|
// for the gateway to find though another process asked, ahead of Codex
|
|
// CLI's copy.
|
|
func TestCodexInstructionsSaved(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("CODEX_HOME", dir)
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
os.WriteFile(filepath.Join(dir, "models_cache.json"), []byte(`{"models":[{"slug":"gpt-5.5","model_messages":{"instructions_template":"cli copy"}}]}`), 0o644)
|
|
if got := codexInstructions("gpt-5.5"); got != "cli copy" {
|
|
t.Fatalf("before a fetch: %q", got)
|
|
}
|
|
if got := codexInstructions("gpt-9"); got != codexcat.Prompt {
|
|
t.Fatalf("unknown model: %q", got)
|
|
}
|
|
saveCodexPrompts([]byte(`{"models":[{"slug":"gpt-5.5","model_messages":{"instructions_template":"fetched"}},{"slug":"gpt-4","base_instructions":"base"}]}`))
|
|
if got := codexInstructions("gpt-5.5"); got != "fetched" {
|
|
t.Fatalf("after a fetch: %q", got)
|
|
}
|
|
saveCodexPrompts([]byte(`{"models":[{"slug":"gpt-6","model_messages":{"instructions_template":"six"}}]}`))
|
|
if codexInstructions("gpt-4") != "base" || codexInstructions("gpt-6") != "six" {
|
|
t.Fatal("a second account's list replaced the first's")
|
|
}
|
|
}
|
|
|
|
// Two saves that share an mtime both stay visible. The cache re-reads only
|
|
// when ModTime changes, and two writes in one tick do not change it. After
|
|
// the second save, Chtimes puts the first mtime back, so the collision is
|
|
// certain on every run.
|
|
func TestCodexPromptsSameMtime(t *testing.T) {
|
|
t.Setenv("CODEX_HOME", t.TempDir())
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
saveCodexPrompts([]byte(`{"models":[{"slug":"gpt-4","base_instructions":"base"}]}`))
|
|
if got := codexInstructions("gpt-4"); got != "base" {
|
|
t.Fatalf("first account: %q", got)
|
|
}
|
|
path := codexPromptsPath()
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prev := st.ModTime()
|
|
saveCodexPrompts([]byte(`{"models":[{"slug":"gpt-6","model_messages":{"instructions_template":"six"}}]}`))
|
|
if err := os.Chtimes(path, prev, prev); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if codexInstructions("gpt-4") != "base" || codexInstructions("gpt-6") != "six" {
|
|
t.Fatal("a second account's list replaced the first's")
|
|
}
|
|
}
|
|
|
|
// Codex's Fast mode reaches the ChatGPT backend as service_tier "priority",
|
|
// its Ultrafast as "ultrafast"; any other tier is dropped as before.
|
|
func TestCodexBodyServiceTier(t *testing.T) {
|
|
t.Setenv("CODEX_HOME", t.TempDir())
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
tier := func(body string) (any, bool) {
|
|
var m map[string]any
|
|
json.Unmarshal(codexBody([]byte(body)), &m)
|
|
v, ok := m["service_tier"]
|
|
return v, ok
|
|
}
|
|
if v, _ := tier(`{"model":"gpt-6-sol","service_tier":"priority","input":"hi"}`); v != "priority" {
|
|
t.Errorf("priority: %v", v)
|
|
}
|
|
if v, _ := tier(`{"model":"gpt-6-sol","service_tier":"ultrafast","input":"hi"}`); v != "ultrafast" {
|
|
t.Errorf("ultrafast: %v", v)
|
|
}
|
|
for _, s := range []string{`"flex"`, `"auto"`, `"default"`, `"fast"`, `null`} {
|
|
if v, ok := tier(`{"model":"gpt-6-sol","service_tier":` + s + `,"input":"hi"}`); ok {
|
|
t.Errorf("%s kept as %v", s, v)
|
|
}
|
|
}
|
|
if v, ok := tier(`{"model":"gpt-6-sol","input":"hi"}`); ok {
|
|
t.Errorf("none became %v", v)
|
|
}
|
|
}
|
|
|
|
// Standalone notifications retain the metadata the native backend recognizes;
|
|
// paired outputs and ID-bearing historical orphans keep their existing rules.
|
|
func TestCodexStandaloneNotifications(t *testing.T) {
|
|
t.Setenv("CODEX_HOME", t.TempDir())
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
for _, kind := range []string{"function_call_output", "custom_tool_call_output"} {
|
|
for _, id := range []string{"", `,"call_id":""`} {
|
|
body := []byte(`{"model":"gpt-5.5","input":[{"type":"` + kind + `","id":"standalone","name":"send_message_to_thread","namespace":"codex_app","output":"native notice"` + id + `}]}`)
|
|
var before, after struct {
|
|
Input []map[string]any `json:"input"`
|
|
}
|
|
json.Unmarshal(body, &before)
|
|
json.Unmarshal(codexBody(body), &after)
|
|
b, _ := json.Marshal(before.Input)
|
|
a, _ := json.Marshal(after.Input)
|
|
if string(a) != string(b) {
|
|
t.Fatalf("standalone changed: %s -> %s", b, a)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A replayed web search goes with the web_search tool declared where the
|
|
// request carries its tools (#1270): in the tools, or for Codex's Responses
|
|
// Lite in an additional_tools item, which the backend wants there; a
|
|
// request that offered no tools still calls none, and one that chose a
|
|
// tool keeps its choice.
|
|
func TestDeclareSearch(t *testing.T) {
|
|
t.Setenv("CODEX_HOME", t.TempDir())
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
const search = `{"type":"web_search_call","status":"completed","action":{"type":"search","query":"go release"}}`
|
|
const user = `{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}`
|
|
type sent struct {
|
|
Input []struct {
|
|
Type string `json:"type"`
|
|
Tools []map[string]any `json:"tools"`
|
|
} `json:"input"`
|
|
Tools []map[string]any `json:"tools"`
|
|
ToolChoice any `json:"tool_choice"`
|
|
}
|
|
types := func(ts []map[string]any) string {
|
|
var out []string
|
|
for _, t := range ts {
|
|
out = append(out, t["type"].(string))
|
|
}
|
|
return strings.Join(out, ",")
|
|
}
|
|
for _, c := range []struct {
|
|
name, body string
|
|
lite bool
|
|
tools, extra, first string
|
|
choice any
|
|
}{
|
|
{"Lite on an account: into its additional_tools", `{"model":"gpt-6.1-sol","parallel_tool_calls":false,"reasoning":{"effort":"low","context":"all_turns"},"input":[{"type":"additional_tools","role":"developer","tools":[{"type":"namespace","name":"functions","tools":[]}]},` + search + `,` + user + `]}`, false, "", "namespace,web_search", "additional_tools", "auto"},
|
|
{"Lite with no tools item: one first", `{"model":"gpt-6.1-sol","parallel_tool_calls":false,"reasoning":{"effort":"low","context":"all_turns"},"input":[` + search + `,{"type":"compaction_trigger"}]}`, false, "", "web_search", "additional_tools", "none"},
|
|
{"declared already", `{"model":"gpt-5.5","tools":[{"type":"web_search_preview"}],"input":[` + search + `]}`, false, "web_search_preview", "", "web_search_call", "auto"},
|
|
{"declared in additional_tools", `{"model":"gpt-5.5","input":[{"type":"additional_tools","tools":[{"type":"web_search"}]},` + search + `]}`, false, "", "web_search", "additional_tools", "auto"},
|
|
} {
|
|
var got sent
|
|
if err := json.Unmarshal(codexBody([]byte(c.body)), &got); err != nil {
|
|
t.Fatalf("%s: %v", c.name, err)
|
|
}
|
|
var extra []map[string]any
|
|
for _, it := range got.Input {
|
|
extra = append(extra, it.Tools...)
|
|
}
|
|
if types(got.Tools) != c.tools || types(extra) != c.extra || got.Input[0].Type != c.first || got.ToolChoice != c.choice {
|
|
t.Errorf("%s: tools %q, additional %q, first %s, tool_choice %v", c.name, types(got.Tools), types(extra), got.Input[0].Type, got.ToolChoice)
|
|
}
|
|
}
|
|
|
|
// a choice of a tool stays; tools that aren't a list are left alone
|
|
for in, want := range map[string]string{
|
|
`{"tools":[],"tool_choice":"required","input":[` + search + `]}`: `"tool_choice":"required"`,
|
|
`{"tool_choice":{"type":"function","name":"shell"},"input":[` + search + `]}`: `"tool_choice":{"name":"shell","type":"function"}`,
|
|
`{"tools":{},"input":[` + search + `]}`: `"tools":{}`,
|
|
`{"tools":[],"input":[` + user + `]}`: `"tools":[]`,
|
|
} {
|
|
if out := string(DeclareSearch([]byte(in), false)); !strings.Contains(out, want) {
|
|
t.Errorf("%s:\n%s", in, out)
|
|
}
|
|
}
|
|
// Codex's Lite header names a request Lite without anything in it
|
|
var lite sent
|
|
json.Unmarshal(DeclareSearch([]byte(`{"input":[`+search+`]}`), true), &lite)
|
|
if len(lite.Tools) != 0 || lite.Input[0].Type != "additional_tools" || types(lite.Input[0].Tools) != "web_search" || lite.ToolChoice != "none" {
|
|
t.Errorf("Lite: %+v", lite)
|
|
}
|
|
}
|