1use std::env;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use thiserror::Error;
8
9use crate::artifact::{ArtifactFetcher, DownloadPolicy};
10use crate::hardware::{HardwareProfile, RuntimeBackend};
11use crate::inference::{ModelCapabilities, OpenAiCompatibleProvider, RuntimeProvenance};
12use crate::model::{ModelAcquireError, ModelCacheInspection, ModelInstall, NeoHorseModelManager};
13use crate::runtime::{
14 LLAMA_CPP_COMMIT, LLAMA_CPP_VERSION, LlamaRuntimeManager, RuntimeCacheInspection, RuntimeError,
15 RuntimeInstall,
16};
17use crate::server::{ManagedServerEndpoint, ServerError, ServerLaunchConfig, ServerManager};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ManagedRuntimeConfig {
21 pub cache_root: PathBuf,
22 pub preferred_backend: Option<RuntimeBackend>,
23 pub download_policy: DownloadPolicy,
24 pub startup_timeout: Duration,
25 pub health_timeout: Duration,
26}
27
28impl ManagedRuntimeConfig {
29 #[must_use]
30 pub fn new(cache_root: impl Into<PathBuf>) -> Self {
31 Self {
32 cache_root: cache_root.into(),
33 preferred_backend: None,
34 download_policy: DownloadPolicy::default(),
35 startup_timeout: Duration::from_secs(10 * 60),
36 health_timeout: Duration::from_secs(2),
37 }
38 }
39
40 pub fn discover() -> Result<Self, ManagedRuntimeError> {
43 Ok(Self::new(default_cache_root()?))
44 }
45
46 pub fn inspect(
48 &self,
49 verify_digests: bool,
50 ) -> Result<ManagedRuntimeInspection, ManagedRuntimeError> {
51 if !self.cache_root.is_absolute() {
52 return Err(ManagedRuntimeError::RelativeCacheRoot(
53 self.cache_root.clone(),
54 ));
55 }
56 let hardware = HardwareProfile::detect();
57 let backend = self
58 .preferred_backend
59 .unwrap_or_else(|| hardware.recommended_backend());
60 let fetcher = ArtifactFetcher::new(self.download_policy);
61 let runtime = LlamaRuntimeManager::new(&self.cache_root, fetcher.clone()).inspect_for(
62 hardware.platform,
63 backend,
64 verify_digests,
65 )?;
66 let model = NeoHorseModelManager::new(&self.cache_root, fetcher).inspect(verify_digests)?;
67 let disk_probe = self
68 .cache_root
69 .ancestors()
70 .find(|path| path.exists())
71 .map(std::path::Path::to_path_buf)
72 .unwrap_or_else(|| PathBuf::from("/"));
73 let available_disk_bytes = fs2::available_space(&disk_probe).map_err(|source| {
74 ManagedRuntimeError::CacheInspection {
75 path: disk_probe,
76 source,
77 }
78 })?;
79 Ok(ManagedRuntimeInspection {
80 cache_root: self.cache_root.clone(),
81 hardware,
82 backend,
83 runtime,
84 model,
85 available_disk_bytes,
86 })
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ManagedRuntimeInspection {
92 pub cache_root: PathBuf,
93 pub hardware: HardwareProfile,
94 pub backend: RuntimeBackend,
95 pub runtime: RuntimeCacheInspection,
96 pub model: ModelCacheInspection,
97 pub available_disk_bytes: u64,
98}
99
100#[derive(Debug)]
101pub struct ManagedRuntime {
102 config: ManagedRuntimeConfig,
103 hardware: HardwareProfile,
104 server: ServerManager,
105}
106
107impl ManagedRuntime {
108 #[must_use]
109 pub fn new(config: ManagedRuntimeConfig) -> Self {
110 Self {
111 config,
112 hardware: HardwareProfile::detect(),
113 server: ServerManager::new(),
114 }
115 }
116
117 #[must_use]
118 pub fn with_hardware(mut self, hardware: HardwareProfile) -> Self {
119 self.hardware = hardware;
120 self
121 }
122
123 pub fn prepare(&mut self) -> Result<PreparedRuntime, ManagedRuntimeError> {
127 if !self.config.cache_root.is_absolute() {
128 return Err(ManagedRuntimeError::RelativeCacheRoot(
129 self.config.cache_root.clone(),
130 ));
131 }
132 let fetcher = ArtifactFetcher::new(self.config.download_policy);
133 let runtime = LlamaRuntimeManager::new(&self.config.cache_root, fetcher.clone())
134 .ensure(&self.hardware, self.config.preferred_backend)?;
135 let model = NeoHorseModelManager::new(&self.config.cache_root, fetcher).ensure()?;
136 let launch_config = server_config(
137 &runtime,
138 &model,
139 &self.hardware,
140 self.config.startup_timeout,
141 self.config.health_timeout,
142 );
143 let endpoint = self.server.ensure_running(launch_config.clone())?;
144 let runtime_provenance = managed_runtime_provenance(
145 &runtime,
146 &model.capabilities,
147 &launch_config.arguments(endpoint.port),
148 &self.hardware,
149 );
150 Ok(PreparedRuntime {
151 endpoint,
152 runtime,
153 model_path: model.path,
154 capabilities: managed_capabilities(model.capabilities),
155 runtime_provenance,
156 hardware: self.hardware.clone(),
157 })
158 }
159
160 pub fn shutdown(&mut self) -> Result<(), ManagedRuntimeError> {
161 self.server.shutdown()?;
162 Ok(())
163 }
164
165 #[must_use]
166 pub fn diagnostics(&self) -> String {
167 self.server.diagnostics()
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct PreparedRuntime {
173 pub endpoint: ManagedServerEndpoint,
174 pub runtime: RuntimeInstall,
175 pub model_path: PathBuf,
176 pub capabilities: ModelCapabilities,
177 pub runtime_provenance: RuntimeProvenance,
178 pub hardware: HardwareProfile,
179}
180
181impl PreparedRuntime {
182 #[must_use]
183 pub fn provider(&self, inference_timeout: Duration) -> OpenAiCompatibleProvider {
184 OpenAiCompatibleProvider::new(
185 &self.endpoint.endpoint,
186 &self.capabilities.identifier,
187 None,
188 inference_timeout,
189 )
190 .with_model_capabilities(self.capabilities.clone())
191 .with_runtime_provenance(self.runtime_provenance.clone())
192 }
193}
194
195#[derive(Debug, Error)]
196pub enum ManagedRuntimeError {
197 #[error(transparent)]
198 Runtime(#[from] RuntimeError),
199 #[error(transparent)]
200 Model(#[from] ModelAcquireError),
201 #[error(transparent)]
202 Server(#[from] ServerError),
203 #[error("managed cache root must be absolute: {0}")]
204 RelativeCacheRoot(PathBuf),
205 #[error("could not determine a user cache directory; set FALSEGREEN_AGENT_CACHE_DIR")]
206 MissingCacheDirectory,
207 #[error("could not inspect managed cache space at {path}: {source}")]
208 CacheInspection {
209 path: PathBuf,
210 #[source]
211 source: std::io::Error,
212 },
213}
214
215fn managed_capabilities(mut capabilities: ModelCapabilities) -> ModelCapabilities {
216 capabilities.qualification = None;
220 capabilities
221}
222
223fn managed_runtime_provenance(
224 runtime: &RuntimeInstall,
225 model: &ModelCapabilities,
226 launch_arguments: &[String],
227 hardware: &HardwareProfile,
228) -> RuntimeProvenance {
229 RuntimeProvenance {
230 runtime: "llama.cpp llama-server".to_owned(),
231 version: LLAMA_CPP_VERSION.to_owned(),
232 commit: LLAMA_CPP_COMMIT.to_owned(),
233 distribution: format!("official GitHub release {}", runtime.release),
234 artifact: runtime.artifact_file_name.clone(),
235 artifact_sha256: Some(runtime.artifact_sha256.clone()),
236 platform: runtime.platform.to_string(),
237 backend: runtime.backend.cache_key().to_owned(),
238 accelerator: hardware.gpus.first().map(|gpu| {
239 format!(
240 "{} device={} architecture={} subsystem={}:{}",
241 gpu.vendor,
242 gpu.device_id.as_deref().unwrap_or("unknown"),
243 gpu.architecture.as_deref().unwrap_or("unknown"),
244 gpu.subsystem_vendor_id.as_deref().unwrap_or("unknown"),
245 gpu.subsystem_device_id.as_deref().unwrap_or("unknown")
246 )
247 }),
248 driver: hardware.graphics_driver.clone(),
249 launch_arguments: launch_arguments.to_vec(),
250 context_tokens: model.context_window_tokens,
251 chat_template: model.chat_template.clone(),
252 mtp_enabled: Some(false),
253 qualified_stack: false,
254 qualification_note: "NeoHorse V1's frozen qualification used the Ollama-bundled ROCm 7.2 backend on an AMD Radeon RX 7900 XTX (gfx1100); this managed distribution is separately pinned but not that qualified stack".to_owned(),
255 }
256}
257
258fn server_config(
259 runtime: &RuntimeInstall,
260 model: &ModelInstall,
261 hardware: &HardwareProfile,
262 startup_timeout: Duration,
263 health_timeout: Duration,
264) -> ServerLaunchConfig {
265 ServerLaunchConfig {
266 executable: runtime.server_executable.clone(),
267 model: model.path.clone(),
268 model_identifier: model.capabilities.identifier.clone(),
269 backend: runtime.backend,
270 context_tokens: model.capabilities.context_window_tokens.unwrap_or(8_192),
271 logical_cpus: hardware.logical_cpus,
272 startup_timeout,
273 health_timeout,
274 }
275}
276
277fn default_cache_root() -> Result<PathBuf, ManagedRuntimeError> {
278 if let Some(path) = env::var_os("FALSEGREEN_AGENT_CACHE_DIR") {
279 let path = PathBuf::from(path);
280 return absolute_cache_path(path);
281 }
282 if cfg!(target_os = "windows") {
283 if let Some(path) = env::var_os("LOCALAPPDATA") {
284 return Ok(PathBuf::from(path).join("falsegreen-agent"));
285 }
286 } else if let Some(path) = env::var_os("XDG_CACHE_HOME") {
287 return Ok(PathBuf::from(path).join("falsegreen-agent"));
288 }
289 let home = env::var_os("HOME")
290 .map(PathBuf::from)
291 .ok_or(ManagedRuntimeError::MissingCacheDirectory)?;
292 if cfg!(target_os = "macos") {
293 Ok(home.join("Library/Caches/falsegreen-agent"))
294 } else {
295 Ok(home.join(".cache/falsegreen-agent"))
296 }
297}
298
299fn absolute_cache_path(path: PathBuf) -> Result<PathBuf, ManagedRuntimeError> {
300 if path.is_absolute() {
301 Ok(path)
302 } else {
303 Err(ManagedRuntimeError::RelativeCacheRoot(path))
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use std::path::PathBuf;
310 use std::time::Duration;
311
312 use super::{
313 ManagedRuntimeConfig, managed_capabilities, managed_runtime_provenance, server_config,
314 };
315 use crate::hardware::{
316 Architecture, HardwareProfile, OperatingSystem, RuntimeBackend, RuntimePlatform,
317 };
318 use crate::inference::{
319 InferenceProvider, ModelCapabilities, ModelQualification, OpenAiCompatibleProvider,
320 };
321 use crate::model::ModelInstall;
322 use crate::runtime::{LLAMA_CPP_COMMIT, LLAMA_CPP_RELEASE, RuntimeInstall};
323
324 #[test]
325 fn server_seam_uses_detected_threads_and_pinned_inputs() {
326 let runtime = RuntimeInstall {
327 root: PathBuf::from("/cache/runtime"),
328 server_executable: PathBuf::from("/cache/runtime/llama-server"),
329 artifact_file_name: "llama-b10630-bin-ubuntu-rocm-7.14-x64.tar.gz".to_owned(),
330 artifact_sha256: "runtime-digest".to_owned(),
331 platform: RuntimePlatform {
332 os: OperatingSystem::Linux,
333 architecture: Architecture::X86_64,
334 },
335 backend: RuntimeBackend::Rocm,
336 release: LLAMA_CPP_RELEASE.to_owned(),
337 commit: LLAMA_CPP_COMMIT.to_owned(),
338 reused: false,
339 };
340 let model = ModelInstall {
341 path: PathBuf::from("/cache/model.gguf"),
342 capabilities: ModelCapabilities {
343 identifier: "neohorse".to_owned(),
344 repository: None,
345 artifact: None,
346 artifact_sha256: None,
347 quantization: None,
348 chat_template: None,
349 context_window_tokens: Some(8_192),
350 native_tools: true,
351 qualification: None,
352 },
353 reused: false,
354 };
355 let hardware = HardwareProfile {
356 platform: runtime.platform,
357 kernel_release: Some("test-kernel".to_owned()),
358 logical_cpus: 24,
359 total_memory_bytes: None,
360 gpus: Vec::new(),
361 rocm_available: true,
362 vulkan_available: true,
363 graphics_driver: Some("amdgpu on kernel test-kernel".to_owned()),
364 diagnostics: Vec::new(),
365 };
366 let config = server_config(
367 &runtime,
368 &model,
369 &hardware,
370 Duration::from_secs(10),
371 Duration::from_secs(1),
372 );
373 assert_eq!(config.logical_cpus, 24);
374 assert_eq!(config.backend, RuntimeBackend::Rocm);
375 assert_eq!(config.context_tokens, 8_192);
376 }
377
378 #[test]
379 fn explicit_cache_configuration_is_deterministic() {
380 let config = ManagedRuntimeConfig::new("/var/cache/falsegreen-test");
381 assert_eq!(
382 config.cache_root,
383 PathBuf::from("/var/cache/falsegreen-test")
384 );
385 assert!(config.preferred_backend.is_none());
386 }
387
388 #[test]
389 fn every_managed_backend_fails_closed_on_frozen_stack_qualification() {
390 let qualified = ModelCapabilities {
391 identifier: "neohorse".to_owned(),
392 repository: None,
393 artifact: None,
394 artifact_sha256: None,
395 quantization: None,
396 chat_template: None,
397 context_window_tokens: Some(8_192),
398 native_tools: true,
399 qualification: Some(ModelQualification {
400 profile_name: "neohorse-v1".to_owned(),
401 revision: "revision".to_owned(),
402 expected_artifact: "model.gguf".to_owned(),
403 runtime: "Ollama-bundled ROCm HIP backend".to_owned(),
404 runtime_version: "ROCm 7.2 / llama-server 0.3.0-dev".to_owned(),
405 runtime_commit: "d222767c7".to_owned(),
406 accelerator: "AMD Radeon RX 7900 XTX".to_owned(),
407 architecture: "gfx1100".to_owned(),
408 mtp_enabled: false,
409 artifact_validated: true,
410 }),
411 };
412 for backend in [
413 RuntimeBackend::Cpu,
414 RuntimeBackend::Metal,
415 RuntimeBackend::Vulkan,
416 RuntimeBackend::Rocm,
417 ] {
418 let runtime = RuntimeInstall {
419 root: PathBuf::from("/cache/runtime"),
420 server_executable: PathBuf::from("/cache/runtime/llama-server"),
421 artifact_file_name: format!("managed-{backend:?}"),
422 artifact_sha256: "runtime-digest".to_owned(),
423 platform: RuntimePlatform {
424 os: if backend == RuntimeBackend::Metal {
425 OperatingSystem::Macos
426 } else {
427 OperatingSystem::Linux
428 },
429 architecture: Architecture::X86_64,
430 },
431 backend,
432 release: LLAMA_CPP_RELEASE.to_owned(),
433 commit: LLAMA_CPP_COMMIT.to_owned(),
434 reused: false,
435 };
436 let capabilities = managed_capabilities(qualified.clone());
437 let hardware = HardwareProfile {
438 platform: runtime.platform,
439 kernel_release: Some("test-kernel".to_owned()),
440 logical_cpus: 8,
441 total_memory_bytes: None,
442 gpus: Vec::new(),
443 rocm_available: backend == RuntimeBackend::Rocm,
444 vulkan_available: backend == RuntimeBackend::Vulkan,
445 graphics_driver: None,
446 diagnostics: Vec::new(),
447 };
448 let provenance = managed_runtime_provenance(&runtime, &capabilities, &[], &hardware);
449 assert!(capabilities.qualification.is_none(), "backend {backend:?}");
450 assert!(!provenance.qualified_stack, "backend {backend:?}");
451 assert!(provenance.qualification_note.contains("ROCm 7.2"));
452 assert_eq!(provenance.backend, backend.cache_key());
453 let provider = OpenAiCompatibleProvider::new(
454 "http://127.0.0.1:1",
455 "neohorse",
456 None,
457 Duration::from_secs(1),
458 )
459 .with_model_capabilities(capabilities)
460 .with_runtime_provenance(provenance.clone());
461 let reported = provider.capabilities();
462 assert!(reported.model.qualification.is_none());
463 assert_eq!(reported.runtime_provenance, Some(provenance));
464 }
465 }
466}