@volter/twin-fal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +51 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +27 -0
- package/dist/src/fal-budget.d.ts +85 -0
- package/dist/src/fal-budget.js +410 -0
- package/dist/src/fal-capabilities.d.ts +4 -0
- package/dist/src/fal-capabilities.js +419 -0
- package/dist/src/fal-conformance.d.ts +7 -0
- package/dist/src/fal-conformance.js +38 -0
- package/dist/src/fal-connector.d.ts +58 -0
- package/dist/src/fal-connector.js +91 -0
- package/dist/src/fal-scenario.d.ts +26 -0
- package/dist/src/fal-scenario.js +100 -0
- package/dist/src/fal-server.d.ts +18 -0
- package/dist/src/fal-server.js +53 -0
- package/dist/src/fal-twin.d.ts +36 -0
- package/dist/src/fal-twin.js +414 -0
- package/dist/src/fal-webhooks.d.ts +60 -0
- package/dist/src/fal-webhooks.js +202 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +55 -0
- package/package.json +51 -0
- package/src/cli.ts +26 -0
- package/src/fal-budget.ts +456 -0
- package/src/fal-capabilities.ts +448 -0
- package/src/fal-conformance.ts +42 -0
- package/src/fal-connector.ts +123 -0
- package/src/fal-scenario.ts +112 -0
- package/src/fal-server.ts +64 -0
- package/src/fal-twin.ts +479 -0
- package/src/fal-webhooks.ts +234 -0
- package/src/index.ts +100 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// fal capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored top-down
|
|
2
|
+
// from what the fal.ai API actually does — GROUNDED read-only (2026-07-09) against
|
|
3
|
+
// fal.ai/docs/model-endpoints/{queue,webhooks} and the fal-js client source
|
|
4
|
+
// (github.com/fal-ai/fal-js, libs/client/src/{config,middleware,queue,request}.ts) — see
|
|
5
|
+
// spec-sources.json — NOT from what this twin has built. This is the honest denominator: many
|
|
6
|
+
// entries start as `todo` and coverage reads LOW until the twin truly reaches 100% of the API.
|
|
7
|
+
// `verify()` (required to count as done) is ground truth and drives the KERNEL-BACKED handler on
|
|
8
|
+
// a FRESH temp root — never a spawned mock. `expected:'done'` only on capabilities we genuinely
|
|
9
|
+
// claim, so a broken one shows as a regression.
|
|
10
|
+
//
|
|
11
|
+
// v1 SLICE (honesty, per the build spec): queue lifecycle (submit/status/response/cancel) +
|
|
12
|
+
// the sync `fal.run` variant + host-split routing + ed25519 webhooks/JWKS + FastAPI error
|
|
13
|
+
// envelopes are modeled `done`. fal's real surface is genuinely SMALLER than replicate's here —
|
|
14
|
+
// no models/versions/collections/trainings/deployments CRUD (no such REST resources exist for
|
|
15
|
+
// fal) — but SSE streaming, realtime WS, storage/asset upload, and per-model app discovery are
|
|
16
|
+
// left `todo` with vendor-shaped failure (unmodeled route -> 404) — a deliberate v1 scope cut,
|
|
17
|
+
// not an oversight; see README `## Coverage`.
|
|
18
|
+
//
|
|
19
|
+
// THE GENERATIVE STUB IS THE ANSWER: a request's output is a clearly-labeled deterministic stub
|
|
20
|
+
// while the protocol envelope (status machine, queue_position, logs, urls, metrics) is faithful.
|
|
21
|
+
// Every entry here is either done or todo.
|
|
22
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
|
|
26
|
+
import { handleFalTwinRequest } from "./fal-twin.js";
|
|
27
|
+
import { checkFalConformance } from "./fal-conformance.js";
|
|
28
|
+
import { syncFalFromReal } from "./fal-connector.js";
|
|
29
|
+
import { buildSignedFalWebhook, verifyFalWebhook, verifyFalWebhookSignatureWithKey, falWebhookPublicKeyFromJwk, FalWebhookVerificationError, } from "./fal-webhooks.js";
|
|
30
|
+
// (fal is an API-first vendor with no product UI worth mirroring — docs/contributing/architecture.md C1b — so this
|
|
31
|
+
// pack ships no mirror, and there are no UI capabilities.)
|
|
32
|
+
const OCCURRED_AT = '2026-01-01T00:00:00.000Z';
|
|
33
|
+
// Every webhook verify below pins `now` to this same fixed instant — the tolerance check
|
|
34
|
+
// otherwise defaults to the REAL current wall clock, which drifts arbitrarily far from a fixed
|
|
35
|
+
// past `occurredAt` (this file is authored once; `bun test` may run it months later) and would
|
|
36
|
+
// spuriously fail outside `fal.webhooks.timestamp_tolerance`'s own dedicated test of that check.
|
|
37
|
+
const OCCURRED_AT_EPOCH_SEC = Math.floor(Date.parse(OCCURRED_AT) / 1000);
|
|
38
|
+
/** Run a sequence of real fal requests against an isolated root; return all responses. The
|
|
39
|
+
* callback also receives `root` itself — some verifies (webhook/JWKS key-matching) need to
|
|
40
|
+
* build local artifacts against the SAME root the handler used. */
|
|
41
|
+
async function withRoot(steps) {
|
|
42
|
+
const root = mkdtempSync(join(tmpdir(), 'fal-cap-'));
|
|
43
|
+
const h = (s) => handleFalTwinRequest({
|
|
44
|
+
method: s.m,
|
|
45
|
+
path: s.p,
|
|
46
|
+
body: s.b === undefined ? undefined : JSON.stringify(s.b),
|
|
47
|
+
root,
|
|
48
|
+
...(s.h ? { headers: s.h } : {}),
|
|
49
|
+
...(s.host ? { host: s.host } : {}),
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
return await verifyBoundary('fal.withRoot', () => steps(h, root));
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
rmSync(root, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** An isolated root for the verifies that exercise ONLY the local ed25519 helpers (no handler).
|
|
59
|
+
* They still touch state: the signing seed is entropy-born and PERSISTED on first use
|
|
60
|
+
* (fal-webhooks.ts), so a root of their own keeps them hermetic instead of folding a key into
|
|
61
|
+
* whatever project root the suite happens to run from. */
|
|
62
|
+
async function withTempRoot(steps) {
|
|
63
|
+
const root = mkdtempSync(join(tmpdir(), 'fal-cap-'));
|
|
64
|
+
try {
|
|
65
|
+
return await steps(root);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
rmSync(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
72
|
+
const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
73
|
+
export const FAL_CAPABILITIES = [
|
|
74
|
+
todo('fal.assets.served_output_bytes', 'storage', 'Output URLs resolve: persist and serve deterministic placeholder media bytes at the output urls a request settles to (today the output is a labeled placeholder value)', 'api', 'common'),
|
|
75
|
+
// ── QUEUE (submit -> status progression -> response -> cancel) ──────────────────────────
|
|
76
|
+
done('fal.queue.submit', 'queue', 'POST queue.fal.run/{model_id} submits -> 200 (⚠ status code doc-unverified — the docs\' own example omits an explicit code) with request_id/urls/queue_position', 'api', 'core', () => withRoot(async (h) => {
|
|
77
|
+
const r = await h({ m: 'POST', p: '/acme/widget', b: { prompt: 'hi' }, host: 'queue.fal.run' });
|
|
78
|
+
if (r.status !== 200)
|
|
79
|
+
return false;
|
|
80
|
+
const b = r.body;
|
|
81
|
+
const id = b.request_id;
|
|
82
|
+
return typeof id === 'string' && /^[0-9a-f-]{36}$/i.test(id) && b.status === 'IN_QUEUE' && b.queue_position === 0
|
|
83
|
+
&& typeof b.response_url === 'string' && b.response_url.includes(id)
|
|
84
|
+
&& typeof b.status_url === 'string' && b.status_url.includes(id)
|
|
85
|
+
&& typeof b.cancel_url === 'string' && b.cancel_url.includes(id);
|
|
86
|
+
})),
|
|
87
|
+
done('fal.queue.submit_nested_model_id', 'queue', '3-segment model id (owner/app/variant, e.g. fal-ai/flux/schnell) round-trips through the /requests/ split', 'api', 'common', () => withRoot(async (h) => {
|
|
88
|
+
const c = await h({ m: 'POST', p: '/fal-ai/flux/schnell', b: { prompt: 'hi' }, host: 'queue.fal.run' });
|
|
89
|
+
const id = c.body.request_id;
|
|
90
|
+
// a dead twin returns {} — id must be a REAL non-empty string first, or the final
|
|
91
|
+
// comparison (undefined === undefined) is vacuous (the mutation gate catches exactly this).
|
|
92
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
93
|
+
return false;
|
|
94
|
+
const s = await h({ m: 'GET', p: `/fal-ai/flux/schnell/requests/${id}/status`, host: 'queue.fal.run' });
|
|
95
|
+
return c.status === 200 && s.status === 200 && s.body.request_id === id;
|
|
96
|
+
})),
|
|
97
|
+
done('fal.queue.status_in_queue', 'queue', 'first status GET after submit: IN_QUEUE echo + queue_position + base url fields', 'api', 'core', () => withRoot(async (h) => {
|
|
98
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
99
|
+
const id = c.body.request_id;
|
|
100
|
+
const s = await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
101
|
+
const b = s.body;
|
|
102
|
+
return s.status === 200 && b.status === 'IN_QUEUE' && b.queue_position === 0 && b.request_id === id
|
|
103
|
+
&& typeof b.response_url === 'string' && typeof b.status_url === 'string' && typeof b.cancel_url === 'string';
|
|
104
|
+
})),
|
|
105
|
+
done('fal.queue.status_progresses', 'queue', 'status polls progress IN_QUEUE -> IN_PROGRESS -> COMPLETED; COMPLETED carries numeric metrics.inference_time', 'api', 'core', () => withRoot(async (h) => {
|
|
106
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
107
|
+
const id = c.body.request_id;
|
|
108
|
+
const p1 = await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
109
|
+
const p2 = await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
110
|
+
const p3 = await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
111
|
+
if (p1.body.status !== 'IN_QUEUE')
|
|
112
|
+
return false;
|
|
113
|
+
if (p2.body.status !== 'IN_PROGRESS')
|
|
114
|
+
return false;
|
|
115
|
+
const b3 = p3.body;
|
|
116
|
+
return b3.status === 'COMPLETED' && typeof b3.metrics?.inference_time === 'number';
|
|
117
|
+
})),
|
|
118
|
+
done('fal.queue.status_logs_param', 'queue', '?logs=1 returns log entries {message,level,source:USER,timestamp}; logs unset -> absent (⚠ doc-unverified: absent vs explicit-empty)', 'api', 'common', () => withRoot(async (h) => {
|
|
119
|
+
const submit = async () => (await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' })).body.request_id;
|
|
120
|
+
const idA = await submit();
|
|
121
|
+
const idB = await submit();
|
|
122
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${idA}/status`, host: 'queue.fal.run' }); // poll1 IN_QUEUE
|
|
123
|
+
const a2 = await h({ m: 'GET', p: `/acme/widget/requests/${idA}/status?logs=1`, host: 'queue.fal.run' }); // poll2 IN_PROGRESS, logs requested
|
|
124
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${idB}/status`, host: 'queue.fal.run' }); // poll1 IN_QUEUE
|
|
125
|
+
const b2 = await h({ m: 'GET', p: `/acme/widget/requests/${idB}/status`, host: 'queue.fal.run' }); // poll2 IN_PROGRESS, no logs param
|
|
126
|
+
const aBody = a2.body;
|
|
127
|
+
const bBody = b2.body;
|
|
128
|
+
if (aBody.status !== 'IN_PROGRESS' || bBody.status !== 'IN_PROGRESS')
|
|
129
|
+
return false;
|
|
130
|
+
const logs = aBody.logs;
|
|
131
|
+
if (!Array.isArray(logs) || logs.length === 0)
|
|
132
|
+
return false;
|
|
133
|
+
const entry = logs[0];
|
|
134
|
+
const shapeOk = typeof entry.message === 'string' && typeof entry.timestamp === 'string' && typeof entry.level === 'string' && entry.source === 'USER';
|
|
135
|
+
return shapeOk && bBody.logs === undefined;
|
|
136
|
+
})),
|
|
137
|
+
done('fal.queue.logs_progress', 'queue', 'logs array grows across polls (poll2 length < poll3 length), deterministic across independent requests', 'api', 'common', () => withRoot(async (h) => {
|
|
138
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
139
|
+
const id = c.body.request_id;
|
|
140
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status?logs=1`, host: 'queue.fal.run' }); // poll1
|
|
141
|
+
const p2 = await h({ m: 'GET', p: `/acme/widget/requests/${id}/status?logs=1`, host: 'queue.fal.run' }); // poll2
|
|
142
|
+
const p3 = await h({ m: 'GET', p: `/acme/widget/requests/${id}/status?logs=1`, host: 'queue.fal.run' }); // poll3
|
|
143
|
+
const logs2 = p2.body.logs;
|
|
144
|
+
const logs3 = p3.body.logs;
|
|
145
|
+
if (!Array.isArray(logs2) || !Array.isArray(logs3) || logs2.length >= logs3.length)
|
|
146
|
+
return false;
|
|
147
|
+
// determinism: an independent, identically-shaped request reaches the SAME poll-2 log text.
|
|
148
|
+
const c2 = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
149
|
+
const id2 = c2.body.request_id;
|
|
150
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id2}/status?logs=1`, host: 'queue.fal.run' });
|
|
151
|
+
const p2b = await h({ m: 'GET', p: `/acme/widget/requests/${id2}/status?logs=1`, host: 'queue.fal.run' });
|
|
152
|
+
const logs2b = p2b.body.logs;
|
|
153
|
+
return logs2[0].message === logs2b[0].message;
|
|
154
|
+
})),
|
|
155
|
+
done('fal.queue.response', 'queue', 'GET .../requests/{id} after COMPLETED returns the stub output; the docs-example /response alias route also serves it', 'api', 'core', () => withRoot(async (h) => {
|
|
156
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: { prompt: 'hi' }, host: 'queue.fal.run' });
|
|
157
|
+
const id = c.body.request_id;
|
|
158
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
159
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
160
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
161
|
+
const bare = await h({ m: 'GET', p: `/acme/widget/requests/${id}`, host: 'queue.fal.run' });
|
|
162
|
+
const alias = await h({ m: 'GET', p: `/acme/widget/requests/${id}/response`, host: 'queue.fal.run' });
|
|
163
|
+
const out1 = bare.body.output;
|
|
164
|
+
const out2 = alias.body.output;
|
|
165
|
+
return bare.status === 200 && alias.status === 200 && typeof out1 === 'string' && out1.includes('[twin-stub:fal:') && out1 === out2;
|
|
166
|
+
})),
|
|
167
|
+
done('fal.queue.response_deterministic_stub', 'queue', 'stub output is a pure fn of (model_id, input): same input twice -> identical, different input -> different', 'api', 'core', () => withRoot(async (h) => {
|
|
168
|
+
const settle = async (input) => {
|
|
169
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: input, host: 'queue.fal.run' });
|
|
170
|
+
const id = c.body.request_id;
|
|
171
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' }); // poll1 IN_QUEUE
|
|
172
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' }); // poll2 IN_PROGRESS
|
|
173
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' }); // poll3 COMPLETED
|
|
174
|
+
const r = await h({ m: 'GET', p: `/acme/widget/requests/${id}`, host: 'queue.fal.run' });
|
|
175
|
+
return r.body.output;
|
|
176
|
+
};
|
|
177
|
+
const a = await settle({ text: 'same' });
|
|
178
|
+
const b = await settle({ text: 'same' });
|
|
179
|
+
const c = await settle({ text: 'different' });
|
|
180
|
+
return typeof a === 'string' && a.includes('[twin-stub:fal:') && a === b && a !== c;
|
|
181
|
+
})),
|
|
182
|
+
done('fal.queue.request_isolation', 'queue', 'two independent submits: progressing one to COMPLETED leaves the other at IN_QUEUE', 'api', 'common', () => withRoot(async (h) => {
|
|
183
|
+
const a = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
184
|
+
const b = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
185
|
+
const idA = a.body.request_id;
|
|
186
|
+
const idB = b.body.request_id;
|
|
187
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${idA}/status`, host: 'queue.fal.run' });
|
|
188
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${idA}/status`, host: 'queue.fal.run' });
|
|
189
|
+
const sa = await h({ m: 'GET', p: `/acme/widget/requests/${idA}/status`, host: 'queue.fal.run' });
|
|
190
|
+
const sb = await h({ m: 'GET', p: `/acme/widget/requests/${idB}/status`, host: 'queue.fal.run' });
|
|
191
|
+
return sa.body.status === 'COMPLETED' && sb.body.status === 'IN_QUEUE';
|
|
192
|
+
})),
|
|
193
|
+
done('fal.queue.status_unknown_404', 'queue', 'GET status for an unknown request_id -> 404 {detail} (⚠ doc-unverified exact message text)', 'api', 'common', () => withRoot(async (h) => {
|
|
194
|
+
const r = await h({ m: 'GET', p: '/acme/widget/requests/does-not-exist/status', host: 'queue.fal.run' });
|
|
195
|
+
return r.status === 404 && typeof r.body.detail === 'string';
|
|
196
|
+
})),
|
|
197
|
+
done('fal.queue.cancel_in_queue', 'queue', 'PUT cancel on a non-terminal request -> 202 {"status":"CANCELLATION_REQUESTED"} exact', 'api', 'core', () => withRoot(async (h) => {
|
|
198
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
199
|
+
const id = c.body.request_id;
|
|
200
|
+
const r = await h({ m: 'PUT', p: `/acme/widget/requests/${id}/cancel`, host: 'queue.fal.run' });
|
|
201
|
+
return r.status === 202 && JSON.stringify(r.body) === JSON.stringify({ status: 'CANCELLATION_REQUESTED' });
|
|
202
|
+
})),
|
|
203
|
+
done('fal.queue.cancel_completed_400', 'queue', 'PUT cancel on a COMPLETED request -> 400 {"status":"ALREADY_COMPLETED"} exact', 'api', 'common', () => withRoot(async (h) => {
|
|
204
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
205
|
+
const id = c.body.request_id;
|
|
206
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
207
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
208
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
209
|
+
const r = await h({ m: 'PUT', p: `/acme/widget/requests/${id}/cancel`, host: 'queue.fal.run' });
|
|
210
|
+
return r.status === 400 && JSON.stringify(r.body) === JSON.stringify({ status: 'ALREADY_COMPLETED' });
|
|
211
|
+
})),
|
|
212
|
+
done('fal.queue.cancel_unknown_404', 'queue', 'PUT cancel on an unknown request_id -> 404 {"status":"NOT_FOUND"} exact (its OWN documented shape, distinct from the {detail} envelope every other 404 in this pack uses)', 'api', 'common', () => withRoot(async (h) => {
|
|
213
|
+
const r = await h({ m: 'PUT', p: '/acme/widget/requests/does-not-exist/cancel', host: 'queue.fal.run' });
|
|
214
|
+
return r.status === 404 && JSON.stringify(r.body) === JSON.stringify({ status: 'NOT_FOUND' });
|
|
215
|
+
})),
|
|
216
|
+
done('fal.queue.urls_self_consistent', 'queue', 'response_url/status_url/cancel_url are IDENTICAL across the submit and status responses, and embed the same model_id/request_id', 'api', 'common', () => withRoot(async (h) => {
|
|
217
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: {}, host: 'queue.fal.run' });
|
|
218
|
+
const cb = c.body;
|
|
219
|
+
const id = cb.request_id;
|
|
220
|
+
const s = await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
221
|
+
const sb = s.body;
|
|
222
|
+
const urlsMatch = cb.response_url === sb.response_url && cb.status_url === sb.status_url && cb.cancel_url === sb.cancel_url;
|
|
223
|
+
return urlsMatch && cb.status_url.includes('acme/widget') && cb.status_url.includes(id);
|
|
224
|
+
})),
|
|
225
|
+
// ── SYNC (fal.run direct-output variant) + HOST-SPLIT ROUTING ────────────────────────────
|
|
226
|
+
done('fal.sync.run', 'sync', 'POST fal.run/{model_id} (sync surface) settles in one call; output equals the queue-completed stub for the same input', 'api', 'core', () => withRoot(async (h) => {
|
|
227
|
+
const input = { text: 'sync-test' };
|
|
228
|
+
const sync = await h({ m: 'POST', p: '/acme/widget', b: input, host: 'fal.run' });
|
|
229
|
+
const queueC = await h({ m: 'POST', p: '/acme/widget', b: input, host: 'queue.fal.run' });
|
|
230
|
+
const qid = queueC.body.request_id;
|
|
231
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${qid}/status`, host: 'queue.fal.run' }); // poll1 IN_QUEUE
|
|
232
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${qid}/status`, host: 'queue.fal.run' }); // poll2 IN_PROGRESS
|
|
233
|
+
await h({ m: 'GET', p: `/acme/widget/requests/${qid}/status`, host: 'queue.fal.run' }); // poll3 COMPLETED
|
|
234
|
+
const qOut = await h({ m: 'GET', p: `/acme/widget/requests/${qid}`, host: 'queue.fal.run' });
|
|
235
|
+
const syncOut = sync.body.output;
|
|
236
|
+
return sync.status === 200 && typeof syncOut === 'string' && syncOut === qOut.body.output;
|
|
237
|
+
})),
|
|
238
|
+
done('fal.sync.host_split', 'sync', 'identical POST method+path routed by HOST alone: queue.fal.run -> queue envelope (request_id/urls), fal.run -> direct output (no envelope)', 'api', 'common', () => withRoot(async (h) => {
|
|
239
|
+
const queueR = await h({ m: 'POST', p: '/acme/widget', b: { x: 1 }, host: 'queue.fal.run' });
|
|
240
|
+
const syncR = await h({ m: 'POST', p: '/acme/widget', b: { x: 1 }, host: 'fal.run' });
|
|
241
|
+
const qb = queueR.body;
|
|
242
|
+
const sb = syncR.body;
|
|
243
|
+
return queueR.status === 200 && syncR.status === 200 && typeof qb.request_id === 'string' && qb.output === undefined
|
|
244
|
+
&& sb.request_id === undefined && typeof sb.output === 'string';
|
|
245
|
+
})),
|
|
246
|
+
done('fal.proxy.target_url_header', 'proxy', 'x-fal-target-url header (the REAL fal-js proxy protocol) routes to the queue surface with no explicit host', 'api', 'common', () => withRoot(async (h) => {
|
|
247
|
+
const r = await h({ m: 'POST', p: '/acme/widget', b: {}, h: { 'x-fal-target-url': 'https://queue.fal.run/acme/widget' } });
|
|
248
|
+
return r.status === 200 && typeof r.body.request_id === 'string';
|
|
249
|
+
})),
|
|
250
|
+
// ── WEBHOOKS (ed25519 sign/verify + JWKS) ─────────────────────────────────────────────────
|
|
251
|
+
done('fal.webhooks.request_signing', 'webhooks', 'ed25519 sign/verify round-trip over request_id\\nuser_id\\ntimestamp\\nsha256hex(body); a tampered signature is rejected', 'api', 'common', () => withTempRoot(async (root) => {
|
|
252
|
+
const built = await buildSignedFalWebhook({ requestId: 'req_abc', userId: 'user_1', status: 'OK', payload: { ok: true }, occurredAt: OCCURRED_AT, root });
|
|
253
|
+
const verified = await verifyFalWebhook(built.body, built.headers, root, { now: OCCURRED_AT_EPOCH_SEC });
|
|
254
|
+
if (verified.request_id !== 'req_abc' || verified.status !== 'OK')
|
|
255
|
+
return false;
|
|
256
|
+
const lastChar = built.headers['x-fal-webhook-signature'].slice(-1);
|
|
257
|
+
const flipped = lastChar === 'a' ? 'b' : 'a';
|
|
258
|
+
const tampered = { ...built.headers, 'x-fal-webhook-signature': built.headers['x-fal-webhook-signature'].slice(0, -1) + flipped };
|
|
259
|
+
try {
|
|
260
|
+
await verifyFalWebhook(built.body, tampered, root, { now: OCCURRED_AT_EPOCH_SEC });
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
return err instanceof FalWebhookVerificationError;
|
|
265
|
+
}
|
|
266
|
+
})),
|
|
267
|
+
done('fal.webhooks.jwks', 'webhooks', "GET /.well-known/jwks.json -> {keys:[{kty:'OKP',crv:'Ed25519',x}]}, stable per root; a locally-built signed webhook verifies against the key the ROUTE served (through the handler -> real teeth)", 'api', 'common', () => withRoot(async (h, root) => {
|
|
268
|
+
const a = await h({ m: 'GET', p: '/.well-known/jwks.json', host: 'rest.fal.ai' });
|
|
269
|
+
const ab = a.body;
|
|
270
|
+
if (a.status !== 200 || ab.keys?.[0]?.kty !== 'OKP' || ab.keys[0]?.crv !== 'Ed25519' || !ab.keys[0]?.x)
|
|
271
|
+
return false;
|
|
272
|
+
const built = await buildSignedFalWebhook({ requestId: 'req_jwks', status: 'OK', payload: { a: 1 }, occurredAt: OCCURRED_AT, root });
|
|
273
|
+
const pubKey = falWebhookPublicKeyFromJwk(ab.keys[0]);
|
|
274
|
+
const verified = verifyFalWebhookSignatureWithKey(built.body, built.headers, pubKey, { now: OCCURRED_AT_EPOCH_SEC });
|
|
275
|
+
const b = await h({ m: 'GET', p: '/.well-known/jwks.json', host: 'rest.fal.ai' });
|
|
276
|
+
const stable = b.body.keys?.[0]?.x === ab.keys[0].x;
|
|
277
|
+
return verified.request_id === 'req_jwks' && stable;
|
|
278
|
+
})),
|
|
279
|
+
done('fal.webhooks.timestamp_tolerance', 'webhooks', 'reject a webhook whose |now - timestamp| exceeds the 300s tolerance; accept one within it', 'api', 'common', () => withTempRoot(async (root) => {
|
|
280
|
+
const built = await buildSignedFalWebhook({ requestId: 'req_ts', status: 'OK', payload: {}, occurredAt: OCCURRED_AT, root });
|
|
281
|
+
const stale = Math.floor(Date.parse(OCCURRED_AT) / 1000) + 301;
|
|
282
|
+
try {
|
|
283
|
+
await verifyFalWebhook(built.body, built.headers, root, { now: stale });
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
if (!(err instanceof FalWebhookVerificationError))
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const fresh = Math.floor(Date.parse(OCCURRED_AT) / 1000) + 100;
|
|
291
|
+
const verified = await verifyFalWebhook(built.body, built.headers, root, { now: fresh });
|
|
292
|
+
return verified.request_id === 'req_ts';
|
|
293
|
+
})),
|
|
294
|
+
done('fal.webhooks.payload_shape', 'webhooks', 'OK and ERROR webhook payload variants match the exact documented shape ({request_id,gateway_request_id,status,payload} / {...,error})', 'api', 'common', () => withTempRoot(async (root) => {
|
|
295
|
+
const ok = await buildSignedFalWebhook({ requestId: 'req_ok', gatewayRequestId: 'gw_1', status: 'OK', payload: { output: 'x' }, occurredAt: OCCURRED_AT, root });
|
|
296
|
+
const okEvent = JSON.parse(ok.body);
|
|
297
|
+
const okShape = okEvent.request_id === 'req_ok' && okEvent.gateway_request_id === 'gw_1' && okEvent.status === 'OK'
|
|
298
|
+
&& JSON.stringify(okEvent.payload) === JSON.stringify({ output: 'x' }) && okEvent.error === undefined;
|
|
299
|
+
const err = await buildSignedFalWebhook({ requestId: 'req_err', status: 'ERROR', error: 'boom', occurredAt: OCCURRED_AT, root });
|
|
300
|
+
const errEvent = JSON.parse(err.body);
|
|
301
|
+
const errShape = errEvent.request_id === 'req_err' && errEvent.status === 'ERROR' && errEvent.payload === null && errEvent.error === 'boom';
|
|
302
|
+
return okShape && errShape;
|
|
303
|
+
})),
|
|
304
|
+
// ── ERRORS ────────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
done('fal.errors.validation_422', 'errors', 'submit with a non-object JSON body -> 422 {detail:[{loc,msg,type}]} (FastAPI/Pydantic shape)', 'api', 'core', () => withRoot(async (h) => {
|
|
306
|
+
const r = await h({ m: 'POST', p: '/acme/widget', b: [1, 2, 3], host: 'queue.fal.run' });
|
|
307
|
+
if (r.status !== 422)
|
|
308
|
+
return false;
|
|
309
|
+
const detail = r.body.detail;
|
|
310
|
+
return Array.isArray(detail) && detail.length > 0 && Array.isArray(detail[0].loc) && typeof detail[0].msg === 'string' && typeof detail[0].type === 'string';
|
|
311
|
+
})),
|
|
312
|
+
// ── SAFETY / HONESTY ─────────────────────────────────────────────────────────────────────
|
|
313
|
+
done('fal.read_only.rejects_writes', 'safety', 'readOnly mode rejects writes with 405 but allows GET (incl. the JWKS route)', 'api', 'common', async () => {
|
|
314
|
+
const root = mkdtempSync(join(tmpdir(), 'fal-ro-'));
|
|
315
|
+
try {
|
|
316
|
+
const w = await handleFalTwinRequest({ method: 'POST', path: '/acme/widget', body: JSON.stringify({}), root, readOnly: true, host: 'queue.fal.run' });
|
|
317
|
+
const g = await handleFalTwinRequest({ method: 'GET', path: '/.well-known/jwks.json', root, readOnly: true, host: 'rest.fal.ai' });
|
|
318
|
+
return w.status === 405 && g.status === 200;
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
rmSync(root, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
}),
|
|
324
|
+
done('fal.unmodeled_route.404', 'safety', 'an unmodeled REST route returns a FastAPI-style 404 {detail} envelope (never a fabricated success)', 'api', 'common', () => withRoot(async (h) => {
|
|
325
|
+
const r = await h({ m: 'GET', p: '/some/totally/unknown/route', host: 'rest.fal.ai' });
|
|
326
|
+
return r.status === 404 && typeof r.body.detail === 'string';
|
|
327
|
+
})),
|
|
328
|
+
done('fal.state.kernel_persisted', 'state', 'state is kernel-folded — a request created in one call is visible (by id) in the next', 'api', 'core', () => withRoot(async (h) => {
|
|
329
|
+
const c = await h({ m: 'POST', p: '/acme/widget', b: { a: 1 }, host: 'queue.fal.run' });
|
|
330
|
+
const id = c.body.request_id;
|
|
331
|
+
// a dead twin returns {} — the id must be a REAL non-empty string first, or the final
|
|
332
|
+
// comparison is vacuous (undefined === undefined).
|
|
333
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
334
|
+
return false;
|
|
335
|
+
const s = await h({ m: 'GET', p: `/acme/widget/requests/${id}/status`, host: 'queue.fal.run' });
|
|
336
|
+
return s.status === 200 && s.body.request_id === id;
|
|
337
|
+
})),
|
|
338
|
+
// ── CONFORMANCE / CONNECTOR ──────────────────────────────────────────────────────────────
|
|
339
|
+
done('fal.conformance.snapshot', 'conformance', 'conformance snapshot covers both resource types + the core queue-lifecycle contracts', 'api', 'common', () => {
|
|
340
|
+
const report = checkFalConformance();
|
|
341
|
+
return report.ok && report.endpointsChecked >= 10 && report.resourceTypesChecked === 2;
|
|
342
|
+
}),
|
|
343
|
+
done('fal.connector.sync.entrypoint', 'connector', 'syncFalFromReal pulls the CURRENT real state of explicitly-named {modelId,requestId} handles (fal has no list-requests API) into the twin; idempotent re-pull', 'connector', 'core', async () => {
|
|
344
|
+
const root = mkdtempSync(join(tmpdir(), 'fal-conn-'));
|
|
345
|
+
try {
|
|
346
|
+
const client = {
|
|
347
|
+
queue: {
|
|
348
|
+
status: async () => ({ status: 'COMPLETED', queue_position: 0, logs: [{ message: 'done', level: 'INFO', timestamp: OCCURRED_AT }], metrics: { inference_time: 1.5 } }),
|
|
349
|
+
result: async () => ({ output: 'real pulled output' }),
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
const handles = [{ modelId: 'acme/real-model', requestId: 'real-req-1' }];
|
|
353
|
+
const first = await syncFalFromReal(client, { root, requests: handles, occurredAt: OCCURRED_AT, budgetOptions: { root } });
|
|
354
|
+
if (first.observed !== 1 || first.deltasAppended < 1)
|
|
355
|
+
return false;
|
|
356
|
+
// a re-pull of identical state is a no-op (shadow-diff dedup) -> 0 deltas appended
|
|
357
|
+
const second = await syncFalFromReal(client, { root, requests: handles, occurredAt: OCCURRED_AT, budgetOptions: { root } });
|
|
358
|
+
if (second.deltasAppended !== 0)
|
|
359
|
+
return false;
|
|
360
|
+
// the pulled request is now visible via the twin handler (real fold, not a count)
|
|
361
|
+
const r = await handleFalTwinRequest({ method: 'GET', path: '/acme/real-model/requests/real-req-1/status', root, host: 'queue.fal.run' });
|
|
362
|
+
return r.status === 200 && r.body.status === 'COMPLETED';
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
rmSync(root, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
}),
|
|
368
|
+
done('fal.connector.empty_client', 'connector', 'syncFalFromReal with no client.queue / no handles observes nothing', 'connector', 'common', async () => {
|
|
369
|
+
// isolated temp root — never the process default root (a verify must not touch shared state).
|
|
370
|
+
const root = mkdtempSync(join(tmpdir(), 'fal-conn-empty-'));
|
|
371
|
+
try {
|
|
372
|
+
const result = await syncFalFromReal({}, { root, budgetOptions: { root } });
|
|
373
|
+
return result.observed === 0 && result.deltasAppended === 0;
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
rmSync(root, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
}),
|
|
379
|
+
// ── TODO: the rest of the fal.ai v1 surface (honest denominator) ────────────────────────
|
|
380
|
+
todo('fal.streaming.status_stream_sse', 'streaming', 'GET .../requests/{id}/status/stream — SSE token stream of status updates', 'api', 'common'),
|
|
381
|
+
todo('fal.streaming.model_stream', 'streaming', 'fal.stream() — per-model streaming inference (distinct from the queue status SSE)', 'api', 'niche'),
|
|
382
|
+
todo('fal.realtime.websocket', 'realtime', 'fal realtime WebSocket API (low-latency bidirectional model sessions)', 'api', 'niche'),
|
|
383
|
+
todo('fal.webhooks.delivery', 'webhooks', 'an injected offline deliverer actually POSTs the signed webhook to the registered URL on completion', 'api', 'common'),
|
|
384
|
+
todo('fal.webhooks.retry_policy', 'webhooks', 'delivery retry policy (15s timeout, up to 10 retries over 2h)', 'api', 'niche'),
|
|
385
|
+
todo('fal.webhooks.error_payload_delivery', 'webhooks', 'a FAILED request delivers the ERROR-status webhook payload variant end-to-end', 'api', 'common'),
|
|
386
|
+
todo('fal.webhooks.submit_param_validation', 'webhooks', '?fal_webhook= / webhook_url submit-time param validation (malformed URL -> 422)', 'api', 'niche'),
|
|
387
|
+
todo('fal.queue.cancelled_terminal_state', 'queue', "the real terminal state a successful cancellation settles into (fal's documented status enum has no CANCELLED member)", 'api', 'common'),
|
|
388
|
+
todo('fal.queue.response_long_poll', 'queue', 'pre-completion long-poll behavior of the result endpoint (this twin settles instantly on status polls instead)', 'api', 'niche'),
|
|
389
|
+
todo('fal.queue.response_alias_route', 'queue', 'the /response alias route as its OWN claimed capability (today it is served but only exercised via fal.queue.response)', 'api', 'niche'),
|
|
390
|
+
todo('fal.queue.failed_state', 'queue', 'a deterministic, input-triggered FAILED status + error/error_type fields', 'api', 'common'),
|
|
391
|
+
todo('fal.queue.submit_status_code_parity', 'queue', 'confirm the real submit response status code against a live account (modeled as 200; doc-unverified)', 'api', 'niche'),
|
|
392
|
+
todo('fal.queue.priority_hint', 'queue', 'a priority/queue-jump hint on submit', 'api', 'niche'),
|
|
393
|
+
todo('fal.sync.timeout_504', 'sync', "the fal.run sync surface's documented 504 user-timeout behavior + headers", 'api', 'niche'),
|
|
394
|
+
todo('fal.sync.request_id_header', 'sync', "a request-id response header on the sync surface (parity with the queue envelope's request_id field)", 'api', 'niche'),
|
|
395
|
+
todo('fal.storage.initiate_upload', 'storage', 'fal storage: initiate-upload handshake for large inputs', 'api', 'common'),
|
|
396
|
+
todo('fal.storage.file_url_shape', 'storage', 'the fal.media CDN file-url shape returned by storage uploads', 'api', 'niche'),
|
|
397
|
+
todo('fal.storage.client_auto_upload', 'storage', "the fal-js client's automatic large-input-to-storage-URL substitution", 'api', 'niche'),
|
|
398
|
+
todo('fal.apps.unknown_model_404', 'apps', 'submitting to a genuinely unknown/unregistered model_id — a distinct 404 shape from an unknown request_id', 'api', 'common'),
|
|
399
|
+
todo('fal.apps.per_model_openapi', 'apps', 'GET /{model_id}/openapi.json — the per-model input/output schema document', 'api', 'niche'),
|
|
400
|
+
todo('fal.apps.registry_fixture', 'apps', 'a small fixture registry of real, named fal model ids (for realistic model_id enumeration in tests)', 'api', 'niche'),
|
|
401
|
+
todo('fal.auth.key_401_parity', 'auth', 'missing/invalid `Authorization: Key $FAL_KEY` -> 401 parity', 'api', 'common'),
|
|
402
|
+
todo('fal.auth.key_format', 'auth', 'FAL_KEY format validation (the documented `id:secret` shape)', 'api', 'niche'),
|
|
403
|
+
todo('fal.errors.detail_string_variant', 'errors', 'the plain-string `detail` error variant (vs. the structured array form) on non-validation errors', 'api', 'niche'),
|
|
404
|
+
todo('fal.errors.429_rate_limit', 'errors', 'rate limit (429) deterministic opt-in trigger + Retry-After envelope', 'api', 'niche'),
|
|
405
|
+
todo('fal.errors.403_budget', 'errors', 'account-budget-exceeded 403 envelope', 'api', 'niche'),
|
|
406
|
+
todo('fal.connector.push', 'connector', 'push a locally-created request against a real fal account (fal has no such write API for pre-existing requests today — see README ## Coverage)', 'connector', 'common'),
|
|
407
|
+
todo('fal.connector.pull_storage', 'connector', 'connector: pull uploaded storage assets from the real account', 'connector', 'common'),
|
|
408
|
+
todo('fal.fixtures.seed', 'connector', 'seed a small library of realistic model ids + inputs from fixtures', 'connector', 'niche'),
|
|
409
|
+
];
|
|
410
|
+
export const FAL_AREAS = [
|
|
411
|
+
'queue', 'sync', 'streaming', 'realtime', 'webhooks', 'storage', 'apps', 'proxy', 'auth',
|
|
412
|
+
'errors', 'connector', 'conformance', 'safety', 'state',
|
|
413
|
+
];
|
|
414
|
+
// NOTE: deliberately NO 'pagination' area — fal has no list endpoints in its real surface (no
|
|
415
|
+
// GET /requests, no GET /models — every route is either handle-scoped or a bare model_id path),
|
|
416
|
+
// so there is nothing to paginate. Recorded here rather than silently omitted.
|
|
417
|
+
export async function falCapabilities() {
|
|
418
|
+
return checkCapabilities('fal', FAL_CAPABILITIES);
|
|
419
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// fal conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
|
|
2
|
+
// Checks the twin's endpoint/resource inventory against itself — SELF-REFERENTIAL, the same
|
|
3
|
+
// pattern replicate/elevenlabs/polar use (docs/contributing/conformance.md's "2 spec" discussion): it does not
|
|
4
|
+
// diff against a captured vendor document, it asserts the pack's own declared snapshot is
|
|
5
|
+
// internally consistent (every resource type has a route, the core lifecycle routes exist).
|
|
6
|
+
import { falTwinSnapshot, FAL_RESOURCE_TYPES } from "./fal-twin.js";
|
|
7
|
+
export function checkFalConformance() {
|
|
8
|
+
const snapshot = falTwinSnapshot();
|
|
9
|
+
const violations = [];
|
|
10
|
+
// Every declared resource type must have at least one implemented endpoint touching it.
|
|
11
|
+
// `signing_key` has no REST path of its own — it's served (lazily materialized) at the JWKS
|
|
12
|
+
// endpoint, so it maps to the 'jwks' stem instead of its own name.
|
|
13
|
+
const stemFor = {
|
|
14
|
+
request: '/requests',
|
|
15
|
+
signing_key: 'jwks',
|
|
16
|
+
};
|
|
17
|
+
for (const type of FAL_RESOURCE_TYPES) {
|
|
18
|
+
const stem = stemFor[type];
|
|
19
|
+
if (!stem || !snapshot.implementedEndpoints.some((e) => e.includes(stem))) {
|
|
20
|
+
violations.push(`resource type '${type}' has no implemented endpoint`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// The core queue-lifecycle endpoints must be present in the protocol surface.
|
|
24
|
+
if (!snapshot.implementedEndpoints.some((e) => e.startsWith('POST queue.fal.run')))
|
|
25
|
+
violations.push('missing queue submit endpoint');
|
|
26
|
+
if (!snapshot.implementedEndpoints.some((e) => e.startsWith('POST fal.run')))
|
|
27
|
+
violations.push('missing sync submit endpoint');
|
|
28
|
+
if (!snapshot.implementedEndpoints.some((e) => e.includes('/cancel')))
|
|
29
|
+
violations.push('missing cancel endpoint');
|
|
30
|
+
if (!snapshot.implementedEndpoints.some((e) => e.includes('jwks')))
|
|
31
|
+
violations.push('missing JWKS endpoint');
|
|
32
|
+
return {
|
|
33
|
+
ok: violations.length === 0,
|
|
34
|
+
endpointsChecked: snapshot.implementedEndpoints.length,
|
|
35
|
+
resourceTypesChecked: snapshot.resourceTypes.length,
|
|
36
|
+
violations,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ObservedResource } from '@volter/world-core';
|
|
2
|
+
import { type FalBudgetedOptions } from './fal-budget.js';
|
|
3
|
+
export type { FalBudgetedOptions };
|
|
4
|
+
export type FalRequestHandle = {
|
|
5
|
+
modelId: string;
|
|
6
|
+
requestId: string;
|
|
7
|
+
};
|
|
8
|
+
export type FalRealQueueLog = {
|
|
9
|
+
message: string;
|
|
10
|
+
level?: string;
|
|
11
|
+
timestamp?: string;
|
|
12
|
+
};
|
|
13
|
+
export type FalRealQueueStatus = {
|
|
14
|
+
status: string;
|
|
15
|
+
queue_position?: number | null;
|
|
16
|
+
logs?: FalRealQueueLog[];
|
|
17
|
+
metrics?: {
|
|
18
|
+
inference_time?: number | null;
|
|
19
|
+
};
|
|
20
|
+
error?: string | null;
|
|
21
|
+
error_type?: string | null;
|
|
22
|
+
};
|
|
23
|
+
export interface FalLikeClient {
|
|
24
|
+
queue?: {
|
|
25
|
+
status: (opts: {
|
|
26
|
+
endpointId: string;
|
|
27
|
+
requestId: string;
|
|
28
|
+
logs?: boolean;
|
|
29
|
+
}) => Promise<FalRealQueueStatus>;
|
|
30
|
+
result: (opts: {
|
|
31
|
+
endpointId: string;
|
|
32
|
+
requestId: string;
|
|
33
|
+
}) => Promise<unknown>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Pure mapper (real fal queue-status/result → ObservedResource) — never touches a client, so the
|
|
37
|
+
* mutation-test connector-seam sweep (which sabotages every export matching the
|
|
38
|
+
* sync-or-push-or-pull-or-fullSync naming convention) leaves this real, per the pack convention
|
|
39
|
+
* (`replicate-connector.ts`'s `mapModel`). */
|
|
40
|
+
export declare function mapQueueRequest(handle: FalRequestHandle, status: FalRealQueueStatus, result: unknown): ObservedResource;
|
|
41
|
+
/** Pull the CURRENT real state of every named handle. A handle with no `client.queue` (or an
|
|
42
|
+
* empty handle list) observes nothing — there is nothing to enumerate without a handle. */
|
|
43
|
+
export declare function pullFalRequests(rawClient: FalLikeClient, handles: FalRequestHandle[], opts?: FalBudgetedOptions): Promise<ObservedResource[]>;
|
|
44
|
+
/**
|
|
45
|
+
* D7 entry point: pull the current real state of every explicitly-named `{modelId, requestId}`
|
|
46
|
+
* handle and OBSERVE it as ONE batch (the kernel diffs against the root's tree). No type is named
|
|
47
|
+
* `complete`: the pull reads only the handles it was given, never an enumeration, so a request it
|
|
48
|
+
* did not name says nothing about whether it still exists. Returns `{observed, deltasAppended}` —
|
|
49
|
+
* a re-pull of identical state appends ZERO deltas.
|
|
50
|
+
*/
|
|
51
|
+
export declare function syncFalFromReal(rawClient: FalLikeClient, opts?: {
|
|
52
|
+
root?: string;
|
|
53
|
+
occurredAt?: string;
|
|
54
|
+
requests?: FalRequestHandle[];
|
|
55
|
+
} & FalBudgetedOptions): Promise<{
|
|
56
|
+
observed: number;
|
|
57
|
+
deltasAppended: number;
|
|
58
|
+
}>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// fal CONNECTOR — the live-vendor pull path that gives the fal twin the "git for SaaS"
|
|
2
|
+
// lifecycle over an INJECTED client (the auth boundary).
|
|
3
|
+
//
|
|
4
|
+
// PULL (real → twin) is deliberately HANDLE-DRIVEN, not list-driven: fal has NO API to list
|
|
5
|
+
// outstanding/past queue requests (grounded — fal.ai/docs/model-endpoints/queue documents only
|
|
6
|
+
// per-request status/result/cancel by an already-known `{model_id, request_id}` handle; there is
|
|
7
|
+
// no `GET /requests` enumeration endpoint anywhere in the surface). The real fal-js client's own
|
|
8
|
+
// `fal.queue.status({...})` / `fal.queue.result({...})` methods are themselves handle-scoped for
|
|
9
|
+
// the same reason. So a caller of this connector must already know which requests it cares about
|
|
10
|
+
// (e.g. from its own application database of request ids it previously submitted) — the connector
|
|
11
|
+
// pulls the CURRENT real state of each named handle and OBSERVES it (the kernel diffs each
|
|
12
|
+
// resource against the root's tree and folds only what changed, so a re-pull of identical state
|
|
13
|
+
// appends nothing).
|
|
14
|
+
//
|
|
15
|
+
// The vendor I/O is an INJECTED client interface (`FalLikeClient`): a fake in tests, the real
|
|
16
|
+
// `@fal-ai/client` singleton in prod. The pack imports NO SDK and holds NO key.
|
|
17
|
+
import { observeResources } from '@volter/world-core';
|
|
18
|
+
//
|
|
19
|
+
// ── The client-side RATE BUDGET is not optional here ────────────────────────────────────────
|
|
20
|
+
// fal publishes CONCURRENCY limits (2 concurrent requests on a new account) and documents that
|
|
21
|
+
// IN_QUEUE requests do not count toward them — so queue.status / queue.result polling, which is all
|
|
22
|
+
// this connector does, has no published ceiling at all.
|
|
23
|
+
// Every entrypoint below GUARDS the injected client before touching it (`guardFalClient`, which is
|
|
24
|
+
// idempotent — a caller who already wrapped is not double-charged, a caller who forgot is protected
|
|
25
|
+
// anyway); there is deliberately no option that turns the budget off. See fal-budget.ts.
|
|
26
|
+
import { falBudgetOf, guardFalClient } from "./fal-budget.js";
|
|
27
|
+
const SERVICE = 'fal';
|
|
28
|
+
function kid(type, id) {
|
|
29
|
+
return `${type}:${id}`;
|
|
30
|
+
}
|
|
31
|
+
/** Pure mapper (real fal queue-status/result → ObservedResource) — never touches a client, so the
|
|
32
|
+
* mutation-test connector-seam sweep (which sabotages every export matching the
|
|
33
|
+
* sync-or-push-or-pull-or-fullSync naming convention) leaves this real, per the pack convention
|
|
34
|
+
* (`replicate-connector.ts`'s `mapModel`). */
|
|
35
|
+
export function mapQueueRequest(handle, status, result) {
|
|
36
|
+
return {
|
|
37
|
+
type: 'request',
|
|
38
|
+
id: kid('request', handle.requestId),
|
|
39
|
+
fields: {
|
|
40
|
+
model_id: handle.modelId,
|
|
41
|
+
status: status.status,
|
|
42
|
+
queue_position: status.queue_position ?? null,
|
|
43
|
+
logs: status.logs ?? [],
|
|
44
|
+
output: status.status === 'COMPLETED' ? (result ?? null) : null,
|
|
45
|
+
error: status.error ?? null,
|
|
46
|
+
error_type: status.error_type ?? null,
|
|
47
|
+
inference_time: status.metrics?.inference_time ?? null,
|
|
48
|
+
created_at: null,
|
|
49
|
+
completed_at: null,
|
|
50
|
+
webhook_url: null,
|
|
51
|
+
poll_count: 0,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Pull the CURRENT real state of every named handle. A handle with no `client.queue` (or an
|
|
56
|
+
* empty handle list) observes nothing — there is nothing to enumerate without a handle. */
|
|
57
|
+
export async function pullFalRequests(rawClient, handles, opts = {}) {
|
|
58
|
+
const client = guardFalClient(rawClient, opts);
|
|
59
|
+
if (!client.queue || handles.length === 0)
|
|
60
|
+
return [];
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const handle of handles) {
|
|
63
|
+
const status = await client.queue.status({ endpointId: handle.modelId, requestId: handle.requestId, logs: true });
|
|
64
|
+
let result = null;
|
|
65
|
+
if (status.status === 'COMPLETED') {
|
|
66
|
+
result = await client.queue.result({ endpointId: handle.modelId, requestId: handle.requestId });
|
|
67
|
+
}
|
|
68
|
+
out.push(mapQueueRequest(handle, status, result));
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* D7 entry point: pull the current real state of every explicitly-named `{modelId, requestId}`
|
|
74
|
+
* handle and OBSERVE it as ONE batch (the kernel diffs against the root's tree). No type is named
|
|
75
|
+
* `complete`: the pull reads only the handles it was given, never an enumeration, so a request it
|
|
76
|
+
* did not name says nothing about whether it still exists. Returns `{observed, deltasAppended}` —
|
|
77
|
+
* a re-pull of identical state appends ZERO deltas.
|
|
78
|
+
*/
|
|
79
|
+
export async function syncFalFromReal(rawClient, opts = {}) {
|
|
80
|
+
// Guard ONCE here and hand the guarded client down: the per-handle loop below is unbounded in
|
|
81
|
+
// handle count, so this is the entrypoint that must be unable to run unbudgeted.
|
|
82
|
+
const client = guardFalClient(rawClient, falBudgetOf(opts));
|
|
83
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
84
|
+
const resources = await pullFalRequests(client, opts.requests ?? []);
|
|
85
|
+
const report = observeResources(SERVICE, resources, {
|
|
86
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
87
|
+
at: occurredAt,
|
|
88
|
+
batch: `obs:${SERVICE}:${occurredAt}`,
|
|
89
|
+
});
|
|
90
|
+
return { observed: report.observed, deltasAppended: report.appended + report.removed };
|
|
91
|
+
}
|