135 lines
4.0 KiB
JavaScript
135 lines
4.0 KiB
JavaScript
// 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 };
|
|
},
|
|
};
|