@opencode/ai 0.0.0-dev-20061 → 0.0.0-dev-20065

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.
Files changed (48) hide show
  1. package/README.md +56 -1
  2. package/dist/image-client.d.ts +14 -7
  3. package/dist/image-client.js +24 -8
  4. package/dist/image.d.ts +392 -12
  5. package/dist/image.js +41 -9
  6. package/dist/media-model.d.ts +3 -1
  7. package/dist/media-model.js +10 -0
  8. package/dist/promise.d.ts +33 -5
  9. package/dist/promise.js +4 -2
  10. package/dist/protocols/bfl-images.d.ts +32 -0
  11. package/dist/protocols/bfl-images.js +153 -0
  12. package/dist/protocols/fal-images.d.ts +24 -0
  13. package/dist/protocols/fal-images.js +114 -0
  14. package/dist/protocols/fal-video.d.ts +0 -10
  15. package/dist/protocols/fal-video.js +8 -59
  16. package/dist/protocols/openai-images.d.ts +124 -3
  17. package/dist/protocols/openai-images.js +102 -35
  18. package/dist/protocols/openai-responses.d.ts +5 -5
  19. package/dist/protocols/replicate-images.d.ts +28 -0
  20. package/dist/protocols/replicate-images.js +133 -0
  21. package/dist/protocols/stability-images.d.ts +38 -0
  22. package/dist/protocols/stability-images.js +148 -0
  23. package/dist/protocols/utils/fal-queue.d.ts +28 -0
  24. package/dist/protocols/utils/fal-queue.js +69 -0
  25. package/dist/protocols/utils/media-input.d.ts +4 -0
  26. package/dist/protocols/utils/media-input.js +4 -0
  27. package/dist/provider-error.js +3 -0
  28. package/dist/providers/azure.d.ts +2 -2
  29. package/dist/providers/black-forest-labs.d.ts +25 -0
  30. package/dist/providers/black-forest-labs.js +28 -0
  31. package/dist/providers/cloudflare-ai-gateway.d.ts +2 -2
  32. package/dist/providers/fal.d.ts +5 -0
  33. package/dist/providers/fal.js +8 -4
  34. package/dist/providers/index.d.ts +3 -0
  35. package/dist/providers/index.js +3 -0
  36. package/dist/providers/openai.d.ts +1 -1
  37. package/dist/providers/replicate.d.ts +25 -0
  38. package/dist/providers/replicate.js +22 -0
  39. package/dist/providers/stability.d.ts +28 -0
  40. package/dist/providers/stability.js +23 -0
  41. package/dist/route/media-protocol.d.ts +3 -1
  42. package/dist/route/media.d.ts +13 -2
  43. package/dist/route/media.js +37 -2
  44. package/dist/transcription-client.d.ts +1 -1
  45. package/dist/transcription-client.js +10 -35
  46. package/dist/transcription.d.ts +2 -2
  47. package/dist/transcription.js +2 -9
  48. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  import { Effect } from "effect";
2
2
  import { Endpoint } from "./route/endpoint.js";
3
- import type { MediaRoute } from "./route/media.js";
3
+ import { MediaRoute } from "./route/media.js";
4
4
  import type { MediaProtocol } from "./route/media-protocol.js";
5
5
  import { AIError, HttpOptions, ModelID, ProviderID } from "./schema/index.js";
6
6
  /**
@@ -33,10 +33,12 @@ export declare namespace MediaModel {
33
33
  /** Headers the protocol requires on every call, such as a pinned API version; deployment headers win. */
34
34
  readonly headers?: Record<string, string>;
35
35
  }
36
+ type AnyRouteInput<Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token> = RouteInput<Request, MediaProtocol.Inline<Request, Response>> | RouteInput<MediaProtocol.Addressed<Request>, MediaProtocol.Streamed<Request, Event, Frame, State>> | RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>>;
36
37
  }
37
38
  /** Compose a protocol route input with one deployment through `MediaRoute.inline`, `queued`, or `stream`. */
38
39
  export declare const composeRoute: <Request extends MediaRoute.MediaRequest, Protocol, Route>(compose: (input: MediaRoute.Composition<Request> & {
39
40
  readonly protocol: Protocol;
40
41
  }) => Route, route: MediaModel.RouteInput<Request, Protocol>, input: MediaRoute.ModelInput) => Route;
42
+ export declare const composeAnyRoute: <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>, input: MediaRoute.ModelInput, collect: (events: ReadonlyArray<Event>) => Effect.Effect<Response, AIError>) => MediaRoute.AnyRoute<Request, Event, Response>;
41
43
  /** Lift a synchronous Schema-class constructor into a typed `InvalidRequest` failure. */
