@cad0p/pi-usage 0.11.6-20261107.0 → 0.11.6

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/README.md CHANGED
@@ -6,7 +6,6 @@ One source of truth for quota: **the credentials Pi already owns**.
6
6
  > `@cad0p/pi-usage` is a fork of [J3pf/piQuota](https://github.com/J3pf/piQuota) (`@j3pf/piquota`). It adds:
7
7
  >
8
8
  > * **OpenCode Go from the API key**: `GET https://opencode.ai/zen/go/v1/usage` returns the rolling (5h), weekly and monthly windows for the key Pi already stores, so no browser session is needed.
9
- > * **Mistral from the API key**: `GET https://api.mistral.ai/v1/billing/budget` and `/v1/billing/subscription`, the two reads the Vibe CLI makes for its usage gauge, return the monthly Vibe Code and API budgets and the plan for the key Pi already stores (`mistral`, and siblings such as `mistral-1-api`). They are read-only and cost no tokens.
10
9
  > * **Numbered accounts**: `auth.json` entries such as `opencode-go-2` … `opencode-go-14` (the [pi-fallback-provider](https://github.com/cad0p/pi-fallback-provider) sibling convention) are read as extra accounts of their base provider. The accounts of one provider are queried in parallel (8 at a time, `PI_QUOTA_ACCOUNT_CONCURRENCY` from 1 to 32), so 14 OpenCode Go keys cost about one round trip instead of fourteen. Every request carries `User-Agent: pi-usage/<version> (+https://github.com/cad0p/pi-usage)` unless a provider needs its own client string: OpenCode Go asks clients not to use a generic HTTP-library name, and Node's default is `node`.
11
10
  > * **One Omarchy tab per provider**: a multi-account provider shows each account's binding window (`#2 · Monthly`) in a single tab, and summarises failed accounts.
12
11
  > * **Tokens by day and by model**: the tokens Pi's own session files recorded are folded per provider and shown in `/usage`, in `--json` (`localStats`) and in the Omarchy records, at a bounded scan cost.
@@ -84,7 +83,6 @@ shuvquota, and it never writes to a credential file.
84
83
  | Codex | `openai-codex.access` + `accountId` | `GET chatgpt.com/backend-api/wham/usage` | ✅ 5h + weekly + plan |
85
84
  | Antigravity | `google-antigravity.access` (or legacy `antigravity`) + `projectId` | `POST cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` | ✅ Gemini + Claude/GPT buckets, **with in-memory refresh** |
86
85
  | OpenCode Go | `opencode-go.key`, numbered siblings included | `GET opencode.ai/zen/go/v1/usage` | ✅ 5h + weekly + monthly per account; the session cookie and SSR dashboard are a base-account fallback |
87
- | Mistral | `mistral.key`, numbered siblings included | `GET api.mistral.ai/v1/billing/budget` + `/v1/billing/subscription` | ✅ Vibe monthly + API monthly + plan per key; without the `see_org_inference_usage` permission the plan alone, with a note. Keys of one organisation share its budgets, so they show the same numbers |
88
86
  | GitHub Actions (opt-in) | Authenticated `gh` CLI | `gh api /organizations/<org>/settings/billing/usage/summary?product=Actions` | ✅ org-wide monthly Linux minutes, plan-derived allowance |
89
87
 
90
88
  Four findings worth recording, each of which cost a wrong hypothesis:
@@ -386,8 +384,7 @@ right rail instead, under Status and TODO, and the above-editor box stays quiet
386
384
  the numbers are not shown twice. See [the rail slot](#the-gentle-pi-rail-slot).
387
385
 
388
386
  * each name is painted with its **own brand colour** — Claude `#D97757`, Codex
389
- `#10A37F`, Antigravity `#4285F4`, OpenCode `#007AFF`, Mistral `#FA520F` (its row,
390
- the Vibe budget, appears only when a Mistral key is configured);
387
+ `#10A37F`, Antigravity `#4285F4`, OpenCode `#007AFF`;
391
388
  * the numbers are **used**, not remaining, and the semaphore is both colour and
392
389
  shape (`○` plenty left → `◔` → `◕` → `●` nearly spent), so it still reads on a
393
390
  colourblind or mono terminal;
@@ -530,10 +527,6 @@ Rp suite) without external network daemons or closed-source intermediaries:
530
527
  }
531
528
  ```
532
529
 
533
- A provider may also carry a top-level `note` when it answered but has no windows
534
- to show, such as a Mistral key without the `see_org_inference_usage` permission
535
- (`"ok": true`, `"windows": []`, the plan, and the reason in `note`).
536
-
537
530
  The token part of the same report is abridged here (`recentDays` always carries
538
531
  seven entries, oldest first, today last):
539
532
 
@@ -570,7 +563,7 @@ stale without triggering rate limits:
570
563
  | Family | Clock |
571
564
  | --- | --- |
572
565
  | Claude | **300 s** (`--claude-ttl`) |
573
- | Codex, Antigravity, OpenCode Go, Mistral | **60 s** (`--fetch-ttl`) |
566
+ | Codex, Antigravity, OpenCode Go | **60 s** (`--fetch-ttl`) |
574
567
 
575
568
  `src/refresh.js` keeps a timestamp per family, requests only what is past its own
576
569
  clock, and merges the result back into the canonical report. `src/sticky.js`
@@ -583,7 +576,7 @@ credential) are never masked.
583
576
  Omarchy 4.0.4's **Agents** bar panel draws every `*.json` record in
584
577
  `${XDG_STATE_HOME:-~/.local/state}/omarchy/agents/usage/`, whoever wrote it.
585
578
  `piquota omarchy` (opt-in, one shot) writes one record per provider piQuota can
586
- read, so Claude, Codex, Antigravity, OpenCode Go and Mistral all get a tab:
579
+ read, so Claude, Codex, Antigravity and OpenCode Go all get a tab:
587
580
 
588
581
  ```bash
589
582
  piquota omarchy # write pi-claude.json, pi-codex.json, ... once
@@ -706,7 +699,7 @@ What counts: one assistant message with a non-zero token count is one prompt,
706
699
  and its tokens are `input + output + cacheRead + cacheWrite` — the same sum the
707
700
  Omarchy panel renders. Messages are bucketed by **local** date (the panel calls
708
701
  the last row "Today"). Only providers piQuota can bill are counted:
709
- `anthropic`, `openai-codex`, `antigravity`, `opencode-go`, `mistral` and their numbered
702
+ `anthropic`, `openai-codex`, `antigravity`, `opencode-go` and their numbered
710
703
  siblings, so `opencode-go-7` is account `#7`. Providers it does not manage
711
704
  (`deepinfra`, `xai`, ...) are ignored, and so is the custom `claude-bridge`
712
705
  provider: its tokens are not a Pi subscription.
@@ -875,13 +868,12 @@ extensions/quota-panel.ts Pi TUI line and /usage
875
868
  ## Tests
876
869
 
877
870
  ```bash
878
- node --test tests/*.test.mjs # 476 tests, fake tokens only, no network
871
+ node --test tests/*.test.mjs # 455 tests, fake tokens only, no network
879
872
  ```
880
873
 
881
874
  Modules covered: `auth.json` parsing and de-duplication, the Claude Code store
882
875
  (including that the refresh token never leaves it and that reading leaves the file
883
- byte-identical), Claude source precedence, the six providers (including Mistral's two budgets,
884
- the missing usage permission and a rejected key, GitHub
876
+ byte-identical), Claude source precedence, the five providers (including GitHub
885
877
  Actions plan resolution, opt-in gating and degradation, the two Antigravity
886
878
  failures and the OpenCode degradation), the dashboard parser's three
887
879
  strategies, the Firefox cookie reader against a synthetic SQLite database, the
package/bin/piquota.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * piquota — independent, read-only quota for the credentials Pi already owns.
4
4
  *
5
- * piquota panel for Claude, Codex, Antigravity, OpenCode Go, Mistral
5
+ * piquota panel for Claude, Codex, Antigravity, OpenCode Go
6
6
  * piquota --json normalized report (used by rp-host / tools)
7
7
  * piquota --compact | --status | --explain
8
8
  * piquota auth opencode open the OpenCode login and capture the session
@@ -187,7 +187,6 @@ const BRAND: Record<string, string> = {
187
187
  codex: "#10A37F",
188
188
  antigravity: "#4285F4",
189
189
  "opencode-go": "#007AFF",
190
- mistral: "#FA520F",
191
190
  "github-actions": "#2088FF",
192
191
  };
193
192
 
@@ -197,7 +196,6 @@ const SHORT_NAME: Record<string, string> = {
197
196
  codex: "Codex",
198
197
  antigravity: "Agy",
199
198
  "opencode-go": "OP-Go",
200
- mistral: "Mistral",
201
199
  "github-actions": "GH",
202
200
  };
203
201
 
@@ -471,9 +469,6 @@ export function renderBox(report: QuotaReport, theme: Theme, totalWidth?: number
471
469
  { label: "Agy C/G:", color: "#7AA2F7", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
472
470
  { label: "OP-Go: ", color: "#007AFF", ...windowFor("opencode-go", isFiveHour) },
473
471
  ];
474
- if (providerFor("mistral") && providerFor("mistral")?.notConfigured !== true) {
475
- rows.push({ label: "Mistral:", color: "#FA520F", ...windowFor("mistral", (window) => window.id === "vibe") });
476
- }
477
472
  if (providerFor("github-actions")) {
478
473
  rows.push({ label: "GH Actions:", color: "#2088FF", ...windowFor("github-actions", (window) => window.id === "monthly") });
479
474
  }
@@ -523,9 +518,6 @@ function renderSidebarCard(report: QuotaReport | null, theme: Theme, width: numb
523
518
  { label: "Agy: ", color: "#4285F4", ...windowFor("antigravity", (window) => window.id === "gemini-5h" || (/gemini/i.test(window.id) && isFiveHour(window))) },
524
519
  { label: "Agy C/G:", color: "#7AA2F7", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
525
520
  { label: "OP-Go: ", color: "#007AFF", ...windowFor("opencode-go", isFiveHour) },
526
- ...(providerFor("mistral") && providerFor("mistral")?.notConfigured !== true
527
- ? [{ label: "Mistral:", color: "#FA520F", ...windowFor("mistral", (window) => window.id === "vibe") }]
528
- : []),
529
521
  ...(providerFor("github-actions")
530
522
  ? [{ label: "GH Actions:", color: "#2088FF", ...windowFor("github-actions", (window) => window.id === "monthly") }]
531
523
  : []),
@@ -1138,7 +1130,6 @@ const FAMILY_LABEL: Record<string, string> = {
1138
1130
  codex: "Codex (Pi)",
1139
1131
  antigravity: "Antigravity (Pi)",
1140
1132
  "opencode-go": "OpenCode Go (Pi)",
1141
- mistral: "Mistral (Pi)",
1142
1133
  "github-actions": "GitHub Actions",
1143
1134
  };
1144
1135
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@cad0p/pi-usage",
3
- "version": "0.11.6-20261107.0",
3
+ "version": "0.11.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "type": "module",
8
- "description": "Read-only usage/quota for the AI subscriptions Pi already owns (Claude, Codex, multi-account OpenCode Go, Antigravity, Mistral): /usage in Pi and the Omarchy Agents panel. Fork of @j3pf/piquota.",
8
+ "description": "Read-only usage/quota for the AI subscriptions Pi already owns (Claude, Codex, multi-account OpenCode Go, Antigravity): /usage in Pi and the Omarchy Agents panel. Fork of @j3pf/piquota.",
9
9
  "engines": {
10
10
  "node": ">=22.19.0"
11
11
  },
@@ -34,7 +34,6 @@
34
34
  "usage",
35
35
  "omarchy",
36
36
  "opencode-go",
37
- "mistral",
38
37
  "multi-account"
39
38
  ],
40
39
  "repository": {
@@ -28,7 +28,6 @@ export const FAMILY_BY_PROVIDER = {
28
28
  // kept for stores written by older Pi releases and third-party plugins.
29
29
  "google-antigravity": "antigravity",
30
30
  "opencode-go": "opencode-go",
31
- mistral: "mistral",
32
31
  };
33
32
 
34
33
  /** Display label used by every surface (terminal, Pi TUI, Moshi cards). */
@@ -37,7 +36,6 @@ export const LABEL_BY_FAMILY = {
37
36
  claude: "Claude (Pi)",
38
37
  antigravity: "Antigravity (Pi)",
39
38
  "opencode-go": "OpenCode Go (Pi)",
40
- mistral: "Mistral (Pi)",
41
39
  "github-actions": "GitHub Actions",
42
40
  };
43
41
 
@@ -22,8 +22,6 @@ export const FAMILY_ALIASES = {
22
22
  opencode: "opencode-go",
23
23
  go: "opencode-go",
24
24
  zen: "opencode-go",
25
- mistral: "mistral",
26
- vibe: "mistral",
27
25
  "github-actions": "github-actions",
28
26
  gh: "github-actions",
29
27
  actions: "github-actions",
package/src/engine.js CHANGED
@@ -16,11 +16,10 @@ import { fetchQuota as fetchClaude } from "./providers/claude.js";
16
16
  import { fetchQuota as fetchCodex } from "./providers/codex.js";
17
17
  import { fetchQuota as fetchAntigravity } from "./providers/antigravity.js";
18
18
  import { fetchQuota as fetchOpenCodeGo } from "./providers/opencode-go.js";
19
- import { fetchQuota as fetchMistral } from "./providers/mistral.js";
20
19
  import { fetchQuota as fetchGithubActions } from "./providers/github-actions.js";
21
20
 
22
21
  /** Canonical family order used by every surface. */
23
- export const FAMILIES = ["claude", "codex", "antigravity", "opencode-go", "mistral", "github-actions"];
22
+ export const FAMILIES = ["claude", "codex", "antigravity", "opencode-go", "github-actions"];
24
23
 
25
24
  /**
26
25
  * Return the ordered families enabled for a run.
@@ -197,7 +196,6 @@ const PROVIDERS = {
197
196
  codex: fetchCodex,
198
197
  antigravity: fetchAntigravity,
199
198
  "opencode-go": fetchOpenCodeGo,
200
- mistral: fetchMistral,
201
199
  "github-actions": fetchGithubActions,
202
200
  };
203
201
 
@@ -38,7 +38,6 @@ const LOGIN_HINT = {
38
38
  codex: "Run `/login openai-codex` in Pi to restore usage.",
39
39
  antigravity: "Run `/login antigravity` in Pi to restore usage.",
40
40
  "opencode-go": "Run `piquota auth opencode` to restore usage.",
41
- mistral: "Run `/login mistral` in Pi to restore usage.",
42
41
  };
43
42
 
44
43
  const HELP_MAX = 200;
@@ -119,11 +118,9 @@ function mapLimits(windows, provider, prefix = "") {
119
118
  for (const window of windows ?? []) {
120
119
  // A window without a percentage would otherwise read as "0% used".
121
120
  if (typeof window.usedPercent !== "number" || !Number.isFinite(window.usedPercent)) continue;
122
- // Spending past the allowance changes what 100% means, so the title says so.
123
- const payg = typeof window.note === "string" && /pay-as-you-go/i.test(window.note) ? " · pay-as-you-go" : "";
124
121
  limits.push({
125
122
  label: redact(`${prefix}${window.label || window.id || "Limit"}`),
126
- title: redact(`${prefix}${windowTitle(window, provider)}${payg}`),
123
+ title: redact(`${prefix}${windowTitle(window, provider)}`),
127
124
  percent: Math.min(1, Math.max(0, window.usedPercent / 100)),
128
125
  resetsAt: typeof window.resetsAt === "string" ? window.resetsAt : "",
129
126
  });
@@ -387,11 +384,7 @@ function buildFamilyRecord(family, providers, options = {}) {
387
384
  const window = bindingWindow(provider.windows);
388
385
  const mapped = window ? mapLimits([window], provider, label === "" ? "" : `${label} · `)[0] : null;
389
386
  if (mapped) limits.push(mapped);
390
- const accountLimits = mapLimits(provider.windows, provider);
391
- // An account that answered without windows (Mistral without the usage
392
- // permission) says why instead of showing an empty row.
393
- const quiet = accountLimits.length === 0 && typeof provider.note === "string" ? redact(provider.note).slice(0, HELP_MAX) : "";
394
- accounts.push({ id: redact(label), ok: true, status: quiet, limits: accountLimits });
387
+ accounts.push({ id: redact(label), ok: true, status: "", limits: mapLimits(provider.windows, provider) });
395
388
  return;
396
389
  }
397
390
  failures.push({ label, reason: failureReason(provider.error) });
@@ -488,11 +481,9 @@ export function buildRecord(provider, options = {}) {
488
481
 
489
482
  if (failed && errorKind(provider.error) === "missing") return null;
490
483
 
491
- const status = provider.ok && limits.length === 0 && typeof provider.note === "string" && provider.note !== ""
492
- ? { usageStatusText: "Usage not visible", authHelpText: redact(provider.note).slice(0, HELP_MAX) }
493
- : failed
494
- ? describeError(provider.family, provider.error ?? "no usage windows")
495
- : { usageStatusText: "", authHelpText: "" };
484
+ const status = failed
485
+ ? describeError(provider.family, provider.error ?? "no usage windows")
486
+ : { usageStatusText: "", authHelpText: "" };
496
487
 
497
488
  const id = recordId(provider.family);
498
489
  return {
@@ -15,7 +15,6 @@ const FAMILY_SHORT = {
15
15
  codex: "X",
16
16
  antigravity: "A",
17
17
  "opencode-go": "G",
18
- mistral: "M",
19
18
  "github-actions": "GH",
20
19
  };
21
20
 
@@ -24,7 +23,6 @@ const FAMILY_TITLE = {
24
23
  codex: "Codex (Pi)",
25
24
  antigravity: "Antigravity (Pi)",
26
25
  "opencode-go": "OpenCode Go (Pi)",
27
- mistral: "Mistral (Pi)",
28
26
  "github-actions": "GitHub Actions",
29
27
  };
30
28
 
@@ -220,8 +218,6 @@ export function renderProviderLines(provider, paint, contentWidth = MAX_WIDTH, o
220
218
  }
221
219
 
222
220
  if ((provider.windows ?? []).length === 0) {
223
- // A provider that answered but cannot show windows says why in its note.
224
- if (provider.note) lines.push(` ${paint("warning", `~ ${clip(provider.note, contentWidth - 4)}`)}`);
225
221
  lines.push(` ${paint("dim", "no rate-limit windows reported")}`);
226
222
  return lines;
227
223
  }
@@ -289,11 +285,6 @@ export function renderBoxWidget(providers, paint, totalWidth = typeof process?.s
289
285
  { label: "Agy C/G:", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
290
286
  { label: "OP-Go: ", ...windowFor("opencode-go") },
291
287
  ];
292
- // Mistral joins the compact widget only when it is configured, so a user
293
- // without it never sees a permanent "not configured" row.
294
- if (providerFor("mistral") && providerFor("mistral").notConfigured !== true) {
295
- rows.push({ label: "Mistral:", ...windowFor("mistral", (window) => window.id === "vibe") });
296
- }
297
288
  if (providerFor("github-actions")) {
298
289
  rows.push({ label: "GH Actions:", ...windowFor("github-actions", (window) => window.id === "monthly") });
299
290
  }
@@ -1,18 +0,0 @@
1
- <svg viewBox="0 0 10 10" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
- <rect x="0" y="0" width="2" height="2" fill="#FFD800"/>
3
- <rect x="8" y="0" width="2" height="2" fill="#FFD800"/>
4
- <rect x="0" y="2" width="2" height="2" fill="#FFAF00"/>
5
- <rect x="2" y="2" width="2" height="2" fill="#FFAF00"/>
6
- <rect x="6" y="2" width="2" height="2" fill="#FFAF00"/>
7
- <rect x="8" y="2" width="2" height="2" fill="#FFAF00"/>
8
- <rect x="0" y="4" width="2" height="2" fill="#FF8205"/>
9
- <rect x="2" y="4" width="2" height="2" fill="#FF8205"/>
10
- <rect x="4" y="4" width="2" height="2" fill="#FF8205"/>
11
- <rect x="6" y="4" width="2" height="2" fill="#FF8205"/>
12
- <rect x="8" y="4" width="2" height="2" fill="#FF8205"/>
13
- <rect x="0" y="6" width="2" height="2" fill="#FA500F"/>
14
- <rect x="4" y="6" width="2" height="2" fill="#FA500F"/>
15
- <rect x="8" y="6" width="2" height="2" fill="#FA500F"/>
16
- <rect x="0" y="8" width="2" height="2" fill="#E10500"/>
17
- <rect x="8" y="8" width="2" height="2" fill="#E10500"/>
18
- </svg>
@@ -1,18 +0,0 @@
1
- <svg viewBox="0 0 10 10" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
- <rect x="0" y="0" width="2" height="2" fill="#FFD800"/>
3
- <rect x="8" y="0" width="2" height="2" fill="#FFD800"/>
4
- <rect x="0" y="2" width="2" height="2" fill="#FFAF00"/>
5
- <rect x="2" y="2" width="2" height="2" fill="#FFAF00"/>
6
- <rect x="6" y="2" width="2" height="2" fill="#FFAF00"/>
7
- <rect x="8" y="2" width="2" height="2" fill="#FFAF00"/>
8
- <rect x="0" y="4" width="2" height="2" fill="#FF8205"/>
9
- <rect x="2" y="4" width="2" height="2" fill="#FF8205"/>
10
- <rect x="4" y="4" width="2" height="2" fill="#FF8205"/>
11
- <rect x="6" y="4" width="2" height="2" fill="#FF8205"/>
12
- <rect x="8" y="4" width="2" height="2" fill="#FF8205"/>
13
- <rect x="0" y="6" width="2" height="2" fill="#FA500F"/>
14
- <rect x="4" y="6" width="2" height="2" fill="#FA500F"/>
15
- <rect x="8" y="6" width="2" height="2" fill="#FA500F"/>
16
- <rect x="0" y="8" width="2" height="2" fill="#E10500"/>
17
- <rect x="8" y="8" width="2" height="2" fill="#E10500"/>
18
- </svg>
@@ -1,251 +0,0 @@
1
- /**
2
- * Mistral quota.
3
- *
4
- * Pi's built-in `mistral` provider stores an API key (a Vibe key or a
5
- * la Plateforme key; Mistral accepts either on the same API). The plan's usage
6
- * is served by Mistral's public billing API, the same two reads the Vibe CLI
7
- * makes for its footer gauge (`vibe/app_server/_billing.py`):
8
- *
9
- * GET /v1/billing/subscription -> plan_name, payg_enabled, vibe_payg_enabled
10
- * GET /v1/billing/budget -> vibe_budget, api_budget: usage_percentage,
11
- * initial_budget, currency, reset_at, payg_enabled
12
- *
13
- * Both are read-only GETs that cost no tokens. `/budget` needs the
14
- * `see_org_inference_usage` permission, so a member without it still gets the
15
- * plan, with a note instead of windows. The key is sent only as a Bearer header
16
- * to api.mistral.ai and never appears in a result.
17
- */
18
-
19
- import { buildWindow, clampPercent, degradedResult, stringValue } from "../model.js";
20
- import { requestJson } from "../http.js";
21
-
22
- const API_BASE = "https://api.mistral.ai/v1";
23
- const SUBSCRIPTION_URL = `${API_BASE}/billing/subscription`;
24
- const BUDGET_URL = `${API_BASE}/billing/budget`;
25
-
26
- /** A monthly budget, in the order every surface lists them. */
27
- const BUDGETS = [
28
- { field: "vibe_budget", id: "vibe", label: "Vibe monthly" },
29
- { field: "api_budget", id: "api", label: "API monthly" },
30
- ];
31
-
32
- const MONTH_SECONDS = 2_592_000;
33
- const PAYG = "pay-as-you-go";
34
-
35
- /**
36
- * @param {unknown} value
37
- * @returns {Record<string, unknown> | null}
38
- */
39
- function record(value) {
40
- return value && typeof value === "object" && !Array.isArray(value)
41
- ? /** @type {Record<string, unknown>} */ (value)
42
- : null;
43
- }
44
-
45
- /**
46
- * The account label shown by every surface: the base auth.json entry has no
47
- * numeric suffix, so it gets no number; a sibling slot is `#<slot>`.
48
- *
49
- * @param {{ slot?: string | null }} credential
50
- * @returns {string}
51
- */
52
- function accountLabel(credential) {
53
- return credential.slot ? `#${credential.slot}` : "";
54
- }
55
-
56
- /**
57
- * "127.50 EUR", or null when the budget carries no usable amount.
58
- *
59
- * @param {unknown} amount
60
- * @param {unknown} currency
61
- * @returns {string | null}
62
- */
63
- function money(amount, currency) {
64
- const value = typeof amount === "number" ? amount : typeof amount === "string" && amount.trim() !== "" ? Number(amount) : NaN;
65
- // A budget is a few hundred euros; anything absurd is not printed as 1e+308.
66
- if (!Number.isFinite(value) || value < 0 || value >= 1e9) return null;
67
- const code = stringValue(currency);
68
- const safeCode = code && /^[A-Za-z]{3}$/.test(code) ? ` ${code.toUpperCase()}` : "";
69
- return `${value.toFixed(2)}${safeCode}`;
70
- }
71
-
72
- /**
73
- * Map a `/billing/budget` body onto canonical windows.
74
- *
75
- * Mistral also repeats one budget at the top level of the body (today the API
76
- * one); it is read only when neither named budget is present, so an older or
77
- * smaller answer still shows something and a full answer never shows a budget
78
- * twice.
79
- *
80
- * A budget that does not say whether pay-as-you-go is on falls back to the
81
- * subscription's flag for it (`vibe_payg_enabled` for Vibe, `payg_enabled`
82
- * for the API).
83
- *
84
- * @param {unknown} body
85
- * @param {number} now
86
- * @param {{ vibe?: boolean | null, api?: boolean | null }} [payg] From `/billing/subscription`.
87
- * @returns {import("../model.js").QuotaWindow[]}
88
- */
89
- export function parseBudgetWindows(body, now = Date.now(), payg = {}) {
90
- const root = record(body);
91
- if (!root) return [];
92
-
93
- /** @type {Array<{ id: string, label: string, entry: Record<string, unknown> }>} */
94
- const named = [];
95
- for (const spec of BUDGETS) {
96
- const entry = record(root[spec.field]);
97
- if (entry) named.push({ id: spec.id, label: spec.label, entry });
98
- }
99
- const windows = toWindows(named, now, payg);
100
- // The top-level copy stands in only when no named budget produced a window,
101
- // so a full answer never shows a budget twice and an unreadable named one
102
- // does not hide a readable top-level one.
103
- if (windows.length === 0 && root.usage_percentage !== undefined) {
104
- return toWindows([{ id: "budget", label: "Monthly budget", entry: root }], now, payg);
105
- }
106
- return windows;
107
- }
108
-
109
- /**
110
- * @param {Array<{ id: string, label: string, entry: Record<string, unknown> }>} budgets
111
- * @param {number} now
112
- * @param {{ vibe?: boolean | null, api?: boolean | null }} payg
113
- * @returns {import("../model.js").QuotaWindow[]}
114
- */
115
- function toWindows(budgets, now, payg) {
116
- /** @type {import("../model.js").QuotaWindow[]} */
117
- const windows = [];
118
- for (const { id, label, entry } of budgets) {
119
- // No readable percentage, no window: "n/a" must not become an empty bar.
120
- if (clampPercent(entry.usage_percentage) === null) continue;
121
- const notes = [];
122
- const amount = money(entry.initial_budget, entry.currency);
123
- if (amount) notes.push(`of ${amount}`);
124
- const paygOn = typeof entry.payg_enabled === "boolean" ? entry.payg_enabled : (id === "vibe" ? payg.vibe : payg.api) === true;
125
- if (paygOn) notes.push(PAYG);
126
- windows.push(
127
- buildWindow({
128
- id,
129
- label,
130
- usedPercent: entry.usage_percentage,
131
- resetsAt: entry.reset_at ?? null,
132
- windowSeconds: MONTH_SECONDS,
133
- note: notes.length > 0 ? notes.join(" · ") : null,
134
- now,
135
- }),
136
- );
137
- }
138
- return windows;
139
- }
140
-
141
- /**
142
- * The pay-as-you-go flags of a `/billing/subscription` body.
143
- *
144
- * @param {unknown} body
145
- * @returns {{ vibe: boolean | null, api: boolean | null }}
146
- */
147
- export function parsePayg(body) {
148
- const root = record(body);
149
- const flag = (value) => (typeof value === "boolean" ? value : null);
150
- return { vibe: flag(root?.vibe_payg_enabled), api: flag(root?.payg_enabled) };
151
- }
152
-
153
- /**
154
- * The plan's name from a `/billing/subscription` body, or null.
155
- *
156
- * @param {unknown} body
157
- * @returns {string | null}
158
- */
159
- export function parsePlanName(body) {
160
- const name = stringValue(record(body)?.plan_name);
161
- if (!name) return null;
162
- // A display string only: keep it short and free of control characters.
163
- const clean = name.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim().slice(0, 40);
164
- return clean === "" ? null : clean;
165
- }
166
-
167
- /**
168
- * @param {import("../http.js").HttpResult} response
169
- * @param {string} what
170
- * @returns {string}
171
- */
172
- function failureMessage(response, what) {
173
- if (response.status === 401) return "Mistral key rejected (401); run /login mistral in Pi";
174
- if (response.status === 403) return `Mistral ${what} not readable with this key (403)`;
175
- if (response.status === 404) return `Mistral billing API not served (404 on ${what})`;
176
- return `Mistral ${what} request failed: ${response.error ?? `HTTP ${response.status}`}`;
177
- }
178
-
179
- /**
180
- * @param {import("../auth/pi-auth.js").PiCredential} credential
181
- * @param {{
182
- * now?: number,
183
- * fetchFn?: typeof fetch,
184
- * timeoutMs?: number,
185
- * expiresInMin?: number | null,
186
- * }} [options]
187
- * @returns {Promise<import("../model.js").QuotaResult>}
188
- */
189
- export async function fetchQuota(credential, options = {}) {
190
- const now = options.now ?? Date.now();
191
- const base = {
192
- family: "mistral",
193
- label: "Mistral (Pi)",
194
- account: accountLabel(credential),
195
- source: credential.source,
196
- sourceKind: credential.sourceKind ?? "pi",
197
- expiresInMin: options.expiresInMin ?? null,
198
- };
199
-
200
- if (!credential.key) {
201
- return degradedResult({ ...base, error: "no key stored for mistral in the Pi store; run /login mistral in Pi", now });
202
- }
203
-
204
- const request = (url) =>
205
- requestJson(url, {
206
- headers: { Authorization: `Bearer ${credential.key}`, Accept: "application/json" },
207
- fetchFn: options.fetchFn,
208
- timeoutMs: options.timeoutMs,
209
- });
210
- const [subscription, budget] = await Promise.all([request(SUBSCRIPTION_URL), request(BUDGET_URL)]);
211
-
212
- const plan = subscription.ok ? parsePlanName(subscription.body) : null;
213
- const payg = subscription.ok ? parsePayg(subscription.body) : { vibe: null, api: null };
214
-
215
- // A rejected key is a statement about the credential: report it once, as an
216
- // auth failure, whichever read noticed it.
217
- if (subscription.status === 401 || budget.status === 401) {
218
- return degradedResult({ ...base, plan, error: failureMessage({ status: 401 }, "billing"), now });
219
- }
220
-
221
- if (budget.ok) {
222
- const windows = parseBudgetWindows(budget.body, now, payg);
223
- if (windows.length === 0) {
224
- return degradedResult({ ...base, plan, error: "Mistral budget endpoint returned no budgets", now });
225
- }
226
- return {
227
- ...base,
228
- plan,
229
- windows,
230
- error: null,
231
- ok: true,
232
- updatedAt: new Date(now).toISOString(),
233
- };
234
- }
235
-
236
- // Without the usage permission the plan is still worth showing: the account
237
- // works, it just cannot see the organisation's budget.
238
- if (budget.status === 403 && subscription.ok) {
239
- return {
240
- ...base,
241
- plan,
242
- windows: [],
243
- note: `budget needs the see_org_inference_usage permission${payg.vibe === true || payg.api === true ? ` · ${PAYG} on` : ""}`,
244
- error: null,
245
- ok: true,
246
- updatedAt: new Date(now).toISOString(),
247
- };
248
- }
249
-
250
- return degradedResult({ ...base, plan, error: failureMessage(budget, "budget"), now });
251
- }