/** * ARIA-patched cli-to-openai adapter. * * Erweitert die npm-Version von claude-max-api-proxy: * - normalizeModelName ist null-safe (Original-Patch der vorher per sed lief). * - Parser fuer {json}-Bloecke im Result-Text: * Wenn welche gefunden werden, wandert das in `message.tool_calls` * (OpenAI-Format) und finish_reason=tool_calls. Der restliche Text * (alles ausserhalb der Bloecke) wird verworfen, weil das interner * Tool-Use-Schritt war, nicht User-facing. * * Wird zur Container-Startzeit ueber die npm-Version geschrieben * (siehe docker-compose.yml proxy-Block). */ import { randomUUID } from "crypto"; export function extractTextContent(message) { return message.message.content .filter((c) => c.type === "text") .map((c) => c.text) .join(""); } export function cliToOpenaiChunk(message, requestId, isFirst = false) { const text = extractTextContent(message); return { id: `chatcmpl-${requestId}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: normalizeModelName(message.message.model), choices: [ { index: 0, delta: { role: isFirst ? "assistant" : undefined, content: text, }, finish_reason: message.message.stop_reason ? "stop" : null, }, ], }; } export function createDoneChunk(requestId, model) { return { id: `chatcmpl-${requestId}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: normalizeModelName(model), choices: [ { index: 0, delta: {}, finish_reason: "stop", }, ], }; } /** * Sucht im Result-Text alle {json} * Bloecke. Gibt [{id, name, arguments(json-string)}, restText] zurueck. * * Defensiv: * - "name"-Attribut sowohl in Doppel- als auch Einzelhochkommata * - Whitespace beim JSON tolerant * - Bei JSON-Parse-Fehler: das Argument wird als _raw weitergereicht * (unser Brain-Side-Parser kennt das) */ function _parseToolCalls(text) { if (!text || typeof text !== "string") return { tool_calls: [], rest: text || "" }; const re = /([\s\S]*?)<\/tool_call>/gi; const tcs = []; let lastIndex = 0; const restParts = []; let m; while ((m = re.exec(text)) !== null) { restParts.push(text.slice(lastIndex, m.index)); const name = m[1]; let argsBody = (m[2] || "").trim(); // Fences entfernen falls Claude welche eingebaut hat argsBody = argsBody.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim(); if (!argsBody) argsBody = "{}"; // Validieren — aber in OpenAI-Format ist arguments immer ein STRING try { JSON.parse(argsBody); } catch (_) { // Behalten als Roh-String — Brain-Side toleriert das via {_raw:...} } tcs.push({ id: `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`, type: "function", function: { name, arguments: argsBody }, }); lastIndex = re.lastIndex; } restParts.push(text.slice(lastIndex)); return { tool_calls: tcs, rest: restParts.join("").trim() }; } // Laengere Texte, die Claude im Turn VOR der letzten Antwort geschrieben hat // (z.B. ein Bericht, danach noch Warten/Werkzeuge, dann eine kurze // Schlussnotiz). result.result enthaelt nur den LETZTEN Textblock — der // Bericht ging so verloren (28.10., 2-Stunden-Turn). Kurze Zwischensaetze // ("Ich schaue nach …") bleiben weg, Sprachausgabe nur aus der letzten Antwort. const EARLIER_TEXT_MIN = 300; function _mergeEarlier(finalText, earlier) { const keep = (earlier || []) .map(t => String(t || "").replace(/[\s\S]*?<\/voice>/g, "").replace(/\[\[[A-Z_]+\]\]/g, "").trim()) .filter(t => t.length >= EARLIER_TEXT_MIN && !finalText.includes(t)); return keep.length ? keep.join("\n\n") + "\n\n" + finalText : finalText; } export function cliResultToOpenai(result, requestId, earlierTexts = []) { // modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem // der Request wirklich lief — bei einem Alias also die aufgeloeste Version // ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call // zustande kam (z.B. unbekanntes Modell). const usage = result.modelUsage || {}; const usedKey = Object.keys(usage)[0]; const modelName = (usedKey && (usage[usedKey]?.canonicalModel || usedKey)) || ""; const rawText = result.result || ""; const { tool_calls, rest } = _parseToolCalls(rawText); const message = { role: "assistant" }; let finishReason = "stop"; if (tool_calls.length > 0) { message.tool_calls = tool_calls; // Wenn Claude neben den Tool-Calls noch Text geschrieben hat, behalten // wir den im content — Brain-Seite kann ihn als Pre-Tool-Plaintext sehen. // Wenn nur Tool-Calls da waren (rest leer), content explizit null. message.content = rest || null; finishReason = "tool_calls"; } else { message.content = _mergeEarlier(rawText, earlierTexts); } return { id: `chatcmpl-${requestId}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: normalizeModelName(modelName), choices: [ { index: 0, message, finish_reason: finishReason }, ], usage: { prompt_tokens: result.usage?.input_tokens || 0, completion_tokens: result.usage?.output_tokens || 0, total_tokens: (result.usage?.input_tokens || 0) + (result.usage?.output_tokens || 0), }, // ARIA-Zusatzfelder (ausserhalb des OpenAI-Schemas, Clients ignorieren // sie). Damit kann die Diagnostic pruefen, ob ein Modell wirklich // gelaufen ist: bei einem unbekannten Namen kommt die CLI gar nicht bis // zum Call, dann ist modelUsage leer und is_error gesetzt. Ohne das // koennte man einen Fehlschlag nicht von einer Antwort unterscheiden — // die Fehlermeldung steht naemlich einfach im content. aria_model: modelName, aria_error: result.is_error === true, aria_context_window: (usedKey && usage[usedKey]?.contextWindow) || 0, aria_max_output: (usedKey && usage[usedKey]?.maxOutputTokens) || 0, }; } /** * Modellname fuer die Antwort. * * Hier wurde der Name FRUEHER umgeschrieben: alles mit "opus" drin wurde zu * "claude-opus-4", Sonnet zu "claude-sonnet-4" usw. Damit log das Feld — die * CLI liefert in modelUsage den tatsaechlich benutzten Namen (z.B. * "claude-opus-5-5"), und genau der wurde weggeworfen. Im OpenAI-Protokoll * soll `model` das Modell nennen, das geantwortet HAT; ein erfundener Name * macht jede Ueberpruefung unmoeglich ("welches Modell lief gerade?"). * * Jetzt bleibt der echte Name stehen; nur wenn gar keiner da ist, gibt es * einen neutralen Platzhalter. */ function normalizeModelName(model) { return model || "unknown"; }