diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index b995c38bc4..40de199706 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 18d44e1ad9..e3a3e55a9e 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -79,11 +79,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) @@ -115,18 +117,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 d06b72e095..269a28cf62 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,8 +1,10 @@ import { Button, Flex, RowFlex, Switch, Tooltip, WarnTooltip } from '@cherrystudio/ui' +import { adaptProvider } from '@renderer/aiCore/provider/providerConfig' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import { LoadingIcon } from '@renderer/components/Icons' 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 { Divider, Input, Select, Space } 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 连通性检查状态指示器,目前仅在失败时显示 @@ -498,16 +487,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" + /> +
+
+ +
diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts index 5b9d0f64f6..c00c2e0f60 100644 --- a/src/renderer/src/utils/__tests__/api.test.ts +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -13,7 +13,8 @@ import { routeToEndpoint, splitApiKeyString, validateApiHost, - withoutTrailingApiVersion + withoutTrailingApiVersion, + withoutTrailingSharp } from '../api' vi.mock('@renderer/store', () => { @@ -81,6 +82,27 @@ describe('api', () => { it('keeps host untouched when api version unsupported', () => { expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com') }) + + it('removes trailing # and does not append api version when host ends with #', () => { + expect(formatApiHost('https://api.example.com#')).toBe('https://api.example.com') + expect(formatApiHost('http://localhost:5173/#')).toBe('http://localhost:5173/') + expect(formatApiHost(' https://api.openai.com/# ')).toBe('https://api.openai.com/') + }) + + it('handles trailing # with custom api version settings', () => { + expect(formatApiHost('https://api.example.com#', true, 'v2')).toBe('https://api.example.com') + expect(formatApiHost('https://api.example.com#', false, 'v2')).toBe('https://api.example.com') + }) + + it('handles host with both trailing # and existing api version', () => { + expect(formatApiHost('https://api.example.com/v2#')).toBe('https://api.example.com/v2') + expect(formatApiHost('https://api.example.com/v3beta#')).toBe('https://api.example.com/v3beta') + }) + + it('trims whitespace before processing trailing #', () => { + expect(formatApiHost(' https://api.example.com# ')).toBe('https://api.example.com') + expect(formatApiHost('\thttps://api.example.com#\n')).toBe('https://api.example.com') + }) }) describe('hasAPIVersion', () => { @@ -404,4 +426,56 @@ describe('api', () => { expect(withoutTrailingApiVersion('')).toBe('') }) }) + + describe('withoutTrailingSharp', () => { + it('removes trailing # from URL', () => { + expect(withoutTrailingSharp('https://api.example.com#')).toBe('https://api.example.com') + expect(withoutTrailingSharp('http://localhost:3000#')).toBe('http://localhost:3000') + }) + + it('returns URL unchanged when no trailing #', () => { + expect(withoutTrailingSharp('https://api.example.com')).toBe('https://api.example.com') + expect(withoutTrailingSharp('http://localhost:3000')).toBe('http://localhost:3000') + }) + + it('handles URLs with multiple # characters but only removes trailing one', () => { + expect(withoutTrailingSharp('https://api.example.com#path#')).toBe('https://api.example.com#path') + }) + + it('handles URLs with # in the middle (not trailing)', () => { + expect(withoutTrailingSharp('https://api.example.com#section/path')).toBe('https://api.example.com#section/path') + expect(withoutTrailingSharp('https://api.example.com/v1/chat/completions#')).toBe( + 'https://api.example.com/v1/chat/completions' + ) + }) + + it('handles empty string', () => { + expect(withoutTrailingSharp('')).toBe('') + }) + + it('handles single character #', () => { + expect(withoutTrailingSharp('#')).toBe('') + }) + + it('preserves whitespace around the URL (pure function)', () => { + expect(withoutTrailingSharp(' https://api.example.com# ')).toBe(' https://api.example.com# ') + expect(withoutTrailingSharp('\thttps://api.example.com#\n')).toBe('\thttps://api.example.com#\n') + }) + + it('only removes exact trailing # character', () => { + expect(withoutTrailingSharp('https://api.example.com# ')).toBe('https://api.example.com# ') + expect(withoutTrailingSharp(' https://api.example.com#')).toBe(' https://api.example.com') + expect(withoutTrailingSharp('https://api.example.com#\t')).toBe('https://api.example.com#\t') + }) + + it('handles URLs ending with multiple # characters', () => { + expect(withoutTrailingSharp('https://api.example.com##')).toBe('https://api.example.com#') + expect(withoutTrailingSharp('https://api.example.com###')).toBe('https://api.example.com##') + }) + + it('preserves URL with trailing # and other content', () => { + expect(withoutTrailingSharp('https://api.example.com/v1#')).toBe('https://api.example.com/v1') + expect(withoutTrailingSharp('https://api.example.com/v2beta#')).toBe('https://api.example.com/v2beta') + }) + }) }) diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 72d44c5c25..efadb7813c 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -62,6 +62,23 @@ export function withoutTrailingSlash(url: T): T { return url.replace(/\/$/, '') as T } +/** + * Removes the trailing '#' from a URL string if it exists. + * + * @template T - The string type to preserve type safety + * @param {T} url - The URL string to process + * @returns {T} The URL string without a trailing '#' + * + * @example + * ```ts + * withoutTrailingSharp('https://example.com#') // 'https://example.com' + * withoutTrailingSharp('https://example.com') // 'https://example.com' + * ``` + */ +export function withoutTrailingSharp(url: T): T { + return url.replace(/#$/, '') as T +} + /** * Formats an API host URL by normalizing it and optionally appending an API version. * @@ -70,12 +87,12 @@ export function withoutTrailingSlash(url: T): T { * @param apiVersion - The API version to append if needed. Defaults to `'v1'`. * * @returns The formatted API host URL. If the host is empty after normalization, returns an empty string. - * If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host as-is. + * If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host with trailing '#' removed. * Otherwise, returns the host with the API version appended. * * @example * formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1' - * formatApiHost('https://api.example.com#') // Returns 'https://api.example.com#' + * formatApiHost('https://api.example.com#') // Returns 'https://api.example.com' * formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2' */ export function formatApiHost(host?: string, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string { @@ -84,10 +101,13 @@ export function formatApiHost(host?: string, supportApiVersion: boolean = true, return '' } - if (normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) { - return normalizedHost + const shouldAppendApiVersion = !(normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) + + if (shouldAppendApiVersion) { + return `${normalizedHost}/${apiVersion}` + } else { + return withoutTrailingSharp(normalizedHost) } - return `${normalizedHost}/${apiVersion}` } /**