main
1{
2 pkgs,
3 config,
4 ...
5}:
6{
7 imports = [
8 ../common/programs/direnv.nix
9 ../common/programs/git.nix
10 ../common/programs/nix-ld.nix
11 ../common/programs/tmux.nix
12 ../common/services/networkmanager.nix
13 ../common/services/containers.nix
14 ../common/services/docker.nix
15 ../common/services/binfmt.nix
16
17 ../common/services/oomd.nix
18 ../../modules/laptop-keyboard-inhibit
19 # Build and cache infrastructure
20 ../../modules/harmonia
21 ../../modules/job-notify
22 ../../modules/flux-generate
23
24 ];
25
26 # Disable built-in MediaTek MT7922 WiFi (using USB TP-Link AC600 instead)
27 boot.blacklistedKernelModules = [ "mt7921e" ];
28
29 # Xbox controller support (Bluetooth)
30 hardware.xpadneo.enable = true;
31
32 # Gaming setup
33 programs.steam = {
34 enable = true;
35 remotePlay.openFirewall = true; # Steam Link support
36 dedicatedServer.openFirewall = true; # Game server hosting
37 };
38
39 programs.gamemode = {
40 enable = true;
41 settings = {
42 general = {
43 renice = 10; # Increase game process priority
44 };
45 gpu = {
46 apply_gpu_optimisations = "accept-responsibility";
47 gpu_device = 0;
48 amd_performance_level = "high"; # AMD GPU performance mode
49 };
50 };
51 };
52
53 # Greeter wallpaper (accessible to greeter user)
54 environment.etc."greetd/wallpaper.jpg".source = ../../dots/wallpapers/greeter.jpg;
55
56 # QMK/ZMK keyboard flashing support (udev rules + plugdev group)
57 hardware.keyboard.qmk.enable = true;
58
59 # Auto-disable internal keyboard when Eyelash Corne (BT) is connected
60 services.laptop-keyboard-inhibit = {
61 enable = false; # disabled: spams D-Bus with bluetooth reconnect attempts
62 keyboards = [ "Eyelash Corne" ];
63 };
64
65 # Age secrets
66 age.secrets = {
67 "opencode-password" = {
68 file = ../../secrets/okinawa/opencode-password.age;
69 mode = "400";
70 owner = "vincent";
71 };
72 "groq-api-key" = {
73 file = ../../secrets/okinawa/groq-api-key.age;
74 mode = "400";
75 owner = "vincent";
76 };
77 "openrouter-api-key" = {
78 file = ../../secrets/okinawa/openrouter-api-key.age;
79 mode = "400";
80 owner = "vincent";
81 };
82 "gemini-api-key" = {
83 file = ../../secrets/okinawa/gemini-api-key.age;
84 mode = "400";
85 owner = "vincent";
86 };
87 "xmpp-research-bot-password" = {
88 file = ../../secrets/okinawa/xmpp-research-bot-password.age;
89 mode = "400";
90 owner = "vincent";
91 };
92 # Harmonia binary cache signing key
93 "harmonia-okinawa-signing-key" = {
94 file = ../../secrets/harmonia/okinawa-signing-key.age;
95 mode = "440";
96 owner = "root";
97 group = "root";
98 };
99 # ntfy notification token (shared secret)
100 "ntfy-token" = {
101 file = ../../secrets/sakhalin/ntfy-token.age;
102 mode = "440";
103 owner = "root";
104 group = "users";
105 };
106 };
107
108 # Binary cache server (x86_64-linux)
109 services.harmonia-cache = {
110 enable = true;
111 signKeyPath = config.age.secrets."harmonia-okinawa-signing-key".path;
112 port = 5000;
113 workers = 4;
114 priority = 30;
115
116 # Nightly cache pre-population
117 builder = {
118 enable = true;
119 systems = [
120 "okinawa" # Self
121 "kyushu" # Work laptop
122 "sakhalin" # Server
123 ];
124 schedule = "02:00"; # 2 AM daily
125 notification = {
126 enable = true;
127 tokenFile = config.age.secrets."ntfy-token".path;
128 };
129 };
130 };
131
132 # Remote build system
133 services.job-notify = {
134 enable = true;
135 ntfyServer = "https://ntfy.sbr.pm";
136 ntfyTokenFile = config.age.secrets."ntfy-token".path;
137 defaultTopic = "builds";
138 };
139
140 # Website flux auto-generate and deploy (hourly)
141 services.flux-generate.enable = true;
142
143 # OpenCode web interface for remote AI coding
144 # Accessible via opencode.sbr.pm through rhea's Traefik reverse proxy
145 systemd.services.opencode-web =
146 let
147 opencode-config = pkgs.writeText "opencode.json" (
148 builtins.toJSON {
149 "$schema" = "https://opencode.ai/config.json";
150 provider = {
151 # Local llama-server (OpenAI-compatible API)
152 llama-server = {
153 npm = "@ai-sdk/openai-compatible";
154 name = "llama-server (local)";
155 options = {
156 baseURL = "http://127.0.0.1:8090/v1";
157 apiKey = "local-test";
158 };
159 models = {
160 "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M" = {
161 name = "Qwen3 Coder 30B MoE";
162 };
163 "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M" = {
164 name = "Qwen 2.5 Coder 7B";
165 };
166 "Qwen/Qwen3-8B-GGUF:Q4_K_M" = {
167 name = "Qwen3 8B";
168 };
169 "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M" = {
170 name = "DeepSeek R1 Distill 7B";
171 };
172 "unsloth/Phi-4-mini-instruct-GGUF:Q4_K_M" = {
173 name = "Phi-4 mini 3.8B";
174 };
175 "Qwen/Qwen3-Coder-Next-GGUF:Q3_K_M" = {
176 name = "Qwen3 Coder Next 80B MoE";
177 };
178 };
179 };
180 # Vertex AI (Claude on Google Cloud)
181 google-vertex = {
182 options = {
183 project = "itpc-gcp-pnd-pe-eng-claude";
184 location = "global";
185 };
186 };
187 };
188 server = {
189 hostname = "0.0.0.0";
190 port = 5555;
191 };
192 }
193 );
194 in
195 {
196 description = "OpenCode web interface";
197 after = [
198 "network.target"
199 "llama-cpp.service"
200 ];
201 wantedBy = [ "multi-user.target" ];
202
203 serviceConfig = {
204 Type = "simple";
205 WorkingDirectory = "/home/vincent";
206 ExecStart = pkgs.writeShellScript "opencode-web-start" ''
207 # Load secrets into environment
208 export OPENCODE_SERVER_PASSWORD=$(cat ${config.age.secrets."opencode-password".path})
209 export GROQ_API_KEY=$(cat ${config.age.secrets."groq-api-key".path})
210 export OPENROUTER_API_KEY=$(cat ${config.age.secrets."openrouter-api-key".path})
211 export GOOGLE_GENERATIVE_AI_API_KEY=$(cat ${config.age.secrets."gemini-api-key".path})
212 exec ${pkgs.opencode}/bin/opencode web
213 '';
214 Restart = "on-failure";
215 RestartSec = 10;
216
217 User = "vincent";
218 Group = "users";
219 };
220
221 environment = {
222 HOME = "/home/vincent";
223 OPENCODE_SERVER_USERNAME = "opencode";
224 # Config file location
225 XDG_CONFIG_HOME = "/run/opencode/config";
226 GOOGLE_VERTEX_PROJECT = "itpc-gcp-pnd-pe-eng-claude";
227 GOOGLE_VERTEX_LOCATION = "global";
228 };
229
230 # Copy config file before start (--remove-destination needed because
231 # the nix store source is read-only, so cp can't overwrite in place)
232 preStart = ''
233 mkdir -p /run/opencode/config/opencode
234 cp --remove-destination ${opencode-config} /run/opencode/config/opencode/opencode.json
235 '';
236 };
237
238 # Ensure /run/opencode directory exists
239 systemd.tmpfiles.rules = [
240 "d /run/opencode 0755 vincent users -"
241 "d /run/opencode/config 0755 vincent users -"
242 ];
243
244 # Firewall: OpenCode web + llama-server (VPN access) + monitoring + Harmonia
245 networking.firewall.allowedTCPPorts = [
246 5000 # Harmonia binary cache
247 5555 # OpenCode web
248 8090 # llama-server
249 8880 # readwise-reader triage report
250 9000 # Prometheus node exporter
251 ];
252
253 # llama.cpp server for local LLM inference with Vulkan GPU (RX 6700S)
254 # Router mode: serves multiple models with on-demand loading (--models-max 1)
255 # Only one model loaded in VRAM at a time; auto-swaps on request
256 # Benchmarks on RX 6700S: 7B dense ~40 tok/s, 3B active MoE ~20-40 tok/s (Vulkan, Q4_K_M)
257 systemd.services.llama-cpp =
258 let
259 llama-cpp-vulkan = pkgs.llama-cpp.override { vulkanSupport = true; };
260
261 # Model preset INI for router mode
262 # Section names become model IDs in the API (used in models.json)
263 modelsPreset = pkgs.writeText "llama-models.ini" ''
264 version = 1
265
266 ; Shared settings for all models
267 [*]
268 n-gpu-layers = 99
269 jinja = true
270
271 ; === Coding models ===
272
273 ; Qwen3 Coder Next 80B MoE (3B active) — best coding model, needs ~48GB RAM
274 [Qwen/Qwen3-Coder-Next-GGUF:Q3_K_M]
275
276 ; Qwen3 Coder 30B MoE (3B active) — sweet spot coding, ~19GB RAM
277 [Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M]
278
279 ; Qwen 2.5 Coder 7B dense — lightweight coding, ~5GB (current default)
280 [Qwen/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M]
281
282 ; === General purpose models ===
283
284 ; Qwen3 8B dense — best all-rounder (reasoning, tool use, multilingual)
285 [Qwen/Qwen3-8B-GGUF:Q4_K_M]
286
287 ; DeepSeek R1 Distill 7B — deep reasoning / chain-of-thought
288 [bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M]
289
290 ; Phi-4 mini 3.8B — ultra-fast utility model
291 [unsloth/Phi-4-mini-instruct-GGUF:Q4_K_M]
292 '';
293
294 in
295 {
296 description = "llama.cpp server (Vulkan, router mode)";
297 after = [ "network.target" ];
298 wantedBy = [ "multi-user.target" ];
299
300 environment = {
301 GGML_VK_VISIBLE_DEVICES = "0"; # Use RX 6700S dGPU only
302 HIP_VISIBLE_DEVICES = ""; # Disable ROCm to avoid conflicts
303 };
304
305 serviceConfig = {
306 Type = "idle";
307 KillSignal = "SIGINT";
308 ExecStart = builtins.concatStringsSep " " [
309 "${llama-cpp-vulkan}/bin/llama-server"
310 "--log-disable"
311 "--host 0.0.0.0"
312 "--port 8090"
313 "--models-preset ${modelsPreset}"
314 "--models-max 1"
315 "-np 1"
316 "--api-key local-test"
317 ];
318 Restart = "on-failure";
319 RestartSec = 30;
320
321 # Run as vincent to access HuggingFace model cache
322 User = "vincent";
323 Group = "users";
324
325 # GPU access requires relaxed sandboxing
326 PrivateDevices = false;
327 ProtectHome = false;
328 MemoryDenyWriteExecute = false;
329 };
330 };
331
332 # Oneshot service to pre-download all LLM models
333 # Run manually: sudo systemctl start llama-download-models
334 systemd.services.llama-download-models =
335 let
336 llama-cpp-vulkan = pkgs.llama-cpp.override { vulkanSupport = true; };
337 in
338 {
339 description = "Download LLM models for llama.cpp";
340 after = [ "network-online.target" ];
341 wants = [ "network-online.target" ];
342
343 serviceConfig = {
344 Type = "oneshot";
345 RemainAfterExit = false;
346 User = "vincent";
347 Group = "users";
348 ExecStart = pkgs.writeShellScript "llama-download-models" ''
349 set -euo pipefail
350 export PATH="${llama-cpp-vulkan}/bin:$PATH"
351
352 models=(
353 "Qwen/Qwen3-Coder-Next-GGUF:Q3_K_M"
354 "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M"
355 "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M"
356 "Qwen/Qwen3-8B-GGUF:Q4_K_M"
357 "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M"
358 "unsloth/Phi-4-mini-instruct-GGUF:Q4_K_M"
359 )
360
361 for model in "''${models[@]}"; do
362 echo "Checking/downloading $model..."
363 # Use llama-cli to trigger download, then exit immediately
364 timeout 120 llama-cli -hf "$model" -p "test" -n 1 --no-display-prompt 2>&1 || true
365 echo "Done: $model"
366 done
367 '';
368 };
369 };
370
371 # System packages for LLM, gaming, and tools
372 environment.systemPackages = with pkgs; [
373 # LLM tools (same package as the service, for CLI use)
374 (llama-cpp.override {
375 vulkanSupport = true;
376 })
377
378 # GPU monitoring and management
379 radeontop # GPU usage monitor
380 clinfo # Check OpenCL
381 vulkan-tools # vulkaninfo
382 rocmPackages.rocminfo
383 asusctl # CLI for supergfxctl
384 supergfxctl
385
386 # Gaming tools
387 mangohud # FPS and performance overlay
388 goverlay # MangoHud configuration GUI
389 protonup-qt # Install custom Proton versions (Proton-GE)
390 protontricks # Winetricks for Proton prefixes
391 heroic # GOG/Epic Games launcher
392
393 # Development
394 python3
395 uv
396
397 # Audio utilities for G14
398 alsa-utils # amixer, alsactl for AMP control
399 jamesdsp # DSP/EQ to replace Dolby Atmos (use with asus-jamesdsp presets)
400 ];
401
402 # Prometheus node exporter (configured in common module)
403 # Port and basic collectors already set in ../common/base (prometheus-exporters-node.nix)
404
405 # Keep dGPU (Navi 23 / RX 6700S) awake so HDMI hotplug is detected.
406 # The HDMI port is wired to the dGPU; runtime suspend prevents detection.
407 services.udev.extraRules = ''
408 ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x1002", ATTR{device}=="0x73ef", ATTR{power/control}="on"
409 '';
410
411 # Lid handling: ignore (desktop replacement, mostly on AC)
412 services.logind.settings.Login = {
413 HandleLidSwitch = "ignore";
414 HandleLidSwitchExternalPower = "ignore";
415 HandleLidSwitchDocked = "ignore";
416 };
417
418 # ASUS G14 2022 Audio Fix
419 # Sets ALSA mixer levels at boot and after resume
420 systemd.services.asus-g14-audio-fix = {
421 description = "ASUS G14 Audio Amplifier Initialization";
422 after = [
423 "sound.target"
424 "alsa-restore.service"
425 ];
426 wantedBy = [
427 "multi-user.target"
428 "post-resume.target"
429 ];
430
431 serviceConfig = {
432 Type = "oneshot";
433 RemainAfterExit = true;
434 ExecStart = pkgs.writeShellScript "g14-audio-init" ''
435 # Find the ALC285 audio card (internal speakers)
436 CARD=$(${pkgs.alsa-utils}/bin/aplay -l | grep "ALC285" | head -1 | sed 's/card \([0-9]\).*/\1/')
437
438 if [ -z "$CARD" ]; then
439 echo "ERROR: Could not find ALC285 audio card"
440 exit 1
441 fi
442
443 echo "Initializing G14 audio on card $CARD"
444
445 # Set Master volume to 100% and unmute
446 ${pkgs.alsa-utils}/bin/amixer -c "$CARD" sset Master 100% unmute || true
447
448 # Set Speaker volume (tweeters)
449 ${pkgs.alsa-utils}/bin/amixer -c "$CARD" sset Speaker 100% unmute || true
450
451 # Set Bass Speaker (subwoofers)
452 ${pkgs.alsa-utils}/bin/amixer -c "$CARD" sset 'Bass Speaker' unmute || true
453
454 # Set Headphone volume
455 ${pkgs.alsa-utils}/bin/amixer -c "$CARD" sset Headphone 100% unmute || true
456
457 echo "G14 audio initialization complete"
458 '';
459 };
460 };
461
462 services.udev.packages = [ pkgs.sane-airscan ];
463 hardware.sane = {
464 enable = true;
465 extraBackends = [ pkgs.sane-airscan ];
466 openFirewall = true;
467 netConf = "192.168.12.70";
468 };
469}