/** * API Route Handlers * * Implements OpenAI-compatible endpoints for Clawdbot integration * * ============================================================================ * ARIA-Patch (ersetzt die npm-Stock-Datei komplett statt per sed, weil * handleStreamingResponse strukturell umgebaut werden musste): * * 1) systemPrompt-Passthrough: cliInput.systemPrompt wird jetzt in BEIDEN * subprocess.start(...)-Aufrufen (Streaming + Non-Streaming) mitgegeben. * Vorher fehlte das komplett -> Claude lief mit Default-Identitaet statt * dem injizierten Tool-Prompt. * * 2) Tool-Call-Parsing im Streaming-Pfad: Die Stock-Version von * handleStreamingResponse reicht jedes content_delta-Token roh (als * normalen Text) an den Client durch und setzt finish_reason nie auf * "tool_calls" -- der ganze {...}-Parser * aus adapter/cli-to-openai.js (_parseToolCalls / cliResultToOpenai) wurde * nur vom NICHT-Streaming-Pfad (handleNonStreamingResponse) aufgerufen. * Ergebnis: Hermes bekam beim Chat (der immer stream:true nutzt) die * Tool-Call-Tags als literalen Fliesstext angezeigt, nie als echtes * OpenAI tool_calls-Array -> keine Tool-Ausfuehrung, kein Ergebnis- * Round-Trip, Claude haeufte neue -Bloecke an weil nie eine * Antwort zurueckkam ("es passiert nicht viel"). * * Fix: handleStreamingResponse streamt keine rohen content_delta-Tokens * mehr live, sondern wartet auf das "result"-Event (= kompletter Text, * exakt wie im Non-Streaming-Pfad) und baut daraus per cliResultToOpenai() * dieselbe tool_calls/finish_reason-Struktur, verpackt als ein * Chunk + Done-Chunk. Kostet den Live-Tipp-Effekt, aber Tool-Calls * funktionieren jetzt ueberhaupt erst. * ============================================================================ */ import { v4 as uuidv4 } from "uuid"; import { ClaudeSubprocess } from "../subprocess/manager.js"; import { openaiToCli } from "../adapter/openai-to-cli.js"; import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js"; /** * Handle POST /v1/chat/completions * * Main endpoint for chat requests, supports both streaming and non-streaming */ export async function handleChatCompletions(req, res) { const requestId = uuidv4().replace(/-/g, "").slice(0, 24); const body = req.body; const stream = body.stream === true; try { // Validate request if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) { res.status(400).json({ error: { message: "messages is required and must be a non-empty array", type: "invalid_request_error", code: "invalid_messages", }, }); return; } // Convert to CLI input format const cliInput = openaiToCli(body); const subprocess = new ClaudeSubprocess(); if (stream) { await handleStreamingResponse(req, res, subprocess, cliInput, requestId); } else { await handleNonStreamingResponse(res, subprocess, cliInput, requestId); } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; console.error("[handleChatCompletions] Error:", message); if (!res.headersSent) { res.status(500).json({ error: { message, type: "server_error", code: null, }, }); } } } /** * Handle streaming response (SSE) * * IMPORTANT: The Express req.on("close") event fires when the request body * is fully received, NOT when the client disconnects. For SSE connections, * we use res.on("close") to detect actual client disconnection. * * ARIA-Patch: content_delta wird NICHT mehr live weitergestreamt (siehe * Kommentar am Dateikopf) -- stattdessen wird auf "result" gewartet und die * volle Antwort ueber cliResultToOpenai() (Tool-Call-faehig) in genau zwei * SSE-Chunks (Content/Tool-Calls + Done) verpackt. */ async function handleStreamingResponse(req, res, subprocess, cliInput, requestId) { // Set SSE headers res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Request-Id", requestId); // CRITICAL: Flush headers immediately to establish SSE connection // Without this, headers are buffered and client times out waiting res.flushHeaders(); // Send initial comment to confirm connection is alive res.write(":ok\n\n"); return new Promise((resolve, reject) => { let isComplete = false; // Handle actual client disconnect (response stream closed) res.on("close", () => { if (!isComplete) { // Client disconnected before response completed - kill subprocess subprocess.kill(); } resolve(); }); subprocess.on("result", (result) => { isComplete = true; // ARIA-Patch (20.07.2026): Diagnose-Log fuer "Empty response"-Retries. // Zeigt ob die "result"-Message ueberhaupt Text enthielt (result.result), // welcher subtype/is_error sie hatte, und wieviele Zeichen am Ende als // Content beim Client ankommen -- damit sich naechstes Mal in EINEM // "docker logs hermes-proxy" sehen laesst ob (a) Claude wirklich leer // geantwortet hat, (b) ein Fehler-Subtype ohne result-Feld vorlag, oder // (c) der Text da ist aber beim Parsen/Weiterreichen verloren geht. console.error( "[Streaming][DEBUG] result-event: subtype=" + result?.subtype + " is_error=" + result?.is_error + " resultTextLen=" + (typeof result?.result === "string" ? result.result.length : "n/a (" + typeof result?.result + ")") + " resultPreview=" + JSON.stringify((typeof result?.result === "string" ? result.result : "").slice(0, 200)) ); if (!res.writableEnded) { // Volle Antwort ist da -- durch denselben Tool-Call-Parser wie im // Non-Streaming-Pfad jagen, damit -Bloecke als echtes // OpenAI tool_calls-Array rauskommen statt als roher Text. const completion = cliResultToOpenai(result, requestId); const choice = completion.choices[0]; const contentChunk = { id: `chatcmpl-${requestId}`, object: "chat.completion.chunk", created: completion.created, model: completion.model, choices: [ { index: 0, delta: choice.message.tool_calls ? { role: "assistant", content: choice.message.content || null, tool_calls: choice.message.tool_calls, } : { role: "assistant", content: choice.message.content, }, finish_reason: null, }, ], }; res.write(`data: ${JSON.stringify(contentChunk)}\n\n`); const doneChunk = createDoneChunk(requestId, completion.model); doneChunk.choices[0].finish_reason = choice.finish_reason; res.write(`data: ${JSON.stringify(doneChunk)}\n\n`); res.write("data: [DONE]\n\n"); res.end(); } resolve(); }); subprocess.on("error", (error) => { console.error("[Streaming] Error:", error.message); if (!res.writableEnded) { res.write(`data: ${JSON.stringify({ error: { message: error.message, type: "server_error", code: null }, })}\n\n`); res.end(); } resolve(); }); subprocess.on("close", (code) => { // Subprocess exited - ensure response is closed if (!res.writableEnded) { if (!isComplete) { // ARIA-Patch (20.07.2026): vorher wurde hier NUR bei code!==0 ein // Fehler gemeldet. Faelle in denen der Subprocess mit code===0 // schliesst OHNE je ein "result"-Event gefeuert zu haben (z.B. // Claude CLI liefert eine "result"-Message ohne "result"-Textfeld, // oder ein "error_max_turns"/aehnlicher Abschluss-Subtype, oder das // letzte JSON-Fragment im Buffer war unvollstaendig/unparsbar) // wurden bisher STILL mit nur "[DONE]" beendet -- Hermes bekam einen // Stream OHNE jeden content/tool_calls-Chunk und wertete das als // "Empty response (no content or reasoning)" -> eigener Retry-Loop // im hermes-agent-Container (agent.conversation_loop, retry 1/3..3/3). // Jetzt: IMMER einen sichtbaren Fehler-Chunk schreiben, egal welcher // Exit-Code -- macht die Ursache in "docker logs hermes-proxy" // sichtbar statt lautlos leer zu bleiben. console.error(`[Streaming] Subprocess closed (code=${code}) without ever emitting a "result" event -- no content/tool_calls was sent to the client.`); res.write(`data: ${JSON.stringify({ error: { message: `Claude CLI process closed (code ${code}) without producing a result`, type: "server_error", code: null }, })}\n\n`); } res.write("data: [DONE]\n\n"); res.end(); } resolve(); }); // Start the subprocess subprocess.start(cliInput.prompt, { model: cliInput.model, systemPrompt: cliInput.systemPrompt, sessionId: cliInput.sessionId, }).catch((err) => { console.error("[Streaming] Subprocess start error:", err); reject(err); }); }); } /** * Handle non-streaming response */ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) { return new Promise((resolve) => { let finalResult = null; subprocess.on("result", (result) => { finalResult = result; }); subprocess.on("error", (error) => { console.error("[NonStreaming] Error:", error.message); res.status(500).json({ error: { message: error.message, type: "server_error", code: null, }, }); resolve(); }); subprocess.on("close", (code) => { if (finalResult) { res.json(cliResultToOpenai(finalResult, requestId)); } else if (!res.headersSent) { res.status(500).json({ error: { message: `Claude CLI exited with code ${code} without response`, type: "server_error", code: null, }, }); } resolve(); }); // Start the subprocess subprocess .start(cliInput.prompt, { model: cliInput.model, systemPrompt: cliInput.systemPrompt, sessionId: cliInput.sessionId, }) .catch((error) => { res.status(500).json({ error: { message: error.message, type: "server_error", code: null, }, }); resolve(); }); }); } /** * Handle GET /v1/models * * Returns available models */ export function handleModels(_req, res) { res.json({ object: "list", data: [ { id: "claude-opus-4", object: "model", owned_by: "anthropic", created: Math.floor(Date.now() / 1000), }, { id: "claude-sonnet-4", object: "model", owned_by: "anthropic", created: Math.floor(Date.now() / 1000), }, { id: "claude-haiku-4", object: "model", owned_by: "anthropic", created: Math.floor(Date.now() / 1000), }, ], }); } /** * Handle GET /health * * Health check endpoint */ export function handleHealth(_req, res) { res.json({ status: "ok", provider: "claude-code-cli", timestamp: new Date().toISOString(), }); } //# sourceMappingURL=routes.js.map