9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
2085 lines
100 KiB
Bash
2085 lines
100 KiB
Bash
#!/bin/bash
|
|
# ============================================================================
|
|
# bootstrap-upgrade.sh — Background Model Download + Auto Hot-Swap
|
|
# ============================================================================
|
|
# Runs in the background after the installer starts services with the
|
|
# bootstrap model. Downloads the full tier-appropriate model, then swaps
|
|
# llama-server to the new model with minimal downtime.
|
|
#
|
|
# Usage (called by phase 11, not directly by users):
|
|
# nohup bash bootstrap-upgrade.sh \
|
|
# <install_dir> <gguf_file> <gguf_url> <gguf_sha256> \
|
|
# <llm_model> <max_context> [<bootstrap_gguf_file>] \
|
|
# > logs/model-upgrade.log 2>&1 &
|
|
#
|
|
# Arg 7 (bootstrap_gguf_file) is optional and defaults to the historical
|
|
# Qwen3.5-2B-Q4_K_M.gguf for backwards compatibility. Phase 11 must pass the
|
|
# canonical $BOOTSTRAP_GGUF_FILE from installers/lib/bootstrap-model.sh so the
|
|
# Phase 4b cleanup step removes the actual bootstrap model after hot-swap.
|
|
#
|
|
# On failure: logs the error, preserves any .part download for resume, and
|
|
# exits. The bootstrap model continues running; `ods start`, `ods restart`,
|
|
# or re-running the installer retries the full-model upgrade.
|
|
# ============================================================================
|
|
|
|
set -uo pipefail
|
|
# Note: no set -e — we handle errors explicitly to avoid killing the
|
|
# background process on transient failures.
|
|
|
|
# ── Arguments ──
|
|
INSTALL_DIR="$1"
|
|
FULL_GGUF_FILE="$2"
|
|
FULL_GGUF_URL="$3"
|
|
FULL_GGUF_SHA256="$4"
|
|
FULL_LLM_MODEL="$5"
|
|
FULL_MAX_CONTEXT="$6"
|
|
BOOTSTRAP_GGUF_FILE="${7:-Qwen3.5-2B-Q4_K_M.gguf}"
|
|
|
|
LOG_TAG="[BOOTSTRAP-UPGRADE]"
|
|
|
|
log() { echo "$LOG_TAG $(date '+%H:%M:%S') $*"; }
|
|
fail() { log "ERROR: $*"; release_upgrade_lock; exit 1; }
|
|
|
|
if [[ -z "$INSTALL_DIR" || ! -d "$INSTALL_DIR" ]]; then
|
|
log "ERROR: install directory does not exist: ${INSTALL_DIR:-<empty>}"
|
|
exit 1
|
|
fi
|
|
if ! INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd -P)"; then
|
|
log "ERROR: could not resolve install directory: $INSTALL_DIR"
|
|
exit 1
|
|
fi
|
|
cd "$INSTALL_DIR" || {
|
|
log "ERROR: could not enter install directory: $INSTALL_DIR"
|
|
exit 1
|
|
}
|
|
|
|
MODELS_DIR="$INSTALL_DIR/data/models"
|
|
ENV_FILE="$INSTALL_DIR/.env"
|
|
MODELS_INI="$INSTALL_DIR/config/llama-server/models.ini"
|
|
STATUS_FILE="$INSTALL_DIR/data/bootstrap-status.json"
|
|
UPGRADE_LOCK_DIR=""
|
|
|
|
# Cross-platform file size (GNU stat on Linux/WSL2, BSD stat on macOS)
|
|
# IMPORTANT: Try GNU stat -c %s FIRST (Linux). stat -f on Linux returns filesystem
|
|
# block count (not file size). BSD stat -f %z is the macOS fallback.
|
|
file_size() {
|
|
if stat -c %s "$1" 2>/dev/null; then
|
|
return
|
|
fi
|
|
stat -f %z "$1" 2>/dev/null || echo 0
|
|
}
|
|
|
|
# Get total size via HTTP HEAD request
|
|
get_remote_size() {
|
|
local url="$1"
|
|
curl -sI -L --connect-timeout 10 "$url" 2>/dev/null \
|
|
| grep -i '^content-length:' | tail -1 | tr -dc '0-9'
|
|
}
|
|
|
|
# Write status JSON (atomic via mv)
|
|
write_status() {
|
|
local status="$1" percent="${2:-}" downloaded="${3:-0}" total="${4:-0}" speed="${5:-0}" eta="${6:-}"
|
|
local _safe_model="${FULL_GGUF_FILE//\"/\\\"}"
|
|
cat > "$STATUS_FILE.tmp" << STATUSEOF
|
|
{
|
|
"status": "$status",
|
|
"model": "$_safe_model",
|
|
"percent": ${percent:-null},
|
|
"bytesDownloaded": $downloaded,
|
|
"bytesTotal": $total,
|
|
"speedBytesPerSec": $speed,
|
|
"eta": "${eta:-}",
|
|
"updatedAt": "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%dT%H:%M:%SZ')"
|
|
}
|
|
STATUSEOF
|
|
mv "$STATUS_FILE.tmp" "$STATUS_FILE"
|
|
}
|
|
|
|
status_percent() {
|
|
local downloaded="${1:-0}" total="${2:-0}"
|
|
if [[ "$total" -le 0 ]]; then
|
|
echo ""
|
|
return
|
|
fi
|
|
local display_bytes="$downloaded"
|
|
[[ "$display_bytes" -lt 0 ]] && display_bytes=0
|
|
[[ "$display_bytes" -gt "$total" ]] && display_bytes="$total"
|
|
awk "BEGIN { printf \"%.1f\", ($display_bytes / $total) * 100 }"
|
|
}
|
|
|
|
write_failed_download_status() {
|
|
local part_file="${1:-}" total="${2:-0}" message="${3:-}"
|
|
local downloaded=0 percent=""
|
|
if [[ -n "$part_file" && -f "$part_file" ]]; then
|
|
downloaded=$(file_size "$part_file")
|
|
fi
|
|
percent=$(status_percent "$downloaded" "$total")
|
|
write_status "failed" "$percent" "$downloaded" "$total" 0 "$message"
|
|
}
|
|
|
|
compose_recreate_llama_server_with_retry() {
|
|
local -a compose_args=("$@")
|
|
local max_attempts="${ODS_BOOTSTRAP_COMPOSE_RETRY_ATTEMPTS:-3}"
|
|
if ! [[ "$max_attempts" =~ ^[0-9]+$ ]] || (( max_attempts < 1 )); then
|
|
max_attempts=3
|
|
fi
|
|
|
|
local attempt=1
|
|
local retries=$(( max_attempts - 1 ))
|
|
local output rc
|
|
while true; do
|
|
output=$(env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE \
|
|
$DOCKER_COMPOSE_CMD "${compose_args[@]}" up -d --force-recreate --no-deps llama-server 2>&1)
|
|
rc=$?
|
|
[[ -n "$output" ]] && printf '%s\n' "$output"
|
|
(( rc == 0 )) && return 0
|
|
|
|
if (( attempt >= max_attempts )) || \
|
|
! grep -Eiq 'dependency failed to start|No such container|container .* (exited|is unhealthy)|service .* failed to start|failed to start.*container' <<<"$output"; then
|
|
return "$rc"
|
|
fi
|
|
|
|
log "Docker reported a transient llama-server recreate failure; waiting briefly and retrying (${attempt}/${retries})."
|
|
sleep "${ODS_BOOTSTRAP_COMPOSE_RETRY_DELAY:-15}"
|
|
attempt=$(( attempt + 1 ))
|
|
done
|
|
}
|
|
|
|
release_upgrade_lock() {
|
|
if [[ -n "${UPGRADE_LOCK_DIR:-}" && -d "$UPGRADE_LOCK_DIR" ]]; then
|
|
rm -rf "$UPGRADE_LOCK_DIR"
|
|
fi
|
|
UPGRADE_LOCK_DIR=""
|
|
}
|
|
|
|
acquire_upgrade_lock() {
|
|
local tmp_root="${TMPDIR:-/tmp}"
|
|
local lock_key
|
|
lock_key="$(printf '%s\0%s' "$INSTALL_DIR" "$FULL_GGUF_FILE" | cksum | awk '{print $1}')"
|
|
local lock_dir="$tmp_root/ods-bootstrap-upgrade-${lock_key}.lock"
|
|
local pid_file="$lock_dir/pid"
|
|
local existing_pid=""
|
|
|
|
mkdir -p "$(dirname "$lock_dir")"
|
|
while ! mkdir "$lock_dir" 2>/dev/null; do
|
|
existing_pid=""
|
|
if [[ -f "$pid_file" ]]; then
|
|
existing_pid="$(tr -dc '0-9' < "$pid_file" 2>/dev/null || true)"
|
|
fi
|
|
|
|
if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
|
|
log "Another bootstrap model upgrade is already running (pid $existing_pid); leaving it in control."
|
|
if [[ ! -f "$STATUS_FILE" ]]; then
|
|
write_status "downloading" "" 0 0 0 "Another bootstrap model upgrade is already running."
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
log "Removing stale bootstrap model upgrade lock: $lock_dir"
|
|
rm -rf "$lock_dir"
|
|
done
|
|
|
|
UPGRADE_LOCK_DIR="$lock_dir"
|
|
printf '%s\n' "$$" > "$pid_file"
|
|
trap 'release_upgrade_lock' EXIT
|
|
}
|
|
|
|
model_sha256() {
|
|
local path="$1"
|
|
if command -v sha256sum &>/dev/null; then
|
|
sha256sum "$path" 2>/dev/null | awk '{print $1}'
|
|
elif command -v shasum &>/dev/null; then
|
|
shasum -a 256 "$path" 2>/dev/null | awk '{print $1}'
|
|
else
|
|
return 2
|
|
fi
|
|
}
|
|
|
|
verify_model_integrity() {
|
|
local path="$1" actual_hash
|
|
[[ -n "$FULL_GGUF_SHA256" ]] || return 0
|
|
|
|
actual_hash="$(model_sha256 "$path")"
|
|
case "$?" in
|
|
0) ;;
|
|
2)
|
|
log "WARNING: No checksum tool available — skipping SHA256 verification"
|
|
return 0
|
|
;;
|
|
*)
|
|
log "Could not compute SHA256 for $path"
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
if [[ -z "$actual_hash" ]]; then
|
|
log "Could not compute SHA256 for $path"
|
|
return 1
|
|
fi
|
|
if [[ "$actual_hash" != "$FULL_GGUF_SHA256" ]]; then
|
|
log "SHA256 mismatch (expected: $FULL_GGUF_SHA256, got: $actual_hash)."
|
|
return 1
|
|
fi
|
|
|
|
log "SHA256 verified"
|
|
return 0
|
|
}
|
|
|
|
ACTIVE_CONFIG_SNAPSHOT_DIR=""
|
|
|
|
snapshot_active_model_config() {
|
|
local snapshot_base
|
|
snapshot_base="${INSTALL_DIR}/data"
|
|
mkdir -p "$snapshot_base" 2>/dev/null || return 1
|
|
ACTIVE_CONFIG_SNAPSHOT_DIR="$(mktemp -d "${snapshot_base}/bootstrap-upgrade-active-config.XXXXXX" 2>/dev/null || true)"
|
|
[[ -n "$ACTIVE_CONFIG_SNAPSHOT_DIR" ]] || return 1
|
|
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
cp -p "$ENV_FILE" "$ACTIVE_CONFIG_SNAPSHOT_DIR/env" || return 1
|
|
else
|
|
: > "$ACTIVE_CONFIG_SNAPSHOT_DIR/env.missing"
|
|
fi
|
|
|
|
if [[ -f "$MODELS_INI" ]]; then
|
|
mkdir -p "$ACTIVE_CONFIG_SNAPSHOT_DIR/config-llama-server" || return 1
|
|
cp -p "$MODELS_INI" "$ACTIVE_CONFIG_SNAPSHOT_DIR/config-llama-server/models.ini" || return 1
|
|
else
|
|
: > "$ACTIVE_CONFIG_SNAPSHOT_DIR/models.ini.missing"
|
|
fi
|
|
}
|
|
|
|
restore_active_model_config() {
|
|
[[ -n "${ACTIVE_CONFIG_SNAPSHOT_DIR:-}" && -d "$ACTIVE_CONFIG_SNAPSHOT_DIR" ]] || return 1
|
|
|
|
if [[ -f "$ACTIVE_CONFIG_SNAPSHOT_DIR/env" ]]; then
|
|
cp -p "$ACTIVE_CONFIG_SNAPSHOT_DIR/env" "$ENV_FILE" || return 1
|
|
elif [[ -f "$ACTIVE_CONFIG_SNAPSHOT_DIR/env.missing" ]]; then
|
|
rm -f "$ENV_FILE"
|
|
fi
|
|
|
|
if [[ -f "$ACTIVE_CONFIG_SNAPSHOT_DIR/config-llama-server/models.ini" ]]; then
|
|
mkdir -p "$(dirname "$MODELS_INI")" || return 1
|
|
cp -p "$ACTIVE_CONFIG_SNAPSHOT_DIR/config-llama-server/models.ini" "$MODELS_INI" || return 1
|
|
elif [[ -f "$ACTIVE_CONFIG_SNAPSHOT_DIR/models.ini.missing" ]]; then
|
|
rm -f "$MODELS_INI"
|
|
fi
|
|
|
|
rm -rf "$ACTIVE_CONFIG_SNAPSHOT_DIR"
|
|
ACTIVE_CONFIG_SNAPSHOT_DIR=""
|
|
}
|
|
|
|
discard_active_model_config_snapshot() {
|
|
if [[ -n "${ACTIVE_CONFIG_SNAPSHOT_DIR:-}" ]]; then
|
|
rm -rf "$ACTIVE_CONFIG_SNAPSHOT_DIR"
|
|
ACTIVE_CONFIG_SNAPSHOT_DIR=""
|
|
fi
|
|
}
|
|
|
|
restore_docker_llama_server_after_swap_failure() {
|
|
local health_url="${1:-}"
|
|
local compose_arg_count=0
|
|
local rollback_healthy=false
|
|
|
|
log "Restoring previous active model config after Docker llama-server swap failure..."
|
|
if ! restore_active_model_config; then
|
|
log "WARNING: could not restore previous active model config; inspect $ENV_FILE and $MODELS_INI"
|
|
return 1
|
|
fi
|
|
|
|
if declare -p COMPOSE_ARGS >/dev/null 2>&1; then
|
|
compose_arg_count=${#COMPOSE_ARGS[@]}
|
|
fi
|
|
if [[ "$compose_arg_count" -eq 0 || -z "${DOCKER_COMPOSE_CMD:-}" ]]; then
|
|
log "WARNING: cannot recreate llama-server with previous config because compose flags are unavailable."
|
|
return 1
|
|
fi
|
|
|
|
log "Recreating llama-server with previous active model config..."
|
|
if ! compose_recreate_llama_server_with_retry "${COMPOSE_ARGS[@]}"; then
|
|
log "WARNING: rollback recreate failed; inspect docker logs ods-llama-server"
|
|
return 1
|
|
fi
|
|
|
|
[[ -n "$health_url" ]] || return 0
|
|
log "Waiting for restored llama-server health at $health_url ..."
|
|
for _rollback_i in $(seq 1 60); do
|
|
if curl -sf --max-time 5 "$health_url" >/dev/null 2>&1; then
|
|
rollback_healthy=true
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
if [[ "$rollback_healthy" == "true" ]]; then
|
|
log "Rollback complete: llama-server is healthy with the previous active model config."
|
|
return 0
|
|
fi
|
|
|
|
log "WARNING: rollback config was restored, but llama-server did not become healthy within the wait window."
|
|
return 1
|
|
}
|
|
|
|
docker_llama_server_container_failed_after_swap() {
|
|
[[ -n "${DOCKER_CMD:-}" ]] || return 1
|
|
|
|
local state
|
|
state=$($DOCKER_CMD inspect ods-llama-server \
|
|
--format '{{.State.Status}} {{.State.Restarting}} {{.State.ExitCode}}' \
|
|
2>/dev/null || true)
|
|
case "$state" in
|
|
restarting*|exited*|dead*)
|
|
return 0
|
|
;;
|
|
running\ true\ *|created\ true\ *)
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
return 1
|
|
}
|
|
|
|
BOOTSTRAP_SWAP_BACKUP_PATH=""
|
|
|
|
move_bootstrap_model_aside_for_windows_swap() {
|
|
[[ -f "$BOOTSTRAP_PATH" ]] || return 0
|
|
|
|
BOOTSTRAP_SWAP_BACKUP_PATH="${BOOTSTRAP_PATH}.ods-swap-backup"
|
|
rm -f "$BOOTSTRAP_SWAP_BACKUP_PATH"
|
|
mv "$BOOTSTRAP_PATH" "$BOOTSTRAP_SWAP_BACKUP_PATH" || return 1
|
|
log "Moved bootstrap model aside before Windows Lemonade full-model restart: $(basename "$BOOTSTRAP_PATH")"
|
|
}
|
|
|
|
restore_bootstrap_model_after_windows_swap_failure() {
|
|
[[ -n "$BOOTSTRAP_SWAP_BACKUP_PATH" && -f "$BOOTSTRAP_SWAP_BACKUP_PATH" ]] || return 0
|
|
|
|
mv "$BOOTSTRAP_SWAP_BACKUP_PATH" "$BOOTSTRAP_PATH" || return 1
|
|
log "Restored bootstrap model after Windows Lemonade swap failure: $(basename "$BOOTSTRAP_PATH")"
|
|
}
|
|
|
|
discard_bootstrap_model_backup_after_windows_swap() {
|
|
[[ -n "$BOOTSTRAP_SWAP_BACKUP_PATH" && -f "$BOOTSTRAP_SWAP_BACKUP_PATH" ]] || return 0
|
|
|
|
rm -f "$BOOTSTRAP_SWAP_BACKUP_PATH"
|
|
log "Removed bootstrap model after verified Windows Lemonade full-model serving: $(basename "$BOOTSTRAP_PATH")"
|
|
}
|
|
|
|
sync_windows_opencode_config() {
|
|
case "$(uname -s)" in
|
|
MINGW*|MSYS*|CYGWIN*) ;;
|
|
*) return 0 ;;
|
|
esac
|
|
|
|
local sync_script="$INSTALL_DIR/scripts/update-windows-opencode-config.ps1"
|
|
[[ -f "$sync_script" ]] || return 0
|
|
|
|
local ps_cmd=""
|
|
if command -v powershell.exe >/dev/null 2>&1; then
|
|
ps_cmd="powershell.exe"
|
|
elif command -v pwsh.exe >/dev/null 2>&1; then
|
|
ps_cmd="pwsh.exe"
|
|
fi
|
|
[[ -n "$ps_cmd" ]] || return 0
|
|
|
|
local install_dir_arg="$INSTALL_DIR"
|
|
local sync_script_arg="$sync_script"
|
|
if command -v cygpath >/dev/null 2>&1; then
|
|
install_dir_arg=$(cygpath -w "$INSTALL_DIR")
|
|
sync_script_arg=$(cygpath -w "$sync_script")
|
|
fi
|
|
|
|
log "Refreshing Windows OpenCode config for model: $FULL_GGUF_FILE"
|
|
"$ps_cmd" -NoProfile -ExecutionPolicy Bypass -File "$sync_script_arg" -InstallDir "$install_dir_arg" \
|
|
>/dev/null 2>&1 || log "WARNING: OpenCode config refresh failed (non-fatal)"
|
|
}
|
|
|
|
read_env_value() {
|
|
local key="$1"
|
|
[[ -f "$ENV_FILE" ]] || return 0
|
|
grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"\047\r'
|
|
}
|
|
|
|
is_windows_bash() {
|
|
case "$(uname -s)" in
|
|
MINGW*|MSYS*|CYGWIN*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
windows_path() {
|
|
local path="$1"
|
|
if command -v cygpath >/dev/null 2>&1; then
|
|
cygpath -w "$path"
|
|
else
|
|
printf '%s\n' "$path"
|
|
fi
|
|
}
|
|
|
|
windows_ps_command() {
|
|
if command -v powershell.exe >/dev/null 2>&1; then
|
|
printf '%s\n' "powershell.exe"
|
|
elif command -v pwsh.exe >/dev/null 2>&1; then
|
|
printf '%s\n' "pwsh.exe"
|
|
fi
|
|
}
|
|
|
|
restart_windows_lemonade_with_full_model() {
|
|
is_windows_bash || return 1
|
|
|
|
local runtime llm_backend managed runtime_mode lemonade_external
|
|
runtime="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
|
|
llm_backend="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
|
|
[[ "$runtime" == "lemonade" || "$llm_backend" == "lemonade" ]] || return 1
|
|
managed="$(read_env_value AMD_INFERENCE_MANAGED | tr '[:upper:]' '[:lower:]')"
|
|
runtime_mode="$(read_env_value AMD_INFERENCE_RUNTIME_MODE | tr '[:upper:]' '[:lower:]')"
|
|
lemonade_external="$(read_env_value LEMONADE_EXTERNAL | tr '[:upper:]' '[:lower:]')"
|
|
if [[ "$managed" == "false" || "$runtime_mode" == "external-lemonade" || "$lemonade_external" == "true" ]]; then
|
|
log "Skipping native Windows Lemonade restart because the runtime is externally managed."
|
|
return 1
|
|
fi
|
|
|
|
local ps_cmd
|
|
ps_cmd="$(windows_ps_command)"
|
|
[[ -n "$ps_cmd" ]] || {
|
|
log "WARNING: no PowerShell executable found; cannot restart native Windows Lemonade."
|
|
return 1
|
|
}
|
|
|
|
local pid_file bind_addr lemonade_port
|
|
pid_file="$INSTALL_DIR/data/llama-server.pid"
|
|
bind_addr="$(read_env_value BIND_ADDRESS)"
|
|
[[ -n "$bind_addr" ]] || bind_addr="127.0.0.1"
|
|
lemonade_port="$(read_env_value AMD_INFERENCE_PORT)"
|
|
[[ -n "$lemonade_port" ]] || lemonade_port="8080"
|
|
|
|
log "Restarting native Windows Lemonade with full model..."
|
|
ODS_WIN_PID_FILE="$(windows_path "$pid_file")" \
|
|
ODS_WIN_MODELS_DIR="$(windows_path "$MODELS_DIR")" \
|
|
ODS_WIN_BIND_ADDR="$bind_addr" \
|
|
ODS_WIN_LEMONADE_PORT="$lemonade_port" \
|
|
"$ps_cmd" -NoProfile -ExecutionPolicy Bypass -Command '
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$roots = @()
|
|
if ($env:ProgramFiles) { $roots += $env:ProgramFiles }
|
|
$pf86 = [Environment]::GetFolderPath("ProgramFilesX86")
|
|
if ($pf86) { $roots += $pf86 }
|
|
$exe = $null
|
|
foreach ($root in $roots) {
|
|
$candidate = Join-Path (Join-Path (Join-Path $root "Lemonade Server") "bin") "lemonade-server.exe"
|
|
if (Test-Path $candidate) { $exe = $candidate; break }
|
|
}
|
|
if (-not $exe) { throw "lemonade-server.exe not found under Program Files roots" }
|
|
|
|
function Stop-ODSProcessId {
|
|
param([int]$ProcessId)
|
|
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
|
|
for ($i = 0; $i -lt 30; $i++) {
|
|
$old = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
|
|
if (-not $old) { return }
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
}
|
|
|
|
$pidPath = $env:ODS_WIN_PID_FILE
|
|
if (Test-Path $pidPath) {
|
|
$rawPid = (Get-Content -LiteralPath $pidPath -Raw).Trim()
|
|
if ($rawPid -match "^\d+$") {
|
|
Stop-ODSProcessId -ProcessId ([int]$rawPid)
|
|
}
|
|
Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
# Lemonade also has a process-wide router singleton. If the saved PID
|
|
# is stale or points at a child that has already exited, Start-Process
|
|
# can report success while the new router immediately exits with
|
|
# "Another instance of lemonade-router is already running"; the swap
|
|
# then polls the old bootstrap instance forever. Clear the configured
|
|
# listener and any Lemonade Server child processes before launching the
|
|
# full-model instance.
|
|
$port = [int]$env:ODS_WIN_LEMONADE_PORT
|
|
$deadline = (Get-Date).AddSeconds(20)
|
|
do {
|
|
$listeners = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)
|
|
foreach ($listener in $listeners) {
|
|
if ($listener.OwningProcess -gt 0) {
|
|
Stop-ODSProcessId -ProcessId ([int]$listener.OwningProcess)
|
|
}
|
|
}
|
|
if ($listeners.Count -eq 0) { break }
|
|
Start-Sleep -Milliseconds 500
|
|
} while ((Get-Date) -lt $deadline)
|
|
|
|
$binDir = Split-Path -Parent $exe
|
|
$userProfile = [Environment]::GetFolderPath("UserProfile")
|
|
$lemonadeCacheBin = if ($userProfile) { Join-Path (Join-Path (Join-Path $userProfile ".cache") "lemonade") "bin" } else { $null }
|
|
$odsModelsDir = $env:ODS_WIN_MODELS_DIR
|
|
$lemonadeChildren = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
|
($_.ExecutablePath -and $_.ExecutablePath.StartsWith($binDir, [StringComparison]::OrdinalIgnoreCase)) -or
|
|
($lemonadeCacheBin -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($lemonadeCacheBin, [StringComparison]::OrdinalIgnoreCase)) -or
|
|
($odsModelsDir -and $_.CommandLine -and $_.CommandLine.IndexOf($odsModelsDir, [StringComparison]::OrdinalIgnoreCase) -ge 0)
|
|
})
|
|
foreach ($child in $lemonadeChildren) {
|
|
Stop-ODSProcessId -ProcessId ([int]$child.ProcessId)
|
|
}
|
|
|
|
$stillListening = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)
|
|
if ($stillListening.Count -gt 0) {
|
|
throw "port $port is still occupied after stopping the previous Lemonade instance"
|
|
}
|
|
|
|
$logPath = Join-Path $env:TEMP "lemonade-server.log"
|
|
Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue
|
|
|
|
$args = @(
|
|
"serve",
|
|
"--port", $env:ODS_WIN_LEMONADE_PORT,
|
|
"--host", $env:ODS_WIN_BIND_ADDR,
|
|
"--no-tray",
|
|
"--llamacpp", "vulkan",
|
|
"--extra-models-dir", $env:ODS_WIN_MODELS_DIR
|
|
)
|
|
$proc = Start-Process -FilePath $exe -ArgumentList $args -WindowStyle Hidden -PassThru
|
|
New-Item -ItemType Directory -Path (Split-Path -Parent $pidPath) -Force | Out-Null
|
|
Set-Content -LiteralPath $pidPath -Value $proc.Id
|
|
|
|
Start-Sleep -Seconds 2
|
|
$started = Get-Process -Id $proc.Id -ErrorAction SilentlyContinue
|
|
if (-not $started) {
|
|
try {
|
|
$health = Invoke-WebRequest -Uri ("http://127.0.0.1:{0}/api/v1/models" -f $port) -TimeoutSec 3 -UseBasicParsing
|
|
if ([int]$health.StatusCode -eq 200) { return }
|
|
} catch { }
|
|
$tail = ""
|
|
if (Test-Path $logPath) {
|
|
$tail = (Get-Content -LiteralPath $logPath -Tail 8 -ErrorAction SilentlyContinue) -join " "
|
|
}
|
|
throw "lemonade-server exited immediately after restart. $tail"
|
|
}
|
|
' >/dev/null 2>&1 || {
|
|
log "WARNING: native Windows Lemonade restart failed."
|
|
return 1
|
|
}
|
|
|
|
log "Waiting for native Windows Lemonade to serve extra.$FULL_GGUF_FILE ..."
|
|
local model_id _swap_attempts
|
|
model_id="extra.${FULL_GGUF_FILE//\"/\\\"}"
|
|
# A freshly-swapped full model can take several minutes to register in
|
|
# --extra-models-dir and then load on the first completion. The old 12x10s
|
|
# (~2 min) budget timed out before a 22 GB MoE (Qwen3.6-35B-A3B) was even
|
|
# listed, so the swap reverted to bootstrap (#1517). Use the same longer
|
|
# budget as the llama.cpp warm-up path; override with ODS_LEMONADE_SWAP_ATTEMPTS.
|
|
_swap_attempts="${ODS_LEMONADE_SWAP_ATTEMPTS:-60}"
|
|
case "$_swap_attempts" in ''|*[!0-9]*|0) _swap_attempts=60 ;; esac
|
|
for _i in $(seq 1 "$_swap_attempts"); do
|
|
if curl -sf --max-time 5 "http://127.0.0.1:${lemonade_port}/api/v1/models" 2>/dev/null \
|
|
| grep -q "\"id\"[[:space:]]*:[[:space:]]*\"${model_id}\""; then
|
|
if curl -sf --max-time 240 -X POST \
|
|
"http://127.0.0.1:${lemonade_port}/api/v1/chat/completions" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"model\":\"${model_id}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1,\"temperature\":0,\"stream\":false}" \
|
|
>/dev/null 2>&1; then
|
|
log "SUCCESS: native Windows Lemonade completed with ${model_id}"
|
|
return 0
|
|
fi
|
|
log "Windows Lemonade lists ${model_id}, but completion is not ready yet (attempt $_i/${_swap_attempts})."
|
|
else
|
|
log "Waiting for Windows Lemonade to register ${model_id} (attempt $_i/${_swap_attempts})."
|
|
fi
|
|
sleep 10
|
|
done
|
|
|
|
log "WARNING: native Windows Lemonade did not complete with ${model_id} after ${_swap_attempts} attempts; keeping bootstrap model for recovery."
|
|
return 1
|
|
}
|
|
|
|
restart_windows_native_llama_server_with_full_model() {
|
|
is_windows_bash || return 1
|
|
|
|
local runtime managed runtime_mode location ps_cmd
|
|
runtime="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
|
|
managed="$(read_env_value AMD_INFERENCE_MANAGED | tr '[:upper:]' '[:lower:]')"
|
|
runtime_mode="$(read_env_value AMD_INFERENCE_RUNTIME_MODE | tr '[:upper:]' '[:lower:]')"
|
|
location="$(read_env_value AMD_INFERENCE_LOCATION | tr '[:upper:]' '[:lower:]')"
|
|
[[ "$runtime_mode" == "windows-llama-server-fallback" || ( "$runtime" == "llama-server" && "$location" == "host" ) ]] || return 1
|
|
[[ "$managed" == "false" || "$location" == "external" ]] && return 1
|
|
|
|
ps_cmd="$(windows_ps_command)"
|
|
[[ -n "$ps_cmd" ]] || {
|
|
log "WARNING: no PowerShell executable found; cannot restart native Windows llama-server."
|
|
return 1
|
|
}
|
|
|
|
local pid_file llama_exe model_path rollback_model_path log_path bind_addr ctx_size llama_port reasoning reasoning_fmt
|
|
pid_file="$INSTALL_DIR/data/llama-server.pid"
|
|
llama_exe="$INSTALL_DIR/llama-server/llama-server.exe"
|
|
model_path="$MODELS_DIR/$FULL_GGUF_FILE"
|
|
rollback_model_path="$MODELS_DIR/$BOOTSTRAP_GGUF_FILE"
|
|
log_path="$INSTALL_DIR/data/llama-server.log"
|
|
bind_addr="$(read_env_value BIND_ADDRESS)"
|
|
[[ -n "$bind_addr" ]] || bind_addr="127.0.0.1"
|
|
ctx_size="$(read_env_value CTX_SIZE)"
|
|
[[ -n "$ctx_size" ]] || ctx_size="$(read_env_value MAX_CONTEXT)"
|
|
[[ -n "$ctx_size" ]] || ctx_size="$FULL_MAX_CONTEXT"
|
|
llama_port="$(read_env_value AMD_INFERENCE_PORT)"
|
|
[[ -n "$llama_port" ]] || llama_port="8080"
|
|
reasoning="$(read_env_value LLAMA_REASONING)"
|
|
[[ -n "$reasoning" ]] || reasoning="off"
|
|
case "$reasoning" in
|
|
off) reasoning_fmt="none" ;;
|
|
on) reasoning_fmt="deepseek" ;;
|
|
*) reasoning_fmt="$reasoning" ;;
|
|
esac
|
|
|
|
[[ -f "$llama_exe" ]] || {
|
|
log "WARNING: llama-server.exe not found at $llama_exe. Cannot hot-swap native Windows llama-server."
|
|
return 1
|
|
}
|
|
[[ -f "$model_path" ]] || {
|
|
log "WARNING: full model not found at $model_path. Cannot hot-swap native Windows llama-server."
|
|
return 1
|
|
}
|
|
|
|
log "Restarting native Windows llama-server with full model..."
|
|
ODS_WIN_PID_FILE="$(windows_path "$pid_file")" \
|
|
ODS_WIN_LLAMA_EXE="$(windows_path "$llama_exe")" \
|
|
ODS_WIN_MODEL_PATH="$(windows_path "$model_path")" \
|
|
ODS_WIN_ROLLBACK_MODEL_PATH="$(windows_path "$rollback_model_path")" \
|
|
ODS_WIN_LOG_PATH="$(windows_path "$log_path")" \
|
|
ODS_WIN_BIND_ADDR="$bind_addr" \
|
|
ODS_WIN_LLAMA_PORT="$llama_port" \
|
|
ODS_WIN_CTX_SIZE="$ctx_size" \
|
|
ODS_WIN_REASONING_FORMAT="$reasoning_fmt" \
|
|
ODS_WIN_FLASH_ATTN="$(read_env_value LLAMA_ARG_FLASH_ATTN)" \
|
|
ODS_WIN_CACHE_TYPE_K="$(read_env_value LLAMA_ARG_CACHE_TYPE_K)" \
|
|
ODS_WIN_CACHE_TYPE_V="$(read_env_value LLAMA_ARG_CACHE_TYPE_V)" \
|
|
ODS_WIN_N_CPU_MOE="$(read_env_value LLAMA_ARG_N_CPU_MOE)" \
|
|
ODS_WIN_PARALLEL="$(read_env_value LLAMA_PARALLEL)" \
|
|
ODS_WIN_CHECKPOINT_EVERY_N_TOKENS="$(read_env_value LLAMA_ARG_CHECKPOINT_EVERY_N_TOKENS)" \
|
|
ODS_WIN_NO_CACHE_PROMPT="$(read_env_value LLAMA_ARG_NO_CACHE_PROMPT)" \
|
|
ODS_WIN_SPEC_TYPE="$(read_env_value LLAMA_ARG_SPEC_TYPE)" \
|
|
ODS_WIN_SPEC_DRAFT_N_MAX="$(read_env_value LLAMA_ARG_SPEC_DRAFT_N_MAX)" \
|
|
"$ps_cmd" -NoProfile -ExecutionPolicy Bypass -Command '
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
function Stop-ODSProcessId {
|
|
param([int]$ProcessId)
|
|
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
|
|
for ($i = 0; $i -lt 30; $i++) {
|
|
$old = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
|
|
if (-not $old) { return }
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
}
|
|
|
|
function Stop-ODSLlamaListeners {
|
|
param([int]$Port)
|
|
$deadline = (Get-Date).AddSeconds(20)
|
|
do {
|
|
$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
|
|
foreach ($listener in $listeners) {
|
|
if ($listener.OwningProcess -gt 0) {
|
|
$proc = Get-CimInstance Win32_Process -Filter ("ProcessId = {0}" -f [int]$listener.OwningProcess) -ErrorAction SilentlyContinue
|
|
if ($proc -and (
|
|
($proc.Name -like "llama-server*") -or
|
|
($proc.ExecutablePath -and $proc.ExecutablePath.Equals($env:ODS_WIN_LLAMA_EXE, [StringComparison]::OrdinalIgnoreCase)) -or
|
|
($proc.CommandLine -and $proc.CommandLine.IndexOf("llama-server", [StringComparison]::OrdinalIgnoreCase) -ge 0)
|
|
)) {
|
|
Stop-ODSProcessId -ProcessId ([int]$listener.OwningProcess)
|
|
}
|
|
}
|
|
}
|
|
if ($listeners.Count -eq 0) { return }
|
|
Start-Sleep -Milliseconds 500
|
|
} while ((Get-Date) -lt $deadline)
|
|
}
|
|
|
|
function Start-ODSLlama {
|
|
param([string]$ModelPath)
|
|
|
|
$args = @(
|
|
"--model", $ModelPath,
|
|
"--host", $env:ODS_WIN_BIND_ADDR,
|
|
"--port", $env:ODS_WIN_LLAMA_PORT,
|
|
"--n-gpu-layers", "999",
|
|
"--ctx-size", $env:ODS_WIN_CTX_SIZE,
|
|
"--reasoning-format", $env:ODS_WIN_REASONING_FORMAT,
|
|
"--metrics"
|
|
)
|
|
if ($env:ODS_WIN_FLASH_ATTN) { $args += @("--flash-attn", $env:ODS_WIN_FLASH_ATTN) }
|
|
if ($env:ODS_WIN_CACHE_TYPE_K) { $args += @("--cache-type-k", $env:ODS_WIN_CACHE_TYPE_K) }
|
|
if ($env:ODS_WIN_CACHE_TYPE_V) { $args += @("--cache-type-v", $env:ODS_WIN_CACHE_TYPE_V) }
|
|
if ($env:ODS_WIN_N_CPU_MOE) { $args += @("--n-cpu-moe", $env:ODS_WIN_N_CPU_MOE) }
|
|
if ($env:ODS_WIN_PARALLEL) { $args += @("--parallel", $env:ODS_WIN_PARALLEL) }
|
|
if ($env:ODS_WIN_CHECKPOINT_EVERY_N_TOKENS) { $args += @("--checkpoint-every-n-tokens", $env:ODS_WIN_CHECKPOINT_EVERY_N_TOKENS) }
|
|
if ($env:ODS_WIN_NO_CACHE_PROMPT -and $env:ODS_WIN_NO_CACHE_PROMPT -notin @("0", "false", "off", "no")) { $args += @("--no-cache-prompt") }
|
|
if ($env:ODS_WIN_SPEC_TYPE) { $args += @("--spec-type", $env:ODS_WIN_SPEC_TYPE) }
|
|
if ($env:ODS_WIN_SPEC_DRAFT_N_MAX) { $args += @("--spec-draft-n-max", $env:ODS_WIN_SPEC_DRAFT_N_MAX) }
|
|
|
|
New-Item -ItemType Directory -Path (Split-Path -Parent $env:ODS_WIN_PID_FILE) -Force | Out-Null
|
|
New-Item -ItemType Directory -Path (Split-Path -Parent $env:ODS_WIN_LOG_PATH) -Force | Out-Null
|
|
$proc = Start-Process -FilePath $env:ODS_WIN_LLAMA_EXE `
|
|
-ArgumentList $args -WindowStyle Hidden -RedirectStandardOutput $env:ODS_WIN_LOG_PATH -RedirectStandardError ($env:ODS_WIN_LOG_PATH + ".err") -PassThru
|
|
Set-Content -LiteralPath $env:ODS_WIN_PID_FILE -Value $proc.Id
|
|
return $proc
|
|
}
|
|
|
|
function Wait-ODSLlamaHealth {
|
|
param([int]$ProcessId, [int]$Port)
|
|
for ($i = 0; $i -lt 60; $i++) {
|
|
$proc = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
|
|
if (-not $proc) { return $false }
|
|
try {
|
|
$resp = Invoke-WebRequest -Uri ("http://127.0.0.1:{0}/health" -f $Port) -TimeoutSec 5 -UseBasicParsing
|
|
if ([int]$resp.StatusCode -eq 200) { return $true }
|
|
} catch { }
|
|
Start-Sleep -Seconds 5
|
|
}
|
|
return $false
|
|
}
|
|
|
|
$pidPath = $env:ODS_WIN_PID_FILE
|
|
if (Test-Path $pidPath) {
|
|
$rawPid = (Get-Content -LiteralPath $pidPath -Raw).Trim()
|
|
if ($rawPid -match "^\d+$") {
|
|
Stop-ODSProcessId -ProcessId ([int]$rawPid)
|
|
}
|
|
Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
$port = [int]$env:ODS_WIN_LLAMA_PORT
|
|
Stop-ODSLlamaListeners -Port $port
|
|
|
|
$fullProc = Start-ODSLlama -ModelPath $env:ODS_WIN_MODEL_PATH
|
|
if (Wait-ODSLlamaHealth -ProcessId ([int]$fullProc.Id) -Port $port) { exit 0 }
|
|
|
|
Stop-ODSProcessId -ProcessId ([int]$fullProc.Id)
|
|
if ($env:ODS_WIN_ROLLBACK_MODEL_PATH -and (Test-Path $env:ODS_WIN_ROLLBACK_MODEL_PATH)) {
|
|
$rollbackProc = Start-ODSLlama -ModelPath $env:ODS_WIN_ROLLBACK_MODEL_PATH
|
|
[void](Wait-ODSLlamaHealth -ProcessId ([int]$rollbackProc.Id) -Port $port)
|
|
}
|
|
exit 1
|
|
' >/dev/null 2>&1 || {
|
|
log "WARNING: native Windows llama-server restart failed."
|
|
return 1
|
|
}
|
|
|
|
log "SUCCESS: native Windows llama-server running with ${FULL_GGUF_FILE}"
|
|
return 0
|
|
}
|
|
|
|
patch_hermes_yaml_with_sed() {
|
|
local path="$1" model="$2" context_length="$3" base_url="${4:-}" request_timeout_seconds="${5:-180}"
|
|
[[ -f "$path" ]] || return 1
|
|
|
|
local model_sed base_url_sed
|
|
model_sed="$(printf '%s' "$model" | sed 's/[\\&|]/\\&/g')"
|
|
base_url_sed="$(printf '%s' "$base_url" | sed 's/[\\&|]/\\&/g')"
|
|
|
|
local sed_args=(
|
|
-e "s|^ default: \".*\"[[:space:]]*$| default: \"${model_sed}\"|"
|
|
-e "s|^ context_length: .*| context_length: ${context_length}|"
|
|
-e "s|^ context_length: .*| context_length: ${context_length}|"
|
|
)
|
|
if [[ "$request_timeout_seconds" =~ ^[0-9]+$ && "$request_timeout_seconds" != "180" ]]; then
|
|
sed_args+=(-e "s|^ request_timeout_seconds: 180[[:space:]]*$| request_timeout_seconds: ${request_timeout_seconds}|")
|
|
fi
|
|
if [[ -n "$base_url" ]]; then
|
|
sed_args+=(-e "s|^ base_url: \".*\"[[:space:]]*$| base_url: \"${base_url_sed}\"|")
|
|
fi
|
|
|
|
if sed -i.bak \
|
|
"${sed_args[@]}" \
|
|
"$path" 2>&1; then
|
|
rm -f "${path}.bak"
|
|
else
|
|
[[ -f "${path}.bak" ]] && mv "${path}.bak" "$path"
|
|
return 1
|
|
fi
|
|
|
|
grep -Fq " default: \"${model}\"" "$path" \
|
|
&& grep -Fq " context_length: ${context_length}" "$path" \
|
|
&& { [[ -z "$base_url" ]] || grep -Fq " base_url: \"${base_url}\"" "$path"; }
|
|
}
|
|
|
|
patch_hermes_model_after_swap() {
|
|
local runtime llm_backend hermes_base_url old_model new_model tpl live live_host_patch_failed hermes_request_timeout
|
|
runtime="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
|
|
llm_backend="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
|
|
hermes_base_url="$(read_env_value HERMES_LLM_BASE_URL)"
|
|
old_model="$BOOTSTRAP_GGUF_FILE"
|
|
new_model="$FULL_GGUF_FILE"
|
|
if [[ "$runtime" == "lemonade" || "$llm_backend" == "lemonade" ]]; then
|
|
old_model="extra.$BOOTSTRAP_GGUF_FILE"
|
|
new_model="extra.$FULL_GGUF_FILE"
|
|
fi
|
|
|
|
log "Patching Hermes config after full-model swap: ${old_model} -> ${new_model}"
|
|
hermes_request_timeout=180
|
|
if is_windows_bash || [[ "$runtime" == "lemonade" || "$llm_backend" == "lemonade" ]]; then
|
|
hermes_request_timeout=900
|
|
fi
|
|
|
|
tpl="$INSTALL_DIR/extensions/services/hermes/cli-config.yaml.template"
|
|
if [[ -f "$tpl" ]]; then
|
|
if ! patch_hermes_yaml_with_sed "$tpl" "$new_model" "$FULL_MAX_CONTEXT" "$hermes_base_url" "$hermes_request_timeout"; then
|
|
log "ERROR: Could not patch ${tpl} after full-model swap."
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
live="$INSTALL_DIR/data/hermes/config.yaml"
|
|
live_host_patch_failed=false
|
|
if [[ -f "$live" ]]; then
|
|
if ! patch_hermes_yaml_with_sed "$live" "$new_model" "$FULL_MAX_CONTEXT" "$hermes_base_url" "$hermes_request_timeout"; then
|
|
live_host_patch_failed=true
|
|
log "WARNING: Could not patch ${live} after full-model swap; will try the container copy if Hermes is running."
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$DOCKER_CMD" ]] && $DOCKER_CMD ps --filter name=ods-hermes --format '{{.Names}}' 2>/dev/null | grep -q ods-hermes; then
|
|
local live_patch new_model_sed hermes_base_url_sed
|
|
new_model_sed="$(printf '%s' "$new_model" | sed 's/[\\&|]/\\&/g')"
|
|
hermes_base_url_sed="$(printf '%s' "$hermes_base_url" | sed 's/[\\&|]/\\&/g')"
|
|
live_patch="sed -i -e 's|^ default: \".*\"[[:space:]]*$| default: \"${new_model_sed}\"|' -e 's|^ context_length: .*| context_length: ${FULL_MAX_CONTEXT}|' -e 's|^ context_length: .*| context_length: ${FULL_MAX_CONTEXT}|'"
|
|
if [[ -n "$hermes_base_url" ]]; then
|
|
live_patch="${live_patch} -e 's|^ base_url: \".*\"| base_url: \"${hermes_base_url_sed}\"|'"
|
|
fi
|
|
if [[ "$hermes_request_timeout" != "180" ]]; then
|
|
live_patch="${live_patch} -e 's|^ request_timeout_seconds: 180[[:space:]]*$| request_timeout_seconds: ${hermes_request_timeout}|'"
|
|
fi
|
|
live_patch="${live_patch} /opt/data/config.yaml"
|
|
$DOCKER_CMD exec ods-hermes sh -c \
|
|
"$live_patch" 2>&1 || {
|
|
log "ERROR: Could not patch Hermes live config after full-model swap."
|
|
return 1
|
|
}
|
|
$DOCKER_CMD restart ods-hermes 2>&1 || {
|
|
log "ERROR: Could not restart Hermes after full-model swap."
|
|
return 1
|
|
}
|
|
elif [[ "$live_host_patch_failed" == "true" ]]; then
|
|
log "ERROR: Could not patch Hermes live config after full-model swap."
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
refresh_windows_native_litellm_local_config_after_swap() {
|
|
is_windows_bash || return 0
|
|
|
|
local runtime runtime_mode location managed
|
|
runtime="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
|
|
runtime_mode="$(read_env_value AMD_INFERENCE_RUNTIME_MODE | tr '[:upper:]' '[:lower:]')"
|
|
location="$(read_env_value AMD_INFERENCE_LOCATION | tr '[:upper:]' '[:lower:]')"
|
|
managed="$(read_env_value AMD_INFERENCE_MANAGED | tr '[:upper:]' '[:lower:]')"
|
|
|
|
[[ "$managed" != "false" && "$location" == "host" ]] || return 0
|
|
[[ "$runtime_mode" == "windows-llama-server-fallback" || "$runtime" == "llama-server" ]] || return 0
|
|
|
|
local litellm_dir litellm_config native_port native_api_base model_sed
|
|
litellm_dir="$INSTALL_DIR/config/litellm"
|
|
litellm_config="$litellm_dir/local.yaml"
|
|
native_port="$(read_env_value AMD_INFERENCE_PORT)"
|
|
[[ -n "$native_port" ]] || native_port="8080"
|
|
native_api_base="http://host.docker.internal:${native_port}/v1"
|
|
model_sed="${FULL_GGUF_FILE//\"/\\\"}"
|
|
|
|
log "Updating LiteLLM local config for native Windows llama-server: ${FULL_GGUF_FILE}"
|
|
mkdir -p "$litellm_dir" || return 1
|
|
cat > "$litellm_config" << LITELLM_NATIVE_LOCAL_EOF
|
|
model_list:
|
|
- model_name: default
|
|
litellm_params:
|
|
model: openai/${model_sed}
|
|
api_base: ${native_api_base}
|
|
api_key: not-needed
|
|
extra_body:
|
|
chat_template_kwargs:
|
|
enable_thinking: false
|
|
|
|
- model_name: "*"
|
|
litellm_params:
|
|
model: openai/*
|
|
api_base: ${native_api_base}
|
|
api_key: not-needed
|
|
extra_body:
|
|
chat_template_kwargs:
|
|
enable_thinking: false
|
|
|
|
general_settings:
|
|
master_key: os.environ/LITELLM_MASTER_KEY
|
|
|
|
litellm_settings:
|
|
drop_params: true
|
|
set_verbose: false
|
|
request_timeout: 900
|
|
stream_timeout: 900
|
|
LITELLM_NATIVE_LOCAL_EOF
|
|
|
|
if [[ -n "$DOCKER_CMD" ]] && $DOCKER_CMD ps --filter name=ods-litellm --format '{{.Names}}' 2>/dev/null | grep -q ods-litellm; then
|
|
$DOCKER_CMD restart ods-litellm 2>&1 || log "WARNING: LiteLLM restart failed after native Windows config refresh (non-fatal)"
|
|
fi
|
|
}
|
|
|
|
refresh_lemonade_after_bootstrap_cleanup() {
|
|
local gpu_backend llm_backend
|
|
gpu_backend="$(read_env_value GPU_BACKEND | tr '[:upper:]' '[:lower:]')"
|
|
llm_backend="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
|
|
|
|
[[ "$gpu_backend" == "amd" || "$llm_backend" == "lemonade" ]] || return 0
|
|
is_windows_bash && return 0
|
|
[[ "$FULL_GGUF_FILE" != "$BOOTSTRAP_GGUF" ]] || return 0
|
|
[[ -n "$DOCKER_CMD" ]] || return 1
|
|
|
|
local compose_args=()
|
|
if [[ -f "$INSTALL_DIR/.compose-flags" ]]; then
|
|
read -ra compose_args <<< "$(cat "$INSTALL_DIR/.compose-flags")"
|
|
fi
|
|
if [[ ${#compose_args[@]} -eq 0 || -z "$DOCKER_COMPOSE_CMD" ]]; then
|
|
log "WARNING: cannot refresh Lemonade after bootstrap cleanup because compose flags are unavailable."
|
|
return 1
|
|
fi
|
|
|
|
log "Refreshing Lemonade after bootstrap model cleanup so stale model metadata is dropped..."
|
|
env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE \
|
|
$DOCKER_COMPOSE_CMD "${compose_args[@]}" up -d --force-recreate --no-deps llama-server 2>&1 || return 1
|
|
|
|
local lemonade_port model_id old_model_id models_json
|
|
lemonade_port="$(read_env_value OLLAMA_PORT)"
|
|
[[ -n "$lemonade_port" ]] || lemonade_port="8080"
|
|
model_id="extra.${FULL_GGUF_FILE//\"/\\\"}"
|
|
old_model_id="extra.${BOOTSTRAP_GGUF//\"/\\\"}"
|
|
|
|
for _i in $(seq 1 60); do
|
|
models_json="$(curl -sf --max-time 5 "http://127.0.0.1:${lemonade_port}/api/v1/models" 2>/dev/null || true)"
|
|
if echo "$models_json" | grep -q "\"id\"[[:space:]]*:[[:space:]]*\"${model_id}\"" \
|
|
&& ! echo "$models_json" | grep -q "\"id\"[[:space:]]*:[[:space:]]*\"${old_model_id}\""; then
|
|
if curl -sf --max-time 240 -X POST \
|
|
"http://127.0.0.1:${lemonade_port}/api/v1/chat/completions" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"model\":\"${model_id}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1,\"temperature\":0,\"stream\":false}" \
|
|
>/dev/null 2>&1; then
|
|
log "Lemonade refreshed with only the full model advertised: ${model_id}"
|
|
return 0
|
|
fi
|
|
log "Lemonade lists ${model_id} after cleanup, but completion is not ready yet (attempt $_i/60)."
|
|
else
|
|
log "Waiting for Lemonade to drop bootstrap metadata after cleanup (attempt $_i/60)."
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
# Background monitor: polls .part file size every 2s
|
|
monitor_download() {
|
|
local part_file="$1" total_bytes="$2"
|
|
|
|
# Wait for curl to create the .part file (up to 30s)
|
|
for _wait in $(seq 1 30); do
|
|
[[ -f "$part_file" ]] && break
|
|
sleep 1
|
|
done
|
|
[[ -f "$part_file" ]] || return 0
|
|
|
|
local prev_bytes=0 prev_time
|
|
prev_time=$(date +%s)
|
|
|
|
while [[ -f "$part_file" ]]; do
|
|
sleep 2
|
|
[[ -f "$part_file" ]] || break
|
|
|
|
local current_bytes
|
|
current_bytes=$(file_size "$part_file")
|
|
local now
|
|
now=$(date +%s)
|
|
local elapsed=$((now - prev_time))
|
|
|
|
local speed=0
|
|
if [[ $elapsed -gt 0 && $current_bytes -ge $prev_bytes ]]; then
|
|
speed=$(( (current_bytes - prev_bytes) / elapsed ))
|
|
fi
|
|
|
|
local percent="null"
|
|
local eta=""
|
|
local progress_bytes="$current_bytes"
|
|
if [[ $total_bytes -gt 0 ]]; then
|
|
[[ "$progress_bytes" -gt "$total_bytes" ]] && progress_bytes="$total_bytes"
|
|
percent=$(status_percent "$progress_bytes" "$total_bytes")
|
|
if [[ $speed -gt 0 ]]; then
|
|
local remaining=$(( total_bytes - progress_bytes ))
|
|
local eta_secs=$(( remaining / speed ))
|
|
local eta_min=$(( eta_secs / 60 ))
|
|
local eta_sec=$(( eta_secs % 60 ))
|
|
eta="${eta_min}m ${eta_sec}s"
|
|
else
|
|
eta="calculating..."
|
|
fi
|
|
fi
|
|
|
|
write_status "downloading" "$percent" "$progress_bytes" "$total_bytes" "$speed" "$eta"
|
|
prev_bytes=$current_bytes
|
|
prev_time=$now
|
|
done
|
|
}
|
|
|
|
# ── Docker permission detection ──
|
|
# This script runs detached via nohup, so DOCKER_CMD from the parent installer
|
|
# is not inherited. For Linux installs we MUST be able to talk to the docker
|
|
# daemon — silently failing here leaves the user running the small bootstrap
|
|
# model forever. macOS installs use a native llama-server PID file and never
|
|
# enter the docker hot-swap path; skip detection there. Mirrors the
|
|
# sudo-fallback pattern in installers/phases/05-docker.sh.
|
|
DOCKER_CMD=""
|
|
DOCKER_COMPOSE_CMD=""
|
|
if command -v docker >/dev/null 2>&1; then
|
|
if docker info >/dev/null 2>&1; then
|
|
DOCKER_CMD="docker"
|
|
elif command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then
|
|
DOCKER_CMD="sudo docker"
|
|
log "Detected docker requires sudo (user not in docker group). Using 'sudo docker'."
|
|
elif [[ ! -f "$INSTALL_DIR/data/.llama-server.pid" ]]; then
|
|
# Linux install: docker is the only hot-swap path. Failing silently
|
|
# would leave the bootstrap model running forever — fail loudly.
|
|
log "ERROR: docker is installed but not accessible by this user."
|
|
log " Tried 'docker info' and 'sudo -n docker info' — both failed."
|
|
log " The bootstrap model will continue running. Fix one of:"
|
|
log " 1. Re-login (so 'docker' group membership takes effect), then re-run this script."
|
|
log " 2. Configure passwordless sudo for 'docker' (e.g. NOPASSWD in /etc/sudoers.d)."
|
|
write_status "failed"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$DOCKER_CMD" ]]; then
|
|
# Pick docker compose v2 (plugin) if available, else legacy docker-compose v1.
|
|
if $DOCKER_CMD compose version >/dev/null 2>&1; then
|
|
DOCKER_COMPOSE_CMD="$DOCKER_CMD compose"
|
|
elif command -v docker-compose >/dev/null 2>&1; then
|
|
if [[ "$DOCKER_CMD" == "sudo docker" ]]; then
|
|
DOCKER_COMPOSE_CMD="sudo docker-compose"
|
|
else
|
|
DOCKER_COMPOSE_CMD="docker-compose"
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
log "Starting full model download: $FULL_GGUF_FILE"
|
|
log "URL: $FULL_GGUF_URL"
|
|
log "Target: $MODELS_DIR/$FULL_GGUF_FILE"
|
|
|
|
# ── Phase 1: Download the full model ──
|
|
mkdir -p "$MODELS_DIR"
|
|
acquire_upgrade_lock
|
|
|
|
# Get total file size for progress calculation
|
|
TOTAL_BYTES=$(get_remote_size "$FULL_GGUF_URL")
|
|
[[ -z "$TOTAL_BYTES" ]] && TOTAL_BYTES=0
|
|
log "Expected file size: $TOTAL_BYTES bytes"
|
|
|
|
# Write initial status
|
|
write_status "starting" "" 0 "$TOTAL_BYTES" 0 "calculating..."
|
|
|
|
_part_path="$MODELS_DIR/$FULL_GGUF_FILE.part"
|
|
_final_path="$MODELS_DIR/$FULL_GGUF_FILE"
|
|
_dl_success=false
|
|
_download_attempts="${ODS_BOOTSTRAP_DOWNLOAD_ATTEMPTS:-6}"
|
|
case "$_download_attempts" in
|
|
''|*[!0-9]*|0) _download_attempts=6 ;;
|
|
esac
|
|
|
|
if [[ -f "$_final_path" ]]; then
|
|
log "Full model already exists on disk; verifying before reuse"
|
|
if verify_model_integrity "$_final_path"; then
|
|
_dl_success=true
|
|
write_status "verifying" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
|
|
else
|
|
log "Existing full model failed integrity; deleting and retrying from a clean file."
|
|
rm -f "$_final_path"
|
|
fi
|
|
fi
|
|
|
|
if [[ -f "$_part_path" && "$TOTAL_BYTES" -gt 0 ]]; then
|
|
_part_bytes="$(file_size "$_part_path")"
|
|
if [[ "$_part_bytes" -gt "$TOTAL_BYTES" ]]; then
|
|
log "Existing partial is larger than the remote file (got $_part_bytes, expected $TOTAL_BYTES); deleting corrupt resume state."
|
|
rm -f "$_part_path"
|
|
fi
|
|
fi
|
|
|
|
if [[ "$_dl_success" != "true" ]]; then
|
|
monitor_download "$_part_path" "$TOTAL_BYTES" &
|
|
_monitor_pid=$!
|
|
trap 'kill $_monitor_pid 2>/dev/null || true; write_failed_download_status "$_part_path" "$TOTAL_BYTES" "Download interrupted; partial file preserved for resume."; release_upgrade_lock; exit 1' TERM INT
|
|
|
|
# Download with resume support. curl success is not enough: finalizing the
|
|
# .part file can fail, and checksum verification can expose a corrupt
|
|
# resume. Keep retries inside the script so the detached upgrade can
|
|
# recover without leaving the user on the bootstrap model.
|
|
for ((_attempt=1; _attempt<=_download_attempts; _attempt++)); do
|
|
[[ $_attempt -gt 1 ]] && log "Retry attempt $_attempt of $_download_attempts..." && sleep 5
|
|
|
|
if [[ -f "$_part_path" && "$TOTAL_BYTES" -gt 0 ]]; then
|
|
_part_bytes="$(file_size "$_part_path")"
|
|
if [[ "$_part_bytes" -gt "$TOTAL_BYTES" ]]; then
|
|
log "Partial file grew larger than expected before attempt $_attempt (got $_part_bytes, expected $TOTAL_BYTES); deleting corrupt resume state."
|
|
rm -f "$_part_path"
|
|
elif [[ "$_part_bytes" -eq "$TOTAL_BYTES" ]]; then
|
|
log "Partial file already has the expected size; promoting it for integrity verification."
|
|
if ! mv "$_part_path" "$_final_path"; then
|
|
log "Download attempt $_attempt failed while finalizing $_part_path -> $_final_path"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [[ ! -f "$_final_path" ]]; then
|
|
# Let this script own retry/resume. curl's internal retry path can
|
|
# restart the transfer from byte zero after a long connection reset,
|
|
# truncating an otherwise good multi-GB .part file.
|
|
if curl -fSL -C - --connect-timeout 30 --speed-time 300 --speed-limit 1024 \
|
|
-o "$_part_path" "$FULL_GGUF_URL" 2>&1; then
|
|
if [[ ! -s "$_part_path" ]]; then
|
|
log "Download attempt $_attempt reported success but produced no partial file: $_part_path"
|
|
elif mv "$_part_path" "$_final_path"; then
|
|
:
|
|
else
|
|
log "Download attempt $_attempt failed while finalizing $_part_path -> $_final_path"
|
|
fi
|
|
else
|
|
log "Download attempt $_attempt failed"
|
|
fi
|
|
fi
|
|
|
|
if [[ ! -s "$_final_path" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$TOTAL_BYTES" -gt 0 ]]; then
|
|
ACTUAL_BYTES=$(file_size "$_final_path")
|
|
if [[ "$ACTUAL_BYTES" -lt "$TOTAL_BYTES" ]]; then
|
|
mv "$_final_path" "$_part_path" 2>/dev/null || rm -f "$_final_path"
|
|
log "Downloaded model is smaller than expected (got $ACTUAL_BYTES, expected $TOTAL_BYTES); preserving as partial for retry."
|
|
continue
|
|
fi
|
|
if [[ "$ACTUAL_BYTES" -gt "$TOTAL_BYTES" ]]; then
|
|
rm -f "$_final_path" "$_part_path"
|
|
log "Downloaded model is larger than expected (got $ACTUAL_BYTES, expected $TOTAL_BYTES); deleting corrupt file and retrying from scratch."
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
write_status "verifying" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
|
|
log "Download complete: $FULL_GGUF_FILE"
|
|
if verify_model_integrity "$_final_path"; then
|
|
_dl_success=true
|
|
break
|
|
fi
|
|
|
|
rm -f "$_final_path" "$_part_path"
|
|
log "Integrity verification failed on attempt $_attempt; retrying from a clean download."
|
|
done
|
|
|
|
kill $_monitor_pid 2>/dev/null || true
|
|
trap - TERM INT
|
|
|
|
if [[ "$_dl_success" != "true" ]]; then
|
|
write_failed_download_status "$_part_path" "$TOTAL_BYTES" "Download failed after $_download_attempts attempts; partial file preserved for resume."
|
|
fail "Download failed after $_download_attempts attempts. Preserved partial file for resume: $_part_path. Bootstrap model will continue running."
|
|
fi
|
|
fi
|
|
|
|
# ── Phase 2: Verify integrity (if SHA256 provided) ──
|
|
if [[ -n "$FULL_GGUF_SHA256" ]]; then
|
|
write_status "verifying" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
|
|
log "Verifying SHA256..."
|
|
if ! verify_model_integrity "$MODELS_DIR/$FULL_GGUF_FILE"; then
|
|
rm -f "$MODELS_DIR/$FULL_GGUF_FILE"
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Downloaded model failed SHA256 after retries. Corrupt file deleted; bootstrap model left running."
|
|
fail "SHA256 mismatch after retries. Deleted corrupt file."
|
|
fi
|
|
fi
|
|
|
|
_windows_lemonade_swap_applies=false
|
|
_windows_native_llama_swap_applies=false
|
|
_docker_llama_swap_applies=false
|
|
if is_windows_bash; then
|
|
_runtime_for_swap="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
|
|
_backend_for_swap="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
|
|
_managed_for_swap="$(read_env_value AMD_INFERENCE_MANAGED | tr '[:upper:]' '[:lower:]')"
|
|
_runtime_mode_for_swap="$(read_env_value AMD_INFERENCE_RUNTIME_MODE | tr '[:upper:]' '[:lower:]')"
|
|
_location_for_swap="$(read_env_value AMD_INFERENCE_LOCATION | tr '[:upper:]' '[:lower:]')"
|
|
if [[ "$_runtime_for_swap" == "lemonade" || "$_backend_for_swap" == "lemonade" ]]; then
|
|
_windows_lemonade_swap_applies=true
|
|
elif [[ "$_managed_for_swap" != "false" && "$_location_for_swap" != "external" ]] \
|
|
&& [[ "$_runtime_mode_for_swap" == "windows-llama-server-fallback" || ( "$_runtime_for_swap" == "llama-server" && "$_location_for_swap" == "host" ) ]]; then
|
|
_windows_native_llama_swap_applies=true
|
|
fi
|
|
elif [[ -n "$DOCKER_CMD" ]]; then
|
|
# Linux Docker installs mutate .env/models.ini before attempting a
|
|
# llama-server hot-swap. Snapshot whenever Docker is available so every
|
|
# Docker failure path can restore the last known-good model config.
|
|
_docker_llama_swap_applies=true
|
|
fi
|
|
|
|
if [[ "$_windows_lemonade_swap_applies" == "true" || "$_windows_native_llama_swap_applies" == "true" || "$_docker_llama_swap_applies" == "true" ]]; then
|
|
log "Snapshotting active model config before full-model swap..."
|
|
if ! snapshot_active_model_config; then
|
|
discard_active_model_config_snapshot
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model downloaded and verified, but ODS could not snapshot active model config before swap. Bootstrap model left unchanged; re-run to retry."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# ── Phase 3: Update .env ──
|
|
write_status "swapping" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
|
|
log "Updating .env..."
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
# Update GGUF_FILE
|
|
if grep -q '^GGUF_FILE=' "$ENV_FILE"; then
|
|
awk -v v="$FULL_GGUF_FILE" '{ if (index($0, "GGUF_FILE=") == 1) print "GGUF_FILE=" v; else print }' \
|
|
"$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
|
|
fi
|
|
# Update LLM_MODEL
|
|
if grep -q '^LLM_MODEL=' "$ENV_FILE"; then
|
|
awk -v v="$FULL_LLM_MODEL" '{ if (index($0, "LLM_MODEL=") == 1) print "LLM_MODEL=" v; else print }' \
|
|
"$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
|
|
fi
|
|
# Update MAX_CONTEXT / CTX_SIZE
|
|
if grep -q '^MAX_CONTEXT=' "$ENV_FILE"; then
|
|
awk -v v="$FULL_MAX_CONTEXT" '{ if (index($0, "MAX_CONTEXT=") == 1) print "MAX_CONTEXT=" v; else print }' \
|
|
"$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
|
|
fi
|
|
if grep -q '^CTX_SIZE=' "$ENV_FILE"; then
|
|
awk -v v="$FULL_MAX_CONTEXT" '{ if (index($0, "CTX_SIZE=") == 1) print "CTX_SIZE=" v; else print }' \
|
|
"$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
|
|
fi
|
|
log ".env updated"
|
|
else
|
|
fail ".env not found at $ENV_FILE"
|
|
fi
|
|
|
|
# ── Phase 4: Update models.ini ──
|
|
log "Updating models.ini..."
|
|
mkdir -p "$(dirname "$MODELS_INI")"
|
|
cat > "$MODELS_INI" << EOF
|
|
[${FULL_LLM_MODEL}]
|
|
filename = ${FULL_GGUF_FILE}
|
|
load-on-startup = true
|
|
n-ctx = ${FULL_MAX_CONTEXT}
|
|
EOF
|
|
log "models.ini updated"
|
|
|
|
BOOTSTRAP_GGUF="${BOOTSTRAP_GGUF_FILE:-Qwen3.5-2B-Q4_K_M.gguf}"
|
|
BOOTSTRAP_PATH="$MODELS_DIR/$BOOTSTRAP_GGUF"
|
|
HOT_SWAP_VERIFIED=false
|
|
|
|
# ── Phase 5: Hot-swap llama-server (if running) ──
|
|
# Read OLLAMA_PORT from .env (nohup doesn't inherit env vars from parent)
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
OLLAMA_PORT=$(grep -E '^OLLAMA_PORT=' "$ENV_FILE" | cut -d= -f2 | tr -d '"\047\r')
|
|
fi
|
|
|
|
if [[ "$_windows_lemonade_swap_applies" == "true" ]]; then
|
|
if ! move_bootstrap_model_aside_for_windows_swap; then
|
|
restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model downloaded and verified, but ODS could not move the bootstrap model aside before the Windows Lemonade swap. Previous active model config restored; re-run to retry."
|
|
exit 1
|
|
fi
|
|
|
|
if restart_windows_lemonade_with_full_model; then
|
|
if ! patch_hermes_model_after_swap; then
|
|
# The full model downloaded, verified, and loaded; only the Hermes
|
|
# config patch failed. Report the real byte counts and the actual
|
|
# cause, not a bare write_status "failed" that zeroes the byte fields
|
|
# and reads as a 0-byte download failure (#1517).
|
|
log "Restoring previous active model config after Hermes patch failure..."
|
|
restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
|
|
restore_bootstrap_model_after_windows_swap_failure || log "WARNING: could not restore bootstrap model; inspect $BOOTSTRAP_PATH"
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model downloaded and loaded, but the Hermes config patch failed after swap. Previous active model config restored; re-run to retry."
|
|
exit 1
|
|
fi
|
|
HOT_SWAP_VERIFIED=true
|
|
discard_active_model_config_snapshot
|
|
discard_bootstrap_model_backup_after_windows_swap
|
|
else
|
|
# The model downloaded and verified (byte counts are real); the native
|
|
# Windows Lemonade swap did not register/load it within the wait window,
|
|
# so the bootstrap model is kept. Report the real bytes + cause instead
|
|
# of a bare write_status "failed" that zeroes bytesDownloaded/bytesTotal
|
|
# and misreads as a 0-byte download failure (#1517).
|
|
log "Restoring previous active model config after Windows Lemonade swap timeout..."
|
|
restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
|
|
restore_bootstrap_model_after_windows_swap_failure || log "WARNING: could not restore bootstrap model; inspect $BOOTSTRAP_PATH"
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Model downloaded and verified, but native Windows Lemonade did not load it after swap (registration timeout). Previous active model config restored and bootstrap model kept; re-run to retry the swap."
|
|
exit 1
|
|
fi
|
|
elif [[ "$_windows_native_llama_swap_applies" == "true" ]]; then
|
|
if restart_windows_native_llama_server_with_full_model; then
|
|
if ! patch_hermes_model_after_swap; then
|
|
log "Restoring previous active model config after Hermes patch failure..."
|
|
restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model downloaded and loaded in native Windows llama-server, but the Hermes config patch failed after swap. Previous active model config restored; re-run to retry."
|
|
exit 1
|
|
fi
|
|
if ! refresh_windows_native_litellm_local_config_after_swap; then
|
|
log "Restoring previous active model config after LiteLLM config refresh failure..."
|
|
restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model downloaded and loaded in native Windows llama-server, but the LiteLLM local config refresh failed after swap. Previous active model config restored; re-run to retry."
|
|
exit 1
|
|
fi
|
|
HOT_SWAP_VERIFIED=true
|
|
discard_active_model_config_snapshot
|
|
else
|
|
log "Restoring previous active model config after native Windows llama-server swap timeout..."
|
|
restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Model downloaded and verified, but native Windows llama-server did not load it after swap. Previous active model config restored and bootstrap model kept; re-run to retry the swap."
|
|
exit 1
|
|
fi
|
|
elif [[ -n "$DOCKER_CMD" ]] && $DOCKER_CMD ps --filter name=ods-llama-server --format '{{.Names}}' 2>/dev/null | grep -q ods-llama-server; then
|
|
log "Restarting llama-server with full model..."
|
|
|
|
# Read GPU backend from .env (needed for health endpoint and restart strategy)
|
|
_gpu_backend=""
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
_gpu_backend=$(grep -E '^GPU_BACKEND=' "$ENV_FILE" | cut -d= -f2 | tr -d '"\047\r')
|
|
fi
|
|
|
|
# Detect compose files
|
|
COMPOSE_ARGS=()
|
|
if [[ -f "$INSTALL_DIR/.compose-flags" ]]; then
|
|
read -ra COMPOSE_ARGS <<< "$(cat "$INSTALL_DIR/.compose-flags")"
|
|
elif [[ -x "$INSTALL_DIR/scripts/resolve-compose-stack.sh" ]]; then
|
|
_tier="1"
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
_tier=$(grep -E '^TIER=' "$ENV_FILE" | cut -d= -f2 | tr -d '"\047\r')
|
|
[[ -n "$_tier" ]] || _tier="1"
|
|
fi
|
|
_resolved_env=$("$INSTALL_DIR/scripts/resolve-compose-stack.sh" \
|
|
--script-dir "$INSTALL_DIR" \
|
|
--tier "$_tier" \
|
|
--gpu-backend "${_gpu_backend:-cpu}" \
|
|
--env 2>/dev/null || true)
|
|
_resolved_flags=$(printf '%s\n' "$_resolved_env" | sed -n 's/^COMPOSE_FLAGS="\([^"]*\)".*/\1/p')
|
|
if [[ -n "$_resolved_flags" ]]; then
|
|
read -ra COMPOSE_ARGS <<< "$_resolved_flags"
|
|
printf '%s\n' "$_resolved_flags" > "$INSTALL_DIR/.compose-flags"
|
|
log "Recovered compose flags via resolve-compose-stack.sh"
|
|
fi
|
|
elif [[ -f "$INSTALL_DIR/docker-compose.base.yml" ]]; then
|
|
COMPOSE_ARGS=(-f "$INSTALL_DIR/docker-compose.base.yml")
|
|
case "${_gpu_backend}" in
|
|
nvidia) [[ -f "$INSTALL_DIR/docker-compose.nvidia.yml" ]] && COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.nvidia.yml") ;;
|
|
amd) [[ -f "$INSTALL_DIR/docker-compose.amd.yml" ]] && COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.amd.yml") ;;
|
|
apple)
|
|
# On Darwin hosts the canonical macOS overlay lives at
|
|
# installers/macos/docker-compose.macos.yml (native Metal llama-server
|
|
# replicas: 0, llama-server-ready sidecar, host.docker.internal for
|
|
# dashboard-api). The top-level docker-compose.apple.yml remains
|
|
# valid for Linux hosts that select --gpu-backend apple (Lemonade).
|
|
# Mirror the branch in scripts/resolve-compose-stack.sh so that the
|
|
# .compose-flags fallback selects the same overlay the resolver does.
|
|
if [[ "$(uname -s)" == "Darwin" && -f "$INSTALL_DIR/installers/macos/docker-compose.macos.yml" ]]; then
|
|
COMPOSE_ARGS+=(-f "$INSTALL_DIR/installers/macos/docker-compose.macos.yml")
|
|
elif [[ -f "$INSTALL_DIR/docker-compose.apple.yml" ]]; then
|
|
COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.apple.yml")
|
|
fi
|
|
;;
|
|
# cpu or unknown: base only, no GPU overlay
|
|
esac
|
|
fi
|
|
|
|
cd "$INSTALL_DIR" || fail "Cannot cd to $INSTALL_DIR"
|
|
|
|
# Restart llama-server — strategy depends on GPU backend:
|
|
# - AMD (Lemonade): use 'restart' to preserve cached llama-server build.
|
|
# Lemonade reads models.ini at startup, so it picks up the new model.
|
|
# - NVIDIA/CPU (llama.cpp): force-recreate so the new GGUF_FILE in .env
|
|
# takes effect. `compose stop` + `compose up -d` is NOT enough — when the
|
|
# service has a stopped container, compose will start the existing
|
|
# container in place (preserving its baked --model arg) instead of
|
|
# building a fresh one from the updated .env. The original CMD points at
|
|
# /models/${BOOTSTRAP_GGUF_FILE}, which Phase 4b just deleted, so the
|
|
# container crash-loops. `--force-recreate --no-deps` guarantees a new
|
|
# container; --no-deps avoids touching other services in the project.
|
|
log "Restarting llama-server container (backend: ${_gpu_backend:-unknown})..."
|
|
# Both backends need a container *recreate*, not just a restart, so the
|
|
# updated CTX_SIZE / MAX_CONTEXT / GGUF_FILE env values from the
|
|
# freshly-bumped .env land in the new container. A plain `compose
|
|
# restart` preserves the original env vars from when the container
|
|
# first started — which on AMD/Lemonade leaves LEMONADE_CTX_SIZE pinned
|
|
# at the bootstrap-tier value (e.g. 8192) even after .env says 131072.
|
|
# The lemonade-entrypoint.sh wrapper then sees the stale env value,
|
|
# never updates /root/.cache/lemonade/config.json, and Lemonade serves
|
|
# the full model at the bootstrap context size — Hermes then returns
|
|
# empty responses in ~1s because its 16k-token tool catalog overruns
|
|
# the 8k context window. OpenCode hits the same wall.
|
|
#
|
|
# `env -u` strips the model-config vars from compose's shell so the
|
|
# freshly-updated .env wins interpolation. Compose precedence is
|
|
# shell-env > .env > compose default, and Phase 11 (parent of this
|
|
# nohup'd script) sets the bootstrap-tier values as shell variables.
|
|
#
|
|
# Named volumes (lemonade-cache / lemonade-llama / lemonade-recipe on
|
|
# AMD) survive --force-recreate, so the Lemonade binary cache + HF
|
|
# cache + recipe state all persist across the recreate. The older
|
|
# "AMD uses restart to preserve cached binary" comment was wrong —
|
|
# named volumes are decoupled from the container lifecycle.
|
|
if [[ ${#COMPOSE_ARGS[@]} -gt 0 && -n "$DOCKER_COMPOSE_CMD" ]]; then
|
|
compose_recreate_llama_server_with_retry "${COMPOSE_ARGS[@]}" || \
|
|
log "WARNING: llama-server recreate command failed after retries; continuing to health check before declaring failure."
|
|
else
|
|
# No reliable compose stack is available. Do NOT stop/remove the
|
|
# currently-running bootstrap container here: leaving an old but
|
|
# serving model online is safer than turning a completed download into
|
|
# an outage. The operator can repair the compose cache or re-run the
|
|
# installer; both paths can recreate llama-server from the updated .env.
|
|
log "WARNING: unable to recover compose flags — leaving the current llama-server container untouched."
|
|
log "Manual recovery: re-run the installer, or restore $INSTALL_DIR/.compose-flags and run the ODS CLI restart command."
|
|
restore_active_model_config || log "WARNING: could not restore previous active model config; inspect $ENV_FILE and $MODELS_INI"
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model downloaded and verified, but ODS could not recover compose flags for Docker hot-swap. Previous active model config restored; re-run to retry."
|
|
exit 1
|
|
fi
|
|
|
|
# Pick health endpoint based on GPU backend — Lemonade (AMD) serves
|
|
# /api/v1/health, llama.cpp (NVIDIA/Apple/CPU) serves /health.
|
|
if [[ "$_gpu_backend" == "amd" ]]; then
|
|
_health_url="http://127.0.0.1:${OLLAMA_PORT:-8080}/api/v1/health"
|
|
else
|
|
_health_url="http://127.0.0.1:${OLLAMA_PORT:-8080}/health"
|
|
fi
|
|
|
|
# Wait for health (up to 5 minutes for the larger model to load)
|
|
# For AMD/Lemonade: check that model_loaded is non-null in the JSON response.
|
|
# Lemonade returns 200 with "model_loaded": null when no model is loaded yet.
|
|
# Lemonade doesn't auto-load models from models.ini — it uses --extra-models-dir
|
|
# for discovery but loads on-demand. We send a warm-up request to trigger loading.
|
|
# For llama.cpp: a simple 200 check is sufficient — the server only starts
|
|
# after loading the model specified in --model.
|
|
log "Waiting for llama-server health at $_health_url ..."
|
|
_healthy=false
|
|
_warmup_sent=false
|
|
for _i in $(seq 1 60); do
|
|
_resp=$(curl -sf --max-time 5 "$_health_url" 2>/dev/null || echo "")
|
|
if [[ -n "$_resp" ]]; then
|
|
if [[ "$_gpu_backend" == "amd" ]]; then
|
|
# Lemonade: verify a model is actually loaded, not just "status: ok"
|
|
if echo "$_resp" | grep -q '"model_loaded"' && ! echo "$_resp" | grep -q '"model_loaded": *null'; then
|
|
_healthy=true
|
|
break
|
|
fi
|
|
# Lemonade is healthy but no model loaded — send a warm-up request
|
|
# to trigger on-demand loading of the new model. Lemonade caches the
|
|
# previously-loaded model name across restarts, which fails after the
|
|
# bootstrap GGUF is deleted. This request forces it to load the new one.
|
|
# Retry every 15s — the first request may fail if Lemonade isn't fully
|
|
# ready to accept chat completions yet.
|
|
if [[ "$_warmup_sent" == "false" ]] || (( _i % 3 == 0 )); then
|
|
# Escape any double-quotes in the filename so the JSON body
|
|
# below stays well-formed even for non-standard library entries.
|
|
# Mirrors the _safe_model pattern in write_status() above.
|
|
_model_id="extra.${FULL_GGUF_FILE//\"/\\\"}"
|
|
log "Sending warm-up request to trigger model loading: $_model_id (attempt $_i/60)"
|
|
if curl -sf --max-time 30 -X POST \
|
|
"http://127.0.0.1:${OLLAMA_PORT:-8080}/api/v1/chat/completions" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"model\":\"${_model_id}\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"max_tokens\":1}" \
|
|
&>/dev/null; then
|
|
_warmup_sent=true
|
|
log "Warm-up request accepted — waiting for model to finish loading"
|
|
fi
|
|
fi
|
|
log "Lemonade healthy but no model loaded yet (attempt $_i/60)"
|
|
else
|
|
# llama.cpp: 200 means model is loaded
|
|
_healthy=true
|
|
break
|
|
fi
|
|
fi
|
|
if [[ "$_gpu_backend" != "amd" ]] && docker_llama_server_container_failed_after_swap; then
|
|
log "llama-server container exited or is restarting while loading the full model; treating Docker hot-swap as failed."
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
# Assert the recreated container's --model arg actually points at the new
|
|
# GGUF file. If compose handed us back a started-not-recreated container
|
|
# (the bug --force-recreate above is meant to prevent), the health check
|
|
# may still pass on Lemonade (which loads on demand) but llama.cpp will
|
|
# crash-loop the moment the next request hits, because Phase 4b has
|
|
# already deleted the bootstrap GGUF the baked CMD refers to. Fail loudly
|
|
# so the operator does not discover this hours later via a 502.
|
|
if [[ "$_gpu_backend" != "amd" ]] && [[ -n "$DOCKER_CMD" ]]; then
|
|
_running_cmd=$($DOCKER_CMD inspect ods-llama-server --format '{{join .Config.Cmd " "}}' 2>/dev/null || echo "")
|
|
if [[ -z "$_running_cmd" ]]; then
|
|
log "ERROR: could not inspect llama-server container command after recreate."
|
|
log " Recover with: cd $INSTALL_DIR && docker compose \$(cat .compose-flags) up -d --force-recreate --no-deps llama-server"
|
|
write_status "failed"
|
|
fail "llama-server command inspection failed after force-recreate."
|
|
elif ! [[ "$_running_cmd" == *"/models/${FULL_GGUF_FILE}"* ]]; then
|
|
log "ERROR: llama-server container started with stale --model arg."
|
|
log " expected /models/${FULL_GGUF_FILE}, got: $_running_cmd"
|
|
log " This means 'compose up -d --force-recreate' did not pick up the updated .env."
|
|
# Dump what compose would have seen so a future regression can be
|
|
# diagnosed from logs alone. If any of these have non-empty values,
|
|
# they overrode the .env at compose interpolation time.
|
|
for _k in GGUF_FILE LLM_MODEL MAX_CONTEXT CTX_SIZE; do
|
|
_v="$(printenv "$_k" 2>/dev/null || true)"
|
|
if [[ -n "$_v" ]]; then
|
|
log " shell env leak: $_k=$_v (overrode .env's $_k)"
|
|
fi
|
|
done
|
|
log " .env now has:"
|
|
for _k in GGUF_FILE LLM_MODEL MAX_CONTEXT CTX_SIZE; do
|
|
log " $(grep -E "^${_k}=" "$ENV_FILE" 2>/dev/null || echo "${_k}=<missing>")"
|
|
done
|
|
log " Recover with: cd $INSTALL_DIR && env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE docker compose \$(cat .compose-flags) up -d --force-recreate --no-deps llama-server"
|
|
write_status "failed"
|
|
fail "llama-server container started with stale --model arg after force-recreate."
|
|
fi
|
|
fi
|
|
|
|
if $_healthy; then
|
|
log "SUCCESS: llama-server is running with $FULL_LLM_MODEL"
|
|
HOT_SWAP_VERIFIED=true
|
|
discard_active_model_config_snapshot
|
|
# Regenerate lemonade.yaml with the new model ID and restart LiteLLM.
|
|
# Lemonade exposes models as "extra.<GGUF_FILE>" — the config must
|
|
# reference the exact ID, not a wildcard passthrough.
|
|
if $DOCKER_CMD ps --filter name=ods-litellm --format '{{.Names}}' 2>/dev/null | grep -q ods-litellm; then
|
|
log "Updating LiteLLM config for new model: extra.${FULL_GGUF_FILE}"
|
|
# Read per-install lemonade key from .env; fall back to literal so
|
|
# older installs without the key still produce a valid config (lemonade
|
|
# itself ignores the value).
|
|
LITELLM_LEMONADE_API_KEY=$(grep '^LITELLM_LEMONADE_API_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"\047\r')
|
|
: "${LITELLM_LEMONADE_API_KEY:=sk-lemonade}"
|
|
# extra_body.chat_template_kwargs.enable_thinking=false is what
|
|
# un-blocks Perplexica + any other client that doesn't manually
|
|
# prepend `/no_think` to its prompts. Qwen3 thinking models
|
|
# (qwen3-coder-next, Qwen3.6-35B-A3B, Qwen3-30B-A3B) emit a
|
|
# <think>...</think> reasoning block before the answer, which on
|
|
# Strix Halo with a long Perplexica synthesis prompt can run for
|
|
# minutes — the user sees the GPU spin up but no tokens reach the
|
|
# UI until the close-think tag fires. Passing
|
|
# `chat_template_kwargs: {enable_thinking: false}` to llama.cpp /
|
|
# Lemonade hits the Qwen3 chat template's enable_thinking switch
|
|
# and skips the reasoning block entirely.
|
|
#
|
|
# Verified 2026-05-20 on Strix Halo (AMD/Lemonade): same prompt
|
|
# went from "hangs indefinitely" to 1.8s end-to-end with this
|
|
# one block added. The kwarg is a Qwen3-specific switch and is
|
|
# safely ignored by non-Qwen3 chat templates.
|
|
_lemonade_api_base="http://llama-server:8080/api/v1"
|
|
_amd_location="$(read_env_value AMD_INFERENCE_LOCATION | tr '[:upper:]' '[:lower:]')"
|
|
_amd_port="$(read_env_value AMD_INFERENCE_PORT)"
|
|
: "${_amd_port:=8080}"
|
|
if [[ "$_amd_location" == "host" ]]; then
|
|
_lemonade_api_base="http://host.docker.internal:${_amd_port}/api/v1"
|
|
fi
|
|
_renderer_ok=false
|
|
_renderer_script="$INSTALL_DIR/scripts/render-runtime-configs.py"
|
|
_renderer_py="${ODS_PYTHON_CMD:-}"
|
|
if [[ -z "$_renderer_py" && -f "$INSTALL_DIR/lib/python-cmd.sh" ]]; then
|
|
. "$INSTALL_DIR/lib/python-cmd.sh"
|
|
_renderer_py="$(ods_detect_python_cmd 2>/dev/null || true)"
|
|
fi
|
|
if [[ -z "$_renderer_py" ]]; then
|
|
_renderer_py="python3"
|
|
fi
|
|
if [[ -f "$_renderer_script" ]] && command -v "$_renderer_py" >/dev/null 2>&1; then
|
|
if "$_renderer_py" "$_renderer_script" \
|
|
--surface litellm-lemonade \
|
|
--ods-mode lemonade \
|
|
--gpu-backend amd \
|
|
--gguf-file "$FULL_GGUF_FILE" \
|
|
--lemonade-api-base "$_lemonade_api_base" \
|
|
--litellm-key "$LITELLM_LEMONADE_API_KEY" \
|
|
--output-root "$INSTALL_DIR" \
|
|
--write >/dev/null 2>&1; then
|
|
_renderer_ok=true
|
|
else
|
|
log "WARNING: Runtime config renderer failed for LiteLLM; falling back to inline writer"
|
|
fi
|
|
fi
|
|
if [[ "$_renderer_ok" != "true" ]]; then
|
|
cat > "$INSTALL_DIR/config/litellm/lemonade.yaml" << LITELLM_UPGRADE_EOF
|
|
model_list:
|
|
- model_name: default
|
|
litellm_params:
|
|
model: openai/extra.${FULL_GGUF_FILE}
|
|
api_base: ${_lemonade_api_base}
|
|
api_key: ${LITELLM_LEMONADE_API_KEY}
|
|
extra_body:
|
|
chat_template_kwargs:
|
|
enable_thinking: false
|
|
|
|
- model_name: "*"
|
|
litellm_params:
|
|
model: openai/extra.${FULL_GGUF_FILE}
|
|
api_base: ${_lemonade_api_base}
|
|
api_key: ${LITELLM_LEMONADE_API_KEY}
|
|
extra_body:
|
|
chat_template_kwargs:
|
|
enable_thinking: false
|
|
|
|
litellm_settings:
|
|
drop_params: true
|
|
set_verbose: false
|
|
request_timeout: 900
|
|
stream_timeout: 900
|
|
LITELLM_UPGRADE_EOF
|
|
fi
|
|
unset _renderer_ok _renderer_script _renderer_py _lemonade_api_base _amd_location _amd_port
|
|
log "Restarting LiteLLM to pick up model change..."
|
|
$DOCKER_CMD restart ods-litellm 2>&1 || log "WARNING: LiteLLM restart failed (non-fatal)"
|
|
fi
|
|
# Recreate OpenClaw so inject-token.js picks up the new GGUF_FILE/LLM_MODEL
|
|
# from .env. A restart alone won't work — env vars are baked in at container
|
|
# creation time, and inject-token.js builds the Lemonade model name from them.
|
|
# Strip the same model-config vars here as the llama-server recreate path
|
|
# so shell-env pollution cannot override the freshly-updated .env.
|
|
if $DOCKER_CMD ps --filter name=ods-openclaw --format '{{.Names}}' 2>/dev/null | grep -q ods-openclaw; then
|
|
log "Recreating OpenClaw to pick up model change..."
|
|
# Guard on BOTH compose args AND a non-empty $DOCKER_COMPOSE_CMD —
|
|
# mirrors the llama-server hot-swap contract above (the
|
|
# `${#COMPOSE_ARGS[@]} -gt 0 && -n "$DOCKER_COMPOSE_CMD"` checks).
|
|
# If $DOCKER_COMPOSE_CMD is empty (no compose v2 plugin AND no
|
|
# docker-compose v1 binary), expanding it as the command word
|
|
# would turn the line into `"${COMPOSE_ARGS[@]}" up -d ...`,
|
|
# which executes the first compose-arg (e.g. `-f`) as a binary.
|
|
# Skip the recreate and surface a clear warning instead.
|
|
if [[ ${#COMPOSE_ARGS[@]} -gt 0 && -n "$DOCKER_COMPOSE_CMD" ]]; then
|
|
env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE \
|
|
$DOCKER_COMPOSE_CMD "${COMPOSE_ARGS[@]}" up -d --force-recreate openclaw 2>&1 || \
|
|
log "WARNING: OpenClaw recreate failed (non-fatal)"
|
|
else
|
|
log "WARNING: No compose binary available (DOCKER_COMPOSE_CMD empty or compose args missing) — OpenClaw was NOT recreated. The new model will not take effect until OpenClaw is recreated manually with: env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE docker compose up -d --force-recreate openclaw"
|
|
fi
|
|
fi
|
|
# Patch Hermes Agent's config so it stops asking the LLM server for the
|
|
# bootstrap model id. PR #1191 substitutes model.default in the template
|
|
# at install time, but at install time we've only loaded the bootstrap
|
|
# model (Qwen3.5-2B) — Hermes's /opt/data/config.yaml is therefore
|
|
# pinned to that name. Once this script swaps Lemonade/llama-server
|
|
# to the full model, Hermes keeps sending the stale bootstrap id and
|
|
# every chat completion 404s.
|
|
#
|
|
# This is hard-broken on AMD/Lemonade (which strictly validates the
|
|
# `model` field) and silently masked on NVIDIA/Apple (llama.cpp
|
|
# ignores the field and serves whatever's loaded), so the bug
|
|
# surfaces as "Hermes works on Tower2/Mac but every prompt 404s on
|
|
# Strix Halo" after a bootstrap-to-full swap.
|
|
#
|
|
# Three files/views to keep in sync:
|
|
# 1. data/hermes/config.yaml on the host — the bind-mounted live
|
|
# config that persists across Hermes restarts.
|
|
# 2. /opt/data/config.yaml inside the container — the same live
|
|
# config from Hermes's view. Patch via docker exec too so Linux
|
|
# container-owned files can still be recovered.
|
|
# 3. extensions/services/hermes/cli-config.yaml.template — the
|
|
# source Hermes copies into /opt/data on first start. Updating
|
|
# it keeps subsequent down-and-up cycles correct.
|
|
# Lemonade prefixes the served model id with "extra."; llama.cpp
|
|
# serves under the bare file name. Mirror the same branch PR #1191
|
|
# added in installers/phases/11-services.sh.
|
|
_hermes_old_model="$BOOTSTRAP_GGUF_FILE"
|
|
_hermes_new_model="$FULL_GGUF_FILE"
|
|
_hermes_base_url="$(read_env_value HERMES_LLM_BASE_URL)"
|
|
_gpu_backend_for_hermes=$(grep -E '^GPU_BACKEND=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' || echo "")
|
|
if [[ "$_gpu_backend_for_hermes" == "amd" ]]; then
|
|
_hermes_old_model="extra.$BOOTSTRAP_GGUF_FILE"
|
|
_hermes_new_model="extra.$FULL_GGUF_FILE"
|
|
fi
|
|
log "Patching Hermes config: model.default $_hermes_old_model -> $_hermes_new_model"
|
|
_hermes_request_timeout=180
|
|
_hermes_llm_backend_for_timeout="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
|
|
if is_windows_bash || [[ "$_gpu_backend_for_hermes" == "amd" || "$_hermes_llm_backend_for_timeout" == "lemonade" ]]; then
|
|
_hermes_request_timeout=900
|
|
fi
|
|
|
|
# Template on host (user-owned, no sudo needed). Patch this even when
|
|
# Hermes is stopped so future container creates do not copy the stale
|
|
# bootstrap model id.
|
|
_hermes_tpl="$INSTALL_DIR/extensions/services/hermes/cli-config.yaml.template"
|
|
if [[ -f "$_hermes_tpl" ]]; then
|
|
if ! patch_hermes_yaml_with_sed "$_hermes_tpl" "$_hermes_new_model" "$FULL_MAX_CONTEXT" "$_hermes_base_url" "$_hermes_request_timeout"; then
|
|
log "WARNING: Could not patch ${_hermes_tpl} (non-fatal; operator can hand-edit before restarting Hermes)"
|
|
fi
|
|
fi
|
|
|
|
_hermes_live="$INSTALL_DIR/data/hermes/config.yaml"
|
|
_hermes_live_host_patched=false
|
|
if [[ -f "$_hermes_live" ]]; then
|
|
if patch_hermes_yaml_with_sed "$_hermes_live" "$_hermes_new_model" "$FULL_MAX_CONTEXT" "$_hermes_base_url" "$_hermes_request_timeout"; then
|
|
_hermes_live_host_patched=true
|
|
else
|
|
log "WARNING: Could not patch ${_hermes_live} on host (non-fatal if container patch below succeeds)"
|
|
fi
|
|
fi
|
|
|
|
if $DOCKER_CMD ps --filter name=ods-hermes --format '{{.Names}}' 2>/dev/null | grep -q ods-hermes; then
|
|
# Live config inside the running container (owned by container UID).
|
|
_hermes_new_model_sed="$(printf '%s' "$_hermes_new_model" | sed 's/[\\&|]/\\&/g')"
|
|
_hermes_base_url_sed="$(printf '%s' "$_hermes_base_url" | sed 's/[\\&|]/\\&/g')"
|
|
_hermes_live_patch="sed -i -e 's|^ default: \".*\"[[:space:]]*$| default: \"${_hermes_new_model_sed}\"|' -e 's|^ context_length: .*| context_length: ${FULL_MAX_CONTEXT}|' -e 's|^ context_length: .*| context_length: ${FULL_MAX_CONTEXT}|'"
|
|
if [[ -n "$_hermes_base_url" ]]; then
|
|
_hermes_live_patch="${_hermes_live_patch} -e 's|^ base_url: \".*\"| base_url: \"${_hermes_base_url_sed}\"|'"
|
|
fi
|
|
if [[ "$_hermes_request_timeout" != "180" ]]; then
|
|
_hermes_live_patch="${_hermes_live_patch} -e 's|^ request_timeout_seconds: 180[[:space:]]*$| request_timeout_seconds: ${_hermes_request_timeout}|'"
|
|
fi
|
|
_hermes_live_patch="${_hermes_live_patch} -e 's|^ enabled: .*| enabled: true|' -e 's|^ threshold: .*| threshold: 0.75|' -e 's|^ target_ratio: .*| target_ratio: 0.50|' -e 's|^ protect_last_n: .*| protect_last_n: 40|' /opt/data/config.yaml"
|
|
$DOCKER_CMD exec ods-hermes sh -c \
|
|
"$_hermes_live_patch" 2>&1 || \
|
|
log "WARNING: Could not patch Hermes /opt/data/config.yaml (non-fatal — operator can hand-edit and 'docker restart ods-hermes')"
|
|
log "Restarting Hermes to pick up model change..."
|
|
$DOCKER_CMD restart ods-hermes 2>&1 || log "WARNING: Hermes restart failed (non-fatal — hand-restart with 'docker restart ods-hermes')"
|
|
|
|
# Pre-warm the freshly-swapped LLM + Hermes's 14K-token system prompt.
|
|
#
|
|
# Two latency hits if we skip this:
|
|
# 1. llama-server / Lemonade loads the full model into VRAM on first
|
|
# request (`--n-gpu-layers 999` is lazy). PR #1192 already warms
|
|
# this at install time, but that warm-up was against the
|
|
# bootstrap model — after the swap, the slot is cold again.
|
|
# 2. Hermes's runtime config bakes a 14K-token system prompt
|
|
# (skills, soul, tool descriptors). First Hermes prompt has
|
|
# to prefill all of it. Empirically 67s on Strix Halo,
|
|
# 1m25s on macOS, ~5s once cached. We've seen real users
|
|
# think Hermes is broken because they alt-tabbed away during
|
|
# a fresh install and the first prompt looked stuck.
|
|
#
|
|
# Mirrors PR #1192's pattern: best-effort, time-bounded, never fails
|
|
# the upgrade. If either warm-up times out the swap still succeeds —
|
|
# the user just eats the slow first call.
|
|
log "Pre-warming llama-server slot with full model..."
|
|
_prewarm_api_path="/v1"
|
|
_prewarm_model="$FULL_GGUF_FILE"
|
|
if [[ "$_gpu_backend_for_hermes" == "amd" ]]; then
|
|
_prewarm_api_path="/api/v1"
|
|
_prewarm_model="extra.$FULL_GGUF_FILE"
|
|
fi
|
|
_prewarm_body="{\"model\":\"${_prewarm_model}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1,\"temperature\":0,\"stream\":false}"
|
|
if $DOCKER_CMD exec ods-hermes curl -sf --max-time 120 -X POST \
|
|
"http://llama-server:8080${_prewarm_api_path}/chat/completions" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$_prewarm_body" >/dev/null 2>&1; then
|
|
log "llama-server slot pre-warmed."
|
|
else
|
|
log "WARNING: llama-server pre-warm timed out — first Hermes prompt may be slow."
|
|
fi
|
|
|
|
# Wait for Hermes to come back up after the restart, then trigger
|
|
# one no-op invocation so the 14K system prompt gets into
|
|
# llama-server's KV cache. We cap at 90s total — long enough for
|
|
# Hermes's skills sync + config bootstrap (start_period: 60s in
|
|
# compose.yaml) plus a few decode tokens, short enough that a
|
|
# broken Hermes doesn't stall the script forever.
|
|
log "Pre-warming Hermes system prompt (caches 14K-token prefill)..."
|
|
_hermes_ready=false
|
|
for _i in $(seq 1 30); do
|
|
if $DOCKER_CMD exec ods-hermes curl -sf --max-time 3 http://127.0.0.1:9119/api/status >/dev/null 2>&1; then
|
|
_hermes_ready=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if $_hermes_ready; then
|
|
if $DOCKER_CMD exec ods-hermes timeout 90 \
|
|
/opt/hermes/.venv/bin/hermes -z "ping" --yolo \
|
|
>/dev/null 2>&1; then
|
|
log "Hermes system prompt cached — first user prompt will be fast."
|
|
else
|
|
log "WARNING: Hermes warm-up timed out (>90s). First user prompt will incur the full 14K-token prefill."
|
|
fi
|
|
else
|
|
log "WARNING: Hermes did not respond on /api/status within 60s; skipping system-prompt warm-up."
|
|
fi
|
|
else
|
|
if [[ -f "$_hermes_live" && "$_hermes_live_host_patched" != "true" ]]; then
|
|
log "WARNING: Hermes is stopped and ${_hermes_live} could not be patched; operator can hand-edit and restart Hermes"
|
|
fi
|
|
fi
|
|
sync_windows_opencode_config
|
|
else
|
|
log "WARNING: llama-server health check timed out. The model may still be loading."
|
|
log "Check: docker logs ods-llama-server"
|
|
_rollback_status="Previous active model config restore was attempted; inspect docker logs ods-llama-server and re-run to retry."
|
|
if restore_docker_llama_server_after_swap_failure "$_health_url"; then
|
|
_rollback_status="Previous active model config restored and llama-server is healthy; re-run to retry the full-model swap."
|
|
fi
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model downloaded and verified, but Docker llama-server did not become healthy after the hot-swap. ${_rollback_status}"
|
|
exit 1
|
|
fi
|
|
elif [[ -f "$INSTALL_DIR/data/.llama-server.pid" ]]; then
|
|
# macOS native llama-server (Metal) — restart with new model
|
|
log "Detected native llama-server (macOS Metal mode)"
|
|
|
|
LLAMA_SERVER_BIN="$INSTALL_DIR/bin/llama-server"
|
|
LLAMA_SERVER_PID_FILE="$INSTALL_DIR/data/.llama-server.pid"
|
|
LLAMA_SERVER_LOG="$INSTALL_DIR/data/llama-server.log"
|
|
|
|
if [[ ! -x "$LLAMA_SERVER_BIN" ]]; then
|
|
log "WARNING: llama-server binary not found at $LLAMA_SERVER_BIN. Cannot hot-swap."
|
|
log "Run './ods-macos.sh restart' to load the new model manually."
|
|
else
|
|
# Read updated model config from .env
|
|
_gguf_file=$(grep '^GGUF_FILE=' "$ENV_FILE" | cut -d= -f2 | tr -d '"'"'")
|
|
_ctx_size=$(grep '^CTX_SIZE=' "$ENV_FILE" | cut -d= -f2 | tr -d '"'"'" || echo "")
|
|
[[ -z "$_ctx_size" ]] && _ctx_size=$(grep '^MAX_CONTEXT=' "$ENV_FILE" | cut -d= -f2 | tr -d '"'"'" || echo "")
|
|
[[ -z "$_ctx_size" ]] && _ctx_size="16384"
|
|
_model_path="$MODELS_DIR/${_gguf_file}"
|
|
|
|
if [[ ! -f "$_model_path" ]]; then
|
|
log "WARNING: Model file not found at $_model_path"
|
|
else
|
|
# Capture old model path for rollback before we kill the process
|
|
_old_pid=$(cat "$LLAMA_SERVER_PID_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
_old_model_path=""
|
|
if [[ -n "$_old_pid" ]] && kill -0 "$_old_pid" 2>/dev/null; then
|
|
_old_model_path=$(ps -p "$_old_pid" -o args= 2>/dev/null | grep -oE '\-\-model [^ ]+' | awk '{print $2}') || true
|
|
fi
|
|
|
|
# Stop existing native llama-server
|
|
if [[ -n "$_old_pid" ]] && kill -0 "$_old_pid" 2>/dev/null; then
|
|
# Verify it's actually llama-server (PID could have been reused)
|
|
if ps -p "$_old_pid" -o comm= 2>/dev/null | grep -q llama; then
|
|
log "Stopping native llama-server (PID $_old_pid)..."
|
|
kill "$_old_pid" 2>/dev/null || true
|
|
sleep 2
|
|
if kill -0 "$_old_pid" 2>/dev/null; then
|
|
kill -9 "$_old_pid" 2>/dev/null || true
|
|
fi
|
|
else
|
|
log "PID $_old_pid is no longer llama-server, skipping kill"
|
|
fi
|
|
fi
|
|
|
|
# Read reasoning mode from .env (default off to prevent thinking models
|
|
# from consuming the entire token budget on internal reasoning)
|
|
_reasoning=$(grep '^LLAMA_REASONING=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "")
|
|
[[ -z "$_reasoning" ]] && _reasoning="off"
|
|
case "$_reasoning" in
|
|
off) _reasoning_fmt="none" ;;
|
|
on) _reasoning_fmt="deepseek" ;;
|
|
*) _reasoning_fmt="$_reasoning" ;;
|
|
esac
|
|
|
|
# Honour the unified BIND_ADDRESS knob (PR #964); empty/missing → loopback.
|
|
_bind=$(grep '^BIND_ADDRESS=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
|
|
[[ -z "$_bind" ]] && _bind="127.0.0.1"
|
|
_flash_attn=$(grep '^LLAMA_ARG_FLASH_ATTN=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
|
|
_cache_type_k=$(grep '^LLAMA_ARG_CACHE_TYPE_K=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
|
|
_cache_type_v=$(grep '^LLAMA_ARG_CACHE_TYPE_V=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
|
|
_n_cpu_moe=$(grep '^LLAMA_ARG_N_CPU_MOE=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
|
|
_spec_type=$(grep '^LLAMA_ARG_SPEC_TYPE=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
|
|
_spec_draft_n_max=$(grep '^LLAMA_ARG_SPEC_DRAFT_N_MAX=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
|
|
_llama_args=(
|
|
--host "$_bind" --port 8080
|
|
--model "$_model_path"
|
|
--ctx-size "$_ctx_size"
|
|
--n-gpu-layers 999
|
|
--reasoning-format "$_reasoning_fmt"
|
|
--metrics
|
|
)
|
|
[[ -n "$_flash_attn" ]] && _llama_args+=(--flash-attn "$_flash_attn")
|
|
[[ -n "$_cache_type_k" ]] && _llama_args+=(--cache-type-k "$_cache_type_k")
|
|
[[ -n "$_cache_type_v" ]] && _llama_args+=(--cache-type-v "$_cache_type_v")
|
|
[[ -n "$_n_cpu_moe" ]] && _llama_args+=(--n-cpu-moe "$_n_cpu_moe")
|
|
[[ -n "$_spec_type" ]] && _llama_args+=(--spec-type "$_spec_type")
|
|
[[ -n "$_spec_draft_n_max" ]] && _llama_args+=(--spec-draft-n-max "$_spec_draft_n_max")
|
|
|
|
# Relaunch with new model
|
|
log "Starting native llama-server with ${_gguf_file}..."
|
|
(
|
|
cd "$INSTALL_DIR" || exit 1
|
|
exec "$LLAMA_SERVER_BIN" "${_llama_args[@]}"
|
|
) > "$LLAMA_SERVER_LOG" 2>&1 &
|
|
_new_pid=$!
|
|
echo "$_new_pid" > "$LLAMA_SERVER_PID_FILE"
|
|
|
|
# Wait for health
|
|
log "Waiting for native llama-server health..."
|
|
_healthy=false
|
|
for _i in $(seq 1 60); do
|
|
if curl -sf --max-time 5 "http://127.0.0.1:8080/health" &>/dev/null; then
|
|
_healthy=true
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
if $_healthy; then
|
|
log "SUCCESS: Native llama-server running with ${_gguf_file} (PID $_new_pid)"
|
|
HOT_SWAP_VERIFIED=true
|
|
else
|
|
log "WARNING: New model failed to load. Attempting rollback..."
|
|
kill "$_new_pid" 2>/dev/null || true
|
|
sleep 2
|
|
if kill -0 "$_new_pid" 2>/dev/null; then
|
|
kill -9 "$_new_pid" 2>/dev/null || true
|
|
fi
|
|
if [[ -n "${_old_model_path:-}" && -f "$_old_model_path" ]]; then
|
|
(
|
|
cd "$INSTALL_DIR" || exit 1
|
|
exec "$LLAMA_SERVER_BIN" \
|
|
--host "$_bind" --port 8080 \
|
|
--model "$_old_model_path" \
|
|
--ctx-size "$_ctx_size" \
|
|
--n-gpu-layers 999 \
|
|
--reasoning-format "${_reasoning_fmt:-none}" \
|
|
--metrics
|
|
) > "$LLAMA_SERVER_LOG" 2>&1 &
|
|
_rollback_pid=$!
|
|
echo "$_rollback_pid" > "$LLAMA_SERVER_PID_FILE"
|
|
log "Rolled back to previous model: $(basename "$_old_model_path") (PID $_rollback_pid)"
|
|
else
|
|
log "WARNING: Could not rollback — previous model not found."
|
|
log "Run './ods-macos.sh restart' to manually recover."
|
|
fi
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model downloaded and verified, but native macOS llama-server did not load it after swap. Bootstrap model kept; run './ods-macos.sh restart' or re-run to retry."
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
else
|
|
log "Docker services not running. Config updated — full model will load on next start."
|
|
discard_active_model_config_snapshot
|
|
fi
|
|
|
|
# ── Phase 5b: Remove bootstrap model only after verified full-model serving ──
|
|
# Lemonade's --extra-models-dir auto-discovers all GGUFs in /models. Removing
|
|
# the bootstrap too early can wedge Windows Lemonade: it may keep serving the
|
|
# old model id while the file is gone, producing 500s until a manual restart.
|
|
# Keep the bootstrap as the recovery path unless the new model has answered a
|
|
# real completion.
|
|
if [[ "$HOT_SWAP_VERIFIED" == "true" && -f "$BOOTSTRAP_PATH" && "$FULL_GGUF_FILE" != "$BOOTSTRAP_GGUF" ]]; then
|
|
log "Removing bootstrap model after verified full-model serving: $BOOTSTRAP_GGUF"
|
|
rm -f "$BOOTSTRAP_PATH"
|
|
log "Bootstrap model removed"
|
|
if ! refresh_lemonade_after_bootstrap_cleanup; then
|
|
write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
|
|
"Full model served, but ODS could not refresh Lemonade after removing the bootstrap model. Re-run to retry."
|
|
fail "Lemonade refresh after bootstrap cleanup failed."
|
|
fi
|
|
elif [[ "$FULL_GGUF_FILE" != "$BOOTSTRAP_GGUF" && -f "$BOOTSTRAP_PATH" ]]; then
|
|
log "Keeping bootstrap model until the full model is verified serving: $BOOTSTRAP_GGUF"
|
|
fi
|
|
|
|
# ── Phase 5c: Update Perplexica's defaultChatModel ──
|
|
# Phase 12 of the installer configures Perplexica with whatever LLM_MODEL was
|
|
# in scope at that time — which on bootstrap installs is the bootstrap model
|
|
# name (e.g. qwen3.5-2b), NOT the full model. Without an update here,
|
|
# Perplexica's settings.preferences.defaultChatModel stays "qwen3.5-2b"
|
|
# forever, even after the hot-swap replaces the underlying GGUF. The UI
|
|
# shows the wrong model name in the dropdown, and the chatModels list under
|
|
# the OpenAI provider keeps the bootstrap entry instead of the full model.
|
|
#
|
|
# Requests still functionally route via LiteLLM's `*` wildcard or llama.cpp's
|
|
# served-model-passthrough, so this hasn't been a hard failure — but it's a
|
|
# cosmetic + future-proofing issue (a non-wildcard router would 404 the
|
|
# stale model id). Mirror the install-time logic from
|
|
# installers/phases/12-health.sh:194-238: update modelProviders + preferences
|
|
# via Perplexica's `/api/config` PUT endpoint.
|
|
PERPLEXICA_PORT=$(grep -E '^PERPLEXICA_PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r')
|
|
: "${PERPLEXICA_PORT:=3004}"
|
|
_perplexica_url="http://127.0.0.1:${PERPLEXICA_PORT}"
|
|
if curl -sf --max-time 3 "${_perplexica_url}/api/config" >/dev/null 2>&1; then
|
|
log "Updating Perplexica config to point at ${FULL_LLM_MODEL}..."
|
|
_py_cmd="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
|
|
if [[ -n "$_py_cmd" ]]; then
|
|
# On Lemonade, LiteLLM exposes the model id as "extra.<GGUF_FILE>".
|
|
# On NVIDIA/Apple/CPU, llama.cpp serves under the bare GGUF id.
|
|
_px_model="$FULL_GGUF_FILE"
|
|
_runtime_for_perplexica=$(grep -E '^AMD_INFERENCE_RUNTIME=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' | tr '[:upper:]' '[:lower:]' || echo "")
|
|
_llm_backend_for_perplexica=$(grep -E '^LLM_BACKEND=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' | tr '[:upper:]' '[:lower:]' || echo "")
|
|
if [[ "$_runtime_for_perplexica" == "lemonade" || "$_llm_backend_for_perplexica" == "lemonade" ]]; then
|
|
_px_model="extra.$FULL_GGUF_FILE"
|
|
fi
|
|
_litellm_key=$(grep -E '^LITELLM_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' || echo "no-key")
|
|
: "${_litellm_key:=no-key}"
|
|
_px_base_url=$(grep -E '^LLM_API_URL=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' || echo "http://llama-server:8080")
|
|
: "${_px_base_url:=http://llama-server:8080}"
|
|
case "$_px_base_url" in
|
|
*/v1|*/api/v1) ;;
|
|
*) _px_base_url="${_px_base_url%/}/v1" ;;
|
|
esac
|
|
if curl -sf --max-time 3 "${_perplexica_url}/api/config" 2>/dev/null | \
|
|
PERPLEXICA_URL="$_perplexica_url" \
|
|
PX_MODEL="$_px_model" \
|
|
PX_KEY="$_litellm_key" \
|
|
PX_BASE_URL="$_px_base_url" \
|
|
"$_py_cmd" -c '
|
|
import os, sys, json, urllib.request
|
|
config = json.load(sys.stdin)["values"]
|
|
providers = config.get("modelProviders", [])
|
|
openai_prov = next((p for p in providers if p["type"] == "openai"), None)
|
|
if not openai_prov:
|
|
sys.exit(0) # Perplexica has no OpenAI provider configured; skip (non-fatal)
|
|
url = os.environ["PERPLEXICA_URL"] + "/api/config"
|
|
model = os.environ["PX_MODEL"]
|
|
key = os.environ["PX_KEY"]
|
|
base_url = os.environ["PX_BASE_URL"]
|
|
def post(k, v):
|
|
data = json.dumps({"key": k, "value": v}).encode()
|
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
urllib.request.urlopen(req, timeout=5)
|
|
def post_setup_complete():
|
|
setup_url = os.environ["PERPLEXICA_URL"] + "/api/config/setup-complete"
|
|
req = urllib.request.Request(setup_url, data=b"{}", headers={"Content-Type": "application/json"})
|
|
try:
|
|
urllib.request.urlopen(req, timeout=5)
|
|
except Exception:
|
|
post("setupComplete", True)
|
|
openai_prov["chatModels"] = [{"key": model, "name": model}]
|
|
# Refresh the provider config so Perplexica can reach the active ODS route.
|
|
prov_config = openai_prov.get("config") or {}
|
|
prov_config["apiKey"] = key
|
|
prov_config["baseURL"] = base_url
|
|
openai_prov["config"] = prov_config
|
|
post("modelProviders", providers)
|
|
prefs = config.get("preferences", {})
|
|
prefs["defaultChatModel"] = model
|
|
prefs["defaultChatProvider"] = openai_prov["id"]
|
|
post("preferences", prefs)
|
|
post_setup_complete()
|
|
print("ok")
|
|
' >/dev/null 2>&1; then
|
|
log "Perplexica defaultChatModel updated to ${_px_model}."
|
|
else
|
|
log "WARNING: Perplexica config update failed (non-fatal — defaultChatModel may still read the bootstrap value)"
|
|
fi
|
|
else
|
|
log "WARNING: python3 not found, skipping Perplexica config update"
|
|
fi
|
|
fi
|
|
|
|
# ── Phase 6: Restart host agent (if running) ──
|
|
# The host agent may cache stale state — restart it so it picks up the new
|
|
# model config and any updated endpoints.
|
|
if command -v systemctl &>/dev/null && systemctl --user is-active ods-host-agent.service &>/dev/null; then
|
|
log "Restarting ods-host-agent (systemd)..."
|
|
systemctl --user restart ods-host-agent.service 2>&1 || \
|
|
log "WARNING: Could not restart host agent (non-fatal)"
|
|
elif [[ -f "$HOME/Library/LaunchAgents/com.ods.host-agent.plist" ]]; then
|
|
log "Restarting ods-host-agent (launchctl)..."
|
|
launchctl kickstart -k "gui/$(id -u)/com.ods.host-agent" 2>&1 || \
|
|
log "WARNING: Could not restart host agent (non-fatal)"
|
|
fi
|
|
|
|
write_status "complete" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
|
|
log "Bootstrap upgrade complete."
|