import { Platform } from 'react-native'; import { ChatCompletionRequest, ChatCompletionResponse, ChatMessage, LMModel, ModelsResponse, ServerConfig, StreamChunk, } from './types'; function buildBaseUrl(config: ServerConfig): string { const host = config.host.replace(/\/+$/, ''); const protocol = host.startsWith('http') ? '' : 'http://'; return `${protocol}${host}:${config.port}/v1`; } function buildHeaders(config: ServerConfig): Record { const headers: Record = { 'Content-Type': 'application/json', }; if (config.apiKey) { headers['Authorization'] = `Bearer ${config.apiKey}`; } return headers; } export async function testConnection(config: ServerConfig): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const res = await fetch(`${buildBaseUrl(config)}/models`, { headers: buildHeaders(config), signal: controller.signal, }); clearTimeout(timeout); return res.ok; } catch { return false; } } export async function getModels(config: ServerConfig): Promise { const res = await fetch(`${buildBaseUrl(config)}/models`, { headers: buildHeaders(config), }); if (!res.ok) { throw new Error(`Failed to fetch models: ${res.status} ${res.statusText}`); } const data: ModelsResponse = await res.json(); return data.data ?? []; } export async function chatCompletion( config: ServerConfig, model: string, messages: ChatMessage[], options?: Partial>, ): Promise { const body: ChatCompletionRequest = { model, messages, stream: false, temperature: 0.7, max_tokens: 2048, ...options, }; const res = await fetch(`${buildBaseUrl(config)}/chat/completions`, { method: 'POST', headers: buildHeaders(config), body: JSON.stringify(body), }); if (!res.ok) { throw new Error(`Chat completion failed: ${res.status} ${res.statusText}`); } return res.json(); } // Parse SSE lines from a text buffer function parseSSELines( buffer: string, onChunk: (content: string) => void, onDone: () => void, ): { remaining: string; isDone: boolean } { const lines = buffer.split('\n'); const remaining = lines.pop() ?? ''; let isDone = false; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith('data: ')) continue; const payload = trimmed.slice(6); if (payload === '[DONE]') { isDone = true; onDone(); return { remaining: '', isDone: true }; } try { const chunk: StreamChunk = JSON.parse(payload); const content = chunk.choices?.[0]?.delta?.content; if (content) { onChunk(content); } } catch { // skip malformed chunks } } return { remaining, isDone }; } // Native streaming using XMLHttpRequest (works on React Native) function streamWithXHR( url: string, headers: Record, body: string, onChunk: (content: string) => void, onDone: () => void, onError: (error: Error) => void, ): { abort: () => void } { const xhr = new XMLHttpRequest(); let buffer = ''; let lastIndex = 0; xhr.open('POST', url); Object.entries(headers).forEach(([key, value]) => { xhr.setRequestHeader(key, value); }); xhr.onprogress = () => { const newData = xhr.responseText.substring(lastIndex); lastIndex = xhr.responseText.length; buffer += newData; const result = parseSSELines(buffer, onChunk, onDone); buffer = result.remaining; }; xhr.onload = () => { // Process any remaining buffer if (buffer.trim()) { parseSSELines(buffer + '\n', onChunk, onDone); } onDone(); }; xhr.onerror = () => { onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`)); }; xhr.ontimeout = () => { onError(new Error('Request timed out')); }; xhr.send(body); return { abort: () => { try { xhr.abort(); } catch { } }, }; } // Web streaming using ReadableStream (works in browsers) async function streamWithFetch( url: string, headers: Record, body: string, onChunk: (content: string) => void, onDone: () => void, onError: (error: Error) => void, signal: AbortSignal, ): Promise { const res = await fetch(url, { method: 'POST', headers, body, signal, }); if (!res.ok) { throw new Error(`Stream failed: ${res.status} ${res.statusText}`); } const reader = res.body?.getReader(); if (!reader) { throw new Error('ReadableStream not supported'); } const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const result = parseSSELines(buffer, onChunk, onDone); buffer = result.remaining; if (result.isDone) return; } onDone(); } export function chatCompletionStream( config: ServerConfig, model: string, messages: ChatMessage[], onChunk: (content: string) => void, onDone: () => void, onError: (error: Error) => void, options?: Partial>, ): { abort: () => void } { const body: ChatCompletionRequest = { model, messages, stream: true, temperature: 0.7, max_tokens: 2048, ...options, }; const url = `${buildBaseUrl(config)}/chat/completions`; const headers = buildHeaders(config); const bodyStr = JSON.stringify(body); let doneEmitted = false; const safeOnDone = () => { if (!doneEmitted) { doneEmitted = true; onDone(); } }; if (Platform.OS === 'web') { // Web: use fetch + ReadableStream const controller = new AbortController(); streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal) .catch((err) => { if ((err as Error).name !== 'AbortError') { onError(err as Error); } }); return { abort: () => controller.abort() }; } else { // Native: use XMLHttpRequest return streamWithXHR(url, headers, bodyStr, onChunk, safeOnDone, onError); } }