Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18777b3872 | |||
| 8e09da1ca0 | |||
| b588a43405 | |||
| 5d0ad18e45 | |||
| 3e6dc56196 |
@@ -27,6 +27,7 @@ import { buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
|
|||||||
import { buildPlugins } from './plugins/PluginBuilder'
|
import { buildPlugins } from './plugins/PluginBuilder'
|
||||||
import { createAiSdkProvider } from './provider/factory'
|
import { createAiSdkProvider } from './provider/factory'
|
||||||
import {
|
import {
|
||||||
|
adaptProvider,
|
||||||
getActualProvider,
|
getActualProvider,
|
||||||
isModernSdkSupported,
|
isModernSdkSupported,
|
||||||
prepareSpecialProviderConfig,
|
prepareSpecialProviderConfig,
|
||||||
@@ -64,12 +65,11 @@ export default class ModernAiProvider {
|
|||||||
* - URL will be automatically formatted via `formatProviderApiHost`, adding version suffixes like `/v1`
|
* - URL will be automatically formatted via `formatProviderApiHost`, adding version suffixes like `/v1`
|
||||||
*
|
*
|
||||||
* 2. When called with `(model, provider)`:
|
* 2. When called with `(model, provider)`:
|
||||||
* - **Directly uses the provided provider WITHOUT going through `getActualProvider`**
|
* - The provided provider will be adapted via `adaptProvider`
|
||||||
* - **URL will NOT be automatically formatted, `/v1` suffix will NOT be added**
|
* - URL formatting behavior depends on the adapted result
|
||||||
* - This is legacy behavior kept for backward compatibility
|
|
||||||
*
|
*
|
||||||
* 3. When called with `(provider)`:
|
* 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)
|
* - Used for operations that don't need a model (e.g., fetchModels)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
@@ -77,7 +77,7 @@ export default class ModernAiProvider {
|
|||||||
* // Recommended: Auto-format URL
|
* // Recommended: Auto-format URL
|
||||||
* const ai = new ModernAiProvider(model)
|
* const ai = new ModernAiProvider(model)
|
||||||
*
|
*
|
||||||
* // Not recommended: Skip URL formatting (only for special cases)
|
* // Provider will be adapted
|
||||||
* const ai = new ModernAiProvider(model, customProvider)
|
* const ai = new ModernAiProvider(model, customProvider)
|
||||||
*
|
*
|
||||||
* // For operations that don't need a model
|
* // For operations that don't need a model
|
||||||
@@ -91,12 +91,12 @@ export default class ModernAiProvider {
|
|||||||
if (this.isModel(modelOrProvider)) {
|
if (this.isModel(modelOrProvider)) {
|
||||||
// 传入的是 Model
|
// 传入的是 Model
|
||||||
this.model = modelOrProvider
|
this.model = modelOrProvider
|
||||||
this.actualProvider = provider || getActualProvider(modelOrProvider)
|
this.actualProvider = provider ? adaptProvider({ provider }) : getActualProvider(modelOrProvider)
|
||||||
// 只保存配置,不预先创建executor
|
// 只保存配置,不预先创建executor
|
||||||
this.config = providerToAiSdkConfig(this.actualProvider, modelOrProvider)
|
this.config = providerToAiSdkConfig(this.actualProvider, modelOrProvider)
|
||||||
} else {
|
} else {
|
||||||
// 传入的是 Provider
|
// 传入的是 Provider
|
||||||
this.actualProvider = modelOrProvider
|
this.actualProvider = adaptProvider({ provider: modelOrProvider })
|
||||||
// model为可选,某些操作(如fetchModels)不需要model
|
// model为可选,某些操作(如fetchModels)不需要model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||||
import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio'
|
import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio'
|
||||||
import { getAssistantSettings } from '@renderer/services/AssistantService'
|
import { getAssistantSettings } from '@renderer/services/AssistantService'
|
||||||
|
import type { RootState } from '@renderer/store'
|
||||||
import type {
|
import type {
|
||||||
Assistant,
|
Assistant,
|
||||||
GenerateImageParams,
|
GenerateImageParams,
|
||||||
@@ -245,10 +246,9 @@ export abstract class BaseApiClient<
|
|||||||
|
|
||||||
protected getVerbosity(model?: Model): OpenAIVerbosity {
|
protected getVerbosity(model?: Model): OpenAIVerbosity {
|
||||||
try {
|
try {
|
||||||
const state = window.store?.getState()
|
const state = window.store?.getState() as RootState
|
||||||
const verbosity = state?.settings?.openAI?.verbosity
|
const verbosity = state?.settings?.openAI?.verbosity
|
||||||
|
|
||||||
if (verbosity && ['low', 'medium', 'high'].includes(verbosity)) {
|
|
||||||
// If model is provided, check if the verbosity is supported by the model
|
// If model is provided, check if the verbosity is supported by the model
|
||||||
if (model) {
|
if (model) {
|
||||||
const supportedVerbosity = getModelSupportedVerbosity(model)
|
const supportedVerbosity = getModelSupportedVerbosity(model)
|
||||||
@@ -256,12 +256,10 @@ export abstract class BaseApiClient<
|
|||||||
return supportedVerbosity.includes(verbosity) ? verbosity : supportedVerbosity[0]
|
return supportedVerbosity.includes(verbosity) ? verbosity : supportedVerbosity[0]
|
||||||
}
|
}
|
||||||
return verbosity
|
return verbosity
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to get verbosity from state:', error as Error)
|
logger.warn('Failed to get verbosity from state. Fallback to undefined.', error as Error)
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'medium'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getTimeout(model: Model) {
|
protected getTimeout(model: Model) {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
isSupportedThinkingTokenModel,
|
isSupportedThinkingTokenModel,
|
||||||
isSupportedThinkingTokenQwenModel,
|
isSupportedThinkingTokenQwenModel,
|
||||||
isSupportedThinkingTokenZhipuModel,
|
isSupportedThinkingTokenZhipuModel,
|
||||||
isSupportVerbosityModel,
|
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
MODEL_SUPPORTED_REASONING_EFFORT,
|
MODEL_SUPPORTED_REASONING_EFFORT,
|
||||||
ZHIPU_RESULT_TOKENS
|
ZHIPU_RESULT_TOKENS
|
||||||
@@ -714,13 +713,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
|||||||
...modalities,
|
...modalities,
|
||||||
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
||||||
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
||||||
...(isSupportVerbosityModel(model)
|
// verbosity. getVerbosity ensure the returned value is valid.
|
||||||
? {
|
verbosity: this.getVerbosity(model),
|
||||||
text: {
|
|
||||||
verbosity: this.getVerbosity(model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...this.getProviderSpecificParameters(assistant, model),
|
...this.getProviderSpecificParameters(assistant, model),
|
||||||
...reasoningEffort,
|
...reasoningEffort,
|
||||||
// ...getOpenAIWebSearchParams(model, enableWebSearch),
|
// ...getOpenAIWebSearchParams(model, enableWebSearch),
|
||||||
|
|||||||
@@ -78,11 +78,13 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主要用来对齐AISdk的BaseURL格式
|
* Format and normalize the API host URL for a provider.
|
||||||
* @param provider
|
* Handles provider-specific URL formatting rules (e.g., appending version paths, Azure formatting).
|
||||||
* @returns
|
*
|
||||||
|
* @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 }
|
const formatted = { ...provider }
|
||||||
if (formatted.anthropicApiHost) {
|
if (formatted.anthropicApiHost) {
|
||||||
formatted.anthropicApiHost = formatApiHost(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 {
|
export function getActualProvider(model: Model): Provider {
|
||||||
const baseProvider = getProviderByModel(model)
|
const baseProvider = getProviderByModel(model)
|
||||||
|
|
||||||
// 按顺序处理各种转换
|
return adaptProvider({ provider: baseProvider, model })
|
||||||
let actualProvider = cloneDeep(baseProvider)
|
}
|
||||||
actualProvider = handleSpecialProviders(model, actualProvider)
|
|
||||||
actualProvider = formatProviderApiHost(actualProvider)
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -222,18 +222,22 @@ describe('model utils', () => {
|
|||||||
|
|
||||||
describe('getModelSupportedVerbosity', () => {
|
describe('getModelSupportedVerbosity', () => {
|
||||||
it('returns only "high" for GPT-5 Pro models', () => {
|
it('returns only "high" for GPT-5 Pro models', () => {
|
||||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, 'high'])
|
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, null, 'high'])
|
||||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([undefined, 'high'])
|
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
'high'
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns all levels for non-Pro GPT-5 models', () => {
|
it('returns all levels for non-Pro GPT-5 models', () => {
|
||||||
const previewModel = createModel({ id: 'gpt-5-preview' })
|
const previewModel = createModel({ id: 'gpt-5-preview' })
|
||||||
expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high'])
|
expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, null, 'low', 'medium', 'high'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns all levels for GPT-5.1 models', () => {
|
it('returns all levels for GPT-5.1 models', () => {
|
||||||
const gpt51Model = createModel({ id: 'gpt-5.1-preview' })
|
const gpt51Model = createModel({ id: 'gpt-5.1-preview' })
|
||||||
expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, 'low', 'medium', 'high'])
|
expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, null, 'low', 'medium', 'high'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns only undefined for non-GPT-5 models', () => {
|
it('returns only undefined for non-GPT-5 models', () => {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
isGPT51SeriesModel,
|
isGPT51SeriesModel,
|
||||||
isOpenAIChatCompletionOnlyModel,
|
isOpenAIChatCompletionOnlyModel,
|
||||||
isOpenAIOpenWeightModel,
|
isOpenAIOpenWeightModel,
|
||||||
isOpenAIReasoningModel
|
isOpenAIReasoningModel,
|
||||||
|
isSupportVerbosityModel
|
||||||
} from './openai'
|
} from './openai'
|
||||||
import { isQwenMTModel } from './qwen'
|
import { isQwenMTModel } from './qwen'
|
||||||
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision'
|
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision'
|
||||||
@@ -154,10 +155,10 @@ const MODEL_SUPPORTED_VERBOSITY: readonly {
|
|||||||
* For GPT-5-pro, only 'high' is supported; for other GPT-5 models, 'low', 'medium', and 'high' are supported.
|
* For GPT-5-pro, only 'high' is supported; for other GPT-5 models, 'low', 'medium', and 'high' are supported.
|
||||||
* For GPT-5.1 series models, 'low', 'medium', and 'high' are supported.
|
* For GPT-5.1 series models, 'low', 'medium', and 'high' are supported.
|
||||||
* @param model - The model to check
|
* @param model - The model to check
|
||||||
* @returns An array of supported verbosity levels, always including `undefined` as the first element
|
* @returns An array of supported verbosity levels, always including `undefined` as the first element and `null` when applicable
|
||||||
*/
|
*/
|
||||||
export const getModelSupportedVerbosity = (model: Model | undefined | null): OpenAIVerbosity[] => {
|
export const getModelSupportedVerbosity = (model: Model | undefined | null): OpenAIVerbosity[] => {
|
||||||
if (!model) {
|
if (!model || !isSupportVerbosityModel(model)) {
|
||||||
return [undefined]
|
return [undefined]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ export const getModelSupportedVerbosity = (model: Model | undefined | null): Ope
|
|||||||
|
|
||||||
for (const { validator, values } of MODEL_SUPPORTED_VERBOSITY) {
|
for (const { validator, values } of MODEL_SUPPORTED_VERBOSITY) {
|
||||||
if (validator(model)) {
|
if (validator(model)) {
|
||||||
supportedValues = [...values]
|
supportedValues = [null, ...values]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "Preview: {{url}}",
|
"preview": "Preview: {{url}}",
|
||||||
"reset": "Reset",
|
"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",
|
"api_host": "API Host",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "预览: {{url}}",
|
"preview": "预览: {{url}}",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"tip": "# 结尾强制使用输入地址"
|
"tip": "在末尾添加 # 以禁用自动附加的API版本。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api_host": "API 地址",
|
"api_host": "API 地址",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "預覽:{{url}}",
|
"preview": "預覽:{{url}}",
|
||||||
"reset": "重設",
|
"reset": "重設",
|
||||||
"tip": "# 結尾強制使用輸入位址"
|
"tip": "在末尾添加 # 以停用自動附加的 API 版本。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api_host": "API 主機地址",
|
"api_host": "API 主機地址",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "Vorschau: {{url}}",
|
"preview": "Vorschau: {{url}}",
|
||||||
"reset": "Zurücksetzen",
|
"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",
|
"api_host": "API-Adresse",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "Προεπισκόπηση: {{url}}",
|
"preview": "Προεπισκόπηση: {{url}}",
|
||||||
"reset": "Επαναφορά",
|
"reset": "Επαναφορά",
|
||||||
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
|
"tip": "Προσθέστε το σύμβολο # στο τέλος για να απενεργοποιήσετε την αυτόματα προστιθέμενη έκδοση API."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api_host": "Διεύθυνση API",
|
"api_host": "Διεύθυνση API",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "Vista previa: {{url}}",
|
"preview": "Vista previa: {{url}}",
|
||||||
"reset": "Restablecer",
|
"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",
|
"api_host": "Dirección API",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "Aperçu : {{url}}",
|
"preview": "Aperçu : {{url}}",
|
||||||
"reset": "Réinitialiser",
|
"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",
|
"api_host": "Adresse API",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "プレビュー: {{url}}",
|
"preview": "プレビュー: {{url}}",
|
||||||
"reset": "リセット",
|
"reset": "リセット",
|
||||||
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
|
"tip": "自動的に付加されるAPIバージョンを無効にするには、末尾に#を追加します。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api_host": "APIホスト",
|
"api_host": "APIホスト",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "Pré-visualização: {{url}}",
|
"preview": "Pré-visualização: {{url}}",
|
||||||
"reset": "Redefinir",
|
"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",
|
"api_host": "Endereço API",
|
||||||
|
|||||||
@@ -4372,7 +4372,7 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"preview": "Предпросмотр: {{url}}",
|
"preview": "Предпросмотр: {{url}}",
|
||||||
"reset": "Сброс",
|
"reset": "Сброс",
|
||||||
"tip": "заканчивая на # принудительно использует введенный адрес"
|
"tip": "Добавьте # в конце, чтобы отключить автоматически добавляемую версию API."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api_host": "Хост API",
|
"api_host": "Хост API",
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
type VerbosityOption = {
|
type VerbosityOption = {
|
||||||
value: NonNullable<OpenAIVerbosity> | 'undefined'
|
value: NonNullable<OpenAIVerbosity> | 'undefined' | 'null'
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryTextOption = {
|
type SummaryTextOption = {
|
||||||
value: NonNullable<OpenAISummaryText> | 'undefined'
|
value: NonNullable<OpenAISummaryText> | 'undefined' | 'null'
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +85,10 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
|||||||
value: 'undefined',
|
value: 'undefined',
|
||||||
label: t('common.ignore')
|
label: t('common.ignore')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'null',
|
||||||
|
label: t('common.off')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: t('settings.openai.summary_text_mode.auto')
|
label: t('settings.openai.summary_text_mode.auto')
|
||||||
@@ -105,6 +109,10 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
|||||||
value: 'undefined',
|
value: 'undefined',
|
||||||
label: t('common.ignore')
|
label: t('common.ignore')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'null',
|
||||||
|
label: t('common.off')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'low',
|
value: 'low',
|
||||||
label: t('settings.openai.verbosity.low')
|
label: t('settings.openai.verbosity.low')
|
||||||
@@ -203,9 +211,9 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</SettingRowTitleSmall>
|
</SettingRowTitleSmall>
|
||||||
<Selector
|
<Selector
|
||||||
value={summaryText}
|
value={toOptionValue(summaryText)}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSummaryText(value as OpenAISummaryText)
|
setSummaryText(toRealValue(value))
|
||||||
}}
|
}}
|
||||||
options={summaryTextOptions}
|
options={summaryTextOptions}
|
||||||
/>
|
/>
|
||||||
@@ -222,9 +230,9 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</SettingRowTitleSmall>
|
</SettingRowTitleSmall>
|
||||||
<Selector
|
<Selector
|
||||||
value={verbosity}
|
value={toOptionValue(verbosity)}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setVerbosity(value as OpenAIVerbosity)
|
setVerbosity(toRealValue(value))
|
||||||
}}
|
}}
|
||||||
options={verbosityOptions}
|
options={verbosityOptions}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { adaptProvider } from '@renderer/aiCore/provider/providerConfig'
|
||||||
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
|
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
|
||||||
import { LoadingIcon } from '@renderer/components/Icons'
|
import { LoadingIcon } from '@renderer/components/Icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
|
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
|
||||||
import Selector from '@renderer/components/Selector'
|
import Selector from '@renderer/components/Selector'
|
||||||
|
import { HelpTooltip } from '@renderer/components/TooltipIcons'
|
||||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||||
import { PROVIDER_URLS } from '@renderer/config/providers'
|
import { PROVIDER_URLS } from '@renderer/config/providers'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
@@ -19,14 +21,7 @@ import type { SystemProviderId } from '@renderer/types'
|
|||||||
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
|
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
|
||||||
import type { ApiKeyConnectivity } from '@renderer/types/healthCheck'
|
import type { ApiKeyConnectivity } from '@renderer/types/healthCheck'
|
||||||
import { HealthStatus } from '@renderer/types/healthCheck'
|
import { HealthStatus } from '@renderer/types/healthCheck'
|
||||||
import {
|
import { formatApiHost, formatApiKeys, getFancyProviderName, validateApiHost } from '@renderer/utils'
|
||||||
formatApiHost,
|
|
||||||
formatApiKeys,
|
|
||||||
formatAzureOpenAIApiHost,
|
|
||||||
formatVertexApiHost,
|
|
||||||
getFancyProviderName,
|
|
||||||
validateApiHost
|
|
||||||
} from '@renderer/utils'
|
|
||||||
import { formatErrorMessage } from '@renderer/utils/error'
|
import { formatErrorMessage } from '@renderer/utils/error'
|
||||||
import {
|
import {
|
||||||
isAIGatewayProvider,
|
isAIGatewayProvider,
|
||||||
@@ -36,7 +31,6 @@ import {
|
|||||||
isNewApiProvider,
|
isNewApiProvider,
|
||||||
isOpenAICompatibleProvider,
|
isOpenAICompatibleProvider,
|
||||||
isOpenAIProvider,
|
isOpenAIProvider,
|
||||||
isSupportAPIVersionProvider,
|
|
||||||
isVertexProvider
|
isVertexProvider
|
||||||
} from '@renderer/utils/provider'
|
} from '@renderer/utils/provider'
|
||||||
import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd'
|
import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd'
|
||||||
@@ -281,12 +275,10 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
|||||||
}, [configuredApiHost, apiHost])
|
}, [configuredApiHost, apiHost])
|
||||||
|
|
||||||
const hostPreview = () => {
|
const hostPreview = () => {
|
||||||
if (apiHost.endsWith('#')) {
|
const formattedApiHost = adaptProvider({ provider: { ...provider, apiHost } }).apiHost
|
||||||
return apiHost.replace('#', '')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOpenAICompatibleProvider(provider)) {
|
if (isOpenAICompatibleProvider(provider)) {
|
||||||
return formatApiHost(apiHost, isSupportAPIVersionProvider(provider)) + '/chat/completions'
|
return formattedApiHost + '/chat/completions'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAzureOpenAIProvider(provider)) {
|
if (isAzureOpenAIProvider(provider)) {
|
||||||
@@ -294,29 +286,26 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
|||||||
const path = !['preview', 'v1'].includes(apiVersion)
|
const path = !['preview', 'v1'].includes(apiVersion)
|
||||||
? `/v1/chat/completion?apiVersion=v1`
|
? `/v1/chat/completion?apiVersion=v1`
|
||||||
: `/v1/responses?apiVersion=v1`
|
: `/v1/responses?apiVersion=v1`
|
||||||
return formatAzureOpenAIApiHost(apiHost) + path
|
return formattedApiHost + path
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAnthropicProvider(provider)) {
|
if (isAnthropicProvider(provider)) {
|
||||||
// AI SDK uses the baseURL with /v1, then appends /messages
|
return formattedApiHost + '/messages'
|
||||||
// formatApiHost adds /v1 automatically if not present
|
|
||||||
const normalizedHost = formatApiHost(apiHost)
|
|
||||||
return normalizedHost + '/messages'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGeminiProvider(provider)) {
|
if (isGeminiProvider(provider)) {
|
||||||
return formatApiHost(apiHost, true, 'v1beta') + '/models'
|
return formattedApiHost + '/models'
|
||||||
}
|
}
|
||||||
if (isOpenAIProvider(provider)) {
|
if (isOpenAIProvider(provider)) {
|
||||||
return formatApiHost(apiHost) + '/responses'
|
return formattedApiHost + '/responses'
|
||||||
}
|
}
|
||||||
if (isVertexProvider(provider)) {
|
if (isVertexProvider(provider)) {
|
||||||
return formatVertexApiHost(provider) + '/publishers/google'
|
return formattedApiHost + '/publishers/google'
|
||||||
}
|
}
|
||||||
if (isAIGatewayProvider(provider)) {
|
if (isAIGatewayProvider(provider)) {
|
||||||
return formatApiHost(apiHost) + '/language-model'
|
return formattedApiHost + '/language-model'
|
||||||
}
|
}
|
||||||
return formatApiHost(apiHost)
|
return formattedApiHost
|
||||||
}
|
}
|
||||||
|
|
||||||
// API key 连通性检查状态指示器,目前仅在失败时显示
|
// API key 连通性检查状态指示器,目前仅在失败时显示
|
||||||
@@ -494,7 +483,9 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
|||||||
{!isDmxapi && (
|
{!isDmxapi && (
|
||||||
<>
|
<>
|
||||||
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
|
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
|
||||||
|
<div>
|
||||||
<Selector
|
<Selector
|
||||||
size={14}
|
size={14}
|
||||||
value={activeHostField}
|
value={activeHostField}
|
||||||
@@ -503,7 +494,10 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
|||||||
style={{ paddingLeft: 1, fontWeight: 'bold' }}
|
style={{ paddingLeft: 1, fontWeight: 'bold' }}
|
||||||
placement="bottomLeft"
|
placement="bottomLeft"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<HelpTooltip title={t('settings.provider.api.url.tip')}></HelpTooltip>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -2906,6 +2906,23 @@ const migrateConfig = {
|
|||||||
logger.error('migrate 179 error', error as Error)
|
logger.error('migrate 179 error', error as Error)
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'180': (state: RootState) => {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error
|
||||||
|
if (state.settings.openAI.summaryText === 'undefined') {
|
||||||
|
state.settings.openAI.summaryText = undefined
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
if (state.settings.openAI.verbosity === 'undefined') {
|
||||||
|
state.settings.openAI.summaryText = undefined
|
||||||
|
}
|
||||||
|
logger.info('migrate 180 success')
|
||||||
|
return state
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('migrate 180 error', error as Error)
|
||||||
|
return state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type OpenAI from '@cherrystudio/openai'
|
import type OpenAI from '@cherrystudio/openai'
|
||||||
import type { NotNull, NotUndefined } from '@types'
|
import type { NotUndefined } from '@types'
|
||||||
import type { ImageModel, LanguageModel } from 'ai'
|
import type { ImageModel, LanguageModel } from 'ai'
|
||||||
import type { generateObject, generateText, ModelMessage, streamObject, streamText } from 'ai'
|
import type { generateObject, generateText, ModelMessage, streamObject, streamText } from 'ai'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
@@ -32,17 +32,15 @@ export type GenerateObjectParams = Omit<Parameters<typeof generateObject>[0], 'm
|
|||||||
export type AiSdkModel = LanguageModel | ImageModel
|
export type AiSdkModel = LanguageModel | ImageModel
|
||||||
|
|
||||||
// The original type unite both undefined and null.
|
// The original type unite both undefined and null.
|
||||||
// I pick undefined as the unique falsy type since they seem like share the same meaning according to OpenAI API docs.
|
|
||||||
// Parameter would not be passed into request if it's undefined.
|
// Parameter would not be passed into request if it's undefined.
|
||||||
export type OpenAIVerbosity = NotNull<OpenAI.Responses.ResponseTextConfig['verbosity']>
|
export type OpenAIVerbosity = OpenAI.Responses.ResponseTextConfig['verbosity']
|
||||||
export type ValidOpenAIVerbosity = NotUndefined<OpenAIVerbosity>
|
export type ValidOpenAIVerbosity = NotUndefined<OpenAIVerbosity>
|
||||||
|
|
||||||
export type OpenAIReasoningEffort = OpenAI.ReasoningEffort
|
export type OpenAIReasoningEffort = OpenAI.ReasoningEffort
|
||||||
|
|
||||||
// The original type unite both undefined and null.
|
// The original type unite both undefined and null.
|
||||||
// I pick undefined as the unique falsy type since they seem like share the same meaning according to OpenAI API docs.
|
|
||||||
// Parameter would not be passed into request if it's undefined.
|
// Parameter would not be passed into request if it's undefined.
|
||||||
export type OpenAISummaryText = NotNull<OpenAI.Reasoning['summary']>
|
export type OpenAISummaryText = OpenAI.Reasoning['summary']
|
||||||
|
|
||||||
const AiSdkParamsSchema = z.enum([
|
const AiSdkParamsSchema = z.enum([
|
||||||
'maxOutputTokens',
|
'maxOutputTokens',
|
||||||
|
|||||||
@@ -128,10 +128,6 @@ export type OpenAIExtraBody = {
|
|||||||
source_lang: 'auto'
|
source_lang: 'auto'
|
||||||
target_lang: string
|
target_lang: string
|
||||||
}
|
}
|
||||||
// for gpt-5 series models verbosity control
|
|
||||||
text?: {
|
|
||||||
verbosity?: 'low' | 'medium' | 'high'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// image is for openrouter. audio is ignored for now
|
// image is for openrouter. audio is ignored for now
|
||||||
export type OpenAIModality = OpenAI.ChatCompletionModality | 'image'
|
export type OpenAIModality = OpenAI.ChatCompletionModality | 'image'
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
routeToEndpoint,
|
routeToEndpoint,
|
||||||
splitApiKeyString,
|
splitApiKeyString,
|
||||||
validateApiHost,
|
validateApiHost,
|
||||||
withoutTrailingApiVersion
|
withoutTrailingApiVersion,
|
||||||
|
withoutTrailingSharp
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
|
||||||
vi.mock('@renderer/store', () => {
|
vi.mock('@renderer/store', () => {
|
||||||
@@ -81,6 +82,27 @@ describe('api', () => {
|
|||||||
it('keeps host untouched when api version unsupported', () => {
|
it('keeps host untouched when api version unsupported', () => {
|
||||||
expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com')
|
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', () => {
|
describe('hasAPIVersion', () => {
|
||||||
@@ -404,4 +426,56 @@ describe('api', () => {
|
|||||||
expect(withoutTrailingApiVersion('')).toBe('')
|
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
|
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.
|
* 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'`.
|
* @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.
|
* @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.
|
* Otherwise, returns the host with the API version appended.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1'
|
* 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'
|
* 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 {
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) {
|
const shouldAppendApiVersion = !(normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost))
|
||||||
return normalizedHost
|
|
||||||
}
|
if (shouldAppendApiVersion) {
|
||||||
return `${normalizedHost}/${apiVersion}`
|
return `${normalizedHost}/${apiVersion}`
|
||||||
|
} else {
|
||||||
|
return withoutTrailingSharp(normalizedHost)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user