From 4710e4071b795e60478631dde88820ca30a47bee Mon Sep 17 00:00:00 2001 From: ARIA Date: Mon, 20 Jul 2026 07:55:55 +0000 Subject: [PATCH] fix(proxy): Streaming-Pfad parst -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 {json}-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 - 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 -Tags) getestet -- liefert korrekt 4 tool_calls + finish_reason=tool_calls. Co-Authored-By: Claude Sonnet 5 --- docker-compose.yml | 26 +++- proxy-patches/routes.js | 287 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 proxy-patches/routes.js diff --git a/docker-compose.yml b/docker-compose.yml index b84ea73..9ffe8b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 -Parser aus + # cli-to-openai.js auf (der laeuft nur im Non-Streaming-Pfad). Hermes + # nutzt aber immer stream:true -> Claude gibt + # {...} 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. diff --git a/proxy-patches/routes.js b/proxy-patches/routes.js new file mode 100644 index 0000000..852e3df --- /dev/null +++ b/proxy-patches/routes.js @@ -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 {...}-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 -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 -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