From 91e68d9ea936fdba0a9950ba0d2c0acc05cf1852 Mon Sep 17 00:00:00 2001 From: Elton Turing Date: Sat, 5 Sep 2026 12:42:47 +0200 Subject: [PATCH] =?UTF-8?q?llmrouter:=20Claude=20Code/Desktop=20=E2=80=94?= =?UTF-8?q?=20nackte=20claude-IDs=20->=20alt/anthropic/[:effort]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++-- config.json | 4 +++- src/config.ts | 2 ++ src/server.ts | 44 ++++++++++++++++++++++++++++--------- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 483dd74..58170f5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ reicht Durchreichen. Entscheidung Andreas, 2026-09-05. | `openrouter/[:effort]` | 1:1 an OpenRouter, Effort als `reasoning.effort` | | `alt/[:effort]` | **günstigste Alternative derselben Klasse** wie `` auf dieser Stufe | | `role/` | Rolle aus `rollen`, zeigt auf einen der obigen Namen | +| `claude-*` (nackte Anthropic-ID) | Claude Code/Desktop: wird zu `alt/anthropic/[: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 diff --git a/config.json b/config.json index 41e57c4..ff356aa 100644 --- a/config.json +++ b/config.json @@ -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/[:effort] = 1:1 an OpenRouter, Effort als reasoning.effort; alt/[: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/ = Rolle (rollen). Alles andere -> fallback (LiteLLM)." + "_namen_doku": "Namensraeume: 42i/* = konfigurierte Aliasse (aliases); deizo/* = Modelle im Prod-RZ deizo (kira, fest); openrouter/[:effort] = 1:1 an OpenRouter, Effort als reasoning.effort; alt/[: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/ = 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/ (Standard) oder openrouter/anthropic/ ab; ein Client-Effort (output_config.effort) wird als Stufe angehaengt. Rechte: die ACL prueft den abgebildeten Namen, also alt/* bzw. openrouter/*." } diff --git a/src/config.ts b/src/config.ts index 7812c22..ccf35ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,8 @@ export type Config = { altnamen?: Record; /** 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/ -> Zielname (42i/*, openrouter/*, alt/*). Agenten kennen nur Rollen; Modelle stellt man hier um. */ rollen?: Record; }; diff --git a/src/server.ts b/src/server.ts index 2bd880b..65705a6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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/ (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}` : ""}`; } -async function zielBestimmen(angefragt: string, keyName: string): Promise { +/** 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 { + 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, ziel: Ziel, pfad: string): Reco const b: Record = { ...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(); 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; try { orig = (await req.json()) as Record; } 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;