Files
bDS/tests/engine/lmstudio-provider.test.ts
Georg Bauer 5747925503 Feature/lmstudio provider (#30)
* 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>
2026-03-02 13:35:42 +01:00

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;
}
});
});
});