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