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
This commit is contained in:
1 parent
37dfa265f5
commit
808cb1610b
2 files changed
+429
-21
No files matched your search
@@ -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<Uint8Array>({
|
||||||
|
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<void>((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<string, string> = {};
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+39
-21
@@ -9,12 +9,18 @@ import {
|
|||||||
StreamChunk,
|
StreamChunk,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
function buildBaseUrl(config: ServerConfig): string {
|
export function buildBaseUrl(config: ServerConfig): string {
|
||||||
const host = config.host.replace(/\/+$/, '');
|
const host = config.host.replace(/\/+$/, '');
|
||||||
const protocol = host.startsWith('http') ? '' : 'http://';
|
const protocol = /^https?:\/\//i.test(host) ? '' : 'http://';
|
||||||
return `${protocol}${host}:${config.port}/v1`;
|
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<string, string> {
|
function buildHeaders(config: ServerConfig): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -26,19 +32,19 @@ function buildHeaders(config: ServerConfig): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function testConnection(config: ServerConfig): Promise<boolean> {
|
export async function testConnection(config: ServerConfig): Promise<boolean> {
|
||||||
try {
|
const controller = new AbortController();
|
||||||
const controller = new AbortController();
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await fetch(`${buildBaseUrl(config)}/models`, {
|
const res = await fetch(`${buildBaseUrl(config)}/models`, {
|
||||||
headers: buildHeaders(config),
|
headers: buildHeaders(config),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
return res.ok;
|
return res.ok;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +90,7 @@ export async function chatCompletion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse SSE lines from a text buffer
|
// Parse SSE lines from a text buffer
|
||||||
function parseSSELines(
|
export function parseSSELines(
|
||||||
buffer: string,
|
buffer: string,
|
||||||
onChunk: (content: string) => void,
|
onChunk: (content: string) => void,
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
@@ -95,9 +101,9 @@ function parseSSELines(
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
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]') {
|
if (payload === '[DONE]') {
|
||||||
isDone = true;
|
isDone = true;
|
||||||
onDone();
|
onDone();
|
||||||
@@ -123,6 +129,7 @@ function streamWithXHR(
|
|||||||
url: string,
|
url: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
body: string,
|
body: string,
|
||||||
|
baseUrl: string,
|
||||||
onChunk: (content: string) => void,
|
onChunk: (content: string) => void,
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
onError: (error: Error) => void,
|
onError: (error: Error) => void,
|
||||||
@@ -146,6 +153,10 @@ function streamWithXHR(
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
onError(new Error(`Stream failed: ${xhr.status} ${xhr.statusText}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Process any remaining buffer
|
// Process any remaining buffer
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
parseSSELines(buffer + '\n', onChunk, onDone);
|
parseSSELines(buffer + '\n', onChunk, onDone);
|
||||||
@@ -154,7 +165,7 @@ function streamWithXHR(
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`));
|
onError(connectionError(baseUrl));
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.ontimeout = () => {
|
xhr.ontimeout = () => {
|
||||||
@@ -175,17 +186,23 @@ async function streamWithFetch(
|
|||||||
url: string,
|
url: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
body: string,
|
body: string,
|
||||||
|
baseUrl: string,
|
||||||
onChunk: (content: string) => void,
|
onChunk: (content: string) => void,
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
onError: (error: Error) => void,
|
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(url, {
|
let res: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers,
|
res = await fetch(url, {
|
||||||
body,
|
method: 'POST',
|
||||||
signal,
|
headers,
|
||||||
});
|
body,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === 'AbortError') throw err;
|
||||||
|
throw connectionError(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Stream failed: ${res.status} ${res.statusText}`);
|
throw new Error(`Stream failed: ${res.status} ${res.statusText}`);
|
||||||
@@ -229,7 +246,8 @@ export function chatCompletionStream(
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = `${buildBaseUrl(config)}/chat/completions`;
|
const baseUrl = buildBaseUrl(config);
|
||||||
|
const url = `${baseUrl}/chat/completions`;
|
||||||
const headers = buildHeaders(config);
|
const headers = buildHeaders(config);
|
||||||
const bodyStr = JSON.stringify(body);
|
const bodyStr = JSON.stringify(body);
|
||||||
|
|
||||||
@@ -245,7 +263,7 @@ export function chatCompletionStream(
|
|||||||
// Web: use fetch + ReadableStream
|
// Web: use fetch + ReadableStream
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal)
|
streamWithFetch(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, controller.signal)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if ((err as Error).name !== 'AbortError') {
|
if ((err as Error).name !== 'AbortError') {
|
||||||
onError(err as Error);
|
onError(err as Error);
|
||||||
@@ -255,6 +273,6 @@ export function chatCompletionStream(
|
|||||||
return { abort: () => controller.abort() };
|
return { abort: () => controller.abort() };
|
||||||
} else {
|
} else {
|
||||||
// Native: use XMLHttpRequest
|
// Native: use XMLHttpRequest
|
||||||
return streamWithXHR(url, headers, bodyStr, onChunk, safeOnDone, onError);
|
return streamWithXHR(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, onError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user