solidworks-mcp-python 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. solidworks_mcp/__init__.py +57 -0
  2. solidworks_mcp/adapters/__init__.py +40 -0
  3. solidworks_mcp/adapters/base.py +1882 -0
  4. solidworks_mcp/adapters/circuit_breaker.py +2052 -0
  5. solidworks_mcp/adapters/com_executor.py +252 -0
  6. solidworks_mcp/adapters/complexity_analyzer.py +272 -0
  7. solidworks_mcp/adapters/connection_pool.py +1884 -0
  8. solidworks_mcp/adapters/factory.py +366 -0
  9. solidworks_mcp/adapters/intelligent_router.py +218 -0
  10. solidworks_mcp/adapters/mock_adapter.py +4016 -0
  11. solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
  12. solidworks_mcp/adapters/solidworks/__init__.py +17 -0
  13. solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
  14. solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
  15. solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
  16. solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
  17. solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
  18. solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
  19. solidworks_mcp/adapters/solidworks/features.py +3362 -0
  20. solidworks_mcp/adapters/solidworks/io.py +3649 -0
  21. solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
  22. solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
  23. solidworks_mcp/adapters/solidworks/selection.py +308 -0
  24. solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
  25. solidworks_mcp/adapters/sw_feature_errors.py +66 -0
  26. solidworks_mcp/adapters/sw_type_info.py +317 -0
  27. solidworks_mcp/adapters/vba_adapter.py +318 -0
  28. solidworks_mcp/adapters/vba_macro_executor.py +215 -0
  29. solidworks_mcp/agents/__init__.py +68 -0
  30. solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
  31. solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
  32. solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
  33. solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
  34. solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
  35. solidworks_mcp/agents/harness.py +216 -0
  36. solidworks_mcp/agents/history_db.py +1508 -0
  37. solidworks_mcp/agents/retrieval_index.py +164 -0
  38. solidworks_mcp/agents/schemas.py +165 -0
  39. solidworks_mcp/agents/smoke_test.py +285 -0
  40. solidworks_mcp/agents/soc_exporter.py +650 -0
  41. solidworks_mcp/agents/soc_pickup.py +332 -0
  42. solidworks_mcp/agents/soc_rewind.py +221 -0
  43. solidworks_mcp/agents/vector_rag.py +665 -0
  44. solidworks_mcp/cache/__init__.py +5 -0
  45. solidworks_mcp/cache/response_cache.py +164 -0
  46. solidworks_mcp/config.py +729 -0
  47. solidworks_mcp/exceptions.py +37 -0
  48. solidworks_mcp/security/__init__.py +68 -0
  49. solidworks_mcp/security/auth.py +128 -0
  50. solidworks_mcp/security/cors.py +27 -0
  51. solidworks_mcp/security/rate_limiting.py +90 -0
  52. solidworks_mcp/security/runtime.py +155 -0
  53. solidworks_mcp/server.py +777 -0
  54. solidworks_mcp/server_cli_fixed.py +120 -0
  55. solidworks_mcp/setup/__init__.py +1 -0
  56. solidworks_mcp/setup/cli.py +399 -0
  57. solidworks_mcp/setup/clients.py +157 -0
  58. solidworks_mcp/setup/config_file.py +230 -0
  59. solidworks_mcp/setup/handshake.py +202 -0
  60. solidworks_mcp/setup/launcher.py +102 -0
  61. solidworks_mcp/setup/legacy.py +100 -0
  62. solidworks_mcp/setup/registration.py +193 -0
  63. solidworks_mcp/setup/solidworks.py +49 -0
  64. solidworks_mcp/tools/__init__.py +64 -0
  65. solidworks_mcp/tools/analysis.py +723 -0
  66. solidworks_mcp/tools/automation.py +578 -0
  67. solidworks_mcp/tools/docs_discovery.py +1476 -0
  68. solidworks_mcp/tools/drawing.py +1015 -0
  69. solidworks_mcp/tools/drawing_analysis.py +456 -0
  70. solidworks_mcp/tools/drawing_automation.py +627 -0
  71. solidworks_mcp/tools/engineering_data.py +466 -0
  72. solidworks_mcp/tools/error_classifier.py +232 -0
  73. solidworks_mcp/tools/export.py +833 -0
  74. solidworks_mcp/tools/file_facts.py +40 -0
  75. solidworks_mcp/tools/file_management.py +1473 -0
  76. solidworks_mcp/tools/input_compat.py +17 -0
  77. solidworks_mcp/tools/macro_recording.py +464 -0
  78. solidworks_mcp/tools/modeling.py +2360 -0
  79. solidworks_mcp/tools/sketching.py +1422 -0
  80. solidworks_mcp/tools/template_management.py +367 -0
  81. solidworks_mcp/tools/tool_boundary.py +280 -0
  82. solidworks_mcp/tools/vba_generation.py +1145 -0
  83. solidworks_mcp/ui/__init__.py +9 -0
  84. solidworks_mcp/ui/local_llm.py +725 -0
  85. solidworks_mcp/ui/routers/__init__.py +10 -0
  86. solidworks_mcp/ui/routers/checkpoint.py +24 -0
  87. solidworks_mcp/ui/routers/docs.py +47 -0
  88. solidworks_mcp/ui/routers/llm.py +83 -0
  89. solidworks_mcp/ui/routers/local_model.py +69 -0
  90. solidworks_mcp/ui/routers/model.py +75 -0
  91. solidworks_mcp/ui/routers/preview.py +38 -0
  92. solidworks_mcp/ui/routers/session.py +161 -0
  93. solidworks_mcp/ui/routers/viewer.py +154 -0
  94. solidworks_mcp/ui/schemas.py +502 -0
  95. solidworks_mcp/ui/server.py +298 -0
  96. solidworks_mcp/ui/service.py +481 -0
  97. solidworks_mcp/ui/services/__init__.py +136 -0
  98. solidworks_mcp/ui/services/_utils.py +958 -0
  99. solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
  100. solidworks_mcp/ui/services/docs_service.py +282 -0
  101. solidworks_mcp/ui/services/llm_service.py +956 -0
  102. solidworks_mcp/ui/services/model_service.py +459 -0
  103. solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
  104. solidworks_mcp/ui/services/preview_service.py +489 -0
  105. solidworks_mcp/ui/services/session_service.py +1389 -0
  106. solidworks_mcp/utils/__init__.py +9 -0
  107. solidworks_mcp/utils/feature_tree_classifier.py +342 -0
  108. solidworks_mcp/utils/logging.py +76 -0
  109. solidworks_mcp/utils/validation.py +77 -0
  110. solidworks_mcp/version.py +8 -0
  111. solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
  112. solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
  113. solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
  114. solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
  115. solidworks_mcp_python-1.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,725 @@
