@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/src/images.ts ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * NaN image generation API — custom API id, model definitions, and a
3
+ * ProviderImages implementation talking to the OpenAI-compatible image
4
+ * endpoints at api.nan.builders.
5
+ *
6
+ * NaN exposes two OpenAI-compatible image endpoints:
7
+ *
8
+ * POST /v1/images/generations — text-to-image (JSON body)
9
+ * POST /v1/images/edits — image-to-image (multipart/form-data)
10
+ *
11
+ * Both require the inference-tier membership (403 otherwise) and share a
12
+ * 20 req/min / 100 req/month quota separate from the chat token budget
13
+ * (https://nan.builders/docs/models and https://nan.builders/openapi.json,
14
+ * checked 2026-10-01).
15
+ *
16
+ * We register a custom API id "nan-images" so pi's model registry can route
17
+ * generateImages calls through our implementation (createNanImagesApi), and
18
+ * register the resulting ProviderImages via createProvider({ images: ... }).
19
+ *
20
+ * Style note (per repo conventions): only type-only imports from the bare
21
+ * root "@earendil-works/pi-ai". No subpath imports — pi's extension loader
22
+ * aliases subpaths and the whole extension fails to load. No imageErrorResult
23
+ * import either: pi-ai exposes it only under the forbidden
24
+ * "@earendil-works/pi-ai/utils/..." subpath, so error results are built here.
25
+ */
26
+
27
+ import type {
28
+ AssistantImages,
29
+ ImageModel,
30
+ ProviderImages,
31
+ } from "@earendil-works/pi-ai";
32
+
33
+ // ── Custom API id ──────────────────────────────────────────────────────────
34
+
35
+ /** Custom image-api id for NaN image generation. */
36
+ export const NAN_IMAGE_API = "nan-images";
37
+
38
+ // ── Model definitions ─────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Static image-model definition carrying input/output modalities and
42
+ * provenance notes. Kept local (not in the generated chat catalog) because
43
+ * these are image endpoints, not chat models: NON_CHAT_MODEL_IDS still keeps
44
+ * them out of the chat catalog, and this list is the image-side counterpart.
45
+ */
46
+ export interface NanImageModelDefinition {
47
+ /** Stable model id, e.g. "flux-2-klein". */
48
+ id: string;
49
+ /** Human-readable model name, e.g. "FLUX 2 Klein". */
50
+ name: string;
51
+ /** Input modalities the model accepts. */
52
+ input: ("text" | "image")[];
53
+ /** Output modalities the model produces. */
54
+ output: ("text" | "image")[];
55
+ /** Provenance notes citing sources for every capability claim. */
56
+ notes: string[];
57
+ }
58
+
59
+ /**
60
+ * All NaN image models known to this provider. Two entries, ordered:
61
+ * flux-2-klein (text+image input), qwen-image-2.1 (text-only).
62
+ */
63
+ export const NAN_IMAGE_MODELS: readonly NanImageModelDefinition[] = [
64
+ {
65
+ id: "flux-2-klein",
66
+ name: "FLUX 2 Klein",
67
+ input: ["text", "image"],
68
+ output: ["image"],
69
+ notes: [
70
+ "Image generation endpoint (text-to-image and image-to-image). " +
71
+ "Documented in https://nan.builders/docs/models and " +
72
+ "https://nan.builders/openapi.json (checked 2026-10-01): POST " +
73
+ "/v1/images/generations and POST /v1/images/edits (1-4 reference " +
74
+ "images; mask unsupported). Requires inference-tier membership " +
75
+ "(403 otherwise); image endpoints are rate-limited separately " +
76
+ "from chat (20 req/min, 100 req/month shared with qwen-image-2.1) " +
77
+ "and do not consume the chat token budget. models.dev provider " +
78
+ "nan does not list flux-2-klein (checked 2026-10-01), so this is " +
79
+ "a manual entry; per-token cost is 0 because NaN bills image " +
80
+ "generation by request quota on the same membership model as chat.",
81
+ ],
82
+ },
83
+ {
84
+ id: "qwen-image-2.1",
85
+ name: "Qwen-Image-2.1",
86
+ input: ["text"],
87
+ output: ["image"],
88
+ notes: [
89
+ "Image generation endpoint (text-to-image only). Documented in " +
90
+ "https://nan.builders/docs/models and " +
91
+ "https://nan.builders/openapi.json (checked 2026-10-01): POST " +
92
+ "/v1/images/generations. models.dev provider nan lists it " +
93
+ "(checked 2026-10-01) with cost 0/0 and limit.output 0 (it " +
94
+ "returns image bytes, not tokens). Same inference-tier " +
95
+ "requirement and 100 req/month quota shared with " +
96
+ "flux-2-klein; image-to-image (/images/edits) is not documented " +
97
+ "for this model.",
98
+ ],
99
+ },
100
+ ] as const;
101
+
102
+ // ── Baseline models ────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Structural source shape (same as fetch-models.ts CatalogSource) declared
106
+ * locally so this module has zero circular dependency on fetch-models.
107
+ */
108
+ interface ImageCatalogSource {
109
+ /** Provider id as registered in pi, e.g. "nan". */
110
+ providerId: string;
111
+ /** OpenAI-compatible base URL including version path. */
112
+ baseUrl: string;
113
+ }
114
+
115
+ /**
116
+ * Map every entry in NAN_IMAGE_MODELS to a pi-ai ImageModel for our custom
117
+ * API id. Per-token cost is 0: NaN bills image generation by request quota
118
+ * on the membership model, the same convention the generated chat catalog
119
+ * uses for NaN's quota-based pricing.
120
+ */
121
+ export function baselineImageModels(
122
+ source: ImageCatalogSource,
123
+ ): ImageModel<typeof NAN_IMAGE_API>[] {
124
+ return NAN_IMAGE_MODELS.map(
125
+ (model): ImageModel<typeof NAN_IMAGE_API> => ({
126
+ id: model.id,
127
+ name: model.name,
128
+ type: "image",
129
+ api: NAN_IMAGE_API,
130
+ provider: source.providerId,
131
+ baseUrl: source.baseUrl,
132
+ input: [...model.input],
133
+ output: [...model.output],
134
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
135
+ }),
136
+ );
137
+ }
138
+
139
+ // ── API implementation ─────────────────────────────────────────────────────
140
+
141
+ /** Injectable options for createNanImagesApi (fetchImpl is for tests). */
142
+ export interface CreateNanImagesApiOptions {
143
+ /** Injectable fetch for tests; defaults to globalThis.fetch. */
144
+ fetchImpl?: typeof fetch;
145
+ /** Default per-request timeout in ms when the caller does not supply one. */
146
+ timeoutMs?: number;
147
+ }
148
+
149
+ /**
150
+ * Build a ProviderImages implementation for NaN's OpenAI-compatible image
151
+ * endpoints (POST /v1/images/generations and POST /v1/images/edits).
152
+ *
153
+ * We always request b64_json (not "url") so the returned images carry their
154
+ * base64 data directly — no separate download step is needed. NaN returns
155
+ * raw base64 with no MIME type, so the output type is sniffed from the
156
+ * decoded magic bytes.
157
+ *
158
+ * Contract: never throws. Auth failures, invalid input, transport errors and
159
+ * non-OK responses all surface as an AssistantImages with stopReason
160
+ * "error" (or "aborted" when the caller's signal aborted).
161
+ */
162
+ export function createNanImagesApi(
163
+ options?: CreateNanImagesApiOptions,
164
+ ): ProviderImages {
165
+ const fetchImpl = options?.fetchImpl ?? globalThis.fetch;
166
+
167
+ return {
168
+ async generateImages(model, context, requestOptions) {
169
+ const base: AssistantImages = {
170
+ api: model.api,
171
+ provider: model.provider,
172
+ model: model.id,
173
+ output: [],
174
+ stopReason: "stop",
175
+ timestamp: Date.now(),
176
+ };
177
+
178
+ try {
179
+ // ── Auth ────────────────────────────────────────────────
180
+ const apiKey = requestOptions?.apiKey;
181
+ if (!apiKey) {
182
+ throw new Error("No API key for provider: " + model.provider);
183
+ }
184
+
185
+ // ── Input extraction ────────────────────────────────────
186
+ const text = context.input
187
+ .filter((block) => block.type === "text")
188
+ .map((block) => block.text)
189
+ .join("\n")
190
+ .trim();
191
+ const referenceImages = context.input.filter(
192
+ (block) => block.type === "image",
193
+ );
194
+
195
+ if (text.length === 0 && referenceImages.length === 0) {
196
+ throw new Error(
197
+ "Image generation requires a text prompt or a reference image",
198
+ );
199
+ }
200
+
201
+ // Reference images need a model that accepts image input
202
+ // (flux-2-klein); qwen-image-2.1 is text-to-image only.
203
+ if (
204
+ referenceImages.length > 0 &&
205
+ !model.input.includes("image")
206
+ ) {
207
+ throw new Error(
208
+ "Model " + model.id + " does not accept image input",
209
+ );
210
+ }
211
+
212
+ // ── Endpoint selection ──────────────────────────────────
213
+ const endpoint =
214
+ referenceImages.length > 0
215
+ ? "/images/edits"
216
+ : "/images/generations";
217
+ const url = model.baseUrl.replace(/\/+$/, "") + endpoint;
218
+
219
+ // ── Headers ─────────────────────────────────────────────
220
+ // Merge model headers with caller headers, dropping null
221
+ // values (ProviderHeaders uses null to suppress a default).
222
+ // Bearer auth is applied last so caller values cannot
223
+ // override the provider credential.
224
+ const headers: Record<string, string> = {};
225
+ for (const [key, value] of Object.entries({
226
+ ...model.headers,
227
+ ...requestOptions?.headers,
228
+ })) {
229
+ if (typeof value === "string") headers[key] = value;
230
+ }
231
+ headers.Authorization = "Bearer " + apiKey;
232
+
233
+ // ── Optional metadata passthrough ───────────────────────
234
+ // n, size, seed and guidance are NaN extensions accepted on
235
+ // both endpoints; pass them through only with the right type.
236
+ const metadata = (requestOptions?.metadata ?? {}) as Record<
237
+ string,
238
+ unknown
239
+ >;
240
+ const optionalFields: Record<string, string | number> = {};
241
+ if (typeof metadata.n === "number") optionalFields.n = metadata.n;
242
+ if (typeof metadata.size === "string") optionalFields.size = metadata.size;
243
+ if (typeof metadata.seed === "number") optionalFields.seed = metadata.seed;
244
+ if (typeof metadata.guidance === "number") {
245
+ optionalFields.guidance = metadata.guidance;
246
+ }
247
+
248
+ // ── Request body ────────────────────────────────────────
249
+ let body: FormData | string;
250
+ if (referenceImages.length > 0) {
251
+ // Image-to-image: multipart/form-data. Content-Type is
252
+ // deliberately NOT set — fetch adds the boundary.
253
+ const form = new FormData();
254
+ form.set("model", model.id);
255
+ form.set("prompt", text);
256
+ form.set("response_format", "b64_json");
257
+ for (const [key, value] of Object.entries(optionalFields)) {
258
+ form.set(key, String(value));
259
+ }
260
+ for (const image of referenceImages) {
261
+ // Decode base64 with atob (no Buffer: the extension
262
+ // must not assume a Node-only global).
263
+ const bytes = Uint8Array.from(atob(image.data), (char) =>
264
+ char.charCodeAt(0),
265
+ );
266
+ form.append(
267
+ "image[]",
268
+ new Blob([bytes], {
269
+ type: image.mimeType || "image/png",
270
+ }),
271
+ "reference.png",
272
+ );
273
+ }
274
+ body = form;
275
+ } else {
276
+ headers["Content-Type"] = "application/json";
277
+ // fetch does not serialize plain objects: build the JSON text here.
278
+ body = JSON.stringify({
279
+ model: model.id,
280
+ prompt: text,
281
+ response_format: "b64_json",
282
+ ...optionalFields,
283
+ });
284
+ }
285
+
286
+ // ── Signal (timeout + caller abort) ─────────────────────
287
+ const timeoutMs = requestOptions?.timeoutMs ?? options?.timeoutMs;
288
+ let signal: AbortSignal | undefined;
289
+ if (timeoutMs) {
290
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
291
+ signal = requestOptions?.signal
292
+ ? AbortSignal.any([timeoutSignal, requestOptions.signal])
293
+ : timeoutSignal;
294
+ } else if (requestOptions?.signal) {
295
+ signal = requestOptions.signal;
296
+ }
297
+
298
+ // ── HTTP call ───────────────────────────────────────────
299
+ const response = await fetchImpl(url, {
300
+ method: "POST",
301
+ headers,
302
+ body: body as RequestInit["body"],
303
+ ...(signal ? { signal } : {}),
304
+ });
305
+
306
+ if (!response.ok) {
307
+ // Best-effort error body: NaN usually returns
308
+ // {"error":{"message":"..."}} (403 for a missing
309
+ // inference tier, 429 for the quota).
310
+ let bodyText = "";
311
+ try {
312
+ bodyText = await response.text();
313
+ } catch {
314
+ // Ignore read errors — best effort only.
315
+ }
316
+ throw new Error(
317
+ "NaN image request failed: HTTP " +
318
+ response.status +
319
+ (bodyText ? " " + bodyText : ""),
320
+ );
321
+ }
322
+
323
+ // ── Parse response ──────────────────────────────────────
324
+ const payload = (await response.json()) as {
325
+ data?: Array<{ b64_json?: string }>;
326
+ };
327
+ for (const item of payload.data ?? []) {
328
+ if (
329
+ typeof item.b64_json === "string" &&
330
+ item.b64_json.length > 0
331
+ ) {
332
+ base.output.push({
333
+ type: "image",
334
+ data: item.b64_json,
335
+ mimeType: sniffImageMimeType(item.b64_json),
336
+ });
337
+ }
338
+ }
339
+ if (base.output.length === 0) {
340
+ throw new Error("NaN image response contained no image data");
341
+ }
342
+
343
+ return base;
344
+ } catch (error) {
345
+ base.stopReason =
346
+ requestOptions?.signal?.aborted === true ? "aborted" : "error";
347
+ base.errorMessage =
348
+ error instanceof Error ? error.message : String(error);
349
+ return base;
350
+ }
351
+ },
352
+ };
353
+ }
354
+
355
+ // ── MIME-type sniffing ─────────────────────────────────────────────────────
356
+
357
+ /**
358
+ * Sniff the MIME type from a base64-encoded image string by inspecting the
359
+ * leading magic bytes.
360
+ *
361
+ * PNG: 89 50 4E 47 → "image/png"
362
+ * JPEG: FF D8 FF → "image/jpeg"
363
+ * WebP: RIFF....WEBP → "image/webp"
364
+ *
365
+ * NaN returns raw base64 without a MIME type, so we infer it from the
366
+ * signature instead of guessing. Falls back to "image/png" and never throws.
367
+ */
368
+ function sniffImageMimeType(base64: string): string {
369
+ try {
370
+ // Only the first 16 bytes are needed to identify PNG/JPEG/WebP.
371
+ const decoded = atob(base64.slice(0, 16));
372
+ const bytes = new Uint8Array(decoded.length);
373
+ for (let i = 0; i < decoded.length; i++) {
374
+ bytes[i] = decoded.charCodeAt(i);
375
+ }
376
+
377
+ if (
378
+ bytes[0] === 0x89 &&
379
+ bytes[1] === 0x50 &&
380
+ bytes[2] === 0x4e &&
381
+ bytes[3] === 0x47
382
+ ) {
383
+ return "image/png";
384
+ }
385
+ if (
386
+ bytes[0] === 0xff &&
387
+ bytes[1] === 0xd8 &&
388
+ bytes[2] === 0xff
389
+ ) {
390
+ return "image/jpeg";
391
+ }
392
+ if (
393
+ bytes[0] === 0x52 &&
394
+ bytes[1] === 0x49 &&
395
+ bytes[2] === 0x46 &&
396
+ bytes[3] === 0x46 &&
397
+ bytes[8] === 0x57 &&
398
+ bytes[9] === 0x45 &&
399
+ bytes[10] === 0x42 &&
400
+ bytes[11] === 0x50
401
+ ) {
402
+ return "image/webp";
403
+ }
404
+ return "image/png";
405
+ } catch {
406
+ return "image/png";
407
+ }
408
+ }
package/src/index.ts CHANGED
@@ -17,11 +17,12 @@
17
17
  *
