Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ vi.mock('@renderer/config/models', async (importOriginal) => {
isGrokReasoningModel: vi.fn(() => false),
isOpenAIReasoningModel: vi.fn(() => false),
isQwenAlwaysThinkModel: vi.fn(() => false),
isHostedGemma4ThinkingModel: vi.fn(() => false),
isSupportedThinkingTokenHunyuanModel: vi.fn(() => false),
isSupportedThinkingTokenModel: vi.fn(() => false),
isGPT51SeriesModel: vi.fn(() => false),
Expand Down Expand Up @@ -1566,6 +1567,58 @@ describe('reasoning utils', () => {
})
})

it('should map hosted Gemma 4 minimal effort to minimal thinkingLevel without thoughts', () => {
vi.mocked(mockModels.isReasoningModel).mockReturnValue(true)
vi.mocked(mockModels.isSupportedThinkingTokenGeminiModel).mockReturnValue(true)
vi.mocked(mockModels.isHostedGemma4ThinkingModel).mockReturnValue(true)

const model: Model = {
id: 'gemma-4-31b-it',
name: 'Gemma 4 31B',
provider: SystemProviderIds.gemini
} as Model

const assistant: Assistant = {
id: 'test',
name: 'Test',
settings: { reasoning_effort: 'minimal' }
} as Assistant

const result = getGeminiReasoningParams(assistant, model)
expect(result).toEqual({
thinkingConfig: {
includeThoughts: false,
thinkingLevel: 'minimal'
}
})
})

it('should map hosted Gemma 4 high effort to high thinkingLevel with thoughts', () => {
vi.mocked(mockModels.isReasoningModel).mockReturnValue(true)
vi.mocked(mockModels.isSupportedThinkingTokenGeminiModel).mockReturnValue(true)
vi.mocked(mockModels.isHostedGemma4ThinkingModel).mockReturnValue(true)

const model: Model = {
id: 'gemma-4-31b-it',
name: 'Gemma 4 31B',
provider: SystemProviderIds.gemini
} as Model

const assistant: Assistant = {
id: 'test',
name: 'Test',
settings: { reasoning_effort: 'high' }
} as Assistant

const result = getGeminiReasoningParams(assistant, model)
expect(result).toEqual({
thinkingConfig: {
includeThoughts: true,
thinkingLevel: 'high'
}
})
})

it('should enable thinking with budget for reasoning effort', () => {
vi.mocked(mockModels.isReasoningModel).mockReturnValue(true)
vi.mocked(mockModels.isSupportedThinkingTokenGeminiModel).mockReturnValue(true)
Expand Down
16 changes: 16 additions & 0 deletions src/renderer/src/aiCore/utils/reasoning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isDoubaoThinkingAutoModel,
isGemini3ThinkingTokenModel,
isGrok4FastReasoningModel,
isHostedGemma4ThinkingModel,
isOpenAIDeepResearchModel,
isOpenAIModel,
isOpenAIOpenWeightModel,
Expand Down Expand Up @@ -860,6 +861,21 @@ export function getGeminiReasoningParams(
let thinkingLevel: GoogleThinkingLevel | null = null
const includeThoughts = reasoningEffort !== 'none'

if (isHostedGemma4ThinkingModel(model)) {
// Hosted Gemma 4 does not expose a distinct hard-off mode on the Gemini API.
// We only surface minimal/high in the UI and collapse legacy or unexpected
// `none` inputs to `minimal` for compatibility.
const isHighThinking = reasoningEffort === 'high' || reasoningEffort === 'xhigh'
thinkingLevel = isHighThinking ? 'high' : 'minimal'

return {
thinkingConfig: {
includeThoughts: isHighThinking,
thinkingLevel
}
}
}

// https://ai.google.dev/gemini-api/docs/gemini-3?thinking=high#new_api_features_in_gemini_3
if (isGemini3ThinkingTokenModel(model)) {
thinkingLevel = mapToGeminiThinkingLevel(reasoningEffort)
Expand Down
39 changes: 39 additions & 0 deletions src/renderer/src/config/models/__tests__/reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,11 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
expect(getThinkModelType(createModel({ id: 'gemini-3.1-pro-preview' }))).toBe('gemini3_1_pro')
expect(getThinkModelType(createModel({ id: 'gemini-pro-latest' }))).toBe('gemini3_1_pro')
})

it('should return gemma4_hosted for hosted Gemma 4 models on Gemini provider', () => {
expect(getThinkModelType(createModel({ id: 'gemma-4-31b-it', provider: 'gemini' }))).toBe('gemma4_hosted')
expect(getThinkModelType(createModel({ id: 'google/gemma-4-e2b-it', provider: 'gemini' }))).toBe('gemma4_hosted')
})
})

describe('Qwen models', () => {
Expand Down Expand Up @@ -1403,6 +1408,21 @@ describe('Gemini Models', () => {
})
).toBe(false)
})

