voxagent 0.1.0__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.
Files changed (53) hide show
  1. voxagent/__init__.py +143 -0
  2. voxagent/_version.py +5 -0
  3. voxagent/agent/__init__.py +32 -0
  4. voxagent/agent/abort.py +178 -0
  5. voxagent/agent/core.py +902 -0
  6. voxagent/code/__init__.py +9 -0
  7. voxagent/mcp/__init__.py +16 -0
  8. voxagent/mcp/manager.py +188 -0
  9. voxagent/mcp/tool.py +152 -0
  10. voxagent/providers/__init__.py +110 -0
  11. voxagent/providers/anthropic.py +498 -0
  12. voxagent/providers/augment.py +293 -0
  13. voxagent/providers/auth.py +116 -0
  14. voxagent/providers/base.py +268 -0
  15. voxagent/providers/chatgpt.py +415 -0
  16. voxagent/providers/claudecode.py +162 -0
  17. voxagent/providers/cli_base.py +265 -0
  18. voxagent/providers/codex.py +183 -0
  19. voxagent/providers/failover.py +90 -0
  20. voxagent/providers/google.py +532 -0
  21. voxagent/providers/groq.py +96 -0
  22. voxagent/providers/ollama.py +425 -0
  23. voxagent/providers/openai.py +435 -0
  24. voxagent/providers/registry.py +175 -0
  25. voxagent/py.typed +1 -0
  26. voxagent/security/__init__.py +14 -0
  27. voxagent/security/events.py +75 -0
  28. voxagent/security/filter.py +169 -0
  29. voxagent/security/registry.py +87 -0
  30. voxagent/session/__init__.py +39 -0
  31. voxagent/session/compaction.py +237 -0
  32. voxagent/session/lock.py +103 -0
  33. voxagent/session/model.py +109 -0
  34. voxagent/session/storage.py +184 -0
  35. voxagent/streaming/__init__.py +52 -0
  36. voxagent/streaming/emitter.py +286 -0
  37. voxagent/streaming/events.py +255 -0
  38. voxagent/subagent/__init__.py +20 -0
  39. voxagent/subagent/context.py +124 -0
  40. voxagent/subagent/definition.py +172 -0
  41. voxagent/tools/__init__.py +32 -0
  42. voxagent/tools/context.py +50 -0
  43. voxagent/tools/decorator.py +175 -0
  44. voxagent/tools/definition.py +131 -0
  45. voxagent/tools/executor.py +109 -0
  46. voxagent/tools/policy.py +89 -0
  47. voxagent/tools/registry.py +89 -0
  48. voxagent/types/__init__.py +46 -0
  49. voxagent/types/messages.py +134 -0
  50. voxagent/types/run.py +176 -0
  51. voxagent-0.1.0.dist-info/METADATA +186 -0
  52. voxagent-0.1.0.dist-info/RECORD +53 -0
  53. voxagent-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,425 @@
