@johpaz/hive 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CONTRIBUTING.md +44 -0
  2. package/README.md +310 -0
  3. package/package.json +96 -0
  4. package/packages/cli/package.json +28 -0
  5. package/packages/cli/src/commands/agent-run.ts +168 -0
  6. package/packages/cli/src/commands/agents.ts +398 -0
  7. package/packages/cli/src/commands/chat.ts +142 -0
  8. package/packages/cli/src/commands/config.ts +50 -0
  9. package/packages/cli/src/commands/cron.ts +161 -0
  10. package/packages/cli/src/commands/dev.ts +95 -0
  11. package/packages/cli/src/commands/doctor.ts +133 -0
  12. package/packages/cli/src/commands/gateway.ts +443 -0
  13. package/packages/cli/src/commands/logs.ts +57 -0
  14. package/packages/cli/src/commands/mcp.ts +175 -0
  15. package/packages/cli/src/commands/message.ts +77 -0
  16. package/packages/cli/src/commands/onboard.ts +1868 -0
  17. package/packages/cli/src/commands/security.ts +144 -0
  18. package/packages/cli/src/commands/service.ts +50 -0
  19. package/packages/cli/src/commands/sessions.ts +116 -0
  20. package/packages/cli/src/commands/skills.ts +187 -0
  21. package/packages/cli/src/commands/update.ts +25 -0
  22. package/packages/cli/src/index.ts +185 -0
  23. package/packages/cli/src/utils/token.ts +6 -0
  24. package/packages/code-bridge/README.md +78 -0
  25. package/packages/code-bridge/package.json +18 -0
  26. package/packages/code-bridge/src/index.ts +95 -0
  27. package/packages/code-bridge/src/process-manager.ts +212 -0
  28. package/packages/code-bridge/src/schemas.ts +133 -0
  29. package/packages/core/package.json +46 -0
  30. package/packages/core/src/agent/agent-loop.ts +369 -0
  31. package/packages/core/src/agent/compaction.ts +140 -0
  32. package/packages/core/src/agent/context-compiler.ts +378 -0
  33. package/packages/core/src/agent/context-guard.ts +91 -0
  34. package/packages/core/src/agent/context.ts +138 -0
  35. package/packages/core/src/agent/conversation-store.ts +198 -0
  36. package/packages/core/src/agent/curator.ts +158 -0
  37. package/packages/core/src/agent/hooks.ts +166 -0
  38. package/packages/core/src/agent/index.ts +116 -0
  39. package/packages/core/src/agent/llm-client.ts +503 -0
  40. package/packages/core/src/agent/native-tools.ts +505 -0
  41. package/packages/core/src/agent/prompt-builder.ts +532 -0
  42. package/packages/core/src/agent/providers/index.ts +167 -0
  43. package/packages/core/src/agent/providers.ts +1 -0
  44. package/packages/core/src/agent/reflector.ts +170 -0
  45. package/packages/core/src/agent/service.ts +64 -0
  46. package/packages/core/src/agent/stuck-loop.ts +133 -0
  47. package/packages/core/src/agent/supervisor.ts +39 -0
  48. package/packages/core/src/agent/tracer.ts +102 -0
  49. package/packages/core/src/agent/workspace.ts +110 -0
  50. package/packages/core/src/canvas/canvas-manager.test.ts +161 -0
  51. package/packages/core/src/canvas/canvas-manager.ts +319 -0
  52. package/packages/core/src/canvas/canvas-tools.ts +420 -0
  53. package/packages/core/src/canvas/emitter.ts +115 -0
  54. package/packages/core/src/canvas/index.ts +2 -0
  55. package/packages/core/src/channels/base.ts +138 -0
  56. package/packages/core/src/channels/discord.ts +260 -0
  57. package/packages/core/src/channels/index.ts +7 -0
  58. package/packages/core/src/channels/manager.ts +383 -0
  59. package/packages/core/src/channels/slack.ts +287 -0
  60. package/packages/core/src/channels/telegram.ts +502 -0
  61. package/packages/core/src/channels/webchat.ts +128 -0
  62. package/packages/core/src/channels/whatsapp.ts +375 -0
  63. package/packages/core/src/config/index.ts +12 -0
  64. package/packages/core/src/config/loader.ts +529 -0
  65. package/packages/core/src/events/event-bus.ts +169 -0
  66. package/packages/core/src/gateway/index.ts +5 -0
  67. package/packages/core/src/gateway/initializer.ts +290 -0
  68. package/packages/core/src/gateway/lane-queue.ts +169 -0
  69. package/packages/core/src/gateway/resolver.ts +108 -0
  70. package/packages/core/src/gateway/router.ts +124 -0
  71. package/packages/core/src/gateway/server.ts +3317 -0
  72. package/packages/core/src/gateway/session.ts +95 -0
  73. package/packages/core/src/gateway/slash-commands.ts +192 -0
  74. package/packages/core/src/heartbeat/index.ts +157 -0
  75. package/packages/core/src/index.ts +19 -0
  76. package/packages/core/src/integrations/catalog.ts +286 -0
  77. package/packages/core/src/integrations/env.ts +64 -0
  78. package/packages/core/src/integrations/index.ts +2 -0
  79. package/packages/core/src/memory/index.ts +1 -0
  80. package/packages/core/src/memory/notes.ts +68 -0
  81. package/packages/core/src/plugins/api.ts +128 -0
  82. package/packages/core/src/plugins/index.ts +2 -0
  83. package/packages/core/src/plugins/loader.ts +365 -0
  84. package/packages/core/src/resilience/circuit-breaker.ts +225 -0
  85. package/packages/core/src/security/google-chat.ts +269 -0
  86. package/packages/core/src/security/index.ts +192 -0
  87. package/packages/core/src/security/pairing.ts +250 -0
  88. package/packages/core/src/security/rate-limit.ts +270 -0
  89. package/packages/core/src/security/signal.ts +321 -0
  90. package/packages/core/src/state/store.ts +312 -0
  91. package/packages/core/src/storage/bun-sqlite-store.ts +188 -0
  92. package/packages/core/src/storage/crypto.ts +101 -0
  93. package/packages/core/src/storage/db-context.ts +333 -0
  94. package/packages/core/src/storage/onboarding.ts +1087 -0
  95. package/packages/core/src/storage/schema.ts +541 -0
  96. package/packages/core/src/storage/seed.ts +571 -0
  97. package/packages/core/src/storage/sqlite.ts +387 -0
  98. package/packages/core/src/storage/usage.ts +212 -0
  99. package/packages/core/src/tools/bridge-events.ts +74 -0
  100. package/packages/core/src/tools/browser.ts +275 -0
  101. package/packages/core/src/tools/codebridge.ts +421 -0
  102. package/packages/core/src/tools/coordinator-tools.ts +179 -0
  103. package/packages/core/src/tools/cron.ts +611 -0
  104. package/packages/core/src/tools/exec.ts +140 -0
  105. package/packages/core/src/tools/fs.ts +364 -0
  106. package/packages/core/src/tools/index.ts +12 -0
  107. package/packages/core/src/tools/memory.ts +176 -0
  108. package/packages/core/src/tools/notify.ts +113 -0
  109. package/packages/core/src/tools/project-management.ts +376 -0
  110. package/packages/core/src/tools/project.ts +375 -0
  111. package/packages/core/src/tools/read.ts +158 -0
  112. package/packages/core/src/tools/web.ts +436 -0
  113. package/packages/core/src/tools/workspace.ts +171 -0
  114. package/packages/core/src/utils/benchmark.ts +80 -0
  115. package/packages/core/src/utils/crypto.ts +73 -0
  116. package/packages/core/src/utils/date.ts +42 -0
  117. package/packages/core/src/utils/index.ts +4 -0
  118. package/packages/core/src/utils/logger.ts +388 -0
  119. package/packages/core/src/utils/retry.ts +70 -0
  120. package/packages/core/src/voice/index.ts +583 -0
  121. package/packages/core/tsconfig.json +9 -0
  122. package/packages/mcp/package.json +26 -0
  123. package/packages/mcp/src/config.ts +13 -0
  124. package/packages/mcp/src/index.ts +1 -0
  125. package/packages/mcp/src/logger.ts +42 -0
  126. package/packages/mcp/src/manager.ts +434 -0
  127. package/packages/mcp/src/transports/index.ts +67 -0
  128. package/packages/mcp/src/transports/sse.ts +241 -0
  129. package/packages/mcp/src/transports/websocket.ts +159 -0
  130. package/packages/skills/package.json +21 -0
  131. package/packages/skills/src/bundled/agent_management/SKILL.md +24 -0
  132. package/packages/skills/src/bundled/browser_automation/SKILL.md +30 -0
  133. package/packages/skills/src/bundled/context_compact/SKILL.md +35 -0
  134. package/packages/skills/src/bundled/cron_manager/SKILL.md +52 -0
  135. package/packages/skills/src/bundled/file_manager/SKILL.md +76 -0
  136. package/packages/skills/src/bundled/http_client/SKILL.md +24 -0
  137. package/packages/skills/src/bundled/memory/SKILL.md +42 -0
  138. package/packages/skills/src/bundled/project_management/SKILL.md +26 -0
  139. package/packages/skills/src/bundled/shell/SKILL.md +43 -0
  140. package/packages/skills/src/bundled/system_notify/SKILL.md +52 -0
  141. package/packages/skills/src/bundled/voice/SKILL.md +25 -0
  142. package/packages/skills/src/bundled/web_search/SKILL.md +29 -0
  143. package/packages/skills/src/index.ts +1 -0
  144. package/packages/skills/src/loader.ts +282 -0
  145. package/packages/tools/package.json +43 -0
  146. package/packages/tools/src/browser/browser.test.ts +111 -0
  147. package/packages/tools/src/browser/index.ts +272 -0
  148. package/packages/tools/src/canvas/index.ts +220 -0
  149. package/packages/tools/src/cron/cron.test.ts +164 -0
  150. package/packages/tools/src/cron/index.ts +304 -0
  151. package/packages/tools/src/filesystem/filesystem.test.ts +240 -0
  152. package/packages/tools/src/filesystem/index.ts +379 -0
  153. package/packages/tools/src/git/index.ts +239 -0
  154. package/packages/tools/src/index.ts +4 -0
  155. package/packages/tools/src/shell/detect-env.ts +70 -0
  156. package/packages/tools/tsconfig.json +9 -0
