diff --git a/CHANGELOG.md b/CHANGELOG.md index e40fcfc..60cb152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- fal queue `status`/`result` responses now emit `x-fal-request-id`, and a `billableUnits` fixture field (e.g. `onFalQueue(model, json, { billableUnits })`) emits `x-fal-billable-units` on the completed result so adapters like `@tanstack/ai-fal` can surface `usage.unitsBilled` on replay. Record mode captures the upstream `x-fal-billable-units` header automatically, so recorded fixtures round-trip billing with no hand-editing (#269) + ## [1.31.0] - 2026-06-10 ### Added diff --git a/docs/fal-ai/index.html b/docs/fal-ai/index.html index 9312b2b..6340f54 100644 --- a/docs/fal-ai/index.html +++ b/docs/fal-ai/index.html @@ -145,6 +145,27 @@

Typed Helpers: onFalImage / onFalVideo

file_size: 0, seed: 0.

+

Billing Headers: x-fal-request-id / x-fal-billable-units

+

+ aimock always sets x-fal-request-id on queue status and + result responses, matching real fal. To exercise a consumer's cost accounting + (e.g. @tanstack/ai-fal's usage.unitsBilled), pass + billableUnits — it rides the completed result response as + x-fal-billable-units. Omit it to keep the header-less default. In record mode + the upstream x-fal-billable-units header is captured automatically and + written into the fixture's response.billableUnits, so recorded fixtures + round-trip billing with no hand-editing. +

+ +
+
billing.test.ts ts
+
mock.onFalImage(/flux/, {
+  images: [{ url: "https://mock.fal.media/x.png" }],
+}, { billableUnits: 42 });
+
+// completed queue-result now carries: x-fal-billable-units: 42
+
+

Client Configuration

