@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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createChaiHandler`: the one server route the built-in engines call.
|
|
3
|
+
* The browser never sees an API key. `createFalEngine()` and
|
|
4
|
+
* `createOpenRouterEngine()` post to this route (default `/api/chai/...`),
|
|
5
|
+
* and it adds the key and forwards the request to the provider.
|
|
6
|
+
*
|
|
7
|
+
* Built on the web-standard `Request` → `Response`, so the same handler
|
|
8
|
+
* runs in Next.js, React Router (Remix), Hono, Cloudflare Workers, Bun and
|
|
9
|
+
* Deno. `toNodeHandler` (./node.ts) adapts it for Express and plain Node.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const FAL_QUEUE_BASE = "https://queue.fal.run";
|
|
13
|
+
const OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
14
|
+
|
|
15
|
+
export type ChaiProviderId = "fal" | "openrouter";
|
|
16
|
+
|
|
17
|
+
export interface ChaiHandlerOptions {
|
|
18
|
+
/** Defaults to the `FAL_KEY` environment variable. */
|
|
19
|
+
falKey?: string;
|
|
20
|
+
/** Defaults to the `OPENROUTER_API_KEY` environment variable. */
|
|
21
|
+
openRouterKey?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Who may use this route. Return false to reject with 403. Without it,
|
|
24
|
+
* anyone who can reach your site can spend your API credit: check your
|
|
25
|
+
* own login or session here before going live.
|
|
26
|
+
*/
|
|
27
|
+
authorize?: (request: Request) => boolean | Promise<boolean>;
|
|
28
|
+
/**
|
|
29
|
+
* The only model ids this route will run, across both providers (e.g.
|
|
30
|
+
* `"fal-ai/flux/schnell"`, `"anthropic/claude-opus-5"`). Anything else is
|
|
31
|
+
* rejected with 403, so nobody can run an expensive model through you.
|
|
32
|
+
* Omit to allow every model.
|
|
33
|
+
*/
|
|
34
|
+
allowedModels?: string[];
|
|
35
|
+
/** Injectable for tests; defaults to the global `fetch`. */
|
|
36
|
+
fetch?: typeof fetch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ChaiHandler {
|
|
40
|
+
/** Handles any request under the route. */
|
|
41
|
+
handle: (request: Request) => Promise<Response>;
|
|
42
|
+
/** The same function, named for frameworks that export one per method (Next.js). */
|
|
43
|
+
GET: (request: Request) => Promise<Response>;
|
|
44
|
+
POST: (request: Request) => Promise<Response>;
|
|
45
|
+
/** Fal's cancel call. */
|
|
46
|
+
PUT: (request: Request) => Promise<Response>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createChaiHandler(options: ChaiHandlerOptions = {}): ChaiHandler {
|
|
50
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
51
|
+
|
|
52
|
+
async function handle(request: Request): Promise<Response> {
|
|
53
|
+
const route = parseRoute(new URL(request.url).pathname);
|
|
54
|
+
if (!route) {
|
|
55
|
+
return Response.json(
|
|
56
|
+
{ error: { message: "chai-handler: expected a path ending in /fal/<model…> or /openrouter/chat." } },
|
|
57
|
+
{ status: 404 }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const fail = (status: number, message: string) => errorResponse(route.provider, status, message);
|
|
61
|
+
|
|
62
|
+
if (options.authorize && !(await options.authorize(request))) return fail(403, "Not authorized.");
|
|
63
|
+
|
|
64
|
+
if (route.provider === "openrouter") {
|
|
65
|
+
if (route.rest !== "chat") return fail(404, `Unknown OpenRouter path "${route.rest}".`);
|
|
66
|
+
if (request.method !== "POST") return fail(405, "Use POST.");
|
|
67
|
+
const key = options.openRouterKey ?? readEnv("OPENROUTER_API_KEY");
|
|
68
|
+
if (!key) return fail(500, "OPENROUTER_API_KEY isn't set on the server.");
|
|
69
|
+
const body = await request.text();
|
|
70
|
+
const model = readModel(body);
|
|
71
|
+
if (options.allowedModels && !(model && options.allowedModels.includes(model))) {
|
|
72
|
+
return fail(403, `Model "${model ?? "(none)"}" isn't allowed on this server.`);
|
|
73
|
+
}
|
|
74
|
+
return forward(fetchImpl, route.provider, OPENROUTER_CHAT_URL, {
|
|
75
|
+
signal: request.signal,
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
78
|
+
body,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fal's queue API: POST /<model> submits, GET /<model>/requests/<id>[/status]
|
|
83
|
+
// polls, PUT /<model>/requests/<id>/cancel cancels.
|
|
84
|
+
const isCancel = request.method === "PUT" && route.rest.endsWith("/cancel");
|
|
85
|
+
if (request.method !== "GET" && request.method !== "POST" && !isCancel) return fail(405, "Use GET or POST.");
|
|
86
|
+
const key = options.falKey ?? readEnv("FAL_KEY");
|
|
87
|
+
if (!key) return fail(500, "FAL_KEY isn't set on the server.");
|
|
88
|
+
if (options.allowedModels && !options.allowedModels.some((m) => route.rest === m || route.rest.startsWith(`${m}/`))) {
|
|
89
|
+
return fail(403, `Model "${falModel(route.rest)}" isn't allowed on this server.`);
|
|
90
|
+
}
|
|
91
|
+
return forward(fetchImpl, route.provider, `${FAL_QUEUE_BASE}/${route.rest}${new URL(request.url).search}`, {
|
|
92
|
+
signal: request.signal,
|
|
93
|
+
method: request.method,
|
|
94
|
+
headers: { Authorization: `Key ${key}`, "Content-Type": "application/json" },
|
|
95
|
+
body: request.method === "POST" ? await request.text() : undefined,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { handle, GET: handle, POST: handle, PUT: handle };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Finds the provider segment anywhere in the path, so the route can be mounted at any prefix. */
|
|
103
|
+
function parseRoute(pathname: string): { provider: ChaiProviderId; rest: string } | null {
|
|
104
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
105
|
+
const i = parts.findIndex((p) => p === "fal" || p === "openrouter");
|
|
106
|
+
if (i === -1 || i === parts.length - 1) return null;
|
|
107
|
+
return { provider: parts[i] as ChaiProviderId, rest: parts.slice(i + 1).join("/") };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Passes the provider's response straight through, streaming included. */
|
|
111
|
+
async function forward(fetchImpl: typeof fetch, provider: ChaiProviderId, url: string, init: RequestInit): Promise<Response> {
|
|
112
|
+
try {
|
|
113
|
+
const upstream = await fetchImpl(url, init);
|
|
114
|
+
return new Response(upstream.body, {
|
|
115
|
+
status: upstream.status,
|
|
116
|
+
headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/json" },
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return errorResponse(provider, 502, `Couldn't reach ${provider === "fal" ? "Fal.ai" : "OpenRouter"}: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Errors in the shape each engine already reads: Fal's `{ error }`, OpenRouter's `{ error: { message } }`. */
|
|
124
|
+
function errorResponse(provider: ChaiProviderId, status: number, message: string): Response {
|
|
125
|
+
const body = provider === "fal" ? { error: message } : { error: { message } };
|
|
126
|
+
return Response.json(body, { status });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readModel(body: string): string | undefined {
|
|
130
|
+
try {
|
|
131
|
+
const model = (JSON.parse(body) as { model?: unknown }).model;
|
|
132
|
+
return typeof model === "string" ? model : undefined;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The model part of a Fal queue path, for error messages: everything before `/requests/`. */
|
|
139
|
+
function falModel(rest: string): string {
|
|
140
|
+
return rest.split("/requests/")[0]!;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readEnv(name: string): string | undefined {
|
|
144
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
|
|
145
|
+
return env?.[name] || undefined;
|
|
146
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a `ChaiHandler` on Node's own request/response objects, for Express
|
|
3
|
+
* and plain `http.createServer` (and dev servers built on them, like
|
|
4
|
+
* Vite's). Typed structurally, so this package needs no Node types.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChaiHandler } from "./chai-handler.js";
|
|
8
|
+
|
|
9
|
+
interface NodeRequest extends AsyncIterable<Uint8Array | string> {
|
|
10
|
+
method?: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
/** Express keeps the full path here when the route is mounted at a prefix. */
|
|
13
|
+
originalUrl?: string;
|
|
14
|
+
headers: Record<string, string | string[] | undefined>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface NodeResponse {
|
|
18
|
+
statusCode: number;
|
|
19
|
+
writableEnded?: boolean;
|
|
20
|
+
on?(event: "close", listener: () => void): unknown;
|
|
21
|
+
setHeader(name: string, value: string): unknown;
|
|
22
|
+
write(chunk: Uint8Array): unknown;
|
|
23
|
+
end(chunk?: string | Uint8Array): unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toNodeHandler(handler: ChaiHandler) {
|
|
27
|
+
return async (req: NodeRequest, res: NodeResponse): Promise<void> => {
|
|
28
|
+
const host = String(req.headers.host ?? "localhost");
|
|
29
|
+
const url = new URL(req.originalUrl ?? req.url ?? "/", `http://${host}`);
|
|
30
|
+
const method = req.method ?? "GET";
|
|
31
|
+
const headers = new Headers();
|
|
32
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
33
|
+
if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
34
|
+
}
|
|
35
|
+
let body: Uint8Array<ArrayBuffer> | undefined;
|
|
36
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
37
|
+
const chunks: Uint8Array[] = [];
|
|
38
|
+
for await (const chunk of req) chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
39
|
+
body = concat(chunks);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// When the browser disconnects (e.g. the person pressed stop), abort the
|
|
43
|
+
// provider call too, so a stream stops at the provider.
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
res.on?.("close", () => {
|
|
46
|
+
if (!res.writableEnded) controller.abort();
|
|
47
|
+
});
|
|
48
|
+
const response = await handler.handle(new Request(url, { method, headers, body, signal: controller.signal }));
|
|
49
|
+
res.statusCode = response.status;
|
|
50
|
+
response.headers.forEach((value, name) => res.setHeader(name, value));
|
|
51
|
+
if (!response.body) return void res.end();
|
|
52
|
+
// Streamed through as it arrives, so streaming text renders live.
|
|
53
|
+
const reader = response.body.getReader();
|
|
54
|
+
try {
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { done, value } = await reader.read();
|
|
57
|
+
if (done) break;
|
|
58
|
+
res.write(value);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// The provider stream was aborted because the browser left; nothing to send.
|
|
62
|
+
}
|
|
63
|
+
res.end();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function concat(chunks: Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
68
|
+
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
|
69
|
+
let offset = 0;
|
|
70
|
+
for (const c of chunks) {
|
|
71
|
+
out.set(c, offset);
|
|
72
|
+
offset += c.length;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHAI UI — shared data types for the component library.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** The kinds of media a component can take in or produce. */
|
|
6
|
+
export type MediaKind = "image" | "video" | "audio" | "text";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A piece of media a person attached (or an engine received) — just enough
|
|
10
|
+
* to know its kind and where it lives.
|
|
11
|
+
*/
|
|
12
|
+
export interface DroppedMedia {
|
|
13
|
+
src: string;
|
|
14
|
+
kind: MediaKind;
|
|
15
|
+
/** Original file name, for the upload-summary UI. Absent for non-file sources. */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** Original file size in bytes, for the upload-summary UI. */
|
|
18
|
+
size?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Token/cost accounting for one model call, when the engine reports one.
|
|
23
|
+
* `costUsd` is deliberately separate from the token counts: an engine may
|
|
24
|
+
* know real token counts but have no verified per-model pricing to convert
|
|
25
|
+
* them with (see `ModelOption.pricePerMillionTokens`) — degrade to showing
|
|
26
|
+
* tokens without a cost rather than guessing a price.
|
|
27
|
+
*/
|
|
28
|
+
export interface Usage {
|
|
29
|
+
promptTokens: number;
|
|
30
|
+
completionTokens: number;
|
|
31
|
+
totalTokens: number;
|
|
32
|
+
costUsd?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The verdict of one quality check the builder's own backend ran on a
|
|
37
|
+
* generated output (e.g. brand safety, prompt adherence), appended to the
|
|
38
|
+
* model call's response. CHAI never runs or scores a check; it only
|
|
39
|
+
* renders the verdict: `passed` true is green, false is red.
|
|
40
|
+
*/
|
|
41
|
+
export interface Evaluation {
|
|
42
|
+
id: string;
|
|
43
|
+
label: string;
|
|
44
|
+
passed: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The thin value-in/value-out shape a form control satisfies (e.g.
|
|
49
|
+
* `Toggle`) — deliberately minimal so most existing components already
|
|
50
|
+
* qualify without an adapter.
|
|
51
|
+
*/
|
|
52
|
+
export interface PrimitiveProps<T = unknown> {
|
|
53
|
+
value: T;
|
|
54
|
+
onChange: (next: T) => void;
|
|
55
|
+
disabled?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One labeled option, for option-list controls (e.g. Composer's aspect ratios). */
|
|
59
|
+
export interface ParameterOption<T> {
|
|
60
|
+
value: T;
|
|
61
|
+
label: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A model a component can offer or route to. */
|
|
65
|
+
export interface ModelOption {
|
|
66
|
+
id: string;
|
|
67
|
+
label: string;
|
|
68
|
+
provider: string;
|
|
69
|
+
/** Rough cost/latency signal, for future cost/latency-aware routing. */
|
|
70
|
+
speed: "fast" | "standard" | "slow";
|
|
71
|
+
/**
|
|
72
|
+
* USD per million tokens, if known — lets real token counts convert into
|
|
73
|
+
* a real cost. Deliberately optional and absent by default: provider
|
|
74
|
+
* pricing changes and isn't tracked anywhere in this repo yet, so a wrong
|
|
75
|
+
* hardcoded number would be worse than no cost at all. Set this per model
|
|
76
|
+
* only from a source you're actively keeping current.
|
|
77
|
+
*/
|
|
78
|
+
pricePerMillionTokens?: { input: number; output: number };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A specific generation use case. These names deliberately match the
|
|
83
|
+
* leaderboard categories at github.com/oolong-tea-2026/arena-ai-leaderboards,
|
|
84
|
+
* so a future model-ranking lookup can join on it directly, plus
|
|
85
|
+
* "text-to-text" for a plain text request (a Composer with no use case
|
|
86
|
+
* picked). That repo has no audio-generation category at all, a real gap
|
|
87
|
+
* for any future audio use case.
|
|
88
|
+
*/
|
|
89
|
+
export type GenerationUseCase =
|
|
90
|
+
| "text-to-image"
|
|
91
|
+
| "image-edit"
|
|
92
|
+
| "text-to-video"
|
|
93
|
+
| "image-to-video"
|
|
94
|
+
| "video-edit"
|
|
95
|
+
| "vision"
|
|
96
|
+
| "text-to-text";
|