pi-ollama-cloud 0.12.2 → 0.12.3

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,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.12.3] - 2026-10-06
6
+
7
+ - Keep the usage indicator accurate when turning it off or switching providers, and show clearer errors for connection problems and invalid responses.
8
+ - Hide unsupported reasoning controls on affected gpt-oss models and limit disk space used by web-tool caching.
9
+ - Keep Ollama Cloud models compatible with Pi 1.0.
10
+ - Allow model-limit updates to use the API key configured in Pi, with `OLLAMA_API_KEY` as a fallback.
11
+ - Run the test suite successfully before publishing a release.
12
+
5
13
  ## [0.12.2] - 2026-09-27
6
14
 
7
15
  - Refresh the model catalog for the September 25, 2026 retirements (`deepseek-v4-flash:0731`, `glm-5.1`, `qwen3.5:397b`): dropped from the generated catalog and pricing, 20 -> 17 models. Recommended replacements per Ollama: `deepseek-v4.1-flash`, `glm-5.3`, and `glm-5.3-flash` / `deepseek-v4.1-flash`.
package/README.md CHANGED
@@ -134,11 +134,11 @@ Model metadata is derived from the `/api/show` response:
134
134
  | `reasoning` | `capabilities` includes `"thinking"` |
135
135
  | `thinkingLevelMap` | [`thinking-levels.ts`](thinking-levels.ts) + [`reasoning.generated.ts`](reasoning.generated.ts) (models.dev reasoning options), with an `off` override table for models that ignore `none` |
136
136
  | `input` | `["text", "image"]` if `capabilities` includes `"vision"`, else `["text"]` |
