feat: add ollama provider

This commit is contained in:
2026-06-15 13:35:31 +01:00
parent 1228d9f2db
commit 7bc0622c9c
5 changed files with 391 additions and 7 deletions
+105 -2
View File
@@ -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');
});
});
+144 -4
View File
@@ -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;
}
});
});