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 9152178 fix(channel): stop doubling the /v1 prefix in upstream urls
9152178 is described below
commit 9152178969ab32f70ab29f2e091798c5e549ba50
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 16 23:38:57 2026 +0800
fix(channel): stop doubling the /v1 prefix in upstream urls
---
controllers/proxy.go | 6 ++++--
object/channel.go | 27 ++++++++++++++++++++++----
web/src/ChannelEditPage.js | 45 ++++++++++++++++++++++++++++++++++++++++++++
web/src/locales/en/data.json | 1 +
web/src/locales/zh/data.json | 1 +
5 files changed, 74 insertions(+), 6 deletions(-)
diff --git a/controllers/proxy.go b/controllers/proxy.go
index edb882c..1faf1d5 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -170,8 +170,10 @@ func (c *ApiController) ChatCompletions() {
// the next channel. The response of the last channel is always relayed, even
// when its status would otherwise be retried.
func (c *ApiController) forwardToChannel(channel *object.Channel, rawBody
[]byte, stream bool, isLast bool) (int, string, bool) {
- // The path is hardcoded to the OpenAI-compatible /v1/chat/completions.
- upstreamUrl := strings.TrimRight(channel.BaseUrl, "/") +
"/v1/chat/completions"
+ upstreamUrl, err := object.BuildOpenAiUrl(channel.BaseUrl,
"/chat/completions")
+ if err != nil {
+ return http.StatusBadGateway, err.Error(), false
+ }
// The context is cancelled when this function returns, which happens
only
// after the response body has been relayed to the client.
diff --git a/object/channel.go b/object/channel.go
index 6762b04..1a13e14 100644
--- a/object/channel.go
+++ b/object/channel.go
@@ -202,6 +202,25 @@ func validateBaseUrl(baseUrl string) error {
return nil
}
+// BuildOpenAiUrl joins an OpenAI-compatible endpoint onto a channel base URL.
+// The base URL may be bare, already carry the /v1 prefix or already end with
+// the endpoint itself; none of those forms are doubled.
+func BuildOpenAiUrl(baseUrl string, endpoint string) (string, error) {
+ u, err := url.Parse(baseUrl)
+ if err != nil {
+ return "", fmt.Errorf("invalid base URL: %s", err.Error())
+ }
+
+ path := strings.TrimSuffix(strings.TrimRight(u.Path, "/"), endpoint)
+ if !strings.HasSuffix(path, "/v1") {
+ path += "/v1"
+ }
+
+ u.Path = path + endpoint
+ u.RawPath = ""
+ return u.String(), nil
+}
+
func AddChannel(channel *Channel) (bool, error) {
if err := validateChannel(channel); err != nil {
return false, err
@@ -273,16 +292,16 @@ func TestChannelConnectivity(channel *Channel) (bool,
int, string) {
return false, 0, err.Error()
}
- probeUrl := strings.TrimRight(stored.BaseUrl, "/")
- if stored.Type == "openai" {
- probeUrl += "/v1/models"
+ probeUrl, err := BuildOpenAiUrl(stored.BaseUrl, "/models")
+ if err != nil {
+ return false, 0, err.Error()
}
req, err := http.NewRequest(http.MethodGet, probeUrl, nil)
if err != nil {
return false, 0, err.Error()
}
- if stored.Type == "openai" && stored.ApiKey != "" {
+ if stored.ApiKey != "" {
req.Header.Set("Authorization", "Bearer "+stored.ApiKey)
}
diff --git a/web/src/ChannelEditPage.js b/web/src/ChannelEditPage.js
index 002e85a..eb899bc 100644
--- a/web/src/ChannelEditPage.js
+++ b/web/src/ChannelEditPage.js
@@ -29,6 +29,33 @@ const BASE_URL_PRESETS = {
custom: ["https://oneapi.example.com", "https://api.deepseek.com/v1",
"https://api.moonshot.cn/v1"],
};
+// The types the gateway forwards as OpenAI-formatted requests.
+const OPENAI_COMPATIBLE_TYPES = ["openai", "custom"];
+
+// Mirrors object.BuildOpenAiUrl on the server.
+function buildOpenAiUrl(baseUrl, endpoint) {
+ let url;
+ try {
+ url = new URL(baseUrl);
+ } catch {
+ return "";
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ return "";
+ }
+
+ let path = url.pathname.replace(/\/+$/, "");
+ if (path.endsWith(endpoint)) {
+ path = path.slice(0, path.length - endpoint.length);
+ }
+ if (!path.endsWith("/v1")) {
+ path += "/v1";
+ }
+
+ url.pathname = path + endpoint;
+ return url.toString();
+}
+
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"],
@@ -107,6 +134,23 @@ class ChannelEditPage extends React.Component {
});
}
+ renderUpstreamUrl(channel) {
+ if (!OPENAI_COMPATIBLE_TYPES.includes(channel.type)) {
+ return null;
+ }
+
+ const upstreamUrl = buildOpenAiUrl(channel.baseUrl, "/chat/completions");
+ if (upstreamUrl === "") {
+ return null;
+ }
+
+ return (
+ <div style={{color: "#888", fontSize: "12px", marginTop: "4px"}}>
+ {i18next.t("channel:Base URL hint")}: <span style={{fontFamily:
"monospace"}}>{upstreamUrl}</span>
+ </div>
+ );
+ }
+
render() {
const channel = this.state.channel;
if (channel === undefined) {
@@ -201,6 +245,7 @@ class ChannelEditPage extends React.Component {
}}
placeholder="https://api.openai.com/v1"
/>
+ {this.renderUpstreamUrl(channel)}
</Col>
</Row>
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 24dd332..50d07ec 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -79,6 +79,7 @@
"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",
+ "Base URL hint": "Requests will be sent to",
"Channel": "Channel",
"Channel added successfully": "Channel added successfully",
"Channel deleted successfully": "Channel deleted successfully",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index ea47c7c..a1765fa 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -79,6 +79,7 @@
"API Key": "API 密钥",
"API Key hint": "已保存的密钥不会返回给浏览器。保持 *** 不变即可沿用当前密钥,清空该字段则删除密钥。",
"Base URL": "基础 URL",
+ "Base URL hint": "实际请求将发往",
"Channel": "渠道",
"Channel added successfully": "渠道添加成功",
"Channel deleted successfully": "渠道删除成功",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]