// 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 }; }, };