From 7fea5c82c4f84750ca91625085dd0a438b4438db Mon Sep 17 00:00:00 2001 From: Elton Turing Date: Mon, 7 Sep 2026 13:20:06 +0200 Subject: [PATCH] Router: Namensraum auto/ mit acht Profilen auf drei Achsen (42i/intern#1252) Der Preisfinder waehlte bisher ueber EINE Zahl. Ein Modell kann generalistisch gut aussehen und agentisch durchfallen -- gemini-3.8-flash hat Coding 76,3 und Agentic 41,2 und traegt den Werkzeug-Rundlauf nicht. - openrouter-auswahl: billigsterUeberSchwelle reicht minCoding/minAgentic an klasseFiltern durch (nahm es bereits entgegen, wurde nur nie gefuettert) - config: art "schwelle" hat jetzt index, coding und agentic, jede optional; "fest" kennt ausweich - resolver: Beschreibung nennt die geforderten Achsen - server: ein festes Ziel weicht bei Upstream-Ausfall EINMAL auf den konfigurierten Alias aus. Ohne das haengt mit dem lokalen Knoten jede Titelzeile jedes Agenten, weil auto/mass ueberall als small_model haengt. - acht Profile, Rollen darauf umgehaengt, 42i/marvin-2609:* bleiben als Namen Aufgeloest (ohne Last-Probe, alle Ziele laufen bereits ueber bestehende Aliasse): coding:junior und leader:junior -> glm-5.3-flash, coding:senior -> grok-4.6:high, leader:senior -> glm-5.3:max (15,66 statt 26 gewichtet -- die Regel findet etwas Billigeres als die Schaetzung im Ticket), mass:fast -> ling. Co-Authored-By: Claude Opus 5 --- config.json | 100 ++++++++++++++++++++++++++++++++++++++---------- key-acls.json | 6 ++- src/config.ts | 9 ++++- src/resolver.ts | 10 ++++- src/server.ts | 44 ++++++++++++++++++--- 5 files changed, 137 insertions(+), 32 deletions(-) diff --git a/config.json b/config.json index 6c79846..444babd 100644 --- a/config.json +++ b/config.json @@ -150,32 +150,90 @@ } }, "_doku": "Test-Hirn fuer Inka (2026-09-06): Qwen 3.8 27B OHNE Denkstufe und mit festen Providern (fp8, Cache-Treffer $0,05/1M, Uptime >99,6 %), damit der Prompt-Cache ueber die Zuege traegt. Erster Qwen-Anruf ohne das: 5,2 s je Zug, 700 Reasoning-Token, Cache in 3 von 8 Zuegen." + }, + "auto/mass": { + "art": "fest", + "upstream": "qwen", + "model": "qwen36:35b", + "body": { + "chat_template_kwargs": { + "enable_thinking": true + } + }, + "ausweich": "auto/mass:fast", + "_doku": "Massenarbeit ohne Latenzanspruch: Mail-Ingest, Triage, Zusammenfassungen, opencodes small_model. Laeuft auf der eigenen Karte und kostet nichts. Steht der Knoten oder ist die Karte belegt, weicht der Router einmal auf auto/mass:fast aus -- ohne das haengt sonst jede Titelzeile jedes Agenten (42i/intern#1252)." + }, + "auto/mass:fast": { + "art": "schwelle", + "index": 25, + "minContext": 256000, + "_doku": "Wie auto/mass, aber wenn es schnell gehen muss: billigstes Cloud-Modell ueber der Mindestschwelle. Andreas 2026-09-06: 'bei masse ist halt: brauchst du es schnell, nimm ling, ist geschwindigkeit nicht wichtig, nimm core'." + }, + "auto/allround:junior": { + "art": "schwelle", + "index": 40, + "minContext": 256000, + "_doku": "Standard fuer alles ohne besondere Achse: Text, Service, Assistenz, Doku." + }, + "auto/allround:senior": { + "art": "schwelle", + "index": 49, + "minContext": 256000, + "_doku": "Wie allround, aber wenn der generalistische Index zaehlt -- begutachten und entwerfen statt Werkzeugschleife." + }, + "auto/coding:junior": { + "art": "schwelle", + "coding": 68, + "agentic": 45, + "minContext": 256000, + "_doku": "Code schreiben und pruefen. Agentic als Untergrenze, weil der Werkzeug-Rundlauf (lesen, patchen, testen, Fehler deuten) sonst nicht traegt." + }, + "auto/coding:senior": { + "art": "schwelle", + "coding": 76, + "agentic": 50, + "minContext": 256000, + "_doku": "Schwerere Code-Aufgaben. Ausdruecklich anfordern (/model auto/coding:senior); keine Rolle zeigt von sich aus hierher." + }, + "auto/leader:junior": { + "art": "schwelle", + "index": 45, + "agentic": 50, + "minContext": 256000, + "_doku": "Fuehren und koordinieren: lange Schleifen ueber fremde Tickets, Subagenten ansetzen, fremde Fehler erkennen. Latte 50 (Andreas 2026-09-06): tiefer zu gehen laesst nur Schlechteres zu, nichts Billigeres." + }, + "auto/leader:senior": { + "art": "schwelle", + "coding": 74, + "agentic": 53, + "minContext": 256000, + "_doku": "Die teure Stufe. Latte 53 statt 55 (Andreas 2026-09-06): damit erfuellt grok-4.6 das Profil, Opus 5 bleibt erreichbar, wird aber nicht mehr automatisch gewaehlt. Stand 2026-09-07 zeigt KEINE Rolle hierher." } }, "_stufen_doku": "Stufen als Index-Schwelle (Artificial-Analysis-Intelligence-Index, 0-100) mit Mindestkontext (Andreas 2026-09-05): low 25/256k, medium 45/256k, high 49/500k, max 53/500k. Die Art \"alternative\" mit Referenzmodell (z.B. anthropic/claude-opus-5:medium) bleibt möglich. Namen versioniert wie bei LiteLLM (2608): 42i/marvin-2609:*.", "_fallback_entfernt": "Bis 2026-09-07 gingen unbekannte Modellnamen (die 2608-Reihe) an LiteLLM durch. Mit der Abschaltung von LiteLLM ist der Weg weg: unbekannte Namen sind jetzt ein Fehler statt einer stillen Weiterleitung. Das ist Absicht -- eine Weiterleitung an einen toten Dienst waere ein Timeout ohne Hinweis, und seit Caddy auf :4000 auf den Router zeigt, waere es sogar eine Schleife auf sich selbst.", - "_rollen_doku": "Rollen entkoppeln Agenten von Modellnamen: ein Agent ruft role/worker, welches Modell dahintersteht, stellt man HIER um -- aus der Ferne, ohne Container-Deploy. Ziele: 42i/*-Alias, openrouter/[:effort] (1:1) oder alt/[:effort] (guenstigste Alternative). Vorbelegung 2026-09-05 (Elton): Fuehrungsrollen high, Rest medium. Feinjustierung Andreas 2026-09-07: teamleader, lead UND xo auf medium (= leader:junior nach 42i/intern#1252). Der xo kam am selben Tag von Claude Code/Max-Abo auf opencode ueber den Router -- ausdruecklich als Versuch, hochstufen ist eine Zeile hier. Damit steht KEINE Rolle mehr auf high: architect folgte am selben Tag (Andreas). Alle 21 Rollen liegen auf der guenstigen Stufe; hochgestuft wird einzeln und mit Anlass. role/inka (2026-09-06): Inkas Telefon-Hirn = Luna (Cloud-Optimum: 1,8 s/Zug, Cache greift). Getestet und verworfen: Qwen 3.8 27B via OpenRouter (mit Denken 5,2 s/Zug; ohne Denken 1,4 s, aber kein Cache-Treffer bei AkashML -> 3-4x Kosten) und lokales qwen36:35b auf der B70 (Prefill 7,5 s, Decode 19 s). Alias 42i/inka-qwen bleibt fuer weitere Tests.", + "_rollen_doku": "Rollen entkoppeln Agenten von Modellnamen: ein Agent ruft role/worker, welches Modell dahintersteht, stellt man HIER um -- aus der Ferne, ohne Container-Deploy. Seit 2026-09-07 zeigen die Rollen auf auto/-Profile (42i/intern#1252) statt direkt auf eine Index-Stufe: die Wahl haengt jetzt an der Achse, die zur Aufgabe passt. xo, teamleader und lead auf auto/leader:junior (Andreas 2026-09-07: keine Rolle mehr auf der teuren Stufe); worker, engineer, qa und perf auf auto/coding:junior; alles uebrige auf auto/allround:junior. role/inka bleibt fest auf Luna (Telefon-Hirn, Cache-Verhalten gemessen). Die 42i/marvin-2609:*-Stufen bleiben als eigene Namen bestehen, damit direkte Aufrufe weiter gehen.", "rollen": { - "xo": "42i/marvin-2609:medium", - "architect": "42i/marvin-2609:medium", - "teamleader": "42i/marvin-2609:medium", - "lead": "42i/marvin-2609:medium", - "worker": "42i/marvin-2609:medium", - "engineer": "42i/marvin-2609:medium", - "po": "42i/marvin-2609:medium", - "speaker": "42i/marvin-2609:medium", - "support": "42i/marvin-2609:medium", - "assistant": "42i/marvin-2609:medium", - "privacy": "42i/marvin-2609:medium", - "service": "42i/marvin-2609:medium", - "ops": "42i/marvin-2609:medium", - "marketing": "42i/marvin-2609:medium", - "qa": "42i/marvin-2609:medium", - "perf": "42i/marvin-2609:medium", - "i18n": "42i/marvin-2609:medium", - "docs": "42i/marvin-2609:medium", - "ar": "42i/marvin-2609:medium", - "archivist": "42i/marvin-2609:medium", + "xo": "auto/leader:junior", + "architect": "auto/allround:junior", + "teamleader": "auto/leader:junior", + "lead": "auto/leader:junior", + "worker": "auto/coding:junior", + "engineer": "auto/coding:junior", + "po": "auto/allround:junior", + "speaker": "auto/allround:junior", + "support": "auto/allround:junior", + "assistant": "auto/allround:junior", + "privacy": "auto/allround:junior", + "service": "auto/allround:junior", + "ops": "auto/allround:junior", + "marketing": "auto/allround:junior", + "qa": "auto/coding:junior", + "perf": "auto/coding:junior", + "i18n": "auto/allround:junior", + "docs": "auto/allround:junior", + "ar": "auto/allround:junior", + "archivist": "auto/allround:junior", "inka": "openrouter/openai/gpt-5.6-luna" }, "_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).", diff --git a/key-acls.json b/key-acls.json index 2d55b3c..07e5ac6 100644 --- a/key-acls.json +++ b/key-acls.json @@ -61,7 +61,8 @@ "42i/marvin-tts-turbo", "role/*", "openrouter/*", - "alt/*" + "alt/*", + "auto/*" ], "persona-festus": [ "42i/marvin-2609-core:none", @@ -134,7 +135,8 @@ "deizo/kira-stt", "deizo/kira-tts", "role/xo", - "role/*" + "role/*", + "auto/*" ], "team-copilot": [ "42i/marvin-2609:low", diff --git a/src/config.ts b/src/config.ts index ccf35ed..7297b2c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,8 +13,13 @@ import { dirname, resolve } from "path"; export type Upstream = { base: string; keyEnv?: string; maxParallel?: number; /** Client-Token unveraendert weitergeben (Durchreiche an LiteLLM) */ clientKey?: boolean }; export type Alias = | { art: "alternative"; referenz: string } - | { art: "schwelle"; index: number; minContext: number } - | { art: "fest"; upstream: string; model: string; body?: Record }; + // Schwelle auf bis zu drei Achsen (42i/intern#1252): index = Intelligence, + // coding und agentic je eigene Untergrenze. Fehlt eine, gilt sie nicht. + | { art: "schwelle"; index?: number; coding?: number; agentic?: number; minContext: number } + // ausweich: Alias, auf den bei Upstream-Ausfall gewechselt wird. Nur fuer + // "fest" sinnvoll -- ein festes Ziel hat sonst keinen Rueckfall, und genau + // das trifft das lokale Qwen: steht der Knoten, haengt jede Massenarbeit. + | { art: "fest"; upstream: string; model: string; body?: Record; ausweich?: string }; export type Config = { port: number; logDir: string; diff --git a/src/resolver.ts b/src/resolver.ts index 89f0d63..3dbc0da 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -149,8 +149,14 @@ export async function aufloesen(c: Config, bisher: State, opts?: { dryRun?: bool const { referenz, auswahl } = await guenstigereAlternative(def.referenz, d.alle, d.key, melden, d.gesperrt, o); ziel = auswahl === null ? zuordnungAus(referenz, def.referenz, alt?.seit ?? neu.aktualisiert, true) : zuordnungAus(auswahl.candidate, def.referenz, alt?.seit ?? neu.aktualisiert, false); } else { - const beschreibung = `Index>=${def.index}, Kontext>=${def.minContext}`; - const { auswahl } = await billigsterUeberSchwelle(def.index, def.minContext, d.alle, d.key, melden, d.gesperrt, o); + const teile = [ + def.index !== undefined ? `Index>=${def.index}` : null, + def.coding !== undefined ? `Coding>=${def.coding}` : null, + def.agentic !== undefined ? `Agentic>=${def.agentic}` : null, + `Kontext>=${def.minContext}`, + ].filter((t): t is string => t !== null); + const beschreibung = teile.join(", "); + const { auswahl } = await billigsterUeberSchwelle(def.index ?? 0, def.minContext, d.alle, d.key, melden, d.gesperrt, { ...o, minCoding: def.coding, minAgentic: def.agentic }); if (auswahl === null) throw new Error(`kein Modell erfuellt ${beschreibung}`); ziel = zuordnungAus(auswahl.candidate, beschreibung, alt?.seit ?? neu.aktualisiert, false); } diff --git a/src/server.ts b/src/server.ts index 7074603..711502f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -73,7 +73,7 @@ async function belegen(name: string, u: Upstream): Promise<() => void> { } // --- Aufloesung Name -> Ziel --------------------------------------------------- -type Ziel = { alias: string; upstreamName: string; upstream: Upstream; model: string; effort: Effort | null; body?: Record; effortSetzen: boolean }; +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; @@ -116,7 +116,7 @@ async function zielBestimmen(angefragtRoh: string, keyName: string, clientEffort } 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 }; + 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 }; @@ -243,6 +243,7 @@ Bun.serve({ 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 }); } @@ -254,6 +255,7 @@ Bun.serve({ 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 { @@ -287,14 +289,46 @@ Bun.serve({ try { log.schreiben(eintrag); } catch (e) { sagen(`Log-Fehler: ${(e as Error).message}`); } }; - const freigeben = await belegen(ziel.upstreamName, ziel.upstream); + let freigeben = await belegen(ziel.upstreamName, ziel.upstream); let antwort: Response; try { antwort = await fetch(`${ziel.upstream.base}${pfad}`, { method: "POST", headers, body: upstreamBody, signal: AbortSignal.timeout(600_000) }); } catch (e) { freigeben(); - eintrag.status = 502; eintrag.fehler = (e as Error).message; abschliessen(); - return Response.json({ error: { message: `Upstream ${ziel.upstreamName} nicht erreichbar: ${eintrag.fehler}` } }, { status: 502 }); + 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") ?? "";