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
+146
View File
@@ -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;
}