#!/bin/sh # RPCD backend for LocalAI LuCI integration # Copyright (C) 2025 CyberMind.fr . /lib/functions.sh CONFIG="localai" LOCALAI_CTL="/usr/sbin/localaictl" # Load UCI config load_config() { config_load "$CONFIG" config_get API_PORT main api_port "8081" config_get DATA_PATH main data_path "/srv/localai" config_get MODELS_PATH main models_path "/srv/localai/models" config_get MEMORY_LIMIT main memory_limit "2G" config_get THREADS main threads "4" config_get CONTEXT_SIZE main context_size "2048" } # Check if LocalAI is running (supports LXC, Docker, Podman) is_running() { # Check LXC container if command -v lxc-info >/dev/null 2>&1; then lxc-info -n localai -s 2>/dev/null | grep -q "RUNNING" && return 0 fi # Check Podman container if command -v podman >/dev/null 2>&1; then podman ps --format '{{.Names}}' 2>/dev/null | grep -q "^localai$" && return 0 fi # Check Docker container if command -v docker >/dev/null 2>&1; then docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^localai$" && return 0 fi # Fallback to direct process check (native binary) pgrep -f "local-ai" >/dev/null 2>&1 } # Get service status get_status() { load_config local running="false" local uptime=0 if is_running; then running="true" # Try to get container/process uptime # LXC container if command -v lxc-info >/dev/null 2>&1 && lxc-info -n localai -s 2>/dev/null | grep -q "RUNNING"; then # Get LXC container PID and calculate uptime local lxc_pid=$(lxc-info -n localai -p 2>/dev/null | awk '{print $2}') if [ -n "$lxc_pid" ] && [ -d "/proc/$lxc_pid" ]; then local start_time=$(stat -c %Y /proc/$lxc_pid 2>/dev/null || echo 0) local now=$(date +%s) uptime=$((now - start_time)) fi # Podman container elif command -v podman >/dev/null 2>&1; then local status=$(podman ps --filter "name=localai" --format '{{.Status}}' 2>/dev/null | head -1) if [ -n "$status" ]; then case "$status" in *minute*) uptime=$(($(echo "$status" | grep -oE '[0-9]+' | head -1) * 60)) ;; *hour*) uptime=$(($(echo "$status" | grep -oE '[0-9]+' | head -1) * 3600)) ;; *second*) uptime=$(echo "$status" | grep -oE '[0-9]+' | head -1) ;; *) uptime=0 ;; esac fi # Docker container elif command -v docker >/dev/null 2>&1; then local status=$(docker ps --filter "name=localai" --format '{{.Status}}' 2>/dev/null | head -1) if [ -n "$status" ]; then case "$status" in *minute*) uptime=$(($(echo "$status" | grep -oE '[0-9]+' | head -1) * 60)) ;; *hour*) uptime=$(($(echo "$status" | grep -oE '[0-9]+' | head -1) * 3600)) ;; *second*) uptime=$(echo "$status" | grep -oE '[0-9]+' | head -1) ;; *) uptime=0 ;; esac fi # Native process else local pid=$(pgrep -f "local-ai" | head -1) if [ -n "$pid" ] && [ -d "/proc/$pid" ]; then local start_time=$(stat -c %Y /proc/$pid 2>/dev/null || echo 0) local now=$(date +%s) uptime=$((now - start_time)) fi fi fi # Get enabled status local enabled="false" [ "$(uci -q get ${CONFIG}.main.enabled)" = "1" ] && enabled="true" cat </dev/null if [ -f "$tmpfile" ] && [ -s "$tmpfile" ]; then # Try indexed access for each model (max 20) local i=0 while [ $i -lt 20 ]; do local model_id=$(jsonfilter -i "$tmpfile" -e "@.data[$i].id" 2>/dev/null) [ -z "$model_id" ] && break [ $first -eq 0 ] && echo "," first=0 seen="$seen $model_id" cat </dev/null || echo 0) local ext="${name##*.}" local type="unknown" local loaded="false" case "$ext" in gguf) type="llama-cpp" ;; bin) type="transformers" ;; onnx) type="onnx" ;; esac # Check if this model is in the seen list (loaded from API) case " $seen " in *" $basename_no_ext "*) continue ;; esac [ $first -eq 0 ] && echo "," first=0 cat </dev/null) if echo "$response" | grep -q "ok"; then healthy="true" api_status="ok" else api_status="unhealthy" fi else api_status="stopped" fi cat </dev/null || echo 0) # Get CPU from ps cpu_percent=$(ps -o %cpu= -p $pid 2>/dev/null | tr -d ' ' || echo "0") fi fi cat </dev/null 2>&1 sleep 2 if is_running; then echo '{"success":true}' else echo '{"success":false,"error":"Failed to start"}' fi } # Stop service do_stop() { /etc/init.d/localai stop >/dev/null 2>&1 sleep 1 if ! is_running; then echo '{"success":true}' else echo '{"success":false,"error":"Failed to stop"}' fi } # Restart service do_restart() { /etc/init.d/localai restart >/dev/null 2>&1 sleep 3 if is_running; then echo '{"success":true}' else echo '{"success":false,"error":"Failed to restart"}' fi } # Install model do_model_install() { local name="$1" [ -z "$name" ] && { echo '{"success":false,"error":"Model name required"}'; return; } local output=$($LOCALAI_CTL model-install "$name" 2>&1) local ret=$? if [ $ret -eq 0 ]; then echo '{"success":true}' else local error=$(echo "$output" | tail -1 | sed 's/"/\\"/g') echo "{\"success\":false,\"error\":\"$error\"}" fi } # Remove model do_model_remove() { local name="$1" [ -z "$name" ] && { echo '{"success":false,"error":"Model name required"}'; return; } local output=$($LOCALAI_CTL model-remove "$name" 2>&1) local ret=$? if [ $ret -eq 0 ]; then echo '{"success":true}' else local error=$(echo "$output" | tail -1 | sed 's/"/\\"/g') echo "{\"success\":false,\"error\":\"$error\"}" fi } # Chat completion (proxy to LocalAI API) do_chat() { load_config local model="$1" local messages="$2" if ! is_running; then echo '{"response":"","error":"LocalAI is not running. Start with: /etc/init.d/localai start"}' return fi # Validate inputs [ -z "$model" ] && { echo '{"response":"","error":"Model not specified"}'; return; } [ -z "$messages" ] && { echo '{"response":"","error":"Messages not provided"}'; return; } # Messages comes as JSON string from LuCI RPC - it should be a valid JSON array # Build request body for LocalAI /v1/chat/completions endpoint local request_body="{\"model\":\"$model\",\"messages\":$messages}" # Log for debugging logger -t localai-chat "Request to model: $model" # Call LocalAI API using curl if available, otherwise wget local tmpfile="/tmp/localai_chat_$$" local tmpfile_err="/tmp/localai_chat_err_$$" # Use longer timeout for LLM responses (120 seconds) if command -v curl >/dev/null 2>&1; then curl -s -m 120 -X POST "http://127.0.0.1:$API_PORT/v1/chat/completions" \ -H "Content-Type: application/json" \ -d "$request_body" \ -o "$tmpfile" 2>"$tmpfile_err" else wget -q -T 120 -O "$tmpfile" --post-data "$request_body" \ --header="Content-Type: application/json" \ "http://127.0.0.1:$API_PORT/v1/chat/completions" 2>"$tmpfile_err" fi if [ -f "$tmpfile" ] && [ -s "$tmpfile" ]; then # Log raw response for debugging logger -t localai-chat "Raw response: $(head -c 200 "$tmpfile")" # Extract message content using jsonfilter local content=$(jsonfilter -i "$tmpfile" -e '@.choices[0].message.content' 2>/dev/null) local error=$(jsonfilter -i "$tmpfile" -e '@.error.message' 2>/dev/null) if [ -n "$error" ]; then # Escape quotes and newlines in error error=$(echo "$error" | sed 's/"/\\"/g' | tr '\n' ' ') echo "{\"response\":\"\",\"error\":\"$error\"}" elif [ -n "$content" ]; then # Properly escape the content for JSON output # Handle quotes, backslashes, and newlines content=$(printf '%s' "$content" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk '{printf "%s\\n", $0}' | sed 's/\\n$//') echo "{\"response\":\"$content\"}" else echo '{"response":"","error":"Empty response from LocalAI API - model may not support chat format"}' fi rm -f "$tmpfile" "$tmpfile_err" 2>/dev/null else local err_msg="" [ -f "$tmpfile_err" ] && err_msg=$(cat "$tmpfile_err" | head -c 200 | sed 's/"/\\"/g') rm -f "$tmpfile" "$tmpfile_err" 2>/dev/null if [ -n "$err_msg" ]; then echo "{\"response\":\"\",\"error\":\"API request failed: $err_msg\"}" else echo '{"response":"","error":"API request failed - check if LocalAI is running and model is loaded"}' fi fi } # Text completion do_complete() { load_config local model="$1" local prompt="$2" if ! is_running; then echo '{"text":"","error":"LocalAI is not running"}' return fi local response=$(wget -q -O - --post-data "{\"model\":\"$model\",\"prompt\":\"$prompt\"}" \ --header="Content-Type: application/json" \ "http://127.0.0.1:$API_PORT/v1/completions" 2>/dev/null) if [ -n "$response" ]; then local text=$(echo "$response" | jsonfilter -e '@.choices[0].text' 2>/dev/null) echo "{\"text\":\"$(echo "$text" | sed 's/"/\\"/g')\"}" else echo '{"text":"","error":"API request failed"}' fi } # UBUS method list case "$1" in list) cat <<'EOF' { "status": {}, "models": {}, "config": {}, "health": {}, "metrics": {}, "start": {}, "stop": {}, "restart": {}, "model_install": {"name": "string"}, "model_remove": {"name": "string"}, "chat": {"model": "string", "messages": "array"}, "complete": {"model": "string", "prompt": "string"} } EOF ;; call) case "$2" in status) get_status ;; models) get_models ;; config) get_config ;; health) get_health ;; metrics) get_metrics ;; start) do_start ;; stop) do_stop ;; restart) do_restart ;; model_install) read -r input name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) do_model_install "$name" ;; model_remove) read -r input name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) do_model_remove "$name" ;; chat) read -r input model=$(echo "$input" | jsonfilter -e '@.model' 2>/dev/null) messages=$(echo "$input" | jsonfilter -e '@.messages' 2>/dev/null) do_chat "$model" "$messages" ;; complete) read -r input model=$(echo "$input" | jsonfilter -e '@.model' 2>/dev/null) prompt=$(echo "$input" | jsonfilter -e '@.prompt' 2>/dev/null) do_complete "$model" "$prompt" ;; *) echo '{"error":"Unknown method"}' ;; esac ;; esac