From ce5fde62f13db84e7221d7e6e5f2d038b39b02c0 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sun, 19 Jul 2026 19:58:19 +0000 Subject: [PATCH] Initial Hermes Agent stack: Claude-Max proxy + gateway, docker-compose, README --- .env.example | 12 ++ README.md | 116 +++++++++++++++++ docker-compose.yml | 128 +++++++++++++++++++ proxy-patches/cli-to-openai.js | 145 +++++++++++++++++++++ proxy-patches/gateway.js | 92 ++++++++++++++ proxy-patches/openai-to-cli.js | 225 +++++++++++++++++++++++++++++++++ 6 files changed, 718 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 proxy-patches/cli-to-openai.js create mode 100644 proxy-patches/gateway.js create mode 100644 proxy-patches/openai-to-cli.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1484ddf --- /dev/null +++ b/.env.example @@ -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://:/v1 +HERMES_GATEWAY_PORT=8447 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1159ab2 --- /dev/null +++ b/README.md @@ -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 + 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 `-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` <-> ``-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://:${HERMES_GATEWAY_PORT:-8447}/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -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://:8447/v1" + api_key: "" + ``` + `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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9f03ed6 --- /dev/null +++ b/docker-compose.yml @@ -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 <-> -Text-Bridge, kein ARIA-Code drin): + # - openai-to-cli.js: haengt `tools`-Definitionen als Anleitung in den + # System-Prompt ("gib {json} 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 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://:${HERMES_GATEWAY_PORT:-8447}/v1" + # api_key: "" + # OpenAI-kompatible Clients schicken api_key automatisch als + # "Authorization: Bearer " — 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 diff --git a/proxy-patches/cli-to-openai.js b/proxy-patches/cli-to-openai.js new file mode 100644 index 0000000..d613518 --- /dev/null +++ b/proxy-patches/cli-to-openai.js @@ -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 {json}-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 {json} + * 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 = /([\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; +} diff --git a/proxy-patches/gateway.js b/proxy-patches/gateway.js new file mode 100644 index 0000000..36ecad2 --- /dev/null +++ b/proxy-patches/gateway.js @@ -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 "-Header. In Hermes' config.yaml also + * einfach `api_key: ""` (= 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}`); +}); diff --git a/proxy-patches/openai-to-cli.js b/proxy-patches/openai-to-cli.js new file mode 100644 index 0000000..f9a2398 --- /dev/null +++ b/proxy-patches/openai-to-cli.js @@ -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 + * {...} Format zu benutzen statt + * freiem Text. + * - Wenn Messages role=tool enthalten: deren Inhalt wird als + * 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 {json args} + * 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('{"arg1":"value","arg2":123}'); + 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(`\n${toolsBlock}\n\n`); + } + for (const msg of messages) { + if (!msg) continue; + switch (msg.role) { + case "system": + parts.push(`\n${_text(msg.content)}\n\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 `${args}`; + }).join("\n"); + const combined = [txt, tcParts].filter(Boolean).join("\n").trim(); + if (combined) parts.push(`\n${combined}\n\n`); + break; + } + case "tool": { + const name = msg.name || ""; + const id = msg.tool_call_id || ""; + parts.push( + `\n${_text(msg.content)}\n\n` + ); + break; + } + } + } + return parts.join("\n").trim(); +} + +/** + * Extrahiert NUR den System-Anteil (System-Messages + Tool-Use-Block) als + * rohen Text — OHNE -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 `${args}`; + }).join("\n"); + const combined = [txt, tcParts].filter(Boolean).join("\n").trim(); + if (combined) parts.push(`\n${combined}\n\n`); + break; + } + case "tool": { + const name = msg.name || ""; + const id = msg.tool_call_id || ""; + parts.push( + `\n${_text(msg.content)}\n\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, + }; +}