@flowcodex/core 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/index-LbxYtxxS.d.ts +560 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +3840 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +551 -0
- package/dist/kernel/index.js.map +1 -0
- package/package.json +39 -0
- package/src/agent/agent-loop.ts +254 -0
- package/src/agent/context.ts +99 -0
- package/src/agent/conversation-state.ts +44 -0
- package/src/agent/provider-runner.ts +241 -0
- package/src/agent/system-prompt-builder.ts +193 -0
- package/src/execution/compactor.ts +256 -0
- package/src/execution/index.ts +7 -0
- package/src/execution/output-serializer.ts +90 -0
- package/src/execution/schema-validator.ts +124 -0
- package/src/execution/tool-executor.ts +276 -0
- package/src/execution/tool-registry.ts +104 -0
- package/src/index.ts +215 -0
- package/src/infrastructure/catalog-parser.ts +218 -0
- package/src/infrastructure/index.ts +16 -0
- package/src/infrastructure/path-resolver.ts +123 -0
- package/src/infrastructure/provider-factory.ts +116 -0
- package/src/infrastructure/provider-presets.ts +19 -0
- package/src/infrastructure/retry-policy.ts +50 -0
- package/src/infrastructure/secret-scrubber.ts +67 -0
- package/src/infrastructure/token-counter.ts +156 -0
- package/src/infrastructure/tracer.ts +23 -0
- package/src/kernel/container.ts +166 -0
- package/src/kernel/events.ts +323 -0
- package/src/kernel/index.ts +18 -0
- package/src/kernel/pipeline.ts +152 -0
- package/src/kernel/run-controller.ts +85 -0
- package/src/kernel/tokens.ts +21 -0
- package/src/security/index.ts +13 -0
- package/src/security/permission-policy.ts +273 -0
- package/src/session/audit-log.ts +201 -0
- package/src/session/auth-service.ts +178 -0
- package/src/session/index.ts +26 -0
- package/src/session/secret-vault.ts +183 -0
- package/src/session/session-store.ts +339 -0
- package/src/session/types.ts +100 -0
- package/src/types/blocks.ts +56 -0
- package/src/types/context.ts +54 -0
- package/src/types/errors.ts +359 -0
- package/src/types/index.ts +34 -0
- package/src/types/provider.ts +58 -0
- package/src/types/tool.ts +39 -0
- package/src/utils/error.ts +3 -0
- package/src/utils/fs.ts +185 -0
- package/src/utils/image-resize.ts +76 -0
- package/src/utils/ssrf-guard.ts +133 -0
- package/src/utils/ulid.ts +72 -0
- package/src/utils/version-check.ts +59 -0
- package/tests/agent-loop.test.ts +490 -0
- package/tests/audit-log.test.ts +199 -0
- package/tests/auth-service.test.ts +170 -0
- package/tests/blocks.test.ts +79 -0
- package/tests/catalog-parser.test.ts +174 -0
- package/tests/compactor.test.ts +180 -0
- package/tests/container.test.ts +224 -0
- package/tests/conversation-state.test.ts +75 -0
- package/tests/errors.test.ts +429 -0
- package/tests/events-v021.test.ts +60 -0
- package/tests/events-v022.test.ts +75 -0
- package/tests/events.test.ts +340 -0
- package/tests/fixtures/large-image.png +0 -0
- package/tests/fixtures/small-image.png +0 -0
- package/tests/fs-utils.test.ts +164 -0
- package/tests/image-resize.test.ts +51 -0
- package/tests/output-serializer.test.ts +79 -0
- package/tests/path-resolver.test.ts +91 -0
- package/tests/permission-policy.test.ts +174 -0
- package/tests/pipeline.test.ts +193 -0
- package/tests/provider-factory.test.ts +245 -0
- package/tests/provider-runner.test.ts +535 -0
- package/tests/retry-policy.test.ts +104 -0
- package/tests/run-controller.test.ts +115 -0
- package/tests/sanity.test.ts +26 -0
- package/tests/schema-validator.test.ts +109 -0
- package/tests/secret-scrubber.test.ts +133 -0
- package/tests/secret-vault.test.ts +130 -0
- package/tests/session-store.test.ts +429 -0
- package/tests/ssrf-guard.test.ts +112 -0
- package/tests/system-prompt-builder.test.ts +116 -0
- package/tests/token-counter.test.ts +163 -0
- package/tests/tokens.test.ts +42 -0
- package/tests/tool-executor.test.ts +452 -0
- package/tests/tool-registry.test.ts +143 -0
- package/tests/tracer.test.ts +32 -0
- package/tests/ulid.test.ts +53 -0
- package/tests/version-check.test.ts +57 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runProviderWithRetry } from '../src/agent/provider-runner.js';
|
|
3
|
+
import type { ModelInfo } from '../src/infrastructure/catalog-parser.js';
|
|
4
|
+
import { DefaultCatalogParser } from '../src/infrastructure/catalog-parser.js';
|
|
5
|
+
import { DefaultRetryPolicy } from '../src/infrastructure/retry-policy.js';
|
|
6
|
+
import { EventBus } from '../src/kernel/events.js';
|
|
7
|
+
import type { LLMEvent, LLMRequest, Provider } from '../src/types/provider.js';
|
|
8
|
+
|
|
9
|
+
function makeErrorProvider(opts: {
|
|
10
|
+
failTimes: number;
|
|
11
|
+
status: number;
|
|
12
|
+
thenSucceedWith?: LLMEvent[];
|
|
13
|
+
modelName?: string;
|
|
14
|
+
}): { provider: Provider; get callCount(): number; get modelsSeen(): string[] } {
|
|
15
|
+
const state = { callCount: 0, modelsSeen: [] as string[] };
|
|
16
|
+
const provider: Provider = {
|
|
17
|
+
name: opts.modelName ?? 'mock',
|
|
18
|
+
async *stream(req: LLMRequest): AsyncIterable<LLMEvent> {
|
|
19
|
+
state.callCount++;
|
|
20
|
+
state.modelsSeen.push(req.model);
|
|
21
|
+
if (state.callCount <= opts.failTimes) {
|
|
22
|
+
const err = new Error(`HTTP ${opts.status}`);
|
|
23
|
+
(err as { status?: number }).status = opts.status;
|
|
24
|
+
(err as { retryable?: boolean }).retryable = true;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
const events = opts.thenSucceedWith ?? [
|
|
28
|
+
{ type: 'text_delta', text: 'ok' },
|
|
29
|
+
{ type: 'finish', usage: { input: 10, output: 5 }, stopReason: 'end_turn' },
|
|
30
|
+
];
|
|
31
|
+
for (const ev of events) yield ev;
|
|
32
|
+
},
|
|
33
|
+
async complete(): Promise<never> {
|
|
34
|
+
throw new Error('not impl');
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
provider,
|
|
39
|
+
get callCount() {
|
|
40
|
+
return state.callCount;
|
|
41
|
+
},
|
|
42
|
+
get modelsSeen() {
|
|
43
|
+
return state.modelsSeen;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeCatalogWithModels(models: ModelInfo[]): DefaultCatalogParser {
|
|
49
|
+
const parser = new DefaultCatalogParser({ disableNetwork: true });
|
|
50
|
+
(
|
|
51
|
+
parser as unknown as { cached: { models: ModelInfo[]; fetchedAt: number; source: string } }
|
|
52
|
+
).cached = {
|
|
53
|
+
models,
|
|
54
|
+
fetchedAt: Date.now(),
|
|
55
|
+
source: 'snapshot',
|
|
56
|
+
};
|
|
57
|
+
return parser;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CHEAP_MODELS: ModelInfo[] = [
|
|
61
|
+
{
|
|
62
|
+
id: 'claude-haiku-4-5',
|
|
63
|
+
name: 'Haiku',
|
|
64
|
+
provider: 'mock',
|
|
65
|
+
reasoning: false,
|
|
66
|
+
limit: { context: 200_000, output: 8192 },
|
|
67
|
+
cost: { input: 0.8, output: 4 },
|
|
68
|
+
tool_call: true,
|
|
69
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const SIGNAL = new AbortController().signal;
|
|
74
|
+
|
|
75
|
+
describe('runProviderWithRetry — fallback', () => {
|
|
76
|
+
it('falls back to explicit fallbackModel after retry exhaustion on 429', async () => {
|
|
77
|
+
const { provider, modelsSeen } = makeErrorProvider({
|
|
78
|
+
failTimes: 10,
|
|
79
|
+
status: 429,
|
|
80
|
+
thenSucceedWith: [
|
|
81
|
+
{ type: 'text_delta', text: 'fallback ok' },
|
|
82
|
+
{ type: 'finish', usage: { input: 5, output: 2 }, stopReason: 'end_turn' },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const events = new EventBus();
|
|
87
|
+
const request: LLMRequest = {
|
|
88
|
+
model: 'claude-sonnet-4-6',
|
|
89
|
+
system: [],
|
|
90
|
+
messages: [],
|
|
91
|
+
max_tokens: 8192,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = await runProviderWithRetry({
|
|
95
|
+
provider,
|
|
96
|
+
request,
|
|
97
|
+
signal: SIGNAL,
|
|
98
|
+
events,
|
|
99
|
+
retry: new DefaultRetryPolicy(),
|
|
100
|
+
fallbackModel: 'claude-haiku-4-5',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.stopReason).toBe('end_turn');
|
|
104
|
+
expect(modelsSeen[0]).toBe('claude-sonnet-4-6');
|
|
105
|
+
expect(modelsSeen[modelsSeen.length - 1]).toBe('claude-haiku-4-5');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('emits provider.fallback event with correct payload', async () => {
|
|
109
|
+
const { provider } = makeErrorProvider({
|
|
110
|
+
failTimes: 10,
|
|
111
|
+
status: 429,
|
|
112
|
+
thenSucceedWith: [
|
|
113
|
+
{ type: 'text_delta', text: 'ok' },
|
|
114
|
+
{ type: 'finish', usage: { input: 1, output: 1 }, stopReason: 'end_turn' },
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const events = new EventBus();
|
|
119
|
+
let fallbackEvent: { from: string; to: string; reason: string; status: number } | undefined;
|
|
120
|
+
|
|
121
|
+
events.on('provider.fallback', (e) => {
|
|
122
|
+
fallbackEvent = e;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const request: LLMRequest = {
|
|
126
|
+
model: 'sonnet',
|
|
127
|
+
system: [],
|
|
128
|
+
messages: [],
|
|
129
|
+
max_tokens: 8192,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
await runProviderWithRetry({
|
|
133
|
+
provider,
|
|
134
|
+
request,
|
|
135
|
+
signal: SIGNAL,
|
|
136
|
+
events,
|
|
137
|
+
retry: new DefaultRetryPolicy(),
|
|
138
|
+
fallbackModel: 'haiku',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(fallbackEvent).toBeDefined();
|
|
142
|
+
expect(fallbackEvent!.from).toBe('mock/sonnet');
|
|
143
|
+
expect(fallbackEvent!.to).toBe('mock/haiku');
|
|
144
|
+
expect(fallbackEvent!.reason).toBe('rate_limit_exhausted');
|
|
145
|
+
expect(fallbackEvent!.status).toBe(429);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('throws original error when no fallback configured', async () => {
|
|
149
|
+
const { provider } = makeErrorProvider({
|
|
150
|
+
failTimes: 10,
|
|
151
|
+
status: 429,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const events = new EventBus();
|
|
155
|
+
const request: LLMRequest = {
|
|
156
|
+
model: 'sonnet',
|
|
157
|
+
system: [],
|
|
158
|
+
messages: [],
|
|
159
|
+
max_tokens: 8192,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
await expect(
|
|
163
|
+
runProviderWithRetry({
|
|
164
|
+
provider,
|
|
165
|
+
request,
|
|
166
|
+
signal: SIGNAL,
|
|
167
|
+
events,
|
|
168
|
+
retry: new DefaultRetryPolicy(),
|
|
169
|
+
}),
|
|
170
|
+
).rejects.toThrow();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('throws when fallback model also exhausts retries', async () => {
|
|
174
|
+
const { provider } = makeErrorProvider({
|
|
175
|
+
failTimes: 100,
|
|
176
|
+
status: 429,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const events = new EventBus();
|
|
180
|
+
const request: LLMRequest = {
|
|
181
|
+
model: 'sonnet',
|
|
182
|
+
system: [],
|
|
183
|
+
messages: [],
|
|
184
|
+
max_tokens: 8192,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
runProviderWithRetry({
|
|
189
|
+
provider,
|
|
190
|
+
request,
|
|
191
|
+
signal: SIGNAL,
|
|
192
|
+
events,
|
|
193
|
+
retry: new DefaultRetryPolicy(),
|
|
194
|
+
fallbackModel: 'haiku',
|
|
195
|
+
}),
|
|
196
|
+
).rejects.toThrow();
|
|
197
|
+
}, 120_000);
|
|
198
|
+
|
|
199
|
+
it('does not fall back on 500 errors', async () => {
|
|
200
|
+
const { provider, modelsSeen } = makeErrorProvider({
|
|
201
|
+
failTimes: 10,
|
|
202
|
+
status: 500,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const events = new EventBus();
|
|
206
|
+
let fallbackEmitted = false;
|
|
207
|
+
events.on('provider.fallback', () => {
|
|
208
|
+
fallbackEmitted = true;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const request: LLMRequest = {
|
|
212
|
+
model: 'sonnet',
|
|
213
|
+
system: [],
|
|
214
|
+
messages: [],
|
|
215
|
+
max_tokens: 8192,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
await expect(
|
|
219
|
+
runProviderWithRetry({
|
|
220
|
+
provider,
|
|
221
|
+
request,
|
|
222
|
+
signal: SIGNAL,
|
|
223
|
+
events,
|
|
224
|
+
retry: new DefaultRetryPolicy(),
|
|
225
|
+
fallbackModel: 'haiku',
|
|
226
|
+
}),
|
|
227
|
+
).rejects.toThrow();
|
|
228
|
+
|
|
229
|
+
expect(fallbackEmitted).toBe(false);
|
|
230
|
+
for (const m of modelsSeen) expect(m).toBe('sonnet');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('falls back via catalog auto-select when no explicit fallbackModel', async () => {
|
|
234
|
+
const { provider, modelsSeen } = makeErrorProvider({
|
|
235
|
+
failTimes: 10,
|
|
236
|
+
status: 429,
|
|
237
|
+
thenSucceedWith: [
|
|
238
|
+
{ type: 'text_delta', text: 'ok' },
|
|
239
|
+
{ type: 'finish', usage: { input: 1, output: 1 }, stopReason: 'end_turn' },
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const catalog = makeCatalogWithModels(CHEAP_MODELS);
|
|
244
|
+
const events = new EventBus();
|
|
245
|
+
const request: LLMRequest = {
|
|
246
|
+
model: 'claude-sonnet-4-6',
|
|
247
|
+
system: [],
|
|
248
|
+
messages: [],
|
|
249
|
+
max_tokens: 8192,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const result = await runProviderWithRetry({
|
|
253
|
+
provider,
|
|
254
|
+
request,
|
|
255
|
+
signal: SIGNAL,
|
|
256
|
+
events,
|
|
257
|
+
retry: new DefaultRetryPolicy(),
|
|
258
|
+
catalog,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.stopReason).toBe('end_turn');
|
|
262
|
+
expect(modelsSeen[modelsSeen.length - 1]).toBe('claude-haiku-4-5');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('does not fall back to the same model as the current request', async () => {
|
|
266
|
+
const { provider } = makeErrorProvider({
|
|
267
|
+
failTimes: 10,
|
|
268
|
+
status: 429,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const catalog = makeCatalogWithModels([
|
|
272
|
+
{
|
|
273
|
+
id: 'sonnet',
|
|
274
|
+
name: 'Sonnet',
|
|
275
|
+
provider: 'mock',
|
|
276
|
+
reasoning: false,
|
|
277
|
+
limit: { context: 200_000, output: 8192 },
|
|
278
|
+
cost: { input: 3, output: 15 },
|
|
279
|
+
tool_call: true,
|
|
280
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
281
|
+
},
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
const events = new EventBus();
|
|
285
|
+
const request: LLMRequest = {
|
|
286
|
+
model: 'sonnet',
|
|
287
|
+
system: [],
|
|
288
|
+
messages: [],
|
|
289
|
+
max_tokens: 8192,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
await expect(
|
|
293
|
+
runProviderWithRetry({
|
|
294
|
+
provider,
|
|
295
|
+
request,
|
|
296
|
+
signal: SIGNAL,
|
|
297
|
+
events,
|
|
298
|
+
retry: new DefaultRetryPolicy(),
|
|
299
|
+
catalog,
|
|
300
|
+
}),
|
|
301
|
+
).rejects.toThrow();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('does not fall back when aborted', async () => {
|
|
305
|
+
const ctrl = new AbortController();
|
|
306
|
+
const { provider } = makeErrorProvider({
|
|
307
|
+
failTimes: 10,
|
|
308
|
+
status: 429,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const events = new EventBus();
|
|
312
|
+
let fallbackEmitted = false;
|
|
313
|
+
events.on('provider.fallback', () => {
|
|
314
|
+
fallbackEmitted = true;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const request: LLMRequest = {
|
|
318
|
+
model: 'sonnet',
|
|
319
|
+
system: [],
|
|
320
|
+
messages: [],
|
|
321
|
+
max_tokens: 8192,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
ctrl.abort();
|
|
325
|
+
|
|
326
|
+
await expect(
|
|
327
|
+
runProviderWithRetry({
|
|
328
|
+
provider,
|
|
329
|
+
request,
|
|
330
|
+
signal: ctrl.signal,
|
|
331
|
+
events,
|
|
332
|
+
retry: new DefaultRetryPolicy(),
|
|
333
|
+
fallbackModel: 'haiku',
|
|
334
|
+
}),
|
|
335
|
+
).rejects.toThrow();
|
|
336
|
+
|
|
337
|
+
expect(fallbackEmitted).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
function makeKeylessProvider(name: string): Provider {
|
|
342
|
+
return {
|
|
343
|
+
name,
|
|
344
|
+
// biome-ignore lint/correctness/useYield: error-only stub
|
|
345
|
+
async *stream(): AsyncIterable<LLMEvent> {
|
|
346
|
+
throw new Error('should not be called');
|
|
347
|
+
},
|
|
348
|
+
async complete() {
|
|
349
|
+
return { content: [], usage: { input: 0, output: 0 }, stopReason: 'end_turn' };
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
describe('runProviderWithRetry — cross-provider fallback chain', () => {
|
|
355
|
+
it('walks fallback chain on 429 exhaustion and succeeds on the second provider', async () => {
|
|
356
|
+
const calls: string[] = [];
|
|
357
|
+
const primary: Provider = {
|
|
358
|
+
name: 'anthropic',
|
|
359
|
+
// biome-ignore lint/correctness/useYield: error-only stub
|
|
360
|
+
async *stream(req) {
|
|
361
|
+
calls.push(`anthropic:${req.model}`);
|
|
362
|
+
const err = new Error('HTTP 429');
|
|
363
|
+
(err as { status?: number }).status = 429;
|
|
364
|
+
(err as { retryable?: boolean }).retryable = true;
|
|
365
|
+
throw err;
|
|
366
|
+
},
|
|
367
|
+
async complete() {
|
|
368
|
+
throw new Error('not impl');
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const secondary: Provider = {
|
|
373
|
+
name: 'openai',
|
|
374
|
+
async *stream(req) {
|
|
375
|
+
calls.push(`openai:${req.model}`);
|
|
376
|
+
yield { type: 'text_delta', text: 'ok from openai' };
|
|
377
|
+
yield { type: 'finish', usage: { input: 5, output: 3 }, stopReason: 'end_turn' };
|
|
378
|
+
},
|
|
379
|
+
async complete() {
|
|
380
|
+
throw new Error('not impl');
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const events = new EventBus();
|
|
385
|
+
const fallbackEvents: Array<{ from: string; to: string; reason: string }> = [];
|
|
386
|
+
events.on('provider.fallback', (e) => fallbackEvents.push(e));
|
|
387
|
+
|
|
388
|
+
const result = await runProviderWithRetry({
|
|
389
|
+
provider: primary,
|
|
390
|
+
request: { model: 'claude-sonnet-4-6', system: [], messages: [], max_tokens: 8192 },
|
|
391
|
+
signal: SIGNAL,
|
|
392
|
+
events,
|
|
393
|
+
retry: new DefaultRetryPolicy(),
|
|
394
|
+
fallback: [{ providerId: 'openai', model: 'gpt-4o-mini', providerFactory: () => secondary }],
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(result.stopReason).toBe('end_turn');
|
|
398
|
+
expect(calls).toContain('anthropic:claude-sonnet-4-6');
|
|
399
|
+
expect(calls).toContain('openai:gpt-4o-mini');
|
|
400
|
+
expect(fallbackEvents[0]?.from).toBe('anthropic/claude-sonnet-4-6');
|
|
401
|
+
expect(fallbackEvents[0]?.to).toBe('openai/gpt-4o-mini');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('skips a chain entry whose factory returns undefined (no api key) and continues', async () => {
|
|
405
|
+
const calls: string[] = [];
|
|
406
|
+
const primary: Provider = {
|
|
407
|
+
name: 'anthropic',
|
|
408
|
+
// biome-ignore lint/correctness/useYield: error-only stub
|
|
409
|
+
async *stream(req) {
|
|
410
|
+
calls.push(`anthropic:${req.model}`);
|
|
411
|
+
const err = new Error('HTTP 429');
|
|
412
|
+
(err as { status?: number }).status = 429;
|
|
413
|
+
(err as { retryable?: boolean }).retryable = true;
|
|
414
|
+
throw err;
|
|
415
|
+
},
|
|
416
|
+
async complete() {
|
|
417
|
+
throw new Error('not impl');
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
const tertiary: Provider = {
|
|
421
|
+
name: 'deepseek',
|
|
422
|
+
async *stream(req) {
|
|
423
|
+
calls.push(`deepseek:${req.model}`);
|
|
424
|
+
yield { type: 'text_delta', text: 'ok' };
|
|
425
|
+
yield { type: 'finish', usage: { input: 1, output: 1 }, stopReason: 'end_turn' };
|
|
426
|
+
},
|
|
427
|
+
async complete() {
|
|
428
|
+
throw new Error('not impl');
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
const events = new EventBus();
|
|
432
|
+
const skipped: Array<{ providerId: string; reason: string }> = [];
|
|
433
|
+
events.on('provider.fallback_skipped', (e) => skipped.push(e));
|
|
434
|
+
|
|
435
|
+
const result = await runProviderWithRetry({
|
|
436
|
+
provider: primary,
|
|
437
|
+
request: { model: 'sonnet', system: [], messages: [], max_tokens: 8192 },
|
|
438
|
+
signal: SIGNAL,
|
|
439
|
+
events,
|
|
440
|
+
retry: new DefaultRetryPolicy(),
|
|
441
|
+
fallback: [
|
|
442
|
+
{ providerId: 'openai', model: 'gpt-4o-mini', providerFactory: () => undefined },
|
|
443
|
+
{ providerId: 'deepseek', model: 'deepseek-chat', providerFactory: () => tertiary },
|
|
444
|
+
],
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(result.stopReason).toBe('end_turn');
|
|
448
|
+
expect(skipped[0]?.providerId).toBe('openai');
|
|
449
|
+
expect(skipped[0]?.reason).toBe('no_api_key');
|
|
450
|
+
expect(calls).toContain('deepseek:deepseek-chat');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('throws when the entire chain is exhausted', async () => {
|
|
454
|
+
const primary: Provider = {
|
|
455
|
+
name: 'anthropic',
|
|
456
|
+
// biome-ignore lint/correctness/useYield: error-only stub
|
|
457
|
+
async *stream() {
|
|
458
|
+
const err = new Error('HTTP 429');
|
|
459
|
+
(err as { status?: number }).status = 429;
|
|
460
|
+
(err as { retryable?: boolean }).retryable = true;
|
|
461
|
+
throw err;
|
|
462
|
+
},
|
|
463
|
+
async complete() {
|
|
464
|
+
throw new Error('not impl');
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
const secondary: Provider = {
|
|
468
|
+
name: 'openai',
|
|
469
|
+
// biome-ignore lint/correctness/useYield: error-only stub
|
|
470
|
+
async *stream() {
|
|
471
|
+
const err = new Error('HTTP 429');
|
|
472
|
+
(err as { status?: number }).status = 429;
|
|
473
|
+
(err as { retryable?: boolean }).retryable = true;
|
|
474
|
+
throw err;
|
|
475
|
+
},
|
|
476
|
+
async complete() {
|
|
477
|
+
throw new Error('not impl');
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
const events = new EventBus();
|
|
481
|
+
|
|
482
|
+
await expect(
|
|
483
|
+
runProviderWithRetry({
|
|
484
|
+
provider: primary,
|
|
485
|
+
request: { model: 'sonnet', system: [], messages: [], max_tokens: 8192 },
|
|
486
|
+
signal: SIGNAL,
|
|
487
|
+
events,
|
|
488
|
+
retry: new DefaultRetryPolicy(),
|
|
489
|
+
fallback: [
|
|
490
|
+
{ providerId: 'openai', model: 'gpt-4o-mini', providerFactory: () => secondary },
|
|
491
|
+
],
|
|
492
|
+
}),
|
|
493
|
+
).rejects.toThrow();
|
|
494
|
+
}, 120_000);
|
|
495
|
+
|
|
496
|
+
it('does not walk the chain on 500 errors', async () => {
|
|
497
|
+
const primary: Provider = {
|
|
498
|
+
name: 'anthropic',
|
|
499
|
+
// biome-ignore lint/correctness/useYield: error-only stub
|
|
500
|
+
async *stream() {
|
|
501
|
+
const err = new Error('HTTP 500');
|
|
502
|
+
(err as { status?: number }).status = 500;
|
|
503
|
+
(err as { retryable?: boolean }).retryable = true;
|
|
504
|
+
throw err;
|
|
505
|
+
},
|
|
506
|
+
async complete() {
|
|
507
|
+
throw new Error('not impl');
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
const events = new EventBus();
|
|
511
|
+
let walked = false;
|
|
512
|
+
events.on('provider.fallback', () => {
|
|
513
|
+
walked = true;
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
await expect(
|
|
517
|
+
runProviderWithRetry({
|
|
518
|
+
provider: primary,
|
|
519
|
+
request: { model: 'sonnet', system: [], messages: [], max_tokens: 8192 },
|
|
520
|
+
signal: SIGNAL,
|
|
521
|
+
events,
|
|
522
|
+
retry: new DefaultRetryPolicy(),
|
|
523
|
+
fallback: [
|
|
524
|
+
{
|
|
525
|
+
providerId: 'openai',
|
|
526
|
+
model: 'gpt-4o-mini',
|
|
527
|
+
providerFactory: () => makeKeylessProvider('openai'),
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
}),
|
|
531
|
+
).rejects.toThrow();
|
|
532
|
+
|
|
533
|
+
expect(walked).toBe(false);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DefaultRetryPolicy, parseRetryAfter, type ProviderError } from '../src/infrastructure/retry-policy.js';
|
|
3
|
+
|
|
4
|
+
describe('DefaultRetryPolicy', () => {
|
|
5
|
+
const policy = new DefaultRetryPolicy();
|
|
6
|
+
|
|
7
|
+
describe('shouldRetry', () => {
|
|
8
|
+
it('retries 429 (rate limited)', () => {
|
|
9
|
+
const err: ProviderError = { status: 429, message: 'rate limited', retryable: true };
|
|
10
|
+
expect(policy.shouldRetry(err, 0)).toBe(true);
|
|
11
|
+
expect(policy.shouldRetry(err, 4)).toBe(true);
|
|
12
|
+
expect(policy.shouldRetry(err, 5)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('retries 529 (overloaded)', () => {
|
|
16
|
+
const err: ProviderError = { status: 529, message: 'overloaded', retryable: true };
|
|
17
|
+
expect(policy.shouldRetry(err, 0)).toBe(true);
|
|
18
|
+
expect(policy.shouldRetry(err, 3)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('retries 500/502/503/504', () => {
|
|
22
|
+
for (const status of [500, 502, 503, 504]) {
|
|
23
|
+
const err: ProviderError = { status, message: 'server error', retryable: true };
|
|
24
|
+
expect(policy.shouldRetry(err, 0)).toBe(true);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('retries 599 (stream hang)', () => {
|
|
29
|
+
const err: ProviderError = { status: 599, message: 'stream hang', retryable: true };
|
|
30
|
+
expect(policy.shouldRetry(err, 0)).toBe(true);
|
|
31
|
+
expect(policy.shouldRetry(err, 2)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not retry 400 (bad request)', () => {
|
|
35
|
+
const err: ProviderError = { status: 400, message: 'bad request', retryable: false };
|
|
36
|
+
expect(policy.shouldRetry(err, 0)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not retry 401 (auth failed)', () => {
|
|
40
|
+
const err: ProviderError = { status: 401, message: 'auth', retryable: false };
|
|
41
|
+
expect(policy.shouldRetry(err, 0)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not retry 403 (forbidden)', () => {
|
|
45
|
+
const err: ProviderError = { status: 403, message: 'forbidden', retryable: false };
|
|
46
|
+
expect(policy.shouldRetry(err, 0)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not retry 422 (validation)', () => {
|
|
50
|
+
const err: ProviderError = { status: 422, message: 'validation', retryable: false };
|
|
51
|
+
expect(policy.shouldRetry(err, 0)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('maxAttempts', () => {
|
|
56
|
+
it('returns 5 for 429', () => {
|
|
57
|
+
expect(policy.maxAttempts({ status: 429, message: '', retryable: true })).toBe(5);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns 3 for 529', () => {
|
|
61
|
+
expect(policy.maxAttempts({ status: 529, message: '', retryable: true })).toBe(3);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns 3 for unknown 5xx', () => {
|
|
65
|
+
expect(policy.maxAttempts({ status: 599, message: '', retryable: true })).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('delayMs', () => {
|
|
70
|
+
it('returns exponential backoff with jitter', () => {
|
|
71
|
+
const delay0 = policy.delayMs(0, { status: 429, message: '', retryable: true });
|
|
72
|
+
const delay1 = policy.delayMs(1, { status: 429, message: '', retryable: true });
|
|
73
|
+
const delay2 = policy.delayMs(2, { status: 429, message: '', retryable: true });
|
|
74
|
+
expect(delay0).toBeGreaterThanOrEqual(1000);
|
|
75
|
+
expect(delay0).toBeLessThanOrEqual(2000);
|
|
76
|
+
expect(delay1).toBeGreaterThanOrEqual(2000);
|
|
77
|
+
expect(delay1).toBeLessThanOrEqual(3000);
|
|
78
|
+
expect(delay2).toBeGreaterThanOrEqual(4000);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('caps at 30 seconds', () => {
|
|
82
|
+
const delay = policy.delayMs(10, { status: 429, message: '', retryable: true });
|
|
83
|
+
expect(delay).toBeLessThanOrEqual(31_000);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('parseRetryAfter', () => {
|
|
89
|
+
it('parses seconds value', () => {
|
|
90
|
+
expect(parseRetryAfter('5')).toBe(5_000);
|
|
91
|
+
expect(parseRetryAfter('120')).toBe(120_000);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('clamps to [1s, 120s]', () => {
|
|
95
|
+
expect(parseRetryAfter('0')).toBe(1_000);
|
|
96
|
+
expect(parseRetryAfter('999')).toBe(120_000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns undefined for invalid', () => {
|
|
100
|
+
expect(parseRetryAfter(undefined)).toBeUndefined();
|
|
101
|
+
expect(parseRetryAfter('')).toBeUndefined();
|
|
102
|
+
expect(parseRetryAfter('abc')).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|