1
+ """Ollama provider implementation.
2
+
3
+ This module implements the OllamaProvider for local Ollama models,
4
+ supporting NDJSON streaming and tool calling.
5
+
6
+ Ollama is unique:
7
+ - No API key required (local server)
8
+ - NDJSON streaming (not SSE)
9
+ - Dynamic model list from server
10
+ """
11
+
12
+ import json
13
+ from collections.abc import AsyncIterator
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from voxagent.providers.base import (
19
+ AbortSignal,
20
+ BaseProvider,
21
+ ErrorChunk,
22
+ MessageEndChunk,
23
+ StreamChunk,
24
+ TextDeltaChunk,
25
+ ToolUseChunk,
26
+ )
27
+ from voxagent.types import Message, ToolCall
28
+
29
+
30
+ class OllamaProvider(BaseProvider):
31
+ """Provider for local Ollama models.
32
+
33
+ Supports streaming via NDJSON, tool calling, and dynamic model discovery.
34
+ No API key required as Ollama runs locally.
35
+ """
36
+
37
+ ENV_KEY = "" # No API key needed
38
+ DEFAULT_BASE_URL = "http://localhost:11434"
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str | None = None, # Ignored
43
+ base_url: str | None = None,
44
+ model: str = "llama3.3",
45
+ **kwargs: Any,
46
+ ) -> None:
47
+ """Initialize the Ollama provider.
48
+
49
+ Args:
50
+ api_key: Ignored - Ollama doesn't need authentication.
51
+ base_url: Custom base URL for remote Ollama servers.
52
+ model: Model name to use. Defaults to "llama3.3".
53
+ **kwargs: Additional provider-specific arguments.
54
+ """
55
+ super().__init__(api_key=api_key, base_url=base_url, **kwargs)
56
+ self._model = model
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ """Get the provider name."""
61
+ return "ollama"
62
+
63
+ @property
64
+ def model(self) -> str:
65
+ """Get the current model name."""
66
+ return self._model
67
+
68
+ @property
69
+ def models(self) -> list[str]:
70
+ """Get the list of supported model names.
71
+
72
+ Returns empty list as models are dynamic from server.
73
+ Use list_local_models() to fetch available models.
74
+ """
75
+ return []
76
+
77
+ @property
78
+ def supports_tools(self) -> bool:
79
+ """Check if the provider supports tool/function calling."""
80
+ return True
81
+
82
+ @property
83
+ def supports_streaming(self) -> bool:
84
+ """Check if the provider supports streaming responses."""
85
+ return True
86
+
87
+ @property
88
+ def context_limit(self) -> int:
89
+ """Get the maximum context length in tokens."""
90
+ return 128000
91
+
92
+ def _get_api_endpoint(self) -> str:
93
+ """Get the API endpoint for chat."""
94
+ base = self._base_url or self.DEFAULT_BASE_URL
95
+ return f"{base}/api/chat"
96
+
97
+ def _get_tags_endpoint(self) -> str:
98
+ """Get the API endpoint for listing models."""
99
+ base = self._base_url or self.DEFAULT_BASE_URL
100
+ return f"{base}/api/tags"
101
+
102
+ def _convert_messages_to_ollama(
103
+ self, messages: list[Message], system: str | None = None
104
+ ) -> list[dict[str, Any]]:
105
+ """Convert voxagent Messages to Ollama message format.
106
+
107
+ Args:
108
+ messages: List of voxagent Message objects.
109
+ system: Optional system prompt to prepend.
110
+
111
+ Returns:
112
+ List of Ollama-format message dictionaries.
113
+ """
114
+ result: list[dict[str, Any]] = []
115
+
116
+ if system:
117
+ result.append({"role": "system", "content": system})
118
+
119
+ for msg in messages:
120
+ ollama_msg: dict[str, Any] = {"role": msg.role}
121
+
122
+ # Handle content
123
+ if isinstance(msg.content, str):
124
+ ollama_msg["content"] = msg.content
125
+ else:
126
+ # Handle content blocks - convert to string
127
+ text_parts = [b.text for b in msg.content if hasattr(b, "text")]
128
+ ollama_msg["content"] = " ".join(text_parts) if text_parts else ""
129
+
130
+ # Handle tool calls
131
+ if msg.tool_calls:
132
+ ollama_msg["tool_calls"] = [
133
+ {
134
+ "id": tc.id,
135
+ "type": "function",
136
+ "function": {
137
+ "name": tc.name,
138
+ "arguments": tc.params,
139
+ },
140
+ }
141
+ for tc in msg.tool_calls
142
+ ]
143
+
144
+ result.append(ollama_msg)
145
+
146
+ return result
147
+
148
+ def _convert_ollama_response_to_message(self, response: dict[str, Any]) -> Message:
149
+ """Convert Ollama response to voxagent Message.
150
+
151
+ Args:
152
+ response: Ollama API response dictionary.
153
+
154
+ Returns:
155
+ A voxagent Message object.
156
+ """
157
+ msg_data = response.get("message", {})
158
+ role = msg_data.get("role", "assistant")
159
+ content = msg_data.get("content", "")
160
+
161
+ tool_calls: list[ToolCall] | None = None
162
+ if "tool_calls" in msg_data and msg_data["tool_calls"]:
163
+ tool_calls = []
164
+ for tc in msg_data["tool_calls"]:
165
+ func = tc.get("function", {})
166
+ params = func.get("arguments", {})
167
+ tool_calls.append(
168
+ ToolCall(id=tc.get("id", ""), name=func.get("name", ""), params=params)
169
+ )
170
+
171
+ return Message(role=role, content=content, tool_calls=tool_calls)
172
+
173
+ def _parse_ndjson_line(self, data: dict[str, Any]) -> StreamChunk | None:
174
+ """Parse an NDJSON line and return appropriate StreamChunk.
175
+
176
+ Args:
177
+ data: The parsed JSON data from a line.
178
+
179
+ Returns:
180
+ A StreamChunk or None if no chunk should be yielded.
181
+ """
182
+ # Check for error response
183
+ if "error" in data:
184
+ return ErrorChunk(error=data["error"])
185
+
186
+ msg_data = data.get("message", {})
187
+ done = data.get("done", False)
188
+
189
+ # Check for tool calls first
190
+ if "tool_calls" in msg_data and msg_data["tool_calls"]:
191
+ tc = msg_data["tool_calls"][0] # Process first tool call
192
+ func = tc.get("function", {})
193
+ params = func.get("arguments", {})
194
+ return ToolUseChunk(
195
+ tool_call=ToolCall(
196
+ id=tc.get("id", ""),
197
+ name=func.get("name", ""),
198
+ params=params,
199
+ )
200
+ )
201
+
202
+ # Check for text content
203
+ content = msg_data.get("content", "")
204
+ if content:
205
+ return TextDeltaChunk(delta=content)
206
+
207
+ # Check if done
208
+ if done:
209
+ return MessageEndChunk()
210
+
211
+ return None
212
+
213
+ def _build_request_body(
214
+ self,
215
+ messages: list[Message],
216
+ system: str | None = None,
217
+ tools: list[Any] | None = None,
218
+ stream: bool = False,
219
+ ) -> dict[str, Any]:
220
+ """Build the request body for Ollama API.
221
+
222
+ Args:
223
+ messages: List of voxagent Message objects.
224
+ system: Optional system prompt.
225
+ tools: Optional list of tool definitions.
226
+ stream: Whether to enable streaming.
227
+
228
+ Returns:
229
+ Request body dictionary.
230
+ """
231
+ body: dict[str, Any] = {
232
+ "model": self._model,
233
+ "messages": self._convert_messages_to_ollama(messages, system=system),
234
+ "stream": stream,
235
+ }
236
+
237
+ if tools:
238
+ body["tools"] = tools
239
+
240
+ return body
241
+
242
+ async def _make_request(
243
+ self,
244
+ messages: list[Message],
245
+ system: str | None = None,
246
+ tools: list[Any] | None = None,
247
+ _endpoint: str | None = None,
248
+ ) -> dict[str, Any]:
249
+ """Make a non-streaming request to the Ollama API.
250
+
251
+ Args:
252
+ messages: List of voxagent Message objects.
253
+ system: Optional system prompt.
254
+ tools: Optional list of tool definitions.
255
+ _endpoint: Internal parameter for endpoint selection ("tags" for model list).
256
+
257
+ Returns:
258
+ The JSON response from the API.
259
+
260
+ Raises:
261
+ Exception: If the API request fails.
262
+ """
263
+ async with httpx.AsyncClient() as client:
264
+ if _endpoint == "tags":
265
+ # GET request for listing models
266
+ response = await client.get(
267
+ self._get_tags_endpoint(),
268
+ timeout=30.0,
269
+ )
270
+ else:
271
+ # POST request for chat
272
+ body = self._build_request_body(messages, system=system, tools=tools, stream=False)
273
+ response = await client.post(
274
+ self._get_api_endpoint(),
275
+ json=body,
276
+ timeout=120.0,
277
+ )
278
+ response.raise_for_status()
279
+ return response.json()
280
+
281
+ async def _make_streaming_request(
282
+ self,
283
+ messages: list[Message],
284
+ system: str | None = None,
285
+ tools: list[Any] | None = None,
286
+ ) -> AsyncIterator[str]:
287
+ """Make a streaming request to the Ollama API.
288
+
289
+ Args:
290
+ messages: List of voxagent Message objects.
291
+ system: Optional system prompt.
292
+ tools: Optional list of tool definitions.
293
+
294
+ Yields:
295
+ NDJSON lines from the response.
296
+ """
297
+ body = self._build_request_body(messages, system=system, tools=tools, stream=True)
298
+
299
+ async with httpx.AsyncClient() as client:
300
+ async with client.stream(
301
+ "POST",
302
+ self._get_api_endpoint(),
303
+ json=body,
304
+ timeout=120.0,
305
+ ) as response:
306
+ response.raise_for_status()
307
+ async for line in response.aiter_lines():
308
+ if line:
309
+ yield line
310
+
311
+ async def stream(
312
+ self,
313
+ messages: list[Message],
314
+ system: str | None = None,
315
+ tools: list[Any] | None = None,
316
+ abort_signal: AbortSignal | None = None,
317
+ ) -> AsyncIterator[StreamChunk]:
318
+ """Stream a response from the Ollama API.
319
+
320
+ Args:
321
+ messages: The conversation messages.
322
+ system: Optional system prompt.
323
+ tools: Optional list of tool definitions.
324
+ abort_signal: Optional signal to abort the stream.
325
+
326
+ Yields:
327
+ StreamChunk objects containing response data.
328
+ """
329
+ try:
330
+ async for line in self._make_streaming_request(messages, system=system, tools=tools):
331
+ if abort_signal and abort_signal.aborted:
332
+ return
333
+
334
+ try:
335
+ data = json.loads(line)
336
+ chunk = self._parse_ndjson_line(data)
337
+ if chunk:
338
+ yield chunk
339
+ if isinstance(chunk, MessageEndChunk):
340
+ return
341
+ except json.JSONDecodeError:
342
+ continue
343
+
344
+ except Exception as e:
345
+ yield ErrorChunk(error=str(e))
346
+
347
+ async def complete(
348
+ self,
349
+ messages: list[Message],
350
+ system: str | None = None,
351
+ tools: list[Any] | None = None,
352
+ ) -> Message:
353
+ """Get a complete response from the Ollama API.
354
+
355
+ Args:
356
+ messages: The conversation messages.
357
+ system: Optional system prompt.
358
+ tools: Optional list of tool definitions.
359
+
360
+ Returns:
361
+ The assistant's response message.
362
+
363
+ Raises:
364
+ Exception: If the API request fails.
365
+ """
366
+ response = await self._make_request(messages, system=system, tools=tools)
367
+ return self._convert_ollama_response_to_message(response)
368
+
369
+ def count_tokens(
370
+ self,
371
+ messages: list[Message],
372
+ system: str | None = None,
373
+ ) -> int:
374
+ """Count tokens in the messages.
375
+
376
+ Uses a simple estimation based on character count.
377
+ Ollama doesn't provide a token counting API.
378
+
379
+ Args:
380
+ messages: The conversation messages.
381
+ system: Optional system prompt.
382
+
383
+ Returns:
384
+ Estimated token count.
385
+ """
386
+ # Approximate token counting (roughly 4 chars per token for English)
387
+ char_count = 0
388
+
389
+ if system:
390
+ char_count += len(system)
391
+
392
+ for msg in messages:
393
+ if isinstance(msg.content, str):
394
+ char_count += len(msg.content)
395
+ else:
396
+ for block in msg.content:
397
+ if hasattr(block, "text"):
398
+ char_count += len(block.text)
399
+
400
+ # Add overhead for role and structure
401
+ char_count += 10
402
+
403
+ if msg.tool_calls:
404
+ for tc in msg.tool_calls:
405
+ char_count += len(tc.name) + len(json.dumps(tc.params))
406
+
407
+ # Rough estimate: 4 characters per token
408
+ return max(1, char_count // 4)
409
+
410
+ async def list_local_models(self) -> list[str]:
411
+ """List locally available models from Ollama server.
412
+
413
+ Returns:
414
+ List of model names available on the server.
415
+
416
+ Raises:
417
+ Exception: If the server connection fails.
418
+ """
419
+ response = await self._make_request(messages=[], _endpoint="tags")
420
+ models = response.get("models", [])
421
+ return [m.get("name", "") for m in models if m.get("name")]
422
+
423
+
424
+ __all__ = ["OllamaProvider"]
425
+