daemon-llm.mjs
sha256:6a102aafafdfe7e70a24f4e59740200f0ee713ce7915f1b53e9d4ba5ee8b4410
Initial Muse snapshot
Human
49 days ago
| 1 | /** |
| 2 | * Daemon LLM wrapper — Phase E of the Daemon Consolidation Spec. |
| 3 | * |
| 4 | * Resolves daemon.llm configuration and routes to the correct provider/endpoint, |
| 5 | * delegating to completeChat for the anthropic and ollama provider paths. |
| 6 | * |
| 7 | * Supported configurations via config.daemon.llm: |
| 8 | * |
| 9 | * provider | base_url set? | behaviour |
| 10 | * ----------|---------------|-------------------------------------------------- |
| 11 | * null | yes | OpenAI-compat fetch to base_url (OpenRouter, vLLM, LM Studio…) |
| 12 | * "openai" | yes/no | OpenAI-compat fetch (base_url or api.openai.com) |
| 13 | * "anthropic"| ignored | delegates to completeChat (uses ANTHROPIC_API_KEY); warns if base_url set |
| 14 | * "ollama" | ignored | delegates to completeChat (uses OLLAMA_URL/OLLAMA_CHAT_MODEL) |
| 15 | * null | no | delegates to completeChat (auto-detects from env) |
| 16 | * |
| 17 | * API key resolution for the OpenAI-compat path: |
| 18 | * - If daemon.llm.api_key_env is set → process.env[api_key_env] |
| 19 | * - Otherwise → process.env.OPENAI_API_KEY |
| 20 | * |
| 21 | * Config keys (all under config.daemon.llm): |
| 22 | * provider — openai | anthropic | ollama | null |
| 23 | * base_url — custom endpoint (env override: KNOWTATION_DAEMON_LLM_BASE_URL) |
| 24 | * api_key_env — name of the env var holding the API key (e.g. "OPENROUTER_API_KEY") |
| 25 | * model — model name override |
| 26 | * max_tokens — per-call token limit (default: 1024) |
| 27 | */ |
| 28 | |
| 29 | import { completeChat } from './llm-complete.mjs'; |
| 30 | |
| 31 | const OPENAI_DEFAULT_BASE_URL = 'https://api.openai.com/v1'; |
| 32 | |
| 33 | // ── Internal helpers ────────────────────────────────────────────────────────── |
| 34 | |
| 35 | /** |
| 36 | * Resolve the API key for the OpenAI-compat path. |
| 37 | * When api_key_env is provided, reads from that named env var. |
| 38 | * Otherwise falls back to OPENAI_API_KEY. |
| 39 | * |
| 40 | * @param {string|null} apiKeyEnv — env var name, e.g. "OPENROUTER_API_KEY" |
| 41 | * @returns {string|null} |
| 42 | */ |
| 43 | export function resolveApiKey(apiKeyEnv) { |
| 44 | if (apiKeyEnv && typeof apiKeyEnv === 'string') { |
| 45 | return process.env[apiKeyEnv] ?? null; |
| 46 | } |
| 47 | return process.env.OPENAI_API_KEY ?? null; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Build a merged config that overrides the model field used by completeChat |
| 52 | * for the specified provider. Used when delegating to completeChat so that |
| 53 | * daemon.llm.model is honoured. |
| 54 | * |
| 55 | * @param {object} config — loadConfig() result |
| 56 | * @param {{ provider: string|null, model: string|null }} opts |
| 57 | * @returns {object} |
| 58 | */ |
| 59 | export function buildDelegateConfig(config, { provider, model }) { |
| 60 | if (!model) return config; |
| 61 | const llmPatch = {}; |
| 62 | if (provider === 'anthropic') { |
| 63 | llmPatch.anthropic_chat_model = model; |
| 64 | } else if (provider === 'ollama') { |
| 65 | llmPatch.ollama_chat_model = model; |
| 66 | } else { |
| 67 | llmPatch.openai_chat_model = model; |
| 68 | } |
| 69 | return { ...config, llm: { ...config.llm, ...llmPatch } }; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Make a fetch call to an OpenAI Chat Completions-compatible endpoint. |
| 74 | * |
| 75 | * @param {{ |
| 76 | * baseUrl: string, |
| 77 | * apiKey: string, |
| 78 | * model: string, |
| 79 | * maxTokens: number, |
| 80 | * system: string, |
| 81 | * user: string, |
| 82 | * }} params |
| 83 | * @returns {Promise<string>} |
| 84 | */ |
| 85 | export async function callOpenAiCompat({ baseUrl, apiKey, model, maxTokens, system, user }) { |
| 86 | const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`; |
| 87 | const res = await fetch(url, { |
| 88 | method: 'POST', |
| 89 | headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, |
| 90 | body: JSON.stringify({ |
| 91 | model, |
| 92 | messages: [ |
| 93 | { role: 'system', content: system }, |
| 94 | { role: 'user', content: user }, |
| 95 | ], |
| 96 | max_tokens: maxTokens, |
| 97 | }), |
| 98 | }); |
| 99 | if (!res.ok) { |
| 100 | const body = await res.text(); |
| 101 | throw new Error(`OpenAI-compat chat failed (${url}): ${res.status} ${body}`); |
| 102 | } |
| 103 | const data = await res.json(); |
| 104 | const text = data.choices?.[0]?.message?.content; |
| 105 | if (!text) throw new Error(`OpenAI-compat chat: empty response from ${url}`); |
| 106 | return String(text).trim(); |
| 107 | } |
| 108 | |
| 109 | // ── Public API ──────────────────────────────────────────────────────────────── |
| 110 | |
| 111 | /** |
| 112 | * LLM completion function that respects daemon.llm configuration. |
| 113 | * |
| 114 | * Pass this as the `llmFn` option to consolidateMemory, runDiscoverPass, and |
| 115 | * validateLlmConnectivity when you want daemon-specific LLM routing: |
| 116 | * |
| 117 | * await consolidateMemory(config, { llmFn: daemonLlm }); |
| 118 | * |
| 119 | * Falls back transparently to completeChat (env-based auto-detection) when no |
| 120 | * daemon-specific LLM config is present. |
| 121 | * |
| 122 | * @param {object} config — loadConfig() result; daemon.llm is read from config.daemon.llm |
| 123 | * @param {{ system: string, user: string, maxTokens?: number }} opts |
| 124 | * @returns {Promise<string>} |
| 125 | */ |
| 126 | export async function daemonLlm(config, opts) { |
| 127 | const dlm = config.daemon?.llm ?? {}; |
| 128 | const provider = dlm.provider ?? null; |
| 129 | const baseUrl = dlm.base_url ?? null; |
| 130 | const apiKeyEnv = dlm.api_key_env ?? null; |
| 131 | const model = dlm.model ?? null; |
| 132 | const maxTokens = opts.maxTokens ?? dlm.max_tokens ?? 1024; |
| 133 | |
| 134 | // ── Anthropic: base_url is not applicable; warn and delegate ───────────────── |
| 135 | if (provider === 'anthropic') { |
| 136 | if (baseUrl) { |
| 137 | process.stderr.write( |
| 138 | '[daemon-llm] Warning: base_url is ignored when provider is "anthropic". ' + |
| 139 | 'Use provider: null or provider: "openai" for custom OpenAI-compatible endpoints.\n', |
| 140 | ); |
| 141 | } |
| 142 | return completeChat(buildDelegateConfig(config, { provider: 'anthropic', model }), { |
| 143 | ...opts, |
| 144 | maxTokens, |
| 145 | }); |
| 146 | } |
| 147 | |
| 148 | // ── Ollama: delegate to completeChat (uses OLLAMA_URL / OLLAMA_CHAT_MODEL) ─── |
| 149 | if (provider === 'ollama') { |
| 150 | return completeChat(buildDelegateConfig(config, { provider: 'ollama', model }), { |
| 151 | ...opts, |
| 152 | maxTokens, |
| 153 | }); |
| 154 | } |
| 155 | |
| 156 | // ── OpenAI-compat: base_url set, or provider: "openai", or api_key_env set ─── |
| 157 | // |
| 158 | // provider: null + base_url → OpenAI-compat endpoint (OpenRouter, vLLM, LM Studio…) |
| 159 | // provider: "openai" + base_url → same |
| 160 | // provider: "openai" (no base_url) → standard OpenAI URL with optional api_key_env |
| 161 | // api_key_env set (no base_url) → standard OpenAI URL with custom key var |
| 162 | if (baseUrl || provider === 'openai' || apiKeyEnv) { |
| 163 | const effectiveBaseUrl = baseUrl ?? OPENAI_DEFAULT_BASE_URL; |
| 164 | const apiKey = resolveApiKey(apiKeyEnv); |
| 165 | if (!apiKey) { |
| 166 | const envVarName = apiKeyEnv ?? 'OPENAI_API_KEY'; |
| 167 | throw new Error( |
| 168 | `daemon-llm: API key not found. Set the "${envVarName}" environment variable.`, |
| 169 | ); |
| 170 | } |
| 171 | return callOpenAiCompat({ |
| 172 | baseUrl: effectiveBaseUrl, |
| 173 | apiKey, |
| 174 | model: model || 'gpt-4o-mini', |
| 175 | maxTokens, |
| 176 | system: opts.system, |
| 177 | user: opts.user, |
| 178 | }); |
| 179 | } |
| 180 | |
| 181 | // ── No daemon-specific config: fall through to completeChat (env auto-detect) ─ |
| 182 | return completeChat(buildDelegateConfig(config, { provider: null, model }), { |
| 183 | ...opts, |
| 184 | maxTokens, |
| 185 | }); |
| 186 | } |
File History
1 commit
sha256:6a102aafafdfe7e70a24f4e59740200f0ee713ce7915f1b53e9d4ba5ee8b4410
Initial Muse snapshot
Human
49 days ago