@gtrabanco/pi-nan-provider 0.5.2 → 0.6.2

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
@@ -115,7 +115,10 @@ Every PR that changes code MUST bump `package.json` version in the same PR; CI p
115
115
  "growing registry" — use tools/list to discover.
116
116
  - Community `nan-mcp-server` (https://github.com/luciferfran/nan-mcp-server):
117
117
  stdio MCP server, spawned per tool call (lazy), opt-in NAN_MEDIA_MCP=1,
118
- version-pinned via NAN_MEDIA_MCP_VERSION (default 1.0.7) or a full command
118
+ version-pinned via NAN_MEDIA_MCP_VERSION (default 1.0.8) or a full command
119
119
  override via NAN_MEDIA_MCP_COMMAND. Tools: generate_image, edit_image,
120
120
  text_to_speech, list_voices, speech_to_text, embed, rerank, list_models
121
- (we bridge the audio/image/transcription scope).
121
+ (we bridge the audio/image/transcription scope). An automated check
122
+ (scripts/check-nan-mcp-server.ts + .github/workflows/check-nan-mcp-server-update.yml)
123
+ compares the npm registry against the pin weekly and files a `dependencies` issue
124
+ with a breaking/safe verdict from the live server tool surface (unpkg).
package/README.es.md CHANGED
@@ -98,8 +98,27 @@ Variables de entorno:
98
98
  | Variable | Por defecto | Significado |
99
99
  |---|---|---|
100
100
  | `NAN_MEDIA_MCP` | — | Override por sesión del puente de media: cualquier valor explícito (incl. `0`) gana al toggle persistido de `/nan-mcp`; sin definir → persistido/por defecto |
101
- | `NAN_MEDIA_MCP_VERSION` | `1.0.7` | Versión del servidor fijada para `npx -y nan-mcp-server@<v>` (recomendación de supply-chain del propio proyecto) |
102
- | `NAN_MEDIA_MCP_COMMAND` | — | Comando personalizado completo, p. ej. `bunx nan-mcp-server@1.0.7` |
101
+ | `NAN_MEDIA_MCP_VERSION` | `1.0.8` | Versión del servidor fijada para `npx -y nan-mcp-server@<v>` (recomendación de supply-chain del propio proyecto) |
102
+ | `NAN_MEDIA_MCP_COMMAND` | — | Comando personalizado completo, p. ej. `bunx nan-mcp-server@1.0.8` |
103
+
104
+ #### Detección automatizada de actualizaciones
105
+
106
+ Una nueva versión de `nan-mcp-server` no desviará silenciosamente el pin de esta conexión. El
107
+ planificador (`.github/workflows/check-nan-mcp-server-update.yml`) ejecuta `bun run scripts/check-nan-mcp-server.ts`
108
+ semanalmente y, cuando el registro npm muestra una versión más reciente, abre (o refresca) un único
109
+ issue etiquetado `dependencies` que describe si el bump es **breaking** o **seguro**, más la lista de
110
+ commits aguas arriba. Puedes ejecutarlo localmente en cualquier momento:
111
+
112
+ ```bash
113
+ bun run check-nan-mcp-server # informe legible
114
+ bun run check-nan-mcp-server --json # JSON para máquina
115
+ bun run check-nan-mcp-server --issue # crea/refresca el issue (requiere GITHUB_TOKEN)
116
+ ```
117
+
118
+ La decisión se toma a partir de la *superficie de tools* en vivo (vía unpkg), no de docs obsoletos: si
119
+ el último servidor sigue exponiendo todas las tools conectadas (`generate_image`, `edit_image`,
120
+ `text_to_speech`, `list_voices`, `speech_to_text`), el bump se reporta como **no rompente**; si elimina
121
+ o renombra alguna tool conectada, el issue se marca **breaking** para revisión manual antes de subir.
103
122
  | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Timeout por llamada; el proceso se mata al expirar |
104
123
  | `NAN_MCP_TOOLS` | — | Override por sesión del puente oficial: `0`/`false`/`off` desactiva `nan_web_search`; sin definir → persistido/por defecto |
105
124
 
@@ -124,6 +143,7 @@ Notas (grabadas por entrada en `scripts/models.generated.ts`):
124
143
  - `mimo-v2.5` es omnimodal (texto/imagen/audio) en NaN, pero el tipo de modelo de pi solo representa entrada texto/imagen, así que el audio se omite en `input`.
125
144
  - NaN factura por cuota de membresía, que models.dev reporta como coste cero por token — el coste mostrado por pi será $0.
126
145
  - Compat (`supportsDeveloperRole: false`, `supportsReasoningEffort: true`, `supportsUsageInStreaming: true`, `maxTokensField: "max_tokens"`) coincide con la config LiteLLM probada en batalla que este paquete reemplaza; el ejemplo de los docs de NaN (`supportsDeveloperRole: true`) no está probado.
146
+ - **Conformidad con el esquema estricto**: NaN valida cada payload de `/chat/completions` contra su propio esquema estricto ([openapi.json](https://nan.builders/openapi.json)) y devuelve HTTP 400 `Invalid request. Check your request parameters.` para formas no permitidas — p. ej. un mensaje `assistant` reenviado con un bloque `toolCall` dentro de `content`, un campo `reasoning_details` solo de OpenAI, campos a nivel superior no documentados como `store` / `stream_options`, o un **array vacío `tools: []`**. Por eso cada petición se reescribe con un saneador en el proveedor (`src/openai-compat-sanitizer.ts`, conectado vía `onPayload` en la fábrica compartida) para que siempre sea válida según el esquema, sin importar qué versión de pi-ai esté empaquetada:
127
147
  - **Tier/cuota**: qué modelos puedes llamar lo decide tu membresía de NaN. Con clave, el fetch en vivo refleja exactamente eso (ver *Cómo funciona* — detección de tier). El `glm5.3` de tier premium no está en el proveedor `nan` de models.dev y ninguna fuente documenta su límite de salida, así que no entra en el catálogo estático (marcado como no emitible en los metadatos); las claves premium lo reciben en vivo vía el refresh de `/models`, con límites conservadores (128K contexto / 4K salida). Solo está `glm5.3-flash` en el catálogo estático.
128
148
 
129
149
  ### Relación con `~/.pi/agent/models.json`
package/README.md CHANGED
@@ -98,8 +98,27 @@ Environment variables:
98
98
  | Variable | Default | Meaning |
99
99
  |---|---|---|
100
100
  | `NAN_MEDIA_MCP` | — | Per-session override for the media bridge: any explicit value (incl. `0`) beats the `/nan-mcp` persisted toggle; unset → persisted/default |
101
- | `NAN_MEDIA_MCP_VERSION` | `1.0.7` | Pinned server version for `npx -y nan-mcp-server@<v>` (upstream's own supply-chain recommendation) |
102
- | `NAN_MEDIA_MCP_COMMAND` | — | Full custom command, e.g. `bunx nan-mcp-server@1.0.7` |
101
+ | `NAN_MEDIA_MCP_VERSION` | `1.0.8` | Pinned server version for `npx -y nan-mcp-server@<v>` (upstream's own supply-chain recommendation) |
102
+ | `NAN_MEDIA_MCP_COMMAND` | — | Full custom command, e.g. `bunx nan-mcp-server@1.0.8` |
103
+
104
+ #### Automated update detection
105
+
106
+ A newer `nan-mcp-server` release won't silently drift this bridge's pin. The scheduler
107
+ (`.github/workflows/check-nan-mcp-server-update.yml`) runs `bun run scripts/check-nan-mcp-server.ts`
108
+ weekly and, when the npm registry shows a newer version, opens (or refreshes) one
109
+ `dependencies`-labelled issue describing whether the bump is **breaking** or **safe**, plus the
110
+ upstream commit list. Run it locally any time:
111
+
112
+ ```bash
113
+ bun run check-nan-mcp-server # human-readable report
114
+ bun run check-nan-mcp-server --json # machine-readable JSON
115
+ bun run check-nan-mcp-server --issue # create/refresh the issue (needs GITHUB_TOKEN)
116
+ ```
117
+
118
+ The decision is made from the *live* server tool surface (via unpkg), not from stale docs: if the
119
+ latest server still exposes every bridged tool (`generate_image`, `edit_image`, `text_to_speech`,
120
+ `list_voices`, `speech_to_text`), the bump is reported as **non-breaking**; if it drops or renames a
121
+ bridged tool, the issue is flagged **breaking** for manual review before bumping.
103
122
  | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Per-call timeout; the process is killed after it |
104
123
  | `NAN_MCP_TOOLS` | — | Per-session override for the official bridge: `0`/`false`/`off` disables `nan_web_search`; unset → persisted/default |
105
124
 
@@ -124,6 +143,7 @@ Notes (recorded per entry in `scripts/models.generated.ts`):
124
143
  - `mimo-v2.5` is omnimodal (text/image/audio) on NaN, but pi's model type only represents text/image input, so audio is dropped from `input`.
125
144
  - NaN bills via membership quota, which models.dev reports as zero per-token cost — pi's cost display will read $0.
126
145
  - Compat (`supportsDeveloperRole: false`, `supportsReasoningEffort: true`, `supportsUsageInStreaming: true`, `maxTokensField: "max_tokens"`) matches the battle-tested LiteLLM config this package replaces; NaN's docs example (`supportsDeveloperRole: true`) is not battle-tested.
146
+ - **Strict schema conformance**: NaN validates every `/chat/completions` payload against its own strict schema ([openapi.json](https://nan.builders/openapi.json)) and returns HTTP 400 `Invalid request. Check your request parameters.` for disallowed shapes — e.g. a replayed `assistant` message with a `toolCall` block inside `content`, an OpenAI-only `reasoning_details` field, undocumented top-level fields like `store` / `stream_options`, or an **empty `tools: []` array**. Every request is therefore rewritten by a provider-side sanitizer (`src/openai-compat-sanitizer.ts`, wired through the shared factory's `onPayload`) so it is always schema-valid no matter which pi-ai version is bundled:
127
147
  - **Tier/quota**: which models you can call is decided by your NaN membership. With a key, the live fetch reflects exactly that (see *How it works* — tier detection). The premium-tier `glm5.3` is absent from the models.dev `nan` provider and no source documents its max output tokens, so it is not in the static catalog (flagged as unemittable in the catalog metadata); premium keys still get it live via the `/models` refresh with conservative placeholder limits (128K context / 4K output). Only `glm5.3-flash` is in the static catalog.
128
148
 
129
149
  ### Relationship to `~/.pi/agent/models.json`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-nan-provider",
3
- "version": "0.5.2",
3
+ "version": "0.6.2",
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",
@@ -35,6 +35,7 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "generate-models": "bun run scripts/generate-models.ts",
38
+ "check-nan-mcp-server": "bun run scripts/check-nan-mcp-server.ts",
38
39
  "prepublishOnly": "bun run generate-models && bun test && bun run typecheck",
39
40
  "test": "bun test",
40
41
  "typecheck": "bunx tsc --noEmit"
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Detect whether an updated `nan-mcp-server` is available and, if so, decide
4
+ * whether it is safe to bump the pinned version this package bridges.
5
+ *
6
+ * This is the automated companion to the manual "should we update?" question
7
+ * for the community stdio MCP server (`nan-mcp-server`, the media bridge). It
8
+ * exists because pi implements NO MCP client — we bridge the server's tools as
9
+ * native pi tools, and the bridge is pinned to a version via
10
+ * `DEFAULT_NAN_MEDIA_MCP_VERSION`. When the upstream server ships a new
11
+ * version we want a signal, not a silent drift.
12
+ *
13
+ * What it checks (all live, no human guesswork):
14
+ * 1. Latest published `nan-mcp-server` version on the npm registry.
15
+ * 2. Whether the latest version is newer than the pinned version.
16
+ * 3. The *tool surface* of the latest server vs the tools we bridge: if the
17
+ * latest server still exposes every bridged tool, the update is **safe**
18
+ * (non-breaking) even if the implementation changed. If it dropped or
19
+ * renamed a bridged tool, the bump is **breaking** and needs manual
20
+ * review before proceeding — an agent check cannot auto-bump that.
21
+ * 4. The upstream commit list between the pinned and latest versions, so a
22
+ * maintainer sees WHAT changed without re-deriving from stale docs.
23
+ *
24
+ * Output: a single structured `NanMcpServerCheckReport` (JSON). The script
25
+ * exits 0 when up-to-date or when the check cannot be completed (so a cron is
26
+ * quiet unless there is news), and it creates/updates a GitHub issue when an
27
+ * update is available and `GITHUB_TOKEN` is present.
28
+ *
29
+ * Usage:
30
+ * bun run scripts/check-nan-mcp-server.ts # report to stdout
31
+ * bun run scripts/check-nan-mcp-server.ts --json # machine-readable JSON
32
+ * bun run scripts/check-nan-mcp-server.ts --issue # create/update an issue (needs GITHUB_TOKEN)
33
+ *
34
+ * The pure decision logic (`parseVersion`, `compareVersions`, `extractServerTools`,
35
+ * `assessUpdate`, `buildReport`) is exported for unit tests so the whole verdict
36
+ * is verifiable without touching the network. The network fetchers accept an
37
+ * injected `fetch` so tests can stub the registry/unpkg/github sources.
38
+ *
39
+ * Exit codes: 0 always (a cron should be quiet unless a human acts). Use
40
+ * `--json` to consume the report and decide what to do with it.
41
+ */
42
+
43
+ import {
44
+ DEFAULT_NAN_MEDIA_MCP_VERSION,
45
+ NAN_MEDIA_MCP_SERVER_TOOLS,
46
+ } from "../src/mcp/nan-media.ts";
47
+
48
+ /** npm package name of the community stdio MCP server we bridge. */
49
+ export const NAN_MCP_SERVER_PACKAGE = "nan-mcp-server";
50
+ /** npm registry endpoint for the package's latest release. */
51
+ export const NAN_MCP_SERVER_REGISTRY_URL = "https://registry.npmjs.org/nan-mcp-server/latest";
52
+ /** unpkg CDN path to the published server source, used to extract the tool surface. */
53
+ export const NAN_MCP_SERVER_SOURCE_URL = (version: string) =>
54
+ `https://unpkg.com/nan-mcp-server@${version}/server.js`;
55
+ /** GitHub compare API for the upstream commit list between two tags. */
56
+ export const NAN_MCP_SERVER_COMPARE_URL = (from: string, to: string) =>
57
+ `https://api.github.com/repos/luciferfran/nan-mcp-server/compare/v${from.replace(/^v/, "")}...v${to.replace(/^v/, "")}`;
58
+
59
+ /** GitHub search-issues endpoint used to dedupe the update issue. */
60
+ const GITHUB_SEARCH_ISSUES_URL =
61
+ "https://api.github.com/search/issues?q=" +
62
+ "repo:gtrabanco/pi-nan-provider+is:issue+is:open+in:title" +
63
+ '+label:dependencies+type:issue+"nan-mcp-server update"';
64
+
65
+ /** Label to attach to the auto-created update issue (dedupe key + visibility). */
66
+ export const NAN_MCP_UPDATE_ISSUE_LABEL = "dependencies";
67
+ /** Title template; the version pair is appended so each update is its own issue. */
68
+ export const NAN_MCP_UPDATE_ISSUE_TITLE_PREFIX = "[auto] nan-mcp-server update available";
69
+
70
+ export interface NanMcpServerCheckReport {
71
+ /** npm package scanned. */
72
+ package: string;
73
+ /** Version pinned in this package's bridge (source of truth: nan-media.ts). */
74
+ pinnedVersion: string;
75
+ /** Latest version published on npm; null when the registry could not be read. */
76
+ latestVersion: string | null;
77
+ /** true when latestVersion is strictly newer than pinnedVersion. */
78
+ updateAvailable: boolean;
79
+ /** How the verdict was reached: registry in reach, no drift, or an error. */
80
+ status: "up-to-date" | "update-available" | "check-failed";
81
+ /** true when a bridged tool is missing from the latest server surface. */
82
+ breaking: boolean;
83
+ /** Tools added by the latest server that we do NOT bridge (informational). */
84
+ addedTools: string[];
85
+ /** Bridged tools dropped/renamed by the latest server (breaking → manual). */
86
+ removedTools: string[];
87
+ /** Upstream commit subjects between pinned and latest (when reachable). */
88
+ changelog: string[];
89
+ /** Human-readable verdict sentence. */
90
+ reason: string;
91
+ /** When the check could not reach a source (registry/unpkg/github). */
92
+ error?: string;
93
+ }
94
+
95
+ /**
96
+ * Parse a semver-ish string (`1.0.8`, `v1.0.8`) into numeric major/minor/patch.
97
+ * Non-numeric release segments are clamped to 0 so `1.0.8` and `1.0.8-rc.1`
98
+ * compare sensibly; malformed input yields `[0, 0, 0]` (never throws).
99
+ */
100
+ export function parseVersion(input: string): [number, number, number] {
101
+ const cleaned = String(input).trim().replace(/^v/, "").split(/[-+.]/);
102
+ const toNum = (segment: string | undefined) => {
103
+ const n = Number.parseInt(segment ?? "", 10);
104
+ return Number.isFinite(n) ? n : 0;
105
+ };
106
+ return [toNum(cleaned[0]), toNum(cleaned[1]), toNum(cleaned[2])];
107
+ }
108
+
109
+ /**
110
+ * Compare two version strings. Returns 1 when `a` is newer, -1 when older,
111
+ * 0 when equal. Comparable to a numeric semver compare for x.y.z inputs.
112
+ */
113
+ export function compareVersions(a: string, b: string): number {
114
+ const [amaj, amin, apatch] = parseVersion(a);
115
+ const [bmaj, bmin, bpatch] = parseVersion(b);
116
+ if (amaj !== bmaj) return amaj > bmaj ? 1 : -1;
117
+ if (amin !== bmin) return amin > bmin ? 1 : -1;
118
+ if (apatch !== bpatch) return apatch > bpatch ? 1 : -1;
119
+ return 0;
120
+ }
121
+
122
+ /** Extract every `registerTool("NAME")` from a server.js source string. */
123
+ export function extractServerTools(source: string): string[] {
124
+ const names = new Set<string>();
125
+ const re = /registerTool\(\s*["']([^"']+)["']/g;
126
+ let match: RegExpExecArray | null;
127
+ while ((match = re.exec(source)) !== null) {
128
+ names.add(match[1]!);
129
+ }
130
+ return [...names].sort();
131
+ }
132
+
133
+ /**
134
+ * Compare the latest server tool surface against the bridged tools and decide
135
+ * whether the update is safe to bump. The only hard break is a bridged tool
136
+ * disappearing (dropped or renamed); anything else — new tools, bug fixes,
137
+ * schema tightening (e.g. `edit_image` now rejects >4 images) — is safe for
138
+ * our bridge, because we forward the params the schema already documents.
139
+ */
140
+ export function assessUpdate(latestTools: string[]): {
141
+ breaking: boolean;
142
+ addedTools: string[];
143
+ removedTools: string[];
144
+ reason: string;
145
+ } {
146
+ const bridged: readonly string[] = [...NAN_MEDIA_MCP_SERVER_TOOLS];
147
+ const latest = new Set(latestTools.map((t) => t.trim()));
148
+
149
+ const removedTools = bridged.filter((tool) => !latest.has(tool));
150
+ const addedTools = latestTools.filter((tool) => !bridged.includes(tool));
151
+
152
+ const breaking = removedTools.length > 0;
153
+
154
+ let reason: string;
155
+ if (breaking) {
156
+ reason =
157
+ `BREAKING: the latest nan-mcp-server no longer exposes ${removedTools.join(", ")}. ` +
158
+ "Bumping the pin would break the bridged tool(s) — manual review required before deciding.";
159
+ } else if (addedTools.length > 0) {
160
+ reason = `Safe to bump (non-breaking): every bridged tool is still present. New upstream tool(s) ${addedTools.join(", ")} are NOT bridged by this package (optional future work).`;
161
+ } else {
162
+ reason = "Safe to bump (non-breaking): every bridged tool is still present, and no new upstream tools were added.";
163
+ }
164
+
165
+ return { breaking, addedTools, removedTools, reason };
166
+ }
167
+
168
+ /**
169
+ * Build the full report from already-fetched data. Pure — no network. Missing
170
+ * or stale inputs degrade to `check-failed`, and an update is only reported
171
+ * when the latest version is strictly newer than the pinned one.
172
+ */
173
+ export function buildReport(input: {
174
+ pinnedVersion: string;
175
+ latestVersion: string | null;
176
+ latestTools: string[];
177
+ changelog: string[];
178
+ error?: string;
179
+ }): NanMcpServerCheckReport {
180
+ const { pinnedVersion, latestVersion, latestTools, changelog, error } = input;
181
+
182
+ if (error) {
183
+ return {
184
+ package: NAN_MCP_SERVER_PACKAGE,
185
+ pinnedVersion,
186
+ latestVersion: null,
187
+ updateAvailable: false,
188
+ status: "check-failed",
189
+ breaking: false,
190
+ addedTools: [],
191
+ removedTools: [],
192
+ changelog: [],
193
+ reason: `Could not complete the check: ${error}`,
194
+ error,
195
+ };
196
+ }
197
+
198
+ if (latestVersion === null) {
199
+ return {
200
+ package: NAN_MCP_SERVER_PACKAGE,
201
+ pinnedVersion,
202
+ latestVersion: null,
203
+ updateAvailable: false,
204
+ status: "check-failed",
205
+ breaking: false,
206
+ addedTools: [],
207
+ removedTools: [],
208
+ changelog: [],
209
+ reason: "Could not read the npm registry for the latest version.",
210
+ error: "registry-unreachable",
211
+ };
212
+ }
213
+
214
+ const updateAvailable = compareVersions(latestVersion, pinnedVersion) > 0;
215
+ if (!updateAvailable) {
216
+ return {
217
+ package: NAN_MCP_SERVER_PACKAGE,
218
+ pinnedVersion,
219
+ latestVersion,
220
+ updateAvailable: false,
221
+ status: "up-to-date",
222
+ breaking: false,
223
+ addedTools: [],
224
+ removedTools: [],
225
+ changelog: [],
226
+ reason: `Up to date: pinned ${pinnedVersion} is not older than latest ${latestVersion}.`,
227
+ };
228
+ }
229
+
230
+ const verdict = assessUpdate(latestTools);
231
+ return {
232
+ package: NAN_MCP_SERVER_PACKAGE,
233
+ pinnedVersion,
234
+ latestVersion,
235
+ updateAvailable: true,
236
+ status: "update-available",
237
+ breaking: verdict.breaking,
238
+ addedTools: verdict.addedTools,
239
+ removedTools: verdict.removedTools,
240
+ changelog,
241
+ reason: verdict.reason,
242
+ };
243
+ }
244
+
245
+ /** A minimal fetch-compatible signature so tests can stub it without undici types. */
246
+ export type FetchLike = (url: string, init?: { headers?: Record<string, string> }) => Promise<Response>;
247
+
248
+ async function readJson<T>(url: string, fetchImpl: FetchLike, headers?: Record<string, string>): Promise<T> {
249
+ const res = await fetchImpl(url, { headers });
250
+ if (!res.ok) {
251
+ throw new Error(`HTTP ${res.status} from ${url}`);
252
+ }
253
+ return (await res.json()) as T;
254
+ }
255
+
256
+ /** Fetch the latest published version of the package from the npm registry. */
257
+ export async function fetchLatestVersion(fetchImpl: FetchLike = fetch): Promise<string> {
258
+ const data = await readJson<{ version?: string; "dist-tags"?: { latest?: string } }>(
259
+ NAN_MCP_SERVER_REGISTRY_URL,
260
+ fetchImpl,
261
+ );
262
+ const version = data["dist-tags"]?.latest ?? data.version;
263
+ if (!version) throw new Error("npm registry response had no version");
264
+ return version;
265
+ }
266
+
267
+ /** Fetch the published server.js source and extract its tool surface. */
268
+ export async function fetchServerTools(
269
+ version: string,
270
+ fetchImpl: FetchLike = fetch,
271
+ ): Promise<string[]> {
272
+ const res = await fetchImpl(NAN_MCP_SERVER_SOURCE_URL(version));
273
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${NAN_MCP_SERVER_SOURCE_URL(version)}`);
274
+ return extractServerTools(await res.text());
275
+ }
276
+
277
+ /** Fetch upstream commit subjects between two tags (best-effort; empty on failure). */
278
+ export async function fetchChangelog(
279
+ from: string,
280
+ to: string,
281
+ fetchImpl: FetchLike = fetch,
282
+ ): Promise<string[]> {
283
+ try {
284
+ const data = await readJson<{ commits?: Array<{ commit?: { message?: string } }> }>(
285
+ NAN_MCP_SERVER_COMPARE_URL(from, to),
286
+ fetchImpl,
287
+ );
288
+ return (data.commits ?? [])
289
+ .map((c) => c.commit?.message?.split("\n")[0]?.trim() ?? "")
290
+ .filter(Boolean);
291
+ } catch {
292
+ return [];
293
+ }
294
+ }
295
+
296
+ const ARGS = process.argv.slice(2);
297
+ /** Rendered issue body from a report — what a maintainer reads to decide. */
298
+ export function renderIssueBody(report: NanMcpServerCheckReport): string {
299
+ const lines: string[] = [];
300
+ lines.push(`**Package**: \`${report.package}\``);
301
+ lines.push(`**Pinned by this package**: \`${report.pinnedVersion}\``);
302
+ lines.push(`**Latest on npm**: \`${report.latestVersion ?? "unknown"}\``);
303
+ lines.push(`**Verdict**: ${report.breaking ? "⚠️ BREAKING — manual review required" : "✅ Safe to bump (non-breaking)"}`);
304
+ lines.push("");
305
+ lines.push(`> ${report.reason}`);
306
+ lines.push("");
307
+ if (report.changelog.length > 0) {
308
+ lines.push("### Upstream changes");
309
+ lines.push("", ...report.changelog.map((c) => `- ${c}`), "");
310
+ }
311
+ lines.push("### To bump");
312
+ lines.push(
313
+ `1. Edit \`DEFAULT_NAN_MEDIA_MCP_VERSION\` in \`src/mcp/nan-media.ts\` from \`${report.pinnedVersion}\` to \`${report.latestVersion}\`.`,
314
+ );
315
+ lines.push("2. Run \`bun test && bun run typecheck\`.");
316
+ lines.push(
317
+ report.breaking
318
+ ? "3. Investigate the removed tool(s) — decide whether to bridge an alternative or keep the older pin."
319
+ : "3. Commit the bump and close this issue (it was auto-created).",
320
+ );
321
+ return lines.join("\n");
322
+ }
323
+
324
+ /** GitHub API root for this repository's issues. */
325
+ export const NAN_MCP_REPO_ISSUES_URL = "https://api.github.com/repos/gtrabanco/pi-nan-provider/issues";
326
+
327
+ async function sendIssueRequest<T>(
328
+ url: string,
329
+ method: string,
330
+ body: object | undefined,
331
+ token: string,
332
+ ): Promise<T> {
333
+ const res = await fetch(url, {
334
+ method,
335
+ headers: {
336
+ Authorization: `Bearer ${token}`,
337
+ Accept: "application/vnd.github+json",
338
+ ...(body ? { "Content-Type": "application/json" } : {}),
339
+ },
340
+ ...(body ? { body: JSON.stringify(body) } : {}),
341
+ });
342
+ if (!res.ok) throw new Error(`GitHub API ${method} ${url} → HTTP ${res.status}`);
343
+ return (await res.json()) as T;
344
+ }
345
+
346
+ /**
347
+ * Create a pre-engineered update issue, or refresh an existing open one with
348
+ * the same title+label instead of stacking duplicates across cron runs.
349
+ * Returns the issue URL (web or API) so the caller can surface it.
350
+ */
351
+ export async function createOrUpdateIssue(
352
+ report: NanMcpServerCheckReport,
353
+ token: string,
354
+ ): Promise<string> {
355
+ const title = `${NAN_MCP_UPDATE_ISSUE_TITLE_PREFIX}: ${report.pinnedVersion} → ${report.latestVersion ?? "?"}`;
356
+ const body = renderIssueBody(report);
357
+
358
+ // Dedupe key: an OPEN issue whose title matches AND carries the label.
359
+ const searchUrl = `${GITHUB_SEARCH_ISSUES_URL}+${encodeURIComponent(`"${title}"`)}`;
360
+ const search = await sendIssueRequest<{ items?: Array<{ number: number; html_url: string }> }>(
361
+ searchUrl,
362
+ "GET",
363
+ undefined,
364
+ token,
365
+ );
366
+ const existing = search.items?.[0];
367
+ if (existing) {
368
+ const patchUrl = `${NAN_MCP_REPO_ISSUES_URL}/${existing.number}`;
369
+ await sendIssueRequest(patchUrl, "PATCH", { body }, token);
370
+ return existing.html_url;
371
+ }
372
+
373
+ const created = await sendIssueRequest<{ html_url: string }>(
374
+ NAN_MCP_REPO_ISSUES_URL,
375
+ "POST",
376
+ { title, body, labels: [NAN_MCP_UPDATE_ISSUE_LABEL] },
377
+ token,
378
+ );
379
+ return created.html_url;
380
+ }
381
+
382
+ /** Main entry point. */
383
+ async function main(): Promise<void> {
384
+ let report: NanMcpServerCheckReport;
385
+ try {
386
+ const pinnedVersion = DEFAULT_NAN_MEDIA_MCP_VERSION;
387
+ const latestVersion = await fetchLatestVersion();
388
+ let latestTools: string[] = [];
389
+ let changelog: string[] = [];
390
+ if (compareVersions(latestVersion, pinnedVersion) > 0) {
391
+ latestTools = await fetchServerTools(latestVersion).catch(() => []);
392
+ changelog = await fetchChangelog(pinnedVersion, latestVersion);
393
+ }
394
+ report = buildReport({ pinnedVersion, latestVersion, latestTools, changelog });
395
+ } catch (error) {
396
+ report = buildReport({
397
+ pinnedVersion: DEFAULT_NAN_MEDIA_MCP_VERSION,
398
+ latestVersion: null,
399
+ latestTools: [],
400
+ changelog: [],
401
+ error: error instanceof Error ? error.message : String(error),
402
+ });
403
+ }
404
+
405
+ if (ARGS.includes("--json")) {
406
+ console.log(JSON.stringify(report, null, 2));
407
+ } else {
408
+ console.log(`nan-mcp-server: pinned ${report.pinnedVersion}, latest ${report.latestVersion ?? "unknown"}`);
409
+ console.log(` status: ${report.status} breaking: ${report.breaking}`);
410
+ console.log(` ${report.reason}`);
411
+ if (report.changelog.length > 0) {
412
+ console.log(" upstream:");
413
+ for (const c of report.changelog) console.log(` - ${c}`);
414
+ }
415
+ }
416
+
417
+ const token = process.env.GITHUB_TOKEN;
418
+ if (ARGS.includes("--issue") && report.status === "update-available" && token) {
419
+ const url = await createOrUpdateIssue(report, token);
420
+ console.log(` issue: ${url}`);
421
+ } else if (ARGS.includes("--issue") && report.status === "update-available" && !token) {
422
+ console.log(" --issue requested but GITHUB_TOKEN is not set; issue NOT created.");
423
+ }
424
+ }
425
+
426
+ await main();
@@ -80,11 +80,12 @@ const NAN_COMPAT = {
80
80
  supportsDeveloperRole: false,
81
81
  supportsReasoningEffort: true,
82
82
  supportsUsageInStreaming: true,
83
+ supportsFinishReason: false,
83
84
  maxTokensField: "max_tokens" as const,
84
85
  };
85
86
 
86
87
  const NAN_COMPAT_NOTE =
87
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.";
88
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.";
88
89
 
89
90
  interface ModelsDevModel {
90
91
  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-07T12:31:36.901Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-09T23:52:27.718Z
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.
@@ -32,10 +32,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
32
32
  "supportsDeveloperRole": false,
33
33
  "supportsReasoningEffort": true,
34
34
  "supportsUsageInStreaming": true,
35
+ "supportsFinishReason": false,
35
36
  "maxTokensField": "max_tokens"
36
37
  },
37
38
  "notes": [
38
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.",
39
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.",
39
40
  "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models, checked 2026-09-07; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models); models.dev provider nan lists text only."
40
41
  ],
41
42
  "extras": {
@@ -91,10 +92,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
91
92
  "supportsDeveloperRole": false,
92
93
  "supportsReasoningEffort": true,
93
94
  "supportsUsageInStreaming": true,
95
+ "supportsFinishReason": false,
94
96
  "maxTokensField": "max_tokens"
95
97
  },
96
98
  "notes": [
97
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
99
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
98
100
  ],
99
101
  "extras": {
100
102
  "id": "gemma4",
@@ -153,10 +155,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
153
155
  "supportsDeveloperRole": false,
154
156
  "supportsReasoningEffort": true,
155
157
  "supportsUsageInStreaming": true,
158
+ "supportsFinishReason": false,
156
159
  "maxTokensField": "max_tokens"
157
160
  },
158
161
  "notes": [
159
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
162
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
160
163
  ],
161
164
  "extras": {
162
165
  "id": "glm5.3-flash",
@@ -180,7 +183,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
180
183
  "text"
181
184
  ]
182
185
  },
183
- "open_weights": false,
186
+ "open_weights": true,
184
187
  "limit": {
185
188
  "context": 1000000,
186
189
  "output": 131072
@@ -211,10 +214,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
211
214
  "supportsDeveloperRole": false,
212
215
  "supportsReasoningEffort": true,
213
216
  "supportsUsageInStreaming": true,
217
+ "supportsFinishReason": false,
214
218
  "maxTokensField": "max_tokens"
215
219
  },
216
220
  "notes": [
217
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
221
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
218
222
  ],
219
223
  "extras": {
220
224
  "id": "mimo-v2.5",
@@ -270,10 +274,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
270
274
  "supportsDeveloperRole": false,
271
275
  "supportsReasoningEffort": true,
272
276
  "supportsUsageInStreaming": true,
277
+ "supportsFinishReason": false,
273
278
  "maxTokensField": "max_tokens"
274
279
  },
275
280
  "notes": [
276
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
281
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
277
282
  ],
278
283
  "extras": {
279
284
  "id": "qwen3.6",
@@ -332,10 +337,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
332
337
  "supportsDeveloperRole": false,
333
338
  "supportsReasoningEffort": true,
334
339
  "supportsUsageInStreaming": true,
340
+ "supportsFinishReason": false,
335
341
  "maxTokensField": "max_tokens"
336
342
  },
337
343
  "notes": [
338
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.",
344
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.",
339
345
  "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)."
340
346
  ],
341
347
  "extras": {
@@ -375,13 +381,13 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
375
381
  export const GENERATED_CATALOG_META = {
376
382
  source: "https://models.dev/api.json",
377
383
  modelsDevProvider: "nan",
378
- fetchedAt: "2026-09-07T12:31:36.901Z",
384
+ fetchedAt: "2026-09-09T23:52:27.718Z",
379
385
  modelCount: 6,
380
386
  models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.5","qwen3.6","qwen3.8-flash"],
381
387
  notes: [
382
388
  "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)",
383
389
  "glm5.3: served by NaN on the GLM 5.3 premium tier (https://nan.builders/docs/models + https://nan.builders/openapi.json, checked 2026-09-07) but absent from models.dev, and no source documents its max output tokens — no entry is generated (no-fabrication rule); premium keys still get it live via the /models refresh with conservative placeholder limits",
384
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.",
390
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.",
385
391
  "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models, checked 2026-09-07; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models); models.dev provider nan lists text only.",
386
392
  "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)."
387
393
  ],
@@ -12,12 +12,16 @@
12
12
  * only model `id`s — no capability fields. It is used solely to confirm
13
13
  * which model IDs are currently live.
14
14
  *
15
- * Merge: live IDs × generated capability data. A live ID with no generated
16
- * match is kept with conservative placeholder limits (the same defaults used
17
- * in custom-provider.md's dynamic-discovery example) and no reasoning
18
- * support — capabilities stay "unknown", nothing is fabricated. On fetch
19
- * failure, timeout, or an unusable response, callers fall back to the
20
- * generated catalog so startup is never blocked.
15
+ * Merge: live IDs × generated capability data — an allowlist, not an open
16
+ * ingest. Only allowlisted IDs surface: a live ID with generated data keeps
17
+ * its generated capabilities, and an allowlisted uncatalogued ID (premium
18
+ * live-only) gets conservative placeholder limits (the same defaults used in
19
+ * custom-provider.md's dynamic-discovery example) with no reasoning support -
20
+ * capabilities stay "unknown", nothing is fabricated. Every other live ID is
21
+ * dropped so undocumented models (and any model NaN starts serving later)
22
+ * and non-chat endpoints never surface. On fetch failure, timeout, or an
23
+ * unusable response, callers fall back to the generated catalog so startup
24
+ * is never blocked.
21
25
  */
22
26
 
23
27
  import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
@@ -60,6 +64,28 @@ export const NAN_COMPAT_API = "openai-completions" as const;
60
64
  */
61
65
  export const UNKNOWN_MODEL_LIMITS = { contextWindow: 128_000, maxTokens: 4_096 } as const;
62
66
 
67
+ /**
68
+ * The allowlist of model ids this package may surface. Everything a live
69
+ * /models response returns that is NOT here is dropped — so models that are
70
+ * undocumented (and might become available later, e.g. one NaN starts
71
+ * serving after a release) can never leak through the package.
72
+ *
73
+ * Every current chat model is listed: the six models.dev-documented entries
74
+ * plus the premium live-only glm5.3 (which NaN serves via /models but that
75
+ * models.dev omits — see the "unemittable" note in scripts/generate-models.ts).
76
+ * Non-chat endpoints (flux-2-klein, kokoro, whisper, qwen3-embedding, rerank)
77
+ * are deliberately excluded: they are MCP-bridge territory, not chat models.
78
+ */
79
+ const ALLOWED_MODEL_IDS = [
80
+ "deepseek-v4-flash",
81
+ "gemma4",
82
+ "glm5.3-flash",
83
+ "glm5.3",
84
+ "mimo-v2.5",
85
+ "qwen3.6",
86
+ "qwen3.8-flash",
87
+ ] as const;
88
+
63
89
  /** Timeout for the live /models fetch; matches the pi-synthetic-provider precedent (~3s). */
64
90
  export const DEFAULT_MODELS_TIMEOUT_MS = 3_000;
65
91
 
@@ -87,9 +113,10 @@ export function toModel(entry: GeneratedModelEntry, source: CatalogSource): Mode
87
113
  };
88
114
  }
89
115
 
90
- /** The generated fallback catalog as pi-ai Models for the given provider. */
116
+ /** The generated fallback catalog as pi-ai Models for the given provider (allowlisted entries only). */
91
117
  export function baselineModels(source: CatalogSource): Model<"openai-completions">[] {
92
- return NAN_GENERATED_MODELS.map((entry) => toModel(entry, source));
118
+ const allowed = new Set<string>(ALLOWED_MODEL_IDS);
119
+ return NAN_GENERATED_MODELS.filter((entry) => allowed.has(entry.id)).map((entry) => toModel(entry, source));
93
120
  }
94
121
 
95
122
  export interface LiveModelListOptions {
@@ -142,15 +169,17 @@ export interface MergedCatalog {
142
169
  models: Model<"openai-completions">[];
143
170
  /** Live IDs resolved against generated capability data. */
144
171
  matched: string[];
145
- /** Live IDs kept with unknown capabilities (conservative limits). */
172
+ /** Allowlisted live IDs kept with unknown capabilities (conservative limits). */
146
173
  unknown: string[];
147
174
  }
148
175
 
149
176
  /**
150
- * Merge live model IDs with the generated capability catalog.
151
- * Known IDs get generated data; unknown IDs get conservative placeholder
152
- * limits, `reasoning: false`, and zero cost — documented defaults, not
153
- * invented capabilities.
177
+ * Merge live model IDs with the generated capability catalog, gated by the
178
+ * allowlist. Only allowlisted IDs surface: live IDs with generated data keep
179
+ * their generated capabilities; allowlisted uncatalogued IDs (glm5.3) get
180
+ * conservative placeholder limits, `reasoning: false`, and zero cost
181
+ * (documented defaults, not invented capabilities). Every non-allowlisted
182
+ * live ID is dropped.
154
183
  */
155
184
  export function mergeLiveWithGenerated(
156
185
  liveIds: readonly string[],
@@ -158,16 +187,25 @@ export function mergeLiveWithGenerated(
158
187
  generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
159
188
  ): MergedCatalog {
160
189
  const byId = new Map(generated.map((entry) => [entry.id, entry]));
190
+ const allowed = new Set<string>(ALLOWED_MODEL_IDS);
161
191
  const models: Model<"openai-completions">[] = [];
162
192
  const matched: string[] = [];
163
193
  const unknown: string[] = [];
164
194
 
165
195
  for (const id of liveIds) {
196
+ if (!allowed.has(id)) continue;
166
197
  const entry = byId.get(id);
167
198
  if (entry) {
168
199
  models.push(toModel(entry, source));
169
200
  matched.push(id);
170
201
  } else {
202
+ // Conservative placeholder for allowlisted uncatalogued live ids
203
+ // (e.g. premium glm5.3): limits are the documented safe envelope and
204
+ // capabilities stay "unknown". supportsFinishReason: false is NOT a
205
+ // capability claim — it is a client-tolerance flag for the same
206
+ // gateway-level SSE truncation handled in NAN_COMPAT (LiteLLM cutting
207
+ // streams before finish_reason); without it pi-ai throws "Stream
208
+ // ended without finish_reason" on those models too.
171
209
  models.push({
172
210
  id,
173
211
  name: id,
@@ -179,6 +217,7 @@ export function mergeLiveWithGenerated(
179
217
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
180
218
  contextWindow: UNKNOWN_MODEL_LIMITS.contextWindow,
181
219
  maxTokens: UNKNOWN_MODEL_LIMITS.maxTokens,
220
+ compat: { supportsFinishReason: false },
182
221
  });
183
222
  unknown.push(id);
184
223
  }
@@ -205,7 +244,7 @@ export interface ResolvedCatalog {
205
244
  * when the live fetch failed and the generated fallback was used.
206
245
  */
207
246
  liveIds: Set<string> | undefined;
208
- /** Live IDs kept with unknown capabilities (present only when liveIds is set). */
247
+ /** Allowlisted live IDs kept with unknown capabilities (present only when liveIds is set). */
209
248
  unknownIds: string[];
210
249
  }
211
250
 
@@ -14,8 +14,8 @@
14
14
  * other env vars inherit from your environment (generated files land in
15
15
  * ~/nan-mcp-output/ by default).
16
16
  * - Version pinning follows the upstream server's own supply-chain guidance:
17
- * NAN_MEDIA_MCP_VERSION (default "1.0.7"), or pass a custom command with
18
- * NAN_MEDIA_MCP_COMMAND (space-separated, e.g. "bunx nan-mcp-server@1.0.7").
17
+ * NAN_MEDIA_MCP_VERSION (default "1.0.8"), or pass a custom command with
18
+ * NAN_MEDIA_MCP_COMMAND (space-separated, e.g. "bunx nan-mcp-server@1.0.8").
19
19
  */
20
20
 
21
21
  import { Type, type TSchema } from "@earendil-works/pi-ai";
@@ -28,7 +28,21 @@ export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
28
28
  export const NAN_MEDIA_MCP_VERSION_ENV = "NAN_MEDIA_MCP_VERSION";
29
29
  export const NAN_MEDIA_MCP_COMMAND_ENV = "NAN_MEDIA_MCP_COMMAND";
30
30
  export const NAN_MEDIA_MCP_TIMEOUT_ENV = "NAN_MEDIA_MCP_TIMEOUT_MS";
31
- export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.0.7";
31
+ export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.0.8";
32
+
33
+ /**
34
+ * The MCP tool names on the stdio nan-mcp-server that this package bridges as
35
+ * pi tools. Kept as the single source of truth for the check script
36
+ * (scripts/check-nan-mcp-server.ts) and to type each spec's `mcpTool` so the
37
+ * bridge cannot drift from the set it claims to expose.
38
+ */
39
+ export const NAN_MEDIA_MCP_SERVER_TOOLS = [
40
+ "generate_image",
41
+ "edit_image",
42
+ "text_to_speech",
43
+ "list_voices",
44
+ "speech_to_text",
45
+ ] as const;
32
46
  export const DEFAULT_MEDIA_MCP_TIMEOUT_MS = 120_000;
33
47
 
34
48
  /** Media tools bridged from the stdio MCP server (audio/image/transcription scope). */
@@ -84,7 +98,7 @@ function mediaMcpTimeoutMs(): number {
84
98
 
85
99
  interface MediaToolSpec<TParams extends TSchema = TSchema> {
86
100
  name: (typeof NAN_MEDIA_TOOLS)[number];
87
- mcpTool: string;
101
+ mcpTool: (typeof NAN_MEDIA_MCP_SERVER_TOOLS)[number];
88
102
  label: string;
89
103
  description: string;
90
104
  promptSnippet: string;
@@ -129,7 +143,7 @@ function defineMediaTool<TParams extends TSchema>(spec: MediaToolSpec<TParams>):
129
143
  /**
130
144
  * Build the media tool set. Registered only when NAN_MEDIA_MCP=1 and the
131
145
  * runtime supports registerTool; execution spawns the MCP server per call.
132
- * Schemas mirror nan-mcp-server's zod input schemas (v1.0.7).
146
+ * Schemas mirror nan-mcp-server's zod input schemas (v1.0.8).
133
147
  */
134
148
  export function createNanMediaTools(): ToolDefinition[] {
135
149
  return [
@@ -160,6 +174,8 @@ export function createNanMediaTools(): ToolDefinition[] {
160
174
  prompt: Type.String({ description: "Description of the edit or transformation to apply" }),
161
175
  images: Type.Array(Type.String(), {
162
176
  description: "Absolute paths to reference image files (PNG, JPEG, WebP; up to 4, each < 25MB)",
177
+ minItems: 1,
178
+ maxItems: 4,
163
179
  }),
164
180
  size: sizeProperty(),
165
181
  n: Type.Optional(Type.Integer({ description: "Number of images to generate (1-4). Default 1", minimum: 1, maximum: 4 })),
@@ -18,7 +18,7 @@ const PROTOCOL_VERSION = "2024-11-05";
18
18
  const CLIENT_INFO = { name: "pi-nan-provider", version: "0.2.0" };
19
19
 
20
20
  export interface StdioMcpCallOptions {
21
- /** Command to spawn, e.g. ["npx", "-y", "nan-mcp-server@1.0.7"]. */
21
+ /** Command to spawn, e.g. ["npx", "-y", "nan-mcp-server@1.0.8"]. */
22
22
  command: readonly string[];
23
23
  /** Extra environment for the child (merged over process.env). */
24
24
  env?: Record<string, string | undefined>;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Strict OpenAI Chat Completions schema conformance for NaN-compatible
3
+ * providers.
4
+ *
5
+ * NaN's gateway returns HTTP 400 `Invalid request. Check your request parameters.`
6
+ * for any request that does not match the schema it publishes at
7
+ * https://nan.builders/openapi.json (checked 2026-09-09). That schema is
8
+ * stricter than the permissive OpenAI shape models like OpenAI/Anthropic
9
+ * tolerate, and it does NOT match what pi-ai's message transformer emits in
10
+ * every case. This module rewrites the outgoing `/chat/completions` payload
11
+ * so it is always schema-valid, no matter which pi-ai version is bundled or
12
+ * how the history was constructed.
13
+ *
14
+ * The violated shapes we correct (each one traced to NaN's schema):
15
+ *
16
+ * 1. An `assistant` message whose `content` ARRAY contains a `toolCall`
17
+ * block. NaN's `ContentPart` oneOf allows ONLY `{type:"text"}` and
18
+ * `{type:"image_url"}` parts; a tool call is rejected. Tool calls must
19
+ * live in the top-level `tool_calls` field:
20
+ * `{ id, type:"function", function:{ name, arguments } }` with
21
+ * `arguments` as a JSON-encoded string. (This is the shape reported in
22
+ * the issue: a replayed assistant message with a `toolCall` block still
23
+ * inside `content` → 400.)
24
+ * 2. An `assistant` message carrying `reasoning_details`. NaN's `Message`
25
+ * schema admits `role/content/name/tool_calls/tool_call_id/reasoning_content`
26
+ * but NOT `reasoning_details` (an OpenAI-specific field pi-ai emits on
27
+ * same-model replay of encrypted/text reasoning signatures). That field
28
+ * is stripped; reasoning content is delivered the way NaN understands it
29
+ * (`reasoning_content`, or as plain text already present in `content`).
30
+ * 3. A `content` array containing an unknown part type (e.g. `thinking`).
31
+ * NaN only accepts `text` and `image_url`; other types are dropped, and
32
+ * thinking text is folded into a `text` part so the model's reasoning is
33
+ * not silently lost.
34
+ * 4. Top-level fields NaN's schema does not list: `store` and
35
+ * `stream_options`. These are opt-in/usage fields pi-ai sends by default
36
+ * for a "standard" provider; NaN does not document them, so they are
37
+ * removed. (Removing `stream_options` only costs live token-usage in the
38
+ * stream; NaN models are membership-quota based with zero per-token cost,
39
+ * so this is a safe trade.)
40
+ * 5. An EMPTY `tools` array. Verified against the live gateway (2026-09-09):
41
+ * NaN rejects `tools: []` with the same 400, while `stream: true`, a
42
+ * `system` message, string content, and a `tool` role message are all
43
+ * accepted. pi-ai emits `tools: []` when the conversation has tool-call
44
+ * history but no active tools; NaN only accepts a real tool list, so the
45
+ * empty array is dropped and a non-empty list is kept.
46
+ *
47
+ * This is applied by wrapping the provider's api `stream`/`streamSimple`
48
+ * with an `onPayload` hook in src/provider-factory.ts, so every provider
49
+ * registered through the shared factory stays schema-valid. A user-supplied
50
+ * `onPayload` (if pi or a consumer passes one) is preserved and chained
51
+ * after sanitization.
52
+ */
53
+
54
+ interface ContentPart {
55
+ type?: unknown;
56
+ text?: unknown;
57
+ thinking?: unknown;
58
+ [m: string]: unknown;
59
+ }
60
+
61
+ interface ToolCallBlock {
62
+ id?: unknown;
63
+ name?: unknown;
64
+ arguments?: unknown;
65
+ [m: string]: unknown;
66
+ }
67
+
68
+ function isObject(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
71
+
72
+ /** Monotonic counter for deterministic fallback tool-call ids (pi-ai always supplies ids; this is pure defense). */
73
+ let anonymousToolCallSeq = 0;
74
+
75
+ /** Normalize a `toolCall` content block into NaN's `tool_calls[].{id,type,function}` shape. */
76
+ function toToolCall(block: ToolCallBlock): Record<string, unknown> {
77
+ const rawArgs = block.arguments;
78
+ let argumentsJson: string;
79
+ if (typeof rawArgs === "string") {
80
+ argumentsJson = rawArgs;
81
+ } else {
82
+ try {
83
+ argumentsJson = JSON.stringify(rawArgs ?? {});
84
+ } catch {
85
+ argumentsJson = "{}";
86
+ }
87
+ }
88
+ const fallbackName = typeof block.name === "string" && block.name.length > 0 ? block.name : "function";
89
+ return {
90
+ id: typeof block.id === "string" && block.id.length > 0 ? block.id : `call_${fallbackName}_${++anonymousToolCallSeq}`,
91
+ type: "function",
92
+ function: {
93
+ name: fallbackName,
94
+ arguments: argumentsJson,
95
+ },
96
+ };
97
+ }
98
+
99
+ /** Merge tool calls, de-duplicating by id and preferring the pre-existing (pi-ai-built) entries. */
100
+ function mergeToolCalls(existing: unknown[], incoming: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
101
+ const byId = new Map<string, Record<string, unknown>>();
102
+ for (const tc of existing) {
103
+ if (isObject(tc) && typeof tc.id === "string") byId.set(tc.id, tc);
104
+ }
105
+ for (const tc of incoming) {
106
+ if (typeof tc.id === "string" && !byId.has(tc.id)) byId.set(tc.id, tc);
107
+ }
108
+ return [...byId.values()];
109
+ }
110
+
111
+ /**
112
+ * Rebuild an assistant `content` value from an array of sanitized parts.
113
+ * NaN follows the OpenAI convention: a plain string when there is only text,
114
+ * an array of `text`/`image_url` parts when there are images, and `null` when
115
+ * there is no content (valid on an assistant message that returns tool calls).
116
+ */
117
+ function normalizeAssistantContent(parts: Array<Record<string, unknown>>): string | Array<unknown> | null {
118
+ if (parts.length === 0) return null;
119
+ const allText = parts.every((part) => part.type === "text");
120
+ if (allText) {
121
+ const text = parts
122
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
123
+ .join("");
124
+ return text.length > 0 ? text : null;
125
+ }
126
+ return parts;
127
+ }
128
+
129
+ /** Permit only NaN's approved content-part types; fold `thinking` into text. */
130
+ function sanitizeAssistantContentPart(part: unknown): Record<string, unknown> | undefined {
131
+ if (!isObject(part)) return undefined;
132
+ const type = part.type;
133
+ if (type === "text") {
134
+ return { type: "text", text: typeof part.text === "string" ? part.text : String(part.text ?? "") };
135
+ }
136
+ if (type === "image_url") {
137
+ return { type: "image_url", image_url: part.image_url };
138
+ }
139
+ if (type === "thinking") {
140
+ const thinking = typeof part.thinking === "string" ? part.thinking : "";
141
+ if (thinking.trim().length === 0) return undefined;
142
+ return { type: "text", text: thinking };
143
+ }
144
+ // Anything else (toolCall handled separately, unknown types dropped) — NaN rejects it.
145
+ return undefined;
146
+ }
147
+
148
+ /** Sanitize a single message against NaN's strict schema. */
149
+ function sanitizeMessage(message: unknown): unknown {
150
+ if (!isObject(message)) return message;
151
+ if (message.role !== "assistant") return message;
152
+
153
+ const out: Record<string, unknown> = { ...message };
154
+ delete out.reasoning_details; // OpenAI-only; absent from NaN's Message schema.
155
+ // NaN understands `reasoning_content` (not the generic `reasoning` field), so
156
+ // carry any reasoning text over to the field NaN accepts rather than dropping it.
157
+ if (out.reasoning !== undefined && out.reasoning_content === undefined) {
158
+ out.reasoning_content = out.reasoning;
159
+ }
160
+ delete out.reasoning;
161
+
162
+ const content = message.content;
163
+ if (!Array.isArray(content)) return out; // string / null content is already schema-valid.
164
+
165
+ const textParts: Array<Record<string, unknown>> = [];
166
+ const toolCallBlocks: ToolCallBlock[] = [];
167
+ for (const part of content) {
168
+ if (isObject(part) && part.type === "toolCall") {
169
+ toolCallBlocks.push(part as unknown as ToolCallBlock);
170
+ continue;
171
+ }
172
+ const sanitized = sanitizeAssistantContentPart(part);
173
+ if (sanitized) textParts.push(sanitized);
174
+ }
175
+
176
+ out.content = normalizeAssistantContent(textParts);
177
+
178
+ const existingToolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
179
+ const toolCalls = toolCallBlocks.length > 0 ? mergeToolCalls(existingToolCalls, toolCallBlocks.map(toToolCall)) : [...existingToolCalls];
180
+ if (toolCalls.length > 0) out.tool_calls = toolCalls;
181
+ else delete out.tool_calls;
182
+
183
+ return out;
184
+ }
185
+
186
+ /**
187
+ * Rewrite an OpenAI-compatible `/chat/completions` payload so every field
188
+ * conforms to NaN's published schema. Returns the updated payload; if the
189
+ * payload has no `messages` array it is returned unchanged.
190
+ */
191
+ export function sanitizeOpenAICompatPayload(payload: unknown): unknown {
192
+ if (!isObject(payload) || !Array.isArray(payload.messages)) return payload;
193
+
194
+ const messages = payload.messages.map(sanitizeMessage);
195
+ const out: Record<string, unknown> = { ...payload, messages };
196
+
197
+ // Patch function-tools call arguments: NaN wants function-call arguments
198
+ // serialized as a JSON string under `function.arguments`. pi-ai already does
199
+ // this, but a hand-built / older-version payload may not. Normalize each.
200
+ if (Array.isArray(out.messages)) {
201
+ out.messages = out.messages.map((m) => {
202
+ if (!isObject(m) || m.role !== "assistant") return m;
203
+ if (!Array.isArray(m.tool_calls)) return m;
204
+ const normalized = m.tool_calls.map((tc) => {
205
+ if (!isObject(tc)) return tc;
206
+ if (typeof tc.type === "string" && tc.type !== "function") return tc;
207
+ const fn = isObject(tc.function) ? tc.function : {};
208
+ let args = fn.arguments;
209
+ if (args !== undefined && typeof args !== "string") {
210
+ try {
211
+ args = JSON.stringify(args);
212
+ } catch {
213
+ args = "{}";
214
+ }
215
+ }
216
+ return { ...tc, type: "function", function: { ...fn, ...(args !== undefined ? { arguments: args } : {}) } };
217
+ });
218
+ return { ...m, tool_calls: normalized };
219
+ });
220
+ }
221
+
222
+ // Top-level fields absent from NaN's schema.
223
+ delete out.store;
224
+ delete out.stream_options;
225
+ // NaN documents `max_tokens`, not `max_completion_tokens`.
226
+ if ("max_completion_tokens" in out && !("max_tokens" in out)) {
227
+ out.max_tokens = out.max_completion_tokens;
228
+ delete out.max_completion_tokens;
229
+ }
230
+ // NaN rejects an EMPTY `tools` array with HTTP 400 `Invalid request. Check
231
+ // your request parameters.` (verified against the live gateway 2026-09-09:
232
+ // everything else in the payload — stream/system/content-as-string/tool role
233
+ // — is accepted, but `tools: []` is not). pi-ai emits `tools: []` whenever
234
+ // the conversation has tool-call history but no active tools; NaN only
235
+ // accepts a real tool list, so drop the empty array. A non-empty `tools`
236
+ // list is preserved unchanged.
237
+ if (Array.isArray(out.tools) && out.tools.length === 0) {
238
+ delete out.tools;
239
+ }
240
+
241
+ return out;
242
+ }
@@ -32,6 +32,7 @@ import {
32
32
  resolveCatalog,
33
33
  type CatalogSource,
34
34
  } from "./fetch-models.ts";
35
+ import { sanitizeOpenAICompatPayload } from "./openai-compat-sanitizer.ts";
35
36
 
36
37
  export interface OpenAICompatibleProviderConfig {
37
38
  /** Provider id as registered in pi, e.g. "nan". */
@@ -80,6 +81,48 @@ export async function resolveOpenAICompletionsApi(): Promise<OpenAICompletionsAp
80
81
  return cachedApiFactory;
81
82
  }
82
83
 
84
+ /**
85
+ * Wrap an api so every outgoing `/chat/completions` payload is made conformant
86
+ * to the strict OpenAI Chat Completions schema NaN enforces (see
87
+ * ./openai-compat-sanitizer.ts). NaN returns HTTP 400 `Invalid request. Check
88
+ * your request parameters.` for any payload that violates it — including a
89
+ * replayed assistant message with a `toolCall` block inside `content`, a
90
+ * `reasoning_details` field, or undocumented top-level fields like `store` /
91
+ * `stream_options`. Sanitizing via the `onPayload` hook works regardless of
92
+ * which pi-ai version the runtime bundles, so the fix is not tied to a
93
+ * specific upstream build.
94
+ *
95
+ * Any caller-supplied `onPayload` (e.g. pi's own debug/passthrough hook) is
96
+ * preserved and chained AFTER sanitization, so the final payload is always
97
+ * schema-valid.
98
+ */
99
+ function isObject(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === "object" && value !== null;
101
+ }
102
+
103
+ export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStreams {
104
+ const withSanitizer = <TOptions extends object | undefined>(options: TOptions): TOptions => {
105
+ const userOnPayload = isObject(options) ? (options.onPayload as unknown) : undefined;
106
+ return {
107
+ ...((options ?? {}) as Record<string, unknown>),
108
+ onPayload: async (payload: unknown, model: unknown) => {
109
+ const sanitized = sanitizeOpenAICompatPayload(payload);
110
+ if (typeof userOnPayload === "function") {
111
+ const userResult = await (userOnPayload as (p: unknown, m: unknown) => unknown)(sanitized, model);
112
+ return userResult ?? sanitized;
113
+ }
114
+ return sanitized;
115
+ },
116
+ } as TOptions;
117
+ };
118
+
119
+ return {
120
+ ...api,
121
+ stream: (model, context, options) => api.stream(model, context, withSanitizer(options)),
122
+ streamSimple: (model, context, options) => api.streamSimple(model, context, withSanitizer(options)),
123
+ };
124
+ }
125
+
83
126
  /**
84
127
  * Build a complete pi-ai Provider for an OpenAI-compatible endpoint:
85
128
  *
@@ -130,6 +173,6 @@ export async function createNanCompatibleProvider(
130
173
  const current = liveIds;
131
174
  return current ? models.filter((model) => current.has(model.id)) : models;
132
175
  },
133
- api: apiFactory(),
176
+ api: wrapApiForStrictSanitization(apiFactory()),
134
177
  });
135
178
  }