@gtrabanco/pi-nan-provider 0.10.2 → 1.0.0

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
+ }
@@ -12,19 +12,23 @@
12
12
  * only model `id`s — no capability fields. It is used as the authoritative
13
13
  * list of what the endpoint returns for the API key.
14
14
  *
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.
15
+ * Merge: live IDs × generated capability data — non-chat IDs (see
16
+ * `NON_CHAT_MODEL_IDS`) are excluded from the registered model set and
17
+ * reported in `MergedCatalog.nonChat`; all other live IDs surface: a live ID
18
+ * with generated data keeps its generated capabilities, and an uncatalogued
19
+ * live ID (e.g. a new or undocumented model) gets conservative placeholder
20
+ * limits (the same defaults used in custom-provider.md's dynamic-discovery
21
+ * example) with no reasoning support — capabilities stay "unknown", nothing is
22
+ * fabricated. On fetch failure, timeout, or an unusable response, callers fall
23
+ * back to the generated catalog so startup is never blocked.
22
24
  */
23
25
 
24
- import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
25
- 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";
26
28
  import { GENERATED_CATALOG_META, NAN_GENERATED_MODELS } from "../scripts/models.generated.ts";
27
29
 
30
+ export { NAN_GENERATED_MODELS };
31
+
28
32
  export type { Model };
29
33
 
30
34
  /**
@@ -81,6 +85,32 @@ export const UNKNOWN_MODEL_LIMITS = { contextWindow: 128_000, maxTokens: 4_096 }
81
85
  /** Timeout for the live /models fetch; matches the pi-synthetic-provider precedent (~3s). */
82
86
  export const DEFAULT_MODELS_TIMEOUT_MS = 3_000;
83
87
 
