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]

Reply via email to