| import type {
|
| ChatMessage,
|
| ChatCompletionResponse,
|
| ChatCompletionChunk,
|
| Platform,
|
| } from '@freellmapi/shared/types.js';
|
| import { BaseProvider, type CompletionOptions } from './base.js';
|
|
|
| |
| |
| |
| |
|
|
| export class OpenAICompatProvider extends BaseProvider {
|
| readonly platform: Platform;
|
| readonly name: string;
|
| private readonly baseUrl: string;
|
| private readonly extraHeaders: Record<string, string>;
|
| private readonly validateUrl?: string;
|
| |
|
|
| private readonly timeoutMs: number;
|
|
|
| constructor(opts: {
|
| platform: Platform;
|
| name: string;
|
| baseUrl: string;
|
| extraHeaders?: Record<string, string>;
|
| validateUrl?: string;
|
| timeoutMs?: number;
|
| keyless?: boolean;
|
| }) {
|
| super();
|
| this.platform = opts.platform;
|
| this.name = opts.name;
|
| this.baseUrl = opts.baseUrl;
|
| this.extraHeaders = opts.extraHeaders ?? {};
|
| this.validateUrl = opts.validateUrl;
|
| this.timeoutMs = opts.timeoutMs ?? 15000;
|
| this.keyless = opts.keyless ?? false;
|
| }
|
|
|
| |
| |
|
|
| private authHeader(apiKey: string): Record<string, string> {
|
| return this.keyless ? {} : { 'Authorization': `Bearer ${apiKey}` };
|
| }
|
|
|
| async chatCompletion(
|
| apiKey: string,
|
| messages: ChatMessage[],
|
| modelId: string,
|
| options?: CompletionOptions,
|
| ): Promise<ChatCompletionResponse> {
|
| const res = await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`, {
|
| method: 'POST',
|
| headers: {
|
| ...this.authHeader(apiKey),
|
| 'Content-Type': 'application/json',
|
| ...this.extraHeaders,
|
| },
|
| body: JSON.stringify({
|
| model: modelId,
|
| messages,
|
| temperature: options?.temperature,
|
| max_tokens: options?.max_tokens,
|
| top_p: options?.top_p,
|
| tools: options?.tools,
|
| tool_choice: options?.tool_choice,
|
| parallel_tool_calls: options?.parallel_tool_calls,
|
| }),
|
| }, this.timeoutMs);
|
|
|
| if (!res.ok) {
|
| const err = await res.json().catch(() => ({}));
|
| throw new Error(`${this.name} API error ${res.status}: ${(err as any).error?.message ?? res.statusText}`);
|
| }
|
|
|
| let data: ChatCompletionResponse;
|
| try {
|
| data = await res.json() as ChatCompletionResponse;
|
| } catch {
|
|
|
|
|
|
|
|
|
| throw new Error(
|
| `${this.name} returned 200 with a non-JSON body — the endpoint is not OpenAI-compatible. ` +
|
| `Check the base URL (for Ollama use http://host:11434/v1, for llama.cpp/vLLM/LM Studio the /v1 path).`,
|
| );
|
| }
|
| normalizeChoices(data);
|
| data._routed_via = { platform: this.platform, model: modelId };
|
| return data;
|
| }
|
|
|
| async *streamChatCompletion(
|
| apiKey: string,
|
| messages: ChatMessage[],
|
| modelId: string,
|
| options?: CompletionOptions,
|
| ): AsyncGenerator<ChatCompletionChunk> {
|
| const res = await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`, {
|
| method: 'POST',
|
| headers: {
|
| ...this.authHeader(apiKey),
|
| 'Content-Type': 'application/json',
|
| ...this.extraHeaders,
|
| },
|
| body: JSON.stringify({
|
| model: modelId,
|
| messages,
|
| temperature: options?.temperature,
|
| max_tokens: options?.max_tokens,
|
| top_p: options?.top_p,
|
| tools: options?.tools,
|
| tool_choice: options?.tool_choice,
|
| parallel_tool_calls: options?.parallel_tool_calls,
|
| stream: true,
|
| }),
|
| }, this.timeoutMs);
|
|
|
| if (!res.ok) {
|
| const err = await res.json().catch(() => ({}));
|
| throw new Error(`${this.name} API error ${res.status}: ${(err as any).error?.message ?? res.statusText}`);
|
| }
|
|
|
| yield* this.readSseStream(res);
|
| }
|
|
|
| async validateKey(apiKey: string): Promise<boolean> {
|
|
|
|
|
|
|
| const url = this.validateUrl ?? `${this.baseUrl}/models`;
|
|
|
|
|
|
|
|
|
|
|
| const res = await this.fetchWithTimeout(url, {
|
| method: 'GET',
|
| headers: {
|
| ...this.authHeader(apiKey),
|
| ...this.extraHeaders,
|
| },
|
| }, 30000);
|
| return res.status !== 401 && res.status !== 403;
|
| }
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function normalizeChoices(data: ChatCompletionResponse): void {
|
| for (const choice of data.choices ?? []) {
|
| const msg = choice.message as ChatMessage & {
|
| reasoning_content?: string;
|
| reasoning?: string;
|
| content: unknown;
|
| };
|
|
|
| if (Array.isArray(msg.content)) {
|
| msg.content = (msg.content as Array<{ text?: string; type?: string }>)
|
| .map(seg => (typeof seg === 'string' ? seg : (seg.text ?? '')))
|
| .join('');
|
| }
|
|
|
|
|
|
|
|
|
|
|
| const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
|
| if (!hasToolCalls && (msg.content === '' || msg.content == null)) {
|
| const fold = (typeof msg.reasoning_content === 'string' && msg.reasoning_content.length > 0)
|
| ? msg.reasoning_content
|
| : (typeof msg.reasoning === 'string' && msg.reasoning.length > 0 ? msg.reasoning : null);
|
| if (fold !== null) msg.content = fold;
|
| }
|
| }
|
| }
|
|
|