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
+59 -2
View File
@@ -20,6 +20,7 @@ reicht Durchreichen. Entscheidung Andreas, 2026-09-05.
| `openrouter/<id>[:effort]` | 1:1 an OpenRouter, Effort als `reasoning.effort` |
| `alt/<id>[:effort]` | **günstigste Alternative derselben Klasse** wie `<id>` auf dieser Stufe |
| `role/<name>` | Rolle aus `rollen`, zeigt auf einen der obigen Namen |
| `claude-*` (nackte Anthropic-ID) | Claude Code/Desktop: wird zu `alt/anthropic/<id>[:effort]` (siehe unten) |
| alles andere | Durchreiche an LiteLLM (`fallback`), unverändert mit Client-Key |
**Klasse** (`alt/`): Intelligence-, Coding- und Agentic-Index jeweils mindestens
@@ -45,8 +46,64 @@ Präfix-Muster (`openrouter/*`, `alt/*`, `role/*`); `[]` = alles. Geprüft wird
der angefragte Name, vor der Rollenauflösung.
`/v1/models` zeigt je Key alles Erlaubte: Aliasse, Rollen, alle OpenRouter-
Modelle des Kontos als `openrouter/…` und `alt/…`, jeweils auch mit den bei AA
gemessenen Effort-Stufen, plus die LiteLLM-Liste im Parallelbetrieb.
Modelle des Kontos als `openrouter/…`, als `alt/…` nur die mit bestimmbarer
Klasse (Index bei OpenRouter oder Stufen bei AA), jeweils mit den gemessenen
Effort-Stufen, plus die LiteLLM-Liste im Parallelbetrieb.
## Claude Code und Claude Desktop
Claude Code spricht Anthropics Messages-Format (`/v1/messages`,
`/v1/messages/count_tokens`). Der Router reicht beide Pfade an OpenRouters
Messages-Endpunkt durch, der dasselbe Format für alle Modelle bietet, mit
`x-api-key`, `anthropic-version` und `anthropic-beta` des Clients. Das ist die
einzige Format-Übersetzung, die wir nicht selbst schreiben. Nur für
OpenRouter-Ziele; das lokale Qwen spricht kein Messages-Format und antwortet
mit 400. `HEAD /api/hello`, Claude Codes Start-Ping, bekommt 200.
**Modellnamen.** Claude Code schickt nackte Anthropic-IDs (`claude-sonnet-5`,
`claude-opus-5-20260723`, `claude-haiku-4-5-20251001`, `claude-sonnet-4-5[1m]`).
Der Router normalisiert sie (Datum, `-latest`, `[1m]` weg; Versionsziffern mit
Punkt wie bei OpenRouter) und bildet sie je `anthropicModus` ab:
- `alt` (Standard): `claude-opus-5``alt/anthropic/claude-opus-5` — die Wahl
in Claude bestimmt die **Klasse**, der Router sucht das günstigste Modell dazu.
- `openrouter`: `openrouter/anthropic/claude-opus-5` — 1:1 das echte Modell.
Die Rechte prüfen den abgebildeten Namen; ein Key braucht also `alt/*` bzw.
`openrouter/*`. Eigene Namen gehen weiter per `/model alt/x-ai/grok-4.6:high`
oder `/model 42i/marvin-2609:high`.
**Effort.** Claude Code schickt seine Effort-Einstellung als
`output_config.effort` mit (`/effort` bzw. Settings). Der Router hängt sie als
Stufe an: `claude-opus-5` + high → `alt/anthropic/claude-opus-5:high`. Hat AA
die Stufe für das Referenzmodell nicht gemessen (Sonnet 5 hat nur max und
none), vertritt die nächste gemessene Stufe darunter die Referenz, sonst die
nächste darüber; ohne jede Stufe die Modellzahl. Am Ziel wird die Stufe des
**Ziels** gesetzt, `thinking` und `output_config` des Clients werden verworfen.
**Anschluss.** Die Base-URL muss aus der **Prozessumgebung** kommen; der
`env`-Block einer `settings.json` greift dafür nicht (Claude Code 2.1.220 liest
`ANTHROPIC_BASE_URL` vor den Settings). Der Key kann per `apiKeyHelper` aus
OpenBao kommen (der Router trimmt den Zeilenumbruch):
```bash
ANTHROPIC_BASE_URL=http://llm.lan:4010 claude --model claude-sonnet-5
# settings.json: "apiKeyHelper": "/Users/andreas/.agents/bin/secret get elton litellm key"
```
Für Claude Desktop (GUI erbt keine Shell-Umgebung): `launchctl setenv
ANTHROPIC_BASE_URL http://llm.lan:4010` vor dem App-Start; wirkt dann auf alle
Sessions. Testprojekt mit README: `~/src/lab/llmrouter-claude`.
Verifiziert 2026-09-05: `claude -p --model claude-opus-5``alt/anthropic/claude-opus-5:high`
`openai/gpt-5.6-sol:xhigh`; `claude-sonnet-5``:high` → Rückfall auf max → `z-ai/glm-5.3-flash`;
`claude-haiku-4-5-20251001``inclusionai/ling-3.0-flash`. Kosten kommen auch über den
Messages-Endpunkt von OpenRouter mit.
**opencode** braucht nichts davon: `~/.agents/bin/opencode-normalize` erzeugt den
`llmlan`-Provider aus `/v1/models` des Routers, Stufen als `variants`; die Stufe
steckt im Modellnamen. `openrouter/` und `alt/` nur für die Anbieter in `ANBIETER`
und nur, wo der Router ein `alt/` anbietet (Index bekannt).
## Auflösung und Takt
+3 -1
View File
@@ -152,5 +152,7 @@
"ar": "42i/marvin-2609:medium",
"archivist": "42i/marvin-2609:medium"
},
"_namen_doku": "Namensraeume: 42i/* = konfigurierte Aliasse (aliases); deizo/* = Modelle im Prod-RZ deizo (kira, fest); openrouter/<id>[:effort] = 1:1 an OpenRouter, Effort als reasoning.effort; alt/<id>[:effort] = guenstigste Alternative derselben Klasse (Intelligence, Coding, Agentic je >= 95 % der Referenz, Kontext >= 90 %, billiger), aufgeloest bei der ersten Anfrage und im 12h-Takt erneuert, OHNE Last-Probe; role/<name> = Rolle (rollen). Alles andere -> fallback (LiteLLM)."
"_namen_doku": "Namensraeume: 42i/* = konfigurierte Aliasse (aliases); deizo/* = Modelle im Prod-RZ deizo (kira, fest); openrouter/<id>[:effort] = 1:1 an OpenRouter, Effort als reasoning.effort; alt/<id>[:effort] = guenstigste Alternative derselben Klasse (Intelligence, Coding, Agentic je >= 95 % der Referenz, Kontext >= 90 %, billiger), aufgeloest bei der ersten Anfrage und im 12h-Takt erneuert, OHNE Last-Probe; role/<name> = Rolle (rollen). Alles andere -> fallback (LiteLLM).",
"anthropicModus": "alt",
"_anthropic_doku": "Claude Code/Desktop schicken nackte IDs (claude-sonnet-5, claude-opus-5-20260723, claude-sonnet-4-5[1m]). Der Router normalisiert sie und bildet sie je anthropicModus auf alt/anthropic/<id> (Standard) oder openrouter/anthropic/<id> ab; ein Client-Effort (output_config.effort) wird als Stufe angehaengt. Rechte: die ACL prueft den abgebildeten Namen, also alt/* bzw. openrouter/*."
}
+2
View File
@@ -26,6 +26,8 @@ export type Config = {
altnamen?: Record<string, string>;
/** Unbekannte Modellnamen unveraendert an diesen Upstream (Parallelbetrieb mit LiteLLM). */
fallback?: { upstream: string };
/** Nackte Anthropic-IDs (Claude Code/Desktop): "alt" = guenstigste Alternative der Klasse, "openrouter" = 1:1. */
anthropicModus?: "alt" | "openrouter";
/** role/<name> -> Zielname (42i/*, openrouter/*, alt/*). Agenten kennen nur Rollen; Modelle stellt man hier um. */
rollen?: Record<string, string>;
};
+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;