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.
177 lines
7.0 KiB
Go
177 lines
7.0 KiB
Go
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)
|
||
}
|
||
}
|