@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.
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/FalImageGenerator.d.mts +152 -0
- package/dist/FalImageGenerator.d.mts.map +1 -0
- package/dist/FalImageGenerator.mjs +273 -0
- package/dist/FalImageGenerator.mjs.map +1 -0
- package/dist/codec.d.mts +17 -0
- package/dist/codec.d.mts.map +1 -0
- package/dist/codec.mjs +73 -0
- package/dist/codec.mjs.map +1 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +4 -0
- package/dist/models.d.mts +32 -0
- package/dist/models.d.mts.map +1 -0
- package/dist/models.mjs +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +59 -0
- package/src/FalImageGenerator.ts +524 -0
- package/src/codec.ts +130 -0
- package/src/index.ts +3 -0
- package/src/models.ts +49 -0
|
@@ -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