Merge branch 'main' into v2

This commit is contained in:
fullex
2025-11-26 20:56:00 +08:00
107 changed files with 3290 additions and 956 deletions
+3
View File
@@ -1,6 +1,7 @@
import { cacheService } from '@data/CacheService'
import { loggerService } from '@logger'
import { reduxService } from '@main/services/ReduxService'
import { isSiliconAnthropicCompatibleModel } from '@shared/config/providers'
import type { ApiModel, Model, Provider } from '@types'
const logger = loggerService.withContext('ApiServerUtils')
@@ -287,6 +288,8 @@ export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model
return (m: Model) => m.endpoint_type === 'anthropic'
case 'aihubmix':
return (m: Model) => m.id.includes('claude')
case 'silicon':
return (m: Model) => isSiliconAnthropicCompatibleModel(m.id)
default:
// allow all models when checker not configured
return () => true
+39 -6
View File
@@ -50,7 +50,40 @@ export default class ModernAiProvider {
private model?: Model
private localProvider: Awaited<AiSdkProvider> | null = null
// 构造函数重载签名
/**
* Constructor for ModernAiProvider
*
* @param modelOrProvider - Model or Provider object
* @param provider - Optional Provider object (only used when first param is Model)
*
* @remarks
* **Important behavior notes**:
*
* 1. When called with `(model)`:
* - Calls `getActualProvider(model)` to retrieve and format the provider
* - 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
*
* 3. When called with `(provider)`:
* - Directly uses the provider without requiring a model
* - Used for operations that don't need a model (e.g., fetchModels)
*
* @example
* ```typescript
* // Recommended: Auto-format URL
* const ai = new ModernAiProvider(model)
*
* // Not recommended: Skip URL formatting (only for special cases)
* const ai = new ModernAiProvider(model, customProvider)
*
* // For operations that don't need a model
* const ai = new ModernAiProvider(provider)
* ```
*/
constructor(model: Model, provider?: Provider)
constructor(provider: Provider)
constructor(modelOrProvider: Model | Provider, provider?: Provider)
@@ -156,7 +189,7 @@ export default class ModernAiProvider {
config: ModernAiProviderConfig
): Promise<CompletionsResult> {
// ai-gateway不是image/generation 端点,所以就先不走legacy了
if (config.isImageGenerationEndpoint && config.provider!.id !== SystemProviderIds['ai-gateway']) {
if (config.isImageGenerationEndpoint && this.getActualProvider().id !== SystemProviderIds['ai-gateway']) {
// 使用 legacy 实现处理图像生成(支持图片编辑等高级功能)
if (!config.uiMessages) {
throw new Error('uiMessages is required for image generation endpoint')
@@ -322,10 +355,10 @@ export default class ModernAiProvider {
}
}
/**
* 使用现代化 AI SDK 的图像生成实现,支持流式输出
* @deprecated 已改为使用 legacy 实现以支持图片编辑等高级功能
*/
// /**
// * 使用现代化 AI SDK 的图像生成实现,支持流式输出
// * @deprecated 已改为使用 legacy 实现以支持图片编辑等高级功能
// */
/*
private async modernImageGeneration(
model: ImageModel,
@@ -11,10 +11,8 @@ import {
findTokenLimit,
GEMINI_FLASH_MODEL_REGEX,
getThinkModelType,
isClaudeReasoningModel,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGeminiReasoningModel,
isGPT5SeriesModel,
isGrokReasoningModel,
isNotSupportSystemMessageModel,
@@ -651,7 +649,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
logger.warn('No user message. Some providers may not support.')
}
// poe 需要通过用户消息传递 reasoningEffort
const reasoningEffort = this.getReasoningEffort(assistant, model)
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
@@ -662,22 +659,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, qwenThinkModeEnabled)
}
if (this.provider.id === SystemProviderIds.poe) {
// 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
let suffix = ''
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) {
suffix = ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
} else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) {
suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
} else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) {
suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
}
// FIXME: poe 不支持多个text part,上传文本文件的时候用的不是file part而是text part,因此会出问题
// 临时解决方案是强制poe用string content,但是其实poe部分支持array
if (typeof lastUserMsg.content === 'string') {
lastUserMsg.content += suffix
}
}
}
// 4. 最终请求消息
@@ -1,6 +1,6 @@
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import { isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import type { MCPTool } from '@renderer/types'
import { type Assistant, type Message, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
@@ -9,11 +9,13 @@ import type { LanguageModelMiddleware } from 'ai'
import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
import { isEmpty } from 'lodash'
import { getAiSdkProviderId } from '../provider/factory'
import { isOpenRouterGeminiGenerateImageModel } from '../utils/image'
import { noThinkMiddleware } from './noThinkMiddleware'
import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware'
import { openrouterReasoningMiddleware } from './openrouterReasoningMiddleware'
import { qwenThinkingMiddleware } from './qwenThinkingMiddleware'
import { skipGeminiThoughtSignatureMiddleware } from './skipGeminiThoughtSignatureMiddleware'
import { toolChoiceMiddleware } from './toolChoiceMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
@@ -257,6 +259,15 @@ function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: Ai
middleware: openrouterGenerateImageMiddleware()
})
}
if (isGemini3Model(config.model)) {
const aiSdkId = getAiSdkProviderId(config.provider)
builder.add({
name: 'skip-gemini3-thought-signature',
middleware: skipGeminiThoughtSignatureMiddleware(aiSdkId)
})
logger.debug('Added skip Gemini3 thought signature middleware')
}
}
/**
@@ -0,0 +1,36 @@
import type { LanguageModelMiddleware } from 'ai'
/**
* skip Gemini Thought Signature Middleware
* 由于多模型客户端请求的复杂性(可以中途切换其他模型),这里选择通过中间件方式添加跳过所有 Gemini3 思考签名
* Due to the complexity of multi-model client requests (which can switch to other models mid-process),
* it was decided to add a skip for all Gemini3 thinking signatures via middleware.
* @param aiSdkId AI SDK Provider ID
* @returns LanguageModelMiddleware
*/
export function skipGeminiThoughtSignatureMiddleware(aiSdkId: string): LanguageModelMiddleware {
const MAGIC_STRING = 'skip_thought_signature_validator'
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const transformedParams = { ...params }
// Process messages in prompt
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
transformedParams.prompt = transformedParams.prompt.map((message) => {
if (typeof message.content !== 'string') {
for (const part of message.content) {
const googleOptions = part?.providerOptions?.[aiSdkId]
if (googleOptions?.thoughtSignature) {
googleOptions.thoughtSignature = MAGIC_STRING
}
}
}
return message
})
}
return transformedParams
}
}
}
@@ -180,6 +180,10 @@ describe('messageConverter', () => {
const result = await convertMessagesToSdkMessages([initialUser, assistant, finalUser], model)
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start editing' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Here is the current preview' }]
@@ -217,6 +221,7 @@ describe('messageConverter', () => {
expect(result).toEqual([
{ role: 'system', content: 'fileid://reference' },
{ role: 'user', content: [{ type: 'text', text: 'Use this document as inspiration' }] },
{
role: 'assistant',
content: [{ type: 'text', text: 'Generated previews ready' }]
@@ -194,20 +194,20 @@ async function convertMessageToAssistantModelMessage(
* This function processes messages and transforms them into the format required by the SDK.
* It handles special cases for vision models and image enhancement models.
*
* @param messages - Array of messages to convert. Must contain at least 2 messages when using image enhancement models.
* @param messages - Array of messages to convert. Must contain at least 3 messages when using image enhancement models for special handling.
* @param model - The model configuration that determines conversion behavior
*
* @returns A promise that resolves to an array of SDK-compatible model messages
*
* @remarks
* For image enhancement models with 2+ messages:
* - Expects the second-to-last message (index length-2) to be an assistant message containing image blocks
* - Expects the last message (index length-1) to be a user message
* - Extracts images from the assistant message and appends them to the user message content
* - Returns only the last two processed messages [assistantSdkMessage, userSdkMessage]
* For image enhancement models with 3+ messages:
* - Examines the last 2 messages to find an assistant message containing image blocks
* - If found, extracts images from the assistant message and appends them to the last user message content
* - Returns all converted messages (not just the last two) with the images merged into the user message
* - Typical pattern: [system?, assistant(image), user] -> [system?, assistant, user(image)]
*
* For other models:
* - Returns all converted messages in order
* - Returns all converted messages in order without special image handling
*
* The function automatically detects vision model capabilities and adjusts conversion accordingly.
*/
@@ -220,29 +220,25 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M
sdkMessages.push(...(Array.isArray(sdkMessage) ? sdkMessage : [sdkMessage]))
}
// Special handling for image enhancement models
// Only keep the last two messages and merge images into the user message
// [system?, user, assistant, user]
// Only merge images into the user message
// [system?, assistant(image), user] -> [system?, assistant, user(image)]
if (isImageEnhancementModel(model) && messages.length >= 3) {
const needUpdatedMessages = messages.slice(-2)
const needUpdatedSdkMessages = sdkMessages.slice(-2)
const assistantMessage = needUpdatedMessages.filter((m) => m.role === 'assistant')[0]
const assistantSdkMessage = needUpdatedSdkMessages.filter((m) => m.role === 'assistant')[0]
const userSdkMessage = needUpdatedSdkMessages.filter((m) => m.role === 'user')[0]
const systemSdkMessages = sdkMessages.filter((m) => m.role === 'system')
const imageBlocks = findImageBlocks(assistantMessage)
const imageParts = await convertImageBlockToImagePart(imageBlocks)
const parts: Array<TextPart | ImagePart | FilePart> = []
if (typeof userSdkMessage.content === 'string') {
parts.push({ type: 'text', text: userSdkMessage.content })
parts.push(...imageParts)
userSdkMessage.content = parts
} else {
userSdkMessage.content.push(...imageParts)
const assistantMessage = needUpdatedMessages.find((m) => m.role === 'assistant')
const userSdkMessage = sdkMessages[sdkMessages.length - 1]
if (assistantMessage && userSdkMessage?.role === 'user') {
const imageBlocks = findImageBlocks(assistantMessage)
const imageParts = await convertImageBlockToImagePart(imageBlocks)
if (imageParts.length > 0) {
if (typeof userSdkMessage.content === 'string') {
userSdkMessage.content = [{ type: 'text', text: userSdkMessage.content }, ...imageParts]
} else if (Array.isArray(userSdkMessage.content)) {
userSdkMessage.content.push(...imageParts)
}
}
}
if (systemSdkMessages.length > 0) {
return [systemSdkMessages[0], assistantSdkMessage, userSdkMessage]
}
return [assistantSdkMessage, userSdkMessage]
}
return sdkMessages
@@ -3,7 +3,6 @@
* 处理温度、TopP、超时等基础参数的获取逻辑
*/
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import {
isClaude45ReasoningModel,
isClaudeReasoningModel,
@@ -73,11 +72,19 @@ export function getTimeout(model: Model): number {
export function getMaxTokens(assistant: Assistant, model: Model): number | undefined {
// NOTE: ai-sdk会把maxToken和budgetToken加起来
let { maxTokens = DEFAULT_MAX_TOKENS } = getAssistantSettings(assistant)
const assistantSettings = getAssistantSettings(assistant)
const enabledMaxTokens = assistantSettings.enableMaxTokens ?? false
let maxTokens = assistantSettings.maxTokens
// If user hasn't enabled enableMaxTokens, return undefined to let the API use its default value.
// Note: Anthropic API requires max_tokens, but that's handled by the Anthropic client with a fallback.
if (!enabledMaxTokens || maxTokens === undefined) {
return undefined
}
const provider = getProviderByModel(model)
if (isSupportedThinkingTokenClaudeModel(model) && ['anthropic', 'aws-bedrock'].includes(provider.type)) {
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
const { reasoning_effort: reasoningEffort } = assistantSettings
const budget = getAnthropicThinkingBudget(maxTokens, reasoningEffort, model.id)
if (budget) {
maxTokens -= budget
@@ -106,7 +106,7 @@ export async function buildStreamTextParams(
searchWithTime: store.getState().websearch.searchWithTime
}
const providerOptions = buildProviderOptions(assistant, model, provider, {
const { providerOptions, standardParams } = buildProviderOptions(assistant, model, provider, {
enableReasoning,
enableWebSearch,
enableGenerateImage
@@ -181,11 +181,16 @@ export async function buildStreamTextParams(
}
// 构建基础参数
// Note: standardParams (topK, frequencyPenalty, presencePenalty, stopSequences, seed)
// are extracted from custom parameters and passed directly to streamText()
// instead of being placed in providerOptions
const params: StreamTextParams = {
messages: sdkMessages,
maxOutputTokens: getMaxTokens(assistant, model),
temperature: getTemperature(assistant, model),
topP: getTopP(assistant, model),
// Include AI SDK standard params extracted from custom parameters
...standardParams,
abortSignal: options.requestOptions?.signal,
headers,
providerOptions,
+6 -2
View File
@@ -60,8 +60,12 @@ function tryResolveProviderId(identifier: string): ProviderId | null {
export function getAiSdkProviderId(provider: Provider): string {
// 1. 尝试解析provider.id
const resolvedFromId = tryResolveProviderId(provider.id)
if (isAzureOpenAIProvider(provider) && isAzureResponsesEndpoint(provider)) {
return 'azure-responses'
if (isAzureOpenAIProvider(provider)) {
if (isAzureResponsesEndpoint(provider)) {
return 'azure-responses'
} else {
return 'azure'
}
}
if (resolvedFromId) {
return resolvedFromId
@@ -91,6 +91,7 @@ function formatProviderApiHost(provider: Provider): Provider {
if (isAnthropicProvider(provider)) {
const baseHost = formatted.anthropicApiHost || formatted.apiHost
// AI SDK needs /v1 in baseURL, Anthropic SDK will strip it in getSdkClient
formatted.apiHost = formatApiHost(baseHost)
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
@@ -0,0 +1,652 @@
/**
* extractAiSdkStandardParams Unit Tests
* Tests for extracting AI SDK standard parameters from custom parameters
*/
import { describe, expect, it, vi } from 'vitest'
import { extractAiSdkStandardParams } from '../options'
// Mock logger to prevent errors
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn()
})
}
}))
// Mock settings store
vi.mock('@renderer/store/settings', () => ({
default: (state = { settings: {} }) => state
}))
// Mock hooks to prevent uuid errors
vi.mock('@renderer/hooks/useSettings', () => ({
getStoreSetting: vi.fn(() => ({}))
}))
// Mock uuid to prevent errors
vi.mock('uuid', () => ({
v4: vi.fn(() => 'test-uuid')
}))
// Mock AssistantService to prevent uuid errors
vi.mock('@renderer/services/AssistantService', () => ({
getDefaultAssistant: vi.fn(() => ({
id: 'test-assistant',
name: 'Test Assistant',
settings: {}
})),
getDefaultTopic: vi.fn(() => ({
id: 'test-topic',
assistantId: 'test-assistant',
createdAt: new Date().toISOString()
}))
}))
// Mock provider service
vi.mock('@renderer/services/ProviderService', () => ({
getProviderById: vi.fn(() => ({
id: 'test-provider',
name: 'Test Provider'
}))
}))
// Mock config modules
vi.mock('@renderer/config/models', () => ({
isOpenAIModel: vi.fn(() => false),
isQwenMTModel: vi.fn(() => false),
isSupportFlexServiceTierModel: vi.fn(() => false),
isSupportVerbosityModel: vi.fn(() => false),
getModelSupportedVerbosity: vi.fn(() => [])
}))
vi.mock('@renderer/config/translate', () => ({
mapLanguageToQwenMTModel: vi.fn()
}))
vi.mock('@renderer/utils/provider', () => ({
isSupportServiceTierProvider: vi.fn(() => false),
isSupportVerbosityProvider: vi.fn(() => false)
}))
describe('extractAiSdkStandardParams', () => {
describe('Positive cases - Standard parameters extraction', () => {
it('should extract all AI SDK standard parameters', () => {
const customParams = {
maxOutputTokens: 1000,
temperature: 0.7,
topP: 0.9,
topK: 40,
presencePenalty: 0.5,
frequencyPenalty: 0.3,
stopSequences: ['STOP', 'END'],
seed: 42
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
maxOutputTokens: 1000,
temperature: 0.7,
topP: 0.9,
topK: 40,
presencePenalty: 0.5,
frequencyPenalty: 0.3,
stopSequences: ['STOP', 'END'],
seed: 42
})
expect(result.providerParams).toStrictEqual({})
})
it('should extract single standard parameter', () => {
const customParams = {
temperature: 0.8
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
temperature: 0.8
})
expect(result.providerParams).toStrictEqual({})
})
it('should extract topK parameter', () => {
const customParams = {
topK: 50
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
topK: 50
})
expect(result.providerParams).toStrictEqual({})
})
it('should extract frequencyPenalty parameter', () => {
const customParams = {
frequencyPenalty: 0.6
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
frequencyPenalty: 0.6
})
expect(result.providerParams).toStrictEqual({})
})
it('should extract presencePenalty parameter', () => {
const customParams = {
presencePenalty: 0.4
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
presencePenalty: 0.4
})
expect(result.providerParams).toStrictEqual({})
})
it('should extract stopSequences parameter', () => {
const customParams = {
stopSequences: ['HALT', 'TERMINATE']
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
stopSequences: ['HALT', 'TERMINATE']
})
expect(result.providerParams).toStrictEqual({})
})
it('should extract seed parameter', () => {
const customParams = {
seed: 12345
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
seed: 12345
})
expect(result.providerParams).toStrictEqual({})
})
it('should extract maxOutputTokens parameter', () => {
const customParams = {
maxOutputTokens: 2048
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
maxOutputTokens: 2048
})
expect(result.providerParams).toStrictEqual({})
})
it('should extract topP parameter', () => {
const customParams = {
topP: 0.95
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
topP: 0.95
})
expect(result.providerParams).toStrictEqual({})
})
})
describe('Negative cases - Provider-specific parameters', () => {
it('should place all non-standard parameters in providerParams', () => {
const customParams = {
customParam: 'value',
anotherParam: 123,
thirdParam: true
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
customParam: 'value',
anotherParam: 123,
thirdParam: true
})
})
it('should place single provider-specific parameter in providerParams', () => {
const customParams = {
reasoningEffort: 'high'
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
reasoningEffort: 'high'
})
})
it('should place model-specific parameter in providerParams', () => {
const customParams = {
thinking: { type: 'enabled', budgetTokens: 5000 }
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
thinking: { type: 'enabled', budgetTokens: 5000 }
})
})
it('should place serviceTier in providerParams', () => {
const customParams = {
serviceTier: 'auto'
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
serviceTier: 'auto'
})
})
it('should place textVerbosity in providerParams', () => {
const customParams = {
textVerbosity: 'high'
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
textVerbosity: 'high'
})
})
})
describe('Mixed parameters', () => {
it('should correctly separate mixed standard and provider-specific parameters', () => {
const customParams = {
temperature: 0.7,
topK: 40,
customParam: 'custom_value',
reasoningEffort: 'medium',
frequencyPenalty: 0.5,
seed: 999
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
temperature: 0.7,
topK: 40,
frequencyPenalty: 0.5,
seed: 999
})
expect(result.providerParams).toStrictEqual({
customParam: 'custom_value',
reasoningEffort: 'medium'
})
})
it('should handle complex mixed parameters with nested objects', () => {
const customParams = {
topP: 0.9,
presencePenalty: 0.3,
thinking: { type: 'enabled', budgetTokens: 5000 },
stopSequences: ['STOP'],
serviceTier: 'auto',
maxOutputTokens: 4096
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
topP: 0.9,
presencePenalty: 0.3,
stopSequences: ['STOP'],
maxOutputTokens: 4096
})
expect(result.providerParams).toStrictEqual({
thinking: { type: 'enabled', budgetTokens: 5000 },
serviceTier: 'auto'
})
})
it('should handle all standard params with some provider params', () => {
const customParams = {
maxOutputTokens: 2000,
temperature: 0.8,
topP: 0.95,
topK: 50,
presencePenalty: 0.6,
frequencyPenalty: 0.4,
stopSequences: ['END', 'DONE'],
seed: 777,
customApiParam: 'value',
anotherCustomParam: 123
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
maxOutputTokens: 2000,
temperature: 0.8,
topP: 0.95,
topK: 50,
presencePenalty: 0.6,
frequencyPenalty: 0.4,
stopSequences: ['END', 'DONE'],
seed: 777
})
expect(result.providerParams).toStrictEqual({
customApiParam: 'value',
anotherCustomParam: 123
})
})
})
describe('Edge cases', () => {
it('should handle empty object', () => {
const customParams = {}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({})
})
it('should handle zero values for numeric parameters', () => {
const customParams = {
temperature: 0,
topK: 0,
seed: 0
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
temperature: 0,
topK: 0,
seed: 0
})
expect(result.providerParams).toStrictEqual({})
})
it('should handle negative values for numeric parameters', () => {
const customParams = {
presencePenalty: -0.5,
frequencyPenalty: -0.3,
seed: -1
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
presencePenalty: -0.5,
frequencyPenalty: -0.3,
seed: -1
})
expect(result.providerParams).toStrictEqual({})
})
it('should handle empty arrays for stopSequences', () => {
const customParams = {
stopSequences: []
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
stopSequences: []
})
expect(result.providerParams).toStrictEqual({})
})
it('should handle null values in mixed parameters', () => {
const customParams = {
temperature: 0.7,
customNull: null,
topK: 40
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
temperature: 0.7,
topK: 40
})
expect(result.providerParams).toStrictEqual({
customNull: null
})
})
it('should handle undefined values in mixed parameters', () => {
const customParams = {
temperature: 0.7,
customUndefined: undefined,
topK: 40
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
temperature: 0.7,
topK: 40
})
expect(result.providerParams).toStrictEqual({
customUndefined: undefined
})
})
it('should handle boolean values for standard parameters', () => {
const customParams = {
temperature: 0.7,
customBoolean: false,
topK: 40
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
temperature: 0.7,
topK: 40
})
expect(result.providerParams).toStrictEqual({
customBoolean: false
})
})
it('should handle very large numeric values', () => {
const customParams = {
maxOutputTokens: 999999,
seed: 2147483647,
topK: 10000
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
maxOutputTokens: 999999,
seed: 2147483647,
topK: 10000
})
expect(result.providerParams).toStrictEqual({})
})
it('should handle decimal values with high precision', () => {
const customParams = {
temperature: 0.123456789,
topP: 0.987654321,
presencePenalty: 0.111111111
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
temperature: 0.123456789,
topP: 0.987654321,
presencePenalty: 0.111111111
})
expect(result.providerParams).toStrictEqual({})
})
})
describe('Case sensitivity', () => {
it('should NOT extract parameters with incorrect case - uppercase first letter', () => {
const customParams = {
Temperature: 0.7,
TopK: 40,
FrequencyPenalty: 0.5
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
Temperature: 0.7,
TopK: 40,
FrequencyPenalty: 0.5
})
})
it('should NOT extract parameters with incorrect case - all uppercase', () => {
const customParams = {
TEMPERATURE: 0.7,
TOPK: 40,
SEED: 42
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
TEMPERATURE: 0.7,
TOPK: 40,
SEED: 42
})
})
it('should NOT extract parameters with incorrect case - all lowercase', () => {
const customParams = {
maxoutputtokens: 1000,
frequencypenalty: 0.5,
stopsequences: ['STOP']
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
maxoutputtokens: 1000,
frequencypenalty: 0.5,
stopsequences: ['STOP']
})
})
it('should correctly extract exact case match while rejecting incorrect case', () => {
const customParams = {
temperature: 0.7,
Temperature: 0.8,
TEMPERATURE: 0.9,
topK: 40,
TopK: 50
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
temperature: 0.7,
topK: 40
})
expect(result.providerParams).toStrictEqual({
Temperature: 0.8,
TEMPERATURE: 0.9,
TopK: 50
})
})
})
describe('Parameter name variations', () => {
it('should NOT extract similar but incorrect parameter names', () => {
const customParams = {
temp: 0.7, // should not match temperature
top_k: 40, // should not match topK
max_tokens: 1000, // should not match maxOutputTokens
freq_penalty: 0.5 // should not match frequencyPenalty
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
temp: 0.7,
top_k: 40,
max_tokens: 1000,
freq_penalty: 0.5
})
})
it('should NOT extract snake_case versions of standard parameters', () => {
const customParams = {
top_k: 40,
top_p: 0.9,
presence_penalty: 0.5,
frequency_penalty: 0.3,
stop_sequences: ['STOP'],
max_output_tokens: 1000
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({})
expect(result.providerParams).toStrictEqual({
top_k: 40,
top_p: 0.9,
presence_penalty: 0.5,
frequency_penalty: 0.3,
stop_sequences: ['STOP'],
max_output_tokens: 1000
})
})
it('should extract exact camelCase parameters only', () => {
const customParams = {
topK: 40, // correct
top_k: 50, // incorrect
topP: 0.9, // correct
top_p: 0.8, // incorrect
frequencyPenalty: 0.5, // correct
frequency_penalty: 0.4 // incorrect
}
const result = extractAiSdkStandardParams(customParams)
expect(result.standardParams).toStrictEqual({
topK: 40,
topP: 0.9,
frequencyPenalty: 0.5
})
expect(result.providerParams).toStrictEqual({
top_k: 50,
top_p: 0.8,
frequency_penalty: 0.4
})
})
})
})
@@ -128,7 +128,20 @@ vi.mock('../reasoning', () => ({
reasoningConfig: { type: 'enabled', budgetTokens: 5000 }
})),
getReasoningEffort: vi.fn(() => ({ reasoningEffort: 'medium' })),
getCustomParameters: vi.fn(() => ({}))
getCustomParameters: vi.fn(() => ({})),
extractAiSdkStandardParams: vi.fn((customParams: Record<string, any>) => {
const AI_SDK_STANDARD_PARAMS = ['topK', 'frequencyPenalty', 'presencePenalty', 'stopSequences', 'seed']
const standardParams: Record<string, any> = {}
const providerParams: Record<string, any> = {}
for (const [key, value] of Object.entries(customParams)) {
if (AI_SDK_STANDARD_PARAMS.includes(key)) {
standardParams[key] = value
} else {
providerParams[key] = value
}
}
return { standardParams, providerParams }
})
}))
vi.mock('../image', () => ({
@@ -184,8 +197,9 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result).toHaveProperty('openai')
expect(result.openai).toBeDefined()
expect(result.providerOptions).toHaveProperty('openai')
expect(result.providerOptions.openai).toBeDefined()
expect(result.standardParams).toBeDefined()
})
it('should include reasoning parameters when enabled', () => {
@@ -195,8 +209,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result.openai).toHaveProperty('reasoningEffort')
expect(result.openai.reasoningEffort).toBe('medium')
expect(result.providerOptions.openai).toHaveProperty('reasoningEffort')
expect(result.providerOptions.openai.reasoningEffort).toBe('medium')
})
it('should include service tier when supported', () => {
@@ -211,8 +225,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result.openai).toHaveProperty('serviceTier')
expect(result.openai.serviceTier).toBe(OpenAIServiceTiers.auto)
expect(result.providerOptions.openai).toHaveProperty('serviceTier')
expect(result.providerOptions.openai.serviceTier).toBe(OpenAIServiceTiers.auto)
})
})
@@ -239,8 +253,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result).toHaveProperty('anthropic')
expect(result.anthropic).toBeDefined()
expect(result.providerOptions).toHaveProperty('anthropic')
expect(result.providerOptions.anthropic).toBeDefined()
})
it('should include reasoning parameters when enabled', () => {
@@ -250,8 +264,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result.anthropic).toHaveProperty('thinking')
expect(result.anthropic.thinking).toEqual({
expect(result.providerOptions.anthropic).toHaveProperty('thinking')
expect(result.providerOptions.anthropic.thinking).toEqual({
type: 'enabled',
budgetTokens: 5000
})
@@ -282,8 +296,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result).toHaveProperty('google')
expect(result.google).toBeDefined()
expect(result.providerOptions).toHaveProperty('google')
expect(result.providerOptions.google).toBeDefined()
})
it('should include reasoning parameters when enabled', () => {
@@ -293,8 +307,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result.google).toHaveProperty('thinkingConfig')
expect(result.google.thinkingConfig).toEqual({
expect(result.providerOptions.google).toHaveProperty('thinkingConfig')
expect(result.providerOptions.google.thinkingConfig).toEqual({
include_thoughts: true
})
})
@@ -306,8 +320,8 @@ describe('options utils', () => {
enableGenerateImage: true
})
expect(result.google).toHaveProperty('responseModalities')
expect(result.google.responseModalities).toEqual(['TEXT', 'IMAGE'])
expect(result.providerOptions.google).toHaveProperty('responseModalities')
expect(result.providerOptions.google.responseModalities).toEqual(['TEXT', 'IMAGE'])
})
})
@@ -335,8 +349,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result).toHaveProperty('xai')
expect(result.xai).toBeDefined()
expect(result.providerOptions).toHaveProperty('xai')
expect(result.providerOptions.xai).toBeDefined()
})
it('should include reasoning parameters when enabled', () => {
@@ -346,8 +360,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result.xai).toHaveProperty('reasoningEffort')
expect(result.xai.reasoningEffort).toBe('high')
expect(result.providerOptions.xai).toHaveProperty('reasoningEffort')
expect(result.providerOptions.xai.reasoningEffort).toBe('high')
})
})
@@ -374,8 +388,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result).toHaveProperty('deepseek')
expect(result.deepseek).toBeDefined()
expect(result.providerOptions).toHaveProperty('deepseek')
expect(result.providerOptions.deepseek).toBeDefined()
})
})
@@ -402,8 +416,8 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result).toHaveProperty('openrouter')
expect(result.openrouter).toBeDefined()
expect(result.providerOptions).toHaveProperty('openrouter')
expect(result.providerOptions.openrouter).toBeDefined()
})
it('should include web search parameters when enabled', () => {
@@ -413,12 +427,12 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result.openrouter).toHaveProperty('enable_search')
expect(result.providerOptions.openrouter).toHaveProperty('enable_search')
})
})
describe('Custom parameters', () => {
it('should merge custom parameters', async () => {
it('should merge custom provider-specific parameters', async () => {
const { getCustomParameters } = await import('../reasoning')
vi.mocked(getCustomParameters).mockReturnValue({
@@ -443,10 +457,88 @@ describe('options utils', () => {
}
)
expect(result.openai).toHaveProperty('custom_param')
expect(result.openai.custom_param).toBe('custom_value')
expect(result.openai).toHaveProperty('another_param')
expect(result.openai.another_param).toBe(123)
expect(result.providerOptions.openai).toHaveProperty('custom_param')
expect(result.providerOptions.openai.custom_param).toBe('custom_value')
expect(result.providerOptions.openai).toHaveProperty('another_param')
expect(result.providerOptions.openai.another_param).toBe(123)
})
it('should extract AI SDK standard params from custom parameters', async () => {
const { getCustomParameters } = await import('../reasoning')
vi.mocked(getCustomParameters).mockReturnValue({
topK: 5,
frequencyPenalty: 0.5,
presencePenalty: 0.3,
seed: 42,
custom_param: 'custom_value'
})
const result = buildProviderOptions(
mockAssistant,
mockModel,
{
id: SystemProviderIds.gemini,
name: 'Google',
type: 'gemini',
apiKey: 'test-key',
apiHost: 'https://generativelanguage.googleapis.com'
} as Provider,
{
enableReasoning: false,
enableWebSearch: false,
enableGenerateImage: false
}
)
// Standard params should be extracted and returned separately
expect(result.standardParams).toEqual({
topK: 5,
frequencyPenalty: 0.5,
presencePenalty: 0.3,
seed: 42
})
// Provider-specific params should still be in providerOptions
expect(result.providerOptions.google).toHaveProperty('custom_param')
expect(result.providerOptions.google.custom_param).toBe('custom_value')
// Standard params should NOT be in providerOptions
expect(result.providerOptions.google).not.toHaveProperty('topK')
expect(result.providerOptions.google).not.toHaveProperty('frequencyPenalty')
expect(result.providerOptions.google).not.toHaveProperty('presencePenalty')
expect(result.providerOptions.google).not.toHaveProperty('seed')
})
it('should handle stopSequences in custom parameters', async () => {
const { getCustomParameters } = await import('../reasoning')
vi.mocked(getCustomParameters).mockReturnValue({
stopSequences: ['STOP', 'END'],
custom_param: 'value'
})
const result = buildProviderOptions(
mockAssistant,
mockModel,
{
id: SystemProviderIds.gemini,
name: 'Google',
type: 'gemini',
apiKey: 'test-key',
apiHost: 'https://generativelanguage.googleapis.com'
} as Provider,
{
enableReasoning: false,
enableWebSearch: false,
enableGenerateImage: false
}
)
expect(result.standardParams).toEqual({
stopSequences: ['STOP', 'END']
})
expect(result.providerOptions.google).not.toHaveProperty('stopSequences')
})
})
@@ -474,8 +566,8 @@ describe('options utils', () => {
enableGenerateImage: true
})
expect(result.google).toHaveProperty('thinkingConfig')
expect(result.google).toHaveProperty('responseModalities')
expect(result.providerOptions.google).toHaveProperty('thinkingConfig')
expect(result.providerOptions.google).toHaveProperty('responseModalities')
})
it('should handle all capabilities enabled', () => {
@@ -485,8 +577,8 @@ describe('options utils', () => {
enableGenerateImage: true
})
expect(result.google).toBeDefined()
expect(Object.keys(result.google).length).toBeGreaterThan(0)
expect(result.providerOptions.google).toBeDefined()
expect(Object.keys(result.providerOptions.google).length).toBeGreaterThan(0)
})
})
@@ -513,7 +605,7 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result).toHaveProperty('google')
expect(result.providerOptions).toHaveProperty('google')
})
it('should map google-vertex-anthropic to anthropic', () => {
@@ -538,7 +630,7 @@ describe('options utils', () => {
enableGenerateImage: false
})
expect(result).toHaveProperty('anthropic')
expect(result.providerOptions).toHaveProperty('anthropic')
})
})
})
@@ -0,0 +1,288 @@
import type { Assistant, Model, ReasoningEffortOption } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { describe, expect, it, vi } from 'vitest'
import { getReasoningEffort } from '../reasoning'
// Mock logger
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({
warn: vi.fn(),
info: vi.fn(),
error: vi.fn()
})
}
}))
vi.mock('@renderer/store/settings', () => ({
default: {},
settingsSlice: {
name: 'settings',
reducer: vi.fn(),
actions: {}
}
}))
vi.mock('@renderer/store/assistants', () => {
const mockAssistantsSlice = {
name: 'assistants',
reducer: vi.fn((state = { entities: {}, ids: [] }) => state),
actions: {
updateTopicUpdatedAt: vi.fn(() => ({ type: 'UPDATE_TOPIC_UPDATED_AT' }))
}
}
return {
default: mockAssistantsSlice.reducer,
updateTopicUpdatedAt: vi.fn(() => ({ type: 'UPDATE_TOPIC_UPDATED_AT' })),
assistantsSlice: mockAssistantsSlice
}
})
// Mock provider service
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: (model: Model) => ({
id: model.provider,
name: 'Poe',
type: 'openai'
}),
getAssistantSettings: (assistant: Assistant) => assistant.settings || {}
}))
describe('Poe Provider Reasoning Support', () => {
const createPoeModel = (id: string): Model => ({
id,
name: id,
provider: SystemProviderIds.poe,
group: 'poe'
})
const createAssistant = (reasoning_effort?: ReasoningEffortOption, maxTokens?: number): Assistant => ({
id: 'test-assistant',
name: 'Test Assistant',
emoji: '🤖',
prompt: '',
topics: [],
messages: [],
type: 'assistant',
regularPhrases: [],
settings: {
reasoning_effort,
maxTokens
}
})
describe('GPT-5 Series Models', () => {
it('should return reasoning_effort in extra_body for GPT-5 model with low effort', () => {
const model = createPoeModel('gpt-5')
const assistant = createAssistant('low')
const result = getReasoningEffort(assistant, model)
expect(result).toEqual({
extra_body: {
reasoning_effort: 'low'
}
})
})
it('should return reasoning_effort in extra_body for GPT-5 model with medium effort', () => {
const model = createPoeModel('gpt-5')
const assistant = createAssistant('medium')
const result = getReasoningEffort(assistant, model)
expect(result).toEqual({
extra_body: {
reasoning_effort: 'medium'
}
})
})
it('should return reasoning_effort in extra_body for GPT-5 model with high effort', () => {
const model = createPoeModel('gpt-5')
const assistant = createAssistant('high')
const result = getReasoningEffort(assistant, model)
expect(result).toEqual({
extra_body: {
reasoning_effort: 'high'
}
})
})
it('should convert auto to medium for GPT-5 model in extra_body', () => {
const model = createPoeModel('gpt-5')
const assistant = createAssistant('auto')
const result = getReasoningEffort(assistant, model)
expect(result).toEqual({
extra_body: {
reasoning_effort: 'medium'
}
})
})
it('should return reasoning_effort in extra_body for GPT-5.1 model', () => {
const model = createPoeModel('gpt-5.1')
const assistant = createAssistant('medium')
const result = getReasoningEffort(assistant, model)
expect(result).toEqual({
extra_body: {
reasoning_effort: 'medium'
}
})
})
})
describe('Claude Models', () => {
it('should return thinking_budget in extra_body for Claude 3.7 Sonnet', () => {
const model = createPoeModel('claude-3.7-sonnet')
const assistant = createAssistant('medium', 4096)
const result = getReasoningEffort(assistant, model)
expect(result).toHaveProperty('extra_body')
expect(result.extra_body).toHaveProperty('thinking_budget')
expect(typeof result.extra_body?.thinking_budget).toBe('number')
expect(result.extra_body?.thinking_budget).toBeGreaterThan(0)
})
it('should return thinking_budget in extra_body for Claude Sonnet 4', () => {
const model = createPoeModel('claude-sonnet-4')
const assistant = createAssistant('high', 8192)
const result = getReasoningEffort(assistant, model)
expect(result).toHaveProperty('extra_body')
expect(result.extra_body).toHaveProperty('thinking_budget')
expect(typeof result.extra_body?.thinking_budget).toBe('number')
})
it('should calculate thinking_budget based on effort ratio and maxTokens', () => {
const model = createPoeModel('claude-3.7-sonnet')
const assistant = createAssistant('low', 4096)
const result = getReasoningEffort(assistant, model)
expect(result.extra_body?.thinking_budget).toBeGreaterThanOrEqual(1024)
})
})
describe('Gemini Models', () => {
it('should return thinking_budget in extra_body for Gemini 2.5 Flash', () => {
const model = createPoeModel('gemini-2.5-flash')
const assistant = createAssistant('medium')
const result = getReasoningEffort(assistant, model)
expect(result).toHaveProperty('extra_body')
expect(result.extra_body).toHaveProperty('thinking_budget')
expect(typeof result.extra_body?.thinking_budget).toBe('number')
})
it('should return thinking_budget in extra_body for Gemini 2.5 Pro', () => {
const model = createPoeModel('gemini-2.5-pro')
const assistant = createAssistant('high')
const result = getReasoningEffort(assistant, model)
expect(result).toHaveProperty('extra_body')
expect(result.extra_body).toHaveProperty('thinking_budget')
})
it('should use -1 for auto effort', () => {
const model = createPoeModel('gemini-2.5-flash')
const assistant = createAssistant('auto')
const result = getReasoningEffort(assistant, model)
expect(result.extra_body?.thinking_budget).toBe(-1)
})
it('should calculate thinking_budget for non-auto effort', () => {
const model = createPoeModel('gemini-2.5-flash')
const assistant = createAssistant('low')
const result = getReasoningEffort(assistant, model)
expect(typeof result.extra_body?.thinking_budget).toBe('number')
})
})
describe('No Reasoning Effort', () => {
it('should return empty object when reasoning_effort is not set', () => {
const model = createPoeModel('gpt-5')
const assistant = createAssistant(undefined)
const result = getReasoningEffort(assistant, model)
expect(result).toEqual({})
})
it('should return empty object when reasoning_effort is "none"', () => {
const model = createPoeModel('gpt-5')
const assistant = createAssistant('none')
const result = getReasoningEffort(assistant, model)
expect(result).toEqual({})
})
})
describe('Non-Reasoning Models', () => {
it('should return empty object for non-reasoning models', () => {
const model = createPoeModel('gpt-4')
const assistant = createAssistant('medium')
const result = getReasoningEffort(assistant, model)
expect(result).toEqual({})
})
})
describe('Edge Cases: Models Without Token Limit Configuration', () => {
it('should return empty object for Claude models without token limit configuration', () => {
const model = createPoeModel('claude-unknown-variant')
const assistant = createAssistant('medium', 4096)
const result = getReasoningEffort(assistant, model)
// Should return empty object when token limit is not found
expect(result).toEqual({})
expect(result.extra_body?.thinking_budget).toBeUndefined()
})
it('should return empty object for unmatched Poe reasoning models', () => {
// A hypothetical reasoning model that doesn't match GPT-5, Claude, or Gemini
const model = createPoeModel('some-reasoning-model')
// Make it appear as a reasoning model by giving it a name that won't match known categories
const assistant = createAssistant('medium')
const result = getReasoningEffort(assistant, model)
// Should return empty object for unmatched models
expect(result).toEqual({})
})
it('should fallback to -1 for Gemini models without token limit', () => {
// Use a Gemini model variant that won't match any token limit pattern
// The current regex patterns cover gemini-.*-flash.*$ and gemini-.*-pro.*$
// so we need a model that matches isSupportedThinkingTokenGeminiModel but not THINKING_TOKEN_MAP
const model = createPoeModel('gemini-2.5-flash')
const assistant = createAssistant('auto')
const result = getReasoningEffort(assistant, model)
// For 'auto' effort, should use -1
expect(result.extra_body?.thinking_budget).toBe(-1)
})
it('should enforce minimum 1024 token floor for Claude models', () => {
const model = createPoeModel('claude-3.7-sonnet')
// Use very small maxTokens to test the minimum floor
const assistant = createAssistant('low', 100)
const result = getReasoningEffort(assistant, model)
expect(result.extra_body?.thinking_budget).toBeGreaterThanOrEqual(1024)
})
it('should handle undefined maxTokens for Claude models', () => {
const model = createPoeModel('claude-3.7-sonnet')
const assistant = createAssistant('medium', undefined)
const result = getReasoningEffort(assistant, model)
expect(result).toHaveProperty('extra_body')
expect(result.extra_body).toHaveProperty('thinking_budget')
expect(typeof result.extra_body?.thinking_budget).toBe('number')
expect(result.extra_body?.thinking_budget).toBeGreaterThanOrEqual(1024)
})
})
})
+85 -20
View File
@@ -14,6 +14,7 @@ import {
} from '@renderer/config/models'
import { mapLanguageToQwenMTModel } from '@renderer/config/translate'
import { getStoreSetting } from '@renderer/hooks/useSettings'
import { getProviderById } from '@renderer/services/ProviderService'
import {
type Assistant,
type GroqServiceTier,
@@ -30,8 +31,8 @@ import {
type Provider,
type ServiceTier
} from '@renderer/types'
import type { OpenAIVerbosity } from '@renderer/types/aiCoreTypes'
import { isSupportServiceTierProvider } from '@renderer/utils/provider'
import { type AiSdkParam, isAiSdkParam, type OpenAIVerbosity } from '@renderer/types/aiCoreTypes'
import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@renderer/utils/provider'
import type { JSONValue } from 'ai'
import { t } from 'i18next'
@@ -90,15 +91,56 @@ function getServiceTier<T extends Provider>(model: Model, provider: T): OpenAISe
}
}
function getVerbosity(): OpenAIVerbosity {
function getVerbosity(model: Model): OpenAIVerbosity {
if (!isSupportVerbosityModel(model) || !isSupportVerbosityProvider(getProviderById(model.provider)!)) {
return undefined
}
const openAI = getStoreSetting('openAI')
return openAI.verbosity
const userVerbosity = openAI.verbosity
if (userVerbosity) {
const supportedVerbosity = getModelSupportedVerbosity(model)
// Use user's verbosity if supported, otherwise use the first supported option
const verbosity = supportedVerbosity.includes(userVerbosity) ? userVerbosity : supportedVerbosity[0]
return verbosity
}
return undefined
}
/**
* Extract AI SDK standard parameters from custom parameters
* These parameters should be passed directly to streamText() instead of providerOptions
*/
export function extractAiSdkStandardParams(customParams: Record<string, any>): {
standardParams: Partial<Record<AiSdkParam, any>>
providerParams: Record<string, any>
} {
const standardParams: Partial<Record<AiSdkParam, any>> = {}
const providerParams: Record<string, any> = {}
for (const [key, value] of Object.entries(customParams)) {
if (isAiSdkParam(key)) {
standardParams[key] = value
} else {
providerParams[key] = value
}
}
return { standardParams, providerParams }
}
/**
* 构建 AI SDK 的 providerOptions
* 按 provider 类型分离,保持类型安全
* 返回格式:{ 'providerId': providerOptions }
* 返回格式:{
* providerOptions: { 'providerId': providerOptions },
* standardParams: { topK, frequencyPenalty, presencePenalty, stopSequences, seed }
* }
*
* Custom parameters are split into two categories:
* 1. AI SDK standard parameters (topK, frequencyPenalty, etc.) - returned separately to be passed to streamText()
* 2. Provider-specific parameters - merged into providerOptions
*/
export function buildProviderOptions(
assistant: Assistant,
@@ -109,13 +151,16 @@ export function buildProviderOptions(
enableWebSearch: boolean
enableGenerateImage: boolean
}
): Record<string, Record<string, JSONValue>> {
): {
providerOptions: Record<string, Record<string, JSONValue>>
standardParams: Partial<Record<AiSdkParam, any>>
} {
logger.debug('buildProviderOptions', { assistant, model, actualProvider, capabilities })
const rawProviderId = getAiSdkProviderId(actualProvider)
// 构建 provider 特定的选项
let providerSpecificOptions: Record<string, any> = {}
const serviceTier = getServiceTier(model, actualProvider)
const textVerbosity = getVerbosity()
const textVerbosity = getVerbosity(model)
// 根据 provider 类型分离构建逻辑
const { data: baseProviderId, success } = baseProviderIdSchema.safeParse(rawProviderId)
if (success) {
@@ -130,7 +175,8 @@ export function buildProviderOptions(
assistant,
model,
capabilities,
serviceTier
serviceTier,
textVerbosity
)
providerSpecificOptions = options
}
@@ -163,7 +209,8 @@ export function buildProviderOptions(
model,
capabilities,
actualProvider,
serviceTier
serviceTier,
textVerbosity
)
break
default:
@@ -201,10 +248,14 @@ export function buildProviderOptions(
}
}
// 合并自定义参数 provider 特定的选项中
// 获取自定义参数并分离标准参数和 provider 特定参数
const customParams = getCustomParameters(assistant)
const { standardParams, providerParams } = extractAiSdkStandardParams(customParams)
// 合并 provider 特定的自定义参数到 providerSpecificOptions
providerSpecificOptions = {
...providerSpecificOptions,
...getCustomParameters(assistant)
...providerParams
}
let rawProviderKey =
@@ -212,16 +263,21 @@ export function buildProviderOptions(
'google-vertex': 'google',
'google-vertex-anthropic': 'anthropic',
'azure-anthropic': 'anthropic',
'ai-gateway': 'gateway'
'ai-gateway': 'gateway',
azure: 'openai',
'azure-responses': 'openai'
}[rawProviderId] || rawProviderId
if (rawProviderKey === 'cherryin') {
rawProviderKey = { gemini: 'google' }[actualProvider.type] || actualProvider.type
rawProviderKey = { gemini: 'google', ['openai-response']: 'openai' }[actualProvider.type] || actualProvider.type
}
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions }
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions } 以及提取的标准参数
return {
[rawProviderKey]: providerSpecificOptions
providerOptions: {
[rawProviderKey]: providerSpecificOptions
},
standardParams
}
}
@@ -236,7 +292,8 @@ function buildOpenAIProviderOptions(
enableWebSearch: boolean
enableGenerateImage: boolean
},
serviceTier: OpenAIServiceTier
serviceTier: OpenAIServiceTier,
textVerbosity?: OpenAIVerbosity
): OpenAIResponsesProviderOptions {
const { enableReasoning } = capabilities
let providerOptions: OpenAIResponsesProviderOptions = {}
@@ -248,8 +305,13 @@ function buildOpenAIProviderOptions(
...reasoningParams
}
}
const provider = getProviderById(model.provider)
if (isSupportVerbosityModel(model)) {
if (!provider) {
throw new Error(`Provider ${model.provider} not found`)
}
if (isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider)) {
const openAI = getStoreSetting<'openAI'>('openAI')
const userVerbosity = openAI?.verbosity
@@ -267,7 +329,8 @@ function buildOpenAIProviderOptions(
providerOptions = {
...providerOptions,
serviceTier
serviceTier,
textVerbosity
}
return providerOptions
@@ -366,11 +429,13 @@ function buildCherryInProviderOptions(
enableGenerateImage: boolean
},
actualProvider: Provider,
serviceTier: OpenAIServiceTier
serviceTier: OpenAIServiceTier,
textVerbosity: OpenAIVerbosity
): OpenAIResponsesProviderOptions | AnthropicProviderOptions | GoogleGenerativeAIProviderOptions {
switch (actualProvider.type) {
case 'openai':
return buildOpenAIProviderOptions(assistant, model, capabilities, serviceTier)
case 'openai-response':
return buildOpenAIProviderOptions(assistant, model, capabilities, serviceTier, textVerbosity)
case 'anthropic':
return buildAnthropicProviderOptions(assistant, model, capabilities)
+88 -23
View File
@@ -12,7 +12,8 @@ import {
isDeepSeekHybridInferenceModel,
isDoubaoSeedAfter251015,
isDoubaoThinkingAutoModel,
isGemini3Model,
isGemini3ThinkingTokenModel,
isGPT5SeriesModel,
isGPT51SeriesModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
@@ -36,7 +37,7 @@ import {
} from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings'
import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService'
import type { Assistant, Model, ReasoningEffortOption } from '@renderer/types'
import type { Assistant, Model } from '@renderer/types'
import { EFFORT_RATIO, isSystemProvider, SystemProviderIds } from '@renderer/types'
import type { OpenAISummaryText } from '@renderer/types/aiCoreTypes'
import type { ReasoningEffortOptionalParams } from '@renderer/types/sdk'
@@ -142,6 +143,69 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
}
// reasoningEffort有效的情况
// https://creator.poe.com/docs/external-applications/openai-compatible-api#additional-considerations
// Poe provider - supports custom bot parameters via extra_body
if (provider.id === SystemProviderIds.poe) {
// GPT-5 series models use reasoning_effort parameter in extra_body
if (isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) {
return {
extra_body: {
reasoning_effort: reasoningEffort === 'auto' ? 'medium' : reasoningEffort
}
}
}
// Claude models use thinking_budget parameter in extra_body
if (isSupportedThinkingTokenClaudeModel(model)) {
const effortRatio = EFFORT_RATIO[reasoningEffort]
const tokenLimit = findTokenLimit(model.id)
const maxTokens = assistant.settings?.maxTokens
if (!tokenLimit) {
logger.warn(
`No token limit configuration found for Claude model "${model.id}" on Poe provider. ` +
`Reasoning effort setting "${reasoningEffort}" will not be applied.`
)
return {}
}
let budgetTokens = Math.floor((tokenLimit.max - tokenLimit.min) * effortRatio + tokenLimit.min)
budgetTokens = Math.floor(Math.max(1024, Math.min(budgetTokens, (maxTokens || DEFAULT_MAX_TOKENS) * effortRatio)))
return {
extra_body: {
thinking_budget: budgetTokens
}
}
}
// Gemini models use thinking_budget parameter in extra_body
if (isSupportedThinkingTokenGeminiModel(model)) {
const effortRatio = EFFORT_RATIO[reasoningEffort]
const tokenLimit = findTokenLimit(model.id)
let budgetTokens: number | undefined
if (tokenLimit && reasoningEffort !== 'auto') {
budgetTokens = Math.floor((tokenLimit.max - tokenLimit.min) * effortRatio + tokenLimit.min)
} else if (!tokenLimit && reasoningEffort !== 'auto') {
logger.warn(
`No token limit configuration found for Gemini model "${model.id}" on Poe provider. ` +
`Using auto (-1) instead of requested effort "${reasoningEffort}".`
)
}
return {
extra_body: {
thinking_budget: budgetTokens ?? -1
}
}
}
// Poe reasoning model not in known categories (GPT-5, Claude, Gemini)
logger.warn(
`Poe provider reasoning model "${model.id}" does not match known categories ` +
`(GPT-5, Claude, Gemini). Reasoning effort setting "${reasoningEffort}" will not be applied.`
)
return {}
}
// OpenRouter models
if (model.provider === SystemProviderIds.openrouter) {
@@ -281,7 +345,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// gemini series, openai compatible api
if (isSupportedThinkingTokenGeminiModel(model)) {
// https://ai.google.dev/gemini-api/docs/gemini-3?thinking=high#openai_compatibility
if (isGemini3Model(model)) {
if (isGemini3ThinkingTokenModel(model)) {
return {
reasoning_effort: reasoningEffort
}
@@ -465,20 +529,20 @@ export function getAnthropicReasoningParams(
return {}
}
type GoogelThinkingLevel = NonNullable<GoogleGenerativeAIProviderOptions['thinkingConfig']>['thinkingLevel']
// type GoogleThinkingLevel = NonNullable<GoogleGenerativeAIProviderOptions['thinkingConfig']>['thinkingLevel']
function mapToGeminiThinkingLevel(reasoningEffort: ReasoningEffortOption): GoogelThinkingLevel {
switch (reasoningEffort) {
case 'low':
return 'low'
case 'medium':
return 'medium'
case 'high':
return 'high'
default:
return 'medium'
}
}
// function mapToGeminiThinkingLevel(reasoningEffort: ReasoningEffortOption): GoogelThinkingLevel {
// switch (reasoningEffort) {
// case 'low':
// return 'low'
// case 'medium':
// return 'medium'
// case 'high':
// return 'high'
// default:
// return 'medium'
// }
// }
/**
* 获取 Gemini 推理参数
@@ -507,14 +571,15 @@ export function getGeminiReasoningParams(
}
}
// TODO: 很多中转还不支持
// https://ai.google.dev/gemini-api/docs/gemini-3?thinking=high#new_api_features_in_gemini_3
if (isGemini3Model(model)) {
return {
thinkingConfig: {
thinkingLevel: mapToGeminiThinkingLevel(reasoningEffort)
}
}
}
// if (isGemini3ThinkingTokenModel(model)) {
// return {
// thinkingConfig: {
// thinkingLevel: mapToGeminiThinkingLevel(reasoningEffort)
// }
// }
// }
const effortRatio = EFFORT_RATIO[reasoningEffort]
+3 -3
View File
@@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
interface SelectorOption<V = string | number | undefined | null> {
interface SelectorOption<V = string | number> {
label: string | ReactNode
value: V
type?: 'group'
@@ -14,7 +14,7 @@ interface SelectorOption<V = string | number | undefined | null> {
disabled?: boolean
}
interface BaseSelectorProps<V = string | number | undefined | null> {
interface BaseSelectorProps<V = string | number> {
options: SelectorOption<V>[]
placeholder?: string
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
@@ -39,7 +39,7 @@ interface MultipleSelectorProps<V> extends BaseSelectorProps<V> {
export type SelectorProps<V> = SingleSelectorProps<V> | MultipleSelectorProps<V>
const Selector = <V extends string | number | undefined | null>({
const Selector = <V extends string | number>({
options,
value,
onChange = () => {},
@@ -33,6 +33,7 @@ import {
MODEL_SUPPORTED_OPTIONS,
MODEL_SUPPORTED_REASONING_EFFORT
} from '../reasoning'
import { isGemini3ThinkingTokenModel } from '../utils'
import { isTextToImageModel } from '../vision'
vi.mock('@renderer/store', () => ({
@@ -799,20 +800,6 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
})
})
describe('Token limit lookup', () => {
it.each([
['gemini-2.5-flash-lite-latest', { min: 512, max: 24576 }],
['qwen-plus-2025-07-14', { min: 0, max: 38912 }],
['claude-haiku-4', { min: 1024, max: 64000 }]
])('returns configured min/max pairs for %s', (id, expected) => {
expect(findTokenLimit(id)).toEqual(expected)
})
it('returns undefined when regex misses', () => {
expect(findTokenLimit('unknown-model')).toBeUndefined()
})
})
describe('Gemini Models', () => {
describe('isSupportedThinkingTokenGeminiModel', () => {
it('should return true for gemini 2.5 models', () => {
@@ -955,7 +942,7 @@ describe('Gemini Models', () => {
provider: '',
group: ''
})
).toBe(true)
).toBe(false)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-3.0-flash-image-preview',
@@ -963,7 +950,7 @@ describe('Gemini Models', () => {
provider: '',
group: ''
})
).toBe(true)
).toBe(false)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-3.5-pro-image-preview',
@@ -971,7 +958,7 @@ describe('Gemini Models', () => {
provider: '',
group: ''
})
).toBe(true)
).toBe(false)
})
it('should return false for gemini-2.x image models', () => {
@@ -1163,7 +1150,7 @@ describe('Gemini Models', () => {
provider: '',
group: ''
})
).toBe(true)
).toBe(false)
expect(
isGeminiReasoningModel({
id: 'gemini-3.5-flash-image-preview',
@@ -1171,7 +1158,7 @@ describe('Gemini Models', () => {
provider: '',
group: ''
})
).toBe(true)
).toBe(false)
})
it('should return false for older gemini models without thinking', () => {
@@ -1200,6 +1187,19 @@ describe('Gemini Models', () => {
})
describe('findTokenLimit', () => {
describe('General token limit lookup', () => {
it.each([
['gemini-2.5-flash-lite-latest', { min: 512, max: 24576 }],
['qwen-plus-2025-07-14', { min: 0, max: 38912 }]
])('returns configured min/max pairs for %s', (id, expected) => {
expect(findTokenLimit(id)).toEqual(expected)
})
it('returns undefined when regex misses', () => {
expect(findTokenLimit('unknown-model')).toBeUndefined()
})
})
const cases: Array<{ modelId: string; expected: { min: number; max: number } }> = [
{ modelId: 'gemini-2.5-flash-lite-exp', expected: { min: 512, max: 24_576 } },
{ modelId: 'gemini-1.5-flash', expected: { min: 0, max: 24_576 } },
@@ -1215,11 +1215,7 @@ describe('findTokenLimit', () => {
{ modelId: 'qwen-plus-ultra', expected: { min: 0, max: 81_920 } },
{ modelId: 'qwen-turbo-pro', expected: { min: 0, max: 38_912 } },
{ modelId: 'qwen-flash-lite', expected: { min: 0, max: 81_920 } },
{ modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } },
{ modelId: 'claude-3.7-sonnet-extended', expected: { min: 1_024, max: 64_000 } },
{ modelId: 'claude-sonnet-4.1', expected: { min: 1_024, max: 64_000 } },
{ modelId: 'claude-sonnet-4-5-20250929', expected: { min: 1_024, max: 64_000 } },
{ modelId: 'claude-opus-4-1-extended', expected: { min: 1_024, max: 32_000 } }
{ modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } }
]
it.each(cases)('returns correct limits for $modelId', ({ modelId, expected }) => {
@@ -1229,4 +1225,355 @@ describe('findTokenLimit', () => {
it('returns undefined for unknown models', () => {
expect(findTokenLimit('unknown-model')).toBeUndefined()
})
describe('Claude models', () => {
describe('Claude 3.7 Sonnet models', () => {
it.each([
'claude-3.7-sonnet',
'claude-3-7-sonnet',
'claude-3.7-sonnet-latest',
'claude-3-7-sonnet-latest',
'claude-3.7-sonnet-20250201',
'claude-3-7-sonnet-20250201',
// Official Claude API IDs
'claude-3-7-sonnet-20250219',
// AWS Bedrock format
'anthropic.claude-3-7-sonnet-20250219-v1:0',
// GCP Vertex AI format
'claude-3-7-sonnet@20250219'
])('should return { min: 1024, max: 64000 } for %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 64_000 })
})
it.each(['CLAUDE-3.7-SONNET', 'Claude-3-7-Sonnet-Latest'])('should be case insensitive for %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 64_000 })
})
})
describe('Claude 4.0 series models', () => {
it.each([
'claude-sonnet-4',
'claude-sonnet-4.0',
'claude-sonnet-4-0',
'claude-sonnet-4-preview',
'claude-sonnet-4.0-preview',
'claude-sonnet-4-20250101',
// Official Claude API IDs
'claude-sonnet-4-20250514',
// AWS Bedrock format
'anthropic.claude-sonnet-4-20250514-v1:0',
// GCP Vertex AI format
'claude-sonnet-4@20250514'
])('should return { min: 1024, max: 64000 } for Sonnet variant %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 64_000 })
})
it.each([
'claude-opus-4',
'claude-opus-4.0',
'claude-opus-4-0',
'claude-opus-4-preview',
'claude-opus-4.0-preview',
'claude-opus-4-20250101',
// Official Claude API IDs
'claude-opus-4-20250514',
// AWS Bedrock format
'anthropic.claude-opus-4-20250514-v1:0',
// GCP Vertex AI format
'claude-opus-4@20250514'
])('should return { min: 1024, max: 32000 } for Opus variant %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 32_000 })
})
it.each(['CLAUDE-SONNET-4', 'Claude-Opus-4-Preview'])('should be case insensitive for %s', (modelId) => {
const expectedSonnet = { min: 1024, max: 64_000 }
const expectedOpus = { min: 1024, max: 32_000 }
const result = findTokenLimit(modelId)
expect(result).toBeDefined()
expect([expectedSonnet, expectedOpus]).toContainEqual(result)
})
})
describe('Claude Opus 4.1 models', () => {
it.each([
'claude-opus-4.1',
'claude-opus-4-1',
'claude-opus-4.1-preview',
'claude-opus-4-1-preview',
'claude-opus-4.1-20250120',
'claude-opus-4-1-20250120',
// Official Claude API IDs
'claude-opus-4-1-20250805',
// AWS Bedrock format
'anthropic.claude-opus-4-1-20250805-v1:0',
// GCP Vertex AI format
'claude-opus-4-1@20250805'
])('should return { min: 1024, max: 32000 } for %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 32_000 })
})
it.each(['CLAUDE-OPUS-4.1', 'Claude-Opus-4-1-Preview'])('should be case insensitive for %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 32_000 })
})
})
describe('Claude 4.5 series models (Haiku, Sonnet, Opus)', () => {
it.each([
'claude-haiku-4.5',
'claude-haiku-4-5',
'claude-haiku-4.5-preview',
'claude-haiku-4-5-preview',
'claude-haiku-4.5-20250929',
'claude-haiku-4-5-20250929',
// Official Claude API IDs
'claude-haiku-4-5-20251001',
// AWS Bedrock format
'anthropic.claude-haiku-4-5-20251001-v1:0',
// GCP Vertex AI format
'claude-haiku-4-5@20251001'
])('should return { min: 1024, max: 64000 } for Haiku variant %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 64_000 })
})
it.each([
'claude-sonnet-4.5',
'claude-sonnet-4-5',
'claude-sonnet-4.5-preview',
'claude-sonnet-4-5-preview',
'claude-sonnet-4.5-20250929',
'claude-sonnet-4-5-20250929',
// Official Claude API IDs
'claude-sonnet-4-5-20250929',
// AWS Bedrock format
'anthropic.claude-sonnet-4-5-20250929-v1:0',
// GCP Vertex AI format
'claude-sonnet-4-5@20250929'
])('should return { min: 1024, max: 64000 } for Sonnet variant %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 64_000 })
})
it.each([
'claude-opus-4.5',
'claude-opus-4-5',
'claude-opus-4.5-preview',
'claude-opus-4-5-preview',
'claude-opus-4.5-20250929',
'claude-opus-4-5-20250929',
// Official Claude API IDs
'claude-opus-4-5-20251101',
// AWS Bedrock format
'anthropic.claude-opus-4-5-20251101-v1:0',
// GCP Vertex AI format
'claude-opus-4-5@20251101'
])('should return { min: 1024, max: 64000 } for Opus variant %s', (modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 64_000 })
})
it.each(['CLAUDE-HAIKU-4.5', 'Claude-Sonnet-4-5-Preview', 'CLAUDE-OPUS-4.5-20250929'])(
'should be case insensitive for %s',
(modelId) => {
expect(findTokenLimit(modelId)).toEqual({ min: 1024, max: 64_000 })
}
)
})
describe('Claude models that should NOT match', () => {
it.each([
'claude-3-opus',
'claude-3-sonnet',
'claude-3-haiku',
'claude-3.5-sonnet',
'claude-3-5-sonnet',
'claude-2.1',
'claude-instant',
'claude-haiku-4',
'claude-haiku-4.0',
'claude-haiku-4-0',
'claude-opus-4.2',
'claude-opus-4-2',
'claude-sonnet-4.2',
'claude-sonnet-4-2',
// Old Haiku models (no Extended thinking support)
'claude-3-5-haiku-20241022',
'claude-3-5-haiku-latest',
'anthropic.claude-3-5-haiku-20241022-v1:0',
'claude-3-5-haiku@20241022',
'claude-3-haiku-20240307',
'anthropic.claude-3-haiku-20240307-v1:0',
'claude-3-haiku@20240307'
])('should return undefined for older/unsupported model %s', (modelId) => {
expect(findTokenLimit(modelId)).toBeUndefined()
})
})
describe('Edge cases', () => {
it('should handle models with custom suffixes', () => {
expect(findTokenLimit('claude-3.7-sonnet-custom-variant')).toEqual({ min: 1024, max: 64_000 })
expect(findTokenLimit('claude-opus-4.1-custom')).toEqual({ min: 1024, max: 32_000 })
expect(findTokenLimit('claude-sonnet-4.5-custom-variant')).toEqual({ min: 1024, max: 64_000 })
})
it('should NOT match non-existent Claude 4.1 variants (only Opus 4.1 exists)', () => {
// Claude Sonnet 4.1 and Haiku 4.1 do not exist
expect(findTokenLimit('claude-sonnet-4.1')).toBeUndefined()
expect(findTokenLimit('claude-haiku-4.1')).toBeUndefined()
})
it('should not match partial model names', () => {
expect(findTokenLimit('claude-3.7')).toBeUndefined()
expect(findTokenLimit('claude-opus')).toBeUndefined()
expect(findTokenLimit('claude-4.5')).toBeUndefined()
})
})
})
})
describe('isGemini3ThinkingTokenModel', () => {
it('should return true for Gemini 3 non-image models', () => {
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3-pro-preview',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGemini3ThinkingTokenModel({
id: 'google/gemini-3-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3.0-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3.5-pro-preview',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return false for Gemini 3 image models', () => {
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3-flash-image',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3-pro-image-preview',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3.0-flash-image-preview',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-3.5-pro-image-preview',
name: '',
provider: '',
group: ''
})
).toBe(false)
})
it('should return false for non-Gemini 3 models', () => {
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-2.5-flash',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isGemini3ThinkingTokenModel({
id: 'gemini-1.5-pro',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isGemini3ThinkingTokenModel({
id: 'gpt-4',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isGemini3ThinkingTokenModel({
id: 'claude-3-opus',
name: '',
provider: '',
group: ''
})
).toBe(false)
})
it('should handle case insensitivity', () => {
expect(
isGemini3ThinkingTokenModel({
id: 'Gemini-3-Flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGemini3ThinkingTokenModel({
id: 'GEMINI-3-PRO',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGemini3ThinkingTokenModel({
id: 'Gemini-3-Pro-Image',
name: '',
provider: '',
group: ''
})
).toBe(false)
})
})
@@ -24,9 +24,9 @@ import {
isGemmaModel,
isGenerateImageModels,
isMaxTemperatureOneModel,
isNotSupportedTextDelta,
isNotSupportSystemMessageModel,
isNotSupportTemperatureAndTopP,
isNotSupportTextDeltaModel,
isSupportedFlexServiceTier,
isSupportedModel,
isSupportFlexServiceTierModel,
@@ -215,12 +215,51 @@ describe('model utils', () => {
it('aggregates boolean helpers based on regex rules', () => {
expect(isAnthropicModel(createModel({ id: 'claude-3.5' }))).toBe(true)
expect(isQwenMTModel(createModel({ id: 'qwen-mt-large' }))).toBe(true)
expect(isNotSupportedTextDelta(createModel({ id: 'qwen-mt-large' }))).toBe(true)
expect(isQwenMTModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
expect(isNotSupportSystemMessageModel(createModel({ id: 'gemma-moe' }))).toBe(true)
expect(isOpenAIOpenWeightModel(createModel({ id: 'gpt-oss-free' }))).toBe(true)
})
describe('isNotSupportedTextDelta', () => {
it('returns true for qwen-mt-turbo and qwen-mt-plus models', () => {
// qwen-mt series that don't support text delta
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo' }))).toBe(true)
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Turbo' }))).toBe(true)
expect(isNotSupportTextDeltaModel(createModel({ id: 'QWEN-MT-PLUS' }))).toBe(true)
})
it('returns false for qwen-mt-flash and other models', () => {
// qwen-mt-flash supports text delta
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-flash' }))).toBe(false)
expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Flash' }))).toBe(false)
// Legacy qwen models without mt prefix (support text delta)
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo' }))).toBe(false)
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus' }))).toBe(false)
// Other qwen models
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-max' }))).toBe(false)
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen2.5-72b' }))).toBe(false)
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-vl-plus' }))).toBe(false)
// Non-qwen models
expect(isNotSupportTextDeltaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
expect(isNotSupportTextDeltaModel(createModel({ id: 'claude-3.5' }))).toBe(false)
expect(isNotSupportTextDeltaModel(createModel({ id: 'glm-4-plus' }))).toBe(false)
})
it('handles models with version suffixes', () => {
// qwen-mt models with version suffixes
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo-1201' }))).toBe(true)
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus-0828' }))).toBe(true)
// Legacy qwen models with version suffixes (support text delta)
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo-0828' }))).toBe(false)
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus-latest' }))).toBe(false)
})
})
it('evaluates GPT-5 family helpers', () => {
expect(isGPT5SeriesModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
expect(isGPT5SeriesModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(false)
+16 -16
View File
@@ -16,7 +16,7 @@ import {
isOpenAIReasoningModel,
isSupportedReasoningEffortOpenAIModel
} from './openai'
import { GEMINI_FLASH_MODEL_REGEX, isGemini3Model } from './utils'
import { GEMINI_FLASH_MODEL_REGEX, isGemini3ThinkingTokenModel } from './utils'
import { isTextToImageModel } from './vision'
// Reasoning models
@@ -115,7 +115,7 @@ const _getThinkModelType = (model: Model): ThinkingModelType => {
} else {
thinkingModelType = 'gemini_pro'
}
if (isGemini3Model(model)) {
if (isGemini3ThinkingTokenModel(model)) {
thinkingModelType = 'gemini3'
}
} else if (isSupportedReasoningEffortGrokModel(model)) thinkingModelType = 'grok'
@@ -201,7 +201,7 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
}
const modelId = getLowerBaseModelName(model.id)
const providerId = model.provider.toLowerCase()
const providerId = model?.provider?.toLowerCase()
if (modelId.includes('grok-3-mini')) {
return true
}
@@ -271,14 +271,6 @@ export const GEMINI_THINKING_MODEL_REGEX =
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
if (GEMINI_THINKING_MODEL_REGEX.test(modelId)) {
// gemini-3.x 的 image 模型支持思考模式
if (isGemini3Model(model)) {
if (modelId.includes('tts')) {
return false
}
return true
}
// gemini-2.x 的 image/tts 模型不支持
if (modelId.includes('image') || modelId.includes('tts')) {
return false
}
@@ -555,7 +547,7 @@ export function isReasoningModel(model?: Model): boolean {
return REASONING_REGEX.test(modelId) || false
}
export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
// Gemini models
'gemini-2\\.5-flash-lite.*$': { min: 512, max: 24576 },
'gemini-.*-flash.*$': { min: 0, max: 24576 },
@@ -576,10 +568,18 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
'qwen-flash.*$': { min: 0, max: 81_920 },
'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
// Claude models
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64_000 },
'claude-(:?haiku|sonnet)-4.*$': { min: 1024, max: 64_000 },
'claude-opus-4-1.*$': { min: 1024, max: 32_000 }
// Claude models (supports AWS Bedrock 'anthropic.' prefix, GCP Vertex AI '@' separator, and '-v1:0' suffix)
'(?:anthropic\\.)?claude-3[.-]7.*sonnet.*(?:-v\\d+:\\d+)?$': { min: 1024, max: 64_000 },
'(?:anthropic\\.)?claude-(:?haiku|sonnet|opus)-4[.-]5.*(?:-v\\d+:\\d+)?$': { min: 1024, max: 64_000 },
'(?:anthropic\\.)?claude-opus-4[.-]1.*(?:-v\\d+:\\d+)?$': { min: 1024, max: 32_000 },
'(?:anthropic\\.)?claude-sonnet-4(?:[.-]0)?(?:[@-](?:\\d{4,}|[a-z][\\w-]*))?(?:-v\\d+:\\d+)?$': {
min: 1024,
max: 64_000
},
'(?:anthropic\\.)?claude-opus-4(?:[.-]0)?(?:[@-](?:\\d{4,}|[a-z][\\w-]*))?(?:-v\\d+:\\d+)?$': {
min: 1024,
max: 32_000
}
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
+2 -1
View File
@@ -43,7 +43,8 @@ const FUNCTION_CALLING_EXCLUDED_MODELS = [
'gpt-5-chat(?:-[\\w-]+)?',
'glm-4\\.5v',
'gemini-2.5-flash-image(?:-[\\w-]+)?',
'gemini-2.0-flash-preview-image-generation'
'gemini-2.0-flash-preview-image-generation',
'gemini-3(?:\\.\\d+)?-pro-image(?:-[\\w-]+)?'
]
export const FUNCTION_CALLING_REGEX = new RegExp(
+11 -2
View File
@@ -19,6 +19,7 @@ export function isSupportFlexServiceTierModel(model: Model): boolean {
(modelId.includes('o3') && !modelId.includes('o3-mini')) || modelId.includes('o4-mini') || modelId.includes('gpt-5')
)
}
export function isSupportedFlexServiceTier(model: Model): boolean {
return isSupportFlexServiceTierModel(model)
}
@@ -111,8 +112,11 @@ export const isAnthropicModel = (model?: Model): boolean => {
return modelId.startsWith('claude')
}
export const isNotSupportedTextDelta = (model: Model): boolean => {
return isQwenMTModel(model)
const NOT_SUPPORT_TEXT_DELTA_MODEL_REGEX = new RegExp('qwen-mt-(?:turbo|plus)')
export const isNotSupportTextDeltaModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id)
return NOT_SUPPORT_TEXT_DELTA_MODEL_REGEX.test(modelId)
}
export const isNotSupportSystemMessageModel = (model: Model): boolean => {
@@ -160,3 +164,8 @@ export const isGemini3Model = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('gemini-3')
}
export const isGemini3ThinkingTokenModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return isGemini3Model(model) && !modelId.includes('image')
}
+2
View File
@@ -95,6 +95,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://api.siliconflow.cn',
anthropicApiHost: 'https://api.siliconflow.cn',
models: SYSTEM_MODELS.silicon,
isSystem: true,
enabled: false
@@ -168,6 +169,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://www.dmxapi.cn',
anthropicApiHost: 'https://www.dmxapi.cn',
models: SYSTEM_MODELS.dmxapi,
isSystem: true,
enabled: false
+9
View File
@@ -1148,6 +1148,7 @@
"fullscreen": "Entered fullscreen mode. Press F11 to exit",
"go_to_settings": "Go to settings",
"i_know": "I know",
"ignore": "Ignore",
"inspect": "Inspect",
"invalid_value": "Invalid Value",
"knowledge_base": "Knowledge Base",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "Enter additional information or context for this knowledge base...",
"provider_not_found": "Provider not found",
"quota": "{{name}} Left Quota: {{quota}}",
"quota_empty": "Today's {{name}} quota exhausted, please apply on the official website",
"quota_infinity": "{{name}} Quota: Unlimited",
"rename": "Rename",
"search": "Search knowledge base",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "View WebDAV settings"
},
"groq": {
"title": "Groq Settings"
},
"hardware_acceleration": {
"confirm": {
"content": "Disabling hardware acceleration requires restarting the app to take effect. Do you want to restart now?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "Does the provider support the stream_options parameter?",
"label": "Support stream_options"
},
"verbosity": {
"help": "Whether the provider supports the verbosity parameter",
"label": "Support verbosity"
}
},
"url": {
+9
View File
@@ -1148,6 +1148,7 @@
"fullscreen": "已进入全屏模式,按 F11 退出",
"go_to_settings": "前往设置",
"i_know": "我知道了",
"ignore": "忽略",
"inspect": "检查",
"invalid_value": "无效值",
"knowledge_base": "知识库",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "输入此知识库的附加信息或上下文...",
"provider_not_found": "未找到服务商",
"quota": "{{name}} 剩余额度:{{quota}}",
"quota_empty": "今日{{name}}额度不足,请前往官网申请",
"quota_infinity": "{{name}} 剩余额度:无限制",
"rename": "重命名",
"search": "搜索知识库",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "查看 WebDAV 设置"
},
"groq": {
"title": "Groq 设置"
},
"hardware_acceleration": {
"confirm": {
"content": "禁用硬件加速需要重启应用才能生效,是否现在重启?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "该提供商是否支持 stream_options 参数",
"label": "支持 stream_options"
},
"verbosity": {
"help": "该提供商是否支持 verbosity 参数",
"label": "支持 verbosity"
}
},
"url": {
+9
View File
@@ -1148,6 +1148,7 @@
"fullscreen": "已進入全螢幕模式,按 F11 結束",
"go_to_settings": "前往設定",
"i_know": "我知道了",
"ignore": "忽略",
"inspect": "檢查",
"invalid_value": "無效值",
"knowledge_base": "知識庫",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "輸入此知識庫的附加資訊或上下文...",
"provider_not_found": "未找到服務商",
"quota": "{{name}} 剩餘配額:{{quota}}",
"quota_empty": "今日{{name}}額度不足,請前往官網申請",
"quota_infinity": "{{name}} 配額:無限制",
"rename": "重新命名",
"search": "搜尋知識庫",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "檢視 WebDAV 設定"
},
"groq": {
"title": "Groq 設定"
},
"hardware_acceleration": {
"confirm": {
"content": "禁用硬件加速需要重新啟動應用程序才能生效。是否立即重新啟動?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "該提供商是否支援 stream_options 參數",
"label": "支援 stream_options"
},
"verbosity": {
"help": "提供者是否支援詳細程度參數",
"label": "支援詳細資訊"
}
},
"url": {
@@ -1148,6 +1148,7 @@
"fullscreen": "Vollbildmodus aktiviert, F11 zum Beenden",
"go_to_settings": "Zu Einstellungen",
"i_know": "Verstanden",
"ignore": "Ignorieren",
"inspect": "Prüfen",
"invalid_value": "Ungültiger Wert",
"knowledge_base": "Wissensdatenbank",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "Zusätzliche Informationen oder Kontext für diese Wissensdatenbank eingeben...",
"provider_not_found": "Anbieter nicht gefunden",
"quota": "{{name}} verbleibendes Kontingent: {{quota}}",
"quota_empty": "Das heutige {{name}}-Kontingent ist erschöpft. Bitte beantragen Sie es auf der offiziellen Website",
"quota_infinity": "{{name}} verbleibendes Kontingent: unbegrenzt",
"rename": "Umbenennen",
"search": "Wissensdatenbank durchsuchen",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "WebDAV-Einstellungen anzeigen"
},
"groq": {
"title": "Groq Einstellungen"
},
"hardware_acceleration": {
"confirm": {
"content": "Deaktivierung der Hardwarebeschleunigung erfordert Neustart. Jetzt neu starten?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "Unterstützt stream_options",
"label": "Unterstützt stream_options"
},
"verbosity": {
"help": "Ob der Anbieter den Ausführlichkeitsparameter unterstützt",
"label": "Unterstützung der Ausführlichkeit"
}
},
"url": {
@@ -1148,6 +1148,7 @@
"fullscreen": "Εισήχθη σε πλήρη οθόνη, πατήστε F11 για να έξω",
"go_to_settings": "Πηγαίνετε στις ρυθμίσεις",
"i_know": "Το έχω καταλάβει",
"ignore": "Αγνόησε",
"inspect": "Επιθεώρηση",
"invalid_value": "Μη έγκυρη τιμή",
"knowledge_base": "Βάση Γνώσεων",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "Εισάγετε πρόσθετες πληροφορίες ή πληροφορίες προσδιορισμού για αυτή τη βάση γνώσεων...",
"provider_not_found": "Η παροχή υπηρεσιών μοντέλου βάσης γνώσεων χαθηκε, αυτή η βάση γνώσεων δεν θα υποστηρίζεται πλέον, παρακαλείστε να δημιουργήσετε ξανά μια βάση γνώσεων",
"quota": "Διαθέσιμο όριο για {{name}}: {{quota}}",
"quota_empty": "Το σημερινό όριο {{name}} εξαντλήθηκε, παρακαλούμε υποβάλετε αίτηση στην επίσημη ιστοσελίδα",
"quota_infinity": "Διαθέσιμο όριο για {{name}}: Απεριόριστο",
"rename": "Μετονομασία",
"search": "Αναζήτηση βάσης γνώσεων",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "Προβολή ρυθμίσεων WebDAV"
},
"groq": {
"title": "Ρυθμίσεις Groq"
},
"hardware_acceleration": {
"confirm": {
"content": "Η απενεργοποίηση της υλικοποιημένης επιτάχυνσης απαιτεί επανεκκίνηση της εφαρμογής για να τεθεί σε ισχύ. Θέλετε να επανεκκινήσετε τώρα;",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "Υποστηρίζει ο πάροχος την παράμετρο stream_options;",
"label": "Υποστήριξη stream_options"
},
"verbosity": {
"help": "Αν ο πάροχος υποστηρίζει την παράμετρο αναλυτικότητας",
"label": "Υποστήριξη πολυλογίας"
}
},
"url": {
@@ -1148,6 +1148,7 @@
"fullscreen": "En modo pantalla completa, presione F11 para salir",
"go_to_settings": "Ir a la configuración",
"i_know": "Entendido",
"ignore": "Ignorar",
"inspect": "Inspeccionar",
"invalid_value": "Valor inválido",
"knowledge_base": "Base de conocimiento",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "Ingrese información adicional o contexto para esta base de conocimientos...",
"provider_not_found": "El proveedor del modelo de la base de conocimientos ha sido perdido, esta base de conocimientos ya no es compatible, por favor cree una nueva base de conocimientos",
"quota": "Cupo restante de {{name}}: {{quota}}",
"quota_empty": "La cuota de {{name}} de hoy está agotada, por favor solicítela en el sitio web oficial",
"quota_infinity": "Cupo restante de {{name}}: ilimitado",
"rename": "Renombrar",
"search": "Buscar en la base de conocimientos",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "Ver configuración WebDAV"
},
"groq": {
"title": "Configuración de Groq"
},
"hardware_acceleration": {
"confirm": {
"content": "La desactivación de la aceleración por hardware requiere reiniciar la aplicación para que surta efecto, ¿desea reiniciar ahora?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "¿Admite el proveedor el parámetro stream_options?",
"label": "Admite stream_options"
},
"verbosity": {
"help": "Si el proveedor admite el parámetro de verbosidad",
"label": "Soporte de verbosidad"
}
},
"url": {
@@ -1148,6 +1148,7 @@
"fullscreen": "Mode plein écran, appuyez sur F11 pour quitter",
"go_to_settings": "Aller aux paramètres",
"i_know": "J'ai compris",
"ignore": "Ignorer",
"inspect": "Vérifier",
"invalid_value": "valeur invalide",
"knowledge_base": "Base de connaissances",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "Entrez des informations supplémentaires ou un contexte pour cette base de connaissances...",
"provider_not_found": "Le fournisseur du modèle de la base de connaissances a été perdu, cette base de connaissances ne sera plus supportée, veuillez en créer une nouvelle",
"quota": "Quota restant pour {{name}} : {{quota}}",
"quota_empty": "Le quota {{name}} d'aujourd'hui est épuisé, veuillez en faire la demande sur le site officiel",
"quota_infinity": "Quota restant pour {{name}} : illimité",
"rename": "Renommer",
"search": "Rechercher dans la base de connaissances",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "Voir les paramètres WebDAV"
},
"groq": {
"title": "Paramètres Groq"
},
"hardware_acceleration": {
"confirm": {
"content": "La désactivation de l'accélération matérielle nécessite un redémarrage de l'application pour prendre effet. Voulez-vous redémarrer maintenant ?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "Le fournisseur prend-il en charge le paramètre stream_options ?",
"label": "Prise en charge des options de flux"
},
"verbosity": {
"help": "Si le fournisseur prend en charge le paramètre de verbosité",
"label": "Prend en charge la verbosité"
}
},
"url": {
+11 -2
View File
@@ -893,7 +893,7 @@
"title": "コード実行"
},
"code_fancy_block": {
"label": "<translate_input>\n装飾的なコードブロック\n</translate_input>",
"label": "装飾的なコードブロック",
"tip": "より見栄えの良いコードブロックスタイルを使用する、例えばHTMLカード"
},
"code_image_tools": {
@@ -1148,6 +1148,7 @@
"fullscreen": "全画面モードに入りました。F11キーで終了します",
"go_to_settings": "設定に移動",
"i_know": "わかりました",
"ignore": "無視",
"inspect": "検査",
"invalid_value": "無効な値",
"knowledge_base": "ナレッジベース",
@@ -1296,7 +1297,7 @@
"statusCode": "ステータスコード",
"statusText": "状態テキスト",
"text": "テキスト",
"toolInput": "<translate_input>\nツール入力\n</translate_input>",
"toolInput": "ツール入力",
"toolName": "ツール名",
"unknown": "不明なエラー",
"usage": "用量",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "このナレッジベースの追加情報やコンテキストを入力...",
"provider_not_found": "プロバイダーが見つかりません",
"quota": "{{name}} 残りクォータ: {{quota}}",
"quota_empty": "本日の{{name}}クォータが不足しています。公式サイトで申請してください",
"quota_infinity": "{{name}} クォータ: 無制限",
"rename": "名前を変更",
"search": "ナレッジベースを検索",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "WebDAV設定を表示"
},
"groq": {
"title": "Groq設定"
},
"hardware_acceleration": {
"confirm": {
"content": "ハードウェアアクセラレーションを無効にするには、アプリを再起動する必要があります。再起動しますか?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "このプロバイダーは stream_options パラメータをサポートしていますか",
"label": "stream_options をサポート"
},
"verbosity": {
"help": "プロバイダーが冗長度パラメータをサポートしているかどうか",
"label": "冗長性のサポート"
}
},
"url": {
@@ -1148,6 +1148,7 @@
"fullscreen": "Entrou no modo de tela cheia, pressione F11 para sair",
"go_to_settings": "Ir para configurações",
"i_know": "Entendi",
"ignore": "Pular",
"inspect": "Verificar",
"invalid_value": "Valor inválido",
"knowledge_base": "Base de Conhecimento",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "Digite informações adicionais ou contexto para este repositório de conhecimento...",
"provider_not_found": "O provedor do modelo do repositório de conhecimento foi perdido, este repositório de conhecimento não será mais suportado, por favor, crie um novo repositório de conhecimento",
"quota": "Cota restante de {{name}}: {{quota}}",
"quota_empty": "A cota de {{name}} de hoje está esgotada, por favor solicite no site oficial",
"quota_infinity": "Cota restante de {{name}}: ilimitada",
"rename": "Renomear",
"search": "Pesquisar repositório de conhecimento",
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "Ver configurações WebDAV"
},
"groq": {
"title": "Configurações do Groq"
},
"hardware_acceleration": {
"confirm": {
"content": "A desativação da aceleração de hardware requer a reinicialização do aplicativo para entrar em vigor. Deseja reiniciar agora?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "O fornecedor suporta o parâmetro stream_options?",
"label": "suporta stream_options"
},
"verbosity": {
"help": "Se o provedor suporta o parâmetro de verbosidade",
"label": "Suportar verbosidade"
}
},
"url": {
+14 -5
View File
@@ -1148,6 +1148,7 @@
"fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода",
"go_to_settings": "Перейти в настройки",
"i_know": "Я понял",
"ignore": "Игнорировать",
"inspect": "Осмотреть",
"invalid_value": "недопустимое значение",
"knowledge_base": "База знаний",
@@ -1504,6 +1505,7 @@
"notes_placeholder": "Введите дополнительную информацию или контекст для этой базы знаний...",
"provider_not_found": "Поставщик не найден",
"quota": "{{name}} Остаток квоты: {{quota}}",
"quota_empty": "Сегодняшняя квота {{name}} исчерпана, пожалуйста, подайте заявку на официальном сайте",
"quota_infinity": "{{name}} Квота: Не ограничена",
"rename": "Переименовать",
"search": "Поиск в базе знаний",
@@ -1513,7 +1515,7 @@
"preprocessing_tooltip": "Предварительная обработка документов",
"title": "Настройки базы знаний"
},
"sitemap_added": "添加成功",
"sitemap_added": "Карта сайта добавлена",
"sitemap_placeholder": "Введите URL карты сайта",
"sitemaps": "Сайты",
"source": "Источник",
@@ -2173,12 +2175,12 @@
"font_size": "Размер шрифта",
"font_size_description": "Отрегулируйте размер шрифта для лучшего чтения (10–30 пикселей)",
"font_size_large": "Большой",
"font_size_medium": "",
"font_size_small": "<translate_input>\nмаленький\n</translate_input>",
"font_size_medium": "Средний",
"font_size_small": "маленький",
"font_title": "Настройки шрифта",
"serif_font": "Serif Font",
"show_table_of_contents": "Показать оглавление",
"show_table_of_contents_description": "显示目录大纲侧边栏,方便文档内导航",
"show_table_of_contents_description": "Показать боковую панель оглавления для удобной навигации по документу",
"title": "показывать"
},
"editor": {
@@ -3771,6 +3773,9 @@
},
"view_webdav_settings": "Просмотр настроек WebDAV"
},
"groq": {
"title": "Настройки Groq"
},
"hardware_acceleration": {
"confirm": {
"content": "Отключение аппаратного ускорения требует перезапуска приложения для вступления в силу. Перезапустить приложение?",
@@ -4357,6 +4362,10 @@
"stream_options": {
"help": "Поддерживает ли этот провайдер параметр stream_options",
"label": "Поддержка stream_options"
},
"verbosity": {
"help": "Поддерживает ли провайдер параметр verbosity",
"label": "Поддержка многословности"
}
},
"url": {
@@ -4767,7 +4776,7 @@
}
},
"prompt": "Следуйте системному запросу",
"title": "翻译设置"
"title": "Настройки перевода"
},
"tray": {
"onclose": "Свернуть в трей при закрытии",
@@ -18,6 +18,7 @@ import type { EndpointType, Model } from '@renderer/types'
import { getClaudeSupportedProviders } from '@renderer/utils/provider'
import type { TerminalConfig } from '@shared/config/constant'
import { codeTools, terminalApps } from '@shared/config/constant'
import { isSiliconAnthropicCompatibleModel } from '@shared/config/providers'
import { Alert, Checkbox, Input, Popover, Select, Space } from 'antd'
import { ArrowUpRight, Download, FolderOpen, HelpCircle, Terminal, X } from 'lucide-react'
import type { FC } from 'react'
@@ -82,6 +83,10 @@ const CodeToolsPage: FC = () => {
if (m.supported_endpoint_types) {
return m.supported_endpoint_types.includes('anthropic')
}
// Special handling for silicon provider: only specific models support Anthropic API
if (m.provider === 'silicon') {
return isSiliconAnthropicCompatibleModel(m.id)
}
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
}
+19 -3
View File
@@ -1,4 +1,4 @@
import type { EndpointType, Model, Provider } from '@renderer/types'
import { type EndpointType, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import { codeTools } from '@shared/config/constant'
export interface LaunchValidationResult {
@@ -25,7 +25,17 @@ export const CLI_TOOLS = [
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = [
'deepseek',
'moonshot',
'zhipu',
'dashscope',
'modelscope',
'minimax',
'longcat',
SystemProviderIds.qiniu,
SystemProviderIds.silicon
]
export const CLAUDE_SUPPORTED_PROVIDERS = [
'aihubmix',
'dmxapi',
@@ -79,6 +89,11 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
anthropic: {
api_base_url: 'https://api-inference.modelscope.cn'
}
},
minimax: {
anthropic: {
api_base_url: 'https://api.minimaxi.com/anthropic'
}
}
}
@@ -125,7 +140,8 @@ export const generateToolEnvironment = ({
switch (tool) {
case codeTools.claudeCode:
env.ANTHROPIC_BASE_URL = getCodeToolsApiBaseUrl(model, 'anthropic') || modelProvider.apiHost
env.ANTHROPIC_BASE_URL =
getCodeToolsApiBaseUrl(model, 'anthropic') || modelProvider.anthropicApiHost || modelProvider.apiHost
env.ANTHROPIC_MODEL = model.id
if (modelProvider.type === 'anthropic') {
env.ANTHROPIC_API_KEY = apiKey
@@ -4,6 +4,7 @@ import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo, ZhipuLogo } from
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
import {
isFunctionCallingModel,
isGeminiModel,
isGPT5SeriesReasoningModel,
isOpenAIWebSearchModel,
@@ -18,6 +19,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { isPromptToolUse } from '@renderer/utils/mcp-tools'
import { isGeminiWebSearchProvider } from '@renderer/utils/provider'
import { Globe } from 'lucide-react'
import { useCallback, useEffect, useMemo } from 'react'
@@ -126,20 +128,25 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr
const providerItems = useMemo<QuickPanelListItem[]>(() => {
const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model)
const items: QuickPanelListItem[] = providers
.map((p) => ({
label: p.name,
description: WebSearchService.isWebSearchEnabled(p.id)
? hasObjectKey(p, 'apiKey')
? t('settings.tool.websearch.apikey')
: t('settings.tool.websearch.free')
: t('chat.input.web_search.enable_content'),
icon: <WebSearchProviderIcon size={13} pid={p.id} />,
isSelected: p.id === assistant?.webSearchProviderId,
disabled: !WebSearchService.isWebSearchEnabled(p.id),
action: () => updateQuickPanelItem(p.id)
}))
.filter((item) => !item.disabled)
const items: QuickPanelListItem[] = []
if (isFunctionCallingModel(assistant.model) || isPromptToolUse(assistant)) {
items.push(
...providers
.map((p) => ({
label: p.name,
description: WebSearchService.isWebSearchEnabled(p.id)
? hasObjectKey(p, 'apiKey')
? t('settings.tool.websearch.apikey')
: t('settings.tool.websearch.free')
: t('chat.input.web_search.enable_content'),
icon: <WebSearchProviderIcon size={13} pid={p.id} />,
isSelected: p.id === assistant?.webSearchProviderId,
disabled: !WebSearchService.isWebSearchEnabled(p.id),
action: () => updateQuickPanelItem(p.id)
}))
.filter((item) => !item.disabled)
)
}
if (isWebSearchModelEnabled) {
items.unshift({
@@ -155,15 +162,7 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr
}
return items
}, [
assistant.enableWebSearch,
assistant.model,
assistant?.webSearchProviderId,
providers,
t,
updateQuickPanelItem,
updateToModelBuiltinWebSearch
])
}, [assistant, providers, t, updateQuickPanelItem, updateToModelBuiltinWebSearch])
const openQuickPanel = useCallback(() => {
quickPanelController.open({
@@ -1,4 +1,4 @@
import { isMandatoryWebSearchModel, isWebSearchModel } from '@renderer/config/models'
import { isMandatoryWebSearchModel } from '@renderer/config/models'
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
import WebSearchButton from './components/WebSearchButton'
@@ -15,7 +15,7 @@ const webSearchTool = defineTool({
label: (t) => t('chat.input.web_search.label'),
visibleInScopes: [TopicType.Chat],
condition: ({ model }) => isWebSearchModel(model) && !isMandatoryWebSearchModel(model),
condition: ({ model }) => !isMandatoryWebSearchModel(model),
render: function WebSearchToolRender(context) {
const { assistant, quickPanelController } = context
@@ -31,8 +31,10 @@ import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup'
import { getDefaultModel } from '@renderer/services/AssistantService'
import type { Assistant, AssistantSettings, CodeStyleVarious, MathEngine } from '@renderer/types'
import { isGroqSystemProvider } from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
import { isSupportServiceTierProvider } from '@renderer/utils/provider'
import type { MultiModelMessageStyle, SendMessageShortcut } from '@shared/data/preference/preferenceTypes'
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import { Col, InputNumber, Row, Slider } from 'antd'
@@ -42,6 +44,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import GroqSettingsGroup from './components/GroqSettingsGroup'
import OpenAISettingsGroup from './components/OpenAISettingsGroup'
// Type definition for select items
@@ -239,7 +242,7 @@ const SettingsTab: FC<Props> = (props) => {
const model = assistant.model || getDefaultModel()
const isOpenAI = isOpenAIModel(model)
const showOpenAiSettings = isOpenAIModel(model) || isSupportServiceTierProvider(provider)
return (
<Container className="settings-tab">
@@ -390,7 +393,7 @@ const SettingsTab: FC<Props> = (props) => {
</SettingGroup>
</CollapsibleSettingGroup>
)}
{isOpenAI && (
{showOpenAiSettings && (
<OpenAISettingsGroup
model={model}
providerId={provider.id}
@@ -398,6 +401,9 @@ const SettingsTab: FC<Props> = (props) => {
SettingRowTitleSmall={SettingRowTitleSmall}
/>
)}
{isGroqSystemProvider(provider) && (
<GroqSettingsGroup SettingGroup={SettingGroup} SettingRowTitleSmall={SettingRowTitleSmall} />
)}
<CollapsibleSettingGroup title={t('settings.messages.title')} defaultExpanded={true}>
<SettingGroup>
<SettingRow>
@@ -0,0 +1,79 @@
import Selector from '@renderer/components/Selector'
import { useProvider } from '@renderer/hooks/useProvider'
import { SettingDivider, SettingRow } from '@renderer/pages/settings'
import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup'
import type { GroqServiceTier, ServiceTier } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { toOptionValue, toRealValue } from '@renderer/utils/select'
import { Tooltip } from 'antd'
import { CircleHelp } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
type ServiceTierOptions = { value: NonNullable<GroqServiceTier> | 'undefined'; label: string }
interface Props {
SettingGroup: FC<{ children: React.ReactNode }>
SettingRowTitleSmall: FC<{ children: React.ReactNode }>
}
const GroqSettingsGroup: FC<Props> = ({ SettingGroup, SettingRowTitleSmall }) => {
const { t } = useTranslation()
const { provider, updateProvider } = useProvider(SystemProviderIds.groq)
const serviceTierMode = provider.serviceTier
const setServiceTierMode = useCallback(
(value: ServiceTier) => {
updateProvider({ serviceTier: value })
},
[updateProvider]
)
const serviceTierOptions = useMemo(() => {
const options = [
{
value: 'undefined',
label: t('common.ignore')
},
{
value: 'auto',
label: t('settings.openai.service_tier.auto')
},
{
value: 'on_demand',
label: t('settings.openai.service_tier.on_demand')
},
{
value: 'flex',
label: t('settings.openai.service_tier.flex')
}
] as const satisfies ServiceTierOptions[]
return options
}, [t])
return (
<CollapsibleSettingGroup title={t('settings.groq.title')} defaultExpanded={true}>
<SettingGroup>
<SettingRow>
<SettingRowTitleSmall>
{t('settings.openai.service_tier.title')}{' '}
<Tooltip title={t('settings.openai.service_tier.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<Selector
value={toOptionValue(serviceTierMode)}
onChange={(value) => {
setServiceTierMode(toRealValue(value))
}}
options={serviceTierOptions}
/>
</SettingRow>
</SettingGroup>
<SettingDivider />
</CollapsibleSettingGroup>
)
}
export default GroqSettingsGroup
@@ -12,29 +12,27 @@ import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup'
import type { RootState } from '@renderer/store'
import { useAppDispatch } from '@renderer/store'
import { setOpenAISummaryText, setOpenAIVerbosity } from '@renderer/store/settings'
import type { GroqServiceTier, Model, OpenAIServiceTier, ServiceTier } from '@renderer/types'
import { GroqServiceTiers, OpenAIServiceTiers, SystemProviderIds } from '@renderer/types'
import type { Model, OpenAIServiceTier, ServiceTier } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import type { OpenAISummaryText, OpenAIVerbosity } from '@renderer/types/aiCoreTypes'
import { isSupportServiceTierProvider } from '@renderer/utils/provider'
import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@renderer/utils/provider'
import { toOptionValue, toRealValue } from '@renderer/utils/select'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
type VerbosityOption = {
value: OpenAIVerbosity
value: NonNullable<OpenAIVerbosity> | 'undefined'
label: string
}
type SummaryTextOption = {
value: OpenAISummaryText
value: NonNullable<OpenAISummaryText> | 'undefined'
label: string
}
type OpenAIServiceTierOption = { value: OpenAIServiceTier; label: string }
type GroqServiceTierOption = { value: GroqServiceTier; label: string }
type ServiceTierOptions = OpenAIServiceTierOption[] | GroqServiceTierOption[]
type OpenAIServiceTierOption = { value: NonNullable<OpenAIServiceTier> | 'null' | 'undefined'; label: string }
interface Props {
model: Model
@@ -51,13 +49,14 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
const serviceTierMode = provider.serviceTier
const dispatch = useAppDispatch()
const isOpenAIReasoning =
const showSummarySetting =
isSupportedReasoningEffortOpenAIModel(model) &&
!model.id.includes('o1-pro') &&
(provider.type === 'openai-response' || provider.id === 'aihubmix')
const isSupportVerbosity = isSupportVerbosityModel(model)
(provider.type === 'openai-response' || model.endpoint_type === 'openai-response' || provider.id === 'aihubmix')
const showVerbositySetting = isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider)
const isSupportFlexServiceTier = isSupportFlexServiceTierModel(model)
const isSupportServiceTier = isSupportServiceTierProvider(provider)
const isSupportedFlexServiceTier = isSupportFlexServiceTierModel(model)
const showServiceTierSetting = isSupportServiceTier && providerId !== SystemProviderIds.groq
const setSummaryText = useCallback(
(value: OpenAISummaryText) => {
@@ -82,8 +81,8 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
const summaryTextOptions = [
{
value: undefined,
label: t('common.default')
value: 'undefined',
label: t('common.ignore')
},
{
value: 'auto',
@@ -102,8 +101,8 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
const verbosityOptions = useMemo(() => {
const allOptions = [
{
value: undefined,
label: t('common.default')
value: 'undefined',
label: t('common.ignore')
},
{
value: 'low',
@@ -118,73 +117,44 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
label: t('settings.openai.verbosity.high')
}
] as const satisfies VerbosityOption[]
const supportedVerbosityLevels = getModelSupportedVerbosity(model)
const supportedVerbosityLevels = getModelSupportedVerbosity(model).map((v) => toOptionValue(v))
return allOptions.filter((option) => supportedVerbosityLevels.includes(option.value))
}, [model, t])
const serviceTierOptions = useMemo(() => {
let options: ServiceTierOptions
if (provider.id === SystemProviderIds.groq) {
options = [
{
value: null,
label: t('common.off')
},
{
value: undefined,
label: t('common.default')
},
{
value: 'auto',
label: t('settings.openai.service_tier.auto')
},
{
value: 'on_demand',
label: t('settings.openai.service_tier.on_demand')
},
{
value: 'flex',
label: t('settings.openai.service_tier.flex')
}
] as const satisfies GroqServiceTierOption[]
} else {
// 其他情况默认是和 OpenAI 相同
options = [
{
value: 'auto',
label: t('settings.openai.service_tier.auto')
},
{
value: 'default',
label: t('settings.openai.service_tier.default')
},
{
value: 'flex',
label: t('settings.openai.service_tier.flex')
},
{
value: 'priority',
label: t('settings.openai.service_tier.priority')
}
] as const satisfies OpenAIServiceTierOption[]
}
const options = [
{
value: 'undefined',
label: t('common.ignore')
},
{
value: 'null',
label: t('common.off')
},
{
value: 'auto',
label: t('settings.openai.service_tier.auto')
},
{
value: 'default',
label: t('settings.openai.service_tier.default')
},
{
value: 'flex',
label: t('settings.openai.service_tier.flex')
},
{
value: 'priority',
label: t('settings.openai.service_tier.priority')
}
] as const satisfies OpenAIServiceTierOption[]
return options.filter((option) => {
if (option.value === 'flex') {
return isSupportedFlexServiceTier
return isSupportFlexServiceTier
}
return true
})
}, [isSupportedFlexServiceTier, provider.id, t])
useEffect(() => {
if (serviceTierMode && !serviceTierOptions.some((option) => option.value === serviceTierMode)) {
if (provider.id === SystemProviderIds.groq) {
setServiceTierMode(GroqServiceTiers.on_demand)
} else {
setServiceTierMode(OpenAIServiceTiers.auto)
}
}
}, [provider.id, serviceTierMode, serviceTierOptions, setServiceTierMode])
}, [isSupportFlexServiceTier, t])
useEffect(() => {
if (verbosity && !verbosityOptions.some((option) => option.value === verbosity)) {
@@ -195,14 +165,14 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
}
}, [model, verbosity, verbosityOptions, setVerbosity])
if (!isOpenAIReasoning && !isSupportServiceTier && !isSupportVerbosity) {
if (!showSummarySetting && !showServiceTierSetting && !showVerbositySetting) {
return null
}
return (
<CollapsibleSettingGroup title={t('settings.openai.title')} defaultExpanded={true}>
<SettingGroup>
{isSupportServiceTier && (
{showServiceTierSetting && (
<>
<SettingRow>
<SettingRowTitleSmall>
@@ -210,18 +180,17 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
<HelpTooltip content={t('settings.openai.service_tier.tip')} />
</SettingRowTitleSmall>
<Selector
value={serviceTierMode}
value={toOptionValue(serviceTierMode)}
onChange={(value) => {
setServiceTierMode(value as OpenAIServiceTier)
setServiceTierMode(toRealValue(value))
}}
options={serviceTierOptions}
placeholder={t('settings.openai.service_tier.auto')}
/>
</SettingRow>
{(isOpenAIReasoning || isSupportVerbosity) && <SettingDivider />}
{(showSummarySetting || showVerbositySetting) && <SettingDivider />}
</>
)}
{isOpenAIReasoning && (
{showSummarySetting && (
<>
<SettingRow>
<SettingRowTitleSmall>
@@ -236,10 +205,10 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
options={summaryTextOptions}
/>
</SettingRow>
{isSupportVerbosity && <SettingDivider />}
{showVerbositySetting && <SettingDivider />}
</>
)}
{isSupportVerbosity && (
{showVerbositySetting && (
<SettingRow>
<SettingRowTitleSmall>
{t('settings.openai.verbosity.title')} <HelpTooltip content={t('settings.openai.verbosity.tip')} />
@@ -10,6 +10,8 @@ import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('QuotaTag')
const QUOTA_UNLIMITED = -9999
const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quota?: number }> = ({
base,
providerId,
@@ -24,8 +26,8 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quot
if (provider.id !== 'mineru') return
// 使用用户的key时quota为无限
if (provider.apiKey) {
setQuota(-9999)
updateProvider({ quota: -9999 })
setQuota(QUOTA_UNLIMITED)
updateProvider({ quota: QUOTA_UNLIMITED })
return
}
if (quota === undefined) {
@@ -43,28 +45,37 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quot
}
}
if (_quota !== undefined) {
setQuota(_quota)
updateProvider({ quota: _quota })
return
}
checkQuota()
}, [_quota, base, provider.id, provider.apiKey, provider, quota, updateProvider])
return (
<>
{quota && (
const getQuotaDisplay = () => {
if (quota === undefined) return null
if (quota === QUOTA_UNLIMITED) {
return (
<Tag color="orange" style={{ borderRadius: 20, margin: 0 }}>
{quota === -9999
? t('knowledge.quota_infinity', {
name: provider.name
})
: t('knowledge.quota', {
name: provider.name,
quota: quota
})}
{t('knowledge.quota_infinity', { name: provider.name })}
</Tag>
)}
</>
)
)
}
if (quota === 0) {
return (
<Tag color="red" style={{ borderRadius: 20, margin: 0 }}>
{t('knowledge.quota_empty', { name: provider.name })}
</Tag>
)
}
return (
<Tag color="orange" style={{ borderRadius: 20, margin: 0 }}>
{t('knowledge.quota', { name: provider.name, quota: quota })}
</Tag>
)
}
return <>{getQuotaDisplay()}</>
}
export default QuotaTag
@@ -89,7 +89,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
const getNewPainting = useCallback(() => {
return {
...DEFAULT_PAINTING,
model: mode === 'aihubmix_image_generate' ? 'gpt-image-1' : 'V_3',
model: mode === 'aihubmix_image_generate' ? 'gemini-3-pro-image-preview' : 'V_3',
id: uuid()
}
}, [mode])
@@ -197,6 +197,74 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) })
}
return
} else if (painting.model === 'gemini-3-pro-image-preview') {
const geminiUrl = `${aihubmixProvider.apiHost}/gemini/v1beta/models/gemini-3-pro-image-preview:streamGenerateContent`
const geminiHeaders = {
'Content-Type': 'application/json',
'x-goog-api-key': aihubmixProvider.apiKey
}
const requestBody = {
contents: [
{
parts: [
{
text: prompt
}
],
role: 'user'
}
],
generationConfig: {
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: {
aspectRatio: painting.aspectRatio?.replace('ASPECT_', '').replace('_', ':') || '1:1',
imageSize: painting.imageSize || '1k'
}
}
}
logger.silly(`Gemini Request: ${JSON.stringify(requestBody)}`)
const response = await fetch(geminiUrl, {
method: 'POST',
headers: geminiHeaders,
body: JSON.stringify(requestBody)
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Gemini API Error:', errorData)
throw new Error(errorData.error?.message || '生成图像失败')
}
const data = await response.json()
logger.silly(`Gemini API Response: ${JSON.stringify(data)}`)
// Handle array response (stream) or single object
const responseItems = Array.isArray(data) ? data : [data]
const base64s: string[] = []
responseItems.forEach((item) => {
item.candidates?.forEach((candidate: any) => {
candidate.content?.parts?.forEach((part: any) => {
if (part.inlineData?.data) {
base64s.push(part.inlineData.data)
}
})
})
})
if (base64s.length > 0) {
const validFiles = await Promise.all(
base64s.map(async (base64: string) => {
return await window.api.file.saveBase64Image(base64)
})
)
await FileManager.addFiles(validFiles)
updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) })
}
return
} else if (painting.model === 'V_3') {
// V3 API uses different endpoint and parameters format
const formData = new FormData()
@@ -72,6 +72,7 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
label: 'Gemini',
title: 'Gemini',
options: [
{ label: 'Nano Banana Pro', value: 'gemini-3-pro-image-preview' },
{ label: 'imagen-4.0-preview', value: 'imagen-4.0-generate-preview-06-06' },
{ label: 'imagen-4.0-ultra', value: 'imagen-4.0-ultra-generate-preview-06-06' }
]
@@ -224,7 +225,20 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
{ label: '16:9', value: 'ASPECT_16_9' }
],
initialValue: 'ASPECT_1_1',
condition: (painting) => Boolean(painting.model?.startsWith('imagen-'))
condition: (painting) =>
Boolean(painting.model?.startsWith('imagen-') || painting.model === 'gemini-3-pro-image-preview')
},
{
type: 'select',
key: 'imageSize',
title: 'paintings.image.size',
options: [
{ label: '1K', value: '1K' },
{ label: '2K', value: '2K' },
{ label: '4K', value: '4K' }
],
initialValue: '1K',
condition: (painting) => painting.model === 'gemini-3-pro-image-preview'
},
{
type: 'select',
@@ -398,7 +412,7 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
// 几种默认的绘画配置
export const DEFAULT_PAINTING: PaintingAction = {
id: 'aihubmix_1',
model: 'gpt-image-1',
model: 'gemini-3-pro-image-preview',
aspectRatio: 'ASPECT_1_1',
numImages: 1,
styleType: 'AUTO',
@@ -420,5 +434,6 @@ export const DEFAULT_PAINTING: PaintingAction = {
moderation: 'auto',
n: 1,
numberOfImages: 4,
safetyTolerance: 6
safetyTolerance: 6,
imageSize: '1K'
}
@@ -76,6 +76,17 @@ const ApiOptionsSettings = ({ providerId }: Props) => {
})
},
checked: !provider.apiOptions?.isNotSupportEnableThinking
},
{
key: 'openai_verbosity',
label: t('settings.provider.api.options.verbosity.label'),
tip: t('settings.provider.api.options.verbosity.help'),
onChange: (checked: boolean) => {
updateProviderTransition({
apiOptions: { ...provider.apiOptions, isNotSupportVerbosity: !checked }
})
},
checked: !provider.apiOptions?.isNotSupportVerbosity
}
],
[t, provider, updateProviderTransition]
@@ -1,7 +1,7 @@
import { Flex } from '@cherrystudio/ui'
import { Button } from '@cherrystudio/ui'
import { TopView } from '@renderer/components/TopView'
import { isNotSupportedTextDelta } from '@renderer/config/models'
import { isNotSupportTextDeltaModel } from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider'
import type { Model, Provider } from '@renderer/types'
import { getDefaultGroupName } from '@renderer/utils'
@@ -60,7 +60,7 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
group: values.group ?? getDefaultGroupName(id)
}
addModel({ ...model, supported_text_delta: !isNotSupportedTextDelta(model) })
addModel({ ...model, supported_text_delta: !isNotSupportTextDeltaModel(model) })
return true
}
@@ -6,7 +6,7 @@ import {
groupQwenModels,
isEmbeddingModel,
isFunctionCallingModel,
isNotSupportedTextDelta,
isNotSupportTextDeltaModel,
isReasoningModel,
isRerankModel,
isVisionModel,
@@ -136,13 +136,13 @@ const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
addModel({
...model,
endpoint_type: endpointTypes.includes('image-generation') ? 'image-generation' : endpointTypes[0],
supported_text_delta: !isNotSupportedTextDelta(model)
supported_text_delta: !isNotSupportTextDeltaModel(model)
})
} else {
NewApiAddModelPopup.show({ title: t('settings.models.add.add_model'), provider, model })
}
} else {
addModel({ ...model, supported_text_delta: !isNotSupportedTextDelta(model) })
addModel({ ...model, supported_text_delta: !isNotSupportTextDeltaModel(model) })
}
}
},
@@ -2,7 +2,7 @@ import { Flex } from '@cherrystudio/ui'
import { Button } from '@cherrystudio/ui'
import { TopView } from '@renderer/components/TopView'
import { endpointTypeOptions } from '@renderer/config/endpointTypes'
import { isNotSupportedTextDelta } from '@renderer/config/models'
import { isNotSupportTextDeltaModel } from '@renderer/config/models'
import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth'
import { useProvider } from '@renderer/hooks/useProvider'
import type { EndpointType, Model, Provider } from '@renderer/types'
@@ -67,7 +67,7 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve, model, endp
endpoint_type: isNewApiProvider(provider) ? values.endpointType : undefined
}
addModel({ ...model, supported_text_delta: !isNotSupportedTextDelta(model) })
addModel({ ...model, supported_text_delta: !isNotSupportTextDeltaModel(model) })
return true
}
@@ -2,7 +2,7 @@ import { Flex } from '@cherrystudio/ui'
import { Button } from '@cherrystudio/ui'
import { TopView } from '@renderer/components/TopView'
import { endpointTypeOptions } from '@renderer/config/endpointTypes'
import { isNotSupportedTextDelta } from '@renderer/config/models'
import { isNotSupportTextDeltaModel } from '@renderer/config/models'
import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth'
import { useProvider } from '@renderer/hooks/useProvider'
import type { EndpointType, Model, Provider } from '@renderer/types'
@@ -50,7 +50,7 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve, batchModels
addModel({
...model,
endpoint_type: values.endpointType,
supported_text_delta: !isNotSupportedTextDelta(model)
supported_text_delta: !isNotSupportTextDeltaModel(model)
})
})
return true
@@ -82,7 +82,10 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [
SystemProviderIds.grok,
SystemProviderIds.cherryin,
SystemProviderIds.longcat,
SystemProviderIds.minimax
SystemProviderIds.minimax,
SystemProviderIds.silicon,
SystemProviderIds.qiniu,
SystemProviderIds.dmxapi
] as const
type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number]
@@ -295,7 +298,10 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
}
if (isAnthropicProvider(provider)) {
return formatApiHost(apiHost) + '/messages'
// AI SDK uses the baseURL with /v1, then appends /messages
// formatApiHost adds /v1 automatically if not present
const normalizedHost = formatApiHost(apiHost)
return normalizedHost + '/messages'
}
if (isGeminiProvider(provider)) {
@@ -349,6 +355,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const anthropicHostPreview = useMemo(() => {
const rawHost = anthropicApiHost ?? provider.anthropicApiHost
// AI SDK uses the baseURL with /v1, then appends /messages
const normalizedHost = formatApiHost(rawHost)
return `${normalizedHost}/messages`
+38 -61
View File
@@ -3,8 +3,6 @@
*/
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import AiProvider from '@renderer/aiCore'
import type { CompletionsParams } from '@renderer/aiCore/legacy/middleware/schemas'
import type { AiSdkMiddlewareConfig } from '@renderer/aiCore/middleware/AiSdkMiddlewareBuilder'
import { buildStreamTextParams } from '@renderer/aiCore/prepareParams'
import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingModel } from '@renderer/config/models'
@@ -463,76 +461,55 @@ export function checkApiProvider(provider: Provider): void {
export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise<void> {
checkApiProvider(provider)
// Don't pass in provider parameter. We need auto-format URL
const ai = new AiProviderNew(model)
const assistant = getDefaultAssistant()
assistant.model = model
assistant.prompt = 'test' // 避免部分 provider 空系统提示词会报错
try {
if (isEmbeddingModel(model)) {
// race 超时 15s
logger.silly("it's a embedding model")
const timerPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout))
await Promise.race([ai.getEmbeddingDimensions(model), timerPromise])
} else {
const abortId = uuid()
const signal = readyToAbort(abortId)
let chunkError
const params: StreamTextParams = {
system: assistant.prompt,
prompt: 'hi',
abortSignal: signal
}
const config: ModernAiProviderConfig = {
streamOutput: true,
enableReasoning: false,
isSupportedToolUse: false,
isImageGenerationEndpoint: false,
enableWebSearch: false,
enableGenerateImage: false,
isPromptToolUse: false,
enableUrlContext: false,
assistant,
callType: 'check',
onChunk: (chunk: Chunk) => {
if (chunk.type === ChunkType.ERROR) {
chunkError = chunk.error
} else {
abortCompletion(abortId)
}
}
}
// Try streaming check first
try {
await ai.completions(model.id, params, config)
} catch (e) {
if (!isAbortError(e) && !isAbortError(chunkError)) {
throw e
if (isEmbeddingModel(model)) {
// race 超时 15s
logger.silly("it's a embedding model")
const timerPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout))
await Promise.race([ai.getEmbeddingDimensions(model), timerPromise])
} else {
const abortId = uuid()
const signal = readyToAbort(abortId)
let chunkError
const params: StreamTextParams = {
system: assistant.prompt,
prompt: 'hi',
abortSignal: signal
}
const config: ModernAiProviderConfig = {
streamOutput: true,
enableReasoning: false,
isSupportedToolUse: false,
isImageGenerationEndpoint: false,
enableWebSearch: false,
enableGenerateImage: false,
isPromptToolUse: false,
enableUrlContext: false,
assistant,
callType: 'check',
onChunk: (chunk: Chunk) => {
if (chunk.type === ChunkType.ERROR) {
chunkError = chunk.error
} else {
abortCompletion(abortId)
}
}
}
} catch (error: any) {
// 失败回退legacy
const legacyAi = new AiProvider(provider)
if (error.message.includes('stream')) {
const params: CompletionsParams = {
callType: 'check',
messages: 'hi',
assistant,
streamOutput: false,
shouldThrow: true
// Try streaming check
try {
await ai.completions(model.id, params, config)
} catch (e) {
if (!isAbortError(e) && !isAbortError(chunkError)) {
throw e
}
const result = await legacyAi.completions(params)
if (!result.getText()) {
throw new Error('No response received')
}
} else {
throw error
}
// } finally {
// removeAbortController(taskId, abortFn)
// }
}
}
+4 -5
View File
@@ -202,11 +202,10 @@ const assistantsSlice = createSlice({
},
updateAssistantPreset: (state, action: PayloadAction<AssistantPreset>) => {
const preset = action.payload
state.presets.forEach((a) => {
if (a.id === preset.id) {
a = preset
}
})
const index = state.presets.findIndex((a) => a.id === preset.id)
if (index !== -1) {
state.presets[index] = preset
}
},
updateAssistantPresetSettings: (
state,
+1 -1
View File
@@ -71,7 +71,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 177,
version: 179,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},
+37 -2
View File
@@ -8,7 +8,7 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import {
glm45FlashModel,
isFunctionCallingModel,
isNotSupportedTextDelta,
isNotSupportTextDeltaModel,
SYSTEM_MODELS
} from '@renderer/config/models'
import { BUILTIN_OCR_PROVIDERS, BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
@@ -1993,7 +1993,7 @@ const migrateConfig = {
const updateModelTextDelta = (model?: Model) => {
if (model) {
model.supported_text_delta = true
if (isNotSupportedTextDelta(model)) {
if (isNotSupportTextDeltaModel(model)) {
model.supported_text_delta = false
}
}
@@ -2876,6 +2876,41 @@ const migrateConfig = {
logger.error('migrate 177 error', error as Error)
return state
}
},
'178': (state: RootState) => {
try {
const groq = state.llm.providers.find((p) => p.id === SystemProviderIds.groq)
if (groq) {
groq.verbosity = undefined
}
logger.info('migrate 178 success')
return state
} catch (error) {
logger.error('migrate 178 error', error as Error)
return state
}
},
'179': (state: RootState) => {
try {
state.llm.providers.forEach((provider) => {
switch (provider.id) {
case SystemProviderIds.silicon:
provider.anthropicApiHost = 'https://api.siliconflow.cn'
break
case SystemProviderIds.qiniu:
provider.anthropicApiHost = 'https://api.qnaigc.com'
break
case SystemProviderIds.dmxapi:
provider.anthropicApiHost = provider.apiHost
break
}
})
logger.info('migrate 179 success')
return state
} catch (error) {
logger.error('migrate 179 error', error as Error)
return state
}
}
}
+1 -1
View File
@@ -383,7 +383,7 @@ export const initialState: SettingsState = {
openAI: {
summaryText: 'auto',
serviceTier: 'auto',
verbosity: 'medium'
verbosity: undefined
},
notification: {
assistant: false,
+18
View File
@@ -2,6 +2,7 @@ import type OpenAI from '@cherrystudio/openai'
import type { NotNull, NotUndefined } from '@types'
import type { ImageModel, LanguageModel } from 'ai'
import type { generateObject, generateText, ModelMessage, streamObject, streamText } from 'ai'
import * as z from 'zod'
export type StreamTextParams = Omit<Parameters<typeof streamText>[0], 'model' | 'messages'> &
(
@@ -42,3 +43,20 @@ export type OpenAIReasoningEffort = OpenAI.ReasoningEffort
// 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.
export type OpenAISummaryText = NotNull<OpenAI.Reasoning['summary']>
const AiSdkParamsSchema = z.enum([
'maxOutputTokens',
'temperature',
'topP',
'topK',
'presencePenalty',
'frequencyPenalty',
'stopSequences',
'seed'
])
export type AiSdkParam = z.infer<typeof AiSdkParamsSchema>
export const isAiSdkParam = (param: string): param is AiSdkParam => {
return AiSdkParamsSchema.safeParse(param).success
}
+1
View File
@@ -320,6 +320,7 @@ export interface GeneratePainting extends PaintingParams {
safetyTolerance?: number
width?: number
height?: number
imageSize?: string
}
export interface EditPainting extends PaintingParams {
+12 -9
View File
@@ -20,28 +20,30 @@ export const ProviderTypeSchema = z.enum([
export type ProviderType = z.infer<typeof ProviderTypeSchema>
// undefined 视为支持,默认支持
// undefined is treated as supported, enabled by default
export type ProviderApiOptions = {
/** 是否不支持 message content 为数组类型 */
/** Whether message content of array type is not supported */
isNotSupportArrayContent?: boolean
/** 是否不支持 stream_options 参数 */
/** Whether the stream_options parameter is not supported */
isNotSupportStreamOptions?: boolean
/**
* @deprecated
* message role developer */
* Whether message role 'developer' is not supported */
isNotSupportDeveloperRole?: boolean
/* 是否支持 message role developer */
/* Whether message role 'developer' is supported */
isSupportDeveloperRole?: boolean
/**
* @deprecated
* service_tier . Only for OpenAI Models. */
* Whether the service_tier parameter is not supported. Only for OpenAI Models. */
isNotSupportServiceTier?: boolean
/* 是否支持 service_tier 参数. Only for OpenAI Models. */
/* Whether the service_tier parameter is supported. Only for OpenAI Models. */
isSupportServiceTier?: boolean
/** 是否不支持 enable_thinking 参数 */
/** Whether the enable_thinking parameter is not supported */
isNotSupportEnableThinking?: boolean
/** 是否不支持 APIVersion */
/** Whether APIVersion is not supported */
isNotSupportAPIVersion?: boolean
/** Whether verbosity is not supported. For OpenAI API (completions & responses). */
isNotSupportVerbosity?: boolean
}
// scale is not well supported now. It even lacks of docs
@@ -61,6 +63,7 @@ export function isOpenAIServiceTier(tier: string | null | undefined): tier is Op
}
// https://console.groq.com/docs/api-reference#responses
// null is not used.
export type GroqServiceTier = 'auto' | 'on_demand' | 'flex' | undefined | null
export const GroqServiceTiers = {
+2
View File
@@ -96,6 +96,8 @@ export type ReasoningEffortOptionalParams = {
include_thoughts?: boolean
}
}
thinking_budget?: number
reasoning_effort?: OpenAI.Chat.Completions.ChatCompletionCreateParams['reasoning_effort'] | 'auto'
}
disable_reasoning?: boolean
// Add any other potential reasoning-related keys here if they exist
@@ -19,7 +19,8 @@ import {
isSupportEnableThinkingProvider,
isSupportServiceTierProvider,
isSupportStreamOptionsProvider,
isSupportUrlContextProvider
isSupportUrlContextProvider,
isSupportVerbosityProvider
} from '../provider'
vi.mock('@renderer/store/settings', () => ({
@@ -104,6 +105,35 @@ describe('provider utils', () => {
expect(isSupportServiceTierProvider(createSystemProvider({ id: SystemProviderIds.github }))).toBe(false)
})
it('determines verbosity support', () => {
// Custom providers with explicit flag
expect(isSupportVerbosityProvider(createProvider({ apiOptions: { isNotSupportVerbosity: false } }))).toBe(true)
expect(isSupportVerbosityProvider(createProvider({ apiOptions: { isNotSupportVerbosity: true } }))).toBe(false)
// Custom providers without apiOptions (should support by default)
expect(isSupportVerbosityProvider(createProvider())).toBe(true)
expect(isSupportVerbosityProvider(createProvider({ apiOptions: {} }))).toBe(true)
// System providers that support verbosity (default behavior)
expect(isSupportVerbosityProvider(createSystemProvider())).toBe(true)
expect(isSupportVerbosityProvider(createSystemProvider({ id: SystemProviderIds.openai }))).toBe(true)
// System providers in the NOT_SUPPORT_VERBOSITY_PROVIDERS list (cannot be overridden by apiOptions)
expect(isSupportVerbosityProvider(createSystemProvider({ id: SystemProviderIds.groq }))).toBe(false)
expect(
isSupportVerbosityProvider(
createSystemProvider({ id: SystemProviderIds.groq, apiOptions: { isNotSupportVerbosity: false } })
)
).toBe(false)
// apiOptions can disable verbosity for any provider
expect(
isSupportVerbosityProvider(
createSystemProvider({ id: SystemProviderIds.openai, apiOptions: { isNotSupportVerbosity: true } })
)
).toBe(false)
})
it('detects URL context capable providers', () => {
expect(isSupportUrlContextProvider(createProvider({ type: 'gemini' }))).toBe(true)
expect(
+15
View File
@@ -83,6 +83,21 @@ export const isSupportServiceTierProvider = (provider: Provider) => {
)
}
const NOT_SUPPORT_VERBOSITY_PROVIDERS = ['groq'] as const satisfies SystemProviderId[]
/**
* Determines whether the provider supports the verbosity option.
* Only applies to system providers that are not in the exclusion list.
* @param provider - The provider to check
* @returns true if the provider supports verbosity, false otherwise
*/
export const isSupportVerbosityProvider = (provider: Provider) => {
return (
provider.apiOptions?.isNotSupportVerbosity !== true &&
!NOT_SUPPORT_VERBOSITY_PROVIDERS.some((pid) => pid === provider.id)
)
}
const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
'gemini',
'vertexai',
+36
View File
@@ -0,0 +1,36 @@
/**
* Convert a value (string | undefined | null) into an option-compatible string.
* - `undefined` becomes the literal string `'undefined'`
* - `null` becomes the literal string `'null'`
* - Any other string is returned as-is
*
* @param v - The value to convert
* @returns The string representation safe for option usage
*/
export function toOptionValue<T extends undefined | Exclude<string, null>>(v: T): NonNullable<T> | 'undefined'
export function toOptionValue<T extends null | Exclude<string, undefined>>(v: T): NonNullable<T> | 'null'
export function toOptionValue<T extends string | undefined | null>(v: T): NonNullable<T> | 'undefined' | 'null'
export function toOptionValue<T extends Exclude<string, null | undefined>>(v: T): T
export function toOptionValue(v: string | undefined | null) {
if (v === undefined) return 'undefined'
if (v === null) return 'null'
return v
}
/**
* Convert an option string back to its original value.
* - The literal string `'undefined'` becomes `undefined`
* - The literal string `'null'` becomes `null`
* - Any other string is returned as-is
*
* @param v - The option string to convert
* @returns The real value (`undefined`, `null`, or the original string)
*/
export function toRealValue<T extends 'undefined'>(v: T): undefined
export function toRealValue<T extends 'null'>(v: T): null
export function toRealValue<T extends string>(v: T): Exclude<T, 'undefined' | 'null'>
export function toRealValue(v: string) {
if (v === 'undefined') return undefined
if (v === 'null') return null
return v
}