@chai-ui/core 0.8.0 → 0.10.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 (49) hide show
  1. package/README.md +6 -5
  2. package/dist/composer-view.d.ts +6 -0
  3. package/dist/composer-view.d.ts.map +1 -1
  4. package/dist/composer-view.js +6 -0
  5. package/dist/composer-view.js.map +1 -1
  6. package/dist/composer.d.ts +1 -1
  7. package/dist/composer.d.ts.map +1 -1
  8. package/dist/composer.js +7 -3
  9. package/dist/composer.js.map +1 -1
  10. package/dist/engine.d.ts +1 -1
  11. package/dist/engine.js +1 -1
  12. package/dist/engines/fal.d.ts +25 -0
  13. package/dist/engines/fal.d.ts.map +1 -1
  14. package/dist/engines/fal.js +41 -0
  15. package/dist/engines/fal.js.map +1 -1
  16. package/dist/engines/openrouter.d.ts +9 -2
  17. package/dist/engines/openrouter.d.ts.map +1 -1
  18. package/dist/engines/openrouter.js +2 -2
  19. package/dist/engines/openrouter.js.map +1 -1
  20. package/dist/engines/vercel.d.ts +64 -0
  21. package/dist/engines/vercel.d.ts.map +1 -0
  22. package/dist/engines/vercel.js +137 -0
  23. package/dist/engines/vercel.js.map +1 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/server/chai-handler.d.ts +7 -2
  29. package/dist/server/chai-handler.d.ts.map +1 -1
  30. package/dist/server/chai-handler.js +31 -3
  31. package/dist/server/chai-handler.js.map +1 -1
  32. package/dist/suggested-models.d.ts.map +1 -1
  33. package/dist/suggested-models.js +2 -1
  34. package/dist/suggested-models.js.map +1 -1
  35. package/dist/voice-input.d.ts +79 -0
  36. package/dist/voice-input.d.ts.map +1 -0
  37. package/dist/voice-input.js +207 -0
  38. package/dist/voice-input.js.map +1 -0
  39. package/package.json +4 -3
  40. package/src/composer-view.ts +6 -0
  41. package/src/composer.ts +7 -3
  42. package/src/engine.ts +1 -1
  43. package/src/engines/fal.ts +55 -0
  44. package/src/engines/openrouter.ts +3 -3
  45. package/src/engines/vercel.ts +198 -0
  46. package/src/index.ts +2 -0
  47. package/src/server/chai-handler.ts +37 -5
  48. package/src/suggested-models.ts +2 -1
  49. package/src/voice-input.ts +257 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Voice input for the Composer: record from the microphone, send the clip
