@brotu/ai 0.2.0 → 0.4.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/dist/index.cjs CHANGED
@@ -32,7 +32,9 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  BYTEPLUS_CATALOG: () => BYTEPLUS_CATALOG,
34
34
  BYTEPLUS_MODELS: () => BYTEPLUS_MODELS,
35
+ BrotuAdapter: () => BrotuAdapter,
35
36
  BytePlusAdapter: () => BytePlusAdapter,
37
+ DEFAULT_BROTU_API_URL: () => DEFAULT_BROTU_API_URL,
36
38
  ELEVENLABS_CATALOG: () => ELEVENLABS_CATALOG,
37
39
  ELEVENLABS_MODELS: () => ELEVENLABS_MODELS,
38
40
  ELEVENLABS_OUTPUT_FORMATS: () => ELEVENLABS_OUTPUT_FORMATS,
@@ -61,6 +63,7 @@ __export(index_exports, {
61
63
  QwenAdapter: () => QwenAdapter,
62
64
  brotuClient: () => brotuClient,
63
65
  createS3Storage: () => createS3Storage,
66
+ deliverWebhook: () => deliverWebhook,
64
67
  fail: () => fail,
65
68
  getAvailableModels: () => getAvailableModels,
66
69
  getModel: () => getModel,
@@ -72,7 +75,8 @@ __export(index_exports, {
72
75
  persistOutputs: () => persistOutputs,
73
76
  registerModels: () => registerModels,
74
77
  resetCatalog: () => resetCatalog,
75
- resolveProvider: () => resolveProvider
78
+ resolveProvider: () => resolveProvider,
79
+ resolveWebhook: () => resolveWebhook
76
80
  });
77
81
  module.exports = __toCommonJS(index_exports);
78
82
 
@@ -1265,8 +1269,16 @@ var PROVIDER_BASE_URLS = {
1265
1269
  elevenlabs: "https://api.elevenlabs.io",
1266
1270
  google: "https://generativelanguage.googleapis.com",
1267
1271
  openai: "https://api.openai.com",
1268
- qwen: "https://dashscope-intl.aliyuncs.com"
1272
+ qwen: "https://dashscope-intl.aliyuncs.com",
1273
+ brotu: "https://api.brotu.app"
1269
1274
  };
1275
+ var DEFAULT_BROTU_API_URL = PROVIDER_BASE_URLS.brotu;
1276
+ function vendorProviders(options) {
1277
+ return options.providers ?? {};
1278
+ }
1279
+ function brotuApiKey(options) {
1280
+ return options.apiKey?.trim() || void 0;
1281
+ }
1270
1282
  var BUILT_IN = [
1271
1283
  ...KLING_CATALOG,
1272
1284
  ...KLING_AUDIO_CATALOG,
@@ -1322,24 +1334,36 @@ function resolveProvider(modelId, options) {
1322
1334
  `Model "${modelId}" declares no provider, so nothing knows how to run it.`
1323
1335
  );
1324
1336
  }
1325
- const configured = options.providers[id];
1326
- if (!configured) {
1327
- const owned = Object.keys(options.providers).join(", ") || "none";
1328
- throw new Error(
1329
- `Model "${modelId}" runs on "${id}", but no API key was given for it (configured: ${owned}).`
1330
- );
1337
+ const configured = vendorProviders(options)[id];
1338
+ if (configured) {
1339
+ const baseUrl = configured.baseUrl ?? PROVIDER_BASE_URLS[id];
1340
+ if (!baseUrl) {
1341
+ throw new Error(
1342
+ `Provider "${id}" has no known base URL \u2014 pass providers.${id}.baseUrl.`
1343
+ );
1344
+ }
1345
+ return { id, apiKey: configured.apiKey, baseUrl: baseUrl.replace(/\/$/, "") };
1331
1346
  }
1332
- const baseUrl = configured.baseUrl ?? PROVIDER_BASE_URLS[id];
1333
- if (!baseUrl) {
1334
- throw new Error(
1335
- `Provider "${id}" has no known base URL \u2014 pass providers.${id}.baseUrl.`
1336
- );
1347
+ const platformKey = brotuApiKey(options);
1348
+ if (platformKey) {
1349
+ return {
1350
+ id: "brotu",
1351
+ apiKey: platformKey,
1352
+ baseUrl: (options.apiUrl ?? DEFAULT_BROTU_API_URL).replace(/\/$/, "")
1353
+ };
1337
1354
  }
1338
- return { id, apiKey: configured.apiKey, baseUrl: baseUrl.replace(/\/$/, "") };
1355
+ const owned = Object.keys(vendorProviders(options)).join(", ") || "none";
1356
+ throw new Error(
1357
+ `Model "${modelId}" runs on "${id}", but no API key was given for it (configured: ${owned}). Pass a Brotu key to generate on the platform.`
1358
+ );
1339
1359
  }
1340
1360
  function getAvailableModels(options) {
1361
+ if (brotuApiKey(options)) {
1362
+ return catalog.filter((model) => Boolean(model.provider));
1363
+ }
1364
+ const vendors = vendorProviders(options);
1341
1365
  return catalog.filter(
1342
- (model) => model.provider && model.provider in options.providers
1366
+ (model) => model.provider && model.provider in vendors
1343
1367
  );
1344
1368
  }
1345
1369
 
@@ -1369,12 +1393,316 @@ function estimateFor(provider, type, params, defaults) {
1369
1393
  };
1370
1394
  }
1371
1395
 
1396
+ // src/adapters/brotu.adapter.ts
1397
+ var DEFAULT_API_URL = "https://api.brotu.app";
1398
+ var POLL_INTERVAL_MS = 3e3;
1399
+ var DEFAULT_MAX_POLL_ATTEMPTS = 400;
1400
+ var PLATFORM_MODEL_IDS = {
1401
+ "kling/v2-6": "kling-2.6",
1402
+ "kling/v3": "kling-3.0/video",
1403
+ "dreamina-seedance-2-5-260628": "bytedance/seedance-2-5",
1404
+ "dreamina-seedance-2-0-260128": "bytedance/seedance-2",
1405
+ "dreamina-seedance-2-0-fast-260128": "bytedance/seedance-2-fast",
1406
+ "gpt-image-1.5": "gpt-image/1.5"
1407
+ };
1408
+ var UNSUPPORTED_VIA_CREDITS = "Speech and text generate on the vendor. Pass that provider's key.";
1409
+ var BrotuAdapter = class {
1410
+ constructor(opts) {
1411
+ this.opts = opts;
1412
+ }
1413
+ opts;
1414
+ providerName = "brotu";
1415
+ supportedTypes = ["image", "video"];
1416
+ workspaceIdCache;
1417
+ get origin() {
1418
+ return (this.opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
1419
+ }
1420
+ headers() {
1421
+ return {
1422
+ Authorization: `Bearer ${this.opts.apiKey}`,
1423
+ "Content-Type": "application/json"
1424
+ };
1425
+ }
1426
+ async workspaceId() {
1427
+ if (this.opts.workspaceId) return this.opts.workspaceId;
1428
+ if (this.workspaceIdCache) return this.workspaceIdCache;
1429
+ const payload = await this.request("/api/v1/studio/default-workspace");
1430
+ const id = payload.workspaceId?.trim();
1431
+ if (!id) {
1432
+ throw new Error(
1433
+ "This Brotu account has no workspace. Open https://brotu.app and create one."
1434
+ );
1435
+ }
1436
+ this.workspaceIdCache = id;
1437
+ return id;
1438
+ }
1439
+ studioPath(path, workspaceId) {
1440
+ const joiner = path.includes("?") ? "&" : "?";
1441
+ return `${path}${joiner}workspaceId=${encodeURIComponent(workspaceId)}`;
1442
+ }
1443
+ async request(path, init) {
1444
+ const response = await fetch(`${this.origin}${path}`, {
1445
+ ...init,
1446
+ headers: { ...this.headers(), ...init?.headers }
1447
+ });
1448
+ const text = await response.text();
1449
+ let body = void 0;
1450
+ if (text) {
1451
+ try {
1452
+ body = JSON.parse(text);
1453
+ } catch {
1454
+ body = text;
1455
+ }
1456
+ }
1457
+ if (!response.ok) {
1458
+ const parsed = body;
1459
+ const message = parsed && typeof parsed === "object" ? parsed.error?.message || parsed.message || text : text;
1460
+ throw new Error(message || `Brotu API ${response.status}`);
1461
+ }
1462
+ return body;
1463
+ }
1464
+ platformModel(modelId) {
1465
+ return PLATFORM_MODEL_IDS[modelId] ?? modelId;
1466
+ }
1467
+ async startGeneration(kind, params) {
1468
+ const modelId = params.model ?? "";
1469
+ const workspaceId = await this.workspaceId();
1470
+ const path = kind === "video" ? "/api/v1/studio/videos" : "/api/v1/studio/images";
1471
+ const body = kind === "video" ? this.videoBody(params, modelId) : this.imageBody(params, modelId);
1472
+ const result = await this.request(
1473
+ this.studioPath(path, workspaceId),
1474
+ { method: "POST", body: JSON.stringify(body) }
1475
+ );
1476
+ const generationId = result.generationId?.trim();
1477
+ if (!generationId) {
1478
+ throw new Error("Brotu accepted the request but returned no generation id.");
1479
+ }
1480
+ return { generationId, workspaceId };
1481
+ }
1482
+ imageBody(params, modelId) {
1483
+ return {
1484
+ prompt: params.prompt,
1485
+ model: this.platformModel(modelId),
1486
+ aspectRatio: params.aspectRatio,
1487
+ resolution: params.resolution,
1488
+ negativePrompt: params.negativePrompt,
1489
+ outputFormat: params.outputFormat,
1490
+ seed: params.seed,
1491
+ referenceImages: params.referenceImages
1492
+ };
1493
+ }
1494
+ videoBody(params, modelId) {
1495
+ return {
1496
+ prompt: params.prompt,
1497
+ model: this.platformModel(modelId),
1498
+ duration: params.duration,
1499
+ resolution: params.resolution,
1500
+ aspectRatio: params.aspectRatio,
1501
+ mode: params.mode,
1502
+ withAudio: params.withAudio,
1503
+ seed: params.seed,
1504
+ imageUrl: params.imageUrl,
1505
+ imageUrls: params.imageUrls,
1506
+ videoUrl: params.videoUrl,
1507
+ videoUrls: params.videoUrls,
1508
+ referenceImages: params.referenceImages
1509
+ };
1510
+ }
1511
+ async run(kind, params) {
1512
+ const startedAt = Date.now();
1513
+ const modelId = params.model ?? "(none)";
1514
+ const failure = (error) => ({
1515
+ success: false,
1516
+ outputs: [],
1517
+ creditsUsed: 0,
1518
+ provider: this.providerName,
1519
+ model: modelId,
1520
+ processingTimeMs: Date.now() - startedAt,
1521
+ error
1522
+ });
1523
+ let submitted;
1524
+ try {
1525
+ submitted = await this.startGeneration(kind, params);
1526
+ } catch (error) {
1527
+ return failure(error instanceof Error ? error.message : String(error));
1528
+ }
1529
+ const pollEndpoint = this.studioPath(
1530
+ `/api/v1/studio/generations/${submitted.generationId}`,
1531
+ submitted.workspaceId
1532
+ );
1533
+ if (isSubmitMode()) {
1534
+ throw new PendingJob(submitted.generationId, pollEndpoint);
1535
+ }
1536
+ const job = {
1537
+ id: submitted.generationId,
1538
+ provider: this.providerName,
1539
+ model: modelId,
1540
+ kind,
1541
+ pollEndpoint,
1542
+ params,
1543
+ submittedAt: new Date(startedAt).toISOString()
1544
+ };
1545
+ const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS;
1546
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1547
+ const snapshot = await this.completeJob(job);
1548
+ if (snapshot.status === "failed") {
1549
+ return failure(snapshot.error ?? "Brotu generation failed.");
1550
+ }
1551
+ if (snapshot.status === "succeeded" && snapshot.result) {
1552
+ return { ...snapshot.result, processingTimeMs: Date.now() - startedAt };
1553
+ }
1554
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
1555
+ }
1556
+ return failure(
1557
+ `Brotu generation ${submitted.generationId} did not finish after ${maxAttempts} checks.`
1558
+ );
1559
+ }
1560
+ async completeJob(job) {
1561
+ const workspaceId = this.workspaceFromPoll(job.pollEndpoint) ?? await this.workspaceId();
1562
+ const path = this.studioPath(
1563
+ `/api/v1/studio/generations/${job.id}`,
1564
+ workspaceId
1565
+ );
1566
+ const generation = await this.request(path);
1567
+ const status = (generation.status ?? "").toLowerCase();
1568
+ if (status === "failed") {
1569
+ return {
1570
+ status: "failed",
1571
+ error: generation.errorMessage ?? "Brotu generation failed."
1572
+ };
1573
+ }
1574
+ if (status !== "completed") {
1575
+ return { status: "pending" };
1576
+ }
1577
+ const outputs = outputsFrom(generation, job.kind);
1578
+ return {
1579
+ status: "succeeded",
1580
+ result: {
1581
+ success: true,
1582
+ outputs,
1583
+ creditsUsed: generation.creditsUsed ?? 0,
1584
+ provider: this.providerName,
1585
+ model: job.model,
1586
+ processingTimeMs: 0
1587
+ }
1588
+ };
1589
+ }
1590
+ workspaceFromPoll(pollEndpoint) {
1591
+ if (!pollEndpoint) return void 0;
1592
+ try {
1593
+ const url = new URL(pollEndpoint, "https://brotu.invalid");
1594
+ return url.searchParams.get("workspaceId") ?? void 0;
1595
+ } catch {
1596
+ return void 0;
1597
+ }
1598
+ }
1599
+ generateImage(params) {
1600
+ return this.run("image", params);
1601
+ }
1602
+ generateVideo(params) {
1603
+ return this.run("video", params);
1604
+ }
1605
+ async generateText(params) {
1606
+ return {
1607
+ success: false,
1608
+ outputs: [],
1609
+ creditsUsed: 0,
1610
+ provider: this.providerName,
1611
+ model: params.model ?? "(none)",
1612
+ processingTimeMs: 0,
1613
+ error: UNSUPPORTED_VIA_CREDITS
1614
+ };
1615
+ }
1616
+ async generateAudio(params) {
1617
+ return {
1618
+ success: false,
1619
+ outputs: [],
1620
+ creditsUsed: 0,
1621
+ provider: this.providerName,
1622
+ model: params.model ?? "(none)",
1623
+ processingTimeMs: 0,
1624
+ error: UNSUPPORTED_VIA_CREDITS
1625
+ };
1626
+ }
1627
+ async estimateCost(type, params) {
1628
+ if (type === "audio" || type === "text") {
1629
+ const estimate = estimateFor(this.providerName, type, params);
1630
+ return {
1631
+ ...estimate,
1632
+ usd: null,
1633
+ note: UNSUPPORTED_VIA_CREDITS
1634
+ };
1635
+ }
1636
+ try {
1637
+ const workspaceId = await this.workspaceId();
1638
+ const video = params;
1639
+ const image = params;
1640
+ const payload = await this.request(
1641
+ this.studioPath("/api/v1/studio/estimate", workspaceId),
1642
+ {
1643
+ method: "POST",
1644
+ body: JSON.stringify({
1645
+ model: this.platformModel(params.model ?? ""),
1646
+ duration: video.duration,
1647
+ resolution: video.resolution ?? image.resolution,
1648
+ aspectRatio: video.aspectRatio ?? image.aspectRatio,
1649
+ withAudio: video.withAudio,
1650
+ hasReferenceImage: Boolean(
1651
+ video.imageUrl || video.imageUrls?.length || video.referenceImages?.length || image.referenceImages?.length
1652
+ ),
1653
+ hasReferenceVideo: Boolean(video.videoUrl || video.videoUrls?.length),
1654
+ quality: image.quality
1655
+ })
1656
+ }
1657
+ );
1658
+ const credits = payload.estimatedCredits ?? 0;
1659
+ return {
1660
+ unit: type === "video" ? "second" : "image",
1661
+ units: credits,
1662
+ usd: null,
1663
+ note: `${credits} Brotu credit${credits === 1 ? "" : "s"}. A vendor key generates on that provider.`,
1664
+ provider: this.providerName,
1665
+ model: params.model ?? ""
1666
+ };
1667
+ } catch (error) {
1668
+ const estimate = estimateFor(this.providerName, type, params);
1669
+ return {
1670
+ ...estimate,
1671
+ usd: null,
1672
+ note: error instanceof Error ? error.message : "Could not estimate Brotu credits."
1673
+ };
1674
+ }
1675
+ }
1676
+ supportsModel() {
1677
+ return true;
1678
+ }
1679
+ getAvailableModels() {
1680
+ return [];
1681
+ }
1682
+ };
1683
+ function outputsFrom(generation, kind) {
1684
+ const bucket = generation.outputs;
1685
+ const urls = kind === "video" ? bucket?.videos ?? bucket?.images ?? [] : bucket?.images ?? bucket?.videos ?? [];
1686
+ const copies = bucket?.copies ?? [];
1687
+ const mimeType = kind === "video" ? "video/mp4" : "image/png";
1688
+ const fromUrls = urls.filter(Boolean).map((url) => ({
1689
+ url,
1690
+ mimeType
1691
+ }));
1692
+ if (fromUrls.length > 0) return fromUrls;
1693
+ return copies.filter(Boolean).map((url) => ({
1694
+ url,
1695
+ mimeType: "text/plain",
1696
+ raw: { text: url }
1697
+ }));
1698
+ }
1699
+
1372
1700
  // src/adapters/byteplus.adapter.ts
1373
1701
  var DEFAULT_BASE_URL = "https://ark.ap-southeast.bytepluses.com";
1374
1702
  var TASKS_PATH = "/api/v3/contents/generations/tasks";
1375
1703
  var IMAGES_PATH = "/api/v3/images/generations";
1376
- var POLL_INTERVAL_MS = 5e3;
1377
- var DEFAULT_MAX_POLL_ATTEMPTS = 240;
1704
+ var POLL_INTERVAL_MS2 = 5e3;
1705
+ var DEFAULT_MAX_POLL_ATTEMPTS2 = 240;
1378
1706
  var BytePlusAdapter = class {
1379
1707
  providerName = "byteplus";
1380
1708
  supportedTypes = ["image", "video"];
@@ -1574,7 +1902,7 @@ var BytePlusAdapter = class {
1574
1902
  params,
1575
1903
  submittedAt: new Date(startedAt).toISOString()
1576
1904
  };
1577
- const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS;
1905
+ const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS2;
1578
1906
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1579
1907
  const snapshot = await this.completeJob(job);
1580
1908
  if (snapshot.status === "failed") {
@@ -1583,7 +1911,7 @@ var BytePlusAdapter = class {
1583
1911
  if (snapshot.status === "succeeded" && snapshot.result) {
1584
1912
  return { ...snapshot.result, processingTimeMs: Date.now() - startedAt };
1585
1913
  }
1586
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
1914
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS2));
1587
1915
  }
1588
1916
  return failure(
1589
1917
  `Ark task ${taskId} did not finish after ${maxAttempts} checks.`
@@ -1860,8 +2188,8 @@ var ElevenLabsAdapter = class {
1860
2188
  // src/adapters/google.adapter.ts
1861
2189
  var DEFAULT_BASE_URL3 = "https://generativelanguage.googleapis.com";
1862
2190
  var INTERACTIONS_PATH = "/v1beta/interactions";
1863
- var POLL_INTERVAL_MS2 = 1e4;
1864
- var DEFAULT_MAX_POLL_ATTEMPTS2 = 120;
2191
+ var POLL_INTERVAL_MS3 = 1e4;
2192
+ var DEFAULT_MAX_POLL_ATTEMPTS3 = 120;
1865
2193
  var GoogleAdapter = class {
1866
2194
  providerName = "google";
1867
2195
  supportedTypes = [
@@ -2133,7 +2461,7 @@ var GoogleAdapter = class {
2133
2461
  params,
2134
2462
  submittedAt: new Date(startedAt).toISOString()
2135
2463
  };
2136
- const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS2;
2464
+ const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS3;
2137
2465
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2138
2466
  const snapshot = await this.completeJob(job);
2139
2467
  if (snapshot.status === "failed") {
@@ -2142,7 +2470,7 @@ var GoogleAdapter = class {
2142
2470
  if (snapshot.status === "succeeded" && snapshot.result) {
2143
2471
  return { ...snapshot.result, processingTimeMs: Date.now() - startedAt };
2144
2472
  }
2145
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS2));
2473
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS3));
2146
2474
  }
2147
2475
  return failure(`Veo operation ${operation} did not finish in time.`);
2148
2476
  }
@@ -2357,8 +2685,8 @@ var GoogleAdapter = class {
2357
2685
 
2358
2686
  // src/adapters/kling.adapter.ts
2359
2687
  var DEFAULT_BASE_URL4 = "https://api-singapore.klingai.com";
2360
- var POLL_INTERVAL_MS3 = 5e3;
2361
- var DEFAULT_MAX_POLL_ATTEMPTS3 = 240;
2688
+ var POLL_INTERVAL_MS4 = 5e3;
2689
+ var DEFAULT_MAX_POLL_ATTEMPTS4 = 240;
2362
2690
  function stateOf(task) {
2363
2691
  const raw = (task.status ?? task.task_status ?? "").toLowerCase();
2364
2692
  if (raw === "failed") return "failed";
@@ -2555,7 +2883,7 @@ var KlingAdapter = class {
2555
2883
  params,
2556
2884
  submittedAt: new Date(startedAt).toISOString()
2557
2885
  };
2558
- const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS3;
2886
+ const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS4;
2559
2887
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2560
2888
  const snapshot = await this.completeJob(job);
2561
2889
  if (snapshot.status === "failed") {
@@ -2564,7 +2892,7 @@ var KlingAdapter = class {
2564
2892
  if (snapshot.status === "succeeded" && snapshot.result) {
2565
2893
  return { ...snapshot.result, processingTimeMs: Date.now() - startedAt };
2566
2894
  }
2567
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS3));
2895
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS4));
2568
2896
  }
2569
2897
  return failure(
2570
2898
  `Kling task ${submitted.taskId} did not finish after ${maxAttempts} checks.`
@@ -2935,8 +3263,8 @@ var OpenAIAdapter = class {
2935
3263
  // src/adapters/qwen.adapter.ts
2936
3264
  var DEFAULT_BASE_URL6 = "https://dashscope-intl.aliyuncs.com";
2937
3265
  var TASKS_PATH2 = "/api/v1/tasks";
2938
- var POLL_INTERVAL_MS4 = 5e3;
2939
- var DEFAULT_MAX_POLL_ATTEMPTS4 = 240;
3266
+ var POLL_INTERVAL_MS5 = 5e3;
3267
+ var DEFAULT_MAX_POLL_ATTEMPTS5 = 240;
2940
3268
  var QwenAdapter = class {
2941
3269
  providerName = "qwen";
2942
3270
  supportedTypes = ["image", "video"];
@@ -3143,7 +3471,7 @@ var QwenAdapter = class {
3143
3471
  params,
3144
3472
  submittedAt: new Date(startedAt).toISOString()
3145
3473
  };
3146
- const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS4;
3474
+ const maxAttempts = this.opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS5;
3147
3475
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
3148
3476
  const snapshot = await this.completeJob(job);
3149
3477
  if (snapshot.status === "failed") {
@@ -3152,7 +3480,7 @@ var QwenAdapter = class {
3152
3480
  if (snapshot.status === "succeeded" && snapshot.result) {
3153
3481
  return { ...snapshot.result, processingTimeMs: Date.now() - startedAt };
3154
3482
  }
3155
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS4));
3483
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS5));
3156
3484
  }
3157
3485
  return failure(
3158
3486
  `DashScope task ${taskId} did not finish after ${maxAttempts} checks.`
@@ -3534,6 +3862,45 @@ async function persistOutputs(storage, outputs) {
3534
3862
  );
3535
3863
  }
3536
3864
 
3865
+ // src/lib/webhook.ts
3866
+ function resolveWebhook(value) {
3867
+ if (!value) return void 0;
3868
+ if (typeof value === "string") {
3869
+ return isHttpUrl(value) ? { url: value } : void 0;
3870
+ }
3871
+ if (!isHttpUrl(value.url)) return void 0;
3872
+ return {
3873
+ url: value.url,
3874
+ secret: value.secret,
3875
+ headers: value.headers
3876
+ };
3877
+ }
3878
+ function isHttpUrl(value) {
3879
+ try {
3880
+ const parsed = new URL(value);
3881
+ return parsed.protocol === "https:" || parsed.protocol === "http:";
3882
+ } catch {
3883
+ return false;
3884
+ }
3885
+ }
3886
+ async function deliverWebhook(config, payload) {
3887
+ try {
3888
+ await fetch(config.url, {
3889
+ method: "POST",
3890
+ headers: {
3891
+ "content-type": "application/json",
3892
+ "user-agent": "@brotu/ai",
3893
+ "x-brotu-event": payload.event,
3894
+ ...config.secret ? { "x-brotu-webhook-secret": config.secret } : {},
3895
+ ...config.headers
3896
+ },
3897
+ body: JSON.stringify(payload),
3898
+ signal: AbortSignal.timeout(1e4)
3899
+ });
3900
+ } catch {
3901
+ }
3902
+ }
3903
+
3537
3904
  // src/client.ts
3538
3905
  var NATIVE_PROVIDERS = [
3539
3906
  "byteplus",
@@ -3544,10 +3911,17 @@ var NATIVE_PROVIDERS = [
3544
3911
  "qwen"
3545
3912
  ];
3546
3913
  function brotuClient(options) {
3547
- if (Object.keys(options.providers).length === 0) {
3548
- throw new Error("brotuClient requires at least one provider API key.");
3914
+ const apiKey = options.apiKey?.trim();
3915
+ if (!apiKey) {
3916
+ throw new Error(
3917
+ "Pass a Brotu API key (brotu_sk_\u2026). Get one at https://brotu.app."
3918
+ );
3549
3919
  }
3920
+ const optionsWithKey = { ...options, apiKey };
3921
+ const vendors = optionsWithKey.providers ?? {};
3550
3922
  registerModels(options.models);
3923
+ let registeredWebhook = resolveWebhook(options.webhook);
3924
+ const notifiedJobIds = /* @__PURE__ */ new Set();
3551
3925
  const storage = options.storage ? createS3Storage(options.storage) : void 0;
3552
3926
  const shouldPersist = storage && options.storage?.persistOutputs !== false;
3553
3927
  const adapters = /* @__PURE__ */ new Map();
@@ -3568,7 +3942,7 @@ function brotuClient(options) {
3568
3942
  }
3569
3943
  let provider;
3570
3944
  try {
3571
- provider = resolveProvider(modelId, options);
3945
+ provider = resolveProvider(modelId, optionsWithKey);
3572
3946
  } catch (error) {
3573
3947
  return failFrom("missing_key", error, { model: modelId });
3574
3948
  }
@@ -3589,6 +3963,13 @@ function brotuClient(options) {
3589
3963
  return ok({ adapter, provider: provider.id, model: modelId });
3590
3964
  }
3591
3965
  function buildAdapter(provider) {
3966
+ if (provider.id === "brotu") {
3967
+ return new BrotuAdapter({
3968
+ apiKey,
3969
+ apiUrl: optionsWithKey.apiUrl,
3970
+ workspaceId: optionsWithKey.workspaceId
3971
+ });
3972
+ }
3592
3973
  if (provider.id === "kling") {
3593
3974
  return new KlingAdapter({
3594
3975
  apiKey: provider.apiKey,
@@ -3637,6 +4018,31 @@ function brotuClient(options) {
3637
4018
  return adapter.generateAudio(params);
3638
4019
  return adapter.generateText(params);
3639
4020
  }
4021
+ function webhookFor(params) {
4022
+ return resolveWebhook(params?.webhook) ?? registeredWebhook;
4023
+ }
4024
+ async function notifySettled(input) {
4025
+ const config = webhookFor(input.params);
4026
+ if (!config) return;
4027
+ const jobId = input.job?.id;
4028
+ if (jobId) {
4029
+ if (notifiedJobIds.has(jobId)) return;
4030
+ notifiedJobIds.add(jobId);
4031
+ }
4032
+ const payload = {
4033
+ event: input.event,
4034
+ jobId,
4035
+ provider: input.provider ?? input.job?.provider,
4036
+ model: input.model ?? input.job?.model,
4037
+ kind: input.kind ?? input.job?.kind,
4038
+ outputs: input.outputs,
4039
+ error: input.error ? { code: input.error.code, message: input.error.message } : void 0,
4040
+ metadata: input.metadata ?? input.job?.metadata,
4041
+ processingTimeMs: input.processingTimeMs,
4042
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
4043
+ };
4044
+ await deliverWebhook(config, payload);
4045
+ }
3640
4046
  async function toGeneration(raw, metadata) {
3641
4047
  if (!raw.success) {
3642
4048
  return fail({
@@ -3659,15 +4065,50 @@ function brotuClient(options) {
3659
4065
  const routed = route(params.model);
3660
4066
  if (routed.error) return fail(routed.error);
3661
4067
  try {
3662
- return await toGeneration(
4068
+ const result = await toGeneration(
3663
4069
  await generateWith(routed.data.adapter, kind, params),
3664
4070
  params.metadata
3665
4071
  );
4072
+ if (result.error) {
4073
+ await notifySettled({
4074
+ event: "generation.failed",
4075
+ params,
4076
+ kind,
4077
+ provider: routed.data.provider,
4078
+ model: routed.data.model,
4079
+ error: result.error,
4080
+ metadata: params.metadata
4081
+ });
4082
+ return result;
4083
+ }
4084
+ await notifySettled({
4085
+ event: "generation.succeeded",
4086
+ params,
4087
+ kind,
4088
+ provider: result.data.provider,
4089
+ model: result.data.model,
4090
+ outputs: result.data.outputs,
4091
+ metadata: result.data.metadata,
4092
+ processingTimeMs: result.data.processingTimeMs
4093
+ });
4094
+ return result;
3666
4095
  } catch (error) {
3667
- return failFrom("provider_error", error, {
4096
+ const failed = failFrom("provider_error", error, {
3668
4097
  provider: routed.data.provider,
3669
4098
  model: routed.data.model
3670
4099
  });
4100
+ if (failed.error) {
4101
+ await notifySettled({
4102
+ event: "generation.failed",
4103
+ params,
4104
+ kind,
4105
+ provider: routed.data.provider,
4106
+ model: routed.data.model,
4107
+ error: failed.error,
4108
+ metadata: params.metadata
4109
+ });
4110
+ }
4111
+ return failed;
3671
4112
  }
3672
4113
  }
3673
4114
  async function submit(kind, params) {
@@ -3686,18 +4127,40 @@ function brotuClient(options) {
3686
4127
  () => generateWith(routed.data.adapter, kind, params)
3687
4128
  );
3688
4129
  if (!raw.success) {
3689
- return fail({
4130
+ const error = {
3690
4131
  code: "provider_error",
3691
4132
  message: raw.error ?? "The provider rejected the request.",
3692
4133
  provider: raw.provider,
3693
4134
  model: raw.model
4135
+ };
4136
+ await notifySettled({
4137
+ event: "generation.failed",
4138
+ params,
4139
+ kind,
4140
+ provider: raw.provider,
4141
+ model: raw.model,
4142
+ error,
4143
+ metadata: params.metadata
3694
4144
  });
4145
+ return fail(error);
3695
4146
  }
3696
- return ok({
4147
+ const job = {
3697
4148
  ...base,
3698
4149
  id: `inline-${base.model}-${base.submittedAt}`,
3699
4150
  result: raw
4151
+ };
4152
+ await notifySettled({
4153
+ event: "generation.succeeded",
4154
+ params,
4155
+ job,
4156
+ kind,
4157
+ provider: raw.provider,
4158
+ model: raw.model,
4159
+ outputs: raw.outputs,
4160
+ metadata: params.metadata,
4161
+ processingTimeMs: raw.processingTimeMs
3700
4162
  });
4163
+ return ok(job);
3701
4164
  } catch (error) {
3702
4165
  if (!isPendingJob(error)) {
3703
4166
  return failFrom("provider_error", error, {
@@ -3712,11 +4175,54 @@ function brotuClient(options) {
3712
4175
  });
3713
4176
  }
3714
4177
  }
4178
+ async function notifyFromSnapshot(job, snapshot) {
4179
+ if (snapshot.status === "succeeded" && snapshot.result) {
4180
+ await notifySettled({
4181
+ event: "generation.succeeded",
4182
+ params: job.params,
4183
+ job,
4184
+ outputs: snapshot.result.outputs,
4185
+ provider: snapshot.result.provider,
4186
+ model: snapshot.result.model,
4187
+ processingTimeMs: snapshot.result.processingTimeMs,
4188
+ metadata: job.metadata
4189
+ });
4190
+ return;
4191
+ }
4192
+ if (snapshot.status === "failed") {
4193
+ await notifySettled({
4194
+ event: "generation.failed",
4195
+ params: job.params,
4196
+ job,
4197
+ error: {
4198
+ code: "provider_error",
4199
+ message: snapshot.error ?? "The job failed.",
4200
+ provider: job.provider,
4201
+ model: job.model
4202
+ },
4203
+ metadata: job.metadata
4204
+ });
4205
+ }
4206
+ }
4207
+ async function finalizeSnapshot(job, snapshot) {
4208
+ if (snapshot.status === "succeeded" && snapshot.result) {
4209
+ const persisted = await toGeneration(snapshot.result, job.metadata);
4210
+ if (!persisted.error) {
4211
+ snapshot = {
4212
+ status: "succeeded",
4213
+ result: {
4214
+ ...snapshot.result,
4215
+ outputs: persisted.data.outputs
4216
+ }
4217
+ };
4218
+ }
4219
+ }
4220
+ await notifyFromSnapshot(job, snapshot);
4221
+ return snapshot;
4222
+ }
3715
4223
  async function pollJob(job) {
3716
4224
  if (job.result) {
3717
- return ok(
3718
- job.result.success ? { status: "succeeded", result: job.result } : { status: "failed", error: job.result.error }
3719
- );
4225
+ return ok(await finalizeSnapshot(job, job.result.success ? { status: "succeeded", result: job.result } : { status: "failed", error: job.result.error }));
3720
4226
  }
3721
4227
  const routed = route(job.model);
3722
4228
  if (routed.error) return fail(routed.error);
@@ -3729,7 +4235,7 @@ function brotuClient(options) {
3729
4235
  });
3730
4236
  }
3731
4237
  try {
3732
- return ok(await adapter.completeJob(job));
4238
+ return ok(await finalizeSnapshot(job, await adapter.completeJob(job)));
3733
4239
  } catch (error) {
3734
4240
  return failFrom("provider_error", error, {
3735
4241
  provider: job.provider,
@@ -3738,7 +4244,7 @@ function brotuClient(options) {
3738
4244
  }
3739
4245
  }
3740
4246
  function klingNamespace() {
3741
- const configured = options.providers.kling;
4247
+ const configured = vendors.kling;
3742
4248
  if (!configured) return void 0;
3743
4249
  const adapter = new KlingAdapter({
3744
4250
  apiKey: configured.apiKey,
@@ -3763,7 +4269,7 @@ function brotuClient(options) {
3763
4269
  };
3764
4270
  }
3765
4271
  function googleNamespace() {
3766
- const configured = options.providers.google;
4272
+ const configured = vendors.google;
3767
4273
  if (!configured) return void 0;
3768
4274
  const adapter = new GoogleAdapter({
3769
4275
  apiKey: configured.apiKey,
@@ -3798,6 +4304,17 @@ function brotuClient(options) {
3798
4304
  submit: (params) => submit("audio", params),
3799
4305
  generate: (params) => generate("audio", params)
3800
4306
  },
4307
+ webhook: {
4308
+ set(value) {
4309
+ registeredWebhook = resolveWebhook(value);
4310
+ },
4311
+ clear() {
4312
+ registeredWebhook = void 0;
4313
+ },
4314
+ get() {
4315
+ return registeredWebhook;
4316
+ }
4317
+ },
3801
4318
  jobs: {
3802
4319
  poll: pollJob,
3803
4320
  wait: async (job, waitOptions) => {
@@ -3807,7 +4324,13 @@ function brotuClient(options) {
3807
4324
  if (polled.error) return fail(polled.error);
3808
4325
  const snapshot = polled.data;
3809
4326
  if (snapshot.status === "succeeded" && snapshot.result) {
3810
- return toGeneration(snapshot.result, job.metadata);
4327
+ return ok({
4328
+ outputs: snapshot.result.outputs,
4329
+ provider: snapshot.result.provider,
4330
+ model: snapshot.result.model,
4331
+ processingTimeMs: snapshot.result.processingTimeMs,
4332
+ metadata: job.metadata
4333
+ });
3811
4334
  }
3812
4335
  if (snapshot.status === "failed") {
3813
4336
  return fail({
@@ -3829,7 +4352,7 @@ function brotuClient(options) {
3829
4352
  }
3830
4353
  }
3831
4354
  },
3832
- models: () => getAvailableModels(options),
4355
+ models: () => getAvailableModels(optionsWithKey),
3833
4356
  estimateCost: async (type, params) => {
3834
4357
  const routed = route(params.model);
3835
4358
  if (routed.error) return fail(routed.error);
@@ -3848,7 +4371,9 @@ function brotuClient(options) {
3848
4371
  0 && (module.exports = {
3849
4372
  BYTEPLUS_CATALOG,
3850
4373
  BYTEPLUS_MODELS,
4374
+ BrotuAdapter,
3851
4375
  BytePlusAdapter,
4376
+ DEFAULT_BROTU_API_URL,
3852
4377
  ELEVENLABS_CATALOG,
3853
4378
  ELEVENLABS_MODELS,
3854
4379
  ELEVENLABS_OUTPUT_FORMATS,
@@ -3877,6 +4402,7 @@ function brotuClient(options) {
3877
4402
  QwenAdapter,
3878
4403
  brotuClient,
3879
4404
  createS3Storage,
4405
+ deliverWebhook,
3880
4406
  fail,
3881
4407
  getAvailableModels,
3882
4408
  getModel,
@@ -3888,5 +4414,6 @@ function brotuClient(options) {
3888
4414
  persistOutputs,
3889
4415
  registerModels,
3890
4416
  resetCatalog,
3891
- resolveProvider
4417
+ resolveProvider,
4418
+ resolveWebhook
3892
4419
  });