ramllm 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 (105) hide show
  1. ramllm/__init__.py +3 -0
  2. ramllm/__main__.py +6 -0
  3. ramllm/api/__init__.py +5 -0
  4. ramllm/api/server.py +306 -0
  5. ramllm/backends/__init__.py +21 -0
  6. ramllm/backends/base.py +98 -0
  7. ramllm/backends/llamacpp/__init__.py +7 -0
  8. ramllm/backends/llamacpp/adapter.py +75 -0
  9. ramllm/backends/llamacpp/flags.py +78 -0
  10. ramllm/backends/llamacpp/install.py +75 -0
  11. ramllm/backends/llamacpp/launcher.py +130 -0
  12. ramllm/backends/mock.py +119 -0
  13. ramllm/cli/__init__.py +14 -0
  14. ramllm/cli/app.py +714 -0
  15. ramllm/cli/chat/__init__.py +12 -0
  16. ramllm/cli/chat/client.py +199 -0
  17. ramllm/cli/chat/history.py +16 -0
  18. ramllm/cli/chat/render.py +69 -0
  19. ramllm/cli/chat/repl.py +449 -0
  20. ramllm/cli/chat/slash.py +381 -0
  21. ramllm/cli/chat/toolbar.py +58 -0
  22. ramllm/cli/chat/transcripts.py +117 -0
  23. ramllm/cli/commands/backend_cmd.py +132 -0
  24. ramllm/cli/commands/chat_cmd.py +327 -0
  25. ramllm/cli/commands/clean_cmd.py +168 -0
  26. ramllm/cli/commands/completion_cmd.py +107 -0
  27. ramllm/cli/commands/config_cmd.py +114 -0
  28. ramllm/cli/commands/dashboard_cmd.py +83 -0
  29. ramllm/cli/commands/doctor_cmd.py +73 -0
  30. ramllm/cli/commands/explain_cmd.py +43 -0
  31. ramllm/cli/commands/models_cmd.py +396 -0
  32. ramllm/cli/commands/process_cmd.py +322 -0
  33. ramllm/cli/commands/serve_cmd.py +310 -0
  34. ramllm/cli/commands/setup_cmd.py +26 -0
  35. ramllm/cli/commands/tour_cmd.py +87 -0
  36. ramllm/cli/commands/update_cmd.py +74 -0
  37. ramllm/cli/commands/version_cmd.py +25 -0
  38. ramllm/cli/dashboard/__init__.py +5 -0
  39. ramllm/cli/dashboard/app.py +181 -0
  40. ramllm/cli/dashboard/widgets.py +147 -0
  41. ramllm/cli/home/menu.py +126 -0
  42. ramllm/cli/main.py +11 -0
  43. ramllm/cli/setup/state.py +58 -0
  44. ramllm/cli/setup/wizard.py +276 -0
  45. ramllm/cli/typo.py +25 -0
  46. ramllm/cli/ui/console.py +53 -0
  47. ramllm/cli/ui/errors.py +280 -0
  48. ramllm/cli/ui/glossary.py +80 -0
  49. ramllm/cli/ui/theme.py +44 -0
  50. ramllm/core/__init__.py +1 -0
  51. ramllm/core/analyzer/__init__.py +12 -0
  52. ramllm/core/analyzer/gguf.py +147 -0
  53. ramllm/core/analyzer/kv.py +56 -0
  54. ramllm/core/analyzer/moe.py +51 -0
  55. ramllm/core/governor/__init__.py +18 -0
  56. ramllm/core/governor/enforcement/__init__.py +20 -0
  57. ramllm/core/governor/enforcement/base.py +42 -0
  58. ramllm/core/governor/enforcement/cgroups.py +123 -0
  59. ramllm/core/governor/enforcement/darwin.py +37 -0
  60. ramllm/core/governor/enforcement/factory.py +27 -0
  61. ramllm/core/governor/enforcement/windows.py +138 -0
  62. ramllm/core/governor/monitor.py +224 -0
  63. ramllm/core/governor/recovery.py +211 -0
  64. ramllm/core/planner/__init__.py +17 -0
  65. ramllm/core/planner/candidates.py +726 -0
  66. ramllm/core/planner/cost_model.py +106 -0
  67. ramllm/core/planner/explain.py +75 -0
  68. ramllm/core/planner/feasibility.py +104 -0
  69. ramllm/core/planner/planner.py +123 -0
  70. ramllm/core/planner/recommend.py +134 -0
  71. ramllm/core/planner/reduction.py +360 -0
  72. ramllm/core/profiler/__init__.py +5 -0
  73. ramllm/core/profiler/cpu.py +61 -0
  74. ramllm/core/profiler/disk.py +85 -0
  75. ramllm/core/profiler/gpu_amd.py +51 -0
  76. ramllm/core/profiler/gpu_apple.py +69 -0
  77. ramllm/core/profiler/gpu_nvidia.py +131 -0
  78. ramllm/core/profiler/gpu_vulkan.py +46 -0
  79. ramllm/core/profiler/memory.py +58 -0
  80. ramllm/core/profiler/pcie.py +31 -0
  81. ramllm/core/profiler/prober.py +119 -0
  82. ramllm/core/schemas.py +158 -0
  83. ramllm/data/models_index.json +1098 -0
  84. ramllm/registry/__init__.py +26 -0
  85. ramllm/registry/catalog.py +182 -0
  86. ramllm/registry/download.py +137 -0
  87. ramllm/services/__init__.py +5 -0
  88. ramllm/services/backend_manager.py +558 -0
  89. ramllm/services/config_service.py +125 -0
  90. ramllm/services/doctor_service.py +272 -0
  91. ramllm/services/model_resolver.py +259 -0
  92. ramllm/services/model_service.py +219 -0
  93. ramllm/services/paths.py +73 -0
  94. ramllm/services/plan_service.py +209 -0
  95. ramllm/services/process_registry.py +171 -0
  96. ramllm/services/upgrade_service.py +81 -0
  97. ramllm/telemetry/__init__.py +13 -0
  98. ramllm/telemetry/bench.py +121 -0
  99. ramllm/telemetry/calibration.py +73 -0
  100. ramllm/util/parsing.py +61 -0
  101. ramllm-0.1.0.dist-info/METADATA +276 -0
  102. ramllm-0.1.0.dist-info/RECORD +105 -0
  103. ramllm-0.1.0.dist-info/WHEEL +4 -0
  104. ramllm-0.1.0.dist-info/entry_points.txt +2 -0
  105. ramllm-0.1.0.dist-info/licenses/LICENSE +17 -0
