@gtrabanco/pi-nan-provider 0.10.0 → 0.10.3
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 +44 -3
- package/README.es.md +51 -1
- package/README.md +39 -4
- package/package.json +1 -1
- package/scripts/generate-models.ts +17 -12
- package/scripts/manual-overrides.ts +59 -7
- package/scripts/models.generated.ts +34 -8
- package/src/commands.ts +75 -29
- package/src/cross-model-thinking-guard.ts +18 -0
- package/src/fetch-models.ts +83 -16
- package/src/index.ts +27 -9
- package/src/mcp/api-key.ts +33 -0
- package/src/mcp/host-support.ts +94 -0
- package/src/mcp/state.ts +6 -0
package/AGENTS.md
CHANGED
|
@@ -140,6 +140,24 @@ Every PR that changes code MUST bump `package.json` version in the same PR; CI p
|
|
|
140
140
|
runtime then drives `fetchModels` refreshes (network refresh at interactive
|
|
141
141
|
startup and periodically, cache-only at registration) and persists the overlay.
|
|
142
142
|
A `fetchModels` rejection never blocks startup.
|
|
143
|
+
- **pi 0.99 virtual models do not break the provider, but they do interact with the
|
|
144
|
+
cross-model thinking guard** (verified pi 0.99.1/0.99.2). A virtual model
|
|
145
|
+
(`pi.registerVirtualModel`, api `pi-virtual`) can be registered under `nan` and
|
|
146
|
+
route to physical `nan` models via `ctx.modelRegistry.find("nan", id)` — no
|
|
147
|
+
provider-side change needed. Inside the extension `context` hook, though,
|
|
148
|
+
`ctx.model` is the **selection** (the virtual entry), never the routed physical
|
|
149
|
+
model (`core/virtual-models.js`; `agent-session.js` "The selection stays in
|
|
150
|
+
agent state; only this request uses the routed model"), and the routed target is
|
|
151
|
+
not exposed to the hook. `stripCrossModelThinking` therefore skips virtual
|
|
152
|
+
selections (`src/cross-model-thinking-guard.ts`, `VIRTUAL_MODEL_API`); otherwise
|
|
153
|
+
its same-model check would strip reasoning on every continuation. Registered
|
|
154
|
+
virtual models are otherwise transparent to the catalog/factory.
|
|
155
|
+
- **Factory-time ExtensionAPI has NO `modelRegistry`** (verified pi 0.99.1):
|
|
156
|
+
The `ExtensionAPI` interface does not include `modelRegistry`; it exists only on
|
|
157
|
+
`ExtensionContext` (the `ctx` passed to command/event handlers). At factory time,
|
|
158
|
+
stored credentials are resolved via `readStoredCredential("nan")` from
|
|
159
|
+
`@earendil-works/pi-coding-agent`. Command-time paths (`/nan-mcp`, `/nan-usage`) use
|
|
160
|
+
`ctx.modelRegistry`.
|
|
143
161
|
- models.json overrides compose **above** registered native providers.
|
|
144
162
|
- Capability values that diverge from models.dev are recorded as build-time
|
|
145
163
|
`MANUAL_OVERRIDES` (mandatory provenance note) in `scripts/manual-overrides.ts`,
|
|
@@ -180,9 +198,16 @@ Every PR that changes code MUST bump `package.json` version in the same PR; CI p
|
|
|
180
198
|
entry) so the id is never emitted twice.
|
|
181
199
|
Non-chat endpoints: qwen3-embedding, rerank, kokoro (TTS), whisper (STT),
|
|
182
200
|
flux-2-klein and qwen-image-2.1 (images) — MCP-bridge territory, not chat
|
|
183
|
-
catalog models
|
|
184
|
-
`
|
|
185
|
-
|
|
201
|
+
catalog models. The classification is now applied at RUNTIME too:
|
|
202
|
+
`NON_CHAT_MODEL_IDS` is exported from `src/fetch-models.ts` (single source
|
|
203
|
+
of truth: id → provenance reason), imported by `scripts/generate-models.ts`
|
|
204
|
+
(one definition, no duplicate), enforced by `mergeLiveWithGenerated` (those
|
|
205
|
+
ids land in the `nonChat` bucket and never enter the registry), and filtered
|
|
206
|
+
defensively by `baselineModels`. The generator excluded `qwen-image-2.1` via
|
|
207
|
+
this constant before (it also sits on models.dev with `limit.output: 0`,
|
|
208
|
+
excluded instead of being flagged "needs manual verification" forever); all
|
|
209
|
+
six ids are now under the same guard so they can never be emitted as chat
|
|
210
|
+
models or registered at runtime.
|
|
186
211
|
- NaN can close an SSE stream **before** `finish_reason`. The catalog sets
|
|
187
212
|
`supportsFinishReason: true` so pi-ai raises the retryable
|
|
188
213
|
`Stream ended without finish_reason` (pi-ai's `RETRYABLE_PROVIDER_ERROR_PATTERN`
|
|
@@ -202,6 +227,7 @@ Every PR that changes code MUST bump `package.json` version in the same PR; CI p
|
|
|
202
227
|
`test/issue-2-truncated-stream.test.ts`, `test/issue-4-token-usage.test.ts`,
|
|
203
228
|
`test/issue-7-streaming-usage-default.test.ts`;
|
|
204
229
|
issues #2, #4 and #7.
|
|
230
|
+
- **Catalog now declares `thinkingLevelMap` for reasoning suppression** (issue #16, measured 2026-09-27 against live gateway, repro script in the issue). `deepseek-v4-flash` → `{ off: "none" }` (0 reasoning tokens, reproducible); `glm5.3-flash` → `{ off: "minimal" }` (38 reasoning tokens + answer; `none` does NOT work on glm5.3 — 13,382 tokens); `qwen3.6` and `gemma4` → `{ off: "none" }` (gap left by the measured-only fix: the NaN docs state that with no parameter both reason by default with a 16,384-token budget and that `none`/`minimal` skip the reasoning phase entirely — https://nan.builders/docs/models#controlling-reasoning, checked 2026-10-01 — so `off` must send `none` explicitly). `reasoningEffortValues` corrected: deepseek-v4-flash from `[]` to `["none"]`; glm5.3-flash from `["low","medium","high","max"]` to include `"minimal"`. README limits table notes that `maxTokens` does NOT bound the reasoning phase (measured: max_tokens=512 still yielded 13,376 reasoning tokens). pi-ai's `buildRequest` (0.99.1) maps `reasoning: "off"` → `reasoningEffort: undefined` → fires the off-branch that checks `model.thinkingLevelMap?.off` (verified in pi-ai source ~line 718-722). A model whose reasoning depth is genuinely not adjustable (`qwen3.8-flash`, `mimo-v2.6-flash`) keeps no `thinkingLevelMap`: `off` sends nothing and the model uses its own default.
|
|
205
231
|
- A NaN request that still exceeds the destination model's context window (the
|
|
206
232
|
cross-model thinking guard is disabled with `NAN_THINKING_GUARD=0`, the
|
|
207
233
|
inflation is not a `thinking` block, or the window is smaller) gets NaN's
|
|
@@ -241,3 +267,18 @@ Every PR that changes code MUST bump `package.json` version in the same PR; CI p
|
|
|
241
267
|
- Automated check (scripts/check-nan-mcp-server.ts) compares the npm registry
|
|
242
268
|
against the pin weekly and files a `dependencies` issue when a newer release
|
|
243
269
|
exists, with a breaking/safe verdict from the live server tool surface.
|
|
270
|
+
- **pi reports unhandled MCP registrations right after `session_start`** (verified
|
|
271
|
+
on pi 0.99.1): `AgentSession.bindExtensions()` emits `session_start` and then
|
|
272
|
+
calls `ExtensionRunner.reportUnhandledMcpServers()`, which raises
|
|
273
|
+
`MCP server "<name>" is registered, but no loaded extension connects MCP
|
|
274
|
+
servers` whenever no loaded extension handles `mcp_servers_change`. A host
|
|
275
|
+
that builds `DefaultResourceLoader` WITHOUT `extensionFactories` loads no
|
|
276
|
+
`builtin:*` extension at all — PI WEB's `createAgentSessionServices` does
|
|
277
|
+
exactly that (pi-web 1.202609.1, verified 2026-09-30 with a faithful loader
|
|
278
|
+
run: packages load, `builtin:mcp` absent, no `/mcp` command), so its sessions
|
|
279
|
+
have no MCP connector and `mcp.json` servers never connect either.
|
|
280
|
+
`src/mcp/host-support.ts` detects the gap through the `/mcp` command
|
|
281
|
+
(`pi.getCommands()` missing → conservative "connector present"), claims
|
|
282
|
+
`mcp_servers_change` so pi's report stays silent, and warns once per session;
|
|
283
|
+
`/nan-mcp enable` persists the toggle without registering. Regression tests:
|
|
284
|
+
`test/mcp-host-connector.test.ts`.
|
package/README.es.md
CHANGED
|
@@ -41,6 +41,8 @@ El proveedor utiliza un **catálogo de modelos de dos capas** para garantizar la
|
|
|
41
41
|
> [!IMPORTANT]
|
|
42
42
|
> **Detección de Tier**: La lista en vivo es la autoridad. Si tu clave tiene acceso premium, esos modelos aparecerán automáticamente; de lo contrario, se filtran.
|
|
43
43
|
|
|
44
|
+
Los ids no-chat que devuelve `/models` en vivo (embedding, rerank, TTS, STT, imagen) **no se registran** como modelos de chat — la clasificación es el único `NON_CHAT_MODEL_IDS` exportado desde `src/fetch-models.ts` (la misma fuente que usa el generador en build-time). La fusión salta sus marcadores y los coloca en un bucket observable `nonChat`; `baselineModels` también los filtra como capa defensiva. Esto evita que el selector de chat de pi ofrezca endpoints que no pueden servir chat (las peticiones devolverían 404).
|
|
45
|
+
|
|
44
46
|
El registro es síncrono a propósito: el catálogo de fallback está disponible al instante, y el runtime de Models de pi dirige el refresco en vivo (refresco de red en el arranque interactivo y periódico, solo caché en el registro), persistiendo el overlay entre ejecuciones.
|
|
45
47
|
|
|
46
48
|
### 🧠 Seguridad al cambiar de modelo (guard de razonamiento cross-model)
|
|
@@ -107,10 +109,42 @@ Ambos puentes están **activados por defecto** (por sesión, visibles en `/mcp`)
|
|
|
107
109
|
|
|
108
110
|
| Comando | Efecto |
|
|
109
111
|
| :--- | :--- |
|
|
110
|
-
| `/nan-mcp status` | Muestra el estado actual de ambos puentes. |
|
|
112
|
+
| `/nan-mcp status` | Muestra el estado actual de ambos puentes y la resolución de la clave API. |
|
|
111
113
|
| `/nan-mcp enable [target]` | Activa `web-search` o `nan-mcp-server` (persiste). |
|
|
112
114
|
| `/nan-mcp disable [target]` | Desactiva un puente de forma persistente. |
|
|
113
115
|
|
|
116
|
+
Ambos puentes resuelven la credencial almacenada de `/login nan` (almacenada → env). Si ninguna se resuelve, el puente no se registra y se muestra un aviso en la salida de estado.
|
|
117
|
+
|
|
118
|
+
> [!NOTE]
|
|
119
|
+
> El registro ocurre cuando se carga la extensión. Si ejecutas `/login nan` **después** de que la sesión haya empezado, la factoría ya se ejecutó — usa `/reload` (o `/nan-mcp enable`) para registrar los puentes sin reiniciar pi.
|
|
120
|
+
|
|
121
|
+
### 🚧 Hosts sin la extensión MCP integrada de pi (PI WEB)
|
|
122
|
+
|
|
123
|
+
pi conecta los servidores registrados desde la extensión que maneja `mcp_servers_change` — la
|
|
124
|
+
extensión integrada `mcp`, que además registra `/mcp`. Los hosts que construyen el resource loader
|
|
125
|
+
de pi sin `extensionFactories` no cargan **ninguna** extensión integrada (verificado:
|
|
126
|
+
`createAgentSessionServices` de PI WEB, pi 0.99.1 / pi-web 1.202609.1), así que nada conecta los
|
|
127
|
+
servidores y pi reporta `MCP server "nan-media" is registered, but no loaded extension connects MCP
|
|
128
|
+
servers`.
|
|
129
|
+
|
|
130
|
+
Ahí los puentes no pueden exponer herramientas, así que este paquete reemplaza ese error críptico
|
|
131
|
+
con un único aviso por sesión, mantiene los registros inactivos y deja que `/nan-mcp enable` persista
|
|
132
|
+
el toggle sin registrar un servidor que nunca podrá conectarse. Para tener MCP en la CLI, activa el
|
|
133
|
+
conector con `pi config` → Built-in extensions → `mcp`.
|
|
134
|
+
|
|
135
|
+
### Funciones de pi 0.99 y modelos NaN
|
|
136
|
+
|
|
137
|
+
- **Modelos virtuales** — registra un router bajo `nan` (`pi.registerVirtualModel({ provider: "nan", ... })`);
|
|
138
|
+
enruta a los modelos físicos de NaN sin configuración por parte del proveedor. El guard de
|
|
139
|
+
razonamiento entre modelos se salta las selecciones virtuales, porque el modelo físico enrutado no
|
|
140
|
+
es visible para el hook `context`.
|
|
141
|
+
- **Codemode** — funciona con cualquier modelo NaN que soporte tool calling. Las herramientas MCP de
|
|
142
|
+
este paquete usan `exposure: "direct"`, así que los scripts de codemode también pueden llamarlas.
|
|
143
|
+
Requiere la extensión integrada `codemode` de pi (ver la nota de PI WEB arriba).
|
|
144
|
+
- **Modelos clasificadores** — no aplica: NaN no expone ninguna API de clasificación (solo chat y
|
|
145
|
+
endpoints de embedding/rerank/audio/imagen), así que este proveedor no registra modelos
|
|
146
|
+
`type: "classifier"`.
|
|
147
|
+
|
|
114
148
|
---
|
|
115
149
|
|
|
116
150
|
### 1. Servidor MCP oficial de NaN
|
|
@@ -271,6 +305,22 @@ Catálogo base (verificado contra [docs de NaN](https://nan.builders/docs/models
|
|
|
271
305
|
|
|
272
306
|
---
|
|
273
307
|
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## 🧠 Controles de razonamiento
|
|
311
|
+
|
|
312
|
+
El parámetro `reasoning_effort` de NaN controla cuánto piensa el modelo antes de responder — pero el grado de control varía por modelo:
|
|
313
|
+
|
|
314
|
+
| Modelo | Effort de razonamiento | Cómo funciona |
|
|
315
|
+
| :--- | :--- | :--- |
|
|
316
|
+
| `glm5.3`, `glm5.3-flash` | `minimal` · `low` · `medium` · `high` · `max` | Completamente controlable — valores más altos permiten al modelo razonar más; `minimal` produce ~38 tokens de razonamiento (issue #16, medido 2026-09-27); `none` NO suprime el razonamiento en glm5.3-flash (13,382 tokens) |
|
|
317
|
+
| `qwen3.6`, `gemma4` | `none` · `minimal` · `low` · `medium` · `high` · `max` | `none`/`minimal` omiten el razonamiento por completo; otros valores limitan a 2K / 8K / 16K / 32K tokens. `thinking: "off"` envía `reasoning_effort: "none"` automáticamente vía el `thinkingLevelMap` del catálogo — los docs de NaN dicen que sin parámetro estos modelos razonan por defecto (presupuesto de 16,384 tokens), así que `off` debe ser explícito |
|
|
318
|
+
| `deepseek-v4-flash` | `none` · `minimal` · `low` · `medium` · `high` · `max` (el catálogo declara ["none"]) | `none` deshabilita el razonamiento de forma fiable (0 tokens de razonamiento, medido 2026-09-27); cualquier otro valor deja 13-14K tokens de razonamiento y causa razonamiento descontrolado (finish_reason:"length" con CERO texto de respuesta) — el catálogo solo declara `none` porque el resto es ruido. `thinking: "off"` ahora envía `reasoning_effort: "none"` automáticamente vía el `thinkingLevelMap` del catálogo |
|
|
319
|
+
| `qwen3.8-flash`, `mimo-v2.6-flash` | *(aceptado pero no ajustable)* | El parámetro es aceptado y nunca rechazado, pero el modelo gestiona su propia profundidad de razonamiento — nunca es un error enviar un valor que estos modelos no ajustan |
|
|
320
|
+
|
|
321
|
+
> [!NOTE]
|
|
322
|
+
> `maxTokens` NO limita la fase de razonamiento en los modelos de NaN. La fase de razonamiento corre hasta completarse (o se trunca a ~60,000 caracteres para deepseek-v4-flash) independientemente de `maxTokens` — medido: `max_tokens=512` en deepseek-v4-flash aún consumió 13,376 tokens de completación, todos de razonamiento (26x el techo). Este es un comportamiento del gateway confirmado por el issue #16 (medido 2026-09-27).
|
|
323
|
+
|
|
274
324
|
## 🚀 Desarrollo
|
|
275
325
|
|
|
276
326
|
```bash
|
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ The provider uses a **two-layer model catalog** to ensure reliability:
|
|
|
41
41
|
> [!IMPORTANT]
|
|
42
42
|
> **Tier Detection**: The live list is authoritative. If your key has premium access, those models will appear automatically; otherwise, they are filtered out.
|
|
43
43
|
|
|
44
|
+
NaN's live `/models` also returns non-chat endpoint ids (embedding, rerank, TTS, STT, image). These are **not registered** as chat models — the classification is the single exported `NON_CHAT_MODEL_IDS` from `src/fetch-models.ts` (the same source used by the build-time generator). The merge skips their placeholders and places them in an observable `nonChat` bucket; `baselineModels` also filters them defensively. This prevents pi's chat picker from offering endpoints that cannot serve chat (requests would 404).
|
|
45
|
+
|
|
44
46
|
The registration is synchronous on purpose: the generated fallback catalog is available immediately, and pi's Models runtime drives the live refresh (network refresh at interactive startup and periodically, cache-only at registration), persisting the overlay between runs.
|
|
45
47
|
|
|
46
48
|
### 🧠 Model-switch safety (cross-model reasoning guard)
|
|
@@ -107,10 +109,39 @@ Both bridges are **enabled by default** (session-scoped, visible in `/mcp`). Use
|
|
|
107
109
|
|
|
108
110
|
| Command | Effect |
|
|
109
111
|
| :--- | :--- |
|
|
110
|
-
| `/nan-mcp status` | Shows current state of both bridges. |
|
|
112
|
+
| `/nan-mcp status` | Shows current state of both bridges and API key resolution. |
|
|
111
113
|
| `/nan-mcp enable [target]` | Enables `web-search` or `nan-mcp-server` (persisted). |
|
|
112
114
|
| `/nan-mcp disable [target]` | Disables a bridge persistently. |
|
|
113
115
|
|
|
116
|
+
Both bridges resolve the stored `/login nan` credential (stored → env). If neither resolves, the bridge does not register and a warning is shown in the status output.
|
|
117
|
+
|
|
118
|
+
> [!NOTE]
|
|
119
|
+
> Registration happens when the extension loads. If you run `/login nan` **after** the session started, the factory has already run — run `/reload` (or `/nan-mcp enable`) to register the bridges without restarting pi.
|
|
120
|
+
|
|
121
|
+
### 🚧 Hosts without pi's built-in MCP extension (PI WEB)
|
|
122
|
+
|
|
123
|
+
pi connects registered servers from the extension that handles `mcp_servers_change` — the built-in
|
|
124
|
+
`mcp` extension, which also registers `/mcp`. Hosts that build pi's resource loader without
|
|
125
|
+
`extensionFactories` load **no** built-in extension at all (verified: PI WEB's
|
|
126
|
+
`createAgentSessionServices`, pi 0.99.1 / pi-web 1.202609.1), so nothing connects the servers and pi
|
|
127
|
+
reports `MCP server "nan-media" is registered, but no loaded extension connects MCP servers`.
|
|
128
|
+
|
|
129
|
+
There the bridges cannot expose tools, so this package replaces that cryptic error with one warning
|
|
130
|
+
per session, keeps the registrations idle, and lets `/nan-mcp enable` persist the toggle without
|
|
131
|
+
registering a server that can never connect. To get MCP in the CLI, enable the connector with
|
|
132
|
+
`pi config` → Built-in extensions → `mcp`.
|
|
133
|
+
|
|
134
|
+
### pi 0.99 features and NaN models
|
|
135
|
+
|
|
136
|
+
- **Virtual models** — register a router under `nan` (`pi.registerVirtualModel({ provider: "nan", ... })`);
|
|
137
|
+
it routes to the physical NaN models with no provider-side configuration. The cross-model thinking
|
|
138
|
+
guard skips virtual selections, because the routed physical model is not visible to the `context` hook.
|
|
139
|
+
- **Codemode** — works with any tool-calling NaN model. This package's MCP tools use
|
|
140
|
+
`exposure: "direct"`, so codemode scripts can call them as well. Requires pi's built-in `codemode`
|
|
141
|
+
extension (see the PI WEB note above).
|
|
142
|
+
- **Classifier models** — not applicable: NaN exposes no classifier API (chat plus
|
|
143
|
+
embedding/rerank/audio/image endpoints only), so this provider registers no `type: "classifier"` models.
|
|
144
|
+
|
|
114
145
|
---
|
|
115
146
|
|
|
116
147
|
### 1. Official NaN MCP Server
|
|
@@ -277,9 +308,13 @@ NaN's `reasoning_effort` parameter controls how much the model thinks before ans
|
|
|
277
308
|
|
|
278
309
|
| Model | Reasoning effort | How it works |
|
|
279
310
|
| :--- | :--- | :--- |
|
|
280
|
-
| `glm5.3`, `glm5.3-flash` | `low` · `medium` · `high` · `max` | Fully controllable — higher values let the model reason longer |
|
|
281
|
-
| `qwen3.6`, `gemma4` | `none` · `minimal` · `low` · `medium` · `high` · `max` | `none`/`minimal` skip reasoning entirely; others cap at 2K / 8K / 16K / 32K tokens |
|
|
282
|
-
| `deepseek-v4-flash
|
|
311
|
+
| `glm5.3`, `glm5.3-flash` | `minimal` · `low` · `medium` · `high` · `max` | Fully controllable — higher values let the model reason longer; `minimal` yields ~38 reasoning tokens (issue #16, measured 2026-09-27); `none` does NOT suppress reasoning on glm5.3-flash (13,382 tokens) |
|
|
312
|
+
| `qwen3.6`, `gemma4` | `none` · `minimal` · `low` · `medium` · `high` · `max` | `none`/`minimal` skip reasoning entirely; others cap at 2K / 8K / 16K / 32K tokens. `thinking: "off"` sends `reasoning_effort: "none"` automatically via the catalog's `thinkingLevelMap` — the NaN docs state that with no parameter these models reason by default (16,384-token budget), so off must be explicit |
|
|
313
|
+
| `deepseek-v4-flash` | `none` · `minimal` · `low` · `medium` · `high` · `max` (catalog declares ["none"]) | `none` reliably disables reasoning (0 reasoning tokens, measured 2026-09-27); every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:"length" with ZERO answer text) — the catalog only declares `none` because the rest are useless. `thinking: "off"` now sends `reasoning_effort: "none"` automatically via the catalog's `thinkingLevelMap` |
|
|
314
|
+
| `qwen3.8-flash`, `mimo-v2.6-flash` | *(accepted but not adjustable)* | The parameter is accepted and never rejected, but the model manages its own reasoning depth — it is never an error to send a value these models don't adjust |
|
|
315
|
+
> [!NOTE]
|
|
316
|
+
> `maxTokens` does NOT bound the reasoning phase on NaN models. The reasoning phase runs to completion (or truncates at ~60,000 chars for deepseek-v4-flash) regardless of `maxTokens` — measured: `max_tokens=512` on deepseek-v4-flash still consumed 13,376 completion tokens, all reasoning (26x the ceiling). This is gateway behavior confirmed by issue #16 (measured 2026-09-27).
|
|
317
|
+
|
|
283
318
|
|
|
284
319
|
---
|
|
285
320
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtrabanco/pi-nan-provider",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
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",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
* MANUAL_ONLY entry — or this script exits non-zero.
|
|
26
26
|
* - Any other models.dev entry missing `limit.context`/`limit.output` is
|
|
27
27
|
* skipped and flagged "needs manual verification" — never guessed. Entries
|
|
28
|
-
* that are not chat models are excluded via NON_CHAT_MODEL_IDS instead
|
|
28
|
+
* that are not chat models are excluded via NON_CHAT_MODEL_IDS instead
|
|
29
|
+
* (defined in `src/fetch-models.ts` and also enforced at runtime).
|
|
29
30
|
* - models.dev modalities are intersected with pi's supported input set
|
|
30
31
|
* ("text" | "image"); e.g. mimo-v2.6-flash's audio input is not
|
|
31
32
|
* representable in pi's Model type and is dropped from `input` (noted on
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
36
|
import type { GeneratedModelEntry } from "../src/fetch-models.ts";
|
|
37
|
+
import { NON_CHAT_MODEL_IDS } from "../src/fetch-models.ts";
|
|
36
38
|
import type { ManualModelOverride } from "./manual-overrides.ts";
|
|
37
39
|
import {
|
|
38
40
|
MANUAL_OVERRIDES,
|
|
@@ -97,17 +99,10 @@ const PROVIDER_REMOVED_MODEL_IDS: Record<string, string> = {
|
|
|
97
99
|
"removed by NaN (checked 2026-09-29): zero mentions in https://nan.builders/docs/models and in the model param description of https://nan.builders/openapi.json, and models.dev provider nan dropped it as well — superseded by mimo-v2.6-flash; excluded so regeneration does not resurrect it",
|
|
98
100
|
};
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
* "needs manual verification" forever — that flag would suggest limits worth
|
|
105
|
-
* confirming for a model this package must not serve as chat.
|
|
106
|
-
*/
|
|
107
|
-
const NON_CHAT_MODEL_IDS: Record<string, string> = {
|
|
108
|
-
"qwen-image-2.1":
|
|
109
|
-
"image-generation model (text→image; output modality image) documented in https://nan.builders/docs/models and https://nan.builders/openapi.json (checked 2026-09-29) — not a chat model, out of the static chat catalog (models.dev reports limit.output 0)",
|
|
110
|
-
};
|
|
102
|
+
// NON_CHAT_MODEL_IDS (six non-chat endpoint ids) is imported from its single
|
|
103
|
+
// source of truth, `src/fetch-models.ts`, where the runtime live merge applies
|
|
104
|
+
// the same classification. Here it feeds convertModel's exclusion and the
|
|
105
|
+
// exclusion-note list in GENERATED_CATALOG_META.notes.
|
|
111
106
|
|
|
112
107
|
/**
|
|
113
108
|
* Premium/tier-gated models that models.dev documents but that are deliberately
|
|
@@ -237,6 +232,7 @@ function buildManualOnlyModelEntry(
|
|
|
237
232
|
contextWindow: override?.contextWindow ?? 1_048_576,
|
|
238
233
|
maxTokens: override?.maxTokens ?? 131_072,
|
|
239
234
|
reasoningEffortValues,
|
|
235
|
+
...(override?.thinkingLevelMap ? { thinkingLevelMap: { ...override.thinkingLevelMap } } : {}),
|
|
240
236
|
compat: { ...NAN_COMPAT },
|
|
241
237
|
notes: [
|
|
242
238
|
NAN_COMPAT_NOTE,
|
|
@@ -300,6 +296,7 @@ function convertModel(modelId: string, m: ModelsDevModel): GeneratedModel | { sk
|
|
|
300
296
|
contextWindow: override?.contextWindow ?? contextWindow,
|
|
301
297
|
maxTokens: override?.maxTokens ?? maxTokens,
|
|
302
298
|
reasoningEffortValues,
|
|
299
|
+
...(override?.thinkingLevelMap ? { thinkingLevelMap: { ...override.thinkingLevelMap } } : {}),
|
|
303
300
|
compat: { ...NAN_COMPAT },
|
|
304
301
|
notes: [
|
|
305
302
|
NAN_COMPAT_NOTE,
|
|
@@ -344,6 +341,14 @@ async function main(): Promise<void> {
|
|
|
344
341
|
// so the manual data never disappears silently.
|
|
345
342
|
const entryById = new Map(entries.map((entry) => [entry.id, entry]));
|
|
346
343
|
for (const [modelId, detail] of Object.entries(MANUAL_ONLY_MODEL_IDS)) {
|
|
344
|
+
// Guard: a MANUAL_ONLY_MODEL_IDS entry must not be a non-chat id.
|
|
345
|
+
// (REQUIRED_MODEL_IDS can't land here: if a required id were non-chat,
|
|
346
|
+
// convertModel skips it and the existing missing-required fail already
|
|
347
|
+
// fires — but this guard is explicit for MANUAL_ONLY.)
|
|
348
|
+
const nonChatReason = NON_CHAT_MODEL_IDS[modelId];
|
|
349
|
+
if (nonChatReason) {
|
|
350
|
+
fail(`MANUAL_ONLY_MODEL_IDS id "${modelId}" is classified as non-chat (${nonChatReason}) — this is a contradictory repo configuration`);
|
|
351
|
+
}
|
|
347
352
|
const existing = entryById.get(modelId);
|
|
348
353
|
if (existing) {
|
|
349
354
|
const note = `manual-only: "${modelId}" skipped: models.dev provider "${SOURCE_PROVIDER_ID}" now lists it, so the manual-only entry is not emitted and the models.dev values are used (${detail})`;
|
|
@@ -37,13 +37,20 @@ export interface ManualModelOverride {
|
|
|
37
37
|
maxTokens?: number;
|
|
38
38
|
/** Reasoning effort values as declared by NaN docs. An empty array means the parameter is accepted but depth is model-managed (not adjustable by the user). */
|
|
39
39
|
reasoningEffortValues?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Mapping from pi-ai's user-facing thinking levels to model-specific effort
|
|
42
|
+
* strings. Only the "off" key is needed to suppress runaway reasoning.
|
|
43
|
+
* When `reasoning: "off"` maps to undefined in pi-ai, thinkingLevelMap.off
|
|
44
|
+
* supplies the effort value that NaN understands (e.g. "none" or "minimal").
|
|
45
|
+
*/
|
|
46
|
+
thinkingLevelMap?: Partial<Record<string, string | null>>;
|
|
40
47
|
/** Required provenance note; emitted verbatim onto the generated entry. */
|
|
41
48
|
note: string;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
|
-
*
|
|
46
|
-
* #controlling-reasoning, checked 2026-09-29).
|
|
52
|
+
* Manual reasoning effort values sourced from the NaN docs
|
|
53
|
+
* (https://nan.builders/docs/models #controlling-reasoning, checked 2026-09-29).
|
|
47
54
|
*
|
|
48
55
|
* models.dev has NO `reasoning_effort_values` field — only `reasoning_options`
|
|
49
56
|
* (which is [{type:"toggle"}] or []), so this mapping must be hand-maintained
|
|
@@ -53,8 +60,16 @@ export interface ManualModelOverride {
|
|
|
53
60
|
*
|
|
54
61
|
* Per-model contract from the docs:
|
|
55
62
|
* - glm5.3, glm5.3-flash: fully controllable (low/medium/high/max)
|
|
63
|
+
* + minimal also accepted (issue #16, measured 2026-09-27): minimal
|
|
64
|
+
* yields 38 reasoning tokens vs 2,288 for high — the docs simply
|
|
65
|
+
* omitted it because it was not originally advertised, but it IS
|
|
66
|
+
* accepted and effective by the gateway.
|
|
56
67
|
* - qwen3.6, gemma4: none/minimal skip reasoning, others cap depth
|
|
57
|
-
* - deepseek-v4-flash: any value accepted but
|
|
68
|
+
* - deepseek-v4-flash: any value accepted but only "none" deterministically
|
|
69
|
+
* disables reasoning (issue #16, measured 2026-09-27); every other value
|
|
70
|
+
* (minimal/low/medium/high/omitted) leaves 13-14K reasoning tokens with
|
|
71
|
+
* runaway reasoning and finish_reason:"length" with ZERO answer. Since only
|
|
72
|
+
* "none" has a measurable effect, the catalog declares ["none"].
|
|
58
73
|
* - qwen3.8-flash, mimo-v2.6-flash: accepted, depth not adjustable
|
|
59
74
|
*
|
|
60
75
|
* (mimo-v2.5, which used to share that last row, was removed by NaN — absent
|
|
@@ -65,7 +80,7 @@ export interface ManualModelOverride {
|
|
|
65
80
|
*/
|
|
66
81
|
export const REASONING_EFFORT_VALUES: Record<string, string[]> = {
|
|
67
82
|
"glm5.3": ["low", "medium", "high", "max"],
|
|
68
|
-
"glm5.3-flash": ["low", "medium", "high", "max"],
|
|
83
|
+
"glm5.3-flash": ["minimal", "low", "medium", "high", "max"],
|
|
69
84
|
"qwen3.6": ["none", "minimal", "low", "medium", "high", "max"],
|
|
70
85
|
"gemma4": ["none", "minimal", "low", "medium", "high", "max"],
|
|
71
86
|
};
|
|
@@ -105,11 +120,48 @@ export const MANUAL_ONLY_MODELS: Record<string, ManualModelOverride> = {
|
|
|
105
120
|
},
|
|
106
121
|
};
|
|
107
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Manual overrides keyed by model id. Fields override the models.dev-derived
|
|
125
|
+
* entry one-for-one. Every override carries a provenance note.
|
|
126
|
+
*
|
|
127
|
+
* Issue #16 (measured 2026-09-27 against live gateway, repro included):
|
|
128
|
+
* - deepseek-v4-flash: reasoning_effort_values=["none"] (only "none" suppresses);
|
|
129
|
+
* thinkingLevelMap={off:"none"} (supplies reasoning_effort when pi maps
|
|
130
|
+
* reasoning:"off" → undefined).
|
|
131
|
+
* - glm5.3-flash: reasoningEffortValues includes "minimal" (not documented but
|
|
132
|
+
* effective); thinkingLevelMap={off:"minimal"} (supplies reasoning_effort
|
|
133
|
+
* when pi maps reasoning:"off" → undefined).
|
|
134
|
+
* - qwen3.6, gemma4: the NaN docs already document `none` as "skip the
|
|
135
|
+
* reasoning phase entirely" and state that with no parameter the model
|
|
136
|
+
* defaults to reasoning ON (16,384-token budget), so off must send
|
|
137
|
+
* `none` explicitly — thinkingLevelMap={off:"none"} (NaN docs
|
|
138
|
+
* #controlling-reasoning, checked 2026-10-01). No measurement needed: the
|
|
139
|
+
* docs are explicit about both the default and the `none` behavior.
|
|
140
|
+
*/
|
|
108
141
|
export const MANUAL_OVERRIDES: Record<string, ManualModelOverride> = {
|
|
109
142
|
"deepseek-v4-flash": {
|
|
110
143
|
input: ["text", "image"],
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
note:
|
|
144
|
+
reasoningEffortValues: ["none"],
|
|
145
|
+
thinkingLevelMap: { off: "none" },
|
|
146
|
+
note:
|
|
147
|
+
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. " +
|
|
148
|
+
"reasoning_effort_values=[\"none\"] (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): only \"none\" deterministically disables reasoning (0 reasoning tokens, answer produced). Every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:\"length\" with ZERO answer text). The catalog declares only [\"none\"] because the remaining values are useless noise. " +
|
|
149
|
+
"thinkingLevelMap { off: \"none\" } (issue #16, measured 2026-09-27): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"none\" supplies the effort so the gateway receives reasoning_effort:\"none\" and does NOT enter runaway reasoning mode.",
|
|
150
|
+
},
|
|
151
|
+
"glm5.3-flash": {
|
|
152
|
+
thinkingLevelMap: { off: "minimal" },
|
|
153
|
+
note:
|
|
154
|
+
"thinkingLevelMap { off: \"minimal\" } (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"minimal\" supplies the effort so the gateway receives reasoning_effort:\"minimal\" (38 reasoning tokens + answer). Note: \"none\" does NOT suppress reasoning on glm5.3-flash (13,382 tokens) — only \"minimal\" was measured to be effective. " +
|
|
155
|
+
"reasoning_effort_values includes \"minimal\" (not documented by NaN docs but accepted and effective by the gateway, issue #16).",
|
|
156
|
+
},
|
|
157
|
+
"qwen3.6": {
|
|
158
|
+
thinkingLevelMap: { off: "none" },
|
|
159
|
+
note:
|
|
160
|
+
"thinkingLevelMap { off: \"none\" } (issue #16): NaN docs state that with no reasoning_effort parameter every model uses its own default and that default is reasoning ON for qwen3.6 (16,384-token budget), while `none` and `minimal` skip the reasoning phase entirely (https://nan.builders/docs/models#controlling-reasoning, checked 2026-10-01). pi maps reasoning:\"off\" to reasoningEffort:undefined, so without this map `thinking: off` sent no parameter and the model reasoned anyway; the map sends reasoning_effort:\"none\" and actually disables reasoning.",
|
|
161
|
+
},
|
|
162
|
+
"gemma4": {
|
|
163
|
+
thinkingLevelMap: { off: "none" },
|
|
164
|
+
note:
|
|
165
|
+
"thinkingLevelMap { off: \"none\" } (issue #16): NaN docs state that with no reasoning_effort parameter gemma4 defaults to reasoning ON (16,384-token budget), while `none`/`minimal` skip reasoning entirely (https://nan.builders/docs/models#controlling-reasoning, checked 2026-10-01). pi maps reasoning:\"off\" to reasoningEffort:undefined, so without this map `thinking: off` sent no parameter and the model reasoned anyway; the map sends reasoning_effort:\"none\" and actually disables reasoning.",
|
|
114
166
|
},
|
|
115
167
|
};
|
|
@@ -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-
|
|
4
|
+
// Source: https://models.dev/api.json (provider "nan"), fetched 2026-10-01T10:56:14.717Z
|
|
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.
|
|
@@ -28,7 +28,12 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
28
28
|
},
|
|
29
29
|
"contextWindow": 1000000,
|
|
30
30
|
"maxTokens": 384000,
|
|
31
|
-
"reasoningEffortValues": [
|
|
31
|
+
"reasoningEffortValues": [
|
|
32
|
+
"none"
|
|
33
|
+
],
|
|
34
|
+
"thinkingLevelMap": {
|
|
35
|
+
"off": "none"
|
|
36
|
+
},
|
|
32
37
|
"compat": {
|
|
33
38
|
"supportsDeveloperRole": false,
|
|
34
39
|
"supportsReasoningEffort": true,
|
|
@@ -38,7 +43,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
38
43
|
},
|
|
39
44
|
"notes": [
|
|
40
45
|
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
|
|
41
|
-
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. reasoning_effort_values=[]
|
|
46
|
+
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. reasoning_effort_values=[\"none\"] (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): only \"none\" deterministically disables reasoning (0 reasoning tokens, answer produced). Every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:\"length\" with ZERO answer text). The catalog declares only [\"none\"] because the remaining values are useless noise. thinkingLevelMap { off: \"none\" } (issue #16, measured 2026-09-27): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"none\" supplies the effort so the gateway receives reasoning_effort:\"none\" and does NOT enter runaway reasoning mode."
|
|
42
47
|
],
|
|
43
48
|
"extras": {
|
|
44
49
|
"id": "deepseek-v4-flash",
|
|
@@ -99,6 +104,9 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
99
104
|
"high",
|
|
100
105
|
"max"
|
|
101
106
|
],
|
|
107
|
+
"thinkingLevelMap": {
|
|
108
|
+
"off": "none"
|
|
109
|
+
},
|
|
102
110
|
"compat": {
|
|
103
111
|
"supportsDeveloperRole": false,
|
|
104
112
|
"supportsReasoningEffort": true,
|
|
@@ -107,7 +115,8 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
107
115
|
"maxTokensField": "max_tokens"
|
|
108
116
|
},
|
|
109
117
|
"notes": [
|
|
110
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict."
|
|
118
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
|
|
119
|
+
"thinkingLevelMap { off: \"none\" } (issue #16): NaN docs state that with no reasoning_effort parameter gemma4 defaults to reasoning ON (16,384-token budget), while `none`/`minimal` skip reasoning entirely (https://nan.builders/docs/models#controlling-reasoning, checked 2026-10-01). pi maps reasoning:\"off\" to reasoningEffort:undefined, so without this map `thinking: off` sent no parameter and the model reasoned anyway; the map sends reasoning_effort:\"none\" and actually disables reasoning."
|
|
111
120
|
],
|
|
112
121
|
"extras": {
|
|
113
122
|
"id": "gemma4",
|
|
@@ -171,11 +180,15 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
171
180
|
"contextWindow": 1000000,
|
|
172
181
|
"maxTokens": 131072,
|
|
173
182
|
"reasoningEffortValues": [
|
|
183
|
+
"minimal",
|
|
174
184
|
"low",
|
|
175
185
|
"medium",
|
|
176
186
|
"high",
|
|
177
187
|
"max"
|
|
178
188
|
],
|
|
189
|
+
"thinkingLevelMap": {
|
|
190
|
+
"off": "minimal"
|
|
191
|
+
},
|
|
179
192
|
"compat": {
|
|
180
193
|
"supportsDeveloperRole": false,
|
|
181
194
|
"supportsReasoningEffort": true,
|
|
@@ -184,7 +197,8 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
184
197
|
"maxTokensField": "max_tokens"
|
|
185
198
|
},
|
|
186
199
|
"notes": [
|
|
187
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict."
|
|
200
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
|
|
201
|
+
"thinkingLevelMap { off: \"minimal\" } (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"minimal\" supplies the effort so the gateway receives reasoning_effort:\"minimal\" (38 reasoning tokens + answer). Note: \"none\" does NOT suppress reasoning on glm5.3-flash (13,382 tokens) — only \"minimal\" was measured to be effective. reasoning_effort_values includes \"minimal\" (not documented by NaN docs but accepted and effective by the gateway, issue #16)."
|
|
188
202
|
],
|
|
189
203
|
"extras": {
|
|
190
204
|
"id": "glm5.3-flash",
|
|
@@ -316,6 +330,9 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
316
330
|
"high",
|
|
317
331
|
"max"
|
|
318
332
|
],
|
|
333
|
+
"thinkingLevelMap": {
|
|
334
|
+
"off": "none"
|
|
335
|
+
},
|
|
319
336
|
"compat": {
|
|
320
337
|
"supportsDeveloperRole": false,
|
|
321
338
|
"supportsReasoningEffort": true,
|
|
@@ -324,7 +341,8 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
324
341
|
"maxTokensField": "max_tokens"
|
|
325
342
|
},
|
|
326
343
|
"notes": [
|
|
327
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict."
|
|
344
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
|
|
345
|
+
"thinkingLevelMap { off: \"none\" } (issue #16): NaN docs state that with no reasoning_effort parameter every model uses its own default and that default is reasoning ON for qwen3.6 (16,384-token budget), while `none` and `minimal` skip the reasoning phase entirely (https://nan.builders/docs/models#controlling-reasoning, checked 2026-10-01). pi maps reasoning:\"off\" to reasoningEffort:undefined, so without this map `thinking: off` sent no parameter and the model reasoned anyway; the map sends reasoning_effort:\"none\" and actually disables reasoning."
|
|
328
346
|
],
|
|
329
347
|
"extras": {
|
|
330
348
|
"id": "qwen3.6",
|
|
@@ -437,7 +455,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
437
455
|
export const GENERATED_CATALOG_META = {
|
|
438
456
|
source: "https://models.dev/api.json",
|
|
439
457
|
modelsDevProvider: "nan",
|
|
440
|
-
fetchedAt: "2026-
|
|
458
|
+
fetchedAt: "2026-10-01T10:56:14.717Z",
|
|
441
459
|
modelCount: 6,
|
|
442
460
|
models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.6-flash","qwen3.6","qwen3.8-flash"],
|
|
443
461
|
notes: [
|
|
@@ -445,9 +463,17 @@ export const GENERATED_CATALOG_META = {
|
|
|
445
463
|
"live-only: \"glm5.3\" kept out of the static catalog (premium-tier model (models.dev now documents it with 1M context / 131,072 max output; NaN docs https://nan.builders/docs/models + https://nan.builders/openapi.json, checked 2026-09-13) kept live-only so a non-premium key never sees a model it cannot call when the live /models fetch is unavailable; premium keys still get it via the /models refresh with conservative placeholder limits)",
|
|
446
464
|
"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)",
|
|
447
465
|
"provider-removed: \"mimo-v2.5\" excluded from the catalog (removed by NaN (checked 2026-09-29): zero mentions in https://nan.builders/docs/models and in the model param description of https://nan.builders/openapi.json, and models.dev provider nan dropped it as well — superseded by mimo-v2.6-flash; excluded so regeneration does not resurrect it)",
|
|
466
|
+
"non-chat: \"qwen3-embedding\" excluded from the chat catalog (embedding endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model)",
|
|
467
|
+
"non-chat: \"rerank\" excluded from the chat catalog (rerank endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model)",
|
|
468
|
+
"non-chat: \"kokoro\" excluded from the chat catalog (text-to-speech (TTS) endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model)",
|
|
469
|
+
"non-chat: \"whisper\" excluded from the chat catalog (speech-to-text (STT) endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model)",
|
|
470
|
+
"non-chat: \"flux-2-klein\" excluded from the chat catalog (image generation endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model)",
|
|
448
471
|
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
|
|
449
|
-
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. reasoning_effort_values=[]
|
|
472
|
+
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. reasoning_effort_values=[\"none\"] (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): only \"none\" deterministically disables reasoning (0 reasoning tokens, answer produced). Every other value leaves 13-14K reasoning tokens and causes runaway reasoning (finish_reason:\"length\" with ZERO answer text). The catalog declares only [\"none\"] because the remaining values are useless noise. thinkingLevelMap { off: \"none\" } (issue #16, measured 2026-09-27): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"none\" supplies the effort so the gateway receives reasoning_effort:\"none\" and does NOT enter runaway reasoning mode.",
|
|
473
|
+
"thinkingLevelMap { off: \"none\" } (issue #16): NaN docs state that with no reasoning_effort parameter gemma4 defaults to reasoning ON (16,384-token budget), while `none`/`minimal` skip reasoning entirely (https://nan.builders/docs/models#controlling-reasoning, checked 2026-10-01). pi maps reasoning:\"off\" to reasoningEffort:undefined, so without this map `thinking: off` sent no parameter and the model reasoned anyway; the map sends reasoning_effort:\"none\" and actually disables reasoning.",
|
|
474
|
+
"thinkingLevelMap { off: \"minimal\" } (issue #16, measured 2026-09-27 against live gateway, repro included in the issue): when pi maps reasoning:\"off\" to reasoningEffort:undefined, the thinkingLevelMap.off=\"minimal\" supplies the effort so the gateway receives reasoning_effort:\"minimal\" (38 reasoning tokens + answer). Note: \"none\" does NOT suppress reasoning on glm5.3-flash (13,382 tokens) — only \"minimal\" was measured to be effective. reasoning_effort_values includes \"minimal\" (not documented by NaN docs but accepted and effective by the gateway, issue #16).",
|
|
450
475
|
"manual-only: \"mimo-v2.6-flash\" skipped: models.dev provider \"nan\" now lists it, so the manual-only entry is not emitted and the models.dev values are used (omnimodal model (text, image, audio input) served by NaN; limits and modalities from https://nan.builders/docs/models#mimo-v2-6-flash (checked 2026-09-25, quotas re-checked 2026-09-29): 1,048,576 context / 131,072 max output / 1.0B monthly quota per member. Reasoning: accepted, depth not adjustable (docs table, checked 2026-09-29). Tool calling: yes. Streaming: yes. Input modalities (pi-representable): text, image (audio not representable in pi's Model type). Fallback entry: emitted only while models.dev provider nan does not list the model — models.dev started listing it on 2026-09-29, so the generator then keeps the models.dev values and attaches this note to that entry instead of emitting a second one.)",
|
|
476
|
+
"thinkingLevelMap { off: \"none\" } (issue #16): NaN docs state that with no reasoning_effort parameter every model uses its own default and that default is reasoning ON for qwen3.6 (16,384-token budget), while `none` and `minimal` skip the reasoning phase entirely (https://nan.builders/docs/models#controlling-reasoning, checked 2026-10-01). pi maps reasoning:\"off\" to reasoningEffort:undefined, so without this map `thinking: off` sent no parameter and the model reasoned anyway; the map sends reasoning_effort:\"none\" and actually disables reasoning.",
|
|
451
477
|
"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)."
|
|
452
478
|
],
|
|
453
479
|
} as const;
|
package/src/commands.ts
CHANGED
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
29
29
|
import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
|
|
30
30
|
import { webSearchBridgeEnabled, webSearchBridgeSource, mcpToolsEnvExplicit, mcpToolsDisabled } from "./mcp/api-key.ts";
|
|
31
|
-
import { NAN_API_KEY_ENV, tryResolveNanApiKeyViaRegistry } from "./mcp/api-key.ts";
|
|
32
|
-
import { NAN_STATE_FILE, readBridgeState, writeBridgeState, type BridgeKey, readState, writeState } from "./mcp/state.ts";
|
|
31
|
+
import { NAN_API_KEY_ENV, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey } from "./mcp/api-key.ts";
|
|
32
|
+
import { NAN_STATE_FILE, MCP_SERVER_NAMES, readBridgeState, writeBridgeState, type BridgeKey, readState, writeState } from "./mcp/state.ts";
|
|
33
|
+
import { connectorMissingMessage, hasMcpConnector } from "./mcp/host-support.ts";
|
|
33
34
|
|
|
34
35
|
const USAGE =
|
|
35
36
|
"Usage: /nan-mcp [status] · /nan-mcp enable [web-search|nan-mcp-server] · /nan-mcp disable [web-search|nan-mcp-server]";
|
|
@@ -67,7 +68,7 @@ function sourceLabel(bridge: BridgeKey, source: "env" | "persisted" | "default")
|
|
|
67
68
|
|
|
68
69
|
/** Resolve a bridge key to its native server name. */
|
|
69
70
|
function serverName(bridge: BridgeKey): string {
|
|
70
|
-
return bridge
|
|
71
|
+
return MCP_SERVER_NAMES[bridge];
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
/** Describe the bridge in the status message. */
|
|
@@ -86,10 +87,16 @@ function nativeServerRegistered(pi: ExtensionAPI, name: string): boolean {
|
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
/** Status message listing both bridges' gate state and native registration.
|
|
90
|
-
|
|
90
|
+
/** Status message listing both bridges' gate state and native registration.
|
|
91
|
+
* `ctx` is the command context (provides modelRegistry for key lookup).
|
|
92
|
+
*/
|
|
93
|
+
async function statusMessage(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<string> {
|
|
91
94
|
const lines: string[] = [];
|
|
92
95
|
|
|
96
|
+
if (!hasMcpConnector(pi)) {
|
|
97
|
+
lines.push(connectorMissingMessage([MCP_SERVER_NAMES.webSearch, MCP_SERVER_NAMES.mediaMcp]));
|
|
98
|
+
}
|
|
99
|
+
|
|
93
100
|
for (const bridge of ["webSearch" as BridgeKey, "mediaMcp" as BridgeKey]) {
|
|
94
101
|
const enabled = bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
|
|
95
102
|
const source = bridge === "webSearch" ? webSearchBridgeSource() : mediaMcpSource();
|
|
@@ -98,7 +105,7 @@ function statusMessage(pi: ExtensionAPI): string {
|
|
|
98
105
|
|
|
99
106
|
if (bridge === "webSearch") {
|
|
100
107
|
// Key status for web-search
|
|
101
|
-
const keyStatus = resolveKeyStatus(
|
|
108
|
+
const keyStatus = await resolveKeyStatus(ctx);
|
|
102
109
|
lines.push(` API key: ${keyStatus}.`);
|
|
103
110
|
}
|
|
104
111
|
if (bridge === "mediaMcp") {
|
|
@@ -110,12 +117,14 @@ function statusMessage(pi: ExtensionAPI): string {
|
|
|
110
117
|
return lines.join("\n");
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
/** Check API key resolution status for web-search.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
/** Check API key resolution status for web-search.
|
|
121
|
+
* `ctx` is the command context (provides modelRegistry).
|
|
122
|
+
*/
|
|
123
|
+
async function resolveKeyStatus(ctx: ExtensionCommandContext): Promise<string> {
|
|
124
|
+
const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
|
|
125
|
+
const storedKey = resolveStoredNanApiKey();
|
|
117
126
|
const envKey = process.env[NAN_API_KEY_ENV];
|
|
118
|
-
if (registryKey || envKey) return "resolved";
|
|
127
|
+
if (registryKey || storedKey || envKey) return "resolved";
|
|
119
128
|
return "NOT SET — run /login nan or export NAN_API_KEY";
|
|
120
129
|
}
|
|
121
130
|
|
|
@@ -161,7 +170,7 @@ export function registerNanMcpCommand(pi: ExtensionAPI): void {
|
|
|
161
170
|
const subcommand = rawSubcommand.toLowerCase();
|
|
162
171
|
|
|
163
172
|
if (subcommand === "status") {
|
|
164
|
-
ctx.ui.notify(statusMessage(pi), "info");
|
|
173
|
+
ctx.ui.notify(await statusMessage(pi, ctx), "info");
|
|
165
174
|
return;
|
|
166
175
|
}
|
|
167
176
|
|
|
@@ -183,6 +192,22 @@ export function registerNanMcpCommand(pi: ExtensionAPI): void {
|
|
|
183
192
|
writeBridgeState(bridge, enabled);
|
|
184
193
|
}
|
|
185
194
|
|
|
195
|
+
const where = target ? `for "${target}"` : "for both bridges";
|
|
196
|
+
const persistence = `persisted in <agentDir>/${NAN_STATE_FILE}`;
|
|
197
|
+
|
|
198
|
+
// Without an extension that handles `mcp_servers_change` pi cannot connect the
|
|
199
|
+
// servers; registering would only trigger its "no loaded extension connects MCP
|
|
200
|
+
// servers" error (see src/mcp/host-support.ts). The toggle above still persists,
|
|
201
|
+
// so it applies as soon as a connector (built-in `mcp`) is available.
|
|
202
|
+
if (enabled && !hasMcpConnector(pi)) {
|
|
203
|
+
ctx.ui.notify(
|
|
204
|
+
`Enabled ${describeBridges(bridges)} ${where}, ${persistence} — NOT registered. ` +
|
|
205
|
+
connectorMissingMessage(bridges.map(serverName)),
|
|
206
|
+
"warning",
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
// Immediate native registration/unregistration.
|
|
187
212
|
for (const bridge of bridges) {
|
|
188
213
|
const name = serverName(bridge);
|
|
@@ -190,9 +215,10 @@ export function registerNanMcpCommand(pi: ExtensionAPI): void {
|
|
|
190
215
|
// Only register if gate allows it.
|
|
191
216
|
if (bridgeEffectivelyEnabled(bridge)) {
|
|
192
217
|
if (bridge === "webSearch") {
|
|
193
|
-
//
|
|
194
|
-
const registryKey = await tryResolveNanApiKeyViaRegistry(
|
|
195
|
-
const
|
|
218
|
+
// Resolve key: stored credential → registry → env.
|
|
219
|
+
const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
|
|
220
|
+
const storedKey = resolveStoredNanApiKey();
|
|
221
|
+
const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
|
|
196
222
|
if (apiKey) {
|
|
197
223
|
pi.registerMcpServer(name, {
|
|
198
224
|
type: "http",
|
|
@@ -207,15 +233,26 @@ export function registerNanMcpCommand(pi: ExtensionAPI): void {
|
|
|
207
233
|
);
|
|
208
234
|
}
|
|
209
235
|
} else {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
236
|
+
// Resolve key for media: stored credential → registry → env.
|
|
237
|
+
const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
|
|
238
|
+
const storedKey = resolveStoredNanApiKey();
|
|
239
|
+
const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
|
|
240
|
+
if (apiKey) {
|
|
241
|
+
const mediaArgs = mediaMcpCommand();
|
|
242
|
+
pi.registerMcpServer(name, {
|
|
243
|
+
type: "stdio",
|
|
244
|
+
command: mediaArgs[0]!,
|
|
245
|
+
args: mediaArgs.slice(1),
|
|
246
|
+
env: { [NAN_API_KEY_ENV]: apiKey },
|
|
247
|
+
exposure: "direct",
|
|
248
|
+
timeout: mediaMcpTimeoutSec(),
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
ctx.ui.notify(
|
|
252
|
+
`${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
|
|
253
|
+
"warning",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
219
256
|
}
|
|
220
257
|
}
|
|
221
258
|
} else {
|
|
@@ -223,13 +260,22 @@ export function registerNanMcpCommand(pi: ExtensionAPI): void {
|
|
|
223
260
|
}
|
|
224
261
|
}
|
|
225
262
|
|
|
226
|
-
const where = target ? `for "${target}"` : "for both bridges";
|
|
227
|
-
const persistence = `persisted in <agentDir>/${NAN_STATE_FILE}`;
|
|
228
263
|
if (enabled) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
// Only claim success if servers were actually registered.
|
|
265
|
+
const allRegistered = bridges.every((b: BridgeKey) =>
|
|
266
|
+
nativeServerRegistered(pi, serverName(b)),
|
|
232
267
|
);
|
|
268
|
+
if (allRegistered) {
|
|
269
|
+
ctx.ui.notify(
|
|
270
|
+
`Enabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers registered for this session.`,
|
|
271
|
+
"info",
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
ctx.ui.notify(
|
|
275
|
+
`Enabled ${describeBridges(bridges)} ${where}, ${persistence} — API key missing; servers not registered.`,
|
|
276
|
+
"warning",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
233
279
|
} else {
|
|
234
280
|
ctx.ui.notify(
|
|
235
281
|
`Disabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers unregistered — tools hidden immediately.`,
|
|
@@ -65,6 +65,14 @@ export function crossModelThinkingGuardEnabled(env: NodeJS.ProcessEnv = process.
|
|
|
65
65
|
return value !== "0" && value !== "false" && value !== "no" && value !== "off";
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* API id pi 0.99 assigns to virtual catalog entries (`core/virtual-models.js`,
|
|
70
|
+
* `VIRTUAL_MODEL_API`). A virtual selection routes each request to a physical
|
|
71
|
+
* model; the physical target is not exposed to the `context` hook, so the guard
|
|
72
|
+
* cannot tell which physical model will answer.
|
|
73
|
+
*/
|
|
74
|
+
const VIRTUAL_MODEL_API = "pi-virtual";
|
|
75
|
+
|
|
68
76
|
function isGuardMessage(value: unknown): value is GuardMessage {
|
|
69
77
|
return typeof value === "object" && value !== null;
|
|
70
78
|
}
|
|
@@ -85,6 +93,16 @@ export function stripCrossModelThinking<T>(
|
|
|
85
93
|
): readonly T[] {
|
|
86
94
|
if (!target?.provider || !options.providerIds.has(target.provider)) return messages;
|
|
87
95
|
|
|
96
|
+
// Virtual selection (pi 0.99): `ctx.model` is the selectable virtual entry
|
|
97
|
+
// (api "pi-virtual"), not the physical model the router picks for this
|
|
98
|
+
// request. Its api/id never match a physical assistant message, so the
|
|
99
|
+
// same-model check below would strip reasoning on EVERY continuation —
|
|
100
|
+
// including when the router keeps the same physical model — losing the
|
|
101
|
+
// prompt cache and thinking continuity the guard promises to preserve.
|
|
102
|
+
// The routed physical model is not available here, so skip virtual
|
|
103
|
+
// selections entirely; cross-model trimming is the router's concern there.
|
|
104
|
+
if (target.api === VIRTUAL_MODEL_API) return messages;
|
|
105
|
+
|
|
88
106
|
let changed = false;
|
|
89
107
|
|
|
90
108
|
const next = messages.map((raw) => {
|
package/src/fetch-models.ts
CHANGED
|
@@ -12,16 +12,19 @@
|
|
|
12
12
|
* only model `id`s — no capability fields. It is used as the authoritative
|
|
13
13
|
* list of what the endpoint returns for the API key.
|
|
14
14
|
*
|
|
15
|
-
* Merge: live IDs × generated capability data —
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* Merge: live IDs × generated capability data — non-chat IDs (see
|
|
16
|
+
* `NON_CHAT_MODEL_IDS`) are excluded from the registered model set and
|
|
17
|
+
* reported in `MergedCatalog.nonChat`; all other live IDs surface: a live ID
|
|
18
|
+
* with generated data keeps its generated capabilities, and an uncatalogued
|
|
19
|
+
* live ID (e.g. a new or undocumented model) gets conservative placeholder
|
|
20
|
+
* limits (the same defaults used in custom-provider.md's dynamic-discovery
|
|
21
|
+
* example) with no reasoning support — capabilities stay "unknown", nothing is
|
|
22
|
+
* fabricated. On fetch failure, timeout, or an unusable response, callers fall
|
|
23
|
+
* back to the generated catalog so startup is never blocked.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
|
|
27
|
+
import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
25
28
|
import { GENERATED_CATALOG_META, NAN_GENERATED_MODELS } from "../scripts/models.generated.ts";
|
|
26
29
|
|
|
27
30
|
export type { Model };
|
|
@@ -46,6 +49,15 @@ export interface GeneratedModelEntry {
|
|
|
46
49
|
* An undefined array means the field was not set (legacy / uncatalogued).
|
|
47
50
|
*/
|
|
48
51
|
reasoningEffortValues?: string[];
|
|
52
|
+
/**
|
|
53
|
+
* Mapping from pi-ai's user-facing thinking levels to model-specific effort
|
|
54
|
+
* strings. When `reasoning: "off"` maps to undefined in pi-ai,
|
|
55
|
+
* thinkingLevelMap.off supplies the effort value that NaN understands
|
|
56
|
+
* (e.g. "none" for deepseek-v4-flash, "minimal" for glm5.3-flash).
|
|
57
|
+
* Without this, no reasoning_effort is sent and NaN does NOT disable
|
|
58
|
+
* reasoning, causing runaway reasoning (issue #16).
|
|
59
|
+
*/
|
|
60
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
49
61
|
/** Compat applied to every NaN-compatible model (LiteLLM-confirmed, see scripts/generate-models.ts). */
|
|
50
62
|
compat?: OpenAICompletionsCompat;
|
|
51
63
|
/** Provenance notes for values overriding models.dev or needing manual confirmation. */
|
|
@@ -71,6 +83,32 @@ export const UNKNOWN_MODEL_LIMITS = { contextWindow: 128_000, maxTokens: 4_096 }
|
|
|
71
83
|
/** Timeout for the live /models fetch; matches the pi-synthetic-provider precedent (~3s). */
|
|
72
84
|
export const DEFAULT_MODELS_TIMEOUT_MS = 3_000;
|
|
73
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Live endpoint IDs classified as non-chat and excluded from the registered
|
|
88
|
+
* model set. Keys are model ids; values are provenance reasons citing the
|
|
89
|
+
* source that designated the endpoint as non-chat.
|
|
90
|
+
*
|
|
91
|
+
* Source: https://nan.builders/docs/models (checked 2026-10-01), plus
|
|
92
|
+
* qwen-image-2.1: https://models.dev API `limit.output: 0` (checked 2026-09-29).
|
|
93
|
+
* These are MCP-bridge territory (embedding / rerank / TTS / STT / image
|
|
94
|
+
* generation), not chat catalog models — see AGENTS.md classification notes
|
|
95
|
+
* and issue #15.
|
|
96
|
+
*/
|
|
97
|
+
export const NON_CHAT_MODEL_IDS: Readonly<Record<string, string>> = {
|
|
98
|
+
"qwen3-embedding":
|
|
99
|
+
"embedding endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
|
|
100
|
+
rerank:
|
|
101
|
+
"rerank endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
|
|
102
|
+
kokoro:
|
|
103
|
+
"text-to-speech (TTS) endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
|
|
104
|
+
whisper:
|
|
105
|
+
"speech-to-text (STT) endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
|
|
106
|
+
"flux-2-klein":
|
|
107
|
+
"image generation endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
|
|
108
|
+
"qwen-image-2.1":
|
|
109
|
+
"image-generation model (text→image; output modality image) documented in https://nan.builders/docs/models and https://nan.builders/openapi.json (checked 2026-09-29) — not a chat model, out of the static chat catalog (models.dev reports limit.output 0)",
|
|
110
|
+
} as const;
|
|
111
|
+
|
|
74
112
|
export interface CatalogSource {
|
|
75
113
|
/** Provider id as registered in pi, e.g. "nan". */
|
|
76
114
|
providerId: string;
|
|
@@ -92,12 +130,17 @@ export function toModel(entry: GeneratedModelEntry, source: CatalogSource): Mode
|
|
|
92
130
|
contextWindow: entry.contextWindow,
|
|
93
131
|
maxTokens: entry.maxTokens,
|
|
94
132
|
...(entry.compat ? { compat: { ...entry.compat } } : {}),
|
|
133
|
+
...(entry.thinkingLevelMap ? { thinkingLevelMap: { ...entry.thinkingLevelMap } } : {}),
|
|
95
134
|
};
|
|
96
135
|
}
|
|
97
136
|
|
|
98
137
|
/** The generated fallback catalog as pi-ai Models for the given provider. */
|
|
99
138
|
export function baselineModels(source: CatalogSource): Model<"openai-completions">[] {
|
|
100
|
-
|
|
139
|
+
// Defensive: the generated baseline never contains non-chat ids today,
|
|
140
|
+
// but the invariant must hold on every path (issue #15).
|
|
141
|
+
return NAN_GENERATED_MODELS.filter(
|
|
142
|
+
(entry) => !NON_CHAT_MODEL_IDS[entry.id],
|
|
143
|
+
).map((entry) => toModel(entry, source));
|
|
101
144
|
}
|
|
102
145
|
|
|
103
146
|
export interface LiveModelListOptions {
|
|
@@ -152,12 +195,18 @@ export interface MergedCatalog {
|
|
|
152
195
|
matched: string[];
|
|
153
196
|
/** Allowlisted live IDs kept with unknown capabilities (conservative limits). */
|
|
154
197
|
unknown: string[];
|
|
198
|
+
/**
|
|
199
|
+
* Live model IDs classified as non-chat and excluded from the registered
|
|
200
|
+
* model set, in live order. Populated from `NON_CHAT_MODEL_IDS` (issue #15).
|
|
201
|
+
*/
|
|
202
|
+
nonChat: string[];
|
|
155
203
|
}
|
|
156
204
|
|
|
157
205
|
/**
|
|
158
|
-
* Merge live model IDs with the generated capability catalog.
|
|
159
|
-
*
|
|
160
|
-
*
|
|
206
|
+
* Merge live model IDs with the generated capability catalog. Non-chat IDs
|
|
207
|
+
* (from `NON_CHAT_MODEL_IDS`) are excluded and reported in `nonChat`; all
|
|
208
|
+
* other live IDs surface: live IDs with generated data keep their generated
|
|
209
|
+
* capabilities; uncatalogued live IDs get conservative placeholder limits,
|
|
161
210
|
* `reasoning: false`, and zero cost (documented defaults, not invented
|
|
162
211
|
* capabilities).
|
|
163
212
|
*/
|
|
@@ -170,8 +219,15 @@ export function mergeLiveWithGenerated(
|
|
|
170
219
|
const models: Model<"openai-completions">[] = [];
|
|
171
220
|
const matched: string[] = [];
|
|
172
221
|
const unknown: string[] = [];
|
|
222
|
+
const nonChat: string[] = [];
|
|
173
223
|
|
|
174
224
|
for (const id of liveIds) {
|
|
225
|
+
// Issue #15: non-chat endpoint ids must never surface as chat models.
|
|
226
|
+
const nonChatReason = NON_CHAT_MODEL_IDS[id];
|
|
227
|
+
if (nonChatReason) {
|
|
228
|
+
nonChat.push(id);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
175
231
|
const entry = byId.get(id);
|
|
176
232
|
if (entry) {
|
|
177
233
|
models.push(toModel(entry, source));
|
|
@@ -206,7 +262,7 @@ export function mergeLiveWithGenerated(
|
|
|
206
262
|
}
|
|
207
263
|
}
|
|
208
264
|
|
|
209
|
-
return { models, matched, unknown };
|
|
265
|
+
return { models, matched, unknown, nonChat };
|
|
210
266
|
}
|
|
211
267
|
|
|
212
268
|
export interface FetchModelsOptions {
|
|
@@ -229,13 +285,19 @@ export interface ResolvedCatalog {
|
|
|
229
285
|
liveIds: Set<string> | undefined;
|
|
230
286
|
/** Allowlisted live IDs kept with unknown capabilities (present only when liveIds is set). */
|
|
231
287
|
unknownIds: string[];
|
|
288
|
+
/**
|
|
289
|
+
* Live model IDs classified as non-chat and excluded from the registered
|
|
290
|
+
* model set (issue #15). Empty when the live fetch failed.
|
|
291
|
+
*/
|
|
292
|
+
nonChatIds: string[];
|
|
232
293
|
}
|
|
233
294
|
|
|
234
295
|
/**
|
|
235
296
|
* Resolve the effective catalog: live `/models` IDs × generated capability
|
|
236
297
|
* data. A successful live fetch is authoritative (tier-aware: it reflects
|
|
237
298
|
* exactly what your NaN key can use); failure degrades to the generated
|
|
238
|
-
* fallback so startup is never blocked.
|
|
299
|
+
* fallback so startup is never blocked. Non-chat IDs are excluded from
|
|
300
|
+
* models and reported in `nonChatIds` (issue #15).
|
|
239
301
|
*/
|
|
240
302
|
export async function resolveCatalog(
|
|
241
303
|
source: CatalogSource,
|
|
@@ -249,11 +311,16 @@ export async function resolveCatalog(
|
|
|
249
311
|
timeoutMs: options.timeoutMs,
|
|
250
312
|
fetchImpl: options.fetchImpl,
|
|
251
313
|
});
|
|
252
|
-
if (!liveIds) return { models: baseline, liveIds: undefined, unknownIds: [] };
|
|
314
|
+
if (!liveIds) return { models: baseline, liveIds: undefined, unknownIds: [], nonChatIds: [] };
|
|
253
315
|
const merged = mergeLiveWithGenerated(liveIds, source);
|
|
254
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
models: merged.models,
|
|
318
|
+
liveIds: new Set(liveIds),
|
|
319
|
+
unknownIds: merged.unknown,
|
|
320
|
+
nonChatIds: merged.nonChat,
|
|
321
|
+
};
|
|
255
322
|
} catch {
|
|
256
|
-
return { models: baseline, liveIds: undefined, unknownIds: [] };
|
|
323
|
+
return { models: baseline, liveIds: undefined, unknownIds: [], nonChatIds: [] };
|
|
257
324
|
}
|
|
258
325
|
}
|
|
259
326
|
|
package/src/index.ts
CHANGED
|
@@ -39,8 +39,9 @@ import {
|
|
|
39
39
|
import { baselineModels } from "./fetch-models.ts";
|
|
40
40
|
import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
|
|
41
41
|
import { PROVIDERS } from "./providers.ts";
|
|
42
|
-
import { NAN_API_KEY_ENV, mcpToolsDisabled, mcpToolsEnvExplicit, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, NAN_MCP_TOOLS_ENV, webSearchBridgeEnabled } from "./mcp/api-key.ts";
|
|
42
|
+
import { NAN_API_KEY_ENV, mcpToolsDisabled, mcpToolsEnvExplicit, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey, NAN_MCP_TOOLS_ENV, webSearchBridgeEnabled } from "./mcp/api-key.ts";
|
|
43
43
|
import { mediaMcpCommand, mediaMcpEnabled, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
|
|
44
|
+
import { registerMcpHostGuard } from "./mcp/host-support.ts";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Register a provider on any pi version: the native full-Provider overload
|
|
@@ -78,6 +79,7 @@ async function registerProviderCompat(
|
|
|
78
79
|
contextWindow: model.contextWindow,
|
|
79
80
|
maxTokens: model.maxTokens,
|
|
80
81
|
...(model.compat ? { compat: { ...model.compat } } : {}),
|
|
82
|
+
...(model.thinkingLevelMap ? { thinkingLevelMap: { ...model.thinkingLevelMap } } : {}),
|
|
81
83
|
})),
|
|
82
84
|
};
|
|
83
85
|
pi.registerProvider(config.id, legacy);
|
|
@@ -93,10 +95,13 @@ const NO_NATIVE_MCP_GUARD_MSG =
|
|
|
93
95
|
"pi-nan-provider 0.10+ requires pi >= 0.99 for MCP tools (native MCP); upgrade pi or stay on package 0.9.x";
|
|
94
96
|
|
|
95
97
|
/** Register the official NaN web-search MCP server (session-scoped). */
|
|
96
|
-
async function registerWebSearchMcpServer(pi: ExtensionAPI): Promise<boolean> {
|
|
98
|
+
async function registerWebSearchMcpServer(pi: ExtensionAPI, apiKey: string | undefined): Promise<boolean> {
|
|
97
99
|
if (!webSearchBridgeEnabled()) return false;
|
|
98
|
-
const apiKey = process.env[NAN_API_KEY_ENV];
|
|
99
100
|
if (!apiKey) {
|
|
101
|
+
console.warn(
|
|
102
|
+
"[pi-nan-provider] NAN_API_KEY is not set — web-search server not registered. " +
|
|
103
|
+
"Run `/login nan` in pi or export `NAN_API_KEY=<your-key>`.",
|
|
104
|
+
);
|
|
100
105
|
return false;
|
|
101
106
|
}
|
|
102
107
|
pi.registerMcpServer("nan-search", {
|
|
@@ -109,14 +114,21 @@ async function registerWebSearchMcpServer(pi: ExtensionAPI): Promise<boolean> {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
/** Register the community media stdio MCP server (session-scoped). */
|
|
112
|
-
function registerMediaMcpServer(pi: ExtensionAPI): boolean {
|
|
117
|
+
function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): boolean {
|
|
113
118
|
if (!mediaMcpEnabled()) return false;
|
|
119
|
+
if (!apiKey) {
|
|
120
|
+
console.warn(
|
|
121
|
+
"[pi-nan-provider] NAN_API_KEY is not set — media server not registered. " +
|
|
122
|
+
"Run `/login nan` in pi or export `NAN_API_KEY=<your-key>`.",
|
|
123
|
+
);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
114
126
|
const mediaArgs = mediaMcpCommand();
|
|
115
127
|
pi.registerMcpServer("nan-media", {
|
|
116
128
|
type: "stdio",
|
|
117
129
|
command: mediaArgs[0]!,
|
|
118
130
|
args: mediaArgs.slice(1),
|
|
119
|
-
env: { [NAN_API_KEY_ENV]:
|
|
131
|
+
env: { [NAN_API_KEY_ENV]: apiKey },
|
|
120
132
|
exposure: "direct",
|
|
121
133
|
timeout: mediaMcpTimeoutSec(),
|
|
122
134
|
});
|
|
@@ -141,11 +153,14 @@ async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
|
|
|
141
153
|
return;
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
// Resolve the API key ONCE: stored credential (auth.json) → env fallback.
|
|
157
|
+
// The factory-time ExtensionAPI has NO modelRegistry; stored credentials
|
|
158
|
+
// must be read through the coding-agent's readStoredCredential API.
|
|
159
|
+
const storedKey = resolveStoredNanApiKey();
|
|
160
|
+
const apiKey = storedKey ?? process.env[NAN_API_KEY_ENV];
|
|
144
161
|
|
|
145
|
-
|
|
146
|
-
registerMediaMcpServer(pi);
|
|
147
|
-
|
|
148
|
-
|
|
162
|
+
await registerWebSearchMcpServer(pi, apiKey);
|
|
163
|
+
registerMediaMcpServer(pi, apiKey);
|
|
149
164
|
}
|
|
150
165
|
|
|
151
166
|
// ── Extension entrypoint ─────────────────────────────────────────────────
|
|
@@ -179,6 +194,9 @@ export function registerCrossModelThinkingGuard(pi: ExtensionAPI): void {
|
|
|
179
194
|
|
|
180
195
|
export default async function nanProviderExtension(pi: ExtensionAPI): Promise<void> {
|
|
181
196
|
registerCrossModelThinkingGuard(pi);
|
|
197
|
+
// Replaces pi's cryptic "no loaded extension connects MCP servers" error with
|
|
198
|
+
// actionable guidance when the host loads no built-in `mcp` extension (PI WEB).
|
|
199
|
+
registerMcpHostGuard(pi);
|
|
182
200
|
for (const config of PROVIDERS) {
|
|
183
201
|
await registerProviderCompat(pi, config);
|
|
184
202
|
}
|
package/src/mcp/api-key.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { bridgeSource, resolveBridgeEnabled } from "./state.ts";
|
|
2
|
+
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* NaN API key resolution — shared across every surface (web search, media, usage).
|
|
@@ -7,6 +8,38 @@ import { bridgeSource, resolveBridgeEnabled } from "./state.ts";
|
|
|
7
8
|
* Never logs or embeds the key beyond the Authorization header.
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the NaN API key through pi's stored credential (auth.json).
|
|
13
|
+
* Reads synchronously via `readStoredCredential("nan")` — the factory-time
|
|
14
|
+
* ExtensionAPI has NO modelRegistry (verified pi 0.99.1); stored credentials
|
|
15
|
+
* must be resolved at load time through the coding-agent API.
|
|
16
|
+
*
|
|
17
|
+
* Guards the credential shape: only accepts `{ type: "api_key", key: "sk-..." }`
|
|
18
|
+
* with a non-empty string key. Wraps in try/catch → undefined on any error.
|
|
19
|
+
*
|
|
20
|
+
* Precedence: this function is the **stored-credential** layer; callers compose
|
|
21
|
+
* it with the env var fallback to achieve stored → env order.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveStoredNanApiKey(): string | undefined {
|
|
24
|
+
try {
|
|
25
|
+
const credential = readStoredCredential(NAN_PROVIDER_ID);
|
|
26
|
+
if (
|
|
27
|
+
credential != null &&
|
|
28
|
+
typeof credential === "object" &&
|
|
29
|
+
"type" in credential &&
|
|
30
|
+
credential.type === "api_key" &&
|
|
31
|
+
"key" in credential &&
|
|
32
|
+
typeof credential.key === "string" &&
|
|
33
|
+
credential.key.length > 0
|
|
34
|
+
) {
|
|
35
|
+
return credential.key;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
10
43
|
/** Env var that overrides the official web_search bridge. */
|
|
11
44
|
export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
|
|
12
45
|
/** Env var that overrides the media bridge. */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side MCP connector detection plus the `session_start` guard that keeps
|
|
3
|
+
* this package's registrations from tripping pi's cryptic extension error.
|
|
4
|
+
*
|
|
5
|
+
* pi connects servers registered with `pi.registerMcpServer()` from the
|
|
6
|
+
* built-in `mcp` extension: it registers the `/mcp` command and handles
|
|
7
|
+
* `mcp_servers_change`. When NO loaded extension handles that event, pi reports
|
|
8
|
+
* every registration as an extension error right after the `session_start`
|
|
9
|
+
* emit (`ExtensionRunner.reportUnhandledMcpServers()` called from
|
|
10
|
+
* `AgentSession.bindExtensions()`):
|
|
11
|
+
*
|
|
12
|
+
* MCP server "nan-media" is registered, but no loaded extension connects MCP
|
|
13
|
+
* servers; another extension may have replaced the built-in MCP support
|
|
14
|
+
*
|
|
15
|
+
* Hosts that build pi's `DefaultResourceLoader` without `extensionFactories`
|
|
16
|
+
* load no `builtin:*` extension at all — including `builtin:mcp`. PI WEB does
|
|
17
|
+
* exactly that (`createAgentSessionServices` → `new DefaultResourceLoader(...)`,
|
|
18
|
+
* verified on pi 0.99.1 / pi-web 1.202609.1), so in every PI WEB session this
|
|
19
|
+
* package's registrations are dead and pi reports the error above.
|
|
20
|
+
*
|
|
21
|
+
* Detection: the built-in MCP extension always registers `/mcp`, and pi's
|
|
22
|
+
* replacement contract is phrased in the same terms ("an extension that
|
|
23
|
+
* registers `/mcp` ... replaces the built-in one"). When `pi.getCommands()` is
|
|
24
|
+
* unavailable (older pi, minimal mocks) the answer is unknowable, so we stay
|
|
25
|
+
* conservative and assume a connector exists — pi's own behavior is untouched.
|
|
26
|
+
*
|
|
27
|
+
* With no connector we claim `mcp_servers_change` ourselves: handling the event
|
|
28
|
+
* is pi's signal that somebody connects the servers, so its report stays silent
|
|
29
|
+
* and our warning (with the fix) replaces the cryptic one. The claim is
|
|
30
|
+
* released by a later `session_start` that finds a connector. Known trade-off:
|
|
31
|
+
* while claimed, pi's report is silent for every extension's registration, not
|
|
32
|
+
* only ours — that is the price of replacing an error we cannot scope, and our
|
|
33
|
+
* warning still names the missing connector.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { MCP_SERVER_NAMES } from "./state.ts";
|
|
38
|
+
|
|
39
|
+
/** Built-in MCP command; duplicate registrations resolve to `mcp:<n>`. */
|
|
40
|
+
const MCP_COMMAND_PATTERN = /^mcp(?::\d+)?$/;
|
|
41
|
+
|
|
42
|
+
/** Servers this package registers; only they are ours to explain or claim for. */
|
|
43
|
+
const OWNED_SERVER_NAMES: string[] = Object.values(MCP_SERVER_NAMES);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether some loaded extension connects MCP servers.
|
|
47
|
+
*
|
|
48
|
+
* `true` whenever it cannot be determined (no `pi.getCommands()`), so callers
|
|
49
|
+
* only ever act on a positively missing connector.
|
|
50
|
+
*/
|
|
51
|
+
export function hasMcpConnector(pi: ExtensionAPI): boolean {
|
|
52
|
+
if (typeof pi.getCommands !== "function") return true;
|
|
53
|
+
try {
|
|
54
|
+
const commands = pi.getCommands();
|
|
55
|
+
if (!Array.isArray(commands)) return true;
|
|
56
|
+
return commands.some((command) => MCP_COMMAND_PATTERN.test(command?.name ?? ""));
|
|
57
|
+
} catch {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Actionable explanation of a missing connector, naming the affected servers. */
|
|
63
|
+
export function connectorMissingMessage(serverNames: string[]): string {
|
|
64
|
+
const names = serverNames.map((name) => `\`${name}\``).join(" and ");
|
|
65
|
+
return (
|
|
66
|
+
"MCP connector: MISSING — no loaded extension connects MCP servers in this session " +
|
|
67
|
+
"(pi's built-in `/mcp` command is not loaded), so " +
|
|
68
|
+
`${names} cannot expose tools. Load the built-in connector with \`pi config\` ` +
|
|
69
|
+
"→ Built-in extensions → `mcp`; hosts that load no built-in extensions " +
|
|
70
|
+
"(PI WEB sessions) have no MCP support at all."
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Guard against pi's "registered, but no loaded extension connects MCP servers"
|
|
76
|
+
* error. Registered from the extension entrypoint; see the module comment.
|
|
77
|
+
*/
|
|
78
|
+
export function registerMcpHostGuard(pi: ExtensionAPI): void {
|
|
79
|
+
if (typeof pi.on !== "function") return; // old pi without event hooks
|
|
80
|
+
let releaseClaim: (() => void) | undefined;
|
|
81
|
+
pi.on("session_start", (_event, ctx) => {
|
|
82
|
+
releaseClaim?.();
|
|
83
|
+
releaseClaim = undefined;
|
|
84
|
+
const registered = typeof pi.getMcpServers === "function" ? pi.getMcpServers() : [];
|
|
85
|
+
const owned = registered
|
|
86
|
+
.map((server) => server.name)
|
|
87
|
+
.filter((name) => OWNED_SERVER_NAMES.includes(name));
|
|
88
|
+
// Nothing registered (pi <0.99 guard, gates off) → nothing to explain.
|
|
89
|
+
if (owned.length === 0) return;
|
|
90
|
+
if (hasMcpConnector(pi)) return;
|
|
91
|
+
releaseClaim = pi.on("mcp_servers_change", () => {});
|
|
92
|
+
ctx.ui.notify(connectorMissingMessage(owned), "warning");
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/mcp/state.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface NanProviderState {
|
|
|
25
25
|
|
|
26
26
|
export type BridgeKey = keyof NanProviderState;
|
|
27
27
|
|
|
28
|
+
/** Server name each bridge registers with `pi.registerMcpServer()`. Single source of truth. */
|
|
29
|
+
export const MCP_SERVER_NAMES: Record<BridgeKey, string> = {
|
|
30
|
+
webSearch: "nan-search",
|
|
31
|
+
mediaMcp: "nan-media",
|
|
32
|
+
};
|
|
33
|
+
|
|
28
34
|
export type BridgeSource = "env" | "persisted" | "default";
|
|
29
35
|
|
|
30
36
|
function stateFilePath(): string {
|