@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.
- package/.github/CODEOWNERS +9 -0
- package/.github/workflows/publish.yml +89 -0
- package/.github/workflows/version-bump.yml +102 -0
- package/CHANGELOG.md +38 -0
- package/README.md +158 -0
- package/bun.lock +543 -0
- package/bunfig.toml +7 -0
- package/docs/API-AGENTS.md +316 -0
- package/docs/API-CONTEXT-COMPILER.md +252 -0
- package/docs/API-DAG-SCHEDULER.md +273 -0
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +293 -0
- package/docs/API-WORKERS-EVENTS.md +152 -0
- package/docs/INDEX.md +141 -0
- package/docs/README.md +68 -0
- package/package.json +54 -105
- package/packages/cli/package.json +17 -0
- package/packages/cli/src/commands/init.ts +56 -0
- package/packages/cli/src/commands/run.ts +45 -0
- package/packages/cli/src/commands/test.ts +42 -0
- package/packages/cli/src/commands/trace.ts +55 -0
- package/packages/cli/src/index.ts +43 -0
- package/packages/core/package.json +58 -0
- package/packages/core/src/ace/Curator.ts +158 -0
- package/packages/core/src/ace/Reflector.ts +200 -0
- package/packages/core/src/ace/Tracer.ts +100 -0
- package/packages/core/src/ace/index.ts +4 -0
- package/packages/core/src/agent/AgentRunner.ts +699 -0
- package/packages/core/src/agent/Compaction.ts +221 -0
- package/packages/core/src/agent/ContextCompiler.ts +567 -0
- package/packages/core/src/agent/ContextGuard.ts +91 -0
- package/packages/core/src/agent/ConversationStore.ts +244 -0
- package/packages/core/src/agent/Hooks.ts +166 -0
- package/packages/core/src/agent/NativeTools.ts +31 -0
- package/packages/core/src/agent/PromptBuilder.ts +169 -0
- package/packages/core/src/agent/Service.ts +267 -0
- package/packages/core/src/agent/StuckLoop.ts +133 -0
- package/packages/core/src/agent/index.ts +12 -0
- package/packages/core/src/agent/providers/LLMClient.ts +149 -0
- package/packages/core/src/agent/providers/anthropic.ts +212 -0
- package/packages/core/src/agent/providers/gemini.ts +215 -0
- package/packages/core/src/agent/providers/index.ts +199 -0
- package/packages/core/src/agent/providers/interface.ts +195 -0
- package/packages/core/src/agent/providers/ollama.ts +175 -0
- package/packages/core/src/agent/providers/openai-compat.ts +231 -0
- package/packages/core/src/agent/providers.ts +1 -0
- package/packages/core/src/agent/selectors/PlaybookSelector.ts +147 -0
- package/packages/core/src/agent/selectors/SkillSelector.ts +478 -0
- package/packages/core/src/agent/selectors/ToolSelector.ts +577 -0
- package/packages/core/src/agent/selectors/index.ts +6 -0
- package/packages/core/src/api/createAgent.test.ts +48 -0
- package/packages/core/src/api/createAgent.ts +122 -0
- package/packages/core/src/api/index.ts +2 -0
- package/packages/core/src/canvas/CanvasManager.ts +390 -0
- package/packages/core/src/canvas/a2ui-tools.ts +255 -0
- package/packages/core/src/canvas/canvas-tools.ts +448 -0
- package/packages/core/src/canvas/emitter.ts +149 -0
- package/packages/core/src/canvas/index.ts +6 -0
- package/packages/core/src/config/index.ts +2 -0
- package/packages/core/src/config/loader.ts +554 -0
- package/packages/core/src/ethics/EthicsGuard.test.ts +54 -0
- package/packages/core/src/ethics/EthicsGuard.ts +66 -0
- package/packages/core/src/ethics/index.ts +2 -0
- package/packages/core/src/gateway/channel-notify.test.ts +14 -0
- package/packages/core/src/gateway/channel-notify.ts +12 -0
- package/packages/core/src/gateway/index.ts +1 -0
- package/packages/core/src/index.ts +37 -0
- package/packages/core/src/mcp/MCPClient.ts +439 -0
- package/packages/core/src/mcp/MCPToolAdapter.ts +176 -0
- package/packages/core/src/mcp/config.ts +13 -0
- package/packages/core/src/mcp/hot-reload.ts +147 -0
- package/packages/core/src/mcp/index.ts +11 -0
- package/packages/core/src/mcp/logger.ts +42 -0
- package/packages/core/src/mcp/singleton.ts +21 -0
- package/packages/core/src/mcp/transports/index.ts +67 -0
- package/packages/core/src/mcp/transports/sse.ts +241 -0
- package/packages/core/src/mcp/transports/websocket.ts +159 -0
- package/packages/core/src/memory/Scratchpad.test.ts +47 -0
- package/packages/core/src/memory/Scratchpad.ts +37 -0
- package/packages/core/src/memory/Storage.ts +6 -0
- package/packages/core/src/memory/index.ts +2 -0
- package/packages/core/src/multimodal/VisionService.ts +293 -0
- package/packages/core/src/multimodal/index.ts +2 -0
- package/packages/core/src/multimodal/types.ts +28 -0
- package/packages/core/src/security/Pairing.ts +250 -0
- package/packages/core/src/security/RateLimit.ts +270 -0
- package/packages/core/src/security/index.ts +4 -0
- package/packages/core/src/skills/SkillLoader.ts +388 -0
- package/packages/core/src/skills/bundled-data.generated.ts +3332 -0
- package/packages/core/src/skills/defineSkill.ts +18 -0
- package/packages/core/src/skills/index.ts +4 -0
- package/packages/core/src/state/index.ts +2 -0
- package/packages/core/src/state/store.ts +312 -0
- package/packages/core/src/storage/SQLiteStorage.ts +407 -0
- package/packages/core/src/storage/crypto.ts +101 -0
- package/packages/core/src/storage/index.ts +10 -0
- package/packages/core/src/storage/onboarding.ts +1603 -0
- package/packages/core/src/storage/schema.ts +689 -0
- package/packages/core/src/storage/seed.ts +740 -0
- package/packages/core/src/storage/usage.ts +374 -0
- package/packages/core/src/swarm/AgentBus.ts +460 -0
- package/packages/core/src/swarm/AgentExecutor.ts +53 -0
- package/packages/core/src/swarm/Coordinator.ts +251 -0
- package/packages/core/src/swarm/EventBridge.ts +122 -0
- package/packages/core/src/swarm/EventBus.ts +169 -0
- package/packages/core/src/swarm/TaskGraph.ts +192 -0
- package/packages/core/src/swarm/TaskNode.ts +97 -0
- package/packages/core/src/swarm/TaskResult.ts +22 -0
- package/packages/core/src/swarm/WorkerPool.ts +236 -0
- package/packages/core/src/swarm/errors.ts +37 -0
- package/packages/core/src/swarm/index.ts +30 -0
- package/packages/core/src/swarm/presets/HiveLearnPreset.ts +99 -0
- package/packages/core/src/swarm/presets/ResearchPreset.ts +97 -0
- package/packages/core/src/swarm/presets/index.ts +4 -0
- package/packages/core/src/swarm/strategies/ParallelStrategy.ts +21 -0
- package/packages/core/src/swarm/strategies/PriorityStrategy.ts +46 -0
- package/packages/core/src/swarm/strategies/index.ts +3 -0
- package/packages/core/src/swarm/types.ts +164 -0
- package/packages/core/src/tools/ToolExecutor.ts +58 -0
- package/packages/core/src/tools/ToolRegistry.test.ts +98 -0
- package/packages/core/src/tools/ToolRegistry.ts +61 -0
- package/packages/core/src/tools/agents/get-available-models.ts +118 -0
- package/packages/core/src/tools/agents/index.ts +715 -0
- package/packages/core/src/tools/bridge-events.ts +26 -0
- package/packages/core/src/tools/canvas/index.ts +375 -0
- package/packages/core/src/tools/cli/index.ts +142 -0
- package/packages/core/src/tools/codebridge/index.ts +342 -0
- package/packages/core/src/tools/core/index.ts +476 -0
- package/packages/core/src/tools/cron/index.ts +626 -0
- package/packages/core/src/tools/filesystem/fs-delete.ts +78 -0
- package/packages/core/src/tools/filesystem/fs-edit.ts +106 -0
- package/packages/core/src/tools/filesystem/fs-exists.ts +63 -0
- package/packages/core/src/tools/filesystem/fs-glob.ts +108 -0
- package/packages/core/src/tools/filesystem/fs-list.ts +129 -0
- package/packages/core/src/tools/filesystem/fs-read.ts +72 -0
- package/packages/core/src/tools/filesystem/fs-write.ts +67 -0
- package/packages/core/src/tools/filesystem/index.ts +34 -0
- package/packages/core/src/tools/filesystem/workspace-guard.ts +62 -0
- package/packages/core/src/tools/index.ts +231 -0
- package/packages/core/src/tools/meeting/index.ts +363 -0
- package/packages/core/src/tools/office/index.ts +47 -0
- package/packages/core/src/tools/office/office-escribir-docx.ts +192 -0
- package/packages/core/src/tools/office/office-escribir-pdf.ts +172 -0
- package/packages/core/src/tools/office/office-escribir-pptx.ts +174 -0
- package/packages/core/src/tools/office/office-escribir-xlsx.ts +116 -0
- package/packages/core/src/tools/office/office-leer-docx.ts +93 -0
- package/packages/core/src/tools/office/office-leer-pdf.ts +114 -0
- package/packages/core/src/tools/office/office-leer-pptx.ts +136 -0
- package/packages/core/src/tools/office/office-leer-xlsx.ts +124 -0
- package/packages/core/src/tools/projects/index.ts +37 -0
- package/packages/core/src/tools/projects/project-create.ts +94 -0
- package/packages/core/src/tools/projects/project-done.ts +66 -0
- package/packages/core/src/tools/projects/project-fail.ts +66 -0
- package/packages/core/src/tools/projects/project-list.ts +96 -0
- package/packages/core/src/tools/projects/project-update.ts +72 -0
- package/packages/core/src/tools/projects/task-create.ts +68 -0
- package/packages/core/src/tools/projects/task-evaluate.ts +93 -0
- package/packages/core/src/tools/projects/task-update.ts +93 -0
- package/packages/core/src/tools/types.ts +39 -0
- package/packages/core/src/tools/voice/index.ts +104 -0
- package/packages/core/src/tools/web/browser-click.ts +78 -0
- package/packages/core/src/tools/web/browser-extract.ts +139 -0
- package/packages/core/src/tools/web/browser-navigate.ts +106 -0
- package/packages/core/src/tools/web/browser-screenshot.ts +87 -0
- package/packages/core/src/tools/web/browser-script.ts +88 -0
- package/packages/core/src/tools/web/browser-service.ts +554 -0
- package/packages/core/src/tools/web/browser-type.ts +101 -0
- package/packages/core/src/tools/web/browser-wait.ts +136 -0
- package/packages/core/src/tools/web/index.ts +41 -0
- package/packages/core/src/tools/web/web-fetch.ts +78 -0
- package/packages/core/src/tools/web/web-search.ts +123 -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 +10 -0
- package/packages/core/src/utils/logger.ts +389 -0
- package/packages/core/src/utils/retry.ts +70 -0
- package/packages/core/src/utils/toon.ts +253 -0
- package/packages/core/src/voice/index.ts +656 -0
- package/test/setup-db.ts +216 -0
- package/tsconfig.json +39 -0
- package/src/agents.ts +0 -1
- package/src/canvas.ts +0 -1
- package/src/channels.ts +0 -1
- package/src/config.ts +0 -1
- package/src/events.ts +0 -1
- package/src/gateway.ts +0 -1
- package/src/index.ts +0 -304
- package/src/mcp.ts +0 -1
- package/src/multimodal.ts +0 -1
- package/src/scheduler.ts +0 -1
- package/src/security.ts +0 -1
- package/src/skills.ts +0 -1
- package/src/state.ts +0 -1
- package/src/storage.ts +0 -1
- package/src/tools.ts +0 -1
- package/src/tts.ts +0 -1
- package/src/types.ts +0 -82
- package/src/utils.ts +0 -1
- 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";
|