fix: one-shot and thinking models can conflict
Some checks failed
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Some checks failed
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
This commit is contained in:
@@ -130,7 +130,7 @@ export class ProviderRegistry {
|
||||
private genericOpenAIApiKey = '';
|
||||
private genericOpenAIProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||
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 _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<string, { tools: boolean; vision: boolean }> {
|
||||
const result: Record<string, { tools: boolean; vision: boolean }> = {};
|
||||
getAllGenericOpenAIModelCapabilities(): Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }> {
|
||||
const result: Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }> = {};
|
||||
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<string, { tools: boolean; vision: boolean }>): void {
|
||||
loadGenericOpenAIModelCapabilities(data: Record<string, { tools: boolean; vision: boolean; disableThinking?: boolean }>): 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<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({
|
||||
baseURL: this.genericOpenAIBaseURL,
|
||||
apiKey: this.genericOpenAIApiKey || 'dummy-key',
|
||||
fetch: genericFetch,
|
||||
});
|
||||
}
|
||||
return this.genericOpenAIProvider.chat(modelId);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<ChatModel[]>;
|
||||
getGenericOpenAIModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
||||
setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
|
||||
getGenericOpenAIModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }>>;
|
||||
setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Offline / Airplane mode
|
||||
getOfflineMode: () => Promise<boolean>;
|
||||
|
||||
@@ -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<Record<string, { tools: boolean; vision: boolean }>>({});
|
||||
const [genericOpenAICapabilities, setGenericOpenAICapabilities] = useState<Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }>>({});
|
||||
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 = () => {
|
||||
<th>{t('settings.ai.genericOpenAICapModel')}</th>
|
||||
<th>{t('settings.ai.genericOpenAICapTools')}</th>
|
||||
<th>{t('settings.ai.genericOpenAICapVision')}</th>
|
||||
<th>{t('settings.ai.genericOpenAICapDisableThinking')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{genericOpenAIModels.map(m => {
|
||||
const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false };
|
||||
const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false, disableThinking: false };
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td>{m.name}</td>
|
||||
@@ -1961,6 +1962,13 @@ export const SettingsView: React.FC = () => {
|
||||
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'vision', e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={caps.disableThinking}
|
||||
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'disableThinking', e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user