Commit 2befbb27bab2

Vincent Demeester <vincent@sbr.pm>
2026-02-16 16:55:11
feat: add OpenCode web and expose llama-server
Added OpenCode web interface on okinawa (opencode.sbr.pm) with multi-provider support (llama-server, Vertex AI, Gemini, Groq, OpenRouter). Replaced obsolete ollama route with llama-server (llm.sbr.pm) and opened llama-server to VPN.
1 parent 3884e64
secrets/okinawa/gemini-api-key.age
@@ -0,0 +1,10 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA A3fHEtxe9/4OeuW8FXm/e5KsVzgbTGB2ARiQccP0hyA7
+kE0gbISO1Z/VpjmB4kk0gBSMMEj7bZhZZDewkc57P6I
+-> piv-p256 cUinNw A/gvIJ6g6KK/jgrs9Mt5NBwy231oJ7r6DntZdk8bS9yi
+TGcVqkqYuwrmceVvFSsiIWws7lbBAPHPP5cV6e6GrrA
+-> ssh-ed25519 3Z+PEA fq9mL8HZljoOC2Kgihde9ZP90IrCrOVT3YRUHjvR7Sc
+UEkzZY1pDy8QRZ7E4MXuMPgZQjfU/OvpsSEmwe1o340
+--- D2wfYs58FZBT1qfjbMHYbP3kdx38ZFwYTVSv7qY38wM
+�1I'���?�4s���Q#S�7��'xd�h���,�79�<{޳��ҙ��x�g�p�&�)
+�dݎ1�H��
\ No newline at end of file
secrets/okinawa/groq-api-key.age
Binary file
secrets/okinawa/opencode-password.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA A9JTl1FqobWybB9TSTUgTg8aZf2Kj0NgBbR0oREkH9iu
+71cLdZRzQ4yC8D6cRBc0NNAXapnHg4igyYRjaV1ljbA
+-> piv-p256 cUinNw A4qgekq6P7RnQZ2DkGtnLA+JqERDYIEi418bDsX4wDtV
+5dLW2ha+MwhauzUMLurYeGCdL5TZEJWUWQt7Rg7tN+U
+-> ssh-ed25519 3Z+PEA OvO7e/qMLqcHvuJysqLNvhVAFF0bnguffDRqMA0wZT4
++Wdn+iaQqjQFf5/b44ok/VPxkaPVJI61G0n/agtNUzc
+--- FlO3nWgpodCUheE2ycLSaK+wPk+g/spCRksrj0GXJ2s
+��D�3��m�U���{��J$Uj×�~�ʜ	��6�;y��rG��|xn�
\ No newline at end of file
secrets/okinawa/openrouter-api-key.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA A+J7Q0Svnkf+Qup3i3vWAKvhOLQ7v2WUWgA/SKsChX15
+s7bw+A9YuQLWy1dd/PFqnOAW6LP6NZPq1UDn6C7sNAk
+-> piv-p256 cUinNw AxJJna3MTbqDA9y7OtyzgpQkYymE+M+UoeOI2AUZqoKO
+zV6WYn51PUWfccLsM1Z7GvSjaxzP+6I3LB0IA8kCinM
+-> ssh-ed25519 3Z+PEA tODvvH4TtPeWcAT6QLQbQfit+Daljkq/5Y0tLGXpojU
+h01f48auvvVrGX49hOVsz4Jt1IBdCI4qfeB5O0Mwsi8
+--- gTronstY7QZJzmTsOnMeBPYvx2QQ1hV2OMRyPwno0BU
+��_�
�&�E7*�����^(?�s?t:�Zn������A2�	�okՃ���w��	͜陂�8=��T�������	�EQ͉�xW����/�� �A&������
\ No newline at end of file
systems/okinawa/extra.nix
@@ -1,11 +1,14 @@
 {
   pkgs,
+  inputs,
   libx,
   globals,
+  config,
   ...
 }:
 {
   imports = [
+    inputs.daneel.nixosModules.daneel
     ../common/programs/direnv.nix
     ../common/programs/git.nix
     ../common/programs/nix-ld.nix
@@ -65,6 +68,159 @@
     endpointPublicKey = "${globals.machines.kerkouane.net.vpn.pubkey}";
   };
 
+  # Age secrets for OpenCode web
+  age.secrets = {
+    "opencode-password" = {
+      file = ../../secrets/okinawa/opencode-password.age;
+      mode = "400";
+      owner = "vincent";
+    };
+    "groq-api-key" = {
+      file = ../../secrets/okinawa/groq-api-key.age;
+      mode = "400";
+      owner = "vincent";
+    };
+    "openrouter-api-key" = {
+      file = ../../secrets/okinawa/openrouter-api-key.age;
+      mode = "400";
+      owner = "vincent";
+    };
+    "gemini-api-key" = {
+      file = ../../secrets/okinawa/gemini-api-key.age;
+      mode = "400";
+      owner = "vincent";
+    };
+    "xmpp-research-bot-password" = {
+      file = ../../secrets/okinawa/xmpp-research-bot-password.age;
+      mode = "400";
+      owner = "vincent";
+    };
+  };
+
+  # Daneel XMPP Research Bot
+  services.daneel = {
+    enable = true;
+    xmppJid = "researchbot@xmpp.sbr.pm";
+    ownerJid = "vincent@xmpp.sbr.pm";
+    xmppPasswordFile = config.age.secrets."xmpp-research-bot-password".path;
+    geminiApiKeyFile = config.age.secrets."gemini-api-key".path;
+    inboxPath = "/home/vincent/desktop/org/inbox.org";
+    debug = true;
+    user = "vincent";
+    group = "users";
+  };
+
+  # OpenCode web interface for remote AI coding
+  # Accessible via opencode.sbr.pm through rhea's Traefik reverse proxy
+  systemd.services.opencode-web =
+    let
+      opencode-config = pkgs.writeText "opencode.json" (
+        builtins.toJSON {
+          "$schema" = "https://opencode.ai/config.json";
+          provider = {
+            # Local llama-server (OpenAI-compatible API)
+            llama-server = {
+              npm = "@ai-sdk/openai-compatible";
+              name = "llama-server (local)";
+              options = {
+                baseURL = "http://127.0.0.1:8090/v1";
+                apiKey = "local-test";
+              };
+              models = {
+                "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M" = {
+                  name = "Qwen3 Coder 30B MoE";
+                };
+                "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M" = {
+                  name = "Qwen 2.5 Coder 7B";
+                };
+                "Qwen/Qwen3-8B-GGUF:Q4_K_M" = {
+                  name = "Qwen3 8B";
+                };
+                "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M" = {
+                  name = "DeepSeek R1 Distill 7B";
+                };
+                "unsloth/Phi-4-mini-instruct-GGUF:Q4_K_M" = {
+                  name = "Phi-4 mini 3.8B";
+                };
+                "Qwen/Qwen3-Coder-Next-GGUF:Q3_K_M" = {
+                  name = "Qwen3 Coder Next 80B MoE";
+                };
+              };
+            };
+            # Vertex AI (Claude on Google Cloud)
+            google-vertex = {
+              options = {
+                project = "cloudability-it-gemini";
+                location = "us-east5";
+              };
+            };
+          };
+          server = {
+            hostname = "0.0.0.0";
+            port = 5555;
+          };
+        }
+      );
+    in
+    {
+      description = "OpenCode web interface";
+      after = [
+        "network.target"
+        "llama-cpp.service"
+      ];
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        Type = "simple";
+        WorkingDirectory = "/home/vincent";
+        ExecStart = "${pkgs.opencode}/bin/opencode web";
+        Restart = "on-failure";
+        RestartSec = 10;
+
+        User = "vincent";
+        Group = "users";
+
+        # Environment file for secrets (built at service start)
+        EnvironmentFile = "/run/opencode/env";
+      };
+
+      environment = {
+        HOME = "/home/vincent";
+        OPENCODE_SERVER_USERNAME = "opencode";
+        # Config file location
+        XDG_CONFIG_HOME = "/run/opencode/config";
+        GOOGLE_VERTEX_PROJECT = "cloudability-it-gemini";
+        GOOGLE_VERTEX_LOCATION = "us-east5";
+      };
+
+      # Build environment file from secrets and copy config
+      preStart = ''
+        mkdir -p /run/opencode/config/opencode
+        cp ${opencode-config} /run/opencode/config/opencode/opencode.json
+
+        # Build env file from secrets
+        {
+          echo "OPENCODE_SERVER_PASSWORD=$(cat ${config.age.secrets."opencode-password".path})"
+          echo "GROQ_API_KEY=$(cat ${config.age.secrets."groq-api-key".path})"
+          echo "OPENROUTER_API_KEY=$(cat ${config.age.secrets."openrouter-api-key".path})"
+          echo "GOOGLE_GENERATIVE_AI_API_KEY=$(cat ${config.age.secrets."gemini-api-key".path})"
+        } > /run/opencode/env
+        chmod 400 /run/opencode/env
+      '';
+    };
+
+  # Ensure /run/opencode directory exists
+  systemd.tmpfiles.rules = [
+    "d /run/opencode 0755 vincent users -"
+    "d /run/opencode/config 0755 vincent users -"
+  ];
+
+  # Firewall: OpenCode web + llama-server (VPN access)
+  networking.firewall.allowedTCPPorts = [
+    5555 # OpenCode web
+    8090 # llama-server
+  ];
+
   # llama.cpp server for local LLM inference with Vulkan GPU (RX 6700S)
   # Router mode: serves multiple models with on-demand loading (--models-max 1)
   # Only one model loaded in VRAM at a time; auto-swaps on request
@@ -123,7 +279,7 @@
         ExecStart = builtins.concatStringsSep " " [
           "${llama-cpp-vulkan}/bin/llama-server"
           "--log-disable"
-          "--host 127.0.0.1"
+          "--host 0.0.0.0"
           "--port 8090"
           "--models-preset ${modelsPreset}"
           "--models-max 1"
systems/rhea/extra.nix
@@ -341,11 +341,10 @@ in
                 ];
                 lidarr = mkRouter "lidarr" [ "lidarr.sbr.pm" ];
                 homepage = mkRouter "homepage" [ "homepage.sbr.pm" ];
-                # Ollama LLM service (VPN-only, no auth needed)
-                ollama = mkRouter "ollama" [
-                  "ollama.sbr.pm"
-                  "llm.sbr.pm"
-                ];
+                # OpenCode web interface on okinawa (VPN-only)
+                opencode = mkRouter "opencode" [ "opencode.sbr.pm" ];
+                # llama-server on okinawa (VPN-only, OpenAI-compatible API)
+                llm = mkRouter "llm" [ "llm.sbr.pm" ];
                 # SearXNG metasearch engine on aomi
                 search = mkRouter "search" [
                   "search.sbr.pm"
@@ -373,7 +372,8 @@ in
                 homepage = mkService "http://${builtins.head globals.machines.aion.net.ips}:3001";
                 audiobookshelf = mkService "http://${builtins.head globals.machines.aion.net.ips}:13378";
                 lidarr = mkService "http://${builtins.head globals.machines.aion.net.ips}:8686";
-                ollama = mkService "http://${builtins.head globals.machines.aomi.net.ips}:8000";
+                opencode = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:5555";
+                llm = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8090";
                 search = mkService "http://${builtins.head globals.machines.aomi.net.ips}:8888";
               };
             middlewares =
globals.nix
@@ -631,11 +631,10 @@ _: {
     n8n.host = "rhea";
     paperless.host = "rhea";
     grafana.host = "rhea";
-    # Ollama LLM service on aomi (routed through rhea/traefik)
-    ollama = {
-      host = "rhea";
-      aliases = [ "llm" ];
-    };
+    # OpenCode web interface on okinawa (routed through rhea/traefik)
+    opencode.host = "rhea";
+    # llama-server on okinawa (routed through rhea/traefik)
+    llm.host = "rhea";
     # SearXNG metasearch engine on aomi (routed through rhea/traefik)
     search = {
       host = "rhea";
secrets.nix
@@ -161,6 +161,14 @@ in
   "secrets/aomi/xmpp-research-bot-password.age".publicKeys = users ++ [ aomi ];
   "secrets/aomi/gemini-api-key.age".publicKeys = users ++ [ aomi ];
   "secrets/aomi/searxng-secret-key.age".publicKeys = users ++ [ aomi ];
+
+  # OpenCode web on okinawa
+  "secrets/okinawa/opencode-password.age".publicKeys = users ++ [ okinawa ];
+  "secrets/okinawa/groq-api-key.age".publicKeys = users ++ [ okinawa ];
+  "secrets/okinawa/openrouter-api-key.age".publicKeys = users ++ [ okinawa ];
+  "secrets/okinawa/gemini-api-key.age".publicKeys = users ++ [ okinawa ];
+  # Daneel XMPP bot on okinawa
+  "secrets/okinawa/xmpp-research-bot-password.age".publicKeys = users ++ [ okinawa ];
   "secrets/rhea/restic-aix-password.age".publicKeys = users ++ [ rhea ];
 
   # Harmonia binary cache signing keys