From 808cb1610b7d07b05dd825fbc9a1c4f7d54bf46b Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sat, 10 Oct 2026 04:12:13 -0300 Subject: [PATCH] fix(api): handle hosts starting with http, HTTP errors on XHR and clearer connection errors - buildBaseUrl treated any host starting with 'http' (e.g. httpbin.local) as having a scheme - native streaming finished silently on 4xx/5xx; it now reports the status - network failures now explain how to fix the connection instead of 'XHR streaming failed: 0' - testConnection left its 5 s timer running on failure - SSE parser accepts 'data:' without a space, as the spec allows --- __tests__/lmStudioApi.test.ts | 390 ++++++++++++++++++++++++++++++++++ services/lmStudioApi.ts | 60 ++++-- 2 files changed, 429 insertions(+), 21 deletions(-) create mode 100644 __tests__/lmStudioApi.test.ts diff --git a/__tests__/lmStudioApi.test.ts b/__tests__/lmStudioApi.test.ts new file mode 100644 index 0000000..791c141 --- /dev/null +++ b/__tests__/lmStudioApi.test.ts @@ -0,0 +1,390 @@ +import { Platform } from 'react-native'; +import { + buildBaseUrl, + chatCompletion, + chatCompletionStream, + getModels, + parseSSELines, + testConnection, +} from '../services/lmStudioApi'; +import { ServerConfig } from '../services/types'; + +const config: ServerConfig = { host: '192.168.0.10', port: 1234 }; + +const fetchMock = jest.fn(); +const originalFetch = global.fetch; + +beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; +}); + +afterAll(() => { + global.fetch = originalFetch; +}); + +const sseLine = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`; + +describe('buildBaseUrl', () => { + it('prepends http:// and appends port and /v1', () => { + expect(buildBaseUrl(config)).toBe('http://192.168.0.10:1234/v1'); + }); + + it('keeps an explicit http or https scheme', () => { + expect(buildBaseUrl({ host: 'http://lan.local', port: 8080 })).toBe('http://lan.local:8080/v1'); + expect(buildBaseUrl({ host: 'https://lan.local', port: 443 })).toBe('https://lan.local:443/v1'); + }); + + it('strips trailing slashes from the host', () => { + expect(buildBaseUrl({ host: 'http://lan.local//', port: 1234 })).toBe('http://lan.local:1234/v1'); + }); + + it('does not mistake a host that merely starts with "http" for a scheme', () => { + expect(buildBaseUrl({ host: 'httpbin.local', port: 1234 })).toBe('http://httpbin.local:1234/v1'); + }); +}); + +describe('parseSSELines', () => { + it('emits content of every complete data line and keeps the partial tail', () => { + const onChunk = jest.fn(); + const onDone = jest.fn(); + const result = parseSSELines(`${sseLine('Hel')}\n${sseLine('lo')}\ndata: {"cho`, onChunk, onDone); + + expect(onChunk.mock.calls).toEqual([['Hel'], ['lo']]); + expect(result).toEqual({ remaining: 'data: {"cho', isDone: false }); + expect(onDone).not.toHaveBeenCalled(); + }); + + it('stops at [DONE] and calls onDone', () => { + const onChunk = jest.fn(); + const onDone = jest.fn(); + const result = parseSSELines(`${sseLine('a')}\ndata: [DONE]\n${sseLine('ignored')}\n`, onChunk, onDone); + + expect(onChunk.mock.calls).toEqual([['a']]); + expect(onDone).toHaveBeenCalledTimes(1); + expect(result).toEqual({ remaining: '', isDone: true }); + }); + + it('skips malformed JSON, empty deltas, comments and non-data lines', () => { + const onChunk = jest.fn(); + parseSSELines( + [ + 'data: {not json}', + 'data: {"choices":[{"delta":{}}]}', + 'data: {"choices":[]}', + ': keep-alive', + 'event: message', + '', + sseLine('ok'), + '', + ].join('\n'), + onChunk, + jest.fn(), + ); + expect(onChunk.mock.calls).toEqual([['ok']]); + }); + + it('handles CRLF line endings and "data:" without a space', () => { + const onChunk = jest.fn(); + const noSpace = `data:${JSON.stringify({ choices: [{ delta: { content: 'nospace' } }] })}`; + parseSSELines(`${sseLine('crlf')}\r\n${noSpace}\n`, onChunk, jest.fn()); + expect(onChunk.mock.calls).toEqual([['crlf'], ['nospace']]); + }); +}); + +describe('testConnection', () => { + it('sends the Authorization header only when an API key is set', async () => { + fetchMock.mockResolvedValue({ ok: true }); + await testConnection({ ...config, apiKey: 'secret' }); + await testConnection({ ...config, apiKey: '' }); + + expect(fetchMock.mock.calls[0][0]).toBe('http://192.168.0.10:1234/v1/models'); + expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer secret' }); + expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty('Authorization'); + }); + + it('returns true on a 2xx response and false otherwise', async () => { + fetchMock.mockResolvedValueOnce({ ok: true }); + await expect(testConnection(config)).resolves.toBe(true); + fetchMock.mockResolvedValueOnce({ ok: false }); + await expect(testConnection(config)).resolves.toBe(false); + }); + + it('returns false when the network call throws', async () => { + fetchMock.mockRejectedValue(new TypeError('Network request failed')); + await expect(testConnection(config)).resolves.toBe(false); + }); + + it('aborts after 5 seconds and returns false', async () => { + jest.useFakeTimers(); + try { + fetchMock.mockImplementation( + (_url: string, init: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => reject(new Error('aborted'))); + }), + ); + const pending = testConnection(config); + await jest.advanceTimersByTimeAsync(5000); + await expect(pending).resolves.toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it('does not leave the timeout timer running after a failure', async () => { + jest.useFakeTimers(); + try { + fetchMock.mockRejectedValue(new TypeError('Network request failed')); + await testConnection(config); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe('getModels', () => { + it('returns the data array', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'm1' }] }) }); + await expect(getModels(config)).resolves.toEqual([{ id: 'm1' }]); + }); + + it('falls back to an empty list when data is missing', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) }); + await expect(getModels(config)).resolves.toEqual([]); + }); + + it('throws with the HTTP status when the response is not ok', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' }); + await expect(getModels(config)).rejects.toThrow('Failed to fetch models: 401 Unauthorized'); + }); +}); + +describe('chatCompletion', () => { + it('posts a non-streaming request with defaults that options can override', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'x' }) }); + await chatCompletion(config, 'model-a', [{ role: 'user', content: 'hi' }], { temperature: 0.1 }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://192.168.0.10:1234/v1/chat/completions'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + model: 'model-a', + messages: [{ role: 'user', content: 'hi' }], + stream: false, + temperature: 0.1, + max_tokens: 2048, + }); + }); + + it('throws with the HTTP status when the response is not ok', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }); + await expect(chatCompletion(config, 'm', [])).rejects.toThrow('Chat completion failed: 500 Server Error'); + }); +}); + +describe('chatCompletionStream (web, fetch + ReadableStream)', () => { + const originalOS = Platform.OS; + const encoder = new TextEncoder(); + + beforeEach(() => { + Platform.OS = 'web'; + }); + afterEach(() => { + Platform.OS = originalOS; + }); + + const streamOf = (parts: string[]) => + new ReadableStream({ + start(controller) { + parts.forEach((p) => controller.enqueue(encoder.encode(p))); + controller.close(); + }, + }); + + // Starts a stream and resolves once either onDone or onError fired. + const run = () => { + const onChunk = jest.fn(); + const onDone = jest.fn(); + const onError = jest.fn(); + const finished = new Promise((resolve) => { + chatCompletionStream( + config, + 'm', + [], + onChunk, + () => { + onDone(); + resolve(); + }, + (e) => { + onError(e); + resolve(); + }, + ); + }); + return { onChunk, onDone, onError, finished }; + }; + + it('streams chunks split across reads and calls onDone exactly once', async () => { + const whole = `${sseLine('Hi')}\n${sseLine(' there')}\ndata: [DONE]\n`; + fetchMock.mockResolvedValue({ ok: true, body: streamOf([whole.slice(0, 20), whole.slice(20)]) }); + + const { onChunk, onDone, onError, finished } = run(); + await finished; + + expect(onChunk.mock.calls).toEqual([['Hi'], [' there']]); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).stream).toBe(true); + }); + + it('calls onDone when the stream ends without [DONE]', async () => { + fetchMock.mockResolvedValue({ ok: true, body: streamOf([`${sseLine('x')}\n`]) }); + const { onChunk, onDone, finished } = run(); + await finished; + expect(onChunk).toHaveBeenCalledWith('x'); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('reports an HTTP error through onError', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }); + const { onError, finished } = run(); + await finished; + expect(onError.mock.calls[0][0].message).toBe('Stream failed: 404 Not Found'); + }); + + it('turns a network failure into an actionable connection error', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + const { onError, finished } = run(); + await finished; + expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1'); + }); + + it('does not report an error when the request is aborted', async () => { + fetchMock.mockImplementation( + (_url: string, init: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + reject(err); + }); + }), + ); + const onError = jest.fn(); + const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError); + handle.abort(); + await new Promise((r) => setTimeout(r, 0)); + expect(onError).not.toHaveBeenCalled(); + }); +}); + +describe('chatCompletionStream (native, XMLHttpRequest)', () => { + class FakeXHR { + static last: FakeXHR; + status = 200; + statusText = 'OK'; + responseText = ''; + headers: Record = {}; + aborted = false; + onprogress: (() => void) | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + ontimeout: (() => void) | null = null; + open = jest.fn(); + send = jest.fn(); + constructor() { + FakeXHR.last = this; + } + setRequestHeader(k: string, v: string) { + this.headers[k] = v; + } + abort() { + this.aborted = true; + } + } + + const originalXHR = global.XMLHttpRequest; + beforeEach(() => { + global.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest; + }); + afterEach(() => { + global.XMLHttpRequest = originalXHR; + }); + + it('parses progress events incrementally and finishes once', () => { + const onChunk = jest.fn(); + const onDone = jest.fn(); + chatCompletionStream({ ...config, apiKey: 'k' }, 'm', [], onChunk, onDone, jest.fn()); + const xhr = FakeXHR.last; + + expect(xhr.open).toHaveBeenCalledWith('POST', 'http://192.168.0.10:1234/v1/chat/completions'); + expect(xhr.headers.Authorization).toBe('Bearer k'); + + const full = `${sseLine('A')}\n${sseLine('B')}\n`; + xhr.responseText = full.slice(0, 30); + xhr.onprogress?.(); + xhr.responseText = full; + xhr.onprogress?.(); + xhr.responseText = `${full}data: [DONE]\n`; + xhr.onprogress?.(); + xhr.onload?.(); + + expect(onChunk.mock.calls).toEqual([['A'], ['B']]); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('flushes a final line that has no trailing newline on load', () => { + const onChunk = jest.fn(); + chatCompletionStream(config, 'm', [], onChunk, jest.fn(), jest.fn()); + const xhr = FakeXHR.last; + xhr.responseText = sseLine('tail'); + xhr.onprogress?.(); + xhr.onload?.(); + expect(onChunk.mock.calls).toEqual([['tail']]); + }); + + it('reports an HTTP error status instead of finishing silently', () => { + const onDone = jest.fn(); + const onError = jest.fn(); + chatCompletionStream(config, 'm', [], jest.fn(), onDone, onError); + const xhr = FakeXHR.last; + xhr.status = 401; + xhr.statusText = 'Unauthorized'; + xhr.responseText = '{"error":"bad key"}'; + xhr.onprogress?.(); + xhr.onload?.(); + + expect(onError.mock.calls[0][0].message).toBe('Stream failed: 401 Unauthorized'); + expect(onDone).not.toHaveBeenCalled(); + }); + + it('turns a network error into an actionable connection error', () => { + const onError = jest.fn(); + chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError); + FakeXHR.last.status = 0; + FakeXHR.last.onerror?.(); + expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1'); + }); + + it('reports a timeout', () => { + const onError = jest.fn(); + chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError); + FakeXHR.last.ontimeout?.(); + expect(onError.mock.calls[0][0].message).toBe('Request timed out'); + }); + + it('abort() aborts the request and swallows errors thrown by abort', () => { + const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), jest.fn()); + const xhr = FakeXHR.last; + handle.abort(); + expect(xhr.aborted).toBe(true); + + xhr.abort = () => { + throw new Error('boom'); + }; + expect(() => handle.abort()).not.toThrow(); + }); +}); diff --git a/services/lmStudioApi.ts b/services/lmStudioApi.ts index 02f3ba9..c720064 100644 --- a/services/lmStudioApi.ts +++ b/services/lmStudioApi.ts @@ -9,12 +9,18 @@ import { StreamChunk, } from './types'; -function buildBaseUrl(config: ServerConfig): string { +export function buildBaseUrl(config: ServerConfig): string { const host = config.host.replace(/\/+$/, ''); - const protocol = host.startsWith('http') ? '' : 'http://'; + const protocol = /^https?:\/\//i.test(host) ? '' : 'http://'; return `${protocol}${host}:${config.port}/v1`; } +function connectionError(baseUrl: string): Error { + return new Error( + `Could not reach the server at ${baseUrl}. Check the host and port in Settings and that LM Studio's local server is running.`, + ); +} + function buildHeaders(config: ServerConfig): Record { const headers: Record = { 'Content-Type': 'application/json', @@ -26,19 +32,19 @@ function buildHeaders(config: ServerConfig): Record { } export async function testConnection(config: ServerConfig): Promise { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { const res = await fetch(`${buildBaseUrl(config)}/models`, { headers: buildHeaders(config), signal: controller.signal, }); - - clearTimeout(timeout); return res.ok; } catch { return false; + } finally { + clearTimeout(timeout); } } @@ -84,7 +90,7 @@ export async function chatCompletion( } // Parse SSE lines from a text buffer -function parseSSELines( +export function parseSSELines( buffer: string, onChunk: (content: string) => void, onDone: () => void, @@ -95,9 +101,9 @@ function parseSSELines( for (const line of lines) { const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith('data: ')) continue; + if (!trimmed.startsWith('data:')) continue; - const payload = trimmed.slice(6); + const payload = trimmed.slice(5).trim(); if (payload === '[DONE]') { isDone = true; onDone(); @@ -123,6 +129,7 @@ function streamWithXHR( url: string, headers: Record, body: string, + baseUrl: string, onChunk: (content: string) => void, onDone: () => void, onError: (error: Error) => void, @@ -146,6 +153,10 @@ function streamWithXHR( }; xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { + onError(new Error(`Stream failed: ${xhr.status} ${xhr.statusText}`)); + return; + } // Process any remaining buffer if (buffer.trim()) { parseSSELines(buffer + '\n', onChunk, onDone); @@ -154,7 +165,7 @@ function streamWithXHR( }; xhr.onerror = () => { - onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`)); + onError(connectionError(baseUrl)); }; xhr.ontimeout = () => { @@ -175,17 +186,23 @@ async function streamWithFetch( url: string, headers: Record, body: string, + baseUrl: string, onChunk: (content: string) => void, onDone: () => void, - onError: (error: Error) => void, signal: AbortSignal, ): Promise { - const res = await fetch(url, { - method: 'POST', - headers, - body, - signal, - }); + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers, + body, + signal, + }); + } catch (err) { + if ((err as Error).name === 'AbortError') throw err; + throw connectionError(baseUrl); + } if (!res.ok) { throw new Error(`Stream failed: ${res.status} ${res.statusText}`); @@ -229,7 +246,8 @@ export function chatCompletionStream( ...options, }; - const url = `${buildBaseUrl(config)}/chat/completions`; + const baseUrl = buildBaseUrl(config); + const url = `${baseUrl}/chat/completions`; const headers = buildHeaders(config); const bodyStr = JSON.stringify(body); @@ -245,7 +263,7 @@ export function chatCompletionStream( // Web: use fetch + ReadableStream const controller = new AbortController(); - streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal) + streamWithFetch(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, controller.signal) .catch((err) => { if ((err as Error).name !== 'AbortError') { onError(err as Error); @@ -255,6 +273,6 @@ export function chatCompletionStream( return { abort: () => controller.abort() }; } else { // Native: use XMLHttpRequest - return streamWithXHR(url, headers, bodyStr, onChunk, safeOnDone, onError); + return streamWithXHR(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, onError); } }