superqode 0.1.5__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 (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,486 @@
1
+ """Enhanced Ollama client with full API support.
2
+
3
+ This module provides comprehensive access to the Ollama API including:
4
+ - Model listing and management
5
+ - Running model detection
6
+ - Detailed model information
7
+ - Model pull/delete operations
8
+ - Tool calling capability testing
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import os
14
+ import time
15
+ from datetime import datetime
16
+ from typing import Any, Dict, List, Optional
17
+ from urllib.error import URLError
18
+ from urllib.request import Request, urlopen
19
+
20
+ from superqode.providers.local.base import (
21
+ LocalProviderClient,
22
+ LocalProviderType,
23
+ LocalModel,
24
+ ProviderStatus,
25
+ ToolTestResult,
26
+ detect_model_family,
27
+ detect_quantization,
28
+ likely_supports_tools,
29
+ )
30
+
31
+
32
+ class OllamaClient(LocalProviderClient):
33
+ """Enhanced Ollama API client.
34
+
35
+ Provides full access to Ollama's REST API:
36
+ - GET /api/tags - List available models
37
+ - GET /api/ps - List running models
38
+ - POST /api/show - Get model details
39
+ - POST /api/pull - Pull a model
40
+ - DELETE /api/delete - Delete a model
41
+ - POST /api/chat - Chat completion (for tool testing)
42
+ - GET /api/version - Get Ollama version
43
+
44
+ Environment:
45
+ OLLAMA_HOST: Override default host (default: http://localhost:11434)
46
+ """
47
+
48
+ provider_type = LocalProviderType.OLLAMA
49
+ default_port = 11434
50
+
51
+ def __init__(self, host: Optional[str] = None):
52
+ """Initialize Ollama client.
53
+
54
+ Args:
55
+ host: Ollama host URL. Falls back to OLLAMA_HOST env var,
56
+ then to http://localhost:11434.
57
+ """
58
+ if host is None:
59
+ host = os.environ.get("OLLAMA_HOST")
60
+ super().__init__(host)
61
+ self._version: Optional[str] = None
62
+
63
+ def _request(
64
+ self, method: str, endpoint: str, data: Optional[Dict] = None, timeout: float = 30.0
65
+ ) -> Dict[str, Any]:
66
+ """Make a request to the Ollama API.
67
+
68
+ Args:
69
+ method: HTTP method (GET, POST, DELETE)
70
+ endpoint: API endpoint (e.g., "/api/tags")
71
+ data: Request body for POST/DELETE
72
+ timeout: Request timeout in seconds
73
+
74
+ Returns:
75
+ JSON response as dict.
76
+
77
+ Raises:
78
+ URLError: If request fails.
79
+ """
80
+ url = f"{self.host}{endpoint}"
81
+ headers = {"Content-Type": "application/json"}
82
+
83
+ body = None
84
+ if data is not None:
85
+ body = json.dumps(data).encode("utf-8")
86
+
87
+ request = Request(url, data=body, headers=headers, method=method)
88
+
89
+ with urlopen(request, timeout=timeout) as response:
90
+ return json.loads(response.read().decode("utf-8"))
91
+
92
+ async def _async_request(
93
+ self, method: str, endpoint: str, data: Optional[Dict] = None, timeout: float = 30.0
94
+ ) -> Dict[str, Any]:
95
+ """Async wrapper for _request."""
96
+ loop = asyncio.get_event_loop()
97
+ return await loop.run_in_executor(
98
+ None, lambda: self._request(method, endpoint, data, timeout)
99
+ )
100
+
101
+ async def is_available(self) -> bool:
102
+ """Check if Ollama is running."""
103
+ try:
104
+ await self._async_request("GET", "/api/tags", timeout=5.0)
105
+ return True
106
+ except Exception:
107
+ return False
108
+
109
+ async def get_status(self) -> ProviderStatus:
110
+ """Get detailed Ollama status."""
111
+ start_time = time.time()
112
+
113
+ try:
114
+ # Get models list
115
+ models_response = await self._async_request("GET", "/api/tags", timeout=5.0)
116
+ latency = (time.time() - start_time) * 1000
117
+
118
+ models = models_response.get("models", [])
119
+
120
+ # Get running models
121
+ try:
122
+ ps_response = await self._async_request("GET", "/api/ps", timeout=5.0)
123
+ running = ps_response.get("models", [])
124
+ running_count = len(running)
125
+ except Exception:
126
+ running_count = 0
127
+
128
+ # Get version
129
+ version = ""
130
+ try:
131
+ version_response = await self._async_request("GET", "/api/version", timeout=5.0)
132
+ version = version_response.get("version", "")
133
+ except Exception:
134
+ pass
135
+
136
+ return ProviderStatus(
137
+ available=True,
138
+ provider_type=self.provider_type,
139
+ host=self.host,
140
+ version=version,
141
+ models_count=len(models),
142
+ running_models=running_count,
143
+ gpu_available=True, # Ollama handles GPU detection
144
+ latency_ms=latency,
145
+ last_checked=datetime.now(),
146
+ )
147
+
148
+ except Exception as e:
149
+ return ProviderStatus(
150
+ available=False,
151
+ provider_type=self.provider_type,
152
+ host=self.host,
153
+ error=str(e),
154
+ last_checked=datetime.now(),
155
+ )
156
+
157
+ async def list_models(self) -> List[LocalModel]:
158
+ """List all available Ollama models."""
159
+ try:
160
+ response = await self._async_request("GET", "/api/tags")
161
+ models = response.get("models", [])
162
+
163
+ result = []
164
+ for model_data in models:
165
+ model = self._parse_model(model_data)
166
+ result.append(model)
167
+
168
+ return result
169
+
170
+ except Exception:
171
+ return []
172
+
173
+ async def list_running(self) -> List[LocalModel]:
174
+ """List models currently loaded in memory."""
175
+ try:
176
+ response = await self._async_request("GET", "/api/ps")
177
+ models = response.get("models", [])
178
+
179
+ result = []
180
+ for model_data in models:
181
+ model = self._parse_running_model(model_data)
182
+ result.append(model)
183
+
184
+ return result
185
+
186
+ except Exception:
187
+ return []
188
+
189
+ async def get_model_info(self, model_id: str) -> Optional[LocalModel]:
190
+ """Get detailed information about a model."""
191
+ try:
192
+ response = await self._async_request("POST", "/api/show", data={"name": model_id})
193
+
194
+ # Get basic model from list for size info
195
+ models = await self.list_models()
196
+ base_model = next((m for m in models if m.id == model_id), None)
197
+
198
+ model = self._parse_model_show(response, model_id)
199
+
200
+ # Merge size info from list
201
+ if base_model:
202
+ model.size_bytes = base_model.size_bytes
203
+ model.modified_at = base_model.modified_at
204
+ model.digest = base_model.digest
205
+
206
+ # Check if running
207
+ running = await self.list_running()
208
+ model.running = any(m.id == model_id for m in running)
209
+
210
+ return model
211
+
212
+ except Exception:
213
+ return None
214
+
215
+ async def test_tool_calling(self, model_id: str) -> ToolTestResult:
216
+ """Test if a model supports tool calling."""
217
+ start_time = time.time()
218
+
219
+ # First check heuristically
220
+ if not likely_supports_tools(model_id):
221
+ return ToolTestResult(
222
+ model_id=model_id,
223
+ supports_tools=False,
224
+ notes="Model family not known to support tools",
225
+ )
226
+
227
+ # Test with a simple tool call
228
+ test_tools = [
229
+ {
230
+ "type": "function",
231
+ "function": {
232
+ "name": "get_weather",
233
+ "description": "Get the weather for a city",
234
+ "parameters": {
235
+ "type": "object",
236
+ "properties": {"city": {"type": "string", "description": "City name"}},
237
+ "required": ["city"],
238
+ },
239
+ },
240
+ }
241
+ ]
242
+
243
+ test_messages = [{"role": "user", "content": "What's the weather in Paris?"}]
244
+
245
+ try:
246
+ response = await self._async_request(
247
+ "POST",
248
+ "/api/chat",
249
+ data={
250
+ "model": model_id,
251
+ "messages": test_messages,
252
+ "tools": test_tools,
253
+ "stream": False,
254
+ },
255
+ timeout=60.0,
256
+ )
257
+
258
+ latency = (time.time() - start_time) * 1000
259
+
260
+ message = response.get("message", {})
261
+ tool_calls = message.get("tool_calls", [])
262
+
263
+ if tool_calls:
264
+ return ToolTestResult(
265
+ model_id=model_id,
266
+ supports_tools=True,
267
+ parallel_tools=len(tool_calls) > 1,
268
+ tool_choice=["auto"],
269
+ latency_ms=latency,
270
+ notes="Tool calling verified",
271
+ )
272
+ else:
273
+ # Model responded but didn't call the tool
274
+ return ToolTestResult(
275
+ model_id=model_id,
276
+ supports_tools=False,
277
+ latency_ms=latency,
278
+ notes="Model did not call tool in test",
279
+ )
280
+
281
+ except Exception as e:
282
+ return ToolTestResult(
283
+ model_id=model_id, supports_tools=False, error=str(e), notes="Tool test failed"
284
+ )
285
+
286
+ async def pull_model(self, model_id: str) -> bool:
287
+ """Pull a model from Ollama registry."""
288
+ try:
289
+ # Note: This is a streaming endpoint in practice
290
+ # For simplicity, we just start the pull
291
+ await self._async_request(
292
+ "POST",
293
+ "/api/pull",
294
+ data={"name": model_id, "stream": False},
295
+ timeout=600.0, # 10 minute timeout for downloads
296
+ )
297
+ return True
298
+ except Exception:
299
+ return False
300
+
301
+ async def delete_model(self, model_id: str) -> bool:
302
+ """Delete a model."""
303
+ try:
304
+ await self._async_request("DELETE", "/api/delete", data={"name": model_id})
305
+ return True
306
+ except Exception:
307
+ return False
308
+
309
+ def get_litellm_model_name(self, model_id: str) -> str:
310
+ """Get LiteLLM-compatible model name."""
311
+ # LiteLLM uses "ollama/" prefix for Ollama models
312
+ if model_id.startswith("ollama/"):
313
+ return model_id
314
+ return f"ollama/{model_id}"
315
+
316
+ def _parse_model(self, data: Dict[str, Any]) -> LocalModel:
317
+ """Parse model from /api/tags response."""
318
+ model_id = data.get("name", data.get("model", "unknown"))
319
+
320
+ # Parse modified_at
321
+ modified_at = None
322
+ if "modified_at" in data:
323
+ try:
324
+ modified_at = datetime.fromisoformat(data["modified_at"].replace("Z", "+00:00"))
325
+ except Exception:
326
+ pass
327
+
328
+ # Extract details
329
+ details = data.get("details", {})
330
+ family = details.get("family", detect_model_family(model_id))
331
+ parameter_size = details.get("parameter_size", "")
332
+ quant = details.get("quantization_level", detect_quantization(model_id))
333
+
334
+ return LocalModel(
335
+ id=model_id,
336
+ name=self._format_model_name(model_id),
337
+ size_bytes=data.get("size", 0),
338
+ quantization=quant,
339
+ context_window=self._estimate_context(model_id, details),
340
+ supports_tools=likely_supports_tools(model_id),
341
+ supports_vision=self._supports_vision(model_id, details),
342
+ family=family,
343
+ running=False,
344
+ parameter_count=parameter_size,
345
+ modified_at=modified_at,
346
+ digest=data.get("digest", ""),
347
+ details=details,
348
+ )
349
+
350
+ def _parse_running_model(self, data: Dict[str, Any]) -> LocalModel:
351
+ """Parse model from /api/ps response."""
352
+ model_id = data.get("name", data.get("model", "unknown"))
353
+
354
+ # Running models have additional info
355
+ size_vram = data.get("size_vram", 0)
356
+
357
+ details = data.get("details", {})
358
+ family = details.get("family", detect_model_family(model_id))
359
+ parameter_size = details.get("parameter_size", "")
360
+ quant = details.get("quantization_level", detect_quantization(model_id))
361
+
362
+ return LocalModel(
363
+ id=model_id,
364
+ name=self._format_model_name(model_id),
365
+ size_bytes=data.get("size", 0),
366
+ quantization=quant,
367
+ context_window=self._estimate_context(model_id, details),
368
+ supports_tools=likely_supports_tools(model_id),
369
+ supports_vision=self._supports_vision(model_id, details),
370
+ family=family,
371
+ running=True,
372
+ vram_usage=size_vram,
373
+ parameter_count=parameter_size,
374
+ details=details,
375
+ )
376
+
377
+ def _parse_model_show(self, data: Dict[str, Any], model_id: str) -> LocalModel:
378
+ """Parse model from /api/show response."""
379
+ details = data.get("details", {})
380
+ modelfile = data.get("modelfile", "")
381
+ template = data.get("template", "")
382
+ parameters = data.get("parameters", "")
383
+
384
+ family = details.get("family", detect_model_family(model_id))
385
+ parameter_size = details.get("parameter_size", "")
386
+ quant = details.get("quantization_level", detect_quantization(model_id))
387
+
388
+ # Try to extract context window from parameters
389
+ context_window = self._extract_context_from_params(parameters)
390
+ if context_window == 0:
391
+ context_window = self._estimate_context(model_id, details)
392
+
393
+ return LocalModel(
394
+ id=model_id,
395
+ name=self._format_model_name(model_id),
396
+ quantization=quant,
397
+ context_window=context_window,
398
+ supports_tools=likely_supports_tools(model_id),
399
+ supports_vision=self._supports_vision(model_id, details),
400
+ family=family,
401
+ parameter_count=parameter_size,
402
+ details={
403
+ **details,
404
+ "modelfile": modelfile[:500] if modelfile else "",
405
+ "template": template[:500] if template else "",
406
+ "parameters": parameters,
407
+ },
408
+ )
409
+
410
+ def _format_model_name(self, model_id: str) -> str:
411
+ """Format model ID into display name."""
412
+ # Remove tag suffix for display
413
+ name = model_id.split(":")[0]
414
+
415
+ # Capitalize and format
416
+ name = name.replace("-", " ").replace("_", " ")
417
+ return name.title()
418
+
419
+ def _estimate_context(self, model_id: str, details: Dict) -> int:
420
+ """Estimate context window size."""
421
+ # Check if details has context info
422
+ if "context_length" in details:
423
+ return details["context_length"]
424
+
425
+ model_lower = model_id.lower()
426
+
427
+ # Known context sizes by model
428
+ if "llama3" in model_lower:
429
+ return 128000
430
+ if "qwen2.5" in model_lower:
431
+ return 32768
432
+ if "mistral" in model_lower:
433
+ return 32768
434
+ if "phi" in model_lower:
435
+ return 16384
436
+ if "gemma" in model_lower:
437
+ return 8192
438
+
439
+ # Default
440
+ return 4096
441
+
442
+ def _supports_vision(self, model_id: str, details: Dict) -> bool:
443
+ """Check if model supports vision/images."""
444
+ # Check families that support vision
445
+ families = details.get("families", [])
446
+ if "clip" in families or "vision" in families:
447
+ return True
448
+
449
+ model_lower = model_id.lower()
450
+
451
+ # Known vision models
452
+ vision_patterns = ["llava", "vision", "vl", "bakllava", "moondream"]
453
+ return any(p in model_lower for p in vision_patterns)
454
+
455
+ def _extract_context_from_params(self, parameters: str) -> int:
456
+ """Extract num_ctx from parameters string."""
457
+ if not parameters:
458
+ return 0
459
+
460
+ for line in parameters.split("\n"):
461
+ if "num_ctx" in line:
462
+ try:
463
+ # Format: "num_ctx 4096"
464
+ parts = line.strip().split()
465
+ if len(parts) >= 2:
466
+ return int(parts[1])
467
+ except Exception:
468
+ pass
469
+
470
+ return 0
471
+
472
+
473
+ # Convenience function
474
+ async def get_ollama_client(host: Optional[str] = None) -> Optional[OllamaClient]:
475
+ """Get an Ollama client if available.
476
+
477
+ Args:
478
+ host: Optional host override.
479
+
480
+ Returns:
481
+ OllamaClient if Ollama is running, None otherwise.
482
+ """
483
+ client = OllamaClient(host)
484
+ if await client.is_available():
485
+ return client
486
+ return None