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
|
# - cli-to-openai.js: parsed genau dieses Format aus Claudes Antwort
|
||||||
# wieder raus und baut daraus OpenAI-konforme tool_calls[]
|
# wieder raus und baut daraus OpenAI-konforme tool_calls[]
|
||||||
#
|
#
|
||||||
# BEWUSST NICHT uebernommen: ARIAs routes.js-Patch. Der haengt zusaetzlich
|
# ARIAs routes.js-Patch wurde bewusst NICHT 1:1 uebernommen (haengt
|
||||||
# an aria-bridge:8090 (Live-Tool-Stream + Not-Aus-Killswitch) und liest
|
# zusaetzlich an aria-bridge:8090 fuer Live-Tool-Stream + Not-Aus-
|
||||||
# /shared/config/models.json — beides ARIA-Bridge-spezifisch. Hier laeuft
|
# Killswitch sowie /shared/config/models.json — beides ARIA-Bridge-
|
||||||
# die NPM-Stock-Version von routes.js (macht die Kern-Route trotzdem,
|
# spezifisch, hier nicht vorhanden).
|
||||||
# nur ohne die Bridge-Extras). Falls Hermes spaeter auch Live-Stream/
|
#
|
||||||
# Killswitch braucht: sag Bescheid, dann bauen wir das nach.
|
# 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:
|
hermes-proxy:
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
container_name: hermes-proxy
|
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 '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 '/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|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/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/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||||
|
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||||
claude-max-api"
|
claude-max-api"
|
||||||
volumes:
|
volumes:
|
||||||
# WICHTIG: eigenes, von ARIA getrenntes Volume fuer die Claude-CLI-Session.
|
# 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