@howells/motif-sdk 4.0.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/dist/image.d.ts +8 -8
- package/dist/image.js +262 -135
- package/dist/index.cjs +564 -152
- package/dist/index.d.cts +89 -64
- package/dist/index.d.ts +89 -64
- package/dist/index.js +564 -152
- package/package.json +11 -12
package/dist/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:
|
|
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:
|
|
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
|
|
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: "
|
|
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,
|
|
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: "
|
|
1644
|
-
label: "
|
|
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
|
|
1651
|
-
description: "
|
|
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
|
|
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: "
|
|
1669
|
-
description: "
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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: "
|
|
1679
|
-
description: "
|
|
1680
|
-
id: "
|
|
1681
|
-
label: "
|
|
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: "
|
|
1688
|
-
description: "
|
|
1689
|
-
id: "
|
|
1690
|
-
label: "
|
|
1691
|
-
model: "
|
|
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:
|
|
1705
|
-
clause: "
|
|
1706
|
-
description: "
|
|
1707
|
-
id: "
|
|
1708
|
-
label: "
|
|
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: "
|
|
1724
|
-
description: "
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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 —
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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: "
|
|
3597
|
-
* const r = await img.generate({ model: "gemini-
|
|
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 = "
|
|
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 "
|
|
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
|
-
...
|
|
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 =
|
|
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
|
|
3783
|
-
|
|
3784
|
-
|
|
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,
|
|
3972
|
+
export { FAL_API_KEY_ENV, OPENAI_API_KEY_ENV, OPENROUTER_API_KEY_ENV, PROVIDERS, REPLICATE_API_KEY_ENV, costForImages, costFromProviderMetadata, createMotifImage, getProviderAdapter, providerPricePerImageUsd };
|