18
18
  * 2. MCP servers via pi's native registerMcpServer (pi >=0.99):
19
19
  * - `nan-search` — the official NaN remote MCP server
20
- * (https://api.nan.builders/mcp), exposing `mcp__nan-search__web_search`.
20
+ * (https://api.nan.builders/mcp), exposing `mcp__nan_search__web_search`
21
+ * (pi >=0.99.2; 0.99.0-0.99.1 kept the hyphen: `mcp__nan-search__web_search`).
21
22
  * Default: enabled. NAN_MCP_TOOLS=0 to disable.
22
23
  * - `nan-media` — the community stdio media server
23
24
  * (flux-2-klein / kokoro / whisper), exposing
24
- * `mcp__nan-media__generate_image` etc. Default: enabled,
25
+ * `mcp__nan_media__generate_image` etc. Default: enabled,
25
26
  * NAN_MEDIA_MCP=0 to disable.
26
27
  *
27
28
  * Both are session-scoped (visible in /mcp with source "extension").
@@ -109,6 +110,7 @@ async function registerWebSearchMcpServer(pi: ExtensionAPI, apiKey: string | und
109
110
  url: "https://api.nan.builders/mcp",
110
111
  headers: { Authorization: `Bearer ${apiKey}` },
111
112
  exposure: "direct",
113
+ description: "Web search through NaN's gateway (official NaN MCP server)",
112
114
  });
113
115
  return true;
114
116
  }
