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/12 07:26:37 UTC

(casbin-gateway) branch master updated: feat: add channel management for LLM API gateway (#187)

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 df64d9c  feat: add channel management for LLM API gateway (#187)
df64d9c is described below

commit df64d9c2feb67b5a33bdfc07c243fc118b775b09
Author: Ziyang Dong <ba...@gmail.com>
AuthorDate: Wed Aug 12 15:26:32 2026 +0800

    feat: add channel management for LLM API gateway (#187)
---
 controllers/channel.go            | 182 ++++++++++++++++++++++++
 object/channel.go                 | 260 +++++++++++++++++++++++++++++++++
 object/ormer.go                   |   6 +
 routers/router.go                 |   8 ++
 web/src/App.js                    |  15 ++
 web/src/App.less                  |  10 +-
 web/src/ChannelEditPage.js        | 292 ++++++++++++++++++++++++++++++++++++++
 web/src/ChannelListPage.js        | 233 ++++++++++++++++++++++++++++++
 web/src/backend/ChannelBackend.js |  53 +++++++
 web/src/locales/en/data.json      |  26 +++-
 web/src/locales/zh/data.json      |  26 +++-
 11 files changed, 1108 insertions(+), 3 deletions(-)

diff --git a/controllers/channel.go b/controllers/channel.go
new file mode 100644
index 0000000..b085a8d
--- /dev/null
+++ b/controllers/channel.go
@@ -0,0 +1,182 @@
+// 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"
+
+	"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())
+}
+
+// GetChannels returns a paginated list of channels with tenant isolation.
+func (c *ApiController) GetChannels() {
+	if c.RequireSignedIn() {
+		return
+	}
+
+	user := c.GetSessionUser()
+	owner := c.Input().Get("owner")
+
+	if !user.IsAdmin || owner == "" || owner == "admin" {
+		if !user.IsAdmin {
+			owner = c.GetSessionUsername()
+		} else if owner == "" || owner == "admin" {
+			owner = ""
+		}
+	}
+
+	limit := c.Input().Get("pageSize")
+	page := c.Input().Get("p")
+	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)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	p := pagination.SetPaginator(c.Ctx, limitInt, count)
+	channels, err := object.GetPaginationChannels(owner, p.Offset(), limitInt)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	c.ResponseOk(object.GetMaskedChannels(channels), p.Nums())
+}
+
+// GetChannel returns a single channel by owner/name id.
+func (c *ApiController) GetChannel() {
+	if c.RequireSignedIn() {
+		return
+	}
+
+	id := c.Input().Get("id")
+	owner, _ := util.GetOwnerAndNameFromId(id)
+	if !c.channelAccess(owner) {
+		c.ResponseError("unauthorized")
+		return
+	}
+
+	channel, err := object.GetChannel(id)
+	if err != nil {
+		c.ResponseError(err.Error())
+		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
+	}
+
+	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, _ := util.GetOwnerAndNameFromId(id)
+	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()
+}
+
+// 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 !c.channelAccess(request.Owner) {
+		c.ResponseError("unauthorized")
+		return
+	}
+
+	channel := &object.Channel{Owner: request.Owner, Name: request.Name}
+	ok, code, msg := object.TestChannelConnectivity(channel, "")
+	c.ResponseOk(map[string]interface{}{"success": ok, "statusCode": code, "message": msg})
+}
diff --git a/object/channel.go b/object/channel.go
new file mode 100644
index 0000000..e12e8d4
--- /dev/null
+++ b/object/channel.go
@@ -0,0 +1,260 @@
+// 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 object
+
+import (
+	"context"
+	"crypto/tls"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/apache/casbin-gateway/util"
+	"github.com/xorm-io/core"
+)
+
+// Channel is an upstream AI provider channel. (Milestone 1.1)
+type Channel 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"`
+	UpdatedTime string   `xorm:"varchar(100)" json:"updatedTime"`
+	DisplayName string   `xorm:"varchar(100)" json:"displayName"`
+	Type        string   `xorm:"varchar(100)" json:"type"`
+	BaseUrl     string   `xorm:"varchar(255)" json:"baseUrl"`
+	// TODO(1.3): ApiKey is stored as plaintext; AES encryption will be added in milestone 1.3.
+	ApiKey  string   `xorm:"varchar(500)" json:"apiKey"`
+	Models  []string `xorm:"varchar(500)" json:"models"`
+	// TODO(1.2): Priority routing strategy will be defined in milestone 1.2.
+	Priority int    `xorm:"int" json:"priority"`
+	Status   string `xorm:"varchar(100)" json:"status"`
+}
+
+func GetChannels(owner string) ([]*Channel, error) {
+	channels := []*Channel{}
+	engine := ormer.Engine.Desc("created_time")
+	if owner != "" {
+		engine = engine.Where("owner = ?", owner)
+	}
+	err := engine.Find(&channels)
+	return channels, err
+}
+
+func GetChannelCount(owner string) (int64, error) {
+	if owner == "" {
+		return ormer.Engine.Count(&Channel{})
+	}
+	return ormer.Engine.Where("owner = ?", owner).Count(&Channel{})
+}
+
+func GetPaginationChannels(owner string, offset, limit int) ([]*Channel, error) {
+	channels := []*Channel{}
+	engine := ormer.Engine.Desc("created_time").Limit(limit, offset)
+	if owner != "" {
+		engine = engine.Where("owner = ?", owner)
+	}
+	err := engine.Find(&channels)
+	return channels, err
+}
+
+func getChannel(owner, name string) (*Channel, error) {
+	channel := &Channel{Owner: owner, Name: name}
+	ok, err := ormer.Engine.Get(channel)
+	if err != nil {
+		return nil, err
+	}
+	if !ok {
+		return nil, nil
+	}
+	return channel, nil
+}
+
+func GetChannel(id string) (*Channel, error) {
+	owner, name := util.GetOwnerAndNameFromId(id)
+	return getChannel(owner, name)
+}
+
+func GetMaskedChannel(channel *Channel) *Channel {
+	if channel != nil && channel.ApiKey != "" {
+		channel.ApiKey = "***"
+	}
+	return channel
+}
+
+func GetMaskedChannels(channels []*Channel) []*Channel {
+	for _, channel := range channels {
+		GetMaskedChannel(channel)
+	}
+	return channels
+}
+
+func validateChannel(channel *Channel) error {
+	if channel.Type == "" {
+		channel.Type = "openai"
+	}
+	if channel.Status == "" {
+		channel.Status = "enabled"
+	}
+	if channel.Models == nil {
+		channel.Models = []string{}
+	}
+
+	if channel.Type != "openai" && channel.Type != "claude" && channel.Type != "gemini" && channel.Type != "custom" {
+		return fmt.Errorf("invalid channel type")
+	}
+	if channel.Status != "enabled" && channel.Status != "disabled" {
+		return fmt.Errorf("invalid channel status")
+	}
+	if len(strings.Join(channel.Models, "")) > 500 {
+		return fmt.Errorf("models exceed 500 characters")
+	}
+
+	return nil
+}
+
+func AddChannel(channel *Channel) (bool, error) {
+	if err := validateChannel(channel); err != nil {
+		return false, err
+	}
+
+	now := util.GetCurrentTime()
+	if channel.CreatedTime == "" {
+		channel.CreatedTime = now
+	}
+	channel.UpdatedTime = now
+
+	n, err := ormer.Engine.Insert(channel)
+	return n != 0, err
+}
+
+func UpdateChannel(id string, channel *Channel) (bool, error) {
+	owner, name := util.GetOwnerAndNameFromId(id)
+
+	if err := validateChannel(channel); err != nil {
+		return false, err
+	}
+
+	channel.Owner = owner
+	channel.Name = name
+	channel.UpdatedTime = util.GetCurrentTime()
+
+	var n int64
+	var err error
+	if channel.ApiKey == "***" || channel.ApiKey == "" {
+		n, err = ormer.Engine.ID(core.PK{owner, name}).Omit("api_key").AllCols().Update(channel)
+	} else {
+		n, err = ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(channel)
+	}
+
+	return n != 0, err
+}
+
+func DeleteChannel(channel *Channel) (bool, error) {
+	n, err := ormer.Engine.ID(core.PK{channel.Owner, channel.Name}).Delete(&Channel{})
+	return n != 0, err
+}
+
+func isAllowedPort(port int) bool {
+	return port == 80 || port == 443 || (port >= 8000 && port <= 9999)
+}
+
+func TestChannelConnectivity(channel *Channel, apiKeyOverride string) (bool, int, string) {
+	stored, err := getChannel(channel.Owner, channel.Name)
+	if err != nil || stored == nil {
+		return false, 0, "channel not found"
+	}
+
+	if channel.Type == "claude" {
+		return false, 0, "claude type not yet supported in this stage"
+	}
+	if channel.Type == "gemini" {
+		return false, 0, "gemini type not yet supported in this stage"
+	}
+
+	key := apiKeyOverride
+	if key == "" || key == "***" {
+		key = stored.ApiKey
+	}
+
+	u, err := url.Parse(stored.BaseUrl)
+	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
+		return false, 0, "connection blocked by security policy"
+	}
+
+	path := strings.TrimRight(stored.BaseUrl, "/")
+	if stored.Type == "openai" {
+		path += "/v1/models"
+	}
+
+	req, err := http.NewRequest(http.MethodGet, path, nil)
+	if err != nil {
+		return false, 0, "connection blocked by security policy"
+	}
+	if stored.Type == "openai" {
+		req.Header.Set("Authorization", "Bearer "+key)
+	}
+
+	host := u.Hostname()
+	transport := &http.Transport{
+		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+			_, port, e := net.SplitHostPort(addr)
+			if e != nil {
+				return nil, fmt.Errorf("connection blocked by security policy")
+			}
+
+			p, e := strconv.Atoi(port)
+			if e != nil || !isAllowedPort(p) {
+				return nil, fmt.Errorf("connection blocked by security policy")
+			}
+
+			ips, e := net.LookupIP(host)
+			if e != nil || len(ips) == 0 {
+				return nil, fmt.Errorf("connection blocked by security policy")
+			}
+
+			for _, ip := range ips {
+				if util.IsIntranetIp(ip.String()) {
+					return nil, fmt.Errorf("connection blocked by security policy")
+				}
+			}
+
+			return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
+		},
+	}
+
+	if u.Scheme == "https" {
+		transport.TLSClientConfig = &tls.Config{ServerName: host}
+	}
+
+	client := &http.Client{
+		Transport:     transport,
+		Timeout:       10 * time.Second,
+		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return false, 0, "connection blocked by security policy"
+	}
+	defer resp.Body.Close()
+
+	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+	return resp.StatusCode >= 200 && resp.StatusCode < 300, resp.StatusCode, resp.Status
+}
diff --git a/object/ormer.go b/object/ormer.go
index cc31a0c..71f7910 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -203,4 +203,10 @@ func (a *Ormer) createTable() {
 	if err != nil {
 		panic(err)
 	}
+
+	// Register Channel table for LLM gateway milestone 1.1.
+	err = a.Engine.Sync2(new(Channel))
+	if err != nil {
+		panic(err)
+	}
 }
