@howells/motif-sdk 0.9.0 → 0.11.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/README.md +28 -0
- package/dist/image.d.ts +68 -2
- package/dist/image.js +140 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -172,6 +172,34 @@ Four providers are implemented, each reading its own API key from the environmen
|
|
|
172
172
|
|
|
173
173
|
`generate()` and `edit()` accept `tier` (`"fast" | "balanced" | "quality" | "hero"`) to resolve a model per provider, or an explicit `model` id. Every result carries a normalized per-call `cost: { usd, source }`.
|
|
174
174
|
|
|
175
|
+
### Best-of-N with an injectable judge
|
|
176
|
+
|
|
177
|
+
`bestOfN()` generates `n` candidates in parallel and picks a winner. It reuses the same options as `generate()` (text→image) or `edit()` (pass `images` for the edit path), plus `n` and an optional `judge`. When a `seed` is given each candidate uses `seed + index`, so the N vary. The judge is a caller-provided function — the layer takes no text-client dependency, so it pairs well with `@howells/ai`'s vision client but does not require it. Omit the judge and candidate 0 wins.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const best = await img.bestOfN({
|
|
181
|
+
prompt: "a plain room, bare concrete wall",
|
|
182
|
+
n: 4,
|
|
183
|
+
seed: 100, // candidates get seeds 100, 101, 102, 103
|
|
184
|
+
// Caller-provided judge: receives the successful candidates + context,
|
|
185
|
+
// returns the winning index. Wire in @howells/ai here if you want a vision judge.
|
|
186
|
+
judge: async (candidates, context) => {
|
|
187
|
+
// ...score candidates[i].images[0] against context.prompt...
|
|
188
|
+
return { index: 0, reason: "sharpest wall texture" };
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (best.isOk()) {
|
|
193
|
+
best.value.best; // the winning MotifImageResult
|
|
194
|
+
best.value.chosenIndex; // its index within candidates
|
|
195
|
+
best.value.reason; // the judge's rationale, if any
|
|
196
|
+
best.value.candidates; // every successful candidate (generation order)
|
|
197
|
+
best.value.totalCostUsd; // summed USD across all candidates generated
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
If some candidates fail, the judge sees only the survivors; if all `n` fail, `bestOfN()` returns `Result.err`.
|
|
202
|
+
|
|
175
203
|
## Testing
|
|
176
204
|
|
|
177
205
|
```bash
|
package/dist/image.d.ts
CHANGED
|
@@ -97,6 +97,12 @@ interface GenerateImageOptions {
|
|
|
97
97
|
seed?: number;
|
|
98
98
|
/** Abort signal to cancel the in-flight request. */
|
|
99
99
|
signal?: AbortSignal;
|
|
100
|
+
/**
|
|
101
|
+
* Extra HTTP headers forwarded to the provider request. Fal's non-retained IO
|
|
102
|
+
* (paired with FalClient.deletePayloads) is
|
|
103
|
+
* `headers: { "X-Fal-Store-IO": "0" }`.
|
|
104
|
+
*/
|
|
105
|
+
headers?: Record<string, string>;
|
|
100
106
|
/**
|
|
101
107
|
* Provider-specific options, passed straight through to the underlying model
|
|
102
108
|
* as body parameters. Outer key = provider name, inner key = option name.
|
|
@@ -138,6 +144,12 @@ interface EditImageOptions {
|
|
|
138
144
|
seed?: number;
|
|
139
145
|
/** Abort signal to cancel the in-flight request. */
|
|
140
146
|
signal?: AbortSignal;
|
|
147
|
+
/**
|
|
148
|
+
* Extra HTTP headers forwarded to the provider request. Fal's non-retained IO
|
|
149
|
+
* (paired with FalClient.deletePayloads) is
|
|
150
|
+
* `headers: { "X-Fal-Store-IO": "0" }`.
|
|
151
|
+
*/
|
|
152
|
+
headers?: Record<string, string>;
|
|
141
153
|
/** Provider-specific options (see {@link GenerateImageOptions.providerOptions}). */
|
|
142
154
|
providerOptions?: Record<string, Record<string, unknown>>;
|
|
143
155
|
}
|
|
@@ -166,12 +178,62 @@ interface MotifImageResult {
|
|
|
166
178
|
*/
|
|
167
179
|
warnings?: readonly string[];
|
|
168
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Picks the winning candidate for a {@link MotifImageClient.bestOfN} call.
|
|
183
|
+
*
|
|
184
|
+
* Receives the successful candidates (index-aligned to
|
|
185
|
+
* {@link BestOfNResult.candidates}) and the request context, and returns the
|
|
186
|
+
* chosen index (plus an optional human-readable reason). May be sync or async.
|
|
187
|
+
* The judge is caller-provided so the image layer stays decoupled from any text
|
|
188
|
+
* client — it pairs well with `@howells/ai`'s vision client, but that dependency
|
|
189
|
+
* is not required.
|
|
190
|
+
*/
|
|
191
|
+
type ImageJudge = (candidates: readonly MotifImageResult[], context: {
|
|
192
|
+
readonly prompt?: string;
|
|
193
|
+
readonly instruction?: string;
|
|
194
|
+
}) => Promise<{
|
|
195
|
+
index: number;
|
|
196
|
+
reason?: string;
|
|
197
|
+
}> | {
|
|
198
|
+
index: number;
|
|
199
|
+
reason?: string;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Options for a best-of-N generation. Extends either {@link GenerateImageOptions}
|
|
203
|
+
* (text→image) or {@link EditImageOptions} (multi-image edit) — the presence of
|
|
204
|
+
* `images` selects the edit path — with the candidate count and an optional judge.
|
|
205
|
+
*/
|
|
206
|
+
type BestOfNOptions = (GenerateImageOptions | EditImageOptions) & {
|
|
207
|
+
/** How many candidates to generate (>= 1). */
|
|
208
|
+
n: number;
|
|
209
|
+
/** Chooses the winner. Omit → candidate 0 wins. */
|
|
210
|
+
judge?: ImageJudge;
|
|
211
|
+
};
|
|
212
|
+
/** Result of a {@link MotifImageClient.bestOfN} call. */
|
|
213
|
+
interface BestOfNResult {
|
|
214
|
+
/** The winning candidate (its single-image {@link MotifImageResult}). */
|
|
215
|
+
best: MotifImageResult;
|
|
216
|
+
/** The index of `best` within `candidates`. */
|
|
217
|
+
chosenIndex: number;
|
|
218
|
+
/** Judge's rationale, if it returned one. */
|
|
219
|
+
reason?: string;
|
|
220
|
+
/** All successful candidates, in generation order. */
|
|
221
|
+
candidates: readonly MotifImageResult[];
|
|
222
|
+
/** Total USD across all candidates that were generated (successes only). */
|
|
223
|
+
totalCostUsd: number;
|
|
224
|
+
}
|
|
169
225
|
/** The provider-agnostic image client. Every method returns a Result — no throws. */
|
|
170
226
|
interface MotifImageClient {
|
|
171
227
|
/** Text→image generation. */
|
|
172
228
|
generate: (opts: GenerateImageOptions) => Promise<Result<MotifImageResult, MotifError>>;
|
|
173
229
|
/** Multi-image edit (with optional mask). */
|
|
174
230
|
edit: (opts: EditImageOptions) => Promise<Result<MotifImageResult, MotifError>>;
|
|
231
|
+
/**
|
|
232
|
+
* Generate N candidates (in parallel) and pick the best via an optional
|
|
233
|
+
* injectable judge. Discriminates generate vs edit by the presence of
|
|
234
|
+
* `images`. Returns the winner plus all successful candidates and total spend.
|
|
235
|
+
*/
|
|
236
|
+
bestOfN: (opts: BestOfNOptions) => Promise<Result<BestOfNResult, MotifError>>;
|
|
175
237
|
}
|
|
176
238
|
|
|
177
239
|
/**
|
|
@@ -318,7 +380,11 @@ declare const REPLICATE_API_KEY_ENV = "REPLICATE_API_TOKEN";
|
|
|
318
380
|
* });
|
|
319
381
|
*/
|
|
320
382
|
|
|
321
|
-
/**
|
|
383
|
+
/**
|
|
384
|
+
* Tier → fal model id. For fal, explicit `model:` endpoint ids are the primary
|
|
385
|
+
* path (any fal endpoint resolves via passthrough); this tier map is a
|
|
386
|
+
* convenience covering the two most common (FLUX Pro Ultra + gpt-image).
|
|
387
|
+
*/
|
|
322
388
|
declare const FAL_TIER_MODELS: Readonly<Record<ImageTier, string>>;
|
|
323
389
|
/** Env var read for the fal key when `apiKey` is not supplied in config. */
|
|
324
390
|
declare const FAL_API_KEY_ENV = "FAL_KEY";
|
|
@@ -373,4 +439,4 @@ declare function costForImages(provider: ImageProviderId, modelId: string, provi
|
|
|
373
439
|
*/
|
|
374
440
|
declare function createMotifImage(config?: MotifImageConfig, deps?: MotifImageDeps): MotifImageClient;
|
|
375
441
|
|
|
376
|
-
export { type EditImageOptions, FAL_API_KEY_ENV, FAL_TIER_MODELS, GOOGLE_API_KEY_ENV, GOOGLE_TIER_MODELS, type GenerateImageOptions, type ImageCost, type ImageCostSource, type ImageProviderAdapter, type ImageProviderId, type ImageTier, type MotifImageClient, type MotifImageConfig, type MotifImageFile, type MotifImageResult, OPENAI_API_KEY_ENV, OPENAI_TIER_MODELS, PROVIDERS, REPLICATE_API_KEY_ENV, REPLICATE_TIER_MODELS, costForImages, costFromProviderMetadata, createMotifImage, getProviderAdapter };
|
|
442
|
+
export { type BestOfNOptions, type BestOfNResult, type EditImageOptions, FAL_API_KEY_ENV, FAL_TIER_MODELS, GOOGLE_API_KEY_ENV, GOOGLE_TIER_MODELS, type GenerateImageOptions, type ImageCost, type ImageCostSource, type ImageJudge, type ImageProviderAdapter, type ImageProviderId, type ImageTier, type MotifImageClient, type MotifImageConfig, type MotifImageFile, type MotifImageResult, OPENAI_API_KEY_ENV, OPENAI_TIER_MODELS, PROVIDERS, REPLICATE_API_KEY_ENV, REPLICATE_TIER_MODELS, costForImages, costFromProviderMetadata, createMotifImage, getProviderAdapter };
|
package/dist/image.js
CHANGED
|
@@ -1291,8 +1291,58 @@ var FAL_TIER_MODELS = {
|
|
|
1291
1291
|
};
|
|
1292
1292
|
var FAL_API_KEY_ENV = "FAL_KEY";
|
|
1293
1293
|
var FAL_IMAGE_PRICE_USD = {
|
|
1294
|
+
// FLUX family.
|
|
1294
1295
|
[FAL_FLUX_MODEL]: MODELS.flux?.pricePerImageUsd ?? 0.06,
|
|
1295
|
-
|
|
1296
|
+
// MODELS.flux
|
|
1297
|
+
"fal-ai/flux/schnell": MODELS["flux-fast"]?.pricePerImageUsd ?? 3e-3,
|
|
1298
|
+
// MODELS["flux-fast"]
|
|
1299
|
+
// FLUX.2 family.
|
|
1300
|
+
"fal-ai/flux-2-max": MODELS["flux2-max"]?.pricePerImageUsd ?? 0.07,
|
|
1301
|
+
// MODELS["flux2-max"]
|
|
1302
|
+
"fal-ai/flux-2-pro": MODELS["flux2-pro"]?.pricePerImageUsd ?? 0.03,
|
|
1303
|
+
// MODELS["flux2-pro"]
|
|
1304
|
+
"fal-ai/flux-2-flex": MODELS["flux2-flex"]?.pricePerImageUsd ?? 0.05,
|
|
1305
|
+
// MODELS["flux2-flex"]
|
|
1306
|
+
"fal-ai/flux-2": MODELS["flux2-dev"]?.pricePerImageUsd ?? 0.012,
|
|
1307
|
+
// MODELS["flux2-dev"]
|
|
1308
|
+
"fal-ai/flux-2/turbo": MODELS["flux2-turbo"]?.pricePerImageUsd ?? 8e-3,
|
|
1309
|
+
// MODELS["flux2-turbo"]
|
|
1310
|
+
// gpt-image (fal adds overhead vs OpenAI-direct — see design doc §10).
|
|
1311
|
+
[FAL_GPT_IMAGE_MODEL]: MODELS.gpt?.pricePerImageUsd ?? 0.133,
|
|
1312
|
+
// MODELS.gpt
|
|
1313
|
+
"openai/gpt-image-2": MODELS.gpt2?.pricePerImageUsd ?? 0.211,
|
|
1314
|
+
// MODELS.gpt2
|
|
1315
|
+
// Nano Banana (Gemini image) family.
|
|
1316
|
+
"fal-ai/nano-banana-2": MODELS.banana2?.pricePerImageUsd ?? 0.08,
|
|
1317
|
+
// MODELS.banana2
|
|
1318
|
+
"fal-ai/nano-banana-pro": MODELS.banana?.pricePerImageUsd ?? 0.15,
|
|
1319
|
+
// MODELS.banana
|
|
1320
|
+
"fal-ai/gemini-25-flash-image": MODELS.gemini?.pricePerImageUsd ?? 0.0398,
|
|
1321
|
+
// MODELS.gemini
|
|
1322
|
+
"fal-ai/gemini-3-pro-image-preview": MODELS.gemini3?.pricePerImageUsd ?? 0.15,
|
|
1323
|
+
// MODELS.gemini3
|
|
1324
|
+
// Seedream family.
|
|
1325
|
+
"fal-ai/bytedance/seedream/v4/text-to-image": MODELS.seedream4?.pricePerImageUsd ?? 0.03,
|
|
1326
|
+
// MODELS.seedream4
|
|
1327
|
+
"fal-ai/bytedance/seedream/v4.5/text-to-image": MODELS.seedream45?.pricePerImageUsd ?? 0.04,
|
|
1328
|
+
// MODELS.seedream45
|
|
1329
|
+
"bytedance/seedream/v5/pro/text-to-image": MODELS.seedream5?.pricePerImageUsd ?? 0.0675,
|
|
1330
|
+
// MODELS.seedream5
|
|
1331
|
+
"fal-ai/bytedance/seedream/v5/lite/text-to-image": MODELS["seedream5-lite"]?.pricePerImageUsd ?? 0.035,
|
|
1332
|
+
// MODELS["seedream5-lite"]
|
|
1333
|
+
// Other priced fal generation endpoints.
|
|
1334
|
+
"fal-ai/recraft-v3": MODELS.recraft?.pricePerImageUsd ?? 0.04,
|
|
1335
|
+
// MODELS.recraft
|
|
1336
|
+
"fal-ai/recraft/v4/text-to-image": MODELS.recraft4?.pricePerImageUsd ?? 0.04,
|
|
1337
|
+
// MODELS.recraft4
|
|
1338
|
+
"fal-ai/ideogram/v3": MODELS.ideogram?.pricePerImageUsd ?? 0.03,
|
|
1339
|
+
// MODELS.ideogram
|
|
1340
|
+
"ideogram/v4": MODELS.ideogram4?.pricePerImageUsd ?? 0.03,
|
|
1341
|
+
// MODELS.ideogram4
|
|
1342
|
+
"xai/grok-imagine-image": MODELS["grok-image"]?.pricePerImageUsd ?? 0.02,
|
|
1343
|
+
// MODELS["grok-image"]
|
|
1344
|
+
"fal-ai/qwen-image": MODELS.qwen?.pricePerImageUsd ?? 0.02
|
|
1345
|
+
// MODELS.qwen
|
|
1296
1346
|
};
|
|
1297
1347
|
function resolveModel(modelId, apiKey) {
|
|
1298
1348
|
const key = apiKey ?? process.env[FAL_API_KEY_ENV];
|
|
@@ -1505,6 +1555,7 @@ function createMotifImage(config = {}, deps = {}) {
|
|
|
1505
1555
|
...opts.aspectRatio === void 0 ? {} : { aspectRatio: opts.aspectRatio },
|
|
1506
1556
|
...opts.seed === void 0 ? {} : { seed: opts.seed },
|
|
1507
1557
|
...opts.signal === void 0 ? {} : { abortSignal: opts.signal },
|
|
1558
|
+
...opts.headers === void 0 ? {} : { headers: opts.headers },
|
|
1508
1559
|
...opts.providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(opts.providerOptions) }
|
|
1509
1560
|
});
|
|
1510
1561
|
return ok2(toMotifImageResult(result, provider, modelId));
|
|
@@ -1527,6 +1578,7 @@ function createMotifImage(config = {}, deps = {}) {
|
|
|
1527
1578
|
...opts.n === void 0 ? {} : { n: opts.n },
|
|
1528
1579
|
...opts.seed === void 0 ? {} : { seed: opts.seed },
|
|
1529
1580
|
...opts.signal === void 0 ? {} : { abortSignal: opts.signal },
|
|
1581
|
+
...opts.headers === void 0 ? {} : { headers: opts.headers },
|
|
1530
1582
|
...opts.providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(opts.providerOptions) }
|
|
1531
1583
|
});
|
|
1532
1584
|
return ok2(toMotifImageResult(result, provider, modelId));
|
|
@@ -1534,7 +1586,91 @@ function createMotifImage(config = {}, deps = {}) {
|
|
|
1534
1586
|
return err2(toMotifError(error));
|
|
1535
1587
|
}
|
|
1536
1588
|
}
|
|
1537
|
-
|
|
1589
|
+
async function bestOfN(opts) {
|
|
1590
|
+
return await runBestOfN(opts, generate, edit);
|
|
1591
|
+
}
|
|
1592
|
+
return { generate, edit, bestOfN };
|
|
1593
|
+
}
|
|
1594
|
+
async function runCandidate(opts, index, generate, edit) {
|
|
1595
|
+
const candidateSeed = opts.seed === void 0 ? void 0 : opts.seed + index;
|
|
1596
|
+
const overrides = {
|
|
1597
|
+
n: 1,
|
|
1598
|
+
...candidateSeed === void 0 ? {} : { seed: candidateSeed }
|
|
1599
|
+
};
|
|
1600
|
+
if ("images" in opts) {
|
|
1601
|
+
return await edit({ ...opts, ...overrides });
|
|
1602
|
+
}
|
|
1603
|
+
return await generate({ ...opts, ...overrides });
|
|
1604
|
+
}
|
|
1605
|
+
async function selectWinner(opts, candidates) {
|
|
1606
|
+
const { judge } = opts;
|
|
1607
|
+
if (judge === void 0) {
|
|
1608
|
+
return ok2({ chosenIndex: 0 });
|
|
1609
|
+
}
|
|
1610
|
+
const context = "images" in opts ? { instruction: opts.instruction } : { prompt: opts.prompt };
|
|
1611
|
+
const decision = await judge(candidates, context);
|
|
1612
|
+
if (!Number.isInteger(decision.index) || decision.index < 0 || decision.index >= candidates.length) {
|
|
1613
|
+
return err2(
|
|
1614
|
+
new MotifError(
|
|
1615
|
+
`bestOfN judge returned an out-of-range index ${decision.index} (expected 0..${candidates.length - 1})`,
|
|
1616
|
+
0
|
|
1617
|
+
)
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
return ok2({
|
|
1621
|
+
chosenIndex: decision.index,
|
|
1622
|
+
...decision.reason === void 0 ? {} : { reason: decision.reason }
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
async function runBestOfN(opts, generate, edit) {
|
|
1626
|
+
try {
|
|
1627
|
+
const { n } = opts;
|
|
1628
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1629
|
+
return err2(
|
|
1630
|
+
new MotifError(`bestOfN requires an integer n >= 1 (got ${n})`, 0)
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
const results = await Promise.all(
|
|
1634
|
+
Array.from({ length: n }, (_unused, index) => index).map(
|
|
1635
|
+
async (index) => await runCandidate(opts, index, generate, edit)
|
|
1636
|
+
)
|
|
1637
|
+
);
|
|
1638
|
+
const candidates = [];
|
|
1639
|
+
let firstError;
|
|
1640
|
+
for (const result of results) {
|
|
1641
|
+
if (result.isOk()) {
|
|
1642
|
+
candidates.push(result.value);
|
|
1643
|
+
} else {
|
|
1644
|
+
firstError ??= result.error;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
if (candidates.length === 0) {
|
|
1648
|
+
return err2(
|
|
1649
|
+
firstError ?? new MotifError(`all ${n} bestOfN candidates failed`, 0)
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
const winner = await selectWinner(opts, candidates);
|
|
1653
|
+
if (winner.isErr()) {
|
|
1654
|
+
return err2(winner.error);
|
|
1655
|
+
}
|
|
1656
|
+
const { chosenIndex, reason } = winner.value;
|
|
1657
|
+
const best = candidates[chosenIndex];
|
|
1658
|
+
if (best === void 0) {
|
|
1659
|
+
return err2(new MotifError("bestOfN failed to resolve a winner", 0));
|
|
1660
|
+
}
|
|
1661
|
+
const totalCostUsd = Number(
|
|
1662
|
+
candidates.reduce((sum, candidate) => sum + candidate.cost.usd, 0).toFixed(6)
|
|
1663
|
+
);
|
|
1664
|
+
return ok2({
|
|
1665
|
+
best,
|
|
1666
|
+
chosenIndex,
|
|
1667
|
+
...reason === void 0 ? {} : { reason },
|
|
1668
|
+
candidates,
|
|
1669
|
+
totalCostUsd
|
|
1670
|
+
});
|
|
1671
|
+
} catch (error) {
|
|
1672
|
+
return err2(toMotifError(error));
|
|
1673
|
+
}
|
|
1538
1674
|
}
|
|
1539
1675
|
function defaultResolveModel(provider, modelId, apiKey) {
|
|
1540
1676
|
return getProviderAdapter(provider).resolveModel(modelId, apiKey);
|
|
@@ -1655,7 +1791,8 @@ function toMotifError(error) {
|
|
|
1655
1791
|
}
|
|
1656
1792
|
const message = error instanceof Error ? error.message : String(error);
|
|
1657
1793
|
const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
1658
|
-
|
|
1794
|
+
const status = error instanceof Error && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : 0;
|
|
1795
|
+
return new MotifError(message, status, code);
|
|
1659
1796
|
}
|
|
1660
1797
|
export {
|
|
1661
1798
|
FAL_API_KEY_ENV,
|