llmrouter: Claude Code/Desktop — nackte claude-IDs -> alt/anthropic/<id>[:effort]

- anthropicModus (alt|openrouter), Normalisierung der IDs (Datum, [1m], Punkte),
  Client-Effort aus output_config.effort als Stufe, HEAD /api/hello -> 200
- Auswahl: nicht gemessene Stufe faellt auf die naechste gemessene darunter
  (sonst darueber, sonst Modellzahl) -- Sonnet 5 hat nur max/none
- /models: alt/ nur bei bestimmbarer Klasse; opencode-normalize nimmt
  openrouter/+alt/ genau dann (GLM 5.3 Flash war weggefallen)
- README: Abschnitt Claude Code und Claude Desktop
Verifiziert per claude -p mit claude-sonnet-5, claude-opus-5, claude-haiku-4-5-20251001.

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 0d971b81dc
commit 91e68d9ea9
4 changed files with 98 additions and 13 deletions
+34 -10
View File
@@ -17,7 +17,7 @@
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 { referenzFormat, referenzParsen, type Effort } from "../../llmlite/openrouter-auswahl.ts";
import { referenzFormat, referenzParsen, EFFORTS, type Effort } from "../../llmlite/openrouter-auswahl.ts";
const config: Config = configLaden();
const acl = aclLaden();
@@ -76,12 +76,29 @@ type Ziel = { alias: string; upstreamName: string; upstream: Upstream; model: st
type Fehler = { fehler: string; status: number };
const OR = (): Upstream => config.upstreams.openrouter;
/** 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/") || config.aliases[name] !== undefined;
/**
* 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/<id> (Klasse -> guenstigstes
* Modell) oder openrouter/anthropic/<id> (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}` : ""}`;
}
async function zielBestimmen(angefragt: string, keyName: string): Promise<Ziel | Fehler> {
/** 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<Ziel | Fehler> {
const angefragt = anthropicName(angefragtRoh, clientEffort) ?? angefragtRoh;
if (!eigenerName(angefragt)) {
if (config.fallback !== undefined) {
const u = config.fallback.upstream;
@@ -126,7 +143,7 @@ function jsonBody(orig: Record<string, unknown>, ziel: Ziel, pfad: string): Reco
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 }; }
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") {
@@ -153,9 +170,13 @@ async function modellListe(keyName: string, auth: string): Promise<{ id: string;
const d = await datenHolen(config);
const stufen = new Map<string, Effort[]>();
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"); add(`alt/${m.id}`, "alt");
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}`); }
@@ -180,9 +201,10 @@ Bun.serve({
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") ?? "";
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 });
@@ -190,7 +212,7 @@ Bun.serve({
// Client
const keyName = tokens.get(auth);
if (keyName === undefined) return Response.json({ error: { message: "Ungueltiger API-Key" } }, { status: 401 });
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 });
@@ -205,7 +227,9 @@ Bun.serve({
let orig: Record<string, unknown>;
try { orig = (await req.json()) as Record<string, unknown>; } catch { return Response.json({ error: { message: "Body ist kein JSON" } }, { status: 400 }); }
angefragt = String(orig.model ?? "");
const z = await zielBestimmen(angefragt, keyName);
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;