@gtrabanco/pi-nan-provider 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +1 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/scripts/generate-models.ts +2 -1
- package/scripts/models.generated.ts +15 -9
- package/src/fetch-models.ts +8 -0
- package/src/openai-compat-sanitizer.ts +242 -0
- package/src/provider-factory.ts +44 -1
package/README.es.md
CHANGED
|
@@ -143,6 +143,7 @@ Notas (grabadas por entrada en `scripts/models.generated.ts`):
|
|
|
143
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
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
145
|
- Compat (`supportsDeveloperRole: false`, `supportsReasoningEffort: true`, `supportsUsageInStreaming: true`, `maxTokensField: "max_tokens"`) coincide con la config LiteLLM probada en batalla que este paquete reemplaza; el ejemplo de los docs de NaN (`supportsDeveloperRole: true`) no está probado.
|
|
146
|
+
- **Conformidad con el esquema estricto**: NaN valida cada payload de `/chat/completions` contra su propio esquema estricto ([openapi.json](https://nan.builders/openapi.json)) y devuelve HTTP 400 `Invalid request. Check your request parameters.` para formas no permitidas — p. ej. un mensaje `assistant` reenviado con un bloque `toolCall` dentro de `content`, un campo `reasoning_details` solo de OpenAI, campos a nivel superior no documentados como `store` / `stream_options`, o un **array vacío `tools: []`**. Por eso cada petición se reescribe con un saneador en el proveedor (`src/openai-compat-sanitizer.ts`, conectado vía `onPayload` en la fábrica compartida) para que siempre sea válida según el esquema, sin importar qué versión de pi-ai esté empaquetada:
|
|
146
147
|
- **Tier/cuota**: qué modelos puedes llamar lo decide tu membresía de NaN. Con clave, el fetch en vivo refleja exactamente eso (ver *Cómo funciona* — detección de tier). El `glm5.3` de tier premium no está en el proveedor `nan` de models.dev y ninguna fuente documenta su límite de salida, así que no entra en el catálogo estático (marcado como no emitible en los metadatos); las claves premium lo reciben en vivo vía el refresh de `/models`, con límites conservadores (128K contexto / 4K salida). Solo está `glm5.3-flash` en el catálogo estático.
|
|
147
148
|
|
|
148
149
|
### Relación con `~/.pi/agent/models.json`
|
package/README.md
CHANGED
|
@@ -143,6 +143,7 @@ Notes (recorded per entry in `scripts/models.generated.ts`):
|
|
|
143
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
144
|
- NaN bills via membership quota, which models.dev reports as zero per-token cost — pi's cost display will read $0.
|
|
145
145
|
- Compat (`supportsDeveloperRole: false`, `supportsReasoningEffort: true`, `supportsUsageInStreaming: true`, `maxTokensField: "max_tokens"`) matches the battle-tested LiteLLM config this package replaces; NaN's docs example (`supportsDeveloperRole: true`) is not battle-tested.
|
|
146
|
+
- **Strict schema conformance**: NaN validates every `/chat/completions` payload against its own strict schema ([openapi.json](https://nan.builders/openapi.json)) and returns HTTP 400 `Invalid request. Check your request parameters.` for disallowed shapes — e.g. a replayed `assistant` message with a `toolCall` block inside `content`, an OpenAI-only `reasoning_details` field, undocumented top-level fields like `store` / `stream_options`, or an **empty `tools: []` array**. Every request is therefore rewritten by a provider-side sanitizer (`src/openai-compat-sanitizer.ts`, wired through the shared factory's `onPayload`) so it is always schema-valid no matter which pi-ai version is bundled:
|
|
146
147
|
- **Tier/quota**: which models you can call is decided by your NaN membership. With a key, the live fetch reflects exactly that (see *How it works* — tier detection). The premium-tier `glm5.3` is absent from the models.dev `nan` provider and no source documents its max output tokens, so it is not in the static catalog (flagged as unemittable in the catalog metadata); premium keys still get it live via the `/models` refresh with conservative placeholder limits (128K context / 4K output). Only `glm5.3-flash` is in the static catalog.
|
|
147
148
|
|
|
148
149
|
### Relationship to `~/.pi/agent/models.json`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtrabanco/pi-nan-provider",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "NaN Builders (api.nan.builders) model provider for pi - OpenAI-compatible registration with a models.dev-generated fallback, tier-aware live catalog, and MCP bridges (official web search + optional community media server)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -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-
|
|
4
|
+
// Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-09T23:52:27.718Z
|
|
5
5
|
// Provenance: every contextWindow/maxTokens/input/cost value traces to
|
|
6
6
|
// models.dev or to the per-entry notes below. Nothing is invented; entries
|
|
7
7
|
// models.dev documents incompletely are omitted and flagged instead.
|
|
@@ -32,10 +32,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
32
32
|
"supportsDeveloperRole": false,
|
|
33
33
|
"supportsReasoningEffort": true,
|
|
34
34
|
"supportsUsageInStreaming": true,
|
|
35
|
+
"supportsFinishReason": false,
|
|
35
36
|
"maxTokensField": "max_tokens"
|
|
36
37
|
},
|
|
37
38
|
"notes": [
|
|
38
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.",
|
|
39
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.",
|
|
39
40
|
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models, checked 2026-09-07; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models); models.dev provider nan lists text only."
|
|
40
41
|
],
|
|
41
42
|
"extras": {
|
|
@@ -91,10 +92,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
91
92
|
"supportsDeveloperRole": false,
|
|
92
93
|
"supportsReasoningEffort": true,
|
|
93
94
|
"supportsUsageInStreaming": true,
|
|
95
|
+
"supportsFinishReason": false,
|
|
94
96
|
"maxTokensField": "max_tokens"
|
|
95
97
|
},
|
|
96
98
|
"notes": [
|
|
97
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
|
|
99
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
|
|
98
100
|
],
|
|
99
101
|
"extras": {
|
|
100
102
|
"id": "gemma4",
|
|
@@ -153,10 +155,11 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
|
|
|
153
155
|
"supportsDeveloperRole": false,
|
|
154
156
|
"supportsReasoningEffort": true,
|
|
155
157
|
"supportsUsageInStreaming": true,
|
|
158
|
+
"supportsFinishReason": false,
|
|
156
159
|
"maxTokensField": "max_tokens"
|
|
157
160
|
},
|
|
158
161
|
"notes": [
|
|
159
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested."
|
|
162
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring."
|
|
160
163
|
],
|
|
161
164
|
"extras": {
|
|
162
165
|
"id": "glm5.3-flash",
|
|
@@ -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-
|
|
384
|
+
fetchedAt: "2026-09-09T23:52:27.718Z",
|
|
379
385
|
modelCount: 6,
|
|
380
386
|
models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.5","qwen3.6","qwen3.8-flash"],
|
|
381
387
|
notes: [
|
|
382
388
|
"provider-removed: \"glm5.2\" excluded from the catalog (removed by NaN (2026-09-05); absent from the official chat model list in https://nan.builders/openapi.json and https://nan.builders/docs/models (checked 2026-09-07) while models.dev provider nan still listed it — excluded so regeneration does not resurrect it)",
|
|
383
389
|
"glm5.3: served by NaN on the GLM 5.3 premium tier (https://nan.builders/docs/models + https://nan.builders/openapi.json, checked 2026-09-07) but absent from models.dev, and no source documents its max output tokens — no entry is generated (no-fabrication rule); premium keys still get it live via the /models refresh with conservative placeholder limits",
|
|
384
|
-
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested.",
|
|
390
|
+
"compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, supportsUsageInStreaming true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason false added 2026-09-08: the LiteLLM gateway intermittently cuts SSE streams before emitting finish_reason (observed on glm5.3-flash, ~2026-09-08), and with the default true pi-ai throws 'Stream ended without finish_reason'; false makes pi-ai treat those truncated streams as stop/toolUse instead of erroring.",
|
|
385
391
|
"input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models, checked 2026-09-07; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models); models.dev provider nan lists text only.",
|
|
386
392
|
"contextWindow 262,144: the earlier 1,000,000 override (maintainer-confirmed 2026-09-05) was withdrawn 2026-09-07 — the updated https://nan.builders/docs/models still states '262K token context, the model's native window' and models.dev agrees at 262,144; NaN docs are treated as the most reliable source (maintainer instruction, 2026-09-07)."
|
|
387
393
|
],
|
package/src/fetch-models.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
package/src/provider-factory.ts
CHANGED
|
@@ -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
|
}
|