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 09:33:54 UTC
(casbin-gateway) 01/03: feat(providers): hold a Claude subscription too, in the shape its endpoint answers
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 44b4006589dbd04b771ff61ff82303184f2f883f
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sun Sep 6 17:05:08 2026 +0800
feat(providers): hold a Claude subscription too, in the shape its endpoint answers
---
controllers/proxy.go | 4 +
.../en/1-getting-started/1.4-quickstart.md | 2 +-
.../user-manual/en/3-providers/3.3-caller-login.md | 42 ++--
docs/user-manual/en/6-faq/6.1-faq.md | 2 +-
.../zh/1-getting-started/1.4-quickstart.md | 2 +-
.../user-manual/zh/3-providers/3.3-caller-login.md | 42 ++--
docs/user-manual/zh/6-faq/6.1-faq.md | 2 +-
object/provider_subscription.go | 237 +++++++++++++++++++--
web/src/components/ProviderSourcePicker.tsx | 6 +-
web/src/lib/providers.ts | 7 +-
web/src/locales/en/data.json | 2 +-
web/src/locales/zh/data.json | 2 +-
12 files changed, 279 insertions(+), 71 deletions(-)
diff --git a/controllers/proxy.go b/controllers/proxy.go
index a26b0d2..723787c 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -515,6 +515,9 @@ func (c *ApiController) forwardToProvider(attempt object.RouteAttempt, route *pr
if err != nil {
return http.StatusBadRequest, err.Error(), false
}
+ // A subscription endpoint answers the client its token was granted to, so
+ // the body is put in the shape that client would have sent.
+ requestBody = object.ShapeSubscriptionBody(provider, requestBody)
upstreamUrl, err := object.BuildProviderUrl(provider.BaseUrl, upstream.Name(), route.upstreamEndpoint(upstream))
if err != nil {
@@ -526,6 +529,7 @@ func (c *ApiController) forwardToProvider(attempt object.RouteAttempt, route *pr
if route.passthrough(upstream) {
upstreamUrl = object.AppendQuery(upstreamUrl, c.Ctx.Request.URL.RawQuery)
}
+ upstreamUrl = object.AppendQuery(upstreamUrl, object.SubscriptionQuery(provider))
// The context is cancelled when this function returns, which happens only
// after the response body has been relayed to the client.
diff --git a/docs/user-manual/en/1-getting-started/1.4-quickstart.md b/docs/user-manual/en/1-getting-started/1.4-quickstart.md
index a77137a..0600713 100644
--- a/docs/user-manual/en/1-getting-started/1.4-quickstart.md
+++ b/docs/user-manual/en/1-getting-started/1.4-quickstart.md
@@ -18,7 +18,7 @@ Open **Agents**. Every agent installed on this machine is there, with the accoun
Gateway probes it against the upstream before storing it, and [Authenticity](../3-providers/3.4-authenticity.md) starts grading it on its own from here.
-No API key, because the agent is on a ChatGPT or Claude subscription? Set **Authentication** to **the caller's own login** instead — [3.3](../3-providers/3.3-caller-login.md).
+No API key, because you have a ChatGPT or Claude subscription? Pick that subscription as the source and sign in — [3.3](../3-providers/3.3-caller-login.md).
## Step 2 — point an agent at it
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 b48a43e..d873799 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
@@ -1,18 +1,26 @@
-# 3.3 No API key: the caller's own login
+# 3.3 No API key: a subscription instead
-An agent signed in with a ChatGPT or Claude subscription has no API key to paste. It does not need one.
+A ChatGPT or Claude subscription has no API key to paste. It does not need one. There are two ways to put one behind a provider, and they differ in who holds the sign-in.
-Set the provider's **Authentication** to **the caller's own login**:
+## Gateway holds it
-- The base URL points at the vendor.
-- Every request is forwarded with the credentials **the agent itself sent**, so it keeps its own sign-in.
-- Leave **Models** empty and the provider accepts any model name.
+Pick the **ChatGPT subscription** or **Claude 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, never sent to any agent.
-Gateway records and routes that traffic exactly as it does for a provider with a key. It just never sees one.
+From then on the subscription is used exactly like a key: **every** agent can spend it, in whatever API it speaks, and the model routing, permissions and records all work as they do for a keyed provider.
-## The environment snippet is shorter
+- The base URL is the endpoint that subscription is served from, which is not the vendor's API: `https://chatgpt.com/backend-api/codex` for ChatGPT, `https://api.anthropic.com` for Claude.
+- **Models** stays empty: what the account may ask for is decided by the plan behind it.
+- Requests are sent as the client that sign-in was granted to — that is what the endpoint answers.
-For such a provider the snippet sets the base URL and nothing else:
+Sign in again to put another account behind the same provider. Snapshots never carry a sign-in, so an imported provider has to be signed in again.
+
+## The agent holds it
+
+Set **Authentication** to **the caller's own login** instead. Every request is then forwarded with the credentials **the agent itself sent**, so it keeps its own sign-in and nothing is stored here at all.
+
+Only the agent holding that sign-in can use such a provider — Claude Code for a Claude one, Codex for a ChatGPT one. For Codex, **Write configuration** puts `requires_openai_auth = true` in `~/.codex/config.toml` in place of a key, which is what makes it send the ChatGPT sign-in it already has.
+
+The environment snippet of such a provider sets the base URL and nothing else:
```bash
export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code"
@@ -20,22 +28,14 @@ export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code"
A token there would **replace** the sign-in the agent already has, which is the one thing this mode exists to keep.
-## What you still get
+Codex's spend is 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.
+
+## What you get either way
-Everything except holding the key:
+Everything except a key of your own:
- LLM Records, in full ([4.2](../4-governance/4.2-llm-records.md)).
- 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)).
-## A ChatGPT subscription
-
-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.
-
-**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.
-
Next: [3.4 Authenticity](3.4-authenticity.md).
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 8460c06..6682414 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 **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)).
+Pick the **ChatGPT subscription** or **Claude subscription** source and sign in: Gateway holds the subscription and spends it like a key, for every agent. To leave the sign-in with the agent that has it instead, set **Authentication** to the caller's own login ([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/1-getting-started/1.4-quickstart.md b/docs/user-manual/zh/1-getting-started/1.4-quickstart.md
index 2999beb..68be6fc 100644
--- a/docs/user-manual/zh/1-getting-started/1.4-quickstart.md
+++ b/docs/user-manual/zh/1-getting-started/1.4-quickstart.md
@@ -18,7 +18,7 @@
存之前Gateway会先探一次上游,从此[真伪检测](../3-providers/3.4-authenticity.md)会自动给它打分。
-Agent用的是ChatGPT或Claude订阅、根本没有API Key?把**认证方式**设为**调用方自己的登录**,见[3.3](../3-providers/3.3-caller-login.md)。
+手上只有ChatGPT或Claude订阅、根本没有API Key?在来源里选那个订阅,登录一次就行,见[3.3](../3-providers/3.3-caller-login.md)。
## 第2步——把Agent指过去
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 f712952..fa71f72 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
@@ -1,18 +1,26 @@
-# 3.3 没有API Key:沿用调用方自己的登录
+# 3.3 没有API Key:用订阅顶上
-用ChatGPT或Claude订阅登录的Agent,手里根本没有API Key可粘。它也不需要。
+ChatGPT或Claude订阅本来就没有API Key可粘,也不需要。把订阅接到供应商上有两条路,区别只在于登录握在谁手里。
-把供应商的**认证方式**设成**调用方自己的登录**:
+## Gateway保管
-- 基础URL指向厂商。
-- 每个请求都带着**Agent自己发来的凭据**转发出去,所以它保住了自己的登录态。
-- **模型**留空,这个供应商就接受任意模型名。
+新建供应商时选**ChatGPT订阅**或**Claude订阅**,点**登录**:厂商自己的页面会在浏览器里打开,拿回来的令牌存在这边,和Key一样加密,过期前自动续,一个字都不会交给Agent。
-Gateway记录和路由这类流量的方式,和有Key的供应商完全一样。它只是从头到尾没见过Key。
+之后这个订阅就跟一个Key没区别:**任何**Agent都能花它,它说的是哪套API无所谓,模型路由、权限、记录也都和有Key的供应商一模一样。
-## 环境变量片段更短
+- 基础URL指向的是这个订阅自己的服务端,不是厂商的API:ChatGPT是`https://chatgpt.com/backend-api/codex`,Claude是`https://api.anthropic.com`。
+- **模型**留空:这个账号能用哪些模型,由它背后的套餐说了算。
+- 请求会按"这个登录本来是发给谁用的"那个样子发出去——那个服务端只认这个样子。
-这类供应商的片段只设基础URL,别的都不设:
+想换账号,再登录一次就行。快照里从来不带登录,所以导入进来的这类供应商要重新登录一次。
+
+## Agent自己保管
+
+把**认证方式**设成**调用方自己的登录**。这时每个请求都带着**Agent自己发来的凭据**转发出去,它保住自己的登录态,这边什么都不存。
+
+这样的供应商只有握着那个登录的Agent能用——Claude的归Claude Code,ChatGPT的归Codex。对Codex来说,**写入配置**往`~/.codex/config.toml`里写的不是Key,而是`requires_openai_auth = true`,它由此把自己已有的ChatGPT登录发过来。
+
+这类供应商的环境变量片段只设基础URL,别的都不设:
```bash
export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code"
@@ -20,22 +28,14 @@ export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code"
在这里再放一个token,会**顶掉**Agent已有的登录——而保住那个登录正是这种模式存在的理由。
-## 你仍然拿得到什么
+不管走哪条路,Codex的花费都会出现在[用量](../4-governance/4.3-usage-and-pricing.md)页上,因为那一页读的是Agent自己的会话记录,而不是转发记录。
+
+## 两条路都给你的
-除了"替它保管Key"以外的全部:
+除了"你自己那把Key"以外的全部:
- 完整的LLM记录([4.2](../4-governance/4.2-llm-records.md))。
- 对工具、模型、供应商的权限控制([4.1](../4-governance/4.1-permissions.md))。
- 在会话记录已有数据之上,再加一份来自转发流量的用量([4.3](../4-governance/4.3-usage-and-pricing.md))。
-## ChatGPT订阅
-
-ChatGPT的登录对接的不是OpenAI的API,而是Codex后端,所以供应商也指向那里:`https://chatgpt.com/backend-api/codex`,上游API选Responses。这个登录放在哪一边都行。
-
-**放在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自己的会话记录,而不是转发记录。
-
下一节:[3.4 真伪检测](3.4-authenticity.md)。
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 ffa4afd..b49f0e5 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。**
-选**ChatGPT订阅**这个来源,点登录,Gateway会保管这个订阅,像Key一样花。Claude的话选**Claude订阅**,它转发Claude Code已有的登录([3.3](../3-providers/3.3-caller-login.md))。
+选**ChatGPT订阅**或**Claude订阅**这个来源,点登录:Gateway保管这个订阅,像Key一样给每个Agent花。想把登录留在Agent自己手里,就把**认证方式**设成调用方自己的登录([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_subscription.go b/object/provider_subscription.go
index a82e380..5281bfc 100644
--- a/object/provider_subscription.go
+++ b/object/provider_subscription.go
@@ -33,10 +33,20 @@ import (
// 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"
+const (
+ SubscriptionOpenAi = "openai"
+ SubscriptionAnthropic = "anthropic"
+)
-// codexOriginator names the client a ChatGPT subscription is spent through.
-const codexOriginator = "codex_cli_rs"
+// What a subscription's requests have to look like to be served: the vendor
+// hands these tokens to one client of its own, and its endpoint answers that
+// client. They are the same values the client on this machine would send.
+const (
+ codexOriginator = "codex_cli_rs"
+ claudeUserAgent = "claude-cli/2.1.170 (external, cli)"
+ claudeBetas = "oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14"
+ claudePreamble = "You are Claude Code, Anthropic's official CLI for Claude."
+)
// SubscriptionVendor is one sign-in Gateway can hold: where it is granted, and
// what the provider holding it then talks to.
@@ -58,6 +68,22 @@ type SubscriptionVendor struct {
callbackPath string
scope string
extraParams map[string]string
+ // codeJson posts the first exchange as JSON. A renewal is JSON everywhere.
+ codeJson bool
+ // stateInFragment marks a vendor that returns the state after a "#", which
+ // never reaches a server, so the callback page hands it back itself.
+ stateInFragment bool
+ // tokenHeaders are what the token endpoint wants beside the body.
+ tokenHeaders map[string]string
+ // query is appended to every upstream URL, for an endpoint that selects a
+ // variant that way.
+ query string
+ // apply puts the sign-in on an upstream request, in the headers this vendor
+ // reads it from.
+ apply func(http.Header, *ProviderSubscription)
+ // shape rewrites a request body the vendor's endpoint would otherwise
+ // refuse. Nil where nothing has to be changed.
+ shape func([]byte) []byte
}
var subscriptionVendors = []*SubscriptionVendor{
@@ -78,6 +104,44 @@ var subscriptionVendors = []*SubscriptionVendor{
"id_token_add_organizations": "true",
"originator": codexOriginator,
},
+ tokenHeaders: map[string]string{"Originator": codexOriginator},
+ apply: func(header http.Header, subscription *ProviderSubscription) {
+ header.Set("Authorization", "Bearer "+subscription.AccessToken)
+ if subscription.AccountId != "" {
+ header.Set("Chatgpt-Account-Id", subscription.AccountId)
+ }
+ if header.Get("Originator") == "" {
+ header.Set("Originator", codexOriginator)
+ }
+ },
+ },
+ {
+ Id: SubscriptionAnthropic,
+ Label: "Claude",
+ BaseUrl: "https://api.anthropic.com",
+ Protocol: ProtocolAnthropic,
+
+ clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
+ authorizeUrl: "https://claude.ai/oauth/authorize",
+ tokenUrl: "https://api.anthropic.com/v1/oauth/token",
+ redirectUri: "http://localhost:54545/callback",
+ listenAddr: "127.0.0.1:54545",
+ callbackPath: "/callback",
+ scope: "org:create_api_key user:profile user:inference",
+ codeJson: true,
+ stateInFragment: true,
+ tokenHeaders: map[string]string{"User-Agent": claudeUserAgent},
+ query: "beta=true",
+ apply: func(header http.Header, subscription *ProviderSubscription) {
+ // The subscription endpoint reads a bearer token, not the x-api-key
+ // an API account authenticates with.
+ header.Del("X-Api-Key")
+ header.Set("Authorization", "Bearer "+subscription.AccessToken)
+ header.Set("Anthropic-Version", AnthropicVersion)
+ header.Set("Anthropic-Beta", mergeBetas(header.Get("Anthropic-Beta"), claudeBetas))
+ header.Set("User-Agent", claudeUserAgent)
+ },
+ shape: withClaudePreamble,
},
}
@@ -221,24 +285,120 @@ func storeSubscription(provider *Provider, subscription *ProviderSubscription) e
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.
+// SetSubscriptionAuth puts a held sign-in on an upstream request, in the shape
+// the vendor's endpoint reads it: beside the token it wants the account it is
+// spent on and the client it was granted to.
func SetSubscriptionAuth(header http.Header, provider *Provider) {
subscription := subscriptionOf(provider)
if subscription == nil {
return
}
+ vendor, err := subscriptionVendorOf(subscription.Vendor)
+ if err != nil || vendor.apply == nil {
+ return
+ }
+ vendor.apply(header, subscription)
+}
+
+// SubscriptionQuery is what a subscription endpoint wants on the URL, empty for
+// one that wants nothing.
+func SubscriptionQuery(provider *Provider) string {
+ if vendor := vendorOfProvider(provider); vendor != nil {
+ return vendor.query
+ }
+ return ""
+}
+
+// ShapeSubscriptionBody rewrites a request the subscription endpoint would
+// refuse as it stands. The body is returned unchanged where nothing has to be.
+func ShapeSubscriptionBody(provider *Provider, body []byte) []byte {
+ vendor := vendorOfProvider(provider)
+ if vendor == nil || vendor.shape == nil {
+ return body
+ }
+ return vendor.shape(body)
+}
+
+func vendorOfProvider(provider *Provider) *SubscriptionVendor {
+ subscription := subscriptionOf(provider)
+ if subscription == nil {
+ return nil
+ }
+ vendor, err := subscriptionVendorOf(subscription.Vendor)
+ if err != nil {
+ return nil
+ }
+ return vendor
+}
+
+// mergeBetas keeps the betas the client asked for and adds the ones the
+// subscription endpoint needs, without repeating any.
+func mergeBetas(asked string, needed string) string {
+ seen := map[string]bool{}
+ merged := []string{}
+ for _, list := range []string{asked, needed} {
+ for _, beta := range strings.Split(list, ",") {
+ beta = strings.TrimSpace(beta)
+ if beta == "" || seen[beta] {
+ continue
+ }
+ seen[beta] = true
+ merged = append(merged, beta)
+ }
+ }
+ return strings.Join(merged, ",")
+}
- header.Set("Authorization", "Bearer "+subscription.AccessToken)
- if subscription.Vendor == SubscriptionOpenAi {
- if subscription.AccountId != "" {
- header.Set("Chatgpt-Account-Id", subscription.AccountId)
+// withClaudePreamble puts the line Anthropic answers a subscription token for at
+// the front of the system prompt. Without it the endpoint refuses the request,
+// and it is the same line the client this token was granted to sends.
+func withClaudePreamble(body []byte) []byte {
+ fields := map[string]json.RawMessage{}
+ if json.Unmarshal(body, &fields) != nil {
+ return body
+ }
+
+ preamble := []map[string]string{{"type": "text", "text": claudePreamble}}
+ blocks := []map[string]any{}
+ switch existing := fields["system"]; {
+ case len(existing) == 0:
+ default:
+ var text string
+ if json.Unmarshal(existing, &text) == nil {
+ if strings.HasPrefix(strings.TrimSpace(text), claudePreamble) {
+ return body
+ }
+ blocks = append(blocks, map[string]any{"type": "text", "text": text})
+ break
+ }
+ var carried []map[string]any
+ if json.Unmarshal(existing, &carried) != nil {
+ return body
}
- if header.Get("Originator") == "" {
- header.Set("Originator", codexOriginator)
+ if len(carried) > 0 {
+ if first, ok := carried[0]["text"].(string); ok && strings.HasPrefix(strings.TrimSpace(first), claudePreamble) {
+ return body
+ }
}
+ blocks = carried
+ }
+
+ system := make([]any, 0, len(blocks)+1)
+ system = append(system, preamble[0])
+ for _, block := range blocks {
+ system = append(system, block)
+ }
+ encoded, err := json.Marshal(system)
+ if err != nil {
+ return body
}
+ fields["system"] = encoded
+
+ shaped, err := json.Marshal(fields)
+ if err != nil {
+ return body
+ }
+ return shaped
}
// SubscriptionLogin is one sign-in Gateway started, running or finished. It
@@ -314,7 +474,16 @@ func StartSubscriptionLogin(vendorId string) (*SubscriptionLogin, error) {
mux := http.NewServeMux()
mux.HandleFunc(vendor.callbackPath, func(writer http.ResponseWriter, request *http.Request) {
- finishSubscriptionLogin(session, vendor, request.URL.Query(), writer)
+ query := request.URL.Query()
+ // This vendor returns the state after a "#", which no browser sends to a
+ // server. The page below asks for the same address again with it in the
+ // query, and that request is the one that finishes the sign-in.
+ if vendor.stateInFragment && query.Get("state") == "" && query.Get("error") == "" {
+ writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(writer, fragmentPage)
+ return
+ }
+ finishSubscriptionLogin(session, vendor, query, writer)
})
session.server = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
@@ -476,6 +645,21 @@ func grantOf(session *loginSession, vendor *SubscriptionVendor, query url.Values
return nil, fmt.Errorf("the vendor sent no authorization code back")
}
+ if vendor.codeJson {
+ body, err := json.Marshal(map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ "state": session.state,
+ "redirect_uri": vendor.redirectUri,
+ "client_id": vendor.clientId,
+ "code_verifier": session.verifier,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return postSubscriptionToken(vendor, "application/json", strings.NewReader(string(body)), nil)
+ }
+
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
@@ -508,7 +692,9 @@ func postSubscriptionToken(vendor *SubscriptionVendor, contentType string, body
}
request.Header.Set("Content-Type", contentType)
request.Header.Set("Accept", "application/json")
- request.Header.Set("Originator", codexOriginator)
+ for name, value := range vendor.tokenHeaders {
+ request.Header.Set(name, value)
+ }
client := &http.Client{Timeout: exchangeTimeout, Transport: proxy.Transport()}
response, err := client.Do(request)
@@ -525,8 +711,12 @@ func postSubscriptionToken(vendor *SubscriptionVendor, contentType string, body
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IdToken string `json:"id_token"`
- Error string `json:"error"`
- ErrorText string `json:"error_description"`
+ ExpiresIn int64 `json:"expires_in"`
+ Account struct {
+ EmailAddress string `json:"email_address"`
+ } `json:"account"`
+ Error string `json:"error"`
+ ErrorText string `json:"error_description"`
}
readable := json.Unmarshal(answer, &granted) == nil
@@ -553,6 +743,10 @@ func postSubscriptionToken(vendor *SubscriptionVendor, contentType string, body
subscription.RefreshToken = previous.RefreshToken
}
}
+ if granted.ExpiresIn > 0 {
+ subscription.ExpiresAt = time.Now().Add(time.Duration(granted.ExpiresIn) * time.Second).UTC().Format(time.RFC3339)
+ }
+ fillClaim(&subscription.Account, granted.Account.EmailAddress)
describeGrant(subscription, granted.IdToken)
return subscription, nil
}
@@ -560,7 +754,7 @@ func postSubscriptionToken(vendor *SubscriptionVendor, contentType string, body
// 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 {
+ if expiry, ok := agent.DecodeJWTClaims(subscription.AccessToken)["exp"].(float64); ok && subscription.ExpiresAt == "" {
subscription.ExpiresAt = time.Unix(int64(expiry), 0).UTC().Format(time.RFC3339)
}
@@ -615,6 +809,15 @@ func forgetFinishedLoginsLocked() {
}
}
+// fragmentPage hands back the part of the address the browser kept to itself.
+const fragmentPage = `<!doctype html><meta charset="utf-8"><title>Signing in</title>` +
+ `<body style="font:16px system-ui;margin:4rem auto;max-width:32rem;text-align:center">` +
+ `<p style="color:#666">Finishing the sign-in...</p><script>` +
+ `var hash=location.hash.slice(1);` +
+ `if(hash){location.replace(location.pathname+location.search+"&state="+encodeURIComponent(hash));}` +
+ `else{document.body.innerHTML="<p>This sign-in carried no state, so it cannot be finished. Start it again.</p>";}` +
+ `</script>`
+
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/web/src/components/ProviderSourcePicker.tsx b/web/src/components/ProviderSourcePicker.tsx
index a764eaf..aab8a11 100644
--- a/web/src/components/ProviderSourcePicker.tsx
+++ b/web/src/components/ProviderSourcePicker.tsx
@@ -23,7 +23,7 @@ import {
providerSources,
chatgptSource,
customSource,
- subscriptionSource,
+ claudeSource,
vendorCategories,
type ProviderSource,
type VendorCategory,
@@ -35,7 +35,7 @@ import {
* instead, because their base URL would not.
*/
export function sourceTitle(source: ProviderSource) {
- if (source.key === subscriptionSource) {
+ if (source.key === claudeSource) {
return i18next.t("provider:Claude subscription");
}
if (source.key === chatgptSource) {
@@ -48,7 +48,7 @@ export function sourceTitle(source: ProviderSource) {
}
function sourceDetail(source: ProviderSource) {
- if (source.key === subscriptionSource) {
+ if (source.key === claudeSource) {
return i18next.t("provider:Claude subscription detail");
}
if (source.key === chatgptSource) {
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index 2cd8d44..5c08981 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -561,7 +561,7 @@ export interface ProviderSource {
provider: Partial<Provider>;
}
-export const subscriptionSource = "subscription";
+export const claudeSource = "claude";
export const chatgptSource = "chatgpt";
export const customSource = "custom";
@@ -576,14 +576,15 @@ export const chatgptCodexBaseUrl = "https://chatgpt.com/backend-api/codex";
*/
export const providerSources: ProviderSource[] = [
{
- key: subscriptionSource,
+ key: claudeSource,
label: "",
+ vendor: "anthropic",
provider: {
type: "anthropic",
baseUrl: providerPresets.find(preset => preset.type === "anthropic")?.baseUrl ?? "",
models: [],
apiKey: "",
- authMode: authClient,
+ authMode: authSubscription,
},
},
{
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 2f10bdc..487c024 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -1228,7 +1228,7 @@
"ChatGPT subscription": "ChatGPT subscription",
"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.",
+ "Claude subscription detail": "Sign in to Claude here. Gateway holds the subscription and spends it like a key, for any agent.",
"Clear": "Clear",
"Client auth hint": "Requests are forwarded with the credentials the client sent, so an agent already signed in with a subscription keeps its own login and no key is stored here. Only the agent that holds that sign-in can use this provider.",
"Client auth usage hint": "Only the base URL is set: a token here would replace the sign-in the client already has.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 9702b40..8f92e19 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -1414,7 +1414,7 @@
"ChatGPT subscription": "ChatGPT订阅",
"ChatGPT subscription detail": "在这里登录ChatGPT,Gateway保管这个订阅,像Key一样给任何Agent用。",
"Claude subscription": "Claude订阅登录",
- "Claude subscription detail": "直接用Claude Code已有的登录,原样转发给上游。不需要API Key,什么都不用填。",
+ "Claude subscription detail": "在这里登录Claude,Gateway保管这个订阅,像Key一样给任何Agent用。",
"Clear": "清空",
"Client auth hint": "请求会带着客户端自己的凭据转发上游,已经用订阅登录过的Agent可以继续用自己的登录,这里不保存任何Key。只有握着那个登录的Agent能用这个供应商。",
"Client auth usage hint": "只设置base URL:这里再设一个token会覆盖客户端已有的登录。",
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org