Point the @fal-ai/client at aimock using requestMiddleware to diff --git a/src/__tests__/fal.test.ts b/src/__tests__/fal.test.ts index 6aa40e0..8e1516f 100644 --- a/src/__tests__/fal.test.ts +++ b/src/__tests__/fal.test.ts @@ -47,6 +47,116 @@ describe("fal.ai general handler — fixture lookup", () => { expect(resultBody).toEqual({ images: [{ url: "https://example.com/cat.png" }] }); }); + test("queue status/result responses carry x-fal-request-id", async () => { + mock = new LLMock({ port: 0 }); + mock.onFalQueue(/flux/, { images: [{ url: "https://example.com/cat.png" }] }); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "a cat" } }), + }); + const envelope = await submit.json(); + + const status = await fetch( + `${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}/status`, + { headers: { "x-fal-target-host": "queue.fal.run" } }, + ); + expect(status.headers.get("x-fal-request-id")).toBe(envelope.request_id); + + const result = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`, { + headers: { "x-fal-target-host": "queue.fal.run" }, + }); + expect(result.headers.get("x-fal-request-id")).toBe(envelope.request_id); + // No billableUnits configured → no x-fal-billable-units header. + expect(result.headers.get("x-fal-billable-units")).toBeNull(); + }); + + test("billableUnits opt-in emits x-fal-billable-units on completed result only", async () => { + mock = new LLMock({ port: 0 }); + mock.onFalQueue( + /flux/, + { images: [{ url: "https://example.com/cat.png" }] }, + { + billableUnits: 42, + }, + ); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "a cat" } }), + }); + const envelope = await submit.json(); + + const result = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`, { + headers: { "x-fal-target-host": "queue.fal.run" }, + }); + expect(result.status).toBe(200); + expect(result.headers.get("x-fal-request-id")).toBe(envelope.request_id); + expect(result.headers.get("x-fal-billable-units")).toBe("42"); + }); + + test("billableUnits: 0 still emits x-fal-billable-units (zero is a real billed count)", async () => { + // Guards the deliberate `!= null` / `Number.isFinite` checks: a truthy + // guard (`if (job.billableUnits)`) would drop the header for a zero-cost + // call, and every other billableUnits test would stay green. + mock = new LLMock({ port: 0 }); + mock.onFalQueue( + /flux/, + { images: [{ url: "https://example.com/cat.png" }] }, + { billableUnits: 0 }, + ); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "a cat" } }), + }); + const envelope = await submit.json(); + + const result = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`, { + headers: { "x-fal-target-host": "queue.fal.run" }, + }); + expect(result.status).toBe(200); + expect(result.headers.get("x-fal-billable-units")).toBe("0"); + }); + + test("billableUnits header is withheld until the result completes", async () => { + // Progression keeps the job IN_QUEUE on the first result poll, so the + // billable-units header must not ride the 202 — only the completed 200. + mock = new LLMock({ port: 0, falQueue: { pollsBeforeCompleted: 2 } }); + mock.onFalQueue( + /flux/, + { images: [{ url: "https://example.com/cat.png" }] }, + { + billableUnits: 7, + }, + ); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "a cat" } }), + }); + const envelope = await submit.json(); + const url = `${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`; + const headers = { "x-fal-target-host": "queue.fal.run" }; + + const pending = await fetch(url, { headers }); + expect(pending.status).toBe(202); + expect(pending.headers.get("x-fal-request-id")).toBe(envelope.request_id); + expect(pending.headers.get("x-fal-billable-units")).toBeNull(); + + const done = await fetch(url, { headers }); + expect(done.status).toBe(200); + expect(done.headers.get("x-fal-billable-units")).toBe("7"); + }); + test("body extraction handles input.prompt nesting (fal-client default shape)", async () => { mock = new LLMock({ port: 0 }); mock.onFalQueue(/flux/, { images: [{ url: "https://example.com/x.png" }] }); @@ -211,6 +321,8 @@ function startFalQueueUpstream(opts: { finalBody: unknown; pollsBeforeCompleted?: number; upstreamRequestId?: string; + /** When set, the GET result response carries this x-fal-billable-units header. */ + billableUnits?: string; }): Promise<{ url: string; close: () => Promise; @@ -255,7 +367,12 @@ function startFalQueueUpstream(opts: { } if (req.method === "GET" && resultMatch && !statusMatch) { counts.result++; - send(200, opts.finalBody); + const resultHeaders: Record = { "Content-Type": "application/json" }; + if (opts.billableUnits !== undefined) { + resultHeaders["x-fal-billable-units"] = opts.billableUnits; + } + res.writeHead(200, resultHeaders); + res.end(JSON.stringify(opts.finalBody)); return; } if (req.method === "POST") { @@ -378,6 +495,95 @@ describe("fal.ai general handler — record and replay", () => { expect(recorded.fixtures[0].response.json).toEqual(FINAL_BODY); }); + test("captures upstream x-fal-billable-units during recording → persists + replays it", async () => { + const FINAL_BODY = { images: [{ url: "https://mock.fal.media/files/billed.png" }] }; + queueUpstream = await startFalQueueUpstream({ + finalBody: FINAL_BODY, + pollsBeforeCompleted: 2, + billableUnits: "13", + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-queue-billed-")); + + mock = new LLMock({ + port: 0, + record: { + providers: { fal: queueUpstream.url }, + fixturePath: tmpDir, + fal: { pollIntervalMs: 5, timeoutMs: 5000 }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "a cat" } }), + }); + const envelope = await submit.json(); + + // Same-session replay surfaces the captured units without reloading the fixture. + const result = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`, { + headers: { "x-fal-target-host": "queue.fal.run" }, + }); + expect(result.status).toBe(200); + expect(result.headers.get("x-fal-billable-units")).toBe("13"); + + // Persisted fixture carries response.billableUnits so a fresh load also replays it. + const files = fs.readdirSync(tmpDir).filter((f) => f.startsWith("fal-") && f.endsWith(".json")); + expect(files.length).toBe(1); + const recorded = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")); + expect(recorded.fixtures[0].response.billableUnits).toBe(13); + }); + + test.each([ + ["absent", undefined], + ["non-numeric", "not-a-number"], + ])( + "recording omits billableUnits when the upstream header is %s", + async (_label, headerValue) => { + const FINAL_BODY = { images: [{ url: "https://mock.fal.media/files/unbilled.png" }] }; + queueUpstream = await startFalQueueUpstream({ + finalBody: FINAL_BODY, + pollsBeforeCompleted: 2, + billableUnits: headerValue, + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-queue-unbilled-")); + + mock = new LLMock({ + port: 0, + record: { + providers: { fal: queueUpstream.url }, + fixturePath: tmpDir, + fal: { pollIntervalMs: 5, timeoutMs: 5000 }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "a cat" } }), + }); + const envelope = await submit.json(); + + // No usable upstream units → no header on the same-session replay. + const result = await fetch( + `${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`, + { headers: { "x-fal-target-host": "queue.fal.run" } }, + ); + expect(result.status).toBe(200); + expect(result.headers.get("x-fal-billable-units")).toBeNull(); + + // …and the persisted fixture stays clean: no billableUnits key at all. + const files = fs + .readdirSync(tmpDir) + .filter((f) => f.startsWith("fal-") && f.endsWith(".json")); + expect(files.length).toBe(1); + const recorded = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")); + expect("billableUnits" in recorded.fixtures[0].response).toBe(false); + }, + ); + test("mock-internal headers never reach the upstream on the recorded queue walk", async () => { // CHANGELOG/docs claim x-test-id / x-aimock-strict / x-aimock-context / // x-aimock-chaos-* are stripped "on every provider proxy path" — pin the diff --git a/src/fal-audio.ts b/src/fal-audio.ts index f2a01b8..62aa6a1 100644 --- a/src/fal-audio.ts +++ b/src/fal-audio.ts @@ -569,7 +569,10 @@ async function tryRecordAudioQueueWalk(args: { let finalBody: unknown; try { - finalBody = await walkFalQueue({ + // The legacy audio queue stores an AudioResponse and serves its own + // headers, so the walk's captured billableUnits doesn't apply here — take + // the body only. + ({ body: finalBody } = await walkFalQueue({ upstreamBase, submitPath: pathname, body, @@ -581,7 +584,7 @@ async function tryRecordAudioQueueWalk(args: { fallbackStatusPath: (id) => `/fal/queue/requests/${id}/status`, fallbackResultPath: (id) => `/fal/queue/requests/${id}`, logger: defaults.logger, - }); + })); } catch (err) { const msg = err instanceof Error ? err.message : "Unknown queue-walk error"; defaults.logger.error(`fal-audio queue-walk proxy failed: ${msg}`); diff --git a/src/fal.ts b/src/fal.ts index ec6bd9a..9fb16ba 100644 --- a/src/fal.ts +++ b/src/fal.ts @@ -51,6 +51,12 @@ interface FalQueueJob { modelId: string; status: FalQueueStatus; result: unknown; + /** + * Billed quantity emitted as the `x-fal-billable-units` header on the + * completed `queue-result` response. Sourced from the fixture's + * `response.billableUnits`; `null` when the fixture omitted it (no header). + */ + billableUnits: number | null; /** Number of `/status` (or `/{id}`) polls the caller has made against this job. */ pollCount: number; /** Poll-count threshold for `IN_QUEUE → IN_PROGRESS` transition. */ @@ -268,6 +274,32 @@ const FAL_HOSTS = { gateway: "gateway.fal.ai", } as const; +// Headers real fal sets on its queue responses. `@tanstack/ai-fal` (and +// likely other adapters) read both to correlate a billed quantity with the +// originating request: the request-id keys the lookup, the billable-units +// value is the quantity. aimock emits the request-id on the `status` and +// `result` queue responses (where adapters perform the billing lookup); the +// submit envelope already carries `request_id` in its JSON body, and the +// cancel / not-found responses omit it. The billable-units header rides only +// the completed `result`, and only when a fixture opts in via +// `response.billableUnits`. +const FAL_REQUEST_ID_HEADER = "x-fal-request-id"; +const FAL_BILLABLE_UNITS_HEADER = "x-fal-billable-units"; + +/** + * Read an optional `billableUnits` off a resolved fixture response. Returns the + * finite numeric value or `null` (absent, non-numeric, or non-finite) — the + * `x-fal-billable-units` header is emitted only for a non-null result. + */ +function extractBillableUnits(response: unknown): number | null { + if (response && typeof response === "object" && "billableUnits" in response) { + // The `in` narrowing types `response.billableUnits` as `unknown` — no cast. + const value: unknown = response.billableUnits; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return null; +} + const QUEUE_REQUESTS_RE = /^(.+)\/requests\/([^/]+)(\/status|\/cancel)?$/; const STORAGE_INITIATE_PATH = "/storage/upload/initiate"; @@ -416,7 +448,9 @@ export async function handleFal( return "handled"; } advanceJob(job); - writeJson(req, res, 200, statusResponseBody(job), pathname, journal); + writeJson(req, res, 200, statusResponseBody(job), pathname, journal, { + [FAL_REQUEST_ID_HEADER]: job.requestId, + }); return "handled"; } @@ -429,11 +463,19 @@ export async function handleFal( // Callers may fetch result without first polling status — advance so // tests that skip the status check still reach completion. advanceJob(job); + // Emit x-fal-request-id on both the in-progress (202) and completed (200) + // result; adapters key their billing lookup off it. + const resultHeaders: Record = { [FAL_REQUEST_ID_HEADER]: job.requestId }; if (job.status !== "COMPLETED") { - writeJson(req, res, 202, statusResponseBody(job), pathname, journal); + writeJson(req, res, 202, statusResponseBody(job), pathname, journal, resultHeaders); return "handled"; } - writeJson(req, res, 200, job.result, pathname, journal); + // x-fal-billable-units rides only the completed result, and only when the + // fixture opted into a billed quantity. + if (job.billableUnits != null) { + resultHeaders[FAL_BILLABLE_UNITS_HEADER] = String(job.billableUnits); + } + writeJson(req, res, 200, job.result, pathname, journal, resultHeaders); return "handled"; } @@ -722,6 +764,7 @@ export async function handleFal( modelId, status: initialStatus, result: payload, + billableUnits: extractBillableUnits(response), pollCount: 0, pollsBeforeInProgress: progression.pollsBeforeInProgress, pollsBeforeCompleted: progression.pollsBeforeCompleted, @@ -795,10 +838,23 @@ const DEFAULT_FAL_FETCH_TIMEOUT_MS = 30_000; // fal queue walk never leaks control headers onto a real provider's wire // (one shared list, not a per-surface copy, so the surfaces cannot drift). +/** + * The result of a queue walk: the parsed final body plus the billed quantity + * captured from the result fetch's `x-fal-billable-units` header (`null` when + * upstream didn't set it). The caller persists `body` as the fixture and seeds + * local queue state; `billableUnits` round-trips real fal billing so a recorded + * fixture replays `x-fal-billable-units` without hand-editing. + */ +export interface FalQueueWalkResult { + body: unknown; + billableUnits: number | null; +} + /** * Walk a fal-shaped queue protocol upstream: POST submit, poll status until - * COMPLETED, GET final result body. Returns the parsed final body so the caller - * can persist it as the fixture and seed local queue state. + * COMPLETED, GET final result. Returns the parsed final body and the result + * response's billed quantity so the caller can persist the fixture and seed + * local queue state. * * Decoupled from the route layer so the legacy `/fal/queue/submit/{model}` * audio path (`fal-audio.ts`) can reuse the same logic. @@ -829,7 +885,7 @@ export async function walkFalQueue(args: { fallbackResultPath: (requestId: string) => string; /** Warn sink for the same-origin envelope-URL gate (omitting it only mutes the warns). */ logger?: Logger; -}): Promise { +}): Promise { const { upstreamBase, submitPath, @@ -947,7 +1003,21 @@ export async function walkFalQueue(args: { if (!resultRes.ok) { throw new Error(`Result ${resultRes.status}: ${resultText.slice(0, 200)}`); } - return parseJsonOrThrow(resultText, "Result"); + return { + body: parseJsonOrThrow(resultText, "Result"), + billableUnits: parseBillableUnitsHeader(resultRes.headers.get(FAL_BILLABLE_UNITS_HEADER)), + }; +} + +/** + * Parse the upstream `x-fal-billable-units` header into a finite number, or + * `null` when absent/unparseable — mirrors the consumer-side parse so a + * recorded fixture's captured units match what the live adapter would have read. + */ +function parseBillableUnitsHeader(raw: string | null): number | null { + if (raw == null) return null; + const value = Number(raw.trim()); + return Number.isFinite(value) ? value : null; } async function proxyAndRecordFalQueueSubmit(args: { @@ -988,8 +1058,9 @@ async function proxyAndRecordFalQueueSubmit(args: { defaults.logger.warn(`NO FIXTURE MATCH — walking fal queue at ${upstreamBase}${strippedPath}`); let finalBody: unknown; + let billableUnits: number | null; try { - finalBody = await walkFalQueue({ + const walk = await walkFalQueue({ upstreamBase, submitPath: strippedPath, body, @@ -1001,6 +1072,8 @@ async function proxyAndRecordFalQueueSubmit(args: { fallbackResultPath: (id) => `${modelId}/requests/${id}`, logger: defaults.logger, }); + finalBody = walk.body; + billableUnits = walk.billableUnits; } catch (err) { const msg = err instanceof Error ? err.message : "Unknown queue-walk error"; defaults.logger.error(`fal queue-walk proxy failed: ${msg}`); @@ -1030,7 +1103,14 @@ async function proxyAndRecordFalQueueSubmit(args: { : syntheticReq; const fixture: Fixture = { match: buildFixtureMatch(matchRequest, record), - response: { json: finalBody, status: 200 }, + // Persist the captured billed quantity so the recorded fixture replays + // x-fal-billable-units verbatim; omit the field entirely when upstream + // didn't bill (keeps recorded fixtures clean). + response: { + json: finalBody, + status: 200, + ...(billableUnits != null ? { billableUnits } : {}), + }, }; const persistResult = persistFixture({ record, @@ -1058,6 +1138,10 @@ async function proxyAndRecordFalQueueSubmit(args: { modelId, status: initialStatus, result: finalBody, + // Captured from the upstream result's x-fal-billable-units header so the + // same-session replay (before the persisted fixture is reloaded) already + // surfaces the billed quantity. + billableUnits, pollCount: 0, pollsBeforeInProgress: progression.pollsBeforeInProgress, pollsBeforeCompleted: progression.pollsBeforeCompleted, @@ -1125,6 +1209,7 @@ function writeJson( payload: unknown, pathname: string, journal: Journal, + extraHeaders?: Record, ): void { journal.add({ method: req.method ?? "GET", @@ -1133,7 +1218,7 @@ function writeJson( body: null, response: { status, fixture: null }, }); - res.writeHead(status, { "Content-Type": "application/json" }); + res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders }); res.end(JSON.stringify(payload)); } diff --git a/src/llmock.ts b/src/llmock.ts index e74d6e2..ecac47e 100644 --- a/src/llmock.ts +++ b/src/llmock.ts @@ -2,6 +2,7 @@ import type { AudioResponse, ChaosConfig, EmbeddingFixtureOpts, + FalQueueOpts, Fixture, FixtureFileEntry, FixtureFileResponse, @@ -226,15 +227,21 @@ export class LLMock { } // fal.queue.* is the dominant client API; onFalRun is a sync alias. - onFalQueue(modelOrPrompt: string | RegExp, response: unknown, opts?: FixtureOpts): this { + // + // `opts.billableUnits` rides through to the completed `queue-result` + // response's `x-fal-billable-units` header (emitted alongside + // `x-fal-request-id`), letting consumers like `@tanstack/ai-fal` surface a + // billed-units value on replay. Omit it to preserve the header-less default. + onFalQueue(modelOrPrompt: string | RegExp, response: unknown, opts?: FalQueueOpts): this { + const { billableUnits, ...fixtureOpts } = opts ?? {}; return this.addFixture({ match: { model: modelOrPrompt, endpoint: "fal" }, - response: { json: response }, - ...opts, + response: { json: response, ...(billableUnits != null ? { billableUnits } : {}) }, + ...fixtureOpts, }); } - onFalRun(modelOrPrompt: string | RegExp, response: unknown, opts?: FixtureOpts): this { + onFalRun(modelOrPrompt: string | RegExp, response: unknown, opts?: FalQueueOpts): this { return this.onFalQueue(modelOrPrompt, response, opts); } @@ -244,7 +251,7 @@ export class LLMock { * before storing it as a `RawJSONResponse`. Defaults `width`/`height` to * 1024 when the fixture's `ImageItem` doesn't carry them. */ - onFalImage(modelOrPrompt: string | RegExp, response: ImageResponse, opts?: FixtureOpts): this { + onFalImage(modelOrPrompt: string | RegExp, response: ImageResponse, opts?: FalQueueOpts): this { return this.onFalQueue(modelOrPrompt, imageResponseToFalJson(response), opts); } @@ -253,7 +260,7 @@ export class LLMock { * envelope (`{ video: { url, content_type, file_name, file_size }, seed }`) * before storing it as a `RawJSONResponse`. */ - onFalVideo(modelOrPrompt: string | RegExp, response: VideoResponse, opts?: FixtureOpts): this { + onFalVideo(modelOrPrompt: string | RegExp, response: VideoResponse, opts?: FalQueueOpts): this { return this.onFalQueue(modelOrPrompt, videoResponseToFalJson(response), opts); } diff --git a/src/types.ts b/src/types.ts index 0a7f8dc..72f6e48 100644 --- a/src/types.ts +++ b/src/types.ts @@ -272,6 +272,16 @@ export interface VideoResponse { export interface RawJSONResponse extends ResponseOverrides { json: unknown; status?: number; + /** + * Billed quantity surfaced on the completed fal `queue-result` response via + * the `x-fal-billable-units` header (emitted alongside `x-fal-request-id` on + * the completed result). Real fal sets this header on its queue responses; + * recent versions of `@tanstack/ai-fal` read it to populate a consumer-side + * billed-units field (e.g. `usage.unitsBilled`). Omit it to preserve the + * header-less default — present only to let a fixture opt into exercising a + * consumer's cost/billing accounting path on replay. + */ + billableUnits?: number; } export type FixtureResponse = @@ -375,6 +385,12 @@ export interface Fixture { export type FixtureOpts = Omit; export type EmbeddingFixtureOpts = Pick; +/** + * Options for the fal queue/run fixture builders. Adds `billableUnits`, which + * is emitted as the `x-fal-billable-units` header on the completed + * `queue-result` response (see {@link RawJSONResponse.billableUnits}). + */ +export type FalQueueOpts = FixtureOpts & { billableUnits?: number }; // Fixture file format (JSON on disk) //