Part A: qwen3-no-think.jinja.j2 Ansible template
- New template: templates/qwen3-no-think.jinja.j2
Standard Qwen3 chat template with enable_thinking unconditionally false
(hardcoded empty <think></think> prefix at add_generation_prompt step).
Deployed to /opt/models/templates/qwen3-no-think.jinja on astro-orbiter.
- tasks/models.yml: deploy templates dir + qwen3-no-think.jinja via
ansible.builtin.template task (tags: models, chat_templates).
Part B: INI preset template — two Qwen3-8B sections
- templates/llama-server-router-preset.ini.j2:
[Qwen3-8B-Q4_K_M] — thinking variant, same GGUF, baked-in template.
[Qwen3-8B-Q4_K_M-no_think] — no-think variant, same GGUF,
chat-template-file = /opt/models/templates/qwen3-no-think.jinja.
Both sections: n-gpu-layers=99, ctx-size=32768, flash-attn=true,
q4_0 KV cache, sleep-idle-seconds=60.
Part C: defaults/main.yml — llama-swap models + matrix
- llm_swapmode_models: added Qwen3-8B-Q4_K_M (port 8106, GPU) and
Qwen3-8B-Q4_K_M-no_think (port 8107, GPU, chat_template_file set).
- llm_swapmode_matrix_rows: row5 (Qwen3-8B thinking + embed),
row6 (Qwen3-8B no_think + embed). Neither co-resident with Qwen3.8-27B.
- templates/llama-swap-config.yaml.j2: added chat_template_file support
(--chat-template-file flag conditional on model.chat_template_file).
- tasks/swapmode.yml: GATE 2 assert updated 5 -> 7 models.
Part D: war-machine config.yaml (not tracked in git)
- custom_providers.astro-orbiter.models: added Qwen3-8B-Q4_K_M-no_think
(context_length: 32768).
- Reassigned 5 latency-sensitive aux tasks from Meta-Llama-3.1-8B to
Qwen3-8B-Q4_K_M-no_think: skills_hub, approval, mcp, title_generation,
profile_describer. Rationale: GPU-resident, lower latency, json_schema OK.
- web_extract and compression remain on Phi-3.5-mini (long scrapes).
VRAM: both Qwen3-8B variants co-reside with nomic-embed only (~10.4GB +
84MB). Cannot co-reside with Qwen3.8-27B (17.8GB); LRU eviction applies.
306 lines
12 KiB
YAML
306 lines
12 KiB
YAML
---
|
|
# ------------------------------------------------------------------------------
|
|
# FILE: roles/llm-inference-multimodel/tasks/swapmode.yml
|
|
# DESCRIPTION: Phase S — llama-swap mode hot-swap proxy (port 8001).
|
|
#
|
|
# This phase is ADDITIVE and IDEMPOTENT. The existing production
|
|
# unit (llama-server-qwen, port 8002) is never touched here.
|
|
#
|
|
# All tasks are gated on llm_swapmode_enabled | default(false).
|
|
# With the default (false) this entire file is a no-op.
|
|
#
|
|
# When llm_swapmode_enabled: true (set by host_vars or extra-vars),
|
|
# this phase:
|
|
# swapmode_binary — download + install binary
|
|
# swapmode_config — template config.yaml
|
|
# swapmode_systemd — deploy llama-swap.service unit
|
|
# swapmode_firewall — open port 8001 to Hermes subnet
|
|
# swapmode_verify — start service, run 4 validation gates
|
|
#
|
|
# Tags map 1:1 to the sub-phases for independent execution:
|
|
# --tags swapmode_binary,swapmode_config,swapmode_systemd,swapmode_firewall,swapmode_verify
|
|
#
|
|
# IMPORTANT: swapmode_verify starts the service. Do not run
|
|
# swapmode_verify unless swapmode_binary and swapmode_systemd
|
|
# have already run.
|
|
#
|
|
# Added 2026-08-18 (t_c1e44190): llama-swap Phase 3 Ansible integration — Wong.
|
|
# Approved by War Machine Phase 1 validation (3 of 4 hard gates PASS).
|
|
# Phase 3 gated on all profiles migrated + production router decommissioned.
|
|
# ------------------------------------------------------------------------------
|
|
|
|
# =============================================================================
|
|
# TAG: swapmode_binary
|
|
# Download and install llama-swap binary from GitHub releases.
|
|
# Idempotent: checks for existing binary and verifies architecture.
|
|
# =============================================================================
|
|
|
|
- name: "[swapmode_binary] Detect host architecture (x86_64 / aarch64)"
|
|
ansible.builtin.command:
|
|
cmd: uname -m
|
|
register: llm_swapmode_arch
|
|
changed_when: false
|
|
become: false
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_binary]
|
|
|
|
- name: "[swapmode_binary] Ensure config directory exists"
|
|
ansible.builtin.file:
|
|
path: "{{ llm_swapmode_config_dir }}"
|
|
state: directory
|
|
owner: "{{ llm_swapmode_service_user }}"
|
|
group: "{{ llm_swapmode_service_user }}"
|
|
mode: "0755"
|
|
become: true
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_binary]
|
|
|
|
- name: "[swapmode_binary] Download llama-swap binary"
|
|
ansible.builtin.get_url:
|
|
url: "{{ llm_swapmode_binary_url }}"
|
|
dest: "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz"
|
|
checksum: "{{ llm_swapmode_checksum }}"
|
|
mode: "0644"
|
|
become: true
|
|
register: llm_swapmode_download
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_binary]
|
|
|
|
- name: "[swapmode_binary] Extract llama-swap binary"
|
|
ansible.builtin.unarchive:
|
|
src: "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz"
|
|
dest: /tmp
|
|
remote_src: true
|
|
creates: /tmp/llama-swap
|
|
become: true
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_binary]
|
|
|
|
- name: "[swapmode_binary] Install llama-swap to /usr/local/bin"
|
|
ansible.builtin.copy:
|
|
src: /tmp/llama-swap
|
|
dest: /usr/local/bin/llama-swap
|
|
owner: root
|
|
group: root
|
|
mode: "0755"
|
|
remote_src: true
|
|
become: true
|
|
register: llm_swapmode_binary_installed
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_binary]
|
|
|
|
- name: "[swapmode_binary] Verify llama-swap binary is executable"
|
|
ansible.builtin.command:
|
|
cmd: /usr/local/bin/llama-swap --version
|
|
register: llm_swapmode_version_check
|
|
changed_when: false
|
|
become: false
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_binary]
|
|
|
|
- name: "[swapmode_binary] Cleanup download artifacts"
|
|
ansible.builtin.file:
|
|
path: "{{ item }}"
|
|
state: absent
|
|
become: true
|
|
loop:
|
|
- "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz"
|
|
- /tmp/llama-swap
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_binary]
|
|
|
|
# =============================================================================
|
|
# TAG: swapmode_config
|
|
# Render config.yaml.j2 template and deploy to /etc/llama-swap/config.yaml
|
|
# =============================================================================
|
|
|
|
- name: "[swapmode_config] Deploy llama-swap config.yaml from template"
|
|
ansible.builtin.template:
|
|
src: llama-swap-config.yaml.j2
|
|
dest: "{{ llm_swapmode_config_file }}"
|
|
owner: "{{ llm_swapmode_service_user }}"
|
|
group: "{{ llm_swapmode_service_user }}"
|
|
mode: "0644"
|
|
become: true
|
|
register: llm_swapmode_config_deployed
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_config]
|
|
|
|
- name: "[swapmode_config] Validate config.yaml syntax (YAML parse check)"
|
|
ansible.builtin.command:
|
|
cmd: python3 -c "import yaml; yaml.safe_load(open('{{ llm_swapmode_config_file }}'))"
|
|
register: llm_swapmode_config_validate
|
|
changed_when: false
|
|
become: true
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_config]
|
|
|
|
# =============================================================================
|
|
# TAG: swapmode_systemd
|
|
# Deploy the llama-swap systemd unit file and reload systemd.
|
|
# Does NOT start the service — that is swapmode_verify only.
|
|
# =============================================================================
|
|
|
|
- name: "[swapmode_systemd] Deploy llama-swap systemd unit"
|
|
ansible.builtin.template:
|
|
src: llama-swap.service.j2
|
|
dest: "/etc/systemd/system/{{ llm_swapmode_service_name }}.service"
|
|
owner: root
|
|
group: root
|
|
mode: "0644"
|
|
become: true
|
|
register: llm_swapmode_unit_deployed
|
|
notify:
|
|
- reload systemd
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_systemd]
|
|
|
|
- name: "[swapmode_systemd] Flush handlers so daemon-reload lands before swapmode_verify starts the unit"
|
|
ansible.builtin.meta: flush_handlers
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_systemd]
|
|
|
|
# =============================================================================
|
|
# TAG: swapmode_firewall
|
|
# Open port 8001 in ufw scoped to the Hermes source subnet.
|
|
# Idempotent: named comment + state: present prevents duplicate rules.
|
|
# =============================================================================
|
|
|
|
- name: "[swapmode_firewall] Check whether ufw is installed/active"
|
|
ansible.builtin.command:
|
|
cmd: ufw status
|
|
register: llm_swapmode_ufw_status
|
|
changed_when: false
|
|
failed_when: false
|
|
become: true
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_firewall]
|
|
|
|
- name: "[swapmode_firewall] WARNING — ufw not active, port {{ llm_swapmode_port }} scoping cannot be applied"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
ufw does not appear to be active on this host. Firewall scoping for
|
|
port {{ llm_swapmode_port }} was skipped. Bind address alone
|
|
({{ llm_swapmode_bind_address }}) limits exposure — flag to Ryan.
|
|
when:
|
|
- llm_swapmode_enabled | default(false)
|
|
- "'Status: active' not in (llm_swapmode_ufw_status.stdout | default(''))"
|
|
tags: [swapmode_firewall]
|
|
|
|
- name: "[swapmode_firewall] Allow llama-swap port ({{ llm_swapmode_port }}) from Hermes source subnet"
|
|
community.general.ufw:
|
|
rule: allow
|
|
port: "{{ llm_swapmode_port | string }}"
|
|
proto: tcp
|
|
src: "{{ llm_swapmode_allowed_source_cidr }}"
|
|
comment: "llm-inference-multimodel: llama-swap ({{ llm_swapmode_port }}) — scoped to Hermes subnet"
|
|
become: true
|
|
when:
|
|
- llm_swapmode_enabled | default(false)
|
|
- "'Status: active' in (llm_swapmode_ufw_status.stdout | default(''))"
|
|
tags: [swapmode_firewall]
|
|
|
|
# =============================================================================
|
|
# TAG: swapmode_verify
|
|
# Start the service, then run the 4 validation gates.
|
|
# This is the ONLY phase that actually starts llama-swap.
|
|
# =============================================================================
|
|
|
|
- name: "[swapmode_verify] Start llama-swap service"
|
|
ansible.builtin.systemd:
|
|
name: "{{ llm_swapmode_service_name }}"
|
|
state: started
|
|
enabled: true
|
|
daemon_reload: true
|
|
become: true
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_verify]
|
|
|
|
# GATE 1: Health check
|
|
- name: "[swapmode_verify] GATE 1 — Health check (/health endpoint)"
|
|
ansible.builtin.uri:
|
|
url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/health"
|
|
method: GET
|
|
status_code: 200
|
|
register: llm_swapmode_health
|
|
until: llm_swapmode_health.status == 200
|
|
retries: 30
|
|
delay: 2
|
|
become: false
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_verify]
|
|
|
|
# GATE 2: Model discovery
|
|
- name: "[swapmode_verify] GATE 2 — Model discovery (/v1/models)"
|
|
ansible.builtin.uri:
|
|
url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/v1/models"
|
|
method: GET
|
|
status_code: 200
|
|
register: llm_swapmode_models_list
|
|
become: false
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_verify]
|
|
|
|
- name: "[swapmode_verify] Assert all 7 models are discoverable"
|
|
ansible.builtin.assert:
|
|
that:
|
|
- llm_swapmode_models_list.json.data | map(attribute='id') | list | length == 7
|
|
fail_msg: >-
|
|
Expected 7 models in /v1/models response, got {{ llm_swapmode_models_list.json.data | length }}.
|
|
Models: {{ llm_swapmode_models_list.json.data | map(attribute='id') | list }}
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_verify]
|
|
|
|
# GATE 3: Smoke test — simple completion on a CPU-offload model (no VRAM conflict)
|
|
- name: "[swapmode_verify] GATE 3 — Smoke test completion (Meta-Llama-3.1-8B CPU-offload)"
|
|
ansible.builtin.uri:
|
|
url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/v1/chat/completions"
|
|
method: POST
|
|
body_format: json
|
|
body:
|
|
model: "Meta-Llama-3.1-8B-Instruct-Q4_K_M"
|
|
messages:
|
|
- role: "user"
|
|
content: "What is 2+2?"
|
|
temperature: 0.1
|
|
max_tokens: 50
|
|
status_code: 200
|
|
register: llm_swapmode_smoke_test
|
|
become: false
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_verify]
|
|
|
|
# GATE 4: VRAM guard check
|
|
- name: "[swapmode_verify] GATE 4 — VRAM usage check (must be < {{ llm_swapmode_vram_max_mib }} MiB)"
|
|
ansible.builtin.shell:
|
|
cmd: nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits | head -1
|
|
register: llm_swapmode_vram_used
|
|
changed_when: false
|
|
become: false
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_verify]
|
|
|
|
- name: "[swapmode_verify] Assert VRAM usage is within budget"
|
|
ansible.builtin.assert:
|
|
that:
|
|
- (llm_swapmode_vram_used.stdout | int) < llm_swapmode_vram_max_mib
|
|
fail_msg: >-
|
|
VRAM usage ({{ llm_swapmode_vram_used.stdout }} MiB) exceeds gate limit ({{ llm_swapmode_vram_max_mib }} MiB).
|
|
Check for resource contention with production router or other services.
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_verify]
|
|
|
|
# Display verification results
|
|
- name: "[swapmode_verify] Display verification results"
|
|
ansible.builtin.debug:
|
|
msg: |
|
|
✓ GATE 1: Health check PASS
|
|
✓ GATE 2: Model discovery PASS — {{ llm_swapmode_models_list.json.data | map(attribute='id') | list | join(', ') }}
|
|
✓ GATE 3: Smoke test (Llama-3.1-8B) PASS
|
|
✓ GATE 4: VRAM guard ({{ llm_swapmode_vram_used.stdout }} MiB < {{ llm_swapmode_vram_max_mib }} MiB) PASS
|
|
|
|
llama-swap service is ready at http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/
|
|
NOTE: 7 models registered (5 original + Qwen3-8B-Q4_K_M + Qwen3-8B-Q4_K_M-no_think).
|
|
when: llm_swapmode_enabled | default(false)
|
|
tags: [swapmode_verify]
|