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:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "blogging-desktop-server",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "blogging-desktop-server",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.50",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "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",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -294,8 +294,10 @@ export class ChatService {
|
||||
// Build tools (skip for Ollama/LM Studio models unless capability override is set)
|
||||
const isOllama = this.providers.isOllamaModel(modelId);
|
||||
const isLmstudio = this.providers.isLmstudioModel(modelId);
|
||||
const isGenericOpenAI = this.providers.isGenericOpenAIModel(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 a2uiToolsRaw = skipTools ? {} : createA2UITools();
|
||||
const allTools = { ...blogTools, ...a2uiToolsRaw };
|
||||
|
||||
@@ -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 LMSTUDIO_BASE_URL = 'http://localhost:1234/v1';
|
||||
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 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 GENERIC_OPENAI_FETCH_TIMEOUT = 10000; // 10 s for generic endpoints
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gateway factory
|
||||
@@ -123,6 +125,12 @@ export class ProviderRegistry {
|
||||
private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||
private lmstudioModelIds = new Set<string>();
|
||||
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 _offlineMode = false;
|
||||
|
||||
@@ -297,9 +305,94 @@ export class ProviderRegistry {
|
||||
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
|
||||
* registration first, then falling back to prefix-based detection.
|
||||
* Detect the effective provider for a model ID, checking Ollama, LM Studio,
|
||||
* and generic OpenAI registration first, then falling back to prefix-based detection.
|
||||
*/
|
||||
detectModelProvider(modelId: string): string {
|
||||
if (this.ollamaModelIds.has(modelId)) {
|
||||
@@ -308,6 +401,9 @@ export class ProviderRegistry {
|
||||
if (this.lmstudioModelIds.has(modelId)) {
|
||||
return 'lmstudio';
|
||||
}
|
||||
if (this.genericOpenAIModelIds.has(modelId)) {
|
||||
return 'generic-openai';
|
||||
}
|
||||
return detectProvider(modelId);
|
||||
}
|
||||
|
||||
@@ -316,7 +412,7 @@ export class ProviderRegistry {
|
||||
if (this._offlineMode) {
|
||||
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. */
|
||||
@@ -327,6 +423,9 @@ export class ProviderRegistry {
|
||||
if (provider === 'lmstudio') {
|
||||
return this.lmstudioEnabled;
|
||||
}
|
||||
if (provider === 'generic-openai') {
|
||||
return this.genericOpenAIEnabled && Boolean(this.genericOpenAIBaseURL);
|
||||
}
|
||||
// In offline mode, cloud providers are unavailable
|
||||
if (this._offlineMode) {
|
||||
return false;
|
||||
@@ -338,12 +437,13 @@ export class ProviderRegistry {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
opencode: !!this.opencodeKey,
|
||||
mistral: !!this.mistralKey,
|
||||
ollama: this.ollamaEnabled,
|
||||
lmstudio: this.lmstudioEnabled,
|
||||
genericOpenAI: this.genericOpenAIEnabled,
|
||||
offlineMode: this._offlineMode,
|
||||
};
|
||||
}
|
||||
@@ -385,6 +485,20 @@ export class ProviderRegistry {
|
||||
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);
|
||||
|
||||
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) {
|
||||
this.cachedModels = allModels;
|
||||
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 async fetchModelsFromEndpoint(
|
||||
@@ -725,6 +924,14 @@ export class ProviderRegistry {
|
||||
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[]> {
|
||||
try {
|
||||
const catalog = await this.modelCatalogEngine.getAll();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -132,6 +132,13 @@ async function ensureInitialized(): Promise<void> {
|
||||
}
|
||||
} 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
|
||||
try {
|
||||
const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled');
|
||||
@@ -167,6 +174,21 @@ async function ensureInitialized(): Promise<void> {
|
||||
}
|
||||
} 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
|
||||
try {
|
||||
const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities');
|
||||
@@ -176,6 +198,15 @@ async function ensureInitialized(): Promise<void> {
|
||||
}
|
||||
} 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)
|
||||
try {
|
||||
const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids');
|
||||
@@ -186,6 +217,16 @@ async function ensureInitialized(): Promise<void> {
|
||||
}
|
||||
} 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
|
||||
try {
|
||||
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 ============
|
||||
|
||||
ipcMain.handle('chat:getOfflineMode', async () => {
|
||||
@@ -627,12 +809,16 @@ export function registerChatHandlers(): void {
|
||||
// Persist known local model IDs so offline mode survives restarts
|
||||
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 genericOpenAIModels = models.filter(m => m.provider === 'generic-openai').map(m => m.id);
|
||||
if (ollamaModels.length > 0) {
|
||||
await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {});
|
||||
}
|
||||
if (lmstudioModels.length > 0) {
|
||||
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 };
|
||||
} catch (error) {
|
||||
|
||||
@@ -357,6 +357,18 @@ export const electronAPI: ElectronAPI = {
|
||||
getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'),
|
||||
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
|
||||
getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'),
|
||||
setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled),
|
||||
|
||||
@@ -495,7 +495,7 @@ export interface ChatReadyStatus {
|
||||
ready: boolean;
|
||||
error?: 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 {
|
||||
@@ -1022,6 +1022,18 @@ export interface ElectronAPI {
|
||||
getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
||||
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
|
||||
getOfflineMode: () => Promise<boolean>;
|
||||
setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
@@ -253,6 +253,13 @@ export const SettingsView: React.FC = () => {
|
||||
const [lmstudioEnabled, setLmstudioEnabled] = useState(false);
|
||||
const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
|
||||
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 [offlineChatModel, setOfflineChatModel] = 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 })));
|
||||
}
|
||||
|
||||
// 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
|
||||
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
|
||||
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) => {
|
||||
try {
|
||||
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 === 'ollama') return t('settings.ai.providerOllama');
|
||||
if (provider === 'lmstudio') return t('settings.ai.providerLmstudio');
|
||||
if (provider === 'generic-openai') return t('settings.ai.providerGenericOpenAI');
|
||||
return provider;
|
||||
};
|
||||
|
||||
@@ -1716,6 +1861,117 @@ export const SettingsView: React.FC = () => {
|
||||
)}
|
||||
</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
|
||||
id="ai-offline"
|
||||
label={t('settings.ai.offlineLabel')}
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Lokal)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Lokal)",
|
||||
"settings.ai.providerGenericOpenAI": "Generischer OpenAI-Endpunkt",
|
||||
"settings.ai.providerOther": "Andere",
|
||||
"settings.ai.ollamaLabel": "Ollama (Lokale Modelle)",
|
||||
"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.toast.lmstudioEnabled": "LM Studio aktiviert",
|
||||
"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.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.",
|
||||
"settings.ai.offlineEnable": "Flugmodus aktivieren",
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Local)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||
"settings.ai.providerGenericOpenAI": "Generic OpenAI Endpoint",
|
||||
"settings.ai.providerOther": "Other",
|
||||
"settings.ai.ollamaLabel": "Ollama (Local 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.toast.lmstudioEnabled": "LM Studio enabled",
|
||||
"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.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.",
|
||||
"settings.ai.offlineEnable": "Enable Airplane Mode",
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Local)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||
"settings.ai.providerGenericOpenAI": "Endpoint Genérico OpenAI",
|
||||
"settings.ai.providerOther": "Otro",
|
||||
"settings.ai.ollamaLabel": "Ollama (Modelos 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.toast.lmstudioEnabled": "LM Studio activado",
|
||||
"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.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",
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Local)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||
"settings.ai.providerGenericOpenAI": "Point de terminaison OpenAI générique",
|
||||
"settings.ai.providerOther": "Autre",
|
||||
"settings.ai.ollamaLabel": "Ollama (Modèles 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.toast.lmstudioEnabled": "LM Studio activé",
|
||||
"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.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",
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Locale)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Locale)",
|
||||
"settings.ai.providerGenericOpenAI": "Endpoint Generico OpenAI",
|
||||
"settings.ai.providerOther": "Altro",
|
||||
"settings.ai.ollamaLabel": "Ollama (Modelli locali)",
|
||||
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
|
||||
@@ -871,6 +872,22 @@
|
||||
"settings.ai.lmstudioCapVision": "Visione",
|
||||
"settings.toast.lmstudioEnabled": "LM Studio attivato",
|
||||
"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.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.",
|
||||
"settings.ai.offlineEnable": "Attiva modalità aereo",
|
||||
|
||||
@@ -140,15 +140,15 @@ describe('ProviderRegistry', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
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');
|
||||
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);
|
||||
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);
|
||||
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', () => {
|
||||
|
||||
106
tests/engine/generic-openai-chat-service.test.ts
Normal file
106
tests/engine/generic-openai-chat-service.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
58
tests/engine/generic-openai-provider.test.ts
Normal file
58
tests/engine/generic-openai-provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,11 @@ const secureKeyStoreInstances: Array<Record<string, any>> = [];
|
||||
|
||||
// Per-test overrides for SecureKeyStore mock behavior
|
||||
let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key';
|
||||
let secureKeyStoreRetrieveByKey = new Map<string, string | null>();
|
||||
let secureKeyStoreStoreError: Error | null = null;
|
||||
let secureKeyStoreRetrieveError: Error | null = null;
|
||||
let secureKeyStoreCleanupError: Error | null = null;
|
||||
let chatEngineSettingValues = new Map<string, string | null>();
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
@@ -55,7 +57,7 @@ vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
ChatEngine: class {
|
||||
constructor() {
|
||||
const instance = {
|
||||
getSetting: vi.fn(async () => null),
|
||||
getSetting: vi.fn(async (key: string) => chatEngineSettingValues.get(key) ?? null),
|
||||
setSetting: vi.fn(async () => undefined),
|
||||
deleteSetting: vi.fn(async () => undefined),
|
||||
getSelectedModel: vi.fn(async () => 'gpt-5'),
|
||||
@@ -75,8 +77,11 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
|
||||
store: vi.fn(async (_key: string, _value: string) => {
|
||||
if (secureKeyStoreStoreError) throw secureKeyStoreStoreError;
|
||||
}),
|
||||
retrieve: vi.fn(async () => {
|
||||
retrieve: vi.fn(async (key: string) => {
|
||||
if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError;
|
||||
if (secureKeyStoreRetrieveByKey.has(key)) {
|
||||
return secureKeyStoreRetrieveByKey.get(key) ?? null;
|
||||
}
|
||||
return secureKeyStoreRetrieveResult;
|
||||
}),
|
||||
remove: vi.fn(async () => undefined),
|
||||
@@ -98,9 +103,17 @@ vi.mock('../../src/main/engine/ai/providers', () => ({
|
||||
getOpencodeKey: vi.fn(() => 'abc12345'),
|
||||
setMistralKey: 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),
|
||||
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(),
|
||||
getAvailableModels: vi.fn(async () => []),
|
||||
validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
||||
@@ -141,9 +154,11 @@ describe('chatHandlers keychain integration', () => {
|
||||
providerRegistryInstances.length = 0;
|
||||
secureKeyStoreInstances.length = 0;
|
||||
secureKeyStoreRetrieveResult = 'encrypted-stored-key';
|
||||
secureKeyStoreRetrieveByKey = new Map();
|
||||
secureKeyStoreStoreError = null;
|
||||
secureKeyStoreRetrieveError = null;
|
||||
secureKeyStoreCleanupError = null;
|
||||
chatEngineSettingValues = new Map();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -282,6 +297,42 @@ describe('chatHandlers keychain integration', () => {
|
||||
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 () => {
|
||||
secureKeyStoreStoreError = new Error('encryption unavailable');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
@@ -24,6 +24,8 @@ describe('MCPAgentButton uninstall', () => {
|
||||
app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') },
|
||||
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',
|
||||
@@ -36,6 +38,17 @@ describe('MCPAgentButton uninstall', () => {
|
||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||
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' }),
|
||||
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
|
||||
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
|
||||
@@ -114,6 +127,8 @@ describe('SettingsView Diff Preferences', () => {
|
||||
meta: {
|
||||
...(window as any).electronAPI?.meta,
|
||||
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
|
||||
getPublishingPreferences: vi.fn().mockResolvedValue(null),
|
||||
setPublishingPreferences: vi.fn().mockResolvedValue({}),
|
||||
getProjectMetadata: vi.fn().mockResolvedValue({
|
||||
maxPostsPerPage: 75,
|
||||
publicUrl: 'https://example.com',
|
||||
@@ -131,6 +146,17 @@ describe('SettingsView Diff Preferences', () => {
|
||||
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||
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: {
|
||||
...(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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user