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,
+ };
+}