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

commit df81802ad1597d5e72c8096afe82fe1ccd387a56
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 6 15:25:17 2026 +0800

    feat(providers): add a ChatGPT subscription source that forwards Codex's 
own sign-in
---
 agentprovider/codex.go                             | 20 ++++++++++----
 agentprovider/provider.go                          |  4 +++
 controllers/agent_provider.go                      |  3 ++-
 controllers/proxy.go                               |  3 +++
 .../user-manual/en/3-providers/3.3-caller-login.md |  6 +++--
 docs/user-manual/en/6-faq/6.1-faq.md               |  2 +-
 .../user-manual/zh/3-providers/3.3-caller-login.md |  6 +++--
 docs/user-manual/zh/6-faq/6.1-faq.md               |  2 +-
 object/provider.go                                 |  7 ++++-
 web/src/components/ProviderSourcePicker.tsx        | 20 ++++++++++----
 web/src/lib/providers.ts                           | 31 +++++++++++++++++-----
 web/src/locales/en/data.json                       |  6 +++--
 web/src/locales/zh/data.json                       |  6 +++--
 13 files changed, 87 insertions(+), 29 deletions(-)

diff --git a/agentprovider/codex.go b/agentprovider/codex.go
index 1a0443d..bd98a70 100644
--- a/agentprovider/codex.go
+++ b/agentprovider/codex.go
@@ -40,6 +40,10 @@ const (
        // provider, which is the one place Codex reads it from without an
        // environment variable and without a sign-in of its own.
        codexAuthHeader = "Authorization"
+       // codexOpenAiAuthKey tells Codex to send the ChatGPT sign-in it 
already has
+       // to this provider instead of looking for a key of its own. It is what 
puts a
+       // subscription through the gateway, which forwards it to the Codex 
backend.
+       codexOpenAiAuthKey = "requires_openai_auth"
 )
 
 // The keys of the root table Gateway owns, remembered under these names.
@@ -58,10 +62,10 @@ var (
        codexHeaderPath   = []string{"model_providers", codexProviderName, 
"http_headers"}
 )
 
