| import type {
|
| ChatMessage,
|
| ChatCompletionResponse,
|
| ChatCompletionChunk,
|
| ChatToolDefinition,
|
| ChatToolChoice,
|
| Platform,
|
| } from '@freellmapi/shared/types.js';
|
|
|
| export interface CompletionOptions {
|
| model?: string;
|
| temperature?: number;
|
| max_tokens?: number;
|
| top_p?: number;
|
| tools?: ChatToolDefinition[];
|
| tool_choice?: ChatToolChoice;
|
| parallel_tool_calls?: boolean;
|
| }
|
|
|
| export abstract class BaseProvider {
|
| abstract readonly platform: Platform;
|
| abstract readonly name: string;
|
| |
| |
| |
|
|
| keyless = false;
|
|
|
| abstract chatCompletion(
|
| apiKey: string,
|
| messages: ChatMessage[],
|
| modelId: string,
|
| options?: CompletionOptions,
|
| ): Promise<ChatCompletionResponse>;
|
|
|
| abstract streamChatCompletion(
|
| apiKey: string,
|
| messages: ChatMessage[],
|
| modelId: string,
|
| options?: CompletionOptions,
|
| ): AsyncGenerator<ChatCompletionChunk>;
|
|
|
| abstract validateKey(apiKey: string): Promise<boolean>;
|
|
|
| protected async fetchWithTimeout(
|
| url: string,
|
| init: RequestInit,
|
| timeoutMs = 15000,
|
| ): Promise<Response> {
|
| const controller = new AbortController();
|
| const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
| try {
|
| return await fetch(url, { ...init, signal: controller.signal });
|
| } finally {
|
| clearTimeout(timeout);
|
| }
|
| }
|
|
|
| protected makeId(): string {
|
| return `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| protected async *readSseStream(
|
| res: Response,
|
| inactivityTimeoutMs = 90000,
|
| ): AsyncGenerator<ChatCompletionChunk> {
|
| const reader = res.body?.getReader();
|
| if (!reader) throw new Error('No response body');
|
|
|
| const decoder = new TextDecoder();
|
| let buffer = '';
|
| let sawFinishReason = false;
|
|
|
| try {
|
| while (true) {
|
| let timer: ReturnType<typeof setTimeout> | undefined;
|
| const result = await Promise.race([
|
| reader.read(),
|
| new Promise<never>((_, reject) => {
|
| timer = setTimeout(
|
| () => reject(new Error(`${this.name} stream stalled: no data for ${inactivityTimeoutMs}ms (timeout)`)),
|
| inactivityTimeoutMs,
|
| );
|
| }),
|
| ]).finally(() => clearTimeout(timer));
|
|
|
| const { done, value } = result;
|
| if (done) break;
|
|
|
| buffer += decoder.decode(value, { stream: true });
|
| const lines = buffer.split('\n');
|
| buffer = lines.pop() ?? '';
|
|
|
| for (const line of lines) {
|
| const trimmed = line.trim();
|
| if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
| const data = trimmed.slice(6);
|
| if (data === '[DONE]') return;
|
| try {
|
| const chunk = JSON.parse(data) as ChatCompletionChunk;
|
| if (chunk.choices?.some(c => c.finish_reason != null)) sawFinishReason = true;
|
| yield chunk;
|
| } catch {
|
|
|
| }
|
| }
|
| }
|
| } finally {
|
| reader.cancel().catch(() => { });
|
| }
|
|
|
| if (!sawFinishReason) {
|
| throw new Error(`${this.name} stream ended unexpectedly (no [DONE], no finish_reason) — connection reset or truncated upstream`);
|
| }
|
| }
|
| }
|
|
|