3
+ * to a speech-to-text model, and hand back the transcript. Framework-free,
4
+ * so React and Vue each add only the button.
5
+ */
6
+ import { abortError, isAbortError } from "./engine.js";
7
+ import { createFalTranscriber } from "./engines/fal.js";
8
+ /** How many level readings a waveform shows. */
9
+ export const VOICE_LEVEL_COUNT = 48;
10
+ export const DEFAULT_VOICE_MAX_SECONDS = 60;
11
+ /** Recording formats, best first. Chrome and Firefox record WebM, Safari MP4. */
12
+ const MIME_TYPES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
13
+ /** The `Transcribe` a config describes: its own, or a Fal transcriber for its model. */
14
+ export function voiceTranscriber(config) {
15
+ return config.transcribe ?? createFalTranscriber({ model: config.model, endpoint: config.endpoint });
16
+ }
17
+ /** "0:05", "1:00": how long a recording has run. */
18
+ export function formatVoiceElapsed(ms) {
19
+ const s = Math.floor(ms / 1000);
20
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
21
+ }
22
+ /** Why the microphone couldn't start, in words for the person. */
23
+ export function describeMicError(err) {
24
+ const { name, message } = err ?? {};
25
+ // Chrome's wording when the computer, not the site, blocks the browser
26
+ // (macOS Privacy & Security, Windows microphone privacy).
27
+ if (name === "NotAllowedError" && /system/i.test(message ?? "")) {
28
+ return "Your computer is blocking this browser from the microphone. Allow it in your system's privacy settings, then reload.";
29
+ }
30
+ if (name === "NotAllowedError" || name === "SecurityError") {
31
+ return "Microphone access is blocked. Allow it in your browser's site settings to use voice input.";
32
+ }
33
+ if (name === "NotFoundError" || name === "OverconstrainedError")
34
+ return "No microphone found.";
35
+ if (name === "NotReadableError")
36
+ return "The microphone is in use by another app.";
37
+ return "The microphone couldn't start.";
38
+ }
39
+ /**
40
+ * The recorder behind the mic button. `start` asks for the microphone and
41
+ * records; `stop` ends the recording and transcribes it; `cancel` stops
42
+ * either without a transcript. `onTranscript` gets the text: it never
43
+ * submits, so the person can fix it first.
44
+ */
45
+ export function createVoiceInput(getConfig, onTranscript, deps = {}) {
46
+ let state = { status: "idle", elapsedMs: 0, error: null, levels: [] };
47
+ const listeners = new Set();
48
+ const set = (next) => {
49
+ state = { ...state, ...next };
50
+ listeners.forEach((l) => l());
51
+ };
52
+ let recorder = null;
53
+ let stream = null;
54
+ let timer = null;
55
+ let controller = null;
56
+ let discard = false;
57
+ let audio = null;
58
+ let readLevel = null;
59
+ function release() {
60
+ if (timer)
61
+ clearInterval(timer);
62
+ timer = null;
63
+ void audio?.close().catch(() => { });
64
+ audio = null;
65
+ readLevel = null;
66
+ stream?.getTracks().forEach((t) => t.stop());
67
+ stream = null;
68
+ recorder = null;
69
+ }
70
+ async function start() {
71
+ if (state.status === "recording" || state.status === "transcribing")
72
+ return;
73
+ const Recorder = deps.MediaRecorder ?? (typeof MediaRecorder === "undefined" ? undefined : MediaRecorder);
74
+ const getUserMedia = deps.getUserMedia ?? (typeof navigator !== "undefined" ? navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices) : undefined);
75
+ if (!Recorder || !getUserMedia)
76
+ return set({ status: "error", error: "Voice input isn't supported in this browser." });
77
+ try {
78
+ stream = await getUserMedia({ audio: true });
79
+ }
80
+ catch (err) {
81
+ return set({ status: "error", error: describeMicError(err) });
82
+ }
83
+ const mimeType = MIME_TYPES.find((t) => Recorder.isTypeSupported?.(t));
84
+ const chunks = [];
85
+ discard = false;
86
+ recorder = new Recorder(stream, mimeType ? { mimeType } : undefined);
87
+ recorder.ondataavailable = (e) => {
88
+ if (e.data.size > 0)
89
+ chunks.push(e.data);
90
+ };
91
+ recorder.onstop = () => {
92
+ const type = recorder?.mimeType || mimeType || "audio/webm";
93
+ release();
94
+ if (discard)
95
+ return set({ status: "idle", elapsedMs: 0, levels: [] });
96
+ void transcribe(new Blob(chunks, { type }), type);
97
+ };
98
+ recorder.start();
99
+ readLevel = listen(stream);
100
+ const startedAt = Date.now();
101
+ const maxMs = (getConfig().maxSeconds ?? DEFAULT_VOICE_MAX_SECONDS) * 1000;
102
+ set({ status: "recording", elapsedMs: 0, error: null, levels: [] });
103
+ timer = setInterval(() => {
104
+ const elapsedMs = Date.now() - startedAt;
105
+ const levels = readLevel ? [...state.levels, readLevel()].slice(-VOICE_LEVEL_COUNT) : state.levels;
106
+ set({ elapsedMs, levels });
107
+ if (elapsedMs >= maxMs)
108
+ stop();
109
+ }, 80);
110
+ }
111
+ /** Reads the stream's loudness, 0–1, through Web Audio. Null without it. */
112
+ function listen(source) {
113
+ const Context = deps.AudioContext ?? (typeof AudioContext === "undefined" ? undefined : AudioContext);
114
+ if (!Context)
115
+ return null;
116
+ try {
117
+ audio = new Context();
118
+ const analyser = audio.createAnalyser();
119
+ analyser.fftSize = 256;
120
+ audio.createMediaStreamSource(source).connect(analyser);
121
+ const samples = new Uint8Array(analyser.fftSize);
122
+ return () => {
123
+ analyser.getByteTimeDomainData(samples);
124
+ let sum = 0;
125
+ for (const v of samples)
126
+ sum += ((v - 128) / 128) ** 2;
127
+ // Speech sits low on this scale: lift it so a normal voice fills the bars.
128
+ return Math.min(1, Math.sqrt(sum / samples.length) * 4);
129
+ };
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ async function transcribe(blob, mimeType) {
136
+ if (blob.size === 0)
137
+ return set({ status: "error", error: "Nothing was recorded. Try again." });
138
+ set({ status: "transcribing", levels: [] });
139
+ controller = new AbortController();
140
+ try {
141
+ const src = await readAsDataUrl(blob);
142
+ if (controller.signal.aborted)
143
+ throw abortError();
144
+ const text = (await voiceTranscriber(getConfig())({ src, kind: "audio", mimeType }, { signal: controller.signal })).trim();
145
+ if (!text)
146
+ return set({ status: "error", error: "Didn't catch that. Try again." });
147
+ onTranscript(text);
148
+ set({ status: "idle", elapsedMs: 0, levels: [] });
149
+ }
150
+ catch (err) {
151
+ if (isAbortError(err))
152
+ return set({ status: "idle", elapsedMs: 0, levels: [] });
153
+ set({ status: "error", error: "Transcription failed. Try again." });
154
+ }
155
+ finally {
156
+ controller = null;
157
+ }
158
+ }
159
+ /** Ends the recording and transcribes it. */
160
+ function stop() {
161
+ if (recorder && recorder.state !== "inactive")
162
+ recorder.stop();
163
+ }
164
+ /** Stops recording or transcribing, with no transcript. */
165
+ function cancel() {
166
+ if (state.status === "recording") {
167
+ discard = true;
168
+ stop();
169
+ }
170
+ else if (state.status === "transcribing")
171
+ controller?.abort();
172
+ }
173
+ /** Clears an error, back to the idle mic. */
174
+ function dismiss() {
175
+ if (state.status === "error")
176
+ set({ status: "idle", error: null, elapsedMs: 0, levels: [] });
177
+ }
178
+ return {
179
+ getState: () => state,
180
+ subscribe(listener) {
181
+ listeners.add(listener);
182
+ return () => listeners.delete(listener);
183
+ },
184
+ start,
185
+ stop,
186
+ cancel,
187
+ dismiss,
188
+ /** Stops everything, for when the Composer goes away. */
189
+ dispose() {
190
+ discard = true;
191
+ controller?.abort();
192
+ if (recorder && recorder.state !== "inactive")
193
+ recorder.stop();
194
+ release();
195
+ },
196
+ };
197
+ }
198
+ /** A clip's data URL, for the model's `audio_url`. */
199
+ function readAsDataUrl(blob) {
200
+ return new Promise((resolve, reject) => {
201
+ const reader = new FileReader();
202
+ reader.onload = () => resolve(String(reader.result));
203
+ reader.onerror = () => reject(reader.error);
204
+ reader.readAsDataURL(blob);
205
+ });
206
+ }
207
+ //# sourceMappingURL=voice-input.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice-input.js","sourceRoot":"","sources":["../src/voice-input.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chai-ui/core",
3
- "version": "0.8.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.",
3
+ "version": "0.10.0",
4
+ "description": "CHAI UI headless core — the Request/Run/Result data model and pluggable generation engines (Fal.ai, OpenRouter, Vercel AI Gateway, mock). No framework, no rendering.",
5
5
  "license": "MIT",
6
6
  "author": "kazarazat",
7
7
  "homepage": "https://chai-ui.com",
@@ -21,7 +21,8 @@
21
21
  "headless",
22
22
  "ai-engine",
23
23
  "fal",
24
- "openrouter"
24
+ "openrouter",
25
+ "vercel-ai-gateway"
25
26
  ],
