@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.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/engine.d.ts +84 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +173 -0
- package/dist/engine.js.map +1 -0
- package/dist/engines/fal.d.ts +95 -0
- package/dist/engines/fal.d.ts.map +1 -0
- package/dist/engines/fal.js +209 -0
- package/dist/engines/fal.js.map +1 -0
- package/dist/engines/openrouter.d.ts +141 -0
- package/dist/engines/openrouter.d.ts.map +1 -0
- package/dist/engines/openrouter.js +220 -0
- package/dist/engines/openrouter.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/media-analysis-prompts.d.ts +18 -0
- package/dist/media-analysis-prompts.d.ts.map +1 -0
- package/dist/media-analysis-prompts.js +35 -0
- package/dist/media-analysis-prompts.js.map +1 -0
- package/dist/model-routing.d.ts +49 -0
- package/dist/model-routing.d.ts.map +1 -0
- package/dist/model-routing.js +72 -0
- package/dist/model-routing.js.map +1 -0
- package/dist/run.d.ts +75 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +90 -0
- package/dist/run.js.map +1 -0
- package/dist/server/chai-handler.d.ts +43 -0
- package/dist/server/chai-handler.d.ts.map +1 -0
- package/dist/server/chai-handler.js +106 -0
- package/dist/server/chai-handler.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/node.d.ts +24 -0
- package/dist/server/node.d.ts.map +1 -0
- package/dist/server/node.js +60 -0
- package/dist/server/node.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
- package/src/engine.ts +237 -0
- package/src/engines/fal.ts +271 -0
- package/src/engines/openrouter.ts +301 -0
- package/src/index.ts +7 -0
- package/src/media-analysis-prompts.ts +45 -0
- package/src/model-routing.ts +104 -0
- package/src/run.ts +145 -0
- package/src/server/chai-handler.ts +146 -0
- package/src/server/index.ts +3 -0
- package/src/server/node.ts +75 -0
- package/src/types.ts +96 -0
package/dist/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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
|
+
}
|