@howells/motif-sdk 4.0.0 → 5.0.1
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/README.md +40 -8
- package/dist/image.d.ts +8 -8
- package/dist/image.js +262 -135
- package/dist/index.cjs +564 -152
- package/dist/index.d.cts +89 -64
- package/dist/index.d.ts +89 -64
- package/dist/index.js +564 -152
- package/package.json +11 -12
package/dist/index.cjs
CHANGED
|
@@ -4,7 +4,6 @@ let _howells_envy = require("@howells/envy");
|
|
|
4
4
|
let zod = require("zod");
|
|
5
5
|
let ai = require("ai");
|
|
6
6
|
let _ai_sdk_fal = require("@ai-sdk/fal");
|
|
7
|
-
let _ai_sdk_google = require("@ai-sdk/google");
|
|
8
7
|
let _ai_sdk_openai = require("@ai-sdk/openai");
|
|
9
8
|
let _ai_sdk_replicate = require("@ai-sdk/replicate");
|
|
10
9
|
//#region src/aspects.ts
|
|
@@ -268,10 +267,11 @@ var CreativeOptionError = class extends Error {
|
|
|
268
267
|
/** Canonical creative field order used for prompt enrichment and schema output. */
|
|
269
268
|
const CREATIVE_FIELDS = ["look", "mood"];
|
|
270
269
|
/**
|
|
271
|
-
* Built-in creative direction catalogue:
|
|
270
|
+
* Built-in creative direction catalogue: nine house looks and six light moods.
|
|
272
271
|
*
|
|
273
272
|
* Each option carries the exact prompt sentence appended when it is selected.
|
|
274
|
-
* Looks also carry the aspect ratio and model they were tuned for.
|
|
273
|
+
* Looks also carry the aspect ratio and model they were tuned for. The five
|
|
274
|
+
* photographic looks come first, then the four flat ones.
|
|
275
275
|
*/
|
|
276
276
|
const CREATIVE_TAXONOMY = {
|
|
277
277
|
look: [
|
|
@@ -287,102 +287,75 @@ const CREATIVE_TAXONOMY = {
|
|
|
287
287
|
{
|
|
288
288
|
acceptsMood: true,
|
|
289
289
|
aspect: "1:1",
|
|
290
|
-
clause: "Editorial still life on a warm bone plaster ground, chalky unglazed surfaces, a long soft shadow, generous empty space, shot on film with fine grain. No text, no logos, no people",
|
|
291
|
-
description: "Objects and
|
|
290
|
+
clause: "Editorial still life in the register of Aesop and Kinfolk, on a warm bone plaster ground, chalky unglazed surfaces in muted mineral colour, a long soft shadow, generous empty space, shot on film with fine grain, restrained and materially rich. No text, no logos, no people",
|
|
291
|
+
description: "Objects and products on a plaster ground, for product and editorial still life.",
|
|
292
292
|
id: "still-life",
|
|
293
|
-
label: "
|
|
293
|
+
label: "Editorial still life",
|
|
294
294
|
model: "flux2-pro"
|
|
295
295
|
},
|
|
296
296
|
{
|
|
297
297
|
acceptsMood: true,
|
|
298
298
|
aspect: "3:2",
|
|
299
|
-
clause: "Interior photograph shot square-on at eye level on a 35mm lens, warm off-white plaster, wide oak floorboards, linen, brass and a little pattern, light, bright and layered, collected rather than styled,
|
|
299
|
+
clause: "Interior photograph in the register of House & Garden and Kinfolk, shot square-on at eye level on a 35mm lens, warm off-white plaster, wide oak floorboards, linen, brass and a little pattern, light, bright and layered, collected rather than styled, lived-in rather than showroom-perfect, soft natural daylight, shot on film with fine grain. No text, no logos, no people",
|
|
300
300
|
description: "Bright, collected rooms that feel lived in, for interior scenes.",
|
|
301
|
-
id: "
|
|
302
|
-
label: "
|
|
301
|
+
id: "interior",
|
|
302
|
+
label: "Interior",
|
|
303
303
|
model: "flux2-pro"
|
|
304
304
|
},
|
|
305
305
|
{
|
|
306
306
|
acceptsMood: true,
|
|
307
307
|
aspect: "4:5",
|
|
308
|
-
clause: "Architectural
|
|
309
|
-
description: "
|
|
308
|
+
clause: "Architectural photograph in the register of House & Garden and Kinfolk, a considered house seen from outside at editorial distance with its garden and setting, pale render, stone or timber meeting precise detailing, clipped planting, soft warm daylight and long shadow, generous negative space, immaculate and calm, shot on film with fine grain. No text, no logos, no people",
|
|
309
|
+
description: "Buildings and their settings from outside, for architecture, property and place.",
|
|
310
310
|
id: "architectural",
|
|
311
|
-
label: "Architectural
|
|
311
|
+
label: "Architectural exterior",
|
|
312
312
|
model: "banana"
|
|
313
313
|
},
|
|
314
|
-
{
|
|
315
|
-
acceptsMood: true,
|
|
316
|
-
aspect: "4:3",
|
|
317
|
-
clause: "Amateur phone photo of a real home taken by the homeowner, slightly wonky framing, unstyled domestic photography, ordinary exposure. No text, no people",
|
|
318
|
-
description: "Unstyled phone snapshots of real homes, for believable before and after shots.",
|
|
319
|
-
id: "homeowner",
|
|
320
|
-
label: "Homeowner snapshot",
|
|
321
|
-
model: "seedream45"
|
|
322
|
-
},
|
|
323
314
|
{
|
|
324
315
|
acceptsMood: true,
|
|
325
316
|
aspect: "1:1",
|
|
326
|
-
clause: "
|
|
327
|
-
description: "
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
model: "gpt2"
|
|
317
|
+
clause: "Editorial documentary portrait in the register of Kinfolk, muted warm palette, waist-up and unposed against a plain plaster or linen ground, plain clothing with no logos, soft natural light, shot on film with fine grain. No text",
|
|
318
|
+
description: "Natural, unposed documentary portraits of people. Pair with a mood for the light.",
|
|
319
|
+
id: "portrait",
|
|
320
|
+
label: "Documentary portrait",
|
|
321
|
+
model: "seedream45"
|
|
332
322
|
},
|
|
333
323
|
{
|
|
334
324
|
acceptsMood: false,
|
|
335
325
|
aspect: "1:1",
|
|
336
|
-
clause: "
|
|
337
|
-
description: "
|
|
338
|
-
id: "
|
|
339
|
-
label: "
|
|
326
|
+
clause: "A single matte object centred with generous empty space, soft diffused studio light, minimal and quiet in the register of Aesop, one committed muted mineral colour on a plain ground. No text, no logos, no people",
|
|
327
|
+
description: "One object in one colour on a clean ground, for icons and simple product shots.",
|
|
328
|
+
id: "object",
|
|
329
|
+
label: "Studio object",
|
|
340
330
|
model: "flux2-pro"
|
|
341
331
|
},
|
|
342
332
|
{
|
|
343
333
|
acceptsMood: false,
|
|
344
334
|
aspect: "1:1",
|
|
345
|
-
clause: "
|
|
346
|
-
description: "
|
|
347
|
-
id: "
|
|
348
|
-
label: "
|
|
349
|
-
model: "
|
|
350
|
-
},
|
|
351
|
-
{
|
|
352
|
-
acceptsMood: false,
|
|
353
|
-
aspect: "2:3",
|
|
354
|
-
clause: "Tightly cropped photograph of a single piece of late-1940s American printed matter, flat and square-on in even light, every pixel paper, letterpress and wood type, sun-faded ink, foxing, soft creases and thumbtack holes, era-correct typography, nothing that looks like a digital photo run through a filter",
|
|
355
|
-
description: "Aged mid-century printed matter such as posters and cards, where the lettering matters.",
|
|
356
|
-
id: "ephemera",
|
|
357
|
-
label: "Period ephemera",
|
|
358
|
-
model: "ideogram4"
|
|
335
|
+
clause: "Straight-on orthographic photograph of the surface filling the entire frame edge to edge, even shadowless studio light, crisp macro texture, colour-accurate and quietly material. No text, no logos",
|
|
336
|
+
description: "Flat, edge-to-edge surface photographs, for textures, backgrounds and material swatches.",
|
|
337
|
+
id: "surface",
|
|
338
|
+
label: "Flat surface",
|
|
339
|
+
model: "flux2-pro"
|
|
359
340
|
},
|
|
360
341
|
{
|
|
361
342
|
acceptsMood: false,
|
|
362
|
-
aspect: "3:
|
|
363
|
-
clause: "
|
|
364
|
-
description: "
|
|
365
|
-
id: "
|
|
366
|
-
label: "
|
|
343
|
+
aspect: "3:2",
|
|
344
|
+
clause: "Painted abstraction filling the frame edge to edge, mineral pigment and chalk gesso on coarse natural linen, two or three confident gestures, warm ivory, oatmeal, putty and soft charcoal, flat diffuse reproduction light. No text",
|
|
345
|
+
description: "Painted abstraction edge to edge, for wall art, heroes and calm backgrounds.",
|
|
346
|
+
id: "abstract",
|
|
347
|
+
label: "Painted abstract",
|
|
367
348
|
model: "banana"
|
|
368
349
|
},
|
|
369
|
-
{
|
|
370
|
-
acceptsMood: true,
|
|
371
|
-
aspect: "1:1",
|
|
372
|
-
clause: "Editorial documentary portrait, muted warm palette, waist-up, unposed, plain clothing with no logos. No text",
|
|
373
|
-
description: "Natural, unposed documentary portraits of people. Pair with a mood for the light.",
|
|
374
|
-
id: "portrait",
|
|
375
|
-
label: "Documentary portrait",
|
|
376
|
-
model: "seedream45"
|
|
377
|
-
},
|
|
378
350
|
{
|
|
379
351
|
acceptsMood: false,
|
|
380
352
|
aspect: "1:1",
|
|
381
|
-
clause: "
|
|
382
|
-
description: "
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
353
|
+
clause: "Stylised editorial illustration in the register of Kinfolk, colour laid as flat planes in a warm muted palette of ivory, putty, sage and charcoal, fine hand-drawn line with a gentle gouache wash, generous empty space, clearly a drawing rather than a photograph. No text, no logos",
|
|
354
|
+
description: "Line and gouache illustration of any subject, for drawn editorial imagery.",
|
|
355
|
+
experimental: true,
|
|
356
|
+
id: "illustration",
|
|
357
|
+
label: "Editorial illustration",
|
|
358
|
+
model: "gpt2"
|
|
386
359
|
}
|
|
387
360
|
],
|
|
388
361
|
mood: [
|
|
@@ -857,12 +830,16 @@ const MODELS = {
|
|
|
857
830
|
unit: "units",
|
|
858
831
|
unitPrice: 1
|
|
859
832
|
},
|
|
860
|
-
maxReferenceImages:
|
|
833
|
+
maxReferenceImages: 16,
|
|
861
834
|
name: "GPT Image 2",
|
|
862
835
|
pricePerImageUsd: .211,
|
|
863
836
|
pricing: "$0.211",
|
|
864
837
|
sizeMode: "image_size_enum",
|
|
865
838
|
supportsAspect: true,
|
|
839
|
+
streaming: {
|
|
840
|
+
generation: true,
|
|
841
|
+
edit: true
|
|
842
|
+
},
|
|
866
843
|
supportsEdit: true,
|
|
867
844
|
supportsMaskImage: true,
|
|
868
845
|
maskImageField: "mask_url",
|
|
@@ -924,6 +901,10 @@ const MODELS = {
|
|
|
924
901
|
sizeMode: "gpt_size",
|
|
925
902
|
supportsAspect: false,
|
|
926
903
|
supportsBackground: true,
|
|
904
|
+
streaming: {
|
|
905
|
+
generation: true,
|
|
906
|
+
edit: true
|
|
907
|
+
},
|
|
927
908
|
supportsEdit: true,
|
|
928
909
|
supportsMaskImage: true,
|
|
929
910
|
supportsNumImages: true,
|
|
@@ -1558,12 +1539,16 @@ const MODELS = {
|
|
|
1558
1539
|
unit: "compute seconds",
|
|
1559
1540
|
unitPrice: .00167
|
|
1560
1541
|
},
|
|
1561
|
-
maxReferenceImages:
|
|
1542
|
+
maxReferenceImages: 4,
|
|
1562
1543
|
name: "FLUX.2 [dev]",
|
|
1563
1544
|
pricePerImageUsd: .012,
|
|
1564
1545
|
pricing: "$0.00167/sec",
|
|
1565
1546
|
sizeMode: "image_size_enum",
|
|
1566
1547
|
supportsAspect: true,
|
|
1548
|
+
streaming: {
|
|
1549
|
+
generation: true,
|
|
1550
|
+
edit: true
|
|
1551
|
+
},
|
|
1567
1552
|
supportsEdit: true,
|
|
1568
1553
|
supportsGuidanceScale: true,
|
|
1569
1554
|
supportsInferenceSteps: true,
|
|
@@ -2315,7 +2300,7 @@ function estimateVideoCost(durationSeconds = 5, generateAudio = true, model = "k
|
|
|
2315
2300
|
* `Response.json()` yields `any`; these helpers narrow that untyped payload into
|
|
2316
2301
|
* the SDK's response types with runtime guards instead of unchecked assertions.
|
|
2317
2302
|
*/
|
|
2318
|
-
function isRecord$
|
|
2303
|
+
function isRecord$4(value) {
|
|
2319
2304
|
return typeof value === "object" && value !== null;
|
|
2320
2305
|
}
|
|
2321
2306
|
function asString(value) {
|
|
@@ -2325,7 +2310,7 @@ function asNumber(value) {
|
|
|
2325
2310
|
return typeof value === "number" ? value : void 0;
|
|
2326
2311
|
}
|
|
2327
2312
|
function toMotifImage(value) {
|
|
2328
|
-
if (isRecord$
|
|
2313
|
+
if (isRecord$4(value)) return {
|
|
2329
2314
|
content_type: asString(value.content_type),
|
|
2330
2315
|
height: asNumber(value.height),
|
|
2331
2316
|
url: asString(value.url) ?? "",
|
|
@@ -2340,7 +2325,7 @@ function parseImages(value) {
|
|
|
2340
2325
|
function parseLogs(value) {
|
|
2341
2326
|
if (!Array.isArray(value)) return;
|
|
2342
2327
|
const entries = [];
|
|
2343
|
-
for (const entry of value) if (isRecord$
|
|
2328
|
+
for (const entry of value) if (isRecord$4(entry)) entries.push({
|
|
2344
2329
|
message: asString(entry.message) ?? "",
|
|
2345
2330
|
timestamp: asString(entry.timestamp) ?? ""
|
|
2346
2331
|
});
|
|
@@ -2360,7 +2345,7 @@ function endpointFromQueueUrl(url, fallback) {
|
|
|
2360
2345
|
}
|
|
2361
2346
|
}
|
|
2362
2347
|
function parseQueueSubmission(data) {
|
|
2363
|
-
if (!isRecord$
|
|
2348
|
+
if (!isRecord$4(data)) return { requestId: "" };
|
|
2364
2349
|
return {
|
|
2365
2350
|
requestId: asString(data.request_id) ?? "",
|
|
2366
2351
|
responseUrl: asString(data.response_url)
|
|
@@ -2400,7 +2385,7 @@ function requestIdFromBody(text) {
|
|
|
2400
2385
|
} catch {
|
|
2401
2386
|
return;
|
|
2402
2387
|
}
|
|
2403
|
-
if (!isRecord$
|
|
2388
|
+
if (!isRecord$4(parsed)) return;
|
|
2404
2389
|
return asString(parsed.request_id) ?? asString(parsed.requestId) ?? asString(parsed.trace_id);
|
|
2405
2390
|
}
|
|
2406
2391
|
//#endregion
|
|
@@ -4248,7 +4233,7 @@ async function runRequest(exec, prepared) {
|
|
|
4248
4233
|
const response = await exec.request(`${FAL_BASE_URL$1}/${prepared.endpoint}`, requestInit(prepared));
|
|
4249
4234
|
if (response.isErr()) return (0, neverthrow.err)(response.error);
|
|
4250
4235
|
const data = await response.value.json();
|
|
4251
|
-
const record = isRecord$
|
|
4236
|
+
const record = isRecord$4(data) ? data : {};
|
|
4252
4237
|
const requestId = response.value.headers.get("x-fal-request-id") ?? asString(record.request_id);
|
|
4253
4238
|
return (0, neverthrow.ok)({
|
|
4254
4239
|
data: record,
|
|
@@ -4275,7 +4260,7 @@ async function getToolResult(exec, job) {
|
|
|
4275
4260
|
const response = await exec.request(url);
|
|
4276
4261
|
if (response.isErr()) return (0, neverthrow.err)(response.error);
|
|
4277
4262
|
const data = await response.value.json();
|
|
4278
|
-
return (0, neverthrow.ok)(isRecord$
|
|
4263
|
+
return (0, neverthrow.ok)(isRecord$4(data) ? data : {});
|
|
4279
4264
|
}
|
|
4280
4265
|
/** Submit a prepared request, poll it to completion and fetch the result. */
|
|
4281
4266
|
async function runRequestQueued(exec, prepared, onProgress) {
|
|
@@ -4430,7 +4415,7 @@ var FalClient = class {
|
|
|
4430
4415
|
const response = await this.request(url);
|
|
4431
4416
|
if (response.isErr()) return (0, neverthrow.err)(response.error);
|
|
4432
4417
|
const data = await response.value.json();
|
|
4433
|
-
const record = isRecord$
|
|
4418
|
+
const record = isRecord$4(data) ? data : {};
|
|
4434
4419
|
const rawStatus = asString(record.status);
|
|
4435
4420
|
let status;
|
|
4436
4421
|
if (rawStatus === "IN_QUEUE") status = "queued";
|
|
@@ -4538,7 +4523,7 @@ var FalClient = class {
|
|
|
4538
4523
|
const response = await this.request(url);
|
|
4539
4524
|
if (response.isErr()) return (0, neverthrow.err)(response.error);
|
|
4540
4525
|
const data = await response.value.json();
|
|
4541
|
-
const video = isRecord$
|
|
4526
|
+
const video = isRecord$4(data) && isRecord$4(data.video) ? data.video : void 0;
|
|
4542
4527
|
if (video === void 0) return (0, neverthrow.err)(new MotifError("No video in response", 0));
|
|
4543
4528
|
return (0, neverthrow.ok)({
|
|
4544
4529
|
contentType: asString(video.content_type) ?? "",
|
|
@@ -4562,8 +4547,8 @@ var FalClient = class {
|
|
|
4562
4547
|
});
|
|
4563
4548
|
if (initiateResponse.isErr()) return (0, neverthrow.err)(initiateResponse.error);
|
|
4564
4549
|
const initiateData = await initiateResponse.value.json();
|
|
4565
|
-
const fileUrl = isRecord$
|
|
4566
|
-
const uploadUrl = isRecord$
|
|
4550
|
+
const fileUrl = isRecord$4(initiateData) ? asString(initiateData.file_url) ?? "" : "";
|
|
4551
|
+
const uploadUrl = isRecord$4(initiateData) ? asString(initiateData.upload_url) : void 0;
|
|
4567
4552
|
if (uploadUrl === void 0 || uploadUrl === "") return (0, neverthrow.err)(new MotifError("Upload initiate response missing upload_url", 0));
|
|
4568
4553
|
let putResponse;
|
|
4569
4554
|
try {
|
|
@@ -4715,7 +4700,7 @@ var FalClient = class {
|
|
|
4715
4700
|
* Some APIs return `{ image: {...} }` instead of `{ images: [...] }`.
|
|
4716
4701
|
*/
|
|
4717
4702
|
normalizeResponse(data, fallbackRequestId) {
|
|
4718
|
-
if (!isRecord$
|
|
4703
|
+
if (!isRecord$4(data)) return (0, neverthrow.err)(new MotifError("Unexpected fal response shape", 0, "FAL_ERROR"));
|
|
4719
4704
|
const obj = data;
|
|
4720
4705
|
const requestId = asString(obj.request_id) ?? asString(obj.requestId) ?? fallbackRequestId;
|
|
4721
4706
|
if ("detail" in obj) return (0, neverthrow.err)(new MotifError(asString(obj.detail) ?? "", 0, "FAL_ERROR"));
|
|
@@ -8414,6 +8399,17 @@ function resolveModel$3(modelId, apiKey, fetch) {
|
|
|
8414
8399
|
...toProviderFetch(fetch)
|
|
8415
8400
|
}).image(modelId);
|
|
8416
8401
|
}
|
|
8402
|
+
/**
|
|
8403
|
+
* How a registered fal edit endpoint takes its input images, from the
|
|
8404
|
+
* registry's `editImagesField` (default `image_urls`). `@ai-sdk/fal` sends only
|
|
8405
|
+
* the first image as `image_url` unless told otherwise, which list-only
|
|
8406
|
+
* endpoints reject and which silently drops every reference after the first.
|
|
8407
|
+
* Undefined for an endpoint the registry does not list as an edit route.
|
|
8408
|
+
*/
|
|
8409
|
+
function falEditImagesField(modelId) {
|
|
8410
|
+
const config = Object.values(MODELS).find((entry) => entry.editEndpoint === modelId);
|
|
8411
|
+
return config === void 0 ? void 0 : config.editImagesField ?? "image_urls";
|
|
8412
|
+
}
|
|
8417
8413
|
/** The fal provider adapter registered in the provider registry. */
|
|
8418
8414
|
const falAdapter = {
|
|
8419
8415
|
id: "fal",
|
|
@@ -8422,61 +8418,6 @@ const falAdapter = {
|
|
|
8422
8418
|
priceUsdByModel: FAL_IMAGE_PRICE_USD
|
|
8423
8419
|
};
|
|
8424
8420
|
//#endregion
|
|
8425
|
-
//#region src/image/google.ts
|
|
8426
|
-
/**
|
|
8427
|
-
* Google (Gemini) provider adapter.
|
|
8428
|
-
*
|
|
8429
|
-
* Builds a Vercel AI SDK `ImageModel` from `@ai-sdk/google`. Building a model
|
|
8430
|
-
* performs no network I/O — the request only happens when `generateImage`
|
|
8431
|
-
* invokes `model.doGenerate`. Gemini supports both text→image generation and
|
|
8432
|
-
* multi-image-in → image-out editing (with an optional mask), which is the core
|
|
8433
|
-
* operation this layer normalizes.
|
|
8434
|
-
*/
|
|
8435
|
-
/** Env var read for the Google API key when `apiKey` is not supplied in config. */
|
|
8436
|
-
const GOOGLE_API_KEY_ENV = "GOOGLE_GENERATIVE_AI_API_KEY";
|
|
8437
|
-
/**
|
|
8438
|
-
* Static Google-direct USD/image, keyed by model id.
|
|
8439
|
-
*
|
|
8440
|
-
* Sources (Google direct, not fal-hosted):
|
|
8441
|
-
* - `gemini-2.5-flash-image` ("nano banana"): image output billed at 1290
|
|
8442
|
-
* output tokens/image at $30 / 1M output tokens ≈ $0.039/image.
|
|
8443
|
-
* Source: https://ai.google.dev/gemini-api/docs/pricing
|
|
8444
|
-
* Sanity anchor: fal-hosted `fal-ai/gemini-25-flash-image` is $0.0398
|
|
8445
|
-
* (`MODELS.gemini.pricePerImageUsd` in ../models) — same ballpark.
|
|
8446
|
-
* - `gemini-3-pro-image-preview` ("nano banana pro"): standard 1K/2K image
|
|
8447
|
-
* output ≈ $0.134/image (higher tiers/4K cost more).
|
|
8448
|
-
* Source: https://ai.google.dev/gemini-api/docs/pricing
|
|
8449
|
-
* Sanity anchor: fal-hosted `fal-ai/gemini-3-pro-image-preview` is $0.15
|
|
8450
|
-
* (`MODELS.gemini3`/`MODELS.banana` in ../models) — fal adds overhead.
|
|
8451
|
-
* - `gemini-3.1-flash-image-preview`: flash-tier image output; priced with the
|
|
8452
|
-
* 2.5 flash-image line (≈ $0.039/image) pending a distinct published rate.
|
|
8453
|
-
*/
|
|
8454
|
-
const GOOGLE_IMAGE_PRICE_USD = {
|
|
8455
|
-
"gemini-2.5-flash-image": .039,
|
|
8456
|
-
"gemini-3.1-flash-image-preview": .039,
|
|
8457
|
-
"gemini-3-pro-image-preview": .134
|
|
8458
|
-
};
|
|
8459
|
-
/**
|
|
8460
|
-
* Build a Google Gemini `ImageModel`. Prefers the passed `apiKey`, else the
|
|
8461
|
-
* `GOOGLE_GENERATIVE_AI_API_KEY` env var. Throws `MotifError` when neither is
|
|
8462
|
-
* present (callers translate this into a `Result.err`).
|
|
8463
|
-
*/
|
|
8464
|
-
function resolveModel$2(modelId, apiKey, fetch) {
|
|
8465
|
-
const key = apiKey ?? process.env["GOOGLE_GENERATIVE_AI_API_KEY"];
|
|
8466
|
-
if (key === void 0 || key === "") throw new MotifError(`Google image generation requires an API key (config.google.apiKey or ${GOOGLE_API_KEY_ENV})`, 0);
|
|
8467
|
-
return (0, _ai_sdk_google.createGoogleGenerativeAI)({
|
|
8468
|
-
apiKey: key,
|
|
8469
|
-
...toProviderFetch(fetch)
|
|
8470
|
-
}).image(modelId);
|
|
8471
|
-
}
|
|
8472
|
-
/** The Google (Gemini) provider adapter registered in the provider registry. */
|
|
8473
|
-
const googleAdapter = {
|
|
8474
|
-
id: "google",
|
|
8475
|
-
apiKeyEnv: GOOGLE_API_KEY_ENV,
|
|
8476
|
-
resolveModel: resolveModel$2,
|
|
8477
|
-
priceUsdByModel: GOOGLE_IMAGE_PRICE_USD
|
|
8478
|
-
};
|
|
8479
|
-
//#endregion
|
|
8480
8421
|
//#region src/image/openai.ts
|
|
8481
8422
|
/**
|
|
8482
8423
|
* OpenAI (gpt-image) provider adapter.
|
|
@@ -8495,7 +8436,7 @@ const OPENAI_API_KEY_ENV = "OPENAI_API_KEY";
|
|
|
8495
8436
|
* and size — this is a documented approximation for the common case.
|
|
8496
8437
|
* Source: https://platform.openai.com/docs/pricing (image generation)
|
|
8497
8438
|
* Sanity anchor: the Phase 0 benchmark measured gpt-image direct at $0.042
|
|
8498
|
-
* (vs $0.133 via fal —
|
|
8439
|
+
* (vs $0.133 via fal — full data and methodology on Linear MOT-23).
|
|
8499
8440
|
*
|
|
8500
8441
|
* GPT Image 2.5 is token-priced, with no published per-image estimate. Leave
|
|
8501
8442
|
* these models absent so cost remains unknown unless supplied by the provider.
|
|
@@ -8508,7 +8449,7 @@ const OPENAI_IMAGE_PRICE_USD = { "gpt-image-1": .042 };
|
|
|
8508
8449
|
* `OPENAI_API_KEY` env var. Throws `MotifError` when neither is present
|
|
8509
8450
|
* (callers translate this into a `Result.err`).
|
|
8510
8451
|
*/
|
|
8511
|
-
function resolveModel$
|
|
8452
|
+
function resolveModel$2(modelId, apiKey, fetch) {
|
|
8512
8453
|
const key = apiKey ?? process.env["OPENAI_API_KEY"];
|
|
8513
8454
|
if (key === void 0 || key === "") throw new MotifError(`OpenAI image generation requires an API key (config.openai.apiKey or ${OPENAI_API_KEY_ENV})`, 0);
|
|
8514
8455
|
return (0, _ai_sdk_openai.createOpenAI)({
|
|
@@ -8520,10 +8461,174 @@ function resolveModel$1(modelId, apiKey, fetch) {
|
|
|
8520
8461
|
const openaiAdapter = {
|
|
8521
8462
|
id: "openai",
|
|
8522
8463
|
apiKeyEnv: OPENAI_API_KEY_ENV,
|
|
8523
|
-
resolveModel: resolveModel$
|
|
8464
|
+
resolveModel: resolveModel$2,
|
|
8524
8465
|
priceUsdByModel: OPENAI_IMAGE_PRICE_USD
|
|
8525
8466
|
};
|
|
8526
8467
|
//#endregion
|
|
8468
|
+
//#region src/image/openrouter.ts
|
|
8469
|
+
/** Env var read for the OpenRouter API key when `apiKey` is not supplied in config. */
|
|
8470
|
+
const OPENROUTER_API_KEY_ENV = "OPENROUTER_API_KEY";
|
|
8471
|
+
const OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images";
|
|
8472
|
+
/** Key under which this adapter's metadata sits on `providerMetadata`. */
|
|
8473
|
+
const METADATA_KEY = "openrouter";
|
|
8474
|
+
/**
|
|
8475
|
+
* The Gemini image models Motif exposes, by their bare Google names, mapped to
|
|
8476
|
+
* the OpenRouter slug that serves them. All six are listed by
|
|
8477
|
+
* `GET https://openrouter.ai/api/v1/images/models`.
|
|
8478
|
+
*/
|
|
8479
|
+
const OPENROUTER_GEMINI_IMAGE_MODELS = {
|
|
8480
|
+
"gemini-2.5-flash-image": "google/gemini-2.5-flash-image",
|
|
8481
|
+
"gemini-3.1-flash-image-preview": "google/gemini-3.1-flash-image-preview",
|
|
8482
|
+
"gemini-3-pro-image-preview": "google/gemini-3-pro-image-preview",
|
|
8483
|
+
"gemini-3.1-flash-image": "google/gemini-3.1-flash-image",
|
|
8484
|
+
"gemini-3-pro-image": "google/gemini-3-pro-image",
|
|
8485
|
+
"gemini-3.1-flash-lite-image": "google/gemini-3.1-flash-lite-image"
|
|
8486
|
+
};
|
|
8487
|
+
/**
|
|
8488
|
+
* Static USD/image estimate, keyed by the bare model name. Used only when a
|
|
8489
|
+
* response carries no `usage.cost`; OpenRouter normally reports the real figure.
|
|
8490
|
+
*
|
|
8491
|
+
* - `gemini-2.5-flash-image`: 1290 output tokens at $30 / 1M ≈ $0.039.
|
|
8492
|
+
* - `gemini-3-pro-image-preview`, `gemini-3-pro-image`: ≈ $0.134 at 1K/2K.
|
|
8493
|
+
* - `gemini-3.1-flash-image-preview`: priced with the 2.5 flash-image line
|
|
8494
|
+
* pending a distinct published rate.
|
|
8495
|
+
* - `gemini-3.1-flash-image`: $0.067 at 1K.
|
|
8496
|
+
* - `gemini-3.1-flash-lite-image`: ≈ $0.0336 at 1K.
|
|
8497
|
+
* Source: https://ai.google.dev/gemini-api/docs/pricing
|
|
8498
|
+
*/
|
|
8499
|
+
const OPENROUTER_IMAGE_PRICE_USD = {
|
|
8500
|
+
"gemini-2.5-flash-image": .039,
|
|
8501
|
+
"gemini-3.1-flash-image-preview": .039,
|
|
8502
|
+
"gemini-3-pro-image-preview": .134,
|
|
8503
|
+
"gemini-3.1-flash-image": .067,
|
|
8504
|
+
"gemini-3-pro-image": .134,
|
|
8505
|
+
"gemini-3.1-flash-lite-image": .0336
|
|
8506
|
+
};
|
|
8507
|
+
/**
|
|
8508
|
+
* Resolve a Motif model name to an OpenRouter slug. A bare Gemini name maps to
|
|
8509
|
+
* its `google/<id>` slug; a name already containing `/` is an OpenRouter slug
|
|
8510
|
+
* and passes through. Anything else is unknown and throws.
|
|
8511
|
+
*/
|
|
8512
|
+
function openRouterModelSlug(modelId) {
|
|
8513
|
+
if (modelId.includes("/")) return modelId;
|
|
8514
|
+
const slug = OPENROUTER_GEMINI_IMAGE_MODELS[modelId];
|
|
8515
|
+
if (slug === void 0) throw new MotifError(`No OpenRouter image model for "${modelId}". Known: ${Object.keys(OPENROUTER_GEMINI_IMAGE_MODELS).join(", ")}; or pass an OpenRouter slug such as google/gemini-3.1-flash-image.`, 0);
|
|
8516
|
+
return slug;
|
|
8517
|
+
}
|
|
8518
|
+
function isRecord$3(value) {
|
|
8519
|
+
return typeof value === "object" && value !== null;
|
|
8520
|
+
}
|
|
8521
|
+
function toDataUrl(mediaType, data) {
|
|
8522
|
+
return `data:${mediaType};base64,${typeof data === "string" ? data : Buffer.from(data).toString("base64")}`;
|
|
8523
|
+
}
|
|
8524
|
+
/** One `input_references` entry for an input file: a URL as-is, bytes as a data URL. */
|
|
8525
|
+
function toInputReference(file) {
|
|
8526
|
+
return {
|
|
8527
|
+
type: "image_url",
|
|
8528
|
+
image_url: { url: file.type === "url" ? file.url : toDataUrl(file.mediaType, file.data) }
|
|
8529
|
+
};
|
|
8530
|
+
}
|
|
8531
|
+
function buildBody(slug, options) {
|
|
8532
|
+
if (options.mask !== void 0) throw new MotifError("OpenRouter's Image API takes no mask; describe the region in the instruction instead.", 0);
|
|
8533
|
+
return {
|
|
8534
|
+
...options.providerOptions[METADATA_KEY] ?? {},
|
|
8535
|
+
model: slug,
|
|
8536
|
+
prompt: options.prompt,
|
|
8537
|
+
n: options.n,
|
|
8538
|
+
...options.aspectRatio === void 0 ? {} : { aspect_ratio: options.aspectRatio },
|
|
8539
|
+
...options.files === void 0 || options.files.length === 0 ? {} : { input_references: options.files.map(toInputReference) }
|
|
8540
|
+
};
|
|
8541
|
+
}
|
|
8542
|
+
function errorMessage(body, fallback) {
|
|
8543
|
+
if (isRecord$3(body) && isRecord$3(body.error)) {
|
|
8544
|
+
const { message } = body.error;
|
|
8545
|
+
if (typeof message === "string" && message !== "") return message;
|
|
8546
|
+
}
|
|
8547
|
+
return fallback;
|
|
8548
|
+
}
|
|
8549
|
+
function parseResponse(body) {
|
|
8550
|
+
if (!isRecord$3(body) || !Array.isArray(body.data)) throw new MotifError("OpenRouter image response had no data array", 502);
|
|
8551
|
+
const images = [];
|
|
8552
|
+
for (const entry of body.data) if (isRecord$3(entry) && typeof entry.b64_json === "string") images.push(entry.b64_json);
|
|
8553
|
+
if (images.length === 0) throw new MotifError("OpenRouter returned no images", 502);
|
|
8554
|
+
return {
|
|
8555
|
+
images,
|
|
8556
|
+
cost: isRecord$3(body.usage) && typeof body.usage.cost === "number" ? body.usage.cost : void 0
|
|
8557
|
+
};
|
|
8558
|
+
}
|
|
8559
|
+
function buildModel(modelId, apiKey, doFetch) {
|
|
8560
|
+
const slug = openRouterModelSlug(modelId);
|
|
8561
|
+
return {
|
|
8562
|
+
specificationVersion: "v4",
|
|
8563
|
+
provider: METADATA_KEY,
|
|
8564
|
+
modelId: slug,
|
|
8565
|
+
maxImagesPerCall: 10,
|
|
8566
|
+
async doGenerate(options) {
|
|
8567
|
+
const warnings = [];
|
|
8568
|
+
if (options.seed !== void 0) warnings.push({
|
|
8569
|
+
type: "unsupported",
|
|
8570
|
+
feature: "seed"
|
|
8571
|
+
});
|
|
8572
|
+
if (options.size !== void 0) warnings.push({
|
|
8573
|
+
type: "unsupported",
|
|
8574
|
+
feature: "size",
|
|
8575
|
+
details: "Gemini on OpenRouter takes aspectRatio, not pixel sizes."
|
|
8576
|
+
});
|
|
8577
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
8578
|
+
const response = await doFetch(OPENROUTER_IMAGES_URL, {
|
|
8579
|
+
method: "POST",
|
|
8580
|
+
headers: {
|
|
8581
|
+
...options.headers,
|
|
8582
|
+
Authorization: `Bearer ${apiKey}`,
|
|
8583
|
+
"Content-Type": "application/json"
|
|
8584
|
+
},
|
|
8585
|
+
body: JSON.stringify(buildBody(slug, options)),
|
|
8586
|
+
...options.abortSignal === void 0 ? {} : { signal: options.abortSignal }
|
|
8587
|
+
});
|
|
8588
|
+
const text = await response.text();
|
|
8589
|
+
let body;
|
|
8590
|
+
try {
|
|
8591
|
+
body = JSON.parse(text);
|
|
8592
|
+
} catch {
|
|
8593
|
+
body = void 0;
|
|
8594
|
+
}
|
|
8595
|
+
if (!response.ok) throw new MotifError(errorMessage(body, `OpenRouter ${response.status}: ${text}`), response.status);
|
|
8596
|
+
const { images, cost } = parseResponse(body);
|
|
8597
|
+
return {
|
|
8598
|
+
images,
|
|
8599
|
+
warnings,
|
|
8600
|
+
providerMetadata: { [METADATA_KEY]: {
|
|
8601
|
+
images: images.map(() => ({})),
|
|
8602
|
+
...cost === void 0 ? {} : { cost }
|
|
8603
|
+
} },
|
|
8604
|
+
response: {
|
|
8605
|
+
timestamp,
|
|
8606
|
+
modelId: slug,
|
|
8607
|
+
headers: Object.fromEntries(response.headers.entries())
|
|
8608
|
+
}
|
|
8609
|
+
};
|
|
8610
|
+
}
|
|
8611
|
+
};
|
|
8612
|
+
}
|
|
8613
|
+
/**
|
|
8614
|
+
* Build an OpenRouter `ImageModel`. Prefers the passed `apiKey`, else the
|
|
8615
|
+
* `OPENROUTER_API_KEY` env var. Throws `MotifError` when neither is present or
|
|
8616
|
+
* the model name is unknown (callers translate this into a `Result.err`).
|
|
8617
|
+
*/
|
|
8618
|
+
function resolveModel$1(modelId, apiKey, fetch) {
|
|
8619
|
+
const key = apiKey ?? process.env["OPENROUTER_API_KEY"];
|
|
8620
|
+
if (key === void 0 || key === "") throw new MotifError(`OpenRouter image generation requires an API key (config.openrouter.apiKey or ${OPENROUTER_API_KEY_ENV})`, 0);
|
|
8621
|
+
const configured = toProviderFetch(fetch);
|
|
8622
|
+
return buildModel(modelId, key, "fetch" in configured ? configured.fetch : globalThis.fetch);
|
|
8623
|
+
}
|
|
8624
|
+
/** The OpenRouter provider adapter registered in the provider registry. */
|
|
8625
|
+
const openrouterAdapter = {
|
|
8626
|
+
id: "openrouter",
|
|
8627
|
+
apiKeyEnv: OPENROUTER_API_KEY_ENV,
|
|
8628
|
+
resolveModel: resolveModel$1,
|
|
8629
|
+
priceUsdByModel: OPENROUTER_IMAGE_PRICE_USD
|
|
8630
|
+
};
|
|
8631
|
+
//#endregion
|
|
8527
8632
|
//#region src/image/replicate.ts
|
|
8528
8633
|
/**
|
|
8529
8634
|
* Replicate provider adapter.
|
|
@@ -8567,7 +8672,7 @@ function resolveModel(modelId, apiKey, fetch) {
|
|
|
8567
8672
|
* goes through {@link getProviderAdapter}.
|
|
8568
8673
|
*/
|
|
8569
8674
|
const PROVIDERS = {
|
|
8570
|
-
|
|
8675
|
+
openrouter: openrouterAdapter,
|
|
8571
8676
|
openai: openaiAdapter,
|
|
8572
8677
|
replicate: {
|
|
8573
8678
|
id: "replicate",
|
|
@@ -8622,11 +8727,16 @@ function roundUsd(value) {
|
|
|
8622
8727
|
return Number(value.toFixed(6));
|
|
8623
8728
|
}
|
|
8624
8729
|
/**
|
|
8625
|
-
*
|
|
8626
|
-
*
|
|
8730
|
+
* Cost across every underlying model call of one generation. Provider-metadata
|
|
8731
|
+
* costs from the calls that report one are summed; otherwise the static table
|
|
8732
|
+
* (× image count), then unknown.
|
|
8627
8733
|
*/
|
|
8628
|
-
function
|
|
8629
|
-
|
|
8734
|
+
function costForCalls(provider, modelId, callMetadata, imageCount) {
|
|
8735
|
+
let metaCost;
|
|
8736
|
+
for (const providerMetadata of callMetadata) {
|
|
8737
|
+
const callCost = costFromProviderMetadata(providerMetadata);
|
|
8738
|
+
if (callCost !== void 0) metaCost = (metaCost ?? 0) + callCost;
|
|
8739
|
+
}
|
|
8630
8740
|
if (metaCost !== void 0) return {
|
|
8631
8741
|
usd: roundUsd(metaCost),
|
|
8632
8742
|
source: "provider-metadata"
|
|
@@ -8649,18 +8759,18 @@ function costForImages(provider, modelId, providerMetadata, imageCount) {
|
|
|
8649
8759
|
* ESM-only subpath export, built on the Vercel AI SDK image interface
|
|
8650
8760
|
* (`generateImage`, `@ai-sdk/*`). The caller names the provider and model;
|
|
8651
8761
|
* reuses the SDK's Result convention (`Result<T, MotifError>` — no
|
|
8652
|
-
* thrown exceptions).
|
|
8762
|
+
* thrown exceptions). Gemini is reached through OpenRouter.
|
|
8653
8763
|
*
|
|
8654
8764
|
* @example
|
|
8655
8765
|
* ```ts
|
|
8656
8766
|
* import { createMotifImage } from "@howells/motif-sdk/image";
|
|
8657
8767
|
*
|
|
8658
|
-
* const img = createMotifImage({ defaultProvider: "
|
|
8659
|
-
* const r = await img.generate({ model: "gemini-
|
|
8768
|
+
* const img = createMotifImage({ defaultProvider: "openrouter" });
|
|
8769
|
+
* const r = await img.generate({ model: "gemini-3.1-flash-image", prompt: "a bare concrete wall" });
|
|
8660
8770
|
* if (r.isOk()) console.log(r.value.images[0].mediaType, r.value.cost);
|
|
8661
8771
|
* ```
|
|
8662
8772
|
*/
|
|
8663
|
-
const DEFAULT_PROVIDER = "
|
|
8773
|
+
const DEFAULT_PROVIDER = "openrouter";
|
|
8664
8774
|
/**
|
|
8665
8775
|
* Create a provider-agnostic image client.
|
|
8666
8776
|
*
|
|
@@ -8676,7 +8786,7 @@ function createMotifImage(config = {}, deps = {}) {
|
|
|
8676
8786
|
}
|
|
8677
8787
|
function apiKeyFor(provider) {
|
|
8678
8788
|
switch (provider) {
|
|
8679
|
-
case "
|
|
8789
|
+
case "openrouter": return config.openrouter?.apiKey;
|
|
8680
8790
|
case "openai": return config.openai?.apiKey;
|
|
8681
8791
|
case "replicate": return config.replicate?.apiToken;
|
|
8682
8792
|
case "fal": return config.fal?.apiKey;
|
|
@@ -8707,6 +8817,15 @@ function createMotifImage(config = {}, deps = {}) {
|
|
|
8707
8817
|
}
|
|
8708
8818
|
async function edit(opts) {
|
|
8709
8819
|
const provider = resolveProvider(opts.provider);
|
|
8820
|
+
const imagesField = provider === "fal" ? falEditImagesField(opts.model) : void 0;
|
|
8821
|
+
if (imagesField === "image_url" && opts.images.length > 1) return (0, neverthrow.err)(new MotifError(`${opts.model} takes one input image; ${opts.images.length} were given`, 0));
|
|
8822
|
+
const providerOptions = imagesField === "image_urls" && opts.providerOptions?.fal?.useMultipleImages === void 0 ? {
|
|
8823
|
+
...opts.providerOptions,
|
|
8824
|
+
fal: {
|
|
8825
|
+
...opts.providerOptions?.fal,
|
|
8826
|
+
useMultipleImages: true
|
|
8827
|
+
}
|
|
8828
|
+
} : opts.providerOptions;
|
|
8710
8829
|
try {
|
|
8711
8830
|
const modelId = opts.model;
|
|
8712
8831
|
const model = resolveModelFn(provider, modelId, apiKeyFor(provider), config.fetch);
|
|
@@ -8722,7 +8841,7 @@ function createMotifImage(config = {}, deps = {}) {
|
|
|
8722
8841
|
...config.maxRetries === void 0 ? {} : { maxRetries: config.maxRetries },
|
|
8723
8842
|
...opts.signal === void 0 ? {} : { abortSignal: opts.signal },
|
|
8724
8843
|
...opts.headers === void 0 ? {} : { headers: opts.headers },
|
|
8725
|
-
...
|
|
8844
|
+
...providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(providerOptions) }
|
|
8726
8845
|
});
|
|
8727
8846
|
return (0, neverthrow.ok)(toMotifImageResult(result, provider, modelId));
|
|
8728
8847
|
} catch (error) {
|
|
@@ -8815,7 +8934,7 @@ function toMotifImageResult(result, provider, model) {
|
|
|
8815
8934
|
base64: file.base64,
|
|
8816
8935
|
mediaType: file.mediaType
|
|
8817
8936
|
}));
|
|
8818
|
-
const cost =
|
|
8937
|
+
const cost = costForCalls(provider, model, result.calls.map((call) => call.providerMetadata), images.length);
|
|
8819
8938
|
const requestId = extractRequestId(result);
|
|
8820
8939
|
const warnings = result.warnings.map(renderWarning);
|
|
8821
8940
|
return {
|
|
@@ -8841,9 +8960,11 @@ function isRecord$1(value) {
|
|
|
8841
8960
|
}
|
|
8842
8961
|
/** Look for a provider correlation id in providerMetadata, then response headers. */
|
|
8843
8962
|
function extractRequestId(result) {
|
|
8844
|
-
const
|
|
8845
|
-
|
|
8846
|
-
|
|
8963
|
+
for (const call of result.calls) {
|
|
8964
|
+
const fromMetadata = requestIdFromMetadata(call.providerMetadata);
|
|
8965
|
+
if (fromMetadata !== void 0) return fromMetadata;
|
|
8966
|
+
}
|
|
8967
|
+
for (const { response } of result.calls) {
|
|
8847
8968
|
const { headers } = response;
|
|
8848
8969
|
if (headers) {
|
|
8849
8970
|
const id = headers["x-request-id"] ?? headers["x-goog-request-id"] ?? headers["x-fal-request-id"];
|
|
@@ -9777,7 +9898,7 @@ function generationPlan(task, model, config, input) {
|
|
|
9777
9898
|
endpoint: built.endpoint,
|
|
9778
9899
|
prompt: typeof built.body.prompt === "string" ? built.body.prompt : void 0,
|
|
9779
9900
|
provider: "fal",
|
|
9780
|
-
queued: config.useQueue === true
|
|
9901
|
+
queued: config.useQueue === true || input.onProgress !== void 0
|
|
9781
9902
|
});
|
|
9782
9903
|
}
|
|
9783
9904
|
/**
|
|
@@ -9869,6 +9990,286 @@ function upscalerPlan(task, model, input) {
|
|
|
9869
9990
|
});
|
|
9870
9991
|
}
|
|
9871
9992
|
//#endregion
|
|
9993
|
+
//#region src/task-stream.ts
|
|
9994
|
+
const MAX_EVENT_CHARACTERS = 16777216;
|
|
9995
|
+
const JSON_START = /^[[{]/;
|
|
9996
|
+
function parsePayload(text) {
|
|
9997
|
+
let raw;
|
|
9998
|
+
try {
|
|
9999
|
+
raw = zod.z.json().parse(JSON.parse(text));
|
|
10000
|
+
} catch (error) {
|
|
10001
|
+
if (JSON_START.test(text.trim())) throw error;
|
|
10002
|
+
raw = text;
|
|
10003
|
+
}
|
|
10004
|
+
if (!isRecord$4(raw) || Array.isArray(raw)) return {
|
|
10005
|
+
raw,
|
|
10006
|
+
files: []
|
|
10007
|
+
};
|
|
10008
|
+
const images = Object.fromEntries(["images", "image"].map((key) => {
|
|
10009
|
+
const value = raw[key];
|
|
10010
|
+
const parsed = zod.z.array(zod.z.json()).safeParse(value);
|
|
10011
|
+
return [key, (parsed.success ? parsed.data : [value]).map((item) => {
|
|
10012
|
+
const url = asString(item);
|
|
10013
|
+
return url?.startsWith("data:image/") === true ? { url } : item;
|
|
10014
|
+
})];
|
|
10015
|
+
}));
|
|
10016
|
+
const number = asNumber(raw.progress);
|
|
10017
|
+
const progress = number !== void 0 && Number.isFinite(number) && number >= 0 && number <= 1 ? number : void 0;
|
|
10018
|
+
const message = asString(raw.message);
|
|
10019
|
+
const error = raw.error !== void 0 && raw.error !== null ? asString(raw.detail) ?? message ?? "Provider reported a streaming error." : void 0;
|
|
10020
|
+
return {
|
|
10021
|
+
raw,
|
|
10022
|
+
files: collectUrls(images, ["images", "image"]),
|
|
10023
|
+
progress,
|
|
10024
|
+
message,
|
|
10025
|
+
error
|
|
10026
|
+
};
|
|
10027
|
+
}
|
|
10028
|
+
function normalise(payload, metadata) {
|
|
10029
|
+
const { raw, files, progress, message } = payload;
|
|
10030
|
+
if (files.length > 0) return {
|
|
10031
|
+
...metadata,
|
|
10032
|
+
type: "images",
|
|
10033
|
+
files,
|
|
10034
|
+
raw
|
|
10035
|
+
};
|
|
10036
|
+
if (progress !== void 0 || message !== void 0) return {
|
|
10037
|
+
...metadata,
|
|
10038
|
+
type: "progress",
|
|
10039
|
+
...progress !== void 0 && { progress },
|
|
10040
|
+
...message !== void 0 && { message },
|
|
10041
|
+
raw
|
|
10042
|
+
};
|
|
10043
|
+
return {
|
|
10044
|
+
...metadata,
|
|
10045
|
+
type: "provider",
|
|
10046
|
+
data: raw
|
|
10047
|
+
};
|
|
10048
|
+
}
|
|
10049
|
+
function validateStream(plan, key, options) {
|
|
10050
|
+
const model = MODELS[plan.model];
|
|
10051
|
+
const supported = model !== void 0 && (plan.endpoint === model.endpoint && model.streaming?.generation === true || plan.endpoint === model.editEndpoint && model.streaming?.edit === true);
|
|
10052
|
+
if (plan.provider !== "fal" || !supported) return new MotifError("The resolved route does not support streaming.", 0, "STREAMING_UNSUPPORTED", void 0, {
|
|
10053
|
+
model: plan.model,
|
|
10054
|
+
endpoint: plan.endpoint
|
|
10055
|
+
});
|
|
10056
|
+
if (key === void 0) return new MotifError("FAL_KEY is not set.", 0, "MISSING_API_KEY", void 0, { envVar: "FAL_KEY" });
|
|
10057
|
+
if (options.timeout !== void 0 && (!Number.isFinite(options.timeout) || options.timeout <= 0)) return new MotifError("Stream timeout must be a positive finite number.", 0, "INVALID_OPTION");
|
|
10058
|
+
}
|
|
10059
|
+
function createSession(options) {
|
|
10060
|
+
const controller = new AbortController();
|
|
10061
|
+
let timedOut = false;
|
|
10062
|
+
let requestId;
|
|
10063
|
+
let reader;
|
|
10064
|
+
const abort = () => {
|
|
10065
|
+
controller.abort();
|
|
10066
|
+
};
|
|
10067
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
10068
|
+
if (options.signal?.aborted === true) abort();
|
|
10069
|
+
const timer = setTimeout(() => {
|
|
10070
|
+
timedOut = true;
|
|
10071
|
+
abort();
|
|
10072
|
+
cleanup();
|
|
10073
|
+
}, options.timeout ?? 12e4);
|
|
10074
|
+
const cleanup = async () => {
|
|
10075
|
+
clearTimeout(timer);
|
|
10076
|
+
options.signal?.removeEventListener("abort", abort);
|
|
10077
|
+
controller.abort();
|
|
10078
|
+
try {
|
|
10079
|
+
await reader?.cancel();
|
|
10080
|
+
} catch {}
|
|
10081
|
+
};
|
|
10082
|
+
const failure = (cause) => {
|
|
10083
|
+
if (controller.signal.aborted) return new MotifError(timedOut ? "Stream deadline exceeded." : "Stream aborted.", 0, timedOut ? "TIMEOUT" : "ABORTED", requestId);
|
|
10084
|
+
return cause instanceof MotifError ? cause : new MotifError(cause instanceof Error ? cause.message : String(cause), 0, "STREAM_ERROR", requestId);
|
|
10085
|
+
};
|
|
10086
|
+
const cancellable = async (operation) => {
|
|
10087
|
+
if (controller.signal.aborted) throw failure();
|
|
10088
|
+
let rejectAbort;
|
|
10089
|
+
const interrupted = new Promise((_, reject) => {
|
|
10090
|
+
rejectAbort = () => {
|
|
10091
|
+
reject(failure());
|
|
10092
|
+
};
|
|
10093
|
+
controller.signal.addEventListener("abort", rejectAbort, { once: true });
|
|
10094
|
+
});
|
|
10095
|
+
try {
|
|
10096
|
+
return await Promise.race([operation, interrupted]);
|
|
10097
|
+
} finally {
|
|
10098
|
+
if (rejectAbort !== void 0) controller.signal.removeEventListener("abort", rejectAbort);
|
|
10099
|
+
}
|
|
10100
|
+
};
|
|
10101
|
+
return {
|
|
10102
|
+
controller,
|
|
10103
|
+
failure,
|
|
10104
|
+
cleanup,
|
|
10105
|
+
cancellable,
|
|
10106
|
+
setRequestId(value) {
|
|
10107
|
+
requestId = value;
|
|
10108
|
+
},
|
|
10109
|
+
setReader(value) {
|
|
10110
|
+
reader = value;
|
|
10111
|
+
}
|
|
10112
|
+
};
|
|
10113
|
+
}
|
|
10114
|
+
/** Direct inference streaming. Never submits to the queue or retries. */
|
|
10115
|
+
async function streamTask(plan, key, fetch, ephemeral, options) {
|
|
10116
|
+
const invalid = validateStream(plan, key, options);
|
|
10117
|
+
if (invalid !== void 0) return (0, neverthrow.err)(invalid);
|
|
10118
|
+
if (key === void 0) return (0, neverthrow.err)(new MotifError("FAL_KEY is not set.", 0, "MISSING_API_KEY"));
|
|
10119
|
+
const session = createSession(options);
|
|
10120
|
+
const { controller, failure, cleanup, cancellable } = session;
|
|
10121
|
+
let requestId;
|
|
10122
|
+
let reader;
|
|
10123
|
+
try {
|
|
10124
|
+
if (controller.signal.aborted) throw failure();
|
|
10125
|
+
const response = await cancellable(fetch(`https://fal.run/${plan.endpoint}/stream`, {
|
|
10126
|
+
method: "POST",
|
|
10127
|
+
signal: controller.signal,
|
|
10128
|
+
headers: {
|
|
10129
|
+
Authorization: `Key ${key}`,
|
|
10130
|
+
"Content-Type": "application/json",
|
|
10131
|
+
Accept: "text/event-stream",
|
|
10132
|
+
...ephemeral && { "X-Fal-Store-IO": "0" }
|
|
10133
|
+
},
|
|
10134
|
+
body: JSON.stringify(plan.body)
|
|
10135
|
+
}));
|
|
10136
|
+
requestId = response.headers.get("x-fal-request-id") ?? void 0;
|
|
10137
|
+
session.setRequestId(requestId);
|
|
10138
|
+
if (!response.ok) {
|
|
10139
|
+
const body = await cancellable(response.text());
|
|
10140
|
+
const error = falHttpError(response.status, body, requestId ?? requestIdFromBody(body));
|
|
10141
|
+
await cleanup();
|
|
10142
|
+
return (0, neverthrow.err)(error);
|
|
10143
|
+
}
|
|
10144
|
+
if (response.headers.get("content-type")?.toLowerCase().includes("text/event-stream") !== true || response.body === null) {
|
|
10145
|
+
await response.body?.cancel();
|
|
10146
|
+
await cleanup();
|
|
10147
|
+
return (0, neverthrow.err)(new MotifError("Expected an SSE response body.", response.status, "INVALID_STREAM_RESPONSE", requestId));
|
|
10148
|
+
}
|
|
10149
|
+
reader = response.body.getReader();
|
|
10150
|
+
session.setReader(reader);
|
|
10151
|
+
} catch (error) {
|
|
10152
|
+
const failed = failure(error);
|
|
10153
|
+
await cleanup();
|
|
10154
|
+
return (0, neverthrow.err)(failed);
|
|
10155
|
+
}
|
|
10156
|
+
const iterator = consume(reader, session, requestId);
|
|
10157
|
+
let claimed = false;
|
|
10158
|
+
const events = { [Symbol.asyncIterator]() {
|
|
10159
|
+
if (claimed) throw new Error("Stream events can only be consumed once.");
|
|
10160
|
+
claimed = true;
|
|
10161
|
+
return {
|
|
10162
|
+
next: async () => await iterator.next(),
|
|
10163
|
+
async return() {
|
|
10164
|
+
await cleanup();
|
|
10165
|
+
return await iterator.return(void 0);
|
|
10166
|
+
}
|
|
10167
|
+
};
|
|
10168
|
+
} };
|
|
10169
|
+
return (0, neverthrow.ok)({
|
|
10170
|
+
plan,
|
|
10171
|
+
...requestId !== void 0 && { requestId },
|
|
10172
|
+
events,
|
|
10173
|
+
abort() {
|
|
10174
|
+
controller.abort();
|
|
10175
|
+
cleanup();
|
|
10176
|
+
}
|
|
10177
|
+
});
|
|
10178
|
+
}
|
|
10179
|
+
async function* consume(reader, session, requestId) {
|
|
10180
|
+
const { cancellable, failure, cleanup } = session;
|
|
10181
|
+
const decoder = new TextDecoder();
|
|
10182
|
+
const frames = createFrames(requestId);
|
|
10183
|
+
try {
|
|
10184
|
+
if (reader === void 0) throw new Error("Missing stream reader.");
|
|
10185
|
+
let finished = false;
|
|
10186
|
+
while (!finished) {
|
|
10187
|
+
const chunk = await cancellable(reader.read());
|
|
10188
|
+
finished = chunk.done;
|
|
10189
|
+
frames.append(chunk.done ? decoder.decode() : decoder.decode(chunk.value, { stream: true }), finished);
|
|
10190
|
+
let frame = frames.next();
|
|
10191
|
+
while (frame !== void 0) {
|
|
10192
|
+
if (frame === "done") return;
|
|
10193
|
+
yield frame;
|
|
10194
|
+
if (frame.isErr()) return;
|
|
10195
|
+
frame = frames.next();
|
|
10196
|
+
}
|
|
10197
|
+
}
|
|
10198
|
+
} catch (error) {
|
|
10199
|
+
yield (0, neverthrow.err)(failure(error));
|
|
10200
|
+
} finally {
|
|
10201
|
+
await cleanup();
|
|
10202
|
+
}
|
|
10203
|
+
}
|
|
10204
|
+
function createFrames(requestId) {
|
|
10205
|
+
let buffer = "";
|
|
10206
|
+
let finished = false;
|
|
10207
|
+
let data = [];
|
|
10208
|
+
let event;
|
|
10209
|
+
let id;
|
|
10210
|
+
let length = 0;
|
|
10211
|
+
const parse = () => {
|
|
10212
|
+
if (data.length === 0) {
|
|
10213
|
+
event = void 0;
|
|
10214
|
+
length = 0;
|
|
10215
|
+
return;
|
|
10216
|
+
}
|
|
10217
|
+
const text = data.join("\n");
|
|
10218
|
+
const metadata = {
|
|
10219
|
+
...event !== void 0 && { event },
|
|
10220
|
+
...id !== void 0 && { id }
|
|
10221
|
+
};
|
|
10222
|
+
data = [];
|
|
10223
|
+
event = void 0;
|
|
10224
|
+
length = 0;
|
|
10225
|
+
if (text.trim() === "[DONE]") return "done";
|
|
10226
|
+
let payload;
|
|
10227
|
+
try {
|
|
10228
|
+
payload = parsePayload(text);
|
|
10229
|
+
} catch {
|
|
10230
|
+
return (0, neverthrow.err)(new MotifError("Malformed JSON in stream event.", 0, "INVALID_STREAM_DATA", requestId));
|
|
10231
|
+
}
|
|
10232
|
+
if (metadata.event === "error" || payload.error !== void 0) return (0, neverthrow.err)(new MotifError(payload.error ?? payload.message ?? "Provider reported a streaming error.", 0, "STREAM_ERROR", requestId, { data: payload.raw }));
|
|
10233
|
+
return (0, neverthrow.ok)(normalise(payload, metadata));
|
|
10234
|
+
};
|
|
10235
|
+
function acceptField(line) {
|
|
10236
|
+
if (line.startsWith(":")) return;
|
|
10237
|
+
const colon = line.indexOf(":");
|
|
10238
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
10239
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
10240
|
+
if (field === "data") data.push(value);
|
|
10241
|
+
else if (field === "event") event = value;
|
|
10242
|
+
else if (field === "id" && !value.includes("\0")) id = value;
|
|
10243
|
+
}
|
|
10244
|
+
function next() {
|
|
10245
|
+
while (buffer.length > 0) {
|
|
10246
|
+
const lf = buffer.search(/[\r\n]/);
|
|
10247
|
+
if (lf < 0 || !finished && lf === buffer.length - 1 && buffer[lf] === "\r") break;
|
|
10248
|
+
const line = buffer.slice(0, lf);
|
|
10249
|
+
const width = buffer[lf] === "\r" && buffer[lf + 1] === "\n" ? 2 : 1;
|
|
10250
|
+
buffer = buffer.slice(lf + width);
|
|
10251
|
+
length += line.length;
|
|
10252
|
+
enforceBufferLimit(length, requestId);
|
|
10253
|
+
if (line === "") {
|
|
10254
|
+
const parsed = parse();
|
|
10255
|
+
if (parsed === "done") return "done";
|
|
10256
|
+
if (parsed !== void 0) return parsed;
|
|
10257
|
+
} else acceptField(line);
|
|
10258
|
+
}
|
|
10259
|
+
enforceBufferLimit(length + buffer.length, requestId);
|
|
10260
|
+
}
|
|
10261
|
+
return {
|
|
10262
|
+
next,
|
|
10263
|
+
append(text, done) {
|
|
10264
|
+
buffer += text;
|
|
10265
|
+
finished = done;
|
|
10266
|
+
}
|
|
10267
|
+
};
|
|
10268
|
+
}
|
|
10269
|
+
function enforceBufferLimit(length, requestId) {
|
|
10270
|
+
if (length > MAX_EVENT_CHARACTERS) throw new MotifError("Stream event exceeds the buffer limit.", 0, "STREAM_EVENT_TOO_LARGE", requestId);
|
|
10271
|
+
}
|
|
10272
|
+
//#endregion
|
|
9872
10273
|
//#region src/task-client.ts
|
|
9873
10274
|
/**
|
|
9874
10275
|
* The Task client: one function per Task. `createMotif()` returns a client
|
|
@@ -10055,6 +10456,16 @@ async function openAiOutput(plan, openAiKey, config) {
|
|
|
10055
10456
|
tier: plan.tier
|
|
10056
10457
|
});
|
|
10057
10458
|
}
|
|
10459
|
+
function taskStreamer(config, plan, context) {
|
|
10460
|
+
return async (task, input, options = {}) => {
|
|
10461
|
+
const planned = plan(task, input);
|
|
10462
|
+
if (planned.isErr()) return (0, neverthrow.err)(planned.error);
|
|
10463
|
+
return await streamTask(planned.value, context().falKey, config.fetch ?? (async (url, init) => await globalThis.fetch(url, init)), input.ephemeral === true, {
|
|
10464
|
+
timeout: config.timeout,
|
|
10465
|
+
...options
|
|
10466
|
+
});
|
|
10467
|
+
};
|
|
10468
|
+
}
|
|
10058
10469
|
/** Create the Task client. Keys fall back to FAL_KEY and OPENAI_API_KEY. */
|
|
10059
10470
|
function createMotif(config = {}) {
|
|
10060
10471
|
function context() {
|
|
@@ -10123,6 +10534,7 @@ function createMotif(config = {}) {
|
|
|
10123
10534
|
},
|
|
10124
10535
|
plan,
|
|
10125
10536
|
run,
|
|
10537
|
+
stream: taskStreamer(config, plan, context),
|
|
10126
10538
|
async upload(bytes, contentType, fileName) {
|
|
10127
10539
|
const client = falClient();
|
|
10128
10540
|
return client.isErr() ? (0, neverthrow.err)(client.error) : await client.value.uploadToFalCdn(bytes, {
|