pi-ollama-cloud 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ - Fix extension crash on pi 0.80.8+ where `AuthStorage` is no longer exported by `@earendil-works/pi-coding-agent`. Web tools now resolve the API key through the tool execution context's `modelRegistry.getApiKeyForProvider()`, preserving runtime/CLI overrides and the registered `apiKey: "$OLLAMA_API_KEY"` config. Thanks @badlogic for the cross-version analysis (#34, #35, #37).
8
+ - Restore the `OLLAMA_API_KEY` env-var fallback in `ollama_web_search` and `ollama_web_fetch`. Thanks @cawilliamson (#26).
9
+ - Add a 15s timeout to web search/fetch requests and preserve tool-cancellation by extending `fetchJsonWithTimeout` with an external `AbortSignal`.
10
+ - Always target `https://ollama.com` and warn when `OLLAMA_API_BASE` is set to a non-cloud host, instead of silently querying a local Ollama daemon. Thanks @valueforvalue (#32, #33).
11
+ - Add a `glm-5.2` thinking level map exposing `off`, `high`, and `xhigh` (`reasoning_effort: "none"`, `"high"`, `"max"`). Thanks @Thinkscape (#29).
12
+ - Add estimated per-token pricing for Ollama Cloud models so `/cost` shows comparable usage. Prices are generated from [models.dev](https://models.dev) (the same source pi uses) via `scripts/generate-pricing.ts` into `pricing.generated.ts`, not hand-typed, and regenerate with the catalog via `npm run generate-models`. Prices are pinned to the installed package version: `/ollama-cloud-refresh` updates the model list and metadata but does not re-fetch prices, so newly added models register with zero cost until the next release. Not actual subscription charges. Thanks @DxTa (#9).
13
+ - Refresh the generated model catalog from the live Ollama Cloud API. Adds `glm-5.2` (1M context, text-only) and `kimi-k2.7-code` (262K context, text + image). `deepseek-v4-pro` context window is now 524288 (was 1048576).
14
+ - Retire models deprecated per https://docs.ollama.com/cloud#deprecations: July 15, 2026 batch (`deepseek-v3.1:671b`, `deepseek-v3.2`, `devstral-2:123b`, `devstral-small-2:24b`, `ministral-3:14b`, `ministral-3:3b`, `ministral-3:8b`, `gemini-3-flash-preview`, `gemma3:12b`, `gemma3:27b`, `gemma3:4b`, `glm-4.7`, `glm-5`, `minimax-m2.1`, `qwen3-coder-next`, `qwen3-coder:480b`) and June 30 (`rnj-1:8b`). The shipped catalog goes from 30 to 18 models.
15
+ - Bump `@earendil-works/pi-coding-agent` runtime dependency to 0.80.10.
16
+
5
17
  ## [0.6.0] - 2026-06-05
6
18
 
7
19
  - Fix `apiKey` registered as a literal string instead of an environment variable reference. Changed `apiKey: "OLLAMA_API_KEY"` to `apiKey: "$OLLAMA_API_KEY"` in `registerProvider`, resolving the deprecation warning emitted by pi v0.77.0+ and making the `OLLAMA_API_KEY` env var work alongside `auth.json` (env var takes priority, falls back to `auth.json`). Thanks @mandusm (#21).
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-ollama-cloud
2
2
 
3
- Ollama Cloud provider plugin for [Pi](https://github.com/badlogic/pi-mono) coding agent.
3
+ Ollama Cloud provider plugin for the [Pi](https://pi.dev) coding agent.
4
4
 
5
5
  Registers Ollama Cloud as a model provider with dynamically fetched models, and provides `ollama_web_search` and `ollama_web_fetch` tools that use the [Ollama Cloud web search API](https://docs.ollama.com/capabilities/web-search) - no local Ollama server required.
6
6
 
@@ -14,7 +14,7 @@ Registers Ollama Cloud as a model provider with dynamically fetched models, and
14
14
  - **`/ollama-cloud-refresh` command** - Re-fetches the model list and updates the cache and provider registration live (no restart needed).
15
15
  - **`ollama_web_search` tool** - Search the web for real-time information using Ollama Cloud's `/api/web_search` endpoint. Returns titles, URLs, and content snippets.
16
16
  - **`ollama_web_fetch` tool** - Fetch and extract text content from a web page URL using Ollama Cloud's `/api/web_fetch` endpoint. Returns page title, content, and links.
17
- - **Zero cost tracking** - All models are registered with zero costs since Ollama Cloud uses a flat subscription model (Free, Pro, Max) rather than per-token billing. Per-request costs don't apply, so Pi's cost tracker always shows zero. See [ollama.com/pricing](https://ollama.com/pricing) for plan details.
17
+ - **Estimated cost tracking** - Models are registered with estimated per-token costs sourced from [models.dev](https://models.dev) (the same catalog pi uses), so Pi's `/cost` shows comparable usage. Ollama Cloud is subscription-billed (Free, Pro, Max), so these are equivalent pay-as-you-go estimates, not actual charges. See [ollama.com/pricing](https://ollama.com/pricing) for plan details.
18
18
 
19
19
  ## Prerequisites
20
20
 
@@ -142,11 +142,11 @@ Model metadata is derived from the cached data:
142
142
  | Field | Source |
143
143
  |---|---|
144
144
  | `reasoning` | `capabilities` includes `"thinking"` |
145
- | `thinkingLevelMap` | [`thinking-levels.ts`](thinking-levels.ts) with 4 maps (DEFAULT, GPT_OSS, QWEN3, NO_OFF) based on API testing |
145
+ | `thinkingLevelMap` | [`thinking-levels.ts`](thinking-levels.ts) with 5 maps (DEFAULT, GPT_OSS, QWEN3, GLM_52, NO_OFF) based on API testing |
146
146
  | `input` | `["text", "image"]` if `capabilities` includes `"vision"`, else `["text"]` |
147
147
  | `contextWindow` | `model_info.*.context_length` (falls back to 128000) |
148
148
  | `maxTokens` | Fixed at 32768 |
149
- | `cost` | All zeros (Ollama Cloud uses subscription plans, not per-token billing - see [pricing](https://ollama.com/pricing)) |
149
+ | `cost` | Estimated per-1M-token prices from [models.dev](https://models.dev), generated by `scripts/generate-pricing.ts` into `pricing.generated.ts`. Ollama Cloud is subscription-billed, so these are equivalent pay-as-you-go estimates, not actual charges. Unmapped models default to zero. Prices are pinned to the installed package version: `/ollama-cloud-refresh` updates the model list and metadata but does not re-fetch prices, so newly added models register with zero cost until the next release. |
150
150
 
151
151
  ### Thinking level mapping
152
152
 
@@ -157,6 +157,7 @@ Pi's thinking levels are mapped to Ollama Cloud's OpenAI-compatible `reasoning_e
157
157
  | `DEFAULT` | Most thinking models | off, low, medium, high, xhigh | `minimal` hidden (duplicate of low) |
158
158
  | `GPT_OSS` | `gpt-oss*` | low, medium, high | Can't disable thinking, no off or xhigh |
159
159
  | `QWEN3` | `qwen3*` (except `qwen3-vl*`) | off, medium | Binary-only (think/nothink), no gradation |
160
+ | `GLM_52` | `glm-5.2` | off, high, xhigh | GLM supports disabled thinking; Ollama's model page confirms `high` and `max` reasoning efforts |
160
161
  | `NO_OFF` | `qwen3-vl*`, `kimi-k2-thinking`, `minimax*` | low, medium, high, xhigh | "none" doesn't disable thinking on these models |
161
162
 
162
163
  See [docs/think-experiment.md](docs/think-experiment.md) for the testing methodology and results.
@@ -182,7 +183,7 @@ Both tools use the same Ollama Cloud API key configured for the provider. No loc
182
183
 
183
184
  | Command | Description |
184
185
  |---|---|
185
- | `/ollama-cloud-refresh` | Fetch models from the Ollama Cloud API, update cache, and re-register the provider |
186
+ | `/ollama-cloud-refresh` | Fetch models from the Ollama Cloud API, update cache, and re-register the provider. Updates the model list and metadata but not estimated prices (those are pinned to the installed package version). |
186
187
  | `/ollama-webtools [on\|off\|enable\|disable]` | Enable or disable the `ollama_web_search` and `ollama_web_fetch` tools. Toggles if no argument given. |
187
188
 
188
189
  ## Development
package/index.ts CHANGED
@@ -124,9 +124,8 @@ export default async function (pi: ExtensionAPI) {
124
124
  // When cache is missing, GENERATED_MODELS serves as the cache —
125
125
  // it is manually generated via `npm run generate-models` and committed to the repo.
126
126
  const needsStartupRefresh = cacheState.status === "stale";
127
- // GENERATED_MODELS ships with the package (36 tool-capable models from
128
- // the build script). Used when no local cache exists. A fresh user cache
129
- // from /ollama-cloud-refresh takes precedence over the generated list.
127
+ // GENERATED_MODELS ships with the package. Used when no local cache exists. A
128
+ // fresh user cache from /ollama-cloud-refresh takes precedence over the generated list.
130
129
  const models = cacheState.status === "missing" ? GENERATED_MODELS : assembleModels(cacheState.models);
131
130
 
132
131
  registerProvider(pi, models);
@@ -1,91 +1,11 @@
1
1
  // Auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually.
3
- // Generated: 2026-06-05T15:26:51.472Z
4
- // Model count: 30
3
+ // Generated: 2026-07-17T19:55:27.551Z
4
+ // Model count: 18
5
5
 
6
6
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
7
7
 
8
8
  export const GENERATED_MODELS: ProviderModelConfig[] = [
9
- {
10
- id: "deepseek-v3.1:671b",
11
- name: "deepseek-v3.1:671b",
12
- compat: {
13
- maxTokensField: "max_tokens",
14
- openRouterRouting: {},
15
- requiresAssistantAfterToolResult: false,
16
- requiresReasoningContentOnAssistantMessages: false,
17
- requiresThinkingAsText: false,
18
- requiresToolResultName: false,
19
- sendSessionAffinityHeaders: false,
20
- supportsDeveloperRole: false,
21
- supportsLongCacheRetention: false,
22
- supportsReasoningEffort: true,
23
- supportsStore: false,
24
- supportsStrictMode: false,
25
- supportsUsageInStreaming: true,
26
- thinkingFormat: "openai",
27
- vercelGatewayRouting: {},
28
- zaiToolStream: false,
29
- },
30
- contextWindow: 163840,
31
- cost: {
32
- cacheRead: 0,
33
- cacheWrite: 0,
34
- input: 0,
35
- output: 0,
36
- },
37
- input: ["text"],
38
- maxTokens: 32768,
39
- reasoning: true,
40
- thinkingLevelMap: {
41
- high: "high",
42
- low: "low",
43
- medium: "medium",
44
- minimal: null,
45
- off: "none",
46
- xhigh: "max",
47
- },
48
- },
49
- {
50
- id: "deepseek-v3.2",
51
- name: "deepseek-v3.2",
52
- compat: {
53
- maxTokensField: "max_tokens",
54
- openRouterRouting: {},
55
- requiresAssistantAfterToolResult: false,
56
- requiresReasoningContentOnAssistantMessages: false,
57
- requiresThinkingAsText: false,
58
- requiresToolResultName: false,
59
- sendSessionAffinityHeaders: false,
60
- supportsDeveloperRole: false,
61
- supportsLongCacheRetention: false,
62
- supportsReasoningEffort: true,
63
- supportsStore: false,
64
- supportsStrictMode: false,
65
- supportsUsageInStreaming: true,
66
- thinkingFormat: "openai",
67
- vercelGatewayRouting: {},
68
- zaiToolStream: false,
69
- },
70
- contextWindow: 163840,
71
- cost: {
72
- cacheRead: 0,
73
- cacheWrite: 0,
74
- input: 0,
75
- output: 0,
76
- },
77
- input: ["text"],
78
- maxTokens: 32768,
79
- reasoning: true,
80
- thinkingLevelMap: {
81
- high: "high",
82
- low: "low",
83
- medium: "medium",
84
- minimal: null,
85
- off: "none",
86
- xhigh: "max",
87
- },
88
- },
89
9
  {
90
10
  id: "deepseek-v4-flash",
91
11
  name: "deepseek-v4-flash",
@@ -109,10 +29,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
109
29
  },
110
30
  contextWindow: 1048576,
111
31
  cost: {
112
- cacheRead: 0,
32
+ cacheRead: 0.0028,
113
33
  cacheWrite: 0,
114
- input: 0,
115
- output: 0,
34
+ input: 0.14,
35
+ output: 0.28,
116
36
  },
117
37
  input: ["text"],
118
38
  maxTokens: 32768,
@@ -147,119 +67,15 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
147
67
  vercelGatewayRouting: {},
148
68
  zaiToolStream: false,
149
69
  },
150
- contextWindow: 1048576,
151
- cost: {
152
- cacheRead: 0,
153
- cacheWrite: 0,
154
- input: 0,
155
- output: 0,
156
- },
157
- input: ["text"],
158
- maxTokens: 32768,
159
- reasoning: true,
160
- thinkingLevelMap: {
161
- high: "high",
162
- low: "low",
163
- medium: "medium",
164
- minimal: null,
165
- off: "none",
166
- xhigh: "max",
167
- },
168
- },
169
- {
170
- id: "devstral-2:123b",
171
- name: "devstral-2:123b",
172
- compat: {
173
- maxTokensField: "max_tokens",
174
- openRouterRouting: {},
175
- requiresAssistantAfterToolResult: false,
176
- requiresReasoningContentOnAssistantMessages: false,
177
- requiresThinkingAsText: false,
178
- requiresToolResultName: false,
179
- sendSessionAffinityHeaders: false,
180
- supportsDeveloperRole: false,
181
- supportsLongCacheRetention: false,
182
- supportsReasoningEffort: true,
183
- supportsStore: false,
184
- supportsStrictMode: false,
185
- supportsUsageInStreaming: true,
186
- thinkingFormat: "openai",
187
- vercelGatewayRouting: {},
188
- zaiToolStream: false,
189
- },
190
- contextWindow: 262144,
70
+ contextWindow: 524288,
191
71
  cost: {
192
- cacheRead: 0,
72
+ cacheRead: 0.003625,
193
73
  cacheWrite: 0,
194
- input: 0,
195
- output: 0,
74
+ input: 0.435,
75
+ output: 0.87,
196
76
  },
197
77
  input: ["text"],
198
78
  maxTokens: 32768,
199
- reasoning: false,
200
- },
201
- {
202
- id: "devstral-small-2:24b",
203
- name: "devstral-small-2:24b",
204
- compat: {
205
- maxTokensField: "max_tokens",
206
- openRouterRouting: {},
207
- requiresAssistantAfterToolResult: false,
208
- requiresReasoningContentOnAssistantMessages: false,
209
- requiresThinkingAsText: false,
210
- requiresToolResultName: false,
211
- sendSessionAffinityHeaders: false,
212
- supportsDeveloperRole: false,
213
- supportsLongCacheRetention: false,
214
- supportsReasoningEffort: true,
215
- supportsStore: false,
216
- supportsStrictMode: false,
217
- supportsUsageInStreaming: true,
218
- thinkingFormat: "openai",
219
- vercelGatewayRouting: {},
220
- zaiToolStream: false,
221
- },
222
- contextWindow: 262144,
223
- cost: {
224
- cacheRead: 0,
225
- cacheWrite: 0,
226
- input: 0,
227
- output: 0,
228
- },
229
- input: ["text", "image"],
230
- maxTokens: 32768,
231
- reasoning: false,
232
- },
233
- {
234
- id: "gemini-3-flash-preview",
235
- name: "gemini-3-flash-preview",
236
- compat: {
237
- maxTokensField: "max_tokens",
238
- openRouterRouting: {},
239
- requiresAssistantAfterToolResult: false,
240
- requiresReasoningContentOnAssistantMessages: false,
241
- requiresThinkingAsText: false,
242
- requiresToolResultName: false,
243
- sendSessionAffinityHeaders: false,
244
- supportsDeveloperRole: false,
245
- supportsLongCacheRetention: false,
246
- supportsReasoningEffort: true,
247
- supportsStore: false,
248
- supportsStrictMode: false,
249
- supportsUsageInStreaming: true,
250
- thinkingFormat: "openai",
251
- vercelGatewayRouting: {},
252
- zaiToolStream: false,
253
- },
254
- contextWindow: 1048576,
255
- cost: {
256
- cacheRead: 0,
257
- cacheWrite: 0,
258
- input: 0,
259
- output: 0,
260
- },
261
- input: ["text", "image"],
262
- maxTokens: 32768,
263
79
  reasoning: true,
264
80
  thinkingLevelMap: {
265
81
  high: "high",
@@ -293,10 +109,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
293
109
  },
294
110
  contextWindow: 262144,
295
111
  cost: {
296
- cacheRead: 0,
112
+ cacheRead: 0.12,
297
113
  cacheWrite: 0,
298
- input: 0,
299
- output: 0,
114
+ input: 0.22,
115
+ output: 0.55,
300
116
  },
301
117
  input: ["text", "image"],
302
118
  maxTokens: 32768,
@@ -311,48 +127,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
311
127
  },
312
128
  },
313
129
  {
314
- id: "glm-4.7",
315
- name: "glm-4.7",
316
- compat: {
317
- maxTokensField: "max_tokens",
318
- openRouterRouting: {},
319
- requiresAssistantAfterToolResult: false,
320
- requiresReasoningContentOnAssistantMessages: false,
321
- requiresThinkingAsText: false,
322
- requiresToolResultName: false,
323
- sendSessionAffinityHeaders: false,
324
- supportsDeveloperRole: false,
325
- supportsLongCacheRetention: false,
326
- supportsReasoningEffort: true,
327
- supportsStore: false,
328
- supportsStrictMode: false,
329
- supportsUsageInStreaming: true,
330
- thinkingFormat: "openai",
331
- vercelGatewayRouting: {},
332
- zaiToolStream: false,
333
- },
334
- contextWindow: 202752,
335
- cost: {
336
- cacheRead: 0,
337
- cacheWrite: 0,
338
- input: 0,
339
- output: 0,
340
- },
341
- input: ["text"],
342
- maxTokens: 32768,
343
- reasoning: true,
344
- thinkingLevelMap: {
345
- high: "high",
346
- low: "low",
347
- medium: "medium",
348
- minimal: null,
349
- off: "none",
350
- xhigh: "max",
351
- },
352
- },
353
- {
354
- id: "glm-5",
355
- name: "glm-5",
130
+ id: "glm-5.1",
131
+ name: "glm-5.1",
356
132
  compat: {
357
133
  maxTokensField: "max_tokens",
358
134
  openRouterRouting: {},
@@ -373,10 +149,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
373
149
  },
374
150
  contextWindow: 202752,
375
151
  cost: {
376
- cacheRead: 0,
152
+ cacheRead: 0.26,
377
153
  cacheWrite: 0,
378
- input: 0,
379
- output: 0,
154
+ input: 1.4,
155
+ output: 4.4,
380
156
  },
381
157
  input: ["text"],
382
158
  maxTokens: 32768,
@@ -391,8 +167,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
391
167
  },
392
168
  },
393
169
  {
394
- id: "glm-5.1",
395
- name: "glm-5.1",
170
+ id: "glm-5.2",
171
+ name: "glm-5.2",
396
172
  compat: {
397
173
  maxTokensField: "max_tokens",
398
174
  openRouterRouting: {},
@@ -411,20 +187,20 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
411
187
  vercelGatewayRouting: {},
412
188
  zaiToolStream: false,
413
189
  },
414
- contextWindow: 202752,
190
+ contextWindow: 1000000,
415
191
  cost: {
416
- cacheRead: 0,
192
+ cacheRead: 0.26,
417
193
  cacheWrite: 0,
418
- input: 0,
419
- output: 0,
194
+ input: 1.4,
195
+ output: 4.4,
420
196
  },
421
197
  input: ["text"],
422
198
  maxTokens: 32768,
423
199
  reasoning: true,
424
200
  thinkingLevelMap: {
425
201
  high: "high",
426
- low: "low",
427
- medium: "medium",
202
+ low: null,
203
+ medium: null,
428
204
  minimal: null,
429
205
  off: "none",
430
206
  xhigh: "max",
@@ -455,8 +231,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
455
231
  cost: {
456
232
  cacheRead: 0,
457
233
  cacheWrite: 0,
458
- input: 0,
459
- output: 0,
234
+ input: 0.037,
235
+ output: 0.17,
460
236
  },
461
237
  input: ["text"],
462
238
  maxTokens: 32768,
@@ -493,10 +269,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
493
269
  },
494
270
  contextWindow: 131072,
495
271
  cost: {
496
- cacheRead: 0,
272
+ cacheRead: 0.03,
497
273
  cacheWrite: 0,
498
- input: 0,
499
- output: 0,
274
+ input: 0.03,
275
+ output: 0.13,
500
276
  },
501
277
  input: ["text"],
502
278
  maxTokens: 32768,
@@ -533,10 +309,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
533
309
  },
534
310
  contextWindow: 262144,
535
311
  cost: {
536
- cacheRead: 0,
312
+ cacheRead: 0.1,
537
313
  cacheWrite: 0,
538
- input: 0,
539
- output: 0,
314
+ input: 0.6,
315
+ output: 3,
540
316
  },
541
317
  input: ["text", "image"],
542
318
  maxTokens: 32768,
@@ -573,10 +349,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
573
349
  },
574
350
  contextWindow: 262144,
575
351
  cost: {
576
- cacheRead: 0,
352
+ cacheRead: 0.16,
577
353
  cacheWrite: 0,
578
- input: 0,
579
- output: 0,
354
+ input: 0.95,
355
+ output: 4,
580
356
  },
581
357
  input: ["text", "image"],
582
358
  maxTokens: 32768,
@@ -591,8 +367,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
591
367
  },
592
368
  },
593
369
  {
594
- id: "minimax-m2.1",
595
- name: "minimax-m2.1",
370
+ id: "kimi-k2.7-code",
371
+ name: "kimi-k2.7-code",
596
372
  compat: {
597
373
  maxTokensField: "max_tokens",
598
374
  openRouterRouting: {},
@@ -611,14 +387,14 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
611
387
  vercelGatewayRouting: {},
612
388
  zaiToolStream: false,
613
389
  },
614
- contextWindow: 204800,
390
+ contextWindow: 262144,
615
391
  cost: {
616
- cacheRead: 0,
392
+ cacheRead: 0.19,
617
393
  cacheWrite: 0,
618
- input: 0,
619
- output: 0,
394
+ input: 0.95,
395
+ output: 4,
620
396
  },
621
- input: ["text"],
397
+ input: ["text", "image"],
622
398
  maxTokens: 32768,
623
399
  reasoning: true,
624
400
  thinkingLevelMap: {
@@ -626,7 +402,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
626
402
  low: "low",
627
403
  medium: "medium",
628
404
  minimal: null,
629
- off: null,
405
+ off: "none",
630
406
  xhigh: "max",
631
407
  },
632
408
  },
@@ -653,10 +429,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
653
429
  },
654
430
  contextWindow: 196608,
655
431
  cost: {
656
- cacheRead: 0,
657
- cacheWrite: 0,
658
- input: 0,
659
- output: 0,
432
+ cacheRead: 0.03,
433
+ cacheWrite: 0.375,
434
+ input: 0.3,
435
+ output: 1.2,
660
436
  },
661
437
  input: ["text"],
662
438
  maxTokens: 32768,
@@ -693,10 +469,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
693
469
  },
694
470
  contextWindow: 196608,
695
471
  cost: {
696
- cacheRead: 0,
697
- cacheWrite: 0,
698
- input: 0,
699
- output: 0,
472
+ cacheRead: 0.06,
473
+ cacheWrite: 0.375,
474
+ input: 0.3,
475
+ output: 1.2,
700
476
  },
701
477
  input: ["text"],
702
478
  maxTokens: 32768,
@@ -733,10 +509,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
733
509
  },
734
510
  contextWindow: 524288,
735
511
  cost: {
736
- cacheRead: 0,
512
+ cacheRead: 0.06,
737
513
  cacheWrite: 0,
738
- input: 0,
739
- output: 0,
514
+ input: 0.3,
515
+ output: 1.2,
740
516
  },
741
517
  input: ["text", "image"],
742
518
  maxTokens: 32768,
@@ -750,102 +526,6 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
750
526
  xhigh: "max",
751
527
  },
752
528
  },
753
- {
754
- id: "ministral-3:14b",
755
- name: "ministral-3:14b",
756
- compat: {
757
- maxTokensField: "max_tokens",
758
- openRouterRouting: {},
759
- requiresAssistantAfterToolResult: false,
760
- requiresReasoningContentOnAssistantMessages: false,
761
- requiresThinkingAsText: false,
762
- requiresToolResultName: false,
763
- sendSessionAffinityHeaders: false,
764
- supportsDeveloperRole: false,
765
- supportsLongCacheRetention: false,
766
- supportsReasoningEffort: true,
767
- supportsStore: false,
768
- supportsStrictMode: false,
769
- supportsUsageInStreaming: true,
770
- thinkingFormat: "openai",
771
- vercelGatewayRouting: {},
772
- zaiToolStream: false,
773
- },
774
- contextWindow: 262144,
775
- cost: {
776
- cacheRead: 0,
777
- cacheWrite: 0,
778
- input: 0,
779
- output: 0,
780
- },
781
- input: ["text", "image"],
782
- maxTokens: 32768,
783
- reasoning: false,
784
- },
785
- {
786
- id: "ministral-3:3b",
787
- name: "ministral-3:3b",
788
- compat: {
789
- maxTokensField: "max_tokens",
790
- openRouterRouting: {},
791
- requiresAssistantAfterToolResult: false,
792
- requiresReasoningContentOnAssistantMessages: false,
793
- requiresThinkingAsText: false,
794
- requiresToolResultName: false,
795
- sendSessionAffinityHeaders: false,
796
- supportsDeveloperRole: false,
797
- supportsLongCacheRetention: false,
798
- supportsReasoningEffort: true,
799
- supportsStore: false,
800
- supportsStrictMode: false,
801
- supportsUsageInStreaming: true,
802
- thinkingFormat: "openai",
803
- vercelGatewayRouting: {},
804
- zaiToolStream: false,
805
- },
806
- contextWindow: 262144,
807
- cost: {
808
- cacheRead: 0,
809
- cacheWrite: 0,
810
- input: 0,
811
- output: 0,
812
- },
813
- input: ["text", "image"],
814
- maxTokens: 32768,
815
- reasoning: false,
816
- },
817
- {
818
- id: "ministral-3:8b",
819
- name: "ministral-3:8b",
820
- compat: {
821
- maxTokensField: "max_tokens",
822
- openRouterRouting: {},
823
- requiresAssistantAfterToolResult: false,
824
- requiresReasoningContentOnAssistantMessages: false,
825
- requiresThinkingAsText: false,
826
- requiresToolResultName: false,
827
- sendSessionAffinityHeaders: false,
828
- supportsDeveloperRole: false,
829
- supportsLongCacheRetention: false,
830
- supportsReasoningEffort: true,
831
- supportsStore: false,
832
- supportsStrictMode: false,
833
- supportsUsageInStreaming: true,
834
- thinkingFormat: "openai",
835
- vercelGatewayRouting: {},
836
- zaiToolStream: false,
837
- },
838
- contextWindow: 262144,
839
- cost: {
840
- cacheRead: 0,
841
- cacheWrite: 0,
842
- input: 0,
843
- output: 0,
844
- },
845
- input: ["text", "image"],
846
- maxTokens: 32768,
847
- reasoning: false,
848
- },
849
529
  {
850
530
  id: "mistral-large-3:675b",
851
531
  name: "mistral-large-3:675b",
@@ -871,8 +551,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
871
551
  cost: {
872
552
  cacheRead: 0,
873
553
  cacheWrite: 0,
874
- input: 0,
875
- output: 0,
554
+ input: 0.5,
555
+ output: 1.5,
876
556
  },
877
557
  input: ["text", "image"],
878
558
  maxTokens: 32768,
@@ -903,8 +583,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
903
583
  cost: {
904
584
  cacheRead: 0,
905
585
  cacheWrite: 0,
906
- input: 0,
907
- output: 0,
586
+ input: 0.05,
587
+ output: 0.2,
908
588
  },
909
589
  input: ["text"],
910
590
  maxTokens: 32768,
@@ -943,8 +623,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
943
623
  cost: {
944
624
  cacheRead: 0,
945
625
  cacheWrite: 0,
946
- input: 0,
947
- output: 0,
626
+ input: 0.2,
627
+ output: 0.8,
948
628
  },
949
629
  input: ["text"],
950
630
  maxTokens: 32768,
@@ -981,10 +661,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
981
661
  },
982
662
  contextWindow: 262144,
983
663
  cost: {
984
- cacheRead: 0,
664
+ cacheRead: 0.15,
985
665
  cacheWrite: 0,
986
- input: 0,
987
- output: 0,
666
+ input: 0.5,
667
+ output: 2.5,
988
668
  },
989
669
  input: ["text"],
990
670
  maxTokens: 32768,
@@ -998,70 +678,6 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
998
678
  xhigh: "max",
999
679
  },
1000
680
  },
1001
- {
1002
- id: "qwen3-coder-next",
1003
- name: "qwen3-coder-next",
1004
- compat: {
1005
- maxTokensField: "max_tokens",
1006
- openRouterRouting: {},
1007
- requiresAssistantAfterToolResult: false,
1008
- requiresReasoningContentOnAssistantMessages: false,
1009
- requiresThinkingAsText: false,
1010
- requiresToolResultName: false,
1011
- sendSessionAffinityHeaders: false,
1012
- supportsDeveloperRole: false,
1013
- supportsLongCacheRetention: false,
1014
- supportsReasoningEffort: true,
1015
- supportsStore: false,
1016
- supportsStrictMode: false,
1017
- supportsUsageInStreaming: true,
1018
- thinkingFormat: "openai",
1019
- vercelGatewayRouting: {},
1020
- zaiToolStream: false,
1021
- },
1022
- contextWindow: 262144,
1023
- cost: {
1024
- cacheRead: 0,
1025
- cacheWrite: 0,
1026
- input: 0,
1027
- output: 0,
1028
- },
1029
- input: ["text"],
1030
- maxTokens: 32768,
1031
- reasoning: false,
1032
- },
1033
- {
1034
- id: "qwen3-coder:480b",
1035
- name: "qwen3-coder:480b",
1036
- compat: {
1037
- maxTokensField: "max_tokens",
1038
- openRouterRouting: {},
1039
- requiresAssistantAfterToolResult: false,
1040
- requiresReasoningContentOnAssistantMessages: false,
1041
- requiresThinkingAsText: false,
1042
- requiresToolResultName: false,
1043
- sendSessionAffinityHeaders: false,
1044
- supportsDeveloperRole: false,
1045
- supportsLongCacheRetention: false,
1046
- supportsReasoningEffort: true,
1047
- supportsStore: false,
1048
- supportsStrictMode: false,
1049
- supportsUsageInStreaming: true,
1050
- thinkingFormat: "openai",
1051
- vercelGatewayRouting: {},
1052
- zaiToolStream: false,
1053
- },
1054
- contextWindow: 262144,
1055
- cost: {
1056
- cacheRead: 0,
1057
- cacheWrite: 0,
1058
- input: 0,
1059
- output: 0,
1060
- },
1061
- input: ["text"],
1062
- maxTokens: 32768,
1063
- reasoning: false,
1064
- },
1065
681
  {
1066
682
  id: "qwen3.5:397b",
1067
683
  name: "qwen3.5:397b",
@@ -1087,8 +703,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
1087
703
  cost: {
1088
704
  cacheRead: 0,
1089
705
  cacheWrite: 0,
1090
- input: 0,
1091
- output: 0,
706
+ input: 0.6,
707
+ output: 3.6,
1092
708
  },
1093
709
  input: ["text", "image"],
1094
710
  maxTokens: 32768,
@@ -1102,36 +718,4 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
1102
718
  xhigh: null,
1103
719
  },
1104
720
  },
1105
- {
1106
- id: "rnj-1:8b",
1107
- name: "rnj-1:8b",
1108
- compat: {
1109
- maxTokensField: "max_tokens",
1110
- openRouterRouting: {},
1111
- requiresAssistantAfterToolResult: false,
1112
- requiresReasoningContentOnAssistantMessages: false,
1113
- requiresThinkingAsText: false,
1114
- requiresToolResultName: false,
1115
- sendSessionAffinityHeaders: false,
1116
- supportsDeveloperRole: false,
1117
- supportsLongCacheRetention: false,
1118
- supportsReasoningEffort: true,
1119
- supportsStore: false,
1120
- supportsStrictMode: false,
1121
- supportsUsageInStreaming: true,
1122
- thinkingFormat: "openai",
1123
- vercelGatewayRouting: {},
1124
- zaiToolStream: false,
1125
- },
1126
- contextWindow: 32768,
1127
- cost: {
1128
- cacheRead: 0,
1129
- cacheWrite: 0,
1130
- input: 0,
1131
- output: 0,
1132
- },
1133
- input: ["text"],
1134
- maxTokens: 32768,
1135
- reasoning: false,
1136
- },
1137
721
  ];
package/models.ts CHANGED
@@ -1,16 +1,55 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { type ExtensionCommandContext, getAgentDir, type ProviderModelConfig } from "@earendil-works/pi-coding-agent";
4
+ import { MODEL_PRICING, type ModelPrice } from "./pricing.generated.ts";
4
5
  import { resolve as resolveThinkingLevelMap } from "./thinking-levels.ts";
5
6
  import { concurrentMap, fetchJsonWithTimeout, getContextLength } from "./utils.ts";
6
7
 
8
+ // --- Pricing ---
9
+ // Estimated per-1M-token prices are generated from models.dev by
10
+ // scripts/generate-pricing.ts (see pricing.generated.ts, do not edit by hand).
11
+ // Ollama Cloud is subscription-billed; these are equivalent pay-as-you-go
12
+ // estimates so /cost shows comparable usage, not actual charges.
13
+
14
+ /** Resolve the estimated price for an Ollama Cloud model ID. Exact match only;
15
+ * unmapped models return zero. */
16
+ function resolvePrice(id: string): ModelPrice {
17
+ return MODEL_PRICING[id] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
18
+ }
19
+
7
20
  // --- Constants ---
8
21
  const CACHE_DIR = join(getAgentDir(), "cache");
9
22
  const CACHE_FILE = join(CACHE_DIR, "ollama-cloud-models.json");
10
23
  const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
11
24
  const FETCH_TIMEOUT_MS = 10000;
12
25
 
13
- export const OLLAMA_BASE = (process.env.OLLAMA_API_BASE || "https://ollama.com").replace(/\/+$/, "");
26
+ // The cloud extension always targets ollama.com; local Ollama daemons (typically
27
+ // pointed at via OLLAMA_API_BASE for the local CLI) are a different product and
28
+ // must not silently redirect cloud requests. Warn once at module load if the
29
+ // env var looks like a non-cloud target so the misconfiguration is visible.
30
+ const CLOUD_BASE_URL = "https://ollama.com";
31
+ const envBase = typeof process !== "undefined" ? process.env?.OLLAMA_API_BASE : undefined;
32
+ if (envBase) {
33
+ // Warn when OLLAMA_API_BASE is set to anything other than the cloud host.
34
+ // Parse the URL so lookalikes (e.g. https://ollama.com.evil.com) are not
35
+ // mistaken for the cloud base. OLLAMA_API_BASE is otherwise ignored: this
36
+ // extension always targets CLOUD_BASE_URL.
37
+ let isCloudBase = false;
38
+ try {
39
+ const url = new URL(envBase);
40
+ isCloudBase = url.protocol === "https:" && url.hostname === "ollama.com";
41
+ } catch {
42
+ // Invalid URL: not the cloud base.
43
+ }
44
+ if (!isCloudBase) {
45
+ console.warn(
46
+ `[pi-ollama-cloud] Ignoring OLLAMA_API_BASE=${envBase}; ` +
47
+ `this extension always targets ${CLOUD_BASE_URL}. ` +
48
+ `Unset OLLAMA_API_BASE (or set it to the cloud URL) to silence this warning.`,
49
+ );
50
+ }
51
+ }
52
+ export const OLLAMA_BASE = CLOUD_BASE_URL.replace(/\/+$/, "");
14
53
 
15
54
  // --- Raw API types ---
16
55
  /** Response from POST /api/show */
@@ -105,7 +144,7 @@ export function assembleModels(raw: Record<string, CachedOllamaModel>): Provider
105
144
  reasoning: data.capabilities?.includes("thinking") ?? false,
106
145
  thinkingLevelMap: resolveThinkingLevelMap(id, data.capabilities ?? []),
107
146
  input: (data.capabilities?.includes("vision") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
108
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
147
+ cost: resolvePrice(id),
109
148
  contextWindow: getContextLength(data.model_info ?? {}),
110
149
  // No per-model limit exposed by the API (https://docs.ollama.com/api-reference/show-model-details,
111
150
  // https://github.com/ollama/ollama/issues/7222). 32768 matches most Ollama Cloud context windows.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ollama-cloud",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -10,6 +10,7 @@
10
10
  "config.ts",
11
11
  "models.ts",
12
12
  "models.generated.ts",
13
+ "pricing.generated.ts",
13
14
  "thinking-levels.ts",
14
15
  "utils.ts",
15
16
  "web-tools.ts",
@@ -26,7 +27,8 @@
26
27
  "lint": "biome check .",
27
28
  "format": "biome format --write .",
28
29
  "test": "vitest run",
29
- "generate-models": "tsx scripts/generate-models.ts && biome format --write models.generated.ts"
30
+ "smoke:web-tools": "tsx scripts/smoke-web-tools.ts",
31
+ "generate-models": "tsx scripts/generate-pricing.ts && tsx scripts/generate-models.ts && biome format --write models.generated.ts pricing.generated.ts"
30
32
  },
31
33
  "pi": {
32
34
  "extensions": [
@@ -0,0 +1,32 @@
1
+ // Auto-generated by scripts/generate-pricing.ts
2
+ // Do not edit manually.
3
+ // Generated: 2026-07-17T19:55:25.388Z
4
+ // Model count: 18
5
+
6
+ export interface ModelPrice {
7
+ input: number;
8
+ output: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ }
12
+
13
+ export const MODEL_PRICING: Record<string, ModelPrice> = {
14
+ "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
15
+ "deepseek-v4-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
16
+ "gemma4:31b": { input: 0.22, output: 0.55, cacheRead: 0.12, cacheWrite: 0 },
17
+ "glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
18
+ "glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
19
+ "gpt-oss:120b": { input: 0.037, output: 0.17, cacheRead: 0, cacheWrite: 0 },
20
+ "gpt-oss:20b": { input: 0.03, output: 0.13, cacheRead: 0.03, cacheWrite: 0 },
21
+ "kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
22
+ "kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
23
+ "kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
24
+ "minimax-m2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.375 },
25
+ "minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
26
+ "minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
27
+ "mistral-large-3:675b": { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 },
28
+ "nemotron-3-nano:30b": { input: 0.05, output: 0.2, cacheRead: 0, cacheWrite: 0 },
29
+ "nemotron-3-super": { input: 0.2, output: 0.8, cacheRead: 0, cacheWrite: 0 },
30
+ "nemotron-3-ultra": { input: 0.5, output: 2.5, cacheRead: 0.15, cacheWrite: 0 },
31
+ "qwen3.5:397b": { input: 0.6, output: 3.6, cacheRead: 0, cacheWrite: 0 },
32
+ };
@@ -14,6 +14,7 @@
14
14
  * - GPT-OSS: no off mode, only low/medium/high
15
15
  * - Qwen 3.x (non-VL): binary-only (think/nothink) - off works
16
16
  * - Qwen 3 VL: "none" doesn't disable thinking - off is hidden
17
+ * - GLM 5.2: off/high/max are exposed; low/medium are hidden
17
18
  * - Kimi K2 Thinking: "none" doesn't disable thinking - off is hidden
18
19
  * - MiniMax M2.x: "none" doesn't disable thinking - off is hidden
19
20
  *
@@ -56,6 +57,17 @@ export const QWEN3: ThinkingLevelMap = {
56
57
  xhigh: null,
57
58
  };
58
59
 
60
+ /** GLM 5.2: Ollama's model page confirms support for "high" and "max" reasoning efforts.
61
+ * https://ollama.com/library/glm-5.2 */
62
+ export const GLM_52: ThinkingLevelMap = {
63
+ off: "none",
64
+ minimal: null,
65
+ low: null,
66
+ medium: null,
67
+ high: "high",
68
+ xhigh: "max",
69
+ };
70
+
59
71
  /** "none" doesn't disable thinking - off is hidden.
60
72
  * Used by kimi and minimax families. */
61
73
  export const NO_OFF: ThinkingLevelMap = {
@@ -75,6 +87,7 @@ export function resolve(id: string, capabilities: string[]): ThinkingLevelMap |
75
87
  if (!capabilities.includes("thinking")) return undefined;
76
88
 
77
89
  if (id.startsWith("gpt-oss")) return GPT_OSS;
90
+ if (id === "glm-5.2") return GLM_52;
78
91
  if (id.startsWith("qwen3-vl")) return NO_OFF;
79
92
  if (id.startsWith("qwen3")) return QWEN3;
80
93
  if (id === "kimi-k2-thinking") return NO_OFF;
package/utils.ts CHANGED
@@ -2,9 +2,23 @@ export async function fetchJsonWithTimeout<T>(
2
2
  url: string,
3
3
  init: RequestInit,
4
4
  timeoutMs: number,
5
+ externalSignal?: AbortSignal,
5
6
  ): Promise<{ ok: boolean; status: number; data: T | null; error?: string }> {
6
7
  const controller = new AbortController();
7
8
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
9
+
10
+ // Link an external abort signal (e.g. a tool's cancellation signal) so the
11
+ // request aborts on either the timeout or the caller aborting. Cleanup runs
12
+ // in the finally block on both the happy and error paths.
13
+ const onExternalAbort = () => controller.abort();
14
+ if (externalSignal) {
15
+ if (externalSignal.aborted) {
16
+ controller.abort();
17
+ } else {
18
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
19
+ }
20
+ }
21
+
8
22
  try {
9
23
  const res = await fetch(url, { ...init, signal: controller.signal });
10
24
  const text = await res.text();
@@ -25,6 +39,9 @@ export async function fetchJsonWithTimeout<T>(
25
39
  return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
26
40
  } finally {
27
41
  clearTimeout(timeout);
42
+ if (externalSignal && !externalSignal.aborted) {
43
+ externalSignal.removeEventListener("abort", onExternalAbort);
44
+ }
28
45
  }
29
46
  }
30
47
 
package/web-tools.ts CHANGED
@@ -3,15 +3,30 @@
3
3
  *
4
4
  * Self-contained module. Depends on:
5
5
  * - models.ts - only for OLLAMA_BASE URL constant
6
- * - pi-coding-agent - AuthStorage, ExtensionAPI, keyHint, truncateToVisualLines
6
+ * - pi-coding-agent - ExtensionAPI, ExtensionContext, keyHint, truncateToVisualLines
7
7
  * - pi-tui - Text, truncateToWidth
8
+ * - utils.ts - fetchJsonWithTimeout
8
9
  * Does NOT depend on provider registration or model fetching internals.
10
+ *
11
+ * API key resolution: each tool's execute() receives an ExtensionContext whose
12
+ * modelRegistry resolves the registered provider's key (runtime/CLI overrides,
13
+ * the registered apiKey: "$OLLAMA_API_KEY" config, and stored auth.json). The
14
+ * OLLAMA_API_KEY env var is a fallback for when the provider is not yet
15
+ * registered at tool-call time. This avoids direct AuthStorage access, which is
16
+ * not part of the public pi-coding-agent API on 0.80.8+.
9
17
  */
10
18
 
11
- import { AuthStorage, type ExtensionAPI, keyHint, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
12
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
19
+ import {
20
+ type ExtensionAPI,
21
+ type ExtensionContext,
22
+ keyHint,
23
+ type Theme,
24
+ truncateToVisualLines,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
13
27
  import { Type } from "@sinclair/typebox";
14
28
  import { OLLAMA_BASE } from "./models.ts";
29
+ import { fetchJsonWithTimeout } from "./utils.ts";
15
30
 
16
31
  // --- Types ---
17
32
 
@@ -31,10 +46,21 @@ interface FetchResponse {
31
46
 
32
47
  // --- Helpers ---
33
48
 
34
- const authStorage = AuthStorage.create();
49
+ const WEB_TOOLS_TIMEOUT_MS = 15000;
35
50
 
36
- async function getCloudApiKey(): Promise<string | undefined> {
37
- return authStorage.getApiKey("ollama-cloud") ?? process.env.OLLAMA_API_KEY;
51
+ /**
52
+ * Resolve the Ollama Cloud API key for a tool execution.
53
+ *
54
+ * Prefers the canonical provider auth chain (ctx.modelRegistry.getApiKeyForProvider),
55
+ * which honors runtime/CLI key overrides, the registered
56
+ * apiKey: "$OLLAMA_API_KEY" config, and stored auth.json credentials. Falls back
57
+ * to the OLLAMA_API_KEY env var for the case where the provider is not yet
58
+ * registered at tool-call time.
59
+ *
60
+ * Exported for unit testing.
61
+ */
62
+ export async function getCloudApiKey(ctx: Pick<ExtensionContext, "modelRegistry">): Promise<string | undefined> {
63
+ return (await ctx.modelRegistry.getApiKeyForProvider("ollama-cloud")) ?? process.env.OLLAMA_API_KEY;
38
64
  }
39
65
 
40
66
  function noApiKeyError() {
@@ -49,6 +75,58 @@ function noApiKeyError() {
49
75
  };
50
76
  }
51
77
 
78
+ /** Search error response for a non-ok result, mapping distinct status codes. */
79
+ function searchError(status: number, error?: string) {
80
+ if (status === 401 || status === 403) {
81
+ return {
82
+ content: [
83
+ {
84
+ type: "text" as const,
85
+ text:
86
+ "Ollama Cloud search failed: authentication error. " + "Check your API key in OLLAMA_API_KEY or auth.json.",
87
+ },
88
+ ],
89
+ isError: true,
90
+ };
91
+ }
92
+ if (status === 429) {
93
+ return {
94
+ content: [{ type: "text" as const, text: "Ollama Cloud search failed: rate limited. Try again shortly." }],
95
+ isError: true,
96
+ };
97
+ }
98
+ return {
99
+ content: [{ type: "text" as const, text: `Search API error (status ${status}): ${error || "unknown error"}` }],
100
+ isError: true,
101
+ };
102
+ }
103
+
104
+ /** Fetch error response for a non-ok result, mapping distinct status codes. */
105
+ function fetchError(status: number, error?: string) {
106
+ if (status === 401 || status === 403) {
107
+ return {
108
+ content: [
109
+ {
110
+ type: "text" as const,
111
+ text:
112
+ "Ollama Cloud fetch failed: authentication error. " + "Check your API key in OLLAMA_API_KEY or auth.json.",
113
+ },
114
+ ],
115
+ isError: true,
116
+ };
117
+ }
118
+ if (status === 429) {
119
+ return {
120
+ content: [{ type: "text" as const, text: "Ollama Cloud fetch failed: rate limited. Try again shortly." }],
121
+ isError: true,
122
+ };
123
+ }
124
+ return {
125
+ content: [{ type: "text" as const, text: `Fetch API error (status ${status}): ${error || "unknown error"}` }],
126
+ isError: true,
127
+ };
128
+ }
129
+
52
130
  const PREVIEW_LINES = 8;
53
131
 
54
132
  /**
@@ -59,10 +137,10 @@ function createRenderResult() {
59
137
  return (
60
138
  result: { content: Array<{ type: string; text: string }>; isError?: boolean },
61
139
  options: { expanded: boolean; isPartial: boolean },
62
- theme: import("@earendil-works/pi-coding-agent").Theme,
140
+ theme: Theme,
63
141
  context: {
64
142
  invalidate: () => void;
65
- lastComponent: import("@earendil-works/pi-tui").Component | undefined;
143
+ lastComponent: Component | undefined;
66
144
  state: { cachedWidth?: number; cachedLines?: string[]; cachedSkipped?: number };
67
145
  },
68
146
  ) => {
@@ -107,6 +185,18 @@ function createRenderResult() {
107
185
  };
108
186
  }
109
187
 
188
+ /** Validate a parsed web_search response: must have a results array. */
189
+ function isSearchResponse(data: unknown): data is SearchResponse {
190
+ return data != null && typeof data === "object" && Array.isArray((data as SearchResponse).results);
191
+ }
192
+
193
+ /** Validate a parsed web_fetch response: must have string title/content and a links array. */
194
+ function isFetchResponse(data: unknown): data is FetchResponse {
195
+ if (data == null || typeof data !== "object") return false;
196
+ const d = data as FetchResponse;
197
+ return typeof d.title === "string" && typeof d.content === "string" && Array.isArray(d.links);
198
+ }
199
+
110
200
  // --- Registrations ---
111
201
 
112
202
  export function registerWebSearchTool(pi: ExtensionAPI) {
@@ -128,12 +218,13 @@ export function registerWebSearchTool(pi: ExtensionAPI) {
128
218
  }),
129
219
  ),
130
220
  }),
131
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
132
- const apiKey = await getCloudApiKey();
221
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
222
+ const apiKey = await getCloudApiKey(ctx);
133
223
  if (!apiKey) return noApiKeyError();
134
224
 
135
- try {
136
- const res = await fetch(`${OLLAMA_BASE}/api/web_search`, {
225
+ const res = await fetchJsonWithTimeout<SearchResponse>(
226
+ `${OLLAMA_BASE}/api/web_search`,
227
+ {
137
228
  method: "POST",
138
229
  headers: {
139
230
  Authorization: `Bearer ${apiKey}`,
@@ -143,53 +234,27 @@ export function registerWebSearchTool(pi: ExtensionAPI) {
143
234
  query: params.query,
144
235
  max_results: params.max_results ?? 5,
145
236
  }),
146
- signal,
147
- });
148
-
149
- if (!res.ok) {
150
- const errorText = await res.text().catch(() => "");
151
- if (res.status === 401 || res.status === 403) {
152
- return {
153
- content: [
154
- {
155
- type: "text",
156
- text:
157
- "Ollama Cloud search failed: authentication error. " +
158
- "Check your API key in OLLAMA_API_KEY or auth.json.",
159
- },
160
- ],
161
- isError: true,
162
- };
163
- }
164
- if (res.status === 429) {
165
- return {
166
- content: [{ type: "text", text: "Ollama Cloud search failed: rate limited. Try again shortly." }],
167
- isError: true,
168
- };
169
- }
170
- return {
171
- content: [
172
- { type: "text", text: `Search API error (status ${res.status}): ${errorText || res.statusText}` },
173
- ],
174
- isError: true,
175
- };
176
- }
237
+ },
238
+ WEB_TOOLS_TIMEOUT_MS,
239
+ signal,
240
+ );
177
241
 
178
- const data = (await res.json()) as SearchResponse;
179
- const formatted = data.results
180
- .map((r, i) => `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.content}`)
181
- .join("\n\n");
182
-
183
- return {
184
- content: [{ type: "text", text: formatted || "No results found." }],
185
- details: { results: data.results },
186
- };
187
- } catch (err) {
242
+ if (!res.ok) return searchError(res.status, res.error);
243
+ if (!isSearchResponse(res.data)) {
188
244
  return {
189
- content: [{ type: "text", text: `Web search failed: ${err instanceof Error ? err.message : String(err)}` }],
245
+ content: [{ type: "text", text: "Web search failed: unexpected response shape from the API." }],
190
246
  isError: true,
191
247
  };
192
248
  }
249
+
250
+ const formatted = res.data.results
251
+ .map((r, i) => `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.content}`)
252
+ .join("\n\n");
253
+
254
+ return {
255
+ content: [{ type: "text", text: formatted || "No results found." }],
256
+ details: { results: res.data.results },
257
+ };
193
258
  },
194
259
  renderCall(args, theme, _context) {
195
260
  const display = args.query ? `ollama_web_search("${args.query}")` : "ollama_web_search";
@@ -210,69 +275,47 @@ export function registerWebFetchTool(pi: ExtensionAPI) {
210
275
  parameters: Type.Object({
211
276
  url: Type.String({ description: "URL to fetch and extract content from", format: "uri" }),
212
277
  }),
213
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
214
- const apiKey = await getCloudApiKey();
278
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
279
+ const apiKey = await getCloudApiKey(ctx);
215
280
  if (!apiKey) return noApiKeyError();
216
281
 
217
- try {
218
- const res = await fetch(`${OLLAMA_BASE}/api/web_fetch`, {
282
+ const res = await fetchJsonWithTimeout<FetchResponse>(
283
+ `${OLLAMA_BASE}/api/web_fetch`,
284
+ {
219
285
  method: "POST",
220
286
  headers: {
221
287
  Authorization: `Bearer ${apiKey}`,
222
288
  "Content-Type": "application/json",
223
289
  },
224
290
  body: JSON.stringify({ url: params.url }),
225
- signal,
226
- });
227
-
228
- if (!res.ok) {
229
- const errorText = await res.text().catch(() => "");
230
- if (res.status === 401 || res.status === 403) {
231
- return {
232
- content: [
233
- {
234
- type: "text",
235
- text:
236
- "Ollama Cloud fetch failed: authentication error. " +
237
- "Check your API key in OLLAMA_API_KEY or auth.json.",
238
- },
239
- ],
240
- isError: true,
241
- };
242
- }
243
- if (res.status === 429) {
244
- return {
245
- content: [{ type: "text", text: "Ollama Cloud fetch failed: rate limited. Try again shortly." }],
246
- isError: true,
247
- };
248
- }
249
- return {
250
- content: [{ type: "text", text: `Fetch API error (status ${res.status}): ${errorText || res.statusText}` }],
251
- isError: true,
252
- };
253
- }
291
+ },
292
+ WEB_TOOLS_TIMEOUT_MS,
293
+ signal,
294
+ );
254
295
 
255
- const data = (await res.json()) as FetchResponse;
256
- const formatted = [
257
- `Title: ${data.title}`,
258
- "",
259
- "Content:",
260
- data.content,
261
- "",
262
- `Links found: ${data.links?.length ?? 0}`,
263
- ...(data.links?.slice(0, 10).map((l) => ` - ${l}`) ?? []),
264
- ].join("\n");
265
-
266
- return {
267
- content: [{ type: "text", text: formatted }],
268
- details: { title: data.title, content: data.content, links: data.links },
269
- };
270
- } catch (err) {
296
+ if (!res.ok) return fetchError(res.status, res.error);
297
+ if (!isFetchResponse(res.data)) {
271
298
  return {
272
- content: [{ type: "text", text: `Web fetch failed: ${err instanceof Error ? err.message : String(err)}` }],
299
+ content: [{ type: "text", text: "Web fetch failed: unexpected response shape from the API." }],
273
300
  isError: true,
274
301
  };
275
302
  }
303
+
304
+ const data = res.data;
305
+ const formatted = [
306
+ `Title: ${data.title}`,
307
+ "",
308
+ "Content:",
309
+ data.content,
310
+ "",
311
+ `Links found: ${data.links?.length ?? 0}`,
312
+ ...(data.links?.slice(0, 10).map((l) => ` - ${l}`) ?? []),
313
+ ].join("\n");
314
+
315
+ return {
316
+ content: [{ type: "text", text: formatted }],
317
+ details: { title: data.title, content: data.content, links: data.links },
318
+ };
276
319
  },
277
320
  renderCall(args, theme, _context) {
278
321
  const display = args.url ? `ollama_web_fetch("${args.url}")` : "ollama_web_fetch";