From 60c8e935cf0e1aa8738fe50b69f7120d21ba0f72 Mon Sep 17 00:00:00 2001 From: hugo Date: Tue, 21 Apr 2026 22:30:35 +0200 Subject: [PATCH] fix: one-shot and thinking models can conflict --- src/main/engine/ai/providers.ts | 63 ++++++++++++++++--- src/main/ipc/chatHandlers.ts | 2 +- src/main/preload.ts | 2 +- src/main/shared/electronApi.ts | 4 +- .../components/SettingsView/SettingsView.tsx | 16 +++-- src/renderer/i18n/locales/de.json | 1 + src/renderer/i18n/locales/en.json | 1 + src/renderer/i18n/locales/es.json | 1 + src/renderer/i18n/locales/fr.json | 1 + src/renderer/i18n/locales/it.json | 1 + tests/engine/generic-openai-provider.test.ts | 54 ++++++++++++++++ 11 files changed, 129 insertions(+), 17 deletions(-) diff --git a/src/main/engine/ai/providers.ts b/src/main/engine/ai/providers.ts index 5e578c6..a294fe8 100644 --- a/src/main/engine/ai/providers.ts +++ b/src/main/engine/ai/providers.ts @@ -130,7 +130,7 @@ export class ProviderRegistry { private genericOpenAIApiKey = ''; private genericOpenAIProvider: ReturnType | null = null; private genericOpenAIModelIds = new Set(); - private genericOpenAICapabilities = new Map(); + private genericOpenAICapabilities = new Map(); private modelCatalogEngine = new ModelCatalogEngine(); private _offlineMode = false; @@ -353,19 +353,23 @@ export class ProviderRegistry { } /** Get capability overrides for a specific generic OpenAI model. */ - getGenericOpenAIModelCapabilities(modelId: string): { tools: boolean; vision: boolean } { - return this.genericOpenAICapabilities.get(modelId) ?? { tools: false, vision: false }; + getGenericOpenAIModelCapabilities(modelId: string): { tools: boolean; vision: boolean; disableThinking: boolean } { + return this.genericOpenAICapabilities.get(modelId) ?? { tools: false, vision: false, disableThinking: false }; } /** Set capability overrides for a specific generic OpenAI model. */ - setGenericOpenAIModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean }): void { - this.genericOpenAICapabilities.set(modelId, caps); + setGenericOpenAIModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean; disableThinking?: boolean }): void { + this.genericOpenAICapabilities.set(modelId, { + tools: caps.tools, + vision: caps.vision, + disableThinking: caps.disableThinking ?? false, + }); this.invalidateModelCache(); } /** Get all stored generic OpenAI capability overrides. */ - getAllGenericOpenAIModelCapabilities(): Record { - const result: Record = {}; + getAllGenericOpenAIModelCapabilities(): Record { + const result: Record = {}; for (const [id, caps] of this.genericOpenAICapabilities) { result[id] = caps; } @@ -373,10 +377,14 @@ export class ProviderRegistry { } /** Load generic OpenAI capability overrides from serialized object. */ - loadGenericOpenAIModelCapabilities(data: Record): void { + loadGenericOpenAIModelCapabilities(data: Record): void { this.genericOpenAICapabilities.clear(); for (const [id, caps] of Object.entries(data)) { - this.genericOpenAICapabilities.set(id, caps); + this.genericOpenAICapabilities.set(id, { + tools: caps.tools, + vision: caps.vision, + disableThinking: caps.disableThinking ?? false, + }); } } @@ -390,6 +398,11 @@ export class ProviderRegistry { return this.genericOpenAICapabilities.get(modelId)?.vision ?? false; } + /** Check whether a generic OpenAI model should disable reasoning/thinking output. */ + genericOpenAIModelDisablesThinking(modelId: string): boolean { + return this.genericOpenAICapabilities.get(modelId)?.disableThinking ?? false; + } + /** * Detect the effective provider for a model ID, checking Ollama, LM Studio, * and generic OpenAI registration first, then falling back to prefix-based detection. @@ -491,9 +504,41 @@ export class ProviderRegistry { throw new Error(`Generic OpenAI endpoint not configured for model '${modelId}'`); } if (!this.genericOpenAIProvider) { + const genericFetch: typeof fetch = async (input, init) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + + if (url.endsWith('/chat/completions') && typeof init?.body === 'string') { + try { + const body = JSON.parse(init.body) as { model?: string; chat_template_kwargs?: Record }; + if (body.model && this.genericOpenAIModelDisablesThinking(body.model)) { + const nextInit = { + ...init, + body: JSON.stringify({ + ...body, + chat_template_kwargs: { + ...body.chat_template_kwargs, + enable_thinking: false, + }, + }), + }; + return fetch(input, nextInit); + } + } catch { + // Fall back to the original request if the body isn't JSON. + } + } + + return fetch(input, init); + }; + this.genericOpenAIProvider = createOpenAI({ baseURL: this.genericOpenAIBaseURL, apiKey: this.genericOpenAIApiKey || 'dummy-key', + fetch: genericFetch, }); } return this.genericOpenAIProvider.chat(modelId); diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index ff65548..bad8784 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -634,7 +634,7 @@ export function registerChatHandlers(): void { }); // Set capability override for a single generic OpenAI model - ipcMain.handle('chat:setGenericOpenAIModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean }) => { + ipcMain.handle('chat:setGenericOpenAIModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => { try { await ensureInitialized(); const reg = getProviders(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 733ada3..d1f8509 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -367,7 +367,7 @@ export const electronAPI: ElectronAPI = { validateGenericOpenAIConfig: () => ipcRenderer.invoke('chat:validateGenericOpenAIConfig'), getGenericOpenAIModels: () => ipcRenderer.invoke('chat:getGenericOpenAIModels'), getGenericOpenAIModelCapabilities: () => ipcRenderer.invoke('chat:getGenericOpenAIModelCapabilities'), - setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setGenericOpenAIModelCapabilities', modelId, caps), + setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => ipcRenderer.invoke('chat:setGenericOpenAIModelCapabilities', modelId, caps), // Offline / Airplane Mode getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index d118691..df9d7e6 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -1031,8 +1031,8 @@ export interface ElectronAPI { setGenericOpenAIApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; validateGenericOpenAIConfig: () => Promise<{ isValid: boolean; models: ChatModel[]; error?: string }>; getGenericOpenAIModels: () => Promise; - getGenericOpenAIModelCapabilities: () => Promise>; - setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; + getGenericOpenAIModelCapabilities: () => Promise>; + setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => Promise<{ success: boolean; error?: string }>; // Offline / Airplane mode getOfflineMode: () => Promise; diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 4e5e18b..bcde498 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -259,7 +259,7 @@ export const SettingsView: React.FC = () => { const [hasGenericOpenAIApiKey, setHasGenericOpenAIApiKey] = useState(false); const [newGenericOpenAIApiKey, setNewGenericOpenAIApiKey] = useState(''); const [genericOpenAIModels, setGenericOpenAIModels] = useState<{id: string; name: string}[]>([]); - const [genericOpenAICapabilities, setGenericOpenAICapabilities] = useState>({}); + const [genericOpenAICapabilities, setGenericOpenAICapabilities] = useState>({}); const [offlineModeEnabled, setOfflineModeEnabled] = useState(false); const [offlineChatModel, setOfflineChatModel] = useState(''); const [offlineTitleModel, setOfflineTitleModel] = useState(''); @@ -1466,8 +1466,8 @@ export const SettingsView: React.FC = () => { } }; - const handleGenericOpenAICapabilityToggle = async (modelId: string, field: 'tools' | 'vision', value: boolean) => { - const current = genericOpenAICapabilities[modelId] ?? { tools: false, vision: false }; + const handleGenericOpenAICapabilityToggle = async (modelId: string, field: 'tools' | 'vision' | 'disableThinking', value: boolean) => { + const current = genericOpenAICapabilities[modelId] ?? { tools: false, vision: false, disableThinking: false }; const updated = { ...current, [field]: value }; try { const result = await window.electronAPI?.chat.setGenericOpenAIModelCapabilities(modelId, updated); @@ -1939,11 +1939,12 @@ export const SettingsView: React.FC = () => { {t('settings.ai.genericOpenAICapModel')} {t('settings.ai.genericOpenAICapTools')} {t('settings.ai.genericOpenAICapVision')} + {t('settings.ai.genericOpenAICapDisableThinking')} {genericOpenAIModels.map(m => { - const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false }; + const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false, disableThinking: false }; return ( {m.name} @@ -1961,6 +1962,13 @@ export const SettingsView: React.FC = () => { onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'vision', e.target.checked)} /> + + handleGenericOpenAICapabilityToggle(m.id, 'disableThinking', e.target.checked)} + /> + ); })} diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 0a752b7..98dc9f8 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -884,6 +884,7 @@ "settings.ai.genericOpenAICapModel": "Modell", "settings.ai.genericOpenAICapTools": "Tools", "settings.ai.genericOpenAICapVision": "Vision", + "settings.ai.genericOpenAICapDisableThinking": "Denkmodus deaktivieren", "settings.toast.genericOpenAIEnabled": "Generischer OpenAI-Endpunkt aktiviert", "settings.toast.genericOpenAIDisabled": "Generischer OpenAI-Endpunkt deaktiviert", "settings.toast.genericOpenAISettingsSaved": "Generische OpenAI-Einstellungen gespeichert", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 0b85005..f1f0403 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -884,6 +884,7 @@ "settings.ai.genericOpenAICapModel": "Model", "settings.ai.genericOpenAICapTools": "Tools", "settings.ai.genericOpenAICapVision": "Vision", + "settings.ai.genericOpenAICapDisableThinking": "Disable Thinking", "settings.toast.genericOpenAIEnabled": "Generic OpenAI endpoint enabled", "settings.toast.genericOpenAIDisabled": "Generic OpenAI endpoint disabled", "settings.toast.genericOpenAISettingsSaved": "Generic OpenAI settings saved", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index 71e6c8c..6fbad81 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -884,6 +884,7 @@ "settings.ai.genericOpenAICapModel": "Modelo", "settings.ai.genericOpenAICapTools": "Herramientas", "settings.ai.genericOpenAICapVision": "Visión", + "settings.ai.genericOpenAICapDisableThinking": "Desactivar razonamiento", "settings.toast.genericOpenAIEnabled": "Endpoint genérico OpenAI habilitado", "settings.toast.genericOpenAIDisabled": "Endpoint genérico OpenAI deshabilitado", "settings.toast.genericOpenAISettingsSaved": "Configuración genérica de OpenAI guardada", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index d4e7453..995322e 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -884,6 +884,7 @@ "settings.ai.genericOpenAICapModel": "Modèle", "settings.ai.genericOpenAICapTools": "Outils", "settings.ai.genericOpenAICapVision": "Vision", + "settings.ai.genericOpenAICapDisableThinking": "Désactiver le raisonnement", "settings.toast.genericOpenAIEnabled": "Point de terminaison OpenAI générique activé", "settings.toast.genericOpenAIDisabled": "Point de terminaison OpenAI générique désactivé", "settings.toast.genericOpenAISettingsSaved": "Paramètres OpenAI génériques enregistrés", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 8a72912..9877dc7 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -884,6 +884,7 @@ "settings.ai.genericOpenAICapModel": "Modello", "settings.ai.genericOpenAICapTools": "Strumenti", "settings.ai.genericOpenAICapVision": "Visione", + "settings.ai.genericOpenAICapDisableThinking": "Disattiva ragionamento", "settings.toast.genericOpenAIEnabled": "Endpoint generico OpenAI abilitato", "settings.toast.genericOpenAIDisabled": "Endpoint generico OpenAI disabilitato", "settings.toast.genericOpenAISettingsSaved": "Impostazioni generiche OpenAI salvate", diff --git a/tests/engine/generic-openai-provider.test.ts b/tests/engine/generic-openai-provider.test.ts index 8f031de..6491c76 100644 --- a/tests/engine/generic-openai-provider.test.ts +++ b/tests/engine/generic-openai-provider.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateText } from 'ai'; import { ProviderRegistry } from '../../src/main/engine/ai/providers'; vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({ @@ -55,4 +56,57 @@ describe('generic OpenAI-compatible provider support', () => { expect(registry.getKnownLocalModels()).toEqual([]); expect(() => registry.resolveModel('custom-model')).toThrow(/not available offline/i); }); + + it('stores disableThinking for generic endpoint models', () => { + registry.setGenericOpenAIModelCapabilities('custom-model', { tools: false, vision: true, disableThinking: true }); + + expect(registry.getGenericOpenAIModelCapabilities('custom-model')).toEqual({ + tools: false, + vision: true, + disableThinking: true, + }); + }); + + it('injects enable_thinking false only when disableThinking is enabled', async () => { + registry.setGenericOpenAIEnabled(true); + registry.setGenericOpenAIBaseURL('http://localhost:4000/v1'); + registry.registerGenericOpenAIModel('custom-model'); + registry.setGenericOpenAIModelCapabilities('custom-model', { tools: false, vision: false, disableThinking: true }); + + const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-test', + object: 'chat.completion', + created: 1, + model: 'custom-model', + choices: [{ + index: 0, + message: { + role: 'assistant', + content: 'Short title', + }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + const originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch; + + try { + const model = registry.resolveModel('custom-model'); + const result = await generateText({ + model, + prompt: 'hello', + maxOutputTokens: 10, + maxRetries: 0, + }); + + expect(result.text).toBe('Short title'); + const [, request] = mockFetch.mock.calls[0] as [string, { body: string }]; + expect(JSON.parse(request.body)).toMatchObject({ + chat_template_kwargs: { enable_thinking: false }, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); }); \ No newline at end of file