You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/08/24 13:31:23 UTC

(casbin-gateway) branch master updated: fix(agent): stop Codex asking for OPENAI_API_KEY after a provider switch

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 9234c61  fix(agent): stop Codex asking for OPENAI_API_KEY after a provider switch
9234c61 is described below

commit 9234c61729108e2e4ac5285a3018d722507fdb87
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Mon Aug 24 21:31:17 2026 +0800

    fix(agent): stop Codex asking for OPENAI_API_KEY after a provider switch
---
 agentprovider/codex.go        | 118 +++++++++++++++++++++++-------------------
 agentprovider/provider.go     |  12 +++--
 controllers/agent.go          |  12 +++--
 controllers/agent_provider.go |  22 ++++++++
 4 files changed, 102 insertions(+), 62 deletions(-)

diff --git a/agentprovider/codex.go b/agentprovider/codex.go
index cec8a7d..e6537f7 100644
--- a/agentprovider/codex.go
+++ b/agentprovider/codex.go
@@ -28,9 +28,15 @@ const (
 	// codexProviderName is the model provider Gateway owns in config.toml.
 	// Everything else in the file, including other providers, is left alone.
 	codexProviderName = "casbin-gateway"
-	// codexAuthKey is where Codex reads the key of a provider whose env_key is
-	// not exported in the shell.
+	// codexAuthKey is the auth.json entry an older release wrote the key to.
+	// Codex reads it only while signed in with an API key: a user signed in
+	// through ChatGPT got "Missing environment variable: OPENAI_API_KEY"
+	// instead, so the key now travels in the header below.
 	codexAuthKey = "OPENAI_API_KEY"
+	// codexAuthHeader carries the key on every request Codex sends to the
+	// provider, which is the one place Codex reads it from without an
+	// environment variable and without a sign-in of its own.
+	codexAuthHeader = "Authorization"
 )
 
 // The keys of the root table Gateway owns, remembered under these names.
@@ -38,12 +44,19 @@ const (
 	codexModelProviderKey = "model_provider"
 	codexModelKey         = "model"
 	codexAuthStateKey     = "auth." + codexAuthKey
+	// codexHeaderStateKey marks a state Gateway wrote without touching
+	// auth.json. A state without it comes from the release that did, and only
+	// that one has an auth.json entry to put back.
+	codexHeaderStateKey = "auth.inHeader"
 )
 
-var codexProviderPath = []string{"model_providers", codexProviderName}
+var (
+	codexProviderPath = []string{"model_providers", codexProviderName}
+	codexHeaderPath   = []string{"model_providers", codexProviderName, "http_headers"}
+)
 
 // errCodexNoKey rejects a provider that forwards the caller's own credentials:
-// Codex reads its key from auth.json, so there is nothing for this provider
+// Codex sends the key Gateway writes, so there is nothing for this provider
 // entry to forward.
 var errCodexNoKey = errors.New("Codex needs an API key, so it cannot use a provider that forwards the credentials of the caller")
 
@@ -83,15 +96,12 @@ func (w codexWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
 	if endpoint.Model != "" {
 		config = tomlSetRootKey(config, codexModelKey, endpoint.Model)
 	}
-	config = tomlTidy(tomlAppend(config, w.providerTable(endpoint)))
+	preview := endpoint
+	preview.ApiKey = maskSecret(endpoint.ApiKey)
+	config = tomlTidy(tomlAppend(config, w.providerTable(preview)))
 
-	auth, err := encodeJSON(map[string]any{codexAuthKey: maskSecret(endpoint.ApiKey)})
-	if err != nil {
-		return nil, err
-	}
 	return []File{
 		{Path: filepath.Join(home, "config.toml"), Format: "toml", Preview: config},
-		{Path: filepath.Join(home, "auth.json"), Format: "json", Preview: string(auth)},
 	}, nil
 }
 
@@ -100,7 +110,7 @@ func (w codexWriter) Apply(target Target, endpoint Endpoint) (map[string]string,
 		return nil, err
 	}
 
-	configPath, authPath, err := w.paths(target)
+	configPath, _, err := w.paths(target)
 	if err != nil {
 		return nil, err
 	}
