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/09/06 08:17:25 UTC

(casbin-gateway) 03/03: feat(providers): let Gateway hold a ChatGPT subscription and spend it like a key

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 9b01f667bc086f7ac243cf4d6b0a65aa6959476b
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sun Sep 6 16:00:07 2026 +0800

    feat(providers): let Gateway hold a ChatGPT subscription and spend it like a key
---
 agent/account.go                                   |   6 +-
 controllers/provider_login.go                      |  51 ++
 controllers/proxy.go                               |  11 +
 .../user-manual/en/3-providers/3.3-caller-login.md |   8 +-
 docs/user-manual/en/6-faq/6.1-faq.md               |   2 +-
 .../user-manual/zh/3-providers/3.3-caller-login.md |   8 +-
 docs/user-manual/zh/6-faq/6.1-faq.md               |   2 +-
 object/provider.go                                 | 151 ++++-
 object/provider_subscription.go                    | 620 +++++++++++++++++++++
 object/snapshot.go                                 |   3 +
 routers/router.go                                  |   2 +
 web/src/backend/ProviderBackend.ts                 |  14 +
 web/src/components/ProviderGridCard.tsx            |  14 +-
 web/src/components/ProviderLoginField.tsx          | 146 +++++
 web/src/components/ProviderModelsField.tsx         |   6 +-
 web/src/lib/providers.ts                           |  38 +-
 web/src/locales/en/data.json                       |  12 +-
 web/src/locales/zh/data.json                       |  12 +-
 web/src/pages/ProviderEditPage.tsx                 |  34 +-
 web/src/pages/ProviderListPage.tsx                 |  36 +-
 web/src/types.ts                                   |  21 +
 21 files changed, 1143 insertions(+), 54 deletions(-)

