@webwaka/core 1.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.
Files changed (151) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/ai.d.ts +25 -0
  3. package/dist/ai.d.ts.map +1 -0
  4. package/dist/ai.js +53 -0
  5. package/dist/ai.js.map +1 -0
  6. package/dist/core/ai/AIEngine.d.ts +69 -0
  7. package/dist/core/ai/AIEngine.d.ts.map +1 -0
  8. package/dist/core/ai/AIEngine.js +185 -0
  9. package/dist/core/ai/AIEngine.js.map +1 -0
  10. package/dist/core/auth/index.d.ts +183 -0
  11. package/dist/core/auth/index.d.ts.map +1 -0
  12. package/dist/core/auth/index.js +369 -0
  13. package/dist/core/auth/index.js.map +1 -0
  14. package/dist/core/billing/index.d.ts +52 -0
  15. package/dist/core/billing/index.d.ts.map +1 -0
  16. package/dist/core/billing/index.js +91 -0
  17. package/dist/core/billing/index.js.map +1 -0
  18. package/dist/core/booking/index.d.ts +38 -0
  19. package/dist/core/booking/index.d.ts.map +1 -0
  20. package/dist/core/booking/index.js +60 -0
  21. package/dist/core/booking/index.js.map +1 -0
  22. package/dist/core/chat/index.d.ts +48 -0
  23. package/dist/core/chat/index.d.ts.map +1 -0
  24. package/dist/core/chat/index.js +83 -0
  25. package/dist/core/chat/index.js.map +1 -0
  26. package/dist/core/document/index.d.ts +41 -0
  27. package/dist/core/document/index.d.ts.map +1 -0
  28. package/dist/core/document/index.js +68 -0
  29. package/dist/core/document/index.js.map +1 -0
  30. package/dist/core/events/index.d.ts +64 -0
  31. package/dist/core/events/index.d.ts.map +1 -0
  32. package/dist/core/events/index.js +60 -0
  33. package/dist/core/events/index.js.map +1 -0
  34. package/dist/core/geolocation/index.d.ts +32 -0
  35. package/dist/core/geolocation/index.d.ts.map +1 -0
  36. package/dist/core/geolocation/index.js +50 -0
  37. package/dist/core/geolocation/index.js.map +1 -0
  38. package/dist/core/kyc/index.d.ts +37 -0
  39. package/dist/core/kyc/index.d.ts.map +1 -0
  40. package/dist/core/kyc/index.js +60 -0
  41. package/dist/core/kyc/index.js.map +1 -0
  42. package/dist/core/logger/index.d.ts +60 -0
  43. package/dist/core/logger/index.d.ts.map +1 -0
  44. package/dist/core/logger/index.js +91 -0
  45. package/dist/core/logger/index.js.map +1 -0
  46. package/dist/core/notifications/index.d.ts +41 -0
  47. package/dist/core/notifications/index.d.ts.map +1 -0
  48. package/dist/core/notifications/index.js +111 -0
  49. package/dist/core/notifications/index.js.map +1 -0
  50. package/dist/core/rbac/index.d.ts +43 -0
  51. package/dist/core/rbac/index.d.ts.map +1 -0
  52. package/dist/core/rbac/index.js +66 -0
  53. package/dist/core/rbac/index.js.map +1 -0
  54. package/dist/events.d.ts +23 -0
  55. package/dist/events.d.ts.map +1 -0
  56. package/dist/events.js +22 -0
  57. package/dist/events.js.map +1 -0
  58. package/dist/index.d.ts +33 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +56 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/kyc.d.ts +12 -0
  63. package/dist/kyc.d.ts.map +1 -0
  64. package/dist/kyc.js +2 -0
  65. package/dist/kyc.js.map +1 -0
  66. package/dist/nanoid.d.ts +8 -0
  67. package/dist/nanoid.d.ts.map +1 -0
  68. package/dist/nanoid.js +15 -0
  69. package/dist/nanoid.js.map +1 -0
  70. package/dist/ndpr.d.ts +13 -0
  71. package/dist/ndpr.d.ts.map +1 -0
  72. package/dist/ndpr.js +19 -0
  73. package/dist/ndpr.js.map +1 -0
  74. package/dist/optimistic-lock.d.ts +11 -0
  75. package/dist/optimistic-lock.d.ts.map +1 -0
  76. package/dist/optimistic-lock.js +24 -0
  77. package/dist/optimistic-lock.js.map +1 -0
  78. package/dist/payment.d.ts +41 -0
  79. package/dist/payment.d.ts.map +1 -0
  80. package/dist/payment.js +116 -0
  81. package/dist/payment.js.map +1 -0
  82. package/dist/pin.d.ts +6 -0
  83. package/dist/pin.d.ts.map +1 -0
  84. package/dist/pin.js +18 -0
  85. package/dist/pin.js.map +1 -0
  86. package/dist/query-helpers.d.ts +18 -0
  87. package/dist/query-helpers.d.ts.map +1 -0
  88. package/dist/query-helpers.js +22 -0
  89. package/dist/query-helpers.js.map +1 -0
  90. package/dist/rate-limit.d.ts +13 -0
  91. package/dist/rate-limit.d.ts.map +1 -0
  92. package/dist/rate-limit.js +16 -0
  93. package/dist/rate-limit.js.map +1 -0
  94. package/dist/sms.d.ts +23 -0
  95. package/dist/sms.d.ts.map +1 -0
  96. package/dist/sms.js +60 -0
  97. package/dist/sms.js.map +1 -0
  98. package/dist/tax.d.ts +25 -0
  99. package/dist/tax.d.ts.map +1 -0
  100. package/dist/tax.js +31 -0
  101. package/dist/tax.js.map +1 -0
  102. package/package.json +99 -0
  103. package/src/ai.test.ts +146 -0
  104. package/src/ai.ts +75 -0
  105. package/src/core/ai/AIEngine.test.ts +386 -0
  106. package/src/core/ai/AIEngine.ts +281 -0
  107. package/src/core/auth/index.test.ts +268 -0
  108. package/src/core/auth/index.ts +570 -0
  109. package/src/core/billing/index.test.ts +154 -0
  110. package/src/core/billing/index.ts +132 -0
  111. package/src/core/booking/index.test.ts +153 -0
  112. package/src/core/booking/index.ts +91 -0
  113. package/src/core/chat/index.test.ts +159 -0
  114. package/src/core/chat/index.ts +130 -0
  115. package/src/core/document/index.test.ts +106 -0
  116. package/src/core/document/index.ts +99 -0
  117. package/src/core/events/index.test.ts +91 -0
  118. package/src/core/events/index.ts +91 -0
  119. package/src/core/geolocation/index.test.ts +70 -0
  120. package/src/core/geolocation/index.ts +69 -0
  121. package/src/core/kyc/index.test.ts +105 -0
  122. package/src/core/kyc/index.ts +86 -0
  123. package/src/core/logger/index.test.ts +110 -0
  124. package/src/core/logger/index.ts +127 -0
  125. package/src/core/notifications/index.test.ts +85 -0
  126. package/src/core/notifications/index.ts +136 -0
  127. package/src/core/rbac/index.test.ts +81 -0
  128. package/src/core/rbac/index.ts +85 -0
  129. package/src/events.test.ts +43 -0
  130. package/src/events.ts +23 -0
  131. package/src/index.test.ts +123 -0
  132. package/src/index.ts +97 -0
  133. package/src/kyc.ts +23 -0
  134. package/src/nanoid.test.ts +43 -0
  135. package/src/nanoid.ts +16 -0
  136. package/src/ndpr.test.ts +68 -0
  137. package/src/ndpr.ts +49 -0
  138. package/src/optimistic-lock.test.ts +75 -0
  139. package/src/optimistic-lock.ts +36 -0
  140. package/src/payment.test.ts +152 -0
  141. package/src/payment.ts +163 -0
  142. package/src/pin.test.ts +57 -0
  143. package/src/pin.ts +38 -0
  144. package/src/query-helpers.test.ts +98 -0
  145. package/src/query-helpers.ts +36 -0
  146. package/src/rate-limit.test.ts +98 -0
  147. package/src/rate-limit.ts +33 -0
  148. package/src/sms.test.ts +112 -0
  149. package/src/sms.ts +85 -0
  150. package/src/tax.test.ts +85 -0
  151. package/src/tax.ts +57 -0
