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:
@@ -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
@@ -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/*."
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user