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