@copilotkit/aimock 1.31.0 → 1.32.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/dist/fal.cjs CHANGED
@@ -164,6 +164,20 @@ const FAL_HOSTS = {
164
164
  storageAlpha: "rest.alpha.fal.ai",
165
165
  gateway: "gateway.fal.ai"
166
166
  };
167
+ const FAL_REQUEST_ID_HEADER = "x-fal-request-id";
168
+ const FAL_BILLABLE_UNITS_HEADER = "x-fal-billable-units";
169
+ /**
170
+ * Read an optional `billableUnits` off a resolved fixture response. Returns the
171
+ * finite numeric value or `null` (absent, non-numeric, or non-finite) — the
172
+ * `x-fal-billable-units` header is emitted only for a non-null result.
173
+ */
174
+ function extractBillableUnits(response) {
175
+ if (response && typeof response === "object" && "billableUnits" in response) {
176
+ const value = response.billableUnits;
177
+ if (typeof value === "number" && Number.isFinite(value)) return value;
178
+ }
179
+ return null;
180
+ }
167
181
  const QUEUE_REQUESTS_RE = /^(.+)\/requests\/([^/]+)(\/status|\/cancel)?$/;
168
182
  const STORAGE_INITIATE_PATH = "/storage/upload/initiate";
169
183
  function stripFalPrefix(pathname) {
@@ -274,7 +288,7 @@ async function handleFal(req, res, body, pathname, fixtures, defaults, journal)
274
288
  return "handled";
275
289
  }
276
290
  advanceJob(job);
277
- writeJson(req, res, 200, statusResponseBody(job), pathname, journal);
291
+ writeJson(req, res, 200, statusResponseBody(job), pathname, journal, { [FAL_REQUEST_ID_HEADER]: job.requestId });
278
292
  return "handled";
279
293
  }
280
294
  case "queue-result": {
@@ -284,11 +298,13 @@ async function handleFal(req, res, body, pathname, fixtures, defaults, journal)
284
298
  return "handled";
285
299
  }
286
300
  advanceJob(job);
301
+ const resultHeaders = { [FAL_REQUEST_ID_HEADER]: job.requestId };
287
302
  if (job.status !== "COMPLETED") {
288
- writeJson(req, res, 202, statusResponseBody(job), pathname, journal);
303
+ writeJson(req, res, 202, statusResponseBody(job), pathname, journal, resultHeaders);
289
304
  return "handled";
290
305
  }
291
- writeJson(req, res, 200, job.result, pathname, journal);
306
+ if (job.billableUnits != null) resultHeaders[FAL_BILLABLE_UNITS_HEADER] = String(job.billableUnits);
307
+ writeJson(req, res, 200, job.result, pathname, journal, resultHeaders);
292
308
  return "handled";
293
309
  }
