@gtrabanco/pi-nan-provider 0.6.1 → 0.6.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/README.es.md CHANGED
@@ -1,176 +1,145 @@
1
1
  # @gtrabanco/pi-nan-provider
2
2
 
3
- Proveedor de modelos de [NaN Builders](https://nan.builders) + puentes MCP para [pi](https://github.com/earendil-works/pi). Registra el proveedor `nan` vía `pi.registerProvider()` usando la API OpenAI-compatible de NaN (`https://api.nan.builders/v1`, LiteLLM por debajo), y conecta las herramientas MCP de NaN en pi con `pi.registerTool()`.
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![Version](https://img.shields.io/badge/version-0.6.3-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
4
5
 
5
- **Documentación en español** (este archivo) · [Documentation in English](README.md)
6
+ [NaN Builders](https://nan.builders) model provider + MCP bridges para [pi](https://github.com/earendil-works/pi).
6
7
 
7
- Consigue tu API key de NaN (enlace de referidos): **<https://cloud.nan.builders/r/7GK06FX8>**
8
+ Registra el proveedor `nan` vía `pi.registerProvider()` usando la API OpenAI-compatible de NaN (`https://api.nan.builders/v1`), y conecta las herramientas MCP de NaN en pi con `pi.registerTool()`.
8
9
 
9
- ## Cómo funciona
10
+ ---
10
11
 
11
- Catálogo de modelos de dos capas, nunca una sola:
12
+ ### ⚡ Inicio Rápido
12
13
 
13
- 1. **Fallback generado** (`scripts/models.generated.ts`, versionado): se genera en build desde [models.dev](https://models.dev) (proveedor `nan`). Es la configuración *servida* por NaN — si el modelo subyacente soporta 2M de contexto pero NaN lo sirve a 1M, el catálogo dice lo que tu clave obtiene, no el máximo teórico del modelo. Cada valor traza a su fuente, y la entrada cruda completa de models.dev se conserva por modelo (`extras`) para no perder ninguna propiedad documentada. Nada se inventa: las entradas incompletas en models.dev se omiten y se marcan.
14
- 2. **Fetch en vivo de `/models`**: el endpoint de NaN solo devuelve los `id` de los modelos, así que se usa para confirmar qué IDs puede llamar tu clave. Los IDs en vivo se combinan con los datos de capacidades generados; un ID en vivo sin datos generados se conserva con límites conservadores de ejemplo (nunca capacidades fabricadas). Ante timeout (~3s), fallo de red, error de auth o respuesta inutilizable, se usa el catálogo generado y el arranque nunca se bloquea.
14
+ 1. **Consigue tu API Key**: [Reclama tu API key de NaN aquí](https://cloud.nan.builders/r/7GK06FX8) (enlace de referidos).
15
+ 2. **Instala**:
16
+ ```bash
17
+ pi install npm:@gtrabanco/pi-nan-provider
18
+ ```
19
+ 3. **Autentica**:
20
+ ```bash
21
+ export NAN_API_KEY="sk-tu-clave-aqui"
22
+ ```
23
+ 4. **Verifica**:
24
+ ```bash
25
+ pi --list-models nan
26
+ ```
15
27
 
16
- **Detección de tier:** con clave configurada, la lista en vivo de `/models` es la autoridad — lista exactamente los modelos que tu membresía de NaN puede llamar, y los modelos ausentes se filtran del conjunto disponible (`filterModels`). Eso incluye los modelos con tier: un modelo de tier premium simplemente no aparece si tu clave no lo tiene. Sin clave (o si falla el fetch), se muestra el catálogo generado completo.
28
+ ---
17
29
 
18
- El registro es síncrono a propósito: el catálogo generado 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.
30
+ **Documentación en español** (este archivo) · [Docs in English](README.md)
19
31
 
20
- ## Instalación
32
+ ## ⚙️ Cómo funciona
21
33
 
22
- ```bash
23
- pi install npm:@gtrabanco/pi-nan-provider
24
- # o, desde git:
25
- pi install git:github.com/gtrabanco/pi-nan-provider
26
- # o, para probarlo sin instalar:
27
- pi -e npm:@gtrabanco/pi-nan-provider
28
- ```
29
-
30
- Después reinicia pi (o `/reload`). Verifica con:
31
-
32
- ```bash
33
- pi --list-models nan
34
- ```
34
+ El proveedor utiliza un **catálogo de modelos de dos capas** para garantizar la fiabilidad:
35
35
 
36
- ## Autenticación
36
+ | Capa | Fuente | Propósito |
37
+ | :--- | :--- | :--- |
38
+ | **1. Fallback generado** | `scripts/models.generated.ts` | Snapshot en tiempo de build desde [models.dev](https://models.dev). Asegura que pi siempre pueda arrancar, incluso si la red falla. |
39
+ | **2. Fetch en vivo de `/models`** | NaN Runtime API | Obtiene los modelos disponibles en tiempo real según el tier de tu API key. Se combina con los datos del fallback. |
37
40
 
38
- `resolve()` comprueba primero la credencial almacenada y después recurre a la variable de entorno correspondiente — la misma precedencia que usan los proveedores nativos de pi. No hace falta ningún prompt si la env var está configurada. Las claves nunca se hardcodean ni se loguean.
41
+ > [!IMPORTANT]
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.
39
43
 
40
- **Opción 1 — variable de entorno (rápida):** con el paquete instalado basta con exportar la clave para tener NaN configurado:
44
+ 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.
41
45
 
42
- ```bash
43
- export NAN_API_KEY="sk-tu-clave-aqui"
44
- ```
46
+ ### 🧠 Seguridad al cambiar de modelo (guard de razonamiento cross-model)
45
47
 
46
- **Opción 2 — `/login` (persistente):** ejecuta `/login nan` en pi y pega tu clave; se guarda en `~/.pi/agent/auth.json`.
48
+ Al cambiar de modelo, pi-ai reenvía el razonamiento del modelo anterior como texto plano de asistente — **sin límite de tamaño**. Un único razonamiento largo o degenerado puede desbordar la ventana de un modelo de 262K, y NaN responde con un `400 Invalid request. Check your request parameters.` genérico que parece un bug del proveedor (seguimiento upstream: [pi-nan-provider#3](https://github.com/gtrabanco/pi-nan-provider/issues/3); issue abierta upstream: [pi#6167](https://github.com/earendil-works/pi/issues/6167)).
47
49
 
48
- **Opción 3 — `~/.pi/agent/auth.json` directamente:**
50
+ Este paquete limita cada bloque de razonamiento **cross-model** a 16.000 caracteres con un marcador visible de truncado. El razonamiento del mismo modelo no se toca nunca, y el guard solo actúa sobre peticiones dirigidas a los proveedores de este paquete. Pon `NAN_THINKING_GUARD=0` para desactivarlo.
49
51
 
50
- ```json
51
- {
52
- "nan": { "type": "api_key", "key": "sk-tu-clave-aqui" }
53
- }
54
- ```
52
+ ## 🔑 Autenticación
55
53
 
56
- Consigue una clave en la [plataforma NaN](https://cloud.nan.builders/r/7GK06FX8) (ajustes de usuario → API Keys; enlace de referidos). La clave es personal e intransferible.
54
+ `resolve()` comprueba primero la credencial almacenada y después recurre a la variable de entorno correspondiente.
57
55
 
58
- ## Puentes MCP
56
+ | Método | Comando / Acción | Notas |
57
+ | :--- | :--- | :--- |
58
+ | **Var de Entorno** | `export NAN_API_KEY="..."` | Lo más rápido para desarrollo local. |
59
+ | **`/login`** | `pi > /login nan` | Persistente; se guarda en `~/.pi/agent/auth.json`. |
60
+ | **Config Manual** | Editar `~/.pi/agent/auth.json` | Manipulación directa de JSON. |
59
61
 
60
- pi no incluye cliente MCP a propósito ("It intentionally does not include built-in MCP" — `docs/usage.md` de pi). Este paquete conecta servidores MCP dentro de pi como herramientas nativas personalizadas, de modo que el LLM las llama como cualquier herramienta integrada.
62
+ Consigue una clave en la [plataforma NaN](https://cloud.nan.builders/r/7GK06FX8) (ajustes de usuario → API Keys; enlace de referidos).
61
63
 
62
- Ambos puentes están **activados y perezosos por defecto**, y se configuran con el comando `/nan-mcp` que trae este paquete (pi no tiene comando `/mcp` propio — no tiene cliente MCP en absoluto — así que el comando se llama `/nan-mcp`):
64
+ ## 🔌 Puentes MCP
63
65
 
64
- | Comando | Efecto |
65
- |---|---|
66
- | `/nan-mcp status` | Estado de ambos puentes y de dónde sale cada interruptor (env / persistido / por defecto) |
67
- | `/nan-mcp enable [target]` | Activa un puente — o ambos si no das target — y lo persiste en `<agentDir>/nan-provider.json` (p. ej. `~/.pi/agent/nan-provider.json`); las herramientas se registran al instante en la sesión actual |
68
- | `/nan-mcp disable [target]` | Desactiva persistentemente; pi no tiene `unregisterTool`, así que las herramientas ya registradas siguen hasta reiniciar; las sesiones futuras no las registran |
66
+ Dado que [pi no incluye un cliente MCP integrado](https://github.com/earendil-works/pi/blob/main/docs/usage.md), este paquete conecta los servidores MCP como **herramientas nativas de pi**.
69
67
 
70
- Targets: `web-search` (puente oficial; alias `search`) y `nan-mcp-server` (puente de media de la comunidad; alias `media`). Ejemplo: `/nan-mcp enable nan-mcp-server`. Las variables de entorno explícitas tienen prioridad sobre los toggles persistidos (ver tabla inferior).
68
+ Ambos puentes están **activados y son perezosos (lazy) por defecto**. Usa `/nan-mcp` para gestionarlos.
71
69
 
72
- ### 1. Servidor MCP oficial de NaN (por defecto: activado, perezoso)
70
+ ### 🛠️ Comando de Gestión: `/nan-mcp`
73
71
 
74
- El servidor MCP remoto oficial de NaN ([`https://api.nan.builders/mcp`](https://nan.builders/docs/api), JSON-RPC 2.0 sobre HTTP, misma clave `sk-`, mismo límite de tasa/cuota/concurrencia que la API REST) se conecta como:
72
+ | Comando | Efecto |
73
+ | :--- | :--- |
74
+ | `/nan-mcp status` | Muestra el estado actual de ambos puentes. |
75
+ | `/nan-mcp enable [target]` | Activa `web-search` o `nan-mcp-server` (persiste). |
76
+ | `/nan-mcp disable [target]` | Desactiva un puente de forma persistente. |
75
77
 
76
- - **`nan_web_search(query, count?, freshness?, fetch_content?)`** — búsqueda web vía NaN. La llamada HTTP solo ocurre cuando se invoca la herramienta.
78
+ ---
77
79
 
78
- El servidor es un registro en crecimiento (descubrible con `tools/list`); este paquete conecta por ahora la herramienta documentada `web_search` y mantiene un helper genérico `callNanMcpTool()` para herramientas futuras.
80
+ ### 1. Servidor MCP oficial de NaN
81
+ *Puente oficial para herramientas remotas vía [https://api.nan.builders/mcp](https://nan.builders/docs/api).*
79
82
 
80
- ### 2. Servidor MCP de media de la comunidad (por defecto: activado, perezoso)
83
+ - **`nan_web_search(query, ...)`**: Realiza búsquedas web a través del gateway de NaN.
81
84
 
82
- [`nan-mcp-server`](https://github.com/luciferfran/nan-mcp-server) es un servidor MCP stdio que expone las herramientas de media de NaN: generación/edición de imágenes (flux-2-klein), TTS (kokoro) y STT (whisper). Como pi no tiene cliente MCP, este paquete lo conecta como herramientas de pi mediante un cliente MCP stdio mínimo:
85
+ ### 2. Servidor MCP de Media (Comunidad)
86
+ *Conecta [`nan-mcp-server`](https://github.com/luciferfran/nan-mcp-server) mediante un cliente stdio local mínimo.*
83
87
 
84
- - **Activado por defecto**, conmutado persistentemente con `/nan-mcp enable|disable nan-mcp-server` (o `media`), o por sesión con `NAN_MEDIA_MCP` (cualquier valor explícito — p. ej. `NAN_MEDIA_MCP=0` — tiene prioridad sobre el toggle persistido).
85
- - **Perezoso (lazy)**: el proceso del servidor MCP se lanza *por cada llamada* y se termina justo después. No arranca ni conecta nada a menos que se invoque realmente generación de audio/imagen/transcripción.
86
- - **Configuración**: `NAN_API_KEY` se reenvía automáticamente (la misma clave del proveedor); los ficheros generados van a `~/nan-mcp-output/` (por defecto del servidor, configurable con `NAN_OUTPUT_DIR`).
88
+ - **Carga Perezosa (Lazy)**: El proceso del servidor se lanza **solo** cuando se invoca una herramienta y se cierra inmediatamente después.
89
+ - **Configuración**: Los archivos se guardan en `~/nan-mcp-output/`.
87
90
 
88
91
  | Herramienta | Propósito |
89
- |---|---|
90
- | `nan_generate_image(prompt, size?, n?, seed?, guidance?, outputName?)` | Generar una imagen (flux-2-klein) |
91
- | `nan_edit_image(prompt, images, size?, n?, seed?, guidance?, outputName?)` | Editar una imagen imagen→imagen (flux-2-klein) |
92
- | `nan_text_to_speech(text, voice?, format?, speed?, outputName?)` | Sintetizar audio (kokoro) |
93
- | `nan_list_voices()` | Listar voces kokoro por idioma |
94
- | `nan_speech_to_text(file, language?, verbose?)` | Transcribir audio (whisper) |
92
+ | :--- | :--- |
93
+ | `nan_generate_image` | Generación de imágenes (flux-2-klein) |
94
+ | `nan_edit_image` | Edición imagen→imagen (flux-2-klein) |
95
+ | `nan_text_to_speech` | Síntesis de audio (kokoro) |
96
+ | `nan_list_voices` | Listar voces disponibles |
97
+ | `nan_speech_to_text` | Transcripción de audio (whisper) |
95
98
 
96
- Variables de entorno:
99
+ #### 🔧 Configuración del Puente de Media
97
100
 
98
- | Variable | Por defecto | Significado |
99
- |---|---|---|
100
- | `NAN_MEDIA_MCP` | — | Override por sesión del puente de media: cualquier valor explícito (incl. `0`) gana al toggle persistido de `/nan-mcp`; sin definir → persistido/por defecto |
101
- | `NAN_MEDIA_MCP_VERSION` | `1.0.8` | Versión del servidor fijada para `npx -y nan-mcp-server@<v>` (recomendación de supply-chain del propio proyecto) |
102
- | `NAN_MEDIA_MCP_COMMAND` | — | Comando personalizado completo, p. ej. `bunx nan-mcp-server@1.0.8` |
101
+ | Variable | Por defecto | Descripción |
102
+ | :--- | :--- | :--- |
103
+ | `NAN_MEDIA_MCP` | — | Override por sesión (`0` o `false` para desactivar). |
104
+ | `NAN_MEDIA_MCP_VERSION` | `1.0.8` | Versión del servidor fijada (recomendado). |
105
+ | `NAN_MEDIA_MCP_COMMAND` | — | Override del comando personalizado. |
106
+ | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Timeout por llamada. |
107
+ | `NAN_MCP_TOOLS` | — | Override para el puente oficial (`0` para desactivar). |
103
108
 
104
- #### Detección automatizada de actualizaciones
109
+ #### 🤖 Detección Automática de Actualizaciones
105
110
 
106
- Una nueva versión de `nan-mcp-server` no desviará silenciosamente el pin de esta conexión. El
107
- planificador (`.github/workflows/check-nan-mcp-server-update.yml`) ejecuta `bun run scripts/check-nan-mcp-server.ts`
108
- semanalmente y, cuando el registro npm muestra una versión más reciente, abre (o refresca) un único
109
- issue etiquetado `dependencies` que describe si el bump es **breaking** o **seguro**, más la lista de
110
- commits aguas arriba. Puedes ejecutarlo localmente en cualquier momento:
111
+ Una nueva versión de `nan-mcp-server` no desviará silenciosamente el pin de esta conexión. El planificador (`.github/workflows/check-nan-mcp-server-update.yml`) se ejecuta semanalmente y, cuando encuentra una versión nueva, abre un issue indicando si el cambio es **breaking** o **seguro**.
111
112
 
112
113
  ```bash
113
- bun run check-nan-mcp-server # informe legible
114
- bun run check-nan-mcp-server --json # JSON para máquina
115
- bun run check-nan-mcp-server --issue # crea/refresca el issue (requiere GITHUB_TOKEN)
114
+ bun run check-nan-mcp-server # reporte legible
115
+ bun run check-nan-mcp-server --json # JSON para máquinas
116
+ bun run check-nan-mcp-server --issue # crea/refresca el issue
116
117
  ```
117
118
 
118
- La decisión se toma a partir de la *superficie de tools* en vivo (vía unpkg), no de docs obsoletos: si
119
- el último servidor sigue exponiendo todas las tools conectadas (`generate_image`, `edit_image`,
120
- `text_to_speech`, `list_voices`, `speech_to_text`), el bump se reporta como **no rompente**; si elimina
121
- o renombra alguna tool conectada, el issue se marca **breaking** para revisión manual antes de subir.
122
- | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Timeout por llamada; el proceso se mata al expirar |
123
- | `NAN_MCP_TOOLS` | — | Override por sesión del puente oficial: `0`/`false`/`off` desactiva `nan_web_search`; sin definir → persistido/por defecto |
124
-
125
- ## Modelos
126
-
127
- Catálogo base (de models.dev, proveedor `nan`, obtenido 2026-09-07 y corregido contra [los docs de NaN](https://nan.builders/docs/models) y [openapi.json](https://nan.builders/openapi.json) — límites *servidos* por NaN, no máximos teóricos):
128
-
129
- | Modelo | Contexto | Máx. salida | Entrada | Razonamiento |
130
- |---|---|---|---|---|
131
- | `qwen3.6` | 262,144 | 65,536 | texto, imagen | sí |
132
- | `gemma4` | 262,144 | 32,768 | texto, imagen | sí |
133
- | `deepseek-v4-flash` | 1,000,000 | 384,000 | texto, imagen | sí |
134
- | `mimo-v2.5` | 1,048,576 | 131,072 | texto, imagen | sí |
135
- | `glm5.3-flash` | 1,000,000 | 131,072 | texto, imagen | sí |
136
- | `qwen3.8-flash` | 262,144 | 131,072 | texto, imagen | sí |
137
-
138
- Notas (grabadas por entrada en `scripts/models.generated.ts`):
139
-
140
- - `qwen3.8-flash` sirve 262K tokens, «la ventana nativa del modelo» ([docs de NaN](https://nan.builders/docs/models), 2026-09-07). Un override previo de 1M (confirmado por el mantenedor el 2026-09-05) se retiró cuando los docs actualizados siguieron diciendo 262K; models.dev coincide en 262,144. Este tipo de divergencias se registran como `MANUAL_OVERRIDES` en tiempo de build (con procedencia) en `scripts/manual-overrides.ts` — añade una ahí en vez de editar el fichero generado.
141
- - `deepseek-v4-flash` incluye entrada de imagen porque NaN sirve la variante Vision-Exp ([docs de NaN](https://nan.builders/docs/models), confirmado por los content-parts de visión en [openapi.json](https://nan.builders/openapi.json)); models.dev la lista como solo texto.
142
- - `glm5.2` fue eliminado por NaN (2026-09-05). models.dev aún lo listaba el 2026-09-07, así que el generador lo excluye vía `PROVIDER_REMOVED_MODEL_IDS` con la razón registrada — una regeneración no debe resucitar modelos retirados por el proveedor.
143
- - `mimo-v2.5` es omnimodal (texto/imagen/audio) en NaN, pero el tipo de modelo de pi solo representa entrada texto/imagen, así que el audio se omite en `input`.
144
- - NaN factura por cuota de membresía, que models.dev reporta como coste cero por token — el coste mostrado por pi será $0.
145
- - Compat (`supportsDeveloperRole: false`, `supportsReasoningEffort: true`, `supportsUsageInStreaming: true`, `maxTokensField: "max_tokens"`) coincide con la config LiteLLM probada en batalla que este paquete reemplaza; el ejemplo de los docs de NaN (`supportsDeveloperRole: true`) no está probado.
146
- - **Tier/cuota**: qué modelos puedes llamar lo decide tu membresía de NaN. Con clave, el fetch en vivo refleja exactamente eso (ver *Cómo funciona* — detección de tier). El `glm5.3` de tier premium no está en el proveedor `nan` de models.dev y ninguna fuente documenta su límite de salida, así que no entra en el catálogo estático (marcado como no emitible en los metadatos); las claves premium lo reciben en vivo vía el refresh de `/models`, con límites conservadores (128K contexto / 4K salida). Solo está `glm5.3-flash` en el catálogo estático.
147
-
148
- ### Relación con `~/.pi/agent/models.json`
149
-
150
- Este paquete reemplaza el bloque `nan` manual de `~/.pi/agent/models.json` (el [ejemplo pi](https://nan.builders/docs/examples) de los docs de NaN). Si conservas ese bloque, ten en cuenta que **models.json se compone por encima de los proveedores registrados** — el fichero estático gana sobre este paquete. Elimina la entrada `nan` de `models.json` (conserva `defaultProvider`/`defaultModel` en `settings.json` si los usas) para usar el catálogo en vivo de este paquete. Los topes de salida por petición pueden seguir configurándose ahí o vía `params` del modelo.
151
-
152
- ## Compatibilidad con versiones de pi
119
+ ---
153
120
 
154
- Verificado contra pi **0.83.0**, **0.84.4** y la línea 0.85 (`registerProvider(provider)`, `registerProvider(name, config)`, `registerTool` y `modelRegistry.getApiKeyForProvider` presentes en ambas; el entrypoint compat de pi-ai reexporta la fábrica de la API openai-completions en 0.83 y 0.84 por igual). La extensión degrada con elegancia entre versiones:
121
+ ## 📊 Modelos
155
122
 
156
- - **Ruta nativa**: Provider completo con auth credencial-almacenada-primero-then-env, overlay de catálogo en vivo y filtrado por tier.
157
- - **Fallback legacy**: si el overload nativo de Provider es rechazado (o la construcción del proveedor falla), el registro cae a la forma legacy documentada `(name, config)` con el mismo catálogo generado y auth por env `$NAN_API_KEY` (la auth por credencial almacenada es una limitación del camino legacy, no un cambio silencioso).
158
- - **Puentes MCP**: se omiten por completo en runtimes sin `registerTool`; los proveedores se registran igualmente.
159
- - **Entrada asíncrona**: pi espera las factorías de extensión en 0.83 y 0.84 por igual, así que la resolución de la API de streaming durante el registro es transparente.
160
- - `peerDependencies` es `>=0.83.0` sin límite superior (incluidos los forks en 0.83).
161
- - **Imports de pi-ai en la extensión**: solo se importa estáticamente el root `@earendil-works/pi-ai`. El loader de extensiones de pi aliasa ese especificador al entrypoint compat; los imports por subruta (p. ej. `@earendil-works/pi-ai/api/openai-completions.lazy`) reciben el alias como prefijo y no resuelven, lo que rompe la carga de toda la extensión. Protegido por `test/extension-load.test.ts`.
123
+ Catálogo base (verificado contra [docs de NaN](https://nan.builders/docs/models) y [OpenAPI](https://nan.builders/openapi.json)).
162
124
 
163
- ## helmcode
125
+ | Modelo | Contexto | Máx. Salida | Entrada | Razonamiento |
126
+ | :--- | :--- | :--- | :--- | :---: |
127
+ | `qwen3.6` | 262,144 | 65,536 | texto, imagen | ✅ |
128
+ | `gemma4` | 262,144 | 32,768 | texto, imagen | ✅ |
129
+ | `deepseek-v4-flash` | 1,000,000 | 384,000 | texto, imagen | ✅ |
130
+ | `mimo-v2.5` | 1,048,576 | 131,072 | texto, imagen | ✅ |
131
+ | `glm5.3-flash` | 1,000,000 | 131,072 | texto, imagen | ✅ |
132
+ | `qwen3.8-flash` | 262,144 | 131,072 | texto, imagen | ✅ |
164
133
 
165
- La fábrica compartida (`src/provider-factory.ts`) es agnóstica del proveedor, pero `helmcode` **no está registrado**: no existe una URL base ni fuente de capacidades confirmadas (ausente de models.dev y de los docs de NaN), y este repo no fabrica datos de proveedores. Cuando se confirme un endpoint, registrarlo es una entrada en `src/providers.ts` más datos de catálogo — sin una segunda implementación. Un test de contrato (`factory is shared`) ya ejercita un segundo proveedor por el mismo camino de código.
134
+ ---
166
135
 
167
- ## Desarrollo
136
+ ## 🚀 Desarrollo
168
137
 
169
138
  ```bash
170
139
  bun install
171
- bun run generate-models # regenerar el catálogo fallback desde models.dev (pre-publish)
172
- bun test # tests unitarios + integración (fetch, auth, puentes MCP, compat)
173
- bun run typecheck # typecheck vía bunx (tsc local, se autoinstala si falta)
140
+ bun run generate-models # Regenerar catálogo de fallback
141
+ bun test # Ejecutar todos los tests
142
+ bun run typecheck # Ejecutar typechecking
174
143
  ```
175
144
 
176
- `prepublishOnly` ejecuta generación + tests + typecheck. El typecheck resuelve `tsc` vía `bunx` porque `bun publish` ejecuta los scripts de ciclo de vida sin `node_modules/.bin` en el PATH (un `tsc` pelado falla ahí con exit 127). Las releases siguen semver estricto (ver `AGENTS.md`); CI publica cuando un merge a main cambia código y la versión. Ver `CONTRIBUTING.md` para el flujo completo de contribución.
145
+ *Las versiones siguen semver estricto. CI publica automáticamente al hacer merge a `main`.*
package/README.md CHANGED
@@ -1,176 +1,145 @@
1
1
  # @gtrabanco/pi-nan-provider
2
2
 
3
- [NaN Builders](https://nan.builders) model provider + MCP bridges for [pi](https://github.com/earendil-works/pi). Registers the `nan` provider via `pi.registerProvider()` using NaN's OpenAI-compatible API (`https://api.nan.builders/v1`, LiteLLM behind it), and bridges NaN's MCP tools into pi with `pi.registerTool()`.
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![Version](https://img.shields.io/badge/version-0.6.3-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
4
5
 
5
- **Docs in English** (this file) · [Documentación en español](README.es.md)
6
+ [NaN Builders](https://nan.builders) model provider + MCP bridges for [pi](https://github.com/earendil-works/pi).
6
7
 
7
- Get your NaN API key (referral link): **<https://cloud.nan.builders/r/7GK06FX8>**
8
+ Registers the `nan` provider via `pi.registerProvider()` using NaN's OpenAI-compatible API (`https://api.nan.builders/v1`), and bridges NaN's MCP tools into pi with `pi.registerTool()`.
8
9
 
9
- ## How it works
10
+ ---
10
11
 
11
- Two-layer model catalog, never either alone:
12
+ ### ⚡ Quick Start
12
13
 
13
- 1. **Generated fallback** (`scripts/models.generated.ts`, committed): pulled at build time from [models.dev](https://models.dev) (provider `nan`). This is NaN's *served* configuration — if the underlying model can do 2M context but NaN serves it at 1M, the catalog says what your key gets, not the raw model maximum. Every value traces to its source, and the full raw models.dev entry is preserved per model (`extras`) so no documented property is lost. Nothing is invented: entries models.dev documents incompletely are omitted and flagged.
14
- 2. **Live `/models` fetch** at runtime: NaN's endpoint returns only model `id`s, so it is used to confirm which IDs your key can actually call. Live IDs are merged with the generated capability data; a live ID without generated data is kept with conservative placeholder limits (never fabricated capabilities). On timeout (~3s), network failure, auth error, or an unusable response, the generated catalog is used and startup is never blocked.
14
+ 1. **Get an API Key**: [Claim your NaN API key here](https://cloud.nan.builders/r/7GK06FX8) (referral link).
15
+ 2. **Install**:
16
+ ```bash
17
+ pi install npm:@gtrabanco/pi-nan-provider
18
+ ```
19
+ 3. **Authenticate**:
20
+ ```bash
21
+ export NAN_API_KEY="sk-your-key-here"
22
+ ```
23
+ 4. **Verify**:
24
+ ```bash
25
+ pi --list-models nan
26
+ ```
15
27
 
16
- **Tier detection:** with a key configured, the live `/models` list is authoritative — it lists exactly the models your NaN membership can call, and models absent from it are filtered out of the available set (`filterModels`). That includes tier-gated models: a premium-tier model simply does not appear unless your key has the tier. Without a key (or if the fetch fails), the full generated catalog is shown.
28
+ ---
17
29
 
18
- 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.
30
+ **Docs in English** (this file) · [Documentación en español](README.es.md)
19
31
 
20
- ## Install
32
+ ## ⚙️ How it works
21
33
 
22
- ```bash
23
- pi install npm:@gtrabanco/pi-nan-provider
24
- # or, from git:
25
- pi install git:github.com/gtrabanco/pi-nan-provider
26
- # or, to try it without installing:
27
- pi -e npm:@gtrabanco/pi-nan-provider
28
- ```
34
+ The provider uses a **two-layer model catalog** to ensure reliability:
29
35
 
30
- Then restart pi (or `/reload`). Verify with:
36
+ | Layer | Source | Purpose |
37
+ | :--- | :--- | :--- |
38
+ | **1. Generated Fallback** | `scripts/models.generated.ts` | Build-time snapshot from [models.dev](https://models.dev). Ensures pi can always start, even if the network fails. |
39
+ | **2. Live `/models` Fetch** | NaN Runtime API | Fetches your real-time available models based on your API key's tier. Merged with fallback data. |
31
40
 
32
- ```bash
33
- pi --list-models nan
34
- ```
41
+ > [!IMPORTANT]
42
+ > **Tier Detection**: The live list is authoritative. If your key has premium access, those models will appear automatically; otherwise, they are filtered out.
35
43
 
36
- ## Authentication
44
+ 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.
37
45
 
38
- `resolve()` checks the stored credential first, then falls back to the matching env var — the same precedence pi's built-in providers use. No prompt is needed when the env var is set. Keys are never hardcoded or logged.
46
+ ### 🧠 Model-switch safety (cross-model reasoning guard)
39
47
 
40
- **Option 1 — env var (quick):** having the package installed is enough; just export the key and NaN is configured:
48
+ When you switch models, pi-ai replays the previous model's reasoning as plain assistant text — with **no size bound**. A single long or degenerate reasoning trace can therefore overflow a 262K-context model's window, and NaN answers with a generic `400 Invalid request. Check your request parameters.` that looks like a provider bug (upstream tracking: [pi-nan-provider#3](https://github.com/gtrabanco/pi-nan-provider/issues/3); open upstream issue: [pi#6167](https://github.com/earendil-works/pi/issues/6167)).
41
49
 
42
- ```bash
43
- export NAN_API_KEY="sk-your-key-here"
44
- ```
50
+ This package caps every replayed **cross-model** reasoning block at 16,000 chars with a visible truncation marker. Same-model reasoning is never altered, and the guard only acts on requests targeting this package's providers. Set `NAN_THINKING_GUARD=0` to disable it.
45
51
 
46
- **Option 2 — `/login` (persistent):** run `/login nan` in pi and paste your key; it is stored in `~/.pi/agent/auth.json`.
52
+ ## 🔑 Authentication
47
53
 
48
- **Option 3 — `~/.pi/agent/auth.json` directly:**
54
+ `resolve()` checks the stored credential first, then falls back to the matching environment variable.
49
55
 
50
- ```json
51
- {
52
- "nan": { "type": "api_key", "key": "sk-your-key-here" }
53
- }
54
- ```
56
+ | Method | Command / Action | Notes |
57
+ | :--- | :--- | :--- |
58
+ | **Env Var** | `export NAN_API_KEY="..."` | Fastest for local development. |
59
+ | **`/login`** | `pi > /login nan` | Persistent; stores in `~/.pi/agent/auth.json`. |
60
+ | **Manual Config** | Edit `~/.pi/agent/auth.json` | Direct JSON manipulation. |
55
61
 
56
- Get a key from the [NaN platform](https://cloud.nan.builders/r/7GK06FX8) (user settings → API Keys; referral link). The key is personal and non-transferable.
62
+ Get a key from the [NaN platform](https://cloud.nan.builders/r/7GK06FX8) (user settings → API Keys; referral link).
57
63
 
58
- ## MCP bridges
64
+ ## 🔌 MCP Bridges
59
65
 
60
- pi intentionally ships without an MCP client ("It intentionally does not include built-in MCP" — pi's `docs/usage.md`). This package bridges MCP servers into pi as native custom tools, so the LLM calls them like any built-in tool.
66
+ Since [pi does not include a built-in MCP client](https://github.com/earendil-works/pi/blob/main/docs/usage.md), this package bridges MCP servers as **native pi tools**.
61
67
 
62
- Both bridges are **enabled and lazy by default** and are configured with the `/nan-mcp` slash command this package ships (pi has no `/mcp` command of its own — it has no MCP client at all — so the command is namespaced `/nan-mcp`):
68
+ Both bridges are **enabled and lazy by default**. Use `/nan-mcp` to manage them.
63
69
 
64
- | Command | Effect |
65
- |---|---|
66
- | `/nan-mcp status` | State of both bridges and where each toggle comes from (env / persisted / default) |
67
- | `/nan-mcp enable [target]` | Enable a bridge — or both when no target is given — and persist it in `<agentDir>/nan-provider.json` (e.g. `~/.pi/agent/nan-provider.json`); tools register immediately for the current session |
68
- | `/nan-mcp disable [target]` | Disable persistently; pi has no `unregisterTool`, so already-registered tools remain until restart, future sessions skip them |
70
+ ### 🛠️ Management Command: `/nan-mcp`
69
71
 
70
- Targets: `web-search` (official bridge) and `nan-mcp-server` (community media bridge; alias `media`). Example: `/nan-mcp enable nan-mcp-server`. Explicit env vars override the persisted toggles for the session (see the table below).
71
-
72
- ### 1. Official NaN MCP server (default: enabled, lazy)
73
-
74
- NaN's official remote MCP server ([`https://api.nan.builders/mcp`](https://nan.builders/docs/api), JSON-RPC 2.0 over HTTP, same `sk-` key, same rate limit/quota/concurrency as the REST API) is bridged as:
72
+ | Command | Effect |
73
+ | :--- | :--- |
74
+ | `/nan-mcp status` | Shows current state of both bridges. |
75
+ | `/nan-mcp enable [target]` | Enables `web-search` or `nan-mcp-server` (persisted). |
76
+ | `/nan-mcp disable [target]` | Disables a bridge persistently. |
75
77
 
76
- - **`nan_web_search(query, count?, freshness?, fetch_content?)`** — web search through NaN. The HTTP call happens only when the tool is invoked.
78
+ ---
77
79
 
78
- The server is a growing registry (discover with `tools/list`); this package currently bridges the documented `web_search` tool and keeps a generic `callNanMcpTool()` helper for future tools.
80
+ ### 1. Official NaN MCP Server
81
+ *Official bridge for remote tools via [https://api.nan.builders/mcp](https://nan.builders/docs/api).*
79
82
 
80
- ### 2. Community media MCP server (default: enabled, lazy)
83
+ - **`nan_web_search(query, ...)`**: Performs web searches through NaN's gateway.
81
84
 
82
- [`nan-mcp-server`](https://github.com/luciferfran/nan-mcp-server) is a stdio MCP server exposing NaN's media tools: image generation/editing (flux-2-klein), TTS (kokoro), and STT (whisper). Because pi has no MCP client, this package bridges it as pi tools via a minimal built-in MCP stdio client:
85
+ ### 2. Community Media MCP Server
86
+ *Bridges [`nan-mcp-server`](https://github.com/luciferfran/nan-mcp-server) via a minimal local stdio client.*
83
87
 
84
- - **Enabled by default**, toggled persistently with `/nan-mcp enable|disable nan-mcp-server` (or `media`), or per-session with `NAN_MEDIA_MCP` (any explicit value — e.g. `NAN_MEDIA_MCP=0` — overrides the persisted toggle).
85
- - **Lazy**: the MCP server process is spawned *per tool call* and terminated immediately after. Nothing starts, connects, or costs anything unless audio/image/transcription is actually invoked.
86
- - **Config**: `NAN_API_KEY` is forwarded automatically (same key as the provider); generated files land in `~/nan-mcp-output/` (the server's default, override with `NAN_OUTPUT_DIR`).
88
+ - **Lazy Loading**: The server process is spawned **only** when a tool is invoked and terminated immediately after.
89
+ - **Config**: Files land in `~/nan-mcp-output/`.
87
90
 
88
91
  | Tool | Purpose |
89
- |---|---|
90
- | `nan_generate_image(prompt, size?, n?, seed?, guidance?, outputName?)` | Generate an image (flux-2-klein) |
91
- | `nan_edit_image(prompt, images, size?, n?, seed?, guidance?, outputName?)` | Edit an image image→image (flux-2-klein) |
92
- | `nan_text_to_speech(text, voice?, format?, speed?, outputName?)` | Synthesize audio (kokoro) |
93
- | `nan_list_voices()` | List kokoro voices by language |
94
- | `nan_speech_to_text(file, language?, verbose?)` | Transcribe audio (whisper) |
92
+ | :--- | :--- |
93
+ | `nan_generate_image` | Image generation (flux-2-klein) |
94
+ | `nan_edit_image` | Image-to-image editing (flux-2-klein) |
95
+ | `nan_text_to_speech` | Audio synthesis (kokoro) |
96
+ | `nan_list_voices` | List available voices |
97
+ | `nan_speech_to_text` | Audio transcription (whisper) |
95
98
 
96
- Environment variables:
99
+ #### 🔧 Media Bridge Configuration
97
100
 
98
- | Variable | Default | Meaning |
99
- |---|---|---|
100
- | `NAN_MEDIA_MCP` | — | Per-session override for the media bridge: any explicit value (incl. `0`) beats the `/nan-mcp` persisted toggle; unset → persisted/default |
101
- | `NAN_MEDIA_MCP_VERSION` | `1.0.8` | Pinned server version for `npx -y nan-mcp-server@<v>` (upstream's own supply-chain recommendation) |
102
- | `NAN_MEDIA_MCP_COMMAND` | — | Full custom command, e.g. `bunx nan-mcp-server@1.0.8` |
101
+ | Variable | Default | Description |
102
+ | :--- | :--- | :--- |
103
+ | `NAN_MEDIA_MCP` | — | Per-session override (`0` or `false` to disable). |
104
+ | `NAN_MEDIA_MCP_VERSION` | `1.0.8` | Pinned server version (recommended). |
105
+ | `NAN_MEDIA_MCP_COMMAND` | — | Custom command override. |
106
+ | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Per-call timeout. |
107
+ | `NAN_MCP_TOOLS` | — | Override for the official bridge (`0` to disable). |
103
108
 
104
- #### Automated update detection
109
+ #### 🤖 Automated Update Detection
105
110
 
106
- A newer `nan-mcp-server` release won't silently drift this bridge's pin. The scheduler
107
- (`.github/workflows/check-nan-mcp-server-update.yml`) runs `bun run scripts/check-nan-mcp-server.ts`
108
- weekly and, when the npm registry shows a newer version, opens (or refreshes) one
109
- `dependencies`-labelled issue describing whether the bump is **breaking** or **safe**, plus the
110
- upstream commit list. Run it locally any time:
111
+ A newer `nan-mcp-server` release won't silently drift this bridge's pin. The scheduler (`.github/workflows/check-nan-mcp-server-update.yml`) runs weekly and, when a newer version is found, opens an issue describing if the bump is **breaking** or **safe**.
111
112
 
112
113
  ```bash
113
114
  bun run check-nan-mcp-server # human-readable report
114
115
  bun run check-nan-mcp-server --json # machine-readable JSON
115
- bun run check-nan-mcp-server --issue # create/refresh the issue (needs GITHUB_TOKEN)
116
+ bun run check-nan-mcp-server --issue # create/refresh the issue
116
117
  ```
117
118
 
118
- The decision is made from the *live* server tool surface (via unpkg), not from stale docs: if the
119
- latest server still exposes every bridged tool (`generate_image`, `edit_image`, `text_to_speech`,
120
- `list_voices`, `speech_to_text`), the bump is reported as **non-breaking**; if it drops or renames a
121
- bridged tool, the issue is flagged **breaking** for manual review before bumping.
122
- | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Per-call timeout; the process is killed after it |
123
- | `NAN_MCP_TOOLS` | — | Per-session override for the official bridge: `0`/`false`/`off` disables `nan_web_search`; unset → persisted/default |
124
-
125
- ## Models
126
-
127
- Baseline catalog (from models.dev, provider `nan`, fetched 2026-09-07 and corrected against [NaN's docs](https://nan.builders/docs/models) and [openapi.json](https://nan.builders/openapi.json) — NaN's *served* limits, not raw model maxima):
128
-
129
- | Model | Context | Max output | Input | Reasoning |
130
- |---|---|---|---|---|
131
- | `qwen3.6` | 262,144 | 65,536 | text, image | yes |
132
- | `gemma4` | 262,144 | 32,768 | text, image | yes |
133
- | `deepseek-v4-flash` | 1,000,000 | 384,000 | text, image | yes |
134
- | `mimo-v2.5` | 1,048,576 | 131,072 | text, image | yes |
135
- | `glm5.3-flash` | 1,000,000 | 131,072 | text, image | yes |
136
- | `qwen3.8-flash` | 262,144 | 131,072 | text, image | yes |
137
-
138
- Notes (recorded per entry in `scripts/models.generated.ts`):
139
-
140
- - `qwen3.8-flash` serves 262K tokens, "the model's native window" ([NaN docs](https://nan.builders/docs/models), 2026-09-07). An earlier 1M override (maintainer-confirmed 2026-09-05) was withdrawn once the updated docs still said 262K; models.dev agrees at 262,144. Divergences like this are recorded as build-time `MANUAL_OVERRIDES` (with provenance) in `scripts/manual-overrides.ts` — apply one instead of editing the generated file.
141
- - `deepseek-v4-flash` includes image input because NaN serves the Vision-Exp variant ([NaN docs](https://nan.builders/docs/models), confirmed by the vision content-parts in [openapi.json](https://nan.builders/openapi.json)); models.dev lists text only.
142
- - `glm5.2` was removed by NaN (2026-09-05). models.dev still listed it on 2026-09-07, so the generator excludes it via `PROVIDER_REMOVED_MODEL_IDS` with a recorded reason — a regeneration must not resurrect provider-removed models.
143
- - `mimo-v2.5` is omnimodal (text/image/audio) on NaN, but pi's model type only represents text/image input, so audio is dropped from `input`.
144
- - NaN bills via membership quota, which models.dev reports as zero per-token cost — pi's cost display will read $0.
145
- - Compat (`supportsDeveloperRole: false`, `supportsReasoningEffort: true`, `supportsUsageInStreaming: true`, `maxTokensField: "max_tokens"`) matches the battle-tested LiteLLM config this package replaces; NaN's docs example (`supportsDeveloperRole: true`) is not battle-tested.
146
- - **Tier/quota**: which models you can call is decided by your NaN membership. With a key, the live fetch reflects exactly that (see *How it works* — tier detection). The premium-tier `glm5.3` is absent from the models.dev `nan` provider and no source documents its max output tokens, so it is not in the static catalog (flagged as unemittable in the catalog metadata); premium keys still get it live via the `/models` refresh with conservative placeholder limits (128K context / 4K output). Only `glm5.3-flash` is in the static catalog.
147
-
148
- ### Relationship to `~/.pi/agent/models.json`
149
-
150
- This package replaces the hand-written `nan` block in `~/.pi/agent/models.json` (the NaN docs [pi example](https://nan.builders/docs/examples)). If you keep that block, be aware that **models.json overrides compose above registered providers** — the static file wins over this package. Remove the `nan` entry from `models.json` (keep `defaultProvider`/`defaultModel` in `settings.json` if you use them) to use the live catalog from this package. Per-request output caps can still be set there or via model `params`.
151
-
152
- ## pi version compatibility
119
+ ---
153
120
 
154
- Verified against pi **0.83.0**, **0.84.4**, and the 0.85 line (`registerProvider(provider)`, `registerProvider(name, config)`, `registerTool`, and `modelRegistry.getApiKeyForProvider` all present in both; pi-ai's compat entrypoint re-exports the openai-completions API factory on 0.83 and 0.84 alike). The extension degrades gracefully across versions:
121
+ ## 📊 Models
155
122
 
156
- - **Native path**: full Provider with stored-credential-then-env auth, live catalog overlay, and tier filtering.
157
- - **Legacy fallback**: if the native Provider overload is rejected (or provider construction fails), registration falls back to the documented legacy `(name, config)` form with the same generated catalog and `$NAN_API_KEY` env auth (stored-credential auth is a limitation of the legacy path, not a silent behavior change).
158
- - **MCP bridges**: skipped entirely on runtimes without `registerTool`; providers still register.
159
- - **Async entrypoint**: pi awaits extension factories on 0.83 and 0.84 alike, so the streaming-API resolution at registration is transparent.
160
- - `peerDependencies` is `>=0.83.0` with no upper bound (0.83 forks included).
161
- - **Extension-side pi-ai imports**: only the bare `@earendil-works/pi-ai` root is imported statically. pi's extension loader aliases that specifier to the compat entrypoint; subpath imports (e.g. `@earendil-works/pi-ai/api/openai-completions.lazy`) get the alias applied as a prefix and fail to resolve, which is a whole-extension load failure. Guarded by `test/extension-load.test.ts`.
123
+ Baseline catalog (verified against [NaN docs](https://nan.builders/docs/models) and [OpenAPI](https://nan.builders/openapi.json)).
162
124
 
163
- ## helmcode
125
+ | Model | Context | Max Output | Input | Reasoning |
126
+ | :--- | :--- | :--- | :--- | :---: |
127
+ | `qwen3.6` | 262,144 | 65,536 | text, image | ✅ |
128
+ | `gemma4` | 262,144 | 32,768 | text, image | ✅ |
129
+ | `deepseek-v4-flash` | 1,000,000 | 384,000 | text, image | ✅ |
130
+ | `mimo-v2.5` | 1,048,576 | 131,072 | text, image | ✅ |
131
+ | `glm5.3-flash` | 1,000,000 | 131,072 | text, image | ✅ |
132
+ | `qwen3.8-flash` | 262,144 | 131,072 | text, image | ✅ |
164
133
 
165
- The shared factory (`src/provider-factory.ts`) is provider-agnostic, but `helmcode` is **not registered**: no confirmed base URL or capability source exists for it (absent from models.dev and NaN's docs), and this repo does not fabricate provider data. When an endpoint is confirmed, registering it is one entry in `src/providers.ts` plus catalog data — no second implementation. A contract test (`factory is shared`) already exercises a second provider through the same code path.
134
+ ---
166
135
 
167
- ## Development
136
+ ## 🚀 Development
168
137
 
169
138
  ```bash
170
139
  bun install
171
- bun run generate-models # regenerate the fallback catalog from models.dev (pre-publish)
172
- bun test # unit + integration tests (fetch, auth, MCP bridges, compat)
173
- bun run typecheck # typecheck via bunx (local tsc, auto-installs if missing)
140
+ bun run generate-models # Regenerate fallback catalog
141
+ bun test # Run all tests
142
+ bun run typecheck # Run typechecking
174
143
  ```
175
144
 
176
- `prepublishOnly` runs generation + tests + typecheck. Typecheck resolves `tsc` via `bunx` because `bun publish` runs lifecycle scripts without `node_modules/.bin` on PATH (a bare `tsc` fails there with exit 127). Releases follow strict semver (see `AGENTS.md`); CI publishes when a merge to main changes code and the version. See `CONTRIBUTING.md` for the full contribution flow.
145
+ *Releases follow strict semver. CI publishes automatically on merge to `main`.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-nan-provider",
3
- "version": "0.6.1",
3
+ "version": "0.6.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",
@@ -80,11 +80,12 @@ const NAN_COMPAT = {
80
80
  supportsDeveloperRole: false,
81
81
  supportsReasoningEffort: true,
82
82
  supportsUsageInStreaming: true,
83
+ supportsFinishReason: false,
83
84
  maxTokensField: "max_tokens" as const,
84
85
  };
85
86
 
86
87
  const NAN_COMPAT_NOTE =
87
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.";
88
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.";
88
89
 
89
90
  interface ModelsDevModel {
90
91
  id?: string;
@@ -1,7 +1,7 @@
1
1
  // This file is auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually — run `bun run generate-models` to update.
3
3
  //
4
- // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-08T22:49:12.806Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-10T11:57:39.263Z
5
5
  // Provenance: every contextWindow/maxTokens/input/cost value traces to
6
6
  // models.dev or to the per-entry notes below. Nothing is invented; entries
7
7
  // models.dev documents incompletely are omitted and flagged instead.
@@ -32,10 +32,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
32
32
  "supportsDeveloperRole": false,
33
33
  "supportsReasoningEffort": true,
34
34
  "supportsUsageInStreaming": true,
35
+ "supportsFinishReason": false,
35
36
  "maxTokensField": "max_tokens"
36
37
  },
37
38
  "notes": [
38
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.",
39
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.",
39
40
  "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models, checked 2026-09-07; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models); models.dev provider nan lists text only."
40
41
  ],
41
42
  "extras": {
@@ -91,10 +92,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
91
92
  "supportsDeveloperRole": false,
92
93
  "supportsReasoningEffort": true,
93
94
  "supportsUsageInStreaming": true,
95
+ "supportsFinishReason": false,
94
96
  "maxTokensField": "max_tokens"
95
97
  },
96
98
  "notes": [
97
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
99
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
98
100
  ],
99
101
  "extras": {
100
102
  "id": "gemma4",
@@ -153,10 +155,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
153
155
  "supportsDeveloperRole": false,
154
156
  "supportsReasoningEffort": true,
155
157
  "supportsUsageInStreaming": true,
158
+ "supportsFinishReason": false,
156
159
  "maxTokensField": "max_tokens"
157
160
  },
158
161
  "notes": [
159
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
162
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
160
163
  ],
161
164
  "extras": {
162
165
  "id": "glm5.3-flash",
@@ -211,10 +214,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
211
214
  "supportsDeveloperRole": false,
212
215
  "supportsReasoningEffort": true,
213
216
  "supportsUsageInStreaming": true,
217
+ "supportsFinishReason": false,
214
218
  "maxTokensField": "max_tokens"
215
219
  },
216
220
  "notes": [
217
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
221
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
218
222
  ],
219
223
  "extras": {
220
224
  "id": "mimo-v2.5",
@@ -270,10 +274,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
270
274
  "supportsDeveloperRole": false,
271
275
  "supportsReasoningEffort": true,
272
276
  "supportsUsageInStreaming": true,
277
+ "supportsFinishReason": false,
273
278
  "maxTokensField": "max_tokens"
274
279
  },
275
280
  "notes": [
276
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
281
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
277
282
  ],
278
283
  "extras": {
279
284
  "id": "qwen3.6",
@@ -332,10 +337,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
332
337
  "supportsDeveloperRole": false,
333
338
  "supportsReasoningEffort": true,
334
339
  "supportsUsageInStreaming": true,
340
+ "supportsFinishReason": false,
335
341
  "maxTokensField": "max_tokens"
336
342
  },
337
343
  "notes": [
338
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.",
344
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.",
339
345
  "contextWindow 262,144: the earlier 1,000,000 override (maintainer-confirmed 2026-09-05) was withdrawn 2026-09-07 — the updated https://nan.builders/docs/models still states '262K token context, the model's native window' and models.dev agrees at 262,144; NaN docs are treated as the most reliable source (maintainer instruction, 2026-09-07)."
340
346
  ],
341
347
  "extras": {
@@ -375,13 +381,13 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
375
381
  export const GENERATED_CATALOG_META = {
376
382
  source: "https://models.dev/api.json",
377
383
  modelsDevProvider: "nan",
378
- fetchedAt: "2026-09-08T22:49:12.806Z",
384
+ fetchedAt: "2026-09-10T11:57:39.263Z",
379
385
  modelCount: 6,
380
386
  models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.5","qwen3.6","qwen3.8-flash"],
381
387
  notes: [
382
388
  "provider-removed: \"glm5.2\" excluded from the catalog (removed by NaN (2026-09-05); absent from the official chat model list in https://nan.builders/openapi.json and https://nan.builders/docs/models (checked 2026-09-07) while models.dev provider nan still listed it — excluded so regeneration does not resurrect it)",
383
389
  "glm5.3: served by NaN on the GLM 5.3 premium tier (https://nan.builders/docs/models + https://nan.builders/openapi.json, checked 2026-09-07) but absent from models.dev, and no source documents its max output tokens — no entry is generated (no-fabrication rule); premium keys still get it live via the /models refresh with conservative placeholder limits",
384
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.",
390
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.",
385
391
  "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models, checked 2026-09-07; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models); models.dev provider nan lists text only.",
386
392
  "contextWindow 262,144: the earlier 1,000,000 override (maintainer-confirmed 2026-09-05) was withdrawn 2026-09-07 — the updated https://nan.builders/docs/models still states '262K token context, the model's native window' and models.dev agrees at 262,144; NaN docs are treated as the most reliable source (maintainer instruction, 2026-09-07)."
387
393
  ],
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Cross-model thinking guard.
3
+ *
4
+ * Root cause this guards against (pi-ai, still present on 0.85.1 / main): when
5
+ * history is replayed into a DIFFERENT model, `transformMessages` downgrades
6
+ * every non-redacted `thinking` block to a plain `text` block verbatim
7
+ * (`packages/ai/src/api/transform-messages.ts`), and `openai-completions`
8
+ * serializes that into the assistant `content` string. Nothing bounds
9
+ * `block.thinking`, so a long/degenerate reasoning trace from the previous
10
+ * model (seen in the wild: a 445,888-char / 131,072-token thinking block after
11
+ * `glm5.3-flash` ended with `stopReason: "length"`) is replayed as a 445 KB
12
+ * assistant text message. The request then exceeds the destination model's
13
+ * context window and NaN's gateway answers with a generic
14
+ * `400 Invalid request. Check your request parameters.` (verified live
15
+ * 2026-09-10: the same payload returns 200 for deepseek-v4-flash /
16
+ * glm5.3-flash and 200 for qwen3.6 once that message is removed).
17
+ *
18
+ * The extension runs in pi's `context` hook, which fires BEFORE pi-ai's
19
+ * `transformMessages` (pi-agent-core `transformContext` → `convertToLlm` →
20
+ * provider stream). It therefore sees the original `thinking` blocks and can
21
+ * bound what they will become. Only messages whose (provider, api, model)
22
+ * differ from the target are touched; same-model replay keeps its reasoning
23
+ * byte-for-byte because signatures/continuity depend on it.
24
+ *
25
+ * This is a bounded mitigation, not a fix: it caps each replayed cross-model
26
+ * reasoning block, which is sufficient for the observed single-degenerate-trace
27
+ * failure. The real fix belongs upstream (see
28
+ * https://github.com/earendil-works/pi/issues/9433).
29
+ */
30
+
31
+ /** Env var that opts out of the guard (`0`, `false`, `no` or `off`). Default: enabled. */
32
+ export const NAN_THINKING_GUARD_ENV = "NAN_THINKING_GUARD";
33
+
34
+ /**
35
+ * Maximum characters of a single cross-model reasoning block replayed as text.
36
+ * 16,000 chars is ~4K tokens — far above any real reasoning trace, far below
37
+ * the 445,888-char degenerate trace that caused the 400.
38
+ */
39
+ export const MAX_CROSS_MODEL_THINKING_CHARS = 16_000;
40
+
41
+ /** Appended after the kept prefix so the substitution is visible, never silent. */
42
+ export const CROSS_MODEL_THINKING_TRUNCATION_MARKER =
43
+ "\n\n[…previous-model reasoning truncated by pi-nan-provider to keep the request within NaN's context]";
44
+
45
+ export interface CrossModelThinkingGuardOptions {
46
+ /** Max chars kept per replayed cross-model reasoning block. */
47
+ maxCharsPerBlock?: number;
48
+ /** Provider ids this guard applies to (the caller's registered providers). */
49
+ providerIds: ReadonlySet<string>;
50
+ }
51
+
52
+ interface GuardTarget {
53
+ provider?: string;
54
+ api?: string;
55
+ id?: string;
56
+ }
57
+
58
+ interface ThinkingBlock {
59
+ type?: string;
60
+ thinking?: unknown;
61
+ [k: string]: unknown;
62
+ }
63
+
64
+ interface GuardMessage {
65
+ role?: string;
66
+ provider?: string;
67
+ api?: string;
68
+ model?: string;
69
+ content?: unknown;
70
+ }
71
+
72
+ /** Explicit opt-out only: anything other than a known falsy value keeps the guard on. */
73
+ export function crossModelThinkingGuardEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
74
+ const value = env[NAN_THINKING_GUARD_ENV]?.trim().toLowerCase();
75
+ return value !== "0" && value !== "false" && value !== "no" && value !== "off";
76
+ }
77
+
78
+ function isGuardMessage(value: unknown): value is GuardMessage {
79
+ return typeof value === "object" && value !== null;
80
+ }
81
+
82
+ function isThinkingBlock(value: unknown): value is ThinkingBlock {
83
+ return typeof value === "object" && value !== null && (value as ThinkingBlock).type === "thinking";
84
+ }
85
+
86
+ /**
87
+ * Bound every cross-model `thinking` block to `maxCharsPerBlock` characters.
88
+ *
89
+ * Returns the SAME array reference when nothing changed, so callers can skip
90
+ * cloning the whole context on the common path. Never mutates the input.
91
+ */
92
+ export function boundCrossModelThinking<T>(
93
+ messages: readonly T[],
94
+ target: GuardTarget | undefined,
95
+ options: CrossModelThinkingGuardOptions,
96
+ ): readonly T[] {
97
+ if (!target?.provider || !options.providerIds.has(target.provider)) return messages;
98
+
99
+ const maxChars = options.maxCharsPerBlock ?? MAX_CROSS_MODEL_THINKING_CHARS;
100
+ let changed = false;
101
+
102
+ const next = messages.map((raw) => {
103
+ if (!isGuardMessage(raw) || raw.role !== "assistant" || !Array.isArray(raw.content)) return raw;
104
+
105
+ // Same-model replay keeps reasoning intact: signatures and continuity depend on it.
106
+ const isSameModel = raw.provider === target.provider && raw.api === target.api && raw.model === target.id;
107
+ if (isSameModel) return raw;
108
+
109
+ let messageChanged = false;
110
+ const content = (raw.content as unknown[]).map((block) => {
111
+ if (!isThinkingBlock(block)) return block;
112
+ const text = block.thinking;
113
+ if (typeof text !== "string" || text.length <= maxChars) return block;
114
+ messageChanged = true;
115
+ return { ...block, thinking: text.slice(0, maxChars) + CROSS_MODEL_THINKING_TRUNCATION_MARKER };
116
+ });
117
+
118
+ if (!messageChanged) return raw;
119
+ changed = true;
120
+ return { ...raw, content };
121
+ });
122
+
123
+ return changed ? next : messages;
124
+ }
@@ -199,6 +199,13 @@ export function mergeLiveWithGenerated(
199
199
  models.push(toModel(entry, source));
200
200
  matched.push(id);
201
201
  } else {
202
+ // Conservative placeholder for allowlisted uncatalogued live ids
203
+ // (e.g. premium glm5.3): limits are the documented safe envelope and
204
+ // capabilities stay "unknown". supportsFinishReason: false is NOT a
205
+ // capability claim — it is a client-tolerance flag for the same
206
+ // gateway-level SSE truncation handled in NAN_COMPAT (LiteLLM cutting
207
+ // streams before finish_reason); without it pi-ai throws "Stream
208
+ // ended without finish_reason" on those models too.
202
209
  models.push({
203
210
  id,
204
211
  name: id,
@@ -210,6 +217,7 @@ export function mergeLiveWithGenerated(
210
217
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
211
218
  contextWindow: UNKNOWN_MODEL_LIMITS.contextWindow,
212
219
  maxTokens: UNKNOWN_MODEL_LIMITS.maxTokens,
220
+ compat: { supportsFinishReason: false },
213
221
  });
214
222
  unknown.push(id);
215
223
  }
package/src/index.ts CHANGED
@@ -25,9 +25,14 @@
25
25
  * audio/image/transcription is actually invoked.
26
26
  */
27
27
 
28
- import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
28
+ import type { ContextEvent, ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
29
29
  import type { Provider } from "@earendil-works/pi-ai";
30
30
  import { registerNanMcpCommand } from "./commands.ts";
31
+ import {
32
+ boundCrossModelThinking,
33
+ crossModelThinkingGuardEnabled,
34
+ MAX_CROSS_MODEL_THINKING_CHARS,
35
+ } from "./cross-model-thinking-guard.ts";
31
36
  import { baselineModels } from "./fetch-models.ts";
32
37
  import { createNanWebSearchTool, webSearchBridgeEnabled, NAN_API_KEY_ENV } from "./mcp/nan-search.ts";
33
38
  import { createNanMediaTools, mediaMcpEnabled } from "./mcp/nan-media.ts";
@@ -108,7 +113,37 @@ function registerMcpToolsCompat(pi: ExtensionAPI): void {
108
113
  }
109
114
  }
110
115
 
116
+ /**
117
+ * Bound the reasoning pi-ai replays across a model switch.
118
+ *
119
+ * pi-ai's `transformMessages` downgrades a previous model's `thinking` blocks
120
+ * to plain text with no size bound (still true on 0.85.1 / main), so a long or
121
+ * degenerate reasoning trace is re-inlined into the assistant `content` and
122
+ * can push the request past the destination model's context window. NaN's
123
+ * gateway answers that with a generic `400 Invalid request. Check your request
124
+ * parameters.`, which reads as a provider bug and not as an oversized prompt.
125
+ * This hook runs before pi-ai converts the blocks, so capping them here keeps
126
+ * the replayed context bounded. See src/cross-model-thinking-guard.ts.
127
+ *
128
+ * Scope: only requests targeting this package's providers are touched, and
129
+ * only messages from a DIFFERENT model — same-model reasoning is never altered.
130
+ */
131
+ export function registerCrossModelThinkingGuard(pi: ExtensionAPI): void {
132
+ if (typeof pi.on !== "function") return; // old pi without the context hook
133
+ const providerIds = new Set(PROVIDERS.map((provider) => provider.id));
134
+ pi.on("context", (event, ctx) => {
135
+ if (!crossModelThinkingGuardEnabled()) return;
136
+ const guarded = boundCrossModelThinking(event.messages, ctx.model, {
137
+ maxCharsPerBlock: MAX_CROSS_MODEL_THINKING_CHARS,
138
+ providerIds,
139
+ });
140
+ if (guarded === event.messages) return;
141
+ return { messages: guarded as ContextEvent["messages"] };
142
+ });
143
+ }
144
+
111
145
  export default async function nanProviderExtension(pi: ExtensionAPI): Promise<void> {
146
+ registerCrossModelThinkingGuard(pi);
112
147
  for (const config of PROVIDERS) {
113
148
  await registerProviderCompat(pi, config);
114
149
  }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Strict OpenAI Chat Completions schema conformance for NaN-compatible
3
+ * providers.
4
+ *
5
+ * NaN's gateway returns HTTP 400 `Invalid request. Check your request parameters.`
6
+ * for any request that does not match the schema it publishes at
7
+ * https://nan.builders/openapi.json (checked 2026-09-09). That schema is
8
+ * stricter than the permissive OpenAI shape models like OpenAI/Anthropic
9
+ * tolerate, and it does NOT match what pi-ai's message transformer emits in
10
+ * every case. This module rewrites the outgoing `/chat/completions` payload
11
+ * so it is always schema-valid, no matter which pi-ai version is bundled or
12
+ * how the history was constructed.
13
+ *
14
+ * The violated shapes we correct (each one traced to NaN's schema):
15
+ *
16
+ * 1. An `assistant` message whose `content` ARRAY contains a `toolCall`
17
+ * block. NaN's `ContentPart` oneOf allows ONLY `{type:"text"}` and
18
+ * `{type:"image_url"}` parts; a tool call is rejected. Tool calls must
19
+ * live in the top-level `tool_calls` field:
20
+ * `{ id, type:"function", function:{ name, arguments } }` with
21
+ * `arguments` as a JSON-encoded string. (This is the shape reported in
22
+ * the issue: a replayed assistant message with a `toolCall` block still
23
+ * inside `content` → 400.)
24
+ * 2. An `assistant` message carrying `reasoning_details`. NaN's `Message`
25
+ * schema admits `role/content/name/tool_calls/tool_call_id/reasoning_content`
26
+ * but NOT `reasoning_details` (an OpenAI-specific field pi-ai emits on
27
+ * same-model replay of encrypted/text reasoning signatures). That field
28
+ * is stripped; reasoning content is delivered the way NaN understands it
29
+ * (`reasoning_content`, or as plain text already present in `content`).
30
+ * 3. A `content` array containing an unknown part type (e.g. `thinking`).
31
+ * NaN only accepts `text` and `image_url`; other types are dropped, and
32
+ * thinking text is folded into a `text` part so the model's reasoning is
33
+ * not silently lost.
34
+ * 4. Top-level fields NaN's schema does not list: `store` and
35
+ * `stream_options`. These are opt-in/usage fields pi-ai sends by default
36
+ * for a "standard" provider; NaN does not document them, so they are
37
+ * removed. (Removing `stream_options` only costs live token-usage in the
38
+ * stream; NaN models are membership-quota based with zero per-token cost,
39
+ * so this is a safe trade.)
40
+ * 5. An EMPTY `tools` array. Verified against the live gateway (2026-09-09):
41
+ * NaN rejects `tools: []` with the same 400, while `stream: true`, a
42
+ * `system` message, string content, and a `tool` role message are all
43
+ * accepted. pi-ai emits `tools: []` when the conversation has tool-call
44
+ * history but no active tools; NaN only accepts a real tool list, so the
45
+ * empty array is dropped and a non-empty list is kept.
46
+ *
47
+ * This is applied by wrapping the provider's api `stream`/`streamSimple`
48
+ * with an `onPayload` hook in src/provider-factory.ts, so every provider
49
+ * registered through the shared factory stays schema-valid. A user-supplied
50
+ * `onPayload` (if pi or a consumer passes one) is preserved and chained
51
+ * after sanitization.
52
+ */
53
+
54
+ interface ContentPart {
55
+ type?: unknown;
56
+ text?: unknown;
57
+ thinking?: unknown;
58
+ [m: string]: unknown;
59
+ }
60
+
61
+ interface ToolCallBlock {
62
+ id?: unknown;
63
+ name?: unknown;
64
+ arguments?: unknown;
65
+ [m: string]: unknown;
66
+ }
67
+
68
+ function isObject(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
71
+
72
+ /** Monotonic counter for deterministic fallback tool-call ids (pi-ai always supplies ids; this is pure defense). */
73
+ let anonymousToolCallSeq = 0;
74
+
75
+ /** Normalize a `toolCall` content block into NaN's `tool_calls[].{id,type,function}` shape. */
76
+ function toToolCall(block: ToolCallBlock): Record<string, unknown> {
77
+ const rawArgs = block.arguments;
78
+ let argumentsJson: string;
79
+ if (typeof rawArgs === "string") {
80
+ argumentsJson = rawArgs;
81
+ } else {
82
+ try {
83
+ argumentsJson = JSON.stringify(rawArgs ?? {});
84
+ } catch {
85
+ argumentsJson = "{}";
86
+ }
87
+ }
88
+ const fallbackName = typeof block.name === "string" && block.name.length > 0 ? block.name : "function";
89
+ return {
90
+ id: typeof block.id === "string" && block.id.length > 0 ? block.id : `call_${fallbackName}_${++anonymousToolCallSeq}`,
91
+ type: "function",
92
+ function: {
93
+ name: fallbackName,
94
+ arguments: argumentsJson,
95
+ },
96
+ };
97
+ }
98
+
99
+ /** Merge tool calls, de-duplicating by id and preferring the pre-existing (pi-ai-built) entries. */
100
+ function mergeToolCalls(existing: unknown[], incoming: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
101
+ const byId = new Map<string, Record<string, unknown>>();
102
+ for (const tc of existing) {
103
+ if (isObject(tc) && typeof tc.id === "string") byId.set(tc.id, tc);
104
+ }
105
+ for (const tc of incoming) {
106
+ if (typeof tc.id === "string" && !byId.has(tc.id)) byId.set(tc.id, tc);
107
+ }
108
+ return [...byId.values()];
109
+ }
110
+
111
+ /**
112
+ * Rebuild an assistant `content` value from an array of sanitized parts.
113
+ * NaN follows the OpenAI convention: a plain string when there is only text,
114
+ * an array of `text`/`image_url` parts when there are images, and `null` when
115
+ * there is no content (valid on an assistant message that returns tool calls).
116
+ */
117
+ function normalizeAssistantContent(parts: Array<Record<string, unknown>>): string | Array<unknown> | null {
118
+ if (parts.length === 0) return null;
119
+ const allText = parts.every((part) => part.type === "text");
120
+ if (allText) {
121
+ const text = parts
122
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
123
+ .join("");
124
+ return text.length > 0 ? text : null;
125
+ }
126
+ return parts;
127
+ }
128
+
129
+ /** Permit only NaN's approved content-part types; fold `thinking` into text. */
130
+ function sanitizeAssistantContentPart(part: unknown): Record<string, unknown> | undefined {
131
+ if (!isObject(part)) return undefined;
132
+ const type = part.type;
133
+ if (type === "text") {
134
+ return { type: "text", text: typeof part.text === "string" ? part.text : String(part.text ?? "") };
135
+ }
136
+ if (type === "image_url") {
137
+ return { type: "image_url", image_url: part.image_url };
138
+ }
139
+ if (type === "thinking") {
140
+ const thinking = typeof part.thinking === "string" ? part.thinking : "";
141
+ if (thinking.trim().length === 0) return undefined;
142
+ return { type: "text", text: thinking };
143
+ }
144
+ // Anything else (toolCall handled separately, unknown types dropped) — NaN rejects it.
145
+ return undefined;
146
+ }
147
+
148
+ /** Sanitize a single message against NaN's strict schema. */
149
+ function sanitizeMessage(message: unknown): unknown {
150
+ if (!isObject(message)) return message;
151
+ if (message.role !== "assistant") return message;
152
+
153
+ const out: Record<string, unknown> = { ...message };
154
+ delete out.reasoning_details; // OpenAI-only; absent from NaN's Message schema.
155
+ // NaN understands `reasoning_content` (not the generic `reasoning` field), so
156
+ // carry any reasoning text over to the field NaN accepts rather than dropping it.
157
+ if (out.reasoning !== undefined && out.reasoning_content === undefined) {
158
+ out.reasoning_content = out.reasoning;
159
+ }
160
+ delete out.reasoning;
161
+
162
+ const content = message.content;
163
+ if (!Array.isArray(content)) return out; // string / null content is already schema-valid.
164
+
165
+ const textParts: Array<Record<string, unknown>> = [];
166
+ const toolCallBlocks: ToolCallBlock[] = [];
167
+ for (const part of content) {
168
+ if (isObject(part) && part.type === "toolCall") {
169
+ toolCallBlocks.push(part as unknown as ToolCallBlock);
170
+ continue;
171
+ }
172
+ const sanitized = sanitizeAssistantContentPart(part);
173
+ if (sanitized) textParts.push(sanitized);
174
+ }
175
+
176
+ out.content = normalizeAssistantContent(textParts);
177
+
178
+ const existingToolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
179
+ const toolCalls = toolCallBlocks.length > 0 ? mergeToolCalls(existingToolCalls, toolCallBlocks.map(toToolCall)) : [...existingToolCalls];
180
+ if (toolCalls.length > 0) out.tool_calls = toolCalls;
181
+ else delete out.tool_calls;
182
+
183
+ return out;
184
+ }
185
+
186
+ /**
187
+ * Rewrite an OpenAI-compatible `/chat/completions` payload so every field
188
+ * conforms to NaN's published schema. Returns the updated payload; if the
189
+ * payload has no `messages` array it is returned unchanged.
190
+ */
191
+ export function sanitizeOpenAICompatPayload(payload: unknown): unknown {
192
+ if (!isObject(payload) || !Array.isArray(payload.messages)) return payload;
193
+
194
+ const messages = payload.messages.map(sanitizeMessage);
195
+ const out: Record<string, unknown> = { ...payload, messages };
196
+
197
+ // Patch function-tools call arguments: NaN wants function-call arguments
198
+ // serialized as a JSON string under `function.arguments`. pi-ai already does
199
+ // this, but a hand-built / older-version payload may not. Normalize each.
200
+ if (Array.isArray(out.messages)) {
201
+ out.messages = out.messages.map((m) => {
202
+ if (!isObject(m) || m.role !== "assistant") return m;
203
+ if (!Array.isArray(m.tool_calls)) return m;
204
+ const normalized = m.tool_calls.map((tc) => {
205
+ if (!isObject(tc)) return tc;
206
+ if (typeof tc.type === "string" && tc.type !== "function") return tc;
207
+ const fn = isObject(tc.function) ? tc.function : {};
208
+ let args = fn.arguments;
209
+ if (args !== undefined && typeof args !== "string") {
210
+ try {
211
+ args = JSON.stringify(args);
212
+ } catch {
213
+ args = "{}";
214
+ }
215
+ }
216
+ return { ...tc, type: "function", function: { ...fn, ...(args !== undefined ? { arguments: args } : {}) } };
217
+ });
218
+ return { ...m, tool_calls: normalized };
219
+ });
220
+ }
221
+
222
+ // Top-level fields absent from NaN's schema.
223
+ delete out.store;
224
+ delete out.stream_options;
225
+ // NaN documents `max_tokens`, not `max_completion_tokens`.
226
+ if ("max_completion_tokens" in out && !("max_tokens" in out)) {
227
+ out.max_tokens = out.max_completion_tokens;
228
+ delete out.max_completion_tokens;
229
+ }
230
+ // NaN rejects an EMPTY `tools` array with HTTP 400 `Invalid request. Check
231
+ // your request parameters.` (verified against the live gateway 2026-09-09:
232
+ // everything else in the payload — stream/system/content-as-string/tool role
233
+ // — is accepted, but `tools: []` is not). pi-ai emits `tools: []` whenever
234
+ // the conversation has tool-call history but no active tools; NaN only
235
+ // accepts a real tool list, so drop the empty array. A non-empty `tools`
236
+ // list is preserved unchanged.
237
+ if (Array.isArray(out.tools) && out.tools.length === 0) {
238
+ delete out.tools;
239
+ }
240
+
241
+ return out;
242
+ }
@@ -32,6 +32,7 @@ import {
32
32
  resolveCatalog,
33
33
  type CatalogSource,
34
34
  } from "./fetch-models.ts";
35
+ import { sanitizeOpenAICompatPayload } from "./openai-compat-sanitizer.ts";
35
36
 
36
37
  export interface OpenAICompatibleProviderConfig {
37
38
  /** Provider id as registered in pi, e.g. "nan". */
@@ -80,6 +81,48 @@ export async function resolveOpenAICompletionsApi(): Promise<OpenAICompletionsAp
80
81
  return cachedApiFactory;
81
82
  }
82
83
 
84
+ /**
85
+ * Wrap an api so every outgoing `/chat/completions` payload is made conformant
86
+ * to the strict OpenAI Chat Completions schema NaN enforces (see
87
+ * ./openai-compat-sanitizer.ts). NaN returns HTTP 400 `Invalid request. Check
88
+ * your request parameters.` for any payload that violates it — including a
89
+ * replayed assistant message with a `toolCall` block inside `content`, a
90
+ * `reasoning_details` field, or undocumented top-level fields like `store` /
91
+ * `stream_options`. Sanitizing via the `onPayload` hook works regardless of
92
+ * which pi-ai version the runtime bundles, so the fix is not tied to a
93
+ * specific upstream build.
94
+ *
95
+ * Any caller-supplied `onPayload` (e.g. pi's own debug/passthrough hook) is
96
+ * preserved and chained AFTER sanitization, so the final payload is always
97
+ * schema-valid.
98
+ */
99
+ function isObject(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === "object" && value !== null;
101
+ }
102
+
103
+ export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStreams {
104
+ const withSanitizer = <TOptions extends object | undefined>(options: TOptions): TOptions => {
105
+ const userOnPayload = isObject(options) ? (options.onPayload as unknown) : undefined;
106
+ return {
107
+ ...((options ?? {}) as Record<string, unknown>),
108
+ onPayload: async (payload: unknown, model: unknown) => {
109
+ const sanitized = sanitizeOpenAICompatPayload(payload);
110
+ if (typeof userOnPayload === "function") {
111
+ const userResult = await (userOnPayload as (p: unknown, m: unknown) => unknown)(sanitized, model);
112
+ return userResult ?? sanitized;
113
+ }
114
+ return sanitized;
115
+ },
116
+ } as TOptions;
117
+ };
118
+
119
+ return {
120
+ ...api,
121
+ stream: (model, context, options) => api.stream(model, context, withSanitizer(options)),
122
+ streamSimple: (model, context, options) => api.streamSimple(model, context, withSanitizer(options)),
123
+ };
124
+ }
125
+
83
126
  /**
84
127
  * Build a complete pi-ai Provider for an OpenAI-compatible endpoint:
85
128
  *
@@ -130,6 +173,6 @@ export async function createNanCompatibleProvider(
130
173
  const current = liveIds;
131
174
  return current ? models.filter((model) => current.has(model.id)) : models;
132
175
  },
133
- api: apiFactory(),
176
+ api: wrapApiForStrictSanitization(apiFactory()),
134
177
  });
135
178
  }