gateway: a Codex on another computer reads magpie's models with a gateway key from GET /v1/codex/models, in Codex's own catalog shape, as its model_catalog_url; /v1/models stays OpenAI's list (#1281, jskorlol)

Codex decodes a model_catalog_url as ModelsResponse {models:[ModelInfo]}
(codex-rs model-provider/src/models_endpoint.rs), which /v1/models' OpenAI
list isn't, and /backend-api/codex/models relays to the ChatGPT backend,
which answers a gateway key (replaced by magpie's token) with 401. The new
route lists the models magpie shows Codex, as codexcat writes them for
model_catalog_json, kept to the key's models (keyAllowed, as /v1/models),
the ChatGPT account's own included (provider.CodexCatalog). Codex reads at
most 1 MiB of such a catalog (MAX_MODEL_CATALOG_BYTES); entries past it are
left out and counted in X-Magpie-Left-Out rather than failing the list.

Verified:
- TestCodexCatalogForAGatewayKey through lanGuard(Handler) from a LAN
  address: required ModelInfo fields, unique slugs, held key sees only its
  models, no key / wrong key 401, /v1/models unchanged. Without the route it
  fails "404 ... magpie serves /v1/chat/completions ..."; with keyAllowed
  removed it fails "held key: [plan/m1 spare/m2]".
- TestCodexCatalogFitsCodexsLimit: 300 models -> 127 in 1047147 bytes, 173
  left out. Fails 404 without the route.
- Real Codex CLI 0.161.0 (npm) and 0.160.1 in sandbox CODEX_HOMEs against a
  sandbox `magpie serve` (0.0.0.0:3591, LAN on, two gateway keys) over
  10.0.0.111 with a fake chat upstream: models_cache.json holds the catalog
  (held key: fake/fake-coder only; free key: both), no "Unknown model"
  fallback warning, codex exec answered PONG-1281 through /v1/responses,
  also with the reporter's requires_openai_auth = true. 0.160.1 needs
  [features] api_key_model_discovery = true.
- go test -tags nogui ./internal/gateway ./internal/provider in a temp HOME:
  ok. Full ./...: 44 packages ok (internal/agent had timed out once
  under load; alone it passes in 45s). go vet; GOOS=linux and
  GOOS=windows vet of gateway+provider and nogui builds.
Not verified: Codex's TUI /model picker driven by hand.
This commit is contained in:
yetone committed 2026-10-08 18:46:28 +08:00
1 parent e387c5a2ed
commit fe405b23e2
7 files changed
+279 -8

No files matched your search

+32 -2
View File
@@ -377,8 +377,8 @@ openai/gpt-5 anthropic/*` (`all` takes the restriction off). A pattern is
serves the call, so a bare model name is resolved first, or a routing group,
`group/<id>` (`group/*` for every group). A key that names a group may use
the group with every member in it, though not those members asked for by
name. Such a key sees only its models in `/v1/models` and the Anthropic and
Gemini lists, a routing group it doesn't name only when it may use every
name. Such a key sees only its models in `/v1/models`, `/v1/codex/models`
and the Anthropic and Gemini lists, a routing group it doesn't name only when it may use every
member, and is refused any other model with a 403 in the API's error shape
before a provider is asked; a fallback it may not use is skipped. A key with
no models listed may use every model.
@@ -418,6 +418,35 @@ own: `MAGPIE_ADDR=127.0.0.1:3425` behind Tailscale Serve, or one interface's
address, stays where it is while shared, and what reaches it from elsewhere
still needs an enabled gateway key (#1112).
**Codex on another computer** (#1281) can name the shared magpie its model
provider, with a gateway key, and read magpie's models in Codex's own
catalog shape from `GET /v1/codex/models`. `/v1/models` is OpenAI's list,
which Codex can't read, and `/backend-api/codex/models` is the ChatGPT
backend's, for a Codex signed in to ChatGPT on the gateway's own computer;
a gateway key gets a 401 there.
```toml
model_provider = "magpie"
model = "provider/model"
[model_providers.magpie]
name = "magpie"
base_url = "http://192.168.1.20:3425/v1"
model_catalog_url = "http://192.168.1.20:3425/v1/codex/models"
env_key = "MAGPIE_API_KEY" # the gateway key
wire_api = "responses"
supports_websockets = false
```
The list is the models the shared magpie shows Codex (its Agents page
picks), each as the `model_catalog_json` magpie writes for a Codex on its
own computer describes it, and only the models the key may use. Codex 0.161
reads it by default; 0.160 needs `[features] api_key_model_discovery = true`.
Codex reads at most 1 MiB of a catalog, and every entry carries Codex's
prompt, so past about 125 models the list keeps the first that fit and the
answer's `X-Magpie-Left-Out` header says how many it left out: pick fewer
for Codex, or hold the key to fewer.
A request that reaches loopback through a proxy or tunnel on this computer
(Cloudflare Tunnel's `cloudflared`, ngrok, Tailscale serve or funnel, frp's
HTTP proxy, Caddy, nginx) is someone else's, and is answered as one from
@@ -1054,6 +1083,7 @@ It exposes:
| `/v1/messages/count_tokens` | Anthropic token counting |
| `/v1beta/models/{model}:generateContent` | Google Gemini (also `:streamGenerateContent`, `:countTokens`) |
| `/v1/models`, `/v1beta/models` | the catalog |
| `/v1/codex/models` | the catalog as Codex's `model_catalog_url` reads it ([Codex on another computer](#providers-and-the-gateway)) |
Each `/v1/models` entry includes `reasoning` and `supported_reasoning_levels`
(`[{"effort":"low"}, ...]`). A routing group is marked `reasoning` when any
+1 -1
View File
@@ -16,7 +16,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
| Part | Responsibility | Source |
| --- | --- | --- |
| Address and caller | `DefaultAddr`. The local token `magpie` and the per-agent `magpie-<agent>` (`TokenFor`). `agentOf` tells which agent asked, first by token, then by User-Agent. | [`gateway.go`](../../internal/gateway/gateway.go) |
| HTTP surface | `Handler` routes `/v1/chat/completions`, `/v1/responses`, `/v1/messages` (each also without `/v1`), `/v1/models`, Gemini's `/v1beta/models/…`, the Codex backend path, `/mcp/{name}` and the `/v1/magpie/*` status endpoints. A GET on `/v1/responses` (a client opening Responses over a WebSocket) is answered 426, a plain one 405: Responses is relayed over HTTP only. Every route is wrapped in `counted`, `corsGuard`, `callerGuard`, `withCaller` and `keyLimited`; `/v1/messages`, `/messages` and `/v1/messages/count_tokens` in `keepsSignIn` too. `corsGuard` (#1051) serves CORS only to the origins listed in Settings › Network › Web pages (`settings.CORSOrigins`, cleaned to `scheme://host[:port]` by `settings.CleanOrigins`, which refuses a wildcard). For a listed origin it answers the preflight (204) and adds `Access-Control-Allow-Origin`, and every other request from it needs an enabled gateway key (`access.Authenticate`), even on loopback, or gets a 401. It takes the key from the caller `lanGuard` recorded too, since on the running server `lanGuard` has already put magpie's own token in the key's place. Any other web page is refused 403 on every route (`foreignPage`): on loopback a call needs no key and the body's Content-Type isn't checked, so a page the user visits could otherwise send a "simple" `text/plain` POST and spend their subscriptions blind. Only an `http`/`https` Origin is judged, and one on this computer (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`, any port) or the request's own origin (its host and port equal to `Host`, such as a page on the gateway's LAN address) passes, as does usemagpie.ai on `/v1/magpie/omarchy`. `null` and other schemes (`app://`, `file://`, `tauri://`, `vscode-webview://`, `chrome-extension://`) come from desktop apps, webviews and extensions that don't enforce CORS, and pass. A request with no Origin (every agent's) passes through with no CORS headers, as before. There is no `Host` check, so a page whose own name resolves to 127.0.0.1 (DNS rebinding) counts as same-origin. | [`gateway.go`](../../internal/gateway/gateway.go), [`cors.go`](../../internal/gateway/cors.go), [`internal/settings/origins.go`](../../internal/settings/origins.go) |
| HTTP surface | `Handler` routes `/v1/chat/completions`, `/v1/responses`, `/v1/messages` (each also without `/v1`), `/v1/models`, Gemini's `/v1beta/models/…`, the Codex backend path, `/v1/codex/models` (Codex's catalog shape for a Codex elsewhere with a gateway key, `codexCatalog`, kept to the key's models by `keyAllowed`; #1281), `/mcp/{name}` and the `/v1/magpie/*` status endpoints. A GET on `/v1/responses` (a client opening Responses over a WebSocket) is answered 426, a plain one 405: Responses is relayed over HTTP only. Every route is wrapped in `counted`, `corsGuard`, `callerGuard`, `withCaller` and `keyLimited`; `/v1/messages`, `/messages` and `/v1/messages/count_tokens` in `keepsSignIn` too. `corsGuard` (#1051) serves CORS only to the origins listed in Settings › Network › Web pages (`settings.CORSOrigins`, cleaned to `scheme://host[:port]` by `settings.CleanOrigins`, which refuses a wildcard). For a listed origin it answers the preflight (204) and adds `Access-Control-Allow-Origin`, and every other request from it needs an enabled gateway key (`access.Authenticate`), even on loopback, or gets a 401. It takes the key from the caller `lanGuard` recorded too, since on the running server `lanGuard` has already put magpie's own token in the key's place. Any other web page is refused 403 on every route (`foreignPage`): on loopback a call needs no key and the body's Content-Type isn't checked, so a page the user visits could otherwise send a "simple" `text/plain` POST and spend their subscriptions blind. Only an `http`/`https` Origin is judged, and one on this computer (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`, any port) or the request's own origin (its host and port equal to `Host`, such as a page on the gateway's LAN address) passes, as does usemagpie.ai on `/v1/magpie/omarchy`. `null` and other schemes (`app://`, `file://`, `tauri://`, `vscode-webview://`, `chrome-extension://`) come from desktop apps, webviews and extensions that don't enforce CORS, and pass. A request with no Origin (every agent's) passes through with no CORS headers, as before. There is no `Host` check, so a page whose own name resolves to 127.0.0.1 (DNS rebinding) counts as same-origin. | [`gateway.go`](../../internal/gateway/gateway.go), [`cors.go`](../../internal/gateway/cors.go), [`codex_catalog.go`](../../internal/gateway/codex_catalog.go), [`internal/settings/origins.go`](../../internal/settings/origins.go) |
| Model resolution | `serve` resolves the model the agent asked for: Claude tier stand-ins, a routing group (`provider.GroupFor`: its id, the model's id however a vendor spells it, else the one group whose name it is, unless a provider serves a model by that id), auto stand-ins, then `provider.Resolve`. An unknown model gets a 404 naming why: a switched-off provider, a disabled group or an empty group. | [`gateway.go`](../../internal/gateway/gateway.go), [`internal/provider/group.go`](../../internal/provider/group.go) |
| Candidates | `plan` lists one provider's candidates: one per key or account on, then each `Fallback` model, with resting ones last (`restLast`). `planGroup` and `planLevel` weigh a group's members together. A nested group plans by its own routing (#576). A member's own fallbacks are not the group's. | [`fallback.go`](../../internal/gateway/fallback.go) |
| Order of keys and accounts | `Routing`: smart (""), `order`, `rotate`, `usage` (least used, with an `usageHalfLife` of 1h), `pace` or `weight` (`weightedFirst`). Smart, `usage` and `pace` weigh a subscription by its allowance (`allowances`), and a key by its own usage windows when magpie reads them (`keyAllowance`: a sub2api key given a 5-hour, day or 7-day limit, or a key bought as a plan, such as Kimi Code's or a GLM Coding Plan's); any other key has none. A lone key or account is weighed against nobody, but what it has left is still read into the trace (`weighRouted`), so the Routing page shows it as it does for several (#1016); all but used up, it keeps its conversation rather than being said to move (`affine`). A failure rests a candidate (`failure`, `failureOf`). | [`routing.go`](../../internal/gateway/routing.go), [`weighted.go`](../../internal/gateway/weighted.go), [`internal/provider/routing.go`](../../internal/provider/routing.go) |
+69
View File
@@ -0,0 +1,69 @@
package gateway
import (
"bytes"
"encoding/json"
"log"
"net/http"
"strconv"
"github.com/yetone/magpie/internal/codexcat"
"github.com/yetone/magpie/internal/provider"
)
// CodexCatalogPath is Codex's model list in its own shape, for a Codex that
// names magpie its model_provider (base_url <gateway>/v1) on another
// computer, with a gateway key (#1281): its model_catalog_url. /v1/models
// stays OpenAI's list, which Codex can't read, and CodexPath's list is the
// ChatGPT backend's, for a Codex signed in to ChatGPT here.
const CodexCatalogPath = "/v1/codex/models"
// codexCatalogMost is the most of a catalog Codex reads from a
// model_catalog_url (MAX_MODEL_CATALOG_BYTES in codex-rs's models
// endpoint): past it the whole list fails to load and Codex's picker has
// none of magpie's models.
const codexCatalogMost = 1 << 20
// codexCatalog answers CodexCatalogPath: the models magpie shows Codex, as
// the model_catalog_json magpie writes for a Codex here describes them,
// kept to the models a gateway key may use (keyAllowed, as /v1/models is).
// Codex's ?client_version= changes nothing. Entries that would take the
// answer past codexCatalogMost are left out, the last first, and the
// answer says how many in X-Magpie-Left-Out.
func (s *Server) codexCatalog(w http.ResponseWriter, r *http.Request) {
// the catalog, the groups and the windows read the providers over and
// over: held, they are built once (#746)
defer provider.Hold()()
shown, _ := provider.CatalogFor("codex")
ms := provider.CodexCatalog(keyAllowed(r, shown))
head, tail := []byte(`{"models":[`), []byte("]}\n")
var b bytes.Buffer
b.Write(head)
n, out := 0, 0
for _, e := range codexcat.Entries(ms, 0) {
var one bytes.Buffer
enc := json.NewEncoder(&one)
enc.SetEscapeHTML(false)
if enc.Encode(e) != nil {
continue
}
raw := bytes.TrimRight(one.Bytes(), "\n")
if b.Len()+1+len(raw)+len(tail) > codexCatalogMost {
out++
continue
}
if n > 0 {
b.WriteByte(',')
}
b.Write(raw)
n++
}
b.Write(tail)
if out > 0 {
log.Printf("%s: %d of Codex's %d models left out, past the %d bytes Codex reads of a catalog; pick fewer for Codex on the Agents page, or hold the key to fewer", CodexCatalogPath, out, n+out, codexCatalogMost)
w.Header().Set("X-Magpie-Left-Out", strconv.Itoa(out))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b.Bytes())
}
+158
View File
@@ -0,0 +1,158 @@
package gateway
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/provider"
)
// codexModelInfo is what codex-rs's ModelInfo can't do without
// (protocol/src/openai_models.rs): a field missing here fails the whole
// list there, "failed to decode models response".
type codexModelInfo struct {
Slug *string `json:"slug"`
DisplayName *string `json:"display_name"`
BaseInstructions *string `json:"base_instructions"`
Levels *[]map[string]any `json:"supported_reasoning_levels"`
ShellType *string `json:"shell_type"`
Visibility *string `json:"visibility"`
SupportedInAPI *bool `json:"supported_in_api"`
Priority *int `json:"priority"`
Verbosity *bool `json:"support_verbosity"`
Truncation *struct {
Mode string `json:"mode"`
Limit int `json:"limit"`
} `json:"truncation_policy"`
Tools *[]string `json:"experimental_supported_tools"`
}
// A Codex on another computer that names magpie its model_provider, with a
// gateway key (#1281), reads magpie's models from /v1/codex/models in
// Codex's own shape — through the server as it runs, lanGuard and all —
// and sees only the models its key may use; another computer without a key
// gets nothing, and /v1/models stays OpenAI's list.
func TestCodexCatalogForAGatewayKey(t *testing.T) {
fresh(t)
t.Setenv("MAGPIE_ADDR", "")
plan := &fake{t: t, ctype: "application/json", reply: `{}`}
spare := &fake{t: t, ctype: "application/json", reply: `{}`}
twoProviders(t, plan, spare)
keys, secrets := newCaller(t, "Held", "Free")
if _, err := access.Update("models-key", access.Change{Key: keys[0].ID, Models: []string{"plan/*"}}); err != nil {
t.Fatal(err)
}
h := lanGuard(New().Handler())
host := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.RemoteAddr = "192.168.1.9:5000" // another computer
h.ServeHTTP(w, r)
}))
t.Cleanup(host.Close)
get := func(path, key string) (*http.Response, []byte) {
req, _ := http.NewRequest("GET", host.URL+path, nil)
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res, b
}
// what Codex asks: model_catalog_url with ?client_version= after it
slugs := func(key string) []string {
t.Helper()
res, b := get(CodexCatalogPath+"?client_version=0.161.0", key)
if res.StatusCode != 200 {
t.Fatalf("%d %s", res.StatusCode, b)
}
var list struct {
Models []codexModelInfo `json:"models"`
}
if err := json.Unmarshal(b, &list); err != nil {
t.Fatal(err, string(b))
}
var out []string
for _, m := range list.Models {
if m.Slug == nil || m.DisplayName == nil || m.BaseInstructions == nil || *m.BaseInstructions == "" ||
m.Levels == nil || m.ShellType == nil || m.Visibility == nil || m.SupportedInAPI == nil ||
m.Priority == nil || m.Verbosity == nil || m.Truncation == nil || m.Tools == nil {
t.Fatalf("an entry Codex can't read: %s", b)
}
if slices.Contains(out, *m.Slug) {
t.Fatalf("slug %s twice: Codex refuses the list", *m.Slug)
}
out = append(out, *m.Slug)
}
return out
}
if got := slugs(secrets[1]); !slices.Contains(got, "plan/m1") || !slices.Contains(got, "spare/m2") {
t.Fatalf("free key: %v", got)
}
// a key held to some models is listed only those, as on /v1/models
if got := slugs(secrets[0]); !slices.Contains(got, "plan/m1") || slices.Contains(got, "spare/m2") {
t.Fatalf("held key: %v", got)
}
if res, b := get(CodexCatalogPath, ""); res.StatusCode != 401 {
t.Fatalf("no key: %d %s", res.StatusCode, b)
}
if res, b := get(CodexCatalogPath, "sk-not-a-key"); res.StatusCode != 401 {
t.Fatalf("a wrong key: %d %s", res.StatusCode, b)
}
// /v1/models is OpenAI's list as it was
res, b := get("/v1/models", secrets[1])
var l struct {
Object string `json:"object"`
Data []struct {
ID string `json:"id"`
} `json:"data"`
Models any `json:"models"`
}
if json.Unmarshal(b, &l); res.StatusCode != 200 || l.Object != "list" || len(l.Data) == 0 || l.Models != nil {
t.Fatalf("/v1/models: %d %s", res.StatusCode, b)
}
}
// Codex reads at most 1 MiB of a model_catalog_url and refuses the whole
// list past it; every entry carries Codex's prompt (~8 KB), so a gateway
// with hundreds of models hands Codex as many as fit, and says how many it
// left out, rather than a list Codex loads none of.
func TestCodexCatalogFitsCodexsLimit(t *testing.T) {
fresh(t)
var ms []string
for i := range 300 {
ms = append(ms, fmt.Sprintf("model-%03d", i))
}
if err := provider.Save(provider.Provider{ID: "many", Name: "Many", Key: "k", Chat: "http://127.0.0.1:9/v1", Models: ms}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
New().Handler().ServeHTTP(w, httptest.NewRequest("GET", CodexCatalogPath, nil))
if w.Code != 200 || w.Body.Len() > codexCatalogMost {
t.Fatalf("%d, %d bytes", w.Code, w.Body.Len())
}
var list struct {
Models []codexModelInfo `json:"models"`
}
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
out, _ := strconv.Atoi(w.Header().Get("X-Magpie-Left-Out"))
t.Logf("%d listed in %d bytes, %d left out", len(list.Models), w.Body.Len(), out)
if len(list.Models) < 100 || out == 0 || len(list.Models)+out != 300 {
t.Fatalf("%d listed, %d left out", len(list.Models), out)
}
if first := *list.Models[0].Slug; !strings.HasPrefix(first, "many/model-000") {
t.Fatalf("the first kept is %s", first)
}
}
+1
View File
@@ -522,6 +522,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /_magpie/claude-mcp/{token}", s.subscription.mcpCall)
mux.HandleFunc("/mcp/{name}", s.mcpProxy)
mux.HandleFunc(CodexPath+"/", s.codexBackend)
mux.HandleFunc("GET "+CodexCatalogPath, s.codexCatalog)
mux.HandleFunc("GET /v1beta/models", s.geminiModels)
mux.HandleFunc("POST /v1beta/models/{call...}", s.gemini)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+17 -4
View File
@@ -370,7 +370,19 @@ func CodexListed() []catalog.Model {
return codexListed(shown, func(id string) []Member {
_, ms, _ := find(id)
return ms
})
}, false)
}
// CodexCatalog is shown as a Codex that names magpie its model_provider is
// handed it from GET /v1/codex/models (#1281): every model, a ChatGPT
// account's own among them, since that Codex reaches them through magpie's
// /v1 by magpie's id, not through the ChatGPT backend's list.
func CodexCatalog(shown []Entry) []catalog.Model {
find := GroupFinder()
return codexListed(shown, func(id string) []Member {
_, ms, _ := find(id)
return ms
}, true)
}
// CodexNativeHidden is the ChatGPT account's own model slugs the user took
@@ -513,8 +525,9 @@ func codexWindowsTag() []catalog.Model {
// codexListed marks a group Fast when a ChatGPT account's GPT model is in
// it, so Codex offers /fast there too; the tier goes out only to that
// account (buildResponses).
func codexListed(shown []Entry, members func(id string) []Member) []catalog.Model {
// account (buildResponses). own keeps a ChatGPT account's own models in,
// which a signed-in Codex has from the backend already.
func codexListed(shown []Entry, members func(id string) []Member, own bool) []catalog.Model {
var ms []catalog.Model
// named among all shown: the account's own, which the backend lists,
// are in Codex's picker beside these
@@ -523,7 +536,7 @@ func codexListed(shown []Entry, members func(id string) []Member) []catalog.Mode
s := settings.Load()
find := func(id string) (Group, []Member, bool) { return Group{}, members(id), true }
for i, e := range shown {
if CodexOwn(e) {
if CodexOwn(e) && !own {
continue
}
m := catalog.Model{ID: e.ID, Name: labels[i], Efforts: e.Efforts, Images: e.Images || seen, Context: e.Context, AgentsV2: e.AgentsV2}
+1 -1
View File
@@ -90,7 +90,7 @@ func TestCodexListedKeepsGroups(t *testing.T) {
{ID: "or/glm-5.3", Model: "glm-5.3", Provider: Provider{ID: "or"}},
}, func(id string) []Member {
return []Member{{Provider: Provider{ID: "or"}, Model: "glm-5.3"}, {Provider: acct, Model: "gpt-5.5"}}
})
}, false)
var ids []string
for _, m := range ms {
ids = append(ids, m.ID)