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

Reply via email to