daemon-llm.mjs
195 lines 7.5 KB
Raw
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