You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/08/22 13:29:26 UTC
(casbin-gateway) branch master updated: refactor(provider)!: rename channel to provider
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 54f9c92 refactor(provider)!: rename channel to provider
54f9c92 is described below
commit 54f9c9221a9f87ebfae16fad54b9b28835e7b576
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 22 21:29:18 2026 +0800
refactor(provider)!: rename channel to provider
---
README.md | 20 +-
README_zh.md | 20 +-
agentprovider/claude_code.go | 4 +-
agentprovider/codex.go | 4 +-
agentprovider/provider.go | 24 +-
agentprovider/state.go | 2 +-
conf/app.conf | 2 +-
controllers/agent.go | 40 +--
controllers/agent_provider.go | 34 +-
controllers/{provider.go => casdoor_provider.go} | 2 +-
controllers/channel.go | 283 ----------------
controllers/llm_record.go | 2 +-
controllers/provider.go | 262 ++++++++++++++-
controllers/proxy.go | 138 ++++----
controllers/proxy_record.go | 6 +-
controllers/proxy_test.go | 72 ++--
object/agent.go | 106 +++---
object/agent_test.go | 68 ++--
object/channel_health.go | 167 ---------
object/llm_record.go | 52 +--
object/ormer.go | 4 +-
object/{channel.go => provider.go} | 372 ++++++++++-----------
...nnel_crypto_test.go => provider_crypto_test.go} | 78 ++---
object/provider_health.go | 167 +++++++++
.../{channel_url_test.go => provider_url_test.go} | 26 +-
routers/router.go | 22 +-
util/crypto_test.go | 10 +-
web/src/App.tsx | 10 +-
web/src/backend/AgentBackend.ts | 12 +-
web/src/backend/ChannelBackend.ts | 66 ----
web/src/backend/LlmRecordBackend.ts | 2 +-
web/src/backend/MiscBackend.ts | 6 +-
web/src/backend/ProviderBackend.ts | 66 ++++
web/src/components/EnvSnippet.tsx | 2 +-
web/src/components/ProviderCard.tsx | 98 +++---
...nnelModelsField.tsx => ProviderModelsField.tsx} | 46 +--
...elSourcePicker.tsx => ProviderSourcePicker.tsx} | 24 +-
web/src/components/shared/status-badge.tsx | 2 +-
web/src/lib/agents.ts | 18 +-
web/src/lib/{channels.ts => providers.ts} | 54 +--
web/src/locales/en/data.json | 172 +++++-----
web/src/locales/zh/data.json | 172 +++++-----
web/src/nav.ts | 2 +-
web/src/pages/AgentDashboardPage.tsx | 34 +-
web/src/pages/AgentDetailPage.tsx | 38 +--
web/src/pages/AgentsPage.tsx | 14 +-
web/src/pages/LlmRecordsPage.tsx | 4 +-
.../{ChannelEditPage.tsx => ProviderEditPage.tsx} | 156 ++++-----
.../{ChannelListPage.tsx => ProviderListPage.tsx} | 196 +++++------
web/src/pages/SiteEditPage.tsx | 2 +-
web/src/types.ts | 34 +-
51 files changed, 1590 insertions(+), 1627 deletions(-)
diff --git a/README.md b/README.md
index 915b713..34a2019 100644
--- a/README.md
+++ b/README.md
@@ -65,8 +65,8 @@ 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 — 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) |
+| **Providers** | 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 provider, 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) |
Agents are found by reading the user accounts, home directories and install paths of **the machine Gateway runs on**, so run it on the machine whose agents you want to watch.
@@ -75,8 +75,8 @@ Agents are found by reading the user accounts, home directories and install path
This is what fills **LLM Records**, and what lets Gateway keep the vendor key instead of the agent.
-1. **Channels** → **Add**: pick the type (OpenAI- or Anthropic-compatible), paste the vendor base URL and API key, and list the models it serves.
-2. **Agents** → open an agent → pick that channel.
+1. **Providers** → **Add**: pick the type (OpenAI- or Anthropic-compatible), paste the vendor base URL and API key, and list the models it serves.
+2. **Agents** → open an agent → pick that provider.
3. Copy the environment snippet the page shows, and start the agent from a shell that has it:
```bash
@@ -84,15 +84,15 @@ export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code"
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.
+The token is a placeholder — the agent refuses to start without one, and Gateway authenticates upstream with the provider'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.
+An agent signed in with a ChatGPT or Claude subscription has no API key to paste. Set the provider'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 provider 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.
+The environment snippet for such a provider 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 provider 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.
+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 provider with an API key.
### Stopping, upgrading, removing
@@ -106,7 +106,7 @@ Set `INSTALL_DIR` to install somewhere else, or `NO_START=1` to install without
### Running in Docker or Podman
-**A container cannot see the agents on your machine.** Agents are discovered by reading the home directories and install paths of the machine Gateway runs on, and inside a container that is the container's own filesystem. **Agents**, **Skills & MCP** and agent monitoring therefore stay empty there, and the pages say so rather than pretending nothing is installed. Everything that does not depend on the host works normally: **Channels**, **LLM Records** and the reverse-proxy WAF.
+**A container cannot see the agents on your machine.** Agents are discovered by reading the home directories and install paths of the machine Gateway runs on, and inside a container that is the container's own filesystem. **Agents**, **Skills & MCP** and agent monitoring therefore stay empty there, and the pages say so rather than pretending nothing is installed. Everything that does not depend on the host works normally: **Providers**, **LLM Records** and the reverse-proxy WAF.
So run the one-command install above on the machine whose agents you want to watch, and use a container when Gateway is only a model endpoint or a reverse proxy for other machines.
@@ -144,7 +144,7 @@ Everything is optional. Settings are changed on the **Settings** page of the web
| `gatewayEnabled` | `false` | Turns the reverse-proxy WAF on |
| `gatewayHttpPort` / `gatewayHttpsPort` | `80` / `443` | Ports the proxy listens on |
| `llmRecordMode` | `off` | How much of each relayed LLM request is kept |
-| `apiKeyEncryptionKey` | empty | Encrypts channel API keys at rest (AES-256-GCM) |
+| `apiKeyEncryptionKey` | empty | Encrypts provider API keys at rest (AES-256-GCM) |
| `casdoorEndpoint` | empty | Switches sign-in over to [Casdoor](https://casdoor.org) SSO |
Gateway prints what it is actually doing when it starts, so the result can be checked instead of the file:
diff --git a/README_zh.md b/README_zh.md
index 30b7bd6..bd6d839 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -65,8 +65,8 @@ 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 拿不到;也可以转发 Agent 自己的登录,什么都不持有。 | 一个厂商的 API Key,或者什么都不用 |
-| **LLM Records** | Agent 转发的每一次请求:完整的 system prompt、每一条消息和工具调用、模型可用的每个工具的 schema,以及 token 数和费用。 | 一个 Channel,以及 `llmRecordMode` —— 见[记录提示词](#记录提示词) |
+| **Providers** | 挡在模型厂商前面的统一入口。API Key 由 Gateway 持有,Agent 拿不到;也可以转发 Agent 自己的登录,什么都不持有。 | 一个厂商的 API Key,或者什么都不用 |
+| **LLM Records** | Agent 转发的每一次请求:完整的 system prompt、每一条消息和工具调用、模型可用的每个工具的 schema,以及 token 数和费用。 | 一个 Provider,以及 `llmRecordMode` —— 见[记录提示词](#记录提示词) |
| **Advanced → Sites** | 反向代理 WAF:按站点的路由、规则、证书和统计。 | 打开代理 —— 见[开启 WAF 反向代理](#开启-waf-反向代理) |
Agent 是通过读取 **Gateway 所在机器**的用户账户、home 目录和安装路径发现的,所以要在你想观察的那台机器上运行它。
@@ -75,8 +75,8 @@ Agent 是通过读取 **Gateway 所在机器**的用户账户、home 目录和
这是 **LLM Records** 的数据来源,也是让 Gateway 而不是 Agent 持有厂商 Key 的方式。
-1. **Channels** → **Add**:选类型(OpenAI 兼容或 Anthropic 兼容),填厂商的 base URL 和 API Key,并列出它提供的模型。
-2. **Agents** → 打开一个 Agent → 选中该 Channel。
+1. **Providers** → **Add**:选类型(OpenAI 兼容或 Anthropic 兼容),填厂商的 base URL 和 API Key,并列出它提供的模型。
+2. **Agents** → 打开一个 Agent → 选中该 Provider。
3. 复制页面显示的环境变量片段,在设置了这些变量的 shell 里启动 Agent:
```bash
@@ -84,15 +84,15 @@ export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code"
export ANTHROPIC_AUTH_TOKEN="casbin-gateway"
```
-这个 token 只是占位符 —— Agent 没有它就拒绝启动,而 Gateway 会用 Channel 自己的 Key 去认证上游。
+这个 token 只是占位符 —— Agent 没有它就拒绝启动,而 Gateway 会用 Provider 自己的 Key 去认证上游。
### 没有 API Key:沿用 Agent 已有的登录
-用 ChatGPT 或 Claude 订阅登录的 Agent 根本没有 API Key 可填。把 Channel 的**认证方式**设成**调用方自己的登录**,它就不需要 Key:base URL 指向厂商,每个请求都带着 Agent 自己发来的凭据转发上游,Agent 继续用它已有的登录。**Models** 留空,这个 Channel 就接受任何模型名。
+用 ChatGPT 或 Claude 订阅登录的 Agent 根本没有 API Key 可填。把 Provider 的**认证方式**设成**调用方自己的登录**,它就不需要 Key:base URL 指向厂商,每个请求都带着 Agent 自己发来的凭据转发上游,Agent 继续用它已有的登录。**Models** 留空,这个 Provider 就接受任何模型名。
-这种 Channel 的环境变量片段只设 base URL,不设别的 —— 在这里再设一个 token 会覆盖 Agent 已有的登录。记录和路由和有 Key 的 Channel 完全一样,只是 Gateway 从头到尾没见过任何 Key。
+这种 Provider 的环境变量片段只设 base URL,不设别的 —— 在这里再设一个 token 会覆盖 Agent 已有的登录。记录和路由和有 Key 的 Provider 完全一样,只是 Gateway 从头到尾没见过任何 Key。
-Codex 是例外:它的 ChatGPT 登录走的是另一套 API,不是 Gateway 转发的 chat completions,所以 Codex CLI 仍然需要一个带 API Key 的 Channel。
+Codex 是例外:它的 ChatGPT 登录走的是另一套 API,不是 Gateway 转发的 chat completions,所以 Codex CLI 仍然需要一个带 API Key 的 Provider。
### 停止、升级、卸载
@@ -106,7 +106,7 @@ Codex 是例外:它的 ChatGPT 登录走的是另一套 API,不是 Gateway
### 在 Docker 或 Podman 里运行
-**容器看不到你机器上的 Agent。** Agent 是靠读取 Gateway 所在机器的用户主目录和安装路径发现的,而在容器里那是容器自己的文件系统。所以 **Agents**、**Skills & MCP** 和 Agent 监控在容器里一直是空的,页面会直接说明这一点,而不是让人以为什么都没装。不依赖宿主机的部分照常可用:**Channels**、**LLM Records** 和 WAF 反向代理。
+**容器看不到你机器上的 Agent。** Agent 是靠读取 Gateway 所在机器的用户主目录和安装路径发现的,而在容器里那是容器自己的文件系统。所以 **Agents**、**Skills & MCP** 和 Agent 监控在容器里一直是空的,页面会直接说明这一点,而不是让人以为什么都没装。不依赖宿主机的部分照常可用:**Providers**、**LLM Records** 和 WAF 反向代理。
因此,想监控哪台机器上的 Agent,就在那台机器上跑上面的一键安装;只把 Gateway 当作模型入口或别的机器的反向代理时,才用容器部署。
@@ -144,7 +144,7 @@ podman compose up -d
| `gatewayEnabled` | `false` | 打开反向代理 WAF |
| `gatewayHttpPort` / `gatewayHttpsPort` | `80` / `443` | 代理监听的端口 |
| `llmRecordMode` | `off` | 每次转发的 LLM 请求保留多少内容 |
-| `apiKeyEncryptionKey` | 空 | 加密存储 Channel 的 API Key(AES-256-GCM) |
+| `apiKeyEncryptionKey` | 空 | 加密存储 Provider 的 API Key(AES-256-GCM) |
| `casdoorEndpoint` | 空 | 把登录切换到 [Casdoor](https://casdoor.org) SSO |
Gateway 启动时会打印它实际在做什么,所以可以直接看结果而不用去查配置文件:
diff --git a/agentprovider/claude_code.go b/agentprovider/claude_code.go
index 39a9f88..b877cbd 100644
--- a/agentprovider/claude_code.go
+++ b/agentprovider/claude_code.go
@@ -151,7 +151,7 @@ func (w claudeCodeWriter) Current(target Target) (string, error) {
}
// env is the block written into settings.json. Claude Code authenticates with
-// ANTHROPIC_AUTH_TOKEN, so the channel key goes there and not into
+// ANTHROPIC_AUTH_TOKEN, so the provider key goes there and not into
// ANTHROPIC_API_KEY, which stays cleared.
func (claudeCodeWriter) env(endpoint Endpoint, masked bool) map[string]any {
token := endpoint.ApiKey
@@ -162,7 +162,7 @@ func (claudeCodeWriter) env(endpoint Endpoint, masked bool) map[string]any {
env := map[string]any{
"ANTHROPIC_BASE_URL": endpoint.BaseUrl,
}
- // A channel that forwards the caller's own credentials has no token to
+ // A provider 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 != "" {
diff --git a/agentprovider/codex.go b/agentprovider/codex.go
index e57568d..71bb186 100644
--- a/agentprovider/codex.go
+++ b/agentprovider/codex.go
@@ -42,10 +42,10 @@ const (
var codexProviderPath = []string{"model_providers", codexProviderName}
-// errCodexNoKey rejects a channel that forwards the caller's own credentials:
+// errCodexNoKey rejects a provider 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")
+var errCodexNoKey = errors.New("Codex needs an API key, so it cannot use a provider that forwards the credentials of the caller")
type codexWriter struct {
id string
diff --git a/agentprovider/provider.go b/agentprovider/provider.go
index e27f568..dc06524 100644
--- a/agentprovider/provider.go
+++ b/agentprovider/provider.go
@@ -30,7 +30,7 @@ var ErrNotSupported = errors.New("switching the provider of this agent is not su
// The two ways an agent reaches its provider.
const (
// ModeGateway points the agent at the local proxy, so changing the bound
- // channel afterwards takes effect without touching a file again.
+ // provider afterwards takes effect without touching a file again.
ModeGateway = "gateway"
// ModeDirect writes the provider's own base URL and key into the config,
// which is what a switcher without a proxy does.
@@ -45,9 +45,9 @@ type Target struct {
}
// Endpoint is the upstream one agent is switched to, already resolved: in
-// gateway mode BaseUrl is the local proxy, in direct mode the channel's own.
+// gateway mode BaseUrl is the local proxy, in direct mode the provider's own.
type Endpoint struct {
- Channel string `json:"channel"`
+ Provider string `json:"provider"`
Protocol string `json:"protocol"`
BaseUrl string `json:"baseUrl"`
ApiKey string `json:"apiKey"`
@@ -68,7 +68,7 @@ type File struct {
type Status struct {
Supported bool `json:"supported"`
Applied bool `json:"applied"`
- Channel string `json:"channel"`
+ Provider string `json:"provider"`
Mode string `json:"mode"`
BaseUrl string `json:"baseUrl"`
Time string `json:"time"`
@@ -147,7 +147,7 @@ func Apply(target Target, endpoint Endpoint) error {
}
return saveState(target, &state{
AgentId: target.AgentId,
- Channel: endpoint.Channel,
+ Provider: endpoint.Provider,
Mode: endpoint.Mode,
BaseUrl: endpoint.BaseUrl,
Time: nowString(),
@@ -212,7 +212,7 @@ func StatusOf(target Target) Status {
}
status.Applied = true
- status.Channel = saved.Channel
+ status.Provider = saved.Provider
status.Mode = saved.Mode
status.BaseUrl = saved.BaseUrl
status.Time = saved.Time
@@ -221,7 +221,7 @@ func StatusOf(target Target) Status {
status.Applied = false
status.Detail = "Changed outside Gateway, the agent now points at " + emptyAs(current, "no provider")
} else if saved.Mode == ModeGateway {
- status.Detail = "Routed through the local proxy, switching the channel needs no restart"
+ status.Detail = "Routed through the local proxy, switching the provider needs no restart"
} else {
status.Detail = "Written directly into the agent configuration"
}
@@ -232,11 +232,11 @@ func writerFor(target Target, endpoint Endpoint) (writer, error) {
if target.AgentId == "" {
return nil, errors.New("agentId is required")
}
- if endpoint.Channel == "" {
- return nil, errors.New("no channel is bound to this agent")
+ if endpoint.Provider == "" {
+ return nil, errors.New("no provider is bound to this agent")
}
if endpoint.BaseUrl == "" {
- return nil, errors.New("the base URL of the bound channel is empty")
+ return nil, errors.New("the base URL of the bound provider is empty")
}
value, ok := writers[target.AgentId]
@@ -244,8 +244,8 @@ func writerFor(target Target, endpoint Endpoint) (writer, error) {
return nil, fmt.Errorf("%s: %w", target.AgentId, ErrNotSupported)
}
if value.Protocol() != endpoint.Protocol {
- return nil, fmt.Errorf("%s speaks the %s API, but channel %s speaks %s",
- target.AgentId, value.Protocol(), endpoint.Channel, endpoint.Protocol)
+ return nil, fmt.Errorf("%s speaks the %s API, but provider %s speaks %s",
+ target.AgentId, value.Protocol(), endpoint.Provider, endpoint.Protocol)
}
return value, nil
}
diff --git a/agentprovider/state.go b/agentprovider/state.go
index 6fe8d79..908cf91 100644
--- a/agentprovider/state.go
+++ b/agentprovider/state.go
@@ -30,7 +30,7 @@ import (
// missing from it was not set, and is deleted again on restore.
type state struct {
AgentId string `json:"agentId"`
- Channel string `json:"channel"`
+ Provider string `json:"provider"`
Mode string `json:"mode"`
BaseUrl string `json:"baseUrl"`
Time string `json:"time"`
diff --git a/conf/app.conf b/conf/app.conf
index 69b7234..a021897 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -25,7 +25,7 @@ dbName = casbin_gateway
; Optional Redis for sessions. Sessions are kept in ./tmp files when empty.
redisEndpoint =
-; Optional AES-256-GCM encryption for channel API keys at rest. Any length works
+; Optional AES-256-GCM encryption for provider API keys at rest. Any length works
; ("openssl rand -base64 32"); leave it empty to keep storing keys as plaintext.
; Existing plaintext keys are re-written as ciphertext the first time they are
; read, so turning this on needs no manual step. Back the key up with the
diff --git a/controllers/agent.go b/controllers/agent.go
index 56bafd2..86bf157 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -32,15 +32,15 @@ type discoveredAgent struct {
agent.Installation
agentpatch.Status
- // Channel is the "owner/name" id of the bound channel, and Fallbacks are the
- // channels tried after it. Installations are discovered per host while the
+ // Provider is the "owner/name" id of the bound provider, and Fallbacks are the
+ // providers tried after it. Installations are discovered per host while the
// routing is stored per agent id, so they merge here.
- Channel string `json:"channel"`
+ Provider string `json:"provider"`
Fallbacks []string `json:"fallbacks"`
Mode string `json:"mode"`
- // Provider is the state of the agent's own configuration file, which is
- // what the config orchestrator writes.
- Provider agentprovider.Status `json:"provider"`
+ // ProviderConfig is the state of the agent's own configuration file, which
+ // is what the config orchestrator writes.
+ ProviderConfig agentprovider.Status `json:"providerConfig"`
}
// GetAgents scans known installation locations and returns the AI agents
@@ -66,14 +66,14 @@ func (c *ApiController) GetAgents() {
for _, installation := range installations {
target := targetOf(installation)
item := &discoveredAgent{
- Installation: installation,
- Status: agentpatch.StatusOf(target),
- Fallbacks: []string{},
- Mode: object.ModeGateway,
- Provider: agentprovider.StatusOf(providerTarget(target)),
+ Installation: installation,
+ Status: agentpatch.StatusOf(target),
+ Fallbacks: []string{},
+ Mode: object.ModeGateway,
+ ProviderConfig: agentprovider.StatusOf(providerTarget(target)),
}
if stored, ok := agents[installation.AgentId]; ok {
- item.Channel = stored.Channel
+ item.Provider = stored.Provider
item.Mode = stored.Mode
if stored.Fallbacks != nil {
item.Fallbacks = stored.Fallbacks
@@ -84,22 +84,22 @@ func (c *ApiController) GetAgents() {
c.ResponseOk(result, agent.InContainer())
}
-// UpdateAgentChannel binds one agent to the channel its requests are forwarded
-// to, to the channels tried when that one cannot answer, and to the way it
+// UpdateAgentRouting binds one agent to the provider its requests are forwarded
+// to, to the providers tried when that one cannot answer, and to the way it
// reaches them. The binding is per agent id.
//
// An installation whose configuration file Gateway already wrote is rewritten
// here: in gateway mode the file does not change, which is what makes a switch
-// take effect without restarting the agent, but in direct mode the new channel
+// take effect without restarting the agent, but in direct mode the new provider
// only reaches the agent through its own configuration.
-func (c *ApiController) UpdateAgentChannel() {
+func (c *ApiController) UpdateAgentRouting() {
if c.RequireAdmin() {
return
}
var form struct {
AgentId string `json:"agentId"`
- Channel string `json:"channel"`
+ Provider string `json:"provider"`
Fallbacks []string `json:"fallbacks"`
Mode string `json:"mode"`
}
@@ -112,18 +112,18 @@ func (c *ApiController) UpdateAgentChannel() {
return
}
- if err := object.SetAgentRouting(form.AgentId, form.Channel, form.Fallbacks, form.Mode); err != nil {
+ if err := object.SetAgentRouting(form.AgentId, form.Provider, form.Fallbacks, form.Mode); err != nil {
c.ResponseError(err.Error())
return
}
- if form.Channel != "" {
+ if form.Provider != "" {
if failure := reapplyAgentProvider(form.AgentId); failure != "" {
c.ResponseError("the routing was saved, but the agent configuration was not rewritten: " + failure)
return
}
}
- c.ResponseOk(form.Channel)
+ c.ResponseOk(form.Provider)
}
// PatchAgent enables monitoring for one discovered installation.
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index 9743a44..d6175e7 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -26,7 +26,7 @@ import (
)
// gatewayToken is what an agent sends to the local proxy. The proxy authenticates
-// upstream with the channel's own key, so this value is never used for anything.
+// upstream with the provider's own key, so this value is never used for anything.
const gatewayToken = "casbin-gateway"
// PlanAgentProvider renders what a switch would write, without touching a file.
@@ -53,7 +53,7 @@ func (c *ApiController) PlanAgentProvider() {
c.ResponseOk(files)
}
-// ApplyAgentProvider writes the bound channel into the agent's own configuration
+// ApplyAgentProvider writes the bound provider into the agent's own configuration
// file, in that agent's format.
func (c *ApiController) ApplyAgentProvider() {
if c.RequireAdmin() {
@@ -95,18 +95,18 @@ func (c *ApiController) RestoreAgentProvider() {
c.ResponseOk(agentprovider.StatusOf(providerTarget(target)))
}
-// GetChannelHealth reports what the proxy has seen of each channel, which is
-// what says why a request went to a fallback rather than to the bound channel.
-func (c *ApiController) GetChannelHealth() {
+// GetProviderHealth reports what the proxy has seen of each provider, which is
+// what says why a request went to a fallback rather than to the bound provider.
+func (c *ApiController) GetProviderHealth() {
if c.RequireAdmin() {
return
}
- c.ResponseOk(object.GetChannelHealth())
+ c.ResponseOk(object.GetProviderHealth())
}
// agentEndpoint resolves where one agent should be pointed. In gateway mode
// that is the local proxy, which is what makes a later switch take effect
-// without rewriting a file; in direct mode it is the channel's own upstream.
+// without rewriting a file; in direct mode it is the provider's own upstream.
func agentEndpoint(agentId string) (agentprovider.Endpoint, error) {
endpoint := agentprovider.Endpoint{Mode: object.ModeGateway}
@@ -118,34 +118,34 @@ func agentEndpoint(agentId string) (agentprovider.Endpoint, error) {
endpoint.Mode = stored.Mode
}
- channel, err := object.GetChannelByAgent(agentId)
+ provider, err := object.GetProviderByAgent(agentId)
if err != nil {
return endpoint, err
}
- endpoint.Channel = channel.GetId()
- endpoint.Protocol = object.ChannelProtocol(channel)
- if len(channel.Models) > 0 {
- endpoint.Model = channel.Models[0]
+ endpoint.Provider = provider.GetId()
+ endpoint.Protocol = object.ProviderProtocol(provider)
+ if len(provider.Models) > 0 {
+ endpoint.Model = provider.Models[0]
}
if endpoint.Mode == object.ModeDirect {
- endpoint.BaseUrl = channel.BaseUrl
- endpoint.ApiKey = channel.ApiKey
+ endpoint.BaseUrl = provider.BaseUrl
+ endpoint.ApiKey = provider.ApiKey
return endpoint, nil
}
endpoint.BaseUrl = gatewayAgentUrl(agentId)
- // A client-auth channel forwards whatever the agent sends, so it must keep
+ // A client-auth provider 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) {
+ if !object.UsesClientAuth(provider) {
endpoint.ApiKey = gatewayToken
}
return endpoint, nil
}
-// gatewayAgentUrl is the loopback base URL an agent reaches its own channel at.
+// gatewayAgentUrl is the loopback base URL an agent reaches its own provider at.
// One URL serves both wire formats: an OpenAI client appends /chat/completions
// to it, an Anthropic one appends /v1/messages.
func gatewayAgentUrl(agentId string) string {
diff --git a/controllers/provider.go b/controllers/casdoor_provider.go
similarity index 95%
copy from controllers/provider.go
copy to controllers/casdoor_provider.go
index 31af4f6..02fcdce 100644
--- a/controllers/provider.go
+++ b/controllers/casdoor_provider.go
@@ -19,7 +19,7 @@ import (
"github.com/apache/casbin-gateway/conf"
)
-func (c *ApiController) GetProviders() {
+func (c *ApiController) GetCasdoorProviders() {
// Providers live in Casdoor. Without it, the site alert provider picker
// simply has nothing to offer instead of failing the whole page.
if !conf.IsCasdoorAvailable() {
diff --git a/controllers/channel.go b/controllers/channel.go
deleted file mode 100644
index 3f8dc7f..0000000
--- a/controllers/channel.go
+++ /dev/null
@@ -1,283 +0,0 @@
-// Copyright 2023 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package controllers
-
-import (
- "encoding/json"
- "strings"
-
- "github.com/apache/casbin-gateway/object"
- "github.com/apache/casbin-gateway/util"
- "github.com/beego/beego/utils/pagination"
-)
-
-func (c *ApiController) channelAccess(owner string) bool {
- user := c.GetSessionUser()
- return user != nil && (user.IsAdmin || owner == c.GetSessionUsername())
-}
-
-// getChannelOwnerAndName splits an "owner/name" id. util.GetOwnerAndNameFromId()
-// panics on malformed input, and the id here comes straight from the query
-// string, so it is validated before being passed on.
-func getChannelOwnerAndName(id string) (string, string, bool) {
- tokens := strings.Split(id, "/")
- if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" {
- return "", "", false
- }
-
- return tokens[0], tokens[1], true
-}
-
-// channelSortFields is a whitelist: the sort field is interpolated into the
-// ORDER BY clause by object.GetSession(), so it must not be taken from the
-// query string unchecked.
-var channelSortFields = map[string]bool{
- "owner": true, "name": true, "createdTime": true, "updatedTime": true,
- "displayName": true, "type": true, "baseUrl": true, "priority": true, "status": true,
-}
-
-func getChannelSortField(sortField string) string {
- if channelSortFields[sortField] {
- return sortField
- }
-
- return ""
-}
-
-// GetChannels returns a paginated list of channels with tenant isolation.
-func (c *ApiController) GetChannels() {
- if c.RequireSignedIn() {
- return
- }
-
- owner := c.Input().Get("owner")
- if !c.GetSessionUser().IsAdmin {
- // A non-admin may only ever see their own channels.
- owner = c.GetSessionUsername()
- } else if owner == "admin" {
- // The admin's own owner name means "no filter", i.e. every channel.
- owner = ""
- }
-
- limit := c.Input().Get("pageSize")
- page := c.Input().Get("p")
- field := c.Input().Get("field")
- value := c.Input().Get("value")
- sortField := getChannelSortField(c.Input().Get("sortField"))
- sortOrder := c.Input().Get("sortOrder")
-
- if limit == "" || page == "" {
- channels, err := object.GetChannels(owner)
- if err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- c.ResponseOk(object.GetMaskedChannels(channels))
- return
- }
-
- limitInt := util.ParseInt(limit)
- count, err := object.GetChannelCount(owner, field, value)
- if err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- paginator := pagination.SetPaginator(c.Ctx, limitInt, count)
- channels, err := object.GetPaginationChannels(owner, paginator.Offset(), limitInt, field, value, sortField, sortOrder)
- if err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- c.ResponseOk(object.GetMaskedChannels(channels), paginator.Nums())
-}
-
-// GetChannel returns a single channel by owner/name id.
-func (c *ApiController) GetChannel() {
- if c.RequireSignedIn() {
- return
- }
-
- id := c.Input().Get("id")
- owner, _, ok := getChannelOwnerAndName(id)
- if !ok {
- c.ResponseError("invalid channel ID: " + id)
- return
- }
- if !c.channelAccess(owner) {
- c.ResponseError("unauthorized")
- return
- }
-
- channel, err := object.GetChannel(id)
- if err != nil {
- c.ResponseError(err.Error())
- return
- }
- if channel == nil {
- c.ResponseError("the channel does not exist")
- return
- }
-
- c.ResponseOk(object.GetMaskedChannel(channel))
-}
-
-// AddChannel creates a new channel.
-func (c *ApiController) AddChannel() {
- if c.RequireSignedIn() {
- return
- }
-
- var channel object.Channel
- if err := json.Unmarshal(c.Ctx.Input.RequestBody, &channel); err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- if channel.Name == "" {
- c.ResponseError("the channel name cannot be empty")
- return
- }
-
- channel.Owner = c.GetSessionUsername()
- c.Data["json"] = wrapActionResponse(object.AddChannel(&channel))
- c.ServeJSON()
-}
-
-// UpdateChannel updates an existing channel.
-func (c *ApiController) UpdateChannel() {
- if c.RequireSignedIn() {
- return
- }
-
- id := c.Input().Get("id")
- owner, _, ok := getChannelOwnerAndName(id)
- if !ok {
- c.ResponseError("invalid channel ID: " + id)
- return
- }
- if !c.channelAccess(owner) {
- c.ResponseError("unauthorized")
- return
- }
-
- var channel object.Channel
- if err := json.Unmarshal(c.Ctx.Input.RequestBody, &channel); err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- c.Data["json"] = wrapActionResponse(object.UpdateChannel(id, &channel))
- c.ServeJSON()
-}
-
-// DeleteChannel deletes a channel.
-func (c *ApiController) DeleteChannel() {
- if c.RequireSignedIn() {
- return
- }
-
- var channel object.Channel
- if err := json.Unmarshal(c.Ctx.Input.RequestBody, &channel); err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- if !c.channelAccess(channel.Owner) {
- c.ResponseError("unauthorized")
- return
- }
-
- c.Data["json"] = wrapActionResponse(object.DeleteChannel(&channel))
- c.ServeJSON()
-}
-
-// GetChannelModels lists the models a channel's upstream reports. The channel
-// comes from the request body rather than from the database: the new-channel
-// form has nothing saved yet.
-func (c *ApiController) GetChannelModels() {
- if c.RequireSignedIn() {
- return
- }
-
- var channel object.Channel
- if err := json.Unmarshal(c.Ctx.Input.RequestBody, &channel); err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- if channel.Owner == "" {
- channel.Owner = c.GetSessionUsername()
- }
- if !c.channelAccess(channel.Owner) {
- c.ResponseError("unauthorized")
- return
- }
-
- // The browser only ever sees the mask, so an untouched key field means the
- // probe has to use the one the channel already has stored.
- if channel.ApiKey == object.ApiKeyMask {
- channel.ApiKey = ""
- if channel.Name != "" {
- stored, err := object.GetChannel(channel.GetId())
- if err != nil {
- c.ResponseError(err.Error())
- return
- }
- if stored != nil {
- channel.ApiKey = stored.ApiKey
- }
- }
- }
-
- models, err := object.FetchChannelModels(&channel)
- if err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- c.ResponseOk(models)
-}
-
-// TestChannel tests connectivity to an upstream channel.
-func (c *ApiController) TestChannel() {
- if c.RequireSignedIn() {
- return
- }
-
- var request struct {
- Owner string `json:"owner"`
- Name string `json:"name"`
- }
- if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil {
- c.ResponseError(err.Error())
- return
- }
-
- if request.Owner == "" || request.Name == "" {
- c.ResponseError("the channel owner and name cannot be empty")
- return
- }
- if !c.channelAccess(request.Owner) {
- c.ResponseError("unauthorized")
- return
- }
-
- channel := &object.Channel{Owner: request.Owner, Name: request.Name}
- success, statusCode, message := object.TestChannelConnectivity(channel)
- c.ResponseOk(map[string]interface{}{"success": success, "statusCode": statusCode, "message": message})
-}
diff --git a/controllers/llm_record.go b/controllers/llm_record.go
index 3635662..75275f5 100644
--- a/controllers/llm_record.go
+++ b/controllers/llm_record.go
@@ -66,7 +66,7 @@ func (c *ApiController) GetLlmRecords() {
func (c *ApiController) readLlmRecordFilter() object.LlmRecordFilter {
filter := object.LlmRecordFilter{
Model: c.Input().Get("model"),
- Channel: c.Input().Get("channel"),
+ Provider: c.Input().Get("provider"),
Agent: c.Input().Get("agent"),
ClientIp: c.Input().Get("clientIp"),
Outcome: c.Input().Get("outcome"),
diff --git a/controllers/provider.go b/controllers/provider.go
index 31af4f6..eb71d46 100644
--- a/controllers/provider.go
+++ b/controllers/provider.go
@@ -15,23 +15,269 @@
package controllers
import (
- "github.com/apache/casbin-gateway/auth"
- "github.com/apache/casbin-gateway/conf"
+ "encoding/json"
+ "strings"
+
+ "github.com/apache/casbin-gateway/object"
+ "github.com/apache/casbin-gateway/util"
+ "github.com/beego/beego/utils/pagination"
)
+func (c *ApiController) providerAccess(owner string) bool {
+ user := c.GetSessionUser()
+ return user != nil && (user.IsAdmin || owner == c.GetSessionUsername())
+}
+
+// getProviderOwnerAndName splits an "owner/name" id. util.GetOwnerAndNameFromId()
+// panics on malformed input, and the id here comes straight from the query
+// string, so it is validated before being passed on.
+func getProviderOwnerAndName(id string) (string, string, bool) {
+ tokens := strings.Split(id, "/")
+ if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" {
+ return "", "", false
+ }
+
+ return tokens[0], tokens[1], true
+}
+
+// providerSortFields is a whitelist: the sort field is interpolated into the
+// ORDER BY clause by object.GetSession(), so it must not be taken from the
+// query string unchecked.
+var providerSortFields = map[string]bool{
+ "owner": true, "name": true, "createdTime": true, "updatedTime": true,
+ "displayName": true, "type": true, "baseUrl": true, "priority": true, "status": true,
+}
+
+func getProviderSortField(sortField string) string {
+ if providerSortFields[sortField] {
+ return sortField
+ }
+
+ return ""
+}
+
+// GetProviders returns a paginated list of providers with tenant isolation.
func (c *ApiController) GetProviders() {
- // Providers live in Casdoor. Without it, the site alert provider picker
- // simply has nothing to offer instead of failing the whole page.
- if !conf.IsCasdoorAvailable() {
- c.ResponseOk([]*auth.Provider{})
+ if c.RequireSignedIn() {
+ return
+ }
+
+ owner := c.Input().Get("owner")
+ if !c.GetSessionUser().IsAdmin {
+ // A non-admin may only ever see their own providers.
+ owner = c.GetSessionUsername()
+ } else if owner == "admin" {
+ // The admin's own owner name means "no filter", i.e. every provider.
+ owner = ""
+ }
+
+ limit := c.Input().Get("pageSize")
+ page := c.Input().Get("p")
+ field := c.Input().Get("field")
+ value := c.Input().Get("value")
+ sortField := getProviderSortField(c.Input().Get("sortField"))
+ sortOrder := c.Input().Get("sortOrder")
+
+ if limit == "" || page == "" {
+ providers, err := object.GetProviders(owner)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(object.GetMaskedProviders(providers))
+ return
+ }
+
+ limitInt := util.ParseInt(limit)
+ count, err := object.GetProviderCount(owner, field, value)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ paginator := pagination.SetPaginator(c.Ctx, limitInt, count)
+ providers, err := object.GetPaginationProviders(owner, paginator.Offset(), limitInt, field, value, sortField, sortOrder)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(object.GetMaskedProviders(providers), paginator.Nums())
+}
+
+// GetProvider returns a single provider by owner/name id.
+func (c *ApiController) GetProvider() {
+ if c.RequireSignedIn() {
return
}
- providers, err := auth.GetProviders()
+ id := c.Input().Get("id")
+ owner, _, ok := getProviderOwnerAndName(id)
+ if !ok {
+ c.ResponseError("invalid provider ID: " + id)
+ return
+ }
+ if !c.providerAccess(owner) {
+ c.ResponseError("unauthorized")
+ return
+ }
+
+ provider, err := object.GetProvider(id)
if err != nil {
c.ResponseError(err.Error())
return
}
+ if provider == nil {
+ c.ResponseError("the provider does not exist")
+ return
+ }
+
+ c.ResponseOk(object.GetMaskedProvider(provider))
+}
+
+// AddProvider creates a new provider.
+func (c *ApiController) AddProvider() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ var provider object.Provider
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &provider); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ if provider.Name == "" {
+ c.ResponseError("the provider name cannot be empty")
+ return
+ }
+
+ provider.Owner = c.GetSessionUsername()
+ c.Data["json"] = wrapActionResponse(object.AddProvider(&provider))
+ c.ServeJSON()
+}
+
+// UpdateProvider updates an existing provider.
+func (c *ApiController) UpdateProvider() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ id := c.Input().Get("id")
+ owner, _, ok := getProviderOwnerAndName(id)
+ if !ok {
+ c.ResponseError("invalid provider ID: " + id)
+ return
+ }
+ if !c.providerAccess(owner) {
+ c.ResponseError("unauthorized")
+ return
+ }
+
+ var provider object.Provider
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &provider); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.Data["json"] = wrapActionResponse(object.UpdateProvider(id, &provider))
+ c.ServeJSON()
+}
+
+// DeleteProvider deletes a provider.
+func (c *ApiController) DeleteProvider() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ var provider object.Provider
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &provider); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ if !c.providerAccess(provider.Owner) {
+ c.ResponseError("unauthorized")
+ return
+ }
+
+ c.Data["json"] = wrapActionResponse(object.DeleteProvider(&provider))
+ c.ServeJSON()
+}
+
+// GetProviderModels lists the models a provider's upstream reports. The provider
+// comes from the request body rather than from the database: the new-provider
+// form has nothing saved yet.
+func (c *ApiController) GetProviderModels() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ var provider object.Provider
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &provider); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ if provider.Owner == "" {
+ provider.Owner = c.GetSessionUsername()
+ }
+ if !c.providerAccess(provider.Owner) {
+ c.ResponseError("unauthorized")
+ return
+ }
+
+ // The browser only ever sees the mask, so an untouched key field means the
+ // probe has to use the one the provider already has stored.
+ if provider.ApiKey == object.ApiKeyMask {
+ provider.ApiKey = ""
+ if provider.Name != "" {
+ stored, err := object.GetProvider(provider.GetId())
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if stored != nil {
+ provider.ApiKey = stored.ApiKey
+ }
+ }
+ }
+
+ models, err := object.FetchProviderModels(&provider)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(models)
+}
+
+// TestProvider tests connectivity to an upstream provider.
+func (c *ApiController) TestProvider() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ var request struct {
+ Owner string `json:"owner"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ if request.Owner == "" || request.Name == "" {
+ c.ResponseError("the provider owner and name cannot be empty")
+ return
+ }
+ if !c.providerAccess(request.Owner) {
+ c.ResponseError("unauthorized")
+ return
+ }
- c.ResponseOk(providers)
+ provider := &object.Provider{Owner: request.Owner, Name: request.Name}
+ success, statusCode, message := object.TestProviderConnectivity(provider)
+ c.ResponseOk(map[string]interface{}{"success": success, "statusCode": statusCode, "message": message})
}
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 0c6e864..663093c 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -76,7 +76,7 @@ type proxyRoute struct {
body []byte
model string
stream bool
- // source describes how the channels were chosen, for the error a client
+ // source describes how the providers were chosen, for the error a client
// sees when none of them can be used.
source string
start time.Time
@@ -131,8 +131,8 @@ var proxyClient = &http.Client{
}
// ChatCompletions is the OpenAI-compatible chat completions proxy endpoint.
-// It matches the upstream channels by model name and forwards the request and
-// response body as-is (pass-through), trying the channels in priority order
+// It matches the upstream providers by model name and forwards the request and
+// response body as-is (pass-through), trying the providers in priority order
// until one of them answers. Supports SSE streaming when stream=true in the
// request body.
// This endpoint does NOT require Casdoor authentication (auth deferred to milestone 1.3).
@@ -153,7 +153,7 @@ func (c *ApiController) CountTokens() {
}
// AgentChatCompletions is the per-agent entry point of the same proxy: an agent
-// pointed at ".../v1/agents/<agentId>" reaches the channel bound to it rather
+// pointed at ".../v1/agents/<agentId>" reaches the provider bound to it rather
// than one chosen by model name.
func (c *ApiController) AgentChatCompletions() {
c.proxyByAgent(openAiChat)
@@ -171,7 +171,7 @@ func (c *ApiController) AgentCountTokens() {
c.proxyByAgent(anthropicCountTokens)
}
-// proxyByModel forwards to the channels that serve the model the request names.
+// proxyByModel forwards to the providers that serve the model the request names.
func (c *ApiController) proxyByModel(target proxyTarget) {
route, ok := c.readProxyRoute(target)
if !ok {
@@ -182,24 +182,24 @@ func (c *ApiController) proxyByModel(target proxyTarget) {
// what a record describes, so this is the only place one is written.
defer c.finishLlmRecord(route)
- // Match the channels globally, without an owner filter.
- channels, err := object.GetChannelsByModel(route.model)
+ // Match the providers globally, without an owner filter.
+ providers, err := object.GetProvidersByModel(route.model)
if err != nil {
- if errors.Is(err, object.ErrNoChannelAvailable) {
+ if errors.Is(err, object.ErrNoProviderAvailable) {
route.recordOutcome(http.StatusBadRequest, err.Error())
c.writeProxyError(target.protocol, http.StatusBadRequest, "invalid_request_error", err.Error())
} else {
- beego.Error("channel lookup failed:", err)
- route.recordOutcome(http.StatusBadGateway, "channel lookup failed")
- c.writeProxyError(target.protocol, http.StatusBadGateway, "server_error", "channel lookup failed")
+ beego.Error("provider lookup failed:", err)
+ route.recordOutcome(http.StatusBadGateway, "provider lookup failed")
+ c.writeProxyError(target.protocol, http.StatusBadGateway, "server_error", "provider lookup failed")
}
return
}
- c.forwardToChannels(channels, route)
+ c.forwardToProviders(providers, route)
}
-// proxyByAgent forwards to the channel chain bound to the agent in the path.
+// proxyByAgent forwards to the provider chain bound to the agent in the path.
func (c *ApiController) proxyByAgent(target proxyTarget) {
route, ok := c.readProxyRoute(target)
if !ok {
@@ -212,22 +212,22 @@ func (c *ApiController) proxyByAgent(target proxyTarget) {
}
defer c.finishLlmRecord(route)
- // The whole chain is forwarded to, so a bound channel that is down fails
+ // The whole chain is forwarded to, so a bound provider that is down fails
// over to the agent's fallbacks instead of failing the request.
- channels, err := object.GetChannelsByAgent(agentId)
+ providers, err := object.GetProvidersByAgent(agentId)
if err != nil {
- if errors.Is(err, object.ErrAgentNoChannel) {
+ if errors.Is(err, object.ErrAgentNoProvider) {
route.recordOutcome(http.StatusBadRequest, err.Error())
c.writeProxyError(target.protocol, http.StatusBadRequest, "invalid_request_error", err.Error())
} else {
- beego.Error("agent channel lookup failed:", err)
+ beego.Error("agent provider lookup failed:", err)
route.recordOutcome(http.StatusBadGateway, err.Error())
c.writeProxyError(target.protocol, http.StatusBadGateway, "server_error", err.Error())
}
return
}
- c.forwardToChannels(channels, route)
+ c.forwardToProviders(providers, route)
}
func (c *ApiController) readProxyRoute(target proxyTarget) (*proxyRoute, bool) {
@@ -256,22 +256,22 @@ func (c *ApiController) readProxyRoute(target proxyTarget) (*proxyRoute, bool) {
return route, true
}
-// forwardToChannels relays the request to the first channel that answers.
-func (c *ApiController) forwardToChannels(channels []*object.Channel, route *proxyRoute) {
- // Drop the channels this proxy cannot talk to before forwarding, so that
- // the last usable channel is known and its response can be relayed as-is.
- usableChannels := []*object.Channel{}
+// forwardToProviders relays the request to the first provider that answers.
+func (c *ApiController) forwardToProviders(providers []*object.Provider, route *proxyRoute) {
+ // Drop the providers this proxy cannot talk to before forwarding, so that
+ // the last usable provider is known and its response can be relayed as-is.
+ usableProviders := []*object.Provider{}
skipReason := ""
- for _, channel := range channels {
- if reason := c.channelUnusableReason(channel, route.target.protocol); reason != "" {
- beego.Error("skipped channel", channel.GetId()+":", reason)
+ for _, provider := range providers {
+ if reason := c.providerUnusableReason(provider, route.target.protocol); reason != "" {
+ beego.Error("skipped provider", provider.GetId()+":", reason)
skipReason = reason
continue
}
- usableChannels = append(usableChannels, channel)
+ usableProviders = append(usableProviders, provider)
}
- if len(usableChannels) == 0 {
- message := fmt.Sprintf("no usable channel for %s", route.source)
+ if len(usableProviders) == 0 {
+ message := fmt.Sprintf("no usable provider for %s", route.source)
if skipReason != "" {
message = skipReason
}
@@ -280,22 +280,22 @@ func (c *ApiController) forwardToChannels(channels []*object.Channel, route *pro
return
}
- // A channel inside its failure cooldown goes last, so a dead upstream stops
+ // A provider inside its failure cooldown goes last, so a dead upstream stops
// costing every request the time it takes to time out.
- usableChannels = object.SortChannelsByHealth(usableChannels)
+ usableProviders = object.SortProvidersByHealth(usableProviders)
- // Fail over to the next channel as long as nothing has been written to the
- // client yet. The last channel is never retried, so the client gets the
+ // Fail over to the next provider as long as nothing has been written to the
+ // client yet. The last provider is never retried, so the client gets the
// real upstream answer instead of a synthesized error.
lastStatus, lastMessage := http.StatusBadGateway, "upstream connection failed"
- for i, channel := range usableChannels {
+ for i, provider := range usableProviders {
if c.Ctx.Request.Context().Err() != nil {
// The client hung up, there is nobody left to fail over for.
route.recordOutcome(0, "client disconnected")
return
}
- status, message, written := c.forwardToChannel(channel, route, i == len(usableChannels)-1)
+ status, message, written := c.forwardToProvider(provider, route, i == len(usableProviders)-1)
if written {
return
}
@@ -306,17 +306,17 @@ func (c *ApiController) forwardToChannels(channels []*object.Channel, route *pro
c.writeProxyError(route.target.protocol, lastStatus, "server_error", lastMessage)
}
-// forwardToChannel sends the request to a single channel's upstream. It reports
+// forwardToProvider sends the request to a single provider's upstream. It reports
// whether the client response was already written, and when it was not, the
// status and message describing the failure so that the caller can fail over to
-// the next channel. The response of the last channel is always relayed, even
+// the next provider. The response of the last provider is always relayed, even
// when its status would otherwise be retried.
-func (c *ApiController) forwardToChannel(channel *object.Channel, route *proxyRoute, isLast bool) (int, string, bool) {
- route.recordAttempt(channel.GetId())
+func (c *ApiController) forwardToProvider(provider *object.Provider, route *proxyRoute, isLast bool) (int, string, bool) {
+ route.recordAttempt(provider.GetId())
- upstreamUrl, err := object.BuildChannelUrl(channel.BaseUrl, route.target.protocol, route.target.endpoint)
+ upstreamUrl, err := object.BuildProviderUrl(provider.BaseUrl, route.target.protocol, route.target.endpoint)
if err != nil {
- object.ReportChannelFailure(channel.GetId(), err.Error())
+ object.ReportProviderFailure(provider.GetId(), err.Error())
return http.StatusBadGateway, err.Error(), false
}
upstreamUrl = object.AppendQuery(upstreamUrl, c.Ctx.Request.URL.RawQuery)
@@ -331,8 +331,8 @@ func (c *ApiController) forwardToChannel(channel *object.Channel, route *proxyRo
return http.StatusBadGateway, "upstream connection failed", false
}
upstreamReq.Header.Set("Content-Type", "application/json")
- object.SetChannelAuth(upstreamReq.Header, channel)
- if object.UsesClientAuth(channel) {
+ object.SetProviderAuth(upstreamReq.Header, provider)
+ if object.UsesClientAuth(provider) {
c.copyClientAuthHeaders(upstreamReq.Header)
}
if route.target.protocol == object.ProtocolAnthropic {
@@ -347,21 +347,21 @@ func (c *ApiController) forwardToChannel(channel *object.Channel, route *proxyRo
return 0, "", true
}
- beego.Error("upstream request to channel", channel.GetId(), "failed:", err)
+ beego.Error("upstream request to provider", provider.GetId(), "failed:", err)
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
- object.ReportChannelFailure(channel.GetId(), "upstream timeout")
+ object.ReportProviderFailure(provider.GetId(), "upstream timeout")
return http.StatusGatewayTimeout, "upstream timeout", false
}
- object.ReportChannelFailure(channel.GetId(), "upstream connection failed")
+ object.ReportProviderFailure(provider.GetId(), "upstream connection failed")
return http.StatusBadGateway, "upstream connection failed", false
}
defer upstreamResp.Body.Close()
- reportChannelStatus(channel, upstreamResp.StatusCode)
+ reportProviderStatus(provider, upstreamResp.StatusCode)
if !isLast && isRetryableStatus(upstreamResp.StatusCode) {
- beego.Error("channel", channel.GetId(), "returned a retryable status:", upstreamResp.Status)
+ beego.Error("provider", provider.GetId(), "returned a retryable status:", upstreamResp.Status)
// Drain a bounded amount so the connection can be pooled and reused.
_, _ = io.Copy(io.Discard, io.LimitReader(upstreamResp.Body, 4096))
return http.StatusBadGateway, fmt.Sprintf("upstream returned %s", upstreamResp.Status), false
@@ -384,23 +384,23 @@ func (c *ApiController) forwardToChannel(channel *object.Channel, route *proxyRo
return 0, "", true
}
-// reportChannelStatus feeds the breaker that decides in which order channels are
+// reportProviderStatus feeds the breaker that decides in which order providers are
// tried. A status the upstream itself rejected the request with counts as a
-// channel failure: a wrong key or an exhausted quota is not something the next
+// provider failure: a wrong key or an exhausted quota is not something the next
// request will do better.
-func reportChannelStatus(channel *object.Channel, statusCode int) {
+func reportProviderStatus(provider *object.Provider, statusCode int) {
switch {
case isRetryableStatus(statusCode):
- object.ReportChannelFailure(channel.GetId(), fmt.Sprintf("upstream returned %d", statusCode))
+ object.ReportProviderFailure(provider.GetId(), fmt.Sprintf("upstream returned %d", statusCode))
case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden ||
statusCode == http.StatusPaymentRequired:
- object.ReportChannelFailure(channel.GetId(), fmt.Sprintf("upstream rejected the credentials with %d", statusCode))
+ object.ReportProviderFailure(provider.GetId(), fmt.Sprintf("upstream rejected the credentials with %d", statusCode))
default:
- object.ReportChannelSuccess(channel.GetId())
+ object.ReportProviderSuccess(provider.GetId())
}
}
-// clientAuthHeaders are forwarded verbatim by a channel that authenticates with
+// clientAuthHeaders are forwarded verbatim by a provider 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
@@ -417,7 +417,7 @@ var clientAuthHeaders = []string{
}
// hasClientCredentials reports whether the client request carries a credential
-// a client-auth channel could forward.
+// a client-auth provider could forward.
func (c *ApiController) hasClientCredentials() bool {
header := c.Ctx.Request.Header
return header.Get("Authorization") != "" || header.Get("X-Api-Key") != ""
@@ -518,29 +518,29 @@ func isEventStream(upstreamResp *http.Response) bool {
return strings.Contains(strings.ToLower(upstreamResp.Header.Get("Content-Type")), "text/event-stream")
}
-// isRetryableStatus reports whether another channel is worth trying. A rate
-// limit or an upstream-side error is transient or specific to that channel,
+// isRetryableStatus reports whether another provider is worth trying. A rate
+// limit or an upstream-side error is transient or specific to that provider,
// while a 4xx caused by the request itself would fail the same way everywhere.
func isRetryableStatus(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || statusCode >= 500
}
-// channelUnusableReason reports why the proxy cannot forward to a channel, or
+// providerUnusableReason reports why the proxy cannot forward to a provider, or
// an empty string when it can.
-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)
+func (c *ApiController) providerUnusableReason(provider *object.Provider, protocol string) string {
+ if !object.IsProviderTypeSupported(provider) {
+ return fmt.Sprintf("the %s provider type is not supported", provider.Type)
}
- if object.ChannelProtocol(channel) != protocol {
- return fmt.Sprintf("channel %s does not speak the %s API", channel.GetId(), protocol)
+ if object.ProviderProtocol(provider) != protocol {
+ return fmt.Sprintf("provider %s does not speak the %s API", provider.GetId(), protocol)
}
- if channel.BaseUrl == "" {
- return "channel base URL is not configured"
+ if provider.BaseUrl == "" {
+ return "provider 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())
+ // reads as a broken provider rather than a client that sent no key.
+ if object.UsesClientAuth(provider) && !c.hasClientCredentials() {
+ return fmt.Sprintf("provider %s forwards the credentials of the caller, but the request carries none", provider.GetId())
}
return ""
}
diff --git a/controllers/proxy_record.go b/controllers/proxy_record.go
index 0966f52..4e97df3 100644
--- a/controllers/proxy_record.go
+++ b/controllers/proxy_record.go
@@ -28,7 +28,7 @@ import (
const usageTailBytes = 16 * 1024
// finishLlmRecord queues the record of one relayed request. Every path out of
-// forwardToChannels ends the client request, so this is the only place it is
+// forwardToProviders ends the client request, so this is the only place it is
// called from.
func (c *ApiController) finishLlmRecord(route *proxyRoute) {
if route.record == nil {
@@ -41,12 +41,12 @@ func (c *ApiController) finishLlmRecord(route *proxyRoute) {
object.AddLlmRecord(record, route.body)
}
-func (route *proxyRoute) recordAttempt(channelId string) {
+func (route *proxyRoute) recordAttempt(providerId string) {
if route.record == nil {
return
}
route.record.Attempts++
- route.record.Channel = channelId
+ route.record.Provider = providerId
}
func (route *proxyRoute) recordOutcome(status int, message string) {
diff --git a/controllers/proxy_test.go b/controllers/proxy_test.go
index 1772fda..abcf92f 100644
--- a/controllers/proxy_test.go
+++ b/controllers/proxy_test.go
@@ -72,49 +72,49 @@ func TestIsRetryableStatus(t *testing.T) {
}
}
-func TestChannelUnusableReason(t *testing.T) {
+func TestProviderUnusableReason(t *testing.T) {
openAi := object.ProtocolOpenAi
anthropic := object.ProtocolAnthropic
c, _ := newTestApiController()
- channelUnusableReason := c.channelUnusableReason
+ providerUnusableReason := c.providerUnusableReason
- 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)
+ if reason := providerUnusableReason(&object.Provider{Type: "claude", BaseUrl: "https://example.com"}, openAi); !strings.Contains(reason, "not supported") {
+ t.Errorf("the claude provider type should be rejected, got: %s", reason)
}
- if reason := channelUnusableReason(&object.Channel{Type: "openai", BaseUrl: ""}, openAi); !strings.Contains(reason, "base URL") {
+ if reason := providerUnusableReason(&object.Provider{Type: "openai", BaseUrl: ""}, openAi); !strings.Contains(reason, "base URL") {
t.Errorf("an empty base URL should be rejected, got: %s", reason)
}
- if reason := channelUnusableReason(&object.Channel{Type: "custom", BaseUrl: "https://example.com"}, openAi); reason != "" {
- t.Errorf("the custom channel type should be usable, got: %s", reason)
+ if reason := providerUnusableReason(&object.Provider{Type: "custom", BaseUrl: "https://example.com"}, openAi); reason != "" {
+ t.Errorf("the custom provider type should be usable, got: %s", reason)
}
// An OpenAI body cannot be forwarded to an Anthropic upstream, or the other
- // way around, however healthy the channel is.
- mismatched := &object.Channel{Owner: "admin", Name: "claude", Type: "anthropic", BaseUrl: "https://api.anthropic.com"}
- if reason := channelUnusableReason(mismatched, openAi); !strings.Contains(reason, "does not speak") {
- t.Errorf("an anthropic channel should be rejected for an openai request, got: %s", reason)
+ // way around, however healthy the provider is.
+ mismatched := &object.Provider{Owner: "admin", Name: "claude", Type: "anthropic", BaseUrl: "https://api.anthropic.com"}
+ if reason := providerUnusableReason(mismatched, openAi); !strings.Contains(reason, "does not speak") {
+ t.Errorf("an anthropic provider should be rejected for an openai request, got: %s", reason)
}
- if reason := channelUnusableReason(mismatched, anthropic); reason != "" {
- t.Errorf("an anthropic channel should be usable for an anthropic request, got: %s", reason)
+ if reason := providerUnusableReason(mismatched, anthropic); reason != "" {
+ t.Errorf("an anthropic provider should be usable for an anthropic request, got: %s", reason)
}
- 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)
+ if reason := providerUnusableReason(&object.Provider{Owner: "admin", Name: "gpt", Type: "openai", BaseUrl: "https://api.openai.com/v1"}, anthropic); !strings.Contains(reason, "does not speak") {
+ t.Errorf("an openai provider should be rejected for an anthropic request, got: %s", reason)
}
- passthrough := &object.Channel{
+ passthrough := &object.Provider{
Owner: "admin",
Name: "passthrough",
Type: "openai",
BaseUrl: "https://api.openai.com/v1",
- AuthMode: object.ChannelAuthClient,
+ AuthMode: object.ProviderAuthClient,
}
- 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)
+ if reason := providerUnusableReason(passthrough, openAi); !strings.Contains(reason, "carries none") {
+ t.Errorf("a client-auth provider 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)
+ if reason := providerUnusableReason(passthrough, openAi); reason != "" {
+ t.Errorf("a client-auth provider should be usable with a credential, got: %s", reason)
}
}
@@ -227,7 +227,7 @@ func TestIdleTimeoutReader(t *testing.T) {
}
}
-func TestForwardToChannel(t *testing.T) {
+func TestForwardToProvider(t *testing.T) {
overloadedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
@@ -252,15 +252,15 @@ func TestForwardToChannel(t *testing.T) {
}))
defer healthyServer.Close()
- overloadedChannel := &object.Channel{Owner: "admin", Name: "overloaded", Type: "openai", BaseUrl: overloadedServer.URL, ApiKey: "sk-bad"}
- healthyChannel := &object.Channel{Owner: "admin", Name: "healthy", Type: "openai", BaseUrl: healthyServer.URL + "/", ApiKey: "sk-good"}
+ overloadedProvider := &object.Provider{Owner: "admin", Name: "overloaded", Type: "openai", BaseUrl: overloadedServer.URL, ApiKey: "sk-bad"}
+ healthyProvider := &object.Provider{Owner: "admin", Name: "healthy", Type: "openai", BaseUrl: healthyServer.URL + "/", ApiKey: "sk-good"}
rawBody := []byte(`{"model":"gpt-4","messages":[]}`)
route := &proxyRoute{target: openAiChat, body: rawBody}
// A retryable status fails over instead of reaching the client.
c, recorder := newTestApiController()
- statusCode, message, written := c.forwardToChannel(overloadedChannel, route, false)
+ statusCode, message, written := c.forwardToProvider(overloadedProvider, route, false)
if written {
t.Fatal("a retryable status was relayed instead of failing over")
}
@@ -271,31 +271,31 @@ func TestForwardToChannel(t *testing.T) {
t.Errorf("a body was written before failing over: %s", recorder.Body.String())
}
- // The last channel is relayed as-is, even with a retryable status, so that
+ // The last provider is relayed as-is, even with a retryable status, so that
// the client sees the real upstream answer.
c, recorder = newTestApiController()
- _, _, written = c.forwardToChannel(overloadedChannel, route, true)
+ _, _, written = c.forwardToProvider(overloadedProvider, route, true)
if !written || recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "overloaded") {
- t.Errorf("the last channel was not relayed: written = %v, statusCode = %d, body = %s", written, recorder.Code, recorder.Body.String())
+ t.Errorf("the last provider was not relayed: written = %v, statusCode = %d, body = %s", written, recorder.Code, recorder.Body.String())
}
- // A healthy channel, with a trailing slash in its base URL.
+ // A healthy provider, with a trailing slash in its base URL.
c, recorder = newTestApiController()
- _, _, written = c.forwardToChannel(healthyChannel, route, true)
+ _, _, written = c.forwardToProvider(healthyProvider, route, true)
if !written || recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "choices") {
- t.Errorf("the healthy channel failed: written = %v, statusCode = %d, body = %s", written, recorder.Code, recorder.Body.String())
+ t.Errorf("the healthy provider failed: written = %v, statusCode = %d, body = %s", written, recorder.Code, recorder.Body.String())
}
// stream=true, but the upstream rejected the request: the JSON error must
// not be dressed up as an SSE stream.
c, recorder = newTestApiController()
- c.forwardToChannel(overloadedChannel, &proxyRoute{target: openAiChat, body: rawBody, stream: true}, true)
+ c.forwardToProvider(overloadedProvider, &proxyRoute{target: openAiChat, body: rawBody, stream: true}, true)
if header := recorder.Header().Get("Content-Type"); header != "application/json" {
t.Errorf("Content-Type = %s, expected application/json", header)
}
}
-func TestForwardToChannelAnthropic(t *testing.T) {
+func TestForwardToProviderAnthropic(t *testing.T) {
var gotPath, gotKey, gotVersion, gotAuth string
var gotBeta []string
@@ -311,13 +311,13 @@ func TestForwardToChannelAnthropic(t *testing.T) {
}))
defer server.Close()
- channel := &object.Channel{Owner: "admin", Name: "claude", Type: "anthropic", BaseUrl: server.URL, ApiKey: "sk-ant-test"}
+ provider := &object.Provider{Owner: "admin", Name: "claude", Type: "anthropic", BaseUrl: server.URL, ApiKey: "sk-ant-test"}
route := &proxyRoute{target: anthropicMessages, body: []byte(`{"model":"claude-opus-5","messages":[]}`)}
c, recorder := newTestApiController()
c.Ctx.Request.Header.Add("Anthropic-Beta", "fine-grained-tool-streaming-2025-05-14")
- if _, _, written := c.forwardToChannel(channel, route, true); !written {
- t.Fatal("the anthropic channel was not relayed")
+ if _, _, written := c.forwardToProvider(provider, route, true); !written {
+ t.Fatal("the anthropic provider was not relayed")
}
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "content") {
diff --git a/object/agent.go b/object/agent.go
index 72dd883..7c9065e 100644
--- a/object/agent.go
+++ b/object/agent.go
@@ -23,14 +23,14 @@ import (
"github.com/xorm-io/core"
)
-// ErrAgentNoChannel lets the proxy answer with a client error, not a gateway one.
-var ErrAgentNoChannel = errors.New("no channel is bound to this agent")
+// ErrAgentNoProvider lets the proxy answer with a client error, not a gateway one.
+var ErrAgentNoProvider = errors.New("no provider is bound to this agent")
// AgentOwner is the owner every agent row is stored under: an agent belongs to
// the host, and its proxy endpoint is reached without a session.
const AgentOwner = "admin"
-// How an agent reaches its channel. The values are the ones agentprovider
+// How an agent reaches its provider. The values are the ones agentprovider
// writes into the agent's own configuration file.
const (
ModeGateway = "gateway"
@@ -45,13 +45,13 @@ type Agent struct {
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
- // Channel is the "owner/name" id of the bound channel, empty when unbound.
- Channel string `xorm:"varchar(200)" json:"channel"`
- // Fallbacks are the channel ids tried, in order, when Channel cannot answer.
+ // Provider is the "owner/name" id of the bound provider, empty when unbound.
+ Provider string `xorm:"varchar(200)" json:"provider"`
+ // Fallbacks are the provider ids tried, in order, when Provider cannot answer.
// They are JSON-serialized by xorm, hence the text column.
Fallbacks []string `xorm:"mediumtext" json:"fallbacks"`
- // Mode is how the agent reaches its channel: ModeGateway routes it through
- // the local proxy, ModeDirect writes the channel's own endpoint into the
+ // Mode is how the agent reaches its provider: ModeGateway routes it through
+ // the local proxy, ModeDirect writes the provider's own endpoint into the
// agent's configuration file.
Mode string `xorm:"varchar(20)" json:"mode"`
}
@@ -91,11 +91,11 @@ func GetAgents() (map[string]*Agent, error) {
return result, nil
}
-// SetAgentRouting stores where one agent's requests go: the bound channel, the
-// channels to fall over to when it cannot answer, and how the agent reaches
-// them. Every channel is resolved here so a typo fails at the form rather than
+// SetAgentRouting stores where one agent's requests go: the bound provider, the
+// providers to fall over to when it cannot answer, and how the agent reaches
+// them. Every provider is resolved here so a typo fails at the form rather than
// on the next relayed request.
-func SetAgentRouting(agentId string, channelId string, fallbacks []string, mode string) error {
+func SetAgentRouting(agentId string, providerId string, fallbacks []string, mode string) error {
if agentId == "" {
return errors.New("the agent id is empty")
}
@@ -106,17 +106,17 @@ func SetAgentRouting(agentId string, channelId string, fallbacks []string, mode
return fmt.Errorf("invalid agent mode: %s", mode)
}
- fallbacks = normalizeFallbacks(channelId, fallbacks)
- for _, id := range append([]string{channelId}, fallbacks...) {
+ fallbacks = normalizeFallbacks(providerId, fallbacks)
+ for _, id := range append([]string{providerId}, fallbacks...) {
if id == "" {
continue
}
- channel, err := getChannelById(id)
+ provider, err := getProviderById(id)
if err != nil {
return err
}
- if channel == nil {
- return fmt.Errorf("the channel does not exist: %s", id)
+ if provider == nil {
+ return fmt.Errorf("the provider does not exist: %s", id)
}
}
@@ -132,38 +132,38 @@ func SetAgentRouting(agentId string, channelId string, fallbacks []string, mode
Name: agentId,
CreatedTime: now,
UpdatedTime: now,
- Channel: channelId,
+ Provider: providerId,
Fallbacks: fallbacks,
Mode: mode,
})
return err
}
- // Cols() is what writes an empty channel: xorm skips zero values otherwise.
+ // Cols() is what writes an empty provider: xorm skips zero values otherwise.
_, err = ormer.Engine.ID(core.PK{AgentOwner, agentId}).
- Cols("channel", "fallbacks", "mode", "updated_time").
- Update(&Agent{Channel: channelId, Fallbacks: fallbacks, Mode: mode, UpdatedTime: now})
+ Cols("provider", "fallbacks", "mode", "updated_time").
+ Update(&Agent{Provider: providerId, Fallbacks: fallbacks, Mode: mode, UpdatedTime: now})
return err
}
-// SetAgentChannel binds an agent to a channel, or unbinds it when channelId is
+// SetAgentProvider binds an agent to a provider, or unbinds it when providerId is
// empty, leaving its fallbacks and mode as they are.
-func SetAgentChannel(agentId string, channelId string) error {
+func SetAgentProvider(agentId string, providerId string) error {
stored, err := GetAgent(agentId)
if err != nil {
return err
}
if stored == nil {
- return SetAgentRouting(agentId, channelId, nil, "")
+ return SetAgentRouting(agentId, providerId, nil, "")
}
- return SetAgentRouting(agentId, channelId, stored.Fallbacks, stored.Mode)
+ return SetAgentRouting(agentId, providerId, stored.Fallbacks, stored.Mode)
}
-// normalizeFallbacks drops empty entries, duplicates and the primary channel,
+// normalizeFallbacks drops empty entries, duplicates and the primary provider,
// so the chain never tries the same upstream twice.
-func normalizeFallbacks(channelId string, fallbacks []string) []string {
+func normalizeFallbacks(providerId string, fallbacks []string) []string {
result := []string{}
- seen := map[string]bool{channelId: true, "": true}
+ seen := map[string]bool{providerId: true, "": true}
for _, id := range fallbacks {
if seen[id] {
continue
@@ -174,61 +174,61 @@ func normalizeFallbacks(channelId string, fallbacks []string) []string {
return result
}
-// GetChannelsByAgent is the chain one agent's requests are tried against: the
-// bound channel first, then its fallbacks. A missing or disabled entry is
+// GetProvidersByAgent is the chain one agent's requests are tried against: the
+// bound provider first, then its fallbacks. A missing or disabled entry is
// skipped rather than failing the request, which is what makes the fallbacks
// worth configuring; a chain with nothing left in it reports why.
-func GetChannelsByAgent(agentId string) ([]*Channel, error) {
+func GetProvidersByAgent(agentId string) ([]*Provider, error) {
agent, err := GetAgent(agentId)
if err != nil {
return nil, err
}
- if agent == nil || agent.Channel == "" {
- return nil, fmt.Errorf("%w: %s", ErrAgentNoChannel, agentId)
+ if agent == nil || agent.Provider == "" {
+ return nil, fmt.Errorf("%w: %s", ErrAgentNoProvider, agentId)
}
- channels := []*Channel{}
+ providers := []*Provider{}
skipped := ""
- for _, id := range append([]string{agent.Channel}, agent.Fallbacks...) {
- channel, err := getChannelById(id)
+ for _, id := range append([]string{agent.Provider}, agent.Fallbacks...) {
+ provider, err := getProviderById(id)
if err != nil {
skipped = err.Error()
continue
}
- if channel == nil {
- skipped = fmt.Sprintf("the channel bound to agent %s no longer exists: %s", agentId, id)
+ if provider == nil {
+ skipped = fmt.Sprintf("the provider bound to agent %s no longer exists: %s", agentId, id)
continue
}
- if channel.Status != "enabled" {
- skipped = fmt.Sprintf("the channel bound to agent %s is disabled: %s", agentId, id)
+ if provider.Status != "enabled" {
+ skipped = fmt.Sprintf("the provider bound to agent %s is disabled: %s", agentId, id)
continue
}
- channels = append(channels, channel)
+ providers = append(providers, provider)
}
- if len(channels) == 0 {
+ if len(providers) == 0 {
if skipped == "" {
- return nil, fmt.Errorf("%w: %s", ErrAgentNoChannel, agentId)
+ return nil, fmt.Errorf("%w: %s", ErrAgentNoProvider, agentId)
}
return nil, errors.New(skipped)
}
- return channels, nil
+ return providers, nil
}
-// GetChannelByAgent resolves the first channel of an agent's chain.
-func GetChannelByAgent(agentId string) (*Channel, error) {
- channels, err := GetChannelsByAgent(agentId)
+// GetProviderByAgent resolves the first provider of an agent's chain.
+func GetProviderByAgent(agentId string) (*Provider, error) {
+ providers, err := GetProvidersByAgent(agentId)
if err != nil {
return nil, err
}
- return channels[0], nil
+ return providers[0], nil
}
-// getChannelById is GetChannel() without its panic on a malformed id.
-func getChannelById(channelId string) (*Channel, error) {
- tokens := strings.Split(channelId, "/")
+// getProviderById is GetProvider() without its panic on a malformed id.
+func getProviderById(providerId string) (*Provider, error) {
+ tokens := strings.Split(providerId, "/")
if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" {
- return nil, fmt.Errorf("invalid channel ID: %s", channelId)
+ return nil, fmt.Errorf("invalid provider ID: %s", providerId)
}
- return getChannel(tokens[0], tokens[1])
+ return getProvider(tokens[0], tokens[1])
}
diff --git a/object/agent_test.go b/object/agent_test.go
index cc0949c..5c63ccf 100644
--- a/object/agent_test.go
+++ b/object/agent_test.go
@@ -19,66 +19,66 @@ import (
"testing"
)
-func TestSetAgentChannelBindsAndUnbinds(t *testing.T) {
+func TestSetAgentProviderBindsAndUnbinds(t *testing.T) {
initSqliteOrmer(t)
- channel := newTestChannel("codex-upstream", "sk-test")
- channel.BaseUrl = "https://api.openai.com/v1"
- if _, err := AddChannel(channel); err != nil {
+ provider := newTestProvider("codex-upstream", "sk-test")
+ provider.BaseUrl = "https://api.openai.com/v1"
+ if _, err := AddProvider(provider); err != nil {
t.Fatal(err)
}
- if err := SetAgentChannel("codex", "admin/codex-upstream"); err != nil {
+ if err := SetAgentProvider("codex", "admin/codex-upstream"); err != nil {
t.Fatal(err)
}
agents, err := GetAgents()
if err != nil {
t.Fatal(err)
}
- if agents["codex"].Channel != "admin/codex-upstream" {
- t.Fatalf("expected the binding to be stored, got %q", agents["codex"].Channel)
+ if agents["codex"].Provider != "admin/codex-upstream" {
+ t.Fatalf("expected the binding to be stored, got %q", agents["codex"].Provider)
}
- if err := SetAgentChannel("codex", ""); err != nil {
+ if err := SetAgentProvider("codex", ""); err != nil {
t.Fatal(err)
}
agents, err = GetAgents()
if err != nil {
t.Fatal(err)
}
- if agents["codex"].Channel != "" {
- t.Fatal("expected an empty channel to unbind the agent")
+ if agents["codex"].Provider != "" {
+ t.Fatal("expected an empty provider to unbind the agent")
}
}
-func TestSetAgentChannelRejectsAMissingChannel(t *testing.T) {
+func TestSetAgentProviderRejectsAMissingProvider(t *testing.T) {
initSqliteOrmer(t)
- if err := SetAgentChannel("codex", "admin/nope"); err == nil {
- t.Fatal("expected binding to a channel that does not exist to fail")
+ if err := SetAgentProvider("codex", "admin/nope"); err == nil {
+ t.Fatal("expected binding to a provider that does not exist to fail")
}
- if err := SetAgentChannel("codex", "nope"); err == nil {
- t.Fatal("expected a malformed channel id to fail")
+ if err := SetAgentProvider("codex", "nope"); err == nil {
+ t.Fatal("expected a malformed provider id to fail")
}
}
-func TestGetChannelByAgent(t *testing.T) {
+func TestGetProviderByAgent(t *testing.T) {
initSqliteOrmer(t)
- channel := newTestChannel("bound", "sk-test")
- channel.BaseUrl = "https://api.openai.com/v1"
- if _, err := AddChannel(channel); err != nil {
+ provider := newTestProvider("bound", "sk-test")
+ provider.BaseUrl = "https://api.openai.com/v1"
+ if _, err := AddProvider(provider); err != nil {
t.Fatal(err)
}
- if _, err := GetChannelByAgent("codex"); !errors.Is(err, ErrAgentNoChannel) {
- t.Fatalf("expected ErrAgentNoChannel for an unbound agent, got %v", err)
+ if _, err := GetProviderByAgent("codex"); !errors.Is(err, ErrAgentNoProvider) {
+ t.Fatalf("expected ErrAgentNoProvider for an unbound agent, got %v", err)
}
- if err := SetAgentChannel("codex", "admin/bound"); err != nil {
+ if err := SetAgentProvider("codex", "admin/bound"); err != nil {
t.Fatal(err)
}
- resolved, err := GetChannelByAgent("codex")
+ resolved, err := GetProviderByAgent("codex")
if err != nil {
t.Fatal(err)
}
@@ -86,28 +86,28 @@ func TestGetChannelByAgent(t *testing.T) {
t.Fatalf("expected the decrypted API key, got %q", resolved.ApiKey)
}
- if _, err := DeleteChannel(channel); err != nil {
+ if _, err := DeleteProvider(provider); err != nil {
t.Fatal(err)
}
- if _, err := GetChannelByAgent("codex"); err == nil {
- t.Fatal("expected a binding pointing at a deleted channel to fail")
+ if _, err := GetProviderByAgent("codex"); err == nil {
+ t.Fatal("expected a binding pointing at a deleted provider to fail")
}
}
-func TestGetChannelByAgentRejectsADisabledChannel(t *testing.T) {
+func TestGetProviderByAgentRejectsADisabledProvider(t *testing.T) {
initSqliteOrmer(t)
- channel := newTestChannel("off", "sk-test")
- channel.BaseUrl = "https://api.openai.com/v1"
- channel.Status = "disabled"
- if _, err := AddChannel(channel); err != nil {
+ provider := newTestProvider("off", "sk-test")
+ provider.BaseUrl = "https://api.openai.com/v1"
+ provider.Status = "disabled"
+ if _, err := AddProvider(provider); err != nil {
t.Fatal(err)
}
- if err := SetAgentChannel("codex", "admin/off"); err != nil {
+ if err := SetAgentProvider("codex", "admin/off"); err != nil {
t.Fatal(err)
}
- if _, err := GetChannelByAgent("codex"); err == nil {
- t.Fatal("expected a disabled channel to be rejected")
+ if _, err := GetProviderByAgent("codex"); err == nil {
+ t.Fatal("expected a disabled provider to be rejected")
}
}
diff --git a/object/channel_health.go b/object/channel_health.go
deleted file mode 100644
index ef4317b..0000000
--- a/object/channel_health.go
+++ /dev/null
@@ -1,167 +0,0 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package object
-
-import (
- "sort"
- "sync"
- "time"
-
- "github.com/apache/casbin-gateway/util"
-)
-
-const (
- // channelFailureThreshold is how many failures in a row take a channel out
- // of rotation. One failure is not enough: an upstream that answers 500 once
- // is still the channel the operator picked.
- channelFailureThreshold = 2
- channelCooldown = 15 * time.Second
- channelMaxCooldown = 5 * time.Minute
-)
-
-// ChannelHealth is what the proxy has seen of one channel. It lives in memory
-// only: it describes this process's view of an upstream, not a stored setting.
-type ChannelHealth struct {
- Channel string `json:"channel"`
- Healthy bool `json:"healthy"`
- Successes int64 `json:"successes"`
- Failures int64 `json:"failures"`
- Consecutive int `json:"consecutive"`
- LastError string `json:"lastError"`
- LastFailure string `json:"lastFailure"`
- RetryTime string `json:"retryTime"`
-}
-
-type channelHealth struct {
- successes int64
- failures int64
- consecutive int
- lastError string
- lastFailure time.Time
- retryTime time.Time
-}
-
-var (
- channelHealthMutex sync.Mutex
- channelHealthMap = map[string]*channelHealth{}
-)
-
-// ReportChannelSuccess closes the breaker: an upstream that answered is back in
-// rotation whatever it did before.
-func ReportChannelSuccess(channelId string) {
- channelHealthMutex.Lock()
- defer channelHealthMutex.Unlock()
-
- health := healthOf(channelId)
- health.successes++
- health.consecutive = 0
- health.retryTime = time.Time{}
-}
-
-// ReportChannelFailure records an attempt that could not be relayed. Once a
-// channel has failed channelFailureThreshold times in a row it is suspended for
-// a window that doubles with every further failure, so a dead upstream stops
-// costing every request the time it takes to time out.
-func ReportChannelFailure(channelId string, reason string) {
- channelHealthMutex.Lock()
- defer channelHealthMutex.Unlock()
-
- health := healthOf(channelId)
- health.failures++
- health.consecutive++
- health.lastError = reason
- health.lastFailure = time.Now()
-
- if health.consecutive < channelFailureThreshold {
- return
- }
- cooldown := channelCooldown << (health.consecutive - channelFailureThreshold)
- if cooldown > channelMaxCooldown || cooldown <= 0 {
- cooldown = channelMaxCooldown
- }
- health.retryTime = time.Now().Add(cooldown)
-}
-
-// IsChannelSuspended reports whether a channel is inside its cooldown window.
-// A suspended channel is tried last rather than dropped: it may be the only one
-// the agent has.
-func IsChannelSuspended(channelId string) bool {
- channelHealthMutex.Lock()
- defer channelHealthMutex.Unlock()
-
- health, ok := channelHealthMap[channelId]
- return ok && time.Now().Before(health.retryTime)
-}
-
-// ClearChannelHealth forgets what the proxy saw of a channel, which is what an
-// edited channel deserves: its base URL or key may be the thing that was fixed.
-func ClearChannelHealth(channelId string) {
- channelHealthMutex.Lock()
- defer channelHealthMutex.Unlock()
- delete(channelHealthMap, channelId)
-}
-
-// GetChannelHealth lists what is known about every channel used since startup.
-func GetChannelHealth() []*ChannelHealth {
- channelHealthMutex.Lock()
- defer channelHealthMutex.Unlock()
-
- now := time.Now()
- result := make([]*ChannelHealth, 0, len(channelHealthMap))
- for id, health := range channelHealthMap {
- item := &ChannelHealth{
- Channel: id,
- Healthy: !now.Before(health.retryTime),
- Successes: health.successes,
- Failures: health.failures,
- Consecutive: health.consecutive,
- LastError: health.lastError,
- }
- if !health.lastFailure.IsZero() {
- item.LastFailure = util.FormatTime(health.lastFailure)
- }
- if now.Before(health.retryTime) {
- item.RetryTime = util.FormatTime(health.retryTime)
- }
- result = append(result, item)
- }
-
- sort.Slice(result, func(i, j int) bool { return result[i].Channel < result[j].Channel })
- return result
-}
-
-// SortChannelsByHealth puts the channels that are inside a cooldown window last,
-// keeping the configured order among the rest.
-func SortChannelsByHealth(channels []*Channel) []*Channel {
- ready := make([]*Channel, 0, len(channels))
- suspended := []*Channel{}
- for _, channel := range channels {
- if IsChannelSuspended(channel.GetId()) {
- suspended = append(suspended, channel)
- continue
- }
- ready = append(ready, channel)
- }
- return append(ready, suspended...)
-}
-
-func healthOf(channelId string) *channelHealth {
- health, ok := channelHealthMap[channelId]
- if !ok {
- health = &channelHealth{}
- channelHealthMap[channelId] = health
- }
- return health
-}
diff --git a/object/llm_record.go b/object/llm_record.go
index 1f337f3..65b0374 100644
--- a/object/llm_record.go
+++ b/object/llm_record.go
@@ -48,7 +48,7 @@ type LlmRecord struct {
Protocol string `xorm:"varchar(20)" json:"protocol"`
Endpoint string `xorm:"varchar(100)" json:"endpoint"`
Model string `xorm:"varchar(255) index" json:"model"`
- Channel string `xorm:"varchar(201) index" json:"channel"`
+ Provider string `xorm:"varchar(201) index" json:"provider"`
Agent string `xorm:"varchar(201) index" json:"agent"`
ClientIp string `xorm:"varchar(100) index" json:"clientIp"`
Stream bool `xorm:"bool" json:"stream"`
@@ -88,7 +88,7 @@ type LlmRecord struct {
// LlmRecordFilter narrows the record list. Empty fields match everything.
type LlmRecordFilter struct {
Model string
- Channel string
+ Provider string
Agent string
ClientIp string
Outcome string
@@ -105,10 +105,10 @@ type LlmModelStat struct {
Cost float64 `json:"cost"`
}
-// LlmChannelStat is one channel's share of the window the stats cover, which is
+// LlmProviderStat is one provider's share of the window the stats cover, which is
// what tells two providers serving the same models apart.
-type LlmChannelStat struct {
- Channel string `json:"channel"`
+type LlmProviderStat struct {
+ Provider string `json:"provider"`
Requests int64 `json:"requests"`
Failed int64 `json:"failed"`
Tokens int64 `json:"tokens"`
@@ -117,17 +117,17 @@ type LlmChannelStat struct {
// LlmRecordStats totals the records a filter matches.
type LlmRecordStats struct {
- Requests int64 `json:"requests"`
- Failed int64 `json:"failed"`
- PromptTokens int64 `json:"promptTokens"`
- CompletionTokens int64 `json:"completionTokens"`
- CacheReadTokens int64 `json:"cacheReadTokens"`
- CacheWriteTokens int64 `json:"cacheWriteTokens"`
- TotalTokens int64 `json:"totalTokens"`
- Cost float64 `json:"cost"`
- Unpriced int64 `json:"unpriced"`
- Models []LlmModelStat `json:"models"`
- Channels []LlmChannelStat `json:"channels"`
+ Requests int64 `json:"requests"`
+ Failed int64 `json:"failed"`
+ PromptTokens int64 `json:"promptTokens"`
+ CompletionTokens int64 `json:"completionTokens"`
+ CacheReadTokens int64 `json:"cacheReadTokens"`
+ CacheWriteTokens int64 `json:"cacheWriteTokens"`
+ TotalTokens int64 `json:"totalTokens"`
+ Cost float64 `json:"cost"`
+ Unpriced int64 `json:"unpriced"`
+ Models []LlmModelStat `json:"models"`
+ Providers []LlmProviderStat `json:"providers"`
}
// LlmRecordStatus is what the management page shows about the recorder itself,
@@ -468,8 +468,8 @@ func llmRecordSession(filter LlmRecordFilter) *xorm.Session {
if filter.Model != "" {
session = session.And("model like ?", "%"+filter.Model+"%")
}
- if filter.Channel != "" {
- session = session.And("channel like ?", "%"+filter.Channel+"%")
+ if filter.Provider != "" {
+ session = session.And("provider like ?", "%"+filter.Provider+"%")
}
if filter.Agent != "" {
session = session.And("agent = ?", filter.Agent)
@@ -590,7 +590,7 @@ func GetLlmRecordStats(filter LlmRecordFilter, topModels int) (*LlmRecordStats,
Cost: cost,
Unpriced: unpriced,
Models: []LlmModelStat{},
- Channels: []LlmChannelStat{},
+ Providers: []LlmProviderStat{},
}
modelSession := llmRecordSession(filter)
@@ -605,17 +605,17 @@ func GetLlmRecordStats(filter LlmRecordFilter, topModels int) (*LlmRecordStats,
return nil, err
}
- channelSession := llmRecordSession(filter)
- defer channelSession.Close()
- err = channelSession.Table("llm_record").
- Select("channel as channel, COUNT(*) as requests, " +
+ providerSession := llmRecordSession(filter)
+ defer providerSession.Close()
+ err = providerSession.Table("llm_record").
+ Select("provider as provider, COUNT(*) as requests, " +
"SUM(CASE WHEN status >= 200 AND status < 300 THEN 0 ELSE 1 END) as failed, " +
"SUM(total_tokens) as tokens, SUM(cost) as cost").
- Where("channel <> ''").
- GroupBy("channel").
+ Where("provider <> ''").
+ GroupBy("provider").
Desc("requests").
Limit(topModels).
- Find(&stats.Channels)
+ Find(&stats.Providers)
if err != nil {
return nil, err
}
diff --git a/object/ormer.go b/object/ormer.go
index f5e56f5..02c4f06 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -283,8 +283,8 @@ func (a *Ormer) createTable() {
panic(err)
}
- // Register Channel table for LLM gateway milestone 1.1.
- err = a.Engine.Sync2(new(Channel))
+ // Register Provider table for LLM gateway milestone 1.1.
+ err = a.Engine.Sync2(new(Provider))
if err != nil {
panic(err)
}
diff --git a/object/channel.go b/object/provider.go
similarity index 57%
rename from object/channel.go
rename to object/provider.go
index db45d31..2d4073e 100644
--- a/object/channel.go
+++ b/object/provider.go
@@ -31,11 +31,11 @@ import (
"github.com/xorm-io/core"
)
-// ErrNoChannelAvailable is returned by GetChannelsByModel when no enabled
-// channel matches the requested model name. It is a sentinel error so
+// ErrNoProviderAvailable is returned by GetProvidersByModel when no enabled
+// provider matches the requested model name. It is a sentinel error so
// callers can distinguish "no match" (client error, HTTP 400) from
// database failures (server error, HTTP 502).
-var ErrNoChannelAvailable = errors.New("no available channel")
+var ErrNoProviderAvailable = errors.New("no available provider")
// ApiKeyMask is what the API returns in place of a stored API key. Sending it
// back in an update means "keep the existing key"; sending anything else
@@ -53,121 +53,121 @@ func apiKeyEncryptionSecret() string {
}
// apiKeyAad binds the ciphertext to its own row, so a value copied into another
-// channel's api_key column no longer decrypts.
-func apiKeyAad(channel *Channel) string {
- return channel.GetId()
+// provider's api_key column no longer decrypts.
+func apiKeyAad(provider *Provider) string {
+ return provider.GetId()
}
-// encryptApiKey needs channel.Owner and channel.Name to be set already.
-func encryptApiKey(channel *Channel) error {
- encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), channel.ApiKey, apiKeyAad(channel))
+// encryptApiKey needs provider.Owner and provider.Name to be set already.
+func encryptApiKey(provider *Provider) error {
+ encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), provider.ApiKey, apiKeyAad(provider))
if err != nil {
return err
}
- channel.ApiKey = encrypted
+ provider.ApiKey = encrypted
return nil
}
-// decryptChannel restores the plaintext ApiKey on a channel just read from the
+// decryptProvider restores the plaintext ApiKey on a provider just read from the
// database. A failure leaves the stored value in place rather than dropping the
-// channel, but is logged: otherwise a changed key looks exactly like a healthy
-// channel whose upstream answers 401.
-func decryptChannel(channel *Channel) {
- if channel == nil {
+// provider, but is logged: otherwise a changed key looks exactly like a healthy
+// provider whose upstream answers 401.
+func decryptProvider(provider *Provider) {
+ if provider == nil {
return
}
secret := apiKeyEncryptionSecret()
- stored := channel.ApiKey
+ stored := provider.ApiKey
- plain, err := util.DecryptWithKey(secret, stored, apiKeyAad(channel))
+ plain, err := util.DecryptWithKey(secret, stored, apiKeyAad(provider))
if err != nil {
- fmt.Printf("decryptChannel(): channel [%s]: %v\n", channel.GetId(), err)
+ fmt.Printf("decryptProvider(): provider [%s]: %v\n", provider.GetId(), err)
return
}
- channel.ApiKey = plain
+ provider.ApiKey = plain
if util.NeedsReEncryption(secret, stored) {
- upgradeStoredApiKey(channel)
+ upgradeStoredApiKey(provider)
}
}
-// apiKeyUpgrades collapses concurrent upgrades of the same row: GetChannelsByModel()
+// apiKeyUpgrades collapses concurrent upgrades of the same row: GetProvidersByModel()
// runs on every proxied request.
var apiKeyUpgrades sync.Map
// upgradeStoredApiKey rewrites a plaintext or older-format key in the current
// format. Only api_key is touched, so UpdatedTime keeps reflecting the last real
// edit. A failure is logged and ignored, and retried on the next read.
-func upgradeStoredApiKey(channel *Channel) {
- id := channel.GetId()
+func upgradeStoredApiKey(provider *Provider) {
+ id := provider.GetId()
if _, busy := apiKeyUpgrades.LoadOrStore(id, struct{}{}); busy {
return
}
defer apiKeyUpgrades.Delete(id)
- encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), channel.ApiKey, apiKeyAad(channel))
+ encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), provider.ApiKey, apiKeyAad(provider))
if err != nil {
- fmt.Printf("upgradeStoredApiKey(): channel [%s]: %v\n", id, err)
+ fmt.Printf("upgradeStoredApiKey(): provider [%s]: %v\n", id, err)
return
}
- _, err = ormer.Engine.ID(core.PK{channel.Owner, channel.Name}).
- Cols("api_key").Update(&Channel{ApiKey: encrypted})
+ _, err = ormer.Engine.ID(core.PK{provider.Owner, provider.Name}).
+ Cols("api_key").Update(&Provider{ApiKey: encrypted})
if err != nil {
- fmt.Printf("upgradeStoredApiKey(): channel [%s]: %v\n", id, err)
+ fmt.Printf("upgradeStoredApiKey(): provider [%s]: %v\n", id, err)
}
}
-func decryptChannels(channels []*Channel) {
- for _, channel := range channels {
- decryptChannel(channel)
+func decryptProviders(providers []*Provider) {
+ for _, provider := range providers {
+ decryptProvider(provider)
}
}
const (
- maxChannelModels = 200
- maxChannelModelChars = 100
+ maxProviderModels = 200
+ maxProviderModelChars = 100
)
var (
- channelTypes = []string{"openai", "custom", "anthropic"}
- channelStatuses = []string{"enabled", "disabled"}
+ providerTypes = []string{"openai", "custom", "anthropic"}
+ providerStatuses = []string{"enabled", "disabled"}
)
-// The two ways a channel authenticates upstream. In ChannelAuthClient mode the
+// The two ways a provider authenticates upstream. In ProviderAuthClient 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"
+ ProviderAuthProvider = "provider"
+ ProviderAuthClient = "client"
)
-var channelAuthModes = []string{ChannelAuthChannel, ChannelAuthClient}
+var providerAuthModes = []string{ProviderAuthProvider, ProviderAuthClient}
-// UsesClientAuth reports whether the channel authenticates with the caller's
+// UsesClientAuth reports whether the provider 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
+func UsesClientAuth(provider *Provider) bool {
+ return provider.AuthMode == ProviderAuthClient
}
// 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.
+// forwarded to a provider whose upstream speaks the same one.
const (
ProtocolOpenAi = "openai"
ProtocolAnthropic = "anthropic"
)
-// IsChannelTypeSupported reports whether the gateway can talk to the channel's
+// IsProviderTypeSupported reports whether the gateway can talk to the provider's
// upstream.
-func IsChannelTypeSupported(channel *Channel) bool {
- return containsString(channelTypes, channel.Type)
+func IsProviderTypeSupported(provider *Provider) bool {
+ return containsString(providerTypes, provider.Type)
}
-// ChannelProtocol is the wire format a channel's upstream speaks. Everything
+// ProviderProtocol is the wire format a provider's upstream speaks. Everything
// that is not Anthropic is reached with an OpenAI-formatted request.
-func ChannelProtocol(channel *Channel) string {
- if channel.Type == "anthropic" {
+func ProviderProtocol(provider *Provider) string {
+ if provider.Type == "anthropic" {
return ProtocolAnthropic
}
return ProtocolOpenAi
@@ -182,8 +182,8 @@ func containsString(values []string, value string) bool {
return false
}
-// Channel is an upstream AI provider channel. (Milestone 1.1)
-type Channel struct {
+// Provider is an upstream AI provider provider. (Milestone 1.1)
+type Provider struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
@@ -205,115 +205,115 @@ type Channel struct {
Status string `xorm:"varchar(100)" json:"status"`
}
-func (channel *Channel) GetId() string {
- return fmt.Sprintf("%s/%s", channel.Owner, channel.Name)
+func (provider *Provider) GetId() string {
+ return fmt.Sprintf("%s/%s", provider.Owner, provider.Name)
}
-func GetChannels(owner string) ([]*Channel, error) {
- channels := []*Channel{}
+func GetProviders(owner string) ([]*Provider, error) {
+ providers := []*Provider{}
session := GetSession(owner, -1, -1, "", "", "", "")
- err := session.Find(&channels)
- decryptChannels(channels)
- return channels, err
+ err := session.Find(&providers)
+ decryptProviders(providers)
+ return providers, err
}
-func GetChannelCount(owner, field, value string) (int64, error) {
+func GetProviderCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
- return session.Count(&Channel{})
+ return session.Count(&Provider{})
}
-func GetPaginationChannels(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Channel, error) {
- channels := []*Channel{}
+func GetPaginationProviders(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Provider, error) {
+ providers := []*Provider{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
- err := session.Find(&channels)
- decryptChannels(channels)
- return channels, err
+ err := session.Find(&providers)
+ decryptProviders(providers)
+ return providers, err
}
-func getChannel(owner, name string) (*Channel, error) {
- channel := &Channel{Owner: owner, Name: name}
- existed, err := ormer.Engine.Get(channel)
+func getProvider(owner, name string) (*Provider, error) {
+ provider := &Provider{Owner: owner, Name: name}
+ existed, err := ormer.Engine.Get(provider)
if err != nil {
return nil, err
}
if !existed {
return nil, nil
}
- decryptChannel(channel)
- return channel, nil
+ decryptProvider(provider)
+ return provider, nil
}
-func GetChannel(id string) (*Channel, error) {
+func GetProvider(id string) (*Provider, error) {
owner, name := util.GetOwnerAndNameFromId(id)
- return getChannel(owner, name)
+ return getProvider(owner, name)
}
-// GetMaskedChannel returns a copy of the channel with the API key replaced by
+// GetMaskedProvider returns a copy of the provider with the API key replaced by
// ApiKeyMask, so the stored key never reaches the browser.
-func GetMaskedChannel(channel *Channel) *Channel {
- if channel == nil {
+func GetMaskedProvider(provider *Provider) *Provider {
+ if provider == nil {
return nil
}
- masked := *channel
+ masked := *provider
if masked.ApiKey != "" {
masked.ApiKey = ApiKeyMask
}
return &masked
}
-func GetMaskedChannels(channels []*Channel) []*Channel {
- maskedChannels := make([]*Channel, 0, len(channels))
- for _, channel := range channels {
- maskedChannels = append(maskedChannels, GetMaskedChannel(channel))
+func GetMaskedProviders(providers []*Provider) []*Provider {
+ maskedProviders := make([]*Provider, 0, len(providers))
+ for _, provider := range providers {
+ maskedProviders = append(maskedProviders, GetMaskedProvider(provider))
}
- return maskedChannels
+ return maskedProviders
}
-func validateChannel(channel *Channel) error {
- if channel.Type == "" {
- channel.Type = "openai"
+func validateProvider(provider *Provider) error {
+ if provider.Type == "" {
+ provider.Type = "openai"
}
- if channel.Status == "" {
- channel.Status = "enabled"
+ if provider.Status == "" {
+ provider.Status = "enabled"
}
- if channel.Models == nil {
- channel.Models = []string{}
+ if provider.Models == nil {
+ provider.Models = []string{}
}
- if channel.AuthMode == "" {
- channel.AuthMode = ChannelAuthChannel
+ if provider.AuthMode == "" {
+ provider.AuthMode = ProviderAuthProvider
}
- if !containsString(channelTypes, channel.Type) {
- return fmt.Errorf("invalid channel type: %s", channel.Type)
+ if !containsString(providerTypes, provider.Type) {
+ return fmt.Errorf("invalid provider type: %s", provider.Type)
}
- if !containsString(channelStatuses, channel.Status) {
- return fmt.Errorf("invalid channel status: %s", channel.Status)
+ if !containsString(providerStatuses, provider.Status) {
+ return fmt.Errorf("invalid provider status: %s", provider.Status)
}
- if !containsString(channelAuthModes, channel.AuthMode) {
- return fmt.Errorf("invalid channel auth mode: %s", channel.AuthMode)
+ if !containsString(providerAuthModes, provider.AuthMode) {
+ return fmt.Errorf("invalid provider auth mode: %s", provider.AuthMode)
}
- // A channel that forwards the caller's credentials has no use for a stored
+ // A provider that forwards the caller's credentials has no use for a stored
// key, and one left behind would be sent upstream again after a switch back.
- if channel.AuthMode == ChannelAuthClient {
- channel.ApiKey = ""
+ if provider.AuthMode == ProviderAuthClient {
+ provider.ApiKey = ""
}
- if channel.BaseUrl != "" {
- if err := validateBaseUrl(channel.BaseUrl); err != nil {
+ if provider.BaseUrl != "" {
+ if err := validateBaseUrl(provider.BaseUrl); err != nil {
return err
}
}
- if len(channel.Models) > maxChannelModels {
- return fmt.Errorf("too many models: %d, at most %d are allowed", len(channel.Models), maxChannelModels)
+ if len(provider.Models) > maxProviderModels {
+ return fmt.Errorf("too many models: %d, at most %d are allowed", len(provider.Models), maxProviderModels)
}
- for _, model := range channel.Models {
+ for _, model := range provider.Models {
if strings.TrimSpace(model) == "" {
return fmt.Errorf("model name cannot be empty")
}
- if len(model) > maxChannelModelChars {
+ if len(model) > maxProviderModelChars {
return fmt.Errorf("model name is too long: %s", model)
}
}
@@ -335,7 +335,7 @@ func validateBaseUrl(baseUrl string) error {
return nil
}
-// BuildOpenAiUrl joins an OpenAI-compatible endpoint onto a channel base URL.
+// BuildOpenAiUrl joins an OpenAI-compatible endpoint onto a provider base URL.
// The base URL may be bare, already carry the /v1 prefix or already end with
// the endpoint itself; none of those forms are doubled.
func BuildOpenAiUrl(baseUrl string, endpoint string) (string, error) {
@@ -354,7 +354,7 @@ func BuildOpenAiUrl(baseUrl string, endpoint string) (string, error) {
return u.String(), nil
}
-// BuildAnthropicUrl joins an Anthropic endpoint onto a channel base URL. Unlike
+// BuildAnthropicUrl joins an Anthropic endpoint onto a provider base URL. Unlike
// an OpenAI base URL, an Anthropic one is bare and the endpoint carries the /v1
// prefix; a base URL that already has one is not doubled.
func BuildAnthropicUrl(baseUrl string, endpoint string) (string, error) {
@@ -371,9 +371,9 @@ func BuildAnthropicUrl(baseUrl string, endpoint string) (string, error) {
return u.String(), nil
}
-// BuildChannelUrl is the upstream URL a request in the given protocol is sent
+// BuildProviderUrl is the upstream URL a request in the given protocol is sent
// to. The endpoint is the protocol's own, not a shared one.
-func BuildChannelUrl(baseUrl string, protocol string, endpoint string) (string, error) {
+func BuildProviderUrl(baseUrl string, protocol string, endpoint string) (string, error) {
if protocol == ProtocolAnthropic {
return BuildAnthropicUrl(baseUrl, endpoint)
}
@@ -401,80 +401,80 @@ func AppendQuery(rawUrl string, rawQuery string) string {
return u.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) {
+// SetProviderAuth puts the provider's credentials on an upstream request, in the
+// header the provider's protocol authenticates with.
+func SetProviderAuth(header http.Header, provider *Provider) {
// The caller's own credentials are already on the request, and the proxy
// copies them across itself.
- if UsesClientAuth(channel) {
+ if UsesClientAuth(provider) {
return
}
- if ChannelProtocol(channel) == ProtocolAnthropic {
- header.Set("X-Api-Key", channel.ApiKey)
+ if ProviderProtocol(provider) == ProtocolAnthropic {
+ header.Set("X-Api-Key", provider.ApiKey)
return
}
- header.Set("Authorization", "Bearer "+channel.ApiKey)
+ header.Set("Authorization", "Bearer "+provider.ApiKey)
}
-func AddChannel(channel *Channel) (bool, error) {
- if err := validateChannel(channel); err != nil {
+func AddProvider(provider *Provider) (bool, error) {
+ if err := validateProvider(provider); err != nil {
return false, err
}
now := util.GetCurrentTime()
- if channel.CreatedTime == "" {
- channel.CreatedTime = now
+ if provider.CreatedTime == "" {
+ provider.CreatedTime = now
}
- channel.UpdatedTime = now
+ provider.UpdatedTime = now
- if err := encryptApiKey(channel); err != nil {
+ if err := encryptApiKey(provider); err != nil {
return false, err
}
- affected, err := ormer.Engine.Insert(channel)
+ affected, err := ormer.Engine.Insert(provider)
return affected != 0, err
}
-func UpdateChannel(id string, channel *Channel) (bool, error) {
+func UpdateProvider(id string, provider *Provider) (bool, error) {
owner, name := util.GetOwnerAndNameFromId(id)
- if stored, err := getChannel(owner, name); err != nil {
+ if stored, err := getProvider(owner, name); err != nil {
return false, err
} else if stored == nil {
return false, nil
}
- if err := validateChannel(channel); err != nil {
+ if err := validateProvider(provider); err != nil {
return false, err
}
- channel.Owner = owner
- channel.Name = name
- channel.UpdatedTime = util.GetCurrentTime()
+ provider.Owner = owner
+ provider.Name = name
+ provider.UpdatedTime = util.GetCurrentTime()
session := ormer.Engine.ID(core.PK{owner, name})
// The browser only ever sees the mask, so getting it back means the user
// did not touch the field. Any other value (including "") is written, which
// is what makes clearing a key possible.
- if channel.ApiKey == ApiKeyMask {
+ if provider.ApiKey == ApiKeyMask {
session = session.Omit("api_key")
- } else if err := encryptApiKey(channel); err != nil {
+ } else if err := encryptApiKey(provider); err != nil {
return false, err
}
- affected, err := session.AllCols().Update(channel)
+ affected, err := session.AllCols().Update(provider)
if err == nil {
// The edit may be the fix for whatever the proxy last saw, so the
- // channel starts from a clean slate.
- ClearChannelHealth(channel.GetId())
+ // provider starts from a clean slate.
+ ClearProviderHealth(provider.GetId())
}
return affected != 0, err
}
-func DeleteChannel(channel *Channel) (bool, error) {
- affected, err := ormer.Engine.ID(core.PK{channel.Owner, channel.Name}).Delete(&Channel{})
+func DeleteProvider(provider *Provider) (bool, error) {
+ affected, err := ormer.Engine.ID(core.PK{provider.Owner, provider.Name}).Delete(&Provider{})
if err == nil {
- ClearChannelHealth(channel.GetId())
+ ClearProviderHealth(provider.GetId())
}
return affected != 0, err
}
@@ -483,41 +483,41 @@ func DeleteChannel(channel *Channel) (bool, error) {
// aggregator is long, and an error page can be arbitrarily long.
const maxProbeBody = 1 << 20
-// channelProbe is what a read-only GET against a channel's models endpoint
+// providerProbe is what a read-only GET against a provider's models endpoint
// returned. The same probe answers both "is this upstream reachable" and
// "which models does it serve".
-type channelProbe struct {
+type providerProbe struct {
statusCode int
status string
body []byte
}
-func (probe *channelProbe) ok() bool {
+func (probe *providerProbe) ok() bool {
return probe.statusCode >= 200 && probe.statusCode < 300
}
-// probeChannel performs the read-only GET against the channel's models
-// endpoint. The channel is used as given rather than read back from the
-// database, so a channel that is not saved yet can be probed too.
-func probeChannel(channel *Channel) (*channelProbe, error) {
- if !IsChannelTypeSupported(channel) {
- return nil, fmt.Errorf("the %s channel type is not supported", channel.Type)
+// probeProvider performs the read-only GET against the provider's models
+// endpoint. The provider is used as given rather than read back from the
+// database, so a provider that is not saved yet can be probed too.
+func probeProvider(provider *Provider) (*providerProbe, error) {
+ if !IsProviderTypeSupported(provider) {
+ return nil, fmt.Errorf("the %s provider type is not supported", provider.Type)
}
- if channel.BaseUrl == "" {
+ if provider.BaseUrl == "" {
return nil, errors.New("the base URL is empty")
}
- if err := validateBaseUrl(channel.BaseUrl); err != nil {
+ if err := validateBaseUrl(provider.BaseUrl); err != nil {
return nil, err
}
- protocol := ChannelProtocol(channel)
+ protocol := ProviderProtocol(provider)
probeEndpoint := "/models"
if protocol == ProtocolAnthropic {
probeEndpoint = "/v1/models"
}
- probeUrl, err := BuildChannelUrl(channel.BaseUrl, protocol, probeEndpoint)
+ probeUrl, err := BuildProviderUrl(provider.BaseUrl, protocol, probeEndpoint)
if err != nil {
return nil, err
}
@@ -529,8 +529,8 @@ func probeChannel(channel *Channel) (*channelProbe, error) {
if protocol == ProtocolAnthropic {
req.Header.Set("Anthropic-Version", AnthropicVersion)
}
- if channel.ApiKey != "" {
- SetChannelAuth(req.Header, channel)
+ if provider.ApiKey != "" {
+ SetProviderAuth(req.Header, provider)
}
client := &http.Client{
@@ -557,27 +557,27 @@ func probeChannel(channel *Channel) (*channelProbe, error) {
return nil, err
}
- return &channelProbe{statusCode: resp.StatusCode, status: resp.Status, body: body}, nil
+ return &providerProbe{statusCode: resp.StatusCode, status: resp.Status, body: body}, nil
}
-// TestChannelConnectivity performs a read-only probe against the channel's
+// TestProviderConnectivity performs a read-only probe against the provider's
// upstream. It returns whether the probe succeeded, the upstream HTTP status
// code (0 when no response was received) and a human-readable message.
-func TestChannelConnectivity(channel *Channel) (bool, int, string) {
- stored, err := getChannel(channel.Owner, channel.Name)
+func TestProviderConnectivity(provider *Provider) (bool, int, string) {
+ stored, err := getProvider(provider.Owner, provider.Name)
if err != nil {
return false, 0, err.Error()
}
if stored == nil {
- return false, 0, "the channel does not exist"
+ return false, 0, "the provider does not exist"
}
- probe, err := probeChannel(stored)
+ probe, err := probeProvider(stored)
if err != nil {
return false, 0, err.Error()
}
- // A client-auth channel has no key to probe with, so an upstream that
+ // A client-auth provider has no key to probe with, so an upstream that
// rejects the unauthenticated probe has still proven it is reachable.
if UsesClientAuth(stored) && (probe.statusCode == http.StatusUnauthorized || probe.statusCode == http.StatusForbidden) {
return true, probe.statusCode, "reachable, and authenticated with the caller's own credentials"
@@ -586,10 +586,10 @@ func TestChannelConnectivity(channel *Channel) (bool, int, string) {
return probe.ok(), probe.statusCode, probe.status
}
-// FetchChannelModels lists what the channel's upstream reports at its models
+// FetchProviderModels lists what the provider's upstream reports at its models
// endpoint, so the model names do not have to be typed by hand.
-func FetchChannelModels(channel *Channel) ([]string, error) {
- probe, err := probeChannel(channel)
+func FetchProviderModels(provider *Provider) ([]string, error) {
+ probe, err := probeProvider(provider)
if err != nil {
return nil, err
}
@@ -624,7 +624,7 @@ func parseModelList(body []byte) ([]string, error) {
seen := map[string]bool{}
for _, item := range payload.Data {
id := strings.TrimSpace(item.Id)
- if id == "" || len(id) > maxChannelModelChars || seen[id] {
+ if id == "" || len(id) > maxProviderModelChars || seen[id] {
continue
}
seen[id] = true
@@ -647,46 +647,46 @@ func probeDetail(body []byte) string {
return ": " + text
}
-// GetChannelsByModel returns every enabled channel that supports the given
+// GetProvidersByModel returns every enabled provider that supports the given
// model name, ordered by priority (ascending, so the lowest value comes first)
-// so that the caller can fail over from one channel to the next. It queries all
-// channels globally (no owner filter) because /v1/chat/completions is an
+// so that the caller can fail over from one provider to the next. It queries all
+// providers globally (no owner filter) because /v1/chat/completions is an
// unauthenticated public endpoint.
-func GetChannelsByModel(model string) ([]*Channel, error) {
- channels := []*Channel{}
- err := ormer.Engine.Where("status = ?", "enabled").Asc("priority").Find(&channels)
+func GetProvidersByModel(model string) ([]*Provider, error) {
+ providers := []*Provider{}
+ err := ormer.Engine.Where("status = ?", "enabled").Asc("priority").Find(&providers)
if err != nil {
- return nil, fmt.Errorf("channel query failed: %w", err)
+ return nil, fmt.Errorf("provider query failed: %w", err)
}
// 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
+ matchedProviders := []*Provider{}
+ // A provider authenticated with the caller's own credentials cannot know
// which models the account behind them may use, so an empty model list
- // there means "any model". Those channels are tried after the ones that
+ // there means "any model". Those providers are tried after the ones that
// name the model, so a wildcard never takes traffic from an exact match.
- wildcardChannels := []*Channel{}
- for _, channel := range channels {
- if len(channel.Models) == 0 {
- if UsesClientAuth(channel) {
- wildcardChannels = append(wildcardChannels, channel)
+ wildcardProviders := []*Provider{}
+ for _, provider := range providers {
+ if len(provider.Models) == 0 {
+ if UsesClientAuth(provider) {
+ wildcardProviders = append(wildcardProviders, provider)
}
continue
}
- for _, channelModel := range channel.Models {
- if channelModel == model {
- matchedChannels = append(matchedChannels, channel)
+ for _, providerModel := range provider.Models {
+ if providerModel == model {
+ matchedProviders = append(matchedProviders, provider)
break
}
}
}
- matchedChannels = append(matchedChannels, wildcardChannels...)
+ matchedProviders = append(matchedProviders, wildcardProviders...)
- decryptChannels(matchedChannels)
+ decryptProviders(matchedProviders)
- if len(matchedChannels) == 0 {
- return nil, fmt.Errorf("%w: %s", ErrNoChannelAvailable, model)
+ if len(matchedProviders) == 0 {
+ return nil, fmt.Errorf("%w: %s", ErrNoProviderAvailable, model)
}
- return matchedChannels, nil
+ return matchedProviders, nil
}
diff --git a/object/channel_crypto_test.go b/object/provider_crypto_test.go
similarity index 66%
rename from object/channel_crypto_test.go
rename to object/provider_crypto_test.go
index 828a0ab..3ca5561 100644
--- a/object/channel_crypto_test.go
+++ b/object/provider_crypto_test.go
@@ -24,19 +24,19 @@ import (
func readStoredApiKey(t *testing.T, owner, name string) string {
t.Helper()
- results, err := ormer.Engine.Table("channel").
+ results, err := ormer.Engine.Table("provider").
Where("owner = ? and name = ?", owner, name).QueryString()
if err != nil {
t.Fatal(err)
}
if len(results) != 1 {
- t.Fatalf("expected 1 stored channel, got %d", len(results))
+ t.Fatalf("expected 1 stored provider, got %d", len(results))
}
return results[0]["api_key"]
}
-func newTestChannel(name, apiKey string) *Channel {
- return &Channel{
+func newTestProvider(name, apiKey string) *Provider {
+ return &Provider{
Owner: "admin",
Name: name,
Type: "openai",
@@ -46,12 +46,12 @@ func newTestChannel(name, apiKey string) *Channel {
}
}
-func TestChannelApiKeyIsEncryptedAtRest(t *testing.T) {
+func TestProviderApiKeyIsEncryptedAtRest(t *testing.T) {
initSqliteOrmer(t)
setEnv(t, map[string]string{"apiKeyEncryptionKey": "test-encryption-key"})
const plainKey = "sk-1234567890abcdef"
- if _, err := AddChannel(newTestChannel("encrypted", plainKey)); err != nil {
+ if _, err := AddProvider(newTestProvider("encrypted", plainKey)); err != nil {
t.Fatal(err)
}
@@ -63,21 +63,21 @@ func TestChannelApiKeyIsEncryptedAtRest(t *testing.T) {
t.Fatalf("the stored api key is missing the encryption marker: %q", stored)
}
- channel, err := GetChannel("admin/encrypted")
+ provider, err := GetProvider("admin/encrypted")
if err != nil {
t.Fatal(err)
}
- if channel.ApiKey != plainKey {
- t.Fatalf("reading back the channel gave %q, want %q", channel.ApiKey, plainKey)
+ if provider.ApiKey != plainKey {
+ t.Fatalf("reading back the provider gave %q, want %q", provider.ApiKey, plainKey)
}
}
-func TestChannelApiKeyStaysPlaintextWithoutKey(t *testing.T) {
+func TestProviderApiKeyStaysPlaintextWithoutKey(t *testing.T) {
initSqliteOrmer(t)
setEnv(t, map[string]string{"apiKeyEncryptionKey": ""})
const plainKey = "sk-not-encrypted"
- if _, err := AddChannel(newTestChannel("plain", plainKey)); err != nil {
+ if _, err := AddProvider(newTestProvider("plain", plainKey)); err != nil {
t.Fatal(err)
}
@@ -91,7 +91,7 @@ func TestLegacyPlaintextIsUpgradedOnRead(t *testing.T) {
setEnv(t, map[string]string{"apiKeyEncryptionKey": ""})
const plainKey = "sk-legacy-plaintext"
- if _, err := AddChannel(newTestChannel("legacy", plainKey)); err != nil {
+ if _, err := AddProvider(newTestProvider("legacy", plainKey)); err != nil {
t.Fatal(err)
}
if stored := readStoredApiKey(t, "admin", "legacy"); stored != plainKey {
@@ -100,12 +100,12 @@ func TestLegacyPlaintextIsUpgradedOnRead(t *testing.T) {
setEnv(t, map[string]string{"apiKeyEncryptionKey": "test-encryption-key"})
- channel, err := GetChannel("admin/legacy")
+ provider, err := GetProvider("admin/legacy")
if err != nil {
t.Fatal(err)
}
- if channel.ApiKey != plainKey {
- t.Fatalf("a legacy plaintext key should still be readable, got %q", channel.ApiKey)
+ if provider.ApiKey != plainKey {
+ t.Fatalf("a legacy plaintext key should still be readable, got %q", provider.ApiKey)
}
stored := readStoredApiKey(t, "admin", "legacy")
@@ -113,12 +113,12 @@ func TestLegacyPlaintextIsUpgradedOnRead(t *testing.T) {
t.Fatalf("the legacy key should have been re-written as ciphertext, got %q", stored)
}
- channel, err = GetChannel("admin/legacy")
+ provider, err = GetProvider("admin/legacy")
if err != nil {
t.Fatal(err)
}
- if channel.ApiKey != plainKey {
- t.Fatalf("after the upgrade the key read back as %q, want %q", channel.ApiKey, plainKey)
+ if provider.ApiKey != plainKey {
+ t.Fatalf("after the upgrade the key read back as %q, want %q", provider.ApiKey, plainKey)
}
}
@@ -126,20 +126,20 @@ func TestUpgradeOnlyTouchesTheApiKeyColumn(t *testing.T) {
initSqliteOrmer(t)
setEnv(t, map[string]string{"apiKeyEncryptionKey": ""})
- if _, err := AddChannel(newTestChannel("untouched", "sk-legacy")); err != nil {
+ if _, err := AddProvider(newTestProvider("untouched", "sk-legacy")); err != nil {
t.Fatal(err)
}
- before, err := GetChannel("admin/untouched")
+ before, err := GetProvider("admin/untouched")
if err != nil {
t.Fatal(err)
}
setEnv(t, map[string]string{"apiKeyEncryptionKey": "test-encryption-key"})
- if _, err = GetChannel("admin/untouched"); err != nil {
+ if _, err = GetProvider("admin/untouched"); err != nil {
t.Fatal(err)
}
- after, err := GetChannel("admin/untouched")
+ after, err := GetProvider("admin/untouched")
if err != nil {
t.Fatal(err)
}
@@ -154,66 +154,66 @@ func TestUpgradeOnlyTouchesTheApiKeyColumn(t *testing.T) {
}
}
-func TestCiphertextIsBoundToItsChannel(t *testing.T) {
+func TestCiphertextIsBoundToItsProvider(t *testing.T) {
initSqliteOrmer(t)
setEnv(t, map[string]string{"apiKeyEncryptionKey": "test-encryption-key"})
- if _, err := AddChannel(newTestChannel("victim", "sk-victims-key")); err != nil {
+ if _, err := AddProvider(newTestProvider("victim", "sk-victims-key")); err != nil {
t.Fatal(err)
}
- if _, err := AddChannel(newTestChannel("thief", "sk-thiefs-key")); err != nil {
+ if _, err := AddProvider(newTestProvider("thief", "sk-thiefs-key")); err != nil {
t.Fatal(err)
}
victimCipher := readStoredApiKey(t, "admin", "victim")
if _, err := ormer.Engine.ID(core.PK{"admin", "thief"}).
- Cols("api_key").Update(&Channel{ApiKey: victimCipher}); err != nil {
+ Cols("api_key").Update(&Provider{ApiKey: victimCipher}); err != nil {
t.Fatal(err)
}
- thief, err := GetChannel("admin/thief")
+ thief, err := GetProvider("admin/thief")
if err != nil {
t.Fatal(err)
}
if thief.ApiKey == "sk-victims-key" {
- t.Fatal("a ciphertext moved to another channel decrypted, it is not bound to its row")
+ t.Fatal("a ciphertext moved to another provider decrypted, it is not bound to its row")
}
if !util.IsEncrypted(thief.ApiKey) {
t.Fatalf("a value that cannot be decrypted should be left alone, got %q", thief.ApiKey)
}
- victim, err := GetChannel("admin/victim")
+ victim, err := GetProvider("admin/victim")
if err != nil {
t.Fatal(err)
}
if victim.ApiKey != "sk-victims-key" {
- t.Fatalf("the victim channel read back as %q", victim.ApiKey)
+ t.Fatalf("the victim provider read back as %q", victim.ApiKey)
}
}
-func TestUpdateChannelKeepsMaskedApiKey(t *testing.T) {
+func TestUpdateProviderKeepsMaskedApiKey(t *testing.T) {
initSqliteOrmer(t)
setEnv(t, map[string]string{"apiKeyEncryptionKey": "test-encryption-key"})
const plainKey = "sk-keep-me"
- if _, err := AddChannel(newTestChannel("masked", plainKey)); err != nil {
+ if _, err := AddProvider(newTestProvider("masked", plainKey)); err != nil {
t.Fatal(err)
}
- edited := newTestChannel("masked", ApiKeyMask)
+ edited := newTestProvider("masked", ApiKeyMask)
edited.DisplayName = "renamed"
- if _, err := UpdateChannel("admin/masked", edited); err != nil {
+ if _, err := UpdateProvider("admin/masked", edited); err != nil {
t.Fatal(err)
}
- channel, err := GetChannel("admin/masked")
+ provider, err := GetProvider("admin/masked")
if err != nil {
t.Fatal(err)
}
- if channel.ApiKey != plainKey {
- t.Fatalf("the stored key changed on a masked update: got %q, want %q", channel.ApiKey, plainKey)
+ if provider.ApiKey != plainKey {
+ t.Fatalf("the stored key changed on a masked update: got %q, want %q", provider.ApiKey, plainKey)
}
- if channel.DisplayName != "renamed" {
- t.Fatalf("the update did not apply: displayName is %q", channel.DisplayName)
+ if provider.DisplayName != "renamed" {
+ t.Fatalf("the update did not apply: displayName is %q", provider.DisplayName)
}
}
diff --git a/object/provider_health.go b/object/provider_health.go
new file mode 100644
index 0000000..36644ec
--- /dev/null
+++ b/object/provider_health.go
@@ -0,0 +1,167 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/util"
+)
+
+const (
+ // providerFailureThreshold is how many failures in a row take a provider out
+ // of rotation. One failure is not enough: an upstream that answers 500 once
+ // is still the provider the operator picked.
+ providerFailureThreshold = 2
+ providerCooldown = 15 * time.Second
+ providerMaxCooldown = 5 * time.Minute
+)
+
+// ProviderHealth is what the proxy has seen of one provider. It lives in memory
+// only: it describes this process's view of an upstream, not a stored setting.
+type ProviderHealth struct {
+ Provider string `json:"provider"`
+ Healthy bool `json:"healthy"`
+ Successes int64 `json:"successes"`
+ Failures int64 `json:"failures"`
+ Consecutive int `json:"consecutive"`
+ LastError string `json:"lastError"`
+ LastFailure string `json:"lastFailure"`
+ RetryTime string `json:"retryTime"`
+}
+
+type providerHealth struct {
+ successes int64
+ failures int64
+ consecutive int
+ lastError string
+ lastFailure time.Time
+ retryTime time.Time
+}
+
+var (
+ providerHealthMutex sync.Mutex
+ providerHealthMap = map[string]*providerHealth{}
+)
+
+// ReportProviderSuccess closes the breaker: an upstream that answered is back in
+// rotation whatever it did before.
+func ReportProviderSuccess(providerId string) {
+ providerHealthMutex.Lock()
+ defer providerHealthMutex.Unlock()
+
+ health := healthOf(providerId)
+ health.successes++
+ health.consecutive = 0
+ health.retryTime = time.Time{}
+}
+
+// ReportProviderFailure records an attempt that could not be relayed. Once a
+// provider has failed providerFailureThreshold times in a row it is suspended for
+// a window that doubles with every further failure, so a dead upstream stops
+// costing every request the time it takes to time out.
+func ReportProviderFailure(providerId string, reason string) {
+ providerHealthMutex.Lock()
+ defer providerHealthMutex.Unlock()
+
+ health := healthOf(providerId)
+ health.failures++
+ health.consecutive++
+ health.lastError = reason
+ health.lastFailure = time.Now()
+
+ if health.consecutive < providerFailureThreshold {
+ return
+ }
+ cooldown := providerCooldown << (health.consecutive - providerFailureThreshold)
+ if cooldown > providerMaxCooldown || cooldown <= 0 {
+ cooldown = providerMaxCooldown
+ }
+ health.retryTime = time.Now().Add(cooldown)
+}
+
+// IsProviderSuspended reports whether a provider is inside its cooldown window.
+// A suspended provider is tried last rather than dropped: it may be the only one
+// the agent has.
+func IsProviderSuspended(providerId string) bool {
+ providerHealthMutex.Lock()
+ defer providerHealthMutex.Unlock()
+
+ health, ok := providerHealthMap[providerId]
+ return ok && time.Now().Before(health.retryTime)
+}
+
+// ClearProviderHealth forgets what the proxy saw of a provider, which is what an
+// edited provider deserves: its base URL or key may be the thing that was fixed.
+func ClearProviderHealth(providerId string) {
+ providerHealthMutex.Lock()
+ defer providerHealthMutex.Unlock()
+ delete(providerHealthMap, providerId)
+}
+
+// GetProviderHealth lists what is known about every provider used since startup.
+func GetProviderHealth() []*ProviderHealth {
+ providerHealthMutex.Lock()
+ defer providerHealthMutex.Unlock()
+
+ now := time.Now()
+ result := make([]*ProviderHealth, 0, len(providerHealthMap))
+ for id, health := range providerHealthMap {
+ item := &ProviderHealth{
+ Provider: id,
+ Healthy: !now.Before(health.retryTime),
+ Successes: health.successes,
+ Failures: health.failures,
+ Consecutive: health.consecutive,
+ LastError: health.lastError,
+ }
+ if !health.lastFailure.IsZero() {
+ item.LastFailure = util.FormatTime(health.lastFailure)
+ }
+ if now.Before(health.retryTime) {
+ item.RetryTime = util.FormatTime(health.retryTime)
+ }
+ result = append(result, item)
+ }
+
+ sort.Slice(result, func(i, j int) bool { return result[i].Provider < result[j].Provider })
+ return result
+}
+
+// SortProvidersByHealth puts the providers that are inside a cooldown window last,
+// keeping the configured order among the rest.
+func SortProvidersByHealth(providers []*Provider) []*Provider {
+ ready := make([]*Provider, 0, len(providers))
+ suspended := []*Provider{}
+ for _, provider := range providers {
+ if IsProviderSuspended(provider.GetId()) {
+ suspended = append(suspended, provider)
+ continue
+ }
+ ready = append(ready, provider)
+ }
+ return append(ready, suspended...)
+}
+
+func healthOf(providerId string) *providerHealth {
+ health, ok := providerHealthMap[providerId]
+ if !ok {
+ health = &providerHealth{}
+ providerHealthMap[providerId] = health
+ }
+ return health
+}
diff --git a/object/channel_url_test.go b/object/provider_url_test.go
similarity index 74%
rename from object/channel_url_test.go
rename to object/provider_url_test.go
index e202e66..b506bcf 100644
--- a/object/channel_url_test.go
+++ b/object/provider_url_test.go
@@ -19,7 +19,7 @@ import (
"testing"
)
-func TestBuildChannelUrl(t *testing.T) {
+func TestBuildProviderUrl(t *testing.T) {
cases := []struct {
baseUrl string
protocol string
@@ -40,37 +40,37 @@ func TestBuildChannelUrl(t *testing.T) {
}
for _, tc := range cases {
- got, err := BuildChannelUrl(tc.baseUrl, tc.protocol, tc.endpoint)
+ got, err := BuildProviderUrl(tc.baseUrl, tc.protocol, tc.endpoint)
if err != nil {
t.Fatal(err)
}
if got != tc.expected {
- t.Errorf("BuildChannelUrl(%s, %s, %s) = %s, expected %s", tc.baseUrl, tc.protocol, tc.endpoint, got, tc.expected)
+ t.Errorf("BuildProviderUrl(%s, %s, %s) = %s, expected %s", tc.baseUrl, tc.protocol, tc.endpoint, got, tc.expected)
}
}
}
-func TestChannelProtocolAndAuth(t *testing.T) {
- for _, channelType := range []string{"openai", "custom", ""} {
- channel := &Channel{Type: channelType, ApiKey: "sk-test"}
- if got := ChannelProtocol(channel); got != ProtocolOpenAi {
- t.Errorf("ChannelProtocol(%q) = %s, expected %s", channelType, got, ProtocolOpenAi)
+func TestProviderProtocolAndAuth(t *testing.T) {
+ for _, providerType := range []string{"openai", "custom", ""} {
+ provider := &Provider{Type: providerType, ApiKey: "sk-test"}
+ if got := ProviderProtocol(provider); got != ProtocolOpenAi {
+ t.Errorf("ProviderProtocol(%q) = %s, expected %s", providerType, got, ProtocolOpenAi)
}
header := http.Header{}
- SetChannelAuth(header, channel)
+ SetProviderAuth(header, provider)
if header.Get("Authorization") != "Bearer sk-test" || header.Get("X-Api-Key") != "" {
t.Errorf("openai auth header = %v", header)
}
}
- channel := &Channel{Type: "anthropic", ApiKey: "sk-ant-test"}
- if got := ChannelProtocol(channel); got != ProtocolAnthropic {
- t.Errorf("ChannelProtocol(anthropic) = %s", got)
+ provider := &Provider{Type: "anthropic", ApiKey: "sk-ant-test"}
+ if got := ProviderProtocol(provider); got != ProtocolAnthropic {
+ t.Errorf("ProviderProtocol(anthropic) = %s", got)
}
header := http.Header{}
- SetChannelAuth(header, channel)
+ SetProviderAuth(header, provider)
if header.Get("X-Api-Key") != "sk-ant-test" || header.Get("Authorization") != "" {
t.Errorf("anthropic auth header = %v", header)
}
diff --git a/routers/router.go b/routers/router.go
index c9d45ba..3647056 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -37,7 +37,7 @@ func initAPI() {
beego.Router("/api/get-signin-options", &controllers.ApiController{}, "GET:GetSigninOptions")
beego.Router("/api/get-account", &controllers.ApiController{}, "GET:GetAccount")
beego.Router("/api/update-account", &controllers.ApiController{}, "POST:UpdateAccount")
- beego.Router("/api/get-providers", &controllers.ApiController{}, "GET:GetProviders")
+ beego.Router("/api/get-casdoor-providers", &controllers.ApiController{}, "GET:GetCasdoorProviders")
beego.Router("/api/get-gateway-status", &controllers.ApiController{}, "GET:GetGatewayStatus")
beego.Router("/api/get-setting", &controllers.ApiController{}, "GET:GetSetting")
beego.Router("/api/update-setting", &controllers.ApiController{}, "POST:UpdateSetting")
@@ -68,7 +68,7 @@ func initAPI() {
beego.Router("/api/get-agents", &controllers.ApiController{}, "GET:GetAgents")
beego.Router("/api/patch-agent", &controllers.ApiController{}, "POST:PatchAgent")
beego.Router("/api/unpatch-agent", &controllers.ApiController{}, "POST:UnpatchAgent")
- beego.Router("/api/update-agent-channel", &controllers.ApiController{}, "POST:UpdateAgentChannel")
+ beego.Router("/api/update-agent-routing", &controllers.ApiController{}, "POST:UpdateAgentRouting")
beego.Router("/api/get-agent-records", &controllers.ApiController{}, "GET:GetAgentRecords")
beego.Router("/api/get-agent-sessions", &controllers.ApiController{}, "GET:GetAgentSessions")
beego.Router("/api/add-agent-record", &controllers.ApiController{}, "POST:AddAgentRecord")
@@ -101,15 +101,15 @@ func initAPI() {
beego.Router("/api/update-rule", &controllers.ApiController{}, "POST:UpdateRule")
beego.Router("/api/delete-rule", &controllers.ApiController{}, "POST:DeleteRule")
- // Channel routes for LLM gateway milestone 1.1.
- beego.Router("/api/get-channels", &controllers.ApiController{}, "GET:GetChannels")
- beego.Router("/api/get-channel", &controllers.ApiController{}, "GET:GetChannel")
- beego.Router("/api/add-channel", &controllers.ApiController{}, "POST:AddChannel")
- beego.Router("/api/update-channel", &controllers.ApiController{}, "POST:UpdateChannel")
- beego.Router("/api/delete-channel", &controllers.ApiController{}, "POST:DeleteChannel")
- beego.Router("/api/get-channel-models", &controllers.ApiController{}, "POST:GetChannelModels")
- beego.Router("/api/test-channel", &controllers.ApiController{}, "POST:TestChannel")
- beego.Router("/api/get-channel-health", &controllers.ApiController{}, "GET:GetChannelHealth")
+ // Provider routes for LLM gateway milestone 1.1.
+ beego.Router("/api/get-providers", &controllers.ApiController{}, "GET:GetProviders")
+ beego.Router("/api/get-provider", &controllers.ApiController{}, "GET:GetProvider")
+ beego.Router("/api/add-provider", &controllers.ApiController{}, "POST:AddProvider")
+ beego.Router("/api/update-provider", &controllers.ApiController{}, "POST:UpdateProvider")
+ beego.Router("/api/delete-provider", &controllers.ApiController{}, "POST:DeleteProvider")
+ beego.Router("/api/get-provider-models", &controllers.ApiController{}, "POST:GetProviderModels")
+ beego.Router("/api/test-provider", &controllers.ApiController{}, "POST:TestProvider")
+ beego.Router("/api/get-provider-health", &controllers.ApiController{}, "GET:GetProviderHealth")
beego.Router("/api/get-llm-records", &controllers.ApiController{}, "GET:GetLlmRecords")
beego.Router("/api/get-llm-record", &controllers.ApiController{}, "GET:GetLlmRecord")
diff --git a/util/crypto_test.go b/util/crypto_test.go
index fe63a3c..07665e5 100644
--- a/util/crypto_test.go
+++ b/util/crypto_test.go
@@ -22,7 +22,7 @@ import (
"testing"
)
-const testAad = "admin/channel1"
+const testAad = "admin/provider1"
func TestEncryptDecryptRoundTrip(t *testing.T) {
secret := "test-secret"
@@ -102,11 +102,11 @@ func TestDecryptEncryptedWithoutKeyFails(t *testing.T) {
}
func TestDecryptWrongAadFails(t *testing.T) {
- ciphertext, _ := EncryptWithKey("key", "secret-value", "admin/channel1")
- if _, err := DecryptWithKey("key", ciphertext, "admin/channel2"); err == nil {
+ ciphertext, _ := EncryptWithKey("key", "secret-value", "admin/provider1")
+ if _, err := DecryptWithKey("key", ciphertext, "admin/provider2"); err == nil {
t.Fatal("decrypting with a different aad should fail")
}
- if got, err := DecryptWithKey("key", ciphertext, "admin/channel1"); err != nil || got != "secret-value" {
+ if got, err := DecryptWithKey("key", ciphertext, "admin/provider1"); err != nil || got != "secret-value" {
t.Fatalf("decrypting with the original aad failed: got %q, err %v", got, err)
}
}
@@ -159,7 +159,7 @@ func TestNeedsReEncryption(t *testing.T) {
}
}
-// The api_key column in object.Channel is sized against this growth.
+// The api_key column in object.Provider is sized against this growth.
func TestCiphertextFitsColumn(t *testing.T) {
const maxKeyChars, columnWidth = 500, 1000
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 60e8f2d..ac21834 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -36,8 +36,8 @@ import AgentsPage from "@/pages/AgentsPage";
import AuthCallback from "@/pages/AuthCallback";
import CertEditPage from "@/pages/CertEditPage";
import CertListPage from "@/pages/CertListPage";
-import ChannelEditPage from "@/pages/ChannelEditPage";
-import ChannelListPage from "@/pages/ChannelListPage";
+import ProviderEditPage from "@/pages/ProviderEditPage";
+import ProviderListPage from "@/pages/ProviderListPage";
import LlmRecordsPage from "@/pages/LlmRecordsPage";
import NodeEditPage from "@/pages/NodeEditPage";
import NodeListPage from "@/pages/NodeListPage";
@@ -287,10 +287,10 @@ export default function App() {
<Route path="/records/:owner/:id" element={requireSignin(() => <RecordEditPage />)} />
<Route path="/rules" element={requireSignin(user => <RuleListPage account={user} />)} />
<Route path="/rules/:owner/:ruleName" element={requireSignin(() => <RuleEditPage />)} />
- <Route path="/channels" element={requireSignin(user => <ChannelListPage account={user} />)} />
+ <Route path="/providers" element={requireSignin(user => <ProviderListPage account={user} />)} />
<Route
- path="/channels/:owner/:channelName"
- element={requireSignin(() => <ChannelEditPage />)}
+ path="/providers/:owner/:providerName"
+ element={requireSignin(() => <ProviderEditPage />)}
/>
<Route
path="/llm-records"
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 178c6e2..44a4d49 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -13,7 +13,7 @@
// limitations under the License.
import {query, request} from "@/backend/request";
-import type {Agent, AgentProvider, AgentProviderFile, AgentRecord, AgentSession} from "@/types";
+import type {Agent, AgentProviderConfig, AgentProviderFile, AgentRecord, AgentSession} from "@/types";
export interface PatchTarget {
agentId: string;
@@ -35,13 +35,13 @@ export function unpatchAgent(target: PatchTarget) {
}
export interface AgentRouting {
- channel: string;
+ provider: string;
fallbacks: string[];
mode: string;
}
-export function updateAgentChannel(agentId: string, routing: AgentRouting) {
- return request("/api/update-agent-channel", "POST", {agentId: agentId, ...routing});
+export function updateAgentRouting(agentId: string, routing: AgentRouting) {
+ return request("/api/update-agent-routing", "POST", {agentId: agentId, ...routing});
}
/** What a switch would write, rendered without touching a file. */
@@ -50,11 +50,11 @@ export function planAgentProvider(target: PatchTarget) {
}
export function applyAgentProvider(target: PatchTarget) {
- return request<AgentProvider>("/api/apply-agent-provider", "POST", target);
+ return request<AgentProviderConfig>("/api/apply-agent-provider", "POST", target);
}
export function restoreAgentProvider(target: PatchTarget) {
- return request<AgentProvider>("/api/restore-agent-provider", "POST", target);
+ return request<AgentProviderConfig>("/api/restore-agent-provider", "POST", target);
}
export function getAgentRecords(agent = "", eventType = "", outcome = "", session = "", limit = 200) {
diff --git a/web/src/backend/ChannelBackend.ts b/web/src/backend/ChannelBackend.ts
deleted file mode 100644
index ce8e288..0000000
--- a/web/src/backend/ChannelBackend.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {itemId, query, request} from "@/backend/request";
-import type {Channel, ChannelHealth, ChannelTestResult} from "@/types";
-
-export function getChannels(
- owner: string,
- page: string | number = "",
- pageSize: string | number = "",
- sortField = "",
- sortOrder = "",
-) {
- return request<Channel[], number>(
- `/api/get-channels${query({
- owner: owner,
- p: page,
- pageSize: pageSize,
- sortField: sortField,
- sortOrder: sortOrder,
- })}`,
- );
-}
-
-export function getChannel(owner: string, name: string) {
- return request<Channel>(`/api/get-channel${query({id: itemId(owner, name)})}`);
-}
-
-export function addChannel(channel: Channel) {
- return request("/api/add-channel", "POST", channel);
-}
-
-export function updateChannel(owner: string, name: string, channel: Channel) {
- return request(`/api/update-channel${query({id: itemId(owner, name)})}`, "POST", channel);
-}
-
-export function deleteChannel(channel: Channel) {
- return request("/api/delete-channel", "POST", channel);
-}
-
-/** The models the channel's upstream reports. The whole channel is posted, not
- * an id: the new-channel form has nothing saved to look up yet. */
-export function getChannelModels(channel: Channel) {
- return request<string[]>("/api/get-channel-models", "POST", channel);
-}
-
-export function testChannel(owner: string, name: string) {
- return request<ChannelTestResult>("/api/test-channel", "POST", {owner: owner, name: name});
-}
-
-/** What the proxy has seen of each channel, which is what says why a request
- * went to a fallback rather than to the bound channel. */
-export function getChannelHealth() {
- return request<ChannelHealth[]>("/api/get-channel-health");
-}
diff --git a/web/src/backend/LlmRecordBackend.ts b/web/src/backend/LlmRecordBackend.ts
index 31a6304..f5a173f 100644
--- a/web/src/backend/LlmRecordBackend.ts
+++ b/web/src/backend/LlmRecordBackend.ts
@@ -18,7 +18,7 @@ import type {LlmPrice, LlmRecord, LlmRecordStats, LlmRecordStatus} from "@/types
export interface LlmRecordFilter {
model?: string;
- channel?: string;
+ provider?: string;
agent?: string;
clientIp?: string;
outcome?: string;
diff --git a/web/src/backend/MiscBackend.ts b/web/src/backend/MiscBackend.ts
index 63370de..b5fd7bd 100644
--- a/web/src/backend/MiscBackend.ts
+++ b/web/src/backend/MiscBackend.ts
@@ -13,10 +13,10 @@
// limitations under the License.
import {query, request} from "@/backend/request";
-import type {Application, GatewayStatus, MetricPoint, Provider} from "@/types";
+import type {Application, CasdoorProvider, GatewayStatus, MetricPoint} from "@/types";
-export function getProviders() {
- return request<Provider[]>("/api/get-providers");
+export function getCasdoorProviders() {
+ return request<CasdoorProvider[]>("/api/get-casdoor-providers");
}
export function getApplications(owner: string) {
diff --git a/web/src/backend/ProviderBackend.ts b/web/src/backend/ProviderBackend.ts
new file mode 100644
index 0000000..4e40bbd
--- /dev/null
+++ b/web/src/backend/ProviderBackend.ts
@@ -0,0 +1,66 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {itemId, query, request} from "@/backend/request";
+import type {Provider, ProviderHealth, ProviderTestResult} from "@/types";
+
+export function getProviders(
+ owner: string,
+ page: string | number = "",
+ pageSize: string | number = "",
+ sortField = "",
+ sortOrder = "",
+) {
+ return request<Provider[], number>(
+ `/api/get-providers${query({
+ owner: owner,
+ p: page,
+ pageSize: pageSize,
+ sortField: sortField,
+ sortOrder: sortOrder,
+ })}`,
+ );
+}
+
+export function getProvider(owner: string, name: string) {
+ return request<Provider>(`/api/get-provider${query({id: itemId(owner, name)})}`);
+}
+
+export function addProvider(provider: Provider) {
+ return request("/api/add-provider", "POST", provider);
+}
+
+export function updateProvider(owner: string, name: string, provider: Provider) {
+ return request(`/api/update-provider${query({id: itemId(owner, name)})}`, "POST", provider);
+}
+
+export function deleteProvider(provider: Provider) {
+ return request("/api/delete-provider", "POST", provider);
+}
+
+/** The models the provider's upstream reports. The whole provider is posted, not
+ * an id: the new-provider form has nothing saved to look up yet. */
+export function getProviderModels(provider: Provider) {
+ return request<string[]>("/api/get-provider-models", "POST", provider);
+}
+
+export function testProvider(owner: string, name: string) {
+ return request<ProviderTestResult>("/api/test-provider", "POST", {owner: owner, name: name});
+}
+
+/** What the proxy has seen of each provider, which is what says why a request
+ * went to a fallback rather than to the bound provider. */
+export function getProviderHealth() {
+ return request<ProviderHealth[]>("/api/get-provider-health");
+}
diff --git a/web/src/components/EnvSnippet.tsx b/web/src/components/EnvSnippet.tsx
index c15ca6b..c215b96 100644
--- a/web/src/components/EnvSnippet.tsx
+++ b/web/src/components/EnvSnippet.tsx
@@ -21,7 +21,7 @@ import * as Setting from "@/Setting";
import {Button} from "@/components/ui/button";
import {SimpleTooltip} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
-import {envSnippet, shells, type Shell} from "@/lib/channels";
+import {envSnippet, shells, type Shell} from "@/lib/providers";
/**
* The environment variables that point a client at one Gateway endpoint, ready
diff --git a/web/src/components/ProviderCard.tsx b/web/src/components/ProviderCard.tsx
index f96f26b..8f57b89 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -41,15 +41,15 @@ import {
} from "@/components/ui/select";
import {Switch} from "@/components/ui/switch";
import {agentProxyBaseUrl, agentSetupNoteKey, directMode, gatewayMode} from "@/lib/agents";
-import {channelProtocol, shellForPath, usesClientAuth} from "@/lib/channels";
+import {providerProtocol, shellForPath, usesClientAuth} from "@/lib/providers";
import {cn} from "@/lib/utils";
-import type {Agent, AgentProviderFile, Channel, ChannelHealth} from "@/types";
+import type {Agent, AgentProviderFile, Provider, ProviderHealth} from "@/types";
/** Radix rejects an empty item value, so "unbound" needs a stand-in. */
-const noChannel = "-";
+const noProvider = "-";
-function channelIdOf(channel: Channel) {
- return `${channel.owner}/${channel.name}`;
+function providerIdOf(provider: Provider) {
+ return `${provider.owner}/${provider.name}`;
}
/** The file preview, loaded when the dialog opens rather than on every render. */
@@ -117,8 +117,8 @@ function PreviewDialog({
);
}
-/** One channel of the chain, with what the proxy last saw of it. */
-function ChannelChip({
+/** One provider of the chain, with what the proxy last saw of it. */
+function ProviderChip({
label,
active,
disabled,
@@ -128,7 +128,7 @@ function ChannelChip({
label: string;
active: boolean;
disabled?: boolean;
- health?: ChannelHealth;
+ health?: ProviderHealth;
onClick?: () => void;
}) {
return (
@@ -157,30 +157,30 @@ function ChannelChip({
*/
export function ProviderCard({
agent,
- channels,
+ providers,
health,
busy,
onRouting,
onWrite,
}: {
agent: Agent;
- channels: Channel[];
- health: ChannelHealth[];
+ providers: Provider[];
+ health: ProviderHealth[];
busy: boolean;
onRouting: (routing: Partial<AgentBackend.AgentRouting>) => void;
onWrite: (restore: boolean) => void;
}) {
const [preview, setPreview] = React.useState(false);
- const bound = channels.find(channel => channelIdOf(channel) === agent.channel);
+ const bound = providers.find(provider => providerIdOf(provider) === agent.provider);
const mode = agent.mode || gatewayMode;
const fallbacks = agent.fallbacks ?? [];
// An older backend does not report the provider state at all, and the card
// still has to render for the parts that do not depend on it.
- const provider = agent.provider ?? {
+ const providerConfig = agent.providerConfig ?? {
supported: false,
applied: false,
- channel: "",
+ provider: "",
mode: "",
baseUrl: "",
time: "",
@@ -188,15 +188,15 @@ export function ProviderCard({
detail: "",
};
const noteKey = agentSetupNoteKey(agent.agentId);
- const healthOf = (id: string) => health.find(item => item.channel === id);
- const boundHealth = healthOf(agent.channel);
+ const healthOf = (id: string) => health.find(item => item.provider === id);
+ const boundHealth = healthOf(agent.provider);
- // A channel can only take over for one that speaks the same wire format: the
+ // A provider can only take over for one that speaks the same wire format: the
// proxy relays the request as it arrived.
- const candidates = channels.filter(
- channel =>
- channelIdOf(channel) !== agent.channel &&
- (bound === undefined || channelProtocol(channel.type) === channelProtocol(bound.type)),
+ const candidates = providers.filter(
+ provider =>
+ providerIdOf(provider) !== agent.provider &&
+ (bound === undefined || providerProtocol(provider.type) === providerProtocol(bound.type)),
);
const toggleFallback = (id: string) => {
@@ -210,26 +210,26 @@ export function ProviderCard({
return (
<Card>
<CardHeader className="p-4 pb-2">
- <CardTitle className="text-base">{i18next.t("agent:Channel")}</CardTitle>
+ <CardTitle className="text-base">{i18next.t("agent:Provider")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
<Select
- value={agent.channel === "" ? noChannel : agent.channel}
+ value={agent.provider === "" ? noProvider : agent.provider}
disabled={busy}
- onValueChange={value => onRouting({channel: value === noChannel ? "" : value, fallbacks: []})}
+ onValueChange={value => onRouting({provider: value === noProvider ? "" : value, fallbacks: []})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
- <SelectItem value={noChannel}>
- <span className="text-muted-foreground">{i18next.t("agent:No channel")}</span>
+ <SelectItem value={noProvider}>
+ <span className="text-muted-foreground">{i18next.t("agent:No provider")}</span>
</SelectItem>
- {channels.map(channel => (
- <SelectItem key={channelIdOf(channel)} value={channelIdOf(channel)}>
- {channel.displayName || channel.name}
+ {providers.map(provider => (
+ <SelectItem key={providerIdOf(provider)} value={providerIdOf(provider)}>
+ {provider.displayName || provider.name}
{/* The type is the wire format, which has to match the one the agent speaks. */}
- <span className="ml-2 text-xs text-muted-foreground">{channel.type}</span>
+ <span className="ml-2 text-xs text-muted-foreground">{provider.type}</span>
</SelectItem>
))}
</SelectContent>
@@ -237,30 +237,30 @@ export function ProviderCard({
{boundHealth && !boundHealth.healthy ? (
<p className="text-sm text-warning">
- {`${i18next.t("agent:Bound channel cooling down")}: ${boundHealth.lastError}`}
+ {`${i18next.t("agent:Bound provider cooling down")}: ${boundHealth.lastError}`}
</p>
) : null}
- {agent.channel === "" ? (
- <p className="text-sm text-muted-foreground">{i18next.t("agent:Channel hint")}</p>
+ {agent.provider === "" ? (
+ <p className="text-sm text-muted-foreground">{i18next.t("agent:Provider hint")}</p>
) : bound === undefined ? null : (
<>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{i18next.t("agent:Fallback hint")}</p>
{candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">
- {i18next.t("agent:No other channel speaks this API")}
+ {i18next.t("agent:No other provider speaks this API")}
</p>
) : (
<div className="flex flex-wrap gap-2">
- {candidates.map(channel => (
- <ChannelChip
- key={channelIdOf(channel)}
- label={channel.displayName || channel.name}
- active={fallbacks.includes(channelIdOf(channel))}
+ {candidates.map(provider => (
+ <ProviderChip
+ key={providerIdOf(provider)}
+ label={provider.displayName || provider.name}
+ active={fallbacks.includes(providerIdOf(provider))}
disabled={busy}
- health={healthOf(channelIdOf(channel))}
- onClick={() => toggleFallback(channelIdOf(channel))}
+ health={healthOf(providerIdOf(provider))}
+ onClick={() => toggleFallback(providerIdOf(provider))}
/>
))}
</div>
@@ -286,24 +286,24 @@ export function ProviderCard({
</span>
</label>
- {provider.supported ? (
+ {providerConfig.supported ? (
<div className="space-y-2 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<FileCog className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{i18next.t("agent:Agent configuration")}
</span>
- {provider.applied ? (
+ {providerConfig.applied ? (
<Badge variant="success">{i18next.t("agent:Written by Gateway")}</Badge>
) : (
<Badge variant="secondary">{i18next.t("agent:Not written")}</Badge>
)}
</div>
- {provider.detail ? (
- <p className="text-sm text-muted-foreground">{provider.detail}</p>
+ {providerConfig.detail ? (
+ <p className="text-sm text-muted-foreground">{providerConfig.detail}</p>
) : null}
- {(provider.files ?? []).map(path => (
+ {(providerConfig.files ?? []).map(path => (
<code key={path} className="block truncate text-xs">
{path}
</code>
@@ -317,7 +317,7 @@ export function ProviderCard({
onConfirm={() => onWrite(false)}
>
<Button disabled={busy}>
- {provider.applied
+ {providerConfig.applied
? i18next.t("agent:Rewrite configuration")
: i18next.t("agent:Write configuration")}
</Button>
@@ -327,7 +327,7 @@ export function ProviderCard({
{i18next.t("agent:Preview")}
</Button>
- {provider.applied || provider.channel !== "" ? (
+ {providerConfig.applied || providerConfig.provider !== "" ? (
<ConfirmDialog
title={i18next.t("agent:Restore the configuration of {agent}?").replace("{agent}", agent.name)}
description={i18next.t("agent:Restore confirm hint")}
@@ -347,7 +347,7 @@ export function ProviderCard({
<>
<p className="text-sm text-muted-foreground">{i18next.t("agent:Base URL hint")}</p>
<EnvSnippet
- protocol={channelProtocol(bound.type)}
+ protocol={providerProtocol(bound.type)}
baseUrl={agentProxyBaseUrl(agent.agentId)}
defaultShell={shellForPath(agent.path)}
includeToken={!usesClientAuth(bound)}
diff --git a/web/src/components/ChannelModelsField.tsx b/web/src/components/ProviderModelsField.tsx
similarity index 77%
rename from web/src/components/ChannelModelsField.tsx
rename to web/src/components/ProviderModelsField.tsx
index 2c9135a..ed12b57 100644
--- a/web/src/components/ChannelModelsField.tsx
+++ b/web/src/components/ProviderModelsField.tsx
@@ -16,28 +16,28 @@ import * as React from "react";
import {Check, RefreshCw, X} from "lucide-react";
import i18next from "i18next";
-import * as ChannelBackend from "@/backend/ChannelBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
import {Field} from "@/components/shared/form-dialog";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {TagsInput} from "@/components/ui/tags-input";
-import {modelPresets, modelsPlaceholder, usesClientAuth} from "@/lib/channels";
-import type {Channel} from "@/types";
+import {modelPresets, modelsPlaceholder, usesClientAuth} from "@/lib/providers";
+import type {Provider} from "@/types";
/**
- * The Models field of both channel forms: the names can be typed, or asked of
+ * The Models field of both provider forms: the names can be typed, or asked of
* the upstream itself, which every OpenAI- and Anthropic-compatible API answers
* at its models endpoint. What comes back is offered as chips to pick from
* rather than written straight into the field: an aggregator lists hundreds of
- * models, most of which this channel is not meant to serve.
+ * models, most of which this provider is not meant to serve.
*/
-export function ChannelModelsField({
- channel,
+export function ProviderModelsField({
+ provider,
hint,
onChange,
}: {
- channel: Channel;
+ provider: Provider;
hint?: React.ReactNode;
onChange: (models: string[]) => void;
}) {
@@ -48,32 +48,32 @@ export function ChannelModelsField({
// the form points somewhere else.
React.useEffect(() => {
setFetched(null);
- }, [channel.baseUrl, channel.type]);
+ }, [provider.baseUrl, provider.type]);
- const models = channel.models ?? [];
- // A channel that forwards the caller's login sends no key upstream, so there
+ const models = provider.models ?? [];
+ // A provider that forwards the caller's login sends no key upstream, so there
// is nothing to list models with, and an empty list already means "any model".
- const canFetch = !usesClientAuth(channel) && channel.baseUrl !== "";
+ const canFetch = !usesClientAuth(provider) && provider.baseUrl !== "";
const fetchModels = () => {
setFetching(true);
- ChannelBackend.getChannelModels(channel)
+ ProviderBackend.getProviderModels(provider)
.then(res => {
setFetching(false);
if (res.status === "error") {
- Setting.showMessage("error", `${i18next.t("channel:Failed to fetch models")}: ${res.msg}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to fetch models")}: ${res.msg}`);
return;
}
const list = res.data ?? [];
setFetched(list);
Setting.showMessage(
"success",
- i18next.t("channel:Fetched {count} models").replace("{count}", `${list.length}`),
+ i18next.t("provider:Fetched {count} models").replace("{count}", `${list.length}`),
);
})
.catch(error => {
setFetching(false);
- Setting.showMessage("error", `${i18next.t("channel:Failed to fetch models")}: ${error}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to fetch models")}: ${error}`);
});
};
@@ -94,11 +94,11 @@ export function ChannelModelsField({
<Field
label={
<span className="flex items-center gap-2">
- {i18next.t("channel:Models")}
+ {i18next.t("provider:Models")}
{canFetch ? (
<Button type="button" size="xs" variant="outline" loading={fetching} onClick={fetchModels}>
<RefreshCw />
- {i18next.t("channel:Fetch models")}
+ {i18next.t("provider:Fetch models")}
</Button>
) : null}
</span>
@@ -107,22 +107,22 @@ export function ChannelModelsField({
>
<TagsInput
value={models}
- placeholder={modelsPlaceholder(channel.type)}
- suggestions={fetched ?? modelPresets(channel.type)}
+ placeholder={modelsPlaceholder(provider.type)}
+ suggestions={fetched ?? modelPresets(provider.type)}
onChange={onChange}
/>
{fetched === null ? null : (
<div className="grid gap-2 rounded-md border p-2">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
- {i18next.t("channel:Models from upstream")} ({fetched.length})
+ {i18next.t("provider:Models from upstream")} ({fetched.length})
</span>
<div className="flex items-center gap-1">
<Button type="button" size="xs" variant="ghost" onClick={selectAll}>
- {i18next.t("channel:Select all")}
+ {i18next.t("provider:Select all")}
</Button>
<Button type="button" size="xs" variant="ghost" onClick={clearFetched}>
- {i18next.t("channel:Clear")}
+ {i18next.t("provider:Clear")}
</Button>
<Button
type="button"
diff --git a/web/src/components/ChannelSourcePicker.tsx b/web/src/components/ProviderSourcePicker.tsx
similarity index 74%
rename from web/src/components/ChannelSourcePicker.tsx
rename to web/src/components/ProviderSourcePicker.tsx
index 54e442c..8556d03 100644
--- a/web/src/components/ChannelSourcePicker.tsx
+++ b/web/src/components/ProviderSourcePicker.tsx
@@ -15,34 +15,34 @@
import {KeyRound, LogIn, Settings2} from "lucide-react";
import i18next from "i18next";
-import {channelSources, customSource, subscriptionSource, type ChannelSource} from "@/lib/channels";
+import {providerSources, customSource, subscriptionSource, type ProviderSource} from "@/lib/providers";
/**
* The title and the line under it. A vendor card is named after the vendor and
* says where its requests go; the two cards that are not a vendor say what they
* are instead, because their base URL would not.
*/
-export function sourceTitle(source: ChannelSource) {
+export function sourceTitle(source: ProviderSource) {
if (source.key === subscriptionSource) {
- return i18next.t("channel:Claude subscription");
+ return i18next.t("provider:Claude subscription");
}
if (source.key === customSource) {
- return i18next.t("channel:Custom vendor");
+ return i18next.t("provider:Custom vendor");
}
return source.label;
}
-function sourceDetail(source: ChannelSource) {
+function sourceDetail(source: ProviderSource) {
if (source.key === subscriptionSource) {
- return i18next.t("channel:Claude subscription detail");
+ return i18next.t("provider:Claude subscription detail");
}
if (source.key === customSource) {
- return i18next.t("channel:Custom vendor detail");
+ return i18next.t("provider:Custom vendor detail");
}
- return source.channel.baseUrl ?? "";
+ return source.provider.baseUrl ?? "";
}
-function sourceIcon(source: ChannelSource) {
+function sourceIcon(source: ProviderSource) {
if (source.key === subscriptionSource) {
return LogIn;
}
@@ -53,14 +53,14 @@ function sourceIcon(source: ChannelSource) {
}
/**
- * The first step of creating a channel: where its credentials come from. Picking
+ * The first step of creating a provider: where its credentials come from. Picking
* a card fills in the type, base URL, models and authentication mode, so a
* subscription needs nothing typed and a vendor needs only its key.
*/
-export function ChannelSourcePicker({onPick}: {onPick: (source: ChannelSource) => void}) {
+export function ProviderSourcePicker({onPick}: {onPick: (source: ProviderSource) => void}) {
return (
<div className="grid gap-3 sm:grid-cols-2">
- {channelSources.map(source => {
+ {providerSources.map(source => {
const Icon = sourceIcon(source);
return (
<button
diff --git a/web/src/components/shared/status-badge.tsx b/web/src/components/shared/status-badge.tsx
index 1df9ff1..5a9ddf3 100644
--- a/web/src/components/shared/status-badge.tsx
+++ b/web/src/components/shared/status-badge.tsx
@@ -58,7 +58,7 @@ export function ColorTag({
);
}
-// States that recur across agents, channels, sites and certificates. A page with
+// States that recur across agents, providers, sites and certificates. A page with
// a genuinely local vocabulary still passes its own map via `variants`.
const defaultStatusVariants: Record<string, BadgeVariant> = {
Active: "success",
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index db9a4cd..d88e59b 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -39,7 +39,7 @@ export function agentKey(agent: Pick<Agent, "owner" | "path">) {
}
/**
- * The base URL an agent is pointed at to reach its own channel. One URL serves
+ * The base URL an agent is pointed at to reach its own provider. One URL serves
* both wire formats: an OpenAI client appends /chat/completions to it, an
* Anthropic one appends /v1/messages.
*/
@@ -144,31 +144,31 @@ export function useAgents(enabled = true) {
/**
* setRouting saves where an agent's requests go. Only the changed part is
* passed in; the rest of the routing is carried over from the agent so a
- * fallback list is not dropped by a channel change.
+ * fallback list is not dropped by a provider change.
*/
const setRouting = React.useCallback(
(agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
const next: AgentBackend.AgentRouting = {
- channel: routing.channel ?? agent.channel,
+ provider: routing.provider ?? agent.provider,
fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
mode: routing.mode ?? agent.mode ?? gatewayMode,
};
setBusyKey(agentKey(agent));
- AgentBackend.updateAgentChannel(agent.agentId, next)
+ AgentBackend.updateAgentRouting(agent.agentId, next)
.then(res => {
if (res.status === "ok") {
Setting.showMessage(
"success",
- next.channel === ""
- ? i18next.t("agent:Channel cleared")
- : `${i18next.t("agent:Channel saved")}: ${next.channel}`,
+ next.provider === ""
+ ? i18next.t("agent:Provider cleared")
+ : `${i18next.t("agent:Provider saved")}: ${next.provider}`,
);
scan();
} else {
Setting.showMessage(
"error",
- res.msg || i18next.t("agent:Failed to update agent channel"),
+ res.msg || i18next.t("agent:Failed to update agent provider"),
);
// The routing itself may already be stored: only writing the agent's
// own configuration file failed, and the page has to show that.
@@ -182,7 +182,7 @@ export function useAgents(enabled = true) {
);
/**
- * writeProvider writes the bound channel into the agent's own config file, or
+ * writeProvider writes the bound provider into the agent's own config file, or
* puts back what the file held before Gateway first wrote it.
*/
const writeProvider = React.useCallback(
diff --git a/web/src/lib/channels.ts b/web/src/lib/providers.ts
similarity index 74%
rename from web/src/lib/channels.ts
rename to web/src/lib/providers.ts
index ed9788b..fde0d32 100644
--- a/web/src/lib/channels.ts
+++ b/web/src/lib/providers.ts
@@ -13,20 +13,20 @@
// limitations under the License.
import * as Setting from "@/Setting";
-import type {Channel} from "@/types";
+import type {Provider} from "@/types";
-/** Mirrors object.ChannelProtocol on the server. */
-export function channelProtocol(type: string) {
+/** Mirrors object.ProviderProtocol on the server. */
+export function providerProtocol(type: string) {
return type === "anthropic" ? "anthropic" : "openai";
}
-/** Mirrors object.ChannelAuthChannel and object.ChannelAuthClient on the server. */
-export const authChannel = "channel";
+/** Mirrors object.ProviderAuthProvider and object.ProviderAuthClient on the server. */
+export const authProvider = "provider";
export const authClient = "client";
/** Mirrors object.UsesClientAuth: whose credentials reach the upstream. */
-export function usesClientAuth(channel: {authMode?: string} | undefined) {
- return channel?.authMode === authClient;
+export function usesClientAuth(provider: {authMode?: string} | undefined) {
+ return provider?.authMode === authClient;
}
/** The env vars a client of one wire format reads its endpoint and key from. */
@@ -37,7 +37,7 @@ const protocolEnv: Record<string, {baseUrl: string; token: string}> = {
/**
* A stand-in for the key a client refuses to start without. Gateway authenticates
- * upstream with the channel's own key, so this value is never used for anything.
+ * upstream with the provider's own key, so this value is never used for anything.
*/
const placeholderToken = "casbin-gateway";
@@ -46,7 +46,7 @@ export type Shell = (typeof shells)[number];
/**
* 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
+ * provider 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) {
@@ -83,15 +83,15 @@ export function localShell(): Shell {
return navigator.userAgent.includes("Windows") ? "PowerShell" : "bash";
}
-/** A vendor the channel forms can fill themselves in from. */
-export interface ChannelPreset {
+/** A vendor the provider forms can fill themselves in from. */
+export interface ProviderPreset {
label: string;
type: string;
baseUrl: string;
models: string[];
}
-export const channelPresets: ChannelPreset[] = [
+export const providerPresets: ProviderPreset[] = [
{
label: "OpenAI",
type: "openai",
@@ -124,14 +124,14 @@ export const channelPresets: ChannelPreset[] = [
},
];
-/** Where a new channel gets its credentials: one card of the picker. */
-export interface ChannelSource {
+/** Where a new provider gets its credentials: one card of the picker. */
+export interface ProviderSource {
/** Stable id. The two cards that are not a vendor are titled by the picker. */
key: string;
/** The vendor's own name, empty when the picker titles the card itself. */
label: string;
/** What the form starts from once the card is picked. */
- channel: Partial<Channel>;
+ provider: Partial<Provider>;
}
export const subscriptionSource = "subscription";
@@ -143,48 +143,48 @@ export const customSource = "custom";
* is the vendor of the clients that mode works with; Codex signs in against an
* API Gateway does not relay.
*/
-export const channelSources: ChannelSource[] = [
+export const providerSources: ProviderSource[] = [
{
key: subscriptionSource,
label: "",
- channel: {
+ provider: {
type: "anthropic",
- baseUrl: channelPresets.find(preset => preset.type === "anthropic")?.baseUrl ?? "",
+ baseUrl: providerPresets.find(preset => preset.type === "anthropic")?.baseUrl ?? "",
models: [],
apiKey: "",
authMode: authClient,
},
},
- ...channelPresets.map(preset => ({
+ ...providerPresets.map(preset => ({
key: preset.label.toLowerCase(),
label: preset.label,
- channel: {
+ provider: {
type: preset.type,
baseUrl: preset.baseUrl,
models: preset.models,
- authMode: authChannel,
+ authMode: authProvider,
},
})),
{
key: customSource,
label: "",
- channel: {type: "custom", baseUrl: "", models: [], authMode: authChannel},
+ provider: {type: "custom", baseUrl: "", models: [], authMode: authProvider},
},
];
-/** The base URLs and models offered for a channel type, from the vendors of it. */
+/** The base URLs and models offered for a provider type, from the vendors of it. */
export function baseUrlPresets(type: string) {
- return channelPresets.filter(preset => preset.type === type).map(preset => preset.baseUrl);
+ return providerPresets.filter(preset => preset.type === type).map(preset => preset.baseUrl);
}
export function modelPresets(type: string) {
- return channelPresets.filter(preset => preset.type === type).flatMap(preset => preset.models);
+ return providerPresets.filter(preset => preset.type === type).flatMap(preset => preset.models);
}
export function baseUrlPlaceholder(type: string) {
- return channelProtocol(type) === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
+ return providerProtocol(type) === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
}
export function modelsPlaceholder(type: string) {
- return channelProtocol(type) === "anthropic" ? "claude-opus-5, claude-sonnet-5" : "gpt-5, gpt-5-mini";
+ return providerProtocol(type) === "anthropic" ? "claude-opus-5, claude-sonnet-5" : "gpt-5, gpt-5-mini";
}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index cef1bb3..992e618 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -18,8 +18,8 @@
"Username": "Username"
},
"agent": {
- "Add a channel": "Add a channel",
- "Add a channel hint": "Channels → New Channel: pick a vendor, paste your API key, and list the models it serves.",
+ "Add a provider": "Add a provider",
+ "Add a provider hint": "Providers → New Provider: pick a vendor, paste your API key, and list the models it serves.",
"Agent": "Agent",
"Agent Records": "Activity",
"Agent Sessions": "Sessions",
@@ -32,16 +32,12 @@
"All event types": "All event types",
"All outcomes": "All outcomes",
"Auto refresh": "Auto refresh",
- "Base URL hint": "Give the agent the settings below. Requests that arrive at this URL always go to the channel selected above, whatever model they name.",
- "Bind the channel": "Bind the channel to an agent",
- "Bind the channel hint": "Open an agent, pick the channel, and let Gateway write the agent's configuration for you.",
- "Bound channel cooling down": "The bound channel is out of rotation, requests go to the fallbacks",
- "Channel": "Channel",
- "Channel cleared": "Channel cleared",
- "Channel hint": "With no channel selected, this agent still talks to its own upstream and Gateway only watches it.",
- "Channel saved": "Channel saved",
+ "Base URL hint": "Give the agent the settings below. Requests that arrive at this URL always go to the provider selected above, whatever model they name.",
+ "Bind the provider": "Bind the provider to an agent",
+ "Bind the provider hint": "Open an agent, pick the provider, and let Gateway write the agent's configuration for you.",
+ "Bound provider cooling down": "The bound provider is out of rotation, requests go to the fallbacks",
"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.",
+ "Client auth token hint": "This provider 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",
@@ -51,20 +47,20 @@
"Detail": "Detail",
"Details": "Details",
"Direct": "Direct",
- "Direct mode hint": "The channel's own base URL and key are written into the agent's configuration. Switching to another channel rewrites the file, and the agent has to be restarted to pick it up.",
+ "Direct mode hint": "The provider's own base URL and key are written into the agent's configuration. Switching to another provider rewrites the file, and the agent has to be restarted to pick it up.",
"Duration": "Duration",
"Event": "Event",
"Failed to get agent records": "Failed to get agent records",
"Failed to get agent sessions": "Failed to get agent sessions",
"Failed to scan agents": "Failed to scan agents",
- "Failed to update agent channel": "Failed to update agent channel",
"Failed to update agent patch": "Failed to update agent patch",
+ "Failed to update agent provider": "Failed to update agent provider",
"Failed to write the agent configuration": "Failed to write the agent configuration",
- "Fallback hint": "Channels tried, in the order picked, when the bound one cannot answer.",
+ "Fallback hint": "Providers tried, in the order picked, when the bound one cannot answer.",
"Filter": "Filter",
"First activity": "First activity",
"Gateway": "Gateway",
- "Gateway mode hint": "The agent is pointed at the local proxy, so switching to another channel takes effect on the next request without restarting the agent.",
+ "Gateway mode hint": "The agent is pointed at the local proxy, so switching to another provider takes effect on the next request without restarting the agent.",
"Install Method": "Install Method",
"Install an AI agent on this machine, then scan again": "Install an AI agent on this machine, then scan again",
"Installation": "Installation",
@@ -79,8 +75,8 @@
"Monitoring, no activity yet": "Monitoring, no activity yet",
"No agent records yet - patch an agent to start collecting them": "No activity yet - turn on monitoring for an agent to start collecting it",
"No agent sessions yet - patch an agent to start collecting them": "No sessions yet - turn on monitoring for an agent to start collecting them",
- "No channel": "No channel",
- "No other channel speaks this API": "No other channel speaks this API",
+ "No other provider speaks this API": "No other provider speaks this API",
+ "No provider": "No provider",
"No supported agents found": "No supported agents found",
"No usage yet": "No requests have been relayed for this agent yet",
"Not patched": "Not monitored",
@@ -94,6 +90,10 @@
"Preview": "Preview",
"Preview hint": "What Gateway would write, with the keys masked. Everything else in these files is left as it is.",
"Prompt ID": "Prompt ID",
+ "Provider": "Provider",
+ "Provider cleared": "Provider cleared",
+ "Provider hint": "With no provider selected, this agent still talks to its own upstream and Gateway only watches it.",
+ "Provider saved": "Provider saved",
"Records": "Records",
"Records are kept in memory only and are lost when Gateway restarts": "Records are kept in memory only and are lost when Gateway restarts",
"Reported from": "Reported from",
@@ -103,13 +103,13 @@
"Rewrite configuration": "Rewrite configuration",
"Route through Gateway": "Route through Gateway",
"Running in a container": "Gateway is running in a container",
- "Running in a container detail": "Agents are found by reading the home directories of the machine Gateway runs on, and in a container that is the container's own filesystem rather than the host's. Install Gateway on the host to see the agents installed there. Channels and the WAF proxy work either way.",
+ "Running in a container detail": "Agents are found by reading the home directories of the machine Gateway runs on, and in a container that is the container's own filesystem rather than the host's. Install Gateway on the host to see the agents installed there. Providers and the WAF proxy work either way.",
"Scan": "Rescan",
"Session": "Session",
"Session title": "Session title",
"Target / Model": "Target / Model",
"Time": "Time",
- "Token hint": "Gateway authenticates upstream with the channel's own key, so this token is only there because the client insists on one - any value works.",
+ "Token hint": "Gateway authenticates upstream with the provider's own key, so this token is only there because the client insists on one - any value works.",
"Tool": "Tool",
"Tool use ID": "Tool use ID",
"Turn on monitoring to collect activity": "Turn on monitoring to collect activity",
@@ -123,7 +123,7 @@
"View Records": "View Records",
"View all": "View all",
"Write configuration": "Write configuration",
- "Write confirm hint": "Gateway writes the selected channel into this agent's own configuration file, replacing the file in one step. Restart any session that is already running.",
+ "Write confirm hint": "Gateway writes the selected provider into this agent's own configuration file, replacing the file in one step. Restart any session that is already running.",
"Write the configuration of {agent}?": "Write the configuration of {agent}?",
"Written by Gateway": "Written by Gateway",
"{count} sessions": "{count} sessions"
@@ -271,70 +271,6 @@
"Provider": "Provider",
"Type": "Type"
},
- "channel": {
- "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",
- "Change source": "Change",
- "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",
- "Claude subscription": "Claude subscription",
- "Claude subscription detail": "The sign-in Claude Code already has, forwarded upstream. No API key, nothing to fill in. Codex is the exception: it signs in against an API Gateway does not relay.",
- "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. 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",
- "Custom vendor": "Custom",
- "Custom vendor detail": "Any OpenAI- or Anthropic-compatible API. You give it a base URL and a key.",
- "Disabled": "Disabled",
- "Edit Channel": "Edit Channel",
- "Enabled": "Enabled",
- "Failed to add": "Failed to add",
- "Failed to delete": "Failed to delete",
- "Failed to fetch models": "Failed to fetch models",
- "Failed to get channel": "Failed to get channel",
- "Failed to get channels": "Failed to get channels",
- "Failed to save": "Failed to save",
- "Failed to test": "Failed to test",
- "Fetch models": "Fetch models",
- "Fetched {count} models": "Fetched {count} models",
- "Health": "Health",
- "Healthy": "Healthy",
- "Model routing hint": "Requests that arrive here are routed by the model name they carry, to the enabled channel with the smallest priority value that serves it. To send one agent's requests to this channel whatever model they name, bind the channel on the agent's page instead.",
- "Models": "Models",
- "Models from upstream": "Models from upstream",
- "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, 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 — 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",
- "Select all": "Select all",
- "Source": "Source",
- "Source hint": "Where this channel gets its credentials. Picking one fills in the type, base URL and models, so a subscription needs nothing typed and a vendor needs only its key.",
- "Status": "Status",
- "Stored API key": "Stored API key",
- "Test Connectivity": "Test Connectivity",
- "Type": "Type",
- "Usage": "Usage",
- "Usage hint": "Point any client that speaks this channel's API at Gateway with these variables."
- },
"general": {
"Action": "Action",
"Add": "Add",
@@ -438,7 +374,6 @@
"Cache read": "Cache read",
"Cache write": "Cache write",
"Cached input": "Cached input",
- "Channel": "Channel",
"Clear": "Clear",
"Cost": "Cost",
"Delete every stored record?": "Delete every stored record?",
@@ -471,6 +406,7 @@
"Output": "Output",
"Output tokens": "Output tokens",
"Parameters": "Parameters",
+ "Provider": "Provider",
"Raw": "Raw",
"Record metadata": "Record metadata",
"Record metadata and bodies": "Record metadata and bodies",
@@ -517,6 +453,70 @@
"Edit Node": "Edit Node",
"New Node": "New Node"
},
+ "provider": {
+ "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 provider 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",
+ "Change source": "Change",
+ "Claude subscription": "Claude subscription",
+ "Claude subscription detail": "The sign-in Claude Code already has, forwarded upstream. No API key, nothing to fill in. Codex is the exception: it signs in against an API Gateway does not relay.",
+ "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. 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",
+ "Custom vendor": "Custom",
+ "Custom vendor detail": "Any OpenAI- or Anthropic-compatible API. You give it a base URL and a key.",
+ "Disabled": "Disabled",
+ "Edit Provider": "Edit Provider",
+ "Enabled": "Enabled",
+ "Failed to add": "Failed to add",
+ "Failed to delete": "Failed to delete",
+ "Failed to fetch models": "Failed to fetch models",
+ "Failed to get provider": "Failed to get provider",
+ "Failed to get providers": "Failed to get providers",
+ "Failed to save": "Failed to save",
+ "Failed to test": "Failed to test",
+ "Fetch models": "Fetch models",
+ "Fetched {count} models": "Fetched {count} models",
+ "Health": "Health",
+ "Healthy": "Healthy",
+ "Model routing hint": "Requests that arrive here are routed by the model name they carry, to the enabled provider with the smallest priority value that serves it. To send one agent's requests to this provider whatever model they name, bind the provider on the agent's page instead.",
+ "Models": "Models",
+ "Models from upstream": "Models from upstream",
+ "Models hint": "The model names this provider serves, used to route requests that arrive by model name.",
+ "New Provider": "New Provider",
+ "No providers yet": "No providers yet",
+ "No providers yet detail": "A provider 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 — 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 providers serve the same model, the one with the smaller value is used first.",
+ "Provider": "Provider",
+ "Provider added successfully": "Provider added successfully",
+ "Provider deleted successfully": "Provider deleted successfully",
+ "Provider not found": "Provider not found",
+ "Provider saved": "Provider saved",
+ "Providers": "Providers",
+ "Recovering": "Recovering",
+ "Retried at": "retried at",
+ "Select all": "Select all",
+ "Source": "Source",
+ "Source hint": "Where this provider gets its credentials. Picking one fills in the type, base URL and models, so a subscription needs nothing typed and a vendor needs only its key.",
+ "Status": "Status",
+ "Stored API key": "Stored API key",
+ "Test Connectivity": "Test Connectivity",
+ "Type": "Type",
+ "Usage": "Usage",
+ "Usage hint": "Point any client that speaks this provider's API at Gateway with these variables."
+ },
"rule": {
"Allow": "Allow",
"Block": "Block",
@@ -559,7 +559,7 @@
"ACME email hint": "The Let's Encrypt account that issues the certificates for your sites.",
"ACME private key": "ACME private key",
"API key encryption key": "API key encryption key",
- "API key encryption key hint": "Encrypts the channel API keys at rest, any length. Back it up with the database: losing it makes already-encrypted keys unreadable.",
+ "API key encryption key hint": "Encrypts the provider API keys at rest, any length. Back it up with the database: losing it makes already-encrypted keys unreadable.",
"Agent poll seconds": "Agent poll interval, in seconds",
"Agent record capacity": "Agent records kept",
"Agent record capacity hint": "Agent records are never written to disk, so this also bounds the memory they use.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 38941f6..f168e4a 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -18,8 +18,8 @@
"Username": "用户名"
},
"agent": {
- "Add a channel": "添加一个 Channel",
- "Add a channel hint": "Channels → New Channel:选一个厂商,填上你的 API Key,列出它提供的模型。",
+ "Add a provider": "添加一个供应商",
+ "Add a provider hint": "供应商 → 新建供应商:选一个厂商,填上你的 API Key,列出它提供的模型。",
"Agent": "智能体",
"Agent Records": "活动记录",
"Agent Sessions": "会话",
@@ -32,16 +32,12 @@
"All event types": "全部事件类型",
"All outcomes": "全部结果",
"Auto refresh": "自动刷新",
- "Base URL hint": "把下面的设置配给该 Agent。到达这个地址的请求一律转发到上面选定的渠道,与请求里的模型名无关。",
- "Bind the channel": "把 Channel 绑定到 Agent",
- "Bind the channel hint": "打开一个 Agent,选中该 Channel,Gateway 会替你写好它的配置。",
- "Bound channel cooling down": "绑定通道已暂时移出轮转,请求将走备用通道",
- "Channel": "渠道",
- "Channel cleared": "已清除渠道",
- "Channel hint": "未选择渠道时,该 Agent 仍直连自己的上游,网关只做观测。",
- "Channel saved": "渠道已保存",
+ "Base URL hint": "把下面的设置配给该 Agent。到达这个地址的请求一律转发到上面选定的供应商,与请求里的模型名无关。",
+ "Bind the provider": "把供应商绑定到 Agent",
+ "Bind the provider hint": "打开一个 Agent,选中该供应商,Gateway 会替你写好它的配置。",
+ "Bound provider cooling down": "绑定的供应商已暂时移出轮转,请求将走备用供应商",
"Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env 段,这样不用每次 export,所有会话都会生效。",
- "Client auth token hint": "该通道转发 Agent 自己的凭据,所以不设置 token,Agent 继续用它已有的登录。",
+ "Client auth token hint": "该供应商转发 Agent 自己的凭据,所以不设置 token,Agent 继续用它已有的登录。",
"Codex CLI config hint": "base URL 也可以写成 ~/.codex/config.toml 里的一个 model provider,再用 model_provider 选中它。",
"Configuration preview": "配置预览",
"Configuration restored": "配置已还原",
@@ -51,20 +47,20 @@
"Detail": "详情",
"Details": "详情",
"Direct": "直连",
- "Direct mode hint": "直接把该通道的 Base URL 和密钥写入 Agent 配置文件。切换通道会重写文件,且需要重启 Agent 才能生效。",
+ "Direct mode hint": "直接把该供应商的 Base URL 和密钥写入 Agent 配置文件。切换供应商会重写文件,且需要重启 Agent 才能生效。",
"Duration": "耗时",
"Event": "事件",
"Failed to get agent records": "获取 Agent 记录失败",
"Failed to get agent sessions": "获取 Agent 会话失败",
"Failed to scan agents": "探测 Agent 失败",
- "Failed to update agent channel": "更新 Agent 渠道失败",
"Failed to update agent patch": "更新 Agent Patch 失败",
+ "Failed to update agent provider": "更新 Agent 供应商失败",
"Failed to write the agent configuration": "写入 Agent 配置失败",
- "Fallback hint": "当绑定通道无法响应时,按所选顺序依次尝试的备用通道。",
+ "Fallback hint": "当绑定的供应商无法响应时,按所选顺序依次尝试的备用供应商。",
"Filter": "筛选",
"First activity": "首次活动",
"Gateway": "网关",
- "Gateway mode hint": "把 Agent 指向本地代理,切换通道在下一次请求即生效,无需重启 Agent。",
+ "Gateway mode hint": "把 Agent 指向本地代理,切换供应商在下一次请求即生效,无需重启 Agent。",
"Install Method": "安装方式",
"Install an AI agent on this machine, then scan again": "在本机安装一个 AI 智能体后,再扫描一次",
"Installation": "安装信息",
@@ -79,8 +75,8 @@
"Monitoring, no activity yet": "监控中,暂无活动",
"No agent records yet - patch an agent to start collecting them": "还没有活动记录,开启某个智能体的监控后才会开始收集",
"No agent sessions yet - patch an agent to start collecting them": "还没有会话,开启某个智能体的监控后才会开始收集",
- "No channel": "未设置渠道",
- "No other channel speaks this API": "没有其他通道使用相同的 API 协议",
+ "No other provider speaks this API": "没有其他供应商使用相同的 API 协议",
+ "No provider": "未设置供应商",
"No supported agents found": "没有发现本机安装的智能体",
"No usage yet": "该 Agent 还没有经过网关的请求",
"Not patched": "未开启",
@@ -94,6 +90,10 @@
"Preview": "预览",
"Preview hint": "Gateway 将要写入的内容,密钥已打码。文件中的其他配置保持不变。",
"Prompt ID": "Prompt ID",
+ "Provider": "供应商",
+ "Provider cleared": "已清除供应商",
+ "Provider hint": "未选择供应商时,该 Agent 仍直连自己的上游,网关只做观测。",
+ "Provider saved": "供应商已保存",
"Records": "记录数",
"Records are kept in memory only and are lost when Gateway restarts": "记录仅保存在内存中,Gateway 重启后会丢失",
"Reported from": "上报来源",
@@ -103,13 +103,13 @@
"Rewrite configuration": "重新写入配置",
"Route through Gateway": "经由 Gateway 路由",
"Running in a container": "Gateway 正跑在容器里",
- "Running in a container detail": "Agent 是靠读取 Gateway 所在机器的用户主目录发现的,而在容器里那是容器自己的文件系统,不是宿主机的。把 Gateway 装到宿主机上才能看到装在那里的 Agent。Channel 和 WAF 反向代理在容器里照常可用。",
+ "Running in a container detail": "Agent 是靠读取 Gateway 所在机器的用户主目录发现的,而在容器里那是容器自己的文件系统,不是宿主机的。把 Gateway 装到宿主机上才能看到装在那里的 Agent。供应商和 WAF 反向代理在容器里照常可用。",
"Scan": "重新扫描",
"Session": "会话",
"Session title": "会话标题",
"Target / Model": "目标 / 模型",
"Time": "时间",
- "Token hint": "网关是用渠道自己的密钥去认证上游的,这里的 token 只是因为客户端一定要有一个,填什么都行。",
+ "Token hint": "网关是用供应商自己的密钥去认证上游的,这里的 token 只是因为客户端一定要有一个,填什么都行。",
"Tool": "工具",
"Tool use ID": "工具调用 ID",
"Turn on monitoring to collect activity": "开启监控后才会收集活动",
@@ -123,7 +123,7 @@
"View Records": "查看活动",
"View all": "查看全部",
"Write configuration": "写入配置",
- "Write confirm hint": "Gateway 会把所选通道以该 Agent 自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
+ "Write confirm hint": "Gateway 会把所选供应商以该 Agent 自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
"Write the configuration of {agent}?": "写入 {agent} 的配置?",
"Written by Gateway": "已由 Gateway 写入",
"{count} sessions": "{count} 个会话"
@@ -271,70 +271,6 @@
"Provider": "提供者",
"Type": "类型"
},
- "channel": {
- "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": "调用方自己的登录",
- "Change source": "更换",
- "Channel": "渠道",
- "Channel added successfully": "渠道添加成功",
- "Channel deleted successfully": "渠道删除成功",
- "Channel not found": "渠道不存在",
- "Channel saved": "渠道已保存",
- "Channels": "渠道",
- "Claude subscription": "Claude 订阅登录",
- "Claude subscription detail": "直接用 Claude Code 已有的登录,原样转发给上游。不需要 API Key,什么都不用填。Codex 除外:它的登录所对的 API 不在 Gateway 的转发范围内。",
- "Clear": "清空",
- "Client auth hint": "请求会带着客户端自己的凭据转发上游,已经用订阅登录过的 Agent 可以继续用自己的登录,这里不保存任何 Key。Codex 除外:它的 ChatGPT 登录走的是另一套 API。",
- "Client auth usage hint": "只设置 base URL:这里再设一个 token 会覆盖客户端已有的登录。",
- "Connection Failed": "连接失败",
- "Connection Successful": "连接成功",
- "Cooling down": "冷却中",
- "Custom vendor": "自定义",
- "Custom vendor detail": "任何兼容 OpenAI 或 Anthropic 的 API,由你填写 base URL 和 Key。",
- "Disabled": "已禁用",
- "Edit Channel": "编辑渠道",
- "Enabled": "已启用",
- "Failed to add": "添加失败",
- "Failed to delete": "删除失败",
- "Failed to fetch models": "获取模型列表失败",
- "Failed to get channel": "获取渠道失败",
- "Failed to get channels": "获取渠道列表失败",
- "Failed to save": "保存失败",
- "Failed to test": "测试失败",
- "Fetch models": "自动获取",
- "Fetched {count} models": "共获取到 {count} 个模型",
- "Health": "健康状态",
- "Healthy": "正常",
- "Model routing hint": "到达这里的请求按自己带的模型名路由,转发到支持该模型、且 priority 值最小的已启用渠道。如果想让某个 Agent 的请求不管模型名一律走这个渠道,请到该 Agent 的页面上绑定。",
- "Models": "模型",
- "Models from upstream": "上游返回的模型",
- "Models hint": "这个 Channel 提供的模型名,按模型名路由的请求靠它匹配。",
- "New Channel": "新建渠道",
- "No channels yet": "还没有 Channel",
- "No channels yet detail": "一个 Channel 就是你自己的厂商账号:base URL、它提供的模型,以及一个 API Key,或者转发调用方自己的登录。先建一个,再把它绑定到 Agent,该 Agent 的请求就会走它。",
- "Not used yet": "尚未使用",
- "Page description": "挡在模型厂商前面的统一入口。API Key 由 Gateway 保管,Agent 拿不到;也可以转发 Agent 自己的登录,什么都不保管。",
- "Priority": "优先级",
- "Priority hint": "数字越小优先级越高,0 为最高;同模型多渠道时优先使用数值小的渠道。",
- "Recovering": "恢复中",
- "Retried at": "将于",
- "Select all": "全选",
- "Source": "来源",
- "Source hint": "这个 Channel 的凭据从哪里来。选一个就会自动填好类型、base URL 和模型:订阅登录什么都不用填,厂商只用填一个 Key。",
- "Status": "状态",
- "Stored API key": "Gateway 保存的 API Key",
- "Test Connectivity": "测试连接",
- "Type": "类型",
- "Usage": "使用方法",
- "Usage hint": "任何说这个渠道所用 API 的客户端,配上下面的变量就能走网关。"
- },
"general": {
"Action": "操作",
"Add": "添加",
@@ -438,7 +374,6 @@
"Cache read": "缓存读取",
"Cache write": "缓存写入",
"Cached input": "缓存输入",
- "Channel": "渠道",
"Clear": "清空",
"Cost": "费用",
"Delete every stored record?": "确定删除全部已保存的记录?",
@@ -471,6 +406,7 @@
"Output": "输出",
"Output tokens": "输出 Token",
"Parameters": "参数",
+ "Provider": "供应商",
"Raw": "原始 JSON",
"Record metadata": "只记录元数据",
"Record metadata and bodies": "记录元数据和正文",
@@ -517,6 +453,70 @@
"Edit Node": "编辑节点",
"New Node": "新建节点"
},
+ "provider": {
+ "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": "调用方自己的登录",
+ "Change source": "更换",
+ "Claude subscription": "Claude 订阅登录",
+ "Claude subscription detail": "直接用 Claude Code 已有的登录,原样转发给上游。不需要 API Key,什么都不用填。Codex 除外:它的登录所对的 API 不在 Gateway 的转发范围内。",
+ "Clear": "清空",
+ "Client auth hint": "请求会带着客户端自己的凭据转发上游,已经用订阅登录过的 Agent 可以继续用自己的登录,这里不保存任何 Key。Codex 除外:它的 ChatGPT 登录走的是另一套 API。",
+ "Client auth usage hint": "只设置 base URL:这里再设一个 token 会覆盖客户端已有的登录。",
+ "Connection Failed": "连接失败",
+ "Connection Successful": "连接成功",
+ "Cooling down": "冷却中",
+ "Custom vendor": "自定义",
+ "Custom vendor detail": "任何兼容 OpenAI 或 Anthropic 的 API,由你填写 base URL 和 Key。",
+ "Disabled": "已禁用",
+ "Edit Provider": "编辑供应商",
+ "Enabled": "已启用",
+ "Failed to add": "添加失败",
+ "Failed to delete": "删除失败",
+ "Failed to fetch models": "获取模型列表失败",
+ "Failed to get provider": "获取供应商失败",
+ "Failed to get providers": "获取供应商列表失败",
+ "Failed to save": "保存失败",
+ "Failed to test": "测试失败",
+ "Fetch models": "自动获取",
+ "Fetched {count} models": "共获取到 {count} 个模型",
+ "Health": "健康状态",
+ "Healthy": "正常",
+ "Model routing hint": "到达这里的请求按自己带的模型名路由,转发到支持该模型、且 priority 值最小的已启用供应商。如果想让某个 Agent 的请求不管模型名一律走这个供应商,请到该 Agent 的页面上绑定。",
+ "Models": "模型",
+ "Models from upstream": "上游返回的模型",
+ "Models hint": "这个供应商提供的模型名,按模型名路由的请求靠它匹配。",
+ "New Provider": "新建供应商",
+ "No providers yet": "还没有供应商",
+ "No providers yet detail": "一个供应商就是你自己的厂商账号:base URL、它提供的模型,以及一个 API Key,或者转发调用方自己的登录。先建一个,再把它绑定到 Agent,该 Agent 的请求就会走它。",
+ "Not used yet": "尚未使用",
+ "Page description": "挡在模型厂商前面的统一入口。API Key 由 Gateway 保管,Agent 拿不到;也可以转发 Agent 自己的登录,什么都不保管。",
+ "Priority": "优先级",
+ "Priority hint": "数字越小优先级越高,0 为最高;同一模型有多个供应商时优先使用数值小的那个。",
+ "Provider": "供应商",
+ "Provider added successfully": "供应商添加成功",
+ "Provider deleted successfully": "供应商删除成功",
+ "Provider not found": "供应商不存在",
+ "Provider saved": "供应商已保存",
+ "Providers": "供应商",
+ "Recovering": "恢复中",
+ "Retried at": "将于",
+ "Select all": "全选",
+ "Source": "来源",
+ "Source hint": "这个供应商的凭据从哪里来。选一个就会自动填好类型、base URL 和模型:订阅登录什么都不用填,厂商只用填一个 Key。",
+ "Status": "状态",
+ "Stored API key": "Gateway 保存的 API Key",
+ "Test Connectivity": "测试连接",
+ "Type": "类型",
+ "Usage": "使用方法",
+ "Usage hint": "任何说这个供应商所用 API 的客户端,配上下面的变量就能走网关。"
+ },
"rule": {
"Allow": "允许",
"Block": "阻止",
@@ -559,7 +559,7 @@
"ACME email hint": "用于为站点签发 Let's Encrypt 证书的账号。",
"ACME private key": "ACME 私钥",
"API key encryption key": "API Key 加密密钥",
- "API key encryption key hint": "用于加密存储的通道 API Key,长度不限。请与数据库一起备份:一旦丢失,已加密的密钥将无法读取。",
+ "API key encryption key hint": "用于加密存储的供应商 API Key,长度不限。请与数据库一起备份:一旦丢失,已加密的密钥将无法读取。",
"Agent poll seconds": "Agent 轮询间隔(秒)",
"Agent record capacity": "Agent 记录条数",
"Agent record capacity hint": "Agent 记录不会落盘,因此这个值也决定了它们占用的内存上限。",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index d6aa2fa..8f5843a 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -76,7 +76,7 @@ export const navGroups: NavGroup[] = [
adminOnly: true,
},
{key: "/agent-records", label: "agent:Agent Records", icon: FileSearch, path: "/agent-records", adminOnly: true},
- {key: "/channels", label: "channel:Channels", icon: Plug, path: "/channels"},
+ {key: "/providers", label: "provider:Providers", icon: Plug, path: "/providers"},
{key: "/llm-records", label: "llm:LLM Records", icon: Logs, path: "/llm-records", adminOnly: true},
{
key: "/advanced",
diff --git a/web/src/pages/AgentDashboardPage.tsx b/web/src/pages/AgentDashboardPage.tsx
index 1b3bb51..e8a013c 100644
--- a/web/src/pages/AgentDashboardPage.tsx
+++ b/web/src/pages/AgentDashboardPage.tsx
@@ -26,7 +26,7 @@ import {
} from "lucide-react";
import i18next from "i18next";
-import * as ChannelBackend from "@/backend/ChannelBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
@@ -164,18 +164,18 @@ function AgentCard({
}
/** The two steps between a fresh install and an agent running on your own key. */
-function SetupGuide({hasChannel, agents}: {hasChannel: boolean; agents: Agent[]}) {
+function SetupGuide({hasProvider, agents}: {hasProvider: boolean; agents: Agent[]}) {
const steps = [
{
- done: hasChannel,
- title: i18next.t("agent:Add a channel"),
- hint: i18next.t("agent:Add a channel hint"),
- to: "/channels",
+ done: hasProvider,
+ title: i18next.t("agent:Add a provider"),
+ hint: i18next.t("agent:Add a provider hint"),
+ to: "/providers",
},
{
- done: agents.some(agent => agent.channel !== ""),
- title: i18next.t("agent:Bind the channel"),
- hint: i18next.t("agent:Bind the channel hint"),
+ done: agents.some(agent => agent.provider !== ""),
+ title: i18next.t("agent:Bind the provider"),
+ hint: i18next.t("agent:Bind the provider hint"),
// With nothing scanned there is no agent to open, only the list saying so.
to: agents.length > 0 ? agentDetailPath(agents[0]) : "/agents",
},
@@ -216,16 +216,16 @@ export default function AgentDashboardPage({account}: {account: Account}) {
const isAdmin = Setting.isAdminUser(account);
const {agents, loading, error, busyKey, scanned, inContainer, scan, togglePatch} = useAgents(isAdmin);
const {activity, recordCount} = useAgentSessions(isAdmin, "", 5000);
- // null until the channel count is known, so the guide never flashes.
- const [hasChannel, setHasChannel] = React.useState<boolean | null>(null);
+ // null until the provider count is known, so the guide never flashes.
+ const [hasProvider, setHasProvider] = React.useState<boolean | null>(null);
React.useEffect(() => {
if (!isAdmin) {
return;
}
- ChannelBackend.getChannels(account.name, 1, 1)
- .then(res => setHasChannel(res.status === "ok" && (res.data2 ?? 0) > 0))
- .catch(() => setHasChannel(false));
+ ProviderBackend.getProviders(account.name, 1, 1)
+ .then(res => setHasProvider(res.status === "ok" && (res.data2 ?? 0) > 0))
+ .catch(() => setHasProvider(false));
}, [isAdmin, account.name]);
if (!isAdmin) {
@@ -234,7 +234,7 @@ export default function AgentDashboardPage({account}: {account: Account}) {
const patchedCount = agents.filter(agent => agent.patched).length;
const sessionCount = Object.values(activity).reduce((total, entry) => total + entry.sessionCount, 0);
- const setupDone = hasChannel === true && agents.some(agent => agent.channel !== "");
+ const setupDone = hasProvider === true && agents.some(agent => agent.provider !== "");
return (
<PageContainer>
@@ -258,8 +258,8 @@ export default function AgentDashboardPage({account}: {account: Account}) {
<StatCard label={i18next.t("agent:Records")} value={recordCount} icon={FileSearch} />
</div>
- {scanned && hasChannel !== null && !setupDone ? (
- <SetupGuide hasChannel={hasChannel} agents={agents} />
+ {scanned && hasProvider !== null && !setupDone ? (
+ <SetupGuide hasProvider={hasProvider} agents={agents} />
) : null}
{!scanned ? (
diff --git a/web/src/pages/AgentDetailPage.tsx b/web/src/pages/AgentDetailPage.tsx
index 2a5f233..050ba8b 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -19,7 +19,7 @@ import copy from "copy-to-clipboard";
import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
-import * as ChannelBackend from "@/backend/ChannelBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
@@ -47,8 +47,8 @@ import type {
Agent,
AgentRecord,
AgentSession,
- Channel,
- ChannelHealth,
+ Provider,
+ ProviderHealth,
LlmRecordStats,
} from "@/types";
@@ -121,7 +121,7 @@ function MonitoringCard({
);
}
-/** What this agent has spent through the proxy, and on which channel. */
+/** What this agent has spent through the proxy, and on which provider. */
function UsageCard({stats}: {stats: LlmRecordStats | null}) {
return (
<Card>
@@ -150,16 +150,16 @@ function UsageCard({stats}: {stats: LlmRecordStats | null}) {
</p>
) : null}
- {/* The per-channel split is what says whether the fallbacks are carrying
- traffic the bound channel could not. */}
- {stats && stats.channels.length > 0 ? (
+ {/* The per-provider split is what says whether the fallbacks are carrying
+ traffic the bound provider could not. */}
+ {stats && stats.providers.length > 0 ? (
<div className="flex flex-wrap gap-2">
- {stats.channels.map(channel => (
- <Badge key={channel.channel} variant="muted" className="gap-2">
- <span className="font-mono">{channel.channel}</span>
- <span>{channel.requests.toLocaleString()}</span>
+ {stats.providers.map(provider => (
+ <Badge key={provider.provider} variant="muted" className="gap-2">
+ <span className="font-mono">{provider.provider}</span>
+ <span>{provider.requests.toLocaleString()}</span>
<span className="text-muted-foreground">
- {formatTokens(channel.tokens)} · {formatCost(channel.cost)}
+ {formatTokens(provider.tokens)} · {formatCost(provider.cost)}
</span>
</Badge>
))}
@@ -181,8 +181,8 @@ export default function AgentDetailPage({account}: {account: Account}) {
const [tab, setTab] = React.useState<Tab>("Agent Sessions");
const [records, setRecords] = React.useState<AgentRecord[]>([]);
const [recordError, setRecordError] = React.useState("");
- const [channels, setChannels] = React.useState<Channel[]>([]);
- const [health, setHealth] = React.useState<ChannelHealth[]>([]);
+ const [providers, setProviders] = React.useState<Provider[]>([]);
+ const [health, setHealth] = React.useState<ProviderHealth[]>([]);
const [stats, setStats] = React.useState<LlmRecordStats | null>(null);
const agentId = params.agentId ?? "";
@@ -199,15 +199,15 @@ export default function AgentDetailPage({account}: {account: Account}) {
if (!isAdmin) {
return;
}
- ChannelBackend.getChannels(account.name)
+ ProviderBackend.getProviders(account.name)
.then(res => {
if (res.status === "ok") {
- setChannels(res.data ?? []);
+ setProviders(res.data ?? []);
}
})
- .catch(() => setChannels([]));
+ .catch(() => setProviders([]));
- ChannelBackend.getChannelHealth()
+ ProviderBackend.getProviderHealth()
.then(res => {
if (res.status === "ok") {
setHealth(res.data ?? []);
@@ -392,7 +392,7 @@ export default function AgentDetailPage({account}: {account: Account}) {
<ProviderCard
agent={agent}
- channels={channels}
+ providers={providers}
health={health}
busy={busyKey === agentKey(agent)}
onRouting={routing => setRouting(agent, routing)}
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 2beaf25..96d7f48 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -74,13 +74,13 @@ export default function AgentsPage({account}: {account: Account}) {
render: (value: string) => <CodeText copyable>{value}</CodeText>,
},
{
- title: i18next.t("agent:Channel"),
- key: "channel",
- dataIndex: "channel",
+ title: i18next.t("agent:Provider"),
+ key: "provider",
+ dataIndex: "provider",
render: (value: string, record) =>
value ? (
<div className="flex flex-col items-start gap-1">
- <Link to={`/channels/${value}`} className="text-primary hover:underline">
+ <Link to={`/providers/${value}`} className="text-primary hover:underline">
{value}
</Link>
<span className="flex flex-wrap items-center gap-1">
@@ -89,8 +89,8 @@ export default function AgentsPage({account}: {account: Account}) {
<Badge variant="muted">{`+${record.fallbacks.length}`}</Badge>
</SimpleTooltip>
) : null}
- {record.provider?.applied ? (
- <SimpleTooltip title={record.provider.detail}>
+ {record.providerConfig?.applied ? (
+ <SimpleTooltip title={record.providerConfig.detail}>
<Badge variant="success">
{i18next.t(record.mode === directMode ? "agent:Direct" : "agent:Gateway")}
</Badge>
@@ -99,7 +99,7 @@ export default function AgentsPage({account}: {account: Account}) {
</span>
</div>
) : (
- <span className="text-muted-foreground">{i18next.t("agent:No channel")}</span>
+ <span className="text-muted-foreground">{i18next.t("agent:No provider")}</span>
),
},
{
diff --git a/web/src/pages/LlmRecordsPage.tsx b/web/src/pages/LlmRecordsPage.tsx
index f5747e0..0c78aa1 100644
--- a/web/src/pages/LlmRecordsPage.tsx
+++ b/web/src/pages/LlmRecordsPage.tsx
@@ -96,7 +96,7 @@ function RecordDetail({record, onDelete}: {record: LlmRecord; onDelete: () => vo
{label: i18next.t("general:ID"), value: record.id},
{label: i18next.t("general:Created time"), value: Setting.getFormattedDate(record.createdTime)},
{label: i18next.t("llm:Endpoint"), value: <CodeText>{`${record.protocol} ${record.endpoint}`}</CodeText>},
- record.channel && {label: i18next.t("llm:Channel"), value: <CodeText>{record.channel}</CodeText>},
+ record.provider && {label: i18next.t("llm:Provider"), value: <CodeText>{record.provider}</CodeText>},
record.agent && {label: i18next.t("agent:Agent"), value: <CodeText>{record.agent}</CodeText>},
{label: i18next.t("llm:Attempts"), value: record.attempts},
{
@@ -377,7 +377,7 @@ export default function LlmRecordsPage({account}: {account: Account}) {
render: (value: string, record) => (
<div className="flex min-w-0 flex-col gap-0.5">
<CodeText>{value}</CodeText>
- <span className="text-muted-foreground truncate text-xs">{record.agent || record.channel}</span>
+ <span className="text-muted-foreground truncate text-xs">{record.agent || record.provider}</span>
</div>
),
},
diff --git a/web/src/pages/ChannelEditPage.tsx b/web/src/pages/ProviderEditPage.tsx
similarity index 59%
rename from web/src/pages/ChannelEditPage.tsx
rename to web/src/pages/ProviderEditPage.tsx
index 6f2d638..27159e5 100644
--- a/web/src/pages/ChannelEditPage.tsx
+++ b/web/src/pages/ProviderEditPage.tsx
@@ -17,9 +17,9 @@ import {useNavigate, useParams} from "react-router-dom";
import {CircleCheck, CircleX, Save, Zap} from "lucide-react";
import i18next from "i18next";
-import * as ChannelBackend from "@/backend/ChannelBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
-import {ChannelModelsField} from "@/components/ChannelModelsField";
+import {ProviderModelsField} from "@/components/ProviderModelsField";
import {EnvSnippet} from "@/components/EnvSnippet";
import {Field} from "@/components/shared/form-dialog";
import {Loading} from "@/components/shared/loading";
@@ -35,12 +35,12 @@ import {
authClient,
baseUrlPlaceholder,
baseUrlPresets,
- channelProtocol,
+ providerProtocol,
gatewayBaseUrl,
localShell,
usesClientAuth,
-} from "@/lib/channels";
-import type {Channel, ChannelTestResult} from "@/types";
+} from "@/lib/providers";
+import type {Provider, ProviderTestResult} from "@/types";
// Mirrors object.BuildOpenAiUrl on the server.
function buildOpenAiUrl(baseUrl: string, endpoint: string) {
@@ -89,110 +89,110 @@ function buildAnthropicUrl(baseUrl: string, endpoint: string) {
return url.toString();
}
-/** The upstream URL requests to a channel of this type end up at. */
+/** The upstream URL requests to a provider of this type end up at. */
function buildUpstreamUrl(baseUrl: string, type: string) {
- return channelProtocol(type) === "anthropic"
+ return providerProtocol(type) === "anthropic"
? buildAnthropicUrl(baseUrl, "/v1/messages")
: buildOpenAiUrl(baseUrl, "/chat/completions");
}
-export default function ChannelEditPage() {
- const {owner = "", channelName = ""} = useParams();
+export default function ProviderEditPage() {
+ const {owner = "", providerName = ""} = useParams();
const navigate = useNavigate();
- // undefined while loading, null when the channel could not be loaded.
- const [channel, setChannel] = React.useState<Channel | null | undefined>(undefined);
+ // undefined while loading, null when the provider could not be loaded.
+ const [provider, setProvider] = React.useState<Provider | null | undefined>(undefined);
const [testing, setTesting] = React.useState(false);
- const [result, setResult] = React.useState<ChannelTestResult | null>(null);
+ const [result, setResult] = React.useState<ProviderTestResult | null>(null);
React.useEffect(() => {
- ChannelBackend.getChannel(owner, channelName)
+ ProviderBackend.getProvider(owner, providerName)
.then(res => {
if (res.status === "ok") {
- setChannel(res.data);
+ setProvider(res.data);
} else {
- setChannel(null);
- Setting.showMessage("error", `${i18next.t("channel:Failed to get channel")}: ${res.msg}`);
+ setProvider(null);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to get provider")}: ${res.msg}`);
}
})
.catch(error => {
- setChannel(null);
- Setting.showMessage("error", `${i18next.t("channel:Failed to get channel")}: ${error}`);
+ setProvider(null);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to get provider")}: ${error}`);
});
- }, [channelName, owner]);
+ }, [providerName, owner]);
- const setField = <K extends keyof Channel>(key: K, value: Channel[K]) => {
- setChannel(current => (current ? {...current, [key]: value} : current));
+ const setField = <K extends keyof Provider>(key: K, value: Provider[K]) => {
+ setProvider(current => (current ? {...current, [key]: value} : current));
};
const save = () => {
- if (!channel) {
+ if (!provider) {
return Promise.resolve(false);
}
- return ChannelBackend.updateChannel(owner, channelName, channel)
+ return ProviderBackend.updateProvider(owner, providerName, provider)
.then(res => {
if (res.status === "error") {
- Setting.showMessage("error", `${i18next.t("channel:Failed to save")}: ${res.msg}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to save")}: ${res.msg}`);
return false;
}
- Setting.showMessage("success", i18next.t("channel:Channel saved"));
+ Setting.showMessage("success", i18next.t("provider:Provider saved"));
return true;
})
.catch(error => {
- Setting.showMessage("error", `${i18next.t("channel:Failed to save")}: ${error}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to save")}: ${error}`);
return false;
});
};
- // The test probes the stored channel, so the edits have to be saved first.
+ // The test probes the stored provider, so the edits have to be saved first.
const test = () => {
setTesting(true);
setResult(null);
save()
- .then(saved => (saved ? ChannelBackend.testChannel(owner, channelName) : null))
+ .then(saved => (saved ? ProviderBackend.testProvider(owner, providerName) : null))
.then(res => {
setTesting(false);
if (res === null) {
return;
}
if (res.status === "error") {
- Setting.showMessage("error", `${i18next.t("channel:Failed to test")}: ${res.msg}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to test")}: ${res.msg}`);
return;
}
setResult(res.data);
})
.catch(error => {
setTesting(false);
- Setting.showMessage("error", `${i18next.t("channel:Failed to test")}: ${error}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to test")}: ${error}`);
});
};
- if (channel === undefined) {
+ if (provider === undefined) {
return <Loading type="page" />;
}
- if (channel === null) {
+ if (provider === null) {
return (
<ResultScreen
status="404"
- title={i18next.t("channel:Channel not found")}
- extra={<Button onClick={() => navigate("/channels")}>{i18next.t("channel:Channels")}</Button>}
+ title={i18next.t("provider:Provider not found")}
+ extra={<Button onClick={() => navigate("/providers")}>{i18next.t("provider:Providers")}</Button>}
/>
);
}
- const upstreamUrl = buildUpstreamUrl(channel.baseUrl, channel.type);
+ const upstreamUrl = buildUpstreamUrl(provider.baseUrl, provider.type);
return (
<PageContainer>
<PageHeader
- title={i18next.t("channel:Edit Channel")}
- description={`${channel.owner} / ${channel.name}`}
+ title={i18next.t("provider:Edit Provider")}
+ description={`${provider.owner} / ${provider.name}`}
actions={
<>
<Button variant="outline" onClick={test} loading={testing}>
<Zap />
- {i18next.t("channel:Test Connectivity")}
+ {i18next.t("provider:Test Connectivity")}
</Button>
<Button onClick={save}>
<Save />
@@ -206,23 +206,23 @@ export default function ChannelEditPage() {
<MessageAlert
variant={result.success ? "success" : "destructive"}
title={
- result.success ? i18next.t("channel:Connection Successful") : i18next.t("channel:Connection Failed")
+ result.success ? i18next.t("provider:Connection Successful") : i18next.t("provider:Connection Failed")
}
description={result.statusCode ? `HTTP ${result.statusCode} - ${result.message}` : result.message}
/>
) : null}
- <Section title={i18next.t("channel:Channel")}>
- <Field label={i18next.t("general:Display name")} htmlFor="channel-display-name">
+ <Section title={i18next.t("provider:Provider")}>
+ <Field label={i18next.t("general:Display name")} htmlFor="provider-display-name">
<Input
- id="channel-display-name"
- value={channel.displayName}
+ id="provider-display-name"
+ value={provider.displayName}
onChange={event => setField("displayName", event.target.value)}
/>
</Field>
- <Field label={i18next.t("channel:Type")}>
+ <Field label={i18next.t("provider:Type")}>
<SimpleSelect
- value={channel.type}
+ value={provider.type}
onChange={value => setField("type", value)}
options={[
{label: "OpenAI", value: "openai"},
@@ -232,72 +232,72 @@ export default function ChannelEditPage() {
/>
</Field>
<Field
- label={i18next.t("channel:Base URL")}
+ label={i18next.t("provider:Base URL")}
hint={
upstreamUrl === "" ? undefined : (
<>
- {i18next.t("channel:Base URL hint")}: <CodeText>{upstreamUrl}</CodeText>
+ {i18next.t("provider:Base URL hint")}: <CodeText>{upstreamUrl}</CodeText>
</>
)
}
>
<SearchSelect
allowCustomValue
- value={channel.baseUrl}
- placeholder={baseUrlPlaceholder(channel.type)}
- options={baseUrlPresets(channel.type)}
+ value={provider.baseUrl}
+ placeholder={baseUrlPlaceholder(provider.type)}
+ options={baseUrlPresets(provider.type)}
onChange={value => setField("baseUrl", value)}
/>
</Field>
<Field
- label={i18next.t("channel:Authentication")}
- hint={usesClientAuth(channel) ? i18next.t("channel:Client auth hint") : undefined}
+ label={i18next.t("provider:Authentication")}
+ hint={usesClientAuth(provider) ? i18next.t("provider:Client auth hint") : undefined}
>
<SimpleSelect
- value={channel.authMode}
+ value={provider.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))
+ setProvider(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},
+ {label: i18next.t("provider:Stored API key"), value: "provider"},
+ {label: i18next.t("provider:Caller's own login"), value: authClient},
]}
/>
</Field>
- {usesClientAuth(channel) ? null : (
+ {usesClientAuth(provider) ? null : (
<Field
- label={i18next.t("channel:API Key")}
- htmlFor="channel-api-key"
- hint={i18next.t("channel:API Key hint")}
+ label={i18next.t("provider:API Key")}
+ htmlFor="provider-api-key"
+ hint={i18next.t("provider:API Key hint")}
>
<PasswordInput
- id="channel-api-key"
+ id="provider-api-key"
placeholder="sk-..."
- value={channel.apiKey}
+ value={provider.apiKey}
onChange={event => setField("apiKey", event.target.value)}
/>
</Field>
)}
- <ChannelModelsField
- channel={channel}
- hint={usesClientAuth(channel) ? i18next.t("channel:Any model hint") : undefined}
+ <ProviderModelsField
+ provider={provider}
+ hint={usesClientAuth(provider) ? i18next.t("provider:Any model hint") : undefined}
onChange={value => setField("models", value)}
/>
- <Field label={i18next.t("channel:Priority")} hint={i18next.t("channel:Priority hint")}>
- <NumberInput min={0} value={channel.priority} onChange={value => setField("priority", value)} />
+ <Field label={i18next.t("provider:Priority")} hint={i18next.t("provider:Priority hint")}>
+ <NumberInput min={0} value={provider.priority} onChange={value => setField("priority", value)} />
</Field>
- <Field label={i18next.t("channel:Status")}>
+ <Field label={i18next.t("provider:Status")}>
<SimpleSelect
- value={channel.status}
+ value={provider.status}
onChange={value => setField("status", value)}
options={[
{
label: (
<span className="flex items-center gap-2">
<CircleCheck className="text-success size-4" />
- {i18next.t("channel:Enabled")}
+ {i18next.t("provider:Enabled")}
</span>
),
value: "enabled",
@@ -306,7 +306,7 @@ export default function ChannelEditPage() {
label: (
<span className="flex items-center gap-2">
<CircleX className="text-destructive size-4" />
- {i18next.t("channel:Disabled")}
+ {i18next.t("provider:Disabled")}
</span>
),
value: "disabled",
@@ -316,17 +316,17 @@ export default function ChannelEditPage() {
</Field>
</Section>
- <Section title={i18next.t("channel:Usage")} description={i18next.t("channel:Usage hint")} columns={1}>
+ <Section title={i18next.t("provider:Usage")} description={i18next.t("provider:Usage hint")} columns={1}>
<EnvSnippet
- protocol={channelProtocol(channel.type)}
- baseUrl={gatewayBaseUrl(channelProtocol(channel.type))}
+ protocol={providerProtocol(provider.type)}
+ baseUrl={gatewayBaseUrl(providerProtocol(provider.type))}
defaultShell={localShell()}
- includeToken={!usesClientAuth(channel)}
+ includeToken={!usesClientAuth(provider)}
/>
- {usesClientAuth(channel) ? (
- <p className="text-muted-foreground text-sm">{i18next.t("channel:Client auth usage hint")}</p>
+ {usesClientAuth(provider) ? (
+ <p className="text-muted-foreground text-sm">{i18next.t("provider:Client auth usage hint")}</p>
) : null}
- <p className="text-muted-foreground text-sm">{i18next.t("channel:Model routing hint")}</p>
+ <p className="text-muted-foreground text-sm">{i18next.t("provider:Model routing hint")}</p>
</Section>
</PageContainer>
);
diff --git a/web/src/pages/ChannelListPage.tsx b/web/src/pages/ProviderListPage.tsx
similarity index 68%
rename from web/src/pages/ChannelListPage.tsx
rename to web/src/pages/ProviderListPage.tsx
index 8aaf31c..51d1c31 100644
--- a/web/src/pages/ChannelListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -17,10 +17,10 @@ import {Link, useNavigate} from "react-router-dom";
import {ChevronDown, CircleCheck, CircleX, Pencil, Plug, Plus, RefreshCw, Trash2} from "lucide-react";
import i18next from "i18next";
-import * as ChannelBackend from "@/backend/ChannelBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
-import {ChannelModelsField} from "@/components/ChannelModelsField";
-import {ChannelSourcePicker, sourceTitle} from "@/components/ChannelSourcePicker";
+import {ProviderModelsField} from "@/components/ProviderModelsField";
+import {ProviderSourcePicker, sourceTitle} from "@/components/ProviderSourcePicker";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {DataTable, type Column, type SortOrder} from "@/components/shared/data-table";
import {Field, FormDialog} from "@/components/shared/form-dialog";
@@ -34,22 +34,22 @@ import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/compo
import {Input} from "@/components/ui/input";
import {SimpleTooltip} from "@/components/ui/tooltip";
import {
- authChannel,
+ authProvider,
authClient,
baseUrlPlaceholder,
baseUrlPresets,
customSource,
usesClientAuth,
- type ChannelSource,
-} from "@/lib/channels";
+ type ProviderSource,
+} from "@/lib/providers";
import {cn} from "@/lib/utils";
-import type {Account, Channel, ChannelHealth} from "@/types";
+import type {Account, Provider, ProviderHealth} from "@/types";
-function newChannel(owner: string, label = "New Channel"): Channel {
+function newProvider(owner: string, label = "New Provider"): Provider {
const randomName = Setting.getRandomName();
return {
owner: owner,
- name: `channel_${randomName}`,
+ name: `provider_${randomName}`,
displayName: `${label} - ${randomName}`,
type: "openai",
status: "enabled",
@@ -57,13 +57,13 @@ function newChannel(owner: string, label = "New Channel"): Channel {
priority: 0,
baseUrl: "",
apiKey: "",
- authMode: authChannel,
+ authMode: authProvider,
};
}
/** What a picked source leaves to fill in, which for a subscription is nothing. */
-function channelFromSource(owner: string, source: ChannelSource): Channel {
- return {...newChannel(owner, sourceTitle(source)), ...source.channel};
+function providerFromSource(owner: string, source: ProviderSource): Provider {
+ return {...newProvider(owner, sourceTitle(source)), ...source.provider};
}
/**
@@ -89,9 +89,9 @@ function Advanced({defaultOpen, children}: {defaultOpen: boolean; children: Reac
);
}
-export default function ChannelListPage({account}: {account: Account}) {
+export default function ProviderListPage({account}: {account: Account}) {
const navigate = useNavigate();
- const [data, setData] = React.useState<Channel[]>([]);
+ const [data, setData] = React.useState<Provider[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
@@ -103,16 +103,16 @@ export default function ChannelListPage({account}: {account: Account}) {
});
const [addOpen, setAddOpen] = React.useState(false);
const [adding, setAdding] = React.useState(false);
- const [form, setForm] = React.useState<Channel>(() => newChannel(account.name));
+ const [form, setForm] = React.useState<Provider>(() => newProvider(account.name));
// null while the dialog is still asking where the credentials come from.
- const [source, setSource] = React.useState<ChannelSource | null>(null);
+ const [source, setSource] = React.useState<ProviderSource | null>(null);
const [nameError, setNameError] = React.useState("");
- const [health, setHealth] = React.useState<ChannelHealth[]>([]);
+ const [health, setHealth] = React.useState<ProviderHealth[]>([]);
- const fetchChannels = React.useCallback(
+ const fetchProviders = React.useCallback(
(nextPage = page, nextPageSize = pageSize, nextSort = sort) => {
setLoading(true);
- ChannelBackend.getChannels(
+ ProviderBackend.getProviders(
account.name,
nextPage,
nextPageSize,
@@ -129,28 +129,28 @@ export default function ChannelListPage({account}: {account: Account}) {
setPageSize(nextPageSize);
setSort(nextSort);
} else {
- Setting.showMessage("error", `${i18next.t("channel:Failed to get channels")}: ${res.msg}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to get providers")}: ${res.msg}`);
}
})
.catch(error => {
setLoading(false);
setLoaded(true);
- Setting.showMessage("error", `${i18next.t("channel:Failed to get channels")}: ${error}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to get providers")}: ${error}`);
});
},
[account.name, page, pageSize, sort],
);
React.useEffect(() => {
- fetchChannels(1, 10, {field: "", order: undefined});
+ fetchProviders(1, 10, {field: "", order: undefined});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account.name]);
- // What the proxy has seen of each channel lives in memory and changes as
+ // What the proxy has seen of each provider lives in memory and changes as
// requests are relayed, so it is polled rather than read once.
React.useEffect(() => {
const load = () => {
- ChannelBackend.getChannelHealth()
+ ProviderBackend.getProviderHealth()
.then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
.catch(() => setHealth([]));
};
@@ -160,63 +160,63 @@ export default function ChannelListPage({account}: {account: Account}) {
return () => clearInterval(interval);
}, []);
- const openAddDialog = (start?: ChannelSource) => {
- setForm(start ? channelFromSource(account.name, start) : newChannel(account.name));
+ const openAddDialog = (start?: ProviderSource) => {
+ setForm(start ? providerFromSource(account.name, start) : newProvider(account.name));
setSource(start ?? null);
setNameError("");
setAddOpen(true);
};
- const setFormField = <K extends keyof Channel>(key: K, value: Channel[K]) => {
+ const setFormField = <K extends keyof Provider>(key: K, value: Provider[K]) => {
setForm(prev => ({...prev, [key]: value}));
};
- const pickSource = (picked: ChannelSource) => {
- setForm(channelFromSource(account.name, picked));
+ const pickSource = (picked: ProviderSource) => {
+ setForm(providerFromSource(account.name, picked));
setNameError("");
setSource(picked);
};
- const addChannel = () => {
+ const addProvider = () => {
const name = form.name.trim();
if (name === "") {
setNameError(i18next.t("general:Name cannot be empty"));
return;
}
setAdding(true);
- ChannelBackend.addChannel({...form, name: name})
+ ProviderBackend.addProvider({...form, name: name})
.then(res => {
setAdding(false);
if (res.status === "error") {
- Setting.showMessage("error", `${i18next.t("channel:Failed to add")}: ${res.msg}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to add")}: ${res.msg}`);
} else {
- Setting.showMessage("success", i18next.t("channel:Channel added successfully"));
+ Setting.showMessage("success", i18next.t("provider:Provider added successfully"));
setAddOpen(false);
- fetchChannels();
+ fetchProviders();
}
})
.catch(error => {
setAdding(false);
- Setting.showMessage("error", `${i18next.t("channel:Failed to add")}: ${error}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to add")}: ${error}`);
});
};
- const deleteChannel = (channel: Channel) => {
- ChannelBackend.deleteChannel(channel)
+ const deleteProvider = (provider: Provider) => {
+ ProviderBackend.deleteProvider(provider)
.then(res => {
if (res.status === "error") {
- Setting.showMessage("error", `${i18next.t("channel:Failed to delete")}: ${res.msg}`);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to delete")}: ${res.msg}`);
return;
}
- Setting.showMessage("success", i18next.t("channel:Channel deleted successfully"));
- fetchChannels();
+ Setting.showMessage("success", i18next.t("provider:Provider deleted successfully"));
+ fetchProviders();
})
.catch(error =>
- Setting.showMessage("error", `${i18next.t("channel:Failed to delete")}: ${error}`),
+ Setting.showMessage("error", `${i18next.t("provider:Failed to delete")}: ${error}`),
);
};
- const columns: Column<Channel>[] = [
+ const columns: Column<Provider>[] = [
{
title: i18next.t("general:Name"),
key: "name",
@@ -228,7 +228,7 @@ export default function ChannelListPage({account}: {account: Account}) {
render: (text: string, record) => (
<SimpleTooltip title={text}>
<Link
- to={`/channels/${record.owner}/${record.name}`}
+ to={`/providers/${record.owner}/${record.name}`}
className="text-primary block truncate font-medium hover:underline"
>
{text}
@@ -245,14 +245,14 @@ export default function ChannelListPage({account}: {account: Account}) {
render: (text: string) => (text ? <span className="block truncate">{text}</span> : "-"),
},
{
- title: i18next.t("channel:Type"),
+ title: i18next.t("provider:Type"),
key: "type",
dataIndex: "type",
width: "110px",
render: (text: string) => <Badge variant={text === "openai" ? "success" : "info"}>{text}</Badge>,
},
{
- title: i18next.t("channel:Base URL"),
+ title: i18next.t("provider:Base URL"),
key: "baseUrl",
dataIndex: "baseUrl",
width: "220px",
@@ -269,13 +269,13 @@ export default function ChannelListPage({account}: {account: Account}) {
),
},
{
- title: i18next.t("channel:Models"),
+ title: i18next.t("provider:Models"),
key: "models",
dataIndex: "models",
width: "220px",
render: (models: string[], record) =>
!models || models.length === 0 ? (
- usesClientAuth(record) ? <Badge variant="muted">{i18next.t("channel:Any model")}</Badge> : "-"
+ usesClientAuth(record) ? <Badge variant="muted">{i18next.t("provider:Any model")}</Badge> : "-"
) : (
<div className="flex flex-wrap gap-1">
{models.map(model => (
@@ -287,14 +287,14 @@ export default function ChannelListPage({account}: {account: Account}) {
),
},
{
- title: i18next.t("channel:Priority"),
+ title: i18next.t("provider:Priority"),
key: "priority",
dataIndex: "priority",
width: "100px",
sorter: true,
},
{
- title: i18next.t("channel:Status"),
+ title: i18next.t("provider:Status"),
key: "status",
dataIndex: "status",
width: "120px",
@@ -302,37 +302,37 @@ export default function ChannelListPage({account}: {account: Account}) {
text === "enabled" ? (
<Badge variant="success">
<CircleCheck />
- {i18next.t("channel:Enabled")}
+ {i18next.t("provider:Enabled")}
</Badge>
) : (
<Badge variant="muted">
<CircleX />
- {i18next.t("channel:Disabled")}
+ {i18next.t("provider:Disabled")}
</Badge>
),
},
{
- title: i18next.t("channel:Health"),
+ title: i18next.t("provider:Health"),
key: "health",
width: "150px",
render: (_text, record) => {
- const item = health.find(entry => entry.channel === `${record.owner}/${record.name}`);
+ const item = health.find(entry => entry.provider === `${record.owner}/${record.name}`);
if (!item) {
- return <span className="text-muted-foreground">{i18next.t("channel:Not used yet")}</span>;
+ return <span className="text-muted-foreground">{i18next.t("provider:Not used yet")}</span>;
}
- // A channel that is out of its cooldown but whose last attempts failed
+ // A provider that is out of its cooldown but whose last attempts failed
// is back in rotation without having proven anything yet.
const badge = !item.healthy ? (
<Badge variant="warning">
<CircleX />
- {i18next.t("channel:Cooling down")}
+ {i18next.t("provider:Cooling down")}
</Badge>
) : item.consecutive > 0 ? (
- <Badge variant="muted">{i18next.t("channel:Recovering")}</Badge>
+ <Badge variant="muted">{i18next.t("provider:Recovering")}</Badge>
) : (
<Badge variant="success">
<CircleCheck />
- {i18next.t("channel:Healthy")}
+ {i18next.t("provider:Healthy")}
</Badge>
);
return (
@@ -340,7 +340,7 @@ export default function ChannelListPage({account}: {account: Account}) {
title={
item.healthy
? `${item.successes} / ${item.successes + item.failures}`
- : `${item.lastError} · ${i18next.t("channel:Retried at")} ${item.retryTime}`
+ : `${item.lastError} · ${i18next.t("provider:Retried at")} ${item.retryTime}`
}
>
{badge}
@@ -354,14 +354,14 @@ export default function ChannelListPage({account}: {account: Account}) {
width: "190px",
render: (_text, record) => (
<div className="flex gap-2">
- <Button size="sm" variant="outline" onClick={() => navigate(`/channels/${record.owner}/${record.name}`)}>
+ <Button size="sm" variant="outline" onClick={() => navigate(`/providers/${record.owner}/${record.name}`)}>
<Pencil />
{i18next.t("general:Edit")}
</Button>
<ConfirmDialog
title={i18next.t("general:Sure to delete {name} ?").replace("{name}", record.name)}
confirmText={i18next.t("general:Delete")}
- onConfirm={() => deleteChannel(record)}
+ onConfirm={() => deleteProvider(record)}
>
<Button size="sm" variant="outline" className="text-destructive">
<Trash2 />
@@ -376,12 +376,12 @@ export default function ChannelListPage({account}: {account: Account}) {
return (
<PageContainer>
<PageHeader
- title={i18next.t("channel:Channels")}
- description={i18next.t("channel:Page description")}
+ title={i18next.t("provider:Providers")}
+ description={i18next.t("provider:Page description")}
actions={
<Button onClick={() => openAddDialog()}>
<Plus />
- {i18next.t("channel:New Channel")}
+ {i18next.t("provider:New Provider")}
</Button>
}
/>
@@ -391,33 +391,33 @@ export default function ChannelListPage({account}: {account: Account}) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plug className="size-4" />
- {i18next.t("channel:No channels yet")}
+ {i18next.t("provider:No providers yet")}
</CardTitle>
- <CardDescription>{i18next.t("channel:No channels yet detail")}</CardDescription>
+ <CardDescription>{i18next.t("provider:No providers yet detail")}</CardDescription>
</CardHeader>
<CardContent>
- <ChannelSourcePicker onPick={openAddDialog} />
+ <ProviderSourcePicker onPick={openAddDialog} />
</CardContent>
</Card>
) : (
<DataTable
columns={columns}
dataSource={data}
- // An admin sees channels across owners, where names may collide.
+ // An admin sees providers across owners, where names may collide.
rowKey={record => `${record.owner}/${record.name}`}
loading={loading}
- onSort={(field, order) => fetchChannels(1, pageSize, {field: field, order: order})}
+ onSort={(field, order) => fetchProviders(1, pageSize, {field: field, order: order})}
serverPagination={{
page: page,
pageSize: pageSize,
total: total,
- onChange: (nextPage, nextPageSize) => fetchChannels(nextPage, nextPageSize),
+ onChange: (nextPage, nextPageSize) => fetchProviders(nextPage, nextPageSize),
}}
- title={i18next.t("channel:Channels")}
- description={`${total} ${i18next.t("channel:Channels")}`}
+ title={i18next.t("provider:Providers")}
+ description={`${total} ${i18next.t("provider:Providers")}`}
emptyIcon={Plug}
toolbar={
- <Button variant="outline" size="sm" onClick={() => fetchChannels()} loading={loading}>
+ <Button variant="outline" size="sm" onClick={() => fetchProviders()} loading={loading}>
<RefreshCw />
{i18next.t("general:Refresh")}
</Button>
@@ -428,11 +428,11 @@ export default function ChannelListPage({account}: {account: Account}) {
<FormDialog
open={addOpen}
onOpenChange={setAddOpen}
- title={i18next.t("channel:New Channel")}
- description={source === null ? i18next.t("channel:Source hint") : undefined}
+ title={i18next.t("provider:New Provider")}
+ description={source === null ? i18next.t("provider:Source hint") : undefined}
size={source === null ? "lg" : "default"}
submitting={adding}
- onSubmit={addChannel}
+ onSubmit={addProvider}
// Nothing is filled in yet while the source is still being picked, so
// there is nothing to submit.
footer={
@@ -444,23 +444,23 @@ export default function ChannelListPage({account}: {account: Account}) {
}
>
{source === null ? (
- <ChannelSourcePicker onPick={pickSource} />
+ <ProviderSourcePicker onPick={pickSource} />
) : (
<>
- <Field label={i18next.t("channel:Source")}>
+ <Field label={i18next.t("provider:Source")}>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="info">{sourceTitle(source)}</Badge>
{usesClientAuth(form) ? (
- <Badge variant="muted">{i18next.t("channel:Caller's own login")}</Badge>
+ <Badge variant="muted">{i18next.t("provider:Caller's own login")}</Badge>
) : null}
<Button type="button" size="xs" variant="ghost" onClick={() => setSource(null)}>
- {i18next.t("channel:Change source")}
+ {i18next.t("provider:Change source")}
</Button>
</div>
</Field>
- <Field label={i18next.t("general:Name")} htmlFor="channel-name" required error={nameError}>
+ <Field label={i18next.t("general:Name")} htmlFor="provider-name" required error={nameError}>
<Input
- id="channel-name"
+ id="provider-name"
value={form.name}
onChange={event => {
setFormField("name", event.target.value);
@@ -468,34 +468,34 @@ export default function ChannelListPage({account}: {account: Account}) {
}}
/>
</Field>
- <Field label={i18next.t("general:Display name")} htmlFor="channel-display-name">
+ <Field label={i18next.t("general:Display name")} htmlFor="provider-display-name">
<Input
- id="channel-display-name"
+ id="provider-display-name"
value={form.displayName}
onChange={event => setFormField("displayName", event.target.value)}
/>
</Field>
{usesClientAuth(form) ? null : (
<Field
- label={i18next.t("channel:API Key")}
- htmlFor="channel-api-key"
- hint={i18next.t("channel:API Key ownership hint")}
+ label={i18next.t("provider:API Key")}
+ htmlFor="provider-api-key"
+ hint={i18next.t("provider:API Key ownership hint")}
>
<PasswordInput
- id="channel-api-key"
+ id="provider-api-key"
placeholder="sk-..."
value={form.apiKey}
onChange={event => setFormField("apiKey", event.target.value)}
/>
</Field>
)}
- <ChannelModelsField
- channel={form}
- hint={usesClientAuth(form) ? i18next.t("channel:Any model hint") : i18next.t("channel:Models hint")}
+ <ProviderModelsField
+ provider={form}
+ hint={usesClientAuth(form) ? i18next.t("provider:Any model hint") : i18next.t("provider:Models hint")}
onChange={value => setFormField("models", value)}
/>
<Advanced key={source.key} defaultOpen={source.key === customSource}>
- <Field label={i18next.t("channel:Type")}>
+ <Field label={i18next.t("provider:Type")}>
<SimpleSelect
value={form.type}
onChange={value => setFormField("type", value)}
@@ -506,10 +506,10 @@ export default function ChannelListPage({account}: {account: Account}) {
]}
/>
</Field>
- <Field label={i18next.t("channel:Base URL")} htmlFor="channel-base-url">
+ <Field label={i18next.t("provider:Base URL")} htmlFor="provider-base-url">
<SearchSelect
allowCustomValue
- id="channel-base-url"
+ id="provider-base-url"
value={form.baseUrl}
placeholder={baseUrlPlaceholder(form.type)}
options={baseUrlPresets(form.type)}
@@ -517,16 +517,16 @@ export default function ChannelListPage({account}: {account: Account}) {
/>
</Field>
<Field
- label={i18next.t("channel:Authentication")}
- hint={usesClientAuth(form) ? i18next.t("channel:Client auth hint") : undefined}
+ label={i18next.t("provider:Authentication")}
+ hint={usesClientAuth(form) ? i18next.t("provider:Client auth hint") : undefined}
>
<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: authChannel},
- {label: i18next.t("channel:Caller's own login"), value: authClient},
+ {label: i18next.t("provider:Stored API key"), value: authProvider},
+ {label: i18next.t("provider:Caller's own login"), value: authClient},
]}
/>
</Field>
diff --git a/web/src/pages/SiteEditPage.tsx b/web/src/pages/SiteEditPage.tsx
index c1c7c6c..f9ef1fc 100644
--- a/web/src/pages/SiteEditPage.tsx
+++ b/web/src/pages/SiteEditPage.tsx
@@ -97,7 +97,7 @@ export default function SiteEditPage({account}: {account: Account}) {
setApplications(res.data ?? []);
}
});
- MiscBackend.getProviders().then(res => {
+ MiscBackend.getCasdoorProviders().then(res => {
if (res.status === "ok") {
// Only the notification providers can be alert targets.
setProviders(
diff --git a/web/src/types.ts b/web/src/types.ts
index 67f4ac3..eccc158 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -146,7 +146,7 @@ export interface Record {
userAgent: string;
}
-export interface Channel {
+export interface Provider {
owner: string;
name: string;
displayName: string;
@@ -159,7 +159,7 @@ export interface Channel {
authMode: string;
}
-export interface ChannelTestResult {
+export interface ProviderTestResult {
success: boolean;
statusCode?: number;
message: string;
@@ -173,10 +173,10 @@ export interface AgentProviderFile {
}
/** The state of the agent's own configuration file, written by the orchestrator. */
-export interface AgentProvider {
+export interface AgentProviderConfig {
supported: boolean;
applied: boolean;
- channel: string;
+ provider: string;
mode: string;
baseUrl: string;
time: string;
@@ -196,25 +196,25 @@ export interface Agent {
detail?: string;
notice?: string;
followup?: string;
- /** The "owner/name" id of the channel this agent's requests are sent to. */
- channel: string;
- /** The channels tried, in order, when the bound one cannot answer. */
+ /** The "owner/name" id of the provider this agent's requests are sent to. */
+ provider: string;
+ /** The providers tried, in order, when the bound one cannot answer. */
fallbacks: string[];
/** "gateway" routes through the local proxy, "direct" writes the upstream. */
mode: string;
- provider: AgentProvider;
+ providerConfig: AgentProviderConfig;
}
-/** What the proxy has seen of one channel since Gateway started. */
-export interface ChannelHealth {
- channel: string;
+/** What the proxy has seen of one provider since Gateway started. */
+export interface ProviderHealth {
+ provider: string;
healthy: boolean;
successes: number;
failures: number;
consecutive: number;
lastError: string;
lastFailure: string;
- /** When a suspended channel is tried again, empty while it is healthy. */
+ /** When a suspended provider is tried again, empty while it is healthy. */
retryTime: string;
}
@@ -256,7 +256,7 @@ export interface LlmRecord {
protocol: string;
endpoint: string;
model: string;
- channel: string;
+ provider: string;
agent: string;
clientIp: string;
stream: boolean;
@@ -308,8 +308,8 @@ export interface LlmModelStat {
cost: number;
}
-export interface LlmChannelStat {
- channel: string;
+export interface LlmProviderStat {
+ provider: string;
requests: number;
failed: number;
tokens: number;
@@ -328,7 +328,7 @@ export interface LlmRecordStats {
/** Records whose model has no price entry. */
unpriced: number;
models: LlmModelStat[];
- channels: LlmChannelStat[];
+ providers: LlmProviderStat[];
}
export interface MetricPoint {
@@ -336,7 +336,7 @@ export interface MetricPoint {
count: number;
}
-export interface Provider {
+export interface CasdoorProvider {
name: string;
category: string;
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org