137
- | `contextWindow` | `model_info.*.context_length` (falls back to 128000) |
138
- | `maxTokens` | Probed per-model limits from [`limits.generated.ts`](limits.generated.ts), generated by `scripts/generate-limits.ts` (requires `OLLAMA_API_KEY`). Models without a probed limit fall back to 32768. |
137
+ | `contextWindow` | Positive integer `model_info.*.context_length` (falls back to 128000 when missing or invalid) |
138
+ | `maxTokens` | Probed per-model limits from [`limits.generated.ts`](limits.generated.ts), generated by `scripts/generate-limits.ts` (reads the Ollama Cloud API key from Pi's `auth.json` or `OLLAMA_API_KEY`). Models without a probed limit fall back to 32768. |
139
139
  | `cost` | Official per-1M-token prices from the [ollama.com/pricing](https://ollama.com/pricing) model table, generated by `scripts/generate-pricing.ts` into `pricing.generated.ts`. Ollama Cloud is subscription-billed, so these are equivalent pay-as-you-go rates, not actual charges. Catalog IDs with no matching pricing row 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. |
140
140
 
141
- The per-model max output token table (`limits.generated.ts`) is probed against the live API by `scripts/generate-limits.ts`, since `/api/show` does not expose the limit. It needs an API key: `OLLAMA_API_KEY=<key> npm run generate-limits`. Limits ship with the package, so regenerated values take effect on the next release.
141
+ The per-model max output token table (`limits.generated.ts`) is probed against the live API by `scripts/generate-limits.ts`, since `/api/show` does not expose the limit. The script uses the `ollama-cloud` API key in Pi's `auth.json`, falling back to `OLLAMA_API_KEY`; run `npm run generate-limits` (optionally followed by a model ID to probe only that model). Limits ship with the package, so regenerated values take effect on the next release.
142
142
 
143
143
  The API itself returns no cost data: completion responses report only token counts (`prompt_tokens`/`completion_tokens`/`total_tokens`, including the final usage chunk when streaming), and `/api/show` exposes no pricing fields. The prices above come from the static `/pricing` page table and are only as fresh as the last regeneration.
144
144
 
@@ -167,7 +167,7 @@ Both tools cache results on disk (under the pi agent home, `~/.pi/agent/cache/pi
167
167
 
168
168
  - Successful searches and pages: cached for 24h
169
169
  - Failed page fetches: negative-cached for 15 min, so retrying a dead page does not re-call the API. Auth (401/403), rate-limit (429), transport (timeouts, aborts, network errors), and server (5xx) failures are not cached — fixing the key, waiting out the limit, or a transient blip lets a retry through immediately
170
- - Expired entries are pruned on write and the cache is capped at 500 entries per kind (searches/pages), evicting the oldest first. This bounds entry count, not file size: full page and search content can still make `cache.json` large, and loading it parses the whole file
170
+ - Expired entries are pruned on write; the cache is capped at 500 entries per kind (searches/pages), 256 KiB per serialized entry (including its key), and 8 MiB for the complete serialized file. Oldest entries are evicted first to meet the limits. Oversized content remains available for the current call but is not persisted, so a later paging/expand request may fetch it again
171
171
  - The cache file is written with `0600` permissions. It stores full page content and raw URLs, which can embed credentials in query strings — avoid fetching URLs that carry secrets in the query string, or set a custom `PI_OLLAMA_SEARCH_CACHE_PATH`
172
172
  - Concurrent pi processes share the cache file on a last-writer-wins basis (no cross-process locking): one process's save can drop another's fresh entries, at the cost of a redundant API call
173
173
  - `refresh=true` on either tool bypasses the cache (including a cached failure) and re-calls the API; the fresh result replaces the cache entry
@@ -268,7 +268,7 @@ npm run check # lint + format + type-check (auto-fix)
268
268
  npm run lint # lint only (no fixes)
269
269
  npm run typecheck # type-check only (tsgo --noEmit)
270
270
  npm run format # format only
271
- OLLAMA_API_KEY=<key> npm run generate-limits # probe max output tokens (writes limits.generated.ts)
271
+ npm run generate-limits # probe max output tokens using auth.json or OLLAMA_API_KEY (writes limits.generated.ts)
272
272
  ```
273
273
 
274
274
  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.
@@ -325,20 +325,24 @@ The `-e`/`--extension` flag loads the extension from the local checkout without
325
325
 
326
326
  ## Releasing
327
327
 
328
- Publishing a new version to npm is a two-command process:
328
+ Publishing a new version to npm is a three-step process:
329
329
 
330
330
  ```bash
331
- # 1. Bump version and create a git tag in one step
331
+ # 1. Commit and push main, then wait for the Test workflow to go green
332
+ # (gh run watch, or the Actions tab). CI must be green before tagging.
333
+ # 2. Bump version and create a git tag in one step
332
334
  npm version minor # or patch, or major
333
- # 2. Push the tag to trigger the GitHub Actions publish workflow
335
+ # 3. Push the tag to trigger the GitHub Actions publish workflow
334
336
  git push --tags
335
337
  ```
336
338
 
339
+ The publish workflow gates on CI: its `await-tests` job polls the check runs on the tagged commit and only proceeds if the Test workflow completed successfully on that exact commit (it fails closed after a timeout if the commit was never tested, e.g. a tag on a commit that is not on `main`).
340
+
337
341
  Because the model catalog refreshes automatically at runtime, a release is **not** needed to ship new models. Publish only when:
338
342
 
339
343
  - 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`).
340
344
  - Pricing changes: Ollama updates the model pricing table, or a new model needs a pricing row (regenerate `pricing.generated.ts`).
341
- - Max output token limits changed: run `OLLAMA_API_KEY=<key> npm run generate-limits` locally and commit.
345
+ - Max output token limits changed: run `npm run generate-limits` locally (uses Pi's `auth.json` or `OLLAMA_API_KEY`) and commit.
342
346
 
343
347
  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.
344
348
 
package/cache.ts CHANGED
@@ -10,6 +10,10 @@ export const CACHE_TTL_MS = envInt("PI_OLLAMA_SEARCH_TTL_HOURS", 24) * 60 * 60 *
10
10
  export const FAIL_TTL_MS = envInt("PI_OLLAMA_SEARCH_FAIL_TTL_MINUTES", 15) * 60 * 1000;
11
11
  /** Max entries per map (searches/pages); oldest-ts entries are evicted beyond this. */
12
12
  export const MAX_ENTRIES = envInt("PI_OLLAMA_SEARCH_MAX_ENTRIES", 500);
13
+ /** Maximum serialized UTF-8 size for one persisted entry, including its key. */
14
+ export const MAX_PERSISTED_ENTRY_BYTES = 256 * 1024;
15
+ /** Maximum serialized UTF-8 size of the complete persisted cache file. */
16
+ export const MAX_CACHE_BYTES = 8 * 1024 * 1024;
13
17
 
14
18
  export interface SearchResult {
15
19
  title: string;
@@ -43,6 +47,8 @@ interface CacheOptions {
43
47
  ttlMs: number;
44
48
  failTtlMs: number;
45
49
  maxEntries: number;
50
+ maxPersistedEntryBytes: number;
51
+ maxCacheBytes: number;
46
52
  }
47
53
 
48
54
  function isRecord(v: unknown): v is Record<string, unknown> {
@@ -69,6 +75,14 @@ function isSearchEntry(v: unknown): v is SearchCacheEntry {
69
75
  );
70
76
  }
71
77
 
78
+ function normalizeSearchEntry(entry: SearchCacheEntry): SearchCacheEntry {
79
+ return {
80
+ ts: entry.ts,
81
+ q: entry.q,
82
+ results: entry.results.map(({ title, url, content }) => ({ title, url, content })),
83
+ };
84
+ }
85
+
72
86
  function isPageEntry(v: unknown): v is PageCacheEntry {
73
87
  if (!isRecord(v) || typeof v.ts !== "number") return false;
74
88
  const fieldsValid =
@@ -89,6 +103,17 @@ function isPageEntry(v: unknown): v is PageCacheEntry {
89
103
  );
90
104
  }
91
105
 
106
+ function normalizePageEntry(entry: PageCacheEntry): PageCacheEntry {
107
+ const normalized: PageCacheEntry = { ts: entry.ts };
108
+ if (entry.status !== undefined) normalized.status = entry.status;
109
+ if (entry.title !== undefined) normalized.title = entry.title;
110
+ if (entry.content !== undefined) normalized.content = entry.content;
111
+ if (entry.links !== undefined) normalized.links = entry.links;
112
+ if (entry.error !== undefined) normalized.error = entry.error;
113
+ if (entry.errorType !== undefined) normalized.errorType = entry.errorType;
114
+ return normalized;
115
+ }
116
+
92
117
  export interface CacheStore {
93
118
  loadCache(): CacheData;
94
119
  saveCache(): void;
@@ -100,6 +125,8 @@ export function createCache(options: Partial<CacheOptions> = {}): CacheStore {
100
125
  const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
101
126
  const failTtlMs = options.failTtlMs ?? FAIL_TTL_MS;
102
127
  const maxEntries = options.maxEntries ?? MAX_ENTRIES;
128
+ const maxPersistedEntryBytes = options.maxPersistedEntryBytes ?? MAX_PERSISTED_ENTRY_BYTES;
129
+ const maxCacheBytes = options.maxCacheBytes ?? MAX_CACHE_BYTES;
103
130
  let cacheData: CacheData | null = null;
104
131
 
105
132
  function loadCache(): CacheData {
@@ -111,10 +138,10 @@ export function createCache(options: Partial<CacheOptions> = {}): CacheStore {
111
138
  // file degrades to "those entries are gone" instead of crashing calls.
112
139
  cacheData = { searches: {}, pages: {} };
113
140
  for (const [key, entry] of Object.entries(raw.searches)) {
114
- if (isSafeKey(key) && isSearchEntry(entry)) cacheData.searches[key] = entry;
141
+ if (isSafeKey(key) && isSearchEntry(entry)) cacheData.searches[key] = normalizeSearchEntry(entry);
115
142
  }
116
143
  for (const [key, entry] of Object.entries(raw.pages)) {
117
- if (isSafeKey(key) && isPageEntry(entry)) cacheData.pages[key] = entry;
144
+ if (isSafeKey(key) && isPageEntry(entry)) cacheData.pages[key] = normalizePageEntry(entry);
118
145
  }
119
146
  return cacheData;
120
147
  }
@@ -139,14 +166,66 @@ export function createCache(options: Partial<CacheOptions> = {}): CacheStore {
139
166
  for (const key of overflow) delete map[key];
140
167
  }
141
168
 
169
+ function serializedEntrySize(
170
+ key: string,
171
+ entry: SearchCacheEntry | PageCacheEntry,
172
+ ): {
173
+ entryBytes: number;
174
+ propertyBytes: number;
175
+ } {
176
+ // The one-property object includes the JSON-escaped cache key and braces.
177
+ // Removing only those two braces yields the exact property size in a map.
178
+ const onePropertyObject = JSON.stringify({ [key]: entry });
179
+ const entryBytes = Buffer.byteLength(onePropertyObject);
180
+ return { entryBytes, propertyBytes: entryBytes - 2 };
181
+ }
182
+
142
183
  function saveCache(): void {
143
184
  const data = loadCache();
144
185
  for (const [key, entry] of Object.entries(data.searches)) if (!isFresh(entry)) delete data.searches[key];
145
186
  for (const [key, entry] of Object.entries(data.pages)) if (!isFresh(entry)) delete data.pages[key];
146
- // TTL bounds entry age; the cap bounds entry count so an aggressive session
147
- // cannot grow the file without limit.
187
+ // Prune stale and over-count entries before applying serialized-byte limits.
148
188
  evictOldest(data.searches);
149
189
  evictOldest(data.pages);
190
+
191
+ const persistedData: CacheData = { searches: { ...data.searches }, pages: { ...data.pages } };
192
+ const searches = Object.entries(persistedData.searches).map(([key, entry]) => ({
193
+ ...serializedEntrySize(key, entry),
194
+ key,
195
+ entry,
196
+ map: persistedData.searches,
197
+ }));
198
+ const pages = Object.entries(persistedData.pages).map(([key, entry]) => ({
199
+ ...serializedEntrySize(key, entry),
200
+ key,
201
+ entry,
202
+ map: persistedData.pages,
203
+ }));
204
+ const entries = [...searches, ...pages];
205
+ for (const item of entries) {
206
+ if (item.entryBytes > maxPersistedEntryBytes) delete item.map[item.key];
207
+ }
208
+
209
+ function serializedCacheSize(): number {
210
+ let bytes = Buffer.byteLength('{"searches":{},"pages":{}}');
211
+ for (const mapEntries of [searches, pages]) {
212
+ const persistedEntries = mapEntries.filter((item) => item.map[item.key] !== undefined);
213
+ if (persistedEntries.length === 0) continue;
214
+ // The empty-map braces are already included in the base size.
215
+ bytes += persistedEntries.reduce((total, item) => total + item.propertyBytes, 0);
216
+ bytes += persistedEntries.length - 1;
217
+ }
218
+ return bytes;
219
+ }
220
+
221
+ let persistedBytes = serializedCacheSize();
222
+ const oldestFirst = entries.sort((a, b) => a.entry.ts - b.entry.ts);
223
+ for (const item of oldestFirst) {
224
+ if (persistedBytes <= maxCacheBytes) break;
225
+ if (item.map[item.key] === undefined) continue;
226
+ delete item.map[item.key];
227
+ persistedBytes = serializedCacheSize();
228
+ }
150
229
  try {
151
230
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
152
231
  // Use a unique temporary path so concurrent processes cannot overwrite
@@ -155,7 +234,7 @@ export function createCache(options: Partial<CacheOptions> = {}): CacheStore {
155
234
  try {
156
235
  // 0o600: the cache stores full page content and URLs, which can embed
157
236
  // credentials in query strings; it should not be world-readable.
158
- writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
237
+ writeFileSync(tmp, JSON.stringify(persistedData), { mode: 0o600 });
159
238
  renameSync(tmp, path);
160
239
  // Fix perms of a file written by a pre-0600 version.
161
240
  chmodSync(path, 0o600);
package/index.ts CHANGED
@@ -32,10 +32,36 @@ import { fetchUsage, formatUsage, formatUsageStatusColored } from "./usage.ts";
32
32
  import { getCloudApiKey } from "./utils.ts";
33
33
  import { registerWebFetchTool, registerWebSearchTool } from "./web-tools.ts";
34
34
 
35
- /**
36
- * Resolve the new enabled state for /ollama-usage-status from its argument.
37
- * Exported for unit testing.
38
- */
35
+ /** Guard usage-status refreshes against stale generations and duplicate triggers. */
36
+ export function createUsageStatusRefreshGuard() {
37
+ let generation = 0;
38
+ let inFlightGeneration: number | undefined;
39
+
40
+ return {
41
+ invalidate() {
42
+ generation += 1;
43
+ },
44
+ async run(
45
+ isCurrent: () => boolean,
46
+ refresh: (stillCurrent: () => boolean) => Promise<string | undefined>,
47
+ setStatus: (text: string | undefined) => void,
48
+ ): Promise<void> {
49
+ if (inFlightGeneration === generation || !isCurrent()) return;
50
+ const requestGeneration = generation;
51
+ inFlightGeneration = requestGeneration;
52
+ try {
53
+ const text = await refresh(() => generation === requestGeneration && isCurrent());
54
+ if (generation === requestGeneration && isCurrent()) setStatus(text);
55
+ } catch {
56
+ if (generation === requestGeneration && isCurrent()) setStatus(undefined);
57
+ } finally {
58
+ if (inFlightGeneration === requestGeneration) inFlightGeneration = undefined;
59
+ }
60
+ },
61
+ };
62
+ }
63
+
64
+ /** Resolve the new enabled state for /ollama-usage-status from its argument. */
39
65
  export function resolveUsageStatusToggle(arg: string, current: boolean): { enabled: boolean; error?: string } {
40
66
  const a = arg.trim().toLowerCase();
41
67
  if (a === "on" || a === "enable") return { enabled: true };
@@ -161,26 +187,24 @@ export default async function (pi: ExtensionAPI) {
161
187
  const USAGE_REFRESH_MS = 5 * 60_000;
162
188
  let usageTimer: ReturnType<typeof setInterval> | null = null;
163
189
  let usageActive = false;
190
+ const usageRefreshGuard = createUsageStatusRefreshGuard();
164
191
  // Timestamp (ms) of the most recent refresh attempt; gates the agent_end
165
192
  // refresh so it fires at most once per cooldown. Set when a fetch starts, so
166
193
  // a failing endpoint is also throttled, not just a successful one.
167
194
  let lastRefreshAt = 0;
168
195
 
169
196
  async function refreshUsageStatus(ctx: ExtensionContext) {
170
- try {
171
- const apiKey = await getCloudApiKey(ctx);
172
- if (!apiKey) {
173
- ctx.ui.setStatus(USAGE_STATUS_KEY, undefined);
174
- return;
175
- }
176
- lastRefreshAt = Date.now();
177
- const data = await fetchUsage(apiKey);
178
- ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatusColored(ctx.ui.theme, data));
179
- } catch {
180
- // Transient errors (undocumented endpoint, network) should not spam the
181
- // footer; clear the status and retry on the next refresh.
182
- ctx.ui.setStatus(USAGE_STATUS_KEY, undefined);
183
- }
197
+ await usageRefreshGuard.run(
198
+ () => usageActive && isOllamaCloud(ctx),
199
+ async (stillCurrent) => {
200
+ const apiKey = await getCloudApiKey(ctx);
201
+ if (!stillCurrent() || !apiKey) return undefined;
202
+ lastRefreshAt = Date.now();
203
+ const data = await fetchUsage(apiKey);
204
+ return formatUsageStatusColored(ctx.ui.theme, data);
205
+ },
206
+ (text) => ctx.ui.setStatus(USAGE_STATUS_KEY, text),
207
+ );
184
208
  }
185
209
 
186
210
  function startUsageStatus(ctx: ExtensionContext) {
@@ -193,6 +217,7 @@ export default async function (pi: ExtensionAPI) {
193
217
  }
194
218
 
195
219
  function stopUsageStatus(ctx: ExtensionContext) {
220
+ usageRefreshGuard.invalidate();
196
221
  usageActive = false;
197
222
  if (usageTimer) {
198
223
  clearInterval(usageTimer);
@@ -1,13 +1,11 @@
1
1
  // Auto-generated by scripts/generate-limits.ts
2
2
  // Do not edit manually.
3
- // Entries: 20
3
+ // Entries: 17
4
4
 
5
5
  export const MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
6
- "deepseek-v4-flash:0731": 65536,
7
6
  "deepseek-v4-pro:0813": 65536,
8
7
  "deepseek-v4.1-flash": 393216,
9
8
  "gemma4:31b": 262144,
10
- "glm-5.1": 131072,
11
9
  "glm-5.2": 131072,
12
10
  "glm-5.3": 524288,
13
11
  "glm-5.3-flash": 524288,
@@ -22,5 +20,4 @@ export const MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
22
20
  "nemotron-3-nano:30b": 131072,
23
21
  "nemotron-3-super": 65536,
24
22
  "nemotron-3-ultra": 65536,
25
- "qwen3.5:397b": 65536,
26
23
  };
@@ -3,9 +3,9 @@
3
3
  // Generated: 2026-09-27T00:52:07.773Z
4
4
  // Model count: 17
5
5
 
6
- import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
6
+ import type { ChatModelConfig } from "./models.ts";
7
7
 
8
- export const GENERATED_MODELS: ProviderModelConfig[] = [
8
+ export const GENERATED_MODELS: ChatModelConfig[] = [
9
9
  {
10
10
  id: "deepseek-v4-pro:0813",
11
11
  name: "deepseek-v4-pro:0813",
package/models.ts CHANGED
@@ -6,6 +6,11 @@ import { MODEL_PRICING, type ModelPrice } from "./pricing.generated.ts";
6
6
  import { resolve as resolveThinkingLevelMap } from "./thinking-levels.ts";
7
7
  import { concurrentMap, fetchJsonWithTimeout, getContextLength } from "./utils.ts";
8
8
 
9
+ export type ChatModelConfig =
10
+ Extract<ProviderModelConfig, { type?: "chat" }> extends never
11
+ ? ProviderModelConfig
12
+ : Extract<ProviderModelConfig, { type?: "chat" }>;
13
+
9
14
  // --- Pricing ---
10
15
  // Per-1M-token prices are generated from the ollama.com/pricing model table by
11
16
  // scripts/generate-pricing.ts (see pricing.generated.ts, do not edit by hand).
@@ -66,17 +71,21 @@ export const OLLAMA_BASE = CLOUD_BASE_URL.replace(/\/+$/, "");
66
71
  // --- Raw API types ---
67
72
  /** Response from POST /api/show */
68
73
  interface OllamaShowResponse {
69
- details: {
70
- parent_model: string;
71
- format: string;
72
- family: string;
73
- families: string[] | null;
74
- parameter_size: string;
75
- quantization_level: string;
76
- };
77
- model_info: Record<string, unknown>;
74
+ model_info?: Record<string, unknown> | null;
78
75
  capabilities: string[];
79
- modified_at: string;
76
+ }
77
+
78
+ function isRecord(value: unknown): value is Record<string, unknown> {
79
+ return typeof value === "object" && value !== null && !Array.isArray(value);
80
+ }
81
+
82
+ function isOllamaShowResponse(value: unknown): value is OllamaShowResponse {
83
+ return (
84
+ isRecord(value) &&
85
+ (value.model_info === undefined || value.model_info === null || isRecord(value.model_info)) &&
86
+ Array.isArray(value.capabilities) &&
87
+ value.capabilities.every((capability) => typeof capability === "string")
88
+ );
80
89
  }
81
90
 
82
91
  // --- Assembly: raw API data -> ProviderModelConfig[] ---
@@ -89,7 +98,7 @@ interface OllamaShowResponse {
89
98
  * pi type definition: https://github.com/earendil-works/pi/blob/b94482762321ed0b9f8f245be57c84d786a7105d/packages/ai/src/types.ts#L361-L400
90
99
  * pi compat resolution: https://docs.ollama.com/api/openai-compatibility https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/types.ts#L365-L425
91
100
  */
92
- function buildCompat(): ProviderModelConfig["compat"] {
101
+ function buildCompat(): ChatModelConfig["compat"] {
93
102
  return {
94
103
  // Ollama uses "system" role, not "developer" (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsDeveloperRole).
95
104
  supportsDeveloperRole: false,
@@ -138,15 +147,21 @@ function buildCompat(): ProviderModelConfig["compat"] {
138
147
  };
139
148
  }
140
149
 
141
- export function assembleModels(raw: Record<string, OllamaShowResponse>): ProviderModelConfig[] {
150
+ export function assembleModels(raw: Record<string, unknown>): ChatModelConfig[] {
142
151
  return Object.entries(raw)
143
- .filter(([, data]) => data.capabilities?.includes("tools"))
152
+ .map(([id, data]) => {
153
+ if (!isOllamaShowResponse(data)) {
154
+ throw new Error(`Invalid /api/show response for ${id}: expected string capabilities and model_info object.`);
155
+ }
156
+ return [id, data] as const;
157
+ })
158
+ .filter(([, data]) => data.capabilities.includes("tools"))
144
159
  .map(([id, data]) => ({
145
160
  id,
146
161
  name: id,
147
- reasoning: data.capabilities?.includes("thinking") ?? false,
148
- thinkingLevelMap: resolveThinkingLevelMap(id, data.capabilities ?? []),
149
- input: (data.capabilities?.includes("vision") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
162
+ reasoning: data.capabilities.includes("thinking"),
163
+ thinkingLevelMap: resolveThinkingLevelMap(id, data.capabilities),
164
+ input: (data.capabilities.includes("vision") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
150
165
  cost: resolvePrice(id),
151
166
  contextWindow: getContextLength(data.model_info ?? {}),
152
167
  maxTokens: resolveMaxTokens(id),
@@ -162,21 +177,30 @@ export async function fetchModelIds(signal?: AbortSignal, timeoutMs = FETCH_TIME
162
177
  headers.Authorization = `Bearer ${apiKey}`;
163
178
  }
164
179
 
165
- const res = await fetchJsonWithTimeout<{ data: { id: string }[] }>(
166
- `${OLLAMA_BASE}/v1/models`,
167
- { headers },
168
- timeoutMs,
169
- signal,
170
- );
180
+ const res = await fetchJsonWithTimeout<unknown>(`${OLLAMA_BASE}/v1/models`, { headers }, timeoutMs, signal);
171
181
 
172
182
  if (res.status === 429) {
173
183
  throw new Error("Ollama Cloud model list fetch rate limited. Try again shortly.");
174
184
  }
175
- if (!res.ok || !res.data) {
185
+ if (!res.ok) {
186
+ if (res.status === 0) {
187
+ throw new Error(
188
+ `Ollama Cloud model list fetch failed: transport error (${res.error ?? "unknown"}). Try again shortly.`,
189
+ );
190
+ }
176
191
  throw new Error(`Failed to fetch model list: ${res.status}${res.error ? ` - ${res.error}` : ""}`);
177
192
  }
178
-
179
- return res.data.data.map((m) => m.id);
193
+ if (!isRecord(res.data) || !Array.isArray(res.data.data)) {
194
+ throw new Error("Failed to fetch model list: unexpected response shape (expected a data array).");
195
+ }
196
+ const ids: string[] = [];
197
+ for (const [index, model] of res.data.data.entries()) {
198
+ if (!isRecord(model) || typeof model.id !== "string" || model.id.trim() !== model.id || model.id === "") {
199
+ throw new Error(`Failed to fetch model list: invalid model ID at data[${index}].`);
200
+ }
201
+ ids.push(model.id);
202
+ }
203
+ return ids;
180
204
  }
181
205
 
182
206
  export async function fetchModelDetails(
@@ -190,7 +214,7 @@ export async function fetchModelDetails(
190
214
  headers.Authorization = `Bearer ${apiKey}`;
191
215
  }
192
216
 
193
- const res = await fetchJsonWithTimeout<OllamaShowResponse>(
217
+ const res = await fetchJsonWithTimeout<unknown>(
194
218
  `${OLLAMA_BASE}/api/show`,
195
219
  {
196
220
  method: "POST",
@@ -204,10 +228,19 @@ export async function fetchModelDetails(
204
228
  if (res.status === 429) {
205
229
  throw new Error("Ollama Cloud /api/show rate limited. Try again shortly.");
206
230
  }
207
- if (!res.ok || !res.data) {
231
+ if (!res.ok) {
232
+ if (res.status === 0) {
233
+ throw new Error(
234
+ `Ollama Cloud /api/show for ${id} failed: transport error (${res.error ?? "unknown"}). Try again shortly.`,
235
+ );
236
+ }
208
237
  throw new Error(`Failed to fetch /api/show for ${id}: ${res.status}${res.error ? ` - ${res.error}` : ""}`);
209
238
  }
210
-
239
+ if (!isOllamaShowResponse(res.data)) {
240
+ throw new Error(
241
+ `Failed to fetch /api/show for ${id}: unexpected response shape (expected string capabilities and model_info object).`,
242
+ );
243
+ }
211
244
  return res.data;
212
245
  }
213
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ollama-cloud",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -33,7 +33,12 @@
33
33
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
34
34
  import { MODEL_REASONING_OPTIONS, type ModelsDevReasoningOption } from "./reasoning.generated.ts";
35
35
 
36
- export type ThinkingLevelMap = NonNullable<ProviderModelConfig["thinkingLevelMap"]>;
36
+ type ChatModelConfig =
37
+ Extract<ProviderModelConfig, { type?: "chat" }> extends never
38
+ ? ProviderModelConfig
39
+ : Extract<ProviderModelConfig, { type?: "chat" }>;
40
+
41
+ export type ThinkingLevelMap = NonNullable<ChatModelConfig["thinkingLevelMap"]>;
37
42
 
38
43
  /** Default: off/low/medium/high/xhigh with minimal hidden. */
39
44
  export const DEFAULT: ThinkingLevelMap = {
@@ -70,6 +75,10 @@ function hidesOff(id: string): boolean {
70
75
  return OFF_NULL_EXACT.has(id) || OFF_NULL_FAMILIES.some((prefix) => id.startsWith(prefix));
71
76
  }
72
77
 
78
+ function applyOffRestriction(id: string, map: ThinkingLevelMap): ThinkingLevelMap {
79
+ return hidesOff(id) ? { ...map, off: null } : map;
80
+ }
81
+
73
82
  /**
74
83
  * Map a models.dev `effort` value onto the Pi level key and the reasoning_effort
75
84
  * string to send for it. Ollama's top effort value is "max"; Pi exposes it via
@@ -140,6 +149,6 @@ export function resolve(id: string, capabilities: string[]): ThinkingLevelMap |
140
149
  // An empty array (e.g. minimax-m2.5) carries no verified options; fall back
141
150
  // to DEFAULT rather than a degenerate map whose only selectable level can
142
151
  // be a leaking off.
143
- if (options === undefined || options.length === 0) return DEFAULT;
152
+ if (options === undefined || options.length === 0) return applyOffRestriction(id, DEFAULT);
144
153
  return buildMap(options, matchedKey);
145
154
  }
package/usage.ts CHANGED
@@ -114,6 +114,9 @@ export async function fetchUsage(apiKey: string, externalSignal?: AbortSignal):
114
114
  );
115
115
 
116
116
  if (!res.ok) {
117
+ if (res.status === 0) {
118
+ throw new Error(`Ollama Cloud usage failed: transport error (${res.error ?? "unknown"}). Try again shortly.`);
119
+ }
117
120
  // The 404 case is specific to this undocumented endpoint: it may have
118
121
  // changed or disappeared, so surface that distinctly before the shared
119
122
  // status mapping.
package/utils.ts CHANGED
@@ -71,7 +71,7 @@ export async function concurrentMap<T, R>(
71
71
 
72
72
  export function getContextLength(modelInfo: Record<string, unknown>): number {
73
73
  for (const [key, value] of Object.entries(modelInfo)) {
74
- if (key.endsWith(".context_length") && typeof value === "number") {
74
+ if (key.endsWith(".context_length") && typeof value === "number" && Number.isInteger(value) && value > 0) {
75
75
  return value;
76
76
  }
77
77
  }
@@ -96,6 +96,9 @@ export async function getCloudApiKey(ctx: Pick<ExtensionContext, "modelRegistry"
96
96
  * distinct status codes. Shared by the web tools and the usage command.
97
97
  */
98
98
  export function httpError(op: string, status: number, error?: string): never {
99
+ if (status === 0) {
100
+ throw new Error(`Ollama Cloud ${op} failed: transport error (${error ?? "unknown"}). Try again shortly.`);
101
+ }
99
102
  if (status === 401 || status === 403) {
100
103
  throw new Error(
101
104
  `Ollama Cloud ${op} failed: authentication error. Check your API key in OLLAMA_API_KEY or auth.json.`,
package/web-tools.ts CHANGED
@@ -137,7 +137,15 @@ export function isSearchResponse(data: unknown): data is SearchResponse {
137
137
  const results = (data as SearchResponse).results;
138
138
  return (
139
139
  Array.isArray(results) &&
140
- results.every((r) => typeof r.title === "string" && typeof r.url === "string" && typeof r.content === "string")
140
+ results.every(
141
+ (result) =>
142
+ typeof result === "object" &&
143
+ result !== null &&
144
+ !Array.isArray(result) &&
145
+ typeof result.title === "string" &&
146
+ typeof result.url === "string" &&
147
+ typeof result.content === "string",
148
+ )
141
149
  );
142
150
  }
143
151
 
@@ -176,6 +184,11 @@ function fetchFailureMessage(
176
184
  );
177
185
  } else if (entry.status === 429) {
178
186
  lines.push("Likely cause: rate limited.", "Suggestion: try again shortly (rate-limit failures are not cached).");
187
+ } else if (entry.status === 0) {
188
+ lines.push(
189
+ "Likely cause: a transport error (timeout, cancellation, or network failure).",
190
+ "Suggestion: try again shortly; transport failures are not cached.",
191
+ );
179
192
  } else if (entry.errorType === "response-shape") {
180
193
  lines.push(
181
194
  "Likely cause: Ollama Cloud returned an unexpected response shape.",
@@ -403,7 +416,14 @@ export function registerWebFetchTool(pi: ExtensionAPI, cacheStore: CacheStore =
403
416
  );
404
417
 
405
418
  if (!res.ok) {
406
- entry = { ts: Date.now(), status: res.status, error: `HTTP ${res.status}: ${res.error ?? "unknown error"}` };
419
+ entry = {
420
+ ts: Date.now(),
421
+ status: res.status,
422
+ error:
423
+ res.status === 0
424
+ ? `Transport error: ${res.error ?? "unknown error"}`
425
+ : `HTTP ${res.status}: ${res.error ?? "unknown error"}`,
426
+ };
407
427
  // Auth, rate-limit, transport (status 0: timeout, abort, DNS/connection
408
428
  // errors), and server (5xx) failures are not negative-cached: a fixed
409
429
  // key, an expired rate-limit window, or a transient server/network