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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- 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
|