@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
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 `
|
|
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
|
-
* `
|
|
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;
|
package/src/provider-factory.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
}
|