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.
314 lines
13 KiB
JavaScript
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
|