@gtrabanco/pi-nan-provider 0.7.0 → 0.10.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/src/usage.ts CHANGED
@@ -1,21 +1,31 @@
1
1
  /**
2
- * `/nan-usage` — slash command showing NaN quota status per model.
2
+ * `/nan-usage` — slash command: NaN token usage merged with the documented
3
+ * quota limits.
3
4
  *
4
- * NaN's quota endpoint (`cloud-api.nan.builders/api/usage/quota`) requires
5
- * a session token (not API key auth). The token is obtained via the NaN CLI
6
- * login flow (email → link → `nan_session` cookie), stored in
7
- * `~/.config/nan/session.json`.
5
+ * Data source: NaN's `GET /v1/usage` (OpenAPI tag "Usage",
6
+ * https://nan.builders/docs/api#tag/usage), authenticated with the same
7
+ * personal API key used for chat (`Authorization: Bearer`) — pi's stored
8
+ * credential (`/login nan`) or `NAN_API_KEY`.
8
9
  *
9
- * This command auto-detects the nan-cli session file. No env vars needed —
10
- * just run `nan auth login` once and `/nan-usage` works.
10
+ * This replaced the previous dashboard flow, which needed a NaN CLI session
11
+ * cookie (`nan auth login` → `~/.config/nan/session.json`). `/usage` is
12
+ * member-scoped and API-key authed, so no CLI login is involved anymore
13
+ * (NaN docs, checked 2026-09-27).
11
14
  *
12
- * Quota sources: https://nan.builders/docs/models (checked 2026-09-21)
15
+ * The endpoint reports consumption, never caps: quotas below come from
16
+ * https://nan.builders/docs/models (checked 2026-09-29) and are merged with
17
+ * the returned per-model totals. mimo-v2.5 has no row: NaN removed it from
18
+ * the docs (checked 2026-09-29), so a cap for it would have no source —
19
+ * historical consumption still appears under the undocumented-models section.
20
+ * `/usage` returns daily rows (paginated) plus
21
+ * `totals` covering the whole requested window — max 90 inclusive days, wider
22
+ * windows are rejected with 400 — so the command reads `totals.by_model` and
23
+ * asks for a single-row page. Rate limit: 30 requests/min, separate from the
24
+ * model endpoints.
13
25
  */
14
26
 
15
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
- import { readFileSync } from "node:fs";
17
- import { join } from "node:path";
18
- import { homedir } from "node:os";
27
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
28
+ import { NAN_API_KEY_ENV, resolveNanApiKey, tryResolveNanApiKeyViaRegistry } from "./mcp/api-key.ts";
19
29
 
20
30
  // ── Known quota limits per model (from NaN docs) ──────────────────────────
21
31
 
