@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/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: twelve house looks and six light moods.
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 material samples on a plaster ground, for product and swatch shots.",
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: "Material still life",
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, slightly imperfect and lived-in rather than showroom-perfect, photographic realism. No text, no logos, no people",
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: "lived-in",
302
- label: "Lived-in interior",
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 editorial photograph at full room scale, honest materials meeting precise detailing, one hero element genuinely installed, plausible light and shadow, generous negative space, empty of people. No text, no logos",
309
- description: "Whole rooms with one product installed, for showing a material at scale.",
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 scale",
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: "Stylised architectural illustration of the room, colour laid as flat planes on walls, joinery and trim, fine hand-drawn line with a gentle gouache wash, clearly a drawing of a design decision rather than a photograph. No text, no people",
327
- description: "Line and gouache room drawings, for showing a colour scheme as a design idea.",
328
- experimental: true,
329
- id: "drawing",
330
- label: "Palette drawing",
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: "Straight-on orthographic photograph of the surface filling the entire frame edge to edge, even shadowless studio light, crisp macro texture, colour-accurate. No text, no logos",
337
- description: "Flat, edge-to-edge surface photographs, for textures and material swatches.",
338
- id: "plate",
339
- label: "Flat plate",
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: "Fine hand-engraved botanical plate with delicate hatching and dry brush, grey ink only, reaching near-black at its densest, on matte uncoated stock under flat even light, cropped mid-motif and running past all four edges, never simplified or cartoonish. No text",
346
- description: "Grey-ink botanical engravings that run off the edges, for patterns and backgrounds.",
347
- id: "engraved",
348
- label: "Engraved grey ink",
349
- model: "gpt2"
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:4",
363
- clause: "Physical mineral pigment and chalk gesso on coarse natural linen, two or three confident gestures, warm ivory, oatmeal, putty and soft charcoal, flat diffuse museum reproduction lighting, shown unframed. No text",
364
- description: "Loose abstract paintings on linen, for wall art and calm backgrounds.",
365
- id: "canvas",
366
- label: "Linen abstract",
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: "A single matte object centred with generous empty space, soft diffused studio light, minimal and quiet, one committed colour. No text, no logos, no people",
382
- description: "One object in one colour on a clean ground, for icons and simple product shots.",
383
- id: "object",
384
- label: "Studio object",
385
- model: "flux2-pro"
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: 4,
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: 10,
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$3(value) {
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$3(value)) return {
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$3(entry)) entries.push({
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$3(data)) return { requestId: "" };
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$3(parsed)) return;
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$3(data) ? data : {};
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$3(data) ? data : {});
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$3(data) ? data : {};
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$3(data) && isRecord$3(data.video) ? data.video : void 0;
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$3(initiateData) ? asString(initiateData.file_url) ?? "" : "";
4566
- const uploadUrl = isRecord$3(initiateData) ? asString(initiateData.upload_url) : void 0;
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$3(data)) return (0, neverthrow.err)(new MotifError("Unexpected fal response shape", 0, "FAL_ERROR"));
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 — see docs/design/provider-agnostic-image-layer.md §10).
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$1(modelId, apiKey, fetch) {
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$1,
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
- google: googleAdapter,
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
- * Normalized per-call cost for a generation. Prefers a provider-metadata cost,
8626
- * then the static table (× image count), then unknown.
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 costForImages(provider, modelId, providerMetadata, imageCount) {
8629
- const metaCost = costFromProviderMetadata(providerMetadata);
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). Google (Gemini) is the only provider in Phase 1a.
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: "google" });
8659
- * const r = await img.generate({ model: "gemini-2.5-flash-image", prompt: "a bare concrete wall" });
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 = "google";
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 "google": return config.google?.apiKey;
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
- ...opts.providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(opts.providerOptions) }
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 = costForImages(provider, model, result.providerMetadata, images.length);
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 fromMetadata = requestIdFromMetadata(result.providerMetadata);
8845
- if (fromMetadata !== void 0) return fromMetadata;
8846
- for (const response of result.responses) {
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, {