@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/README.es.md +49 -24
- package/README.md +65 -24
- package/package.json +3 -2
- package/scripts/check-pi-sdk-versions.mjs +392 -0
- package/scripts/generate-models.ts +78 -1
- package/scripts/manual-overrides.ts +57 -1
- package/scripts/models.generated.ts +61 -6
- package/src/fetch-models.ts +7 -0
- package/src/usage.ts +347 -174
package/src/usage.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `/nan-usage` — slash command
|
|
2
|
+
* `/nan-usage` — slash command: NaN token usage merged with the documented
|
|
3
|
+
* quota limits.
|
|
3
4
|
*
|
|
4
|
-
* NaN's
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
// ──
|
|
55
|
+
// ── GET /v1/usage types ───────────────────────────────────────────────────
|
|
48
56
|
|
|
49
|
-
interface
|
|
57
|
+
export interface UsageModelTotals {
|
|
50
58
|
model: string;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
const
|
|
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
|
|
273
|
+
"📊 NaN Usage",
|
|
274
|
+
"",
|
|
275
|
+
`🗓️ Window: ${start} → ${end} UTC (${days} day${days === 1 ? "" : "s"})`,
|
|
236
276
|
"",
|
|
237
|
-
|
|
277
|
+
resetLine(now),
|
|
238
278
|
"",
|
|
239
279
|
];
|
|
240
280
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
}
|