react-native-ai-hooks 0.3.0 → 0.5.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 (37) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/CONTRIBUTING.md +122 -0
  3. package/README.md +73 -20
  4. package/docs/ARCHITECTURE.md +301 -0
  5. package/docs/ARCHITECTURE_GUIDE.md +467 -0
  6. package/docs/IMPLEMENTATION_COMPLETE.md +349 -0
  7. package/docs/README.md +17 -0
  8. package/docs/TECHNICAL_SPECIFICATION.md +748 -0
  9. package/example/App.tsx +95 -0
  10. package/example/README.md +27 -0
  11. package/example/index.js +5 -0
  12. package/example/package.json +22 -0
  13. package/example/src/components/ProviderPicker.tsx +62 -0
  14. package/example/src/context/APIKeysContext.tsx +96 -0
  15. package/example/src/screens/ChatScreen.tsx +205 -0
  16. package/example/src/screens/SettingsScreen.tsx +124 -0
  17. package/example/tsconfig.json +7 -0
  18. package/jest.config.cjs +7 -0
  19. package/jest.setup.ts +28 -0
  20. package/package.json +17 -3
  21. package/src/hooks/__tests__/useAIForm.test.ts +345 -0
  22. package/src/hooks/__tests__/useAIStream.test.ts +427 -0
  23. package/src/hooks/useAIChat.ts +111 -51
  24. package/src/hooks/useAICode.ts +8 -0
  25. package/src/hooks/useAIForm.ts +92 -202
  26. package/src/hooks/useAIStream.ts +114 -58
  27. package/src/hooks/useAISummarize.ts +8 -0
  28. package/src/hooks/useAITranslate.ts +9 -0
  29. package/src/hooks/useAIVoice.ts +8 -0
  30. package/src/hooks/useImageAnalysis.ts +134 -79
  31. package/src/index.ts +25 -1
  32. package/src/types/index.ts +178 -4
  33. package/src/utils/__tests__/fetchWithRetry.test.ts +168 -0
  34. package/src/utils/__tests__/providerFactory.test.ts +493 -0
  35. package/src/utils/fetchWithRetry.ts +100 -0
  36. package/src/utils/index.ts +8 -0
  37. package/src/utils/providerFactory.ts +288 -0
