@narumitw/pi-usage 0.51.0 → 0.52.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/README.md CHANGED
@@ -134,6 +134,16 @@ GitHub's quota endpoint requires the original GitHub OAuth token rather than the
134
134
 
135
135
  The extension does not call OpenRouter's account-level `/credits` endpoint because that operation requires a separate management key. OpenRouter documents the distinction between credit and rate limits in its [API limits guide](https://openrouter.ai/docs/api_reference/limits).
136
136
 
137
+ ### OpenCode Go (Zen)
138
+
139
+ - Provider ID: `opencode-go`
140
+ - Semantics: OpenCode Zen plan usage windows—rolling, weekly, and monthly
141
+ - Source: `GET {model base URL}/usage` on the configured `opencode.ai` gateway using Pi's resolved inference API key
142
+ - Displayed data: used percentage and reset time for each window; `rate-limited` windows remain visible at their reported usage, while unknown statuses are reported as unavailable notes
143
+ - Statusline examples: `zen 0% r 4% w 2% m`
144
+
145
+ The usage endpoint is derived from the model's base URL (`…/zen/go/v1/usage`) and is only queried when the resolved origin is `https://opencode.ai`; other origins fail before sending the credential.
146
+
137
147
  ## 🧭 Current and configured accounts
138
148
 
139
149
  `Current` means the provider and credential used by Pi's selected model. `Configured` means Pi reports runtime auth for another supported provider; it does not mean that provider is active.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.51.0",
4
- "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, and OpenRouter.",
3
+ "version": "0.52.0",
4
+ "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, OpenRouter, and OpenCode Zen.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -13,7 +13,9 @@
13
13
  "quota",
14
14
  "codex",
15
15
  "copilot",
16
- "openrouter"
16
+ "openrouter",
17
+ "opencode",
18
+ "zen"
17
19
  ],
18
20
  "files": [
19
21
  "src",
@@ -38,10 +40,10 @@
38
40
  "@earendil-works/pi-coding-agent": "*"
39
41
  },
40
42
  "devDependencies": {
41
- "@biomejs/biome": "2.5.7",
42
- "@earendil-works/pi-ai": "0.84.1",
43
- "@earendil-works/pi-coding-agent": "0.84.1",
44
- "@types/node": "26.1.2",
43
+ "@biomejs/biome": "2.5.8",
44
+ "@earendil-works/pi-ai": "0.84.2",
45
+ "@earendil-works/pi-coding-agent": "0.84.2",
46
+ "@types/node": "26.2.0",
45
47
  "typescript": "7.0.2"
46
48
  },
47
49
  "repository": {
package/src/format.ts CHANGED
@@ -18,6 +18,7 @@ export function formatUsageReport(report: UsageReport, displayState: UsageDispla
18
18
  if (report.providerId === "openai-codex") formatCodexReport(lines, report);
19
19
  else if (report.providerId === "github-copilot") formatGitHubCopilotReport(lines, report);
20
20
  else if (report.providerId === "openrouter") formatOpenRouterReport(lines, report);
21
+ else if (report.providerId === "opencode-go") formatOpenCodeZenReport(lines, report);
21
22
  else formatGenericReport(lines, report);
22
23
 
23
24
  if (report.notes) {
@@ -35,6 +36,7 @@ export function formatUsageStatusline(report: UsageReport, model?: UsageModel):
35
36
  const total = report.metrics.find((metric) => metric.id === "usage-total");
36
37
  if (typeof total?.value === "number") return `openrouter ${formatUsd(total.value)} used`;
37
38
  }
39
+ if (report.providerId === "opencode-go") return formatOpenCodeZenStatusline(report);
38
40
  return undefined;
39
41
  }
40
42
 
@@ -140,6 +142,24 @@ function formatOpenRouterReport(lines: string[], report: UsageReport): void {
140
142
  }
141
143
  }
142
144
 
