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,418 @@
|
|
|
1
|
+
"""Local provider auto-discovery service.
|
|
2
|
+
|
|
3
|
+
This module provides automatic discovery of running local LLM servers
|
|
4
|
+
by scanning common ports and detecting provider types.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import socket
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
from urllib.error import URLError
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from superqode.providers.local.base import (
|
|
17
|
+
LocalProviderType,
|
|
18
|
+
LocalProviderClient,
|
|
19
|
+
LocalModel,
|
|
20
|
+
ProviderStatus,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Default ports for each provider type
|
|
25
|
+
DEFAULT_PORTS = {
|
|
26
|
+
LocalProviderType.OLLAMA: [11434],
|
|
27
|
+
LocalProviderType.LMSTUDIO: [1234],
|
|
28
|
+
LocalProviderType.VLLM: [8000],
|
|
29
|
+
LocalProviderType.SGLANG: [30000],
|
|
30
|
+
LocalProviderType.TGI: [8080],
|
|
31
|
+
LocalProviderType.MLX: [8080],
|
|
32
|
+
LocalProviderType.LLAMACPP: [8080],
|
|
33
|
+
LocalProviderType.OPENAI_COMPAT: [8000, 8080, 5000],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# All ports to scan for discovery
|
|
37
|
+
ALL_PORTS = [11434, 1234, 8000, 8080, 30000, 5000, 3000]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class DiscoveredProvider:
|
|
42
|
+
"""A discovered local LLM provider.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
provider_type: Type of provider detected
|
|
46
|
+
host: Provider host URL
|
|
47
|
+
port: Port number
|
|
48
|
+
version: Provider version (if available)
|
|
49
|
+
models: List of available models
|
|
50
|
+
running_models: List of models currently loaded
|
|
51
|
+
latency_ms: Discovery latency
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
provider_type: LocalProviderType
|
|
55
|
+
host: str
|
|
56
|
+
port: int
|
|
57
|
+
version: str = ""
|
|
58
|
+
models: List[LocalModel] = field(default_factory=list)
|
|
59
|
+
running_models: List[LocalModel] = field(default_factory=list)
|
|
60
|
+
latency_ms: float = 0.0
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def url(self) -> str:
|
|
64
|
+
"""Get the full provider URL."""
|
|
65
|
+
return f"http://localhost:{self.port}"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def model_count(self) -> int:
|
|
69
|
+
"""Get number of available models."""
|
|
70
|
+
return len(self.models)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def running_count(self) -> int:
|
|
74
|
+
"""Get number of running models."""
|
|
75
|
+
return len(self.running_models)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LocalProviderDiscovery:
|
|
79
|
+
"""Discovers running local LLM servers.
|
|
80
|
+
|
|
81
|
+
Scans common ports and detects provider types:
|
|
82
|
+
- 11434: Ollama
|
|
83
|
+
- 1234: LM Studio
|
|
84
|
+
- 8000: vLLM, OpenAI-compatible
|
|
85
|
+
- 30000: SGLang
|
|
86
|
+
- 8080: TGI, MLX, llama.cpp
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, timeout: float = 2.0):
|
|
90
|
+
"""Initialize the discovery service.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
timeout: Connection timeout for port scanning.
|
|
94
|
+
"""
|
|
95
|
+
self._timeout = timeout
|
|
96
|
+
self._discovered: Dict[str, DiscoveredProvider] = {}
|
|
97
|
+
|
|
98
|
+
async def scan_all(self) -> Dict[str, DiscoveredProvider]:
|
|
99
|
+
"""Scan all common ports for local providers.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dict mapping host:port to DiscoveredProvider.
|
|
103
|
+
"""
|
|
104
|
+
# Check all ports in parallel
|
|
105
|
+
tasks = []
|
|
106
|
+
for port in ALL_PORTS:
|
|
107
|
+
tasks.append(self._scan_port(port))
|
|
108
|
+
|
|
109
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
110
|
+
|
|
111
|
+
discovered = {}
|
|
112
|
+
for result in results:
|
|
113
|
+
if isinstance(result, DiscoveredProvider):
|
|
114
|
+
key = f"localhost:{result.port}"
|
|
115
|
+
discovered[key] = result
|
|
116
|
+
|
|
117
|
+
self._discovered = discovered
|
|
118
|
+
return discovered
|
|
119
|
+
|
|
120
|
+
async def scan_port(self, port: int) -> Optional[DiscoveredProvider]:
|
|
121
|
+
"""Scan a specific port for a provider.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
port: Port number to scan.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
DiscoveredProvider if found, None otherwise.
|
|
128
|
+
"""
|
|
129
|
+
return await self._scan_port(port)
|
|
130
|
+
|
|
131
|
+
async def _scan_port(self, port: int) -> Optional[DiscoveredProvider]:
|
|
132
|
+
"""Internal port scanning implementation."""
|
|
133
|
+
# First check if port is open
|
|
134
|
+
if not await self._is_port_open(port):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
start_time = time.time()
|
|
138
|
+
|
|
139
|
+
# Try to detect provider type
|
|
140
|
+
provider_type = await self._detect_provider_type(port)
|
|
141
|
+
|
|
142
|
+
if provider_type is None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
latency = (time.time() - start_time) * 1000
|
|
146
|
+
|
|
147
|
+
# Get provider details
|
|
148
|
+
version = await self._get_version(port, provider_type)
|
|
149
|
+
models = await self._list_models(port, provider_type)
|
|
150
|
+
running = await self._list_running(port, provider_type)
|
|
151
|
+
|
|
152
|
+
return DiscoveredProvider(
|
|
153
|
+
provider_type=provider_type,
|
|
154
|
+
host=f"http://localhost:{port}",
|
|
155
|
+
port=port,
|
|
156
|
+
version=version,
|
|
157
|
+
models=models,
|
|
158
|
+
running_models=running,
|
|
159
|
+
latency_ms=latency,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def _is_port_open(self, port: int) -> bool:
|
|
163
|
+
"""Check if a port is open."""
|
|
164
|
+
loop = asyncio.get_event_loop()
|
|
165
|
+
|
|
166
|
+
def check():
|
|
167
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
168
|
+
sock.settimeout(self._timeout)
|
|
169
|
+
try:
|
|
170
|
+
result = sock.connect_ex(("localhost", port))
|
|
171
|
+
return result == 0
|
|
172
|
+
finally:
|
|
173
|
+
sock.close()
|
|
174
|
+
|
|
175
|
+
return await loop.run_in_executor(None, check)
|
|
176
|
+
|
|
177
|
+
async def _detect_provider_type(self, port: int) -> Optional[LocalProviderType]:
|
|
178
|
+
"""Detect the provider type from port responses."""
|
|
179
|
+
loop = asyncio.get_event_loop()
|
|
180
|
+
|
|
181
|
+
def detect():
|
|
182
|
+
# Try Ollama-specific endpoint
|
|
183
|
+
if port == 11434:
|
|
184
|
+
try:
|
|
185
|
+
req = Request(f"http://localhost:{port}/api/tags")
|
|
186
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
187
|
+
data = json.loads(resp.read())
|
|
188
|
+
if "models" in data:
|
|
189
|
+
return LocalProviderType.OLLAMA
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
# Try LM Studio-specific detection
|
|
194
|
+
if port == 1234:
|
|
195
|
+
try:
|
|
196
|
+
req = Request(f"http://localhost:{port}/v1/models")
|
|
197
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
198
|
+
data = json.loads(resp.read())
|
|
199
|
+
if "data" in data:
|
|
200
|
+
return LocalProviderType.LMSTUDIO
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
# Try SGLang-specific detection (has /health endpoint)
|
|
205
|
+
if port == 30000:
|
|
206
|
+
try:
|
|
207
|
+
req = Request(f"http://localhost:{port}/health")
|
|
208
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
209
|
+
return LocalProviderType.SGLANG
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
# Try TGI-specific detection (has /info endpoint)
|
|
214
|
+
try:
|
|
215
|
+
req = Request(f"http://localhost:{port}/info")
|
|
216
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
217
|
+
data = json.loads(resp.read())
|
|
218
|
+
if "model_id" in data:
|
|
219
|
+
return LocalProviderType.TGI
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
# Try vLLM-specific detection
|
|
224
|
+
if port == 8000:
|
|
225
|
+
try:
|
|
226
|
+
req = Request(f"http://localhost:{port}/health")
|
|
227
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
228
|
+
# vLLM health endpoint exists
|
|
229
|
+
pass
|
|
230
|
+
# Also check for models endpoint
|
|
231
|
+
req2 = Request(f"http://localhost:{port}/v1/models")
|
|
232
|
+
with urlopen(req2, timeout=self._timeout) as resp:
|
|
233
|
+
return LocalProviderType.VLLM
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
# Generic OpenAI-compatible check
|
|
238
|
+
try:
|
|
239
|
+
req = Request(f"http://localhost:{port}/v1/models")
|
|
240
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
241
|
+
data = json.loads(resp.read())
|
|
242
|
+
if "data" in data:
|
|
243
|
+
# Could be MLX, llama.cpp, or generic OpenAI-compatible
|
|
244
|
+
if port == 8080:
|
|
245
|
+
return LocalProviderType.MLX # Common MLX port
|
|
246
|
+
return LocalProviderType.OPENAI_COMPAT
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
return await loop.run_in_executor(None, detect)
|
|
253
|
+
|
|
254
|
+
async def _get_version(self, port: int, provider_type: LocalProviderType) -> str:
|
|
255
|
+
"""Get provider version string."""
|
|
256
|
+
loop = asyncio.get_event_loop()
|
|
257
|
+
|
|
258
|
+
def get_ver():
|
|
259
|
+
if provider_type == LocalProviderType.OLLAMA:
|
|
260
|
+
try:
|
|
261
|
+
req = Request(f"http://localhost:{port}/api/version")
|
|
262
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
263
|
+
data = json.loads(resp.read())
|
|
264
|
+
return data.get("version", "")
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
if provider_type == LocalProviderType.TGI:
|
|
269
|
+
try:
|
|
270
|
+
req = Request(f"http://localhost:{port}/info")
|
|
271
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
272
|
+
data = json.loads(resp.read())
|
|
273
|
+
return data.get("version", "")
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
return ""
|
|
278
|
+
|
|
279
|
+
return await loop.run_in_executor(None, get_ver)
|
|
280
|
+
|
|
281
|
+
async def _list_models(self, port: int, provider_type: LocalProviderType) -> List[LocalModel]:
|
|
282
|
+
"""List available models from provider."""
|
|
283
|
+
loop = asyncio.get_event_loop()
|
|
284
|
+
|
|
285
|
+
def list_mod():
|
|
286
|
+
models = []
|
|
287
|
+
|
|
288
|
+
if provider_type == LocalProviderType.OLLAMA:
|
|
289
|
+
try:
|
|
290
|
+
req = Request(f"http://localhost:{port}/api/tags")
|
|
291
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
292
|
+
data = json.loads(resp.read())
|
|
293
|
+
for m in data.get("models", []):
|
|
294
|
+
models.append(
|
|
295
|
+
LocalModel(
|
|
296
|
+
id=m.get("name", ""),
|
|
297
|
+
name=m.get("name", "").split(":")[0].title(),
|
|
298
|
+
size_bytes=m.get("size", 0),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
elif provider_type in (
|
|
305
|
+
LocalProviderType.LMSTUDIO,
|
|
306
|
+
LocalProviderType.VLLM,
|
|
307
|
+
LocalProviderType.OPENAI_COMPAT,
|
|
308
|
+
LocalProviderType.MLX,
|
|
309
|
+
):
|
|
310
|
+
try:
|
|
311
|
+
req = Request(f"http://localhost:{port}/v1/models")
|
|
312
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
313
|
+
data = json.loads(resp.read())
|
|
314
|
+
for m in data.get("data", []):
|
|
315
|
+
models.append(
|
|
316
|
+
LocalModel(
|
|
317
|
+
id=m.get("id", ""),
|
|
318
|
+
name=m.get("id", "").split("/")[-1],
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
elif provider_type == LocalProviderType.TGI:
|
|
325
|
+
try:
|
|
326
|
+
req = Request(f"http://localhost:{port}/info")
|
|
327
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
328
|
+
data = json.loads(resp.read())
|
|
329
|
+
model_id = data.get("model_id", "")
|
|
330
|
+
if model_id:
|
|
331
|
+
models.append(
|
|
332
|
+
LocalModel(
|
|
333
|
+
id=model_id,
|
|
334
|
+
name=model_id.split("/")[-1],
|
|
335
|
+
context_window=data.get("max_input_length", 4096),
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
return models
|
|
342
|
+
|
|
343
|
+
return await loop.run_in_executor(None, list_mod)
|
|
344
|
+
|
|
345
|
+
async def _list_running(self, port: int, provider_type: LocalProviderType) -> List[LocalModel]:
|
|
346
|
+
"""List running models from provider."""
|
|
347
|
+
loop = asyncio.get_event_loop()
|
|
348
|
+
|
|
349
|
+
def list_run():
|
|
350
|
+
if provider_type != LocalProviderType.OLLAMA:
|
|
351
|
+
return [] # Only Ollama has running model tracking
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
req = Request(f"http://localhost:{port}/api/ps")
|
|
355
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
356
|
+
data = json.loads(resp.read())
|
|
357
|
+
return [
|
|
358
|
+
LocalModel(
|
|
359
|
+
id=m.get("name", ""),
|
|
360
|
+
name=m.get("name", "").split(":")[0].title(),
|
|
361
|
+
running=True,
|
|
362
|
+
vram_usage=m.get("size_vram", 0),
|
|
363
|
+
)
|
|
364
|
+
for m in data.get("models", [])
|
|
365
|
+
]
|
|
366
|
+
except Exception:
|
|
367
|
+
return []
|
|
368
|
+
|
|
369
|
+
return await loop.run_in_executor(None, list_run)
|
|
370
|
+
|
|
371
|
+
async def discover_models(self) -> List[LocalModel]:
|
|
372
|
+
"""Discover all available models from all running providers.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Combined list of LocalModel from all discovered providers.
|
|
376
|
+
"""
|
|
377
|
+
if not self._discovered:
|
|
378
|
+
await self.scan_all()
|
|
379
|
+
|
|
380
|
+
all_models = []
|
|
381
|
+
for provider in self._discovered.values():
|
|
382
|
+
for model in provider.models:
|
|
383
|
+
# Add provider info to model
|
|
384
|
+
model.details["provider_type"] = provider.provider_type.value
|
|
385
|
+
model.details["provider_host"] = provider.host
|
|
386
|
+
all_models.append(model)
|
|
387
|
+
|
|
388
|
+
return all_models
|
|
389
|
+
|
|
390
|
+
def get_discovered(self) -> Dict[str, DiscoveredProvider]:
|
|
391
|
+
"""Get cached discovered providers."""
|
|
392
|
+
return self._discovered
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# Singleton instance
|
|
396
|
+
_discovery_instance: Optional[LocalProviderDiscovery] = None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def get_discovery_service() -> LocalProviderDiscovery:
|
|
400
|
+
"""Get the global discovery service instance.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
LocalProviderDiscovery instance.
|
|
404
|
+
"""
|
|
405
|
+
global _discovery_instance
|
|
406
|
+
if _discovery_instance is None:
|
|
407
|
+
_discovery_instance = LocalProviderDiscovery()
|
|
408
|
+
return _discovery_instance
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def quick_scan() -> Dict[str, DiscoveredProvider]:
|
|
412
|
+
"""Perform a quick scan for local providers.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Dict of discovered providers.
|
|
416
|
+
"""
|
|
417
|
+
service = get_discovery_service()
|
|
418
|
+
return await service.scan_all()
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""LM Studio client for local model inference.
|
|
2
|
+
|
|
3
|
+
LM Studio is a desktop application for running LLMs locally with
|
|
4
|
+
a user-friendly interface and OpenAI-compatible API server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
from urllib.error import URLError
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from superqode.providers.local.base import (
|
|
17
|
+
LocalProviderClient,
|
|
18
|
+
LocalProviderType,
|
|
19
|
+
LocalModel,
|
|
20
|
+
ProviderStatus,
|
|
21
|
+
ToolTestResult,
|
|
22
|
+
detect_model_family,
|
|
23
|
+
detect_quantization,
|
|
24
|
+
likely_supports_tools,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LMStudioClient(LocalProviderClient):
|
|
29
|
+
"""LM Studio local server client.
|
|
30
|
+
|
|
31
|
+
LM Studio provides:
|
|
32
|
+
- User-friendly GUI for model management
|
|
33
|
+
- OpenAI-compatible local server
|
|
34
|
+
- GGUF model support
|
|
35
|
+
- GPU and CPU inference
|
|
36
|
+
|
|
37
|
+
API Endpoints (OpenAI-compatible):
|
|
38
|
+
- GET /v1/models - List loaded models
|
|
39
|
+
- POST /v1/chat/completions - Chat completion
|
|
40
|
+
- POST /v1/completions - Text completion
|
|
41
|
+
- POST /v1/embeddings - Embeddings
|
|
42
|
+
|
|
43
|
+
Environment:
|
|
44
|
+
LMSTUDIO_HOST: Override default host (default: http://localhost:1234)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
provider_type = LocalProviderType.LMSTUDIO
|
|
48
|
+
default_port = 1234
|
|
49
|
+
|
|
50
|
+
def __init__(self, host: Optional[str] = None):
|
|
51
|
+
"""Initialize LM Studio client.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
host: LM Studio host URL. Falls back to LMSTUDIO_HOST env var.
|
|
55
|
+
"""
|
|
56
|
+
if host is None:
|
|
57
|
+
host = os.environ.get("LMSTUDIO_HOST")
|
|
58
|
+
super().__init__(host)
|
|
59
|
+
|
|
60
|
+
def _request(
|
|
61
|
+
self, method: str, endpoint: str, data: Optional[Dict] = None, timeout: float = 30.0
|
|
62
|
+
) -> Any:
|
|
63
|
+
"""Make a request to the LM Studio API."""
|
|
64
|
+
url = f"{self.host}{endpoint}"
|
|
65
|
+
headers = {"Content-Type": "application/json"}
|
|
66
|
+
|
|
67
|
+
body = None
|
|
68
|
+
if data is not None:
|
|
69
|
+
body = json.dumps(data).encode("utf-8")
|
|
70
|
+
|
|
71
|
+
request = Request(url, data=body, headers=headers, method=method)
|
|
72
|
+
|
|
73
|
+
with urlopen(request, timeout=timeout) as response:
|
|
74
|
+
return json.loads(response.read().decode("utf-8"))
|
|
75
|
+
|
|
76
|
+
async def _async_request(
|
|
77
|
+
self, method: str, endpoint: str, data: Optional[Dict] = None, timeout: float = 30.0
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Async wrapper for _request."""
|
|
80
|
+
loop = asyncio.get_event_loop()
|
|
81
|
+
return await loop.run_in_executor(
|
|
82
|
+
None, lambda: self._request(method, endpoint, data, timeout)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def is_available(self) -> bool:
|
|
86
|
+
"""Check if LM Studio server is running."""
|
|
87
|
+
try:
|
|
88
|
+
await self._async_request("GET", "/v1/models", timeout=5.0)
|
|
89
|
+
return True
|
|
90
|
+
except Exception:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
async def get_status(self) -> ProviderStatus:
|
|
94
|
+
"""Get detailed LM Studio status."""
|
|
95
|
+
start_time = time.time()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
models_response = await self._async_request("GET", "/v1/models", timeout=5.0)
|
|
99
|
+
latency = (time.time() - start_time) * 1000
|
|
100
|
+
|
|
101
|
+
models = models_response.get("data", [])
|
|
102
|
+
|
|
103
|
+
return ProviderStatus(
|
|
104
|
+
available=True,
|
|
105
|
+
provider_type=self.provider_type,
|
|
106
|
+
host=self.host,
|
|
107
|
+
version="LM Studio",
|
|
108
|
+
models_count=len(models),
|
|
109
|
+
running_models=len(models),
|
|
110
|
+
gpu_available=True,
|
|
111
|
+
latency_ms=latency,
|
|
112
|
+
last_checked=datetime.now(),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return ProviderStatus(
|
|
117
|
+
available=False,
|
|
118
|
+
provider_type=self.provider_type,
|
|
119
|
+
host=self.host,
|
|
120
|
+
error=str(e),
|
|
121
|
+
last_checked=datetime.now(),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def list_models(self) -> List[LocalModel]:
|
|
125
|
+
"""List available models."""
|
|
126
|
+
try:
|
|
127
|
+
response = await self._async_request("GET", "/v1/models")
|
|
128
|
+
models = response.get("data", [])
|
|
129
|
+
|
|
130
|
+
result = []
|
|
131
|
+
for model_data in models:
|
|
132
|
+
model_id = model_data.get("id", "")
|
|
133
|
+
|
|
134
|
+
# Parse model info from ID (LM Studio often uses paths)
|
|
135
|
+
name = model_id.split("/")[-1]
|
|
136
|
+
family = detect_model_family(model_id)
|
|
137
|
+
quant = detect_quantization(model_id)
|
|
138
|
+
|
|
139
|
+
result.append(
|
|
140
|
+
LocalModel(
|
|
141
|
+
id=model_id,
|
|
142
|
+
name=name,
|
|
143
|
+
quantization=quant,
|
|
144
|
+
family=family,
|
|
145
|
+
supports_tools=likely_supports_tools(model_id),
|
|
146
|
+
running=True,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
except Exception:
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
async def list_running(self) -> List[LocalModel]:
|
|
156
|
+
"""List running models."""
|
|
157
|
+
return await self.list_models()
|
|
158
|
+
|
|
159
|
+
async def get_model_info(self, model_id: str) -> Optional[LocalModel]:
|
|
160
|
+
"""Get model information."""
|
|
161
|
+
models = await self.list_models()
|
|
162
|
+
for m in models:
|
|
163
|
+
if m.id == model_id or model_id in m.id:
|
|
164
|
+
return m
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
async def test_tool_calling(self, model_id: str) -> ToolTestResult:
|
|
168
|
+
"""Test tool calling capability."""
|
|
169
|
+
start_time = time.time()
|
|
170
|
+
|
|
171
|
+
if not likely_supports_tools(model_id):
|
|
172
|
+
return ToolTestResult(
|
|
173
|
+
model_id=model_id,
|
|
174
|
+
supports_tools=False,
|
|
175
|
+
notes="Model family not known to support tools",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
test_tools = [
|
|
179
|
+
{
|
|
180
|
+
"type": "function",
|
|
181
|
+
"function": {
|
|
182
|
+
"name": "get_weather",
|
|
183
|
+
"description": "Get weather for a city",
|
|
184
|
+
"parameters": {
|
|
185
|
+
"type": "object",
|
|
186
|
+
"properties": {"city": {"type": "string"}},
|
|
187
|
+
"required": ["city"],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
response = await self._async_request(
|
|
195
|
+
"POST",
|
|
196
|
+
"/v1/chat/completions",
|
|
197
|
+
data={
|
|
198
|
+
"model": model_id,
|
|
199
|
+
"messages": [{"role": "user", "content": "What's the weather in Paris?"}],
|
|
200
|
+
"tools": test_tools,
|
|
201
|
+
},
|
|
202
|
+
timeout=60.0,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
latency = (time.time() - start_time) * 1000
|
|
206
|
+
|
|
207
|
+
choices = response.get("choices", [])
|
|
208
|
+
if choices:
|
|
209
|
+
message = choices[0].get("message", {})
|
|
210
|
+
tool_calls = message.get("tool_calls", [])
|
|
211
|
+
|
|
212
|
+
if tool_calls:
|
|
213
|
+
return ToolTestResult(
|
|
214
|
+
model_id=model_id,
|
|
215
|
+
supports_tools=True,
|
|
216
|
+
parallel_tools=len(tool_calls) > 1,
|
|
217
|
+
tool_choice=["auto"],
|
|
218
|
+
latency_ms=latency,
|
|
219
|
+
notes="Tool calling verified via LM Studio",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return ToolTestResult(
|
|
223
|
+
model_id=model_id,
|
|
224
|
+
supports_tools=False,
|
|
225
|
+
latency_ms=latency,
|
|
226
|
+
notes="Model did not use tools in test",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
return ToolTestResult(
|
|
231
|
+
model_id=model_id,
|
|
232
|
+
supports_tools=False,
|
|
233
|
+
error=str(e),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def get_litellm_model_name(self, model_id: str) -> str:
|
|
237
|
+
"""Get LiteLLM-compatible model name."""
|
|
238
|
+
# LM Studio uses lm_studio/ prefix in LiteLLM
|
|
239
|
+
if model_id.startswith("lm_studio/"):
|
|
240
|
+
return model_id
|
|
241
|
+
return f"lm_studio/{model_id}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def get_lmstudio_client(host: Optional[str] = None) -> Optional[LMStudioClient]:
|
|
245
|
+
"""Get an LM Studio client if available.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
host: Optional host override.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
LMStudioClient if LM Studio server is running, None otherwise.
|
|
252
|
+
"""
|
|
253
|
+
client = LMStudioClient(host)
|
|
254
|
+
if await client.is_available():
|
|
255
|
+
return client
|
|
256
|
+
return None
|