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