diff --git a/agent/account.go b/agent/account.go
index acfe77f..6777546 100644
--- a/agent/account.go
+++ b/agent/account.go
@@ -154,7 +154,7 @@ func codexAccountOf(data []byte) *Account {
 	// The id token carries email and name as standard claims, the access token
 	// carries them nested in a profile object; either may be present.
 	for _, token := range []string{auth.Tokens.IDToken, auth.Tokens.AccessToken} {
-		claims := decodeJWTClaims(token)
+		claims := DecodeJWTClaims(token)
 		if claims == nil {
 			continue
 		}
@@ -283,9 +283,9 @@ func idbMarkedString(data []byte, field string) string {
 	return string(value)
 }
 
-// decodeJWTClaims returns the claim set of a JWT without verifying it: the
+// DecodeJWTClaims returns the claim set of a JWT without verifying it: the
 // account fields are read from local state the user already trusts.
-func decodeJWTClaims(token string) map[string]any {
+func DecodeJWTClaims(token string) map[string]any {
 	parts := strings.Split(token, ".")
 	if len(parts) != 3 {
 		return nil
diff --git a/controllers/provider_login.go b/controllers/provider_login.go
new file mode 100644
index 0000000..753661a
--- /dev/null
+++ b/controllers/provider_login.go
@@ -0,0 +1,51 @@
+// 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 controllers
+
+import (
+	"github.com/apache/casbin-gateway/object"
+)
+
+// SignInProvider starts a browser sign-in for a vendor whose subscription
+// Gateway can hold, and answers with the address to approve it at. The token it
+// brings back stays here: the page carries only the id of the sign-in, which
+// the save of the provider redeems.
+func (c *ApiController) SignInProvider() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	session, err := object.StartSubscriptionLogin(c.Input().Get("vendor"))
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk(session)
+}
+
+// GetProviderSignin reports how a sign-in that was started is getting on. The
+// page polls it: approving one takes as long as whoever is at the browser.
+func (c *ApiController) GetProviderSignin() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	session, ok := object.SubscriptionLoginSession(c.Input().Get("id"))
+	if !ok {
+		c.ResponseError("no sign-in was started under this id")
+		return
+	}
+	c.ResponseOk(session)
+}
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 91cbbbd..a26b0d2 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -537,6 +537,14 @@ func (c *ApiController) forwardToProvider(attempt object.RouteAttempt, route *pr
 		return http.StatusBadGateway, "upstream connection failed", false
 	}
 	upstreamReq.Header.Set("Content-Type", "application/json")
+	// The sign-in a subscription provider spends is renewed here rather than on
+	// a timer, so a token is only refreshed when something is about to send it.
+	if object.UsesSubscription(provider) {
+		if err := object.EnsureSubscription(provider); err != nil {
+			object.ReportProviderFailure(provider.GetId(), err.Error())
+			return http.StatusBadGateway, err.Error(), false
+		}
+	}
 	object.SetProviderAuth(upstreamReq.Header, provider)
 	if object.UsesClientAuth(provider) {
 		c.copyClientAuthHeaders(upstreamReq.Header, upstream)
@@ -938,6 +946,9 @@ func (c *ApiController) providerUnusableReason(provider *object.Provider) string
 	if object.UsesClientAuth(provider) && !c.hasClientCredentials() {
 		return fmt.Sprintf("provider %s forwards the credentials of the caller, but the request carries none", provider.GetId())
 	}
+	if object.UsesSubscription(provider) && !object.HasSubscription(provider) {
+		return fmt.Sprintf("provider %s spends a subscription, but it is not signed in", provider.GetId())
+	}
 	return ""
 }
 
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 e3b77b1..b48a43e 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,11 +28,13 @@ 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)).
 
-## Codex
+## A ChatGPT subscription
 
-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.
+A ChatGPT sign-in talks to the Codex backend rather than the OpenAI API, so the provider points there: `https://chatgpt.com/backend-api/codex`, with the upstream API set to Responses. Either side of the sign-in can hold it.
 
-**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.
+**Gateway holds it.** Pick the **ChatGPT subscription** source and press **Sign in**: the vendor's own page opens in your browser, and what it grants is stored here — encrypted like a key, renewed before it expires. Every agent can then spend that subscription, in whatever API it speaks, exactly as it would spend a key.
+
+**Codex holds it.** Set **Authentication** to the caller's own login instead. **Write configuration** then puts `requires_openai_auth = true` in `~/.codex/config.toml` in place of a key, which makes Codex send the ChatGPT sign-in it already has. Gateway forwards it upstream and stores none of it — but only Codex can use such a provider, because only Codex holds that sign-in.
 
 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 85c093e..8460c06 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.**
-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)).
+Pick the **ChatGPT subscription** source and sign in: Gateway holds that subscription and spends it like a key. For a Claude one, pick **Claude subscription**, which forwards the sign-in Claude Code already has ([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 35966a7..f712952 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,11 +28,13 @@ 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
+## ChatGPT订阅
 
-Codex的ChatGPT登录对接的不是OpenAI的API,而是Codex后端,所以它的供应商也指向那里:新建时选**ChatGPT订阅**这个来源,或者自己把基础URL填成`https://chatgpt.com/backend-api/codex`、上游API选Responses。
+ChatGPT的登录对接的不是OpenAI的API,而是Codex后端,所以供应商也指向那里:`https://chatgpt.com/backend-api/codex`,上游API选Responses。这个登录放在哪一边都行。
 
-这时**写入配置**往`~/.codex/config.toml`里写的不是Key,而是`requires_openai_auth = true`——Codex由此把自己已有的ChatGPT登录发过来,Gateway原样转发上游,一个字都不留。
+**放在Gateway。**新建供应商时选**ChatGPT订阅**这个来源,点**登录**,厂商自己的页面会在浏览器里打开;拿回来的令牌存在这边,和Key一样加密,过期前自动续。之后任何Agent都能花这个订阅,不管它说的是哪套API,跟花一个Key没有区别。
+
+**放在Codex。**把**认证方式**设成调用方自己的登录。这时**写入配置**往`~/.codex/config.toml`里写的不是Key,而是`requires_openai_auth = true`——Codex把自己已有的ChatGPT登录发过来,Gateway原样转发,一个字都不留。但这样的供应商只有Codex能用,因为只有它手上有那个登录。
 
 不管怎样,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 462ea89..ffa4afd 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。**
-新建供应商时选**Claude订阅**或**ChatGPT订阅**这两个来源,**认证方式**会自动设成调用方自己的登录([3.3](../3-providers/3.3-caller-login.md))。
+选**ChatGPT订阅**这个来源,点登录,Gateway会保管这个订阅,像Key一样花。Claude的话选**Claude订阅**,它转发Claude Code已有的登录([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 1912d9d..7532847 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -70,6 +70,51 @@ func encryptApiKey(provider *Provider) error {
 	return nil
 }
 
+// subscriptionAad keeps a sign-in's ciphertext from being usable in the api_key
+// column of the same row, or in any other row.
+func subscriptionAad(provider *Provider) string {
+	return provider.GetId() + "/subscription"
+}
+
+// encryptedSubscription is one sign-in as it is stored, empty for none.
+func encryptedSubscription(provider *Provider, subscription *ProviderSubscription) (string, error) {
+	if subscription == nil {
+		return "", nil
+	}
+
+	plain, err := json.Marshal(subscription)
+	if err != nil {
+		return "", err
+	}
+	return util.EncryptWithKey(apiKeyEncryptionSecret(), string(plain), subscriptionAad(provider))
+}
+
+func encryptSubscription(provider *Provider) error {
+	if provider.Subscription == "" {
+		return nil
+	}
+
+	encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), provider.Subscription, subscriptionAad(provider))
+	if err != nil {
+		return err
+	}
+	provider.Subscription = encrypted
+	return nil
+}
+
+func decryptSubscription(provider *Provider) {
+	if provider.Subscription == "" {
+		return
+	}
+
+	plain, err := util.DecryptWithKey(apiKeyEncryptionSecret(), provider.Subscription, subscriptionAad(provider))
+	if err != nil {
+		fmt.Printf("decryptSubscription(): provider [%s]: %v\n", provider.GetId(), err)
+		return
+	}
+	provider.Subscription = plain
+}
+
 // quotaTokenAad keeps the quota token's ciphertext from being usable in the
 // api_key column of the same row, or in any other row.
 func quotaTokenAad(provider *Provider) string {
@@ -123,6 +168,7 @@ func decryptProvider(provider *Provider) {
 	}
 
 	decryptQuotaToken(provider)
+	decryptSubscription(provider)
 
 	secret := apiKeyEncryptionSecret()
 	stored := provider.ApiKey
@@ -188,9 +234,12 @@ var (
 const (
 	ProviderAuthProvider = "provider"
 	ProviderAuthClient   = "client"
+	// ProviderAuthSubscription is a sign-in Gateway holds and renews itself, so
+	// a subscription is used from here exactly like a key.
+	ProviderAuthSubscription = "subscription"
 )
 
-var providerAuthModes = []string{ProviderAuthProvider, ProviderAuthClient}
+var providerAuthModes = []string{ProviderAuthProvider, ProviderAuthClient, ProviderAuthSubscription}
 
 // UsesClientAuth reports whether the provider authenticates with the caller's
 // own credentials. An empty AuthMode is a row written before the field existed.
@@ -198,6 +247,12 @@ func UsesClientAuth(provider *Provider) bool {
 	return provider.AuthMode == ProviderAuthClient
 }
 
+// ServesAnyModel reports whether the provider names no models on purpose: what
+// a sign-in may ask for is decided by the account behind it, not by this table.
+func ServesAnyModel(provider *Provider) bool {
+	return UsesClientAuth(provider) || UsesSubscription(provider)
+}
+
 // The wire formats a provider's upstream can serve. A request that arrived in
 // another one is translated by the proxy, see the protocol package.
 const (
@@ -269,6 +324,18 @@ type Provider struct {
 	ApiKey string `xorm:"varchar(1000)" json:"apiKey"`
 	// AuthMode selects whose credentials reach the upstream, see UsesClientAuth.
 	AuthMode string `xorm:"varchar(100)" json:"authMode"`
+	// Subscription is the sign-in of a ProviderAuthSubscription provider, stored
+	// as ciphertext like the key. Only the sign-in flow writes it, and it leaves
+	// this process no more than the key does.
+	Subscription string `xorm:"mediumtext" json:"-"`
+	// LoginId names a finished sign-in whose credential this save takes over,
+	// which is how a token reaches the row without passing through the browser.
+	LoginId string `xorm:"-" json:"loginId,omitempty"`
+	// These three describe the stored sign-in, so the page can say whose account
+	// a provider spends and offer to sign in again where it was granted.
+	SubscriptionVendor  string `xorm:"-" json:"subscriptionVendor,omitempty"`
+	SubscriptionAccount string `xorm:"-" json:"subscriptionAccount,omitempty"`
+	SubscriptionPlan    string `xorm:"-" json:"subscriptionPlan,omitempty"`
 	// Models is JSON-serialized by xorm, so it needs a text column rather than
 	// a varchar: the serialized form is longer than the joined model names.
 	Models []string `xorm:"mediumtext" json:"models"`
@@ -340,6 +407,8 @@ func GetMaskedProvider(provider *Provider) *Provider {
 	if masked.ApiKey != "" {
 		masked.ApiKey = ApiKeyMask
 	}
+	describeSubscription(&masked)
+	masked.Subscription = ""
 	// The quota token shares a column with the rest of the quota configuration,
 	// so the whole struct is copied before the token is replaced.
 	if masked.Quota != nil && masked.Quota.Token != "" {
@@ -385,9 +454,10 @@ func validateProvider(provider *Provider) error {
 		return fmt.Errorf("invalid provider auth mode: %s", provider.AuthMode)
 	}
 
-	// A provider that forwards the caller's credentials has no use for a stored
-	// key, and one left behind would be sent upstream again after a switch back.
-	if provider.AuthMode == ProviderAuthClient {
+	// A provider that forwards the caller's credentials, or spends a sign-in of
+	// its own, has no use for a stored key, and one left behind would be sent
+	// upstream again after a switch back.
+	if provider.AuthMode != ProviderAuthProvider {
 		provider.ApiKey = ""
 	}
 
@@ -527,6 +597,10 @@ func SetProviderAuth(header http.Header, provider *Provider) {
 	if UsesClientAuth(provider) {
 		return
 	}
+	if UsesSubscription(provider) {
+		SetSubscriptionAuth(header, provider)
+		return
+	}
 
 	if ProviderProtocol(provider) == ProtocolAnthropic {
 		header.Set("X-Api-Key", provider.ApiKey)
@@ -535,6 +609,32 @@ func SetProviderAuth(header http.Header, provider *Provider) {
 	header.Set("Authorization", "Bearer "+provider.ApiKey)
 }
 
+// takeProviderLogin moves the credential of a finished sign-in onto the row
+// being saved. A save that brings none leaves the stored one alone, which is
+// what makes editing the rest of a signed-in provider harmless.
+func takeProviderLogin(provider *Provider) error {
+	loginId := provider.LoginId
+	provider.LoginId = ""
+	provider.Subscription = ""
+
+	if loginId != "" {
+		granted, err := takeSubscriptionLogin(loginId)
+		if err != nil {
+			return err
+		}
+		plain, err := json.Marshal(granted)
+		if err != nil {
+			return err
+		}
+		provider.Subscription = string(plain)
+	}
+
+	if !UsesSubscription(provider) {
+		provider.Subscription = ""
+	}
+	return nil
+}
+
 func AddProvider(provider *Provider) (bool, error) {
 	if err := validateProvider(provider); err != nil {
 		return false, err
@@ -561,11 +661,17 @@ func AddProvider(provider *Provider) (bool, error) {
 	if err := encryptQuotaToken(provider); err != nil {
 		return false, err
 	}
+	if err := takeProviderLogin(provider); err != nil {
+		return false, err
+	}
 	// The probe needs the key as it was typed, and encryption is in place.
 	probeTarget := *provider
 	if err := encryptApiKey(provider); err != nil {
 		return false, err
 	}
+	if err := encryptSubscription(provider); err != nil {
+		return false, err
+	}
 
 	affected, err := ormer.Engine.Insert(provider)
 	if err == nil && affected != 0 {
@@ -633,7 +739,19 @@ func UpdateProvider(id string, provider *Provider) (bool, error) {
 		return false, err
 	}
 
+	if err := takeProviderLogin(provider); err != nil {
+		return false, err
+	}
+
 	session := ormer.Engine.ID(core.PK{owner, name})
+	// The sign-in never reaches the browser, so a save that carries none is
+	// leaving the stored one alone rather than clearing it. Switching the
+	// provider off a subscription does clear it: it is spent by nothing now.
+	if provider.Subscription == "" && UsesSubscription(provider) {
+		session = session.Omit("subscription")
+	} else if err = encryptSubscription(provider); err != nil {
+		return false, err
+	}
 	// The browser only ever sees the mask, so getting it back means the user
 	// did not touch the field. Any other value (including "") is written, which
 	// is what makes clearing a key possible.
@@ -773,10 +891,10 @@ func TestProviderConnectivity(provider *Provider) (bool, int, string) {
 		return false, 0, err.Error()
 	}
 
-	// A client-auth provider carries no credential of its own, so a completion
-	// would only ever measure the missing key. All that can be checked is that
-	// the endpoint is there.
-	if UsesClientAuth(provider) || provider.ApiKey == "" {
+	// A provider authenticated by a sign-in names no model to ask for, and a
+	// client-auth one carries no credential of its own. All that can be checked
+	// either way is that the endpoint is there.
+	if ServesAnyModel(provider) || provider.ApiKey == "" {
 		return testProviderReachable(provider)
 	}
 
@@ -796,8 +914,11 @@ func testProviderReachable(provider *Provider) (bool, int, string) {
 	}
 
 	// An upstream that rejects the unauthenticated probe has still proven it is
-	// reachable, which is the whole of what a client-auth provider can show.
-	if UsesClientAuth(provider) && (probe.statusCode == http.StatusUnauthorized || probe.statusCode == http.StatusForbidden) {
+	// reachable, which is the whole of what a provider with no key can show.
+	if ServesAnyModel(provider) && (probe.statusCode == http.StatusUnauthorized || probe.statusCode == http.StatusForbidden) {
+		if UsesSubscription(provider) {
+			return true, probe.statusCode, "reachable, and spending the sign-in stored here"
+		}
 		return true, probe.statusCode, "reachable, and authenticated with the caller's own credentials"
 	}
 
@@ -923,14 +1044,14 @@ func GetProvidersByModel(model string) ([]*Provider, error) {
 	// The models are JSON-serialized into a single column, so the match cannot
 	// be pushed down into the query.
 	matchedProviders := []*Provider{}
-	// A provider authenticated with the caller's own credentials cannot know
-	// which models the account behind them may use, so an empty model list
-	// there means "any model". Those providers are tried after the ones that
-	// name the model, so a wildcard never takes traffic from an exact match.
+	// A provider authenticated with a sign-in rather than a key cannot know
+	// which models the account behind it may use, so an empty model list there
+	// means "any model". Those providers are tried after the ones that name the
+	// model, so a wildcard never takes traffic from an exact match.
 	wildcardProviders := []*Provider{}
 	for _, provider := range providers {
 		if len(provider.Models) == 0 {
-			if UsesClientAuth(provider) {
+			if ServesAnyModel(provider) {
 				wildcardProviders = append(wildcardProviders, provider)
 			}
 			continue
diff --git a/object/provider_subscription.go b/object/provider_subscription.go
new file mode 100644
index 0000000..a82e380
--- /dev/null
+++ b/object/provider_subscription.go
@@ -0,0 +1,620 @@
+// 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 object
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/apache/casbin-gateway/agent"
+	"github.com/apache/casbin-gateway/proxy"
+	"github.com/xorm-io/core"
+)
+
+// The vendors whose subscription Gateway can hold. The sign-in is the one that
+// vendor's own client uses, because the endpoint behind a subscription accepts
+// no other OAuth application.
+const SubscriptionOpenAi = "openai"
+
+// codexOriginator names the client a ChatGPT subscription is spent through.
+const codexOriginator = "codex_cli_rs"
+
+// SubscriptionVendor is one sign-in Gateway can hold: where it is granted, and
+// what the provider holding it then talks to.
+type SubscriptionVendor struct {
+	Id    string `json:"id"`
+	Label string `json:"label"`
+	// BaseUrl and Protocol are the endpoint a subscription is served from,
+	// which is the vendor's own client backend rather than its API.
+	BaseUrl  string `json:"baseUrl"`
+	Protocol string `json:"protocol"`
+
+	clientId     string
+	authorizeUrl string
+	tokenUrl     string
+	// redirectUri is registered by the client this sign-in belongs to, port and
+	// path included, so the browser has to come back to exactly this.
+	redirectUri  string
+	listenAddr   string
+	callbackPath string
+	scope        string
+	extraParams  map[string]string
+}
+
+var subscriptionVendors = []*SubscriptionVendor{
+	{
+		Id:       SubscriptionOpenAi,
+		Label:    "ChatGPT",
+		BaseUrl:  "https://chatgpt.com" + ChatGptCodexPath,
+		Protocol: ProtocolResponses,
+
+		clientId:     "app_EMoamEEZ73f0CkXaXp7hrann",
+		authorizeUrl: "https://auth.openai.com/oauth/authorize",
+		tokenUrl:     "https://auth.openai.com/oauth/token",
+		redirectUri:  "http://localhost:1455/auth/callback",
+		listenAddr:   "127.0.0.1:1455",
+		callbackPath: "/auth/callback",
+		scope:        "openid profile email offline_access",
+		extraParams: map[string]string{
+			"id_token_add_organizations": "true",
+			"originator":                 codexOriginator,
+		},
+	},
+}
+
+func SubscriptionVendors() []*SubscriptionVendor { return subscriptionVendors }
+
+func subscriptionVendorOf(id string) (*SubscriptionVendor, error) {
+	for _, vendor := range subscriptionVendors {
+		if vendor.Id == id {
+			return vendor, nil
+		}
+	}
+	return nil, fmt.Errorf("gateway cannot sign in to %q", id)
+}
+
+// UsesSubscription reports whether the provider authenticates with a sign-in
+// Gateway holds rather than with a key. What reaches the upstream is the same
+// either way: the difference is only where the credential came from.
+func UsesSubscription(provider *Provider) bool {
+	return provider.AuthMode == ProviderAuthSubscription
+}
+
+// ProviderSubscription is the sign-in a subscription provider authenticates
+// with. It is stored encrypted in one column and never leaves the server.
+type ProviderSubscription struct {
+	Vendor       string `json:"vendor"`
+	AccessToken  string `json:"accessToken"`
+	RefreshToken string `json:"refreshToken"`
+	// AccountId is the workspace the requests are billed to, which the vendor
+	// wants beside the token.
+	AccountId string `json:"accountId"`
+	Account   string `json:"account"`
+	Plan      string `json:"plan"`
+	ExpiresAt string `json:"expiresAt"`
+}
+
+func (subscription *ProviderSubscription) expiry() time.Time {
+	at, err := time.Parse(time.RFC3339, subscription.ExpiresAt)
+	if err != nil {
+		return time.Time{}
+	}
+	return at
+}
+
+// subscriptionOf reads the stored sign-in of a decrypted provider, nil when it
+// holds none.
+func subscriptionOf(provider *Provider) *ProviderSubscription {
+	if provider == nil || strings.TrimSpace(provider.Subscription) == "" {
+		return nil
+	}
+
+	subscription := &ProviderSubscription{}
+	if err := json.Unmarshal([]byte(provider.Subscription), subscription); err != nil {
+		fmt.Printf("subscriptionOf(): provider [%s]: %v\n", provider.GetId(), err)
+		return nil
+	}
+	if subscription.AccessToken == "" && subscription.RefreshToken == "" {
+		return nil
+	}
+	return subscription
+}
+
+// HasSubscription reports whether a subscription provider is signed in at all.
+func HasSubscription(provider *Provider) bool {
+	return subscriptionOf(provider) != nil
+}
+
+// describeSubscription fills the fields the page shows about a stored sign-in.
+// The tokens themselves are left behind: json:"-" keeps the column out of every
+// answer, and these two are all a page needs to say whose account it is.
+func describeSubscription(provider *Provider) {
+	subscription := subscriptionOf(provider)
+	if subscription == nil {
+		return
+	}
+	provider.SubscriptionVendor = subscription.Vendor
+	provider.SubscriptionAccount = subscription.Account
+	provider.SubscriptionPlan = subscription.Plan
+}
+
+// subscriptionRefreshes serializes the renewals of one provider, which the
+// proxy would otherwise start once per concurrent request.
+var subscriptionRefreshes sync.Map
+
+// EnsureSubscription renews the access token of a subscription provider when it
+// is spent or about to be, and puts the new one back in the row. It is called
+// where the credential is about to be used, so a token is renewed only when
+// something is really going to send it.
+func EnsureSubscription(provider *Provider) error {
+	subscription := subscriptionOf(provider)
+	if subscription == nil {
+		return fmt.Errorf("provider %s is not signed in", provider.GetId())
+	}
+	expiry := subscription.expiry()
+	if subscription.AccessToken != "" && (expiry.IsZero() || time.Until(expiry) > refreshMargin) {
+		return nil
+	}
+	if subscription.RefreshToken == "" {
+		return fmt.Errorf("the sign-in of provider %s has expired, sign in again", provider.GetId())
+	}
+
+	id := provider.GetId()
+	lock, _ := subscriptionRefreshes.LoadOrStore(id, &sync.Mutex{})
+	mutex := lock.(*sync.Mutex)
+	mutex.Lock()
+	defer mutex.Unlock()
+
+	// Another request may have renewed it while this one waited.
+	if stored, err := GetProvider(id); err == nil && stored != nil {
+		if fresh := subscriptionOf(stored); fresh != nil && time.Until(fresh.expiry()) > refreshMargin {
+			provider.Subscription = stored.Subscription
+			return nil
+		}
+	}
+
+	vendor, err := subscriptionVendorOf(subscription.Vendor)
+	if err != nil {
+		return err
+	}
+	granted, err := refreshSubscription(vendor, subscription)
+	if err != nil {
+		return err
+	}
+
+	stored, err := json.Marshal(granted)
+	if err != nil {
+		return err
+	}
+	provider.Subscription = string(stored)
+	return storeSubscription(provider, granted)
+}
+
+// storeSubscription writes the sign-in column alone, so a renewal in the middle
+// of a request does not touch what somebody is editing on the page.
+func storeSubscription(provider *Provider, subscription *ProviderSubscription) error {
+	encrypted, err := encryptedSubscription(provider, subscription)
+	if err != nil {
+		return err
+	}
+	_, err = ormer.Engine.ID(core.PK{provider.Owner, provider.Name}).
+		Cols("subscription").Update(&Provider{Subscription: encrypted})
+	return err
+}
+
+// SetSubscriptionAuth puts a held sign-in on an upstream request. Beside the
+// token the vendor wants the account it is spent on and the client it was
+// granted to, which is what its own client sends.
+func SetSubscriptionAuth(header http.Header, provider *Provider) {
+	subscription := subscriptionOf(provider)
+	if subscription == nil {
+		return
+	}
+
+	header.Set("Authorization", "Bearer "+subscription.AccessToken)
+	if subscription.Vendor == SubscriptionOpenAi {
+		if subscription.AccountId != "" {
+			header.Set("Chatgpt-Account-Id", subscription.AccountId)
+		}
+		if header.Get("Originator") == "" {
+			header.Set("Originator", codexOriginator)
+		}
+	}
+}
+
+// SubscriptionLogin is one sign-in Gateway started, running or finished. It
+// takes as long as whoever is at the browser, so the page polls this rather
+// than holding a request open.
+type SubscriptionLogin struct {
+	Id     string `json:"id"`
+	Vendor string `json:"vendor"`
+	// Url is where the sign-in is approved. The page opens it.
+	Url     string `json:"url"`
+	Running bool   `json:"running"`
+	Ok      bool   `json:"ok"`
+	Account string `json:"account,omitempty"`
+	Plan    string `json:"plan,omitempty"`
+	Error   string `json:"error,omitempty"`
+
+	// granted is what the finished sign-in brought back, taken out of here by
+	// the save that stores it on a provider. It never reaches the browser.
+	granted *ProviderSubscription
+}
+
+type loginSession struct {
+	sync.Mutex
+	SubscriptionLogin
+	verifier string
+	state    string
+	server   *http.Server
+	started  time.Time
+}
+
+var logins = struct {
+	sync.Mutex
+	byId map[string]*loginSession
+}{byId: map[string]*loginSession{}}
+
+// StartSubscriptionLogin opens the callback the vendor's client is registered
+// with and returns the address to approve the sign-in at.
+func StartSubscriptionLogin(vendorId string) (*SubscriptionLogin, error) {
+	vendor, err := subscriptionVendorOf(vendorId)
+	if err != nil {
+		return nil, err
+	}
+
+	id, err := randomString()
+	if err != nil {
+		return nil, err
+	}
+	state, err := randomString()
+	if err != nil {
+		return nil, err
+	}
+	verifier, err := randomString()
+	if err != nil {
+		return nil, err
+	}
+
+	listener, err := listenForCallback(vendor)
+	if err != nil {
+		return nil, err
+	}
+
+	session := &loginSession{
+		SubscriptionLogin: SubscriptionLogin{
+			Id:      id,
+			Vendor:  vendor.Id,
+			Url:     authorizeSubscriptionUrl(vendor, state, verifier),
+			Running: true,
+		},
+		verifier: verifier,
+		state:    state,
+		started:  time.Now(),
+	}
+
+	mux := http.NewServeMux()
+	mux.HandleFunc(vendor.callbackPath, func(writer http.ResponseWriter, request *http.Request) {
+		finishSubscriptionLogin(session, vendor, request.URL.Query(), writer)
+	})
+	session.server = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
+
+	logins.Lock()
+	forgetFinishedLoginsLocked()
+	logins.byId[id] = session
+	logins.Unlock()
+
+	go func() {
+		_ = session.server.Serve(listener)
+	}()
+	// A sign-in nobody finishes must not hold the port forever: the vendor's own
+	// client wants it back.
+	time.AfterFunc(pendingTtl, func() {
+		session.Lock()
+		running := session.Running
+		if running {
+			session.Running = false
+			session.Error = "the sign-in was not finished in time"
+		}
+		session.Unlock()
+		_ = session.server.Close()
+	})
+
+	answer := session.snapshot()
+	return &answer, nil
+}
+
+// listenForCallback takes the address this vendor's client is registered with.
+// A sign-in nobody finished is still holding it, so it is dropped first rather
+// than left to fail the next attempt for ten minutes.
+func listenForCallback(vendor *SubscriptionVendor) (net.Listener, error) {
+	cancelRunningLogins(vendor.Id)
+
+	var err error
+	for attempt := 0; attempt < 10; attempt++ {
+		var listener net.Listener
+		listener, err = net.Listen("tcp", vendor.listenAddr)
+		if err == nil {
+			return listener, nil
+		}
+		time.Sleep(50 * time.Millisecond)
+	}
+	return nil, fmt.Errorf("gateway cannot take %s to finish the sign-in on, which is the address %s registered: %v",
+		vendor.listenAddr, vendor.Label, err)
+}
+
+// cancelRunningLogins drops the sign-ins of one vendor that are still waiting
+// for a browser, which is what frees its port.
+func cancelRunningLogins(vendorId string) {
+	logins.Lock()
+	started := make([]*loginSession, 0, len(logins.byId))
+	for _, session := range logins.byId {
+		if session.Vendor == vendorId {
+			started = append(started, session)
+		}
+	}
+	logins.Unlock()
+
+	for _, session := range started {
+		session.Lock()
+		running := session.Running
+		if running {
+			session.Running = false
+			session.Error = "another sign-in was started"
+		}
+		session.Unlock()
+		if running {
+			_ = session.server.Close()
+		}
+	}
+}
+
+// SubscriptionLoginSession is how a sign-in that was started is getting on.
+func SubscriptionLoginSession(id string) (SubscriptionLogin, bool) {
+	logins.Lock()
+	session, ok := logins.byId[id]
+	logins.Unlock()
+	if !ok {
+		return SubscriptionLogin{}, false
+	}
+	return session.snapshot(), true
+}
+
+// takeSubscriptionLogin hands the credential of a finished sign-in to the save
+// that stores it, and forgets the session: one sign-in fills one provider.
+func takeSubscriptionLogin(id string) (*ProviderSubscription, error) {
+	logins.Lock()
+	session, ok := logins.byId[id]
+	logins.Unlock()
+	if !ok {
+		return nil, fmt.Errorf("no sign-in was started under this id")
+	}
+
+	session.Lock()
+	defer session.Unlock()
+	if session.Running {
+		return nil, fmt.Errorf("this sign-in is not finished yet")
+	}
+	if session.granted == nil {
+		return nil, fmt.Errorf("this sign-in did not finish: %s", session.Error)
+	}
+	granted := session.granted
+	session.granted = nil
+
+	logins.Lock()
+	delete(logins.byId, id)
+	logins.Unlock()
+	return granted, nil
+}
+
+func (session *loginSession) snapshot() SubscriptionLogin {
+	session.Lock()
+	defer session.Unlock()
+	answer := session.SubscriptionLogin
+	answer.granted = nil
+	return answer
+}
+
+func (session *loginSession) finish(granted *ProviderSubscription, err error) {
+	session.Lock()
+	session.Running = false
+	if err != nil {
+		session.Error = err.Error()
+	} else {
+		session.Ok = true
+		session.granted = granted
+		session.Account = granted.Account
+		session.Plan = granted.Plan
+	}
+	session.Unlock()
+	go func() {
+		_ = session.server.Close()
+	}()
+}
+
+func finishSubscriptionLogin(session *loginSession, vendor *SubscriptionVendor, query url.Values, writer http.ResponseWriter) {
+	granted, err := grantOf(session, vendor, query)
+	session.finish(granted, err)
+
+	writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err != nil {
+		writer.WriteHeader(http.StatusBadRequest)
+		fmt.Fprintf(writer, callbackPage, "Sign-in failed", err.Error())
+		return
+	}
+	fmt.Fprintf(writer, callbackPage, "Signed in", "You can close this page and go back to Casbin Gateway.")
+}
+
+func grantOf(session *loginSession, vendor *SubscriptionVendor, query url.Values) (*ProviderSubscription, error) {
+	if failure := query.Get("error"); failure != "" {
+		return nil, fmt.Errorf("%s %s", failure, query.Get("error_description"))
+	}
+	if query.Get("state") != session.state {
+		return nil, fmt.Errorf("this sign-in was not the one started here")
+	}
+	code := query.Get("code")
+	if code == "" {
+		return nil, fmt.Errorf("the vendor sent no authorization code back")
+	}
+
+	form := url.Values{}
+	form.Set("grant_type", "authorization_code")
+	form.Set("code", code)
+	form.Set("redirect_uri", vendor.redirectUri)
+	form.Set("client_id", vendor.clientId)
+	form.Set("code_verifier", session.verifier)
+
+	return postSubscriptionToken(vendor, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()), nil)
+}
+
+func refreshSubscription(vendor *SubscriptionVendor, subscription *ProviderSubscription) (*ProviderSubscription, error) {
+	body, err := json.Marshal(map[string]string{
+		"client_id":     vendor.clientId,
+		"grant_type":    "refresh_token",
+		"refresh_token": subscription.RefreshToken,
+	})
+	if err != nil {
+		return nil, err
+	}
+	return postSubscriptionToken(vendor, "application/json", strings.NewReader(string(body)), subscription)
+}
+
+// postSubscriptionToken asks the vendor's token endpoint for tokens and reads
+// who they belong to out of them. previous carries what a renewal keeps: the
+// account details, and the refresh token when a renewal does not issue one.
+func postSubscriptionToken(vendor *SubscriptionVendor, contentType string, body io.Reader, previous *ProviderSubscription) (*ProviderSubscription, error) {
+	request, err := http.NewRequest(http.MethodPost, vendor.tokenUrl, body)
+	if err != nil {
+		return nil, err
+	}
+	request.Header.Set("Content-Type", contentType)
+	request.Header.Set("Accept", "application/json")
+	request.Header.Set("Originator", codexOriginator)
+
+	client := &http.Client{Timeout: exchangeTimeout, Transport: proxy.Transport()}
+	response, err := client.Do(request)
+	if err != nil {
+		return nil, err
+	}
+	defer response.Body.Close()
+
+	answer, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024))
+	if err != nil {
+		return nil, err
+	}
+	var granted struct {
+		AccessToken  string `json:"access_token"`
+		RefreshToken string `json:"refresh_token"`
+		IdToken      string `json:"id_token"`
+		Error        string `json:"error"`
+		ErrorText    string `json:"error_description"`
+	}
+	readable := json.Unmarshal(answer, &granted) == nil
+
+	if readable && granted.Error != "" {
+		return nil, fmt.Errorf("the sign-in was refused: %s %s", granted.Error, granted.ErrorText)
+	}
+	if response.StatusCode >= 400 {
+		return nil, fmt.Errorf("the token endpoint answered %s: %s", response.Status, summarize(answer))
+	}
+	if !readable || granted.AccessToken == "" {
+		return nil, fmt.Errorf("the token endpoint returned no access token: %s", summarize(answer))
+	}
+
+	subscription := &ProviderSubscription{
+		Vendor:       vendor.Id,
+		AccessToken:  granted.AccessToken,
+		RefreshToken: granted.RefreshToken,
+	}
+	if previous != nil {
+		subscription.AccountId = previous.AccountId
+		subscription.Account = previous.Account
+		subscription.Plan = previous.Plan
+		if subscription.RefreshToken == "" {
+			subscription.RefreshToken = previous.RefreshToken
+		}
+	}
+	describeGrant(subscription, granted.IdToken)
+	return subscription, nil
+}
+
+// describeGrant reads whose account this is out of the tokens themselves. The
+// id token carries the profile, the access token carries when it dies.
+func describeGrant(subscription *ProviderSubscription, idToken string) {
+	if expiry, ok := agent.DecodeJWTClaims(subscription.AccessToken)["exp"].(float64); ok {
+		subscription.ExpiresAt = time.Unix(int64(expiry), 0).UTC().Format(time.RFC3339)
+	}
+
+	for _, token := range []string{idToken, subscription.AccessToken} {
+		claims := agent.DecodeJWTClaims(token)
+		if claims == nil {
+			continue
+		}
+		if auth, ok := claims["https://api.openai.com/auth"].(map[string]any); ok {
+			fillClaim(&subscription.AccountId, auth["chatgpt_account_id"])
+			fillClaim(&subscription.Plan, auth["chatgpt_plan_type"])
+		}
+		if profile, ok := claims["https://api.openai.com/profile"].(map[string]any); ok {
+			fillClaim(&subscription.Account, profile["email"])
+		}
+		fillClaim(&subscription.Account, claims["email"])
+	}
+}
+
+func fillClaim(target *string, value any) {
+	if *target != "" {
+		return
+	}
+	if text, ok := value.(string); ok && text != "" {
+		*target = text
+	}
+}
+
+func authorizeSubscriptionUrl(vendor *SubscriptionVendor, state string, verifier string) string {
+	query := url.Values{}
+	query.Set("response_type", "code")
+	query.Set("client_id", vendor.clientId)
+	query.Set("redirect_uri", vendor.redirectUri)
+	query.Set("scope", vendor.scope)
+	query.Set("code_challenge", challengeOf(verifier))
+	query.Set("code_challenge_method", "S256")
+	query.Set("state", state)
+	for name, value := range vendor.extraParams {
+		query.Set(name, value)
+	}
+	return vendor.authorizeUrl + "?" + query.Encode()
+}
+
+// forgetFinishedLoginsLocked drops what is too old to still be polled. A sign-in
+// that finished stays until then, so the page that started it reads how it went
+// rather than "no such sign-in".
+func forgetFinishedLoginsLocked() {
+	for id, session := range logins.byId {
+		if time.Since(session.started) > pendingTtl {
+			delete(logins.byId, id)
+		}
+	}
+}
+
+const callbackPage = `<!doctype html><meta charset="utf-8"><title>%[1]s</title>` +
+	`<body style="font:16px system-ui;margin:4rem auto;max-width:32rem;text-align:center">` +
+	`<h1 style="font-size:1.25rem">%[1]s</h1><p style="color:#666">%[2]s</p>`
diff --git a/object/snapshot.go b/object/snapshot.go
index 6700613..7e01191 100644
--- a/object/snapshot.go
+++ b/object/snapshot.go
@@ -558,6 +558,9 @@ func credentiallessDetail(connection *Connection) string {
 // keylessDetail marks a provider that arrives with no key, which is the one
 // thing somebody has to finish by hand after importing a redacted snapshot.
 func keylessDetail(provider *Provider) string {
+	if provider.AuthMode == ProviderAuthSubscription {
+		return "the snapshot carries no sign-in for it, so it has to be signed in again"
+	}
 	if provider.ApiKey == "" && provider.AuthMode != ProviderAuthClient {
 		return "the snapshot carries no API key for it"
 	}
diff --git a/routers/router.go b/routers/router.go
index 70a42d6..6cf5a7f 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -164,6 +164,8 @@ func initAPI() {
 	beego.Router("/api/delete-provider", &controllers.ApiController{}, "POST:DeleteProvider")
 	beego.Router("/api/get-provider-models", &controllers.ApiController{}, "POST:GetProviderModels")
 	beego.Router("/api/test-provider", &controllers.ApiController{}, "POST:TestProvider")
+	beego.Router("/api/sign-in-provider", &controllers.ApiController{}, "POST:SignInProvider")
+	beego.Router("/api/get-provider-signin", &controllers.ApiController{}, "GET:GetProviderSignin")
 	beego.Router("/api/get-provider-health", &controllers.ApiController{}, "GET:GetProviderHealth")
 	beego.Router("/api/get-provider-quotas", &controllers.ApiController{}, "GET:GetProviderQuotas")
 	beego.Router("/api/refresh-provider-quotas", &controllers.ApiController{}, "POST:RefreshProviderQuotas")
diff --git a/web/src/backend/ProviderBackend.ts b/web/src/backend/ProviderBackend.ts
index c2fbeb5..9c7ea5c 100644
--- a/web/src/backend/ProviderBackend.ts
+++ b/web/src/backend/ProviderBackend.ts
@@ -19,6 +19,7 @@ import type {
   ProviderProbe,
   ProviderProbeMode,
   ProviderQuota,
+  ProviderSignin,
   ProviderTestResult,
 } from "@/types";
 
@@ -73,6 +74,19 @@ export function testProvider(provider: Provider) {
   return request<ProviderTestResult>("/api/test-provider", "POST", provider);
 }
 
+/** Starts a browser sign-in for a vendor whose subscription Gateway can hold.
+ *  The token stays on the server; what comes back is the id of the sign-in,
+ *  which the save of the provider redeems. */
+export function signInProvider(vendor: string) {
+  return request<ProviderSignin>(`/api/sign-in-provider${query({vendor: vendor})}`, "POST");
+}
+
+/** How a sign-in that was started is getting on. Approving one takes as long as
+ *  whoever is at the browser, so the page polls. */
+export function getProviderSignin(id: string) {
+  return request<ProviderSignin>(`/api/get-provider-signin${query({id: id})}`);
+}
+
 /** What the proxy has seen of each provider, which is what says why a request
  * went to a fallback rather than to the bound provider. */
 export function getProviderHealth() {
diff --git a/web/src/components/ProviderGridCard.tsx b/web/src/components/ProviderGridCard.tsx
index 28ca79f..3be6d79 100644
--- a/web/src/components/ProviderGridCard.tsx
+++ b/web/src/components/ProviderGridCard.tsx
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {CircleX, Pencil, Trash2} from "lucide-react";
+import {CircleX, LogIn, Pencil, Trash2} from "lucide-react";
 import i18next from "i18next";
 
 import {ProviderIcon} from "@/components/ProviderIcon";
@@ -30,7 +30,7 @@ import {
 } from "@/components/ui/dropdown-menu";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {agentSpeaks} from "@/lib/agents";
-import {providerIdOf, providerProtocol, usesClientAuth} from "@/lib/providers";
+import {providerIdOf, providerProtocol, servesAnyModel, usesClientAuth, usesSubscription} from "@/lib/providers";
 import {cn} from "@/lib/utils";
 import type {Agent, Provider, ProviderHealth, ProviderQuota} from "@/types";
 
@@ -126,6 +126,14 @@ export function ProviderGridCard({
           {usesClientAuth(provider) ? (
             <Badge variant="muted">{i18next.t("provider:Caller's own login")}</Badge>
           ) : null}
+          {/* A held sign-in is named after the account it spends: that, and not
+              the base URL, is what tells two of them apart. */}
+          {usesSubscription(provider) ? (
+            <Badge variant="muted">
+              <LogIn />
+              {provider.subscriptionAccount || i18next.t("provider:Held sign-in")}
+            </Badge>
+          ) : null}
           {provider.status === "enabled" ? null : (
             <Badge variant="muted">
               <CircleX />
@@ -139,7 +147,7 @@ export function ProviderGridCard({
         <div className="flex flex-wrap gap-1">
           {models.length === 0 ? (
             <span className="text-muted-foreground text-xs">
-              {usesClientAuth(provider) ? i18next.t("provider:Any model") : "-"}
+              {servesAnyModel(provider) ? i18next.t("provider:Any model") : "-"}
             </span>
           ) : (
             <>
diff --git a/web/src/components/ProviderLoginField.tsx b/web/src/components/ProviderLoginField.tsx
new file mode 100644
index 0000000..361e22a
--- /dev/null
+++ b/web/src/components/ProviderLoginField.tsx
@@ -0,0 +1,146 @@
+// 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.
+
+import * as React from "react";
+import {CircleCheck, ExternalLink, LogIn} from "lucide-react";
+import i18next from "i18next";
+
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import {Field} from "@/components/shared/form-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import type {Provider, ProviderSignin} from "@/types";
+
+/** How often the page asks how the sign-in in the browser is getting on. */
+const pollInterval = 1500;
+
+/** Who a provider is signed in as, empty when it is not signed in at all. */
+export function signedInAs(provider: Provider) {
+  return provider.subscriptionAccount ?? "";
+}
+
+/**
+ * The sign-in row of a subscription provider, which takes the place of the API
+ * key field: the credential is granted in the vendor's own browser flow and
+ * kept here, so there is nothing to paste.
+ */
+export function ProviderLoginField({
+  provider,
+  vendor,
+  onSignedIn,
+}: {
+  provider: Provider;
+  /** The vendor whose subscription this is, as the server names it. */
+  vendor: string;
+  onSignedIn: (session: ProviderSignin) => void;
+}) {
+  const [session, setSession] = React.useState<ProviderSignin | null>(null);
+  const [starting, setStarting] = React.useState(false);
+  const [error, setError] = React.useState("");
+  // The poll outlives no dialog: a sign-in nobody finished stops being followed
+  // as soon as the form it belongs to is gone.
+  const timer = React.useRef<number | undefined>(undefined);
+
+  React.useEffect(() => () => window.clearTimeout(timer.current), []);
+
+  const follow = React.useCallback(
+    (id: string) => {
+      timer.current = window.setTimeout(() => {
+        ProviderBackend.getProviderSignin(id)
+          .then(res => {
+            if (res.status !== "ok" || !res.data) {
+              setError(res.msg ?? "");
+              setSession(null);
+              return;
+            }
+            setSession(res.data);
+            if (res.data.running) {
+              follow(id);
+              return;
+            }
+            if (res.data.ok) {
+              onSignedIn(res.data);
+            } else {
+              setError(res.data.error ?? "");
+            }
+          })
+          .catch(reason => {
+            setError(String(reason));
+            setSession(null);
+          });
+      }, pollInterval);
+    },
+    [onSignedIn],
+  );
+
+  const start = () => {
+    setStarting(true);
+    setError("");
+    ProviderBackend.signInProvider(vendor)
+      .then(res => {
+        if (res.status !== "ok" || !res.data) {
+          setError(res.msg ?? "");
+          return;
+        }
+        setSession(res.data);
+        // The approval happens on the vendor's own site, in a tab of its own,
+        // so the half-filled form behind it is still here afterwards.
+        window.open(res.data.url, "_blank", "noopener");
+        follow(res.data.id);
+      })
+      .catch(reason => setError(String(reason)))
+      .then(() => setStarting(false));
+  };
+
+  const account = session?.ok ? (session.account ?? "") : signedInAs(provider);
+  const plan = session?.ok ? (session.plan ?? "") : (provider.subscriptionPlan ?? "");
+  const waiting = session?.running === true;
+
+  return (
+    <Field
+      label={i18next.t("provider:Sign-in")}
+      hint={account === "" ? i18next.t("provider:Sign-in hint") : i18next.t("provider:Signed in hint")}
+    >
+      <div className="grid gap-2">
+        <div className="flex flex-wrap items-center gap-3">
+          <Button type="button" size="sm" variant="outline" loading={starting || waiting} onClick={start}>
+            <LogIn />
+            {account === "" ? i18next.t("provider:Sign in") : i18next.t("provider:Sign in again")}
+          </Button>
+          {account === "" ? null : (
+            <Badge variant="success" className="w-fit">
+              <CircleCheck />
+              {plan === "" ? account : `${account} · ${plan}`}
+            </Badge>
+          )}
+        </div>
+        {waiting && session ? (
+          <p className="text-muted-foreground text-xs">
+            {i18next.t("provider:Finish the sign-in in the browser")}{" "}
+            <a
+              href={session.url}
+              target="_blank"
+              rel="noreferrer"
+              className="text-primary inline-flex items-center gap-1 hover:underline"
+            >
+              <ExternalLink className="size-3" />
+              {i18next.t("provider:Open the sign-in page")}
+            </a>
+          </p>
+        ) : null}
+        {error === "" ? null : <p className="text-destructive text-xs">{error}</p>}
+      </div>
+    </Field>
+  );
+}
diff --git a/web/src/components/ProviderModelsField.tsx b/web/src/components/ProviderModelsField.tsx
index 514c5ef..436d85d 100644
--- a/web/src/components/ProviderModelsField.tsx
+++ b/web/src/components/ProviderModelsField.tsx
@@ -22,7 +22,7 @@ import {Field} from "@/components/shared/form-dialog";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {TagsInput} from "@/components/ui/tags-input";
-import {modelPresets, modelsPlaceholder, usesClientAuth} from "@/lib/providers";
+import {modelPresets, modelsPlaceholder, servesAnyModel} from "@/lib/providers";
 import type {Provider} from "@/types";
 
 /**
@@ -55,7 +55,7 @@ export function ProviderModelsField({
   const models = provider.models ?? [];
   // A provider that forwards the caller's login sends no key upstream, so there
   // is nothing to list models with, and an empty list already means "any model".
-  const canFetch = !usesClientAuth(provider) && provider.baseUrl !== "";
+  const canFetch = !servesAnyModel(provider) && provider.baseUrl !== "";
 
   const fetchModels = () => {
     setFetching(true);
@@ -117,7 +117,7 @@ export function ProviderModelsField({
       {/* An empty list only means "any model" for a provider that forwards the
           caller's login. Anywhere else it means the router will never pick this
           provider, which is worth saying before the form is submitted. */}
-      {models.length === 0 && !usesClientAuth(provider) ? (
+      {models.length === 0 && !servesAnyModel(provider) ? (
         <p className="text-warning text-xs">{i18next.t("provider:No models hint")}</p>
       ) : null}
       {fetched === null ? null : (
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index 3a207b9..2cd8d44 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -48,9 +48,10 @@ export function wireApiEndpoint(wireApi: string) {
   }
 }
 
-/** Mirrors object.ProviderAuthProvider and object.ProviderAuthClient on the server. */
+/** Mirrors object.ProviderAuthProvider and the two on the server beside it. */
 export const authProvider = "provider";
 export const authClient = "client";
+export const authSubscription = "subscription";
 
 /** Mirrors object.ServesResponsesApi: OpenAI itself, and anything pointed at it. */
 export function servesResponsesApi(provider: {type?: string; protocol?: string} | undefined) {
@@ -62,6 +63,32 @@ export function usesClientAuth(provider: {authMode?: string} | undefined) {
   return provider?.authMode === authClient;
 }
 
+/** Mirrors object.UsesSubscription: a sign-in Gateway holds and renews itself. */
+export function usesSubscription(provider: {authMode?: string} | undefined) {
+  return provider?.authMode === authSubscription;
+}
+
+/**
+ * The authentication modes a provider form offers. A held sign-in is not among
+ * them unless the provider already has one: it is granted by the source card,
+ * which knows which vendor it is signing in to.
+ */
+export function authModeOptions(provider: {authMode?: string} | undefined, label: (key: string) => string) {
+  const options = [
+    {label: label("provider:Stored API key"), value: authProvider},
+    {label: label("provider:Caller's own login"), value: authClient},
+  ];
+  if (usesSubscription(provider)) {
+    options.push({label: label("provider:Held sign-in"), value: authSubscription});
+  }
+  return options;
+}
+
+/** Mirrors object.ServesAnyModel: a provider that names no models on purpose. */
+export function servesAnyModel(provider: {authMode?: string} | undefined) {
+  return usesClientAuth(provider) || usesSubscription(provider);
+}
+
 /** The env vars a client of one wire format reads its endpoint and key from. */
 const protocolEnv: Record<string, {baseUrl: string; token: string}> = {
   anthropic: {baseUrl: "ANTHROPIC_BASE_URL", token: "ANTHROPIC_AUTH_TOKEN"},
@@ -528,6 +555,8 @@ export interface ProviderSource {
   category?: VendorCategory;
   /** Where the vendor hands out API keys. */
   website?: string;
+  /** The sign-in Gateway holds for this source, for the cards that have one. */
+  vendor?: string;
   /** What the form starts from once the card is picked. */
   provider: Partial<Provider>;
 }
@@ -560,15 +589,16 @@ export const providerSources: ProviderSource[] = [
   {
     key: chatgptSource,
     label: "",
+    vendor: "openai",
     provider: {
       type: "openai",
-      // Codex signs in against the ChatGPT backend rather than the API, and
-      // that endpoint serves the Responses format alone.
+      // A ChatGPT subscription is spent against the Codex backend rather than
+      // the API, and that endpoint serves the Responses format alone.
       protocol: "responses",
       baseUrl: chatgptCodexBaseUrl,
       models: [],
       apiKey: "",
-      authMode: authClient,
+      authMode: authSubscription,
     },
   },
   {
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 4532efa..2f10bdc 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -1205,7 +1205,7 @@
     "Add provider": "Add provider",
     "Aggregators": "Aggregators",
     "Any model": "Any model",
-    "Any model hint": "Leave this empty to accept every model name, which is what a provider that forwards the caller's login usually wants.",
+    "Any model hint": "Leave this empty to accept every model name, which is what a provider authenticated by a sign-in usually wants: the account behind it decides.",
     "Ask an endpoint of my own": "Ask an endpoint of my own",
     "Asked at": "asked at",
     "Authentication": "Authentication",
@@ -1226,7 +1226,7 @@
     "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.",
+    "ChatGPT subscription detail": "Sign in to ChatGPT here. Gateway holds the subscription and spends it like a key, for any agent.",
     "Claude subscription": "Claude subscription",
     "Claude subscription detail": "The sign-in Claude Code already has, forwarded upstream. No API key, nothing to fill in.",
     "Clear": "Clear",
@@ -1264,12 +1264,14 @@
     "Fetch models": "Fetch models",
     "Fetched {count} models": "Fetched {count} models",
     "Fill in from": "Fill in from a preset",
+    "Finish the sign-in in the browser": "Finish the sign-in in the browser tab that just opened.",
     "From the type": "From the type",
     "Get a key from {vendor}": "Get a key from {vendor}",
     "Granted": "granted",
     "Granted path": "Granted path",
     "Health": "Health",
     "Healthy": "Healthy",
+    "Held sign-in": "A subscription held here",
     "Icon": "Icon",
     "Icon hint": "The site the vendor's icon is taken from, or an image URL. Leave it empty to take it from the base URL.",
     "Inference platforms": "Inference platforms",
@@ -1294,6 +1296,7 @@
     "Notes": "Notes",
     "Notes hint": "For whoever reads this later: which account the key belongs to, when it expires, who pays for it.",
     "Nothing to test": "There is nothing to test yet",
+    "Open the sign-in page": "Open the sign-in page",
     "Page description": "One endpoint in front of your model vendors. Gateway holds the API key, so the agents never have it — or forwards the agent's own sign-in and holds nothing.",
     "Paste a link or connection info": "Paste a link or connection info",
     "Paste a link or connection info hint": "Vendors publish these as \"add to CC Switch\" buttons, and a New API site copies its connection info out as JSON. Gateway reads either one and fills the form in below, and stores nothing until you confirm.",
@@ -1322,6 +1325,11 @@
     "Scale hint": "Divides every number read, for a vendor that counts in an internal unit of its own. New API counts in 1/500000 of a dollar, so its scale is 500000.",
     "Search vendors": "Search vendors",
     "Select all": "Select all",
+    "Sign in": "Sign in",
+    "Sign in again": "Sign in again",
+    "Sign-in": "Sign-in",
+    "Sign-in hint": "Gateway signs in to the vendor in your browser and keeps the token here, renewing it as it expires. Nothing is pasted, and no agent ever sees it.",
+    "Signed in hint": "Gateway holds this sign-in and renews it by itself. Sign in again to put another account behind this provider.",
     "Since the start": "set when you save",
     "Source": "Source",
     "Source hint": "Where this provider gets its credentials. Picking one fills in the type, base URL and models, so a subscription needs nothing typed and a vendor needs only its key.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 7a47f8f..9702b40 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -1391,7 +1391,7 @@
     "Add provider": "添加供应商",
     "Aggregators": "聚合站",
     "Any model": "任意模型",
-    "Any model hint": "留空表示接受任何模型名,转发调用方登录的供应商一般都这样配。",
+    "Any model hint": "留空表示接受任何模型名。用登录(而不是Key)认证的供应商一般都留空:能用哪些模型由那个账号说了算。",
     "Ask an endpoint of my own": "使用自定义接口",
     "Asked at": "查询于",
     "Authentication": "认证方式",
@@ -1412,7 +1412,7 @@
     "Caller's own login": "调用方自己的登录",
     "Change source": "更换",
     "ChatGPT subscription": "ChatGPT订阅",
-    "ChatGPT subscription detail": "直接用Codex已有的ChatGPT登录,转发给OpenAI的Codex后端。不需要API Key,什么都不用填。",
+    "ChatGPT subscription detail": "在这里登录ChatGPT,Gateway保管这个订阅,像Key一样给任何Agent用。",
     "Claude subscription": "Claude订阅登录",
     "Claude subscription detail": "直接用Claude Code已有的登录,原样转发给上游。不需要API Key,什么都不用填。",
     "Clear": "清空",
@@ -1450,12 +1450,14 @@
     "Fetch models": "自动获取",
     "Fetched {count} models": "共获取到{count}个模型",
     "Fill in from": "从预设填入",
+    "Finish the sign-in in the browser": "在刚打开的浏览器标签页里完成登录。",
     "From the type": "按类型推断",
     "Get a key from {vendor}": "去{vendor}获取密钥",
     "Granted": "总额",
     "Granted path": "总额字段路径",
     "Health": "健康状态",
     "Healthy": "正常",
+    "Held sign-in": "Gateway保管的订阅",
     "Icon": "图标",
     "Icon hint": "取图标的站点域名,或一个图片地址。留空则从Base URL推断。",
     "Inference platforms": "推理平台",
@@ -1480,6 +1482,7 @@
     "Notes": "备注",
     "Notes hint": "写给以后的自己:这个密钥属于哪个账号、什么时候过期、由谁付费。",
     "Nothing to test": "暂时没有可探测的内容",
+    "Open the sign-in page": "打开登录页面",
     "Page description": "挡在模型厂商前面的统一入口。API Key由Gateway保管,Agent拿不到;也可以转发Agent自己的登录,什么都不保管。",
     "Paste a link or connection info": "粘贴链接或连接信息",
     "Paste a link or connection info hint": "中转站通常把它做成“添加到CC Switch”的按钮,New API站点则是把连接信息复制成一段JSON。两种Gateway都认,读完只会把下面的表单填好,你确认之前不会保存任何东西。",
@@ -1508,6 +1511,11 @@
     "Scale hint": "读到的数字都除以它,用于按内部单位计数的厂商。New API以1/500000美元计数,系数就是500000。",
     "Search vendors": "搜索厂商",
     "Select all": "全选",
+    "Sign in": "登录",
+    "Sign in again": "重新登录",
+    "Sign-in": "登录",
+    "Sign-in hint": "Gateway会在浏览器里登录一次厂商,把令牌留在这里,过期自动续。不用粘任何东西,Agent也拿不到它。",
+    "Signed in hint": "这个登录由Gateway保管并自动续期。想换个账号就再登录一次。",
     "Since the start": "保存后确定",
     "Source": "来源",
     "Source hint": "这个供应商的凭据从哪里来。选一个就会自动填好类型、base URL和模型:订阅登录什么都不用填,厂商只用填一个Key。",
diff --git a/web/src/pages/ProviderEditPage.tsx b/web/src/pages/ProviderEditPage.tsx
index 70bd297..80d8391 100644
--- a/web/src/pages/ProviderEditPage.tsx
+++ b/web/src/pages/ProviderEditPage.tsx
@@ -20,6 +20,7 @@ import i18next from "i18next";
 import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
 import {ProviderIcon, ProviderIconField} from "@/components/ProviderIcon";
+import {ProviderLoginField} from "@/components/ProviderLoginField";
 import {ProviderModelsField} from "@/components/ProviderModelsField";
 import {ProviderQuotaSection} from "@/components/ProviderQuota";
 import {ProviderTestField, useProviderTest} from "@/components/ProviderTestField";
@@ -36,13 +37,14 @@ import {Button} from "@/components/ui/button";
 import {Input} from "@/components/ui/input";
 import {Textarea} from "@/components/ui/textarea";
 import {
-  authClient,
+  authModeOptions,
   baseUrlPlaceholder,
   providerProtocol,
   providerWireApi,
   gatewayBaseUrl,
   localShell,
   usesClientAuth,
+  usesSubscription,
   wireApiEndpoint,
 } from "@/lib/providers";
 import type {Provider, QuotaConfig} from "@/types";
@@ -239,13 +241,27 @@ export default function ProviderEditPage() {
             onChange={value =>
               setProvider(current => (current ? {...current, authMode: value, apiKey: ""} : current))
             }
-            options={[
-              {label: i18next.t("provider:Stored API key"), value: "provider"},
-              {label: i18next.t("provider:Caller's own login"), value: authClient},
-            ]}
+            options={authModeOptions(provider, key => i18next.t(key))}
           />
         </Field>
-        {usesClientAuth(provider) ? null : (
+        {usesSubscription(provider) ? (
+          <ProviderLoginField
+            provider={provider}
+            vendor={provider.subscriptionVendor ?? ""}
+            onSignedIn={session =>
+              setProvider(current =>
+                current
+                  ? {
+                    ...current,
+                    loginId: session.id,
+                    subscriptionAccount: session.account ?? "",
+                    subscriptionPlan: session.plan ?? "",
+                  }
+                  : current,
+              )
+            }
+          />
+        ) : usesClientAuth(provider) ? null : (
           <Field
             label={i18next.t("provider:API Key")}
             htmlFor="provider-api-key"
@@ -261,7 +277,11 @@ export default function ProviderEditPage() {
         )}
         <ProviderModelsField
           provider={provider}
-          hint={usesClientAuth(provider) ? i18next.t("provider:Any model hint") : undefined}
+          hint={
+            usesClientAuth(provider) || usesSubscription(provider)
+              ? i18next.t("provider:Any model hint")
+              : undefined
+          }
           onChange={value => setField("models", value)}
         />
         <Field label={i18next.t("provider:Priority")} hint={i18next.t("provider:Priority hint")}>
diff --git a/web/src/pages/ProviderListPage.tsx b/web/src/pages/ProviderListPage.tsx
index 419d824..72cf917 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -31,6 +31,7 @@ import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
 import {ProviderIconField} from "@/components/ProviderIcon";
 import {ProviderGridCard} from "@/components/ProviderGridCard";
+import {ProviderLoginField, signedInAs} from "@/components/ProviderLoginField";
 import {ProviderModelsField} from "@/components/ProviderModelsField";
 import {ProviderSourcePicker, sourceTitle} from "@/components/ProviderSourcePicker";
 import {ProviderTestField, useProviderTest} from "@/components/ProviderTestField";
@@ -49,14 +50,15 @@ import {Input} from "@/components/ui/input";
 import {Textarea} from "@/components/ui/textarea";
 import {clearDraft, readDraft, writeDraft, type Draft} from "@/lib/draft";
 import {
+  authModeOptions,
   authProvider,
-  authClient,
   baseUrlPlaceholder,
   customSource,
   providerIdOf,
   providerSlug,
   providerSources,
   usesClientAuth,
+  usesSubscription,
   type ProviderSource,
 } from "@/lib/providers";
 import {cn} from "@/lib/utils";
@@ -525,6 +527,9 @@ export default function ProviderListPage({account}: {account: Account}) {
           )
         }
         submitting={adding || test.testing}
+        // A provider that spends a subscription is nothing until it is signed
+        // in: there would be no credential to send.
+        submitDisabled={usesSubscription(form) && signedInAs(form) === ""}
         submitText={i18next.t("provider:Add provider")}
         onSubmit={submitProvider}
         // Nothing is filled in yet while the source is still being picked, so
@@ -572,6 +577,9 @@ export default function ProviderListPage({account}: {account: Account}) {
                 {usesClientAuth(form) ? (
                   <Badge variant="muted">{i18next.t("provider:Caller's own login")}</Badge>
                 ) : null}
+                {usesSubscription(form) ? (
+                  <Badge variant="muted">{i18next.t("provider:Held sign-in")}</Badge>
+                ) : null}
                 <Button type="button" size="xs" variant="ghost" onClick={() => setSource(null)}>
                   {i18next.t("provider:Change source")}
                 </Button>
@@ -616,7 +624,20 @@ export default function ProviderListPage({account}: {account: Account}) {
                 }}
               />
             </Field>
-            {usesClientAuth(form) ? null : (
+            {usesSubscription(form) ? (
+              <ProviderLoginField
+                provider={form}
+                vendor={source.vendor ?? ""}
+                onSignedIn={session =>
+                  setForm(prev => ({
+                    ...prev,
+                    loginId: session.id,
+                    subscriptionAccount: session.account ?? "",
+                    subscriptionPlan: session.plan ?? "",
+                  }))
+                }
+              />
+            ) : usesClientAuth(form) ? null : (
               <Field
                 label={
                   <span className="flex flex-wrap items-center gap-2">
@@ -649,7 +670,11 @@ export default function ProviderListPage({account}: {account: Account}) {
             <ProviderModelsField
               provider={form}
               className="md:col-span-2"
-              hint={usesClientAuth(form) ? i18next.t("provider:Any model hint") : i18next.t("provider:Models hint")}
+              hint={
+                usesClientAuth(form) || usesSubscription(form)
+                  ? i18next.t("provider:Any model hint")
+                  : i18next.t("provider:Models hint")
+              }
               onChange={value => setFormField("models", value)}
             />
             <ProviderTestField test={test} submitText={i18next.t("provider:Add provider")} />
@@ -700,10 +725,7 @@ export default function ProviderListPage({account}: {account: Account}) {
                   value={form.authMode}
                   // The stored key is meaningless once the caller's own is forwarded.
                   onChange={value => setForm(prev => ({...prev, authMode: value, apiKey: ""}))}
-                  options={[
-                    {label: i18next.t("provider:Stored API key"), value: authProvider},
-                    {label: i18next.t("provider:Caller's own login"), value: authClient},
-                  ]}
+                  options={authModeOptions(form, key => i18next.t(key))}
                 />
               </Field>
               <ProviderIconField provider={form} onChange={value => setFormField("icon", value)} />
diff --git a/web/src/types.ts b/web/src/types.ts
index 9fba496..1fde7d3 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -65,6 +65,14 @@ export interface Provider {
   protocol?: string;
   apiKey: string;
   authMode: string;
+  /** A finished sign-in this save takes the credential of. Write-only: the
+   *  token itself never reaches the browser. */
+  loginId?: string;
+  /** Whose subscription this provider spends, when it holds one, and where
+   *  that sign-in was granted. */
+  subscriptionVendor?: string;
+  subscriptionAccount?: string;
+  subscriptionPlan?: string;
   /** A site the vendor's icon is taken from, or an image URL. Empty derives it
    * from the base URL. */
   icon: string;
@@ -109,6 +117,19 @@ export interface ProviderQuota {
   time: string;
 }
 
+/** One browser sign-in Gateway started for a subscription provider. */
+export interface ProviderSignin {
+  id: string;
+  vendor: string;
+  /** Where the sign-in is approved. The page opens it. */
+  url: string;
+  running: boolean;
+  ok: boolean;
+  account?: string;
+  plan?: string;
+  error?: string;
+}
+
 export interface ProviderTestResult {
   success: boolean;
   statusCode?: number;


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