npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/ai
npm i @brotu/ai
pnpm add @brotu/ai

Client

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.