@gtrabanco/pi-nan-provider 0.6.5 → 0.6.8
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 +19 -3
- package/README.es.md +16 -2
- package/README.md +16 -2
- package/package.json +1 -1
- package/scripts/generate-models.ts +7 -4
- package/scripts/models.generated.ts +9 -9
- package/src/context-overflow-classifier.ts +181 -0
- package/src/fetch-models.ts +27 -54
- package/src/mcp/nan-media.ts +4 -4
- package/src/mcp/stdio-client.ts +1 -1
- package/src/openai-compat-sanitizer.ts +24 -5
- package/src/provider-factory.ts +29 -2
package/AGENTS.md
CHANGED
|
@@ -136,9 +136,25 @@ Every PR that changes code MUST bump `package.json` version in the same PR; CI p
|
|
|
136
136
|
`supportsFinishReason: true` so pi-ai raises the retryable
|
|
137
137
|
`Stream ended without finish_reason` (pi-ai's `RETRYABLE_PROVIDER_ERROR_PATTERN`
|
|
138
138
|
matches `"ended without"`, so the turn is retried) instead of silently
|
|
139
|
-
synthesizing `stop`/`toolUse`. `supportsUsageInStreaming`
|
|
140
|
-
|
|
141
|
-
`
|
|
139
|
+
synthesizing `stop`/`toolUse`. `supportsUsageInStreaming` stays `false` by
|
|
140
|
+
default (NaN's published schema does not document `stream_options`), but the
|
|
141
|
+
sanitizer gates its `stream_options` removal on the model's effective
|
|
142
|
+
`compat.supportsUsageInStreaming`, so a confirmed per-model `models.json`
|
|
143
|
+
override now yields real usage instead of being silently undone
|
|
144
|
+
(`test/issue-4-token-usage.test.ts`; issues #2, #4). Regression tests:
|
|
145
|
+
`test/issue-2-truncated-stream.test.ts`, `test/issue-4-token-usage.test.ts`;
|
|
146
|
+
issues #2 and #4.
|
|
147
|
+
- A NaN request that still exceeds the destination model's context window (the
|
|
148
|
+
cross-model thinking guard is disabled with `NAN_THINKING_GUARD=0`, the
|
|
149
|
+
inflation is not a `thinking` block, or the window is smaller) gets NaN's
|
|
150
|
+
generic 400 `Invalid request. Check your request parameters.`, which pi-ai's
|
|
151
|
+
`isContextOverflow()` does NOT match — so pi never compacts and the session
|
|
152
|
+
wedges (upstream `earendil-works/pi#9409`). `src/context-overflow-classifier.ts`
|
|
153
|
+
re-checks the request size at the provider boundary and rewrites that error
|
|
154
|
+
into a pi-recognizable overflow message (chars/3.47 estimate; conservative:
|
|
155
|
+
only when estimated over the window). Wired in `src/provider-factory.ts` after
|
|
156
|
+
the sanitizer. Regression tests: `test/issue-3-model-switch-overflow.test.ts`,
|
|
157
|
+
`test/context-overflow-classifier.test.ts`; issue #3.
|
|
142
158
|
- Relative imports inside this package use `.ts` extensions (pi's official
|
|
143
159
|
extension examples do the same; pi transpiles extension sources).
|
|
144
160
|
- pi intentionally has NO built-in MCP client (docs/usage.md). MCP integration
|
package/README.es.md
CHANGED
|
@@ -49,6 +49,8 @@ Al cambiar de modelo, pi-ai reenvía el razonamiento del modelo anterior como te
|
|
|
49
49
|
|
|
50
50
|
Este paquete **elimina todos los bloques de razonamiento cross-model reenviados**, de modo que cambiar de un modelo de 1M de contexto a uno de 262K (`qwen3.6`) ya no desborda la ventana. Las respuestas y los tool results de los modelos no se tocan — solo se quitan sus trazas internas de razonamiento, así que `qwen3.6` puede seguir respondiendo sobre lo que hizo otro modelo. 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.
|
|
51
51
|
|
|
52
|
+
Si una petición sigue desbordando — guard desactivado, inflado que no es un bloque de razonamiento (tool outputs grandes, imágenes) o una ventana de destino más pequeña — NaN responde con el mismo 400 genérico en lugar de nombrar el desbordamiento, y la auto-compactación de pi no lo reconoce, así que la sesión se queda atascada en el techo. Por eso el proveedor **vuelve a comprobar el tamaño de la petición a la salida**: cuando llega ese 400 genérico para una petición estimada por encima de la ventana del modelo, el error se reescribe como un mensaje de context overflow que pi reconoce, de modo que compacta y reintenta en vez de bloquearse. Un 400 genérico en una petición dentro de la ventana no se toca, así que nunca se etiquetan mal errores no relacionados.
|
|
53
|
+
|
|
52
54
|
### ⏱️ Streams truncados intermitentes (auto-retry, sin stall silencioso)
|
|
53
55
|
|
|
54
56
|
El gateway LiteLLM de NaN cierra ocasionalmente un stream SSE **antes** de emitir el chunk final `finish_reason` (observado en `glm5.3-flash`; [issue #2](https://github.com/gtrabanco/pi-nan-provider/issues/2)). El catálogo declara `supportsFinishReason: true`, así que pi-ai lo convierte en el error `Stream ended without finish_reason` — que coincide con el patrón de errores reintentables de pi y se **reintenta automáticamente**, en vez de aceptar en silencio una respuesta a medias. Si una versión del gateway nunca manda `finish_reason`, el turno ahora falla de forma visible al agotar los reintentos.
|
|
@@ -69,7 +71,19 @@ Puedes sobrescribir el `compat` de cualquier modelo en `~/.pi/agent/models.json`
|
|
|
69
71
|
|
|
70
72
|
> Poner `supportsFinishReason: false` restaura el antiguo stall silencioso — no recomendado.
|
|
71
73
|
|
|
72
|
-
**Uso de tokens en streaming:** `supportsUsageInStreaming` es `false` porque el esquema
|
|
74
|
+
**Uso de tokens en streaming:** `supportsUsageInStreaming` es `false` por defecto porque el esquema publicado de NaN no documenta `stream_options`; sin él, el usage aparece a cero. Si has confirmado que tu modelo devuelve el chunk de usage en streaming, activalo por modelo — el sanitizer de peticiones entonces reenvía `stream_options: { "include_usage": true }` y pi muestra los tokens reales en lugar de ceros:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"providers": {
|
|
79
|
+
"nan": {
|
|
80
|
+
"modelOverrides": {
|
|
81
|
+
"qwen3.6": { "compat": { "supportsUsageInStreaming": true } }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
73
87
|
|
|
74
88
|
## 🔑 Autenticación
|
|
75
89
|
|
|
@@ -123,7 +137,7 @@ Ambos puentes están **activados y son perezosos (lazy) por defecto**. Usa `/nan
|
|
|
123
137
|
| Variable | Por defecto | Descripción |
|
|
124
138
|
| :--- | :--- | :--- |
|
|
125
139
|
| `NAN_MEDIA_MCP` | — | Override por sesión (`0` o `false` para desactivar). |
|
|
126
|
-
| `NAN_MEDIA_MCP_VERSION` | `1.
|
|
140
|
+
| `NAN_MEDIA_MCP_VERSION` | `1.1.2` | Versión del servidor fijada (recomendado). |
|
|
127
141
|
| `NAN_MEDIA_MCP_COMMAND` | — | Override del comando personalizado. |
|
|
128
142
|
| `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Timeout por llamada. |
|
|
129
143
|
| `NAN_MCP_TOOLS` | — | Override para el puente oficial (`0` para desactivar). |
|
package/README.md
CHANGED
|
@@ -49,6 +49,8 @@ When you switch models, pi-ai replays the previous model's reasoning as plain as
|
|
|
49
49
|
|
|
50
50
|
This package **drops every replayed cross-model reasoning block**, so switching from a 1M-context model to a 262K one (`qwen3.6`) no longer overflows the window. The models' answers and tool results are untouched — only their internal reasoning traces are removed, so `qwen3.6` can still answer about what another model did. 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.
|
|
51
51
|
|
|
52
|
+
If a request still overflows — the guard is disabled, the inflation is not a reasoning block (large tool outputs, images), or the destination window is simply smaller — NaN answers the same generic 400 instead of naming the overflow, and pi's auto-compaction does not recognize it, so the session wedges at the ceiling. The provider therefore **re-checks the request size on the way out**: when that generic 400 arrives for a request estimated over the model's window, the error is rewritten into a context-overflow message pi recognizes, so it compacts and retries instead of stalling. A generic 400 on a within-window request is left untouched, so unrelated errors are never mislabelled.
|
|
53
|
+
|
|
52
54
|
### ⏱️ Intermittent truncated streams (auto-retry, no silent stall)
|
|
53
55
|
|
|
54
56
|
NaN's LiteLLM gateway occasionally closes an SSE stream **before** emitting the final `finish_reason` chunk (observed on `glm5.3-flash`; [issue #2](https://github.com/gtrabanco/pi-nan-provider/issues/2)). The catalog declares `supportsFinishReason: true`, so pi-ai turns that into the error `Stream ended without finish_reason` — which matches pi's retryable-provider pattern and is **retried automatically**, instead of silently accepting a half-finished answer. If a gateway version never sends `finish_reason`, the turn now fails visibly once the retry budget is exhausted.
|
|
@@ -69,7 +71,19 @@ You can override any model's `compat` per-model in `~/.pi/agent/models.json` (pi
|
|
|
69
71
|
|
|
70
72
|
> Setting `supportsFinishReason: false` restores the old silent-stall behavior — not recommended.
|
|
71
73
|
|
|
72
|
-
**Streaming token usage:** `supportsUsageInStreaming` is `false` because NaN's
|
|
74
|
+
**Streaming token usage:** `supportsUsageInStreaming` is `false` by default because NaN's published schema does not document `stream_options`; without it usage reads as zero. If you have confirmed that your model returns a streaming usage chunk, opt in per model — the request sanitizer then forwards `stream_options: { "include_usage": true }` and pi reports real token counts instead of zeros:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"providers": {
|
|
79
|
+
"nan": {
|
|
80
|
+
"modelOverrides": {
|
|
81
|
+
"qwen3.6": { "compat": { "supportsUsageInStreaming": true } }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
73
87
|
|
|
74
88
|
## 🔑 Authentication
|
|
75
89
|
|
|
@@ -123,7 +137,7 @@ Both bridges are **enabled and lazy by default**. Use `/nan-mcp` to manage them.
|
|
|
123
137
|
| Variable | Default | Description |
|
|
124
138
|
| :--- | :--- | :--- |
|
|
125
139
|
| `NAN_MEDIA_MCP` | — | Per-session override (`0` or `false` to disable). |
|
|
126
|
-
| `NAN_MEDIA_MCP_VERSION` | `1.
|
|
140
|
+
| `NAN_MEDIA_MCP_VERSION` | `1.1.2` | Pinned server version (recommended). |
|
|
127
141
|
| `NAN_MEDIA_MCP_COMMAND` | — | Custom command override. |
|
|
128
142
|
| `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Per-call timeout. |
|
|
129
143
|
| `NAN_MCP_TOOLS` | — | Override for the official bridge (`0` to disable). |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtrabanco/pi-nan-provider",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
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",
|
|
@@ -84,9 +84,12 @@ const NAN_COMPAT = {
|
|
|
84
84
|
supportsDeveloperRole: false,
|
|
85
85
|
supportsReasoningEffort: true,
|
|
86
86
|
// pi-ai only sends `stream_options: { include_usage: true }` when this is
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
87
|
+
// not false. NaN's published schema does not document `stream_options`, so
|
|
88
|
+
// the conservative default is false: the sanitizer strips it and usage
|
|
89
|
+
// stays zero. A user who confirms their gateway reports streaming usage
|
|
90
|
+
// can opt in per model with a models.json compat override
|
|
91
|
+
// (`supportsUsageInStreaming: true`); the sanitizer then forwards
|
|
92
|
+
// `stream_options` instead of deleting it (issue #4, 2026-09-13).
|
|
90
93
|
supportsUsageInStreaming: false,
|
|
91
94
|
// The NaN/LiteLLM gateway intermittently closes SSE streams before emitting
|
|
92
95
|
// `finish_reason`. With true, pi-ai raises "Stream ended without
|
|
@@ -99,7 +102,7 @@ const NAN_COMPAT = {
|
|
|
99
102
|
};
|
|
100
103
|
|
|
101
104
|
const NAN_COMPAT_NOTE =
|
|
102
|
-
"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 false (2026-09-13
|
|
105
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13): pi-ai only sends stream_options when this is not false, and NaN's published schema does not document it, so the conservative default stays false (sanitizer removes stream_options, usage reads zero). A user who has confirmed that their model returns a streaming usage chunk can opt in per model with a models.json compat override (supportsUsageInStreaming: true); the sanitizer then forwards stream_options instead of deleting it (issue #4).";
|
|
103
106
|
|
|
104
107
|
interface ModelsDevModel {
|
|
105
108
|
id?: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// This file is auto-generated by scripts/generate-models.ts
|
|
2
2
|
// Do not edit manually — run `bun run generate-models` to update.
|
|
3
3
|
//
|
|
4
|
-
// Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-
|
|
4
|
+
// Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-14T20:40:55.922Z
|
|
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.
|
|
@@ -36,7 +36,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
36
36
|
"maxTokensField": "max_tokens"
|
|
37
37
|
},
|
|
38
38
|
"notes": [
|
|
39
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13
|
|
39
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13): pi-ai only sends stream_options when this is not false, and NaN's published schema does not document it, so the conservative default stays false (sanitizer removes stream_options, usage reads zero). A user who has confirmed that their model returns a streaming usage chunk can opt in per model with a models.json compat override (supportsUsageInStreaming: true); the sanitizer then forwards stream_options instead of deleting it (issue #4).",
|
|
40
40
|
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence."
|
|
41
41
|
],
|
|
42
42
|
"extras": {
|
|
@@ -97,7 +97,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
97
97
|
"maxTokensField": "max_tokens"
|
|
98
98
|
},
|
|
99
99
|
"notes": [
|
|
100
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13
|
|
100
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13): pi-ai only sends stream_options when this is not false, and NaN's published schema does not document it, so the conservative default stays false (sanitizer removes stream_options, usage reads zero). A user who has confirmed that their model returns a streaming usage chunk can opt in per model with a models.json compat override (supportsUsageInStreaming: true); the sanitizer then forwards stream_options instead of deleting it (issue #4)."
|
|
101
101
|
],
|
|
102
102
|
"extras": {
|
|
103
103
|
"id": "gemma4",
|
|
@@ -160,7 +160,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
160
160
|
"maxTokensField": "max_tokens"
|
|
161
161
|
},
|
|
162
162
|
"notes": [
|
|
163
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13
|
|
163
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13): pi-ai only sends stream_options when this is not false, and NaN's published schema does not document it, so the conservative default stays false (sanitizer removes stream_options, usage reads zero). A user who has confirmed that their model returns a streaming usage chunk can opt in per model with a models.json compat override (supportsUsageInStreaming: true); the sanitizer then forwards stream_options instead of deleting it (issue #4)."
|
|
164
164
|
],
|
|
165
165
|
"extras": {
|
|
166
166
|
"id": "glm5.3-flash",
|
|
@@ -219,7 +219,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
219
219
|
"maxTokensField": "max_tokens"
|
|
220
220
|
},
|
|
221
221
|
"notes": [
|
|
222
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13
|
|
222
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13): pi-ai only sends stream_options when this is not false, and NaN's published schema does not document it, so the conservative default stays false (sanitizer removes stream_options, usage reads zero). A user who has confirmed that their model returns a streaming usage chunk can opt in per model with a models.json compat override (supportsUsageInStreaming: true); the sanitizer then forwards stream_options instead of deleting it (issue #4)."
|
|
223
223
|
],
|
|
224
224
|
"extras": {
|
|
225
225
|
"id": "mimo-v2.5",
|
|
@@ -279,7 +279,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
279
279
|
"maxTokensField": "max_tokens"
|
|
280
280
|
},
|
|
281
281
|
"notes": [
|
|
282
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13
|
|
282
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13): pi-ai only sends stream_options when this is not false, and NaN's published schema does not document it, so the conservative default stays false (sanitizer removes stream_options, usage reads zero). A user who has confirmed that their model returns a streaming usage chunk can opt in per model with a models.json compat override (supportsUsageInStreaming: true); the sanitizer then forwards stream_options instead of deleting it (issue #4)."
|
|
283
283
|
],
|
|
284
284
|
"extras": {
|
|
285
285
|
"id": "qwen3.6",
|
|
@@ -342,7 +342,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
342
342
|
"maxTokensField": "max_tokens"
|
|
343
343
|
},
|
|
344
344
|
"notes": [
|
|
345
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13
|
|
345
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13): pi-ai only sends stream_options when this is not false, and NaN's published schema does not document it, so the conservative default stays false (sanitizer removes stream_options, usage reads zero). A user who has confirmed that their model returns a streaming usage chunk can opt in per model with a models.json compat override (supportsUsageInStreaming: true); the sanitizer then forwards stream_options instead of deleting it (issue #4).",
|
|
346
346
|
"contextWindow 262,144: the earlier 1,000,000 override (maintainer-confirmed 2026-09-05) was withdrawn 2026-09-07 — the updated https://nan.builders/docs/models still states '262K token context, the model's native window' and models.dev agrees at 262,144; NaN docs are treated as the most reliable source (maintainer instruction, 2026-09-07)."
|
|
347
347
|
],
|
|
348
348
|
"extras": {
|
|
@@ -382,13 +382,13 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
382
382
|
export const GENERATED_CATALOG_META = {
|
|
383
383
|
source: "https://models.dev/api.json",
|
|
384
384
|
modelsDevProvider: "nan",
|
|
385
|
-
fetchedAt: "2026-09-
|
|
385
|
+
fetchedAt: "2026-09-14T20:40:55.922Z",
|
|
386
386
|
modelCount: 6,
|
|
387
387
|
models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.5","qwen3.6","qwen3.8-flash"],
|
|
388
388
|
notes: [
|
|
389
389
|
"live-only: \"glm5.3\" kept out of the static catalog (premium-tier model (models.dev now documents it with 1M context / 131,072 max output; NaN docs https://nan.builders/docs/models + https://nan.builders/openapi.json, checked 2026-09-13) kept live-only so a non-premium key never sees a model it cannot call when the live /models fetch is unavailable; premium keys still get it via the /models refresh with conservative placeholder limits)",
|
|
390
390
|
"provider-removed: \"glm5.2\" excluded from the catalog (removed by NaN (2026-09-05); absent from the official chat model list in https://nan.builders/openapi.json and https://nan.builders/docs/models (checked 2026-09-07) while models.dev provider nan still listed it — excluded so regeneration does not resurrect it)",
|
|
391
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13
|
|
391
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming false (2026-09-13): pi-ai only sends stream_options when this is not false, and NaN's published schema does not document it, so the conservative default stays false (sanitizer removes stream_options, usage reads zero). A user who has confirmed that their model returns a streaming usage chunk can opt in per model with a models.json compat override (supportsUsageInStreaming: true); the sanitizer then forwards stream_options instead of deleting it (issue #4).",
|
|
392
392
|
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence.",
|
|
393
393
|
"contextWindow 262,144: the earlier 1,000,000 override (maintainer-confirmed 2026-09-05) was withdrawn 2026-09-07 — the updated https://nan.builders/docs/models still states '262K token context, the model's native window' and models.dev agrees at 262,144; NaN docs are treated as the most reliable source (maintainer instruction, 2026-09-07)."
|
|
394
394
|
],
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NaN-aware context-overflow classification.
|
|
3
|
+
*
|
|
4
|
+
* Root cause (pi-ai, still present on 0.85.1 / main): when history is replayed
|
|
5
|
+
* into a DIFFERENT model, `transformMessages` downgrades every non-redacted
|
|
6
|
+
* `thinking` block to plain `text` verbatim, with no size bound — and nothing
|
|
7
|
+
* bounds the SUM across messages. Switching from a 1M-context model
|
|
8
|
+
* (`glm5.3-flash`, `deepseek-v4-flash`) to a 262K one (`qwen3.6`) can therefore
|
|
9
|
+
* push the request past the destination window (see
|
|
10
|
+
* `src/cross-model-thinking-guard.ts` for the primary mitigation, which drops
|
|
11
|
+
* that replayed reasoning before pi-ai converts it).
|
|
12
|
+
*
|
|
13
|
+
* When the request still overflows — the guard is disabled
|
|
14
|
+
* (`NAN_THINKING_GUARD=0`), the trace is not a `thinking` block (large tool
|
|
15
|
+
* outputs, images), or the destination window is simply smaller — NaN's LiteLLM
|
|
16
|
+
* gateway answers the generic
|
|
17
|
+
* HTTP 400 `Invalid request. Check your request parameters.`
|
|
18
|
+
* instead of naming the context overflow. pi's auto-compaction keys on pi-ai's
|
|
19
|
+
* `isContextOverflow()`, whose documented patterns do NOT match that text, so
|
|
20
|
+
* the session wedges permanently at the ceiling (the issue's upstream
|
|
21
|
+
* earendil-works/pi#9409, "Sessions wedge permanently at the context ceiling on
|
|
22
|
+
* reasoning models").
|
|
23
|
+
*
|
|
24
|
+
* This module makes the package **re-check the request size on the way out**
|
|
25
|
+
* (one of the two upstream fixes the tracking issue names). After the request is
|
|
26
|
+
* sent and the gateway answers, if
|
|
27
|
+
* - the terminal assistant message is an error whose text carries NaN's
|
|
28
|
+
* generic 400 marker, AND
|
|
29
|
+
* - the request we sent was estimated to exceed the model's context window,
|
|
30
|
+
* then the error message is rewritten into a form that matches pi-ai's
|
|
31
|
+
* documented overflow patterns, so pi compacts and retries instead of wedging.
|
|
32
|
+
* The original provider text is preserved in the rewritten message. The
|
|
33
|
+
* reclassification is deliberately conservative: a generic 400 on a
|
|
34
|
+
* within-window request is left untouched, so unrelated 400s are never
|
|
35
|
+
* mislabelled as overflow.
|
|
36
|
+
*
|
|
37
|
+
* Estimation ratio: the same chars/3.47 the issue's offline session measurements
|
|
38
|
+
* use (`chars/3.47 + ~31K tools + ~12K system`). The system prompt and tool
|
|
39
|
+
* schemas are counted explicitly, so the constant is only applied to the
|
|
40
|
+
* message/character budget. This is a heuristic for deciding whether a generic
|
|
41
|
+
* 400 is plausibly an overflow — it never fabricates model metadata.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type {
|
|
45
|
+
AssistantMessage,
|
|
46
|
+
AssistantMessageEventStream,
|
|
47
|
+
Context,
|
|
48
|
+
Model,
|
|
49
|
+
ProviderStreams,
|
|
50
|
+
} from "@earendil-works/pi-ai";
|
|
51
|
+
|
|
52
|
+
/** The exact generic body NaN's gateway returns for an over-window request. */
|
|
53
|
+
export const NAN_GENERIC_BAD_REQUEST = "Invalid request. Check your request parameters.";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Characters per token used for the request-size estimate. Same ratio as the
|
|
57
|
+
* issue's offline measurements (chars/3.47); a conservative, provider-agnostic
|
|
58
|
+
* heuristic, never a capability claim.
|
|
59
|
+
*/
|
|
60
|
+
export const ESTIMATED_CHARS_PER_TOKEN = 3.47;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Estimate the input tokens of an outgoing request from its character size.
|
|
64
|
+
* Returns 0 when the context cannot be serialized (nothing is reclassified).
|
|
65
|
+
*/
|
|
66
|
+
export function estimateRequestTokens(
|
|
67
|
+
context: Pick<Context, "systemPrompt" | "messages" | "tools"> | undefined,
|
|
68
|
+
): number {
|
|
69
|
+
if (!context) return 0;
|
|
70
|
+
const systemChars = typeof context.systemPrompt === "string" ? context.systemPrompt.length : 0;
|
|
71
|
+
const toolsChars = safeJsonLength(context.tools);
|
|
72
|
+
const messagesChars = safeJsonLength(context.messages);
|
|
73
|
+
const total = systemChars + toolsChars + messagesChars;
|
|
74
|
+
if (!Number.isFinite(total) || total <= 0) return 0;
|
|
75
|
+
return Math.ceil(total / ESTIMATED_CHARS_PER_TOKEN);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeJsonLength(value: unknown): number {
|
|
79
|
+
if (value === undefined || value === null) return 0;
|
|
80
|
+
if (Array.isArray(value) && value.length === 0) return 0;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.stringify(value)?.length ?? 0;
|
|
83
|
+
} catch {
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True when a terminal error message carries NaN's generic-400 marker (with or
|
|
90
|
+
* without the `400: {...}` wrapper pi-ai adds).
|
|
91
|
+
*/
|
|
92
|
+
export function isGenericNanBadRequest(message: Pick<AssistantMessage, "errorMessage"> | undefined): boolean {
|
|
93
|
+
return typeof message?.errorMessage === "string" && message.errorMessage.includes(NAN_GENERIC_BAD_REQUEST);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reclassify a terminal assistant error as a context overflow when — and only
|
|
98
|
+
* when — NaN returned its generic 400 for a request estimated to exceed the
|
|
99
|
+
* model's context window. Mutates and returns the message (the same object
|
|
100
|
+
* flows through the error event and `result()`).
|
|
101
|
+
*/
|
|
102
|
+
export function classifyContextOverflowError<T extends AssistantMessage>(
|
|
103
|
+
message: T,
|
|
104
|
+
model: Pick<Model<"openai-completions">, "contextWindow"> | { contextWindow?: number } | undefined,
|
|
105
|
+
estimatedInputTokens: number,
|
|
106
|
+
): T {
|
|
107
|
+
if (message.stopReason !== "error") return message;
|
|
108
|
+
if (!isGenericNanBadRequest(message)) return message;
|
|
109
|
+
const contextWindow = model?.contextWindow ?? 0;
|
|
110
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return message;
|
|
111
|
+
if (!Number.isFinite(estimatedInputTokens) || estimatedInputTokens <= contextWindow) return message;
|
|
112
|
+
|
|
113
|
+
message.errorMessage =
|
|
114
|
+
`Requested token count exceeds the model's maximum context length of ${contextWindow} tokens ` +
|
|
115
|
+
`(estimated ${Math.ceil(estimatedInputTokens)} input tokens). ` +
|
|
116
|
+
`NaN's gateway answered HTTP 400 "${NAN_GENERIC_BAD_REQUEST}" instead of naming the overflow, ` +
|
|
117
|
+
`so the context must be compacted before retrying.`;
|
|
118
|
+
return message;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Wrap a provider stream so its terminal error is classified against the size
|
|
123
|
+
* of the request that produced it. Both the `error` event and `result()` yield
|
|
124
|
+
* the same (mutated) message, so iterating consumers and `result()`-only
|
|
125
|
+
* consumers agree.
|
|
126
|
+
*/
|
|
127
|
+
export function classifyStreamContextOverflow(
|
|
128
|
+
stream: AssistantMessageEventStream,
|
|
129
|
+
model: { contextWindow?: number } | undefined,
|
|
130
|
+
estimatedInputTokens: number,
|
|
131
|
+
): AssistantMessageEventStream {
|
|
132
|
+
const rewrite = (message: AssistantMessage | undefined): AssistantMessage | undefined => {
|
|
133
|
+
if (message) classifyContextOverflowError(message, model, estimatedInputTokens);
|
|
134
|
+
return message;
|
|
135
|
+
};
|
|
136
|
+
const resultPromise = stream.result().then(rewrite);
|
|
137
|
+
|
|
138
|
+
return new Proxy(stream, {
|
|
139
|
+
get(target, property, receiver) {
|
|
140
|
+
if (property === "result") return () => resultPromise;
|
|
141
|
+
if (property === Symbol.asyncIterator) {
|
|
142
|
+
return () => {
|
|
143
|
+
const iterator = target[Symbol.asyncIterator]();
|
|
144
|
+
return {
|
|
145
|
+
async next() {
|
|
146
|
+
const step = await iterator.next();
|
|
147
|
+
if (!step.done && step.value?.type === "error") rewrite(step.value.error);
|
|
148
|
+
return step;
|
|
149
|
+
},
|
|
150
|
+
async return(value?: unknown) {
|
|
151
|
+
return iterator.return ? iterator.return(value) : { done: true, value };
|
|
152
|
+
},
|
|
153
|
+
async throw(error?: unknown) {
|
|
154
|
+
if (iterator.throw) return iterator.throw(error);
|
|
155
|
+
throw error;
|
|
156
|
+
},
|
|
157
|
+
[Symbol.asyncIterator]() {
|
|
158
|
+
return this;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const value = Reflect.get(target, property, receiver);
|
|
164
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
165
|
+
},
|
|
166
|
+
}) as AssistantMessageEventStream;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Wrap every `stream`/`streamSimple` call so a NaN over-window request whose
|
|
171
|
+
* gateway answer is the opaque generic 400 is surfaced as a context overflow.
|
|
172
|
+
*/
|
|
173
|
+
export function withContextOverflowClassification(api: ProviderStreams): ProviderStreams {
|
|
174
|
+
return {
|
|
175
|
+
...api,
|
|
176
|
+
stream: (model, context, options) =>
|
|
177
|
+
classifyStreamContextOverflow(api.stream(model, context, options), model, estimateRequestTokens(context)),
|
|
178
|
+
streamSimple: (model, context, options) =>
|
|
179
|
+
classifyStreamContextOverflow(api.streamSimple(model, context, options), model, estimateRequestTokens(context)),
|
|
180
|
+
};
|
|
181
|
+
}
|
package/src/fetch-models.ts
CHANGED
|
@@ -9,19 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 2. Runtime fetch of the provider's own `/models` endpoint. NaN runs
|
|
11
11
|
* LiteLLM behind an OpenAI-compatible facade, so the response carries
|
|
12
|
-
* only model `id`s — no capability fields. It is used
|
|
13
|
-
*
|
|
12
|
+
* only model `id`s — no capability fields. It is used as the authoritative
|
|
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
|
-
*
|
|
22
|
-
* and non-chat endpoints never surface. On fetch failure, timeout, or an
|
|
23
|
-
* unusable response, callers fall back to the generated catalog so startup
|
|
24
|
-
* is never blocked.
|
|
15
|
+
* Merge: live IDs × generated capability data — all live IDs surface:
|
|
16
|
+
* a live ID with generated data keeps its generated capabilities, and an
|
|
17
|
+
* uncatalogued live ID (e.g. a new or undocumented model) gets conservative
|
|
18
|
+
* placeholder limits (the same defaults used in custom-provider.md's
|
|
19
|
+
* dynamic-discovery example) with no reasoning support — capabilities stay
|
|
20
|
+
* "unknown", nothing is fabricated. On fetch failure, timeout, or an unusable
|
|
21
|
+
* response, callers fall back to the generated catalog so startup is never blocked.
|
|
25
22
|
*/
|
|
26
23
|
|
|
27
24
|
import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
|
|
@@ -64,28 +61,6 @@ export const NAN_COMPAT_API = "openai-completions" as const;
|
|
|
64
61
|
*/
|
|
65
62
|
export const UNKNOWN_MODEL_LIMITS = { contextWindow: 128_000, maxTokens: 4_096 } as const;
|
|
66
63
|
|
|
67
|
-
/**
|
|
68
|
-
* The allowlist of model ids this package may surface. Everything a live
|
|
69
|
-
* /models response returns that is NOT here is dropped — so models that are
|
|
70
|
-
* undocumented (and might become available later, e.g. one NaN starts
|
|
71
|
-
* serving after a release) can never leak through the package.
|
|
72
|
-
*
|
|
73
|
-
* Every current chat model is listed: the six models.dev-documented entries
|
|
74
|
-
* plus the premium live-only glm5.3 (which NaN serves via /models but that
|
|
75
|
-
* models.dev omits — see the "unemittable" note in scripts/generate-models.ts).
|
|
76
|
-
* Non-chat endpoints (flux-2-klein, kokoro, whisper, qwen3-embedding, rerank)
|
|
77
|
-
* are deliberately excluded: they are MCP-bridge territory, not chat models.
|
|
78
|
-
*/
|
|
79
|
-
const ALLOWED_MODEL_IDS = [
|
|
80
|
-
"deepseek-v4-flash",
|
|
81
|
-
"gemma4",
|
|
82
|
-
"glm5.3-flash",
|
|
83
|
-
"glm5.3",
|
|
84
|
-
"mimo-v2.5",
|
|
85
|
-
"qwen3.6",
|
|
86
|
-
"qwen3.8-flash",
|
|
87
|
-
] as const;
|
|
88
|
-
|
|
89
64
|
/** Timeout for the live /models fetch; matches the pi-synthetic-provider precedent (~3s). */
|
|
90
65
|
export const DEFAULT_MODELS_TIMEOUT_MS = 3_000;
|
|
91
66
|
|
|
@@ -113,10 +88,9 @@ export function toModel(entry: GeneratedModelEntry, source: CatalogSource): Mode
|
|
|
113
88
|
};
|
|
114
89
|
}
|
|
115
90
|
|
|
116
|
-
/** The generated fallback catalog as pi-ai Models for the given provider
|
|
91
|
+
/** The generated fallback catalog as pi-ai Models for the given provider. */
|
|
117
92
|
export function baselineModels(source: CatalogSource): Model<"openai-completions">[] {
|
|
118
|
-
|
|
119
|
-
return NAN_GENERATED_MODELS.filter((entry) => allowed.has(entry.id)).map((entry) => toModel(entry, source));
|
|
93
|
+
return NAN_GENERATED_MODELS.map((entry) => toModel(entry, source));
|
|
120
94
|
}
|
|
121
95
|
|
|
122
96
|
export interface LiveModelListOptions {
|
|
@@ -174,12 +148,11 @@ export interface MergedCatalog {
|
|
|
174
148
|
}
|
|
175
149
|
|
|
176
150
|
/**
|
|
177
|
-
* Merge live model IDs with the generated capability catalog
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* live ID is dropped.
|
|
151
|
+
* Merge live model IDs with the generated capability catalog. All live IDs
|
|
152
|
+
* surface: live IDs with generated data keep their generated capabilities;
|
|
153
|
+
* uncatalogued live IDs get conservative placeholder limits,
|
|
154
|
+
* `reasoning: false`, and zero cost (documented defaults, not invented
|
|
155
|
+
* capabilities).
|
|
183
156
|
*/
|
|
184
157
|
export function mergeLiveWithGenerated(
|
|
185
158
|
liveIds: readonly string[],
|
|
@@ -187,27 +160,27 @@ export function mergeLiveWithGenerated(
|
|
|
187
160
|
generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
|
|
188
161
|
): MergedCatalog {
|
|
189
162
|
const byId = new Map(generated.map((entry) => [entry.id, entry]));
|
|
190
|
-
const allowed = new Set<string>(ALLOWED_MODEL_IDS);
|
|
191
163
|
const models: Model<"openai-completions">[] = [];
|
|
192
164
|
const matched: string[] = [];
|
|
193
165
|
const unknown: string[] = [];
|
|
194
166
|
|
|
195
167
|
for (const id of liveIds) {
|
|
196
|
-
if (!allowed.has(id)) continue;
|
|
197
168
|
const entry = byId.get(id);
|
|
198
169
|
if (entry) {
|
|
199
170
|
models.push(toModel(entry, source));
|
|
200
171
|
matched.push(id);
|
|
201
172
|
} else {
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
173
|
+
// Placeholder for uncatalogued live ids (e.g. new or undocumented):
|
|
174
|
+
// limits are the documented safe envelope and capabilities stay
|
|
175
|
+
// "unknown". The compat keys are the same gateway-behavior flags
|
|
176
|
+
// applied to every catalog model in NAN_COMPAT: the NaN/LiteLLM
|
|
177
|
+
// gateway cuts SSE streams before finish_reason, so supportsFinishReason
|
|
178
|
+
// must stay true (pi-ai then raises the retryable "Stream ended without
|
|
179
|
+
// finish_reason" instead of silently stalling), and
|
|
180
|
+
// supportsUsageInStreaming stays false because NaN's schema does not
|
|
181
|
+
// document `stream_options`: the sanitizer strips it unless the model's
|
|
182
|
+
// effective compat opts in (issue #4), so false matches the default wire
|
|
183
|
+
// payload.
|
|
211
184
|
models.push({
|
|
212
185
|
id,
|
|
213
186
|
name: id,
|
package/src/mcp/nan-media.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* other env vars inherit from your environment (generated files land in
|
|
15
15
|
* ~/nan-mcp-output/ by default).
|
|
16
16
|
* - Version pinning follows the upstream server's own supply-chain guidance:
|
|
17
|
-
* NAN_MEDIA_MCP_VERSION (default "1.
|
|
18
|
-
* NAN_MEDIA_MCP_COMMAND (space-separated, e.g. "bunx nan-mcp-server@1.
|
|
17
|
+
* NAN_MEDIA_MCP_VERSION (default "1.1.2"), or pass a custom command with
|
|
18
|
+
* NAN_MEDIA_MCP_COMMAND (space-separated, e.g. "bunx nan-mcp-server@1.1.2").
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { Type, type TSchema } from "@earendil-works/pi-ai";
|
|
@@ -28,7 +28,7 @@ export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
|
|
|
28
28
|
export const NAN_MEDIA_MCP_VERSION_ENV = "NAN_MEDIA_MCP_VERSION";
|
|
29
29
|
export const NAN_MEDIA_MCP_COMMAND_ENV = "NAN_MEDIA_MCP_COMMAND";
|
|
30
30
|
export const NAN_MEDIA_MCP_TIMEOUT_ENV = "NAN_MEDIA_MCP_TIMEOUT_MS";
|
|
31
|
-
export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.
|
|
31
|
+
export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.1.2";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* The MCP tool names on the stdio nan-mcp-server that this package bridges as
|
|
@@ -143,7 +143,7 @@ function defineMediaTool<TParams extends TSchema>(spec: MediaToolSpec<TParams>):
|
|
|
143
143
|
/**
|
|
144
144
|
* Build the media tool set. Registered only when NAN_MEDIA_MCP=1 and the
|
|
145
145
|
* runtime supports registerTool; execution spawns the MCP server per call.
|
|
146
|
-
* Schemas mirror nan-mcp-server's zod input schemas (v1.
|
|
146
|
+
* Schemas mirror nan-mcp-server's zod input schemas (v1.1.2).
|
|
147
147
|
*/
|
|
148
148
|
export function createNanMediaTools(): ToolDefinition[] {
|
|
149
149
|
return [
|
package/src/mcp/stdio-client.ts
CHANGED
|
@@ -18,7 +18,7 @@ const PROTOCOL_VERSION = "2024-11-05";
|
|
|
18
18
|
const CLIENT_INFO = { name: "pi-nan-provider", version: "0.2.0" };
|
|
19
19
|
|
|
20
20
|
export interface StdioMcpCallOptions {
|
|
21
|
-
/** Command to spawn, e.g. ["npx", "-y", "nan-mcp-server@1.
|
|
21
|
+
/** Command to spawn, e.g. ["npx", "-y", "nan-mcp-server@1.1.2"]. */
|
|
22
22
|
command: readonly string[];
|
|
23
23
|
/** Extra environment for the child (merged over process.env). */
|
|
24
24
|
env?: Record<string, string | undefined>;
|
|
@@ -34,9 +34,13 @@
|
|
|
34
34
|
* 4. Top-level fields NaN's schema does not list: `store` and
|
|
35
35
|
* `stream_options`. These are opt-in/usage fields pi-ai sends by default
|
|
36
36
|
* for a "standard" provider; NaN does not document them, so they are
|
|
37
|
-
* removed.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* removed by default. `stream_options` is the one exception that can be
|
|
38
|
+
* explicitly opted into: when the model's effective
|
|
39
|
+
* `compat.supportsUsageInStreaming` is true (a catalog flag or a user
|
|
40
|
+
* `models.json` override), the caller has confirmed the gateway reports
|
|
41
|
+
* usage and `stream_options` is preserved — deleting it unconditionally
|
|
42
|
+
* would silently zero out `message.usage` (issue #4). `store` is always
|
|
43
|
+
* removed.
|
|
40
44
|
* 5. An EMPTY `tools` array. Verified against the live gateway (2026-09-09):
|
|
41
45
|
* NaN rejects `tools: []` with the same 400, while `stream: true`, a
|
|
42
46
|
* `system` message, string content, and a `tool` role message are all
|
|
@@ -183,12 +187,23 @@ function sanitizeMessage(message: unknown): unknown {
|
|
|
183
187
|
return out;
|
|
184
188
|
}
|
|
185
189
|
|
|
190
|
+
export interface SanitizerOptions {
|
|
191
|
+
/**
|
|
192
|
+
* Keep `stream_options` in the payload. Set only when the model's effective
|
|
193
|
+
* `compat.supportsUsageInStreaming` is true: pi-ai then asks the gateway for
|
|
194
|
+
* `stream_options: { include_usage: true }` and expects the terminal usage
|
|
195
|
+
* chunk. When false/absent, `stream_options` is stripped from the strict
|
|
196
|
+
* NaN payload as before.
|
|
197
|
+
*/
|
|
198
|
+
preserveStreamOptions?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
186
201
|
/**
|
|
187
202
|
* Rewrite an OpenAI-compatible `/chat/completions` payload so every field
|
|
188
203
|
* conforms to NaN's published schema. Returns the updated payload; if the
|
|
189
204
|
* payload has no `messages` array it is returned unchanged.
|
|
190
205
|
*/
|
|
191
|
-
export function sanitizeOpenAICompatPayload(payload: unknown): unknown {
|
|
206
|
+
export function sanitizeOpenAICompatPayload(payload: unknown, options: SanitizerOptions = {}): unknown {
|
|
192
207
|
if (!isObject(payload) || !Array.isArray(payload.messages)) return payload;
|
|
193
208
|
|
|
194
209
|
const messages = payload.messages.map(sanitizeMessage);
|
|
@@ -221,7 +236,11 @@ export function sanitizeOpenAICompatPayload(payload: unknown): unknown {
|
|
|
221
236
|
|
|
222
237
|
// Top-level fields absent from NaN's schema.
|
|
223
238
|
delete out.store;
|
|
224
|
-
|
|
239
|
+
// `stream_options` is preserved only when the model declares that the
|
|
240
|
+
// gateway reports usage in the stream (issue #4). Otherwise it is stripped:
|
|
241
|
+
// pi-ai would not send it for such a model anyway, and a hand-built payload
|
|
242
|
+
// must stay schema-strict.
|
|
243
|
+
if (options.preserveStreamOptions !== true) delete out.stream_options;
|
|
225
244
|
// NaN documents `max_tokens`, not `max_completion_tokens`.
|
|
226
245
|
if ("max_completion_tokens" in out && !("max_tokens" in out)) {
|
|
227
246
|
out.max_tokens = out.max_completion_tokens;
|
package/src/provider-factory.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
ProviderStreams,
|
|
27
27
|
RefreshModelsContext,
|
|
28
28
|
} from "@earendil-works/pi-ai";
|
|
29
|
+
import { withContextOverflowClassification } from "./context-overflow-classifier.ts";
|
|
29
30
|
import {
|
|
30
31
|
baselineModels,
|
|
31
32
|
DEFAULT_MODELS_TIMEOUT_MS,
|
|
@@ -92,6 +93,12 @@ export async function resolveOpenAICompletionsApi(): Promise<OpenAICompletionsAp
|
|
|
92
93
|
* which pi-ai version the runtime bundles, so the fix is not tied to a
|
|
93
94
|
* specific upstream build.
|
|
94
95
|
*
|
|
96
|
+
* `stream_options` is the one field whose removal is conditional: when the
|
|
97
|
+
* model's effective `compat.supportsUsageInStreaming` is true (catalog value
|
|
98
|
+
* or user `models.json` override), pi-ai requested usage and the gateway will
|
|
99
|
+
* return it — stripping the field would silently zero `message.usage`
|
|
100
|
+
* (issue #4). Every other model keeps the strict payload.
|
|
101
|
+
*
|
|
95
102
|
* Any caller-supplied `onPayload` (e.g. pi's own debug/passthrough hook) is
|
|
96
103
|
* preserved and chained AFTER sanitization, so the final payload is always
|
|
97
104
|
* schema-valid.
|
|
@@ -100,13 +107,27 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
100
107
|
return typeof value === "object" && value !== null;
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Whether the model's effective compat asks pi-ai for streaming usage. pi-ai
|
|
112
|
+
* emits `stream_options: { include_usage: true }` when this is not false, and
|
|
113
|
+
* `models.json` overrides compose above the registered catalog, so this reads
|
|
114
|
+
* the user's confirmed value rather than the generated default.
|
|
115
|
+
*/
|
|
116
|
+
function modelSupportsUsageInStreaming(model: unknown): boolean {
|
|
117
|
+
if (!isObject(model)) return false;
|
|
118
|
+
const compat = model.compat;
|
|
119
|
+
return isObject(compat) && compat.supportsUsageInStreaming === true;
|
|
120
|
+
}
|
|
121
|
+
|
|
103
122
|
export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStreams {
|
|
104
123
|
const withSanitizer = <TOptions extends object | undefined>(options: TOptions): TOptions => {
|
|
105
124
|
const userOnPayload = isObject(options) ? (options.onPayload as unknown) : undefined;
|
|
106
125
|
return {
|
|
107
126
|
...((options ?? {}) as Record<string, unknown>),
|
|
108
127
|
onPayload: async (payload: unknown, model: unknown) => {
|
|
109
|
-
const sanitized = sanitizeOpenAICompatPayload(payload
|
|
128
|
+
const sanitized = sanitizeOpenAICompatPayload(payload, {
|
|
129
|
+
preserveStreamOptions: modelSupportsUsageInStreaming(model),
|
|
130
|
+
});
|
|
110
131
|
if (typeof userOnPayload === "function") {
|
|
111
132
|
const userResult = await (userOnPayload as (p: unknown, m: unknown) => unknown)(sanitized, model);
|
|
112
133
|
return userResult ?? sanitized;
|
|
@@ -173,6 +194,12 @@ export async function createNanCompatibleProvider(
|
|
|
173
194
|
const current = liveIds;
|
|
174
195
|
return current ? models.filter((model) => current.has(model.id)) : models;
|
|
175
196
|
},
|
|
176
|
-
|
|
197
|
+
// Sanitize the payload for NaN's strict schema, then classify an opaque
|
|
198
|
+
// generic 400 as a context overflow when the request we just sent was over
|
|
199
|
+
// the model's window. The second layer keeps a replayed cross-model
|
|
200
|
+
// reasoning trace (or any other over-window request the context-hook guard
|
|
201
|
+
// cannot reach, e.g. NAN_THINKING_GUARD=0) recoverable instead of wedging
|
|
202
|
+
// the session — see src/context-overflow-classifier.ts.
|
|
203
|
+
api: withContextOverflowClassification(wrapApiForStrictSanitization(apiFactory())),
|
|
177
204
|
});
|
|
178
205
|
}
|