42
44
  export declare const tryRequest: <A>(make: () => A) => Effect.Effect<A, AIError>;
@@ -1,5 +1,6 @@
1
1
  import { Effect } from "effect";
2
2
  import { Endpoint } from "./route/endpoint.js";
3
+ import { MediaRoute } from "./route/media.js";
3
4
  import { AIError, HttpOptions, InvalidRequestError, ModelID, ProviderID } from "./schema/index.js";
4
5
  /**
5
6
  * What every media model carries: ids, the configured route, and deployment `http` overlays. Modality classes
@@ -27,6 +28,15 @@ export const composeRoute = (compose, route, input) => compose({
27
28
  auth: input.auth,
28
29
  headers: route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
29
30
  });
31
+ export const composeAnyRoute = (route, input, collect) => {
32
+ if (isStreamInput(route))
33
+ return composeRoute((composition) => MediaRoute.stream({ ...composition, collect }), route, input);
34
+ if (isQueuedInput(route))
35
+ return composeRoute(MediaRoute.queued, route, input);
36
+ return composeRoute(MediaRoute.inline, route, input);
37
+ };
38
+ const isStreamInput = (route) => route.protocol.kind === "stream";
39
+ const isQueuedInput = (route) => route.protocol.kind === "queued";
30
40
  /** Lift a synchronous Schema-class constructor into a typed `InvalidRequest` failure. */
31
41
  export const tryRequest = (make) => Effect.try({
32
42
  try: make,
package/dist/promise.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Effect, Layer } from "effect";
2
2
  import type { AwaitOptions, Snapshot } from "./generation.js";
3
- import { ImageModel, ImageRequest, type ImageRequestInput } from "./image.js";
3
+ import { ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js";
4
4
  import { ImageClient } from "./image-client.js";
5
5
  import { LLMClient } from "./route/client.js";
6
6
  import { RequestExecutor } from "./route/executor.js";
@@ -253,8 +253,20 @@ export declare const make: (options?: Options) => {
253
253
  };
254
254
  image: {
255
255
  request: typeof import("./image.js").request;
256
- generate: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) => Promise<import("./image.js").ImageResponse>;
257
- stream: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) => AsyncIterable<{
256
+ generate: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: AwaitOptions & RunOptions) => Promise<import("./image.js").ImageResponse>;
257
+ stream: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: AwaitOptions & RunOptions) => AsyncIterable<{
258
+ readonly id: string;
259
+ readonly type: "generation-queued";
260
+ readonly position?: number | undefined;
261
+ } | {
262
+ readonly id: string;
263
+ readonly type: "generation-progress";
264
+ readonly progress?: number | undefined;
265
+ } | {
266
+ readonly type: "image-partial";
267
+ readonly index: number;
268
+ readonly image: import("./media.js").Asset;
269
+ } | {
258
270
  readonly type: "image";
259
271
  readonly index: number;
260
272
  readonly image: import("./media.js").Asset;
@@ -296,6 +308,8 @@ export declare const make: (options?: Options) => {
296
308
  } | undefined;
297
309
  }[] | undefined;
298
310
  }>;
311
+ start: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) => Promise<GenerationHandle<import("./image.js").ImageResponse>>;
312
+ resume: <Options extends ImageOptions>(model: ImageModel<Options>, token: unknown, options?: RunOptions) => Promise<GenerationHandle<import("./image.js").ImageResponse>>;
299
313
  };
300
314
  video: {
301
315
  request: typeof import("./video.js").request;
@@ -709,8 +723,20 @@ export declare const ai: {
709
723
  };
710
724
  image: {
711
725
  request: typeof import("./image.js").request;
712
- generate: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) => Promise<import("./image.js").ImageResponse>;
713
- stream: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) => AsyncIterable<{
726
+ generate: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: AwaitOptions & RunOptions) => Promise<import("./image.js").ImageResponse>;
727
+ stream: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: AwaitOptions & RunOptions) => AsyncIterable<{
728
+ readonly id: string;
729
+ readonly type: "generation-queued";
730
+ readonly position?: number | undefined;
731
+ } | {
732
+ readonly id: string;
733
+ readonly type: "generation-progress";
734
+ readonly progress?: number | undefined;
735
+ } | {
736
+ readonly type: "image-partial";
737
+ readonly index: number;
738
+ readonly image: import("./media.js").Asset;
739
+ } | {
714
740
  readonly type: "image";
715
741
  readonly index: number;
716
742
  readonly image: import("./media.js").Asset;
@@ -752,6 +778,8 @@ export declare const ai: {
752
778
  } | undefined;
753
779
  }[] | undefined;
