Skip to content
Open
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
11 changes: 11 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2820,10 +2820,21 @@
"google": {
"aboutModels": "More about Google models",
"api": "Google API",
"defaultThinkingBudget": "Default thinking budget",
"defaultThinkingBudgetExplain": "Applied when a request does not set its own thinking budget. Empty or -1 lets Gemini choose automatically, 0 disables thinking, and any value above 0 sets the thinking token limit.",
"defaultThinkingBudgetHint": "Empty or -1 = automatic, 0 = off, above 0 = thinking token limit",
"gemini": "Gemini",
"location": "Location",
"pricing": "Google pricing",
"project": "Project ID",
"safetyBlockFew": "Block few (high probability only)",
"safetyBlockMost": "Block most (low probability and above)",
"safetyBlockNone": "Block none",
"safetyBlockSome": "Block some (medium probability and above)",
"safetyDefault": "Use model defaults",
"safetyOff": "Turn off safety filter",
"safetySettings": "Safety filter",
"safetySettingsHint": "Applied to harassment, hate speech, explicit content, and dangerous content",
"vertexai": "Vertex AI"
},
"groq": {
Expand Down
28 changes: 24 additions & 4 deletions src/renderer/services/llms/google.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GenerateContentConfig } from '@google/genai'
import { GenerateContentConfig, HarmBlockThreshold, HarmCategory } from '@google/genai'
import { ChatModel, Google, LlmCompletionOpts } from 'multi-llm-ts'
import { GoogleEngineConfig } from 'types/config'

