This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit d0713e352e88d4311b051fe3b7fcfd942b338a48
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            | 236 +++++++++++++++++++++++++++++
 object/channel.go                 | 293 +++++++++++++++++++++++++++++++++++
 object/ormer.go                   |   6 +
 routers/router.go                 |   8 +
 web/src/App.js                    |  15 ++
 web/src/ChannelEditPage.js        | 310 ++++++++++++++++++++++++++++++++++++++
 web/src/ChannelListPage.js        | 265 ++++++++++++++++++++++++++++++++
 web/src/backend/ChannelBackend.js |  53 +++++++
 web/src/locales/en/data.json      |  31 +++-
 web/src/locales/zh/data.json      |  31 +++-
 10 files changed, 1246 insertions(+), 2 deletions(-)

diff --git a/controllers/channel.go b/controllers/channel.go
new file mode 100644
index 0000000..d278fff
--- /dev/null
+++ b/controllers/channel.go
@@ -0,0 +1,236 @@
+// 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()
+}
+
+// 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/object/channel.go b/object/channel.go
new file mode 100644
index 0000000..8dffa1f
--- /dev/null
+++ b/object/channel.go
@@ -0,0 +1,293 @@
+// 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 (
+       "errors"
+       "fmt"
+       "io"
+       "net/http"
+       "net/url"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/util"
+       "github.com/xorm-io/core"
+)
+
+// 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
+// (including an empty string) overwrites the stored key.
+const ApiKeyMask = "***"
+
+const (
+       maxChannelModels     = 200
+       maxChannelModelChars = 100
+)
+
+var (
+       channelTypes    = []string{"openai", "claude", "gemini", "custom"}
+       channelStatuses = []string{"enabled", "disabled"}
+)
+
+func containsString(values []string, value string) bool {
+       for _, v := range values {
+               if v == value {
+                       return true
+               }
+       }
+       return false
+}
+
+// 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 is JSON-serialized by xorm, so it needs a text column rather 
than
+       // a varchar: the serialized form is longer than the joined model names.
+       Models []string `xorm:"mediumtext" json:"models"`
+       // 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 (channel *Channel) GetId() string {
+       return fmt.Sprintf("%s/%s", channel.Owner, channel.Name)
+}
+
+func GetChannels(owner string) ([]*Channel, error) {
+       channels := []*Channel{}
+       session := GetSession(owner, -1, -1, "", "", "", "")
+       err := session.Find(&channels)
+       return channels, err
+}
+
+func GetChannelCount(owner, field, value string) (int64, error) {
+       session := GetSession(owner, -1, -1, field, value, "", "")
+       return session.Count(&Channel{})
+}
+
+func GetPaginationChannels(owner string, offset, limit int, field, value, 
sortField, sortOrder string) ([]*Channel, error) {
+       channels := []*Channel{}
+       session := GetSession(owner, offset, limit, field, value, sortField, 
sortOrder)
+       err := session.Find(&channels)
+       return channels, err
+}
+
+func getChannel(owner, name string) (*Channel, error) {
+       channel := &Channel{Owner: owner, Name: name}
+       existed, err := ormer.Engine.Get(channel)
+       if err != nil {
+               return nil, err
+       }
+       if !existed {
+               return nil, nil
+       }
+       return channel, nil
+}
+
+func GetChannel(id string) (*Channel, error) {
+       owner, name := util.GetOwnerAndNameFromId(id)
+       return getChannel(owner, name)
+}
+
+// GetMaskedChannel returns a copy of the channel with the API key replaced by
+// ApiKeyMask, so the stored key never reaches the browser.
+func GetMaskedChannel(channel *Channel) *Channel {
+       if channel == nil {
+               return nil
+       }
+
+       masked := *channel
+       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))
+       }
+       return maskedChannels
+}
+
+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 !containsString(channelTypes, channel.Type) {
+               return fmt.Errorf("invalid channel type: %s", channel.Type)
+       }
+       if !containsString(channelStatuses, channel.Status) {
+               return fmt.Errorf("invalid channel status: %s", channel.Status)
+       }
+
+       if channel.BaseUrl != "" {
+               if err := validateBaseUrl(channel.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)
+       }
+       for _, model := range channel.Models {
+               if strings.TrimSpace(model) == "" {
+                       return fmt.Errorf("model name cannot be empty")
+               }
+               if len(model) > maxChannelModelChars {
+                       return fmt.Errorf("model name is too long: %s", model)
+               }
+       }
+
+       return nil
+}
+
+func validateBaseUrl(baseUrl string) error {
+       u, err := url.Parse(baseUrl)
+       if err != nil {
+               return fmt.Errorf("invalid base URL: %s", err.Error())
+       }
+       if u.Scheme != "http" && u.Scheme != "https" {
+               return fmt.Errorf("invalid base URL: only the http and https 
schemes are supported")
+       }
+       if u.Hostname() == "" {
+               return fmt.Errorf("invalid base URL: the host is empty")
+       }
+       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
+
+       affected, err := ormer.Engine.Insert(channel)
+       return affected != 0, err
+}
+
+func UpdateChannel(id string, channel *Channel) (bool, error) {
+       owner, name := util.GetOwnerAndNameFromId(id)
+       if stored, err := getChannel(owner, name); err != nil {
+               return false, err
+       } else if stored == nil {
+               return false, nil
+       }
+
+       if err := validateChannel(channel); err != nil {
+               return false, err
+       }
+
+       channel.Owner = owner
+       channel.Name = name
+       channel.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 {
+               session = session.Omit("api_key")
+       }
+
+       affected, err := session.AllCols().Update(channel)
+       return affected != 0, err
+}
+
+func DeleteChannel(channel *Channel) (bool, error) {
+       affected, err := ormer.Engine.ID(core.PK{channel.Owner, 
channel.Name}).Delete(&Channel{})
+       return affected != 0, err
+}
+
+// TestChannelConnectivity performs a read-only probe against the channel'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)
+       if err != nil {
+               return false, 0, err.Error()
+       }
+       if stored == nil {
+               return false, 0, "the channel does not exist"
+       }
+
+       if stored.Type == "claude" || stored.Type == "gemini" {
+               return false, 0, fmt.Sprintf("the %s channel type is not 
supported yet in this stage", stored.Type)
+       }
+
+       if stored.BaseUrl == "" {
+               return false, 0, "the base URL is empty"
+       }
+       if err = validateBaseUrl(stored.BaseUrl); err != nil {
+               return false, 0, err.Error()
+       }
+
+       probeUrl := strings.TrimRight(stored.BaseUrl, "/")
+       if stored.Type == "openai" {
+               probeUrl += "/v1/models"
+       }
+
+       req, err := http.NewRequest(http.MethodGet, probeUrl, nil)
+       if err != nil {
+               return false, 0, err.Error()
+       }
+       if stored.Type == "openai" && stored.ApiKey != "" {
+               req.Header.Set("Authorization", "Bearer "+stored.ApiKey)
+       }
+
+       client := &http.Client{
+               Timeout: 10 * time.Second,
+               // Do not follow redirects, so the reported status is the one 
the
+               // configured base URL actually returns.
+               CheckRedirect: func(*http.Request, []*http.Request) error { 
return http.ErrUseLastResponse },
+       }
+
+       resp, err := client.Do(req)
+       if err != nil {
+               // Unwrap the *url.Error so the reason is not buried behind the 
URL.
+               var urlErr *url.Error
+               if errors.As(err, &urlErr) {
+                       return false, 0, urlErr.Err.Error()
+               }
+               return false, 0, err.Error()
+       }
+       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/ChannelEditPage.js b/web/src/ChannelEditPage.js
new file mode 100644
index 0000000..002e85a
--- /dev/null
+++ b/web/src/ChannelEditPage.js
@@ -0,0 +1,310 @@
+// 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, Result, 
Row, Select, Space, Spin} from "antd";
+import {ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, 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 = {
+      // undefined while loading, null when the channel could not be loaded.
+      channel: undefined,
+      testing: false,
+      result: null,
+    };
+  }
+
+  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});
+      } else {
+        this.setState({channel: null});
+        Setting.showMessage("error", `${i18next.t("channel:Failed to get 
channel")}: ${res.msg}`);
+      }
+    }).catch(error => {
+      this.setState({channel: null});
+      Setting.showMessage("error", `${i18next.t("channel:Failed to get 
channel")}: ${error}`);
+    });
+  }
+
+  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(res => {
+      if (res.status === "error") {
+        Setting.showMessage("error", `${i18next.t("channel:Failed to save")}: 
${res.msg}`);
+        return;
+      }
+
+      Setting.showMessage("success", i18next.t("channel:Channel saved"));
+    }).catch(error => {
+      Setting.showMessage("error", `${i18next.t("channel:Failed to save")}: 
${error}`);
+    });
+  }
+
+  test() {
+    const {owner, channelName} = this.props.match.params;
+    this.setState({testing: true, result: null});
+
+    ChannelBackend.testChannel(owner, channelName).then(res => {
+      if (res.status === "error") {
+        this.setState({testing: false});
+        Setting.showMessage("error", `${i18next.t("channel:Failed to test")}: 
${res.msg}`);
+        return;
+      }
+
+      this.setState({testing: false, result: res.data});
+    }).catch(error => {
+      this.setState({testing: false});
+      Setting.showMessage("error", `${i18next.t("channel:Failed to test")}: 
${error}`);
+    });
+  }
+
+  render() {
+    const channel = this.state.channel;
+    if (channel === undefined) {
+      return (
+        <div style={{textAlign: "center", marginTop: "100px"}}>
+          <Spin size="large" />
+        </div>
+      );
+    }
+    if (channel === null) {
+      return (
+        <Result
+          status="404"
+          title={i18next.t("channel:Channel not found")}
+          extra={<Button type="primary" onClick={() => 
this.props.history.push("/channels")}>{i18next.t("channel:Channels")}</Button>}
+        />
+      );
+    }
+
+    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.testing}
+              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.Password
+              placeholder="sk-..."
+              value={channel.apiKey}
+              onChange={e => {
+                this.setChannelField("apiKey", e.target.value);
+              }}
+            />
+            <div style={{color: "#888", fontSize: "12px", marginTop: "4px"}}>
+              {i18next.t("channel:API Key hint")}
+            </div>
+          </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-5, gpt-5-mini"
+              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.statusCode ? `HTTP 
${this.state.result.statusCode} - ${this.state.result.message}` : 
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..c60ba00
--- /dev/null
+++ b/web/src/ChannelListPage.js
@@ -0,0 +1,265 @@
+// 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() {
+    const pagination = {...this.state.pagination, current: 1, pageSize: 10};
+    this.setState({pagination: pagination});
+    this.fetch({pagination: pagination});
+  }
+
+  fetch = (params = {}) => {
+    const sortField = params.sortField, sortOrder = params.sortOrder;
+    if (!params.pagination) {
+      params.pagination = {current: 1, pageSize: 10};
+    }
+    this.setState({loading: true});
+
+    ChannelBackend.getChannels(this.props.account.name, 
params.pagination.current, params.pagination.pageSize, sortField, 
sortOrder).then(res => {
+      this.setState({loading: false});
+      if (res.status === "ok") {
+        this.setState({
+          data: res.data,
+          pagination: {
+            ...params.pagination,
+            total: res.data2,
+          },
+        });
+      } else {
+        Setting.showMessage("error", `${i18next.t("channel:Failed to get 
channels")}: ${res.msg}`);
+      }
+    }).catch(error => {
+      this.setState({loading: false});
+      Setting.showMessage("error", `${i18next.t("channel:Failed to get 
channels")}: ${error}`);
+    });
+  };
+
+  deleteChannel = (channel) => {
+    ChannelBackend.deleteChannel(channel).then(res => {
+      if (res.status === "error") {
+        Setting.showMessage("error", `${i18next.t("channel:Failed to 
delete")}: ${res.msg}`);
+        return;
+      }
+
+      Setting.showMessage("success", i18next.t("channel:Channel deleted 
successfully"));
+      this.fetch({
+        pagination: {
+          ...this.state.pagination,
+          current: this.state.pagination.current > 1 && this.state.data.length 
=== 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
+        },
+      });
+    }).catch(error => {
+      Setting.showMessage("error", `${i18next.t("channel:Failed to delete")}: 
${error}`);
+    });
+  };
+
+  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.fetch({pagination: this.state.pagination});
+      }
+    }).catch(error => {
+      Setting.showMessage("error", `${i18next.t("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,
+        // The server sorts and paginates, so sorting locally would only 
reorder
+        // the current page.
+        sorter: true,
+        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: true,
+        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: true,
+      },
+      {
+        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}
+          // An admin sees channels across owners, where names may collide.
+          rowKey={(record) => `${record.owner}/${record.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={this.state.loading}
+          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..84359bb
--- /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 = "", sortField = "", 
sortOrder = "") {
+  return 
request(`/api/get-channels?owner=${encodeURIComponent(owner)}&p=${page}&pageSize=${pageSize}&sortField=${sortField}&sortOrder=${sortOrder}`);
+}
+
+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", "POST", {
+    owner: owner,
+    name: name,
+  });
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index d0ff6dd..739b57a 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -24,6 +24,35 @@
     "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.",
+    "Base URL": "Base URL",
+    "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",
+    "Connection Failed": "Connection Failed",
+    "Connection Successful": "Connection Successful",
+    "Disabled": "Disabled",
+    "Edit Channel": "Edit Channel",
+    "Enabled": "Enabled",
+    "Failed to add": "Failed to add",
+    "Failed to delete": "Failed to delete",
+    "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",
+    "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 +169,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..35c83f0 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -24,6 +24,35 @@
     "Provider": "提供者",
     "Type": "类型"
   },