ramllm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RAMLLM: Run bigger models on smaller machines."""
2
+
3
+ __version__ = "0.1.0"
ramllm/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Main entry point for python -m ramllm."""
2
+
3
+ from ramllm.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
ramllm/api/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """API package."""
2
+
3
+ from ramllm.api.server import api_app, create_app
4
+
5
+ __all__ = ["api_app", "create_app"]
ramllm/api/server.py ADDED
@@ -0,0 +1,306 @@
1
+ """FastAPI server providing OpenAI-compatible endpoints and RAMLLM extensions."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator
5
+ from contextlib import asynccontextmanager
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from fastapi import FastAPI, HTTPException, Request, Response
10
+ from fastapi.responses import JSONResponse, StreamingResponse
11
+ from pydantic import BaseModel
12
+
13
+ from ramllm.backends import LlamaCppAdapter
14
+ from ramllm.backends.base import BackendAdapter, BackendProcess
15
+ from ramllm.core.analyzer.gguf import analyze_gguf
16
+ from ramllm.core.governor.monitor import MemoryGovernor, TierMetrics
17
+ from ramllm.core.governor.recovery import RecoveryExhaustedError, launch_with_recovery
18
+ from ramllm.core.planner import create_execution_plans
19
+ from ramllm.core.profiler import probe_hardware
20
+ from ramllm.core.schemas import ExecutionPlan, HardwareProfile, ModelInfo, UserRequest
21
+
22
+
23
+ class ReplanRequest(BaseModel):
24
+ """Payload for dynamic runtime replanning."""
25
+
26
+ vram_budget_bytes: dict[int, int] | None = None
27
+ ram_budget_bytes: int | None = None
28
+ context: int | None = None
29
+ gpu_indices: list[int] | None = None
30
+ allow_streaming: bool | None = None
31
+
32
+
33
+ class ServerState:
34
+ """Shared state for the RAMLLM API server."""
35
+
36
+ def __init__(self) -> None:
37
+ self.model_path: str = ""
38
+ self.model_info: ModelInfo | None = None
39
+ self.hw_profile: HardwareProfile | None = None
40
+ self.user_request: UserRequest | None = None
41
+ self.active_plan: ExecutionPlan | None = None
42
+ self.backend_process: BackendProcess | None = None
43
+ self.governor: MemoryGovernor | None = None
44
+ self.internal_port: int = 18080
45
+ self.adapter: BackendAdapter = LlamaCppAdapter()
46
+ self.lock: asyncio.Lock = asyncio.Lock()
47
+ self.replan_pending: bool = False
48
+ self.auto_retry: bool = True
49
+ self.max_retries: int = 3
50
+
51
+
52
+ state = ServerState()
53
+
54
+
55
+ def _handle_server_memory_pressure(metrics: TierMetrics) -> None:
56
+ """Invoked when memory governor detects external memory pressure during server runtime."""
57
+ state.replan_pending = True
58
+
59
+
60
+ def launch_active_session(auto_retry: bool | None = None) -> None:
61
+ """Plan and launch the backend session with reactive recovery and memory governor."""
62
+ if not state.model_info or not state.hw_profile or not state.user_request:
63
+ raise RuntimeError("Server state not initialized.")
64
+
65
+ plans = create_execution_plans(state.model_info, state.hw_profile, state.user_request)
66
+ chosen = plans[0]
67
+ if chosen.verdict == "INFEASIBLE":
68
+ raise ValueError(f"Plan is INFEASIBLE: {'; '.join(chosen.warnings)}")
69
+
70
+ use_auto_retry = state.auto_retry if auto_retry is None else auto_retry
71
+
72
+ proc, final_plan, _ = launch_with_recovery(
73
+ adapter=state.adapter,
74
+ plan=chosen,
75
+ model=state.model_info,
76
+ hw=state.hw_profile,
77
+ req=state.user_request,
78
+ port=state.internal_port,
79
+ timeout_s=40.0,
80
+ max_retries=state.max_retries,
81
+ auto_retry=use_auto_retry,
82
+ )
83
+
84
+ state.active_plan = final_plan
85
+ state.backend_process = proc
86
+ state.governor = MemoryGovernor(
87
+ proc,
88
+ state.hw_profile,
89
+ on_pressure=_handle_server_memory_pressure,
90
+ )
91
+ state.governor.start()
92
+ state.replan_pending = False
93
+
94
+
95
+ def shutdown_active_session() -> None:
96
+ """Stop memory governor and terminate backend engine."""
97
+ if state.governor:
98
+ state.governor.stop()
99
+ state.governor = None
100
+ if state.backend_process:
101
+ state.adapter.stop(state.backend_process)
102
+ state.backend_process = None
103
+
104
+
105
+ @asynccontextmanager
106
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
107
+ """FastAPI application lifespan managing backend lifecycle."""
108
+ if state.model_path:
109
+ launch_active_session()
110
+ yield
111
+ shutdown_active_session()
112
+
113
+
114
+ api_app = FastAPI(
115
+ title="RAMLLM Server",
116
+ description="OpenAI-compatible LLM inference server with strict RAM/VRAM budget governor.",
117
+ version="0.1.0",
118
+ lifespan=lifespan,
119
+ )
120
+
121
+
122
+ @api_app.get("/healthz")
123
+ async def healthz() -> dict[str, str]:
124
+ """Liveness check endpoint."""
125
+ return {"status": "ok"}
126
+
127
+
128
+ @api_app.get("/ramllm/plan")
129
+ async def get_plan() -> ExecutionPlan:
130
+ """Return the currently executing ExecutionPlan."""
131
+ if not state.active_plan:
132
+ raise HTTPException(status_code=503, detail="No active plan scheduled.")
133
+ return state.active_plan
134
+
135
+
136
+ @api_app.get("/ramllm/metrics")
137
+ async def get_metrics() -> TierMetrics:
138
+ """Return real-time per-tier memory metrics and governor warnings."""
139
+ if not state.governor:
140
+ raise HTTPException(status_code=503, detail="Governor not running.")
141
+ return state.governor.get_latest_metrics()
142
+
143
+
144
+ @api_app.post("/ramllm/replan")
145
+ async def replan(req: ReplanRequest) -> ExecutionPlan:
146
+ """Change memory budgets or context at runtime and replan execution."""
147
+ async with state.lock:
148
+ if not state.user_request or not state.model_info or not state.hw_profile:
149
+ raise HTTPException(status_code=503, detail="Server not initialized with model.")
150
+
151
+ shutdown_active_session()
152
+
153
+ # Update request
154
+ ctx = req.context or state.user_request.min_context
155
+ vram_dict = (
156
+ req.vram_budget_bytes
157
+ if req.vram_budget_bytes is not None
158
+ else state.user_request.vram_budget_bytes
159
+ )
160
+ ram_bytes = (
161
+ req.ram_budget_bytes
162
+ if req.ram_budget_bytes is not None
163
+ else state.user_request.ram_budget_bytes
164
+ )
165
+
166
+ gpu_idxs = (
167
+ req.gpu_indices if req.gpu_indices is not None else state.user_request.gpu_indices
168
+ )
169
+
170
+ allow_streaming = (
171
+ req.allow_streaming
172
+ if req.allow_streaming is not None
173
+ else state.user_request.allow_streaming
174
+ )
175
+
176
+ state.user_request = UserRequest(
177
+ model=state.model_path,
178
+ vram_budget_bytes=vram_dict,
179
+ ram_budget_bytes=ram_bytes,
180
+ min_context=ctx,
181
+ target_context=ctx,
182
+ gpu_indices=gpu_idxs,
183
+ allow_streaming=allow_streaming,
184
+ )
185
+
186
+ try:
187
+ launch_active_session()
188
+ except (ValueError, RecoveryExhaustedError) as err:
189
+ raise HTTPException(
190
+ status_code=422,
191
+ detail=f"Cannot replan under requested constraints: {err}",
192
+ ) from err
193
+ except Exception as err:
194
+ raise HTTPException(
195
+ status_code=500,
196
+ detail=f"Replan failed unexpectedly: {err}",
197
+ ) from err
198
+
199
+ if not state.active_plan:
200
+ raise HTTPException(status_code=500, detail="Failed to establish new plan.")
201
+ return state.active_plan
202
+
203
+
204
+ @api_app.get("/v1/models")
205
+ async def list_models_api() -> dict[str, Any]:
206
+ """OpenAI-compatible models list endpoint."""
207
+ model_name = state.model_info.arch if state.model_info else "ramllm-model"
208
+ return {
209
+ "object": "list",
210
+ "data": [
211
+ {
212
+ "id": model_name,
213
+ "object": "model",
214
+ "created": 1700000000,
215
+ "owned_by": "ramllm",
216
+ }
217
+ ],
218
+ }
219
+
220
+
221
+ @api_app.post("/v1/chat/completions")
222
+ async def chat_completions(request: Request) -> Response:
223
+ """OpenAI-compatible chat completions proxy with streaming SSE support."""
224
+ body = await request.json()
225
+ is_stream = body.get("stream", False)
226
+ target_url = f"http://127.0.0.1:{state.internal_port}/v1/chat/completions"
227
+
228
+ client = httpx.AsyncClient(timeout=120.0)
229
+
230
+ if is_stream:
231
+
232
+ async def event_generator() -> AsyncGenerator[bytes, None]:
233
+ try:
234
+ async with client.stream("POST", target_url, json=body) as resp:
235
+ async for chunk in resp.aiter_bytes():
236
+ yield chunk
237
+ finally:
238
+ await client.aclose()
239
+
240
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
241
+ else:
242
+ try:
243
+ resp = await client.post(target_url, json=body)
244
+ return JSONResponse(status_code=resp.status_code, content=resp.json())
245
+ finally:
246
+ await client.aclose()
247
+
248
+
249
+ @api_app.post("/v1/completions")
250
+ async def completions(request: Request) -> Response:
251
+ """OpenAI-compatible text completions proxy."""
252
+ body = await request.json()
253
+ is_stream = body.get("stream", False)
254
+ target_url = f"http://127.0.0.1:{state.internal_port}/v1/completions"
255
+
256
+ client = httpx.AsyncClient(timeout=120.0)
257
+
258
+ if is_stream:
259
+
260
+ async def event_generator() -> AsyncGenerator[bytes, None]:
261
+ try:
262
+ async with client.stream("POST", target_url, json=body) as resp:
263
+ async for chunk in resp.aiter_bytes():
264
+ yield chunk
265
+ finally:
266
+ await client.aclose()
267
+
268
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
269
+ else:
270
+ try:
271
+ resp = await client.post(target_url, json=body)
272
+ return JSONResponse(status_code=resp.status_code, content=resp.json())
273
+ finally:
274
+ await client.aclose()
275
+
276
+
277
+ def create_app(
278
+ model_path: str,
279
+ vram_budget_bytes: dict[int, int] | None = None,
280
+ ram_budget_bytes: int | None = None,
281
+ context: int = 4096,
282
+ gpu_indices: list[int] | None = None,
283
+ internal_port: int = 18080,
284
+ adapter: BackendAdapter | None = None,
285
+ hw_profile: HardwareProfile | None = None,
286
+ auto_retry: bool = True,
287
+ max_retries: int = 3,
288
+ ) -> FastAPI:
289
+ """Configure server state and return FastAPI application instance."""
290
+ state.model_path = model_path
291
+ state.internal_port = internal_port
292
+ state.auto_retry = auto_retry
293
+ state.max_retries = max_retries
294
+ if adapter:
295
+ state.adapter = adapter
296
+ state.model_info = analyze_gguf(model_path)
297
+ state.hw_profile = hw_profile or probe_hardware(quick=True)
298
+ state.user_request = UserRequest(
299
+ model=model_path,
300
+ vram_budget_bytes=vram_budget_bytes,
301
+ ram_budget_bytes=ram_budget_bytes,
302
+ min_context=context,
303
+ target_context=context,
304
+ gpu_indices=gpu_indices,
305
+ )
306
+ return api_app
@@ -0,0 +1,21 @@
1
+ """Backends package."""
2
+
3
+ from ramllm.backends.base import (
4
+ BackendAdapter,
5
+ BackendInstall,
6
+ BackendProcess,
7
+ Capabilities,
8
+ MemoryReport,
9
+ )
10
+ from ramllm.backends.llamacpp import LlamaCppAdapter
11
+ from ramllm.backends.mock import MockBackendAdapter
12
+
13
+ __all__ = [
14
+ "BackendAdapter",
15
+ "BackendInstall",
16
+ "BackendProcess",
17
+ "Capabilities",
18
+ "MemoryReport",
19
+ "LlamaCppAdapter",
20
+ "MockBackendAdapter",
21
+ ]
@@ -0,0 +1,98 @@
1
+ """Abstract backend interface, capabilities, and process models."""
2
+
3
+ import subprocess
4
+ from abc import ABC, abstractmethod
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from ramllm.core.schemas import ExecutionPlan
9
+
10
+
11
+ class Capabilities(BaseModel):
12
+ """Features supported by a specific backend installation."""
13
+
14
+ model_config = ConfigDict(frozen=True)
15
+
16
+ partial_gpu_offload: bool = True
17
+ per_tensor_placement: bool = False
18
+ kv_on_gpu_or_cpu_choice: bool = True
19
+ kv_quantization: bool = True
20
+ multi_gpu_layer_split: bool = True
21
+ multi_gpu_tensor_split: bool = False
22
+ flash_attention: bool = True
23
+ mmap_control: bool = True
24
+ async_weight_streaming: bool = False
25
+ expert_level_eviction: bool = False
26
+ apis: list[str] = Field(default_factory=list)
27
+
28
+
29
+ class BackendInstall(BaseModel):
30
+ """Metadata describing a discovered backend binary on the system."""
31
+
32
+ model_config = ConfigDict(frozen=True)
33
+
34
+ name: str
35
+ binary_path: str
36
+ version: str | None = None
37
+ build_info: str | None = None
38
+ capabilities: Capabilities = Field(default_factory=Capabilities)
39
+
40
+
41
+ class MemoryReport(BaseModel):
42
+ """Reported peak and current memory usage from runtime process sampling and logs."""
43
+
44
+ vram_used_bytes: dict[int, int] = Field(default_factory=dict)
45
+ ram_rss_bytes: int = 0
46
+ model_buffer_bytes: int = 0
47
+ kv_buffer_bytes: int = 0
48
+ compute_buffer_bytes: int = 0
49
+
50
+
51
+ class BackendProcess(BaseModel):
52
+ """Handle to a running backend server process."""
53
+
54
+ model_config = ConfigDict(arbitrary_types_allowed=True)
55
+
56
+ pid: int
57
+ port: int
58
+ process: subprocess.Popen[str] | None = None
59
+ plan: ExecutionPlan
60
+ initial_memory_report: MemoryReport = Field(default_factory=MemoryReport)
61
+
62
+
63
+ class BackendAdapter(ABC):
64
+ """Abstract interface for all RAMLLM execution engines."""
65
+
66
+ name: str
67
+
68
+ @abstractmethod
69
+ def detect(self) -> BackendInstall | None:
70
+ """Discover backend binary on system PATH or configured locations."""
71
+ ...
72
+
73
+ @abstractmethod
74
+ def capabilities(self, install: BackendInstall) -> Capabilities:
75
+ """Inspect backend capabilities from its binary help output."""
76
+ ...
77
+
78
+ @abstractmethod
79
+ def build_args(self, plan: ExecutionPlan, port: int = 8080) -> list[str]:
80
+ """Convert an ExecutionPlan into concrete command-line arguments."""
81
+ ...
82
+
83
+ @abstractmethod
84
+ def launch(
85
+ self, plan: ExecutionPlan, port: int = 8080, timeout_s: float = 30.0
86
+ ) -> BackendProcess:
87
+ """Launch the backend process, await healthiness, and extract parsed buffer sizes."""
88
+ ...
89
+
90
+ @abstractmethod
91
+ def memory_report(self, proc: BackendProcess) -> MemoryReport:
92
+ """Collect current memory consumption across hardware tiers."""
93
+ ...
94
+
95
+ @abstractmethod
96
+ def stop(self, proc: BackendProcess) -> None:
97
+ """Gracefully terminate the backend process."""
98
+ ...
@@ -0,0 +1,7 @@
1
+ """llama.cpp backend package."""
2
+
3
+ from ramllm.backends.llamacpp.adapter import LlamaCppAdapter
4
+ from ramllm.backends.llamacpp.flags import build_llamacpp_args
5
+ from ramllm.backends.llamacpp.install import detect_llamacpp
6
+
7
+ __all__ = ["LlamaCppAdapter", "detect_llamacpp", "build_llamacpp_args"]
@@ -0,0 +1,75 @@
1
+ """Llama.cpp Backend Adapter implementation."""
2
+
3
+ import psutil
4
+
5
+ from ramllm.backends.base import (
6
+ BackendAdapter,
7
+ BackendInstall,
8
+ BackendProcess,
9
+ Capabilities,
10
+ MemoryReport,
11
+ )
12
+ from ramllm.backends.llamacpp.flags import build_llamacpp_args
13
+ from ramllm.backends.llamacpp.install import detect_llamacpp
14
+ from ramllm.backends.llamacpp.launcher import launch_llamacpp_server
15
+ from ramllm.core.schemas import ExecutionPlan
16
+
17
+
18
+ class LlamaCppAdapter(BackendAdapter):
19
+ """Execution engine adapter for llama.cpp via managed llama-server subprocess."""
20
+
21
+ name = "llamacpp"
22
+
23
+ def __init__(self, binary_path: str | None = None):
24
+ self.binary_path = binary_path
25
+ self._install: BackendInstall | None = None
26
+
27
+ def detect(self) -> BackendInstall | None:
28
+ if self.binary_path:
29
+ self._install = detect_llamacpp(self.binary_path)
30
+ else:
31
+ from ramllm.services.backend_manager import discover_backend
32
+
33
+ res = discover_backend()
34
+ self._install = res.install if res else None
35
+ return self._install
36
+
37
+ def capabilities(self, install: BackendInstall) -> Capabilities:
38
+ return install.capabilities
39
+
40
+ def build_args(self, plan: ExecutionPlan, port: int = 8080) -> list[str]:
41
+ if not self._install:
42
+ self.detect()
43
+ return build_llamacpp_args(plan, install=self._install, port=port)
44
+
45
+ def launch(
46
+ self, plan: ExecutionPlan, port: int = 8080, timeout_s: float = 30.0
47
+ ) -> BackendProcess:
48
+ cmd = self.build_args(plan, port=port)
49
+ return launch_llamacpp_server(cmd, plan=plan, port=port, timeout_s=timeout_s)
50
+
51
+ def memory_report(self, proc: BackendProcess) -> MemoryReport:
52
+ rss = 0
53
+ try:
54
+ p = psutil.Process(proc.pid)
55
+ rss = p.memory_info().rss
56
+ for child in p.children(recursive=True):
57
+ rss += child.memory_info().rss
58
+ except Exception:
59
+ rss = proc.initial_memory_report.ram_rss_bytes
60
+
61
+ return MemoryReport(
62
+ vram_used_bytes=proc.plan.memory.vram_peak_bytes,
63
+ ram_rss_bytes=rss,
64
+ model_buffer_bytes=proc.initial_memory_report.model_buffer_bytes,
65
+ kv_buffer_bytes=proc.initial_memory_report.kv_buffer_bytes,
66
+ compute_buffer_bytes=proc.initial_memory_report.compute_buffer_bytes,
67
+ )
68
+
69
+ def stop(self, proc: BackendProcess) -> None:
70
+ if proc.process and proc.process.poll() is None:
71
+ proc.process.terminate()
72
+ try:
73
+ proc.process.wait(timeout=3)
74
+ except Exception:
75
+ proc.process.kill()
@@ -0,0 +1,78 @@
1
+ """Flag translation from ExecutionPlan to llama-server CLI arguments."""
2
+
3
+ from ramllm.backends.base import BackendInstall
4
+ from ramllm.core.schemas import ExecutionPlan
5
+
6
+
7
+ def build_llamacpp_args(
8
+ plan: ExecutionPlan,
9
+ install: BackendInstall | None = None,
10
+ host: str = "127.0.0.1",
11
+ port: int = 8080,
12
+ ) -> list[str]:
13
+ """Map an ExecutionPlan to concrete arguments for llama-server."""
14
+ binary = install.binary_path if install else "llama-server"
15
+ args = [binary, "--host", host, "--port", str(port)]
16
+
17
+ # Extract model path from backend_args in plan
18
+ model_path = ""
19
+ for i, a in enumerate(plan.backend_args):
20
+ if a in ("-m", "--model") and i + 1 < len(plan.backend_args):
21
+ model_path = plan.backend_args[i + 1]
22
+ break
23
+
24
+ if model_path:
25
+ args.extend(["-m", model_path])
26
+
27
+ # GPU layers
28
+ gpu_layer_count = sum(len(layer_list) for layer_list in plan.placement.gpu_layers.values())
29
+ args.extend(["-ngl", str(gpu_layer_count)])
30
+
31
+ # Context size
32
+ args.extend(["-c", str(plan.kv.context)])
33
+
34
+ # Predictable single-slot memory usage
35
+ args.extend(["-np", "1"])
36
+
37
+ # KV quantization
38
+ if plan.kv.type_k != "f16":
39
+ args.extend(["-ctk", plan.kv.type_k])
40
+ if plan.kv.type_v != "f16":
41
+ args.extend(["-ctv", plan.kv.type_v])
42
+
43
+ # KV offload to CPU if requested
44
+ if plan.kv.location == "cpu":
45
+ args.append("--no-kv-offload")
46
+
47
+ # Multi-GPU split
48
+ if len(plan.placement.gpu_layers) > 1:
49
+ total_offloaded = sum(len(layers) for layers in plan.placement.gpu_layers.values())
50
+ if total_offloaded > 0:
51
+ gpu_indices = sorted(plan.placement.gpu_layers.keys())
52
+ max_gpu_idx = max(gpu_indices)
53
+ fractions = [
54
+ f"{len(plan.placement.gpu_layers.get(idx, [])) / total_offloaded:.2f}"
55
+ for idx in range(max_gpu_idx + 1)
56
+ ]
57
+ main_gpu = gpu_indices[0]
58
+ for i, a in enumerate(plan.backend_args):
59
+ if a in ("-mg", "--main-gpu") and i + 1 < len(plan.backend_args):
60
+ main_gpu = int(plan.backend_args[i + 1])
61
+ break
62
+ args.extend(["-sm", "layer", "-ts", ",".join(fractions), "-mg", str(main_gpu)])
63
+
64
+ # MoE CPU offload flags
65
+ if plan.placement.expert_placement == "cpu":
66
+ args.append("-cmoe")
67
+
68
+ # Partial MoE CPU offload (-ncmoe)
69
+ for i, a in enumerate(plan.backend_args):
70
+ if a in ("-ncmoe", "--n-cpu-moe") and i + 1 < len(plan.backend_args):
71
+ args.extend(["-ncmoe", plan.backend_args[i + 1]])
72
+ break
73
+
74
+ # Disk streaming / no-mmap
75
+ if plan.placement.disk_streamed_layers:
76
+ args.extend(["-lm", "none"])
77
+
78
+ return args
@@ -0,0 +1,75 @@
1
+ """Discovery and capability introspection for llama.cpp installations."""
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ from ramllm.backends.base import BackendInstall, Capabilities
7
+
8
+
9
+ def detect_llamacpp(custom_path: str | None = None) -> BackendInstall | None:
10
+ """Discover llama-server binary and introspect its capabilities.
11
+
12
+ Returns None if no working llama-server executable is found.
13
+ """
14
+ binary = custom_path or shutil.which("llama-server") or shutil.which("llama-cli")
15
+ if not binary:
16
+ return None
17
+
18
+ # Check version
19
+ version_str: str | None = None
20
+ build_info: str | None = None
21
+ try:
22
+ ver_res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=5)
23
+ out = ver_res.stdout + ver_res.stderr
24
+ for line in out.splitlines():
25
+ if "version:" in line.lower() or "build" in line.lower():
26
+ version_str = line.strip()
27
+ if "built with" in line.lower():
28
+ build_info = line.strip()
29
+ except Exception:
30
+ pass
31
+
32
+ # Check help output for supported flags
33
+ help_text = ""
34
+ try:
35
+ help_res = subprocess.run([binary, "--help"], capture_output=True, text=True, timeout=5)
36
+ help_text = help_res.stdout + help_res.stderr
37
+ except Exception:
38
+ pass
39
+
40
+ # Derive capabilities from help text
41
+ has_ngl = "-ngl" in help_text or "--gpu-layers" in help_text
42
+ has_cpu_moe = "-cmoe" in help_text or "--cpu-moe" in help_text
43
+ has_kv_quant = "-ctk" in help_text or "--cache-type-k" in help_text
44
+ has_override_tensor = "-ot" in help_text or "--override-tensor" in help_text
45
+ has_split_mode = "-sm" in help_text or "--split-mode" in help_text
46
+ has_flash_attn = "-fa" in help_text or "--flash-attn" in help_text
47
+
48
+ apis: list[str] = []
49
+ if "metal" in help_text.lower() or "metal" in (build_info or "").lower():
50
+ apis.append("metal")
51
+ if "cuda" in help_text.lower() or "cuda" in (build_info or "").lower():
52
+ apis.append("cuda")
53
+ if "vulkan" in help_text.lower():
54
+ apis.append("vulkan")
55
+ if "rocm" in help_text.lower():
56
+ apis.append("rocm")
57
+
58
+ caps = Capabilities(
59
+ partial_gpu_offload=has_ngl,
60
+ per_tensor_placement=has_override_tensor or has_cpu_moe,
61
+ kv_on_gpu_or_cpu_choice=True,
62
+ kv_quantization=has_kv_quant,
63
+ multi_gpu_layer_split=has_split_mode,
64
+ flash_attention=has_flash_attn,
65
+ mmap_control=True,
66
+ apis=apis,
67
+ )
68
+
69
+ return BackendInstall(
70
+ name="llamacpp",
71
+ binary_path=binary,
72
+ version=version_str,
73
+ build_info=build_info,
74
+ capabilities=caps,
75
+ )