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