Skip to content

Commit 506fef2

Browse files
committed
feat: fal handler — typed image/video helpers + queue polling realism
Closes #170. The general fal handler (#152) accepts opaque `RawJSONResponse` payloads and always reports `COMPLETED` on submit. Two follow-up gaps this PR closes: 1. Typed helpers for image and video. Callers building fal mocks against the real wire shape currently hand-write the envelope every time (`{ images: [{ url, width, height, content_type }], timings, seed, has_nsfw_concepts, prompt }` for image; `{ video: { url, content_type, file_name, file_size }, seed }` for video). Add `LLMock.onFalImage(prompt, ImageResponse, opts?)` and `LLMock.onFalVideo(prompt, VideoResponse, opts?)` that accept the same response shapes used by `onImage` / `onVideo` and translate them into the fal envelope under the hood. Both delegate to `onFalQueue` so recording, matching, and lifecycle behavior stay identical. The converters live in `src/fal.ts` next to the queue logic. 2. Queue polling realism. Opt in via `MockServerOptions.falQueue: { pollsBeforeInProgress, pollsBeforeCompleted }`. When set, jobs advance `IN_QUEUE → IN_PROGRESS → COMPLETED` over the configured number of `/status` (or `/{id}`) polls. Status responses include `logs: [{ timestamp, level, message }]` (one entry per state transition) and `metrics.inference_time` (wall-clock elapsed since submit) once `COMPLETED`. Cancel before completion returns `200 { status: "CANCELLED" }`; after still returns `400 { status: "ALREADY_COMPLETED" }`. Result fetch before completion returns `202` with the current status body (was previously unreachable). Defaults preserve the existing instant-complete behavior so existing tests and fixtures remain green without change. Tests: 8 new cases (image/video lifecycles, sync run with image envelope, polling progression with logs+metrics, result-before-complete, cancel-before and cancel-after, ImageItem URL fallback). All 2849 tests pass. Docs: new "Typed Helpers" and "Polling Realism" sections in docs/fal-ai/. README "Multimedia APIs" line includes fal.ai.
1 parent a9996c7 commit 506fef2

7 files changed

