pi-ollama-cloud 0.7.0 → 0.8.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,7 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.8.0] - 2026-08-09
6
+
7
+ - **Breaking:** Migrate model refresh to pi's native `refreshModels` mechanism. Remove the `/ollama-cloud-refresh` command and the manual `~/.pi/agent/cache/ollama-cloud-models.json` cache. The catalog now refreshes automatically on startup, on `/model` open, and via `pi update --models`, persisted through pi's own `FileModelsStore`. Users should delete the orphaned cache file after upgrade: `rm ~/.pi/agent/cache/ollama-cloud-models.json`.
8
+ - The shipped `GENERATED_MODELS` list is now a first-launch fallback; releases are no longer required for model freshness (only for deprecation or pricing changes).
9
+ - Refresh the generated catalog from the live API: add `deepseek-v4-flash:0731`, `deepseek-v4-flash:preview`, and `kimi-k3`; remove `kimi-k2.5` and `minimax-m2.5`, which are no longer listed. Refresh models.dev pricing, including the new models. Thanks @noruthedev (#43).
10
+ - Requires a pi version with the `stored`/`publish` `RefreshModelsContext` (pi 0.84.0+). Add `@earendil-works/pi-ai` as a peer dependency.
11
+ - Web tools now throw on errors instead of returning an `isError` result, aligning with pi 0.84.0's `AgentToolResult` contract.
12
+ - Fix `ollama_web_fetch` failing on pages where the API returns `links: null` (e.g. GitHub PR pages); the response is now accepted and rendered without a links list.
13
+ - Refresh robustness: add a 4-hour cooldown so repeated `/model` opens don't re-fetch the catalog; on a partial refresh failure, keep the last-good catalog and surface the error instead of silently returning an incomplete list.
14
+ - Add `tsgo --noEmit` type-checking to `npm run check` and CI.
15
+
16
+ ## [0.7.0] - 2026-07-18
6
17
 
7
18
  - 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
19
  - Restore the `OLLAMA_API_KEY` env-var fallback in `ollama_web_search` and `ollama_web_fetch`. Thanks @cawilliamson (#26).
package/README.md CHANGED
@@ -8,10 +8,8 @@ Registers Ollama Cloud as a model provider with dynamically fetched models, and
8
8
 
9
9
  - **Dynamic model discovery** - Fetches the full model list from `ollama.com/v1/models`, then fetches per-model details via `/api/show` to determine capabilities, context length, and tool support.
10
10
  - **Curated thinking levels** - Maps Pi's thinking levels to Ollama Cloud's OpenAI-compatible `reasoning_effort` values via `thinking-levels.ts`, with per-model exceptions based on API testing.
11
- - **Baked-in model list** - A generated model list (`models.generated.ts`) ships with the extension so models are available immediately on first launch without any network calls. Updated by running `npm run generate-models` and releasing a new version.
12
- - **Persistent cache** - Running `/ollama-cloud-refresh` fetches the latest models from the API and caches them to `~/.pi/agent/cache/ollama-cloud-models.json`. On subsequent launches, this disk cache takes precedence over the baked-in list.
13
- - **Auto-refresh on stale cache** - When the disk cache is older than 30 days, the extension uses it immediately and shows a visible refresh progress widget on the next `session_start` to pull in any new models.
14
- - **`/ollama-cloud-refresh` command** - Re-fetches the model list and updates the cache and provider registration live (no restart needed).
11
+ - **Baked-in model list** - A generated fallback list (`models.generated.ts`) ships with the extension so models are available on first launch without any network calls. It is only a fallback: pi refreshes the live catalog at runtime, so shipping a new release for catalog freshness is no longer needed.
12
+ - **Automatic model refresh** - On startup, `/model` open, and `pi update --models`, pi calls the extension's `refreshModels` callback to fetch the latest models from the API and persists them through pi's own model store. No manual refresh command.
15
13
  - **`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
14
  - **`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
15
  - **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.
@@ -110,19 +108,7 @@ Example `ollama-cloud.json`:
110
108
 
111
109
  The `PI_OLLAMA_WEB_TOOLS` environment variable still works as an override above config files. Set it to `0`, `false`, `no`, or `off` to disable web tools regardless of config file settings.
112
110
 
113
- ### 4. Fetch models (optional)
114
-
115
- On first launch the plugin uses a baked-in model list shipped with the extension — no network calls needed. If you want the very latest models, run `/ollama-cloud-refresh` to fetch from the API and cache the result to disk. After that, the disk cache is used on subsequent launches.
116
-
117
- If the disk cache is older than 30 days, the extension uses it immediately and runs a visible refresh on the next session start (progress appears in the UI widget). You can also run:
118
-
119
- ```
120
- /ollama-cloud-refresh
121
- ```
122
-
123
- This fetches the full model list from the Ollama Cloud API and overwrites the local cache.
124
-
125
- ### 5. Select a model
111
+ ### 4. Select a model
126
112
 
127
113
  Use `/model` or `Ctrl+L` to switch to an Ollama Cloud model. Models appear under the `ollama-cloud` provider.
128
114
 
@@ -135,9 +121,11 @@ The plugin uses two Ollama Cloud API endpoints to build the model list:
135
121
 
136
122
  Only models with the `tools` capability are registered - these are the ones Pi can use for tool-calling.
137
123
 
138
- The raw `/api/show` responses are cached at `~/.pi/agent/cache/ollama-cloud-models.json` with a top-level `timestamp` value. If that local cache is older than 30 days, the plugin keeps using it immediately and runs a visible refresh on `session_start` (progress appears in the UI widget). If the cache is missing, the plugin uses the baked-in model list shipped with the extension (`models.generated.ts`).
124
+ The model list refreshes automatically: pi calls the extension's `refreshModels` callback on startup, when `/model` opens, and on `pi update --models`, fetching the live catalog and persisting it through pi's own model store. A model removed from the Ollama Cloud API disappears after the next successful refresh. The baked-in `models.generated.ts` list (regenerated via `npm run generate-models`) is only a first-launch fallback when no persisted catalog exists yet.
139
125
 
140
- Model metadata is derived from the cached data:
126
+ The model fetch itself is keyless (the `/v1/models` and `/api/show` endpoints are public), but pi only runs the live refresh when a credential resolves, so a user without a configured API key stays on the baked-in list until they add one. That is a non-issue in practice because a credentialless user cannot run models anyway.
127
+
128
+ Model metadata is derived from the `/api/show` response:
141
129
 
142
130
  | Field | Source |
143
131
  |---|---|
@@ -146,7 +134,7 @@ Model metadata is derived from the cached data:
146
134
  | `input` | `["text", "image"]` if `capabilities` includes `"vision"`, else `["text"]` |
147
135
  | `contextWindow` | `model_info.*.context_length` (falls back to 128000) |
148
136
  | `maxTokens` | Fixed at 32768 |
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. |
137
+ | `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 and only update on a new release, so newly added models register with zero cost until then. |
150
138
 
151
139
  ### Thinking level mapping
152
140
 
@@ -162,14 +150,6 @@ Pi's thinking levels are mapped to Ollama Cloud's OpenAI-compatible `reasoning_e
162
150
 
163
151
  See [docs/think-experiment.md](docs/think-experiment.md) for the testing methodology and results.
164
152
 
165
- Refresh from inside Pi:
166
-
167
- ```text
168
- /ollama-cloud-refresh
169
- ```
170
-
171
- That command updates `~/.pi/agent/cache/ollama-cloud-models.json` with a new `timestamp` and re-registers the provider live, so no restart is required.
172
-
173
153
  ## Tools
174
154
 
175
155
  | Tool | Description |
@@ -183,19 +163,48 @@ Both tools use the same Ollama Cloud API key configured for the provider. No loc
183
163
 
184
164
  | Command | Description |
185
165
  |---|---|
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). |
187
166
  | `/ollama-webtools [on\|off\|enable\|disable]` | Enable or disable the `ollama_web_search` and `ollama_web_fetch` tools. Toggles if no argument given. |
188
167
 
189
168
  ## Development
190
169
 
191
170
  ```bash
192
- npm install # install devDependencies (biome)
193
- npm run check # lint + format with auto-fix
194
- npm run lint # lint only (no fixes)
195
- npm run format # format only
171
+ npm install # install devDependencies
172
+ npm run check # lint + format + type-check (auto-fix)
173
+ npm run lint # lint only (no fixes)
174
+ npm run typecheck # type-check only (tsgo --noEmit)
175
+ npm run format # format only
196
176
  ```
197
177
 
198
- The project uses [Biome](https://biomejs.dev/) for linting and formatting (2-space indent, line width 120).
178
+ The project uses [Biome](https://biomejs.dev/) for linting and formatting (2-space indent, line width 120) and [tsgo](https://github.com/microsoft/typescript-go) for type-checking.
179
+
180
+ ### Testing local changes
181
+
182
+ Static checks (no API key needed):
183
+
184
+ ```bash
185
+ npm install
186
+ npm run check # lint + format + type-check
187
+ npm run test # unit tests
188
+ ```
189
+
190
+ Live smoke against the real API (needs an `OLLAMA_API_KEY` or an `ollama-cloud` entry in `auth.json`):
191
+
192
+ ```bash
193
+ # Run pi with the local extension, no install required. The --no-* flags isolate
194
+ # the run from other installed extensions, skills, prompt templates, themes,
195
+ # context files, and session storage so only the local checkout is exercised.
196
+ pi --no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files --no-session \
197
+ -e ./index.ts --model "ollama-cloud/gemma4:31b" --no-tools -p "Say hi in one word"
198
+
199
+ # Verify thinking is suppressed when off
200
+ pi --no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files --no-session \
201
+ -e ./index.ts --thinking off --model "ollama-cloud/glm-5.2" --no-tools --mode json -p 'hi'
202
+
203
+ # Web tools
204
+ npm run smoke:web-tools
205
+ ```
206
+
207
+ The `-e`/`--extension` flag loads the extension from the local checkout without installing it; `--no-extensions` disables all other extension discovery so the run cannot pick up an installed `pi-ollama-cloud` or other plugins. The same commands run in CI (`.github/workflows/test.yml`), gated on the `OLLAMA_CLOUD_API_KEY` secret.
199
208
 
200
209
  ## How is this different from `ollama launch pi`?
201
210
 
@@ -211,7 +220,7 @@ The project uses [Biome](https://biomejs.dev/) for linting and formatting (2-spa
211
220
  | **Authentication** | Handled by the local server (sign-in flow via `ollama`) | Ollama Cloud API key (set via `OLLAMA_API_KEY` or `auth.json`) |
212
221
  | **Model discovery** | Interactive picker with curated recommendations + pulled models | Dynamic - fetches all available cloud models with tool support from the API |
213
222
  | **Web tools** | Auto-installed (`@ollama/pi-web-search`) when cloud is enabled | ✅ Built-in: `ollama_web_search` and `ollama_web_fetch` use the [Ollama Cloud web search API](https://docs.ollama.com/capabilities/web-search) directly (same API key, no local server needed) |
214
- | **Setup effort** | One command: `ollama launch pi` | Install extension + API key + `/ollama-cloud-refresh` |
223
+ | **Setup effort** | One command: `ollama launch pi` | Install extension + API key |
215
224
  | **Use when** | You're already running Ollama locally and want the default experience | You don't want to run a local server, or want a standalone cloud-only provider alongside your local setup |
216
225
 
217
226
  **You can use both at the same time.** The providers live under different names (`ollama` vs `ollama-cloud`), so you can switch between them with `/model` or `Ctrl+L`. For example, use your local `ollama` provider for low-latency work on smaller models, and `ollama-cloud` for direct access to the full catalog of cloud models without needing a local server.
@@ -229,6 +238,11 @@ npm version minor # or patch, or major
229
238
  git push --tags
230
239
  ```
231
240
 
241
+ Because the model catalog refreshes automatically at runtime, a release is **not** needed to ship new models. Publish only when:
242
+
243
+ - A model is retired and still listed by the API: add it to `RETIRED_MODEL_IDS` in `scripts/generate-models.ts` (check https://docs.ollama.com/cloud#retirements, then regenerate `models.generated.ts`).
244
+ - Pricing changes: models.dev prices updated, or a new model needs an `OLLAMA_TO_MODELSDEV` mapping line (regenerate `pricing.generated.ts`).
245
+
232
246
  The tag version must match the version in `package.json` - `npm version` handles this automatically. The workflow at `.github/workflows/publish.yml` verifies the match before publishing to npm.
233
247
 
234
248
  The workflow uses npm's [trusted publishing](https://docs.npmjs.com/trusted-publishers/) (OIDC) - no tokens stored as secrets. To set it up:
@@ -240,7 +254,15 @@ The workflow uses npm's [trusted publishing](https://docs.npmjs.com/trusted-publ
240
254
 
241
255
  Each publish also gets automatic [provenance attestation](https://docs.npmjs.com/generating-provenance-statements).
242
256
 
257
+ ## Upgrading
258
+
259
+ Since 0.8.0:
260
+
261
+ - The `/ollama-cloud-refresh` command is removed. Models refresh automatically on startup, `/model` open, and `pi update --models`.
262
+ - The old cache file at `~/.pi/agent/cache/ollama-cloud-models.json` is orphaned. Delete it manually: `rm ~/.pi/agent/cache/ollama-cloud-models.json`.
263
+ - Requires a pi version with the native `refreshModels` API (pi 0.84.0+).
264
+
243
265
  ## Notes
244
266
 
245
- - The fetch timeout is 10 seconds per request. On slow connections, some model detail fetches may time out - the plugin reports how many succeeded vs failed.
267
+ - The fetch timeout is 10 seconds per request. On slow connections, some model detail fetches may time out; the refresh uses whatever succeeded and only fails if every model detail fetch fails.
246
268
  - `deepseek-v4` occasionally emits raw `<|DSML|tool_calls|>` markup as plain text instead of structured tool calls, then stops. This is DeepSeek's native tool-call format leaking through Ollama Cloud's OpenAI-compatible endpoint, so it looks like an upstream Ollama issue rather than something this extension can fix. If you hit it, retry or switch models.
package/index.ts CHANGED
@@ -1,144 +1,46 @@
1
1
  /**
2
2
  * Ollama Cloud Provider Extension
3
3
  *
4
- * Registers Ollama Cloud as a model provider with dynamically fetched models.
4
+ * Registers Ollama Cloud as a model provider with a baked-in fallback catalog
5
+ * and a native `refreshModels` callback that overlays live API updates.
5
6
  *
6
7
  * Setup:
7
8
  * 1. Get an API key from https://ollama.com
8
9
  * 2. Add to auth.json in the agent config dir (~/.pi/agent/auth.json, or set PI_CODING_AGENT_DIR):
9
10
  * { "ollama-cloud": { "type": "api_key", "key": "your-key" } }
10
- * 3. Run /ollama-cloud-refresh to fetch model metadata
11
- * 4. Use /model or ctrl+l to select an Ollama Cloud model
11
+ * 3. 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
14
  * - GET https://ollama.com/v1/models -> list of model IDs
15
15
  * - POST https://ollama.com/api/show -> per-model details (capabilities, context length)
16
16
  *
17
- * Raw /api/show responses are cached at <agentDir>/cache/ollama-cloud-models.json
18
- * so the provider assembly can be debugged and re-derived without re-fetching.
19
- *
20
- * Startup behavior:
21
- * - Missing cache: uses baked-in GENERATED_MODELS (manually generated via
22
- * `npm run generate-models` and committed to the repo).
23
- * - Stale cache (>30 days): uses the cached data immediately and triggers a visible refresh
24
- * on session_start that shows progress in the UI widget.
25
- * - Fresh cache: uses cached data directly, no refresh triggered.
17
+ * Catalog behavior:
18
+ * - The baked-in GENERATED_MODELS list (via `npm run generate-models`) is the
19
+ * first-launch fallback when no persisted catalog exists.
20
+ * - On startup, /model open, and `pi update --models`, pi calls the
21
+ * `refreshModels` callback, which fetches the live catalog and persists it
22
+ * through pi's own FileModelsStore. Refresh is automatic.
26
23
  *
27
24
  * Only models with "tools" capability are registered.
28
25
  */
29
26
 
30
- import type { ExtensionAPI, ExtensionCommandContext, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
27
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
28
  import { loadConfig, resolveWebToolsEnv } from "./config.ts";
32
29
  import { GENERATED_MODELS } from "./models.generated.ts";
33
- import {
34
- assembleModels,
35
- fetchModels,
36
- OLLAMA_BASE,
37
- type RefreshProgress,
38
- readCacheState,
39
- writeCache,
40
- } from "./models.ts";
30
+ import { OLLAMA_BASE, refreshOllamaCatalog } from "./models.ts";
41
31
  import { registerWebFetchTool, registerWebSearchTool } from "./web-tools.ts";
42
32
 
43
- // --- Registrations ---
33
+ // --- Main ---
44
34
 
45
- function registerProvider(pi: ExtensionAPI, models: ProviderModelConfig[]) {
35
+ export default async function (pi: ExtensionAPI) {
46
36
  pi.registerProvider("ollama-cloud", {
47
37
  name: "Ollama Cloud",
48
38
  baseUrl: `${OLLAMA_BASE}/v1`,
49
39
  apiKey: "$OLLAMA_API_KEY",
50
40
  api: "openai-completions",
51
- models,
41
+ models: GENERATED_MODELS,
42
+ refreshModels: refreshOllamaCatalog,
52
43
  });
53
- }
54
-
55
- function renderProgressBar(current: number, total: number, width = 15): string {
56
- if (total <= 0) return `[${"░".repeat(width)}]`;
57
- const ratio = Math.max(0, Math.min(1, current / total));
58
- const filled = Math.round(ratio * width);
59
- return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
60
- }
61
-
62
- function createRefreshProgressUi(ctx: Pick<ExtensionCommandContext, "ui">) {
63
- const key = "ollama-cloud-refresh";
64
- return {
65
- update(progress: RefreshProgress) {
66
- const current = progress.current ?? 0;
67
- const total = progress.total ?? 0;
68
- const percent = total > 0 ? Math.round((current / total) * 100) : 0;
69
- const failed = progress.failed ? `, ${progress.failed} failed` : "";
70
- const stage =
71
- progress.stage === "list"
72
- ? "Discovering models"
73
- : progress.stage === "details"
74
- ? "Fetching model details"
75
- : "Done";
76
- const summary = total > 0 ? `${current}/${total} (${percent}%${failed})` : progress.message;
77
- const line = `☁ Ollama Cloud - ${stage} — ${summary} ${renderProgressBar(current, total)}`;
78
-
79
- ctx.ui.setWorkingMessage(`Refreshing Ollama Cloud models - ${stage.toLowerCase()}`);
80
- ctx.ui.setWidget(key, [line], { placement: "belowEditor" });
81
- },
82
- clear() {
83
- ctx.ui.setWidget(key, undefined);
84
- ctx.ui.setStatus(key, undefined);
85
- ctx.ui.setWorkingMessage();
86
- },
87
- };
88
- }
89
-
90
- async function runRefresh(pi: ExtensionAPI, ctx: Pick<ExtensionCommandContext, "ui">) {
91
- const progressUi = createRefreshProgressUi(ctx);
92
- try {
93
- progressUi.update({ stage: "list", message: "Starting refresh..." });
94
-
95
- const raw = await fetchModels(ctx, (progress) => progressUi.update(progress));
96
- if (!raw) return false;
97
-
98
- writeCache(raw);
99
- const newModels = assembleModels(raw);
100
-
101
- registerProvider(pi, newModels);
102
-
103
- ctx.ui.notify(`Registered ${newModels.length} Ollama Cloud models`, "info");
104
- return true;
105
- } finally {
106
- progressUi.clear();
107
- }
108
- }
109
-
110
- function registerRefreshCommand(pi: ExtensionAPI) {
111
- pi.registerCommand("ollama-cloud-refresh", {
112
- description: "Refresh Ollama Cloud models from the API",
113
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
114
- await runRefresh(pi, ctx);
115
- },
116
- });
117
- }
118
-
119
- // --- Main ---
120
-
121
- export default async function (pi: ExtensionAPI) {
122
- const cacheState = readCacheState();
123
- // Auto-refresh only when the disk cache is stale (>30 days).
124
- // When cache is missing, GENERATED_MODELS serves as the cache —
125
- // it is manually generated via `npm run generate-models` and committed to the repo.
126
- const needsStartupRefresh = cacheState.status === "stale";
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.
129
- const models = cacheState.status === "missing" ? GENERATED_MODELS : assembleModels(cacheState.models);
130
-
131
- registerProvider(pi, models);
132
- registerRefreshCommand(pi);
133
-
134
- if (needsStartupRefresh) {
135
- let started = false;
136
- pi.on("session_start", async (_event, ctx) => {
137
- if (started) return;
138
- started = true;
139
- await runRefresh(pi, ctx);
140
- });
141
- }
142
44
 
143
45
  // --- Web Tools Management ---
144
46
 
@@ -1,14 +1,54 @@
1
1
  // Auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually.
3
- // Generated: 2026-07-17T19:55:27.551Z
3
+ // Generated: 2026-08-08T04:19:46.959Z
4
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
9
  {
10
- id: "deepseek-v4-flash",
11
- name: "deepseek-v4-flash",
10
+ id: "deepseek-v4-flash:0731",
11
+ name: "deepseek-v4-flash:0731",
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: 1048576,
31
+ cost: {
32
+ cacheRead: 0.0028,
33
+ cacheWrite: 0,
34
+ input: 0.14,
35
+ output: 0.28,
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-v4-flash:preview",
51
+ name: "deepseek-v4-flash:preview",
12
52
  compat: {
13
53
  maxTokensField: "max_tokens",
14
54
  openRouterRouting: {},
@@ -109,10 +149,10 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
109
149
  },
110
150
  contextWindow: 262144,
111
151
  cost: {
112
- cacheRead: 0.12,
152
+ cacheRead: 0.1,
113
153
  cacheWrite: 0,
114
- input: 0.22,
115
- output: 0.55,
154
+ input: 0.1,
155
+ output: 0.34,
116
156
  },
117
157
  input: ["text", "image"],
118
158
  maxTokens: 32768,
@@ -286,46 +326,6 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
286
326
  xhigh: null,
287
327
  },
288
328
  },
289
- {
290
- id: "kimi-k2.5",
291
- name: "kimi-k2.5",
292
- compat: {
293
- maxTokensField: "max_tokens",
294
- openRouterRouting: {},
295
- requiresAssistantAfterToolResult: false,
296
- requiresReasoningContentOnAssistantMessages: false,
297
- requiresThinkingAsText: false,
298
- requiresToolResultName: false,
299
- sendSessionAffinityHeaders: false,
300
- supportsDeveloperRole: false,
301
- supportsLongCacheRetention: false,
302
- supportsReasoningEffort: true,
303
- supportsStore: false,
304
- supportsStrictMode: false,
305
- supportsUsageInStreaming: true,
306
- thinkingFormat: "openai",
307
- vercelGatewayRouting: {},
308
- zaiToolStream: false,
309
- },
310
- contextWindow: 262144,
311
- cost: {
312
- cacheRead: 0.1,
313
- cacheWrite: 0,
314
- input: 0.6,
315
- output: 3,
316
- },
317
- input: ["text", "image"],
318
- maxTokens: 32768,
319
- reasoning: true,
320
- thinkingLevelMap: {
321
- high: "high",
322
- low: "low",
323
- medium: "medium",
324
- minimal: null,
325
- off: "none",
326
- xhigh: "max",
327
- },
328
- },
329
329
  {
330
330
  id: "kimi-k2.6",
331
331
  name: "kimi-k2.6",
@@ -407,8 +407,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
407
407
  },
408
408
  },
409
409
  {
410
- id: "minimax-m2.5",
411
- name: "minimax-m2.5",
410
+ id: "kimi-k3",
411
+ name: "kimi-k3",
412
412
  compat: {
413
413
  maxTokensField: "max_tokens",
414
414
  openRouterRouting: {},
@@ -427,14 +427,14 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
427
427
  vercelGatewayRouting: {},
428
428
  zaiToolStream: false,
429
429
  },
430
- contextWindow: 196608,
430
+ contextWindow: 1048576,
431
431
  cost: {
432
- cacheRead: 0.03,
433
- cacheWrite: 0.375,
434
- input: 0.3,
435
- output: 1.2,
432
+ cacheRead: 0.3,
433
+ cacheWrite: 0,
434
+ input: 3,
435
+ output: 15,
436
436
  },
437
- input: ["text"],
437
+ input: ["text", "image"],
438
438
  maxTokens: 32768,
439
439
  reasoning: true,
440
440
  thinkingLevelMap: {
@@ -442,7 +442,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
442
442
  low: "low",
443
443
  medium: "medium",
444
444
  minimal: null,
445
- off: null,
445
+ off: "none",
446
446
  xhigh: "max",
447
447
  },
448
448
  },
@@ -581,7 +581,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
581
581
  },
582
582
  contextWindow: 262144,
583
583
  cost: {
584
- cacheRead: 0,
584
+ cacheRead: 0.03,
585
585
  cacheWrite: 0,
586
586
  input: 0.05,
587
587
  output: 0.2,
package/models.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { type ExtensionCommandContext, getAgentDir, type ProviderModelConfig } from "@earendil-works/pi-coding-agent";
1
+ import type { RefreshModelsContext } from "@earendil-works/pi-ai";
2
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
3
+ import { GENERATED_MODELS } from "./models.generated.ts";
4
4
  import { MODEL_PRICING, type ModelPrice } from "./pricing.generated.ts";
5
5
  import { resolve as resolveThinkingLevelMap } from "./thinking-levels.ts";
6
6
  import { concurrentMap, fetchJsonWithTimeout, getContextLength } from "./utils.ts";
@@ -18,10 +18,10 @@ function resolvePrice(id: string): ModelPrice {
18
18
  }
19
19
 
20
20
  // --- Constants ---
21
- const CACHE_DIR = join(getAgentDir(), "cache");
22
- const CACHE_FILE = join(CACHE_DIR, "ollama-cloud-models.json");
23
- const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
24
21
  const FETCH_TIMEOUT_MS = 10000;
22
+ // How long a stored catalog is considered fresh before the next network refresh
23
+ // (mirrors pi-mono's REMOTE_CATALOG_REFRESH_INTERVAL_MS in remote-catalog-provider.ts).
24
+ const REFRESH_COOLDOWN_MS = 4 * 60 * 60 * 1000;
25
25
 
26
26
  // The cloud extension always targets ollama.com; local Ollama daemons (typically
27
27
  // pointed at via OLLAMA_API_BASE for the local CLI) are a different product and
@@ -67,25 +67,6 @@ interface OllamaShowResponse {
67
67
  modified_at: string;
68
68
  }
69
69
 
70
- type CachedOllamaModel = OllamaShowResponse;
71
-
72
- /** On-disk cache: raw /api/show responses keyed by model ID. */
73
- interface CachedData {
74
- /** Unix epoch milliseconds used to decide when the generated metadata is stale. */
75
- timestamp?: number;
76
- models: Record<string, CachedOllamaModel>;
77
- }
78
-
79
- type RefreshProgressStage = "list" | "details" | "done";
80
-
81
- export interface RefreshProgress {
82
- stage: RefreshProgressStage;
83
- current?: number;
84
- total?: number;
85
- failed?: number;
86
- message: string;
87
- }
88
-
89
70
  // --- Assembly: raw API data -> ProviderModelConfig[] ---
90
71
 
91
72
  /**
@@ -135,7 +116,7 @@ function buildCompat(): ProviderModelConfig["compat"] {
135
116
  };
136
117
  }
137
118
 
138
- export function assembleModels(raw: Record<string, CachedOllamaModel>): ProviderModelConfig[] {
119
+ export function assembleModels(raw: Record<string, OllamaShowResponse>): ProviderModelConfig[] {
139
120
  return Object.entries(raw)
140
121
  .filter(([, data]) => data.capabilities?.includes("tools"))
141
122
  .map(([id, data]) => ({
@@ -153,60 +134,8 @@ export function assembleModels(raw: Record<string, CachedOllamaModel>): Provider
153
134
  }));
154
135
  }
155
136
 
156
- // --- Cache I/O ---
157
- type CacheState =
158
- | { status: "fresh"; models: Record<string, CachedOllamaModel> }
159
- | { status: "stale"; models: Record<string, CachedOllamaModel> }
160
- | { status: "missing" };
161
-
162
- function createCacheData(models: Record<string, CachedOllamaModel>, now = new Date()): CachedData {
163
- return { timestamp: now.getTime(), models };
164
- }
165
-
166
- function readCacheData(path: string): CachedData | null {
167
- try {
168
- const data: CachedData = JSON.parse(readFileSync(path, "utf-8"));
169
- if (!data.models || Object.keys(data.models).length === 0) return null;
170
- return data;
171
- } catch {
172
- return null;
173
- }
174
- }
175
-
176
- function isFreshGeneratedCache(data: CachedData): boolean {
177
- if (typeof data.timestamp !== "number" || !Number.isFinite(data.timestamp)) return false;
178
- return Date.now() - data.timestamp <= CACHE_MAX_AGE_MS;
179
- }
180
-
181
- export function readCacheState(): CacheState {
182
- if (!existsSync(CACHE_FILE)) return { status: "missing" };
183
-
184
- const data = readCacheData(CACHE_FILE);
185
- if (!data) {
186
- try {
187
- rmSync(CACHE_FILE, { force: true });
188
- } catch {
189
- // Ignore cache delete errors.
190
- }
191
- return { status: "missing" };
192
- }
193
-
194
- return isFreshGeneratedCache(data)
195
- ? { status: "fresh", models: data.models }
196
- : { status: "stale", models: data.models };
197
- }
198
-
199
- export function writeCache(models: Record<string, CachedOllamaModel>): void {
200
- try {
201
- mkdirSync(CACHE_DIR, { recursive: true });
202
- writeFileSync(CACHE_FILE, JSON.stringify(createCacheData(models), null, 2));
203
- } catch {
204
- // Ignore cache write errors
205
- }
206
- }
207
-
208
137
  // --- Fetch Models ---
209
- export async function fetchModelIds(timeoutMs = FETCH_TIMEOUT_MS): Promise<string[]> {
138
+ export async function fetchModelIds(signal?: AbortSignal, timeoutMs = FETCH_TIMEOUT_MS): Promise<string[]> {
210
139
  const headers: Record<string, string> = {};
211
140
  const apiKey = process.env.OLLAMA_API_KEY;
212
141
  if (apiKey) {
@@ -217,10 +146,11 @@ export async function fetchModelIds(timeoutMs = FETCH_TIMEOUT_MS): Promise<strin
217
146
  `${OLLAMA_BASE}/v1/models`,
218
147
  { headers },
219
148
  timeoutMs,
149
+ signal,
220
150
  );
221
151
 
222
152
  if (res.status === 429) {
223
- throw new Error("Ollama Cloud rate limited. Try again shortly.");
153
+ throw new Error("Ollama Cloud model list fetch rate limited. Try again shortly.");
224
154
  }
225
155
  if (!res.ok || !res.data) {
226
156
  throw new Error(`Failed to fetch model list: ${res.status}${res.error ? ` - ${res.error}` : ""}`);
@@ -229,7 +159,11 @@ export async function fetchModelIds(timeoutMs = FETCH_TIMEOUT_MS): Promise<strin
229
159
  return res.data.data.map((m) => m.id);
230
160
  }
231
161
 
232
- export async function fetchModelDetails(id: string, timeoutMs = FETCH_TIMEOUT_MS): Promise<CachedOllamaModel> {
162
+ export async function fetchModelDetails(
163
+ id: string,
164
+ signal?: AbortSignal,
165
+ timeoutMs = FETCH_TIMEOUT_MS,
166
+ ): Promise<OllamaShowResponse> {
233
167
  const headers: Record<string, string> = { "Content-Type": "application/json" };
234
168
  const apiKey = process.env.OLLAMA_API_KEY;
235
169
  if (apiKey) {
@@ -244,10 +178,11 @@ export async function fetchModelDetails(id: string, timeoutMs = FETCH_TIMEOUT_MS
244
178
  body: JSON.stringify({ model: id }),
245
179
  },
246
180
  timeoutMs,
181
+ signal,
247
182
  );
248
183
 
249
184
  if (res.status === 429) {
250
- throw new Error("Ollama Cloud rate limited. Try again shortly.");
185
+ throw new Error("Ollama Cloud /api/show rate limited. Try again shortly.");
251
186
  }
252
187
  if (!res.ok || !res.data) {
253
188
  throw new Error(`Failed to fetch /api/show for ${id}: ${res.status}${res.error ? ` - ${res.error}` : ""}`);
@@ -256,69 +191,161 @@ export async function fetchModelDetails(id: string, timeoutMs = FETCH_TIMEOUT_MS
256
191
  return res.data;
257
192
  }
258
193
 
259
- export async function refreshOllamaCloudModels(params: {
260
- notify?: (message: string, level?: "info" | "error") => void;
261
- onProgress?: (progress: RefreshProgress) => void;
262
- workers?: number;
263
- }): Promise<Record<string, CachedOllamaModel>> {
264
- const notify = params.notify ?? (() => undefined);
265
- const onProgress = params.onProgress ?? (() => undefined);
266
- onProgress({ stage: "list", message: "Fetching model list..." });
267
- const modelIds = await fetchModelIds();
268
- notify(`Found ${modelIds.length} models, fetching details...`);
269
- onProgress({ stage: "details", current: 0, total: modelIds.length, failed: 0, message: "Fetching model details" });
270
-
271
- let detailsDone = 0;
272
- let detailsFailed = 0;
273
- const detailResults = await concurrentMap(modelIds, params.workers ?? 8, async (id) => {
274
- try {
275
- return [id, await fetchModelDetails(id)] as const;
276
- } catch (error) {
277
- detailsFailed++;
278
- throw error;
279
- } finally {
280
- detailsDone++;
281
- onProgress({
282
- stage: "details",
283
- current: detailsDone,
284
- total: modelIds.length,
285
- failed: detailsFailed,
286
- message: "Fetching model details",
287
- });
288
- }
194
+ /**
195
+ * Fetch per-model /api/show details for a list of model IDs, 8 workers at a time.
196
+ * Returns the models that succeeded. Throws when every detail request fails
197
+ * (the zero-succeeded case), so the caller can surface a real failure instead
198
+ * of an empty catalog.
199
+ */
200
+ export async function refreshOllamaCloudModels(
201
+ modelIds: string[],
202
+ signal?: AbortSignal,
203
+ ): Promise<{ models: Record<string, OllamaShowResponse>; failed: number }> {
204
+ const detailResults = await concurrentMap(modelIds, 8, async (id) => {
205
+ return [id, await fetchModelDetails(id, signal)] as const;
289
206
  });
290
- const models: Record<string, CachedOllamaModel> = {};
207
+ const models: Record<string, OllamaShowResponse> = {};
208
+ let failed = 0;
291
209
  for (const result of detailResults) {
292
210
  if (result.status === "fulfilled") {
293
211
  const [id, data] = result.value;
294
212
  models[id] = data;
213
+ } else {
214
+ failed++;
295
215
  }
296
216
  }
297
- const succeeded = Object.keys(models).length;
298
- if (succeeded === 0)
299
- throw new Error(`Failed to fetch model details${detailsFailed ? ` (${detailsFailed} failed)` : ""}`);
300
- notify(`Fetched ${succeeded} model details${detailsFailed ? ` (${detailsFailed} failed)` : ""}`, "info");
301
-
302
- onProgress({
303
- stage: "done",
304
- current: Object.keys(models).length,
305
- total: Object.keys(models).length,
306
- message: "Done",
307
- });
308
- return models;
217
+ if (Object.keys(models).length === 0) {
218
+ throw new Error(`Failed to fetch model details${failed ? ` (${failed} failed)` : ""}`);
219
+ }
220
+ return { models, failed };
309
221
  }
310
222
 
311
- export async function fetchModels(
312
- ctx: Pick<ExtensionCommandContext, "ui">,
313
- onProgress?: (progress: RefreshProgress) => void,
314
- ): Promise<Record<string, CachedOllamaModel> | null> {
223
+ // --- refreshModels callback ---
224
+
225
+ /**
226
+ * The `refreshModels` callback pi invokes for the "ollama-cloud" provider.
227
+ * Pi calls it twice per refresh: a restore phase (`allowNetwork: false`) before
228
+ * auth resolution, then a network phase (`allowNetwork: true`) only when a
229
+ * credential resolves. The composer swaps the return value into the model list
230
+ * on every invocation, so this must never return `[]`.
231
+ *
232
+ * The model fetch itself is keyless (public `/v1/models` and `/api/show`
233
+ * endpoints; `Authorization` is only added when `OLLAMA_API_KEY` is set). But
234
+ * pi only invokes this network phase when a credential resolves, so a
235
+ * credentialless user stays on `GENERATED_MODELS` until they configure a key.
236
+ * That is a non-issue in practice because a credentialless user cannot run
237
+ * models anyway.
238
+ */
239
+ export async function refreshOllamaCatalog(context: RefreshModelsContext): Promise<ProviderModelConfig[]> {
240
+ // The fallback list: the persisted snapshot (copied) when non-empty, else the
241
+ // baked-in list. Guards against a stored empty catalog (e.g. a prior bad
242
+ // refresh) propagating [] across sessions. A mutable copy is returned because
243
+ // the stored list is `readonly` and the return type is a mutable array.
244
+ const fallback = context.stored?.models.length ? [...context.stored.models] : GENERATED_MODELS;
245
+
246
+ // Restore phase. Rehydrate from the persisted snapshot so removals stick
247
+ // across sessions; fall back to the baked-in list on first launch. Also the
248
+ // early-out for an already-aborted signal.
249
+ if (!context.allowNetwork || context.signal.aborted) {
250
+ return fallback;
251
+ }
252
+
253
+ // Cooldown: skip the network fetch when the stored catalog was checked within
254
+ // the freshness window and the refresh isn't forced (mirrors pi-mono's
255
+ // remote-catalog-provider). A forced refresh (pi update --models) always fetches.
256
+ if (
257
+ !context.force &&
258
+ context.stored?.checkedAt !== undefined &&
259
+ Date.now() - context.stored.checkedAt < REFRESH_COOLDOWN_MS
260
+ ) {
261
+ return fallback;
262
+ }
263
+
264
+ // Network phase. The /v1/models and /api/show endpoints are publicly
265
+ // accessible and do not require authentication, so context.credential is
266
+ // intentionally not threaded into fetchModelIds/fetchModelDetails. Only
267
+ // the web tools (search, fetch) require an API key.
268
+ //
269
+ // Pi's model-selector aborts a catalog refresh after 15s
270
+ // (packages/coding-agent/src/modes/interactive/components/model-selector.ts
271
+ // in pi-mono), so a cold refresh must stay under that budget or the in-memory
272
+ // list won't update on the first picker-open. With ~18 models and 8 workers
273
+ // this is ~1s today; revisit if the catalog grows or the API slows.
274
+ let modelIds: string[];
275
+ let raw: Record<string, OllamaShowResponse>;
276
+ let failed = 0;
315
277
  try {
316
- return await refreshOllamaCloudModels({
317
- notify: (message, level) => ctx.ui.notify(message, level),
318
- onProgress,
319
- });
278
+ modelIds = await fetchModelIds(context.signal);
279
+ if (context.signal.aborted) {
280
+ return fallback;
281
+ }
282
+ const result = await refreshOllamaCloudModels(modelIds, context.signal);
283
+ raw = result.models;
284
+ failed = result.failed;
320
285
  } catch (error) {
321
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
322
- return null;
286
+ // Abort mid-flight returns the current baseline; any other error propagates
287
+ // and pi keeps the last good catalog (no publish was reached).
288
+ if (context.signal.aborted) {
289
+ return fallback;
290
+ }
291
+ throw error;
292
+ }
293
+ if (context.signal.aborted) {
294
+ return fallback;
295
+ }
296
+
297
+ const models = assembleModels(raw);
298
+ // Guard: an empty assembled list (e.g. the live API returned no tools-capable
299
+ // models) must not be persisted or swapped in, or it would kill the provider
300
+ // for the cooldown window. Keep the last good catalog instead.
301
+ if (models.length === 0) {
302
+ return fallback;
323
303
  }
304
+
305
+ // The store is typed to pi-ai's internal Model shape, so rehydrate the live
306
+ // list with the provider identity fields. A Model is structurally assignable
307
+ // to ProviderModelConfig, so the same list is returned to pi (the composer
308
+ // overrides provider/api/baseUrl on the in-memory swap regardless).
309
+ const persisted = models.map((model) => ({
310
+ ...model,
311
+ provider: "ollama-cloud",
312
+ api: "openai-completions",
313
+ baseUrl: `${OLLAMA_BASE}/v1`,
314
+ }));
315
+
316
+ // Best-effort persistence into pi's FileModelsStore. The in-memory list swap
317
+ // happens automatically from the return value, so a failed store write must
318
+ // not prevent returning the fresh catalog.
319
+ if (failed === 0) {
320
+ // Fully successful: persist the fresh catalog.
321
+ try {
322
+ const published = await context.publish({ persist: { models: persisted, checkedAt: Date.now() } });
323
+ if (!published) {
324
+ console.warn("[pi-ollama-cloud] Catalog persist rejected (generation check failed or refresh superseded).");
325
+ }
326
+ } catch {
327
+ // Persistence failure is non-fatal.
328
+ }
329
+ } else {
330
+ // Partial failure: keep the last-good catalog (if any) but advance checkedAt
331
+ // so the cooldown applies and a flaky catalog isn't re-fetched on every
332
+ // /model open, then surface the incomplete refresh. Mirrors pi-mono's
333
+ // remote-catalog-provider, which persists then throws on a transient failure;
334
+ // pi keeps the last-good catalog and reports the error.
335
+ if (context.stored?.models.length) {
336
+ try {
337
+ const published = await context.publish({ persist: { ...context.stored, checkedAt: Date.now() } });
338
+ if (!published) {
339
+ console.warn(
340
+ "[pi-ollama-cloud] Partial-failure persist rejected (generation check failed or refresh superseded).",
341
+ );
342
+ }
343
+ } catch {
344
+ // Persistence failure is non-fatal.
345
+ }
346
+ }
347
+ throw new Error(`Ollama Cloud catalog refresh incomplete: ${failed} model(s) failed`);
348
+ }
349
+
350
+ return persisted;
324
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ollama-cloud",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -20,11 +20,12 @@
20
20
  ],
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "https://github.com/fgrehm/pi-ollama-cloud"
23
+ "url": "git+https://github.com/fgrehm/pi-ollama-cloud.git"
24
24
  },
25
25
  "scripts": {
26
- "check": "biome check --write .",
26
+ "check": "biome check --write . && tsgo --noEmit",
27
27
  "lint": "biome check .",
28
+ "typecheck": "tsgo --noEmit",
28
29
  "format": "biome format --write .",
29
30
  "test": "vitest run",
30
31
  "smoke:web-tools": "tsx scripts/smoke-web-tools.ts",
@@ -36,12 +37,15 @@
36
37
  ]
37
38
  },
38
39
  "peerDependencies": {
40
+ "@earendil-works/pi-ai": "*",
39
41
  "@earendil-works/pi-coding-agent": "*",
40
42
  "@earendil-works/pi-tui": "*",
41
43
  "@sinclair/typebox": "*"
42
44
  },
43
45
  "devDependencies": {
44
46
  "@biomejs/biome": "2",
47
+ "@types/node": "^26.1.2",
48
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
45
49
  "tsx": "^4.19.0",
46
50
  "vitest": "^4.1.6"
47
51
  }
@@ -1,6 +1,6 @@
1
1
  // Auto-generated by scripts/generate-pricing.ts
2
2
  // Do not edit manually.
3
- // Generated: 2026-07-17T19:55:25.388Z
3
+ // Generated: 2026-08-08T04:19:45.594Z
4
4
  // Model count: 18
5
5
 
6
6
  export interface ModelPrice {
@@ -11,21 +11,21 @@ export interface ModelPrice {
11
11
  }
12
12
 
13
13
  export const MODEL_PRICING: Record<string, ModelPrice> = {
14
- "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
14
+ "deepseek-v4-flash:0731": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
15
+ "deepseek-v4-flash:preview": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
15
16
  "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
+ "gemma4:31b": { input: 0.1, output: 0.34, cacheRead: 0.1, cacheWrite: 0 },
17
18
  "glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
18
19
  "glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
19
20
  "gpt-oss:120b": { input: 0.037, output: 0.17, cacheRead: 0, cacheWrite: 0 },
20
21
  "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
22
  "kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
23
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 },
24
+ "kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
25
25
  "minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
26
26
  "minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
27
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 },
28
+ "nemotron-3-nano:30b": { input: 0.05, output: 0.2, cacheRead: 0.03, cacheWrite: 0 },
29
29
  "nemotron-3-super": { input: 0.2, output: 0.8, cacheRead: 0, cacheWrite: 0 },
30
30
  "nemotron-3-ultra": { input: 0.5, output: 2.5, cacheRead: 0.15, cacheWrite: 0 },
31
31
  "qwen3.5:397b": { input: 0.6, output: 3.6, cacheRead: 0, cacheWrite: 0 },
package/web-tools.ts CHANGED
@@ -17,10 +17,12 @@
17
17
  */
18
18
 
19
19
  import {
20
+ type AgentToolResult,
20
21
  type ExtensionAPI,
21
22
  type ExtensionContext,
22
23
  keyHint,
23
24
  type Theme,
25
+ type ToolRenderResultOptions,
24
26
  truncateToVisualLines,
25
27
  } from "@earendil-works/pi-coding-agent";
26
28
  import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
@@ -41,7 +43,7 @@ interface SearchResponse {
41
43
  interface FetchResponse {
42
44
  title: string;
43
45
  content: string;
44
- links: string[];
46
+ links: string[] | null;
45
47
  }
46
48
 
47
49
  // --- Helpers ---
@@ -63,68 +65,45 @@ export async function getCloudApiKey(ctx: Pick<ExtensionContext, "modelRegistry"
63
65
  return (await ctx.modelRegistry.getApiKeyForProvider("ollama-cloud")) ?? process.env.OLLAMA_API_KEY;
64
66
  }
65
67
 
66
- function noApiKeyError() {
67
- return {
68
- content: [
69
- {
70
- type: "text" as const,
71
- text: "Error: No Ollama Cloud API key configured. Set OLLAMA_API_KEY or add to auth.json.",
72
- },
73
- ],
74
- isError: true,
75
- };
68
+ /** Throw a no-API-key error. */
69
+ function noApiKeyError(): never {
70
+ throw new Error("No Ollama Cloud API key configured. Set OLLAMA_API_KEY or add to auth.json.");
76
71
  }
77
72
 
78
- /** Search error response for a non-ok result, mapping distinct status codes. */
79
- function searchError(status: number, error?: string) {
73
+ /** Throw a search error for a non-ok result, mapping distinct status codes. */
74
+ function searchError(status: number, error?: string): never {
80
75
  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
- };
76
+ throw new Error(
77
+ "Ollama Cloud search failed: authentication error. " + "Check your API key in OLLAMA_API_KEY or auth.json.",
78
+ );
91
79
  }
92
80
  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
- };
81
+ throw new Error("Ollama Cloud search failed: rate limited. Try again shortly.");
97
82
  }
98
- return {
99
- content: [{ type: "text" as const, text: `Search API error (status ${status}): ${error || "unknown error"}` }],
100
- isError: true,
101
- };
83
+ if (status >= 500) {
84
+ throw new Error(`Ollama Cloud search failed: server error (status ${status}). Try again shortly.`);
85
+ }
86
+ throw new Error(
87
+ `Ollama Cloud search failed: unexpected response (status ${status}${error ? `: ${error}` : ""}). Try again shortly.`,
88
+ );
102
89
  }
103
90
 
104
- /** Fetch error response for a non-ok result, mapping distinct status codes. */
105
- function fetchError(status: number, error?: string) {
91
+ /** Throw a fetch error for a non-ok result, mapping distinct status codes. */
92
+ function fetchError(status: number, error?: string): never {
106
93
  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
- };
94
+ throw new Error(
95
+ "Ollama Cloud fetch failed: authentication error. " + "Check your API key in OLLAMA_API_KEY or auth.json.",
96
+ );
117
97
  }
118
98
  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
- };
99
+ throw new Error("Ollama Cloud fetch failed: rate limited. Try again shortly.");
123
100
  }
124
- return {
125
- content: [{ type: "text" as const, text: `Fetch API error (status ${status}): ${error || "unknown error"}` }],
126
- isError: true,
127
- };
101
+ if (status >= 500) {
102
+ throw new Error(`Ollama Cloud fetch failed: server error (status ${status}). Try again shortly.`);
103
+ }
104
+ throw new Error(
105
+ `Ollama Cloud fetch failed: unexpected response (status ${status}${error ? `: ${error}` : ""}). Try again shortly.`,
106
+ );
128
107
  }
129
108
 
130
109
  const PREVIEW_LINES = 8;
@@ -135,17 +114,19 @@ const PREVIEW_LINES = 8;
135
114
  */
136
115
  function createRenderResult() {
137
116
  return (
138
- result: { content: Array<{ type: string; text: string }>; isError?: boolean },
139
- options: { expanded: boolean; isPartial: boolean },
117
+ result: AgentToolResult<unknown>,
118
+ options: ToolRenderResultOptions,
140
119
  theme: Theme,
141
120
  context: {
142
121
  invalidate: () => void;
143
122
  lastComponent: Component | undefined;
123
+ isError: boolean;
144
124
  state: { cachedWidth?: number; cachedLines?: string[]; cachedSkipped?: number };
145
125
  },
146
126
  ) => {
147
127
  const state = context.state;
148
128
  const output = result.content
129
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
149
130
  .map((c) => c.text)
150
131
  .join("")
151
132
  .trim();
@@ -154,9 +135,9 @@ function createRenderResult() {
154
135
  .map((line: string) => theme.fg("toolOutput", line))
155
136
  .join("\n");
156
137
 
157
- if (options.expanded || result.isError) {
138
+ if (options.expanded || context.isError) {
158
139
  const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
159
- text.setText(result.isError ? styledOutput : `\n${styledOutput}`);
140
+ text.setText(context.isError ? styledOutput : `\n${styledOutput}`);
160
141
  return text;
161
142
  }
162
143
 
@@ -185,16 +166,25 @@ function createRenderResult() {
185
166
  };
186
167
  }
187
168
 
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);
169
+ /** Validate a parsed web_search response: must have a results array of well-formed entries. */
170
+ export function isSearchResponse(data: unknown): data is SearchResponse {
171
+ if (data == null || typeof data !== "object") return false;
172
+ const results = (data as SearchResponse).results;
173
+ return (
174
+ Array.isArray(results) &&
175
+ results.every((r) => typeof r.title === "string" && typeof r.url === "string" && typeof r.content === "string")
176
+ );
191
177
  }
192
178
 
193
- /** Validate a parsed web_fetch response: must have string title/content and a links array. */
194
- function isFetchResponse(data: unknown): data is FetchResponse {
179
+ /** Validate a parsed web_fetch response: must have string title/content and a links array of strings (or null). */
180
+ export function isFetchResponse(data: unknown): data is FetchResponse {
195
181
  if (data == null || typeof data !== "object") return false;
196
182
  const d = data as FetchResponse;
197
- return typeof d.title === "string" && typeof d.content === "string" && Array.isArray(d.links);
183
+ return (
184
+ typeof d.title === "string" &&
185
+ typeof d.content === "string" &&
186
+ (d.links === null || (Array.isArray(d.links) && d.links.every((l) => typeof l === "string")))
187
+ );
198
188
  }
199
189
 
200
190
  // --- Registrations ---
@@ -220,7 +210,9 @@ export function registerWebSearchTool(pi: ExtensionAPI) {
220
210
  }),
221
211
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
222
212
  const apiKey = await getCloudApiKey(ctx);
223
- if (!apiKey) return noApiKeyError();
213
+ if (!apiKey) {
214
+ noApiKeyError();
215
+ }
224
216
 
225
217
  const res = await fetchJsonWithTimeout<SearchResponse>(
226
218
  `${OLLAMA_BASE}/api/web_search`,
@@ -239,12 +231,11 @@ export function registerWebSearchTool(pi: ExtensionAPI) {
239
231
  signal,
240
232
  );
241
233
 
242
- if (!res.ok) return searchError(res.status, res.error);
234
+ if (!res.ok) {
235
+ searchError(res.status, res.error);
236
+ }
243
237
  if (!isSearchResponse(res.data)) {
244
- return {
245
- content: [{ type: "text", text: "Web search failed: unexpected response shape from the API." }],
246
- isError: true,
247
- };
238
+ throw new Error("Web search failed: unexpected response shape from the API.");
248
239
  }
249
240
 
250
241
  const formatted = res.data.results
@@ -277,7 +268,9 @@ export function registerWebFetchTool(pi: ExtensionAPI) {
277
268
  }),
278
269
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
279
270
  const apiKey = await getCloudApiKey(ctx);
280
- if (!apiKey) return noApiKeyError();
271
+ if (!apiKey) {
272
+ noApiKeyError();
273
+ }
281
274
 
282
275
  const res = await fetchJsonWithTimeout<FetchResponse>(
283
276
  `${OLLAMA_BASE}/api/web_fetch`,
@@ -293,12 +286,11 @@ export function registerWebFetchTool(pi: ExtensionAPI) {
293
286
  signal,
294
287
  );
295
288
 
296
- if (!res.ok) return fetchError(res.status, res.error);
289
+ if (!res.ok) {
290
+ fetchError(res.status, res.error);
291
+ }
297
292
  if (!isFetchResponse(res.data)) {
298
- return {
299
- content: [{ type: "text", text: "Web fetch failed: unexpected response shape from the API." }],
300
- isError: true,
301
- };
293
+ throw new Error("Web fetch failed: unexpected response shape from the API.");
302
294
  }
303
295
 
304
296
  const data = res.data;