@windagency/valora-plugin-ollama 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @windagency/valora-plugin-ollama
2
+
3
+ Self-managed Ollama LLM provider — runs models locally via the Ollama binary.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ valora plugin add ollama
9
+ ```
10
+
11
+ The plugin depends on the `ollama` binary. On first run Valora prompts before running the install command. After install, the plugin manages the `ollama serve` lifecycle (start on activate, stop on deactivate) and lazily pulls any model that is requested but not yet local.
12
+
13
+ ## What it contributes
14
+
15
+ - `api.providers.register('ollama', ...)` — an LLM provider with `validateModel` that lists locally pulled models via `/api/tags`.
16
+ - `api.lifecycle.onDeactivate(...)` — gracefully stops the Ollama process.
17
+
18
+ ## Permissions
19
+
20
+ - `code-exec` — required by the loader; also justified by `spawn('ollama', ['serve'])`.
21
+ - `fs-write` — informational (the plugin's runtime code writes nothing; the install script does).
22
+ - `network` — informational; the plugin polls `http://localhost:11434/api/tags` and pulls models.
23
+
24
+ ## Configuration
25
+
26
+ | Env var | Effect |
27
+ | ---------------------- | ------------------------------------------------------------- |
28
+ | `OLLAMA_DEFAULT_MODEL` | Default model when none is specified. Defaults to `llama3.1`. |
29
+
30
+ The plugin requires no API key. `peerDependencies` includes `openai@^4.67.0` — Valora's host already provides this.
31
+
32
+ ## See also
33
+
34
+ - [Plugins user guide](../../documentation/user-guide/plugins.md)
package/dist/index.js ADDED
@@ -0,0 +1,327 @@
1
+ import { createRequire } from 'module'; const require = createRequire(import.meta.url);
2
+
3
+ // src/binary-manager.ts
4
+ import { execFile } from "child_process";
5
+ import { promisify } from "util";
6
+ var execFileAsync = promisify(execFile);
7
+ var OllamaNotInstalledError = class extends Error {
8
+ constructor() {
9
+ super("Ollama binary not found. Install from https://ollama.com and ensure it is on your PATH.");
10
+ this.name = "OllamaNotInstalledError";
11
+ }
12
+ };
13
+ var OllamaBinaryManagerImpl = class {
14
+ executor;
15
+ constructor(executor = () => execFileAsync("ollama", ["--version"])) {
16
+ this.executor = executor;
17
+ }
18
+ /** Returns `true` when `ollama --version` exits with code 0. */
19
+ async isInstalled() {
20
+ try {
21
+ await this.executor();
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+ /** Throws {@link OllamaNotInstalledError} if the binary is not on PATH. */
28
+ async assertInstalled() {
29
+ if (!await this.isInstalled()) {
30
+ throw new OllamaNotInstalledError();
31
+ }
32
+ }
33
+ };
34
+
35
+ // src/model-manager.ts
36
+ var OllamaApiError = class extends Error {
37
+ statusCode;
38
+ constructor(message, statusCode) {
39
+ super(message);
40
+ this.name = "OllamaApiError";
41
+ this.statusCode = statusCode;
42
+ }
43
+ };
44
+ function assertOk(response, context) {
45
+ if (!response.ok) {
46
+ throw new OllamaApiError(`Ollama API error during ${context}: ${response.statusText}`, response.status);
47
+ }
48
+ }
49
+ var OllamaModelManagerImpl = class {
50
+ /** Returns the names of all models stored locally on the Ollama server. */
51
+ async listLocalModels(baseUrl) {
52
+ const response = await globalThis.fetch(`${baseUrl}/api/tags`);
53
+ assertOk(response, "listLocalModels");
54
+ const body = await response.json();
55
+ return body.models.map((m) => m.name);
56
+ }
57
+ /**
58
+ * Ensures the given model is available locally.
59
+ * If the model is already present, this method returns immediately.
60
+ * Otherwise it issues a pull request and waits for completion.
61
+ */
62
+ async ensureModel(baseUrl, model) {
63
+ const localModels = await this.listLocalModels(baseUrl);
64
+ if (localModels.includes(model)) {
65
+ return;
66
+ }
67
+ const response = await globalThis.fetch(`${baseUrl}/api/pull`, {
68
+ body: JSON.stringify({ name: model, stream: false }),
69
+ headers: { "Content-Type": "application/json" },
70
+ method: "POST"
71
+ });
72
+ assertOk(response, "ensureModel");
73
+ }
74
+ };
75
+
76
+ // src/ollama-provider.ts
77
+ import OpenAI from "openai";
78
+ var DEFAULT_OLLAMA_HOST = "http://localhost:11434";
79
+ var DEFAULT_MODEL = "llama3.1";
80
+ var DEFAULT_EMBED_MODEL = "nomic-embed-text";
81
+ var OllamaProvider = class {
82
+ name = "ollama";
83
+ client = null;
84
+ config;
85
+ managers;
86
+ constructor(config, managers) {
87
+ this.config = config;
88
+ this.managers = managers;
89
+ }
90
+ async complete(options) {
91
+ const model = options.model ?? this.getModel();
92
+ await this.ensureReady(model);
93
+ const response = await this.getClient().chat.completions.create({
94
+ max_tokens: options.max_tokens,
95
+ messages: this.mapMessages(options.messages),
96
+ model,
97
+ stop: options.stop,
98
+ temperature: options.temperature,
99
+ tools: options.tools?.map((tool) => ({
100
+ function: { description: tool.description, name: tool.name, parameters: tool.parameters },
101
+ type: "function"
102
+ })),
103
+ top_p: options.top_p
104
+ });
105
+ const choice = response.choices[0];
106
+ if (!choice) throw new Error("Ollama returned no choices");
107
+ return {
108
+ content: choice.message.content ?? "",
109
+ finish_reason: choice.finish_reason,
110
+ model: response.model,
111
+ role: "assistant",
112
+ tool_calls: choice.message.tool_calls?.map((tc) => ({
113
+ arguments: this.parseArgs(tc.function.arguments),
114
+ id: tc.id,
115
+ name: tc.function.name
116
+ })),
117
+ usage: response.usage ? {
118
+ completion_tokens: response.usage.completion_tokens,
119
+ prompt_tokens: response.usage.prompt_tokens,
120
+ total_tokens: response.usage.total_tokens
121
+ } : void 0
122
+ };
123
+ }
124
+ async embed(req) {
125
+ const model = req.model ?? DEFAULT_EMBED_MODEL;
126
+ await this.ensureReady(model);
127
+ const response = await this.getClient().embeddings.create({ input: req.input, model });
128
+ const vectors = response.data.map((d) => d.embedding);
129
+ const dim = vectors[0]?.length ?? 0;
130
+ return { dim, model: response.model, vectors };
131
+ }
132
+ getAlternativeModels() {
133
+ return ["llama3.1", "mistral", "codellama", "phi3", "qwen2"];
134
+ }
135
+ isConfigured() {
136
+ return true;
137
+ }
138
+ async streamComplete(options, onChunk) {
139
+ const model = options.model ?? this.getModel();
140
+ await this.ensureReady(model);
141
+ const stream = await this.getClient().chat.completions.create({
142
+ max_tokens: options.max_tokens,
143
+ messages: this.mapMessages(options.messages),
144
+ model,
145
+ stop: options.stop,
146
+ stream: true,
147
+ temperature: options.temperature,
148
+ top_p: options.top_p
149
+ });
150
+ let fullContent = "";
151
+ let finishReason;
152
+ for await (const chunk of stream) {
153
+ const choice = chunk.choices[0];
154
+ if (choice?.delta?.content) {
155
+ fullContent += choice.delta.content;
156
+ onChunk(choice.delta.content);
157
+ }
158
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
159
+ }
160
+ return { content: fullContent, finish_reason: finishReason, role: "assistant" };
161
+ }
162
+ async validateModel(modelName) {
163
+ try {
164
+ const host = this.getOllamaHost();
165
+ await this.managers.binary.assertInstalled();
166
+ await this.managers.process.ensureRunning(host);
167
+ const models = await this.managers.model.listLocalModels(host);
168
+ return models.some((m) => m === modelName || m.startsWith(`${modelName}:`));
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+ async ensureReady(model) {
174
+ const host = this.getOllamaHost();
175
+ await this.managers.binary.assertInstalled();
176
+ await this.managers.process.ensureRunning(host);
177
+ await this.managers.model.ensureModel(host, model);
178
+ }
179
+ getClient() {
180
+ this.client ??= new OpenAI({
181
+ apiKey: "ollama",
182
+ baseURL: `${this.getOllamaHost()}/v1`,
183
+ maxRetries: 2
184
+ });
185
+ return this.client;
186
+ }
187
+ getModel() {
188
+ return getStringConfig(this.config, "model", DEFAULT_MODEL);
189
+ }
190
+ getOllamaHost() {
191
+ return getStringConfig(this.config, "ollama_host", DEFAULT_OLLAMA_HOST);
192
+ }
193
+ mapMessages(messages) {
194
+ return messages.map((m) => ({
195
+ content: m.content,
196
+ role: m.role
197
+ }));
198
+ }
199
+ parseArgs(raw) {
200
+ try {
201
+ return JSON.parse(raw);
202
+ } catch {
203
+ return {};
204
+ }
205
+ }
206
+ };
207
+ function getStringConfig(config, key, fallback) {
208
+ const value = config[key];
209
+ return typeof value === "string" ? value : fallback;
210
+ }
211
+
212
+ // src/process-manager.ts
213
+ import { spawn } from "child_process";
214
+ var SIGKILL_TIMEOUT_MS = 5e3;
215
+ var OllamaStartupError = class extends Error {
216
+ constructor(baseUrl) {
217
+ super(`Ollama server did not start within the expected time at ${baseUrl}.`);
218
+ this.name = "OllamaStartupError";
219
+ }
220
+ };
221
+ var OllamaProcessManagerImpl = class {
222
+ pollDelayMs;
223
+ process = null;
224
+ constructor(pollDelayMs = 500) {
225
+ this.pollDelayMs = pollDelayMs;
226
+ }
227
+ /** Returns `true` when a GET to `${baseUrl}/api/tags` responds with a 2xx status. */
228
+ async isRunning(baseUrl) {
229
+ try {
230
+ const response = await globalThis.fetch(`${baseUrl}/api/tags`);
231
+ return response.ok;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+ /** Starts `ollama serve` if the server is not already reachable, then waits until ready. */
237
+ async ensureRunning(baseUrl) {
238
+ if (await this.isRunning(baseUrl)) return;
239
+ await this.startAndWait(baseUrl);
240
+ }
241
+ startAndWait(baseUrl) {
242
+ return new Promise((resolve, reject) => {
243
+ let settled = false;
244
+ const settle = (fn) => {
245
+ if (settled) return;
246
+ settled = true;
247
+ fn();
248
+ };
249
+ const child = spawn("ollama", ["serve"], { detached: false, stdio: "ignore" });
250
+ this.process = child;
251
+ child.once("error", (err) => {
252
+ this.process = null;
253
+ settle(() => reject(err));
254
+ });
255
+ child.once("close", () => {
256
+ this.process = null;
257
+ });
258
+ this.waitForReady(baseUrl).then(
259
+ () => settle(resolve),
260
+ (err) => settle(() => reject(err))
261
+ );
262
+ });
263
+ }
264
+ /**
265
+ * Sends SIGTERM to the managed process and waits for it to exit.
266
+ * If the process is already dead, resolves immediately without signalling.
267
+ * Escalates to SIGKILL after 5 000 ms if the process does not close.
268
+ */
269
+ async stop() {
270
+ const child = this.process;
271
+ if (!child) return;
272
+ this.process = null;
273
+ if (child.exitCode !== null || child.signalCode !== null) return;
274
+ return new Promise((resolve) => {
275
+ const timeout = setTimeout(() => {
276
+ child.kill("SIGKILL");
277
+ resolve();
278
+ }, SIGKILL_TIMEOUT_MS);
279
+ child.once("close", () => {
280
+ clearTimeout(timeout);
281
+ resolve();
282
+ });
283
+ child.kill("SIGTERM");
284
+ });
285
+ }
286
+ async waitForReady(baseUrl, maxAttempts = 20) {
287
+ for (let i = 0; i < maxAttempts; i++) {
288
+ if (await this.isRunning(baseUrl)) return;
289
+ await sleep(this.pollDelayMs);
290
+ }
291
+ throw new OllamaStartupError(baseUrl);
292
+ }
293
+ };
294
+ function sleep(ms) {
295
+ return new Promise((resolve) => setTimeout(resolve, ms));
296
+ }
297
+
298
+ // src/index.ts
299
+ function register(api) {
300
+ const binary = new OllamaBinaryManagerImpl();
301
+ const processManager = new OllamaProcessManagerImpl();
302
+ const model = new OllamaModelManagerImpl();
303
+ api.providers.register("ollama", (config) => new OllamaProvider(config, { binary, model, process: processManager }), {
304
+ configSchema: void 0,
305
+ contextWindows: { codellama: 16384, "llama3.1": 128e3, mistral: 32768, phi3: 128e3, qwen2: 32768 },
306
+ defaultModel: "llama3.1",
307
+ description: "Self-managed Ollama provider \u2014 runs models locally via the Ollama binary",
308
+ envVars: { model: "OLLAMA_DEFAULT_MODEL" },
309
+ helpText: "Use any model available via ollama pull. No API key required.",
310
+ label: "Ollama",
311
+ modelModes: [
312
+ { mode: "default", model: "llama3.1" },
313
+ { mode: "default", model: "mistral" },
314
+ { mode: "default", model: "codellama" },
315
+ { mode: "default", model: "phi3" },
316
+ { mode: "default", model: "qwen2" }
317
+ ],
318
+ modelPrefix: "ollama:",
319
+ requiresApiKey: false
320
+ });
321
+ api.lifecycle.onDeactivate(async () => {
322
+ await processManager.stop();
323
+ });
324
+ }
325
+ export {
326
+ register
327
+ };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@windagency/valora-plugin-ollama",
3
+ "version": "1.0.0",
4
+ "description": "Self-managed Ollama provider plugin for Valora — manages the Ollama binary and server process, supports any model available via ollama pull.",
5
+ "keywords": [
6
+ "valora",
7
+ "valora-plugin",
8
+ "ai",
9
+ "llm",
10
+ "ollama",
11
+ "local-llm",
12
+ "provider",
13
+ "self-hosted",
14
+ "open-source",
15
+ "typescript"
16
+ ],
17
+ "author": "Damien TIVELET <damien@wind-agency.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/windagency/valora.ai"
21
+ },
22
+ "license": "MIT",
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=22.0.0"
26
+ },
27
+ "volta": {
28
+ "node": "22.21.0",
29
+ "pnpm": "10.19.0"
30
+ },
31
+ "files": [
32
+ "valora-plugin.json",
33
+ "dist"
34
+ ],
35
+ "peerDependencies": {
36
+ "@windagency/valora": ">=0.1.0",
37
+ "openai": "^4.67.0"
38
+ },
39
+ "devDependencies": {
40
+ "esbuild": "^0.28.0",
41
+ "openai": "^4.67.0",
42
+ "@windagency/valora-plugin-api": "1.0.0"
43
+ },
44
+ "scripts": {
45
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:openai --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"",
46
+ "clean": "rm -rf ./dist",
47
+ "lint": "eslint --color",
48
+ "lint:fix": "eslint --color --fix",
49
+ "beautify": "prettier --check \"**/*.+(js|jsx|ts|tsx|json|md|yml|yaml)\"",
50
+ "beautify:fix": "prettier --write \"**/*.+(js|jsx|ts|tsx|json|md|yml|yaml)\"",
51
+ "format": "pnpm beautify:fix && pnpm lint:fix",
52
+ "test": "vitest run"
53
+ }
54
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "valora-plugin-ollama",
3
+ "version": "1.0.0",
4
+ "description": "Self-managed Ollama provider — downloads the Ollama binary on first use and runs it locally. Supports any model available via ollama pull.",
5
+ "engines": { "valora": ">=0.1.0" },
6
+ "contributes": ["code"],
7
+ "permissions": ["code-exec", "fs-write", "network"],
8
+ "requiresBinary": [
9
+ {
10
+ "autoInstall": true,
11
+ "install": "https://ollama.com",
12
+ "installCommand": "command -v zstd >/dev/null 2>&1 || sudo apt-get install -y zstd; curl -fsSL https://github.com/ollama/ollama/releases/latest/download/install.sh | sh",
13
+ "name": "ollama",
14
+ "postInstallCommand": "ollama serve >/dev/null 2>&1 & TRIES=0; until curl -sf http://localhost:11434/api/tags >/dev/null 2>&1 || [ $TRIES -ge 10 ]; do sleep 1; TRIES=$((TRIES+1)); done"
15
+ }
16
+ ],
17
+ "codeEntrypoint": "dist/index.js"
18
+ }