diff --git a/routers/router.go b/routers/router.go
index e1fa18c..9e6e03f 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -75,4 +75,12 @@ func initAPI() {
 	beego.Router("/api/add-rule", &controllers.ApiController{}, "POST:AddRule")
 	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/test-channel", &controllers.ApiController{}, "POST:TestChannel")
 }
diff --git a/web/src/App.js b/web/src/App.js
index b7bab50..3055064 100644
--- a/web/src/App.js
+++ b/web/src/App.js
@@ -30,6 +30,8 @@ import CertListPage from "./CertListPage";
 import CertEditPage from "./CertEditPage";
 import RuleListPage from "./RuleListPage";
 import RuleEditPage from "./RuleEditPage";
+import ChannelListPage from "./ChannelListPage";
+import ChannelEditPage from "./ChannelEditPage";
 import SigninPage from "./SigninPage";
 import RecordListPage from "./RecordListPage";
 import RecordEditPage from "./RecordEditPage";
@@ -89,6 +91,8 @@ class App extends Component {
       this.setState({selectedMenuKey: "/records"});
     } else if (uri.includes("/rules")) {
       this.setState({selectedMenuKey: "/rules"});
+    } else if (uri.includes("/channels")) {
+      this.setState({selectedMenuKey: "/channels"});
     } else {
       this.setState({selectedMenuKey: "null"});
     }
