@gtrabanco/pi-nan-provider 0.10.3 → 1.0.1
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 +68 -9
- package/README.es.md +57 -9
- package/README.md +60 -10
- package/package.json +5 -5
- package/scripts/manual-overrides.ts +2 -0
- package/scripts/models.generated.ts +5 -5
- package/src/commands.ts +6 -4
- package/src/context-overflow-classifier.ts +114 -23
- package/src/fetch-models.ts +147 -2
- package/src/images.ts +408 -0
- package/src/index.ts +5 -2
- package/src/provider-factory.ts +109 -30
- package/src/reasoning-only-stream-guard.ts +201 -0
|
@@ -23,22 +23,50 @@
|
|
|
23
23
|
*
|
|
24
24
|
* This module makes the package **re-check the request size on the way out**
|
|
25
25
|
* (one of the two upstream fixes the tracking issue names). After the request is
|
|
26
|
-
* sent and the gateway answers,
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
26
|
+
* sent and the gateway answers, two independent branches reclassify:
|
|
27
|
+
*
|
|
28
|
+
* 1. **HTTP 400 branch** — keys on the **model's context window**. When the
|
|
29
|
+
* terminal error carries NaN's generic 400 marker AND the estimated input
|
|
30
|
+
* tokens exceed the destination model's `contextWindow`, the message is
|
|
31
|
+
* rewritten. A 400 may be caused by other schema violations, so the rewrite
|
|
32
|
+
* conservatively requires the request to be over the window.
|
|
33
|
+
*
|
|
34
|
+
* 2. **HTTP 524 branch** — keys on a **fixed cold-cache ceiling**
|
|
35
|
+
* (`NAN_COLD_CACHE_CEILING_TOKENS`, 200,000). Cloudflare's 524
|
|
36
|
+
* origin_response_timeout fires when a large cold prompt hangs past the 120s
|
|
37
|
+
* proxy read timeout. A 524 on an over-ceiling request is an overflow
|
|
38
|
+
* masquerading as a timeout; a 524 under the ceiling is a genuine transient
|
|
39
|
+
* timeout. The ceiling is a *practical endpoint limit* (Cloudflare 120s vs
|
|
40
|
+
* cold-prompt prefill), NOT a model capability — so the model's
|
|
41
|
+
* `contextWindow` is irrelevant to the 524 reclassify decision. The rewritten
|
|
42
|
+
* message still mentions the model window for user clarity, but the
|
|
43
|
+
* overflow-classification hinges only on the ceiling.
|
|
44
|
+
*
|
|
45
|
+
* The original provider text is preserved in rewritten messages. The
|
|
46
|
+
* reclassification is deliberately conservative so unrelated errors are never
|
|
35
47
|
* mislabelled as overflow.
|
|
36
48
|
*
|
|
49
|
+
* ---
|
|
50
|
+
*
|
|
51
|
+
* Cold-cache 524 (issue #18): large COLD prompts (≳1 MB body / ~220k tokens) to
|
|
52
|
+
* https://api.nan.builders/v1/chat/completions hang past Cloudflare's 120s Proxy
|
|
53
|
+
* Read Timeout → HTTP 524 origin_response_timeout with JSON body
|
|
54
|
+
* {"status":524,"error_name":"origin_response_timeout","retryable":true,...}
|
|
55
|
+
* (sometimes a Cloudflare HTML page for even larger bodies). Measured: cold
|
|
56
|
+
* 208,036 tokens (960 KB) succeeds (TTFT 12.9 s); ~220k and ~286k tokens cold
|
|
57
|
+
* → 524 at ~126 s. pi-ai classifies 524 as RETRYABLE (retrying an identical cold
|
|
58
|
+
* payload always fails) and its `isContextOverflow()` matches nothing, so the
|
|
59
|
+
* session wedges. When the error is a 524 AND the estimated input tokens exceed
|
|
60
|
+
* `NAN_COLD_CACHE_CEILING_TOKENS`, the error is rewritten into a pi-recognizable
|
|
61
|
+
* overflow message so pi compacts and retries instead of wedging. The original
|
|
62
|
+
* provider text is preserved. A 524 on a small request (under the ceiling) is
|
|
63
|
+
* left untouched — it is a genuine transient timeout.
|
|
64
|
+
*
|
|
37
65
|
* Estimation ratio: the same chars/3.47 the issue's offline session measurements
|
|
38
66
|
* use (`chars/3.47 + ~31K tools + ~12K system`). The system prompt and tool
|
|
39
67
|
* schemas are counted explicitly, so the constant is only applied to the
|
|
40
68
|
* message/character budget. This is a heuristic for deciding whether a generic
|
|
41
|
-
* 400 is plausibly an overflow — it never fabricates model metadata.
|
|
69
|
+
* 400 / 524 is plausibly an overflow — it never fabricates model metadata.
|
|
42
70
|
*/
|
|
43
71
|
|
|
44
72
|
import type {
|
|
@@ -52,6 +80,19 @@ import type {
|
|
|
52
80
|
/** The exact generic body NaN's gateway returns for an over-window request. */
|
|
53
81
|
export const NAN_GENERIC_BAD_REQUEST = "Invalid request. Check your request parameters.";
|
|
54
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Cloudflare 524 origin_response_timeout cold-cache ceiling (tokens).
|
|
85
|
+
*
|
|
86
|
+
* Measured (issue #18 repro): cold 208,036 tokens (960 KB) succeeds
|
|
87
|
+
* (TTFT 12.9 s); ~220k and ~286k tokens cold → 524 at ~126 s. 200,000 is a
|
|
88
|
+
* conservative midpoint — well below the working 208k measurement — so the
|
|
89
|
+
* working cold cache path is not affected while larger cold requests hit this
|
|
90
|
+
* rewrite. This is a practical endpoint ceiling (Cloudflare 120s proxy read
|
|
91
|
+
* timeout vs cold prompt prefill), NOT a model capability — contextWindow in
|
|
92
|
+
* the catalog must NOT change.
|
|
93
|
+
*/
|
|
94
|
+
export const NAN_COLD_CACHE_CEILING_TOKENS = 200_000;
|
|
95
|
+
|
|
55
96
|
/**
|
|
56
97
|
* Characters per token used for the request-size estimate. Same ratio as the
|
|
57
98
|
* issue's offline measurements (chars/3.47); a conservative, provider-agnostic
|
|
@@ -93,11 +134,34 @@ export function isGenericNanBadRequest(message: Pick<AssistantMessage, "errorMes
|
|
|
93
134
|
return typeof message?.errorMessage === "string" && message.errorMessage.includes(NAN_GENERIC_BAD_REQUEST);
|
|
94
135
|
}
|
|
95
136
|
|
|
137
|
+
/**
|
|
138
|
+
* True when a terminal error message carries a Cloudflare 524
|
|
139
|
+
* origin_response_timeout marker. Matches the 524 status followed by either
|
|
140
|
+
* `origin_response_timeout` in the JSON body, or Cloudflare HTML markers
|
|
141
|
+
* (`Error 524` / `A timeout occurred`). Conservative: only these known
|
|
142
|
+
* markers are matched, not any arbitrary 524 response.
|
|
143
|
+
*/
|
|
144
|
+
function isCloudflare524(message: Pick<AssistantMessage, "errorMessage"> | undefined): boolean {
|
|
145
|
+
const text = message?.errorMessage;
|
|
146
|
+
if (typeof text !== "string") return false;
|
|
147
|
+
// Match the status code + origin_response_timeout JSON body.
|
|
148
|
+
if (text.includes("524") && text.includes("origin_response_timeout")) return true;
|
|
149
|
+
// Cloudflare HTML error page variant (occurs for very large bodies).
|
|
150
|
+
if (text.includes("524") && (text.includes("Error 524") || text.includes("A timeout occurred"))) return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
96
154
|
/**
|
|
97
155
|
* Reclassify a terminal assistant error as a context overflow when — and only
|
|
98
156
|
* when — NaN returned its generic 400 for a request estimated to exceed the
|
|
99
|
-
* model's context window
|
|
100
|
-
*
|
|
157
|
+
* model's context window, OR when a Cloudflare 524 fires for an over-ceiling
|
|
158
|
+
* cold request. Mutates and returns the message (the same object flows through
|
|
159
|
+
* the error event and `result()`).
|
|
160
|
+
*
|
|
161
|
+
* The two error paths use independent thresholds:
|
|
162
|
+
* - 400: estimated > contextWindow (conservative; a 400 may be schema-related)
|
|
163
|
+
* - 524: estimated > CEILING (a 524 is either overflow or transient;
|
|
164
|
+
* the ceiling is the discriminator)
|
|
101
165
|
*/
|
|
102
166
|
export function classifyContextOverflowError<T extends AssistantMessage>(
|
|
103
167
|
message: T,
|
|
@@ -105,16 +169,43 @@ export function classifyContextOverflowError<T extends AssistantMessage>(
|
|
|
105
169
|
estimatedInputTokens: number,
|
|
106
170
|
): T {
|
|
107
171
|
if (message.stopReason !== "error") return message;
|
|
108
|
-
|
|
109
|
-
const contextWindow = model?.contextWindow
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
message
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
172
|
+
|
|
173
|
+
const contextWindow = model?.contextWindow;
|
|
174
|
+
const hasValidWindow = Number.isFinite(contextWindow) && contextWindow! > 0;
|
|
175
|
+
|
|
176
|
+
// --- 400 branch: keys on the model's context window ---
|
|
177
|
+
if (isGenericNanBadRequest(message) && hasValidWindow
|
|
178
|
+
&& Number.isFinite(estimatedInputTokens)
|
|
179
|
+
&& estimatedInputTokens > contextWindow!) {
|
|
180
|
+
message.errorMessage =
|
|
181
|
+
`Requested token count exceeds the model's maximum context length of ${contextWindow!} tokens ` +
|
|
182
|
+
`(estimated ${Math.ceil(estimatedInputTokens)} input tokens). ` +
|
|
183
|
+
`NaN's gateway answered HTTP 400 "${NAN_GENERIC_BAD_REQUEST}" instead of naming the overflow, ` +
|
|
184
|
+
`so the context must be compacted before retrying.`;
|
|
185
|
+
return message;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- 524 branch: keys on the cold-cache ceiling only ---
|
|
189
|
+
// A 524 origin_response_timeout on an over-ceiling request means the edge
|
|
190
|
+
// timed out while prefilling a large cold prompt — it is an overflow, not a
|
|
191
|
+
// transient error. Small-requests that hit 524 are genuine timeouts.
|
|
192
|
+
if (isCloudflare524(message)
|
|
193
|
+
&& Number.isFinite(estimatedInputTokens)
|
|
194
|
+
&& estimatedInputTokens > NAN_COLD_CACHE_CEILING_TOKENS) {
|
|
195
|
+
const windowClause = hasValidWindow
|
|
196
|
+
? ` (declared model window: ${contextWindow!} tokens)`
|
|
197
|
+
: "";
|
|
198
|
+
message.errorMessage =
|
|
199
|
+
`Requested token count exceeds the maximum context length of ${NAN_COLD_CACHE_CEILING_TOKENS} tokens ` +
|
|
200
|
+
`servable with a cold prompt cache through NaN's endpoint` +
|
|
201
|
+
windowClause +
|
|
202
|
+
`, so the context must be compacted before retrying. ` +
|
|
203
|
+
`(estimated ${Math.ceil(estimatedInputTokens)} input tokens; ` +
|
|
204
|
+
`Cloudflare answers HTTP 524 origin_response_timeout past the 120s proxy read timeout). ` +
|
|
205
|
+
`Original error: ${message.errorMessage}.`;
|
|
206
|
+
return message;
|
|
207
|
+
}
|
|
208
|
+
|
|
118
209
|
return message;
|
|
119
210
|
}
|
|
120
211
|
|
|
@@ -178,4 +269,4 @@ export function withContextOverflowClassification(api: ProviderStreams): Provide
|
|
|
178
269
|
streamSimple: (model, context, options) =>
|
|
179
270
|
classifyStreamContextOverflow(api.streamSimple(model, context, options), model, estimateRequestTokens(context)),
|
|
180
271
|
};
|
|
181
|
-
}
|
|
272
|
+
}
|
package/src/fetch-models.ts
CHANGED
|
@@ -23,10 +23,12 @@
|
|
|
23
23
|
* back to the generated catalog so startup is never blocked.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type {
|
|
27
|
-
import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
26
|
+
import type { ImageModel, Model } from "@earendil-works/pi-ai";
|
|
27
|
+
import type { ThinkingLevelMap, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
|
|
28
28
|
import { GENERATED_CATALOG_META, NAN_GENERATED_MODELS } from "../scripts/models.generated.ts";
|
|
29
29
|
|
|
30
|
+
export { NAN_GENERATED_MODELS };
|
|
31
|
+
|
|
30
32
|
export type { Model };
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -341,3 +343,146 @@ export async function fetchNanCompatibleModels(
|
|
|
341
343
|
export function generatedCatalogMeta() {
|
|
342
344
|
return GENERATED_CATALOG_META;
|
|
343
345
|
}
|
|
346
|
+
|
|
347
|
+
// ── Stale catalog overlay (issue #19) ───────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Build a lookup map from model id to generated catalog entry. Used by
|
|
351
|
+
* `overlayGeneratedCapabilities` so the overlay is a single Map lookup.
|
|
352
|
+
*/
|
|
353
|
+
export function buildGeneratedCatalogMap(
|
|
354
|
+
generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
|
|
355
|
+
): Map<string, GeneratedModelEntry> {
|
|
356
|
+
return new Map(generated.map((entry) => [entry.id, entry]));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Pre-built lookup map for generated chat-catalog entries.
|
|
361
|
+
* Built once at module evaluation time so every read is a true O(1) Map.get.
|
|
362
|
+
* (The doc on `overlayGeneratedCapabilities` previously claimed O(1)
|
|
363
|
+
* `buildGeneratedCatalogMap` was called on every call — this fixes that.)
|
|
364
|
+
*/
|
|
365
|
+
const _generatedCatalogMap: Map<string, GeneratedModelEntry> = buildGeneratedCatalogMap();
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Overlay generated-catalog capability data onto a stored/restored model when
|
|
369
|
+
* the model id exists in the generated catalog (and the model type matches).
|
|
370
|
+
*
|
|
371
|
+
* Returns the generated catalog entry converted to a pi-ai Model via `toModel`
|
|
372
|
+
* when there is an exact id match with the same api (openai-completions).
|
|
373
|
+
* Image models, classifier models, or ids not in the catalog pass through
|
|
374
|
+
* unchanged — the persisted store decides which ids exist, but only the
|
|
375
|
+
* generated catalog decides capability data (issue #19).
|
|
376
|
+
*
|
|
377
|
+
* When `prebuilt` is provided it must be a Map<string, GeneratedModelEntry>
|
|
378
|
+
* keyed by model id for chat entries only. This parameter exists so the
|
|
379
|
+
* provider wrapper can pass a prebuilt map instead of rebuilding it every
|
|
380
|
+
* call (module-level `_generatedCatalogMap` is used when omitted).
|
|
381
|
+
*
|
|
382
|
+
* This function is pure, stateless, and O(1) — a single Map.get lookup.
|
|
383
|
+
* It is called at read time from the provider wrapper so every consumer
|
|
384
|
+
* (pi, the model registry, downstream code) sees the latest capabilities.
|
|
385
|
+
*/
|
|
386
|
+
export function overlayGeneratedCapabilities(
|
|
387
|
+
model: Model<string> | ImageModel<string>,
|
|
388
|
+
generatedOrPrebuilt?: readonly GeneratedModelEntry[] | Map<string, GeneratedModelEntry>,
|
|
389
|
+
): Model<string> | ImageModel<string> {
|
|
390
|
+
if (!("reasoning" in model && "contextWindow" in model)) {
|
|
391
|
+
// ImageModel and ClassifierModel do not have reasoning/contextWindow.
|
|
392
|
+
return model;
|
|
393
|
+
}
|
|
394
|
+
// Resolve the lookup map: prebuilt map > array > module-level singleton.
|
|
395
|
+
const byId: Map<string, GeneratedModelEntry> =
|
|
396
|
+
generatedOrPrebuilt instanceof Map
|
|
397
|
+
? generatedOrPrebuilt
|
|
398
|
+
: Array.isArray(generatedOrPrebuilt)
|
|
399
|
+
? buildGeneratedCatalogMap(generatedOrPrebuilt)
|
|
400
|
+
: _generatedCatalogMap;
|
|
401
|
+
const entry = byId.get(model.id);
|
|
402
|
+
// Only match when the generated catalog has this id AND the api is the
|
|
403
|
+
// chat api (openai-completions). Image models use a different api and
|
|
404
|
+
// a separate static list — they are not in this catalog.
|
|
405
|
+
if (entry && model.api === NAN_COMPAT_API) {
|
|
406
|
+
return toModel(entry, { providerId: model.provider, baseUrl: model.baseUrl });
|
|
407
|
+
}
|
|
408
|
+
return model;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── Combined overlay for chat + image models (GAP 1: getAllModels wrapping) ─
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Internal representation of an overlay entry: either a generated chat entry
|
|
415
|
+
* (GeneratedModelEntry → toModel result) or a baseline image model
|
|
416
|
+
* (ImageModel). Keys are model ids that are unique across chat/image
|
|
417
|
+
* namespaces.
|
|
418
|
+
*/
|
|
419
|
+
export interface OverlayEntry {
|
|
420
|
+
/** For chat models: the pi-ai Model produced by toModel(entry, source). */
|
|
421
|
+
chat?: Model<string>;
|
|
422
|
+
/** For image models: the ImageModel from baselineImageModels(source). */
|
|
423
|
+
image?: ImageModel<string>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Build a combined overlay map keyed by model id. The overlay contains:
|
|
428
|
+
* - For every generated chat model id: the toModel() result (chat entry).
|
|
429
|
+
* - For every baseline image model: the ImageModel itself (image entry).
|
|
430
|
+
*
|
|
431
|
+
* This map is built once at factory time (in withStaleCatalogOverlay) and
|
|
432
|
+
* used by both `getModels` and `getAllModels` wrappers so every read path
|
|
433
|
+
* receives generated-catalog capability data regardless of whether the
|
|
434
|
+
* model is chat or image (issue #19, GAP 1).
|
|
435
|
+
*/
|
|
436
|
+
export function buildOverlayMap(
|
|
437
|
+
source: CatalogSource,
|
|
438
|
+
generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
|
|
439
|
+
imageModels?: ImageModel<string>[],
|
|
440
|
+
): Map<string, OverlayEntry> {
|
|
441
|
+
const overlay = new Map<string, OverlayEntry>();
|
|
442
|
+
// Chat models from the generated catalog.
|
|
443
|
+
for (const entry of generated) {
|
|
444
|
+
overlay.set(entry.id, { chat: toModel(entry, source) });
|
|
445
|
+
}
|
|
446
|
+
// Image models from the baseline (these live under a different api id
|
|
447
|
+
// so they cannot coexist as chat entries — ids are unique across types).
|
|
448
|
+
if (imageModels) {
|
|
449
|
+
for (const img of imageModels) {
|
|
450
|
+
overlay.set(img.id, { image: img });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return overlay;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Internal model-like shape used by applyOverlay: every model type has id, api,
|
|
458
|
+
* and (for images) a `type` discriminator.
|
|
459
|
+
*/
|
|
460
|
+
interface _ModelLike {
|
|
461
|
+
id: string;
|
|
462
|
+
api: string;
|
|
463
|
+
type?: string;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Apply the combined overlay (chat + image) to a single model. Looks up the
|
|
468
|
+
* id in the provided map and returns the overlaid entry when present.
|
|
469
|
+
* Image models (type === "image") receive their ImageModel overlay; chat
|
|
470
|
+
* models receive their generated-catalog entry via `overlayGeneratedCapabilities`.
|
|
471
|
+
* ClassifierModel and other model types pass through unchanged.
|
|
472
|
+
*/
|
|
473
|
+
export function applyOverlay<T extends _ModelLike>(
|
|
474
|
+
model: T,
|
|
475
|
+
overlay: Map<string, OverlayEntry>,
|
|
476
|
+
): T {
|
|
477
|
+
const entry = overlay.get(model.id);
|
|
478
|
+
if (!entry) return model;
|
|
479
|
+
// Image models: return the baseline ImageModel overlay.
|
|
480
|
+
if ((model as _ModelLike).type === "image") {
|
|
481
|
+
return (entry.image ?? model) as T;
|
|
482
|
+
}
|
|
483
|
+
// Chat models: use the standard overlay function (still O(1)).
|
|
484
|
+
if (entry.chat && model.api === NAN_COMPAT_API) {
|
|
485
|
+
return (entry.chat as unknown as T) ?? model;
|
|
486
|
+
}
|
|
487
|
+
return model;
|
|
488
|
+
}
|