@howells/motif-sdk 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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: twelve house looks and six light moods.
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 material samples on a plaster ground, for product and swatch shots.",
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: "Material still life",
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, slightly imperfect and lived-in rather than showroom-perfect, photographic realism. No text, no logos, no people",
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: "lived-in",
301
- label: "Lived-in interior",
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 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",
308
- description: "Whole rooms with one product installed, for showing a material at scale.",
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 scale",
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: "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",
326
- description: "Line and gouache room drawings, for showing a colour scheme as a design idea.",
327
- experimental: true,
328
- id: "drawing",
329
- label: "Palette drawing",
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: "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",
336
- description: "Flat, edge-to-edge surface photographs, for textures and material swatches.",
337
- id: "plate",
338
- label: "Flat plate",
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: "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",
345
- description: "Grey-ink botanical engravings that run off the edges, for patterns and backgrounds.",
346
- id: "engraved",
347
- label: "Engraved grey ink",
348
- model: "gpt2"
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:4",
362
- 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",
363
- description: "Loose abstract paintings on linen, for wall art and calm backgrounds.",
364
- id: "canvas",
365
- label: "Linen abstract",
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: "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",
381
- description: "One object in one colour on a clean ground, for icons and simple product shots.",
382
- id: "object",
383
- label: "Studio object",
384
- model: "flux2-pro"
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: 4,
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: 10,
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$3(value) {
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$3(value)) return {
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$3(entry)) entries.push({
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$3(data)) return { requestId: "" };
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$3(parsed)) return;
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$3(data) ? data : {};
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$3(data) ? data : {});
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$3(data) ? data : {};
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$3(data) && isRecord$3(data.video) ? data.video : void 0;
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$3(initiateData) ? asString(initiateData.file_url) ?? "" : "";
4565
- const uploadUrl = isRecord$3(initiateData) ? asString(initiateData.upload_url) : void 0;
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$3(data)) return err$1(new MotifError("Unexpected fal response shape", 0, "FAL_ERROR"));
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 — see docs/design/provider-agnostic-image-layer.md §10).
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$1(modelId, apiKey, fetch) {
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$1,
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
- google: googleAdapter,
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
- * Normalized per-call cost for a generation. Prefers a provider-metadata cost,
8625
- * then the static table (× image count), then unknown.
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 costForImages(provider, modelId, providerMetadata, imageCount) {
8628
- const metaCost = costFromProviderMetadata(providerMetadata);
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). Google (Gemini) is the only provider in Phase 1a.
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: "google" });
8658
- * const r = await img.generate({ model: "gemini-2.5-flash-image", prompt: "a bare concrete wall" });
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 = "google";
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 "google": return config.google?.apiKey;
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
- ...opts.providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(opts.providerOptions) }
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 = costForImages(provider, model, result.providerMetadata, images.length);
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 fromMetadata = requestIdFromMetadata(result.providerMetadata);
8844
- if (fromMetadata !== void 0) return fromMetadata;
8845
- for (const response of result.responses) {
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, {