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:
2026-05-12 01:42:05 +02:00
parent 0d13118f7e
commit e26226f370
3 changed files with 308 additions and 3 deletions
+159
View File
@@ -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,
};
}