@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/image.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { generateImage } from "ai";
2
2
  import { err, ok } from "neverthrow";
3
3
  import { createFal } from "@ai-sdk/fal";
4
- import { createGoogleGenerativeAI } from "@ai-sdk/google";
5
4
  import { createOpenAI } from "@ai-sdk/openai";
6
5
  import { createReplicate } from "@ai-sdk/replicate";
7
6
  //#region src/errors.ts
@@ -175,12 +174,16 @@ const MODELS = {
175
174
  unit: "units",
176
175
  unitPrice: 1
177
176
  },
178
- maxReferenceImages: 4,
177
+ maxReferenceImages: 16,
179
178
  name: "GPT Image 2",
180
179
  pricePerImageUsd: .211,
181
180
  pricing: "$0.211",
182
181
  sizeMode: "image_size_enum",
183
182
  supportsAspect: true,
183
+ streaming: {
184
+ generation: true,
185
+ edit: true
186
+ },
184
187
  supportsEdit: true,
185
188
  supportsMaskImage: true,
186
189
  maskImageField: "mask_url",
@@ -242,6 +245,10 @@ const MODELS = {
242
245
  sizeMode: "gpt_size",
243
246
  supportsAspect: false,
244
247
  supportsBackground: true,
248
+ streaming: {
249
+ generation: true,
250
+ edit: true
251
+ },
245
252
  supportsEdit: true,
246
253
  supportsMaskImage: true,
247
254
  supportsNumImages: true,
@@ -876,12 +883,16 @@ const MODELS = {
876
883
  unit: "compute seconds",
877
884
  unitPrice: .00167
878
885
  },
879
- maxReferenceImages: 10,
886
+ maxReferenceImages: 4,
880
887
  name: "FLUX.2 [dev]",
881
888
  pricePerImageUsd: .012,
882
889
  pricing: "$0.00167/sec",
883
890
  sizeMode: "image_size_enum",
884
891
  supportsAspect: true,
892
+ streaming: {
893
+ generation: true,
894
+ edit: true
895
+ },
885
896
  supportsEdit: true,
886
897
  supportsGuidanceScale: true,
887
898
  supportsInferenceSteps: true,
@@ -1629,102 +1640,75 @@ Object.keys(OPTION_CAPABILITIES).filter(isModelOption);
1629
1640
  {
1630
1641
  acceptsMood: true,
1631
1642
  aspect: "1:1",
1632
- 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",
1633
- description: "Objects and material samples on a plaster ground, for product and swatch shots.",
1643
+ 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",
1644
+ description: "Objects and products on a plaster ground, for product and editorial still life.",
1634
1645
  id: "still-life",
1635
- label: "Material still life",
1646
+ label: "Editorial still life",
1636
1647
  model: "flux2-pro"
1637
1648
  },
1638
1649
  {
1639
1650
  acceptsMood: true,
1640
1651
  aspect: "3:2",
1641
- 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",
1652
+ 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",
1642
1653
  description: "Bright, collected rooms that feel lived in, for interior scenes.",
1643
- id: "lived-in",
1644
- label: "Lived-in interior",
1654
+ id: "interior",
1655
+ label: "Interior",
1645
1656
  model: "flux2-pro"
1646
1657
  },
1647
1658
  {
1648
1659
  acceptsMood: true,
1649
1660
  aspect: "4:5",
1650
- 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",
1651
- description: "Whole rooms with one product installed, for showing a material at scale.",
1661
+ 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",
1662
+ description: "Buildings and their settings from outside, for architecture, property and place.",
1652
1663
  id: "architectural",
1653
- label: "Architectural scale",
1664
+ label: "Architectural exterior",
1654
1665
  model: "banana"
1655
1666
  },
1656
- {
1657
- acceptsMood: true,
1658
- aspect: "4:3",
1659
- clause: "Amateur phone photo of a real home taken by the homeowner, slightly wonky framing, unstyled domestic photography, ordinary exposure. No text, no people",
1660
- description: "Unstyled phone snapshots of real homes, for believable before and after shots.",
1661
- id: "homeowner",
1662
- label: "Homeowner snapshot",
1663
- model: "seedream45"
1664
- },
1665
1667
  {
1666
1668
  acceptsMood: true,
1667
1669
  aspect: "1:1",
1668
- 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",
1669
- description: "Line and gouache room drawings, for showing a colour scheme as a design idea.",
1670
- experimental: true,
1671
- id: "drawing",
1672
- label: "Palette drawing",
1673
- model: "gpt2"
1670
+ 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",
1671
+ description: "Natural, unposed documentary portraits of people. Pair with a mood for the light.",
1672
+ id: "portrait",
1673
+ label: "Documentary portrait",
1674
+ model: "seedream45"
1674
1675
  },
1675
1676
  {
1676
1677
  acceptsMood: false,
1677
1678
  aspect: "1:1",
1678
- 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",
1679
- description: "Flat, edge-to-edge surface photographs, for textures and material swatches.",
1680
- id: "plate",
1681
- label: "Flat plate",
1679
+ 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",
1680
+ description: "One object in one colour on a clean ground, for icons and simple product shots.",
1681
+ id: "object",
1682
+ label: "Studio object",
1682
1683
  model: "flux2-pro"
1683
1684
  },
1684
1685
  {
1685
1686
  acceptsMood: false,
1686
1687
  aspect: "1:1",
1687
- 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",
1688
- description: "Grey-ink botanical engravings that run off the edges, for patterns and backgrounds.",
1689
- id: "engraved",
1690
- label: "Engraved grey ink",
1691
- model: "gpt2"
1692
- },
1693
- {
1694
- acceptsMood: false,
1695
- aspect: "2:3",
1696
- 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",
1697
- description: "Aged mid-century printed matter such as posters and cards, where the lettering matters.",
1698
- id: "ephemera",
1699
- label: "Period ephemera",
1700
- model: "ideogram4"
1688
+ 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",
1689
+ description: "Flat, edge-to-edge surface photographs, for textures, backgrounds and material swatches.",
1690
+ id: "surface",
1691
+ label: "Flat surface",
1692
+ model: "flux2-pro"
1701
1693
  },
1702
1694
  {
1703
1695
  acceptsMood: false,
1704
- aspect: "3:4",
1705
- 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",
1706
- description: "Loose abstract paintings on linen, for wall art and calm backgrounds.",
1707
- id: "canvas",
1708
- label: "Linen abstract",
1696
+ aspect: "3:2",
1697
+ 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",
1698
+ description: "Painted abstraction edge to edge, for wall art, heroes and calm backgrounds.",
1699
+ id: "abstract",
1700
+ label: "Painted abstract",
1709
1701
  model: "banana"
1710
1702
  },
1711
- {
1712
- acceptsMood: true,
1713
- aspect: "1:1",
1714
- clause: "Editorial documentary portrait, muted warm palette, waist-up, unposed, plain clothing with no logos. No text",
1715
- description: "Natural, unposed documentary portraits of people. Pair with a mood for the light.",
1716
- id: "portrait",
1717
- label: "Documentary portrait",
1718
- model: "seedream45"
1719
- },
1720
1703
  {
1721
1704
  acceptsMood: false,
1722
1705
  aspect: "1:1",
1723
- 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",
1724
- description: "One object in one colour on a clean ground, for icons and simple product shots.",
1725
- id: "object",
1726
- label: "Studio object",
1727
- model: "flux2-pro"
1706
+ 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",
1707
+ description: "Line and gouache illustration of any subject, for drawn editorial imagery.",
1708
+ experimental: true,
1709
+ id: "illustration",
1710
+ label: "Editorial illustration",
1711
+ model: "gpt2"
1728
1712
  }
1729
1713
  ],
