diff --git a/src/app/v1/_lib/url.ts b/src/app/v1/_lib/url.ts index 0bb9bdcc3..55239045f 100644 --- a/src/app/v1/_lib/url.ts +++ b/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)}(?/.*)?$`), +})); + /** * 构建代理目标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(); } } diff --git a/tests/unit/app/v1/url.test.ts b/tests/unit/app/v1/url.test.ts index bec47b9fa..b639d8121 100644 --- a/tests/unit/app/v1/url.test.ts +++ b/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"); + }); });