Files
magpie/internal/agent/codex.go
T
yetone 528d6c12dc agent, gui: Codex left on model_provider = "magpie" with no [model_providers.magpie] loads its config again, and connecting with nothing in magpie says what to do, in the reader's language (Tystem on Discord) — the Codex app said "无法加载 config.toml … Model provider magpie not found", magpie showed Codex as not connected on "deepseek-flash", and its switch answered only "Codex can't be connected to magpie", in English. Codex looks model_provider up in [model_providers.*] as it loads config.toml (codex-rs: "Model provider {id} not found"), so the key without the table keeps it from loading any config, a new thread too, not just an old one. magpie never writes one without the other: it takes model_provider out whenever it steps out as the provider (disconnect, Unwire, a model of Codex's own, Join) and keeps the table so the threads started on magpie still open. That is unchanged, and TestCodexSteppingOutLeavesNoHalfState now covers it. Another tool or a hand edit had taken the table away. Sync, at magpie's start and whenever the catalog changes, now writes the table back whenever the config names magpie as its provider, at the top or in a profile, and has no table, whether Codex is connected or not. Check reports that state too. A config magpie can't parse is still only reported, never written to. A Connect that finds none of magpie's models because no provider or subscription has been added answers agent.NoModelsError, "Add a provider or subscription in magpie first, then connect Codex". /api answers it with code no_models and the agent's name, which app.js says through t() (en/zh/ja/de). docs/reference.md no longer says the table goes with a native model. TestCodexProviderWithoutTable (top and profile: Check says so, Sync writes the table) and TestConnectWithNoModels in internal/agent, TestAgentConnectNoModels in internal/gui, and agent-connect-no-models.test.cjs (WebKit, en/zh/ja/de: the toast in the reader's language, the switch back off, no scroll, no native dialog) are new and fail without the change; the latter's en case passes either way, as the server's message is English. Chromium wouldn't launch on this machine for this test or the existing agent-connect.test.cjs. TestCodexSyncAddsBaseURLToProvider now expects the table added to a hand-made provider that had none. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. In the first full run, internal/gui's TestProvidersAnswerWhileListsComeIn and internal/proc's TestUserPath failed under load; both passed alone and in the second full run.
2026-10-05 21:09:13 +08:00

1280 lines
45 KiB
Go

