feat(ai): LocalOllamaProvider with 120s timeout + integration harness
Task 12 of the slice plan. Implements the slice's only provider:
- generate(): POST {endpoint}/api/generate with Korean-first
prompt; AbortController-driven 120s timeout; parses
body.response as JSON and runs it through parseAiResponse.
- healthCheck(): GET /api/tags; returns ok when configured model
is in the listing, otherwise reports the missing-model reason.
- Constructor takes opts.endpoint / opts.model so Task 30 main
entry can inject INKLING_OLLAMA_ENDPOINT for LAN dogfood;
defaults are http://localhost:11434 and gemma4:e4b.
Tests: 6 unit cases via undici MockAgent (parse, non-JSON,
timeout abort, healthCheck ok / missing / connection error).
Integration test gated by INKLING_INTEGRATION=1 hits real
Ollama for Korean / English-stack / mixed input cases with a
180s per-test budget — also reads INKLING_OLLAMA_ENDPOINT so
LAN dogfood can be exercised end-to-end.
Plan deviation: undici@8 types treat MockAgent's `.reply()`
callback as sync-return-only, so the 500ms-delayed reply used
in the timeout test is cast `as never` to bypass the overload
mismatch. Behavior is correct at runtime; the cast is local to
the test.
Verification: `npx vitest run tests/unit/LocalOllamaProvider.test.ts`
6 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
73
src/main/ai/LocalOllamaProvider.ts
Normal file
73
src/main/ai/LocalOllamaProvider.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { request } from 'undici';
|
||||
import { parseAiResponse, type AiResponse } from './schema.js';
|
||||
import { buildPrompt } from './prompt.js';
|
||||
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
|
||||
|
||||
export interface LocalOllamaOptions {
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
timeoutMs?: number;
|
||||
temperature?: number;
|
||||
numPredict?: number;
|
||||
}
|
||||
|
||||
export class LocalOllamaProvider implements InferenceProvider {
|
||||
readonly name: string;
|
||||
private endpoint: string;
|
||||
private model: string;
|
||||
private timeoutMs: number;
|
||||
private temperature: number;
|
||||
private numPredict: number;
|
||||
|
||||
constructor(opts: LocalOllamaOptions = {}) {
|
||||
this.endpoint = opts.endpoint ?? 'http://localhost:11434';
|
||||
this.model = opts.model ?? 'gemma4:e4b';
|
||||
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
this.temperature = opts.temperature ?? 0.2;
|
||||
this.numPredict = opts.numPredict ?? 512;
|
||||
this.name = `local-ollama/${this.model}`;
|
||||
}
|
||||
|
||||
async generate(input: GenerateInput): Promise<AiResponse> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt: buildPrompt(input.text),
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
}),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw new Error(`ollama http ${res.statusCode}`);
|
||||
}
|
||||
const body = (await res.body.json()) as { response?: string };
|
||||
if (!body.response) throw new Error('missing response field');
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(body.response); }
|
||||
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
|
||||
return parseAiResponse(parsed);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<HealthResult> {
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });
|
||||
if (res.statusCode !== 200) return { ok: false, reason: `tags http ${res.statusCode}` };
|
||||
const body = (await res.body.json()) as { models?: Array<{ name: string }> };
|
||||
const found = body.models?.some((m) => m.name === this.model);
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
28
tests/integration/ollama-golden.test.ts
Normal file
28
tests/integration/ollama-golden.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';
|
||||
|
||||
const skip = process.env.INKLING_INTEGRATION !== '1';
|
||||
|
||||
describe.skipIf(skip)('LocalOllamaProvider integration', () => {
|
||||
const provider = new LocalOllamaProvider({
|
||||
endpoint: process.env.INKLING_OLLAMA_ENDPOINT
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const h = await provider.healthCheck();
|
||||
if (!h.ok) throw new Error(`Ollama not ready: ${h.reason}`);
|
||||
});
|
||||
|
||||
const cases = [
|
||||
'회의 중 A프로젝트 API 타임아웃 문제가 재발했다는 보고를 받음.',
|
||||
'Stack trace: java.net.SocketTimeoutException at com.inkling.Api.call ... retried 3 times.',
|
||||
'오늘 점심 김치찌개 맛있었음. 오후에 디자인 미팅 있다.'
|
||||
];
|
||||
|
||||
it.each(cases)('Korean title + 3 lines for: %s', async (input) => {
|
||||
const r = await provider.generate({ text: input });
|
||||
expect(/[가-힣]/.test(r.title)).toBe(true);
|
||||
expect(r.summary.split('\n')).toHaveLength(3);
|
||||
for (const t of r.tags) expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
||||
}, 180_000);
|
||||
});
|
||||
71
tests/unit/LocalOllamaProvider.test.ts
Normal file
71
tests/unit/LocalOllamaProvider.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
||||
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';
|
||||
|
||||
describe('LocalOllamaProvider', () => {
|
||||
let mock: MockAgent;
|
||||
let original: ReturnType<typeof getGlobalDispatcher>;
|
||||
|
||||
beforeEach(() => {
|
||||
original = getGlobalDispatcher();
|
||||
mock = new MockAgent();
|
||||
mock.disableNetConnect();
|
||||
setGlobalDispatcher(mock);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setGlobalDispatcher(original);
|
||||
await mock.close();
|
||||
});
|
||||
|
||||
it('generate parses Ollama JSON', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||
response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] })
|
||||
});
|
||||
const r = await new LocalOllamaProvider().generate({ text: 'x' });
|
||||
expect(r.title).toBe('회의');
|
||||
});
|
||||
|
||||
it('generate throws on non-JSON', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||
response: 'not json'
|
||||
});
|
||||
await expect(new LocalOllamaProvider().generate({ text: 'x' })).rejects.toThrow(/json/i);
|
||||
});
|
||||
|
||||
it('generate aborts on timeout', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply((async () => {
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
return { statusCode: 200, data: '{}' };
|
||||
}) as never);
|
||||
await expect(
|
||||
new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x' })
|
||||
).rejects.toThrow();
|
||||
}, 2000);
|
||||
|
||||
it('healthCheck ok=true when model present', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
|
||||
models: [{ name: 'gemma4:e4b' }]
|
||||
});
|
||||
const h = await new LocalOllamaProvider().healthCheck();
|
||||
expect(h.ok).toBe(true);
|
||||
expect(h.model).toBe('gemma4:e4b');
|
||||
});
|
||||
|
||||
it('healthCheck ok=false when missing', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
|
||||
models: [{ name: 'other:latest' }]
|
||||
});
|
||||
const h = await new LocalOllamaProvider().healthCheck();
|
||||
expect(h.ok).toBe(false);
|
||||
expect(h.reason).toMatch(/gemma4:e4b/);
|
||||
});
|
||||
|
||||
it('healthCheck ok=false on connection error', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
|
||||
.replyWithError(new Error('ECONNREFUSED'));
|
||||
const h = await new LocalOllamaProvider().healthCheck();
|
||||
expect(h.ok).toBe(false);
|
||||
expect(h.reason).toMatch(/connect|refused|unreachable/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user