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]
