@smythos/sre 1.6.0 → 1.6.1

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.
@@ -0,0 +1,362 @@
1
+ import { Ollama, ChatResponse } from 'ollama';
2
+ import EventEmitter from 'events';
3
+
4
+ import { JSON_RESPONSE_INSTRUCTION, BUILT_IN_MODEL_PREFIX } from '@sre/constants';
5
+ import {
6
+ TLLMMessageBlock,
7
+ ToolData,
8
+ TLLMMessageRole,
9
+ APIKeySource,
10
+ TLLMEvent,
11
+ ILLMRequestFuncParams,
12
+ TLLMChatResponse,
13
+ ILLMRequestContext,
14
+ TLLMPreparedParams,
15
+ TLLMToolResultMessageBlock,
16
+ TLLMRequestBody,
17
+ } from '@sre/types/LLM.types';
18
+ import { LLMHelper } from '@sre/LLMManager/LLM.helper';
19
+
20
+ import { LLMConnector } from '../LLMConnector';
21
+ import { SystemEvents } from '@sre/Core/SystemEvents';
22
+ import { Logger } from '@sre/helpers/Log.helper';
23
+
24
+ const logger = Logger('OllamaConnector');
25
+
26
+ type OllamaChatRequest = {
27
+ model: string;
28
+ messages: any[];
29
+ stream?: boolean;
30
+ options?: {
31
+ num_predict?: number;
32
+ temperature?: number;
33
+ top_p?: number;
34
+ top_k?: number;
35
+ stop?: string[];
36
+ };
37
+ tools?: any[];
38
+ };
39
+
40
+ export class OllamaConnector extends LLMConnector {
41
+ public name = 'LLM:Ollama';
42
+
43
+ private getClient(context: ILLMRequestContext): Ollama {
44
+ // Extract baseURL and sanitize it for Ollama SDK
45
+ let host = 'http://localhost:11434';
46
+
47
+ if (context.modelInfo.baseURL) {
48
+ // Handle baseURL that might include /api/ suffix
49
+ const baseURL = context.modelInfo.baseURL;
50
+ if (baseURL.endsWith('/api/')) {
51
+ // Remove /api/ suffix to get the root host
52
+ host = baseURL.replace(/\/api\/$/, '');
53
+ } else if (baseURL.endsWith('/api')) {
54
+ // Remove /api suffix
55
+ host = baseURL.replace(/\/api$/, '');
56
+ } else {
57
+ host = baseURL;
58
+ }
59
+ }
60
+
61
+ // No API key validation required for Ollama (local by default)
62
+ return new Ollama({ host });
63
+ }
64
+
65
+ protected async request({ acRequest, body, context }: ILLMRequestFuncParams): Promise<TLLMChatResponse> {
66
+ try {
67
+ logger.debug(`request ${this.name}`, acRequest.candidate);
68
+ const ollama = this.getClient(context);
69
+
70
+ const result = await ollama.chat({
71
+ ...body,
72
+ stream: false,
73
+ }) as unknown as ChatResponse;
74
+
75
+ const message = result.message;
76
+ const finishReason = result.done_reason || 'stop';
77
+ const usage = {
78
+ prompt_tokens: result.prompt_eval_count || 0,
79
+ completion_tokens: result.eval_count || 0,
80
+ total_tokens: (result.prompt_eval_count || 0) + (result.eval_count || 0),
81
+ };
82
+
83
+ this.reportUsage(usage, {
84
+ modelEntryName: context.modelEntryName,
85
+ keySource: context.isUserKey ? APIKeySource.User : APIKeySource.Smyth,
86
+ agentId: context.agentId,
87
+ teamId: context.teamId,
88
+ });
89
+
90
+ let toolsData: ToolData[] = [];
91
+ let useTool = false;
92
+
93
+ // Handle tool calls if present
94
+ if (message?.tool_calls) {
95
+ toolsData = message.tool_calls.map((tool, index) => ({
96
+ index,
97
+ id: tool.function?.name || `tool_${index}`,
98
+ type: 'function',
99
+ name: tool.function.name,
100
+ arguments: tool.function.arguments,
101
+ role: TLLMMessageRole.Assistant,
102
+ }));
103
+ useTool = true;
104
+ }
105
+
106
+ return {
107
+ content: message?.content ?? '',
108
+ finishReason,
109
+ useTool,
110
+ toolsData,
111
+ message: message as any,
112
+ usage,
113
+ };
114
+ } catch (error) {
115
+ logger.error(`request ${this.name}`, error, acRequest.candidate);
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ protected async streamRequest({ acRequest, body, context }: ILLMRequestFuncParams): Promise<EventEmitter> {
121
+ try {
122
+ logger.debug(`streamRequest ${this.name}`, acRequest.candidate);
123
+ const emitter = new EventEmitter();
124
+ const usage_data = [];
125
+
126
+ const ollama = this.getClient(context);
127
+ const stream = await ollama.chat({
128
+ ...body,
129
+ stream: true,
130
+ }) as AsyncIterable<ChatResponse>;
131
+
132
+ let toolsData: ToolData[] = [];
133
+ let fullContent = '';
134
+
135
+ (async () => {
136
+ for await (const chunk of stream) {
137
+ // Emit content deltas
138
+ if (chunk.message?.content) {
139
+ const content = chunk.message.content;
140
+ fullContent += content;
141
+ emitter.emit('content', content);
142
+ }
143
+
144
+ // Handle tool calls accumulation
145
+ if (chunk.message?.tool_calls) {
146
+ chunk.message.tool_calls.forEach((toolCall, index) => {
147
+ if (!toolsData[index]) {
148
+ toolsData[index] = {
149
+ index,
150
+ id: toolCall.function?.name || `tool_${index}`,
151
+ type: 'function',
152
+ name: toolCall.function?.name,
153
+ arguments: toolCall.function?.arguments || '',
154
+ role: 'assistant',
155
+ };
156
+ } else {
157
+ // Merge arguments across chunks for string arguments
158
+ if (typeof toolsData[index].arguments === 'string' && typeof toolCall.function?.arguments === 'string') {
159
+ toolsData[index].arguments += toolCall.function.arguments;
160
+ } else {
161
+ // For object arguments, merge them properly
162
+ toolsData[index].arguments = { ...toolsData[index].arguments as any, ...toolCall.function?.arguments };
163
+ }
164
+ }
165
+ });
166
+ }
167
+
168
+ // Capture usage data when available
169
+ if (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined) {
170
+ const usage = {
171
+ prompt_tokens: chunk.prompt_eval_count || 0,
172
+ completion_tokens: chunk.eval_count || 0,
173
+ total_tokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0),
174
+ };
175
+ usage_data.push(usage);
176
+ }
177
+ }
178
+
179
+ // Emit tool info if tools were requested
180
+ if (toolsData.length > 0) {
181
+ emitter.emit(TLLMEvent.ToolInfo, toolsData);
182
+ }
183
+
184
+ // Report usage
185
+ usage_data.forEach((usage) => {
186
+ this.reportUsage(usage, {
187
+ modelEntryName: context.modelEntryName,
188
+ keySource: context.isUserKey ? APIKeySource.User : APIKeySource.Smyth,
189
+ agentId: context.agentId,
190
+ teamId: context.teamId,
191
+ });
192
+ });
193
+
194
+ // Final end event
195
+ setTimeout(() => {
196
+ emitter.emit('end', toolsData);
197
+ }, 100);
198
+ })();
199
+
200
+ return emitter;
201
+ } catch (error: any) {
202
+ logger.error(`streamRequest ${this.name}`, error, acRequest.candidate);
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ protected async reqBodyAdapter(params: TLLMPreparedParams): Promise<TLLMRequestBody> {
208
+ const messages = params?.messages || [];
209
+
210
+ const body: OllamaChatRequest = {
211
+ model: params.model as string,
212
+ messages,
213
+ };
214
+
215
+ // Handle JSON response format
216
+ const responseFormat = params?.responseFormat || '';
217
+ if (responseFormat === 'json') {
218
+ if (messages?.[0]?.role === 'system') {
219
+ messages[0].content += JSON_RESPONSE_INSTRUCTION;
220
+ } else {
221
+ messages.unshift({ role: 'system', content: JSON_RESPONSE_INSTRUCTION });
222
+ }
223
+ }
224
+
225
+ // Map SRE options to Ollama options
226
+ const options: any = {};
227
+
228
+ if (params.maxTokens !== undefined) options.num_predict = params.maxTokens;
229
+ if (params.temperature !== undefined) options.temperature = params.temperature;
230
+ if (params.topP !== undefined) options.top_p = params.topP;
231
+ if (params.topK !== undefined) options.top_k = params.topK;
232
+ if (params.stopSequences?.length) options.stop = params.stopSequences;
233
+
234
+ if (Object.keys(options).length > 0) {
235
+ body.options = options;
236
+ }
237
+
238
+ // Handle tools
239
+ if (params.toolsConfig?.tools) {
240
+ body.tools = params.toolsConfig.tools.map(tool => ({
241
+ type: 'function',
242
+ function: {
243
+ name: tool.function.name,
244
+ description: tool.function.description,
245
+ parameters: tool.function.parameters,
246
+ },
247
+ }));
248
+ }
249
+
250
+ return body as unknown as TLLMRequestBody;
251
+ }
252
+
253
+ protected reportUsage(
254
+ usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number },
255
+ metadata: { modelEntryName: string; keySource: APIKeySource; agentId: string; teamId: string }
256
+ ) {
257
+ // SmythOS (built-in) models have a prefix, so we need to remove it to get the model name
258
+ const modelName = metadata.modelEntryName.replace(BUILT_IN_MODEL_PREFIX, '');
259
+
260
+ const usageData = {
261
+ sourceId: `llm:${modelName}`,
262
+ input_tokens: usage.prompt_tokens,
263
+ output_tokens: usage.completion_tokens,
264
+ input_tokens_cache_write: 0,
265
+ input_tokens_cache_read: 0,
266
+ keySource: metadata.keySource,
267
+ agentId: metadata.agentId,
268
+ teamId: metadata.teamId,
269
+ };
270
+ SystemEvents.emit('USAGE:LLM', usageData);
271
+
272
+ return usageData;
273
+ }
274
+
275
+ public transformToolMessageBlocks({
276
+ messageBlock,
277
+ toolsData,
278
+ }: {
279
+ messageBlock: TLLMMessageBlock;
280
+ toolsData: ToolData[];
281
+ }): TLLMToolResultMessageBlock[] {
282
+ const messageBlocks: TLLMToolResultMessageBlock[] = [];
283
+
284
+ // Transform the assistant message block if present
285
+ if (messageBlock) {
286
+ const transformedMessageBlock = {
287
+ ...messageBlock,
288
+ content: typeof messageBlock.content === 'object' ? JSON.stringify(messageBlock.content) : messageBlock.content,
289
+ };
290
+ if (transformedMessageBlock.tool_calls) {
291
+ for (let toolCall of transformedMessageBlock.tool_calls) {
292
+ const args = toolCall?.function?.arguments;
293
+ if (typeof args === 'string') {
294
+ try {
295
+ toolCall.function.arguments = JSON.parse(args);
296
+ } catch {
297
+ toolCall.function.arguments = {};
298
+ }
299
+ }
300
+ // If it's already an object, keep as-is for Ollama
301
+ }
302
+ }
303
+ messageBlocks.push(transformedMessageBlock);
304
+ }
305
+
306
+ // Transform tool results into tool role messages
307
+ const transformedToolsData = toolsData.map((toolData) => ({
308
+ tool_call_id: toolData.id,
309
+ role: TLLMMessageRole.Tool,
310
+ name: toolData.name,
311
+ content: typeof toolData.result === 'string' ? toolData.result : JSON.stringify(toolData.result),
312
+ }));
313
+
314
+ return [...messageBlocks, ...transformedToolsData];
315
+ }
316
+
317
+ public formatToolsConfig({ type = 'function', toolDefinitions, toolChoice = 'auto' }) {
318
+ let tools = [];
319
+
320
+ if (type === 'function') {
321
+ tools = toolDefinitions.map((tool) => {
322
+ const { name, description, properties, requiredFields } = tool;
323
+
324
+ return {
325
+ type: 'function',
326
+ function: {
327
+ name,
328
+ description,
329
+ parameters: {
330
+ type: 'object',
331
+ properties,
332
+ required: requiredFields,
333
+ },
334
+ },
335
+ };
336
+ });
337
+ }
338
+
339
+ return tools?.length > 0 ? { tools, tool_choice: toolChoice } : {};
340
+ }
341
+
342
+ public getConsistentMessages(messages: TLLMMessageBlock[]): TLLMMessageBlock[] {
343
+ const _messages = LLMHelper.removeDuplicateUserMessages(messages);
344
+
345
+ return _messages.map((message) => {
346
+ const _message = { ...message };
347
+ let textContent = '';
348
+
349
+ if (message?.parts) {
350
+ textContent = message.parts.map((textBlock) => textBlock?.text || '').join(' ');
351
+ } else if (Array.isArray(message?.content)) {
352
+ textContent = message.content.map((textBlock) => textBlock?.text || '').join(' ');
353
+ } else if (message?.content) {
354
+ textContent = message.content as string;
355
+ }
356
+
357
+ _message.content = textContent;
358
+
359
+ return _message;
360
+ });
361
+ }
362
+ }
@@ -11,6 +11,7 @@ import { BedrockConnector } from './connectors/Bedrock.class';
11
11
  import { VertexAIConnector } from './connectors/VertexAI.class';
12
12
  import { PerplexityConnector } from './connectors/Perplexity.class';
13
13
  import { xAIConnector } from './connectors/xAI.class';
14
+ import { OllamaConnector } from './connectors/Ollama.class';
14
15
 
15
16
  export class LLMService extends ConnectorServiceProvider {
16
17
  public register() {
@@ -25,6 +26,7 @@ export class LLMService extends ConnectorServiceProvider {
25
26
  ConnectorService.register(TConnectorService.LLM, 'VertexAI', VertexAIConnector);
26
27
  ConnectorService.register(TConnectorService.LLM, 'xAI', xAIConnector);
27
28
  ConnectorService.register(TConnectorService.LLM, 'Perplexity', PerplexityConnector);
29
+ ConnectorService.register(TConnectorService.LLM, 'Ollama', OllamaConnector);
28
30
  }
29
31
 
30
32
  public init() {
@@ -40,5 +42,6 @@ export class LLMService extends ConnectorServiceProvider {
40
42
  ConnectorService.init(TConnectorService.LLM, 'VertexAI');
41
43
  ConnectorService.init(TConnectorService.LLM, 'xAI');
42
44
  ConnectorService.init(TConnectorService.LLM, 'Perplexity');
45
+ ConnectorService.init(TConnectorService.LLM, 'Ollama');
43
46
  }
44
47
  }
@@ -268,6 +268,7 @@ export const BuiltinLLMProviders = {
268
268
  VertexAI: 'VertexAI',
269
269
  xAI: 'xAI',
270
270
  Perplexity: 'Perplexity',
271
+ Ollama: 'Ollama',
271
272
  } as const;
272
273
  // Base provider type
273
274
  export type TBuiltinLLMProvider = (typeof BuiltinLLMProviders)[keyof typeof BuiltinLLMProviders];