@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/AGENTS.md +70 -23
- package/README.es.md +67 -37
- package/README.md +66 -40
- package/package.json +5 -5
- package/scripts/check-nan-mcp-server.ts +11 -3
- package/scripts/check-pi-sdk-versions.mjs +392 -0
- package/scripts/generate-models.ts +64 -12
- package/scripts/manual-overrides.ts +20 -8
- package/scripts/models.generated.ts +55 -52
- package/src/commands.ts +132 -52
- package/src/context-overflow-classifier.ts +1 -1
- package/src/cross-model-thinking-guard.ts +1 -1
- package/src/index.ts +81 -41
- package/src/mcp/api-key.ts +76 -0
- package/src/mcp/media-server.ts +56 -0
- package/src/provider-factory.ts +7 -2
- package/src/usage.ts +352 -175
- package/src/mcp/nan-media.ts +0 -236
- package/src/mcp/nan-search.ts +0 -241
- package/src/mcp/stdio-client.ts +0 -222
package/src/usage.ts
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
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-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 {
|
|
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
|
-
// ──
|
|
57
|
+
// ── GET /v1/usage types ───────────────────────────────────────────────────
|
|
49
58
|
|
|
50
|
-
interface
|
|
59
|
+
export interface UsageModelTotals {
|
|
51
60
|
model: string;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
const
|
|
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
|
|
275
|
+
"📊 NaN Usage",
|
|
276
|
+
"",
|
|
277
|
+
`🗓️ Window: ${start} → ${end} UTC (${days} day${days === 1 ? "" : "s"})`,
|
|
237
278
|
"",
|
|
238
|
-
|
|
279
|
+
resetLine(now),
|
|
239
280
|
"",
|
|
240
281
|
];
|
|
241
282
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
}
|