Bisher liess sich nicht nachsehen, ob eine Modell-Einstellung ueberhaupt greift. Der Proxy hat die Information sogar aktiv weggeworfen: normalizeModelName() nahm den echten Namen aus modelUsage (z.B. "claude-opus-5-5") und schrieb ihn auf "claude-opus-4" um. Das `model`-Feld der Antwort log also — und damit war jede Ueberpruefung unmoeglich. - normalizeModelName gibt den echten Namen durch. Im OpenAI-Protokoll soll `model` das Modell nennen, das geantwortet HAT; ein erfundener Name ist schlechter als keiner. - Zusatzfelder aria_model / aria_error / aria_context_window / aria_max_output aus modelUsage.canonicalModel. Noetig, weil die CLI bei einem unbekannten Modell gar nicht bis zum Call kommt und ihre Begruendung einfach in den Antworttext legt — ohne Flag waere ein Fehlschlag nicht von einer Antwort zu unterscheiden. - Knopf "🔬 Testen" neben Dropdown und Freitextfeld. Er schickt eine echte Mini-Anfrage durch den GANZEN Weg (Diagnostic → Proxy → extractModel → claude CLI → Anthropic). Ausdruecklich NICHT gemacht: das Modell nach seiner eigenen Version fragen. Darauf ist kein Verlass — Modelle kennen ihren Versionsstring oft nicht und raten. Und den Dropdown-Wert zurueckzugeben waere sinnlos. Die Wahrheit kommt aus modelUsage der CLI. Vier unterscheidbare Ergebnisse: Tier-Alias gruen — zeigt die aufgeloeste Version ("opus" → Opus 5.5) exakte Version gruen — angefragt == geantwortet nicht erkannt GELB — der Proxy hat still "opus" eingesetzt; warnt ausdruecklich davor, die ID so zu setzen ungueltig ROT — CLI-Begruendung im Klartext Der gelbe Fall war wichtig: ein Name, der nicht wie ein Claude-Modell aussieht, faellt auf "opus" zurueck und haette sonst als Erfolg gegolten — man setzt dann eine ID, die nie benutzt wird, und merkt es nie. Geprueft gegen ECHTE CLI-Ausgaben aus dem Proxy-Container (9 Faelle: Alias, exakte Version, Tippfehler) plus 7 Faelle fuer die Anzeige-Logik. Nebenbefund: `--model opus` loest derzeit auf claude-opus-5-5 auf. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170 lines
6.4 KiB
JavaScript
170 lines
6.4 KiB
JavaScript
/**
|
|
* 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) {
|
|
// modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem
|
|
// der Request wirklich lief — bei einem Alias also die aufgeloeste Version
|
|
// ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call
|
|
// zustande kam (z.B. unbekanntes Modell).
|
|
const usage = result.modelUsage || {};
|
|
const usedKey = Object.keys(usage)[0];
|
|
const modelName = (usedKey && (usage[usedKey]?.canonicalModel || usedKey)) || "";
|
|
|
|
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),
|
|
},
|
|
// ARIA-Zusatzfelder (ausserhalb des OpenAI-Schemas, Clients ignorieren
|
|
// sie). Damit kann die Diagnostic pruefen, ob ein Modell wirklich
|
|
// gelaufen ist: bei einem unbekannten Namen kommt die CLI gar nicht bis
|
|
// zum Call, dann ist modelUsage leer und is_error gesetzt. Ohne das
|
|
// koennte man einen Fehlschlag nicht von einer Antwort unterscheiden —
|
|
// die Fehlermeldung steht naemlich einfach im content.
|
|
aria_model: modelName,
|
|
aria_error: result.is_error === true,
|
|
aria_context_window: (usedKey && usage[usedKey]?.contextWindow) || 0,
|
|
aria_max_output: (usedKey && usage[usedKey]?.maxOutputTokens) || 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Modellname fuer die Antwort.
|
|
*
|
|
* Hier wurde der Name FRUEHER umgeschrieben: alles mit "opus" drin wurde zu
|
|
* "claude-opus-4", Sonnet zu "claude-sonnet-4" usw. Damit log das Feld — die
|
|
* CLI liefert in modelUsage den tatsaechlich benutzten Namen (z.B.
|
|
* "claude-opus-5-5"), und genau der wurde weggeworfen. Im OpenAI-Protokoll
|
|
* soll `model` das Modell nennen, das geantwortet HAT; ein erfundener Name
|
|
* macht jede Ueberpruefung unmoeglich ("welches Modell lief gerade?").
|
|
*
|
|
* Jetzt bleibt der echte Name stehen; nur wenn gar keiner da ist, gibt es
|
|
* einen neutralen Platzhalter.
|
|
*/
|
|
function normalizeModelName(model) {
|
|
return model || "unknown";
|
|
}
|