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]
