fix: one-shot and thinking models can conflict
Some checks failed
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled

This commit is contained in:
2026-04-21 22:30:35 +02:00
parent f19fde6879
commit 60c8e935cf
11 changed files with 129 additions and 17 deletions

View File

@@ -130,7 +130,7 @@ export class ProviderRegistry {
private genericOpenAIApiKey = ''; private genericOpenAIApiKey = '';
private genericOpenAIProvider: ReturnType<typeof createOpenAI> | null = null; private genericOpenAIProvider: ReturnType<typeof createOpenAI> | null = null;
private genericOpenAIModelIds = new Set<string>(); private genericOpenAIModelIds = new Set<string>();
private genericOpenAICapabilities = new Map<string, { tools: boolean; vision: boolean }>(); private genericOpenAICapabilities = new Map<string, { tools: boolean; vision: boolean; disableThinking: boolean }>();
private modelCatalogEngine = new ModelCatalogEngine(); private modelCatalogEngine = new ModelCatalogEngine();
private _offlineMode = false; private _offlineMode = false;
@@ -353,19 +353,23 @@ export class ProviderRegistry {
} }
/** Get capability overrides for a specific generic OpenAI model. */ /** Get capability overrides for a specific generic OpenAI model. */
getGenericOpenAIModelCapabilities(modelId: string): { tools: boolean; vision: boolean } { getGenericOpenAIModelCapabilities(modelId: string): { tools: boolean; vision: boolean; disableThinking: boolean } {
return this.genericOpenAICapabilities.get(modelId) ?? { tools: false, vision: false }; return this.genericOpenAICapabilities.get(modelId) ?? { tools: false, vision: false, disableThinking: false };
} }
/** Set capability overrides for a specific generic OpenAI model. */ /** Set capability overrides for a specific generic OpenAI model. */
setGenericOpenAIModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean }): void { setGenericOpenAIModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean; disableThinking?: boolean }): void {
this.genericOpenAICapabilities.set(modelId, caps); this.genericOpenAICapabilities.set(modelId, {
tools: caps.tools,
vision: caps.vision,
disableThinking: caps.disableThinking ?? false,
});
this.invalidateModelCache(); this.invalidateModelCache();
} }
/** Get all stored generic OpenAI capability overrides. */ /** Get all stored generic OpenAI capability overrides. */
getAllGenericOpenAIModelCapabilities(): Record<string, { tools: boolean; vision: boolean }> { getAllGenericOpenAIModelCapabilities(): Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }> {
const result: Record<string, { tools: boolean; vision: boolean }> = {}; const result: Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }> = {};
for (const [id, caps] of this.genericOpenAICapabilities) { for (const [id, caps] of this.genericOpenAICapabilities) {
result[id] = caps; result[id] = caps;
} }
@@ -373,10 +377,14 @@ export class ProviderRegistry {
} }
/** Load generic OpenAI capability overrides from serialized object. */ /** Load generic OpenAI capability overrides from serialized object. */
loadGenericOpenAIModelCapabilities(data: Record<string, { tools: boolean; vision: boolean }>): void { loadGenericOpenAIModelCapabilities(data: Record<string, { tools: boolean; vision: boolean; disableThinking?: boolean }>): void {
this.genericOpenAICapabilities.clear(); this.genericOpenAICapabilities.clear();
for (const [id, caps] of Object.entries(data)) { 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; 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, * Detect the effective provider for a model ID, checking Ollama, LM Studio,
* and generic OpenAI registration first, then falling back to prefix-based detection. * 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}'`); throw new Error(`Generic OpenAI endpoint not configured for model '${modelId}'`);
} }
if (!this.genericOpenAIProvider) { 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<string, unknown> };
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({ this.genericOpenAIProvider = createOpenAI({
baseURL: this.genericOpenAIBaseURL, baseURL: this.genericOpenAIBaseURL,
apiKey: this.genericOpenAIApiKey || 'dummy-key', apiKey: this.genericOpenAIApiKey || 'dummy-key',
fetch: genericFetch,
}); });
} }
return this.genericOpenAIProvider.chat(modelId); return this.genericOpenAIProvider.chat(modelId);

View File

@@ -634,7 +634,7 @@ export function registerChatHandlers(): void {
}); });
// Set capability override for a single generic OpenAI model // 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 { try {
await ensureInitialized(); await ensureInitialized();
const reg = getProviders(); const reg = getProviders();

View File

@@ -367,7 +367,7 @@ export const electronAPI: ElectronAPI = {
validateGenericOpenAIConfig: () => ipcRenderer.invoke('chat:validateGenericOpenAIConfig'), validateGenericOpenAIConfig: () => ipcRenderer.invoke('chat:validateGenericOpenAIConfig'),
getGenericOpenAIModels: () => ipcRenderer.invoke('chat:getGenericOpenAIModels'), getGenericOpenAIModels: () => ipcRenderer.invoke('chat:getGenericOpenAIModels'),
getGenericOpenAIModelCapabilities: () => ipcRenderer.invoke('chat:getGenericOpenAIModelCapabilities'), 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 // Offline / Airplane Mode
getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'), getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'),