@@ -114,8 +124,11 @@ func (w codexWriter) Apply(target Target, endpoint Endpoint) (map[string]string,
 		return nil, err
 	}
 
-	previous := map[string]string{}
-	if value, ok := document[codexModelProviderKey].(string); ok {
+	previous := map[string]string{codexHeaderStateKey: "1"}
+	// The CLI and the VS Code integration share one ~/.codex, so the selection
+	// found here can be Gateway's own from the other one, which is not what a
+	// restore should put back.
+	if value, ok := document[codexModelProviderKey].(string); ok && value != codexProviderName {
 		previous[codexModelProviderKey] = value
 	}
 	if value, ok := document[codexModelKey].(string); ok {
@@ -129,28 +142,11 @@ func (w codexWriter) Apply(target Target, endpoint Endpoint) (map[string]string,
 	}
 	text = tomlAppend(text, w.providerTable(endpoint))
 
-	auth, _, err := readJSON(authPath)
-	if err != nil {
-		return nil, err
-	}
-	if value, ok := auth[codexAuthKey].(string); ok {
-		previous[codexAuthStateKey] = value
-	}
-	auth[codexAuthKey] = endpoint.ApiKey
-	authData, err := encodeJSON(auth)
-	if err != nil {
-		return nil, err
-	}
-
 	changes := &txn{}
 	if err := changes.write(configPath, []byte(text)); err != nil {
 		changes.abort()
 		return nil, err
 	}
-	if err := changes.write(authPath, authData); err != nil {
-		changes.abort()
-		return nil, err
-	}
 	if err := changes.commit(); err != nil {
 		return nil, err
 	}
@@ -169,7 +165,13 @@ func (w codexWriter) Restore(target Target, previous map[string]string) error {
 	}
 	text := tomlCutTable(string(data), codexProviderPath...)
 	for _, key := range []string{codexModelProviderKey, codexModelKey} {
-		if value, ok := previous[key]; ok {
+		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 ok {
 			text = tomlSetRootKey(text, key, value)
 		} else {
 			text = tomlDeleteRootKey(text, key)
@@ -177,28 +179,34 @@ func (w codexWriter) Restore(target Target, previous map[string]string) error {
 	}
 	text = strings.TrimLeft(text, "\n")
 
-	auth, _, err := readJSON(authPath)
-	if err != nil {
-		return err
-	}
-	if value, ok := previous[codexAuthStateKey]; ok {
-		auth[codexAuthKey] = value
-	} else {
-		delete(auth, codexAuthKey)
-	}
-	authData, err := encodeJSON(auth)
-	if err != nil {
-		return err
-	}
-
 	changes := &txn{}
 	if err := changes.write(configPath, []byte(text)); err != nil {
 		changes.abort()
 		return err
 	}
-	if err := changes.write(authPath, authData); err != nil {
-		changes.abort()
-		return err
+
+	// Only a state from the release that put the key in auth.json has one to
+	// take back out; a newer one never opened the file.
+	if _, inHeader := previous[codexHeaderStateKey]; !inHeader {
+		auth, _, err := readJSON(authPath)
+		if err != nil {
+			changes.abort()
+			return err
+		}
+		if value, ok := previous[codexAuthStateKey]; ok {
+			auth[codexAuthKey] = value
+		} else {
+			delete(auth, codexAuthKey)
+		}
+		authData, err := encodeJSON(auth)
+		if err != nil {
+			changes.abort()
+			return err
+		}
+		if err := changes.write(authPath, authData); err != nil {
+			changes.abort()
+			return err
+		}
 	}
 	return changes.commit()
 }
@@ -242,17 +250,21 @@ func (codexWriter) check(endpoint Endpoint) error {
 	return nil
 }
 
-// providerTable is the [model_providers.casbin-gateway] block. The key lives in
-// auth.json under env_key, so config.toml can be read without exposing it.
+// providerTable is the [model_providers.casbin-gateway] block, followed by the
+// header sub-table carrying the key. env_key would send Codex looking for an
+// environment variable no shell exports, so the key is written here instead.
 func (codexWriter) providerTable(endpoint Endpoint) string {
-	return tomlTable(codexProviderPath,
-		[]string{"name", "base_url", "wire_api", "env_key"},
+	table := tomlTable(codexProviderPath,
+		[]string{"name", "base_url", "wire_api"},
 		map[string]string{
 			"name":     "Casbin Gateway",
 			"base_url": endpoint.BaseUrl,
 			"wire_api": "responses",
-			"env_key":  codexAuthKey,
 		})
+	headers := tomlTable(codexHeaderPath,
+		[]string{codexAuthHeader},
+		map[string]string{codexAuthHeader: "Bearer " + endpoint.ApiKey})
+	return table + "\n" + headers
 }
 
 func (codexWriter) decode(path string, data []byte) (map[string]any, error) {
diff --git a/agentprovider/provider.go b/agentprovider/provider.go
index 75948ae..5f3a220 100644
--- a/agentprovider/provider.go
+++ b/agentprovider/provider.go
@@ -166,18 +166,20 @@ func Apply(target Target, endpoint Endpoint) error {
 // Restore puts back the provider settings the agent had before the first Apply
 // and forgets the installation. It is a no-op when Gateway never switched it.
 func Restore(target Target) error {
-	value, ok := writers[target.AgentId]
-	if !ok {
-		return fmt.Errorf("%s: %w", target.AgentId, ErrNotSupported)
-	}
-
 	writerMutex.Lock()
 	defer writerMutex.Unlock()
 
+	// An installation Gateway never switched has nothing to put back, whether
+	// or not its format is one Gateway can write.
 	saved, err := loadState(target)
 	if err != nil || saved == nil {
 		return err
 	}
+
+	value, ok := writers[target.AgentId]
+	if !ok {
+		return fmt.Errorf("%s: %w", target.AgentId, ErrNotSupported)
+	}
 	if err := value.Restore(target, saved.Previous); err != nil {
 		return err
 	}
diff --git a/controllers/agent.go b/controllers/agent.go
index fd8fc6a..8dd88bc 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -98,7 +98,8 @@ func (c *ApiController) GetAgents() {
 // An installation whose configuration file Gateway already wrote is rewritten
 // here: in gateway mode the file does not change, which is what makes a switch
 // take effect without restarting the agent, but in direct mode the new provider
-// only reaches the agent through its own configuration.
+// only reaches the agent through its own configuration. Unbinding the agent
+// puts that configuration back the way it was found.
 func (c *ApiController) UpdateAgentRouting() {
 	if c.RequireAdmin() {
 		return
@@ -129,11 +130,14 @@ func (c *ApiController) UpdateAgentRouting() {
 		return
 	}
 
-	if form.Provider != "" {
-		if failure := reapplyAgentProvider(form.AgentId); failure != "" {
-			c.ResponseError("the routing was saved, but the agent configuration was not rewritten: " + failure)
+	if form.Provider == "" {
+		if failure := restoreAgentProvider(form.AgentId); failure != "" {
+			c.ResponseError("the routing was cleared, but the agent configuration was not restored: " + failure)
 			return
 		}
+	} else if failure := reapplyAgentProvider(form.AgentId); failure != "" {
+		c.ResponseError("the routing was saved, but the agent configuration was not rewritten: " + failure)
+		return
 	}
 	c.ResponseOk(form.Provider)
 }
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index 9b5af02..e192adc 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -215,6 +215,28 @@ func reapplyAgentProvider(agentId string) string {
 	return strings.Join(failures, "; ")
 }
 
+// restoreAgentProvider puts back the configuration of every installation
+// Gateway wrote, which is what unbinding an agent means for the files it left
+// behind: they would otherwise keep pointing at a gateway URL that no longer
+// has a provider to forward to.
+func restoreAgentProvider(agentId string) string {
+	installations, err := agent.Scan(false)
+	if err != nil {
+		return err.Error()
+	}
+
+	failures := []string{}
+	for _, installation := range installations {
+		if installation.AgentId != agentId {
+			continue
+		}
+		if err := agentprovider.Restore(providerTarget(targetOf(installation))); err != nil {
+			failures = append(failures, err.Error())
+		}
+	}
+	return strings.Join(failures, "; ")
+}
+
 func providerTarget(target agentpatch.Target) agentprovider.Target {
 	return agentprovider.Target{AgentId: target.AgentId, Path: target.Path, Owner: target.Owner}
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org