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.
+
+
+
+
+
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)
//