@@ -130,6 +132,7 @@ function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): b
130
132
  args: mediaArgs.slice(1),
131
133
  env: { [NAN_API_KEY_ENV]: apiKey },
132
134
  exposure: "direct",
135
+ description: "NaN media tools: image generation/editing, text-to-speech and speech-to-text via the community nan-mcp-server",
133
136
  timeout: mediaMcpTimeoutSec(),
134
137
  });
135
138
  return true;
@@ -22,6 +22,12 @@
22
22
  * Streaming API resolution: bare root under pi's compat alias is the instance
23
23
  * anchor; otherwise a file URL from import.meta.resolve; never a bare subpath
24
24
  * (loud failure — issue #8). Full logic in src/pi-ai-loader.ts.
25
+ *
26
+ * The provider also exposes NaN image models (flux-2-klein, qwen-image-2.1)
27
+ * through an image API ("nan-images"): `baselineImageModels` are added to the
28
+ * model set and a `ProviderImages` implementation is wired via
29
+ * `createNanImagesApi`, so pi's model registry can route
30
+ * `generateImages()` calls through it.
25
31
  */
26
32
 
27
33
  import * as piAi from "@earendil-works/pi-ai";
@@ -31,11 +37,17 @@ import type {
31
37
  RefreshModelsContext,
32
38
  } from "@earendil-works/pi-ai";
33
39
  import { withContextOverflowClassification } from "./context-overflow-classifier.ts";
40
+ import { NAN_IMAGE_API, baselineImageModels, createNanImagesApi } from "./images.ts";
34
41
  import {
35
42
  baselineModels,
36
43
  DEFAULT_MODELS_TIMEOUT_MS,
37
44
  resolveCatalog,
38
45
  type CatalogSource,
46
+ overlayGeneratedCapabilities,
47
+ buildOverlayMap,
48
+ applyOverlay,
49
+ type OverlayEntry,
50
+ NAN_GENERATED_MODELS,
39
51
  } from "./fetch-models.ts";
40
52
  import { resolveOpenAICompletionsApi } from "./pi-ai-loader.ts";
41
53
  import { sanitizeOpenAICompatPayload } from "./openai-compat-sanitizer.ts";
@@ -126,6 +138,55 @@ export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStre
126
138
  };
