@gtrabanco/pi-nan-provider 0.7.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # @gtrabanco/pi-nan-provider
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
- [![Version](https://img.shields.io/badge/version-0.6.5-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
4
+ [![Version](https://img.shields.io/badge/version-0.8.0-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
5
5
 
6
6
  [NaN Builders](https://nan.builders) model provider + MCP bridges para [pi](https://github.com/earendil-works/pi).
7
7
 
@@ -156,74 +156,99 @@ bun run check-nan-mcp-server --issue # crea/refresca el issue
156
156
 
157
157
  ## 📊 Uso de Cuotas: `/nan-usage`
158
158
 
159
- Muestra tu uso de tokens de NaN por modelo, los límites mensuales y el tiempo hasta el reinicio del ciclo de facturación.
159
+ Muestra tu uso de tokens de NaN por modelo combinado con los límites mensuales documentados, los totales de la ventana, tus totales acumulados y el tiempo hasta el reinicio del ciclo de facturación.
160
160
 
161
161
  ### Cómo funciona
162
162
 
163
- `/nan-usage` lee el token de sesión desde `~/.config/nan/session.json` — el mismo archivo que usa la [CLI de NaN](https://github.com/helmcode/nan-cli). Si el archivo existe y contiene una sesión válida, el comando obtiene datos de uso reales del dashboard de NaN. Si no, muestra los límites de cuota estáticos de la documentación.
163
+ `/nan-usage` llama al endpoint [`GET /v1/usage`](https://nan.builders/docs/api#tag/usage) de NaN con **la misma API key que ya usas para chat** — la credencial guardada en pi (`/login nan`) o `NAN_API_KEY`. Sin login de la CLI, sin cookie de sesión, sin configuración extra.
164
+
165
+ El endpoint devuelve tu uso (filas por fecha y modelo) y los totales de la ventana solicitada — máximo 90 días inclusivos. El comando lee esos totales y los combina con los límites publicados en la [documentación de NaN](https://nan.builders/docs/models) para mostrarte qué porcentaje de cada cuota mensual has consumido. `/usage` solo reporta consumo, así que los límites siempre vienen de la tabla documentada (comprobado 2026-09-27).
166
+
167
+ Límite de tasa: 30 peticiones por minuto para `/usage`, separado del de los endpoints de modelos.
168
+
169
+ ### Uso
170
+
171
+ ```
172
+ /nan-usage # mes en curso en UTC (coincide con las cuotas mensuales)
173
+ /nan-usage 7 # ventana móvil de 7 días (1-90)
174
+ /nan-usage 30 # ventana móvil de 30 días
175
+ /nan-usage help # muestra la línea de uso
176
+ ```
164
177
 
165
178
  ### Configuración
166
179
 
167
- 1. **Instala la CLI de NaN**:
168
- ```bash
169
- curl -fsSL https://nan.builders/install | sh
180
+ 1. **Autentica el proveedor `nan` en pi** con tu clave `sk-...`:
181
+ ```
182
+ /login nan
170
183
  ```
171
- 2. **Inicia sesión**:
184
+ o expórtala en su lugar:
172
185
  ```bash
173
- nan auth login
186
+ export NAN_API_KEY=sk-...
174
187
  ```
175
- Te envía un enlace de inicio de sesión por email. Pega el enlace en la terminal.
176
- 3. **Usa en pi**:
188
+ 2. **Úsalo en pi**:
177
189
  ```
178
190
  /nan-usage
179
191
  ```
180
192
 
181
193
  > [!TIP]
182
- > El token de sesión se comparte automáticamente — no necesitas variables de entorno ni configuración extra. Si la sesión expira, ejecuta `nan auth login` de nuevo.
194
+ > ¿No se resuelve ninguna API key? El comando muestra la tabla estática de cuotas de la documentación y te indica cómo autenticarte.
183
195
 
184
196
  ### Lo que ves
185
197
 
186
- **Con sesión válida** (uso real):
198
+ **Con API key** (uso real):
187
199
  ```
188
- 📊 NaN Quota Status
200
+ 📊 NaN Usage
201
+
202
+ 🗓️ Window: 2026-09-01 → 2026-09-27 UTC (27 days)
189
203
 
190
- ⏱️ Next billing reset: 2026-10-01 UTC (8d 14h 32m 15s)
204
+ ⏱️ Next billing reset: 2026-09-30 UTC (2d 14h 30m 0s)
191
205
 
192
206
  Models with monthly caps:
193
207
 
194
208
  DeepSeek V4 Flash:
195
- [████████░░░░░░░░░░░░] 40.2%
196
- Used: 1.2B / 3.0B (1.8B remaining)
209
+ [████████░░░░░░░░░░░░] 40.0% of monthly cap
210
+ Used: 1.2B / 3.0B (1.8B remaining) · 5,120 requests
197
211
 
198
212
  MiMo V2.5:
199
- [██░░░░░░░░░░░░░░░░░░] 12.5%
200
- Used: 125.0M / 1.0B (875.0M remaining)
213
+ [███░░░░░░░░░░░░░░░░░] 12.5% of monthly cap
214
+ Used: 125.0M / 1.0B (875.0M remaining) · 840 requests
215
+
216
+ GLM 5.3 👑:
217
+ [████████░░░░░░░░░░░░] 40.0% of monthly cap
218
+ Used: 1.2B / 3.0B (1.8B remaining) · 2,400 requests
219
+ ↳ rolling window: 400.0M / 4h (daily granularity — /usage cannot break it down)
201
220
 
202
221
  Uncapped models:
203
222
 
204
- Qwen 3.6: 890.5K used
205
- Gemma 4: 234.1K used
223
+ Qwen 3.6: 890.5K used · 123 requests
224
+
225
+ Window totals: 2.6B tokens (2.0B prompt / 640.0M completion) · 18,432 requests
226
+ All time: 13.2B tokens · 41,250 requests (cached 2026-02-01)
227
+ 💡 Source: GET /v1/usage · caps from https://nan.builders/docs/models
206
228
  ```
207
229
 
208
- **Sin sesión** (solo límites estáticos):
230
+ **Sin API key** (solo límites estáticos):
209
231
  ```
210
232
  📊 NaN Quota Status (static limits)
211
233
 
212
- ⏱️ Next billing reset: 2026-10-01 UTC (8d 14h 32m 15s)
234
+ ⏱️ Next billing reset: 2026-09-30 UTC (2d 14h 30m 0s)
213
235
 
214
236
  Model Monthly Cap
215
237
  ─────────────────────────────────────────────────
216
238
  DeepSeek V4 Flash 3.0B
217
239
  MiMo V2.5 1.0B
240
+ MiMo V2.6 Flash 1.0B
218
241
  Qwen 3.6 uncapped
219
242
  Gemma 4 uncapped
220
243
  Qwen 3.8 Flash 500.0M
221
244
  GLM 5.3 Flash 2.0B
222
- GLM 5.3 👑 3.0B (rolling 400.0M/4h)
245
+ GLM 5.3 3.0B 👑 (rolling 400.0M/4h)
223
246
 
224
- 💡 Run `nan auth login` to see real usage data.
247
+ 💡 Set NAN_API_KEY or run `/login nan` to see real usage (GET /v1/usage).
225
248
  ```
226
249
 
250
+ Los errores siguen siendo accionables: `401` → ejecuta `/login nan` o corrige `NAN_API_KEY`, `404` → la cuenta no tiene identidad de uso que reportar, `429` → reintenta tras los segundos de `Retry-After`.
251
+
227
252
  ---
228
253
 
229
254
  ## 📊 Modelos
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @gtrabanco/pi-nan-provider
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
- [![Version](https://img.shields.io/badge/version-0.6.5-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
4
+ [![Version](https://img.shields.io/badge/version-0.8.0-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
5
5
 
6
6
  [NaN Builders](https://nan.builders) model provider + MCP bridges for [pi](https://github.com/earendil-works/pi).
7
7
 
@@ -156,74 +156,99 @@ bun run check-nan-mcp-server --issue # create/refresh the issue
156
156
 
157
157
  ## 📊 Quota Usage: `/nan-usage`
158
158
 
159
- Shows your NaN token usage per model, monthly limits, and time until the billing cycle resets.
159
+ Shows your NaN token usage per model merged with the documented monthly caps, the window totals, your all-time totals, and the time until the billing cycle resets.
160
160
 
161
161
  ### How it works
162
162
 
163
- `/nan-usage` reads the session token from `~/.config/nan/session.json` — the same file the [NaN CLI](https://github.com/helmcode/nan-cli) uses. If the file exists and contains a valid session, the command fetches real usage data from NaN's dashboard. Otherwise, it shows static quota limits from the docs.
163
+ `/nan-usage` calls NaN's [`GET /v1/usage`](https://nan.builders/docs/api#tag/usage) endpoint with the **same API key you already use for chat** — pi's stored credential (`/login nan`) or `NAN_API_KEY`. No CLI login, no session cookie, no extra setup.
164
+
165
+ The endpoint returns your usage (rows per date and model) plus totals covering the requested window — at most 90 inclusive days. The command reads those totals and merges them with the caps published in the [NaN docs](https://nan.builders/docs/models) to show how much of each monthly cap you have consumed. `/usage` reports consumption only, so the caps themselves always come from the documented table (checked 2026-09-27).
166
+
167
+ Rate limit: 30 requests per minute for `/usage`, separate from the model endpoints.
168
+
169
+ ### Usage
170
+
171
+ ```
172
+ /nan-usage # current UTC month (matches the monthly caps)
173
+ /nan-usage 7 # rolling 7-day window (1-90)
174
+ /nan-usage 30 # rolling 30-day window
175
+ /nan-usage help # show the usage line
176
+ ```
164
177
 
165
178
  ### Setup
166
179
 
167
- 1. **Install the NaN CLI**:
168
- ```bash
169
- curl -fsSL https://nan.builders/install | sh
180
+ 1. **Authenticate the `nan` provider in pi** with your `sk-...` key:
181
+ ```
182
+ /login nan
170
183
  ```
171
- 2. **Log in**:
184
+ or export it instead:
172
185
  ```bash
173
- nan auth login
186
+ export NAN_API_KEY=sk-...
174
187
  ```
175
- This sends a sign-in link to your email. Paste the link back into the terminal.
176
- 3. **Use in pi**:
188
+ 2. **Use in pi**:
177
189
  ```
178
190
  /nan-usage
179
191
  ```
180
192
 
181
193
  > [!TIP]
182
- > The session token is shared automatically — no env vars or extra config needed. If the session expires, run `nan auth login` again.
194
+ > No API key resolves? The command falls back to the static quota table from the docs and tells you how to authenticate.
183
195
 
184
196
  ### What you see
185
197
 
186
- **With a valid session** (real usage):
198
+ **With an API key** (real usage):
187
199
  ```
188
- 📊 NaN Quota Status
200
+ 📊 NaN Usage
201
+
202
+ 🗓️ Window: 2026-09-01 → 2026-09-27 UTC (27 days)
189
203
 
190
- ⏱️ Next billing reset: 2026-10-01 UTC (8d 14h 32m 15s)
204
+ ⏱️ Next billing reset: 2026-09-30 UTC (2d 14h 30m 0s)
191
205
 
192
206
  Models with monthly caps:
193
207
 
194
208
  DeepSeek V4 Flash:
195
- [████████░░░░░░░░░░░░] 40.2%
196
- Used: 1.2B / 3.0B (1.8B remaining)
209
+ [████████░░░░░░░░░░░░] 40.0% of monthly cap
210
+ Used: 1.2B / 3.0B (1.8B remaining) · 5,120 requests
197
211
 
198
212
  MiMo V2.5:
199
- [██░░░░░░░░░░░░░░░░░░] 12.5%
200
- Used: 125.0M / 1.0B (875.0M remaining)
213
+ [███░░░░░░░░░░░░░░░░░] 12.5% of monthly cap
214
+ Used: 125.0M / 1.0B (875.0M remaining) · 840 requests
215
+
216
+ GLM 5.3 👑:
217
+ [████████░░░░░░░░░░░░] 40.0% of monthly cap
218
+ Used: 1.2B / 3.0B (1.8B remaining) · 2,400 requests
219
+ ↳ rolling window: 400.0M / 4h (daily granularity — /usage cannot break it down)
201
220
 
202
221
  Uncapped models:
203
222
 
204
- Qwen 3.6: 890.5K used
205
- Gemma 4: 234.1K used
223
+ Qwen 3.6: 890.5K used · 123 requests
224
+
225
+ Window totals: 2.6B tokens (2.0B prompt / 640.0M completion) · 18,432 requests
226
+ All time: 13.2B tokens · 41,250 requests (cached 2026-02-01)
227
+ 💡 Source: GET /v1/usage · caps from https://nan.builders/docs/models
206
228
  ```
207
229
 
208
- **Without a session** (static limits only):
230
+ **Without an API key** (static limits only):
209
231
  ```
210
232
  📊 NaN Quota Status (static limits)
211
233
 
212
- ⏱️ Next billing reset: 2026-10-01 UTC (8d 14h 32m 15s)
234
+ ⏱️ Next billing reset: 2026-09-30 UTC (2d 14h 30m 0s)
213
235
 
214
236
  Model Monthly Cap
215
237
  ─────────────────────────────────────────────────
216
238
  DeepSeek V4 Flash 3.0B
217
239
  MiMo V2.5 1.0B
240
+ MiMo V2.6 Flash 1.0B
218
241
  Qwen 3.6 uncapped
219
242
  Gemma 4 uncapped
220
243
  Qwen 3.8 Flash 500.0M
221
244
  GLM 5.3 Flash 2.0B
222
- GLM 5.3 👑 3.0B (rolling 400.0M/4h)
245
+ GLM 5.3 3.0B 👑 (rolling 400.0M/4h)
223
246
 
224
- 💡 Run `nan auth login` to see real usage data.
247
+ 💡 Set NAN_API_KEY or run `/login nan` to see real usage (GET /v1/usage).
225
248
  ```
226
249
 
250
+ Failures stay actionable: `401` → run `/login nan` or fix `NAN_API_KEY`, `404` → the account has no usage identity to report, `429` → retry after the `Retry-After` seconds.
251
+
227
252
  ---
228
253
 
229
254
  ## 📊 Models
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-nan-provider",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "NaN Builders (api.nan.builders) model provider for pi - OpenAI-compatible registration with a models.dev-generated fallback, tier-aware live catalog, and MCP bridges (official web search + optional community media server)",
5
5
  "keywords": [
6
6
  "pi",
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Monitor PI SDK versions and NaN-related dependencies for pi-nan-provider.
4
+ *
5
+ * This script:
6
+ * 1. Reads current versions from package.json (devDependencies and peerDependencies).
7
+ * 2. Queries npm for latest versions.
8
+ * 3. Flags version bumps and checks compatibility.
9
+ *
10
+ * Exit 0 = nothing to do.
11
+ * Exit 1 = updates available.
12
+ *
13
+ * Usage:
14
+ * node scripts/check-pi-sdk-versions.mjs --report
15
+ * node scripts/check-pi-sdk-versions.mjs --fail
16
+ * node scripts/check-pi-sdk-versions.mjs (JSON output for CI)
17
+ */
18
+
19
+ import https from "node:https";
20
+ import { fileURLToPath } from "node:url";
21
+ import { readFileSync } from "node:fs";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Config
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const ROOT = fileURLToPath(new URL("../", import.meta.url));
28
+
29
+ /**
30
+ * Packages that ship from the `@earendil-works` scope and carry Pi SDK code.
31
+ * pi-nan-provider depends on these for AI model operations.
32
+ */
33
+ const SDK_PACKAGES = [
34
+ "@earendil-works/pi-ai",
35
+ "@earendil-works/pi-coding-agent",
36
+ ];
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Package.json reader
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function readPackageJson() {
43
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf-8");
44
+ return JSON.parse(raw);
45
+ }
46
+
47
+ /**
48
+ * Get the currently constrained version for a package from
49
+ * devDependencies and peerDependencies.
50
+ */
51
+ function getConstrainedVersion(pkg, packageName) {
52
+ const sources = ["devDependencies", "peerDependencies"];
53
+ for (const src of sources) {
54
+ const ver = pkg[src]?.[packageName];
55
+ if (ver) return { version: ver, source: src };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // npm registry client (no dependencies)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function npmGet(packageName) {
65
+ return new Promise((resolve, reject) => {
66
+ const req = https.get(
67
+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
68
+ { headers: { accept: "application/json" } },
69
+ (res) => {
70
+ if (res.statusCode !== 200) {
71
+ reject(new Error(`npm registry returned ${res.statusCode} for ${packageName}`));
72
+ return;
73
+ }
74
+ let data = "";
75
+ res.on("data", (chunk) => (data += chunk));
76
+ res.on("end", () => resolve(JSON.parse(data)));
77
+ }
78
+ );
79
+ req.on("error", reject);
80
+ req.setTimeout(15_000, () => {
81
+ req.destroy();
82
+ reject(new Error(`Timeout fetching ${packageName} from npm`));
83
+ });
84
+ });
85
+ }
86
+
87
+ function npmLatest(packageName) {
88
+ return npmGet(packageName).then((data) => {
89
+ const distTags = data["dist-tags"] ?? {};
90
+ const latestVersion = distTags["latest"];
91
+ if (!latestVersion) {
92
+ throw new Error(`No "latest" dist-tag for ${packageName}`);
93
+ }
94
+ const versions = Object.keys(data.versions ?? {}).sort((a, b) => {
95
+ const pa = a.split(".").map(Number);
96
+ const pb = b.split(".").map(Number);
97
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
98
+ const va = pa[i] ?? 0;
99
+ const vb = pb[i] ?? 0;
100
+ if (va < vb) return -1;
101
+ if (va > vb) return 1;
102
+ }
103
+ return 0;
104
+ });
105
+ return { latestVersion, latestTag: "latest", allVersions: versions, data };
106
+ });
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Semver helpers
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function parseSemver(version) {
114
+ // Handle versions like "1" (major only) or "1.2" (major.minor)
115
+ let normalized = version;
116
+ while (normalized.split(".").length < 3) {
117
+ normalized += ".0";
118
+ }
119
+ const m = normalized.match(/^(\d+)\.(\d+)\.(\d+)/);
120
+ if (!m) return null;
121
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), raw: version };
122
+ }
123
+
124
+ function bumpLevel(current, target) {
125
+ const c = parseSemver(current);
126
+ const t = parseSemver(target);
127
+ if (!c || !t) return "unknown";
128
+ if (t.major > c.major) return "major";
129
+ if (t.minor > c.minor) return "minor";
130
+ if (t.patch > c.patch) return "patch";
131
+ return "none";
132
+ }
133
+
134
+ /**
135
+ * Check if a version satisfies a semver range.
136
+ * Handles caret, tilde, comparison operators, and ranges.
137
+ */
138
+ function checkSemverSatisfies(version, range) {
139
+ const cVer = parseSemver(version);
140
+ if (!cVer) return false;
141
+
142
+ // Handle disjunction (OR): ">=1.0.0 <2.0.0 || >=3.0.0"
143
+ if (range.includes("||")) {
144
+ const parts = range.split("||").map((p) => p.trim());
145
+ return parts.some((part) => checkSemverSatisfies(version, part));
146
+ }
147
+
148
+ // Handle conjunction (AND via spaces): ">=1.0.0 <2.0.0"
149
+ const parts = [];
150
+ let current = "";
151
+ const tokens = range.split(/\s+/);
152
+ for (const token of tokens) {
153
+ if (token.startsWith(">=") || token.startsWith("<=") ||
154
+ token.startsWith(">") || token.startsWith("<") || token.startsWith("=") ||
155
+ token.startsWith("^") || token.startsWith("~")) {
156
+ if (current && !current.startsWith("^") && !current.startsWith("~")) {
157
+ parts.push(current);
158
+ }
159
+ current = token;
160
+ } else if (current) {
161
+ current = current + " " + token;
162
+ } else {
163
+ current = token;
164
+ }
165
+ }
166
+ if (current) parts.push(current);
167
+
168
+ if (parts.length > 1) {
169
+ // Check each part
170
+ return parts.every((part) => checkSemverSatisfies(version, part));
171
+ }
172
+
173
+ // Single constraint
174
+ const constraint = range.trim();
175
+
176
+ // Handle caret: ^X.Y.Z
177
+ if (constraint.startsWith("^")) {
178
+ const target = parseSemver(constraint.slice(1));
179
+ if (!target) return false;
180
+ // ^0.x: same major and minor >= target.minor
181
+ // ^1.x+: same major and minor >= target.minor
182
+ return cVer.major === target.major && cVer.minor >= target.minor;
183
+ }
184
+
185
+ // Handle tilde: ~X.Y.Z
186
+ if (constraint.startsWith("~")) {
187
+ const target = parseSemver(constraint.slice(1));
188
+ if (!target) return false;
189
+ return cVer.major === target.major && cVer.minor === target.minor && cVer.patch >= target.patch;
190
+ }
191
+
192
+ // Handle comparison: >=X.Y.Z, <X.Y.Z, =X.Y.Z, etc.
193
+ let op = "";
194
+ let verStr = constraint;
195
+ if (constraint.startsWith(">=")) {
196
+ op = ">=";
197
+ verStr = constraint.slice(2);
198
+ } else if (constraint.startsWith("<=")) {
199
+ op = "<=";
200
+ verStr = constraint.slice(2);
201
+ } else if (constraint.startsWith(">")) {
202
+ op = ">";
203
+ verStr = constraint.slice(1);
204
+ } else if (constraint.startsWith("<")) {
205
+ op = "<";
206
+ verStr = constraint.slice(1);
207
+ } else if (constraint.startsWith("=")) {
208
+ op = "=";
209
+ verStr = constraint.slice(1);
210
+ }
211
+
212
+ const target = parseSemver(verStr);
213
+ if (!target) return false;
214
+
215
+ const cmp = semverCompare(cVer, target);
216
+
217
+ switch (op) {
218
+ case ">=": return cmp >= 0;
219
+ case "<=": return cmp <= 0;
220
+ case ">": return cmp > 0;
221
+ case "<": return cmp < 0;
222
+ case "=": return cmp === 0;
223
+ default: return false;
224
+ }
225
+ }
226
+
227
+ function semverCompare(a, b) {
228
+ if (a.major !== b.major) return a.major - b.major;
229
+ if (a.minor !== b.minor) return a.minor - b.minor;
230
+ return (a.patch ?? 0) - (b.patch ?? 0);
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Main
235
+ // ---------------------------------------------------------------------------
236
+
237
+ async function main() {
238
+ const args = process.argv.slice(2);
239
+ const flags = {
240
+ report: args.includes("--report"),
241
+ fail: args.includes("--fail"),
242
+ };
243
+
244
+ const pkg = readPackageJson();
245
+
246
+ const results = [];
247
+ let exitCode = 0;
248
+
249
+ for (const packageName of SDK_PACKAGES) {
250
+ try {
251
+ const npmInfo = await npmLatest(packageName);
252
+ const constrained = getConstrainedVersion(pkg, packageName);
253
+
254
+ if (!constrained) {
255
+ results.push({
256
+ packageName,
257
+ status: "not-found",
258
+ message: "Not found in package.json dependencies",
259
+ });
260
+ continue;
261
+ }
262
+
263
+ // Extract numeric version from constraint
264
+ let constraintVer = constrained.version;
265
+ // Remove version range operators to get the base version
266
+ if (constraintVer.startsWith("^")) {
267
+ constraintVer = constraintVer.slice(1);
268
+ } else if (constraintVer.startsWith("~")) {
269
+ constraintVer = constraintVer.slice(1);
270
+ }
271
+
272
+ const bump = bumpLevel(constraintVer, npmInfo.latestVersion);
273
+
274
+ // Check if latest satisfies peer dependencies
275
+ const peerRange = pkg.peerDependencies?.[packageName] ?? null;
276
+ const latestSatisfiesPeer = peerRange
277
+ ? checkSemverSatisfies(npmInfo.latestVersion, peerRange)
278
+ : true;
279
+
280
+ // Check if current satisfies peer dependencies
281
+ const currentSatisfiesPeer = peerRange
282
+ ? checkSemverSatisfies(constraintVer, peerRange)
283
+ : true;
284
+
285
+ const result = {
286
+ packageName,
287
+ source: constrained.source,
288
+ constrainedVersion: constrained.version,
289
+ constraintVer,
290
+ latestVersion: npmInfo.latestVersion,
291
+ latestTag: npmInfo.latestTag,
292
+ bump,
293
+ peerRange,
294
+ latestSatisfiesPeer,
295
+ currentSatisfiesPeer,
296
+ totalVersions: npmInfo.allVersions.length,
297
+ allVersions: npmInfo.allVersions,
298
+ };
299
+
300
+ // Determine status
301
+ if (!currentSatisfiesPeer) {
302
+ result.status = "WARNING";
303
+ result.message = `Current version ${constraintVer} does NOT satisfy peer dependency range ${peerRange}`;
304
+ exitCode = 1;
305
+ } else if (bump === "major" && npmInfo.latestVersion !== constraintVer) {
306
+ result.status = "BREAKING";
307
+ result.message = `Major version bump: ${constraintVer} → ${npmInfo.latestVersion}. Review changelog before upgrading.`;
308
+ if (latestSatisfiesPeer) {
309
+ result.message += ` Latest version satisfies peer dependency range.`;
310
+ } else {
311
+ result.message += ` ⚠️ Latest version does NOT satisfy peer dependency range!`;
312
+ }
313
+ exitCode = 1;
314
+ } else if (bump === "minor" && npmInfo.latestVersion !== constraintVer) {
315
+ result.status = "minor";
316
+ result.message = `Minor version bump: ${constraintVer} → ${npmInfo.latestVersion}. Check for API changes.`;
317
+ exitCode = 1;
318
+ } else if (bump === "patch" && npmInfo.latestVersion !== constraintVer) {
319
+ result.status = "patch";
320
+ result.message = `Patch version bump: ${constraintVer} → ${npmInfo.latestVersion}. May be safe to update.`;
321
+ } else {
322
+ result.status = "up-to-date";
323
+ result.message = `Already on latest version.`;
324
+ }
325
+
326
+ if (latestSatisfiesPeer && bump !== "none") {
327
+ result.message += ` Latest satisfies peer dependency range.`;
328
+ } else if (!latestSatisfiesPeer) {
329
+ result.message += ` Latest does NOT satisfy peer dependency range!`;
330
+ }
331
+
332
+ results.push(result);
333
+ } catch (err) {
334
+ results.push({
335
+ packageName,
336
+ status: "error",
337
+ message: err.message,
338
+ });
339
+ exitCode = 1;
340
+ }
341
+ }
342
+
343
+ // Print report
344
+ if (flags.report) {
345
+ console.log("\n=== PI SDK Version Monitor (pi-nan-provider) ===\n");
346
+
347
+ for (const r of results) {
348
+ if (r.status === "not-found") {
349
+ console.log(` ❌ ${r.packageName}: ${r.message}`);
350
+ continue;
351
+ }
352
+ if (r.status === "error") {
353
+ console.log(` ⚠️ ${r.packageName}: ${r.message}`);
354
+ continue;
355
+ }
356
+ if (r.status === "up-to-date") {
357
+ console.log(` ✅ ${r.packageName}: ${r.message} (${r.latestVersion})`);
358
+ continue;
359
+ }
360
+ if (r.status === "WARNING") {
361
+ console.log(` ❗ ${r.packageName}: ${r.message} (current: ${r.constrainedVersion})`);
362
+ continue;
363
+ }
364
+ const icon = r.status === "BREAKING" ? "🔴" : r.status === "minor" ? "🟡" : "🟢";
365
+ console.log(` ${icon} ${r.packageName}: ${r.message} (${r.source}: ${r.constrainedVersion})`);
366
+ console.log(` → Latest available: ${r.latestVersion} (tag: ${r.latestTag})`);
367
+ if (r.peerRange) {
368
+ console.log(` → Peer dependency range: ${r.peerRange}`);
369
+ console.log(` → Latest satisfies peer range: ${r.latestSatisfiesPeer ? "✅" : "❌"}`);
370
+ }
371
+ if (r.bump === "major") {
372
+ console.log(` → Total versions published: ${r.totalVersions}`);
373
+ }
374
+ }
375
+
376
+ console.log();
377
+ } else {
378
+ // JSON output for CI consumption
379
+ console.log(JSON.stringify(results, null, 2));
380
+ }
381
+
382
+ if (flags.fail && exitCode === 1) {
383
+ process.exit(1);
384
+ }
385
+
386
+ process.exit(exitCode);
387
+ }
388
+
389
+ main().catch((err) => {
390
+ console.error("Fatal:", err.message);
391
+ process.exit(2);
392
+ });
@@ -1,7 +1,7 @@
1
1
  // This file is auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually — run `bun run generate-models` to update.
3
3
  //
4
- // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-25T22:43:00.930Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-27T21:25:24.946Z
5
5
  // Provenance: every contextWindow/maxTokens/input/cost value traces to
6
6
  // models.dev or to the per-entry notes below. Nothing is invented; entries
7
7
  // models.dev documents incompletely are omitted and flagged instead.
@@ -436,7 +436,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
436
436
  export const GENERATED_CATALOG_META = {
437
437
  source: "https://models.dev/api.json",
438
438
  modelsDevProvider: "nan",
439
- fetchedAt: "2026-09-25T22:43:00.930Z",
439
+ fetchedAt: "2026-09-27T21:25:24.946Z",
440
440
  modelCount: 7,
441
441
  models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.5","mimo-v2.6-flash","qwen3.6","qwen3.8-flash"],
442
442
  notes: [
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
 
@@ -45,58 +52,88 @@ export const MODEL_QUOTAS: readonly ModelQuota[] = [
45
52
  { model: "glm5.3", label: "GLM 5.3", monthlyCap: 3_000_000_000, rollingWindowCap: 400_000_000, rollingWindowHours: 4, premium: true },
46
53
  ];
47
54
 
48
- // ── Dashboard API types ───────────────────────────────────────────────────
55
+ // ── GET /v1/usage types ───────────────────────────────────────────────────
49
56
 
50
- interface DashboardModelQuota {
57
+ export interface UsageModelTotals {
51
58
  model: string;
52
- tokensUsed: number;
53
- cap: number;
54
- percentage: number;
55
- resetAt: string | null;
56
- windowHours: number | null;
59
+ prompt_tokens: number;
60
+ completion_tokens: number;
61
+ total_tokens: number;
62
+ api_requests: number;
57
63
  }
58
64
 
59
- interface DashboardUncappedModelQuota {
60
- model: string;
61
- tokensUsed: number;
62
- resetAt: string | null;
63
- 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[];
64
71
  }
65
72
 
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
- }>;
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;
75
79
  }
76
80
 
77
- // ── nan-cli session reader ────────────────────────────────────────────────
78
-
79
- interface NanCliSession {
80
- 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;
81
93
  }
82
94
 
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;
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;
96
102
  }
97
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
+
98
117
  // ── Time helpers ──────────────────────────────────────────────────────────
99
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
+
100
137
  function formatDuration(ms: number): string {
101
138
  if (ms <= 0) return "already reset";
102
139
  const totalSeconds = Math.floor(ms / 1000);
@@ -113,97 +150,96 @@ function formatDuration(ms: number): string {
113
150
  return parts.join(" ");
114
151
  }
115
152
 
116
- function getNextBillingReset(): Date {
117
- const now = new Date();
153
+ function getNextBillingReset(now = new Date()): Date {
118
154
  const year = now.getUTCFullYear();
119
155
  const month = now.getUTCMonth();
120
156
  return new Date(Date.UTC(year, month + 1, 0, 0, 0, 0));
121
157
  }
122
158
 
123
- 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 {
124
205
  if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
125
206
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
126
207
  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
127
208
  return String(n);
128
209
  }
129
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
+
130
216
  function progressBar(percentage: number, width = 20): string {
131
- 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);
132
219
  const empty = width - filled;
133
220
  return `[${"█".repeat(filled)}${"░".repeat(empty)}]`;
134
221
  }
135
222
 
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 };
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`;
195
233
  }
196
234
 
197
235
  // ── Message builders ──────────────────────────────────────────────────────
198
236
 
199
- function buildStaticMessage(): string {
200
- const resetDate = getNextBillingReset();
201
- const timeUntilReset = resetDate.getTime() - Date.now();
202
-
237
+ /** Static quota table — shown when no API key resolves. */
238
+ export function buildStaticMessage(now = new Date()): string {
203
239
  const lines: string[] = [
204
240
  "📊 NaN Quota Status (static limits)",
205
241
  "",
206
- `⏱️ Next billing reset: ${resetDate.toISOString().split("T")[0]} UTC (${formatDuration(timeUntilReset)})`,
242
+ resetLine(now),
207
243
  "",
208
244
  "Model Monthly Cap",
209
245
  "─".repeat(45),
@@ -219,89 +255,225 @@ function buildStaticMessage(): string {
219
255
  lines.push(`${quota.label.padEnd(28)} ${capStr}${premiumStr}${rollingStr}`);
220
256
  }
221
257
 
222
- lines.push("");
223
- lines.push("💡 Run `nan auth login` to see real usage data.");
224
-
258
+ lines.push("", `💡 Set ${NAN_API_KEY_ENV} or run \`/login nan\` to see real usage (GET /v1/usage).`);
225
259
  return lines.join("\n");
226
260
  }
227
261
 
228
- function buildDashboardMessage(
229
- capped: DashboardModelQuota[],
230
- uncapped: DashboardUncappedModelQuota[],
231
- ): string {
232
- const resetDate = getNextBillingReset();
233
- 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;
234
271
 
235
272
  const lines: string[] = [
236
- "📊 NaN Quota Status",
273
+ "📊 NaN Usage",
274
+ "",
275
+ `🗓️ Window: ${start} → ${end} UTC (${days} day${days === 1 ? "" : "s"})`,
237
276
  "",
238
- `⏱️ Next billing reset: ${resetDate.toISOString().split("T")[0]} UTC (${formatDuration(timeUntilReset)})`,
277
+ resetLine(now),
239
278
  "",
240
279
  ];
241
280
 
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`);
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
+ );
255
309
  }
256
310
  lines.push("");
257
311
  }
258
- }
259
312
 
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`);
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("");
267
321
  }
268
- lines.push("");
269
- }
270
322
 
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.");
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
+ }
274
330
  }
275
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");
276
343
  return lines.join("\n");
277
344
  }
278
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
+
279
420
  // ── Command registration ──────────────────────────────────────────────────
280
421
 
281
- 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 {
282
430
  if (typeof pi.registerCommand !== "function") return;
283
431
 
432
+ const resolveApiKey = options.resolveApiKey ?? ((ctx: ExtensionCommandContext) => resolveNanApiKey(ctx));
433
+
284
434
  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 {
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) {
303
463
  ctx.ui.notify(buildStaticMessage(), "info");
464
+ return;
304
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
+ );
305
477
  },
306
478
  });
307
479
  }