llmrouter: Anthropic-Messages-Pfad (/messages, count_tokens) für Claude Code

Weiterleitung an OpenRouters Messages-Endpunkt (x-api-key, anthropic-version,
anthropic-beta), Effort als reasoning.effort, Usage in Anthropic-Form gelesen
(auch im Stream aus message_start/message_delta). Nur openrouter-Ziele; lokale
llama-server sprechen kein Messages-Format. Verifiziert: claude -p über
http://llm.lan:4010 mit alt/anthropic/claude-sonnet-5 -> glm-5.3-flash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 16:23:26 +02:00
committed by andreas
co-authored by Claude Fable 5.1
parent 9d0bd7e1a3
commit 0d971b81dc
2 changed files with 30 additions and 8 deletions
+8 -4
View File
@@ -63,11 +63,15 @@ export class Log {
/** Zieht Tokens und Kosten aus einer OpenAI-/OpenRouter-Usage. */
export function usageLesen(u: unknown): Pick<Eintrag, "prompt_tokens" | "completion_tokens" | "cached_tokens" | "cost"> {
const usage = (u ?? {}) as { prompt_tokens?: number; completion_tokens?: number; cost?: number; prompt_tokens_details?: { cached_tokens?: number } };
// OpenAI-Form (prompt_tokens/completion_tokens) und Anthropic-Form (input_tokens/output_tokens)
const usage = (u ?? {}) as {
prompt_tokens?: number; completion_tokens?: number; cost?: number; prompt_tokens_details?: { cached_tokens?: number };
input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number;
};
return {
prompt_tokens: usage.prompt_tokens ?? null,
completion_tokens: usage.completion_tokens ?? null,
cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? null,
prompt_tokens: usage.prompt_tokens ?? usage.input_tokens ?? null,
completion_tokens: usage.completion_tokens ?? usage.output_tokens ?? null,
cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? usage.cache_read_input_tokens ?? null,
cost: typeof usage.cost === "number" ? usage.cost : null,
};
}
+22 -4
View File
@@ -116,11 +116,19 @@ async function zielBestimmen(angefragt: string, keyName: string): Promise<Ziel |
}
// --- Anfrage bauen -------------------------------------------------------------
const JSON_PFADE = new Set(["/chat/completions", "/completions", "/embeddings", "/audio/speech", "/responses"]);
// /messages + /messages/count_tokens: Anthropic-Messages-Format (Claude Code). OpenRouter
// bietet denselben Endpunkt fuer alle Modelle -- die einzige Format-Uebersetzung, die wir
// nicht selbst schreiben. Nur openrouter-Ziele; lokale llama-server koennen das nicht.
const JSON_PFADE = new Set(["/chat/completions", "/completions", "/embeddings", "/audio/speech", "/responses", "/messages", "/messages/count_tokens"]);
const FORM_PFADE = new Set(["/audio/transcriptions", "/audio/translations"]);
function jsonBody(orig: Record<string, unknown>, ziel: Ziel): Record<string, unknown> {
function jsonBody(orig: Record<string, unknown>, ziel: Ziel, pfad: string): Record<string, unknown> {
const b: Record<string, unknown> = { ...orig, model: ziel.model, ...(ziel.body ?? {}) };
if (pfad.startsWith("/messages")) {
// Anthropic-Format: kein usage.include, Effort als reasoning.effort (OpenRouter nimmt es dort an)
if (ziel.effortSetzen) { delete b.thinking; if (ziel.effort !== null) b.reasoning = { effort: ziel.effort }; }
return b;
}
if (ziel.upstreamName === "openrouter") {
b.usage = { include: true }; // Kosten kommen in der Antwort mit
if (b.stream === true) b.stream_options = { ...(b.stream_options as object ?? {}), include_usage: true };
@@ -201,7 +209,7 @@ Bun.serve({
if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status });
ziel = z;
stream = orig.stream === true;
upstreamBody = JSON.stringify(ziel.upstream.clientKey === true ? orig : jsonBody(orig, ziel));
upstreamBody = JSON.stringify(ziel.upstream.clientKey === true ? orig : jsonBody(orig, ziel, pfad));
headers.set("content-type", "application/json");
} else {
const form = await req.formData();
@@ -215,6 +223,12 @@ Bun.serve({
const k = upstreamKey(ziel.upstream);
if (k !== undefined) headers.set("authorization", `Bearer ${k}`);
if (ziel.upstream.clientKey === true) headers.set("authorization", `Bearer ${auth}`);
if (pfad.startsWith("/messages")) {
if (ziel.upstreamName !== "openrouter") return Response.json({ error: { type: "invalid_request_error", message: `${angefragt} liegt auf ${ziel.upstreamName}, das kein Messages-Format spricht` } }, { status: 400 });
if (k !== undefined) headers.set("x-api-key", k);
headers.set("anthropic-version", req.headers.get("anthropic-version") ?? "2023-06-01");
const beta = req.headers.get("anthropic-beta"); if (beta) headers.set("anthropic-beta", beta);
}
if (ziel.upstreamName === "openrouter") { headers.set("http-referer", "https://llm.lan"); headers.set("x-title", `llmrouter/${keyName}`); }
const accept = req.headers.get("accept"); if (accept) headers.set("accept", accept);
@@ -255,7 +269,11 @@ Bun.serve({
while ((i = rest.indexOf("\n")) >= 0) {
const zeile = rest.slice(0, i).trim(); rest = rest.slice(i + 1);
if (zeile.startsWith("data:") && zeile.includes('"usage"')) {
try { const j = JSON.parse(zeile.slice(5)); if (j.usage) usage = j.usage; } catch { /* Teilzeile */ }
try {
const j = JSON.parse(zeile.slice(5));
const u = j.usage ?? j.message?.usage; // OpenAI: usage | Anthropic: message_start.message.usage, message_delta.usage
if (u) usage = { ...(usage as object ?? {}), ...u };
} catch { /* Teilzeile */ }
}
}
},