Add hermes

This commit is contained in:
muon 2026-06-01 14:15:44 +00:00
parent 28bb03187d
commit 609b3384c0
9 changed files with 526 additions and 6 deletions

227
flake.lock generated
View file

@ -132,6 +132,27 @@
} }
}, },
"flake-parts": { "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": { "inputs": {
"nixpkgs-lib": [ "nixpkgs-lib": [
"nvf", "nvf",
@ -152,7 +173,7 @@
"type": "github" "type": "github"
} }
}, },
"flake-parts_2": { "flake-parts_3": {
"inputs": { "inputs": {
"nixpkgs-lib": [ "nixpkgs-lib": [
"stylix", "stylix",
@ -224,6 +245,29 @@
"type": "github" "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": { "home-manager": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@ -426,6 +470,22 @@
} }
}, },
"nixpkgs": { "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": { "locked": {
"lastModified": 1778869304, "lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
@ -441,6 +501,27 @@
"type": "github" "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": { "nur": {
"inputs": { "inputs": {
"flake-parts": [ "flake-parts": [
@ -469,7 +550,7 @@
"nvf": { "nvf": {
"inputs": { "inputs": {
"flake-compat": "flake-compat_3", "flake-compat": "flake-compat_3",
"flake-parts": "flake-parts", "flake-parts": "flake-parts_2",
"mnw": "mnw", "mnw": "mnw",
"ndg": "ndg", "ndg": "ndg",
"nixpkgs": [ "nixpkgs": [
@ -491,14 +572,103 @@
"type": "github" "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": { "root": {
"inputs": { "inputs": {
"hermes-agent": "hermes-agent",
"home-manager": "home-manager", "home-manager": "home-manager",
"impermanence": "impermanence", "impermanence": "impermanence",
"nix-alien": "nix-alien", "nix-alien": "nix-alien",
"nix-flatpak": "nix-flatpak", "nix-flatpak": "nix-flatpak",
"nix-minecraft": "nix-minecraft", "nix-minecraft": "nix-minecraft",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs_2",
"nvf": "nvf", "nvf": "nvf",
"sops-nix": "sops-nix", "sops-nix": "sops-nix",
"stylix": "stylix", "stylix": "stylix",
@ -554,7 +724,7 @@
"base16-helix": "base16-helix", "base16-helix": "base16-helix",
"base16-vim": "base16-vim", "base16-vim": "base16-vim",
"firefox-gnome-theme": "firefox-gnome-theme", "firefox-gnome-theme": "firefox-gnome-theme",
"flake-parts": "flake-parts_2", "flake-parts": "flake-parts_3",
"gnome-shell": "gnome-shell", "gnome-shell": "gnome-shell",
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
@ -689,6 +859,55 @@
"type": "github" "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": { "valheim-server": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [

View file

@ -35,6 +35,8 @@
valheim-server.inputs.nixpkgs.follows = "nixpkgs"; valheim-server.inputs.nixpkgs.follows = "nixpkgs";
nix-flatpak.url = "github:gmodena/nix-flatpak?ref=latest"; nix-flatpak.url = "github:gmodena/nix-flatpak?ref=latest";
hermes-agent.url = "github:NousResearch/hermes-agent";
}; };
outputs = inputs @ { outputs = inputs @ {
@ -103,6 +105,7 @@
inputs.home-manager.nixosModules.default inputs.home-manager.nixosModules.default
inputs.stylix.nixosModules.stylix inputs.stylix.nixosModules.stylix
inputs.impermanence.nixosModules.impermanence inputs.impermanence.nixosModules.impermanence
inputs.hermes-agent.nixosModules.default
]; ];
home-manager.sharedModules = [ home-manager.sharedModules = [

View file

@ -64,6 +64,11 @@ in {
# port defaults to 8317 # port defaults to 8317
# Run `auth2api --login` once after deploying to authorise your Claude account. # 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.server.sync.enable = true;
mods.tailscale.enable = true; mods.tailscale.enable = true;
mods.openvpn.enable = false; mods.openvpn.enable = false;

View file

@ -50,6 +50,11 @@ let
# off | errors | verbose # off | errors | verbose
debug: "${cfg.debug}" 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: cloaking:
cli-version: "2.1.88" cli-version: "2.1.88"
entrypoint: "cli" entrypoint: "cli"
@ -110,6 +115,23 @@ with lib; {
description = "Log level: off | errors | verbose."; 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 { openFirewall = mkOption {
type = types.bool; type = types.bool;
default = false; default = false;

View file

@ -31,5 +31,6 @@
./atuin.nix ./atuin.nix
./murmur.nix ./murmur.nix
./auth2api.nix ./auth2api.nix
./hermes.nix
]; ];
} }

View file

@ -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;
};
};
};
}

View file

@ -8,7 +8,7 @@
buildNpmPackage { buildNpmPackage {
pname = "auth2api"; pname = "auth2api";
version = sources.auth2api.version; version = "${sources.auth2api.version}-thinking3";
src = sources.auth2api.src; src = sources.auth2api.src;
nodejs = nodejs_20; nodejs = nodejs_20;
@ -17,7 +17,10 @@ buildNpmPackage {
# Patch to allow running with an empty api-keys list (unauthenticated). # Patch to allow running with an empty api-keys list (unauthenticated).
# Safe because the service binds to 127.0.0.1 by default. # 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/) # auth2api's build script is `tsc` (TypeScript compile → dist/)
# devDeps (typescript, tsx) are needed for the build; buildNpmPackage # devDeps (typescript, tsx) are needed for the build; buildNpmPackage

View file

@ -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<string>;
"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,
),
};
}

View file

@ -12,6 +12,7 @@ in {
inputs.home-manager.nixosModules.default inputs.home-manager.nixosModules.default
inputs.stylix.nixosModules.stylix inputs.stylix.nixosModules.stylix
inputs.impermanence.nixosModules.impermanence inputs.impermanence.nixosModules.impermanence
inputs.hermes-agent.nixosModules.default
]; ];
}; };