New lib/ai/local-discovery.ts: one /api/tags query against the loopback
addresses Ollama binds to (127.0.0.1/localhost), no follow-up /api/show
round trips needed — the tags response already carries capabilities, size,
and parameter_size, enough to recommend a default model. Picks the
smallest non-"thinking" chat-capable model for the fastest first response
("Connect" pre-fills provider+baseUrl+model in one click), and separately
surfaces the largest as a "higher quality" alternative.
New banner in ai-assistant-settings.tsx: fires when Local isn't yet
configured, offers one-click Connect or a persisted "Not now" dismissal.
13 new unit tests using this machine's actual Ollama /api/tags response
(11 real installed models — qwen2.5:32b, llama3.2, deepseek-r1 x2,
gemma4 x3, hermes3, qwen3, qwen3.5, nomic-embed-text) as literal fixtures,
per the explicit instruction to use this machine as the test case:
confirms exactly one query is required, the heuristic recommends
llama3.2:latest (fastest) / qwen2.5:32b (largest) on this real fleet,
never recommends an embedding-only model, and degrades correctly when a
candidate base URL is unreachable.
Full QA gate: tsc clean, eslint clean, 2498/2498 tests passing, build clean.
Also live-verified in a real browser session against this machine's real
Ollama — the banner rendered with exactly these two model names.
151 lines
6.8 KiB
TypeScript
151 lines
6.8 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
discoverLocalOllama,
|
|
recommendDefaultModel,
|
|
largestModel,
|
|
isLocalDiscoveryDismissed,
|
|
dismissLocalDiscovery,
|
|
type DiscoveredLocalModel,
|
|
} from '../local-discovery';
|
|
|
|
/**
|
|
* Real model list from this machine's Ollama (`curl 127.0.0.1:11434/api/tags`,
|
|
* 2026-08-06) — used as the test fixture rather than invented data, per the
|
|
* explicit instruction to use the real local runtime as the test case for
|
|
* "which queries are required and how to add most of the modules
|
|
* automatically". Sizes/params/capabilities are copied verbatim.
|
|
*/
|
|
const REAL_MACHINE_MODELS: DiscoveredLocalModel[] = [
|
|
{ name: 'nomic-embed-text:latest', capabilities: ['embedding'], parameterSize: '137M', sizeBytes: 274_302_450 },
|
|
{ name: 'qwen2.5:32b', capabilities: ['completion', 'tools'], parameterSize: '32.8B', sizeBytes: 19_851_349_669 },
|
|
{ name: 'gemma4:12b-mlx', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '', sizeBytes: 9_977_519_169 },
|
|
{ name: 'gemma4:latest', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.0B', sizeBytes: 9_608_350_718 },
|
|
{ name: 'deepseek-r1:32b', capabilities: ['completion', 'thinking'], parameterSize: '32.8B', sizeBytes: 19_851_337_809 },
|
|
{ name: 'deepseek-r1:14b', capabilities: ['completion', 'thinking'], parameterSize: '14.8B', sizeBytes: 8_988_112_209 },
|
|
{ name: 'llama3.2:latest', capabilities: ['completion', 'tools'], parameterSize: '3.2B', sizeBytes: 2_019_393_189 },
|
|
{ name: 'hermes3:8b', capabilities: ['completion', 'tools'], parameterSize: '8B', sizeBytes: 4_661_227_000 },
|
|
{ name: 'qwen3:latest', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.2B', sizeBytes: 5_200_000_000 },
|
|
{ name: 'gemma4:e4b', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.0B', sizeBytes: 9_600_000_000 },
|
|
{ name: 'qwen3.5:latest', capabilities: ['vision', 'completion', 'tools', 'thinking'], parameterSize: '9.7B', sizeBytes: 6_600_000_000 },
|
|
];
|
|
|
|
describe('recommendDefaultModel', () => {
|
|
it('picks the smallest non-"thinking" chat model from a real mixed fleet', () => {
|
|
// llama3.2 (2.0GB) is the smallest completion-capable, non-reasoning
|
|
// model on this real machine — everything smaller is embedding-only.
|
|
expect(recommendDefaultModel(REAL_MACHINE_MODELS)).toBe('llama3.2:latest');
|
|
});
|
|
|
|
it('never recommends an embedding-only model', () => {
|
|
const onlyEmbedding = [REAL_MACHINE_MODELS[0]]; // nomic-embed-text
|
|
expect(recommendDefaultModel(onlyEmbedding)).toBeNull();
|
|
});
|
|
|
|
it('falls back to the smallest "thinking" model when nothing else qualifies', () => {
|
|
const onlyReasoning = REAL_MACHINE_MODELS.filter((m) => m.capabilities.includes('thinking') && !m.capabilities.includes('vision'));
|
|
// Smallest of the thinking-only pool here is qwen3 (5.2GB) before gemma4 variants.
|
|
expect(recommendDefaultModel(onlyReasoning)).toBe('qwen3:latest');
|
|
});
|
|
|
|
it('returns null when no models are chat-capable at all', () => {
|
|
expect(recommendDefaultModel([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('largestModel', () => {
|
|
it('picks the biggest chat-capable model — qwen2.5:32b, by 11,860 bytes over deepseek-r1:32b', () => {
|
|
// Both are ~19.85GB on this real machine (same base size class), but
|
|
// qwen2.5:32b's actual manifest is very slightly larger — not a tie.
|
|
expect(largestModel(REAL_MACHINE_MODELS)).toBe('qwen2.5:32b');
|
|
});
|
|
|
|
it('excludes embedding-only models even though they can be tiny or huge', () => {
|
|
expect(largestModel([REAL_MACHINE_MODELS[0]])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('discoverLocalOllama', () => {
|
|
const originalFetch = global.fetch;
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('parses a real-shaped /api/tags response into DiscoveredLocalModel[]', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
models: [
|
|
{ name: 'llama3.2:latest', capabilities: ['completion', 'tools'], size: 2_019_393_189, details: { parameter_size: '3.2B' } },
|
|
{ name: 'nomic-embed-text:latest', capabilities: ['embedding'], size: 274_302_450, details: { parameter_size: '137M' } },
|
|
],
|
|
}),
|
|
}) as unknown as typeof fetch;
|
|
|
|
const result = await discoverLocalOllama(['http://127.0.0.1:11434']);
|
|
expect(result).not.toBeNull();
|
|
expect(result?.baseUrl).toBe('http://127.0.0.1:11434');
|
|
expect(result?.models).toHaveLength(2);
|
|
expect(result?.models[0]).toEqual({
|
|
name: 'llama3.2:latest',
|
|
capabilities: ['completion', 'tools'],
|
|
parameterSize: '3.2B',
|
|
sizeBytes: 2_019_393_189,
|
|
});
|
|
});
|
|
|
|
it('requires exactly one query — a single /api/tags call, no follow-up /api/show requests', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ models: [{ name: 'llama3.2:latest', capabilities: ['completion'], size: 1, details: {} }] }),
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await discoverLocalOllama(['http://127.0.0.1:11434']);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:11434/api/tags', expect.anything());
|
|
});
|
|
|
|
it('falls through to the next candidate base URL when the first is unreachable', async () => {
|
|
const fetchMock = vi.fn()
|
|
.mockRejectedValueOnce(new Error('connection refused'))
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ models: [{ name: 'llama3.2:latest', capabilities: ['completion'], size: 1, details: {} }] }),
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const result = await discoverLocalOllama(['http://127.0.0.1:11434', 'http://localhost:11434']);
|
|
expect(result?.baseUrl).toBe('http://localhost:11434');
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('returns null when nothing answers on any candidate', async () => {
|
|
global.fetch = vi.fn().mockRejectedValue(new Error('connection refused')) as unknown as typeof fetch;
|
|
const result = await discoverLocalOllama(['http://127.0.0.1:11434', 'http://localhost:11434']);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null (not an empty-models result) when Ollama answers with zero models installed', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ models: [] }) }) as unknown as typeof fetch;
|
|
const result = await discoverLocalOllama(['http://127.0.0.1:11434']);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('dismissal persistence', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it('is not dismissed by default', () => {
|
|
expect(isLocalDiscoveryDismissed()).toBe(false);
|
|
});
|
|
|
|
it('persists a dismissal across calls', () => {
|
|
dismissLocalDiscovery();
|
|
expect(isLocalDiscoveryDismissed()).toBe(true);
|
|
});
|
|
});
|