View File

@@ -1031,8 +1031,8 @@ export interface ElectronAPI {
setGenericOpenAIApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; setGenericOpenAIApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
validateGenericOpenAIConfig: () => Promise<{ isValid: boolean; models: ChatModel[]; error?: string }>; validateGenericOpenAIConfig: () => Promise<{ isValid: boolean; models: ChatModel[]; error?: string }>;
getGenericOpenAIModels: () => Promise<ChatModel[]>; getGenericOpenAIModels: () => Promise<ChatModel[]>;
getGenericOpenAIModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>; getGenericOpenAIModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }>>;
setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => Promise<{ success: boolean; error?: string }>;
// Offline / Airplane mode // Offline / Airplane mode
getOfflineMode: () => Promise<boolean>; getOfflineMode: () => Promise<boolean>;

View File

@@ -259,7 +259,7 @@ export const SettingsView: React.FC = () => {
const [hasGenericOpenAIApiKey, setHasGenericOpenAIApiKey] = useState(false); const [hasGenericOpenAIApiKey, setHasGenericOpenAIApiKey] = useState(false);
const [newGenericOpenAIApiKey, setNewGenericOpenAIApiKey] = useState(''); const [newGenericOpenAIApiKey, setNewGenericOpenAIApiKey] = useState('');
const [genericOpenAIModels, setGenericOpenAIModels] = useState<{id: string; name: string}[]>([]); const [genericOpenAIModels, setGenericOpenAIModels] = useState<{id: string; name: string}[]>([]);
const [genericOpenAICapabilities, setGenericOpenAICapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({}); const [genericOpenAICapabilities, setGenericOpenAICapabilities] = useState<Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }>>({});
const [offlineModeEnabled, setOfflineModeEnabled] = useState(false); const [offlineModeEnabled, setOfflineModeEnabled] = useState(false);
const [offlineChatModel, setOfflineChatModel] = useState(''); const [offlineChatModel, setOfflineChatModel] = useState('');
const [offlineTitleModel, setOfflineTitleModel] = 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 handleGenericOpenAICapabilityToggle = async (modelId: string, field: 'tools' | 'vision' | 'disableThinking', value: boolean) => {
const current = genericOpenAICapabilities[modelId] ?? { tools: false, vision: false }; const current = genericOpenAICapabilities[modelId] ?? { tools: false, vision: false, disableThinking: false };
const updated = { ...current, [field]: value }; const updated = { ...current, [field]: value };
try { try {
const result = await window.electronAPI?.chat.setGenericOpenAIModelCapabilities(modelId, updated); const result = await window.electronAPI?.chat.setGenericOpenAIModelCapabilities(modelId, updated);
@@ -1939,11 +1939,12 @@ export const SettingsView: React.FC = () => {
<th>{t('settings.ai.genericOpenAICapModel')}</th> <th>{t('settings.ai.genericOpenAICapModel')}</th>
<th>{t('settings.ai.genericOpenAICapTools')}</th> <th>{t('settings.ai.genericOpenAICapTools')}</th>
<th>{t('settings.ai.genericOpenAICapVision')}</th> <th>{t('settings.ai.genericOpenAICapVision')}</th>
<th>{t('settings.ai.genericOpenAICapDisableThinking')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{genericOpenAIModels.map(m => { {genericOpenAIModels.map(m => {
const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false }; const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false, disableThinking: false };
return ( return (
<tr key={m.id}> <tr key={m.id}>
<td>{m.name}</td> <td>{m.name}</td>
@@ -1961,6 +1962,13 @@ export const SettingsView: React.FC = () => {
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'vision', e.target.checked)} onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'vision', e.target.checked)}
/> />
</td> </td>
<td>
<input
type="checkbox"
checked={caps.disableThinking}
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'disableThinking', e.target.checked)}
/>
</td>
</tr> </tr>
); );
})} })}

View File

@@ -884,6 +884,7 @@
"settings.ai.genericOpenAICapModel": "Modell", "settings.ai.genericOpenAICapModel": "Modell",
"settings.ai.genericOpenAICapTools": "Tools", "settings.ai.genericOpenAICapTools": "Tools",
"settings.ai.genericOpenAICapVision": "Vision", "settings.ai.genericOpenAICapVision": "Vision",
"settings.ai.genericOpenAICapDisableThinking": "Denkmodus deaktivieren",
"settings.toast.genericOpenAIEnabled": "Generischer OpenAI-Endpunkt aktiviert", "settings.toast.genericOpenAIEnabled": "Generischer OpenAI-Endpunkt aktiviert",
"settings.toast.genericOpenAIDisabled": "Generischer OpenAI-Endpunkt deaktiviert", "settings.toast.genericOpenAIDisabled": "Generischer OpenAI-Endpunkt deaktiviert",
"settings.toast.genericOpenAISettingsSaved": "Generische OpenAI-Einstellungen gespeichert", "settings.toast.genericOpenAISettingsSaved": "Generische OpenAI-Einstellungen gespeichert",

