Browse Source

fix: 修复 buildProxyUrl 重复拼接版本前缀的问题(Gemini) (#693)

* fix: 修复 buildProxyUrl 重复拼接版本前缀的问题

* refactor(proxy): optimize regex compilation in buildProxyUrl

- Move escapeRegExp helper to module scope to avoid recreation
- Remove case-insensitive flag from version endpoint regex

* refactor(proxy): optimize endpoint regex compilation in URL builder

Move endpoint regex compilation outside the loop to avoid repeated regex creation on every request. Pre-compile endpoint patterns at module load time for better performance.
sunxyw 1 week ago
parent
commit
78d5e65445
2 changed files with 62 additions and 24 deletions
  1. 35 24
      src/app/v1/_lib/url.ts
  2. 27 0
      tests/unit/app/v1/url.test.ts

+ 35 - 24
src/app/v1/_lib/url.ts

@@ -1,5 +1,19 @@
 import { logger } from "@/lib/logger";
 
+const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+const targetEndpoints = [
+  "/responses", // Codex Response API
+  "/messages", // Claude Messages API
+  "/chat/completions", // OpenAI Compatible
+  "/models", // Gemini & OpenAI models
+] as const;
+
+const endpointRegexes = targetEndpoints.map((endpoint) => ({
+  endpoint,
+  regex: new RegExp(`^/(v\\d+[a-z0-9]*)${escapeRegExp(endpoint)}(?<suffix>/.*)?$`),
+}));
+
 /**
  * 构建代理目标URL(智能检测版本)
  *
@@ -38,30 +52,27 @@ export function buildProxyUrl(baseUrl: string, requestUrl: URL): string {
     }
 
     // Case 2: baseUrl 已包含“端点根路径”(可能带有额外前缀),仅追加 requestPath 的子路径部分。
-    const targetEndpoints = [
-      "/responses", // Codex Response API
-      "/messages", // Claude Messages API
-      "/chat/completions", // OpenAI Compatible
-      "/models", // Gemini & OpenAI models
-    ];
-
-    for (const endpoint of targetEndpoints) {
-      const requestRoot = `/v1${endpoint}`; // /v1/messages, /v1/responses 等
-      if (requestPath === requestRoot || requestPath.startsWith(`${requestRoot}/`)) {
-        if (basePath.endsWith(endpoint) || basePath.endsWith(requestRoot)) {
-          const suffix = requestPath.slice(requestRoot.length); // 例如 /count_tokens
-          baseUrlObj.pathname = basePath + suffix;
-          baseUrlObj.search = requestUrl.search;
-
-          logger.debug("[buildProxyUrl] Detected endpoint root in baseUrl", {
-            basePath,
-            requestPath,
-            endpoint,
-            action: "append_suffix",
-          });
-
-          return baseUrlObj.toString();
-        }
+
+    for (const { endpoint, regex } of endpointRegexes) {
+      const m = requestPath.match(regex);
+      if (!m) continue;
+
+      const version = m[1];
+      const requestRoot = `/${version}${endpoint}`;
+      const suffix = m.groups?.suffix ?? "";
+
+      if (basePath.endsWith(endpoint) || basePath.endsWith(requestRoot)) {
+        baseUrlObj.pathname = basePath + suffix;
+        baseUrlObj.search = requestUrl.search;
+
+        logger.debug("[buildProxyUrl] Detected endpoint root in baseUrl", {
+          basePath,
+          requestPath,
+          endpoint,
+          action: "append_suffix",
+        });
+
+        return baseUrlObj.toString();
       }
     }
 

+ 27 - 0
tests/unit/app/v1/url.test.ts

@@ -54,4 +54,31 @@ describe("buildProxyUrl", () => {
 
     expect(out).toBe("https://api.example.com/v1/messages?from=request");
   });
+
+  test("baseUrl 以 /models 结尾时去除请求中的版本前缀", () => {
+    const out = buildProxyUrl(
+      "https://api.example.com/gemini/models",
+      new URL("https://dummy.com/v1beta/models/gemini-1.5-pro:streamGenerateContent")
+    );
+
+    expect(out).toBe("https://api.example.com/gemini/models/gemini-1.5-pro:streamGenerateContent");
+  });
+
+  test("支持 v1internal 版本前缀", () => {
+    const out = buildProxyUrl(
+      "https://example.com/gemini/models",
+      new URL("https://dummy.com/v1internal/models/gemini-2.5-flash:generateContent")
+    );
+
+    expect(out).toBe("https://example.com/gemini/models/gemini-2.5-flash:generateContent");
+  });
+
+  test("支持未来的版本前缀如 v2", () => {
+    const out = buildProxyUrl(
+      "https://example.com/api/models",
+      new URL("https://dummy.com/v2/models/some-model:action")
+    );
+
+    expect(out).toBe("https://example.com/api/models/some-model:action");
+  });
 });