Files
nixos/services/llm-steamdeck/default.nix
T
Hermes Agent 2ece11d430 feat(llm-steamdeck): serveur llama.cpp Vulkan OpenAI-compatible (Steam Deck)
Module services.llm-steamdeck, desactive par defaut (enable a passer a true dans le profil deck, cf. README) : llama-server en Vulkan sur RADV, GGUF telecharge depuis Hugging Face au premier demarrage, web UI, limites memoire pour les 16 Go unifies. Importe par services/default.nix (donc inerte sur laptop/tower) et par profiles/deck.
2026-10-17 16:56:13 -04:00

293 lines
9.2 KiB
Nix

{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.llm-steamdeck;
# Default model configuration - Qwen3.5-9B Q4_K_M (~6.17 GB)
defaultModel = {
repo = "bartowski/Qwen_Qwen3.5-9B-GGUF";
file = "Qwen3.5-9B-Q4_K_M.gguf";
contextSize = 8192;
};
# Alternative: TheStageAI Qwen3.5-9B Q4_K_M (~5.06 GB, slightly smaller)
altModel = {
repo = "TheStageAI/Qwen3.5-9B-GGUF";
file = "Qwen3.5-9B-M-TS-Q4_K_M.gguf";
contextSize = 8192;
};
# Model to use (can be overridden)
model = if cfg.modelRepo != null then {
repo = cfg.modelRepo;
file = cfg.modelFile or "Qwen3.5-9B-Q4_K_M.gguf";
contextSize = cfg.contextSize or 8192;
} else defaultModel;
# Download model on first run using llama.cpp's built-in HF support
modelPath = "/var/lib/llm-steamdeck/${model.repo}/${model.file}";
in {
options.services.llm-steamdeck = {
enable = mkEnableOption "llama.cpp OpenAI-compatible server for Steam Deck (Vulkan)";
# Model configuration
modelRepo = mkOption {
type = types.nullOr types.str;
default = null;
description = "Hugging Face repository for the GGUF model (e.g. 'bartowski/Qwen_Qwen3.5-9B-GGUF'). Null uses default Qwen3.5-9B Q4_K_M.";
};
modelFile = mkOption {
type = types.str;
default = "Qwen3.5-9B-Q4_K_M.gguf";
description = "GGUF filename within the repository.";
};
contextSize = mkOption {
type = types.int;
default = 8192;
description = "Context window size. 8192 recommended for 16GB RAM; 16384 possible in headless mode.";
};
# Server configuration
port = mkOption {
type = types.portNumber;
default = 8080;
description = "Port for the OpenAI-compatible API server.";
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Host to bind to. Use '127.0.0.1' for local-only access.";
};
# GPU/performance options
gpuLayers = mkOption {
type = types.int;
default = 99;
description = "Number of layers to offload to GPU (Vulkan). 99 = all layers. Reduce if OOM.";
};
threads = mkOption {
type = types.int;
default = 8;
description = "CPU threads for llama.cpp (Steam Deck has 8 cores).";
};
batchSize = mkOption {
type = types.int;
default = 512;
description = "Batch size for prompt processing. Lower if prefill OOM.";
};
ubatchSize = mkOption {
type = types.int;
default = 512;
description = "Micro-batch size for prompt processing.";
};
flashAttn = mkOption {
type = types.bool;
default = true;
description = "Enable flash attention (requires llama.cpp with Qwen3.5 support).";
};
# Sampling defaults (can be overridden per-request)
temperature = mkOption {
type = types.float;
default = 0.7;
description = "Default sampling temperature.";
};
topP = mkOption {
type = types.float;
default = 0.95;
description = "Default top-p sampling.";
};
topK = mkOption {
type = types.int;
default = 20;
description = "Default top-k sampling.";
};
repeatPenalty = mkOption {
type = types.float;
default = 1.1;
description = "Default repeat penalty.";
};
# Service management
user = mkOption {
type = types.str;
default = "deck";
description = "User to run the service as.";
};
group = mkOption {
type = types.str;
default = "deck";
description = "Group for the service.";
};
extraArgs = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Extra arguments passed to llama-server.";
};
# Model download
autoDownload = mkOption {
type = types.bool;
default = true;
description = "Automatically download model on first start via llama.cpp HF support.";
};
hfToken = mkOption {
type = types.nullOr types.str;
default = null;
description = "Hugging Face token for private/gated models (set via sops secret).";
};
# Web UI
enableWebUI = mkOption {
type = types.bool;
default = true;
description = "Enable built-in web UI at /";
};
};
config = mkIf cfg.enable {
# Ensure Vulkan support for RADV (Steam Deck AMD GPU)
hardware.opengl.enable = true;
hardware.opengl.driSupport = true;
hardware.opengl.driSupport32Bit = true;
# Vulkan ICD loader for llama.cpp
environment.systemPackages = with pkgs; [
vulkan-tools
vulkan-loader
mesa
mesa.drivers
mesa_glu
];
# Create model directory with proper permissions
systemd.tmpfiles.rules = [
"d /var/lib/llm-steamdeck 0755 ${cfg.user} ${cfg.group} - -"
"d /var/lib/llm-steamdeck/${model.repo} 0755 ${cfg.user} ${cfg.group} - -"
];
# Pre-download model if autoDownload enabled (using llama-cli --hf-repo)
systemd.services.llm-steamdeck-download = {
description = "Download LLM model from Hugging Face";
documentation = [ "https://github.com/ggml-org/llama.cpp" ];
wantedBy = [ "multi-user.target" ];
before = [ "llm-steamdeck.service" ];
conditionPathExists = "!${modelPath}";
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
ExecStart = ''
${pkgs.llama-cpp-vulkan}/bin/llama-cli \
--hf-repo ${model.repo} \
--hf-file ${model.file} \
--model ${modelPath} \
-p " " \
-n 1 \
${lib.optionalString (cfg.hfToken != null) "--hf-token ${cfg.hfToken}"}
'';
Environment = [
"HOME=/home/${cfg.user}"
"XDG_CACHE_HOME=/home/${cfg.user}/.cache"
];
StateDirectory = "llm-steamdeck";
TimeoutStartSec = 1800; # 30 min for download
};
};
# Main llama-server service
systemd.services.llm-steamdeck = {
description = "llama.cpp OpenAI-compatible server (Vulkan) for Steam Deck";
documentation = [
"https://github.com/ggml-org/llama.cpp"
"https://huggingface.co/${model.repo}"
];
after = [ "network-online.target" "llm-steamdeck-download.service" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "/var/lib/llm-steamdeck";
ExecStart = ''
${pkgs.llama-cpp-vulkan}/bin/llama-server \
--model ${modelPath} \
--host ${cfg.host} \
--port ${toString cfg.port} \
--ctx-size ${toString model.contextSize} \
--n-gpu-layers ${toString cfg.gpuLayers} \
--threads ${toString cfg.threads} \
--batch-size ${toString cfg.batchSize} \
--ubatch-size ${toString cfg.ubatchSize} \
--temp ${toString cfg.temperature} \
--top-p ${toString cfg.topP} \
--top-k ${toString cfg.topK} \
--repeat-penalty ${toString cfg.repeatPenalty} \
${lib.optionalString cfg.flashAttn "--flash-attn"} \
${lib.optionalString cfg.enableWebUI "--web-ui"} \
${lib.optionalString (cfg.hfToken != null) "--hf-token ${cfg.hfToken}"} \
${concatStringsSep " " cfg.extraArgs}
'';
Restart = "on-failure";
RestartSec = 10;
TimeoutStartSec = 120;
TimeoutStopSec = 30;
# Memory limits for Steam Deck 16GB unified RAM
MemoryMax = "12G";
MemorySwapMax = "2G";
# GPU access
SupplementaryGroups = [ "video" "render" ];
Environment = [
"VK_ICD_FILENAMES=/etc/vulkan/icd.d/radeon_icd.x86_64.json:/etc/vulkan/icd.d/radeon_icd.i686.json"
"RADV_PERFTEST=aco,nggc,mesh_shaders"
"AMD_DEBUG=w32ge,w32cs"
"HOME=/home/${cfg.user}"
"XDG_CACHE_HOME=/home/${cfg.user}/.cache"
"XDG_RUNTIME_DIR=/run/user/1000"
];
# Logging
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "llm-steamdeck";
};
};
# Enable and start services
systemd.services.llm-steamdeck-download.enable = cfg.autoDownload;
systemd.services.llm-steamdeck.enable = true;
# Firewall opening for local network access
networking.firewall.allowedTCPPorts = [ cfg.port ];
# User setup (ensure deck user has video/render groups)
users.users.${cfg.user} = {
extraGroups = [ "video" "render" ];
};
# Convenience: systemd user service alias for easy management
systemd.user.services.llm-steamdeck = {
description = "User-level alias for llm-steamdeck";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.systemd}/bin/systemctl --user start llm-steamdeck.service";
RemainAfterExit = true;
};
};
# Logrotate for llama-server logs (uses systemd journal, logrotate not needed for journald)
# services.logrotate = {
# enable = true;
# settings."llm-steamdeck" = {
# path = [ "/var/log/journal/*/system.journal" ];
# rotate = 7;
# daily = true;
# compress = true;
# missingok = true;
# notifempty = true;
# };
# };
};
}