@@ -0,0 +1,493 @@
1
+ import { createProvider } from '../providerFactory';
2
+ import { fetchWithRetry } from '../fetchWithRetry';
3
+
4
+ jest.mock('../fetchWithRetry', () => ({
5
+ fetchWithRetry: jest.fn(),
6
+ }));
7
+
8
+ describe('ProviderFactory', () => {
9
+ const mockedFetchWithRetry = fetchWithRetry as jest.MockedFunction<typeof fetchWithRetry>;
10
+
11
+ const createResponse = (body: unknown): Response => {
12
+ return {
13
+ ok: true,
14
+ status: 200,
15
+ json: async () => body,
16
+ text: async () => JSON.stringify(body),
17
+ } as unknown as Response;
18
+ };
19
+
20
+ const createErrorResponse = (status: number, body: unknown): Response => {
21
+ return {
22
+ ok: false,
23
+ status,
24
+ json: async () => body,
25
+ text: async () => JSON.stringify(body),
26
+ } as unknown as Response;
27
+ };
28
+
29
+ beforeEach(() => {
30
+ mockedFetchWithRetry.mockReset();
31
+ });
32
+
33
+ it('normalizes Anthropic responses to the shared AIResponse shape', async () => {
34
+ const anthropicRaw = {
35
+ content: [{ type: 'text', text: 'Hello from Claude' }],
36
+ usage: {
37
+ input_tokens: 12,
38
+ output_tokens: 34,
39
+ },
40
+ };
41
+
42
+ mockedFetchWithRetry.mockResolvedValueOnce(createResponse(anthropicRaw));
43
+
44
+ const provider = createProvider({
45
+ provider: 'anthropic',
46
+ apiKey: 'anthropic-key',
47
+ model: 'claude-sonnet-4-20250514',
48
+ });
49
+
50
+ const result = await provider.makeRequest({
51
+ prompt: 'Say hello',
52
+ options: { maxTokens: 256, temperature: 0.2 },
53
+ });
54
+
55
+ expect(result).toEqual({
56
+ text: 'Hello from Claude',
57
+ raw: anthropicRaw,
58
+ usage: {
59
+ inputTokens: 12,
60
+ outputTokens: 34,
61
+ totalTokens: 46,
62
+ },
63
+ });
64
+
65
+ expect(mockedFetchWithRetry).toHaveBeenCalledWith(
66
+ 'https://api.anthropic.com/v1/messages',
67
+ expect.objectContaining({
68
+ method: 'POST',
69
+ headers: expect.objectContaining({
70
+ 'Content-Type': 'application/json',
71
+ 'x-api-key': 'anthropic-key',
72
+ 'anthropic-version': '2023-06-01',
73
+ }),
74
+ }),
75
+ expect.objectContaining({
76
+ timeout: 30000,
77
+ maxRetries: 3,
78
+ }),
79
+ );
80
+ });
81
+
82
+ it('normalizes OpenAI responses to the shared AIResponse shape', async () => {
83
+ const openAIRaw = {
84
+ choices: [{ message: { content: 'Hello from GPT' } }],
85
+ usage: {
86
+ prompt_tokens: 10,
87
+ completion_tokens: 20,
88
+ total_tokens: 30,
89
+ },
90
+ };
91
+
92
+ mockedFetchWithRetry.mockResolvedValueOnce(createResponse(openAIRaw));
93
+
94
+ const provider = createProvider({
95
+ provider: 'openai',
96
+ apiKey: 'openai-key',
97
+ model: 'gpt-4o-mini',
98
+ });
99
+
100
+ const result = await provider.makeRequest({
101
+ prompt: 'Say hello',
102
+ options: { maxTokens: 128, temperature: 0.5 },
103
+ });
104
+
105
+ expect(result).toEqual({
106
+ text: 'Hello from GPT',
107
+ raw: openAIRaw,
108
+ usage: {
109
+ inputTokens: 10,
110
+ outputTokens: 20,
111
+ totalTokens: 30,
112
+ },
113
+ });
114
+
115
+ const [url, requestInit, retryOptions] = mockedFetchWithRetry.mock.calls[0];
116
+
117
+ expect(url).toBe('https://api.openai.com/v1/chat/completions');
118
+ expect(requestInit).toEqual(
119
+ expect.objectContaining({
120
+ method: 'POST',
121
+ headers: expect.objectContaining({
122
+ 'Content-Type': 'application/json',
123
+ Authorization: 'Bearer openai-key',
124
+ }),
125
+ }),
126
+ );
127
+ expect(retryOptions).toEqual(
128
+ expect.objectContaining({
129
+ timeout: 30000,
130
+ maxRetries: 3,
131
+ }),
132
+ );
133
+
134
+ const openAIBody = JSON.parse(String((requestInit as RequestInit).body));
135
+ expect(openAIBody).toMatchObject({
136
+ model: 'gpt-4o-mini',
137
+ max_tokens: 128,
138
+ temperature: 0.5,
139
+ messages: [{ role: 'user', content: 'Say hello' }],
140
+ });
141
+ expect(openAIBody.system).toBeUndefined();
142
+ });
143
+
144
+ it('prepends OpenAI system prompt into messages when provided', async () => {
145
+ mockedFetchWithRetry.mockResolvedValueOnce(
146
+ createResponse({
147
+ choices: [{ message: { content: 'ok' } }],
148
+ }),
149
+ );
150
+
151
+ const provider = createProvider({
152
+ provider: 'openai',
153
+ apiKey: 'openai-key',
154
+ model: 'gpt-4o-mini',
155
+ });
156
+
157
+ await provider.makeRequest({
158
+ prompt: 'Say hello',
159
+ context: [{ role: 'assistant', content: 'existing assistant context' }],
160
+ options: {
161
+ system: 'You are concise',
162
+ },
163
+ });
164
+
165
+ const [, requestInit] = mockedFetchWithRetry.mock.calls[0];
166
+ const openAIBody = JSON.parse(String((requestInit as RequestInit).body));
167
+
168
+ expect(openAIBody.messages).toEqual([
169
+ { role: 'system', content: 'You are concise' },
170
+ { role: 'assistant', content: 'existing assistant context' },
171
+ { role: 'user', content: 'Say hello' },
172
+ ]);
173
+ expect(openAIBody.system).toBeUndefined();
174
+ });
175
+
176
+ it('maps Gemini request configuration and normalizes Gemini responses', async () => {
177
+ const geminiRaw = {
178
+ candidates: [{ content: { parts: [{ text: 'Hello from Gemini' }] } }],
179
+ usageMetadata: {
180
+ promptTokenCount: 7,
181
+ candidatesTokenCount: 9,
182
+ totalTokenCount: 16,
183
+ },
184
+ };
185
+
186
+ mockedFetchWithRetry.mockResolvedValueOnce(createResponse(geminiRaw));
187
+
188
+ const provider = createProvider({
189
+ provider: 'gemini',
190
+ apiKey: 'gemini-key',
191
+ model: 'gemini-1.5-pro',
192
+ baseUrl: 'https://gemini-proxy.example.com',
193
+ timeout: 45000,
194
+ maxRetries: 5,
195
+ });
196
+
197
+ const result = await provider.makeRequest({
198
+ prompt: 'Summarize this text',
199
+ context: [{ role: 'assistant', content: 'Earlier response context' }],
200
+ options: { maxTokens: 200, temperature: 0.3 },
201
+ });
202
+
203
+ expect(result).toEqual({
204
+ text: 'Hello from Gemini',
205
+ raw: geminiRaw,
206
+ usage: {
207
+ inputTokens: 7,
208
+ outputTokens: 9,
209
+ totalTokens: 16,
210
+ },
211
+ });
212
+
213
+ const [url, requestInit, retryOptions] = mockedFetchWithRetry.mock.calls[0];
214
+
215
+ expect(url).toBe(
216
+ 'https://gemini-proxy.example.com/v1beta/models/gemini-1.5-pro:generateContent?key=gemini-key',
217
+ );
218
+ expect(requestInit).toEqual(
219
+ expect.objectContaining({
220
+ method: 'POST',
221
+ headers: expect.objectContaining({
222
+ 'Content-Type': 'application/json',
223
+ }),
224
+ }),
225
+ );
226
+ expect(retryOptions).toEqual(
227
+ expect.objectContaining({
228
+ timeout: 45000,
229
+ maxRetries: 5,
230
+ }),
231
+ );
232
+
233
+ const geminiBody = JSON.parse(String((requestInit as RequestInit).body));
234
+ expect(geminiBody).toMatchObject({
235
+ generationConfig: {
236
+ maxOutputTokens: 200,
237
+ temperature: 0.3,
238
+ },
239
+ contents: [
240
+ {
241
+ role: 'model',
242
+ parts: [{ text: 'Earlier response context' }],
243
+ },
244
+ {
245
+ role: 'user',
246
+ parts: [{ text: 'Summarize this text' }],
247
+ },
248
+ ],
249
+ });
250
+ });
251
+
252
+ it('throws on unknown provider values in both URL routing and request routing', async () => {
253
+ const provider = createProvider({
254
+ provider: 'anthropic',
255
+ apiKey: 'key',
256
+ model: 'model',
257
+ }) as unknown as {
258
+ config: { provider: string };
259
+ getBaseUrl: () => string;
260
+ makeRequest: (request: { prompt: string }) => Promise<unknown>;
261
+ };
262
+
263
+ provider.config.provider = 'unknown';
264
+
265
+ expect(() => provider.getBaseUrl()).toThrow('Unknown provider: unknown');
266
+ await expect(provider.makeRequest({ prompt: 'hello' })).rejects.toThrow('Unknown provider: unknown');
267
+ });
268
+
269
+ it('maps provider API error payloads to thrown errors', async () => {
270
+ mockedFetchWithRetry.mockResolvedValueOnce(
271
+ createErrorResponse(401, {
272
+ error: { message: 'Anthropic invalid key' },
273
+ }),
274
+ );
275
+
276
+ const anthropicProvider = createProvider({
277
+ provider: 'anthropic',
278
+ apiKey: 'bad-key',
279
+ model: 'claude-sonnet-4-20250514',
280
+ });
281
+
282
+ await expect(anthropicProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('Anthropic invalid key');
283
+
284
+ mockedFetchWithRetry.mockResolvedValueOnce(
285
+ createErrorResponse(401, {
286
+ error: { message: 'OpenAI invalid key' },
287
+ }),
288
+ );
289
+
290
+ const openAIProvider = createProvider({
291
+ provider: 'openai',
292
+ apiKey: 'bad-key',
293
+ model: 'gpt-4o-mini',
294
+ });
295
+
296
+ await expect(openAIProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('OpenAI invalid key');
297
+
298
+ mockedFetchWithRetry.mockResolvedValueOnce(
299
+ createErrorResponse(429, {
300
+ error: { message: 'Gemini rate limited' },
301
+ }),
302
+ );
303
+
304
+ const geminiProvider = createProvider({
305
+ provider: 'gemini',
306
+ apiKey: 'bad-key',
307
+ model: 'gemini-1.5-pro',
308
+ });
309
+
310
+ await expect(geminiProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('Gemini rate limited');
311
+ });
312
+
313
+ it('falls back to status-based messages when provider error payloads have no message', async () => {
314
+ mockedFetchWithRetry.mockResolvedValueOnce(
315
+ createErrorResponse(500, {
316
+ error: {},
317
+ }),
318
+ );
319
+
320
+ const anthropicProvider = createProvider({
321
+ provider: 'anthropic',
322
+ apiKey: 'bad-key',
323
+ model: 'claude-sonnet-4-20250514',
324
+ });
325
+
326
+ await expect(anthropicProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('API error');
327
+
328
+ mockedFetchWithRetry.mockResolvedValueOnce(
329
+ createErrorResponse(502, {
330
+ error: {},
331
+ }),
332
+ );
333
+
334
+ const openAIProvider = createProvider({
335
+ provider: 'openai',
336
+ apiKey: 'bad-key',
337
+ model: 'gpt-4o-mini',
338
+ });
339
+
340
+ await expect(openAIProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('API error');
341
+
342
+ mockedFetchWithRetry.mockResolvedValueOnce(
343
+ createErrorResponse(503, {
344
+ error: {},
345
+ }),
346
+ );
347
+
348
+ const geminiProvider = createProvider({
349
+ provider: 'gemini',
350
+ apiKey: 'bad-key',
351
+ model: 'gemini-1.5-pro',
352
+ });
353
+
354
+ await expect(geminiProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('API error');
355
+ });
356
+
357
+ it('falls back to generic API error when provider error JSON parsing fails', async () => {
358
+ const createMalformedErrorResponse = (status: number): Response =>
359
+ ({
360
+ ok: false,
361
+ status,
362
+ json: async () => {
363
+ throw new Error('invalid json');
364
+ },
365
+ text: async () => 'bad payload',
366
+ }) as unknown as Response;
367
+
368
+ mockedFetchWithRetry.mockResolvedValueOnce(createMalformedErrorResponse(500));
369
+
370
+ const anthropicProvider = createProvider({
371
+ provider: 'anthropic',
372
+ apiKey: 'bad-key',
373
+ model: 'claude-sonnet-4-20250514',
374
+ });
375
+
376
+ await expect(anthropicProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('API error');
377
+
378
+ mockedFetchWithRetry.mockResolvedValueOnce(createMalformedErrorResponse(500));
379
+
380
+ const openAIProvider = createProvider({
381
+ provider: 'openai',
382
+ apiKey: 'bad-key',
383
+ model: 'gpt-4o-mini',
384
+ });
385
+
386
+ await expect(openAIProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('API error');
387
+
388
+ mockedFetchWithRetry.mockResolvedValueOnce(createMalformedErrorResponse(500));
389
+
390
+ const geminiProvider = createProvider({
391
+ provider: 'gemini',
392
+ apiKey: 'bad-key',
393
+ model: 'gemini-1.5-pro',
394
+ });
395
+
396
+ await expect(geminiProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('API error');
397
+ });
398
+
399
+ it('throws when provider payloads do not include text content', async () => {
400
+ mockedFetchWithRetry.mockResolvedValueOnce(
401
+ createResponse({
402
+ content: [{ type: 'image' }],
403
+ }),
404
+ );
405
+
406
+ const anthropicProvider = createProvider({
407
+ provider: 'anthropic',
408
+ apiKey: 'key',
409
+ model: 'claude-sonnet-4-20250514',
410
+ });
411
+
412
+ await expect(anthropicProvider.makeRequest({ prompt: 'x' })).rejects.toThrow(
413
+ 'No text content returned by Anthropic API',
414
+ );
415
+
416
+ mockedFetchWithRetry.mockResolvedValueOnce(
417
+ createResponse({
418
+ choices: [{ message: { content: '' } }],
419
+ }),
420
+ );
421
+
422
+ const openAIProvider = createProvider({
423
+ provider: 'openai',
424
+ apiKey: 'key',
425
+ model: 'gpt-4o-mini',
426
+ });
427
+
428
+ await expect(openAIProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('No text content returned by OpenAI API');
429
+
430
+ mockedFetchWithRetry.mockResolvedValueOnce(
431
+ createResponse({
432
+ candidates: [{ content: { parts: [{ text: '' }] } }],
433
+ }),
434
+ );
435
+
436
+ const geminiProvider = createProvider({
437
+ provider: 'gemini',
438
+ apiKey: 'key',
439
+ model: 'gemini-1.5-pro',
440
+ });
441
+
442
+ await expect(geminiProvider.makeRequest({ prompt: 'x' })).rejects.toThrow('No text content returned by Gemini API');
443
+ });
444
+
445
+ it('maps Gemini context user role to user message role in request body', async () => {
446
+ mockedFetchWithRetry.mockResolvedValueOnce(
447
+ createResponse({
448
+ candidates: [{ content: { parts: [{ text: 'ok' }] } }],
449
+ }),
450
+ );
451
+
452
+ const provider = createProvider({
453
+ provider: 'gemini',
454
+ apiKey: 'gemini-key',
455
+ model: 'gemini-1.5-pro',
456
+ });
457
+
458
+ await provider.makeRequest({
459
+ prompt: 'new prompt',
460
+ context: [{ role: 'user', content: 'existing user msg' }],
461
+ });
462
+
463
+ const [, requestInit] = mockedFetchWithRetry.mock.calls[0];
464
+ const geminiBody = JSON.parse(String((requestInit as RequestInit).body));
465
+
466
+ expect(geminiBody.contents[0]).toEqual({
467
+ role: 'user',
468
+ parts: [{ text: 'existing user msg' }],
469
+ });
470
+ });
471
+
472
+ it('uses zero defaults for missing Anthropic usage values', async () => {
473
+ mockedFetchWithRetry.mockResolvedValueOnce(
474
+ createResponse({
475
+ content: [{ type: 'text', text: 'No usage provided' }],
476
+ }),
477
+ );
478
+
479
+ const provider = createProvider({
480
+ provider: 'anthropic',
481
+ apiKey: 'anthropic-key',
482
+ model: 'claude-sonnet-4-20250514',
483
+ });
484
+
485
+ const result = await provider.makeRequest({ prompt: 'hello' });
486
+
487
+ expect(result.usage).toEqual({
488
+ inputTokens: undefined,
489
+ outputTokens: undefined,
490
+ totalTokens: 0,
491
+ });
492
+ });
493
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Fetch with exponential backoff, timeout, and rate-limit handling
3
+ */
4
+
5
+ interface FetchWithRetryOptions {
6
+ maxRetries?: number;
7
+ baseDelay?: number;
8
+ maxDelay?: number;
9
+ timeout?: number;
10
+ backoffMultiplier?: number;
11
+ }
12
+
13
+ interface RetryableError extends Error {
14
+ statusCode?: number;
15
+ isRetryable?: boolean;
16
+ }
17
+
18
+ export async function fetchWithRetry(
19
+ url: string,
20
+ init?: RequestInit,
21
+ options?: FetchWithRetryOptions,
22
+ ): Promise<Response> {
23
+ const {
24
+ maxRetries = 3,
25
+ baseDelay = 1000,
26
+ maxDelay = 10000,
27
+ timeout = 30000,
28
+ backoffMultiplier = 2,
29
+ } = options || {};
30
+
31
+ let lastError: RetryableError | undefined;
32
+
33
+ const getBackoffDelay = (attempt: number) =>
34
+ Math.min(baseDelay * Math.pow(backoffMultiplier, attempt) * (1 + Math.random() * 0.3), maxDelay);
35
+
36
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
37
+ try {
38
+ const controller = new AbortController();
39
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
40
+
41
+ try {
42
+ const response = await fetch(url, {
43
+ ...init,
44
+ signal: controller.signal,
45
+ });
46
+
47
+ // Handle rate limiting
48
+ if (response.status === 429) {
49
+ const retryAfter = response.headers.get('Retry-After');
50
+ const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : getBackoffDelay(attempt);
51
+
52
+ if (attempt < maxRetries) {
53
+ await new Promise(resolve => setTimeout(resolve, delay));
54
+ continue;
55
+ }
56
+ }
57
+
58
+ // Handle server errors (5xx) with retry
59
+ if (response.status >= 500 && attempt < maxRetries) {
60
+ const delay = getBackoffDelay(attempt);
61
+ await new Promise(resolve => setTimeout(resolve, delay));
62
+ continue;
63
+ }
64
+
65
+ return response;
66
+ } finally {
67
+ clearTimeout(timeoutId);
68
+ }
69
+ } catch (err) {
70
+ const error = err as RetryableError;
71
+
72
+ // Handle timeout
73
+ if (error.name === 'AbortError') {
74
+ error.isRetryable = attempt < maxRetries;
75
+
76
+ if (attempt < maxRetries) {
77
+ const delay = getBackoffDelay(attempt);
78
+ await new Promise(resolve => setTimeout(resolve, delay));
79
+ continue;
80
+ }
81
+ }
82
+
83
+ lastError = error;
84
+
85
+ if (error.isRetryable === false) {
86
+ throw error;
87
+ }
88
+
89
+ if (attempt < maxRetries) {
90
+ const delay = getBackoffDelay(attempt);
91
+ await new Promise(resolve => setTimeout(resolve, delay));
92
+ continue;
93
+ }
94
+
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ throw lastError || new Error('Fetch failed after retries');
100
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Public utility exports for advanced use cases
3
+ */
4
+
5
+ export { createProvider, ProviderFactory } from './providerFactory';
6
+ export { fetchWithRetry } from './fetchWithRetry';
7
+
8
+ export type { ProviderConfig, AIResponse, AIRequestOptions } from '../types';