Initial Hermes Agent stack: Claude-Max proxy + gateway, docker-compose, README

This commit is contained in:
ARIA
2026-07-19 19:58:19 +00:00
commit ce5fde62f1
6 changed files with 718 additions and 0 deletions
+145
View File
@@ -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;
}
+92
View File
@@ -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}`);
});
+225
View File
@@ -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,
};
}