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")}
+ <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