@gtrabanco/pi-nan-provider 0.6.9 → 0.6.11

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 CHANGED
@@ -77,18 +77,32 @@ 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.5.0, verified on pi-ai 0.83.0 AND 0.84.4):**
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 in every loading
83
- mode (bundled CLI interception, Node-mode jiti aliases, compiled-binary
84
- virtualModules), and the compat entrypoint re-exports every lazy API factory —
85
- including `openAICompletionsApi`. A static SUBPATH import
86
- (`@earendil-works/pi-ai/api/...`) gets the alias applied as a prefix and
87
- resolves to `<compat.js>/api/...`, which does not exist: the whole extension
88
- fails to load (the v0.4.x load failure). Type-only subpath imports are erased
89
- before resolution and are safe; a DYNAMIC subpath `import()` is the sanctioned
90
- plain-node fallback and never runs under pi because the root (compat) exports
91
- the factory. Guarded by `test/extension-load.test.ts`.
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 the host loaded: use the root export
97
+ when present, else resolve the bare root **from the host process entrypoint**
98
+ (`process.argv[1]`) via
99
+ `import.meta.resolve("@earendil-works/pi-ai", hostAnchor)` — an
100
+ extension-relative resolve returns the extension tree's stale copy (the
101
+ v0.6.10 regression that left #8 open) — then derive a FILE URL for
102
+ `api/openai-completions.lazy.js` (then `compat.js`). No bare pi-ai subpath
103
+ specifier is imported anywhere in `src/` (static or dynamic); failure is loud
104
+ (`PiAiStreamingApiResolutionError`). Guarded by `test/extension-load.test.ts`
105
+ and `test/issue-8-pi-ai-instance.test.ts`.
92
106
  - The REAL pi-ai root (plain node/bun, outside pi) does not export
93
107
  `openAICompletionsApi`; `createProvider` and `envApiKeyAuth(name, envVars)` are
94
108
  on the root. `envApiKeyAuth` implements exactly: stored credential key wins →
