diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix index c0c8affb983b..4237f792eb02 100644 --- a/maintainers/maintainer-list.nix +++ b/maintainers/maintainer-list.nix @@ -22688,6 +22688,12 @@ keys = [ { fingerprint = "7DCA 5615 8AB2 621F 2F32 9FF4 1C7C E491 479F A273"; } ]; name = "Rahul Butani"; }; + rrvsh = { + email = "rafiq@rrv.sh"; + github = "rrvsh"; + githubId = 20300874; + name = "Mohammad Rafiq"; + }; rseichter = { email = "nixos.org@seichter.de"; github = "rseichter"; diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 4d5f7c6a9db7..9f8cd65c86fc 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -18,6 +18,8 @@ - [ImmichFrame](https://immichframe.dev/), display your photos from Immich as a digital photo frame. Available as `services.immichframe`. +- [LibreChat](https://www.librechat.ai/), open-source self-hostable ChatGPT clone with Agents and RAG APIs. Available as [services.librechat](#opt-services.librechat.enable). + - [udp-over-tcp](https://github.com/mullvad/udp-over-tcp), a tunnel for proxying UDP traffic over a TCP stream. Available as `services.udp-over-tcp`. ## Backward Incompatibilities {#sec-release-26.05-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 2df1045248b5..2bfca447e011 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1655,6 +1655,7 @@ ./services/web-apps/lasuite-docs.nix ./services/web-apps/lasuite-meet.nix ./services/web-apps/lemmy.nix + ./services/web-apps/librechat.nix ./services/web-apps/librespeed.nix ./services/web-apps/libretranslate.nix ./services/web-apps/limesurvey.nix diff --git a/nixos/modules/services/web-apps/librechat.nix b/nixos/modules/services/web-apps/librechat.nix new file mode 100644 index 000000000000..a1cfd2e67559 --- /dev/null +++ b/nixos/modules/services/web-apps/librechat.nix @@ -0,0 +1,259 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.librechat; + format = pkgs.formats.yaml { }; + configFile = format.generate "librechat.yaml" cfg.settings; + exportCredentials = n: _: ''export ${n}="$(${pkgs.systemd}/bin/systemd-creds cat ${n}_FILE)"''; + exportAllCredentials = vars: lib.concatStringsSep "\n" (lib.mapAttrsToList exportCredentials vars); + getLoadCredentialList = lib.mapAttrsToList (n: v: "${n}_FILE:${v}") cfg.credentials; +in +{ + options.services.librechat = { + enable = lib.mkEnableOption "the LibreChat server"; + + package = lib.mkPackageOption pkgs "librechat" { }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to open the port in the firewall. + ''; + }; + + dataDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/librechat"; + example = "/persist/librechat"; + description = "Absolute path for where the LibreChat server will use as its data directory to store logs, user uploads, and generated images."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "librechat"; + example = "alice"; + description = "The user to run the service as."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "librechat"; + example = "users"; + description = "The group to run the service as."; + }; + + credentials = lib.mkOption { + type = lib.types.attrsOf lib.types.path; + default = { }; + example = { + CREDS_KEY = "/run/secrets/creds_key"; + }; + description = '' + Environment variables which are loaded from the contents of files at a file paths, mainly used for secrets. + See [LibreChat environment variables](https://www.librechat.ai/docs/configuration/dotenv). + Alternatively you can use `services.librechat.credentialsFile` to define all the variables in a single file. + ''; + }; + + env = lib.mkOption { + type = lib.types.submodule { + freeformType = + with lib.types; + attrsOf (oneOf [ + str + path + (coercedTo int toString str) + (coercedTo float toString str) + (coercedTo port toString str) + (coercedTo bool (x: if x then "true" else "false") str) + ]); + options = { + CONFIG_PATH = lib.mkOption { + default = configFile; + internal = true; + readOnly = true; + }; + PORT = lib.mkOption { + type = with lib.types; coercedTo port toString str; + default = 3080; + example = 2309; + description = "The value that will be passed to the PORT environment variable, telling LibreChat what to listen on."; + }; + }; + }; + example = { + ALLOW_REGISTRATION = true; + HOST = "0.0.0.0"; + PORT = 2309; + CONSOLE_JSON_STRING_LENGTH = 255; + }; + description = '' + Environment variables that will be set for the service. + See [LibreChat environment variables](https://www.librechat.ai/docs/configuration/dotenv). + ''; + }; + + credentialsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + description = '' + Path to a file that contains environment variables. + See [LibreChat environment variables](https://www.librechat.ai/docs/configuration/dotenv). + + Example content of the file: + ``` + CREDS_KEY=6d6deb03cdfb27ea454f6b9ddd42494bdce4af25d50d8aee454ddce583690cc5 + ``` + + Alternatively you can use `services.librechat.credentials` to define the value of each variable in a separate file. + ''; + default = "/dev/null"; + example = "/run/secrets/librechat"; + }; + + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = format.type; + }; + default = { }; + example = { + version = "1.0.8"; + cache = true; + interface = { + privacyPolicy = { + externalUrl = "https://librechat.ai/privacy-policy"; + openNewTab = true; + }; + }; + endpoints = { + custom = [ + { + name = "OpenRouter"; + apiKey = "\${OPENROUTER_KEY}"; + baseURL = "https://openrouter.ai/api/v1"; + models = { + default = [ "meta-llama/llama-3-70b-instruct" ]; + fetch = true; + }; + titleConvo = true; + titleModule = "meta-llama/llama-3-70b-instruct"; + dropParams = [ "stop" ]; + modelDisplayLabel = "OpenRouter"; + } + ]; + }; + }; + description = '' + A free-form attribute set that will be written to librechat.yaml. + See the [LibreChat configuration options](https://www.librechat.ai/docs/configuration/librechat_yaml). + You can use environment variables by wrapping them in $\{}. Take care to escape the \$ character. + ''; + }; + + enableLocalDB = lib.mkEnableOption "a local mongodb instance"; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.env ? MONGO_URI || cfg.credentials ? MONGO_URI; + message = "MongoDB is not configured, either set `services.librechat.enableLocalDB = true` or provide your own MongoDB instance by setting `services.librechat.env.MONGO_URI` or `services.credentials.MONGO_URI`."; + } + { + assertion = + cfg.credentialsFile != "/dev/null" + || ( + (cfg.env ? CREDS_KEY || cfg.credentials ? CREDS_KEY) + && (cfg.env ? CREDS_IV || cfg.credentials ? CREDS_IV) + && (cfg.env ? JWT_SECRET || cfg.credentials ? JWT_SECRET) + && (cfg.env ? JWT_REFRESH_SECRET || cfg.credentials ? JWT_REFRESH_SECRET) + ); + message = '' + CREDS_KEY, CREDS_IV, JWT_SECRET, and JWT_REFRESH_SECRET must be defined in `services.librechat.credentials` and point to locations of files on the host or in a file that `services.credentialsFile` is pointing to. + Alternatively it can be defined in `services.librechat.env` with literal values but they will be saved within the world-readable nix store.; + You can use https://www.librechat.ai/toolkit/creds_generator to generate these. + ''; + } + ]; + + networking.firewall.allowedTCPPorts = lib.optional cfg.openFirewall cfg.port; + + systemd.tmpfiles.settings."10-librechat"."${cfg.dataDir}".d = { + mode = "0755"; + inherit (cfg) user group; + }; + + systemd.services.librechat = { + wantedBy = [ "multi-user.target" ]; + after = [ + "tmpfiles.target" + ] + ++ lib.optional cfg.enableLocalDB "mongodb.service"; + description = "Open-source app for all your AI conversations, fully customizable and compatible with any AI provider"; + environment = cfg.env; + script = # sh + '' + ${exportAllCredentials cfg.credentials} + cd ${cfg.dataDir} + ${lib.getExe cfg.package} + ''; + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + StateDirectory = builtins.baseNameOf cfg.dataDir; + WorkingDirectory = cfg.dataDir; + LoadCredential = getLoadCredentialList; + EnvironmentFile = cfg.credentialsFile; + Restart = "on-failure"; + RestartSec = 10; + + # Hardening + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + PrivateUsers = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateMounts = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + UMask = "0077"; + }; + }; + + users.users.librechat = lib.mkIf (cfg.user == "librechat") { + name = "librechat"; + isSystemUser = true; + group = "librechat"; + description = "LibreChat server user"; + }; + + users.groups.librechat = lib.mkIf (cfg.user == "librechat") { }; + + services.librechat.env.MONGO_URI = lib.mkIf cfg.enableLocalDB "mongodb://localhost:27017"; + services.mongodb.enable = lib.mkIf cfg.enableLocalDB true; + }; + + meta.maintainers = with lib.maintainers; [ + gepbird + niklaskorz + rrvsh + ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 3b28c61fee03..e4f0b3f121f9 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -857,6 +857,7 @@ in lemurs-xorg = runTest ./lemurs/lemurs-xorg.nix; lemurs-xorg-script = runTest ./lemurs/lemurs-xorg-script.nix; libinput = runTest ./libinput.nix; + librechat = runTest ./librechat.nix; librenms = runTest ./librenms.nix; libresprite = runTest ./libresprite.nix; libreswan = runTest ./libreswan.nix; diff --git a/nixos/tests/librechat.nix b/nixos/tests/librechat.nix new file mode 100644 index 000000000000..e737c139844f --- /dev/null +++ b/nixos/tests/librechat.nix @@ -0,0 +1,50 @@ +# Run this test with NIXPKGS_ALLOW_UNFREE=1 +{ lib, ... }: +{ + name = "librechat"; + + meta.maintainers = with lib.maintainers; [ + gepbird + niklaskorz + rrvsh + ]; + + nodes.machine = + { pkgs, ... }: + let + # !!! Don't do this with real keys. The /nix store is world-readable! + credsKeyFile = pkgs.writeText "librechat-creds-key" "6d6deb03cdfb27ea454f6b9ddd42494bdce4af25d50d8aee454ddce583690cc5"; + credsIvFile = pkgs.writeText "librechat-creds-iv" "7c09a571f65ac793611685cc9ab1dbe7"; + jwtSecret = pkgs.writeText "librechat-jwt-secret" "29c4dc7f7de15306accf5eddb4cb8a70eb233d9fba4301f8f47f14c8c047ac81"; + jwtRefreshSecret = pkgs.writeText "librechat-jwt-refresh-secret" "f2c1685561f2f570b3e7955df267b5c602ee099f14dc5caa0dacc320580ea180"; + in + { + services.librechat = { + enable = true; + env = { + ALLOW_REGISTRATION = true; + }; + credentials = { + # The following were randomly generated with https://www.librechat.ai/toolkit/creds_generator + CREDS_KEY = credsKeyFile; + CREDS_IV = credsIvFile; + JWT_SECRET = jwtSecret; + JWT_REFRESH_SECRET = jwtRefreshSecret; + }; + enableLocalDB = true; + }; + }; + + testScript = '' + machine.start() + + machine.succeed("grep -qF 'ALLOW_REGISTRATION=true' /etc/systemd/system/librechat.service") + + machine.wait_for_unit("librechat.service") + machine.wait_for_open_port(3080) + + machine.succeed("curl --fail http://localhost:3080/") + + machine.shutdown() + ''; +} diff --git a/pkgs/by-name/li/librechat/package.nix b/pkgs/by-name/li/librechat/package.nix index 1bdedae56441..bde68f2fa1db 100644 --- a/pkgs/by-name/li/librechat/package.nix +++ b/pkgs/by-name/li/librechat/package.nix @@ -7,6 +7,7 @@ node-gyp, vips, nix-update-script, + nixosTests, }: buildNpmPackage rec { @@ -84,6 +85,9 @@ buildNpmPackage rec { "^v(\\d+\\.\\d+\\.\\d+)$" ]; }; + tests = { + inherit (nixosTests) librechat; + }; }; meta = {