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
1506 lines
56 KiB
Go
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
|
|
}
|