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]
