Files
magpie/internal/agent/codex_failover_test.go
yetone 0b5b10da2a agent, gateway, gui, tui: a Codex that goes through magpie only to fail over to the user's other ChatGPT accounts says so on the Agents row and keeps to its own models (#1385, shenghsi)
With several ChatGPT accounts on, failover writes openai_base_url into
~/.codex/config.toml so a turn the signed-in account can't pay for goes on
to the next account. With Codex's switch off, the row said only "not
connected", but every request still went through the gateway, and the
gateway's /backend-api/codex/models answer added all of magpie's models
(62 for the reporter).

- agent: Agent.FailingOver means not wired, while the stash's codex.failover
  (or a CC Switch mirror's codex.mirror_failover) base URL points at magpie.
  FailoverSaid is the line the CLI list and the TUI show. CodexOwnOnly is
  true for this machine's Codex when it is failing over and no WSL Codex is
  connected, since a WSL Codex shares the gateway path. Sync ages a
  models_cache.json that isn't tagged with the own-only list.
- gateway: codexModels leaves out provider.CodexListed when
  gateway.CodexOwnOnly (set by main to agent.CodexOwnOnly) says so. The
  ETag and X-Models-Etag carry provider.CodexOwnListTag. Neither tag
  contains the other, so Codex fetches the list again when it is connected
  or disconnected.
- gui: /api/state carries failover. The row says "Not connected · goes
  through magpie only to fail over to your other ChatGPT accounts". Its
  title says what turns it off. A "ChatGPT accounts" button opens
  Providers › Codex. Strings are in zh, zh-TW, ja and de. openProvider no
  longer draws the Providers list before it has loaded. It was only safe
  once the tab had been opened, and this is its first caller from another
  page.
- docs: agent-wiring and gateway-routing describe the failover-only Codex
  and list the new tests.

A user's hand-written openai_base_url (#856) isn't failover's, so it stays a
plain not-connected row.

Verified:
- TestCodexFailoverOnlyKeepsOwnModels (agent) builds the reporter's state in
  a temp HOME: Codex signed in, two more ChatGPT logins on, Sync, so the
  stash has codex.failover=1 and no joined/beside. It checks that the row is
  not wired and is FailingOver, that FailoverSaid names Providers › Codex,
  and that the real gateway handler's /backend-api/codex/models lists only
  the backend's gpt-5.5. It also checks that the cached full list is aged,
  and that after Join magpie's fake/m1 is back.
- TestCodexModelListOwnOnlyForFailover (gateway) checks the list and both
  ETags with the hook on and off.
- agent-failover.test.cjs checks the row text, title and button in en, zh,
  zh-TW, ja and de, at 980px and 420px, in Chromium and WebKit (20 pass).
  It also checks that the button opens Codex's editor, that the page
  doesn't scroll sideways, and that no click moves it.
- Broken by hand, each test goes red:
  - codexModels ignoring the hook fails both Go tests.
  - failingOver returning false fails the agent test.
  - dropping the Sync cache case fails the agent test.
  - removing the GUI branch fails the GUI test.
  - drawing the list before it has loaded (the old openProvider) fails the
    GUI test.
- go test -tags nogui ./internal/agent ./internal/gateway ./internal/provider
  ./internal/gui ./internal/tui ./internal/codexcat . in a temp HOME: all ok.
- node --test agent-*.test.cjs (254 pass) and i18n-keys, gui-ja, gui-de,
  click-scroll: all pass.
- gofmt -l is clean.
- go vet -tags nogui on darwin, linux and windows: clean.
- go build, and GOOS=windows go build: ok.
2026-10-09 14:10:13 +08:00

177 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
)
// Codex on one of its own models, signed in to ChatGPT, goes through
// magpie while another of its accounts is on there — so a turn the first
// has no allowance left for goes to the next — and straight to OpenAI
// once none is.
func TestCodexOwnModelThroughMagpieWhileAccountsOn(t *testing.T) {
claims := func(m map[string]any) string {
b, _ := json.Marshal(m)
return "h." + base64.RawURLEncoding.EncodeToString(b) + ".s"
}
auth := func(email, acct string) map[string]any {
return map[string]any{"auth_mode": "chatgpt", "tokens": map[string]any{
"id_token": claims(map[string]any{"email": email}),
"access_token": claims(map[string]any{"exp": time.Now().Add(time.Hour).Unix()}),
"refresh_token": "r-" + acct, "account_id": acct}}
}
me, _ := json.Marshal(auth("me@example.com", "acct-1"))
home, read := codexHome(t, string(me), "model = \"gpt-5.5\"\n")
cx := codex(home)
logins := func(on bool) {
b, _ := json.Marshal([]map[string]any{{"agent": "codex", "user": "spare@example.com", "on": on,
"seen": time.Now(), "auth": auth("spare@example.com", "acct-2")}})
os.MkdirAll(filepath.Dir(provider.Path()), 0o755)
os.WriteFile(filepath.Join(filepath.Dir(provider.Path()), "logins.json"), b, 0o600)
}
logins(false)
if err := cx.Fields[0].Set("gpt-5.4"); err != nil {
t.Fatal(err)
}
if cfg := read(); strings.Contains(cfg, "openai_base_url") {
t.Fatalf("one account:\n%s", cfg)
}
logins(true)
if err := cx.Sync(); err != nil {
t.Fatal(err)
}
if cfg := read(); !strings.Contains(cfg, `openai_base_url = "http://127.0.0.1:`) || !strings.Contains(cfg, `model = "gpt-5.4"`) {
t.Fatalf("second account on:\n%s", cfg)
}
// picked again, it stays so
if err := cx.Fields[0].Set("gpt-5.5"); err != nil {
t.Fatal(err)
}
if cfg := read(); !strings.Contains(cfg, "openai_base_url") || !strings.Contains(cfg, `model = "gpt-5.5"`) {
t.Fatalf("picked with two accounts on:\n%s", cfg)
}
logins(false)
if err := cx.Sync(); err != nil {
t.Fatal(err)
}
if cfg := read(); strings.Contains(cfg, "openai_base_url") || !strings.Contains(cfg, `model = "gpt-5.5"`) {
t.Fatalf("second account off:\n%s", cfg)
}
}
// A base URL the user wrote in config.toml by hand, to have Codex's own
// models go through magpie, stays when magpie starts (Sync) with no other
// ChatGPT account on to fail over to: failover takes away only the one it
// wrote (#856: it was gone again after every magpie update).
func TestCodexHandWrittenBaseURLStays(t *testing.T) {
base := gateway.URL() + gateway.CodexPath
home, read := codexHome(t, `{"tokens":{"access_token":"x","id_token":"x.e30.x"}}`,
"model = \"gpt-5.5\"\nopenai_base_url = \""+base+"\"\n")
cx := codex(home)
for i := 0; i < 2; i++ {
if err := cx.Sync(); err != nil {
t.Fatal(err)
}
if cfg := read(); !strings.Contains(cfg, `openai_base_url = "`+base+`"`) || !strings.Contains(cfg, `model = "gpt-5.5"`) {
t.Fatalf("sync %d took the user's base URL away:\n%s", i, cfg)
}
}
}
// #1385 (shenghsi): Codex signed in to ChatGPT, not connected, with two
// more of the user's ChatGPT accounts on in magpie. failover points Codex at
// the gateway; the Agents row says it goes through magpie for that and what
// turns it off, and Codex's model list is its own, not its own and 62 of
// magpie's. Connected, it is magpie's models' again.
func TestCodexFailoverOnlyKeepsOwnModels(t *testing.T) {
claims := func(m map[string]any) string {
b, _ := json.Marshal(m)
return "h." + base64.RawURLEncoding.EncodeToString(b) + ".s"
}
auth := func(email, acct string) map[string]any {
return map[string]any{"auth_mode": "chatgpt", "tokens": map[string]any{
"id_token": claims(map[string]any{"email": email}),
"access_token": claims(map[string]any{"exp": time.Now().Add(time.Hour).Unix()}),
"refresh_token": "r-" + acct, "account_id": acct}}
}
me, _ := json.Marshal(auth("me@example.com", "acct-1"))
home, read := codexHome(t, string(me), "model = \"gpt-5.5\"\n")
var saved []map[string]any
for i, u := range []string{"spare1@example.com", "spare2@example.com"} {
saved = append(saved, map[string]any{"agent": "codex", "user": u, "on": true,
"seen": time.Now(), "auth": auth(u, "acct-"+string(rune('2'+i)))})
}
b, _ := json.Marshal(saved)
os.MkdirAll(filepath.Dir(provider.Path()), 0o755)
os.WriteFile(filepath.Join(filepath.Dir(provider.Path()), "logins.json"), b, 0o600)
// Codex fetched magpie's whole list before
cache := filepath.Join(home, ".codex", "models_cache.json")
now := time.Now().UTC().Format(time.RFC3339)
os.WriteFile(cache, []byte(`{"etag":"\"v1+magpie-`+provider.CodexListTag()+`\"","fetched_at":"`+now+`","models":[]}`), 0o644)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"models":[{"slug":"gpt-5.5","priority":1}]}`)
}))
defer up.Close()
wasBase, wasHook := provider.CodexBase, gateway.CodexOwnOnly
provider.CodexBase = up.URL + "/backend-api/codex"
gateway.CodexOwnOnly = CodexOwnOnly
defer func() { provider.CodexBase, gateway.CodexOwnOnly = wasBase, wasHook }()
models := func() []string {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", gateway.CodexPath+"/models", nil)
req.Header.Set("Authorization", "Bearer chatgpt-token")
gateway.New().Handler().ServeHTTP(rec, req)
var got struct {
Models []map[string]any `json:"models"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
var slugs []string
for _, m := range got.Models {
slug, _ := m["slug"].(string)
slugs = append(slugs, slug)
}
return slugs
}
cx := codex(home)
if err := cx.Sync(); err != nil {
t.Fatal(err)
}
s := stashLoad()
if cfg := read(); !strings.Contains(cfg, "openai_base_url") || s["codex.failover"] != "1" || s["codex.joined"] != "" || s["codex.beside"] != "" {
t.Fatalf("not the reporter's state: stash %v\n%s", s, cfg)
}
if cx.Wired() || !cx.FailingOver() || !CodexOwnOnly() || !strings.Contains(cx.FailoverSaid(), "Providers › Codex") {
t.Errorf("wired %v, failing over %v, own only %v, said %q", cx.Wired(), cx.FailingOver(), CodexOwnOnly(), cx.FailoverSaid())
}
if got := models(); len(got) != 1 || got[0] != "gpt-5.5" {
t.Errorf("failover-only Codex lists %v, want just gpt-5.5", got)
}
if c, _ := os.ReadFile(cache); !strings.Contains(string(c), "1970-01-01") {
t.Errorf("Codex's cached list of magpie's models kept:\n%s", c)
}
// connected, magpie's models are in its list and the row says connected
if ok, err := cx.Join(); !ok || err != nil {
t.Fatalf("join %v %v", ok, err)
}
if !cx.Wired() || cx.FailingOver() || CodexOwnOnly() || cx.FailoverSaid() != "" {
t.Errorf("joined: wired %v, failing over %v, own only %v", cx.Wired(), cx.FailingOver(), CodexOwnOnly())
}
if got := strings.Join(models(), " "); !strings.Contains(got, "fake/m1") {
t.Errorf("joined Codex lists %v, want magpie's too", got)
}
}