pi-ollama-cloud 0.10.0 → 0.12.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 +15 -1
- package/README.md +49 -14
- package/cache.ts +181 -0
- package/index.ts +2 -2
- package/limits.generated.ts +2 -1
- package/models.generated.ts +73 -33
- package/models.ts +2 -2
- package/package.json +7 -4
- package/pricing.generated.ts +3 -2
- package/reasoning.generated.ts +35 -0
- package/thinking-levels.ts +110 -62
- package/usage.ts +48 -12
- package/utils.ts +6 -0
- package/web-tools.ts +262 -57
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [0.12.0] - 2026-09-11
|
|
6
|
+
|
|
7
|
+
- Source per-model thinking levels from models.dev instead of hardcoded maps. `scripts/generate-reasoning.ts` fetches the `ollama-cloud` provider's `reasoning_options` into `reasoning.generated.ts`, and `thinking-levels.ts` maps each model's effort values onto Pi's levels (toggle-only models become a binary on/off map; models with no models.dev entry fall back to `DEFAULT`). The `off` switch is handled by a small override table for models verified not to honor `reasoning_effort:"none"` (`gpt-oss:20b`, `gpt-oss:120b`, `minimax-m2.7`). Removed the now-stale per-family maps and the `docs/think-experiment.md` doc.
|
|
8
|
+
- `generate-models` now also refreshes `reasoning.generated.ts` (runs `generate-pricing`, `generate-reasoning`, then `generate-models`).
|
|
9
|
+
- Fix `generate-pricing` mis-dropping models whose pricing-page cached-input cell is `-` (no cache rate): those rows now match and their `cacheRead` equals `input`. This restored pricing for `mistral-large-3:675b`, `nemotron-3-nano:30b`, and `qwen3.5:397b`, which the earlier regex had left at zero cost.
|
|
10
|
+
- Refresh the model catalog: added `deepseek-v4.1-flash` (probed max output 393216).
|
|
11
|
+
|
|
12
|
+
## [0.11.0] - 2026-09-07
|
|
13
|
+
|
|
14
|
+
- Fix `/ollama-cloud-usage` and the usage status bar failing with "unexpected response shape" after the undocumented `/api/usage` endpoint flipped between a single `limits.monthly` bucket and `limits.session` plus `limits.weekly` (the shape has flip-flopped repeatedly as of 2026-09). Any bucket present (`monthly`, `session`, `weekly`) is accepted alone or in combination, and whichever are present are displayed as `5h`/`7d`/`30d` segments. Thanks @johanngyger (#56).
|
|
15
|
+
- Cache `ollama_web_search` results (24h) and `ollama_web_fetch` pages (24h success / 15 min failure) on disk under the pi agent home, so repeated queries and page reads cost 0 API calls. Expired entries are pruned on write, the cache is capped at 500 entries per kind (oldest evicted beyond the cap; `PI_OLLAMA_SEARCH_MAX_ENTRIES`), a partially corrupted cache file is validated per entry and degrades to "no cache" instead of crashing tool calls, and the file is written with `0600` permissions since it stores page content and URLs that can embed credentials. Tune with `PI_OLLAMA_SEARCH_TTL_HOURS`, `PI_OLLAMA_SEARCH_FAIL_TTL_MINUTES`, `PI_OLLAMA_SEARCH_MAX_ENTRIES`, and `PI_OLLAMA_SEARCH_CACHE_PATH`.
|
|
16
|
+
- Bound web tool context usage: search snippets truncate to 500 chars with `[truncated]`/`[complete]` markers, `expand=<index>` returns a truncated result's full content from the cached search (0 extra API calls), and `ollama_web_fetch` pages long pages in 3000-char chunks via `offset`/`full` with a `Continue:` hint for the next offset (`PI_OLLAMA_SEARCH_SNIPPET_CHARS`/`PI_OLLAMA_SEARCH_CHUNK_CHARS` to tune).
|
|
17
|
+
- Add `refresh=true` to both web tools to bypass the cache (including a cached failure) and re-call the API; the fresh result replaces the cache entry.
|
|
18
|
+
- Failed page fetches are negative-cached for 15 min with a diagnostic message (likely cause + next steps) instead of a bare error. Auth (401/403), rate-limit (429), transport (timeout/abort/network), server (5xx), and unexpected-response-shape failures are never cached — search reports transport errors as `transport error` instead of a confusing `status 0` — so retrying after a fixed key, an expired rate-limit window, or a transient server blip re-calls the API immediately.
|
|
19
|
+
- Note: `ollama_web_fetch` tool results now carry `details: { title, totalChars, links }` (previously `{ title, content, links }`); paged content is read via the tool output text, not `details.content`.
|
|
6
20
|
|
|
7
21
|
## [0.10.0] - 2026-09-03
|
|
8
22
|
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Registers Ollama Cloud as a model provider with dynamically fetched models, and
|
|
|
7
7
|
## Features
|
|
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
|
+
- **Data-driven thinking levels** - Maps Pi's thinking levels to Ollama Cloud's OpenAI-compatible `reasoning_effort` values via `thinking-levels.ts`, sourced from models.dev per-model reasoning options with a small override table for the models where `none` doesn't disable thinking.
|
|
11
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
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.
|
|
13
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.
|
|
@@ -132,7 +132,7 @@ Model metadata is derived from the `/api/show` response:
|
|
|
132
132
|
| Field | Source |
|
|
133
133
|
|---|---|
|
|
134
134
|
| `reasoning` | `capabilities` includes `"thinking"` |
|
|
135
|
-
| `thinkingLevelMap` | [`thinking-levels.ts`](thinking-levels.ts)
|
|
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
137
|
| `contextWindow` | `model_info.*.context_length` (falls back to 128000) |
|
|
138
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. |
|
|
@@ -146,17 +146,11 @@ Cache pricing is informational only: the `/pricing` page lists a "Cached input"
|
|
|
146
146
|
|
|
147
147
|
### Thinking level mapping
|
|
148
148
|
|
|
149
|
-
Pi's thinking levels are mapped to Ollama Cloud's OpenAI-compatible `reasoning_effort` parameter in [`thinking-levels.ts`](thinking-levels.ts). The API accepts `none`, `low`, `medium`, `high`, and `max`. Effects of `max` over `high` vary by model and prompt difficulty
|
|
149
|
+
Pi's thinking levels are mapped to Ollama Cloud's OpenAI-compatible `reasoning_effort` parameter in [`thinking-levels.ts`](thinking-levels.ts). The API accepts `none`, `low`, `medium`, `high`, `xhigh`, and `max`. Effects of `max` over `high` vary by model and prompt difficulty.
|
|
150
150
|
|
|
151
|
-
|
|
152
|
-
|---|---|---|---|
|
|
153
|
-
| `DEFAULT` | Most thinking models | off, low, medium, high, xhigh | `minimal` hidden (duplicate of low) |
|
|
154
|
-
| `GPT_OSS` | `gpt-oss*` | low, medium, high | Can't disable thinking, no off or xhigh |
|
|
155
|
-
| `QWEN3` | `qwen3*` (except `qwen3-vl*`) | off, medium | Binary-only (think/nothink), no gradation |
|
|
156
|
-
| `GLM_52` | `glm-5.2` | off, high, xhigh | GLM supports disabled thinking; Ollama's model page confirms `high` and `max` reasoning efforts |
|
|
157
|
-
| `NO_OFF` | `qwen3-vl*`, `kimi-k2-thinking`, `minimax*` | low, medium, high, xhigh | "none" doesn't disable thinking on these models |
|
|
151
|
+
Per-model support is sourced from models.dev: [`scripts/generate-reasoning.ts`](scripts/generate-reasoning.ts) fetches the `ollama-cloud` provider's `reasoning_options` into `reasoning.generated.ts`, and `resolve()` maps each model's effort values onto Pi's levels. Models with `effort` values expose those grades; `toggle`-only models expose a single on/off level. Models with no models.dev entry fall back to `DEFAULT`.
|
|
158
152
|
|
|
159
|
-
|
|
153
|
+
Because the API reports only a boolean `thinking` capability and models.dev does not reliably encode the `none` behavior, the `off` switch is handled via a small override table in `thinking-levels.ts`: it defaults to enabled, and is hidden only for models verified (by live probing) not to honor `reasoning_effort:"none"` - currently `gpt-oss:20b`, `gpt-oss:120b`, and `minimax-m2.7`. The per-model metadata gaps behind the models.dev sourcing are tracked upstream in [ollama/ollama#18385](https://github.com/ollama/ollama/issues/18385).
|
|
160
154
|
|
|
161
155
|
## Tools
|
|
162
156
|
|
|
@@ -167,19 +161,60 @@ See [docs/think-experiment.md](docs/think-experiment.md) for the testing methodo
|
|
|
167
161
|
|
|
168
162
|
Both tools use the same Ollama Cloud API key configured for the provider. No local Ollama server is needed.
|
|
169
163
|
|
|
164
|
+
### Caching
|
|
165
|
+
|
|
166
|
+
Both tools cache results on disk (under the pi agent home, `~/.pi/agent/cache/pi-ollama-cloud/cache.json`). A repeated search query or page fetch within the TTL is served from cache and costs 0 API calls:
|
|
167
|
+
|
|
168
|
+
- Successful searches and pages: cached for 24h
|
|
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
|
|
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
|
+
- 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
|
+
- `refresh=true` on either tool bypasses the cache (including a cached failure) and re-calls the API; the fresh result replaces the cache entry
|
|
174
|
+
|
|
175
|
+
### `ollama_web_search`
|
|
176
|
+
|
|
177
|
+
Returns up to 5 results by default (`max_results`, max 10; title, URL, 500-char snippet). Snippets are marked `[truncated]` when the source is longer than the snippet. Output ends with `# live query` or `# from cache` to show whether the API was called.
|
|
178
|
+
|
|
179
|
+
The search API returns each result's full content; it is cached in full, so a truncated result can be expanded without a separate fetch:
|
|
180
|
+
|
|
181
|
+
- `expand=<index>` — return the full content of that result (1-based) from the cached search, 0 extra API calls. The cache key includes `max_results`, so expanding hits the cache only when the query was searched with the same `max_results`; otherwise the search runs live first.
|
|
182
|
+
- Use `ollama_web_fetch` only when the search result's content is not enough (e.g. you need a different page, or the search excerpt is shorter than the full page).
|
|
183
|
+
|
|
184
|
+
### `ollama_web_fetch`
|
|
185
|
+
|
|
186
|
+
Returns the page title, a 3000-char slice of the content, and links. Long pages are read in chunks to keep the context window small:
|
|
187
|
+
|
|
188
|
+
- `offset=N` — continue reading from character N (the output tells you the next offset)
|
|
189
|
+
- `full=true` — return all remaining content from `offset` in one call
|
|
190
|
+
|
|
191
|
+
A failed fetch throws a diagnostic message (likely cause + next steps) instead of a bare error.
|
|
192
|
+
|
|
193
|
+
### Tuning
|
|
194
|
+
|
|
195
|
+
| Env var | Default | Meaning |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| `PI_OLLAMA_SEARCH_TTL_HOURS` | `24` | Success cache TTL |
|
|
198
|
+
| `PI_OLLAMA_SEARCH_FAIL_TTL_MINUTES` | `15` | Failure (negative) cache TTL |
|
|
199
|
+
| `PI_OLLAMA_SEARCH_CACHE_PATH` | `<pi agent home>/cache/pi-ollama-cloud/cache.json` | Cache file location |
|
|
200
|
+
| `PI_OLLAMA_SEARCH_MAX_ENTRIES` | `500` | Max cached entries per kind (searches/pages); oldest evicted beyond the cap |
|
|
201
|
+
| `PI_OLLAMA_SEARCH_SNIPPET_CHARS` | `500` | Search snippet length |
|
|
202
|
+
| `PI_OLLAMA_SEARCH_CHUNK_CHARS` | `3000` | Fetch chunk size |
|
|
203
|
+
|
|
170
204
|
## Commands
|
|
171
205
|
|
|
172
206
|
| Command | Description |
|
|
173
207
|
|---|---|
|
|
174
208
|
| `/ollama-webtools [on\|off\|enable\|disable]` | Enable or disable the `ollama_web_search` and `ollama_web_fetch` tools. Toggles if no argument given. |
|
|
175
|
-
| `/ollama-cloud-usage` | Show Ollama Cloud
|
|
209
|
+
| `/ollama-cloud-usage` | Show Ollama Cloud usage limits (one section per limit bucket the API reports), per-model request counts, and the 4-week activity cost. |
|
|
176
210
|
| `/ollama-usage-status [on\|off\|enable\|disable]` | Enable or disable the footer usage status bar. Toggles if no argument given. |
|
|
177
211
|
|
|
178
212
|
## Usage status bar
|
|
179
213
|
|
|
180
214
|
While an `ollama-cloud` model is the active provider, the footer shows a compact
|
|
181
|
-
live usage readout
|
|
182
|
-
|
|
215
|
+
live usage readout with one segment per limit bucket the API reports
|
|
216
|
+
(`5h ▕███░░░░░░░▏ 34% 7d ▕█░░░░░░░░░▏ 7%`, or a single `30d` segment) that
|
|
217
|
+
refreshes every 5 minutes and after each agent turn (but no more often than every 5 minutes). It is colored by how close
|
|
183
218
|
it is to the cap: green below 60%, yellow at 60-79%, red at 80%+. It reads the
|
|
184
219
|
same undocumented `/api/usage` endpoint as `/ollama-cloud-usage` and clears
|
|
185
220
|
itself on transient errors or when you switch to a non-Ollama-Cloud provider.
|
package/cache.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { envInt } from "./utils.ts";
|
|
6
|
+
|
|
7
|
+
export const CACHE_PATH =
|
|
8
|
+
process.env.PI_OLLAMA_SEARCH_CACHE_PATH ?? join(getAgentDir(), "cache", "pi-ollama-cloud", "cache.json");
|
|
9
|
+
export const CACHE_TTL_MS = envInt("PI_OLLAMA_SEARCH_TTL_HOURS", 24) * 60 * 60 * 1000;
|
|
10
|
+
export const FAIL_TTL_MS = envInt("PI_OLLAMA_SEARCH_FAIL_TTL_MINUTES", 15) * 60 * 1000;
|
|
11
|
+
/** Max entries per map (searches/pages); oldest-ts entries are evicted beyond this. */
|
|
12
|
+
export const MAX_ENTRIES = envInt("PI_OLLAMA_SEARCH_MAX_ENTRIES", 500);
|
|
13
|
+
|
|
14
|
+
export interface SearchResult {
|
|
15
|
+
title: string;
|
|
16
|
+
url: string;
|
|
17
|
+
content: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SearchCacheEntry {
|
|
21
|
+
ts: number;
|
|
22
|
+
q: string;
|
|
23
|
+
results: SearchResult[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PageCacheEntry {
|
|
27
|
+
ts: number;
|
|
28
|
+
status?: number;
|
|
29
|
+
title?: string;
|
|
30
|
+
content?: string;
|
|
31
|
+
links?: string[] | null;
|
|
32
|
+
error?: string;
|
|
33
|
+
errorType?: "response-shape";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CacheData {
|
|
37
|
+
searches: Record<string, SearchCacheEntry>;
|
|
38
|
+
pages: Record<string, PageCacheEntry>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CacheOptions {
|
|
42
|
+
path: string;
|
|
43
|
+
ttlMs: number;
|
|
44
|
+
failTtlMs: number;
|
|
45
|
+
maxEntries: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
49
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Keys that must never come from a parsed JSON file (prototype pollution). */
|
|
53
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
54
|
+
|
|
55
|
+
export function isSafeKey(key: string): boolean {
|
|
56
|
+
return !UNSAFE_KEYS.has(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shallow shape checks so a partially corrupted cache file degrades instead of crashing tool calls. */
|
|
60
|
+
function isSearchEntry(v: unknown): v is SearchCacheEntry {
|
|
61
|
+
return (
|
|
62
|
+
isRecord(v) &&
|
|
63
|
+
typeof v.ts === "number" &&
|
|
64
|
+
typeof v.q === "string" &&
|
|
65
|
+
Array.isArray(v.results) &&
|
|
66
|
+
v.results.every(
|
|
67
|
+
(r) => isRecord(r) && typeof r.title === "string" && typeof r.url === "string" && typeof r.content === "string",
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isPageEntry(v: unknown): v is PageCacheEntry {
|
|
73
|
+
if (!isRecord(v) || typeof v.ts !== "number") return false;
|
|
74
|
+
const fieldsValid =
|
|
75
|
+
(v.status === undefined || typeof v.status === "number") &&
|
|
76
|
+
(v.title === undefined || typeof v.title === "string") &&
|
|
77
|
+
(v.content === undefined || typeof v.content === "string") &&
|
|
78
|
+
(v.links === null ||
|
|
79
|
+
v.links === undefined ||
|
|
80
|
+
(Array.isArray(v.links) && v.links.every((l) => typeof l === "string"))) &&
|
|
81
|
+
(v.error === undefined || (typeof v.error === "string" && v.error !== "")) &&
|
|
82
|
+
(v.errorType === undefined || v.errorType === "response-shape");
|
|
83
|
+
if (!fieldsValid) return false;
|
|
84
|
+
// Must be either a real failure or a real success; anything else (e.g. an
|
|
85
|
+
// entry with neither content nor a non-empty error) would render as a fake
|
|
86
|
+
// empty success.
|
|
87
|
+
return (
|
|
88
|
+
(typeof v.error === "string" && v.error !== "") || (typeof v.title === "string" && typeof v.content === "string")
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CacheStore {
|
|
93
|
+
loadCache(): CacheData;
|
|
94
|
+
saveCache(): void;
|
|
95
|
+
isFresh(entry: { ts: number; error?: string } | undefined): boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createCache(options: Partial<CacheOptions> = {}): CacheStore {
|
|
99
|
+
const path = options.path ?? CACHE_PATH;
|
|
100
|
+
const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
|
|
101
|
+
const failTtlMs = options.failTtlMs ?? FAIL_TTL_MS;
|
|
102
|
+
const maxEntries = options.maxEntries ?? MAX_ENTRIES;
|
|
103
|
+
let cacheData: CacheData | null = null;
|
|
104
|
+
|
|
105
|
+
function loadCache(): CacheData {
|
|
106
|
+
if (cacheData) return cacheData;
|
|
107
|
+
try {
|
|
108
|
+
const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
109
|
+
if (isRecord(raw) && isRecord(raw.searches) && isRecord(raw.pages)) {
|
|
110
|
+
// Per-entry validation: drop poisoned entries so a partially corrupt
|
|
111
|
+
// file degrades to "those entries are gone" instead of crashing calls.
|
|
112
|
+
cacheData = { searches: {}, pages: {} };
|
|
113
|
+
for (const [key, entry] of Object.entries(raw.searches)) {
|
|
114
|
+
if (isSafeKey(key) && isSearchEntry(entry)) cacheData.searches[key] = entry;
|
|
115
|
+
}
|
|
116
|
+
for (const [key, entry] of Object.entries(raw.pages)) {
|
|
117
|
+
if (isSafeKey(key) && isPageEntry(entry)) cacheData.pages[key] = entry;
|
|
118
|
+
}
|
|
119
|
+
return cacheData;
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// First run or corrupt file, start fresh.
|
|
123
|
+
}
|
|
124
|
+
cacheData = { searches: {}, pages: {} };
|
|
125
|
+
return cacheData;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isFresh(entry: { ts: number; error?: string } | undefined): boolean {
|
|
129
|
+
if (!entry) return false;
|
|
130
|
+
// A future ts (hand-edited file) would otherwise be fresh forever; treat as stale.
|
|
131
|
+
if (entry.ts > Date.now()) return false;
|
|
132
|
+
return Date.now() - entry.ts < (entry.error ? failTtlMs : ttlMs);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function evictOldest(map: Record<string, { ts: number }>): void {
|
|
136
|
+
const keys = Object.keys(map);
|
|
137
|
+
if (keys.length <= maxEntries) return;
|
|
138
|
+
const overflow = keys.sort((a, b) => map[a].ts - map[b].ts).slice(0, keys.length - maxEntries);
|
|
139
|
+
for (const key of overflow) delete map[key];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function saveCache(): void {
|
|
143
|
+
const data = loadCache();
|
|
144
|
+
for (const [key, entry] of Object.entries(data.searches)) if (!isFresh(entry)) delete data.searches[key];
|
|
145
|
+
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.
|
|
148
|
+
evictOldest(data.searches);
|
|
149
|
+
evictOldest(data.pages);
|
|
150
|
+
try {
|
|
151
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
152
|
+
// Use a unique temporary path so concurrent processes cannot overwrite
|
|
153
|
+
// one another's in-progress writes.
|
|
154
|
+
const tmp = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
155
|
+
try {
|
|
156
|
+
// 0o600: the cache stores full page content and URLs, which can embed
|
|
157
|
+
// credentials in query strings; it should not be world-readable.
|
|
158
|
+
writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
159
|
+
renameSync(tmp, path);
|
|
160
|
+
// Fix perms of a file written by a pre-0600 version.
|
|
161
|
+
chmodSync(path, 0o600);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
rmSync(tmp, { force: true });
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// Cache is best-effort; a failed write must not break the tool call.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { loadCache, saveCache, isFresh };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const defaultCache = createCache();
|
|
175
|
+
export const loadCache = defaultCache.loadCache;
|
|
176
|
+
export const saveCache = defaultCache.saveCache;
|
|
177
|
+
export const isFresh = defaultCache.isFresh;
|
|
178
|
+
|
|
179
|
+
export function searchCacheKey(query: string, maxResults: number): string {
|
|
180
|
+
return createHash("sha1").update(`${query}\n${maxResults}`).digest("hex");
|
|
181
|
+
}
|
package/index.ts
CHANGED
|
@@ -134,7 +134,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
134
134
|
// --- Usage Command ---
|
|
135
135
|
|
|
136
136
|
pi.registerCommand("ollama-cloud-usage", {
|
|
137
|
-
description: "Show Ollama Cloud
|
|
137
|
+
description: "Show Ollama Cloud usage limits.",
|
|
138
138
|
handler: async (_args, ctx) => {
|
|
139
139
|
const apiKey = await getCloudApiKey(ctx);
|
|
140
140
|
if (!apiKey) {
|
|
@@ -152,7 +152,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
152
152
|
|
|
153
153
|
// --- Usage Status Bar ---
|
|
154
154
|
|
|
155
|
-
// Footer status showing live
|
|
155
|
+
// Footer status showing live usage while ollama-cloud is the
|
|
156
156
|
// active provider. Refreshes on a 5-minute timer; agent_end also triggers a
|
|
157
157
|
// refresh but is throttled to the same cooldown so a turn never hammers the
|
|
158
158
|
// undocumented /api/usage endpoint. The quota-bar concept is inspired by
|
package/limits.generated.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Auto-generated by scripts/generate-limits.ts
|
|
2
2
|
// Do not edit manually.
|
|
3
|
-
//
|
|
3
|
+
// Entries: 20
|
|
4
4
|
|
|
5
5
|
export const MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
|
|
6
6
|
"deepseek-v4-flash:0731": 65536,
|
|
7
7
|
"deepseek-v4-pro:0813": 65536,
|
|
8
|
+
"deepseek-v4.1-flash": 393216,
|
|
8
9
|
"gemma4:31b": 262144,
|
|
9
10
|
"glm-5.1": 131072,
|
|
10
11
|
"glm-5.2": 131072,
|
package/models.generated.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Auto-generated by scripts/generate-models.ts
|
|
2
2
|
// Do not edit manually.
|
|
3
|
-
// Generated: 2026-09-
|
|
4
|
-
// Model count:
|
|
3
|
+
// Generated: 2026-09-12T02:41:57.025Z
|
|
4
|
+
// Model count: 20
|
|
5
5
|
|
|
6
6
|
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
|
|
@@ -39,8 +39,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
39
39
|
reasoning: true,
|
|
40
40
|
thinkingLevelMap: {
|
|
41
41
|
high: "high",
|
|
42
|
-
low:
|
|
43
|
-
medium:
|
|
42
|
+
low: null,
|
|
43
|
+
medium: null,
|
|
44
44
|
minimal: null,
|
|
45
45
|
off: "none",
|
|
46
46
|
xhigh: "max",
|
|
@@ -77,10 +77,50 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
77
77
|
input: ["text"],
|
|
78
78
|
maxTokens: 65536,
|
|
79
79
|
reasoning: true,
|
|
80
|
+
thinkingLevelMap: {
|
|
81
|
+
high: "high",
|
|
82
|
+
low: null,
|
|
83
|
+
medium: null,
|
|
84
|
+
minimal: null,
|
|
85
|
+
off: "none",
|
|
86
|
+
xhigh: "max",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "deepseek-v4.1-flash",
|
|
91
|
+
name: "deepseek-v4.1-flash",
|
|
92
|
+
compat: {
|
|
93
|
+
maxTokensField: "max_tokens",
|
|
94
|
+
openRouterRouting: {},
|
|
95
|
+
requiresAssistantAfterToolResult: false,
|
|
96
|
+
requiresReasoningContentOnAssistantMessages: false,
|
|
97
|
+
requiresThinkingAsText: false,
|
|
98
|
+
requiresToolResultName: false,
|
|
99
|
+
sendSessionAffinityHeaders: false,
|
|
100
|
+
supportsDeveloperRole: false,
|
|
101
|
+
supportsLongCacheRetention: false,
|
|
102
|
+
supportsReasoningEffort: true,
|
|
103
|
+
supportsStore: false,
|
|
104
|
+
supportsStrictMode: false,
|
|
105
|
+
supportsUsageInStreaming: true,
|
|
106
|
+
thinkingFormat: "openai",
|
|
107
|
+
vercelGatewayRouting: {},
|
|
108
|
+
zaiToolStream: false,
|
|
109
|
+
},
|
|
110
|
+
contextWindow: 1048576,
|
|
111
|
+
cost: {
|
|
112
|
+
cacheRead: 0.006,
|
|
113
|
+
cacheWrite: 0,
|
|
114
|
+
input: 0.3,
|
|
115
|
+
output: 1.2,
|
|
116
|
+
},
|
|
117
|
+
input: ["text", "image"],
|
|
118
|
+
maxTokens: 393216,
|
|
119
|
+
reasoning: true,
|
|
80
120
|
thinkingLevelMap: {
|
|
81
121
|
high: "high",
|
|
82
122
|
low: "low",
|
|
83
|
-
medium:
|
|
123
|
+
medium: null,
|
|
84
124
|
minimal: null,
|
|
85
125
|
off: "none",
|
|
86
126
|
xhigh: "max",
|
|
@@ -118,12 +158,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
118
158
|
maxTokens: 262144,
|
|
119
159
|
reasoning: true,
|
|
120
160
|
thinkingLevelMap: {
|
|
121
|
-
high:
|
|
122
|
-
low:
|
|
161
|
+
high: null,
|
|
162
|
+
low: null,
|
|
123
163
|
medium: "medium",
|
|
124
164
|
minimal: null,
|
|
125
165
|
off: "none",
|
|
126
|
-
xhigh:
|
|
166
|
+
xhigh: null,
|
|
127
167
|
},
|
|
128
168
|
},
|
|
129
169
|
{
|
|
@@ -158,12 +198,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
158
198
|
maxTokens: 131072,
|
|
159
199
|
reasoning: true,
|
|
160
200
|
thinkingLevelMap: {
|
|
161
|
-
high:
|
|
162
|
-
low:
|
|
201
|
+
high: null,
|
|
202
|
+
low: null,
|
|
163
203
|
medium: "medium",
|
|
164
204
|
minimal: null,
|
|
165
205
|
off: "none",
|
|
166
|
-
xhigh:
|
|
206
|
+
xhigh: null,
|
|
167
207
|
},
|
|
168
208
|
},
|
|
169
209
|
{
|
|
@@ -240,7 +280,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
240
280
|
thinkingLevelMap: {
|
|
241
281
|
high: "high",
|
|
242
282
|
low: "low",
|
|
243
|
-
medium:
|
|
283
|
+
medium: null,
|
|
244
284
|
minimal: null,
|
|
245
285
|
off: "none",
|
|
246
286
|
xhigh: "max",
|
|
@@ -280,7 +320,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
280
320
|
thinkingLevelMap: {
|
|
281
321
|
high: "high",
|
|
282
322
|
low: "low",
|
|
283
|
-
medium:
|
|
323
|
+
medium: null,
|
|
284
324
|
minimal: null,
|
|
285
325
|
off: "none",
|
|
286
326
|
xhigh: "max",
|
|
@@ -398,12 +438,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
398
438
|
maxTokens: 262144,
|
|
399
439
|
reasoning: true,
|
|
400
440
|
thinkingLevelMap: {
|
|
401
|
-
high:
|
|
402
|
-
low:
|
|
441
|
+
high: null,
|
|
442
|
+
low: null,
|
|
403
443
|
medium: "medium",
|
|
404
444
|
minimal: null,
|
|
405
445
|
off: "none",
|
|
406
|
-
xhigh:
|
|
446
|
+
xhigh: null,
|
|
407
447
|
},
|
|
408
448
|
},
|
|
409
449
|
{
|
|
@@ -438,12 +478,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
438
478
|
maxTokens: 262144,
|
|
439
479
|
reasoning: true,
|
|
440
480
|
thinkingLevelMap: {
|
|
441
|
-
high:
|
|
442
|
-
low:
|
|
481
|
+
high: null,
|
|
482
|
+
low: null,
|
|
443
483
|
medium: "medium",
|
|
444
484
|
minimal: null,
|
|
445
485
|
off: "none",
|
|
446
|
-
xhigh:
|
|
486
|
+
xhigh: null,
|
|
447
487
|
},
|
|
448
488
|
},
|
|
449
489
|
{
|
|
@@ -480,7 +520,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
480
520
|
thinkingLevelMap: {
|
|
481
521
|
high: "high",
|
|
482
522
|
low: "low",
|
|
483
|
-
medium:
|
|
523
|
+
medium: null,
|
|
484
524
|
minimal: null,
|
|
485
525
|
off: "none",
|
|
486
526
|
xhigh: "max",
|
|
@@ -518,12 +558,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
518
558
|
maxTokens: 131072,
|
|
519
559
|
reasoning: true,
|
|
520
560
|
thinkingLevelMap: {
|
|
521
|
-
high:
|
|
522
|
-
low:
|
|
561
|
+
high: null,
|
|
562
|
+
low: null,
|
|
523
563
|
medium: "medium",
|
|
524
564
|
minimal: null,
|
|
525
565
|
off: null,
|
|
526
|
-
xhigh:
|
|
566
|
+
xhigh: null,
|
|
527
567
|
},
|
|
528
568
|
},
|
|
529
569
|
{
|
|
@@ -562,7 +602,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
562
602
|
low: "low",
|
|
563
603
|
medium: "medium",
|
|
564
604
|
minimal: null,
|
|
565
|
-
off:
|
|
605
|
+
off: "none",
|
|
566
606
|
xhigh: "max",
|
|
567
607
|
},
|
|
568
608
|
},
|
|
@@ -630,12 +670,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
630
670
|
maxTokens: 131072,
|
|
631
671
|
reasoning: true,
|
|
632
672
|
thinkingLevelMap: {
|
|
633
|
-
high:
|
|
634
|
-
low:
|
|
673
|
+
high: null,
|
|
674
|
+
low: null,
|
|
635
675
|
medium: "medium",
|
|
636
676
|
minimal: null,
|
|
637
677
|
off: "none",
|
|
638
|
-
xhigh:
|
|
678
|
+
xhigh: null,
|
|
639
679
|
},
|
|
640
680
|
},
|
|
641
681
|
{
|
|
@@ -670,12 +710,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
670
710
|
maxTokens: 65536,
|
|
671
711
|
reasoning: true,
|
|
672
712
|
thinkingLevelMap: {
|
|
673
|
-
high:
|
|
674
|
-
low:
|
|
713
|
+
high: null,
|
|
714
|
+
low: null,
|
|
675
715
|
medium: "medium",
|
|
676
716
|
minimal: null,
|
|
677
717
|
off: "none",
|
|
678
|
-
xhigh:
|
|
718
|
+
xhigh: null,
|
|
679
719
|
},
|
|
680
720
|
},
|
|
681
721
|
{
|
|
@@ -710,12 +750,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
|
|
|
710
750
|
maxTokens: 65536,
|
|
711
751
|
reasoning: true,
|
|
712
752
|
thinkingLevelMap: {
|
|
713
|
-
high:
|
|
714
|
-
low:
|
|
753
|
+
high: null,
|
|
754
|
+
low: null,
|
|
715
755
|
medium: "medium",
|
|
716
756
|
minimal: null,
|
|
717
757
|
off: "none",
|
|
718
|
-
xhigh:
|
|
758
|
+
xhigh: null,
|
|
719
759
|
},
|
|
720
760
|
},
|
|
721
761
|
{
|
package/models.ts
CHANGED
|
@@ -93,7 +93,7 @@ function buildCompat(): ProviderModelConfig["compat"] {
|
|
|
93
93
|
return {
|
|
94
94
|
// Ollama uses "system" role, not "developer" (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsDeveloperRole).
|
|
95
95
|
supportsDeveloperRole: false,
|
|
96
|
-
// reasoning_effort works (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsReasoningEffort
|
|
96
|
+
// reasoning_effort works (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsReasoningEffort).
|
|
97
97
|
supportsReasoningEffort: true,
|
|
98
98
|
// "store" is not a supported field (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsStore).
|
|
99
99
|
supportsStore: false,
|
|
@@ -109,7 +109,7 @@ function buildCompat(): ProviderModelConfig["compat"] {
|
|
|
109
109
|
requiresThinkingAsText: false,
|
|
110
110
|
// DeepSeek-specific, not needed for Ollama (pi: types.ts#requiresReasoningContentOnAssistantMessages).
|
|
111
111
|
requiresReasoningContentOnAssistantMessages: false,
|
|
112
|
-
// reasoning_effort format works (pi: types.ts#thinkingFormat
|
|
112
|
+
// reasoning_effort format works (pi: types.ts#thinkingFormat).
|
|
113
113
|
thinkingFormat: "openai",
|
|
114
114
|
// Ollama does not support tool_choice, so strict mode is unavailable (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsStrictMode).
|
|
115
115
|
supportsStrictMode: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ollama-cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"index.ts",
|
|
10
10
|
"config.ts",
|
|
11
|
+
"cache.ts",
|
|
11
12
|
"limits.generated.ts",
|
|
12
13
|
"models.ts",
|
|
13
14
|
"models.generated.ts",
|
|
14
15
|
"pricing.generated.ts",
|
|
16
|
+
"reasoning.generated.ts",
|
|
15
17
|
"thinking-levels.ts",
|
|
16
18
|
"usage.ts",
|
|
17
19
|
"utils.ts",
|
|
@@ -31,8 +33,9 @@
|
|
|
31
33
|
"format": "biome format --write .",
|
|
32
34
|
"test": "vitest run",
|
|
33
35
|
"smoke:web-tools": "tsx scripts/smoke-web-tools.ts",
|
|
34
|
-
"generate-models": "tsx scripts/generate-pricing.ts && tsx scripts/generate-models.ts && biome format --write models.generated.ts pricing.generated.ts",
|
|
35
|
-
"generate-limits": "tsx scripts/generate-limits.ts
|
|
36
|
+
"generate-models": "tsx scripts/generate-pricing.ts && tsx scripts/generate-reasoning.ts && tsx scripts/generate-models.ts && biome format --write models.generated.ts pricing.generated.ts reasoning.generated.ts",
|
|
37
|
+
"generate-limits": "tsx scripts/generate-limits.ts",
|
|
38
|
+
"generate-reasoning": "tsx scripts/generate-reasoning.ts && biome format --write reasoning.generated.ts"
|
|
36
39
|
},
|
|
37
40
|
"pi": {
|
|
38
41
|
"extensions": [
|
|
@@ -50,6 +53,6 @@
|
|
|
50
53
|
"@types/node": "^26.1.2",
|
|
51
54
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
52
55
|
"tsx": "^4.19.0",
|
|
53
|
-
"vitest": "^4.1.
|
|
56
|
+
"vitest": "^4.1.11"
|
|
54
57
|
}
|
|
55
58
|
}
|
package/pricing.generated.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Auto-generated by scripts/generate-pricing.ts
|
|
2
2
|
// Do not edit manually.
|
|
3
|
-
// Generated: 2026-09-
|
|
4
|
-
// Model count:
|
|
3
|
+
// Generated: 2026-09-12T02:41:55.206Z
|
|
4
|
+
// Model count: 20
|
|
5
5
|
|
|
6
6
|
export interface ModelPrice {
|
|
7
7
|
input: number;
|
|
@@ -13,6 +13,7 @@ export interface ModelPrice {
|
|
|
13
13
|
export const MODEL_PRICING: Record<string, ModelPrice> = {
|
|
14
14
|
"deepseek-v4-flash:0731": { input: 0.44, output: 1.32, cacheRead: 0.014, cacheWrite: 0 },
|
|
15
15
|
"deepseek-v4-pro:0813": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 },
|
|
16
|
+
"deepseek-v4.1-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
|
|
16
17
|
"gemma4:31b": { input: 0.14, output: 0.4, cacheRead: 0.05, cacheWrite: 0 },
|
|
17
18
|
"glm-5.1": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 },
|
|
18
19
|
"glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
|