127
139
  }
128
140
 
141
+ /**
142
+ * Stale-catalog overlay (issue #19): pi-ai's `createProvider` builds an
143
+ * internal `refreshModels` where phase 1 restores `context.stored.models`
144
+ * verbatim and phase 2 calls the input `fetchModels` + persists. The
145
+ * `dynamicModels` list is a closure variable — external code cannot assign
146
+ * it. When pi restores a persisted store entry written by an older version
147
+ * of this package (e.g. without `thinkingLevelMap`), that stale copy is
148
+ * used as-is and never refreshed (because `checkedAt` is recent).
149
+ *
150
+ * `currentModels()` merges static `input.models` with `dynamicModels` and a
151
+ * dynamic entry REPLACES the baseline entry with the same id+type (the
152
+ * shadowing mechanism). The `ModelsImpl` in pi resolves models exclusively
153
+ * via `getModels()` / `getAllModels()` on the provider object — it never
154
+ * inspects the internal dynamicModels list.
155
+ *
156
+ * The fix: wrap `getModels` and `getAllModels` on the returned provider so
157
+ * every call re-checks the generated catalog and applies capability data
158
+ * when the id exists there. The store decides which ids exist; the
159
+ * generated catalog decides capability data. Read-time overlay is the only
160
+ * provider-side hook that wins over the stale restore.
161
+ *
162
+ * `overlayMap` is prebuilt once at factory time from BOTH the generated
163
+ * chat catalog AND `baselineImageModels(source)`. Both `getModels`
164
+ * and `getAllModels` use `applyOverlay` for O(1) per-model lookup (GAP 2).
165
+ * This also covers image models: pi-ai's image models are only visible
166
+ * through `getAllModels()`, so a stale stored IMAGE entry would otherwise
167
+ * replace the baseline image entry with no overlay (GAP 1).
168
+ *
169
+ * We do NOT override `refreshModels` — the built-in phases stay intact. The
170
+ * overlay only affects read paths.
171
+ */
172
+ function withStaleCatalogOverlay(
173
+ provider: Provider<"openai-completions">,
174
+ overlayMap: Map<string, OverlayEntry>,
175
+ ): Provider<"openai-completions"> {
176
+ return {
177
+ ...provider,
178
+ getModels: () => {
179
+ const models = provider.getModels();
180
+ return models.map((m) => applyOverlay(m, overlayMap)) as typeof models;
181
+ },
182
+ getAllModels: () => {
183
+ const all = provider.getAllModels?.();
184
+ if (!all) return [];
185
+ return all.map((m) => applyOverlay(m, overlayMap));
186
+ },
187
+ };
188
+ }
189
+
129
190
  /**
130
191
  * Build a complete pi-ai Provider for an OpenAI-compatible endpoint:
131
192
  *
@@ -133,11 +194,15 @@ export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStre
133
194
  * `/login <id>` prompts for the key (pi's `envApiKeyAuth` semantics — the
134
195
  * same precedence the built-in providers use). No prompt is needed when the
135
196
  * env var is set.
136
- * - models: the generated fallback catalog as static baseline, so models are
197
+ * - models: the generated chat-model fallback catalog plus NaN image models
198
+ * (flux-2-klein, qwen-image-2.1) as static baseline, so all models are
137
199
  * available with zero network.
138
200
  * - fetchModels: live `/models` IDs × generated capability data; falls back
139
201
  * to the baseline when the endpoint is unreachable. pi's Models runtime
140
202
  * drives refreshes (startup/periodic) and persists the overlay.
203
+ * - images: a `ProviderImages` implementation for NaN's OpenAI-compatible
204
+ * image endpoints, so `generateImages()` calls are routed through pi's
205
+ * model registry.
141
206
  * - api: the openai-completions streaming implementation resolved via
142
207
  * `resolveOpenAICompletionsApi` (bare root under pi's compat alias as
143
208
  * the instance anchor; otherwise a file URL from import.meta.resolve;
@@ -157,33 +222,40 @@ export async function createNanCompatibleProvider(
157
222
  // `available` (e.g. premium-tier models you are not subscribed to).
158
223
  let liveIds: Set<string> | undefined;
159
224
 
160
- return piAi.createProvider({
161
- id: config.id,
162
- name: config.name,
163
- baseUrl: config.baseUrl,
164
- auth: { apiKey: piAi.envApiKeyAuth(`${config.name} API key`, config.envVars) },
165
- models: baselineModels(source),
166
- fetchModels: async (context: RefreshModelsContext) => {
167
- const credential = context.credential;
168
- const resolved = await resolveCatalog(source, {
169
- apiKey: credential?.type === "api_key" ? credential.key : undefined,
170
- timeoutMs: options.timeoutMs ?? DEFAULT_MODELS_TIMEOUT_MS,
171
- fetchImpl: options.fetchImpl,
172
- });
173
- liveIds = resolved.liveIds;
174
- return resolved.models;
175
- },
176
- filterModels: (models) => {
177
- // Snapshot: TS can't prove `liveIds` unchanged across the closure boundary.
178
- const current = liveIds;
179
- return current ? models.filter((model) => current.has(model.id)) : models;
180
- },
181
- // Sanitize the payload for NaN's strict schema, then classify an opaque
182
- // generic 400 as a context overflow when the request we just sent was over
183
- // the model's window. The second layer keeps a replayed cross-model
184
- // reasoning trace (or any other over-window request the context-hook guard
185
- // cannot reach, e.g. NAN_THINKING_GUARD=0) recoverable instead of wedging
186
- // the session — see src/context-overflow-classifier.ts.
187
- api: withContextOverflowClassification(wrapApiForStrictSanitization(apiFactory())),
188
- });
225
+ const imageModels = baselineImageModels(source);
226
+ const overlayMap = buildOverlayMap(source, NAN_GENERATED_MODELS, imageModels);
227
+
228
+ return withStaleCatalogOverlay(
229
+ piAi.createProvider({
230
+ id: config.id,
231
+ name: config.name,
232
+ baseUrl: config.baseUrl,
233
+ auth: { apiKey: piAi.envApiKeyAuth(`${config.name} API key`, config.envVars) },
234
+ models: [...baselineModels(source), ...baselineImageModels(source)],
235
+ images: { [NAN_IMAGE_API]: createNanImagesApi({ fetchImpl: options.fetchImpl, timeoutMs: options.timeoutMs }) },
236
+ fetchModels: async (context: RefreshModelsContext) => {
237
+ const credential = context.credential;
238
+ const resolved = await resolveCatalog(source, {
239
+ apiKey: credential?.type === "api_key" ? credential.key : undefined,
240
+ timeoutMs: options.timeoutMs ?? DEFAULT_MODELS_TIMEOUT_MS,
241
+ fetchImpl: options.fetchImpl,
242
+ });
243
+ liveIds = resolved.liveIds;
244
+ return resolved.models;
245
+ },
246
+ filterModels: (models) => {
247
+ // Snapshot: TS can't prove `liveIds` unchanged across the closure boundary.
248
+ const current = liveIds;
249
+ return current ? models.filter((model) => current.has(model.id)) : models;
250
+ },
251
+ // Sanitize the payload for NaN's strict schema, then classify an opaque
252
+ // generic 400 as a context overflow when the request we just sent was over
253
+ // the model's window. The second layer keeps a replayed cross-model
254
+ // reasoning trace (or any other over-window request the context-hook guard
255
+ // cannot reach, e.g. NAN_THINKING_GUARD=0) recoverable instead of wedging
256
+ // the session — see src/context-overflow-classifier.ts.
257
+ api: withContextOverflowClassification(wrapApiForStrictSanitization(apiFactory())),
258
+ }),
259
+ overlayMap,
260
+ );
189
261
  }