Files
magpie/internal/agent/codex.go
T
yetone 588e51ea4c agent, gui: an agent connected by its config to an address where nothing answers is told, not shown connected (#1013, cjyrainbow: Codex Desktop's WSL environment reads Windows' ~/.codex/config.toml, whose openai_base_url and [model_providers.magpie].base_url the reporter pointed at Windows as WSL sees it, forwarded to 127.0.0.1:3425 by a portproxy of their own; after a reboot the portproxy listened no more and the Agents page still said Codex was connected) — Drift looked only at the config, which was right. Now an agent pointed at an address off loopback (this machine's Codex at an address the user kept, #816, through Agent.reach; every WSL twin under NAT, at Windows as the distro sees it, through Agent.Gateway) has that address tried from here (reach.go: GET /v1/models, any HTTP status an answer, 2 s timeout, in the background, at most every 20 s per address, never waited for), and one that doesn't answer while the gateway answers on loopback is a new drift, "unreachable", with Addr and the advice in Detail: under WSL (or on Windows) magpie with Settings › Share on local network on, or the user's netsh interface portproxy, which may need setting up again after a restart; Windows' firewall allowing the port for the WSL network; on Windows 11, networkingMode=mirrored; elsewhere, the forward to start again or gateway.URL() to point at. An answer not had yet, and a gateway down on loopback too, are no drift. The config is never changed for it, except where the address is one a WSL distro reached Windows at before (distro.Was, the last 4 kept in wsl.json as a re-probe finds Gateway changed) and the distro reaches Windows at another now: Drift's Move names that one, and Reapply moves the config there (Agent.move; Codex's codexMoveGateway rewrites openai_base_url and magpie's own provider's base_url, only where they begin with the old address). An address no distro was seen at is the user's own and is never offered a move. In the GUI the row's line says "{agent} is set up, but nothing answers at the address it reaches magpie by" in red with the advice as its tooltip, and its pill is "How to fix" (an i, not the reapply arrow), which opens the advice in a dialog (too long for the status line; it wraps and can be copied) and sets nothing — or "Use <address>" (the existing "Use {url}" string) when there is a Move, which reapplies; two new t() strings with zh, ja and de. The probe runs from Windows, not from inside WSL, so a firewall keeping WSL out isn't seen; the advice names it. TestKeptAddressNotAnswering (the reporter's case: Codex at 172.28.96.1, unreachable with the address and loopback in the detail; no drift once it answers, none with the gateway down too), TestKeptAddressProbeNeverWaitedFor, TestKeptAddressMovedWithWSL (Move from a Was address, Reapply rewrites both URLs, Check empty and no drift after; a 10.0.0.7 address is told but never moved) and TestWSLAgentAddressNotAnswering (Codex in a NAT distro told, with Share on local network, portproxy and firewall; a mirrored one never probed) fail without the Drift change and pass; TestLoopbackNotProbed, TestReachProbe (httptest: a 401 is an answer, a closed port none) and TestWSLWas pass. TestMain stubs reachProbe so no test dials out. -race -count=10 on them; go test -tags nogui ./internal/... under a temp HOME, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. agent-unreachable.test.cjs (the window at 560px and the tray panel at 440px: the red line and tooltip, How to fix's dialog posting nothing, Use <address> posting the reapply, no sideways scroll) fails on main and passes on Chromium and WebKit in en, zh, ja and de; agent-connect, agent-layout, agent-field-names, aside-native, gui-de, gui-ja and i18n-keys pass on both. Not tried on a real WSL NAT setup (the Windows test box didn't answer ssh). docs/subsystems/agent-wiring.md says it.
2026-10-06 19:17:55 +08:00

1326 lines
48 KiB
Go

package agent
import (
"cmp"
"context"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/codexcat"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
)
// Codex talks the OpenAI Responses API. Signed in to ChatGPT, it is
// routed through magpie with `openai_base_url` alone: Codex keeps its
// built-in OpenAI provider and sign-in, so its own models, its threads
// (listed per provider) and the Codex app's model picker stay as they are,
// and magpie's gateway passes its own models through to OpenAI while it
// answers the rest — the model list included, so magpie's models join
// OpenAI's in /model. Not signed in, the built-in provider can't run, and
// magpie is a provider of its own: a [model_providers.magpie] table,
// `model_provider = "magpie"`, and a model catalog file for /model. So it
// is too for Codex signed in with an API key, often one a relay issued:
// Codex then never asks for the model list (its models manager skips the
// fetch for an API key), so its picker was the models it was built with
// and the one set, magpie's others missing, and the built-in ones went to
// OpenAI with that key (#322). So it is too for a ChatGPT account 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).
func codexIn(at place) *Agent {
dir := filepath.Join(at.home, ".codex")
path := filepath.Join(dir, "config.toml")
catalogPath := filepath.Join(dir, "magpie-models.json")
get := func(k string) string { v, _ := edit.GetTOMLTop(path, k); return v }
// this machine's Codex reaches the gateway at the address its config
// gives, when that is magpie's on another host: the Codex app on
// Windows with its agent in WSL reads Windows' config.toml from WSL,
// where 127.0.0.1 is the distro's own, so the user points it at
// Windows as WSL sees it, or a forward of theirs (#816); magpie keeps
// that address, checks against it and writes it again
if at.spell == nil && at.base == nil {
at.base = func() string { return codexKeptGateway(path) }
}
// the catalog as the config names it: relative to config.toml on
// Windows, where a C:\ path is no file to the same Codex reading it
// from WSL (#816); Codex reads a relative one from its home
catalogRef := at.native(catalogPath)
if at.spell == nil && runtime.GOOS == "windows" {
catalogRef = filepath.Base(catalogPath)
}
// ownCatalog: the config names magpie's catalog, by any spelling
ownCatalog := func(c string) bool {
if c == "" {
return false
}
if c == catalogRef || c == at.native(catalogPath) {
return true
}
return at.spell == nil && !filepath.IsAbs(c) && filepath.Clean(filepath.Join(dir, c)) == filepath.Clean(catalogPath)
}
asProvider := func() bool { return get("model_provider") == magpieID }
viaBase := func() bool { return isCodexGatewayOn(get("openai_base_url"), at.host()) }
routed := func() bool { return asProvider() || viaBase() }
// joined: connected by Join, on a model of Codex's own (its last pick)
// with magpie's beside it, by the base URL
joined := func() bool { return viaBase() && stashLoad()[at.key("codex.joined")] == "1" }
// 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; a fresh model gets its default.
// Routed, the Codex app offers magpie's models' efforts too (#310).
settle := func() error {
if routed() {
if err := codexEnableEfforts(path, magpieModels("codex")); err != nil {
return err
}
}
ms := models()
model, effort := get("model"), get("model_reasoning_effort")
if e := catalog.Efforts(ms, model); len(e) > 0 && !contains(e, effort) {
if err := edit.SetTOMLTop(path, edit.KV{Path: "model_reasoning_effort", Value: codexcat.DefaultEffort(e)}); err != nil {
return err
}
}
return dropSubEffort()
}
// magpie as a provider Codex can name; its threads started on magpie do
putProvider := func() error {
return edit.SetTOMLTable(path, "model_providers."+magpieID,
edit.KV{Path: "name", Value: "magpie"},
edit.KV{Path: "base_url", Value: at.v1()},
edit.KV{Path: "wire_api", Value: "responses"},
edit.KV{Path: "experimental_bearer_token", Value: at.gwKey()},
// Codex offers its built-in image tool (image_gen.imagegen) and
// the other OpenAI-provider tools only to a provider it reads as
// the OpenAI actor: one that does not require OpenAI auth but
// carries x-openai-actor-authorization. Without this header the
// model is never given the tool, so a Codex on magpie can't draw
// (the request that names model/draws never happens at all).
// uses_openai_actor_authorization() is exactly this check.
edit.KV{Path: "http_headers", Value: edit.Raw(`{ "x-openai-actor-authorization" = "magpie" }`)},
)
}
hasProvider := func() bool {
t, err := edit.GetTOMLTable(path, "model_providers."+magpieID)
return err == nil && t != nil
}
// noProvider: the config was read and has no such table; one magpie
// can't read is told by Check as it is, and written to by nothing
noProvider := func() bool {
t, err := edit.GetTOMLTable(path, "model_providers."+magpieID)
return err == nil && t == nil
}
// namesMagpie: the config has Codex on magpie as its provider, at its
// top or in a profile. Codex then reads [model_providers.magpie] as it
// loads the config, before any thread: without the table it loads no
// config at all ("Model provider `magpie` not found"), the app and the
// CLI both, whatever the model.
namesMagpie := func() bool {
if asProvider() {
return true
}
names, _ := edit.TOMLTables(path)
for _, n := range names {
if strings.HasPrefix(n, "profiles.") {
if t, _ := edit.GetTOMLTable(path, n); t["model_provider"] == magpieID {
return true
}
}
}
return false
}
// dropProvider takes magpie out as the provider Codex is on; the table
// stays for the threads started on it
dropProvider := func() error {
if !asProvider() {
return nil
}
if err := edit.DelTOMLTop(path, "model_provider", "model_catalog_json"); err != nil {
return err
}
os.Remove(catalogPath)
return nil
}
// isCCSwitchMirror reports whether a model_providers table is CC Switch's
// official OpenAI mirror: name = "OpenAI", requires_openai_auth = true, and no
// base_url (or magpie's codexURL while failing over).
isCCSwitchMirror := func(p string) bool {
if p == "" || p == "openai" || p == magpieID {
return false
}
t, err := edit.GetTOMLTable(path, "model_providers."+p)
if err != nil || t["name"] != "OpenAI" || t["requires_openai_auth"] != "true" {
return false
}
u := t["base_url"]
return u == "" || (u == at.codexURL() && stashLoad()[at.key("codex.mirror_failover")] == p)
}
// CC Switch's provider tables in Codex's config, each one's base URL
// by its id. A thread keeps the provider it was
// started on, and CC Switch moves every third-party thread onto its
// "custom" one, so one reopened there still went to the relay, a
// magpie model picked in it too, which the relay didn't know. While a
// magpie model is on, those tables go through magpie as well; a table
// a profile names is the user's to pick, and stays as it is.
ccSwitchTables := func() map[string]string {
names, _ := edit.TOMLTables(path)
named := map[string]bool{}
for _, n := range names {
if strings.HasPrefix(n, "profiles.") {
if t, _ := edit.GetTOMLTable(path, n); t["model_provider"] != "" {
named[t["model_provider"]] = true
}
}
}
out := map[string]string{}
for _, n := range names {
id, ok := strings.CutPrefix(n, "model_providers.")
if !ok || !ccSwitchProvider.MatchString(id) || named[id] || isCCSwitchMirror(id) {
continue
}
if t, _ := edit.GetTOMLTable(path, n); t["base_url"] != "" {
out[id] = t["base_url"]
}
}
return out
}
// takeTables points CC Switch's tables at magpie, each one's own base
// URL kept in the stash for giveTables (a table already on magpie's
// keeps the one kept for it)
takeTables := func() error {
was := map[string]string{}
json.Unmarshal([]byte(stashLoad()[at.key("codex.tables")]), &was)
for id, u := range ccSwitchTables() {
if u == at.v1() {
continue
}
if err := edit.SetTOMLKey(path, "model_providers."+id, "base_url", at.v1()); err != nil {
return err
}
was[id] = u
}
if len(was) == 0 {
return nil
}
b, _ := json.Marshal(was)
stash(map[string]string{at.key("codex.tables"): string(b)})
return nil
}
// giveTables puts the base URLs back, on the tables still pointed at
// magpie; one changed since is left as it is now
giveTables := func() error {
var was map[string]string
json.Unmarshal([]byte(stashLoad()[at.key("codex.tables")]), &was)
now := ccSwitchTables()
for id, u := range was {
if now[id] != at.v1() {
continue
}
if err := edit.SetTOMLKey(path, "model_providers."+id, "base_url", u); err != nil {
return err
}
}
forget(at.key("codex.tables"))
return nil
}
// api: the user wants magpie as Codex's provider even while Codex is
// signed in to ChatGPT
api := func() bool { return stashLoad()[at.key("codex.login")] == "api" }
// 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
}
modelOptions := func(withMagpie bool) []Option {
var own []Option
// each of Codex's own says which way it goes, as Claude Code's do:
// to its provider directly, or through magpie while routed by the
// base URL or with magpie its provider (EZN7L2C3, #834: Codex 下拉
// 选项中没有 via magpie)
p := get("model_provider")
to, through := "OpenAI", routed()
if p != "" && p != magpieID && p != "openai" {
// a provider of the user's own, or CC Switch's, pointed at
// magpie or not
t, _ := edit.GetTOMLTable(path, "model_providers."+p)
to, through = p, t["base_url"] == at.codexURL() || t["base_url"] == at.v1()
}
say := func(own []Option) []Option {
for i := range own {
if through {
own[i].Via = true
} else {
own[i].Direct = to
}
}
return own
}
if p != "" && p != magpieID && !isCCSwitchMirror(p) {
own = say(group(p, options(catalog.Codex(), "")))
} else {
own = say(group("OpenAI", options(ownCodex(), "")))
// on magpie API, Codex's own models are reached through magpie,
// on its ChatGPT account there: picked so, set so (#701)
if api() {
for i, o := range own {
if id := codexOwnViaMagpie(o.Value); id != "" {
own[i].Label, own[i].Value, own[i].Ref = o.Value, id, id
}
}
}
}
if !withMagpie {
return own
}
return append(own, viaMagpieFor("codex", "")...)
}
// unroute takes magpie out as the way Codex reaches its models and puts
// back what the stash kept from before magpie was wired in (its
// provider, catalog and effort); it answers the model Codex was on
// then, for Unwire to go back to
unroute := func() (string, error) {
forget(at.key("codex.out"), at.key("codex.joined"), 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
}
os.Remove(catalogPath)
forget(at.key("codex.model"), at.key("codex.effort"), at.key("codex.provider"), at.key("codex.catalog"), at.key("codex.out"), at.key("codex.joined"), 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, codexcat.Catalog(magpieModels("codex"))); err != nil {
return err
}
// a thread started on Codex's built-in provider stays on it when
// opened again, and its model can be switched to one of magpie's
// there (the Codex app's, ChatGPT Desktop's picker): the base
// URL sends that one to magpie too, not to the ChatGPT backend,
// which refuses it (#259). A base URL of the user's own stays.
kv := []edit.KV{
{Path: "model_provider", Value: magpieID},
{Path: "model_catalog_json", Value: catalogRef},
{Path: "model", Value: v},
}
if u := get("openai_base_url"); u == "" || viaBase() {
kv = append(kv, edit.KV{Path: "openai_base_url", Value: at.codexURL()})
}
if err := edit.SetTOMLTop(path, kv...); err != nil {
return err
}
if err := takeTables(); err != nil {
return err
}
return settle()
}
// joined, a model of Codex's own is picked beside magpie's, as in
// Codex's /model: it stays connected
if joined() && !api() && codexChatGPT(dir) {
if err := edit.SetTOMLTop(path, edit.KV{Path: "model", Value: v}); err != nil {
return err
}
return settle()
}
if _, err := unroute(); err != nil {
return err
}
if err := edit.SetTOMLTop(path, edit.KV{Path: "model", Value: v}); err != nil {
return err
}
if err := failover(); err != nil {
return err
}
return settle()
}
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
}
os.Remove(catalogPath)
forget(at.key("codex.model"), at.key("codex.effort"), at.key("codex.provider"), at.key("codex.catalog"))
return settle()
},
// signed in with ChatGPT, Codex reaches magpie by the base URL with
// its own models still there: connected so, it stays on the model it
// was on, the one last picked in its /model (the owner: 让 Codex 记住
// 上次的选择), and magpie's models join its list
Join: func() (bool, error) {
if api() || !codexChatGPT(dir) || 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,
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 := codexcat.Catalog(magpieModels("codex"))
if cur, _ := edit.Read(catalogPath); string(cur) != string(b) {
if err := edit.WriteAtomic(catalogPath, b); err != nil {
return err
}
}
case viaBase():
if err := codexStaleCache(filepath.Join(dir, "models_cache.json"), provider.CodexListTag()); err != nil {
return err
}
default:
return nil
}
// a model that now has levels it had none of before (models.dev
// synced, a vendor's list fetched) keeps an effort it takes
if isMagpie(get("model")) {
return settle()
}
return nil
},
Check: func() string {
if namesMagpie() && noProvider() {
return "Codex's config names magpie as its model_provider but has no [model_providers.magpie], so Codex can't load its config (\"Model provider `magpie` not found\"): magpie writes it back when it next syncs, or connect Codex again"
}
if !wired() {
return ""
}
// a profile's settings win over the top level's, magpie's included
if p := get("profile"); p != "" {
t, err := edit.GetTOMLTable(path, "profiles."+p)
if err != nil {
return err.Error()
}
for _, k := range []string{"model", "model_provider", "openai_base_url", "model_catalog_json"} {
if v, ok := t[k]; ok && v != get(k) {
return "Codex's profile " + p + " sets its own " + k + " (" + v + "), which Codex takes over magpie's"
}
}
}
switch {
case asProvider():
t, err := edit.GetTOMLTable(path, "model_providers."+magpieID)
if err != nil {
return err.Error()
}
if t["base_url"] != at.v1() || t["experimental_bearer_token"] != at.gwKey() || t["wire_api"] != "responses" {
return "Codex's [model_providers.magpie] no longer points at magpie's gateway (" + at.v1() + ")"
}
if c := get("model_catalog_json"); !ownCatalog(c) {
return "Codex's model_catalog_json is no longer magpie's list"
}
if _, err := os.Stat(catalogPath); err != nil {
return "magpie's model list for Codex (" + catalogPath + ") is gone"
}
case viaBase():
if u := get("openai_base_url"); strings.TrimSuffix(u, "/") != at.codexURL() {
return "Codex's openai_base_url is " + u + ", not magpie's gateway at " + at.codexURL()
}
default:
return "Codex's config no longer sends its model through magpie (no openai_base_url or model_provider of magpie's), so Codex asks OpenAI for a model OpenAI doesn't have"
}
for id, u := range ccSwitchTables() {
if u != at.v1() {
return "Codex's [model_providers." + id + "] (CC Switch's) sends to " + u + ", not magpie's gateway: a Codex thread started on it goes there when reopened, a magpie model picked in it too"
}
}
return ""
},
// every prompt typed into Codex goes into history.jsonl
LastUsed: func() time.Time { return lastJSONLTime(filepath.Join(dir, "history.jsonl"), "ts", "text") },
// the Codex app writes no history.jsonl, but it and the TUI log each
// model request they open
Reached: func(since time.Time) (time.Time, string, bool) { return codexReached(dir, since) },
// the app-server behind the Codex app (and every codex TUI) builds
// its model list once, at start-up.
Notice: func() string {
if Running(`(^|/)codex( |$)`) {
return "Codex builds its model list at start-up — restart the Codex app (and open codex sessions) to see this."
}
return ""
},
Fields: []Field{
{
Key: "model", Label: "model",
Get: func() string { return get("model") },
Set: set,
Options: func(map[string]string) []Option { return modelOptions(true) },
},
{
Key: "effort", Label: "effort",
// unset, Codex takes the model's default, as the catalog
// magpie wrote says it — shown as such rather than as none
Get: func() string {
if e := get("model_reasoning_effort"); e != "" {
return e
}
if m := get("model"); isMagpie(m) {
if e := catalog.Efforts(models(), m); len(e) > 0 {
return codexcat.DefaultEffort(e)
}
}
return ""
},
Set: func(v string) error {
if v == "" {
return edit.DelTOMLTop(path, "model_reasoning_effort")
}
return edit.SetTOMLTop(path, edit.KV{Path: "model_reasoning_effort", Value: v})
},
Options: func(cur map[string]string) []Option {
if e := catalog.Efforts(models(), cur["model"]); len(e) > 0 {
return static(e...)
}
return static("low", "medium", "high", "xhigh")
},
},
{
// Codex lists only the first few models in the spawn_agent
// tool it gives the model, its own ahead of magpie's, so a
// subagent is put on one of magpie's here, where it can't be
// by the model unless asked by name
Key: "subagent", Label: "subagents", Quiet: true,
Get: func() string { v, _ := subagent(); return v },
Set: func(v string) error {
if v == "" {
return edit.DelTOMLKey(path, "agents", "default_subagent_model")
}
if isMagpie(v) && !routed() {
return fmt.Errorf("pick a model through magpie for Codex first; its subagents can then have one of their own")
}
if err := edit.SetTOMLKey(path, "agents", "default_subagent_model", v); err != nil {
return err
}
return dropSubEffort()
},
Options: func(map[string]string) []Option { return modelOptions(routed()) },
},
{
// [agents] default_subagent_reasoning_effort: unset, a
// subagent runs at the session's effort, or at its model's
// default when it has a model of its own
Key: "subagent_effort", Label: "subagent effort", Quiet: true,
Get: func() string { v, _ := subEffort(); return v },
Set: func(v string) error {
if v == "" {
return edit.DelTOMLKey(path, "agents", "default_subagent_reasoning_effort")
}
if l := subEfforts(); len(l) > 0 && !contains(l, v) {
return fmt.Errorf("Codex runs %s at %s, not %q: it would refuse to start a subagent", subModel(), strings.Join(l, ", "), v)
}
return edit.SetTOMLKey(path, "agents", "default_subagent_reasoning_effort", v)
},
Options: func(map[string]string) []Option {
if e := subEfforts(); len(e) > 0 {
return static(e...)
}
return static("low", "medium", "high", "xhigh")
},
},
{
// [memories] extract_model and consolidation_model, one
// pick for both; Default removes them, for Codex's own
Key: "memories", Label: "memories", Quiet: true,
Get: func() string { v, _ := memories(); return v },
Set: func(v string) error {
if v == "" {
return dropMemories()
}
if isMagpie(v) && !routed() {
return fmt.Errorf("pick a model through magpie for Codex first; its memories can then be written with one of magpie's")
}
for _, k := range []string{"extract_model", "consolidation_model"} {
if err := edit.SetTOMLKey(path, "memories", k, v); err != nil {
return err
}
}
return nil
},
Options: func(map[string]string) []Option { return modelOptions(routed()) },
},
{
// how Codex takes magpie's models: beside its ChatGPT
// sign-in (openai_base_url), 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]+)?)$`)
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
}
// 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
}