From 3e6dc5619680ef5b56aa9be52e6beee9d3a1caea Mon Sep 17 00:00:00 2001 From: Phantom Date: Mon, 1 Dec 2025 16:27:33 +0800 Subject: [PATCH] fix(api): add withoutTrailingSharp utility and fix # handling in formatApiHost (#11604) * docs(providerConfig): improve jsdoc for formatProviderApiHost function * refactor(aiCore): improve provider handling with adaptProvider function Introduce adaptProvider to centralize provider transformations and replace direct usage of handleSpecialProviders and formatProviderApiHost. This improves maintainability and provides consistent behavior across all provider usage scenarios. * refactor(ProviderSettings): simplify api host formatting logic by using adaptProvider Replace multiple format functions with a single adaptProvider utility to centralize host formatting logic and improve maintainability * feat(api): add withoutTrailingSharp utility and update formatApiHost add utility function to remove trailing # from URLs and update formatApiHost to use it add comprehensive tests for new functionality * feat(ProviderSetting): add help tooltip for api url selector Add HelpTooltip component next to host selector to provide additional guidance about API URL configuration --- src/renderer/src/aiCore/index_new.ts | 14 ++-- .../src/aiCore/provider/providerConfig.ts | 44 ++++++++--- src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- src/renderer/src/i18n/translate/de-de.json | 2 +- src/renderer/src/i18n/translate/el-gr.json | 2 +- src/renderer/src/i18n/translate/es-es.json | 2 +- src/renderer/src/i18n/translate/fr-fr.json | 2 +- src/renderer/src/i18n/translate/ja-jp.json | 2 +- src/renderer/src/i18n/translate/pt-pt.json | 2 +- src/renderer/src/i18n/translate/ru-ru.json | 2 +- .../ProviderSettings/ProviderSetting.tsx | 60 +++++++-------- src/renderer/src/utils/__tests__/api.test.ts | 76 ++++++++++++++++++- src/renderer/src/utils/api.ts | 30 ++++++-- 15 files changed, 177 insertions(+), 67 deletions(-) diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index cf12a46aa6..ed92d4ddd8 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -27,6 +27,7 @@ import { buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder' import { buildPlugins } from './plugins/PluginBuilder' import { createAiSdkProvider } from './provider/factory' import { + adaptProvider, getActualProvider, isModernSdkSupported, prepareSpecialProviderConfig, @@ -64,12 +65,11 @@ export default class ModernAiProvider { * - URL will be automatically formatted via `formatProviderApiHost`, adding version suffixes like `/v1` * * 2. When called with `(model, provider)`: - * - **Directly uses the provided provider WITHOUT going through `getActualProvider`** - * - **URL will NOT be automatically formatted, `/v1` suffix will NOT be added** - * - This is legacy behavior kept for backward compatibility + * - The provided provider will be adapted via `adaptProvider` + * - URL formatting behavior depends on the adapted result * * 3. When called with `(provider)`: - * - Directly uses the provider without requiring a model + * - The provider will be adapted via `adaptProvider` * - Used for operations that don't need a model (e.g., fetchModels) * * @example @@ -77,7 +77,7 @@ export default class ModernAiProvider { * // Recommended: Auto-format URL * const ai = new ModernAiProvider(model) * - * // Not recommended: Skip URL formatting (only for special cases) + * // Provider will be adapted * const ai = new ModernAiProvider(model, customProvider) * * // For operations that don't need a model @@ -91,12 +91,12 @@ export default class ModernAiProvider { if (this.isModel(modelOrProvider)) { // 传入的是 Model this.model = modelOrProvider - this.actualProvider = provider || getActualProvider(modelOrProvider) + this.actualProvider = provider ? adaptProvider({ provider }) : getActualProvider(modelOrProvider) // 只保存配置,不预先创建executor this.config = providerToAiSdkConfig(this.actualProvider, modelOrProvider) } else { // 传入的是 Provider - this.actualProvider = modelOrProvider + this.actualProvider = adaptProvider({ provider: modelOrProvider }) // model为可选,某些操作(如fetchModels)不需要model } diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 53194d3506..3ed3633d7c 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -78,11 +78,13 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider { } /** - * 主要用来对齐AISdk的BaseURL格式 - * @param provider - * @returns + * Format and normalize the API host URL for a provider. + * Handles provider-specific URL formatting rules (e.g., appending version paths, Azure formatting). + * + * @param provider - The provider whose API host is to be formatted. + * @returns A new provider instance with the formatted API host. */ -function formatProviderApiHost(provider: Provider): Provider { +export function formatProviderApiHost(provider: Provider): Provider { const formatted = { ...provider } if (formatted.anthropicApiHost) { formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost) @@ -114,18 +116,38 @@ function formatProviderApiHost(provider: Provider): Provider { } /** - * 获取实际的Provider配置 - * 简化版:将逻辑分解为小函数 + * Retrieve the effective Provider configuration for the given model. + * Applies all necessary transformations (special-provider handling, URL formatting, etc.). + * + * @param model - The model whose provider is to be resolved. + * @returns A new Provider instance with all adaptations applied. */ export function getActualProvider(model: Model): Provider { const baseProvider = getProviderByModel(model) - // 按顺序处理各种转换 - let actualProvider = cloneDeep(baseProvider) - actualProvider = handleSpecialProviders(model, actualProvider) - actualProvider = formatProviderApiHost(actualProvider) + return adaptProvider({ provider: baseProvider, model }) +} - return actualProvider +/** + * Transforms a provider configuration by applying model-specific adaptations and normalizing its API host. + * The transformations are applied in the following order: + * 1. Model-specific provider handling (e.g., New-API, system providers, Azure OpenAI) + * 2. API host formatting (provider-specific URL normalization) + * + * @param provider - The base provider configuration to transform. + * @param model - The model associated with the provider; optional but required for special-provider handling. + * @returns A new Provider instance with all transformations applied. + */ +export function adaptProvider({ provider, model }: { provider: Provider; model?: Model }): Provider { + let adaptedProvider = cloneDeep(provider) + + // Apply transformations in order + if (model) { + adaptedProvider = handleSpecialProviders(model, adaptedProvider) + } + adaptedProvider = formatProviderApiHost(adaptedProvider) + + return adaptedProvider } /** diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 782340e011..85e76b5cf4 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4372,7 +4372,7 @@ "url": { "preview": "Preview: {{url}}", "reset": "Reset", - "tip": "ending with # forces use of input address" + "tip": "Add # at the end to disable the automatically appended API version." } }, "api_host": "API Host", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index c1874f7fb8..0ccfa0b16d 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4372,7 +4372,7 @@ "url": { "preview": "预览: {{url}}", "reset": "重置", - "tip": "# 结尾强制使用输入地址" + "tip": "在末尾添加 # 以禁用自动附加的API版本。" } }, "api_host": "API 地址", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index db81e30006..d21f66ccb3 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4372,7 +4372,7 @@ "url": { "preview": "預覽:{{url}}", "reset": "重設", - "tip": "# 結尾強制使用輸入位址" + "tip": "在末尾添加 # 以停用自動附加的 API 版本。" } }, "api_host": "API 主機地址", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index e7314482a3..7d11af7a11 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -4372,7 +4372,7 @@ "url": { "preview": "Vorschau: {{url}}", "reset": "Zurücksetzen", - "tip": "# am Ende erzwingt die Verwendung der Eingabe-Adresse" + "tip": "Fügen Sie am Ende ein # hinzu, um die automatisch angehängte API-Version zu deaktivieren." } }, "api_host": "API-Adresse", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index bc825ec688..f451e8af33 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -4372,7 +4372,7 @@ "url": { "preview": "Προεπισκόπηση: {{url}}", "reset": "Επαναφορά", - "tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως" + "tip": "Προσθέστε το σύμβολο # στο τέλος για να απενεργοποιήσετε την αυτόματα προστιθέμενη έκδοση API." } }, "api_host": "Διεύθυνση API", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 2da83ad229..ee2b03d06b 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -4372,7 +4372,7 @@ "url": { "preview": "Vista previa: {{url}}", "reset": "Restablecer", - "tip": "forzar uso de dirección de entrada con # al final" + "tip": "Añada # al final para deshabilitar la versión de la API que se añade automáticamente." } }, "api_host": "Dirección API", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 3f3e9108c5..e909edc257 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -4372,7 +4372,7 @@ "url": { "preview": "Aperçu : {{url}}", "reset": "Réinitialiser", - "tip": "forcer l'utilisation de l'adresse d'entrée si terminé par #" + "tip": "Ajoutez # à la fin pour désactiver la version d'API ajoutée automatiquement." } }, "api_host": "Adresse API", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index e2591fb20d..aa705da38d 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -4372,7 +4372,7 @@ "url": { "preview": "プレビュー: {{url}}", "reset": "リセット", - "tip": "#で終わる場合、入力されたアドレスを強制的に使用します" + "tip": "自動的に付加されるAPIバージョンを無効にするには、末尾に#を追加します。" } }, "api_host": "APIホスト", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index cae6ebd38d..056306838e 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -4372,7 +4372,7 @@ "url": { "preview": "Pré-visualização: {{url}}", "reset": "Redefinir", - "tip": "e forçar o uso do endereço original quando terminar com '#'" + "tip": "Adicione # no final para desativar a versão da API adicionada automaticamente." } }, "api_host": "Endereço API", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index fe5ebbcb25..12d696ec86 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -4372,7 +4372,7 @@ "url": { "preview": "Предпросмотр: {{url}}", "reset": "Сброс", - "tip": "заканчивая на # принудительно использует введенный адрес" + "tip": "Добавьте # в конце, чтобы отключить автоматически добавляемую версию API." } }, "api_host": "Хост API", diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index f341ac9229..da05409683 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,8 +1,10 @@ +import { adaptProvider } from '@renderer/aiCore/provider/providerConfig' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import { LoadingIcon } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup' import Selector from '@renderer/components/Selector' +import { HelpTooltip } from '@renderer/components/TooltipIcons' import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { PROVIDER_URLS } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' @@ -19,14 +21,7 @@ import type { SystemProviderId } from '@renderer/types' import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types' import type { ApiKeyConnectivity } from '@renderer/types/healthCheck' import { HealthStatus } from '@renderer/types/healthCheck' -import { - formatApiHost, - formatApiKeys, - formatAzureOpenAIApiHost, - formatVertexApiHost, - getFancyProviderName, - validateApiHost -} from '@renderer/utils' +import { formatApiHost, formatApiKeys, getFancyProviderName, validateApiHost } from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' import { isAIGatewayProvider, @@ -36,7 +31,6 @@ import { isNewApiProvider, isOpenAICompatibleProvider, isOpenAIProvider, - isSupportAPIVersionProvider, isVertexProvider } from '@renderer/utils/provider' import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd' @@ -281,12 +275,10 @@ const ProviderSetting: FC = ({ providerId }) => { }, [configuredApiHost, apiHost]) const hostPreview = () => { - if (apiHost.endsWith('#')) { - return apiHost.replace('#', '') - } + const formattedApiHost = adaptProvider({ provider: { ...provider, apiHost } }).apiHost if (isOpenAICompatibleProvider(provider)) { - return formatApiHost(apiHost, isSupportAPIVersionProvider(provider)) + '/chat/completions' + return formattedApiHost + '/chat/completions' } if (isAzureOpenAIProvider(provider)) { @@ -294,29 +286,26 @@ const ProviderSetting: FC = ({ providerId }) => { const path = !['preview', 'v1'].includes(apiVersion) ? `/v1/chat/completion?apiVersion=v1` : `/v1/responses?apiVersion=v1` - return formatAzureOpenAIApiHost(apiHost) + path + return formattedApiHost + path } if (isAnthropicProvider(provider)) { - // AI SDK uses the baseURL with /v1, then appends /messages - // formatApiHost adds /v1 automatically if not present - const normalizedHost = formatApiHost(apiHost) - return normalizedHost + '/messages' + return formattedApiHost + '/messages' } if (isGeminiProvider(provider)) { - return formatApiHost(apiHost, true, 'v1beta') + '/models' + return formattedApiHost + '/models' } if (isOpenAIProvider(provider)) { - return formatApiHost(apiHost) + '/responses' + return formattedApiHost + '/responses' } if (isVertexProvider(provider)) { - return formatVertexApiHost(provider) + '/publishers/google' + return formattedApiHost + '/publishers/google' } if (isAIGatewayProvider(provider)) { - return formatApiHost(apiHost) + '/language-model' + return formattedApiHost + '/language-model' } - return formatApiHost(apiHost) + return formattedApiHost } // API key 连通性检查状态指示器,目前仅在失败时显示 @@ -494,16 +483,21 @@ const ProviderSetting: FC = ({ providerId }) => { {!isDmxapi && ( <> - - setActiveHostField(value as HostField)} - options={hostSelectorOptions} - style={{ paddingLeft: 1, fontWeight: 'bold' }} - placement="bottomLeft" - /> - +
+ +
+ setActiveHostField(value as HostField)} + options={hostSelectorOptions} + style={{ paddingLeft: 1, fontWeight: 'bold' }} + placement="bottomLeft" + /> +
+
+ +