Files
yetone 793af76242 gui: Codex's subagents setting, unset, says the lead picks the model and only falls back to the main model, not just "same as model" (willz on Discord)
willz set Codex's "subagents" to 同主模型 (same as model), and a subagent
still ran on another model. Codex 0.162 lets the spawn_agent call's own
`model` argument win over `[agents] default_subagent_model`
(core/src/agent/child_config.rs:
requested_model.or(agent_default_subagent_model)). The default only
applies to a spawn that names no model, and the spawn_agent tool lists
up to 5 models the lead may name. So "same as model" was false whenever
the lead named a model.

This was reproduced with the real codex-cli 0.162.0 in a sandbox
HOME/CODEX_HOME, against a fake Responses server on port 3683 that logs
each request's model and x-openai-subagent header. The lead ran on
gpt-6.1-sol in every case:
  a. lead names astra, default unset  -> subagent on gpt-6-astra
  b. lead names none,  default unset  -> subagent on gpt-6.1-sol
  c. lead names astra, default sol    -> subagent on gpt-6-astra
  d. lead names none,  default astra  -> subagent on gpt-6-astra

magpie already holds every subagent to one model with the "subagent
model" setting (the gateway's codexSubagentPick, 18958661). So this
relabels Codex's unset "subagents" field rather than changing it:
- The field reads "the lead's pick, else same as model".
- Its picker opens on "Lead's pick", noted "{model} unless the lead
  names another".
- Its title points to the subagent model as the way to make every
  subagent use one model.
The local row and WSL rows both get this. zh, zh-TW, ja and de are
translated. Claude Code's and omp's "subagents" keep "same as model".
Claude Code's case was not checked here: its Explore runs on the haiku
tier, so its label may not be exact either.