@@ -0,0 +1,503 @@
1
+ /**
2
+ * LLM client — direct official SDKs, no abstraction layers.
3
+ *
4
+ * gemini / google → native Gemini REST API (v1beta, ?key=)
5
+ * anthropic → @anthropic-ai/sdk
6
+ * everything else → openai npm package (OpenAI-compatible endpoint)
7
+ *
8
+ * Public interface (LLMMessage, callLLM, resolveProviderConfig) is stable.
9
+ */
10
+
11
+ import { logger } from "../utils/logger"
12
+
13
+ const log = logger.child("llm-client")
14
+
15
+ // ─── Canonical types ───────────────────────────────────────────────────────────
16
+
17
+ export interface LLMToolCall {
18
+ id: string
19
+ type: "function"
20
+ function: { name: string; arguments: string }
21
+ /** Gemini 3.x thought signature — must be round-tripped for tool-calling. */
22
+ thought_signature?: string
23
+ }
24
+
25
+ export interface LLMMessage {
26
+ role: "system" | "user" | "assistant" | "tool"
27
+ content: string
28
+ tool_calls?: LLMToolCall[]
29
+ tool_call_id?: string
30
+ name?: string
31
+ }
32
+
33
+ export interface LLMToolDef {
34
+ type: "function"
35
+ function: {
36
+ name: string
37
+ description: string
38
+ parameters: Record<string, unknown>
39
+ }
40
+ }
41
+
42
+ export interface LLMCallOptions {
43
+ provider: string
44
+ model: string
45
+ apiKey: string
46
+ baseUrl?: string
47
+ numCtx?: number
48
+ messages: LLMMessage[]
49
+ tools?: LLMToolDef[]
50
+ temperature?: number
51
+ maxTokens?: number
52
+ numGpu?: number
53
+ onToken?: (token: string) => void
54
+ }
55
+
56
+ export interface LLMResponse {
57
+ content: string
58
+ tool_calls?: LLMToolCall[]
59
+ stop_reason: "stop" | "tool_calls" | "max_tokens" | "error"
60
+ usage?: { input_tokens: number; output_tokens: number }
61
+ }
62
+
63
+ // ─── Provider routing ──────────────────────────────────────────────────────────
64
+
65
+ const GEMINI_PROVIDERS = new Set(["gemini", "google"])
66
+
67
+ const OPENAI_COMPAT_BASE_URLS: Record<string, string> = {
68
+ groq: "https://api.groq.com/openai/v1",
69
+ mistral: "https://api.mistral.ai/v1",
70
+ openrouter: "https://openrouter.ai/api/v1",
71
+ deepseek: "https://api.deepseek.com/v1",
72
+ kimi: "https://api.moonshot.ai/v1",
73
+ }
74
+
75
+ // ─── Gemini (@google/genai) ───────────────────────────────────────────────────
76
+
77
+ async function callGemini(options: LLMCallOptions): Promise<LLMResponse> {
78
+ const { GoogleGenAI } = await import("@google/genai")
79
+
80
+ const clientOpts: any = { apiKey: options.apiKey }
81
+ if (options.baseUrl?.trim()) clientOpts.httpOptions = { baseUrl: options.baseUrl.trim() }
82
+
83
+ const ai = new GoogleGenAI(clientOpts)
84
+
85
+ // Build toolCallId → name map for converting tool results
86
+ const toolNameMap = new Map<string, string>()
87
+ for (const msg of options.messages) {
88
+ if (msg.role === "assistant" && msg.tool_calls) {
89
+ for (const tc of msg.tool_calls) toolNameMap.set(tc.id, tc.function.name)
90
+ }
91
+ }
92
+
93
+ // Convert canonical messages → Gemini Content[]
94
+ let systemText = ""
95
+ const contents: any[] = []
96
+
97
+ for (const msg of options.messages) {
98
+ if (msg.role === "system") {
99
+ systemText += (systemText ? "\n\n" : "") + msg.content
100
+ continue
101
+ }
102
+ if (msg.role === "user") {
103
+ contents.push({ role: "user", parts: [{ text: msg.content }] })
104
+ continue
105
+ }
106
+ if (msg.role === "assistant") {
107
+ const parts: any[] = []
108
+ if (msg.content) parts.push({ text: msg.content })
109
+ for (const tc of msg.tool_calls ?? []) {
110
+ const fcPart: any = { functionCall: { name: tc.function.name, args: JSON.parse(tc.function.arguments || "{}") } }
111
+ if (tc.thought_signature) fcPart.thoughtSignature = tc.thought_signature
112
+ parts.push(fcPart)
113
+ }
114
+ if (parts.length) contents.push({ role: "model", parts })
115
+ continue
116
+ }
117
+ if (msg.role === "tool") {
118
+ const fnName = toolNameMap.get(msg.tool_call_id || "") || msg.name || "tool"
119
+ const frPart = { functionResponse: { name: fnName, response: { output: msg.content } } }
120
+ const last = contents[contents.length - 1]
121
+ if (last?.role === "user" && Array.isArray(last.parts)) {
122
+ last.parts.push(frPart)
123
+ } else {
124
+ contents.push({ role: "user", parts: [frPart] })
125
+ }
126
+ }
127
+ }
128
+
129
+ const config: any = {}
130
+ if (systemText) config.systemInstruction = systemText
131
+ if (options.maxTokens) config.maxOutputTokens = options.maxTokens
132
+ if (options.temperature !== undefined) config.temperature = options.temperature
133
+ if (options.tools?.length) {
134
+ config.tools = [{
135
+ functionDeclarations: options.tools.map((t) => ({
136
+ name: t.function.name,
137
+ description: t.function.description,
138
+ parameters: t.function.parameters,
139
+ })),
140
+ }]
141
+ }
142
+
143
+ log.info(`[llm-client] gemini/${options.model} — ${contents.length} turns, ${options.tools?.length ?? 0} tools`)
144
+
145
+ const response = await ai.models.generateContent({ model: options.model, contents, config })
146
+
147
+ const candidate = response.candidates?.[0]
148
+ const parts: any[] = candidate?.content?.parts ?? []
149
+
150
+ let content = ""
151
+ const tool_calls: LLMToolCall[] = []
152
+
153
+ for (const part of parts) {
154
+ if (part.text) content += part.text
155
+ if (part.functionCall) {
156
+ tool_calls.push({
157
+ id: crypto.randomUUID(),
158
+ type: "function",
159
+ function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args ?? {}) },
160
+ thought_signature: part.thoughtSignature ?? undefined,
161
+ })
162
+ }
163
+ }
164
+
165
+ const stop_reason: LLMResponse["stop_reason"] =
166
+ tool_calls.length > 0 ? "tool_calls"
167
+ : candidate?.finishReason === "MAX_TOKENS" ? "max_tokens"
168
+ : "stop"
169
+
170
+ return {
171
+ content,
172
+ tool_calls: tool_calls.length ? tool_calls : undefined,
173
+ stop_reason,
174
+ usage: response.usageMetadata ? {
175
+ input_tokens: response.usageMetadata.promptTokenCount ?? 0,
176
+ output_tokens: response.usageMetadata.candidatesTokenCount ?? 0,
177
+ } : undefined,
178
+ }
179
+ }
180
+
181
+ // ─── Anthropic (@anthropic-ai/sdk) ────────────────────────────────────────────
182
+
183
+ async function callAnthropic(options: LLMCallOptions): Promise<LLMResponse> {
184
+ const Anthropic = await import("@anthropic-ai/sdk")
185
+ const client = new Anthropic.default({ apiKey: options.apiKey })
186
+
187
+ const systemText = options.messages
188
+ .filter((m) => m.role === "system")
189
+ .map((m) => m.content)
190
+ .join("\n\n")
191
+
192
+ const anthropicMessages: any[] = []
193
+
194
+ for (const msg of options.messages) {
195
+ if (msg.role === "system") continue
196
+
197
+ if (msg.role === "tool") {
198
+ const block = { type: "tool_result", tool_use_id: msg.tool_call_id, content: msg.content }
199
+ const last = anthropicMessages[anthropicMessages.length - 1]
200
+ if (last?.role === "user" && Array.isArray(last.content)) {
201
+ last.content.push(block)
202
+ } else {
203
+ anthropicMessages.push({ role: "user", content: [block] })
204
+ }
205
+ continue
206
+ }
207
+
208
+ if (msg.role === "assistant" && msg.tool_calls?.length) {
209
+ const content: any[] = []
210
+ if (msg.content) content.push({ type: "text", text: msg.content })
211
+ for (const tc of msg.tool_calls) {
212
+ content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments || "{}") })
213
+ }
214
+ anthropicMessages.push({ role: "assistant", content })
215
+ continue
216
+ }
217
+
218
+ anthropicMessages.push({ role: msg.role, content: msg.content })
219
+ }
220
+
221
+ const tools: any[] = (options.tools ?? []).map((t) => ({
222
+ name: t.function.name,
223
+ description: t.function.description,
224
+ input_schema: t.function.parameters,
225
+ }))
226
+
227
+ const body: any = {
228
+ model: options.model,
229
+ max_tokens: options.maxTokens ?? 8192,
230
+ messages: anthropicMessages,
231
+ }
232
+ if (systemText) body.system = systemText
233
+ if (tools.length) body.tools = tools
234
+
235
+ log.info(`[llm-client] anthropic/${options.model} — ${anthropicMessages.length} msgs, ${tools.length} tools`)
236
+
237
+ const response = await client.messages.create(body)
238
+
239
+ let content = ""
240
+ const tool_calls: LLMToolCall[] = []
241
+
242
+ for (const block of response.content) {
243
+ if (block.type === "text") content = block.text
244
+ if (block.type === "tool_use") {
245
+ tool_calls.push({
246
+ id: block.id,
247
+ type: "function",
248
+ function: { name: block.name, arguments: JSON.stringify(block.input) },
249
+ })
250
+ }
251
+ }
252
+
253
+ return {
254
+ content,
255
+ tool_calls: tool_calls.length ? tool_calls : undefined,
256
+ stop_reason:
257
+ response.stop_reason === "tool_use" ? "tool_calls"
258
+ : response.stop_reason === "max_tokens" ? "max_tokens"
259
+ : "stop",
260
+ usage: {
261
+ input_tokens: response.usage.input_tokens,
262
+ output_tokens: response.usage.output_tokens,
263
+ },
264
+ }
265
+ }
266
+
267
+ // ─── Ollama (sdk nativo) ──────────────────────────────────────────────────────
268
+
269
+ async function callOllama(options: LLMCallOptions): Promise<LLMResponse> {
270
+ const { Ollama } = await import("ollama")
271
+
272
+ // Limpiar el prefijo "ollama/" del modelo si viene del DB
273
+ const modelName = options.model.replace(/^ollama\//, "")
274
+ const host = options.baseUrl?.trim() || "http://localhost:11434"
275
+
276
+ try {
277
+ // Detectar Cloud API de Ollama (ollama.com) → usar Authorization header
278
+ const isCloud = host.includes("ollama.com")
279
+ const headers: Record<string, string> = {}
280
+ if (isCloud && options.apiKey) {
281
+ headers["Authorization"] = `Bearer ${options.apiKey}`
282
+ }
283
+
284
+ const client = new Ollama({
285
+ host,
286
+ ...(Object.keys(headers).length ? { headers } : {}),
287
+ })
288
+
289
+ // Convertir mensajes al formato que espera Ollama:
290
+ // - assistant con tool_calls → incluir tool_calls con arguments como objeto (no string)
291
+ // - tool result → role:"tool", content (sin tool_call_id, Ollama no lo usa)
292
+ const messages = options.messages.map((m): any => {
293
+ if (m.role === "assistant" && m.tool_calls?.length) {
294
+ return {
295
+ role: "assistant",
296
+ content: m.content || "",
297
+ tool_calls: m.tool_calls.map((tc) => ({
298
+ function: {
299
+ name: tc.function.name,
300
+ arguments: (() => {
301
+ try { return JSON.parse(tc.function.arguments) } catch { return {} }
302
+ })(),
303
+ },
304
+ })),
305
+ }
306
+ }
307
+ if (m.role === "tool") {
308
+ return { role: "tool", content: m.content }
309
+ }
310
+ return { role: m.role, content: m.content }
311
+ })
312
+
313
+ // Convertir tools al formato Ollama (igual que OpenAI function-calling)
314
+ const tools = options.tools?.map((t) => ({
315
+ type: "function" as const,
316
+ function: {
317
+ name: t.function.name,
318
+ description: t.function.description,
319
+ parameters: t.function.parameters,
320
+ },
321
+ }))
322
+
323
+ // Opciones de runtime (num_ctx para contextos grandes)
324
+ const runtimeOptions: Record<string, unknown> = {}
325
+ if (options.numCtx) runtimeOptions.num_ctx = options.numCtx
326
+ if (options.numGpu !== undefined) runtimeOptions.num_gpu = options.numGpu
327
+ if (options.temperature !== undefined) runtimeOptions.temperature = options.temperature
328
+
329
+ log.info(
330
+ `[llm-client] ollama/${modelName} @ ${isCloud ? "ollama.com" : host} stream=true` +
331
+ ` — ${messages.length} msgs, ${tools?.length ?? 0} tools` +
332
+ (options.numCtx ? ` num_ctx=${options.numCtx}` : "")
333
+ )
334
+
335
+ // ── Streaming ───────────────────────────────────────────────────────────────
336
+ const stream = await client.chat({
337
+ model: modelName,
338
+ messages,
339
+ tools: tools?.length ? tools : undefined,
340
+ options: Object.keys(runtimeOptions).length ? runtimeOptions : undefined,
341
+ stream: true,
342
+ })
343
+
344
+ let content = ""
345
+ let promptEvalCount = 0
346
+ let evalCount = 0
347
+ const tool_calls: LLMToolCall[] = []
348
+
349
+ for await (const part of stream) {
350
+ // Acumular texto parcial
351
+ const delta = part.message?.content ?? ""
352
+ if (delta) {
353
+ content += delta
354
+ if (options.onToken) options.onToken(delta)
355
+ }
356
+
357
+ // tool_calls solo llegan en el último chunk
358
+ if (part.message?.tool_calls?.length) {
359
+ for (const tc of part.message.tool_calls) {
360
+ tool_calls.push({
361
+ id: crypto.randomUUID(),
362
+ type: "function" as const,
363
+ function: {
364
+ name: (tc as any).function.name,
365
+ arguments: JSON.stringify((tc as any).function.arguments ?? {}),
366
+ },
367
+ })
368
+ }
369
+ }
370
+
371
+ // Tokens de uso en el chunk final
372
+ if (part.prompt_eval_count !== undefined) promptEvalCount = part.prompt_eval_count
373
+ if (part.eval_count !== undefined) evalCount = part.eval_count
374
+ }
375
+
376
+ return {
377
+ content,
378
+ tool_calls: tool_calls.length ? tool_calls : undefined,
379
+ stop_reason: tool_calls.length > 0 ? "tool_calls" : "stop",
380
+ usage:
381
+ evalCount > 0
382
+ ? { input_tokens: promptEvalCount, output_tokens: evalCount }
383
+ : undefined,
384
+ }
385
+ } catch (error: any) {
386
+ // Logger mucho más explícito para errores críticos (especialmente resource issues)
387
+ log.error(`[llm-client] FAILED call to ollama/${modelName} at ${host}`)
388
+ log.error(`[llm-client] Error details: ${error.message || error}`)
389
+ if (options.numCtx) log.error(`[llm-client] Context requested: num_ctx=${options.numCtx}`)
390
+ if (options.tools?.length) log.error(`[llm-client] Tools defined: ${options.tools.length}`)
391
+
392
+ // Sugerencia proactiva si es el error típico de Ollama server crash
393
+ if (error.message?.includes("model runner has unexpectedly stopped")) {
394
+ log.warn(`[llm-client] TIP: This usually means Ollama ran out of VRAM/RAM. Try reducing 'num_ctx' or the number of tools.`)
395
+ }
396
+
397
+ throw error // Re-throw to be caught by the main callLLM for consistent response
398
+ }
399
+ }
400
+
401
+
402
+ // ─── OpenAI-compatible (openai npm package) ────────────────────────────────────
403
+
404
+ async function callOpenAICompat(options: LLMCallOptions): Promise<LLMResponse> {
405
+ const { default: OpenAI } = await import("openai")
406
+
407
+ const baseURL = options.baseUrl?.trim() || OPENAI_COMPAT_BASE_URLS[options.provider] || undefined
408
+ const isLocal = baseURL?.includes("localhost") || baseURL?.includes("127.0.0.1")
409
+ const apiKey = options.apiKey || (isLocal ? "ollama" : "missing-api-key")
410
+
411
+ const client = new OpenAI({ apiKey, baseURL })
412
+
413
+ const body: any = {
414
+ model: options.provider === "ollama" ? options.model.replace(/^ollama\//, "") : options.model,
415
+ messages: options.messages,
416
+ temperature: options.temperature ?? 0.7,
417
+ }
418
+ if (options.maxTokens) body.max_tokens = options.maxTokens
419
+ if (options.numCtx && isLocal) body.num_ctx = options.numCtx
420
+ if (options.tools?.length) {
421
+ body.tools = options.tools
422
+ body.tool_choice = "auto"
423
+ }
424
+
425
+ log.info(`[llm-client] ${options.provider}/${body.model} — ${options.messages.length} msgs, ${options.tools?.length ?? 0} tools`)
426
+
427
+ const response = await client.chat.completions.create(body)
428
+ const choice = response.choices[0]
429
+ const msg = choice.message
430
+
431
+ const tool_calls: LLMToolCall[] | undefined = (msg.tool_calls as any[])?.map((tc: any) => ({
432
+ id: tc.id,
433
+ type: "function" as const,
434
+ function: { name: tc.function.name, arguments: tc.function.arguments },
435
+ }))
436
+
437
+ return {
438
+ content: msg.content ?? "",
439
+ tool_calls: tool_calls?.length ? tool_calls : undefined,
440
+ stop_reason:
441
+ choice.finish_reason === "tool_calls" ? "tool_calls"
442
+ : choice.finish_reason === "length" ? "max_tokens"
443
+ : "stop",
444
+ usage: response.usage ? {
445
+ input_tokens: response.usage.prompt_tokens,
446
+ output_tokens: response.usage.completion_tokens,
447
+ } : undefined,
448
+ }
449
+ }
450
+
451
+ // ─── Public API ────────────────────────────────────────────────────────────────
452
+
453
+ /**
454
+ * Call any LLM provider. Returns a canonical LLMResponse regardless of provider.
455
+ */
456
+ export async function callLLM(options: LLMCallOptions): Promise<LLMResponse> {
457
+ try {
458
+ if (GEMINI_PROVIDERS.has(options.provider)) return await callGemini(options)
459
+ if (options.provider === "anthropic") return await callAnthropic(options)
460
+ if (options.provider === "ollama") return await callOllama(options)
461
+ return await callOpenAICompat(options)
462
+ } catch (err) {
463
+ const msg = (err as Error).message
464
+ const cleanModel = options.model.replace(new RegExp(`^${options.provider}\\/`), "")
465
+ log.error(`[llm-client] Error calling ${options.provider}/${cleanModel}: ${msg}`)
466
+ return { content: `[LLM Error] ${msg}`, stop_reason: "error" }
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Resolve provider config from DB (decrypts API key).
472
+ */
473
+ export async function resolveProviderConfig(
474
+ providerId: string,
475
+ modelId: string
476
+ ): Promise<Pick<LLMCallOptions, "provider" | "model" | "apiKey" | "baseUrl" | "numCtx" | "numGpu">> {
477
+ const { getDb } = await import("../storage/sqlite")
478
+ const { decryptApiKey } = await import("../storage/crypto")
479
+
480
+ const db = getDb()
481
+ const providerRow = db
482
+ .query<any, [string]>("SELECT * FROM providers WHERE id = ? AND enabled = 1")
483
+ .get(providerId)
484
+
485
+ let apiKey = ""
486
+ if (providerRow?.api_key_encrypted && providerRow?.api_key_iv) {
487
+ try {
488
+ apiKey = await decryptApiKey(providerRow.api_key_encrypted, providerRow.api_key_iv)
489
+ } catch { /* fall through to env var */ }
490
+ }
491
+ if (!apiKey) {
492
+ apiKey = process.env[`${providerId.toUpperCase()}_API_KEY`] || ""
493
+ }
494
+
495
+ return {
496
+ provider: providerId,
497
+ model: modelId,
498
+ apiKey,
499
+ baseUrl: providerRow?.base_url || undefined,
500
+ numCtx: providerRow?.num_ctx ?? undefined,
501
+ numGpu: providerRow?.num_gpu ?? undefined,
502
+ }
503
+ }