* chore: just a plan update * Add LM Studio as local AI provider (OpenAI-compatible, like Ollama) * Convert WebP thumbnails to JPEG before image analysis for LM Studio compatibility * Strengthen language enforcement in image analysis prompt for local models * Use i18n localized prompts for image analysis instead of English instructions * Add airplane mode (Flugmodus) with status bar toggle and offline model preferences * Fix flightmode: persist model IDs, skip network when offline, airplane icon * Auto-fallback to offline models in airplane mode for chat, title, and image analysis * Auto-select first local model as offline fallback when no explicit offline model configured * Block git fetch/pull/push and site upload in airplane mode * fix: thumbnails optimized for AI * fix: error handling in airplane mode --------- Co-authored-by: hugo <hugoms@me.com>
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
/**
|
|
* Tests for LM Studio provider integration in ProviderRegistry.
|
|
*
|
|
* LM Studio provides an OpenAI-compatible API at http://localhost:1234/v1
|
|
* with a standard /v1/models endpoint for model listing.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ProviderRegistry, LMSTUDIO_BASE_URL, LMSTUDIO_MODELS_URL } from '../../src/main/engine/ai/providers';
|
|
|
|
// Mock ModelCatalogEngine — no DB in unit tests
|
|
vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({
|
|
ModelCatalogEngine: class {
|
|
getAll = vi.fn().mockResolvedValue([]);
|
|
getContextWindow = vi.fn().mockResolvedValue(null);
|
|
},
|
|
}));
|
|
|
|
describe('LM Studio provider support', () => {
|
|
let registry: ProviderRegistry;
|
|
|
|
beforeEach(() => {
|
|
registry = new ProviderRegistry();
|
|
});
|
|
|
|
// ---- Constants ----
|
|
|
|
it('exports LM Studio URL constants', () => {
|
|
expect(LMSTUDIO_BASE_URL).toBe('http://localhost:1234/v1');
|
|
expect(LMSTUDIO_MODELS_URL).toBe('http://localhost:1234/v1/models');
|
|
});
|
|
|
|
// ---- LM Studio enable/disable ----
|
|
|
|
it('is not enabled by default', () => {
|
|
expect(registry.isLmstudioEnabled()).toBe(false);
|
|
});
|
|
|
|
it('can be enabled and disabled', () => {
|
|
registry.setLmstudioEnabled(true);
|
|
expect(registry.isLmstudioEnabled()).toBe(true);
|
|
registry.setLmstudioEnabled(false);
|
|
expect(registry.isLmstudioEnabled()).toBe(false);
|
|
});
|
|
|
|
it('enabling LM Studio invalidates model cache', () => {
|
|
// Populate cache
|
|
registry['cachedModels'] = [{ id: 'test', name: 'test', provider: 'other' }];
|
|
registry['cachedModelsAt'] = Date.now();
|
|
|
|
registry.setLmstudioEnabled(true);
|
|
|
|
expect(registry['cachedModels']).toBeNull();
|
|
expect(registry['cachedModelsAt']).toBe(0);
|
|
});
|
|
|
|
// ---- Provider status ----
|
|
|
|
it('getProviderStatus includes lmstudio field', () => {
|
|
const status = registry.getProviderStatus();
|
|
expect(status).toHaveProperty('lmstudio');
|
|
expect(status.lmstudio).toBe(false);
|
|
|
|
registry.setLmstudioEnabled(true);
|
|
expect(registry.getProviderStatus().lmstudio).toBe(true);
|
|
});
|
|
|
|
// ---- isReady includes lmstudio ----
|
|
|
|
it('isReady returns true when only LM Studio is enabled', () => {
|
|
expect(registry.isReady()).toBe(false);
|
|
registry.setLmstudioEnabled(true);
|
|
expect(registry.isReady()).toBe(true);
|
|
});
|
|
|
|
// ---- isProviderKeySet for lmstudio ----
|
|
|
|
it('isProviderKeySet returns lmstudio enabled state for provider "lmstudio"', () => {
|
|
expect(registry.isProviderKeySet('lmstudio')).toBe(false);
|
|
registry.setLmstudioEnabled(true);
|
|
expect(registry.isProviderKeySet('lmstudio')).toBe(true);
|
|
});
|
|
|
|
// ---- resolveModel for lmstudio ----
|
|
|
|
it('resolveModel creates an OpenAI-compatible model for LM Studio models', () => {
|
|
registry.setLmstudioEnabled(true);
|
|
registry.registerLmstudioModel('lmstudio-community/Meta-Llama-3-8B');
|
|
|
|
const model = registry.resolveModel('lmstudio-community/Meta-Llama-3-8B');
|
|
expect(model).toBeDefined();
|
|
expect(model.modelId).toBe('lmstudio-community/Meta-Llama-3-8B');
|
|
});
|
|
|
|
it('resolveModel throws when LM Studio is disabled', () => {
|
|
registry.registerLmstudioModel('lmstudio-community/Meta-Llama-3-8B');
|
|
expect(() => registry.resolveModel('lmstudio-community/Meta-Llama-3-8B')).toThrow(/not configured/i);
|
|
});
|
|
|
|
// ---- LM Studio model registration ----
|
|
|
|
it('tracks registered LM Studio model IDs', () => {
|
|
expect(registry.isLmstudioModel('some-model')).toBe(false);
|
|
registry.registerLmstudioModel('some-model');
|
|
expect(registry.isLmstudioModel('some-model')).toBe(true);
|
|
});
|
|
|
|
it('clearLmstudioModels removes all registered models', () => {
|
|
registry.registerLmstudioModel('model-a');
|
|
registry.registerLmstudioModel('model-b');
|
|
registry.clearLmstudioModels();
|
|
expect(registry.isLmstudioModel('model-a')).toBe(false);
|
|
expect(registry.isLmstudioModel('model-b')).toBe(false);
|
|
});
|
|
|
|
// ---- detectModelProvider ----
|
|
|
|
it('detectModelProvider returns "lmstudio" for registered LM Studio models', () => {
|
|
registry.registerLmstudioModel('some-local-model');
|
|
expect(registry.detectModelProvider('some-local-model')).toBe('lmstudio');
|
|
});
|
|
|
|
it('detectModelProvider returns "ollama" for registered Ollama models (not lmstudio)', () => {
|
|
registry.registerOllamaModel('llama3:latest');
|
|
registry.registerLmstudioModel('some-model');
|
|
expect(registry.detectModelProvider('llama3:latest')).toBe('ollama');
|
|
expect(registry.detectModelProvider('some-model')).toBe('lmstudio');
|
|
});
|
|
|
|
// ---- fetchLmstudioModels ----
|
|
|
|
it('fetchLmstudioModels calls the LM Studio models endpoint', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [
|
|
{ id: 'lmstudio-community/Meta-Llama-3-8B' },
|
|
{ id: 'TheBloke/Mistral-7B-v0.1-GGUF' },
|
|
],
|
|
}),
|
|
});
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mockFetch;
|
|
|
|
try {
|
|
const models = await registry.fetchLmstudioModels();
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
LMSTUDIO_MODELS_URL,
|
|
expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }),
|
|
);
|
|
expect(models).toHaveLength(2);
|
|
expect(models[0]).toMatchObject({ id: 'lmstudio-community/Meta-Llama-3-8B', provider: 'lmstudio' });
|
|
expect(models[1]).toMatchObject({ id: 'TheBloke/Mistral-7B-v0.1-GGUF', provider: 'lmstudio' });
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it('fetchLmstudioModels returns empty array on network error', async () => {
|
|
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mockFetch;
|
|
|
|
try {
|
|
const models = await registry.fetchLmstudioModels();
|
|
expect(models).toEqual([]);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it('fetchLmstudioModels registers returned models', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [{ id: 'my-local-model' }],
|
|
}),
|
|
});
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mockFetch;
|
|
|
|
try {
|
|
await registry.fetchLmstudioModels();
|
|
expect(registry.isLmstudioModel('my-local-model')).toBe(true);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
// ---- getAvailableModels includes LM Studio when enabled ----
|
|
|
|
it('getAvailableModels includes LM Studio models when enabled', async () => {
|
|
registry.setLmstudioEnabled(true);
|
|
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [{ id: 'my-local-model' }],
|
|
}),
|
|
});
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mockFetch;
|
|
|
|
try {
|
|
const models = await registry.getAvailableModels();
|
|
const lmModels = models.filter(m => m.provider === 'lmstudio');
|
|
expect(lmModels.length).toBeGreaterThanOrEqual(1);
|
|
expect(lmModels[0].id).toBe('my-local-model');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it('getAvailableModels excludes LM Studio models when disabled', async () => {
|
|
registry.setLmstudioEnabled(false);
|
|
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ id: 'my-local-model' }] }),
|
|
});
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mockFetch;
|
|
|
|
try {
|
|
const models = await registry.getAvailableModels();
|
|
const lmModels = models.filter(m => m.provider === 'lmstudio');
|
|
expect(lmModels).toHaveLength(0);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
// ---- LM Studio model capability overrides ----
|
|
|
|
describe('model capability overrides', () => {
|
|
it('returns default capabilities (tools=false, vision=false) for unknown model', () => {
|
|
const caps = registry.getLmstudioModelCapabilities('unknown-model');
|
|
expect(caps).toEqual({ tools: false, vision: false });
|
|
});
|
|
|
|
it('stores and retrieves capability overrides for a model', () => {
|
|
registry.setLmstudioModelCapabilities('my-model', { tools: true, vision: false });
|
|
expect(registry.getLmstudioModelCapabilities('my-model')).toEqual({ tools: true, vision: false });
|
|
});
|
|
|
|
it('stores vision capability override', () => {
|
|
registry.setLmstudioModelCapabilities('vision-model', { tools: false, vision: true });
|
|
expect(registry.getLmstudioModelCapabilities('vision-model')).toEqual({ tools: false, vision: true });
|
|
});
|
|
|
|
it('supports both capabilities enabled', () => {
|
|
registry.setLmstudioModelCapabilities('full-model', { tools: true, vision: true });
|
|
expect(registry.getLmstudioModelCapabilities('full-model')).toEqual({ tools: true, vision: true });
|
|
});
|
|
|
|
it('getAllLmstudioModelCapabilities returns all stored overrides', () => {
|
|
registry.setLmstudioModelCapabilities('model-a', { tools: true, vision: false });
|
|
registry.setLmstudioModelCapabilities('model-b', { tools: false, vision: true });
|
|
const all = registry.getAllLmstudioModelCapabilities();
|
|
expect(all).toEqual({
|
|
'model-a': { tools: true, vision: false },
|
|
'model-b': { tools: false, vision: true },
|
|
});
|
|
});
|
|
|
|
it('getAllLmstudioModelCapabilities returns empty object when no overrides', () => {
|
|
expect(registry.getAllLmstudioModelCapabilities()).toEqual({});
|
|
});
|
|
|
|
it('loadLmstudioModelCapabilities restores from serialized JSON', () => {
|
|
const data = { 'my-model': { tools: true, vision: false } };
|
|
registry.loadLmstudioModelCapabilities(data);
|
|
expect(registry.getLmstudioModelCapabilities('my-model')).toEqual({ tools: true, vision: false });
|
|
});
|
|
|
|
it('lmstudioModelSupportsTools returns false by default', () => {
|
|
expect(registry.lmstudioModelSupportsTools('unknown')).toBe(false);
|
|
});
|
|
|
|
it('lmstudioModelSupportsTools returns true when override is set', () => {
|
|
registry.setLmstudioModelCapabilities('my-model', { tools: true, vision: false });
|
|
expect(registry.lmstudioModelSupportsTools('my-model')).toBe(true);
|
|
});
|
|
|
|
it('lmstudioModelSupportsVision returns false by default', () => {
|
|
expect(registry.lmstudioModelSupportsVision('unknown')).toBe(false);
|
|
});
|
|
|
|
it('lmstudioModelSupportsVision returns true when override is set', () => {
|
|
registry.setLmstudioModelCapabilities('vision-model', { tools: false, vision: true });
|
|
expect(registry.lmstudioModelSupportsVision('vision-model')).toBe(true);
|
|
});
|
|
|
|
it('fetchLmstudioModels applies vision overrides to returned models', async () => {
|
|
registry.setLmstudioModelCapabilities('vision-model', { tools: false, vision: true });
|
|
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [
|
|
{ id: 'text-model' },
|
|
{ id: 'vision-model' },
|
|
],
|
|
}),
|
|
});
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mockFetch;
|
|
|
|
try {
|
|
const models = await registry.fetchLmstudioModels();
|
|
expect(models).toHaveLength(2);
|
|
expect(models.find(m => m.id === 'text-model')?.vision).toBe(false);
|
|
expect(models.find(m => m.id === 'vision-model')?.vision).toBe(true);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|
|
});
|