dtSpark 1.0.4__py3-none-any.whl
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.
- dtSpark/__init__.py +0 -0
- dtSpark/_description.txt +1 -0
- dtSpark/_full_name.txt +1 -0
- dtSpark/_licence.txt +21 -0
- dtSpark/_metadata.yaml +6 -0
- dtSpark/_name.txt +1 -0
- dtSpark/_version.txt +1 -0
- dtSpark/aws/__init__.py +7 -0
- dtSpark/aws/authentication.py +296 -0
- dtSpark/aws/bedrock.py +578 -0
- dtSpark/aws/costs.py +318 -0
- dtSpark/aws/pricing.py +580 -0
- dtSpark/cli_interface.py +2645 -0
- dtSpark/conversation_manager.py +3050 -0
- dtSpark/core/__init__.py +12 -0
- dtSpark/core/application.py +3355 -0
- dtSpark/core/context_compaction.py +735 -0
- dtSpark/daemon/__init__.py +104 -0
- dtSpark/daemon/__main__.py +10 -0
- dtSpark/daemon/action_monitor.py +213 -0
- dtSpark/daemon/daemon_app.py +730 -0
- dtSpark/daemon/daemon_manager.py +289 -0
- dtSpark/daemon/execution_coordinator.py +194 -0
- dtSpark/daemon/pid_file.py +169 -0
- dtSpark/database/__init__.py +482 -0
- dtSpark/database/autonomous_actions.py +1191 -0
- dtSpark/database/backends.py +329 -0
- dtSpark/database/connection.py +122 -0
- dtSpark/database/conversations.py +520 -0
- dtSpark/database/credential_prompt.py +218 -0
- dtSpark/database/files.py +205 -0
- dtSpark/database/mcp_ops.py +355 -0
- dtSpark/database/messages.py +161 -0
- dtSpark/database/schema.py +673 -0
- dtSpark/database/tool_permissions.py +186 -0
- dtSpark/database/usage.py +167 -0
- dtSpark/files/__init__.py +4 -0
- dtSpark/files/manager.py +322 -0
- dtSpark/launch.py +39 -0
- dtSpark/limits/__init__.py +10 -0
- dtSpark/limits/costs.py +296 -0
- dtSpark/limits/tokens.py +342 -0
- dtSpark/llm/__init__.py +17 -0
- dtSpark/llm/anthropic_direct.py +446 -0
- dtSpark/llm/base.py +146 -0
- dtSpark/llm/context_limits.py +438 -0
- dtSpark/llm/manager.py +177 -0
- dtSpark/llm/ollama.py +578 -0
- dtSpark/mcp_integration/__init__.py +5 -0
- dtSpark/mcp_integration/manager.py +653 -0
- dtSpark/mcp_integration/tool_selector.py +225 -0
- dtSpark/resources/config.yaml.template +631 -0
- dtSpark/safety/__init__.py +22 -0
- dtSpark/safety/llm_service.py +111 -0
- dtSpark/safety/patterns.py +229 -0
- dtSpark/safety/prompt_inspector.py +442 -0
- dtSpark/safety/violation_logger.py +346 -0
- dtSpark/scheduler/__init__.py +20 -0
- dtSpark/scheduler/creation_tools.py +599 -0
- dtSpark/scheduler/execution_queue.py +159 -0
- dtSpark/scheduler/executor.py +1152 -0
- dtSpark/scheduler/manager.py +395 -0
- dtSpark/tools/__init__.py +4 -0
- dtSpark/tools/builtin.py +833 -0
- dtSpark/web/__init__.py +20 -0
- dtSpark/web/auth.py +152 -0
- dtSpark/web/dependencies.py +37 -0
- dtSpark/web/endpoints/__init__.py +17 -0
- dtSpark/web/endpoints/autonomous_actions.py +1125 -0
- dtSpark/web/endpoints/chat.py +621 -0
- dtSpark/web/endpoints/conversations.py +353 -0
- dtSpark/web/endpoints/main_menu.py +547 -0
- dtSpark/web/endpoints/streaming.py +421 -0
- dtSpark/web/server.py +578 -0
- dtSpark/web/session.py +167 -0
- dtSpark/web/ssl_utils.py +195 -0
- dtSpark/web/static/css/dark-theme.css +427 -0
- dtSpark/web/static/js/actions.js +1101 -0
- dtSpark/web/static/js/chat.js +614 -0
- dtSpark/web/static/js/main.js +496 -0
- dtSpark/web/static/js/sse-client.js +242 -0
- dtSpark/web/templates/actions.html +408 -0
- dtSpark/web/templates/base.html +93 -0
- dtSpark/web/templates/chat.html +814 -0
- dtSpark/web/templates/conversations.html +350 -0
- dtSpark/web/templates/goodbye.html +81 -0
- dtSpark/web/templates/login.html +90 -0
- dtSpark/web/templates/main_menu.html +983 -0
- dtSpark/web/templates/new_conversation.html +191 -0
- dtSpark/web/web_interface.py +137 -0
- dtspark-1.0.4.dist-info/METADATA +187 -0
- dtspark-1.0.4.dist-info/RECORD +96 -0
- dtspark-1.0.4.dist-info/WHEEL +5 -0
- dtspark-1.0.4.dist-info/entry_points.txt +3 -0
- dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
- dtspark-1.0.4.dist-info/top_level.txt +1 -0
dtSpark/llm/ollama.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ollama service module for interacting with local Ollama instances.
|
|
3
|
+
|
|
4
|
+
This module provides functionality for:
|
|
5
|
+
- Listing available Ollama models
|
|
6
|
+
- Invoking Ollama models for chat completions
|
|
7
|
+
- Converting between Bedrock and Ollama message formats
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import ssl
|
|
14
|
+
import urllib3
|
|
15
|
+
from typing import List, Dict, Optional, Any
|
|
16
|
+
from dtSpark.llm.base import LLMService
|
|
17
|
+
import tiktoken
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from ollama import Client
|
|
21
|
+
import httpx
|
|
22
|
+
except ImportError:
|
|
23
|
+
logging.error("ollama module not installed. Please run: pip install ollama")
|
|
24
|
+
raise
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OllamaService(LLMService):
|
|
28
|
+
"""Manages interactions with local Ollama instance using official ollama SDK."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base_url: str = "http://localhost:11434", verify_ssl: bool = True):
|
|
31
|
+
"""
|
|
32
|
+
Initialise the Ollama service.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
base_url: Base URL for Ollama API
|
|
36
|
+
verify_ssl: Whether to verify SSL certificates (set to False for self-signed certs)
|
|
37
|
+
"""
|
|
38
|
+
self.base_url = base_url.rstrip('/')
|
|
39
|
+
self.verify_ssl = verify_ssl
|
|
40
|
+
self._ssl_warnings_disabled = False
|
|
41
|
+
|
|
42
|
+
# Handle SSL verification settings and create client
|
|
43
|
+
if not verify_ssl:
|
|
44
|
+
logging.info(f"SSL certificate verification disabled for Ollama at {self.base_url}")
|
|
45
|
+
self._disable_ssl_verification()
|
|
46
|
+
self.client = self._create_client_with_ssl_disabled()
|
|
47
|
+
else:
|
|
48
|
+
self.client = Client(host=self.base_url)
|
|
49
|
+
|
|
50
|
+
self.current_model_id = None
|
|
51
|
+
self._verify_connection()
|
|
52
|
+
|
|
53
|
+
def _disable_ssl_verification(self):
|
|
54
|
+
"""
|
|
55
|
+
Disable SSL certificate verification for httpx/urllib3.
|
|
56
|
+
|
|
57
|
+
This is necessary when connecting to Ollama instances with self-signed certificates.
|
|
58
|
+
"""
|
|
59
|
+
# Suppress InsecureRequestWarning from urllib3
|
|
60
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
61
|
+
self._ssl_warnings_disabled = True
|
|
62
|
+
|
|
63
|
+
# Create an unverified SSL context
|
|
64
|
+
try:
|
|
65
|
+
ssl._create_default_https_context = ssl._create_unverified_context
|
|
66
|
+
except AttributeError:
|
|
67
|
+
# Legacy Python that doesn't support this
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def _create_client_with_ssl_disabled(self) -> Client:
|
|
71
|
+
"""
|
|
72
|
+
Create an Ollama client with SSL verification disabled.
|
|
73
|
+
|
|
74
|
+
This creates a custom httpx client and injects it into the Ollama client.
|
|
75
|
+
"""
|
|
76
|
+
# Ensure base_url has trailing slash for httpx base_url
|
|
77
|
+
base_url = self.base_url
|
|
78
|
+
if not base_url.endswith('/'):
|
|
79
|
+
base_url = base_url + '/'
|
|
80
|
+
|
|
81
|
+
# Create httpx client with SSL verification disabled and proper base URL
|
|
82
|
+
custom_http_client = httpx.Client(
|
|
83
|
+
base_url=base_url,
|
|
84
|
+
verify=False,
|
|
85
|
+
timeout=httpx.Timeout(timeout=120.0)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Create the Ollama client
|
|
89
|
+
client = Client(host=self.base_url)
|
|
90
|
+
|
|
91
|
+
# Monkey-patch the internal httpx client
|
|
92
|
+
# The ollama SDK stores the client as _client
|
|
93
|
+
if hasattr(client, '_client'):
|
|
94
|
+
# Close the original client to free resources
|
|
95
|
+
try:
|
|
96
|
+
client._client.close()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
client._client = custom_http_client
|
|
100
|
+
else:
|
|
101
|
+
# Fallback: try to find and replace the httpx client
|
|
102
|
+
for attr_name in dir(client):
|
|
103
|
+
attr = getattr(client, attr_name, None)
|
|
104
|
+
if isinstance(attr, httpx.Client):
|
|
105
|
+
try:
|
|
106
|
+
attr.close()
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
setattr(client, attr_name, custom_http_client)
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
return client
|
|
113
|
+
|
|
114
|
+
def _verify_connection(self):
|
|
115
|
+
"""Verify connection to Ollama instance."""
|
|
116
|
+
try:
|
|
117
|
+
self.client.list()
|
|
118
|
+
logging.info(f"Connected to Ollama at {self.base_url}")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logging.warning(f"Cannot connect to Ollama at {self.base_url}: {e}")
|
|
121
|
+
|
|
122
|
+
def get_provider_name(self) -> str:
|
|
123
|
+
"""Get provider name."""
|
|
124
|
+
return "Ollama"
|
|
125
|
+
|
|
126
|
+
def get_access_info(self) -> str:
|
|
127
|
+
"""Get access information."""
|
|
128
|
+
return f"Ollama ({self.base_url})"
|
|
129
|
+
|
|
130
|
+
def list_available_models(self) -> List[Dict[str, Any]]:
|
|
131
|
+
"""
|
|
132
|
+
List all available models from Ollama.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of model dictionaries
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
response = self.client.list()
|
|
139
|
+
|
|
140
|
+
models = []
|
|
141
|
+
# Handle both SDK response objects and dict responses
|
|
142
|
+
if hasattr(response, 'models'):
|
|
143
|
+
# SDK response object with attributes
|
|
144
|
+
model_list = response.models
|
|
145
|
+
else:
|
|
146
|
+
# Dict response (for backward compatibility)
|
|
147
|
+
model_list = response.get('models', [])
|
|
148
|
+
|
|
149
|
+
for model in model_list:
|
|
150
|
+
# Handle both SDK model objects and dict models
|
|
151
|
+
if hasattr(model, 'model'):
|
|
152
|
+
# SDK model object
|
|
153
|
+
model_name = model.model
|
|
154
|
+
model_size = model.size if hasattr(model, 'size') else 0
|
|
155
|
+
model_modified = model.modified_at if hasattr(model, 'modified_at') else ''
|
|
156
|
+
else:
|
|
157
|
+
# Dict model
|
|
158
|
+
model_name = model.get('name', '')
|
|
159
|
+
model_size = model.get('size', 0)
|
|
160
|
+
model_modified = model.get('modified_at', '')
|
|
161
|
+
|
|
162
|
+
# Determine tool support based on model capabilities
|
|
163
|
+
supports_tools = self._check_tool_support(model_name)
|
|
164
|
+
|
|
165
|
+
models.append({
|
|
166
|
+
'id': model_name,
|
|
167
|
+
'name': model_name,
|
|
168
|
+
'provider': 'Ollama',
|
|
169
|
+
'access_info': self.get_access_info(),
|
|
170
|
+
'supports_tools': supports_tools,
|
|
171
|
+
'context_length': self._get_context_length(model_name),
|
|
172
|
+
'size': model_size,
|
|
173
|
+
'modified': model_modified
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
logging.info(f"Found {len(models)} Ollama models")
|
|
177
|
+
return models
|
|
178
|
+
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logging.error(f"Failed to list Ollama models: {e}")
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
def _check_tool_support(self, model_name: str) -> bool:
|
|
184
|
+
"""
|
|
185
|
+
Check if a model supports tool calling.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
model_name: Name of the model
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if model likely supports tools
|
|
192
|
+
"""
|
|
193
|
+
# Models known to support function calling
|
|
194
|
+
tool_capable_models = [
|
|
195
|
+
'llama3.2', 'llama3.1', 'llama3',
|
|
196
|
+
'mistral', 'mixtral',
|
|
197
|
+
'qwen2.5', 'qwen2',
|
|
198
|
+
'command-r',
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
model_lower = model_name.lower()
|
|
202
|
+
return any(capable in model_lower for capable in tool_capable_models)
|
|
203
|
+
|
|
204
|
+
def _get_context_length(self, model_name: str) -> int:
|
|
205
|
+
"""
|
|
206
|
+
Get context length for a model.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
model_name: Name of the model
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Context length in tokens
|
|
213
|
+
"""
|
|
214
|
+
# Common context lengths
|
|
215
|
+
if 'llama3.2' in model_name.lower():
|
|
216
|
+
return 128000
|
|
217
|
+
elif 'llama3.1' in model_name.lower():
|
|
218
|
+
return 128000
|
|
219
|
+
elif 'mistral' in model_name.lower():
|
|
220
|
+
return 32000
|
|
221
|
+
else:
|
|
222
|
+
return 8192 # Default
|
|
223
|
+
|
|
224
|
+
def set_model(self, model_id: str):
|
|
225
|
+
"""Set the active Ollama model."""
|
|
226
|
+
self.current_model_id = model_id
|
|
227
|
+
logging.info(f"Ollama model set to: {model_id}")
|
|
228
|
+
|
|
229
|
+
def invoke_model(
|
|
230
|
+
self,
|
|
231
|
+
messages: List[Dict[str, Any]],
|
|
232
|
+
max_tokens: int = 4096,
|
|
233
|
+
temperature: float = 0.7,
|
|
234
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
235
|
+
system: Optional[str] = None,
|
|
236
|
+
max_retries: int = 3
|
|
237
|
+
) -> Optional[Dict[str, Any]]:
|
|
238
|
+
"""
|
|
239
|
+
Invoke Ollama model with conversation.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
messages: Conversation messages
|
|
243
|
+
max_tokens: Maximum tokens to generate
|
|
244
|
+
temperature: Sampling temperature
|
|
245
|
+
tools: Optional tool definitions
|
|
246
|
+
system: Optional system prompt
|
|
247
|
+
max_retries: Maximum retry attempts
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Response dictionary in standard format
|
|
251
|
+
"""
|
|
252
|
+
if not self.current_model_id:
|
|
253
|
+
return {
|
|
254
|
+
'error': True,
|
|
255
|
+
'error_code': 'NoModelSelected',
|
|
256
|
+
'error_message': 'No Ollama model selected',
|
|
257
|
+
'error_type': 'ConfigurationError'
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
# Convert messages from Bedrock format to Ollama format
|
|
262
|
+
ollama_messages = self._convert_messages_to_ollama(messages)
|
|
263
|
+
|
|
264
|
+
# Add system message if provided
|
|
265
|
+
if system:
|
|
266
|
+
ollama_messages.insert(0, {
|
|
267
|
+
'role': 'system',
|
|
268
|
+
'content': system
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
# Build chat options
|
|
272
|
+
chat_options = {
|
|
273
|
+
'temperature': temperature,
|
|
274
|
+
'num_predict': max_tokens
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
logging.debug(f"Invoking Ollama model: {self.current_model_id}")
|
|
278
|
+
|
|
279
|
+
# Use ollama SDK to make the chat request
|
|
280
|
+
if tools and self._check_tool_support(self.current_model_id):
|
|
281
|
+
# Convert tools to Ollama format
|
|
282
|
+
ollama_tools = self._convert_tools_to_ollama(tools)
|
|
283
|
+
response = self.client.chat(
|
|
284
|
+
model=self.current_model_id,
|
|
285
|
+
messages=ollama_messages,
|
|
286
|
+
tools=ollama_tools,
|
|
287
|
+
options=chat_options
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
response = self.client.chat(
|
|
291
|
+
model=self.current_model_id,
|
|
292
|
+
messages=ollama_messages,
|
|
293
|
+
options=chat_options
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Convert response to standard format
|
|
297
|
+
return self._convert_response_from_ollama(response)
|
|
298
|
+
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logging.error(f"Ollama API error: {e}")
|
|
301
|
+
return {
|
|
302
|
+
'error': True,
|
|
303
|
+
'error_code': 'OllamaAPIError',
|
|
304
|
+
'error_message': str(e),
|
|
305
|
+
'error_type': 'RequestError'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
def _convert_messages_to_ollama(
|
|
309
|
+
self,
|
|
310
|
+
messages: List[Dict[str, Any]]
|
|
311
|
+
) -> List[Dict[str, Any]]:
|
|
312
|
+
"""
|
|
313
|
+
Convert Bedrock message format to Ollama format.
|
|
314
|
+
|
|
315
|
+
Bedrock format:
|
|
316
|
+
{
|
|
317
|
+
'role': 'user',
|
|
318
|
+
'content': [{'text': '...'}] or [{'type': 'tool_use', ...}]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
Ollama format:
|
|
322
|
+
{
|
|
323
|
+
'role': 'user',
|
|
324
|
+
'content': '...',
|
|
325
|
+
'tool_calls': [...] # For assistant messages with tool use
|
|
326
|
+
}
|
|
327
|
+
"""
|
|
328
|
+
ollama_messages = []
|
|
329
|
+
# Build a lookup map of tool_use_id -> tool_name for tool result matching
|
|
330
|
+
tool_id_to_name = {}
|
|
331
|
+
|
|
332
|
+
for msg in messages:
|
|
333
|
+
role = msg.get('role', 'user')
|
|
334
|
+
content = msg.get('content', [])
|
|
335
|
+
|
|
336
|
+
# Handle different content formats
|
|
337
|
+
if isinstance(content, str):
|
|
338
|
+
ollama_messages.append({
|
|
339
|
+
'role': role,
|
|
340
|
+
'content': content
|
|
341
|
+
})
|
|
342
|
+
elif isinstance(content, list):
|
|
343
|
+
# Extract text and tool blocks
|
|
344
|
+
text_parts = []
|
|
345
|
+
tool_use_blocks = []
|
|
346
|
+
tool_result_blocks = []
|
|
347
|
+
|
|
348
|
+
for block in content:
|
|
349
|
+
if isinstance(block, dict):
|
|
350
|
+
block_type = block.get('type')
|
|
351
|
+
|
|
352
|
+
if block_type == 'text' or 'text' in block:
|
|
353
|
+
text_parts.append(block.get('text', ''))
|
|
354
|
+
elif block_type == 'tool_use':
|
|
355
|
+
tool_use_blocks.append(block)
|
|
356
|
+
elif block_type == 'tool_result':
|
|
357
|
+
tool_result_blocks.append(block)
|
|
358
|
+
elif isinstance(block, str):
|
|
359
|
+
text_parts.append(block)
|
|
360
|
+
|
|
361
|
+
# Build message based on content
|
|
362
|
+
if role == 'assistant' and tool_use_blocks:
|
|
363
|
+
# Assistant message with tool calls
|
|
364
|
+
ollama_msg = {
|
|
365
|
+
'role': 'assistant',
|
|
366
|
+
'content': '\n'.join(text_parts) if text_parts else '',
|
|
367
|
+
'tool_calls': []
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Convert tool_use blocks to Ollama tool_calls format
|
|
371
|
+
# Also build mapping of tool_id -> tool_name for later tool result matching
|
|
372
|
+
for tool_block in tool_use_blocks:
|
|
373
|
+
tool_id = tool_block.get('id', '')
|
|
374
|
+
tool_name = tool_block.get('name', '')
|
|
375
|
+
|
|
376
|
+
# Store mapping for tool results
|
|
377
|
+
if tool_id and tool_name:
|
|
378
|
+
tool_id_to_name[tool_id] = tool_name
|
|
379
|
+
|
|
380
|
+
ollama_msg['tool_calls'].append({
|
|
381
|
+
'id': tool_id,
|
|
382
|
+
'type': 'function',
|
|
383
|
+
'function': {
|
|
384
|
+
'name': tool_name,
|
|
385
|
+
'arguments': tool_block.get('input', {})
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
ollama_messages.append(ollama_msg)
|
|
390
|
+
|
|
391
|
+
elif role == 'user' and tool_result_blocks:
|
|
392
|
+
# Convert tool results to Ollama SDK format using "tool" role
|
|
393
|
+
# Ollama SDK requires 'tool_name' field (not 'tool_call_id')
|
|
394
|
+
for result_block in tool_result_blocks:
|
|
395
|
+
tool_use_id = result_block.get('tool_use_id', '')
|
|
396
|
+
tool_name = tool_id_to_name.get(tool_use_id, 'unknown_tool')
|
|
397
|
+
|
|
398
|
+
ollama_messages.append({
|
|
399
|
+
'role': 'tool',
|
|
400
|
+
'tool_name': tool_name,
|
|
401
|
+
'content': result_block.get('content', '')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
# If there's also regular text content, include that as user message
|
|
405
|
+
if text_parts:
|
|
406
|
+
ollama_messages.append({
|
|
407
|
+
'role': 'user',
|
|
408
|
+
'content': '\n'.join(text_parts)
|
|
409
|
+
})
|
|
410
|
+
else:
|
|
411
|
+
# Regular message with just text
|
|
412
|
+
text = '\n'.join(text_parts) if text_parts else ''
|
|
413
|
+
ollama_messages.append({
|
|
414
|
+
'role': role,
|
|
415
|
+
'content': text
|
|
416
|
+
})
|
|
417
|
+
else:
|
|
418
|
+
ollama_messages.append({
|
|
419
|
+
'role': role,
|
|
420
|
+
'content': str(content)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
return ollama_messages
|
|
424
|
+
|
|
425
|
+
def _convert_tools_to_ollama(
|
|
426
|
+
self,
|
|
427
|
+
tools: List[Dict[str, Any]]
|
|
428
|
+
) -> List[Dict[str, Any]]:
|
|
429
|
+
"""
|
|
430
|
+
Convert Bedrock tool format to Ollama format.
|
|
431
|
+
|
|
432
|
+
Ollama uses OpenAI-compatible function calling format.
|
|
433
|
+
"""
|
|
434
|
+
ollama_tools = []
|
|
435
|
+
|
|
436
|
+
for tool in tools:
|
|
437
|
+
# Bedrock tools have 'toolSpec' wrapping
|
|
438
|
+
tool_spec = tool.get('toolSpec', tool)
|
|
439
|
+
|
|
440
|
+
ollama_tools.append({
|
|
441
|
+
'type': 'function',
|
|
442
|
+
'function': {
|
|
443
|
+
'name': tool_spec.get('name', ''),
|
|
444
|
+
'description': tool_spec.get('description', ''),
|
|
445
|
+
'parameters': tool_spec.get('inputSchema', {})
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
return ollama_tools
|
|
450
|
+
|
|
451
|
+
def _convert_response_from_ollama(
|
|
452
|
+
self,
|
|
453
|
+
ollama_response: Any
|
|
454
|
+
) -> Dict[str, Any]:
|
|
455
|
+
"""
|
|
456
|
+
Convert Ollama SDK response to standard format.
|
|
457
|
+
|
|
458
|
+
ollama SDK response has attributes:
|
|
459
|
+
- message: with .content, .tool_calls, etc.
|
|
460
|
+
- done: boolean
|
|
461
|
+
"""
|
|
462
|
+
# Handle both SDK response objects and dict (for backward compatibility)
|
|
463
|
+
if hasattr(ollama_response, 'message'):
|
|
464
|
+
# SDK response object
|
|
465
|
+
message = ollama_response.message
|
|
466
|
+
content = message.content if hasattr(message, 'content') else ''
|
|
467
|
+
tool_calls = message.tool_calls if hasattr(message, 'tool_calls') else []
|
|
468
|
+
done = ollama_response.done if hasattr(ollama_response, 'done') else True
|
|
469
|
+
else:
|
|
470
|
+
# Dict response (backward compatibility for tests)
|
|
471
|
+
message = ollama_response.get('message', {})
|
|
472
|
+
content = message.get('content', '')
|
|
473
|
+
tool_calls = message.get('tool_calls', [])
|
|
474
|
+
done = ollama_response.get('done', True)
|
|
475
|
+
|
|
476
|
+
# Estimate token usage (Ollama doesn't always provide this)
|
|
477
|
+
input_tokens = self.count_tokens(str(ollama_response))
|
|
478
|
+
output_tokens = self.count_tokens(content if content else '')
|
|
479
|
+
|
|
480
|
+
# Build standard response format
|
|
481
|
+
response = {
|
|
482
|
+
'stop_reason': 'end_turn' if done else 'max_tokens',
|
|
483
|
+
'usage': {
|
|
484
|
+
'input_tokens': input_tokens,
|
|
485
|
+
'output_tokens': output_tokens
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
# Handle content - build content_blocks array like Bedrock
|
|
490
|
+
content_blocks = []
|
|
491
|
+
|
|
492
|
+
if tool_calls:
|
|
493
|
+
tool_use_blocks = self._convert_tool_calls(tool_calls)
|
|
494
|
+
response['tool_use'] = tool_use_blocks
|
|
495
|
+
response['stop_reason'] = 'tool_use'
|
|
496
|
+
|
|
497
|
+
# Build content_blocks with text (if any) followed by tool calls
|
|
498
|
+
if content:
|
|
499
|
+
# Model provided both text and tool calls
|
|
500
|
+
content_blocks.append({'type': 'text', 'text': content})
|
|
501
|
+
# Add tool use blocks
|
|
502
|
+
content_blocks.extend(tool_use_blocks)
|
|
503
|
+
else:
|
|
504
|
+
# No tool calls, just text content
|
|
505
|
+
if content:
|
|
506
|
+
content_blocks.append({'type': 'text', 'text': content})
|
|
507
|
+
|
|
508
|
+
# Return both formats for compatibility:
|
|
509
|
+
# - content: string (for backward compatibility and text extraction)
|
|
510
|
+
# - content_blocks: array (for conversation manager)
|
|
511
|
+
response['content'] = content
|
|
512
|
+
response['content_blocks'] = content_blocks
|
|
513
|
+
|
|
514
|
+
return response
|
|
515
|
+
|
|
516
|
+
def _convert_tool_calls(
|
|
517
|
+
self,
|
|
518
|
+
tool_calls: List[Any]
|
|
519
|
+
) -> List[Dict[str, Any]]:
|
|
520
|
+
"""Convert Ollama SDK tool calls to standard format."""
|
|
521
|
+
converted = []
|
|
522
|
+
|
|
523
|
+
for call in tool_calls:
|
|
524
|
+
# Handle both SDK objects and dicts (for backward compatibility)
|
|
525
|
+
if hasattr(call, 'function'):
|
|
526
|
+
# SDK response object
|
|
527
|
+
function = call.function
|
|
528
|
+
call_id = call.id if hasattr(call, 'id') else ''
|
|
529
|
+
func_name = function.name if hasattr(function, 'name') else ''
|
|
530
|
+
arguments = function.arguments if hasattr(function, 'arguments') else {}
|
|
531
|
+
else:
|
|
532
|
+
# Dict response (backward compatibility)
|
|
533
|
+
function = call.get('function', {})
|
|
534
|
+
call_id = call.get('id', '')
|
|
535
|
+
func_name = function.get('name', '')
|
|
536
|
+
arguments = function.get('arguments', '{}')
|
|
537
|
+
|
|
538
|
+
# Handle arguments - can be dict or string
|
|
539
|
+
if isinstance(arguments, str):
|
|
540
|
+
# Parse JSON string
|
|
541
|
+
arguments_dict = json.loads(arguments)
|
|
542
|
+
elif isinstance(arguments, dict):
|
|
543
|
+
# Already a dict
|
|
544
|
+
arguments_dict = arguments
|
|
545
|
+
else:
|
|
546
|
+
# Default to empty dict
|
|
547
|
+
arguments_dict = {}
|
|
548
|
+
|
|
549
|
+
converted.append({
|
|
550
|
+
'type': 'tool_use',
|
|
551
|
+
'id': call_id,
|
|
552
|
+
'name': func_name,
|
|
553
|
+
'input': arguments_dict
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
return converted
|
|
557
|
+
|
|
558
|
+
def supports_streaming(self) -> bool:
|
|
559
|
+
"""Check if Ollama supports streaming."""
|
|
560
|
+
return True # Ollama supports streaming, but not implemented yet
|
|
561
|
+
|
|
562
|
+
def count_tokens(self, text: str) -> int:
|
|
563
|
+
"""
|
|
564
|
+
Count tokens using tiktoken (approximation for Ollama models).
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
text: Text to count tokens for
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
Approximate token count
|
|
571
|
+
"""
|
|
572
|
+
try:
|
|
573
|
+
encoding = tiktoken.get_encoding("cl100k_base")
|
|
574
|
+
return len(encoding.encode(text))
|
|
575
|
+
except Exception as e:
|
|
576
|
+
logging.warning(f"Token counting failed: {e}")
|
|
577
|
+
# Fallback: rough estimate of 4 chars per token
|
|
578
|
+
return len(text) // 4
|