Both run on pinix, published only through the pinix Tailnet namespace and Traefik's web-internal entrypoint (unbound local-data + Headscale split DNS for intent./ereader.domain2). Live Intent reads its OpenAI key from the new pinix-only agenix secret. ereaderViewer is a path: flake input for now; the repo split plan moves it into its own repository on cgit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
3.7 KiB
Nix
103 lines
3.7 KiB
Nix
{ 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}"; }
|
|
];
|
|
};
|
|
})
|
|
];
|
|
}
|