Files
hermes-agent/proxy-patches/routes.js
T
ARIA 128c11b49d debug(proxy): Streaming-close/result-Events sichtbar loggen statt still leer zu bleiben
Root Cause fuer 'Empty response (no content or reasoning)'-Retries im
hermes-agent-Log noch nicht zweifelsfrei geklaert -- der bisherige Code
konnte einen Subprocess-Abschluss ohne 'result'-Event (code===0, kein
Fehler-Chunk) still mit nur [DONE] beenden, was beim Client exakt wie eine
leere Antwort aussieht. Jetzt: immer ein sichtbarer Fehler-Chunk + Log-Zeile
bei fehlendem result-Event, und ein Debug-Log bei jedem result-Event mit
subtype/is_error/Textlaenge. Naechster Repro-Versuch mit docker logs
hermes-proxy zeigt damit die echte Ursache.
2026-07-20 07:59:29 +00:00

314 lines
13 KiB
JavaScript

/**
* 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 <tool_call name="...">{...}</tool_call>-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 <tool_call>-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 <tool_call>-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