@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.
- package/CONTRIBUTING.md +44 -0
- package/README.md +310 -0
- package/package.json +96 -0
- package/packages/cli/package.json +28 -0
- package/packages/cli/src/commands/agent-run.ts +168 -0
- package/packages/cli/src/commands/agents.ts +398 -0
- package/packages/cli/src/commands/chat.ts +142 -0
- package/packages/cli/src/commands/config.ts +50 -0
- package/packages/cli/src/commands/cron.ts +161 -0
- package/packages/cli/src/commands/dev.ts +95 -0
- package/packages/cli/src/commands/doctor.ts +133 -0
- package/packages/cli/src/commands/gateway.ts +443 -0
- package/packages/cli/src/commands/logs.ts +57 -0
- package/packages/cli/src/commands/mcp.ts +175 -0
- package/packages/cli/src/commands/message.ts +77 -0
- package/packages/cli/src/commands/onboard.ts +1868 -0
- package/packages/cli/src/commands/security.ts +144 -0
- package/packages/cli/src/commands/service.ts +50 -0
- package/packages/cli/src/commands/sessions.ts +116 -0
- package/packages/cli/src/commands/skills.ts +187 -0
- package/packages/cli/src/commands/update.ts +25 -0
- package/packages/cli/src/index.ts +185 -0
- package/packages/cli/src/utils/token.ts +6 -0
- package/packages/code-bridge/README.md +78 -0
- package/packages/code-bridge/package.json +18 -0
- package/packages/code-bridge/src/index.ts +95 -0
- package/packages/code-bridge/src/process-manager.ts +212 -0
- package/packages/code-bridge/src/schemas.ts +133 -0
- package/packages/core/package.json +46 -0
- package/packages/core/src/agent/agent-loop.ts +369 -0
- package/packages/core/src/agent/compaction.ts +140 -0
- package/packages/core/src/agent/context-compiler.ts +378 -0
- package/packages/core/src/agent/context-guard.ts +91 -0
- package/packages/core/src/agent/context.ts +138 -0
- package/packages/core/src/agent/conversation-store.ts +198 -0
- package/packages/core/src/agent/curator.ts +158 -0
- package/packages/core/src/agent/hooks.ts +166 -0
- package/packages/core/src/agent/index.ts +116 -0
- package/packages/core/src/agent/llm-client.ts +503 -0
- package/packages/core/src/agent/native-tools.ts +505 -0
- package/packages/core/src/agent/prompt-builder.ts +532 -0
- package/packages/core/src/agent/providers/index.ts +167 -0
- package/packages/core/src/agent/providers.ts +1 -0
- package/packages/core/src/agent/reflector.ts +170 -0
- package/packages/core/src/agent/service.ts +64 -0
- package/packages/core/src/agent/stuck-loop.ts +133 -0
- package/packages/core/src/agent/supervisor.ts +39 -0
- package/packages/core/src/agent/tracer.ts +102 -0
- package/packages/core/src/agent/workspace.ts +110 -0
- package/packages/core/src/canvas/canvas-manager.test.ts +161 -0
- package/packages/core/src/canvas/canvas-manager.ts +319 -0
- package/packages/core/src/canvas/canvas-tools.ts +420 -0
- package/packages/core/src/canvas/emitter.ts +115 -0
- package/packages/core/src/canvas/index.ts +2 -0
- package/packages/core/src/channels/base.ts +138 -0
- package/packages/core/src/channels/discord.ts +260 -0
- package/packages/core/src/channels/index.ts +7 -0
- package/packages/core/src/channels/manager.ts +383 -0
- package/packages/core/src/channels/slack.ts +287 -0
- package/packages/core/src/channels/telegram.ts +502 -0
- package/packages/core/src/channels/webchat.ts +128 -0
- package/packages/core/src/channels/whatsapp.ts +375 -0
- package/packages/core/src/config/index.ts +12 -0
- package/packages/core/src/config/loader.ts +529 -0
- package/packages/core/src/events/event-bus.ts +169 -0
- package/packages/core/src/gateway/index.ts +5 -0
- package/packages/core/src/gateway/initializer.ts +290 -0
- package/packages/core/src/gateway/lane-queue.ts +169 -0
- package/packages/core/src/gateway/resolver.ts +108 -0
- package/packages/core/src/gateway/router.ts +124 -0
- package/packages/core/src/gateway/server.ts +3317 -0
- package/packages/core/src/gateway/session.ts +95 -0
- package/packages/core/src/gateway/slash-commands.ts +192 -0
- package/packages/core/src/heartbeat/index.ts +157 -0
- package/packages/core/src/index.ts +19 -0
- package/packages/core/src/integrations/catalog.ts +286 -0
- package/packages/core/src/integrations/env.ts +64 -0
- package/packages/core/src/integrations/index.ts +2 -0
- package/packages/core/src/memory/index.ts +1 -0
- package/packages/core/src/memory/notes.ts +68 -0
- package/packages/core/src/plugins/api.ts +128 -0
- package/packages/core/src/plugins/index.ts +2 -0
- package/packages/core/src/plugins/loader.ts +365 -0
- package/packages/core/src/resilience/circuit-breaker.ts +225 -0
- package/packages/core/src/security/google-chat.ts +269 -0
- package/packages/core/src/security/index.ts +192 -0
- package/packages/core/src/security/pairing.ts +250 -0
- package/packages/core/src/security/rate-limit.ts +270 -0
- package/packages/core/src/security/signal.ts +321 -0
- package/packages/core/src/state/store.ts +312 -0
- package/packages/core/src/storage/bun-sqlite-store.ts +188 -0
- package/packages/core/src/storage/crypto.ts +101 -0
- package/packages/core/src/storage/db-context.ts +333 -0
- package/packages/core/src/storage/onboarding.ts +1087 -0
- package/packages/core/src/storage/schema.ts +541 -0
- package/packages/core/src/storage/seed.ts +571 -0
- package/packages/core/src/storage/sqlite.ts +387 -0
- package/packages/core/src/storage/usage.ts +212 -0
- package/packages/core/src/tools/bridge-events.ts +74 -0
- package/packages/core/src/tools/browser.ts +275 -0
- package/packages/core/src/tools/codebridge.ts +421 -0
- package/packages/core/src/tools/coordinator-tools.ts +179 -0
- package/packages/core/src/tools/cron.ts +611 -0
- package/packages/core/src/tools/exec.ts +140 -0
- package/packages/core/src/tools/fs.ts +364 -0
- package/packages/core/src/tools/index.ts +12 -0
- package/packages/core/src/tools/memory.ts +176 -0
- package/packages/core/src/tools/notify.ts +113 -0
- package/packages/core/src/tools/project-management.ts +376 -0
- package/packages/core/src/tools/project.ts +375 -0
- package/packages/core/src/tools/read.ts +158 -0
- package/packages/core/src/tools/web.ts +436 -0
- package/packages/core/src/tools/workspace.ts +171 -0
- package/packages/core/src/utils/benchmark.ts +80 -0
- package/packages/core/src/utils/crypto.ts +73 -0
- package/packages/core/src/utils/date.ts +42 -0
- package/packages/core/src/utils/index.ts +4 -0
- package/packages/core/src/utils/logger.ts +388 -0
- package/packages/core/src/utils/retry.ts +70 -0
- package/packages/core/src/voice/index.ts +583 -0
- package/packages/core/tsconfig.json +9 -0
- package/packages/mcp/package.json +26 -0
- package/packages/mcp/src/config.ts +13 -0
- package/packages/mcp/src/index.ts +1 -0
- package/packages/mcp/src/logger.ts +42 -0
- package/packages/mcp/src/manager.ts +434 -0
- package/packages/mcp/src/transports/index.ts +67 -0
- package/packages/mcp/src/transports/sse.ts +241 -0
- package/packages/mcp/src/transports/websocket.ts +159 -0
- package/packages/skills/package.json +21 -0
- package/packages/skills/src/bundled/agent_management/SKILL.md +24 -0
- package/packages/skills/src/bundled/browser_automation/SKILL.md +30 -0
- package/packages/skills/src/bundled/context_compact/SKILL.md +35 -0
- package/packages/skills/src/bundled/cron_manager/SKILL.md +52 -0
- package/packages/skills/src/bundled/file_manager/SKILL.md +76 -0
- package/packages/skills/src/bundled/http_client/SKILL.md +24 -0
- package/packages/skills/src/bundled/memory/SKILL.md +42 -0
- package/packages/skills/src/bundled/project_management/SKILL.md +26 -0
- package/packages/skills/src/bundled/shell/SKILL.md +43 -0
- package/packages/skills/src/bundled/system_notify/SKILL.md +52 -0
- package/packages/skills/src/bundled/voice/SKILL.md +25 -0
- package/packages/skills/src/bundled/web_search/SKILL.md +29 -0
- package/packages/skills/src/index.ts +1 -0
- package/packages/skills/src/loader.ts +282 -0
- package/packages/tools/package.json +43 -0
- package/packages/tools/src/browser/browser.test.ts +111 -0
- package/packages/tools/src/browser/index.ts +272 -0
- package/packages/tools/src/canvas/index.ts +220 -0
- package/packages/tools/src/cron/cron.test.ts +164 -0
- package/packages/tools/src/cron/index.ts +304 -0
- package/packages/tools/src/filesystem/filesystem.test.ts +240 -0
- package/packages/tools/src/filesystem/index.ts +379 -0
- package/packages/tools/src/git/index.ts +239 -0
- package/packages/tools/src/index.ts +4 -0
- package/packages/tools/src/shell/detect-env.ts +70 -0
- 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
|
+
}
|