@johpaz/hive-sdk 0.0.14 → 0.0.15

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 (199) hide show
  1. package/.github/CODEOWNERS +9 -0
  2. package/.github/workflows/publish.yml +89 -0
  3. package/.github/workflows/version-bump.yml +102 -0
  4. package/CHANGELOG.md +38 -0
  5. package/README.md +158 -0
  6. package/bun.lock +543 -0
  7. package/bunfig.toml +7 -0
  8. package/docs/API-AGENTS.md +316 -0
  9. package/docs/API-CONTEXT-COMPILER.md +252 -0
  10. package/docs/API-DAG-SCHEDULER.md +273 -0
  11. package/docs/API-TOOLS-SKILLS-CHANNELS.md +293 -0
  12. package/docs/API-WORKERS-EVENTS.md +152 -0
  13. package/docs/INDEX.md +141 -0
  14. package/docs/README.md +68 -0
  15. package/package.json +54 -105
  16. package/packages/cli/package.json +17 -0
  17. package/packages/cli/src/commands/init.ts +56 -0
  18. package/packages/cli/src/commands/run.ts +45 -0
  19. package/packages/cli/src/commands/test.ts +42 -0
  20. package/packages/cli/src/commands/trace.ts +55 -0
  21. package/packages/cli/src/index.ts +43 -0
  22. package/packages/core/package.json +58 -0
  23. package/packages/core/src/ace/Curator.ts +158 -0
  24. package/packages/core/src/ace/Reflector.ts +200 -0
  25. package/packages/core/src/ace/Tracer.ts +100 -0
  26. package/packages/core/src/ace/index.ts +4 -0
  27. package/packages/core/src/agent/AgentRunner.ts +699 -0
  28. package/packages/core/src/agent/Compaction.ts +221 -0
  29. package/packages/core/src/agent/ContextCompiler.ts +567 -0
  30. package/packages/core/src/agent/ContextGuard.ts +91 -0
  31. package/packages/core/src/agent/ConversationStore.ts +244 -0
  32. package/packages/core/src/agent/Hooks.ts +166 -0
  33. package/packages/core/src/agent/NativeTools.ts +31 -0
  34. package/packages/core/src/agent/PromptBuilder.ts +169 -0
  35. package/packages/core/src/agent/Service.ts +267 -0
  36. package/packages/core/src/agent/StuckLoop.ts +133 -0
  37. package/packages/core/src/agent/index.ts +12 -0
  38. package/packages/core/src/agent/providers/LLMClient.ts +149 -0
  39. package/packages/core/src/agent/providers/anthropic.ts +212 -0
  40. package/packages/core/src/agent/providers/gemini.ts +215 -0
  41. package/packages/core/src/agent/providers/index.ts +199 -0
  42. package/packages/core/src/agent/providers/interface.ts +195 -0
  43. package/packages/core/src/agent/providers/ollama.ts +175 -0
  44. package/packages/core/src/agent/providers/openai-compat.ts +231 -0
  45. package/packages/core/src/agent/providers.ts +1 -0
  46. package/packages/core/src/agent/selectors/PlaybookSelector.ts +147 -0
  47. package/packages/core/src/agent/selectors/SkillSelector.ts +478 -0
  48. package/packages/core/src/agent/selectors/ToolSelector.ts +577 -0
  49. package/packages/core/src/agent/selectors/index.ts +6 -0
  50. package/packages/core/src/api/createAgent.test.ts +48 -0
  51. package/packages/core/src/api/createAgent.ts +122 -0
  52. package/packages/core/src/api/index.ts +2 -0
  53. package/packages/core/src/canvas/CanvasManager.ts +390 -0
  54. package/packages/core/src/canvas/a2ui-tools.ts +255 -0
  55. package/packages/core/src/canvas/canvas-tools.ts +448 -0
  56. package/packages/core/src/canvas/emitter.ts +149 -0
  57. package/packages/core/src/canvas/index.ts +6 -0
  58. package/packages/core/src/config/index.ts +2 -0
  59. package/packages/core/src/config/loader.ts +554 -0
  60. package/packages/core/src/ethics/EthicsGuard.test.ts +54 -0
  61. package/packages/core/src/ethics/EthicsGuard.ts +66 -0
  62. package/packages/core/src/ethics/index.ts +2 -0
  63. package/packages/core/src/gateway/channel-notify.test.ts +14 -0
  64. package/packages/core/src/gateway/channel-notify.ts +12 -0
  65. package/packages/core/src/gateway/index.ts +1 -0
  66. package/packages/core/src/index.ts +37 -0
  67. package/packages/core/src/mcp/MCPClient.ts +439 -0
  68. package/packages/core/src/mcp/MCPToolAdapter.ts +176 -0
  69. package/packages/core/src/mcp/config.ts +13 -0
  70. package/packages/core/src/mcp/hot-reload.ts +147 -0
  71. package/packages/core/src/mcp/index.ts +11 -0
  72. package/packages/core/src/mcp/logger.ts +42 -0
  73. package/packages/core/src/mcp/singleton.ts +21 -0
  74. package/packages/core/src/mcp/transports/index.ts +67 -0
  75. package/packages/core/src/mcp/transports/sse.ts +241 -0
  76. package/packages/core/src/mcp/transports/websocket.ts +159 -0
  77. package/packages/core/src/memory/Scratchpad.test.ts +47 -0
  78. package/packages/core/src/memory/Scratchpad.ts +37 -0
  79. package/packages/core/src/memory/Storage.ts +6 -0
  80. package/packages/core/src/memory/index.ts +2 -0
  81. package/packages/core/src/multimodal/VisionService.ts +293 -0
  82. package/packages/core/src/multimodal/index.ts +2 -0
  83. package/packages/core/src/multimodal/types.ts +28 -0
  84. package/packages/core/src/security/Pairing.ts +250 -0
  85. package/packages/core/src/security/RateLimit.ts +270 -0
  86. package/packages/core/src/security/index.ts +4 -0
  87. package/packages/core/src/skills/SkillLoader.ts +388 -0
  88. package/packages/core/src/skills/bundled-data.generated.ts +3332 -0
  89. package/packages/core/src/skills/defineSkill.ts +18 -0
  90. package/packages/core/src/skills/index.ts +4 -0
  91. package/packages/core/src/state/index.ts +2 -0
  92. package/packages/core/src/state/store.ts +312 -0
  93. package/packages/core/src/storage/SQLiteStorage.ts +407 -0
  94. package/packages/core/src/storage/crypto.ts +101 -0
  95. package/packages/core/src/storage/index.ts +10 -0
  96. package/packages/core/src/storage/onboarding.ts +1603 -0
  97. package/packages/core/src/storage/schema.ts +689 -0
  98. package/packages/core/src/storage/seed.ts +740 -0
  99. package/packages/core/src/storage/usage.ts +374 -0
  100. package/packages/core/src/swarm/AgentBus.ts +460 -0
  101. package/packages/core/src/swarm/AgentExecutor.ts +53 -0
  102. package/packages/core/src/swarm/Coordinator.ts +251 -0
  103. package/packages/core/src/swarm/EventBridge.ts +122 -0
  104. package/packages/core/src/swarm/EventBus.ts +169 -0
  105. package/packages/core/src/swarm/TaskGraph.ts +192 -0
  106. package/packages/core/src/swarm/TaskNode.ts +97 -0
  107. package/packages/core/src/swarm/TaskResult.ts +22 -0
  108. package/packages/core/src/swarm/WorkerPool.ts +236 -0
  109. package/packages/core/src/swarm/errors.ts +37 -0
  110. package/packages/core/src/swarm/index.ts +30 -0
  111. package/packages/core/src/swarm/presets/HiveLearnPreset.ts +99 -0
  112. package/packages/core/src/swarm/presets/ResearchPreset.ts +97 -0
  113. package/packages/core/src/swarm/presets/index.ts +4 -0
  114. package/packages/core/src/swarm/strategies/ParallelStrategy.ts +21 -0
  115. package/packages/core/src/swarm/strategies/PriorityStrategy.ts +46 -0
  116. package/packages/core/src/swarm/strategies/index.ts +3 -0
  117. package/packages/core/src/swarm/types.ts +164 -0
  118. package/packages/core/src/tools/ToolExecutor.ts +58 -0
  119. package/packages/core/src/tools/ToolRegistry.test.ts +98 -0
  120. package/packages/core/src/tools/ToolRegistry.ts +61 -0
  121. package/packages/core/src/tools/agents/get-available-models.ts +118 -0
  122. package/packages/core/src/tools/agents/index.ts +715 -0
  123. package/packages/core/src/tools/bridge-events.ts +26 -0
  124. package/packages/core/src/tools/canvas/index.ts +375 -0
  125. package/packages/core/src/tools/cli/index.ts +142 -0
  126. package/packages/core/src/tools/codebridge/index.ts +342 -0
  127. package/packages/core/src/tools/core/index.ts +476 -0
  128. package/packages/core/src/tools/cron/index.ts +626 -0
  129. package/packages/core/src/tools/filesystem/fs-delete.ts +78 -0
  130. package/packages/core/src/tools/filesystem/fs-edit.ts +106 -0
  131. package/packages/core/src/tools/filesystem/fs-exists.ts +63 -0
  132. package/packages/core/src/tools/filesystem/fs-glob.ts +108 -0
  133. package/packages/core/src/tools/filesystem/fs-list.ts +129 -0
  134. package/packages/core/src/tools/filesystem/fs-read.ts +72 -0
  135. package/packages/core/src/tools/filesystem/fs-write.ts +67 -0
  136. package/packages/core/src/tools/filesystem/index.ts +34 -0
  137. package/packages/core/src/tools/filesystem/workspace-guard.ts +62 -0
  138. package/packages/core/src/tools/index.ts +231 -0
  139. package/packages/core/src/tools/meeting/index.ts +363 -0
  140. package/packages/core/src/tools/office/index.ts +47 -0
  141. package/packages/core/src/tools/office/office-escribir-docx.ts +192 -0
  142. package/packages/core/src/tools/office/office-escribir-pdf.ts +172 -0
  143. package/packages/core/src/tools/office/office-escribir-pptx.ts +174 -0
  144. package/packages/core/src/tools/office/office-escribir-xlsx.ts +116 -0
  145. package/packages/core/src/tools/office/office-leer-docx.ts +93 -0
  146. package/packages/core/src/tools/office/office-leer-pdf.ts +114 -0
  147. package/packages/core/src/tools/office/office-leer-pptx.ts +136 -0
  148. package/packages/core/src/tools/office/office-leer-xlsx.ts +124 -0
  149. package/packages/core/src/tools/projects/index.ts +37 -0
  150. package/packages/core/src/tools/projects/project-create.ts +94 -0
  151. package/packages/core/src/tools/projects/project-done.ts +66 -0
  152. package/packages/core/src/tools/projects/project-fail.ts +66 -0
  153. package/packages/core/src/tools/projects/project-list.ts +96 -0
  154. package/packages/core/src/tools/projects/project-update.ts +72 -0
  155. package/packages/core/src/tools/projects/task-create.ts +68 -0
  156. package/packages/core/src/tools/projects/task-evaluate.ts +93 -0
  157. package/packages/core/src/tools/projects/task-update.ts +93 -0
  158. package/packages/core/src/tools/types.ts +39 -0
  159. package/packages/core/src/tools/voice/index.ts +104 -0
  160. package/packages/core/src/tools/web/browser-click.ts +78 -0
  161. package/packages/core/src/tools/web/browser-extract.ts +139 -0
  162. package/packages/core/src/tools/web/browser-navigate.ts +106 -0
  163. package/packages/core/src/tools/web/browser-screenshot.ts +87 -0
  164. package/packages/core/src/tools/web/browser-script.ts +88 -0
  165. package/packages/core/src/tools/web/browser-service.ts +554 -0
  166. package/packages/core/src/tools/web/browser-type.ts +101 -0
  167. package/packages/core/src/tools/web/browser-wait.ts +136 -0
  168. package/packages/core/src/tools/web/index.ts +41 -0
  169. package/packages/core/src/tools/web/web-fetch.ts +78 -0
  170. package/packages/core/src/tools/web/web-search.ts +123 -0
  171. package/packages/core/src/utils/benchmark.ts +80 -0
  172. package/packages/core/src/utils/crypto.ts +73 -0
  173. package/packages/core/src/utils/date.ts +42 -0
  174. package/packages/core/src/utils/index.ts +10 -0
  175. package/packages/core/src/utils/logger.ts +389 -0
  176. package/packages/core/src/utils/retry.ts +70 -0
  177. package/packages/core/src/utils/toon.ts +253 -0
  178. package/packages/core/src/voice/index.ts +656 -0
  179. package/test/setup-db.ts +216 -0
  180. package/tsconfig.json +39 -0
  181. package/src/agents.ts +0 -1
  182. package/src/canvas.ts +0 -1
  183. package/src/channels.ts +0 -1
  184. package/src/config.ts +0 -1
  185. package/src/events.ts +0 -1
  186. package/src/gateway.ts +0 -1
  187. package/src/index.ts +0 -304
  188. package/src/mcp.ts +0 -1
  189. package/src/multimodal.ts +0 -1
  190. package/src/scheduler.ts +0 -1
  191. package/src/security.ts +0 -1
  192. package/src/skills.ts +0 -1
  193. package/src/state.ts +0 -1
  194. package/src/storage.ts +0 -1
  195. package/src/tools.ts +0 -1
  196. package/src/tts.ts +0 -1
  197. package/src/types.ts +0 -82
  198. package/src/utils.ts +0 -1
  199. package/src/voice.ts +0 -1
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Shared types and utilities for LLM providers.
3
+ */
4
+
5
+ import type { LLMCallOptions, LLMMessage, LLMResponse, LLMToolCall, ContentPart } from "./LLMClient.ts"
6
+ export type { LLMCallOptions, LLMMessage, LLMResponse, LLMToolCall, ContentPart }
7
+
8
+ import { logger } from "../../utils/logger.ts"
9
+ const log = logger.child("llm-client")
10
+
11
+ // ─── Provider interface ────────────────────────────────────────────────────────
12
+
13
+ export interface LLMProvider {
14
+ call(options: LLMCallOptions): Promise<LLMResponse>
15
+ }
16
+
17
+ // ─── Shared constants ─────────────────────────────────────────────────────────
18
+
19
+ // Models that only accept temperature=1 (reasoning/thinking models).
20
+ export const FIXED_TEMPERATURE_1_MODELS = new Set(["kimi-k2.5", "kimi-k2", "kimi-k2-5"])
21
+
22
+ export const OPENAI_COMPAT_BASE_URLS: Record<string, string> = {
23
+ groq: "https://api.groq.com/openai/v1",
24
+ mistral: "https://api.mistral.ai/v1",
25
+ openrouter: "https://openrouter.ai/api/v1",
26
+ deepseek: "https://api.deepseek.com/v1",
27
+ kimi: "https://api.moonshot.ai/v1",
28
+ "local-llama": "http://localhost:8080/v1",
29
+ nvidia: "https://integrate.api.nvidia.com/v1",
30
+ qwen: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
31
+ }
32
+
33
+ // ─── Provider profiles ────────────────────────────────────────────────────────
34
+
35
+ export interface ProviderProfile {
36
+ /** Normalize tool names to strict OpenAI format: [a-zA-Z0-9_-]{1,64} */
37
+ normalizeToolNames: boolean
38
+ /** Replacement string for invalid tool name chars, e.g. "__" */
39
+ toolNameReplacement: string
40
+ /** Value for the tool_choice parameter ("auto" | "any" for Mistral) */
41
+ toolChoiceAuto: string
42
+ /** Send parallel_tool_calls: false when true */
43
+ disableParallelToolCalls: boolean
44
+ /** Strip additionalProperties: false from tool parameter schemas */
45
+ stripAdditionalProperties: boolean
46
+ /** Retry the call without tools when these HTTP status codes are returned */
47
+ retryWithoutToolsOnCodes: number[]
48
+ }
49
+
50
+ const DEFAULT_PROFILE: ProviderProfile = {
51
+ normalizeToolNames: false,
52
+ toolNameReplacement: "__",
53
+ toolChoiceAuto: "auto",
54
+ disableParallelToolCalls: false,
55
+ stripAdditionalProperties: false,
56
+ retryWithoutToolsOnCodes: [],
57
+ }
58
+
59
+ export const PROVIDER_PROFILES: Record<string, ProviderProfile> = {
60
+ openai: { ...DEFAULT_PROFILE, normalizeToolNames: true },
61
+ kimi: { ...DEFAULT_PROFILE, normalizeToolNames: true, disableParallelToolCalls: true, retryWithoutToolsOnCodes: [422] },
62
+ deepseek: { ...DEFAULT_PROFILE, normalizeToolNames: true },
63
+ groq: { ...DEFAULT_PROFILE, normalizeToolNames: true, retryWithoutToolsOnCodes: [400, 422] },
64
+ mistral: { ...DEFAULT_PROFILE, normalizeToolNames: true, toolChoiceAuto: "any", stripAdditionalProperties: true },
65
+ openrouter: { ...DEFAULT_PROFILE, normalizeToolNames: true, retryWithoutToolsOnCodes: [400, 422] },
66
+ nvidia: { ...DEFAULT_PROFILE, normalizeToolNames: true },
67
+ qwen: { ...DEFAULT_PROFILE, normalizeToolNames: true, retryWithoutToolsOnCodes: [400, 422] },
68
+ "local-llama": { ...DEFAULT_PROFILE },
69
+ }
70
+
71
+ export function getProviderProfile(provider: string): ProviderProfile {
72
+ return PROVIDER_PROFILES[provider] ?? DEFAULT_PROFILE
73
+ }
74
+
75
+ // ─── Models that don't support tool calling ───────────────────────────────────
76
+
77
+ export const NO_TOOL_MODELS = new Set([
78
+ "deepseek-reasoner",
79
+ "deepseek/deepseek-r1:free",
80
+ ])
81
+
82
+ export function modelSupportsTools(provider: string, model: string): boolean {
83
+ if (NO_TOOL_MODELS.has(model)) return false
84
+ // DeepSeek R1 and OpenRouter-routed R1 variants don't support tools
85
+ if ((provider === "deepseek" || provider === "openrouter") && /[-/]r1\b/i.test(model)) return false
86
+ return true
87
+ }
88
+
89
+ // ─── Tool name & schema normalization ────────────────────────────────────────
90
+
91
+ const OPENAI_TOOL_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/
92
+ const NORMALIZE_CHARS_RE = /[^a-zA-Z0-9_-]/g
93
+
94
+ /** Normalize a tool name to pass strict [a-zA-Z0-9_-]{1,64} validation. */
95
+ export function normalizeToolName(name: string, replacement: string): string {
96
+ if (OPENAI_TOOL_NAME_RE.test(name)) return name
97
+ const escapedReplacement = replacement.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
98
+ const collapseRE = new RegExp(`(${escapedReplacement}){2,}`, "g")
99
+ let n = name.replace(NORMALIZE_CHARS_RE, replacement).replace(collapseRE, replacement)
100
+ if (!/^[a-zA-Z_]/.test(n)) n = "_" + n
101
+ return n.slice(0, 64)
102
+ }
103
+
104
+ /** Strip provider-incompatible fields from a tool parameter schema. */
105
+ export function normalizeToolSchema(
106
+ schema: Record<string, unknown>,
107
+ profile: ProviderProfile
108
+ ): Record<string, unknown> {
109
+ if (!profile.stripAdditionalProperties) return schema
110
+ return deepStripSchema(schema)
111
+ }
112
+
113
+ function deepStripSchema(obj: unknown): any {
114
+ if (typeof obj !== "object" || obj === null) return obj
115
+ if (Array.isArray(obj)) return obj.map(deepStripSchema)
116
+ const result: any = {}
117
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
118
+ if (k === "additionalProperties" && v === false) continue
119
+ result[k] = deepStripSchema(v)
120
+ }
121
+ return result
122
+ }
123
+
124
+ // ─── Temperature constraints ──────────────────────────────────────────────────
125
+
126
+ /**
127
+ * Returns true when the model requires temperature=1.
128
+ * Used for Kimi K2 thinking mode which rejects any other temperature.
129
+ */
130
+ export function requiresTemperature1(provider: string, model: string): boolean {
131
+ if (FIXED_TEMPERATURE_1_MODELS.has(model)) return true
132
+ if (provider === "kimi") {
133
+ const m = model.toLowerCase()
134
+ if (m.includes("k2")) return true
135
+ }
136
+ return false
137
+ }
138
+
139
+ // ─── Message sanitization ─────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Remove tool_calls from assistant messages whose corresponding tool results
143
+ * are missing from the history (e.g. cleared by compaction). Providers like
144
+ * Kimi reject message sequences with orphaned tool_calls.
145
+ */
146
+ export function sanitizeMessages(messages: LLMMessage[]): LLMMessage[] {
147
+ // Pass 0: collect all tool_call_ids that appear in assistant messages.
148
+ const knownToolCallIds = new Set<string>()
149
+ for (const m of messages) {
150
+ if (m.role === "assistant" && m.tool_calls?.length) {
151
+ for (const tc of m.tool_calls) knownToolCallIds.add(tc.id)
152
+ }
153
+ }
154
+
155
+ // Pass 1: determine which tool_call_ids are "dead"
156
+ const deadIds = new Set<string>()
157
+
158
+ for (let i = 0; i < messages.length; i++) {
159
+ const m = messages[i]
160
+ if (m.role !== "assistant" || !m.tool_calls?.length) continue
161
+
162
+ const neededIds = new Set(m.tool_calls.map((tc) => tc.id))
163
+ let j = i + 1
164
+ while (j < messages.length && messages[j].role === "tool") {
165
+ if (messages[j].tool_call_id) neededIds.delete(messages[j].tool_call_id!)
166
+ j++
167
+ }
168
+ if (neededIds.size > 0) {
169
+ log.warn(`[llm-client] Stripping orphaned tool_calls (missing results for: ${[...neededIds].join(", ")})`)
170
+ for (const tc of m.tool_calls) deadIds.add(tc.id)
171
+ }
172
+ }
173
+
174
+ // Pass 2: rebuild message list, dropping/fixing affected messages
175
+ const result: LLMMessage[] = []
176
+ for (const m of messages) {
177
+ if (m.role === "tool" && m.tool_call_id) {
178
+ if (deadIds.has(m.tool_call_id) || !knownToolCallIds.has(m.tool_call_id)) {
179
+ log.warn(`[llm-client] Dropping orphaned tool result (tool_call_id: ${m.tool_call_id})`)
180
+ continue
181
+ }
182
+ }
183
+ if (m.role === "assistant" && m.tool_calls?.some((tc) => deadIds.has(tc.id))) {
184
+ const { tool_calls, ...rest } = m
185
+ const hasContent = typeof rest.content === "string"
186
+ ? rest.content.trim()
187
+ : Array.isArray(rest.content) && rest.content.length > 0
188
+ if (hasContent) result.push(rest as LLMMessage)
189
+ continue
190
+ }
191
+ result.push(m)
192
+ }
193
+
194
+ return result
195
+ }
@@ -0,0 +1,175 @@
1
+ import { logger } from "../../utils/logger.ts"
2
+ import { sanitizeMessages } from "./interface"
3
+ import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface"
4
+ import type { ContentPart, LLMMessage } from "./LLMClient.ts"
5
+
6
+ const log = logger.child("llm-client")
7
+
8
+ export class OllamaProvider implements LLMProvider {
9
+ private _convertMessage(msg: LLMMessage): any {
10
+ if (typeof msg.content === "string") {
11
+ return { role: msg.role, content: msg.content }
12
+ }
13
+
14
+ let textContent = ""
15
+ const images: string[] = []
16
+
17
+ for (const part of msg.content) {
18
+ if (part.type === "text") {
19
+ textContent += part.text
20
+ } else if (part.type === "image_base64") {
21
+ images.push(part.base64)
22
+ } else if (part.type === "document") {
23
+ textContent += `\n[Document: ${part.fileName || "file"}] (base64 content not displayed)`
24
+ } else if (part.type === "image_url") {
25
+ const url = part.image_url.url
26
+ if (url.startsWith("data:")) {
27
+ const match = url.match(/^data:([^;]+);base64,(.+)$/)
28
+ if (match) images.push(match[2])
29
+ } else {
30
+ textContent += `\n[Image URL: ${url}]`
31
+ }
32
+ }
33
+ }
34
+
35
+ return { role: msg.role, content: textContent.trim(), ...(images.length > 0 ? { images } : {}) }
36
+ }
37
+
38
+ async call(options: LLMCallOptions): Promise<LLMResponse> {
39
+ const { Ollama } = await import("ollama")
40
+
41
+ const modelName = options.model.replace(/^ollama\//, "")
42
+ const host = options.baseUrl?.trim() || process.env.OLLAMA_HOST || "http://localhost:11434"
43
+ const isCloud = host.includes("ollama.com")
44
+ const headers: Record<string, string> = {}
45
+ if (isCloud && options.apiKey) headers["Authorization"] = `Bearer ${options.apiKey}`
46
+
47
+ const client = new Ollama({
48
+ host,
49
+ ...(Object.keys(headers).length ? { headers } : {}),
50
+ })
51
+
52
+ const messages = sanitizeMessages(options.messages).map((m): any => {
53
+ if (m.role === "assistant" && m.tool_calls?.length) {
54
+ return {
55
+ role: "assistant",
56
+ content: typeof m.content === "string" ? m.content : m.content.map(p => (p as any).text || "").join(""),
57
+ tool_calls: m.tool_calls.map((tc) => ({
58
+ function: {
59
+ name: tc.function.name,
60
+ arguments: (() => {
61
+ try { return JSON.parse(tc.function.arguments) } catch { return {} }
62
+ })(),
63
+ },
64
+ })),
65
+ }
66
+ }
67
+ if (m.role === "tool") return { role: "tool", content: typeof m.content === "string" ? m.content : JSON.stringify(m.content) }
68
+ return this._convertMessage(m)
69
+ })
70
+
71
+ const tools = options.tools?.map((t) => ({
72
+ type: "function" as const,
73
+ function: {
74
+ name: t.function.name,
75
+ description: t.function.description,
76
+ parameters: t.function.parameters,
77
+ },
78
+ }))
79
+
80
+ // Default num_ctx to 4096 for local models — prevents OOM on small models (2B-7B)
81
+ // when Ollama's default (32k+) is too large for available RAM/VRAM.
82
+ // Users can override via providers.num_ctx in DB.
83
+ const runtimeOptions: Record<string, unknown> = {
84
+ num_ctx: options.numCtx ?? 4096,
85
+ }
86
+ if (options.numGpu !== undefined) runtimeOptions.num_gpu = options.numGpu
87
+ if (options.temperature !== undefined) runtimeOptions.temperature = options.temperature
88
+
89
+ try {
90
+
91
+ log.info(
92
+ `[llm-client] ollama/${modelName} @ ${isCloud ? "ollama.com" : host} stream=true` +
93
+ ` — ${messages.length} msgs, ${tools?.length ?? 0} tools` +
94
+ ` num_ctx=${runtimeOptions.num_ctx}`
95
+ )
96
+
97
+ const stream = await client.chat({
98
+ model: modelName,
99
+ messages,
100
+ tools: tools?.length ? tools : undefined,
101
+ options: Object.keys(runtimeOptions).length ? runtimeOptions : undefined,
102
+ stream: true,
103
+ })
104
+
105
+ let content = ""
106
+ let promptEvalCount = 0
107
+ let evalCount = 0
108
+ const tool_calls: LLMToolCall[] = []
109
+
110
+ for await (const part of stream) {
111
+ const delta = part.message?.content ?? ""
112
+ if (delta) {
113
+ content += delta
114
+ if (options.onToken) options.onToken(delta)
115
+ }
116
+
117
+ if (part.message?.tool_calls?.length) {
118
+ for (const tc of part.message.tool_calls) {
119
+ tool_calls.push({
120
+ id: crypto.randomUUID(),
121
+ type: "function" as const,
122
+ function: {
123
+ name: (tc as any).function.name,
124
+ arguments: JSON.stringify((tc as any).function.arguments ?? {}),
125
+ },
126
+ })
127
+ }
128
+ }
129
+
130
+ if (part.prompt_eval_count !== undefined) promptEvalCount = part.prompt_eval_count
131
+ if (part.eval_count !== undefined) evalCount = part.eval_count
132
+ }
133
+
134
+ return {
135
+ content,
136
+ tool_calls: tool_calls.length ? tool_calls : undefined,
137
+ stop_reason: tool_calls.length > 0 ? "tool_calls" : "stop",
138
+ usage:
139
+ evalCount > 0
140
+ ? { input_tokens: promptEvalCount, output_tokens: evalCount }
141
+ : undefined,
142
+ }
143
+ } catch (error: any) {
144
+ log.error(`[llm-client] FAILED call to ollama/${modelName} at ${host}`)
145
+ log.error(`[llm-client] Error details: ${error.message || error}`)
146
+ if (options.numCtx) log.error(`[llm-client] Context requested: num_ctx=${options.numCtx}`)
147
+ if (options.tools?.length) log.error(`[llm-client] Tools defined: ${options.tools.length}`)
148
+
149
+ // If the model runner crashed (likely OOM) and tools were sent, retry without tools.
150
+ // The model can still answer conversationally — tools will be unavailable this turn.
151
+ // Match by error message string OR HTTP 500 with tools (resilient to Ollama message changes).
152
+ if (
153
+ (error.message?.includes("model runner has unexpectedly stopped") || error.status === 500) &&
154
+ tools?.length
155
+ ) {
156
+ log.warn(`[llm-client] OOM with tools — retrying without tools (num_ctx=${runtimeOptions.num_ctx})`)
157
+ const stream2 = await client.chat({
158
+ model: modelName,
159
+ messages,
160
+ tools: undefined,
161
+ options: runtimeOptions,
162
+ stream: true,
163
+ })
164
+ let content = ""
165
+ for await (const part of stream2) {
166
+ const delta = part.message?.content ?? ""
167
+ if (delta) { content += delta; if (options.onToken) options.onToken(delta) }
168
+ }
169
+ return { content, tool_calls: undefined, stop_reason: "stop" }
170
+ }
171
+
172
+ throw error
173
+ }
174
+ }
175
+ }
@@ -0,0 +1,231 @@
1
+ import { logger } from "../../utils/logger.ts"
2
+ import {
3
+ sanitizeMessages, requiresTemperature1, OPENAI_COMPAT_BASE_URLS,
4
+ getProviderProfile, modelSupportsTools, normalizeToolName, normalizeToolSchema,
5
+ } from "./interface"
6
+ import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface"
7
+ import type { ContentPart, LLMMessage } from "./LLMClient.ts"
8
+
9
+ const log = logger.child("llm-client")
10
+
11
+ export class OpenAICompatProvider implements LLMProvider {
12
+ private _convertContentPart(part: ContentPart): any {
13
+ switch (part.type) {
14
+ case "text":
15
+ return { type: "text", text: part.text }
16
+ case "image_url":
17
+ return { type: "image_url", image_url: { url: part.image_url.url } }
18
+ case "image_base64":
19
+ return { type: "image_url", image_url: { url: `data:${part.mimeType};base64,${part.base64}` } }
20
+ case "document":
21
+ return { type: "text", text: `[Document: ${part.fileName || "file"}] (base64 content not displayed)` }
22
+ default:
23
+ return { type: "text", text: JSON.stringify(part) }
24
+ }
25
+ }
26
+
27
+ private _convertMessage(msg: LLMMessage): any {
28
+ if (Array.isArray(msg.content)) {
29
+ return { ...msg, content: msg.content.map(p => this._convertContentPart(p)) }
30
+ }
31
+ return msg
32
+ }
33
+
34
+ async call(options: LLMCallOptions): Promise<LLMResponse> {
35
+ const { default: OpenAI } = await import("openai")
36
+
37
+ const baseURL = options.baseUrl?.trim() || OPENAI_COMPAT_BASE_URLS[options.provider] || undefined
38
+ const isLocal = baseURL?.includes("localhost") || baseURL?.includes("127.0.0.1") || baseURL?.includes("::1")
39
+ const apiKey = options.apiKey || (isLocal ? "ollama" : undefined)
40
+
41
+ if (!apiKey) {
42
+ throw new Error(`API key missing for provider: ${options.provider}. Configure it in Settings → Providers.`)
43
+ }
44
+
45
+ const client = new OpenAI({ apiKey, baseURL })
46
+
47
+ const isKimi = options.provider === "kimi"
48
+ const isDeepSeek = options.provider === "deepseek"
49
+ // Kimi K2 and DeepSeek reasoner require reasoning_content to be round-tripped
50
+ const needsReasoningRoundtrip = isKimi || isDeepSeek
51
+
52
+ const sanitized = sanitizeMessages(options.messages)
53
+ const rawMessages = needsReasoningRoundtrip
54
+ ? sanitized
55
+ : sanitized.map(({ reasoning_content: _rc, ...rest }) => rest as typeof sanitized[number])
56
+ const messagesForProvider = rawMessages.map(m => this._convertMessage(m))
57
+
58
+ const providerPrefix = new RegExp(`^${options.provider}\\/`, "i")
59
+ const body: any = {
60
+ model: options.model.replace(providerPrefix, ""),
61
+ messages: messagesForProvider,
62
+ temperature: requiresTemperature1(options.provider, options.model) ? 1 : (options.temperature ?? 0.7),
63
+ }
64
+ if (options.maxTokens) body.max_tokens = options.maxTokens
65
+ if (options.numCtx && isLocal) body.num_ctx = options.numCtx
66
+
67
+ // Per-provider profile drives tool call behavior
68
+ const profile = getProviderProfile(options.provider)
69
+ const sendTools = modelSupportsTools(options.provider, options.model) && !!(options.tools?.length)
70
+
71
+ // Map from wire name (normalized) → original name for denormalizing responses
72
+ const toolNameMap = new Map<string, string>()
73
+
74
+ if (sendTools) {
75
+ const preparedTools = options.tools!.map((t) => {
76
+ const originalName = t.function.name
77
+ const wireName = profile.normalizeToolNames
78
+ ? normalizeToolName(originalName, profile.toolNameReplacement)
79
+ : originalName
80
+ if (wireName !== originalName) toolNameMap.set(wireName, originalName)
81
+ return {
82
+ ...t,
83
+ function: {
84
+ ...t.function,
85
+ name: wireName,
86
+ parameters: normalizeToolSchema(t.function.parameters as Record<string, unknown>, profile),
87
+ },
88
+ }
89
+ })
90
+ body.tools = preparedTools
91
+ body.tool_choice = profile.toolChoiceAuto
92
+ if (profile.disableParallelToolCalls) body.parallel_tool_calls = false
93
+ }
94
+
95
+ log.info(`[llm-client] ${options.provider}/${body.model} — ${options.messages.length} msgs, ${options.tools?.length ?? 0} tools${sendTools ? "" : " (tools suppressed)"}`)
96
+
97
+ if (options.onToken) {
98
+ return this._streamCall(client, body, options, toolNameMap, sendTools, profile)
99
+ }
100
+
101
+ let response
102
+ try {
103
+ response = await client.chat.completions.create(body)
104
+ } catch (err: any) {
105
+ const status = err?.status ?? err?.response?.status
106
+ if (sendTools && profile.retryWithoutToolsOnCodes.includes(status)) {
107
+ log.warn(`[llm-client] ${options.provider}: tools rejected (HTTP ${status}) — retrying without tools`)
108
+ const bodyNoTools = { ...body }
109
+ delete bodyNoTools.tools
110
+ delete bodyNoTools.tool_choice
111
+ delete bodyNoTools.parallel_tool_calls
112
+ response = await client.chat.completions.create(bodyNoTools)
113
+ } else {
114
+ throw err
115
+ }
116
+ }
117
+
118
+ const choice = response.choices[0]
119
+ const msg = choice.message
120
+
121
+ const tool_calls: LLMToolCall[] | undefined = (msg.tool_calls as any[])?.map((tc: any) => ({
122
+ id: tc.id,
123
+ type: "function" as const,
124
+ function: {
125
+ name: toolNameMap.get(tc.function.name) ?? tc.function.name,
126
+ arguments: tc.function.arguments,
127
+ },
128
+ }))
129
+
130
+ return {
131
+ content: msg.content ?? "",
132
+ tool_calls: tool_calls?.length ? tool_calls : undefined,
133
+ reasoning_content: (msg as any).reasoning_content ?? undefined,
134
+ stop_reason:
135
+ choice.finish_reason === "tool_calls" ? "tool_calls"
136
+ : choice.finish_reason === "length" ? "max_tokens"
137
+ : "stop",
138
+ usage: response.usage ? {
139
+ input_tokens: response.usage.prompt_tokens,
140
+ output_tokens: response.usage.completion_tokens,
141
+ } : undefined,
142
+ }
143
+ }
144
+
145
+ private async _streamCall(
146
+ client: any,
147
+ body: any,
148
+ options: LLMCallOptions,
149
+ toolNameMap: Map<string, string>,
150
+ sendTools: boolean,
151
+ profile: ReturnType<typeof getProviderProfile>,
152
+ ): Promise<LLMResponse> {
153
+ let stream
154
+ try {
155
+ stream = await client.chat.completions.create({ ...body, stream: true })
156
+ } catch (err: any) {
157
+ const status = err?.status ?? err?.response?.status
158
+ if (sendTools && profile.retryWithoutToolsOnCodes.includes(status)) {
159
+ log.warn(`[llm-client] ${options.provider}: tools rejected (HTTP ${status}) — retrying stream without tools`)
160
+ const bodyNoTools = { ...body }
161
+ delete bodyNoTools.tools
162
+ delete bodyNoTools.tool_choice
163
+ delete bodyNoTools.parallel_tool_calls
164
+ stream = await client.chat.completions.create({ ...bodyNoTools, stream: true })
165
+ } else {
166
+ throw err
167
+ }
168
+ }
169
+
170
+ let content = ""
171
+ let reasoning_content = ""
172
+ let finish_reason = "stop"
173
+ const toolCallMap: Map<number, { id: string; name: string; arguments: string }> = new Map()
174
+ let input_tokens = 0
175
+ let output_tokens = 0
176
+
177
+ for await (const chunk of stream) {
178
+ const choice = chunk.choices?.[0]
179
+ if (!choice) continue
180
+
181
+ const delta = choice.delta as any
182
+ if (delta.content) {
183
+ content += delta.content
184
+ options.onToken!(delta.content)
185
+ }
186
+ if (delta.reasoning_content) {
187
+ reasoning_content += delta.reasoning_content
188
+ }
189
+ if (delta.tool_calls) {
190
+ for (const tc of delta.tool_calls) {
191
+ const idx: number = tc.index
192
+ if (!toolCallMap.has(idx)) {
193
+ toolCallMap.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", arguments: "" })
194
+ }
195
+ const entry = toolCallMap.get(idx)!
196
+ if (tc.id) entry.id = tc.id
197
+ if (tc.function?.name) entry.name = tc.function.name
198
+ if (tc.function?.arguments) entry.arguments += tc.function.arguments
199
+ }
200
+ }
201
+ if (choice.finish_reason) finish_reason = choice.finish_reason
202
+
203
+ if (chunk.usage) {
204
+ input_tokens = chunk.usage.prompt_tokens ?? 0
205
+ output_tokens = chunk.usage.completion_tokens ?? 0
206
+ }
207
+ }
208
+
209
+ const tool_calls: LLMToolCall[] = [...toolCallMap.values()].map((tc) => ({
210
+ id: tc.id,
211
+ type: "function" as const,
212
+ function: {
213
+ name: toolNameMap.get(tc.name) ?? tc.name,
214
+ arguments: tc.arguments || "{}",
215
+ },
216
+ }))
217
+
218
+ return {
219
+ content,
220
+ tool_calls: tool_calls.length ? tool_calls : undefined,
221
+ reasoning_content: reasoning_content || undefined,
222
+ stop_reason:
223
+ finish_reason === "tool_calls" ? "tool_calls"
224
+ : finish_reason === "length" ? "max_tokens"
225
+ : "stop",
226
+ usage: input_tokens > 0 || output_tokens > 0
227
+ ? { input_tokens, output_tokens }
228
+ : undefined,
229
+ }
230
+ }
231
+ }
@@ -0,0 +1 @@
1
+ export * from "./providers/index.ts";