The new codex-subagents-lead.test.cjs fails on the old app.js ("subagents:
same as model"). It passes in Chromium and WebKit, in en, zh, zh-TW, ja
and de, at 1100px and 560px.

Run:
- gofmt -l internal/agent
- go vet -tags nogui ./internal/agent, on darwin, linux and windows
- go test -tags nogui ./... with a temp HOME and pinned caches: green
- GUI, in Chromium and WebKit: codex-subagents-lead, i18n-keys, gui-de,
  gui-ja, gui-zh-tw, click-scroll, codex-subagent-model, omp-roles,
  subagent-effort, profile-details and desktop-tiers: 54/54 in each
2026-10-10 11:58:51 +08:00

1506 lines
56 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"
"github.com/yetone/magpie/internal/sessions"
)
// 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 the
// Codex app holds, out of its allowance with no credits to go on with
// (provider.CodexUsedUp), which it 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, its
// durable threads and remote control gone with it. A window at 100% alone
// is not that: credits carry the account on, the app keeps sending, and
// Codex stays signed in beside magpie's models. 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).
// codexRunning is whether a Codex runs here, whose list a change reaches
// only once it restarts; a var so tests can say.
var codexRunning = func() bool { return Running(`(^|/)codex( |$)`) }
// codexEfforts are the effort control's choices for a model among ms:
// Default first, with the level Codex takes for it when none is set, as
// the catalog entry it reads says it, then the model's levels.
func codexEfforts(ms []catalog.Model, model string) []Option {
e := catalog.Efforts(ms, model)
if len(e) == 0 {
return static("low", "medium", "high", "xhigh")
}
d := Option{Value: "", Note: "Codex's default for the model", Takes: codexcat.TakesEffort(ms, model)}
if d.Takes != "" {
d.Note += ": " + d.Takes
}
return append([]Option{d}, static(e...)...)
}
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)
}
// ownList is magpie's model list for Codex as written to catalogPath:
// what the user added by hand to the list there (a key magpie doesn't
// write, supports_reasoning_summaries on a model magpie gave none) is
// kept, not written over by the next model switch or sync (#1450)
ownList := func() []byte {
cur, _ := edit.Read(catalogPath)
return codexcat.Keep(cur, codexcat.Catalog(magpieModels("codex")))
}
// dropList takes the list away as magpie steps out as Codex's
// provider; one holding what the user added stays, which the config
// no longer names, for ownList to keep when magpie is back
dropList := func() {
cur, err := edit.Read(catalogPath)
if err != nil {
return
}
b := codexcat.Catalog(magpieModels("codex"))
if string(codexcat.Keep(cur, b)) == string(b) {
os.Remove(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" }
// beside: set on one of magpie's models beside the ChatGPT sign-in, by
// the base URL, Codex's own models in its list too: one of them written
// in by Codex (its /model, or a Codex app still running on its old
// pick) leaves it connected, its config still on magpie's gateway
// (#940: it read as not connected), the change told as drift. One
// picked on the Agents page still takes magpie out (set).
beside := func() bool { return viaBase() && stashLoad()[at.key("codex.beside")] == "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() || beside() || 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: one set that the model doesn't
// take becomes its default. One left unset stays unset — Codex then
// takes the model's own default, which is what Default on the effort
// control asked for (lgtm: Default turned into medium on its own).
// 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 && effort != "" && !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.
// An Inline table: a config that holds the headers as their own
// [model_providers.magpie.http_headers] table, or as dotted keys,
// gets the header set there, the user's others kept; written
// inline beside that table it defined http_headers twice and
// every model switch failed (wztlink1013 on Discord).
edit.KV{Path: "http_headers", Value: edit.Inline{{Path: "x-openai-actor-authorization", Value: "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
}
dropList()
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
}
// putThreadTables writes a table for each provider a Codex thread was
// started on that config.toml has none of, pointed at magpie. The
// Codex app resumes a thread on its own provider, and without that
// table it loads no config for it: "Model provider `custom` not found",
// a thread started through CC Switch reopened after CC Switch rewrote
// config.toml without its table (#1372). Codex's built-in providers
// need none, and magpie's own is putProvider's. A table written so
// stays, as magpie's own does, for the threads that name it.
putThreadTables := func() error {
// the providers config.toml has, however spelled (a table, inline,
// dotted keys); one that doesn't parse is left alone
raw, err := edit.Read(path)
if err != nil {
return nil
}
var cfg struct {
Providers map[string]any `toml:"model_providers"`
}
if toml.Unmarshal(raw, &cfg) != nil {
return nil
}
for _, id := range sessions.CodexThreadProviders(dir) {
if _, ok := cfg.Providers[id]; ok || id == magpieID || codexBuiltinProvider[id] {
continue
}
if err := edit.SetTOMLTable(path, "model_providers."+id,
edit.KV{Path: "name", Value: id},
edit.KV{Path: "base_url", Value: at.v1()},
edit.KV{Path: "wire_api", Value: "responses"},
edit.KV{Path: "experimental_bearer_token", Value: at.gwKey()},
); err != nil {
return err
}
}
return nil
}
// 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), and writes the tables threads name that
// are gone (putThreadTables)
takeTables := func() error {
if err := putThreadTables(); err != nil {
return err
}
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" }
// out reports whether the Codex app holds the ChatGPT account Codex is
// signed in to (codexUsedUp), for magpie to become Codex's provider
// while it does (#540): never with the sign-in kept on ChatGPT
// (chatgpt), where the user would rather the Codex app wait for the
// account than leave its ChatGPT state
out := func() bool { return stashLoad()[at.key("codex.login")] != "chatgpt" && codexUsedUp() }
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
}
// 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:
// 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(), "")))
// with magpie Codex's provider (magpie API, or a magpie model
// picked with no ChatGPT sign-in beside it), Codex's own models
// are reached through magpie on a ChatGPT account there: picked
// so, set so (#701). One magpie doesn't serve is picked off
// magpie, straight to OpenAI, and says so: it said via magpie,
// and picking it took Codex off magpie at once (#1269)
if api() || asProvider() {
for i, o := range own {
if id := codexOwnViaMagpie(o.Value); id != "" {
own[i].Label, own[i].Value, own[i].Ref = o.Value, id, id
} else if !api() {
own[i].Via, own[i].Direct = false, "OpenAI"
}
}
}
}
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"), at.key("codex.beside"))
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
}
dropList()
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"), at.key("codex.beside"))
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 the Codex app holds (out of allowance and
// of credits) keeps it 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 account has
// room again.
chatgpt := !api() && codexChatGPT(dir)
if chatgpt && !out() {
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
}
stash(map[string]string{at.key("codex.beside"): "1"})
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"))
}
// magpie is its provider now, not beside the sign-in
forget(at.key("codex.beside"))
if err := putProvider(); err != nil {
return err
}
if err := edit.WriteAtomic(catalogPath, ownList()); 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()
}
a := 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
}
dropList()
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) || out() || 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,
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
// 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 ran out (of its allowance and its
// credits) 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 account has room again (or Codex is on
// one with room), Codex's own models join magpie's again
if m := get("model"); isMagpie(m) && !api() && codexChatGPT(dir) {
switch {
case viaBase() && !asProvider() && out():
return set(m)
case asProvider() && stashLoad()[at.key("codex.out")] == "1" && !out():
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 := ownList()
if cur, _ := edit.Read(catalogPath); string(cur) != string(b) {
if err := edit.WriteAtomic(catalogPath, b); err != nil {
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
}
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. Codex 0.162's TUI attaches to a
// background app-server it starts once and leaves running (its
// daemon_auto_start, on by default on every OS), so closing every
// codex session keeps the old list: a new one shows Codex's own
// models until that daemon is restarted, while the desktop app's
// own app-server, restarted with the app, has magpie's (TJHHHH,
// luci). With no codex session on it magpie restarts it itself
// (codexDaemonNotice); with one, the user says when.
Notice: func() string {
if at.spell == nil {
if n, ok := codexDaemonNotice(path); ok {
return n
}
}
if codexRunning() {
return codexRestartAll
}
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 reads as Default, which it is; the level Codex then
// takes is on the Default option (Takes), not the value, so
// picking Default doesn't read back as medium (lgtm)
Get: func() string { return get("model_reasoning_effort") },
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})
},
// Default first, with the level Codex takes for the model
// when none is set, as the catalog entry it reads says it
Options: func(cur map[string]string) []Option { return codexEfforts(models(), cur["model"]) },
},
{
// 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. It is only the model a
// spawn that names none starts on: unset, the lead's own,
// but a lead naming another in spawn_agent gets that one
// (core/src/agent/child_config.rs), so the GUI says the
// lead picks rather than "same as model" (willz on Discord)
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()) },
},
{
// the model magpie puts every subagent on, whatever the
// lead asked for in spawn_agent (willz on Discord): kept in
// magpie's settings, not Codex's config, since the gateway
// rewrites the subagent's request. Only a ChatGPT account's
// models are offered: a subagent's task is sealed for them
Key: "subagent_model", Label: "subagent model", Quiet: true,
Get: provider.CodexSubagentModel,
Set: provider.SetCodexSubagentModel,
Options: func(map[string]string) []Option {
var out []Option
for _, e := range provider.CodexSubagentModels() {
note := e.Provider.Name + " · via magpie"
if a := e.Provider.Account; a != nil && a.User != "" {
note = a.User + " · via magpie"
}
out = append(out, Option{Value: e.ID, Label: e.Name, Note: note, Icon: e.Provider.Icon,
Group: e.Provider.Name, Ref: e.ID, Context: e.Context})
}
return out
},
},
{
// [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), magpie its provider only while
// the Codex app holds the account (""); beside it always,
// held or not (chatgpt); 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" && v != "chatgpt" {
return fmt.Errorf("sign-in is api, chatgpt or empty (ChatGPT), not %q", v)
}
stash(map[string]string{at.key("codex.login"): v})
m := get("model")
switch {
case v != "api" && 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, and while the Codex app blocks its account (out of allowance, no credits left) magpie is Codex's provider, so the app still sends"},
{Value: "chatgpt", Label: "Always ChatGPT", Note: "as ChatGPT, and kept so while the Codex app blocks its account: magpie never becomes Codex's provider. The Codex app may then send nothing till the account has room; Codex CLI goes on through magpie"},
{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)
// this machine's Codex: the address its config keeps is tried (Drift),
// and moved to WSL's new one when that changed (#1013)
if at.spell == nil {
a.reach = at.base
a.move = func(from, to string) error { return codexMoveGateway(path, from, to) }
}
return a
}
// 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]+)?)$`)
// codexBuiltinProvider is the providers Codex has built in, which a thread
// can name with no table in config.toml (codex-rs model-provider-info:
// built_in_model_providers); "ollama-chat" is one it no longer has, and
// tells the user how to fix itself.
var codexBuiltinProvider = map[string]bool{
"openai": true, "amazon-bedrock": true, "amazon-bedrock-runtime": true,
"ollama": true, "lmstudio": true, "ollama-chat": true,
}
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 }
// 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())
}
// codexMoveGateway points Codex's config at to where it names magpie's
// gateway at from: openai_base_url and [model_providers.magpie]'s
// base_url, each only when it is at from; anything else is the user's.
func codexMoveGateway(path, from, to string) error {
from, to = strings.TrimSuffix(from, "/"), strings.TrimSuffix(to, "/")
if u, _ := edit.GetTOMLTop(path, "openai_base_url"); strings.HasPrefix(u, from+"/") {
if err := edit.SetTOMLTop(path, edit.KV{Path: "openai_base_url", Value: to + strings.TrimPrefix(u, from)}); err != nil {
return err
}
}
if t, _ := edit.GetTOMLTable(path, "model_providers."+magpieID); ourKey(t["experimental_bearer_token"]) && strings.HasPrefix(t["base_url"], from+"/") {
return edit.SetTOMLKey(path, "model_providers."+magpieID, "base_url", to+strings.TrimPrefix(t["base_url"], from))
}
return nil
}
// isCodexGatewayOn reports whether an openai_base_url is magpie's gateway
// reached at host, on whichever port it listened on then.
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
// is held by the Codex app, out of its allowance with no credits to go on
// with (provider.CodexUsedUp). 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
}
// 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.
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
}