294
310
  case "queue-cancel": {
@@ -547,6 +563,7 @@ async function handleFal(req, res, body, pathname, fixtures, defaults, journal)
547
563
  modelId,
548
564
  status: initialStatus,
549
565
  result: payload,
566
+ billableUnits: extractBillableUnits(response),
550
567
  pollCount: 0,
551
568
  pollsBeforeInProgress: progression.pollsBeforeInProgress,
552
569
  pollsBeforeCompleted: progression.pollsBeforeCompleted,
@@ -597,8 +614,9 @@ const DEFAULT_FAL_TIMEOUT_MS = 9e5;
597
614
  const DEFAULT_FAL_FETCH_TIMEOUT_MS = 3e4;
598
615
  /**
599
616
  * Walk a fal-shaped queue protocol upstream: POST submit, poll status until
600
- * COMPLETED, GET final result body. Returns the parsed final body so the caller
601
- * can persist it as the fixture and seed local queue state.
617
+ * COMPLETED, GET final result. Returns the parsed final body and the result
618
+ * response's billed quantity so the caller can persist the fixture and seed
619
+ * local queue state.
602
620
  *
603
621
  * Decoupled from the route layer so the legacy `/fal/queue/submit/{model}`
604
622
  * audio path (`fal-audio.ts`) can reuse the same logic.
@@ -660,7 +678,20 @@ async function walkFalQueue(args) {
660
678
  });
661
679
  const resultText = await resultRes.text();
662
680
  if (!resultRes.ok) throw new Error(`Result ${resultRes.status}: ${resultText.slice(0, 200)}`);
663
- return parseJsonOrThrow(resultText, "Result");
681
+ return {
682
+ body: parseJsonOrThrow(resultText, "Result"),
683
+ billableUnits: parseBillableUnitsHeader(resultRes.headers.get(FAL_BILLABLE_UNITS_HEADER))
684
+ };
685
+ }
686
+ /**
687
+ * Parse the upstream `x-fal-billable-units` header into a finite number, or
688
+ * `null` when absent/unparseable — mirrors the consumer-side parse so a
689
+ * recorded fixture's captured units match what the live adapter would have read.
690
+ */
691
+ function parseBillableUnitsHeader(raw) {
692
+ if (raw == null) return null;
693
+ const value = Number(raw.trim());
694
+ return Number.isFinite(value) ? value : null;
664
695
  }
665
696
  async function proxyAndRecordFalQueueSubmit(args) {
666
697
  const { req, res, syntheticReq, modelId, pathname, strippedPath, body, fixtures, defaults, stateKey, journal } = args;
@@ -673,8 +704,9 @@ async function proxyAndRecordFalQueueSubmit(args) {
673
704
  }
674
705
  defaults.logger.warn(`NO FIXTURE MATCH — walking fal queue at ${upstreamBase}${strippedPath}`);
675
706
  let finalBody;
707
+ let billableUnits;
676
708
  try {
677
- finalBody = await walkFalQueue({
709
+ const walk = await walkFalQueue({
678
710
  upstreamBase,
679
711
  submitPath: strippedPath,
680
712
  body,
@@ -686,6 +718,8 @@ async function proxyAndRecordFalQueueSubmit(args) {
686
718
  fallbackResultPath: (id) => `${modelId}/requests/${id}`,
687
719
  logger: defaults.logger
688
720
  });
721
+ finalBody = walk.body;
722
+ billableUnits = walk.billableUnits;
689
723
  } catch (err) {
690
724
  const msg = err instanceof Error ? err.message : "Unknown queue-walk error";
691
725
  defaults.logger.error(`fal queue-walk proxy failed: ${msg}`);
@@ -712,7 +746,8 @@ async function proxyAndRecordFalQueueSubmit(args) {
712
746
  match: require_recorder.buildFixtureMatch(defaults.requestTransform ? defaults.requestTransform(syntheticReq) : syntheticReq, record),
713
747
  response: {
714
748
  json: finalBody,
715
- status: 200
749
+ status: 200,
750
+ ...billableUnits != null ? { billableUnits } : {}
716
751
  }
717
752
  };
718
753
  const persistResult = require_recorder.persistFixture({
@@ -733,6 +768,7 @@ async function proxyAndRecordFalQueueSubmit(args) {
733
768
  modelId,
734
769
  status: initialStatus,
735
770
  result: finalBody,
771
+ billableUnits,
736
772
  pollCount: 0,
737
773
  pollsBeforeInProgress: progression.pollsBeforeInProgress,
738
774
  pollsBeforeCompleted: progression.pollsBeforeCompleted,
@@ -790,7 +826,7 @@ function withFalUpstream(defaults, targetHost) {
790
826
  }
791
827
  };
792
828
  }
793
- function writeJson(req, res, status, payload, pathname, journal) {
829
+ function writeJson(req, res, status, payload, pathname, journal, extraHeaders) {
794
830
  journal.add({
795
831
  method: req.method ?? "GET",
796
832
  path: pathname,
@@ -801,7 +837,10 @@ function writeJson(req, res, status, payload, pathname, journal) {
801
837
  fixture: null
802
838
  }
803
839
  });
804
- res.writeHead(status, { "Content-Type": "application/json" });
840
+ res.writeHead(status, {
841
+ "Content-Type": "application/json",
842
+ ...extraHeaders
843
+ });
805
844
  res.end(JSON.stringify(payload));
806
845
  }
807
846
  function respondNotFound(req, res, pathname, journal, requestId) {
package/dist/fal.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fal.cjs","names":["getTestId","flattenHeaders","crypto","getContext","matchFixtureDiagnostic","resolveStrictMode","strictNoMatchMessage","strictNoMatchLogLine","strictOverrideField","proxyAndRecord","resolveResponse","isErrorResponse","serializeErrorResponse","isJSONResponse","isAudioResponse","audioToFalFile","clampTimeout","resolveUpstreamUrl","buildForwardHeaders","buildFixtureMatch","persistFixture","sanitizeHeaderValue"],"sources":["../src/fal.ts"],"sourcesContent":["import type http from \"node:http\";\nimport crypto from \"node:crypto\";\nimport type {\n ChatCompletionRequest,\n FalQueueConfig,\n Fixture,\n HandlerDefaults,\n ImageItem,\n ImageResponse,\n RawJSONResponse,\n VideoResponse,\n} from \"./types.js\";\nimport {\n isAudioResponse,\n isErrorResponse,\n serializeErrorResponse,\n isJSONResponse,\n flattenHeaders,\n getContext,\n getTestId,\n resolveResponse,\n resolveStrictMode,\n strictOverrideField,\n strictNoMatchMessage,\n strictNoMatchLogLine,\n} from \"./helpers.js\";\nimport { writeErrorResponse } from \"./sse-writer.js\";\nimport { matchFixtureDiagnostic } from \"./router.js\";\nimport type { Logger } from \"./logger.js\";\nimport {\n buildFixtureMatch,\n buildForwardHeaders,\n clampTimeout,\n persistFixture,\n proxyAndRecord,\n sanitizeHeaderValue,\n} from \"./recorder.js\";\nimport { resolveUpstreamUrl } from \"./url.js\";\nimport type { Journal } from \"./journal.js\";\nimport { audioToFalFile } from \"./fal-audio.js\";\n\n// ─── FalQueueState (TTL + bounded) ───────────────────────────────────────\n\nconst FAL_QUEUE_MAX_ENTRIES = 10_000;\nconst FAL_QUEUE_TTL_MS = 3_600_000; // 1 hour\n\ntype FalQueueStatus = \"IN_QUEUE\" | \"IN_PROGRESS\" | \"COMPLETED\" | \"CANCELLED\";\n\ninterface FalQueueJob {\n requestId: string;\n modelId: string;\n status: FalQueueStatus;\n result: unknown;\n /** Number of `/status` (or `/{id}`) polls the caller has made against this job. */\n pollCount: number;\n /** Poll-count threshold for `IN_QUEUE → IN_PROGRESS` transition. */\n pollsBeforeInProgress: number;\n /** Poll-count threshold for `IN_PROGRESS → COMPLETED` transition. */\n pollsBeforeCompleted: number;\n submittedAt: number;\n completedAt: number | null;\n /** State-transition log entries surfaced in the `/status` response. */\n logs: Array<{ timestamp: string; level: string; message: string }>;\n createdAt: number;\n}\n\ninterface FalQueueEntry {\n job: FalQueueJob;\n createdAt: number;\n}\n\n/**\n * Per-testId queue state for the general fal handler. Mirrors FalJobMap from\n * fal-audio.ts but stores arbitrary JSON payloads instead of audio file\n * objects, so it can serve any fal model (image, video, motion, music, etc.).\n */\nexport class FalQueueStateMap {\n private readonly entries = new Map<string, FalQueueEntry>();\n\n get(key: string): FalQueueJob | undefined {\n const entry = this.entries.get(key);\n if (!entry) return undefined;\n if (Date.now() - entry.createdAt > FAL_QUEUE_TTL_MS) {\n this.entries.delete(key);\n return undefined;\n }\n return entry.job;\n }\n\n set(key: string, job: FalQueueJob): void {\n this.entries.set(key, { job, createdAt: Date.now() });\n if (this.entries.size > FAL_QUEUE_MAX_ENTRIES) {\n const excess = this.entries.size - FAL_QUEUE_MAX_ENTRIES;\n const iter = this.entries.keys();\n for (let i = 0; i < excess; i++) {\n const next = iter.next();\n if (!next.done) this.entries.delete(next.value);\n }\n }\n }\n\n delete(key: string): boolean {\n return this.entries.delete(key);\n }\n\n clear(): void {\n this.entries.clear();\n }\n\n get size(): number {\n return this.entries.size;\n }\n}\n\nexport const falQueueStates = new FalQueueStateMap();\n\n// ─── Typed-response → fal envelope converters ───────────────────────────\n\nfunction extractExtension(url: string, fallback: string): { fileName: string; ext: string } {\n const segment = url.split(\"?\")[0].split(\"#\")[0].split(\"/\").pop() ?? \"\";\n const fileName = segment.length > 0 ? segment : \"\";\n const dotIdx = fileName.lastIndexOf(\".\");\n const ext = dotIdx >= 0 ? fileName.slice(dotIdx + 1).toLowerCase() : fallback;\n return { fileName, ext };\n}\n\nfunction imageItemToFalImage(item: ImageItem, index: number): Record<string, unknown> {\n const url = item.url ?? `https://mock.fal.media/files/generated_image_${index}.png`;\n const { ext } = extractExtension(url, \"png\");\n const contentType = ext === \"jpg\" || ext === \"jpeg\" ? \"image/jpeg\" : `image/${ext}`;\n return {\n url,\n width: 1024,\n height: 1024,\n content_type: contentType,\n };\n}\n\n/**\n * Translate an `ImageResponse` fixture into fal's image envelope shape:\n * `{ images: [...], timings, seed, has_nsfw_concepts, prompt }`.\n * Used by `LLMock.onFalImage` to keep callers from re-deriving the wire shape.\n */\nexport function imageResponseToFalJson(response: ImageResponse): Record<string, unknown> {\n const items = response.images ?? (response.image ? [response.image] : []);\n const images = items.map((item, i) => imageItemToFalImage(item, i));\n return {\n images,\n timings: { inference: 0 },\n seed: 0,\n has_nsfw_concepts: images.map(() => false),\n prompt: \"\",\n };\n}\n\n/**\n * Translate a `VideoResponse` fixture into fal's video envelope shape:\n * `{ video: { url, content_type, file_name, file_size }, seed }`.\n */\nexport function videoResponseToFalJson(response: VideoResponse): Record<string, unknown> {\n const url = response.video.url ?? \"https://mock.fal.media/files/generated_video.mp4\";\n const { fileName, ext } = extractExtension(url, \"mp4\");\n return {\n video: {\n url,\n content_type: `video/${ext}`,\n file_name: fileName || \"generated_video.mp4\",\n file_size: 0,\n },\n seed: 0,\n };\n}\n\n// ─── Queue progression ─────────────────────────────────────────────────\n\nexport function resolveProgression(config: FalQueueConfig | undefined): {\n pollsBeforeInProgress: number;\n pollsBeforeCompleted: number;\n} {\n // Thresholds must resolve to non-negative integers: a NaN would make\n // advanceJob's `pollCount >= threshold` comparison permanently false (a\n // polling client never reaches terminal), and a negative would break the\n // documented \"explicit 0 enables progression\" contract. Non-finite values\n // are treated as unset; anything else is floored and clamped to >= 0.\n const sanitize = (value: number | undefined): number | undefined =>\n typeof value === \"number\" && Number.isFinite(value)\n ? Math.max(0, Math.floor(value))\n : undefined;\n const explicitInProgress = sanitize(config?.pollsBeforeInProgress);\n const explicitCompleted = sanitize(config?.pollsBeforeCompleted);\n const pollsBeforeInProgress = explicitInProgress ?? 0;\n // When only pollsBeforeInProgress is set, default pollsBeforeCompleted to one\n // poll later so the job actually passes through IN_PROGRESS. When the caller\n // sets both explicitly, clamp completed >= inProgress so a misconfigured\n // pair (e.g. completed < inProgress) can't silently skip the IN_PROGRESS\n // transition. When neither is set, both stay 0 (completes on submit).\n let pollsBeforeCompleted: number;\n if (explicitCompleted !== undefined) {\n pollsBeforeCompleted = Math.max(pollsBeforeInProgress, explicitCompleted);\n } else if (explicitInProgress !== undefined) {\n pollsBeforeCompleted = pollsBeforeInProgress + 1;\n } else {\n pollsBeforeCompleted = 0;\n }\n return { pollsBeforeInProgress, pollsBeforeCompleted };\n}\n\n/**\n * Mutates a job in place to advance its state on a status/result poll.\n * IN_QUEUE → IN_PROGRESS → COMPLETED based on poll-count thresholds. No-op\n * once COMPLETED or CANCELLED.\n */\nfunction advanceJob(job: FalQueueJob): void {\n if (job.status === \"COMPLETED\" || job.status === \"CANCELLED\") return;\n\n job.pollCount += 1;\n // Check IN_PROGRESS before COMPLETED so a job whose thresholds are equal\n // still spends one poll in IN_PROGRESS instead of jumping straight to\n // COMPLETED.\n if (job.status === \"IN_QUEUE\" && job.pollCount >= job.pollsBeforeInProgress) {\n job.status = \"IN_PROGRESS\";\n job.logs.push({\n timestamp: new Date().toISOString(),\n level: \"INFO\",\n message: \"Job started processing.\",\n });\n } else if (job.pollCount >= job.pollsBeforeCompleted) {\n job.status = \"COMPLETED\";\n job.completedAt = Date.now();\n job.logs.push({\n timestamp: new Date().toISOString(),\n level: \"INFO\",\n message: \"Job completed.\",\n });\n }\n}\n\nfunction queuePosition(job: FalQueueJob): number {\n if (job.status !== \"IN_QUEUE\") return 0;\n return Math.max(0, job.pollsBeforeInProgress - job.pollCount);\n}\n\nfunction statusResponseBody(job: FalQueueJob): Record<string, unknown> {\n const body: Record<string, unknown> = {\n status: job.status,\n request_id: job.requestId,\n response_url: `https://${FAL_HOSTS.queue}/${job.modelId}/requests/${job.requestId}`,\n logs: job.logs,\n };\n if (job.status === \"IN_QUEUE\" || job.status === \"IN_PROGRESS\") {\n body.queue_position = queuePosition(job);\n }\n if (job.status === \"COMPLETED\" && job.completedAt != null) {\n body.metrics = {\n inference_time: (job.completedAt - job.submittedAt) / 1000,\n };\n }\n return body;\n}\n\n// ─── Hosts and routing ──────────────────────────────────────────────────\n\nconst FAL_HOSTS = {\n queue: \"queue.fal.run\",\n sync: \"fal.run\",\n storage: \"rest.fal.ai\",\n storageAlpha: \"rest.alpha.fal.ai\",\n gateway: \"gateway.fal.ai\",\n} as const;\n\nconst QUEUE_REQUESTS_RE = /^(.+)\\/requests\\/([^/]+)(\\/status|\\/cancel)?$/;\nconst STORAGE_INITIATE_PATH = \"/storage/upload/initiate\";\n\nfunction stripFalPrefix(pathname: string): string {\n const stripped = pathname.replace(/^\\/fal/, \"\");\n return stripped.length > 0 ? stripped : \"/\";\n}\n\nfunction extractPromptFromBody(body: unknown): string {\n if (!body || typeof body !== \"object\") return \"\";\n const obj = body as Record<string, unknown>;\n if (typeof obj.prompt === \"string\") return obj.prompt;\n if (typeof obj.text === \"string\") return obj.text;\n const input = obj.input;\n if (input && typeof input === \"object\") {\n const inputObj = input as Record<string, unknown>;\n if (typeof inputObj.prompt === \"string\") return inputObj.prompt;\n if (typeof inputObj.text === \"string\") return inputObj.text;\n }\n return \"\";\n}\n\ninterface ParsedFalPath {\n modelId: string;\n requestId?: string;\n action?: \"status\" | \"cancel\" | \"result\";\n}\n\nfunction parseFalPath(stripped: string): ParsedFalPath | null {\n if (!stripped.startsWith(\"/\")) return null;\n const trimmed = stripped.replace(/^\\/+/, \"\");\n if (!trimmed) return null;\n\n const m = QUEUE_REQUESTS_RE.exec(`/${trimmed}`);\n if (m) {\n const modelId = m[1].replace(/^\\/+/, \"\");\n const action = m[3] === \"/status\" ? \"status\" : m[3] === \"/cancel\" ? \"cancel\" : \"result\";\n return { modelId, requestId: m[2], action };\n }\n return { modelId: trimmed };\n}\n\nexport type HandleFalOutcome = \"handled\" | \"passthrough\";\n\ninterface FalRouteInfo {\n kind: \"queue-submit\" | \"queue-status\" | \"queue-result\" | \"queue-cancel\" | \"sync-run\" | \"storage\";\n modelId?: string;\n requestId?: string;\n targetHost: string;\n}\n\nfunction classifyRoute(\n req: http.IncomingMessage,\n pathname: string,\n targetHost: string,\n): FalRouteInfo | null {\n const stripped = stripFalPrefix(pathname);\n\n if (targetHost === FAL_HOSTS.storage || targetHost === FAL_HOSTS.storageAlpha) {\n if (req.method === \"POST\" && stripped === STORAGE_INITIATE_PATH) {\n return { kind: \"storage\", targetHost };\n }\n return null;\n }\n\n const parsed = parseFalPath(stripped);\n if (!parsed) return null;\n\n if (targetHost === FAL_HOSTS.queue) {\n if (parsed.requestId) {\n if (parsed.action === \"status\" && req.method === \"GET\") {\n return {\n kind: \"queue-status\",\n modelId: parsed.modelId,\n requestId: parsed.requestId,\n targetHost,\n };\n }\n if (parsed.action === \"cancel\" && req.method === \"PUT\") {\n return {\n kind: \"queue-cancel\",\n modelId: parsed.modelId,\n requestId: parsed.requestId,\n targetHost,\n };\n }\n if (parsed.action === \"result\" && req.method === \"GET\") {\n return {\n kind: \"queue-result\",\n modelId: parsed.modelId,\n requestId: parsed.requestId,\n targetHost,\n };\n }\n return null;\n }\n if (req.method === \"POST\") {\n return { kind: \"queue-submit\", modelId: parsed.modelId, targetHost };\n }\n return null;\n }\n\n if (targetHost === FAL_HOSTS.sync) {\n if (req.method === \"POST\" && parsed.modelId) {\n return { kind: \"sync-run\", modelId: parsed.modelId, targetHost };\n }\n return null;\n }\n\n return null;\n}\n\n/**\n * General fal.ai handler. Routes by `x-fal-target-host` header (the convention\n * used by `@fal-ai/client`'s server-side requestMiddleware workaround for the\n * fact that `proxyUrl` is browser-only).\n *\n * Returns `\"passthrough\"` when the request does not look like a host-mirrored\n * fal call, so the caller can fall back to the legacy `/fal/queue/...` and\n * `/fal/run/...` audio routes.\n */\nexport async function handleFal(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n body: string,\n pathname: string,\n fixtures: Fixture[],\n defaults: HandlerDefaults,\n journal: Journal,\n): Promise<HandleFalOutcome> {\n const targetHostHeader = req.headers[\"x-fal-target-host\"];\n const targetHost = Array.isArray(targetHostHeader) ? targetHostHeader[0] : targetHostHeader;\n if (!targetHost) return \"passthrough\";\n\n const route = classifyRoute(req, pathname, targetHost);\n if (!route) return \"passthrough\";\n\n const testId = getTestId(req);\n const stateKey = (id: string) => `${testId}:${id}`;\n\n switch (route.kind) {\n case \"queue-status\": {\n const job = falQueueStates.get(stateKey(route.requestId!));\n if (!job) {\n respondNotFound(req, res, pathname, journal, route.requestId!);\n return \"handled\";\n }\n advanceJob(job);\n writeJson(req, res, 200, statusResponseBody(job), pathname, journal);\n return \"handled\";\n }\n\n case \"queue-result\": {\n const job = falQueueStates.get(stateKey(route.requestId!));\n if (!job) {\n respondNotFound(req, res, pathname, journal, route.requestId!);\n return \"handled\";\n }\n // Callers may fetch result without first polling status — advance so\n // tests that skip the status check still reach completion.\n advanceJob(job);\n if (job.status !== \"COMPLETED\") {\n writeJson(req, res, 202, statusResponseBody(job), pathname, journal);\n return \"handled\";\n }\n writeJson(req, res, 200, job.result, pathname, journal);\n return \"handled\";\n }\n\n case \"queue-cancel\": {\n const job = falQueueStates.get(stateKey(route.requestId!));\n if (!job) {\n journal.add({\n method: req.method ?? \"PUT\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 404, fixture: null },\n });\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"NOT_FOUND\" }));\n return \"handled\";\n }\n if (job.status === \"COMPLETED\") {\n journal.add({\n method: req.method ?? \"PUT\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 400, fixture: null },\n });\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"ALREADY_COMPLETED\" }));\n return \"handled\";\n }\n if (job.status === \"CANCELLED\") {\n journal.add({\n method: req.method ?? \"PUT\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 200, fixture: null },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"CANCELLED\" }));\n return \"handled\";\n }\n job.status = \"CANCELLED\";\n job.logs.push({\n timestamp: new Date().toISOString(),\n level: \"INFO\",\n message: \"Job cancelled.\",\n });\n journal.add({\n method: req.method ?? \"PUT\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 200, fixture: null },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"CANCELLED\" }));\n return \"handled\";\n }\n\n case \"storage\": {\n let filename = \"upload.bin\";\n try {\n const parsed = body ? (JSON.parse(body) as Record<string, unknown>) : {};\n if (typeof parsed.filename === \"string\") filename = parsed.filename;\n if (typeof parsed.file_name === \"string\") filename = parsed.file_name;\n } catch {\n // ignore — stub doesn't require a structured body\n }\n const fileId = crypto.randomUUID();\n const responseBody = {\n upload_url: `https://${route.targetHost}/storage/upload/${fileId}`,\n file_url: `https://${route.targetHost}/files/${fileId}/${filename}`,\n };\n writeJson(req, res, 200, responseBody, pathname, journal);\n return \"handled\";\n }\n\n case \"queue-submit\":\n case \"sync-run\": {\n const modelId = route.modelId!;\n let parsedBody: Record<string, unknown> | null;\n try {\n parsedBody = parseBody(body);\n } catch (err) {\n const detail = err instanceof Error ? err.message : \"Invalid JSON body\";\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 400, fixture: null },\n });\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: {\n message: detail,\n type: \"invalid_request_error\",\n code: \"invalid_json\",\n },\n }),\n );\n return \"handled\";\n }\n const prompt = extractPromptFromBody(parsedBody);\n const syntheticReq: ChatCompletionRequest = {\n model: modelId,\n messages: [{ role: \"user\", content: prompt || JSON.stringify(parsedBody ?? {}) }],\n _endpointType: \"fal\",\n _context: getContext(req),\n };\n\n const matchCounts = journal.getFixtureMatchCountsForTest(testId);\n const { fixture, skippedBySequenceOrTurn } = matchFixtureDiagnostic(\n fixtures,\n syntheticReq,\n matchCounts,\n defaults.requestTransform,\n );\n\n if (!fixture) {\n const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);\n if (effectiveStrict) {\n const strictMessage = strictNoMatchMessage(skippedBySequenceOrTurn);\n defaults.logger.error(\n strictNoMatchLogLine(req.method ?? \"POST\", pathname, skippedBySequenceOrTurn),\n );\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: 503,\n fixture: null,\n ...strictOverrideField(defaults.strict, req.headers),\n },\n });\n res.writeHead(503, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: {\n message: strictMessage,\n type: \"invalid_request_error\",\n code: \"no_fixture_match\",\n },\n }),\n );\n return \"handled\";\n }\n if (defaults.record) {\n const effectiveDefaults = withFalUpstream(defaults, route.targetHost);\n // queue-submit must walk the queue upstream (submit → poll status →\n // get result) before persisting, so the fixture stores the FINAL job\n // body, not the IN_QUEUE envelope. sync-run is already a single\n // request/response cycle and the generic recorder handles it.\n if (route.kind === \"queue-submit\") {\n const outcome = await proxyAndRecordFalQueueSubmit({\n req,\n res,\n syntheticReq,\n modelId,\n pathname,\n strippedPath: stripFalPrefix(pathname),\n body,\n fixtures,\n defaults: effectiveDefaults,\n stateKey,\n journal,\n });\n if (outcome === \"handled\") return \"handled\";\n // outcome === \"no_upstream\" — fall through to 404 (strict was\n // already handled above)\n } else {\n const outcome = await proxyAndRecord(\n req,\n res,\n syntheticReq,\n \"fal\",\n stripFalPrefix(pathname),\n fixtures,\n effectiveDefaults,\n body,\n );\n if (outcome === \"handled_by_hook\") return \"handled\";\n if (outcome !== \"not_configured\") {\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: res.statusCode ?? 200, fixture: null, source: \"proxy\" },\n });\n return \"handled\";\n }\n }\n }\n\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: 404,\n fixture: null,\n ...strictOverrideField(defaults.strict, req.headers),\n },\n });\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: {\n message: \"No fixture matched\",\n type: \"invalid_request_error\",\n code: \"no_fixture_match\",\n },\n }),\n );\n return \"handled\";\n }\n\n journal.incrementFixtureMatchCount(fixture, fixtures, testId);\n const response = await resolveResponse(fixture, syntheticReq);\n\n if (isErrorResponse(response)) {\n const status = response.status ?? 500;\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status, fixture },\n });\n writeErrorResponse(res, status, serializeErrorResponse(response), {\n retryAfter: response.retryAfter,\n });\n return \"handled\";\n }\n\n let payload: unknown;\n if (isJSONResponse(response)) {\n payload = (response as RawJSONResponse).json;\n } else if (isAudioResponse(response)) {\n payload = audioToFalFile(response);\n } else {\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 500, fixture },\n });\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: {\n message: \"Fixture response is not JSON or audio for fal endpoint\",\n type: \"server_error\",\n },\n }),\n );\n return \"handled\";\n }\n\n if (route.kind === \"sync-run\") {\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 200, fixture },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(payload));\n return \"handled\";\n }\n\n const requestId = crypto.randomUUID();\n const progression = resolveProgression(defaults.falQueue);\n const now = Date.now();\n const initialStatus: FalQueueStatus =\n progression.pollsBeforeCompleted === 0 ? \"COMPLETED\" : \"IN_QUEUE\";\n const job: FalQueueJob = {\n requestId,\n modelId,\n status: initialStatus,\n result: payload,\n pollCount: 0,\n pollsBeforeInProgress: progression.pollsBeforeInProgress,\n pollsBeforeCompleted: progression.pollsBeforeCompleted,\n submittedAt: now,\n completedAt: initialStatus === \"COMPLETED\" ? now : null,\n logs: [\n {\n timestamp: new Date(now).toISOString(),\n level: \"INFO\",\n message: \"Job enqueued.\",\n },\n ],\n createdAt: now,\n };\n falQueueStates.set(stateKey(requestId), job);\n const envelope = {\n request_id: requestId,\n response_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${requestId}`,\n status_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${requestId}/status`,\n cancel_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${requestId}/cancel`,\n queue_position: queuePosition(job),\n };\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 200, fixture },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(envelope));\n return \"handled\";\n }\n }\n}\n\nfunction parseBody(raw: string): Record<string, unknown> | null {\n if (!raw.trim()) return null;\n try {\n return JSON.parse(raw) as Record<string, unknown>;\n } catch (err) {\n const detail = err instanceof Error ? err.message : \"unknown\";\n throw new Error(`Malformed JSON: ${detail}`);\n }\n}\n\n// ─── Queue-walk recording ──────────────────────────────────────────────\n//\n// The fal queue protocol surfaces three endpoints — submit (POST), status\n// (GET, polled), and result (GET) — but at the fixture layer we only store ONE\n// thing: the FINAL job body. A naive `proxyAndRecord` against submit would\n// persist the IN_QUEUE envelope, which is useless to replay (the SDK polls\n// status and then reads the result body, expecting `{ images: [...] }`-shaped\n// model output, not an envelope). So during recording we walk the upstream\n// queue ourselves, capture the result body, and write THAT as the fixture —\n// then synthesise the local envelope the same way the replay path does.\n\nconst DEFAULT_FAL_POLL_INTERVAL_MS = 1000;\n// Video generations (kling, veo, runway, etc.) routinely take 5–10 minutes\n// on the upstream queue; 15 min gives headroom without trapping a genuinely\n// hung job indefinitely.\nconst DEFAULT_FAL_TIMEOUT_MS = 900_000;\n// Per-fetch upstream timeout default for the walk's submit/status/result\n// fetches — same value and clamp conventions as the rest of the recording\n// surfaces (recorder.ts / openrouter-video.ts).\nconst DEFAULT_FAL_FETCH_TIMEOUT_MS = 30_000;\n\n// Upstream header forwarding uses buildForwardHeaders (recorder.ts) — the\n// shared strip list (hop-by-hop, client-set, and the mock-internal x-test-id\n// / x-aimock-strict / x-aimock-context / x-aimock-chaos-* family), so the\n// fal queue walk never leaks control headers onto a real provider's wire\n// (one shared list, not a per-surface copy, so the surfaces cannot drift).\n\n/**\n * Walk a fal-shaped queue protocol upstream: POST submit, poll status until\n * COMPLETED, GET final result body. Returns the parsed final body so the caller\n * can persist it as the fixture and seed local queue state.\n *\n * Decoupled from the route layer so the legacy `/fal/queue/submit/{model}`\n * audio path (`fal-audio.ts`) can reuse the same logic.\n */\nexport async function walkFalQueue(args: {\n upstreamBase: string;\n submitPath: string;\n body: string;\n headers: Record<string, string>;\n pollIntervalMs?: number;\n timeoutMs?: number;\n /**\n * Per-fetch upstream timeout (`record.upstreamTimeoutMs` clamp conventions,\n * 30s default) applied to each of the walk's submit/status/result fetches\n * via AbortSignal — a hung upstream socket must not pin the walk past its\n * budget. Each fetch's signal is additionally clamped to the walk's\n * remaining deadline; an abort surfaces through the caller's existing\n * failure handling (502, no fixture persisted).\n */\n upstreamTimeoutMs?: number;\n /**\n * Build the status-poll URL from `request_id` when upstream's submit\n * response doesn't return a usable `status_url`. The legacy path uses\n * aimock-internal `/fal/queue/requests/<id>/status` rather than fal.ai's\n * `/<model>/requests/<id>/status` layout.\n */\n fallbackStatusPath: (requestId: string) => string;\n fallbackResultPath: (requestId: string) => string;\n /** Warn sink for the same-origin envelope-URL gate (omitting it only mutes the warns). */\n logger?: Logger;\n}): Promise<unknown> {\n const {\n upstreamBase,\n submitPath,\n body,\n headers,\n pollIntervalMs = DEFAULT_FAL_POLL_INTERVAL_MS,\n timeoutMs = DEFAULT_FAL_TIMEOUT_MS,\n upstreamTimeoutMs,\n fallbackStatusPath,\n fallbackResultPath,\n logger,\n } = args;\n\n const deadline = Date.now() + timeoutMs;\n const perFetchTimeoutMs = clampTimeout(upstreamTimeoutMs, DEFAULT_FAL_FETCH_TIMEOUT_MS);\n // Bound every upstream fetch: the per-fetch timeout, additionally clamped\n // to the walk's remaining budget so a fetch can never outlive the deadline\n // (floored at 1ms — AbortSignal.timeout rejects non-positive values; the\n // deadline check in the poll loop is the authoritative expiry).\n const fetchSignal = (): AbortSignal =>\n AbortSignal.timeout(Math.max(1, Math.min(perFetchTimeoutMs, deadline - Date.now())));\n\n // ── 1. POST submit ────────────────────────────────────────────────\n const submitUrl = resolveUpstreamUrl(upstreamBase, submitPath);\n const submitRes = await fetch(submitUrl, {\n method: \"POST\",\n headers,\n body,\n signal: fetchSignal(),\n });\n const submitText = await submitRes.text();\n if (!submitRes.ok) {\n throw new Error(`Submit ${submitRes.status}: ${submitText.slice(0, 200)}`);\n }\n const submitJson = parseJsonOrThrow(submitText, \"Submit\");\n // JSON.parse admits null/arrays/scalars — reject non-object envelopes with\n // a clean error instead of TypeError-ing on the field reads below (the\n // OpenRouter video proxies apply the same guard).\n if (submitJson === null || typeof submitJson !== \"object\" || Array.isArray(submitJson)) {\n throw new Error(\"Submit response is not a JSON object\");\n }\n const env = submitJson as Record<string, unknown>;\n const upstreamRequestId = String(env.request_id ?? \"\").trim();\n if (!upstreamRequestId) {\n throw new Error(\"Submit response missing request_id\");\n }\n\n // Prefer the URLs upstream returned — but ONLY same-origin with the\n // configured upstream (mirroring the OpenRouter video proxy's\n // polling_url gate): every status/result fetch below forwards the client's\n // headers — including Authorization — so an envelope nominating a foreign\n // host must never receive them. Same-origin still covers the documented\n // proxy-in-front case (the configured upstream IS that proxy); off-origin,\n // unparseable, or absent envelope URLs fall back to the constructed\n // canonical paths on the upstream origin, with a warn.\n const upstreamOrigin = submitUrl.origin;\n const adoptSameOrigin = (value: unknown, label: string, fallbackPath: string): URL => {\n if (typeof value === \"string\" && value) {\n try {\n const parsed = new URL(value);\n if (parsed.origin === upstreamOrigin) return parsed;\n logger?.warn(\n `Upstream ${label} origin ${parsed.origin} differs from the upstream origin ${upstreamOrigin} — using the constructed canonical path instead`,\n );\n } catch {\n logger?.warn(\n `Upstream ${label} is not a valid URL (${value.slice(0, 100)}) — using the constructed canonical path instead`,\n );\n }\n }\n return resolveUpstreamUrl(upstreamBase, fallbackPath);\n };\n const statusUrl = adoptSameOrigin(\n env.status_url,\n \"status_url\",\n fallbackStatusPath(upstreamRequestId),\n );\n const resultUrl = adoptSameOrigin(\n env.response_url,\n \"response_url\",\n fallbackResultPath(upstreamRequestId),\n );\n\n // ── 2. Poll status until COMPLETED ───────────────────────────────\n while (true) {\n if (Date.now() > deadline) throw new Error(`Queue walk timed out after ${timeoutMs}ms`);\n const statusRes = await fetch(statusUrl, { headers, signal: fetchSignal() });\n const statusText = await statusRes.text();\n if (!statusRes.ok) {\n throw new Error(`Status ${statusRes.status}: ${statusText.slice(0, 200)}`);\n }\n const statusParsed = parseJsonOrThrow(statusText, \"Status\");\n // Same non-object guard as the submit envelope above.\n if (statusParsed === null || typeof statusParsed !== \"object\" || Array.isArray(statusParsed)) {\n throw new Error(\"Status response is not a JSON object\");\n }\n const statusJson = statusParsed as Record<string, unknown>;\n const s = String(statusJson.status ?? \"\");\n if (s === \"COMPLETED\") break;\n if (s === \"FAILED\" || s === \"ERROR\" || s === \"CANCELLED\") {\n throw new Error(`Upstream job terminated with status ${s}`);\n }\n const remaining = deadline - Date.now();\n // Time out only when the budget is actually exhausted — a zero\n // pollIntervalMs is a valid \"poll as fast as possible\" configuration,\n // not an instant expiry (setTimeout(0) still yields the event loop).\n if (remaining <= 0) throw new Error(`Queue walk timed out after ${timeoutMs}ms`);\n const sleep = Math.min(pollIntervalMs, remaining);\n await new Promise<void>((r) => setTimeout(r, Math.max(0, sleep)));\n }\n\n // ── 3. GET final result ──────────────────────────────────────────\n const resultRes = await fetch(resultUrl, { headers, signal: fetchSignal() });\n const resultText = await resultRes.text();\n if (!resultRes.ok) {\n throw new Error(`Result ${resultRes.status}: ${resultText.slice(0, 200)}`);\n }\n return parseJsonOrThrow(resultText, \"Result\");\n}\n\nasync function proxyAndRecordFalQueueSubmit(args: {\n req: http.IncomingMessage;\n res: http.ServerResponse;\n syntheticReq: ChatCompletionRequest;\n modelId: string;\n pathname: string;\n strippedPath: string;\n body: string;\n fixtures: Fixture[];\n defaults: HandlerDefaults;\n stateKey: (id: string) => string;\n journal: Journal;\n}): Promise<\"handled\" | \"no_upstream\"> {\n const {\n req,\n res,\n syntheticReq,\n modelId,\n pathname,\n strippedPath,\n body,\n fixtures,\n defaults,\n stateKey,\n journal,\n } = args;\n\n const record = defaults.record;\n if (!record) return \"no_upstream\";\n const upstreamBase = record.providers.fal;\n if (!upstreamBase) {\n defaults.logger.warn(`No upstream URL configured for provider \"fal\" — cannot proxy`);\n return \"no_upstream\";\n }\n\n defaults.logger.warn(`NO FIXTURE MATCH — walking fal queue at ${upstreamBase}${strippedPath}`);\n\n let finalBody: unknown;\n try {\n finalBody = await walkFalQueue({\n upstreamBase,\n submitPath: strippedPath,\n body,\n headers: buildForwardHeaders(req),\n pollIntervalMs: record.fal?.pollIntervalMs,\n timeoutMs: record.fal?.timeoutMs,\n upstreamTimeoutMs: record.upstreamTimeoutMs,\n fallbackStatusPath: (id) => `${modelId}/requests/${id}/status`,\n fallbackResultPath: (id) => `${modelId}/requests/${id}`,\n logger: defaults.logger,\n });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown queue-walk error\";\n defaults.logger.error(`fal queue-walk proxy failed: ${msg}`);\n // Guard BEFORE journaling (openrouter-video convention): a client that\n // disconnected during the multi-second walk gets neither a write nor a\n // journal entry.\n if (res.destroyed || res.writableEnded) return \"handled\";\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 502, fixture: null, source: \"proxy\" },\n });\n res.writeHead(502, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: { message: `Proxy to upstream failed: ${msg}`, type: \"proxy_error\" },\n }),\n );\n return \"handled\";\n }\n\n // ── 4. Persist fixture using the FINAL body, not the submit envelope ──\n const matchRequest = defaults.requestTransform\n ? defaults.requestTransform(syntheticReq)\n : syntheticReq;\n const fixture: Fixture = {\n match: buildFixtureMatch(matchRequest, record),\n response: { json: finalBody, status: 200 },\n };\n const persistResult = persistFixture({\n record,\n providerKey: \"fal\",\n testId: getTestId(req),\n fixture,\n fixtures,\n logger: defaults.logger,\n });\n // Surface a persist failure on the envelope (parity with the generic\n // recorder relay and the OpenRouter failed branch) — the synthesized\n // envelope below has not been written yet, so the header can still ride it.\n if (persistResult.kind === \"failed\" && !res.headersSent) {\n res.setHeader(\"X-AIMock-Record-Error\", sanitizeHeaderValue(persistResult.error));\n }\n\n // ── 5. Synthesise envelope + seed state (same shape as the replay path) ──\n const newRequestId = crypto.randomUUID();\n const progression = resolveProgression(defaults.falQueue);\n const now = Date.now();\n const initialStatus: FalQueueStatus =\n progression.pollsBeforeCompleted === 0 ? \"COMPLETED\" : \"IN_QUEUE\";\n const job: FalQueueJob = {\n requestId: newRequestId,\n modelId,\n status: initialStatus,\n result: finalBody,\n pollCount: 0,\n pollsBeforeInProgress: progression.pollsBeforeInProgress,\n pollsBeforeCompleted: progression.pollsBeforeCompleted,\n submittedAt: now,\n completedAt: initialStatus === \"COMPLETED\" ? now : null,\n logs: [{ timestamp: new Date(now).toISOString(), level: \"INFO\", message: \"Job enqueued.\" }],\n createdAt: now,\n };\n falQueueStates.set(stateKey(newRequestId), job);\n const envelope = {\n request_id: newRequestId,\n response_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${newRequestId}`,\n status_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${newRequestId}/status`,\n cancel_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${newRequestId}/cancel`,\n queue_position: queuePosition(job),\n };\n // Guard BEFORE journaling (openrouter-video convention): a client that\n // disconnected during the multi-second walk gets neither a write nor a\n // journal entry. The persisted fixture and seeded state above stay — the\n // captured upstream response is valuable regardless.\n if (res.destroyed || res.writableEnded) return \"handled\";\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 200, fixture: null, source: \"proxy\" },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(envelope));\n return \"handled\";\n}\n\nfunction parseJsonOrThrow(text: string, label: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n throw new Error(`${label} returned non-JSON: ${text.slice(0, 200)}`);\n }\n}\n\nfunction withFalUpstream(defaults: HandlerDefaults, targetHost: string): HandlerDefaults {\n if (!defaults.record) return defaults;\n // Respect an explicit record.providers.fal — tests and dev configs need to\n // point at a stub upstream. Only synthesise from the header when the user\n // didn't configure one (the \"or omit upstream URL — it's in the request\n // hostname\" mode from the issue).\n if (defaults.record.providers.fal) return defaults;\n return {\n ...defaults,\n record: {\n ...defaults.record,\n providers: {\n ...defaults.record.providers,\n fal: `https://${targetHost}`,\n },\n },\n };\n}\n\nfunction writeJson(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n status: number,\n payload: unknown,\n pathname: string,\n journal: Journal,\n): void {\n journal.add({\n method: req.method ?? \"GET\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status, fixture: null },\n });\n res.writeHead(status, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(payload));\n}\n\nfunction respondNotFound(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n pathname: string,\n journal: Journal,\n requestId: string,\n): void {\n journal.add({\n method: req.method ?? \"GET\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 404, fixture: null },\n });\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: { message: `Request ${requestId} not found`, type: \"not_found\" },\n }),\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"fal.cjs","names":["getTestId","flattenHeaders","crypto","getContext","matchFixtureDiagnostic","resolveStrictMode","strictNoMatchMessage","strictNoMatchLogLine","strictOverrideField","proxyAndRecord","resolveResponse","isErrorResponse","serializeErrorResponse","isJSONResponse","isAudioResponse","audioToFalFile","clampTimeout","resolveUpstreamUrl","buildForwardHeaders","buildFixtureMatch","persistFixture","sanitizeHeaderValue"],"sources":["../src/fal.ts"],"sourcesContent":["import type http from \"node:http\";\nimport crypto from \"node:crypto\";\nimport type {\n ChatCompletionRequest,\n FalQueueConfig,\n Fixture,\n HandlerDefaults,\n ImageItem,\n ImageResponse,\n RawJSONResponse,\n VideoResponse,\n} from \"./types.js\";\nimport {\n isAudioResponse,\n isErrorResponse,\n serializeErrorResponse,\n isJSONResponse,\n flattenHeaders,\n getContext,\n getTestId,\n resolveResponse,\n resolveStrictMode,\n strictOverrideField,\n strictNoMatchMessage,\n strictNoMatchLogLine,\n} from \"./helpers.js\";\nimport { writeErrorResponse } from \"./sse-writer.js\";\nimport { matchFixtureDiagnostic } from \"./router.js\";\nimport type { Logger } from \"./logger.js\";\nimport {\n buildFixtureMatch,\n buildForwardHeaders,\n clampTimeout,\n persistFixture,\n proxyAndRecord,\n sanitizeHeaderValue,\n} from \"./recorder.js\";\nimport { resolveUpstreamUrl } from \"./url.js\";\nimport type { Journal } from \"./journal.js\";\nimport { audioToFalFile } from \"./fal-audio.js\";\n\n// ─── FalQueueState (TTL + bounded) ───────────────────────────────────────\n\nconst FAL_QUEUE_MAX_ENTRIES = 10_000;\nconst FAL_QUEUE_TTL_MS = 3_600_000; // 1 hour\n\ntype FalQueueStatus = \"IN_QUEUE\" | \"IN_PROGRESS\" | \"COMPLETED\" | \"CANCELLED\";\n\ninterface FalQueueJob {\n requestId: string;\n modelId: string;\n status: FalQueueStatus;\n result: unknown;\n /**\n * Billed quantity emitted as the `x-fal-billable-units` header on the\n * completed `queue-result` response. Sourced from the fixture's\n * `response.billableUnits`; `null` when the fixture omitted it (no header).\n */\n billableUnits: number | null;\n /** Number of `/status` (or `/{id}`) polls the caller has made against this job. */\n pollCount: number;\n /** Poll-count threshold for `IN_QUEUE → IN_PROGRESS` transition. */\n pollsBeforeInProgress: number;\n /** Poll-count threshold for `IN_PROGRESS → COMPLETED` transition. */\n pollsBeforeCompleted: number;\n submittedAt: number;\n completedAt: number | null;\n /** State-transition log entries surfaced in the `/status` response. */\n logs: Array<{ timestamp: string; level: string; message: string }>;\n createdAt: number;\n}\n\ninterface FalQueueEntry {\n job: FalQueueJob;\n createdAt: number;\n}\n\n/**\n * Per-testId queue state for the general fal handler. Mirrors FalJobMap from\n * fal-audio.ts but stores arbitrary JSON payloads instead of audio file\n * objects, so it can serve any fal model (image, video, motion, music, etc.).\n */\nexport class FalQueueStateMap {\n private readonly entries = new Map<string, FalQueueEntry>();\n\n get(key: string): FalQueueJob | undefined {\n const entry = this.entries.get(key);\n if (!entry) return undefined;\n if (Date.now() - entry.createdAt > FAL_QUEUE_TTL_MS) {\n this.entries.delete(key);\n return undefined;\n }\n return entry.job;\n }\n\n set(key: string, job: FalQueueJob): void {\n this.entries.set(key, { job, createdAt: Date.now() });\n if (this.entries.size > FAL_QUEUE_MAX_ENTRIES) {\n const excess = this.entries.size - FAL_QUEUE_MAX_ENTRIES;\n const iter = this.entries.keys();\n for (let i = 0; i < excess; i++) {\n const next = iter.next();\n if (!next.done) this.entries.delete(next.value);\n }\n }\n }\n\n delete(key: string): boolean {\n return this.entries.delete(key);\n }\n\n clear(): void {\n this.entries.clear();\n }\n\n get size(): number {\n return this.entries.size;\n }\n}\n\nexport const falQueueStates = new FalQueueStateMap();\n\n// ─── Typed-response → fal envelope converters ───────────────────────────\n\nfunction extractExtension(url: string, fallback: string): { fileName: string; ext: string } {\n const segment = url.split(\"?\")[0].split(\"#\")[0].split(\"/\").pop() ?? \"\";\n const fileName = segment.length > 0 ? segment : \"\";\n const dotIdx = fileName.lastIndexOf(\".\");\n const ext = dotIdx >= 0 ? fileName.slice(dotIdx + 1).toLowerCase() : fallback;\n return { fileName, ext };\n}\n\nfunction imageItemToFalImage(item: ImageItem, index: number): Record<string, unknown> {\n const url = item.url ?? `https://mock.fal.media/files/generated_image_${index}.png`;\n const { ext } = extractExtension(url, \"png\");\n const contentType = ext === \"jpg\" || ext === \"jpeg\" ? \"image/jpeg\" : `image/${ext}`;\n return {\n url,\n width: 1024,\n height: 1024,\n content_type: contentType,\n };\n}\n\n/**\n * Translate an `ImageResponse` fixture into fal's image envelope shape:\n * `{ images: [...], timings, seed, has_nsfw_concepts, prompt }`.\n * Used by `LLMock.onFalImage` to keep callers from re-deriving the wire shape.\n */\nexport function imageResponseToFalJson(response: ImageResponse): Record<string, unknown> {\n const items = response.images ?? (response.image ? [response.image] : []);\n const images = items.map((item, i) => imageItemToFalImage(item, i));\n return {\n images,\n timings: { inference: 0 },\n seed: 0,\n has_nsfw_concepts: images.map(() => false),\n prompt: \"\",\n };\n}\n\n/**\n * Translate a `VideoResponse` fixture into fal's video envelope shape:\n * `{ video: { url, content_type, file_name, file_size }, seed }`.\n */\nexport function videoResponseToFalJson(response: VideoResponse): Record<string, unknown> {\n const url = response.video.url ?? \"https://mock.fal.media/files/generated_video.mp4\";\n const { fileName, ext } = extractExtension(url, \"mp4\");\n return {\n video: {\n url,\n content_type: `video/${ext}`,\n file_name: fileName || \"generated_video.mp4\",\n file_size: 0,\n },\n seed: 0,\n };\n}\n\n// ─── Queue progression ─────────────────────────────────────────────────\n\nexport function resolveProgression(config: FalQueueConfig | undefined): {\n pollsBeforeInProgress: number;\n pollsBeforeCompleted: number;\n} {\n // Thresholds must resolve to non-negative integers: a NaN would make\n // advanceJob's `pollCount >= threshold` comparison permanently false (a\n // polling client never reaches terminal), and a negative would break the\n // documented \"explicit 0 enables progression\" contract. Non-finite values\n // are treated as unset; anything else is floored and clamped to >= 0.\n const sanitize = (value: number | undefined): number | undefined =>\n typeof value === \"number\" && Number.isFinite(value)\n ? Math.max(0, Math.floor(value))\n : undefined;\n const explicitInProgress = sanitize(config?.pollsBeforeInProgress);\n const explicitCompleted = sanitize(config?.pollsBeforeCompleted);\n const pollsBeforeInProgress = explicitInProgress ?? 0;\n // When only pollsBeforeInProgress is set, default pollsBeforeCompleted to one\n // poll later so the job actually passes through IN_PROGRESS. When the caller\n // sets both explicitly, clamp completed >= inProgress so a misconfigured\n // pair (e.g. completed < inProgress) can't silently skip the IN_PROGRESS\n // transition. When neither is set, both stay 0 (completes on submit).\n let pollsBeforeCompleted: number;\n if (explicitCompleted !== undefined) {\n pollsBeforeCompleted = Math.max(pollsBeforeInProgress, explicitCompleted);\n } else if (explicitInProgress !== undefined) {\n pollsBeforeCompleted = pollsBeforeInProgress + 1;\n } else {\n pollsBeforeCompleted = 0;\n }\n return { pollsBeforeInProgress, pollsBeforeCompleted };\n}\n\n/**\n * Mutates a job in place to advance its state on a status/result poll.\n * IN_QUEUE → IN_PROGRESS → COMPLETED based on poll-count thresholds. No-op\n * once COMPLETED or CANCELLED.\n */\nfunction advanceJob(job: FalQueueJob): void {\n if (job.status === \"COMPLETED\" || job.status === \"CANCELLED\") return;\n\n job.pollCount += 1;\n // Check IN_PROGRESS before COMPLETED so a job whose thresholds are equal\n // still spends one poll in IN_PROGRESS instead of jumping straight to\n // COMPLETED.\n if (job.status === \"IN_QUEUE\" && job.pollCount >= job.pollsBeforeInProgress) {\n job.status = \"IN_PROGRESS\";\n job.logs.push({\n timestamp: new Date().toISOString(),\n level: \"INFO\",\n message: \"Job started processing.\",\n });\n } else if (job.pollCount >= job.pollsBeforeCompleted) {\n job.status = \"COMPLETED\";\n job.completedAt = Date.now();\n job.logs.push({\n timestamp: new Date().toISOString(),\n level: \"INFO\",\n message: \"Job completed.\",\n });\n }\n}\n\nfunction queuePosition(job: FalQueueJob): number {\n if (job.status !== \"IN_QUEUE\") return 0;\n return Math.max(0, job.pollsBeforeInProgress - job.pollCount);\n}\n\nfunction statusResponseBody(job: FalQueueJob): Record<string, unknown> {\n const body: Record<string, unknown> = {\n status: job.status,\n request_id: job.requestId,\n response_url: `https://${FAL_HOSTS.queue}/${job.modelId}/requests/${job.requestId}`,\n logs: job.logs,\n };\n if (job.status === \"IN_QUEUE\" || job.status === \"IN_PROGRESS\") {\n body.queue_position = queuePosition(job);\n }\n if (job.status === \"COMPLETED\" && job.completedAt != null) {\n body.metrics = {\n inference_time: (job.completedAt - job.submittedAt) / 1000,\n };\n }\n return body;\n}\n\n// ─── Hosts and routing ──────────────────────────────────────────────────\n\nconst FAL_HOSTS = {\n queue: \"queue.fal.run\",\n sync: \"fal.run\",\n storage: \"rest.fal.ai\",\n storageAlpha: \"rest.alpha.fal.ai\",\n gateway: \"gateway.fal.ai\",\n} as const;\n\n// Headers real fal sets on its queue responses. `@tanstack/ai-fal` (and\n// likely other adapters) read both to correlate a billed quantity with the\n// originating request: the request-id keys the lookup, the billable-units\n// value is the quantity. aimock emits the request-id on the `status` and\n// `result` queue responses (where adapters perform the billing lookup); the\n// submit envelope already carries `request_id` in its JSON body, and the\n// cancel / not-found responses omit it. The billable-units header rides only\n// the completed `result`, and only when a fixture opts in via\n// `response.billableUnits`.\nconst FAL_REQUEST_ID_HEADER = \"x-fal-request-id\";\nconst FAL_BILLABLE_UNITS_HEADER = \"x-fal-billable-units\";\n\n/**\n * Read an optional `billableUnits` off a resolved fixture response. Returns the\n * finite numeric value or `null` (absent, non-numeric, or non-finite) — the\n * `x-fal-billable-units` header is emitted only for a non-null result.\n */\nfunction extractBillableUnits(response: unknown): number | null {\n if (response && typeof response === \"object\" && \"billableUnits\" in response) {\n // The `in` narrowing types `response.billableUnits` as `unknown` — no cast.\n const value: unknown = response.billableUnits;\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n }\n return null;\n}\n\nconst QUEUE_REQUESTS_RE = /^(.+)\\/requests\\/([^/]+)(\\/status|\\/cancel)?$/;\nconst STORAGE_INITIATE_PATH = \"/storage/upload/initiate\";\n\nfunction stripFalPrefix(pathname: string): string {\n const stripped = pathname.replace(/^\\/fal/, \"\");\n return stripped.length > 0 ? stripped : \"/\";\n}\n\nfunction extractPromptFromBody(body: unknown): string {\n if (!body || typeof body !== \"object\") return \"\";\n const obj = body as Record<string, unknown>;\n if (typeof obj.prompt === \"string\") return obj.prompt;\n if (typeof obj.text === \"string\") return obj.text;\n const input = obj.input;\n if (input && typeof input === \"object\") {\n const inputObj = input as Record<string, unknown>;\n if (typeof inputObj.prompt === \"string\") return inputObj.prompt;\n if (typeof inputObj.text === \"string\") return inputObj.text;\n }\n return \"\";\n}\n\ninterface ParsedFalPath {\n modelId: string;\n requestId?: string;\n action?: \"status\" | \"cancel\" | \"result\";\n}\n\nfunction parseFalPath(stripped: string): ParsedFalPath | null {\n if (!stripped.startsWith(\"/\")) return null;\n const trimmed = stripped.replace(/^\\/+/, \"\");\n if (!trimmed) return null;\n\n const m = QUEUE_REQUESTS_RE.exec(`/${trimmed}`);\n if (m) {\n const modelId = m[1].replace(/^\\/+/, \"\");\n const action = m[3] === \"/status\" ? \"status\" : m[3] === \"/cancel\" ? \"cancel\" : \"result\";\n return { modelId, requestId: m[2], action };\n }\n return { modelId: trimmed };\n}\n\nexport type HandleFalOutcome = \"handled\" | \"passthrough\";\n\ninterface FalRouteInfo {\n kind: \"queue-submit\" | \"queue-status\" | \"queue-result\" | \"queue-cancel\" | \"sync-run\" | \"storage\";\n modelId?: string;\n requestId?: string;\n targetHost: string;\n}\n\nfunction classifyRoute(\n req: http.IncomingMessage,\n pathname: string,\n targetHost: string,\n): FalRouteInfo | null {\n const stripped = stripFalPrefix(pathname);\n\n if (targetHost === FAL_HOSTS.storage || targetHost === FAL_HOSTS.storageAlpha) {\n if (req.method === \"POST\" && stripped === STORAGE_INITIATE_PATH) {\n return { kind: \"storage\", targetHost };\n }\n return null;\n }\n\n const parsed = parseFalPath(stripped);\n if (!parsed) return null;\n\n if (targetHost === FAL_HOSTS.queue) {\n if (parsed.requestId) {\n if (parsed.action === \"status\" && req.method === \"GET\") {\n return {\n kind: \"queue-status\",\n modelId: parsed.modelId,\n requestId: parsed.requestId,\n targetHost,\n };\n }\n if (parsed.action === \"cancel\" && req.method === \"PUT\") {\n return {\n kind: \"queue-cancel\",\n modelId: parsed.modelId,\n requestId: parsed.requestId,\n targetHost,\n };\n }\n if (parsed.action === \"result\" && req.method === \"GET\") {\n return {\n kind: \"queue-result\",\n modelId: parsed.modelId,\n requestId: parsed.requestId,\n targetHost,\n };\n }\n return null;\n }\n if (req.method === \"POST\") {\n return { kind: \"queue-submit\", modelId: parsed.modelId, targetHost };\n }\n return null;\n }\n\n if (targetHost === FAL_HOSTS.sync) {\n if (req.method === \"POST\" && parsed.modelId) {\n return { kind: \"sync-run\", modelId: parsed.modelId, targetHost };\n }\n return null;\n }\n\n return null;\n}\n\n/**\n * General fal.ai handler. Routes by `x-fal-target-host` header (the convention\n * used by `@fal-ai/client`'s server-side requestMiddleware workaround for the\n * fact that `proxyUrl` is browser-only).\n *\n * Returns `\"passthrough\"` when the request does not look like a host-mirrored\n * fal call, so the caller can fall back to the legacy `/fal/queue/...` and\n * `/fal/run/...` audio routes.\n */\nexport async function handleFal(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n body: string,\n pathname: string,\n fixtures: Fixture[],\n defaults: HandlerDefaults,\n journal: Journal,\n): Promise<HandleFalOutcome> {\n const targetHostHeader = req.headers[\"x-fal-target-host\"];\n const targetHost = Array.isArray(targetHostHeader) ? targetHostHeader[0] : targetHostHeader;\n if (!targetHost) return \"passthrough\";\n\n const route = classifyRoute(req, pathname, targetHost);\n if (!route) return \"passthrough\";\n\n const testId = getTestId(req);\n const stateKey = (id: string) => `${testId}:${id}`;\n\n switch (route.kind) {\n case \"queue-status\": {\n const job = falQueueStates.get(stateKey(route.requestId!));\n if (!job) {\n respondNotFound(req, res, pathname, journal, route.requestId!);\n return \"handled\";\n }\n advanceJob(job);\n writeJson(req, res, 200, statusResponseBody(job), pathname, journal, {\n [FAL_REQUEST_ID_HEADER]: job.requestId,\n });\n return \"handled\";\n }\n\n case \"queue-result\": {\n const job = falQueueStates.get(stateKey(route.requestId!));\n if (!job) {\n respondNotFound(req, res, pathname, journal, route.requestId!);\n return \"handled\";\n }\n // Callers may fetch result without first polling status — advance so\n // tests that skip the status check still reach completion.\n advanceJob(job);\n // Emit x-fal-request-id on both the in-progress (202) and completed (200)\n // result; adapters key their billing lookup off it.\n const resultHeaders: Record<string, string> = { [FAL_REQUEST_ID_HEADER]: job.requestId };\n if (job.status !== \"COMPLETED\") {\n writeJson(req, res, 202, statusResponseBody(job), pathname, journal, resultHeaders);\n return \"handled\";\n }\n // x-fal-billable-units rides only the completed result, and only when the\n // fixture opted into a billed quantity.\n if (job.billableUnits != null) {\n resultHeaders[FAL_BILLABLE_UNITS_HEADER] = String(job.billableUnits);\n }\n writeJson(req, res, 200, job.result, pathname, journal, resultHeaders);\n return \"handled\";\n }\n\n case \"queue-cancel\": {\n const job = falQueueStates.get(stateKey(route.requestId!));\n if (!job) {\n journal.add({\n method: req.method ?? \"PUT\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 404, fixture: null },\n });\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"NOT_FOUND\" }));\n return \"handled\";\n }\n if (job.status === \"COMPLETED\") {\n journal.add({\n method: req.method ?? \"PUT\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 400, fixture: null },\n });\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"ALREADY_COMPLETED\" }));\n return \"handled\";\n }\n if (job.status === \"CANCELLED\") {\n journal.add({\n method: req.method ?? \"PUT\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 200, fixture: null },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"CANCELLED\" }));\n return \"handled\";\n }\n job.status = \"CANCELLED\";\n job.logs.push({\n timestamp: new Date().toISOString(),\n level: \"INFO\",\n message: \"Job cancelled.\",\n });\n journal.add({\n method: req.method ?? \"PUT\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 200, fixture: null },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"CANCELLED\" }));\n return \"handled\";\n }\n\n case \"storage\": {\n let filename = \"upload.bin\";\n try {\n const parsed = body ? (JSON.parse(body) as Record<string, unknown>) : {};\n if (typeof parsed.filename === \"string\") filename = parsed.filename;\n if (typeof parsed.file_name === \"string\") filename = parsed.file_name;\n } catch {\n // ignore — stub doesn't require a structured body\n }\n const fileId = crypto.randomUUID();\n const responseBody = {\n upload_url: `https://${route.targetHost}/storage/upload/${fileId}`,\n file_url: `https://${route.targetHost}/files/${fileId}/${filename}`,\n };\n writeJson(req, res, 200, responseBody, pathname, journal);\n return \"handled\";\n }\n\n case \"queue-submit\":\n case \"sync-run\": {\n const modelId = route.modelId!;\n let parsedBody: Record<string, unknown> | null;\n try {\n parsedBody = parseBody(body);\n } catch (err) {\n const detail = err instanceof Error ? err.message : \"Invalid JSON body\";\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 400, fixture: null },\n });\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: {\n message: detail,\n type: \"invalid_request_error\",\n code: \"invalid_json\",\n },\n }),\n );\n return \"handled\";\n }\n const prompt = extractPromptFromBody(parsedBody);\n const syntheticReq: ChatCompletionRequest = {\n model: modelId,\n messages: [{ role: \"user\", content: prompt || JSON.stringify(parsedBody ?? {}) }],\n _endpointType: \"fal\",\n _context: getContext(req),\n };\n\n const matchCounts = journal.getFixtureMatchCountsForTest(testId);\n const { fixture, skippedBySequenceOrTurn } = matchFixtureDiagnostic(\n fixtures,\n syntheticReq,\n matchCounts,\n defaults.requestTransform,\n );\n\n if (!fixture) {\n const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);\n if (effectiveStrict) {\n const strictMessage = strictNoMatchMessage(skippedBySequenceOrTurn);\n defaults.logger.error(\n strictNoMatchLogLine(req.method ?? \"POST\", pathname, skippedBySequenceOrTurn),\n );\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: 503,\n fixture: null,\n ...strictOverrideField(defaults.strict, req.headers),\n },\n });\n res.writeHead(503, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: {\n message: strictMessage,\n type: \"invalid_request_error\",\n code: \"no_fixture_match\",\n },\n }),\n );\n return \"handled\";\n }\n if (defaults.record) {\n const effectiveDefaults = withFalUpstream(defaults, route.targetHost);\n // queue-submit must walk the queue upstream (submit → poll status →\n // get result) before persisting, so the fixture stores the FINAL job\n // body, not the IN_QUEUE envelope. sync-run is already a single\n // request/response cycle and the generic recorder handles it.\n if (route.kind === \"queue-submit\") {\n const outcome = await proxyAndRecordFalQueueSubmit({\n req,\n res,\n syntheticReq,\n modelId,\n pathname,\n strippedPath: stripFalPrefix(pathname),\n body,\n fixtures,\n defaults: effectiveDefaults,\n stateKey,\n journal,\n });\n if (outcome === \"handled\") return \"handled\";\n // outcome === \"no_upstream\" — fall through to 404 (strict was\n // already handled above)\n } else {\n const outcome = await proxyAndRecord(\n req,\n res,\n syntheticReq,\n \"fal\",\n stripFalPrefix(pathname),\n fixtures,\n effectiveDefaults,\n body,\n );\n if (outcome === \"handled_by_hook\") return \"handled\";\n if (outcome !== \"not_configured\") {\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: res.statusCode ?? 200, fixture: null, source: \"proxy\" },\n });\n return \"handled\";\n }\n }\n }\n\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: 404,\n fixture: null,\n ...strictOverrideField(defaults.strict, req.headers),\n },\n });\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: {\n message: \"No fixture matched\",\n type: \"invalid_request_error\",\n code: \"no_fixture_match\",\n },\n }),\n );\n return \"handled\";\n }\n\n journal.incrementFixtureMatchCount(fixture, fixtures, testId);\n const response = await resolveResponse(fixture, syntheticReq);\n\n if (isErrorResponse(response)) {\n const status = response.status ?? 500;\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status, fixture },\n });\n writeErrorResponse(res, status, serializeErrorResponse(response), {\n retryAfter: response.retryAfter,\n });\n return \"handled\";\n }\n\n let payload: unknown;\n if (isJSONResponse(response)) {\n payload = (response as RawJSONResponse).json;\n } else if (isAudioResponse(response)) {\n payload = audioToFalFile(response);\n } else {\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 500, fixture },\n });\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: {\n message: \"Fixture response is not JSON or audio for fal endpoint\",\n type: \"server_error\",\n },\n }),\n );\n return \"handled\";\n }\n\n if (route.kind === \"sync-run\") {\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 200, fixture },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(payload));\n return \"handled\";\n }\n\n const requestId = crypto.randomUUID();\n const progression = resolveProgression(defaults.falQueue);\n const now = Date.now();\n const initialStatus: FalQueueStatus =\n progression.pollsBeforeCompleted === 0 ? \"COMPLETED\" : \"IN_QUEUE\";\n const job: FalQueueJob = {\n requestId,\n modelId,\n status: initialStatus,\n result: payload,\n billableUnits: extractBillableUnits(response),\n pollCount: 0,\n pollsBeforeInProgress: progression.pollsBeforeInProgress,\n pollsBeforeCompleted: progression.pollsBeforeCompleted,\n submittedAt: now,\n completedAt: initialStatus === \"COMPLETED\" ? now : null,\n logs: [\n {\n timestamp: new Date(now).toISOString(),\n level: \"INFO\",\n message: \"Job enqueued.\",\n },\n ],\n createdAt: now,\n };\n falQueueStates.set(stateKey(requestId), job);\n const envelope = {\n request_id: requestId,\n response_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${requestId}`,\n status_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${requestId}/status`,\n cancel_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${requestId}/cancel`,\n queue_position: queuePosition(job),\n };\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 200, fixture },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(envelope));\n return \"handled\";\n }\n }\n}\n\nfunction parseBody(raw: string): Record<string, unknown> | null {\n if (!raw.trim()) return null;\n try {\n return JSON.parse(raw) as Record<string, unknown>;\n } catch (err) {\n const detail = err instanceof Error ? err.message : \"unknown\";\n throw new Error(`Malformed JSON: ${detail}`);\n }\n}\n\n// ─── Queue-walk recording ──────────────────────────────────────────────\n//\n// The fal queue protocol surfaces three endpoints — submit (POST), status\n// (GET, polled), and result (GET) — but at the fixture layer we only store ONE\n// thing: the FINAL job body. A naive `proxyAndRecord` against submit would\n// persist the IN_QUEUE envelope, which is useless to replay (the SDK polls\n// status and then reads the result body, expecting `{ images: [...] }`-shaped\n// model output, not an envelope). So during recording we walk the upstream\n// queue ourselves, capture the result body, and write THAT as the fixture —\n// then synthesise the local envelope the same way the replay path does.\n\nconst DEFAULT_FAL_POLL_INTERVAL_MS = 1000;\n// Video generations (kling, veo, runway, etc.) routinely take 5–10 minutes\n// on the upstream queue; 15 min gives headroom without trapping a genuinely\n// hung job indefinitely.\nconst DEFAULT_FAL_TIMEOUT_MS = 900_000;\n// Per-fetch upstream timeout default for the walk's submit/status/result\n// fetches — same value and clamp conventions as the rest of the recording\n// surfaces (recorder.ts / openrouter-video.ts).\nconst DEFAULT_FAL_FETCH_TIMEOUT_MS = 30_000;\n\n// Upstream header forwarding uses buildForwardHeaders (recorder.ts) — the\n// shared strip list (hop-by-hop, client-set, and the mock-internal x-test-id\n// / x-aimock-strict / x-aimock-context / x-aimock-chaos-* family), so the\n// fal queue walk never leaks control headers onto a real provider's wire\n// (one shared list, not a per-surface copy, so the surfaces cannot drift).\n\n/**\n * The result of a queue walk: the parsed final body plus the billed quantity\n * captured from the result fetch's `x-fal-billable-units` header (`null` when\n * upstream didn't set it). The caller persists `body` as the fixture and seeds\n * local queue state; `billableUnits` round-trips real fal billing so a recorded\n * fixture replays `x-fal-billable-units` without hand-editing.\n */\nexport interface FalQueueWalkResult {\n body: unknown;\n billableUnits: number | null;\n}\n\n/**\n * Walk a fal-shaped queue protocol upstream: POST submit, poll status until\n * COMPLETED, GET final result. Returns the parsed final body and the result\n * response's billed quantity so the caller can persist the fixture and seed\n * local queue state.\n *\n * Decoupled from the route layer so the legacy `/fal/queue/submit/{model}`\n * audio path (`fal-audio.ts`) can reuse the same logic.\n */\nexport async function walkFalQueue(args: {\n upstreamBase: string;\n submitPath: string;\n body: string;\n headers: Record<string, string>;\n pollIntervalMs?: number;\n timeoutMs?: number;\n /**\n * Per-fetch upstream timeout (`record.upstreamTimeoutMs` clamp conventions,\n * 30s default) applied to each of the walk's submit/status/result fetches\n * via AbortSignal — a hung upstream socket must not pin the walk past its\n * budget. Each fetch's signal is additionally clamped to the walk's\n * remaining deadline; an abort surfaces through the caller's existing\n * failure handling (502, no fixture persisted).\n */\n upstreamTimeoutMs?: number;\n /**\n * Build the status-poll URL from `request_id` when upstream's submit\n * response doesn't return a usable `status_url`. The legacy path uses\n * aimock-internal `/fal/queue/requests/<id>/status` rather than fal.ai's\n * `/<model>/requests/<id>/status` layout.\n */\n fallbackStatusPath: (requestId: string) => string;\n fallbackResultPath: (requestId: string) => string;\n /** Warn sink for the same-origin envelope-URL gate (omitting it only mutes the warns). */\n logger?: Logger;\n}): Promise<FalQueueWalkResult> {\n const {\n upstreamBase,\n submitPath,\n body,\n headers,\n pollIntervalMs = DEFAULT_FAL_POLL_INTERVAL_MS,\n timeoutMs = DEFAULT_FAL_TIMEOUT_MS,\n upstreamTimeoutMs,\n fallbackStatusPath,\n fallbackResultPath,\n logger,\n } = args;\n\n const deadline = Date.now() + timeoutMs;\n const perFetchTimeoutMs = clampTimeout(upstreamTimeoutMs, DEFAULT_FAL_FETCH_TIMEOUT_MS);\n // Bound every upstream fetch: the per-fetch timeout, additionally clamped\n // to the walk's remaining budget so a fetch can never outlive the deadline\n // (floored at 1ms — AbortSignal.timeout rejects non-positive values; the\n // deadline check in the poll loop is the authoritative expiry).\n const fetchSignal = (): AbortSignal =>\n AbortSignal.timeout(Math.max(1, Math.min(perFetchTimeoutMs, deadline - Date.now())));\n\n // ── 1. POST submit ────────────────────────────────────────────────\n const submitUrl = resolveUpstreamUrl(upstreamBase, submitPath);\n const submitRes = await fetch(submitUrl, {\n method: \"POST\",\n headers,\n body,\n signal: fetchSignal(),\n });\n const submitText = await submitRes.text();\n if (!submitRes.ok) {\n throw new Error(`Submit ${submitRes.status}: ${submitText.slice(0, 200)}`);\n }\n const submitJson = parseJsonOrThrow(submitText, \"Submit\");\n // JSON.parse admits null/arrays/scalars — reject non-object envelopes with\n // a clean error instead of TypeError-ing on the field reads below (the\n // OpenRouter video proxies apply the same guard).\n if (submitJson === null || typeof submitJson !== \"object\" || Array.isArray(submitJson)) {\n throw new Error(\"Submit response is not a JSON object\");\n }\n const env = submitJson as Record<string, unknown>;\n const upstreamRequestId = String(env.request_id ?? \"\").trim();\n if (!upstreamRequestId) {\n throw new Error(\"Submit response missing request_id\");\n }\n\n // Prefer the URLs upstream returned — but ONLY same-origin with the\n // configured upstream (mirroring the OpenRouter video proxy's\n // polling_url gate): every status/result fetch below forwards the client's\n // headers — including Authorization — so an envelope nominating a foreign\n // host must never receive them. Same-origin still covers the documented\n // proxy-in-front case (the configured upstream IS that proxy); off-origin,\n // unparseable, or absent envelope URLs fall back to the constructed\n // canonical paths on the upstream origin, with a warn.\n const upstreamOrigin = submitUrl.origin;\n const adoptSameOrigin = (value: unknown, label: string, fallbackPath: string): URL => {\n if (typeof value === \"string\" && value) {\n try {\n const parsed = new URL(value);\n if (parsed.origin === upstreamOrigin) return parsed;\n logger?.warn(\n `Upstream ${label} origin ${parsed.origin} differs from the upstream origin ${upstreamOrigin} — using the constructed canonical path instead`,\n );\n } catch {\n logger?.warn(\n `Upstream ${label} is not a valid URL (${value.slice(0, 100)}) — using the constructed canonical path instead`,\n );\n }\n }\n return resolveUpstreamUrl(upstreamBase, fallbackPath);\n };\n const statusUrl = adoptSameOrigin(\n env.status_url,\n \"status_url\",\n fallbackStatusPath(upstreamRequestId),\n );\n const resultUrl = adoptSameOrigin(\n env.response_url,\n \"response_url\",\n fallbackResultPath(upstreamRequestId),\n );\n\n // ── 2. Poll status until COMPLETED ───────────────────────────────\n while (true) {\n if (Date.now() > deadline) throw new Error(`Queue walk timed out after ${timeoutMs}ms`);\n const statusRes = await fetch(statusUrl, { headers, signal: fetchSignal() });\n const statusText = await statusRes.text();\n if (!statusRes.ok) {\n throw new Error(`Status ${statusRes.status}: ${statusText.slice(0, 200)}`);\n }\n const statusParsed = parseJsonOrThrow(statusText, \"Status\");\n // Same non-object guard as the submit envelope above.\n if (statusParsed === null || typeof statusParsed !== \"object\" || Array.isArray(statusParsed)) {\n throw new Error(\"Status response is not a JSON object\");\n }\n const statusJson = statusParsed as Record<string, unknown>;\n const s = String(statusJson.status ?? \"\");\n if (s === \"COMPLETED\") break;\n if (s === \"FAILED\" || s === \"ERROR\" || s === \"CANCELLED\") {\n throw new Error(`Upstream job terminated with status ${s}`);\n }\n const remaining = deadline - Date.now();\n // Time out only when the budget is actually exhausted — a zero\n // pollIntervalMs is a valid \"poll as fast as possible\" configuration,\n // not an instant expiry (setTimeout(0) still yields the event loop).\n if (remaining <= 0) throw new Error(`Queue walk timed out after ${timeoutMs}ms`);\n const sleep = Math.min(pollIntervalMs, remaining);\n await new Promise<void>((r) => setTimeout(r, Math.max(0, sleep)));\n }\n\n // ── 3. GET final result ──────────────────────────────────────────\n const resultRes = await fetch(resultUrl, { headers, signal: fetchSignal() });\n const resultText = await resultRes.text();\n if (!resultRes.ok) {\n throw new Error(`Result ${resultRes.status}: ${resultText.slice(0, 200)}`);\n }\n return {\n body: parseJsonOrThrow(resultText, \"Result\"),\n billableUnits: parseBillableUnitsHeader(resultRes.headers.get(FAL_BILLABLE_UNITS_HEADER)),\n };\n}\n\n/**\n * Parse the upstream `x-fal-billable-units` header into a finite number, or\n * `null` when absent/unparseable — mirrors the consumer-side parse so a\n * recorded fixture's captured units match what the live adapter would have read.\n */\nfunction parseBillableUnitsHeader(raw: string | null): number | null {\n if (raw == null) return null;\n const value = Number(raw.trim());\n return Number.isFinite(value) ? value : null;\n}\n\nasync function proxyAndRecordFalQueueSubmit(args: {\n req: http.IncomingMessage;\n res: http.ServerResponse;\n syntheticReq: ChatCompletionRequest;\n modelId: string;\n pathname: string;\n strippedPath: string;\n body: string;\n fixtures: Fixture[];\n defaults: HandlerDefaults;\n stateKey: (id: string) => string;\n journal: Journal;\n}): Promise<\"handled\" | \"no_upstream\"> {\n const {\n req,\n res,\n syntheticReq,\n modelId,\n pathname,\n strippedPath,\n body,\n fixtures,\n defaults,\n stateKey,\n journal,\n } = args;\n\n const record = defaults.record;\n if (!record) return \"no_upstream\";\n const upstreamBase = record.providers.fal;\n if (!upstreamBase) {\n defaults.logger.warn(`No upstream URL configured for provider \"fal\" — cannot proxy`);\n return \"no_upstream\";\n }\n\n defaults.logger.warn(`NO FIXTURE MATCH — walking fal queue at ${upstreamBase}${strippedPath}`);\n\n let finalBody: unknown;\n let billableUnits: number | null;\n try {\n const walk = await walkFalQueue({\n upstreamBase,\n submitPath: strippedPath,\n body,\n headers: buildForwardHeaders(req),\n pollIntervalMs: record.fal?.pollIntervalMs,\n timeoutMs: record.fal?.timeoutMs,\n upstreamTimeoutMs: record.upstreamTimeoutMs,\n fallbackStatusPath: (id) => `${modelId}/requests/${id}/status`,\n fallbackResultPath: (id) => `${modelId}/requests/${id}`,\n logger: defaults.logger,\n });\n finalBody = walk.body;\n billableUnits = walk.billableUnits;\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown queue-walk error\";\n defaults.logger.error(`fal queue-walk proxy failed: ${msg}`);\n // Guard BEFORE journaling (openrouter-video convention): a client that\n // disconnected during the multi-second walk gets neither a write nor a\n // journal entry.\n if (res.destroyed || res.writableEnded) return \"handled\";\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 502, fixture: null, source: \"proxy\" },\n });\n res.writeHead(502, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: { message: `Proxy to upstream failed: ${msg}`, type: \"proxy_error\" },\n }),\n );\n return \"handled\";\n }\n\n // ── 4. Persist fixture using the FINAL body, not the submit envelope ──\n const matchRequest = defaults.requestTransform\n ? defaults.requestTransform(syntheticReq)\n : syntheticReq;\n const fixture: Fixture = {\n match: buildFixtureMatch(matchRequest, record),\n // Persist the captured billed quantity so the recorded fixture replays\n // x-fal-billable-units verbatim; omit the field entirely when upstream\n // didn't bill (keeps recorded fixtures clean).\n response: {\n json: finalBody,\n status: 200,\n ...(billableUnits != null ? { billableUnits } : {}),\n },\n };\n const persistResult = persistFixture({\n record,\n providerKey: \"fal\",\n testId: getTestId(req),\n fixture,\n fixtures,\n logger: defaults.logger,\n });\n // Surface a persist failure on the envelope (parity with the generic\n // recorder relay and the OpenRouter failed branch) — the synthesized\n // envelope below has not been written yet, so the header can still ride it.\n if (persistResult.kind === \"failed\" && !res.headersSent) {\n res.setHeader(\"X-AIMock-Record-Error\", sanitizeHeaderValue(persistResult.error));\n }\n\n // ── 5. Synthesise envelope + seed state (same shape as the replay path) ──\n const newRequestId = crypto.randomUUID();\n const progression = resolveProgression(defaults.falQueue);\n const now = Date.now();\n const initialStatus: FalQueueStatus =\n progression.pollsBeforeCompleted === 0 ? \"COMPLETED\" : \"IN_QUEUE\";\n const job: FalQueueJob = {\n requestId: newRequestId,\n modelId,\n status: initialStatus,\n result: finalBody,\n // Captured from the upstream result's x-fal-billable-units header so the\n // same-session replay (before the persisted fixture is reloaded) already\n // surfaces the billed quantity.\n billableUnits,\n pollCount: 0,\n pollsBeforeInProgress: progression.pollsBeforeInProgress,\n pollsBeforeCompleted: progression.pollsBeforeCompleted,\n submittedAt: now,\n completedAt: initialStatus === \"COMPLETED\" ? now : null,\n logs: [{ timestamp: new Date(now).toISOString(), level: \"INFO\", message: \"Job enqueued.\" }],\n createdAt: now,\n };\n falQueueStates.set(stateKey(newRequestId), job);\n const envelope = {\n request_id: newRequestId,\n response_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${newRequestId}`,\n status_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${newRequestId}/status`,\n cancel_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${newRequestId}/cancel`,\n queue_position: queuePosition(job),\n };\n // Guard BEFORE journaling (openrouter-video convention): a client that\n // disconnected during the multi-second walk gets neither a write nor a\n // journal entry. The persisted fixture and seeded state above stay — the\n // captured upstream response is valuable regardless.\n if (res.destroyed || res.writableEnded) return \"handled\";\n journal.add({\n method: req.method ?? \"POST\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 200, fixture: null, source: \"proxy\" },\n });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(envelope));\n return \"handled\";\n}\n\nfunction parseJsonOrThrow(text: string, label: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n throw new Error(`${label} returned non-JSON: ${text.slice(0, 200)}`);\n }\n}\n\nfunction withFalUpstream(defaults: HandlerDefaults, targetHost: string): HandlerDefaults {\n if (!defaults.record) return defaults;\n // Respect an explicit record.providers.fal — tests and dev configs need to\n // point at a stub upstream. Only synthesise from the header when the user\n // didn't configure one (the \"or omit upstream URL — it's in the request\n // hostname\" mode from the issue).\n if (defaults.record.providers.fal) return defaults;\n return {\n ...defaults,\n record: {\n ...defaults.record,\n providers: {\n ...defaults.record.providers,\n fal: `https://${targetHost}`,\n },\n },\n };\n}\n\nfunction writeJson(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n status: number,\n payload: unknown,\n pathname: string,\n journal: Journal,\n extraHeaders?: Record<string, string>,\n): void {\n journal.add({\n method: req.method ?? \"GET\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status, fixture: null },\n });\n res.writeHead(status, { \"Content-Type\": \"application/json\", ...extraHeaders });\n res.end(JSON.stringify(payload));\n}\n\nfunction respondNotFound(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n pathname: string,\n journal: Journal,\n requestId: string,\n): void {\n journal.add({\n method: req.method ?? \"GET\",\n path: pathname,\n headers: flattenHeaders(req.headers),\n body: null,\n response: { status: 404, fixture: null },\n });\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: { message: `Request ${requestId} not found`, type: \"not_found\" },\n }),\n );\n}\n"],"mappings":"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"}
package/dist/fal.d.cts CHANGED
@@ -9,6 +9,12 @@ interface FalQueueJob {
9
9
  modelId: string;
10
10
  status: FalQueueStatus;
11
11
  result: unknown;
12
+ /**
13
+ * Billed quantity emitted as the `x-fal-billable-units` header on the
14
+ * completed `queue-result` response. Sourced from the fixture's
15
+ * `response.billableUnits`; `null` when the fixture omitted it (no header).
16
+ */
17
+ billableUnits: number | null;
12
18
  /** Number of `/status` (or `/{id}`) polls the caller has made against this job. */
13
19
  pollCount: number;
14
20
  /** Poll-count threshold for `IN_QUEUE → IN_PROGRESS` transition. */
@@ -50,12 +56,11 @@ type HandleFalOutcome = "handled" | "passthrough";
50
56
  */
51
57
  declare function handleFal(req: http.IncomingMessage, res: http.ServerResponse, body: string, pathname: string, fixtures: Fixture[], defaults: HandlerDefaults, journal: Journal): Promise<HandleFalOutcome>;
52
58
  /**
53
- * Walk a fal-shaped queue protocol upstream: POST submit, poll status until
54
- * COMPLETED, GET final result body. Returns the parsed final body so the caller
55
- * can persist it as the fixture and seed local queue state.
56
- *
57
- * Decoupled from the route layer so the legacy `/fal/queue/submit/{model}`
58
- * audio path (`fal-audio.ts`) can reuse the same logic.
59
+ * The result of a queue walk: the parsed final body plus the billed quantity
60
+ * captured from the result fetch's `x-fal-billable-units` header (`null` when
61
+ * upstream didn't set it). The caller persists `body` as the fixture and seeds
62
+ * local queue state; `billableUnits` round-trips real fal billing so a recorded
63
+ * fixture replays `x-fal-billable-units` without hand-editing.
59
64
  */
60
65
  //#endregion
61
66
  export { FalQueueStateMap, handleFal };
@@ -1 +1 @@
1
- {"version":3,"file":"fal.d.cts","names":[],"sources":["../src/fal.ts"],"sourcesContent":[],"mappings":";;;;;KA8CK,cAAA;AARuC,UAUlC,WAAA,CAFS;EAET,SAAA,EAAA,MAAW;EAAA,OAAA,EAAA,MAAA;QAGX,EAAA,cAAA;QAWF,EAAA,OAAA;EAAK;EAcA,SAAA,EAAA,MAAA;EAAgB;uBAGT,EAAA,MAAA;;EAUe,oBAAA,EAAA,MAAA;EA+NvB,WAAA,EAAA,MAAgB;EA+EN,WAAA,EAAS,MAAA,GAAA,IAAA;EAAA;MACxB,EA1UC,KA0UI,CAAA;IACL,SAAK,EAAA,MAAA;IAGA,KAAA,EAAA,MAAA;IACA,OAAA,EAAA,MAAA;;WAED,EAAA,MAAA;;;;;;;cAnUE,gBAAA;;oBAGO;wBAUI;;;;;KA+NZ,gBAAA;;;;;;;;;;iBA+EU,SAAA,MACf,IAAA,CAAK,sBACL,IAAA,CAAK,0DAGA,qBACA,0BACD,UACR,QAAQ"}
1
+ {"version":3,"file":"fal.d.cts","names":[],"sources":["../src/fal.ts"],"sourcesContent":[],"mappings":";;;;;KA8CK,cAAA;AARuC,UAUlC,WAAA,CAFS;EAET,SAAA,EAAA,MAAW;EAAA,OAAA,EAAA,MAAA;QAGX,EAAA,cAAA;QAiBF,EAAA,OAAA;EAAK;AAcb;;;;EAamC,aAAA,EAAA,MAAA,GAAA,IAAA;EAyPvB;EA+EU,SAAA,EAAA,MAAS;EAAA;uBACnB,EAAA,MAAA;;sBAIA,EAAA,MAAA;aACA,EAAA,MAAA;aACD,EAAA,MAAA,GAAA,IAAA;;MACR,EA3WK,KA2WL,CAAA;IAAO,SAAA,EAAA,MAAA;;;;;;;;;;;cA7VG,gBAAA;;oBAGO;wBAUI;;;;;KAyPZ,gBAAA;;;;;;;;;;iBA+EU,SAAA,MACf,IAAA,CAAK,sBACL,IAAA,CAAK,0DAGA,qBACA,0BACD,UACR,QAAQ"}
package/dist/fal.d.ts CHANGED
@@ -9,6 +9,12 @@ interface FalQueueJob {
9
9
  modelId: string;
10
10
  status: FalQueueStatus;
11
11
  result: unknown;
12
+ /**
13
+ * Billed quantity emitted as the `x-fal-billable-units` header on the
14
+ * completed `queue-result` response. Sourced from the fixture's
15
+ * `response.billableUnits`; `null` when the fixture omitted it (no header).
16
+ */
17
+ billableUnits: number | null;
12
18
  /** Number of `/status` (or `/{id}`) polls the caller has made against this job. */
13
19
  pollCount: number;
14
20
  /** Poll-count threshold for `IN_QUEUE → IN_PROGRESS` transition. */
@@ -50,12 +56,11 @@ type HandleFalOutcome = "handled" | "passthrough";
50
56
  */
51
57
  declare function handleFal(req: http.IncomingMessage, res: http.ServerResponse, body: string, pathname: string, fixtures: Fixture[], defaults: HandlerDefaults, journal: Journal): Promise<HandleFalOutcome>;
52
58
  /**
53
- * Walk a fal-shaped queue protocol upstream: POST submit, poll status until
54
- * COMPLETED, GET final result body. Returns the parsed final body so the caller
55
- * can persist it as the fixture and seed local queue state.
56
- *
57
- * Decoupled from the route layer so the legacy `/fal/queue/submit/{model}`
58
- * audio path (`fal-audio.ts`) can reuse the same logic.
59
+ * The result of a queue walk: the parsed final body plus the billed quantity
60
+ * captured from the result fetch's `x-fal-billable-units` header (`null` when
61
+ * upstream didn't set it). The caller persists `body` as the fixture and seeds
62
+ * local queue state; `billableUnits` round-trips real fal billing so a recorded
63
+ * fixture replays `x-fal-billable-units` without hand-editing.
59
64
  */
60
65
  //#endregion
61
66
  export { FalQueueStateMap, handleFal };
package/dist/fal.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fal.d.ts","names":[],"sources":["../src/fal.ts"],"sourcesContent":[],"mappings":";;;;;KA8CK,cAAA;AARuC,UAUlC,WAAA,CAFS;EAET,SAAA,EAAA,MAAW;EAAA,OAAA,EAAA,MAAA;QAGX,EAAA,cAAA;QAWF,EAAA,OAAA;EAAK;EAcA,SAAA,EAAA,MAAA;EAAgB;uBAGT,EAAA,MAAA;;EAUe,oBAAA,EAAA,MAAA;EA+NvB,WAAA,EAAA,MAAgB;EA+EN,WAAA,EAAS,MAAA,GAAA,IAAA;EAAA;MACxB,EA1UC,KA0UI,CAAA;IACL,SAAK,EAAA,MAAA;IAGA,KAAA,EAAA,MAAA;IACA,OAAA,EAAA,MAAA;;WAED,EAAA,MAAA;;;;;;;cAnUE,gBAAA;;oBAGO;wBAUI;;;;;KA+NZ,gBAAA;;;;;;;;;;iBA+EU,SAAA,MACf,IAAA,CAAK,sBACL,IAAA,CAAK,0DAGA,qBACA,0BACD,UACR,QAAQ"}
1
+ {"version":3,"file":"fal.d.ts","names":[],"sources":["../src/fal.ts"],"sourcesContent":[],"mappings":";;;;;KA8CK,cAAA;AARuC,UAUlC,WAAA,CAFS;EAET,SAAA,EAAA,MAAW;EAAA,OAAA,EAAA,MAAA;QAGX,EAAA,cAAA;QAiBF,EAAA,OAAA;EAAK;AAcb;;;;EAamC,aAAA,EAAA,MAAA,GAAA,IAAA;EAyPvB;EA+EU,SAAA,EAAA,MAAS;EAAA;uBACnB,EAAA,MAAA;;sBAIA,EAAA,MAAA;aACA,EAAA,MAAA;aACD,EAAA,MAAA,GAAA,IAAA;;MACR,EA3WK,KA2WL,CAAA;IAAO,SAAA,EAAA,MAAA;;;;;;;;;;;cA7VG,gBAAA;;oBAGO;wBAUI;;;;;KAyPZ,gBAAA;;;;;;;;;;iBA+EU,SAAA,MACf,IAAA,CAAK,sBACL,IAAA,CAAK,0DAGA,qBACA,0BACD,UACR,QAAQ"}