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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "预览: {{url}}",
|
||||
"reset": "重置",
|
||||
"tip": "# 结尾强制使用输入地址"
|
||||
"tip": "在末尾添加 # 以禁用自动附加的API版本。"
|
||||
}
|
||||
},
|
||||
"api_host": "API 地址",
|
||||
|
||||
@@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "預覽:{{url}}",
|
||||
"reset": "重設",
|
||||
"tip": "# 結尾強制使用輸入位址"
|
||||
"tip": "在末尾添加 # 以停用自動附加的 API 版本。"
|
||||
}
|
||||
},
|
||||
"api_host": "API 主機地址",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Προεπισκόπηση: {{url}}",
|
||||
"reset": "Επαναφορά",
|
||||
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
|
||||
"tip": "Προσθέστε το σύμβολο # στο τέλος για να απενεργοποιήσετε την αυτόματα προστιθέμενη έκδοση API."
|
||||
}
|
||||
},
|
||||
"api_host": "Διεύθυνση API",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "プレビュー: {{url}}",
|
||||
"reset": "リセット",
|
||||
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
|
||||
"tip": "自動的に付加されるAPIバージョンを無効にするには、末尾に#を追加します。"
|
||||
}
|
||||
},
|
||||
"api_host": "APIホスト",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Предпросмотр: {{url}}",
|
||||
"reset": "Сброс",
|
||||
"tip": "заканчивая на # принудительно использует введенный адрес"
|
||||
"tip": "Добавьте # в конце, чтобы отключить автоматически добавляемую версию API."
|
||||
}
|
||||
},
|
||||
"api_host": "Хост API",
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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,7 +483,9 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
{!isDmxapi && (
|
||||
<>
|
||||
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
|
||||
<div>
|
||||
<Selector
|
||||
size={14}
|
||||
value={activeHostField}
|
||||
@@ -503,7 +494,10 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
style={{ paddingLeft: 1, fontWeight: 'bold' }}
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('settings.provider.api.url.tip')}></HelpTooltip>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Button
|
||||
type="text"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,23 @@ export function withoutTrailingSlash<T extends string>(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<T extends string>(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<T extends string>(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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user