1730
1714
  mood: [
@@ -3348,6 +3332,17 @@ function resolveModel$3(modelId, apiKey, fetch) {
3348
3332
  ...toProviderFetch(fetch)
3349
3333
  }).image(modelId);
3350
3334
  }
3335
+ /**
3336
+ * How a registered fal edit endpoint takes its input images, from the
3337
+ * registry's `editImagesField` (default `image_urls`). `@ai-sdk/fal` sends only
3338
+ * the first image as `image_url` unless told otherwise, which list-only
3339
+ * endpoints reject and which silently drops every reference after the first.
3340
+ * Undefined for an endpoint the registry does not list as an edit route.
3341
+ */
3342
+ function falEditImagesField(modelId) {
3343
+ const config = Object.values(MODELS).find((entry) => entry.editEndpoint === modelId);
3344
+ return config === void 0 ? void 0 : config.editImagesField ?? "image_urls";
3345
+ }
3351
3346
  /** The fal provider adapter registered in the provider registry. */
3352
3347
  const falAdapter = {
3353
3348
  id: "fal",
@@ -3356,61 +3351,6 @@ const falAdapter = {
3356
3351
  priceUsdByModel: FAL_IMAGE_PRICE_USD
3357
3352
  };
3358
3353
  //#endregion
3359
- //#region src/image/google.ts
3360
- /**
3361
- * Google (Gemini) provider adapter.
3362
- *
3363
- * Builds a Vercel AI SDK `ImageModel` from `@ai-sdk/google`. Building a model
3364
- * performs no network I/O — the request only happens when `generateImage`
3365
- * invokes `model.doGenerate`. Gemini supports both text→image generation and
3366
- * multi-image-in → image-out editing (with an optional mask), which is the core
3367
- * operation this layer normalizes.
3368
- */
3369
- /** Env var read for the Google API key when `apiKey` is not supplied in config. */
3370
- const GOOGLE_API_KEY_ENV = "GOOGLE_GENERATIVE_AI_API_KEY";
3371
- /**
3372
- * Static Google-direct USD/image, keyed by model id.
3373
- *
3374
- * Sources (Google direct, not fal-hosted):
3375
- * - `gemini-2.5-flash-image` ("nano banana"): image output billed at 1290
3376
- * output tokens/image at $30 / 1M output tokens ≈ $0.039/image.
3377
- * Source: https://ai.google.dev/gemini-api/docs/pricing
3378
- * Sanity anchor: fal-hosted `fal-ai/gemini-25-flash-image` is $0.0398
3379
- * (`MODELS.gemini.pricePerImageUsd` in ../models) — same ballpark.
3380
- * - `gemini-3-pro-image-preview` ("nano banana pro"): standard 1K/2K image
3381
- * output ≈ $0.134/image (higher tiers/4K cost more).
3382
- * Source: https://ai.google.dev/gemini-api/docs/pricing
3383
- * Sanity anchor: fal-hosted `fal-ai/gemini-3-pro-image-preview` is $0.15
3384
- * (`MODELS.gemini3`/`MODELS.banana` in ../models) — fal adds overhead.
3385
- * - `gemini-3.1-flash-image-preview`: flash-tier image output; priced with the
3386
- * 2.5 flash-image line (≈ $0.039/image) pending a distinct published rate.
3387
- */
3388
- const GOOGLE_IMAGE_PRICE_USD = {
3389
- "gemini-2.5-flash-image": .039,
3390
- "gemini-3.1-flash-image-preview": .039,
3391
- "gemini-3-pro-image-preview": .134
3392
- };
3393
- /**
3394
- * Build a Google Gemini `ImageModel`. Prefers the passed `apiKey`, else the
3395
- * `GOOGLE_GENERATIVE_AI_API_KEY` env var. Throws `MotifError` when neither is
3396
- * present (callers translate this into a `Result.err`).
3397
- */
3398
- function resolveModel$2(modelId, apiKey, fetch) {
3399
- const key = apiKey ?? process.env["GOOGLE_GENERATIVE_AI_API_KEY"];
3400
- if (key === void 0 || key === "") throw new MotifError(`Google image generation requires an API key (config.google.apiKey or ${GOOGLE_API_KEY_ENV})`, 0);
3401
- return createGoogleGenerativeAI({
3402
- apiKey: key,
3403
- ...toProviderFetch(fetch)
3404
- }).image(modelId);
3405
- }
3406
- /** The Google (Gemini) provider adapter registered in the provider registry. */
3407
- const googleAdapter = {
3408
- id: "google",
3409
- apiKeyEnv: GOOGLE_API_KEY_ENV,
3410
- resolveModel: resolveModel$2,
3411
- priceUsdByModel: GOOGLE_IMAGE_PRICE_USD
3412
- };
3413
- //#endregion
3414
3354
  //#region src/image/openai.ts
3415
3355
  /**
3416
3356
  * OpenAI (gpt-image) provider adapter.
@@ -3429,7 +3369,7 @@ const OPENAI_API_KEY_ENV = "OPENAI_API_KEY";
3429
3369
  * and size — this is a documented approximation for the common case.
3430
3370
  * Source: https://platform.openai.com/docs/pricing (image generation)
3431
3371
  * Sanity anchor: the Phase 0 benchmark measured gpt-image direct at $0.042
3432
- * (vs $0.133 via fal — see docs/design/provider-agnostic-image-layer.md §10).
3372
+ * (vs $0.133 via fal — full data and methodology on Linear MOT-23).
3433
3373
  *
3434
3374
  * GPT Image 2.5 is token-priced, with no published per-image estimate. Leave
3435
3375
  * these models absent so cost remains unknown unless supplied by the provider.
@@ -3442,7 +3382,7 @@ const OPENAI_IMAGE_PRICE_USD = { "gpt-image-1": .042 };
3442
3382
  * `OPENAI_API_KEY` env var. Throws `MotifError` when neither is present
3443
3383
  * (callers translate this into a `Result.err`).
3444
3384
  */
3445
- function resolveModel$1(modelId, apiKey, fetch) {
3385
+ function resolveModel$2(modelId, apiKey, fetch) {
3446
3386
  const key = apiKey ?? process.env["OPENAI_API_KEY"];
3447
3387
  if (key === void 0 || key === "") throw new MotifError(`OpenAI image generation requires an API key (config.openai.apiKey or ${OPENAI_API_KEY_ENV})`, 0);
3448
3388
  return createOpenAI({
@@ -3454,10 +3394,174 @@ function resolveModel$1(modelId, apiKey, fetch) {
3454
3394
  const openaiAdapter = {
3455
3395
  id: "openai",
3456
3396
  apiKeyEnv: OPENAI_API_KEY_ENV,
3457
- resolveModel: resolveModel$1,
3397
+ resolveModel: resolveModel$2,
3458
3398
  priceUsdByModel: OPENAI_IMAGE_PRICE_USD
3459
3399
  };
3460
3400
  //#endregion
3401
+ //#region src/image/openrouter.ts
3402
+ /** Env var read for the OpenRouter API key when `apiKey` is not supplied in config. */
3403
+ const OPENROUTER_API_KEY_ENV = "OPENROUTER_API_KEY";
3404
+ const OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images";
3405
+ /** Key under which this adapter's metadata sits on `providerMetadata`. */
3406
+ const METADATA_KEY = "openrouter";
3407
+ /**
3408
+ * The Gemini image models Motif exposes, by their bare Google names, mapped to
3409
+ * the OpenRouter slug that serves them. All six are listed by
3410
+ * `GET https://openrouter.ai/api/v1/images/models`.
3411
+ */
3412
+ const OPENROUTER_GEMINI_IMAGE_MODELS = {
3413
+ "gemini-2.5-flash-image": "google/gemini-2.5-flash-image",
3414
+ "gemini-3.1-flash-image-preview": "google/gemini-3.1-flash-image-preview",
3415
+ "gemini-3-pro-image-preview": "google/gemini-3-pro-image-preview",
3416
+ "gemini-3.1-flash-image": "google/gemini-3.1-flash-image",
3417
+ "gemini-3-pro-image": "google/gemini-3-pro-image",
3418
+ "gemini-3.1-flash-lite-image": "google/gemini-3.1-flash-lite-image"
3419
+ };
3420
+ /**
3421
+ * Static USD/image estimate, keyed by the bare model name. Used only when a
3422
+ * response carries no `usage.cost`; OpenRouter normally reports the real figure.
3423
+ *
3424
+ * - `gemini-2.5-flash-image`: 1290 output tokens at $30 / 1M ≈ $0.039.
3425
+ * - `gemini-3-pro-image-preview`, `gemini-3-pro-image`: ≈ $0.134 at 1K/2K.
3426
+ * - `gemini-3.1-flash-image-preview`: priced with the 2.5 flash-image line
3427
+ * pending a distinct published rate.
3428
+ * - `gemini-3.1-flash-image`: $0.067 at 1K.
3429
+ * - `gemini-3.1-flash-lite-image`: ≈ $0.0336 at 1K.
3430
+ * Source: https://ai.google.dev/gemini-api/docs/pricing
3431
+ */
3432
+ const OPENROUTER_IMAGE_PRICE_USD = {
3433
+ "gemini-2.5-flash-image": .039,
3434
+ "gemini-3.1-flash-image-preview": .039,
3435
+ "gemini-3-pro-image-preview": .134,
3436
+ "gemini-3.1-flash-image": .067,
3437
+ "gemini-3-pro-image": .134,
3438
+ "gemini-3.1-flash-lite-image": .0336
3439
+ };
3440
+ /**
3441
+ * Resolve a Motif model name to an OpenRouter slug. A bare Gemini name maps to
3442
+ * its `google/<id>` slug; a name already containing `/` is an OpenRouter slug
3443
+ * and passes through. Anything else is unknown and throws.
3444
+ */
3445
+ function openRouterModelSlug(modelId) {
3446
+ if (modelId.includes("/")) return modelId;
3447
+ const slug = OPENROUTER_GEMINI_IMAGE_MODELS[modelId];
3448
+ 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);
3449
+ return slug;
3450
+ }
3451
+ function isRecord$2(value) {
3452
+ return typeof value === "object" && value !== null;
3453
+ }
3454
+ function toDataUrl(mediaType, data) {
3455
+ return `data:${mediaType};base64,${typeof data === "string" ? data : Buffer.from(data).toString("base64")}`;
3456
+ }
3457
+ /** One `input_references` entry for an input file: a URL as-is, bytes as a data URL. */
3458
+ function toInputReference(file) {
3459
+ return {
3460
+ type: "image_url",
3461
+ image_url: { url: file.type === "url" ? file.url : toDataUrl(file.mediaType, file.data) }
3462
+ };
3463
+ }
3464
+ function buildBody(slug, options) {
3465
+ if (options.mask !== void 0) throw new MotifError("OpenRouter's Image API takes no mask; describe the region in the instruction instead.", 0);
3466
+ return {
3467
+ ...options.providerOptions[METADATA_KEY] ?? {},
3468
+ model: slug,
3469
+ prompt: options.prompt,
3470
+ n: options.n,
3471
+ ...options.aspectRatio === void 0 ? {} : { aspect_ratio: options.aspectRatio },
3472
+ ...options.files === void 0 || options.files.length === 0 ? {} : { input_references: options.files.map(toInputReference) }
3473
+ };
3474
+ }
3475
+ function errorMessage(body, fallback) {
3476
+ if (isRecord$2(body) && isRecord$2(body.error)) {
3477
+ const { message } = body.error;
3478
+ if (typeof message === "string" && message !== "") return message;
3479
+ }
3480
+ return fallback;
3481
+ }
3482
+ function parseResponse(body) {
3483
+ if (!isRecord$2(body) || !Array.isArray(body.data)) throw new MotifError("OpenRouter image response had no data array", 502);
3484
+ const images = [];
3485
+ for (const entry of body.data) if (isRecord$2(entry) && typeof entry.b64_json === "string") images.push(entry.b64_json);
3486
+ if (images.length === 0) throw new MotifError("OpenRouter returned no images", 502);
3487
+ return {
3488
+ images,
3489
+ cost: isRecord$2(body.usage) && typeof body.usage.cost === "number" ? body.usage.cost : void 0
3490
+ };
3491
+ }
3492
+ function buildModel(modelId, apiKey, doFetch) {
3493
+ const slug = openRouterModelSlug(modelId);
3494
+ return {
3495
+ specificationVersion: "v4",
3496
+ provider: METADATA_KEY,
3497
+ modelId: slug,
3498
+ maxImagesPerCall: 10,
3499
+ async doGenerate(options) {
3500
+ const warnings = [];
3501
+ if (options.seed !== void 0) warnings.push({
3502
+ type: "unsupported",
3503
+ feature: "seed"
3504
+ });
3505
+ if (options.size !== void 0) warnings.push({
3506
+ type: "unsupported",
3507
+ feature: "size",
3508
+ details: "Gemini on OpenRouter takes aspectRatio, not pixel sizes."
3509
+ });
3510
+ const timestamp = /* @__PURE__ */ new Date();
3511
+ const response = await doFetch(OPENROUTER_IMAGES_URL, {
3512
+ method: "POST",
3513
+ headers: {
3514
+ ...options.headers,
3515
+ Authorization: `Bearer ${apiKey}`,
3516
+ "Content-Type": "application/json"
3517
+ },
3518
+ body: JSON.stringify(buildBody(slug, options)),
3519
+ ...options.abortSignal === void 0 ? {} : { signal: options.abortSignal }
3520
+ });
3521
+ const text = await response.text();
3522
+ let body;
3523
+ try {
3524
+ body = JSON.parse(text);
3525
+ } catch {
3526
+ body = void 0;
3527
+ }
3528
+ if (!response.ok) throw new MotifError(errorMessage(body, `OpenRouter ${response.status}: ${text}`), response.status);
3529
+ const { images, cost } = parseResponse(body);
3530
+ return {
3531
+ images,
3532
+ warnings,
3533
+ providerMetadata: { [METADATA_KEY]: {
3534
+ images: images.map(() => ({})),
3535
+ ...cost === void 0 ? {} : { cost }
3536
+ } },
3537
+ response: {
3538
+ timestamp,
3539
+ modelId: slug,
3540
+ headers: Object.fromEntries(response.headers.entries())
3541
+ }
3542
+ };
3543
+ }
3544
+ };
3545
+ }
3546
+ /**
3547
+ * Build an OpenRouter `ImageModel`. Prefers the passed `apiKey`, else the
3548
+ * `OPENROUTER_API_KEY` env var. Throws `MotifError` when neither is present or
3549
+ * the model name is unknown (callers translate this into a `Result.err`).
3550
+ */
3551
+ function resolveModel$1(modelId, apiKey, fetch) {
3552
+ const key = apiKey ?? process.env["OPENROUTER_API_KEY"];
3553
+ if (key === void 0 || key === "") throw new MotifError(`OpenRouter image generation requires an API key (config.openrouter.apiKey or ${OPENROUTER_API_KEY_ENV})`, 0);
3554
+ const configured = toProviderFetch(fetch);
3555
+ return buildModel(modelId, key, "fetch" in configured ? configured.fetch : globalThis.fetch);
3556
+ }
3557
+ /** The OpenRouter provider adapter registered in the provider registry. */
3558
+ const openrouterAdapter = {
3559
+ id: "openrouter",
3560
+ apiKeyEnv: OPENROUTER_API_KEY_ENV,
3561
+ resolveModel: resolveModel$1,
3562
+ priceUsdByModel: OPENROUTER_IMAGE_PRICE_USD
3563
+ };
3564
+ //#endregion
3461
3565
  //#region src/image/replicate.ts
3462
3566
  /**
3463
3567
  * Replicate provider adapter.
@@ -3501,7 +3605,7 @@ function resolveModel(modelId, apiKey, fetch) {
3501
3605
  * goes through {@link getProviderAdapter}.
3502
3606
  */
3503
3607
  const PROVIDERS = {
3504
- google: googleAdapter,
3608
+ openrouter: openrouterAdapter,
3505
3609
  openai: openaiAdapter,
3506
3610
  replicate: {
3507
3611
  id: "replicate",
@@ -3564,7 +3668,19 @@ function roundUsd(value) {
3564
3668
  * then the static table (× image count), then unknown.
3565
3669
  */
3566
3670
  function costForImages(provider, modelId, providerMetadata, imageCount) {
3567
- const metaCost = costFromProviderMetadata(providerMetadata);
3671
+ return costForCalls(provider, modelId, [providerMetadata], imageCount);
3672
+ }
3673
+ /**
3674
+ * Cost across every underlying model call of one generation. Provider-metadata
3675
+ * costs from the calls that report one are summed; otherwise the static table
3676
+ * (× image count), then unknown.
3677
+ */
3678
+ function costForCalls(provider, modelId, callMetadata, imageCount) {
3679
+ let metaCost;
3680
+ for (const providerMetadata of callMetadata) {
3681
+ const callCost = costFromProviderMetadata(providerMetadata);
3682
+ if (callCost !== void 0) metaCost = (metaCost ?? 0) + callCost;
3683
+ }
3568
3684
  if (metaCost !== void 0) return {
3569
3685
  usd: roundUsd(metaCost),
3570
3686
  source: "provider-metadata"
@@ -3587,18 +3703,18 @@ function costForImages(provider, modelId, providerMetadata, imageCount) {
3587
3703
  * ESM-only subpath export, built on the Vercel AI SDK image interface
3588
3704
  * (`generateImage`, `@ai-sdk/*`). The caller names the provider and model;
3589
3705
  * reuses the SDK's Result convention (`Result<T, MotifError>` — no
3590
- * thrown exceptions). Google (Gemini) is the only provider in Phase 1a.
3706
+ * thrown exceptions). Gemini is reached through OpenRouter.
3591
3707
  *
3592
3708
  * @example
3593
3709
  * ```ts
3594
3710
  * import { createMotifImage } from "@howells/motif-sdk/image";
3595
3711
  *
3596
- * const img = createMotifImage({ defaultProvider: "google" });
3597
- * const r = await img.generate({ model: "gemini-2.5-flash-image", prompt: "a bare concrete wall" });
3712
+ * const img = createMotifImage({ defaultProvider: "openrouter" });
3713
+ * const r = await img.generate({ model: "gemini-3.1-flash-image", prompt: "a bare concrete wall" });
3598
3714
  * if (r.isOk()) console.log(r.value.images[0].mediaType, r.value.cost);
3599
3715
  * ```
3600
3716
  */
3601
- const DEFAULT_PROVIDER = "google";
3717
+ const DEFAULT_PROVIDER = "openrouter";
3602
3718
  /**
3603
3719
  * Create a provider-agnostic image client.
3604
3720
  *
@@ -3614,7 +3730,7 @@ function createMotifImage(config = {}, deps = {}) {
3614
3730
  }
3615
3731
  function apiKeyFor(provider) {
3616
3732
  switch (provider) {
3617
- case "google": return config.google?.apiKey;
3733
+ case "openrouter": return config.openrouter?.apiKey;
3618
3734
  case "openai": return config.openai?.apiKey;
3619
3735
  case "replicate": return config.replicate?.apiToken;
3620
3736
  case "fal": return config.fal?.apiKey;
@@ -3645,6 +3761,15 @@ function createMotifImage(config = {}, deps = {}) {
3645
3761
  }
3646
3762
  async function edit(opts) {
3647
3763
  const provider = resolveProvider(opts.provider);
3764
+ const imagesField = provider === "fal" ? falEditImagesField(opts.model) : void 0;
3765
+ if (imagesField === "image_url" && opts.images.length > 1) return err(new MotifError(`${opts.model} takes one input image; ${opts.images.length} were given`, 0));
3766
+ const providerOptions = imagesField === "image_urls" && opts.providerOptions?.fal?.useMultipleImages === void 0 ? {
3767
+ ...opts.providerOptions,
3768
+ fal: {
3769
+ ...opts.providerOptions?.fal,
3770
+ useMultipleImages: true
3771
+ }
3772
+ } : opts.providerOptions;
3648
3773
  try {
3649
3774
  const modelId = opts.model;
3650
3775
  const model = resolveModelFn(provider, modelId, apiKeyFor(provider), config.fetch);
@@ -3660,7 +3785,7 @@ function createMotifImage(config = {}, deps = {}) {
3660
3785
  ...config.maxRetries === void 0 ? {} : { maxRetries: config.maxRetries },
3661
3786
  ...opts.signal === void 0 ? {} : { abortSignal: opts.signal },
3662
3787
  ...opts.headers === void 0 ? {} : { headers: opts.headers },
3663
- ...opts.providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(opts.providerOptions) }
3788
+ ...providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(providerOptions) }
3664
3789
  });
3665
3790
  return ok(toMotifImageResult(result, provider, modelId));
3666
3791
  } catch (error) {
@@ -3753,7 +3878,7 @@ function toMotifImageResult(result, provider, model) {
3753
3878
  base64: file.base64,
3754
3879
  mediaType: file.mediaType
3755
3880
  }));
3756
- const cost = costForImages(provider, model, result.providerMetadata, images.length);
3881
+ const cost = costForCalls(provider, model, result.calls.map((call) => call.providerMetadata), images.length);
3757
3882
  const requestId = extractRequestId(result);
3758
3883
  const warnings = result.warnings.map(renderWarning);
3759
3884
  return {
@@ -3779,9 +3904,11 @@ function isRecord(value) {
3779
3904
  }
3780
3905
  /** Look for a provider correlation id in providerMetadata, then response headers. */
3781
3906
  function extractRequestId(result) {
3782
- const fromMetadata = requestIdFromMetadata(result.providerMetadata);
3783
- if (fromMetadata !== void 0) return fromMetadata;
3784
- for (const response of result.responses) {
3907
+ for (const call of result.calls) {
3908
+ const fromMetadata = requestIdFromMetadata(call.providerMetadata);
3909
+ if (fromMetadata !== void 0) return fromMetadata;
3910
+ }
3911
+ for (const { response } of result.calls) {
3785
3912
  const { headers } = response;
3786
3913
  if (headers) {
3787
3914
  const id = headers["x-request-id"] ?? headers["x-goog-request-id"] ?? headers["x-fal-request-id"];
@@ -3842,4 +3969,4 @@ function toMotifError(error) {
3842
3969
  return new MotifError(message, status, code);
3843
3970
  }
3844
3971
  //#endregion
3845
- export { FAL_API_KEY_ENV, GOOGLE_API_KEY_ENV, OPENAI_API_KEY_ENV, PROVIDERS, REPLICATE_API_KEY_ENV, costForImages, costFromProviderMetadata, createMotifImage, getProviderAdapter, providerPricePerImageUsd };
3972
+ export { FAL_API_KEY_ENV, OPENAI_API_KEY_ENV, OPENROUTER_API_KEY_ENV, PROVIDERS, REPLICATE_API_KEY_ENV, costForImages, costFromProviderMetadata, createMotifImage, getProviderAdapter, providerPricePerImageUsd };