26
27
  "type": "module",
27
28
  "sideEffects": false,
@@ -220,6 +220,12 @@ export function composerView(props: ComposerViewInput) {
220
220
  offeredAspect,
221
221
  selectedAspect,
222
222
  world,
223
+ /**
224
+ * What an Enhance click rewrites for: the active use case, derived like
225
+ * a submit's `mode` ("text-to-text" with no use case picked, "vision"
226
+ * with media attached, "image-edit" while editing). Undefined for a world.
227
+ */
228
+ enhanceMode: editing ? ("image-edit" as const) : deriveGenerationUseCase(activeUseCases[0], attachments),
223
229
  /**
224
230
  * Why submit can't go yet, shown under the environment: there's
225
231
  * something to send (a character or an attachment) but no environment.
package/src/composer.ts CHANGED
@@ -485,13 +485,17 @@ export function createComposerStore(getOptions: () => ComposerOptions, getProvid
485
485
  set((s) => ({ routing: false, runs: cancelAll(s.runs), editRuns: cancelAll(s.editRuns) }));
486
486
  }
487
487
 
488
- /** Rewrites a prompt with the reasoning model, worded for its `field`. Only offer it when `reasoning()` is set. */
489
- async function enhance(value: string, field?: ComposerPromptField): Promise<string> {
488
+ /**
489
+ * Rewrites a prompt with the reasoning model, worded for its `field` and
490
+ * `mode` (the Composer passes both to `onEnhance`, so `onEnhance={enhance}`
491
+ * rewrites for the use case on screen). Only offer it when `reasoning()` is set.
492
+ */
493
+ async function enhance(value: string, field?: ComposerPromptField, mode?: GenerationUseCase): Promise<string> {
490
494
  const r = reasoning();
491
495
  if (!r) throw new Error("[chai-ui] Enhance needs a reasoning engine and model.");
492
496
  set(() => ({ enhancing: true }));
493
497
  try {
494
- const prompt = (getOptions().enhancePrompt ?? defaultEnhancePrompt)(value, state.runs[0]?.request.mode, field);
498
+ const prompt = (getOptions().enhancePrompt ?? defaultEnhancePrompt)(value, mode ?? state.runs[0]?.request.mode, field);
495
499
  const { src } = await r.engine.generate({ modelId: r.model, prompt, attachments: [] });
496
500
  return src;
497
501
  } finally {
package/src/engine.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * A pluggable generation engine — the one interface every model provider
3
- * (`createFalEngine`, `createOpenRouterEngine`, `mockEngine`, or your own)
3
+ * (`createFalEngine`, `createOpenRouterEngine`, `createVercelEngine`, `mockEngine`, or your own)
4
4
  * implements, and the one `useComposer` calls. One call = one model run:
5
5
  * a prompt plus any attached media in, one piece of output back.
6
6
  *
@@ -24,6 +24,7 @@
24
24
  import { abortError, abortableSleep, isAbortError, readEvaluations, type GenerationEngine } from "../engine.js";
25
25
  import type { DroppedMedia, MediaKind } from "../types.js";
26
26
  import { SUGGESTED_FAL_MODELS } from "../suggested-models.js";
27
+ import type { Transcribe } from "../voice-input.js";
27
28
 
28
29
  /** Output kinds this engine can produce — text goes through a reasoning engine instead (see file doc comment). */
29
30
  type FalOutputKind = Exclude<MediaKind, "text">;
@@ -77,6 +78,9 @@ interface FalResultResponse {
77
78
  video?: { url: string };
78
79
  audio?: { url: string };
79
80
  audio_url?: string;
81
+ /** Speech-to-text models answer with the transcript in `text` or `output`. */
82
+ text?: string;
83
+ output?: string;
80
84
  error?: unknown;
81
85
  detail?: unknown;
82
86
  }
@@ -205,6 +209,12 @@ async function awaitFalJob(args: {
205
209
  return json;
206
210
  }
207
211
 
212
+ /** The transcript in a Fal speech-to-text result: `text` (Wizper, Scribe, Cohere) or `output` (Fal's own, Nemotron). */
213
+ export function extractFalTranscript(json: { text?: unknown; output?: unknown }): string | undefined {
214
+ const text = typeof json.text === "string" ? json.text : typeof json.output === "string" ? json.output : undefined;
215
+ return text?.trim();
216
+ }
217
+
208
218
  /**
209
219
  * Asks Fal to drop a job (`PUT …/requests/<id>/cancel`). A queued job is
210
220
  * removed and never runs; a running one gets a cancel signal and may still
@@ -318,3 +328,48 @@ export async function pingFal(
318
328
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
319
329
  }
320
330
  }
331
+
332
+ export interface FalTranscriberOptions {
333
+ /** A Fal speech-to-text model id, e.g. `"fal-ai/wizper"`. Required: Chai doesn't pick one for you. */
334
+ model: string;
335
+ /** Same proxy route `createFalEngine` uses. Defaults to `/api/chai/fal`. */
336
+ endpoint?: string;
337
+ fetchImpl?: typeof fetch;
338
+ pollIntervalMs?: number;
339
+ timeoutMs?: number;
340
+ }
341
+
342
+ /**
343
+ * A `Transcribe` function on a Fal speech-to-text model, through the same
344
+ * server route as `createFalEngine`, so the key stays on the server. The
345
+ * recording goes to the model as `audio_url`, a data URL, like images do.
346
+ * With `allowedModels` on the handler, list the transcription model too.
347
+ */
348
+ export function createFalTranscriber(options: FalTranscriberOptions): Transcribe {
349
+ const endpoint = options.endpoint ?? "/api/chai/fal";
350
+ // Called through a wrapper: browsers throw "Illegal invocation" when fetch runs with an object as `this`.
351
+ const fetchImpl: typeof fetch = (input, init) => (options.fetchImpl ?? fetch)(input, init);
352
+ const { model } = options;
353
+ return async (audio, { signal } = {}) => {
354
+ if (signal?.aborted) throw abortError();
355
+ const { requestId, status } = await submitFalJob({ endpoint, model, body: { audio_url: audio.src }, fetchImpl });
356
+ if (signal?.aborted) {
357
+ await cancelFalJob({ endpoint, model, requestId, fetchImpl });
358
+ throw abortError();
359
+ }
360
+ const result = await awaitFalJob({
361
+ endpoint,
362
+ model,
363
+ requestId,
364
+ status,
365
+ fetchImpl,
366
+ pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
367
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
368
+ signal,
369
+ }).catch(async (err) => {
370
+ if (isAbortError(err)) await cancelFalJob({ endpoint, model, requestId, fetchImpl });
371
+ throw err;
372
+ });
373
+ return extractFalTranscript(result) ?? "";
374
+ };
375
+ }
@@ -55,7 +55,7 @@ type ChatContentPart =
55
55
  | { type: "video_url"; video_url: { url: string } }
56
56
  | { type: "input_audio"; input_audio: { data: string; format: string } };
57
57
 
58
- interface ChatCompletionResponse {
58
+ export interface ChatCompletionResponse {
59
59
  choices?: { message?: { content?: string } }[];
60
60
  // OpenAI-compatible shape OpenRouter itself uses; absent on some
61
61
  // providers routed through it, hence all-optional.
@@ -69,7 +69,7 @@ interface ChatCompletionResponse {
69
69
  }
70
70
 
71
71
  /** Converts OpenRouter's raw `usage` into `Usage`, computing `costUsd` only when a real rate was supplied. */
72
- function toUsage(
72
+ export function toUsage(
73
73
  usage: ChatCompletionResponse["usage"],
74
74
  pricePerMillionTokens?: { input: number; output: number }
75
75
  ): Usage | undefined {
@@ -90,7 +90,7 @@ function toUsage(
90
90
  }
91
91
 
92
92
  /** Prefers the upstream provider's own error message over OpenRouter's generic wrapper, when both are present. */
93
- function describeError(json: ChatCompletionResponse, fallback: string): string {
93
+ export function describeError(json: ChatCompletionResponse, fallback: string): string {
94
94
  const base = json.error?.message ?? fallback;
95
95
  const raw = json.error?.metadata?.raw;
96
96
  if (!raw) return base;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * A `GenerationEngine` backed by Vercel's AI Gateway (vercel.com/docs/ai-gateway)
3
+ * — one OpenAI-compatible endpoint in front of 200+ models from many labs.
4
+ * Covers text/reasoning (chat completions, the same wire format as
5
+ * OpenRouter) and image generation (the OpenAI-style images endpoint).
6
+ *
7
+ * Video isn't offered: the gateway's OpenAI-compatible surface has no
8
+ * confirmed video-generation endpoint, so `outputKind: "video"` doesn't
9
+ * exist here. Use `createFalEngine` for video.
10
+ *
11
+ * Security: this engine never sees an API key and never calls
12
+ * `ai-gateway.vercel.sh` directly. It POSTs to a same-origin `endpoint`
13
+ * (default `/api/chai/vercel`, served by `createChaiHandler`), which adds
14
+ * `AI_GATEWAY_API_KEY` (or `VERCEL_OIDC_TOKEN` on Vercel) server-side.
15
+ * Passing a key into this file would put it in the browser bundle; don't
16
+ * add one.
17
+ */
18
+
19
+ import { readEvaluations, type GenerationEngine } from "../engine.js";
20
+ import type { DroppedMedia, Evaluation, Usage } from "../types.js";
21
+ import {
22
+ buildChatRequest,
23
+ describeError,
24
+ readChatStream,
25
+ toUsage,
26
+ type ChatCompletionResponse,
27
+ } from "./openrouter.js";
28
+
29
+ /** Output kinds the gateway can produce. Video is excluded: see the file doc comment. */
30
+ export type VercelOutputKind = "text" | "image";
31
+
32
+ export interface VercelEngineOptions {
33
+ /** Same-origin proxy base path; the engine appends `/chat` or `/images`. */
34
+ endpoint?: string;
35
+ /**
36
+ * Gateway model id (e.g. `"anthropic/claude-opus-5"`), used when the
37
+ * caller passes no `modelId` and no `modelByKind` entry matches. There is
38
+ * no built-in default: the gateway catalog changes often, so name the
39
+ * model you want.
40
+ */
41
+ model?: string;
42
+ /** Per-output-kind model override, e.g. a text model and an image model. */
43
+ modelByKind?: Partial<Record<VercelOutputKind, string>>;
44
+ /** Which kind of output this engine instance produces. Defaults to "text". */
45
+ outputKind?: VercelOutputKind;
46
+ /** Cap on a text response's length, as in `createOpenRouterEngine`. */
47
+ maxTokens?: number;
48
+ /** USD per million tokens, if you want `usage.costUsd` populated. Not defaulted, for the same reason as OpenRouter's. */
49
+ pricePerMillionTokens?: { input: number; output: number };
50
+ /** Injectable for tests; defaults to the global `fetch`. */
51
+ fetchImpl?: typeof fetch;
52
+ }
53
+
54
+ interface ImagesResponse extends ChatCompletionResponse {
55
+ data?: { url?: string; b64_json?: string }[];
56
+ }
57
+
58
+ export function createVercelEngine(options: VercelEngineOptions = {}): GenerationEngine {
59
+ const endpoint = (options.endpoint ?? "/api/chai/vercel").replace(/\/$/, "");
60
+ const fetchImpl = options.fetchImpl ?? fetch;
61
+ const outputKind = options.outputKind ?? "text";
62
+
63
+ return {
64
+ async generate({ modelId, prompt, attachments, onText, signal }) {
65
+ const model = modelId ?? options.modelByKind?.[outputKind] ?? options.model;
66
+ if (!model) {
67
+ throw new Error(
68
+ `vercelEngine: no model configured for outputKind "${outputKind}" — pass \`model\` or \`modelByKind.${outputKind}\`.`
69
+ );
70
+ }
71
+
72
+ if (outputKind === "image") {
73
+ if (attachments.length > 0) {
74
+ throw new Error("vercelEngine: image generation takes a prompt only; attachments aren't supported here.");
75
+ }
76
+ return generateImage({ endpoint, fetchImpl, model, prompt, signal });
77
+ }
78
+
79
+ return generateText({
80
+ endpoint,
81
+ fetchImpl,
82
+ model,
83
+ prompt,
84
+ attachments,
85
+ onText,
86
+ signal,
87
+ maxTokens: options.maxTokens,
88
+ pricePerMillionTokens: options.pricePerMillionTokens,
89
+ });
90
+ },
91
+ };
92
+ }
93
+
94
+ async function generateText(args: {
95
+ endpoint: string;
96
+ fetchImpl: typeof fetch;
97
+ model: string;
98
+ prompt: string;
99
+ attachments: DroppedMedia[];
100
+ onText?: (textSoFar: string) => void;
101
+ signal?: AbortSignal;
102
+ maxTokens?: number;
103
+ pricePerMillionTokens?: { input: number; output: number };
104
+ }): Promise<{ src: string; kind: "text"; usage?: Usage; evaluations?: Evaluation[] }> {
105
+ const body = buildChatRequest({
106
+ model: args.model,
107
+ prompt: args.prompt,
108
+ media: args.attachments,
109
+ maxTokens: args.maxTokens,
110
+ });
111
+ const res = await args.fetchImpl(`${args.endpoint}/chat`, {
112
+ method: "POST",
113
+ headers: { "Content-Type": "application/json" },
114
+ body: JSON.stringify(args.onText ? { ...body, stream: true } : body),
115
+ signal: args.signal,
116
+ });
117
+
118
+ // A rejected request comes back as plain JSON even when streaming was asked for.
119
+ if (args.onText && res.ok) {
120
+ const { text, usage, evaluations } = await readChatStream(res, args.onText);
121
+ if (!text) throw new Error("vercelEngine: empty response from the AI Gateway");
122
+ return { src: text, kind: "text", usage: toUsage(usage, args.pricePerMillionTokens), evaluations };
123
+ }
124
+
125
+ const json = (await res.json().catch(() => ({}))) as ChatCompletionResponse;
126
+ if (!res.ok || json.error) {
127
+ throw new Error(`vercelEngine: ${describeError(json, `${res.status} ${res.statusText}`)}`);
128
+ }
129
+ const text = json.choices?.[0]?.message?.content;
130
+ if (!text) throw new Error("vercelEngine: empty response from the AI Gateway");
131
+ return {
132
+ src: text,
133
+ kind: "text",
134
+ usage: toUsage(json.usage, args.pricePerMillionTokens),
135
+ evaluations: readEvaluations(json),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Image generation through `/v1/images/generations`. The OpenAI images
141
+ * shape returns either a hosted `url` or inline `b64_json`; both come back
142
+ * as a usable `src`. The gateway's image endpoint takes no aspect-ratio
143
+ * field here, so the engine doesn't send one — size is the model's default.
144
+ */
145
+ async function generateImage(args: {
146
+ endpoint: string;
147
+ fetchImpl: typeof fetch;
148
+ model: string;
149
+ prompt: string;
150
+ signal?: AbortSignal;
151
+ }): Promise<{ src: string; kind: "image" }> {
152
+ const res = await args.fetchImpl(`${args.endpoint}/images`, {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json" },
155
+ body: JSON.stringify({ model: args.model, prompt: args.prompt, n: 1 }),
156
+ signal: args.signal,
157
+ });
158
+ const json = (await res.json().catch(() => ({}))) as ImagesResponse;
159
+ if (!res.ok || json.error) {
160
+ throw new Error(`vercelEngine: ${describeError(json, `${res.status} ${res.statusText}`)}`);
161
+ }
162
+ const item = json.data?.[0];
163
+ const src = item?.url ?? (item?.b64_json ? `data:image/png;base64,${item.b64_json}` : undefined);
164
+ if (!src) throw new Error("vercelEngine: no image in the AI Gateway's response");
165
+ return { src, kind: "image" };
166
+ }
167
+
168
+ /**
169
+ * A minimal, cheap connectivity check through the same-origin route — the
170
+ * "use real inference sparingly" path. Like `pingOpenRouter` and `pingFal`,
171
+ * it's triggered once by a person from the Develop page's "Test connection"
172
+ * button, never by a test or on mount. It needs a `model` because the
173
+ * gateway has no default.
174
+ */
175
+ export async function pingVercel(
176
+ options: { endpoint?: string; model: string; fetchImpl?: typeof fetch }
177
+ ): Promise<{ ok: true; reply: string } | { ok: false; error: string }> {
178
+ const endpoint = (options.endpoint ?? "/api/chai/vercel").replace(/\/$/, "");
179
+ const fetchImpl = options.fetchImpl ?? fetch;
180
+ try {
181
+ const res = await fetchImpl(`${endpoint}/chat`, {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({
185
+ model: options.model,
186
+ messages: [{ role: "user", content: "Reply with exactly one word: OK." }],
187
+ max_tokens: 5,
188
+ }),
189
+ });
190
+ const json = (await res.json().catch(() => ({}))) as ChatCompletionResponse;
191
+ if (!res.ok || json.error) {
192
+ return { ok: false, error: describeError(json, `${res.status} ${res.statusText}`) };
193
+ }
194
+ return { ok: true, reply: json.choices?.[0]?.message?.content?.trim() ?? "" };
195
+ } catch (err) {
196
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
197
+ }
198
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./run.js";
4
4
  export * from "./media-analysis-prompts.js";
5
5
  export * from "./engines/openrouter.js";
6
6
  export * from "./engines/fal.js";
7
+ export * from "./engines/vercel.js";
7
8
  export * from "./model-routing.js";
8
9
  export * from "./region-edit.js";
9
10
  export * from "./suggested-models.js";
@@ -15,3 +16,4 @@ export * from "./media-actions.js";
15
16
  export * from "./result-format.js";
16
17
  export * from "./composer-view.js";
17
18
  export * from "./edit-card.js";
19
+ export * from "./voice-input.js";