@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 +49 -24
- package/README.md +49 -24
- package/package.json +1 -1
- package/scripts/check-pi-sdk-versions.mjs +392 -0
- package/scripts/models.generated.ts +2 -2
- package/src/usage.ts +346 -174
package/README.es.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @gtrabanco/pi-nan-provider
|
|
2
2
|
|
|
3
3
|
[](https://opensource.org/licenses/MIT)
|
|
4
|
-
[](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
|
|
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`
|
|
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. **
|
|
168
|
-
```
|
|
169
|
-
|
|
180
|
+
1. **Autentica el proveedor `nan` en pi** con tu clave `sk-...`:
|
|
181
|
+
```
|
|
182
|
+
/login nan
|
|
170
183
|
```
|
|
171
|
-
|
|
184
|
+
o expórtala en su lugar:
|
|
172
185
|
```bash
|
|
173
|
-
|
|
186
|
+
export NAN_API_KEY=sk-...
|
|
174
187
|
```
|
|
175
|
-
|
|
176
|
-
3. **Usa en pi**:
|
|
188
|
+
2. **Úsalo en pi**:
|
|
177
189
|
```
|
|
178
190
|
/nan-usage
|
|
179
191
|
```
|
|
180
192
|
|
|
181
193
|
> [!TIP]
|
|
182
|
-
>
|
|
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
|
|
198
|
+
**Con API key** (uso real):
|
|
187
199
|
```
|
|
188
|
-
📊 NaN
|
|
200
|
+
📊 NaN Usage
|
|
201
|
+
|
|
202
|
+
🗓️ Window: 2026-09-01 → 2026-09-27 UTC (27 days)
|
|
189
203
|
|
|
190
|
-
⏱️ Next billing reset: 2026-
|
|
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.
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
|
245
|
+
GLM 5.3 3.0B 👑 (rolling 400.0M/4h)
|
|
223
246
|
|
|
224
|
-
💡
|
|
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
|
[](https://opensource.org/licenses/MIT)
|
|
4
|
-
[](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
|
|
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`
|
|
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. **
|
|
168
|
-
```
|
|
169
|
-
|
|
180
|
+
1. **Authenticate the `nan` provider in pi** with your `sk-...` key:
|
|
181
|
+
```
|
|
182
|
+
/login nan
|
|
170
183
|
```
|
|
171
|
-
|
|
184
|
+
or export it instead:
|
|
172
185
|
```bash
|
|
173
|
-
|
|
186
|
+
export NAN_API_KEY=sk-...
|
|
174
187
|
```
|
|
175
|
-
|
|
176
|
-
3. **Use in pi**:
|
|
188
|
+
2. **Use in pi**:
|
|
177
189
|
```
|
|
178
190
|
/nan-usage
|
|
179
191
|
```
|
|
180
192
|
|
|
181
193
|
> [!TIP]
|
|
182
|
-
>
|
|
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
|
|
198
|
+
**With an API key** (real usage):
|
|
187
199
|
```
|
|
188
|
-
📊 NaN
|
|
200
|
+
📊 NaN Usage
|
|
201
|
+
|
|
202
|
+
🗓️ Window: 2026-09-01 → 2026-09-27 UTC (27 days)
|
|
189
203
|
|
|
190
|
-
⏱️ Next billing reset: 2026-
|
|
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.
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
230
|
+
**Without an API key** (static limits only):
|
|
209
231
|
```
|
|
210
232
|
📊 NaN Quota Status (static limits)
|
|
211
233
|
|
|
212
|
-
⏱️ Next billing reset: 2026-
|
|
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
|
|
245
|
+
GLM 5.3 3.0B 👑 (rolling 400.0M/4h)
|
|
223
246
|
|
|
224
|
-
💡
|
|
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.
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
// ──
|
|
55
|
+
// ── GET /v1/usage types ───────────────────────────────────────────────────
|
|
49
56
|
|
|
50
|
-
interface
|
|
57
|
+
export interface UsageModelTotals {
|
|
51
58
|
model: string;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
|
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
|
-
|
|
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 };
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
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;
|
|
234
271
|
|
|
235
272
|
const lines: string[] = [
|
|
236
|
-
"📊 NaN
|
|
273
|
+
"📊 NaN Usage",
|
|
274
|
+
"",
|
|
275
|
+
`🗓️ Window: ${start} → ${end} UTC (${days} day${days === 1 ? "" : "s"})`,
|
|
237
276
|
"",
|
|
238
|
-
|
|
277
|
+
resetLine(now),
|
|
239
278
|
"",
|
|
240
279
|
];
|
|
241
280
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
}
|