This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 0ac1361 feat(agents): show the bound model's name in Codex's picker
0ac1361 is described below
commit 0ac136183b4ea36e9664ed0660b74b2d40301923
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 6 21:19:50 2026 +0800
feat(agents): show the bound model's name in Codex's picker
---
agent/codex_program.go | 54 +++++++++
agentprovider/codex.go | 48 +++++++-
agentprovider/codex_catalog.go | 248 +++++++++++++++++++++++++++++++++++++++++
agentprovider/hide_other.go | 22 ++++
agentprovider/hide_windows.go | 28 +++++
controllers/agent_account.go | 27 +----
6 files changed, 397 insertions(+), 30 deletions(-)
diff --git a/agent/codex_program.go b/agent/codex_program.go
new file mode 100644
index 0000000..d91b6e4
--- /dev/null
+++ b/agent/codex_program.go
@@ -0,0 +1,54 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// CodexProgram is the Codex command line to run on behalf of one installation,
+// empty when this host has none. The desktop app ships no command line of its
+// own, so the CLI installed beside it answers for both.
+func CodexProgram(installation Installation) string {
+ if program := codexProgramOf(installation); program != "" {
+ return program
+ }
+
+ installations, err := Scan(false)
+ if err != nil {
+ return ""
+ }
+ for _, other := range installations {
+ if other.AgentId != "codex" && other.AgentId != "codex-cli" {
+ continue
+ }
+ if program := codexProgramOf(other); program != "" {
+ return program
+ }
+ }
+ return ""
+}
+
+func codexProgramOf(installation Installation) string {
+ executable := LaunchOf(installation).Executable
+ if executable == "" {
+ return ""
+ }
+ if strings.HasPrefix(strings.ToLower(filepath.Base(executable)),
"codex") {
+ return executable
+ }
+ return ""
+}
diff --git a/agentprovider/codex.go b/agentprovider/codex.go
index bd98a70..05d7ed4 100644
--- a/agentprovider/codex.go
+++ b/agentprovider/codex.go
@@ -99,13 +99,21 @@ func (w codexWriter) Plan(target Target, endpoint Endpoint)
([]File, error) {
if endpoint.Model != "" {
config = tomlSetRootKey(config, codexModelKey, endpoint.Model)
}
+
+ files := []File{}
+ catalogPath := filepath.Join(home, codexCatalogFile)
+ if catalog, ok := codexCatalog(target, endpoint); ok {
+ config = tomlSetRootKey(config, codexCatalogKey, catalogPath)
+ files = append(files, File{Path: catalogPath, Format: "json",
Preview: string(catalog)})
+ }
+
preview := endpoint
preview.ApiKey = maskSecret(endpoint.ApiKey)
config = tomlTidy(tomlAppend(config, w.providerTable(preview)))
- return []File{
+ return append([]File{
{Path: filepath.Join(home, "config.toml"), Format: "toml",
Preview: config},
- }, nil
+ }, files...), nil
}
func (w codexWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
@@ -117,6 +125,7 @@ func (w codexWriter) Apply(target Target, endpoint
Endpoint) (map[string]string,
if err != nil {
return nil, err
}
+ catalogPath := filepath.Join(filepath.Dir(configPath), codexCatalogFile)
data, _, _, err := readFile(configPath)
if err != nil {
@@ -138,6 +147,9 @@ func (w codexWriter) Apply(target Target, endpoint
Endpoint) (map[string]string,
if value, ok := document[codexModelKey].(string); ok {
previous[codexModelKey] = value
}
+ if value, ok := document[codexCatalogKey].(string); ok {
+ previous[codexCatalogKey] = value
+ }
}
text := tomlCutTable(string(data), codexProviderPath...)
@@ -145,9 +157,25 @@ func (w codexWriter) Apply(target Target, endpoint
Endpoint) (map[string]string,
if endpoint.Model != "" {
text = tomlSetRootKey(text, codexModelKey, endpoint.Model)
}
+ catalog, named := codexCatalog(target, endpoint)
+ if named {
+ text = tomlSetRootKey(text, codexCatalogKey, catalogPath)
+ } else if stringAt(document, codexCatalogKey) == catalogPath {
+ // One Gateway wrote before, for a Codex that no longer reads
it. A
+ // catalog of the user's own is left where it is.
+ text = tomlDeleteRootKey(text, codexCatalogKey)
+ }
text = tomlAppend(text, w.providerTable(endpoint))
changes := &txn{}
+ // The catalog goes in first: a config.toml naming a file that is not
there
+ // yet is one Codex refuses to start on.
+ if named {
+ if err := changes.write(catalogPath, catalog); err != nil {
+ changes.abort()
+ return nil, err
+ }
+ }
if err := changes.write(configPath, []byte(text)); err != nil {
changes.abort()
return nil, err
@@ -155,6 +183,9 @@ func (w codexWriter) Apply(target Target, endpoint
Endpoint) (map[string]string,
if err := changes.commit(); err != nil {
return nil, err
}
+ if !named {
+ _ = removeFile(catalogPath)
+ }
return previous, nil
}
@@ -168,14 +199,18 @@ func (w codexWriter) Restore(target Target, previous
map[string]string) error {
if err != nil {
return err
}
+ catalogPath := filepath.Join(filepath.Dir(configPath), codexCatalogFile)
text := tomlCutTable(string(data), codexProviderPath...)
- for _, key := range []string{codexModelProviderKey, codexModelKey} {
+ for _, key := range []string{codexModelProviderKey, codexModelKey,
codexCatalogKey} {
value, ok := previous[key]
// A state that recorded Gateway's own entry, from the
installation
// sharing this file, would leave the agent switched if it were
put back.
if key == codexModelProviderKey && value == codexProviderName {
ok = false
}
+ if key == codexCatalogKey && value == catalogPath {
+ ok = false
+ }
if ok {
text = tomlSetRootKey(text, key, value)
} else {
@@ -215,7 +250,12 @@ func (w codexWriter) Restore(target Target, previous
map[string]string) error {
return err
}
}
- return changes.commit()
+ if err := changes.commit(); err != nil {
+ return err
+ }
+ // Nothing but Gateway ever wrote this file, and config.toml no longer
names
+ // it, so it goes with the rest of what was put back.
+ return removeFile(catalogPath)
}
func (w codexWriter) Current(target Target) (string, error) {
diff --git a/agentprovider/codex_catalog.go b/agentprovider/codex_catalog.go
new file mode 100644
index 0000000..9cfc26a
--- /dev/null
+++ b/agentprovider/codex_catalog.go
@@ -0,0 +1,248 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentprovider
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/agent"
+)
+
+const (
+ // codexCatalogFile is the model list Gateway owns beside config.toml.
Codex
+ // names a model in its picker only when its catalog carries an entry
for it,
+ // so a bound model that is not one of OpenAI's reads as "Custom" until
this
+ // file names it.
+ codexCatalogFile = "casbin-gateway-models.json"
+ // codexCatalogKey is the root key pointing at that file. Codex refuses
to
+ // start when it names a file it cannot read, so the key and the file
are
+ // written and taken back together.
+ codexCatalogKey = "model_catalog_json"
+ // codexCatalogWait bounds one run of Codex's own catalog dump.
+ codexCatalogWait = 60 * time.Second
+ // codexCatalogTtl is how long one built catalog answers for the next
call.
+ // A single switch plans and then writes, and both run Codex.
+ codexCatalogTtl = time.Minute
+ codexCatalogDetail = "Served through Casbin Gateway"
+)
+
+// codexCatalogCache is the last catalog built, kept only long enough for the
+// plan and the write of one switch to share it.
+var codexCatalogCache = struct {
+ sync.Mutex
+ key string
+ data []byte
+ built bool
+ time time.Time
+}{}
+
+// codexCatalog is the catalog file for the models endpoint serves, and whether
+// one could be made at all.
+//
+// Every entry is a copy of the one Codex itself would start on, taken from the
+// installed Codex rather than written here: an entry carries the prompt and
the
+// tool wiring sent with each turn, and both change with the release. What
comes
+// out is handed back to the same Codex to parse, and a release Gateway cannot
+// write for leaves the picker as it was rather than a Codex that will not
start.
+func codexCatalog(target Target, endpoint Endpoint) ([]byte, bool) {
+ models := codexCatalogModels(endpoint)
+ if len(models) == 0 {
+ return nil, false
+ }
+ program := agent.CodexProgram(agent.Installation{AgentId:
target.AgentId, Path: target.Path, Owner: target.Owner})
+ if program == "" {
+ return nil, false
+ }
+
+ key := program + "\n" + strings.Join(models, "\n")
+ codexCatalogCache.Lock()
+ defer codexCatalogCache.Unlock()
+ if codexCatalogCache.key == key && time.Since(codexCatalogCache.time) <
codexCatalogTtl {
+ return codexCatalogCache.data, codexCatalogCache.built
+ }
+
+ data, built := codexBuildCatalog(program, models)
+ codexCatalogCache.key, codexCatalogCache.data = key, data
+ codexCatalogCache.built, codexCatalogCache.time = built, time.Now()
+ return data, built
+}
+
+func codexBuildCatalog(program string, models []string) ([]byte, bool) {
+ known, ok := codexKnownModels(program, "")
+ if !ok {
+ return nil, false
+ }
+ donor := codexDonorModel(known)
+ if donor == nil {
+ return nil, false
+ }
+
+ entries := make([]any, 0, len(models))
+ for index, model := range models {
+ entries = append(entries, codexCatalogEntry(donor, model,
index))
+ }
+ data, err := json.MarshalIndent(map[string]any{"models": entries}, "",
" ")
+ if err != nil {
+ return nil, false
+ }
+ data = append(data, '\n')
+ if !codexReadsCatalog(program, data, models[0]) {
+ return nil, false
+ }
+ return data, true
+}
+
+// codexCatalogModels is every model to name in the catalog, the bound one
first.
+func codexCatalogModels(endpoint Endpoint) []string {
+ models := []string{}
+ seen := map[string]bool{}
+ for _, model := range append([]string{endpoint.Model},
endpoint.Models...) {
+ if model == "" || seen[model] {
+ continue
+ }
+ seen[model] = true
+ models = append(models, model)
+ }
+ return models
+}
+
+// codexKnownModels is the catalog Codex resolves for itself, read against a
+// directory of its own so that whatever is in the real one, this file
included,
+// cannot answer for it. catalogPath, when given, is the catalog to read.
+func codexKnownModels(program string, catalogPath string) ([]map[string]any,
bool) {
+ home, err := os.MkdirTemp("", "casbin-gateway-catalog-")
+ if err != nil {
+ return nil, false
+ }
+ defer os.RemoveAll(home)
+
+ if catalogPath != "" {
+ config := tomlSetRootKey("", codexCatalogKey, catalogPath)
+ if err := os.WriteFile(filepath.Join(home, "config.toml"),
[]byte(config), defaultMode); err != nil {
+ return nil, false
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(),
codexCatalogWait)
+ defer cancel()
+ command := exec.CommandContext(ctx, program, "debug", "models")
+ command.Env = append(os.Environ(), "CODEX_HOME="+home)
+ command.Dir = home
+ hideWindow(command)
+
+ output, err := command.Output()
+ if err != nil {
+ return nil, false
+ }
+ document := struct {
+ Models []map[string]any `json:"models"`
+ }{}
+ if err := json.Unmarshal(output, &document); err != nil ||
len(document.Models) == 0 {
+ return nil, false
+ }
+ return document.Models, true
+}
+
+// codexDonorModel is the entry the copies are made from: the one Codex offers
+// first, which is the one it would have started on.
+func codexDonorModel(models []map[string]any) map[string]any {
+ var donor map[string]any
+ priority := 0
+ for _, model := range models {
+ if stringAt(model, "visibility") != "list" {
+ continue
+ }
+ value, ok := model["priority"].(float64)
+ if !ok {
+ continue
+ }
+ if donor == nil || int(value) < priority {
+ donor, priority = model, int(value)
+ }
+ }
+ if donor == nil && len(models) > 0 {
+ donor = models[0]
+ }
+ return donor
+}
+
+func codexCatalogEntry(donor map[string]any, model string, priority int)
map[string]any {
+ entry := map[string]any{}
+ for key, value := range donor {
+ entry[key] = value
+ }
+
+ entry["slug"] = model
+ entry["display_name"] = model
+ entry["description"] = codexCatalogDetail
+ entry["visibility"] = "list"
+ entry["priority"] = priority
+ // None of what the copied entry says about the OpenAI model it came
from
+ // holds here: no announcement, no upgrade, and no faster tier to buy.
+ entry["availability_nux"] = nil
+ entry["upgrade"] = nil
+ replacePresent(entry, "additional_speed_tiers", []any{})
+ replacePresent(entry, "service_tiers", []any{})
+ // Codex leaves these out of what it prints while reading a catalog
without
+ // them as an error, so a copy says what Codex does with a model it has
no
+ // entry for at all.
+ fillMissing(entry, "supports_reasoning_summaries", false)
+ fillMissing(entry, "supports_parallel_tool_calls", false)
+ return entry
+}
+
+// codexReadsCatalog reports whether the Codex being written for parses the
+// catalog back and finds the model in it.
+func codexReadsCatalog(program string, data []byte, model string) bool {
+ directory, err := os.MkdirTemp("", "casbin-gateway-catalog-")
+ if err != nil {
+ return false
+ }
+ defer os.RemoveAll(directory)
+
+ path := filepath.Join(directory, codexCatalogFile)
+ if err := os.WriteFile(path, data, defaultMode); err != nil {
+ return false
+ }
+ models, ok := codexKnownModels(program, path)
+ if !ok {
+ return false
+ }
+ for _, known := range models {
+ if stringAt(known, "slug") == model {
+ return true
+ }
+ }
+ return false
+}
+
+func replacePresent(entry map[string]any, key string, value any) {
+ if _, ok := entry[key]; ok {
+ entry[key] = value
+ }
+}
+
+func fillMissing(entry map[string]any, key string, value any) {
+ if _, ok := entry[key]; !ok {
+ entry[key] = value
+ }
+}
diff --git a/agentprovider/hide_other.go b/agentprovider/hide_other.go
new file mode 100644
index 0000000..1ce6284
--- /dev/null
+++ b/agentprovider/hide_other.go
@@ -0,0 +1,22 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+
+package agentprovider
+
+import "os/exec"
+
+// hideWindow has nothing to hide anywhere but Windows.
+func hideWindow(*exec.Cmd) {}
diff --git a/agentprovider/hide_windows.go b/agentprovider/hide_windows.go
new file mode 100644
index 0000000..6e8cedf
--- /dev/null
+++ b/agentprovider/hide_windows.go
@@ -0,0 +1,28 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build windows
+
+package agentprovider
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// hideWindow keeps the console the agent would open to itself: nothing it
+// prints is for anyone to read here.
+func hideWindow(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+}
diff --git a/controllers/agent_account.go b/controllers/agent_account.go
index 170fd5c..5149f0f 100644
--- a/controllers/agent_account.go
+++ b/controllers/agent_account.go
@@ -18,7 +18,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "path/filepath"
"strings"
"github.com/apache/casbin-gateway/agent"
@@ -320,32 +319,8 @@ func (c *ApiController) readAgentAccountForm()
(agentAccountForm, string, bool)
// which the same scan finds, and which reads the same ~/.codex - is what signs
// in for both.
func codexLoginProgram(installation agent.Installation) (string, error) {
- if program := codexProgramOf(installation); program != "" {
+ if program := agent.CodexProgram(installation); program != "" {
return program, nil
}
-
- installations, err := agent.Scan(false)
- if err != nil {
- return "", err
- }
- for _, other := range installations {
- if !agentauth.Supports(other.AgentId) {
- continue
- }
- if program := codexProgramOf(other); program != "" {
- return program, nil
- }
- }
return "", errors.New("gateway found no Codex program on this machine
to sign in with")
}
-
-func codexProgramOf(installation agent.Installation) string {
- executable := agent.LaunchOf(installation).Executable
- if executable == "" {
- return ""
- }
- if strings.HasPrefix(strings.ToLower(filepath.Base(executable)),
"codex") {
- return executable
- }
- return ""
-}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]