1use super::catalog::{PodCatalog, PodProfile};
2use super::state::{PodGpu, PodHealth, PodState, PodsState, RunningModel};
3use super::store::PodsStore;
4use super::transport::{PodTransport, SshTransport};
5use anyhow::{Context, Result, anyhow};
6use parking_lot::RwLock;
7use std::collections::BTreeMap;
8use std::fmt::Write;
9use std::sync::Arc;
10
11const DEFAULT_START_PORT: u16 = 8001;
12const DEFAULT_LOG_DIR: &str = ".vllm_logs";
13
14#[derive(Debug, Clone)]
16pub struct PodStartRequest {
17 pub pod_name: Option<String>,
19 pub ssh: Option<String>,
21 pub gpus: Vec<PodGpu>,
23 pub models_path: Option<String>,
25 pub name: String,
27 pub model: String,
29 pub profile: Option<String>,
31 pub requested_gpu_count: Option<usize>,
33 pub memory: Option<f32>,
35 pub context: Option<String>,
37}
38
39#[derive(Debug, Clone)]
41pub struct PodStartResult {
42 pub pod: PodState,
44 pub entry: RunningModel,
46 pub profile: PodProfile,
48 pub launch_command: String,
50}
51
52#[derive(Debug, Clone)]
54pub struct PodListEntry {
55 pub name: String,
57 pub model: String,
59 pub port: u16,
61 pub pid: u32,
63 pub gpu_ids: Vec<u32>,
65 pub status: PodHealth,
67}
68
69#[derive(Debug, Clone)]
71pub struct PodStatusDetail {
72 pub name: String,
74 pub model: String,
76 pub gpu_count: usize,
78}
79
80#[derive(Debug, Clone)]
82pub struct KnownModelsReport {
83 pub compatible: Vec<PodStatusDetail>,
85 pub incompatible: Vec<PodStatusDetail>,
87}
88
89#[derive(Debug, Clone)]
91pub struct PodStatusReport {
92 pub pod_name: String,
94 pub entries: Vec<PodListEntry>,
96}
97
98#[derive(Clone)]
100pub struct PodManager {
101 store: PodsStore,
102 transport: Arc<dyn PodTransport>,
103 cached_state: Arc<RwLock<Option<PodsState>>>,
104 cached_catalog: Arc<RwLock<Option<PodCatalog>>>,
105}
106
107impl PodManager {
108 pub fn new() -> Result<Self> {
110 Ok(Self::with_transport(PodsStore::default_store()?, Arc::new(SshTransport)))
111 }
112
113 pub fn with_transport(store: PodsStore, transport: Arc<dyn PodTransport>) -> Self {
115 Self {
116 store,
117 transport,
118 cached_state: Arc::new(RwLock::new(None)),
119 cached_catalog: Arc::new(RwLock::new(None)),
120 }
121 }
122
123 pub async fn load_state(&self) -> Result<PodsState> {
125 if let Some(state) = self.cached_state.read().clone() {
126 return Ok(state);
127 }
128
129 let state = self.store.load_state().await?;
130 *self.cached_state.write() = Some(state.clone());
131 Ok(state)
132 }
133
134 pub async fn load_catalog(&self) -> Result<PodCatalog> {
136 if let Some(catalog) = self.cached_catalog.read().clone() {
137 return Ok(catalog);
138 }
139
140 let catalog = self.store.load_catalog().await?;
141 *self.cached_catalog.write() = Some(catalog.clone());
142 Ok(catalog)
143 }
144
145 pub async fn start_model(&self, request: PodStartRequest) -> Result<PodStartResult> {
147 let mut state = self.load_state().await?;
148 let catalog = self.load_catalog().await?;
149 let pod = self.resolve_active_pod(&mut state, &request).await?;
150 let profile = self.resolve_profile(&catalog, &pod, &request)?;
151 let gpu_count = request.requested_gpu_count.unwrap_or(profile.gpu_count).max(1);
152
153 if gpu_count > pod.gpu_count() {
154 return Err(anyhow!("requested {} GPUs but pod '{}' only has {}", gpu_count, pod.name, pod.gpu_count()));
155 }
156
157 let selected_gpu_ids = select_gpus(&pod, gpu_count);
158 let port = next_port(&pod);
159 let sanitized_name = sanitize_component(&request.name);
160 let run_path = format!("/tmp/model_run_{sanitized_name}.sh");
161 let wrapper_path = format!("/tmp/model_wrapper_{sanitized_name}.sh");
162 let log_path = format!("~/{DEFAULT_LOG_DIR}/{sanitized_name}.log");
163 let vllm_args = render_args(&profile.vllm_args, request.memory, request.context.as_deref())?;
164 let run_script = render_run_script(
165 &profile,
166 &request.model,
167 &request.name,
168 port,
169 &vllm_args,
170 &selected_gpu_ids,
171 pod.models_path.as_deref(),
172 );
173 let wrapper_script = render_wrapper_script(&run_path, &log_path);
174
175 self.transport.write_file(&pod.ssh, &run_path, &run_script).await?;
176 self.transport.write_file(&pod.ssh, &wrapper_path, &wrapper_script).await?;
177
178 let chmod = self
179 .transport
180 .exec_capture(&pod.ssh, &format!("chmod +x {run_path} {wrapper_path}"))
181 .await?;
182 if !chmod.success {
183 return Err(anyhow!("failed to chmod remote scripts: {}", chmod.stderr));
184 }
185
186 let launch_command = format!(
187 "mkdir -p ~/{DEFAULT_LOG_DIR} && find ~/{DEFAULT_LOG_DIR} -name '*.log.old' -mtime +90 -delete 2>/dev/null; setsid {wrapper_path} >/dev/null 2>&1 < /dev/null & echo $!"
188 );
189 let launch = self.transport.exec_capture(&pod.ssh, &launch_command).await?;
190 if !launch.success {
191 return Err(anyhow!("failed to launch remote model: {}", launch.stderr));
192 }
193
194 let pid = parse_pid(&launch.stdout)?;
195 let entry = RunningModel {
196 model: request.model.clone(),
197 port,
198 gpu_ids: selected_gpu_ids.clone(),
199 pid,
200 profile: profile.name.clone(),
201 };
202
203 let mut updated_pod = pod.clone();
204 updated_pod.models.insert(request.name.clone(), entry.clone());
205 state.active_pod = Some(updated_pod.clone());
206 self.persist_state(&state).await?;
207
208 Ok(PodStartResult { pod: updated_pod, entry, profile, launch_command })
209 }
210
211 pub async fn stop_model(&self, name: &str) -> Result<Option<RunningModel>> {
213 let mut state = self.load_state().await?;
214 let Some(pod) = state.active_pod.as_mut() else {
215 return Ok(None);
216 };
217
218 let Some(entry) = pod.models.remove(name) else {
219 return Ok(None);
220 };
221
222 let command = format!("pkill -TERM -P {} || true; kill {} || true", entry.pid, entry.pid);
223 let output = self.transport.exec_capture(&pod.ssh, &command).await?;
224 if !output.success {
225 return Err(anyhow!("failed to stop model '{}': {}", name, output.stderr));
226 }
227
228 self.persist_state(&state).await?;
229 Ok(Some(entry))
230 }
231
232 pub async fn stop_all_models(&self) -> Result<usize> {
234 let mut state = self.load_state().await?;
235 let Some(pod) = state.active_pod.as_mut() else {
236 return Ok(0);
237 };
238
239 let pids = pod.models.values().map(|entry| entry.pid.to_string()).collect::<Vec<_>>();
240
241 if pids.is_empty() {
242 return Ok(0);
243 }
244
245 let command =
246 format!("for PID in {}; do pkill -TERM -P \"$PID\" || true; kill \"$PID\" || true; done", pids.join(" "));
247 let output = self.transport.exec_capture(&pod.ssh, &command).await?;
248 if !output.success {
249 return Err(anyhow!("failed to stop models: {}", output.stderr));
250 }
251
252 let stopped = pod.models.len();
253 pod.models.clear();
254 self.persist_state(&state).await?;
255 Ok(stopped)
256 }
257
258 pub async fn list_models(&self) -> Result<PodStatusReport> {
260 let state = self.load_state().await?;
261 let Some(pod) = state.active_pod.as_ref() else {
262 return Err(anyhow!("no active pod configured"));
263 };
264
265 let mut entries = Vec::new();
266 for (name, model) in &pod.models {
267 let status = self.inspect_model(pod, name, model).await?;
268 entries.push(PodListEntry {
269 name: name.clone(),
270 model: model.model.clone(),
271 port: model.port,
272 pid: model.pid,
273 gpu_ids: model.gpu_ids.clone(),
274 status,
275 });
276 }
277
278 Ok(PodStatusReport { pod_name: pod.name.clone(), entries })
279 }
280
281 pub async fn stream_logs(&self, name: &str) -> Result<()> {
283 let state = self.load_state().await?;
284 let Some(pod) = state.active_pod.as_ref() else {
285 return Err(anyhow!("no active pod configured"));
286 };
287 let Some(entry) = pod.models.get(name) else {
288 return Err(anyhow!("unknown model '{name}'"));
289 };
290
291 let log_path = format!("~/{DEFAULT_LOG_DIR}/{}.log", sanitize_component(name));
292 let command = format!("tail -f {log_path}");
293 let _ = entry;
294 self.transport.exec_stream(&pod.ssh, &command).await
295 }
296
297 pub async fn known_models(&self) -> Result<KnownModelsReport> {
299 let state = self.load_state().await?;
300 let Some(pod) = state.active_pod.as_ref() else {
301 return Err(anyhow!("no active pod configured"));
302 };
303 let catalog = self.load_catalog().await?;
304 let (compatible, incompatible) = catalog.compatible_profiles(pod);
305
306 Ok(KnownModelsReport {
307 compatible: compatible
308 .into_iter()
309 .map(|profile| PodStatusDetail {
310 name: profile.name.clone(),
311 model: profile.model.clone(),
312 gpu_count: profile.gpu_count,
313 })
314 .collect(),
315 incompatible: incompatible
316 .into_iter()
317 .map(|profile| PodStatusDetail {
318 name: profile.name.clone(),
319 model: profile.model.clone(),
320 gpu_count: profile.gpu_count,
321 })
322 .collect(),
323 })
324 }
325
326 async fn persist_state(&self, state: &PodsState) -> Result<()> {
327 self.store.save_state(state).await?;
328 *self.cached_state.write() = Some(state.clone());
329 Ok(())
330 }
331
332 async fn resolve_active_pod(&self, state: &mut PodsState, request: &PodStartRequest) -> Result<PodState> {
333 let mut pod = state.active_pod.clone().unwrap_or_else(|| PodState {
334 name: request.pod_name.clone().unwrap_or_else(|| "active-pod".to_string()),
335 ssh: request.ssh.clone().unwrap_or_default(),
336 models_path: request.models_path.clone(),
337 gpus: Vec::new(),
338 models: BTreeMap::new(),
339 });
340
341 if let Some(name) = &request.pod_name {
342 pod.name = name.clone();
343 }
344 if let Some(ssh) = &request.ssh {
345 pod.ssh = ssh.clone();
346 }
347 if let Some(models_path) = &request.models_path {
348 pod.models_path = Some(models_path.clone());
349 }
350 if !request.gpus.is_empty() {
351 pod.gpus = request.gpus.clone();
352 }
353
354 if pod.ssh.is_empty() {
355 return Err(anyhow!("pod ssh command is required; pass --ssh or reuse the active pod"));
356 }
357 if pod.gpus.is_empty() {
358 return Err(anyhow!("pod gpu inventory is required; pass --gpu entries or reuse the active pod"));
359 }
360
361 state.active_pod = Some(pod.clone());
362 self.persist_state(state).await?;
363 Ok(pod)
364 }
365
366 fn resolve_profile(&self, catalog: &PodCatalog, pod: &PodState, request: &PodStartRequest) -> Result<PodProfile> {
367 if let Some(profile_name) = request.profile.as_deref() {
368 let profile = catalog
369 .profiles
370 .iter()
371 .find(|profile| profile.name == profile_name)
372 .cloned()
373 .ok_or_else(|| anyhow!("unknown pod profile '{profile_name}'"))?;
374 return Ok(profile);
375 }
376
377 let mut candidates = catalog.profiles_for_model(&request.model);
378 candidates.retain(|profile| profile.matches_pod(pod));
379
380 if let Some(requested_gpu_count) = request.requested_gpu_count {
381 if let Some(profile) = candidates
382 .iter()
383 .copied()
384 .find(|profile| profile.matches_gpu_count(requested_gpu_count))
385 {
386 return Ok(profile.clone());
387 }
388
389 if !candidates.is_empty() {
390 let valid_counts = candidates
391 .iter()
392 .map(|profile| profile.gpu_count.to_string())
393 .collect::<Vec<_>>();
394 return Err(anyhow!(
395 "no profile for '{}' with {} GPUs; valid counts: {}",
396 request.model,
397 requested_gpu_count,
398 valid_counts.join(", ")
399 ));
400 }
401 }
402
403 candidates
404 .into_iter()
405 .max_by_key(|profile| profile.gpu_count)
406 .cloned()
407 .or_else(|| {
408 if request.model.is_empty() {
409 None
410 } else {
411 Some(PodProfile {
412 name: request.model.clone(),
413 model: request.model.clone(),
414 gpu_count: request.requested_gpu_count.unwrap_or(1),
415 gpu_types: Vec::new(),
416 command_template: default_command_template(),
417 vllm_args: vec![
418 "--trust-remote-code".to_string(),
419 "--dtype".to_string(),
420 "auto".to_string(),
421 ],
422 env: BTreeMap::new(),
423 })
424 }
425 })
426 .ok_or_else(|| anyhow!("no profile found for model '{}'", request.model))
427 }
428
429 async fn inspect_model(&self, pod: &PodState, name: &str, entry: &RunningModel) -> Result<PodHealth> {
430 let process = self.transport.exec_capture(&pod.ssh, &format!("ps -p {}", entry.pid)).await?;
431 let health = self
432 .transport
433 .exec_capture(&pod.ssh, &format!("curl -s -f http://localhost:{}/health", entry.port))
434 .await?;
435 let log_tail = self
436 .transport
437 .exec_capture(&pod.ssh, &format!("tail -n 20 ~/{DEFAULT_LOG_DIR}/{}.log", sanitize_component(name)))
438 .await?;
439
440 Ok(classify_status(process.success, health.success, &log_tail.stdout))
441 }
442}
443
444impl Default for PodManager {
445 fn default() -> Self {
446 Self::new().expect("pod manager should initialize with a home directory")
447 }
448}
449
450fn render_run_script(
451 profile: &PodProfile,
452 model: &str,
453 name: &str,
454 port: u16,
455 vllm_args: &[String],
456 gpu_ids: &[u32],
457 models_path: Option<&str>,
458) -> String {
459 let mut script = String::new();
460 script.push_str("#!/usr/bin/env bash\n");
461 script.push_str("set -euo pipefail\n");
462 script.push_str("export HF_HUB_ENABLE_HF_TRANSFER=1\n");
463 script.push_str("export VLLM_NO_USAGE_STATS=1\n");
464 script.push_str("export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True\n");
465 script.push_str("export FORCE_COLOR=1\n");
466 script.push_str("export TERM=xterm-256color\n");
467
468 for (key, value) in &profile.env {
469 let _ = writeln!(script, "export {key}={}", shell_quote(value));
470 }
471
472 if gpu_ids.len() == 1 {
473 let _ = writeln!(script, "export CUDA_VISIBLE_DEVICES={}", gpu_ids[0]);
474 }
475
476 if let Some(models_path) = models_path {
477 let _ = writeln!(script, "export MODELS_PATH={}", shell_quote(models_path));
478 }
479
480 let command = render_template(&profile.command_template, model, name, port, &join_args(vllm_args));
481 let _ = writeln!(script, "exec {command}");
482 script
483}
484
485fn render_wrapper_script(run_path: &str, log_path: &str) -> String {
486 format!(
487 r#"#!/usr/bin/env bash
488set -euo pipefail
489mkdir -p ~/.vllm_logs
490LOG_PATH={log_path}
491# Rotate log if it exceeds 100MB to prevent unbounded disk usage
492MAX_LOG_BYTES=104857600
493if [ -f "$LOG_PATH" ] && [ "$(stat -f%z "$LOG_PATH" 2>/dev/null || echo 0)" -ge $MAX_LOG_BYTES ] 2>/dev/null; then
494 mv "$LOG_PATH" "$LOG_PATH.old" 2>/dev/null || true
495fi
496script -q -f -c {run_path} "$LOG_PATH"
497"#
498 )
499}
500
501fn render_template(template: &str, model: &str, name: &str, port: u16, vllm_args: &str) -> String {
502 template
503 .replace("{{MODEL_ID}}", model)
504 .replace("{{NAME}}", name)
505 .replace("{{PORT}}", &port.to_string())
506 .replace("{{VLLM_ARGS}}", vllm_args)
507}
508
509fn join_args(args: &[String]) -> String {
510 args.iter().map(|value| shell_quote(value)).collect::<Vec<_>>().join(" ")
511}
512
513fn shell_quote(value: &str) -> String {
514 if value.is_empty() {
515 return "''".to_string();
516 }
517
518 if value
519 .chars()
520 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | ':' | ',' | '=' | '@'))
521 {
522 return value.to_string();
523 }
524
525 format!("'{}'", value.replace('\'', r#"'"'"'"#))
526}
527
528fn select_gpus(pod: &PodState, count: usize) -> Vec<u32> {
529 if count >= pod.gpus.len() {
530 return pod.gpus.iter().map(|gpu| gpu.id).collect();
531 }
532
533 let mut usage: BTreeMap<u32, usize> = pod.gpus.iter().map(|gpu| (gpu.id, 0)).collect();
534 for model in pod.models.values() {
535 for gpu_id in &model.gpu_ids {
536 if let Some(slot) = usage.get_mut(gpu_id) {
537 *slot += 1;
538 }
539 }
540 }
541
542 let mut gpus = pod.gpus.clone();
543 gpus.sort_by_key(|gpu| (usage.get(&gpu.id).copied().unwrap_or(0), gpu.id));
544 gpus.into_iter().take(count).map(|gpu| gpu.id).collect()
545}
546
547fn next_port(pod: &PodState) -> u16 {
548 let mut port = DEFAULT_START_PORT;
549 let occupied: std::collections::HashSet<u16> = pod.models.values().map(|model| model.port).collect();
550 while occupied.contains(&port) {
551 port = port.saturating_add(1);
552 }
553 port
554}
555
556fn parse_pid(output: &str) -> Result<u32> {
557 let pid = output
558 .trim()
559 .lines()
560 .find_map(|line| line.trim().parse::<u32>().ok())
561 .ok_or_else(|| anyhow!("launch command did not return a pid"))?;
562 Ok(pid)
563}
564
565fn sanitize_component(name: &str) -> String {
566 let mut out = String::with_capacity(name.len());
567 for ch in name.chars() {
568 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') {
569 out.push(ch);
570 } else {
571 out.push('_');
572 }
573 }
574 if out.is_empty() { "model".to_string() } else { out }
575}
576
577fn classify_status(process_alive: bool, health_ok: bool, log_tail: &str) -> PodHealth {
578 if process_alive && health_ok {
579 return PodHealth::Running;
580 }
581
582 let lower = log_tail.to_lowercase();
583 let failed = lower.contains("model runner exiting with code")
584 || lower.contains("script exited with code")
585 || lower.contains("torch.outofmemoryerror")
586 || lower.contains("cuda out of memory")
587 || lower.contains("runtimeerror: engine core initialization failed");
588
589 if failed {
590 PodHealth::Crashed
591 } else if process_alive {
592 PodHealth::Starting
593 } else {
594 PodHealth::Dead
595 }
596}
597
598fn render_args(args: &[String], memory: Option<f32>, context: Option<&str>) -> Result<Vec<String>> {
599 let mut rendered = Vec::new();
600 let mut index = 0;
601 while index < args.len() {
602 let arg = &args[index];
603 if arg == "--gpu-memory-utilization" {
604 index += 2;
605 continue;
606 }
607 if arg == "--max-model-len" {
608 index += 2;
609 continue;
610 }
611 rendered.push(arg.clone());
612 index += 1;
613 }
614
615 if let Some(memory) = memory {
616 let utilization = (memory / 100.0).clamp(0.0, 1.0);
617 rendered.push("--gpu-memory-utilization".to_string());
618 rendered.push(format!("{utilization:.2}"));
619 }
620
621 if let Some(context) = context {
622 rendered.push("--max-model-len".to_string());
623 rendered.push(parse_context_size(context)?.to_string());
624 }
625
626 Ok(rendered)
627}
628
629fn parse_context_size(value: &str) -> Result<u32> {
630 let trimmed = value.trim().to_lowercase();
631 if let Some(stripped) = trimmed.strip_suffix('k') {
632 return Ok(stripped
633 .parse::<u32>()
634 .with_context(|| format!("invalid context size '{value}'"))?
635 .saturating_mul(1024));
636 }
637
638 trimmed
639 .parse::<u32>()
640 .with_context(|| format!("invalid context size '{value}'"))
641}
642
643fn default_command_template() -> String {
644 "vllm serve {{MODEL_ID}} --served-model-name {{NAME}} --port {{PORT}} {{VLLM_ARGS}}".to_string()
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use crate::pods::catalog::PodProfile;
651 use crate::pods::state::PodGpu;
652 use crate::pods::transport::CommandOutput;
653 use anyhow::Result;
654 use async_trait::async_trait;
655 use parking_lot::Mutex;
656 use std::collections::VecDeque;
657
658 #[derive(Clone, Default)]
659 struct MockTransport {
660 commands: Arc<Mutex<Vec<String>>>,
661 writes: Arc<Mutex<Vec<(String, String)>>>,
662 responses: Arc<Mutex<VecDeque<CommandOutput>>>,
663 }
664
665 #[async_trait]
666 impl PodTransport for MockTransport {
667 async fn exec_capture(&self, _ssh_target: &str, command: &str) -> Result<CommandOutput> {
668 self.commands.lock().push(command.to_string());
669 Ok(self.responses.lock().pop_front().unwrap_or_else(|| CommandOutput {
670 success: true,
671 stdout: "12345\n".to_string(),
672 stderr: String::new(),
673 }))
674 }
675
676 async fn write_file(&self, _ssh_target: &str, remote_path: &str, contents: &str) -> Result<()> {
677 self.writes.lock().push((remote_path.to_string(), contents.to_string()));
678 Ok(())
679 }
680
681 async fn exec_stream(&self, _ssh_target: &str, command: &str) -> Result<()> {
682 self.commands.lock().push(command.to_string());
683 Ok(())
684 }
685 }
686
687 #[test]
688 fn context_parser_handles_shorthand() {
689 assert_eq!(parse_context_size("32k").expect("parse"), 32_768);
690 assert_eq!(parse_context_size("131072").expect("parse"), 131_072);
691 }
692
693 #[test]
694 fn classify_status_detects_failure_patterns() {
695 assert_eq!(classify_status(true, false, "RuntimeError: Engine core initialization failed"), PodHealth::Crashed);
696 assert_eq!(classify_status(false, false, ""), PodHealth::Dead);
697 assert_eq!(classify_status(true, false, ""), PodHealth::Starting);
698 }
699
700 #[test]
701 fn select_gpus_prefers_less_loaded_devices() {
702 let pod = PodState {
703 name: "pod".to_string(),
704 ssh: "ssh root@example.com".to_string(),
705 models_path: None,
706 gpus: vec![
707 PodGpu { id: 0, name: "A100".to_string() },
708 PodGpu { id: 1, name: "A100".to_string() },
709 ],
710 models: BTreeMap::from([(
711 "existing".to_string(),
712 RunningModel {
713 model: "model".to_string(),
714 port: 8001,
715 gpu_ids: vec![0],
716 pid: 1,
717 profile: "profile".to_string(),
718 },
719 )]),
720 };
721
722 assert_eq!(select_gpus(&pod, 1), vec![1]);
723 }
724
725 #[tokio::test]
726 async fn render_and_launch_flow_updates_state() {
727 let store = PodsStore::new(std::env::temp_dir().join(format!("vtcode-pods-start-test-{}", std::process::id())));
728 let transport = Arc::new(MockTransport::default());
729 let manager = PodManager::with_transport(store, transport.clone());
730 let state = PodsState {
731 version: env!("CARGO_PKG_VERSION").to_string(),
732 active_pod: Some(PodState {
733 name: "gpu-box".to_string(),
734 ssh: "ssh root@example.com".to_string(),
735 models_path: Some("/models".to_string()),
736 gpus: vec![PodGpu { id: 0, name: "A100".to_string() }],
737 models: BTreeMap::new(),
738 }),
739 };
740 manager.store.save_state(&state).await.expect("save");
741 manager
742 .store
743 .save_catalog(&PodCatalog {
744 version: "1".to_string(),
745 profiles: vec![PodProfile {
746 name: "test".to_string(),
747 model: "test/model".to_string(),
748 gpu_count: 1,
749 gpu_types: vec!["A100".to_string()],
750 command_template: default_command_template(),
751 vllm_args: vec!["--max-model-len".to_string(), "4096".to_string()],
752 env: BTreeMap::new(),
753 }],
754 })
755 .await
756 .expect("save catalog");
757
758 let result = manager
759 .start_model(PodStartRequest {
760 pod_name: None,
761 ssh: None,
762 gpus: Vec::new(),
763 models_path: None,
764 name: "local".to_string(),
765 model: "test/model".to_string(),
766 profile: None,
767 requested_gpu_count: None,
768 memory: Some(75.0),
769 context: Some("4k".to_string()),
770 })
771 .await
772 .expect("start");
773
774 assert_eq!(result.entry.pid, 12345);
775 assert!(
776 transport
777 .writes
778 .lock()
779 .iter()
780 .any(|(path, contents)| path.contains("model_run_local.sh") && contents.contains("vllm serve"))
781 );
782 }
783
784 #[test]
785 fn memory_override_rewrites_existing_argument() {
786 let args = render_args(
787 &[
788 "--dtype".to_string(),
789 "auto".to_string(),
790 "--gpu-memory-utilization".to_string(),
791 "0.80".to_string(),
792 ],
793 Some(90.0),
794 None,
795 )
796 .expect("render args");
797
798 assert!(args.windows(2).any(|pair| pair == ["--gpu-memory-utilization", "0.90"]));
799 }
800}