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.
This commit is contained in:
yetone committed 2026-10-09 14:10:13 +08:00
1 parent 1bc0ed404a
commit 0b5b10da2a
16 files changed
+419 -11

No files matched your search

+3 -1
View File
@@ -50,6 +50,7 @@ something else shows up as drift.
- `unreachable` never sets anything: the config is right, and the page's pill only says what has to listen. The exception is an address a WSL distro reached Windows at before (`distro.Was`, kept in `wsl.json`) while it reaches Windows at another now: the pill offers that one, and `Reapply` moves the config there (`Agent.move`, Codex's `codexMoveGateway`). An address no distro was seen at is the user's own and is never moved. An unknown answer (no probe yet) and a gateway down on loopback too are not drift. Tests stub `reachProbe` in `TestMain`.
- Listing never starts a stopped WSL distro. A stopped distro's agent shows what was last seen there (`asleep`, `wsl.json`); setting one of its fields writes its files, which starts the distro.
- Codex's sign-in (`login`) is ChatGPT (`""`, the default), Always ChatGPT (`chatgpt`) or magpie API (`api`). With ChatGPT, magpie's models join Codex's own beside the sign-in (`openai_base_url`), and while the Codex app holds the ChatGPT account Codex is signed in to (`codexUsedUp`, `provider.CodexUsedUp`: OpenAI no longer allows the account and it has no credits left, a workspace still within its overage excepted), magpie is made Codex's provider (`codex.out`): the Codex app sends nothing at all on an account it holds, a magpie model's turn included (#540), and as a provider of its own magpie is past that. A window at 100% with credits left doesn't count, and Codex stays signed in. Sync puts it back beside the sign-in once the account has room. Always ChatGPT never does that: Codex stays in its ChatGPT state, and the Codex app may send nothing till the account has room, while Codex CLI's turns still reach magpie by the base URL, and its routing and other accounts serve them.
- Codex signed in to ChatGPT and not connected still gets `openai_base_url` while more of its ChatGPT accounts are on in magpie (`failover`, stash `codex.failover`; with a CC Switch mirror as its provider, the mirror's `base_url` and `codex.mirror_failover`), so a turn its own account has no allowance for goes on to the next. That Codex is `FailingOver`: not `Wired`, yet its requests go through magpie. The Agents row says so and what turns it off (`FailoverSaid` in the CLI and TUI, `failover` in `/api/state`, a button to Providers › Codex in the GUI), and the gateway hands it only its own models from `/backend-api/codex/models` (`CodexOwnOnly`, set on `gateway.CodexOwnOnly` by main; its ETag carries `provider.CodexOwnListTag`, and Sync ages a `models_cache.json` without it). A connected WSL Codex shares the gateway path, so while one is connected every Codex gets magpie's models (#1385). A base URL the user wrote by hand (#856) isn't failover's and stays a plain not-connected row.
- An agent's config is written in the form the installed version reads, taken from what that version declares, not the latest one. VSCodium has no Copilot Chat of its own, and the Marketplace's last one (0.48.1) has no Custom Endpoint provider, only OpenAI Compatible (`customoai`, #1014). magpie reads the provider list in the package.json of the `github.copilot-chat` installed under `~/.vscode-oss/extensions` (`vscodeChatOf`). It writes its group for `customendpoint` when that is declared, for `customoai` when only that is, with each model's window as `maxInputTokens` + `maxOutputTokens` and the token as `x-api-key` (that version drops an Authorization in `requestHeaders`). A missing or unreadable extension keeps `customendpoint`. Sync rewrites a group written for the other provider, and Check reports it, as it does a Copilot Chat declaring neither. That Copilot Chat registers its providers only while signed in to GitHub with a personal Copilot plan, which the Notice says.
- Tests never touch the real machine. `TestMain` uses `testenv`, which points HOME, USERPROFILE, the XDG folders and APPDATA into a temporary folder, clears `agentenv.Vars` and puts failing stand-ins for keychain tools and agent CLIs on PATH. Agent-specific side effects (Aside, the Zed keychain, Codex's usage check) are also stubbed in `TestMain`.
@@ -60,5 +61,6 @@ go test -tags nogui ./internal/agent -run 'TestConnect|TestDisconnect|Drift|Test
go test -tags nogui ./internal/agent
go test -tags nogui ./internal/agent -run '^TestAir'
go test -tags nogui ./internal/agent -run '^TestAnte'
node --test internal/gui/tests/agent-connect.test.cjs internal/gui/tests/agent-disconnect-preview.test.cjs internal/gui/tests/claude-signin.test.cjs
go test -tags nogui ./internal/agent -run 'TestCodexFailoverOnlyKeepsOwnModels|TestCodexOwnModelThroughMagpieWhileAccountsOn|TestCodexHandWrittenBaseURLStays'
node --test internal/gui/tests/agent-connect.test.cjs internal/gui/tests/agent-disconnect-preview.test.cjs internal/gui/tests/claude-signin.test.cjs internal/gui/tests/agent-failover.test.cjs
```
+2
View File
@@ -56,6 +56,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
- An account resting out of its window (a Claude account by the time Claude Code's refusal named, `resets`, or any by `window`) is back once magpie's reading of its windows (`provider.Allowances`) finds it full till sooner: the window used below `SpentShareOf`, or its reset passed, with no other window over the same models still full. The reading tells `provider.OnRenewed` as the account's `UsageAgent` and user, and `renewed` lifts that account's rests out of quota or by `window`; its rests for a rate limit, its credit, a verification or a failure stay, as a key's do, and so does every rest of other accounts still out of their windows. Planning reads every account candidate's allowance, ordered groups included, so this needs no step of its own in `restingOf`.
- Only an account is held at a usage cap (`capHeld`) or rested out of one model's pool (`pooled`); a key's windows order it and rest it, nothing more.
- Codex offers its image tool (the `image_gen` namespace, #870) to every magpie provider. When a provider other than a ChatGPT sign-in or openai.com answers a bare 400, `attempt` asks it once more without the tool (`image_tool.go`). This happens both passed through and translated. If that works, the provider is marked unfit for the tool (`tool:image_gen`) and isn't offered it again. If it doesn't, the tool is restored and the 400 stands (#949).
- Codex's model list from the backend path (`codexModels`) is the ChatGPT backend's for its sign-in and then magpie's (`provider.CodexListed`), its ETag and every reply's `X-Models-Etag` tagged with `provider.CodexListTag` so a change has Codex ask again. A Codex there only for account failover, not connected (`CodexOwnOnly`, `agent.CodexOwnOnly`; #1385), gets the backend's alone, tagged with `provider.CodexOwnListTag`; neither tag contains the other, so connecting or disconnecting has Codex fetch the list again.
- A translated Responses reply gives Codex reasoning items magpie made: an `rs_` id and a summary, nothing sealed. Codex hands them back on every turn. A request relayed as is to OpenAI's API or Azure OpenAI goes without them (`withoutBareReasoning`) unless `store` is true, because both look the id up among stored items and answer 400 "Item with id 'rs_…' not found" (#1008). Any other Responses upstream gets them, because a DeepSeek model wants its reasoning back (#388). When one answers 400 naming the input (`refusesInput`: `param` is `input`, or the error names an item by id), as a relay in front of OpenAI does with a message of its own, `passthrough` asks once more without them and, once that works, leaves them out for that provider and model from then on (`bareReasoningRefused`, #1044). Codex's compaction offers no tools, so it is relayed even when its turns were translated. When a provider answers its summary request 404, or 400 for an item it didn't find (`itemNotFound`, a relay in front of either), `codexCompact` asks once more with the conversation as text (`plainCompact`), then compacts locally (#866, #1008). So does the ChatGPT backend's 502 "response protection is unavailable" (`protectionRefused`): a long history refused so was answered as text. Codex also compacts remotely, a `compaction_trigger` on `/responses` that wants exactly one compaction item back, on any provider table named "OpenAI", as CC Switch names its relay tables. magpie points those at `/v1` while a magpie model is on, so `handle` makes the summary there too (`codexInput`, `codexCompact`), and magpie's summaries go back to the model as text (#1301).
- Structured output is carried across every translation (`Format`, [`format.go`](../../internal/gateway/format.go)). It is read from Responses' `text.format`, Chat's `response_format`, Anthropic's `output_config.format` and Gemini's `responseMimeType` with `responseJsonSchema`/`responseSchema`: `json_schema` with its name, description, schema and strict, or `json_object`. Each upstream is asked in its own field: `text.format` on Responses, `response_format` on Chat, `output_config.format` on Messages, and `generationConfig.responseMimeType`/`responseJsonSchema` on Gemini and Code Assist. An API with no name for it gets `response`. Messages and the Claude subscription's `--json-schema` take a schema only, so `json_object` is told in the system prompt there (`inSystem`). The bridges to a vendor's own API (Cursor, Devin, Kiro, Qoder, Command Code, Zed) are always told in the system prompt. A 400 or 422 that names the field (`refusesFormat`: DeepSeek's "This response_format type is unavailable now", a schema Anthropic can't hold) marks the provider and model (`formatRefused`), and the request is asked again with the format told in the system prompt, as are later requests to that provider and model. A relayed request keeps its format as sent. A Gemini client's format is also still told in its system prompt, as before.
- Group members are `provider/model[:effort]`. A member with a fixed effort is asked for that effort, bounded by the group's levels (#671).
@@ -66,6 +67,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
```sh
go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestModelNotInPlanRestsTheModelNotTheKey|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestWindowCapsHoldTheAccount|TestAccountCap|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestProtection|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestClaudeAccountBackOnceItsWindowRenews|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent|TestClaudeSignInNotToldItsSignInFailed|TestSignInWriterReleasesA403|TestSignInBetaStaysWithMagpie|TestStructuredOutput|TestResponsesTextKeepsVerbosityBesideFormat|TestRefusedFormatToldInWords|RPM|TestQueued|TestCustomGeminiUpstream'
go test -tags nogui ./internal/gateway -run 'TestCodexModelList|TestCodexModelsEtag'
go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestClaudeAccountToldRenewed|TestPluginAccountToldRenewed|TestSkipsRedaction|TestSetRPM'
```
+6
View File
@@ -144,6 +144,12 @@ type Agent struct {
// provider), so a model's name the gateway takes as a routing group
// is that group's (#750).
Routed func() bool
// FailingOver reports an agent that isn't connected whose requests
// still go through magpie's gateway, for account failover alone (Codex
// signed in to ChatGPT with more of its accounts on in magpie, #1385):
// the Agents page says so and what turns it off, and the gateway lists
// it only its own models.
FailingOver func() bool
// Follow, for an agent whose own picker moves its main model where
// magpie keeps other settings following it (Claude Code's /model and
// its tiers), brings those along to the model picked there. Run as the
+54 -4
View File
@@ -490,6 +490,22 @@ func codexIn(at place) *Agent {
}
return nil
}
// failingOver: Codex reaches magpie for account failover alone, by the
// base URL failover wrote (codex.failover) or by CC Switch's OpenAI
// mirror pointed at magpie for it (codex.mirror_failover), with nothing
// of magpie's connected. It read as not connected while every request
// went through magpie, magpie's models in its list (#1385).
failingOver := func() bool {
if wired() || asProvider() {
return false
}
s := stashLoad()
if p := get("model_provider"); p != "" && p != "openai" {
t, _ := edit.GetTOMLTable(path, "model_providers."+p)
return s[at.key("codex.mirror_failover")] == p && t["base_url"] == at.codexURL()
}
return viaBase() && s[at.key("codex.failover")] == "1"
}
modelOptions := func(withMagpie bool) []Option {
var own []Option
// each of Codex's own says which way it goes, as Claude Code's do:
@@ -778,10 +794,11 @@ func codexIn(at place) *Agent {
}
return true, settle()
},
Joined: joined,
Beside: beside,
Routed: routed,
OwnVia: codexOwnViaMagpie,
Joined: joined,
Beside: beside,
Routed: routed,
FailingOver: failingOver,
OwnVia: codexOwnViaMagpie,
Sync: func() error {
// model_provider = "magpie" left with its table gone (taken by
// another tool, or a hand edit), which keeps Codex from loading
@@ -849,6 +866,10 @@ func codexIn(at place) *Agent {
return err
}
}
case failingOver():
// the list the gateway hands a Codex there for failover
// alone: its own models, without magpie's (#1385)
return codexStaleCache(filepath.Join(dir, "models_cache.json"), provider.CodexOwnListTag())
case viaBase():
if err := codexStaleCache(filepath.Join(dir, "models_cache.json"), provider.CodexListTag()); err != nil {
return err
@@ -1324,6 +1345,35 @@ func codexFailover() bool {
return false
}
// CodexOwnOnly reports whether the Codex that asks the gateway for its
// model list there (/backend-api/codex/models) reaches magpie for account
// failover alone (FailingOver), to be handed only its own models (#1385).
// That is this machine's Codex; a request doesn't say whether it came from
// a Codex in a WSL distro instead, so one there connected to magpie (what
// magpie set on it, or its joined/beside mark in the stash) keeps magpie's
// models in the list. No distro is probed for it.
func CodexOwnOnly() bool {
home, err := os.UserHomeDir()
if err != nil || !codex(home).FailingOver() {
return false
}
appliedMu.Lock()
set := appliedLoad()
appliedMu.Unlock()
for id := range set {
if strings.HasPrefix(id, "codex@wsl:") {
return false
}
}
// a distro's name may have dots of its own (Ubuntu-24.04)
for k, v := range stashLoad() {
if strings.HasPrefix(k, "codex@wsl:") && v == "1" && (strings.HasSuffix(k, ".joined") || strings.HasSuffix(k, ".beside")) {
return false
}
}
return true
}
// codexChatGPT reports whether Codex is signed in to a ChatGPT account:
// its built-in provider then runs and asks for the model list at
// openai_base_url. Signed in with an API key alone, it asks for none.
+89
View File
@@ -3,6 +3,9 @@ package agent
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -85,3 +88,89 @@ func TestCodexHandWrittenBaseURLStays(t *testing.T) {
}
}
}
// #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)
}
}
+12
View File
@@ -76,6 +76,18 @@ func (a *Agent) Source() string {
return ""
}
// FailoverSaid is, for an agent not connected whose requests still go
// through magpie for account failover alone (FailingOver), what the CLI
// and the TUI say of it, the Agents page's line in words of their own:
// why it goes through magpie, and what turns that off (#1385). "" when it
// doesn't.
func (a *Agent) FailoverSaid() string {
if a.FailingOver == nil || !a.FailingOver() {
return ""
}
return "not connected · goes through magpie only to fail over to your other ChatGPT accounts; switch them off under Providers › Codex to stop it"
}
// startsWith is, for an agent that reads magpie's models only as it
// starts, how to find its processes (patterns for pgrep -f), the files it
// reads them from, and the part of those files magpie writes for it. The
+24 -3
View File
@@ -1035,9 +1035,14 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) {
codexcat.AutoReview(o)
}
}
ms := provider.CodexListed()
// a Codex here for account failover alone, not connected to magpie,
// keeps to its own models (#1385: 62 of magpie's were in its list)
var ms []catalog.Model
if !codexOwnOnly() {
ms = provider.CodexListed()
}
// the list is the backend's and magpie's, and so is its ETag
w.Header().Set("ETag", codexcat.WithTag(etag, provider.CodexListTag()))
w.Header().Set("ETag", codexcat.WithTag(etag, codexListTag()))
all := append(own, codexcat.Entries(ms, len(own)+100)...)
if at, ok := provider.CodexOrder(); ok {
codexcat.Order(all, at)
@@ -1050,10 +1055,26 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) {
// change to either has Codex ask for the list again.
func modelsEtag(h http.Header) {
if v := h.Get("X-Models-Etag"); v != "" {
h.Set("X-Models-Etag", codexcat.WithTag(v, provider.CodexListTag()))
h.Set("X-Models-Etag", codexcat.WithTag(v, codexListTag()))
}
}
// CodexOwnOnly reports whether the Codex asking for its model list at
// CodexPath reaches magpie for account failover alone, not connected to
// it (agent.CodexOwnOnly): it is then handed only its own models (#1385).
// Set by main; nil, every Codex gets magpie's models too.
var CodexOwnOnly func() bool
func codexOwnOnly() bool { return CodexOwnOnly != nil && CodexOwnOnly() }
// codexListTag is the tag of the list codexModels hands out now.
func codexListTag() string {
if codexOwnOnly() {
return provider.CodexOwnListTag()
}
return provider.CodexListTag()
}
// codexInput restores summaries magpie made. For a magpie model, it also
// replaces Codex's compaction trigger with a request to summarise the input.
// OpenAI's own models keep the trigger for their backend to handle.
+60
View File
@@ -782,3 +782,63 @@ func TestCodexOwnModelLogsEffort(t *testing.T) {
t.Errorf("trace %+v", rs)
}
}
// A Codex that reaches magpie for account failover alone, not connected to
// it, is handed only its own models, and an ETag that is neither the full
// list's nor contains it, so moving between the two has Codex ask again
// (#1385: 62 of magpie's models were in its list). Its replies' models ETag
// carries the same tag.
func TestCodexModelListOwnOnlyForFailover(t *testing.T) {
setup(t, provider.Chat, &fake{t: t})
chatgpt(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/models") {
w.Header().Set("ETag", `"v1"`)
io.WriteString(w, `{"models":[{"slug":"gpt-5.5","priority":1}]}`)
return
}
w.Header().Set("X-Models-Etag", `W/"v1"`)
io.WriteString(w, sse(
`data: {"type":"response.created","response":{"id":"r1"}}`,
`data: {"type":"response.completed","response":{"id":"r1"}}`))
})
ownOnly := false
was := CodexOwnOnly
CodexOwnOnly = func() bool { return ownOnly }
t.Cleanup(func() { CodexOwnOnly = was })
list := func() ([]string, string) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", CodexPath+"/models", nil)
req.Header.Set("Authorization", "Bearer chatgpt-token")
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, rec.Header().Get("ETag")
}
full, fullTag := list()
if len(full) < 2 || !strings.Contains(strings.Join(full, " "), "fake/m1") {
t.Fatalf("connected list %v", full)
}
ownOnly = true
own, ownTag := list()
if len(own) != 1 || own[0] != "gpt-5.5" {
t.Errorf("failover-only list = %v, want just gpt-5.5", own)
}
if ownTag == fullTag || ownTag != `"v1+magpie-`+provider.CodexOwnListTag()+`"` ||
codexcat.Tagged(ownTag, provider.CodexListTag()) || codexcat.Tagged(fullTag, provider.CodexOwnListTag()) {
t.Errorf("ETag own %q, full %q", ownTag, fullTag)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":[]}`))
req.Header.Set("Authorization", "Bearer chatgpt-token")
New().Handler().ServeHTTP(rec, req)
if got := rec.Header().Get("X-Models-Etag"); rec.Code != 200 || !codexcat.Tagged(got, provider.CodexOwnListTag()) {
t.Errorf("%d X-Models-Etag %q", rec.Code, got)
}
}
+4
View File
@@ -203,6 +203,9 @@ type agentJSON struct {
// Joined: connected with its own models still in its list (Codex
// signed in with ChatGPT, agent.Agent.Join)
Joined bool `json:"joined,omitempty"`
// Failover: not connected, yet its requests go through magpie for
// account failover alone (agent.Agent.FailingOver, #1385)
Failover bool `json:"failover,omitempty"`
// CLIMissing: its settings are here, its CLI isn't (#843), which
// the row says, and Install another agent offers it again
CLIMissing bool `json:"cliMissing,omitempty"`
@@ -1727,6 +1730,7 @@ func state() stateJSON {
aj.Joined = a.Joined != nil && a.Joined()
} else {
aj.Source = a.Source()
aj.Failover = a.FailingOver != nil && a.FailingOver()
}
if a.Import != nil {
aj.Import, aj.Added = a.Import(), a.Added != nil && a.Added()
+4
View File
@@ -398,6 +398,10 @@ body.panel #agents > .row .ag-handle::before { left: -10px; }
.ag-fix:active { transform: scale(.96); }
.ag-fix:focus-visible { outline: 1.5px solid var(--drift); outline-offset: 1px; }
.ag-fix.busy { pointer-events: none; opacity: .7; }
/* the accounts that keep an agent going through magpie for failover (#1385) */
.ag-acc { display: inline-flex; align-items: center; flex: none; height: 20px; padding: 0 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--card); color: var(--fg-2); font: inherit; font-size: 11.5px; line-height: 1; cursor: pointer; transition: background-color .15s, color .15s; }
.ag-acc:hover { background: var(--pill); color: var(--fg); }
.ag-acc:focus-visible { outline: 1.5px solid var(--fg-2); outline-offset: 1px; }
.ag-fix.busy svg > * { transform-box: view-box; transform-origin: 50% 50%; animation: spin .8s linear infinite; }
/* what has to listen at an address no agent reaches magpie by (#1013): long addresses wrap, and it can be copied */
.drift-why { overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; }
+15 -1
View File
@@ -1065,6 +1065,18 @@ function connectLine(a, kind) {
if (!a.wired) {
const src = a.source || "";
if (a.id === "agy") say(t("Not connected · once connected, start it with magpie's command"));
// not connected, yet through magpie for account failover (#1385): it
// read as Codex on its own while magpie served every request, so the
// line says why, and the accounts that turn it off are a click away
else if (a.failover) {
say(t("Not connected · goes through magpie only to fail over to your other ChatGPT accounts"));
words.title = t("magpie set openai_base_url in {agent}'s config.toml, so a request the ChatGPT account {agent} is signed in to has no allowance for goes on to your other ChatGPT accounts on in magpie. {agent} keeps to its own models. To stop it, switch those accounts off under Providers › Codex; connecting {agent} adds magpie's models.", { agent: a.name });
const go = el("button", "ag-acc", t("ChatGPT accounts"));
go.type = "button";
go.title = words.title;
go.onclick = (e) => { e.stopPropagation(); openProvider("codex"); };
line.append(go);
}
else if (src === "sub") say(t("Not connected · now on its Claude subscription"));
else if (src === "chatgpt") say(t("Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected"));
else if (src === "key") say(t("Not connected · now on an API key of its own"));
@@ -6720,7 +6732,9 @@ async function openProvider(id) {
if (!await show("providers")) return;
closeModal();
adding = false; editing = id; draft = null; presetQuery = "";
renderProviders();
// the list not in yet (the tab not opened before), show's load draws it
// with this one open
if (providers) renderProviders();
syncURL();
}
+12
View File
@@ -379,6 +379,9 @@ const I18N = {
"Not connected · once connected, start it with magpie's command": "未接入 · 接入后要用 magpie 给的命令启动",
"Not connected · now on its Claude subscription": "未接入 · 现在用 Claude 订阅",
"Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected": "未接入 · 现在用 ChatGPT 登录 · 接入后 OpenAI 的模型照常可用",
"Not connected · goes through magpie only to fail over to your other ChatGPT accounts": "未接入 · 只为切换到你的其他 ChatGPT 账号而经过 magpie",
"magpie set openai_base_url in {agent}'s config.toml, so a request the ChatGPT account {agent} is signed in to has no allowance for goes on to your other ChatGPT accounts on in magpie. {agent} keeps to its own models. To stop it, switch those accounts off under Providers › Codex; connecting {agent} adds magpie's models.": "magpie 在 {agent} 的 config.toml 里写了 openai_base_url:{agent} 登录的 ChatGPT 账号额度不够时,请求会转到你在 magpie 里启用的其他 ChatGPT 账号。{agent} 只列出它自己的模型。要停止,请在 供应商 › Codex 里停用那些账号;接入 {agent} 则会加上 magpie 的模型。",
"ChatGPT accounts": "ChatGPT 账号",
"Not connected · now on an API key of its own": "未接入 · 现在用它自己的 API key",
"Not connected · now has {n} providers of its own": "未接入 · 现在有 {n} 个自己的供应商",
"Reconnect": "重新接入",
@@ -4229,6 +4232,9 @@ const I18N = {
"Not connected · once connected, start it with magpie's command": "未接入 · 接入後要用 magpie 給的命令啟動",
"Not connected · now on its Claude subscription": "未接入 · 現在用 Claude 訂閱",
"Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected": "未接入 · 現在用 ChatGPT 登入 · 接入後 OpenAI 的模型照常可用",
"Not connected · goes through magpie only to fail over to your other ChatGPT accounts": "未接入 · 只為切換到你的其他 ChatGPT 帳號而經過 magpie",
"magpie set openai_base_url in {agent}'s config.toml, so a request the ChatGPT account {agent} is signed in to has no allowance for goes on to your other ChatGPT accounts on in magpie. {agent} keeps to its own models. To stop it, switch those accounts off under Providers › Codex; connecting {agent} adds magpie's models.": "magpie 在 {agent} 的 config.toml 裡寫了 openai_base_url:{agent} 登入的 ChatGPT 帳號額度不夠時,請求會轉到你在 magpie 裡啟用的其他 ChatGPT 帳號。{agent} 只列出它自己的模型。要停止,請在 供應商 › Codex 裡停用那些帳號;接入 {agent} 則會加上 magpie 的模型。",
"ChatGPT accounts": "ChatGPT 帳號",
"Not connected · now on an API key of its own": "未接入 · 現在用它自己的 API key",
"Not connected · now has {n} providers of its own": "未接入 · 現在有 {n} 個自己的供應商",
"Reconnect": "重新接入",
@@ -8044,6 +8050,9 @@ const I18N = {
"Not connected · once connected, start it with magpie's command": "未接続 · 接続後は magpie のコマンドで起動します",
"Not connected · now on its Claude subscription": "未接続 · 現在は Claude サブスクリプションを使用中",
"Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected": "未接続 · 現在は ChatGPT でサインイン中 · 接続後も OpenAI のモデルは使えます",
"Not connected · goes through magpie only to fail over to your other ChatGPT accounts": "未接続 · 他の ChatGPT アカウントへ切り替えるためだけに magpie を経由しています",
"magpie set openai_base_url in {agent}'s config.toml, so a request the ChatGPT account {agent} is signed in to has no allowance for goes on to your other ChatGPT accounts on in magpie. {agent} keeps to its own models. To stop it, switch those accounts off under Providers › Codex; connecting {agent} adds magpie's models.": "magpie は {agent} の config.toml に openai_base_url を書き込みました。{agent} がサインインしている ChatGPT アカウントの利用枠が足りないリクエストは、magpie で有効にしている他の ChatGPT アカウントに回ります。{agent} には自身のモデルだけが表示されます。止めるには、プロバイダ › Codex でそれらのアカウントを停止してください。{agent} を接続すると magpie のモデルも加わります。",
"ChatGPT accounts": "ChatGPT アカウント",
"Not connected · now on an API key of its own": "未接続 · 現在は独自の APIキーを使用中",
"Not connected · now has {n} providers of its own": "未接続 · 現在は独自のプロバイダが {n} 件あります",
"Reconnect": "再接続",
@@ -11877,6 +11886,9 @@ const I18N = {
"Not connected · once connected, start it with magpie's command": "Nicht verbunden · nach dem Verbinden mit dem Befehl von magpie starten",
"Not connected · now on its Claude subscription": "Nicht verbunden · nutzt derzeit sein Claude-Abo",
"Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected": "Nicht verbunden · derzeit mit ChatGPT angemeldet · OpenAI-Modelle bleiben nach dem Verbinden verfügbar",
"Not connected · goes through magpie only to fail over to your other ChatGPT accounts": "Nicht verbunden · läuft nur für den Wechsel zu deinen anderen ChatGPT-Konten über magpie",
"magpie set openai_base_url in {agent}'s config.toml, so a request the ChatGPT account {agent} is signed in to has no allowance for goes on to your other ChatGPT accounts on in magpie. {agent} keeps to its own models. To stop it, switch those accounts off under Providers › Codex; connecting {agent} adds magpie's models.": "magpie hat openai_base_url in die config.toml von {agent} geschrieben: Eine Anfrage, für die das ChatGPT-Konto, mit dem {agent} angemeldet ist, kein Kontingent mehr hat, geht an deine anderen in magpie aktiven ChatGPT-Konten. {agent} zeigt nur seine eigenen Modelle. Zum Beenden diese Konten unter Anbieter › Codex deaktivieren; verbindest du {agent}, kommen die Modelle von magpie hinzu.",
"ChatGPT accounts": "ChatGPT-Konten",
"Not connected · now on an API key of its own": "Nicht verbunden · nutzt derzeit einen eigenen API-Schlüssel",
"Not connected · now has {n} providers of its own": "Nicht verbunden · hat derzeit {n} eigene Anbieter",
"Reconnect": "Erneut verbinden",
+108
View File
@@ -0,0 +1,108 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// #1385 (shenghsi): Codex isn't connected, yet magpie set openai_base_url for
// it so a request its ChatGPT account has no allowance for goes on to the
// user's other ChatGPT accounts on in magpie. The row says it goes through
// magpie for that alone, its title says what turns it off, and a button
// opens Providers › Codex, where those accounts are switched off. A Codex
// not failing over says what it said before. In every language, at a narrow
// width too, in Chromium and WebKit; no click moves the page.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const options = [{ value: "gpt-5.5", label: "GPT-5.5" }, { value: "relay/m1", label: "m1", ref: "relay/m1", note: "Relay · via magpie" }];
const state = () => ({
agents: [
{ id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", failover: true, fields: [{ key: "model", label: "model", value: "gpt-5.5", options }] },
{ id: "codex@wsl:Ubuntu-24.04", name: "Codex", icon: "generic", path: "/fixture/wsl-codex", fields: [{ key: "model", label: "model", value: "gpt-5.5", options }] },
],
profiles: [],
});
function server(lang) {
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ ...state(), settings: { lang, theme: "light" } });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
if (url.pathname === "/api/providers") return json({ providers: [codexProvider], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
// three ChatGPT accounts on, as the reporter's
const codexProvider = {
id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "", anthropic: "", catalog: "", routing: "smart",
models: [{ id: "gpt-5.5", name: "GPT-5.5", on: true }], agents: [], fallback: [], headers: {}, keyList: [],
account: {
agent: "codex", agentName: "Codex", user: "me@example.com", plan: "PLUS",
logins: [
{ user: "me@example.com", plan: "PLUS", active: true, on: true },
{ user: "spare1@example.com", plan: "PLUS", on: true },
{ user: "spare2@example.com", plan: "PRO", on: true },
],
},
};
const said = "Not connected · goes through magpie only to fail over to your other ChatGPT accounts";
const title = "magpie set openai_base_url in {agent}'s config.toml, so a request the ChatGPT account {agent} is signed in to has no allowance for goes on to your other ChatGPT accounts on in magpie. {agent} keeps to its own models. To stop it, switch those accounts off under Providers › Codex; connecting {agent} adds magpie's models.";
const button = "ChatGPT accounts";
const plain = "Not connected · {agent} uses its own settings";
const row = (id) => `.row.agent[data-id="${id}"]`;
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh", "zh-TW", "ja", "de"]) {
for (const width of [980, 420]) {
test(`${engine} ${lang} ${width}px: a Codex there for failover alone says so and how to stop it`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width, height: 640 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [], dialogs = [];
page.on("pageerror", (e) => errors.push(e.message));
page.on("dialog", (d) => { dialogs.push(d.message()); d.dismiss(); });
await page.route("**/*", server(lang));
await page.goto("http://magpie.test/?view=agents");
await page.locator(`${row("codex")} .ag-st`).waitFor();
const tr = (k) => page.evaluate(([k, lang]) => (lang === "en" ? k : I18N[lang]?.[k]), [k, lang]);
for (const k of [said, title, button]) assert.ok(await tr(k), `${lang} has ${k.slice(0, 40)}`);
const want = (await tr(said));
const wantTitle = (await tr(title)).replaceAll("{agent}", "Codex");
const st = page.locator(`${row("codex")} .ag-st`);
assert.ok((await st.textContent()).startsWith(want), await st.textContent());
const go = st.locator("button.ag-acc");
assert.equal(await go.textContent(), await tr(button));
assert.equal(await go.getAttribute("title"), wantTitle);
// the twin not failing over says what it always did
assert.equal(await page.locator(`${row("codex@wsl:Ubuntu-24.04")} .ag-st`).textContent(),
(await tr(plain)).replace("{agent}", "Codex"));
assert.equal(await page.locator(`${row("codex@wsl:Ubuntu-24.04")} .ag-acc`).count(), 0);
const wide = await page.evaluate(() => document.scrollingElement.scrollWidth - document.scrollingElement.clientWidth);
assert.ok(wide <= 0, `the page scrolls sideways by ${wide}px`);
const box = await go.boundingBox();
assert.ok(box && box.x >= 0 && box.x + box.width <= width, `button off screen: ${JSON.stringify(box)}`);
const top = await page.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#view-agents")?.scrollTop]);
await go.click();
await page.waitForFunction(() => new URL(location.href).searchParams.get("view") === "providers");
await page.waitForFunction(() => new URL(location.href).searchParams.get("edit") === "codex");
// its editor, with the accounts to switch off, is what opens
await page.waitForFunction(() => !document.querySelector("#modal").hidden && document.querySelector("#modal").innerText.startsWith("Codex"));
assert.ok(await page.locator(".row.provider.selected[data-id=codex]").count(), "Codex is the one open");
assert.deepEqual(dialogs, []);
assert.deepEqual(errors, []);
assert.ok(top.every((v) => v === 0 || v === undefined), `page was scrolled ${top}`);
});
}
}
}
+10 -2
View File
@@ -465,7 +465,15 @@ func CodexOrder() (map[string]int, bool) {
// the account's own taken out of it, the order they are in, the windows set on them, the auto-review model, and whether its OpenAI models say
// multi-agent V1 (settings.CodexAgentsV1), so any of them changing has
// Codex ask for the list again.
func CodexListTag() string {
func CodexListTag() string { return codexListTag("") }
// CodexOwnListTag names the list a Codex that reaches magpie for account
// failover alone is handed, its own models without magpie's (#1385). Neither
// it nor CodexListTag's is a part of the other, so Codex asks again when it
// moves from one list to the other; a V1 list's mark stays in front.
func CodexOwnListTag() string { return codexListTag("own") }
func codexListTag(kind string) string {
ms := CodexListed()
off := slices.Sorted(maps.Keys(CodexNativeHidden()))
for _, slug := range off {
@@ -485,7 +493,7 @@ func CodexListTag() string {
if v := settings.Load().CodexAutoReview; v != "" {
ms = append(ms, catalog.Model{ID: "~autoreview:" + v})
}
return codexcat.PolicyTag(codexcat.Tag(ms))
return codexcat.PolicyTag(kind + codexcat.Tag(ms))
}
// CodexNativePicked is the set of the ChatGPT account's own model slugs the
+9
View File
@@ -97,6 +97,10 @@ type model struct {
w, h int
syncing bool
// failover: what an agent going through magpie for account failover
// alone, not connected, says of it (agent.FailoverSaid, #1385)
failover []string
page page
back mode // where esc goes from a picker or a line to type
ask ask
@@ -184,8 +188,10 @@ func newModel() model {
func (m *model) reload() {
m.values = make([]map[string]string, len(m.agents))
m.failover = make([]string, len(m.agents))
for i, a := range m.agents {
m.values[i] = a.Values()
m.failover[i] = a.FailoverSaid()
}
switch m.page {
case pageProviders:
@@ -812,6 +818,9 @@ func (m model) viewList() string {
}
line += cell + " "
}
if i < len(m.failover) && m.failover[i] != "" {
line += sMuted.Render(" · " + m.failover[i])
}
if sel {
p := tilde(a.Path)
if room := m.w - lipgloss.Width(line) - lipgloss.Width(p) - 2; room > 0 {
+7
View File
@@ -212,6 +212,9 @@ func run(args []string) error {
// a model Claude Code names that magpie doesn't serve goes to the one
// it is set to use for that tier
gateway.StandIn = agent.StandIn
// a Codex that reaches magpie for account failover alone is handed
// only its own models (#1385)
gateway.CodexOwnOnly = agent.CodexOwnOnly
// the setup kept the same on every computer, by whichever serves
gateway.WhileServing = append(gateway.WhileServing, davsync.Run)
// and dsh's patch lists, which dsh reads live: a route left behind by
@@ -519,6 +522,10 @@ func list(agents []*agent.Agent, detectedOnly bool, dimFrom int) error {
r.name = faint.Render(a.Name) + " " + faint.Render("hidden")
}
r.vals = strings.Join(parts, label.Render(" · "))
// through magpie for account failover while not connected (#1385)
if said := a.FailoverSaid(); said != "" {
r.vals += label.Render(" · " + said)
}
if a.Import != nil {
if a.Added != nil && a.Added() {
r.vals = value.Render("magpie added")