Files
magpie/internal/codexcat/codexcat.go
T
yetone e950eb080a codex, claude: a model with a window above 272K is compacted at 272K, as OpenAI's and Anthropic's own are (X: Chen — through magpie on DeepSeek flash, Codex and Claude Code both "wait" before every answer, and more the longer the task) — magpie told both agents a 1M model's whole window, so neither compacted until near 1M and every turn sent the whole conversation again: the live ledger had turns of ~550K tokens waiting 70–90s for a first token, while magpie's own time before the vendor stays under 0.2s. OpenAI runs its own 1M-capable models at 272K in Codex (context_window 272000, max_context_window 872000) and Anthropic its at 200K unless a [1m] one is picked; magpie now does the same by default. Codex's catalog gives such a model context_window 272000 and its whole window as max_context_window; Claude Code gets CLAUDE_CODE_AUTO_COMPACT_WINDOW=272000 while it runs on magpie (it compacts at the smaller of that and the model's window, a [1m] model's too), kept apart from a value the user set and taken out with magpie. Settings → Long conversations ("Compact at 272K" / "Full window", en and zh) turns it off (settings.FullContext), and both agents' lists are written again at once. In a sandbox with the owner's real accounts: Codex's /models listed group/test and group/auto-glm-5-3-flash at 272000 with max 1000000 (stepfun's 32K and 16K as they were, gpt-6-* still 272000/872000), Claude Code on deepseek/deepseek-flash[1m] answered and its /context said 16.3k / 272k, and Full window turned both back to 1M (/context 16.3k / 1m). TestClaudeCompactWindow and the extended TestCodexCatalogToolSearch fail on the old code. go vet (darwin, GOOS=windows), builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-04 18:27:09 +08:00

363 lines
12 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 (
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"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]
}
// 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
}
// 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 {
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"`
// "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"`
}
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)
}
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,
}
// 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"})
}
// 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 {
w := work.Working(c)
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})
}
if len(m.Efforts) > 0 {
d := DefaultEffort(m.Efforts)
e.DefaultEffort = &d
}
entries = append(entries, e)
}
return entries
}
// 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
}
}
// 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)
}