This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/hosts/pinix/localServices/live-intent.nix
pijukka c8cfdcd88e host(pinix): add live intent and ereader viewer services
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>
2026-09-17 12:18:55 +02:00

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}"; }
];
};
})
];
}