diff --git a/components/LLMProviderPanel.jsx b/components/LLMProviderPanel.jsx index 2d80c01..8b54fc3 100644 --- a/components/LLMProviderPanel.jsx +++ b/components/LLMProviderPanel.jsx @@ -101,7 +101,10 @@ export default function LLMProviderPanel({ onSave, onClose }) { }; const isCustom = config.provider === "custom"; - const showUrlField = isCustom || config.provider === "ollama"; + const showUrlField = + isCustom + || config.provider === "ollama" + || config.provider === "ollama_local"; return ( diff --git a/engine/adapters/ollamaLocal.js b/engine/adapters/ollamaLocal.js new file mode 100644 index 0000000..1e60a77 --- /dev/null +++ b/engine/adapters/ollamaLocal.js @@ -0,0 +1,81 @@ +/** + * Ollama (local) streaming adapter — OpenAI-compat path. + * + * Sends chat to Ollama's /v1/chat/completions through the Django proxy. + * The OpenAI-compat endpoint has a stable OpenAI-spec wire contract: + * tool_calls[].function.arguments is always a JSON string. The native + * /api/chat endpoint flipped this field's expected shape between Ollama + * versions (object on <=0.16.2, string on >0.16.2 for inbound history), + * which broke multi-round tool-call sessions for everyone running a + * newer local install. See chatbox-core PR #22 → reverted by #23, whose + * follow-up note named this adapter as the right architectural fix. + * + * Routes through /apps/tethysdash/ollama-proxy/v1/chat/completions/ so + * the browser doesn't hit CORS against a non-publicly-exposed Ollama + * daemon and so CSRF/auth wrap the call uniformly with other LLM + * providers. + * + * Model discovery + capability gating still go through the Ollama + * /api/tags + /api/show paths — see helpers/index.js listModels for + * the ollama_local branch. + */ +import OpenAI from "openai"; +import { mergeToolCalls } from "../../helpers/index.js"; + +const PROXY_BASE = "/apps/tethysdash/ollama-proxy/v1"; + +export async function streamChat({ + baseUrl, apiKey, model, + messages, tools, csrfToken, signal, + onThinkingChunk, onContentChunk, +}) { + const absoluteProxyBase = `${globalThis.location.origin}${PROXY_BASE}`; + + const client = new OpenAI({ + baseURL: absoluteProxyBase, + apiKey: apiKey || "ollama", + dangerouslyAllowBrowser: true, + defaultHeaders: { + ...(csrfToken ? { "x-csrftoken": csrfToken } : {}), + ...(baseUrl ? { "x-ollama-host": baseUrl } : {}), + ...(apiKey ? { "x-ollama-key": apiKey } : {}), + }, + }); + + const mergedMessage = { role: "assistant", content: "", thinking: "", tool_calls: null }; + + const stream = await client.chat.completions.create( + { + model, + messages, + tools: tools?.length ? tools : undefined, + stream: true, + max_completion_tokens: 16384, + }, + { signal }, + ); + + for await (const chunk of stream) { + if (signal?.aborted) break; + + const delta = chunk.choices?.[0]?.delta; + if (!delta) continue; + + if (typeof delta.content === "string" && delta.content) { + mergedMessage.content += delta.content; + onContentChunk?.(delta.content); + } + + if (typeof delta.reasoning === "string" && delta.reasoning) { + mergedMessage.thinking += delta.reasoning; + onThinkingChunk?.(delta.reasoning); + } + + if (Array.isArray(delta.tool_calls)) { + mergedMessage.tool_calls = mergeToolCalls(mergedMessage.tool_calls ?? [], delta.tool_calls); + } + } + + if (mergedMessage.tool_calls === null) delete mergedMessage.tool_calls; + return { message: mergedMessage }; +} diff --git a/engine/adapters/ollamaLocal.test.js b/engine/adapters/ollamaLocal.test.js new file mode 100644 index 0000000..6012210 --- /dev/null +++ b/engine/adapters/ollamaLocal.test.js @@ -0,0 +1,217 @@ +/** + * engine/adapters/ollamaLocal.test.js — coverage for the OpenAI-compat + * adapter that targets Ollama's /v1/chat/completions via the Django proxy. + * + * Pinned behaviors (in order of how they catch regressions): + * - Routes through /apps/tethysdash/ollama-proxy/v1 (NOT direct browser + * call to localhost:11434). Catches a regression where the adapter + * starts hitting the daemon directly and runs into CORS. + * - Forwards Django proxy headers (x-csrftoken, x-ollama-host, + * x-ollama-key) via SDK defaultHeaders so the proxy can route to the + * user-configured Ollama host. + * - Multi-round tool-call wire contract: when the engine echoes a prior + * assistant tool_call back as part of `messages`, the request shape + * must follow OpenAI spec (arguments stringified). This is the + * regression-catcher PR #23 named in its follow-up — "the test that + * would have caught this regression". + * - Return shape matches `{ message: { content, tool_calls } }` — the + * contract engine's getMessage() reads from. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Module-level capture so the mock factory can record what the adapter +// passed in without exporting hooks from the SUT. +let capturedConfig = null; +let capturedCreateParams = null; +let mockStream = null; + +vi.mock("openai", () => { + return { + default: vi.fn(function (config) { + capturedConfig = config; + this.chat = { + completions: { + create: vi.fn(async (params) => { + capturedCreateParams = params; + return mockStream; + }), + }, + }; + }), + }; +}); + +async function* makeStream(chunks) { + for (const chunk of chunks) yield chunk; +} + +beforeEach(() => { + capturedConfig = null; + capturedCreateParams = null; + mockStream = makeStream([]); + // The adapter reads globalThis.location.origin to build the proxy URL. + // jsdom sets it to "http://localhost:3000" by default; pin explicitly so + // assertions don't drift with vitest config changes. + if (!globalThis.location) { + globalThis.location = { origin: "http://localhost:3000" }; + } +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function call(opts = {}) { + const { streamChat } = await import("./ollamaLocal.js"); + return streamChat({ + baseUrl: "http://localhost:11434", + apiKey: "ollama-test-key", + model: "qwen3:30b-a3b-instruct-2507-q4_K_M", + messages: [{ role: "user", content: "hi" }], + tools: [], + csrfToken: "csrf-test", + ...opts, + }); +} + +describe("ollamaLocal adapter — Django proxy routing", () => { + it("uses /apps/tethysdash/ollama-proxy/v1 as the SDK baseURL (absolute)", async () => { + await call(); + expect(capturedConfig.baseURL).toBe( + "http://localhost:3000/apps/tethysdash/ollama-proxy/v1", + ); + }); + + it("does NOT call the Ollama daemon directly (no localhost:11434 in baseURL)", async () => { + await call({ baseUrl: "http://localhost:11434" }); + expect(capturedConfig.baseURL).not.toContain("11434"); + }); + + it("forwards x-csrftoken, x-ollama-host, x-ollama-key via defaultHeaders", async () => { + await call({ + baseUrl: "http://localhost:11434", + apiKey: "secret-key", + csrfToken: "csrf-abc", + }); + expect(capturedConfig.defaultHeaders).toEqual({ + "x-csrftoken": "csrf-abc", + "x-ollama-host": "http://localhost:11434", + "x-ollama-key": "secret-key", + }); + }); + + it("omits header entries when their source values are empty", async () => { + await call({ baseUrl: "", apiKey: "", csrfToken: "" }); + expect(capturedConfig.defaultHeaders).toEqual({}); + }); +}); + +describe("ollamaLocal adapter — multi-round wire contract", () => { + it( + "second-round request includes prior assistant tool_calls with arguments as STRING " + + "(the regression PR #23 named — Ollama /v1 follows OpenAI spec)", + async () => { + // Construct the kind of `messages` array the engine builds AFTER a + // first-round tool call: user → assistant(tool_calls) → tool result. + // Arguments come from mergeToolCalls; for /v1/chat/completions the + // wire shape must be a JSON string. + const messages = [ + { role: "user", content: "list dates" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "list_available_dates", + arguments: '{"model":"cfe_nom"}', + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + content: '{"dates": []}', + }, + ]; + + await call({ messages }); + + const echoed = capturedCreateParams.messages.find((m) => m.role === "assistant"); + expect(echoed).toBeDefined(); + expect(echoed.tool_calls).toBeDefined(); + expect(typeof echoed.tool_calls[0].function.arguments).toBe("string"); + }, + ); +}); + +describe("ollamaLocal adapter — streaming + return shape", () => { + it("accumulates content deltas and returns { message: { content, ... } }", async () => { + mockStream = makeStream([ + { choices: [{ delta: { content: "Hello" } }] }, + { choices: [{ delta: { content: " world" } }] }, + ]); + const chunks = []; + const result = await call({ onContentChunk: (c) => chunks.push(c) }); + expect(chunks).toEqual(["Hello", " world"]); + expect(result).toEqual({ + message: expect.objectContaining({ + role: "assistant", + content: "Hello world", + }), + }); + }); + + it("accumulates tool_calls and drops the field when nothing streamed", async () => { + mockStream = makeStream([ + { choices: [{ delta: { content: "ok" } }] }, + ]); + const result = await call(); + expect("tool_calls" in result.message).toBe(false); + }); + + it("preserves tool_calls when the stream emits them", async () => { + mockStream = makeStream([ + { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "list_available_dates", arguments: '{"model":"cfe_nom"}' }, + }, + ], + }, + }, + ], + }, + ]); + const result = await call(); + expect(Array.isArray(result.message.tool_calls)).toBe(true); + expect(result.message.tool_calls[0].function.name).toBe( + "list_available_dates", + ); + }); + + it("passes model, messages, tools, and max_completion_tokens to chat.completions.create", async () => { + await call({ + model: "qwen3:30b-a3b-instruct-2507-q4_K_M", + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f", parameters: {} } }], + }); + expect(capturedCreateParams.model).toBe( + "qwen3:30b-a3b-instruct-2507-q4_K_M", + ); + expect(capturedCreateParams.messages).toEqual([{ role: "user", content: "hi" }]); + expect(capturedCreateParams.tools).toBeDefined(); + expect(capturedCreateParams.stream).toBe(true); + expect(capturedCreateParams.max_completion_tokens).toBe(16384); + }); +}); diff --git a/engine/index.js b/engine/index.js index 24bdf3c..c0bc504 100644 --- a/engine/index.js +++ b/engine/index.js @@ -43,12 +43,14 @@ import { streamChat as openaiStreamChat } from "./adapters/openai.js"; import { streamChat as anthropicStreamChat } from "./adapters/anthropic.js"; import { streamChat as ollamaStreamChat } from "./adapters/ollama.js"; import { streamChat as geminiStreamChat } from "./adapters/gemini.js"; +import { streamChat as ollamaLocalStreamChat } from "./adapters/ollamaLocal.js"; const PROVIDER_ADAPTERS = { openai: openaiStreamChat, anthropic: anthropicStreamChat, gemini: geminiStreamChat, ollama: ollamaStreamChat, + ollama_local: ollamaLocalStreamChat, custom: openaiStreamChat, }; diff --git a/helpers/index.js b/helpers/index.js index 1e8b979..ce618c7 100644 --- a/helpers/index.js +++ b/helpers/index.js @@ -434,7 +434,7 @@ export async function listModels(providerConfig = {}, options = {}) { } } - if (provider === "ollama") { + if (provider === "ollama" || provider === "ollama_local") { const csrf = typeof options?.csrfToken === "string" ? options.csrfToken : ""; const headers = { ...(csrf ? { "x-csrftoken": csrf } : {}), diff --git a/storage/llmProviderStorage.js b/storage/llmProviderStorage.js index 04f2ed0..bc9b3bf 100644 --- a/storage/llmProviderStorage.js +++ b/storage/llmProviderStorage.js @@ -12,6 +12,7 @@ const PROVIDER_PRESETS = { anthropic: { baseUrl: "https://api.anthropic.com/v1", label: "Anthropic", thinkingBudget: 4096 }, gemini: { baseUrl: "", label: "Google AI Studio" }, ollama: { baseUrl: "", label: "Ollama Cloud" }, + ollama_local: { baseUrl: "http://localhost:11434", label: "Ollama (local)" }, custom: { baseUrl: "", label: "Local / Custom" }, };