pi-ollama-cloud 0.1.2 → 0.2.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # CHANGELOG
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.2.1] - 2026-04-29
6
+
7
+ - Fix API key retrieval by using `AuthStorage` instead of `ctx.modelRegistry.getApiKeyForProvider`. The provider-level API key lookup was failing, causing auth to only work when an environment variable was set. Now reads from `auth.json` directly via the pi `AuthStorage` class.
8
+
9
+ ## [0.2.0] - 2026-04-28
10
+
11
+ - Add `PI_OLLAMA_WEB_TOOLS` environment variable to optionally disable `ollama_web_search` and `ollama_web_fetch` tool registrations. Set to `0`, `false`, `no`, `off`, or an empty string to opt-out. The model provider and `/ollama-cloud-refresh` command remain active regardless.
12
+
package/README.md CHANGED
@@ -81,7 +81,17 @@ Or add it to `~/.pi/agent/auth.json`:
81
81
  }
82
82
  ```
83
83
 
84
- ### 3. Fetch models
84
+ ### 3. Disable web tools (optional)
85
+
86
+ If you want to use a different web search or fetch tool (e.g. Brave) by default, and need to avoid conflicts with the built-in Ollama Cloud tools, set the `PI_OLLAMA_WEB_TOOLS` environment variable to any falsy value:
87
+
88
+ ```bash
89
+ export PI_OLLAMA_WEB_TOOLS=0
90
+ ```
91
+
92
+ Accepted disabling values are `0`, `false`, `no`, `off`, or an empty string. When disabled, `ollama_web_search` and `ollama_web_fetch` are not registered. The model provider and `/ollama-cloud-refresh` command remain active regardless.
93
+
94
+ ### 4. Fetch models
85
95
 
86
96
  On first launch the plugin will use a small set of fallback models. Run:
87
97
 
@@ -91,7 +101,7 @@ On first launch the plugin will use a small set of fallback models. Run:
91
101
 
92
102
  This fetches the full model list from the Ollama Cloud API and caches it locally.
93
103
 
94
- ### 4. Select a model
104
+ ### 5. Select a model
95
105
 
96
106
  Use `/model` or `Ctrl+L` to switch to an Ollama Cloud model. Models appear under the `ollama-cloud` provider.
97
107
 
package/index.ts CHANGED
@@ -11,300 +11,51 @@
11
11
  * 4. Use /model or ctrl+l to select an Ollama Cloud model
12
12
  *
13
13
  * Two endpoints are used to build the model list:
14
- * - GET https://ollama.com/v1/models → list of model IDs
15
- * - POST https://ollama.com/api/show → per-model details (capabilities, context length)
14
+ * - GET https://ollama.com/v1/models -> list of model IDs
15
+ * - POST https://ollama.com/api/show -> per-model details (capabilities, context length)
16
16
  *
17
17
  * Raw /api/show responses are cached at <agentDir>/cache/ollama-cloud-models.json
18
18
  * so the provider assembly can be debugged and re-derived without re-fetching.
19
19
  *
20
- * Cache never expires — run /ollama-cloud-refresh to update.
20
+ * Cache never expires -- run /ollama-cloud-refresh to update.
21
21
  * Cold cache falls back to a small set of hardcoded models.
22
22
  *
23
23
  * Only models with "tools" capability are registered.
24
24
  */
25
25
 
26
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
- import { join } from "node:path";
28
- import {
29
- type ExtensionAPI,
30
- type ExtensionCommandContext,
31
- type ExtensionContext,
32
- type ProviderModelConfig,
33
- getAgentDir,
34
- keyHint,
35
- truncateToVisualLines,
36
- } from "@mariozechner/pi-coding-agent";
37
- import { Text, truncateToWidth } from "@mariozechner/pi-tui";
38
- import { Type } from "@sinclair/typebox";
39
-
40
- const CACHE_DIR = join(getAgentDir(), "cache");
41
- const CACHE_FILE = join(CACHE_DIR, "ollama-cloud-models.json");
42
- const FETCH_TIMEOUT_MS = 10000;
43
-
44
- /**
45
- * Base URL for the Ollama Cloud API.
46
- * Defaults to "https://ollama.com"; override with OLLAMA_API_BASE to point at a proxy or self-hosted instance.
47
- */
48
- const OLLAMA_BASE = process.env.OLLAMA_API_BASE || "https://ollama.com";
49
-
50
- // --- Raw API types ---
51
-
52
- /** Response from POST /api/show */
53
- interface OllamaShowResponse {
54
- details: {
55
- parent_model: string;
56
- format: string;
57
- family: string;
58
- families: string[] | null;
59
- parameter_size: string;
60
- quantization_level: string;
61
- };
62
- model_info: Record<string, unknown>;
63
- capabilities: string[];
64
- modified_at: string;
65
- }
66
-
67
- /** On-disk cache: raw /api/show responses keyed by model ID */
68
- interface CachedData {
69
- timestamp: number;
70
- models: Record<string, OllamaShowResponse>;
71
- }
72
-
73
- // --- Assembly: raw API data → ProviderModelConfig[] ---
74
-
75
- function getContextLength(modelInfo: Record<string, unknown>): number {
76
- for (const [key, value] of Object.entries(modelInfo)) {
77
- if (key.endsWith(".context_length") && typeof value === "number") {
78
- return value;
79
- }
80
- }
81
- return 128000;
82
- }
83
-
84
- function assembleModels(raw: Record<string, OllamaShowResponse>): ProviderModelConfig[] {
85
- return Object.entries(raw)
86
- .filter(([, data]) => data.capabilities?.includes("tools"))
87
- .map(([id, data]) => ({
88
- id,
89
- name: id,
90
- reasoning: data.capabilities?.includes("thinking") ?? false,
91
- input: (data.capabilities?.includes("vision") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
92
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
93
- contextWindow: getContextLength(data.model_info ?? {}),
94
- maxTokens: 32768,
95
- }));
96
- }
97
-
98
- // --- Fallback models (cold cache) ---
99
-
100
- const FALLBACK_MODELS: ProviderModelConfig[] = [
101
- {
102
- id: "glm-5.1:cloud",
103
- name: "GLM 5.1 Cloud",
104
- reasoning: true,
105
- input: ["text"],
106
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
107
- contextWindow: 202752,
108
- maxTokens: 32768,
109
- },
110
- {
111
- id: "gemma4:cloud",
112
- name: "Gemma 4 Cloud",
113
- reasoning: true,
114
- input: ["text"],
115
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
116
- contextWindow: 262144,
117
- maxTokens: 32768,
118
- },
119
- ];
120
-
121
- // --- Cache I/O ---
122
-
123
- function readCache(): Record<string, OllamaShowResponse> | null {
124
- try {
125
- if (!existsSync(CACHE_FILE)) return null;
126
- const data: CachedData = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
127
- if (!data.models || Object.keys(data.models).length === 0) return null;
128
- return data.models;
129
- } catch {
130
- return null;
131
- }
132
- }
133
-
134
- function writeCache(models: Record<string, OllamaShowResponse>): void {
135
- try {
136
- mkdirSync(CACHE_DIR, { recursive: true });
137
- writeFileSync(CACHE_FILE, JSON.stringify({ timestamp: Date.now(), models } satisfies CachedData, null, 2));
138
- } catch {
139
- // Ignore cache write errors
140
- }
141
- }
142
-
143
- // --- API fetch ---
144
-
145
- async function fetchModels(ctx: ExtensionCommandContext): Promise<Record<string, OllamaShowResponse>> {
146
- const apiKey = await ctx.modelRegistry.getApiKeyForProvider("ollama-cloud");
147
- if (!apiKey) {
148
- ctx.ui.notify("No Ollama Cloud API key configured (auth.json or OLLAMA_API_KEY env var)", "error");
149
- return {};
150
- }
151
-
152
- // 1. Fetch model list from /v1/models
153
- const listController = new AbortController();
154
- const listTimeout = setTimeout(() => listController.abort(), FETCH_TIMEOUT_MS);
155
- try {
156
- const res = await fetch(`${OLLAMA_BASE}/v1/models`, {
157
- headers: { Authorization: `Bearer ${apiKey}` },
158
- signal: listController.signal,
159
- });
160
- if (!res.ok) {
161
- ctx.ui.notify(`Failed to fetch model list: ${res.status}`, "error");
162
- return {};
163
- }
164
- const data = (await res.json()) as { data: { id: string }[] };
165
- const modelIds = data.data.map((m) => m.id);
166
- ctx.ui.notify(`Found ${modelIds.length} models, fetching details...`);
167
- clearTimeout(listTimeout);
168
-
169
- // 2. Fetch /api/show for each model in parallel
170
- const results: Record<string, OllamaShowResponse> = {};
171
- const settled = await Promise.allSettled(
172
- modelIds.map(async (id) => {
173
- const showController = new AbortController();
174
- const showTimeout = setTimeout(() => showController.abort(), FETCH_TIMEOUT_MS);
175
- try {
176
- const res = await fetch(`${OLLAMA_BASE}/api/show`, {
177
- method: "POST",
178
- headers: {
179
- Authorization: `Bearer ${apiKey}`,
180
- "Content-Type": "application/json",
181
- },
182
- body: JSON.stringify({ model: id }),
183
- signal: showController.signal,
184
- });
185
- if (!res.ok) return;
186
- const showData = (await res.json()) as OllamaShowResponse;
187
- results[id] = showData;
188
- } finally {
189
- clearTimeout(showTimeout);
190
- }
191
- }),
192
- );
193
-
194
- const succeeded = settled.filter((r) => r.status === "fulfilled").length;
195
- const failed = settled.length - succeeded;
196
- ctx.ui.notify(`Fetched ${Object.keys(results).length} model details${failed ? ` (${failed} failed)` : ""}`, "info");
197
-
198
- return results;
199
- } catch {
200
- ctx.ui.notify("Failed to fetch Ollama Cloud models", "error");
201
- return {};
202
- }
203
- }
204
-
205
- // --- Web search/fetch types ---
206
-
207
- interface SearchResponse {
208
- results: Array<{
209
- title: string;
210
- url: string;
211
- content: string;
212
- }>;
213
- }
214
-
215
- interface FetchResponse {
216
- title: string;
217
- content: string;
218
- links: string[];
219
- }
220
-
221
- async function getCloudApiKey(ctx: ExtensionContext): Promise<string | undefined> {
222
- return ctx.modelRegistry.getApiKeyForProvider("ollama-cloud");
223
- }
224
-
225
- // --- Tool rendering helpers ---
226
-
227
- const PREVIEW_LINES = 8;
26
+ import type { ExtensionAPI, ExtensionCommandContext, ProviderModelConfig } from "@mariozechner/pi-coding-agent";
27
+ import { assembleModels, FALLBACK_MODELS, fetchModels, OLLAMA_BASE, readCache, writeCache } from "./models.ts";
28
+ import { registerWebFetchTool, registerWebSearchTool } from "./web-tools.ts";
228
29
 
229
30
  /**
230
- * Build a renderResult handler that shows a truncated preview when collapsed
231
- * and the full output when expanded. Follows the bash tool pattern.
31
+ * Opt-out flag for the ollama_web_search and ollama_web_fetch tools.
32
+ * When the value is one of "0", "false", "no", "off", or the empty string,
33
+ * both web tool registrations are skipped. The model provider and
34
+ * /ollama-cloud-refresh command remain active regardless.
232
35
  */
233
- function createRenderResult() {
234
- return (
235
- result: { content: Array<{ type: string; text: string }>; isError?: boolean },
236
- options: { expanded: boolean; isPartial: boolean },
237
- theme: import("@mariozechner/pi-coding-agent").Theme,
238
- context: {
239
- invalidate: () => void;
240
- lastComponent: import("@mariozechner/pi-tui").Component | undefined;
241
- state: { cachedWidth?: number; cachedLines?: string[]; cachedSkipped?: number };
242
- },
243
- ) => {
244
- const state = context.state;
245
- const output = result.content
246
- .map((c) => c.text)
247
- .join("")
248
- .trim();
249
- const styledOutput = output
250
- .split("\n")
251
- .map((line: string) => theme.fg("toolOutput", line))
252
- .join("\n");
253
-
254
- if (options.expanded || result.isError) {
255
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
256
- text.setText(result.isError ? styledOutput : `\n${styledOutput}`);
257
- return text;
258
- }
259
-
260
- return {
261
- render: (width: number) => {
262
- if (state.cachedWidth !== width) {
263
- const preview = truncateToVisualLines(styledOutput, PREVIEW_LINES, width);
264
- state.cachedLines = preview.visualLines;
265
- state.cachedSkipped = preview.skippedCount;
266
- state.cachedWidth = width;
267
- }
268
- if (state.cachedSkipped && state.cachedSkipped > 0) {
269
- const hint =
270
- theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
271
- ` ${keyHint("app.tools.expand", "to expand")})`;
272
- return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
273
- }
274
- return ["", ...(state.cachedLines ?? [])];
275
- },
276
- invalidate: () => {
277
- state.cachedWidth = undefined;
278
- state.cachedLines = undefined;
279
- state.cachedSkipped = undefined;
280
- },
281
- };
282
- };
283
- }
36
+ const PI_OWT_RAW = process.env.PI_OLLAMA_WEB_TOOLS;
37
+ const WEB_TOOLS_DISABLED =
38
+ PI_OWT_RAW !== undefined && ["0", "false", "no", "off", ""].includes(PI_OWT_RAW.toLowerCase());
284
39
 
285
- // --- Main ---
286
-
287
- export default async function (pi: ExtensionAPI) {
288
- // Boot: assemble from cache or fall back
289
- const cached = readCache();
290
- const models = cached ? assembleModels(cached) : FALLBACK_MODELS;
40
+ // --- Registrations ---
291
41
 
42
+ function registerProvider(pi: ExtensionAPI, models: ProviderModelConfig[]) {
292
43
  pi.registerProvider("ollama-cloud", {
293
44
  baseUrl: `${OLLAMA_BASE}/v1`,
294
45
  apiKey: "OLLAMA_API_KEY",
295
46
  api: "openai-completions",
296
47
  models,
297
48
  });
49
+ }
298
50
 
299
- // Slash command to refresh model list
51
+ function registerRefreshCommand(pi: ExtensionAPI) {
300
52
  pi.registerCommand("ollama-cloud-refresh", {
301
53
  description: "Refresh Ollama Cloud models from the API",
302
54
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
303
55
  ctx.ui.setWorkingMessage("Refreshing Ollama Cloud models...");
304
56
 
305
57
  const raw = await fetchModels(ctx);
306
- if (Object.keys(raw).length === 0) {
307
- ctx.ui.notify("No models fetched — keeping existing models", "warning");
58
+ if (!raw) {
308
59
  ctx.ui.setWorkingMessage();
309
60
  return;
310
61
  }
@@ -317,155 +68,25 @@ export default async function (pi: ExtensionAPI) {
317
68
  // If that comes up, consider setting `supportsDeveloperRole: false` in the compat field
318
69
  // for the provider or specific models, e.g.:
319
70
  // compat: { supportsDeveloperRole: false }
320
- pi.registerProvider("ollama-cloud", {
321
- baseUrl: `${OLLAMA_BASE}/v1`,
322
- apiKey: "OLLAMA_API_KEY",
323
- api: "openai-completions",
324
- models: newModels,
325
- });
71
+ registerProvider(pi, newModels);
326
72
 
327
73
  ctx.ui.notify(`Registered ${newModels.length} Ollama Cloud models`, "info");
328
74
  ctx.ui.setWorkingMessage();
329
75
  },
330
76
  });
77
+ }
331
78
 
332
- // Web search tool — uses Ollama Cloud API (no local server needed)
333
- pi.registerTool({
334
- name: "ollama_web_search",
335
- label: "Ollama Web Search",
336
- description:
337
- "Search the web for real-time information using Ollama Cloud's web search API. " +
338
- "Returns relevant results with titles, URLs, and content snippets. " +
339
- "Requires an Ollama Cloud API key.",
340
- parameters: Type.Object({
341
- query: Type.String({ description: "The search query to execute" }),
342
- max_results: Type.Optional(
343
- Type.Number({ description: "Maximum number of search results to return (default: 5, max: 10)", default: 5 }),
344
- ),
345
- }),
346
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
347
- const apiKey = await getCloudApiKey(ctx);
348
- if (!apiKey) {
349
- return {
350
- content: [
351
- {
352
- type: "text",
353
- text: "Error: No Ollama Cloud API key configured. Set OLLAMA_API_KEY or add to auth.json.",
354
- },
355
- ],
356
- isError: true,
357
- };
358
- }
359
-
360
- try {
361
- const res = await fetch(`${OLLAMA_BASE}/api/web_search`, {
362
- method: "POST",
363
- headers: {
364
- Authorization: `Bearer ${apiKey}`,
365
- "Content-Type": "application/json",
366
- },
367
- body: JSON.stringify({
368
- query: params.query,
369
- max_results: params.max_results ?? 5,
370
- }),
371
- signal,
372
- });
373
-
374
- if (!res.ok) {
375
- const errorText = await res.text().catch(() => "");
376
- return {
377
- content: [
378
- { type: "text", text: `Search API error (status ${res.status}): ${errorText || res.statusText}` },
379
- ],
380
- isError: true,
381
- };
382
- }
383
-
384
- const data = (await res.json()) as SearchResponse;
385
- const formatted = data.results
386
- .map((r, i) => `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.content}`)
387
- .join("\n\n");
388
-
389
- return {
390
- content: [{ type: "text", text: formatted || "No results found." }],
391
- details: { results: data.results },
392
- };
393
- } catch (err) {
394
- return {
395
- content: [{ type: "text", text: `Web search failed: ${err instanceof Error ? err.message : String(err)}` }],
396
- isError: true,
397
- };
398
- }
399
- },
400
- renderResult: createRenderResult(),
401
- });
402
-
403
- // Web fetch tool — uses Ollama Cloud API (no local server needed)
404
- pi.registerTool({
405
- name: "ollama_web_fetch",
406
- label: "Ollama Web Fetch",
407
- description:
408
- "Fetch and extract text content from a web page URL using Ollama Cloud's web fetch API. " +
409
- "Returns the page title, main content, and links found on the page. " +
410
- "Requires an Ollama Cloud API key.",
411
- parameters: Type.Object({
412
- url: Type.String({ description: "URL to fetch and extract content from" }),
413
- }),
414
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
415
- const apiKey = await getCloudApiKey(ctx);
416
- if (!apiKey) {
417
- return {
418
- content: [
419
- {
420
- type: "text",
421
- text: "Error: No Ollama Cloud API key configured. Set OLLAMA_API_KEY or add to auth.json.",
422
- },
423
- ],
424
- isError: true,
425
- };
426
- }
427
-
428
- try {
429
- const res = await fetch(`${OLLAMA_BASE}/api/web_fetch`, {
430
- method: "POST",
431
- headers: {
432
- Authorization: `Bearer ${apiKey}`,
433
- "Content-Type": "application/json",
434
- },
435
- body: JSON.stringify({ url: params.url }),
436
- signal,
437
- });
79
+ // --- Main ---
438
80
 
439
- if (!res.ok) {
440
- const errorText = await res.text().catch(() => "");
441
- return {
442
- content: [{ type: "text", text: `Fetch API error (status ${res.status}): ${errorText || res.statusText}` }],
443
- isError: true,
444
- };
445
- }
81
+ export default async function (pi: ExtensionAPI) {
82
+ const cached = readCache();
83
+ const models = cached ? assembleModels(cached) : FALLBACK_MODELS;
446
84
 
447
- const data = (await res.json()) as FetchResponse;
448
- const formatted = [
449
- `Title: ${data.title}`,
450
- "",
451
- "Content:",
452
- data.content,
453
- "",
454
- `Links found: ${data.links?.length ?? 0}`,
455
- ...(data.links?.slice(0, 10).map((l) => ` - ${l}`) ?? []),
456
- ].join("\n");
85
+ registerProvider(pi, models);
86
+ registerRefreshCommand(pi);
457
87
 
458
- return {
459
- content: [{ type: "text", text: formatted }],
460
- details: { title: data.title, content: data.content, links: data.links },
461
- };
462
- } catch (err) {
463
- return {
464
- content: [{ type: "text", text: `Web fetch failed: ${err instanceof Error ? err.message : String(err)}` }],
465
- isError: true,
466
- };
467
- }
468
- },
469
- renderResult: createRenderResult(),
470
- });
88
+ if (!WEB_TOOLS_DISABLED) {
89
+ registerWebSearchTool(pi);
90
+ registerWebFetchTool(pi);
91
+ }
471
92
  }
package/models.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { type ExtensionCommandContext, getAgentDir, type ProviderModelConfig } from "@mariozechner/pi-coding-agent";
4
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
5
+
6
+ // --- Constants ---
7
+ const CACHE_DIR = join(getAgentDir(), "cache");
8
+ const CACHE_FILE = join(CACHE_DIR, "ollama-cloud-models.json");
9
+ const FETCH_TIMEOUT_MS = 10000;
10
+
11
+ // --- API fetch ---
12
+ export let OLLAMA_BASE = (process.env.OLLAMA_API_BASE || "https://ollama.com").replace(/\/+$/, "");
13
+
14
+ // Initialize AuthStorage
15
+ const authStorage = AuthStorage.create();
16
+
17
+ // --- Raw API types ---
18
+ /** Response from POST /api/show */
19
+ export interface OllamaShowResponse {
20
+ details: {
21
+ parent_model: string;
22
+ format: string;
23
+ family: string;
24
+ families: string[] | null;
25
+ parameter_size: string;
26
+ quantization_level: string;
27
+ };
28
+ model_info: Record<string, unknown>;
29
+ capabilities: string[];
30
+ modified_at: string;
31
+ }
32
+
33
+ /** On-disk cache: raw /api/show responses keyed by model ID */
34
+ interface CachedData {
35
+ timestamp: number;
36
+ models: Record<string, OllamaShowResponse>;
37
+ }
38
+
39
+ // --- Assembly: raw API data -> ProviderModelConfig[] ---
40
+ function getContextLength(modelInfo: Record<string, unknown>): number {
41
+ for (const [key, value] of Object.entries(modelInfo)) {
42
+ if (key.endsWith(".context_length") && typeof value === "number") {
43
+ return value;
44
+ }
45
+ }
46
+ return 128000;
47
+ }
48
+
49
+ export function assembleModels(raw: Record<string, OllamaShowResponse>): ProviderModelConfig[] {
50
+ return Object.entries(raw)
51
+ .filter(([, data]) => data.capabilities?.includes("tools"))
52
+ .map(([id, data]) => ({
53
+ id,
54
+ name: id,
55
+ reasoning: data.capabilities?.includes("thinking") ?? false,
56
+ input: (data.capabilities?.includes("vision") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
57
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
58
+ contextWindow: getContextLength(data.model_info ?? {}),
59
+ maxTokens: 32768,
60
+ }));
61
+ }
62
+
63
+ // --- Fallback models (cold cache) ---
64
+ export const FALLBACK_MODELS: ProviderModelConfig[] = [
65
+ {
66
+ id: "glm-5.1:cloud",
67
+ name: "GLM 5.1 Cloud",
68
+ reasoning: true,
69
+ input: ["text"],
70
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
71
+ contextWindow: 202752,
72
+ maxTokens: 32768,
73
+ },
74
+ {
75
+ id: "gemma4:cloud",
76
+ name: "Gemma 4 Cloud",
77
+ reasoning: true,
78
+ input: ["text"],
79
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
80
+ contextWindow: 262144,
81
+ maxTokens: 32768,
82
+ },
83
+ ];
84
+
85
+ // --- Cache I/O ---
86
+ export function readCache(): Record<string, OllamaShowResponse> | null {
87
+ try {
88
+ if (!existsSync(CACHE_FILE)) return null;
89
+ const data: CachedData = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
90
+ if (!data.models || Object.keys(data.models).length === 0) return null;
91
+ return data.models;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ export function writeCache(models: Record<string, OllamaShowResponse>): void {
98
+ try {
99
+ mkdirSync(CACHE_DIR, { recursive: true });
100
+ writeFileSync(CACHE_FILE, JSON.stringify({ timestamp: Date.now(), models } satisfies CachedData, null, 2));
101
+ } catch {
102
+ // Ignore cache write errors
103
+ }
104
+ }
105
+
106
+ // --- Fetch Models ---
107
+ export async function fetchModels(ctx: ExtensionCommandContext): Promise<Record<string, OllamaShowResponse> | null> {
108
+ const apiKey = await authStorage.getApiKey("ollama-cloud");
109
+
110
+ if (!apiKey) {
111
+ ctx.ui.notify(
112
+ "No Ollama Cloud API key found. \n" +
113
+ "Please ensure your API key is set in: \n" +
114
+ "- auth.json file (at ~/.pi/agent/auth.json) under 'ollama-cloud' key,\n" +
115
+ "- or via the CLI --api-key flag.\n" +
116
+ "Example auth.json entry: \n" +
117
+ '{ \"ollama-cloud\": { \"type\": \"api_key\", \"key\": \"YOUR_API_KEY\" } }'
118
+ , "error");
119
+ return null;
120
+ }
121
+
122
+ // 1. Fetch model list from /v1/models
123
+ let modelIds: string[];
124
+ const listController = new AbortController();
125
+ const listTimeout = setTimeout(() => listController.abort(), FETCH_TIMEOUT_MS);
126
+ try {
127
+ const res = await fetch(`${OLLAMA_BASE}/v1/models`, {
128
+ headers: { Authorization: `Bearer ${apiKey}` },
129
+ signal: listController.signal,
130
+ });
131
+ if (!res.ok) {
132
+ ctx.ui.notify(`Failed to fetch model list: ${res.status}`, "error");
133
+ return null;
134
+ }
135
+ const data = (await res.json()) as { data: { id: string }[] };
136
+ modelIds = data.data.map((m) => m.id);
137
+ ctx.ui.notify(`Found ${modelIds.length} models, fetching details...`);
138
+ } catch {
139
+ ctx.ui.notify("Failed to fetch Ollama Cloud models", "error");
140
+ return null;
141
+ } finally {
142
+ clearTimeout(listTimeout);
143
+ }
144
+
145
+ // 2. Fetch /api/show for each model in parallel
146
+ const results: Record<string, OllamaShowResponse> = {};
147
+ await Promise.allSettled(
148
+ modelIds.map(async (id) => {
149
+ const showController = new AbortController();
150
+ const showTimeout = setTimeout(() => showController.abort(), FETCH_TIMEOUT_MS);
151
+ try {
152
+ const res = await fetch(`${OLLAMA_BASE}/api/show`, {
153
+ method: "POST",
154
+ headers: {
155
+ Authorization: `Bearer ${apiKey}`,
156
+ "Content-Type": "application/json",
157
+ },
158
+ body: JSON.stringify({ model: id }),
159
+ signal: showController.signal,
160
+ });
161
+ if (!res.ok) throw new Error(`status ${res.status}`);
162
+ const showData = (await res.json()) as OllamaShowResponse;
163
+ results[id] = showData;
164
+ } finally {
165
+ clearTimeout(showTimeout);
166
+ }
167
+ }),
168
+ );
169
+
170
+ const succeeded = Object.keys(results).length;
171
+ const failed = modelIds.length - succeeded;
172
+ if (succeeded === 0) {
173
+ ctx.ui.notify(`Failed to fetch model details${failed ? ` (${failed} failed)` : ""}`, "error");
174
+ return null;
175
+ }
176
+ ctx.ui.notify(`Fetched ${succeeded} model details${failed ? ` (${failed} failed)` : ""}`, "info");
177
+
178
+ return results;
179
+ }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "pi-ollama-cloud",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
7
7
  ],
8
8
  "files": [
9
9
  "index.ts",
10
+ "models.ts",
11
+ "web-tools.ts",
12
+ "CHANGELOG.md",
10
13
  "README.md",
11
14
  "LICENSE"
12
15
  ],
package/web-tools.ts ADDED
@@ -0,0 +1,230 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionContext,
4
+ keyHint,
5
+ truncateToVisualLines,
6
+ } from "@mariozechner/pi-coding-agent";
7
+ import { Text, truncateToWidth } from "@mariozechner/pi-tui";
8
+ import { Type } from "@sinclair/typebox";
9
+ import { OLLAMA_BASE } from "./models.ts";
10
+
11
+ // --- Types ---
12
+
13
+ interface SearchResponse {
14
+ results: Array<{
15
+ title: string;
16
+ url: string;
17
+ content: string;
18
+ }>;
19
+ }
20
+
21
+ interface FetchResponse {
22
+ title: string;
23
+ content: string;
24
+ links: string[];
25
+ }
26
+
27
+ // --- Helpers ---
28
+
29
+ async function getCloudApiKey(ctx: ExtensionContext): Promise<string | undefined> {
30
+ return ctx.modelRegistry.getApiKeyForProvider("ollama-cloud");
31
+ }
32
+
33
+ function noApiKeyError() {
34
+ return {
35
+ content: [
36
+ {
37
+ type: "text" as const,
38
+ text: "Error: No Ollama Cloud API key configured. Set OLLAMA_API_KEY or add to auth.json.",
39
+ },
40
+ ],
41
+ isError: true,
42
+ };
43
+ }
44
+
45
+ const PREVIEW_LINES = 8;
46
+
47
+ /**
48
+ * Build a renderResult handler that shows a truncated preview when collapsed
49
+ * and the full output when expanded. Follows the bash tool pattern.
50
+ */
51
+ function createRenderResult() {
52
+ return (
53
+ result: { content: Array<{ type: string; text: string }>; isError?: boolean },
54
+ options: { expanded: boolean; isPartial: boolean },
55
+ theme: import("@mariozechner/pi-coding-agent").Theme,
56
+ context: {
57
+ invalidate: () => void;
58
+ lastComponent: import("@mariozechner/pi-tui").Component | undefined;
59
+ state: { cachedWidth?: number; cachedLines?: string[]; cachedSkipped?: number };
60
+ },
61
+ ) => {
62
+ const state = context.state;
63
+ const output = result.content
64
+ .map((c) => c.text)
65
+ .join("")
66
+ .trim();
67
+ const styledOutput = output
68
+ .split("\n")
69
+ .map((line: string) => theme.fg("toolOutput", line))
70
+ .join("\n");
71
+
72
+ if (options.expanded || result.isError) {
73
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
74
+ text.setText(result.isError ? styledOutput : `\n${styledOutput}`);
75
+ return text;
76
+ }
77
+
78
+ return {
79
+ render: (width: number) => {
80
+ if (state.cachedWidth !== width) {
81
+ const preview = truncateToVisualLines(styledOutput, PREVIEW_LINES, width);
82
+ state.cachedLines = preview.visualLines;
83
+ state.cachedSkipped = preview.skippedCount;
84
+ state.cachedWidth = width;
85
+ }
86
+ if (state.cachedSkipped && state.cachedSkipped > 0) {
87
+ const hint =
88
+ theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
89
+ ` ${keyHint("app.tools.expand", "to expand")})`;
90
+ return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
91
+ }
92
+ return ["", ...(state.cachedLines ?? [])];
93
+ },
94
+ invalidate: () => {
95
+ state.cachedWidth = undefined;
96
+ state.cachedLines = undefined;
97
+ state.cachedSkipped = undefined;
98
+ },
99
+ };
100
+ };
101
+ }
102
+
103
+ // --- Registrations ---
104
+
105
+ export function registerWebSearchTool(pi: ExtensionAPI) {
106
+ pi.registerTool({
107
+ name: "ollama_web_search",
108
+ label: "Ollama Web Search",
109
+ description:
110
+ "Search the web for real-time information using Ollama Cloud's web search API. " +
111
+ "Returns relevant results with titles, URLs, and content snippets. " +
112
+ "Requires an Ollama Cloud API key.",
113
+ parameters: Type.Object({
114
+ query: Type.String({ description: "The search query to execute" }),
115
+ max_results: Type.Optional(
116
+ Type.Integer({
117
+ description: "Maximum number of search results to return (default: 5, max: 10)",
118
+ default: 5,
119
+ minimum: 1,
120
+ maximum: 10,
121
+ }),
122
+ ),
123
+ }),
124
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
125
+ const apiKey = await getCloudApiKey(ctx);
126
+ if (!apiKey) return noApiKeyError();
127
+
128
+ try {
129
+ const res = await fetch(`${OLLAMA_BASE}/api/web_search`, {
130
+ method: "POST",
131
+ headers: {
132
+ Authorization: `Bearer ${apiKey}`,
133
+ "Content-Type": "application/json",
134
+ },
135
+ body: JSON.stringify({
136
+ query: params.query,
137
+ max_results: params.max_results ?? 5,
138
+ }),
139
+ signal,
140
+ });
141
+
142
+ if (!res.ok) {
143
+ const errorText = await res.text().catch(() => "");
144
+ return {
145
+ content: [
146
+ { type: "text", text: `Search API error (status ${res.status}): ${errorText || res.statusText}` },
147
+ ],
148
+ isError: true,
149
+ };
150
+ }
151
+
152
+ const data = (await res.json()) as SearchResponse;
153
+ const formatted = data.results
154
+ .map((r, i) => `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.content}`)
155
+ .join("\n\n");
156
+
157
+ return {
158
+ content: [{ type: "text", text: formatted || "No results found." }],
159
+ details: { results: data.results },
160
+ };
161
+ } catch (err) {
162
+ return {
163
+ content: [{ type: "text", text: `Web search failed: ${err instanceof Error ? err.message : String(err)}` }],
164
+ isError: true,
165
+ };
166
+ }
167
+ },
168
+ renderResult: createRenderResult(),
169
+ });
170
+ }
171
+
172
+ export function registerWebFetchTool(pi: ExtensionAPI) {
173
+ pi.registerTool({
174
+ name: "ollama_web_fetch",
175
+ label: "Ollama Web Fetch",
176
+ description:
177
+ "Fetch and extract text content from a web page URL using Ollama Cloud's web fetch API. " +
178
+ "Returns the page title, main content, and links found on the page. " +
179
+ "Requires an Ollama Cloud API key.",
180
+ parameters: Type.Object({
181
+ url: Type.String({ description: "URL to fetch and extract content from", format: "uri" }),
182
+ }),
183
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
184
+ const apiKey = await getCloudApiKey(ctx);
185
+ if (!apiKey) return noApiKeyError();
186
+
187
+ try {
188
+ const res = await fetch(`${OLLAMA_BASE}/api/web_fetch`, {
189
+ method: "POST",
190
+ headers: {
191
+ Authorization: `Bearer ${apiKey}`,
192
+ "Content-Type": "application/json",
193
+ },
194
+ body: JSON.stringify({ url: params.url }),
195
+ signal,
196
+ });
197
+
198
+ if (!res.ok) {
199
+ const errorText = await res.text().catch(() => "");
200
+ return {
201
+ content: [{ type: "text", text: `Fetch API error (status ${res.status}): ${errorText || res.statusText}` }],
202
+ isError: true,
203
+ };
204
+ }
205
+
206
+ const data = (await res.json()) as FetchResponse;
207
+ const formatted = [
208
+ `Title: ${data.title}`,
209
+ "",
210
+ "Content:",
211
+ data.content,
212
+ "",
213
+ `Links found: ${data.links?.length ?? 0}`,
214
+ ...(data.links?.slice(0, 10).map((l) => ` - ${l}`) ?? []),
215
+ ].join("\n");
216
+
217
+ return {
218
+ content: [{ type: "text", text: formatted }],
219
+ details: { title: data.title, content: data.content, links: data.links },
220
+ };
221
+ } catch (err) {
222
+ return {
223
+ content: [{ type: "text", text: `Web fetch failed: ${err instanceof Error ? err.message : String(err)}` }],
224
+ isError: true,
225
+ };
226
+ }
227
+ },
228
+ renderResult: createRenderResult(),
229
+ });
230
+ }