227 lines
5.9 KiB
TypeScript
227 lines
5.9 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 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<TextGenResponse> {
|
|
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);
|
|
}
|
|
}
|