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 <[email protected]>
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: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to