pi-ollama-cloud 0.12.3 → 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 +4 -0
- package/README.md +16 -13
- package/index.ts +16 -6
- package/package.json +1 -1
- package/usage.ts +145 -136
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
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
|
+
|
|
5
9
|
## [0.12.3] - 2026-10-06
|
|
6
10
|
|
|
7
11
|
- Keep the usage indicator accurate when turning it off or switching providers, and show clearer errors for connection problems and invalid responses.
|
package/README.md
CHANGED
|
@@ -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
|
|
package/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ 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
|
|
|
@@ -169,7 +169,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
169
169
|
}
|
|
170
170
|
try {
|
|
171
171
|
const data = await fetchUsage(apiKey);
|
|
172
|
-
|
|
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
|
+
}
|
|
173
183
|
} catch (err) {
|
|
174
184
|
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
175
185
|
}
|
|
@@ -178,10 +188,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
178
188
|
|
|
179
189
|
// --- Usage Status Bar ---
|
|
180
190
|
|
|
181
|
-
// Footer status showing live
|
|
191
|
+
// Footer status showing live quota while ollama-cloud is the
|
|
182
192
|
// active provider. Refreshes on a 5-minute timer; agent_end also triggers a
|
|
183
|
-
// refresh but is throttled to the same cooldown
|
|
184
|
-
//
|
|
193
|
+
// refresh but is throttled to the same cooldown. Quota comes from the
|
|
194
|
+
// /api/balance endpoint. The quota-bar concept is inspired by
|
|
185
195
|
// @entelligentsia/pi-ollama-cloud-usage-tracker.
|
|
186
196
|
const USAGE_STATUS_KEY = "ollama-usage";
|
|
187
197
|
const USAGE_REFRESH_MS = 5 * 60_000;
|
|
@@ -240,7 +250,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
240
250
|
|
|
241
251
|
pi.on("agent_end", async (_event, ctx) => {
|
|
242
252
|
// Throttle the after-turn refresh to the same cooldown as the timer so a
|
|
243
|
-
// burst of turns never exceeds one /api/
|
|
253
|
+
// burst of turns never exceeds one /api/balance call per 5 minutes.
|
|
244
254
|
if (usageActive && isOllamaCloud(ctx) && Date.now() - lastRefreshAt >= USAGE_REFRESH_MS) {
|
|
245
255
|
await refreshUsageStatus(ctx);
|
|
246
256
|
}
|
package/package.json
CHANGED
package/usage.ts
CHANGED
|
@@ -1,200 +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
112
|
if (res.status === 0) {
|
|
118
|
-
throw new Error(`Ollama Cloud
|
|
113
|
+
throw new Error(`Ollama Cloud balance failed: transport error (${res.error ?? "unknown"}). Try again shortly.`);
|
|
119
114
|
}
|
|
120
|
-
// The 404 case is specific to this undocumented endpoint: it may have
|
|
121
|
-
// changed or disappeared, so surface that distinctly before the shared
|
|
122
|
-
// status mapping.
|
|
123
115
|
if (res.status === 404) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
"Ollama Cloud usage failed: the /api/usage endpoint is unavailable (status 404). " +
|
|
126
|
-
"It is undocumented and may have changed.",
|
|
127
|
-
);
|
|
116
|
+
throw new Error("Ollama Cloud balance failed: the /api/balance endpoint is unavailable (status 404).");
|
|
128
117
|
}
|
|
129
|
-
httpError("
|
|
130
|
-
}
|
|
131
|
-
if (!isUsageResponse(res.data)) {
|
|
132
|
-
throw new Error("Ollama Cloud usage failed: unexpected response shape from the API.");
|
|
118
|
+
httpError("balance", res.status, res.error);
|
|
133
119
|
}
|
|
120
|
+
if (!isUsageResponse(res.data))
|
|
121
|
+
throw new Error("Ollama Cloud balance failed: unexpected response shape from the API.");
|
|
134
122
|
return res.data;
|
|
135
123
|
}
|
|
136
124
|
|
|
137
|
-
|
|
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
|
+
}
|
|
138
141
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
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);
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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) });
|
|
153
158
|
}
|
|
154
|
-
if (
|
|
155
|
-
|
|
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) });
|
|
156
167
|
}
|
|
157
|
-
return
|
|
168
|
+
return segments;
|
|
158
169
|
}
|
|
159
170
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
lines.push(
|
|
166
|
-
|
|
167
|
-
|
|
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}`);
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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()}`);
|
|
173
191
|
}
|
|
174
|
-
|
|
175
192
|
return lines.join("\n");
|
|
176
193
|
}
|
|
177
194
|
|
|
178
|
-
/** Render a 10-character quota bar for a 0-100 percentage. */
|
|
179
195
|
function quotaBar(pct: number): string {
|
|
180
196
|
const filled = Math.min(Math.max(Math.floor(pct / 10), 0), 10);
|
|
181
197
|
return `▕${"█".repeat(filled)}${"░".repeat(10 - filled)}▏`;
|
|
182
198
|
}
|
|
183
199
|
|
|
184
|
-
/** Color a single usage segment by how close it is to the cap. */
|
|
185
200
|
function colorSegment(theme: Theme, label: string, pct: number): string {
|
|
186
201
|
const color = pct >= 80 ? "error" : pct >= 60 ? "warning" : "success";
|
|
187
202
|
return theme.fg(color, `${label} ${quotaBar(pct)} ${pct}%`);
|
|
188
203
|
}
|
|
189
204
|
|
|
190
|
-
/**
|
|
191
|
-
* Compact one-line usage for the footer status bar, colored by usage level,
|
|
192
|
-
* with one segment per limit bucket present in the response (5h and 7d, or
|
|
193
|
-
* 30d when the API serves the monthly shape). The color reflects the usage
|
|
194
|
-
* fraction rather than pace because the bucket periods differ per shape.
|
|
195
|
-
*/
|
|
196
205
|
export function formatUsageStatusColored(theme: Theme, data: UsageData): string {
|
|
197
206
|
return limitSegments(data)
|
|
198
|
-
.map((
|
|
207
|
+
.map((segment) => colorSegment(theme, segment.short, segment.used))
|
|
199
208
|
.join(" ");
|
|
200
209
|
}
|