it('should return true for hosted gemma 4 models on Gemini provider', () => {
expect(isSupportedThinkingTokenGeminiModel(createModel({ id: 'gemma-4-31b-it', provider: 'gemini' }))).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel(createModel({ id: 'google/gemma-4-e2b-it', provider: 'gemini' }))
).toBe(true)
})

it('should keep non-Gemini Gemma 4 ids out of Gemini thinking token detection', () => {
expect(isSupportedThinkingTokenGeminiModel(createModel({ id: 'gemma-4-31b-it', provider: 'openrouter' }))).toBe(
false
)
expect(isSupportedThinkingTokenGeminiModel(createModel({ id: 'gemma4:31b' }))).toBe(false)
expect(isSupportedThinkingTokenGeminiModel(createModel({ id: 'gemma4:e2b' }))).toBe(false)
})
})

describe('isGeminiReasoningModel', () => {
Expand Down Expand Up @@ -2184,6 +2204,12 @@ describe('getModelSupportedReasoningEffortOptions', () => {
'high'
])
})

it('should return minimal/high options for hosted Gemma 4 on Gemini provider', () => {
expect(
getModelSupportedReasoningEffortOptions(createModel({ id: 'gemma-4-31b-it', provider: 'gemini' }))
).toEqual(['default', 'minimal', 'high'])
})
})

describe('Qwen models', () => {
Expand Down Expand Up @@ -3057,4 +3083,17 @@ describe('Gemma 4 Models', () => {
expect(findTokenLimit('gemma-3-27b')).toBeUndefined()
})
})

describe('thinking controls', () => {
it('treats hosted Gemma 4 as configurable minimal/high reasoning instead of fixed reasoning', () => {
const model = createModel({
id: 'gemma-4-31b-it',
provider: 'gemini'
})

expect(isFixedReasoningModel(model)).toBe(false)
expect(getThinkModelType(model)).toBe('gemma4_hosted')
expect(getModelSupportedReasoningEffortOptions(model)).toEqual(['default', 'minimal', 'high'])
})
})
})
22 changes: 21 additions & 1 deletion src/renderer/src/config/models/reasoning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export const MODEL_SUPPORTED_REASONING_EFFORT = {
gemini3_flash: ['minimal', 'low', 'medium', 'high'] as const,
gemini3_pro: ['low', 'high'] as const,
gemini3_1_pro: ['low', 'medium', 'high'] as const,
// Google-hosted Gemma 4 documents `minimal` as the closest supported near-off
// setting for most requests, but does not guarantee thinking is fully disabled.
// Keep the formal UI options aligned with the API guarantee and omit `none`.
gemma4_hosted: ['minimal', 'high'] as const,
Comment thread
ousugo marked this conversation as resolved.
qwen: ['low', 'medium', 'high'] as const,
qwen_thinking: ['low', 'medium', 'high'] as const,
doubao: ['auto', 'high'] as const,
Expand Down Expand Up @@ -111,6 +115,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
gemini3_flash: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini3_flash] as const,
gemini3_pro: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini3_pro] as const,
gemini3_1_pro: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini3_1_pro] as const,
gemma4_hosted: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemma4_hosted] as const,
qwen: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
qwen_thinking: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking] as const,
doubao: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
Expand Down Expand Up @@ -177,7 +182,9 @@ const _getThinkModelType = (model: Model): ThinkingModelType => {
} else if (isGrok4FastReasoningModel(model)) {
thinkingModelType = 'grok4_fast'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
if (isGemini3FlashModel(model) || isGemini31FlashLiteModel(model)) {
if (isHostedGemma4ThinkingModel(model)) {
thinkingModelType = 'gemma4_hosted'
} else if (isGemini3FlashModel(model) || isGemini31FlashLiteModel(model)) {
thinkingModelType = 'gemini3_flash'
} else if (isGemini3ProModel(model)) {
thinkingModelType = 'gemini3_pro'
Expand Down Expand Up @@ -415,8 +422,21 @@ export function isGeminiReasoningModel(model?: Model): boolean {
export const GEMINI_THINKING_MODEL_REGEX =
/gemini-(?:2\.5.*(?:-latest)?|3(?:\.\d+)?-(?:flash|pro)(?:-preview)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i

export const isHostedGemma4ThinkingModel = (model?: Model): boolean => {
if (!model) {
return false
}

const modelId = getLowerBaseModelName(model.id, '/')
return model.provider?.toLowerCase() === 'gemini' && modelId.startsWith('gemma-4-')
Comment thread
ousugo marked this conversation as resolved.
}

export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
if (isHostedGemma4ThinkingModel(model)) {
return true
}

if (GEMINI_THINKING_MODEL_REGEX.test(modelId)) {
// ref: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image
if (modelId.includes('gemini-3-pro-image')) {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const ThinkModelTypes = [
'gemini3_flash',
'gemini3_pro',
'gemini3_1_pro',
'gemma4_hosted',
'qwen',
'qwen_thinking',
'doubao',
Expand Down
Loading