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
+39
-21
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user