@chai-ui/core 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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/dist/engine.d.ts +84 -0
  4. package/dist/engine.d.ts.map +1 -0
  5. package/dist/engine.js +173 -0
  6. package/dist/engine.js.map +1 -0
  7. package/dist/engines/fal.d.ts +95 -0
  8. package/dist/engines/fal.d.ts.map +1 -0
  9. package/dist/engines/fal.js +209 -0
  10. package/dist/engines/fal.js.map +1 -0
  11. package/dist/engines/openrouter.d.ts +141 -0
  12. package/dist/engines/openrouter.d.ts.map +1 -0
  13. package/dist/engines/openrouter.js +220 -0
  14. package/dist/engines/openrouter.js.map +1 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/media-analysis-prompts.d.ts +18 -0
  20. package/dist/media-analysis-prompts.d.ts.map +1 -0
  21. package/dist/media-analysis-prompts.js +35 -0
  22. package/dist/media-analysis-prompts.js.map +1 -0
  23. package/dist/model-routing.d.ts +49 -0
  24. package/dist/model-routing.d.ts.map +1 -0
  25. package/dist/model-routing.js +72 -0
  26. package/dist/model-routing.js.map +1 -0
  27. package/dist/run.d.ts +75 -0
  28. package/dist/run.d.ts.map +1 -0
  29. package/dist/run.js +90 -0
  30. package/dist/run.js.map +1 -0
  31. package/dist/server/chai-handler.d.ts +43 -0
  32. package/dist/server/chai-handler.d.ts.map +1 -0
  33. package/dist/server/chai-handler.js +106 -0
  34. package/dist/server/chai-handler.js.map +1 -0
  35. package/dist/server/index.d.ts +3 -0
  36. package/dist/server/index.d.ts.map +1 -0
  37. package/dist/server/index.js +4 -0
  38. package/dist/server/index.js.map +1 -0
  39. package/dist/server/node.d.ts +24 -0
  40. package/dist/server/node.d.ts.map +1 -0
  41. package/dist/server/node.js +60 -0
  42. package/dist/server/node.js.map +1 -0
  43. package/dist/types.d.ts +85 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +5 -0
  46. package/dist/types.js.map +1 -0
  47. package/package.json +63 -0
  48. package/src/engine.ts +237 -0
  49. package/src/engines/fal.ts +271 -0
  50. package/src/engines/openrouter.ts +301 -0
  51. package/src/index.ts +7 -0
  52. package/src/media-analysis-prompts.ts +45 -0
  53. package/src/model-routing.ts +104 -0
  54. package/src/run.ts +145 -0
  55. package/src/server/chai-handler.ts +146 -0
  56. package/src/server/index.ts +3 -0
  57. package/src/server/node.ts +75 -0
  58. package/src/types.ts +96 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * CHAI UI — shared data types for the component library.
