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:
evandro committed 2026-10-10 04:12:13 -03:00
1 parent 37dfa265f5
commit 808cb1610b
2 files changed
+429 -21

No files matched your search

+39 -21
View File
@@ -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<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -26,19 +32,19 @@ function buildHeaders(config: ServerConfig): Record<string, string> {
}
export async function testConnection(config: ServerConfig): Promise<boolean> {
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<string, string>,
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<string, string>,
body: string,
baseUrl: string,
onChunk: (content: string) => void,
onDone: () => void,
onError: (error: Error) => void,
signal: AbortSignal,
): Promise<void> {
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);
}
}