package/README.es.md CHANGED
@@ -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
@@ -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.9",
3
+ "version": "0.6.11",
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",
@@ -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-16T19:40:56.569Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-22T08:55:04.775Z
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.
@@ -382,7 +382,7 @@ 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-16T19:40:56.569Z",
385
+ fetchedAt: "2026-09-22T08:55:04.775Z",
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: [
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
 
@@ -0,0 +1,292 @@
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
+ * The anchor is the **host process entrypoint** (`process.argv[1]`), passed to
22
+ * `import.meta.resolve(specifier, parent)`. An extension-relative resolve is
23
+ * NOT enough: it returns whatever the extension's own tree holds, which under
24
+ * pi-web is exactly the stale 0.85.1 copy (that was the v0.6.10 regression:
25
+ * the "host-resolved root" was still the extension's copy). From the
26
+ * host-resolved root derive a **file URL** for the sibling
27
+ * `api/openai-completions.lazy.js` (then `compat.js`) and dynamic-import the
28
+ * URL. A file URL bypasses package resolution entirely, so the loaded module is
29
+ * guaranteed to be the host's instance.
30
+ *
31
+ * Under the bundled CLI / Node-mode aliases / compiled binary, the bare root
32
+ * is the compat entrypoint and already exposes the factory, so the first
33
+ * branch wins and nothing is resolved.
34
+ *
35
+ * **Contract:** no bare `@earendil-works/pi-ai/<subpath>` specifier is ever
36
+ * imported from `src/` (static or dynamic). When neither the root nor the
37
+ * host-derived candidates provide the factory, resolution fails loudly with
38
+ * `PiAiStreamingApiResolutionError` instead of silently loading a stale copy.
39
+ *
40
+ * Guarded by `test/issue-8-pi-ai-instance.test.ts` and
41
+ * `test/extension-load.test.ts`.
42
+ */
43
+
44
+ import * as piAi from "@earendil-works/pi-ai";
45
+ import type { ProviderStreams } from "@earendil-works/pi-ai";
46
+ import { createRequire } from "node:module";
47
+ import { pathToFileURL } from "node:url";
48
+
49
+ /** The only pi-ai specifier this package may import. */
50
+ export const PI_AI_PACKAGE_SPECIFIER = "@earendil-works/pi-ai";
51
+
52
+ /** Export name of the openai-completions lazy factory on compat/lazy entrypoints. */
53
+ export const OPENAI_COMPLETIONS_FACTORY_EXPORT = "openAICompletionsApi";
54
+
55
+ /** Package-relative path of the lazy openai-completions entrypoint. */
56
+ export const OPENAI_COMPLETIONS_LAZY_ENTRY = "api/openai-completions.lazy.js";
57
+
58
+ /** Package-relative path of pi-ai's compat entrypoint (secondary candidate). */
59
+ export const PI_AI_COMPAT_ENTRY = "compat.js";
60
+
61
+ /** pi-ai's lazy API factory shape (same as the compat root export). */
62
+ export type OpenAICompletionsApiFactory = () => ProviderStreams;
63
+
64
+ /** A dynamically imported module namespace. */
65
+ export type ModuleNamespace = Record<string, unknown>;
66
+
67
+ /**
68
+ * The resolution seam. Production uses {@link defaultPiAiLoaderHost}; tests
69
+ * inject a host to prove the loader binds to the resolved instance (and never
70
+ * to the extension's own tree).
71
+ */
72
+ export interface PiAiLoaderHost {
73
+ /** The bare-root namespace this extension statically imported. */
74
+ readonly namespace: ModuleNamespace;
75
+ /** Resolve a specifier the way the host runtime does. */
76
+ resolveSpecifier?(specifier: string): string;
77
+ /** Load a module by absolute URL (a `file://` URL in the derived path). */
78
+ importModule(url: string): Promise<ModuleNamespace>;
79
+ }
80
+
81
+ /**
82
+ * Thrown when the openai-completions factory cannot be bound to the
83
+ * host-resolved pi-ai instance. Carries the resolved root and the URLs that
84
+ * were attempted so the failure is a one-line diagnosis.
85
+ */
86
+ export class PiAiStreamingApiResolutionError extends Error {
87
+ readonly resolvedRootUrl?: string;
88
+ readonly attemptedUrls: readonly string[];
89
+
90
+ constructor(
91
+ message: string,
92
+ options: { resolvedRootUrl?: string; attemptedUrls?: readonly string[] } = {},
93
+ ) {
94
+ super(message);
95
+ this.name = "PiAiStreamingApiResolutionError";
96
+ this.resolvedRootUrl = options.resolvedRootUrl;
97
+ this.attemptedUrls = options.attemptedUrls ?? [];
98
+ }
99
+ }
100
+
101
+ /** Extract the openai-completions factory from a module namespace, if present. */
102
+ export function openAICompletionsApiFrom(namespace: unknown): OpenAICompletionsApiFactory | undefined {
103
+ if (namespace === null || typeof namespace !== "object") return undefined;
104
+ const candidate = (namespace as ModuleNamespace)[OPENAI_COMPLETIONS_FACTORY_EXPORT];
105
+ return typeof candidate === "function" ? (candidate as OpenAICompletionsApiFactory) : undefined;
106
+ }
107
+
108
+ /**
109
+ * `import.meta.resolve` is absent from bun-types' `ImportMeta`, so read it
110
+ * through an explicit shape. Bun/Node expose it at runtime; when it is missing
111
+ * or throws, `createRequire` resolves the same bare root from this module.
112
+ *
113
+ * Referenced directly (not via a variable or type cast) so pi's jiti loader
114
+ * can rewrite it. PR #11 (jiti compatibility) and PR #12 (host-anchored
115
+ * resolution) merge: the direct reference fixes jiti, the host anchor fixes
116
+ * pi-web.
117
+ */
118
+ function readImportMetaResolve(): ((specifier: string, parent?: string) => string) | undefined {
119
+ if (typeof import.meta.resolve !== "function") return undefined;
120
+ return (specifier, parent) =>
121
+ parent === undefined ? import.meta.resolve(specifier) : import.meta.resolve(specifier, parent);
122
+ }
123
+
124
+ /**
125
+ * File URL of the host process entrypoint (pi-web's `sessiond.js`, the pi CLI,
126
+ * a test runner). Resolving FROM it pins the result to the host's module graph
127
+ * instead of the extension's own tree.
128
+ */
129
+ export function hostAnchorUrl(entry: string | undefined): string | undefined {
130
+ if (typeof entry !== "string" || entry.length === 0) return undefined;
131
+ try {
132
+ return pathToFileURL(entry).href;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ /** Injection seam for {@link resolvePiAiSpecifier}. */
139
+ export interface PiAiSpecifierResolution {
140
+ /** Anchor URL to resolve FROM; omitted means extension-relative resolution. */
141
+ anchorUrl?: string;
142
+ /** `import.meta.resolve`, when the runtime exposes it. */
143
+ resolve?: (specifier: string, parent?: string) => string;
144
+ /** Last-resort CJS resolver; only used when neither `resolve` call succeeds. */
145
+ fallback?: (specifier: string) => string;
146
+ }
147
+
148
+ /**
149
+ * Resolve a pi-ai specifier the way the host runtime does.
150
+ *
151
+ * The `anchorUrl` is the whole point. Under pi-web the extension's own tree
152
+ * holds a stale hoisted `@earendil-works/pi-ai` (0.85.1), so an
153
+ * extension-relative resolve returns a package the host never loaded, and the
154
+ * derived "host-resolved root" is still that stale copy. Passing the host
155
+ * entrypoint as the resolver's parent returns the instance the host itself
156
+ * uses (the v0.6.10 fix anchored on `import.meta.resolve` alone and therefore
157
+ * still selected the stale copy).
158
+ */
159
+ export function resolvePiAiSpecifier(
160
+ specifier: string,
161
+ options: PiAiSpecifierResolution = {},
162
+ ): string {
163
+ const resolve = options.resolve ?? readImportMetaResolve();
164
+ const fallback =
165
+ options.fallback ?? ((value: string) => createRequire(import.meta.url).resolve(value));
166
+ if (resolve !== undefined) {
167
+ if (options.anchorUrl !== undefined) {
168
+ try {
169
+ const anchored = resolve(specifier, options.anchorUrl);
170
+ if (typeof anchored === "string" && anchored.length > 0) return anchored;
171
+ } catch {
172
+ // Runtimes without parent support fall through to the bare call.
173
+ }
174
+ }
175
+ try {
176
+ const resolved = resolve(specifier);
177
+ if (typeof resolved === "string" && resolved.length > 0) return resolved;
178
+ } catch {
179
+ // Fall through to createRequire — same bare root, same instance.
180
+ }
181
+ }
182
+ return fallback(specifier);
183
+ }
184
+
185
+ const defaultPiAiLoaderHost: PiAiLoaderHost = {
186
+ namespace: piAi as unknown as ModuleNamespace,
187
+ resolveSpecifier(specifier: string): string {
188
+ return resolvePiAiSpecifier(specifier, { anchorUrl: hostAnchorUrl(process.argv[1]) });
189
+ },
190
+ importModule: (url: string) => import(url) as Promise<ModuleNamespace>,
191
+ };
192
+
193
+ /** Cached result of the default-host resolution; injected hosts bypass it. */
194
+ let defaultHostCache: OpenAICompletionsApiFactory | undefined;
195
+
196
+ /** Build the candidate file URLs from the host-resolved package root. */
197
+ function candidateUrlsFor(rootUrl: string): { rootDir: URL; candidates: string[] } {
198
+ const rootDir = new URL("./", new URL(rootUrl));
199
+ return {
200
+ rootDir,
201
+ candidates: [
202
+ new URL(OPENAI_COMPLETIONS_LAZY_ENTRY, rootDir).href,
203
+ new URL(PI_AI_COMPAT_ENTRY, rootDir).href,
204
+ ],
205
+ };
206
+ }
207
+
208
+ function loudError(
209
+ rootUrl: string | undefined,
210
+ attemptedUrls: readonly string[],
211
+ lastError: Error | undefined,
212
+ ): PiAiStreamingApiResolutionError {
213
+ const lines = [
214
+ `Could not resolve "${OPENAI_COMPLETIONS_FACTORY_EXPORT}" from the host-resolved "${PI_AI_PACKAGE_SPECIFIER}" instance.`,
215
+ `Resolved root: ${rootUrl ?? "<unresolved>"}`,
216
+ attemptedUrls.length > 0
217
+ ? `Attempted URLs:\n${attemptedUrls.map((url) => ` - ${url}`).join("\n")}`
218
+ : "Attempted URLs: none (package root resolution failed)",
219
+ lastError ? `Last error: ${lastError.message}` : undefined,
220
+ "",
221
+ "This provider refuses to load a stale @earendil-works/pi-ai from the extension's own npm tree (issue #8).",
222
+ "On pi-web's sessiond-on-Bun loader the bare root is not mapped to pi's /compat entrypoint, so the",
223
+ "factory must be imported from a file URL derived from import.meta.resolve(...) — never a bare subpath.",
224
+ ];
225
+ return new PiAiStreamingApiResolutionError(lines.filter((line) => line !== undefined).join("\n"), {
226
+ resolvedRootUrl: rootUrl,
227
+ attemptedUrls,
228
+ });
229
+ }
230
+
231
+ /**
232
+ * @param host Resolution seam; omit in production.
233
+ * @returns The openai-completions lazy API factory from the host's pi-ai instance.
234
+ * @throws PiAiStreamingApiResolutionError when no candidate can be bound.
235
+ */
236
+ export async function resolveOpenAICompletionsApi(
237
+ host?: PiAiLoaderHost,
238
+ ): Promise<OpenAICompletionsApiFactory> {
239
+ const usesDefaultHost = host === undefined;
240
+ if (usesDefaultHost && defaultHostCache) return defaultHostCache;
241
+ const activeHost = host ?? defaultPiAiLoaderHost;
242
+
243
+ // 1. Compat root (bundled CLI / Node aliases / compiled binary): the bare
244
+ // root itself re-exports the factory — no resolution needed.
245
+ const fromRoot = openAICompletionsApiFrom(activeHost.namespace);
246
+ if (fromRoot) {
247
+ if (usesDefaultHost) defaultHostCache = fromRoot;
248
+ return fromRoot;
249
+ }
250
+
251
+ // 2. Core root (pi-web on Bun): anchor to the host-resolved package root.
252
+ if (typeof activeHost.resolveSpecifier !== "function") {
253
+ throw loudError(undefined, [], undefined);
254
+ }
255
+
256
+ let rootUrl: string | undefined;
257
+ try {
258
+ rootUrl = activeHost.resolveSpecifier(PI_AI_PACKAGE_SPECIFIER);
259
+ } catch (error) {
260
+ throw loudError(undefined, [], error instanceof Error ? error : new Error(String(error)));
261
+ }
262
+ if (!rootUrl) throw loudError(undefined, [], undefined);
263
+
264
+ // 3. Derive file URLs from the resolved root. The directory-prefix guard is
265
+ // the same-package-instance assertion: a candidate must stay inside the
266
+ // package the host resolved.
267
+ const { rootDir, candidates } = candidateUrlsFor(rootUrl);
268
+ const attemptedUrls: string[] = [];
269
+ let lastError: Error | undefined;
270
+ for (const candidate of candidates) {
271
+ if (!candidate.startsWith(rootDir.href)) {
272
+ throw loudError(
273
+ rootUrl,
274
+ attemptedUrls,
275
+ new Error(`candidate "${candidate}" escapes the resolved package root "${rootDir.href}"`),
276
+ );
277
+ }
278
+ attemptedUrls.push(candidate);
279
+ try {
280
+ const factory = openAICompletionsApiFrom(await activeHost.importModule(candidate));
281
+ if (factory) {
282
+ if (usesDefaultHost) defaultHostCache = factory;
283
+ return factory;
284
+ }
285
+ lastError = new Error(`"${OPENAI_COMPLETIONS_FACTORY_EXPORT}" is not exported by ${candidate}`);
286
+ } catch (error) {
287
+ lastError = error instanceof Error ? error : new Error(String(error));
288
+ }
289
+ }
290
+
291
+ throw loudError(rootUrl, attemptedUrls, lastError);
292
+ }
@@ -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,35 +58,6 @@ 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
@@ -157,8 +133,10 @@ export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStre
157
133
  * - fetchModels: live `/models` IDs × generated capability data; falls back
158
134
  * to the baseline when the endpoint is unreachable. pi's Models runtime
159
135
  * drives refreshes (startup/periodic) and persists the overlay.
160
- * - api: the openai-completions streaming implementation (see
161
- * `resolveOpenAICompletionsApi` for why this is resolved dynamically).
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.
162
140
  */
163
141
  export async function createNanCompatibleProvider(
164
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
+ }