Feat/generic OpenAI provider (#68)

* feat: added a generic openai endpoint provider for self-hosted models

* feat: proper vision and tool checkbox for generic endpoint

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-04-21 21:34:18 +02:00
committed by GitHub
parent 599856cdb2
commit f19fde6879
19 changed files with 1118 additions and 19 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "blogging-desktop-server", "name": "blogging-desktop-server",
"version": "1.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "blogging-desktop-server", "name": "blogging-desktop-server",
"version": "1.0.0", "version": "1.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.50", "@ai-sdk/anthropic": "^3.0.50",

View File

@@ -1,7 +1,7 @@
{ {
"name": "blogging-desktop-server", "name": "blogging-desktop-server",
"productName": "Blogging Desktop Server", "productName": "Blogging Desktop Server",
"version": "1.0.0", "version": "1.0.1",
"description": "A desktop blogging application with offline-first capabilities and cloud sync", "description": "A desktop blogging application with offline-first capabilities and cloud sync",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"scripts": { "scripts": {

View File

@@ -294,8 +294,10 @@ export class ChatService {
// Build tools (skip for Ollama/LM Studio models unless capability override is set) // Build tools (skip for Ollama/LM Studio models unless capability override is set)
const isOllama = this.providers.isOllamaModel(modelId); const isOllama = this.providers.isOllamaModel(modelId);
const isLmstudio = this.providers.isLmstudioModel(modelId); const isLmstudio = this.providers.isLmstudioModel(modelId);
const isGenericOpenAI = this.providers.isGenericOpenAIModel(modelId);
const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId)) const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId))
|| (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId)); || (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId))
|| (isGenericOpenAI && !this.providers.genericOpenAIModelSupportsTools(modelId));
const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps); const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps);
const a2uiToolsRaw = skipTools ? {} : createA2UITools(); const a2uiToolsRaw = skipTools ? {} : createA2UITools();
const allTools = { ...blogTools, ...a2uiToolsRaw }; const allTools = { ...blogTools, ...a2uiToolsRaw };

View File

