@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.
@@ -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, 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
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. Mutates and returns the message (the same object
100
- * flows through the error event and `result()`).
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
- 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.`;
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
+ }
@@ -23,10 +23,12 @@
23
23
  * back to the generated catalog so startup is never blocked.
24
24
  */
25
25
 
26
- import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
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
+ }