Lines changed: 518 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Run them all on one port with `npx @copilotkit/aimock --config aimock.json`, or
5151
- **[Record & Replay](https://aimock.copilotkit.dev/record-replay)** — Proxy real APIs, save as fixtures, replay deterministically forever
5252
- **[Multi-turn Conversations](https://aimock.copilotkit.dev/multi-turn)** — Record and replay multi-turn traces with tool rounds; match distinct turns via `turnIndex`, `hasToolResult`, `toolCallId`, `sequenceIndex`, or custom predicates
5353
- **[12 LLM Providers](https://aimock.copilotkit.dev/docs)** — OpenAI Chat, OpenAI Responses, OpenAI Realtime, Claude, Gemini, Gemini Live, Gemini Interactions, Azure, Bedrock, Vertex AI, Ollama, Cohere — full streaming support
54-
- **Multimedia APIs**[image generation](https://aimock.copilotkit.dev/images) (DALL-E, Imagen), [text-to-speech](https://aimock.copilotkit.dev/speech), [audio transcription](https://aimock.copilotkit.dev/transcription), [video generation](https://aimock.copilotkit.dev/video)
54+
- **Multimedia APIs**[image generation](https://aimock.copilotkit.dev/images) (DALL-E, Imagen), [text-to-speech](https://aimock.copilotkit.dev/speech), [audio transcription](https://aimock.copilotkit.dev/transcription), [video generation](https://aimock.copilotkit.dev/video), [fal.ai](https://aimock.copilotkit.dev/fal-ai) (image / video / audio with queue lifecycle)
5555
- **[MCP](https://aimock.copilotkit.dev/mcp-mock) / [A2A](https://aimock.copilotkit.dev/a2a-mock) / [AG-UI](https://aimock.copilotkit.dev/agui-mock) / [Vector](https://aimock.copilotkit.dev/vector-mock)** — Mock every protocol your AI agents use
5656
- **[Chaos Testing](https://aimock.copilotkit.dev/chaos-testing)** — 500 errors, malformed JSON, mid-stream disconnects at any probability
5757
- **[Drift Detection](https://aimock.copilotkit.dev/drift-detection)** — Daily CI validation against real APIs

docs/fal-ai/index.html

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,36 @@ <h2>Quick Start (Programmatic)</h2>
114114
});</code></pre>
115115
</div>
116116

117+
<h2>Typed Helpers: <code>onFalImage</code> / <code>onFalVideo</code></h2>
118+
<p>
119+
<code>onFalQueue</code> takes a raw JSON payload — the exact bytes that come out of fal.
120+
When you want stronger types and don't want to hand-write the envelope, use the typed
121+
helpers: they accept the same <code>ImageResponse</code> /
122+
<code>VideoResponse</code> shapes you use with <code>onImage</code> / <code>onVideo</code>
123+
and translate them into fal's wire shape before storing.
124+
</p>
125+
126+
<div class="code-block">
127+
<div class="code-block-header">typed.test.ts <span class="lang-tag">ts</span></div>
128+
<pre><code><span class="cm">// Equivalent to onFalQueue(..., { images: [...], timings, seed, has_nsfw_concepts, prompt })</span>
129+
<span class="op">mock</span>.<span class="fn">onFalImage</span>(<span class="str">/flux/</span>, {
130+
<span class="prop">images</span>: [{ <span class="prop">url</span>: <span class="str">"https://mock.fal.media/x.png"</span> }],
131+
});
132+
133+
<span class="cm">// Equivalent to onFalQueue(..., { video: { url, content_type, file_name, file_size }, seed })</span>
134+
<span class="op">mock</span>.<span class="fn">onFalVideo</span>(<span class="str">/kling/</span>, {
135+
<span class="prop">video</span>: { <span class="prop">id</span>: <span class="str">"v1"</span>, <span class="prop">status</span>: <span class="str">"completed"</span>, <span class="prop">url</span>: <span class="str">"https://mock.fal.media/clip.mp4"</span> },
136+
});</code></pre>
137+
</div>
138+
139+
<p>
140+
Defaults filled in for image: <code>width: 1024</code>, <code>height: 1024</code>,
141+
<code>content_type</code> inferred from URL extension,
142+
<code>has_nsfw_concepts: false</code>, <code>timings.inference: 0</code>,
143+
<code>seed: 0</code>. For video: <code>content_type</code> +
144+
<code>file_name</code> inferred from URL, <code>file_size: 0</code>, <code>seed: 0</code>.
145+
</p>
146+
117147
<h2>Client Configuration</h2>
118148
<p>
119149
Point the <code>@fal-ai/client</code> at aimock using <code>requestMiddleware</code> to
@@ -169,23 +199,63 @@ <h2>Queue Lifecycle</h2>
169199
<td>Status</td>
170200
<td>GET</td>
171201
<td><code>/fal/{owner}/{model}/requests/{id}/status</code></td>
172-
<td><code>{ status: "COMPLETED" }</code></td>
202+
<td>
203+
<code>{ status, request_id, response_url, logs[] }</code> &mdash;
204+
<code>queue_position</code> while pending, <code>metrics.inference_time</code> once
205+
<code>COMPLETED</code>
206+
</td>
173207
</tr>
174208
<tr>
175209
<td>Result</td>
176210
<td>GET</td>
177211
<td><code>/fal/{owner}/{model}/requests/{id}</code></td>
178-
<td>The matched fixture payload</td>
212+
<td>
213+
The matched fixture payload (200) once <code>COMPLETED</code>; the status body (202)
214+
before
215+
</td>
179216
</tr>
180217
<tr>
181218
<td>Cancel</td>
182219
<td>PUT</td>
183220
<td><code>/fal/{owner}/{model}/requests/{id}/cancel</code></td>
184-
<td><code>{ status: "ALREADY_COMPLETED" }</code> (400)</td>
221+
<td>
222+
<code>{ status: "CANCELLED" }</code> (200) before completion;
223+
<code>{ status: "ALREADY_COMPLETED" }</code> (400) after
224+
</td>
185225
</tr>
186226
</tbody>
187227
</table>
188228

229+
<h2>Polling Realism</h2>
230+
<p>
231+
By default a queued job completes on submit &mdash; status polls return
232+
<code>COMPLETED</code> immediately and tests stay fast. To exercise client code that
233+
reacts to <code>IN_QUEUE</code> / <code>IN_PROGRESS</code> (queue position decay, log
234+
accumulation, latency metrics), pass <code>falQueue</code> with positive poll thresholds.
235+
The job advances through the state machine over the configured number of
236+
<code>/status</code> calls.
237+
</p>
238+
239+
<div class="code-block">
240+
<div class="code-block-header">polling.test.ts <span class="lang-tag">ts</span></div>
241+
<pre><code><span class="kw">const</span> <span class="op">mock</span> = <span class="kw">new</span> <span class="type">LLMock</span>({
242+
<span class="prop">port</span>: <span class="num">0</span>,
243+
<span class="prop">falQueue</span>: { <span class="prop">pollsBeforeInProgress</span>: <span class="num">1</span>, <span class="prop">pollsBeforeCompleted</span>: <span class="num">2</span> },
244+
});
245+
<span class="op">mock</span>.<span class="fn">onFalImage</span>(<span class="str">/flux/</span>, { <span class="prop">images</span>: [{ <span class="prop">url</span>: <span class="str">"..."</span> }] });
246+
247+
<span class="cm">// Submit → IN_QUEUE, queue_position: 1</span>
248+
<span class="cm">// status1 → IN_PROGRESS, queue_position: 0, logs[2]</span>
249+
<span class="cm">// status2 → COMPLETED, metrics.inference_time set</span>
250+
<span class="cm">// result → 200 with the matched payload</span></code></pre>
251+
</div>
252+
253+
<p>
254+
<code>logs</code> always contains at least one entry (job enqueued); a transition entry is
255+
appended for each state change. Cancelling a job before completion sets status to
256+
<code>CANCELLED</code> and subsequent polls keep reporting that state.
257+
</p>
258+
189259
<h2>JSON Fixture File</h2>
190260

191261
<div class="code-block">

src/__tests__/fal.test.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,219 @@ describe("fal.ai general handler — record and replay", () => {
312312
expect(upstreamCalls).toBe(1);
313313
});
314314
});
315+
316+
describe("fal.ai general handler — typed helpers + polling progression", () => {
317+
let mock: LLMock;
318+
319+
afterEach(async () => {
320+
await mock?.stop();
321+
});
322+
323+
test("onFalImage wraps an ImageResponse into fal's image envelope", async () => {
324+
mock = new LLMock({ port: 0 });
325+
mock.onFalImage(/flux/, {
326+
images: [{ url: "https://mock.fal.media/files/x.png" }],
327+
});
328+
await mock.start();
329+
330+
const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
331+
method: "POST",
332+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
333+
body: JSON.stringify({ input: { prompt: "a cat" } }),
334+
});
335+
expect(submit.status).toBe(200);
336+
const envelope = await submit.json();
337+
338+
const result = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`, {
339+
headers: { "x-fal-target-host": "queue.fal.run" },
340+
});
341+
expect(result.status).toBe(200);
342+
const data = await result.json();
343+
expect(data.images).toEqual([
344+
{
345+
url: "https://mock.fal.media/files/x.png",
346+
width: 1024,
347+
height: 1024,
348+
content_type: "image/png",
349+
},
350+
]);
351+
expect(data.has_nsfw_concepts).toEqual([false]);
352+
expect(data.timings).toEqual({ inference: 0 });
353+
expect(data.seed).toBe(0);
354+
});
355+
356+
test("onFalImage falls back to a mock URL when ImageItem omits one", async () => {
357+
mock = new LLMock({ port: 0 });
358+
mock.onFalImage(/flux/, { images: [{}] });
359+
await mock.start();
360+
361+
const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
362+
method: "POST",
363+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
364+
body: JSON.stringify({ input: { prompt: "fallback" } }),
365+
});
366+
const envelope = await submit.json();
367+
const result = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`, {
368+
headers: { "x-fal-target-host": "queue.fal.run" },
369+
});
370+
const data = await result.json();
371+
expect(data.images[0].url).toBe("https://mock.fal.media/files/generated_image_0.png");
372+
});
373+
374+
test("onFalVideo wraps a VideoResponse into fal's video envelope", async () => {
375+
mock = new LLMock({ port: 0 });
376+
mock.onFalVideo(/kling/, {
377+
video: { id: "v1", status: "completed", url: "https://mock.fal.media/files/clip.mp4" },
378+
});
379+
await mock.start();
380+
381+
const submit = await fetch(`${mock.url}/fal/fal-ai/kling-video/v2/master`, {
382+
method: "POST",
383+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
384+
body: JSON.stringify({ input: { prompt: "a dragon" } }),
385+
});
386+
const envelope = await submit.json();
387+
388+
const result = await fetch(
389+
`${mock.url}/fal/fal-ai/kling-video/v2/master/requests/${envelope.request_id}`,
390+
{ headers: { "x-fal-target-host": "queue.fal.run" } },
391+
);
392+
expect(result.status).toBe(200);
393+
const data = await result.json();
394+
expect(data.video).toEqual({
395+
url: "https://mock.fal.media/files/clip.mp4",
396+
content_type: "video/mp4",
397+
file_name: "clip.mp4",
398+
file_size: 0,
399+
});
400+
expect(data.seed).toBe(0);
401+
});
402+
403+
test("sync run returns the image envelope directly", async () => {
404+
mock = new LLMock({ port: 0 });
405+
mock.onFalImage(/flux/, { images: [{ url: "https://mock.fal.media/y.jpg" }] });
406+
await mock.start();
407+
408+
const res = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
409+
method: "POST",
410+
headers: { "Content-Type": "application/json", "x-fal-target-host": "fal.run" },
411+
body: JSON.stringify({ prompt: "flux sync" }),
412+
});
413+
expect(res.status).toBe(200);
414+
const data = await res.json();
415+
expect(data.images[0].content_type).toBe("image/jpeg");
416+
expect(data.request_id).toBeUndefined();
417+
});
418+
419+
test("polling progression: IN_QUEUE -> IN_PROGRESS -> COMPLETED with logs + metrics", async () => {
420+
mock = new LLMock({
421+
port: 0,
422+
falQueue: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 },
423+
});
424+
mock.onFalImage(/flux/, { images: [{ url: "https://mock.fal.media/x.png" }] });
425+
await mock.start();
426+
427+
const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
428+
method: "POST",
429+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
430+
body: JSON.stringify({ input: { prompt: "slow" } }),
431+
});
432+
const envelope = await submit.json();
433+
expect(envelope.queue_position).toBe(1);
434+
435+
const jobPath = `${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`;
436+
const headers = { "x-fal-target-host": "queue.fal.run" };
437+
438+
const poll1 = await fetch(`${jobPath}/status`, { headers });
439+
const poll1Data = await poll1.json();
440+
expect(poll1Data.status).toBe("IN_PROGRESS");
441+
expect(poll1Data.queue_position).toBe(0);
442+
expect(Array.isArray(poll1Data.logs)).toBe(true);
443+
expect(poll1Data.logs.length).toBeGreaterThanOrEqual(2);
444+
expect(poll1Data.metrics).toBeUndefined();
445+
446+
const poll2 = await fetch(`${jobPath}/status`, { headers });
447+
const poll2Data = await poll2.json();
448+
expect(poll2Data.status).toBe("COMPLETED");
449+
expect(poll2Data.metrics).toBeDefined();
450+
expect(typeof poll2Data.metrics.inference_time).toBe("number");
451+
452+
const result = await fetch(jobPath, { headers });
453+
expect(result.status).toBe(200);
454+
const resultData = await result.json();
455+
expect(resultData.images).toBeDefined();
456+
});
457+
458+
test("result before completion returns 202 with current status", async () => {
459+
mock = new LLMock({
460+
port: 0,
461+
falQueue: { pollsBeforeInProgress: 5, pollsBeforeCompleted: 10 },
462+
});
463+
mock.onFalImage(/flux/, { images: [{ url: "https://mock.fal.media/x.png" }] });
464+
await mock.start();
465+
466+
const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
467+
method: "POST",
468+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
469+
body: JSON.stringify({ input: { prompt: "never" } }),
470+
});
471+
const { request_id } = await submit.json();
472+
473+
const result = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${request_id}`, {
474+
headers: { "x-fal-target-host": "queue.fal.run" },
475+
});
476+
expect(result.status).toBe(202);
477+
const data = await result.json();
478+
expect(data.status).toBe("IN_QUEUE");
479+
expect(data.images).toBeUndefined();
480+
});
481+
482+
test("cancel before completion returns 200 CANCELLED", async () => {
483+
mock = new LLMock({
484+
port: 0,
485+
falQueue: { pollsBeforeInProgress: 5, pollsBeforeCompleted: 10 },
486+
});
487+
mock.onFalImage(/flux/, { images: [{ url: "https://mock.fal.media/x.png" }] });
488+
await mock.start();
489+
490+
const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
491+
method: "POST",
492+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
493+
body: JSON.stringify({ input: { prompt: "cancel me" } }),
494+
});
495+
const { request_id } = await submit.json();
496+
497+
const cancel = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${request_id}/cancel`, {
498+
method: "PUT",
499+
headers: { "x-fal-target-host": "queue.fal.run" },
500+
});
501+
expect(cancel.status).toBe(200);
502+
expect((await cancel.json()).status).toBe("CANCELLED");
503+
504+
const status = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${request_id}/status`, {
505+
headers: { "x-fal-target-host": "queue.fal.run" },
506+
});
507+
const statusData = await status.json();
508+
expect(statusData.status).toBe("CANCELLED");
509+
});
510+
511+
test("cancel after completion keeps ALREADY_COMPLETED semantics", async () => {
512+
mock = new LLMock({ port: 0 });
513+
mock.onFalImage(/flux/, { images: [{ url: "https://mock.fal.media/x.png" }] });
514+
await mock.start();
515+
516+
const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
517+
method: "POST",
518+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
519+
body: JSON.stringify({ input: { prompt: "done" } }),
520+
});
521+
const { request_id } = await submit.json();
522+
523+
const cancel = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${request_id}/cancel`, {
524+
method: "PUT",
525+
headers: { "x-fal-target-host": "queue.fal.run" },
526+
});
527+
expect(cancel.status).toBe(400);
528+
expect((await cancel.json()).status).toBe("ALREADY_COMPLETED");
529+
});
530+
});

0 commit comments

Comments
 (0)