{ config, lib, pkgs, ... }: let cfg = config.services.liveIntent; app = builtins.path { path = ../../../services/live-intent; name = "live-intent"; }; host = "intent.${config.domain2}"; port = 8787; secretFile = ../../../secrets/liveIntentOpenaiApiKey.age; hasApiKeySecret = builtins.pathExists secretFile; in { options.services.liveIntent = { enable = lib.mkEnableOption "Tailnet-only voice conversation and transcript saving"; liveModel = lib.mkOption { type = lib.types.str; default = "gpt-live-1"; description = "OpenAI GPT-Live model used for WebRTC conversations."; }; backendModel = lib.mkOption { type = lib.types.str; default = "gpt-5.6-luna"; description = "Responses model used for in-conversation planning delegation."; }; sessionMaxSeconds = lib.mkOption { type = lib.types.ints.between 60 1800; default = 600; description = "Browser-enforced maximum duration of one Live session."; }; }; config = lib.mkMerge [ { services.liveIntent.enable = lib.mkDefault true; } (lib.mkIf cfg.enable { # The encrypted file deliberately remains absent until the administrator # adds it via agenix. This keeps offline evaluation and UI tests usable; # without it the server returns a controlled 503 for Live session setup. age.secrets = lib.optionalAttrs hasApiKeySecret { liveIntentOpenaiApiKey = { file = secretFile; owner = "root"; mode = "0400"; }; }; systemd.services.live-intent = { description = "Tailnet-only GPT-Live conversation and transcript service"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; serviceConfig = { Type = "simple"; DynamicUser = true; StateDirectory = "live-intent"; StateDirectoryMode = "0700"; UMask = "0077"; WorkingDirectory = app; ExecStart = "${pkgs.nodejs}/bin/node ${app}/server.mjs"; Restart = "on-failure"; RestartSec = "5s"; TimeoutStopSec = "20s"; MemoryMax = "512M"; NoNewPrivileges = true; PrivateTmp = true; PrivateDevices = true; ProtectHome = true; ProtectSystem = "strict"; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; } // lib.optionalAttrs hasApiKeySecret { # The key is never an environment variable or browser response. It # is materialised by systemd only in this unit's credential directory. LoadCredential = "openai-api-key:${config.age.secrets.liveIntentOpenaiApiKey.path}"; }; environment = { LIVE_INTENT_HOST = "127.0.0.1"; LIVE_INTENT_PORT = toString port; LIVE_INTENT_ORIGIN = "https://${host}"; LIVE_INTENT_TRANSCRIPTS_DIR = "/var/lib/live-intent/transcripts"; LIVE_INTENT_KEY_CREDENTIAL = "openai-api-key"; LIVE_INTENT_LIVE_MODEL = cfg.liveModel; LIVE_INTENT_BACKEND_MODEL = cfg.backendModel; LIVE_INTENT_SESSION_MAX_SECONDS = toString cfg.sessionMaxSeconds; }; }; services.traefik.dynamicConfigOptions.http = { routers.live-intent = { rule = "Host(`${host}`)"; entryPoints = [ "web-internal" ]; service = "live-intent"; tls = { }; }; services.live-intent.loadBalancer.servers = [ { url = "http://127.0.0.1:${toString port}"; } ]; }; }) ]; }