@gtrabanco/pi-nan-provider 0.6.8 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +37 -19
- package/README.es.md +74 -2
- package/README.md +74 -2
- package/package.json +1 -1
- package/scripts/generate-models.ts +11 -9
- package/scripts/models.generated.ts +16 -16
- package/src/fetch-models.ts +6 -5
- package/src/index.ts +2 -0
- package/src/openai-compat-sanitizer.ts +10 -8
- package/src/pi-ai-loader.ts +228 -0
- package/src/provider-factory.ts +16 -37
- package/src/usage.ts +306 -0
package/AGENTS.md
CHANGED
|
@@ -77,18 +77,29 @@ Every PR that changes code MUST bump `package.json` version in the same PR; CI p
|
|
|
77
77
|
|
|
78
78
|
## Verified API facts (do not re-derive from stale docs)
|
|
79
79
|
|
|
80
|
-
- **Extension-side pi-ai imports (v0.
|
|
80
|
+
- **Extension-side pi-ai imports + streaming-API instance binding (v0.6.10; verified on pi-ai 0.83.0–0.84.4 and pi 0.87.0):**
|
|
81
81
|
statically import ONLY the bare `@earendil-works/pi-ai` root from `src/`. pi's
|
|
82
|
-
extension loader maps that specifier to the compat entrypoint
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
82
|
+
extension loader maps that specifier to the compat entrypoint on the bundled
|
|
83
|
+
CLI, Node-mode jiti aliases and compiled-binary virtualModules, and compat
|
|
84
|
+
re-exports every lazy API factory — including `openAICompletionsApi`. A static
|
|
85
|
+
SUBPATH import (`@earendil-works/pi-ai/api/...`) gets the alias applied as a
|
|
86
|
+
prefix and resolves to `<compat.js>/api/...`, which does not exist: the whole
|
|
87
|
+
extension fails to load (the v0.4.x load failure). Type-only subpath imports
|
|
88
|
+
are erased before resolution and are safe.
|
|
89
|
+
**Exception (issue #8):** on pi-web's sessiond-on-Bun loader (pi-web
|
|
90
|
+
1.202609.0 / pi 0.87.0 / Bun) the bare root is NOT aliased to `/compat`
|
|
91
|
+
(observed namespace = core, `import.meta.resolve` = core), and a bare SUBPATH
|
|
92
|
+
specifier resolved to a stale hoisted `@earendil-works/pi-ai@0.85.1` under
|
|
93
|
+
`~/.pi/agent/npm/node_modules`; its `estimateMessageTokens` lacks the `system`
|
|
94
|
+
branch and crashes pi 0.87's string-content `system` transcript with
|
|
95
|
+
`block.name.length`. `src/pi-ai-loader.ts` therefore binds the streaming
|
|
96
|
+
factory to the same package instance as the bare-root import: use the root
|
|
97
|
+
export when present, else derive a FILE URL from
|
|
98
|
+
`import.meta.resolve("@earendil-works/pi-ai")`
|
|
99
|
+
(`api/openai-completions.lazy.js`, then `compat.js`). No bare pi-ai subpath
|
|
100
|
+
specifier is imported anywhere in `src/` (static or dynamic); failure is loud
|
|
101
|
+
(`PiAiStreamingApiResolutionError`). Guarded by `test/extension-load.test.ts`
|
|
102
|
+
and `test/issue-8-pi-ai-instance.test.ts`.
|
|
92
103
|
- The REAL pi-ai root (plain node/bun, outside pi) does not export
|
|
93
104
|
`openAICompletionsApi`; `createProvider` and `envApiKeyAuth(name, envVars)` are
|
|
94
105
|
on the root. `envApiKeyAuth` implements exactly: stored credential key wins →
|
|
@@ -136,14 +147,21 @@ Every PR that changes code MUST bump `package.json` version in the same PR; CI p
|
|
|
136
147
|
`supportsFinishReason: true` so pi-ai raises the retryable
|
|
137
148
|
`Stream ended without finish_reason` (pi-ai's `RETRYABLE_PROVIDER_ERROR_PATTERN`
|
|
138
149
|
matches `"ended without"`, so the turn is retried) instead of silently
|
|
139
|
-
synthesizing `stop`/`toolUse`. `supportsUsageInStreaming`
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
`
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
synthesizing `stop`/`toolUse`. `supportsUsageInStreaming` is `true` for chat
|
|
151
|
+
models (issue #7): NaN's published schema does not document `stream_options`,
|
|
152
|
+
but the live gateway honors `stream_options.include_usage` — measured
|
|
153
|
+
2026-09-16 on `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.6`, `mimo-v2.5` and
|
|
154
|
+
`gemma4` (0 usage chunks without the flag, exactly 1 with it, carrying
|
|
155
|
+
prompt/completion/reasoning/cached counts; a real pi session then recorded
|
|
156
|
+
real tokens where it recorded zeros). The sanitizer gates its
|
|
157
|
+
`stream_options` removal on the model's effective
|
|
158
|
+
`compat.supportsUsageInStreaming`, so the stock catalog reports real usage and
|
|
159
|
+
a per-model `models.json` override of `false` restores the strict payload
|
|
160
|
+
(`test/issue-4-token-usage.test.ts`, `test/issue-7-streaming-usage-default.test.ts`;
|
|
161
|
+
issues #2, #4, #7). Regression tests:
|
|
162
|
+
`test/issue-2-truncated-stream.test.ts`, `test/issue-4-token-usage.test.ts`,
|
|
163
|
+
`test/issue-7-streaming-usage-default.test.ts`;
|
|
164
|
+
issues #2, #4 and #7.
|
|
147
165
|
- A NaN request that still exceeds the destination model's context window (the
|
|
148
166
|
cross-model thinking guard is disabled with `NAN_THINKING_GUARD=0`, the
|
|
149
167
|
inflation is not a `thinking` block, or the window is smaller) gets NaN's
|
package/README.es.md
CHANGED
|
@@ -71,14 +71,14 @@ Puedes sobrescribir el `compat` de cualquier modelo en `~/.pi/agent/models.json`
|
|
|
71
71
|
|
|
72
72
|
> Poner `supportsFinishReason: false` restaura el antiguo stall silencioso — no recomendado.
|
|
73
73
|
|
|
74
|
-
**Uso de tokens en streaming:** `supportsUsageInStreaming` es `
|
|
74
|
+
**Uso de tokens en streaming:** `supportsUsageInStreaming` es `true` por defecto. El esquema publicado de NaN no documenta `stream_options`, pero el gateway real lo acepta y lo aplica — medido el 2026-09-16 ([#7](https://github.com/gtrabanco/pi-nan-provider/issues/7)): dos llamadas de streaming idénticas por modelo, 0 chunks de usage sin el flag y exactamente 1 con él, en `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.6`, `mimo-v2.5` y `gemma4`. Por eso pi muestra tokens reales de entrada/salida/reasoning/caché en lugar de ceros. Si un modelo resulta no devolver el usage en streaming, desactívalo por modelo — el sanitizer entonces elimina `stream_options` y el payload vuelve a ser estricto:
|
|
75
75
|
|
|
76
76
|
```json
|
|
77
77
|
{
|
|
78
78
|
"providers": {
|
|
79
79
|
"nan": {
|
|
80
80
|
"modelOverrides": {
|
|
81
|
-
"
|
|
81
|
+
"some-model": { "compat": { "supportsUsageInStreaming": false } }
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
}
|
|
@@ -154,6 +154,78 @@ bun run check-nan-mcp-server --issue # crea/refresca el issue
|
|
|
154
154
|
|
|
155
155
|
---
|
|
156
156
|
|
|
157
|
+
## 📊 Uso de Cuotas: `/nan-usage`
|
|
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.
|
|
160
|
+
|
|
161
|
+
### Cómo funciona
|
|
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.
|
|
164
|
+
|
|
165
|
+
### Configuración
|
|
166
|
+
|
|
167
|
+
1. **Instala la CLI de NaN**:
|
|
168
|
+
```bash
|
|
169
|
+
curl -fsSL https://nan.builders/install | sh
|
|
170
|
+
```
|
|
171
|
+
2. **Inicia sesión**:
|
|
172
|
+
```bash
|
|
173
|
+
nan auth login
|
|
174
|
+
```
|
|
175
|
+
Te envía un enlace de inicio de sesión por email. Pega el enlace en la terminal.
|
|
176
|
+
3. **Usa en pi**:
|
|
177
|
+
```
|
|
178
|
+
/nan-usage
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
> [!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.
|
|
183
|
+
|
|
184
|
+
### Lo que ves
|
|
185
|
+
|
|
186
|
+
**Con sesión válida** (uso real):
|
|
187
|
+
```
|
|
188
|
+
📊 NaN Quota Status
|
|
189
|
+
|
|
190
|
+
⏱️ Next billing reset: 2026-10-01 UTC (8d 14h 32m 15s)
|
|
191
|
+
|
|
192
|
+
Models with monthly caps:
|
|
193
|
+
|
|
194
|
+
DeepSeek V4 Flash:
|
|
195
|
+
[████████░░░░░░░░░░░░] 40.2%
|
|
196
|
+
Used: 1.2B / 3.0B (1.8B remaining)
|
|
197
|
+
|
|
198
|
+
MiMo V2.5:
|
|
199
|
+
[██░░░░░░░░░░░░░░░░░░] 12.5%
|
|
200
|
+
Used: 125.0M / 1.0B (875.0M remaining)
|
|
201
|
+
|
|
202
|
+
Uncapped models:
|
|
203
|
+
|
|
204
|
+
Qwen 3.6: 890.5K used
|
|
205
|
+
Gemma 4: 234.1K used
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Sin sesión** (solo límites estáticos):
|
|
209
|
+
```
|
|
210
|
+
📊 NaN Quota Status (static limits)
|
|
211
|
+
|
|
212
|
+
⏱️ Next billing reset: 2026-10-01 UTC (8d 14h 32m 15s)
|
|
213
|
+
|
|
214
|
+
Model Monthly Cap
|
|
215
|
+
─────────────────────────────────────────────────
|
|
216
|
+
DeepSeek V4 Flash 3.0B
|
|
217
|
+
MiMo V2.5 1.0B
|
|
218
|
+
Qwen 3.6 uncapped
|
|
219
|
+
Gemma 4 uncapped
|
|
220
|
+
Qwen 3.8 Flash 500.0M
|
|
221
|
+
GLM 5.3 Flash 2.0B
|
|
222
|
+
GLM 5.3 👑 3.0B (rolling 400.0M/4h)
|
|
223
|
+
|
|
224
|
+
💡 Run `nan auth login` to see real usage data.
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
157
229
|
## 📊 Modelos
|
|
158
230
|
|
|
159
231
|
Catálogo base (verificado contra [docs de NaN](https://nan.builders/docs/models) y [OpenAPI](https://nan.builders/openapi.json)).
|
package/README.md
CHANGED
|
@@ -71,14 +71,14 @@ You can override any model's `compat` per-model in `~/.pi/agent/models.json` (pi
|
|
|
71
71
|
|
|
72
72
|
> Setting `supportsFinishReason: false` restores the old silent-stall behavior — not recommended.
|
|
73
73
|
|
|
74
|
-
**Streaming token usage:** `supportsUsageInStreaming` is `
|
|
74
|
+
**Streaming token usage:** `supportsUsageInStreaming` is `true` by default. NaN's published schema does not document `stream_options`, but the live gateway honors it — measured 2026-09-16 ([#7](https://github.com/gtrabanco/pi-nan-provider/issues/7)): two identical streaming calls per model, 0 usage chunks without the flag and exactly 1 with it, on `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.6`, `mimo-v2.5` and `gemma4`. pi therefore reports real input/output/reasoning/cache token counts instead of zeros. If a model turns out not to report streaming usage, opt out per model — the request sanitizer then strips `stream_options` and the payload stays strict:
|
|
75
75
|
|
|
76
76
|
```json
|
|
77
77
|
{
|
|
78
78
|
"providers": {
|
|
79
79
|
"nan": {
|
|
80
80
|
"modelOverrides": {
|
|
81
|
-
"
|
|
81
|
+
"some-model": { "compat": { "supportsUsageInStreaming": false } }
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
}
|
|
@@ -154,6 +154,78 @@ bun run check-nan-mcp-server --issue # create/refresh the issue
|
|
|
154
154
|
|
|
155
155
|
---
|
|
156
156
|
|
|
157
|
+
## 📊 Quota Usage: `/nan-usage`
|
|
158
|
+
|
|
159
|
+
Shows your NaN token usage per model, monthly limits, and time until the billing cycle resets.
|
|
160
|
+
|
|
161
|
+
### How it works
|
|
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.
|
|
164
|
+
|
|
165
|
+
### Setup
|
|
166
|
+
|
|
167
|
+
1. **Install the NaN CLI**:
|
|
168
|
+
```bash
|
|
169
|
+
curl -fsSL https://nan.builders/install | sh
|
|
170
|
+
```
|
|
171
|
+
2. **Log in**:
|
|
172
|
+
```bash
|
|
173
|
+
nan auth login
|
|
174
|
+
```
|
|
175
|
+
This sends a sign-in link to your email. Paste the link back into the terminal.
|
|
176
|
+
3. **Use in pi**:
|
|
177
|
+
```
|
|
178
|
+
/nan-usage
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
> [!TIP]
|
|
182
|
+
> The session token is shared automatically — no env vars or extra config needed. If the session expires, run `nan auth login` again.
|
|
183
|
+
|
|
184
|
+
### What you see
|
|
185
|
+
|
|
186
|
+
**With a valid session** (real usage):
|
|
187
|
+
```
|
|
188
|
+
📊 NaN Quota Status
|
|
189
|
+
|
|
190
|
+
⏱️ Next billing reset: 2026-10-01 UTC (8d 14h 32m 15s)
|
|
191
|
+
|
|
192
|
+
Models with monthly caps:
|
|
193
|
+
|
|
194
|
+
DeepSeek V4 Flash:
|
|
195
|
+
[████████░░░░░░░░░░░░] 40.2%
|
|
196
|
+
Used: 1.2B / 3.0B (1.8B remaining)
|
|
197
|
+
|
|
198
|
+
MiMo V2.5:
|
|
199
|
+
[██░░░░░░░░░░░░░░░░░░] 12.5%
|
|
200
|
+
Used: 125.0M / 1.0B (875.0M remaining)
|
|
201
|
+
|
|
202
|
+
Uncapped models:
|
|
203
|
+
|
|
204
|
+
Qwen 3.6: 890.5K used
|
|
205
|
+
Gemma 4: 234.1K used
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Without a session** (static limits only):
|
|
209
|
+
```
|
|
210
|
+
📊 NaN Quota Status (static limits)
|
|
211
|
+
|
|
212
|
+
⏱️ Next billing reset: 2026-10-01 UTC (8d 14h 32m 15s)
|
|
213
|
+
|
|
214
|
+
Model Monthly Cap
|
|
215
|
+
─────────────────────────────────────────────────
|
|
216
|
+
DeepSeek V4 Flash 3.0B
|
|
217
|
+
MiMo V2.5 1.0B
|
|
218
|
+
Qwen 3.6 uncapped
|
|
219
|
+
Gemma 4 uncapped
|
|
220
|
+
Qwen 3.8 Flash 500.0M
|
|
221
|
+
GLM 5.3 Flash 2.0B
|
|
222
|
+
GLM 5.3 👑 3.0B (rolling 400.0M/4h)
|
|
223
|
+
|
|
224
|
+
💡 Run `nan auth login` to see real usage data.
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
157
229
|
## 📊 Models
|
|
158
230
|
|
|
159
231
|
Baseline catalog (verified against [NaN docs](https://nan.builders/docs/models) and [OpenAPI](https://nan.builders/openapi.json)).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtrabanco/pi-nan-provider",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
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",
|
|
@@ -83,14 +83,16 @@ const LIVE_ONLY_MODEL_IDS: Record<string, string> = {
|
|
|
83
83
|
const NAN_COMPAT = {
|
|
84
84
|
supportsDeveloperRole: false,
|
|
85
85
|
supportsReasoningEffort: true,
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
supportsUsageInStreaming: false
|
|
86
|
+
// NaN's published schema is silent about `stream_options`, but the live
|
|
87
|
+
// gateway honors it (issue #7, measured 2026-09-16 on five chat models:
|
|
88
|
+
// 0 usage chunks without the flag, exactly 1 with it). pi-ai only sends
|
|
89
|
+
// `stream_options: { include_usage: true }` when this is not false, and the
|
|
90
|
+
// sanitizer forwards it when the model declares true, so chat models opt in
|
|
91
|
+
// by default and pi reports real token counts instead of zeros (issue #4).
|
|
92
|
+
// A model that does not report streaming usage can still opt out per model
|
|
93
|
+
// with a models.json compat override (`supportsUsageInStreaming: false`);
|
|
94
|
+
// the sanitizer then strips `stream_options` and the payload stays strict.
|
|
95
|
+
supportsUsageInStreaming: true,
|
|
94
96
|
// The NaN/LiteLLM gateway intermittently closes SSE streams before emitting
|
|
95
97
|
// `finish_reason`. With true, pi-ai raises "Stream ended without
|
|
96
98
|
// finish_reason", which its retryable-provider pattern ("ended without")
|
|
@@ -102,7 +104,7 @@ const NAN_COMPAT = {
|
|
|
102
104
|
};
|
|
103
105
|
|
|
104
106
|
const NAN_COMPAT_NOTE =
|
|
105
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming
|
|
107
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.";
|
|
106
108
|
|
|
107
109
|
interface ModelsDevModel {
|
|
108
110
|
id?: string;
|
|
@@ -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-21T23:54:45.052Z
|
|
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.
|
|
@@ -31,12 +31,12 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
31
31
|
"compat": {
|
|
32
32
|
"supportsDeveloperRole": false,
|
|
33
33
|
"supportsReasoningEffort": true,
|
|
34
|
-
"supportsUsageInStreaming":
|
|
34
|
+
"supportsUsageInStreaming": true,
|
|
35
35
|
"supportsFinishReason": true,
|
|
36
36
|
"maxTokensField": "max_tokens"
|
|
37
37
|
},
|
|
38
38
|
"notes": [
|
|
39
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming
|
|
39
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
|
|
40
40
|
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence."
|
|
41
41
|
],
|
|
42
42
|
"extras": {
|
|
@@ -92,12 +92,12 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
92
92
|
"compat": {
|
|
93
93
|
"supportsDeveloperRole": false,
|
|
94
94
|
"supportsReasoningEffort": true,
|
|
95
|
-
"supportsUsageInStreaming":
|
|
95
|
+
"supportsUsageInStreaming": true,
|
|
96
96
|
"supportsFinishReason": true,
|
|
97
97
|
"maxTokensField": "max_tokens"
|
|
98
98
|
},
|
|
99
99
|
"notes": [
|
|
100
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming
|
|
100
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict."
|
|
101
101
|
],
|
|
102
102
|
"extras": {
|
|
103
103
|
"id": "gemma4",
|
|
@@ -155,18 +155,18 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
155
155
|
"compat": {
|
|
156
156
|
"supportsDeveloperRole": false,
|
|
157
157
|
"supportsReasoningEffort": true,
|
|
158
|
-
"supportsUsageInStreaming":
|
|
158
|
+
"supportsUsageInStreaming": true,
|
|
159
159
|
"supportsFinishReason": true,
|
|
160
160
|
"maxTokensField": "max_tokens"
|
|
161
161
|
},
|
|
162
162
|
"notes": [
|
|
163
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming
|
|
163
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict."
|
|
164
164
|
],
|
|
165
165
|
"extras": {
|
|
166
166
|
"id": "glm5.3-flash",
|
|
167
167
|
"name": "GLM-5.3-Flash",
|
|
168
168
|
"description": "Native multimodal GLM model for efficient coding and long-horizon agent tasks",
|
|
169
|
-
"family": "glm",
|
|
169
|
+
"family": "glm-flash",
|
|
170
170
|
"attachment": true,
|
|
171
171
|
"reasoning": true,
|
|
172
172
|
"reasoning_options": [],
|
|
@@ -214,12 +214,12 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
214
214
|
"compat": {
|
|
215
215
|
"supportsDeveloperRole": false,
|
|
216
216
|
"supportsReasoningEffort": true,
|
|
217
|
-
"supportsUsageInStreaming":
|
|
217
|
+
"supportsUsageInStreaming": true,
|
|
218
218
|
"supportsFinishReason": true,
|
|
219
219
|
"maxTokensField": "max_tokens"
|
|
220
220
|
},
|
|
221
221
|
"notes": [
|
|
222
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming
|
|
222
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict."
|
|
223
223
|
],
|
|
224
224
|
"extras": {
|
|
225
225
|
"id": "mimo-v2.5",
|
|
@@ -274,12 +274,12 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
274
274
|
"compat": {
|
|
275
275
|
"supportsDeveloperRole": false,
|
|
276
276
|
"supportsReasoningEffort": true,
|
|
277
|
-
"supportsUsageInStreaming":
|
|
277
|
+
"supportsUsageInStreaming": true,
|
|
278
278
|
"supportsFinishReason": true,
|
|
279
279
|
"maxTokensField": "max_tokens"
|
|
280
280
|
},
|
|
281
281
|
"notes": [
|
|
282
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming
|
|
282
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict."
|
|
283
283
|
],
|
|
284
284
|
"extras": {
|
|
285
285
|
"id": "qwen3.6",
|
|
@@ -337,12 +337,12 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
337
337
|
"compat": {
|
|
338
338
|
"supportsDeveloperRole": false,
|
|
339
339
|
"supportsReasoningEffort": true,
|
|
340
|
-
"supportsUsageInStreaming":
|
|
340
|
+
"supportsUsageInStreaming": true,
|
|
341
341
|
"supportsFinishReason": true,
|
|
342
342
|
"maxTokensField": "max_tokens"
|
|
343
343
|
},
|
|
344
344
|
"notes": [
|
|
345
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming
|
|
345
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
|
|
346
346
|
"contextWindow 262,144: the earlier 1,000,000 override (maintainer-confirmed 2026-09-05) was withdrawn 2026-09-07 — the updated https://nan.builders/docs/models still states '262K token context, the model's native window' and models.dev agrees at 262,144; NaN docs are treated as the most reliable source (maintainer instruction, 2026-09-07)."
|
|
347
347
|
],
|
|
348
348
|
"extras": {
|
|
@@ -382,13 +382,13 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
382
382
|
export const GENERATED_CATALOG_META = {
|
|
383
383
|
source: "https://models.dev/api.json",
|
|
384
384
|
modelsDevProvider: "nan",
|
|
385
|
-
fetchedAt: "2026-09-
|
|
385
|
+
fetchedAt: "2026-09-21T23:54:45.052Z",
|
|
386
386
|
modelCount: 6,
|
|
387
387
|
models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.5","qwen3.6","qwen3.8-flash"],
|
|
388
388
|
notes: [
|
|
389
389
|
"live-only: \"glm5.3\" kept out of the static catalog (premium-tier model (models.dev now documents it with 1M context / 131,072 max output; NaN docs https://nan.builders/docs/models + https://nan.builders/openapi.json, checked 2026-09-13) kept live-only so a non-premium key never sees a model it cannot call when the live /models fetch is unavailable; premium keys still get it via the /models refresh with conservative placeholder limits)",
|
|
390
390
|
"provider-removed: \"glm5.2\" excluded from the catalog (removed by NaN (2026-09-05); absent from the official chat model list in https://nan.builders/openapi.json and https://nan.builders/docs/models (checked 2026-09-07) while models.dev provider nan still listed it — excluded so regeneration does not resurrect it)",
|
|
391
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming
|
|
391
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
|
|
392
392
|
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence.",
|
|
393
393
|
"contextWindow 262,144: the earlier 1,000,000 override (maintainer-confirmed 2026-09-05) was withdrawn 2026-09-07 — the updated https://nan.builders/docs/models still states '262K token context, the model's native window' and models.dev agrees at 262,144; NaN docs are treated as the most reliable source (maintainer instruction, 2026-09-07)."
|
|
394
394
|
],
|
package/src/fetch-models.ts
CHANGED
|
@@ -177,10 +177,11 @@ export function mergeLiveWithGenerated(
|
|
|
177
177
|
// gateway cuts SSE streams before finish_reason, so supportsFinishReason
|
|
178
178
|
// must stay true (pi-ai then raises the retryable "Stream ended without
|
|
179
179
|
// finish_reason" instead of silently stalling), and
|
|
180
|
-
// supportsUsageInStreaming
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
180
|
+
// supportsUsageInStreaming is true because the live gateway honors
|
|
181
|
+
// `stream_options.include_usage` even though the published schema is
|
|
182
|
+
// silent (issue #7, measured 2026-09-16): the sanitizer forwards the
|
|
183
|
+
// field and pi reports real token counts. A model can still opt out
|
|
184
|
+
// with a models.json override of false.
|
|
184
185
|
models.push({
|
|
185
186
|
id,
|
|
186
187
|
name: id,
|
|
@@ -192,7 +193,7 @@ export function mergeLiveWithGenerated(
|
|
|
192
193
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
193
194
|
contextWindow: UNKNOWN_MODEL_LIMITS.contextWindow,
|
|
194
195
|
maxTokens: UNKNOWN_MODEL_LIMITS.maxTokens,
|
|
195
|
-
compat: { supportsFinishReason: true, supportsUsageInStreaming:
|
|
196
|
+
compat: { supportsFinishReason: true, supportsUsageInStreaming: true },
|
|
196
197
|
});
|
|
197
198
|
unknown.push(id);
|
|
198
199
|
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import type { ContextEvent, ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
|
|
29
29
|
import type { Provider } from "@earendil-works/pi-ai";
|
|
30
30
|
import { registerNanMcpCommand } from "./commands.ts";
|
|
31
|
+
import { registerNanUsageCommand } from "./usage.ts";
|
|
31
32
|
import {
|
|
32
33
|
crossModelThinkingGuardEnabled,
|
|
33
34
|
stripCrossModelThinking,
|
|
@@ -109,6 +110,7 @@ function registerMcpToolsCompat(pi: ExtensionAPI): void {
|
|
|
109
110
|
if (mediaMcpEnabled()) registerMediaTools();
|
|
110
111
|
if (typeof pi.registerCommand === "function") {
|
|
111
112
|
registerNanMcpCommand(pi, { registerWebSearchTools: registerSearchTool, registerMediaTools });
|
|
113
|
+
registerNanUsageCommand(pi);
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
|
|
@@ -33,14 +33,16 @@
|
|
|
33
33
|
* not silently lost.
|
|
34
34
|
* 4. Top-level fields NaN's schema does not list: `store` and
|
|
35
35
|
* `stream_options`. These are opt-in/usage fields pi-ai sends by default
|
|
36
|
-
* for a "standard" provider; NaN does not document
|
|
37
|
-
* removed by default. `stream_options` is the one
|
|
38
|
-
* explicitly opted into: when the model's effective
|
|
39
|
-
* `compat.supportsUsageInStreaming` is true (
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
36
|
+
* for a "standard" provider; NaN's published schema does not document
|
|
37
|
+
* them, so they are removed by default. `stream_options` is the one
|
|
38
|
+
* exception that can be explicitly opted into: when the model's effective
|
|
39
|
+
* `compat.supportsUsageInStreaming` is true (the catalog default for chat
|
|
40
|
+
* models since issue #7 — the live gateway was measured honoring
|
|
41
|
+
* `include_usage` on 2026-09-16 — or a user `models.json` override), the
|
|
42
|
+
* gateway reports usage and `stream_options` is preserved — deleting it
|
|
43
|
+
* unconditionally would silently zero out `message.usage` (issue #4).
|
|
44
|
+
* A per-model override of `false` keeps the strict payload. `store` is
|
|
45
|
+
* always removed.
|
|
44
46
|
* 5. An EMPTY `tools` array. Verified against the live gateway (2026-09-09):
|
|
45
47
|
* NaN rejects `tools: []` with the same 400, while `stream: true`, a
|
|
46
48
|
* `system` message, string content, and a `tool` role message are all
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the `openai-completions` streaming API factory from the **same
|
|
3
|
+
* `@earendil-works/pi-ai` package instance** the host resolved for this
|
|
4
|
+
* extension's bare-root import — never from a bare subpath specifier.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this module exists (issue #8)
|
|
7
|
+
*
|
|
8
|
+
* `src/provider-factory.ts` used to fall back to a bare SUBPATH import of the
|
|
9
|
+
* `api/openai-completions.lazy` entry. That is not the same module as the bare
|
|
10
|
+
* root on every runtime. On pi-web's
|
|
11
|
+
* sessiond-on-Bun loader path the bare root `@earendil-works/pi-ai` is NOT
|
|
12
|
+
* aliased to pi's `/compat` entrypoint: it resolves to the host's pi-ai 0.87
|
|
13
|
+
* **core** build (whose namespace has no `openAICompletionsApi`), while the
|
|
14
|
+
* bare subpath resolves, from the extension's own tree, to a stale hoisted
|
|
15
|
+
* `@earendil-works/pi-ai@0.85.1`. That copy's `estimateMessageTokens` has no
|
|
16
|
+
* `system` branch, so pi 0.87's string-content `system` transcript message is
|
|
17
|
+
* iterated character-by-character and crashes on
|
|
18
|
+
* `undefined is not an object (evaluating 'block.name.length')` — before the
|
|
19
|
+
* request is ever sent, so it reads like a NaN/gateway failure.
|
|
20
|
+
*
|
|
21
|
+
* `import.meta.resolve("@earendil-works/pi-ai")` returns the *same instance*
|
|
22
|
+
* the bare-root static import used in every environment measured (both the
|
|
23
|
+
* host's 0.87 core on pi-web, and the extension-tree copy under plain
|
|
24
|
+
* node/jiti). It is therefore the anchor: derive a **file URL** for the
|
|
25
|
+
* sibling `api/openai-completions.lazy.js` (then `compat.js`) from that
|
|
26
|
+
* root and dynamic-import the URL. A file URL bypasses package resolution
|
|
27
|
+
* entirely, so the loaded module is guaranteed to be the host's instance.
|
|
28
|
+
*
|
|
29
|
+
* Under the bundled CLI / Node-mode aliases / compiled binary, the bare root
|
|
30
|
+
* is the compat entrypoint and already exposes the factory, so the first
|
|
31
|
+
* branch wins and nothing is resolved.
|
|
32
|
+
*
|
|
33
|
+
* **Contract:** no bare `@earendil-works/pi-ai/<subpath>` specifier is ever
|
|
34
|
+
* imported from `src/` (static or dynamic). When neither the root nor the
|
|
35
|
+
* host-derived candidates provide the factory, resolution fails loudly with
|
|
36
|
+
* `PiAiStreamingApiResolutionError` instead of silently loading a stale copy.
|
|
37
|
+
*
|
|
38
|
+
* Guarded by `test/issue-8-pi-ai-instance.test.ts` and
|
|
39
|
+
* `test/extension-load.test.ts`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import * as piAi from "@earendil-works/pi-ai";
|
|
43
|
+
import type { ProviderStreams } from "@earendil-works/pi-ai";
|
|
44
|
+
import { createRequire } from "node:module";
|
|
45
|
+
|
|
46
|
+
/** The only pi-ai specifier this package may import. */
|
|
47
|
+
export const PI_AI_PACKAGE_SPECIFIER = "@earendil-works/pi-ai";
|
|
48
|
+
|
|
49
|
+
/** Export name of the openai-completions lazy factory on compat/lazy entrypoints. */
|
|
50
|
+
export const OPENAI_COMPLETIONS_FACTORY_EXPORT = "openAICompletionsApi";
|
|
51
|
+
|
|
52
|
+
/** Package-relative path of the lazy openai-completions entrypoint. */
|
|
53
|
+
export const OPENAI_COMPLETIONS_LAZY_ENTRY = "api/openai-completions.lazy.js";
|
|
54
|
+
|
|
55
|
+
/** Package-relative path of pi-ai's compat entrypoint (secondary candidate). */
|
|
56
|
+
export const PI_AI_COMPAT_ENTRY = "compat.js";
|
|
57
|
+
|
|
58
|
+
/** pi-ai's lazy API factory shape (same as the compat root export). */
|
|
59
|
+
export type OpenAICompletionsApiFactory = () => ProviderStreams;
|
|
60
|
+
|
|
61
|
+
/** A dynamically imported module namespace. */
|
|
62
|
+
export type ModuleNamespace = Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The resolution seam. Production uses {@link defaultPiAiLoaderHost}; tests
|
|
66
|
+
* inject a host to prove the loader binds to the resolved instance (and never
|
|
67
|
+
* to the extension's own tree).
|
|
68
|
+
*/
|
|
69
|
+
export interface PiAiLoaderHost {
|
|
70
|
+
/** The bare-root namespace this extension statically imported. */
|
|
71
|
+
readonly namespace: ModuleNamespace;
|
|
72
|
+
/** Resolve a specifier the way the host runtime does. */
|
|
73
|
+
resolveSpecifier?(specifier: string): string;
|
|
74
|
+
/** Load a module by absolute URL (a `file://` URL in the derived path). */
|
|
75
|
+
importModule(url: string): Promise<ModuleNamespace>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Thrown when the openai-completions factory cannot be bound to the
|
|
80
|
+
* host-resolved pi-ai instance. Carries the resolved root and the URLs that
|
|
81
|
+
* were attempted so the failure is a one-line diagnosis.
|
|
82
|
+
*/
|
|
83
|
+
export class PiAiStreamingApiResolutionError extends Error {
|
|
84
|
+
readonly resolvedRootUrl?: string;
|
|
85
|
+
readonly attemptedUrls: readonly string[];
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
message: string,
|
|
89
|
+
options: { resolvedRootUrl?: string; attemptedUrls?: readonly string[] } = {},
|
|
90
|
+
) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = "PiAiStreamingApiResolutionError";
|
|
93
|
+
this.resolvedRootUrl = options.resolvedRootUrl;
|
|
94
|
+
this.attemptedUrls = options.attemptedUrls ?? [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Extract the openai-completions factory from a module namespace, if present. */
|
|
99
|
+
export function openAICompletionsApiFrom(namespace: unknown): OpenAICompletionsApiFactory | undefined {
|
|
100
|
+
if (namespace === null || typeof namespace !== "object") return undefined;
|
|
101
|
+
const candidate = (namespace as ModuleNamespace)[OPENAI_COMPLETIONS_FACTORY_EXPORT];
|
|
102
|
+
return typeof candidate === "function" ? (candidate as OpenAICompletionsApiFactory) : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `import.meta.resolve` is absent from bun-types' `ImportMeta`, so read it
|
|
107
|
+
* through an explicit shape. Bun/Node expose it at runtime; when it is missing
|
|
108
|
+
* or throws, `createRequire` resolves the same bare root from this module.
|
|
109
|
+
*/
|
|
110
|
+
type ImportMetaWithResolve = ImportMeta & { resolve?: (specifier: string) => string };
|
|
111
|
+
|
|
112
|
+
const defaultPiAiLoaderHost: PiAiLoaderHost = {
|
|
113
|
+
namespace: piAi as unknown as ModuleNamespace,
|
|
114
|
+
resolveSpecifier(specifier: string): string {
|
|
115
|
+
const meta = import.meta as ImportMetaWithResolve;
|
|
116
|
+
if (typeof meta.resolve === "function") {
|
|
117
|
+
try {
|
|
118
|
+
const resolved = meta.resolve(specifier);
|
|
119
|
+
if (typeof resolved === "string" && resolved.length > 0) return resolved;
|
|
120
|
+
} catch {
|
|
121
|
+
// Fall through to createRequire — same bare root, same instance.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return createRequire(import.meta.url).resolve(specifier);
|
|
125
|
+
},
|
|
126
|
+
importModule: (url: string) => import(url) as Promise<ModuleNamespace>,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Cached result of the default-host resolution; injected hosts bypass it. */
|
|
130
|
+
let defaultHostCache: OpenAICompletionsApiFactory | undefined;
|
|
131
|
+
|
|
132
|
+
/** Build the candidate file URLs from the host-resolved package root. */
|
|
133
|
+
function candidateUrlsFor(rootUrl: string): { rootDir: URL; candidates: string[] } {
|
|
134
|
+
const rootDir = new URL("./", new URL(rootUrl));
|
|
135
|
+
return {
|
|
136
|
+
rootDir,
|
|
137
|
+
candidates: [
|
|
138
|
+
new URL(OPENAI_COMPLETIONS_LAZY_ENTRY, rootDir).href,
|
|
139
|
+
new URL(PI_AI_COMPAT_ENTRY, rootDir).href,
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function loudError(
|
|
145
|
+
rootUrl: string | undefined,
|
|
146
|
+
attemptedUrls: readonly string[],
|
|
147
|
+
lastError: Error | undefined,
|
|
148
|
+
): PiAiStreamingApiResolutionError {
|
|
149
|
+
const lines = [
|
|
150
|
+
`Could not resolve "${OPENAI_COMPLETIONS_FACTORY_EXPORT}" from the host-resolved "${PI_AI_PACKAGE_SPECIFIER}" instance.`,
|
|
151
|
+
`Resolved root: ${rootUrl ?? "<unresolved>"}`,
|
|
152
|
+
attemptedUrls.length > 0
|
|
153
|
+
? `Attempted URLs:\n${attemptedUrls.map((url) => ` - ${url}`).join("\n")}`
|
|
154
|
+
: "Attempted URLs: none (package root resolution failed)",
|
|
155
|
+
lastError ? `Last error: ${lastError.message}` : undefined,
|
|
156
|
+
"",
|
|
157
|
+
"This provider refuses to load a stale @earendil-works/pi-ai from the extension's own npm tree (issue #8).",
|
|
158
|
+
"On pi-web's sessiond-on-Bun loader the bare root is not mapped to pi's /compat entrypoint, so the",
|
|
159
|
+
"factory must be imported from a file URL derived from import.meta.resolve(...) — never a bare subpath.",
|
|
160
|
+
];
|
|
161
|
+
return new PiAiStreamingApiResolutionError(lines.filter((line) => line !== undefined).join("\n"), {
|
|
162
|
+
resolvedRootUrl: rootUrl,
|
|
163
|
+
attemptedUrls,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param host Resolution seam; omit in production.
|
|
169
|
+
* @returns The openai-completions lazy API factory from the host's pi-ai instance.
|
|
170
|
+
* @throws PiAiStreamingApiResolutionError when no candidate can be bound.
|
|
171
|
+
*/
|
|
172
|
+
export async function resolveOpenAICompletionsApi(
|
|
173
|
+
host?: PiAiLoaderHost,
|
|
174
|
+
): Promise<OpenAICompletionsApiFactory> {
|
|
175
|
+
const usesDefaultHost = host === undefined;
|
|
176
|
+
if (usesDefaultHost && defaultHostCache) return defaultHostCache;
|
|
177
|
+
const activeHost = host ?? defaultPiAiLoaderHost;
|
|
178
|
+
|
|
179
|
+
// 1. Compat root (bundled CLI / Node aliases / compiled binary): the bare
|
|
180
|
+
// root itself re-exports the factory — no resolution needed.
|
|
181
|
+
const fromRoot = openAICompletionsApiFrom(activeHost.namespace);
|
|
182
|
+
if (fromRoot) {
|
|
183
|
+
if (usesDefaultHost) defaultHostCache = fromRoot;
|
|
184
|
+
return fromRoot;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 2. Core root (pi-web on Bun): anchor to the host-resolved package root.
|
|
188
|
+
if (typeof activeHost.resolveSpecifier !== "function") {
|
|
189
|
+
throw loudError(undefined, [], undefined);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let rootUrl: string | undefined;
|
|
193
|
+
try {
|
|
194
|
+
rootUrl = activeHost.resolveSpecifier(PI_AI_PACKAGE_SPECIFIER);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw loudError(undefined, [], error instanceof Error ? error : new Error(String(error)));
|
|
197
|
+
}
|
|
198
|
+
if (!rootUrl) throw loudError(undefined, [], undefined);
|
|
199
|
+
|
|
200
|
+
// 3. Derive file URLs from the resolved root. The directory-prefix guard is
|
|
201
|
+
// the same-package-instance assertion: a candidate must stay inside the
|
|
202
|
+
// package the host resolved.
|
|
203
|
+
const { rootDir, candidates } = candidateUrlsFor(rootUrl);
|
|
204
|
+
const attemptedUrls: string[] = [];
|
|
205
|
+
let lastError: Error | undefined;
|
|
206
|
+
for (const candidate of candidates) {
|
|
207
|
+
if (!candidate.startsWith(rootDir.href)) {
|
|
208
|
+
throw loudError(
|
|
209
|
+
rootUrl,
|
|
210
|
+
attemptedUrls,
|
|
211
|
+
new Error(`candidate "${candidate}" escapes the resolved package root "${rootDir.href}"`),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
attemptedUrls.push(candidate);
|
|
215
|
+
try {
|
|
216
|
+
const factory = openAICompletionsApiFrom(await activeHost.importModule(candidate));
|
|
217
|
+
if (factory) {
|
|
218
|
+
if (usesDefaultHost) defaultHostCache = factory;
|
|
219
|
+
return factory;
|
|
220
|
+
}
|
|
221
|
+
lastError = new Error(`"${OPENAI_COMPLETIONS_FACTORY_EXPORT}" is not exported by ${candidate}`);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw loudError(rootUrl, attemptedUrls, lastError);
|
|
228
|
+
}
|
package/src/provider-factory.ts
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* API factory. Subpath specifiers (`@earendil-works/pi-ai/api/...`) get the
|
|
19
19
|
* alias applied as a prefix and resolve to `<compat.js>/api/...`, which does
|
|
20
20
|
* not exist — the extension then fails to load entirely.
|
|
21
|
+
*
|
|
22
|
+
* Streaming API resolution: bare root under pi's compat alias is the instance
|
|
23
|
+
* anchor; otherwise a file URL from import.meta.resolve; never a bare subpath
|
|
24
|
+
* (loud failure — issue #8). Full logic in src/pi-ai-loader.ts.
|
|
21
25
|
*/
|
|
22
26
|
|
|
23
27
|
import * as piAi from "@earendil-works/pi-ai";
|
|
@@ -33,6 +37,7 @@ import {
|
|
|
33
37
|
resolveCatalog,
|
|
34
38
|
type CatalogSource,
|
|
35
39
|
} from "./fetch-models.ts";
|
|
40
|
+
import { resolveOpenAICompletionsApi } from "./pi-ai-loader.ts";
|
|
36
41
|
import { sanitizeOpenAICompatPayload } from "./openai-compat-sanitizer.ts";
|
|
37
42
|
|
|
38
43
|
export interface OpenAICompatibleProviderConfig {
|
|
@@ -53,51 +58,23 @@ export interface NanCompatibleProviderOptions {
|
|
|
53
58
|
fetchImpl?: typeof fetch;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
/**
|
|
57
|
-
* Resolve the openai-completions streaming implementation at runtime.
|
|
58
|
-
*
|
|
59
|
-
* Under pi, the bare-root namespace is pi's compat entrypoint, which
|
|
60
|
-
* re-exports `openAICompletionsApi` on both pi-ai 0.83 and 0.84 — so the
|
|
61
|
-
* first branch always wins and no pi-ai subpath is ever resolved there.
|
|
62
|
-
* Outside pi (plain node/bun: tests and direct consumers) the real root
|
|
63
|
-
* does not export the lazy factory; the dynamic subpath import below uses
|
|
64
|
-
* the package's normal `./api/*` export. It is never reached under pi, so
|
|
65
|
-
* the alias-prefix pitfall cannot bite at runtime.
|
|
66
|
-
*/
|
|
67
|
-
type OpenAICompletionsApiFactory = () => ProviderStreams;
|
|
68
|
-
|
|
69
|
-
let cachedApiFactory: OpenAICompletionsApiFactory | undefined;
|
|
70
|
-
|
|
71
|
-
export async function resolveOpenAICompletionsApi(): Promise<OpenAICompletionsApiFactory> {
|
|
72
|
-
if (cachedApiFactory) return cachedApiFactory;
|
|
73
|
-
const fromRoot = (
|
|
74
|
-
piAi as unknown as Partial<Record<"openAICompletionsApi", OpenAICompletionsApiFactory>>
|
|
75
|
-
).openAICompletionsApi;
|
|
76
|
-
if (typeof fromRoot === "function") {
|
|
77
|
-
cachedApiFactory = fromRoot;
|
|
78
|
-
return cachedApiFactory;
|
|
79
|
-
}
|
|
80
|
-
cachedApiFactory = (await import("@earendil-works/pi-ai/api/openai-completions.lazy"))
|
|
81
|
-
.openAICompletionsApi;
|
|
82
|
-
return cachedApiFactory;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
61
|
/**
|
|
86
62
|
* Wrap an api so every outgoing `/chat/completions` payload is made conformant
|
|
87
63
|
* to the strict OpenAI Chat Completions schema NaN enforces (see
|
|
88
64
|
* ./openai-compat-sanitizer.ts). NaN returns HTTP 400 `Invalid request. Check
|
|
89
65
|
* your request parameters.` for any payload that violates it — including a
|
|
90
66
|
* replayed assistant message with a `toolCall` block inside `content`, a
|
|
91
|
-
* `reasoning_details` field, or undocumented top-level
|
|
92
|
-
*
|
|
67
|
+
* `reasoning_details` field, or the undocumented top-level `store` field.
|
|
68
|
+
* Sanitizing via the `onPayload` hook works regardless of
|
|
93
69
|
* which pi-ai version the runtime bundles, so the fix is not tied to a
|
|
94
70
|
* specific upstream build.
|
|
95
71
|
*
|
|
96
72
|
* `stream_options` is the one field whose removal is conditional: when the
|
|
97
|
-
* model's effective `compat.supportsUsageInStreaming` is true (catalog
|
|
98
|
-
* or user `models.json` override),
|
|
99
|
-
* return it — stripping the field
|
|
100
|
-
* (issue #4). Every other model keeps the
|
|
73
|
+
* model's effective `compat.supportsUsageInStreaming` is true (the catalog
|
|
74
|
+
* default for chat models since issue #7, or a user `models.json` override),
|
|
75
|
+
* pi-ai requested usage and the gateway will return it — stripping the field
|
|
76
|
+
* would silently zero `message.usage` (issue #4). Every other model keeps the
|
|
77
|
+
* strict payload.
|
|
101
78
|
*
|
|
102
79
|
* Any caller-supplied `onPayload` (e.g. pi's own debug/passthrough hook) is
|
|
103
80
|
* preserved and chained AFTER sanitization, so the final payload is always
|
|
@@ -156,8 +133,10 @@ export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStre
|
|
|
156
133
|
* - fetchModels: live `/models` IDs × generated capability data; falls back
|
|
157
134
|
* to the baseline when the endpoint is unreachable. pi's Models runtime
|
|
158
135
|
* drives refreshes (startup/periodic) and persists the overlay.
|
|
159
|
-
* - api: the openai-completions streaming implementation
|
|
160
|
-
* `resolveOpenAICompletionsApi`
|
|
136
|
+
* - api: the openai-completions streaming implementation resolved via
|
|
137
|
+
* `resolveOpenAICompletionsApi` (bare root under pi's compat alias as
|
|
138
|
+
* the instance anchor; otherwise a file URL from import.meta.resolve;
|
|
139
|
+
* never a bare subpath — loud failure if unresolved). Issue #8.
|
|
161
140
|
*/
|
|
162
141
|
export async function createNanCompatibleProvider(
|
|
163
142
|
config: OpenAICompatibleProviderConfig,
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/nan-usage` — slash command showing NaN quota status per model.
|
|
3
|
+
*
|
|
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`.
|
|
8
|
+
*
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Quota sources: https://nan.builders/docs/models (checked 2026-09-21)
|
|
13
|
+
*/
|
|
14
|
+
|
|
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";
|
|
19
|
+
|
|
20
|
+
// ── Known quota limits per model (from NaN docs) ──────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface ModelQuota {
|
|
23
|
+
/** Model ID as used in API calls. */
|
|
24
|
+
model: string;
|
|
25
|
+
/** Human-readable name. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** Monthly token cap (0 = uncapped). */
|
|
28
|
+
monthlyCap: number;
|
|
29
|
+
/** Rolling 4h window cap in tokens (0 = none). */
|
|
30
|
+
rollingWindowCap: number;
|
|
31
|
+
/** Rolling window duration in hours. */
|
|
32
|
+
rollingWindowHours: number;
|
|
33
|
+
/** Whether this model is premium-tier (glm5.3). */
|
|
34
|
+
premium: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const MODEL_QUOTAS: readonly ModelQuota[] = [
|
|
38
|
+
{ model: "deepseek-v4-flash", label: "DeepSeek V4 Flash", monthlyCap: 3_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
39
|
+
{ model: "mimo-v2.5", label: "MiMo V2.5", monthlyCap: 1_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
40
|
+
{ model: "qwen3.6", label: "Qwen 3.6", monthlyCap: 0, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
41
|
+
{ model: "gemma4", label: "Gemma 4", monthlyCap: 0, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
42
|
+
{ model: "qwen3.8-flash", label: "Qwen 3.8 Flash", monthlyCap: 500_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
43
|
+
{ model: "glm5.3-flash", label: "GLM 5.3 Flash", monthlyCap: 2_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
44
|
+
{ model: "glm5.3", label: "GLM 5.3", monthlyCap: 3_000_000_000, rollingWindowCap: 400_000_000, rollingWindowHours: 4, premium: true },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// ── Dashboard API types ───────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
interface DashboardModelQuota {
|
|
50
|
+
model: string;
|
|
51
|
+
tokensUsed: number;
|
|
52
|
+
cap: number;
|
|
53
|
+
percentage: number;
|
|
54
|
+
resetAt: string | null;
|
|
55
|
+
windowHours: number | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DashboardUncappedModelQuota {
|
|
59
|
+
model: string;
|
|
60
|
+
tokensUsed: number;
|
|
61
|
+
resetAt: string | null;
|
|
62
|
+
windowHours: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface DashboardQuotaResponse {
|
|
66
|
+
periodStart: string;
|
|
67
|
+
models: Array<{
|
|
68
|
+
model: string;
|
|
69
|
+
tokensUsed: number;
|
|
70
|
+
cap: number;
|
|
71
|
+
windowHours?: number;
|
|
72
|
+
periodEnd?: string;
|
|
73
|
+
}>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── nan-cli session reader ────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
interface NanCliSession {
|
|
79
|
+
token: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read the nan_session token from ~/.config/nan/session.json (shared with nan-cli). */
|
|
83
|
+
function readNanCliSessionToken(): string | undefined {
|
|
84
|
+
try {
|
|
85
|
+
const sessionPath = join(homedir(), ".config", "nan", "session.json");
|
|
86
|
+
const data = readFileSync(sessionPath, "utf8");
|
|
87
|
+
const session = JSON.parse(data) as NanCliSession;
|
|
88
|
+
if (typeof session === "object" && session !== null && typeof session.token === "string" && session.token.length > 0) {
|
|
89
|
+
return session.token;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// File doesn't exist or is invalid.
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Time helpers ──────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function formatDuration(ms: number): string {
|
|
100
|
+
if (ms <= 0) return "already reset";
|
|
101
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
102
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
103
|
+
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
|
104
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
105
|
+
const seconds = totalSeconds % 60;
|
|
106
|
+
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
if (days > 0) parts.push(`${days}d`);
|
|
109
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
110
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
111
|
+
parts.push(`${seconds}s`);
|
|
112
|
+
return parts.join(" ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getNextBillingReset(): Date {
|
|
116
|
+
const now = new Date();
|
|
117
|
+
const year = now.getUTCFullYear();
|
|
118
|
+
const month = now.getUTCMonth();
|
|
119
|
+
return new Date(Date.UTC(year, month + 1, 0, 0, 0, 0));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatTokens(n: number): string {
|
|
123
|
+
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
124
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
125
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
126
|
+
return String(n);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function progressBar(percentage: number, width = 20): string {
|
|
130
|
+
const filled = Math.round((percentage / 100) * width);
|
|
131
|
+
const empty = width - filled;
|
|
132
|
+
return `[${"█".repeat(filled)}${"░".repeat(empty)}]`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Dashboard client ──────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
const DASHBOARD_QUOTA_URL = "https://cloud-api.nan.builders/api/usage/quota";
|
|
138
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
139
|
+
|
|
140
|
+
async function fetchDashboardQuota(token: string): Promise<DashboardQuotaResponse | null> {
|
|
141
|
+
try {
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetch(DASHBOARD_QUOTA_URL, {
|
|
146
|
+
method: "GET",
|
|
147
|
+
headers: { cookie: `nan_session=${token}` },
|
|
148
|
+
redirect: "manual",
|
|
149
|
+
cache: "no-store",
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok) return null;
|
|
153
|
+
return (await response.json()) as DashboardQuotaResponse;
|
|
154
|
+
} finally {
|
|
155
|
+
clearTimeout(timeout);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseDashboardQuota(data: DashboardQuotaResponse): {
|
|
163
|
+
capped: DashboardModelQuota[];
|
|
164
|
+
uncapped: DashboardUncappedModelQuota[];
|
|
165
|
+
} {
|
|
166
|
+
const capped: DashboardModelQuota[] = [];
|
|
167
|
+
const uncapped: DashboardUncappedModelQuota[] = [];
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
|
|
170
|
+
for (const entry of data.models) {
|
|
171
|
+
if (seen.has(entry.model)) continue;
|
|
172
|
+
seen.add(entry.model);
|
|
173
|
+
|
|
174
|
+
if (entry.cap === 0) {
|
|
175
|
+
uncapped.push({
|
|
176
|
+
model: entry.model,
|
|
177
|
+
tokensUsed: entry.tokensUsed,
|
|
178
|
+
resetAt: entry.periodEnd ?? null,
|
|
179
|
+
windowHours: entry.windowHours ?? null,
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
capped.push({
|
|
183
|
+
model: entry.model,
|
|
184
|
+
tokensUsed: entry.tokensUsed,
|
|
185
|
+
cap: entry.cap,
|
|
186
|
+
percentage: (entry.tokensUsed / entry.cap) * 100,
|
|
187
|
+
resetAt: entry.periodEnd ?? null,
|
|
188
|
+
windowHours: entry.windowHours ?? null,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { capped, uncapped };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Message builders ──────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function buildStaticMessage(): string {
|
|
199
|
+
const resetDate = getNextBillingReset();
|
|
200
|
+
const timeUntilReset = resetDate.getTime() - Date.now();
|
|
201
|
+
|
|
202
|
+
const lines: string[] = [
|
|
203
|
+
"📊 NaN Quota Status (static limits)",
|
|
204
|
+
"",
|
|
205
|
+
`⏱️ Next billing reset: ${resetDate.toISOString().split("T")[0]} UTC (${formatDuration(timeUntilReset)})`,
|
|
206
|
+
"",
|
|
207
|
+
"Model Monthly Cap",
|
|
208
|
+
"─".repeat(45),
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
for (const quota of MODEL_QUOTAS) {
|
|
212
|
+
const capStr = quota.monthlyCap > 0 ? formatTokens(quota.monthlyCap) : "uncapped";
|
|
213
|
+
const premiumStr = quota.premium ? " 👑" : "";
|
|
214
|
+
const rollingStr = quota.rollingWindowCap > 0
|
|
215
|
+
? ` (rolling ${formatTokens(quota.rollingWindowCap)}/${quota.rollingWindowHours}h)`
|
|
216
|
+
: "";
|
|
217
|
+
|
|
218
|
+
lines.push(`${quota.label.padEnd(28)} ${capStr}${premiumStr}${rollingStr}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
lines.push("");
|
|
222
|
+
lines.push("💡 Run `nan auth login` to see real usage data.");
|
|
223
|
+
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildDashboardMessage(
|
|
228
|
+
capped: DashboardModelQuota[],
|
|
229
|
+
uncapped: DashboardUncappedModelQuota[],
|
|
230
|
+
): string {
|
|
231
|
+
const resetDate = getNextBillingReset();
|
|
232
|
+
const timeUntilReset = resetDate.getTime() - Date.now();
|
|
233
|
+
|
|
234
|
+
const lines: string[] = [
|
|
235
|
+
"📊 NaN Quota Status",
|
|
236
|
+
"",
|
|
237
|
+
`⏱️ Next billing reset: ${resetDate.toISOString().split("T")[0]} UTC (${formatDuration(timeUntilReset)})`,
|
|
238
|
+
"",
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
if (capped.length > 0) {
|
|
242
|
+
lines.push("Models with monthly caps:");
|
|
243
|
+
lines.push("");
|
|
244
|
+
for (const m of capped) {
|
|
245
|
+
const quota = MODEL_QUOTAS.find((q) => q.model === m.model);
|
|
246
|
+
const label = quota?.label ?? m.model;
|
|
247
|
+
const pct = m.percentage.toFixed(1);
|
|
248
|
+
const remaining = m.cap - m.tokensUsed;
|
|
249
|
+
lines.push(`${label}:`);
|
|
250
|
+
lines.push(` ${progressBar(m.percentage)} ${pct}%`);
|
|
251
|
+
lines.push(` Used: ${formatTokens(m.tokensUsed)} / ${formatTokens(m.cap)} (${formatTokens(remaining)} remaining)`);
|
|
252
|
+
if (m.windowHours) {
|
|
253
|
+
lines.push(` Rolling window: ${m.windowHours}h`);
|
|
254
|
+
}
|
|
255
|
+
lines.push("");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (uncapped.length > 0) {
|
|
260
|
+
lines.push("Uncapped models:");
|
|
261
|
+
lines.push("");
|
|
262
|
+
for (const m of uncapped) {
|
|
263
|
+
const quota = MODEL_QUOTAS.find((q) => q.model === m.model);
|
|
264
|
+
const label = quota?.label ?? m.model;
|
|
265
|
+
lines.push(`${label}: ${formatTokens(m.tokensUsed)} used`);
|
|
266
|
+
}
|
|
267
|
+
lines.push("");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (capped.length === 0 && uncapped.length === 0) {
|
|
271
|
+
lines.push("No usage data. Session may have expired.");
|
|
272
|
+
lines.push("Run `nan auth login` to refresh.");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return lines.join("\n");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Command registration ──────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
export function registerNanUsageCommand(pi: import("@earendil-works/pi-coding-agent").ExtensionAPI): void {
|
|
281
|
+
if (typeof pi.registerCommand !== "function") return;
|
|
282
|
+
|
|
283
|
+
pi.registerCommand("nan-usage", {
|
|
284
|
+
description: "Show NaN quota status: token limits, usage, and time until billing reset",
|
|
285
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
286
|
+
const token = readNanCliSessionToken();
|
|
287
|
+
|
|
288
|
+
if (token) {
|
|
289
|
+
ctx.ui.notify("Fetching usage from NaN dashboard...", "info");
|
|
290
|
+
const data = await fetchDashboardQuota(token);
|
|
291
|
+
if (data) {
|
|
292
|
+
const { capped, uncapped } = parseDashboardQuota(data);
|
|
293
|
+
ctx.ui.notify(buildDashboardMessage(capped, uncapped), "info");
|
|
294
|
+
} else {
|
|
295
|
+
ctx.ui.notify(
|
|
296
|
+
"Failed to fetch dashboard data. Session may have expired.\n" +
|
|
297
|
+
"Run `nan auth login` to refresh.",
|
|
298
|
+
"warning",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
ctx.ui.notify(buildStaticMessage(), "info");
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|