diff --git a/src/renderer/src/aiCore/utils/__tests__/options.test.ts b/src/renderer/src/aiCore/utils/__tests__/options.test.ts index d3622e8696..77b4c1811f 100644 --- a/src/renderer/src/aiCore/utils/__tests__/options.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/options.test.ts @@ -1125,7 +1125,8 @@ describe('options utils', () => { it.each([ { providerId: 'newapi', providerName: 'NewAPI' }, - { providerId: 'aihubmix', providerName: 'AiHubMix' } + { providerId: 'aihubmix', providerName: 'AiHubMix' }, + { providerId: 'cherryin', providerName: 'CherryIN' } ])( 'should route Gemini models to google providerOptions through $providerName', async ({ providerId, providerName }) => { @@ -1166,10 +1167,88 @@ describe('options utils', () => { // Note: For proxy providers like aihubmix/newapi, users should write AI SDK provider ID (google/anthropic) // instead of the Cherry Studio provider ID for custom parameters to work correctly + // model.endpoint_type takes priority over the short-name heuristic so the providerOptions key + // stays aligned with the SDK language-model class each proxy builds. Covers CherryIN's + // mixed-routing models (e.g. `minimax/minimax-m2.7` using the Anthropic endpoint) and + // NewAPI's endpoint_type-driven routing. + it.each([ + { + providerId: 'cherryin', + modelId: 'minimax/minimax-m2.7', + endpointType: 'anthropic' as const, + expectedKey: 'anthropic' + }, + { + providerId: 'cherryin', + modelId: 'custom-id', + endpointType: 'gemini' as const, + expectedKey: 'google' + }, + { + providerId: 'cherryin', + modelId: 'gpt-5', + endpointType: 'openai-response' as const, + expectedKey: 'openai' + }, + { + providerId: 'cherryin', + modelId: 'qwen-max', + endpointType: 'openai' as const, + expectedKey: 'openai-compatible' + }, + { + providerId: 'newapi', + modelId: 'proxy/model', + endpointType: 'anthropic' as const, + expectedKey: 'anthropic' + }, + { + providerId: 'newapi', + modelId: 'proxy/model', + endpointType: 'openai' as const, + expectedKey: 'openai-compatible' + } + ])( + 'should honor model.endpoint_type=$endpointType for $providerId and produce providerOptions.$expectedKey', + async ({ providerId, modelId, endpointType, expectedKey }) => { + const { getCustomParameters } = await import('../reasoning') + vi.mocked(getCustomParameters).mockReturnValue({ + customEndpointParam: 'custom_value' + }) + + const provider: Provider = { + id: providerId, + name: providerId, + type: 'openai', + models: [] as Model[] + } as Provider + + const model: Model = { + id: modelId, + name: modelId, + provider: providerId, + endpoint_type: endpointType + } as Model + + const result = buildProviderOptions(mockAssistant, model, provider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.providerOptions).toHaveProperty(expectedKey) + expect(result.providerOptions).not.toHaveProperty(providerId) + expect(result.providerOptions[expectedKey]).toMatchObject({ + customEndpointParam: 'custom_value' + }) + } + ) + it('should handle cherryin fallback to openai-compatible with custom parameters', async () => { const { getCustomParameters } = await import('../reasoning') - // Mock cherryin provider that falls back to openai-compatible (default case) + // Mock cherryin provider with a non-Gemini/Claude/GPT/Grok model that falls back + // to openai-compatible via buildAIGatewayOptions const cherryinProvider = { id: 'cherryin', name: 'Cherry In', @@ -1196,10 +1275,11 @@ describe('options utils', () => { enableGenerateImage: false }) - // When cherryin falls back to default case, it should use rawProviderId (cherryin) - // User's cherryin params should merge with the provider options - expect(result.providerOptions).toHaveProperty('cherryin') - expect(result.providerOptions.cherryin).toMatchObject({ + // Non-Gemini/Claude/GPT/Grok models fall back to openai-compatible via buildAIGatewayOptions. + // User's custom params (not matching any AI SDK provider ID) merge into the primary bucket. + expect(result.providerOptions).toHaveProperty('openai-compatible') + expect(result.providerOptions).not.toHaveProperty('cherryin') + expect(result.providerOptions['openai-compatible']).toMatchObject({ customCherryinOption: 'cherryin_value' }) }) diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 0bee602478..ec1e48882a 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -192,19 +192,10 @@ export function buildProviderOptions( case 'bedrock': providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities) break - case 'cherryin': - providerSpecificOptions = buildCherryInProviderOptions( - assistant, - model, - capabilities, - actualProvider, - serviceTier, - textVerbosity - ) - break case SystemProviderIds.ollama: providerSpecificOptions = buildOllamaProviderOptions(assistant, model, capabilities) break + case 'cherryin': case 'newapi': case 'aihubmix': case SystemProviderIds.gateway: @@ -464,29 +455,6 @@ function buildXAIProviderOptions( } } -function buildCherryInProviderOptions( - assistant: Assistant, - model: Model, - capabilities: Pick, - actualProvider: Provider, - serviceTier: OpenAIServiceTier, - textVerbosity: OpenAIVerbosity -): Record { - switch (actualProvider.type) { - case 'openai': - return buildGenericProviderOptions('cherryin', assistant, model, capabilities) - case 'openai-response': - return buildOpenAIProviderOptions(assistant, model, capabilities, serviceTier, textVerbosity) - case 'anthropic': - return buildAnthropicProviderOptions(assistant, model, capabilities) - case 'gemini': - return buildGeminiProviderOptions(assistant, model, capabilities) - - default: - return buildGenericProviderOptions('cherryin', assistant, model, capabilities) - } -} - /** * Build Bedrock providerOptions */ @@ -593,6 +561,32 @@ function buildAIGatewayOptions( | GoogleGenerativeAIProviderOptions | Record > { + // Proxy providers (CherryIN, NewAPI) may annotate model.endpoint_type to force a specific protocol. + // Keys here must stay aligned with the language-model class each SDK layer builds, otherwise + // AI SDK drops the custom provider options. See: + // - packages/ai-sdk-provider/src/cherryin-provider.ts (createChatModel) + // - src/renderer/src/aiCore/provider/custom/newapi-provider.ts (createChatModel) + // + // endpoint_type | SDK language-model class | AI SDK providerOptions key + // -------------------+------------------------------------+--------------------------- + // 'anthropic' | AnthropicMessagesLanguageModel | anthropic + // 'gemini' | GoogleGenerativeAILanguageModel | google + // 'openai-response' | OpenAIResponsesLanguageModel | openai + // 'openai' | OpenAICompatibleChatLanguageModel | openai-compatible + // 'image-generation' | OpenAICompatibleChatLanguageModel | openai-compatible + switch (model.endpoint_type) { + case 'anthropic': + return buildAnthropicProviderOptions(assistant, model, capabilities) + case 'gemini': + return buildGeminiProviderOptions(assistant, model, capabilities) + case 'openai-response': + return buildOpenAIProviderOptions(assistant, model, capabilities, serviceTier, textVerbosity) + case 'openai': + case 'image-generation': + return buildGenericProviderOptions('openai-compatible', assistant, model, capabilities) + } + + // Fallback: model-name heuristic (covers Vercel Gateway, AiHubMix, and untagged models) if (isAnthropicModel(model)) { return buildAnthropicProviderOptions(assistant, model, capabilities) } else if (isOpenAIModel(model)) {