Files
magpie/internal/provider/codex_request_test.go
yetone acdaa28aa1 codex: a conversation that searched the web declares cached search to the ChatGPT backend, so its compaction and turns aren't refused "response protection is unavailable" (#1270, #1099)
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.
2026-10-09 22:40:53 +08:00

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)
}
}