/** * 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(""); lines.push( "WICHTIG: Alle unten gelisteten Tools sind bereits authentifiziert, " + "eingeloggt und einsatzbereit — der Nutzer hat das vorab eingerichtet. " + "Du hast KEINE Moeglichkeit und KEINEN Grund, den Verbindungs- oder " + "Login-Status zu pruefen oder in Frage zu stellen — es gibt dafuer kein " + "separates Status-Tool und keinen Hinweis im Prompt, der etwas anderes " + "sagt. Behaupte NIEMALS von dir aus, ein Tool sei \"nicht verbunden\", " + "\"nicht aktiv\" oder \"nicht autorisiert\" — das ist erfunden, wenn es " + "nicht explizit hier steht. Ruf das passende Tool bei einer passenden " + "Anfrage EINFACH DIREKT auf, ohne Rueckfrage zur Verbindung. Nur bei " + "einem echten Fehler in der Tool-ANTWORT (z.B. HTTP 401/403) darfst du " + "das als Problem melden." ); lines.push(""); lines.push( "SEHR WICHTIG — kein natives Tool, keine Tool-Suche: Die unten gelisteten " + "Tools sind NICHT Teil deines eigenen nativen Funktionsaufruf-/MCP-/ToolSearch-" + "Systems und werden dir NICHT als aufrufbare Funktionen uebergeben. Sie sind " + "ausschliesslich TEXT-BESCHREIBUNGEN in diesem Prompt. Versuche NIEMALS, sie " + "als natives Tool aufzurufen, ueber eine interne Tool-Suche zu finden oder zu " + "aktivieren, oder sie mit deinen eigenen Skills/Connectors/MCP-Servern zu " + "verwechseln. Ein nativer Aufruf-Versuch schlaegt IMMER mit einem Fehler wie " + "\"No such tool available\" fehl — das bedeutet NICHT, dass das Tool fehlt, " + "nicht verbunden oder nicht registriert ist, sondern nur, dass du es falsch " + "aufgerufen hast. Der EINZIGE richtige Weg ist, den literalen Text-Block " + "`{...}` als normalen Antworttext auszugeben " + "(siehe Format weiter unten) — kein Funktionsaufruf, keine Suche, kein Session-" + "Neustart noetig." ); 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. const systemPrompt = extractSystemPrompt(request.messages, request.tools); // TEMP-DEBUG (20.07.2026, Root-Cause-Suche "Claude kennt Hermes-Tools nicht"): // Sichtbar per `docker logs hermes-proxy`. Zeigt schwarz auf weiss, ob // Hermes ueberhaupt ein `tools`-Feld + system-Messages mitschickt, und wie // lang der daraus gebaute systemPrompt ist. Wenn systemPromptLen sehr klein // ist (z.B. 0) UND toolsCount 0 ist: Hermes schickt schlicht nichts mit, // das ist dann kein Proxy-Bug sondern ein Hermes-Konfig-Thema (Toolset // nicht aktiv / Persona-Prompt anders uebergeben). Wieder rausnehmen sobald // geklaert. try { const roles = (request.messages || []).map((m) => m && m.role); const toolNames = Array.isArray(request.tools) ? request.tools.map((t) => t?.function?.name).filter(Boolean) : []; console.error( "[openai-to-cli][DEBUG] messageRoles=" + JSON.stringify(roles) + " toolsCount=" + (Array.isArray(request.tools) ? request.tools.length : 0) + " toolNames=" + JSON.stringify(toolNames) + " systemPromptLen=" + systemPrompt.length + " systemPromptPreview=" + JSON.stringify(systemPrompt.slice(0, 200)) ); } catch (e) { console.error("[openai-to-cli][DEBUG] logging failed: " + e.message); } return { prompt: conversationToPrompt(request.messages), systemPrompt, model: extractModel(request.model), sessionId: request.user, }; }