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:
+19
-7
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user