3
+ */
4
+ /** The kinds of media a component can take in or produce. */
5
+ export type MediaKind = "image" | "video" | "audio" | "text";
6
+ /**
7
+ * A piece of media a person attached (or an engine received) — just enough
8
+ * to know its kind and where it lives.
9
+ */
10
+ export interface DroppedMedia {
11
+ src: string;
12
+ kind: MediaKind;
13
+ /** Original file name, for the upload-summary UI. Absent for non-file sources. */
14
+ name?: string;
15
+ /** Original file size in bytes, for the upload-summary UI. */
16
+ size?: number;
17
+ }
18
+ /**
19
+ * Token/cost accounting for one model call, when the engine reports one.
20
+ * `costUsd` is deliberately separate from the token counts: an engine may
21
+ * know real token counts but have no verified per-model pricing to convert
22
+ * them with (see `ModelOption.pricePerMillionTokens`) — degrade to showing
23
+ * tokens without a cost rather than guessing a price.
24
+ */
25
+ export interface Usage {
26
+ promptTokens: number;
27
+ completionTokens: number;
28
+ totalTokens: number;
29
+ costUsd?: number;
30
+ }
31
+ /**
32
+ * The verdict of one quality check the builder's own backend ran on a
33
+ * generated output (e.g. brand safety, prompt adherence), appended to the
34
+ * model call's response. CHAI never runs or scores a check; it only
35
+ * renders the verdict: `passed` true is green, false is red.
36
+ */
37
+ export interface Evaluation {
38
+ id: string;
39
+ label: string;
40
+ passed: boolean;
41
+ }
42
+ /**
43
+ * The thin value-in/value-out shape a form control satisfies (e.g.
44
+ * `Toggle`) — deliberately minimal so most existing components already
45
+ * qualify without an adapter.
46
+ */
47
+ export interface PrimitiveProps<T = unknown> {
48
+ value: T;
49
+ onChange: (next: T) => void;
50
+ disabled?: boolean;
51
+ }
52
+ /** One labeled option, for option-list controls (e.g. Composer's aspect ratios). */
53
+ export interface ParameterOption<T> {
54
+ value: T;
55
+ label: string;
56
+ }
57
+ /** A model a component can offer or route to. */
58
+ export interface ModelOption {
59
+ id: string;
60
+ label: string;
61
+ provider: string;
62
+ /** Rough cost/latency signal, for future cost/latency-aware routing. */
63
+ speed: "fast" | "standard" | "slow";
64
+ /**
65
+ * USD per million tokens, if known — lets real token counts convert into
66
+ * a real cost. Deliberately optional and absent by default: provider
67
+ * pricing changes and isn't tracked anywhere in this repo yet, so a wrong
68
+ * hardcoded number would be worse than no cost at all. Set this per model
69
+ * only from a source you're actively keeping current.
70
+ */
71
+ pricePerMillionTokens?: {
72
+ input: number;
73
+ output: number;
74
+ };
75
+ }
76
+ /**
77
+ * A specific generation use case. These names deliberately match the
78
+ * leaderboard categories at github.com/oolong-tea-2026/arena-ai-leaderboards,
79
+ * so a future model-ranking lookup can join on it directly, plus
80
+ * "text-to-text" for a plain text request (a Composer with no use case
81
+ * picked). That repo has no audio-generation category at all, a real gap
82
+ * for any future audio use case.
83
+ */
84
+ export type GenerationUseCase = "text-to-image" | "image-edit" | "text-to-video" | "image-to-video" | "video-edit" | "vision" | "text-to-text";
85
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,6DAA6D;AAC7D,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC;AAE7D;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,SAAS,CAAC;IAChB,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,MAAM,WAAW,KAAK;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,OAAO;IACzC,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,oFAAoF;AACpF,MAAM,WAAW,eAAe,CAAC,CAAC;IAChC,KAAK,EAAE,CAAC,CAAC;IACT,KAAK,EAAE,MAAM,CAAC;CACf;AAED,iDAAiD;AACjD,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAC;IACpC;;;;;;OAMG;IACH,qBAAqB,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3D;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,iBAAiB,GACzB,eAAe,GACf,YAAY,GACZ,eAAe,GACf,gBAAgB,GAChB,YAAY,GACZ,QAAQ,GACR,cAAc,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * CHAI UI — shared data types for the component library.
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG"}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@chai-ui/core",
3
+ "version": "0.1.0",
4
+ "description": "CHAI UI headless core — the Request/Run/Result data model and pluggable generation engines (Fal.ai, OpenRouter, mock). No framework, no rendering.",
5
+ "license": "MIT",
6
+ "author": "kazarazat",
7
+ "homepage": "https://chai-ui.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kazarazat/chai-ui.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/kazarazat/chai-ui/issues"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "generative-ai",
19
+ "design-system",
20
+ "ai-ui",
21
+ "headless",
22
+ "ai-engine",
23
+ "fal",
24
+ "openrouter"
25
+ ],
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ },
35
+ "./server": {
36
+ "types": "./dist/server/index.d.ts",
37
+ "import": "./dist/server/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "src",
43
+ "!src/**/*.test.*"
44
+ ],
45
+ "devDependencies": {
46
+ "@vitest/coverage-v8": "3.2.7",
47
+ "rimraf": "^6.0.1",
48
+ "typescript": "^5.6.3",
49
+ "vitest": "^3.2.7"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "scripts": {
55
+ "build": "rimraf dist && tsc -p tsconfig.json",
56
+ "dev": "tsc -p tsconfig.json --watch",
57
+ "typecheck": "tsc --noEmit -p tsconfig.json",
58
+ "lint": "echo \"(no lint configured yet)\"",
59
+ "clean": "rimraf dist",
60
+ "test": "vitest run",
61
+ "test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=text-summary"
62
+ }
63
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * A pluggable generation engine — the one interface every model provider
3
+ * (`createFalEngine`, `createOpenRouterEngine`, `mockEngine`, or your own)
4
+ * implements, and the one `useComposer` calls. One call = one model run:
5
+ * a prompt plus any attached media in, one piece of output back.
6
+ *
7
+ * `mockEngine` stands in for a real model API during prototyping: it
8
+ * "generates" a small deterministic placeholder image without any network
9
+ * call or API key. Swap in a real engine without touching any component —
10
+ * that's the whole point of keeping this behind an interface.
11
+ */
12
+
13
+ import type { DroppedMedia, Evaluation, MediaKind, Usage } from "./types.js";
14
+
15
+ export interface GenerationEngine {
16
+ generate(args: {
17
+ /**
18
+ * The model the person picked. An engine uses it when set and falls
19
+ * back to its own configured default when it isn't.
20
+ */
21
+ modelId?: string;
22
+ prompt: string;
23
+ attachments: DroppedMedia[];
24
+ /**
25
+ * Optional streaming hook for text output. An engine that can stream
26
+ * calls this with the full text received so far (not a delta) each time
27
+ * more arrives; one that can't simply never calls it. The returned
28
+ * promise's value is still the authoritative final output.
29
+ */
30
+ onText?: (textSoFar: string) => void;
31
+ /**
32
+ * Aborted when the person stops the request. An engine that can cancel
33
+ * at the provider does so, then rejects with an `AbortError` (see
34
+ * `abortError`). Cancellation is best effort: some providers keep
35
+ * running, and billing, once a request has started.
36
+ */
37
+ signal?: AbortSignal;
38
+ }): Promise<{ src: string; kind: MediaKind; usage?: Usage; evaluations?: Evaluation[] }>;
39
+ }
40
+
41
+ /** The error an engine rejects with when its `signal` was aborted. */
42
+ export function abortError(): Error {
43
+ return new DOMException("The request was cancelled.", "AbortError");
44
+ }
45
+
46
+ /** True for an `AbortError`, the way an engine reports a cancelled call. */
47
+ export function isAbortError(err: unknown): boolean {
48
+ return typeof err === "object" && err !== null && (err as { name?: unknown }).name === "AbortError";
49
+ }
50
+
51
+ /** Resolves after `ms`, or rejects with an `AbortError` as soon as `signal` aborts. */
52
+ export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
53
+ return new Promise((resolve, reject) => {
54
+ if (signal?.aborted) return reject(abortError());
55
+ const timer = setTimeout(() => {
56
+ signal?.removeEventListener("abort", onAbort);
57
+ resolve();
58
+ }, ms);
59
+ const onAbort = () => {
60
+ clearTimeout(timer);
61
+ reject(abortError());
62
+ };
63
+ signal?.addEventListener("abort", onAbort, { once: true });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Thrown by a `GenerationEngine` to fail a call with structured,
69
+ * user-facing content — `reasons` can render as a bullet list, `message`
70
+ * alone as a single sentence. A real engine should unwrap its provider's
71
+ * specific error into this shape rather than let a bare network/HTTP error
72
+ * reach the UI.
73
+ */
74
+ export class GenerationError extends Error {
75
+ reasons?: string[];
76
+ constructor(message: string, reasons?: string[]) {
77
+ super(message);
78
+ this.name = "GenerationError";
79
+ this.reasons = reasons;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Reads the top-level `evaluations` field a builder's backend proxy
85
+ * appended to a provider response, the convention both built-in engines
86
+ * follow. Malformed entries are dropped rather than rendered as verdicts
87
+ * nobody actually returned.
88
+ */
89
+ export function readEvaluations(json: unknown): Evaluation[] | undefined {
90
+ const raw = (json as { evaluations?: unknown } | null)?.evaluations;
91
+ if (!Array.isArray(raw)) return undefined;
92
+ const valid = raw.filter(
93
+ (e): e is Evaluation =>
94
+ !!e && typeof e.id === "string" && typeof e.label === "string" && typeof e.passed === "boolean"
95
+ );
96
+ return valid.length ? valid.map(({ id, label, passed }) => ({ id, label, passed })) : undefined;
97
+ }
98
+
99
+ /** Turns anything an engine threw into the `{ message, reasons }` shape a `Result`'s `error` holds. */
100
+ export function normalizeGenerationError(err: unknown): { message: string; reasons?: string[] } {
101
+ if (err instanceof GenerationError) {
102
+ return { message: err.message, reasons: err.reasons };
103
+ }
104
+ if (err instanceof Error) return { message: err.message };
105
+ return { message: "Something went wrong. Please try again." };
106
+ }
107
+
108
+ function hash(input: string): number {
109
+ let h = 0;
110
+ for (let i = 0; i < input.length; i++) {
111
+ h = (h << 5) - h + input.charCodeAt(i);
112
+ h |= 0;
113
+ }
114
+ return Math.abs(h);
115
+ }
116
+
117
+ function placeholderSvg(seed: string, caption: string): string {
118
+ const h = hash(seed);
119
+ const hue = h % 360;
120
+ const hue2 = (hue + 40 + (h % 60)) % 360;
121
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="360">
122
+ <defs>
123
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
124
+ <stop offset="0%" stop-color="hsl(${hue} 55% 38%)" />
125
+ <stop offset="100%" stop-color="hsl(${hue2} 60% 24%)" />
126
+ </linearGradient>
127
+ </defs>
128
+ <rect width="480" height="360" fill="url(#g)" />
129
+ <text x="24" y="332" font-family="ui-monospace, monospace" font-size="13" fill="rgba(255,255,255,0.85)">${escapeXml(
130
+ caption
131
+ )}</text>
132
+ </svg>`;
133
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
134
+ }
135
+
136
+ function escapeXml(s: string): string {
137
+ return s.replace(
138
+ /[&<>"']/g,
139
+ (c) =>
140
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[
141
+ c
142
+ ] ?? c
143
+ );
144
+ }
145
+
146
+ /**
147
+ * A plausible text reply for a text-output mock (e.g. standing in for a
148
+ * reasoning/vision model) — still not a real model call, just a more
149
+ * honest stand-in than a gradient image would be.
150
+ */
151
+ function mockText(prompt: string, modelId: string): string {
152
+ const stylePhrases = [
153
+ "warm, diffused daylight",
154
+ "a shallow depth of field",
155
+ "muted, desaturated tones",
156
+ "a centered, symmetrical composition",
157
+ "soft shadows and high contrast",
158
+ ];
159
+ const subjectPhrases = [
160
+ "a single object resting on a plain surface",
161
+ "a figure positioned slightly off-center",
162
+ "an outdoor scene with layered depth",
163
+ "a close-up study of texture and material",
164
+ ];
165
+ const style = stylePhrases[hash(modelId + "style") % stylePhrases.length];
166
+ const subject = subjectPhrases[hash(modelId + "subject") % subjectPhrases.length];
167
+ const detail = prompt.includes("detailed")
168
+ ? " Rendered here with extra material and lighting detail, per the requested length."
169
+ : "";
170
+ return `${subject}, shot with ${style}.${detail} (mock — ${modelId})`;
171
+ }
172
+
173
+ /**
174
+ * A plausible, deterministic-per-call token/cost fabrication, so a demo can
175
+ * show usage without a real tokenizer or pricing API. `costUsd` uses an
176
+ * illustrative, not verified, rate — a real engine should only ever report
177
+ * a cost it actually derived from real pricing.
178
+ */
179
+ function mockUsage(seed: string): Usage {
180
+ const promptTokens = 180 + (hash(seed + "prompt") % 420);
181
+ const completionTokens = 40 + (hash(seed + "completion") % 260);
182
+ const illustrativeRatePerMillion = { input: 0.15, output: 0.6 };
183
+ const costUsd =
184
+ (promptTokens * illustrativeRatePerMillion.input +
185
+ completionTokens * illustrativeRatePerMillion.output) /
186
+ 1_000_000;
187
+ return { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens, costUsd };
188
+ }
189
+
190
+ export interface MockEngineOptions {
191
+ /** What the mock "generates". Defaults to "image" (a placeholder gradient); "text" returns a plausible text reply instead. */
192
+ outputKind?: "image" | "text";
193
+ /**
194
+ * Fraction of calls that fail with `GenerationError`, 0-1. Default 0 —
195
+ * pass 1 to force every call to fail (a UI-testing convenience for
196
+ * previewing error states on demand).
197
+ */
198
+ failureRate?: number;
199
+ }
200
+
201
+ let mockCallCount = 0;
202
+
203
+ export function createMockEngine(options: MockEngineOptions = {}): GenerationEngine {
204
+ const outputKind = options.outputKind ?? "image";
205
+ const failureRate = options.failureRate ?? 0;
206
+ return {
207
+ async generate({ modelId = "mock-model", prompt, onText, signal }) {
208
+ mockCallCount += 1;
209
+ const seed = `${modelId}:${prompt}:${mockCallCount}`;
210
+ await abortableSleep(500 + (hash(seed) % 1200), signal);
211
+
212
+ if (failureRate > 0 && (hash(seed) % 100) / 100 < failureRate) {
213
+ throw new GenerationError("Generation failed", [
214
+ "The model could not complete this request.",
215
+ "Try again, or select a different model.",
216
+ ]);
217
+ }
218
+
219
+ if (outputKind === "text") {
220
+ const text = mockText(prompt, modelId);
221
+ if (onText) {
222
+ // Word by word, so a streaming UI has something real to render.
223
+ const words = text.split(" ");
224
+ for (let i = 1; i <= words.length; i++) {
225
+ onText(words.slice(0, i).join(" "));
226
+ await abortableSleep(60, signal);
227
+ }
228
+ }
229
+ return { src: text, kind: "text", usage: mockUsage(seed) };
230
+ }
231
+ const caption = `${modelId} · ${prompt.slice(0, 48)}${prompt.length > 48 ? "…" : ""}`;
232
+ return { src: placeholderSvg(`${modelId}:${prompt}`, caption), kind: "image", usage: mockUsage(seed) };
233
+ },
234
+ };
235
+ }
236
+
237
+ export const mockEngine: GenerationEngine = createMockEngine();
@@ -0,0 +1,271 @@
1
+ /**
2
+ * A real `GenerationEngine` backed by Fal.ai (fal.ai) — CHAI's default
3
+ * model marketplace as of 2026-09-24, chosen for the
4
+ * text/image-to-video and audio-generation coverage OpenRouter lacks.
5
+ * OpenRouter remains a fully-supported alternative, not deprecated by
6
+ * this — see the Develop page's "Model providers" section.
7
+ *
8
+ * Handles media generation only: produces real image/video media via Fal's
9
+ * queue API (submit → poll → fetch result). Fal's own vision/LLM access
10
+ * (`any-llm`, `any-llm/vision`) is itself a proxy in front of OpenRouter,
11
+ * not an independent catalog — for text/reasoning calls
12
+ * (e.g. prompt enhancement, media analysis) use `createOpenRouterEngine`
13
+ * instead of routing through Fal to OpenRouter.
14
+ *
15
+ * Security: this engine never sees an API key and never calls
16
+ * `queue.fal.run` directly. It calls a same-origin `endpoint` (default
17
+ * `/api/chai/fal`, served by `createChaiHandler`) that a server-side proxy owns and mirrors Fal's own queue
18
+ * URL shape (`/{model}`, `/{model}/requests/{id}/status`,
19
+ * `/{model}/requests/{id}`) — the proxy is the only thing that reads
20
+ * `FAL_KEY` and attaches the `Authorization: Key ...` header. Passing a
21
+ * key into this file would put it in the browser bundle; don't add one.
22
+ */
23
+
24
+ import { abortError, abortableSleep, isAbortError, readEvaluations, type GenerationEngine } from "../engine.js";
25
+ import type { DroppedMedia, MediaKind } from "../types.js";
26
+
27
+ /** Output kinds this engine can produce — text goes through a reasoning engine instead (see file doc comment). */
28
+ type FalOutputKind = Exclude<MediaKind, "text">;
29
+
30
+ export interface FalEngineOptions {
31
+ /** Same-origin proxy path that attaches the real key server-side and mirrors Fal's queue URL shape. */
32
+ endpoint?: string;
33
+ /** Fal model id to call when the caller passes no `modelId` and no `modelByKind` entry matches `outputKind`. */
34
+ model?: string;
35
+ /** Per-output-kind Fal model id override — e.g. `{ image: "fal-ai/flux/schnell", video: "minimax/h3-max/text-to-video" }`. */
36
+ modelByKind?: Partial<Record<FalOutputKind, string>>;
37
+ /** Which kind of media this engine instance produces. Defaults to "image". */
38
+ outputKind?: FalOutputKind;
39
+ /** Poll interval while a queued job is in progress. Defaults to 1500ms. */
40
+ pollIntervalMs?: number;
41
+ /** Give up waiting after this long. Defaults to 120000ms (2 min) — video/audio jobs run longer than image ones. */
42
+ timeoutMs?: number;
43
+ /** Injectable for tests; defaults to the global `fetch`. */
44
+ fetchImpl?: typeof fetch;
45
+ }
46
+
47
+ /**
48
+ * Fal model ids verified live against fal.ai's own docs/catalog
49
+ * 2026-09-24, not invented. No default for "audio": CHAI has no audio
50
+ * `GenerationUseCase` yet, so guessing one
51
+ * here would be ahead of any real use case that needs it.
52
+ */
53
+ const DEFAULT_MODEL_BY_KIND: Partial<Record<FalOutputKind, string>> = {
54
+ image: "fal-ai/flux/schnell",
55
+ video: "minimax/h3-max/text-to-video",
56
+ };
57
+
58
+ const DEFAULT_POLL_INTERVAL_MS = 1500;
59
+ const DEFAULT_TIMEOUT_MS = 120_000;
60
+
61
+ interface FalSubmitResponse {
62
+ request_id?: string;
63
+ status?: string;
64
+ error?: unknown;
65
+ detail?: unknown;
66
+ }
67
+
68
+ interface FalStatusResponse {
69
+ status?: "IN_QUEUE" | "IN_PROGRESS" | "COMPLETED" | string;
70
+ error?: unknown;
71
+ detail?: unknown;
72
+ }
73
+
74
+ interface FalResultResponse {
75
+ images?: { url: string }[];
76
+ video?: { url: string };
77
+ audio?: { url: string };
78
+ audio_url?: string;
79
+ error?: unknown;
80
+ detail?: unknown;
81
+ }
82
+
83
+
84
+ /** Fal's error shape varies by failure type — validation errors carry `detail`, job failures carry `error`. Prefer whichever is present and stringify a non-string one. */
85
+ export function describeFalError(json: { error?: unknown; detail?: unknown }, fallback: string): string {
86
+ const raw = json.error ?? json.detail;
87
+ if (!raw) return fallback;
88
+ if (typeof raw === "string") return raw;
89
+ try {
90
+ return JSON.stringify(raw);
91
+ } catch {
92
+ return fallback;
93
+ }
94
+ }
95
+
96
+ /** Builds the one Fal request body CHAI sends — a prompt, plus an `image_url` when an attached image is available (for image-to-video / edit-shaped models). */
97
+ export function buildFalRequestBody(args: {
98
+ prompt: string;
99
+ media?: DroppedMedia;
100
+ }): Record<string, unknown> {
101
+ const body: Record<string, unknown> = { prompt: args.prompt };
102
+ if (args.media?.kind === "image") body.image_url = args.media.src;
103
+ return body;
104
+ }
105
+
106
+ /** Pulls the generated media URL out of Fal's result JSON for the given output kind — the response shape differs by modality. */
107
+ export function extractFalResultSrc(json: FalResultResponse, kind: FalOutputKind): string | undefined {
108
+ if (kind === "image") return json.images?.[0]?.url;
109
+ if (kind === "video") return json.video?.url;
110
+ return json.audio?.url ?? json.audio_url;
111
+ }
112
+
113
+ async function submitFalJob(args: {
114
+ endpoint: string;
115
+ model: string;
116
+ body: Record<string, unknown>;
117
+ fetchImpl: typeof fetch;
118
+ }): Promise<{ requestId: string; status: string }> {
119
+ const res = await args.fetchImpl(`${args.endpoint}/${args.model}`, {
120
+ method: "POST",
121
+ headers: { "Content-Type": "application/json" },
122
+ body: JSON.stringify(args.body),
123
+ });
124
+ const json = (await res.json().catch(() => ({}))) as FalSubmitResponse;
125
+ if (!res.ok || json.error) throw new Error(describeFalError(json, `${res.status} ${res.statusText}`));
126
+ if (!json.request_id) throw new Error("submit response had no request_id");
127
+ return { requestId: json.request_id, status: json.status ?? "IN_QUEUE" };
128
+ }
129
+
130
+ /** Polls a submitted job's status until `COMPLETED`, then fetches and returns its result. */
131
+ async function awaitFalJob(args: {
132
+ endpoint: string;
133
+ model: string;
134
+ requestId: string;
135
+ status: string;
136
+ fetchImpl: typeof fetch;
137
+ pollIntervalMs: number;
138
+ timeoutMs: number;
139
+ signal?: AbortSignal;
140
+ }): Promise<FalResultResponse> {
141
+ let status = args.status;
142
+ const deadline = Date.now() + args.timeoutMs;
143
+ while (status !== "COMPLETED") {
144
+ if (Date.now() > deadline) {
145
+ throw new Error(`timed out after ${args.timeoutMs}ms waiting for the job to complete`);
146
+ }
147
+ await abortableSleep(args.pollIntervalMs, args.signal);
148
+ const res = await args.fetchImpl(`${args.endpoint}/${args.model}/requests/${args.requestId}/status`);
149
+ const json = (await res.json().catch(() => ({}))) as FalStatusResponse;
150
+ if (!res.ok || json.error) throw new Error(describeFalError(json, `${res.status} ${res.statusText}`));
151
+ status = json.status ?? status;
152
+ }
153
+ const res = await args.fetchImpl(`${args.endpoint}/${args.model}/requests/${args.requestId}`);
154
+ const json = (await res.json().catch(() => ({}))) as FalResultResponse;
155
+ if (!res.ok || json.error) throw new Error(describeFalError(json, `${res.status} ${res.statusText}`));
156
+ return json;
157
+ }
158
+
159
+ /**
160
+ * Asks Fal to drop a job (`PUT …/requests/<id>/cancel`). A queued job is
161
+ * removed and never runs; a running one gets a cancel signal and may still
162
+ * finish, depending on the model. Best effort: a failed cancel call is
163
+ * ignored, since the person has already stopped waiting.
164
+ */
165
+ async function cancelFalJob(args: { endpoint: string; model: string; requestId: string; fetchImpl: typeof fetch }) {
166
+ await args.fetchImpl(`${args.endpoint}/${args.model}/requests/${args.requestId}/cancel`, { method: "PUT" }).catch(() => {});
167
+ }
168
+
169
+ /**
170
+ * A media-generation `GenerationEngine` — submits the prompt (plus the
171
+ * first attached image, if any) to Fal's queue API and polls until the job
172
+ * completes, returning the real generated `src` URL. Calls the caller's
173
+ * `modelId` when given, otherwise `modelByKind`/`model`/the built-in default.
174
+ */
175
+ export function createFalEngine(options: FalEngineOptions = {}): GenerationEngine {
176
+ const endpoint = options.endpoint ?? "/api/chai/fal";
177
+ // Called through a wrapper, never as `args.fetchImpl(...)`: browsers throw
178
+ // "Illegal invocation" when fetch runs with an object as `this`.
179
+ const fetchImpl: typeof fetch = (input, init) => (options.fetchImpl ?? fetch)(input, init);
180
+ const outputKind = options.outputKind ?? "image";
181
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
182
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
183
+
184
+ return {
185
+ async generate({ modelId, prompt, attachments, signal }) {
186
+ const model =
187
+ modelId ?? options.modelByKind?.[outputKind] ?? options.model ?? DEFAULT_MODEL_BY_KIND[outputKind];
188
+ if (!model) {
189
+ throw new Error(
190
+ `falEngine: no model configured for outputKind "${outputKind}" — pass \`model\` or \`modelByKind.${outputKind}\`.`
191
+ );
192
+ }
193
+
194
+ const body = buildFalRequestBody({ prompt, media: attachments[0] });
195
+ if (signal?.aborted) throw abortError();
196
+
197
+ try {
198
+ // The submit itself isn't aborted mid-flight: without its request id
199
+ // there'd be nothing to cancel at Fal.
200
+ const { requestId, status } = await submitFalJob({ endpoint, model, body, fetchImpl });
201
+ if (signal?.aborted) {
202
+ await cancelFalJob({ endpoint, model, requestId, fetchImpl });
203
+ throw abortError();
204
+ }
205
+ const result = await awaitFalJob({
206
+ endpoint,
207
+ model,
208
+ requestId,
209
+ status,
210
+ fetchImpl,
211
+ pollIntervalMs,
212
+ timeoutMs,
213
+ signal,
214
+ }).catch(async (err) => {
215
+ if (isAbortError(err)) await cancelFalJob({ endpoint, model, requestId, fetchImpl });
216
+ throw err;
217
+ });
218
+ const src = extractFalResultSrc(result, outputKind);
219
+ if (!src) throw new Error(`no ${outputKind} output in Fal's response`);
220
+ // No `usage`: Fal bills per generation, not per token — `Usage`
221
+ // is token-shaped (promptTokens/completionTokens), and fabricating
222
+ // token counts to fit it would be a worse guess than reporting
223
+ // nothing, per the same rule `openrouter.ts`'s
224
+ // `pricePerMillionTokens` comment documents for cost.
225
+ return { src, kind: outputKind, evaluations: readEvaluations(result) };
226
+ } catch (err) {
227
+ if (isAbortError(err)) throw err;
228
+ throw new Error(`falEngine: ${err instanceof Error ? err.message : String(err)}`);
229
+ }
230
+ },
231
+ };
232
+ }
233
+
234
+ /**
235
+ * A minimal, cheap connectivity check against Fal's fastest image model —
236
+ * the "use real inference sparingly" path, same role as `pingOpenRouter`.
237
+ * Deliberately not a `GenerationEngine` and not exercised by any automated
238
+ * test: meant to be triggered once, by a person, from the Develop page's
239
+ * "Test connection" button.
240
+ */
241
+ export async function pingFal(
242
+ options: { endpoint?: string; model?: string; fetchImpl?: typeof fetch } = {}
243
+ ): Promise<{ ok: true; src: string } | { ok: false; error: string }> {
244
+ const endpoint = options.endpoint ?? "/api/chai/fal";
245
+ // Called through a wrapper, never as `args.fetchImpl(...)`: browsers throw
246
+ // "Illegal invocation" when fetch runs with an object as `this`.
247
+ const fetchImpl: typeof fetch = (input, init) => (options.fetchImpl ?? fetch)(input, init);
248
+ const model = options.model ?? DEFAULT_MODEL_BY_KIND.image!;
249
+ try {
250
+ const { requestId, status } = await submitFalJob({
251
+ endpoint,
252
+ model,
253
+ body: buildFalRequestBody({ prompt: "a single red circle on a white background" }),
254
+ fetchImpl,
255
+ });
256
+ const result = await awaitFalJob({
257
+ endpoint,
258
+ model,
259
+ requestId,
260
+ status,
261
+ fetchImpl,
262
+ pollIntervalMs: 1000,
263
+ timeoutMs: 30_000,
264
+ });
265
+ const src = extractFalResultSrc(result, "image");
266
+ if (!src) return { ok: false, error: "no image output in Fal's response" };
267
+ return { ok: true, src };
268
+ } catch (err) {
269
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
270
+ }
271
+ }