From fe405b23e256b473daf376655e98f86d79942e8d Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 8 Oct 2026 17:37:24 +0800 Subject: [PATCH] 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. --- docs/reference.md | 34 +++++- docs/subsystems/gateway-routing.md | 2 +- internal/gateway/codex_catalog.go | 69 +++++++++++ internal/gateway/codex_catalog_test.go | 158 +++++++++++++++++++++++++ internal/gateway/gateway.go | 1 + internal/provider/codex_models.go | 21 +++- internal/provider/group_test.go | 2 +- 7 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 internal/gateway/codex_catalog.go create mode 100644 internal/gateway/codex_catalog_test.go diff --git a/docs/reference.md b/docs/reference.md index 6f1080ef..7a942e64 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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/` (`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 diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index 9252659c..0c6b3e00 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -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-` (`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) | diff --git a/internal/gateway/codex_catalog.go b/internal/gateway/codex_catalog.go new file mode 100644 index 00000000..2fe6e0f8 --- /dev/null +++ b/internal/gateway/codex_catalog.go @@ -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 /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()) +} diff --git a/internal/gateway/codex_catalog_test.go b/internal/gateway/codex_catalog_test.go new file mode 100644 index 00000000..81ae83b0 --- /dev/null +++ b/internal/gateway/codex_catalog_test.go @@ -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) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 7538b2bd..b7199729 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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) { diff --git a/internal/provider/codex_models.go b/internal/provider/codex_models.go index 3641f122..001153ad 100644 --- a/internal/provider/codex_models.go +++ b/internal/provider/codex_models.go @@ -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} diff --git a/internal/provider/group_test.go b/internal/provider/group_test.go index db04be2f..f35297b8 100644 --- a/internal/provider/group_test.go +++ b/internal/provider/group_test.go @@ -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)