summaryrefslogtreecommitdiff
path: root/lib/hyperstack/model_switcher.rb
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-25 20:05:15 +0300
committerPaul Buetow <paul@buetow.org>2026-05-25 20:05:15 +0300
commit4fd86fbc75670878308fd6a56b7778334b52ccd8 (patch)
tree1f2a611c41248844d9f7e32f4bb78760001cc8cb /lib/hyperstack/model_switcher.rb
parentaa298b3d85a7f4ee7f1e1b4a3192421b95f67828 (diff)
refactor(manager): extract focused collaborators from Manager God Class
Extract VM lifecycle, SSH execution, WireGuard setup, model switching, end-to-end inference tests, and provisioning orchestration into separate collaborator classes. Manager becomes a thin facade (~80 lines). Also fixes CLI edge-cases: status/model-list when no VMs are active, and threads --vllm/--ollama flags through the provisioning pipeline.
Diffstat (limited to 'lib/hyperstack/model_switcher.rb')
-rw-r--r--lib/hyperstack/model_switcher.rb72
1 files changed, 72 insertions, 0 deletions
diff --git a/lib/hyperstack/model_switcher.rb b/lib/hyperstack/model_switcher.rb
new file mode 100644
index 0000000..07f6cdb
--- /dev/null
+++ b/lib/hyperstack/model_switcher.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module HyperstackVM
+ # Hot-switches the running vLLM model on a provisioned VM.
+ class ModelSwitcher
+ def initialize(config:, provisioner:, state_store:, out:)
+ @config = config
+ @provisioner = provisioner
+ @state_store = state_store
+ @out = out
+ end
+
+ def switch(preset_name:, dry_run: false)
+ preset = @config.vllm_preset(preset_name)
+ state = @state_store.load
+
+ old_container = state&.dig('vllm_container_name') || @config.vllm_container_name
+ new_container = preset['container_name']
+ current_model = state&.dig('vllm_model')
+
+ if dry_run
+ print_dry_run(preset_name, preset, current_model, old_container, new_container)
+ return
+ end
+
+ raise Error, "No tracked VM. Run 'create' first." unless state&.dig('vm_id')
+ host = state['public_ip']
+ raise Error, 'No public IP in state file.' if host.nil? || host.empty?
+
+ @provisioner.decommission_litellm(host)
+ @provisioner.stop_vllm_container(host, old_container) if old_container != new_container
+
+ info "Starting vLLM with preset '#{preset_name}' (#{preset['model']})..."
+ @provisioner.install_vllm(host, preset_config: preset, pull_image: false)
+
+ state['vllm_model'] = preset['model']
+ state['vllm_container_name'] = new_container
+ state['vllm_preset'] = preset_name
+ state['vllm_setup_at'] = Time.now.utc.iso8601
+ state['services'] ||= {}
+ state['services']['vllm_enabled'] = true
+ state['services']['ollama_enabled'] = state_ollama_enabled?(state)
+ @state_store.save(state)
+
+ info "Model switched to '#{preset_name}' (#{preset['model']})."
+ info "Run 'ruby hyperstack.rb test' to verify."
+ end
+
+ private
+
+ def print_dry_run(preset_name, preset, current_model, old_container, new_container)
+ info "DRY RUN: model switch to preset '#{preset_name}'"
+ info " #{current_model || 'none'} → #{preset['model']}"
+ info " container: #{old_container} → #{new_container}"
+ trust_note = preset['trust_remote_code'] ? ', trust_remote_code: true' : ''
+ parser_note = preset['tool_call_parser'].to_s.empty? ? 'none' : preset['tool_call_parser']
+ extra_note = preset['extra_vllm_args']&.any? ? ", extra_args: #{preset['extra_vllm_args'].join(' ')}" : ''
+ info " max_model_len: #{preset['max_model_len']}, tool_call_parser: #{parser_note}#{trust_note}#{extra_note}"
+ end
+
+ def state_ollama_enabled?(state)
+ recorded = state&.dig('services', 'ollama_enabled')
+ return recorded unless recorded.nil?
+ return true if state&.key?('ollama_installed_at') || state&.key?('ollama_setup_at')
+ @config.ollama_install_enabled?
+ end
+
+ def info(msg)
+ @out.puts(msg)
+ end
+ end
+end