-// errCodexNoKey rejects a provider that forwards the caller's own credentials:
-// 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")
+// errCodexNoKey rejects a provider with nothing to authenticate with. A
+// client-auth provider is not one of them: there Codex sends the sign-in it
+// already has, and the entry says so instead of carrying a key.
+var errCodexNoKey = errors.New("Codex has no key to send this provider, and 
the provider does not forward the caller's own login either")
 
 // errCodexResponsesApi rejects writing an upstream Codex cannot reach. Codex
 // dropped the chat completions wire format, so a provider that serves only 
that
@@ -267,7 +271,7 @@ func (w codexWriter) document(target Target) 
(map[string]any, error) {
 
 // check reports why Codex cannot be pointed at endpoint.
 func (codexWriter) check(endpoint Endpoint) error {
-       if endpoint.ApiKey == "" {
+       if endpoint.ApiKey == "" && !endpoint.ClientAuth {
                return errCodexNoKey
        }
        if !endpoint.ServesResponsesApi {
@@ -279,6 +283,8 @@ func (codexWriter) check(endpoint Endpoint) error {
 // 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.
+// A client-auth provider has no key to write: the entry asks Codex for the
+// sign-in it already holds, which is the credential forwarded upstream.
 func (codexWriter) providerTable(endpoint Endpoint) string {
        table := tomlTable(codexProviderPath,
                []string{"name", "base_url", "wire_api"},
@@ -287,6 +293,10 @@ func (codexWriter) providerTable(endpoint Endpoint) string 
{
                        "base_url": endpoint.BaseUrl,
                        "wire_api": "responses",
                })
+       if endpoint.ClientAuth {
+               return table + codexOpenAiAuthKey + " = true\n"
+       }
+
        headers := tomlTable(codexHeaderPath,
                []string{codexAuthHeader},
                map[string]string{codexAuthHeader: "Bearer " + endpoint.ApiKey})
diff --git a/agentprovider/provider.go b/agentprovider/provider.go
index a676ba3..06be713 100644
--- a/agentprovider/provider.go
+++ b/agentprovider/provider.go
@@ -65,6 +65,10 @@ type Endpoint struct {
        // API, which is all Codex speaks. The gateway always does, since it
        // translates; a provider's own upstream usually stops at chat 
completions.
        ServesResponsesApi bool `json:"servesResponsesApi"`
+       // ClientAuth reports whether the provider forwards the credentials the
+       // agent itself sends, which is why there is no key to write into its
+       // configuration: the sign-in it already has is the one being used.
+       ClientAuth bool `json:"clientAuth"`
 }
 
 // File is one configuration file a switch writes, with the section it will
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index 95d3cd9..bbf4d70 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -158,6 +158,7 @@ func agentEndpoint(agentId string) (agentprovider.Endpoint, 
error) {
 
        endpoint.Provider = provider.GetId()
        endpoint.Protocol = object.ProviderApiFamily(provider)
+       endpoint.ClientAuth = object.UsesClientAuth(provider)
        if len(provider.Models) > 0 {
                endpoint.Model = provider.Models[0]
                endpoint.Models = provider.Models
@@ -179,7 +180,7 @@ func agentEndpoint(agentId string) (agentprovider.Endpoint, 
error) {
        // A client-auth provider forwards whatever the agent sends, so it must 
keep
        // sending its own credentials: a placeholder token written into the 
agent's
        // configuration would replace the sign-in it already has.
-       if !object.UsesClientAuth(provider) {
+       if !endpoint.ClientAuth {
                endpoint.ApiKey = conf.GetRelayToken()
        }
        return endpoint, nil
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 2c5178b..91cbbbd 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -683,6 +683,9 @@ var clientAuthHeaders = []string{
        "Openai-Organization",
        "Openai-Project",
        "Chatgpt-Account-Id",
+       "Originator",
+       "Session-Id",
+       "Thread-Id",
 }
 
 // hasClientCredentials reports whether the client request carries a credential
diff --git a/docs/user-manual/en/3-providers/3.3-caller-login.md 
b/docs/user-manual/en/3-providers/3.3-caller-login.md
index 23271d3..e3b77b1 100644
--- a/docs/user-manual/en/3-providers/3.3-caller-login.md
+++ b/docs/user-manual/en/3-providers/3.3-caller-login.md
@@ -28,9 +28,11 @@ Everything except holding the key:
 - Permissions over tools, models and providers 
([4.1](../4-governance/4.1-permissions.md)).
 - Usage from the relayed traffic, on top of what the transcripts already gave 
you ([4.3](../4-governance/4.3-usage-and-pricing.md)).
 
-## The exception
+## Codex
 
-**Codex.** Its ChatGPT sign-in talks to an endpoint no provider stands in for, 
so a Codex CLI still needs a provider with an API key.
+Codex signs in against the Codex backend rather than the OpenAI API, so its 
provider points there: pick the **ChatGPT subscription** source, or set the 
base URL to `https://chatgpt.com/backend-api/codex` and the upstream API to 
Responses yourself.
+
+**Write configuration** then puts `requires_openai_auth = true` in 
`~/.codex/config.toml` in place of a key, which is what makes Codex send the 
ChatGPT sign-in it already has. Gateway forwards it upstream and stores none of 
it.
 
 Codex's spend is still on the 
[Usage](../4-governance/4.3-usage-and-pricing.md) page either way, because that 
page reads the agent's own transcripts rather than the relay.
 
diff --git a/docs/user-manual/en/6-faq/6.1-faq.md 
b/docs/user-manual/en/6-faq/6.1-faq.md
index 3861656..85c093e 100644
--- a/docs/user-manual/en/6-faq/6.1-faq.md
+++ b/docs/user-manual/en/6-faq/6.1-faq.md
@@ -23,7 +23,7 @@ Whichever API its own documentation describes. The type says 
what the provider s
 Yes. Codex speaks OpenAI Responses and nothing else; Gateway translates for 
it. The same goes for Claude Code and the Gemini CLI on any provider 
([3.2](../3-providers/3.2-routing.md)).
 
 **My agent is on a ChatGPT or Claude subscription and I have no key.**
-Set the provider's **Authentication** to the caller's own login 
([3.3](../3-providers/3.3-caller-login.md)). Codex is the exception — its 
ChatGPT sign-in needs an endpoint no provider stands in for, so it still needs 
a key.
+Pick the **Claude subscription** or **ChatGPT subscription** source, which 
sets **Authentication** to the caller's own login for you 
([3.3](../3-providers/3.3-caller-login.md)).
 
 **What is `cg-...` in the environment snippet?**
 Gateway's own relay token, not a vendor key. The agent needs *something* in 
that variable; Gateway authenticates upstream with the provider's key instead 
([3.2](../3-providers/3.2-routing.md)).
diff --git a/docs/user-manual/zh/3-providers/3.3-caller-login.md 
b/docs/user-manual/zh/3-providers/3.3-caller-login.md
index f9a6fe4..35966a7 100644
--- a/docs/user-manual/zh/3-providers/3.3-caller-login.md
+++ b/docs/user-manual/zh/3-providers/3.3-caller-login.md
@@ -28,9 +28,11 @@ export 
ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code";
 - 对工具、模型、供应商的权限控制([4.1](../4-governance/4.1-permissions.md))。
 - 在会话记录已有数据之上,再加一份来自转发流量的用量([4.3](../4-governance/4.3-usage-and-pricing.md))。
 
-## 例外
+## Codex
 
-**Codex。**它的ChatGPT登录对接的是一个没有供应商能替代的端点,所以Codex CLI仍然需要一个带API Key的供应商。
+Codex的ChatGPT登录对接的不是OpenAI的API,而是Codex后端,所以它的供应商也指向那里:新建时选**ChatGPT订阅**这个来源,或者自己把基础URL填成`https://chatgpt.com/backend-api/codex`、上游API选Responses。
+
+这时**写入配置**往`~/.codex/config.toml`里写的不是Key,而是`requires_openai_auth = 
true`——Codex由此把自己已有的ChatGPT登录发过来,Gateway原样转发上游,一个字都不留。
 
 
不管怎样,Codex的花费都会出现在[用量](../4-governance/4.3-usage-and-pricing.md)页上,因为那一页读的是Agent自己的会话记录,而不是转发记录。
 
diff --git a/docs/user-manual/zh/6-faq/6.1-faq.md 
b/docs/user-manual/zh/6-faq/6.1-faq.md
index f56eff9..462ea89 100644
--- a/docs/user-manual/zh/6-faq/6.1-faq.md
+++ b/docs/user-manual/zh/6-faq/6.1-faq.md
@@ -23,7 +23,7 @@
 能。Codex只说OpenAI Responses,Gateway替它转换。Claude Code和Gemini 
CLI接任意供应商同理([3.2](../3-providers/3.2-routing.md))。
 
 **我的Agent用的是ChatGPT或Claude订阅,没有Key。**
-把供应商的**认证方式**设成调用方自己的登录([3.3](../3-providers/3.3-caller-login.md))。Codex是例外——它的ChatGPT登录对接的端点没有供应商能替代,所以它仍然需要一个Key。
+新建供应商时选**Claude订阅**或**ChatGPT订阅**这两个来源,**认证方式**会自动设成调用方自己的登录([3.3](../3-providers/3.3-caller-login.md))。
 
 **环境变量里的`cg-...`是什么?**
 
Gateway自己的中继令牌,不是厂商的Key。Agent那个变量必须有东西,而Gateway对上游用的是供应商的Key([3.2](../3-providers/3.2-routing.md))。
diff --git a/object/provider.go b/object/provider.go
index ea6661a..1912d9d 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -426,6 +426,11 @@ func validateBaseUrl(baseUrl string) error {
        return nil
 }
 
+// ChatGptCodexPath is where the Codex requests of a ChatGPT subscription go.
+// It names no version and serves the endpoint straight off the path, so the
+// /v1 a versionless base URL gets would miss it.
+const ChatGptCodexPath = "/backend-api/codex"
+
 // apiVersionSegment matches a path segment that names an API version, which is
 // how a base URL says it is already the root of the API: v1, v3, v1beta.
 var apiVersionSegment = regexp.MustCompile(`^v[0-9]`)
@@ -458,7 +463,7 @@ func BuildOpenAiUrl(baseUrl string, endpoint string) 
(string, error) {
        }
 
        path := strings.TrimSuffix(strings.TrimRight(u.Path, "/"), endpoint)
-       if !namesApiVersion(path) {
+       if !namesApiVersion(path) && !strings.HasSuffix(path, ChatGptCodexPath) 
{
                path += "/v1"
        }
 
diff --git a/web/src/components/ProviderSourcePicker.tsx 
b/web/src/components/ProviderSourcePicker.tsx
index 2bf4d22..a764eaf 100644
--- a/web/src/components/ProviderSourcePicker.tsx
+++ b/web/src/components/ProviderSourcePicker.tsx
@@ -21,6 +21,7 @@ import {Button} from "@/components/ui/button";
 import {Input} from "@/components/ui/input";
 import {
   providerSources,
+  chatgptSource,
   customSource,
   subscriptionSource,
   vendorCategories,
@@ -30,13 +31,16 @@ import {
 
 /**
  * The title and the line under it. A vendor card is named after the vendor and
- * says where its requests go; the two cards that are not a vendor say what 
they
- * are instead, because their base URL would not.
+ * says where its requests go; the cards that are not a vendor say what they 
are
+ * instead, because their base URL would not.
  */
 export function sourceTitle(source: ProviderSource) {
   if (source.key === subscriptionSource) {
     return i18next.t("provider:Claude subscription");
   }
+  if (source.key === chatgptSource) {
+    return i18next.t("provider:ChatGPT subscription");
+  }
   if (source.key === customSource) {
     return i18next.t("provider:Custom vendor");
   }
@@ -47,6 +51,9 @@ function sourceDetail(source: ProviderSource) {
   if (source.key === subscriptionSource) {
     return i18next.t("provider:Claude subscription detail");
   }
+  if (source.key === chatgptSource) {
+    return i18next.t("provider:ChatGPT subscription detail");
+  }
   if (source.key === customSource) {
     return i18next.t("provider:Custom vendor detail");
   }
@@ -64,7 +71,7 @@ function categoryTitle(category: VendorCategory) {
 function SourceCard({source, onPick}: {source: ProviderSource; onPick: 
(source: ProviderSource) => void}) {
   // Only reached when the vendor's own mark will not load.
   const Icon =
-    source.key === subscriptionSource ? LogIn : source.key === customSource ? 
Settings2 : KeyRound;
+    source.key === customSource ? Settings2 : source.category === undefined ? 
LogIn : KeyRound;
 
   return (
     <button
@@ -118,13 +125,16 @@ export function ProviderSourcePicker({
   };
 
   const needle = query.trim().toLowerCase();
+  // The line under the title is searched too: the card a subscription is added
+  // from is named after the client, not after the vendor someone types.
   const matches = (source: ProviderSource) =>
     needle === "" ||
     sourceTitle(source).toLowerCase().includes(needle) ||
+    sourceDetail(source).toLowerCase().includes(needle) ||
     (source.provider.baseUrl ?? "").toLowerCase().includes(needle);
 
-  // The two cards that are not a vendor lead, without a heading of their own:
-  // they are the answers for a vendor that is not on the list at all.
+  // The cards that are not a vendor lead, without a heading of their own: they
+  // are the sign-ins, and the answer for a vendor not on the list at all.
   const leading = providerSources.filter(source => source.category === 
undefined).filter(matches);
   const groups = vendorCategories
     .map(category => ({
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index 69f88dc..3a207b9 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -520,11 +520,11 @@ export const providerPresets: ProviderPreset[] = [
 
 /** Where a new provider gets its credentials: one card of the picker. */
 export interface ProviderSource {
-  /** Stable id. The two cards that are not a vendor are titled by the picker. 
*/
+  /** Stable id. The cards that are not a vendor are titled by the picker. */
   key: string;
   /** The vendor's own name, empty when the picker titles the card itself. */
   label: string;
-  /** Absent on the two cards that are not a vendor, which lead the picker. */
+  /** Absent on the cards that are not a vendor, which lead the picker. */
   category?: VendorCategory;
   /** Where the vendor hands out API keys. */
   website?: string;
@@ -533,14 +533,17 @@ export interface ProviderSource {
 }
 
 export const subscriptionSource = "subscription";
+export const chatgptSource = "chatgpt";
 export const customSource = "custom";
 
+/** Where a ChatGPT subscription's Codex requests go, which is not the API. */
+export const chatgptCodexBaseUrl = "https://chatgpt.com/backend-api/codex";;
+
 /**
- * The sign-in comes first: it is the only source that needs nothing filled in,
- * and the one people with a subscription and no API key are here for. 
Anthropic
- * is the vendor of the clients that mode works with; Codex signs in against an
- * API Gateway does not relay. Custom follows it, because the two of them are
- * the answers for a vendor that is not on the list at all.
+ * The sign-ins come first: they are the only sources that need nothing filled
+ * in, and the ones people with a subscription and no API key are here for. One
+ * per vendor whose client signs in rather than carrying a key: Claude Code and
+ * Codex. Custom follows them, for a vendor that is not on the list at all.
  */
 export const providerSources: ProviderSource[] = [
   {
@@ -554,6 +557,20 @@ export const providerSources: ProviderSource[] = [
       authMode: authClient,
     },
   },
+  {
+    key: chatgptSource,
+    label: "",
+    provider: {
+      type: "openai",
+      // Codex signs in against the ChatGPT backend rather than the API, and
+      // that endpoint serves the Responses format alone.
+      protocol: "responses",
+      baseUrl: chatgptCodexBaseUrl,
+      models: [],
+      apiKey: "",
+      authMode: authClient,
+    },
+  },
   {
     key: customSource,
     label: "",
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index d18c399..4532efa 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -1225,10 +1225,12 @@
     "Bring it all over": "Bring it all over",
     "Caller's own login": "The caller's own login",
     "Change source": "Change",
+    "ChatGPT subscription": "ChatGPT subscription",
+    "ChatGPT subscription detail": "The ChatGPT sign-in Codex already has, 
forwarded to OpenAI's Codex backend. No API key, nothing to fill in.",
     "Claude subscription": "Claude subscription",
-    "Claude subscription detail": "The sign-in Claude Code already has, 
forwarded upstream. No API key, nothing to fill in. Codex is the exception: it 
signs in against an API Gateway does not relay.",
+    "Claude subscription detail": "The sign-in Claude Code already has, 
forwarded upstream. No API key, nothing to fill in.",
     "Clear": "Clear",
-    "Client auth hint": "Requests are forwarded with the credentials the 
client sent, so an agent already signed in with a subscription keeps its own 
login and no key is stored here. Codex is the exception: its ChatGPT sign-in 
speaks a different API.",
+    "Client auth hint": "Requests are forwarded with the credentials the 
client sent, so an agent already signed in with a subscription keeps its own 
login and no key is stored here. Only the agent that holds that sign-in can use 
this provider.",
     "Client auth usage hint": "Only the base URL is set: a token here would 
replace the sign-in the client already has.",
     "Coming from CC Switch": "Coming from CC Switch",
     "Coming from CC Switch hint": "Bring the providers you set up there over 
in one go, along with its MCP servers, instructions and skill sources.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index ea89dbd..758219b 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -1396,10 +1396,12 @@
     "Bring it all over": "全部导入",
     "Caller's own login": "调用方自己的登录",
     "Change source": "更换",
+    "ChatGPT subscription": "ChatGPT订阅",
+    "ChatGPT subscription detail": 
"直接用Codex已有的ChatGPT登录,转发给OpenAI的Codex后端。不需要API Key,什么都不用填。",
     "Claude subscription": "Claude订阅登录",
-    "Claude subscription detail": "直接用Claude Code已有的登录,原样转发给上游。不需要API 
Key,什么都不用填。Codex除外:它的登录所对的API不在Gateway的转发范围内。",
+    "Claude subscription detail": "直接用Claude Code已有的登录,原样转发给上游。不需要API 
Key,什么都不用填。",
     "Clear": "清空",
-    "Client auth hint": 
"请求会带着客户端自己的凭据转发上游,已经用订阅登录过的Agent可以继续用自己的登录,这里不保存任何Key。Codex除外:它的ChatGPT登录走的是另一套API。",
+    "Client auth hint": 
"请求会带着客户端自己的凭据转发上游,已经用订阅登录过的Agent可以继续用自己的登录,这里不保存任何Key。只有握着那个登录的Agent能用这个供应商。",
     "Client auth usage hint": "只设置base URL:这里再设一个token会覆盖客户端已有的登录。",
     "Coming from CC Switch": "从CC Switch迁移过来",
     "Coming from CC Switch hint": "把你在那边配好的供应商一次性导入,连同MCP服务器、指令和技能来源。",


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to