View File

@@ -884,6 +884,7 @@
"settings.ai.genericOpenAICapModel": "Model", "settings.ai.genericOpenAICapModel": "Model",
"settings.ai.genericOpenAICapTools": "Tools", "settings.ai.genericOpenAICapTools": "Tools",
"settings.ai.genericOpenAICapVision": "Vision", "settings.ai.genericOpenAICapVision": "Vision",
"settings.ai.genericOpenAICapDisableThinking": "Disable Thinking",
"settings.toast.genericOpenAIEnabled": "Generic OpenAI endpoint enabled", "settings.toast.genericOpenAIEnabled": "Generic OpenAI endpoint enabled",
"settings.toast.genericOpenAIDisabled": "Generic OpenAI endpoint disabled", "settings.toast.genericOpenAIDisabled": "Generic OpenAI endpoint disabled",
"settings.toast.genericOpenAISettingsSaved": "Generic OpenAI settings saved", "settings.toast.genericOpenAISettingsSaved": "Generic OpenAI settings saved",

View File

@@ -884,6 +884,7 @@
"settings.ai.genericOpenAICapModel": "Modelo", "settings.ai.genericOpenAICapModel": "Modelo",
"settings.ai.genericOpenAICapTools": "Herramientas", "settings.ai.genericOpenAICapTools": "Herramientas",
"settings.ai.genericOpenAICapVision": "Visión", "settings.ai.genericOpenAICapVision": "Visión",
"settings.ai.genericOpenAICapDisableThinking": "Desactivar razonamiento",
"settings.toast.genericOpenAIEnabled": "Endpoint genérico OpenAI habilitado", "settings.toast.genericOpenAIEnabled": "Endpoint genérico OpenAI habilitado",
"settings.toast.genericOpenAIDisabled": "Endpoint genérico OpenAI deshabilitado", "settings.toast.genericOpenAIDisabled": "Endpoint genérico OpenAI deshabilitado",
"settings.toast.genericOpenAISettingsSaved": "Configuración genérica de OpenAI guardada", "settings.toast.genericOpenAISettingsSaved": "Configuración genérica de OpenAI guardada",

View File

@@ -884,6 +884,7 @@
"settings.ai.genericOpenAICapModel": "Modèle", "settings.ai.genericOpenAICapModel": "Modèle",
"settings.ai.genericOpenAICapTools": "Outils", "settings.ai.genericOpenAICapTools": "Outils",
"settings.ai.genericOpenAICapVision": "Vision", "settings.ai.genericOpenAICapVision": "Vision",
"settings.ai.genericOpenAICapDisableThinking": "Désactiver le raisonnement",
"settings.toast.genericOpenAIEnabled": "Point de terminaison OpenAI générique activé", "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.genericOpenAIDisabled": "Point de terminaison OpenAI générique désactivé",
"settings.toast.genericOpenAISettingsSaved": "Paramètres OpenAI génériques enregistrés", "settings.toast.genericOpenAISettingsSaved": "Paramètres OpenAI génériques enregistrés",

View File

@@ -884,6 +884,7 @@
"settings.ai.genericOpenAICapModel": "Modello", "settings.ai.genericOpenAICapModel": "Modello",
"settings.ai.genericOpenAICapTools": "Strumenti", "settings.ai.genericOpenAICapTools": "Strumenti",
"settings.ai.genericOpenAICapVision": "Visione", "settings.ai.genericOpenAICapVision": "Visione",
"settings.ai.genericOpenAICapDisableThinking": "Disattiva ragionamento",
"settings.toast.genericOpenAIEnabled": "Endpoint generico OpenAI abilitato", "settings.toast.genericOpenAIEnabled": "Endpoint generico OpenAI abilitato",
"settings.toast.genericOpenAIDisabled": "Endpoint generico OpenAI disabilitato", "settings.toast.genericOpenAIDisabled": "Endpoint generico OpenAI disabilitato",
"settings.toast.genericOpenAISettingsSaved": "Impostazioni generiche OpenAI salvate", "settings.toast.genericOpenAISettingsSaved": "Impostazioni generiche OpenAI salvate",

View File

@@ -3,6 +3,7 @@
*/ */
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { generateText } from 'ai';
import { ProviderRegistry } from '../../src/main/engine/ai/providers'; import { ProviderRegistry } from '../../src/main/engine/ai/providers';
vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({ vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({
@@ -55,4 +56,57 @@ describe('generic OpenAI-compatible provider support', () => {
expect(registry.getKnownLocalModels()).toEqual([]); expect(registry.getKnownLocalModels()).toEqual([]);
expect(() => registry.resolveModel('custom-model')).toThrow(/not available offline/i); 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;
}
});
}); });