@howells/motif-sdk 5.3.0 → 6.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
@@ -1,10 +1,6 @@
1
1
  import { err, err as err$1, ok, ok as ok$1 } from "neverthrow";
2
2
  import { defineEnv } from "@howells/envy";
3
3
  import { z } from "zod";
4
- import { generateImage } from "ai";
5
- import { createFal } from "@ai-sdk/fal";
6
- import { createOpenAI } from "@ai-sdk/openai";
7
- import { createReplicate } from "@ai-sdk/replicate";
8
4
  //#region src/aspects.ts
9
5
  /** Ordered by popularity: square first, then common ratios */
10
6
  const ASPECT_RATIOS = [
@@ -354,7 +350,7 @@ const CREATIVE_TAXONOMY = {
354
350
  experimental: true,
355
351
  id: "illustration",
356
352
  label: "Editorial illustration",
357
- model: "gpt2"
353
+ model: "flare"
358
354
  }
359
355
  ],
360
356
  mood: [
@@ -537,7 +533,7 @@ function falHttpError(status, body, requestId) {
537
533
  * `CreativeOptionError`'s `"INVALID_OPTION"`) onto the returned error so callers
538
534
  * keep structured error metadata. Status `0` marks a non-HTTP local error.
539
535
  */
540
- function toMotifError$1(error) {
536
+ function toMotifError(error) {
541
537
  if (error instanceof MotifError) return error;
542
538
  return new MotifError(error instanceof Error ? error.message : String(error), 0, error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : void 0);
543
539
  }
@@ -555,6 +551,7 @@ const CARRIED_FIELDS = [
555
551
  "video",
556
552
  "prompt",
557
553
  "mask",
554
+ "markup",
558
555
  "references",
559
556
  "boxes",
560
557
  "margin",
@@ -690,20 +687,13 @@ function promptWarnings(prompt, context = {}) {
690
687
  }
691
688
  //#endregion
692
689
  //#region src/env.ts
693
- const motifEnvSchema = defineEnv({ optional: {
694
- FAL_KEY: z.string().min(1),
695
- OPENAI_API_KEY: z.string().min(1)
696
- } });
690
+ const motifEnvSchema = defineEnv({ optional: { FAL_KEY: z.string().min(1) } });
697
691
  function parseMotifEnv(input = process.env) {
698
692
  return motifEnvSchema.parse(input);
699
693
  }
700
694
  function getFalKeyFromEnv(input = process.env) {
701
695
  return parseMotifEnv(input).FAL_KEY;
702
696
  }
703
- /** The OpenAI key, used by direct OpenAI routes such as gpt2 transparency. */
704
- function getOpenAiKeyFromEnv(input = process.env) {
705
- return parseMotifEnv(input).OPENAI_API_KEY;
706
- }
707
697
  //#endregion
708
698
  //#region src/models.ts
709
699
  const AA_IMAGE_LEADERBOARD_SNAPSHOT = "2026-10-07";
@@ -728,7 +718,7 @@ const MODELS = {
728
718
  rank: 2
729
719
  }
730
720
  },
731
- useCase: "Frontier generation and editing, evaluated at max quality"
721
+ useCase: "Frontier generation and editing at max reasoning effort; scored as GPT Image 2.5 until 2.6 is benchmarked"
732
722
  },
733
723
  defaultQuality: "max",
734
724
  customImageSize: {
@@ -738,11 +728,11 @@ const MODELS = {
738
728
  minPixels: 655360,
739
729
  multipleOf: 16
740
730
  },
741
- editEndpoint: "openai/gpt-image-2.5/flare/edit",
742
- endpoint: "openai/gpt-image-2.5/flare/text-to-image",
731
+ editEndpoint: "openai/gpt-image-2.6/flare/edit",
732
+ endpoint: "openai/gpt-image-2.6/flare/text-to-image",
743
733
  maxReferenceImages: 16,
744
- name: "GPT Image 2.5 Flare",
745
- pricing: "Token-based: text $5/M input, $1.25/M cached, $10/M output; image $8/M input, $2/M cached, $30/M output",
734
+ name: "GPT Image 2.6 Flare",
735
+ pricing: "Token-based: image $8/M input, $2/M cached, $30/M output; text $5/M input, $1.25/M cached",
746
736
  pricePerImageUsd: null,
747
737
  sizeMode: "image_size_enum",
748
738
  supportsAspect: true,
@@ -750,8 +740,23 @@ const MODELS = {
750
740
  supportsEdit: true,
751
741
  supportsMaskImage: true,
752
742
  maskImageField: "mask_url",
743
+ supportsMarkupImage: true,
744
+ autoEditSize: true,
753
745
  supportsNumImages: true,
754
746
  supportsOutputFormat: true,
747
+ supportedOutputFormats: [
748
+ "jpeg",
749
+ "png",
750
+ "webp",
751
+ "gif",
752
+ "svg"
753
+ ],
754
+ supportedEditOutputFormats: [
755
+ "jpeg",
756
+ "png",
757
+ "webp",
758
+ "gif"
759
+ ],
755
760
  supportsQuality: true,
756
761
  supportedQualities: [
757
762
  "auto",
@@ -780,7 +785,7 @@ const MODELS = {
780
785
  rank: 1
781
786
  }
782
787
  },
783
- useCase: "Frontier generation and editing, evaluated at max quality"
788
+ useCase: "Frontier generation and editing at max reasoning effort; scored as GPT Image 2.5 until 2.6 is benchmarked"
784
789
  },
785
790
  defaultQuality: "max",
786
791
  customImageSize: {
@@ -790,11 +795,11 @@ const MODELS = {
790
795
  minPixels: 655360,
791
796
  multipleOf: 16
792
797
  },
793
- editEndpoint: "openai/gpt-image-2.5/sunburst/edit",
794
- endpoint: "openai/gpt-image-2.5/sunburst/text-to-image",
798
+ editEndpoint: "openai/gpt-image-2.6/sunburst/edit",
799
+ endpoint: "openai/gpt-image-2.6/sunburst/text-to-image",
795
800
  maxReferenceImages: 16,
796
- name: "GPT Image 2.5 Sunburst",
797
- pricing: "Token-based: text $5/M input, $1.25/M cached, $10/M output; image $8/M input, $2/M cached, $30/M output",
801
+ name: "GPT Image 2.6 Sunburst",
802
+ pricing: "Token-based: image $8/M input, $2/M cached, $30/M output; text $5/M input, $1.25/M cached",
798
803
  pricePerImageUsd: null,
799
804
  sizeMode: "image_size_enum",
800
805
  supportsAspect: true,
@@ -802,8 +807,23 @@ const MODELS = {
802
807
  supportsEdit: true,
803
808
  supportsMaskImage: true,
804
809
  maskImageField: "mask_url",
810
+ supportsMarkupImage: true,
811
+ autoEditSize: true,
805
812
  supportsNumImages: true,
806
813
  supportsOutputFormat: true,
814
+ supportedOutputFormats: [
815
+ "jpeg",
816
+ "png",
817
+ "webp",
818
+ "gif",
819
+ "svg"
820
+ ],
821
+ supportedEditOutputFormats: [
822
+ "jpeg",
823
+ "png",
824
+ "webp",
825
+ "gif"
826
+ ],
807
827
  supportsQuality: true,
808
828
  supportedQualities: [
809
829
  "auto",
@@ -818,77 +838,6 @@ const MODELS = {
818
838
  type: "generation",
819
839
  useQueue: true
820
840
  },
821
- gpt2: {
822
- benchmark: {
823
- artificialAnalysis: {
824
- editing: {
825
- elo: 1125,
826
- rank: 5
827
- },
828
- snapshotDate: AA_IMAGE_LEADERBOARD_SNAPSHOT,
829
- sourceUrls: AA_IMAGE_SOURCES,
830
- textToImage: {
831
- elo: 1172,
832
- rank: 3
833
- }
834
- },
835
- speed: {
836
- medianSeconds: 200.7,
837
- p95Seconds: 232.6,
838
- source: "artificial-analysis-models"
839
- },
840
- tiers: {
841
- price: "ultra",
842
- quality: "frontier",
843
- speed: "very_slow"
844
- },
845
- useCase: "Established frontier generation and transparent PNGs"
846
- },
847
- customImageSize: {
848
- maxEdge: 3840,
849
- maxPixels: 8294400,
850
- maxRatio: 3,
851
- minPixels: 655360,
852
- multipleOf: 16
853
- },
854
- editEndpoint: "openai/gpt-image-2/edit",
855
- endpoint: "openai/gpt-image-2",
856
- falPricing: {
857
- checkedAt: FAL_PRICING_CHECKED_AT,
858
- currency: "USD",
859
- endpointId: "openai/gpt-image-2",
860
- estimatedCostPerImageUsd: .211,
861
- source: "fal-pricing-api",
862
- unit: "units",
863
- unitPrice: 1
864
- },
865
- maxReferenceImages: 16,
866
- name: "GPT Image 2",
867
- pricePerImageUsd: .211,
868
- pricing: "$0.211",
869
- sizeMode: "image_size_enum",
870
- supportsAspect: true,
871
- streaming: {
872
- generation: true,
873
- edit: true
874
- },
875
- supportsEdit: true,
876
- supportsMaskImage: true,
877
- maskImageField: "mask_url",
878
- supportsNumImages: true,
879
- supportsOutputFormat: true,
880
- supportsQuality: true,
881
- supportsResolution: false,
882
- supportsSyncMode: true,
883
- transparencyRoute: {
884
- apiKeyEnv: "OPENAI_API_KEY",
885
- model: "gpt-image-2",
886
- provider: "openai",
887
- supportsEdit: true
888
- },
889
- type: "generation",
890
- useQueue: true
891
- },
892
841
  gpt: {
893
842
  benchmark: {
894
843
  artificialAnalysis: {
@@ -2470,7 +2419,6 @@ const MODELS = {
2470
2419
  const GENERATION_MODELS = [
2471
2420
  "sunburst",
2472
2421
  "flare",
2473
- "gpt2",
2474
2422
  "gpt",
2475
2423
  "banana21",
2476
2424
  "banana2",
@@ -2535,7 +2483,6 @@ function estimateCost(model, resolution, numImages = 1, editImages = 0) {
2535
2483
  return configuredPrice * numImages;
2536
2484
  }
2537
2485
  switch (model) {
2538
- case "gpt2": return .211 * numImages;
2539
2486
  case "gpt": return .133 * numImages;
2540
2487
  case "banana":
2541
2488
  case "gemini3": return (resolution === "4K" ? .3 : .15) * numImages;
@@ -2564,7 +2511,7 @@ function estimateVideoCost(durationSeconds = 5, generateAudio = true, model = "k
2564
2511
  * `Response.json()` yields `any`; these helpers narrow that untyped payload into
2565
2512
  * the SDK's response types with runtime guards instead of unchecked assertions.
2566
2513
  */
2567
- function isRecord$4(value) {
2514
+ function isRecord$1(value) {
2568
2515
  return typeof value === "object" && value !== null;
2569
2516
  }
2570
2517
  function asString(value) {
@@ -2574,7 +2521,7 @@ function asNumber(value) {
2574
2521
  return typeof value === "number" ? value : void 0;
2575
2522
  }
2576
2523
  function toMotifImage(value) {
2577
- if (isRecord$4(value)) return {
2524
+ if (isRecord$1(value)) return {
2578
2525
  content_type: asString(value.content_type),
2579
2526
  height: asNumber(value.height),
2580
2527
  url: asString(value.url) ?? "",
@@ -2589,7 +2536,7 @@ function parseImages(value) {
2589
2536
  function parseLogs(value) {
2590
2537
  if (!Array.isArray(value)) return;
2591
2538
  const entries = [];
2592
- for (const entry of value) if (isRecord$4(entry)) entries.push({
2539
+ for (const entry of value) if (isRecord$1(entry)) entries.push({
2593
2540
  message: asString(entry.message) ?? "",
2594
2541
  timestamp: asString(entry.timestamp) ?? ""
2595
2542
  });
@@ -2619,7 +2566,7 @@ function queueAppPath(endpoint) {
2619
2566
  return endpoint.split("/").slice(0, 2).join("/");
2620
2567
  }
2621
2568
  function parseQueueSubmission(data) {
2622
- if (!isRecord$4(data)) return { requestId: "" };
2569
+ if (!isRecord$1(data)) return { requestId: "" };
2623
2570
  return {
2624
2571
  requestId: asString(data.request_id) ?? "",
2625
2572
  responseUrl: asString(data.response_url)
@@ -2659,7 +2606,7 @@ function requestIdFromBody(text) {
2659
2606
  } catch {
2660
2607
  return;
2661
2608
  }
2662
- if (!isRecord$4(parsed)) return;
2609
+ if (!isRecord$1(parsed)) return;
2663
2610
  return asString(parsed.request_id) ?? asString(parsed.requestId) ?? asString(parsed.trace_id);
2664
2611
  }
2665
2612
  //#endregion
@@ -2672,6 +2619,18 @@ function requestIdFromBody(text) {
2672
2619
  * error can name both what a model does support and which models support the
2673
2620
  * option that was refused.
2674
2621
  */
2622
+ const DEFAULT_OUTPUT_FORMATS = [
2623
+ "jpeg",
2624
+ "png",
2625
+ "webp"
2626
+ ];
2627
+ /** The output formats a Model returns, for an edit when `editing` is set.
2628
+ * A Model that takes no format returns none it can be asked for. */
2629
+ function outputFormatsOf(config, editing) {
2630
+ if (config.supportsOutputFormat !== true) return [];
2631
+ const formats = config.supportedOutputFormats ?? DEFAULT_OUTPUT_FORMATS;
2632
+ return editing ? config.supportedEditOutputFormats ?? formats : formats;
2633
+ }
2675
2634
  /** Generation options a model can refuse, with the registry test for each. */
2676
2635
  const OPTION_CAPABILITIES = {
2677
2636
  aspect: (config) => (config.sizeMode ?? "aspect_ratio") !== "none",
@@ -2687,6 +2646,7 @@ const OPTION_CAPABILITIES = {
2687
2646
  imageSize: (config) => config.sizeMode === "gpt_size" || config.sizeMode === "image_size_enum",
2688
2647
  inputFidelity: (config) => config.sizeMode === "gpt_size",
2689
2648
  limitGenerations: (config) => config.supportsLimitGenerations === true,
2649
+ markupImageUrl: (config) => config.supportsMarkupImage === true,
2690
2650
  maskImageUrl: (config) => config.supportsMaskImage === true,
2691
2651
  negativePrompt: (config) => config.supportsNegativePrompt === true,
2692
2652
  numImages: (config) => config.supportsNumImages,
@@ -2736,8 +2696,7 @@ var UnsupportedOptionError = class extends Error {
2736
2696
  constructor(config, option) {
2737
2697
  const models = modelsSupporting(option);
2738
2698
  const supported = supportedOptions(config);
2739
- const route = config.transparencyRoute !== void 0 && (option === "transparent output" || option === "background") ? ` ${config.name} does produce transparent PNGs through the ${config.transparencyRoute.provider} route (${config.transparencyRoute.apiKeyEnv}).` : "";
2740
- super(`${config.name} does not support ${option}. Models that do: ${models.length > 0 ? models.join(", ") : "none"}. ${config.name} supports: ${supported.length > 0 ? supported.join(", ") : "none of the optional settings"}.${route}`);
2699
+ super(`${config.name} does not support ${option}. Models that do: ${models.length > 0 ? models.join(", ") : "none"}. ${config.name} supports: ${supported.length > 0 ? supported.join(", ") : "none of the optional settings"}.`);
2741
2700
  this.name = "UnsupportedOptionError";
2742
2701
  this.model = config.name;
2743
2702
  this.modelsSupporting = models;
@@ -2820,6 +2779,9 @@ function validateGenerateOptions(options, config, sizeMode, hasEditImages) {
2820
2779
  if (options.imagePromptStrength !== void 0 && !hasEditImages) throw new Error("imagePromptStrength requires editImageUrls");
2821
2780
  if (options.maskImageUrl !== void 0 && config.supportsMaskImage !== true) unsupported(config, "maskImageUrl");
2822
2781
  if (options.maskImageUrl !== void 0 && !hasEditImages) throw new Error("maskImageUrl requires editImageUrls");
2782
+ if (options.markupImageUrl !== void 0 && config.supportsMarkupImage !== true) unsupported(config, "markupImageUrl");
2783
+ if (options.markupImageUrl !== void 0 && !hasEditImages) throw new Error("markupImageUrl requires editImageUrls");
2784
+ if (options.markupImageUrl !== void 0 && options.maskImageUrl !== void 0) throw new Error("A markup image can't be combined with a mask.");
2823
2785
  if (options.seed !== void 0 && config.supportsSeed !== true) unsupported(config, "seed");
2824
2786
  if (options.outputFormat !== void 0 && config.supportsOutputFormat !== true) unsupported(config, "outputFormat");
2825
2787
  if (options.quality !== void 0 && config.supportsQuality !== true) unsupported(config, "quality");
@@ -2855,11 +2817,11 @@ function validateGenerateOptions(options, config, sizeMode, hasEditImages) {
2855
2817
  * Models use different sizing APIs:
2856
2818
  * - `gpt_size`: GPT's fixed dimensions (1024x1024, 1536x1024, etc.)
2857
2819
  * - `aspect_ratio`: string like "16:9" (Gemini, Grok, FLUX Pro Ultra)
2858
- * - `image_size_enum`: named presets like "landscape_4_3" (FLUX.2, Seedream, Recraft, Ideogram, GPT Image 2)
2820
+ * - `image_size_enum`: named presets like "landscape_4_3" (FLUX.2, Seedream, Recraft, Ideogram, GPT Image)
2859
2821
  * - `none`: no size control (video models)
2860
2822
  */
2861
2823
  function buildGenerateBody(options) {
2862
- const { prompt, model, creative, aspect = "1:1", resolution = "2K", numImages = 1, background, editImageUrls, enableGoogleSearch, enableSafetyChecker, transparent, inputFidelity, imageSize, imagePromptStrength, limitGenerations, maskImageUrl, seed, outputFormat, quality, negativePrompt, style, syncMode, thinkingLevel, renderingSpeed, guidanceScale, numInferenceSteps, raw, enhancePrompt, safetyTolerance, enableWebSearch, expandPrompt } = options;
2824
+ const { prompt, model, creative, aspect = "1:1", resolution = "2K", numImages = 1, background, editImageUrls, enableGoogleSearch, enableSafetyChecker, transparent, inputFidelity, imageSize, imagePromptStrength, limitGenerations, maskImageUrl, markupImageUrl, seed, outputFormat, quality, negativePrompt, style, syncMode, thinkingLevel, renderingSpeed, guidanceScale, numInferenceSteps, raw, enhancePrompt, safetyTolerance, enableWebSearch, expandPrompt } = options;
2863
2825
  const config = MODELS[model];
2864
2826
  if (!config) throw new Error(`Unknown model: ${model}`);
2865
2827
  let { endpoint } = config;
@@ -2878,7 +2840,7 @@ function buildGenerateBody(options) {
2878
2840
  body.image_size = normalizedImageSize ?? (hasEditImages && !explicitAspect ? "auto" : aspectToGptSize(aspect));
2879
2841
  break;
2880
2842
  case "image_size_enum":
2881
- body.image_size = normalizedImageSize ?? falImageSizeFor(config, aspect, resolution);
2843
+ body.image_size = normalizedImageSize ?? (config.autoEditSize === true && hasEditImages && !explicitAspect ? "auto" : falImageSizeFor(config, aspect, resolution));
2882
2844
  break;
2883
2845
  case "aspect_ratio":
2884
2846
  if (config.supportsAspect) body.aspect_ratio = aspect;
@@ -2891,7 +2853,8 @@ function buildGenerateBody(options) {
2891
2853
  if (syncMode !== void 0 && config.supportsSyncMode === true) body.sync_mode = syncMode;
2892
2854
  if (seed !== void 0 && config.supportsSeed === true) body.seed = seed;
2893
2855
  if (outputFormat && config.supportsOutputFormat === true && transparent !== true) {
2894
- if (config.supportedOutputFormats && !config.supportedOutputFormats.includes(outputFormat)) throw new Error(`${config.name} supports output formats: ${config.supportedOutputFormats.join(", ")}`);
2856
+ const formats = outputFormatsOf(config, hasEditImages);
2857
+ if (!formats.includes(outputFormat)) throw new Error(`${config.name} ${hasEditImages ? "edits support" : "supports"} output formats: ${formats.join(", ")}`);
2895
2858
  body.output_format = outputFormat;
2896
2859
  }
2897
2860
  if (config.supportsRaw === true && raw !== void 0) body.raw = raw;
@@ -2950,6 +2913,7 @@ function buildGenerateBody(options) {
2950
2913
  if (inputFidelity) body.input_fidelity = inputFidelity;
2951
2914
  }
2952
2915
  if (maskImageUrl !== void 0 && maskImageUrl !== "" && config.supportsMaskImage === true) body[config.maskImageField ?? "mask_image_url"] = maskImageUrl;
2916
+ if (markupImageUrl !== void 0 && config.supportsMarkupImage === true) body.markup_image_url = markupImageUrl;
2953
2917
  return {
2954
2918
  body,
2955
2919
  endpoint
@@ -4507,7 +4471,7 @@ async function runRequest(exec, prepared) {
4507
4471
  const response = await exec.request(`${FAL_BASE_URL$1}/${prepared.endpoint}`, requestInit(prepared));
4508
4472
  if (response.isErr()) return err$1(response.error);
4509
4473
  const data = await response.value.json();
4510
- const record = isRecord$4(data) ? data : {};
4474
+ const record = isRecord$1(data) ? data : {};
4511
4475
  const requestId = response.value.headers.get("x-fal-request-id") ?? asString(record.request_id);
4512
4476
  return ok$1({
4513
4477
  data: record,
@@ -4534,14 +4498,19 @@ async function getToolResult(exec, job) {
4534
4498
  const response = await exec.request(url);
4535
4499
  if (response.isErr()) return err$1(response.error);
4536
4500
  const data = await response.value.json();
4537
- return ok$1(isRecord$4(data) ? data : {});
4501
+ return ok$1(isRecord$1(data) ? data : {});
4538
4502
  }
4539
4503
  /** Poll a submitted job to completion and fetch its result. Submits nothing. */
4540
4504
  async function awaitQueuedRequest(exec, job, onProgress) {
4505
+ let logsSeen = 0;
4541
4506
  for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
4542
4507
  const status = await checkToolStatus(exec, job);
4543
4508
  if (status.isErr()) return err$1(status.error);
4544
- onProgress?.(status.value.status, status.value.queuePosition);
4509
+ const logs = status.value.logs ?? [];
4510
+ const fresh = logs.slice(logs.length < logsSeen ? 0 : logsSeen);
4511
+ logsSeen = logs.length;
4512
+ if (fresh.length > 0) onProgress?.(status.value.status, status.value.queuePosition, fresh);
4513
+ else onProgress?.(status.value.status, status.value.queuePosition);
4545
4514
  if (status.value.status === "completed") return (await getToolResult(exec, job)).map((data) => ({
4546
4515
  data,
4547
4516
  requestId: job.requestId
@@ -4638,7 +4607,7 @@ var FalClient = class {
4638
4607
  try {
4639
4608
  built = buildGenerateBody(options);
4640
4609
  } catch (error) {
4641
- return err$1(toMotifError$1(error));
4610
+ return err$1(toMotifError(error));
4642
4611
  }
4643
4612
  const { endpoint, body } = built;
4644
4613
  const response = await this.request(`${FAL_BASE_URL}/${endpoint}`, {
@@ -4676,7 +4645,7 @@ var FalClient = class {
4676
4645
  try {
4677
4646
  built = buildGenerateBody(options);
4678
4647
  } catch (error) {
4679
- return err$1(toMotifError$1(error));
4648
+ return err$1(toMotifError(error));
4680
4649
  }
4681
4650
  const { endpoint, body } = built;
4682
4651
  const response = await this.request(`${FAL_QUEUE_URL}/${endpoint}`, {
@@ -4698,7 +4667,7 @@ var FalClient = class {
4698
4667
  const response = await this.request(url);
4699
4668
  if (response.isErr()) return err$1(response.error);
4700
4669
  const data = await response.value.json();
4701
- const record = isRecord$4(data) ? data : {};
4670
+ const record = isRecord$1(data) ? data : {};
4702
4671
  const rawStatus = asString(record.status);
4703
4672
  let status;
4704
4673
  if (rawStatus === "IN_QUEUE") status = "queued";
@@ -4806,7 +4775,7 @@ var FalClient = class {
4806
4775
  const response = await this.request(url);
4807
4776
  if (response.isErr()) return err$1(response.error);
4808
4777
  const data = await response.value.json();
4809
- const video = isRecord$4(data) && isRecord$4(data.video) ? data.video : void 0;
4778
+ const video = isRecord$1(data) && isRecord$1(data.video) ? data.video : void 0;
4810
4779
  if (video === void 0) return err$1(new MotifError("No video in response", 0));
4811
4780
  return ok$1({
4812
4781
  contentType: asString(video.content_type) ?? "",
@@ -4830,8 +4799,8 @@ var FalClient = class {
4830
4799
  });
4831
4800
  if (initiateResponse.isErr()) return err$1(initiateResponse.error);
4832
4801
  const initiateData = await initiateResponse.value.json();
4833
- const fileUrl = isRecord$4(initiateData) ? asString(initiateData.file_url) ?? "" : "";
4834
- const uploadUrl = isRecord$4(initiateData) ? asString(initiateData.upload_url) : void 0;
4802
+ const fileUrl = isRecord$1(initiateData) ? asString(initiateData.file_url) ?? "" : "";
4803
+ const uploadUrl = isRecord$1(initiateData) ? asString(initiateData.upload_url) : void 0;
4835
4804
  if (uploadUrl === void 0 || uploadUrl === "") return err$1(new MotifError("Upload initiate response missing upload_url", 0));
4836
4805
  let putResponse;
4837
4806
  try {
@@ -4983,7 +4952,7 @@ var FalClient = class {
4983
4952
  * Some APIs return `{ image: {...} }` instead of `{ images: [...] }`.
4984
4953
  */
4985
4954
  normalizeResponse(data, fallbackRequestId) {
4986
- if (!isRecord$4(data)) return err$1(new MotifError("Unexpected fal response shape", 0, "FAL_ERROR"));
4955
+ if (!isRecord$1(data)) return err$1(new MotifError("Unexpected fal response shape", 0, "FAL_ERROR"));
4987
4956
  const obj = data;
4988
4957
  const requestId = asString(obj.request_id) ?? asString(obj.requestId) ?? fallbackRequestId;
4989
4958
  if ("detail" in obj) return err$1(new MotifError(asString(obj.detail) ?? "", 0, "FAL_ERROR"));
@@ -5286,6 +5255,188 @@ function sumCosts(costs) {
5286
5255
  };
5287
5256
  }
5288
5257
  //#endregion
5258
+ //#region src/image-benchmarks.ts
5259
+ const BOARDS = {
5260
+ "text-to-image": {
5261
+ sunburst: {
5262
+ elo: 1427,
5263
+ rank: 1,
5264
+ uncertainty: 5,
5265
+ evaluatedModel: "GPT-Image-2.5 Sunburst",
5266
+ preliminary: true,
5267
+ votes: 40323
5268
+ },
5269
+ flare: {
5270
+ elo: 1401,
5271
+ rank: 2,
5272
+ uncertainty: 5,
5273
+ evaluatedModel: "GPT-Image-2.5 Flare",
5274
+ preliminary: true,
5275
+ votes: 39218
5276
+ },
5277
+ banana21: {
5278
+ elo: 1325,
5279
+ rank: 5,
5280
+ uncertainty: 9,
5281
+ evaluatedModel: "Nano Banana 2.1",
5282
+ preliminary: true,
5283
+ votes: 5290
5284
+ },
5285
+ "grok-image-2": {
5286
+ elo: 1317,
5287
+ rank: 6,
5288
+ uncertainty: 7,
5289
+ evaluatedModel: "Grok Imagine Image 2.0",
5290
+ preliminary: true,
5291
+ votes: 10902
5292
+ },
5293
+ "muse-image": {
5294
+ elo: 1269,
5295
+ rank: 8,
5296
+ uncertainty: 5,
5297
+ evaluatedModel: "Muse Image (Meta)",
5298
+ votes: 53444
5299
+ },
5300
+ qwen3: {
5301
+ elo: 1254,
5302
+ rank: 9,
5303
+ uncertainty: 6,
5304
+ evaluatedModel: "Qwen-Image-3.0-Pro",
5305
+ votes: 11444
5306
+ },
5307
+ "mai-image-2.5": {
5308
+ elo: 1253,
5309
+ rank: 10,
5310
+ uncertainty: 4,
5311
+ evaluatedModel: "MAI-Image-2.5",
5312
+ votes: 61286
5313
+ },
5314
+ seedream5: {
5315
+ elo: 1250,
5316
+ rank: 11,
5317
+ uncertainty: 4,
5318
+ evaluatedModel: "Seedream 5.0 Pro",
5319
+ votes: 105824
5320
+ },
5321
+ "banana2-lite": {
5322
+ elo: 1249,
5323
+ rank: 12,
5324
+ uncertainty: 6,
5325
+ evaluatedModel: "Nano Banana 2 Lite",
5326
+ votes: 17813
5327
+ },
5328
+ banana: {
5329
+ elo: 1246,
5330
+ rank: 13,
5331
+ uncertainty: 3,
5332
+ evaluatedModel: "Nano Banana Pro (2K)",
5333
+ votes: 188288
5334
+ },
5335
+ gpt: {
5336
+ elo: 1236,
5337
+ rank: 14,
5338
+ uncertainty: 3,
5339
+ evaluatedModel: "GPT-Image-1.5 (High Fidelity)",
5340
+ votes: 174012
5341
+ },
5342
+ ideogram4: {
5343
+ elo: 1200,
5344
+ rank: 15,
5345
+ uncertainty: 4,
5346
+ evaluatedModel: "Ideogram 4.0 (Quality)",
5347
+ votes: 72865
5348
+ },
5349
+ "flux2-flex": {
5350
+ elo: 1156,
5351
+ rank: 17,
5352
+ uncertainty: 3,
5353
+ evaluatedModel: "FLUX.2 [flex]",
5354
+ votes: 156876
5355
+ }
5356
+ },
5357
+ "image-edit-single-image": {
5358
+ sunburst: {
5359
+ elo: 1517,
5360
+ rank: 1,
5361
+ uncertainty: 5,
5362
+ evaluatedModel: "GPT-Image-2.5 Sunburst",
5363
+ preliminary: true
5364
+ },
5365
+ flare: {
5366
+ elo: 1482,
5367
+ rank: 2,
5368
+ uncertainty: 5,
5369
+ evaluatedModel: "GPT-Image-2.5 Flare",
5370
+ preliminary: true
5371
+ },
5372
+ "grok-image-2": {
5373
+ elo: 1439,
5374
+ rank: 5,
5375
+ uncertainty: 6,
5376
+ evaluatedModel: "Grok Imagine Image 2.0",
5377
+ preliminary: true
5378
+ },
5379
+ banana21: {
5380
+ elo: 1427,
5381
+ rank: 7,
5382
+ uncertainty: 6,
5383
+ evaluatedModel: "Nano Banana 2.1",
5384
+ preliminary: true
5385
+ },
5386
+ "mai-image-2.5": {
5387
+ elo: 1400,
5388
+ rank: 9,
5389
+ uncertainty: 4,
5390
+ evaluatedModel: "MAI-Image-2.5"
5391
+ },
5392
+ "muse-image": {
5393
+ elo: 1400,
5394
+ rank: 10,
5395
+ uncertainty: 4,
5396
+ evaluatedModel: "Muse Image (Meta)"
5397
+ },
5398
+ "grok-image-quality": {
5399
+ elo: 1391,
5400
+ rank: 11,
5401
+ uncertainty: 6,
5402
+ evaluatedModel: "Grok Imagine Image (Quality, 20260519)"
5403
+ },
5404
+ seedream5: {
5405
+ elo: 1390,
5406
+ rank: 12,
5407
+ uncertainty: 3,
5408
+ evaluatedModel: "Seedream 5.0 Pro"
5409
+ },
5410
+ banana: {
5411
+ elo: 1390,
5412
+ rank: 13,
5413
+ uncertainty: 3,
5414
+ evaluatedModel: "Nano Banana Pro (2K)"
5415
+ },
5416
+ gpt: {
5417
+ elo: 1368,
5418
+ rank: 14,
5419
+ uncertainty: 3,
5420
+ evaluatedModel: "GPT-Image-1.5 (High Fidelity)"
5421
+ }
5422
+ }
5423
+ };
5424
+ const SOURCES = {
5425
+ "text-to-image": "https://arena.ai/leaderboard/image/text-to-image",
5426
+ "image-edit-single-image": "https://arena.ai/leaderboard/image/image-edit/single-image"
5427
+ };
5428
+ /** A published score for this route, or null when no matching score was saved. */
5429
+ function imageModelBenchmark(model, task) {
5430
+ const score = BOARDS[task][model];
5431
+ if (score === void 0) return null;
5432
+ return {
5433
+ ...score,
5434
+ source: SOURCES[task],
5435
+ snapshotDate: "2026-10-29",
5436
+ task
5437
+ };
5438
+ }
5439
+ //#endregion
5289
5440
  //#region src/tasks.ts
5290
5441
  /**
5291
5442
  * The Task registry: every job Motif does, with the Models that can do it
@@ -5313,6 +5464,186 @@ const TIERS = [
5313
5464
  const DEFAULT_TIER = "balanced";
5314
5465
  const HAND_RANKED_AT = "2026-09-16";
5315
5466
  const IMAGE_RANKED_AT = "2026-10-29";
5467
+ function withImageBenchmarks(models, task) {
5468
+ return models.map((entry) => ({
5469
+ ...entry,
5470
+ benchmark: imageModelBenchmark(entry.model, task)
5471
+ }));
5472
+ }
5473
+ const GENERATION_MODELS_RANKED = withImageBenchmarks([
5474
+ {
5475
+ model: "sunburst",
5476
+ tier: "quality"
5477
+ },
5478
+ {
5479
+ model: "flare",
5480
+ tier: "quality"
5481
+ },
5482
+ {
5483
+ model: "seedream5",
5484
+ tier: "quality"
5485
+ },
5486
+ {
5487
+ model: "gpt",
5488
+ tier: "quality"
5489
+ },
5490
+ {
5491
+ model: "mai-image-2.5-pro",
5492
+ tier: "quality"
5493
+ },
5494
+ {
5495
+ model: "banana21",
5496
+ tier: "balanced"
5497
+ },
5498
+ {
5499
+ model: "grok-image-2",
5500
+ tier: "balanced"
5501
+ },
5502
+ {
5503
+ model: "muse-image",
5504
+ tier: "balanced"
5505
+ },
5506
+ {
5507
+ model: "banana2",
5508
+ tier: "balanced"
5509
+ },
5510
+ {
5511
+ model: "qwen3",
5512
+ tier: "balanced"
5513
+ },
5514
+ {
5515
+ model: "mai-image-2.5",
5516
+ tier: "balanced"
5517
+ },
5518
+ {
5519
+ model: "banana",
5520
+ tier: "balanced"
5521
+ },
5522
+ {
5523
+ model: "ideogram4",
5524
+ tier: "balanced"
5525
+ },
5526
+ {
5527
+ model: "flux2-flex",
5528
+ tier: "balanced"
5529
+ },
5530
+ {
5531
+ model: "recraft41",
5532
+ tier: "balanced"
5533
+ },
5534
+ {
5535
+ model: "flux2-pro",
5536
+ tier: "balanced"
5537
+ },
5538
+ {
5539
+ model: "krea3",
5540
+ tier: "balanced"
5541
+ },
5542
+ {
5543
+ model: "grok-image-quality",
5544
+ tier: "balanced"
5545
+ },
5546
+ {
5547
+ model: "ideogram3-transparent",
5548
+ requires: ["transparency"],
5549
+ supports: ["transparency"],
5550
+ tier: "balanced"
5551
+ },
5552
+ {
5553
+ model: "banana2-lite",
5554
+ tier: "fast"
5555
+ },
5556
+ {
5557
+ model: "grok-image",
5558
+ tier: "fast"
5559
+ },
5560
+ {
5561
+ model: "seedream5-lite",
5562
+ tier: "fast"
5563
+ },
5564
+ {
5565
+ model: "flux2-turbo",
5566
+ tier: "fast"
5567
+ }
5568
+ ], "text-to-image");
5569
+ const EDITING_MODELS_RANKED = withImageBenchmarks([
5570
+ {
5571
+ model: "sunburst",
5572
+ tier: "quality"
5573
+ },
5574
+ {
5575
+ model: "flare",
5576
+ tier: "quality"
5577
+ },
5578
+ {
5579
+ model: "seedream5",
5580
+ tier: "quality"
5581
+ },
5582
+ {
5583
+ model: "gpt",
5584
+ tier: "quality"
5585
+ },
5586
+ {
5587
+ model: "mai-image-2.5-pro",
5588
+ tier: "quality"
5589
+ },
5590
+ {
5591
+ model: "grok-image-2",
5592
+ tier: "balanced"
5593
+ },
5594
+ {
5595
+ model: "banana21",
5596
+ tier: "balanced"
5597
+ },
5598
+ {
5599
+ model: "mai-image-2.5",
5600
+ tier: "balanced"
5601
+ },
5602
+ {
5603
+ model: "muse-image",
5604
+ tier: "balanced"
5605
+ },
5606
+ {
5607
+ model: "grok-image-quality",
5608
+ tier: "balanced"
5609
+ },
5610
+ {
5611
+ model: "banana",
5612
+ tier: "balanced"
5613
+ },
5614
+ {
5615
+ model: "banana2",
5616
+ tier: "balanced"
5617
+ },
5618
+ {
5619
+ model: "krea3",
5620
+ tier: "balanced"
5621
+ },
5622
+ {
5623
+ model: "qwen3",
5624
+ tier: "balanced"
5625
+ },
5626
+ {
5627
+ model: "flux2-pro",
5628
+ tier: "balanced"
5629
+ },
5630
+ {
5631
+ model: "flux2-flex",
5632
+ tier: "balanced"
5633
+ },
5634
+ {
5635
+ model: "grok-image",
5636
+ tier: "fast"
5637
+ },
5638
+ {
5639
+ model: "banana2-lite",
5640
+ tier: "fast"
5641
+ },
5642
+ {
5643
+ model: "seedream5-lite",
5644
+ tier: "fast"
5645
+ }
5646
+ ], "image-edit-single-image");
5316
5647
  /** Every Task with a Model. A Task is absent here until it has at least one Model. */
5317
5648
  const TASKS = {
5318
5649
  animate: {
@@ -5484,106 +5815,7 @@ const TASKS = {
5484
5815
  },
5485
5816
  generate: {
5486
5817
  basis: "Order follows Arena text-to-image (https://arena.ai/leaderboard/image/text-to-image), read 2026-10-29 (MOT-72). Sunburst and Flare lead quality at the benchmarked max setting. Balanced starts with Nano Banana 2.1 on preliminary Arena evidence, then Grok Image 2 and Muse Image. Models not yet on the board follow the ranked ones in their Tier, in their previous order. The Artificial Analysis v2 snapshot of 2026-10-07 stays as recorded benchmark data. Fast keeps Nano Banana 2 Lite, budget Grok and Seedream 5 Lite, and FLUX.2 Turbo. Design, transparency and tuned Look routes are capability exceptions. Generation with References uses the separate editing ranking. Older routes are explicit/pin/Look-only.",
5487
- models: [
5488
- {
5489
- model: "sunburst",
5490
- tier: "quality"
5491
- },
5492
- {
5493
- model: "flare",
5494
- tier: "quality"
5495
- },
5496
- {
5497
- model: "gpt2",
5498
- tier: "quality"
5499
- },
5500
- {
5501
- model: "seedream5",
5502
- tier: "quality"
5503
- },
5504
- {
5505
- model: "gpt",
5506
- tier: "quality"
5507
- },
5508
- {
5509
- model: "mai-image-2.5-pro",
5510
- tier: "quality"
5511
- },
5512
- {
5513
- model: "banana21",
5514
- tier: "balanced"
5515
- },
5516
- {
5517
- model: "grok-image-2",
5518
- tier: "balanced"
5519
- },
5520
- {
5521
- model: "muse-image",
5522
- tier: "balanced"
5523
- },
5524
- {
5525
- model: "banana2",
5526
- tier: "balanced"
5527
- },
5528
- {
5529
- model: "qwen3",
5530
- tier: "balanced"
5531
- },
5532
- {
5533
- model: "mai-image-2.5",
5534
- tier: "balanced"
5535
- },
5536
- {
5537
- model: "banana",
5538
- tier: "balanced"
5539
- },
5540
- {
5541
- model: "ideogram4",
5542
- tier: "balanced"
5543
- },
5544
- {
5545
- model: "flux2-flex",
5546
- tier: "balanced"
5547
- },
5548
- {
5549
- model: "recraft41",
5550
- tier: "balanced"
5551
- },
5552
- {
5553
- model: "flux2-pro",
5554
- tier: "balanced"
5555
- },
5556
- {
5557
- model: "krea3",
5558
- tier: "balanced"
5559
- },
5560
- {
5561
- model: "grok-image-quality",
5562
- tier: "balanced"
5563
- },
5564
- {
5565
- model: "ideogram3-transparent",
5566
- requires: ["transparency"],
5567
- supports: ["transparency"],
5568
- tier: "balanced"
5569
- },
5570
- {
5571
- model: "banana2-lite",
5572
- tier: "fast"
5573
- },
5574
- {
5575
- model: "grok-image",
5576
- tier: "fast"
5577
- },
5578
- {
5579
- model: "seedream5-lite",
5580
- tier: "fast"
5581
- },
5582
- {
5583
- model: "flux2-turbo",
5584
- tier: "fast"
5585
- }
5586
- ],
5818
+ models: GENERATION_MODELS_RANKED,
5587
5819
  notFor: "Variations of an image you already have (vary), or a consistent set of images (series run).",
5588
5820
  rankedAt: IMAGE_RANKED_AT,
5589
5821
  rankedFrom: "hand",
@@ -6071,88 +6303,7 @@ const TASKS = {
6071
6303
  },
6072
6304
  vary: {
6073
6305
  basis: "Order follows Arena image edit, single image (https://arena.ai/leaderboard/image/image-edit/single-image), read 2026-10-29 (MOT-72). Editing order differs from generation: Grok Image 2 leads balanced, ahead of Nano Banana 2.1, and Seedream 5 Pro outranks GPT Image 1.5. Arena scores Krea 3 only at its Ultra setting, which costs $0.27 to $0.35 an edit and ignores the requested size, so krea3, which runs fal's default setting, is ranked as unscored. Models not yet on the board follow the ranked ones in their Tier, in their previous order. The Artificial Analysis v2 snapshot of 2026-10-07 stays as recorded benchmark data. Sunburst and Flare use max quality. vary reuses a previously chosen Model when explicitly supplied, including retained legacy routes.",
6074
- models: [
6075
- {
6076
- model: "sunburst",
6077
- tier: "quality"
6078
- },
6079
- {
6080
- model: "flare",
6081
- tier: "quality"
6082
- },
6083
- {
6084
- model: "gpt2",
6085
- tier: "quality"
6086
- },
6087
- {
6088
- model: "seedream5",
6089
- tier: "quality"
6090
- },
6091
- {
6092
- model: "gpt",
6093
- tier: "quality"
6094
- },
6095
- {
6096
- model: "mai-image-2.5-pro",
6097
- tier: "quality"
6098
- },
6099
- {
6100
- model: "grok-image-2",
6101
- tier: "balanced"
6102
- },
6103
- {
6104
- model: "banana21",
6105
- tier: "balanced"
6106
- },
6107
- {
6108
- model: "mai-image-2.5",
6109
- tier: "balanced"
6110
- },
6111
- {
6112
- model: "muse-image",
6113
- tier: "balanced"
6114
- },
6115
- {
6116
- model: "grok-image-quality",
6117
- tier: "balanced"
6118
- },
6119
- {
6120
- model: "banana",
6121
- tier: "balanced"
6122
- },
6123
- {
6124
- model: "banana2",
6125
- tier: "balanced"
6126
- },
6127
- {
6128
- model: "krea3",
6129
- tier: "balanced"
6130
- },
6131
- {
6132
- model: "qwen3",
6133
- tier: "balanced"
6134
- },
6135
- {
6136
- model: "flux2-pro",
6137
- tier: "balanced"
6138
- },
6139
- {
6140
- model: "flux2-flex",
6141
- tier: "balanced"
6142
- },
6143
- {
6144
- model: "grok-image",
6145
- tier: "fast"
6146
- },
6147
- {
6148
- model: "banana2-lite",
6149
- tier: "fast"
6150
- },
6151
- {
6152
- model: "seedream5-lite",
6153
- tier: "fast"
6154
- }
6155
- ],
6306
+ models: EDITING_MODELS_RANKED,
6156
6307
  notFor: "A specific change to an image described in words (generate with a reference), or a set of different scenes in one style (series run).",
6157
6308
  rankedAt: IMAGE_RANKED_AT,
6158
6309
  rankedFrom: "hand",
@@ -8346,11 +8497,10 @@ const NO_MODEL_AVAILABLE = "NO_MODEL_AVAILABLE";
8346
8497
  const FAL_KEY = "FAL_KEY";
8347
8498
  function generationProfile(config) {
8348
8499
  const capabilities = /* @__PURE__ */ new Set(["image"]);
8349
- const keyed = /* @__PURE__ */ new Map();
8350
8500
  if (config.supportsBackground === true || config.transparentOutput === true) capabilities.add("transparency");
8351
- else if (config.transparencyRoute !== void 0) keyed.set("transparency", config.transparencyRoute.apiKeyEnv);
8352
8501
  if (config.supportsEdit) capabilities.add("references");
8353
8502
  if (config.supportsMaskImage === true) capabilities.add("mask");
8503
+ if (config.supportsMarkupImage === true) capabilities.add("markup");
8354
8504
  if ((config.sizeMode ?? "aspect_ratio") !== "none") capabilities.add("aspect");
8355
8505
  if (config.supportsResolution) capabilities.add("resolution");
8356
8506
  if (config.supportsNumImages) capabilities.add("count");
@@ -8360,9 +8510,12 @@ function generationProfile(config) {
8360
8510
  return {
8361
8511
  aspects: config.supportedAspects,
8362
8512
  capabilities,
8363
- keyedCapabilities: keyed,
8364
8513
  keys: [FAL_KEY],
8365
8514
  maxReferences: config.supportsEdit ? config.maxReferenceImages ?? 1 : 0,
8515
+ outputFormats: {
8516
+ edit: outputFormatsOf(config, true),
8517
+ text: outputFormatsOf(config, false)
8518
+ },
8366
8519
  resolutions: config.supportedResolutions
8367
8520
  };
8368
8521
  }
@@ -8389,7 +8542,6 @@ function toolProfile(tool) {
8389
8542
  if (takesReference) capabilities.add("references");
8390
8543
  return {
8391
8544
  capabilities,
8392
- keyedCapabilities: /* @__PURE__ */ new Map(),
8393
8545
  keys: [FAL_KEY],
8394
8546
  maxReferences: takesReference ? 1 : 0
8395
8547
  };
@@ -8413,12 +8565,13 @@ function requirements(request) {
8413
8565
  if (request.transparent === true) needed.push("transparency");
8414
8566
  if ((request.references ?? 0) > 0) needed.push("references");
8415
8567
  if (request.mask === true) needed.push("mask");
8568
+ if (request.markup === true) needed.push("markup");
8416
8569
  if (request.aspect !== void 0) needed.push("aspect");
8417
8570
  if (request.resolution !== void 0) needed.push("resolution");
8418
8571
  if ((request.count ?? 1) > 1) needed.push("count");
8419
8572
  if (request.seed === true) needed.push("seed");
8420
8573
  if (request.negativePrompt === true) needed.push("negativePrompt");
8421
- if (request.outputFormat === true) needed.push("outputFormat");
8574
+ if (request.outputFormat !== void 0) needed.push("outputFormat");
8422
8575
  if (request.rig === true) needed.push("rig");
8423
8576
  return needed;
8424
8577
  }
@@ -8427,6 +8580,7 @@ function withinLimits(profile, capability, request) {
8427
8580
  if (capability === "references") return (request.references ?? 0) <= profile.maxReferences;
8428
8581
  if (capability === "aspect") return accepts(profile.aspects, request.aspect);
8429
8582
  if (capability === "resolution") return accepts(profile.resolutions, request.resolution);
8583
+ if (capability === "outputFormat") return accepts((request.references ?? 0) > 0 || request.source !== void 0 ? profile.outputFormats?.edit : profile.outputFormats?.text, request.outputFormat);
8430
8584
  return true;
8431
8585
  }
8432
8586
  function accepts(allowed, value) {
@@ -8443,25 +8597,10 @@ function check(profile, needed, request, keys, requires = []) {
8443
8597
  missingInput: true,
8444
8598
  ok: false
8445
8599
  };
8446
- for (const capability of needed) {
8447
- if (profile.capabilities.has(capability)) {
8448
- if (!withinLimits(profile, capability, request)) return {
8449
- blockedBy: capability,
8450
- ok: false
8451
- };
8452
- continue;
8453
- }
8454
- const key = profile.keyedCapabilities.get(capability);
8455
- if (key === void 0) return {
8456
- blockedBy: capability,
8457
- ok: false
8458
- };
8459
- if (!keys.includes(key)) return {
8460
- blockedBy: "key",
8461
- missingKey: key,
8462
- ok: false
8463
- };
8464
- }
8600
+ for (const capability of needed) if (!profile.capabilities.has(capability) || !withinLimits(profile, capability, request)) return {
8601
+ blockedBy: capability,
8602
+ ok: false
8603
+ };
8465
8604
  return { ok: true };
8466
8605
  }
8467
8606
  const TIER_ORDER = {
@@ -8481,7 +8620,11 @@ const TIER_ORDER = {
8481
8620
  "fast"
8482
8621
  ]
8483
8622
  };
8484
- function describeBlock(blockedBy, missingKey, missingInput) {
8623
+ function describeBlock(blockedBy, request, missingKey, missingInput) {
8624
+ if (blockedBy === "outputFormat" && request.outputFormat !== void 0) {
8625
+ const editing = (request.references ?? 0) > 0 || request.source !== void 0;
8626
+ return `cannot return ${request.outputFormat}${editing ? " from an edit" : ""}`;
8627
+ }
8485
8628
  if (missingInput === true) return `needs a ${blockedBy} input`;
8486
8629
  if (blockedBy === "key") return `needs ${missingKey ?? "a provider key"}`;
8487
8630
  if (blockedBy === "unknown-model") return "is not a Model for this Task";
@@ -8550,7 +8693,7 @@ function resolveFixed(task, fixed, request, needed, keys) {
8550
8693
  if (result.ok) return resolved(task, model, tier, chosenBy);
8551
8694
  const unblockers = chosenBy === "model" ? [] : ["model"];
8552
8695
  unblockers.push(unblockerFor(result));
8553
- return unresolved(task, result.blockedBy, unblockers, `${model} (${chosenBy}) ${describeBlock(result.blockedBy, result.missingKey, result.missingInput)}.`, result.missingKey);
8696
+ return unresolved(task, result.blockedBy, unblockers, `${model} (${chosenBy}) ${describeBlock(result.blockedBy, request, result.missingKey, result.missingInput)}.`, result.missingKey);
8554
8697
  }
8555
8698
  /** Old pins and tuned Looks remain usable without recommending predecessors. */
8556
8699
  function legacyEntry(task, model, mode, tier) {
@@ -8647,730 +8790,6 @@ function tierChangesChoice(task, request, environment) {
8647
8790
  return chosen.size > 1;
8648
8791
  }
8649
8792
  //#endregion
8650
- //#region src/image/fetch.ts
8651
- function urlOf$1(input) {
8652
- if (typeof input === "string") return input;
8653
- return input instanceof URL ? input.href : input.url;
8654
- }
8655
- /** Adapt a configured fetch for a provider factory; undefined keeps global fetch. */
8656
- function toProviderFetch(fetch) {
8657
- if (fetch === void 0) return {};
8658
- return { fetch: async (input, init) => await fetch(urlOf$1(input), init ?? {}) };
8659
- }
8660
- //#endregion
8661
- //#region src/image/fal.ts
8662
- /**
8663
- * fal provider adapter.
8664
- *
8665
- * Builds a Vercel AI SDK `ImageModel` from `@ai-sdk/fal`. Building a model
8666
- * performs no network I/O — the request only happens when `generateImage`
8667
- * invokes `model.doGenerate`. This is the lightweight fal *image* adapter; the
8668
- * richer `FalClient` fal client (queue, upload, upscale, rmbg, video, tools)
8669
- * is a separate, later fold (design doc §8, phase 1d).
8670
- *
8671
- * ENDPOINT QUIRK (Phase 0): fal's gpt-image endpoint wants `image_size` as a
8672
- * STRING enum (e.g. "1024x1024"), passed at generate time via
8673
- * `providerOptions.fal.image_size` — NOT the AI SDK's generic `size` object.
8674
- * This adapter does NOT auto-inject it; callers pass `providerOptions` when they
8675
- * need a specific size. Example:
8676
- * img.generate({
8677
- * provider: "fal",
8678
- * model: "fal-ai/gpt-image-1.5",
8679
- * prompt: "...",
8680
- * providerOptions: { fal: { image_size: "1024x1024" } },
8681
- * });
8682
- */
8683
- /**
8684
- * fal endpoint ids proven to work with this adapter and priced in
8685
- * {@link FAL_IMAGE_PRICE_USD} below (FLUX Pro Ultra + gpt-image).
8686
- */
8687
- const FAL_FLUX_MODEL = "fal-ai/flux-pro/v1.1-ultra";
8688
- const FAL_GPT_IMAGE_MODEL = "fal-ai/gpt-image-1.5";
8689
- /** Env var read for the fal key when `apiKey` is not supplied in config. */
8690
- const FAL_API_KEY_ENV = "FAL_KEY";
8691
- /**
8692
- * Static fal USD/image, keyed by the fal ENDPOINT id. Cost tracking must price
8693
- * the endpoints consumers actually pass — kiln uses
8694
- * `fal-ai/flux/schnell`, `fal-ai/flux-2-pro`, `fal-ai/flux-2-max`. Every price is
8695
- * sourced from the fal registry snapshot in `../models`
8696
- * (`MODELS[...].pricePerImageUsd`) so this table stays in sync rather than
8697
- * drifting to a second hand-maintained copy; the `??` fallbacks are the registry
8698
- * values at time of writing, used only if a registry entry loses its optional
8699
- * price. Each key is `MODELS[x].endpoint`; the trailing comment cites the
8700
- * `MODELS` key it comes from. Covers the flux/flux2 family, banana, seedream, and
8701
- * the other priced fal generation endpoints.
8702
- */
8703
- const FAL_IMAGE_PRICE_USD = {
8704
- [FAL_FLUX_MODEL]: MODELS.flux?.pricePerImageUsd ?? .06,
8705
- "fal-ai/flux/schnell": MODELS["flux-fast"]?.pricePerImageUsd ?? .003,
8706
- "fal-ai/flux-2-max": MODELS["flux2-max"]?.pricePerImageUsd ?? .07,
8707
- "fal-ai/flux-2-pro": MODELS["flux2-pro"]?.pricePerImageUsd ?? .03,
8708
- "fal-ai/flux-2-flex": MODELS["flux2-flex"]?.pricePerImageUsd ?? .05,
8709
- "fal-ai/flux-2": MODELS["flux2-dev"]?.pricePerImageUsd ?? .012,
8710
- "fal-ai/flux-2/turbo": MODELS["flux2-turbo"]?.pricePerImageUsd ?? .008,
8711
- [FAL_GPT_IMAGE_MODEL]: MODELS.gpt?.pricePerImageUsd ?? .133,
8712
- "openai/gpt-image-2": MODELS.gpt2?.pricePerImageUsd ?? .211,
8713
- "fal-ai/nano-banana-2": MODELS.banana2?.pricePerImageUsd ?? .08,
8714
- "fal-ai/nano-banana-pro": MODELS.banana?.pricePerImageUsd ?? .15,
8715
- "fal-ai/gemini-25-flash-image": MODELS.gemini?.pricePerImageUsd ?? .0398,
8716
- "fal-ai/gemini-3-pro-image-preview": MODELS.gemini3?.pricePerImageUsd ?? .15,
8717
- "fal-ai/bytedance/seedream/v4/text-to-image": MODELS.seedream4?.pricePerImageUsd ?? .03,
8718
- "fal-ai/bytedance/seedream/v4.5/text-to-image": MODELS.seedream45?.pricePerImageUsd ?? .04,
8719
- "bytedance/seedream/v5/pro/text-to-image": MODELS.seedream5?.pricePerImageUsd ?? .0675,
8720
- "bytedance/seedream/v5/lite/text-to-image": MODELS["seedream5-lite"]?.pricePerImageUsd ?? .035,
8721
- "fal-ai/recraft/v3/text-to-image": MODELS.recraft?.pricePerImageUsd ?? .04,
8722
- "fal-ai/recraft/v4/text-to-image": MODELS.recraft4?.pricePerImageUsd ?? .04,
8723
- "fal-ai/recraft/v4.1/text-to-image": MODELS.recraft41?.pricePerImageUsd ?? .035,
8724
- "fal-ai/ideogram/v3": MODELS.ideogram?.pricePerImageUsd ?? .03,
8725
- "ideogram/v4": MODELS.ideogram4?.pricePerImageUsd ?? .03,
8726
- "xai/grok-imagine-image": MODELS["grok-image"]?.pricePerImageUsd ?? .02,
8727
- "xai/grok-imagine-image/v2.0/text-to-image": MODELS["grok-image-2"]?.pricePerImageUsd ?? .06,
8728
- "xai/grok-imagine-image/quality/text-to-image": MODELS["grok-image-quality"]?.pricePerImageUsd ?? .05,
8729
- "meta/muse-image/text-to-image": MODELS["muse-image"]?.pricePerImageUsd ?? .01,
8730
- "microsoft/mai-image-2.5-pro": MODELS["mai-image-2.5-pro"]?.pricePerImageUsd ?? .17,
8731
- "fal-ai/ideogram/v3/generate-transparent": MODELS["ideogram3-transparent"]?.pricePerImageUsd ?? .06,
8732
- "alibaba/qwen-image-3/text-to-image": MODELS.qwen3?.pricePerImageUsd ?? .04,
8733
- "fal-ai/qwen-image": MODELS.qwen?.pricePerImageUsd ?? .02
8734
- };
8735
- /**
8736
- * Build a fal `ImageModel`. Prefers the passed `apiKey`, else the `FAL_KEY` env
8737
- * var. Throws `MotifError` when neither is present (callers translate this into
8738
- * a `Result.err`).
8739
- */
8740
- function resolveModel$3(modelId, apiKey, fetch) {
8741
- const key = apiKey ?? process.env["FAL_KEY"];
8742
- if (key === void 0 || key === "") throw new MotifError(`fal image generation requires an API key (config.fal.apiKey or ${FAL_API_KEY_ENV})`, 0);
8743
- return createFal({
8744
- apiKey: key,
8745
- ...toProviderFetch(fetch)
8746
- }).image(modelId);
8747
- }
8748
- /**
8749
- * How a registered fal edit endpoint takes its input images, from the
8750
- * registry's `editImagesField` (default `image_urls`). `@ai-sdk/fal` sends only
8751
- * the first image as `image_url` unless told otherwise, which list-only
8752
- * endpoints reject and which silently drops every reference after the first.
8753
- * Undefined for an endpoint the registry does not list as an edit route.
8754
- */
8755
- function falEditImagesField(modelId) {
8756
- const config = Object.values(MODELS).find((entry) => entry.editEndpoint === modelId);
8757
- return config === void 0 ? void 0 : config.editImagesField ?? "image_urls";
8758
- }
8759
- /** The fal provider adapter registered in the provider registry. */
8760
- const falAdapter = {
8761
- id: "fal",
8762
- apiKeyEnv: FAL_API_KEY_ENV,
8763
- resolveModel: resolveModel$3,
8764
- priceUsdByModel: FAL_IMAGE_PRICE_USD
8765
- };
8766
- //#endregion
8767
- //#region src/image/openai.ts
8768
- /**
8769
- * OpenAI (gpt-image) provider adapter.
8770
- *
8771
- * Builds a Vercel AI SDK `ImageModel` from `@ai-sdk/openai`. Building a model
8772
- * performs no network I/O — the request only happens when `generateImage`
8773
- * invokes `model.doGenerate`.
8774
- */
8775
- /** Env var read for the OpenAI API key when `apiKey` is not supplied in config. */
8776
- const OPENAI_API_KEY_ENV = "OPENAI_API_KEY";
8777
- /**
8778
- * Static OpenAI-direct USD/image, keyed by model id.
8779
- *
8780
- * `gpt-image-1` ≈ $0.042/image at medium quality, 1024×1024. OpenAI bills image
8781
- * output by tokens, so the real figure varies with quality (low/medium/high)
8782
- * and size — this is a documented approximation for the common case.
8783
- * Source: https://platform.openai.com/docs/pricing (image generation)
8784
- * Sanity anchor: the Phase 0 benchmark measured gpt-image direct at $0.042
8785
- * (vs $0.133 via fal — full data and methodology on Linear MOT-23).
8786
- *
8787
- * GPT Image 2.5 is token-priced, with no published per-image estimate. Leave
8788
- * these models absent so cost remains unknown unless supplied by the provider.
8789
- * https://developers.openai.com/api/docs/models/gpt-image-2.5-flare
8790
- * https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst
8791
- */
8792
- const OPENAI_IMAGE_PRICE_USD = { "gpt-image-1": .042 };
8793
- /**
8794
- * Build an OpenAI `ImageModel`. Prefers the passed `apiKey`, else the
8795
- * `OPENAI_API_KEY` env var. Throws `MotifError` when neither is present
8796
- * (callers translate this into a `Result.err`).
8797
- */
8798
- function resolveModel$2(modelId, apiKey, fetch) {
8799
- const key = apiKey ?? process.env["OPENAI_API_KEY"];
8800
- if (key === void 0 || key === "") throw new MotifError(`OpenAI image generation requires an API key (config.openai.apiKey or ${OPENAI_API_KEY_ENV})`, 0);
8801
- return createOpenAI({
8802
- apiKey: key,
8803
- ...toProviderFetch(fetch)
8804
- }).image(modelId);
8805
- }
8806
- /** The OpenAI provider adapter registered in the provider registry. */
8807
- const openaiAdapter = {
8808
- id: "openai",
8809
- apiKeyEnv: OPENAI_API_KEY_ENV,
8810
- resolveModel: resolveModel$2,
8811
- priceUsdByModel: OPENAI_IMAGE_PRICE_USD
8812
- };
8813
- //#endregion
8814
- //#region src/image/openrouter.ts
8815
- /** Env var read for the OpenRouter API key when `apiKey` is not supplied in config. */
8816
- const OPENROUTER_API_KEY_ENV = "OPENROUTER_API_KEY";
8817
- const OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images";
8818
- /** Key under which this adapter's metadata sits on `providerMetadata`. */
8819
- const METADATA_KEY = "openrouter";
8820
- /**
8821
- * The Gemini image models Motif exposes, by their bare Google names, mapped to
8822
- * the OpenRouter slug that serves them. All six are listed by
8823
- * `GET https://openrouter.ai/api/v1/images/models`.
8824
- */
8825
- const OPENROUTER_GEMINI_IMAGE_MODELS = {
8826
- "gemini-2.5-flash-image": "google/gemini-2.5-flash-image",
8827
- "gemini-3.1-flash-image-preview": "google/gemini-3.1-flash-image-preview",
8828
- "gemini-3-pro-image-preview": "google/gemini-3-pro-image-preview",
8829
- "gemini-3.1-flash-image": "google/gemini-3.1-flash-image",
8830
- "gemini-3-pro-image": "google/gemini-3-pro-image",
8831
- "gemini-3.1-flash-lite-image": "google/gemini-3.1-flash-lite-image"
8832
- };
8833
- /**
8834
- * Static USD/image estimate, keyed by the bare model name. Used only when a
8835
- * response carries no `usage.cost`; OpenRouter normally reports the real figure.
8836
- *
8837
- * - `gemini-2.5-flash-image`: 1290 output tokens at $30 / 1M ≈ $0.039.
8838
- * - `gemini-3-pro-image-preview`, `gemini-3-pro-image`: ≈ $0.134 at 1K/2K.
8839
- * - `gemini-3.1-flash-image-preview`: priced with the 2.5 flash-image line
8840
- * pending a distinct published rate.
8841
- * - `gemini-3.1-flash-image`: $0.067 at 1K.
8842
- * - `gemini-3.1-flash-lite-image`: ≈ $0.0336 at 1K.
8843
- * Source: https://ai.google.dev/gemini-api/docs/pricing
8844
- */
8845
- const OPENROUTER_IMAGE_PRICE_USD = {
8846
- "gemini-2.5-flash-image": .039,
8847
- "gemini-3.1-flash-image-preview": .039,
8848
- "gemini-3-pro-image-preview": .134,
8849
- "gemini-3.1-flash-image": .067,
8850
- "gemini-3-pro-image": .134,
8851
- "gemini-3.1-flash-lite-image": .0336
8852
- };
8853
- /**
8854
- * Resolve a Motif model name to an OpenRouter slug. A bare Gemini name maps to
8855
- * its `google/<id>` slug; a name already containing `/` is an OpenRouter slug
8856
- * and passes through. Anything else is unknown and throws.
8857
- */
8858
- function openRouterModelSlug(modelId) {
8859
- if (modelId.includes("/")) return modelId;
8860
- const slug = OPENROUTER_GEMINI_IMAGE_MODELS[modelId];
8861
- 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);
8862
- return slug;
8863
- }
8864
- function isRecord$3(value) {
8865
- return typeof value === "object" && value !== null;
8866
- }
8867
- function toDataUrl(mediaType, data) {
8868
- return `data:${mediaType};base64,${typeof data === "string" ? data : Buffer.from(data).toString("base64")}`;
8869
- }
8870
- /** One `input_references` entry for an input file: a URL as-is, bytes as a data URL. */
8871
- function toInputReference(file) {
8872
- return {
8873
- type: "image_url",
8874
- image_url: { url: file.type === "url" ? file.url : toDataUrl(file.mediaType, file.data) }
8875
- };
8876
- }
8877
- function buildBody(slug, options) {
8878
- if (options.mask !== void 0) throw new MotifError("OpenRouter's Image API takes no mask; describe the region in the instruction instead.", 0);
8879
- return {
8880
- ...options.providerOptions[METADATA_KEY] ?? {},
8881
- model: slug,
8882
- prompt: options.prompt,
8883
- n: options.n,
8884
- ...options.aspectRatio === void 0 ? {} : { aspect_ratio: options.aspectRatio },
8885
- ...options.files === void 0 || options.files.length === 0 ? {} : { input_references: options.files.map(toInputReference) }
8886
- };
8887
- }
8888
- function errorMessage(body, fallback) {
8889
- if (isRecord$3(body) && isRecord$3(body.error)) {
8890
- const { message } = body.error;
8891
- if (typeof message === "string" && message !== "") return message;
8892
- }
8893
- return fallback;
8894
- }
8895
- function parseResponse(body) {
8896
- if (!isRecord$3(body) || !Array.isArray(body.data)) throw new MotifError("OpenRouter image response had no data array", 502);
8897
- const images = [];
8898
- for (const entry of body.data) if (isRecord$3(entry) && typeof entry.b64_json === "string") images.push(entry.b64_json);
8899
- if (images.length === 0) throw new MotifError("OpenRouter returned no images", 502);
8900
- return {
8901
- images,
8902
- cost: isRecord$3(body.usage) && typeof body.usage.cost === "number" ? body.usage.cost : void 0
8903
- };
8904
- }
8905
- function buildModel(modelId, apiKey, doFetch) {
8906
- const slug = openRouterModelSlug(modelId);
8907
- return {
8908
- specificationVersion: "v4",
8909
- provider: METADATA_KEY,
8910
- modelId: slug,
8911
- maxImagesPerCall: 10,
8912
- async doGenerate(options) {
8913
- const warnings = [];
8914
- if (options.seed !== void 0) warnings.push({
8915
- type: "unsupported",
8916
- feature: "seed"
8917
- });
8918
- if (options.size !== void 0) warnings.push({
8919
- type: "unsupported",
8920
- feature: "size",
8921
- details: "Gemini on OpenRouter takes aspectRatio, not pixel sizes."
8922
- });
8923
- const timestamp = /* @__PURE__ */ new Date();
8924
- const response = await doFetch(OPENROUTER_IMAGES_URL, {
8925
- method: "POST",
8926
- headers: {
8927
- ...options.headers,
8928
- Authorization: `Bearer ${apiKey}`,
8929
- "Content-Type": "application/json"
8930
- },
8931
- body: JSON.stringify(buildBody(slug, options)),
8932
- ...options.abortSignal === void 0 ? {} : { signal: options.abortSignal }
8933
- });
8934
- const text = await response.text();
8935
- let body;
8936
- try {
8937
- body = JSON.parse(text);
8938
- } catch {
8939
- body = void 0;
8940
- }
8941
- if (!response.ok) throw new MotifError(errorMessage(body, `OpenRouter ${response.status}: ${text}`), response.status);
8942
- const { images, cost } = parseResponse(body);
8943
- return {
8944
- images,
8945
- warnings,
8946
- providerMetadata: { [METADATA_KEY]: {
8947
- images: images.map(() => ({})),
8948
- ...cost === void 0 ? {} : { cost }
8949
- } },
8950
- response: {
8951
- timestamp,
8952
- modelId: slug,
8953
- headers: Object.fromEntries(response.headers.entries())
8954
- }
8955
- };
8956
- }
8957
- };
8958
- }
8959
- /**
8960
- * Build an OpenRouter `ImageModel`. Prefers the passed `apiKey`, else the
8961
- * `OPENROUTER_API_KEY` env var. Throws `MotifError` when neither is present or
8962
- * the model name is unknown (callers translate this into a `Result.err`).
8963
- */
8964
- function resolveModel$1(modelId, apiKey, fetch) {
8965
- const key = apiKey ?? process.env["OPENROUTER_API_KEY"];
8966
- if (key === void 0 || key === "") throw new MotifError(`OpenRouter image generation requires an API key (config.openrouter.apiKey or ${OPENROUTER_API_KEY_ENV})`, 0);
8967
- const configured = toProviderFetch(fetch);
8968
- return buildModel(modelId, key, "fetch" in configured ? configured.fetch : globalThis.fetch);
8969
- }
8970
- /** The OpenRouter provider adapter registered in the provider registry. */
8971
- const openrouterAdapter = {
8972
- id: "openrouter",
8973
- apiKeyEnv: OPENROUTER_API_KEY_ENV,
8974
- resolveModel: resolveModel$1,
8975
- priceUsdByModel: OPENROUTER_IMAGE_PRICE_USD
8976
- };
8977
- //#endregion
8978
- //#region src/image/replicate.ts
8979
- /**
8980
- * Replicate provider adapter.
8981
- *
8982
- * Builds a Vercel AI SDK `ImageModel` from `@ai-sdk/replicate`. Building a model
8983
- * performs no network I/O — the request only happens when `generateImage`
8984
- * invokes `model.doGenerate`.
8985
- *
8986
- * NOTE: Replicate's SDK names the credential option `apiToken` (not `apiKey`),
8987
- * and reads `REPLICATE_API_TOKEN` from the environment.
8988
- */
8989
- /** Env var read for the Replicate API token when `apiToken` is not in config. */
8990
- const REPLICATE_API_KEY_ENV = "REPLICATE_API_TOKEN";
8991
- /**
8992
- * Static Replicate USD/image, keyed by model id.
8993
- *
8994
- * `black-forest-labs/flux-1.1-pro-ultra` = $0.06/image (Replicate list price).
8995
- * Source: https://replicate.com/black-forest-labs/flux-1.1-pro-ultra
8996
- * Sanity anchor: fal's `fal-ai/flux-pro/v1.1-ultra` is also $0.06
8997
- * (`MODELS.flux.pricePerImageUsd` in ../models) — same price, both routes.
8998
- */
8999
- const REPLICATE_IMAGE_PRICE_USD = { "black-forest-labs/flux-1.1-pro-ultra": .06 };
9000
- /**
9001
- * Build a Replicate `ImageModel`. Prefers the passed `apiKey` (the normalized
9002
- * token), else the `REPLICATE_API_TOKEN` env var. Throws `MotifError` when
9003
- * neither is present (callers translate this into a `Result.err`).
9004
- */
9005
- function resolveModel(modelId, apiKey, fetch) {
9006
- const token = apiKey ?? process.env["REPLICATE_API_TOKEN"];
9007
- if (token === void 0 || token === "") throw new MotifError(`Replicate image generation requires an API token (config.replicate.apiToken or ${REPLICATE_API_KEY_ENV})`, 0);
9008
- return createReplicate({
9009
- apiToken: token,
9010
- ...toProviderFetch(fetch)
9011
- }).image(modelId);
9012
- }
9013
- //#endregion
9014
- //#region src/image/provider.ts
9015
- /**
9016
- * The provider registry. Every id in {@link ImageProviderId}'s closed part maps
9017
- * to its adapter; the open union tail means a lookup can still miss, so access
9018
- * goes through {@link getProviderAdapter}.
9019
- */
9020
- const PROVIDERS = {
9021
- openrouter: openrouterAdapter,
9022
- openai: openaiAdapter,
9023
- replicate: {
9024
- id: "replicate",
9025
- apiKeyEnv: REPLICATE_API_KEY_ENV,
9026
- resolveModel,
9027
- priceUsdByModel: REPLICATE_IMAGE_PRICE_USD
9028
- },
9029
- fal: falAdapter
9030
- };
9031
- /**
9032
- * Look up an adapter by provider id, throwing a `MotifError` for an unknown
9033
- * provider (callers catch this into a `Result.err`).
9034
- */
9035
- function getProviderAdapter(provider) {
9036
- const adapter = PROVIDERS[provider];
9037
- if (adapter === void 0) throw new MotifError(`Unsupported image provider: ${provider}`, 0);
9038
- return adapter;
9039
- }
9040
- //#endregion
9041
- //#region src/image/cost.ts
9042
- /**
9043
- * Per-call cost tracking for the image layer.
9044
- *
9045
- * Preference order:
9046
- * 1. A cost surfaced by the provider on `result.providerMetadata` (most image
9047
- * providers do NOT surface one today, so this is usually absent).
9048
- * 2. A static per-model table, owned per-adapter (`priceUsdByModel`) and read
9049
- * from the provider registry (see sources in each adapter).
9050
- * 3. Unknown → `{ usd: 0, source: "unknown" }`.
9051
- */
9052
- function isRecord$2(value) {
9053
- return typeof value === "object" && value !== null;
9054
- }
9055
- /**
9056
- * Extract a provider-reported total cost from a `generateImage` result's
9057
- * `providerMetadata`, if the provider surfaces a numeric `cost`. The shape is
9058
- * `{ [provider]: { ...; cost?: number } }`. Returns undefined otherwise.
9059
- */
9060
- function costFromProviderMetadata(providerMetadata) {
9061
- if (!isRecord$2(providerMetadata)) return;
9062
- for (const value of Object.values(providerMetadata)) if (isRecord$2(value)) {
9063
- const cost = value.cost;
9064
- if (typeof cost === "number" && Number.isFinite(cost)) return cost;
9065
- }
9066
- }
9067
- function tablePricePerImage(provider, modelId) {
9068
- const adapter = PROVIDERS[provider];
9069
- if (adapter === void 0) return;
9070
- return adapter.priceUsdByModel[modelId];
9071
- }
9072
- function roundUsd(value) {
9073
- return Number(value.toFixed(6));
9074
- }
9075
- /**
9076
- * Cost across every underlying model call of one generation. Provider-metadata
9077
- * costs from the calls that report one are summed; otherwise the static table
9078
- * (× image count), then unknown.
9079
- */
9080
- function costForCalls(provider, modelId, callMetadata, imageCount) {
9081
- let metaCost;
9082
- for (const providerMetadata of callMetadata) {
9083
- const callCost = costFromProviderMetadata(providerMetadata);
9084
- if (callCost !== void 0) metaCost = (metaCost ?? 0) + callCost;
9085
- }
9086
- if (metaCost !== void 0) return {
9087
- usd: roundUsd(metaCost),
9088
- source: "provider-metadata"
9089
- };
9090
- const perImage = tablePricePerImage(provider, modelId);
9091
- if (perImage !== void 0) return {
9092
- usd: roundUsd(perImage * Math.max(imageCount, 1)),
9093
- source: "table"
9094
- };
9095
- return {
9096
- usd: 0,
9097
- source: "unknown"
9098
- };
9099
- }
9100
- //#endregion
9101
- //#region src/image/index.ts
9102
- /**
9103
- * `@howells/motif-sdk/image` — provider-agnostic image generation + editing.
9104
- *
9105
- * ESM-only subpath export, built on the Vercel AI SDK image interface
9106
- * (`generateImage`, `@ai-sdk/*`). The caller names the provider and model;
9107
- * reuses the SDK's Result convention (`Result<T, MotifError>` — no
9108
- * thrown exceptions). Gemini is reached through OpenRouter.
9109
- *
9110
- * @example
9111
- * ```ts
9112
- * import { createMotifImage } from "@howells/motif-sdk/image";
9113
- *
9114
- * const img = createMotifImage({ defaultProvider: "openrouter" });
9115
- * const r = await img.generate({ model: "gemini-3.1-flash-image", prompt: "a bare concrete wall" });
9116
- * if (r.isOk()) console.log(r.value.images[0].mediaType, r.value.cost);
9117
- * ```
9118
- */
9119
- const DEFAULT_PROVIDER = "openrouter";
9120
- /**
9121
- * Create a provider-agnostic image client.
9122
- *
9123
- * @param config - provider selection + keys (keys fall back to env).
9124
- * @param deps - INTERNAL testing seam. Defaults to the real AI SDK `generateImage`
9125
- * and the built-in provider adapters; tests inject fakes here to run offline.
9126
- */
9127
- function createMotifImage(config = {}, deps = {}) {
9128
- const generateImageFn = deps.generateImage ?? generateImage;
9129
- const resolveModelFn = deps.resolveModel ?? defaultResolveModel;
9130
- function resolveProvider(provider) {
9131
- return provider ?? config.defaultProvider ?? DEFAULT_PROVIDER;
9132
- }
9133
- function apiKeyFor(provider) {
9134
- switch (provider) {
9135
- case "openrouter": return config.openrouter?.apiKey;
9136
- case "openai": return config.openai?.apiKey;
9137
- case "replicate": return config.replicate?.apiToken;
9138
- case "fal": return config.fal?.apiKey;
9139
- default: return;
9140
- }
9141
- }
9142
- async function generate(opts) {
9143
- const provider = resolveProvider(opts.provider);
9144
- try {
9145
- const modelId = opts.model;
9146
- const model = resolveModelFn(provider, modelId, apiKeyFor(provider), config.fetch);
9147
- const result = await generateImageFn({
9148
- model,
9149
- prompt: opts.prompt,
9150
- ...opts.n === void 0 ? {} : { n: opts.n },
9151
- ...opts.size === void 0 ? {} : { size: opts.size },
9152
- ...opts.aspectRatio === void 0 ? {} : { aspectRatio: opts.aspectRatio },
9153
- ...opts.seed === void 0 ? {} : { seed: opts.seed },
9154
- ...config.maxRetries === void 0 ? {} : { maxRetries: config.maxRetries },
9155
- ...opts.signal === void 0 ? {} : { abortSignal: opts.signal },
9156
- ...opts.headers === void 0 ? {} : { headers: opts.headers },
9157
- ...opts.providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(opts.providerOptions) }
9158
- });
9159
- return ok$1(toMotifImageResult(result, provider, modelId));
9160
- } catch (error) {
9161
- return err$1(toMotifError(error));
9162
- }
9163
- }
9164
- async function edit(opts) {
9165
- const provider = resolveProvider(opts.provider);
9166
- const imagesField = provider === "fal" ? falEditImagesField(opts.model) : void 0;
9167
- 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));
9168
- const providerOptions = imagesField === "image_urls" && opts.providerOptions?.fal?.useMultipleImages === void 0 ? {
9169
- ...opts.providerOptions,
9170
- fal: {
9171
- ...opts.providerOptions?.fal,
9172
- useMultipleImages: true
9173
- }
9174
- } : opts.providerOptions;
9175
- try {
9176
- const modelId = opts.model;
9177
- const model = resolveModelFn(provider, modelId, apiKeyFor(provider), config.fetch);
9178
- const result = await generateImageFn({
9179
- model,
9180
- prompt: {
9181
- images: opts.images,
9182
- text: opts.instruction,
9183
- ...opts.mask === void 0 ? {} : { mask: opts.mask }
9184
- },
9185
- ...opts.n === void 0 ? {} : { n: opts.n },
9186
- ...opts.seed === void 0 ? {} : { seed: opts.seed },
9187
- ...config.maxRetries === void 0 ? {} : { maxRetries: config.maxRetries },
9188
- ...opts.signal === void 0 ? {} : { abortSignal: opts.signal },
9189
- ...opts.headers === void 0 ? {} : { headers: opts.headers },
9190
- ...providerOptions === void 0 ? {} : { providerOptions: toProviderOptions(providerOptions) }
9191
- });
9192
- return ok$1(toMotifImageResult(result, provider, modelId));
9193
- } catch (error) {
9194
- return err$1(toMotifError(error));
9195
- }
9196
- }
9197
- async function bestOfN(opts) {
9198
- return await runBestOfN(opts, generate, edit);
9199
- }
9200
- return {
9201
- generate,
9202
- edit,
9203
- bestOfN
9204
- };
9205
- }
9206
- /**
9207
- * Fire one best-of-N candidate: reuse `generate`/`edit` (discriminated by the
9208
- * presence of `images`, narrowed inline so TS refines the union). Each candidate
9209
- * makes a single image (`n: 1`) — the outer `n` is the candidate count, not the
9210
- * per-call image count — and takes a distinct `seed` (`seed + index`) when a seed
9211
- * was given, so the N vary. `opts.signal` rides along via the spread, so one
9212
- * cancel aborts every candidate.
9213
- */
9214
- async function runCandidate(opts, index, generate, edit) {
9215
- const candidateSeed = opts.seed === void 0 ? void 0 : opts.seed + index;
9216
- const overrides = {
9217
- n: 1,
9218
- ...candidateSeed === void 0 ? {} : { seed: candidateSeed }
9219
- };
9220
- if ("images" in opts) return await edit({
9221
- ...opts,
9222
- ...overrides
9223
- });
9224
- return await generate({
9225
- ...opts,
9226
- ...overrides
9227
- });
9228
- }
9229
- /** Ask the judge to pick a winner and validate its index; no judge → index 0. */
9230
- async function selectWinner(opts, candidates) {
9231
- const { judge } = opts;
9232
- if (judge === void 0) return ok$1({ chosenIndex: 0 });
9233
- const decision = await judge(candidates, "images" in opts ? { instruction: opts.instruction } : { prompt: opts.prompt });
9234
- if (!Number.isInteger(decision.index) || decision.index < 0 || decision.index >= candidates.length) return err$1(new MotifError(`bestOfN judge returned an out-of-range index ${decision.index} (expected 0..${candidates.length - 1})`, 0));
9235
- return ok$1({
9236
- chosenIndex: decision.index,
9237
- ...decision.reason === void 0 ? {} : { reason: decision.reason }
9238
- });
9239
- }
9240
- /**
9241
- * Best-of-N: generate `n` candidates in parallel, keep the successes, and pick a
9242
- * winner via the (optional, injectable) judge. Wraps everything so no throw
9243
- * escapes — any failure becomes a `Result.err`.
9244
- */
9245
- async function runBestOfN(opts, generate, edit) {
9246
- try {
9247
- const { n } = opts;
9248
- if (!Number.isInteger(n) || n < 1) return err$1(new MotifError(`bestOfN requires an integer n >= 1 (got ${n})`, 0));
9249
- const results = await Promise.all(Array.from({ length: n }, (_unused, index) => index).map(async (index) => await runCandidate(opts, index, generate, edit)));
9250
- const candidates = [];
9251
- let firstError;
9252
- for (const result of results) if (result.isOk()) candidates.push(result.value);
9253
- else firstError ??= result.error;
9254
- if (candidates.length === 0) return err$1(firstError ?? new MotifError(`all ${n} bestOfN candidates failed`, 0));
9255
- const winner = await selectWinner(opts, candidates);
9256
- if (winner.isErr()) return err$1(winner.error);
9257
- const { chosenIndex, reason } = winner.value;
9258
- const best = candidates[chosenIndex];
9259
- if (best === void 0) return err$1(new MotifError("bestOfN failed to resolve a winner", 0));
9260
- const totalCostUsd = Number(candidates.reduce((sum, candidate) => sum + candidate.cost.usd, 0).toFixed(6));
9261
- return ok$1({
9262
- best,
9263
- chosenIndex,
9264
- ...reason === void 0 ? {} : { reason },
9265
- candidates,
9266
- totalCostUsd
9267
- });
9268
- } catch (error) {
9269
- return err$1(toMotifError(error));
9270
- }
9271
- }
9272
- /** Default provider dispatch: resolve the model through the provider registry. */
9273
- function defaultResolveModel(provider, modelId, apiKey, fetch) {
9274
- return getProviderAdapter(provider).resolveModel(modelId, apiKey, fetch);
9275
- }
9276
- /** Map the AI SDK result → normalized MotifImageResult (with cost + requestId). */
9277
- function toMotifImageResult(result, provider, model) {
9278
- const images = result.images.map((file) => ({
9279
- uint8Array: file.uint8Array,
9280
- base64: file.base64,
9281
- mediaType: file.mediaType
9282
- }));
9283
- const cost = costForCalls(provider, model, result.calls.map((call) => call.providerMetadata), images.length);
9284
- const requestId = extractRequestId(result);
9285
- const warnings = result.warnings.map(renderWarning);
9286
- return {
9287
- images,
9288
- cost,
9289
- provider,
9290
- model,
9291
- ...requestId === void 0 ? {} : { requestId },
9292
- ...warnings.length === 0 ? {} : { warnings }
9293
- };
9294
- }
9295
- /** Render an AI SDK `Warning` object into a readable one-line string. */
9296
- function renderWarning(warning) {
9297
- if (warning.type === "unsupported" || warning.type === "compatibility") {
9298
- const label = warning.type === "unsupported" ? "unsupported feature" : "compatibility mode for feature";
9299
- return warning.details === void 0 ? `${label} "${warning.feature}"` : `${label} "${warning.feature}": ${warning.details}`;
9300
- }
9301
- if (warning.type === "deprecated") return `deprecated setting "${warning.setting}": ${warning.message}`;
9302
- return warning.message;
9303
- }
9304
- function isRecord$1(value) {
9305
- return typeof value === "object" && value !== null;
9306
- }
9307
- /** Look for a provider correlation id in providerMetadata, then response headers. */
9308
- function extractRequestId(result) {
9309
- for (const call of result.calls) {
9310
- const fromMetadata = requestIdFromMetadata(call.providerMetadata);
9311
- if (fromMetadata !== void 0) return fromMetadata;
9312
- }
9313
- for (const { response } of result.calls) {
9314
- const { headers } = response;
9315
- if (headers) {
9316
- const id = headers["x-request-id"] ?? headers["x-goog-request-id"] ?? headers["x-fal-request-id"];
9317
- if (typeof id === "string" && id !== "") return id;
9318
- }
9319
- }
9320
- }
9321
- function requestIdFromMetadata(providerMetadata) {
9322
- if (!isRecord$1(providerMetadata)) return;
9323
- for (const value of Object.values(providerMetadata)) if (isRecord$1(value)) {
9324
- const id = value.requestId ?? value.request_id;
9325
- if (typeof id === "string" && id !== "") return id;
9326
- }
9327
- }
9328
- /**
9329
- * Coerce a public `providerOptions` (values typed `unknown`) into the AI SDK's
9330
- * JSON-only `Record<string, Record<string, JSONValue>>`. Fails fast: a
9331
- * non-JSON-representable value (undefined/function/bigint/symbol) throws a
9332
- * `MotifError` naming the offending path rather than silently forwarding a value
9333
- * the caller did not intend.
9334
- */
9335
- function toProviderOptions(input) {
9336
- const out = {};
9337
- for (const [namespace, options] of Object.entries(input)) {
9338
- const inner = {};
9339
- for (const [key, value] of Object.entries(options)) inner[key] = toJsonValue(value, `providerOptions.${namespace}.${key}`);
9340
- out[namespace] = inner;
9341
- }
9342
- return out;
9343
- }
9344
- function toJsonValue(value, path) {
9345
- if (value === null) return null;
9346
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
9347
- if (typeof value === "object") {
9348
- if (Array.isArray(value)) {
9349
- const arr = [];
9350
- for (const [index, item] of value.entries()) arr.push(toJsonValue(item, `${path}[${index}]`));
9351
- return arr;
9352
- }
9353
- const obj = {};
9354
- for (const [key, entry] of Object.entries(value)) obj[key] = toJsonValue(entry, `${path}.${key}`);
9355
- return obj;
9356
- }
9357
- throw new MotifError(`providerOptions value at ${path} is not JSON-representable (type: ${typeof value})`, 0);
9358
- }
9359
- /**
9360
- * Coerce an unknown thrown value into a `MotifError`, mirroring server.ts's
9361
- * mapping: preserve an existing `MotifError`, lift a string `code`, and mark
9362
- * non-HTTP local failures with status `0`.
9363
- */
9364
- function toMotifError(error) {
9365
- if (error instanceof MotifError) return error;
9366
- const message = error instanceof Error ? error.message : String(error);
9367
- const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : void 0;
9368
- const status = error instanceof Error && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : 0;
9369
- const body = error instanceof Error && "responseBody" in error && typeof error.responseBody === "string" ? error.responseBody : message;
9370
- if (isFalAccountLocked(status, body)) return falHttpError(status, body);
9371
- return new MotifError(message, status, code);
9372
- }
9373
- //#endregion
9374
8793
  //#region src/task-output.ts
9375
8794
  function isPlainRecord(value) {
9376
8795
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -9664,14 +9083,14 @@ function projectedOutputs(model, input, body) {
9664
9083
  }
9665
9084
  function outputSize(parameters, input, imageSize) {
9666
9085
  if (typeof imageSize === "string") return FAL_PRESET_SIZES[imageSize];
9667
- if (isPixelSize$1(imageSize)) return imageSize;
9086
+ if (isPixelSize(imageSize)) return imageSize;
9668
9087
  if (imageSize === void 0) {
9669
9088
  const fallback = parameters.find((parameter) => parameter.key === "image_size")?.fallback;
9670
9089
  if (fallback === void 0) return sourceSizeOf(input);
9671
9090
  return typeof fallback === "string" ? FAL_PRESET_SIZES[fallback] : void 0;
9672
9091
  }
9673
9092
  }
9674
- function isPixelSize$1(value) {
9093
+ function isPixelSize(value) {
9675
9094
  return typeof value === "object" && value !== null && "width" in value && "height" in value && typeof value.width === "number" && typeof value.height === "number";
9676
9095
  }
9677
9096
  /** What the plan knows of the source, for prices that read it. */
@@ -10039,11 +9458,12 @@ function taskRequest(task, input) {
10039
9458
  aspect: input.aspect,
10040
9459
  count: input.count,
10041
9460
  look: task === "generate" || task === "vary" ? input.look : void 0,
9461
+ markup: input.markup !== void 0,
10042
9462
  mask: input.mask !== void 0,
10043
9463
  mode: input.mode,
10044
9464
  model: input.model,
10045
9465
  negativePrompt: input.negativePrompt !== void 0,
10046
- outputFormat: input.outputFormat !== void 0,
9466
+ outputFormat: input.outputFormat,
10047
9467
  references,
10048
9468
  resolution: (task === "generate" || task === "vary") && input.resolution === "2K" ? void 0 : input.resolution,
10049
9469
  rig: input.rig,
@@ -10056,7 +9476,6 @@ function taskRequest(task, input) {
10056
9476
  function resolve(task, input, context, options) {
10057
9477
  const keys = [];
10058
9478
  if (options.dryRun === true || context.falKey !== void 0) keys.push("FAL_KEY");
10059
- if (options.dryRun === true || context.openAiKey !== void 0) keys.push("OPENAI_API_KEY");
10060
9479
  const resolution = resolveTask(task, taskRequest(task, input), {
10061
9480
  keys,
10062
9481
  pins: context.pins
@@ -10131,7 +9550,7 @@ function withCode(error, model, task) {
10131
9550
  field: error.field,
10132
9551
  task
10133
9552
  });
10134
- const motif = toMotifError$1(error);
9553
+ const motif = toMotifError(error);
10135
9554
  if (motif.code !== void 0) return motif;
10136
9555
  return new MotifError(motif.message, 0, INVALID_OPTION, void 0, {
10137
9556
  model,
@@ -10154,6 +9573,7 @@ const GENERATION_FIELDS = /* @__PURE__ */ new Set([
10154
9573
  "aspect",
10155
9574
  "count",
10156
9575
  "look",
9576
+ "markup",
10157
9577
  "mask",
10158
9578
  "mood",
10159
9579
  "negativePrompt",
@@ -10164,11 +9584,23 @@ const GENERATION_FIELDS = /* @__PURE__ */ new Set([
10164
9584
  "seed",
10165
9585
  "transparent"
10166
9586
  ]);
9587
+ const IMAGE_QUALITIES = [
9588
+ "auto",
9589
+ "low",
9590
+ "medium",
9591
+ "high",
9592
+ "xhigh",
9593
+ "max"
9594
+ ];
9595
+ function isImageQuality(value) {
9596
+ return IMAGE_QUALITIES.some((level) => level === value);
9597
+ }
10167
9598
  /** The TaskInput field behind each option `buildGenerateBody` can refuse. */
10168
9599
  const FIELD_FOR_OPTION = {
10169
9600
  aspect: "aspect",
10170
9601
  background: "transparent",
10171
9602
  "image editing": "references",
9603
+ markupImageUrl: "markup",
10172
9604
  maskImageUrl: "mask",
10173
9605
  negativePrompt: "negativePrompt",
10174
9606
  numImages: "count",
@@ -10208,23 +9640,34 @@ function generationPlan(task, model, config, input) {
10208
9640
  task
10209
9641
  }));
10210
9642
  const editImageUrls = [...task === "vary" && input.image !== void 0 ? [input.image] : [], ...input.references ?? []];
9643
+ if (input.markup !== void 0 && editImageUrls.length === 0) return err$1(invalidOption("Markup applies to the image being edited; pass references.", {
9644
+ field: "markup",
9645
+ requires: "references"
9646
+ }));
10211
9647
  if (input.mask !== void 0 && editImageUrls.length === 0) return err$1(invalidOption("A mask applies to a reference image; pass references.", {
10212
9648
  field: "mask",
10213
9649
  requires: "references"
10214
9650
  }));
10215
- if (input.transparent === true && config.supportsBackground !== true && config.transparencyRoute !== void 0) return openAiPlan(task, model, config.transparencyRoute, input, editImageUrls);
10216
- if (input.outputFormat !== void 0 && config.supportedOutputFormats !== void 0 && !config.supportedOutputFormats.includes(input.outputFormat)) return err$1(cannotCarry("outputFormat", model, task));
9651
+ if (input.outputFormat !== void 0 && !outputFormatsOf(config, editImageUrls.length > 0).includes(input.outputFormat)) return err$1(cannotCarry("outputFormat", model, task));
9652
+ const { quality, ...params } = input.params ?? {};
9653
+ const liftQuality = config.supportsQuality === true && quality !== void 0;
9654
+ if (liftQuality && !isImageQuality(quality)) return err$1(invalidOption(`params.quality must be one of ${IMAGE_QUALITIES.join(", ")}.`, {
9655
+ field: "params",
9656
+ key: "quality"
9657
+ }));
10217
9658
  const options = {
10218
9659
  aspect: input.aspect,
10219
9660
  background: input.transparent === true ? "transparent" : void 0,
10220
9661
  creative: creativeOf(input),
10221
9662
  editImageUrls: editImageUrls.length > 0 ? editImageUrls : void 0,
9663
+ markupImageUrl: input.markup,
10222
9664
  maskImageUrl: input.mask,
10223
9665
  model,
10224
9666
  negativePrompt: input.negativePrompt,
10225
9667
  numImages: input.count,
10226
9668
  outputFormat: input.outputFormat,
10227
9669
  prompt: task === "vary" && (input.prompt ?? "") === "" ? VARY_PROMPT : input.prompt ?? "",
9670
+ quality: liftQuality && isImageQuality(quality) ? quality : void 0,
10228
9671
  resolution: input.resolution,
10229
9672
  seed: input.seed
10230
9673
  };
@@ -10234,7 +9677,7 @@ function generationPlan(task, model, config, input) {
10234
9677
  } catch (error) {
10235
9678
  return err$1(generationError(error, model, task));
10236
9679
  }
10237
- const merged = mergeParams(built.body, input.params, ownedGenerationKeys(input));
9680
+ const merged = mergeParams(built.body, liftQuality ? params : input.params, ownedGenerationKeys(input));
10238
9681
  if (merged.isErr()) return err$1(merged.error);
10239
9682
  const badSize = checkParamsImageSize(config, model, task, input.params);
10240
9683
  if (badSize !== void 0) return err$1(badSize);
@@ -10247,55 +9690,6 @@ function generationPlan(task, model, config, input) {
10247
9690
  queued: config.useQueue === true || input.onProgress !== void 0 || input.onSubmitted !== void 0
10248
9691
  });
10249
9692
  }
10250
- /**
10251
- * The OpenAI transparency route. Ported from the CLI's generate-openai: the
10252
- * route carries only what gpt-image takes, so everything else is refused.
10253
- */
10254
- function openAiPlan(task, model, route, input, editImageUrls) {
10255
- const allowed = /* @__PURE__ */ new Set([
10256
- "aspect",
10257
- "count",
10258
- "look",
10259
- "mask",
10260
- "mood",
10261
- "prompt",
10262
- "references",
10263
- "transparent"
10264
- ]);
10265
- if (task === "vary") allowed.add("image");
10266
- if (input.outputFormat === "png") allowed.add("outputFormat");
10267
- const refused = refuseOthers(input, allowed, model, task);
10268
- if (refused !== void 0) return err$1(refused);
10269
- if (input.params !== void 0) return err$1(cannotCarry("params", model, task));
10270
- const editing = editImageUrls.length > 0;
10271
- if (editing && !route.supportsEdit) return err$1(cannotCarry("references", model, task));
10272
- let prompt;
10273
- try {
10274
- const creative = creativeOf(input);
10275
- prompt = creative === void 0 ? input.prompt ?? "" : enrichPrompt({
10276
- creative,
10277
- prompt: input.prompt ?? ""
10278
- }).prompt;
10279
- } catch (error) {
10280
- return err$1(withCode(error, model, task));
10281
- }
10282
- return ok$1({
10283
- body: {
10284
- n: input.count ?? 1,
10285
- prompt,
10286
- size: aspectToGptSize(input.aspect ?? "1:1"),
10287
- ...editing && { images: [...editImageUrls] },
10288
- ...input.mask !== void 0 && { mask: input.mask },
10289
- background: "transparent",
10290
- outputFormat: "png"
10291
- },
10292
- cost: UNKNOWN_COST,
10293
- endpoint: `${route.provider}:${route.model}`,
10294
- prompt,
10295
- provider: "openai",
10296
- queued: false
10297
- });
10298
- }
10299
9693
  function flatPrice(pricing) {
10300
9694
  const match = FLAT_PRICE_REGEX.exec(pricing.trim());
10301
9695
  return match === null ? null : Number(match[1]);
@@ -10347,7 +9741,7 @@ function parsePayload(text) {
10347
9741
  if (JSON_START.test(text.trim())) throw error;
10348
9742
  raw = text;
10349
9743
  }
10350
- if (!isRecord$4(raw) || Array.isArray(raw)) return {
9744
+ if (!isRecord$1(raw) || Array.isArray(raw)) return {
10351
9745
  raw,
10352
9746
  files: []
10353
9747
  };
@@ -10620,8 +10014,8 @@ function enforceBufferLimit(length, requestId) {
10620
10014
  /**
10621
10015
  * The Task client: one function per Task. `createMotif()` returns a client
10622
10016
  * whose `generate`, `erase`, `upscale` and the rest resolve the Model through
10623
- * `resolveTask`, build the request that Model needs, run it on fal (or OpenAI
10624
- * for a transparency route) and return the files and fields it produced.
10017
+ * `resolveTask`, build the request that Model needs, run it on fal and return
10018
+ * the files and fields it produced.
10625
10019
  *
10626
10020
  * @example
10627
10021
  * ```ts
@@ -10631,7 +10025,6 @@ function enforceBufferLimit(length, requestId) {
10631
10025
  * ```
10632
10026
  */
10633
10027
  const MISSING_API_KEY = "MISSING_API_KEY";
10634
- const PIXEL_SIZE_REGEX = /^\d+x\d+$/;
10635
10028
  function presentKey(value) {
10636
10029
  return value === void 0 || value === "" ? void 0 : value;
10637
10030
  }
@@ -10649,9 +10042,6 @@ function outputKeysFor(model, body) {
10649
10042
  if (model === "clarity" || model === "crystal") return ["image"];
10650
10043
  return ["images"];
10651
10044
  }
10652
- function isPixelSize(size) {
10653
- return PIXEL_SIZE_REGEX.test(size);
10654
- }
10655
10045
  function isRecord(value) {
10656
10046
  return typeof value === "object" && value !== null;
10657
10047
  }
@@ -10702,106 +10092,6 @@ function falOutput(plan, result) {
10702
10092
  tier: plan.tier
10703
10093
  };
10704
10094
  }
10705
- const DEFAULT_TIMEOUT_MS = 12e4;
10706
- /**
10707
- * Remote images as bytes, fetched through the configured seam: the AI SDK
10708
- * would otherwise download them with global fetch. Data URLs pass through.
10709
- */
10710
- async function localImages(sources, fetch) {
10711
- const images = [];
10712
- for (const source of sources) {
10713
- if (!source.startsWith("https://") && !source.startsWith("http://")) {
10714
- images.push(source);
10715
- continue;
10716
- }
10717
- try {
10718
- const response = await fetch(source, { method: "GET" });
10719
- if (!response.ok) return err$1(new MotifError(`Could not download ${source}: ${response.status}`, response.status, "DOWNLOAD_FAILED"));
10720
- images.push(new Uint8Array(await response.arrayBuffer()));
10721
- } catch (error) {
10722
- return err$1(new MotifError(`Could not download ${source}: ${error instanceof Error ? error.message : String(error)}`, 0, "DOWNLOAD_FAILED"));
10723
- }
10724
- }
10725
- return ok$1(images);
10726
- }
10727
- async function openAiOutput(plan, openAiKey, config) {
10728
- const fetch = config.fetch ?? (async (url, init) => await globalThis.fetch(url, init));
10729
- const client = createMotifImage({
10730
- defaultProvider: "openai",
10731
- fetch,
10732
- openai: { apiKey: openAiKey },
10733
- ...config.retries !== void 0 && { maxRetries: config.retries }
10734
- });
10735
- const signal = AbortSignal.timeout(config.timeout ?? DEFAULT_TIMEOUT_MS);
10736
- const { body } = plan;
10737
- const [provider, model = ""] = plan.endpoint.split(":");
10738
- const n = typeof body.n === "number" ? body.n : 1;
10739
- const prompt = typeof body.prompt === "string" ? body.prompt : "";
10740
- const providerOptions = { openai: {
10741
- background: "transparent",
10742
- outputFormat: "png"
10743
- } };
10744
- const sources = Array.isArray(body.images) ? body.images.filter((item) => typeof item === "string") : [];
10745
- const mask = typeof body.mask === "string" ? [body.mask] : [];
10746
- const downloaded = await localImages([...sources, ...mask], fetch);
10747
- if (downloaded.isErr()) return err$1(downloaded.error);
10748
- const images = downloaded.value.slice(0, sources.length);
10749
- const maskImage = downloaded.value[sources.length];
10750
- const size = typeof body.size === "string" ? body.size : "auto";
10751
- const common = {
10752
- model,
10753
- n,
10754
- provider,
10755
- providerOptions,
10756
- signal
10757
- };
10758
- const result = images.length === 0 ? await client.generate({
10759
- ...common,
10760
- prompt,
10761
- ...isPixelSize(size) && { size }
10762
- }) : await client.edit({
10763
- ...common,
10764
- images,
10765
- instruction: prompt,
10766
- ...maskImage !== void 0 && { mask: maskImage }
10767
- });
10768
- if (result.isErr()) return err$1(result.error);
10769
- const value = result.value;
10770
- let cost = {
10771
- basis: "unknown",
10772
- usd: null
10773
- };
10774
- if (value.cost.source === "provider-metadata") cost = {
10775
- basis: "measured",
10776
- usd: value.cost.usd
10777
- };
10778
- else if (value.cost.source === "table") cost = {
10779
- basis: "projected",
10780
- usd: value.cost.usd
10781
- };
10782
- return ok$1({
10783
- chosenBy: plan.chosenBy,
10784
- cost,
10785
- data: {},
10786
- endpoint: plan.endpoint,
10787
- files: value.images.map((image) => ({
10788
- key: "images",
10789
- url: `data:${image.mediaType};base64,${image.base64}`
10790
- })),
10791
- model: plan.model,
10792
- ...plan.mode !== void 0 && { mode: plan.mode },
10793
- ...plan.prompt !== void 0 && { prompt: plan.prompt },
10794
- provider: plan.provider,
10795
- raw: {
10796
- model: value.model,
10797
- provider: value.provider,
10798
- ...value.warnings !== void 0 && { warnings: value.warnings }
10799
- },
10800
- ...value.requestId !== void 0 && { requestId: value.requestId },
10801
- task: plan.task,
10802
- tier: plan.tier
10803
- });
10804
- }
10805
10095
  function taskStreamer(config, plan, context) {
10806
10096
  return async (task, input, options = {}) => {
10807
10097
  const planned = plan(task, input);
@@ -10812,12 +10102,11 @@ function taskStreamer(config, plan, context) {
10812
10102
  });
10813
10103
  };
10814
10104
  }
10815
- /** Create the Task client. Keys fall back to FAL_KEY and OPENAI_API_KEY. */
10105
+ /** Create the Task client. The key falls back to FAL_KEY. */
10816
10106
  function createMotif(config = {}) {
10817
10107
  function context() {
10818
10108
  return {
10819
10109
  falKey: presentKey(config.falKey ?? getFalKeyFromEnv()),
10820
- openAiKey: presentKey(config.openAiKey ?? getOpenAiKeyFromEnv()),
10821
10110
  pins: config.pins
10822
10111
  };
10823
10112
  }
@@ -10840,11 +10129,6 @@ function createMotif(config = {}) {
10840
10129
  const planned = plan(task, input);
10841
10130
  if (planned.isErr()) return err$1(planned.error);
10842
10131
  const chosen = planned.value;
10843
- if (chosen.provider === "openai") {
10844
- const openAiKey = context().openAiKey;
10845
- if (openAiKey === void 0) return err$1(new MotifError("OPENAI_API_KEY is not set.", 0, MISSING_API_KEY, void 0, { envVar: "OPENAI_API_KEY" }));
10846
- return await openAiOutput(chosen, openAiKey, config);
10847
- }
10848
10132
  const headers = input.ephemeral === true ? { "X-Fal-Store-IO": "0" } : {};
10849
10133
  const prepared = {
10850
10134
  body: chosen.body,
@@ -10852,13 +10136,12 @@ function createMotif(config = {}) {
10852
10136
  headers
10853
10137
  };
10854
10138
  const executor = falRequestExecutor(client.value);
10855
- return (chosen.queued ? await runRequestQueued(executor, prepared, input.onProgress, input.onSubmitted) : await runRequest(executor, prepared)).map((value) => falOutput(chosen, value));
10139
+ return (chosen.queued || input.onSubmitted !== void 0 ? await runRequestQueued(executor, prepared, input.onProgress, input.onSubmitted) : await runRequest(executor, prepared)).map((value) => falOutput(chosen, value));
10856
10140
  }
10857
10141
  async function resume(task, input, requestId) {
10858
10142
  const planned = plan(task, input);
10859
10143
  if (planned.isErr()) return err$1(planned.error);
10860
10144
  const chosen = planned.value;
10861
- if (chosen.provider !== "fal") return err$1(new MotifError(`Only a fal run can be resumed; ${chosen.model} runs on ${chosen.provider}.`, 0));
10862
10145
  const client = falClient();
10863
10146
  if (client.isErr()) return err$1(client.error);
10864
10147
  return (await awaitQueuedRequest(falRequestExecutor(client.value), {
@@ -10904,4 +10187,4 @@ function createMotif(config = {}) {
10904
10187
  };
10905
10188
  }
10906
10189
  //#endregion
10907
- export { ACCOUNT_LOCKED, ASPECT_RATIOS, CREATIVE_FIELDS, CREATIVE_TAXONOMY, CreativeOptionError, DEFAULT_TIER, FORMAT_PRESETS, INVALID_OPTION, ImageSizeBoundsError, LOOKS, MotifError, NO_MODEL_AVAILABLE, RESOLUTIONS, TASKS, TASK_IDS, TIERS, createMotif, enrichPrompt, err, formatCost, getFalKeyFromEnv, getLook, getOpenAiKeyFromEnv, isFalAccountLocked, isTaskId, modelProfile, motifEnvSchema, ok, parseMotifEnv, promptWarnings, resolveTask, sanitizePrompt, sumCosts, tierChangesChoice, validateCreativeDirection };
10190
+ export { ACCOUNT_LOCKED, ASPECT_RATIOS, CREATIVE_FIELDS, CREATIVE_TAXONOMY, CreativeOptionError, DEFAULT_TIER, FORMAT_PRESETS, INVALID_OPTION, ImageSizeBoundsError, LOOKS, MotifError, NO_MODEL_AVAILABLE, RESOLUTIONS, TASKS, TASK_IDS, TIERS, createMotif, enrichPrompt, err, formatCost, getFalKeyFromEnv, getLook, isFalAccountLocked, isTaskId, modelProfile, motifEnvSchema, ok, parseMotifEnv, promptWarnings, resolveTask, sanitizePrompt, sumCosts, tierChangesChoice, validateCreativeDirection };