Files
yetone c65d9fb473 codex: magpie-models.json keeps the keys the user added by hand to a model, and says supports_reasoning_summaries itself, so Codex before 0.145 sends the effort and shows thinking (heng91, #1450)
What was wrong: every write of ~/.codex/magpie-models.json rendered the
catalog from magpie's models alone, so a key the user had put into an
entry was gone after the next model switch, catalog sync (Refresh, a
provider added, a gateway start) or a switch to Codex's own default,
which also deleted the file outright. The key heng91 added,
supports_reasoning_summaries, is one Codex before 0.145 needs: its
client.rs build_reasoning sends no `reasoning` (no effort, no summary,
so no thinking shown) for a model whose entry doesn't say true, until
openai/codex#32206 (first in 0.145). 0.144 also refuses to load a
catalog an entry of which lacks the key at all.

What changed:
- codexcat: every entry says supports_reasoning_summaries, true for a
  model with effort levels, false otherwise. Later Codex ignore it.
- codexcat.Keep: before a write, the keys of an entry on disk magpie
  doesn't write are carried over to the entry of the same slug, and a
  true of supports_reasoning_summaries stays over magpie's false. A key
  magpie writes keeps magpie's value; a model magpie no longer serves
  drops out; an unreadable file keeps nothing.
- agent/codex.go: the write when magpie becomes the provider and the
  catalog sync go through Keep; the three places that removed the file
  remove it only when nothing of the user's is in it.

Run:
- New tests TestCodexCatalogKeepsWhatTheUserAdded,
  TestCodexCatalogRemovedWhenNothingAdded (agent, from the file magpie
  writes, edited by hand) and TestCodexCatalogReasoningSummaries,
  TestKeepUserKeys (codexcat). Each fails on origin/main's code.
- Real Codex in a sandbox CODEX_HOME against a capture server:
  0.144.0 fails to load the old catalog ("missing field
  supports_reasoning_summaries"), loads the new one and sends
  reasoning {effort: high, summary: auto}; with the user's keys kept
  it sends summary "detailed". 0.162.0 loads both and sends the same.
- gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet of
  ./internal/agent ./internal/codexcat; linux and windows builds;
  go test -tags nogui ./... under a temp HOME, all ok.
2026-10-10 09:07:44 +08:00

626 lines
21 KiB
Go

// Package codexcat renders models the way Codex describes them: the entries
// of its models.json, for `model_catalog_json` and for the model list magpie
// hands Codex in place of the ChatGPT backend's.
package codexcat
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"reflect"
"regexp"
"slices"
"strings"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/settings"
)
// Prompt is Codex's generic system prompt (Apache-2.0, openai/codex,
// core/gpt-5.2-codex_prompt.md). Third-party models need one because the
// bundled catalog only carries prompts for OpenAI models.
//
//go:embed codex_prompt.md
var Prompt string
// DefaultEffort picks the middle of the road: "medium" or "high" when
// offered, else whatever the list starts with.
func DefaultEffort(e []string) string {
for _, want := range []string{"medium", "high"} {
if slices.Contains(e, want) {
return want
}
}
return e[0]
}
// TakesEffort is the effort Codex takes for the model id when none is set:
// the default_reasoning_level of the entry it reads for it. Codex's own
// model and a ChatGPT account's (codex/) keep their entry from Codex's
// models_cache.json, so it is that entry's (gpt-6.1-sol: low); one of
// magpie's other models takes the one Entries writes. "" when the model
// has no levels, or Codex's entry names none.
func TakesEffort(ms []catalog.Model, id string) string {
e := catalog.Efforts(ms, id)
if len(e) == 0 {
return ""
}
slug, chatgpt := strings.CutPrefix(id, "codex/")
if raw, ok := CacheEntries()[slug]; ok && (chatgpt || slug == id) {
d, _ := raw["default_reasoning_level"].(string)
return d
}
return DefaultEffort(e)
}
// Catalog renders models as a whole models.json.
func Catalog(ms []catalog.Model) []byte {
out := struct {
Models []any `json:"models"`
}{Models: Entries(ms, 0)}
if out.Models == nil {
out.Models = []any{}
}
b, _ := json.MarshalIndent(out, "", " ")
return b
}
// level, tier and model are an entry of models.json as Entries writes it.
type level struct {
Effort string `json:"effort"`
Description string `json:"description"`
}
type tier struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type model struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Instructions string `json:"base_instructions"`
DefaultEffort *string `json:"default_reasoning_level"`
Efforts []level `json:"supported_reasoning_levels"`
Shell string `json:"shell_type"`
Visibility string `json:"visibility"`
InAPI bool `json:"supported_in_api"`
Priority int `json:"priority"`
Verbosity bool `json:"support_verbosity"`
DefVerbosity *string `json:"default_verbosity"`
ApplyPatch string `json:"apply_patch_tool_type"`
Truncation struct {
Mode string `json:"mode"`
Limit int `json:"limit"`
} `json:"truncation_policy"`
Tools []string `json:"experimental_supported_tools"`
Modalities []string `json:"input_modalities"`
Context *int `json:"context_window,omitempty"`
// the model's whole window when Context is the working one
// (settings.Working): Codex's model_context_window may raise it
// that far, as it does OpenAI's own models'
MaxContext *int `json:"max_context_window,omitempty"`
Tiers []tier `json:"service_tiers"`
// Without the search, Codex puts every MCP tool's schema (a
// ChatGPT sign-in's apps' among them) in every request, 190K
// tokens before the first word (#258); with it, they are named in
// tool_search's description and handed over when searched for,
// as Codex does for its own models. Magpie serves the search to
// any model as a function (gateway/toolsearch.go). Code mode and
// Responses Lite stay off: the one has the model write JavaScript
// against Codex's tools, the other moves the tools and
// instructions into the input, neither for a model not trained on
// them.
SearchTool bool `json:"supports_search_tool"`
// Required from Codex 0.147 (#298: without it the whole catalog
// fails to load); later Codex ask for parallel calls whatever it
// says, so it says what they do.
Parallel bool `json:"supports_parallel_tool_calls"`
// Required by Codex before 0.145: without it the whole catalog fails
// to load ("missing field `supports_reasoning_summaries`", tried on
// 0.144.0), and it sends a request's reasoning parameters — the effort
// picked, and the summary it shows as the model's thinking — only for
// a model whose entry says true (codex-rs client.rs build_reasoning,
// until openai/codex#32206), so the user had to add it by hand
// (#1450). Later Codex send them always and ignore the field. True for
// a model with effort levels; a true the user put in for another stays
// (Keep).
Summaries bool `json:"supports_reasoning_summaries" keep:"true"`
// "v1" only with settings.CodexAgentsV1, on an OpenAI model's
// entry (see V1); "v2" on a model offering Ultra that no ChatGPT
// account answers for (catalog.Model.AgentsV2), as Codex's own
// entry for it says: Ultra hands work to Codex's agents in V2
// alone, and a magpie-served lead writes their tasks as text.
MultiAgent string `json:"multi_agent_version,omitempty"`
// the effort Codex's Ultra sends the model, as Codex's own entry
// for the same model says it (see agentsEffort); without it Codex
// sends max (#1108)
AgentsEffort string `json:"multi_agent_reasoning_effort,omitempty"`
// the model Codex's auto-review runs on, settings.CodexAutoReview
// (see AutoReview)
AutoReview string `json:"auto_review_model_override,omitempty"`
}
// Entries renders models as models.json entries, ranked after the first
// `after`. Only fields Codex requires or that change behaviour are set; the
// rest take Codex's defaults.
func Entries(ms []catalog.Model, after int) []any {
own := CacheEntries()
v1 := V1()
work := settings.Load()
var entries []any
for i, m := range ms {
if raw, ok := own[strings.TrimPrefix(m.ID, "codex/")]; ok && strings.HasPrefix(m.ID, "codex/") {
e := ownEntry(raw, m.ID, m.Name, after+i+1)
// the window as magpie resolves it — the one the user set,
// else the account's list's — not the cache's, which is what
// magpie last handed Codex (#674)
Window(e, m.Context, 0)
if v1 {
Stamp(e)
}
AutoReview(e)
entries = append(entries, e)
continue
}
e := model{
Slug: m.ID, DisplayName: m.Name, Description: m.Name + " via magpie",
Instructions: Prompt, Efforts: []level{},
Shell: "unified_exec", Visibility: "list", InAPI: true, Priority: after + i + 1,
ApplyPatch: "freeform", Tools: []string{}, Modalities: []string{"text"},
Tiers: []tier{}, SearchTool: true, Parallel: true,
AutoReview: work.CodexAutoReview,
}
// Fast mode: a ChatGPT account's GPT model Codex has no entry for,
// or a group one is in, gets the tier Codex's own catalog gives its
// GPT models
if slug, ok := strings.CutPrefix(m.ID, "codex/"); m.Fast || ok && strings.HasPrefix(slug, "gpt-") {
e.Tiers = append(e.Tiers, tier{ID: "priority", Name: "Fast", Description: "1.5x speed, increased usage"})
} else if m.OwnTier {
e.Tiers = ownTiers(own, m)
}
// an OpenAI model: a ChatGPT account's (codex/), or a group one is
// in (Fast, see provider.codexListed)
if v1 && (strings.HasPrefix(m.ID, "codex/") || m.Fast) {
e.MultiAgent = "v1"
} else if m.AgentsV2 {
e.MultiAgent = "v2"
}
if m.Images {
e.Modalities = append(e.Modalities, "image")
}
if c := m.Context; c > 0 {
// the model's or its provider's threshold, else the one for
// every model (#876); one at or above c is its whole window
w := work.Working(c)
if m.Compact > 0 {
w = min(c, m.Compact)
}
e.Context = &w
if w < c {
e.MaxContext = &c
}
}
e.Truncation.Mode, e.Truncation.Limit = "tokens", 10000
for _, ef := range m.Efforts {
e.Efforts = append(e.Efforts, level{Effort: ef})
}
e.AgentsEffort = agentsEffort(own, m)
if len(m.Efforts) > 0 {
d := DefaultEffort(m.Efforts)
e.DefaultEffort = &d
e.Summaries = true
}
entries = append(entries, &e)
}
return entries
}
// owned are the keys of an entry magpie writes, or leaves out on purpose:
// every field of model, and the two ownEntry takes out of Codex's own.
// keepTrue are those of them where a true the user set by hand stays over
// magpie's false (keep:"true").
var owned, keepTrue = func() (own, yes map[string]bool) {
own = map[string]bool{"availability_nux": true, "upgrade": true}
yes = map[string]bool{}
t := reflect.TypeFor[model]()
for i := range t.NumField() {
f := t.Field(i)
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == "" {
continue
}
own[name] = true
if f.Tag.Get("keep") == "true" {
yes[name] = true
}
}
return own, yes
}()
// Keep is the catalog b, as Catalog renders it, with what the user added by
// hand to the entries of cur, the catalog on disk magpie is about to write
// over: a key of an entry of the same slug that magpie doesn't own, and a
// true where magpie says false of a keepTrue key — supports_reasoning_
// summaries, put in for Codex to send a model's effort and show its
// thinking (#1450). Another key magpie writes takes magpie's value; the
// entry of a model magpie no longer serves goes. b as it is when there is
// nothing to keep, or cur isn't a catalog.
func Keep(cur, b []byte) []byte {
type list struct {
Models []map[string]any `json:"models"`
}
read := func(raw []byte, l *list) error {
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
return d.Decode(l)
}
var was list
if len(cur) == 0 || read(cur, &was) != nil {
return b
}
add := map[string]map[string]any{}
for _, e := range was.Models {
slug, _ := e["slug"].(string)
if slug == "" {
continue
}
for k, v := range e {
if !owned[k] || keepTrue[k] && v == true {
if add[slug] == nil {
add[slug] = map[string]any{}
}
add[slug][k] = v
}
}
}
if len(add) == 0 {
return b
}
var now list
if read(b, &now) != nil {
return b
}
kept := false
for _, e := range now.Models {
slug, _ := e["slug"].(string)
for k, v := range add[slug] {
if was, ok := e[k]; !ok || keepTrue[k] && was != true {
e[k], kept = v, true
}
}
}
if !kept {
return b
}
out, err := json.MarshalIndent(now, "", " ")
if err != nil {
return b
}
return out
}
// datedSuffix is a snapshot's date after a model's id (-2026-09-14,
// -20260914).
var datedSuffix = regexp.MustCompile(`-(\d{4}-\d{2}-\d{2}|\d{8})$`)
// agentsEffort is the multi_agent_reasoning_effort of Codex's own entry for
// the model a third-party one serves (s2a/gpt-6-astra, openai/gpt-6-astra on
// a relay, a dated snapshot): Codex's Ultra sends that effort, and max when
// an entry has none, so the same model under magpie's id would otherwise
// run at max where OpenAI's runs at xhigh (#1108). Only a model offering
// Ultra and the effort itself gets it; "" otherwise.
func agentsEffort(own map[string]map[string]any, m catalog.Model) string {
if !slices.Contains(m.Efforts, "ultra") {
return ""
}
slug := strings.ToLower(m.ID)
if i := strings.LastIndex(slug, "/"); i >= 0 {
slug = slug[i+1:]
}
slug = datedSuffix.ReplaceAllString(slug, "")
ef, _ := own[slug]["multi_agent_reasoning_effort"].(string)
if ef == "" || !slices.Contains(m.Efforts, ef) {
return ""
}
return ef
}
// gptModel is an OpenAI model's id, as a relay may serve it: gpt-6-sol, o4.
var gptModel = regexp.MustCompile(`^(gpt-|o\d)`)
// ownTiers are the service tiers Codex's own entry gives the model a
// provider the user added by its address serves (openai/gpt-6-sol on a
// relay, a dated snapshot), Ultrafast among them where the entry has it;
// Fast on another GPT model; none on any other.
func ownTiers(own map[string]map[string]any, m catalog.Model) []tier {
slug := strings.ToLower(m.ID)
if i := strings.LastIndex(slug, "/"); i >= 0 {
slug = slug[i+1:]
}
slug = datedSuffix.ReplaceAllString(slug, "")
if raw, ok := own[slug]["service_tiers"].([]any); ok {
var ts []tier
for _, r := range raw {
t, _ := r.(map[string]any)
id, _ := t["id"].(string)
if id == "" {
continue
}
name, _ := t["name"].(string)
desc, _ := t["description"].(string)
ts = append(ts, tier{ID: id, Name: name, Description: desc})
}
if len(ts) > 0 {
return ts
}
}
if gptModel.MatchString(slug) {
return []tier{{ID: "priority", Name: "Fast", Description: "1.5x speed, increased usage"}}
}
return []tier{}
}
// Order ranks entries — Codex's own, as the backend gives them, and
// magpie's from Entries — in the order the user put them in (#855): Codex
// lists its models by priority, lowest first. The ones at names go first,
// by their place there; the others after them, as they stood. The slice is
// put in that order too.
func Order(entries []any, at map[string]int) {
type ranked struct {
i, by, was int
}
rs := make([]ranked, len(entries))
for i, e := range entries {
slug, was := "", i
switch o := e.(type) {
case map[string]any:
slug, _ = o["slug"].(string)
switch p := o["priority"].(type) {
case float64:
was = int(p)
case int:
was = p
}
case *model:
slug, was = o.Slug, o.Priority
}
by := len(at)
if n, ok := at[slug]; ok {
by = n
}
rs[i] = ranked{i, by, was}
}
slices.SortStableFunc(rs, func(a, b ranked) int {
if a.by != b.by {
return a.by - b.by
}
return a.was - b.was
})
in := slices.Clone(entries)
for n, r := range rs {
switch o := in[r.i].(type) {
case map[string]any:
o["priority"] = n + 1
case *model:
o.Priority = n + 1
}
entries[n] = in[r.i]
}
}
// CacheEntries is Codex's own models, as models_cache.json describes them
// for the ChatGPT account it last asked with, by slug.
func CacheEntries() map[string]map[string]any {
b, err := os.ReadFile(catalog.CodexModelsCache())
if err != nil {
return nil
}
var cache struct {
ETag string `json:"etag"`
Models []map[string]any `json:"models"`
}
if json.Unmarshal(b, &cache) != nil {
return nil
}
out := map[string]map[string]any{}
for _, m := range cache.Models {
desc, _ := m["description"].(string)
if slug, _ := m["slug"].(string); slug != "" && !catalog.MagpieAdded(cache.ETag, slug, desc) {
out[slug] = m
}
}
if MarkedV1(cache.ETag) {
// the versions in it are magpie's, written over the backend's: the
// backend's go back, so a list made from this cache after the
// setting is turned off says what the backend did
was := originals()
for slug, m := range out {
if v, ok := was[slug]; ok {
unstamp(m, v)
}
}
} else {
// as the backend gave them
Remember(slices.Collect(func(yield func(any) bool) {
for _, m := range out {
if !yield(m) {
return
}
}
}))
}
return out
}
// Codex picks a thread's multi-agent tools by its model's entry: its
// multi_agent_version ("v1", "v2") unless features.multi_agent_v2 is on,
// which makes it V2 whatever the entry says. In V2 OpenAI's server seals a
// subagent's task, so a GPT lead can't hand one to a magpie-served
// subagent; in V1 the task goes as text (#141). With settings.CodexAgentsV1
// the OpenAI entries magpie hands Codex say "v1"; nothing else in them
// changes. Codex keeps what it was handed in models_cache.json, versions
// and all, so what the backend itself said is kept aside (originals) and
// put back when the cache is read again (CacheEntries).
// V1 reports whether the OpenAI models magpie hands Codex say "v1".
func V1() bool { return settings.Load().CodexAgentsV1 }
// Stamp has an entry say multi-agent V1.
func Stamp(e map[string]any) { e["multi_agent_version"] = "v1" }
// unstamp puts back the version an entry had: was, or none when "".
func unstamp(e map[string]any, was string) {
if was == "" {
delete(e, "multi_agent_version")
} else {
e["multi_agent_version"] = was
}
}
// Codex's auto-review (the guardian deciding an approval in the user's
// place) runs on the auto_review_model_override of the conversation's
// model's entry, else on codex-auto-review when the list has it, else on the
// conversation's model at low effort (#938). With settings.CodexAutoReview
// every entry magpie hands Codex names that model.
// AutoReview has one of Codex's own entries name the auto-review model the
// user picked. With none picked, one magpie put there before — a magpie id,
// which has a "/" where OpenAI's slugs have none, kept in Codex's cache — is
// taken out, and one OpenAI gave it is left.
func AutoReview(e map[string]any) {
if v := settings.Load().CodexAutoReview; v != "" {
e["auto_review_model_override"] = v
} else if was, _ := e["auto_review_model_override"].(string); strings.Contains(was, "/") {
delete(e, "auto_review_model_override")
}
}
// v1Mark starts the tag of a list whose OpenAI models say V1. It goes before
// the tag's hash, so neither tag is found inside the other (Tagged).
const v1Mark = "v1."
// PolicyTag is a list's tag with the V1 setting in it.
func PolicyTag(tag string) string {
if V1() {
return v1Mark + tag
}
return tag
}
// MarkedV1 reports whether an ETag is of a list magpie stamped V1.
func MarkedV1(etag string) bool { return strings.Contains(etag, tagMark+v1Mark) }
// versionsPath keeps the multi_agent_version the backend gave each of the
// account's models, "" for none, by slug.
func versionsPath() string { return filepath.Join(appdir.Config(), "codex-agent-versions.json") }
func originals() map[string]string {
out := map[string]string{}
if b, err := os.ReadFile(versionsPath()); err == nil {
json.Unmarshal(b, &out)
}
return out
}
// Remember keeps the versions of entries as the backend gave them, before
// any is stamped: those of slugs it names are replaced, the rest kept.
func Remember(entries []any) {
was := originals()
changed := false
for _, e := range entries {
m, _ := e.(map[string]any)
slug, _ := m["slug"].(string)
if slug == "" {
continue
}
v, _ := m["multi_agent_version"].(string)
if cur, ok := was[slug]; !ok || cur != v {
was[slug], changed = v, true
}
}
if !changed {
return
}
if b, err := json.MarshalIndent(was, "", " "); err == nil {
edit.WriteAtomic(versionsPath(), b)
}
}
// ownEntry is one of Codex's own models, reached through magpie with the
// ChatGPT sign-in: its entry as Codex has it (images, context window, tools,
// instructions), under magpie's id. The start-up notice and the upgrade
// prompt are left out; they name slugs the catalog does not have.
func ownEntry(raw map[string]any, id, name string, priority int) map[string]any {
e := make(map[string]any, len(raw))
for k, v := range raw {
e[k] = v
}
delete(e, "availability_nux")
delete(e, "upgrade")
e["slug"], e["display_name"], e["priority"], e["visibility"] = id, name, priority, "list"
if s, _ := e["base_instructions"].(string); s == "" {
e["base_instructions"] = Prompt
}
return e
}
// Window has one of Codex's own entries say a context window of n tokens
// (none: as it says), and most at the most it may be raised to (0: as it
// says). Only those two fields change. An entry whose max_context_window is
// below its window — one the user set past what OpenAI lists — would say
// two things at once, and Codex may hold the window to the max, so the max
// is raised to it.
func Window(e map[string]any, n, most int) {
if n <= 0 {
return
}
e["context_window"] = n
if most > 0 {
e["max_context_window"] = most
}
if cur, ok := e["max_context_window"].(float64); ok && int(cur) < n {
e["max_context_window"] = n
} else if cur, ok := e["max_context_window"].(int); ok && cur < n {
e["max_context_window"] = n
}
}
// Codex keeps the model list it was handed in models_cache.json, with the
// list's ETag, and asks again only once the cache has aged (minutes) — or
// when a reply's X-Models-Etag differs from it; the same one only makes the
// cache young again. Passed on as the ChatGPT backend gave it, the ETag
// said nothing of magpie's models, so a provider added since stayed out of
// Codex's /model for as long as Codex kept getting replies. The ETag magpie
// hands on carries a tag of its models too.
// Tag names a list of magpie's models: another list, another tag.
func Tag(ms []catalog.Model) string {
b, _ := json.Marshal(ms)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:6])
}
// tagMark is where a tag starts in an ETag magpie made.
const tagMark = "+magpie-"
// WithTag is an ETag of the backend's (or none) with magpie's tag in it:
// W/"abc" becomes W/"abc+magpie-<tag>".
func WithTag(etag, tag string) string {
if strings.HasSuffix(etag, `"`) && len(etag) > 1 {
return strings.TrimSuffix(etag, `"`) + tagMark + tag + `"`
}
return etag + tagMark + tag
}
// Tagged reports whether an ETag carries this tag.
func Tagged(etag, tag string) bool {
return strings.Contains(etag, tagMark+tag)
}