/** * Text generation client for calling textgen API endpoints. */ import { TokenUsage, AIResponseMeta, createAIError, ErrorCodes } from './types'; /** Role for chat messages. */ export type MessageRole = 'system' | 'user' | 'assistant'; /** A single message in a conversation. */ export interface TextGenMessage { role: MessageRole; content: string; } /** Request for text generation. */ export interface TextGenRequest { messages: TextGenMessage[]; model?: string; maxTokens?: number; temperature?: number; stream?: boolean; } /** Response from text generation. */ export interface TextGenResponse extends AIResponseMeta { text: string; usage?: TokenUsage; finishReason?: 'stop' | 'length' | 'content_filter'; } /** Chunk received during streaming. */ export interface TextGenChunk { text: string; done: boolean; } /** Options for API calls. */ export interface TextGenOptions { timeout?: number; headers?: Record; } /** * Stream text generation with Server-Sent Events. * * @param endpoint - The textgen API endpoint URL * @param request - The generation request * @param onChunk - Callback for each text chunk received * @param onDone - Callback when generation completes * @param onError - Callback for errors * @param options - Additional options * @returns Abort function to cancel the stream * * @example * ```ts * const abort = streamText( * '/api/textgen', * { messages: [{ role: 'user', content: 'Hello!' }] }, * (chunk) => console.log(chunk), * (response) => console.log('Done:', response), * (error) => console.error(error) * ); * * // Cancel if needed * abort(); * ``` */ export function streamText( endpoint: string, request: TextGenRequest, onChunk: (chunk: string) => void, onDone: (response: TextGenResponse) => void, onError: (error: Error) => void, options: TextGenOptions = {} ): () => void { const controller = new AbortController(); const startTime = Date.now(); const streamRequest = { ...request, stream: true }; fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...options.headers, }, body: JSON.stringify(streamRequest), signal: controller.signal, }) .then(async (response) => { if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw createAIError( errorData.message || `HTTP ${response.status}`, response.status === 429 ? ErrorCodes.RATE_LIMITED : ErrorCodes.PROVIDER_ERROR, undefined, response.status === 429 || response.status >= 500 ); } const reader = response.body?.getReader(); if (!reader) { throw createAIError('No response body', ErrorCodes.NETWORK_ERROR); } const decoder = new TextDecoder(); let fullText = ''; let provider = ''; let usage: TokenUsage | undefined; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data); if (parsed.text) { fullText += parsed.text; onChunk(parsed.text); } if (parsed.provider) provider = parsed.provider; if (parsed.usage) usage = parsed.usage; } catch { // Skip invalid JSON lines } } } } onDone({ text: fullText, provider, usage, latencyMs: Date.now() - startTime, }); }) .catch((error) => { if (error.name === 'AbortError') return; onError(error); }); return () => controller.abort(); } /** * Generate text (non-streaming). * * @param endpoint - The textgen API endpoint URL * @param request - The generation request * @param options - Additional options * @returns Promise resolving to the generation response * * @example * ```ts * const response = await generateText('/api/textgen', { * messages: [ * { role: 'system', content: 'You are a helpful assistant.' }, * { role: 'user', content: 'What is 2 + 2?' } * ], * temperature: 0.7, * }); * console.log(response.text); * ``` */ export async function generateText( endpoint: string, request: TextGenRequest, options: TextGenOptions = {} ): Promise { const startTime = Date.now(); const nonStreamRequest = { ...request, stream: false }; const controller = new AbortController(); const timeoutId = options.timeout ? setTimeout(() => controller.abort(), options.timeout) : undefined; try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...options.headers, }, body: JSON.stringify(nonStreamRequest), signal: controller.signal, }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw createAIError( errorData.message || `HTTP ${response.status}`, response.status === 429 ? ErrorCodes.RATE_LIMITED : ErrorCodes.PROVIDER_ERROR, undefined, response.status === 429 || response.status >= 500 ); } const data = await response.json(); return { text: data.text || '', provider: data.provider || '', model: data.model, usage: data.usage, finishReason: data.finish_reason, latencyMs: Date.now() - startTime, }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw createAIError('Request timed out', ErrorCodes.TIMEOUT, undefined, true); } throw error; } finally { if (timeoutId) clearTimeout(timeoutId); } }