1
+ """Local LLM integration helpers for the SolidWorks MCP UI.
2
+
3
+ Provides three layers of typed abstraction:
4
+
5
+ 1. **Hardware** – detect GPU VRAM / system RAM and pick the right Gemma tier. 2.
6
+ **Config** – ``LocalLLMConfig`` is the single source of truth for endpoint, model name,
7
+ and tier choice, shared by the UI, server endpoints, and the pydantic-ai agent runner.
8
+ 3. **Agent runner** – ``run_local_agent()`` mirrors ``_run_structured_agent`` in
9
+ ``service.py`` but routes exclusively to a local Ollama server. Both accept any
10
+ ``BaseModel`` subclass as ``result_type`` so callers get a fully typed, validated
11
+ response regardless of which backend they use.
12
+
13
+ Model tiers (Gemma 4 family via Ollama's OpenAI-compatible API): small : gemma4:e2b
14
+ (~0-4 GB VRAM / CPU-capable) — edge and smoke tests balanced: gemma4:e4b (~8 GB VRAM) —
15
+ recommended default for local planning large : gemma4:26b (~18 GB VRAM) — workstation-
16
+ class local evaluation
17
+
18
+ Usage::
19
+
20
+ from solidworks_mcp.ui.local_llm import probe_local_model, run_local_agent from
21
+ solidworks_mcp.agents.schemas import ClarificationResponse
22
+
23
+ probe = await probe_local_model() # LocalModelProbeResult result = await
24
+ run_local_agent( system_prompt="You are a SolidWorks CAD assistant.", user_prompt="How
25
+ many sketch constraints are needed for a slot?", result_type=ClarificationResponse,
26
+ config=probe.to_config(), )
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ import os
34
+ import platform
35
+ import subprocess
36
+ import urllib.parse
37
+ from typing import Any, Literal
38
+
39
+ from pydantic import BaseModel, Field
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Constants
45
+ # ---------------------------------------------------------------------------
46
+
47
+ OLLAMA_DEFAULT_ENDPOINT = "http://127.0.0.1:11434"
48
+ OLLAMA_OPENAI_ENDPOINT = f"{OLLAMA_DEFAULT_ENDPOINT}/v1"
49
+
50
+ _ALLOWED_SCHEMES = frozenset({"http", "https"})
51
+
52
+
53
+ def _require_http_endpoint(url: str) -> None:
54
+ """Raise ValueError if *url* does not use an http/https scheme.
55
+
56
+ Guards every ``urllib.request.urlopen`` call so that attacker-controlled
57
+ endpoint values cannot redirect requests to ``file://`` or other
58
+ non-network schemes (CWE-22 / Bandit B310).
59
+
60
+ Args:
61
+ url (str): The full URL (or endpoint prefix) to validate.
62
+
63
+ Raises:
64
+ ValueError: When the parsed scheme is not ``http`` or ``https``.
65
+ """
66
+ scheme = urllib.parse.urlparse(url).scheme.lower()
67
+ if scheme not in _ALLOWED_SCHEMES:
68
+ raise ValueError(
69
+ f"Ollama endpoint must use http or https, got scheme {scheme!r}."
70
+ )
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Tier spec — typed model for each hardware tier
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ class GemmaTierSpec(BaseModel):
79
+ """Hardware and model metadata for a single Gemma inference tier.
80
+
81
+ Attributes:
82
+ label (str): The label value.
83
+ min_ram_gb (float): The min ram gb value.
84
+ min_vram_gb (float): The min vram gb value.
85
+ ollama (str): The ollama value.
86
+ service (str): The service value.
87
+ """
88
+
89
+ ollama: str = Field(description="Ollama model tag, e.g. 'gemma4:e4b'")
90
+ service: str = Field(description="service.py model string, e.g. 'local:gemma4:e4b'")
91
+ label: str = Field(description="Human-readable description shown in UI toasts")
92
+ min_vram_gb: float = Field(ge=0, description="Minimum GPU VRAM in GB (0 = CPU ok)")
93
+ min_ram_gb: float = Field(ge=0, description="Minimum system RAM in GB")
94
+
95
+
96
+ # Gemma 3 / Gemma 4 model tiers — typed registry
97
+ GEMMA_TIERS: dict[str, GemmaTierSpec] = {
98
+ "small": GemmaTierSpec(
99
+ ollama="gemma4:e2b",
100
+ service="local:gemma4:e2b",
101
+ label="Gemma 4 E2B (small — CPU or 4 GB VRAM)",
102
+ min_vram_gb=0,
103
+ min_ram_gb=8,
104
+ ),
105
+ "balanced": GemmaTierSpec(
106
+ ollama="gemma4:e4b",
107
+ service="local:gemma4:e4b",
108
+ label="Gemma 4 E4B (balanced — 8 GB VRAM)",
109
+ min_vram_gb=8,
110
+ min_ram_gb=16,
111
+ ),
112
+ "large": GemmaTierSpec(
113
+ ollama="gemma4:26b",
114
+ service="local:gemma4:26b",
115
+ label="Gemma 4 26B (large — 18 GB VRAM)",
116
+ min_vram_gb=18,
117
+ min_ram_gb=32,
118
+ ),
119
+ }
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Shared config — single source of truth for LLM connection settings
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ class LocalLLMConfig(BaseModel):
127
+ """Runtime configuration for a local Ollama LLM connection.
128
+
129
+ Passed from the probe result into ``run_local_agent()`` or directly into
130
+ ``_build_agent_model()`` in service.py to keep settings consistent across all layers (UI
131
+ state, server endpoints, pydantic-ai agent runner).
132
+
133
+ Attributes:
134
+ api_key (str): The api key value.
135
+ endpoint (str): The endpoint value.
136
+ ollama_model (str): The ollama model value.
137
+ openai_endpoint (str): The openai endpoint value.
138
+ service_model (str): The service model value.
139
+ tier (Literal["small", "balanced", "large"]): The tier value.
140
+ """
141
+
142
+ endpoint: str = Field(
143
+ default=OLLAMA_DEFAULT_ENDPOINT,
144
+ description="Ollama base URL (without /v1 suffix)",
145
+ )
146
+ openai_endpoint: str = Field(
147
+ default=OLLAMA_OPENAI_ENDPOINT,
148
+ description="OpenAI-compatible endpoint for pydantic-ai",
149
+ )
150
+ tier: Literal["small", "balanced", "large"] = Field(default="balanced")
151
+ ollama_model: str = Field(default="gemma4:e4b")
152
+ service_model: str = Field(default="local:gemma4:e4b")
153
+ api_key: str = Field(
154
+ default="local",
155
+ description="API key sent to Ollama (ignored by Ollama but required by OpenAI client)",
156
+ )
157
+
158
+ @classmethod
159
+ def from_env(cls) -> LocalLLMConfig:
160
+ """Build config from environment variables, falling back to defaults.
161
+
162
+ Returns:
163
+ LocalLLMConfig: The result produced by the operation.
164
+ """
165
+ endpoint = os.getenv("SOLIDWORKS_UI_OLLAMA_ENDPOINT", OLLAMA_DEFAULT_ENDPOINT)
166
+ service_model = os.getenv("SOLIDWORKS_UI_MODEL", "local:gemma4:e4b")
167
+ tier = "small"
168
+ for t, spec in GEMMA_TIERS.items():
169
+ if spec.service == service_model:
170
+ tier = t
171
+ break
172
+ spec = GEMMA_TIERS[tier]
173
+ return cls(
174
+ endpoint=endpoint,
175
+ openai_endpoint=f"{endpoint}/v1",
176
+ tier=tier, # type: ignore[arg-type]
177
+ ollama_model=spec.ollama,
178
+ service_model=spec.service,
179
+ api_key=os.getenv("LOCAL_OPENAI_API_KEY", "local"),
180
+ )
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Probe result — typed response returned by the /api/ui/local-model/probe endpoint
185
+ # ---------------------------------------------------------------------------
186
+ class LocalModelProbeResult(BaseModel):
187
+ """Full hardware-detection and Ollama availability result.
188
+
189
+ Returned by ``probe_local_model()`` and serialised as the JSON response from ``GET
190
+ /api/ui/local-model/probe``. The ``to_config()`` helper converts directly into a
191
+ ``LocalLLMConfig`` ready for ``run_local_agent()``.
192
+
193
+ Attributes:
194
+ all_tiers (dict[str, str]): The all tiers value.
195
+ available (bool): The available value.
196
+ endpoint (str): The endpoint value.
197
+ label (str): The label value.
198
+ ollama_model (str): The ollama model value.
199
+ openai_endpoint (str): The openai endpoint value.
200
+ pull_command (str): The pull command value.
201
+ pulled_models (list[str]): The pulled models value.
202
+ ram_gb (float): The ram gb value.
203
+ service_model (str): The service model value.
204
+ status_message (str): The status message value.
205
+ tier (Literal["small", "balanced", "large"]): The tier value.
206
+ tier_already_pulled (bool): The tier already pulled value.
207
+ vram_gb (float): The vram gb value.
208
+ """
209
+
210
+ available: bool = Field(description="True if Ollama responded to /api/tags")
211
+ endpoint: str
212
+ openai_endpoint: str = Field(description="OpenAI-compatible URL (/v1 suffix)")
213
+ tier: Literal["small", "balanced", "large"]
214
+ ollama_model: str
215
+ service_model: str = Field(
216
+ description="Model string for service.py, e.g. 'local:gemma4:e4b'"
217
+ )
218
+ label: str
219
+ vram_gb: float = Field(ge=0)
220
+ ram_gb: float = Field(ge=0)
221
+ pulled_models: list[str] = Field(default_factory=list)
222
+ tier_already_pulled: bool
223
+ pull_command: str
224
+ status_message: str
225
+ all_tiers: dict[str, str] = Field(
226
+ description="Map of tier name → human label for all supported tiers"
227
+ )
228
+
229
+ def to_config(self) -> LocalLLMConfig:
230
+ """Convert probe result into a ready-to-use ``LocalLLMConfig``.
231
+
232
+ Returns:
233
+ LocalLLMConfig: The result produced by the operation.
234
+ """
235
+ return LocalLLMConfig(
236
+ endpoint=self.endpoint,
237
+ openai_endpoint=self.openai_endpoint,
238
+ tier=self.tier,
239
+ ollama_model=self.ollama_model,
240
+ service_model=self.service_model,
241
+ )
242
+
243
+
244
+ class LocalModelPullResult(BaseModel):
245
+ """Result from ``POST /api/ui/local-model/pull``.
246
+
247
+ Attributes:
248
+ error (str | None): The error value.
249
+ model (str): The model value.
250
+ queued (bool): The queued value.
251
+ response (dict[str, Any] | None): The response value.
252
+ """
253
+
254
+ queued: bool
255
+ model: str
256
+ error: str | None = Field(default=None)
257
+ response: dict[str, Any] | None = Field(default=None)
258
+
259
+
260
+ class LocalModelPullRequest(BaseModel):
261
+ """Request body for ``POST /api/ui/local-model/pull``.
262
+
263
+ Attributes:
264
+ endpoint (str | None): The endpoint value.
265
+ model (str): The model value.
266
+ """
267
+
268
+ model: str = Field(description="Ollama model tag to pull, e.g. 'gemma4:e4b'")
269
+ endpoint: str | None = Field(
270
+ default=None,
271
+ description="Override Ollama base URL (omit to use env / default)",
272
+ )
273
+
274
+
275
+ class LocalModelQueryRequest(BaseModel):
276
+ """Request body for ``POST /api/ui/local-model/query``.
277
+
278
+ Attributes:
279
+ endpoint (str | None): The endpoint value.
280
+ model (str | None): The model value.
281
+ prompt (str): The prompt value.
282
+ system_prompt (str): The system prompt value.
283
+ """
284
+
285
+ prompt: str = Field(min_length=1, description="User question or task description")
286
+ system_prompt: str = Field(
287
+ default="You are a SolidWorks CAD design assistant.",
288
+ description="Instruction preamble sent to the LLM",
289
+ )
290
+ model: str | None = Field(
291
+ default=None,
292
+ description="Override model (bare name or 'local:...' prefix). Omit to use env / detected tier.",
293
+ )
294
+ endpoint: str | None = Field(
295
+ default=None,
296
+ description="Override Ollama base URL. Omit to use env / default.",
297
+ )
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Agent result envelope — uniform wrapper for structured LLM outputs
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ class LocalAgentResult[T: BaseModel](BaseModel):
306
+ """Typed envelope wrapping a structured pydantic-ai agent response.
307
+
308
+ ``data`` holds the validated ``result_type`` instance; ``config`` echoes back the
309
+ ``LocalLLMConfig`` used so callers can log or audit provenance. Set ``success=False``
310
+ and ``error`` when the agent returned a ``RecoverableFailure`` or raised an exception.
311
+
312
+ Attributes:
313
+ config (LocalLLMConfig): The config value.
314
+ data (Any): The data value.
315
+ error (str | None): The error value.
316
+ retry_hint (str | None): The retry hint value.
317
+ success (bool): The success value.
318
+ """
319
+
320
+ success: bool
321
+ data: Any = Field(default=None, description="Validated result_type instance")
322
+ error: str | None = Field(default=None)
323
+ retry_hint: str | None = Field(default=None)
324
+ config: LocalLLMConfig
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # VRAM / RAM detection
329
+ # ---------------------------------------------------------------------------
330
+
331
+
332
+ def _detect_gpu_vram_gb() -> float:
333
+ """Return best-effort GPU VRAM estimate in GB, or 0.0 on failure.
334
+
335
+ Returns:
336
+ float: The computed numeric result.
337
+ """
338
+ # Try nvidia-smi first
339
+ try:
340
+ out = subprocess.check_output(
341
+ ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
342
+ stderr=subprocess.DEVNULL,
343
+ timeout=5,
344
+ text=True,
345
+ )
346
+ mib = max(
347
+ int(x.strip()) for x in out.strip().splitlines() if x.strip().isdigit()
348
+ )
349
+ return mib / 1024.0
350
+ except Exception:
351
+ pass
352
+
353
+ # Try wmic on Windows
354
+ if platform.system() == "Windows":
355
+ try:
356
+ out = subprocess.check_output(
357
+ ["wmic", "path", "win32_VideoController", "get", "AdapterRAM"],
358
+ stderr=subprocess.DEVNULL,
359
+ timeout=5,
360
+ text=True,
361
+ )
362
+ bytes_vals = [
363
+ int(x.strip())
364
+ for x in out.splitlines()
365
+ if x.strip().lstrip("-").isdigit() and int(x.strip()) > 0
366
+ ]
367
+ if bytes_vals:
368
+ return max(bytes_vals) / (1024**3)
369
+ except Exception:
370
+ pass
371
+
372
+ return 0.0
373
+
374
+
375
+ def _detect_system_ram_gb() -> float:
376
+ """Return total system RAM in GB.
377
+
378
+ Returns:
379
+ float: The computed numeric result.
380
+ """
381
+ try:
382
+ import psutil # optional dependency
383
+
384
+ return psutil.virtual_memory().total / (1024**3)
385
+ except ImportError:
386
+ pass
387
+
388
+ if platform.system() == "Windows":
389
+ try:
390
+ out = subprocess.check_output(
391
+ ["wmic", "computersystem", "get", "TotalPhysicalMemory"],
392
+ stderr=subprocess.DEVNULL,
393
+ timeout=5,
394
+ text=True,
395
+ )
396
+ for line in out.splitlines():
397
+ line = line.strip()
398
+ if line.isdigit():
399
+ return int(line) / (1024**3)
400
+ except Exception:
401
+ pass
402
+
403
+ return 0.0
404
+
405
+
406
+ def recommend_model_tier(vram_gb: float = 0.0, ram_gb: float = 0.0) -> str:
407
+ """Return 'small' | 'balanced' | 'large' based on available hardware.
408
+
409
+ Args:
410
+ vram_gb (float): The vram gb value. Defaults to 0.0.
411
+ ram_gb (float): The ram gb value. Defaults to 0.0.
412
+
413
+ Returns:
414
+ str: The resulting text value.
415
+ """
416
+ for tier in ("large", "balanced", "small"):
417
+ spec = GEMMA_TIERS[tier]
418
+ if vram_gb >= spec.min_vram_gb and ram_gb >= spec.min_ram_gb:
419
+ return tier
420
+ return "small" # always runnable with quantized 4B
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # Ollama probe
425
+ # ---------------------------------------------------------------------------
426
+
427
+
428
+ async def _ollama_health(endpoint: str = OLLAMA_DEFAULT_ENDPOINT) -> bool:
429
+ """Return True if Ollama HTTP server is responding.
430
+
431
+ Args:
432
+ endpoint (str): The endpoint value. Defaults to OLLAMA_DEFAULT_ENDPOINT.
433
+
434
+ Returns:
435
+ bool: True if ollama health, otherwise False.
436
+ """
437
+ import urllib.request
438
+
439
+ loop = asyncio.get_event_loop()
440
+ try:
441
+ _require_http_endpoint(endpoint)
442
+
443
+ def _get() -> bool:
444
+ """Build internal get.
445
+
446
+ Returns:
447
+ bool: True if get, otherwise False.
448
+ """
449
+
450
+ try:
451
+ with urllib.request.urlopen(f"{endpoint}/api/tags", timeout=3) as r: # nosec B310
452
+ return r.status == 200
453
+ except Exception:
454
+ return False
455
+
456
+ return await loop.run_in_executor(None, _get)
457
+ except Exception:
458
+ return False
459
+
460
+
461
+ async def _ollama_list_models(endpoint: str = OLLAMA_DEFAULT_ENDPOINT) -> list[str]:
462
+ """Return list of model names currently pulled in Ollama.
463
+
464
+ Args:
465
+ endpoint (str): The endpoint value. Defaults to OLLAMA_DEFAULT_ENDPOINT.
466
+
467
+ Returns:
468
+ list[str]: A list containing the resulting items.
469
+ """
470
+ import json
471
+ import urllib.request
472
+
473
+ _require_http_endpoint(endpoint)
474
+ loop = asyncio.get_event_loop()
475
+
476
+ def _get() -> list[str]:
477
+ """Build internal get.
478
+
479
+ Returns:
480
+ list[str]: A list containing the resulting items.
481
+ """
482
+
483
+ try:
484
+ with urllib.request.urlopen(f"{endpoint}/api/tags", timeout=5) as r: # nosec B310
485
+ data = json.loads(r.read())
486
+ return [m.get("name", "") for m in data.get("models", [])]
487
+ except Exception:
488
+ return []
489
+
490
+ return await loop.run_in_executor(None, _get)
491
+
492
+
493
+ async def probe_local_model(
494
+ endpoint: str | None = None,
495
+ ) -> LocalModelProbeResult:
496
+ """Probe Ollama for availability and return a typed recommendation result.
497
+
498
+ The returned ``LocalModelProbeResult`` can be forwarded directly as a FastAPI JSON
499
+ response (it is a ``BaseModel``). Call ``.to_config()`` on the result to build a
500
+ ``LocalLLMConfig`` for ``run_local_agent()``.
501
+
502
+ Args:
503
+ endpoint (str | None): The endpoint value. Defaults to None.
504
+
505
+ Returns:
506
+ LocalModelProbeResult: The result produced by the operation.
507
+ """
508
+ resolved_endpoint = endpoint or os.getenv(
509
+ "SOLIDWORKS_UI_OLLAMA_ENDPOINT", OLLAMA_DEFAULT_ENDPOINT
510
+ )
511
+
512
+ vram_gb = _detect_gpu_vram_gb()
513
+ ram_gb = _detect_system_ram_gb()
514
+ tier = recommend_model_tier(vram_gb=vram_gb, ram_gb=ram_gb)
515
+ spec = GEMMA_TIERS[tier]
516
+
517
+ available = await _ollama_health(resolved_endpoint)
518
+ pulled_models: list[str] = []
519
+ if available:
520
+ pulled_models = await _ollama_list_models(resolved_endpoint)
521
+
522
+ tier_model = spec.ollama
523
+ tier_already_pulled = any(tier_model in m for m in pulled_models)
524
+
525
+ if not available:
526
+ status = (
527
+ f"Ollama is not running at {resolved_endpoint}. "
528
+ "Install from https://ollama.com and run: ollama serve"
529
+ )
530
+ elif tier_already_pulled:
531
+ status = f"Ready: {spec.label} is loaded in Ollama."
532
+ else:
533
+ status = (
534
+ f"Ollama is running. Pull the recommended model with: "
535
+ f"ollama pull {tier_model}"
536
+ )
537
+
538
+ return LocalModelProbeResult(
539
+ available=available,
540
+ endpoint=resolved_endpoint,
541
+ openai_endpoint=f"{resolved_endpoint}/v1",
542
+ tier=tier, # type: ignore[arg-type]
543
+ ollama_model=tier_model,
544
+ service_model=spec.service,
545
+ label=spec.label,
546
+ vram_gb=round(vram_gb, 1),
547
+ ram_gb=round(ram_gb, 1),
548
+ pulled_models=pulled_models,
549
+ tier_already_pulled=tier_already_pulled,
550
+ pull_command=f"ollama pull {tier_model}",
551
+ status_message=status,
552
+ all_tiers={k: v.label for k, v in GEMMA_TIERS.items()},
553
+ )
554
+
555
+
556
+ async def pull_ollama_model(
557
+ model: str,
558
+ endpoint: str | None = None,
559
+ ) -> LocalModelPullResult:
560
+ """Trigger an Ollama model pull. Runs in a thread; returns immediately.
561
+
562
+ Returns a typed ``LocalModelPullResult`` with ``queued=True`` on success.
563
+
564
+ Args:
565
+ model (str): The model value.
566
+ endpoint (str | None): The endpoint value. Defaults to None.
567
+
568
+ Returns:
569
+ LocalModelPullResult: The result produced by the operation.
570
+ """
571
+ import json
572
+ import urllib.request
573
+
574
+ resolved_endpoint = endpoint or os.getenv(
575
+ "SOLIDWORKS_UI_OLLAMA_ENDPOINT", OLLAMA_DEFAULT_ENDPOINT
576
+ )
577
+ _require_http_endpoint(resolved_endpoint)
578
+ loop = asyncio.get_event_loop()
579
+
580
+ def _pull() -> LocalModelPullResult:
581
+ """Build internal pull.
582
+
583
+ Returns:
584
+ LocalModelPullResult: The result produced by the operation.
585
+ """
586
+
587
+ payload = json.dumps({"name": model, "stream": False}).encode()
588
+ req = urllib.request.Request(
589
+ f"{resolved_endpoint}/api/pull",
590
+ data=payload,
591
+ method="POST",
592
+ headers={"Content-Type": "application/json"},
593
+ )
594
+ try:
595
+ with urllib.request.urlopen(req, timeout=300) as r: # nosec B310
596
+ body = json.loads(r.read())
597
+ return LocalModelPullResult(queued=True, model=model, response=body)
598
+ except Exception as exc:
599
+ return LocalModelPullResult(queued=False, model=model, error=str(exc))
600
+
601
+ return await loop.run_in_executor(None, _pull)
602
+
603
+
604
+ # ---------------------------------------------------------------------------
605
+ # pydantic-ai agent runner — structured local LLM inference
606
+ # ---------------------------------------------------------------------------
607
+ async def run_local_agent[T: BaseModel](
608
+ *,
609
+ system_prompt: str,
610
+ user_prompt: str,
611
+ result_type: type[T],
612
+ config: LocalLLMConfig | None = None,
613
+ rag_query: str | None = None,
614
+ rag_namespace: str = "solidworks-api-docs",
615
+ ) -> LocalAgentResult[T]:
616
+ """Run a pydantic-ai ``Agent`` against the local Ollama server and return a.
617
+
618
+ typed ``LocalAgentResult``.
619
+
620
+ This mirrors ``_run_structured_agent`` in ``service.py`` but is self- contained in this
621
+ module so any layer (UI route, service function, or CLI) can call local inference
622
+ without importing the full service graph.
623
+
624
+ Parameters ---------- system_prompt: Instruction preamble for the LLM. user_prompt: The
625
+ concrete question or task. result_type: A ``BaseModel`` subclass. pydantic-ai validates
626
+ the LLM output against this schema and retries automatically on parse failures. config:
627
+ Connection settings. Defaults to ``LocalLLMConfig.from_env()``. rag_query: If provided,
628
+ the FAISS ``solidworks-api-docs`` namespace is queried with this string and the top
629
+ results are prepended to ``system_prompt`` as grounded API context for the model. Pass
630
+ the same text as ``user_prompt`` for a simple "augment with API docs" pattern, or a more
631
+ specific sub-query for targeted retrieval. rag_namespace: FAISS namespace to query when
632
+ ``rag_query`` is set. Defaults to ``"solidworks-api-docs"`` (the COM/VBA surface
633
+ index).
634
+
635
+ Returns ------- LocalAgentResult[T] ``success=True`` with ``data`` set to a validated
636
+ ``result_type`` instance, or ``success=False`` with an ``error`` message.
637
+
638
+ Args:
639
+ system_prompt (str): The system prompt value.
640
+ user_prompt (str): The user prompt value.
641
+ result_type (type[T]): The result type value.
642
+ config (LocalLLMConfig | None): Configuration values for the operation. Defaults to
643
+ None.
644
+ rag_query (str | None): The rag query value. Defaults to None.
645
+ rag_namespace (str): The rag namespace value. Defaults to "solidworks-api-docs".
646
+
647
+ Returns:
648
+ LocalAgentResult[T]: The result produced by the operation.
649
+ """
650
+ from ..agents.schemas import RecoverableFailure # avoid circular at import time
651
+
652
+ resolved_config = config or LocalLLMConfig.from_env()
653
+
654
+ # --- RAG augmentation ---
655
+ augmented_system_prompt = system_prompt
656
+ if rag_query:
657
+ try:
658
+ from ..agents.vector_rag import (
659
+ query_design_knowledge,
660
+ query_solidworks_api_docs,
661
+ )
662
+
663
+ api_context = (
664
+ query_solidworks_api_docs(rag_query)
665
+ if rag_namespace == "solidworks-api-docs"
666
+ else query_design_knowledge(rag_query, namespace=rag_namespace)
667
+ )
668
+ if api_context:
669
+ augmented_system_prompt = f"{system_prompt}\n\n{api_context}"
670
+ logger.debug(
671
+ "run_local_agent: injected %d RAG chars from '%s'",
672
+ len(api_context),
673
+ rag_namespace,
674
+ )
675
+ except Exception as _rag_exc:
676
+ logger.debug("RAG augmentation skipped: %s", _rag_exc)
677
+
678
+ try:
679
+ from pydantic_ai import Agent
680
+ from pydantic_ai.models.openai import OpenAIChatModel
681
+ from pydantic_ai.providers.openai import OpenAIProvider
682
+ except ImportError:
683
+ return LocalAgentResult(
684
+ success=False,
685
+ error="pydantic-ai is not installed in this environment.",
686
+ config=resolved_config,
687
+ )
688
+
689
+ model_id = (
690
+ resolved_config.service_model.split(":", 1)[1]
691
+ if resolved_config.service_model.startswith("local:")
692
+ else resolved_config.service_model
693
+ )
694
+ provider = OpenAIProvider(
695
+ base_url=resolved_config.openai_endpoint,
696
+ api_key=resolved_config.api_key,
697
+ )
698
+ configured_model = OpenAIChatModel(model_id, provider=provider)
699
+
700
+ agent: Agent[None, T | RecoverableFailure] = Agent(
701
+ configured_model,
702
+ system_prompt=augmented_system_prompt,
703
+ output_type=[result_type, RecoverableFailure], # type: ignore[list-item]
704
+ )
705
+
706
+ try:
707
+ result = await agent.run(user_prompt)
708
+ payload = result.data if hasattr(result, "data") else result.output
709
+ except Exception as exc:
710
+ logger.exception("run_local_agent failed")
711
+ return LocalAgentResult(
712
+ success=False,
713
+ error=str(exc),
714
+ config=resolved_config,
715
+ )
716
+
717
+ if isinstance(payload, RecoverableFailure):
718
+ return LocalAgentResult(
719
+ success=False,
720
+ error=payload.explanation,
721
+ retry_hint=getattr(payload, "retry_hint", None),
722
+ config=resolved_config,
723
+ )
724
+
725
+ return LocalAgentResult(success=True, data=payload, config=resolved_config)