@@ -319,6 +323,14 @@ class App extends Component {
         </Link>
       </Menu.Item>
     );
+    // Mount Channel menu for LLM gateway milestone 1.1.
+    res.push(
+      <Menu.Item key="/channels">
+        <Link to="/channels">
+          {i18next.t("channel:Channels")}
+        </Link>
+      </Menu.Item>
+    );
     return res;
   }
 
@@ -383,6 +395,9 @@ class App extends Component {
           <Route exact path="/records/:owner/:id" render={(props) => this.renderSigninIfNotSignedIn(<RecordEditPage account={this.state.account} {...props} />)} />
           <Route exact path="/rules" render={(props) => this.renderSigninIfNotSignedIn(<RuleListPage account={this.state.account} {...props} />)} />
           <Route exact path="/rules/:owner/:ruleName" render={(props) => this.renderSigninIfNotSignedIn(<RuleEditPage account={this.state.account} {...props} />)} />
+          {/* Channel routes for LLM gateway milestone 1.1. */}
+          <Route exact path="/channels" render={(props) => this.renderSigninIfNotSignedIn(<ChannelListPage account={this.state.account} {...props} />)} />
+          <Route exact path="/channels/:owner/:channelName" render={(props) => this.renderSigninIfNotSignedIn(<ChannelEditPage account={this.state.account} {...props} />)} />
           <Route exact path="/dashboard" render={(props) => this.renderSigninIfNotSignedIn(<DashboardPage account={this.state.account} {...props} />)} />
         </Switch>
       </div>
diff --git a/web/src/App.less b/web/src/App.less
index b994f0c..d4b3288 100644
--- a/web/src/App.less
+++ b/web/src/App.less
@@ -76,4 +76,12 @@
   &:hover {
     background-color: #f5f5f5a5 !important;
   }
-}
\ No newline at end of file
+}
+
+/* Global table cell padding (applies to all antd tables). */
+.ant-table tfoot>tr>td,
+.ant-table tfoot>tr>th,
+.ant-table-tbody>tr>td,
+.ant-table-thead>tr>th {
+  padding: 8px;
+}
diff --git a/web/src/ChannelEditPage.js b/web/src/ChannelEditPage.js
new file mode 100644
index 0000000..df135e6
--- /dev/null
+++ b/web/src/ChannelEditPage.js
@@ -0,0 +1,292 @@
+// 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.
+
+import React from "react";
+import {Alert, AutoComplete, Button, Card, Col, Input, InputNumber, Row, Select, Space} from "antd";
+import {ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, EyeInvisibleOutlined, EyeOutlined, SaveOutlined, ThunderboltOutlined} from "@ant-design/icons";
+import i18next from "i18next";
+import * as ChannelBackend from "./backend/ChannelBackend";
+import * as Setting from "./Setting";
+
+const {Option} = Select;
+
+// Hard-coded presets for milestone 1.1 (per channel type).
+const BASE_URL_PRESETS = {
+  openai: ["https://api.openai.com/v1"],
+  claude: ["https://api.anthropic.com/v1"],
+  gemini: ["https://generativelanguage.googleapis.com/v1beta"],
+  custom: ["https://oneapi.example.com", "https://api.deepseek.com/v1", "https://api.moonshot.cn/v1"],
+};
+
+const MODEL_PRESETS = {
+  openai: ["gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "o4-mini"],
+  claude: ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"],
+  gemini: ["gemini-3.6-flash", "gemini-3.5-flash-lite", "gemini-2.5-pro"],
+  custom: ["deepseek-chat", "deepseek-reasoner", "moonshot-v1-8k", "qwen-max"],
+};
+
+class ChannelEditPage extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      channel: null,
+      loading: false,
+      result: null,
+      apiKeyVisible: false,
+    };
+  }
+
+  componentDidMount() {
+    this.load();
+  }
+
+  load() {
+    const {owner, channelName} = this.props.match.params;
+    ChannelBackend.getChannel(owner, channelName).then(res => {
+      if (res.status === "ok") {
+        this.setState({
+          channel: res.data,
+        });
+      }
+    });
+  }
+
+  setChannelField(key, value) {
+    this.setState({
+      channel: {
+        ...this.state.channel,
+        [key]: value,
+      },
+    });
+  }
+
+  save() {
+    const {owner, channelName} = this.props.match.params;
+    ChannelBackend.updateChannel(owner, channelName, this.state.channel).then(() => {
+      Setting.showMessage("success", i18next.t("channel:Channel saved"));
+    });
+  }
+
+  test() {
+    const {owner, channelName} = this.props.match.params;
+    this.setState({
+      loading: true,
+      result: null,
+    });
+    ChannelBackend.testChannel(owner, channelName).then(res => {
+      this.setState({
+        loading: false,
+        result: res.data,
+      });
+    });
+  }
+
+  toggleApiKeyVisible = () => {
+    this.setState({apiKeyVisible: !this.state.apiKeyVisible});
+  };
+
+  render() {
+    const channel = this.state.channel;
+    if (!channel) {
+      return null;
+    }
+
+    return (
+      <Card
+        title={
+          <span>
+            <ApiOutlined style={{marginRight: "8px", color: "#1890ff"}} />
+            {i18next.t("channel:Edit Channel")}: {channel.displayName}
+          </span>
+        }
+        extra={
+          <Space>
+            <Button
+              type="primary"
+              icon={<SaveOutlined />}
+              onClick={this.save.bind(this)}
+            >
+              {i18next.t("general:Save")}
+            </Button>
+            <Button
+              icon={<ThunderboltOutlined />}
+              loading={this.state.loading}
+              onClick={this.test.bind(this)}
+            >
+              {i18next.t("channel:Test Connectivity")}
+            </Button>
+          </Space>
+        }
+      >
+        <Row style={{marginTop: "10px"}}>
+          <Col style={{marginTop: "5px"}} span={2}>
+            {i18next.t("general:Display name")}:
+          </Col>
+          <Col span={22}>
+            <Input
+              value={channel.displayName}
+              onChange={e => {
+                this.setChannelField("displayName", e.target.value);
+              }}
+            />
+          </Col>
+        </Row>
+
+        <Row style={{marginTop: "20px"}}>
+          <Col style={{marginTop: "5px"}} span={2}>
+            {i18next.t("channel:Type")}:
+          </Col>
+          <Col span={22}>
+            <Select
+              style={{width: "100%"}}
+              value={channel.type}
+              onChange={value => {
+                this.setChannelField("type", value);
+              }}
+            >
+              <Option value="openai">OpenAI</Option>
+              <Option value="claude">Claude (Anthropic)</Option>
+              <Option value="gemini">Gemini (Google)</Option>
+              <Option value="custom">Custom</Option>
+            </Select>
+          </Col>
+        </Row>
+
+        <Row style={{marginTop: "20px"}}>
+          <Col style={{marginTop: "5px"}} span={2}>
+            {i18next.t("channel:Base URL")}:
+          </Col>
+          <Col span={22}>
+            <AutoComplete
+              style={{width: "100%"}}
+              value={channel.baseUrl}
+              options={(BASE_URL_PRESETS[channel.type] || []).map(url => ({value: url, label: url}))}
+              onChange={value => {
+                this.setChannelField("baseUrl", value);
+              }}
+              placeholder="https://api.openai.com/v1"
+            />
+          </Col>
+        </Row>
+
+        <Row style={{marginTop: "20px"}}>
+          <Col style={{marginTop: "5px"}} span={2}>
+            {i18next.t("channel:API Key")}:
+          </Col>
+          <Col span={22}>
+            <Input
+              placeholder="sk-..."
+              value={channel.apiKey}
+              onChange={e => {
+                this.setChannelField("apiKey", e.target.value);
+              }}
+              suffix={
+                <span
+                  onClick={this.toggleApiKeyVisible}
+                  style={{cursor: "pointer", color: "#999"}}
+                >
+                  {this.state.apiKeyVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
+                </span>
+              }
+              type={this.state.apiKeyVisible ? "text" : "password"}
+            />
+          </Col>
+        </Row>
+
+        <Row style={{marginTop: "20px"}}>
+          <Col style={{marginTop: "5px"}} span={2}>
+            {i18next.t("channel:Models")}:
+          </Col>
+          <Col span={22}>
+            <Select
+              mode="tags"
+              style={{width: "100%"}}
+              placeholder="gpt-4, gpt-3.5-turbo"
+              value={channel.models}
+              onChange={value => {
+                this.setChannelField("models", value);
+              }}
+            >
+              {
+                (MODEL_PRESETS[channel.type] || []).map(model => (
+                  <Option key={model} value={model}>{model}</Option>
+                ))
+              }
+            </Select>
+          </Col>
+        </Row>
+
+        <Row style={{marginTop: "20px"}}>
+          <Col style={{marginTop: "5px"}} span={2}>
+            {i18next.t("channel:Priority")}:
+          </Col>
+          <Col span={22}>
+            <InputNumber
+              min={0}
+              value={channel.priority}
+              onChange={value => {
+                this.setChannelField("priority", value);
+              }}
+            />
+            <div style={{color: "#888", fontSize: "12px", marginTop: "4px"}}>
+              {i18next.t("channel:Priority hint")}
+            </div>
+          </Col>
+        </Row>
+
+        <Row style={{marginTop: "20px"}}>
+          <Col style={{marginTop: "5px"}} span={2}>
+            {i18next.t("channel:Status")}:
+          </Col>
+          <Col span={22}>
+            <Select
+              style={{width: "100%"}}
+              value={channel.status}
+              onChange={value => {
+                this.setChannelField("status", value);
+              }}
+            >
+              <Option value="enabled">
+                <CheckCircleOutlined style={{color: "#52c41a", marginRight: "8px"}} />
+                {i18next.t("channel:Enabled")}
+              </Option>
+              <Option value="disabled">
+                <CloseCircleOutlined style={{color: "#ff4d4f", marginRight: "8px"}} />
+                {i18next.t("channel:Disabled")}
+              </Option>
+            </Select>
+          </Col>
+        </Row>
+
+        {
+          this.state.result ? (
+            <Row style={{marginTop: "20px"}}>
+              <Col span={2} />
+              <Col span={22}>
+                <Alert
+                  message={this.state.result.success ? i18next.t("channel:Connection Successful") : i18next.t("channel:Connection Failed")}
+                  description={this.state.result.message}
+                  type={this.state.result.success ? "success" : "error"}
+                  showIcon
+                />
+              </Col>
+            </Row>
+          ) : null
+        }
+      </Card>
+    );
+  }
+}
+
+export default ChannelEditPage;
diff --git a/web/src/ChannelListPage.js b/web/src/ChannelListPage.js
new file mode 100644
index 0000000..3ff888e
--- /dev/null
+++ b/web/src/ChannelListPage.js
@@ -0,0 +1,233 @@
+// 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.
+
+import React from "react";
+import {Link} from "react-router-dom";
+import {Button, Popconfirm, Space, Table, Tag, Tooltip} from "antd";
+import {CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined} from "@ant-design/icons";
+import i18next from "i18next";
+import BaseListPage from "./BaseListPage";
+import * as ChannelBackend from "./backend/ChannelBackend";
+import * as Setting from "./Setting";
+
+class ChannelListPage extends BaseListPage {
+  UNSAFE_componentWillMount() {
+    this.fetch = this.fetchChannels;
+    this.fetchChannels();
+  }
+
+  fetchChannels = () => {
+    ChannelBackend.getChannels(this.props.account.name).then(res => {
+      if (res.status === "ok") {
+        this.setState({
+          data: res.data,
+        });
+      }
+    });
+  };
+
+  deleteChannel = (channel) => {
+    ChannelBackend.deleteChannel(channel).then(() => {
+      Setting.showMessage("success", i18next.t("channel:Channel deleted successfully"));
+      this.fetchChannels();
+    });
+  };
+
+  newChannel() {
+    const randomName = Setting.getRandomName();
+    return {
+      owner: this.props.account.name,
+      name: `channel_${randomName}`,
+      displayName: `New Channel - ${randomName}`,
+      type: "openai",
+      status: "enabled",
+      models: [],
+      priority: 0,
+      baseUrl: "",
+      apiKey: "",
+    };
+  }
+
+  addChannel() {
+    const newChannel = this.newChannel();
+    ChannelBackend.addChannel(newChannel).then(res => {
+      if (res.status === "error") {
+        Setting.showMessage("error", `${i18next.t("channel:Failed to add")}: ${res.msg}`);
+      } else {
+        Setting.showMessage("success", i18next.t("channel:Channel added successfully"));
+        this.fetchChannels();
+      }
+    }).catch(error => {
+      Setting.showMessage("error", `${i18next.t("channel:Channel failed to add")}: ${error}`);
+    });
+  }
+
+  renderTypeTag(type) {
+    const colorMap = {
+      openai: "#10a37f",
+      claude: "#d97757",
+      gemini: "#4285f4",
+      custom: "#8b5cf6",
+    };
+    return (
+      <Tag color={colorMap[type] || "default"}>{type}</Tag>
+    );
+  }
+
+  renderStatusTag(status) {
+    return status === "enabled" ? (
+      <Tag icon={<CheckCircleOutlined />} color="success">{i18next.t("channel:Enabled")}</Tag>
+    ) : (
+      <Tag icon={<CloseCircleOutlined />} color="error">{i18next.t("channel:Disabled")}</Tag>
+    );
+  }
+
+  renderTable(data) {
+    const columns = [
+      {
+        title: i18next.t("general:Name"),
+        dataIndex: "name",
+        key: "name",
+        width: "140px",
+        ellipsis: true,
+        sorter: (a, b) => a.name.localeCompare(b.name),
+        render: (text, record) => {
+          return (
+            <Tooltip title={text}>
+              <Link to={`/channels/${record.owner}/${record.name}`}>
+                {text}
+              </Link>
+            </Tooltip>
+          );
+        },
+      },
+      {
+        title: i18next.t("general:Display name"),
+        dataIndex: "displayName",
+        key: "displayName",
+        width: "180px",
+        ellipsis: true,
+        sorter: (a, b) => a.displayName.localeCompare(b.displayName),
+        render: (text) => {
+          if (!text) {return "-";}
+          return (
+            <Tooltip title={text}>
+              <span>{text}</span>
+            </Tooltip>
+          );
+        },
+      },
+      {
+        title: i18next.t("channel:Type"),
+        dataIndex: "type",
+        key: "type",
+        width: "90px",
+        render: (text) => this.renderTypeTag(text),
+      },
+      {
+        title: i18next.t("channel:Base URL"),
+        dataIndex: "baseUrl",
+        key: "baseUrl",
+        width: "150px",
+        ellipsis: true,
+        render: (text) => {
+          if (!text) {return "-";}
+          return (
+            <Tooltip title={text}>
+              <span style={{fontFamily: "monospace", fontSize: "12px"}}>{text}</span>
+            </Tooltip>
+          );
+        },
+      },
+      {
+        title: i18next.t("channel:Models"),
+        dataIndex: "models",
+        key: "models",
+        width: "180px",
+        render: (models) => {
+          if (!models || models.length === 0) {return "-";}
+          return models.map(model => {
+            return <Tag key={model} color="blue">{model}</Tag>;
+          });
+        },
+      },
+      {
+        title: i18next.t("channel:Priority"),
+        dataIndex: "priority",
+        key: "priority",
+        width: "80px",
+        sorter: (a, b) => a.priority - b.priority,
+      },
+      {
+        title: i18next.t("channel:Status"),
+        dataIndex: "status",
+        key: "status",
+        width: "110px",
+        render: (text) => this.renderStatusTag(text),
+      },
+      {
+        title: i18next.t("general:Action"),
+        key: "action",
+        width: "180px",
+        render: (text, record) => {
+          return (
+            <Space>
+              <Button
+                type="primary"
+                size="small"
+                icon={<EditOutlined />}
+                onClick={() => this.props.history.push(`/channels/${record.owner}/${record.name}`)}
+              >
+                {i18next.t("general:Edit")}
+              </Button>
+              <Popconfirm
+                title={`${i18next.t("general:Delete")} channel "${record.name}"?`}
+                onConfirm={() => this.deleteChannel(record)}
+                okText={i18next.t("general:OK")}
+                cancelText={i18next.t("general:Cancel")}
+              >
+                <Button size="small" danger icon={<DeleteOutlined />}>{i18next.t("general:Delete")}</Button>
+              </Popconfirm>
+            </Space>
+          );
+        },
+      },
+    ];
+
+    return (
+      <div>
+        <Table
+          className="channel-table"
+          columns={columns}
+          dataSource={data}
+          rowKey="name"
+          bordered
+          pagination={this.state.pagination}
+          title={() => (
+            <div>
+              {i18next.t("channel:Channels")}&nbsp;&nbsp;&nbsp;&nbsp;
+              <Button type="primary" size="small" icon={<PlusOutlined />} onClick={this.addChannel.bind(this)}>
+                {i18next.t("channel:New Channel")}
+              </Button>
+            </div>
+          )}
+          loading={data === null}
+          onChange={this.handleTableChange}
+        />
+      </div>
+    );
+  }
+}
+
+export default ChannelListPage;
diff --git a/web/src/backend/ChannelBackend.js b/web/src/backend/ChannelBackend.js
new file mode 100644
index 0000000..788668e
--- /dev/null
+++ b/web/src/backend/ChannelBackend.js
@@ -0,0 +1,53 @@
+// 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.
+
+import * as Setting from "../Setting";
+
+function request(url, method = "GET", body = null) {
+  const options = {
+    method: method,
+    credentials: "include",
+  };
+  if (body !== null) {
+    options.body = JSON.stringify(Setting.deepCopy(body));
+  }
+  return fetch(`${Setting.ServerUrl}${url}`, options).then(res => res.json());
+}
+
+export function getChannels(owner, page = "", pageSize = "") {
+  return request(`/api/get-channels?owner=${encodeURIComponent(owner)}&p=${page}&pageSize=${pageSize}`);
+}
+
+export function getChannel(owner, name) {
+  return request(`/api/get-channel?id=${encodeURIComponent(owner)}/${encodeURIComponent(name)}`);
+}
+
+export function addChannel(channel) {
+  return request("/api/add-channel", "POST", channel);
+}
+
+export function updateChannel(owner, name, channel) {
+  return request(`/api/update-channel?id=${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, "POST", channel);
+}
+
+export function deleteChannel(channel) {
+  return request("/api/delete-channel", "POST", channel);
+}
+
+export function testChannel(owner, name) {
+  return request(`/api/test-channel?id=${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, "POST", {
+    owner: owner,
+    name: name,
+  });
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index d0ff6dd..8367562 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -24,6 +24,30 @@
     "Provider": "Provider",
     "Type": "Type"
   },
+  "channel": {
+    "API Key": "API Key",
+    "Base URL": "Base URL",
+    "Channel": "Channel",
+    "Channel added successfully": "Channel added successfully",
+    "Channel deleted successfully": "Channel deleted successfully",
+    "Channel failed to add": "Channel failed to add",
+    "Channel saved": "Channel saved",
+    "Channels": "Channels",
+    "Click to edit": "Click to edit",
+    "Connection Failed": "Connection Failed",
+    "Connection Successful": "Connection Successful",
+    "Disabled": "Disabled",
+    "Edit Channel": "Edit Channel",
+    "Enabled": "Enabled",
+    "Failed to add": "Failed to add",
+    "Models": "Models",
+    "New Channel": "New Channel",
+    "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.",
+    "Status": "Status",
+    "Test Connectivity": "Test Connectivity",
+    "Type": "Type"
+  },
   "general": {
     "Action": "Action",
     "Add": "Add",
@@ -140,4 +164,4 @@
     "Week": "Week",
     "seconds": "seconds"
   }
-}
\ No newline at end of file
+}
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index eb4078f..48f43db 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -24,6 +24,30 @@
     "Provider": "提供者",
     "Type": "类型"
   },
+  "channel": {
+    "API Key": "API 密钥",
+    "Base URL": "基础 URL",
+    "Channel": "渠道",
+    "Channel added successfully": "渠道添加成功",
+    "Channel deleted successfully": "渠道删除成功",
+    "Channel failed to add": "渠道添加失败",
+    "Channel saved": "渠道已保存",
+    "Channels": "渠道",
+    "Click to edit": "点击编辑",
+    "Connection Failed": "连接失败",
+    "Connection Successful": "连接成功",
+    "Disabled": "已禁用",
+    "Edit Channel": "编辑渠道",
+    "Enabled": "已启用",
+    "Failed to add": "添加失败",
+    "Models": "模型",
+    "New Channel": "新建渠道",
+    "Priority": "优先级",
+    "Priority hint": "数字越小优先级越高,0 为最高;同模型多渠道时优先使用数值小的渠道。",
+    "Status": "状态",
+    "Test Connectivity": "测试连接",
+    "Type": "类型"
+  },
   "general": {
     "Action": "操作",
     "Add": "添加",
@@ -140,4 +164,4 @@
     "Week": "周",
     "seconds": "秒"
   }
-}
\ No newline at end of file
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org