fix(proxy): Streaming-Pfad parst <tool_call>-Tags nie -> Tool-Calls wirkungslos

Root Cause (verifiziert am echten npm-Paket claude-max-api-proxy@dist):
handleStreamingResponse() in server/routes.js baut SSE-Chunks komplett
inline aus den rohen content_delta-Tokens und ruft dabei NIE den
<tool_call name="X">{json}</tool_call>-Parser aus adapter/cli-to-openai.js
auf (_parseToolCalls / cliResultToOpenai) -- der laeuft bisher nur im
Non-Streaming-Pfad (handleNonStreamingResponse). Hermes fragt aber immer
mit stream:true an. Folge: Claude gibt die Tool-Call-Tags korrekt als Text
aus (das hatten wir zuletzt gefixt), aber sie kommen nie als echtes OpenAI
tool_calls[]-Array bei Hermes an -- kein Tool wird ausgefuehrt, kein
Ergebnis geht zurueck in die Session, Claude haeuft neue <tool_call>-
Bloecke an weil nie eine Antwort kommt ("es passiert nicht viel", vier
Tags in einer Antwort).

Bisher gab's fuer routes.js nur einen minimalen sed-Patch (systemPrompt
durchreichen), bewusst ohne ARIAs vollen routes.js-Patch (der haengt an
ARIA-Bridge-spezifischen Extras wie Killswitch/Live-Stream). Der Fehler
sitzt aber strukturell in handleStreamingResponse selbst, sed reicht nicht
mehr.

Fix: proxy-patches/routes.js -- eigene schlanke Variante ohne Bridge-
Extras, komplett per cp ersetzt statt sed. handleStreamingResponse
streamt keine rohen Tokens mehr live, sondern wartet auf das volle
"result"-Event und baut daraus per cliResultToOpenai() (dieselbe Logik
wie Non-Streaming) den finalen Chunk inkl. tool_calls[]/finish_reason.
Kostet den Live-Tipp-Effekt, aber Tool-Calls funktionieren jetzt
ueberhaupt erst. systemPrompt-Fix ist in der neuen Datei bereits
enthalten (ersetzt den alten sed dafuer).

Lokal verifiziert: node --check auf der neuen Datei, und
cliResultToOpenai() gegen Stefans exakten Beispiel-Text (vier
zusammenhaengende <tool_call>-Tags) getestet -- liefert korrekt 4
tool_calls + finish_reason=tool_calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ARIA
2026-07-20 07:55:55 +00:00
co-authored by Claude Sonnet 5
parent e00b406e23
commit 4710e4071b
2 changed files with 306 additions and 7 deletions
+19 -7
View File
@@ -60,12 +60,24 @@ services:
# - cli-to-openai.js: parsed genau dieses Format aus Claudes Antwort
# wieder raus und baut daraus OpenAI-konforme tool_calls[]
#
# BEWUSST NICHT uebernommen: ARIAs routes.js-Patch. Der haengt zusaetzlich
# an aria-bridge:8090 (Live-Tool-Stream + Not-Aus-Killswitch) und liest
# /shared/config/models.json — beides ARIA-Bridge-spezifisch. Hier laeuft
# die NPM-Stock-Version von routes.js (macht die Kern-Route trotzdem,
# nur ohne die Bridge-Extras). Falls Hermes spaeter auch Live-Stream/
# Killswitch braucht: sag Bescheid, dann bauen wir das nach.
# ARIAs routes.js-Patch wurde bewusst NICHT 1:1 uebernommen (haengt
# zusaetzlich an aria-bridge:8090 fuer Live-Tool-Stream + Not-Aus-
# Killswitch sowie /shared/config/models.json — beides ARIA-Bridge-
# spezifisch, hier nicht vorhanden).
#
# ABER: proxy-patches/routes.js (eigene, schlanke Variante ohne Bridge-
# Extras) ist inzwischen Pflicht, kein reiner sed-Patch mehr moeglich.
# Grund: die NPM-Stock-Version von handleStreamingResponse() baut SSE-
# Chunks komplett inline und ruft NIE den <tool_call>-Parser aus
# cli-to-openai.js auf (der laeuft nur im Non-Streaming-Pfad). Hermes
# nutzt aber immer stream:true -> Claude gibt <tool_call name="...">
# {...}</tool_call> brav als Text aus, aber es kommt nie als echtes
# OpenAI tool_calls[] an, wird nie ausgefuehrt, Claude haeuft neue
# Calls an weil nie ein Ergebnis zurueckkommt ("es passiert nicht viel").
# proxy-patches/routes.js wartet deshalb im Streaming-Pfad auf das volle
# "result"-Event und jagt es durch dieselbe cliResultToOpenai()-Logik
# wie der Non-Streaming-Pfad (kostet den Live-Tipp-Effekt, aber Tool-
# Calls funktionieren). Enthaelt den systemPrompt-Fix bereits mit.
hermes-proxy:
image: node:22-alpine
container_name: hermes-proxy
@@ -79,9 +91,9 @@ services:
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
sed -i 's/model: cliInput\\.model,/model: cliInput.model, systemPrompt: cliInput.systemPrompt,/' $$DIST/server/routes.js &&
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
claude-max-api"
volumes:
# WICHTIG: eigenes, von ARIA getrenntes Volume fuer die Claude-CLI-Session.
+287
View File
@@ -0,0 +1,287 @@
/**
* 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;
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 (code !== 0 && !isComplete) {
// Abnormal exit without result - send error
res.write(`data: ${JSON.stringify({
error: { message: `Process exited with code ${code}`, 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