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

Reply via email to