754
780
  }>;
781
+ start: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) => Promise<GenerationHandle<import("./image.js").ImageResponse>>;
782
+ resume: <Options extends ImageOptions>(model: ImageModel<Options>, token: unknown, options?: RunOptions) => Promise<GenerationHandle<import("./image.js").ImageResponse>>;
755
783
  };
756
784
  video: {
757
785
  request: typeof import("./video.js").request;
package/dist/promise.js CHANGED
@@ -49,8 +49,10 @@ export const make = (options = {}) => {
49
49
  },
50
50
  image: {
51
51
  request: Image.request,
52
- generate: (input, options) => run(Image.generate(imageRequest(input)), options),
53
- stream: (input, options) => iterate(Image.stream(imageRequest(input)), options),
52
+ generate: (input, options) => run(Image.generate(imageRequest(input), { poll: options?.poll }), options),
53
+ stream: (input, options) => iterate(Image.stream(imageRequest(input), { poll: options?.poll }), options),
54
+ start: (input, options) => run(Image.start(imageRequest(input)), options).then(handle),
55
+ resume: (model, token, options) => run(Image.resume(model, token), options).then(handle),
54
56
  },
55
57
  video: {
56
58
  request: Video.request,
@@ -0,0 +1,32 @@
1
+ import { Schema } from "effect";
2
+ import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js";
3
+ import { MediaProtocol } from "../route/media-protocol.js";
4
+ import { MediaRoute } from "../route/media.js";
5
+ export declare const DEFAULT_BASE_URL = "https://api.bfl.ai";
6
+ export type BlackForestLabsImageOptions = {
7
+ readonly safety_tolerance?: number;
8
+ readonly prompt_upsampling?: boolean;
9
+ readonly disable_pup?: boolean;
10
+ readonly raw?: boolean;
11
+ readonly guidance?: number;
12
+ readonly steps?: number;
13
+ } & Record<string, unknown>;
14
+ export type Request = ImageRequestFor<BlackForestLabsImageOptions>;
15
+ /** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
16
+ export declare const Token: Schema.Struct<{
17
+ readonly id: Schema.String;
18
+ readonly pollingURL: Schema.String;
19
+ }>;
20
+ export type Token = Schema.Schema.Type<typeof Token>;
21
+ export declare const protocol: MediaProtocol.Queued<Request, ImageResponse, {
22
+ readonly id: string;
23
+ readonly pollingURL: string;
24
+ }>;
25
+ export declare const model: (input: MediaRoute.ModelInput) => ImageModel<BlackForestLabsImageOptions>;
26
+ export declare const BlackForestLabsImages: {
27
+ readonly protocol: MediaProtocol.Queued<Request, ImageResponse, {
28
+ readonly id: string;
29
+ readonly pollingURL: string;
30
+ }>;
31
+ readonly model: (input: MediaRoute.ModelInput) => ImageModel<BlackForestLabsImageOptions>;
32
+ };
@@ -0,0 +1,153 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { ImageModel, ImageResponse } from "../image.js";
3
+ import { Media } from "../media.js";
4
+ import { MediaProtocol } from "../route/media-protocol.js";
5
+ import { MediaRoute } from "../route/media.js";
6
+ import { ProviderID, mergeJsonRecords } from "../schema/index.js";
7
+ import { ProviderShared, optionalNull } from "./shared.js";
8
+ import { MediaInput } from "./utils/media-input.js";
9
+ const ADAPTER = "bfl-images";
10
+ const NAME = "Black Forest Labs";
11
+ const PROVIDER = ProviderID.make("black-forest-labs");
12
+ export const DEFAULT_BASE_URL = "https://api.bfl.ai";
13
+ // ---------------------------------------------------------------------------
14
+ // 2. Token and response schemas
15
+ // ---------------------------------------------------------------------------
16
+ /** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
17
+ export const Token = Schema.Struct({ id: Schema.String, pollingURL: Schema.String });
18
+ const StartResponse = Schema.Struct({
19
+ id: Schema.String,
20
+ polling_url: Schema.String,
21
+ });
22
+ const Result = Schema.Struct({
23
+ id: Schema.String,
24
+ status: Schema.String,
25
+ result: optionalNull(Schema.StructWithRest(Schema.Struct({ sample: Schema.String, seed: optionalNull(Schema.Number), prompt: optionalNull(Schema.String) }), [Schema.Record(Schema.String, Schema.Unknown)])),
26
+ cost: optionalNull(Schema.Number),
27
+ });
28
+ const STATUS = {
29
+ Pending: "running",
30
+ Reasoning: "running",
31
+ Generating: "running",
32
+ Ready: "completed",
33
+ Error: "failed",
34
+ // Moderation is terminal; `decodeResult` reports it as a content-policy failure.
35
+ "Content Moderated": "failed",
36
+ "Request Moderated": "failed",
37
+ "Task not found": "expired",
38
+ };
39
+ const isModerated = (status) => status === "Content Moderated" || status === "Request Moderated";
40
+ const capabilities = (model) => {
41
+ if (model.startsWith("flux-pro-1.0-fill"))
42
+ return { sizing: "none", imageField: "image", maxImages: 1, mask: true };
43
+ if (model.startsWith("flux-pro-1.0-expand"))
44
+ return { sizing: "none", imageField: "image", maxImages: 1, mask: false };
45
+ if (model.startsWith("flux-kontext"))
46
+ return { sizing: "aspectRatio", imageField: "input_image", maxImages: 4, mask: false };
47
+ if (model.startsWith("flux-pro-1.1-ultra"))
48
+ return { sizing: "aspectRatio", imageField: "image_prompt", maxImages: 1, mask: false };
49
+ if (model.startsWith("flux-pro-1.1") || model.startsWith("flux-dev"))
50
+ return { sizing: "dimensions", imageField: "image_prompt", maxImages: 1, mask: false };
51
+ if (model.startsWith("flux-2-klein"))
52
+ return { sizing: "dimensions", imageField: "input_image", maxImages: 4, mask: false };
53
+ return { sizing: "dimensions", imageField: "input_image", maxImages: 8, mask: false };
54
+ };
55
+ const unsupported = (model, field, message) => ProviderShared.unsupportedOperation({
56
+ operation: `media.${field}`,
57
+ provider: PROVIDER,
58
+ route: ADAPTER,
59
+ message: `${model} ${message}`,
60
+ });
61
+ const validate = (request, model) => {
62
+ const id = request.model.id;
63
+ const images = request.images?.length ?? 0;
64
+ if (request.n !== undefined && request.n > 1)
65
+ return Effect.fail(unsupported(id, "n", "generates one image per request; call it once per image"));
66
+ if (request.size !== undefined && model.sizing !== "dimensions")
67
+ return Effect.fail(unsupported(id, "size", "does not take size (width and height)"));
68
+ if (request.aspectRatio !== undefined && model.sizing !== "aspectRatio")
69
+ return Effect.fail(unsupported(id, "aspectRatio", "does not take aspectRatio"));
70
+ if (images > model.maxImages)
71
+ return Effect.fail(unsupported(id, "images", `takes at most ${model.maxImages} images`));
72
+ if (request.mask !== undefined && !model.mask)
73
+ return Effect.fail(unsupported(id, "mask", "does not inpaint; use flux-pro-1.0-fill"));
74
+ return Effect.void;
75
+ };
76
+ const imageInput = (asset) => {
77
+ const value = asset.inline()?.base64 ?? ProviderShared.mediaUrl(asset);
78
+ if (value === undefined)
79
+ return Effect.fail(ProviderShared.invalidRequest(`${NAME} accepts inline images or https URLs`));
80
+ return Effect.succeed(value);
81
+ };
82
+ const fromRequest = Effect.fn("BlackForestLabsImages.fromRequest")(function* (request) {
83
+ const model = capabilities(request.model.id);
84
+ yield* validate(request, model);
85
+ const images = yield* Effect.forEach(request.images ?? [], imageInput);
86
+ const fields = images.map((image, index) => [
87
+ index === 0 ? model.imageField : `${model.imageField}_${index + 1}`,
88
+ image,
89
+ ]);
90
+ return MediaProtocol.json(mergeJsonRecords({
91
+ prompt: request.prompt,
92
+ ...(request.size === undefined ? {} : MediaInput.dimensions(request.size)),
93
+ aspect_ratio: request.aspectRatio,
94
+ seed: request.seed,
95
+ output_format: request.format,
96
+ mask: request.mask === undefined ? undefined : yield* imageInput(request.mask),
97
+ ...Object.fromEntries(fields),
98
+ }, request.providerOptions, request.http?.body) ?? {});
99
+ });
100
+ // ---------------------------------------------------------------------------
101
+ // 6. Response decoding
102
+ // ---------------------------------------------------------------------------
103
+ const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
104
+ token: { id: value.id, pollingURL: value.polling_url },
105
+ snapshot: { id: value.id, status: "queued" },
106
+ }));
107
+ const decodeDocument = MediaProtocol.decodeJson(ADAPTER, NAME, Result);
108
+ const decodeStatus = Effect.fn("BlackForestLabsImages.decodeStatus")(function* (response, context) {
109
+ const output = yield* decodeDocument(response);
110
+ return { id: context.token.id, status: yield* MediaProtocol.status(STATUS, output.value.status, output) };
111
+ });
112
+ const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (response, context) {
113
+ const output = yield* decodeDocument(response);
114
+ const document = output.value;
115
+ const status = yield* MediaProtocol.status(STATUS, document.status, output);
116
+ if (isModerated(document.status))
117
+ return yield* output.contentPolicy(`${NAME} moderated the generation`);
118
+ if (status === "failed" || status === "expired")
119
+ return yield* output.ended(status, `${NAME} generation ${context.token.id} ended with ${document.status}`);
120
+ if (status !== "completed" || document.result === undefined || document.result === null)
121
+ return yield* output.invalid(`${NAME} generation ${context.token.id} has no result`);
122
+ const { sample, seed, prompt, ...rest } = document.result;
123
+ return new ImageResponse({
124
+ // `sample` is a signed URL that expires 10 minutes after the result is ready, so it is downloaded now.
125
+ images: [yield* context.materialize(Media.url(sample))],
126
+ usage: document.cost === undefined || document.cost === null ? undefined : { type: "credits", credits: document.cost },
127
+ providerMetadata: {
128
+ bfl: { id: context.token.id, seed: seed ?? undefined, prompt: prompt ?? undefined, ...rest },
129
+ },
130
+ });
131
+ });
132
+ // ---------------------------------------------------------------------------
133
+ // 7. Protocol and route
134
+ // ---------------------------------------------------------------------------
135
+ export const protocol = MediaProtocol.queued({
136
+ id: ADAPTER,
137
+ name: NAME,
138
+ token: Token,
139
+ start: { body: { from: fromRequest }, decode: decodeStart },
140
+ status: { path: (token) => token.pollingURL, decode: decodeStatus },
141
+ result: { path: (token) => token.pollingURL, decode: decodeResult },
142
+ });
143
+ export const model = (input) => ImageModel.fromRoute({
144
+ id: ADAPTER,
145
+ provider: PROVIDER,
146
+ protocol,
147
+ baseURL: DEFAULT_BASE_URL,
148
+ path: ({ request }) => `/v1/${request.model.id}`,
149
+ }, input);
150
+ export const BlackForestLabsImages = {
151
+ protocol,
152
+ model,
153
+ };
@@ -0,0 +1,24 @@
1
+ import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js";
2
+ import { MediaProtocol } from "../route/media-protocol.js";
3
+ import { MediaRoute } from "../route/media.js";
4
+ export type FalImageOptions = {
5
+ readonly image_size?: "square_hd" | "square" | "portrait_4_3" | "portrait_16_9" | "landscape_4_3" | "landscape_16_9" | (string & {});
6
+ readonly enable_safety_checker?: boolean;
7
+ } & Record<string, unknown>;
8
+ export type Request = ImageRequestFor<FalImageOptions>;
9
+ export declare const protocol: MediaProtocol.Queued<Request, ImageResponse, {
10
+ readonly requestID: string;
11
+ readonly statusURL: string;
12
+ readonly responseURL: string;
13
+ readonly cancelURL: string;
14
+ }>;
15
+ export declare const model: (input: MediaRoute.ModelInput) => ImageModel<FalImageOptions>;
16
+ export declare const FalImages: {
17
+ readonly protocol: MediaProtocol.Queued<Request, ImageResponse, {
18
+ readonly requestID: string;
19
+ readonly statusURL: string;
20
+ readonly responseURL: string;
21
+ readonly cancelURL: string;
22
+ }>;
23
+ readonly model: (input: MediaRoute.ModelInput) => ImageModel<FalImageOptions>;
24
+ };
@@ -0,0 +1,114 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { ImageModel, ImageResponse } from "../image.js";
3
+ import { Media } from "../media.js";
4
+ import { MediaProtocol } from "../route/media-protocol.js";
5
+ import { MediaRoute } from "../route/media.js";
6
+ import { ProviderID, mergeJsonRecords } from "../schema/index.js";
7
+ import { ProviderShared, optionalNull } from "./shared.js";
8
+ import { FalQueue } from "./utils/fal-queue.js";
9
+ import { MediaInput } from "./utils/media-input.js";
10
+ const ADAPTER = "fal-images";
11
+ const NAME = "fal Images";
12
+ const PROVIDER = ProviderID.make("fal");
13
+ // ---------------------------------------------------------------------------
14
+ // 2. Response schema
15
+ // ---------------------------------------------------------------------------
16
+ const QueueResult = Schema.StructWithRest(Schema.Struct({
17
+ images: Schema.Array(Schema.Struct({
18
+ url: Schema.String,
19
+ width: optionalNull(Schema.Number),
20
+ height: optionalNull(Schema.Number),
21
+ content_type: optionalNull(Schema.String),
22
+ })),
23
+ seed: optionalNull(Schema.Number),
24
+ has_nsfw_concepts: optionalNull(Schema.Array(Schema.Boolean)),
25
+ }), [Schema.Record(Schema.String, Schema.Unknown)]);
26
+ // ---------------------------------------------------------------------------
27
+ // 5. Request body construction
28
+ // ---------------------------------------------------------------------------
29
+ const sizing = (model) => {
30
+ if (/^fal-ai\/(nano-banana|flux-pro\/v1\.1-ultra)/.test(model))
31
+ return "aspect_ratio";
32
+ if (model.startsWith("fal-ai/flux"))
33
+ return "image_size";
34
+ return undefined;
35
+ };
36
+ const unsupported = (model, field, message) => ProviderShared.unsupportedOperation({
37
+ operation: `media.${field}`,
38
+ provider: PROVIDER,
39
+ route: ADAPTER,
40
+ message: `${model} ${message}`,
41
+ });
42
+ const validate = (request) => {
43
+ const id = request.model.id;
44
+ const field = sizing(id);
45
+ if (request.size !== undefined && request.aspectRatio !== undefined)
46
+ return Effect.fail(ProviderShared.invalidRequest(`${NAME} accepts either size or aspectRatio, not both`));
47
+ if (request.size !== undefined && field === "aspect_ratio")
48
+ return Effect.fail(unsupported(id, "size", "sizes by aspectRatio"));
49
+ if (request.aspectRatio !== undefined && field === "image_size")
50
+ return Effect.fail(unsupported(id, "aspectRatio", "sizes by size (image_size)"));
51
+ if ((request.images?.length ?? 0) > 1 && !isEdit(id))
52
+ return Effect.fail(unsupported(id, "images", "takes one image_url; use an /edit endpoint for several images"));
53
+ return Effect.void;
54
+ };
55
+ // `/edit` endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one `image_url` (beside `mask_url`).
56
+ const isEdit = (model) => model.endsWith("/edit");
57
+ const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request) {
58
+ yield* validate(request);
59
+ const images = yield* Effect.forEach(request.images ?? [], (image) => FalQueue.mediaUrl(image, NAME));
60
+ const edit = isEdit(request.model.id);
61
+ return MediaProtocol.json(mergeJsonRecords({
62
+ prompt: request.prompt,
63
+ num_images: request.n,
64
+ seed: request.seed,
65
+ image_size: request.size === undefined ? undefined : MediaInput.dimensions(request.size),
66
+ aspect_ratio: request.aspectRatio,
67
+ output_format: request.format,
68
+ image_urls: edit && images.length > 0 ? images : undefined,
69
+ image_url: edit ? undefined : images[0],
70
+ mask_url: request.mask === undefined ? undefined : yield* FalQueue.mediaUrl(request.mask, NAME),
71
+ }, request.providerOptions, request.http?.body) ?? {});
72
+ });
73
+ // ---------------------------------------------------------------------------
74
+ // 6. Response decoding
75
+ // ---------------------------------------------------------------------------
76
+ const decodeQueueResult = MediaProtocol.decodeJson(ADAPTER, NAME, QueueResult);
77
+ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (response, context) {
78
+ const output = yield* decodeQueueResult(response);
79
+ const { images, seed, has_nsfw_concepts, ...rest } = output.value;
80
+ if (images.length === 0)
81
+ return yield* output.invalid(`${NAME} returned no images`);
82
+ // With the safety checker on, flagged images come back blacked out rather than omitted.
83
+ const flagged = (has_nsfw_concepts ?? []).flatMap((value, index) => (value ? [index] : []));
84
+ return new ImageResponse({
85
+ images: images.map((image) => Media.url(image.url, {
86
+ mediaType: image.content_type ?? undefined,
87
+ info: { width: image.width ?? undefined, height: image.height ?? undefined },
88
+ })),
89
+ notices: flagged.length === 0
90
+ ? undefined
91
+ : flagged.map((index) => ({ type: "moderated", message: `${NAME} flagged image ${index} as NSFW` })),
92
+ providerMetadata: { fal: { requestId: context.token.requestID, seed: seed ?? undefined, ...rest } },
93
+ });
94
+ });
95
+ // ---------------------------------------------------------------------------
96
+ // 7. Protocol and route
97
+ // ---------------------------------------------------------------------------
98
+ export const protocol = FalQueue.protocol({
99
+ id: ADAPTER,
100
+ name: NAME,
101
+ from: fromRequest,
102
+ decodeResult,
103
+ });
104
+ export const model = (input) => ImageModel.fromRoute({
105
+ id: ADAPTER,
106
+ provider: PROVIDER,
107
+ protocol,
108
+ baseURL: FalQueue.DEFAULT_BASE_URL,
109
+ path: ({ request }) => `/${request.model.id}`,
110
+ }, input);
111
+ export const FalImages = {
112
+ protocol,
113
+ model,
114
+ };
@@ -1,8 +1,6 @@
1
- import { Schema } from "effect";
2
1
  import { MediaProtocol } from "../route/media-protocol.js";
3
2
  import { MediaRoute } from "../route/media.js";
4
3
  import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js";
5
- export declare const DEFAULT_BASE_URL = "https://queue.fal.run";
6
4
  export type FalVideoString<Known extends string> = Known | (string & {});
7
5
  /**
8
6
  * Provider-native input. fal video endpoints are model-specific: `duration` is a string enum whose values differ per
@@ -13,14 +11,6 @@ export type FalVideoOptions = {
13
11
  readonly duration?: FalVideoString<"4s" | "6s" | "8s" | "5" | "10">;
14
12
  } & Record<string, unknown>;
15
13
  export type Request = VideoRequestFor<FalVideoOptions>;
16
- /** fal hands back absolute follow-up URLs on submit; they are authoritative for status, result, and cancel. */
17
- export declare const Token: Schema.Struct<{
18
- readonly requestID: Schema.String;
19
- readonly statusURL: Schema.String;
20
- readonly responseURL: Schema.String;
21
- readonly cancelURL: Schema.String;
22
- }>;
23
- export type Token = Schema.Schema.Type<typeof Token>;
24
14
  export declare const protocol: MediaProtocol.Queued<Request, VideoResponse, {
25
15
  readonly requestID: string;
26
16
  readonly statusURL: string;
@@ -5,32 +5,13 @@ import { MediaRoute } from "../route/media.js";
5
5
  import { ProviderID, mergeJsonRecords } from "../schema/index.js";
6
6
  import { VideoModel, VideoResponse } from "../video.js";
7
7
  import { ProviderShared, optionalNull } from "./shared.js";
8
+ import { FalQueue } from "./utils/fal-queue.js";
8
9
  const ADAPTER = "fal-video";
9
10
  const NAME = "fal Video";
10
11
  const PROVIDER = ProviderID.make("fal");
11
- export const DEFAULT_BASE_URL = "https://queue.fal.run";
12
12
  // ---------------------------------------------------------------------------
13
- // 2. Token and response schemas
13
+ // 2. Response schema
14
14
  // ---------------------------------------------------------------------------
15
- /** fal hands back absolute follow-up URLs on submit; they are authoritative for status, result, and cancel. */
16
- export const Token = Schema.Struct({
17
- requestID: Schema.String,
18
- statusURL: Schema.String,
19
- responseURL: Schema.String,
20
- cancelURL: Schema.String,
21
- });
22
- const StartResponse = Schema.Struct({
23
- request_id: Schema.String,
24
- status_url: Schema.String,
25
- response_url: Schema.String,
26
- cancel_url: Schema.String,
27
- queue_position: optionalNull(Schema.Number),
28
- });
29
- const QueueStatus = Schema.Struct({
30
- status: Schema.String,
31
- queue_position: optionalNull(Schema.Number),
32
- error: optionalNull(Schema.Unknown),
33
- });
34
15
  const QueueResult = Schema.StructWithRest(Schema.Struct({
35
16
  video: Schema.Struct({
36
17
  url: Schema.String,
@@ -40,16 +21,9 @@ const QueueResult = Schema.StructWithRest(Schema.Struct({
40
21
  }),
41
22
  seed: optionalNull(Schema.Number),
42
23
  }), [Schema.Record(Schema.String, Schema.Unknown)]);
43
- const STATUS = {
44
- IN_QUEUE: "queued",
45
- IN_PROGRESS: "running",
46
- COMPLETED: "completed",
47
- };
48
24
  // ---------------------------------------------------------------------------
49
25
  // 5. Request body construction
50
26
  // ---------------------------------------------------------------------------
51
- // fal accepts public URLs and data URIs; there is no provider file handle to forward.
52
- const mediaUrl = (asset) => ProviderShared.mediaReference(asset, undefined, NAME).pipe(Effect.map((reference) => reference.value));
53
27
  const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request) {
54
28
  if (request.frames?.last !== undefined)
55
29
  return yield* ProviderShared.unsupportedOperation({
@@ -58,8 +32,8 @@ const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request) {
58
32
  route: ADAPTER,
59
33
  message: `${NAME} names the last frame per model; pass it through providerOptions (e.g. end_image_url) instead of frames.last`,
60
34
  });
61
- const imageUrl = request.frames?.first === undefined ? undefined : yield* mediaUrl(request.frames.first);
62
- const videoUrl = request.video === undefined ? undefined : yield* mediaUrl(request.video);
35
+ const imageUrl = request.frames?.first === undefined ? undefined : yield* FalQueue.mediaUrl(request.frames.first, NAME);
36
+ const videoUrl = request.video === undefined ? undefined : yield* FalQueue.mediaUrl(request.video, NAME);
63
37
  return MediaProtocol.json(mergeJsonRecords({
64
38
  prompt: request.prompt,
65
39
  negative_prompt: request.negativePrompt,
@@ -74,29 +48,7 @@ const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request) {
74
48
  // ---------------------------------------------------------------------------
75
49
  // 6. Response decoding
76
50
  // ---------------------------------------------------------------------------
77
- const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
78
- token: {
79
- requestID: value.request_id,
80
- statusURL: value.status_url,
81
- responseURL: value.response_url,
82
- cancelURL: value.cancel_url,
83
- },
84
- snapshot: { id: value.request_id, status: "queued", position: value.queue_position ?? undefined },
85
- }));
86
- const decodeQueueStatus = MediaProtocol.decodeJson(ADAPTER, NAME, QueueStatus);
87
51
  const decodeQueueResult = MediaProtocol.decodeJson(ADAPTER, NAME, QueueResult);
88
- const decodeStatus = Effect.fn("FalVideo.decodeStatus")(function* (response, context) {
89
- const output = yield* decodeQueueStatus(response);
90
- const decoded = output.value;
91
- const status = yield* MediaProtocol.status(STATUS, decoded.status, output);
92
- // fal reports request failures as COMPLETED with an `error`; the response endpoint carries the details.
93
- const failed = status === "completed" && decoded.error !== undefined && decoded.error !== null;
94
- return {
95
- id: context.token.requestID,
96
- status: failed ? "failed" : status,
97
- position: status === "queued" ? (decoded.queue_position ?? undefined) : undefined,
98
- };
99
- });
100
52
  const decodeResult = Effect.fn("FalVideo.decodeResult")(function* (response, context) {
101
53
  const output = yield* decodeQueueResult(response);
102
54
  const { video, seed, ...rest } = output.value;
@@ -116,21 +68,18 @@ const decodeResult = Effect.fn("FalVideo.decodeResult")(function* (response, con
116
68
  // ---------------------------------------------------------------------------
117
69
  // 7. Protocol and route
118
70
  // ---------------------------------------------------------------------------
119
- export const protocol = MediaProtocol.queued({
71
+ export const protocol = FalQueue.protocol({
120
72
  id: ADAPTER,
121
73
  name: NAME,
122
- token: Token,
123
74
  unsupported: ["n", "durationSeconds", "references"],
124
- start: { body: { from: fromRequest }, decode: decodeStart },
125
- status: { path: (token) => token.statusURL, decode: decodeStatus },
126
- result: { path: (token) => token.responseURL, decode: decodeResult },
127
- cancel: { method: "PUT", path: (token) => token.cancelURL },
75
+ from: fromRequest,
76
+ decodeResult,
128
77
  });
129
78
  export const model = (input) => VideoModel.fromRoute({
130
79
  id: ADAPTER,
131
80
  provider: PROVIDER,
132
81
  protocol,
133
- baseURL: DEFAULT_BASE_URL,
82
+ baseURL: FalQueue.DEFAULT_BASE_URL,
134
83
  path: ({ request }) => `/${request.model.id}`,
135
84
  }, input);
136
85
  export const FalVideo = {