@gtrabanco/pi-nan-provider 0.10.3 → 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 +68 -9
- package/README.es.md +57 -9
- package/README.md +57 -9
- package/package.json +5 -5
- package/scripts/models.generated.ts +2 -2
- package/src/commands.ts +6 -4
- package/src/context-overflow-classifier.ts +114 -23
- package/src/fetch-models.ts +147 -2
- package/src/images.ts +408 -0
- package/src/index.ts +5 -2
- package/src/provider-factory.ts +102 -30
package/src/fetch-models.ts
CHANGED
|
@@ -23,10 +23,12 @@
|
|
|
23
23
|
* back to the generated catalog so startup is never blocked.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type {
|
|
27
|
-
import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
26
|
+
import type { ImageModel, Model } from "@earendil-works/pi-ai";
|
|
27
|
+
import type { ThinkingLevelMap, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
|
|
28
28
|
import { GENERATED_CATALOG_META, NAN_GENERATED_MODELS } from "../scripts/models.generated.ts";
|
|
29
29
|
|
|
30
|
+
export { NAN_GENERATED_MODELS };
|
|
31
|
+
|
|
30
32
|
export type { Model };
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -341,3 +343,146 @@ export async function fetchNanCompatibleModels(
|
|
|
341
343
|
export function generatedCatalogMeta() {
|
|
342
344
|
return GENERATED_CATALOG_META;
|
|
343
345
|
}
|
|
346
|
+
|
|
347
|
+
// ── Stale catalog overlay (issue #19) ───────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Build a lookup map from model id to generated catalog entry. Used by
|
|
351
|
+
* `overlayGeneratedCapabilities` so the overlay is a single Map lookup.
|
|
352
|
+
*/
|
|
353
|
+
export function buildGeneratedCatalogMap(
|
|
354
|
+
generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
|
|
355
|
+
): Map<string, GeneratedModelEntry> {
|
|
356
|
+
return new Map(generated.map((entry) => [entry.id, entry]));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Pre-built lookup map for generated chat-catalog entries.
|
|
361
|
+
* Built once at module evaluation time so every read is a true O(1) Map.get.
|
|
362
|
+
* (The doc on `overlayGeneratedCapabilities` previously claimed O(1)
|
|
363
|
+
* `buildGeneratedCatalogMap` was called on every call — this fixes that.)
|
|
364
|
+
*/
|
|
365
|
+
const _generatedCatalogMap: Map<string, GeneratedModelEntry> = buildGeneratedCatalogMap();
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Overlay generated-catalog capability data onto a stored/restored model when
|
|
369
|
+
* the model id exists in the generated catalog (and the model type matches).
|
|
370
|
+
*
|
|
371
|
+
* Returns the generated catalog entry converted to a pi-ai Model via `toModel`
|
|
372
|
+
* when there is an exact id match with the same api (openai-completions).
|
|
373
|
+
* Image models, classifier models, or ids not in the catalog pass through
|
|
374
|
+
* unchanged — the persisted store decides which ids exist, but only the
|
|
375
|
+
* generated catalog decides capability data (issue #19).
|
|
376
|
+
*
|
|
377
|
+
* When `prebuilt` is provided it must be a Map<string, GeneratedModelEntry>
|
|
378
|
+
* keyed by model id for chat entries only. This parameter exists so the
|
|
379
|
+
* provider wrapper can pass a prebuilt map instead of rebuilding it every
|
|
380
|
+
* call (module-level `_generatedCatalogMap` is used when omitted).
|
|
381
|
+
*
|
|
382
|
+
* This function is pure, stateless, and O(1) — a single Map.get lookup.
|
|
383
|
+
* It is called at read time from the provider wrapper so every consumer
|
|
384
|
+
* (pi, the model registry, downstream code) sees the latest capabilities.
|
|
385
|
+
*/
|
|
386
|
+
export function overlayGeneratedCapabilities(
|
|
387
|
+
model: Model<string> | ImageModel<string>,
|
|
388
|
+
generatedOrPrebuilt?: readonly GeneratedModelEntry[] | Map<string, GeneratedModelEntry>,
|
|
389
|
+
): Model<string> | ImageModel<string> {
|
|
390
|
+
if (!("reasoning" in model && "contextWindow" in model)) {
|
|
391
|
+
// ImageModel and ClassifierModel do not have reasoning/contextWindow.
|
|
392
|
+
return model;
|
|
393
|
+
}
|
|
394
|
+
// Resolve the lookup map: prebuilt map > array > module-level singleton.
|
|
395
|
+
const byId: Map<string, GeneratedModelEntry> =
|
|
396
|
+
generatedOrPrebuilt instanceof Map
|
|
397
|
+
? generatedOrPrebuilt
|
|
398
|
+
: Array.isArray(generatedOrPrebuilt)
|
|
399
|
+
? buildGeneratedCatalogMap(generatedOrPrebuilt)
|
|
400
|
+
: _generatedCatalogMap;
|
|
401
|
+
const entry = byId.get(model.id);
|
|
402
|
+
// Only match when the generated catalog has this id AND the api is the
|
|
403
|
+
// chat api (openai-completions). Image models use a different api and
|
|
404
|
+
// a separate static list — they are not in this catalog.
|
|
405
|
+
if (entry && model.api === NAN_COMPAT_API) {
|
|
406
|
+
return toModel(entry, { providerId: model.provider, baseUrl: model.baseUrl });
|
|
407
|
+
}
|
|
408
|
+
return model;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── Combined overlay for chat + image models (GAP 1: getAllModels wrapping) ─
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Internal representation of an overlay entry: either a generated chat entry
|
|
415
|
+
* (GeneratedModelEntry → toModel result) or a baseline image model
|
|
416
|
+
* (ImageModel). Keys are model ids that are unique across chat/image
|
|
417
|
+
* namespaces.
|
|
418
|
+
*/
|
|
419
|
+
export interface OverlayEntry {
|
|
420
|
+
/** For chat models: the pi-ai Model produced by toModel(entry, source). */
|
|
421
|
+
chat?: Model<string>;
|
|
422
|
+
/** For image models: the ImageModel from baselineImageModels(source). */
|
|
423
|
+
image?: ImageModel<string>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Build a combined overlay map keyed by model id. The overlay contains:
|
|
428
|
+
* - For every generated chat model id: the toModel() result (chat entry).
|
|
429
|
+
* - For every baseline image model: the ImageModel itself (image entry).
|
|
430
|
+
*
|
|
431
|
+
* This map is built once at factory time (in withStaleCatalogOverlay) and
|
|
432
|
+
* used by both `getModels` and `getAllModels` wrappers so every read path
|
|
433
|
+
* receives generated-catalog capability data regardless of whether the
|
|
434
|
+
* model is chat or image (issue #19, GAP 1).
|
|
435
|
+
*/
|
|
436
|
+
export function buildOverlayMap(
|
|
437
|
+
source: CatalogSource,
|
|
438
|
+
generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
|
|
439
|
+
imageModels?: ImageModel<string>[],
|
|
440
|
+
): Map<string, OverlayEntry> {
|
|
441
|
+
const overlay = new Map<string, OverlayEntry>();
|
|
442
|
+
// Chat models from the generated catalog.
|
|
443
|
+
for (const entry of generated) {
|
|
444
|
+
overlay.set(entry.id, { chat: toModel(entry, source) });
|
|
445
|
+
}
|
|
446
|
+
// Image models from the baseline (these live under a different api id
|
|
447
|
+
// so they cannot coexist as chat entries — ids are unique across types).
|
|
448
|
+
if (imageModels) {
|
|
449
|
+
for (const img of imageModels) {
|
|
450
|
+
overlay.set(img.id, { image: img });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return overlay;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Internal model-like shape used by applyOverlay: every model type has id, api,
|
|
458
|
+
* and (for images) a `type` discriminator.
|
|
459
|
+
*/
|
|
460
|
+
interface _ModelLike {
|
|
461
|
+
id: string;
|
|
462
|
+
api: string;
|
|
463
|
+
type?: string;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Apply the combined overlay (chat + image) to a single model. Looks up the
|
|
468
|
+
* id in the provided map and returns the overlaid entry when present.
|
|
469
|
+
* Image models (type === "image") receive their ImageModel overlay; chat
|
|
470
|
+
* models receive their generated-catalog entry via `overlayGeneratedCapabilities`.
|
|
471
|
+
* ClassifierModel and other model types pass through unchanged.
|
|
472
|
+
*/
|
|
473
|
+
export function applyOverlay<T extends _ModelLike>(
|
|
474
|
+
model: T,
|
|
475
|
+
overlay: Map<string, OverlayEntry>,
|
|
476
|
+
): T {
|
|
477
|
+
const entry = overlay.get(model.id);
|
|
478
|
+
if (!entry) return model;
|
|
479
|
+
// Image models: return the baseline ImageModel overlay.
|
|
480
|
+
if ((model as _ModelLike).type === "image") {
|
|
481
|
+
return (entry.image ?? model) as T;
|
|
482
|
+
}
|
|
483
|
+
// Chat models: use the standard overlay function (still O(1)).
|
|
484
|
+
if (entry.chat && model.api === NAN_COMPAT_API) {
|
|
485
|
+
return (entry.chat as unknown as T) ?? model;
|
|
486
|
+
}
|
|
487
|
+
return model;
|
|
488
|
+
}
|
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;
|