import { AI_API_URL } from '@/config/setting'; export type OpenAIRole = 'system' | 'user' | 'assistant' | 'tool'; export interface OpenAIChatMessage { role: OpenAIRole; content: string; name?: string; } export interface OpenAIModel { id: string; object: string; created?: number; owned_by?: string; name?: string; } export interface OpenAIListModelsResponse { data: OpenAIModel[]; } export interface OpenAIChatCompletionRequest { model: string; messages: OpenAIChatMessage[]; temperature?: number; top_p?: number; max_tokens?: number; stream?: boolean; } export interface OpenAIChatCompletionChoice { index: number; message?: { role: OpenAIRole; content: string }; delta?: { role?: OpenAIRole; content?: string }; finish_reason?: string | null; } export interface OpenAIChatCompletionResponse { id?: string; object?: string; created?: number; model?: string; choices: OpenAIChatCompletionChoice[]; } function getHeaders(apiKey?: string) { const headers: Record = { 'Content-Type': 'application/json' }; if (apiKey) { const trimmed = apiKey.trim(); // Accept either raw token or "Bearer xxx". headers.Authorization = /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; } return headers; } function normalizeBaseURL(baseURL: string) { return baseURL.replace(/\/+$/, ''); } export async function listModels(opts?: { apiKey?: string; baseURL?: string }) { const baseURL = normalizeBaseURL(opts?.baseURL ?? AI_API_URL); const res = await fetch(`${baseURL}/models`, { method: 'GET', headers: getHeaders(opts?.apiKey) }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `listModels failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` ); } return (await res.json()) as OpenAIListModelsResponse; } export async function chatCompletions( body: OpenAIChatCompletionRequest, opts?: { apiKey?: string; baseURL?: string; signal?: AbortSignal } ) { const baseURL = normalizeBaseURL(opts?.baseURL ?? AI_API_URL); const res = await fetch(`${baseURL}/chat/completions`, { method: 'POST', headers: getHeaders(opts?.apiKey), body: JSON.stringify(body), signal: opts?.signal }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `chatCompletions failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` ); } return (await res.json()) as OpenAIChatCompletionResponse; } /** * Stream OpenAI-compatible SSE (`stream: true`) and emit incremental tokens. * Most gateways (Open-WebUI / LiteLLM / Ollama OpenAI proxy) follow: * data: { choices: [{ delta: { content: "..." } }] } * data: [DONE] */ export async function chatCompletionsStream( body: Omit, opts: { apiKey?: string; baseURL?: string; signal?: AbortSignal; onDelta: (text: string) => void; onDone?: () => void; } ) { const baseURL = normalizeBaseURL(opts.baseURL ?? AI_API_URL); const res = await fetch(`${baseURL}/chat/completions`, { method: 'POST', headers: getHeaders(opts.apiKey), body: JSON.stringify({ ...body, stream: true }), signal: opts.signal }); if (!res.ok || !res.body) { const text = await res.text().catch(() => ''); throw new Error( `chatCompletionsStream failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` ); } const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE events are separated by blank lines. let idx = buffer.indexOf('\n\n'); while (idx !== -1) { const rawEvent = buffer.slice(0, idx); buffer = buffer.slice(idx + 2); idx = buffer.indexOf('\n\n'); const lines = rawEvent .split('\n') .map((l) => l.trim()) .filter(Boolean); for (const line of lines) { if (!line.startsWith('data:')) continue; const data = line.slice(5).trim(); if (!data) continue; if (data === '[DONE]') { opts.onDone?.(); return; } try { const json = JSON.parse(data) as OpenAIChatCompletionResponse; const delta = json.choices?.[0]?.delta?.content; if (delta) { opts.onDelta(delta); } } catch { // Ignore malformed chunks } } } } opts.onDone?.(); }