package agent
import (
"cmp"
"context"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/codexcat"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
)
// Codex talks the OpenAI Responses API. Signed in to ChatGPT, it is
// routed through magpie with `openai_base_url` alone: Codex keeps its
// built-in OpenAI provider and sign-in, so its own models, its threads
// (listed per provider) and the Codex app's model picker stay as they are,
// and magpie's gateway passes its own models through to OpenAI while it
// answers the rest — the model list included, so magpie's models join
// OpenAI's in /model. Not signed in, the built-in provider can't run, and
// magpie is a provider of its own: a [model_providers.magpie] table,
// `model_provider = "magpie"`, and a model catalog file for /model. So it
// is too for Codex signed in with an API key, often one a relay issued:
// Codex then never asks for the model list (its models manager skips the
// fetch for an API key), so its picker was the models it was built with
// and the one set, magpie's others missing, and the built-in ones went to
// OpenAI with that key (#322). So it is too for a ChatGPT account that
// has used its allowance up, which the Codex app won't send anything for,
// whoever serves the model, and when the user asks for it (the sign-in
// field's api): the Codex app is then in
// its API state rather than signed in to ChatGPT. The base URL is set then
// too: a thread started on the built-in provider is opened on it again, and
// a magpie model picked in it would otherwise go to the ChatGPT backend.
//
// The [model_providers.magpie] table stays once written. A thread keeps the
// provider it was started on, and one started on magpie can't be opened
// again without the table ("Model provider `magpie` not found"), whichever
// way Codex is routed now. Codex looks model_provider up in the tables as
// it loads config.toml (codex-rs config: "Model provider `{id}` not
// found"), so `model_provider = "magpie"` without the table keeps it from
// loading its config at all, a new thread too: magpie never writes the one
// without the other, takes model_provider out whenever it steps out as the
// provider (disconnected, unwired, joined beside the sign-in), and Sync
// writes the table back when something else took it.
// codexStandIn is the model Codex is set to use, when magpie is its
// provider, for a model it names that magpie doesn't serve. Codex asks for
// some by a name of OpenAI's whatever its provider: auto-review of an
// approval goes out as "codex-auto-review" (the guardian's reviewer model,
// "independent of current login"), which magpie answered 404 "magpie knows
// no model", and Codex reported the review as failed. Codex itself reviews
// with the turn's model when its catalog lacks the reviewer; so does magpie.
// Signed in, Codex's own names go on to OpenAI (codexUpstream) instead.
func codexStandIn(path string) string {
if p, _ := edit.GetTOMLTop(path, "model_provider"); p != magpieID {
return ""
}
m, _ := edit.GetTOMLTop(path, "model")
return m
}
func codex(home string) *Agent { return codexIn(here(home)) }
// codexIn is Codex as it lives at a place: this machine's home, or a WSL
// distro's (see wsl.go).
func codexIn(at place) *Agent {
dir := filepath.Join(at.home, ".codex")
path := filepath.Join(dir, "config.toml")
catalogPath := filepath.Join(dir, "magpie-models.json")
get := func(k string) string { v, _ := edit.GetTOMLTop(path, k); return v }
// this machine's Codex reaches the gateway at the address its config
// gives, when that is magpie's on another host: the Codex app on
// Windows with its agent in WSL reads Windows' config.toml from WSL,
// where 127.0.0.1 is the distro's own, so the user points it at
// Windows as WSL sees it, or a forward of theirs (#816); magpie keeps
// that address, checks against it and writes it again
if at.spell == nil && at.base == nil {
at.base = func() string { return codexKeptGateway(path) }
}
// the catalog as the config names it: relative to config.toml on
// Windows, where a C:\ path is no file to the same Codex reading it
// from WSL (#816); Codex reads a relative one from its home
catalogRef := at.native(catalogPath)
if at.spell == nil && runtime.GOOS == "windows" {
catalogRef = filepath.Base(catalogPath)
}
// ownCatalog: the config names magpie's catalog, by any spelling
ownCatalog := func(c string) bool {
if c == "" {
return false
}
if c == catalogRef || c == at.native(catalogPath) {
return true
}
return at.spell == nil && !filepath.IsAbs(c) && filepath.Clean(filepath.Join(dir, c)) == filepath.Clean(catalogPath)
}
asProvider := func() bool { return get("model_provider") == magpieID }
viaBase := func() bool { return isCodexGatewayOn(get("openai_base_url"), at.host()) }
routed := func() bool { return asProvider() || viaBase() }
// joined: connected by Join, on a model of Codex's own (its last pick)
// with magpie's beside it, by the base URL
joined := func() bool { return viaBase() && stashLoad()[at.key("codex.joined")] == "1" }
// magpie is in Codex's config: on one of magpie's models, or joined;
// or, routed, on the model of the group magpie set it to as Codex
// spells it (gpt-6.1-sol for group/auto-gpt-6-1-sol, #750), which the
// gateway takes as that group
wired := func() bool {
m := get("model")
return isMagpie(m) || joined() || routed() && setAsGroup(cmp.Or(at.id, "codex"), "model", m)
}
models := func() []catalog.Model {
switch {
case asProvider():
return magpieModels("codex")
case viaBase():
return append(catalog.Codex(), magpieModels("codex")...)
}
return catalog.Codex()
}
var dropSubEffort func() error
// keep the effort valid for the model; a fresh model gets its default.
// Routed, the Codex app offers magpie's models' efforts too (#310).
settle := func() error {
if routed() {
if err := codexEnableEfforts(path, magpieModels("codex")); err != nil {
return err
}
}
ms := models()
model, effort := get("model"), get("model_reasoning_effort")
if e := catalog.Efforts(ms, model); len(e) > 0 && !contains(e, effort) {
if err := edit.SetTOMLTop(path, edit.KV{Path: "model_reasoning_effort", Value: codexcat.DefaultEffort(e)}); err != nil {
return err
}
}
return dropSubEffort()
}
// magpie as a provider Codex can name; its threads started on magpie do
putProvider := func() error {
return edit.SetTOMLTable(path, "model_providers."+magpieID,
edit.KV{Path: "name", Value: "magpie"},
edit.KV{Path: "base_url", Value: at.v1()},
edit.KV{Path: "wire_api", Value: "responses"},
edit.KV{Path: "experimental_bearer_token", Value: at.gwKey()},
// Codex offers its built-in image tool (image_gen.imagegen) and
// the other OpenAI-provider tools only to a provider it reads as
// the OpenAI actor: one that does not require OpenAI auth but
// carries x-openai-actor-authorization. Without this header the
// model is never given the tool, so a Codex on magpie can't draw
// (the request that names model/draws never happens at all).
// uses_openai_actor_authorization() is exactly this check.
edit.KV{Path: "http_headers", Value: edit.Raw(`{ "x-openai-actor-authorization" = "magpie" }`)},
)
}
hasProvider := func() bool {
t, err := edit.GetTOMLTable(path, "model_providers."+magpieID)
return err == nil && t != nil
}
// noProvider: the config was read and has no such table; one magpie
// can't read is told by Check as it is, and written to by nothing
noProvider := func() bool {
t, err := edit.GetTOMLTable(path, "model_providers."+magpieID)
return err == nil && t == nil
}
// namesMagpie: the config has Codex on magpie as its provider, at its
// top or in a profile. Codex then reads [model_providers.magpie] as it
// loads the config, before any thread: without the table it loads no
// config at all ("Model provider `magpie` not found"), the app and the
// CLI both, whatever the model.
namesMagpie := func() bool {
if asProvider() {
return true
}
names, _ := edit.TOMLTables(path)
for _, n := range names {
if strings.HasPrefix(n, "profiles.") {
if t, _ := edit.GetTOMLTable(path, n); t["model_provider"] == magpieID {
return true
}
}
}
return false
}
// dropProvider takes magpie out as the provider Codex is on; the table
// stays for the threads started on it
dropProvider := func() error {
if !asProvider() {
return nil
}
if err := edit.DelTOMLTop(path, "model_provider", "model_catalog_json"); err != nil {
return err
}
os.Remove(catalogPath)
return nil
}
// isCCSwitchMirror reports whether a model_providers table is CC Switch's
// official OpenAI mirror: name = "OpenAI", requires_openai_auth = true, and no
// base_url (or magpie's codexURL while failing over).
isCCSwitchMirror := func(p string) bool {
if p == "" || p == "openai" || p == magpieID {
return false
}
t, err := edit.GetTOMLTable(path, "model_providers."+p)
if err != nil || t["name"] != "OpenAI" || t["requires_openai_auth"] != "true" {
return false
}
u := t["base_url"]
return u == "" || (u == at.codexURL() && stashLoad()[at.key("codex.mirror_failover")] == p)
}
// CC Switch's provider tables in Codex's config, each one's base URL
// by its id. A thread keeps the provider it was
// started on, and CC Switch moves every third-party thread onto its
// "custom" one, so one reopened there still went to the relay, a
// magpie model picked in it too, which the relay didn't know. While a
// magpie model is on, those tables go through magpie as well; a table
// a profile names is the user's to pick, and stays as it is.
ccSwitchTables := func() map[string]string {
names, _ := edit.TOMLTables(path)
named := map[string]bool{}
for _, n := range names {
if strings.HasPrefix(n, "profiles.") {
if t, _ := edit.GetTOMLTable(path, n); t["model_provider"] != "" {
named[t["model_provider"]] = true
}
}
}
out := map[string]string{}
for _, n := range names {
id, ok := strings.CutPrefix(n, "model_providers.")
if !ok || !ccSwitchProvider.MatchString(id) || named[id] || isCCSwitchMirror(id) {
continue
}
if t, _ := edit.GetTOMLTable(path, n); t["base_url"] != "" {
out[id] = t["base_url"]
}
}
return out
}
// takeTables points CC Switch's tables at magpie, each one's own base
// URL kept in the stash for giveTables (a table already on magpie's
// keeps the one kept for it)
takeTables := func() error {
was := map[string]string{}
json.Unmarshal([]byte(stashLoad()[at.key("codex.tables")]), &was)
for id, u := range ccSwitchTables() {
if u == at.v1() {
continue
}
if err := edit.SetTOMLKey(path, "model_providers."+id, "base_url", at.v1()); err != nil {
return err
}
was[id] = u
}
if len(was) == 0 {
return nil
}
b, _ := json.Marshal(was)
stash(map[string]string{at.key("codex.tables"): string(b)})
return nil
}
// giveTables puts the base URLs back, on the tables still pointed at
// magpie; one changed since is left as it is now
giveTables := func() error {
var was map[string]string
json.Unmarshal([]byte(stashLoad()[at.key("codex.tables")]), &was)
now := ccSwitchTables()
for id, u := range was {
if now[id] != at.v1() {
continue
}
if err := edit.SetTOMLKey(path, "model_providers."+id, "base_url", u); err != nil {
return err
}
}
forget(at.key("codex.tables"))
return nil
}
// api: the user wants magpie as Codex's provider even while Codex is
// signed in to ChatGPT
api := func() bool { return stashLoad()[at.key("codex.login")] == "api" }
dropBase := func() error {
forget(at.key("codex.failover"))
if !viaBase() {
return nil
}
return edit.DelTOMLTop(path, "openai_base_url")
}
dropMirrorFailover := func() error {
p := stashLoad()[at.key("codex.mirror_failover")]
if p == "" {
return nil
}
forget(at.key("codex.mirror_failover"))
t, _ := edit.GetTOMLTable(path, "model_providers."+p)
if t["base_url"] == at.codexURL() {
return edit.DelTOMLKey(path, "model_providers."+p, "base_url")
}
return nil
}
// the model spawned subagents start on, when not the parent's; one of
// magpie's goes when magpie steps out, as Codex could no longer find it
subagent := func() (string, error) {
agents, err := edit.GetTOMLTable(path, "agents")
return agents["default_subagent_model"], err
}
// the model Codex writes its memories with (Yc on Discord): [memories]
// extract_model summarises a thread, consolidation_model folds them in
// (codex-rs/config/src/types.rs). Unset, Codex takes gpt-5.6-luna and
// gpt-5.6-terra, whatever its model; magpie sets both to one pick.
memories := func() (string, error) {
m, err := edit.GetTOMLTable(path, "memories")
return cmp.Or(m["consolidation_model"], m["extract_model"]), err
}
dropMemories := func() error {
for _, k := range []string{"extract_model", "consolidation_model"} {
if err := edit.DelTOMLKey(path, "memories", k); err != nil {
return err
}
}
return nil
}
// one of magpie's goes too: Codex could no longer find it
dropSubagent := func() error {
if m, err := memories(); err != nil {
return err
} else if isMagpie(m) {
if err := dropMemories(); err != nil {
return err
}
}
model, err := subagent()
if err != nil {
return err
}
if !isMagpie(model) {
return nil
}
return edit.DelTOMLKey(path, "agents", "default_subagent_model")
}
// the effort spawned subagents start at, when not the parent's
// (core/src/agent/child_config.rs): checked against the model they run
// on, theirs or the parent's, and a spawn at one it lacks fails, so one
// that model doesn't take goes
subEffort := func() (string, error) {
agents, err := edit.GetTOMLTable(path, "agents")
return agents["default_subagent_reasoning_effort"], err
}
subModel := func() string {
if m, _ := subagent(); m != "" {
return m
}
return get("model")
}
subEfforts := func() []string { return catalog.Efforts(models(), subModel()) }
dropSubEffort = func() error {
e, err := subEffort()
if err != nil || e == "" {
return err
}
if l := subEfforts(); len(l) > 0 && !contains(l, e) {
return edit.DelTOMLKey(path, "agents", "default_subagent_reasoning_effort")
}
return nil
}
// Codex on one of its own models goes through magpie too while more of
// its ChatGPT accounts are on there, so one out of its allowance hands
// the turn to the next; with none, it goes straight to OpenAI again
failover := func() error {
if wired() || asProvider() {
return nil
}
p := get("model_provider")
mirror := isCCSwitchMirror(p)
if !mirror {
_ = dropMirrorFailover()
}
if p != "" && p != "openai" && !mirror {
return nil
}
on := codexFailover()
if mirror {
t, _ := edit.GetTOMLTable(path, "model_providers."+p)
hasGateway := t["base_url"] == at.codexURL()
switch {
case on && !hasGateway:
if err := edit.SetTOMLKey(path, "model_providers."+p, "base_url", at.codexURL()); err != nil {
return err
}
stash(map[string]string{at.key("codex.mirror_failover"): p})
case !on && hasGateway:
if err := edit.DelTOMLKey(path, "model_providers."+p, "base_url"); err != nil {
return err
}
forget(at.key("codex.mirror_failover"))
}
return nil
}
// the base URL failover wrote is marked (codex.failover), and only
// that one goes again: one the user wrote in config.toml by hand,
// to have Codex's own models go through magpie, stays (#856: it was
// gone again each time magpie started). One already there while
// failover is on is taken as failover's, as a magpie from before
// the mark wrote it.
owned := stashLoad()[at.key("codex.failover")] == "1"
switch {
case on && !viaBase():
if err := edit.SetTOMLTop(path, edit.KV{Path: "openai_base_url", Value: at.codexURL()}); err != nil {
return err
}
stash(map[string]string{at.key("codex.failover"): "1"})
case on && !owned:
stash(map[string]string{at.key("codex.failover"): "1"})
case !on && viaBase() && owned:
return dropBase()
}
return nil
}
modelOptions := func(withMagpie bool) []Option {
var own []Option
// each of Codex's own says which way it goes, as Claude Code's do:
// to its provider directly, or through magpie while routed by the
// base URL or with magpie its provider (EZN7L2C3, #834: Codex 下拉
// 选项中没有 via magpie)
p := get("model_provider")
to, through := "OpenAI", routed()
if p != "" && p != magpieID && p != "openai" {
// a provider of the user's own, or CC Switch's, pointed at
// magpie or not
t, _ := edit.GetTOMLTable(path, "model_providers."+p)
to, through = p, t["base_url"] == at.codexURL() || t["base_url"] == at.v1()
}
say := func(own []Option) []Option {
for i := range own {
if through {
own[i].Via = true
} else {
own[i].Direct = to
}
}
return own
}
if p != "" && p != magpieID && !isCCSwitchMirror(p) {
own = say(group(p, options(catalog.Codex(), "")))
} else {
own = say(group("OpenAI", options(ownCodex(), "")))
// on magpie API, Codex's own models are reached through magpie,
// on its ChatGPT account there: picked so, set so (#701)
if api() {
for i, o := range own {
if id := codexOwnViaMagpie(o.Value); id != "" {
own[i].Label, own[i].Value, own[i].Ref = o.Value, id, id
}
}
}
}
if !withMagpie {
return own
}
return append(own, viaMagpieFor("codex", "")...)
}
// unroute takes magpie out as the way Codex reaches its models and puts
// back what the stash kept from before magpie was wired in (its
// provider, catalog and effort); it answers the model Codex was on
// then, for Unwire to go back to
unroute := func() (string, error) {
forget(at.key("codex.out"), at.key("codex.joined"))
if err := giveTables(); err != nil {
return "", err
}
if !routed() {
return "", nil
}
if err := dropSubagent(); err != nil {
return "", err
}
if err := dropBase(); err != nil {
return "", err
}
if err := dropProvider(); err != nil {
return "", err
}
was := unstash(at.key("codex.model"))
var back []edit.KV
if p := unstash(at.key("codex.provider")); p != "" && p != magpieID {
back = append(back, edit.KV{Path: "model_provider", Value: p})
}
if c := unstash(at.key("codex.catalog")); c != "" && !ownCatalog(c) {
back = append(back, edit.KV{Path: "model_catalog_json", Value: c})
}
if e := unstash(at.key("codex.effort")); e != "" {
back = append(back, edit.KV{Path: "model_reasoning_effort", Value: e})
}
if len(back) > 0 {
if err := edit.SetTOMLTop(path, back...); err != nil {
return "", err
}
}
return was, nil
}
set := func(v string) error {
if v == "" {
if err := dropSubagent(); err != nil {
return err
}
if err := giveTables(); err != nil {
return err
}
if err := dropMirrorFailover(); err != nil {
return err
}
// Codex as installed: OpenAI, its own catalog, its default model
if err := dropBase(); err != nil {
return err
}
if err := edit.DelTOMLTop(path, "model", "model_provider", "model_catalog_json"); err != nil {
return err
}
os.Remove(catalogPath)
forget(at.key("codex.model"), at.key("codex.effort"), at.key("codex.provider"), at.key("codex.catalog"), at.key("codex.out"), at.key("codex.joined"))
return nil
}
// magpie API: magpie stays Codex's provider whichever model is the
// default, so one of Codex's own goes as magpie serves it, on the
// ChatGPT account there; taking magpie out for it took magpie's
// models out of Codex's picker with it (#701)
if api() && !isMagpie(v) {
id := codexOwnViaMagpie(v)
if id == "" {
return fmt.Errorf("Codex's sign-in is magpie API, and magpie serves no %s: switch Codex's ChatGPT subscription on in magpie, or set Codex's sign-in to ChatGPT", v)
}
v = id
}
if isMagpie(v) {
if err := dropMirrorFailover(); err != nil {
return err
}
if !routed() {
stash(map[string]string{at.key("codex.model"): get("model"), at.key("codex.effort"): get("model_reasoning_effort"),
at.key("codex.provider"): get("model_provider"), at.key("codex.catalog"): get("model_catalog_json")})
}
// a ChatGPT account out of allowance keeps the Codex app from
// sending at all, a magpie model's request too; as a provider
// of Codex's own, magpie is past that. Wired so for that
// alone, it is marked (codex.out), for Sync to put it back
// beside the sign-in once the allowance is back.
chatgpt := !api() && codexChatGPT(dir)
if chatgpt && !codexUsedUp() {
forget(at.key("codex.out"))
if err := dropProvider(); err != nil {
return err
}
// for the threads started while magpie was the provider
if err := putProvider(); err != nil {
return err
}
// the base URL is the built-in provider's, and a catalog
// file would stand in for the list magpie hands out
if err := edit.DelTOMLTop(path, "model_provider", "model_catalog_json"); err != nil {
return err
}
if err := edit.SetTOMLTop(path,
edit.KV{Path: "openai_base_url", Value: at.codexURL()},
edit.KV{Path: "model", Value: v},
); err != nil {
return err
}
if err := takeTables(); err != nil {
return err
}
return settle()
}
if chatgpt {
stash(map[string]string{at.key("codex.out"): "1"})
} else {
forget(at.key("codex.out"))
}
if err := putProvider(); err != nil {
return err
}
if err := edit.WriteAtomic(catalogPath, codexcat.Catalog(magpieModels("codex"))); err != nil {
return err
}
// a thread started on Codex's built-in provider stays on it when
// opened again, and its model can be switched to one of magpie's
// there (the Codex app's, ChatGPT Desktop's picker): the base
// URL sends that one to magpie too, not to the ChatGPT backend,
// which refuses it (#259). A base URL of the user's own stays.
kv := []edit.KV{
{Path: "model_provider", Value: magpieID},
{Path: "model_catalog_json", Value: catalogRef},
{Path: "model", Value: v},
}
if u := get("openai_base_url"); u == "" || viaBase() {
kv = append(kv, edit.KV{Path: "openai_base_url", Value: at.codexURL()})
}
if err := edit.SetTOMLTop(path, kv...); err != nil {
return err
}
if err := takeTables(); err != nil {
return err
}
return settle()
}
// joined, a model of Codex's own is picked beside magpie's, as in
// Codex's /model: it stays connected
if joined() && !api() && codexChatGPT(dir) {
if err := edit.SetTOMLTop(path, edit.KV{Path: "model", Value: v}); err != nil {
return err
}
return settle()
}
if _, err := unroute(); err != nil {
return err
}
if err := edit.SetTOMLTop(path, edit.KV{Path: "model", Value: v}); err != nil {
return err
}
if err := failover(); err != nil {
return err
}
return settle()
}
return atomic(&Agent{
ID: "codex", Name: "Codex", Icon: "codex-color", Bin: "codex", Dir: dir, Path: path,
UA: []string{"codex"},
// Codex as it was before magpie: its default puts it back as
// installed, OpenAI and its default model, where this brings back
// the provider and model the user had
Unwire: func() error {
now := get("model")
was, err := unroute()
if err != nil {
return err
}
if err := dropSubagent(); err != nil {
return err
}
// the model left alone where magpie had none to take over; a
// model of Codex's own picked while joined stays, its last pick
switch {
case now != "" && !isMagpie(now):
case was != "" && !isMagpie(was):
err = edit.SetTOMLTop(path, edit.KV{Path: "model", Value: was})
case isMagpie(get("model")):
err = edit.DelTOMLTop(path, "model")
}
if err != nil {
return err
}
os.Remove(catalogPath)
forget(at.key("codex.model"), at.key("codex.effort"), at.key("codex.provider"), at.key("codex.catalog"))
return settle()
},
// signed in with ChatGPT, Codex reaches magpie by the base URL with
// its own models still there: connected so, it stays on the model it
// was on, the one last picked in its /model (the owner: 让 Codex 记住
// 上次的选择), and magpie's models join its list
Join: func() (bool, error) {
if api() || !codexChatGPT(dir) || codexUsedUp() || isMagpie(get("model")) {
return false, nil
}
if p := get("model_provider"); p != "" && p != "openai" && !isCCSwitchMirror(p) {
return false, nil
}
if err := dropMirrorFailover(); err != nil {
return false, err
}
if !routed() {
stash(map[string]string{at.key("codex.model"): get("model"), at.key("codex.effort"): get("model_reasoning_effort"),
at.key("codex.provider"): get("model_provider"), at.key("codex.catalog"): get("model_catalog_json")})
}
forget(at.key("codex.out"))
if err := dropProvider(); err != nil {
return false, err
}
// for the threads started on one of magpie's models
if err := putProvider(); err != nil {
return false, err
}
if err := edit.DelTOMLTop(path, "model_provider", "model_catalog_json"); err != nil {
return false, err
}
if err := edit.SetTOMLTop(path, edit.KV{Path: "openai_base_url", Value: at.codexURL()}); err != nil {
return false, err
}
stash(map[string]string{at.key("codex.joined"): "1"})
if err := takeTables(); err != nil {
return false, err
}
if err := codexStaleCache(filepath.Join(dir, "models_cache.json"), provider.CodexListTag()); err != nil {
return false, err
}
return true, settle()
},
Joined: joined,
Routed: routed,
Sync: func() error {
// model_provider = "magpie" left with its table gone (taken by
// another tool, or a hand edit), which keeps Codex from loading
// its config at all, connected to magpie or not (Tystem on
// Discord): the table is written back, as it stays once written
if namesMagpie() && noProvider() {
if err := putProvider(); err != nil {
return err
}
}
if err := failover(); err != nil {
return err
}
// magpie API on one of Codex's own models, as an older magpie
// left it unwired: wired again, as picking it does now (#701)
if m := get("model"); api() && !isMagpie(m) && !asProvider() && codexOwnViaMagpie(m) != "" {
return set(m)
}
// on a magpie model by the base URL alone with no ChatGPT
// sign-in, as an older magpie left a Codex signed in with an
// API key: its picker never had magpie's models (#322)
if m := get("model"); isMagpie(m) && viaBase() && get("model_provider") == "" && !codexChatGPT(dir) {
return set(m)
}
// the ChatGPT account used its allowance up after a magpie
// model was picked beside its sign-in: the Codex app then
// sends nothing, a magpie model's turn included, in a new
// thread or an old one (#540), so magpie becomes Codex's
// provider as set does for an account already out; and once
// the allowance is back (or Codex is on an account with
// room), Codex's own models join magpie's again
if m := get("model"); isMagpie(m) && !api() && codexChatGPT(dir) {
switch {
case viaBase() && !asProvider() && codexUsedUp():
return set(m)
case asProvider() && stashLoad()[at.key("codex.out")] == "1" && !codexUsedUp():
return set(m)
}
}
// a table taken away before (by an older magpie) comes back
// while magpie is wired, for the threads that name it
if wired() && viaBase() && !hasProvider() {
if err := putProvider(); err != nil {
return err
}
}
// CC Switch's tables, left from before magpie took them over
if wired() && routed() {
if err := takeTables(); err != nil {
return err
}
}
switch {
case asProvider() && ownCatalog(get("model_catalog_json")):
// set up by a magpie from before #259: the threads started
// on Codex's built-in provider reach magpie too
if get("openai_base_url") == "" {
if err := edit.SetTOMLTop(path, edit.KV{Path: "openai_base_url", Value: at.codexURL()}); err != nil {
return err
}
}
b := codexcat.Catalog(magpieModels("codex"))
if cur, _ := edit.Read(catalogPath); string(cur) != string(b) {
if err := edit.WriteAtomic(catalogPath, b); err != nil {
return err
}
}
case viaBase():
if err := codexStaleCache(filepath.Join(dir, "models_cache.json"), provider.CodexListTag()); err != nil {
return err
}
default:
return nil
}
// a model that now has levels it had none of before (models.dev
// synced, a vendor's list fetched) keeps an effort it takes
if isMagpie(get("model")) {
return settle()
}
return nil
},
Check: func() string {
if namesMagpie() && noProvider() {
return "Codex's config names magpie as its model_provider but has no [model_providers.magpie], so Codex can't load its config (\"Model provider `magpie` not found\"): magpie writes it back when it next syncs, or connect Codex again"
}
if !wired() {
return ""
}
// a profile's settings win over the top level's, magpie's included
if p := get("profile"); p != "" {
t, err := edit.GetTOMLTable(path, "profiles."+p)
if err != nil {
return err.Error()
}
for _, k := range []string{"model", "model_provider", "openai_base_url", "model_catalog_json"} {
if v, ok := t[k]; ok && v != get(k) {
return "Codex's profile " + p + " sets its own " + k + " (" + v + "), which Codex takes over magpie's"
}
}
}
switch {
case asProvider():
t, err := edit.GetTOMLTable(path, "model_providers."+magpieID)
if err != nil {
return err.Error()
}
if t["base_url"] != at.v1() || t["experimental_bearer_token"] != at.gwKey() || t["wire_api"] != "responses" {
return "Codex's [model_providers.magpie] no longer points at magpie's gateway (" + at.v1() + ")"
}
if c := get("model_catalog_json"); !ownCatalog(c) {
return "Codex's model_catalog_json is no longer magpie's list"
}
if _, err := os.Stat(catalogPath); err != nil {
return "magpie's model list for Codex (" + catalogPath + ") is gone"
}
case viaBase():
if u := get("openai_base_url"); strings.TrimSuffix(u, "/") != at.codexURL() {
return "Codex's openai_base_url is " + u + ", not magpie's gateway at " + at.codexURL()
}
default:
return "Codex's config no longer sends its model through magpie (no openai_base_url or model_provider of magpie's), so Codex asks OpenAI for a model OpenAI doesn't have"
}
for id, u := range ccSwitchTables() {
if u != at.v1() {
return "Codex's [model_providers." + id + "] (CC Switch's) sends to " + u + ", not magpie's gateway: a Codex thread started on it goes there when reopened, a magpie model picked in it too"
}
}
return ""
},
// every prompt typed into Codex goes into history.jsonl
LastUsed: func() time.Time { return lastJSONLTime(filepath.Join(dir, "history.jsonl"), "ts", "text") },
// the Codex app writes no history.jsonl, but it and the TUI log each
// model request they open
Reached: func(since time.Time) (time.Time, string, bool) { return codexReached(dir, since) },
// the app-server behind the Codex app (and every codex TUI) builds
// its model list once, at start-up.
Notice: func() string {
if Running(`(^|/)codex( |$)`) {
return "Codex builds its model list at start-up — restart the Codex app (and open codex sessions) to see this."
}
return ""
},
Fields: []Field{
{
Key: "model", Label: "model",
Get: func() string { return get("model") },
Set: set,
Options: func(map[string]string) []Option { return modelOptions(true) },
},
{
Key: "effort", Label: "effort",
// unset, Codex takes the model's default, as the catalog
// magpie wrote says it — shown as such rather than as none
Get: func() string {
if e := get("model_reasoning_effort"); e != "" {
return e
}
if m := get("model"); isMagpie(m) {
if e := catalog.Efforts(models(), m); len(e) > 0 {
return codexcat.DefaultEffort(e)
}
}
return ""
},
Set: func(v string) error {
if v == "" {
return edit.DelTOMLTop(path, "model_reasoning_effort")
}
return edit.SetTOMLTop(path, edit.KV{Path: "model_reasoning_effort", Value: v})
},
Options: func(cur map[string]string) []Option {
if e := catalog.Efforts(models(), cur["model"]); len(e) > 0 {
return static(e...)
}
return static("low", "medium", "high", "xhigh")
},
},
{
// Codex lists only the first few models in the spawn_agent
// tool it gives the model, its own ahead of magpie's, so a
// subagent is put on one of magpie's here, where it can't be
// by the model unless asked by name
Key: "subagent", Label: "subagents", Quiet: true,
Get: func() string { v, _ := subagent(); return v },
Set: func(v string) error {
if v == "" {
return edit.DelTOMLKey(path, "agents", "default_subagent_model")
}
if isMagpie(v) && !routed() {
return fmt.Errorf("pick a model through magpie for Codex first; its subagents can then have one of their own")
}
if err := edit.SetTOMLKey(path, "agents", "default_subagent_model", v); err != nil {
return err
}
return dropSubEffort()
},
Options: func(map[string]string) []Option { return modelOptions(routed()) },
},
{
// [agents] default_subagent_reasoning_effort: unset, a
// subagent runs at the session's effort, or at its model's
// default when it has a model of its own
Key: "subagent_effort", Label: "subagent effort", Quiet: true,
Get: func() string { v, _ := subEffort(); return v },
Set: func(v string) error {
if v == "" {
return edit.DelTOMLKey(path, "agents", "default_subagent_reasoning_effort")
}
if l := subEfforts(); len(l) > 0 && !contains(l, v) {
return fmt.Errorf("Codex runs %s at %s, not %q: it would refuse to start a subagent", subModel(), strings.Join(l, ", "), v)
}
return edit.SetTOMLKey(path, "agents", "default_subagent_reasoning_effort", v)
},
Options: func(map[string]string) []Option {
if e := subEfforts(); len(e) > 0 {
return static(e...)
}
return static("low", "medium", "high", "xhigh")
},
},
{
// [memories] extract_model and consolidation_model, one
// pick for both; Default removes them, for Codex's own
Key: "memories", Label: "memories", Quiet: true,
Get: func() string { v, _ := memories(); return v },
Set: func(v string) error {
if v == "" {
return dropMemories()
}
if isMagpie(v) && !routed() {
return fmt.Errorf("pick a model through magpie for Codex first; its memories can then be written with one of magpie's")
}
for _, k := range []string{"extract_model", "consolidation_model"} {
if err := edit.SetTOMLKey(path, "memories", k, v); err != nil {
return err
}
}
return nil
},
Options: func(map[string]string) []Option { return modelOptions(routed()) },
},
{
// how Codex takes magpie's models: beside its ChatGPT
// sign-in (openai_base_url), or with magpie as its provider,
// the Codex app in its API state. Kept in the stash, where
// set("") leaves it.
Key: "login", Label: "sign-in", Quiet: true,
Get: func() string { return stashLoad()[at.key("codex.login")] },
Set: func(v string) error {
if v != "" && v != "api" {
return fmt.Errorf("sign-in is api or empty (ChatGPT), not %q", v)
}
stash(map[string]string{at.key("codex.login"): v})
m := get("model")
switch {
case v == "" && codexOwnOf(m) != "":
// back beside the sign-in, Codex's own model goes to
// OpenAI itself again, not by a hop through magpie
return set(codexOwnOf(m))
case isMagpie(m):
return set(m)
case v == "api" && codexOwnViaMagpie(m) != "":
// magpie API on one of Codex's own models: wired as
// for magpie's, so its models are in Codex's picker
return set(m)
}
return nil
},
Options: func(map[string]string) []Option {
return []Option{
{Value: "", Label: "ChatGPT", Note: "magpie's models join Codex's own; Codex stays signed in to ChatGPT"},
{Value: "api", Label: "magpie API", Note: "magpie is Codex's provider; the Codex app is in its API state, with magpie's models only"},
}
},
},
},
}, path, catalogPath)
}
// ccSwitchProvider matches the ids of the provider tables CC Switch writes
// into Codex's config: "custom", and "cc-switch", "cc-switch-2"… from its
// older versions. Its "cc-switch-official" is its own proxy to OpenAI.
var ccSwitchProvider = regexp.MustCompile(`^(custom|cc-switch(-[0-9]+)?)$`)
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
// The reasoning efforts the Codex app knows, in its order (the enum of its
// enabled-reasoning-efforts setting), and those it offers while the
// setting is unset.
var (
codexAppEfforts = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "persistent"}
codexAppEffortsOn = []string{"low", "medium", "high", "xhigh", "ultra", "persistent"}
)
// codexEnableEfforts adds the efforts magpie's models take to the Codex
// app's [desktop] enabled-reasoning-efforts in config.toml: its model
// picker offers a model's efforts only when they are there, so a model's
// "max" or "minimal" was never offered (#310). The user's entries stay,
// only what is missing is added, and nothing is written when nothing is;
// an effort the app doesn't know is left out, as it would drop the whole
// setting for it. A [desktop] spelled some other way (inline, dotted
// keys) is left alone.
func codexEnableEfforts(path string, ms []catalog.Model) error {
raw, err := edit.Read(path)
if err != nil {
return nil
}
var cfg struct {
Desktop map[string]any `toml:"desktop"`
}
if err := toml.Unmarshal(raw, &cfg); err != nil {
return nil
}
on := codexAppEffortsOn
if cfg.Desktop != nil {
if tables, _ := edit.TOMLTables(path); !slices.Contains(tables, "desktop") {
return nil
}
if v, ok := cfg.Desktop["enabled-reasoning-efforts"]; ok {
xs, ok := v.([]any)
if !ok {
return nil
}
on = nil
for _, x := range xs {
s, ok := x.(string)
if !ok {
return nil
}
on = append(on, s)
}
}
}
add := false
out := slices.Clone(on)
for _, e := range codexAppEfforts {
if slices.Contains(on, e) {
continue
}
for _, m := range ms {
if slices.Contains(m.Efforts, e) {
out, add = append(out, e), true
break
}
}
}
if !add {
return nil
}
q := make([]string, len(out))
for i, e := range out {
q[i] = strconv.Quote(e)
}
return edit.SetTOMLKey(path, "desktop", "enabled-reasoning-efforts", edit.Raw("["+strings.Join(q, ", ")+"]"))
}
// ownCodex is Codex's own models, narrowed to the ones ticked on its ChatGPT
// subscription in magpie when any are — the subscription switched off, its
// picks narrow nothing, as it serves no agent anything.
func ownCodex() []catalog.Model {
ms := catalog.Codex()
p, err := provider.Find("codex")
if err != nil || p.Off || len(p.Models) == 0 {
return ms
}
var out []catalog.Model
for _, m := range ms {
if slices.Contains(p.Models, m.ID) {
out = append(out, m)
}
}
if len(out) == 0 {
return ms
}
return out
}
// codexOwnViaMagpie is the catalog id magpie serves one of Codex's own
// models by on a ChatGPT account of the user's ("codex/gpt-5.5"), those
// shown to Codex first; "" when none serves it.
func codexOwnViaMagpie(model string) string {
if model == "" || isMagpie(model) {
return ""
}
shown, _ := provider.CatalogFor("codex")
for _, es := range [][]provider.Entry{shown, provider.Catalog()} {
for _, e := range es {
if a := e.Provider.Account; e.Group == "" && e.Model == model && a != nil && a.Agent == "codex" {
return e.ID
}
}
}
return ""
}
// codexOwnOf is the model of Codex's own a catalog id serves on a ChatGPT
// account (codexOwnViaMagpie's way back); "" for any other.
func codexOwnOf(id string) string {
if !isMagpie(id) || strings.HasPrefix(id, provider.GroupPrefix) {
return ""
}
p, model, ok := provider.Resolve(id)
if !ok || p.Account == nil || p.Account.Agent != "codex" {
return ""
}
return model
}
// codexGatewayURL is where Codex's built-in OpenAI provider is pointed to
// reach magpie.
func codexGatewayURL() string { return gateway.URL() + gateway.CodexPath }
// isCodexGateway reports whether an openai_base_url is magpie's, on
// whichever port it listened on then.
func isCodexGateway(u string) bool { return isCodexGatewayOn(u, "127.0.0.1") }
// isCodexGatewayOn is isCodexGateway for a gateway reached at host.
// codexKeptGateway is the gateway's URL for this machine's Codex: the
// address its config names when that is magpie's gateway on a host other
// than this one's loopback (openai_base_url at the Codex path, or
// [model_providers.magpie]'s base_url with magpie's key, that one first
// while magpie is Codex's provider), else gateway.URL().
func codexKeptGateway(path string) string {
kept := func(u, suffix string) string {
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
rest, ok := strings.CutPrefix(u, "http://")
if !ok || !strings.HasSuffix(rest, suffix) {
return ""
}
hp := strings.TrimSuffix(rest, suffix)
h, _, err := net.SplitHostPort(hp)
if err != nil || h == "" || h == "127.0.0.1" || h == "localhost" || h == "::1" {
return ""
}
return "http://" + hp
}
base, _ := edit.GetTOMLTop(path, "openai_base_url")
var table string
if t, _ := edit.GetTOMLTable(path, "model_providers."+magpieID); ourKey(t["experimental_bearer_token"]) {
table = kept(t["base_url"], "/v1")
}
// the one Codex is on first: magpie's table when it is the provider
if p, _ := edit.GetTOMLTop(path, "model_provider"); p == magpieID && table != "" {
return table
}
return cmp.Or(kept(base, gateway.CodexPath), table, gateway.URL())
}
func isCodexGatewayOn(u, host string) bool {
return strings.HasPrefix(u, "http://"+host+":") && strings.HasSuffix(strings.TrimSuffix(u, "/"), gateway.CodexPath)
}
// codexStaleCache ages Codex's cached model list if it isn't the one magpie
// would hand out now (its ETag carries the list's tag), so the next Codex to
// start asks the gateway again rather than showing the old one until the
// cache ages on its own; the rest of the cache stays as Codex wrote it.
func codexStaleCache(path, tag string) error {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
var c map[string]json.RawMessage
if json.Unmarshal(b, &c) != nil {
return nil
}
var etag string
json.Unmarshal(c["etag"], &etag)
if codexcat.Tagged(etag, tag) {
return nil
}
old := time.Unix(0, 0).UTC().Format(time.RFC3339)
var at string
if json.Unmarshal(c["fetched_at"], &at) == nil && at == old {
return nil
}
c["fetched_at"], _ = json.Marshal(old)
out, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return edit.WriteAtomic(path, out)
}
// codexUsedUp reports whether the ChatGPT account Codex is signed in to
// has used up its allowance. A var so tests can say.
var codexUsedUp = func() bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return provider.CodexUsedUp(ctx)
}
// codexFailover reports whether Codex is signed in to a ChatGPT account
// with more of its accounts on in magpie, behind it.
func codexFailover() bool {
for _, p := range provider.Accounts() {
if p.Account != nil && p.Account.Agent == "codex" {
return len(p.AlsoOn()) > 0
}
}
return false
}
// 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.
func codexChatGPT(dir string) bool {
var a struct {
Tokens struct {
Access string `json:"access_token"`
} `json:"tokens"`
}
b, err := os.ReadFile(filepath.Join(dir, "auth.json"))
if err != nil || json.Unmarshal(b, &a) != nil {
return false
}
return a.Tokens.Access != ""
}
// codexReached reads Codex's newest model request since a time — the
// newest day of it at most — from the log database the Codex app and the
// TUI share: the address it opened, and whether that was refused (nothing
// listening there). Zero when none is logged.
func codexReached(dir string, since time.Time) (at time.Time, to string, refused bool) {
logs, _ := filepath.Glob(filepath.Join(dir, "logs_*.sqlite"))
if len(logs) == 0 {
return
}
slices.Sort(logs)
db, err := provider.OpenReadOnly(logs[len(logs)-1])
if err != nil {
return
}
defer db.Close()
from := max(since.Unix(), time.Now().Add(-24*time.Hour).Unix())
rows, err := db.Query(`SELECT ts, feedback_log_body FROM logs
WHERE ts > ? AND target = 'codex_api::endpoint::responses_websocket'
ORDER BY ts DESC, ts_nanos DESC, id DESC LIMIT 20`, from)
if err != nil {
return
}
defer rows.Close()
// newest first: a refusal comes before the attempt it answers
failedAt := map[string]bool{}
for rows.Next() {
var ts int64
var body string
if rows.Scan(&ts, &body) != nil {
continue
}
if _, u, ok := strings.Cut(body, "failed to connect to websocket: "); ok {
if _, u, ok := strings.Cut(u, "url: "); ok && (strings.Contains(body, "Connection refused") || strings.Contains(body, "os error 10061")) {
failedAt[strings.TrimSpace(u)] = true
}
continue
}
if _, u, ok := strings.Cut(body, "connecting to websocket: "); ok {
u = strings.TrimSpace(u)
return time.Unix(ts, 0), u, failedAt[u]
}
}
return
}