@brotu/ai 0.1.0 → 0.3.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
@@ -37,8 +37,11 @@ __export(index_exports, {
37
37
  ELEVENLABS_MODELS: () => ELEVENLABS_MODELS,
38
38
  ELEVENLABS_OUTPUT_FORMATS: () => ELEVENLABS_OUTPUT_FORMATS,
39
39
  ElevenLabsAdapter: () => ElevenLabsAdapter,
40
+ GOOGLE_AUDIO_MODELS: () => GOOGLE_AUDIO_MODELS,
40
41
  GOOGLE_CATALOG: () => GOOGLE_CATALOG,
41
42
  GOOGLE_IMAGE_MODELS: () => GOOGLE_IMAGE_MODELS,
43
+ GOOGLE_TEXT_MODELS: () => GOOGLE_TEXT_MODELS,
44
+ GOOGLE_TTS_VOICES: () => GOOGLE_TTS_VOICES,
42
45
  GOOGLE_VIDEO_MODELS: () => GOOGLE_VIDEO_MODELS,
43
46
  GoogleAdapter: () => GoogleAdapter,
44
47
  KLING_CAPABILITIES: () => KLING_CAPABILITIES,
@@ -47,6 +50,7 @@ __export(index_exports, {
47
50
  KlingAdapter: () => KlingAdapter,
48
51
  OPENAI_CATALOG: () => OPENAI_CATALOG,
49
52
  OPENAI_IMAGE_MODELS: () => OPENAI_IMAGE_MODELS,
53
+ OPENAI_TEXT_MODELS: () => OPENAI_TEXT_MODELS,
50
54
  OpenAIAdapter: () => OpenAIAdapter,
51
55
  PendingJob: () => PendingJob,
52
56
  QWEN_AUDIO_MODELS: () => QWEN_AUDIO_MODELS,
@@ -57,6 +61,7 @@ __export(index_exports, {
57
61
  QwenAdapter: () => QwenAdapter,
58
62
  brotuClient: () => brotuClient,
59
63
  createS3Storage: () => createS3Storage,
64
+ deliverWebhook: () => deliverWebhook,
60
65
  fail: () => fail,
61
66
  getAvailableModels: () => getAvailableModels,
62
67
  getModel: () => getModel,
@@ -68,7 +73,8 @@ __export(index_exports, {
68
73
  persistOutputs: () => persistOutputs,
69
74
  registerModels: () => registerModels,
70
75
  resetCatalog: () => resetCatalog,
71
- resolveProvider: () => resolveProvider
76
+ resolveProvider: () => resolveProvider,
77
+ resolveWebhook: () => resolveWebhook
72
78
  });
73
79
  module.exports = __toCommonJS(index_exports);
74
80
 
@@ -398,6 +404,55 @@ var GOOGLE_VIDEO_MODELS = {
398
404
  conversationalEditing: true
399
405
  }
400
406
  };
407
+ var GOOGLE_TEXT_MODELS = {
408
+ "gemini-3.7-flash": { usdPerMillionOutput: 3.75 },
409
+ "gemini-3.6-flash": { usdPerMillionOutput: 3.75 },
410
+ "gemini-3.5-flash": { usdPerMillionOutput: 9 },
411
+ "gemini-3.5-flash-lite": {},
412
+ "gemini-3.1-flash-lite": {},
413
+ "gemini-3.1-pro-preview": { usdPerMillionOutput: 12 },
414
+ "gemini-3-flash-preview": {},
415
+ "gemini-2.5-pro": {},
416
+ "gemini-2.5-flash": {},
417
+ "gemini-2.5-flash-lite": {}
418
+ };
419
+ var GOOGLE_TTS_VOICES = [
420
+ "Zephyr",
421
+ "Puck",
422
+ "Charon",
423
+ "Kore",
424
+ "Fenrir",
425
+ "Leda",
426
+ "Orus",
427
+ "Aoede",
428
+ "Callirrhoe",
429
+ "Autonoe",
430
+ "Enceladus",
431
+ "Iapetus",
432
+ "Umbriel",
433
+ "Algieba",
434
+ "Despina",
435
+ "Erinome",
436
+ "Algenib",
437
+ "Rasalgethi",
438
+ "Laomedeia",
439
+ "Achernar",
440
+ "Alnilam",
441
+ "Schedar",
442
+ "Gacrux",
443
+ "Pulcherrima",
444
+ "Achird",
445
+ "Zubenelgenubi",
446
+ "Vindemiatrix",
447
+ "Sadachbia",
448
+ "Sadaltager",
449
+ "Sulafat"
450
+ ];
451
+ var GOOGLE_AUDIO_MODELS = {
452
+ "gemini-3.1-flash-tts-preview": { voices: GOOGLE_TTS_VOICES },
453
+ "gemini-2.5-flash-preview-tts": { voices: GOOGLE_TTS_VOICES },
454
+ "gemini-2.5-pro-preview-tts": { voices: GOOGLE_TTS_VOICES }
455
+ };
401
456
  var GOOGLE_CATALOG = [
402
457
  ...Object.entries(GOOGLE_IMAGE_MODELS).map(
403
458
  ([id, binding]) => ({
@@ -434,6 +489,35 @@ var GOOGLE_CATALOG = [
434
489
  byResolution: binding.usdByResolution
435
490
  }
436
491
  })
492
+ ),
493
+ ...Object.entries(GOOGLE_TEXT_MODELS).map(
494
+ ([id, binding]) => ({
495
+ id,
496
+ name: id,
497
+ category: "text",
498
+ inputType: "image_optional",
499
+ nodeTypes: ["text"],
500
+ creditsPerUnit: 0,
501
+ creditUnit: "token",
502
+ provider: "google",
503
+ pricing: binding.usdPerMillionOutput === void 0 ? void 0 : {
504
+ unit: "token",
505
+ usdPerUnit: binding.usdPerMillionOutput / 1e6
506
+ }
507
+ })
508
+ ),
509
+ ...Object.entries(GOOGLE_AUDIO_MODELS).map(
510
+ ([id, binding]) => ({
511
+ id,
512
+ name: id,
513
+ category: "audio",
514
+ inputType: "text_only",
515
+ nodeTypes: ["text"],
516
+ creditsPerUnit: 0,
517
+ creditUnit: "character",
518
+ provider: "google",
519
+ description: `Voices: ${binding.voices.join(", ")}`
520
+ })
437
521
  )
438
522
  ];
439
523
 
@@ -824,23 +908,42 @@ var OPENAI_IMAGE_MODELS = {
824
908
  usd: { low: 0.011, medium: 0.042, high: 0.167 }
825
909
  }
826
910
  };
827
- var OPENAI_CATALOG = Object.entries(
828
- OPENAI_IMAGE_MODELS
829
- ).map(([id, binding]) => ({
830
- id,
831
- name: id,
832
- category: "image",
833
- inputType: "image_optional",
834
- nodeTypes: ["image_gen"],
835
- creditsPerUnit: 0,
836
- creditUnit: "image",
837
- supportedResolutions: binding.sizes === "any" ? void 0 : binding.sizes,
838
- provider: "openai",
839
- // Medium is the sane middle; `quality: auto` can silently pick high, which
840
- // is 35x the low tier.
841
- pricing: { usdPerUnit: binding.usd.medium, unit: "image" },
842
- description: binding.shutdownOn ? `Deprecated \u2014 OpenAI shuts this down on ${binding.shutdownOn}.` : void 0
843
- }));
911
+ var OPENAI_TEXT_MODELS = {
912
+ "gpt-5.6-sol": { usdPerMillionOutput: 30 },
913
+ "gpt-5.6-terra": { usdPerMillionOutput: 12 },
914
+ "gpt-5.6-luna": { usdPerMillionOutput: 1.2 }
915
+ };
916
+ var OPENAI_CATALOG = [
917
+ ...Object.entries(OPENAI_IMAGE_MODELS).map(([id, binding]) => ({
918
+ id,
919
+ name: id,
920
+ category: "image",
921
+ inputType: "image_optional",
922
+ nodeTypes: ["image_gen"],
923
+ creditsPerUnit: 0,
924
+ creditUnit: "image",
925
+ supportedResolutions: binding.sizes === "any" ? void 0 : binding.sizes,
926
+ provider: "openai",
927
+ // Medium is the sane middle; `quality: auto` can silently pick high, which
928
+ // is 35x the low tier.
929
+ pricing: { usdPerUnit: binding.usd.medium, unit: "image" },
930
+ description: binding.shutdownOn ? `Deprecated \u2014 OpenAI shuts this down on ${binding.shutdownOn}.` : void 0
931
+ })),
932
+ ...Object.entries(OPENAI_TEXT_MODELS).map(([id, binding]) => ({
933
+ id,
934
+ name: id,
935
+ category: "text",
936
+ inputType: "image_optional",
937
+ nodeTypes: ["text"],
938
+ creditsPerUnit: 0,
939
+ creditUnit: "token",
940
+ provider: "openai",
941
+ pricing: {
942
+ unit: "token",
943
+ usdPerUnit: binding.usdPerMillionOutput / 1e6
944
+ }
945
+ }))
946
+ ];
844
947
 
845
948
  // src/providers/qwen.models.ts
846
949
  var VIDEO_PATH = "/api/v1/services/aigc/video-generation/video-synthesis";
@@ -1763,7 +1866,12 @@ var POLL_INTERVAL_MS2 = 1e4;
1763
1866
  var DEFAULT_MAX_POLL_ATTEMPTS2 = 120;
1764
1867
  var GoogleAdapter = class {
1765
1868
  providerName = "google";
1766
- supportedTypes = ["image", "video"];
1869
+ supportedTypes = [
1870
+ "image",
1871
+ "video",
1872
+ "text",
1873
+ "audio"
1874
+ ];
1767
1875
  opts;
1768
1876
  constructor(opts) {
1769
1877
  this.opts = opts;
@@ -2085,17 +2193,143 @@ var GoogleAdapter = class {
2085
2193
  generateVideo(params) {
2086
2194
  return this.runVideo(params);
2087
2195
  }
2088
- generateAudio(_params) {
2089
- throw new Error("Google has no speech synthesis wired up here.");
2196
+ textBody(modelId, params) {
2197
+ const input = [
2198
+ { type: "text", text: params.prompt }
2199
+ ];
2200
+ for (const image of params.referenceImages ?? []) {
2201
+ const [meta, data] = image.split(",");
2202
+ input.push({
2203
+ type: "image",
2204
+ mime_type: meta?.match(/data:([^;]+)/)?.[1] ?? "image/png",
2205
+ data: data ?? image
2206
+ });
2207
+ }
2208
+ return {
2209
+ model: modelId,
2210
+ input,
2211
+ ...params.systemPrompt ? { instructions: params.systemPrompt } : {},
2212
+ generation_config: {
2213
+ ...params.maxTokens ? { max_output_tokens: params.maxTokens } : {},
2214
+ ...params.temperature !== void 0 ? { temperature: params.temperature } : {},
2215
+ ...params.topP !== void 0 ? { top_p: params.topP } : {}
2216
+ },
2217
+ ...params.providerOptions?.google ?? {}
2218
+ };
2090
2219
  }
2091
- generateText(_params) {
2092
- throw new Error("The Google adapter here covers image and video only.");
2220
+ speechBody(modelId, params, voice) {
2221
+ return {
2222
+ model: modelId,
2223
+ input: params.prompt,
2224
+ response_format: { type: "audio" },
2225
+ generation_config: {
2226
+ speech_config: [{ voice }]
2227
+ },
2228
+ ...params.providerOptions?.google ?? {}
2229
+ };
2230
+ }
2231
+ async generateText(params) {
2232
+ const startedAt = Date.now();
2233
+ const modelId = params.model ?? "";
2234
+ const failure = (error) => ({
2235
+ success: false,
2236
+ outputs: [],
2237
+ creditsUsed: 0,
2238
+ provider: this.providerName,
2239
+ model: modelId,
2240
+ processingTimeMs: Date.now() - startedAt,
2241
+ error
2242
+ });
2243
+ if (!(modelId in GOOGLE_TEXT_MODELS)) {
2244
+ return failure(
2245
+ `"${modelId}" is not a Gemini text model. Known: ${Object.keys(GOOGLE_TEXT_MODELS).join(", ")}.`
2246
+ );
2247
+ }
2248
+ try {
2249
+ const payload = await this.request(
2250
+ INTERACTIONS_PATH,
2251
+ {
2252
+ method: "POST",
2253
+ body: this.textBody(modelId, params)
2254
+ }
2255
+ );
2256
+ const text = payload.output_text ?? payload.steps?.flatMap((step) => step.content ?? []).find((part) => part.type === "text" || part.text)?.text;
2257
+ if (!text) return failure("Google returned no text.");
2258
+ return {
2259
+ success: true,
2260
+ outputs: [
2261
+ {
2262
+ url: `data:text/plain;base64,${Buffer.from(text).toString("base64")}`,
2263
+ mimeType: "text/plain",
2264
+ raw: { text }
2265
+ }
2266
+ ],
2267
+ creditsUsed: 0,
2268
+ provider: this.providerName,
2269
+ model: modelId,
2270
+ processingTimeMs: Date.now() - startedAt
2271
+ };
2272
+ } catch (error) {
2273
+ return failure(error instanceof Error ? error.message : String(error));
2274
+ }
2275
+ }
2276
+ async generateAudio(params) {
2277
+ const startedAt = Date.now();
2278
+ const modelId = params.model ?? "";
2279
+ const failure = (error) => ({
2280
+ success: false,
2281
+ outputs: [],
2282
+ creditsUsed: 0,
2283
+ provider: this.providerName,
2284
+ model: modelId,
2285
+ processingTimeMs: Date.now() - startedAt,
2286
+ error
2287
+ });
2288
+ const binding = GOOGLE_AUDIO_MODELS[modelId];
2289
+ if (!binding) {
2290
+ return failure(
2291
+ `"${modelId}" is not a Gemini speech model. Known: ${Object.keys(GOOGLE_AUDIO_MODELS).join(", ")}.`
2292
+ );
2293
+ }
2294
+ const voice = params.voice ?? "Kore";
2295
+ if (!binding.voices.includes(voice)) {
2296
+ return failure(
2297
+ `Gemini TTS offers ${GOOGLE_TTS_VOICES.join(", ")}, not "${voice}".`
2298
+ );
2299
+ }
2300
+ try {
2301
+ const payload = await this.request(
2302
+ INTERACTIONS_PATH,
2303
+ {
2304
+ method: "POST",
2305
+ body: this.speechBody(modelId, params, voice)
2306
+ }
2307
+ );
2308
+ const data = payload.output_audio?.data;
2309
+ if (!data) return failure("Google returned no audio.");
2310
+ return {
2311
+ success: true,
2312
+ outputs: [
2313
+ {
2314
+ url: `data:audio/wav;base64,${data}`,
2315
+ mimeType: "audio/wav",
2316
+ raw: { inline: true, voice }
2317
+ }
2318
+ ],
2319
+ creditsUsed: 0,
2320
+ provider: this.providerName,
2321
+ model: modelId,
2322
+ processingTimeMs: Date.now() - startedAt
2323
+ };
2324
+ } catch (error) {
2325
+ return failure(error instanceof Error ? error.message : String(error));
2326
+ }
2093
2327
  }
2094
2328
  async estimateCost(type, params) {
2095
2329
  return estimateFor(this.providerName, type, params);
2096
2330
  }
2097
2331
  supportsModel(model) {
2098
- return model in GOOGLE_IMAGE_MODELS || model in GOOGLE_VIDEO_MODELS;
2332
+ return model in GOOGLE_IMAGE_MODELS || model in GOOGLE_VIDEO_MODELS || model in GOOGLE_TEXT_MODELS || model in GOOGLE_AUDIO_MODELS;
2099
2333
  }
2100
2334
  getAvailableModels() {
2101
2335
  return [
@@ -2108,6 +2342,16 @@ var GoogleAdapter = class {
2108
2342
  id,
2109
2343
  name: id,
2110
2344
  type: "video"
2345
+ })),
2346
+ ...Object.keys(GOOGLE_TEXT_MODELS).map((id) => ({
2347
+ id,
2348
+ name: id,
2349
+ type: "text"
2350
+ })),
2351
+ ...Object.keys(GOOGLE_AUDIO_MODELS).map((id) => ({
2352
+ id,
2353
+ name: id,
2354
+ type: "audio"
2111
2355
  }))
2112
2356
  ];
2113
2357
  }
@@ -2464,7 +2708,7 @@ var IMAGES_PATH2 = "/v1/images/generations";
2464
2708
  var OpenAIAdapter = class {
2465
2709
  providerName = "openai";
2466
2710
  // No video: the Videos API shuts down on 24 September 2026.
2467
- supportedTypes = ["image"];
2711
+ supportedTypes = ["image", "text"];
2468
2712
  opts;
2469
2713
  constructor(opts) {
2470
2714
  this.opts = opts;
@@ -2579,8 +2823,79 @@ var OpenAIAdapter = class {
2579
2823
  generateAudio(_params) {
2580
2824
  throw new Error("OpenAI has no speech synthesis wired up here.");
2581
2825
  }
2582
- generateText(_params) {
2583
- throw new Error("The OpenAI adapter here covers images only.");
2826
+ textBody(params) {
2827
+ const content = params.referenceImages?.length ? [
2828
+ { type: "input_text", text: params.prompt },
2829
+ ...params.referenceImages.map((url) => ({
2830
+ type: "input_image",
2831
+ image_url: url
2832
+ }))
2833
+ ] : params.prompt;
2834
+ const input = [];
2835
+ if (params.systemPrompt) {
2836
+ input.push({ role: "system", content: params.systemPrompt });
2837
+ }
2838
+ input.push({ role: "user", content });
2839
+ return {
2840
+ model: params.model,
2841
+ input,
2842
+ ...params.maxTokens ? { max_output_tokens: params.maxTokens } : {},
2843
+ ...params.temperature !== void 0 ? { temperature: params.temperature } : {},
2844
+ ...params.topP !== void 0 ? { top_p: params.topP } : {},
2845
+ ...params.providerOptions?.openai ?? {}
2846
+ };
2847
+ }
2848
+ async generateText(params) {
2849
+ const startedAt = Date.now();
2850
+ const modelId = params.model ?? "";
2851
+ const failure = (error) => ({
2852
+ success: false,
2853
+ outputs: [],
2854
+ creditsUsed: 0,
2855
+ provider: this.providerName,
2856
+ model: modelId,
2857
+ processingTimeMs: Date.now() - startedAt,
2858
+ error
2859
+ });
2860
+ if (!(modelId in OPENAI_TEXT_MODELS)) {
2861
+ return failure(
2862
+ `"${modelId}" is not an OpenAI text model. Known: ${Object.keys(OPENAI_TEXT_MODELS).join(", ")}.`
2863
+ );
2864
+ }
2865
+ try {
2866
+ const response = await fetch(`${this.baseUrl}/v1/responses`, {
2867
+ method: "POST",
2868
+ headers: {
2869
+ Authorization: `Bearer ${this.opts.apiKey}`,
2870
+ "Content-Type": "application/json",
2871
+ ...this.opts.organization ? { "OpenAI-Organization": this.opts.organization } : {}
2872
+ },
2873
+ body: JSON.stringify(this.textBody(params))
2874
+ });
2875
+ const payload = await response.json();
2876
+ if (payload.error) throw new Error(payload.error.message);
2877
+ if (!response.ok) {
2878
+ throw new Error(`OpenAI returned ${response.status}.`);
2879
+ }
2880
+ const text = payload.output_text ?? payload.output?.flatMap((item) => item.content ?? []).find((part) => part.type === "output_text" || part.text)?.text;
2881
+ if (!text) return failure("OpenAI returned no text.");
2882
+ return {
2883
+ success: true,
2884
+ outputs: [
2885
+ {
2886
+ url: `data:text/plain;base64,${Buffer.from(text).toString("base64")}`,
2887
+ mimeType: "text/plain",
2888
+ raw: { text, tokens: payload.usage?.output_tokens }
2889
+ }
2890
+ ],
2891
+ creditsUsed: 0,
2892
+ provider: this.providerName,
2893
+ model: modelId,
2894
+ processingTimeMs: Date.now() - startedAt
2895
+ };
2896
+ } catch (error) {
2897
+ return failure(error instanceof Error ? error.message : String(error));
2898
+ }
2584
2899
  }
2585
2900
  /** Synchronous provider: there is never a queued job to come back to. */
2586
2901
  async completeJob(job) {
@@ -2601,14 +2916,21 @@ var OpenAIAdapter = class {
2601
2916
  };
2602
2917
  }
2603
2918
  supportsModel(model) {
2604
- return model in OPENAI_IMAGE_MODELS;
2919
+ return model in OPENAI_IMAGE_MODELS || model in OPENAI_TEXT_MODELS;
2605
2920
  }
2606
2921
  getAvailableModels() {
2607
- return Object.keys(OPENAI_IMAGE_MODELS).map((id) => ({
2608
- id,
2609
- name: id,
2610
- type: "image"
2611
- }));
2922
+ return [
2923
+ ...Object.keys(OPENAI_IMAGE_MODELS).map((id) => ({
2924
+ id,
2925
+ name: id,
2926
+ type: "image"
2927
+ })),
2928
+ ...Object.keys(OPENAI_TEXT_MODELS).map((id) => ({
2929
+ id,
2930
+ name: id,
2931
+ type: "text"
2932
+ }))
2933
+ ];
2612
2934
  }
2613
2935
  };
2614
2936
 
@@ -3214,6 +3536,45 @@ async function persistOutputs(storage, outputs) {
3214
3536
  );
3215
3537
  }
3216
3538
 
3539
+ // src/lib/webhook.ts
3540
+ function resolveWebhook(value) {
3541
+ if (!value) return void 0;
3542
+ if (typeof value === "string") {
3543
+ return isHttpUrl(value) ? { url: value } : void 0;
3544
+ }
3545
+ if (!isHttpUrl(value.url)) return void 0;
3546
+ return {
3547
+ url: value.url,
3548
+ secret: value.secret,
3549
+ headers: value.headers
3550
+ };
3551
+ }
3552
+ function isHttpUrl(value) {
3553
+ try {
3554
+ const parsed = new URL(value);
3555
+ return parsed.protocol === "https:" || parsed.protocol === "http:";
3556
+ } catch {
3557
+ return false;
3558
+ }
3559
+ }
3560
+ async function deliverWebhook(config, payload) {
3561
+ try {
3562
+ await fetch(config.url, {
3563
+ method: "POST",
3564
+ headers: {
3565
+ "content-type": "application/json",
3566
+ "user-agent": "@brotu/ai",
3567
+ "x-brotu-event": payload.event,
3568
+ ...config.secret ? { "x-brotu-webhook-secret": config.secret } : {},
3569
+ ...config.headers
3570
+ },
3571
+ body: JSON.stringify(payload),
3572
+ signal: AbortSignal.timeout(1e4)
3573
+ });
3574
+ } catch {
3575
+ }
3576
+ }
3577
+
3217
3578
  // src/client.ts
3218
3579
  var NATIVE_PROVIDERS = [
3219
3580
  "byteplus",
@@ -3228,6 +3589,8 @@ function brotuClient(options) {
3228
3589
  throw new Error("brotuClient requires at least one provider API key.");
3229
3590
  }
3230
3591
  registerModels(options.models);
3592
+ let registeredWebhook = resolveWebhook(options.webhook);
3593
+ const notifiedJobIds = /* @__PURE__ */ new Set();
3231
3594
  const storage = options.storage ? createS3Storage(options.storage) : void 0;
3232
3595
  const shouldPersist = storage && options.storage?.persistOutputs !== false;
3233
3596
  const adapters = /* @__PURE__ */ new Map();
@@ -3317,6 +3680,31 @@ function brotuClient(options) {
3317
3680
  return adapter.generateAudio(params);
3318
3681
  return adapter.generateText(params);
3319
3682
  }
3683
+ function webhookFor(params) {
3684
+ return resolveWebhook(params?.webhook) ?? registeredWebhook;
3685
+ }
3686
+ async function notifySettled(input) {
3687
+ const config = webhookFor(input.params);
3688
+ if (!config) return;
3689
+ const jobId = input.job?.id;
3690
+ if (jobId) {
3691
+ if (notifiedJobIds.has(jobId)) return;
3692
+ notifiedJobIds.add(jobId);
3693
+ }
3694
+ const payload = {
3695
+ event: input.event,
3696
+ jobId,
3697
+ provider: input.provider ?? input.job?.provider,
3698
+ model: input.model ?? input.job?.model,
3699
+ kind: input.kind ?? input.job?.kind,
3700
+ outputs: input.outputs,
3701
+ error: input.error ? { code: input.error.code, message: input.error.message } : void 0,
3702
+ metadata: input.metadata ?? input.job?.metadata,
3703
+ processingTimeMs: input.processingTimeMs,
3704
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
3705
+ };
3706
+ await deliverWebhook(config, payload);
3707
+ }
3320
3708
  async function toGeneration(raw, metadata) {
3321
3709
  if (!raw.success) {
3322
3710
  return fail({
@@ -3339,15 +3727,50 @@ function brotuClient(options) {
3339
3727
  const routed = route(params.model);
3340
3728
  if (routed.error) return fail(routed.error);
3341
3729
  try {
3342
- return await toGeneration(
3730
+ const result = await toGeneration(
3343
3731
  await generateWith(routed.data.adapter, kind, params),
3344
3732
  params.metadata
3345
3733
  );
3734
+ if (result.error) {
3735
+ await notifySettled({
3736
+ event: "generation.failed",
3737
+ params,
3738
+ kind,
3739
+ provider: routed.data.provider,
3740
+ model: routed.data.model,
3741
+ error: result.error,
3742
+ metadata: params.metadata
3743
+ });
3744
+ return result;
3745
+ }
3746
+ await notifySettled({
3747
+ event: "generation.succeeded",
3748
+ params,
3749
+ kind,
3750
+ provider: result.data.provider,
3751
+ model: result.data.model,
3752
+ outputs: result.data.outputs,
3753
+ metadata: result.data.metadata,
3754
+ processingTimeMs: result.data.processingTimeMs
3755
+ });
3756
+ return result;
3346
3757
  } catch (error) {
3347
- return failFrom("provider_error", error, {
3758
+ const failed = failFrom("provider_error", error, {
3348
3759
  provider: routed.data.provider,
3349
3760
  model: routed.data.model
3350
3761
  });
3762
+ if (failed.error) {
3763
+ await notifySettled({
3764
+ event: "generation.failed",
3765
+ params,
3766
+ kind,
3767
+ provider: routed.data.provider,
3768
+ model: routed.data.model,
3769
+ error: failed.error,
3770
+ metadata: params.metadata
3771
+ });
3772
+ }
3773
+ return failed;
3351
3774
  }
3352
3775
  }
3353
3776
  async function submit(kind, params) {
@@ -3366,18 +3789,40 @@ function brotuClient(options) {
3366
3789
  () => generateWith(routed.data.adapter, kind, params)
3367
3790
  );
3368
3791
  if (!raw.success) {
3369
- return fail({
3792
+ const error = {
3370
3793
  code: "provider_error",
3371
3794
  message: raw.error ?? "The provider rejected the request.",
3372
3795
  provider: raw.provider,
3373
3796
  model: raw.model
3797
+ };
3798
+ await notifySettled({
3799
+ event: "generation.failed",
3800
+ params,
3801
+ kind,
3802
+ provider: raw.provider,
3803
+ model: raw.model,
3804
+ error,
3805
+ metadata: params.metadata
3374
3806
  });
3807
+ return fail(error);
3375
3808
  }
3376
- return ok({
3809
+ const job = {
3377
3810
  ...base,
3378
3811
  id: `inline-${base.model}-${base.submittedAt}`,
3379
3812
  result: raw
3813
+ };
3814
+ await notifySettled({
3815
+ event: "generation.succeeded",
3816
+ params,
3817
+ job,
3818
+ kind,
3819
+ provider: raw.provider,
3820
+ model: raw.model,
3821
+ outputs: raw.outputs,
3822
+ metadata: params.metadata,
3823
+ processingTimeMs: raw.processingTimeMs
3380
3824
  });
3825
+ return ok(job);
3381
3826
  } catch (error) {
3382
3827
  if (!isPendingJob(error)) {
3383
3828
  return failFrom("provider_error", error, {
@@ -3392,11 +3837,54 @@ function brotuClient(options) {
3392
3837
  });
3393
3838
  }
3394
3839
  }
3840
+ async function notifyFromSnapshot(job, snapshot) {
3841
+ if (snapshot.status === "succeeded" && snapshot.result) {
3842
+ await notifySettled({
3843
+ event: "generation.succeeded",
3844
+ params: job.params,
3845
+ job,
3846
+ outputs: snapshot.result.outputs,
3847
+ provider: snapshot.result.provider,
3848
+ model: snapshot.result.model,
3849
+ processingTimeMs: snapshot.result.processingTimeMs,
3850
+ metadata: job.metadata
3851
+ });
3852
+ return;
3853
+ }
3854
+ if (snapshot.status === "failed") {
3855
+ await notifySettled({
3856
+ event: "generation.failed",
3857
+ params: job.params,
3858
+ job,
3859
+ error: {
3860
+ code: "provider_error",
3861
+ message: snapshot.error ?? "The job failed.",
3862
+ provider: job.provider,
3863
+ model: job.model
3864
+ },
3865
+ metadata: job.metadata
3866
+ });
3867
+ }
3868
+ }
3869
+ async function finalizeSnapshot(job, snapshot) {
3870
+ if (snapshot.status === "succeeded" && snapshot.result) {
3871
+ const persisted = await toGeneration(snapshot.result, job.metadata);
3872
+ if (!persisted.error) {
3873
+ snapshot = {
3874
+ status: "succeeded",
3875
+ result: {
3876
+ ...snapshot.result,
3877
+ outputs: persisted.data.outputs
3878
+ }
3879
+ };
3880
+ }
3881
+ }
3882
+ await notifyFromSnapshot(job, snapshot);
3883
+ return snapshot;
3884
+ }
3395
3885
  async function pollJob(job) {
3396
3886
  if (job.result) {
3397
- return ok(
3398
- job.result.success ? { status: "succeeded", result: job.result } : { status: "failed", error: job.result.error }
3399
- );
3887
+ return ok(await finalizeSnapshot(job, job.result.success ? { status: "succeeded", result: job.result } : { status: "failed", error: job.result.error }));
3400
3888
  }
3401
3889
  const routed = route(job.model);
3402
3890
  if (routed.error) return fail(routed.error);
@@ -3409,7 +3897,7 @@ function brotuClient(options) {
3409
3897
  });
3410
3898
  }
3411
3899
  try {
3412
- return ok(await adapter.completeJob(job));
3900
+ return ok(await finalizeSnapshot(job, await adapter.completeJob(job)));
3413
3901
  } catch (error) {
3414
3902
  return failFrom("provider_error", error, {
3415
3903
  provider: job.provider,
@@ -3478,6 +3966,17 @@ function brotuClient(options) {
3478
3966
  submit: (params) => submit("audio", params),
3479
3967
  generate: (params) => generate("audio", params)
3480
3968
  },
3969
+ webhook: {
3970
+ set(value) {
3971
+ registeredWebhook = resolveWebhook(value);
3972
+ },
3973
+ clear() {
3974
+ registeredWebhook = void 0;
3975
+ },
3976
+ get() {
3977
+ return registeredWebhook;
3978
+ }
3979
+ },
3481
3980
  jobs: {
3482
3981
  poll: pollJob,
3483
3982
  wait: async (job, waitOptions) => {
@@ -3487,7 +3986,13 @@ function brotuClient(options) {
3487
3986
  if (polled.error) return fail(polled.error);
3488
3987
  const snapshot = polled.data;
3489
3988
  if (snapshot.status === "succeeded" && snapshot.result) {
3490
- return toGeneration(snapshot.result, job.metadata);
3989
+ return ok({
3990
+ outputs: snapshot.result.outputs,
3991
+ provider: snapshot.result.provider,
3992
+ model: snapshot.result.model,
3993
+ processingTimeMs: snapshot.result.processingTimeMs,
3994
+ metadata: job.metadata
3995
+ });
3491
3996
  }
3492
3997
  if (snapshot.status === "failed") {
3493
3998
  return fail({
@@ -3533,8 +4038,11 @@ function brotuClient(options) {
3533
4038
  ELEVENLABS_MODELS,
3534
4039
  ELEVENLABS_OUTPUT_FORMATS,
3535
4040
  ElevenLabsAdapter,
4041
+ GOOGLE_AUDIO_MODELS,
3536
4042
  GOOGLE_CATALOG,
3537
4043
  GOOGLE_IMAGE_MODELS,
4044
+ GOOGLE_TEXT_MODELS,
4045
+ GOOGLE_TTS_VOICES,
3538
4046
  GOOGLE_VIDEO_MODELS,
3539
4047
  GoogleAdapter,
3540
4048
  KLING_CAPABILITIES,
@@ -3543,6 +4051,7 @@ function brotuClient(options) {
3543
4051
  KlingAdapter,
3544
4052
  OPENAI_CATALOG,
3545
4053
  OPENAI_IMAGE_MODELS,
4054
+ OPENAI_TEXT_MODELS,
3546
4055
  OpenAIAdapter,
3547
4056
  PendingJob,
3548
4057
  QWEN_AUDIO_MODELS,
@@ -3553,6 +4062,7 @@ function brotuClient(options) {
3553
4062
  QwenAdapter,
3554
4063
  brotuClient,
3555
4064
  createS3Storage,
4065
+ deliverWebhook,
3556
4066
  fail,
3557
4067
  getAvailableModels,
3558
4068
  getModel,
@@ -3564,5 +4074,6 @@ function brotuClient(options) {
3564
4074
  persistOutputs,
3565
4075
  registerModels,
3566
4076
  resetCatalog,
3567
- resolveProvider
4077
+ resolveProvider,
4078
+ resolveWebhook
3568
4079
  });