@@ -31,10 +31,12 @@ export const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags'; export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags';
export const LMSTUDIO_BASE_URL = 'http://localhost:1234/v1'; export const LMSTUDIO_BASE_URL = 'http://localhost:1234/v1';
export const LMSTUDIO_MODELS_URL = 'http://localhost:1234/v1/models'; export const LMSTUDIO_MODELS_URL = 'http://localhost:1234/v1/models';
export const GENERIC_OPENAI_MODELS_PATH = '/models';
const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running
const LMSTUDIO_FETCH_TIMEOUT = 3000; // 3 s — fail fast when LM Studio isn't running const LMSTUDIO_FETCH_TIMEOUT = 3000; // 3 s — fail fast when LM Studio isn't running
const GENERIC_OPENAI_FETCH_TIMEOUT = 10000; // 10 s for generic endpoints
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Gateway factory // Gateway factory
@@ -123,6 +125,12 @@ export class ProviderRegistry {
private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null; private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null;
private lmstudioModelIds = new Set<string>(); private lmstudioModelIds = new Set<string>();
private lmstudioCapabilities = new Map<string, { tools: boolean; vision: boolean }>(); private lmstudioCapabilities = new Map<string, { tools: boolean; vision: boolean }>();
private genericOpenAIEnabled = false;
private genericOpenAIBaseURL = '';
private genericOpenAIApiKey = '';
private genericOpenAIProvider: ReturnType<typeof createOpenAI> | null = null;
private genericOpenAIModelIds = new Set<string>();
private genericOpenAICapabilities = new Map<string, { tools: boolean; vision: boolean }>();
private modelCatalogEngine = new ModelCatalogEngine(); private modelCatalogEngine = new ModelCatalogEngine();
private _offlineMode = false; private _offlineMode = false;
@@ -297,9 +305,94 @@ export class ProviderRegistry {
return this.lmstudioCapabilities.get(modelId)?.vision ?? false; return this.lmstudioCapabilities.get(modelId)?.vision ?? false;
} }
// ---- Generic OpenAI-compatible endpoint management ----
setGenericOpenAIEnabled(enabled: boolean): void {
this.genericOpenAIEnabled = enabled;
this.genericOpenAIProvider = null;
this.invalidateModelCache();
}
isGenericOpenAIEnabled(): boolean {
return this.genericOpenAIEnabled;
}
setGenericOpenAIBaseURL(baseURL: string): void {
this.genericOpenAIBaseURL = this.normalizeGenericOpenAIBaseURL(baseURL);
this.genericOpenAIProvider = null;
this.invalidateModelCache();
}
getGenericOpenAIBaseURL(): string {
return this.genericOpenAIBaseURL;
}
setGenericOpenAIApiKey(apiKey: string): void {
this.genericOpenAIApiKey = apiKey;
this.genericOpenAIProvider = null;
this.invalidateModelCache();
}
getGenericOpenAIApiKey(): string {
return this.genericOpenAIApiKey;
}
/** Register a model ID as belonging to the generic OpenAI endpoint. */
registerGenericOpenAIModel(modelId: string): void {
this.genericOpenAIModelIds.add(modelId);
}
/** Check whether a model ID was registered as a generic OpenAI model. */
isGenericOpenAIModel(modelId: string): boolean {
return this.genericOpenAIModelIds.has(modelId);
}
/** Remove all registered generic OpenAI model IDs. */
clearGenericOpenAIModels(): void {
this.genericOpenAIModelIds.clear();
}
/** 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 };
}
/** Set capability overrides for a specific generic OpenAI model. */
setGenericOpenAIModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean }): void {
this.genericOpenAICapabilities.set(modelId, caps);
this.invalidateModelCache();
}
/** Get all stored generic OpenAI capability overrides. */
getAllGenericOpenAIModelCapabilities(): Record<string, { tools: boolean; vision: boolean }> {
const result: Record<string, { tools: boolean; vision: boolean }> = {};
for (const [id, caps] of this.genericOpenAICapabilities) {
result[id] = caps;
}
return result;
}
/** Load generic OpenAI capability overrides from serialized object. */
loadGenericOpenAIModelCapabilities(data: Record<string, { tools: boolean; vision: boolean }>): void {
this.genericOpenAICapabilities.clear();
for (const [id, caps] of Object.entries(data)) {
this.genericOpenAICapabilities.set(id, caps);
}
}
/** Check whether a generic OpenAI model has tools capability enabled. */
genericOpenAIModelSupportsTools(modelId: string): boolean {
return this.genericOpenAICapabilities.get(modelId)?.tools ?? false;
}
/** Check whether a generic OpenAI model has vision capability enabled. */
genericOpenAIModelSupportsVision(modelId: string): boolean {
return this.genericOpenAICapabilities.get(modelId)?.vision ?? false;
}
/** /**
* Detect the effective provider for a model ID, checking Ollama and LM Studio * Detect the effective provider for a model ID, checking Ollama, LM Studio,
* registration first, then falling back to prefix-based detection. * and generic OpenAI registration first, then falling back to prefix-based detection.
*/ */
detectModelProvider(modelId: string): string { detectModelProvider(modelId: string): string {
if (this.ollamaModelIds.has(modelId)) { if (this.ollamaModelIds.has(modelId)) {
@@ -308,6 +401,9 @@ export class ProviderRegistry {
if (this.lmstudioModelIds.has(modelId)) { if (this.lmstudioModelIds.has(modelId)) {
return 'lmstudio'; return 'lmstudio';
} }
if (this.genericOpenAIModelIds.has(modelId)) {
return 'generic-openai';
}
return detectProvider(modelId); return detectProvider(modelId);
} }
@@ -316,7 +412,7 @@ export class ProviderRegistry {
if (this._offlineMode) { if (this._offlineMode) {
return !!(this.ollamaEnabled || this.lmstudioEnabled); return !!(this.ollamaEnabled || this.lmstudioEnabled);
} }
return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled); return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled || this.genericOpenAIEnabled);
} }
/** Check whether the key for a specific provider is set. */ /** Check whether the key for a specific provider is set. */
@@ -327,6 +423,9 @@ export class ProviderRegistry {
if (provider === 'lmstudio') { if (provider === 'lmstudio') {
return this.lmstudioEnabled; return this.lmstudioEnabled;
} }
if (provider === 'generic-openai') {
return this.genericOpenAIEnabled && Boolean(this.genericOpenAIBaseURL);
}
// In offline mode, cloud providers are unavailable // In offline mode, cloud providers are unavailable
if (this._offlineMode) { if (this._offlineMode) {
return false; return false;
@@ -338,12 +437,13 @@ export class ProviderRegistry {
} }
/** Returns status of all configured providers. */ /** Returns status of all configured providers. */
getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean } { getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; genericOpenAI: boolean; offlineMode: boolean } {
return { return {
opencode: !!this.opencodeKey, opencode: !!this.opencodeKey,
mistral: !!this.mistralKey, mistral: !!this.mistralKey,
ollama: this.ollamaEnabled, ollama: this.ollamaEnabled,
lmstudio: this.lmstudioEnabled, lmstudio: this.lmstudioEnabled,
genericOpenAI: this.genericOpenAIEnabled,
offlineMode: this._offlineMode, offlineMode: this._offlineMode,
}; };
} }
@@ -385,6 +485,20 @@ export class ProviderRegistry {
return this.lmstudioProvider.chat(modelId); return this.lmstudioProvider.chat(modelId);
} }
// Check if this is a registered generic OpenAI model
if (this.genericOpenAIModelIds.has(modelId)) {
if (!this.genericOpenAIEnabled || !this.genericOpenAIBaseURL) {
throw new Error(`Generic OpenAI endpoint not configured for model '${modelId}'`);
}
if (!this.genericOpenAIProvider) {
this.genericOpenAIProvider = createOpenAI({
baseURL: this.genericOpenAIBaseURL,
apiKey: this.genericOpenAIApiKey || 'dummy-key',
});
}
return this.genericOpenAIProvider.chat(modelId);
}
const provider = detectProvider(modelId); const provider = detectProvider(modelId);
if (provider === 'mistral') { if (provider === 'mistral') {
@@ -544,6 +658,19 @@ export class ProviderRegistry {
} }
} }
// Fetch generic OpenAI-compatible endpoint models
if (this.genericOpenAIEnabled && this.genericOpenAIBaseURL && !this._offlineMode) {
try {
const models = await this.fetchGenericOpenAIModels();
allModels.push(...models);
if (models.length > 0) {
fetched = true;
}
} catch {
// Generic OpenAI endpoint not available — skip silently
}
}
if (fetched && allModels.length > 0) { if (fetched && allModels.length > 0) {
this.cachedModels = allModels; this.cachedModels = allModels;
this.cachedModelsAt = Date.now(); this.cachedModelsAt = Date.now();
@@ -678,6 +805,78 @@ export class ProviderRegistry {
} }
} }
// ---- Generic OpenAI-compatible endpoint model listing ----
/**
* Fetch available models from a generic OpenAI-compatible /v1/models endpoint.
* Returns ChatModel[] and registers the model IDs internally.
*/
async fetchGenericOpenAIModels(): Promise<ChatModel[]> {
const normalizedBaseURL = this.normalizeGenericOpenAIBaseURL(this.genericOpenAIBaseURL);
if (!normalizedBaseURL) {
return [];
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), GENERIC_OPENAI_FETCH_TIMEOUT);
const headers: Record<string, string> = {};
if (this.genericOpenAIApiKey) {
headers.Authorization = `Bearer ${this.genericOpenAIApiKey}`;
}
const response = await fetch(`${normalizedBaseURL}${GENERIC_OPENAI_MODELS_PATH}`, {
method: 'GET',
headers,
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return [];
}
const data = await response.json() as { data?: Array<{ id: string }> };
if (!data.data || !Array.isArray(data.data)) {
return [];
}
const models: ChatModel[] = data.data.map(m => ({
id: m.id,
name: m.id,
provider: 'generic-openai',
vision: this.genericOpenAIModelSupportsVision(m.id),
}));
// Only replace registered IDs on successful fetch
this.clearGenericOpenAIModels();
for (const m of models) {
this.registerGenericOpenAIModel(m.id);
}
return models;
} catch {
return [];
}
}
/**
* Validate generic OpenAI endpoint configuration by fetching models.
*/
async validateGenericOpenAIConfig(): Promise<{ isValid: boolean; models: ChatModel[]; error?: string }> {
if (!this.genericOpenAIBaseURL) {
return { isValid: false, models: [], error: 'Base URL is required' };
}
try {
const models = await this.fetchGenericOpenAIModels();
return { isValid: true, models };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { isValid: false, models: [], error: errorMessage };
}
}
// ---- Private helpers ---- // ---- Private helpers ----
private async fetchModelsFromEndpoint( private async fetchModelsFromEndpoint(
@@ -725,6 +924,14 @@ export class ProviderRegistry {
return { vision, names }; return { vision, names };
} }
private normalizeGenericOpenAIBaseURL(baseURL: string): string {
const trimmed = baseURL.trim().replace(/\/+$/, '');
if (!trimmed) {
return '';
}
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
}
private async getModelsFromCatalog(): Promise<ChatModel[]> { private async getModelsFromCatalog(): Promise<ChatModel[]> {
try { try {
const catalog = await this.modelCatalogEngine.getAll(); const catalog = await this.modelCatalogEngine.getAll();

View File

@@ -101,7 +101,7 @@ const SHARED_JS = `\
document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded"); document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded");
} }
const app = new App({ name: "bDS Review", version: "1.0.0" }); const app = new App({ name: "bDS Review", version: "1.0.1" });
let currentData = null; let currentData = null;

View File

@@ -132,6 +132,13 @@ async function ensureInitialized(): Promise<void> {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
try {
const genericOpenAIKey = await keyStore.retrieve('generic_openai_api_key');
if (genericOpenAIKey) {
reg.setGenericOpenAIApiKey(genericOpenAIKey);
}
} catch { /* ignore */ }
// Restore Ollama enabled state from settings DB // Restore Ollama enabled state from settings DB
try { try {
const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled'); const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled');
@@ -167,6 +174,21 @@ async function ensureInitialized(): Promise<void> {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// Restore generic OpenAI enabled state and base URL from settings DB
try {
const genericOpenAIEnabled = await getChatEngine().getSetting('generic_openai_enabled');
if (genericOpenAIEnabled === 'true') {
reg.setGenericOpenAIEnabled(true);
}
} catch { /* ignore */ }
try {
const genericOpenAIBaseURL = await getChatEngine().getSetting('generic_openai_base_url');
if (genericOpenAIBaseURL) {
reg.setGenericOpenAIBaseURL(genericOpenAIBaseURL);
}
} catch { /* ignore */ }
// Restore LM Studio model capability overrides // Restore LM Studio model capability overrides
try { try {
const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities'); const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities');
@@ -176,6 +198,15 @@ async function ensureInitialized(): Promise<void> {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// Restore generic OpenAI model capability overrides
try {
const genericCapsJson = await getChatEngine().getSetting('generic_openai_model_capabilities');
if (genericCapsJson) {
const caps = JSON.parse(genericCapsJson) as Record<string, { tools: boolean; vision: boolean }>;
reg.loadGenericOpenAIModelCapabilities(caps);
}
} catch { /* ignore */ }
// Restore known LM Studio model IDs (so offline mode works without a fresh fetch) // Restore known LM Studio model IDs (so offline mode works without a fresh fetch)
try { try {
const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids'); const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids');
@@ -186,6 +217,16 @@ async function ensureInitialized(): Promise<void> {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// Restore known generic OpenAI model IDs for provider routing before a refresh
try {
const genericIds = await getChatEngine().getSetting('generic_openai_known_model_ids');
if (genericIds) {
for (const id of JSON.parse(genericIds) as string[]) {
reg.registerGenericOpenAIModel(id);
}
}
} catch { /* ignore */ }
// Restore offline mode from settings or auto-detect via OS network status // Restore offline mode from settings or auto-detect via OS network status
try { try {
const savedOffline = await getChatEngine().getSetting('offline_mode'); const savedOffline = await getChatEngine().getSetting('offline_mode');
@@ -468,6 +509,147 @@ export function registerChatHandlers(): void {
} }
}); });
// ============ Generic OpenAI-compatible Endpoint ============
// Get generic OpenAI enabled state
ipcMain.handle('chat:getGenericOpenAIEnabled', async () => {
try {
await ensureInitialized();
return getProviders().isGenericOpenAIEnabled();
} catch (error) {
console.error('[Chat IPC] Error getting generic OpenAI enabled state:', error);
return false;
}
});
// Set generic OpenAI enabled state
ipcMain.handle('chat:setGenericOpenAIEnabled', async (_, enabled: boolean) => {
try {
await ensureInitialized();
const reg = getProviders();
reg.setGenericOpenAIEnabled(enabled);
await getChatEngine().setSetting('generic_openai_enabled', enabled ? 'true' : 'false');
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting generic OpenAI enabled state:', error);
return { success: false, error: (error as Error).message };
}
});
// Get generic OpenAI base URL
ipcMain.handle('chat:getGenericOpenAIBaseURL', async () => {
try {
await ensureInitialized();
return getProviders().getGenericOpenAIBaseURL();
} catch (error) {
console.error('[Chat IPC] Error getting generic OpenAI base URL:', error);
return '';
}
});
// Set generic OpenAI base URL
ipcMain.handle('chat:setGenericOpenAIBaseURL', async (_, baseURL: string) => {
try {
await ensureInitialized();
const reg = getProviders();
reg.setGenericOpenAIBaseURL(baseURL);
await getChatEngine().setSetting('generic_openai_base_url', baseURL);
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting generic OpenAI base URL:', error);
return { success: false, error: (error as Error).message };
}
});
// Get generic OpenAI API key (masked)
ipcMain.handle('chat:getGenericOpenAIApiKey', async () => {
try {
await ensureInitialized();
const key = getProviders().getGenericOpenAIApiKey();
if (!key) {
return { hasKey: false, maskedKey: '' };
}
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
return { hasKey: true, maskedKey: masked };
} catch (error) {
console.error('[Chat IPC] Error getting generic OpenAI API key:', error);
return { hasKey: false, maskedKey: '' };
}
});
// Set generic OpenAI API key
ipcMain.handle('chat:setGenericOpenAIApiKey', async (_, apiKey: string) => {
try {
await ensureInitialized();
const reg = getProviders();
const previousKey = reg.getGenericOpenAIApiKey();
reg.setGenericOpenAIApiKey(apiKey);
// Persist to encrypted storage — roll back in-memory key on failure
try {
await getSecureKeyStore().store('generic_openai_api_key', apiKey);
} catch (storeError) {
reg.setGenericOpenAIApiKey(previousKey);
throw storeError;
}
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting generic OpenAI API key:', error);
return { success: false, error: (error as Error).message };
}
});
// Validate generic OpenAI configuration
ipcMain.handle('chat:validateGenericOpenAIConfig', async () => {
try {
await ensureInitialized();
return await getProviders().validateGenericOpenAIConfig();
} catch (error) {
console.error('[Chat IPC] Error validating generic OpenAI config:', error);
return { isValid: false, models: [], error: (error as Error).message };
}
});
// Fetch generic OpenAI models
ipcMain.handle('chat:getGenericOpenAIModels', async () => {
try {
await ensureInitialized();
return await getProviders().fetchGenericOpenAIModels();
} catch (error) {
console.error('[Chat IPC] Error fetching generic OpenAI models:', error);
return [];
}
});
// Get generic OpenAI model capability overrides
ipcMain.handle('chat:getGenericOpenAIModelCapabilities', async () => {
try {
await ensureInitialized();
return getProviders().getAllGenericOpenAIModelCapabilities();
} catch (error) {
console.error('[Chat IPC] Error getting generic OpenAI model capabilities:', error);
return {};
}
});
// Set capability override for a single generic OpenAI model
ipcMain.handle('chat:setGenericOpenAIModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean }) => {
try {
await ensureInitialized();
const reg = getProviders();
reg.setGenericOpenAIModelCapabilities(modelId, caps);
// Persist all capabilities to settings DB
const allCaps = reg.getAllGenericOpenAIModelCapabilities();
await getChatEngine().setSetting('generic_openai_model_capabilities', JSON.stringify(allCaps));
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting generic OpenAI model capabilities:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ Offline / Airplane Mode ============ // ============ Offline / Airplane Mode ============
ipcMain.handle('chat:getOfflineMode', async () => { ipcMain.handle('chat:getOfflineMode', async () => {
@@ -627,12 +809,16 @@ export function registerChatHandlers(): void {
// Persist known local model IDs so offline mode survives restarts // Persist known local model IDs so offline mode survives restarts
const ollamaModels = models.filter(m => m.provider === 'ollama').map(m => m.id); const ollamaModels = models.filter(m => m.provider === 'ollama').map(m => m.id);
const lmstudioModels = models.filter(m => m.provider === 'lmstudio').map(m => m.id); const lmstudioModels = models.filter(m => m.provider === 'lmstudio').map(m => m.id);
const genericOpenAIModels = models.filter(m => m.provider === 'generic-openai').map(m => m.id);
if (ollamaModels.length > 0) { if (ollamaModels.length > 0) {
await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {}); await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {});
} }
if (lmstudioModels.length > 0) { if (lmstudioModels.length > 0) {
await engine.setSetting('lmstudio_known_model_ids', JSON.stringify(lmstudioModels)).catch(() => {}); await engine.setSetting('lmstudio_known_model_ids', JSON.stringify(lmstudioModels)).catch(() => {});
} }
if (genericOpenAIModels.length > 0) {
await engine.setSetting('generic_openai_known_model_ids', JSON.stringify(genericOpenAIModels)).catch(() => {});
}
return { success: true, models, selectedModel }; return { success: true, models, selectedModel };
} catch (error) { } catch (error) {

View File

@@ -357,6 +357,18 @@ export const electronAPI: ElectronAPI = {
getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'), getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'),
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setLmstudioModelCapabilities', modelId, caps), setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setLmstudioModelCapabilities', modelId, caps),
// Generic OpenAI-compatible Endpoint
getGenericOpenAIEnabled: () => ipcRenderer.invoke('chat:getGenericOpenAIEnabled'),
setGenericOpenAIEnabled: (enabled: boolean) => ipcRenderer.invoke('chat:setGenericOpenAIEnabled', enabled),
getGenericOpenAIBaseURL: () => ipcRenderer.invoke('chat:getGenericOpenAIBaseURL'),
setGenericOpenAIBaseURL: (baseURL: string) => ipcRenderer.invoke('chat:setGenericOpenAIBaseURL', baseURL),
getGenericOpenAIApiKey: () => ipcRenderer.invoke('chat:getGenericOpenAIApiKey'),
setGenericOpenAIApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setGenericOpenAIApiKey', apiKey),
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),
// Offline / Airplane Mode // Offline / Airplane Mode
getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'), getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'),
setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled), setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled),

View File

@@ -495,7 +495,7 @@ export interface ChatReadyStatus {
ready: boolean; ready: boolean;
error?: string; error?: string;
backend?: string; backend?: string;
providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean }; providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; genericOpenAI: boolean; offlineMode: boolean };
} }
export interface ChatApiKeyStatus { export interface ChatApiKeyStatus {
@@ -1022,6 +1022,18 @@ export interface ElectronAPI {
getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>; getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
// Generic OpenAI-compatible endpoint
getGenericOpenAIEnabled: () => Promise<boolean>;
setGenericOpenAIEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
getGenericOpenAIBaseURL: () => Promise<string>;
setGenericOpenAIBaseURL: (baseURL: string) => Promise<{ success: boolean; error?: string }>;
getGenericOpenAIApiKey: () => Promise<ChatApiKeyStatus>;
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 }>;
// Offline / Airplane mode // Offline / Airplane mode
getOfflineMode: () => Promise<boolean>; getOfflineMode: () => Promise<boolean>;
setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>; setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;

View File

@@ -253,6 +253,13 @@ export const SettingsView: React.FC = () => {
const [lmstudioEnabled, setLmstudioEnabled] = useState(false); const [lmstudioEnabled, setLmstudioEnabled] = useState(false);
const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({}); const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
const [lmstudioModels, setLmstudioModels] = useState<{id: string; name: string}[]>([]); const [lmstudioModels, setLmstudioModels] = useState<{id: string; name: string}[]>([]);
const [genericOpenAIEnabled, setGenericOpenAIEnabled] = useState(false);
const [genericOpenAIBaseURL, setGenericOpenAIBaseURL] = useState('');
const [genericOpenAIApiKeyMasked, setGenericOpenAIApiKeyMasked] = useState('');
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 [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('');
@@ -464,6 +471,33 @@ export const SettingsView: React.FC = () => {
if (lmModels) setLmstudioModels(lmModels.map(m => ({ id: m.id, name: m.name }))); if (lmModels) setLmstudioModels(lmModels.map(m => ({ id: m.id, name: m.name })));
} }
// Load generic OpenAI enabled state
const genericOpenAIState = await window.electronAPI?.chat.getGenericOpenAIEnabled();
setGenericOpenAIEnabled(!!genericOpenAIState);
// Load generic OpenAI base URL
const genericOpenAIBaseURLResult = await window.electronAPI?.chat.getGenericOpenAIBaseURL();
if (genericOpenAIBaseURLResult) {
setGenericOpenAIBaseURL(genericOpenAIBaseURLResult);
}
// Load generic OpenAI API key status
const genericOpenAIApiKeyResult = await window.electronAPI?.chat.getGenericOpenAIApiKey();
if (genericOpenAIApiKeyResult) {
setHasGenericOpenAIApiKey(genericOpenAIApiKeyResult.hasKey);
setGenericOpenAIApiKeyMasked(genericOpenAIApiKeyResult.maskedKey || '');
}
// Load generic OpenAI model capabilities and models list
if (genericOpenAIState) {
const [genCaps, genModels] = await Promise.all([
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
window.electronAPI?.chat.getGenericOpenAIModels(),
]);
if (genCaps) setGenericOpenAICapabilities(genCaps);
if (genModels) setGenericOpenAIModels(genModels.map(m => ({ id: m.id, name: m.name })));
}
// Load per-purpose model preferences // Load per-purpose model preferences
const titleModelResult = await window.electronAPI?.chat.getTitleModel(); const titleModelResult = await window.electronAPI?.chat.getTitleModel();
if (titleModelResult?.success && titleModelResult.modelId) { if (titleModelResult?.success && titleModelResult.modelId) {
@@ -1340,6 +1374,116 @@ export const SettingsView: React.FC = () => {
} }
}; };
// Generic OpenAI handlers
const handleGenericOpenAIToggle = async (enabled: boolean) => {
try {
const result = await window.electronAPI?.chat.setGenericOpenAIEnabled(enabled);
if (result?.success) {
setGenericOpenAIEnabled(enabled);
showToast.success(t(enabled ? 'settings.toast.genericOpenAIEnabled' : 'settings.toast.genericOpenAIDisabled'));
// Refresh models after toggle
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
// Load generic OpenAI models and capabilities when enabling
if (enabled) {
const [caps, genModelsList] = await Promise.all([
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
window.electronAPI?.chat.getGenericOpenAIModels(),
]);
if (caps) setGenericOpenAICapabilities(caps);
if (genModelsList) setGenericOpenAIModels(genModelsList.map(m => ({ id: m.id, name: m.name })));
} else {
setGenericOpenAIModels([]);
}
}
} catch (error) {
console.error('Failed to toggle generic OpenAI:', error);
}
};
const handleSaveGenericOpenAIBaseURL = async () => {
try {
const result = await window.electronAPI?.chat.setGenericOpenAIBaseURL(genericOpenAIBaseURL);
if (!result?.success) {
throw new Error(result?.error || 'Failed to save generic OpenAI base URL');
}
const storedBaseURL = await window.electronAPI?.chat.getGenericOpenAIBaseURL();
if (typeof storedBaseURL === 'string') {
setGenericOpenAIBaseURL(storedBaseURL);
}
const [caps, genModelsList, modelsResult] = await Promise.all([
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
window.electronAPI?.chat.getGenericOpenAIModels(),
window.electronAPI?.chat.getAvailableModels(),
]);
setGenericOpenAICapabilities(caps || {});
setGenericOpenAIModels((genModelsList || []).map(m => ({ id: m.id, name: m.name })));
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
showToast.success(t('settings.toast.genericOpenAISettingsSaved'));
} catch (error) {
console.error('Failed to save generic OpenAI base URL:', error);
showToast.error(t('settings.toast.genericOpenAISettingsSaveFailed'));
}
};
const handleSaveGenericOpenAIApiKey = async () => {
if (!newGenericOpenAIApiKey.trim()) return;
try {
const trimmedKey = newGenericOpenAIApiKey.trim();
const result = await window.electronAPI?.chat.setGenericOpenAIApiKey(trimmedKey);
if (!result?.success) {
throw new Error(result?.error || 'Failed to save generic OpenAI API key');
}
setHasGenericOpenAIApiKey(true);
setGenericOpenAIApiKeyMasked('•'.repeat(Math.max(0, trimmedKey.length - 4)) + trimmedKey.slice(-4));
setNewGenericOpenAIApiKey('');
showToast.success(t('settings.toast.apiKeySaved'));
const [caps, genModelsList, modelsResult] = await Promise.all([
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
window.electronAPI?.chat.getGenericOpenAIModels(),
window.electronAPI?.chat.getAvailableModels(),
]);
setGenericOpenAICapabilities(caps || {});
setGenericOpenAIModels((genModelsList || []).map(m => ({ id: m.id, name: m.name })));
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
} catch (error) {
console.error('Failed to save generic OpenAI API key:', error);
showToast.error(t('settings.toast.apiKeySaveFailed'));
}
};
const handleGenericOpenAICapabilityToggle = async (modelId: string, field: 'tools' | 'vision', value: boolean) => {
const current = genericOpenAICapabilities[modelId] ?? { tools: false, vision: false };
const updated = { ...current, [field]: value };
try {
const result = await window.electronAPI?.chat.setGenericOpenAIModelCapabilities(modelId, updated);
if (result?.success) {
setGenericOpenAICapabilities(prev => ({ ...prev, [modelId]: updated }));
// Refresh available models to reflect vision change
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
}
}
} catch (error) {
console.error('Failed to update generic OpenAI model capabilities:', error);
}
};
const handleTitleModelChange = async (modelId: string) => { const handleTitleModelChange = async (modelId: string) => {
try { try {
const result = await window.electronAPI?.chat.setTitleModel(modelId); const result = await window.electronAPI?.chat.setTitleModel(modelId);
@@ -1488,6 +1632,7 @@ export const SettingsView: React.FC = () => {
if (provider === 'mistral') return t('settings.ai.providerMistral'); if (provider === 'mistral') return t('settings.ai.providerMistral');
if (provider === 'ollama') return t('settings.ai.providerOllama'); if (provider === 'ollama') return t('settings.ai.providerOllama');
if (provider === 'lmstudio') return t('settings.ai.providerLmstudio'); if (provider === 'lmstudio') return t('settings.ai.providerLmstudio');
if (provider === 'generic-openai') return t('settings.ai.providerGenericOpenAI');
return provider; return provider;
}; };
@@ -1716,6 +1861,117 @@ export const SettingsView: React.FC = () => {
)} )}
</SettingRow> </SettingRow>
<SettingRow
id="ai-generic-openai"
label={t('settings.ai.genericOpenAILabel')}
description={t('settings.ai.genericOpenOIDescription')}
>
<div className="setting-input-group">
<label className="toggle-label">
<input
id="ai-generic-openai"
type="checkbox"
checked={genericOpenAIEnabled}
onChange={(e) => handleGenericOpenAIToggle(e.target.checked)}
/>
{t('settings.ai.genericOpenAIEnable')}
</label>
{genericOpenAIEnabled && (
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
)}
</div>
{genericOpenAIEnabled && (
<div className="generic-openai-settings">
<div className="setting-field">
<label htmlFor="ai-generic-openai-base-url">{t('settings.ai.genericOpenAIBaseUrlLabel')}</label>
<small className="setting-description">{t('settings.ai.genericOpenAIBaseUrlDescription')}</small>
<input
id="ai-generic-openai-base-url"
type="text"
value={genericOpenAIBaseURL}
onChange={(e) => setGenericOpenAIBaseURL(e.target.value)}
placeholder="https://api.example.com/v1"
/>
<button className="secondary" onClick={handleSaveGenericOpenAIBaseURL}>
{t('common.save')}
</button>
</div>
<div className="setting-field">
<label htmlFor="ai-generic-openai-api-key">{t('settings.ai.genericOpenAIApiKeyLabel')}</label>
<small className="setting-description">{t('settings.ai.genericOpenAIApiKeyDescription')}</small>
{hasGenericOpenAIApiKey ? (
<>
<input
id="ai-generic-openai-api-key"
type="text"
value={genericOpenAIApiKeyMasked}
disabled
placeholder={t('settings.ai.genericOpenAIApiKeyConfigured')}
/>
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
<div className="setting-inline-action">
<button className="text-button" onClick={() => { setHasGenericOpenAIApiKey(false); setGenericOpenAIApiKeyMasked(''); }}>
{t('settings.ai.changeApiKey')}
</button>
</div>
</>
) : (
<>
<input
id="ai-generic-openai-api-key"
type="password"
value={newGenericOpenAIApiKey}
onChange={(e) => setNewGenericOpenAIApiKey(e.target.value)}
placeholder={t('chat.apiKeyPlaceholder')}
/>
<button className="primary" onClick={handleSaveGenericOpenAIApiKey} disabled={!newGenericOpenAIApiKey.trim()}>
{t('chat.apiKeySave')}
</button>
</>
)}
</div>
{genericOpenAIEnabled && genericOpenAIModels.length > 0 && (
<div className="generic-openai-model-capabilities">
<small className="setting-description">{t('settings.ai.genericOpenAICapabilitiesDescription')}</small>
<table className="generic-openai-caps-table">
<thead>
<tr>
<th>{t('settings.ai.genericOpenAICapModel')}</th>
<th>{t('settings.ai.genericOpenAICapTools')}</th>
<th>{t('settings.ai.genericOpenAICapVision')}</th>
</tr>
</thead>
<tbody>
{genericOpenAIModels.map(m => {
const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false };
return (
<tr key={m.id}>
<td>{m.name}</td>
<td>
<input
type="checkbox"
checked={caps.tools}
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'tools', e.target.checked)}
/>
</td>
<td>
<input
type="checkbox"
checked={caps.vision}
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'vision', e.target.checked)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</SettingRow>
<SettingRow <SettingRow
id="ai-offline" id="ai-offline"
label={t('settings.ai.offlineLabel')} label={t('settings.ai.offlineLabel')}

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Lokal)", "settings.ai.providerOllama": "Ollama (Lokal)",
"settings.ai.providerLmstudio": "LM Studio (Lokal)", "settings.ai.providerLmstudio": "LM Studio (Lokal)",
"settings.ai.providerGenericOpenAI": "Generischer OpenAI-Endpunkt",
"settings.ai.providerOther": "Andere", "settings.ai.providerOther": "Andere",
"settings.ai.ollamaLabel": "Ollama (Lokale Modelle)", "settings.ai.ollamaLabel": "Ollama (Lokale Modelle)",
"settings.ai.ollamaDescription": "Verbinde dich mit einer lokal laufenden Ollama-Instanz, um lokale KI-Modelle zu verwenden.", "settings.ai.ollamaDescription": "Verbinde dich mit einer lokal laufenden Ollama-Instanz, um lokale KI-Modelle zu verwenden.",
@@ -871,6 +872,22 @@
"settings.ai.lmstudioCapVision": "Vision", "settings.ai.lmstudioCapVision": "Vision",
"settings.toast.lmstudioEnabled": "LM Studio aktiviert", "settings.toast.lmstudioEnabled": "LM Studio aktiviert",
"settings.toast.lmstudioDisabled": "LM Studio deaktiviert", "settings.toast.lmstudioDisabled": "LM Studio deaktiviert",
"settings.ai.genericOpenAILabel": "Generischer OpenAI-kompatibler Endpunkt",
"settings.ai.genericOpenOIDescription": "Konfiguriere einen benutzerdefinierten OpenAI-kompatiblen API-Endpunkt (z.B. vLLM, Ollama-Gateway, LiteLLM). Modelle werden von diesem Endpunkt bezogen.",
"settings.ai.genericOpenAIEnable": "Generischen OpenAI-Endpunkt aktivieren",
"settings.ai.genericOpenAIBaseUrlLabel": "Basis-URL",
"settings.ai.genericOpenAIBaseUrlDescription": "Die Basis-URL des OpenAI-kompatiblen API-Endpunkts (z.B. http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "API-Schlüssel",
"settings.ai.genericOpenAIApiKeyDescription": "API-Schlüssel für den Endpunkt. Leer lassen, wenn nicht erforderlich.",
"settings.ai.genericOpenAIApiKeyConfigured": "API-Schlüssel konfiguriert",
"settings.ai.genericOpenAICapabilitiesDescription": "Fähigkeiten für jedes Modell von diesem Endpunkt konfigurieren. Tools für Funktionsaufrufe oder Vision für Bildanalyse aktivieren.",
"settings.ai.genericOpenAICapModel": "Modell",
"settings.ai.genericOpenAICapTools": "Tools",
"settings.ai.genericOpenAICapVision": "Vision",
"settings.toast.genericOpenAIEnabled": "Generischer OpenAI-Endpunkt aktiviert",
"settings.toast.genericOpenAIDisabled": "Generischer OpenAI-Endpunkt deaktiviert",
"settings.toast.genericOpenAISettingsSaved": "Generische OpenAI-Einstellungen gespeichert",
"settings.toast.genericOpenAISettingsSaveFailed": "Generische OpenAI-Einstellungen konnten nicht gespeichert werden",
"settings.ai.offlineLabel": "Flugmodus", "settings.ai.offlineLabel": "Flugmodus",
"settings.ai.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.", "settings.ai.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.",
"settings.ai.offlineEnable": "Flugmodus aktivieren", "settings.ai.offlineEnable": "Flugmodus aktivieren",

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Local)", "settings.ai.providerOllama": "Ollama (Local)",
"settings.ai.providerLmstudio": "LM Studio (Local)", "settings.ai.providerLmstudio": "LM Studio (Local)",
"settings.ai.providerGenericOpenAI": "Generic OpenAI Endpoint",
"settings.ai.providerOther": "Other", "settings.ai.providerOther": "Other",
"settings.ai.ollamaLabel": "Ollama (Local Models)", "settings.ai.ollamaLabel": "Ollama (Local Models)",
"settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.", "settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.",
@@ -871,6 +872,22 @@
"settings.ai.lmstudioCapVision": "Vision", "settings.ai.lmstudioCapVision": "Vision",
"settings.toast.lmstudioEnabled": "LM Studio enabled", "settings.toast.lmstudioEnabled": "LM Studio enabled",
"settings.toast.lmstudioDisabled": "LM Studio disabled", "settings.toast.lmstudioDisabled": "LM Studio disabled",
"settings.ai.genericOpenAILabel": "Generic OpenAI-Compatible Endpoint",
"settings.ai.genericOpenOIDescription": "Configure a custom OpenAI-compatible API endpoint (e.g., vLLM, Ollama gateway, LiteLLM). Models will be fetched from this endpoint.",
"settings.ai.genericOpenAIEnable": "Enable Generic OpenAI Endpoint",
"settings.ai.genericOpenAIBaseUrlLabel": "Base URL",
"settings.ai.genericOpenAIBaseUrlDescription": "The base URL of the OpenAI-compatible API endpoint (e.g., http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "API Key",
"settings.ai.genericOpenAIApiKeyDescription": "API key for the endpoint. Leave empty if not required.",
"settings.ai.genericOpenAIApiKeyConfigured": "API key configured",
"settings.ai.genericOpenAICapabilitiesDescription": "Configure capabilities for each model from this endpoint. Enable tools for function calling or vision for image analysis.",
"settings.ai.genericOpenAICapModel": "Model",
"settings.ai.genericOpenAICapTools": "Tools",
"settings.ai.genericOpenAICapVision": "Vision",
"settings.toast.genericOpenAIEnabled": "Generic OpenAI endpoint enabled",
"settings.toast.genericOpenAIDisabled": "Generic OpenAI endpoint disabled",
"settings.toast.genericOpenAISettingsSaved": "Generic OpenAI settings saved",
"settings.toast.genericOpenAISettingsSaveFailed": "Failed to save generic OpenAI settings",
"settings.ai.offlineLabel": "Airplane Mode", "settings.ai.offlineLabel": "Airplane Mode",
"settings.ai.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.", "settings.ai.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.",
"settings.ai.offlineEnable": "Enable Airplane Mode", "settings.ai.offlineEnable": "Enable Airplane Mode",

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Local)", "settings.ai.providerOllama": "Ollama (Local)",
"settings.ai.providerLmstudio": "LM Studio (Local)", "settings.ai.providerLmstudio": "LM Studio (Local)",
"settings.ai.providerGenericOpenAI": "Endpoint Genérico OpenAI",
"settings.ai.providerOther": "Otro", "settings.ai.providerOther": "Otro",
"settings.ai.ollamaLabel": "Ollama (Modelos locales)", "settings.ai.ollamaLabel": "Ollama (Modelos locales)",
"settings.ai.ollamaDescription": "Conéctate a una instancia local de Ollama para usar modelos de IA locales.", "settings.ai.ollamaDescription": "Conéctate a una instancia local de Ollama para usar modelos de IA locales.",
@@ -871,6 +872,22 @@
"settings.ai.lmstudioCapVision": "Visión", "settings.ai.lmstudioCapVision": "Visión",
"settings.toast.lmstudioEnabled": "LM Studio activado", "settings.toast.lmstudioEnabled": "LM Studio activado",
"settings.toast.lmstudioDisabled": "LM Studio desactivado", "settings.toast.lmstudioDisabled": "LM Studio desactivado",
"settings.ai.genericOpenAILabel": "Endpoint Genérico Compatible con OpenAI",
"settings.ai.genericOpenOIDescription": "Configura un endpoint de API compatible con OpenAI personalizado (por ejemplo, vLLM, gateway Ollama, LiteLLM). Los modelos se obtendrán de este endpoint.",
"settings.ai.genericOpenAIEnable": "Habilitar Endpoint Genérico OpenAI",
"settings.ai.genericOpenAIBaseUrlLabel": "URL base",
"settings.ai.genericOpenAIBaseUrlDescription": "La URL base del endpoint de API compatible con OpenAI (por ejemplo, http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "Clave API",
"settings.ai.genericOpenAIApiKeyDescription": "Clave API para el endpoint. Déjela vacía si no es necesaria.",
"settings.ai.genericOpenAIApiKeyConfigured": "Clave API configurada",
"settings.ai.genericOpenAICapabilitiesDescription": "Configure las capacidades para cada modelo de este endpoint. Habilite herramientas para llamadas de funciones o visión para análisis de imágenes.",
"settings.ai.genericOpenAICapModel": "Modelo",
"settings.ai.genericOpenAICapTools": "Herramientas",
"settings.ai.genericOpenAICapVision": "Visión",
"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",
"settings.toast.genericOpenAISettingsSaveFailed": "Error al guardar la configuración genérica de OpenAI",
"settings.ai.offlineLabel": "Modo avión", "settings.ai.offlineLabel": "Modo avión",
"settings.ai.offlineDescription": "Cuando está activado, solo se usan modelos alojados localmente (Ollama, LM Studio). Los proveedores en la nube se desactivan.", "settings.ai.offlineDescription": "Cuando está activado, solo se usan modelos alojados localmente (Ollama, LM Studio). Los proveedores en la nube se desactivan.",
"settings.ai.offlineEnable": "Activar modo avión", "settings.ai.offlineEnable": "Activar modo avión",

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Local)", "settings.ai.providerOllama": "Ollama (Local)",
"settings.ai.providerLmstudio": "LM Studio (Local)", "settings.ai.providerLmstudio": "LM Studio (Local)",
"settings.ai.providerGenericOpenAI": "Point de terminaison OpenAI générique",
"settings.ai.providerOther": "Autre", "settings.ai.providerOther": "Autre",
"settings.ai.ollamaLabel": "Ollama (Modèles locaux)", "settings.ai.ollamaLabel": "Ollama (Modèles locaux)",
"settings.ai.ollamaDescription": "Connectez-vous à une instance Ollama locale pour utiliser des modèles d'IA locaux.", "settings.ai.ollamaDescription": "Connectez-vous à une instance Ollama locale pour utiliser des modèles d'IA locaux.",
@@ -871,6 +872,22 @@
"settings.ai.lmstudioCapVision": "Vision", "settings.ai.lmstudioCapVision": "Vision",
"settings.toast.lmstudioEnabled": "LM Studio activé", "settings.toast.lmstudioEnabled": "LM Studio activé",
"settings.toast.lmstudioDisabled": "LM Studio désactivé", "settings.toast.lmstudioDisabled": "LM Studio désactivé",
"settings.ai.genericOpenAILabel": "Point de terminaison OpenAI générique",
"settings.ai.genericOpenOIDescription": "Configurez un point de terminaison d'API OpenAI compatible personnalisé (par exemple, vLLM, passerelle Ollama, LiteLLM). Les modèles seront récupérés depuis ce point de terminaison.",
"settings.ai.genericOpenAIEnable": "Activer le point de terminaison OpenAI générique",
"settings.ai.genericOpenAIBaseUrlLabel": "URL de base",
"settings.ai.genericOpenAIBaseUrlDescription": "L'URL de base du point de terminaison d'API compatible OpenAI (par exemple, http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "Clé API",
"settings.ai.genericOpenAIApiKeyDescription": "Clé API pour le point de terminaison. Laissez vide si non requise.",
"settings.ai.genericOpenAIApiKeyConfigured": "Clé API configurée",
"settings.ai.genericOpenAICapabilitiesDescription": "Configurez les capacités pour chaque modèle de ce point de terminaison. Activez les outils pour l'appel de fonctions ou la vision pour l'analyse d'images.",
"settings.ai.genericOpenAICapModel": "Modèle",
"settings.ai.genericOpenAICapTools": "Outils",
"settings.ai.genericOpenAICapVision": "Vision",
"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",
"settings.toast.genericOpenAISettingsSaveFailed": "Échec de l'enregistrement des paramètres OpenAI génériques",
"settings.ai.offlineLabel": "Mode avion", "settings.ai.offlineLabel": "Mode avion",
"settings.ai.offlineDescription": "Lorsqu'il est activé, seuls les modèles hébergés localement (Ollama, LM Studio) sont utilisés. Les fournisseurs cloud sont désactivés.", "settings.ai.offlineDescription": "Lorsqu'il est activé, seuls les modèles hébergés localement (Ollama, LM Studio) sont utilisés. Les fournisseurs cloud sont désactivés.",
"settings.ai.offlineEnable": "Activer le mode avion", "settings.ai.offlineEnable": "Activer le mode avion",

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Locale)", "settings.ai.providerOllama": "Ollama (Locale)",
"settings.ai.providerLmstudio": "LM Studio (Locale)", "settings.ai.providerLmstudio": "LM Studio (Locale)",
"settings.ai.providerGenericOpenAI": "Endpoint Generico OpenAI",
"settings.ai.providerOther": "Altro", "settings.ai.providerOther": "Altro",
"settings.ai.ollamaLabel": "Ollama (Modelli locali)", "settings.ai.ollamaLabel": "Ollama (Modelli locali)",
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.", "settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
@@ -871,6 +872,22 @@
"settings.ai.lmstudioCapVision": "Visione", "settings.ai.lmstudioCapVision": "Visione",
"settings.toast.lmstudioEnabled": "LM Studio attivato", "settings.toast.lmstudioEnabled": "LM Studio attivato",
"settings.toast.lmstudioDisabled": "LM Studio disattivato", "settings.toast.lmstudioDisabled": "LM Studio disattivato",
"settings.ai.genericOpenAILabel": "Endpoint Generico Compatibile con OpenAI",
"settings.ai.genericOpenOIDescription": "Configura un endpoint API personalizzato compatibile con OpenAI (ad esempio, vLLM, gateway Ollama, LiteLLM). I modelli verranno recuperati da questo endpoint.",
"settings.ai.genericOpenAIEnable": "Abilita Endpoint Generico OpenAI",
"settings.ai.genericOpenAIBaseUrlLabel": "URL di base",
"settings.ai.genericOpenAIBaseUrlDescription": "L'URL di base dell'endpoint API compatibile con OpenAI (ad esempio, http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "Chiave API",
"settings.ai.genericOpenAIApiKeyDescription": "Chiave API per l'endpoint. Lascia vuoto se non richiesta.",
"settings.ai.genericOpenAIApiKeyConfigured": "Chiave API configurata",
"settings.ai.genericOpenAICapabilitiesDescription": "Configura le capacità per ogni modello da questo endpoint. Abilita gli strumenti per le chiamate alle funzioni o la visione per l'analisi delle immagini.",
"settings.ai.genericOpenAICapModel": "Modello",
"settings.ai.genericOpenAICapTools": "Strumenti",
"settings.ai.genericOpenAICapVision": "Visione",
"settings.toast.genericOpenAIEnabled": "Endpoint generico OpenAI abilitato",
"settings.toast.genericOpenAIDisabled": "Endpoint generico OpenAI disabilitato",
"settings.toast.genericOpenAISettingsSaved": "Impostazioni generiche OpenAI salvate",
"settings.toast.genericOpenAISettingsSaveFailed": "Impossibile salvare le impostazioni generiche OpenAI",
"settings.ai.offlineLabel": "Modalità aereo", "settings.ai.offlineLabel": "Modalità aereo",
"settings.ai.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.", "settings.ai.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.",
"settings.ai.offlineEnable": "Attiva modalità aereo", "settings.ai.offlineEnable": "Attiva modalità aereo",

View File

@@ -140,15 +140,15 @@ describe('ProviderRegistry', () => {
}); });
it('getProviderStatus() reports all providers', () => { it('getProviderStatus() reports all providers', () => {
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
registry.setOpencodeKey('test'); registry.setOpencodeKey('test');
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
registry.setMistralKey('test2'); registry.setMistralKey('test2');
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
registry.setOllamaEnabled(true); registry.setOllamaEnabled(true);
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, genericOpenAI: false, offlineMode: false });
registry.setLmstudioEnabled(true); registry.setLmstudioEnabled(true);
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, genericOpenAI: false, offlineMode: false });
}); });
it('isProviderKeySet() checks per-provider', () => { it('isProviderKeySet() checks per-provider', () => {

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
mockGenerateText: vi.fn(),
}));
vi.mock('ai', async () => {
const actual = await vi.importActual<typeof import('ai')>('ai');
return {
...actual,
streamText: mockStreamText,
generateText: mockGenerateText,
stepCountIs: vi.fn(() => undefined),
};
});
vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({
ModelCatalogEngine: class {
getAll = vi.fn().mockResolvedValue([]);
getContextWindow = vi.fn().mockResolvedValue(8192);
},
}));
import { ChatService } from '../../src/main/engine/ai/chat';
import { ProviderRegistry } from '../../src/main/engine/ai/providers';
function createChatEngine() {
return {
getConversation: vi.fn(async () => ({
id: 'conv-1',
title: 'Untitled',
model: 'generic-model',
createdAt: new Date(),
messages: [],
})),
addMessage: vi.fn(async () => undefined),
getDefaultSystemPrompt: vi.fn(async () => 'You are a helpful assistant'),
getSetting: vi.fn(async (key: string) => {
if (key === 'chat_title_model') {
return 'generic-model';
}
return null;
}),
updateConversation: vi.fn(async () => undefined),
} as any;
}
describe('ChatService generic OpenAI endpoint support', () => {
let registry: ProviderRegistry;
let chatEngine: ReturnType<typeof createChatEngine>;
beforeEach(() => {
vi.clearAllMocks();
mockStreamText.mockResolvedValue({
response: Promise.resolve(),
usage: Promise.resolve(undefined),
text: Promise.resolve('assistant reply'),
});
mockGenerateText.mockResolvedValue({ text: 'Generic Title' });
registry = new ProviderRegistry();
registry.setGenericOpenAIEnabled(true);
registry.setGenericOpenAIBaseURL('http://localhost:4000/v1');
registry.registerGenericOpenAIModel('generic-model');
vi.spyOn(registry, 'resolveModel').mockReturnValue({ modelId: 'generic-model' } as any);
chatEngine = createChatEngine();
});
it('skips tools for generic models when tools capability is disabled', async () => {
registry.setGenericOpenAIModelCapabilities('generic-model', { tools: false, vision: false });
const service = new ChatService(chatEngine, registry, {
postEngine: {} as any,
mediaEngine: {} as any,
postMediaEngine: {} as any,
}, () => null);
const result = await service.sendMessage('conv-1', 'Hello');
expect(result.success).toBe(true);
expect(mockStreamText).toHaveBeenCalledWith(expect.objectContaining({
tools: undefined,
}));
});
it('generates a title with the configured generic endpoint title model', async () => {
registry.setGenericOpenAIModelCapabilities('generic-model', { tools: false, vision: false });
const service = new ChatService(chatEngine, registry, {
postEngine: {} as any,
mediaEngine: {} as any,
postMediaEngine: {} as any,
}, () => null);
await (service as any).generateConversationTitle('conv-1', 'Hello');
expect(mockGenerateText).toHaveBeenCalledWith(expect.objectContaining({
model: expect.anything(),
prompt: 'Topic: Hello',
}));
expect(chatEngine.updateConversation).toHaveBeenCalledWith('conv-1', { title: 'Generic Title' });
});
});

View File

@@ -0,0 +1,58 @@
/**
* Tests for generic OpenAI-compatible endpoint support in ProviderRegistry.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProviderRegistry } from '../../src/main/engine/ai/providers';
vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({
ModelCatalogEngine: class {
getAll = vi.fn().mockResolvedValue([]);
getContextWindow = vi.fn().mockResolvedValue(null);
},
}));
describe('generic OpenAI-compatible provider support', () => {
let registry: ProviderRegistry;
beforeEach(() => {
registry = new ProviderRegistry();
});
it('fetchGenericOpenAIModels does not duplicate the v1 path when base URL already ends with /v1', async () => {
registry.setGenericOpenAIEnabled(true);
registry.setGenericOpenAIBaseURL('http://localhost:4000/v1');
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: 'custom-model' }],
}),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
const models = await registry.fetchGenericOpenAIModels();
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:4000/v1/models',
expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }),
);
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({ id: 'custom-model', provider: 'generic-openai' });
} finally {
globalThis.fetch = originalFetch;
}
});
it('does not treat generic endpoint models as local when airplane mode is active', () => {
registry.setGenericOpenAIEnabled(true);
registry.setGenericOpenAIBaseURL('http://localhost:4000/v1');
registry.registerGenericOpenAIModel('custom-model');
registry.setOfflineMode(true);
expect(registry.isReady()).toBe(false);
expect(registry.getKnownLocalModels()).toEqual([]);
expect(() => registry.resolveModel('custom-model')).toThrow(/not available offline/i);
});
});

View File

@@ -24,9 +24,11 @@ const secureKeyStoreInstances: Array<Record<string, any>> = [];
// Per-test overrides for SecureKeyStore mock behavior // Per-test overrides for SecureKeyStore mock behavior
let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key'; let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key';
let secureKeyStoreRetrieveByKey = new Map<string, string | null>();
let secureKeyStoreStoreError: Error | null = null; let secureKeyStoreStoreError: Error | null = null;
let secureKeyStoreRetrieveError: Error | null = null; let secureKeyStoreRetrieveError: Error | null = null;
let secureKeyStoreCleanupError: Error | null = null; let secureKeyStoreCleanupError: Error | null = null;
let chatEngineSettingValues = new Map<string, string | null>();
vi.mock('electron', () => ({ vi.mock('electron', () => ({
BrowserWindow: { BrowserWindow: {
@@ -55,7 +57,7 @@ vi.mock('../../src/main/engine/ChatEngine', () => ({
ChatEngine: class { ChatEngine: class {
constructor() { constructor() {
const instance = { const instance = {
getSetting: vi.fn(async () => null), getSetting: vi.fn(async (key: string) => chatEngineSettingValues.get(key) ?? null),
setSetting: vi.fn(async () => undefined), setSetting: vi.fn(async () => undefined),
deleteSetting: vi.fn(async () => undefined), deleteSetting: vi.fn(async () => undefined),
getSelectedModel: vi.fn(async () => 'gpt-5'), getSelectedModel: vi.fn(async () => 'gpt-5'),
@@ -75,8 +77,11 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
store: vi.fn(async (_key: string, _value: string) => { store: vi.fn(async (_key: string, _value: string) => {
if (secureKeyStoreStoreError) throw secureKeyStoreStoreError; if (secureKeyStoreStoreError) throw secureKeyStoreStoreError;
}), }),
retrieve: vi.fn(async () => { retrieve: vi.fn(async (key: string) => {
if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError; if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError;
if (secureKeyStoreRetrieveByKey.has(key)) {
return secureKeyStoreRetrieveByKey.get(key) ?? null;
}
return secureKeyStoreRetrieveResult; return secureKeyStoreRetrieveResult;
}), }),
remove: vi.fn(async () => undefined), remove: vi.fn(async () => undefined),
@@ -98,9 +103,17 @@ vi.mock('../../src/main/engine/ai/providers', () => ({
getOpencodeKey: vi.fn(() => 'abc12345'), getOpencodeKey: vi.fn(() => 'abc12345'),
setMistralKey: vi.fn(), setMistralKey: vi.fn(),
getMistralKey: vi.fn(() => ''), getMistralKey: vi.fn(() => ''),
setGenericOpenAIEnabled: vi.fn(),
isGenericOpenAIEnabled: vi.fn(() => false),
setGenericOpenAIBaseURL: vi.fn(),
getGenericOpenAIBaseURL: vi.fn(() => ''),
setGenericOpenAIApiKey: vi.fn(),
getGenericOpenAIApiKey: vi.fn(() => ''),
loadGenericOpenAIModelCapabilities: vi.fn(),
registerGenericOpenAIModel: vi.fn(),
isReady: vi.fn(() => true), isReady: vi.fn(() => true),
isProviderKeySet: vi.fn(() => true), isProviderKeySet: vi.fn(() => true),
getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false })), getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false })),
resolveModel: vi.fn(), resolveModel: vi.fn(),
getAvailableModels: vi.fn(async () => []), getAvailableModels: vi.fn(async () => []),
validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })), validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })),
@@ -141,9 +154,11 @@ describe('chatHandlers keychain integration', () => {
providerRegistryInstances.length = 0; providerRegistryInstances.length = 0;
secureKeyStoreInstances.length = 0; secureKeyStoreInstances.length = 0;
secureKeyStoreRetrieveResult = 'encrypted-stored-key'; secureKeyStoreRetrieveResult = 'encrypted-stored-key';
secureKeyStoreRetrieveByKey = new Map();
secureKeyStoreStoreError = null; secureKeyStoreStoreError = null;
secureKeyStoreRetrieveError = null; secureKeyStoreRetrieveError = null;
secureKeyStoreCleanupError = null; secureKeyStoreCleanupError = null;
chatEngineSettingValues = new Map();
vi.resetModules(); vi.resetModules();
}); });
@@ -282,6 +297,42 @@ describe('chatHandlers keychain integration', () => {
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key'); expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
}); });
it('restores generic endpoint settings from storage on init', async () => {
chatEngineSettingValues = new Map([
['generic_openai_enabled', 'true'],
['generic_openai_base_url', 'http://localhost:4000/v1'],
['generic_openai_model_capabilities', JSON.stringify({
'custom-model': { tools: true, vision: false },
})],
['generic_openai_known_model_ids', JSON.stringify(['custom-model'])],
]);
secureKeyStoreRetrieveByKey = new Map([
['opencode_api_key', 'encrypted-stored-key'],
['mistral_api_key', null],
['generic_openai_api_key', 'generic-secret'],
]);
const mod = await import('../../src/main/ipc/chatHandlers');
const mockBundle = { postEngine: {}, mediaEngine: {}, postMediaEngine: {} };
mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any);
mod.registerChatHandlers();
const handler = registeredHandlers.get('chat:checkReady');
await handler!(undefined);
const registry = providerRegistryInstances[0];
expect(registry.setGenericOpenAIEnabled).toHaveBeenCalledWith(true);
expect(registry.setGenericOpenAIBaseURL).toHaveBeenCalledWith('http://localhost:4000/v1');
expect(registry.setGenericOpenAIApiKey).toHaveBeenCalledWith('generic-secret');
expect(registry.loadGenericOpenAIModelCapabilities).toHaveBeenCalledWith({
'custom-model': { tools: true, vision: false },
});
expect(registry.registerGenericOpenAIModel).toHaveBeenCalledWith('custom-model');
const keyStore = secureKeyStoreInstances[0];
expect(keyStore.retrieve).toHaveBeenCalledWith('generic_openai_api_key');
});
it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => { it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => {
secureKeyStoreStoreError = new Error('encryption unavailable'); secureKeyStoreStoreError = new Error('encryption unavailable');

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView'; import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView';
import { useAppStore } from '../../../src/renderer/store'; import { useAppStore } from '../../../src/renderer/store';
@@ -24,6 +24,8 @@ describe('MCPAgentButton uninstall', () => {
app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') }, app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') },
meta: { meta: {
getCategories: vi.fn().mockResolvedValue(['article']), getCategories: vi.fn().mockResolvedValue(['article']),
getPublishingPreferences: vi.fn().mockResolvedValue(null),
setPublishingPreferences: vi.fn().mockResolvedValue({}),
getProjectMetadata: vi.fn().mockResolvedValue({ getProjectMetadata: vi.fn().mockResolvedValue({
maxPostsPerPage: 75, maxPostsPerPage: 75,
publicUrl: 'https://example.com', publicUrl: 'https://example.com',
@@ -36,6 +38,17 @@ describe('MCPAgentButton uninstall', () => {
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getOllamaEnabled: vi.fn().mockResolvedValue(false),
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue(''),
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
getGenericOpenAIModels: vi.fn().mockResolvedValue([]),
getOfflineMode: vi.fn().mockResolvedValue(false),
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }), getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }), getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }), getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
@@ -114,6 +127,8 @@ describe('SettingsView Diff Preferences', () => {
meta: { meta: {
...(window as any).electronAPI?.meta, ...(window as any).electronAPI?.meta,
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']), getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
getPublishingPreferences: vi.fn().mockResolvedValue(null),
setPublishingPreferences: vi.fn().mockResolvedValue({}),
getProjectMetadata: vi.fn().mockResolvedValue({ getProjectMetadata: vi.fn().mockResolvedValue({
maxPostsPerPage: 75, maxPostsPerPage: 75,
publicUrl: 'https://example.com', publicUrl: 'https://example.com',
@@ -131,6 +146,17 @@ describe('SettingsView Diff Preferences', () => {
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }), getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
getOllamaEnabled: vi.fn().mockResolvedValue(false),
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue(''),
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
getGenericOpenAIModels: vi.fn().mockResolvedValue([]),
getOfflineMode: vi.fn().mockResolvedValue(false),
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
}, },
templates: { templates: {
...(window as any).electronAPI?.templates, ...(window as any).electronAPI?.templates,
@@ -395,3 +421,101 @@ describe('SettingsView Diff Preferences', () => {
); );
}); });
}); });
describe('SettingsView generic endpoint refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
useAppStore.setState({
activeProject: {
id: 'project-1',
name: 'Test Project',
slug: 'test-project',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
gitDiffPreferences: {
wordWrap: true,
viewStyle: 'inline',
hideUnchangedRegions: false,
},
});
(window as any).electronAPI = {
...(window as any).electronAPI,
app: {
...(window as any).electronAPI?.app,
getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'),
},
meta: {
...(window as any).electronAPI?.meta,
getCategories: vi.fn().mockResolvedValue(['article']),
getPublishingPreferences: vi.fn().mockResolvedValue(null),
setPublishingPreferences: vi.fn().mockResolvedValue({}),
getProjectMetadata: vi.fn().mockResolvedValue({
maxPostsPerPage: 75,
publicUrl: 'https://example.com',
categorySettings: { article: { renderInLists: true, showTitle: true } },
}),
updateProjectMetadata: vi.fn().mockResolvedValue({}),
},
chat: {
...(window as any).electronAPI?.chat,
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
getOllamaEnabled: vi.fn().mockResolvedValue(false),
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(true),
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue('http://localhost:4000/v1'),
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
getGenericOpenAIModels: vi.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'generic-model', name: 'Generic Model' }]),
setGenericOpenAIBaseURL: vi.fn().mockResolvedValue({ success: true }),
getOfflineMode: vi.fn().mockResolvedValue(false),
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
},
templates: {
...(window as any).electronAPI?.templates,
getEnabledByKind: vi.fn().mockResolvedValue([]),
},
projects: {
...(window as any).electronAPI?.projects,
update: vi.fn().mockResolvedValue({}),
},
mcp: {
...(window as any).electronAPI?.mcp,
getAgents: vi.fn().mockResolvedValue([]),
isConfigured: vi.fn().mockResolvedValue(false),
getPort: vi.fn().mockResolvedValue(4124),
},
};
});
it('reloads generic models after saving the generic endpoint base URL', async () => {
render(<SettingsView />);
const baseUrlInput = await screen.findByLabelText(/base url/i);
const field = baseUrlInput.closest('.setting-field');
expect(field).not.toBeNull();
const saveButton = within(field as HTMLElement).getByRole('button', { name: /save/i });
await act(async () => {
fireEvent.click(saveButton);
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect((window as any).electronAPI.chat.getAvailableModels).toHaveBeenCalledTimes(2);
expect((window as any).electronAPI.chat.getGenericOpenAIModels).toHaveBeenCalledTimes(2);
});
});