diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index 9e117b0..8ad96fb 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -5,6 +5,14 @@ import { buildVisionPrompt } from './visionPrompt.js'; import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js'; import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js'; +function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' { + const msg = ((e as Error)?.message ?? '').toLowerCase(); + if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout'; + if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network'; + if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns'; + return 'other'; +} + export interface LocalOllamaOptions { endpoint?: string; model?: string; @@ -119,7 +127,8 @@ export class LocalOllamaProvider implements InferenceProvider { return found ? { ok: true, model: this.model } : { ok: false, reason: `${this.model} not installed` }; } catch (err) { - return { ok: false, reason: `unreachable: ${(err as Error).message}` }; + const cls = classifyFetchError(err); + return { ok: false, reason: `unreachable:${cls}` }; } } } diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index f254bb5..b69da29 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -110,6 +110,44 @@ describe('LocalOllamaProvider', () => { expect(provider.name).toBe('local-ollama/gemma4:26b'); }); + describe('healthCheck PII reason masking', () => { + it('classifies ECONNREFUSED as network', async () => { + mock.get('http://192.168.1.5:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('connect ECONNREFUSED 192.168.1.5:11434')); + const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' }); + const h = await provider.healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toBe('unreachable:network'); + expect(h.reason).not.toContain('192.168.1.5'); + }); + + it('classifies AbortError/timeout as timeout', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('The operation was aborted due to timeout')); + const h = await new LocalOllamaProvider().healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toBe('unreachable:timeout'); + }); + + it('classifies ENOTFOUND as dns', async () => { + mock.get('http://nonexistent.local:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('getaddrinfo ENOTFOUND nonexistent.local')); + const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' }); + const h = await provider.healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toBe('unreachable:dns'); + expect(h.reason).not.toContain('nonexistent.local'); + }); + + it('falls back to other for unknown errors', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('something weird happened')); + const h = await new LocalOllamaProvider().healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toBe('unreachable:other'); + }); + }); + describe('vision path (v0.3.1 Cut F)', () => { it('visionModel + images → body.images + model=visionModel + buildVisionPrompt', async () => { let capturedBody: string = '';