@adzenai/ai 1.1.0 → 1.2.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.
@@ -21,11 +21,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var copilotkit_exports = {};
22
22
  __export(copilotkit_exports, {
23
23
  AdzenAsyncMiddleware: () => AdzenAsyncMiddleware,
24
- createImpressionHandler: () => createImpressionHandler
24
+ AdzenStreamMiddleware: () => AdzenStreamMiddleware
25
25
  });
26
26
  module.exports = __toCommonJS(copilotkit_exports);
27
27
 
28
28
  // src/copilotkit/middleware.ts
29
+ function generateIdempotencyKey() {
30
+ const globalCrypto = globalThis.crypto;
31
+ if (globalCrypto?.randomUUID) return globalCrypto.randomUUID();
32
+ return `adzen-${Date.now()}-${Math.random().toString(36).slice(2)}`;
33
+ }
29
34
  function isFinalOutputEvent(event) {
30
35
  const type = String(event.type ?? "");
31
36
  if (type === "RUN_FINISHED" || type === "RunFinished") return false;
@@ -55,13 +60,15 @@ var AdzenAsyncMiddleware = class {
55
60
  fetchedIds = /* @__PURE__ */ new Set();
56
61
  pendingFetches = [];
57
62
  fallbackCounter = 0;
63
+ conversationId;
58
64
  constructor(config) {
59
65
  this.config = {
60
66
  ...config,
61
- endpointUrl: config.endpointUrl ?? "https://api.adzen.ai/v1/ai",
67
+ endpointUrl: config.endpointUrl ?? "https://api.adzen.ai/v1",
62
68
  timeoutMs: config.timeoutMs ?? 3e3,
63
69
  adUnitPosition: config.adUnitPosition ?? "chin"
64
70
  };
71
+ this.conversationId = config.conversationId;
65
72
  }
66
73
  /**
67
74
  * Process a single AG-UI event. Returns an array of events to emit
@@ -71,6 +78,12 @@ var AdzenAsyncMiddleware = class {
71
78
  async processEvent(event) {
72
79
  const type = String(event.type ?? "");
73
80
  switch (type) {
81
+ case "RUN_STARTED":
82
+ case "RunStarted": {
83
+ const threadId = String(event.threadId ?? "");
84
+ if (threadId) this.conversationId = threadId;
85
+ return [event];
86
+ }
74
87
  case "TEXT_MESSAGE_START":
75
88
  case "TextMessageStart": {
76
89
  const messageId = this.resolveMessageId(event);
@@ -158,31 +171,58 @@ var AdzenAsyncMiddleware = class {
158
171
  const controller = new AbortController();
159
172
  const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
160
173
  try {
174
+ const body = {
175
+ content,
176
+ message_id: messageId
177
+ };
178
+ if (this.config.location) body.location = this.config.location;
179
+ if (this.conversationId) body.conversation_id = this.conversationId;
180
+ const headers = {
181
+ "Content-Type": "application/json",
182
+ "X-API-Key": this.config.apiKey,
183
+ // AI-API requires an Idempotency-Key on /process (400 otherwise).
184
+ // One per ad fetch — each completed message is a distinct request.
185
+ "Idempotency-Key": generateIdempotencyKey()
186
+ };
187
+ if (this.config.profileId) headers["X-Profile-Id"] = this.config.profileId;
161
188
  const res = await fetch(`${this.config.endpointUrl}/process`, {
162
189
  method: "POST",
163
- headers: {
164
- "Content-Type": "application/json",
165
- "X-API-Key": this.config.apiKey
166
- },
167
- body: JSON.stringify({
168
- content,
169
- message_id: messageId
170
- }),
190
+ headers,
191
+ body: JSON.stringify(body),
171
192
  signal: controller.signal
172
193
  });
173
194
  if (!res.ok) return null;
174
195
  const data = await res.json();
175
- if (data.decision === "serve" && data.ad) return data.ad;
176
- return null;
196
+ const first = data.ads?.[0];
197
+ if (!first) return null;
198
+ return this.mapResponseToPlacement(first);
177
199
  } catch {
178
200
  return null;
179
201
  } finally {
180
202
  clearTimeout(timeout);
181
203
  }
182
204
  }
205
+ mapResponseToPlacement(ad) {
206
+ return {
207
+ ad_id: String(ad.ad_id),
208
+ advertiser_name: ad.advertiser_name,
209
+ advertiser_image_url: ad.sponsor_logo_url || void 0,
210
+ headline: ad.title,
211
+ description: ad.description,
212
+ cta_text: ad.cta,
213
+ destination_url: ad.click_through_url,
214
+ creative_url: ad.creative_url ?? void 0,
215
+ adUnitPosition: ad.placement ?? this.config.adUnitPosition,
216
+ render_impression_url: ad.render_impression_url,
217
+ view_impression_url: ad.view_impression_url
218
+ };
219
+ }
183
220
  makePlacementEvent(placement, messageId) {
184
221
  return {
185
- type: "CUSTOM_EVENT",
222
+ // AG-UI's event schema only accepts "CUSTOM"; @ag-ui/client zod-validates
223
+ // every SSE frame and aborts the run on an unknown type. "CUSTOM_EVENT"
224
+ // was rejected downstream.
225
+ type: "CUSTOM",
186
226
  name: "adzen_placement",
187
227
  messageId,
188
228
  value: {
@@ -193,72 +233,465 @@ var AdzenAsyncMiddleware = class {
193
233
  }
194
234
  };
195
235
 
196
- // src/copilotkit/impressionHandler.ts
197
- var REQUIRED_FIELDS = [
198
- "type",
199
- "ad_id",
200
- "message_id",
201
- "ad_unit_position",
202
- "timestamp"
203
- ];
204
- function createImpressionHandler(config) {
205
- const endpointUrl = config.endpointUrl ?? "https://api.adzen.ai/v1/ai";
206
- return async (request) => {
207
- if (request.method !== "POST") {
208
- return new Response(JSON.stringify({ error: "Method not allowed" }), {
209
- status: 405,
210
- headers: { "Content-Type": "application/json" }
211
- });
236
+ // src/copilotkit/AdzenStreamClient.ts
237
+ var import_core = require("@adzenai/core");
238
+
239
+ // src/copilotkit/parseSSE.ts
240
+ async function* parseSSE(stream) {
241
+ const reader = stream.getReader();
242
+ const decoder = new TextDecoder();
243
+ let buffer = "";
244
+ let currentEvent;
245
+ let dataLines = [];
246
+ try {
247
+ while (true) {
248
+ const { done, value } = await reader.read();
249
+ if (done) break;
250
+ buffer += decoder.decode(value, { stream: true });
251
+ const lines = buffer.split("\n");
252
+ buffer = lines.pop();
253
+ for (const raw of lines) {
254
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
255
+ if (line === "") {
256
+ if (dataLines.length > 0) {
257
+ yield { event: currentEvent, data: dataLines.join("\n") };
258
+ dataLines = [];
259
+ currentEvent = void 0;
260
+ }
261
+ continue;
262
+ }
263
+ if (line.startsWith(":")) continue;
264
+ const colonIdx = line.indexOf(":");
265
+ if (colonIdx === -1) continue;
266
+ const field = line.slice(0, colonIdx);
267
+ const value2 = line.slice(colonIdx + 1).replace(/^ /, "");
268
+ switch (field) {
269
+ case "data":
270
+ dataLines.push(value2);
271
+ break;
272
+ case "event":
273
+ currentEvent = value2;
274
+ break;
275
+ }
276
+ }
212
277
  }
213
- let body;
214
- try {
215
- body = await request.json();
216
- } catch {
217
- return new Response(JSON.stringify({ error: "Invalid JSON" }), {
218
- status: 400,
219
- headers: { "Content-Type": "application/json" }
220
- });
278
+ if (dataLines.length > 0) {
279
+ yield { event: currentEvent, data: dataLines.join("\n") };
221
280
  }
222
- for (const field of REQUIRED_FIELDS) {
223
- if (!body[field]) {
224
- return new Response(
225
- JSON.stringify({ error: `Missing required field: ${field}` }),
226
- { status: 400, headers: { "Content-Type": "application/json" } }
227
- );
281
+ } finally {
282
+ reader.releaseLock();
283
+ }
284
+ }
285
+
286
+ // src/copilotkit/AdzenStreamClient.ts
287
+ var DEFAULT_ENDPOINT = "https://api.adzen.ai/v1";
288
+ var DEFAULT_TIMEOUT_MS = 3e3;
289
+ var DEFAULT_POLL_INTERVAL_MS = 2e3;
290
+ var SUPPORTS_REQUEST_STREAMS = (() => {
291
+ try {
292
+ let duplexAccessed = false;
293
+ const hasContentType = new Request("http://localhost", {
294
+ method: "POST",
295
+ body: new ReadableStream(),
296
+ get duplex() {
297
+ duplexAccessed = true;
298
+ return "half";
228
299
  }
300
+ }).headers.has("Content-Type");
301
+ return duplexAccessed && !hasContentType;
302
+ } catch {
303
+ return false;
304
+ }
305
+ })();
306
+ var AdzenStreamClient = class {
307
+ endpointUrl;
308
+ apiKey;
309
+ timeoutMs;
310
+ location;
311
+ profileId;
312
+ placementPollIntervalMs;
313
+ prefetchEnabled;
314
+ sidecarEnabled;
315
+ conversationId;
316
+ abortController = null;
317
+ placementEndpoint = null;
318
+ pollTimer = null;
319
+ streamEnded = false;
320
+ inlineAdQueue = [];
321
+ streamPromise = null;
322
+ prefetchPromise = null;
323
+ onInlineAd;
324
+ onPlacement;
325
+ onError;
326
+ onStreamStart;
327
+ onStreamEnd;
328
+ constructor(config) {
329
+ this.endpointUrl = config.endpointUrl ?? DEFAULT_ENDPOINT;
330
+ this.apiKey = config.apiKey;
331
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
332
+ this.location = config.location;
333
+ this.profileId = config.profileId;
334
+ this.placementPollIntervalMs = config.placementPollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
335
+ this.prefetchEnabled = config.prefetch ?? false;
336
+ this.sidecarEnabled = config.sidecar ?? true;
337
+ this.conversationId = config.conversationId ?? (0, import_core.uuidv4)();
338
+ this.onInlineAd = config.onInlineAd;
339
+ this.onPlacement = config.onPlacement;
340
+ this.onError = config.onError;
341
+ this.onStreamStart = config.onStreamStart;
342
+ this.onStreamEnd = config.onStreamEnd;
343
+ }
344
+ getConversationId() {
345
+ return this.conversationId;
346
+ }
347
+ setConversationId(id) {
348
+ this.conversationId = id;
349
+ }
350
+ /**
351
+ * Dispatches the prefetch without waiting for it.
352
+ *
353
+ * The request leaves before `connect()` opens `/stream`, which is all the
354
+ * 2-step chained flow needs — the two calls are correlated server-side by
355
+ * `conversation_id`. Awaiting the response here would stall the assistant
356
+ * message behind a full ad-matching round trip while the LLM keeps
357
+ * streaming, so the buffered tokens would then arrive in one burst.
358
+ */
359
+ startPrefetch(prompt) {
360
+ if (!this.prefetchEnabled) return;
361
+ this.prefetchPromise = this.prefetchProcess(prompt);
362
+ }
363
+ /**
364
+ * 2-step chained flow: POST /process with the user prompt and
365
+ * conversationId. Awaits the response (or timeout) but discards the
366
+ * result. If it fails, logs and continues — the stream will proceed
367
+ * in degraded mode.
368
+ */
369
+ async prefetchProcess(prompt) {
370
+ if (!this.prefetchEnabled) return;
371
+ const controller = new AbortController();
372
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
373
+ try {
374
+ const body = {
375
+ content: prompt,
376
+ message_id: (0, import_core.uuidv4)(),
377
+ conversation_id: this.conversationId
378
+ };
379
+ if (this.location) body.location = this.location;
380
+ const headers = {
381
+ "Content-Type": "application/json",
382
+ "X-API-Key": this.apiKey,
383
+ "Idempotency-Key": (0, import_core.uuidv4)()
384
+ };
385
+ if (this.profileId) headers["X-Profile-Id"] = this.profileId;
386
+ await fetch(`${this.endpointUrl}/process`, {
387
+ method: "POST",
388
+ headers,
389
+ body: JSON.stringify(body),
390
+ signal: controller.signal
391
+ });
392
+ } catch {
393
+ } finally {
394
+ clearTimeout(timeout);
229
395
  }
230
- if (body.type !== "render" && body.type !== "view") {
231
- return new Response(
232
- JSON.stringify({ error: 'Invalid type: must be "render" or "view"' }),
233
- { status: 400, headers: { "Content-Type": "application/json" } }
234
- );
396
+ }
397
+ /**
398
+ * Opens a streaming POST to /stream, piping the upstream LLM response
399
+ * body directly. The API reads the raw LLM stream in real time and
400
+ * sends SSE ad events back on the response side.
401
+ *
402
+ * @param messageId Unique ID for this assistant message
403
+ * @param body The upstream LLM response body (one branch of a tee)
404
+ */
405
+ connect(messageId, body) {
406
+ this.streamEnded = false;
407
+ this.inlineAdQueue = [];
408
+ this.placementEndpoint = null;
409
+ this.streamPromise = null;
410
+ this.abortController = new AbortController();
411
+ if (!SUPPORTS_REQUEST_STREAMS) {
412
+ this.streamEnded = true;
413
+ this.onError?.({
414
+ type: "adzen_error",
415
+ code: "request_streams_unsupported",
416
+ message: "This runtime cannot send a ReadableStream as a request body, so the LLM stream cannot be proxied. Run AdzenStreamMiddleware server-side (Node 18+) \u2014 browsers other than Chromium do not support fetch upload streams."
417
+ });
418
+ void body.cancel().catch(() => {
419
+ });
420
+ return;
235
421
  }
236
- const upstreamPath = body.type === "render" ? "/impressions/render" : "/impressions/view";
237
- const { type: _type, ...forwardBody } = body;
422
+ const headers = {
423
+ "Content-Type": "application/octet-stream",
424
+ "X-API-Key": this.apiKey,
425
+ "Idempotency-Key": (0, import_core.uuidv4)(),
426
+ "X-Message-Id": messageId,
427
+ "X-Conversation-Id": this.conversationId
428
+ };
429
+ if (this.profileId) headers["X-Profile-Id"] = this.profileId;
430
+ if (this.location) headers["X-Location"] = this.location;
431
+ this.streamPromise = this.executeStreamFetch(body, headers);
432
+ }
433
+ /**
434
+ * Waits for the SSE response stream from the API to finish, along with any
435
+ * prefetch still in flight. Neither can reject — both swallow their errors.
436
+ */
437
+ async finalize() {
438
+ await Promise.all([this.prefetchPromise, this.streamPromise]);
439
+ }
440
+ async executeStreamFetch(body, headers) {
441
+ const timeout = setTimeout(
442
+ () => this.abortController?.abort(),
443
+ this.timeoutMs
444
+ );
238
445
  try {
239
- const upstream = await fetch(`${endpointUrl}${upstreamPath}`, {
446
+ const res = await fetch(`${this.endpointUrl}/stream`, {
240
447
  method: "POST",
241
- headers: {
242
- "Content-Type": "application/json",
243
- "X-API-Key": config.apiKey
244
- },
245
- body: JSON.stringify(forwardBody)
448
+ headers,
449
+ body,
450
+ signal: this.abortController?.signal,
451
+ // @ts-expect-error -- required for streaming request bodies (Node/undici)
452
+ duplex: "half"
246
453
  });
247
- return new Response(upstream.body, {
248
- status: upstream.status,
249
- headers: { "Content-Type": "application/json" }
454
+ clearTimeout(timeout);
455
+ if (!res.ok || !res.body) return;
456
+ await this.readStream(res.body);
457
+ } catch {
458
+ clearTimeout(timeout);
459
+ }
460
+ }
461
+ /** Drains and returns any queued inline ad events. */
462
+ drainInlineAds() {
463
+ const ads = this.inlineAdQueue.splice(0);
464
+ return ads;
465
+ }
466
+ /** Cancels pending requests and cleans up state. */
467
+ abort() {
468
+ this.abortController?.abort();
469
+ this.stopPolling();
470
+ this.streamEnded = true;
471
+ }
472
+ /** Polls the placement endpoint once. Called on an interval internally. */
473
+ async pollPlacements() {
474
+ if (!this.sidecarEnabled || !this.placementEndpoint) return;
475
+ try {
476
+ const res = await fetch(this.placementEndpoint, {
477
+ method: "GET",
478
+ headers: { "X-API-Key": this.apiKey }
250
479
  });
480
+ if (!res.ok) return;
481
+ const data = await res.json();
482
+ for (const placement of data.placements) {
483
+ this.onPlacement?.(placement);
484
+ }
251
485
  } catch {
252
- return new Response(
253
- JSON.stringify({ error: "Upstream request failed" }),
254
- { status: 502, headers: { "Content-Type": "application/json" } }
255
- );
256
486
  }
257
- };
258
- }
487
+ }
488
+ async readStream(body) {
489
+ try {
490
+ for await (const frame of parseSSE(body)) {
491
+ if (this.abortController?.signal.aborted) break;
492
+ this.handleFrame(frame.event, frame.data);
493
+ }
494
+ } catch {
495
+ } finally {
496
+ this.streamEnded = true;
497
+ this.stopPolling();
498
+ }
499
+ }
500
+ handleFrame(event, data) {
501
+ switch (event) {
502
+ case "adzen_stream_start": {
503
+ try {
504
+ const parsed = JSON.parse(data);
505
+ const evt = { type: "adzen_stream_start", ...parsed };
506
+ this.placementEndpoint = parsed.placement_endpoint;
507
+ this.onStreamStart?.(evt);
508
+ this.startPolling();
509
+ } catch {
510
+ }
511
+ break;
512
+ }
513
+ case "adzen_inline": {
514
+ try {
515
+ const ad = JSON.parse(data);
516
+ this.inlineAdQueue.push(ad);
517
+ this.onInlineAd?.(ad);
518
+ } catch {
519
+ }
520
+ break;
521
+ }
522
+ case "adzen_error": {
523
+ try {
524
+ const parsed = JSON.parse(data);
525
+ this.onError?.({ type: "adzen_error", ...parsed });
526
+ } catch {
527
+ }
528
+ break;
529
+ }
530
+ case "adzen_stream_end": {
531
+ try {
532
+ const parsed = JSON.parse(data);
533
+ const evt = { type: "adzen_stream_end", ...parsed };
534
+ this.onStreamEnd?.(evt);
535
+ } catch {
536
+ }
537
+ this.streamEnded = true;
538
+ this.stopPolling();
539
+ break;
540
+ }
541
+ default: {
542
+ if (event?.startsWith("adzen_debug_")) break;
543
+ break;
544
+ }
545
+ }
546
+ }
547
+ startPolling() {
548
+ if (!this.sidecarEnabled) return;
549
+ if (this.pollTimer || !this.placementEndpoint) return;
550
+ this.pollTimer = setInterval(
551
+ () => this.pollPlacements(),
552
+ this.placementPollIntervalMs
553
+ );
554
+ }
555
+ stopPolling() {
556
+ if (this.pollTimer) {
557
+ clearInterval(this.pollTimer);
558
+ this.pollTimer = null;
559
+ }
560
+ }
561
+ };
562
+
563
+ // src/copilotkit/streamMiddleware.ts
564
+ var AdzenStreamMiddleware = class {
565
+ config;
566
+ adUnitPosition;
567
+ constructor(config) {
568
+ this.config = config;
569
+ this.adUnitPosition = config.adUnitPosition ?? "chin";
570
+ }
571
+ async *processStream(events, options) {
572
+ const sidecarPlacements = [];
573
+ let client = null;
574
+ let currentMessageId = "";
575
+ let contentOffset = 0;
576
+ const onPlacement = (record) => {
577
+ sidecarPlacements.push(this.mapPlacementRecord(record, currentMessageId));
578
+ };
579
+ const onError = (error) => {
580
+ console.warn(`[adzen-stream] ${error.code}: ${error.message}`);
581
+ };
582
+ for await (const event of events) {
583
+ const type = String(event.type ?? "");
584
+ switch (type) {
585
+ case "RUN_STARTED":
586
+ case "RunStarted": {
587
+ const threadId = String(event.threadId ?? "");
588
+ client = new AdzenStreamClient({
589
+ ...this.config,
590
+ conversationId: threadId || this.config.conversationId,
591
+ onPlacement,
592
+ onError
593
+ });
594
+ if (this.config.prefetch && options?.prompt) {
595
+ client.startPrefetch(options.prompt);
596
+ }
597
+ yield event;
598
+ break;
599
+ }
600
+ case "TEXT_MESSAGE_START":
601
+ case "TextMessageStart": {
602
+ currentMessageId = String(event.messageId ?? "");
603
+ contentOffset = 0;
604
+ if (client && options?.upstreamBody) {
605
+ client.connect(currentMessageId, options.upstreamBody);
606
+ }
607
+ yield event;
608
+ break;
609
+ }
610
+ case "TEXT_MESSAGE_CONTENT":
611
+ case "TextMessageContent": {
612
+ yield event;
613
+ contentOffset += String(event.delta ?? "").length;
614
+ if (client) {
615
+ for (const ad of client.drainInlineAds()) {
616
+ yield this.makeInlineAdEvent(ad, currentMessageId, contentOffset);
617
+ }
618
+ }
619
+ break;
620
+ }
621
+ case "TEXT_MESSAGE_END":
622
+ case "TextMessageEnd": {
623
+ if (client) {
624
+ await client.finalize();
625
+ for (const ad of client.drainInlineAds()) {
626
+ yield this.makeInlineAdEvent(ad, currentMessageId, contentOffset);
627
+ }
628
+ }
629
+ yield event;
630
+ break;
631
+ }
632
+ case "RUN_FINISHED":
633
+ case "RunFinished": {
634
+ if (client) {
635
+ await client.pollPlacements();
636
+ client.abort();
637
+ }
638
+ for (const placement of sidecarPlacements) {
639
+ yield this.makePlacementEvent(placement, placement.ad_id);
640
+ }
641
+ yield event;
642
+ break;
643
+ }
644
+ default:
645
+ yield event;
646
+ }
647
+ }
648
+ }
649
+ /**
650
+ * Wraps an inline ad as an AG-UI custom event anchored to `contentOffset`,
651
+ * the number of message characters emitted before the ad arrived. Clients
652
+ * use it to render the ad directly after that content instead of at the
653
+ * end of the message.
654
+ */
655
+ makeInlineAdEvent(ad, messageId, contentOffset) {
656
+ const value = {
657
+ ...ad,
658
+ message_id: messageId,
659
+ content_offset: contentOffset
660
+ };
661
+ return {
662
+ type: "CUSTOM_EVENT",
663
+ name: "adzen_inline_ad",
664
+ messageId,
665
+ value
666
+ };
667
+ }
668
+ makePlacementEvent(placement, adId) {
669
+ return {
670
+ type: "CUSTOM_EVENT",
671
+ name: "adzen_placement",
672
+ messageId: adId,
673
+ value: {
674
+ ...placement,
675
+ message_id: placement.ad_id
676
+ }
677
+ };
678
+ }
679
+ mapPlacementRecord(record, messageId) {
680
+ return {
681
+ ad_id: String(record.ad_id),
682
+ advertiser_name: "",
683
+ headline: record.headline,
684
+ cta_text: record.cta_text,
685
+ destination_url: record.destination_url,
686
+ adUnitPosition: record.placement || this.adUnitPosition,
687
+ render_impression_url: record.tracking.impression_url,
688
+ view_impression_url: record.tracking.impression_url
689
+ };
690
+ }
691
+ };
259
692
  // Annotate the CommonJS export names for ESM import in node:
260
693
  0 && (module.exports = {
261
694
  AdzenAsyncMiddleware,
262
- createImpressionHandler
695
+ AdzenStreamMiddleware
263
696
  });
264
697
  //# sourceMappingURL=index.cjs.map