Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion components/LLMProviderPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Panel>
Expand Down
81 changes: 81 additions & 0 deletions engine/adapters/ollamaLocal.js
Original file line number Diff line number Diff line change
@@ -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 };
}
217 changes: 217 additions & 0 deletions engine/adapters/ollamaLocal.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions engine/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
2 changes: 1 addition & 1 deletion helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
1 change: 1 addition & 0 deletions storage/llmProviderStorage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
};

Expand Down
Loading