Initial Hermes Agent stack: Claude-Max proxy + gateway, docker-compose, README
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# Nach /shared/projects/hermes_agent/.env kopieren und Werte anpassen.
|
||||||
|
# .env NICHT committen falls das Ganze mal in ein Git-Repo wandert.
|
||||||
|
|
||||||
|
# Shared Secret zwischen hermes-gateway und Hermes Agent (config.yaml -> api_key).
|
||||||
|
# Mindestens 32 zufaellige Zeichen, z.B. erzeugen mit:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
HERMES_GATEWAY_TOKEN=
|
||||||
|
|
||||||
|
# Port auf dem hermes-gateway auf dieser Maschine nach aussen lauscht.
|
||||||
|
# Hermes Agent (auf der anderen Maschine) zeigt in config.yaml auf
|
||||||
|
# http://<diese-maschine>:<PORT>/v1
|
||||||
|
HERMES_GATEWAY_PORT=8447
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Hermes Agent — Claude-Max-Proxy Setup
|
||||||
|
|
||||||
|
Repliziert den Proxy-Trick, den wir fuer ARIA nutzen: `claude-max-api-proxy`
|
||||||
|
(npm) wrappt die Claude Code CLI — angemeldet mit deiner Claude-Max-
|
||||||
|
Subscription — als OpenAI-kompatiblen `/v1/chat/completions`-Endpoint.
|
||||||
|
[Hermes Agent](https://github.com/NousResearch/hermes-agent) (Nous Research,
|
||||||
|
CLI/Desktop-Agent, unterstuetzt laut Doku "your own endpoint" als
|
||||||
|
Model-Provider) kann darueber mit Claude reden, ohne API-Kosten — laeuft
|
||||||
|
ueber die Subscription, nicht ueber Anthropic-API-Billing.
|
||||||
|
|
||||||
|
**Wichtig:** Hermes Agent selbst (das CLI/Desktop-Tool von Nous Research)
|
||||||
|
ist hier NICHT enthalten — Stefan deployt das selbst auf einer anderen
|
||||||
|
Maschine. Dieser Stack liefert nur die Proxy-Seite, die Hermes von dort
|
||||||
|
ansprechen kann. ARIA bleibt komplett unangetastet (eigenes Docker-Netz
|
||||||
|
`hermes-net`, eigenes Claude-CLI-Login-Volume, kein gemeinsamer Container).
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Hermes Agent (andere Maschine)
|
||||||
|
| HTTP(S), Authorization: Bearer <TOKEN>
|
||||||
|
v
|
||||||
|
hermes-gateway (Port ${HERMES_GATEWAY_PORT:-8447}, nach aussen exposed)
|
||||||
|
| prueft Bearer-Token, sonst 401
|
||||||
|
v
|
||||||
|
hermes-proxy (Port 3456, NUR docker-intern erreichbar)
|
||||||
|
| claude-max-api-proxy -> Claude Code CLI (Claude-Max-Login)
|
||||||
|
v
|
||||||
|
Anthropic (ueber deine Claude-Max-Subscription)
|
||||||
|
```
|
||||||
|
|
||||||
|
Warum das Gateway davor: `hermes-proxy` selbst hat keine Authentifizierung
|
||||||
|
(genau wie ARIAs Proxy) — das ist bei ARIA ok, weil der Proxy nie einen
|
||||||
|
Netzwerk-Port bekommt. Hermes laeuft aber auf einer anderen Maschine, der
|
||||||
|
Proxy muss also ueber Netzwerk erreichbar sein. Ohne Auth waere das ein frei
|
||||||
|
nutzbarer Claude-Max-Zugang fuer jeden der den Port erreicht. Das Gateway
|
||||||
|
(`proxy-patches/gateway.js`, pure Node, keine Extra-Deps) prueft dafuer den
|
||||||
|
`Authorization: Bearer <TOKEN>`-Header gegen ein Shared Secret, bevor
|
||||||
|
irgendwas an `hermes-proxy` weitergeleitet wird.
|
||||||
|
|
||||||
|
## Was uebernommen wurde (1:1 aus ARIAs Proxy-Setup)
|
||||||
|
|
||||||
|
- **Sed-Patches** an der npm-CLI-Bridge (host-binding, `--dangerously-skip-permissions`,
|
||||||
|
echter `--system-prompt`-Kanal, 24h-Timeout, Prompt via stdin) — generische
|
||||||
|
Infra-Fixes, nicht ARIA-spezifisch.
|
||||||
|
- **Adapter-Patches** (`proxy-patches/openai-to-cli.js`, `cli-to-openai.js`) —
|
||||||
|
die OpenAI-`tools` <-> `<tool_call>`-Text-Bruecke. Generisch, 1:1 uebernommen.
|
||||||
|
- **NICHT** uebernommen: ARIAs `routes.js`-Patch (Live-Tool-Stream/Not-Aus
|
||||||
|
fuers Diagnostic-UI, ARIA-Bridge-spezifisch). Stattdessen gibt's hier das
|
||||||
|
eigene `gateway.js` fuer die Auth, die ARIA in der Form nicht braucht.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **`.env` anlegen:**
|
||||||
|
```bash
|
||||||
|
cd /shared/projects/hermes_agent
|
||||||
|
cp .env.example .env
|
||||||
|
openssl rand -hex 32 # Ausgabe in .env bei HERMES_GATEWAY_TOKEN eintragen
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Stack starten:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Einmalig Claude-CLI-Login** (oeffnet Browser-OAuth mit deiner Claude-Max-Subscription):
|
||||||
|
```bash
|
||||||
|
docker exec -it hermes-proxy claude
|
||||||
|
```
|
||||||
|
Danach liegen die Credentials persistent in `hermes-data/claude-config/.credentials.json`
|
||||||
|
— ueberlebt Container-Restarts.
|
||||||
|
|
||||||
|
4. **Von der Hermes-Maschine aus testen:**
|
||||||
|
```bash
|
||||||
|
curl -s http://<diese-maschine>:${HERMES_GATEWAY_PORT:-8447}/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <HERMES_GATEWAY_TOKEN aus .env>" \
|
||||||
|
-d '{"model":"claude-sonnet-4","messages":[{"role":"user","content":"Sag Hallo"}]}'
|
||||||
|
```
|
||||||
|
Ohne oder mit falschem Bearer-Token gibt's `401 unauthorized` — Gateway
|
||||||
|
arbeitet.
|
||||||
|
|
||||||
|
5. **Hermes Agent konfigurieren** (auf der Ziel-Maschine, `~/.hermes/config.yaml`):
|
||||||
|
```yaml
|
||||||
|
model:
|
||||||
|
provider: custom
|
||||||
|
model: "sonnet"
|
||||||
|
base_url: "http://<diese-maschine>:8447/v1"
|
||||||
|
api_key: "<derselbe HERMES_GATEWAY_TOKEN>"
|
||||||
|
```
|
||||||
|
`base_url` hat laut Hermes-Doku Vorrang vor `provider` — funktioniert wie
|
||||||
|
bei selbst gehosteten/lokalen Endpoints (vLLM, Ollama etc.), die
|
||||||
|
Hermes-Doku beschreibt genau diesen Ansatz. Modellnamen `sonnet` / `opus`
|
||||||
|
/ `haiku` werden vom Adapter (`openai-to-cli.js`, `MODEL_MAP`) auf die
|
||||||
|
`--model`-CLI-Flags gemappt.
|
||||||
|
|
||||||
|
## Sicherheitshinweis
|
||||||
|
|
||||||
|
Das Gateway schuetzt vor Fremdnutzung deiner Subscription (Bearer-Token-
|
||||||
|
Pflicht), **aber der Traffic selbst ist unverschluesseltes HTTP** — kein TLS
|
||||||
|
zwischen Hermes und dem Gateway. Solange beide Maschinen im selben
|
||||||
|
vertrauenswuerdigen LAN oder per VPN verbunden sind: unkritisch. Wenn die
|
||||||
|
Hermes-Maschine ueber offenes Internet zugreifen soll: Port NICHT direkt
|
||||||
|
exposen, sondern per SSH-Tunnel, WireGuard/Tailscale oder einen TLS-
|
||||||
|
Reverse-Proxy davorschalten. Sag Bescheid, dann bauen wir das nach, sobald
|
||||||
|
klar ist wie die beiden Maschinen tatsaechlich verbunden sind.
|
||||||
|
|
||||||
|
## Wo deployen?
|
||||||
|
|
||||||
|
Diese Compose-Datei ist ortsunabhaengig — lief bisher nirgends deployed,
|
||||||
|
liegt nur unter `/shared/projects/hermes_agent/` (Code-Editor-Sync). Du
|
||||||
|
kannst sie 1:1 dahin kopieren, wo der Proxy tatsaechlich laufen soll (z.B.
|
||||||
|
aria-wohnung als eigener isolierter Stack neben ARIA, oder eine ganz andere
|
||||||
|
Maschine mit Zugriff auf deine Claude-Max-Subscription). Wichtig ist nur:
|
||||||
|
egal wo, `HERMES_GATEWAY_PORT` muss von der Hermes-Maschine aus erreichbar
|
||||||
|
sein.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
# ─── Claude Max API Proxy (fuer Hermes) ─────────────────
|
||||||
|
# Gleicher Trick wie beim ARIA-Proxy (/root/ARIA-AGENT/docker-compose.yml,
|
||||||
|
# Service "proxy"): das npm-Paket claude-max-api-proxy wrappt die Claude
|
||||||
|
# Code CLI (angemeldet mit Claude-Max-Subscription) als OpenAI-kompatiblen
|
||||||
|
# /v1/chat/completions-Endpoint. Die Sed-Patches unten sind identisch zu
|
||||||
|
# ARIA — noetig damit:
|
||||||
|
# - der Server auf 0.0.0.0 bindet (sonst nur localhost im Container)
|
||||||
|
# - Claude ohne interaktive Permission-Prompts laeuft (--dangerously-skip-permissions,
|
||||||
|
# zwingend fuer non-interactive Container-Betrieb als root)
|
||||||
|
# - der System-Prompt komplett ersetzt wird (--system-prompt) statt nur
|
||||||
|
# angehaengt — sonst gewinnt Claude Codes eigene "You are Claude Code"-
|
||||||
|
# Identitaet bei duennem Kontext
|
||||||
|
# - lange Agent-Loops nicht nach 5min timeouten (DEFAULT_TIMEOUT -> 24h)
|
||||||
|
# - der Prompt via stdin statt CLI-Arg uebergeben wird (Arg-Laengenlimit/
|
||||||
|
# Escaping-Probleme bei langen Konversationen)
|
||||||
|
#
|
||||||
|
# Adapter-Patches (proxy-patches/*.js) sind 1:1 aus dem ARIA-Proxy
|
||||||
|
# uebernommen — sie sind generisch (implementieren nur die
|
||||||
|
# OpenAI-tools <-> <tool_call>-Text-Bridge, kein ARIA-Code drin):
|
||||||
|
# - openai-to-cli.js: haengt `tools`-Definitionen als Anleitung in den
|
||||||
|
# System-Prompt ("gib <tool_call name=X>{json}</tool_call> aus")
|
||||||
|
# - cli-to-openai.js: parsed genau dieses Format aus Claudes Antwort
|
||||||
|
# wieder raus und baut daraus OpenAI-konforme tool_calls[]
|
||||||
|
#
|
||||||
|
# BEWUSST NICHT uebernommen: ARIAs routes.js-Patch. Der haengt zusaetzlich
|
||||||
|
# an aria-bridge:8090 (Live-Tool-Stream + Not-Aus-Killswitch) und liest
|
||||||
|
# /shared/config/models.json — beides ARIA-Bridge-spezifisch. Hier laeuft
|
||||||
|
# die NPM-Stock-Version von routes.js (macht die Kern-Route trotzdem,
|
||||||
|
# nur ohne die Bridge-Extras). Falls Hermes spaeter auch Live-Stream/
|
||||||
|
# Killswitch braucht: sag Bescheid, dann bauen wir das nach.
|
||||||
|
hermes-proxy:
|
||||||
|
image: node:22-alpine
|
||||||
|
container_name: hermes-proxy
|
||||||
|
command: >-
|
||||||
|
sh -c "apk add --no-cache bash curl &&
|
||||||
|
npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
|
||||||
|
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
||||||
|
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||||
|
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
|
||||||
|
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||||
|
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||||
|
claude-max-api"
|
||||||
|
volumes:
|
||||||
|
# WICHTIG: eigenes, von ARIA getrenntes Volume fuer die Claude-CLI-Session.
|
||||||
|
# Einmalig `docker exec -it hermes-proxy claude` fuer den Browser-Login
|
||||||
|
# mit der Claude-Max-Subscription (kann derselbe Account wie ARIA sein —
|
||||||
|
# es ist ja dieselbe Subscription, nur eine eigene CLI-Session dafuer).
|
||||||
|
- ./hermes-data/claude-config:/root/.claude
|
||||||
|
# optional: nur noetig falls Hermes Dateien mit Dir austauschen soll
|
||||||
|
- ./hermes-shared:/shared
|
||||||
|
- ./proxy-patches:/proxy-patches:ro
|
||||||
|
tmpfs:
|
||||||
|
# wie bei ARIA: Claude Codes eigenes Auto-Memory nicht persistieren
|
||||||
|
- /root/.claude/projects
|
||||||
|
environment:
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- SHELL=/bin/bash
|
||||||
|
- CLAUDE_CODE_BUBBLEWRAP=1 # erlaubt --dangerously-skip-permissions als root
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- hermes-net
|
||||||
|
# Bewusst KEIN ports:-Mapping nach aussen — genau wie bei ARIAs Proxy.
|
||||||
|
# Nur Container im selben Docker-Netz (hier: hermes-gateway) sollen ihn
|
||||||
|
# erreichen koennen. Das eigentliche Netzwerk-Exposing + Auth macht das
|
||||||
|
# Gateway unten, damit hier niemals ein unauthentifizierter Endpoint
|
||||||
|
# offen im Netz haengt.
|
||||||
|
# Zum manuellen Testen direkt im Container: docker exec -it hermes-proxy curl -s localhost:3456/v1/chat/completions ...
|
||||||
|
|
||||||
|
# ─── Hermes Gateway (Auth + Netzwerk-Expose) ───────────────
|
||||||
|
# Stefan deployt den eigentlichen Hermes-Agent-Client (Nous Research,
|
||||||
|
# https://github.com/NousResearch/hermes-agent, CLI/Desktop-App) SELBST
|
||||||
|
# auf einer ANDEREN Maschine — hier steht daher kein hermes-agent-Service,
|
||||||
|
# nur die Proxy-Seite, die Hermes von aussen ansprechen kann.
|
||||||
|
#
|
||||||
|
# Weil "von aussen" heisst: der Proxy braucht einen echten Netzwerk-Port.
|
||||||
|
# hermes-proxy selbst hat aber keine Authentifizierung eingebaut (siehe
|
||||||
|
# Kommentar oben) — ein offener Port waere ein frei nutzbarer Claude-Max-
|
||||||
|
# Zugang fuer jeden der ihn erreicht. Deshalb dieses schlanke Gateway
|
||||||
|
# (proxy-patches/gateway.js, pure Node, keine Extra-Deps): prueft den
|
||||||
|
# Authorization: Bearer <TOKEN> Header, leitet nur bei Match an
|
||||||
|
# hermes-proxy weiter. Alles andere -> 401.
|
||||||
|
#
|
||||||
|
# In Hermes' ~/.hermes/config.yaml traegst du ein:
|
||||||
|
# model:
|
||||||
|
# provider: custom
|
||||||
|
# model: "sonnet" # oder "opus" / "haiku" — siehe MODEL_MAP
|
||||||
|
# # in proxy-patches/openai-to-cli.js
|
||||||
|
# base_url: "http://<diese-maschine>:${HERMES_GATEWAY_PORT:-8447}/v1"
|
||||||
|
# api_key: "<HERMES_GATEWAY_TOKEN aus .env>"
|
||||||
|
# OpenAI-kompatible Clients schicken api_key automatisch als
|
||||||
|
# "Authorization: Bearer <api_key>" — genau das prueft das Gateway, kein
|
||||||
|
# Extra-Schritt auf Hermes-Seite noetig.
|
||||||
|
hermes-gateway:
|
||||||
|
image: node:22-alpine
|
||||||
|
container_name: hermes-gateway
|
||||||
|
command: ["node", "/gateway/gateway.js"]
|
||||||
|
depends_on:
|
||||||
|
- hermes-proxy
|
||||||
|
volumes:
|
||||||
|
- ./proxy-patches:/gateway:ro
|
||||||
|
environment:
|
||||||
|
- TOKEN=${HERMES_GATEWAY_TOKEN:?HERMES_GATEWAY_TOKEN muss in .env gesetzt sein - siehe .env.example}
|
||||||
|
- UPSTREAM_HOST=hermes-proxy
|
||||||
|
- UPSTREAM_PORT=3456
|
||||||
|
- LISTEN_PORT=8080
|
||||||
|
ports:
|
||||||
|
# Bindet auf ALLE Interfaces, damit Hermes von der anderen Maschine
|
||||||
|
# rankommt. HERMES_GATEWAY_PORT in .env setzen (Default 8447).
|
||||||
|
# WICHTIG (lies auch README "Sicherheitshinweis"): wenn diese Maschine
|
||||||
|
# direkt im Internet haengt statt in einem vertrauenswuerdigen
|
||||||
|
# LAN/VPN, den Port zusaetzlich per Firewall auf Hermes' IP
|
||||||
|
# einschraenken oder per VPN/SSH-Tunnel statt direktem Expose fahren
|
||||||
|
# — Bearer-Token schuetzt vor Fremdnutzung, aber der Traffic selbst
|
||||||
|
# laeuft hier unverschluesselt (HTTP, kein TLS).
|
||||||
|
- "${HERMES_GATEWAY_PORT:-8447}:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- hermes-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
hermes-net:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Hermes-Proxy cli-to-openai Adapter.
|
||||||
|
*
|
||||||
|
* 1:1 uebernommen aus dem ARIA-Proxy (/root/ARIA-AGENT/proxy-patches/cli-to-openai.js) —
|
||||||
|
* dieser Teil ist generisch, kein ARIA-spezifischer Code drin. Erweitert die
|
||||||
|
* npm-Version von claude-max-api-proxy um:
|
||||||
|
* - normalizeModelName ist null-safe (Original-Patch der vorher per sed lief).
|
||||||
|
* - Parser fuer <tool_call name="X">{json}</tool_call>-Bloecke im Result-Text:
|
||||||
|
* Wenn welche gefunden werden, wandert das in `message.tool_calls`
|
||||||
|
* (OpenAI-Format) und finish_reason=tool_calls. Der restliche Text
|
||||||
|
* (alles ausserhalb der Bloecke) wird verworfen, weil das interner
|
||||||
|
* Tool-Use-Schritt war, nicht User-facing.
|
||||||
|
*
|
||||||
|
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
|
||||||
|
* (siehe docker-compose.yml hermes-proxy-Block).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export function extractTextContent(message) {
|
||||||
|
return message.message.content
|
||||||
|
.filter((c) => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cliToOpenaiChunk(message, requestId, isFirst = false) {
|
||||||
|
const text = extractTextContent(message);
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${requestId}`,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: normalizeModelName(message.message.model),
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
role: isFirst ? "assistant" : undefined,
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
finish_reason: message.message.stop_reason ? "stop" : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDoneChunk(requestId, model) {
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${requestId}`,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: normalizeModelName(model),
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: {},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht im Result-Text alle <tool_call name="...">{json}</tool_call>
|
||||||
|
* Bloecke. Gibt [{id, name, arguments(json-string)}, restText] zurueck.
|
||||||
|
*
|
||||||
|
* Defensiv:
|
||||||
|
* - "name"-Attribut sowohl in Doppel- als auch Einzelhochkommata
|
||||||
|
* - Whitespace beim JSON tolerant
|
||||||
|
* - Bei JSON-Parse-Fehler: das Argument wird als _raw weitergereicht
|
||||||
|
* (Empfaenger-seitiger Parser kennt das)
|
||||||
|
*/
|
||||||
|
function _parseToolCalls(text) {
|
||||||
|
if (!text || typeof text !== "string") return { tool_calls: [], rest: text || "" };
|
||||||
|
const re = /<tool_call\s+name=["']([^"']+)["']\s*>([\s\S]*?)<\/tool_call>/gi;
|
||||||
|
const tcs = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
const restParts = [];
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
restParts.push(text.slice(lastIndex, m.index));
|
||||||
|
const name = m[1];
|
||||||
|
let argsBody = (m[2] || "").trim();
|
||||||
|
// Fences entfernen falls Claude welche eingebaut hat
|
||||||
|
argsBody = argsBody.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim();
|
||||||
|
if (!argsBody) argsBody = "{}";
|
||||||
|
// Validieren — aber in OpenAI-Format ist arguments immer ein STRING
|
||||||
|
try {
|
||||||
|
JSON.parse(argsBody);
|
||||||
|
} catch (_) {
|
||||||
|
// Behalten als Roh-String
|
||||||
|
}
|
||||||
|
tcs.push({
|
||||||
|
id: `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`,
|
||||||
|
type: "function",
|
||||||
|
function: { name, arguments: argsBody },
|
||||||
|
});
|
||||||
|
lastIndex = re.lastIndex;
|
||||||
|
}
|
||||||
|
restParts.push(text.slice(lastIndex));
|
||||||
|
return { tool_calls: tcs, rest: restParts.join("").trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cliResultToOpenai(result, requestId) {
|
||||||
|
const modelName = result.modelUsage
|
||||||
|
? Object.keys(result.modelUsage)[0]
|
||||||
|
: "claude-sonnet-4";
|
||||||
|
|
||||||
|
const rawText = result.result || "";
|
||||||
|
const { tool_calls, rest } = _parseToolCalls(rawText);
|
||||||
|
|
||||||
|
const message = { role: "assistant" };
|
||||||
|
let finishReason = "stop";
|
||||||
|
if (tool_calls.length > 0) {
|
||||||
|
message.tool_calls = tool_calls;
|
||||||
|
message.content = rest || null;
|
||||||
|
finishReason = "tool_calls";
|
||||||
|
} else {
|
||||||
|
message.content = rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${requestId}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: normalizeModelName(modelName),
|
||||||
|
choices: [
|
||||||
|
{ index: 0, message, finish_reason: finishReason },
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: result.usage?.input_tokens || 0,
|
||||||
|
completion_tokens: result.usage?.output_tokens || 0,
|
||||||
|
total_tokens:
|
||||||
|
(result.usage?.input_tokens || 0) + (result.usage?.output_tokens || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelName(model) {
|
||||||
|
const m = model || "claude-sonnet-4";
|
||||||
|
if (m.includes("opus")) return "claude-opus-4";
|
||||||
|
if (m.includes("sonnet")) return "claude-sonnet-4";
|
||||||
|
if (m.includes("haiku")) return "claude-haiku-4";
|
||||||
|
return m;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* hermes-gateway — minimaler Bearer-Token Reverse-Proxy vor hermes-proxy.
|
||||||
|
*
|
||||||
|
* WARUM: hermes-proxy selbst hat KEINE Authentifizierung — genau wie ARIAs
|
||||||
|
* Proxy (siehe /root/ARIA-AGENT/docker-compose.yml, Service "proxy"). Bei
|
||||||
|
* ARIA ist das ok, weil der Proxy NIE einen Netzwerk-Port bekommt, nur
|
||||||
|
* Docker-intern von aria-brain erreichbar ist.
|
||||||
|
*
|
||||||
|
* Hermes Agent laeuft aber auf einer ANDEREN Maschine (Stefans Wunsch) und
|
||||||
|
* muss den Proxy ueber Netzwerk erreichen -> der Port muss also offen sein.
|
||||||
|
* Ohne Auth koennte dann JEDER der den Port erreicht (falsches Netz-Segment,
|
||||||
|
* offenes WLAN, Portscan) Stefans Claude-Max-Subscription fremdnutzen —
|
||||||
|
* die eigentliche "Auth" ist ja nur die eingeloggte Claude-CLI-Session im
|
||||||
|
* Volume, kein Provider-seitiger API-Key-Check.
|
||||||
|
*
|
||||||
|
* Dieses Gateway sitzt davor: prueft den Authorization-Header gegen ein
|
||||||
|
* Shared Secret (ENV TOKEN), leitet nur bei Match an hermes-proxy weiter.
|
||||||
|
* Alles andere -> 401, kein Request geht durch.
|
||||||
|
*
|
||||||
|
* Praktischer Nebeneffekt: OpenAI-kompatible Clients (Hermes Agent
|
||||||
|
* eingeschlossen) schicken ihren konfigurierten `api_key` sowieso schon als
|
||||||
|
* "Authorization: Bearer <api_key>"-Header. In Hermes' config.yaml also
|
||||||
|
* einfach `api_key: "<TOKEN>"` (= derselbe Wert wie hier in TOKEN) setzen —
|
||||||
|
* kein Extra-Code auf Hermes-Seite noetig.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require("http");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const TOKEN = process.env.TOKEN;
|
||||||
|
const UPSTREAM_HOST = process.env.UPSTREAM_HOST || "hermes-proxy";
|
||||||
|
const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "3456", 10);
|
||||||
|
const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "8080", 10);
|
||||||
|
|
||||||
|
if (!TOKEN || TOKEN.length < 16) {
|
||||||
|
console.error(
|
||||||
|
"FATAL: TOKEN env fehlt oder ist zu kurz (< 16 Zeichen). " +
|
||||||
|
"Ohne ordentliches Shared Secret startet das Gateway bewusst nicht " +
|
||||||
|
"— sonst haengt hier ein offener Claude-Proxy im Netz."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(a, b) {
|
||||||
|
const ab = Buffer.from(a, "utf8");
|
||||||
|
const bb = Buffer.from(b, "utf8");
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return crypto.timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorized(res) {
|
||||||
|
res.writeHead(401, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: { message: "unauthorized", type: "invalid_api_key" } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const auth = req.headers["authorization"] || "";
|
||||||
|
const expected = `Bearer ${TOKEN}`;
|
||||||
|
|
||||||
|
if (!safeEqual(auth, expected)) {
|
||||||
|
console.log(`[hermes-gateway] 401 ${req.method} ${req.url} von ${req.socket.remoteAddress}`);
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyReq = http.request(
|
||||||
|
{
|
||||||
|
hostname: UPSTREAM_HOST,
|
||||||
|
port: UPSTREAM_PORT,
|
||||||
|
path: req.url,
|
||||||
|
method: req.method,
|
||||||
|
headers: req.headers,
|
||||||
|
},
|
||||||
|
(proxyRes) => {
|
||||||
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
proxyReq.on("error", (err) => {
|
||||||
|
console.error(`[hermes-gateway] upstream error: ${err.message}`);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { "Content-Type": "application/json" });
|
||||||
|
}
|
||||||
|
res.end(JSON.stringify({ error: { message: "bad_gateway", detail: String(err.message) } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.pipe(proxyReq);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(LISTEN_PORT, "0.0.0.0", () => {
|
||||||
|
console.log(`[hermes-gateway] listening on 0.0.0.0:${LISTEN_PORT} -> http://${UPSTREAM_HOST}:${UPSTREAM_PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Hermes-Proxy openai-to-cli Adapter.
|
||||||
|
*
|
||||||
|
* 1:1 uebernommen aus dem ARIA-Proxy (/root/ARIA-AGENT/proxy-patches/openai-to-cli.js) —
|
||||||
|
* generisch, kein ARIA-spezifischer Code drin. Erweitert die npm-Version von
|
||||||
|
* claude-max-api-proxy um:
|
||||||
|
* - Multimodal-Content (Array von text-Parts) wird zu String reduziert.
|
||||||
|
* - Wenn die Anfrage ein `tools`-Feld enthaelt: die Tool-Definitionen
|
||||||
|
* werden in den System-Prompt als Anweisung injiziert, das
|
||||||
|
* <tool_call name="...">{...}</tool_call> Format zu benutzen statt
|
||||||
|
* freiem Text.
|
||||||
|
* - Wenn Messages role=tool enthalten: deren Inhalt wird als
|
||||||
|
* <tool_result tool_call_id="...">…</tool_result> ins Prompt-Fragment
|
||||||
|
* eingewoben damit Claude den Loop-Step bekommt.
|
||||||
|
*
|
||||||
|
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
|
||||||
|
* (siehe docker-compose.yml hermes-proxy-Block).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MODEL_MAP = {
|
||||||
|
"claude-opus-4": "opus",
|
||||||
|
"claude-sonnet-4": "sonnet",
|
||||||
|
"claude-haiku-4": "haiku",
|
||||||
|
"claude-code-cli/claude-opus-4": "opus",
|
||||||
|
"claude-code-cli/claude-sonnet-4": "sonnet",
|
||||||
|
"claude-code-cli/claude-haiku-4": "haiku",
|
||||||
|
"opus": "opus",
|
||||||
|
"sonnet": "sonnet",
|
||||||
|
"haiku": "haiku",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractModel(model) {
|
||||||
|
if (MODEL_MAP[model]) return MODEL_MAP[model];
|
||||||
|
const stripped = (model || "").replace(/^claude-code-cli\//, "");
|
||||||
|
if (MODEL_MAP[stripped]) return MODEL_MAP[stripped];
|
||||||
|
return "opus";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multimodal: content kann String oder Array von Parts sein. */
|
||||||
|
function _text(c) {
|
||||||
|
if (typeof c === "string") return c;
|
||||||
|
if (Array.isArray(c)) {
|
||||||
|
return c
|
||||||
|
.filter((b) => b && b.type === "text")
|
||||||
|
.map((b) => b.text || "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return String(c == null ? "" : c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut den Tool-Use-Block fuer den System-Prompt.
|
||||||
|
* Anweisung: Claude soll <tool_call name="X">{json args}</tool_call>
|
||||||
|
* ausgeben statt das Tool intern via Bash zu simulieren.
|
||||||
|
*/
|
||||||
|
function _toolsBlock(tools) {
|
||||||
|
if (!Array.isArray(tools) || tools.length === 0) return "";
|
||||||
|
const lines = [];
|
||||||
|
lines.push("# Verfuegbare Tools");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
"Du hast neben deinen eigenen internen Tools (Bash, Read, etc.) auch " +
|
||||||
|
"diese externen Tools, die im Backend-System angesiedelt sind. " +
|
||||||
|
"Sie sind die EINZIGE Moeglichkeit diese Aktionen auszuloesen. " +
|
||||||
|
"Simuliere sie NICHT mit Bash/sleep — rufe sie sauber auf:"
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
for (const t of tools) {
|
||||||
|
if (!t || t.type !== "function" || !t.function) continue;
|
||||||
|
const fn = t.function;
|
||||||
|
const name = fn.name || "";
|
||||||
|
const desc = fn.description || "";
|
||||||
|
const params = fn.parameters || {};
|
||||||
|
lines.push(`## ${name}`);
|
||||||
|
if (desc) lines.push(desc);
|
||||||
|
try {
|
||||||
|
lines.push("Schema: " + JSON.stringify(params));
|
||||||
|
} catch (_) {
|
||||||
|
lines.push("Schema: (nicht serialisierbar)");
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
lines.push("# Tool-Call-Format");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
"Wenn du eines der OBIGEN externen Tools aufrufen willst, antworte " +
|
||||||
|
"**ausschliesslich** mit einem oder mehreren Bloecken in genau dieser Form, " +
|
||||||
|
"JEDER fuer sich auf einer eigenen Zeile:"
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push('<tool_call name="TOOL_NAME">{"arg1":"value","arg2":123}</tool_call>');
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
"Regeln: (1) Innerhalb des Blocks steht NUR gueltiges JSON mit den Argumenten. " +
|
||||||
|
"(2) Kein Text drumherum. (3) Keine Code-Fences, kein Markdown. " +
|
||||||
|
"(4) Mehrere Tool-Calls = mehrere Bloecke untereinander. " +
|
||||||
|
"(5) Nach den Bloecken aufhoeren — der Server fuehrt die Tools aus und " +
|
||||||
|
"schickt dir die Ergebnisse fuer den naechsten Turn. " +
|
||||||
|
"(6) Wenn KEIN externes Tool noetig ist, antworte normal als Text."
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messagesToPrompt(messages, tools) {
|
||||||
|
const parts = [];
|
||||||
|
const toolsBlock = _toolsBlock(tools);
|
||||||
|
if (toolsBlock) {
|
||||||
|
parts.push(`<system>\n${toolsBlock}\n</system>\n`);
|
||||||
|
}
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg) continue;
|
||||||
|
switch (msg.role) {
|
||||||
|
case "system":
|
||||||
|
parts.push(`<system>\n${_text(msg.content)}\n</system>\n`);
|
||||||
|
break;
|
||||||
|
case "user":
|
||||||
|
parts.push(_text(msg.content));
|
||||||
|
break;
|
||||||
|
case "assistant": {
|
||||||
|
const txt = _text(msg.content);
|
||||||
|
const tcs = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
||||||
|
const tcParts = tcs.map((tc) => {
|
||||||
|
const name = tc?.function?.name || tc?.name || "";
|
||||||
|
let args = tc?.function?.arguments ?? tc?.arguments ?? "{}";
|
||||||
|
if (typeof args !== "string") {
|
||||||
|
try { args = JSON.stringify(args); } catch (_) { args = "{}"; }
|
||||||
|
}
|
||||||
|
return `<tool_call name="${name}">${args}</tool_call>`;
|
||||||
|
}).join("\n");
|
||||||
|
const combined = [txt, tcParts].filter(Boolean).join("\n").trim();
|
||||||
|
if (combined) parts.push(`<previous_response>\n${combined}\n</previous_response>\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tool": {
|
||||||
|
const name = msg.name || "";
|
||||||
|
const id = msg.tool_call_id || "";
|
||||||
|
parts.push(
|
||||||
|
`<tool_result tool_call_id="${id}" name="${name}">\n${_text(msg.content)}\n</tool_result>\n`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert NUR den System-Anteil (System-Messages + Tool-Use-Block) als
|
||||||
|
* rohen Text — OHNE <system>-Tags. Fuer den ECHTEN System-Prompt-Kanal der
|
||||||
|
* Claude-CLI (--system-prompt, VOLLER Replace — nicht --append). Damit ist
|
||||||
|
* die Ziel-Persona/Rolle DIE Identitaet des Modells und nicht ein Anhaengsel
|
||||||
|
* hinter Claude Codes eigener "You are Claude Code"-Identitaet (die bei
|
||||||
|
* duennem Kontext sonst gewinnt). Der Output muss deshalb SELBSTTRAGEND sein.
|
||||||
|
* Reihenfolge: erst der Tool-Use-Block (Format-Anweisung), dann die
|
||||||
|
* System-Messages in Original-Reihenfolge.
|
||||||
|
*/
|
||||||
|
export function extractSystemPrompt(messages, tools) {
|
||||||
|
const chunks = [];
|
||||||
|
const toolsBlock = _toolsBlock(tools);
|
||||||
|
if (toolsBlock) chunks.push(toolsBlock);
|
||||||
|
for (const msg of messages || []) {
|
||||||
|
if (msg && msg.role === "system") {
|
||||||
|
const t = _text(msg.content).trim();
|
||||||
|
if (t) chunks.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks.join("\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie messagesToPrompt, aber OHNE System-Messages und OHNE Tool-Block — nur der
|
||||||
|
* eigentliche Verlauf (user/assistant/tool). Fuer den Modus, in dem der
|
||||||
|
* System-Prompt ueber --system-prompt separat zugestellt wird.
|
||||||
|
*/
|
||||||
|
export function conversationToPrompt(messages) {
|
||||||
|
const parts = [];
|
||||||
|
for (const msg of messages || []) {
|
||||||
|
if (!msg) continue;
|
||||||
|
switch (msg.role) {
|
||||||
|
case "system":
|
||||||
|
break; // geht ueber --system-prompt
|
||||||
|
case "user":
|
||||||
|
parts.push(_text(msg.content));
|
||||||
|
break;
|
||||||
|
case "assistant": {
|
||||||
|
const txt = _text(msg.content);
|
||||||
|
const tcs = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
||||||
|
const tcParts = tcs.map((tc) => {
|
||||||
|
const name = tc?.function?.name || tc?.name || "";
|
||||||
|
let args = tc?.function?.arguments ?? tc?.arguments ?? "{}";
|
||||||
|
if (typeof args !== "string") {
|
||||||
|
try { args = JSON.stringify(args); } catch (_) { args = "{}"; }
|
||||||
|
}
|
||||||
|
return `<tool_call name="${name}">${args}</tool_call>`;
|
||||||
|
}).join("\n");
|
||||||
|
const combined = [txt, tcParts].filter(Boolean).join("\n").trim();
|
||||||
|
if (combined) parts.push(`<previous_response>\n${combined}\n</previous_response>\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tool": {
|
||||||
|
const name = msg.name || "";
|
||||||
|
const id = msg.tool_call_id || "";
|
||||||
|
parts.push(
|
||||||
|
`<tool_result tool_call_id="${id}" name="${name}">\n${_text(msg.content)}\n</tool_result>\n`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openaiToCli(request) {
|
||||||
|
// Persona/System + Tool-Block gehen ueber den ECHTEN System-Prompt-Kanal
|
||||||
|
// (--system-prompt = VOLLER Replace, siehe manager.js sed-Patch in
|
||||||
|
// docker-compose.yml). Der Prompt enthaelt nur noch den Gespraechsverlauf.
|
||||||
|
// systemPrompt ist immer ein String (nie undefined) — bei --system-prompt
|
||||||
|
// darf er nicht leer sein, sonst laeuft das Modell ohne System-Prompt.
|
||||||
|
return {
|
||||||
|
prompt: conversationToPrompt(request.messages),
|
||||||
|
systemPrompt: extractSystemPrompt(request.messages, request.tools),
|
||||||
|
model: extractModel(request.model),
|
||||||
|
sessionId: request.user,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user