@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.
- package/AGENTS.md +78 -12
- package/README.es.md +59 -9
- package/README.md +59 -9
- package/package.json +5 -5
- package/scripts/generate-models.ts +15 -12
- package/scripts/models.generated.ts +7 -2
- package/src/commands.ts +6 -4
- package/src/context-overflow-classifier.ts +114 -23
- package/src/fetch-models.ts +219 -18
- package/src/images.ts +408 -0
- package/src/index.ts +5 -2
- package/src/provider-factory.ts +102 -30
|
@@ -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
|
@@ -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 —
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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 {
|
|
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
|
+
}
|