@effect-uai/fal 0.13.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.
@@ -0,0 +1,524 @@
1
+ import {
2
+ Context,
3
+ Effect,
4
+ Encoding,
5
+ Layer,
6
+ Match,
7
+ Option,
8
+ Redacted,
9
+ Ref,
10
+ Result,
11
+ Schema,
12
+ Stream,
13
+ pipe,
14
+ } from "effect"
15
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
16
+ import * as AiError from "@effect-uai/core/AiError"
17
+ import * as Capabilities from "@effect-uai/core/Capabilities"
18
+ import type { GeneratedImage, ImageSource } from "@effect-uai/core/Image"
19
+ import { imageBase64, imageUrl, pixelsOf } from "@effect-uai/core/Image"
20
+ import type {
21
+ CommonImageEditRequest,
22
+ CommonImageGenerateRequest,
23
+ CommonStreamImageEditRequest,
24
+ CommonStreamImageRequest,
25
+ ImageGeneratorService,
26
+ ImageResponse,
27
+ ImageStreamEvent,
28
+ } from "@effect-uai/core/ImageGenerator"
29
+ import { ImageGenerator } from "@effect-uai/core/ImageGenerator"
30
+ import { PROVIDER, httpError, missingImageField, transportFailure } from "./codec.js"
31
+ import type { FalImageEditModel, FalImageModel } from "./models.js"
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Public types
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** A named preset (`"landscape_4_3"`, `"auto_2K"`, …) or exact pixels. */
38
+ export type FalImageSize = string | { readonly width: number; readonly height: number }
39
+
40
+ /**
41
+ * Wire knobs shared by most fal image endpoints. fal is not one API but a
42
+ * catalogue of models behind one envelope, so only `prompt`, `num_images`
43
+ * and the output envelope are dependable across all of them; the fields
44
+ * here are the ones the image endpoints agree on.
45
+ */
46
+ type FalImageKnobs = {
47
+ /** Sent as `image_size`. Takes precedence over the portable ratio and tier. */
48
+ readonly imageSize?: FalImageSize
49
+ readonly seed?: number
50
+ readonly outputFormat?: "jpeg" | "png" | "webp"
51
+ /** Diffusion knobs, open-weights models only. */
52
+ readonly numInferenceSteps?: number
53
+ readonly guidanceScale?: number
54
+ readonly enableSafetyChecker?: boolean
55
+ /**
56
+ * `false` returns fal CDN links instead of inline bytes. The default is
57
+ * `true`: every other adapter here hands back bytes, and fal's links
58
+ * expire.
59
+ */
60
+ readonly syncMode?: boolean
61
+ /**
62
+ * Fields only one endpoint takes: `acceleration`, `loras`,
63
+ * `safety_tolerance`, `thinking_level`, `aspect_ratio`. Merged last, so
64
+ * it also overrides anything derived above. Snake case, as the wire
65
+ * spells it. The escape hatch exists because fal's schemas are
66
+ * per-model and tabling them would go stale in a week.
67
+ */
68
+ readonly input?: Record<string, unknown>
69
+ }
70
+
71
+ export type FalImageGenerateRequest = Omit<CommonImageGenerateRequest, "model"> & {
72
+ readonly model: FalImageModel
73
+ } & FalImageKnobs
74
+
75
+ export type FalImageEditRequest = Omit<CommonImageEditRequest, "model"> & {
76
+ /** Edit is a *different endpoint* on fal, not a flag on the same one. */
77
+ readonly model: FalImageEditModel
78
+ } & FalImageKnobs
79
+
80
+ /**
81
+ * Per-image extras on `GeneratedImage.providerData.fal`. All optional:
82
+ * which of them an endpoint reports is per-model. Read with
83
+ * {@link imageDataOf}.
84
+ */
85
+ const ImageData = Schema.Struct({
86
+ width: Schema.optional(Schema.Number),
87
+ height: Schema.optional(Schema.Number),
88
+ fileName: Schema.optional(Schema.String),
89
+ fileSize: Schema.optional(Schema.Number),
90
+ })
91
+ export type FalImageData = typeof ImageData.Type
92
+
93
+ /** Response-level extras on `ImageResponse.providerData.fal`. */
94
+ const ResponseData = Schema.Struct({
95
+ /** The seed the request resolved to. Send it back for the same image. */
96
+ seed: Schema.optional(Schema.Number),
97
+ timings: Schema.optional(Schema.Unknown),
98
+ hasNsfwConcepts: Schema.optional(Schema.Array(Schema.Boolean)),
99
+ })
100
+ export type FalResponseData = typeof ResponseData.Type
101
+
102
+ export type FalImageGeneratorService = {
103
+ readonly generate: (
104
+ request: FalImageGenerateRequest,
105
+ ) => Effect.Effect<ImageResponse, AiError.AiError>
106
+ readonly edit: (request: FalImageEditRequest) => Effect.Effect<ImageResponse, AiError.AiError>
107
+ /** Both always `Unsupported`: `ImageStreaming` is not registered by {@link layer}. */
108
+ readonly streamGeneration: ImageGeneratorService["streamGeneration"]
109
+ readonly streamEdit: ImageGeneratorService["streamEdit"]
110
+ }
111
+
112
+ /**
113
+ * Provider-typed service tag. Yield this for exact pixel sizes, seeds,
114
+ * diffusion knobs and the per-endpoint `input` passthrough; yield the
115
+ * generic `ImageGenerator` tag for provider-portable code. Both are
116
+ * registered by {@link layer}.
117
+ */
118
+ export class FalImageGenerator extends Context.Service<
119
+ FalImageGenerator,
120
+ FalImageGeneratorService
121
+ >()("@betalyra/effect-uai/providers/fal/FalImageGenerator") {}
122
+
123
+ export type Config = {
124
+ readonly apiKey: Redacted.Redacted
125
+ readonly baseUrl?: string
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Codec - size
130
+ //
131
+ // The portable request carries a ratio and a tier; fal endpoints take
132
+ // `image_size`, either a named preset or `{ width, height }`. Pixels are
133
+ // derived because the preset names differ per model family while the
134
+ // object form is the shape they share.
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * The typed `imageSize` wins over the portable pair, which is then
139
+ * dropped. `undefined` means "send no size" and let the endpoint pick.
140
+ */
141
+ export const imageSizeOf = (
142
+ request: Pick<FalImageGenerateRequest, "imageSize" | "aspectRatio" | "resolution">,
143
+ ): Effect.Effect<FalImageSize | undefined, AiError.AiError> =>
144
+ Effect.gen(function* () {
145
+ if (request.imageSize !== undefined) {
146
+ const overridden = "`imageSize` sets the dimensions directly."
147
+ yield* Capabilities.warnDroppedWhen(request.aspectRatio, {
148
+ provider: PROVIDER,
149
+ capability: "aspectRatio",
150
+ field: "aspectRatio",
151
+ reason: overridden,
152
+ })
153
+ yield* Capabilities.warnDroppedWhen(request.resolution, {
154
+ provider: PROVIDER,
155
+ capability: "resolution",
156
+ field: "resolution",
157
+ reason: overridden,
158
+ })
159
+ return request.imageSize
160
+ }
161
+ return yield* Result.match(pixelsOf(request), {
162
+ onSuccess: Effect.succeed<FalImageSize | undefined>,
163
+ onFailure: (reason) =>
164
+ Effect.fail(
165
+ new AiError.InvalidRequest({
166
+ provider: PROVIDER,
167
+ param: "aspectRatio",
168
+ raw: `${reason} Set \`imageSize\` directly to bypass the derivation.`,
169
+ }),
170
+ ),
171
+ })
172
+ })
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Codec - request
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * fal reads reference images from URLs, and a data URI is a URL, so
180
+ * every `ImageSource` has a wire form here. This is the one adapter
181
+ * where passing a `url` reference costs nothing.
182
+ */
183
+ const referenceUrl: (image: ImageSource) => string = Match.type<ImageSource>().pipe(
184
+ Match.tag("url", (i) => i.url),
185
+ Match.tag("base64", (i) => `data:${i.mimeType};base64,${i.base64}`),
186
+ Match.tag("bytes", (i) => `data:${i.mimeType};base64,${Encoding.encodeBase64(i.bytes)}`),
187
+ Match.exhaustive,
188
+ )
189
+
190
+ /** `_urls` takes the set, `_url` takes one. True of every spelling fal uses. */
191
+ const takesMany = (field: string): boolean => field.endsWith("_urls")
192
+
193
+ /**
194
+ * References under the field the endpoint reads. A single-image field
195
+ * gets the last reference rather than the first: callers pass anchors
196
+ * before the image being changed, so the last is the one meant.
197
+ */
198
+ const referenceFields = (
199
+ images: ReadonlyArray<ImageSource>,
200
+ field: string,
201
+ ): Record<string, unknown> => {
202
+ const last = images[images.length - 1]
203
+ return images.length === 0 || last === undefined
204
+ ? {}
205
+ : takesMany(field)
206
+ ? { [field]: images.map(referenceUrl) }
207
+ : { [field]: referenceUrl(last) }
208
+ }
209
+
210
+ export type WireBody = Record<string, unknown> & {
211
+ readonly prompt: string
212
+ }
213
+
214
+ export const buildBody = (
215
+ request: FalImageGenerateRequest,
216
+ images: ReadonlyArray<ImageSource>,
217
+ field: string = "image_urls",
218
+ ): Effect.Effect<WireBody, AiError.AiError> =>
219
+ Effect.gen(function* () {
220
+ const imageSize = yield* imageSizeOf(request)
221
+ const references = referenceFields(images, field)
222
+ return {
223
+ prompt: request.prompt,
224
+ // Inline bytes rather than an expiring CDN link, unless asked otherwise.
225
+ sync_mode: request.syncMode ?? true,
226
+ ...(imageSize !== undefined && { image_size: imageSize }),
227
+ ...(request.n !== undefined && { num_images: request.n }),
228
+ ...(request.seed !== undefined && { seed: request.seed }),
229
+ ...(request.outputFormat !== undefined && { output_format: request.outputFormat }),
230
+ ...(request.numInferenceSteps !== undefined && {
231
+ num_inference_steps: request.numInferenceSteps,
232
+ }),
233
+ ...(request.guidanceScale !== undefined && { guidance_scale: request.guidanceScale }),
234
+ ...(request.enableSafetyChecker !== undefined && {
235
+ enable_safety_checker: request.enableSafetyChecker,
236
+ }),
237
+ ...references,
238
+ ...request.input,
239
+ }
240
+ })
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Codec - response
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * Every field is optional *and* nullable, because fal's per-model
248
+ * schemas disagree about both: one endpoint declares `width` required,
249
+ * the next declares it nullable and sends `null`. Only `url` is
250
+ * dependable. Anything beyond it reaches the caller through
251
+ * `providerData` rather than a promised field.
252
+ */
253
+ const ImageFile = Schema.Struct({
254
+ url: Schema.String,
255
+ content_type: Schema.optional(Schema.NullOr(Schema.String)),
256
+ width: Schema.optional(Schema.NullOr(Schema.Number)),
257
+ height: Schema.optional(Schema.NullOr(Schema.Number)),
258
+ file_name: Schema.optional(Schema.NullOr(Schema.String)),
259
+ file_size: Schema.optional(Schema.NullOr(Schema.Number)),
260
+ })
261
+ type ImageFile = typeof ImageFile.Type
262
+
263
+ /** A handful of endpoints return `image` singular; the rest return `images`. */
264
+ const Wire = Schema.Struct({
265
+ images: Schema.optional(Schema.NullOr(Schema.Array(ImageFile))),
266
+ image: Schema.optional(Schema.NullOr(ImageFile)),
267
+ has_nsfw_concepts: Schema.optional(Schema.NullOr(Schema.Array(Schema.Boolean))),
268
+ /** The seed a request resolved to, which is what makes a run repeatable. */
269
+ seed: Schema.optional(Schema.NullOr(Schema.Number)),
270
+ timings: Schema.optional(Schema.NullOr(Schema.Unknown)),
271
+ })
272
+ type Wire = typeof Wire.Type
273
+
274
+ const decodeBody = Schema.decodeUnknownEffect(Schema.fromJsonString(Wire))
275
+
276
+ /**
277
+ * Decode from the raw body rather than parsed JSON, so a shape this
278
+ * adapter cannot read comes back with the body attached. Output schemas
279
+ * are per-model here, and "we could not read it" is unactionable without
280
+ * the thing we could not read.
281
+ */
282
+ export const decodeWire = (body: string): Effect.Effect<Wire, AiError.AiError> =>
283
+ decodeBody(body).pipe(
284
+ Effect.mapError(
285
+ () =>
286
+ new AiError.GenerationFailed({
287
+ provider: PROVIDER,
288
+ message: "fal returned a body with no readable image field.",
289
+ // `sync_mode` puts a whole image in the body, so the head is
290
+ // what gets attached: enough to see the shape, not megabytes.
291
+ raw: body.length > 2000 ? `${body.slice(0, 2000)}… (${body.length} bytes)` : body,
292
+ }),
293
+ ),
294
+ )
295
+
296
+ const DATA_URI = /^data:([^;,]+);base64,(.*)$/s
297
+
298
+ /** `sync_mode` returns a data URI; without it, a link that expires. */
299
+ const dataUri = (url: string): Option.Option<readonly [string, string]> =>
300
+ pipe(
301
+ Option.fromNullOr(DATA_URI.exec(url)),
302
+ Option.flatMap(([, mimeType, base64]) =>
303
+ mimeType !== undefined && base64 !== undefined
304
+ ? Option.some([mimeType, base64] as const)
305
+ : Option.none(),
306
+ ),
307
+ )
308
+
309
+ /** Only what the endpoint actually reported; absent keys stay absent. */
310
+ const imageExtras = (file: ImageFile): FalImageData => ({
311
+ ...(file.width != null && { width: file.width }),
312
+ ...(file.height != null && { height: file.height }),
313
+ ...(file.file_name != null && { fileName: file.file_name }),
314
+ ...(file.file_size != null && { fileSize: file.file_size }),
315
+ })
316
+
317
+ /** No fal image model documents a watermark, so none is claimed. */
318
+ const generatedImage = (file: ImageFile): GeneratedImage => {
319
+ const extras = imageExtras(file)
320
+ return {
321
+ image: Option.match(dataUri(file.url), {
322
+ onNone: () => imageUrl(file.url, file.content_type ?? undefined),
323
+ onSome: ([mimeType, base64]) => imageBase64(base64, mimeType),
324
+ }),
325
+ ...(Object.keys(extras).length > 0 && { providerData: { fal: extras } }),
326
+ }
327
+ }
328
+
329
+ /** Every image flagged means nothing usable came back, which is a refusal. */
330
+ const refused = (wire: Wire): boolean => {
331
+ const flags = wire.has_nsfw_concepts ?? []
332
+ return flags.length > 0 && flags.every((flagged) => flagged)
333
+ }
334
+
335
+ export const toResponse = (wire: Wire): Effect.Effect<ImageResponse, AiError.AiError> => {
336
+ const files = [...(wire.images ?? []), ...(wire.image == null ? [] : [wire.image])]
337
+ if (refused(wire)) {
338
+ return Effect.fail(
339
+ new AiError.ContentFiltered({
340
+ provider: PROVIDER,
341
+ reason: "The safety checker flagged every generated image.",
342
+ raw: wire,
343
+ }),
344
+ )
345
+ }
346
+ const extras: FalResponseData = {
347
+ ...(wire.seed != null && { seed: wire.seed }),
348
+ ...(wire.timings != null && { timings: wire.timings }),
349
+ ...(wire.has_nsfw_concepts != null && { hasNsfwConcepts: wire.has_nsfw_concepts }),
350
+ }
351
+ // fal bills per image and per megapixel, not per token, so there is no
352
+ // usage to report.
353
+ return files.length === 0
354
+ ? Effect.fail(
355
+ new AiError.GenerationFailed({
356
+ provider: PROVIDER,
357
+ message: "fal returned no image.",
358
+ raw: wire,
359
+ }),
360
+ )
361
+ : Effect.succeed({
362
+ images: files.map(generatedImage),
363
+ usage: {},
364
+ ...(Object.keys(extras).length > 0 && { providerData: { fal: extras } }),
365
+ })
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Reading the extras
370
+ // ---------------------------------------------------------------------------
371
+
372
+ const decodeImageData = Schema.decodeUnknownOption(Schema.Struct({ fal: ImageData }))
373
+ const decodeResponseData = Schema.decodeUnknownOption(Schema.Struct({ fal: ResponseData }))
374
+
375
+ /**
376
+ * Pixel dimensions and file metadata for one image, where the endpoint
377
+ * reported them. `providerData` is a shared slot, so this reads only the
378
+ * `fal` key and returns `None` for an image from another provider.
379
+ */
380
+ export const imageDataOf = (image: GeneratedImage): Option.Option<FalImageData> =>
381
+ Option.map(decodeImageData(image.providerData), (d) => d.fal)
382
+
383
+ /** The seed a request resolved to, plus timings and safety flags. */
384
+ export const responseDataOf = (response: ImageResponse): Option.Option<FalResponseData> =>
385
+ Option.map(decodeResponseData(response.providerData), (d) => d.fal)
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // HTTP
389
+ //
390
+ // The synchronous endpoint: one request, one response. The queue API
391
+ // (`queue.fal.run`) exists for jobs long enough to outlive a connection,
392
+ // which image generation is not.
393
+ // ---------------------------------------------------------------------------
394
+
395
+ const host = (cfg: Config): string => cfg.baseUrl ?? "https://fal.run"
396
+
397
+ type Attempt = { readonly status: number; readonly body: string }
398
+
399
+ const attempt = (
400
+ cfg: Config,
401
+ model: string,
402
+ request: FalImageGenerateRequest,
403
+ images: ReadonlyArray<ImageSource>,
404
+ field: string,
405
+ ): Effect.Effect<Attempt, AiError.AiError, HttpClient.HttpClient> =>
406
+ Effect.gen(function* () {
407
+ const client = yield* HttpClient.HttpClient
408
+ const body = yield* buildBody(request, images, field)
409
+ const response = yield* client
410
+ .execute(
411
+ HttpClientRequest.post(`${host(cfg)}/${model}`).pipe(
412
+ HttpClientRequest.setHeader("authorization", `Key ${Redacted.value(cfg.apiKey)}`),
413
+ HttpClientRequest.bodyJsonUnsafe(body),
414
+ ),
415
+ )
416
+ .pipe(Effect.mapError(transportFailure))
417
+ const text = yield* response.text.pipe(Effect.mapError(transportFailure))
418
+ return { status: response.status, body: text }
419
+ })
420
+
421
+ const finish = (attempt: Attempt): Effect.Effect<ImageResponse, AiError.AiError> =>
422
+ attempt.status >= 400
423
+ ? Effect.flatMap(httpError(attempt.status, attempt.body), Effect.fail)
424
+ : Effect.flatMap(decodeWire(attempt.body), toResponse)
425
+
426
+ /**
427
+ * `learned` remembers what an endpoint asked for, so only the first call
428
+ * to one this adapter has no mapping for pays for the correction. The
429
+ * retry is free of inference cost: fal validates before it runs anything.
430
+ */
431
+ const requestImages = (
432
+ cfg: Config,
433
+ model: string,
434
+ request: FalImageGenerateRequest,
435
+ images: ReadonlyArray<ImageSource>,
436
+ learned: Ref.Ref<Record<string, string>>,
437
+ ): Effect.Effect<ImageResponse, AiError.AiError, HttpClient.HttpClient> =>
438
+ Effect.gen(function* () {
439
+ // Current endpoints take `image_urls`; older ones want `image_url` or
440
+ // another spelling, which their 422 names. Neither the id nor the
441
+ // owner namespace predicts it, so guess and correct.
442
+ const remembered = yield* Ref.get(learned)
443
+ const field = remembered[model] ?? "image_urls"
444
+ const first = yield* attempt(cfg, model, request, images, field)
445
+ if (first.status < 400 || images.length === 0) return yield* finish(first)
446
+
447
+ const asked = yield* Effect.map(missingImageField(first.body), (name) =>
448
+ Option.flatMap(name, (wanted) =>
449
+ wanted === field ? Option.none<string>() : Option.some(wanted),
450
+ ),
451
+ )
452
+ if (Option.isNone(asked)) return yield* finish(first)
453
+
454
+ yield* Ref.update(learned, (known) => ({ ...known, [model]: asked.value }))
455
+ return yield* Effect.flatMap(attempt(cfg, model, request, images, asked.value), finish)
456
+ })
457
+
458
+ /**
459
+ * fal's `/stream` endpoint exists, but the events it carries are defined
460
+ * by each model rather than by fal, and no image endpoint documents
461
+ * partial images. {@link layer} therefore does not register
462
+ * `ImageStreaming`, and calling the top-level `streamGeneration` helper
463
+ * against this Layer alone fails to typecheck; this branch covers the
464
+ * service being used directly.
465
+ */
466
+ const streamUnsupported = (capability: string): Stream.Stream<ImageStreamEvent, AiError.AiError> =>
467
+ Stream.fail(
468
+ new AiError.Unsupported({
469
+ provider: PROVIDER,
470
+ capability,
471
+ reason:
472
+ "No fal image endpoint documents a partial-image stream. Use `generate` or `edit` and render when it resolves.",
473
+ }),
474
+ )
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Constructors
478
+ // ---------------------------------------------------------------------------
479
+
480
+ export const make = (
481
+ cfg: Config,
482
+ ): Effect.Effect<FalImageGeneratorService, never, HttpClient.HttpClient> =>
483
+ Effect.gen(function* () {
484
+ const client = yield* HttpClient.HttpClient
485
+ const learned = yield* Ref.make<Record<string, string>>({})
486
+ return {
487
+ generate: (request) =>
488
+ requestImages(cfg, request.model, request, [], learned).pipe(
489
+ Effect.provideService(HttpClient.HttpClient, client),
490
+ ),
491
+ edit: (request) =>
492
+ requestImages(cfg, request.model, request, request.images, learned).pipe(
493
+ Effect.provideService(HttpClient.HttpClient, client),
494
+ ),
495
+ streamGeneration: () => streamUnsupported("streamGeneration"),
496
+ streamEdit: () => streamUnsupported("streamEdit"),
497
+ }
498
+ })
499
+
500
+ /**
501
+ * Layer registering the provider-typed tag and the generic
502
+ * `ImageGenerator`. A `CommonImageGenerateRequest` is structurally a
503
+ * `FalImageGenerateRequest` with no vendor knobs set, so the generic
504
+ * registration forwards directly.
505
+ *
506
+ * Does NOT register `ImageStreaming`.
507
+ */
508
+ export const layer = (
509
+ cfg: Config,
510
+ ): Layer.Layer<FalImageGenerator | ImageGenerator, never, HttpClient.HttpClient> =>
511
+ Layer.merge(
512
+ Layer.effect(FalImageGenerator, make(cfg)),
513
+ Layer.effect(
514
+ ImageGenerator,
515
+ Effect.map(make(cfg), (s): ImageGeneratorService => ({
516
+ generate: (request: CommonImageGenerateRequest) =>
517
+ s.generate(request as FalImageGenerateRequest),
518
+ edit: (request: CommonImageEditRequest) => s.edit(request as FalImageEditRequest),
519
+ streamGeneration: (_request: CommonStreamImageRequest) =>
520
+ streamUnsupported("streamGeneration"),
521
+ streamEdit: (_request: CommonStreamImageEditRequest) => streamUnsupported("streamEdit"),
522
+ })),
523
+ ),
524
+ )
package/src/codec.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { Array as Arr, Effect, Match, Option, Schema, pipe } from "effect"
2
+ import * as AiError from "@effect-uai/core/AiError"
3
+
4
+ export const PROVIDER = "fal"
5
+
6
+ export const transportFailure = (cause: unknown): AiError.AiError =>
7
+ new AiError.Unavailable({ provider: PROVIDER, raw: cause })
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Errors
11
+ //
12
+ // fal validates request bodies with Pydantic, so a rejected field and a
13
+ // refused prompt arrive the same way: 422 with a `detail` array. The entry's
14
+ // `type` is what separates them. Errors that carry `detail` as a plain
15
+ // string (a missing app, a bad key) fail this decode and fall through to the
16
+ // status mapping, which is where they belong.
17
+ //
18
+ // Reference: https://fal.ai/docs/model-apis/errors
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const Detail = Schema.Struct({
22
+ loc: Schema.optional(Schema.Array(Schema.Unknown)),
23
+ msg: Schema.optional(Schema.String),
24
+ type: Schema.optional(Schema.String),
25
+ })
26
+ type Detail = typeof Detail.Type
27
+
28
+ /** Wrapped in `detail` off the gateway, bare off the runner. */
29
+ const WireError = Schema.Union([
30
+ Schema.Struct({ detail: Schema.Array(Detail) }),
31
+ Schema.Array(Detail),
32
+ ])
33
+ type WireError = typeof WireError.Type
34
+
35
+ const decodeWireError = Schema.decodeUnknownEffect(Schema.fromJsonString(WireError))
36
+
37
+ const detailsOf = (wire: WireError): ReadonlyArray<Detail> =>
38
+ "detail" in wire ? wire.detail : wire
39
+
40
+ /** The one refusal `type` fal documents; every other type is a bad field. */
41
+ const CONTENT_POLICY = "content_policy_violation"
42
+
43
+ const refusalOf = (wire: WireError, raw: string): Option.Option<AiError.AiError> =>
44
+ pipe(
45
+ detailsOf(wire),
46
+ Arr.findFirst((detail) => detail.type === CONTENT_POLICY),
47
+ Option.map(
48
+ (detail) =>
49
+ new AiError.ContentFiltered({
50
+ provider: PROVIDER,
51
+ ...(detail.msg !== undefined && { reason: detail.msg }),
52
+ raw,
53
+ }),
54
+ ),
55
+ )
56
+
57
+ const statusError = (status: number, body: string): AiError.AiError =>
58
+ Match.value(status).pipe(
59
+ Match.when(
60
+ 429,
61
+ (): AiError.AiError => new AiError.RateLimited({ provider: PROVIDER, raw: body }),
62
+ ),
63
+ Match.whenOr(
64
+ 408,
65
+ 504,
66
+ (): AiError.AiError => new AiError.Timeout({ provider: PROVIDER, raw: body }),
67
+ ),
68
+ Match.when(
69
+ 401,
70
+ (): AiError.AiError =>
71
+ new AiError.AuthFailed({ provider: PROVIDER, subtype: "auth", raw: body }),
72
+ ),
73
+ Match.when(
74
+ 403,
75
+ (): AiError.AiError =>
76
+ new AiError.AuthFailed({ provider: PROVIDER, subtype: "permission", raw: body }),
77
+ ),
78
+ Match.when(
79
+ 402,
80
+ (): AiError.AiError =>
81
+ new AiError.AuthFailed({ provider: PROVIDER, subtype: "billing", raw: body }),
82
+ ),
83
+ Match.when(
84
+ 413,
85
+ (): AiError.AiError => new AiError.ContextLengthExceeded({ provider: PROVIDER, raw: body }),
86
+ ),
87
+ Match.when(
88
+ (n) => n >= 500,
89
+ (n): AiError.AiError => new AiError.Unavailable({ provider: PROVIDER, status: n, raw: body }),
90
+ ),
91
+ Match.orElse(
92
+ (): AiError.AiError => new AiError.InvalidRequest({ provider: PROVIDER, raw: body }),
93
+ ),
94
+ )
95
+
96
+ /** Never fails: the returned error is the value. */
97
+ export const httpError = (status: number, body: string): Effect.Effect<AiError.AiError> =>
98
+ decodeWireError(body).pipe(
99
+ Effect.map((wire) => Option.getOrElse(refusalOf(wire, body), () => statusError(status, body))),
100
+ Effect.orElseSucceed(() => statusError(status, body)),
101
+ )
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Reference-field discovery
105
+ //
106
+ // Endpoints spell the reference field four different ways and the id does
107
+ // not say which. Rather than table fal's catalogue, read the answer off
108
+ // the endpoint's own validation error: a `missing` entry names the field
109
+ // it wanted in `loc`.
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** `image_url`, `image_urls`, `input_image_urls`, `reference_image_url`, … */
113
+ const IMAGE_FIELD = /^[a-z_]*image[a-z_]*_urls?$/
114
+
115
+ /**
116
+ * The reference field this endpoint asked for, if it rejected the request
117
+ * for want of one. `Effect` only because the decode is; it never fails.
118
+ */
119
+ export const missingImageField = (body: string): Effect.Effect<Option.Option<string>> =>
120
+ decodeWireError(body).pipe(
121
+ Effect.map((wire) =>
122
+ pipe(
123
+ detailsOf(wire),
124
+ Arr.filter((detail) => detail.type === "missing"),
125
+ Arr.map((detail) => detail.loc?.[1]),
126
+ Arr.findFirst((name): name is string => typeof name === "string" && IMAGE_FIELD.test(name)),
127
+ ),
128
+ ),
129
+ Effect.orElseSucceed(() => Option.none<string>()),
130
+ )
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * as FalImageGenerator from "./FalImageGenerator.js"
2
+ export * as codec from "./codec.js"
3
+ export * from "./models.js"