neuro-cli 4.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/LICENSE +21 -0
- package/README.md +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- package/package.json +83 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// NeuroCLI - Ollama (Local Model) Provider
|
|
3
|
+
// Supports Ollama API + OpenAI-compatible endpoints
|
|
4
|
+
// Streaming, Tool Use, Embeddings, Model Management
|
|
5
|
+
// ============================================================
|
|
6
|
+
export const DEFAULT_OLLAMA_CONFIG = {
|
|
7
|
+
baseUrl: 'http://localhost:11434',
|
|
8
|
+
defaultModel: 'llama3',
|
|
9
|
+
timeout: 120000,
|
|
10
|
+
maxRetries: 1,
|
|
11
|
+
};
|
|
12
|
+
// ----------------------------------------------------------------
|
|
13
|
+
// Error Classes
|
|
14
|
+
// ----------------------------------------------------------------
|
|
15
|
+
export class OllamaProviderError extends Error {
|
|
16
|
+
statusCode;
|
|
17
|
+
body;
|
|
18
|
+
constructor(message, statusCode, body) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.statusCode = statusCode;
|
|
21
|
+
this.body = body;
|
|
22
|
+
this.name = 'OllamaProviderError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class OllamaTimeoutError extends OllamaProviderError {
|
|
26
|
+
constructor(timeout) {
|
|
27
|
+
super(`Request timed out after ${timeout}ms`);
|
|
28
|
+
this.name = 'OllamaTimeoutError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class OllamaConnectionError extends OllamaProviderError {
|
|
32
|
+
constructor(baseUrl, cause) {
|
|
33
|
+
super(`Cannot connect to Ollama at ${baseUrl}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
34
|
+
this.name = 'OllamaConnectionError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ----------------------------------------------------------------
|
|
38
|
+
// OllamaProvider
|
|
39
|
+
// ----------------------------------------------------------------
|
|
40
|
+
export class OllamaProvider {
|
|
41
|
+
config;
|
|
42
|
+
mode = 'ollama';
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = { ...DEFAULT_OLLAMA_CONFIG, ...config };
|
|
45
|
+
this.detectMode();
|
|
46
|
+
}
|
|
47
|
+
// ==============================================================
|
|
48
|
+
// Public API
|
|
49
|
+
// ==============================================================
|
|
50
|
+
/**
|
|
51
|
+
* Chat completion with optional streaming.
|
|
52
|
+
*
|
|
53
|
+
* When callbacks are provided and stream is not explicitly false,
|
|
54
|
+
* the response is streamed token-by-token via the callbacks.
|
|
55
|
+
*/
|
|
56
|
+
async chat(messages, options, callbacks) {
|
|
57
|
+
const model = options?.model ?? this.config.defaultModel;
|
|
58
|
+
const shouldStream = options?.stream !== false;
|
|
59
|
+
if (this.mode === 'ollama') {
|
|
60
|
+
return this.chatOllama(model, messages, options, shouldStream, callbacks);
|
|
61
|
+
}
|
|
62
|
+
return this.chatOpenAICompatible(model, messages, options, shouldStream, callbacks);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* List locally available models.
|
|
66
|
+
*/
|
|
67
|
+
async listModels() {
|
|
68
|
+
if (this.mode === 'ollama') {
|
|
69
|
+
return this.listModelsOllama();
|
|
70
|
+
}
|
|
71
|
+
return this.listModelsOpenAICompatible();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Health check -- returns true if the endpoint is reachable.
|
|
75
|
+
*/
|
|
76
|
+
async isAvailable() {
|
|
77
|
+
try {
|
|
78
|
+
if (this.mode === 'ollama') {
|
|
79
|
+
const response = await this.fetch(`${this.config.baseUrl}`, {
|
|
80
|
+
method: 'GET',
|
|
81
|
+
signal: AbortSignal.timeout(5000),
|
|
82
|
+
});
|
|
83
|
+
return response.ok;
|
|
84
|
+
}
|
|
85
|
+
// OpenAI-compatible: try /v1/models
|
|
86
|
+
const response = await this.fetch(`${this.config.baseUrl}/v1/models`, {
|
|
87
|
+
method: 'GET',
|
|
88
|
+
signal: AbortSignal.timeout(5000),
|
|
89
|
+
});
|
|
90
|
+
return response.ok;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Generate embeddings for a prompt using the Ollama native API.
|
|
98
|
+
* Not supported on OpenAI-compatible endpoints through this method.
|
|
99
|
+
*/
|
|
100
|
+
async generateEmbeddings(prompt, model) {
|
|
101
|
+
if (this.mode !== 'ollama') {
|
|
102
|
+
throw new OllamaProviderError('Embeddings are only supported when using the native Ollama API. ' +
|
|
103
|
+
'Switch baseUrl to an Ollama endpoint to use embeddings.');
|
|
104
|
+
}
|
|
105
|
+
const body = {
|
|
106
|
+
model: model ?? this.config.defaultModel,
|
|
107
|
+
prompt,
|
|
108
|
+
};
|
|
109
|
+
const data = await this.request('/api/embeddings', 'POST', body);
|
|
110
|
+
return data.embedding;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Pull a model from the Ollama registry.
|
|
114
|
+
* Returns true on success.
|
|
115
|
+
*/
|
|
116
|
+
async pullModel(name) {
|
|
117
|
+
if (this.mode !== 'ollama') {
|
|
118
|
+
throw new OllamaProviderError('Model pulling is only supported when using the native Ollama API.');
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const response = await this.fetch(`${this.config.baseUrl}/api/pull`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ name, stream: false }),
|
|
125
|
+
signal: AbortSignal.timeout(Math.max(this.config.timeout, 600000)), // 10 min floor for pulls
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
const body = await response.text();
|
|
129
|
+
throw new OllamaProviderError(`Failed to pull model "${name}": ${response.status} ${body}`, response.status, body);
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof OllamaProviderError)
|
|
135
|
+
throw error;
|
|
136
|
+
throw new OllamaConnectionError(this.config.baseUrl, error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Estimate token count for a text string.
|
|
141
|
+
*
|
|
142
|
+
* Uses a heuristic based on the model family. Ollama does not expose
|
|
143
|
+
* a tokenization endpoint, so this is an approximation:
|
|
144
|
+
* - For most models: ~4 characters per token (GPT-style)
|
|
145
|
+
* - For CJK-heavy text: ~2 characters per token
|
|
146
|
+
* - A slight overhead is added for special tokens / formatting.
|
|
147
|
+
*/
|
|
148
|
+
estimateTokens(text) {
|
|
149
|
+
if (!text)
|
|
150
|
+
return 0;
|
|
151
|
+
// Detect proportion of CJK characters
|
|
152
|
+
const cjkPattern = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g;
|
|
153
|
+
const cjkMatches = text.match(cjkPattern);
|
|
154
|
+
const cjkRatio = cjkMatches ? cjkMatches.length / text.length : 0;
|
|
155
|
+
// Blend between the two heuristics
|
|
156
|
+
const charsPerToken = 4 - 2 * cjkRatio; // 4 for pure ASCII, 2 for pure CJK
|
|
157
|
+
const baseEstimate = Math.ceil(text.length / charsPerToken);
|
|
158
|
+
// Add ~5% overhead for special tokens, formatting, etc.
|
|
159
|
+
return Math.ceil(baseEstimate * 1.05);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Get the current configuration (read-only copy).
|
|
163
|
+
*/
|
|
164
|
+
getConfig() {
|
|
165
|
+
return { ...this.config };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Update configuration. Merges with existing config.
|
|
169
|
+
* Re-detects provider mode if baseUrl changes.
|
|
170
|
+
*/
|
|
171
|
+
updateConfig(updates) {
|
|
172
|
+
const oldBaseUrl = this.config.baseUrl;
|
|
173
|
+
this.config = { ...this.config, ...updates };
|
|
174
|
+
if (updates.baseUrl && updates.baseUrl !== oldBaseUrl) {
|
|
175
|
+
this.detectMode();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ==============================================================
|
|
179
|
+
// Ollama Native Chat
|
|
180
|
+
// ==============================================================
|
|
181
|
+
async chatOllama(model, messages, options, shouldStream, callbacks) {
|
|
182
|
+
const ollamaMessages = this.convertMessages(messages);
|
|
183
|
+
const body = {
|
|
184
|
+
model,
|
|
185
|
+
messages: ollamaMessages,
|
|
186
|
+
stream: shouldStream,
|
|
187
|
+
options: {
|
|
188
|
+
temperature: options?.temperature ?? this.config.temperature,
|
|
189
|
+
num_predict: options?.maxTokens ?? this.config.maxTokens,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
// Add tools if provided and supported
|
|
193
|
+
if (options?.tools && options.tools.length > 0) {
|
|
194
|
+
body.tools = this.convertTools(options.tools);
|
|
195
|
+
}
|
|
196
|
+
let lastError = null;
|
|
197
|
+
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
198
|
+
if (attempt > 0) {
|
|
199
|
+
const waitTime = Math.min(2000 * Math.pow(2, attempt - 1), 10000);
|
|
200
|
+
await this.sleep(waitTime);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
if (shouldStream && callbacks) {
|
|
204
|
+
return await this.streamOllamaChat(body, callbacks);
|
|
205
|
+
}
|
|
206
|
+
const data = await this.request('/api/chat', 'POST', body);
|
|
207
|
+
return this.normalizeOllamaResponse(data, callbacks);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
lastError = error;
|
|
211
|
+
if (!this.isRetryable(error))
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw lastError ?? new OllamaProviderError('Max retries exceeded');
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Stream an Ollama native /api/chat response (NDJSON).
|
|
219
|
+
*/
|
|
220
|
+
async streamOllamaChat(body, callbacks) {
|
|
221
|
+
const response = await this.fetch(`${this.config.baseUrl}/api/chat`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify(body),
|
|
225
|
+
signal: AbortSignal.timeout(this.config.timeout),
|
|
226
|
+
});
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
const text = await response.text();
|
|
229
|
+
throw new OllamaProviderError(`Ollama chat error (${response.status}): ${text}`, response.status, text);
|
|
230
|
+
}
|
|
231
|
+
const reader = response.body?.getReader();
|
|
232
|
+
if (!reader)
|
|
233
|
+
throw new OllamaProviderError('No response body for streaming');
|
|
234
|
+
const decoder = new TextDecoder();
|
|
235
|
+
let fullContent = '';
|
|
236
|
+
let model = '';
|
|
237
|
+
let totalDuration = 0;
|
|
238
|
+
let evalCount = 0;
|
|
239
|
+
let evalDuration = 0;
|
|
240
|
+
const toolCalls = [];
|
|
241
|
+
let buffer = '';
|
|
242
|
+
try {
|
|
243
|
+
while (true) {
|
|
244
|
+
const { done, value } = await reader.read();
|
|
245
|
+
if (done)
|
|
246
|
+
break;
|
|
247
|
+
buffer += decoder.decode(value, { stream: true });
|
|
248
|
+
// NDJSON: each line is a JSON object
|
|
249
|
+
const lines = buffer.split('\n');
|
|
250
|
+
buffer = lines.pop() ?? ''; // keep incomplete line in buffer
|
|
251
|
+
for (const line of lines) {
|
|
252
|
+
const trimmed = line.trim();
|
|
253
|
+
if (!trimmed)
|
|
254
|
+
continue;
|
|
255
|
+
try {
|
|
256
|
+
const chunk = JSON.parse(trimmed);
|
|
257
|
+
// Accumulate metadata from all chunks
|
|
258
|
+
model = chunk.model || model;
|
|
259
|
+
// Stream content tokens
|
|
260
|
+
if (chunk.message?.content) {
|
|
261
|
+
fullContent += chunk.message.content;
|
|
262
|
+
callbacks.onToken?.(chunk.message.content);
|
|
263
|
+
}
|
|
264
|
+
// Handle tool calls
|
|
265
|
+
if (chunk.message?.tool_calls) {
|
|
266
|
+
for (const tc of chunk.message.tool_calls) {
|
|
267
|
+
const toolCall = {
|
|
268
|
+
id: `tc_${toolCalls.length}`,
|
|
269
|
+
type: 'function',
|
|
270
|
+
function: {
|
|
271
|
+
name: tc.function.name,
|
|
272
|
+
arguments: typeof tc.function.arguments === 'string'
|
|
273
|
+
? tc.function.arguments
|
|
274
|
+
: JSON.stringify(tc.function.arguments),
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
toolCalls.push(toolCall);
|
|
278
|
+
callbacks.onToolCall?.(toolCall);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Final chunk has stats
|
|
282
|
+
if (chunk.done) {
|
|
283
|
+
totalDuration = chunk.total_duration ?? 0;
|
|
284
|
+
evalCount = chunk.eval_count ?? 0;
|
|
285
|
+
evalDuration = chunk.eval_duration ?? 0;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// Skip malformed NDJSON lines
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Process any remaining buffer
|
|
294
|
+
if (buffer.trim()) {
|
|
295
|
+
try {
|
|
296
|
+
const chunk = JSON.parse(buffer.trim());
|
|
297
|
+
if (chunk.message?.content) {
|
|
298
|
+
fullContent += chunk.message.content;
|
|
299
|
+
callbacks.onToken?.(chunk.message.content);
|
|
300
|
+
}
|
|
301
|
+
if (chunk.done) {
|
|
302
|
+
totalDuration = chunk.total_duration ?? 0;
|
|
303
|
+
evalCount = chunk.eval_count ?? 0;
|
|
304
|
+
evalDuration = chunk.eval_duration ?? 0;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Ignore trailing malformed data
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
callbacks.onError?.(error);
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
// Append tool calls information to content if any
|
|
317
|
+
let finalContent = fullContent;
|
|
318
|
+
if (toolCalls.length > 0 && !fullContent) {
|
|
319
|
+
finalContent = JSON.stringify(toolCalls.map(tc => ({
|
|
320
|
+
name: tc.function.name,
|
|
321
|
+
arguments: tc.function.arguments,
|
|
322
|
+
})));
|
|
323
|
+
}
|
|
324
|
+
const result = {
|
|
325
|
+
content: finalContent,
|
|
326
|
+
model,
|
|
327
|
+
totalDuration,
|
|
328
|
+
evalCount,
|
|
329
|
+
evalDuration,
|
|
330
|
+
};
|
|
331
|
+
callbacks.onComplete?.(result);
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Normalize a non-streaming Ollama /api/chat response.
|
|
336
|
+
*/
|
|
337
|
+
normalizeOllamaResponse(data, callbacks) {
|
|
338
|
+
let content = data.message?.content ?? '';
|
|
339
|
+
// If there are tool calls and no text content, serialize the tool calls
|
|
340
|
+
if (data.message?.tool_calls && data.message.tool_calls.length > 0 && !content) {
|
|
341
|
+
content = JSON.stringify(data.message.tool_calls.map((tc) => ({
|
|
342
|
+
name: tc.function.name,
|
|
343
|
+
arguments: tc.function.arguments,
|
|
344
|
+
})));
|
|
345
|
+
}
|
|
346
|
+
const result = {
|
|
347
|
+
content,
|
|
348
|
+
model: data.model,
|
|
349
|
+
totalDuration: data.total_duration ?? 0,
|
|
350
|
+
evalCount: data.eval_count ?? 0,
|
|
351
|
+
evalDuration: data.eval_duration ?? 0,
|
|
352
|
+
};
|
|
353
|
+
callbacks?.onComplete?.(result);
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
// ==============================================================
|
|
357
|
+
// OpenAI-Compatible Chat
|
|
358
|
+
// ==============================================================
|
|
359
|
+
async chatOpenAICompatible(model, messages, options, shouldStream, callbacks) {
|
|
360
|
+
const openaiMessages = messages.map(m => {
|
|
361
|
+
const msg = {
|
|
362
|
+
role: m.role,
|
|
363
|
+
content: m.content,
|
|
364
|
+
};
|
|
365
|
+
if (m.toolCalls) {
|
|
366
|
+
msg.tool_calls = m.toolCalls;
|
|
367
|
+
}
|
|
368
|
+
if (m.toolCallId) {
|
|
369
|
+
msg.tool_call_id = m.toolCallId;
|
|
370
|
+
}
|
|
371
|
+
return msg;
|
|
372
|
+
});
|
|
373
|
+
const body = {
|
|
374
|
+
model,
|
|
375
|
+
messages: openaiMessages,
|
|
376
|
+
stream: shouldStream,
|
|
377
|
+
temperature: options?.temperature ?? this.config.temperature ?? 0.7,
|
|
378
|
+
max_tokens: options?.maxTokens ?? this.config.maxTokens,
|
|
379
|
+
};
|
|
380
|
+
// Add tools in OpenAI format
|
|
381
|
+
if (options?.tools && options.tools.length > 0) {
|
|
382
|
+
body.tools = options.tools.map(t => ({
|
|
383
|
+
type: 'function',
|
|
384
|
+
function: {
|
|
385
|
+
name: t.name,
|
|
386
|
+
description: t.description,
|
|
387
|
+
parameters: t.parameters,
|
|
388
|
+
},
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
let lastError = null;
|
|
392
|
+
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
393
|
+
if (attempt > 0) {
|
|
394
|
+
const waitTime = Math.min(2000 * Math.pow(2, attempt - 1), 10000);
|
|
395
|
+
await this.sleep(waitTime);
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
if (shouldStream && callbacks) {
|
|
399
|
+
return await this.streamOpenAIChat(body, callbacks);
|
|
400
|
+
}
|
|
401
|
+
return await this.nonStreamOpenAIChat(body, callbacks);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
lastError = error;
|
|
405
|
+
if (!this.isRetryable(error))
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
throw lastError ?? new OllamaProviderError('Max retries exceeded');
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Stream an OpenAI-compatible /v1/chat/completions response (SSE).
|
|
413
|
+
*/
|
|
414
|
+
async streamOpenAIChat(body, callbacks) {
|
|
415
|
+
const response = await this.fetch(`${this.config.baseUrl}/v1/chat/completions`, {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: { 'Content-Type': 'application/json' },
|
|
418
|
+
body: JSON.stringify(body),
|
|
419
|
+
signal: AbortSignal.timeout(this.config.timeout),
|
|
420
|
+
});
|
|
421
|
+
if (!response.ok) {
|
|
422
|
+
const text = await response.text();
|
|
423
|
+
throw new OllamaProviderError(`OpenAI-compatible chat error (${response.status}): ${text}`, response.status, text);
|
|
424
|
+
}
|
|
425
|
+
const reader = response.body?.getReader();
|
|
426
|
+
if (!reader)
|
|
427
|
+
throw new OllamaProviderError('No response body for streaming');
|
|
428
|
+
const decoder = new TextDecoder();
|
|
429
|
+
let fullContent = '';
|
|
430
|
+
let model = '';
|
|
431
|
+
const toolCalls = new Map();
|
|
432
|
+
let buffer = '';
|
|
433
|
+
const startTime = Date.now();
|
|
434
|
+
try {
|
|
435
|
+
while (true) {
|
|
436
|
+
const { done, value } = await reader.read();
|
|
437
|
+
if (done)
|
|
438
|
+
break;
|
|
439
|
+
buffer += decoder.decode(value, { stream: true });
|
|
440
|
+
// SSE format: lines starting with "data: "
|
|
441
|
+
const lines = buffer.split('\n');
|
|
442
|
+
buffer = lines.pop() ?? '';
|
|
443
|
+
for (const line of lines) {
|
|
444
|
+
const trimmed = line.trim();
|
|
445
|
+
if (!trimmed.startsWith('data: '))
|
|
446
|
+
continue;
|
|
447
|
+
const data = trimmed.slice(6); // strip "data: "
|
|
448
|
+
if (data === '[DONE]')
|
|
449
|
+
continue;
|
|
450
|
+
try {
|
|
451
|
+
const parsed = JSON.parse(data);
|
|
452
|
+
model = parsed.model || model;
|
|
453
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
454
|
+
if (!delta)
|
|
455
|
+
continue;
|
|
456
|
+
if (delta.content) {
|
|
457
|
+
fullContent += delta.content;
|
|
458
|
+
callbacks.onToken?.(delta.content);
|
|
459
|
+
}
|
|
460
|
+
// Handle tool calls in streaming
|
|
461
|
+
if (delta.tool_calls) {
|
|
462
|
+
for (const tc of delta.tool_calls) {
|
|
463
|
+
const existing = toolCalls.get(tc.index);
|
|
464
|
+
if (existing) {
|
|
465
|
+
if (tc.function?.arguments) {
|
|
466
|
+
existing.function.arguments += tc.function.arguments;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
const toolCall = {
|
|
471
|
+
id: tc.id || `tc_${tc.index}`,
|
|
472
|
+
type: 'function',
|
|
473
|
+
function: {
|
|
474
|
+
name: tc.function?.name || '',
|
|
475
|
+
arguments: tc.function?.arguments || '',
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
toolCalls.set(tc.index, toolCall);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Check for tool_calls finish
|
|
483
|
+
if (parsed.choices?.[0]?.finish_reason === 'tool_calls') {
|
|
484
|
+
for (const tc of Array.from(toolCalls.values())) {
|
|
485
|
+
callbacks.onToolCall?.(tc);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
// Skip malformed SSE data
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
callbacks.onError?.(error);
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
// Notify tool calls
|
|
500
|
+
if (toolCalls.size > 0) {
|
|
501
|
+
for (const tc of Array.from(toolCalls.values())) {
|
|
502
|
+
callbacks.onToolCall?.(tc);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const elapsed = Date.now() - startTime;
|
|
506
|
+
const result = {
|
|
507
|
+
content: fullContent,
|
|
508
|
+
model,
|
|
509
|
+
totalDuration: elapsed * 1_000_000, // convert ms to ns for consistency
|
|
510
|
+
evalCount: this.estimateTokens(fullContent),
|
|
511
|
+
evalDuration: elapsed * 1_000_000,
|
|
512
|
+
};
|
|
513
|
+
callbacks.onComplete?.(result);
|
|
514
|
+
return result;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Non-streaming OpenAI-compatible /v1/chat/completions response.
|
|
518
|
+
*/
|
|
519
|
+
async nonStreamOpenAIChat(body, callbacks) {
|
|
520
|
+
const response = await this.fetch(`${this.config.baseUrl}/v1/chat/completions`, {
|
|
521
|
+
method: 'POST',
|
|
522
|
+
headers: { 'Content-Type': 'application/json' },
|
|
523
|
+
body: JSON.stringify({ ...body, stream: false }),
|
|
524
|
+
signal: AbortSignal.timeout(this.config.timeout),
|
|
525
|
+
});
|
|
526
|
+
if (!response.ok) {
|
|
527
|
+
const text = await response.text();
|
|
528
|
+
throw new OllamaProviderError(`OpenAI-compatible chat error (${response.status}): ${text}`, response.status, text);
|
|
529
|
+
}
|
|
530
|
+
const data = await response.json();
|
|
531
|
+
const content = data.choices?.[0]?.message?.content ?? '';
|
|
532
|
+
const model = data.model ?? '';
|
|
533
|
+
const result = {
|
|
534
|
+
content,
|
|
535
|
+
model,
|
|
536
|
+
totalDuration: (data.usage?.total_tokens ?? 0) * 50_000_000, // rough ns estimate
|
|
537
|
+
evalCount: data.usage?.completion_tokens ?? this.estimateTokens(content),
|
|
538
|
+
evalDuration: (data.usage?.completion_tokens ?? 0) * 50_000_000,
|
|
539
|
+
};
|
|
540
|
+
callbacks?.onComplete?.(result);
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
// ==============================================================
|
|
544
|
+
// Model Listing
|
|
545
|
+
// ==============================================================
|
|
546
|
+
async listModelsOllama() {
|
|
547
|
+
const data = await this.request('/api/tags', 'GET');
|
|
548
|
+
return data.models ?? [];
|
|
549
|
+
}
|
|
550
|
+
async listModelsOpenAICompatible() {
|
|
551
|
+
const response = await this.fetch(`${this.config.baseUrl}/v1/models`, {
|
|
552
|
+
method: 'GET',
|
|
553
|
+
signal: AbortSignal.timeout(this.config.timeout),
|
|
554
|
+
});
|
|
555
|
+
if (!response.ok) {
|
|
556
|
+
const text = await response.text();
|
|
557
|
+
throw new OllamaProviderError(`Failed to list models (${response.status}): ${text}`, response.status, text);
|
|
558
|
+
}
|
|
559
|
+
const data = await response.json();
|
|
560
|
+
const models = (data.data ?? []).map((m) => ({
|
|
561
|
+
name: m.id ?? m.name,
|
|
562
|
+
size: 0,
|
|
563
|
+
modified_at: m.created ? new Date(m.created * 1000).toISOString() : new Date().toISOString(),
|
|
564
|
+
details: {
|
|
565
|
+
format: 'unknown',
|
|
566
|
+
family: 'unknown',
|
|
567
|
+
parameter_size: 'unknown',
|
|
568
|
+
quantization_level: 'unknown',
|
|
569
|
+
},
|
|
570
|
+
}));
|
|
571
|
+
return models;
|
|
572
|
+
}
|
|
573
|
+
// ==============================================================
|
|
574
|
+
// Mode Detection
|
|
575
|
+
// ==============================================================
|
|
576
|
+
/**
|
|
577
|
+
* Detect whether the configured endpoint is native Ollama or an
|
|
578
|
+
* OpenAI-compatible server (LM Studio, etc.).
|
|
579
|
+
*
|
|
580
|
+
* Strategy: try the Ollama-specific GET /api/version endpoint. If it
|
|
581
|
+
* responds with a JSON object containing a "version" field, we treat
|
|
582
|
+
* it as native Ollama. Otherwise, fall back to OpenAI-compatible mode.
|
|
583
|
+
*/
|
|
584
|
+
async detectMode() {
|
|
585
|
+
try {
|
|
586
|
+
const controller = new AbortController();
|
|
587
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
588
|
+
const response = await fetch(`${this.config.baseUrl}/api/version`, {
|
|
589
|
+
method: 'GET',
|
|
590
|
+
signal: controller.signal,
|
|
591
|
+
});
|
|
592
|
+
clearTimeout(timeoutId);
|
|
593
|
+
if (response.ok) {
|
|
594
|
+
const data = await response.json();
|
|
595
|
+
if (data && typeof data.version === 'string') {
|
|
596
|
+
this.mode = 'ollama';
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
this.mode = 'openai-compatible';
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
this.mode = 'openai-compatible';
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Get the current provider mode.
|
|
608
|
+
*/
|
|
609
|
+
getMode() {
|
|
610
|
+
return this.mode;
|
|
611
|
+
}
|
|
612
|
+
// ==============================================================
|
|
613
|
+
// HTTP Helpers
|
|
614
|
+
// ==============================================================
|
|
615
|
+
/**
|
|
616
|
+
* Fetch wrapper with timeout and error handling.
|
|
617
|
+
*/
|
|
618
|
+
async fetch(url, init) {
|
|
619
|
+
const controller = new AbortController();
|
|
620
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
621
|
+
// Merge abort signals
|
|
622
|
+
const externalSignal = init?.signal;
|
|
623
|
+
if (externalSignal) {
|
|
624
|
+
externalSignal.addEventListener('abort', () => controller.abort());
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const response = await fetch(url, {
|
|
628
|
+
...init,
|
|
629
|
+
signal: controller.signal,
|
|
630
|
+
});
|
|
631
|
+
return response;
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
635
|
+
throw new OllamaTimeoutError(this.config.timeout);
|
|
636
|
+
}
|
|
637
|
+
throw new OllamaConnectionError(this.config.baseUrl, error);
|
|
638
|
+
}
|
|
639
|
+
finally {
|
|
640
|
+
clearTimeout(timeoutId);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Typed request helper for non-streaming JSON endpoints.
|
|
645
|
+
*/
|
|
646
|
+
async request(path, method, body) {
|
|
647
|
+
let lastError = null;
|
|
648
|
+
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
649
|
+
if (attempt > 0) {
|
|
650
|
+
const waitTime = Math.min(2000 * Math.pow(2, attempt - 1), 10000);
|
|
651
|
+
await this.sleep(waitTime);
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
const init = {
|
|
655
|
+
method,
|
|
656
|
+
headers: { 'Content-Type': 'application/json' },
|
|
657
|
+
signal: AbortSignal.timeout(this.config.timeout),
|
|
658
|
+
};
|
|
659
|
+
if (body !== undefined) {
|
|
660
|
+
init.body = JSON.stringify(body);
|
|
661
|
+
}
|
|
662
|
+
const response = await this.fetch(`${this.config.baseUrl}${path}`, init);
|
|
663
|
+
if (!response.ok) {
|
|
664
|
+
const text = await response.text();
|
|
665
|
+
throw new OllamaProviderError(`Ollama API error (${response.status}): ${text}`, response.status, text);
|
|
666
|
+
}
|
|
667
|
+
return await response.json();
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
lastError = error;
|
|
671
|
+
if (!this.isRetryable(error))
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
throw lastError ?? new OllamaProviderError('Max retries exceeded');
|
|
676
|
+
}
|
|
677
|
+
// ==============================================================
|
|
678
|
+
// Message / Tool Conversion
|
|
679
|
+
// ==============================================================
|
|
680
|
+
convertMessages(messages) {
|
|
681
|
+
return messages.map(m => {
|
|
682
|
+
const msg = {
|
|
683
|
+
role: m.role,
|
|
684
|
+
content: m.content,
|
|
685
|
+
};
|
|
686
|
+
// Convert tool calls to Ollama format
|
|
687
|
+
if (m.toolCalls && m.toolCalls.length > 0) {
|
|
688
|
+
msg.tool_calls = m.toolCalls.map((tc) => {
|
|
689
|
+
let args;
|
|
690
|
+
if (typeof tc.function?.arguments === 'string') {
|
|
691
|
+
try {
|
|
692
|
+
args = JSON.parse(tc.function.arguments);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
args = { raw: tc.function.arguments };
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
else if (tc.function?.arguments) {
|
|
699
|
+
args = tc.function.arguments;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
args = {};
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
function: {
|
|
706
|
+
name: tc.function?.name ?? '',
|
|
707
|
+
arguments: args,
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
// Tool result reference
|
|
713
|
+
if (m.toolCallId) {
|
|
714
|
+
msg.tool_call_id = m.toolCallId;
|
|
715
|
+
}
|
|
716
|
+
return msg;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
convertTools(tools) {
|
|
720
|
+
return tools.map(t => ({
|
|
721
|
+
type: 'function',
|
|
722
|
+
function: {
|
|
723
|
+
name: t.name,
|
|
724
|
+
description: t.description,
|
|
725
|
+
parameters: t.parameters,
|
|
726
|
+
},
|
|
727
|
+
}));
|
|
728
|
+
}
|
|
729
|
+
// ==============================================================
|
|
730
|
+
// Utility
|
|
731
|
+
// ==============================================================
|
|
732
|
+
isRetryable(error) {
|
|
733
|
+
if (error instanceof OllamaProviderError) {
|
|
734
|
+
// Retry on server errors and rate limits
|
|
735
|
+
return (error.statusCode === 429 ||
|
|
736
|
+
(error.statusCode !== undefined && error.statusCode >= 500));
|
|
737
|
+
}
|
|
738
|
+
if (error instanceof OllamaTimeoutError)
|
|
739
|
+
return true;
|
|
740
|
+
if (error instanceof OllamaConnectionError)
|
|
741
|
+
return true;
|
|
742
|
+
// Network-level errors are retryable
|
|
743
|
+
if (error instanceof TypeError && error.message.includes('fetch'))
|
|
744
|
+
return true;
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
sleep(ms) {
|
|
748
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
//# sourceMappingURL=ollama.js.map
|