export default class GoogleEngine extends Google {

Expand All @@ -8,13 +9,32 @@ export default class GoogleEngine extends Google {
// we need opts
if (!opts) opts = {}

// default thinking budget to automatic
// default thinking budget from engine config, or automatic
if (typeof opts.thinkingBudget === 'undefined') {
opts.thinkingBudget = -1
const googleConfig = this.config as unknown as GoogleEngineConfig
opts.thinkingBudget = typeof googleConfig.defaultThinkingBudget !== 'undefined'
? googleConfig.defaultThinkingBudget
: -1
}

// use default
return await super.getGenerationConfig(model, opts)
const config = await super.getGenerationConfig(model, opts)

// apply engine-level safety settings to all content categories
if (config) {
const googleConfig = this.config as unknown as GoogleEngineConfig
if (googleConfig.safetySettings) {
const threshold = googleConfig.safetySettings as HarmBlockThreshold
config.safetySettings = [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold },
]
Comment on lines +25 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In @google/genai, what are the valid HarmBlockThresholdvalues accepted byGenerateContentConfig.safetySettings?

💡 Result:

The valid HarmBlockThreshold values accepted by GenerateContentConfig.safetySettings in @google/genai are: HARM_BLOCK_THRESHOLD_UNSPECIFIED, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH, BLOCK_NONE, and OFF. These are string values corresponding to the enum defined in the Gemini API protobufs, used across Google GenAI SDKs including JavaScript (@google/genai). They control the probability threshold at which content is blocked for each harm category in safety settings. - HARM_BLOCK_THRESHOLD_UNSPECIFIED: Uses default threshold (high confidence from multiple official sources). - BLOCK_LOW_AND_ABOVE: Blocks low, medium, high probability harm. - BLOCK_MEDIUM_AND_ABOVE: Blocks medium, high. - BLOCK_ONLY_HIGH: Blocks only high. - BLOCK_NONE: Blocks none (always allow). - OFF: Turns off safety filter. Example usage in @google/genai (inferred from API compatibility and JS examples): const safetySettings = [ { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_LOW_AND_ABOVE" } ]; Official Gemini API docs confirm these values for safetySettings.threshold, and @google/genai wraps the same REST API.

Citations:


🏁 Script executed:

fd -t f "google.ts" src/renderer/services/llms

Repository: nbonamy/witsy

Length of output: 93


🏁 Script executed:

fd -t f "SettingsGoogle.vue" src/renderer

Repository: nbonamy/witsy

Length of output: 97


🏁 Script executed:

head -60 src/renderer/services/llms/google.ts

Repository: nbonamy/witsy

Length of output: 1587


🏁 Script executed:

cat src/renderer/settings/SettingsGoogle.vue | head -100

Repository: nbonamy/witsy

Length of output: 5112


🏁 Script executed:

grep -A 20 -B 5 "safetySettings" src/renderer/settings/SettingsGoogle.vue

Repository: nbonamy/witsy

Length of output: 4567


🏁 Script executed:

grep -r "HARM_BLOCK_THRESHOLD_UNSPECIFIED" src/

Repository: nbonamy/witsy

Length of output: 39


🏁 Script executed:

cat -n src/renderer/services/llms/google.ts | sed -n '20,40p'

Repository: nbonamy/witsy

Length of output: 958


Validate safetySettings before using it with the Google API.

Line 27 blindly casts safetySettings from persisted config to HarmBlockThreshold without validation. A hand-edited config file with an invalid threshold value will cause API errors. The UI restricts options to valid values, but the persisted setting can be modified outside the application. Add validation to whitelist known values and skip safety settings if an invalid value is encountered so the provider defaults are used instead.

Valid HarmBlockThreshold values are: HARM_BLOCK_THRESHOLD_UNSPECIFIED, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH, BLOCK_NONE, and OFF.

Suggested guard
 import { GenerateContentConfig, HarmBlockThreshold, HarmCategory } from '@google/genai'
 import { ChatModel, Google, LlmCompletionOpts } from 'multi-llm-ts'
 import { GoogleEngineConfig } from 'types/config'
 
+const GOOGLE_SAFETY_THRESHOLDS = new Set([
+  'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
+  'BLOCK_LOW_AND_ABOVE',
+  'BLOCK_MEDIUM_AND_ABOVE',
+  'BLOCK_ONLY_HIGH',
+  'BLOCK_NONE',
+  'OFF',
+])
+
 export default class GoogleEngine extends Google {
-      if (googleConfig.safetySettings) {
+      if (googleConfig.safetySettings && GOOGLE_SAFETY_THRESHOLDS.has(googleConfig.safetySettings)) {
         const threshold = googleConfig.safetySettings as HarmBlockThreshold
         config.safetySettings = [
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const googleConfig = this.config as unknown as GoogleEngineConfig
if (googleConfig.safetySettings) {
const threshold = googleConfig.safetySettings as HarmBlockThreshold
config.safetySettings = [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold },
]
import { GenerateContentConfig, HarmBlockThreshold, HarmCategory } from '@google/genai'
import { ChatModel, Google, LlmCompletionOpts } from 'multi-llm-ts'
import { GoogleEngineConfig } from 'types/config'
const GOOGLE_SAFETY_THRESHOLDS = new Set([
'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
'BLOCK_LOW_AND_ABOVE',
'BLOCK_MEDIUM_AND_ABOVE',
'BLOCK_ONLY_HIGH',
'BLOCK_NONE',
'OFF',
])
export default class GoogleEngine extends Google {
// ... other code ...
const googleConfig = this.config as unknown as GoogleEngineConfig
if (googleConfig.safetySettings && GOOGLE_SAFETY_THRESHOLDS.has(googleConfig.safetySettings)) {
const threshold = googleConfig.safetySettings as HarmBlockThreshold
config.safetySettings = [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold },
]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/services/llms/google.ts` around lines 25 - 33, The code
currently casts googleConfig.safetySettings to HarmBlockThreshold without
validation; update the block that reads googleConfig.safetySettings (in the file
where googleConfig is cast) to whitelist only the valid HarmBlockThreshold
values (HARM_BLOCK_THRESHOLD_UNSPECIFIED, BLOCK_LOW_AND_ABOVE,
BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH, BLOCK_NONE, OFF), and if the persisted
value is not one of those, skip setting config.safetySettings so Google defaults
apply (optionally emit a debug/warn via the same logger). Ensure you reference
the googleConfig.safetySettings value, validate against the HarmBlockThreshold
set, and only populate config.safetySettings when the value is valid.

}
}

return config

}

Expand Down
28 changes: 28 additions & 0 deletions src/renderer/settings/SettingsGoogle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@
<label>{{ t('settings.engines.requestCooldown') }}</label>
<input type="number" name="requestCooldown" v-model.number="requestCooldown" min="0" step="100" :placeholder="t('settings.engines.requestCooldownHint')" @change="save"/>
</div>
<div class="form-field">
<label>{{ t('settings.engines.google.safetySettings') }}</label>
<div class="form-subgroup">
<select name="safetySettings" v-model="safetySettings" @change="save">
<option value="">{{ t('settings.engines.google.safetyDefault') }}</option>
<option value="BLOCK_LOW_AND_ABOVE">{{ t('settings.engines.google.safetyBlockMost') }}</option>
<option value="BLOCK_MEDIUM_AND_ABOVE">{{ t('settings.engines.google.safetyBlockSome') }}</option>
<option value="BLOCK_ONLY_HIGH">{{ t('settings.engines.google.safetyBlockFew') }}</option>
<option value="BLOCK_NONE">{{ t('settings.engines.google.safetyBlockNone') }}</option>
<option value="OFF">{{ t('settings.engines.google.safetyOff') }}</option>
</select>
<span class="hint">{{ t('settings.engines.google.safetySettingsHint') }}</span>
</div>
</div>
<div class="form-field">
<label>{{ t('settings.engines.google.defaultThinkingBudget') }}</label>
<div class="form-subgroup">
<input type="number" name="defaultThinkingBudget" v-model.number="defaultThinkingBudget" min="-1" step="1" :placeholder="t('settings.engines.google.defaultThinkingBudgetHint')" @change="save"/>
<span class="hint">{{ t('settings.engines.google.defaultThinkingBudgetExplain') }}</span>
</div>
</div>
<div class="form-field horizontal">
<input type="checkbox" id="google-disable-tools" name="disableTools" v-model="disableTools" @change="save" />
<label for="google-disable-tools">{{ t('settings.engines.disableTools') }}</label>
Expand All @@ -72,6 +93,7 @@ import RefreshButton from '@components/RefreshButton.vue'
import ModelSelectPlus from '@components/ModelSelectPlus.vue'
import InputObfuscated from '@components/InputObfuscated.vue'
import { ChatModel, defaultCapabilities } from 'multi-llm-ts'
import { GoogleEngineConfig } from 'types/config'

const vertexai = ref(false)
const apiKey = ref(null)
Expand All @@ -80,6 +102,8 @@ const project = ref('')
const location = ref('')
const disableTools = ref(false)
const requestCooldown = ref<number>(null)
const safetySettings = ref<string>('')
const defaultThinkingBudget = ref<number>(null)
Comment on lines +105 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Vue 3, what value does v-model.numberproduce for an when the input is cleared?

💡 Result:

In Vue 3, v-model.number on an produces an empty string ("") when the input is cleared.

Citations:


🏁 Script executed:

# Find and read SettingsGoogle.vue to check lines 105-106 and 169-170
find . -name "SettingsGoogle.vue" -type f

Repository: nbonamy/witsy

Length of output: 99


🏁 Script executed:

# Find google.ts service file
find . -name "google.ts" -path "*/renderer/services/llms/*" -type f

Repository: nbonamy/witsy

Length of output: 95


🏁 Script executed:

# Read SettingsGoogle.vue to check lines 105-106 and 169-170
wc -l ./src/renderer/settings/SettingsGoogle.vue

Repository: nbonamy/witsy

Length of output: 103


🏁 Script executed:

# Read the relevant sections of SettingsGoogle.vue
sed -n '100,180p' ./src/renderer/settings/SettingsGoogle.vue

Repository: nbonamy/witsy

Length of output: 2952


🏁 Script executed:

# Read google.ts to understand how defaultThinkingBudget is handled
cat -n ./src/renderer/services/llms/google.ts | head -100

Repository: nbonamy/witsy

Length of output: 1874


🏁 Script executed:

# Check the template section around lines 57-77 to see the input for defaultThinkingBudget
sed -n '1,100p' ./src/renderer/settings/SettingsGoogle.vue

Repository: nbonamy/witsy

Length of output: 5112


Normalize a cleared thinking budget before saving it.

With Vue's .number modifier, clearing the <input type="number"> yields '', not null. Line 170 uses ??, which doesn't catch empty strings, so '' ?? undefined evaluates to '' and persists verbatim. In google.ts, the check typeof googleConfig.defaultThinkingBudget !== 'undefined' treats this empty string as configured and forwards it to Google instead of falling back to automatic mode.

Fix the type annotation to allow the empty string that Vue produces, and normalize it to undefined during save:

Suggested fix
-const defaultThinkingBudget = ref<number>(null)
+const defaultThinkingBudget = ref<number | '' | null>(null)
-  ;(store.config.engines.google as GoogleEngineConfig).defaultThinkingBudget = defaultThinkingBudget.value ?? undefined
+  ;(store.config.engines.google as GoogleEngineConfig).defaultThinkingBudget =
+    defaultThinkingBudget.value === '' || defaultThinkingBudget.value === null
+      ? undefined
+      : defaultThinkingBudget.value
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/settings/SettingsGoogle.vue` around lines 105 - 106, The
defaultThinkingBudget ref should accept the empty string Vue emits from a
cleared number input and be normalized to undefined before persisting: change
the type of defaultThinkingBudget (currently ref<number>(null)) to allow
number|string (and/or null) so it can hold '' from the input, and in the save
path (the settings save handler that builds googleConfig and where you currently
use the nullish coalescing on defaultThinkingBudget) convert an empty string
('') to undefined so googleConfig.defaultThinkingBudget is omitted rather than
set to ''. Ensure any downstream check in google.ts that uses typeof
googleConfig.defaultThinkingBudget !== 'undefined' will therefore treat cleared
values as not configured.

const chat_model = ref<string>(null)
const vision_model = ref<string>(null)
const chat_models = ref<ChatModel[]>([])
Expand All @@ -102,6 +126,8 @@ const load = () => {
vision_model.value = store.config.engines.google?.model?.vision || ''
disableTools.value = store.config.engines.google?.disableTools || false
requestCooldown.value = store.config.engines.google?.requestCooldown || null
safetySettings.value = (store.config.engines.google as GoogleEngineConfig)?.safetySettings || ''
defaultThinkingBudget.value = (store.config.engines.google as GoogleEngineConfig)?.defaultThinkingBudget ?? null
}

const getModels = async (): Promise<boolean> => {
Expand Down Expand Up @@ -140,6 +166,8 @@ const save = () => {
store.config.engines.google.model.vision = vision_model.value
store.config.engines.google.disableTools = disableTools.value
store.config.engines.google.requestCooldown = requestCooldown.value || undefined
;(store.config.engines.google as GoogleEngineConfig).safetySettings = safetySettings.value || undefined
;(store.config.engines.google as GoogleEngineConfig).defaultThinkingBudget = defaultThinkingBudget.value ?? undefined
store.saveSettings()
}

Expand Down
5 changes: 5 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export type CustomEngineConfig = EngineConfig & {
api: string
}

export type GoogleEngineConfig = EngineConfig & {
safetySettings?: string
defaultThinkingBudget?: number
}

export type ProxyMode = 'default' | 'bypass' | 'custom'

export type GeneralConfig = {
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/renderer/screens/settings_models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ test('google settings', async () => {
expect(store.config.engines.google.disableTools).toBeFalsy()
await google.find('[name=disableTools]').setValue(true)
expect(store.config.engines.google.disableTools).toBeTruthy()
await google.find('[name=safetySettings]').setValue('BLOCK_NONE')
expect((store.config.engines.google as any).safetySettings).toBe('BLOCK_NONE')
await google.find('[name=defaultThinkingBudget]').setValue('1024')
await google.find('[name=defaultThinkingBudget]').trigger('change')
expect((store.config.engines.google as any).defaultThinkingBudget).toBe(1024)
})

test('xai settings', async () => {
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/renderer/services/llms/google.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { HarmCategory } from '@google/genai'
import GoogleEngine from '@services/llms/google'
import { ChatModel, Google, LlmCompletionOpts } from 'multi-llm-ts'
import { afterEach, describe, expect, test, vi } from 'vitest'

const model: ChatModel = {
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
capabilities: {
vision: false,
tools: true,
reasoning: true,
caching: false,
},
} as ChatModel

const createEngine = (config: Record<string, unknown> = {}) => new GoogleEngine({
apiKey: 'test-api-key',
...config,
} as any)

const getGenerationConfig = (engine: GoogleEngine, opts?: LlmCompletionOpts) => {
return (engine as any).getGenerationConfig(model, opts)
}

describe('GoogleEngine', () => {
afterEach(() => {
vi.restoreAllMocks()
})

test('uses configured default thinking budget and applies safety settings', async () => {
const superGetGenerationConfig = vi.spyOn(Google.prototype as any, 'getGenerationConfig').mockImplementation(async (_model, opts) => ({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: opts?.thinkingBudget,
},
}))

const config = await getGenerationConfig(createEngine({
defaultThinkingBudget: 1024,
safetySettings: 'BLOCK_NONE',
}))

expect(superGetGenerationConfig).toHaveBeenCalledWith(model, expect.objectContaining({ thinkingBudget: 1024 }))
expect(config).toMatchObject({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 1024,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: 'BLOCK_NONE' },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: 'BLOCK_NONE' },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: 'BLOCK_NONE' },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: 'BLOCK_NONE' },
],
})
})

test('falls back to automatic thinking budget when no default is configured', async () => {
const superGetGenerationConfig = vi.spyOn(Google.prototype as any, 'getGenerationConfig').mockImplementation(async (_model, opts) => ({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: opts?.thinkingBudget,
},
}))

const config = await getGenerationConfig(createEngine())

expect(superGetGenerationConfig).toHaveBeenCalledWith(model, expect.objectContaining({ thinkingBudget: -1 }))
expect(config).toMatchObject({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: -1,
},
})
})

test('preserves an explicit thinking budget override', async () => {
const superGetGenerationConfig = vi.spyOn(Google.prototype as any, 'getGenerationConfig').mockImplementation(async (_model, opts) => ({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: opts?.thinkingBudget,
},
}))

const config = await getGenerationConfig(createEngine({ defaultThinkingBudget: 1024 }), { thinkingBudget: 256 })

expect(superGetGenerationConfig).toHaveBeenCalledWith(model, expect.objectContaining({ thinkingBudget: 256 }))
expect(config).toMatchObject({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 256,
},
})
})

test('returns undefined when the base engine returns no config', async () => {
vi.spyOn(Google.prototype as any, 'getGenerationConfig').mockResolvedValue(undefined)

await expect(getGenerationConfig(createEngine({
safetySettings: 'BLOCK_NONE',
}))).resolves.toBeUndefined()
})
})