From 7bc0622c9c1accb8119e8b307743c78498d0b4b6 Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 15 Jun 2026 13:35:31 +0100 Subject: [PATCH] feat: add ollama provider --- src/config/env.js | 4 + src/providers/factory.js | 5 +- src/providers/ollama.js | 134 +++++++++++++++++++++++++++++ test/config/env.test.js | 107 +++++++++++++++++++++++- test/providers/factory.test.js | 148 ++++++++++++++++++++++++++++++++- 5 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 src/providers/ollama.js diff --git a/src/config/env.js b/src/config/env.js index 57cb3df..a39324b 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -41,5 +41,9 @@ export function loadConfig() { maxLogChars: parseNum('CHATGPT_MCP_MAX_LOG_CHARS', process.env.CHATGPT_MCP_MAX_LOG_CHARS, 10000), redactSecrets: parseBool('CHATGPT_MCP_REDACT_SECRETS', process.env.CHATGPT_MCP_REDACT_SECRETS, true), chatgptMcpProvider: process.env.CHATGPT_MCP_PROVIDER || 'openai', + ollamaBaseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434', + ollamaModel: process.env.OLLAMA_MODEL || 'qwen3:latest', + ollamaTemperature: parseNum('OLLAMA_TEMPERATURE', process.env.OLLAMA_TEMPERATURE, 0.2), + ollamaTimeout: parseNum('OLLAMA_TIMEOUT', process.env.OLLAMA_TIMEOUT, 60), }; } diff --git a/src/providers/factory.js b/src/providers/factory.js index cb6bfe4..ffb6e43 100644 --- a/src/providers/factory.js +++ b/src/providers/factory.js @@ -3,8 +3,9 @@ import { openaiProvider } from "./openai.js"; import { manualExportProvider } from "./manual-export.js"; +import { ollamaProvider } from "./ollama.js"; -const SUPPORTED_PROVIDERS = new Set(["openai", "manual"]); +const SUPPORTED_PROVIDERS = new Set(["openai", "manual", "ollama"]); /** * Create a chat provider from configuration. @@ -28,6 +29,8 @@ export function createChatProvider(config) { return openaiProvider; case "manual": return manualExportProvider; + case "ollama": + return ollamaProvider; default: // Should not reach here because of the set check above. throw new Error(`Unknown provider "${providerName}".`); diff --git a/src/providers/ollama.js b/src/providers/ollama.js new file mode 100644 index 0000000..5c6f809 --- /dev/null +++ b/src/providers/ollama.js @@ -0,0 +1,134 @@ +// Ollama chat provider adapter. +// Calls Ollama's /api/chat endpoint (OpenAI-compatible format) using native fetch(). +// No external SDK dependency — zero new dependencies. + +const DEFAULT_BASE_URL = "http://localhost:11434"; +const DEFAULT_MODEL = "qwen3:latest"; +const DEFAULT_TEMPERATURE = 0.2; +const DEFAULT_TIMEOUT_SECONDS = 60; + +/** + * Strip trailing slashes from base URL so path concatenation is always correct. + */ +function normalizeBaseUrl(raw) { + return (raw || "").replace(/\/+$/, ""); +} + +/** + * Derive a safe error category string from a caught error. + * Does not create new Error classes — just returns a categorising label. + */ +function getErrorKind(error) { + if (error?.name === "AbortError" || /timed?out/i.test(String(error.message ?? ""))) { + return "OllamaTimeoutError"; + } + + const status = error?.status ?? error?.response?.status; + if (typeof status === "number") { + if (status === 404) return "OllamaModelNotFoundError"; + if (status === 422) return "OllamaValidationError"; + if (status === 501) return "OllamaApiNotAvailableError"; + } + + return "OllamaRequestError"; +} + +/** + * Extract a safe error message, never leaking secrets or internals. + */ +function safeErrorMessage(error) { + const msg = String(error.message ?? ""); + if (!msg) return "unknown Ollama error"; + return msg; +} + +/** + * Chat provider backed by Ollama's /api/chat endpoint. + * Implements { send(reviewRequest, config) => Promise<{ content: string }> }. + */ +export const ollamaProvider = { + /** + * @param {{ prompt?: string }} reviewRequest - ProviderRequest (minimal — only prompt used). + * @param {object} config - Full config from loadConfig(). + * @returns {Promise<{ content: string }>} Advisory response text. + */ + async send(reviewRequest, config) { + const baseUrl = normalizeBaseUrl(config?.ollamaBaseUrl || DEFAULT_BASE_URL); + const model = config?.ollamaModel || DEFAULT_MODEL; + const temperature = + config?.ollamaTemperature != null + ? Number(config.ollamaTemperature) + : DEFAULT_TEMPERATURE; + const timeoutSeconds = + config?.ollamaTimeout != null + ? Number(config.ollamaTimeout) + : DEFAULT_TIMEOUT_SECONDS; + + const prompt = reviewRequest?.prompt || ""; + + // Build the request body in OpenAI-compatible chat format. + const requestBody = JSON.stringify({ + model, + messages: [{ role: "system", content: prompt }], + stream: false, + options: { + temperature: isNaN(temperature) ? DEFAULT_TEMPERATURE : temperature, + }, + }); + + // Build AbortController for timeout. + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000); + + let response; + try { + response = await fetch(`${baseUrl}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: requestBody, + signal: controller.signal, + }); + } catch (error) { + clearTimeout(timeoutId); + + const kind = getErrorKind(error); + throw new Error( + `Ollama API error (${kind}): ${safeErrorMessage(error)}` + ); + } finally { + clearTimeout(timeoutId); + } + + // Handle non-2xx status codes. + if (!response.ok) { + let bodyText = ""; + try { + bodyText = await response.text(); + } catch (_) { + /* ignore unparseable error bodies */ + } + + const errObj = new Error(`HTTP ${response.status}`); + errObj.status = response.status; + const kind = getErrorKind(errObj); + const detail = bodyText ? ` — ${bodyText.slice(0, 200)}` : ""; + throw new Error( + `Ollama API error (${kind}): HTTP ${response.status}${detail}` + ); + } + + // Parse JSON response. + let data; + try { + data = await response.json(); + } catch (_) { + throw new Error( + "Ollama API error (OllamaRequestError): invalid JSON response." + ); + } + + // Ollama /api/chat returns: { model, message: { role, content }, done } + const content = data?.message?.content ?? ""; + return { content }; + }, +}; diff --git a/test/config/env.test.js b/test/config/env.test.js index 8bf4bad..f7c2c33 100644 --- a/test/config/env.test.js +++ b/test/config/env.test.js @@ -5,7 +5,7 @@ const originalEnv = { ...process.env }; function setEnv(partial) { for (const key of Object.keys(process.env)) { - if (key.startsWith('OPENAI_') || key.startsWith('CHATGPT_MCP_')) { + if (key.startsWith('OPENAI_') || key.startsWith('CHATGPT_MCP_') || key.startsWith('OLLAMA_')) { delete process.env[key]; } } @@ -18,7 +18,7 @@ beforeEach(() => { afterEach(() => { for (const key of Object.keys(process.env)) { - if (key.startsWith('OPENAI_') || key.startsWith('CHATGPT_MCP_')) { + if (key.startsWith('OPENAI_') || key.startsWith('CHATGPT_MCP_') || key.startsWith('OLLAMA_')) { delete process.env[key]; } } @@ -47,6 +47,10 @@ describe('loadConfig', () => { expect(cfg.maxFiles).toBe(5); expect(cfg.maxLogChars).toBe(10000); expect(cfg.redactSecrets).toBe(true); + expect(cfg.ollamaBaseUrl).toBe('http://localhost:11434'); + expect(cfg.ollamaModel).toBe('qwen3:latest'); + expect(cfg.ollamaTemperature).toBe(0.2); + expect(cfg.ollamaTimeout).toBe(60); }); it('applies custom string values', () => { @@ -162,3 +166,102 @@ describe('loadConfig chatgptMcpProvider', () => { expect(cfg.chatgptMcpProvider).toBe('ollama'); }); }); + +// --- Ollama env vars --- + +describe('loadConfig ollama env vars', () => { + it('returns default ollama values when no OLLAMA_ vars set', () => { + const cfg = loadConfig(); + expect(cfg.ollamaBaseUrl).toBe('http://localhost:11434'); + expect(cfg.ollamaModel).toBe('qwen3:latest'); + expect(cfg.ollamaTemperature).toBe(0.2); + expect(cfg.ollamaTimeout).toBe(60); + }); + + it('applies custom OLLAMA_BASE_URL', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_BASE_URL: 'http://localhost:11435' }); + const cfg = loadConfig(); + expect(cfg.ollamaBaseUrl).toBe('http://localhost:11435'); + }); + + it('returns raw OLLAMA_BASE_URL — trailing slash normalization is in the provider', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_BASE_URL: 'http://localhost:11434/' }); + const cfg = loadConfig(); + expect(cfg.ollamaBaseUrl).toBe('http://localhost:11434/'); + }); + + it('applies custom OLLAMA_MODEL', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_MODEL: 'llama3' }); + const cfg = loadConfig(); + expect(cfg.ollamaModel).toBe('llama3'); + }); + + it('applies custom OLLAMA_TEMPERATURE', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_TEMPERATURE: '0.7' }); + const cfg = loadConfig(); + expect(cfg.ollamaTemperature).toBe(0.7); + }); + + it('applies custom OLLAMA_TIMEOUT', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_TIMEOUT: '120' }); + const cfg = loadConfig(); + expect(cfg.ollamaTimeout).toBe(120); + }); + + it('throws on invalid OLLAMA_TEMPERATURE', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_TEMPERATURE: 'abc' }); + expect(() => loadConfig()).toThrow( + 'Configuration error: OLLAMA_TEMPERATURE must be a positive number, got "abc".' + ); + }); + + it('throws on negative OLLAMA_TIMEOUT', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_TIMEOUT: '-10' }); + expect(() => loadConfig()).toThrow( + 'Configuration error: OLLAMA_TIMEOUT must be a positive number, got "-10".' + ); + }); + + it('works with ollama provider and all ollama env vars', () => { + setEnv({ + OPENAI_API_KEY: 'sk-test', + CHATGPT_MCP_PROVIDER: 'ollama', + OLLAMA_BASE_URL: 'http://ollama-server:8080', + OLLAMA_MODEL: 'mistral', + OLLAMA_TEMPERATURE: '0.5', + OLLAMA_TIMEOUT: '90', + }); + const cfg = loadConfig(); + expect(cfg.chatgptMcpProvider).toBe('ollama'); + expect(cfg.ollamaBaseUrl).toBe('http://ollama-server:8080'); + expect(cfg.ollamaModel).toBe('mistral'); + expect(cfg.ollamaTemperature).toBe(0.5); + expect(cfg.ollamaTimeout).toBe(90); + }); +}); + +describe('loadConfig ollama env vars with trailing slash', () => { + it('returns raw base URL — normalization happens in the provider', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_BASE_URL: 'http://localhost:11434//' }); + const cfg = loadConfig(); + expect(cfg.ollamaBaseUrl).toBe('http://localhost:11434//'); + }); + + it('returns raw base URL with multiple slashes', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_BASE_URL: 'http://localhost:11434///' }); + const cfg = loadConfig(); + expect(cfg.ollamaBaseUrl).toBe('http://localhost:11434///'); + }); + + it('handles base URL without trailing slash', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_BASE_URL: 'http://localhost:11434' }); + const cfg = loadConfig(); + expect(cfg.ollamaBaseUrl).toBe('http://localhost:11434'); + }); + + it('handles empty base URL as default', () => { + setEnv({ OPENAI_API_KEY: 'test-key', OLLAMA_BASE_URL: '' }); + const cfg = loadConfig(); + expect(cfg.ollamaBaseUrl).toBe('http://localhost:11434'); + }); +}); diff --git a/test/providers/factory.test.js b/test/providers/factory.test.js index 8be2312..86187ee 100644 --- a/test/providers/factory.test.js +++ b/test/providers/factory.test.js @@ -54,10 +54,21 @@ describe("supported providers", () => { // --- Unsupported providers --- describe("unsupported providers", () => { - it("throws on ollama provider", () => { - expect(() => createChatProvider({ chatgptMcpProvider: "ollama" })).toThrow( - /Unsupported chat provider "ollama"/, - ); + it("supports ollama provider", () => { + const provider = createChatProvider({ chatgptMcpProvider: "ollama" }); + expect(provider).toBeDefined(); + expect(typeof provider.send).toBe("function"); + }); + + it("returns the same singleton instance for repeated calls with 'ollama'", () => { + const p1 = createChatProvider({ chatgptMcpProvider: "ollama" }); + const p2 = createChatProvider({ chatgptMcpProvider: "ollama" }); + expect(p1).toBe(p2); + }); + + it("is different from openai provider", () => { + const ollamaP = createChatProvider({ chatgptMcpProvider: "ollama" }); + expect(ollamaP).not.toBe(openaiProvider); }); it("throws on unknown provider name", () => { @@ -259,3 +270,132 @@ describe("manual provider", () => { expect(manualExportProvider).toBe(manualP); }); }); + +// --- Ollama provider send (fetch-mocked) --- + +describe("ollama provider send", () => { + it("builds correct request body with config values", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ model: "qwen3", message: { role: "assistant", content: "Hello!" }, done: true }), + }); + + // Create a custom ollamaProvider with mocked fetch + const { ollamaProvider } = await import("../../src/providers/ollama.js"); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + try { + const config = { + chatgptMcpProvider: "ollama", + ollamaBaseUrl: "http://test:11434", + ollamaModel: "test-model", + ollamaTemperature: 0.5, + ollamaTimeout: 30, + }; + const result = await ollamaProvider.send({ prompt: "What is AI?" }, config); + expect(result.content).toBe("Hello!"); + const calls = fetchMock.mock.calls; + expect(calls.length).toBe(1); + const [url, init] = calls[0]; + expect(url).toBe("http://test:11434/api/chat"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ + model: "test-model", + messages: [{ role: "system", content: "What is AI?" }], + stream: false, + options: { temperature: 0.5 }, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("uses defaults when no config ollama fields are set", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ model: "qwen3", message: { role: "assistant", content: "OK" }, done: true }), + }); + + const { ollamaProvider } = await import("../../src/providers/ollama.js"); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + try { + const config = {}; + const result = await ollamaProvider.send({ prompt: "hi" }, config); + expect(result.content).toBe("OK"); + const [url, init] = fetchMock.mock.calls[0]; + const body = JSON.parse(init.body); + expect(url).toBe("http://localhost:11434/api/chat"); + expect(body.model).toBe("qwen3:latest"); + expect(body.options.temperature).toBe(0.2); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("handles non-2xx response with error category", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404, text: async () => "model not found" }); + + const { ollamaProvider } = await import("../../src/providers/ollama.js"); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + try { + const config = {}; + await expect( + ollamaProvider.send({ prompt: "hi" }, config), + ).rejects.toThrow(/Ollama API error \(OllamaModelNotFoundError\)/); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("handles fetch network errors", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + + const { ollamaProvider } = await import("../../src/providers/ollama.js"); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + try { + const config = {}; + await expect( + ollamaProvider.send({ prompt: "hi" }, config), + ).rejects.toThrow(/Ollama API error \(OllamaRequestError\)/); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("handles invalid JSON response", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => "not json" }); + + const { ollamaProvider } = await import("../../src/providers/ollama.js"); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + try { + const config = {}; + await expect( + ollamaProvider.send({ prompt: "hi" }, config), + ).rejects.toThrow(/invalid JSON response/); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("returns empty content when message.content is missing", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ model: "test", done: true }), + }); + + const { ollamaProvider } = await import("../../src/providers/ollama.js"); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + try { + const config = {}; + const result = await ollamaProvider.send({ prompt: "hi" }, config); + expect(result.content).toBe(""); + } finally { + globalThis.fetch = originalFetch; + } + }); +});