21 KiB
Architecture Decision: pos ai server — llama.cpp Inference Server
TL;DR
Decisions:
- New tool
bin/pos-ai-serverwith subcommands:start,stop,status,models,logs - Systemd user service generated at runtime (same pattern as
pos-network-downloadandpos-communication-telegram-listener) - Config extends existing
aiscope inai.env— no new config files - Provider adapter
lib/ai-providers/llamacpp.shfollows the 4-function contract - Changes to
bin/pos-ai:resolve_key()acceptsllamacpp(no key needed),resolve_model()falls through toLLAMACPP_MODEL,cmd_providers()includes llamacpp - No static
systemd/unit file — the service is generated dynamically because model path, port, and GPU flags are user-configurable
Open items:
- llama-server binary name varies by build (
llama-server,llama.cpp/server,server) — detection logic needs a fallback chain - ROCm detection deferred (Debian/Ubuntu focus, CUDA-only auto-detect)
Decision 1: Tool Structure
Problem: User needs to start/stop/manage a local llama.cpp inference server via pos.
Decision: Create bin/pos-ai-server as a standalone tool under the ai category, with subcommands.
Evidence:
- Existing pattern:
bin/pos-network-downloadis a standalone tool withstart/stop/statussubcommands for the aria2 daemon - Existing pattern:
bin/pos-communication-telegram-listenermanages its own systemd user service - Tool naming:
pos-ai-server→pos ai server(category:ai, command:server)
Subcommands:
| Subcommand | Description |
|---|---|
start [model] |
Install & start the systemd user service (model from arg, config, or interactive pick) |
stop |
Stop & remove the service |
status |
Show running state, loaded model, port, health endpoint |
models |
List available GGUF files from HF_DOWNLOAD_DIR |
logs [lines] |
Show recent server logs via journalctl --user |
Not interactive: pos ai server does NOT read stdin (no prompts that block under tee). It does NOT need to be in INTERACTIVE_CMDS.
Approved scope:
bin/pos-ai-server— 1 file- POS header:
# POS: ai server — llama.cpp local inference server (start, stop, status, models, logs) - POS_SUBCMDS:
start stop status models logs - POS_DEPS:
curl jq(curl for health check + API, jq for JSON parsing) - POS_FLAGS:
--port --host --model --ctx --gpu --threads
[DECIDED]
Decision 2: Systemd User Service (Runtime-Generated)
Problem: The llama-server service needs model path, port, GPU layers, and other parameters that are user-configurable. A static unit file can't carry these.
Decision: Generate the systemd user service file at runtime (same pattern as pos-network-download lines 172-187 and pos-communication-telegram-listener lines 481-500).
Evidence:
pos-network-download: generatespos-aria2.serviceatcmd_start()with$RPC_PORT,$RPC_SECRET,$DOWNLOAD_DIRbaked intoExecStartpos-communication-telegram-listener: generatespos-telegram-listener.servicewith the runner path baked in- Both write to
$USER_SYSTEMD_DIR(~/.config/systemd/user/), thensystemctl --user daemon-reload && enable --now - Both use
cat > "$USER_SYSTEMD_DIR/$SERVICE" <<EOFpattern
Service name: pos-ai-server.service
Service content:
[Unit]
Description=pos llama.cpp inference server (linux-post-install)
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/env llama-server -m <MODEL> --port <PORT> --host <HOST> --n-gpu-layers <GPU_LAYERS> --ctx-size <CTX_SIZE> --threads <THREADS>
Restart=on-failure
RestartSec=5
TimeoutStopSec=10
KillMode=control-group
EnvironmentFile=-%h/.config/linux_post_install/ai.env
[Install]
WantedBy=default.target
Key design choices:
Type=simple— llama-server runs in foreground by default (no daemonize flag needed)Restart=on-failure— restart if it crashes, but not on clean exit (stopsends SIGTERM, which is clean)RestartSec=5— give time for model unload/reloadTimeoutStopSec=10— llama-server handles SIGTERM gracefully (unloads model), 10s is generousKillMode=control-group— ensures the whole process tree is cleaned upEnvironmentFile=-(dash prefix) — missing file is not an error- ExecStart is a direct
llama-servercall (not a wrapper script) — systemd handles the lifecycle
Where config values come from: cmd_start() reads the config file, resolves all values, then bakes them into the generated unit. The EnvironmentFile line in the unit is a fallback but the actual arguments are baked in at generation time. This matches the aria2 pattern exactly.
start subcommand flow:
- Load config from
ai.env - Resolve model (argument →
LLAMACPP_MODEL→ interactive pick) - Resolve port, host, ctx, gpu, threads (flag → config → default)
- Validate model file exists
- Auto-detect GPU if
LLAMACPP_GPU_LAYERSis-1 - Check port availability
- Generate systemd unit file
systemctl --user daemon-reloadsystemctl --user enable --now pos-ai-server.service- Wait briefly, then check health endpoint
Linger warning: Same as existing tools — warn if loginctl enable-linger is needed.
[DECIDED]
Decision 3: Config Keys (Extend ai Scope)
Problem: Server settings need to be persisted alongside existing AI config.
Decision: Extend the existing ai scope in ai.env. No new config file.
Evidence:
ai.envalready holdsAI_PROVIDER,AI_GEMINI_API_KEY,HF_DOWNLOAD_DIR, etc.- The
# POS_CONFIG:header onbin/pos-aialready declares theaiscope - Adding
LLAMACPP_*keys to the same file keeps all AI config in one place pos config aiauto-discovers keys from# POS_CONFIG:headers
Config keys to add:
| Key | Default | Description |
|---|---|---|
LLAMACPP_PORT |
8088 |
Server listen port |
LLAMACPP_HOST |
127.0.0.1 |
Bind address |
LLAMACPP_MODEL |
(empty) | Default model path (GGUF file) |
LLAMACPP_CTX_SIZE |
4096 |
Context window size |
LLAMACPP_GPU_LAYERS |
-1 |
GPU layers (-1 = auto-detect, 0 = CPU only) |
LLAMACPP_THREADS |
$(nproc) |
CPU threads |
POS_CONFIG header on pos-ai: Extend the existing # POS_CONFIG: line to include the new keys. The existing header already uses ai | ai.env | ... format — we append LLAMACPP_* entries.
New header addition (appended to existing # POS_CONFIG: line):
| LLAMACPP_PORT=:Server port (default 8088) | LLAMACPP_HOST=:Bind address (default 127.0.0.1) | LLAMACPP_MODEL=:Default model path (GGUF) | LLAMACPP_CTX_SIZE:num:Context window size (default 4096) | LLAMACPP_GPU_LAYERS:num:GPU layers (-1=auto, 0=CPU only, default -1) | LLAMACPP_THREADS:num:CPU threads (default: nproc)
Config template update: Add commented examples to config/ai.env.
[DECIDED]
Decision 4: Provider Adapter
Problem: pos ai ask should work with the local llama.cpp server as a backend, just like gemini/openrouter.
Decision: Create lib/ai-providers/llamacpp.sh with the 4-function contract.
Evidence:
lib/ai-providers/gemini.shandlib/ai-providers/openrouter.shboth implement:provider_name(),provider_default_model(),provider_generate(),provider_models_list()pos-ailoads providers viaload_provider()which sources$PROVIDER_DIR/$p.sh- The provider adapter pattern is established and stable
Function signatures:
# provider_name → human-readable name
provider_name() { printf 'Local llama.cpp'; }
# provider_default_model → what's loaded on the server
provider_default_model() {
local port="${LLAMACPP_PORT:-8088}"
local model
model="$(curl -sf "http://127.0.0.1:$port/v1/models" 2>/dev/null | jq -r '.data[0].id // empty')"
[ -n "$model" ] && printf '%s' "$model" || printf '(no model loaded)'
}
# provider_generate($1=model, $2=messages JSON, $3=optional system prompt)
# → POST to /v1/chat/completions, stdout = response text
provider_generate() {
local model="$1" messages="$2" system="${3:-}" port="${LLAMACPP_PORT:-8088}"
local body resp code body_out
# Build messages array with optional system prompt
if [ -n "$system" ]; then
body="$(printf '%s' "$messages" | jq -c --arg s "$system" \
'[{role:"system",content:$s}] + .messages')"
else
body="$(printf '%s' "$messages" | jq -c '.messages')"
fi
body="$(printf '%s' "$body" | jq -nc --arg m "$model" --argjson msgs "$body" \
'{model:$m, messages:$msgs, stream:false}')"
resp="$(curl -sS -m 120 -X POST "http://127.0.0.1:$port/v1/chat/completions" \
-H "Content-Type: application/json" \
--write-out $'\n%{http_code}' \
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
code="${resp##*$'\n'}"
body_out="${resp%$'\n'*}"
if [ "$code" != "200" ]; then
echo "API error $code" >&2
return 1
fi
printf '%s' "$body_out" | jq -r '.choices[0].message.content // ""'
}
# provider_models_list($1=current model) → stdout = formatted list
provider_models_list() {
local model="$1" port="${LLAMACPP_PORT:-8088}" resp code body
resp="$(curl -sf "http://127.0.0.1:$port/v1/models" \
--write-out $'\n%{http_code}')" || { echo "server not running" >&2; return 1; }
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
[ "$code" = "200" ] || { echo "API error $code" >&2; return 1; }
echo "Local llama.cpp models:"
printf '%s' "$body" | jq -r '.data[]? | .id' | while IFS= read -r m; do
[ -n "$m" ] || continue
if [ "$m" = "$model" ]; then
printf ' %-48s <- loaded\n' "$m"
else
printf ' %-48s\n' "$m"
fi
done
}
No API key: resolve_key() in bin/pos-ai needs a llamacpp) case that succeeds without a key. The local server has no auth.
Provider detection in resolve_key():
llamacpp) return 0 ;; # No API key needed for local server
Provider detection in resolve_model():
llamacpp)
[ -n "${LLAMACPP_MODEL:-}" ] && printf '%s' "$(basename "$LLAMACPP_MODEL")" && return ;;
Provider detection in cmd_providers(): The existing loop over $PROVIDER_DIR/*.sh auto-discovers llamacpp.sh. The configured check needs updating — llamacpp is "configured" when llama-server is available, not when an API key exists.
POS_CONFIG header on adapter: Add # PROVIDER_CONFIG: LLAMACPP_MODEL=:Default model path (GGUF file) to the adapter so pos config ai discovers it.
[DECIDED]
Decision 5: GPU Auto-Detection
Problem: Auto-detect NVIDIA CUDA to set --n-gpu-layers appropriately.
Decision: Simple CUDA detection — check nvidia-smi and /dev/nvidia*. No ROCm for now (Debian/Ubuntu focus).
Evidence:
nvidia-smiis the standard NVIDIA management interface/dev/nvidia*devices indicate driver presence- llama.cpp uses
--n-gpu-layers -1for auto (offload all possible layers to GPU) - The user's request says "Debian/Ubuntu so mainly CUDA"
Detection function (in pos-ai-server):
detect_gpu() {
if command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null 2>&1; then
echo "cuda"
else
echo "cpu"
fi
}
GPU layer resolution:
resolve_gpu_layers() {
local configured="${LLAMACPP_GPU_LAYERS:-}"
if [ -n "$configured" ] && [ "$configured" != "-1" ]; then
echo "$configured"
return
fi
# Auto-detect
local gpu
gpu="$(detect_gpu)"
case "$gpu" in
cuda) echo "-1" ;;
*) echo "0" ;;
esac
}
Behavior:
LLAMACPP_GPU_LAYERS=-1(default) → auto-detect: CUDA →-1, no CUDA →0LLAMACPP_GPU_LAYERS=0→ CPU only (override)LLAMACPP_GPU_LAYERS=35→ explicit layer count (for fine-tuning)
No ROCm: Explicitly out of scope. The detection function can be extended later.
[DECIDED]
Decision 6: Model Selection
Problem: User needs to pick from downloaded GGUF models.
Decision: pos ai server models scans HF_DOWNLOAD_DIR for .gguf files, reusing pos ai hf list patterns.
Evidence:
pos-ai-hfdownloads to$HF_DOWNLOAD_DIR(default~/.local/share/linux_post_install/ai/models/)cmd_list()inpos-ai-hfalready scans for model directories with.hf-metafiles- GGUF files are the inference-ready format; they're the only files that matter for serving
models subcommand behavior:
cmd_models() {
local dir="${HF_DOWNLOAD_DIR:-$HOME/.local/share/linux_post_install/ai/models}"
[ -d "$dir" ] || { warn "No models directory — run 'pos ai hf download' first"; return 0; }
local found=0
echo "Available GGUF models:"
while IFS= read -r gguf; do
[ -f "$gguf" ] || continue
found=1
local name size
name="$(basename "$gguf")"
local dir_name
dir_name="$(basename "$(dirname "$gguf")")"
size="$(stat -c%s "$gguf" 2>/dev/null || echo 0)"
local human_size
human_size="$(human_size "$size")"
printf ' %-50s %s %s\n' "$dir_name/$name" "$human_size" ""
done < <(find "$dir" -name '*.gguf' -type f 2>/dev/null | sort)
[ "$found" -eq 0 ] && warn "No .gguf files found — download with 'pos ai hf download <repo> --gguf'"
}
Model resolution order for start [model]:
- Explicit argument:
pos ai server start /path/to/model.gguf - Relative path argument:
pos ai server start model.gguf→ searchHF_DOWNLOAD_DIR - Config:
LLAMACPP_MODELfromai.env - Interactive pick: prompt user to select from available models
Interactive pick (only when on a TTY and no model specified):
pick_model() {
local models=() i
while IFS= read -r f; do
[ -f "$f" ] || continue
models+=("$f")
done < <(find "$HF_DOWNLOAD_DIR" -name '*.gguf' -type f 2>/dev/null | sort)
[ ${#models[@]} -gt 0 ] || err "No GGUF models found — run 'pos ai hf download <repo> --gguf'"
echo "Available models:"
for ((i = 0; i < ${#models[@]}; i++)); do
local name size
name="$(basename "${models[$i]}")"
size="$(stat -c%s "${models[$i]}" 2>/dev/null || echo 0)"
printf ' %2d) %-50s %s\n' "$((i + 1))" "$name" "$(human_size "$size")"
done
echo
local choice
printf 'Pick a model [1-%d]: ' "${#models[@]}"
IFS= read -r choice </dev/tty || choice=""
[[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#models[@]}" ] || err "Invalid selection"
printf '%s' "${models[$((choice - 1))]}"
}
[DECIDED]
Decision 7: Health Check & Status
Problem: User needs to know if the server is running and healthy.
Decision: Use llama.cpp's /health endpoint + systemd state.
status subcommand output:
service: running
model: mistral-7b-v0.1.Q4_K_M.gguf
port: 8088
host: 127.0.0.1
gpu: CUDA (-1 layers)
context: 4096
threads: 16
autostart: enabled
endpoint: http://127.0.0.1:8088
health: ok (loaded)
Health check function:
check_health() {
local port="${LLAMACPP_PORT:-8088}"
local resp
resp="$(curl -sf "http://127.0.0.1:$port/health" 2>/dev/null)" || { echo "not running"; return 1; }
# llama.cpp /health returns {"status": "ok"} or {"status": "loading model", ...}
local status
status="$(printf '%s' "$resp" | jq -r '.status // "unknown"' 2>/dev/null)"
echo "$status"
}
logs subcommand: Uses journalctl --user -u pos-ai-server -n <lines> --no-pager.
[DECIDED]
Decision 8: Changes to bin/pos-ai
Problem: pos ai --provider llamacpp should work, routing through the local server.
Decision: Minimal changes to bin/pos-ai — 3 touch points.
Evidence:
resolve_key()has acase "$p" inthat checks each provider — addllamacpp)caseresolve_model()has acase "$p" infor provider-specific fallbacks — addllamacpp)casecmd_providers()checks API key configuration — add llamacpp caserequire_key()has provider-specific error messages — add llamacpp case
Changes:
resolve_key()(line 167): Addllamacpp) return 0 ;;— no key neededresolve_model()(line 193): Addllamacpp) [ -n "${LLAMACPP_MODEL:-}" ] && printf '%s' "$(basename "$LLAMACPP_MODEL")" && return ;;cmd_providers()(line 120): Addllamacpp) [ -n "${LLAMACPP_PORT:-}" ] && configured="configured" || configured="configured (default port)" ;;— local server is always "configured"require_key()(line 176): Addllamacpp) ;;— no key needed, just return
These are all 1-2 line additions within existing case blocks.
[DECIDED]
Decision 9: Error Handling
| Error | Detection | Response |
|---|---|---|
llama-server not found |
command -v llama-server fails |
err "llama-server not found — install llama.cpp (https://github.com/ggerganov/llama.cpp)" |
| Port in use | ss -tlnp or curl to port |
err "Port $PORT already in use — check with 'ss -tlnp'" |
| Model not found | [ -f "$model" ] |
err "Model not found: $model" |
| GPU not detected | detect_gpu returns cpu |
warn "No NVIDIA GPU detected — running in CPU mode" (continues) |
| Service start fails | systemctl --user start returns non-zero |
journalctl --user -u pos-ai-server -n 20 --no-pager |
| Server unhealthy | /health returns non-200 or times out |
warn "Server may not be ready yet — check with 'pos ai server status'" |
| Model too large | Not reliably detectable pre-load | Skip — llama.cpp will fail with OOM and the error is in journal logs |
Binary detection fallback: llama.cpp builds name the binary differently:
find_llamacpp() {
local candidates=("llama-server" "llama.cpp/server" "server" "llama-server-cuda")
for bin in "${candidates[@]}"; do
command -v "$bin" &>/dev/null && { echo "$bin"; return 0; }
done
return 1
}
[DECIDED]
Decision 10: File List & Responsibilities
| File | Action | Responsibility |
|---|---|---|
bin/pos-ai-server |
NEW | Service manager: start/stop/status/models/logs, systemd unit generation, GPU detection, model selection |
lib/ai-providers/llamacpp.sh |
NEW | Provider adapter: provider_name, provider_default_model, provider_generate, provider_models_list |
bin/pos-ai |
MODIFY | Add llamacpp cases to resolve_key, resolve_model, cmd_providers, require_key |
config/ai.env |
MODIFY | Add commented LLAMACPP_* key documentation |
completions/pos.bash |
AUTO | make gen picks up new POS headers — no manual edit |
NOT in scope:
- No static
systemd/pos-ai-server.servicefile (generated at runtime) - No changes to
postinstall.sh(service is user-managed, not installed by system) - No changes to
bin/posdispatcher (tool is auto-discovered) - No changes to
lib/common.sh
Decision 11: Implementation Constraints
-
All
LLAMACPP_*config reads must go throughload_config()— the existing config loader inpos-ai(line 131). The new tool also needs its own config loader (or sourcespos-ai's, which it can't cleanly). Decision:pos-ai-serveruses its ownload_config()copy (same pattern aspos-ai-hfline 26 — every tool that readsai.envhas its own loader). -
The systemd unit must NOT hardcode HOME. The existing pattern (
pos-communication-telegram-listenerline 494-496) explains why: "Do NOT pin Environment=HOME here — the systemd user manager already sets the correct HOME." -
ExecStart must use full paths for llama-server — systemd user services don't inherit the user's full
$PATH. Resolve via$(command -v llama-server)at unit generation time. -
The service must use
--log-formatflag if available (llama.cpp) to produce parseable logs. Not a hard requirement. -
make genmust run after creatingbin/pos-ai-serverto regenerate the tree, dispatch table, completions, and doc tables. -
The tool must pass
make check && make lint— bash -n syntax, exec bit, POS header, --help, deps guards before help.
Verification Plan
-
Unit test (stub PATH):
- Fake
llama-server,nvidia-smi,curl,jqin PATH - Assert
cmd_startgenerates correct unit file content - Assert
cmd_modelsfinds.gguffiles - Assert
detect_gpulogic - Assert config resolution precedence (flag > env > default)
- Fake
-
Integration test (manual):
pos ai server start model.ggufwith a real llama-server binarypos ai server statusshows correct infopos ai server logsshows journal outputpos ai server stopcleans uppos ai --provider llamacpp ask "hello"routes through local server
-
Gates:
make check— greenmake lint— 0 FAIL, 0 WARN
Explicitly Out of Scope
- Model conversion/quantization
- Multi-GPU support
- Authentication on the API endpoint
- Web UI
- GPU driver installation
- ROCm/AMD detection
- Quantization awareness (context size vs model capability)
- Model memory estimation / pre-flight checks
- Automatic model download on
startif none present