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}