145
+ function formatOpenCodeZenReport(lines: string[], report: UsageReport): void {
146
+ for (const bucket of report.buckets) {
147
+ const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
148
+ const used = bucket.used ?? "unavailable";
149
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${used}% used${reset}`);
150
+ }
151
+ }
152
+
153
+ function formatOpenCodeZenStatusline(report: UsageReport): string | undefined {
154
+ const parts = ["zen"];
155
+ for (const bucket of report.buckets) {
156
+ if (bucket.used === undefined) continue;
157
+ const compact = bucket.id === "rolling" ? "r" : bucket.id === "weekly" ? "w" : "m";
158
+ parts.push(`${clampPercent(bucket.used).toFixed(0)}% ${compact}`);
159
+ }
160
+ return parts.length > 1 ? parts.join(" ") : undefined;
161
+ }
162
+
143
163
  function formatGenericReport(lines: string[], report: UsageReport): void {
144
164
  for (const bucket of report.buckets) {
145
165
  lines.push(
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  export { formatProviderStates, formatUsageReport, formatUsageStatusline } from "./format.js";
35
35
  export { normalizeCodexBackendPayload } from "./providers/codex.js";
36
36
  export { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
37
+ export { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
37
38
  export { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
38
39
  export {
39
40
  adapterForProvider,
@@ -0,0 +1,83 @@
1
+ import { sanitizeDisplayText } from "../core.js";
2
+ import type { OpenCodeZenPayload, UsageBucket, UsageReport } from "../types.js";
3
+
4
+ const ZEN_WINDOWS = [
5
+ { key: "rolling", label: "Rolling" },
6
+ { key: "weekly", label: "Weekly" },
7
+ { key: "monthly", label: "Monthly" },
8
+ ] as const;
9
+
10
+ export function normalizeOpenCodeZenPayload(
11
+ payload: OpenCodeZenPayload,
12
+ capturedAt: number,
13
+ ): UsageReport {
14
+ const usage = asObject(payload.usage);
15
+ if (!usage) throw new Error("OpenCode Zen usage response was not an object.");
16
+
17
+ const buckets: UsageBucket[] = [];
18
+ const notes: string[] = [];
19
+ for (const window of ZEN_WINDOWS) {
20
+ const raw = asObject(usage[window.key]);
21
+ if (!raw) continue;
22
+ const status = asString(raw.status);
23
+ if (status !== "ok" && status !== "rate-limited") {
24
+ notes.push(`${window.label} window unavailable (${status ?? "unknown status"}).`);
25
+ continue;
26
+ }
27
+ const used = asNonnegativeNumber(raw.percent);
28
+ if (used === undefined) continue;
29
+ const resetsAt = asEpochSeconds(raw.resetsAt);
30
+ buckets.push({
31
+ id: window.key,
32
+ label: `${window.label} window`,
33
+ used,
34
+ remaining: 100 - clampPercent(used),
35
+ limit: 100,
36
+ unit: "percent",
37
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
38
+ });
39
+ }
40
+ if (buckets.length === 0) {
41
+ throw new Error("OpenCode Zen usage endpoint returned no displayable usage data.");
42
+ }
43
+
44
+ return {
45
+ providerId: "opencode-go",
46
+ providerName: "OpenCode Go",
47
+ capturedAt,
48
+ source: "opencode-zen-usage",
49
+ semantics: {
50
+ kind: "consumer-subscription",
51
+ label: "OpenCode Zen plan usage",
52
+ },
53
+ buckets,
54
+ metrics: [],
55
+ ...(notes.length > 0 ? { notes } : {}),
56
+ };
57
+ }
58
+
59
+ function asObject(value: unknown): Record<string, unknown> | undefined {
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
61
+ return value as Record<string, unknown>;
62
+ }
63
+
64
+ function asString(value: unknown): string | undefined {
65
+ if (typeof value !== "string") return undefined;
66
+ return sanitizeDisplayText(value, 80) || undefined;
67
+ }
68
+
69
+ function asNonnegativeNumber(value: unknown): number | undefined {
70
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
71
+ return value;
72
+ }
73
+
74
+ function asEpochSeconds(value: unknown): number | undefined {
75
+ if (typeof value !== "string" || !value.trim()) return undefined;
76
+ const parsed = Date.parse(value);
77
+ if (Number.isNaN(parsed)) return undefined;
78
+ return Math.floor(parsed / 1000);
79
+ }
80
+
81
+ function clampPercent(value: number): number {
82
+ return Math.min(100, Math.max(0, value));
83
+ }
package/src/query.ts CHANGED
@@ -3,10 +3,12 @@ import { type ExtensionContext, readStoredCredential } from "@earendil-works/pi-
3
3
  import { errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
4
4
  import { normalizeCodexBackendPayload } from "./providers/codex.js";
5
5
  import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
6
+ import { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
6
7
  import { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
7
8
  import type {
8
9
  CodexBackendPayload,
9
10
  GitHubCopilotUsagePayload,
11
+ OpenCodeZenPayload,
10
12
  OpenRouterKeyPayload,
11
13
  PiModel,
12
14
  ResolvedUsageAuth,
@@ -74,6 +76,21 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
74
76
  return normalizeOpenRouterKeyPayload(payload as OpenRouterKeyPayload, Date.now());
75
77
  },
76
78
  },
79
+ {
80
+ id: "opencode-go",
81
+ displayName: "OpenCode Go",
82
+ semantics: { kind: "consumer-subscription", label: "OpenCode Zen plan usage" },
83
+ async query(auth, signal, timeoutMs) {
84
+ const payload = await fetchProviderJson(
85
+ opencodeUsageUrl(auth.model.baseUrl),
86
+ auth,
87
+ signal,
88
+ timeoutMs,
89
+ "OpenCode Zen usage endpoint",
90
+ );
91
+ return normalizeOpenCodeZenPayload(payload as OpenCodeZenPayload, Date.now());
92
+ },
93
+ },
77
94
  ];
78
95
 
79
96
  export function adapterForProvider(
@@ -393,6 +410,7 @@ function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
393
410
  const url = new URL(value);
394
411
  if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
395
412
  if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
413
+ if (providerId === "opencode-go") return url.origin === "https://opencode.ai";
396
414
  if (providerId === "github-copilot") {
397
415
  return (
398
416
  url.protocol === "https:" && /^api\.[a-z0-9-]+\.githubcopilot\.com$/u.test(url.hostname)
@@ -418,6 +436,12 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
418
436
  return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
419
437
  }
420
438
 
439
+ function opencodeUsageUrl(baseUrl: string | undefined): string {
440
+ const base = baseUrl?.trim().replace(/\/+$/u, "");
441
+ if (!base) throw new Error("OpenCode Go model base URL is unavailable.");
442
+ return `${base}/usage`;
443
+ }
444
+
421
445
  function isAbortError(error: unknown): boolean {
422
446
  return error instanceof Error && error.name === "AbortError";
423
447
  }
package/src/types.ts CHANGED
@@ -93,6 +93,10 @@ export type OpenRouterKeyPayload = {
93
93
  data?: unknown;
94
94
  };
95
95
 
96
+ export type OpenCodeZenPayload = {
97
+ usage?: unknown;
98
+ };
99
+
96
100
  export type CodexBackendPayload = {
97
101
  plan_type?: unknown;
98
102
  rate_limit?: unknown;