+  "channel": {
+    "API Key": "API 密钥",
+    "API Key hint": "已保存的密钥不会返回给浏览器。保持 *** 不变即可沿用当前密钥,清空该字段则删除密钥。",
+    "Base URL": "基础 URL",
+    "Channel": "渠道",
+    "Channel added successfully": "渠道添加成功",
+    "Channel deleted successfully": "渠道删除成功",
+    "Channel not found": "渠道不存在",
+    "Channel saved": "渠道已保存",
+    "Channels": "渠道",
+    "Connection Failed": "连接失败",
+    "Connection Successful": "连接成功",
+    "Disabled": "已禁用",
+    "Edit Channel": "编辑渠道",
+    "Enabled": "已启用",
+    "Failed to add": "添加失败",
+    "Failed to delete": "删除失败",
+    "Failed to get channel": "获取渠道失败",
+    "Failed to get channels": "获取渠道列表失败",
+    "Failed to save": "保存失败",
+    "Failed to test": "测试失败",
+    "Models": "模型",
+    "New Channel": "新建渠道",
+    "Priority": "优先级",
+    "Priority hint": "数字越小优先级越高,0 为最高;同模型多渠道时优先使用数值小的渠道。",
+    "Status": "状态",
+    "Test Connectivity": "测试连接",
+    "Type": "类型"
+  },
   "general": {
     "Action": "操作",
     "Add": "添加",
@@ -140,4 +169,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