export const routes = {
health: '/health',
metrics: '/metrics',
cacheStats: '/cache/stats',
models: '/v1/models',
chatCompletions: '/v1/chat/completions',
cancel: '/v1/cancel',
adminModels: '/admin/models',
adminModelsLoad: '/admin/models/load',
adminModelsUnload: '/admin/models/unload',
adminDownload: '/admin/download',
adminTasks: '/admin/tasks',
adminStats: '/admin/stats',
adminTaskCancel: (taskId) => `/admin/tasks/${encodeURIComponent(taskId)}/cancel`,
};
const KEY_STORAGE = 'ferrox.studio.apiKey';
export function apiKey() {
try {
return localStorage.getItem(KEY_STORAGE) || '';
} catch {
return '';
}
}
export function setApiKey(value) {
try {
if (value) localStorage.setItem(KEY_STORAGE, value);
else localStorage.removeItem(KEY_STORAGE);
} catch {
}
}
export const baseUrl = () => window.location.origin;
function headers(extra = {}) {
const h = { ...extra };
const key = apiKey();
if (key) h.Authorization = `Bearer ${key}`;
return h;
}
export class ApiError extends Error {
constructor(status, message, body) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
get isMissingEndpoint() {
return this.status === 404 || this.status === 405;
}
get isAuth() {
return this.status === 401 || this.status === 403;
}
}
async function parse(response) {
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = null;
}
if (!response.ok) {
const message =
body?.error?.message || body?.message || text.slice(0, 300) || response.statusText;
throw new ApiError(response.status, message, body);
}
return body;
}
export async function getJson(path, { signal } = {}) {
let response;
try {
response = await fetch(path, { headers: headers(), signal });
} catch (cause) {
if (cause?.name === 'AbortError') throw cause;
throw new ApiError(0, `cannot reach ${baseUrl()}: ${cause.message}`, null);
}
return parse(response);
}
export async function postJson(path, payload, { signal } = {}) {
let response;
try {
response = await fetch(path, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: payload === undefined ? '{}' : JSON.stringify(payload),
signal,
});
} catch (cause) {
if (cause?.name === 'AbortError') throw cause;
throw new ApiError(0, `cannot reach ${baseUrl()}: ${cause.message}`, null);
}
return parse(response);
}
export function cancelGeneration(requestId) {
if (!requestId) return;
try {
fetch(routes.cancel, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ request_id: requestId }),
keepalive: true,
}).catch(() => {});
} catch {
}
}
const STALL_MS = 45000;
export async function streamChat(
request,
{ signal, onToken, onRequestId, onStall, stallMs = STALL_MS } = {},
) {
const response = await fetch(routes.chatCompletions, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json', Accept: 'text/event-stream' }),
body: JSON.stringify({ ...request, stream: true }),
signal,
});
if (!response.ok || !response.body) {
return parse(response);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let done = false;
let requestId = null;
let usage = null;
let finishReason = null;
const handle = (payload) => {
if (payload === '[DONE]') {
done = true;
return;
}
let chunk;
try {
chunk = JSON.parse(payload);
} catch {
return; }
if (chunk.request_id && !requestId) {
requestId = chunk.request_id;
onRequestId?.(requestId);
}
if (chunk.usage) usage = chunk.usage;
const choice = chunk.choices?.[0];
if (choice?.finish_reason) finishReason = choice.finish_reason;
const text = choice?.delta?.content;
if (text) onToken?.(text);
};
let stalled = false;
let stallTimer = null;
const armStallTimer = () => {
clearTimeout(stallTimer);
if (!onStall) return;
stallTimer = setTimeout(() => {
stalled = true;
onStall(stallMs);
}, stallMs);
};
const disarmStallTimer = () => {
clearTimeout(stallTimer);
stallTimer = null;
};
try {
armStallTimer();
for (;;) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
armStallTimer();
if (stalled) {
stalled = false;
onStall?.(null);
}
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf('\n\n');
while (boundary !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) handle(line.slice(5).trimStart());
}
boundary = buffer.indexOf('\n\n');
}
}
} finally {
disarmStallTimer();
if (stalled) onStall?.(null);
}
if (!done && !finishReason) {
throw new ApiError(
0,
'the stream ended without a finish reason — the response was truncated, not completed',
null,
);
}
return { requestId, usage, finishReason };
}