diff --git a/package.json b/package.json index 17be71ee59..3f95aee6d5 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch", "@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", "@mistralai/mistralai": "^1.7.5", - "@modelcontextprotocol/sdk": "^1.17.5", + "@modelcontextprotocol/sdk": "^1.23.0", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@openrouter/ai-sdk-provider": "^1.2.8", @@ -207,6 +207,7 @@ "@types/content-type": "^1.1.9", "@types/cors": "^2.8.19", "@types/diff": "^7", + "@types/dotenv": "^8.2.3", "@types/express": "^5", "@types/fs-extra": "^11", "@types/he": "^1", diff --git a/packages/shared/api/index.ts b/packages/shared/api/index.ts index 2e85c11c36..dbc8c627a6 100644 --- a/packages/shared/api/index.ts +++ b/packages/shared/api/index.ts @@ -26,6 +26,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 +} + /** * Matches a version segment in a path that starts with `/v` and optionally * continues with `alpha` or `beta`. The segment may be followed by `/` or the end @@ -93,12 +110,12 @@ export function formatVertexApiHost( * @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 { @@ -107,10 +124,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}` } /** diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 0b8db73930..3925376226 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -12,6 +12,7 @@ import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import type { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport, @@ -42,11 +43,14 @@ import { type MCPPrompt, type MCPResource, type MCPServer, - type MCPTool + type MCPTool, + MCPToolInputSchema, + MCPToolOutputSchema } from '@types' import { app, net } from 'electron' import { EventEmitter } from 'events' import { v4 as uuidv4 } from 'uuid' +import * as z from 'zod' import { CacheService } from './CacheService' import DxtService from './DxtService' @@ -343,7 +347,7 @@ class McpService { removeEnvProxy(loginShellEnv) } - const transportOptions: any = { + const transportOptions: StdioServerParameters = { command: cmd, args, env: { @@ -620,6 +624,8 @@ class McpService { tools.map((tool: SDKTool) => { const serverTool: MCPTool = { ...tool, + inputSchema: z.parse(MCPToolInputSchema, tool.inputSchema), + outputSchema: tool.outputSchema ? z.parse(MCPToolOutputSchema, tool.outputSchema) : undefined, id: buildFunctionCallToolName(server.name, tool.name, server.id), serverId: server.id, serverName: server.name, 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/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index c977745a39..6eccbf8bc5 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -177,8 +177,12 @@ export async function buildStreamTextParams( let headers: Record = options.requestOptions?.headers ?? {} if (isAnthropicModel(model) && !isAwsBedrockProvider(provider)) { - const newBetaHeaders = { 'anthropic-beta': addAnthropicHeaders(assistant, model).join(',') } - headers = combineHeaders(headers, newBetaHeaders) + const betaHeaders = addAnthropicHeaders(assistant, model) + // Only add the anthropic-beta header if there are actual beta headers to include + if (betaHeaders.length > 0) { + const newBetaHeaders = { 'anthropic-beta': betaHeaders.join(',') } + headers = combineHeaders(headers, newBetaHeaders) + } } // 构建基础参数 diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 8e1a63f5a0..0d2db2ebdd 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -94,25 +94,52 @@ function getRendererFormatContext(): ProviderFormatContext { } } +/** + * 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 { return sharedFormatProviderApiHost(provider, getRendererFormatContext()) } /** - * 获取实际的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 = resolveActualProvider(actualProvider, model, { - isSystemProvider - }) as Provider - 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 = resolveActualProvider(adaptedProvider, model, { + isSystemProvider + }) + } + 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/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index 6d4210fd1d..c48ae8f794 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -7,6 +7,7 @@ import { useMCPServer, useMCPServers } from '@renderer/hooks/useMCPServers' import { useMCPServerTrust } from '@renderer/hooks/useMCPServerTrust' import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription' import type { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types' +import { parseKeyValueString } from '@renderer/utils/env' import { formatMcpError } from '@renderer/utils/error' import type { TabsProps } from 'antd' import { Badge, Button, Flex, Form, Input, Radio, Select, Switch, Tabs } from 'antd' @@ -63,21 +64,6 @@ const PipRegistry: Registry[] = [ type TabKey = 'settings' | 'description' | 'tools' | 'prompts' | 'resources' -const parseKeyValueString = (str: string): Record => { - const result: Record = {} - str.split('\n').forEach((line) => { - if (line.trim()) { - const [key, ...value] = line.split('=') - const formatValue = value.join('=').trim() - const formatKey = key.trim() - if (formatKey && formatValue) { - result[formatKey] = formatValue - } - } - }) - return result -} - const McpSettings: React.FC = () => { const { t } = useTranslation() const { serverId } = useParams<{ serverId: string }>() diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index b85690c3fb..7aa992b7ba 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' @@ -282,12 +276,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)) { @@ -295,29 +287,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 连通性检查状态指示器,目前仅在失败时显示 @@ -495,16 +484,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" + /> +
+
+ +