mita-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. mita/__init__.py +5 -0
  2. mita/__main__.py +5 -0
  3. mita/agent/__init__.py +1 -0
  4. mita/agent/context.py +43 -0
  5. mita/agent/conversation.py +101 -0
  6. mita/agent/loop.py +594 -0
  7. mita/agent/system_prompt.py +75 -0
  8. mita/cli.py +940 -0
  9. mita/config/__init__.py +6 -0
  10. mita/config/defaults.py +41 -0
  11. mita/config/loader.py +53 -0
  12. mita/config/schema.py +131 -0
  13. mita/hooks/__init__.py +1 -0
  14. mita/hooks/manager.py +94 -0
  15. mita/hooks/runner.py +145 -0
  16. mita/index/__init__.py +1 -0
  17. mita/index/embeddings.py +49 -0
  18. mita/index/manager.py +170 -0
  19. mita/index/parser.py +331 -0
  20. mita/index/retriever.py +53 -0
  21. mita/index/store.py +143 -0
  22. mita/llm/__init__.py +1 -0
  23. mita/llm/client.py +86 -0
  24. mita/llm/instructor.py +80 -0
  25. mita/llm/streaming.py +58 -0
  26. mita/memory/__init__.py +6 -0
  27. mita/memory/discovery.py +61 -0
  28. mita/memory/loader.py +76 -0
  29. mita/memory/manager.py +117 -0
  30. mita/models/__init__.py +13 -0
  31. mita/models/hardware.py +289 -0
  32. mita/models/manager.py +268 -0
  33. mita/models/ollama_client.py +104 -0
  34. mita/models/recommender.py +88 -0
  35. mita/models/registry.py +167 -0
  36. mita/models/server.py +262 -0
  37. mita/plugins/__init__.py +1 -0
  38. mita/plugins/client.py +152 -0
  39. mita/plugins/manager.py +210 -0
  40. mita/py.typed +0 -0
  41. mita/skills/__init__.py +1 -0
  42. mita/skills/executor.py +84 -0
  43. mita/skills/loader.py +117 -0
  44. mita/skills/manager.py +129 -0
  45. mita/tools/__init__.py +1 -0
  46. mita/tools/builtins/__init__.py +28 -0
  47. mita/tools/builtins/file_edit.py +71 -0
  48. mita/tools/builtins/file_read.py +74 -0
  49. mita/tools/builtins/file_write.py +42 -0
  50. mita/tools/builtins/git.py +112 -0
  51. mita/tools/builtins/glob_tool.py +67 -0
  52. mita/tools/builtins/grep_tool.py +93 -0
  53. mita/tools/builtins/shell.py +83 -0
  54. mita/tools/executor.py +80 -0
  55. mita/tools/registry.py +69 -0
  56. mita/tools/safety.py +91 -0
  57. mita/tools/schema.py +87 -0
  58. mita/ui/__init__.py +1 -0
  59. mita/ui/display.py +139 -0
  60. mita/ui/repl.py +88 -0
  61. mita/ui/spinner.py +48 -0
  62. mita/ui/theme.py +23 -0
  63. mita_code-0.1.0.dist-info/METADATA +227 -0
  64. mita_code-0.1.0.dist-info/RECORD +67 -0
  65. mita_code-0.1.0.dist-info/WHEEL +4 -0
  66. mita_code-0.1.0.dist-info/entry_points.txt +3 -0
  67. mita_code-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,289 @@
