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
The following commit(s) were added to refs/heads/master by this push:
new a0bbcf0 feat(channel): forward the caller's own credentials upstream
a0bbcf0 is described below
commit a0bbcf07fda660aad59fdef238d80da1d3a8a40b
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 22:10:05 2026 +0800
feat(channel): forward the caller's own credentials upstream
---
README.md | 10 ++++++-
README_zh.md | 10 ++++++-
agentprovider/claude_code.go | 9 ++++--
agentprovider/codex.go | 14 ++++++++++
controllers/agent_provider.go | 7 ++++-
controllers/proxy.go | 44 +++++++++++++++++++++++++++--
controllers/proxy_test.go | 18 ++++++++++++
object/channel.go | 55 +++++++++++++++++++++++++++++++++++++
web/src/components/EnvSnippet.tsx | 4 ++-
web/src/components/ProviderCard.tsx | 7 +++--
web/src/lib/channels.ts | 24 ++++++++++++----
web/src/locales/en/data.json | 12 ++++++--
web/src/locales/zh/data.json | 12 ++++++--
web/src/pages/ChannelEditPage.tsx | 46 +++++++++++++++++++++++++------
web/src/pages/ChannelListPage.tsx | 44 +++++++++++++++++++++--------
web/src/types.ts | 1 +
16 files changed, 277 insertions(+), 40 deletions(-)
diff --git a/README.md b/README.md
index 443066b..915b713 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,7 @@ That is the whole installation. Gateway keeps its data in a
SQLite file inside i
| --- | --- | --- |
| **Agents** | Every AI coding agent installed on this machine — Claude Code,
Codex CLI, Cursor and more. Click **Patch** on one and its activity streams
into the page live. | Nothing |
| **Skills & MCP** | Every skill and MCP server of every agent in one table.
Add an MCP server to one agent or to several at once, open one, delete it, or
copy it into another agent. | Nothing |
-| **Channels** | One endpoint in front of your model vendors. Gateway holds
the API key, so the agents never have it. | A vendor API key |
+| **Channels** | 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. | A vendor API key, or nothing at all |
| **LLM Records** | Every request an agent relayed: the full system prompt,
every message and tool call, the schema of every tool the model was offered,
plus tokens and cost. | A channel, and `llmRecordMode` — see [Recording
prompts](#recording-prompts) |
| **Advanced → Sites** | The reverse-proxy WAF: per-site routing, rules,
certificates and analytics. | Turning the proxy on — see [Turning the WAF proxy
on](#turning-the-waf-proxy-on) |
@@ -86,6 +86,14 @@ export ANTHROPIC_AUTH_TOKEN="casbin-gateway"
The token is a placeholder — the agent refuses to start without one, and
Gateway authenticates upstream with the channel's own key.
+### No API key: keep the sign-in the agent already has
+
+An agent signed in with a ChatGPT or Claude subscription has no API key to
paste. Set the channel's **Authentication** to **the caller's own login** and
it needs none: the base URL points at the vendor, and every request is
forwarded with the credentials the agent itself sent, so it keeps its own
sign-in. Leave **Models** empty and the channel accepts any model name.
+
+The environment snippet for such a channel sets the base URL and nothing else
— a token there would replace the sign-in the agent already has. Gateway
records and routes the traffic exactly as it does for a channel with a key; it
just never sees one.
+
+Codex is the exception: its ChatGPT sign-in talks to a different API than the
chat completions Gateway relays, so a Codex CLI still needs a channel with an
API key.
+
### Stopping, upgrading, removing
- **Stop**: `Ctrl-C`. **Start again**: `casbin-gateway`, from any directory —
the command is a wrapper that always starts Gateway in its install directory,
where its data lives.
diff --git a/README_zh.md b/README_zh.md
index e7f2bb0..30b7bd6 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -65,7 +65,7 @@ irm
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/insta
| --- | --- | --- |
| **Agents** | 本机安装的每一个 AI 编程 Agent —— Claude Code、Codex CLI、Cursor 等等。点其中一个的
**Patch**,它的活动就会实时流进页面。 | 无 |
| **Skills & MCP** | 所有 Agent 的所有技能和 MCP 服务器汇总在一张表里。可以给一个或多个 Agent 添加 MCP
服务器,也可以打开、删除,或复制到另一个 Agent。 | 无 |
-| **Channels** | 挡在模型厂商前面的统一入口。API Key 由 Gateway 持有,Agent 拿不到。 | 一个厂商的 API Key
|
+| **Channels** | 挡在模型厂商前面的统一入口。API Key 由 Gateway 持有,Agent 拿不到;也可以转发 Agent
自己的登录,什么都不持有。 | 一个厂商的 API Key,或者什么都不用 |
| **LLM Records** | Agent 转发的每一次请求:完整的 system prompt、每一条消息和工具调用、模型可用的每个工具的
schema,以及 token 数和费用。 | 一个 Channel,以及 `llmRecordMode` —— 见[记录提示词](#记录提示词) |
| **Advanced → Sites** | 反向代理 WAF:按站点的路由、规则、证书和统计。 | 打开代理 —— 见[开启 WAF
反向代理](#开启-waf-反向代理) |
@@ -86,6 +86,14 @@ export ANTHROPIC_AUTH_TOKEN="casbin-gateway"
这个 token 只是占位符 —— Agent 没有它就拒绝启动,而 Gateway 会用 Channel 自己的 Key 去认证上游。
+### 没有 API Key:沿用 Agent 已有的登录
+
+用 ChatGPT 或 Claude 订阅登录的 Agent 根本没有 API Key 可填。把 Channel
的**认证方式**设成**调用方自己的登录**,它就不需要 Key:base URL 指向厂商,每个请求都带着 Agent 自己发来的凭据转发上游,Agent
继续用它已有的登录。**Models** 留空,这个 Channel 就接受任何模型名。
+
+这种 Channel 的环境变量片段只设 base URL,不设别的 —— 在这里再设一个 token 会覆盖 Agent 已有的登录。记录和路由和有
Key 的 Channel 完全一样,只是 Gateway 从头到尾没见过任何 Key。
+
+Codex 是例外:它的 ChatGPT 登录走的是另一套 API,不是 Gateway 转发的 chat completions,所以 Codex CLI
仍然需要一个带 API Key 的 Channel。
+
### 停止、升级、卸载
- **停止**:`Ctrl-C`。**再次启动**:在任意目录执行 `casbin-gateway` ——
这个命令是一个包装脚本,总是在安装目录(数据所在的地方)里启动 Gateway。
diff --git a/agentprovider/claude_code.go b/agentprovider/claude_code.go
index e462531..39a9f88 100644
--- a/agentprovider/claude_code.go
+++ b/agentprovider/claude_code.go
@@ -160,8 +160,13 @@ func (claudeCodeWriter) env(endpoint Endpoint, masked
bool) map[string]any {
}
env := map[string]any{
- "ANTHROPIC_BASE_URL": endpoint.BaseUrl,
- "ANTHROPIC_AUTH_TOKEN": token,
+ "ANTHROPIC_BASE_URL": endpoint.BaseUrl,
+ }
+ // A channel that forwards the caller's own credentials has no token to
+ // write, and writing one would override the sign-in Claude Code already
+ // has, which is the login the switch is meant to keep using.
+ if endpoint.ApiKey != "" {
+ env["ANTHROPIC_AUTH_TOKEN"] = token
}
if endpoint.Model != "" {
env["ANTHROPIC_MODEL"] = endpoint.Model
diff --git a/agentprovider/codex.go b/agentprovider/codex.go
index 66dc5b9..e57568d 100644
--- a/agentprovider/codex.go
+++ b/agentprovider/codex.go
@@ -15,6 +15,7 @@
package agentprovider
import (
+ "errors"
"fmt"
"path/filepath"
"strings"
@@ -41,6 +42,11 @@ const (
var codexProviderPath = []string{"model_providers", codexProviderName}
+// errCodexNoKey rejects a channel that forwards the caller's own credentials:
+// Codex reads its key from auth.json, and its own ChatGPT sign-in speaks a
+// different API than the chat completions this provider entry points at.
+var errCodexNoKey = errors.New("Codex needs an API key, so it cannot use a
channel that forwards the credentials of the caller")
+
type codexWriter struct {
id string
}
@@ -59,6 +65,10 @@ func (w codexWriter) AgentId() string { return w.id }
func (codexWriter) Protocol() string { return "openai" }
func (w codexWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+ if endpoint.ApiKey == "" {
+ return nil, errCodexNoKey
+ }
+
home, err := agentmonitor.ResolveCodexHome(target.Path, target.Owner)
if err != nil {
return nil, err
@@ -81,6 +91,10 @@ func (w codexWriter) Plan(target Target, endpoint Endpoint)
([]File, error) {
}
func (w codexWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
+ if endpoint.ApiKey == "" {
+ return nil, errCodexNoKey
+ }
+
configPath, authPath, err := w.paths(target)
if err != nil {
return nil, err
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index fac55d4..9743a44 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -136,7 +136,12 @@ func agentEndpoint(agentId string)
(agentprovider.Endpoint, error) {
}
endpoint.BaseUrl = gatewayAgentUrl(agentId)
- endpoint.ApiKey = gatewayToken
+ // A client-auth channel forwards whatever the agent sends, so it must
keep
+ // sending its own credentials: a placeholder token written into the
agent's
+ // configuration would replace the sign-in it already has.
+ if !object.UsesClientAuth(channel) {
+ endpoint.ApiKey = gatewayToken
+ }
return endpoint, nil
}
diff --git a/controllers/proxy.go b/controllers/proxy.go
index db8adc5..1a9a9c1 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -263,7 +263,7 @@ func (c *ApiController) forwardToChannels(channels
[]*object.Channel, route *pro
usableChannels := []*object.Channel{}
skipReason := ""
for _, channel := range channels {
- if reason := channelUnusableReason(channel,
route.target.protocol); reason != "" {
+ if reason := c.channelUnusableReason(channel,
route.target.protocol); reason != "" {
beego.Error("skipped channel", channel.GetId()+":",
reason)
skipReason = reason
continue
@@ -331,6 +331,9 @@ func (c *ApiController) forwardToChannel(channel
*object.Channel, route *proxyRo
}
upstreamReq.Header.Set("Content-Type", "application/json")
object.SetChannelAuth(upstreamReq.Header, channel)
+ if object.UsesClientAuth(channel) {
+ c.copyClientAuthHeaders(upstreamReq.Header)
+ }
if route.target.protocol == object.ProtocolAnthropic {
c.copyAnthropicHeaders(upstreamReq.Header)
}
@@ -396,6 +399,38 @@ func reportChannelStatus(channel *object.Channel,
statusCode int) {
}
}
+// clientAuthHeaders are forwarded verbatim by a channel that authenticates
with
+// the caller's own credentials: the credential itself, plus what the vendors
+// expect beside a token issued to a CLI rather than to an API account. It is
an
+// allowlist, so nothing else the client sent (a browser cookie, say) leaks
+// upstream.
+var clientAuthHeaders = []string{
+ "Authorization",
+ "X-Api-Key",
+ "User-Agent",
+ "X-App",
+ "Openai-Beta",
+ "Openai-Organization",
+ "Openai-Project",
+ "Chatgpt-Account-Id",
+}
+
+// hasClientCredentials reports whether the client request carries a credential
+// a client-auth channel could forward.
+func (c *ApiController) hasClientCredentials() bool {
+ header := c.Ctx.Request.Header
+ return header.Get("Authorization") != "" || header.Get("X-Api-Key") !=
""
+}
+
+func (c *ApiController) copyClientAuthHeaders(dst http.Header) {
+ for _, name := range clientAuthHeaders {
+ dst.Del(name)
+ for _, value := range c.Ctx.Request.Header.Values(name) {
+ dst.Add(name, value)
+ }
+ }
+}
+
// copyAnthropicHeaders passes the client's API version and beta opt-ins on to
// the upstream: they select response features, so dropping them would answer a
// different request than the one that was made.
@@ -491,7 +526,7 @@ func isRetryableStatus(statusCode int) bool {
// channelUnusableReason reports why the proxy cannot forward to a channel, or
// an empty string when it can.
-func channelUnusableReason(channel *object.Channel, protocol string) string {
+func (c *ApiController) channelUnusableReason(channel *object.Channel,
protocol string) string {
if !object.IsChannelTypeSupported(channel) {
return fmt.Sprintf("the %s channel type is not supported",
channel.Type)
}
@@ -501,6 +536,11 @@ func channelUnusableReason(channel *object.Channel,
protocol string) string {
if channel.BaseUrl == "" {
return "channel base URL is not configured"
}
+ // Without a credential to forward the upstream would answer 401, which
+ // reads as a broken channel rather than a client that sent no key.
+ if object.UsesClientAuth(channel) && !c.hasClientCredentials() {
+ return fmt.Sprintf("channel %s forwards the credentials of the
caller, but the request carries none", channel.GetId())
+ }
return ""
}
diff --git a/controllers/proxy_test.go b/controllers/proxy_test.go
index 99d40fc..1772fda 100644
--- a/controllers/proxy_test.go
+++ b/controllers/proxy_test.go
@@ -76,6 +76,9 @@ func TestChannelUnusableReason(t *testing.T) {
openAi := object.ProtocolOpenAi
anthropic := object.ProtocolAnthropic
+ c, _ := newTestApiController()
+ channelUnusableReason := c.channelUnusableReason
+
if reason := channelUnusableReason(&object.Channel{Type: "claude",
BaseUrl: "https://example.com"}, openAi); !strings.Contains(reason, "not
supported") {
t.Errorf("the claude channel type should be rejected, got: %s",
reason)
}
@@ -98,6 +101,21 @@ func TestChannelUnusableReason(t *testing.T) {
if reason := channelUnusableReason(&object.Channel{Owner: "admin",
Name: "gpt", Type: "openai", BaseUrl: "https://api.openai.com/v1"}, anthropic);
!strings.Contains(reason, "does not speak") {
t.Errorf("an openai channel should be rejected for an anthropic
request, got: %s", reason)
}
+
+ passthrough := &object.Channel{
+ Owner: "admin",
+ Name: "passthrough",
+ Type: "openai",
+ BaseUrl: "https://api.openai.com/v1",
+ AuthMode: object.ChannelAuthClient,
+ }
+ if reason := channelUnusableReason(passthrough, openAi);
!strings.Contains(reason, "carries none") {
+ t.Errorf("a client-auth channel should be rejected without a
credential, got: %s", reason)
+ }
+ c.Ctx.Request.Header.Set("Authorization", "Bearer token")
+ if reason := channelUnusableReason(passthrough, openAi); reason != "" {
+ t.Errorf("a client-auth channel should be usable with a
credential, got: %s", reason)
+ }
}
func TestRelayResponse(t *testing.T) {
diff --git a/object/channel.go b/object/channel.go
index 6202f75..0df2cb3 100644
--- a/object/channel.go
+++ b/object/channel.go
@@ -134,6 +134,22 @@ var (
channelStatuses = []string{"enabled", "disabled"}
)
+// The two ways a channel authenticates upstream. In ChannelAuthClient mode the
+// gateway forwards the credentials the caller sent instead of a stored key, so
+// an agent already signed in with a subscription keeps its own login.
+const (
+ ChannelAuthChannel = "channel"
+ ChannelAuthClient = "client"
+)
+
+var channelAuthModes = []string{ChannelAuthChannel, ChannelAuthClient}
+
+// UsesClientAuth reports whether the channel authenticates with the caller's
+// own credentials. An empty AuthMode is a row written before the field
existed.
+func UsesClientAuth(channel *Channel) bool {
+ return channel.AuthMode == ChannelAuthClient
+}
+
// The wire formats the gateway can speak. A request in one of them can only be
// forwarded to a channel whose upstream speaks the same one.
const (
@@ -178,6 +194,8 @@ type Channel struct {
// ApiKey holds base64 ciphertext, not the bare key, when
// "apiKeyEncryptionKey" is set in app.conf, hence the wider column.
ApiKey string `xorm:"varchar(1000)" json:"apiKey"`
+ // AuthMode selects whose credentials reach the upstream, see
UsesClientAuth.
+ AuthMode string `xorm:"varchar(100)" json:"authMode"`
// 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"`
@@ -261,6 +279,9 @@ func validateChannel(channel *Channel) error {
if channel.Models == nil {
channel.Models = []string{}
}
+ if channel.AuthMode == "" {
+ channel.AuthMode = ChannelAuthChannel
+ }
if !containsString(channelTypes, channel.Type) {
return fmt.Errorf("invalid channel type: %s", channel.Type)
@@ -268,6 +289,15 @@ func validateChannel(channel *Channel) error {
if !containsString(channelStatuses, channel.Status) {
return fmt.Errorf("invalid channel status: %s", channel.Status)
}
+ if !containsString(channelAuthModes, channel.AuthMode) {
+ return fmt.Errorf("invalid channel auth mode: %s",
channel.AuthMode)
+ }
+
+ // A channel 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 channel.AuthMode == ChannelAuthClient {
+ channel.ApiKey = ""
+ }
if channel.BaseUrl != "" {
if err := validateBaseUrl(channel.BaseUrl); err != nil {
@@ -352,6 +382,12 @@ func BuildChannelUrl(baseUrl string, protocol string,
endpoint string) (string,
// SetChannelAuth puts the channel's credentials on an upstream request, in the
// header the channel's protocol authenticates with.
func SetChannelAuth(header http.Header, channel *Channel) {
+ // The caller's own credentials are already on the request, and the
proxy
+ // copies them across itself.
+ if UsesClientAuth(channel) {
+ return
+ }
+
if ChannelProtocol(channel) == ProtocolAnthropic {
header.Set("X-Api-Key", channel.ApiKey)
return
@@ -486,6 +522,13 @@ func TestChannelConnectivity(channel *Channel) (bool, int,
string) {
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+
+ // A client-auth channel has no key to probe with, so an upstream that
+ // rejects the unauthenticated probe has still proven it is reachable.
+ if UsesClientAuth(stored) && (resp.StatusCode ==
http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
+ return true, resp.StatusCode, "reachable, and authenticated
with the caller's own credentials"
+ }
+
return resp.StatusCode >= 200 && resp.StatusCode < 300,
resp.StatusCode, resp.Status
}
@@ -504,7 +547,18 @@ func GetChannelsByModel(model string) ([]*Channel, error) {
// The models are JSON-serialized into a single column, so the match
cannot
// be pushed down into the query.
matchedChannels := []*Channel{}
+ // A channel 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 channels are tried after the ones that
+ // name the model, so a wildcard never takes traffic from an exact
match.
+ wildcardChannels := []*Channel{}
for _, channel := range channels {
+ if len(channel.Models) == 0 {
+ if UsesClientAuth(channel) {
+ wildcardChannels = append(wildcardChannels,
channel)
+ }
+ continue
+ }
for _, channelModel := range channel.Models {
if channelModel == model {
matchedChannels = append(matchedChannels,
channel)
@@ -512,6 +566,7 @@ func GetChannelsByModel(model string) ([]*Channel, error) {
}
}
}
+ matchedChannels = append(matchedChannels, wildcardChannels...)
decryptChannels(matchedChannels)
diff --git a/web/src/components/EnvSnippet.tsx
b/web/src/components/EnvSnippet.tsx
index 24e9a28..c15ca6b 100644
--- a/web/src/components/EnvSnippet.tsx
+++ b/web/src/components/EnvSnippet.tsx
@@ -31,13 +31,15 @@ export function EnvSnippet({
protocol,
baseUrl,
defaultShell = "bash",
+ includeToken = true,
}: {
protocol: string;
baseUrl: string;
defaultShell?: Shell;
+ includeToken?: boolean;
}) {
const [shell, setShell] = React.useState<Shell>(defaultShell);
- const snippet = envSnippet(protocol, baseUrl, shell);
+ const snippet = envSnippet(protocol, baseUrl, shell, includeToken);
return (
<div className="space-y-2">
diff --git a/web/src/components/ProviderCard.tsx
b/web/src/components/ProviderCard.tsx
index 85da2c6..f96f26b 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -41,7 +41,7 @@ import {
} from "@/components/ui/select";
import {Switch} from "@/components/ui/switch";
import {agentProxyBaseUrl, agentSetupNoteKey, directMode, gatewayMode} from
"@/lib/agents";
-import {channelProtocol, shellForPath} from "@/lib/channels";
+import {channelProtocol, shellForPath, usesClientAuth} from "@/lib/channels";
import {cn} from "@/lib/utils";
import type {Agent, AgentProviderFile, Channel, ChannelHealth} from "@/types";
@@ -350,8 +350,11 @@ export function ProviderCard({
protocol={channelProtocol(bound.type)}
baseUrl={agentProxyBaseUrl(agent.agentId)}
defaultShell={shellForPath(agent.path)}
+ includeToken={!usesClientAuth(bound)}
/>
- <p className="text-sm
text-muted-foreground">{i18next.t("agent:Token hint")}</p>
+ <p className="text-sm text-muted-foreground">
+ {usesClientAuth(bound) ? i18next.t("agent:Client auth token
hint") : i18next.t("agent:Token hint")}
+ </p>
{noteKey === "" ? null : (
<p className="text-sm
text-muted-foreground">{i18next.t(noteKey)}</p>
)}
diff --git a/web/src/lib/channels.ts b/web/src/lib/channels.ts
index 266c060..0e9d8eb 100644
--- a/web/src/lib/channels.ts
+++ b/web/src/lib/channels.ts
@@ -19,6 +19,14 @@ export function channelProtocol(type: string) {
return type === "anthropic" ? "anthropic" : "openai";
}
+/** Mirrors object.ChannelAuthClient on the server. */
+export const authClient = "client";
+
+/** Mirrors object.UsesClientAuth: whose credentials reach the upstream. */
+export function usesClientAuth(channel: {authMode?: string} | undefined) {
+ return channel?.authMode === authClient;
+}
+
/** 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"},
@@ -34,13 +42,17 @@ const placeholderToken = "casbin-gateway";
export const shells = ["bash", "PowerShell"] as const;
export type Shell = (typeof shells)[number];
-/** The lines that point a client of one wire format at a base URL. */
-export function envSnippet(protocol: string, baseUrl: string, shell: Shell) {
+/**
+ * The lines that point a client of one wire format at a base URL. A
client-auth
+ * channel forwards the client's own credentials, so the token is left out
+ * there: setting it would replace the sign-in the client already has.
+ */
+export function envSnippet(protocol: string, baseUrl: string, shell: Shell,
includeToken = true) {
const env = protocolEnv[protocol] ?? protocolEnv.openai;
- const variables: [string, string][] = [
- [env.baseUrl, baseUrl],
- [env.token, placeholderToken],
- ];
+ const variables: [string, string][] = [[env.baseUrl, baseUrl]];
+ if (includeToken) {
+ variables.push([env.token, placeholderToken]);
+ }
return variables
.map(([name, value]) =>
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 1525353..a2b4759 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -41,6 +41,7 @@
"Channel hint": "With no channel selected, this agent still talks to its
own upstream and Gateway only watches it.",
"Channel saved": "Channel saved",
"Claude Code config hint": "The same variables can live in the \"env\"
block of ~/.claude/settings.json, so every session picks them up without
exporting anything.",
+ "Client auth token hint": "This channel forwards the agent's own
credentials, so no token is set: the agent keeps the sign-in it already has.",
"Codex CLI config hint": "The base URL can also be a model provider in
~/.codex/config.toml, selected with model_provider.",
"Configuration preview": "Configuration preview",
"Configuration restored": "Configuration restored",
@@ -274,14 +275,20 @@
"API Key": "API Key",
"API Key hint": "The stored key is never sent back to the browser. Leave
*** as it is to keep the current key, or clear the field to remove it.",
"API Key ownership hint": "Stored by Gateway and never handed to an
agent.",
+ "Any model": "Any model",
+ "Any model hint": "Leave this empty to accept every model name, which is
what a channel that forwards the caller's login usually wants.",
+ "Authentication": "Authentication",
"Base URL": "Base URL",
"Base URL hint": "Requests will be sent to",
+ "Caller's own login": "The caller's own login",
"Channel": "Channel",
"Channel added successfully": "Channel added successfully",
"Channel deleted successfully": "Channel deleted successfully",
"Channel not found": "Channel not found",
"Channel saved": "Channel saved",
"Channels": "Channels",
+ "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. Codex is the exception: its ChatGPT sign-in
speaks a different API.",
+ "Client auth usage hint": "Only the base URL is set: a token here would
replace the sign-in the client already has.",
"Connection Failed": "Connection Failed",
"Connection Successful": "Connection Successful",
"Cooling down": "Cooling down",
@@ -301,14 +308,15 @@
"Models hint": "The model names this channel serves, used to route
requests that arrive by model name.",
"New Channel": "New Channel",
"No channels yet": "No channels yet",
- "No channels yet detail": "A channel is your own vendor account: a base
URL, an API key, and the models it serves. Add one, then bind it to an agent to
route that agent's requests through it.",
+ "No channels yet detail": "A channel is your own vendor account: a base
URL, the models it serves, and either an API key or the caller's own login
forwarded upstream. Add one, then bind it to an agent to route that agent's
requests through it.",
"Not used yet": "Not used yet",
- "Page description": "One endpoint in front of your model vendors. Gateway
holds the API key, so the agents never have it.",
+ "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.",
"Priority": "Priority",
"Priority hint": "Smaller number means higher priority, 0 is the highest;
when multiple channels serve the same model, the one with the smaller value is
used first.",
"Recovering": "Recovering",
"Retried at": "retried at",
"Status": "Status",
+ "Stored API key": "Stored API key",
"Test Connectivity": "Test Connectivity",
"Type": "Type",
"Usage": "Usage",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index a2e35b4..05f342a 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -41,6 +41,7 @@
"Channel hint": "未选择渠道时,该 Agent 仍直连自己的上游,网关只做观测。",
"Channel saved": "渠道已保存",
"Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env
段,这样不用每次 export,所有会话都会生效。",
+ "Client auth token hint": "该通道转发 Agent 自己的凭据,所以不设置 token,Agent 继续用它已有的登录。",
"Codex CLI config hint": "base URL 也可以写成 ~/.codex/config.toml 里的一个 model
provider,再用 model_provider 选中它。",
"Configuration preview": "配置预览",
"Configuration restored": "配置已还原",
@@ -274,14 +275,20 @@
"API Key": "API 密钥",
"API Key hint": "已保存的密钥不会返回给浏览器。保持 *** 不变即可沿用当前密钥,清空该字段则删除密钥。",
"API Key ownership hint": "由 Gateway 保存,不会交给任何 Agent。",
+ "Any model": "任意模型",
+ "Any model hint": "留空表示接受任何模型名,转发调用方登录的通道一般都这样配。",
+ "Authentication": "认证方式",
"Base URL": "基础 URL",
"Base URL hint": "实际请求将发往",
+ "Caller's own login": "调用方自己的登录",
"Channel": "渠道",
"Channel added successfully": "渠道添加成功",
"Channel deleted successfully": "渠道删除成功",
"Channel not found": "渠道不存在",
"Channel saved": "渠道已保存",
"Channels": "渠道",
+ "Client auth hint": "请求会带着客户端自己的凭据转发上游,已经用订阅登录过的 Agent 可以继续用自己的登录,这里不保存任何
Key。Codex 除外:它的 ChatGPT 登录走的是另一套 API。",
+ "Client auth usage hint": "只设置 base URL:这里再设一个 token 会覆盖客户端已有的登录。",
"Connection Failed": "连接失败",
"Connection Successful": "连接成功",
"Cooling down": "冷却中",
@@ -301,14 +308,15 @@
"Models hint": "这个 Channel 提供的模型名,按模型名路由的请求靠它匹配。",
"New Channel": "新建渠道",
"No channels yet": "还没有 Channel",
- "No channels yet detail": "一个 Channel 就是你自己的厂商账号:base URL、API
Key,以及它提供的模型。先添加一个,再把它绑定到 Agent,该 Agent 的请求就会走它。",
+ "No channels yet detail": "一个 Channel 就是你自己的厂商账号:base URL、它提供的模型,以及一个 API
Key,或者转发调用方自己的登录。先建一个,再把它绑定到 Agent,该 Agent 的请求就会走它。",
"Not used yet": "尚未使用",
- "Page description": "挡在模型厂商前面的统一入口。API Key 由 Gateway 持有,Agent 拿不到。",
+ "Page description": "挡在模型厂商前面的统一入口。API Key 由 Gateway 保管,Agent 拿不到;也可以转发
Agent 自己的登录,什么都不保管。",
"Priority": "优先级",
"Priority hint": "数字越小优先级越高,0 为最高;同模型多渠道时优先使用数值小的渠道。",
"Recovering": "恢复中",
"Retried at": "将于",
"Status": "状态",
+ "Stored API key": "Gateway 保存的 API Key",
"Test Connectivity": "测试连接",
"Type": "类型",
"Usage": "使用方法",
diff --git a/web/src/pages/ChannelEditPage.tsx
b/web/src/pages/ChannelEditPage.tsx
index ce40799..b7a02f2 100644
--- a/web/src/pages/ChannelEditPage.tsx
+++ b/web/src/pages/ChannelEditPage.tsx
@@ -32,6 +32,7 @@ import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {TagsInput} from "@/components/ui/tags-input";
import {
+ authClient,
baseUrlPlaceholder,
baseUrlPresets,
channelProtocol,
@@ -39,6 +40,7 @@ import {
localShell,
modelPresets,
modelsPlaceholder,
+ usesClientAuth,
} from "@/lib/channels";
import type {Channel, ChannelTestResult} from "@/types";
@@ -250,18 +252,40 @@ export default function ChannelEditPage() {
/>
</Field>
<Field
- label={i18next.t("channel:API Key")}
- htmlFor="channel-api-key"
- hint={i18next.t("channel:API Key hint")}
+ label={i18next.t("channel:Authentication")}
+ hint={usesClientAuth(channel) ? i18next.t("channel:Client auth
hint") : undefined}
>
- <PasswordInput
- id="channel-api-key"
- placeholder="sk-..."
- value={channel.apiKey}
- onChange={event => setField("apiKey", event.target.value)}
+ <SimpleSelect
+ value={channel.authMode}
+ // The stored key is meaningless once the caller's own is
forwarded,
+ // and the server drops it on save anyway.
+ onChange={value =>
+ setChannel(current => (current ? {...current, authMode: value,
apiKey: ""} : current))
+ }
+ options={[
+ {label: i18next.t("channel:Stored API key"), value: "channel"},
+ {label: i18next.t("channel:Caller's own login"), value:
authClient},
+ ]}
/>
</Field>
- <Field label={i18next.t("channel:Models")}>
+ {usesClientAuth(channel) ? null : (
+ <Field
+ label={i18next.t("channel:API Key")}
+ htmlFor="channel-api-key"
+ hint={i18next.t("channel:API Key hint")}
+ >
+ <PasswordInput
+ id="channel-api-key"
+ placeholder="sk-..."
+ value={channel.apiKey}
+ onChange={event => setField("apiKey", event.target.value)}
+ />
+ </Field>
+ )}
+ <Field
+ label={i18next.t("channel:Models")}
+ hint={usesClientAuth(channel) ? i18next.t("channel:Any model hint")
: undefined}
+ >
<TagsInput
value={channel.models}
placeholder={modelsPlaceholder(channel.type)}
@@ -305,7 +329,11 @@ export default function ChannelEditPage() {
protocol={channelProtocol(channel.type)}
baseUrl={gatewayBaseUrl(channelProtocol(channel.type))}
defaultShell={localShell()}
+ includeToken={!usesClientAuth(channel)}
/>
+ {usesClientAuth(channel) ? (
+ <p className="text-muted-foreground
text-sm">{i18next.t("channel:Client auth usage hint")}</p>
+ ) : null}
<p className="text-muted-foreground text-sm">{i18next.t("channel:Model
routing hint")}</p>
</Section>
</PageContainer>
diff --git a/web/src/pages/ChannelListPage.tsx
b/web/src/pages/ChannelListPage.tsx
index b1f8c63..06b10b0 100644
--- a/web/src/pages/ChannelListPage.tsx
+++ b/web/src/pages/ChannelListPage.tsx
@@ -34,11 +34,13 @@ import {Input} from "@/components/ui/input";
import {TagsInput} from "@/components/ui/tags-input";
import {SimpleTooltip} from "@/components/ui/tooltip";
import {
+ authClient,
baseUrlPlaceholder,
baseUrlPresets,
channelPresets,
modelPresets,
modelsPlaceholder,
+ usesClientAuth,
type ChannelPreset,
} from "@/lib/channels";
import type {Account, Channel, ChannelHealth} from "@/types";
@@ -55,6 +57,7 @@ function newChannel(owner: string): Channel {
priority: 0,
baseUrl: "",
apiKey: "",
+ authMode: "channel",
};
}
@@ -237,9 +240,9 @@ export default function ChannelListPage({account}:
{account: Account}) {
key: "models",
dataIndex: "models",
width: "220px",
- render: (models: string[]) =>
+ render: (models: string[], record) =>
!models || models.length === 0 ? (
- "-"
+ usesClientAuth(record) ? <Badge
variant="muted">{i18next.t("channel:Any model")}</Badge> : "-"
) : (
<div className="flex flex-wrap gap-1">
{models.map(model => (
@@ -451,18 +454,37 @@ export default function ChannelListPage({account}:
{account: Account}) {
/>
</Field>
<Field
- label={i18next.t("channel:API Key")}
- htmlFor="channel-api-key"
- hint={i18next.t("channel:API Key ownership hint")}
+ label={i18next.t("channel:Authentication")}
+ hint={usesClientAuth(form) ? i18next.t("channel:Client auth hint") :
undefined}
>
- <PasswordInput
- id="channel-api-key"
- placeholder="sk-..."
- value={form.apiKey}
- onChange={event => setFormField("apiKey", event.target.value)}
+ <SimpleSelect
+ 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("channel:Stored API key"), value: "channel"},
+ {label: i18next.t("channel:Caller's own login"), value:
authClient},
+ ]}
/>
</Field>
- <Field label={i18next.t("channel:Models")}
hint={i18next.t("channel:Models hint")}>
+ {usesClientAuth(form) ? null : (
+ <Field
+ label={i18next.t("channel:API Key")}
+ htmlFor="channel-api-key"
+ hint={i18next.t("channel:API Key ownership hint")}
+ >
+ <PasswordInput
+ id="channel-api-key"
+ placeholder="sk-..."
+ value={form.apiKey}
+ onChange={event => setFormField("apiKey", event.target.value)}
+ />
+ </Field>
+ )}
+ <Field
+ label={i18next.t("channel:Models")}
+ hint={usesClientAuth(form) ? i18next.t("channel:Any model hint") :
i18next.t("channel:Models hint")}
+ >
<TagsInput
value={form.models}
placeholder={modelsPlaceholder(form.type)}
diff --git a/web/src/types.ts b/web/src/types.ts
index 4cb8adc..67f4ac3 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -156,6 +156,7 @@ export interface Channel {
priority: number;
baseUrl: string;
apiKey: string;
+ authMode: string;
}
export interface ChannelTestResult {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]