feat: add unified reverse proxy URL support for all API endpoints

Extract URL construction into src/utils/url-builder.js, replacing 8
scattered inline implementations. Now properly handles reverse proxy
paths like /Gemini/v1 or /antigravity/v1 by detecting version segments
at any path depth. Fixes inconsistent Anthropic URL in multi-ai-generator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cola-Echo
2026-03-31 23:23:55 +08:00
parent 10ea8cc1f4
commit 3a554028ae
10 changed files with 133 additions and 61 deletions

View File

@@ -7,6 +7,7 @@ import Logger from '@core/logger';
import { StreamingHandler } from './streaming-handler';
import { getEnabledProviders } from '@config/config-manager';
import { buildMessagesFromPreset, getPromptPresetById } from '@ui/modals/prompt-preset';
import { buildOpenAIChatUrl, buildAnthropicUrl } from '@utils/url-builder';
const log = Logger.createModuleLogger('多AI生成');
@@ -225,18 +226,12 @@ export class MultiAIGenerator {
async callProvider(provider, messages, signal, onChunk) {
const { apiFormat, apiUrl, apiKey, model, maxTokens, temperature, streaming } = provider;
// 构建请求URL
// 构建请求URL(统一的反代兼容 URL 构造)
let requestUrl = apiUrl;
if (apiFormat === 'openai') {
if (apiUrl.endsWith('/v1') || apiUrl.endsWith('/v1/')) {
requestUrl = apiUrl.replace(/\/v1\/?$/, '/v1/chat/completions');
} else if (!apiUrl.includes('/chat/completions') && !apiUrl.includes('/completions')) {
requestUrl = apiUrl.replace(/\/?$/, '/chat/completions');
}
requestUrl = buildOpenAIChatUrl(apiUrl);
} else if (apiFormat === 'anthropic') {
if (!apiUrl.includes('/messages')) {
requestUrl = apiUrl.replace(/\/?$/, '/messages');
}
requestUrl = buildAnthropicUrl(apiUrl);
} else if (apiFormat === 'google') {
// Google Gemini API
if (!apiUrl.includes(':generateContent')) {

View File

@@ -4,6 +4,7 @@
*/
import Logger from '@core/logger';
import { buildAnthropicUrl } from '@utils/url-builder';
/**
* 模拟流式进度管理器
@@ -90,12 +91,8 @@ export async function callAnthropic(config, systemPrompt, userMessage, signal =
const { apiKey, model, maxTokens, temperature } = config;
let { apiUrl } = config;
// 自动补全 /v1/messages
if (apiUrl.endsWith("/v1") || apiUrl.endsWith("/v1/")) {
apiUrl = apiUrl.replace(/\/v1\/?$/, "/v1/messages");
} else if (!apiUrl.includes("/messages")) {
apiUrl = apiUrl.replace(/\/?$/, "/v1/messages");
}
// 统一的反代兼容 URL 构造
apiUrl = buildAnthropicUrl(apiUrl);
const response = await fetch(apiUrl, {
method: "POST",

View File

@@ -4,6 +4,7 @@
*/
import Logger from '@core/logger';
import { buildGoogleUrl } from '@utils/url-builder';
/**
* 模拟流式进度管理器
@@ -82,11 +83,8 @@ export async function callGoogle(config, systemPrompt, userMessage, signal = nul
const { apiKey, model, maxTokens, temperature } = config;
let { apiUrl } = config;
// Google API URL 格式: https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
if (!apiUrl.includes("/models")) {
apiUrl = apiUrl.replace(/\/?$/, "/models");
}
const url = `${apiUrl}/${model}:generateContent?key=${apiKey}`;
// 统一的反代兼容 URL 构造
const url = buildGoogleUrl(apiUrl, model, apiKey);
// Google API 不支持流式,使用模拟进度
let progressManager = null;

View File

@@ -4,6 +4,8 @@
* @module api/providers/openai
*/
import { buildOpenAIChatUrl } from '@utils/url-builder';
/**
* 模拟流式进度管理器
* 使用检查点驱动的进度增长,模拟真实的流式传输体验
@@ -237,15 +239,8 @@ export async function callOpenAI(
const { apiKey, model, maxTokens, temperature } = config;
let { apiUrl } = config;
// 自动补全 /chat/completions
if (apiUrl.endsWith("/v1") || apiUrl.endsWith("/v1/")) {
apiUrl = apiUrl.replace(/\/v1\/?$/, "/v1/chat/completions");
} else if (
!apiUrl.includes("/chat/completions") &&
!apiUrl.includes("/completions")
) {
apiUrl = apiUrl.replace(/\/?$/, "/chat/completions");
}
// 统一的反代兼容 URL 构造
apiUrl = buildOpenAIChatUrl(apiUrl);
const headers = { "Content-Type": "application/json" };
if (apiKey) {
@@ -357,15 +352,8 @@ export async function callOpenAIWithMessages(
const { apiKey, model, maxTokens, temperature } = config;
let { apiUrl } = config;
// 自动补全 /chat/completions
if (apiUrl.endsWith("/v1") || apiUrl.endsWith("/v1/")) {
apiUrl = apiUrl.replace(/\/v1\/?$/, "/v1/chat/completions");
} else if (
!apiUrl.includes("/chat/completions") &&
!apiUrl.includes("/completions")
) {
apiUrl = apiUrl.replace(/\/?$/, "/chat/completions");
}
// 统一的反代兼容 URL 构造
apiUrl = buildOpenAIChatUrl(apiUrl);
const headers = { "Content-Type": "application/json" };
if (apiKey) {

View File

@@ -25,6 +25,7 @@ import {
import { disableTableFiller, reinitTableFiller } from "@table-filler/index";
import { APIAdapter } from "@api/adapter";
import { bindIndependentTemplateEvents } from "@ui/modals/independent-template-modal";
import { buildOpenAIModelsUrl } from "@utils/url-builder";
const log = Logger.createModuleLogger('Amily表格并发');
@@ -783,12 +784,9 @@ async function handleFetchModels() {
async function fetchModelsFromApi(apiUrl, apiKey, format) {
let modelsUrl = apiUrl;
// 统一的反代兼容模型列表 URL 构造
if (format === "openai") {
if (apiUrl.endsWith("/v1") || apiUrl.endsWith("/v1/")) {
modelsUrl = apiUrl.replace(/\/v1\/?$/, "/v1/models");
} else if (!apiUrl.includes("/models")) {
modelsUrl = apiUrl.replace(/\/?$/, "/models");
}
modelsUrl = buildOpenAIModelsUrl(apiUrl);
} else {
throw new Error("此API格式不支持获取模型列表");
}

View File

@@ -14,6 +14,7 @@ import {
import { defaultMultiAIProvider } from '@config/default-config';
import { APIAdapter } from '@api/adapter';
import { getPromptPresets, showPromptPresetModal, getPromptPresetById } from './prompt-preset';
import { buildOpenAIModelsUrl } from '@utils/url-builder';
const log = Logger.createModuleLogger('多AI配置');
@@ -457,13 +458,9 @@ export function showMultiAIConfigModal(providerId = null) {
async function fetchModels(apiUrl, apiKey, format) {
let modelsUrl = apiUrl;
// 构建模型列表URL
// 统一的反代兼容模型列表 URL 构造
if (format === 'openai') {
if (apiUrl.endsWith('/v1') || apiUrl.endsWith('/v1/')) {
modelsUrl = apiUrl.replace(/\/v1\/?$/, '/v1/models');
} else if (!apiUrl.includes('/models')) {
modelsUrl = apiUrl.replace(/\/?$/, '/models');
}
modelsUrl = buildOpenAIModelsUrl(apiUrl);
} else {
// 其他格式暂不支持获取模型列表
throw new Error('此API格式不支持获取模型列表请手动输入模型名称');

View File

@@ -12,6 +12,7 @@ import {
import { refreshWorldBookList, getSummaryParts } from "@worldbook/refresh";
import { formatCharCount } from "@worldbook/summary-splitter";
import APIAdapter from "@api/adapter";
import { buildOpenAIModelsUrl } from "@utils/url-builder";
/**
* 从API获取模型列表
@@ -23,17 +24,9 @@ import APIAdapter from "@api/adapter";
async function fetchModelsFromApi(apiUrl, apiKey, format) {
let modelsUrl = apiUrl;
// 构建模型列表URL
// 统一的反代兼容模型列表 URL 构造
if (format === 'openai') {
if (apiUrl.endsWith('/v1') || apiUrl.endsWith('/v1/')) {
modelsUrl = apiUrl.replace(/\/v1\/?$/, '/v1/models');
} else if (apiUrl.includes('/v1/chat/completions')) {
modelsUrl = apiUrl.replace('/v1/chat/completions', '/v1/models');
} else if (apiUrl.includes('/chat/completions')) {
modelsUrl = apiUrl.replace('/chat/completions', '/models');
} else if (!apiUrl.includes('/models')) {
modelsUrl = apiUrl.replace(/\/?$/, '/models');
}
modelsUrl = buildOpenAIModelsUrl(apiUrl);
} else if (format === 'anthropic') {
// Anthropic 不支持获取模型列表,返回常用模型
return [
@@ -190,7 +183,7 @@ export function showSummaryPartConfigModal(bookName, partId) {
<div class="mm-form-group">
<label>API URL <span class="mm-required">*</span></label>
<input type="text" id="mm-part-api-url" placeholder="https://api.deepseek.com/v1" value="${escapeHtml(savedConfig.apiUrl || '')}">
<small class="mm-hint">填写到 /v1 即可,会自动补全完整路径</small>
<small class="mm-hint">填写到 /v1 即可,支持反代路径如 /Gemini/v1</small>
</div>
<!-- API Key -->

View File

@@ -27,3 +27,10 @@ export {
reloadKeywordsPromptTemplate,
reloadHistoricalPromptTemplate,
} from './prompt-template';
export {
buildOpenAIChatUrl,
buildOpenAIModelsUrl,
buildAnthropicUrl,
buildGoogleUrl,
} from './url-builder';

99
src/utils/url-builder.js Normal file
View File

@@ -0,0 +1,99 @@
/**
* 统一的 API URL 构造工具
* 支持标准接口和反代接口(如 域名/Gemini/v1、域名/antigravity/v1
* @module utils/url-builder
*/
/**
* 从用户输入的 apiUrl 中提取 base到最后一个 /v... 路径段为止)
* 例:
* https://api.example.com/v1 → https://api.example.com/v1
* https://proxy.com/Gemini/v1 → https://proxy.com/Gemini/v1
* https://proxy.com/antigravity/v1beta → https://proxy.com/antigravity/v1beta
* https://proxy.com/v1/chat/completions → https://proxy.com/v1
* https://proxy.com → https://proxy.com (无 /v 段)
*
* @param {string} url
* @returns {{ base: string, hasVersionSegment: boolean }}
*/
function parseApiBase(url) {
// 去尾斜杠
url = url.replace(/\/+$/, '');
// 已包含完整终端路径,先剥离
const endpointPatterns = [
/\/chat\/completions$/,
/\/completions$/,
/\/messages$/,
/\/models(\/.*)?$/,
/:generateContent(\?.*)?$/,
];
for (const pattern of endpointPatterns) {
if (pattern.test(url)) {
url = url.replace(pattern, '');
break;
}
}
// 再次去尾斜杠
url = url.replace(/\/+$/, '');
// 检测是否包含版本号路径段 /v1、/v1beta、/v2 等
const hasVersionSegment = /\/v\d+[a-z]*$/i.test(url);
return { base: url, hasVersionSegment };
}
/**
* 构建 OpenAI 兼容的 chat/completions URL
* @param {string} apiUrl 用户输入的 API URL
* @returns {string}
*/
export function buildOpenAIChatUrl(apiUrl) {
const { base, hasVersionSegment } = parseApiBase(apiUrl);
if (hasVersionSegment) {
return `${base}/chat/completions`;
}
// 无版本段,默认插入 /v1
return `${base}/v1/chat/completions`;
}
/**
* 构建 OpenAI 兼容的 models 列表 URL
* @param {string} apiUrl 用户输入的 API URL
* @returns {string}
*/
export function buildOpenAIModelsUrl(apiUrl) {
const { base, hasVersionSegment } = parseApiBase(apiUrl);
if (hasVersionSegment) {
return `${base}/models`;
}
return `${base}/v1/models`;
}
/**
* 构建 Anthropic messages URL
* @param {string} apiUrl 用户输入的 API URL
* @returns {string}
*/
export function buildAnthropicUrl(apiUrl) {
const { base, hasVersionSegment } = parseApiBase(apiUrl);
if (hasVersionSegment) {
return `${base}/messages`;
}
return `${base}/v1/messages`;
}
/**
* 构建 Google Gemini generateContent URL
* @param {string} apiUrl 用户输入的 API URL
* @param {string} model 模型名称
* @param {string} apiKey API Key
* @returns {string}
*/
export function buildGoogleUrl(apiUrl, model, apiKey) {
const { base } = parseApiBase(apiUrl);
// Google 格式base/models/{model}:generateContent?key=xxx
const modelsBase = base.includes('/models') ? base : `${base}/models`;
return `${modelsBase}/${model}:generateContent?key=${apiKey}`;
}