feat(proxy): Tool-Use durchreichen — eigene Adapter-Files ueberschreiben npm-Version
Der claude-max-api-proxy ignoriert das OpenAI-tools-Feld komplett:
openai-to-cli.js wandelt nur messages in einen String, manager.js
spawnt 'claude --print' ohne Tools. Claude Code nutzt dann ihre
internen Tools (Bash, etc.) — bei 'Timer in 2min' macht sie ein
'sleep 120' intern und meldet 'erledigt' ohne dass wir je einen
trigger_timer-Call sehen.
Fix: zwei eigene Adapter-Files unter proxy-patches/ die zur
Container-Startzeit ueber die npm-Version kopiert werden:
openai-to-cli.js:
- tools-Feld wird als <system>-Block mit Tool-Schemas + klarer
Anweisung "Antworte <tool_call name=...>{json}</tool_call>"
in den Prompt injiziert
- role=tool messages werden als <tool_result>-Blocks eingewoben
→ Claude sieht den ganzen Tool-Use-Loop
- assistant tool_calls werden als <tool_call>-Bloecke
re-serialisiert, damit History-Roundtrips funktionieren
- Multimodal-content (Array von text-Parts) unveraendert
unterstuetzt (Original-sed-Patch eingebaut)
cli-to-openai.js:
- parsed <tool_call name="X">{json}</tool_call> aus result.result
- liefert OpenAI-konforme tool_calls + finish_reason=tool_calls
- Pre-Tool-Text bleibt im content erhalten
- normalizeModelName null-safe (Original-sed-Patch eingebaut)
docker-compose.yml: zwei sed-Patches die jetzt in den Files leben
sind raus, dafuer ein /proxy-patches:ro-Mount + zwei cp-Kommandos.
Smoke-Tests mit Node lokal alle gruen (single + multi tool_calls,
mit/ohne Pre-Text, History-Replay mit tool_result).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -11,15 +11,15 @@ services:
|
|||||||
npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
|
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) &&
|
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/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 &&
|
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"
|
claude-max-api"
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
|
- ~/.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-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)
|
- 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:
|
environment:
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash)
|
- SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash)
|
||||||
|
|||||||
@@ -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 <tool_call name="X">{json}</tool_call>-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 <tool_call name="...">{json}</tool_call>
|
||||||
|
* 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 = /<tool_call\s+name=["']([^"']+)["']\s*>([\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;
|
||||||
|
}
|
||||||
@@ -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 <system>-Block injiziert, mit klarer
|
||||||
|
* Anweisung das <tool_call name="...">{...}</tool_call> Format
|
||||||
|
* zu verwenden statt freiem Text.
|
||||||
|
* - Wenn Messages role=tool enthalten: deren Inhalt wird als
|
||||||
|
* <tool_result tool_call_id="...">…</tool_result> 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 <tool_call name="X">{json args}</tool_call>
|
||||||
|
* 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('<tool_call name="TOOL_NAME">{"arg1":"value","arg2":123}</tool_call>');
|
||||||
|
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 <tool_result tool_call_id="..." name="..."> eingewoben
|
||||||
|
*/
|
||||||
|
export function messagesToPrompt(messages, tools) {
|
||||||
|
const parts = [];
|
||||||
|
const toolsBlock = _toolsBlock(tools);
|
||||||
|
if (toolsBlock) {
|
||||||
|
parts.push(`<system>\n${toolsBlock}\n</system>\n`);
|
||||||
|
}
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg) continue;
|
||||||
|
switch (msg.role) {
|
||||||
|
case "system":
|
||||||
|
parts.push(`<system>\n${_text(msg.content)}\n</system>\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 `<tool_call name="${name}">${args}</tool_call>`;
|
||||||
|
}).join("\n");
|
||||||
|
const combined = [txt, tcParts].filter(Boolean).join("\n").trim();
|
||||||
|
if (combined) parts.push(`<previous_response>\n${combined}\n</previous_response>\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tool": {
|
||||||
|
const name = msg.name || "";
|
||||||
|
const id = msg.tool_call_id || "";
|
||||||
|
parts.push(
|
||||||
|
`<tool_result tool_call_id="${id}" name="${name}">\n${_text(msg.content)}\n</tool_result>\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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user