@brotu/ai
v0.9.0
Published
One TypeScript client for generative video, image, speech and text across native provider APIs.
Readme
Kling, Seedance, Wan, Veo, Gemini, gpt-image and ElevenLabs all speak different HTTP. This package is one brotu in front of them.
Get your key (brotu_sk_…) at brotu.app. Pass a vendor key you already have and that model hits the vendor. Everything else generates on Brotu.
146 models: 74 video, 41 image, 12 speech, 19 text. Full table in CATALOG.md. That file is generated from the catalog, so it cannot drift from what the code supports.
Install
bun add @brotu/ainpm i @brotu/aipnpm add @brotu/aiClient
import { brotu } from "@brotu/ai";
const ai = brotu({
apiKey: process.env.BROTU_API_KEY!, // brotu_sk_… from https://brotu.app
providers: {
kling: { apiKey: process.env.KLING_API_KEY! },
byteplus: { apiKey: process.env.ARK_API_KEY! },
google: { apiKey: process.env.GEMINI_API_KEY! },
openai: { apiKey: process.env.OPENAI_API_KEY! },
qwen: { apiKey: process.env.QWEN_API_KEY! },
elevenlabs: { apiKey: process.env.ELEVENLABS_API_KEY! },
topaz: { apiKey: process.env.TOPAZ_API_KEY! },
},
webhook: {
url: "https://my.app/hooks/brotu",
secret: process.env.BROTU_WEBHOOK_SECRET,
},
});Your Brotu key opens the catalog. A vendor key, when you have one, generates on that vendor.
Every public call returns { data, error }. data is unusable until you narrow on error.
| Surface | Methods |
|---|---|
| ai.video | submit / generate / list / upscale |
| ai.image | submit / generate / list / upscale |
| ai.text | submit / generate / list |
| ai.audio | submit / generate / list |
| ai.jobs | poll / wait |
| ai.webhook | set / clear / get |
| hooks | constructor option: your own code on start, success and failure |
| ai.estimateCost | units, credits from the catalog, and USD when verified |
| ai.kling | motionControl, omniVideo, avatar, outpainting, imageOmni |
| ai.google | omniVideo (conversational refine) |
ai.kling and ai.google exist only when that key is set.
What your keys reach
The platform generates image and video. Speech and text are the vendor's work, so those need that vendor's key. list says which is which, per model:
const ai = brotu({ apiKey: process.env.BROTU_API_KEY! });
ai.image.list();
// [{ model: { id: "gpt-image-2", … }, runsOn: "brotu", runnable: true }, …]
ai.audio.list();
// [{ model: { id: "kling/tts", … }, runsOn: "kling", runnable: false,
// reason: "Brotu generates image and video only. Pass providers.kling to run this one." }, …]With a Brotu key alone that is 41 image and 74 video models, and the 31 speech and text models listed with the key they are waiting on. Models you cannot reach stay in the list on purpose: hiding them would hide the gap. ai.models() is the filtered view, only what runs.
kie, one key for every model
kie resells every vendor in this catalog. Configure it and it serves any model your own vendor keys do not, ahead of the Brotu fallback:
const ai = brotu({
apiKey: process.env.BROTU_API_KEY!,
providers: {
kie: { apiKey: process.env.KIE_API_KEY! },
kling: { apiKey: process.env.KLING_API_KEY! }, // this one still runs on Kling
},
});Routing order per model: its own vendor key → kie → Brotu credits. So a model moves off kie the day you get its official key, with no code change.
Give it a callback and nothing has to be polled — kie POSTs your endpoint when the task settles, and parseKieCallback turns that body into the same result a poll returns:
import { kieSnapshot, parseKieCallback } from "@brotu/ai";
ai.webhook.set("https://my.app/hooks/kie"); // becomes kie's callBackUrl
const { data: job } = await ai.video.submit({ model: "kling/v2-6", prompt: "a cat" });
// store `job` — it is plain JSON
// in your endpoint
const settled = parseKieCallback(await request.text());
const snapshot = kieSnapshot(settled, job.model);
// { status: "succeeded", result: { outputs: [{ url, mimeType }], creditsUsed } }kie names the same field differently per family — kling wants a string duration and sound, seedance an integer and generate_audio. Those two are mapped; every other model gets the common keys (prompt, image_url, resolution, aspect_ratio, …). Anything that does not fit goes in providerOptions.kie, merged last:
await ai.video.submit({
model: "wan2.6-i2v",
prompt: "x",
providerOptions: { kie: { enable_prompt_expansion: false } },
});Routing reads the catalog, so the model you pass is a catalog id — a kie market id that no catalog entry names is unknown to the client. registerModels is how a catalog of your own gets in. A model whose catalog entry carries endpoint ("/market/<kie model>") uses that id, so a catalog of your own routes through kie without touching the SDK. That is also the fix when kie names a model differently from this catalog and the built-in table does not cover it — kie answers an unknown model with an error rather than a wrong generation.
Video
submit returns a job handle. generate waits. Video takes minutes, so do not generate inside a request handler.
const { data: job, error } = await ai.video.submit({
model: "kling/v2-6",
prompt: "a cat wearing sunglasses, cinematic",
duration: 5,
aspectRatio: "16:9",
});
if (error) return console.error(error.code, error.message);
await db.jobs.insert(job);
const { data } = await ai.jobs.wait(job);
data.outputs[0].url;Image-to-video is the same shape with imageUrl.
Image
const { data, error } = await ai.image.generate({
model: "gpt-image-2",
prompt: "a ginger cat on a windowsill, soft light",
quality: "medium",
});OpenAI and Gemini return a data URI. Give the client storage if you want a real URL.
Text
const { data, error } = await ai.text.generate({
model: "gpt-5.6-luna",
prompt: "summarize this in two sentences",
systemPrompt: "be brief",
maxTokens: 200,
});
data?.outputs[0].raw?.text;OpenAI: gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna on the Responses API.
Gemini: gemini-3.7-flash and the current ladder on the Interactions API.
Qwen: qwen3.8-max, qwen-plus, qwen-turbo, and the rest.
Pass referenceImages for vision. The output is data:text/plain plus raw.text. Token totals are only known after the call, so estimateCost reports the rate and leaves usd null.
Audio
prompt is the spoken text.
const { data, error } = await ai.audio.generate({
model: "gemini-3.1-flash-tts-preview",
prompt: "Say cheerfully: good morning",
voice: "Puck",
});Gemini TTS: 30 official voices, default Kore.
Qwen: Cherry, Ethan, and the rest on qwen3-tts-flash.
ElevenLabs: you must pass voice. There is no default.
Realtime / Live WebSocket models are not on this surface.
CLI
The terminal lives in ../../cli (@brotu/cli). This package is the library only.
Jobs
const { data: snapshot } = await ai.jobs.poll(job);
const { data: result } = await ai.jobs.wait(job, { timeoutMs: 420_000 });
if (result === undefined && snapshot?.status === "pending") {
// still running
}Webhook
Register a URL on the client. When generate, jobs.wait or a terminal jobs.poll settles, the SDK POSTs the result there. A down hook never fails the generation.
ai.webhook.set("https://my.app/hooks/brotu");
ai.webhook.clear();Per request, if one generation should go somewhere else:
await ai.video.submit({
model: "kling/v2-6",
prompt: "a cat",
webhook: "https://my.app/hooks/this-one",
});The POST is JSON. Check x-brotu-event and, if you set a secret, x-brotu-webhook-secret.
{
"event": "generation.succeeded",
"jobId": "task-1",
"provider": "kling",
"model": "kling/v2-6",
"kind": "video",
"outputs": [{ "url": "https://…", "mimeType": "video/mp4" }],
"completedAt": "2026-08-16T12:00:00.000Z"
}generation.failed carries error: { code, message } and no outputs. Timeouts and routing errors do not fire the hook.
Error codes: unknown_model, missing_key, unsupported_provider, invalid_request, provider_error, timeout.
Hooks
Same moments as the webhook, but a function instead of an endpoint — so it runs in your process, with your imports.
const ai = brotu({
apiKey: process.env.BROTU_API_KEY!,
providers: { kling: { apiKey: process.env.KLING_API_KEY! } },
hooks: {
onVideoLoading: (e) => console.log("generating", e.model),
onVideoSuccess: (e) => sendEmail("your video is ready", e.outputs?.[0]?.url),
onVideoError: (e) => sendEmail("your video failed", e.error?.message),
},
});One optional callback per kind and stage — on{Image,Video,Audio,Text}{Loading,Success,Error}, twelve typed names. Loading fires once the model is routed, before the provider is called; Success and Error fire wherever the webhook fires, deduped by job id. A hook that throws never fails the generation.
The event is HookEvent: kind, stage, provider, model, jobId, outputs, error, metadata, creditsUsed, processingTimeMs, at.
Motion control, avatars, omni
Not on ai.video. A shared signature would be a lie.
if (!ai.kling) throw new Error("needs a kling key");
const { data: motion } = await ai.kling.motionControl({
model: "kling-2.6",
imageUrl: "https://example.com/character.png",
videoUrl: "https://example.com/dance.mp4",
characterOrientation: "video",
resolution: "1080p",
});
const { data: talking } = await ai.kling.avatar({
imageUrl: "https://example.com/portrait.png",
soundFileUrl: "https://example.com/voice.mp3",
prompt: "warm, speaking to camera",
mode: "pro",
});
const { data: omni } = await ai.kling.omniVideo({
model: "kling-3.0-omni",
prompt: "@hero walks through @backdrop",
references: [
{ type: "refer_image", url: "https://example.com/hero.png", id: "hero" },
{ type: "refer_image", url: "https://example.com/street.png", id: "backdrop" },
],
duration: 10,
aspectRatio: "9:16",
});
await ai.kling.outpainting({
imageUrl: "https://example.com/square.png",
up: 0,
down: 0,
left: 0.5,
right: 0.5,
prompt: "continue the street",
});Google Omni is the one model you refine by talking to the previous result:
if (!ai.google) throw new Error("needs a google key");
const first = await ai.google.omniVideo({
model: "gemini-omni-flash-preview",
prompt: "a cat walks through rain at night",
});
await ai.google.omniVideo({
model: "gemini-omni-flash-preview",
prompt: "make it dawn, keep the camera",
previousInteractionId: first.data?.interactionId,
});Cost and storage
const { data } = await ai.estimateCost("video", {
model: "kling/v3",
prompt: "x",
duration: 5,
resolution: "720p",
});data.credits is the catalog's credit price for that request, or null where the model carries no rate.
To bill your own users per generation, pass credits on the request. It comes back as creditsUsed on the result, the hook and the webhook, so your ledger debits one number instead of recomputing it per model — nothing is debited here.
const estimate = await ai.estimateCost("video", { model: "kling/v3", prompt: "x", duration: 5 });
await ai.video.generate({
model: "kling/v3",
prompt: "x",
duration: 5,
credits: estimate.data?.credits ?? 0,
metadata: { userId: "u_1" },
});Provider URLs expire. Pass storage (any S3 API) and outputs land in your bucket. outputs[].url is your copy; sourceUrl is the original. @aws-sdk/client-s3 is an optional peer.
storage: {
bucket: "my-bucket",
region: "us-east-2",
accessKeyId: process.env.S3_KEY!,
secretAccessKey: process.env.S3_SECRET!,
endpoint: "https://....r2.cloudflarestorage.com",
publicUrl: "https://cdn.example.com",
}Regions and extra models
providers: {
kling: { apiKey, baseUrl: "https://api-beijing.klingai.com" },
}import { registerModels } from "@brotu/ai";
registerModels([{ id: "kling/v3", provider: "kling" /* ... */ }]);Credits, quotas, accounts and job persistence stay on your server. This package maps params, polls if needed, and hands back URLs.
