/** * ARIA-patched API Route Handlers * * Erweiterung der npm-Version von claude-max-api-proxy: * - Bei jedem Claude-CLI-`assistant`-Event mit tool_use-Block (Bash, Read, * Edit, Grep, …) wird ein HTTP-POST an die Bridge gefeuert * (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity). * Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic → * Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht. * - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht * der Brain-Call NICHT ab. * * Wird zur Container-Startzeit ueber die npm-Version geschrieben * (siehe docker-compose.yml proxy-Block). */ import { v4 as uuidv4 } from "uuid"; import http from "http"; import { ClaudeSubprocess } from "../subprocess/manager.js"; import { openaiToCli } from "../adapter/openai-to-cli.js"; import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js"; const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL || "http://aria-bridge:8090/internal/agent-activity"; /** * Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget — keine Awaits, * keine Fehler nach oben. Logged Fehler still. */ function _emitToolEvent(toolName) { if (!toolName) return; try { const u = new URL(TOOL_HOOK_URL); const body = JSON.stringify({ tool: String(toolName) }); const req = http.request({ method: "POST", hostname: u.hostname, port: u.port || 80, path: u.pathname, headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }, timeout: 2000, }, (res) => { res.resume(); }); req.on("error", () => {}); req.on("timeout", () => req.destroy()); req.write(body); req.end(); } catch (_) { /* niemals weiterwerfen */ } } /** * Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message * kann mehrere content-Bloecke haben — tool_use-Bloecke pushen wir live. */ function _attachToolHook(subprocess) { subprocess.on("assistant", (message) => { try { const blocks = message?.message?.content || []; for (const b of blocks) { if (b && b.type === "tool_use" && b.name) { _emitToolEvent(b.name); } } } catch (_) { /* fail-open */ } }); } /** * 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(); // ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten. // Greift fuer beide Branches (stream + non-stream). _attachToolHook(subprocess); 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. */ 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 isFirst = true; let lastModel = "claude-sonnet-4"; 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(); }); // Handle streaming content deltas subprocess.on("content_delta", (event) => { const text = event.event.delta?.text || ""; if (text && !res.writableEnded) { const chunk = { id: `chatcmpl-${requestId}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: lastModel, choices: [{ index: 0, delta: { role: isFirst ? "assistant" : undefined, content: text, }, finish_reason: null, }], }; res.write(`data: ${JSON.stringify(chunk)}\n\n`); isFirst = false; } }); // Handle final assistant message (for model name) subprocess.on("assistant", (message) => { lastModel = message.message.model; }); subprocess.on("result", (_result) => { isComplete = true; if (!res.writableEnded) { // Send final done chunk with finish_reason const doneChunk = createDoneChunk(requestId, lastModel); 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, 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, 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