@ragbits/api-client 0.0.2 → 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/src/index.ts DELETED
@@ -1,213 +0,0 @@
1
- import type {
2
- ClientConfig,
3
- StreamCallbacks,
4
- ApiEndpointPath,
5
- ApiEndpointResponse,
6
- TypedApiRequestOptions,
7
- StreamingEndpointPath,
8
- StreamingEndpointRequest,
9
- StreamingEndpointStream,
10
- } from './types'
11
-
12
- /**
13
- * Client for communicating with the Ragbits API
14
- */
15
- export class RagbitsClient {
16
- private readonly baseUrl: string
17
-
18
- /**
19
- * @param config - Configuration object
20
- */
21
- constructor(config: ClientConfig = {}) {
22
- this.baseUrl = config.baseUrl || 'http://127.0.0.1:8000'
23
-
24
- // Validate the base URL
25
- try {
26
- new URL(this.baseUrl)
27
- } catch {
28
- throw new Error(
29
- `Invalid base URL: ${this.baseUrl}. Please provide a valid URL.`
30
- )
31
- }
32
-
33
- if (this.baseUrl.endsWith('/')) {
34
- this.baseUrl = this.baseUrl.slice(0, -1)
35
- }
36
- }
37
-
38
- /**
39
- * Get the base URL used by this client
40
- */
41
- getBaseUrl(): string {
42
- return this.baseUrl
43
- }
44
-
45
- /**
46
- * Build full API URL from path
47
- * @private
48
- */
49
- private _buildApiUrl(path: string): string {
50
- return `${this.baseUrl}${path}`
51
- }
52
-
53
- /**
54
- * Make a request to the API
55
- * @private
56
- */
57
- private async _makeRequest(
58
- url: string,
59
- options: RequestInit = {}
60
- ): Promise<Response> {
61
- const defaultOptions: RequestInit = {
62
- headers: {
63
- 'Content-Type': 'application/json',
64
- },
65
- }
66
-
67
- const response = await fetch(url, { ...defaultOptions, ...options })
68
- if (!response.ok) {
69
- throw new Error(`HTTP error! status: ${response.status}`)
70
- }
71
- return response
72
- }
73
-
74
- /**
75
- * Method to make API requests to known endpoints only
76
- * @param endpoint - API endpoint path (must be predefined)
77
- * @param options - Typed request options for the specific endpoint
78
- */
79
- async makeRequest<T extends ApiEndpointPath>(
80
- endpoint: T,
81
- options?: TypedApiRequestOptions<T>
82
- ): Promise<ApiEndpointResponse<T>> {
83
- const {
84
- method = 'GET',
85
- body,
86
- headers = {},
87
- ...restOptions
88
- } = options || {}
89
-
90
- const requestOptions: RequestInit = {
91
- method,
92
- headers,
93
- ...restOptions, // This will include signal and other fetch options
94
- }
95
-
96
- if (body && method !== 'GET') {
97
- requestOptions.body =
98
- typeof body === 'string' ? body : JSON.stringify(body)
99
- }
100
-
101
- const response = await this._makeRequest(
102
- this._buildApiUrl(endpoint),
103
- requestOptions
104
- )
105
- return response.json()
106
- }
107
-
108
- /**
109
- * Method for streaming requests to known endpoints only
110
- * @param endpoint - Streaming endpoint path (must be predefined)
111
- * @param data - Request data
112
- * @param callbacks - Stream callbacks
113
- * @param signal - Optional AbortSignal for cancelling the request
114
- */
115
- makeStreamRequest<T extends StreamingEndpointPath>(
116
- endpoint: T,
117
- data: StreamingEndpointRequest<T>,
118
- callbacks: StreamCallbacks<StreamingEndpointStream<T>>,
119
- signal?: AbortSignal
120
- ): () => void {
121
- let isCancelled = false
122
-
123
- const processStream = async (response: Response): Promise<void> => {
124
- const reader = response.body
125
- ?.pipeThrough(new TextDecoderStream())
126
- .getReader()
127
-
128
- if (!reader) {
129
- throw new Error('Response body is null')
130
- }
131
-
132
- while (!isCancelled && !signal?.aborted) {
133
- try {
134
- const { value, done } = await reader.read()
135
- if (done) {
136
- callbacks.onClose?.()
137
- break
138
- }
139
-
140
- const lines = value.split('\n')
141
- for (const line of lines) {
142
- if (!line.startsWith('data: ')) continue
143
-
144
- try {
145
- const jsonString = line.replace('data: ', '').trim()
146
- const parsedData = JSON.parse(
147
- jsonString
148
- ) as StreamingEndpointStream<T>
149
- await callbacks.onMessage(parsedData)
150
- } catch (parseError) {
151
- console.error('Error parsing JSON:', parseError)
152
- await callbacks.onError(
153
- new Error('Error processing server response')
154
- )
155
- }
156
- }
157
- } catch (streamError) {
158
- console.error('Stream error:', streamError)
159
- await callbacks.onError(new Error('Error reading stream'))
160
- break
161
- }
162
- }
163
- }
164
-
165
- const startStream = async (): Promise<void> => {
166
- try {
167
- const response = await fetch(this._buildApiUrl(endpoint), {
168
- method: 'POST',
169
- headers: {
170
- 'Content-Type': 'application/json',
171
- Accept: 'text/event-stream',
172
- },
173
- body: JSON.stringify(data),
174
- signal,
175
- })
176
-
177
- if (!response.ok) {
178
- throw new Error(`HTTP error! status: ${response.status}`)
179
- }
180
-
181
- await processStream(response)
182
- } catch (error) {
183
- if (signal?.aborted) {
184
- return
185
- }
186
-
187
- console.error('Request error:', error)
188
- const errorMessage =
189
- error instanceof Error
190
- ? error.message
191
- : 'Error connecting to server'
192
- await callbacks.onError(new Error(errorMessage))
193
- }
194
- }
195
-
196
- try {
197
- startStream()
198
- } catch (error) {
199
- const errorMessage =
200
- error instanceof Error
201
- ? error.message
202
- : 'Failed to start stream'
203
- callbacks.onError(new Error(errorMessage))
204
- }
205
-
206
- return () => {
207
- isCancelled = true
208
- }
209
- }
210
- }
211
-
212
- // Re-export types
213
- export * from './types'
@@ -1,484 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { RagbitsClient } from '../index'
3
- import { server } from './setup'
4
- import { http, HttpResponse } from 'msw'
5
- import type { FeedbackRequest } from '../types'
6
- import { FeedbackType } from '../types'
7
- import { defaultConfigResponse } from './utils'
8
-
9
- describe('RagbitsClient', () => {
10
- let client: RagbitsClient
11
-
12
- beforeEach(() => {
13
- client = new RagbitsClient()
14
- })
15
-
16
- describe('Constructor', () => {
17
- it('should create client with default base URL', () => {
18
- const defaultClient = new RagbitsClient()
19
- expect(defaultClient).toBeInstanceOf(RagbitsClient)
20
- })
21
-
22
- it('should create client with custom base URL', () => {
23
- const customClient = new RagbitsClient({
24
- baseUrl: 'https://api.example.com',
25
- })
26
- expect(customClient).toBeInstanceOf(RagbitsClient)
27
- })
28
-
29
- it('should remove trailing slash from base URL', () => {
30
- const clientWithSlash = new RagbitsClient({
31
- baseUrl: 'https://api.example.com/',
32
- })
33
- expect(clientWithSlash).toBeInstanceOf(RagbitsClient)
34
- })
35
-
36
- it('should throw error for invalid base URL', () => {
37
- expect(() => {
38
- new RagbitsClient({ baseUrl: 'invalid-url' })
39
- }).toThrow(
40
- 'Invalid base URL: invalid-url. Please provide a valid URL.'
41
- )
42
- })
43
- })
44
-
45
- describe('getBaseUrl', () => {
46
- it('should return the base URL', () => {
47
- expect(client.getBaseUrl()).toBe('http://127.0.0.1:8000')
48
- })
49
- })
50
-
51
- describe('makeRequest', () => {
52
- it('should make successful GET request', async () => {
53
- const response = await client.makeRequest('/api/config')
54
-
55
- expect(response).toEqual(defaultConfigResponse)
56
- })
57
-
58
- it('should make successful POST request with body', async () => {
59
- const requestBody: FeedbackRequest = {
60
- message_id: 'msg-123',
61
- feedback: FeedbackType.LIKE,
62
- payload: { reason: 'Great response!' },
63
- }
64
-
65
- const response = await client.makeRequest('/api/feedback', {
66
- method: 'POST',
67
- body: requestBody,
68
- })
69
-
70
- expect(response).toEqual({
71
- status: 'success',
72
- })
73
- })
74
-
75
- it('should handle request with custom headers', async () => {
76
- server.use(
77
- http.get('http://127.0.0.1:8000/api/config', ({ request }) => {
78
- const customHeader = request.headers.get('X-Custom-Header')
79
- return HttpResponse.json({
80
- feedback: {
81
- like: { enabled: true, form: null },
82
- dislike: { enabled: false, form: null },
83
- },
84
- receivedHeader: customHeader,
85
- })
86
- })
87
- )
88
-
89
- const response = await client.makeRequest('/api/config', {
90
- headers: {
91
- 'X-Custom-Header': 'test-value',
92
- },
93
- })
94
-
95
- // Check that the header was received by accessing the custom property
96
- const responseWithHeader = response as typeof response & {
97
- receivedHeader: string
98
- }
99
- expect(responseWithHeader.receivedHeader).toBe('test-value')
100
- })
101
-
102
- it('should handle HTTP errors', async () => {
103
- // Mock error response for testing
104
- server.use(
105
- http.get('http://127.0.0.1:8000/api/config', () => {
106
- return new HttpResponse(null, { status: 500 })
107
- })
108
- )
109
-
110
- await expect(client.makeRequest('/api/config')).rejects.toThrow(
111
- 'HTTP error! status: 500'
112
- )
113
- })
114
-
115
- it('should handle request cancellation', async () => {
116
- const abortController = new AbortController()
117
-
118
- // Mock a slow config endpoint
119
- server.use(
120
- http.get('http://127.0.0.1:8000/api/config', () => {
121
- return new Promise((resolve) => {
122
- setTimeout(() => {
123
- resolve(
124
- HttpResponse.json({
125
- feedback: {
126
- like: { enabled: true, form: null },
127
- dislike: { enabled: false, form: null },
128
- },
129
- })
130
- )
131
- }, 1000)
132
- })
133
- })
134
- )
135
-
136
- const requestPromise = client.makeRequest('/api/config', {
137
- signal: abortController.signal,
138
- })
139
-
140
- // Cancel the request immediately
141
- abortController.abort()
142
-
143
- await expect(requestPromise).rejects.toThrow()
144
- })
145
- })
146
-
147
- describe('makeStreamRequest', () => {
148
- it('should handle streaming response', async () => {
149
- const messages: unknown[] = []
150
- const errors: Error[] = []
151
- let closed = false
152
-
153
- const cancelFn = client.makeStreamRequest(
154
- '/api/chat',
155
- { message: 'Start streaming', history: [] },
156
- {
157
- onMessage: (data) => {
158
- messages.push(data)
159
- return Promise.resolve()
160
- },
161
- onError: (error) => {
162
- errors.push(error)
163
- return Promise.resolve()
164
- },
165
- onClose: () => {
166
- closed = true
167
- },
168
- }
169
- )
170
-
171
- // Wait for stream to complete
172
- await new Promise((resolve) => {
173
- const checkComplete = () => {
174
- if (closed || errors.length > 0) {
175
- resolve(void 0)
176
- } else {
177
- setTimeout(checkComplete, 50)
178
- }
179
- }
180
- checkComplete()
181
- })
182
-
183
- expect(messages).toHaveLength(4)
184
- expect(messages[0]).toEqual({
185
- type: 'text',
186
- content: 'Hello there!',
187
- })
188
- expect(messages[1]).toEqual({
189
- type: 'text',
190
- content: 'How can I help you?',
191
- })
192
- expect(messages[2]).toEqual({
193
- type: 'message_id',
194
- content: 'msg-123',
195
- })
196
- expect(messages[3]).toEqual({
197
- type: 'conversation_id',
198
- content: 'conv-456',
199
- })
200
- expect(errors).toHaveLength(0)
201
- expect(closed).toBe(true)
202
-
203
- // Cleanup
204
- cancelFn()
205
- })
206
-
207
- it('should handle stream cancellation', async () => {
208
- const messages: unknown[] = []
209
- const errors: Error[] = []
210
-
211
- const cancelFn = client.makeStreamRequest(
212
- '/api/chat',
213
- { message: 'Start streaming', history: [] },
214
- {
215
- onMessage: (data) => {
216
- messages.push(data)
217
- return Promise.resolve()
218
- },
219
- onError: (error) => {
220
- errors.push(error)
221
- return Promise.resolve()
222
- },
223
- }
224
- )
225
-
226
- // Cancel immediately
227
- cancelFn()
228
-
229
- // Wait a bit to ensure cancellation takes effect
230
- await new Promise((resolve) => setTimeout(resolve, 100))
231
-
232
- expect(messages.length).toBeLessThan(4) // Should not receive all messages
233
- })
234
-
235
- it('should handle stream errors', async () => {
236
- server.use(
237
- http.post('http://127.0.0.1:8000/api/chat', () => {
238
- return new HttpResponse(null, { status: 500 })
239
- })
240
- )
241
-
242
- const errors: Error[] = []
243
-
244
- client.makeStreamRequest(
245
- '/api/chat',
246
- { message: 'Start streaming', history: [] },
247
- {
248
- onMessage: () => Promise.resolve(),
249
- onError: (error) => {
250
- errors.push(error)
251
- return Promise.resolve()
252
- },
253
- }
254
- )
255
-
256
- // Wait for error to be captured
257
- await new Promise((resolve) => setTimeout(resolve, 100))
258
-
259
- expect(errors).toHaveLength(1)
260
- expect(errors[0].message).toContain('HTTP error! status: 500')
261
- })
262
-
263
- it('should handle malformed JSON in stream', async () => {
264
- server.use(
265
- http.post('http://127.0.0.1:8000/api/chat', () => {
266
- const encoder = new TextEncoder()
267
-
268
- const stream = new ReadableStream({
269
- start(controller) {
270
- controller.enqueue(
271
- encoder.encode('data: invalid-json\n\n')
272
- )
273
- controller.close()
274
- },
275
- })
276
-
277
- return new HttpResponse(stream, {
278
- headers: {
279
- 'Content-Type': 'text/event-stream',
280
- },
281
- })
282
- })
283
- )
284
-
285
- const errors: Error[] = []
286
-
287
- client.makeStreamRequest(
288
- '/api/chat',
289
- { message: 'Start streaming', history: [] },
290
- {
291
- onMessage: () => Promise.resolve(),
292
- onError: (error) => {
293
- errors.push(error)
294
- return Promise.resolve()
295
- },
296
- }
297
- )
298
-
299
- // Wait for error to be captured
300
- await new Promise((resolve) => setTimeout(resolve, 100))
301
-
302
- expect(errors).toHaveLength(1)
303
- expect(errors[0].message).toBe('Error processing server response')
304
- })
305
-
306
- it('should handle stream with AbortSignal', async () => {
307
- const abortController = new AbortController()
308
- const messages: unknown[] = []
309
- const errors: Error[] = []
310
-
311
- client.makeStreamRequest(
312
- '/api/chat',
313
- { message: 'Start streaming', history: [] },
314
- {
315
- onMessage: (data) => {
316
- messages.push(data)
317
- return Promise.resolve()
318
- },
319
- onError: (error) => {
320
- errors.push(error)
321
- return Promise.resolve()
322
- },
323
- },
324
- abortController.signal
325
- )
326
-
327
- // Abort after receiving first message (shorter delay to be more reliable)
328
- setTimeout(() => {
329
- abortController.abort()
330
- }, 5)
331
-
332
- // Wait longer for abort to take effect
333
- await new Promise((resolve) => setTimeout(resolve, 200))
334
-
335
- // Should receive fewer than 4 messages due to abort
336
- // Allow for some variation in timing but ensure it's not all 4
337
- expect(messages.length).toBeLessThanOrEqual(3)
338
- })
339
-
340
- it('should handle null response body', async () => {
341
- server.use(
342
- http.post('http://127.0.0.1:8000/api/chat', () => {
343
- return new HttpResponse(null, {
344
- headers: {
345
- 'Content-Type': 'text/event-stream',
346
- },
347
- })
348
- })
349
- )
350
-
351
- const errors: Error[] = []
352
-
353
- client.makeStreamRequest(
354
- '/api/chat',
355
- { message: 'Start streaming', history: [] },
356
- {
357
- onMessage: () => Promise.resolve(),
358
- onError: (error) => {
359
- errors.push(error)
360
- return Promise.resolve()
361
- },
362
- }
363
- )
364
-
365
- // Wait for error to be captured
366
- await new Promise((resolve) => setTimeout(resolve, 100))
367
-
368
- expect(errors).toHaveLength(1)
369
- expect(errors[0].message).toBe('Response body is null')
370
- })
371
-
372
- it('should handle reader stream errors', async () => {
373
- server.use(
374
- http.post('http://127.0.0.1:8000/api/chat', () => {
375
- const stream = new ReadableStream({
376
- start(controller) {
377
- // Simulate a stream error by enqueuing something and then erroring
378
- const encoder = new TextEncoder()
379
- controller.enqueue(
380
- encoder.encode(
381
- 'data: {"type": "text", "content": "Starting"}\n\n'
382
- )
383
- )
384
- // Force an error in the stream
385
- controller.error(new Error('Stream read error'))
386
- },
387
- })
388
-
389
- return new HttpResponse(stream, {
390
- headers: {
391
- 'Content-Type': 'text/event-stream',
392
- },
393
- })
394
- })
395
- )
396
-
397
- const errors: Error[] = []
398
- const messages: unknown[] = []
399
-
400
- client.makeStreamRequest(
401
- '/api/chat',
402
- { message: 'Start streaming', history: [] },
403
- {
404
- onMessage: (data) => {
405
- messages.push(data)
406
- return Promise.resolve()
407
- },
408
- onError: (error) => {
409
- errors.push(error)
410
- return Promise.resolve()
411
- },
412
- }
413
- )
414
-
415
- // Wait for error to be captured
416
- await new Promise((resolve) => setTimeout(resolve, 200))
417
-
418
- expect(errors).toHaveLength(1)
419
- expect(errors[0].message).toBe('Error reading stream')
420
- })
421
-
422
- it('should handle synchronous errors in makeStreamRequest', async () => {
423
- const errors: Error[] = []
424
-
425
- // Create a spy to force a synchronous error
426
- const originalFetch = global.fetch
427
- global.fetch = vi.fn().mockImplementation(() => {
428
- throw new Error('Synchronous fetch error')
429
- })
430
-
431
- client.makeStreamRequest(
432
- '/api/chat',
433
- { message: 'Start streaming', history: [] },
434
- {
435
- onMessage: () => Promise.resolve(),
436
- onError: (error) => {
437
- errors.push(error)
438
- return Promise.resolve()
439
- },
440
- }
441
- )
442
-
443
- // Wait for error to be captured
444
- await new Promise((resolve) => setTimeout(resolve, 100))
445
-
446
- expect(errors).toHaveLength(1)
447
- expect(errors[0].message).toBe('Synchronous fetch error')
448
-
449
- // Restore original fetch
450
- global.fetch = originalFetch
451
- })
452
-
453
- it('should handle non-Error exceptions in stream processing', async () => {
454
- const errors: Error[] = []
455
-
456
- // Mock fetch to throw a non-Error object
457
- const originalFetch = global.fetch
458
- global.fetch = vi.fn().mockImplementation(() => {
459
- throw 'String error' // Non-Error exception
460
- })
461
-
462
- client.makeStreamRequest(
463
- '/api/chat',
464
- { message: 'Start streaming', history: [] },
465
- {
466
- onMessage: () => Promise.resolve(),
467
- onError: (error) => {
468
- errors.push(error)
469
- return Promise.resolve()
470
- },
471
- }
472
- )
473
-
474
- // Wait for error to be captured
475
- await new Promise((resolve) => setTimeout(resolve, 100))
476
-
477
- expect(errors).toHaveLength(1)
478
- expect(errors[0].message).toBe('Error connecting to server')
479
-
480
- // Restore original fetch
481
- global.fetch = originalFetch
482
- })
483
- })
484
- })