@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.
- package/CHANGELOG.md +70 -0
- package/dist/ai.d.ts +25 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +53 -0
- package/dist/ai.js.map +1 -0
- package/dist/core/ai/AIEngine.d.ts +69 -0
- package/dist/core/ai/AIEngine.d.ts.map +1 -0
- package/dist/core/ai/AIEngine.js +185 -0
- package/dist/core/ai/AIEngine.js.map +1 -0
- package/dist/core/auth/index.d.ts +183 -0
- package/dist/core/auth/index.d.ts.map +1 -0
- package/dist/core/auth/index.js +369 -0
- package/dist/core/auth/index.js.map +1 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.d.ts.map +1 -0
- package/dist/core/billing/index.js +91 -0
- package/dist/core/billing/index.js.map +1 -0
- package/dist/core/booking/index.d.ts +38 -0
- package/dist/core/booking/index.d.ts.map +1 -0
- package/dist/core/booking/index.js +60 -0
- package/dist/core/booking/index.js.map +1 -0
- package/dist/core/chat/index.d.ts +48 -0
- package/dist/core/chat/index.d.ts.map +1 -0
- package/dist/core/chat/index.js +83 -0
- package/dist/core/chat/index.js.map +1 -0
- package/dist/core/document/index.d.ts +41 -0
- package/dist/core/document/index.d.ts.map +1 -0
- package/dist/core/document/index.js +68 -0
- package/dist/core/document/index.js.map +1 -0
- package/dist/core/events/index.d.ts +64 -0
- package/dist/core/events/index.d.ts.map +1 -0
- package/dist/core/events/index.js +60 -0
- package/dist/core/events/index.js.map +1 -0
- package/dist/core/geolocation/index.d.ts +32 -0
- package/dist/core/geolocation/index.d.ts.map +1 -0
- package/dist/core/geolocation/index.js +50 -0
- package/dist/core/geolocation/index.js.map +1 -0
- package/dist/core/kyc/index.d.ts +37 -0
- package/dist/core/kyc/index.d.ts.map +1 -0
- package/dist/core/kyc/index.js +60 -0
- package/dist/core/kyc/index.js.map +1 -0
- package/dist/core/logger/index.d.ts +60 -0
- package/dist/core/logger/index.d.ts.map +1 -0
- package/dist/core/logger/index.js +91 -0
- package/dist/core/logger/index.js.map +1 -0
- package/dist/core/notifications/index.d.ts +41 -0
- package/dist/core/notifications/index.d.ts.map +1 -0
- package/dist/core/notifications/index.js +111 -0
- package/dist/core/notifications/index.js.map +1 -0
- package/dist/core/rbac/index.d.ts +43 -0
- package/dist/core/rbac/index.d.ts.map +1 -0
- package/dist/core/rbac/index.js +66 -0
- package/dist/core/rbac/index.js.map +1 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +22 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/kyc.d.ts +12 -0
- package/dist/kyc.d.ts.map +1 -0
- package/dist/kyc.js +2 -0
- package/dist/kyc.js.map +1 -0
- package/dist/nanoid.d.ts +8 -0
- package/dist/nanoid.d.ts.map +1 -0
- package/dist/nanoid.js +15 -0
- package/dist/nanoid.js.map +1 -0
- package/dist/ndpr.d.ts +13 -0
- package/dist/ndpr.d.ts.map +1 -0
- package/dist/ndpr.js +19 -0
- package/dist/ndpr.js.map +1 -0
- package/dist/optimistic-lock.d.ts +11 -0
- package/dist/optimistic-lock.d.ts.map +1 -0
- package/dist/optimistic-lock.js +24 -0
- package/dist/optimistic-lock.js.map +1 -0
- package/dist/payment.d.ts +41 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +116 -0
- package/dist/payment.js.map +1 -0
- package/dist/pin.d.ts +6 -0
- package/dist/pin.d.ts.map +1 -0
- package/dist/pin.js +18 -0
- package/dist/pin.js.map +1 -0
- package/dist/query-helpers.d.ts +18 -0
- package/dist/query-helpers.d.ts.map +1 -0
- package/dist/query-helpers.js +22 -0
- package/dist/query-helpers.js.map +1 -0
- package/dist/rate-limit.d.ts +13 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +16 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/sms.d.ts +23 -0
- package/dist/sms.d.ts.map +1 -0
- package/dist/sms.js +60 -0
- package/dist/sms.js.map +1 -0
- package/dist/tax.d.ts +25 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +31 -0
- package/dist/tax.js.map +1 -0
- package/package.json +99 -0
- package/src/ai.test.ts +146 -0
- package/src/ai.ts +75 -0
- package/src/core/ai/AIEngine.test.ts +386 -0
- package/src/core/ai/AIEngine.ts +281 -0
- package/src/core/auth/index.test.ts +268 -0
- package/src/core/auth/index.ts +570 -0
- package/src/core/billing/index.test.ts +154 -0
- package/src/core/billing/index.ts +132 -0
- package/src/core/booking/index.test.ts +153 -0
- package/src/core/booking/index.ts +91 -0
- package/src/core/chat/index.test.ts +159 -0
- package/src/core/chat/index.ts +130 -0
- package/src/core/document/index.test.ts +106 -0
- package/src/core/document/index.ts +99 -0
- package/src/core/events/index.test.ts +91 -0
- package/src/core/events/index.ts +91 -0
- package/src/core/geolocation/index.test.ts +70 -0
- package/src/core/geolocation/index.ts +69 -0
- package/src/core/kyc/index.test.ts +105 -0
- package/src/core/kyc/index.ts +86 -0
- package/src/core/logger/index.test.ts +110 -0
- package/src/core/logger/index.ts +127 -0
- package/src/core/notifications/index.test.ts +85 -0
- package/src/core/notifications/index.ts +136 -0
- package/src/core/rbac/index.test.ts +81 -0
- package/src/core/rbac/index.ts +85 -0
- package/src/events.test.ts +43 -0
- package/src/events.ts +23 -0
- package/src/index.test.ts +123 -0
- package/src/index.ts +97 -0
- package/src/kyc.ts +23 -0
- package/src/nanoid.test.ts +43 -0
- package/src/nanoid.ts +16 -0
- package/src/ndpr.test.ts +68 -0
- package/src/ndpr.ts +49 -0
- package/src/optimistic-lock.test.ts +75 -0
- package/src/optimistic-lock.ts +36 -0
- package/src/payment.test.ts +152 -0
- package/src/payment.ts +163 -0
- package/src/pin.test.ts +57 -0
- package/src/pin.ts +38 -0
- package/src/query-helpers.test.ts +98 -0
- package/src/query-helpers.ts +36 -0
- package/src/rate-limit.test.ts +98 -0
- package/src/rate-limit.ts +33 -0
- package/src/sms.test.ts +112 -0
- package/src/sms.ts +85 -0
- package/src/tax.test.ts +85 -0
- 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
|
+
}
|