pi-ollama-cloud 0.12.2 → 0.13.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 +12 -0
- package/README.md +29 -22
- package/cache.ts +84 -5
- package/index.ts +59 -24
- package/limits.generated.ts +1 -4
- package/models.generated.ts +2 -2
- package/models.ts +61 -28
- package/package.json +1 -1
- package/thinking-levels.ts +11 -2
- package/usage.ts +147 -135
- package/utils.ts +4 -1
- package/web-tools.ts +22 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.13.0] - 2026-10-07
|
|
6
|
+
|
|
7
|
+
- Fix the usage command and status bar after Ollama moved quota data to `/api/balance`, supporting both legacy remaining-percentage windows and the included-balance allowance shape. Show allowance renewal dates and purchased credits when returned, and optionally display request totals from the documented `/api/usage` histogram endpoint.
|
|
8
|
+
|
|
9
|
+
## [0.12.3] - 2026-10-06
|
|
10
|
+
|
|
11
|
+
- Keep the usage indicator accurate when turning it off or switching providers, and show clearer errors for connection problems and invalid responses.
|
|
12
|
+
- Hide unsupported reasoning controls on affected gpt-oss models and limit disk space used by web-tool caching.
|
|
13
|
+
- Keep Ollama Cloud models compatible with Pi 1.0.
|
|
14
|
+
- Allow model-limit updates to use the API key configured in Pi, with `OLLAMA_API_KEY` as a fallback.
|
|
15
|
+
- Run the test suite successfully before publishing a release.
|
|
16
|
+
|
|
5
17
|
## [0.12.2] - 2026-09-27
|
|
6
18
|
|
|
7
19
|
- 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` (
|
|
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.
|
|
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
|
|
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
|
|
@@ -206,18 +206,20 @@ A failed fetch throws a diagnostic message (likely cause + next steps) instead o
|
|
|
206
206
|
| Command | Description |
|
|
207
207
|
|---|---|
|
|
208
208
|
| `/ollama-webtools [on\|off\|enable\|disable]` | Enable or disable the `ollama_web_search` and `ollama_web_fetch` tools. Toggles if no argument given. |
|
|
209
|
-
| `/ollama-cloud-usage` | Show
|
|
209
|
+
| `/ollama-cloud-usage` | Show included quota or balance, billing-period renewal date when available, purchased credits, and request totals when the histogram endpoint is available. |
|
|
210
210
|
| `/ollama-usage-status [on\|off\|enable\|disable]` | Enable or disable the footer usage status bar. Toggles if no argument given. |
|
|
211
211
|
|
|
212
212
|
## Usage status bar
|
|
213
213
|
|
|
214
214
|
While an `ollama-cloud` model is the active provider, the footer shows a compact
|
|
215
|
-
live
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
215
|
+
live quota readout. It displays one segment per legacy quota window when those
|
|
216
|
+
are returned, or a `plan` segment derived from the included balance and
|
|
217
|
+
allowance. For example, `$55.50` remaining of a `$60.00` allowance displays as
|
|
218
|
+
`plan ▕░░░░░░░░░░▏ 7%`. The bar refreshes every 5 minutes and after an agent turn (no more
|
|
219
|
+
often than every 5 minutes). It is colored by usage: green below 60%, yellow
|
|
220
|
+
at 60-79%, and red at 80% or above. Quota comes from the documented
|
|
221
|
+
[`/api/balance` endpoint](https://docs.ollama.com/api/balance); transient errors
|
|
222
|
+
or switching providers clear the status.
|
|
221
223
|
|
|
222
224
|
It is off by default. Enable it at runtime with `/ollama-usage-status on`, or
|
|
223
225
|
enable it by default with `"usageStatus": true` in `ollama-cloud.json`. If the
|
|
@@ -226,8 +228,8 @@ underlying error (e.g. a misconfigured API key).
|
|
|
226
228
|
|
|
227
229
|
The quota-bar concept is inspired by
|
|
228
230
|
[`@entelligentsia/pi-ollama-cloud-usage-tracker`](https://github.com/Entelligentsia/pi-ollama-cloud-usage-tracker),
|
|
229
|
-
but this extension fetches
|
|
230
|
-
|
|
231
|
+
but this extension fetches quota from `/api/balance` with the API key it already
|
|
232
|
+
resolves, rather than scraping the settings page with Chrome cookies.
|
|
231
233
|
|
|
232
234
|
## Usage API for custom status bars
|
|
233
235
|
|
|
@@ -236,18 +238,19 @@ status bar instead of (or alongside) the built-in one. The relevant modules ship
|
|
|
236
238
|
with the package and are importable directly:
|
|
237
239
|
|
|
238
240
|
```ts
|
|
239
|
-
import { fetchUsage, formatUsage, formatUsageStatusColored } from "pi-ollama-cloud/usage.ts";
|
|
241
|
+
import { fetchUsage, fetchUsageStats, formatUsage, formatUsageStatusColored } from "pi-ollama-cloud/usage.ts";
|
|
240
242
|
import { getCloudApiKey } from "pi-ollama-cloud/utils.ts";
|
|
241
243
|
import type { UsageData } from "pi-ollama-cloud/usage.ts";
|
|
242
244
|
```
|
|
243
245
|
|
|
244
246
|
| Export | Description |
|
|
245
247
|
|---|---|
|
|
246
|
-
| `fetchUsage(apiKey, signal?)` | Fetch the
|
|
248
|
+
| `fetchUsage(apiKey, signal?)` | Fetch quota from the documented [`/api/balance` endpoint](https://docs.ollama.com/api/balance), including legacy windows or the allowance-based billing-period shape. `UsageData.included.period.until` contains the renewal boundary when provided. |
|
|
247
249
|
| `formatUsageStatusColored(theme, data)` | One-line status string with quota bars, colored by usage level. Takes a `Theme` (e.g. `ctx.ui.theme`). |
|
|
248
|
-
| `
|
|
250
|
+
| `fetchUsageStats(apiKey, range?, signal?)` | Fetch request histogram data from the documented [`/api/usage` endpoint](https://docs.ollama.com/api/cloud-usage), limited to 10 requests per minute per user. |
|
|
251
|
+
| `formatUsage(data, stats?)` | Multi-line quota and billing-period output, with request totals when stats are provided. |
|
|
249
252
|
| `getCloudApiKey(ctx)` | Resolve the Ollama Cloud API key the same way the extension does. |
|
|
250
|
-
| `isUsageResponse(data)` / `
|
|
253
|
+
| `isUsageResponse(data)` / `isUsageStats(data)` | Validators for parsing the raw responses yourself. |
|
|
251
254
|
|
|
252
255
|
Example custom status bar:
|
|
253
256
|
|
|
@@ -268,7 +271,7 @@ npm run check # lint + format + type-check (auto-fix)
|
|
|
268
271
|
npm run lint # lint only (no fixes)
|
|
269
272
|
npm run typecheck # type-check only (tsgo --noEmit)
|
|
270
273
|
npm run format # format only
|
|
271
|
-
|
|
274
|
+
npm run generate-limits # probe max output tokens using auth.json or OLLAMA_API_KEY (writes limits.generated.ts)
|
|
272
275
|
```
|
|
273
276
|
|
|
274
277
|
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 +328,24 @@ The `-e`/`--extension` flag loads the extension from the local checkout without
|
|
|
325
328
|
|
|
326
329
|
## Releasing
|
|
327
330
|
|
|
328
|
-
Publishing a new version to npm is a
|
|
331
|
+
Publishing a new version to npm is a three-step process:
|
|
329
332
|
|
|
330
333
|
```bash
|
|
331
|
-
# 1.
|
|
334
|
+
# 1. Commit and push main, then wait for the Test workflow to go green
|
|
335
|
+
# (gh run watch, or the Actions tab). CI must be green before tagging.
|
|
336
|
+
# 2. Bump version and create a git tag in one step
|
|
332
337
|
npm version minor # or patch, or major
|
|
333
|
-
#
|
|
338
|
+
# 3. Push the tag to trigger the GitHub Actions publish workflow
|
|
334
339
|
git push --tags
|
|
335
340
|
```
|
|
336
341
|
|
|
342
|
+
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`).
|
|
343
|
+
|
|
337
344
|
Because the model catalog refreshes automatically at runtime, a release is **not** needed to ship new models. Publish only when:
|
|
338
345
|
|
|
339
346
|
- 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
347
|
- 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 `
|
|
348
|
+
- Max output token limits changed: run `npm run generate-limits` locally (uses Pi's `auth.json` or `OLLAMA_API_KEY`) and commit.
|
|
342
349
|
|
|
343
350
|
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
351
|
|
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
|
-
//
|
|
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(
|
|
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
|
@@ -28,14 +28,40 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
28
28
|
import { loadConfig, resolveWebToolsEnv } from "./config.ts";
|
|
29
29
|
import { GENERATED_MODELS } from "./models.generated.ts";
|
|
30
30
|
import { OLLAMA_BASE, refreshOllamaCatalog } from "./models.ts";
|
|
31
|
-
import { fetchUsage, formatUsage, formatUsageStatusColored } from "./usage.ts";
|
|
31
|
+
import { fetchUsage, fetchUsageStats, formatUsage, formatUsageStatusColored, type UsageStats } from "./usage.ts";
|
|
32
32
|
import { getCloudApiKey } from "./utils.ts";
|
|
33
33
|
import { registerWebFetchTool, registerWebSearchTool } from "./web-tools.ts";
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
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 };
|
|
@@ -143,7 +169,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
143
169
|
}
|
|
144
170
|
try {
|
|
145
171
|
const data = await fetchUsage(apiKey);
|
|
146
|
-
|
|
172
|
+
let stats: UsageStats | undefined;
|
|
173
|
+
let statsError: string | undefined;
|
|
174
|
+
try {
|
|
175
|
+
stats = await fetchUsageStats(apiKey);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
statsError = err instanceof Error ? err.message : String(err);
|
|
178
|
+
}
|
|
179
|
+
ctx.ui.notify(formatUsage(data, stats), "info");
|
|
180
|
+
if (statsError) {
|
|
181
|
+
ctx.ui.notify(`Ollama Cloud request statistics unavailable: ${statsError}`, "warning");
|
|
182
|
+
}
|
|
147
183
|
} catch (err) {
|
|
148
184
|
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
149
185
|
}
|
|
@@ -152,35 +188,33 @@ export default async function (pi: ExtensionAPI) {
|
|
|
152
188
|
|
|
153
189
|
// --- Usage Status Bar ---
|
|
154
190
|
|
|
155
|
-
// Footer status showing live
|
|
191
|
+
// Footer status showing live quota while ollama-cloud is the
|
|
156
192
|
// active provider. Refreshes on a 5-minute timer; agent_end also triggers a
|
|
157
|
-
// refresh but is throttled to the same cooldown
|
|
158
|
-
//
|
|
193
|
+
// refresh but is throttled to the same cooldown. Quota comes from the
|
|
194
|
+
// /api/balance endpoint. The quota-bar concept is inspired by
|
|
159
195
|
// @entelligentsia/pi-ollama-cloud-usage-tracker.
|
|
160
196
|
const USAGE_STATUS_KEY = "ollama-usage";
|
|
161
197
|
const USAGE_REFRESH_MS = 5 * 60_000;
|
|
162
198
|
let usageTimer: ReturnType<typeof setInterval> | null = null;
|
|
163
199
|
let usageActive = false;
|
|
200
|
+
const usageRefreshGuard = createUsageStatusRefreshGuard();
|
|
164
201
|
// Timestamp (ms) of the most recent refresh attempt; gates the agent_end
|
|
165
202
|
// refresh so it fires at most once per cooldown. Set when a fetch starts, so
|
|
166
203
|
// a failing endpoint is also throttled, not just a successful one.
|
|
167
204
|
let lastRefreshAt = 0;
|
|
168
205
|
|
|
169
206
|
async function refreshUsageStatus(ctx: ExtensionContext) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
ctx
|
|
174
|
-
return;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// footer; clear the status and retry on the next refresh.
|
|
182
|
-
ctx.ui.setStatus(USAGE_STATUS_KEY, undefined);
|
|
183
|
-
}
|
|
207
|
+
await usageRefreshGuard.run(
|
|
208
|
+
() => usageActive && isOllamaCloud(ctx),
|
|
209
|
+
async (stillCurrent) => {
|
|
210
|
+
const apiKey = await getCloudApiKey(ctx);
|
|
211
|
+
if (!stillCurrent() || !apiKey) return undefined;
|
|
212
|
+
lastRefreshAt = Date.now();
|
|
213
|
+
const data = await fetchUsage(apiKey);
|
|
214
|
+
return formatUsageStatusColored(ctx.ui.theme, data);
|
|
215
|
+
},
|
|
216
|
+
(text) => ctx.ui.setStatus(USAGE_STATUS_KEY, text),
|
|
217
|
+
);
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
function startUsageStatus(ctx: ExtensionContext) {
|
|
@@ -193,6 +227,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
193
227
|
}
|
|
194
228
|
|
|
195
229
|
function stopUsageStatus(ctx: ExtensionContext) {
|
|
230
|
+
usageRefreshGuard.invalidate();
|
|
196
231
|
usageActive = false;
|
|
197
232
|
if (usageTimer) {
|
|
198
233
|
clearInterval(usageTimer);
|
|
@@ -215,7 +250,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
215
250
|
|
|
216
251
|
pi.on("agent_end", async (_event, ctx) => {
|
|
217
252
|
// Throttle the after-turn refresh to the same cooldown as the timer so a
|
|
218
|
-
// burst of turns never exceeds one /api/
|
|
253
|
+
// burst of turns never exceeds one /api/balance call per 5 minutes.
|
|
219
254
|
if (usageActive && isOllamaCloud(ctx) && Date.now() - lastRefreshAt >= USAGE_REFRESH_MS) {
|
|
220
255
|
await refreshUsageStatus(ctx);
|
|
221
256
|
}
|
package/limits.generated.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
// Auto-generated by scripts/generate-limits.ts
|
|
2
2
|
// Do not edit manually.
|
|
3
|
-
// Entries:
|
|
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
|
};
|
package/models.generated.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// Generated: 2026-09-27T00:52:07.773Z
|
|
4
4
|
// Model count: 17
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type { ChatModelConfig } from "./models.ts";
|
|
7
7
|
|
|
8
|
-
export const GENERATED_MODELS:
|
|
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
|
-
|
|
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
|
-
|
|
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():
|
|
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,
|
|
150
|
+
export function assembleModels(raw: Record<string, unknown>): ChatModelConfig[] {
|
|
142
151
|
return Object.entries(raw)
|
|
143
|
-
.
|
|
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
|
|
148
|
-
thinkingLevelMap: resolveThinkingLevelMap(id, data.capabilities
|
|
149
|
-
input: (data.capabilities
|
|
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<{
|
|
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
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
package/thinking-levels.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -1,197 +1,209 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ollama Cloud usage data plane: fetch and format /api/usage.
|
|
3
|
-
*
|
|
4
|
-
* Self-contained module. Depends on:
|
|
5
|
-
* - models.ts - only for OLLAMA_BASE URL constant
|
|
6
|
-
* - utils.ts - fetchJsonWithTimeout
|
|
7
|
-
* Does NOT depend on provider registration, model fetching, or API key
|
|
8
|
-
* resolution (the caller resolves the key and passes it in).
|
|
9
|
-
*
|
|
10
|
-
* The /api/usage endpoint is undocumented and could change or disappear. The
|
|
11
|
-
* fetch degrades gracefully: distinct HTTP statuses map to distinct
|
|
12
|
-
* user-facing errors, and a malformed body raises a clear error rather than
|
|
13
|
-
* crashing.
|
|
14
|
-
*
|
|
15
|
-
* The response shape has flipped twice: through 2026-09-02 it carried
|
|
16
|
-
* limits.session and limits.weekly, on 2026-09-03 it switched to a single
|
|
17
|
-
* limits.monthly bucket (0.10.0 adapted to that), and by 2026-09-07 it
|
|
18
|
-
* returned session and weekly again. All three buckets are therefore optional
|
|
19
|
-
* and whichever are present are displayed.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
23
2
|
import { OLLAMA_BASE } from "./models.ts";
|
|
24
3
|
import { fetchJsonWithTimeout, httpError } from "./utils.ts";
|
|
25
4
|
|
|
26
|
-
|
|
5
|
+
export interface BalanceWindow {
|
|
6
|
+
remaining_percent: number;
|
|
7
|
+
resets_at?: string;
|
|
8
|
+
}
|
|
27
9
|
|
|
28
|
-
export interface
|
|
29
|
-
|
|
30
|
-
|
|
10
|
+
export interface UsageData {
|
|
11
|
+
included?: {
|
|
12
|
+
session?: BalanceWindow;
|
|
13
|
+
weekly?: BalanceWindow;
|
|
14
|
+
monthly?: BalanceWindow;
|
|
15
|
+
balance_usd?: number;
|
|
16
|
+
allowance_usd?: number;
|
|
17
|
+
period?: { from?: string; until?: string };
|
|
18
|
+
};
|
|
19
|
+
purchased?: { balance_usd?: number };
|
|
31
20
|
}
|
|
32
21
|
|
|
33
|
-
export interface
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
export interface UsageBucket {
|
|
23
|
+
from?: string;
|
|
24
|
+
until?: string;
|
|
25
|
+
request_count: number;
|
|
26
|
+
partial?: boolean;
|
|
27
|
+
usage_usd?: number;
|
|
28
|
+
input_tokens?: number;
|
|
29
|
+
cached_input_tokens?: number;
|
|
30
|
+
output_tokens?: number;
|
|
38
31
|
}
|
|
39
32
|
|
|
40
|
-
export interface
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
33
|
+
export interface UsageStats {
|
|
34
|
+
range?: "24h" | "7d" | "30d";
|
|
35
|
+
totals: {
|
|
36
|
+
request_count: number;
|
|
37
|
+
usage_usd?: number;
|
|
38
|
+
input_tokens?: number;
|
|
39
|
+
cached_input_tokens?: number;
|
|
40
|
+
output_tokens?: number;
|
|
46
41
|
};
|
|
47
|
-
|
|
42
|
+
buckets?: UsageBucket[];
|
|
48
43
|
}
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/** Session (5h) bucket, served before 2026-09-03 and again as of 2026-09-07. */
|
|
55
|
-
session?: UsageLimit;
|
|
56
|
-
/** Weekly (7d) bucket, served before 2026-09-03 and again as of 2026-09-07. */
|
|
57
|
-
weekly?: UsageLimit;
|
|
58
|
-
};
|
|
59
|
-
activity?: UsageActivity;
|
|
45
|
+
const USAGE_TIMEOUT_MS = 10000;
|
|
46
|
+
|
|
47
|
+
function isObject(data: unknown): data is Record<string, unknown> {
|
|
48
|
+
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
60
49
|
}
|
|
61
50
|
|
|
62
|
-
|
|
51
|
+
export function isBalanceWindow(data: unknown): data is BalanceWindow {
|
|
52
|
+
if (!isObject(data) || typeof data.remaining_percent !== "number" || !Number.isFinite(data.remaining_percent)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return data.resets_at === undefined || typeof data.resets_at === "string";
|
|
56
|
+
}
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
function isPurchased(data: unknown): boolean {
|
|
59
|
+
return isObject(data) && typeof data.balance_usd === "number" && Number.isFinite(data.balance_usd);
|
|
60
|
+
}
|
|
65
61
|
|
|
66
|
-
|
|
62
|
+
function isIncluded(data: unknown): data is NonNullable<UsageData["included"]> {
|
|
63
|
+
if (!isObject(data)) return false;
|
|
64
|
+
const windows = [data.session, data.weekly, data.monthly];
|
|
65
|
+
if (windows.some((window) => window !== undefined && !isBalanceWindow(window))) return false;
|
|
66
|
+
|
|
67
|
+
const balance = data.balance_usd;
|
|
68
|
+
const allowance = data.allowance_usd;
|
|
69
|
+
if (balance !== undefined && (typeof balance !== "number" || !Number.isFinite(balance))) return false;
|
|
70
|
+
if (allowance !== undefined && (typeof allowance !== "number" || !Number.isFinite(allowance))) return false;
|
|
71
|
+
if ((balance === undefined) !== (allowance === undefined)) return false;
|
|
72
|
+
|
|
73
|
+
if (data.period !== undefined) {
|
|
74
|
+
if (!isObject(data.period)) return false;
|
|
75
|
+
if (data.period.from !== undefined && typeof data.period.from !== "string") return false;
|
|
76
|
+
if (data.period.until !== undefined && typeof data.period.until !== "string") return false;
|
|
77
|
+
}
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
export function isUsageLimit(data: unknown): data is UsageLimit {
|
|
70
|
-
if (data == null || typeof data !== "object") return false;
|
|
71
|
-
const d = data as UsageLimit;
|
|
72
|
-
return (
|
|
73
|
-
typeof d.usage === "number" &&
|
|
74
|
-
Array.isArray(d.models) &&
|
|
75
|
-
d.models.every(
|
|
76
|
-
(m) =>
|
|
77
|
-
m != null &&
|
|
78
|
-
typeof m === "object" &&
|
|
79
|
-
typeof (m as UsageModel).name === "string" &&
|
|
80
|
-
typeof (m as UsageModel).request_count === "number",
|
|
81
|
-
)
|
|
82
|
-
);
|
|
79
|
+
return true;
|
|
83
80
|
}
|
|
84
81
|
|
|
85
|
-
/**
|
|
86
|
-
* Validate a parsed /api/usage response: needs at least one valid limit bucket.
|
|
87
|
-
* The endpoint is undocumented and flips shape unpredictably (monthly-only,
|
|
88
|
-
* session+weekly, possibly other combinations), so any bucket present alone or
|
|
89
|
-
* in any combination is accepted and rendered.
|
|
90
|
-
*/
|
|
91
82
|
export function isUsageResponse(data: unknown): data is UsageData {
|
|
92
|
-
if (data
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
83
|
+
if (!isObject(data)) return false;
|
|
84
|
+
const included = data.included;
|
|
85
|
+
const hasPurchased = isPurchased(data.purchased);
|
|
86
|
+
if (data.purchased !== undefined && !hasPurchased) return false;
|
|
87
|
+
if (included === undefined) return hasPurchased;
|
|
88
|
+
if (!isIncluded(included)) return false;
|
|
89
|
+
|
|
90
|
+
const hasWindow = [included.session, included.weekly, included.monthly].some(isBalanceWindow);
|
|
91
|
+
const hasAllowance = typeof included.balance_usd === "number" && typeof included.allowance_usd === "number";
|
|
92
|
+
return hasWindow || hasAllowance || hasPurchased;
|
|
97
93
|
}
|
|
98
94
|
|
|
99
|
-
|
|
95
|
+
export function isUsageStats(data: unknown): data is UsageStats {
|
|
96
|
+
if (!isObject(data) || !isObject(data.totals) || typeof data.totals.request_count !== "number") return false;
|
|
97
|
+
if (data.buckets === undefined) return true;
|
|
98
|
+
return (
|
|
99
|
+
Array.isArray(data.buckets) &&
|
|
100
|
+
data.buckets.every((bucket) => isObject(bucket) && typeof bucket.request_count === "number")
|
|
101
|
+
);
|
|
102
|
+
}
|
|
100
103
|
|
|
101
|
-
/**
|
|
102
|
-
* Fetch Ollama Cloud usage from the undocumented /api/usage endpoint.
|
|
103
|
-
* The caller resolves the API key and passes it in.
|
|
104
|
-
*/
|
|
105
104
|
export async function fetchUsage(apiKey: string, externalSignal?: AbortSignal): Promise<UsageData> {
|
|
106
105
|
const res = await fetchJsonWithTimeout<UsageData>(
|
|
107
|
-
`${OLLAMA_BASE}/api/
|
|
108
|
-
{
|
|
109
|
-
method: "GET",
|
|
110
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
111
|
-
},
|
|
106
|
+
`${OLLAMA_BASE}/api/balance`,
|
|
107
|
+
{ method: "GET", headers: { Authorization: `Bearer ${apiKey}` } },
|
|
112
108
|
USAGE_TIMEOUT_MS,
|
|
113
109
|
externalSignal,
|
|
114
110
|
);
|
|
115
|
-
|
|
116
111
|
if (!res.ok) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
if (res.status === 0) {
|
|
113
|
+
throw new Error(`Ollama Cloud balance failed: transport error (${res.error ?? "unknown"}). Try again shortly.`);
|
|
114
|
+
}
|
|
120
115
|
if (res.status === 404) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
"Ollama Cloud usage failed: the /api/usage endpoint is unavailable (status 404). " +
|
|
123
|
-
"It is undocumented and may have changed.",
|
|
124
|
-
);
|
|
116
|
+
throw new Error("Ollama Cloud balance failed: the /api/balance endpoint is unavailable (status 404).");
|
|
125
117
|
}
|
|
126
|
-
httpError("
|
|
127
|
-
}
|
|
128
|
-
if (!isUsageResponse(res.data)) {
|
|
129
|
-
throw new Error("Ollama Cloud usage failed: unexpected response shape from the API.");
|
|
118
|
+
httpError("balance", res.status, res.error);
|
|
130
119
|
}
|
|
120
|
+
if (!isUsageResponse(res.data))
|
|
121
|
+
throw new Error("Ollama Cloud balance failed: unexpected response shape from the API.");
|
|
131
122
|
return res.data;
|
|
132
123
|
}
|
|
133
124
|
|
|
134
|
-
|
|
125
|
+
export async function fetchUsageStats(
|
|
126
|
+
apiKey: string,
|
|
127
|
+
range: "24h" | "7d" | "30d" = "7d",
|
|
128
|
+
externalSignal?: AbortSignal,
|
|
129
|
+
): Promise<UsageStats> {
|
|
130
|
+
const res = await fetchJsonWithTimeout<UsageStats>(
|
|
131
|
+
`${OLLAMA_BASE}/api/usage?range=${range}`,
|
|
132
|
+
{ method: "GET", headers: { Authorization: `Bearer ${apiKey}` } },
|
|
133
|
+
USAGE_TIMEOUT_MS,
|
|
134
|
+
externalSignal,
|
|
135
|
+
);
|
|
136
|
+
if (!res.ok) httpError("usage stats", res.status, res.error);
|
|
137
|
+
if (!isUsageStats(res.data))
|
|
138
|
+
throw new Error("Ollama Cloud usage stats failed: unexpected response shape from the API.");
|
|
139
|
+
return res.data;
|
|
140
|
+
}
|
|
135
141
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return Math.min(Math.max(Math.round(usage * 100), 0), 100);
|
|
142
|
+
function usedPercent(remaining: number): number {
|
|
143
|
+
if (!Number.isFinite(remaining)) return 100;
|
|
144
|
+
return Math.min(Math.max(Math.round(100 - remaining), 0), 100);
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
function limitSegments(data: UsageData): Array<{ short: string; used: number }> {
|
|
148
|
+
const included = data.included;
|
|
149
|
+
if (!included) return [];
|
|
150
|
+
const segments: Array<{ short: string; used: number }> = [];
|
|
151
|
+
for (const [key, short] of [
|
|
152
|
+
["session", "5h"],
|
|
153
|
+
["weekly", "7d"],
|
|
154
|
+
["monthly", "30d"],
|
|
155
|
+
] as const) {
|
|
156
|
+
const window = included[key];
|
|
157
|
+
if (window && isBalanceWindow(window)) segments.push({ short, used: usedPercent(window.remaining_percent) });
|
|
150
158
|
}
|
|
151
|
-
if (
|
|
152
|
-
|
|
159
|
+
if (
|
|
160
|
+
segments.length === 0 &&
|
|
161
|
+
typeof included.balance_usd === "number" &&
|
|
162
|
+
typeof included.allowance_usd === "number" &&
|
|
163
|
+
included.allowance_usd > 0
|
|
164
|
+
) {
|
|
165
|
+
const used = (1 - included.balance_usd / included.allowance_usd) * 100;
|
|
166
|
+
segments.push({ short: "plan", used: Math.min(Math.max(Math.round(used), 0), 100) });
|
|
153
167
|
}
|
|
154
|
-
return
|
|
168
|
+
return segments;
|
|
155
169
|
}
|
|
156
170
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
lines.push(
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
export function formatUsage(data: UsageData, stats?: UsageStats): string {
|
|
172
|
+
const lines = ["Ollama Cloud usage:"];
|
|
173
|
+
for (const segment of limitSegments(data)) lines.push(` ${segment.short}: ${segment.used}% used`);
|
|
174
|
+
const included = data.included;
|
|
175
|
+
if (included && typeof included.balance_usd === "number" && typeof included.allowance_usd === "number") {
|
|
176
|
+
lines.push(
|
|
177
|
+
` Included balance: $${included.balance_usd.toFixed(2)} / $${included.allowance_usd.toFixed(2)} remaining`,
|
|
178
|
+
);
|
|
179
|
+
if (included.period?.from || included.period?.until) {
|
|
180
|
+
lines.push(` Included allowance period: ${included.period.from ?? "?"} to ${included.period.until ?? "?"}`);
|
|
181
|
+
if (included.period.until) lines.push(` Included allowance renews: ${included.period.until}`);
|
|
165
182
|
}
|
|
166
183
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
184
|
+
if (data.purchased?.balance_usd && data.purchased.balance_usd > 0) {
|
|
185
|
+
lines.push(` Purchased credits: $${data.purchased.balance_usd.toFixed(2)}`);
|
|
186
|
+
}
|
|
187
|
+
if (stats) {
|
|
188
|
+
lines.push(` Requests (${stats.range ?? "7d"}): ${stats.totals.request_count.toLocaleString()}`);
|
|
189
|
+
const current = stats.buckets?.at(-1);
|
|
190
|
+
if (current?.partial) lines.push(` - current period: ${current.request_count.toLocaleString()}`);
|
|
170
191
|
}
|
|
171
|
-
|
|
172
192
|
return lines.join("\n");
|
|
173
193
|
}
|
|
174
194
|
|
|
175
|
-
/** Render a 10-character quota bar for a 0-100 percentage. */
|
|
176
195
|
function quotaBar(pct: number): string {
|
|
177
196
|
const filled = Math.min(Math.max(Math.floor(pct / 10), 0), 10);
|
|
178
197
|
return `▕${"█".repeat(filled)}${"░".repeat(10 - filled)}▏`;
|
|
179
198
|
}
|
|
180
199
|
|
|
181
|
-
/** Color a single usage segment by how close it is to the cap. */
|
|
182
200
|
function colorSegment(theme: Theme, label: string, pct: number): string {
|
|
183
201
|
const color = pct >= 80 ? "error" : pct >= 60 ? "warning" : "success";
|
|
184
202
|
return theme.fg(color, `${label} ${quotaBar(pct)} ${pct}%`);
|
|
185
203
|
}
|
|
186
204
|
|
|
187
|
-
/**
|
|
188
|
-
* Compact one-line usage for the footer status bar, colored by usage level,
|
|
189
|
-
* with one segment per limit bucket present in the response (5h and 7d, or
|
|
190
|
-
* 30d when the API serves the monthly shape). The color reflects the usage
|
|
191
|
-
* fraction rather than pace because the bucket periods differ per shape.
|
|
192
|
-
*/
|
|
193
205
|
export function formatUsageStatusColored(theme: Theme, data: UsageData): string {
|
|
194
206
|
return limitSegments(data)
|
|
195
|
-
.map((
|
|
207
|
+
.map((segment) => colorSegment(theme, segment.short, segment.used))
|
|
196
208
|
.join(" ");
|
|
197
209
|
}
|
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(
|
|
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 = {
|
|
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
|