@@ -0,0 +1,386 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { AIEngine } from './AIEngine';
3
+ import { logger } from '../logger';
4
+
5
+ // Mock fetch for OpenRouter
6
+ global.fetch = vi.fn();
7
+
8
+ /**
9
+ * Default engine uses maxRetries: 0 for the legacy three-tier fallback tests so
10
+ * that the existing behavior (single attempt per tier, no retries) is preserved.
11
+ * Retry-specific tests construct their own engine instances with maxRetries > 0.
12
+ */
13
+ describe('CORE-5: AIEngine (Vendor Neutral AI)', () => {
14
+ let aiEngine: AIEngine;
15
+ let mockCloudflareAi: any;
16
+
17
+ beforeEach(() => {
18
+ vi.resetAllMocks();
19
+
20
+ mockCloudflareAi = {
21
+ run: vi.fn().mockResolvedValue({ response: 'Cloudflare AI response' }),
22
+ };
23
+
24
+ // maxRetries: 0 → single attempt per tier, matches the original test expectations.
25
+ aiEngine = new AIEngine('platform-key-123', mockCloudflareAi, { maxRetries: 0 });
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ // ─── Original three-tier fallback tests (no retries) ──────────────────────
33
+
34
+ it('should use Tenant BYOK when provided (Tier 1)', async () => {
35
+ (global.fetch as any).mockResolvedValueOnce({
36
+ ok: true,
37
+ json: async () => ({
38
+ choices: [{ message: { content: 'Tenant BYOK response' } }],
39
+ }),
40
+ });
41
+
42
+ const response = await aiEngine.execute(
43
+ { prompt: 'Hello', tenantId: 'tenant-1' },
44
+ { openRouterKey: 'tenant-key-456' }
45
+ );
46
+
47
+ expect(response.provider).toBe('tenant-openrouter');
48
+ expect(response.text).toBe('Tenant BYOK response');
49
+ expect(global.fetch).toHaveBeenCalledTimes(1);
50
+
51
+ const fetchArgs = (global.fetch as any).mock.calls[0];
52
+ expect(fetchArgs[1].headers['Authorization']).toBe('Bearer tenant-key-456');
53
+ });
54
+
55
+ it('should fallback to Platform Key if Tenant BYOK fails (Tier 2)', async () => {
56
+ // Tier 1 single attempt fails (maxRetries: 0)
57
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
58
+
59
+ // Tier 2 single attempt succeeds
60
+ (global.fetch as any).mockResolvedValueOnce({
61
+ ok: true,
62
+ json: async () => ({
63
+ choices: [{ message: { content: 'Platform key response' } }],
64
+ }),
65
+ });
66
+
67
+ const response = await aiEngine.execute(
68
+ { prompt: 'Hello', tenantId: 'tenant-1' },
69
+ { openRouterKey: 'tenant-key-456' }
70
+ );
71
+
72
+ expect(response.provider).toBe('platform-openrouter');
73
+ expect(response.text).toBe('Platform key response');
74
+ expect(global.fetch).toHaveBeenCalledTimes(2);
75
+
76
+ const secondFetchArgs = (global.fetch as any).mock.calls[1];
77
+ expect(secondFetchArgs[1].headers['Authorization']).toBe('Bearer platform-key-123');
78
+ });
79
+
80
+ it('should fallback to Cloudflare AI if OpenRouter fails completely (Tier 3)', async () => {
81
+ // All fetch calls fail (Tier 1 + Tier 2, each 1 attempt with maxRetries: 0)
82
+ (global.fetch as any).mockRejectedValue(new Error('Network error'));
83
+
84
+ const response = await aiEngine.execute(
85
+ { prompt: 'Hello', tenantId: 'tenant-1' },
86
+ { openRouterKey: 'tenant-key-456' }
87
+ );
88
+
89
+ expect(response.provider).toBe('cloudflare-ai');
90
+ expect(response.text).toBe('Cloudflare AI response');
91
+ expect(mockCloudflareAi.run).toHaveBeenCalledTimes(1);
92
+ });
93
+
94
+ // ─── Retry / backoff tests ─────────────────────────────────────────────────
95
+
96
+ it('should retry within Tier 1 before succeeding (no tier escalation)', async () => {
97
+ // Engine with default maxRetries: 2
98
+ const retryEngine = new AIEngine('platform-key-123', mockCloudflareAi, {
99
+ maxRetries: 2,
100
+ backoffMs: 0, // zero delay for test speed
101
+ });
102
+
103
+ // Mock sleep to avoid real delays
104
+ vi.spyOn(retryEngine as any, 'sleep').mockResolvedValue(undefined);
105
+
106
+ // First two Tier 1 calls fail, third succeeds
107
+ (global.fetch as any)
108
+ .mockRejectedValueOnce(new Error('transient'))
109
+ .mockRejectedValueOnce(new Error('transient'))
110
+ .mockResolvedValueOnce({
111
+ ok: true,
112
+ json: async () => ({
113
+ choices: [{ message: { content: 'Retry success on Tier 1' } }],
114
+ }),
115
+ });
116
+
117
+ const response = await retryEngine.execute(
118
+ { prompt: 'Hello', tenantId: 'tenant-1' },
119
+ { openRouterKey: 'tenant-key-456' }
120
+ );
121
+
122
+ expect(response.provider).toBe('tenant-openrouter');
123
+ expect(response.text).toBe('Retry success on Tier 1');
124
+ // 3 fetch calls — all on Tier 1, no escalation to Tier 2
125
+ expect(global.fetch).toHaveBeenCalledTimes(3);
126
+ expect(mockCloudflareAi.run).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it('should emit logger.warn for each retry attempt', async () => {
130
+ const retryEngine = new AIEngine('platform-key-123', mockCloudflareAi, {
131
+ maxRetries: 2,
132
+ backoffMs: 0,
133
+ });
134
+
135
+ vi.spyOn(retryEngine as any, 'sleep').mockResolvedValue(undefined);
136
+ const warnSpy = vi.spyOn(logger, 'warn');
137
+
138
+ // All Tier 1 attempts fail, Tier 2 first attempt succeeds
139
+ (global.fetch as any)
140
+ .mockRejectedValueOnce(new Error('fail'))
141
+ .mockRejectedValueOnce(new Error('fail'))
142
+ .mockRejectedValueOnce(new Error('fail'))
143
+ .mockResolvedValueOnce({
144
+ ok: true,
145
+ json: async () => ({
146
+ choices: [{ message: { content: 'Platform response' } }],
147
+ }),
148
+ });
149
+
150
+ const response = await retryEngine.execute(
151
+ { prompt: 'Test', tenantId: 'tenant-warn' },
152
+ { openRouterKey: 'tenant-key' }
153
+ );
154
+
155
+ expect(response.provider).toBe('platform-openrouter');
156
+ // logger.warn should have been called for retries + exhaustion
157
+ expect(warnSpy).toHaveBeenCalled();
158
+ const warnMessages = warnSpy.mock.calls.map(c => c[0]);
159
+ expect(warnMessages.some(m => m.includes('retry') || m.includes('exhausted'))).toBe(true);
160
+ });
161
+
162
+ it('should call sleep with exponential backoff delays between retries', async () => {
163
+ const retryEngine = new AIEngine('platform-key-123', mockCloudflareAi, {
164
+ maxRetries: 2,
165
+ backoffMs: 100,
166
+ });
167
+
168
+ const sleepSpy = vi.spyOn(retryEngine as any, 'sleep').mockResolvedValue(undefined);
169
+
170
+ // All Tier 1 attempts fail, Tier 2 succeeds immediately
171
+ (global.fetch as any)
172
+ .mockRejectedValueOnce(new Error('fail'))
173
+ .mockRejectedValueOnce(new Error('fail'))
174
+ .mockRejectedValueOnce(new Error('fail'))
175
+ .mockResolvedValueOnce({
176
+ ok: true,
177
+ json: async () => ({
178
+ choices: [{ message: { content: 'ok' } }],
179
+ }),
180
+ });
181
+
182
+ await retryEngine.execute(
183
+ { prompt: 'Test', tenantId: 't1' },
184
+ { openRouterKey: 'key' }
185
+ );
186
+
187
+ // Retries 1 and 2 within Tier 1: delays = 100*2^0=100ms, 100*2^1=200ms
188
+ const sleepCalls = sleepSpy.mock.calls.map(c => c[0]);
189
+ expect(sleepCalls).toContain(100);
190
+ expect(sleepCalls).toContain(200);
191
+ });
192
+
193
+ it('should retry within Tier 1, exhaust, then fall to Tier 2 and succeed', async () => {
194
+ const retryEngine = new AIEngine('platform-key-123', mockCloudflareAi, {
195
+ maxRetries: 1,
196
+ backoffMs: 0,
197
+ });
198
+
199
+ vi.spyOn(retryEngine as any, 'sleep').mockResolvedValue(undefined);
200
+
201
+ // Tier 1: 2 attempts (initial + 1 retry), both fail
202
+ // Tier 2: 1 attempt, succeeds
203
+ (global.fetch as any)
204
+ .mockRejectedValueOnce(new Error('fail'))
205
+ .mockRejectedValueOnce(new Error('fail'))
206
+ .mockResolvedValueOnce({
207
+ ok: true,
208
+ json: async () => ({
209
+ choices: [{ message: { content: 'Tier 2 after Tier 1 exhaustion' } }],
210
+ }),
211
+ });
212
+
213
+ const response = await retryEngine.execute(
214
+ { prompt: 'Hello', tenantId: 'tenant-1' },
215
+ { openRouterKey: 'tenant-key' }
216
+ );
217
+
218
+ expect(response.provider).toBe('platform-openrouter');
219
+ expect(response.text).toBe('Tier 2 after Tier 1 exhaustion');
220
+ expect(global.fetch).toHaveBeenCalledTimes(3);
221
+ });
222
+
223
+ it('should throw (and trigger retry/fallback) when OpenRouter returns malformed payload', async () => {
224
+ // Engine with maxRetries: 0 so it escalates immediately after one bad response
225
+ // Tier 1: returns HTTP 200 but with missing choices structure → should throw → fallback
226
+ (global.fetch as any).mockResolvedValueOnce({
227
+ ok: true,
228
+ json: async () => ({ result: 'unexpected shape' }), // no choices array
229
+ });
230
+
231
+ // Tier 2: also returns malformed payload → throws → escalates to Tier 3
232
+ (global.fetch as any).mockResolvedValueOnce({
233
+ ok: true,
234
+ json: async () => ({}), // completely empty
235
+ });
236
+
237
+ // Tier 3 CF AI succeeds
238
+ const response = await aiEngine.execute(
239
+ { prompt: 'Hello', tenantId: 'tenant-1' },
240
+ { openRouterKey: 'tenant-key-456' }
241
+ );
242
+
243
+ expect(response.provider).toBe('cloudflare-ai');
244
+ expect(mockCloudflareAi.run).toHaveBeenCalledTimes(1);
245
+ });
246
+
247
+ it('should retry Tier 1 on malformed payload before escalating (maxRetries: 1)', async () => {
248
+ const retryEngine = new AIEngine('platform-key-123', mockCloudflareAi, {
249
+ maxRetries: 1,
250
+ backoffMs: 0,
251
+ });
252
+ vi.spyOn(retryEngine as any, 'sleep').mockResolvedValue(undefined);
253
+
254
+ // Tier 1: attempt 1 → malformed, attempt 2 (retry) → valid
255
+ (global.fetch as any)
256
+ .mockResolvedValueOnce({
257
+ ok: true,
258
+ json: async () => ({ bad: 'payload' }),
259
+ })
260
+ .mockResolvedValueOnce({
261
+ ok: true,
262
+ json: async () => ({
263
+ choices: [{ message: { content: 'Recovered after retry' } }],
264
+ }),
265
+ });
266
+
267
+ const response = await retryEngine.execute(
268
+ { prompt: 'Hello', tenantId: 'tenant-1' },
269
+ { openRouterKey: 'tenant-key-456' }
270
+ );
271
+
272
+ expect(response.provider).toBe('tenant-openrouter');
273
+ expect(response.text).toBe('Recovered after retry');
274
+ expect(global.fetch).toHaveBeenCalledTimes(2);
275
+ });
276
+
277
+ // ─── executeStream tests ───────────────────────────────────────────────────
278
+
279
+ it('should return a ReadableStream from executeStream (Tier 1 success)', async () => {
280
+ const mockBody = new ReadableStream<Uint8Array>();
281
+
282
+ (global.fetch as any).mockResolvedValueOnce({
283
+ ok: true,
284
+ body: mockBody,
285
+ });
286
+
287
+ const stream = await aiEngine.executeStream(
288
+ { prompt: 'Stream test', tenantId: 'tenant-1' },
289
+ { openRouterKey: 'tenant-key-456' }
290
+ );
291
+
292
+ expect(stream).toBeInstanceOf(ReadableStream);
293
+ expect(stream).toBe(mockBody);
294
+ });
295
+
296
+ it('should include stream: true in the OpenRouter request body for executeStream', async () => {
297
+ const mockBody = new ReadableStream<Uint8Array>();
298
+
299
+ (global.fetch as any).mockResolvedValueOnce({ ok: true, body: mockBody });
300
+
301
+ await aiEngine.executeStream(
302
+ { prompt: 'Stream test', tenantId: 'tenant-1' },
303
+ { openRouterKey: 'tenant-key-456' }
304
+ );
305
+
306
+ const fetchCall = (global.fetch as any).mock.calls[0];
307
+ const bodyObj = JSON.parse(fetchCall[1].body);
308
+ expect(bodyObj.stream).toBe(true);
309
+ });
310
+
311
+ it('should fall back to Tier 2 stream if Tier 1 executeStream fails', async () => {
312
+ // maxRetries: 0 → single attempt per tier
313
+ const mockBody = new ReadableStream<Uint8Array>();
314
+
315
+ // Tier 1 fails
316
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
317
+ // Tier 2 succeeds with streaming body
318
+ (global.fetch as any).mockResolvedValueOnce({ ok: true, body: mockBody });
319
+
320
+ const stream = await aiEngine.executeStream(
321
+ { prompt: 'Stream test', tenantId: 'tenant-1' },
322
+ { openRouterKey: 'tenant-key-456' }
323
+ );
324
+
325
+ expect(stream).toBeInstanceOf(ReadableStream);
326
+ expect(stream).toBe(mockBody);
327
+ expect(global.fetch).toHaveBeenCalledTimes(2);
328
+ });
329
+
330
+ it('should exhaust Tier 1 stream retries before falling to Tier 2 stream (maxRetries: 1)', async () => {
331
+ const retryEngine = new AIEngine('platform-key-123', mockCloudflareAi, {
332
+ maxRetries: 1,
333
+ backoffMs: 0,
334
+ });
335
+ vi.spyOn(retryEngine as any, 'sleep').mockResolvedValue(undefined);
336
+
337
+ const mockBody = new ReadableStream<Uint8Array>();
338
+
339
+ // Tier 1: 2 attempts (initial + 1 retry), both fail
340
+ (global.fetch as any)
341
+ .mockRejectedValueOnce(new Error('stream fail'))
342
+ .mockRejectedValueOnce(new Error('stream fail'))
343
+ // Tier 2: succeeds
344
+ .mockResolvedValueOnce({ ok: true, body: mockBody });
345
+
346
+ const stream = await retryEngine.executeStream(
347
+ { prompt: 'Hello', tenantId: 'tenant-1' },
348
+ { openRouterKey: 'tenant-key' }
349
+ );
350
+
351
+ expect(stream).toBeInstanceOf(ReadableStream);
352
+ expect(stream).toBe(mockBody);
353
+ expect(global.fetch).toHaveBeenCalledTimes(3);
354
+ });
355
+
356
+ it('should fall back to Cloudflare AI single-chunk stream if all OpenRouter stream attempts fail', async () => {
357
+ // maxRetries: 0 → both Tier 1 and Tier 2 fail on first attempt
358
+ (global.fetch as any).mockRejectedValue(new Error('Network error'));
359
+
360
+ const stream = await aiEngine.executeStream(
361
+ { prompt: 'Stream test', tenantId: 'tenant-1' },
362
+ { openRouterKey: 'tenant-key-456' }
363
+ );
364
+
365
+ expect(stream).toBeInstanceOf(ReadableStream);
366
+ expect(mockCloudflareAi.run).toHaveBeenCalledTimes(1);
367
+
368
+ // Read the single chunk and verify it contains the CF AI response text
369
+ const reader = stream.getReader();
370
+ const { value, done } = await reader.read();
371
+ expect(done).toBe(false);
372
+ expect(value).toBeInstanceOf(Uint8Array);
373
+ const text = new TextDecoder().decode(value);
374
+ expect(text).toBe('Cloudflare AI response');
375
+ });
376
+
377
+ it('should respect configurable maxRetries and backoffMs via constructor options', () => {
378
+ const customEngine = new AIEngine('key', mockCloudflareAi, {
379
+ maxRetries: 5,
380
+ backoffMs: 50,
381
+ });
382
+ // Access private fields via cast to validate constructor wiring
383
+ expect((customEngine as any).maxRetries).toBe(5);
384
+ expect((customEngine as any).backoffMs).toBe(50);
385
+ });
386
+ });
@@ -0,0 +1,281 @@
1
+ /**
2
+ * CORE-5: AI/BYOK Abstraction Engine
3
+ * Blueprint Reference: Part 9.1 #7 (Vendor Neutral AI)
4
+ *
5
+ * Implements a three-tier fallback mechanism with per-tier retry and exponential backoff:
6
+ * 1. Tenant BYOK (Bring Your Own Key) via OpenRouter
7
+ * 2. Platform Key via OpenRouter
8
+ * 3. Cloudflare Workers AI (Ultimate Fallback)
9
+ *
10
+ * Each tier is retried up to `maxRetries` times (default 2) before escalating.
11
+ * Between attempts the engine sleeps for backoffMs * 2^attempt milliseconds.
12
+ */
13
+
14
+ import { logger } from '../logger';
15
+
16
+ export interface AIRequest {
17
+ prompt: string;
18
+ model?: string;
19
+ tenantId: string;
20
+ }
21
+
22
+ export interface AIResponse {
23
+ text: string;
24
+ provider: 'tenant-openrouter' | 'platform-openrouter' | 'cloudflare-ai';
25
+ modelUsed: string;
26
+ }
27
+
28
+ export interface TenantConfig {
29
+ openRouterKey?: string;
30
+ preferredModel?: string;
31
+ }
32
+
33
+ export interface AIEngineOptions {
34
+ /** Maximum number of retry attempts per tier before escalating. Default: 2 */
35
+ maxRetries?: number;
36
+ /** Base delay in milliseconds for exponential backoff. Default: 200 */
37
+ backoffMs?: number;
38
+ }
39
+
40
+ export class AIEngine {
41
+ private platformOpenRouterKey: string;
42
+ private cloudflareAiBinding: any; // Type would be Ai from @cloudflare/workers-types
43
+ private maxRetries: number;
44
+ private backoffMs: number;
45
+
46
+ constructor(
47
+ platformOpenRouterKey: string,
48
+ cloudflareAiBinding: any,
49
+ options: AIEngineOptions = {}
50
+ ) {
51
+ this.platformOpenRouterKey = platformOpenRouterKey;
52
+ this.cloudflareAiBinding = cloudflareAiBinding;
53
+ this.maxRetries = options.maxRetries ?? 2;
54
+ this.backoffMs = options.backoffMs ?? 200;
55
+ }
56
+
57
+ /**
58
+ * Executes an AI request using the three-tier fallback strategy.
59
+ * Each tier is retried up to `maxRetries` times with exponential backoff
60
+ * before escalating to the next tier.
61
+ */
62
+ async execute(request: AIRequest, tenantConfig: TenantConfig): Promise<AIResponse> {
63
+ // Tier 1: Tenant BYOK via OpenRouter
64
+ if (tenantConfig.openRouterKey) {
65
+ const tier1Result = await this.withRetry(
66
+ 'Tier 1 (tenant BYOK)',
67
+ request.tenantId,
68
+ () => this.callOpenRouter(
69
+ request.prompt,
70
+ tenantConfig.openRouterKey!,
71
+ request.model ?? tenantConfig.preferredModel ?? 'openai/gpt-4o-mini',
72
+ 'tenant-openrouter'
73
+ )
74
+ );
75
+ if (tier1Result !== null) return tier1Result;
76
+ }
77
+
78
+ // Tier 2: Platform Key via OpenRouter
79
+ if (this.platformOpenRouterKey) {
80
+ const tier2Result = await this.withRetry(
81
+ 'Tier 2 (platform key)',
82
+ request.tenantId,
83
+ () => this.callOpenRouter(
84
+ request.prompt,
85
+ this.platformOpenRouterKey,
86
+ request.model ?? 'openai/gpt-4o-mini',
87
+ 'platform-openrouter'
88
+ )
89
+ );
90
+ if (tier2Result !== null) return tier2Result;
91
+ }
92
+
93
+ // Tier 3: Cloudflare Workers AI (Ultimate Fallback — no retry needed, CF AI is durable)
94
+ return await this.callCloudflareAI(request.prompt);
95
+ }
96
+
97
+ /**
98
+ * Executes an AI request and returns a ReadableStream<Uint8Array> for
99
+ * server-sent events streaming.
100
+ *
101
+ * - Tier 1 & 2: calls OpenRouter with `stream: true` and pipes `Response.body`.
102
+ * - Tier 3 (CF AI fallback): calls `execute()` and wraps the text in a
103
+ * single-chunk ReadableStream (CF AI does not support streaming natively).
104
+ */
105
+ async executeStream(
106
+ request: AIRequest,
107
+ tenantConfig: TenantConfig
108
+ ): Promise<ReadableStream<Uint8Array>> {
109
+ // Tier 1: Tenant BYOK streaming
110
+ if (tenantConfig.openRouterKey) {
111
+ const tier1Result = await this.withRetry(
112
+ 'Tier 1 stream (tenant BYOK)',
113
+ request.tenantId,
114
+ () => this.callOpenRouterStream(
115
+ request.prompt,
116
+ tenantConfig.openRouterKey!,
117
+ request.model ?? tenantConfig.preferredModel ?? 'openai/gpt-4o-mini'
118
+ )
119
+ );
120
+ if (tier1Result !== null) return tier1Result;
121
+ }
122
+
123
+ // Tier 2: Platform Key streaming
124
+ if (this.platformOpenRouterKey) {
125
+ const tier2Result = await this.withRetry(
126
+ 'Tier 2 stream (platform key)',
127
+ request.tenantId,
128
+ () => this.callOpenRouterStream(
129
+ request.prompt,
130
+ this.platformOpenRouterKey,
131
+ request.model ?? 'openai/gpt-4o-mini'
132
+ )
133
+ );
134
+ if (tier2Result !== null) return tier2Result;
135
+ }
136
+
137
+ // Tier 3: Cloudflare AI — wrap non-streaming execute() result in a single-chunk stream
138
+ // (CF AI does not support native streaming; this keeps fallback logic in one place)
139
+ const fallback = await this.execute(request, tenantConfig);
140
+ const encoder = new TextEncoder();
141
+ const chunk = encoder.encode(fallback.text);
142
+ return new ReadableStream<Uint8Array>({
143
+ start(controller) {
144
+ controller.enqueue(chunk);
145
+ controller.close();
146
+ },
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Runs `fn` up to `maxRetries + 1` times (1 initial attempt + maxRetries retries).
152
+ * Emits logger.warn on each retry. Returns the result on success, or null after
153
+ * exhausting all attempts (so the caller can escalate to the next tier).
154
+ */
155
+ private async withRetry<T>(
156
+ tierLabel: string,
157
+ tenantId: string,
158
+ fn: () => Promise<T>
159
+ ): Promise<T | null> {
160
+ let lastError: unknown;
161
+
162
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
163
+ if (attempt > 0) {
164
+ const delayMs = this.backoffMs * Math.pow(2, attempt - 1);
165
+ logger.warn(`${tierLabel} retry attempt ${attempt} after ${delayMs}ms`, {
166
+ tenantId,
167
+ attempt,
168
+ delayMs,
169
+ });
170
+ await this.sleep(delayMs);
171
+ }
172
+
173
+ try {
174
+ return await fn();
175
+ } catch (error) {
176
+ lastError = error;
177
+ if (attempt < this.maxRetries) {
178
+ logger.warn(`${tierLabel} attempt ${attempt + 1} failed, will retry`, {
179
+ tenantId,
180
+ attempt: attempt + 1,
181
+ error,
182
+ });
183
+ }
184
+ }
185
+ }
186
+
187
+ logger.warn(`${tierLabel} exhausted all ${this.maxRetries + 1} attempts, escalating`, {
188
+ tenantId,
189
+ error: lastError,
190
+ });
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Delays execution for `ms` milliseconds.
196
+ * Defined as a protected method so tests can spy on it via vi.spyOn(engine, 'sleep').
197
+ */
198
+ protected sleep(ms: number): Promise<void> {
199
+ return new Promise(resolve => setTimeout(resolve, ms));
200
+ }
201
+
202
+ private async callOpenRouter(
203
+ prompt: string,
204
+ apiKey: string,
205
+ model: string,
206
+ provider: 'tenant-openrouter' | 'platform-openrouter'
207
+ ): Promise<AIResponse> {
208
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
209
+ method: 'POST',
210
+ headers: {
211
+ 'Authorization': `Bearer ${apiKey}`,
212
+ 'Content-Type': 'application/json',
213
+ 'HTTP-Referer': 'https://webwaka.com',
214
+ 'X-Title': 'WebWaka OS v4',
215
+ },
216
+ body: JSON.stringify({
217
+ model,
218
+ messages: [{ role: 'user', content: prompt }],
219
+ }),
220
+ });
221
+
222
+ if (!response.ok) {
223
+ throw new Error(`OpenRouter API error: ${response.statusText}`);
224
+ }
225
+
226
+ const data = await response.json() as any;
227
+ const content: string | undefined = data.choices?.[0]?.message?.content;
228
+ if (typeof content !== 'string') {
229
+ throw new Error('OpenRouter returned an unexpected response structure (missing choices[0].message.content)');
230
+ }
231
+ return { text: content, provider, modelUsed: model };
232
+ }
233
+
234
+ private async callOpenRouterStream(
235
+ prompt: string,
236
+ apiKey: string,
237
+ model: string
238
+ ): Promise<ReadableStream<Uint8Array>> {
239
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
240
+ method: 'POST',
241
+ headers: {
242
+ 'Authorization': `Bearer ${apiKey}`,
243
+ 'Content-Type': 'application/json',
244
+ 'HTTP-Referer': 'https://webwaka.com',
245
+ 'X-Title': 'WebWaka OS v4',
246
+ },
247
+ body: JSON.stringify({
248
+ model,
249
+ messages: [{ role: 'user', content: prompt }],
250
+ stream: true,
251
+ }),
252
+ });
253
+
254
+ if (!response.ok) {
255
+ throw new Error(`OpenRouter streaming API error: ${response.statusText}`);
256
+ }
257
+
258
+ if (!response.body) {
259
+ throw new Error('OpenRouter returned no response body for streaming request');
260
+ }
261
+
262
+ return response.body;
263
+ }
264
+
265
+ private async callCloudflareAI(prompt: string): Promise<AIResponse> {
266
+ if (!this.cloudflareAiBinding) {
267
+ throw new Error('Cloudflare AI binding not configured');
268
+ }
269
+
270
+ const model = '@cf/meta/llama-3-8b-instruct';
271
+ const response = await this.cloudflareAiBinding.run(model, {
272
+ messages: [{ role: 'user', content: prompt }],
273
+ });
274
+
275
+ return {
276
+ text: response.response,
277
+ provider: 'cloudflare-ai',
278
+ modelUsed: model,
279
+ };
280
+ }
281
+ }