diff --git a/docker-compose.yml b/docker-compose.yml index 6972706..0fd5c8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,15 +11,15 @@ services: npm install -g @anthropic-ai/claude-code claude-max-api-proxy && DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) && sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js && - sed -i 's/if (model\.includes/if ((model||\"claude-sonnet-4\").includes/g' $$DIST/adapter/cli-to-openai.js && - sed -i '1i\\function _t(c){return typeof c===\"string\"?c:Array.isArray(c)?c.filter(function(b){return b.type===\"text\"}).map(function(b){return b.text||\"\"}).join(\"\"):String(c)}' $$DIST/adapter/openai-to-cli.js && - sed -i 's/msg\\.content/_t(msg.content)/g' $$DIST/adapter/openai-to-cli.js && sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.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 && claude-max-api" volumes: - ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json) - ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA) - aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App) + - ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only) environment: - HOST=0.0.0.0 - SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash) diff --git a/proxy-patches/cli-to-openai.js b/proxy-patches/cli-to-openai.js new file mode 100644 index 0000000..57dc437 --- /dev/null +++ b/proxy-patches/cli-to-openai.js @@ -0,0 +1,146 @@ +/** + * 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() }; +} + +export function cliResultToOpenai(result, requestId) { + const modelName = result.modelUsage + ? Object.keys(result.modelUsage)[0] + : "claude-sonnet-4"; + + 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 = rawText; + } + + 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), + }, + }; +} + +function normalizeModelName(model) { + const m = model || "claude-sonnet-4"; + if (m.includes("opus")) return "claude-opus-4"; + if (m.includes("sonnet")) return "claude-sonnet-4"; + if (m.includes("haiku")) return "claude-haiku-4"; + return m; +} diff --git a/proxy-patches/openai-to-cli.js b/proxy-patches/openai-to-cli.js new file mode 100644 index 0000000..86cd8ac --- /dev/null +++ b/proxy-patches/openai-to-cli.js @@ -0,0 +1,159 @@ +/** + * ARIA-patched openai-to-cli adapter. + * + * Erweitert die npm-Version von claude-max-api-proxy: + * - Multimodal-Content (Array von text-Parts) wird zu String reduziert. + * - Wenn die Anfrage ein `tools`-Feld enthaelt: die Tool-Definitionen + * werden in den Prompt als -Block injiziert, mit klarer + * Anweisung das {...} Format + * zu verwenden statt freiem Text. + * - Wenn Messages role=tool enthalten: deren Inhalt wird als + * ins Prompt-Fragment + * eingewoben damit Claude den Loop-Step bekommt. + * + * Wird zur Container-Startzeit ueber die npm-Version geschrieben + * (siehe docker-compose.yml proxy-Block). + */ + +const MODEL_MAP = { + "claude-opus-4": "opus", + "claude-sonnet-4": "sonnet", + "claude-haiku-4": "haiku", + "claude-code-cli/claude-opus-4": "opus", + "claude-code-cli/claude-sonnet-4": "sonnet", + "claude-code-cli/claude-haiku-4": "haiku", + "opus": "opus", + "sonnet": "sonnet", + "haiku": "haiku", +}; + +export function extractModel(model) { + if (MODEL_MAP[model]) return MODEL_MAP[model]; + const stripped = (model || "").replace(/^claude-code-cli\//, ""); + if (MODEL_MAP[stripped]) return MODEL_MAP[stripped]; + return "opus"; +} + +/** Multimodal: content kann String oder Array von Parts sein. */ +function _text(c) { + if (typeof c === "string") return c; + if (Array.isArray(c)) { + return c + .filter((b) => b && b.type === "text") + .map((b) => b.text || "") + .join(""); + } + return String(c == null ? "" : c); +} + +/** + * Baut den Tool-Use-Block fuer den System-Prompt. + * Anweisung: Claude soll {json args} + * ausgeben statt das Tool intern via Bash zu simulieren. + */ +function _toolsBlock(tools) { + if (!Array.isArray(tools) || tools.length === 0) return ""; + const lines = []; + lines.push("# Verfuegbare Tools"); + lines.push(""); + lines.push( + "Du hast neben deinen eigenen internen Tools (Bash, Read, etc.) auch " + + "diese externen Tools, die im Backend-System angesiedelt sind. " + + "Sie sind die EINZIGE Moeglichkeit Aktionen auszuloesen wie Trigger anlegen, " + + "Skills aufrufen, oder Konfiguration aendern. Simuliere sie NICHT mit Bash/sleep — " + + "rufe sie sauber auf:" + ); + lines.push(""); + for (const t of tools) { + if (!t || t.type !== "function" || !t.function) continue; + const fn = t.function; + const name = fn.name || ""; + const desc = fn.description || ""; + const params = fn.parameters || {}; + lines.push(`## ${name}`); + if (desc) lines.push(desc); + try { + lines.push("Schema: " + JSON.stringify(params)); + } catch (_) { + lines.push("Schema: (nicht serialisierbar)"); + } + lines.push(""); + } + lines.push("# Tool-Call-Format"); + lines.push(""); + lines.push( + "Wenn du eines der OBIGEN externen Tools aufrufen willst, antworte " + + "**ausschliesslich** mit einem oder mehreren Bloecken in genau dieser Form, " + + "JEDER fuer sich auf einer eigenen Zeile:" + ); + lines.push(""); + lines.push('{"arg1":"value","arg2":123}'); + lines.push(""); + lines.push( + "Regeln: (1) Innerhalb des Blocks steht NUR gueltiges JSON mit den Argumenten. " + + "(2) Kein Text drumherum. (3) Keine Code-Fences, kein Markdown. " + + "(4) Mehrere Tool-Calls = mehrere Bloecke untereinander. " + + "(5) Nach den Bloecken aufhoeren — der Server fuehrt die Tools aus und " + + "schickt dir die Ergebnisse fuer den naechsten Turn. " + + "(6) Wenn KEIN externes Tool noetig ist, antworte normal als Text fuer den User. " + + "(7) Nutze Bash/sleep NICHT als Ersatz fuer trigger_timer — das ist genau " + + "der Bug den wir damit fixen." + ); + return lines.join("\n"); +} + +/** + * Wandelt OpenAI-messages in einen Single-String-Prompt um. + * - system/user/assistant wie bisher + * - tool-role: als eingewoben + */ +export function messagesToPrompt(messages, tools) { + const parts = []; + const toolsBlock = _toolsBlock(tools); + if (toolsBlock) { + parts.push(`\n${toolsBlock}\n\n`); + } + for (const msg of messages) { + if (!msg) continue; + switch (msg.role) { + case "system": + parts.push(`\n${_text(msg.content)}\n\n`); + break; + case "user": + parts.push(_text(msg.content)); + break; + case "assistant": { + const txt = _text(msg.content); + const tcs = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + const tcParts = tcs.map((tc) => { + const name = tc?.function?.name || tc?.name || ""; + let args = tc?.function?.arguments ?? tc?.arguments ?? "{}"; + if (typeof args !== "string") { + try { args = JSON.stringify(args); } catch (_) { args = "{}"; } + } + return `${args}`; + }).join("\n"); + const combined = [txt, tcParts].filter(Boolean).join("\n").trim(); + if (combined) parts.push(`\n${combined}\n\n`); + break; + } + case "tool": { + const name = msg.name || ""; + const id = msg.tool_call_id || ""; + parts.push( + `\n${_text(msg.content)}\n\n` + ); + break; + } + } + } + return parts.join("\n").trim(); +} + +export function openaiToCli(request) { + return { + prompt: messagesToPrompt(request.messages, request.tools), + model: extractModel(request.model), + sessionId: request.user, + }; +}