/* ========================================================================= llm/providers.js — minimal LLM chat providers over bare fetch. UMD: window.PROVIDERS in the browser, module.exports under node. NO deps. Browser notes: anthropic works cross-origin via the explicit anthropic-dangerous-direct-browser-access header (key stays user-side); ollama needs OLLAMA_ORIGINS to allow the page origin; openai blocks browser CORS, so it is node-only. ========================================================================= */ (function (root, factory) { if (typeof module !== 'undefined' && module.exports) module.exports = factory(); else root.PROVIDERS = factory(); })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; function makeProvider(cfg) { const f = cfg.fetchFn || fetch; const post = async (url, headers, body) => { const res = await f(url, { method: 'POST', headers: Object.assign({ 'content-type': 'application/json' }, headers), body: JSON.stringify(body), }); if (!res.ok) { const errBody = await res.text().catch(() => ''); throw new Error(cfg.provider + ' HTTP ' + res.status + ': ' + errBody); } return res.json(); }; const postRaw = async (url, headers, body) => { const res = await f(url, { method: 'POST', headers: Object.assign({ 'content-type': 'application/json' }, headers), body: JSON.stringify(body), }); if (!res.ok) { const errBody = await res.text().catch(() => ''); throw new Error(cfg.provider + ' HTTP ' + res.status + ': ' + errBody); } return res; }; const readJsonLines = async (res, onJson) => { if (!res.body || !res.body.getReader) throw new Error(cfg.provider + ': streaming response body is unavailable'); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; for (;;) { const chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); const lines = buf.split(/\r?\n/); buf = lines.pop(); for (const line of lines) { const s = line.trim(); if (s) onJson(JSON.parse(s)); } } buf += decoder.decode(); if (buf.trim()) onJson(JSON.parse(buf)); }; if (cfg.provider === 'anthropic') return { async completeDetailed(prompt) { const data = await post('https://api.anthropic.com/v1/messages', { 'x-api-key': cfg.apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true', }, { model: cfg.model, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }); if (!data || !Array.isArray(data.content)) throw new Error('anthropic: unexpected response shape: ' + JSON.stringify(data)); const text = data.content.filter(b => b.type === 'text').map(b => b.text).join('\n'); if (!text) throw new Error('anthropic: no text content in response'); return { content: text, thinking: '' }; }, async complete(prompt) { return (await this.completeDetailed(prompt)).content; }, }; if (cfg.provider === 'openai') return { async completeDetailed(prompt) { const data = await post('https://api.openai.com/v1/chat/completions', { authorization: 'Bearer ' + cfg.apiKey, }, { model: cfg.model, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }); const msg = data && data.choices && data.choices[0] && data.choices[0].message; if (!msg || msg.content == null) throw new Error('openai: unexpected response shape: ' + JSON.stringify(data)); return { content: msg.content, thinking: '' }; }, async complete(prompt) { return (await this.completeDetailed(prompt)).content; }, }; const ollamaModel = () => (cfg.cloud && !/-cloud$/.test(cfg.model)) ? cfg.model + '-cloud' : cfg.model; if (cfg.provider === 'ollama') return { async completeDetailed(prompt) { const base = (cfg.baseUrl || 'http://127.0.0.1:11434').replace(/\/$/, ''); // cfg.cloud: run an Ollama cloud model (e.g. gpt-oss:120b) through the LOCAL // signed-in daemon, which routes the '-cloud'-tagged model to Ollama's cloud. // Endpoint/auth stay local (no key, no CORS) — only the model name changes. const data = await post(base + '/api/chat', {}, { model: ollamaModel(), stream: false, messages: [{ role: 'user', content: prompt }] }); if (!data || !data.message || data.message.content == null) throw new Error('ollama: unexpected response shape: ' + JSON.stringify(data)); return { content: data.message.content, thinking: data.message.thinking || '' }; }, async complete(prompt) { return (await this.completeDetailed(prompt)).content; }, async completeStream(prompt, hooks) { hooks = hooks || {}; const base = (cfg.baseUrl || 'http://127.0.0.1:11434').replace(/\/$/, ''); const res = await postRaw(base + '/api/chat', {}, { model: ollamaModel(), stream: true, messages: [{ role: 'user', content: prompt }] }); const out = { content: '', thinking: '' }; await readJsonLines(res, (data) => { const msg = data && data.message || {}; const thinking = msg.thinking || ''; const content = msg.content || ''; if (thinking) { out.thinking += thinking; if (hooks.onThinking) hooks.onThinking(thinking, out); } if (content) { out.content += content; if (hooks.onContent) hooks.onContent(content, out); } if (hooks.onChunk) hooks.onChunk(data, out); }); if (hooks.onDone) hooks.onDone(out); return out; }, }; throw new Error('unknown provider: ' + cfg.provider); } return { makeProvider }; });