/** * server.ts — der llmrouter: OpenAI-kompatibler Reverse-Proxy mit vier * Eingriffen je Anfrage: Client-Key pruefen, Namen aufloesen und Modell * umschreiben, Usage/Kosten aus der Antwort mitlesen, eine Zeile loggen. * * Namensraeume (Reihenfolge der Aufloesung): * role/ -> Ziel aus config.rollen, dann weiter * 42i/... (config.aliases) -> fest / schwelle / alternative (State) * openrouter/[:effort] -> 1:1 an OpenRouter * alt/[:effort] -> guenstigste Alternative (auf Abruf, State) * sonst -> fallback (LiteLLM), unveraendert * * Keine Format-Uebersetzung: alle Upstreams sprechen OpenAI-Format. * Umgebung: PORT, KEYS_FILE, ADMIN_KEY, OPENROUTER_API_KEY, MISTRAL_API_KEY, * KIRA_*_KEY, ARTIFICIALANALYSIS_API_KEY, LLMROUTER_CONFIG, MODEL_BLOCKLIST. */ import { configLaden, aclLaden, tokenLaden, upstreamKey, erlaubt, type Config, type Upstream } from "./config.ts"; import { aufloesen, alternativeAufloesen, datenHolen, stateLaden, stateVeraltet, type State } from "./resolver.ts"; import { Log, usageLesen, type Eintrag } from "./log.ts"; import { sprechtextNormalisieren } from "./sprechtext.ts"; import { referenzFormat, referenzParsen, EFFORTS, type Effort } from "../../llmlite/openrouter-auswahl.ts"; const config: Config = configLaden(); const acl = aclLaden(); let tokens = tokenLaden(); let state: State = stateLaden(config); const log = new Log(config.logDir); const jetzt = () => new Date().toISOString(); const sagen = (z: string) => console.log(`${jetzt()} ${z}`); // --- Zuordnung frisch halten ------------------------------------------------ let laeuft = false; async function auffrischen(grund: string): Promise { if (laeuft) return; laeuft = true; try { sagen(`Zuordnung auffrischen (${grund})`); state = await aufloesen(config, state, { log: sagen }); } catch (e) { sagen(`Auffrischen fehlgeschlagen: ${(e as Error).message} -- behalte Stand ${state.aktualisiert}`); } finally { laeuft = false; } } if (stateVeraltet(config, state)) void auffrischen("Start, state veraltet"); else void datenHolen(config).catch((e) => sagen(`Daten laden fehlgeschlagen: ${(e as Error).message}`)); setInterval(() => { if (stateVeraltet(config, state)) void auffrischen("Takt"); tokens = tokenLaden(); }, 15 * 60_000); // alt/-Aufloesungen auf Abruf, je Referenz nur einmal gleichzeitig const altLaeuft = new Map>(); async function altSicherstellen(ref: string): Promise<{ model: string; effort: Effort | null } | { fehler: string }> { const z = state.zuordnung[`alt/${ref}`]; if (z !== undefined) return { model: z.model, effort: z.effort }; let p = altLaeuft.get(ref); if (p === undefined) { p = alternativeAufloesen(config, state, ref, sagen).then(() => undefined).finally(() => altLaeuft.delete(ref)); altLaeuft.set(ref, p); } try { await p; } catch (e) { return { fehler: (e as Error).message }; } const neu = state.zuordnung[`alt/${ref}`]; return neu === undefined ? { fehler: `alt/${ref}: keine Zuordnung` } : { model: neu.model, effort: neu.effort }; } // --- Nebenlaeufigkeit je Upstream (lokales Qwen: ein Slot) ------------------ const semaphoren = new Map void)[] }>(); const SLOT_FRIST_MS = 30_000; /** * Belegt einen Slot des Upstreams und liefert die Freigabe zurueck. * * Zwei Haerten, beide aus 42i/intern#1283 (2026-09-07): Ein abgebrochener * Streaming-Aufruf gegen das lokale Qwen (maxParallel 1) hat den einzigen Slot * dauerhaft gefressen -- die Freigabe hing allein im flush() des * TransformStreams, und der laeuft bei cancel() nie. Danach reihte sich jeder * weitere Aufruf unbegrenzt in die Warteschlange ein, statt zu scheitern: * `auto/mass`, `role/ops` und das small_model aller Container-Personas hingen * zwei Stunden lang, ohne dass irgendwo etwas rot wurde. * * 1. Die Freigabe ist gegen Doppelaufruf gesichert. Sie darf jetzt an jeder * Stelle stehen, auch mehrfach und in einem finally -- ohne das Flag wuerde * ein zweiter Aufruf einen Slot erzeugen, den es nie gab. * 2. Wer laenger als SLOT_FRIST_MS wartet, bekommt eine Absage statt ewiger * Stille. Ein 503 ist eine Antwort, an der ein Aufrufer etwas aendern kann; * ein haengender Aufruf ist es nicht. */ async function belegen(name: string, u: Upstream): Promise<() => void> { if (u.maxParallel === undefined) return () => {}; let s = semaphoren.get(name); if (s === undefined) { s = { frei: u.maxParallel, warteschlange: [] }; semaphoren.set(name, s); } if (s.frei > 0) { s.frei -= 1; } else { let platz: (() => void) | undefined; const bekommen = await new Promise((res) => { platz = () => res(true); s!.warteschlange.push(platz); setTimeout(() => { const i = s!.warteschlange.indexOf(platz!); if (i >= 0) { s!.warteschlange.splice(i, 1); res(false); } }, SLOT_FRIST_MS); }); if (!bekommen) throw new SlotFrist(name); } let schon = false; return () => { if (schon) return; schon = true; const n = s!.warteschlange.shift(); if (n) n(); else s!.frei += 1; }; } /** Kein Slot innerhalb der Frist -- der Aufrufer bekommt 503 statt Stille. */ class SlotFrist extends Error { constructor(public readonly upstream: string) { super(`Upstream ${upstream} ist ausgelastet (kein Slot in ${SLOT_FRIST_MS / 1000} s)`); } } // --- Aufloesung Name -> Ziel --------------------------------------------------- type Ziel = { alias: string; upstreamName: string; upstream: Upstream; model: string; effort: Effort | null; body?: Record; effortSetzen: boolean; /** Alias, auf den bei Upstream-Ausfall einmal ausgewichen wird (nur "fest", s. config.ts). */ ausweich?: string }; type Fehler = { fehler: string; status: number }; const OR = (): Upstream => config.upstreams.openrouter; /** * Nackte Anthropic-IDs, wie Claude Code/Desktop sie schickt ("claude-sonnet-5", * "claude-opus-4-1-20250805", "claude-sonnet-4-5[1m]"), auf unseren Namensraum * abbilden: Datum und [1m] weg, Versionsziffern mit Punkt (OpenRouter-Schreibweise), * dann je config.anthropicModus als alt/anthropic/ (Klasse -> guenstigstes * Modell) oder openrouter/anthropic/ (1:1). Ein vom Client gesetzter Effort * (Anthropic output_config.effort) wird als Stufe angehaengt. */ function anthropicName(name: string, effort: Effort | null): string | undefined { if (!/^claude-/u.test(name)) return undefined; let id = name.replace(/\[1m\]$/u, "").replace(/-\d{8}$/u, "").replace(/-latest$/u, ""); id = id.replace(/^(claude-[a-z]+)-(\d+(?:-\d+)*)$/u, (_, fam: string, ver: string) => `${fam}-${ver.replace(/-/gu, ".")}`); const modus = config.anthropicModus ?? "alt"; return `${modus}/anthropic/${id}${effort !== null ? `:${effort}` : ""}`; } /** Bedient der Router diesen Namen selbst? Sonst Durchreiche -- und dann prueft LiteLLM die Rechte, nicht wir. */ function eigenerName(name: string): boolean { return name.startsWith("role/") || name.startsWith("openrouter/") || name.startsWith("alt/") || /^claude-/u.test(name) || config.aliases[name] !== undefined; } async function zielBestimmen(angefragtRoh: string, keyName: string, clientEffort: Effort | null = null): Promise { // Altnamen zuerst: eine laufende Sitzung traegt ihren Modellnamen bis zum // Ende mit. Verschwindet er waehrend des Laufs aus der Config, stirbt der // Lauf beim naechsten Zug an "Unbekanntes Modell" -- am 2026-09-07 hat genau // das fuenf Teamlaeufe gekostet (42i/intern#1251). Das Feld war deklariert, // aber nie angewendet; die Uebersetzung passiert VOR ACL und Aufloesung, // damit der alte Name sich in jeder Hinsicht wie der neue verhaelt. const ausAltname = config.altnamen?.[angefragtRoh]; const roh = ausAltname ?? angefragtRoh; const angefragt = anthropicName(roh, clientEffort) ?? roh; if (!eigenerName(angefragt)) { if (config.fallback !== undefined) { const u = config.fallback.upstream; return { alias: "(durchreiche)", upstreamName: u, upstream: config.upstreams[u], model: angefragt, effort: null, effortSetzen: false }; } return { fehler: `Unbekanntes Modell ${angefragt}`, status: 404 }; } if (!erlaubt(acl.get(keyName) ?? [], angefragt)) return { fehler: `${keyName} darf ${angefragt} nicht rufen`, status: 403 }; let name = angefragt; if (name.startsWith("role/")) { const ziel = config.rollen?.[name.slice(5)]; if (ziel === undefined) return { fehler: `Unbekannte Rolle ${name}`, status: 404 }; name = ziel; } const def = config.aliases[name]; if (def !== undefined) { if (def.art === "fest") return { alias: name, upstreamName: def.upstream, upstream: config.upstreams[def.upstream], model: def.model, effort: null, body: def.body, effortSetzen: false, ausweich: def.ausweich }; const z = state.zuordnung[name]; if (z === undefined) return { fehler: `${name} ist noch nicht aufgeloest (Zuordnung fehlt) -- spaeter erneut`, status: 503 }; return { alias: name, upstreamName: "openrouter", upstream: OR(), model: z.model, effort: z.effort, effortSetzen: true }; } if (name.startsWith("openrouter/")) { const { id, effort } = referenzParsen(name.slice(11)); return { alias: name, upstreamName: "openrouter", upstream: OR(), model: id, effort, effortSetzen: effort !== null }; } if (name.startsWith("alt/")) { const z = await altSicherstellen(name.slice(4)); if ("fehler" in z) return { fehler: z.fehler, status: 404 }; return { alias: name, upstreamName: "openrouter", upstream: OR(), model: z.model, effort: z.effort, effortSetzen: true }; } return { fehler: `Unbekanntes Modell ${angefragt}`, status: 404 }; // z. B. Rolle zeigt auf einen Namen, den es nicht gibt } // --- Anfrage bauen ------------------------------------------------------------- // /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, ziel: Ziel, pfad: string): Record { const b: Record = { ...orig, model: ziel.model, ...(ziel.body ?? {}) }; // Sprechtext vor der Synthese normalisieren (Postleitzahlen als Ziffernfolge mit Pause), // fuer jedes TTS-Ziel gleich -- die Modelle lesen Betraege und Daten, PLZ nicht. if (pfad === "/audio/speech" && typeof b.input === "string") b.input = sprechtextNormalisieren(b.input); // OpenRouter-TTS kennt nur mp3 und pcm. Clients wie Inkas Telefon-Front-End // verlangen WAV -- wir holen PCM und setzen den RIFF-Kopf selbst (s. unten). if (pfad === "/audio/speech" && ziel.upstreamName === "openrouter" && b.response_format === "wav") b.response_format = "pcm"; if (pfad.startsWith("/messages")) { // Anthropic-Format: kein usage.include, Effort als reasoning.effort (OpenRouter nimmt es dort an) if (ziel.effortSetzen) { delete b.thinking; delete b.output_config; 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 }; } if (ziel.effortSetzen) { // Die Stufe gehoert zum Namen, nicht zum Client: wer 42i/marvin-2609:low // oder openrouter/x:medium ruft, bekommt genau diese Stufe. delete b.reasoning_effort; delete b.reasoning; if (ziel.effort !== null) b.reasoning = { effort: ziel.effort }; } return b; } // --- Modellliste --------------------------------------------------------------- async function modellListe(keyName: string, auth: string): Promise<{ id: string; object: "model"; owned_by: string }[]> { const liste = acl.get(keyName) ?? []; const data: { id: string; object: "model"; owned_by: string }[] = []; const add = (id: string, owned_by: string) => { if (erlaubt(liste, id)) data.push({ id, object: "model", owned_by }); }; for (const id of Object.keys(config.aliases)) add(id, "42i"); for (const r of Object.keys(config.rollen ?? {})) add(`role/${r}`, "42i"); try { const d = await datenHolen(config); const stufen = new Map(); for (const v of d.varianten) if (v.effort !== null) stufen.set(v.id, [...(stufen.get(v.id) ?? []), v.effort]); const gemessen = new Set(d.varianten.map((v) => v.id)); for (const m of d.konto) { if (m.id.includes(":")) { add(`openrouter/${m.id}`, "openrouter"); continue; } // :free, :exacto -- keine Stufen add(`openrouter/${m.id}`, "openrouter"); // alt/ nur, wo eine Klasse bestimmbar ist (Index bei OpenRouter oder Stufen bei AA) -- sonst wuerde die Anfrage scheitern const k = d.alle.get(m.id); if ((k !== undefined && Number.isFinite(k.index)) || gemessen.has(m.id)) add(`alt/${m.id}`, "alt"); for (const e of stufen.get(m.id) ?? []) { add(`openrouter/${m.id}:${e}`, "openrouter"); add(`alt/${m.id}:${e}`, "alt"); } } } catch (e) { sagen(`/models ohne OpenRouter-Liste: ${(e as Error).message}`); } if (config.fallback !== undefined) { // Im Parallelbetrieb auch die LiteLLM-Liste des Clients, sonst verlieren // Clients, die Modelle per /models entdecken, die 2608-Namen. try { const u = config.upstreams[config.fallback.upstream]; const r = await fetch(`${u.base}/models`, { headers: { authorization: `Bearer ${auth}` }, signal: AbortSignal.timeout(10_000) }); const bekannt = new Set(data.map((m) => m.id)); if (r.ok) for (const m of ((await r.json()) as { data?: { id: string }[] }).data ?? []) if (!bekannt.has(m.id)) data.push({ id: m.id, object: "model", owned_by: config.fallback.upstream }); } catch { /* LiteLLM nicht da: nur eigene Liste */ } } return data; } /** Setzt einen RIFF/WAVE-Kopf (PCM 16 bit) vor rohe s16le-Daten. */ function wavVerpacken(pcm: Uint8Array, rate: number, kanaele: number): Uint8Array { const kopf = new ArrayBuffer(44); const v = new DataView(kopf); const str = (o: number, t: string) => { for (let i = 0; i < t.length; i++) v.setUint8(o + i, t.charCodeAt(i)); }; str(0, "RIFF"); v.setUint32(4, 36 + pcm.byteLength, true); str(8, "WAVE"); str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, kanaele, true); v.setUint32(24, rate, true); v.setUint32(28, rate * kanaele * 2, true); v.setUint16(32, kanaele * 2, true); v.setUint16(34, 16, true); str(36, "data"); v.setUint32(40, pcm.byteLength, true); const aus = new Uint8Array(44 + pcm.byteLength); aus.set(new Uint8Array(kopf), 0); aus.set(pcm, 44); return aus; } // --- Server ------------------------------------------------------------------- Bun.serve({ port: Number(process.env.PORT ?? config.port), idleTimeout: 255, async fetch(req) { const url = new URL(req.url); const pfad = url.pathname.replace(/^\/v1/u, ""); if (pfad === "/health") return Response.json({ ok: true, zuordnung: state.aktualisiert }); if (url.pathname === "/api/hello") return new Response(null, { status: 200 }); // Claude Code pingt das beim Start // Admin const auth = (req.headers.get("authorization")?.replace(/^Bearer\s+/iu, "") ?? req.headers.get("x-api-key") ?? "").trim(); const admin = process.env.ADMIN_KEY !== undefined && auth === process.env.ADMIN_KEY; if (pfad === "/stats") return admin ? Response.json(log.statistik(Number(url.searchParams.get("days") ?? 7))) : new Response("admin", { status: 401 }); if (pfad === "/zuordnung") return admin ? Response.json(state) : new Response("admin", { status: 401 }); if (pfad === "/auffrischen" && req.method === "POST") { if (!admin) return new Response("admin", { status: 401 }); void auffrischen("manuell"); return Response.json({ gestartet: !laeuft }); } // Client const keyName = tokens.get(auth); if (keyName === undefined) { sagen(`401 ${req.method} ${pfad} (Key ${auth.length} Zeichen, ${auth.slice(0, 6)}…)`); return Response.json({ error: { message: "Ungueltiger API-Key" } }, { status: 401 }); } if (pfad === "/models") return Response.json({ object: "list", data: await modellListe(keyName, auth) }); if (req.method !== "POST" || !(JSON_PFADE.has(pfad) || FORM_PFADE.has(pfad))) return Response.json({ error: { message: `Pfad ${pfad} wird nicht bedient` } }, { status: 404 }); const start = Date.now(); let angefragt = ""; let upstreamBody: BodyInit; let stream = false; let wavGewuenscht = false; let ziel: Ziel; const headers = new Headers(); let origJson: Record | null = null; // fuer den Ausweich: Body muss mit neuem Modellnamen neu gebaut werden if (JSON_PFADE.has(pfad)) { let orig: Record; try { orig = (await req.json()) as Record; } catch { return Response.json({ error: { message: "Body ist kein JSON" } }, { status: 400 }); } angefragt = String(orig.model ?? ""); const oc = orig.output_config as { effort?: string } | undefined; const ce = String(oc?.effort ?? (orig.reasoning as { effort?: string } | undefined)?.effort ?? orig.reasoning_effort ?? ""); const z = await zielBestimmen(angefragt, keyName, (EFFORTS as readonly string[]).includes(ce) ? (ce as Effort) : null); if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status }); ziel = z; stream = orig.stream === true; wavGewuenscht = pfad === "/audio/speech" && orig.response_format === "wav"; origJson = orig; upstreamBody = JSON.stringify(ziel.upstream.clientKey === true ? orig : jsonBody(orig, ziel, pfad)); headers.set("content-type", "application/json"); } else { const form = await req.formData(); angefragt = String(form.get("model") ?? ""); const z = await zielBestimmen(angefragt, keyName); if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status }); ziel = z; if (ziel.upstream.clientKey !== true) form.set("model", ziel.model); upstreamBody = form; } 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); const eintrag: Eintrag = { ts: jetzt(), key: keyName, alias: ziel.alias, angefragt, model: ziel.model, upstream: ziel.upstreamName, effort: ziel.effort, pfad, status: 0, ms: 0, stream, prompt_tokens: null, completion_tokens: null, cached_tokens: null, cost: null, }; const abschliessen = (usage?: unknown) => { eintrag.ms = Date.now() - start; if (usage !== undefined) Object.assign(eintrag, usageLesen(usage)); try { log.schreiben(eintrag); } catch (e) { sagen(`Log-Fehler: ${(e as Error).message}`); } }; // Kein Slot in der Frist -> 503 statt Stille (42i/intern#1283). Der // Aufrufer sieht, dass der Upstream ausgelastet ist, und kann es spaeter // erneut versuchen; vorher hing er unbegrenzt. let freigeben: () => void; try { freigeben = await belegen(ziel.upstreamName, ziel.upstream); } catch (e) { if (e instanceof SlotFrist) { eintrag.status = 503; eintrag.fehler = e.message; abschliessen(); sagen(`503 ${eintrag.key} ${ziel.alias}: ${e.message}`); return Response.json({ error: { message: e.message, code: 503 } }, { status: 503 }); } throw e; } let antwort: Response; try { antwort = await fetch(`${ziel.upstream.base}${pfad}`, { method: "POST", headers, body: upstreamBody, signal: AbortSignal.timeout(600_000) }); } catch (e) { freigeben(); const grund = (e as Error).message; // Ausweich (42i/intern#1252): ein "festes" Ziel hat keinen Rueckfall -- // steht der lokale LLM-Knoten oder haelt eine fremde Arbeit die Karte, // bekaeme der Aufrufer gar keine Antwort statt einer langsameren. Genau // das trifft die Massenarbeit (auto/mass -> lokales Qwen), die ueberall // als small_model haengt. Deshalb EINMAL auf den konfigurierten Alias // ausweichen; scheitert auch der, bleibt es beim 502. const ausweichName = ziel.ausweich; let gerettet = false; if (ausweichName !== undefined && origJson !== null) { const z2 = await zielBestimmen(ausweichName, keyName); if (!("fehler" in z2)) { sagen(`Ausweich ${ziel.alias} -> ${ausweichName} (${ziel.upstreamName} nicht erreichbar: ${grund})`); const h2 = new Headers(headers); const k2 = upstreamKey(z2.upstream); if (k2 !== undefined) h2.set("authorization", `Bearer ${k2}`); if (z2.upstreamName === "openrouter") { h2.set("http-referer", "https://llm.lan"); h2.set("x-title", `llmrouter/${keyName}`); } const b2 = JSON.stringify(jsonBody(origJson, z2, pfad)); freigeben = await belegen(z2.upstreamName, z2.upstream); try { antwort = await fetch(`${z2.upstream.base}${pfad}`, { method: "POST", headers: h2, body: b2, signal: AbortSignal.timeout(600_000) }); eintrag.alias = `${ziel.alias}->${ausweichName}`; eintrag.model = z2.model; eintrag.upstream = z2.upstreamName; eintrag.effort = z2.effort; ziel = z2; gerettet = true; } catch (e2) { freigeben(); eintrag.fehler = `${grund} / Ausweich: ${(e2 as Error).message}`; } } } if (!gerettet) { eintrag.status = 502; eintrag.fehler = eintrag.fehler ?? grund; abschliessen(); return Response.json({ error: { message: `Upstream ${ziel.upstreamName} nicht erreichbar: ${eintrag.fehler}` } }, { status: 502 }); } } eintrag.status = antwort.status; const ct = antwort.headers.get("content-type") ?? ""; const weiter = new Headers(antwort.headers); weiter.delete("content-length"); weiter.delete("content-encoding"); weiter.delete("transfer-encoding"); weiter.set("x-llmrouter-model", referenzFormat(ziel.model, ziel.effort)); if (ct.startsWith("text/event-stream") && antwort.body !== null) { // Durchreichen und nebenbei den letzten usage-Chunk lesen. let rest = ""; let usage: unknown; const dec = new TextDecoder(); const tee = new TransformStream({ transform(chunk, ctrl) { ctrl.enqueue(chunk); rest += dec.decode(chunk, { stream: true }); let i: number; 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)); 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 */ } } } }, flush() { freigeben(); abschliessen(usage); }, // Bricht der Client ab, laeuft flush() NIE -- ohne diese Zeile bleibt // der Slot fuer immer belegt (42i/intern#1283). Die Freigabe ist gegen // Doppelaufruf gesichert, beide Wege duerfen also feuern. cancel() { freigeben(); abschliessen(usage); }, }); return new Response(antwort.body.pipeThrough(tee), { status: antwort.status, headers: weiter }); } freigeben(); if (ct.startsWith("audio/pcm") && wavGewuenscht) { // PCM s16le -> WAV; Rate und Kanaele stehen im Content-Type (audio/pcm;rate=24000;channels=1) const rate = Number(/rate=(\d+)/u.exec(ct)?.[1] ?? 24000); const kanaele = Number(/channels=(\d+)/u.exec(ct)?.[1] ?? 1); const pcm = new Uint8Array(await antwort.arrayBuffer()); const wav = wavVerpacken(pcm, rate, kanaele); weiter.set("content-type", "audio/wav"); weiter.set("content-length", String(wav.byteLength)); abschliessen(); return new Response(wav, { status: antwort.status, headers: weiter }); } if (ct.includes("application/json")) { const text = await antwort.text(); let usage: unknown; let fehler: string | undefined; try { const j = JSON.parse(text); usage = j.usage; if (j.error) fehler = JSON.stringify(j.error).slice(0, 300); } catch { /* egal */ } if (fehler) eintrag.fehler = fehler; abschliessen(usage); return new Response(text, { status: antwort.status, headers: weiter }); } abschliessen(); return new Response(antwort.body, { status: antwort.status, headers: weiter }); }, }); sagen(`llmrouter lauscht auf :${process.env.PORT ?? config.port}, ${Object.keys(config.aliases).length} Aliasse, ${Object.keys(config.rollen ?? {}).length} Rollen, ${tokens.size} Token, Zuordnung von ${state.aktualisiert}`);