@charming_groot/providers 0.1.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 (61) hide show
  1. package/dist/auth/auth-resolver.d.ts +9 -0
  2. package/dist/auth/auth-resolver.d.ts.map +1 -0
  3. package/dist/auth/auth-resolver.js +200 -0
  4. package/dist/auth/auth-resolver.js.map +1 -0
  5. package/dist/auth/index.d.ts +2 -0
  6. package/dist/auth/index.d.ts.map +1 -0
  7. package/dist/auth/index.js +2 -0
  8. package/dist/auth/index.js.map +1 -0
  9. package/dist/base-provider.d.ts +10 -0
  10. package/dist/base-provider.d.ts.map +1 -0
  11. package/dist/base-provider.js +8 -0
  12. package/dist/base-provider.js.map +1 -0
  13. package/dist/circuit-breaker.d.ts +42 -0
  14. package/dist/circuit-breaker.d.ts.map +1 -0
  15. package/dist/circuit-breaker.js +116 -0
  16. package/dist/circuit-breaker.js.map +1 -0
  17. package/dist/claude-provider.d.ts +15 -0
  18. package/dist/claude-provider.d.ts.map +1 -0
  19. package/dist/claude-provider.js +171 -0
  20. package/dist/claude-provider.js.map +1 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +9 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/openai-provider.d.ts +16 -0
  26. package/dist/openai-provider.d.ts.map +1 -0
  27. package/dist/openai-provider.js +196 -0
  28. package/dist/openai-provider.js.map +1 -0
  29. package/dist/provider-factory.d.ts +17 -0
  30. package/dist/provider-factory.d.ts.map +1 -0
  31. package/dist/provider-factory.js +36 -0
  32. package/dist/provider-factory.js.map +1 -0
  33. package/dist/retry-provider.d.ts +25 -0
  34. package/dist/retry-provider.d.ts.map +1 -0
  35. package/dist/retry-provider.js +92 -0
  36. package/dist/retry-provider.js.map +1 -0
  37. package/dist/thinking-parser.d.ts +28 -0
  38. package/dist/thinking-parser.d.ts.map +1 -0
  39. package/dist/thinking-parser.js +40 -0
  40. package/dist/thinking-parser.js.map +1 -0
  41. package/package.json +34 -0
  42. package/src/auth/auth-resolver.ts +261 -0
  43. package/src/auth/index.ts +1 -0
  44. package/src/base-provider.ts +28 -0
  45. package/src/circuit-breaker.ts +157 -0
  46. package/src/claude-provider.ts +215 -0
  47. package/src/index.ts +13 -0
  48. package/src/openai-provider.ts +239 -0
  49. package/src/provider-factory.ts +48 -0
  50. package/src/retry-provider.ts +135 -0
  51. package/src/thinking-parser.ts +50 -0
  52. package/tests/auth-resolver.test.ts +204 -0
  53. package/tests/circuit-breaker.test.ts +220 -0
  54. package/tests/claude-provider.test.ts +35 -0
  55. package/tests/openai-provider.test.ts +35 -0
  56. package/tests/provider-factory.test.ts +73 -0
  57. package/tests/retry-provider-new.test.ts +166 -0
  58. package/tests/retry-provider.test.ts +118 -0
  59. package/tests/thinking-parser.test.ts +73 -0
  60. package/tsconfig.json +10 -0
  61. package/vitest.config.ts +15 -0
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { CircuitBreakerProvider } from '../src/circuit-breaker.js';
3
+ import { ProviderError } from '@charming_groot/core';
4
+ import type { ILlmProvider, LlmResponse, Message } from '@charming_groot/core';
5
+
6
+ const MOCK_RESPONSE: LlmResponse = {
7
+ content: 'ok',
8
+ toolCalls: [],
9
+ usage: { inputTokens: 5, outputTokens: 3 },
10
+ stopReason: 'end_turn',
11
+ };
12
+
13
+ const MESSAGES: Message[] = [{ role: 'user', content: 'test' }];
14
+
15
+ function makeMockProvider(impl?: Partial<ILlmProvider>): ILlmProvider {
16
+ return {
17
+ providerId: 'mock',
18
+ chat: vi.fn().mockResolvedValue(MOCK_RESPONSE),
19
+ stream: vi.fn().mockImplementation(async function* () { yield* []; }),
20
+ ...impl,
21
+ };
22
+ }
23
+
24
+ describe('CircuitBreakerProvider — 상태 전이', () => {
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.useRealTimers();
31
+ });
32
+
33
+ it('초기 상태는 CLOSED다', () => {
34
+ const cb = new CircuitBreakerProvider(makeMockProvider());
35
+ expect(cb.currentState).toBe('CLOSED');
36
+ });
37
+
38
+ it('성공 시 CLOSED 상태를 유지한다', async () => {
39
+ const cb = new CircuitBreakerProvider(makeMockProvider());
40
+ await cb.chat(MESSAGES);
41
+ expect(cb.currentState).toBe('CLOSED');
42
+ });
43
+
44
+ it('failureThreshold 연속 실패 시 OPEN으로 전이한다', async () => {
45
+ const inner = makeMockProvider({
46
+ chat: vi.fn().mockRejectedValue(new ProviderError('server error 500')),
47
+ });
48
+ const cb = new CircuitBreakerProvider(inner, { failureThreshold: 3 });
49
+
50
+ for (let i = 0; i < 3; i++) {
51
+ await cb.chat(MESSAGES).catch(() => {});
52
+ }
53
+
54
+ expect(cb.currentState).toBe('OPEN');
55
+ });
56
+
57
+ it('OPEN 상태에서 즉시 ProviderError를 던진다 (실제 provider 호출 없음)', async () => {
58
+ const inner = makeMockProvider({
59
+ chat: vi.fn().mockRejectedValue(new ProviderError('error')),
60
+ });
61
+ const cb = new CircuitBreakerProvider(inner, {
62
+ failureThreshold: 2,
63
+ openTimeoutMs: 10_000,
64
+ });
65
+
66
+ // OPEN으로 만들기
67
+ for (let i = 0; i < 2; i++) {
68
+ await cb.chat(MESSAGES).catch(() => {});
69
+ }
70
+ expect(cb.currentState).toBe('OPEN');
71
+
72
+ const callsBefore = (inner.chat as ReturnType<typeof vi.fn>).mock.calls.length;
73
+ await expect(cb.chat(MESSAGES)).rejects.toThrow('Circuit breaker OPEN');
74
+ // inner provider는 호출되지 않아야 함
75
+ expect((inner.chat as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsBefore);
76
+ });
77
+
78
+ it('openTimeoutMs 경과 후 HALF_OPEN으로 전이한다', async () => {
79
+ const inner = makeMockProvider({
80
+ chat: vi.fn()
81
+ .mockRejectedValueOnce(new ProviderError('error'))
82
+ .mockRejectedValueOnce(new ProviderError('error'))
83
+ .mockResolvedValue(MOCK_RESPONSE), // probe 성공
84
+ });
85
+ const cb = new CircuitBreakerProvider(inner, {
86
+ failureThreshold: 2,
87
+ openTimeoutMs: 5_000,
88
+ successThreshold: 1,
89
+ });
90
+
91
+ for (let i = 0; i < 2; i++) {
92
+ await cb.chat(MESSAGES).catch(() => {});
93
+ }
94
+ expect(cb.currentState).toBe('OPEN');
95
+
96
+ vi.advanceTimersByTime(5_001);
97
+
98
+ // probe 요청 — 성공 → CLOSED
99
+ await cb.chat(MESSAGES);
100
+ expect(cb.currentState).toBe('CLOSED');
101
+ });
102
+
103
+ it('HALF_OPEN에서 probe 실패 시 OPEN으로 돌아간다', async () => {
104
+ const inner = makeMockProvider({
105
+ chat: vi.fn().mockRejectedValue(new ProviderError('error')),
106
+ });
107
+ const cb = new CircuitBreakerProvider(inner, {
108
+ failureThreshold: 2,
109
+ openTimeoutMs: 1_000,
110
+ });
111
+
112
+ for (let i = 0; i < 2; i++) {
113
+ await cb.chat(MESSAGES).catch(() => {});
114
+ }
115
+
116
+ vi.advanceTimersByTime(1_001);
117
+ // HALF_OPEN probe → 실패 → OPEN
118
+ await cb.chat(MESSAGES).catch(() => {});
119
+ expect(cb.currentState).toBe('OPEN');
120
+ });
121
+
122
+ it('HALF_OPEN에서 successThreshold 성공 시 CLOSED로 전이한다', async () => {
123
+ const inner = makeMockProvider({
124
+ chat: vi.fn()
125
+ .mockRejectedValueOnce(new ProviderError('err'))
126
+ .mockRejectedValueOnce(new ProviderError('err'))
127
+ .mockResolvedValue(MOCK_RESPONSE),
128
+ });
129
+ const cb = new CircuitBreakerProvider(inner, {
130
+ failureThreshold: 2,
131
+ openTimeoutMs: 1_000,
132
+ successThreshold: 2,
133
+ });
134
+
135
+ for (let i = 0; i < 2; i++) {
136
+ await cb.chat(MESSAGES).catch(() => {});
137
+ }
138
+ expect(cb.currentState).toBe('OPEN');
139
+
140
+ vi.advanceTimersByTime(1_001);
141
+
142
+ // 첫 번째 성공 → HALF_OPEN 유지
143
+ await cb.chat(MESSAGES);
144
+ expect(cb.currentState).toBe('HALF_OPEN');
145
+
146
+ // 두 번째 성공 → CLOSED
147
+ await cb.chat(MESSAGES);
148
+ expect(cb.currentState).toBe('CLOSED');
149
+ });
150
+
151
+ it('CLOSED에서 성공하면 failureCount를 리셋한다', async () => {
152
+ const inner = makeMockProvider({
153
+ chat: vi.fn()
154
+ .mockRejectedValueOnce(new ProviderError('err')) // call 1: fail
155
+ .mockRejectedValueOnce(new ProviderError('err')) // call 2: fail
156
+ .mockResolvedValueOnce(MOCK_RESPONSE) // call 3: success → reset
157
+ .mockRejectedValueOnce(new ProviderError('err')) // call 4: fail
158
+ .mockRejectedValueOnce(new ProviderError('err')) // call 5: fail
159
+ .mockResolvedValue(MOCK_RESPONSE), // default
160
+ });
161
+ const cb = new CircuitBreakerProvider(inner, { failureThreshold: 3 });
162
+
163
+ // 2번 실패
164
+ await cb.chat(MESSAGES).catch(() => {});
165
+ await cb.chat(MESSAGES).catch(() => {});
166
+ expect(cb.currentState).toBe('CLOSED');
167
+
168
+ // 성공 → failure count reset
169
+ await cb.chat(MESSAGES);
170
+ expect(cb.currentState).toBe('CLOSED');
171
+
172
+ // 다시 2번 실패해도 OPEN이 되지 않음 (threshold 3 기준)
173
+ await cb.chat(MESSAGES).catch(() => {});
174
+ await cb.chat(MESSAGES).catch(() => {});
175
+ expect(cb.currentState).toBe('CLOSED');
176
+ });
177
+
178
+ it('providerId를 inner provider로부터 위임한다', () => {
179
+ const cb = new CircuitBreakerProvider(makeMockProvider());
180
+ expect(cb.providerId).toBe('mock');
181
+ });
182
+ });
183
+
184
+ describe('CircuitBreakerProvider — stream', () => {
185
+ beforeEach(() => vi.useFakeTimers());
186
+ afterEach(() => vi.useRealTimers());
187
+
188
+ it('CLOSED 상태에서 stream이 정상 동작한다', async () => {
189
+ const inner = makeMockProvider({
190
+ stream: vi.fn().mockImplementation(async function* () {
191
+ yield { type: 'text_delta' as const, text: 'hello' };
192
+ yield { type: 'done' as const, response: MOCK_RESPONSE };
193
+ }),
194
+ });
195
+ const cb = new CircuitBreakerProvider(inner);
196
+
197
+ const events = [];
198
+ for await (const e of cb.stream(MESSAGES)) {
199
+ events.push(e);
200
+ }
201
+ expect(events).toHaveLength(2);
202
+ expect(cb.currentState).toBe('CLOSED');
203
+ });
204
+
205
+ it('stream 실패가 failureCount에 반영된다', async () => {
206
+ const inner = makeMockProvider({
207
+ stream: vi.fn().mockImplementation(async function* () {
208
+ throw new ProviderError('stream error');
209
+ }),
210
+ });
211
+ const cb = new CircuitBreakerProvider(inner, { failureThreshold: 2 });
212
+
213
+ for (let i = 0; i < 2; i++) {
214
+ try {
215
+ for await (const _ of cb.stream(MESSAGES)) { /* empty */ }
216
+ } catch { /* expected */ }
217
+ }
218
+ expect(cb.currentState).toBe('OPEN');
219
+ });
220
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import type { ProviderConfig } from '@charming_groot/core';
3
+ import { ClaudeProvider } from '../src/claude-provider.js';
4
+
5
+ const CONFIG: ProviderConfig = {
6
+ providerId: 'claude',
7
+ model: 'claude-opus-4-6',
8
+ auth: { type: 'api-key' as const, apiKey: 'test-key' },
9
+ maxTokens: 4096,
10
+ temperature: 0.7,
11
+ };
12
+
13
+ describe('ClaudeProvider', () => {
14
+ it('should have correct providerId', () => {
15
+ const provider = new ClaudeProvider(CONFIG);
16
+ expect(provider.providerId).toBe('claude');
17
+ });
18
+
19
+ it('should implement chat method', () => {
20
+ const provider = new ClaudeProvider(CONFIG);
21
+ expect(typeof provider.chat).toBe('function');
22
+ });
23
+
24
+ it('should implement stream method', () => {
25
+ const provider = new ClaudeProvider(CONFIG);
26
+ expect(typeof provider.stream).toBe('function');
27
+ });
28
+
29
+ it('should throw ProviderError on chat failure', async () => {
30
+ const provider = new ClaudeProvider({ ...CONFIG, auth: { type: 'api-key' as const, apiKey: 'invalid' } });
31
+ await expect(
32
+ provider.chat([{ role: 'user', content: 'test' }])
33
+ ).rejects.toThrow();
34
+ });
35
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { ProviderConfig } from '@charming_groot/core';
3
+ import { OpenAIProvider } from '../src/openai-provider.js';
4
+
5
+ const CONFIG: ProviderConfig = {
6
+ providerId: 'openai',
7
+ model: 'gpt-4',
8
+ auth: { type: 'api-key' as const, apiKey: 'test-key' },
9
+ maxTokens: 4096,
10
+ temperature: 0.7,
11
+ };
12
+
13
+ describe('OpenAIProvider', () => {
14
+ it('should have correct providerId', () => {
15
+ const provider = new OpenAIProvider(CONFIG);
16
+ expect(provider.providerId).toBe('openai');
17
+ });
18
+
19
+ it('should implement chat method', () => {
20
+ const provider = new OpenAIProvider(CONFIG);
21
+ expect(typeof provider.chat).toBe('function');
22
+ });
23
+
24
+ it('should implement stream method', () => {
25
+ const provider = new OpenAIProvider(CONFIG);
26
+ expect(typeof provider.stream).toBe('function');
27
+ });
28
+
29
+ it('should throw ProviderError on chat failure', async () => {
30
+ const provider = new OpenAIProvider({ ...CONFIG, auth: { type: 'api-key' as const, apiKey: 'invalid' } });
31
+ await expect(
32
+ provider.chat([{ role: 'user', content: 'test' }])
33
+ ).rejects.toThrow();
34
+ });
35
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { ProviderConfig, ILlmProvider, Message, LlmResponse, StreamEvent, ToolDescription } from '@charming_groot/core';
3
+ import { ProviderError } from '@charming_groot/core';
4
+ import { createProvider, registerProvider, getProviderRegistry } from '../src/provider-factory.js';
5
+ import { CircuitBreakerProvider } from '../src/circuit-breaker.js';
6
+
7
+ const CLAUDE_CONFIG: ProviderConfig = {
8
+ providerId: 'claude',
9
+ model: 'claude-opus-4-6',
10
+ auth: { type: 'api-key' as const, apiKey: 'test-key' },
11
+ maxTokens: 4096,
12
+ temperature: 0.7,
13
+ };
14
+
15
+ const OPENAI_CONFIG: ProviderConfig = {
16
+ providerId: 'openai',
17
+ model: 'gpt-4',
18
+ auth: { type: 'api-key' as const, apiKey: 'test-key' },
19
+ maxTokens: 4096,
20
+ temperature: 0.7,
21
+ };
22
+
23
+ describe('ProviderFactory', () => {
24
+ it('should create a Claude provider', () => {
25
+ const provider = createProvider(CLAUDE_CONFIG);
26
+ expect(provider).toBeInstanceOf(CircuitBreakerProvider);
27
+ expect(provider.providerId).toBe('claude');
28
+ });
29
+
30
+ it('should create an OpenAI provider', () => {
31
+ const provider = createProvider(OPENAI_CONFIG);
32
+ expect(provider).toBeInstanceOf(CircuitBreakerProvider);
33
+ expect(provider.providerId).toBe('openai');
34
+ });
35
+
36
+ it('should throw for unknown provider', () => {
37
+ expect(() =>
38
+ createProvider({ ...CLAUDE_CONFIG, providerId: 'unknown' })
39
+ ).toThrow(ProviderError);
40
+ });
41
+
42
+ it('should include available providers in error message', () => {
43
+ try {
44
+ createProvider({ ...CLAUDE_CONFIG, providerId: 'unknown' });
45
+ } catch (e) {
46
+ expect((e as ProviderError).message).toContain('claude');
47
+ expect((e as ProviderError).message).toContain('openai');
48
+ }
49
+ });
50
+
51
+ it('should allow registering custom providers', () => {
52
+ class CustomProvider implements ILlmProvider {
53
+ readonly providerId = 'custom';
54
+ async chat(_messages: readonly Message[], _tools?: readonly ToolDescription[]): Promise<LlmResponse> {
55
+ return { content: '', stopReason: 'end_turn', toolCalls: [], usage: { inputTokens: 0, outputTokens: 0 } };
56
+ }
57
+ async *stream(_messages: readonly Message[], _tools?: readonly ToolDescription[]): AsyncIterable<StreamEvent> {
58
+ yield { type: 'done', response: { content: '', stopReason: 'end_turn', toolCalls: [], usage: { inputTokens: 0, outputTokens: 0 } } };
59
+ }
60
+ }
61
+ registerProvider('test-custom', CustomProvider);
62
+ const provider = createProvider({ ...CLAUDE_CONFIG, providerId: 'test-custom' });
63
+ expect(provider.providerId).toBe('custom');
64
+ // cleanup
65
+ getProviderRegistry().unregister('test-custom');
66
+ });
67
+
68
+ it('should expose the provider registry', () => {
69
+ const registry = getProviderRegistry();
70
+ expect(registry.has('claude')).toBe(true);
71
+ expect(registry.has('openai')).toBe(true);
72
+ });
73
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { RetryProvider } from '../src/retry-provider.js';
3
+ import { ProviderError } from '@charming_groot/core';
4
+ import type { ILlmProvider, LlmResponse, Message } from '@charming_groot/core';
5
+
6
+ const MOCK_RESPONSE: LlmResponse = {
7
+ content: 'test response',
8
+ toolCalls: [],
9
+ usage: { inputTokens: 10, outputTokens: 5 },
10
+ stopReason: 'end_turn',
11
+ };
12
+
13
+ function makeMockProvider(impl?: Partial<ILlmProvider>): ILlmProvider {
14
+ return {
15
+ providerId: 'mock',
16
+ chat: vi.fn().mockResolvedValue(MOCK_RESPONSE),
17
+ stream: vi.fn().mockImplementation(async function* () { yield* []; }),
18
+ ...impl,
19
+ };
20
+ }
21
+
22
+ const MESSAGES: Message[] = [{ role: 'user', content: 'hello' }];
23
+
24
+ describe('RetryProvider — chat', () => {
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ });
28
+
29
+ it('첫 번째 시도에 성공하면 바로 반환한다', async () => {
30
+ const inner = makeMockProvider();
31
+ const provider = new RetryProvider(inner, { maxRetries: 3 });
32
+
33
+ const result = await provider.chat(MESSAGES);
34
+ expect(result).toBe(MOCK_RESPONSE);
35
+ expect(inner.chat).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it('rate limit(429) 에러 시 재시도한다', async () => {
39
+ const inner = makeMockProvider({
40
+ chat: vi.fn()
41
+ .mockRejectedValueOnce(new ProviderError('429 rate limit exceeded'))
42
+ .mockResolvedValue(MOCK_RESPONSE),
43
+ });
44
+ const provider = new RetryProvider(inner, { maxRetries: 3, baseDelayMs: 0 });
45
+
46
+ const promise = provider.chat(MESSAGES);
47
+ await vi.runAllTimersAsync();
48
+ const result = await promise;
49
+
50
+ expect(result).toBe(MOCK_RESPONSE);
51
+ expect(inner.chat).toHaveBeenCalledTimes(2);
52
+ });
53
+
54
+ it('서버 에러(500) 시 재시도한다', async () => {
55
+ const inner = makeMockProvider({
56
+ chat: vi.fn()
57
+ .mockRejectedValueOnce(new ProviderError('500 internal server error'))
58
+ .mockResolvedValue(MOCK_RESPONSE),
59
+ });
60
+ const provider = new RetryProvider(inner, { maxRetries: 3, baseDelayMs: 0 });
61
+
62
+ const promise = provider.chat(MESSAGES);
63
+ await vi.runAllTimersAsync();
64
+ const result = await promise;
65
+
66
+ expect(result).toBe(MOCK_RESPONSE);
67
+ expect(inner.chat).toHaveBeenCalledTimes(2);
68
+ });
69
+
70
+ it('네트워크 에러(econnreset) 시 재시도한다', async () => {
71
+ const inner = makeMockProvider({
72
+ chat: vi.fn()
73
+ .mockRejectedValueOnce(new Error('ECONNRESET connection reset'))
74
+ .mockResolvedValue(MOCK_RESPONSE),
75
+ });
76
+ const provider = new RetryProvider(inner, { maxRetries: 3, baseDelayMs: 0 });
77
+
78
+ const promise = provider.chat(MESSAGES);
79
+ await vi.runAllTimersAsync();
80
+ const result = await promise;
81
+
82
+ expect(result).toBe(MOCK_RESPONSE);
83
+ expect(inner.chat).toHaveBeenCalledTimes(2);
84
+ });
85
+
86
+ it('재시도 불가 에러(400)는 즉시 던진다', async () => {
87
+ const error = new ProviderError('400 bad request invalid input');
88
+ const inner = makeMockProvider({
89
+ chat: vi.fn().mockRejectedValue(error),
90
+ });
91
+ const provider = new RetryProvider(inner, { maxRetries: 3, baseDelayMs: 0 });
92
+
93
+ await expect(provider.chat(MESSAGES)).rejects.toThrow('400 bad request');
94
+ expect(inner.chat).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it('maxRetries 소진 후 마지막 에러를 던진다', async () => {
98
+ const error = new ProviderError('overloaded');
99
+ const inner = makeMockProvider({
100
+ chat: vi.fn().mockRejectedValue(error),
101
+ });
102
+ const provider = new RetryProvider(inner, { maxRetries: 2, baseDelayMs: 0 });
103
+
104
+ const promise = provider.chat(MESSAGES);
105
+ await vi.runAllTimersAsync();
106
+
107
+ await expect(promise).rejects.toThrow('overloaded');
108
+ expect(inner.chat).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
109
+ });
110
+
111
+ it('providerId를 inner provider로부터 위임한다', () => {
112
+ const inner = makeMockProvider();
113
+ const provider = new RetryProvider(inner);
114
+ expect(provider.providerId).toBe('mock');
115
+ });
116
+ });
117
+
118
+ describe('RetryProvider — stream', () => {
119
+ beforeEach(() => {
120
+ vi.useFakeTimers();
121
+ });
122
+
123
+ it('stream이 성공하면 이벤트를 그대로 반환한다', async () => {
124
+ const inner = makeMockProvider({
125
+ stream: vi.fn().mockImplementation(async function* () {
126
+ yield { type: 'text_delta' as const, text: 'hello' };
127
+ yield { type: 'done' as const, response: MOCK_RESPONSE };
128
+ }),
129
+ });
130
+ const provider = new RetryProvider(inner, { maxRetries: 2, baseDelayMs: 0 });
131
+
132
+ const events = [];
133
+ for await (const event of provider.stream(MESSAGES)) {
134
+ events.push(event);
135
+ }
136
+
137
+ expect(events).toHaveLength(2);
138
+ expect(events[0]).toEqual({ type: 'text_delta', text: 'hello' });
139
+ });
140
+
141
+ it('stream에서 재시도 가능 에러 발생 시 재시도한다', async () => {
142
+ let callCount = 0;
143
+ const inner = makeMockProvider({
144
+ stream: vi.fn().mockImplementation(async function* () {
145
+ callCount++;
146
+ if (callCount === 1) throw new ProviderError('503 service unavailable');
147
+ yield { type: 'done' as const, response: MOCK_RESPONSE };
148
+ }),
149
+ });
150
+ const provider = new RetryProvider(inner, { maxRetries: 2, baseDelayMs: 0 });
151
+
152
+ const promise = (async () => {
153
+ const events = [];
154
+ for await (const event of provider.stream(MESSAGES)) {
155
+ events.push(event);
156
+ }
157
+ return events;
158
+ })();
159
+
160
+ await vi.runAllTimersAsync();
161
+ const events = await promise;
162
+
163
+ expect(callCount).toBe(2);
164
+ expect(events).toHaveLength(1);
165
+ });
166
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { RetryProvider } from '../src/retry-provider.js';
3
+ import type { ILlmProvider, LlmResponse, StreamEvent } from '@charming_groot/core';
4
+ import { ProviderError } from '@charming_groot/core';
5
+
6
+ const MOCK_RESPONSE: LlmResponse = {
7
+ content: 'Hello',
8
+ stopReason: 'end_turn',
9
+ toolCalls: [],
10
+ usage: { inputTokens: 10, outputTokens: 5 },
11
+ };
12
+
13
+ function createMockProvider(overrides?: Partial<ILlmProvider>): ILlmProvider {
14
+ return {
15
+ providerId: 'mock',
16
+ chat: vi.fn().mockResolvedValue(MOCK_RESPONSE),
17
+ async *stream() {
18
+ yield { type: 'done', response: MOCK_RESPONSE } as StreamEvent;
19
+ },
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ describe('RetryProvider', () => {
25
+ it('should pass through successful calls', async () => {
26
+ const inner = createMockProvider();
27
+ const retry = new RetryProvider(inner);
28
+
29
+ const result = await retry.chat([], []);
30
+ expect(result).toBe(MOCK_RESPONSE);
31
+ expect(inner.chat).toHaveBeenCalledTimes(1);
32
+ });
33
+
34
+ it('should retry on rate limit errors', async () => {
35
+ const inner = createMockProvider({
36
+ chat: vi.fn()
37
+ .mockRejectedValueOnce(new ProviderError('rate limit exceeded (429)'))
38
+ .mockResolvedValueOnce(MOCK_RESPONSE),
39
+ });
40
+ const retry = new RetryProvider(inner, { baseDelayMs: 10 });
41
+
42
+ const result = await retry.chat([], []);
43
+ expect(result).toBe(MOCK_RESPONSE);
44
+ expect(inner.chat).toHaveBeenCalledTimes(2);
45
+ });
46
+
47
+ it('should retry on transient network errors', async () => {
48
+ const inner = createMockProvider({
49
+ chat: vi.fn()
50
+ .mockRejectedValueOnce(new ProviderError('ECONNRESET'))
51
+ .mockResolvedValueOnce(MOCK_RESPONSE),
52
+ });
53
+ const retry = new RetryProvider(inner, { baseDelayMs: 10 });
54
+
55
+ const result = await retry.chat([], []);
56
+ expect(result).toBe(MOCK_RESPONSE);
57
+ expect(inner.chat).toHaveBeenCalledTimes(2);
58
+ });
59
+
60
+ it('should not retry on non-retryable errors', async () => {
61
+ const inner = createMockProvider({
62
+ chat: vi.fn().mockRejectedValue(new ProviderError('Invalid API key')),
63
+ });
64
+ const retry = new RetryProvider(inner, { baseDelayMs: 10 });
65
+
66
+ await expect(retry.chat([], [])).rejects.toThrow('Invalid API key');
67
+ expect(inner.chat).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ it('should give up after maxRetries', async () => {
71
+ const inner = createMockProvider({
72
+ chat: vi.fn().mockRejectedValue(new ProviderError('rate limit exceeded (429)')),
73
+ });
74
+ const retry = new RetryProvider(inner, { maxRetries: 2, baseDelayMs: 10 });
75
+
76
+ await expect(retry.chat([], [])).rejects.toThrow('rate limit');
77
+ expect(inner.chat).toHaveBeenCalledTimes(3); // initial + 2 retries
78
+ });
79
+
80
+ it('should preserve providerId', () => {
81
+ const inner = createMockProvider();
82
+ const retry = new RetryProvider(inner);
83
+ expect(retry.providerId).toBe('mock');
84
+ });
85
+
86
+ it('should retry on overloaded errors', async () => {
87
+ const inner = createMockProvider({
88
+ chat: vi.fn()
89
+ .mockRejectedValueOnce(new ProviderError('overloaded (529)'))
90
+ .mockResolvedValueOnce(MOCK_RESPONSE),
91
+ });
92
+ const retry = new RetryProvider(inner, { baseDelayMs: 10 });
93
+
94
+ const result = await retry.chat([], []);
95
+ expect(result).toBe(MOCK_RESPONSE);
96
+ });
97
+
98
+ it('should retry stream on transient errors', async () => {
99
+ let attempt = 0;
100
+ const inner = createMockProvider({
101
+ async *stream() {
102
+ attempt++;
103
+ if (attempt === 1) {
104
+ throw new ProviderError('socket hang up');
105
+ }
106
+ yield { type: 'done', response: MOCK_RESPONSE } as StreamEvent;
107
+ },
108
+ });
109
+ const retry = new RetryProvider(inner, { baseDelayMs: 10 });
110
+
111
+ const events: StreamEvent[] = [];
112
+ for await (const event of retry.stream([], [])) {
113
+ events.push(event);
114
+ }
115
+ expect(events).toHaveLength(1);
116
+ expect(events[0].type).toBe('done');
117
+ });
118
+ });