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