From 609b3384c079c425bcff576b4c5c0326fa118e1f Mon Sep 17 00:00:00 2001 From: muon Date: Mon, 1 Jun 2026 14:15:44 +0000 Subject: [PATCH] Add hermes --- flake.lock | 227 +++++++++++++++++++++++++++++- flake.nix | 3 + hosts/muon/configuration.nix | 5 + modules/nixos/server/auth2api.nix | 22 +++ modules/nixos/server/default.nix | 1 + modules/nixos/server/hermes.nix | 136 ++++++++++++++++++ pkgs/auth2api/package.nix | 7 +- pkgs/auth2api/thinking.patch | 130 +++++++++++++++++ utils.nix | 1 + 9 files changed, 526 insertions(+), 6 deletions(-) create mode 100644 modules/nixos/server/hermes.nix create mode 100644 pkgs/auth2api/thinking.patch diff --git a/flake.lock b/flake.lock index a3eec6d..9c6102b 100644 --- a/flake.lock +++ b/flake.lock @@ -132,6 +132,27 @@ } }, "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772408722, + "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { "inputs": { "nixpkgs-lib": [ "nvf", @@ -152,7 +173,7 @@ "type": "github" } }, - "flake-parts_2": { + "flake-parts_3": { "inputs": { "nixpkgs-lib": [ "stylix", @@ -224,6 +245,29 @@ "type": "github" } }, + "hermes-agent": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "npm-lockfile-fix": "npm-lockfile-fix", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix_2", + "uv2nix": "uv2nix_2" + }, + "locked": { + "lastModified": 1780309788, + "narHash": "sha256-h0pN9UHOL0g0S0tc2Eqhgn8DA6Y5b5dvoOZoO6ApE2o=", + "owner": "NousResearch", + "repo": "hermes-agent", + "rev": "ef3a650f05d2e9ce14855af1d0184f3ee93455da", + "type": "github" + }, + "original": { + "owner": "NousResearch", + "repo": "hermes-agent", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -426,6 +470,22 @@ } }, "nixpkgs": { + "locked": { + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { "locked": { "lastModified": 1778869304, "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", @@ -441,6 +501,27 @@ "type": "github" } }, + "npm-lockfile-fix": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775903712, + "narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=", + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8", + "type": "github" + }, + "original": { + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "type": "github" + } + }, "nur": { "inputs": { "flake-parts": [ @@ -469,7 +550,7 @@ "nvf": { "inputs": { "flake-compat": "flake-compat_3", - "flake-parts": "flake-parts", + "flake-parts": "flake-parts_2", "mnw": "mnw", "ndg": "ndg", "nixpkgs": [ @@ -491,14 +572,103 @@ "type": "github" } }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ], + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + }, + "locked": { + "lastModified": 1772555609, + "narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "c37f66a953535c394244888598947679af231863", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "pyproject-build-systems", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769936401, + "narHash": "sha256-kwCOegKLZJM9v/e/7cqwg1p/YjjTAukKPqmxKnAZRgA=", + "owner": "nix-community", + "repo": "pyproject.nix", + "rev": "b0d513eeeebed6d45b4f2e874f9afba2021f7812", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "pyproject.nix", + "type": "github" + } + }, + "pyproject-nix_2": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772865871, + "narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "e537db02e72d553cea470976b9733581bcf5b3ed", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "pyproject-nix_3": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "uv2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1771518446, + "narHash": "sha256-nFJSfD89vWTu92KyuJWDoTQJuoDuddkJV3TlOl1cOic=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "eb204c6b3335698dec6c7fc1da0ebc3c6df05937", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, "root": { "inputs": { + "hermes-agent": "hermes-agent", "home-manager": "home-manager", "impermanence": "impermanence", "nix-alien": "nix-alien", "nix-flatpak": "nix-flatpak", "nix-minecraft": "nix-minecraft", - "nixpkgs": "nixpkgs", + "nixpkgs": "nixpkgs_2", "nvf": "nvf", "sops-nix": "sops-nix", "stylix": "stylix", @@ -554,7 +724,7 @@ "base16-helix": "base16-helix", "base16-vim": "base16-vim", "firefox-gnome-theme": "firefox-gnome-theme", - "flake-parts": "flake-parts_2", + "flake-parts": "flake-parts_3", "gnome-shell": "gnome-shell", "nixpkgs": [ "nixpkgs" @@ -689,6 +859,55 @@ "type": "github" } }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "pyproject-build-systems", + "nixpkgs" + ], + "pyproject-nix": [ + "hermes-agent", + "pyproject-build-systems", + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1770770348, + "narHash": "sha256-A2GzkmzdYvdgmMEu5yxW+xhossP+txrYb7RuzRaqhlg=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "5d1b2cb4fe3158043fbafbbe2e46238abbc954b0", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + }, + "uv2nix_2": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ], + "pyproject-nix": "pyproject-nix_3" + }, + "locked": { + "lastModified": 1773039484, + "narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + }, "valheim-server": { "inputs": { "nixpkgs": [ diff --git a/flake.nix b/flake.nix index 85e9d9c..0f8ed3e 100644 --- a/flake.nix +++ b/flake.nix @@ -35,6 +35,8 @@ valheim-server.inputs.nixpkgs.follows = "nixpkgs"; nix-flatpak.url = "github:gmodena/nix-flatpak?ref=latest"; + + hermes-agent.url = "github:NousResearch/hermes-agent"; }; outputs = inputs @ { @@ -103,6 +105,7 @@ inputs.home-manager.nixosModules.default inputs.stylix.nixosModules.stylix inputs.impermanence.nixosModules.impermanence + inputs.hermes-agent.nixosModules.default ]; home-manager.sharedModules = [ diff --git a/hosts/muon/configuration.nix b/hosts/muon/configuration.nix index cf4d88c..b9fd8aa 100644 --- a/hosts/muon/configuration.nix +++ b/hosts/muon/configuration.nix @@ -64,6 +64,11 @@ in { # port defaults to 8317 # Run `auth2api --login` once after deploying to authorise your Claude account. + mods.server.hermes.enable = true; + # model defaults to claude-sonnet-4-6 (auth2api alias) via local proxy + # container defaults to true (Docker backend) — Docker already enabled above + # Run `hermes version` after deploying to confirm the CLI is on PATH. + mods.server.sync.enable = true; mods.tailscale.enable = true; mods.openvpn.enable = false; diff --git a/modules/nixos/server/auth2api.nix b/modules/nixos/server/auth2api.nix index 22ebf80..4cc50b9 100644 --- a/modules/nixos/server/auth2api.nix +++ b/modules/nixos/server/auth2api.nix @@ -50,6 +50,11 @@ let # off | errors | verbose debug: "${cfg.debug}" + thinking: + # Default reasoning effort for requests without reasoning_effort set. + # none | minimal | low | medium | high | xhigh + default-effort: "${cfg.thinking.defaultEffort}" + cloaking: cli-version: "2.1.88" entrypoint: "cli" @@ -110,6 +115,23 @@ with lib; { description = "Log level: off | errors | verbose."; }; + thinking = { + defaultEffort = mkOption { + type = types.enum [ "none" "minimal" "low" "medium" "high" "xhigh" ]; + default = "medium"; + description = '' + Default Claude extended-thinking budget injected into every request + that doesn't already carry a reasoning_effort field. + none = thinking disabled + minimal = 512 tokens + low = 1 024 tokens + medium = 8 192 tokens (default) + high = 24 576 tokens + xhigh = 32 768 tokens + ''; + }; + }; + openFirewall = mkOption { type = types.bool; default = false; diff --git a/modules/nixos/server/default.nix b/modules/nixos/server/default.nix index 8d9d4e8..dceff57 100644 --- a/modules/nixos/server/default.nix +++ b/modules/nixos/server/default.nix @@ -31,5 +31,6 @@ ./atuin.nix ./murmur.nix ./auth2api.nix + ./hermes.nix ]; } diff --git a/modules/nixos/server/hermes.nix b/modules/nixos/server/hermes.nix new file mode 100644 index 0000000..902251e --- /dev/null +++ b/modules/nixos/server/hermes.nix @@ -0,0 +1,136 @@ +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.mods.server.hermes; + auth2apiCfg = config.mods.server.auth2api; + + # auth2api exposes an OpenAI-compatible endpoint; point Hermes at it. + auth2apiUrl = "http://${auth2apiCfg.host}:${toString auth2apiCfg.port}/v1"; +in +with lib; { + options.mods.server.hermes = { + enable = mkEnableOption "Hermes AI agent (NousResearch)"; + + model = mkOption { + type = types.str; + default = "claude-sonnet-4-6"; + description = '' + Model identifier as exposed by auth2api. Available aliases: + sonnet, haiku, opus, claude-sonnet-4-6, claude-haiku-4-5, claude-opus-4-6, etc. + Run: curl http://127.0.0.1:8317/v1/models + ''; + }; + + container = { + enable = mkOption { + type = types.bool; + default = true; + description = "Run Hermes inside a persistent Ubuntu container."; + }; + + hostUsers = mkOption { + type = types.listOf types.str; + default = [ config.mods.user.name ]; + description = '' + Users that get a ~/.hermes symlink to the service stateDir and are + added to the hermes group for shared file access. + ''; + }; + + extraVolumes = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra volume mounts (host:container:mode) for the container."; + }; + }; + + addToSystemPackages = mkOption { + type = types.bool; + default = true; + description = '' + Put the hermes CLI on the system PATH and set HERMES_HOME so the + interactive CLI shares state with the gateway service. + ''; + }; + + extraPackages = mkOption { + type = types.listOf types.package; + default = [ ]; + description = "Extra Nix packages available to the agent at runtime."; + }; + + stateDir = mkOption { + type = types.str; + default = "/var/lib/hermes"; + description = "State directory (HERMES_HOME parent)."; + }; + }; + + config = mkIf cfg.enable { + # Hermes needs auth2api running to have a backend to call. + assertions = [ + { + assertion = auth2apiCfg.enable; + message = "mods.server.hermes requires mods.server.auth2api.enable = true"; + } + ]; + + services.hermes-agent = { + enable = true; + + inherit (cfg) stateDir addToSystemPackages extraPackages; + + # auth2api runs unauthenticated on localhost (api-keys: []), but Hermes's + # provider resolver requires a non-empty key env var for any provider. + # AUTH2API_KEY is a dummy — auth2api ignores Bearer tokens when api-keys: []. + environment.AUTH2API_KEY = "auth2api"; + + # ── Model ────────────────────────────────────────────────────────────── + # Declare auth2api as a named custom provider in the providers: section. + # Hermes resolves it via resolve_user_provider, which accepts arbitrary + # base_url + key_env without needing a built-in provider slug. + settings = { + providers.auth2api = { + name = "auth2api (Claude via OAuth)"; + api = auth2apiUrl; + key_env = "AUTH2API_KEY"; + transport = "openai_chat"; + }; + + model = { + default = cfg.model; + provider = "auth2api"; + }; + + toolsets = [ "all" ]; + max_turns = 100; + + terminal = { + backend = "local"; + timeout = 180; + }; + + memory = { + memory_enabled = true; + user_profile_enabled = true; + }; + + display = { + compact = false; + show_reasoning = true; + }; + }; + + # ── Container ────────────────────────────────────────────────────────── + container = mkIf cfg.container.enable { + enable = true; + backend = "docker"; + inherit (cfg.container) hostUsers extraVolumes; + }; + }; + }; +} diff --git a/pkgs/auth2api/package.nix b/pkgs/auth2api/package.nix index 71abbe0..29376f8 100644 --- a/pkgs/auth2api/package.nix +++ b/pkgs/auth2api/package.nix @@ -8,7 +8,7 @@ buildNpmPackage { pname = "auth2api"; - version = sources.auth2api.version; + version = "${sources.auth2api.version}-thinking3"; src = sources.auth2api.src; nodejs = nodejs_20; @@ -17,7 +17,10 @@ buildNpmPackage { # Patch to allow running with an empty api-keys list (unauthenticated). # Safe because the service binds to 127.0.0.1 by default. - patches = [ ./no-auth.patch ]; + patches = [ + ./no-auth.patch + ./thinking.patch + ]; # auth2api's build script is `tsc` (TypeScript compile → dist/) # devDeps (typescript, tsx) are needed for the build; buildNpmPackage diff --git a/pkgs/auth2api/thinking.patch b/pkgs/auth2api/thinking.patch new file mode 100644 index 0000000..8b2cf7a --- /dev/null +++ b/pkgs/auth2api/thinking.patch @@ -0,0 +1,130 @@ +--- a/src/config.ts ++++ b/src/config.ts +@@ -44,6 +44,12 @@ + "count-tokens-ms": number; + } + ++export interface ThinkingConfig { ++ /** Default reasoning effort when the client sends no reasoning_effort. ++ * Accepts: none | minimal | low | medium | high | xhigh (default: medium) */ ++ "default-effort": string; ++} ++ + export interface StatsConfig { + /** Default true. Set false to disable per-request stats recording entirely. */ + enabled: boolean; +@@ -58,6 +64,7 @@ + "api-keys": Set; + "body-limit": string; + cloaking: CloakingConfig; ++ thinking: ThinkingConfig; + timeouts: TimeoutConfig; + stats: StatsConfig; + debug: DebugMode; +@@ -78,6 +85,9 @@ + "cli-version": "2.1.88", + entrypoint: "cli", + }, ++ thinking: { ++ "default-effort": "medium", ++ }, + timeouts: { + "messages-ms": 120000, + "stream-messages-ms": 600000, +--- a/src/handlers/openai.ts ++++ b/src/handlers/openai.ts +@@ -540,7 +540,13 @@ + const structured = + body.response_format?.type === "json_object" || + body.response_format?.type === "json_schema"; +- const translatedBody = openaiToAnthropic(body); ++ // Inject default thinking effort when the client hasn't specified one. ++ const effort = config.thinking?.["default-effort"] ?? "medium"; ++ const bodyWithThinking = ++ body.reasoning_effort || effort === "none" ++ ? body ++ : { ...body, reasoning_effort: effort }; ++ const translatedBody = openaiToAnthropic(bodyWithThinking); + + if (isDebugLevel(config.debug, "verbose")) { + console.log( +--- a/src/upstream/anthropic-api.ts ++++ b/src/upstream/anthropic-api.ts +@@ -18,17 +18,17 @@ + + if (isHaiku) { + if (structured) { +- return "oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15"; ++ return "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15"; + } else { +- return "oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219"; ++ return "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219"; + } + } + + if (structured) { +- return "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15"; ++ return "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15"; + } + +- return "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24"; ++ return "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24"; + } + + /** +--- a/src/upstream/translator.ts ++++ b/src/upstream/translator.ts +@@ -9,13 +9,14 @@ + inputTokens: number, + outputTokens: number, + cachedTokens: number, ++ reasoningTokens: number = 0, + ): any { + return { + prompt_tokens: inputTokens, + completion_tokens: outputTokens, + total_tokens: inputTokens + outputTokens, + prompt_tokens_details: { cached_tokens: cachedTokens }, +- completion_tokens_details: { reasoning_tokens: 0 }, ++ completion_tokens_details: { reasoning_tokens: reasoningTokens }, + }; + } + +@@ -281,6 +282,7 @@ + + export function anthropicToOpenai(anthropicResp: any, model: string): any { + let textContent = ""; ++ let reasoningContent = ""; + const toolCalls: any[] = []; + + if (Array.isArray(anthropicResp.content)) { +@@ -288,7 +290,7 @@ + if (block.type === "text") { + textContent += block.text; + } else if (block.type === "thinking" && block.thinking) { +- // thinking blocks not exposed in chat completions response ++ reasoningContent += block.thinking; + } else if (block.type === "tool_use") { + toolCalls.push({ + id: block.id, +@@ -303,10 +305,12 @@ + } + + const message: any = { role: "assistant", content: textContent || null }; ++ if (reasoningContent) message.reasoning_content = reasoningContent; + if (toolCalls.length) message.tool_calls = toolCalls; + + const inputTokens = anthropicResp.usage?.input_tokens || 0; + const outputTokens = anthropicResp.usage?.output_tokens || 0; ++ const reasoningTokens = anthropicResp.usage?.output_tokens_details?.thinking_tokens || 0; + + return { + id: `chatcmpl-${uuidv4()}`, +@@ -325,6 +329,7 @@ + inputTokens, + outputTokens, + anthropicResp.usage?.cache_read_input_tokens || 0, ++ reasoningTokens, + ), + }; + } diff --git a/utils.nix b/utils.nix index e1b6890..da612b3 100644 --- a/utils.nix +++ b/utils.nix @@ -12,6 +12,7 @@ in { inputs.home-manager.nixosModules.default inputs.stylix.nixosModules.stylix inputs.impermanence.nixosModules.impermanence + inputs.hermes-agent.nixosModules.default ]; };