@@ -36,7 +46,6 @@ export interface ModelQuota {
36
46
 
37
47
  export const MODEL_QUOTAS: readonly ModelQuota[] = [
38
48
  { model: "deepseek-v4-flash", label: "DeepSeek V4 Flash", monthlyCap: 3_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
39
- { model: "mimo-v2.5", label: "MiMo V2.5", monthlyCap: 1_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
40
49
  { model: "mimo-v2.6-flash", label: "MiMo V2.6 Flash", monthlyCap: 1_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
41
50
  { model: "qwen3.6", label: "Qwen 3.6", monthlyCap: 0, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
42
51
  { model: "gemma4", label: "Gemma 4", monthlyCap: 0, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
@@ -45,58 +54,88 @@ export const MODEL_QUOTAS: readonly ModelQuota[] = [
45
54
  { model: "glm5.3", label: "GLM 5.3", monthlyCap: 3_000_000_000, rollingWindowCap: 400_000_000, rollingWindowHours: 4, premium: true },
46
55
  ];
47
56
 
48
- // ── Dashboard API types ───────────────────────────────────────────────────
57
+ // ── GET /v1/usage types ───────────────────────────────────────────────────
49
58
 
50
- interface DashboardModelQuota {
59
+ export interface UsageModelTotals {
51
60
  model: string;
52
- tokensUsed: number;
53
- cap: number;
54
- percentage: number;
55
- resetAt: string | null;
56
- windowHours: number | null;
61
+ prompt_tokens: number;
62
+ completion_tokens: number;
63
+ total_tokens: number;
64
+ api_requests: number;
57
65
  }
58
66
 
59
- interface DashboardUncappedModelQuota {
60
- model: string;
61
- tokensUsed: number;
62
- resetAt: string | null;
63
- windowHours: number | null;
67
+ export interface UsageTotals {
68
+ prompt_tokens: number;
69
+ completion_tokens: number;
70
+ total_tokens: number;
71
+ api_requests: number;
72
+ by_model: UsageModelTotals[];
64
73
  }
65
74
 
66
- interface DashboardQuotaResponse {
67
- periodStart: string;
68
- models: Array<{
69
- model: string;
70
- tokensUsed: number;
71
- cap: number;
72
- windowHours?: number;
73
- periodEnd?: string;
74
- }>;
75
+ export interface UsageAllTime {
76
+ prompt_tokens: number;
77
+ completion_tokens: number;
78
+ total_tokens: number;
79
+ api_requests: number;
80
+ cached_at: string | null;
75
81
  }
76
82
 
77
- // ── nan-cli session reader ────────────────────────────────────────────────
78
-
79
- interface NanCliSession {
80
- token: string;
83
+ export interface UsageReport {
84
+ object: "usage.report";
85
+ /** First day of the window actually served (after clamping). */
86
+ start_date: string;
87
+ /** Last day of the window actually served (after clamping). */
88
+ end_date: string;
89
+ /** Daily (date, model) rows — paginated; unused here (totals cover the window). */
90
+ data: unknown[];
91
+ totals: UsageTotals;
92
+ all_time: UsageAllTime;
93
+ has_more: boolean;
94
+ next_cursor: string | null;
81
95
  }
82
96
 
83
- /** Read the nan_session token from ~/.config/nan/session.json (shared with nan-cli). */
84
- function readNanCliSessionToken(): string | undefined {
85
- try {
86
- const sessionPath = join(homedir(), ".config", "nan", "session.json");
87
- const data = readFileSync(sessionPath, "utf8");
88
- const session = JSON.parse(data) as NanCliSession;
89
- if (typeof session === "object" && session !== null && typeof session.token === "string" && session.token.length > 0) {
90
- return session.token;
91
- }
92
- } catch {
93
- // File doesn't exist or is invalid.
94
- }
95
- return undefined;
97
+ export interface UsageFetchError {
98
+ /** HTTP status when the endpoint answered with an error. */
99
+ status?: number;
100
+ /** `Retry-After` seconds, present on 429. */
101
+ retryAfterSeconds?: number;
102
+ /** Endpoint or transport detail (error message / provider message). */
103
+ detail?: string;
96
104
  }
97
105
 
106
+ export type FetchUsageResult =
107
+ | { ok: true; report: UsageReport }
108
+ | { ok: false; error: UsageFetchError };
109
+
110
+ /** NaN's usage endpoint (OpenAPI tag "Usage"), API-key authed. */
111
+ export const NAN_USAGE_URL = "https://api.nan.builders/v1/usage";
112
+ export const USAGE_TIMEOUT_MS = 10_000;
113
+ /** The endpoint rejects windows wider than 90 inclusive days with 400. */
114
+ export const MAX_USAGE_WINDOW_DAYS = 90;
115
+
116
+ const USAGE_LINE =
117
+ "Usage: /nan-usage [days] — days 1-90 (default: the current UTC month, matching the monthly caps). /nan-usage help";
118
+
98
119
  // ── Time helpers ──────────────────────────────────────────────────────────
99
120
 
121
+ export interface UsageWindow {
122
+ /** Inclusive start, `YYYY-MM-DD` UTC. */
123
+ start: string;
124
+ /** Inclusive end, `YYYY-MM-DD` UTC. */
125
+ end: string;
126
+ /** Inclusive day count. */
127
+ days: number;
128
+ }
129
+
130
+ function isoDate(date: Date): string {
131
+ return date.toISOString().slice(0, 10);
132
+ }
133
+
134
+ function daysBetween(start: string, end: string): number {
135
+ const ms = Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`);
136
+ return Math.round(ms / 86_400_000) + 1;
137
+ }
138
+
100
139
  function formatDuration(ms: number): string {
101
140
  if (ms <= 0) return "already reset";
102
141
  const totalSeconds = Math.floor(ms / 1000);
@@ -113,97 +152,96 @@ function formatDuration(ms: number): string {
113
152
  return parts.join(" ");
114
153
  }
115
154
 
116
- function getNextBillingReset(): Date {
117
- const now = new Date();
155
+ function getNextBillingReset(now = new Date()): Date {
118
156
  const year = now.getUTCFullYear();
119
157
  const month = now.getUTCMonth();
120
158
  return new Date(Date.UTC(year, month + 1, 0, 0, 0, 0));
121
159
  }
122
160
 
123
- function formatTokens(n: number): string {
161
+ function resetLine(now = new Date()): string {
162
+ const resetDate = getNextBillingReset(now);
163
+ const timeUntilReset = resetDate.getTime() - now.getTime();
164
+ return `⏱️ Next billing reset: ${isoDate(resetDate)} UTC (${formatDuration(timeUntilReset)})`;
165
+ }
166
+
167
+ /** Default window: the current UTC month, so usage lines up with the monthly caps. */
168
+ export function currentMonthWindow(now = new Date()): UsageWindow {
169
+ const start = isoDate(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)));
170
+ const end = isoDate(now);
171
+ return { start, end, days: daysBetween(start, end) };
172
+ }
173
+
174
+ /** Rolling window of `days` inclusive days ending today (UTC), clamped to 1–90. */
175
+ export function rollingWindow(days: number, now = new Date()): UsageWindow {
176
+ const span = Math.min(Math.max(Math.trunc(days), 1), MAX_USAGE_WINDOW_DAYS);
177
+ const start = isoDate(new Date(now.getTime() - (span - 1) * 86_400_000));
178
+ const end = isoDate(now);
179
+ return { start, end, days: daysBetween(start, end) };
180
+ }
181
+
182
+ export type UsageArgs =
183
+ | { kind: "window"; window: UsageWindow }
184
+ | { kind: "help" }
185
+ | { kind: "invalid"; message: string };
186
+
187
+ /** Parse the optional `/nan-usage [days]` argument. */
188
+ export function parseUsageArgs(tokens: readonly string[], now = new Date()): UsageArgs {
189
+ if (tokens.length === 0) return { kind: "window", window: currentMonthWindow(now) };
190
+ if (tokens.length === 1) {
191
+ const token = tokens[0]!;
192
+ if (["help", "-h", "--help"].includes(token.toLowerCase())) return { kind: "help" };
193
+ if (/^\d+$/.test(token)) {
194
+ const days = Number(token);
195
+ if (days >= 1 && days <= MAX_USAGE_WINDOW_DAYS) return { kind: "window", window: rollingWindow(days, now) };
196
+ }
197
+ }
198
+ return {
199
+ kind: "invalid",
200
+ message: `Unknown argument "${tokens.join(" ")}". ${USAGE_LINE}`,
201
+ };
202
+ }
203
+
204
+ // ── Formatting helpers ────────────────────────────────────────────────────
205
+
206
+ export function formatTokens(n: number): string {
124
207
  if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
125
208
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
126
209
  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
127
210
  return String(n);
128
211
  }
129
212
 
213
+ /** Locale-independent thousands separator (request counts). */
214
+ export function formatCount(n: number): string {
215
+ return String(Math.trunc(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
216
+ }
217
+
130
218
  function progressBar(percentage: number, width = 20): string {
131
- const filled = Math.round((percentage / 100) * width);
219
+ const clamped = Math.min(Math.max(percentage, 0), 100);
220
+ const filled = Math.round((clamped / 100) * width);
132
221
  const empty = width - filled;
133
222
  return `[${"█".repeat(filled)}${"░".repeat(empty)}]`;
134
223
  }
135
224
 
136
- // ── Dashboard client ──────────────────────────────────────────────────────
137
-
138
- const DASHBOARD_QUOTA_URL = "https://cloud-api.nan.builders/api/usage/quota";
139
- const FETCH_TIMEOUT_MS = 10_000;
140
-
141
- async function fetchDashboardQuota(token: string): Promise<DashboardQuotaResponse | null> {
142
- try {
143
- const controller = new AbortController();
144
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
145
- try {
146
- const response = await fetch(DASHBOARD_QUOTA_URL, {
147
- method: "GET",
148
- headers: { cookie: `nan_session=${token}` },
149
- redirect: "manual",
150
- cache: "no-store",
151
- signal: controller.signal,
152
- });
153
- if (!response.ok) return null;
154
- return (await response.json()) as DashboardQuotaResponse;
155
- } finally {
156
- clearTimeout(timeout);
157
- }
158
- } catch {
159
- return null;
160
- }
161
- }
162
-
163
- function parseDashboardQuota(data: DashboardQuotaResponse): {
164
- capped: DashboardModelQuota[];
165
- uncapped: DashboardUncappedModelQuota[];
166
- } {
167
- const capped: DashboardModelQuota[] = [];
168
- const uncapped: DashboardUncappedModelQuota[] = [];
169
- const seen = new Set<string>();
170
-
171
- for (const entry of data.models) {
172
- if (seen.has(entry.model)) continue;
173
- seen.add(entry.model);
174
-
175
- if (entry.cap === 0) {
176
- uncapped.push({
177
- model: entry.model,
178
- tokensUsed: entry.tokensUsed,
179
- resetAt: entry.periodEnd ?? null,
180
- windowHours: entry.windowHours ?? null,
181
- });
182
- } else {
183
- capped.push({
184
- model: entry.model,
185
- tokensUsed: entry.tokensUsed,
186
- cap: entry.cap,
187
- percentage: (entry.tokensUsed / entry.cap) * 100,
188
- resetAt: entry.periodEnd ?? null,
189
- windowHours: entry.windowHours ?? null,
190
- });
191
- }
192
- }
193
-
194
- return { capped, uncapped };
225
+ /**
226
+ * `api_requests` comes back as 0 for some models that do have usage (live
227
+ * probe 2026-09-27: qwen3.8-flash 796M tokens / 0 requests, while the window
228
+ * total still sums the other models exactly). Reporting a bare "0 requests"
229
+ * next to real consumption would read as a bug in this command, so say what
230
+ * the endpoint actually reported.
231
+ */
232
+ function requestsSuffix(tokens: number, requests: number): string {
233
+ if (tokens > 0 && requests === 0) return "requests not reported";
234
+ return `${formatCount(requests)} requests`;
195
235
  }
196
236
 
197
237
  // ── Message builders ──────────────────────────────────────────────────────
198
238
 
199
- function buildStaticMessage(): string {
200
- const resetDate = getNextBillingReset();
201
- const timeUntilReset = resetDate.getTime() - Date.now();
202
-
239
+ /** Static quota table — shown when no API key resolves. */
240
+ export function buildStaticMessage(now = new Date()): string {
203
241
  const lines: string[] = [
204
242
  "📊 NaN Quota Status (static limits)",
205
243
  "",
206
- `⏱️ Next billing reset: ${resetDate.toISOString().split("T")[0]} UTC (${formatDuration(timeUntilReset)})`,
244
+ resetLine(now),
207
245
  "",
208
246
  "Model Monthly Cap",
209
247
  "─".repeat(45),
@@ -219,89 +257,228 @@ function buildStaticMessage(): string {
219
257
  lines.push(`${quota.label.padEnd(28)} ${capStr}${premiumStr}${rollingStr}`);
220
258
  }
221
259
 
222
- lines.push("");
223
- lines.push("💡 Run `nan auth login` to see real usage data.");
224
-
260
+ lines.push("", `💡 Set ${NAN_API_KEY_ENV} or run \`/login nan\` to see real usage (GET /v1/usage).`);
225
261
  return lines.join("\n");
226
262
  }
227
263
 
228
- function buildDashboardMessage(
229
- capped: DashboardModelQuota[],
230
- uncapped: DashboardUncappedModelQuota[],
231
- ): string {
232
- const resetDate = getNextBillingReset();
233
- const timeUntilReset = resetDate.getTime() - Date.now();
264
+ /** Usage report merged with the documented caps. */
265
+ export function buildUsageMessage(report: UsageReport, now = new Date()): string {
266
+ const start = report.start_date;
267
+ const end = report.end_date;
268
+ const days = daysBetween(start, end);
269
+ const totals = report.totals;
270
+ const byModel = totals?.by_model ?? [];
271
+ const totalTokens = totals?.total_tokens ?? 0;
272
+ const totalRequests = totals?.api_requests ?? 0;
234
273
 
235
274
  const lines: string[] = [
236
- "📊 NaN Quota Status",
275
+ "📊 NaN Usage",
276
+ "",
277
+ `🗓️ Window: ${start} → ${end} UTC (${days} day${days === 1 ? "" : "s"})`,
237
278
  "",
238
- `⏱️ Next billing reset: ${resetDate.toISOString().split("T")[0]} UTC (${formatDuration(timeUntilReset)})`,
279
+ resetLine(now),
239
280
  "",
240
281
  ];
241
282
 
242
- if (capped.length > 0) {
243
- lines.push("Models with monthly caps:");
244
- lines.push("");
245
- for (const m of capped) {
246
- const quota = MODEL_QUOTAS.find((q) => q.model === m.model);
247
- const label = quota?.label ?? m.model;
248
- const pct = m.percentage.toFixed(1);
249
- const remaining = m.cap - m.tokensUsed;
250
- lines.push(`${label}:`);
251
- lines.push(` ${progressBar(m.percentage)} ${pct}%`);
252
- lines.push(` Used: ${formatTokens(m.tokensUsed)} / ${formatTokens(m.cap)} (${formatTokens(remaining)} remaining)`);
253
- if (m.windowHours) {
254
- lines.push(` Rolling window: ${m.windowHours}h`);
283
+ const usageOf = (model: string): UsageModelTotals | undefined => byModel.find((m) => m.model === model);
284
+
285
+ if (totalTokens === 0 && totalRequests === 0 && byModel.length === 0) {
286
+ lines.push("No usage in this window.");
287
+ } else {
288
+ const capped = MODEL_QUOTAS.filter((q) => q.monthlyCap > 0);
289
+ const uncapped = MODEL_QUOTAS.filter((q) => q.monthlyCap === 0);
290
+ const known = new Set(MODEL_QUOTAS.map((q) => q.model));
291
+ const others = byModel.filter((m) => !known.has(m.model)).sort((a, b) => b.total_tokens - a.total_tokens);
292
+
293
+ lines.push("Models with monthly caps:", "");
294
+ for (const quota of capped) {
295
+ const usage = usageOf(quota.model);
296
+ const used = usage?.total_tokens ?? 0;
297
+ const requests = usage?.api_requests ?? 0;
298
+ const percentage = (used / quota.monthlyCap) * 100;
299
+ const remaining = Math.max(quota.monthlyCap - used, 0);
300
+
301
+ lines.push(`${quota.label}${quota.premium ? " 👑" : ""}:`);
302
+ lines.push(` ${progressBar(percentage)} ${percentage.toFixed(1)}% of monthly cap`);
303
+ lines.push(
304
+ ` Used: ${formatTokens(used)} / ${formatTokens(quota.monthlyCap)} (${formatTokens(remaining)} remaining) · ${requestsSuffix(used, requests)}`,
305
+ );
306
+ if (quota.rollingWindowCap > 0) {
307
+ lines.push(
308
+ ` ↳ rolling window: ${formatTokens(quota.rollingWindowCap)} / ${quota.rollingWindowHours}h ` +
309
+ "(daily granularity — /usage cannot break it down)",
310
+ );
255
311
  }
256
312
  lines.push("");
257
313
  }
258
- }
259
314
 
260
- if (uncapped.length > 0) {
261
- lines.push("Uncapped models:");
262
- lines.push("");
263
- for (const m of uncapped) {
264
- const quota = MODEL_QUOTAS.find((q) => q.model === m.model);
265
- const label = quota?.label ?? m.model;
266
- lines.push(`${label}: ${formatTokens(m.tokensUsed)} used`);
315
+ const usedUncapped = uncapped.filter((q) => (usageOf(q.model)?.total_tokens ?? 0) > 0);
316
+ if (usedUncapped.length > 0) {
317
+ lines.push("Uncapped models:", "");
318
+ for (const quota of usedUncapped) {
319
+ const usage = usageOf(quota.model)!;
320
+ lines.push(`${quota.label}: ${formatTokens(usage.total_tokens)} used · ${requestsSuffix(usage.total_tokens, usage.api_requests)}`);
321
+ }
322
+ lines.push("");
267
323
  }
268
- lines.push("");
269
- }
270
324
 
271
- if (capped.length === 0 && uncapped.length === 0) {
272
- lines.push("No usage data. Session may have expired.");
273
- lines.push("Run `nan auth login` to refresh.");
325
+ if (others.length > 0) {
326
+ lines.push("Other models (no documented cap):", "");
327
+ for (const usage of others) {
328
+ lines.push(`${usage.model}: ${formatTokens(usage.total_tokens)} used · ${requestsSuffix(usage.total_tokens, usage.api_requests)}`);
329
+ }
330
+ lines.push("");
331
+ }
274
332
  }
275
333
 
334
+ lines.push(
335
+ `Window totals: ${formatTokens(totalTokens)} tokens ` +
336
+ `(${formatTokens(totals?.prompt_tokens ?? 0)} prompt / ${formatTokens(totals?.completion_tokens ?? 0)} completion) · ` +
337
+ `${formatCount(totalRequests)} requests`,
338
+ );
339
+ const allTime = report.all_time;
340
+ if (allTime) {
341
+ const cached = allTime.cached_at ? ` (cached ${allTime.cached_at.slice(0, 10)})` : "";
342
+ lines.push(`All time: ${formatTokens(allTime.total_tokens)} tokens · ${formatCount(allTime.api_requests)} requests${cached}`);
343
+ }
344
+ lines.push("💡 Source: GET /v1/usage · caps from https://nan.builders/docs/models");
276
345
  return lines.join("\n");
277
346
  }
278
347
 
348
+ /** Map an endpoint/transport failure to an actionable message. */
349
+ export function formatUsageError(error: UsageFetchError): string {
350
+ const { status, retryAfterSeconds, detail } = error;
351
+ const suffix = detail ? ` (${detail})` : "";
352
+ if (status === 401) return `NaN rejected the API key (401). Run /login nan or set ${NAN_API_KEY_ENV} to a valid key${suffix}.`;
353
+ if (status === 404) return `NaN has no usage identity for this account (404): nothing to report${suffix}.`;
354
+ if (status === 429) {
355
+ const wait = retryAfterSeconds === undefined ? "a few" : String(retryAfterSeconds);
356
+ return `Rate limited by /usage (429): retry in ${wait}s — 30 requests/min, separate from the model endpoints${suffix}.`;
357
+ }
358
+ if (status === 409) return `The API key alias is reserved for a service key (409); usage is not reported for it${suffix}.`;
359
+ if (status === 400) return `NaN rejected the /usage parameters (400)${suffix}.`;
360
+ if (status !== undefined && status >= 500) return `NaN /usage is failing (HTTP ${status})${suffix}. Try again shortly.`;
361
+ return `Could not fetch NaN usage${suffix}.`;
362
+ }
363
+
364
+ // ── Endpoint client ───────────────────────────────────────────────────────
365
+
366
+ export interface FetchUsageOptions {
367
+ apiKey: string;
368
+ window: UsageWindow;
369
+ fetchImpl?: typeof fetch;
370
+ timeoutMs?: number;
371
+ }
372
+
373
+ /**
374
+ * `GET /v1/usage?start_date&end_date&limit=1` with Bearer auth.
375
+ * `limit=1` keeps the payload small: `totals` always spans the whole window,
376
+ * so the daily rows are never needed. Never throws — failures come back as
377
+ * `{ ok: false, error }`.
378
+ */
379
+ export async function fetchUsageReport(options: FetchUsageOptions): Promise<FetchUsageResult> {
380
+ const { apiKey, window, fetchImpl = fetch, timeoutMs = USAGE_TIMEOUT_MS } = options;
381
+ const url = `${NAN_USAGE_URL}?start_date=${window.start}&end_date=${window.end}&limit=1`;
382
+
383
+ const controller = new AbortController();
384
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
385
+ try {
386
+ const response = await fetchImpl(url, {
387
+ method: "GET",
388
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
389
+ cache: "no-store",
390
+ signal: controller.signal,
391
+ });
392
+ if (!response.ok) {
393
+ const retryAfter = Number(response.headers.get("retry-after"));
394
+ let detail: string | undefined;
395
+ try {
396
+ const body = (await response.json()) as { error?: { message?: string } };
397
+ detail = body?.error?.message;
398
+ } catch {
399
+ // Non-JSON error body (proxy/CDN page) — status alone is enough.
400
+ }
401
+ return {
402
+ ok: false,
403
+ error: {
404
+ status: response.status,
405
+ ...(Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}),
406
+ ...(detail ? { detail } : {}),
407
+ },
408
+ };
409
+ }
410
+ const report = (await response.json()) as UsageReport;
411
+ if (!report || typeof report !== "object" || !report.totals) {
412
+ return { ok: false, error: { detail: "unexpected payload from /v1/usage (no totals)" } };
413
+ }
414
+ return { ok: true, report };
415
+ } catch (error) {
416
+ return { ok: false, error: { detail: error instanceof Error ? error.message : String(error) } };
417
+ } finally {
418
+ clearTimeout(timeout);
419
+ }
420
+ }
421
+
279
422
  // ── Command registration ──────────────────────────────────────────────────
280
423
 
281
- export function registerNanUsageCommand(pi: import("@earendil-works/pi-coding-agent").ExtensionAPI): void {
424
+ export interface NanUsageCommandOptions {
425
+ /** Injected transport (tests); defaults to the global fetch. */
426
+ fetchImpl?: typeof fetch;
427
+ /** Injected API-key resolution (tests); defaults to pi's registry + env. */
428
+ resolveApiKey?: (ctx: ExtensionCommandContext) => Promise<string | undefined>;
429
+ }
430
+
431
+ export function registerNanUsageCommand(pi: ExtensionAPI, options: NanUsageCommandOptions = {}): void {
282
432
  if (typeof pi.registerCommand !== "function") return;
283
433
 
434
+ const resolveApiKey = options.resolveApiKey ?? (async (ctx: ExtensionCommandContext): Promise<string | undefined> => {
435
+ const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
436
+ return resolveNanApiKey(registryKey);
437
+ });
438
+
284
439
  pi.registerCommand("nan-usage", {
285
- description: "Show NaN quota status: token limits, usage, and time until billing reset",
286
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
287
- const token = readNanCliSessionToken();
288
-
289
- if (token) {
290
- ctx.ui.notify("Fetching usage from NaN dashboard...", "info");
291
- const data = await fetchDashboardQuota(token);
292
- if (data) {
293
- const { capped, uncapped } = parseDashboardQuota(data);
294
- ctx.ui.notify(buildDashboardMessage(capped, uncapped), "info");
295
- } else {
296
- ctx.ui.notify(
297
- "Failed to fetch dashboard data. Session may have expired.\n" +
298
- "Run `nan auth login` to refresh.",
299
- "warning",
300
- );
301
- }
302
- } else {
440
+ description: "Show NaN token usage vs monthly caps, window/all-time totals, and time until billing reset",
441
+ getArgumentCompletions: (argumentPrefix: string) => {
442
+ const prefix = argumentPrefix.trim().toLowerCase();
443
+ const items = [
444
+ { value: "", label: "(current month)", description: "Usage for the current UTC month" },
445
+ { value: "7", label: "7", description: "Rolling 7-day window" },
446
+ { value: "30", label: "30", description: "Rolling 30-day window" },
447
+ { value: "90", label: "90", description: "Rolling 90-day window (API maximum)" },
448
+ { value: "help", label: "help", description: "Show the usage line" },
449
+ ];
450
+ const filtered = items.filter((item) => item.value.startsWith(prefix));
451
+ return filtered.length > 0 ? filtered : null;
452
+ },
453
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
454
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
455
+ const parsed = parseUsageArgs(tokens);
456
+
457
+ if (parsed.kind === "help") {
458
+ ctx.ui.notify(USAGE_LINE, "info");
459
+ return;
460
+ }
461
+ if (parsed.kind === "invalid") {
462
+ ctx.ui.notify(parsed.message, "warning");
463
+ return;
464
+ }
465
+
466
+ const apiKey = await resolveApiKey(ctx);
467
+ if (!apiKey) {
303
468
  ctx.ui.notify(buildStaticMessage(), "info");
469
+ return;
304
470
  }
471
+
472
+ ctx.ui.notify("Fetching usage from GET /v1/usage ...", "info");
473
+ const result = await fetchUsageReport({
474
+ apiKey,
475
+ window: parsed.window,
476
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
477
+ });
478
+ ctx.ui.notify(
479
+ result.ok ? buildUsageMessage(result.report) : formatUsageError(result.error),
480
+ result.ok ? "info" : "warning",
481
+ );
305
482
  },
306
483
  });
307
484
  }