daemon-llm.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day 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 for a delegated completeChat call. |
| 52 | * |
| 53 | * For the explicit `anthropic` and `ollama` daemon providers this pins |
| 54 | * `llm.provider` so completeChat routes to exactly that provider, independent of any |
| 55 | * globally-configured chat provider (`config.llm.provider` from the Hub Settings UI) — the |
| 56 | * daemon's provider choice must always win for its own passes. The model field is also |
| 57 | * overridden when `daemon.llm.model` is set. |
| 58 | * |
| 59 | * For the null/openai delegate path the provider is intentionally left unset so completeChat |
| 60 | * applies its normal precedence (env → config.llm.provider → auto-detection). |
| 61 | * |
| 62 | * @param {object} config — loadConfig() result |
| 63 | * @param {{ provider: string|null, model: string|null }} opts |
| 64 | * @returns {object} |
| 65 | */ |
| 66 | export function buildDelegateConfig(config, { provider, model }) { |
| 67 | const llmPatch = {}; |
| 68 | if (provider === 'anthropic') { |
| 69 | llmPatch.provider = 'anthropic'; |
| 70 | if (model) llmPatch.anthropic_chat_model = model; |
| 71 | } else if (provider === 'ollama') { |
| 72 | llmPatch.provider = 'ollama'; |
| 73 | if (model) llmPatch.ollama_chat_model = model; |
| 74 | } else if (model) { |
| 75 | llmPatch.openai_chat_model = model; |
| 76 | } |
| 77 | if (Object.keys(llmPatch).length === 0) return config; |
| 78 | return { ...config, llm: { ...config.llm, ...llmPatch } }; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Make a fetch call to an OpenAI Chat Completions-compatible endpoint. |
| 83 | * |
| 84 | * @param {{ |
| 85 | * baseUrl: string, |
| 86 | * apiKey: string, |
| 87 | * model: string, |
| 88 | * maxTokens: number, |
| 89 | * system: string, |
| 90 | * user: string, |
| 91 | * }} params |
| 92 | * @returns {Promise<string>} |
| 93 | */ |
| 94 | export async function callOpenAiCompat({ baseUrl, apiKey, model, maxTokens, system, user }) { |
| 95 | const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`; |
| 96 | const res = await fetch(url, { |
| 97 | method: 'POST', |
| 98 | headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, |
| 99 | body: JSON.stringify({ |
| 100 | model, |
| 101 | messages: [ |
| 102 | { role: 'system', content: system }, |
| 103 | { role: 'user', content: user }, |
| 104 | ], |
| 105 | max_tokens: maxTokens, |
| 106 | }), |
| 107 | }); |
| 108 | if (!res.ok) { |
| 109 | const body = await res.text(); |
| 110 | throw new Error(`OpenAI-compat chat failed (${url}): ${res.status} ${body}`); |
| 111 | } |
| 112 | const data = await res.json(); |
| 113 | const text = data.choices?.[0]?.message?.content; |
| 114 | if (!text) throw new Error(`OpenAI-compat chat: empty response from ${url}`); |
| 115 | return String(text).trim(); |
| 116 | } |
| 117 | |
| 118 | // ── Public API ──────────────────────────────────────────────────────────────── |
| 119 | |
| 120 | /** |
| 121 | * LLM completion function that respects daemon.llm configuration. |
| 122 | * |
| 123 | * Pass this as the `llmFn` option to consolidateMemory, runDiscoverPass, and |
| 124 | * validateLlmConnectivity when you want daemon-specific LLM routing: |
| 125 | * |
| 126 | * await consolidateMemory(config, { llmFn: daemonLlm }); |
| 127 | * |
| 128 | * Falls back transparently to completeChat (env-based auto-detection) when no |
| 129 | * daemon-specific LLM config is present. |
| 130 | * |
| 131 | * @param {object} config — loadConfig() result; daemon.llm is read from config.daemon.llm |
| 132 | * @param {{ system: string, user: string, maxTokens?: number }} opts |
| 133 | * @returns {Promise<string>} |
| 134 | */ |
| 135 | export async function daemonLlm(config, opts) { |
| 136 | const dlm = config.daemon?.llm ?? {}; |
| 137 | const provider = dlm.provider ?? null; |
| 138 | const baseUrl = dlm.base_url ?? null; |
| 139 | const apiKeyEnv = dlm.api_key_env ?? null; |
| 140 | const model = dlm.model ?? null; |
| 141 | const maxTokens = opts.maxTokens ?? dlm.max_tokens ?? 1024; |
| 142 | |
| 143 | // ── Anthropic: base_url is not applicable; warn and delegate ───────────────── |
| 144 | if (provider === 'anthropic') { |
| 145 | if (baseUrl) { |
| 146 | process.stderr.write( |
| 147 | '[daemon-llm] Warning: base_url is ignored when provider is "anthropic". ' + |
| 148 | 'Use provider: null or provider: "openai" for custom OpenAI-compatible endpoints.\n', |
| 149 | ); |
| 150 | } |
| 151 | return completeChat(buildDelegateConfig(config, { provider: 'anthropic', model }), { |
| 152 | ...opts, |
| 153 | maxTokens, |
| 154 | }); |
| 155 | } |
| 156 | |
| 157 | // ── Ollama: delegate to completeChat (uses OLLAMA_URL / OLLAMA_CHAT_MODEL) ─── |
| 158 | if (provider === 'ollama') { |
| 159 | return completeChat(buildDelegateConfig(config, { provider: 'ollama', model }), { |
| 160 | ...opts, |
| 161 | maxTokens, |
| 162 | }); |
| 163 | } |
| 164 | |
| 165 | // ── OpenAI-compat: base_url set, or provider: "openai", or api_key_env set ─── |
| 166 | // |
| 167 | // provider: null + base_url → OpenAI-compat endpoint (OpenRouter, vLLM, LM Studio…) |
| 168 | // provider: "openai" + base_url → same |
| 169 | // provider: "openai" (no base_url) → standard OpenAI URL with optional api_key_env |
| 170 | // api_key_env set (no base_url) → standard OpenAI URL with custom key var |
| 171 | if (baseUrl || provider === 'openai' || apiKeyEnv) { |
| 172 | const effectiveBaseUrl = baseUrl ?? OPENAI_DEFAULT_BASE_URL; |
| 173 | const apiKey = resolveApiKey(apiKeyEnv); |
| 174 | if (!apiKey) { |
| 175 | const envVarName = apiKeyEnv ?? 'OPENAI_API_KEY'; |
| 176 | throw new Error( |
| 177 | `daemon-llm: API key not found. Set the "${envVarName}" environment variable.`, |
| 178 | ); |
| 179 | } |
| 180 | return callOpenAiCompat({ |
| 181 | baseUrl: effectiveBaseUrl, |
| 182 | apiKey, |
| 183 | model: model || 'gpt-4o-mini', |
| 184 | maxTokens, |
| 185 | system: opts.system, |
| 186 | user: opts.user, |
| 187 | }); |
| 188 | } |
| 189 | |
| 190 | // ── No daemon-specific config: fall through to completeChat (env auto-detect) ─ |
| 191 | return completeChat(buildDelegateConfig(config, { provider: null, model }), { |
| 192 | ...opts, |
| 193 | maxTokens, |
| 194 | }); |
| 195 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago