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]

Reply via email to