From 71b527b67c2a2d2cb2c9536fc4d31463e9c51392 Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 23 Jul 2025 16:19:54 +0800 Subject: [PATCH] feat/hunyuan-a13b (#8405) * refactor(AiProvider): enhance client compatibility checks and middleware handling - Updated AiProvider to use a compatibility type check for API clients, improving type safety and middleware management. - Implemented getClientCompatibilityType in AihubmixAPIClient, NewAPIClient, and OpenAIResponseAPIClient to return actual client types. - Added support for Hunyuan models in various model checks and updated the ThinkingButton component to reflect these changes. - Improved logging for middleware construction in AiProvider. * test(ApiService): add client compatibility type checks for mock API clients * fix: minimax-m1 reasoning export btw --------- Co-authored-by: Pleasurecruise <3196812536@qq.com> --- .../src/aiCore/clients/AihubmixAPIClient.ts | 12 ++++++++ .../src/aiCore/clients/BaseApiClient.ts | 11 +++++++ .../src/aiCore/clients/NewAPIClient.ts | 12 ++++++++ .../aiCore/clients/openai/OpenAIApiClient.ts | 10 ++++++- .../clients/openai/OpenAIResponseAPIClient.ts | 12 ++++++++ src/renderer/src/aiCore/index.ts | 15 ++++++---- src/renderer/src/config/models.ts | 30 ++++++++++++++++--- .../pages/home/Inputbar/ThinkingButton.tsx | 12 ++++---- .../src/services/__tests__/ApiService.test.ts | 6 ++-- 9 files changed, 103 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts b/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts index e054057be1..ffd2140d54 100644 --- a/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts +++ b/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts @@ -136,6 +136,18 @@ export class AihubmixAPIClient extends BaseApiClient { return this.currentClient } + /** + * 重写基类方法,返回内部实际使用的客户端类型 + */ + public override getClientCompatibilityType(model?: Model): string[] { + if (!model) { + return [this.constructor.name] + } + + const actualClient = this.getClient(model) + return actualClient.getClientCompatibilityType(model) + } + // ============ BaseApiClient 抽象方法实现 ============ async createCompletions(payload: SdkParams, options?: RequestOptions): Promise { diff --git a/src/renderer/src/aiCore/clients/BaseApiClient.ts b/src/renderer/src/aiCore/clients/BaseApiClient.ts index 2a174ecacc..d4343da925 100644 --- a/src/renderer/src/aiCore/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/clients/BaseApiClient.ts @@ -75,6 +75,17 @@ export abstract class BaseApiClient< this.apiKey = this.getApiKey() } + /** + * 获取客户端的兼容性类型 + * 用于判断客户端是否支持特定功能,避免instanceof检查的类型收窄问题 + * 对于装饰器模式的客户端(如AihubmixAPIClient),应该返回其内部实际使用的客户端类型 + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getClientCompatibilityType(_model?: Model): string[] { + // 默认返回类的名称 + return [this.constructor.name] + } + // // 核心的completions方法 - 在中间件架构中,这通常只是一个占位符 // abstract completions(params: CompletionsParams, internal?: ProcessingState): Promise diff --git a/src/renderer/src/aiCore/clients/NewAPIClient.ts b/src/renderer/src/aiCore/clients/NewAPIClient.ts index 5df64faee9..6242f6a320 100644 --- a/src/renderer/src/aiCore/clients/NewAPIClient.ts +++ b/src/renderer/src/aiCore/clients/NewAPIClient.ts @@ -128,6 +128,18 @@ export class NewAPIClient extends BaseApiClient { return this.currentClient } + /** + * 重写基类方法,返回内部实际使用的客户端类型 + */ + public override getClientCompatibilityType(model?: Model): string[] { + if (!model) { + return [this.constructor.name] + } + + const actualClient = this.getClient(model) + return actualClient.getClientCompatibilityType(model) + } + // ============ BaseApiClient 抽象方法实现 ============ async createCompletions(payload: SdkParams, options?: RequestOptions): Promise { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index f1e1e50cfe..60d93024b5 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -14,6 +14,7 @@ import { isSupportedThinkingTokenClaudeModel, isSupportedThinkingTokenDoubaoModel, isSupportedThinkingTokenGeminiModel, + isSupportedThinkingTokenHunyuanModel, isSupportedThinkingTokenModel, isSupportedThinkingTokenQwenModel, isVisionModel @@ -128,7 +129,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } return { reasoning: { enabled: false, exclude: true } } } - if (isSupportedThinkingTokenQwenModel(model)) { + if (isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model)) { return { enable_thinking: false } } @@ -188,6 +189,13 @@ export class OpenAIAPIClient extends OpenAIBaseClient< return thinkConfig } + // Hunyuan models + if (isSupportedThinkingTokenHunyuanModel(model)) { + return { + enable_thinking: true + } + } + // Grok models if (isSupportedReasoningEffortGrokModel(model)) { return { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 8ea704723e..450f6f4dfc 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -96,6 +96,18 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< } } + /** + * 重写基类方法,返回内部实际使用的客户端类型 + */ + public override getClientCompatibilityType(model?: Model): string[] { + if (!model) { + return [this.constructor.name] + } + + const actualClient = this.getClient(model) + return actualClient.getClientCompatibilityType(model) + } + override async getSdkInstance() { if (this.sdkInstance) { return this.sdkInstance diff --git a/src/renderer/src/aiCore/index.ts b/src/renderer/src/aiCore/index.ts index f96a78a3c4..68f3ab662f 100644 --- a/src/renderer/src/aiCore/index.ts +++ b/src/renderer/src/aiCore/index.ts @@ -9,9 +9,7 @@ import type { GenerateImageParams, Model, Provider } from '@renderer/types' import type { RequestOptions, SdkModel } from '@renderer/types/sdk' import { isEnabledToolUse } from '@renderer/utils/mcp-tools' -import { OpenAIAPIClient } from './clients' import { AihubmixAPIClient } from './clients/AihubmixAPIClient' -import { AnthropicAPIClient } from './clients/anthropic/AnthropicAPIClient' import { NewAPIClient } from './clients/NewAPIClient' import { OpenAIResponseAPIClient } from './clients/openai/OpenAIResponseAPIClient' import { CompletionsMiddlewareBuilder } from './middleware/builder' @@ -87,12 +85,18 @@ export default class AiProvider { builder.remove(ThinkChunkMiddlewareName) logger.silly('ThinkChunkMiddleware is removed') } - // 注意:用client判断会导致typescript类型收窄 - if (!(this.apiClient instanceof OpenAIAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) { + // 使用兼容性类型检查,避免typescript类型收窄和装饰器模式的问题 + const clientTypes = client.getClientCompatibilityType(model) + const isOpenAICompatible = + clientTypes.includes('OpenAIAPIClient') || clientTypes.includes('OpenAIResponseAPIClient') + if (!isOpenAICompatible) { logger.silly('ThinkingTagExtractionMiddleware is removed') builder.remove(ThinkingTagExtractionMiddlewareName) } - if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) { + + const isAnthropicOrOpenAIResponseCompatible = + clientTypes.includes('AnthropicAPIClient') || clientTypes.includes('OpenAIResponseAPIClient') + if (!isAnthropicOrOpenAIResponseCompatible) { logger.silly('RawStreamListenerMiddleware is removed') builder.remove(RawStreamListenerMiddlewareName) } @@ -123,6 +127,7 @@ export default class AiProvider { } const middlewares = builder.build() + logger.silly('middlewares', middlewares) // 3. Create the wrapped SDK method with middlewares const wrappedCompletionMethod = applyCompletionsMiddlewares(client, client.createCompletions, middlewares) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index b2f1584b85..ef3ab76a04 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2513,7 +2513,8 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean { isSupportedThinkingTokenGeminiModel(model) || isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenClaudeModel(model) || - isSupportedThinkingTokenDoubaoModel(model) + isSupportedThinkingTokenDoubaoModel(model) || + isSupportedThinkingTokenHunyuanModel(model) ) } @@ -2598,6 +2599,10 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean { const baseName = getLowerBaseModelName(model.id, '/') + if (baseName.includes('coder')) { + return false + } + return ( baseName.startsWith('qwen3') || [ @@ -2639,12 +2644,27 @@ export function isClaudeReasoningModel(model?: Model): boolean { export const isSupportedThinkingTokenClaudeModel = isClaudeReasoningModel +export const isSupportedThinkingTokenHunyuanModel = (model?: Model): boolean => { + if (!model) { + return false + } + const baseName = getLowerBaseModelName(model.id, '/') + return baseName.includes('hunyuan-a13b') +} + +export const isHunyuanReasoningModel = (model?: Model): boolean => { + if (!model) { + return false + } + return isSupportedThinkingTokenHunyuanModel(model) || model.id.toLowerCase().includes('hunyuan-t1') +} + export function isReasoningModel(model?: Model): boolean { if (!model) { return false } - if (isEmbeddingModel(model)) { + if (isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) { return false } @@ -2664,8 +2684,10 @@ export function isReasoningModel(model?: Model): boolean { isGeminiReasoningModel(model) || isQwenReasoningModel(model) || isGrokReasoningModel(model) || - model.id.includes('glm-z1') || - model.id.includes('magistral') + isHunyuanReasoningModel(model) || + model.id.toLowerCase().includes('glm-z1') || + model.id.toLowerCase().includes('magistral') || + model.id.toLowerCase().includes('minimax-m1') ) { return true } diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 810d0158f4..46a985baab 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -12,6 +12,7 @@ import { isSupportedReasoningEffortGrokModel, isSupportedThinkingTokenDoubaoModel, isSupportedThinkingTokenGeminiModel, + isSupportedThinkingTokenHunyuanModel, isSupportedThinkingTokenQwenModel } from '@renderer/config/models' import { useAssistant } from '@renderer/hooks/useAssistant' @@ -40,7 +41,8 @@ const MODEL_SUPPORTED_OPTIONS: Record = { gemini: ['off', 'low', 'medium', 'high', 'auto'], gemini_pro: ['low', 'medium', 'high', 'auto'], qwen: ['off', 'low', 'medium', 'high'], - doubao: ['off', 'auto', 'high'] + doubao: ['off', 'auto', 'high'], + hunyuan: ['off', 'auto'] } // 选项转换映射表:当选项不支持时使用的替代选项 @@ -62,6 +64,7 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re const isGeminiFlashModel = GEMINI_FLASH_MODEL_REGEX.test(model.id) const isQwenModel = isSupportedThinkingTokenQwenModel(model) const isDoubaoModel = isSupportedThinkingTokenDoubaoModel(model) + const isHunyuanModel = isSupportedThinkingTokenHunyuanModel(model) const currentReasoningEffort = useMemo(() => { return assistant.settings?.reasoning_effort || 'off' @@ -79,8 +82,9 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re if (isGrokModel) return 'grok' if (isQwenModel) return 'qwen' if (isDoubaoModel) return 'doubao' + if (isHunyuanModel) return 'hunyuan' return 'default' - }, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel, isGeminiFlashModel]) + }, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel, isGeminiFlashModel, isHunyuanModel]) // 获取当前模型支持的选项 const supportedOptions = useMemo(() => { @@ -145,7 +149,7 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re [updateAssistantSettings] ) - const baseOptions = useMemo(() => { + const panelItems = useMemo(() => { // 使用表中定义的选项创建UI选项 return supportedOptions.map((option) => ({ level: option, @@ -157,8 +161,6 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re })) }, [t, createThinkingIcon, currentReasoningEffort, supportedOptions, onThinkingChange]) - const panelItems = baseOptions - const openQuickPanel = useCallback(() => { quickPanel.open({ title: t('assistants.settings.reasoning_effort'), diff --git a/src/renderer/src/services/__tests__/ApiService.test.ts b/src/renderer/src/services/__tests__/ApiService.test.ts index 2a7bf29589..b4f43e0cd8 100644 --- a/src/renderer/src/services/__tests__/ApiService.test.ts +++ b/src/renderer/src/services/__tests__/ApiService.test.ts @@ -1047,7 +1047,8 @@ const mockOpenaiApiClient = { provider: {} as Provider, useSystemPromptForTools: true, getBaseURL: vi.fn(() => 'https://api.openai.com'), - getApiKey: vi.fn(() => 'mock-api-key') + getApiKey: vi.fn(() => 'mock-api-key'), + getClientCompatibilityType: vi.fn(() => ['OpenAIAPIClient']) } as unknown as OpenAIAPIClient // 创建 mock 的 GeminiAPIClient @@ -1165,7 +1166,8 @@ const mockGeminiApiClient = { provider: {} as Provider, useSystemPromptForTools: true, getBaseURL: vi.fn(() => 'https://api.gemini.com'), - getApiKey: vi.fn(() => 'mock-api-key') + getApiKey: vi.fn(() => 'mock-api-key'), + getClientCompatibilityType: vi.fn(() => ['GeminiAPIClient']) } as unknown as GeminiAPIClient const mockGeminiThinkingApiClient = cloneDeep(mockGeminiApiClient)