// Unified provider abstraction for chat and embeddings. // Used by build-oracle-index.mjs (build time) and src/pages/api/oracle.ts (runtime). const DEFAULT_OLLAMA_URL = 'http://localhost:11434'; const DEFAULT_OLLAMA_CHAT = 'qwen2.5-coder:14b'; const DEFAULT_OLLAMA_EMBED = 'nomic-embed-text'; const DEFAULT_ANTHROPIC = 'claude-haiku-4-5-20251001'; const DEFAULT_OPENAI_EMBED = 'text-embedding-3-small'; export function llmConfig() { return { provider: process.env.LLM_PROVIDER ?? 'ollama', ollamaUrl: process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_URL, ollamaChatModel: process.env.OLLAMA_CHAT_MODEL ?? DEFAULT_OLLAMA_CHAT, anthropicModel: process.env.ANTHROPIC_MODEL ?? DEFAULT_ANTHROPIC, hasAnthropicKey: !!process.env.ANTHROPIC_API_KEY, }; } export function embedConfig() { const provider = process.env.EMBED_PROVIDER ?? 'ollama'; return { provider, ollamaUrl: process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_URL, ollamaEmbedModel: process.env.OLLAMA_EMBED_MODEL ?? DEFAULT_OLLAMA_EMBED, openaiEmbedModel: process.env.OPENAI_EMBED_MODEL ?? DEFAULT_OPENAI_EMBED, hasOpenAIKey: !!process.env.OPENAI_API_KEY, }; } // --------------------------------------------------------------------------- // Embeddings // --------------------------------------------------------------------------- async function ollamaEmbedBatch(baseUrl, model, inputs) { const out = []; // Ollama /api/embeddings is single-input. Batch by looping. for (const text of inputs) { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/embeddings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, prompt: text }), }); if (!res.ok) { const body = await res.text(); throw new Error(`Ollama embeddings ${res.status}: ${body}`); } const data = await res.json(); if (!Array.isArray(data.embedding)) { throw new Error(`Ollama embeddings: unexpected response: ${JSON.stringify(data).slice(0, 200)}`); } out.push(data.embedding); } return out; } async function openaiEmbedBatch(model, inputs) { const { default: OpenAI } = await import('openai'); const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const res = await client.embeddings.create({ model, input: inputs }); return res.data.map((d) => d.embedding); } export async function embed(inputs, opts = {}) { const cfg = embedConfig(); const provider = opts.provider ?? cfg.provider; const list = Array.isArray(inputs) ? inputs : [inputs]; if (provider === 'ollama') { return ollamaEmbedBatch(cfg.ollamaUrl, cfg.ollamaEmbedModel, list); } if (provider === 'openai') { if (!cfg.hasOpenAIKey) throw new Error('OPENAI_API_KEY not set.'); return openaiEmbedBatch(cfg.openaiEmbedModel, list); } throw new Error(`Unknown EMBED_PROVIDER: ${provider}`); } // --------------------------------------------------------------------------- // Chat // --------------------------------------------------------------------------- export async function chat({ system, messages, maxTokens = 800 }) { const cfg = llmConfig(); if (cfg.provider === 'ollama') { const url = `${cfg.ollamaUrl.replace(/\/$/, '')}/api/chat`; const ollamaMessages = []; if (system) ollamaMessages.push({ role: 'system', content: system }); for (const m of messages) { if (m.role === 'user' || m.role === 'assistant') { ollamaMessages.push({ role: m.role, content: m.content }); } } const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: cfg.ollamaChatModel, messages: ollamaMessages, stream: false, options: { num_predict: maxTokens, temperature: 0.4 }, }), }); if (!res.ok) { const body = await res.text(); throw new Error(`Ollama chat ${res.status}: ${body}`); } const data = await res.json(); return { text: data.message?.content ?? '', model: cfg.ollamaChatModel, provider: 'ollama', }; } if (cfg.provider === 'anthropic') { if (!cfg.hasAnthropicKey) throw new Error('ANTHROPIC_API_KEY not set.'); const { default: Anthropic } = await import('@anthropic-ai/sdk'); const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); const apiMessages = messages .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m) => ({ role: m.role, content: m.content })); const completion = await client.messages.create({ model: cfg.anthropicModel, max_tokens: maxTokens, system, messages: apiMessages.length ? apiMessages : [{ role: 'user', content: '' }], }); const text = completion.content .filter((p) => p.type === 'text') .map((p) => p.text) .join('\n'); return { text, model: cfg.anthropicModel, provider: 'anthropic' }; } throw new Error(`Unknown LLM_PROVIDER: ${cfg.provider}`); }