daemon-llm.mjs
186 lines 7.0 KB
Raw
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