1
+ """Hardware detection: RAM, VRAM, CPU, GPU."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import subprocess
7
+
8
+ import psutil
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class GPUInfo(BaseModel):
13
+ """Information about a detected GPU."""
14
+
15
+ name: str
16
+ vram_gb: float
17
+ vendor: str # "nvidia", "amd", "apple"
18
+
19
+
20
+ class HardwareInfo(BaseModel):
21
+ """Detected hardware capabilities."""
22
+
23
+ ram_gb: float
24
+ cpu_cores: int
25
+ cpu_name: str
26
+ gpus: list[GPUInfo] = Field(default_factory=list)
27
+ os: str # "darwin", "linux", "windows"
28
+ apple_silicon: bool = False
29
+ unified_memory: bool = False
30
+
31
+ @property
32
+ def available_vram_gb(self) -> float:
33
+ """Effective VRAM available for models.
34
+
35
+ On Apple Silicon with unified memory, most of RAM is usable as VRAM.
36
+ On discrete GPUs, use the GPU VRAM.
37
+ Falls back to a conservative fraction of RAM for CPU-only inference.
38
+ """
39
+ if self.unified_memory:
40
+ # Apple Silicon can use ~75% of unified memory for ML
41
+ return self.ram_gb * 0.75
42
+ if self.gpus:
43
+ # Use the largest single GPU — Ollama loads a model into one GPU
44
+ return max(gpu.vram_gb for gpu in self.gpus)
45
+ # CPU-only: models load into RAM, leave room for OS
46
+ return max(0, self.ram_gb - 4)
47
+
48
+
49
+ def detect_hardware() -> HardwareInfo:
50
+ """Detect hardware capabilities of the current machine."""
51
+ system = platform.system().lower()
52
+ os_name = {"darwin": "darwin", "linux": "linux", "windows": "windows"}.get(system, system)
53
+
54
+ ram_gb = round(psutil.virtual_memory().total / (1024**3), 1)
55
+ cpu_cores = psutil.cpu_count(logical=False) or psutil.cpu_count() or 1
56
+ cpu_name = _detect_cpu_name()
57
+
58
+ apple_silicon = False
59
+ unified_memory = False
60
+ gpus: list[GPUInfo] = []
61
+
62
+ if os_name == "darwin":
63
+ apple_silicon, unified_memory, gpus = _detect_macos_gpu(ram_gb)
64
+ elif os_name == "linux":
65
+ gpus = _detect_linux_gpu()
66
+
67
+ return HardwareInfo(
68
+ ram_gb=ram_gb,
69
+ cpu_cores=cpu_cores,
70
+ cpu_name=cpu_name,
71
+ gpus=gpus,
72
+ os=os_name,
73
+ apple_silicon=apple_silicon,
74
+ unified_memory=unified_memory,
75
+ )
76
+
77
+
78
+ def _detect_cpu_name() -> str:
79
+ """Detect the CPU model name."""
80
+ system = platform.system().lower()
81
+
82
+ if system == "darwin":
83
+ try:
84
+ result = subprocess.run(
85
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=5,
89
+ )
90
+ if result.returncode == 0 and result.stdout.strip():
91
+ return result.stdout.strip()
92
+ except (subprocess.SubprocessError, FileNotFoundError):
93
+ pass
94
+ # Apple Silicon doesn't have brand_string, use chip name
95
+ try:
96
+ result = subprocess.run(
97
+ ["sysctl", "-n", "hw.chip"],
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=5,
101
+ )
102
+ if result.returncode == 0 and result.stdout.strip():
103
+ return result.stdout.strip()
104
+ except (subprocess.SubprocessError, FileNotFoundError):
105
+ pass
106
+
107
+ elif system == "linux":
108
+ try:
109
+ with open("/proc/cpuinfo") as f:
110
+ for line in f:
111
+ if line.startswith("model name"):
112
+ return line.split(":", 1)[1].strip()
113
+ except (OSError, IndexError):
114
+ pass
115
+
116
+ return platform.processor() or "Unknown CPU"
117
+
118
+
119
+ def _is_apple_silicon() -> bool:
120
+ """Detect Apple Silicon, even under Rosetta emulation."""
121
+ machine = platform.machine().lower()
122
+ if machine in ("arm64", "aarch64"):
123
+ return True
124
+ # Under Rosetta, platform.machine() returns x86_64.
125
+ # Check sysctl for the actual hardware.
126
+ try:
127
+ result = subprocess.run(
128
+ ["sysctl", "-n", "hw.optional.arm64"],
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=5,
132
+ )
133
+ if result.returncode == 0 and result.stdout.strip() == "1":
134
+ return True
135
+ except (subprocess.SubprocessError, FileNotFoundError):
136
+ pass
137
+ return False
138
+
139
+
140
+ def _detect_macos_gpu(ram_gb: float) -> tuple[bool, bool, list[GPUInfo]]:
141
+ """Detect GPU on macOS. Returns (apple_silicon, unified_memory, gpus)."""
142
+ apple_silicon = _is_apple_silicon()
143
+
144
+ if apple_silicon:
145
+ # Apple Silicon has unified memory — RAM is shared with GPU
146
+ chip_name = _get_apple_chip_name()
147
+ return (
148
+ True,
149
+ True,
150
+ [
151
+ GPUInfo(
152
+ name=chip_name,
153
+ vram_gb=ram_gb, # Unified — all RAM is addressable
154
+ vendor="apple",
155
+ )
156
+ ],
157
+ )
158
+
159
+ # Intel Mac — try to detect discrete GPU via system_profiler
160
+ gpus = _detect_macos_discrete_gpu()
161
+ return False, False, gpus
162
+
163
+
164
+ def _get_apple_chip_name() -> str:
165
+ """Get the Apple Silicon chip name (e.g., 'Apple M2 Pro')."""
166
+ try:
167
+ result = subprocess.run(
168
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
169
+ capture_output=True,
170
+ text=True,
171
+ timeout=5,
172
+ )
173
+ if result.returncode == 0 and result.stdout.strip():
174
+ return result.stdout.strip()
175
+ except (subprocess.SubprocessError, FileNotFoundError):
176
+ pass
177
+ return "Apple Silicon"
178
+
179
+
180
+ def _detect_macos_discrete_gpu() -> list[GPUInfo]:
181
+ """Detect discrete GPUs on Intel Macs via system_profiler."""
182
+ try:
183
+ result = subprocess.run(
184
+ ["system_profiler", "SPDisplaysDataType", "-detailLevel", "basic"],
185
+ capture_output=True,
186
+ text=True,
187
+ timeout=10,
188
+ )
189
+ if result.returncode != 0:
190
+ return []
191
+
192
+ gpus: list[GPUInfo] = []
193
+ current_name = ""
194
+ for line in result.stdout.splitlines():
195
+ stripped = line.strip()
196
+ if stripped.startswith("Chipset Model:"):
197
+ current_name = stripped.split(":", 1)[1].strip()
198
+ elif stripped.startswith("VRAM") and current_name:
199
+ vram_str = stripped.split(":", 1)[1].strip()
200
+ vram_gb = _parse_vram_string(vram_str)
201
+ vendor = _guess_gpu_vendor(current_name)
202
+ gpus.append(GPUInfo(name=current_name, vram_gb=vram_gb, vendor=vendor))
203
+ current_name = ""
204
+ return gpus
205
+ except (subprocess.SubprocessError, FileNotFoundError):
206
+ return []
207
+
208
+
209
+ def _detect_linux_gpu() -> list[GPUInfo]:
210
+ """Detect NVIDIA GPUs on Linux via nvidia-smi."""
211
+ gpus: list[GPUInfo] = []
212
+
213
+ # Try nvidia-smi
214
+ try:
215
+ result = subprocess.run(
216
+ [
217
+ "nvidia-smi",
218
+ "--query-gpu=name,memory.total",
219
+ "--format=csv,noheader,nounits",
220
+ ],
221
+ capture_output=True,
222
+ text=True,
223
+ timeout=10,
224
+ )
225
+ if result.returncode == 0:
226
+ for line in result.stdout.strip().splitlines():
227
+ parts = line.split(",")
228
+ if len(parts) >= 2:
229
+ name = parts[0].strip()
230
+ vram_mb = float(parts[1].strip())
231
+ gpus.append(
232
+ GPUInfo(name=name, vram_gb=round(vram_mb / 1024, 1), vendor="nvidia")
233
+ )
234
+ except (subprocess.SubprocessError, FileNotFoundError, ValueError):
235
+ pass
236
+
237
+ # Try AMD ROCm
238
+ if not gpus:
239
+ try:
240
+ result = subprocess.run(
241
+ ["rocm-smi", "--showmeminfo", "vram", "--csv"],
242
+ capture_output=True,
243
+ text=True,
244
+ timeout=10,
245
+ )
246
+ if result.returncode == 0:
247
+ for line in result.stdout.strip().splitlines()[1:]: # skip header
248
+ parts = line.split(",")
249
+ if len(parts) >= 2:
250
+ try:
251
+ vram_bytes = int(parts[1].strip())
252
+ gpus.append(
253
+ GPUInfo(
254
+ name="AMD GPU",
255
+ vram_gb=round(vram_bytes / (1024**3), 1),
256
+ vendor="amd",
257
+ )
258
+ )
259
+ except ValueError:
260
+ pass
261
+ except (subprocess.SubprocessError, FileNotFoundError):
262
+ pass
263
+
264
+ return gpus
265
+
266
+
267
+ def _parse_vram_string(vram_str: str) -> float:
268
+ """Parse VRAM strings like '8 GB', '4096 MB' into GB."""
269
+ vram_str = vram_str.strip().upper()
270
+ try:
271
+ if "GB" in vram_str:
272
+ return float(vram_str.replace("GB", "").strip())
273
+ if "MB" in vram_str:
274
+ return round(float(vram_str.replace("MB", "").strip()) / 1024, 1)
275
+ except ValueError:
276
+ pass
277
+ return 0.0
278
+
279
+
280
+ def _guess_gpu_vendor(name: str) -> str:
281
+ """Guess GPU vendor from the chipset name."""
282
+ name_lower = name.lower()
283
+ if "nvidia" in name_lower or "geforce" in name_lower or "quadro" in name_lower:
284
+ return "nvidia"
285
+ if "amd" in name_lower or "radeon" in name_lower:
286
+ return "amd"
287
+ if "intel" in name_lower:
288
+ return "intel"
289
+ return "unknown"
mita/models/manager.py ADDED
@@ -0,0 +1,268 @@
1
+ """CLI command handlers for model management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn, TransferSpeedColumn
8
+ from rich.table import Table
9
+
10
+ from mita.config.loader import load_config
11
+ from mita.models.hardware import detect_hardware
12
+ from mita.models.ollama_client import OllamaClient
13
+ from mita.models.recommender import recommend_models
14
+ from mita.models.registry import find_model
15
+
16
+ console = Console()
17
+
18
+
19
+ def _get_client() -> OllamaClient:
20
+ """Get an OllamaClient from the current config."""
21
+ cfg = load_config()
22
+ return OllamaClient(host=cfg.ollama.host, timeout=cfg.ollama.timeout)
23
+
24
+
25
+ def _check_ollama(client: OllamaClient) -> bool:
26
+ """Check if Ollama is running, auto-start if configured."""
27
+ if client.is_running():
28
+ return True
29
+
30
+ # Try auto-start if configured
31
+ cfg = load_config()
32
+ if cfg.ollama.auto_manage:
33
+ from mita.models.server import ensure_server
34
+
35
+ if ensure_server(host=cfg.ollama.host, auto_manage=True, console=console):
36
+ return True
37
+
38
+ console.print(
39
+ "[red]Cannot connect to Ollama.[/red]\n"
40
+ "Make sure Ollama is running: [bold]ollama serve[/bold]"
41
+ )
42
+ return False
43
+
44
+
45
+ def list_models() -> None:
46
+ """List all installed Ollama models."""
47
+ client = _get_client()
48
+ if not _check_ollama(client):
49
+ return
50
+
51
+ models = client.list_models()
52
+ if not models:
53
+ console.print(
54
+ "[dim]No models installed. "
55
+ "Run [bold]mita models pull <name>[/bold] to get started.[/dim]"
56
+ )
57
+ return
58
+
59
+ table = Table(title="Installed Models")
60
+ table.add_column("Name", style="bold")
61
+ table.add_column("Size", justify="right")
62
+ table.add_column("Parameters", justify="right")
63
+ table.add_column("Quantization")
64
+ table.add_column("Family")
65
+
66
+ for m in models:
67
+ table.add_row(
68
+ m.name,
69
+ f"{m.size_gb:.1f} GB",
70
+ m.parameter_size,
71
+ m.quantization,
72
+ m.family,
73
+ )
74
+
75
+ console.print(table)
76
+
77
+
78
+ def pull_model(name: str) -> None:
79
+ """Pull a model from the Ollama registry."""
80
+ import ollama as ollama_lib
81
+
82
+ client = _get_client()
83
+ if not _check_ollama(client):
84
+ return
85
+
86
+ console.print(f"Pulling [bold]{name}[/bold]...")
87
+
88
+ try:
89
+ with Progress(
90
+ TextColumn("[progress.description]{task.description}"),
91
+ BarColumn(),
92
+ DownloadColumn(),
93
+ TransferSpeedColumn(),
94
+ console=console,
95
+ ) as progress:
96
+ task = progress.add_task("Downloading", total=None)
97
+
98
+ for update in client.pull(name, stream=True):
99
+ status = update.get("status", "")
100
+ completed = update.get("completed", 0)
101
+ total = update.get("total", 0)
102
+
103
+ if total > 0:
104
+ progress.update(task, completed=completed, total=total, description=status)
105
+ else:
106
+ progress.update(task, description=status)
107
+
108
+ progress.update(task, description="Complete")
109
+
110
+ console.print(f"[green]Successfully pulled {name}[/green]")
111
+ except ollama_lib.ResponseError as e:
112
+ console.print(f"[red]Failed to pull {name}: {e}[/red]")
113
+
114
+
115
+ def remove_model(name: str) -> None:
116
+ """Remove an installed model."""
117
+ client = _get_client()
118
+ if not _check_ollama(client):
119
+ return
120
+
121
+ import ollama as ollama_lib
122
+
123
+ try:
124
+ client.remove(name)
125
+ console.print(f"[green]Removed {name}[/green]")
126
+ except ollama_lib.ResponseError as e:
127
+ console.print(f"[red]Failed to remove {name}: {e}[/red]")
128
+
129
+
130
+ def show_model_info(name: str) -> None:
131
+ """Show details about a model."""
132
+ # Check registry first
133
+ card = find_model(name)
134
+ if card:
135
+ table = Table(title=f"Registry: {name}", show_header=False)
136
+ table.add_column("Field", style="bold")
137
+ table.add_column("Value")
138
+ table.add_row("Family", card.family)
139
+ table.add_row("Parameters", card.param_count)
140
+ table.add_row("Context Window", f"{card.context_window:,}")
141
+ table.add_row("Min RAM", f"{card.min_ram_gb:.0f} GB")
142
+ table.add_row("Min VRAM", f"{card.min_vram_gb:.0f} GB")
143
+ table.add_row("Tool Calls", "Yes" if card.tool_call_support else "No")
144
+ table.add_row("Quantization", card.quantization)
145
+ table.add_row("Description", card.description)
146
+ table.add_row("Tags", ", ".join(card.tags))
147
+ console.print(table)
148
+ console.print()
149
+
150
+ # Also show Ollama details if installed
151
+ import ollama as ollama_lib
152
+
153
+ client = _get_client()
154
+ if not _check_ollama(client):
155
+ return
156
+
157
+ try:
158
+ info = client.show(name)
159
+ details = info.get("details", {})
160
+ if isinstance(details, dict) and details:
161
+ table = Table(title=f"Ollama: {name}", show_header=False)
162
+ table.add_column("Field", style="bold")
163
+ table.add_column("Value")
164
+ for k, v in details.items():
165
+ table.add_row(str(k), str(v))
166
+ console.print(table)
167
+ elif not card:
168
+ console.print(f"[dim]Model {name} is installed but not in the curated registry.[/dim]")
169
+ except ollama_lib.ResponseError as e:
170
+ if not card:
171
+ console.print(f"[red]Model {name} not found in registry or Ollama: {e}[/red]")
172
+
173
+
174
+ def show_recommendations() -> None:
175
+ """Detect hardware and recommend models."""
176
+ hw = detect_hardware()
177
+
178
+ # Show hardware summary
179
+ hw_panel = Panel(
180
+ f"[bold]CPU:[/bold] {hw.cpu_name} ({hw.cpu_cores} cores)\n"
181
+ f"[bold]RAM:[/bold] {hw.ram_gb:.1f} GB\n"
182
+ f"[bold]GPU:[/bold] {hw.gpus[0].name if hw.gpus else 'None detected'}\n"
183
+ f"[bold]VRAM:[/bold] {hw.available_vram_gb:.1f} GB effective\n"
184
+ f"[bold]OS:[/bold] {hw.os}"
185
+ + ("\n[bold]Unified Memory:[/bold] Yes" if hw.unified_memory else ""),
186
+ title="Hardware Detected",
187
+ border_style="blue",
188
+ )
189
+ console.print(hw_panel)
190
+ console.print()
191
+
192
+ # Show recommendations
193
+ recs = recommend_models(hw)
194
+ if not recs:
195
+ console.print(
196
+ "[yellow]No models in the registry fit your hardware.[/yellow]\n"
197
+ "You may need more RAM/VRAM, or try a smaller model manually."
198
+ )
199
+ return
200
+
201
+ table = Table(title="Recommended Models")
202
+ table.add_column("Model", style="bold")
203
+ table.add_column("Params", justify="right")
204
+ table.add_column("Context", justify="right")
205
+ table.add_column("Fit", justify="right")
206
+ table.add_column("Notes")
207
+
208
+ for rec in recs:
209
+ # Color-code fit score
210
+ score = rec.fit_score
211
+ if score >= 0.8:
212
+ fit_style = "green"
213
+ elif score >= 0.6:
214
+ fit_style = "yellow"
215
+ else:
216
+ fit_style = "red"
217
+
218
+ table.add_row(
219
+ rec.model.name,
220
+ rec.model.param_count,
221
+ f"{rec.model.context_window:,}",
222
+ f"[{fit_style}]{score:.0%}[/{fit_style}]",
223
+ rec.notes,
224
+ )
225
+
226
+ console.print(table)
227
+
228
+
229
+ def set_default_model(name: str) -> None:
230
+ """Set the default model (prints instruction since config is TOML-based)."""
231
+ card = find_model(name)
232
+ if card:
233
+ console.print(f"[green]Model [bold]{name}[/bold] is in the registry.[/green]")
234
+ else:
235
+ console.print(f"[yellow]Model [bold]{name}[/bold] is not in the curated registry.[/yellow]")
236
+
237
+ console.print(
238
+ f"\nTo set as default, add to your config:\n\n"
239
+ f' [bold]mita config set model.default "{name}"[/bold]\n\n'
240
+ f"Or edit your config file directly:\n\n"
241
+ f' [dim][model]\n default = "{name}"[/dim]'
242
+ )
243
+
244
+
245
+ def show_hardware() -> None:
246
+ """Show detected hardware information."""
247
+ hw = detect_hardware()
248
+
249
+ table = Table(title="Hardware Information", show_header=False)
250
+ table.add_column("Field", style="bold")
251
+ table.add_column("Value")
252
+ table.add_row("OS", hw.os)
253
+ table.add_row("CPU", hw.cpu_name)
254
+ table.add_row("CPU Cores", str(hw.cpu_cores))
255
+ table.add_row("RAM", f"{hw.ram_gb:.1f} GB")
256
+ table.add_row("Apple Silicon", "Yes" if hw.apple_silicon else "No")
257
+ table.add_row("Unified Memory", "Yes" if hw.unified_memory else "No")
258
+
259
+ for i, gpu in enumerate(hw.gpus):
260
+ prefix = f"GPU {i}" if len(hw.gpus) > 1 else "GPU"
261
+ table.add_row(prefix, f"{gpu.name} ({gpu.vram_gb:.1f} GB, {gpu.vendor})")
262
+
263
+ if not hw.gpus:
264
+ table.add_row("GPU", "None detected")
265
+
266
+ table.add_row("Effective VRAM", f"{hw.available_vram_gb:.1f} GB")
267
+
268
+ console.print(table)
@@ -0,0 +1,104 @@
1
+ """Wrapper around the Ollama Python client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ import ollama
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class OllamaModelInfo(BaseModel):
13
+ """Info about an installed Ollama model."""
14
+
15
+ name: str
16
+ size_gb: float
17
+ parameter_size: str = ""
18
+ quantization: str = ""
19
+ family: str = ""
20
+ modified_at: str = ""
21
+
22
+
23
+ class OllamaClient:
24
+ """Thin wrapper around the ollama Python client."""
25
+
26
+ def __init__(self, host: str = "http://localhost:11434", timeout: int = 120) -> None:
27
+ self._client = ollama.Client(host=host, timeout=timeout)
28
+ self._host = host
29
+
30
+ def is_running(self) -> bool:
31
+ """Check if the Ollama server is reachable."""
32
+ try:
33
+ self._client.list()
34
+ return True
35
+ except (ConnectionError, OSError, ollama.ResponseError, TimeoutError):
36
+ return False
37
+
38
+ def list_models(self) -> list[OllamaModelInfo]:
39
+ """List all installed models."""
40
+ response = self._client.list()
41
+ models: list[OllamaModelInfo] = []
42
+ for m in response.models:
43
+ details: Any = m.details or {}
44
+ size_gb = round((m.size or 0) / (1024**3), 2)
45
+
46
+ # Handle details as either dict or object
47
+ if isinstance(details, dict):
48
+ param_size = details.get("parameter_size", "")
49
+ quant = details.get("quantization_level", "")
50
+ family = details.get("family", "")
51
+ else:
52
+ param_size = getattr(details, "parameter_size", "") or ""
53
+ quant = getattr(details, "quantization_level", "") or ""
54
+ family = getattr(details, "family", "") or ""
55
+
56
+ models.append(
57
+ OllamaModelInfo(
58
+ name=m.model or "",
59
+ size_gb=size_gb,
60
+ parameter_size=param_size,
61
+ quantization=quant,
62
+ family=family,
63
+ modified_at=str(m.modified_at or ""),
64
+ )
65
+ )
66
+ return models
67
+
68
+ def pull(self, model_name: str, stream: bool = True) -> Iterator[dict[str, Any]]:
69
+ """Pull a model from the Ollama registry.
70
+
71
+ Yields progress dicts with keys like 'status', 'completed', 'total'.
72
+ Raises ollama.ResponseError on failure (e.g., model not found).
73
+ """
74
+ if stream:
75
+ response = self._client.pull(model_name, stream=True)
76
+ for chunk in response:
77
+ if isinstance(chunk, dict):
78
+ yield chunk
79
+ else:
80
+ yield {"status": getattr(chunk, "status", str(chunk))}
81
+ else:
82
+ self._client.pull(model_name, stream=False)
83
+ yield {"status": "success"}
84
+
85
+ def remove(self, model_name: str) -> None:
86
+ """Remove an installed model.
87
+
88
+ Raises ollama.ResponseError if the model is not found.
89
+ """
90
+ self._client.delete(model_name)
91
+
92
+ def show(self, model_name: str) -> dict[str, Any]:
93
+ """Show details about a model."""
94
+ response = self._client.show(model_name)
95
+ if isinstance(response, dict):
96
+ return response
97
+ # Convert response object to dict
98
+ return {
99
+ "modelfile": getattr(response, "modelfile", ""),
100
+ "parameters": getattr(response, "parameters", ""),
101
+ "template": getattr(response, "template", ""),
102
+ "details": getattr(response, "details", {}),
103
+ "model_info": getattr(response, "model_info", {}),
104
+ }