88
+ /**
89
+ * Live endpoint IDs classified as non-chat and excluded from the registered
90
+ * model set. Keys are model ids; values are provenance reasons citing the
91
+ * source that designated the endpoint as non-chat.
92
+ *
93
+ * Source: https://nan.builders/docs/models (checked 2026-10-01), plus
94
+ * qwen-image-2.1: https://models.dev API `limit.output: 0` (checked 2026-09-29).
95
+ * These are MCP-bridge territory (embedding / rerank / TTS / STT / image
96
+ * generation), not chat catalog models — see AGENTS.md classification notes
97
+ * and issue #15.
98
+ */
99
+ export const NON_CHAT_MODEL_IDS: Readonly<Record<string, string>> = {
100
+ "qwen3-embedding":
101
+ "embedding endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
102
+ rerank:
103
+ "rerank endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
104
+ kokoro:
105
+ "text-to-speech (TTS) endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
106
+ whisper:
107
+ "speech-to-text (STT) endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
108
+ "flux-2-klein":
109
+ "image generation endpoint (https://nan.builders/docs/models, checked 2026-10-01) — not a chat model",
110
+ "qwen-image-2.1":
111
+ "image-generation model (text→image; output modality image) documented in https://nan.builders/docs/models and https://nan.builders/openapi.json (checked 2026-09-29) — not a chat model, out of the static chat catalog (models.dev reports limit.output 0)",
112
+ } as const;
113
+
84
114
  export interface CatalogSource {
85
115
  /** Provider id as registered in pi, e.g. "nan". */
86
116
  providerId: string;
@@ -108,7 +138,11 @@ export function toModel(entry: GeneratedModelEntry, source: CatalogSource): Mode
108
138
 
109
139
  /** The generated fallback catalog as pi-ai Models for the given provider. */
110
140
  export function baselineModels(source: CatalogSource): Model<"openai-completions">[] {
111
- return NAN_GENERATED_MODELS.map((entry) => toModel(entry, source));
141
+ // Defensive: the generated baseline never contains non-chat ids today,
142
+ // but the invariant must hold on every path (issue #15).
143
+ return NAN_GENERATED_MODELS.filter(
144
+ (entry) => !NON_CHAT_MODEL_IDS[entry.id],
145
+ ).map((entry) => toModel(entry, source));
112
146
  }
113
147
 
114
148
  export interface LiveModelListOptions {
@@ -163,12 +197,18 @@ export interface MergedCatalog {
163
197
  matched: string[];
164
198
  /** Allowlisted live IDs kept with unknown capabilities (conservative limits). */
165
199
  unknown: string[];
200
+ /**
201
+ * Live model IDs classified as non-chat and excluded from the registered
202
+ * model set, in live order. Populated from `NON_CHAT_MODEL_IDS` (issue #15).
203
+ */
204
+ nonChat: string[];
166
205
  }
167
206
 
168
207
  /**
169
- * Merge live model IDs with the generated capability catalog. All live IDs
170
- * surface: live IDs with generated data keep their generated capabilities;
171
- * uncatalogued live IDs get conservative placeholder limits,
208
+ * Merge live model IDs with the generated capability catalog. Non-chat IDs
209
+ * (from `NON_CHAT_MODEL_IDS`) are excluded and reported in `nonChat`; all
210
+ * other live IDs surface: live IDs with generated data keep their generated
211
+ * capabilities; uncatalogued live IDs get conservative placeholder limits,
172
212
  * `reasoning: false`, and zero cost (documented defaults, not invented
173
213
  * capabilities).
174
214
  */
@@ -181,8 +221,15 @@ export function mergeLiveWithGenerated(
181
221
  const models: Model<"openai-completions">[] = [];
182
222
  const matched: string[] = [];
183
223
  const unknown: string[] = [];
224
+ const nonChat: string[] = [];
184
225
 
185
226
  for (const id of liveIds) {
227
+ // Issue #15: non-chat endpoint ids must never surface as chat models.
228
+ const nonChatReason = NON_CHAT_MODEL_IDS[id];
229
+ if (nonChatReason) {
230
+ nonChat.push(id);
231
+ continue;
232
+ }
186
233
  const entry = byId.get(id);
187
234
  if (entry) {
188
235
  models.push(toModel(entry, source));
@@ -217,7 +264,7 @@ export function mergeLiveWithGenerated(
217
264
  }
218
265
  }
219
266
 
220
- return { models, matched, unknown };
267
+ return { models, matched, unknown, nonChat };
221
268
  }
222
269
 
223
270
  export interface FetchModelsOptions {
@@ -240,13 +287,19 @@ export interface ResolvedCatalog {
240
287
  liveIds: Set<string> | undefined;
241
288
  /** Allowlisted live IDs kept with unknown capabilities (present only when liveIds is set). */
242
289
  unknownIds: string[];
290
+ /**
291
+ * Live model IDs classified as non-chat and excluded from the registered
292
+ * model set (issue #15). Empty when the live fetch failed.
293
+ */
294
+ nonChatIds: string[];
243
295
  }
244
296
 
245
297
  /**
246
298
  * Resolve the effective catalog: live `/models` IDs × generated capability
247
299
  * data. A successful live fetch is authoritative (tier-aware: it reflects
248
300
  * exactly what your NaN key can use); failure degrades to the generated
249
- * fallback so startup is never blocked.
301
+ * fallback so startup is never blocked. Non-chat IDs are excluded from
302
+ * models and reported in `nonChatIds` (issue #15).
250
303
  */
251
304
  export async function resolveCatalog(
252
305
  source: CatalogSource,
@@ -260,11 +313,16 @@ export async function resolveCatalog(
260
313
  timeoutMs: options.timeoutMs,
261
314
  fetchImpl: options.fetchImpl,
262
315
  });
263
- if (!liveIds) return { models: baseline, liveIds: undefined, unknownIds: [] };
316
+ if (!liveIds) return { models: baseline, liveIds: undefined, unknownIds: [], nonChatIds: [] };
264
317
  const merged = mergeLiveWithGenerated(liveIds, source);
265
- return { models: merged.models, liveIds: new Set(liveIds), unknownIds: merged.unknown };
318
+ return {
319
+ models: merged.models,
320
+ liveIds: new Set(liveIds),
321
+ unknownIds: merged.unknown,
322
+ nonChatIds: merged.nonChat,
323
+ };
266
324
  } catch {
267
- return { models: baseline, liveIds: undefined, unknownIds: [] };
325
+ return { models: baseline, liveIds: undefined, unknownIds: [], nonChatIds: [] };
268
326
  }
269
327
  }
270
328
 
@@ -285,3 +343,146 @@ export async function fetchNanCompatibleModels(
285
343
  export function generatedCatalogMeta() {
286
344
  return GENERATED_CATALOG_META;
287
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
+ }