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"; // 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) } 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) } }