From 7c6e74714587c6d433c9881c5c1ef22017803d01 Mon Sep 17 00:00:00 2001 From: Elton Turing Date: Wed, 9 Sep 2026 17:03:51 +0200 Subject: [PATCH] minOutput je Profil senkbar, neue Rolle coder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das 64K-Output-Gate war global und trieb auto/mass:fast auf solar-pro4 (0,42 statt 0,27 $/1M), weil ling-3.0-flash mit 32768 Output-Token durchfiel. Gemessen 09.09.: 1.415 Masse-Requests, 1.146 davon unter 100 Completion-Token — das Gate verfehlte dort seinen Zweck. Jetzt kann je Schwelle-Profil minOutput gesetzt werden (Default 65536); auto/mass:fast laeuft mit 32768, max_output unbekannt bleibt abgelehnt. Dazu role/coder -> auto/coding:junior (ACL fuer persona-spark), damit Code-Arbeit nicht ueber role/worker in die Masse faellt; der engineer-Agent der Mac-opencode.json wechselt auf role/coder. Refs #1283 --- config.json | 4 +++- docs/betrieb.md | 20 +++++++++++++++++--- docs/referenz.md | 11 ++++++++--- key-acls.json | 3 ++- src/config.ts | 8 +++++--- src/openrouter-auswahl.ts | 20 ++++++++++++++------ src/resolver.ts | 3 ++- test/auswahl.test.ts | 27 +++++++++++++++++++++++++++ 8 files changed, 78 insertions(+), 18 deletions(-) diff --git a/config.json b/config.json index e5cd22b..e10c47b 100644 --- a/config.json +++ b/config.json @@ -167,8 +167,9 @@ "art": "schwelle", "index": 25, "minContext": 256000, + "minOutput": 32768, "ausweich": "auto/coding:junior", - "_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'. Ausweich auf auto/coding:junior seit 2026-09-08 (Andreas): ling hat nachts Kapazitaets- und Ratengrenzprobleme (2026-09-07 23:20-23:52: 5x 502 nach ~270 s haengendem Upstream, 117x 429), und auto/mass:fast war als Endstation ohne Rueckfall. coding:junior erfuellt ling strukturell nie (Coding 50,6 / Agentic 21,1 gegen Coding>=68 / Agentic>=45) -- der Ausweich landet also garantiert auf einem tauglichen Modell, nicht auf ling selbst." + "_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'. Ausweich auf auto/coding:junior seit 2026-09-08 (Andreas): ling hat nachts Kapazitaets- und Ratengrenzprobleme (2026-09-07 23:20-23:52: 5x 502 nach ~270 s haengendem Upstream, 117x 429), und auto/mass:fast war als Endstation ohne Rueckfall. coding:junior erfuellt ling strukturell nie (Coding 50,6 / Agentic 21,1 gegen Coding>=68 / Agentic>=45) -- der Ausweich landet also garantiert auf einem tauglichen Modell, nicht auf ling selbst. minOutput 32768 seit 2026-09-09 (Andreas): Masse schreibt fast nur kleine Completion-Haeufchen (gemessen 09.09.: 1.146 von 1.415 Requests unter 100 Token), das 64K-Output-Gate trieb sie sonst unnoetig auf solar-pro4; Arbeitsmodelle behalten 65536." }, "auto/allround:junior": { "art": "schwelle", @@ -221,6 +222,7 @@ "lead": "auto/leader:junior", "leader": "auto/leader:junior", "worker": "auto/mass:fast", + "coder": "auto/coding:junior", "engineer": "auto/coding:junior", "po": "auto/allround:junior", "speaker": "auto/allround:junior", diff --git a/docs/betrieb.md b/docs/betrieb.md index 824416f..01f96a3 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -59,7 +59,7 @@ ohne Deploy, ohne Container anzufassen (42i/intern#1252). | Profil | Regel | trifft am 2026-09-07 | $/1M gew. | |---|---|---|---| | `auto/mass` | fest: lokales Qwen, Ausweich auf `mass:fast` | qwen36:35b | 0 | -| `auto/mass:fast` | Index ≥ 25 | ling-3.0-flash | 0,27 | +| `auto/mass:fast` | Index ≥ 25, Output ≥ 32768 (je Profil senkbar, sonst 65536) | ling-3.0-flash | 0,27 | | `auto/allround:junior` | Index ≥ 40 | glm-5.3-flash | 0,95 | | `auto/allround:senior` | Index ≥ 49 | grok-4.6:high | 26,00 | | `auto/coding:junior` | Coding ≥ 68, Agentic ≥ 45 | glm-5.3-flash | 0,95 | @@ -72,10 +72,13 @@ Agentic **41,2** — es schreibt guten Text und trägt den Werkzeug-Rundlauf nic Ein einzelner Index sieht diesen Unterschied strukturell nicht. **Rollenbelegung:** `xo`, `teamleader`, `lead` → `auto/leader:junior`; -`worker`, `engineer`, `qa`, `perf` → `auto/coding:junior`; alle übrigen → +`worker` → `auto/mass:fast`; `coder`, `engineer`, `qa`, `perf` → +`auto/coding:junior`; alle übrigen → `auto/allround:junior`; `inka` fest auf `openrouter/openai/gpt-5.6-luna`. **Keine Rolle steht auf einer Senior-Stufe** (Andreas, 2026-09-07) — hochgestuft -wird einzeln und mit Anlass, nicht vorsorglich. +wird einzeln und mit Anlass, nicht vorsorglich. `role/coder` existiert seit +2026-09-09 neben `engineer`, damit Code-Arbeit nicht über `role/worker` (Masse) +läuft. **Der Ausweich** gilt für Ziele ohne eigenen Rückfall — festes Ziel wie `auto/mass` (lokales Qwen) oder Schwelle auf einem Ausfall-Modell wie @@ -283,6 +286,17 @@ starten — LiteLLM bedient die `2608`-Namen weiter, die `2609`-, `alt/`- und ## Verlauf +- 2026-09-09: **Output-Gate je Profil senkbar, neue Rolle `coder`.** Das + 64K-Output-Gate (MIN_OUTPUT) trieb `auto/mass:fast` auf `solar-pro4` + (0,42 $/1M), weil ling mit 32768 Output-Token durchfiel — gemessen 09.09.: + 1.415 Masse-Requests, 1.146 davon unter 100 Completion-Token; das Gate + verfehlte dort seinen Zweck und kostete ~35 % Aufschlag. Jetzt ist + `minOutput` pro Profil in `config.json` setzbar (Default bleibt 65536), + `auto/mass:fast` läuft mit 32768. Dazu `role/coder` → + `auto/coding:junior` neben `engineer`, ACL für `persona-spark`, der + engineer-Agent in der Mac-opencode.json von `role/worker` auf + `role/coder` umgezogen. + - 2026-09-09: **Umstieg aufs Image vollzogen.** Prod läuft als OCI-Container (`llmrouter_llmrouter_1`, podman-compose hinter der Unit `llmrouter`, Bild `:latest` von Run #7/e144034). Laufzeitdaten wanderten nach diff --git a/docs/referenz.md b/docs/referenz.md index df19258..24418c7 100644 --- a/docs/referenz.md +++ b/docs/referenz.md @@ -20,7 +20,7 @@ reicht Durchreichen. Entscheidung Andreas, 2026-09-05. | `deizo/kira-reasoning`, `deizo/kira-stt` | Modelle im Prod-RZ deizo, fest | | `openrouter/[:effort]` | 1:1 an OpenRouter, Effort als `reasoning.effort` | | `alt/[:effort]` | **günstigste Alternative derselben Klasse** wie `` auf dieser Stufe | -| `auto/[:stufe]` | **Profil auf drei Achsen** (`mass`, `mass:fast`, `allround`, `coding`, `leader` je `:junior`/`:senior`) — billigstes Modell über den geforderten Untergrenzen | +| `auto/[:stufe]` | **Profil auf drei Achsen** (`mass`, `mass:fast`, `allround`, `coding`, `leader` je `:junior`/`:senior`) — billigstes Modell über den geforderten Untergrenzen; `minOutput` senkt das 64K-Output-Gate je Profil | | `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 | **404** — unbekannte Namen sind ein Fehler, keine stille Weiterleitung (die LiteLLM-Durchreiche ist mit deren Abschaltung 2026-09-07 entfallen) | @@ -45,12 +45,17 @@ nur auf bis zu drei Achsen statt einer: `index` (Intelligence), `coding` und keinen Werkzeug-Rundlauf. `auto/mass` ist der Sonderfall: fest auf dem lokalen Qwen (kostet nichts) und mit `ausweich` auf `auto/mass:fast`, falls der Knoten steht — ohne den hinge sonst jede Titelzeile jedes Agenten, weil `auto/mass` -überall als `small_model` hängt. +überall als `small_model` hängt. Über die Achsen hinaus kann ein Profil das +64K-Output-Gate je Stufe senken: `minOutput` (Default 65536, Andreas +2026-09-09). `auto/mass:fast` läuft mit 32768 — Masse-Arbeit schreibt fast nur +kleine Completion-Häufchen, das Gate trieb sie sonst un nötig auf +`upstage/solar-pro4`; `max_output` unbekannt bleibt abgelehnt. **Rollen** entkoppeln Agenten von Modellnamen: ein Agent ruft `role/worker`, welches Modell dahintersteht, stellt man in `config.json` um, ohne Container. Seit 2026-09-07 zeigen alle Rollen auf `auto/`-Profile: xo/teamleader/lead → -`auto/leader:junior`, worker/engineer/qa/perf → `auto/coding:junior`, der Rest → +`auto/leader:junior`, coder/engineer/qa/perf → `auto/coding:junior`, worker → +`auto/mass:fast`, der Rest → `auto/allround:junior`, `inka` fest auf Luna. Keine Rolle steht auf einer Senior-Stufe (Andreas: hochstufen einzeln und mit Anlass). diff --git a/key-acls.json b/key-acls.json index deb7b9b..c2ad87a 100644 --- a/key-acls.json +++ b/key-acls.json @@ -1,5 +1,5 @@ { - "_note": "Rechte je Client-Key: Name -> erlaubte Aliasse (neue Namen; Altnamen werden vor der Pruefung uebersetzt). [] = alles. Die Token selbst liegen NICHT hier, sondern in KEYS_FILE auf dem Host (token -> name). Abgeleitet aus llmlite/key-acls.json am 2026-09-05: role/* und -auto gestrichen (Andreas), -auto -> :medium. Stand 2026-09-05 abends: Stufen low/medium/high/max; none->low, extra->high, ultra->max, mass->low. Namen ab 2026-09-05: 42i/marvin-2609:*; die Rechte gelten nur fuer Router-Aliasse, unbekannte Namen (2608) reicht der Router an LiteLLM durch, dort gelten dessen ACLs. Muster mit * erlaubt (openrouter/*, alt/*, role/*). Rollen aus der LiteLLM-Zeit je Key wieder eingetragen; Elton und Andreas duerfen openrouter/* und alt/*. 2026-09-07: core:none fuer alle Container-Personas, weil opencodes small_model (Titel, Zusammenfassungen) dorthin zeigt -- nach 42i/intern#1252 wird daraus auto/mass. 2026-09-07 aufgeraeumt: 'nightjob' (kein Token, kein Konsument, keine Nutzung) und 'persona-inka-mx' entfernt -- beide Inka-Tokens (Shim und Telefon-Front-End) laufen im Router unter 'persona-inka', der mx-Name existierte nur noch als LiteLLM-key_alias. 2026-09-08: auto/mass:fast und auto/coding:junior fuer die Container-Personas -- die Ausweich-Kette auto/mass -> auto/mass:fast -> auto/coding:junior laeuft durch die ACL-Pruefung des aufrufenden Keys; ohne die Ziele dort schlaegt der Fallback still fehl (403 in zielBestimmen) und der urspruengliche Fehler geht durch. 2026-09-09: 42i/marvin-stt-turbo und 42i/marvin-tts-turbo fuer die vier Container-Personas (Andreas: 'schaltet TTS und STT grundsaetzlich auf die externen Modelle um, die wir auch fuer Inka verwenden') -- die Agenten-Sprachkanaelle laufen damit ueber Voxtral Small bzw. Grok Voice statt lokalem whisper.cpp/Qwen3-TTS; die lokalen marvin-stt/marvin-tts bleiben als Rueckweg erlaubt.", + "_note": "Rechte je Client-Key: Name -> erlaubte Aliasse (neue Namen; Altnamen werden vor der Pruefung uebersetzt). [] = alles. Die Token selbst liegen NICHT hier, sondern in KEYS_FILE auf dem Host (token -> name). Abgeleitet aus llmlite/key-acls.json am 2026-09-05: role/* und -auto gestrichen (Andreas), -auto -> :medium. Stand 2026-09-05 abends: Stufen low/medium/high/max; none->low, extra->high, ultra->max, mass->low. Namen ab 2026-09-05: 42i/marvin-2609:*; die Rechte gelten nur fuer Router-Aliasse, unbekannte Namen (2608) reicht der Router an LiteLLM durch, dort gelten dessen ACLs. Muster mit * erlaubt (openrouter/*, alt/*, role/*). Rollen aus der LiteLLM-Zeit je Key wieder eingetragen; Elton und Andreas duerfen openrouter/* und alt/*. 2026-09-07: core:none fuer alle Container-Personas, weil opencodes small_model (Titel, Zusammenfassungen) dorthin zeigt -- nach 42i/intern#1252 wird daraus auto/mass. 2026-09-07 aufgeraeumt: 'nightjob' (kein Token, kein Konsument, keine Nutzung) und 'persona-inka-mx' entfernt -- beide Inka-Tokens (Shim und Telefon-Front-End) laufen im Router unter 'persona-inka', der mx-Name existierte nur noch als LiteLLM-key_alias. 2026-09-08: auto/mass:fast und auto/coding:junior fuer die Container-Personas -- die Ausweich-Kette auto/mass -> auto/mass:fast -> auto/coding:junior laeuft durch die ACL-Pruefung des aufrufenden Keys; ohne die Ziele dort schlaegt der Fallback still fehl (403 in zielBestimmen) und der urspruengliche Fehler geht durch. 2026-09-09: 42i/marvin-stt-turbo und 42i/marvin-tts-turbo fuer die vier Container-Personas (Andreas: 'schaltet TTS und STT grundsaetzlich auf die externen Modelle um, die wir auch fuer Inka verwenden') -- die Agenten-Sprachkanaelle laufen damit ueber Voxtral Small bzw. Grok Voice statt lokalem whisper.cpp/Qwen3-TTS; die lokalen marvin-stt/marvin-tts bleiben als Rueckweg erlaubt. 2026-09-09: role/coder fuer persona-spark (neue Rolle, zeigt wie engineer auf auto/coding:junior).", "keys": { "persona-spark": [ "42i/marvin-2609-core:none", @@ -14,6 +14,7 @@ "deizo/kira-stt", "deizo/kira-tts", "role/engineer", + "role/coder", "role/worker" ], "persona-buzz": [ diff --git a/src/config.ts b/src/config.ts index 5026667..0910ea5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,9 +13,11 @@ 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 } - // 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?: string } + // Schwelle auf bis zu vier Achsen (42i/intern#1252): index = Intelligence, + // coding und agentic je eigene Untergrenze, minOutput als Overlay auf das + // 64K-Output-Gate (Andreas 2026-09-09: mass-Arbeit darf mit 32768 laufen, + // Default bleibt 65536). Fehlt eine, gilt sie nicht. + | { art: "schwelle"; index?: number; coding?: number; agentic?: number; minContext: number; minOutput?: number; ausweich?: string } // ausweich: Alias, auf den bei Upstream-Ausfall gewechselt wird. Fuer "fest" // und "schwelle" -- ein Ziel ohne Rueckfall haengt sonst jede Arbeit, die // darauf zeigt: festes Ziel = lokales Qwen, schwelle = Billigmodell mit diff --git a/src/openrouter-auswahl.ts b/src/openrouter-auswahl.ts index 4568e36..6d8aae1 100644 --- a/src/openrouter-auswahl.ts +++ b/src/openrouter-auswahl.ts @@ -32,7 +32,14 @@ import { spawnSync } from "child_process"; export const API = process.env.OPENROUTER_API_URL ?? "https://openrouter.ai/api/v1"; export const MIN_CONTEXT = 256_000; -export const MIN_OUTPUT = 65536; // Andreas 2026-09-08, ticket 42i/intern#1283 — work models need at least 64K output tokens; inclusionai/ling-3.0-flash is hard-capped at 32768 +/** + * Default des Output-Gates, je Schwelle-Profil ueber `minOutput` uebersteuerbar + * (Andreas 2026-09-09: auto/mass:fast laeuft mit 32768, weil Masse-Arbeit + * kleine Completion-Tokenhaeufchen schreibt; Arbeitsmodelle behalten 65536). + * Anlass 2026-09-08, ticket 42i/intern#1283: inclusionai/ling-3.0-flash ist + * hart bei 32768 gedeckelt. + */ +export const MIN_OUTPUT = 65536; export const MAX_LATENCY_MS = 3_000; export const INPUT_WEIGHT = 10; // Agenten-Lastprofil: Input dominiert export const OUTPUT_WEIGHT = 1; @@ -558,9 +565,9 @@ export async function billigsterUeberSchwelle( // und taugt damit fuer Text, nicht fuer den Werkzeug-Rundlauf. Wer nur eine // der Achsen fordert, uebergibt fuer die anderen keinen Wert; minIndex 0 // heisst "auf dieser Achse keine Anforderung". - opts?: { lasttest?: boolean; varianten?: Variante[]; bestand?: string; minCoding?: number; minAgentic?: number }, + opts?: { lasttest?: boolean; varianten?: Variante[]; bestand?: string; minCoding?: number; minAgentic?: number; minOutput?: number }, ): Promise<{ auswahl: Auswahl | null; infrage: Candidate[] }> { - const infrage = klasseFiltern(alle, { minIndex: schwelle, minContext, minCoding: opts?.minCoding, minAgentic: opts?.minAgentic }, opts?.varianten ?? [], gesperrt, abgelehnt); + const infrage = klasseFiltern(alle, { minIndex: schwelle, minContext, minCoding: opts?.minCoding, minAgentic: opts?.minAgentic, minOutput: opts?.minOutput }, opts?.varianten ?? [], gesperrt, abgelehnt); const auswahl = await erstesTaugliches(infrage, key, abgelehnt, opts); return { auswahl, infrage }; } @@ -575,19 +582,20 @@ export async function billigsterUeberSchwelle( */ function klasseFiltern( alle: Map, - regel: { minIndex: number; minCoding?: number; minAgentic?: number; minContext: number; maxWeighted?: number; ausser?: string }, + regel: { minIndex: number; minCoding?: number; minAgentic?: number; minContext: number; minOutput?: number; maxWeighted?: number; ausser?: string }, varianten: Variante[], gesperrt: Map, ablehnen?: (id: string, grund: string) => void, ): Candidate[] { + const minOutput = regel.minOutput ?? MIN_OUTPUT; const infrage: Candidate[] = []; for (const k of alle.values()) { if (k.id === regel.ausser || !k.tauglich || gesperrt.has(k.id)) continue; if (k.context < regel.minContext) continue; if (regel.maxWeighted !== undefined && k.weighted >= regel.maxWeighted) continue; if (regel.minAgentic !== undefined && !(k.agentic >= regel.minAgentic)) continue; - if (k.maxOutput > 0 && k.maxOutput < MIN_OUTPUT) { - ablehnen?.(k.id, `max_output < ${MIN_OUTPUT} (${k.maxOutput})`); + if (k.maxOutput > 0 && k.maxOutput < minOutput) { + ablehnen?.(k.id, `max_output < ${minOutput} (${k.maxOutput})`); continue; } if (k.maxOutput === 0) { diff --git a/src/resolver.ts b/src/resolver.ts index e078c8e..041e8a7 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -154,9 +154,10 @@ export async function aufloesen(c: Config, bisher: State, opts?: { dryRun?: bool def.coding !== undefined ? `Coding>=${def.coding}` : null, def.agentic !== undefined ? `Agentic>=${def.agentic}` : null, `Kontext>=${def.minContext}`, + def.minOutput !== undefined ? `Output>=${def.minOutput}` : null, ].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 }); + const { auswahl } = await billigsterUeberSchwelle(def.index ?? 0, def.minContext, d.alle, d.key, melden, d.gesperrt, { ...o, minCoding: def.coding, minAgentic: def.agentic, minOutput: def.minOutput }); if (auswahl === null) throw new Error(`kein Modell erfuellt ${beschreibung}`); ziel = zuordnungAus(auswahl.candidate, beschreibung, alt?.seit ?? neu.aktualisiert, false); } diff --git a/test/auswahl.test.ts b/test/auswahl.test.ts index 892b9ed..dc47189 100644 --- a/test/auswahl.test.ts +++ b/test/auswahl.test.ts @@ -124,3 +124,30 @@ describe("Grenze maxOutput", () => { expect(abgelehnt).toHaveLength(0); }); }); + +describe("minOutput je Profil", () => { + it("32768 laesst ling als billigsten Kandidaten zu, Default tut es nicht", async () => { + const { alle } = kandidatenBauen({ voll: modelle, konto: null }); + const mitGate = await billigsterUeberSchwelle(40, 0, alle, undefined, undefined, new Map(), { varianten: [] }); + expect(mitGate.auswahl?.candidate.id).toBe("z-ai/glm-5.3-flash"); + const ohneGate = await billigsterUeberSchwelle(40, 0, alle, undefined, undefined, new Map(), { varianten: [], minOutput: 32768 }); + expect(ohneGate.auswahl?.candidate.id).toBe("inclusionai/ling-3.0-flash"); + }); + + it("bekannte Ablehnung traegt den wirksamen Grenzwert", async () => { + const { alle } = kandidatenBauen({ voll: modelle, konto: null }); + const abgelehnt: Array<{ id: string; grund: string }> = []; + await billigsterUeberSchwelle(40, 0, alle, undefined, (id, grund) => abgelehnt.push({ id, grund }), new Map(), { varianten: [], minOutput: 65536 }); + const ling = abgelehnt.find((e) => e.id === "inclusionai/ling-3.0-flash"); + expect(ling?.grund).toContain("65536"); + }); + + it("max_output unbekannt bleibt abgelehnt, auch mit herabgesetztem minOutput", async () => { + const { alle } = kandidatenBauen({ voll: modelle, konto: null }); + const abgelehnt: Array<{ id: string; grund: string }> = []; + await billigsterUeberSchwelle(40, 0, alle, undefined, (id, grund) => abgelehnt.push({ id, grund }), new Map(), { varianten: [], minOutput: 1 }); + const foo = abgelehnt.find((e) => e.id === "foo/ohne-angabe"); + expect(foo).toBeDefined(); + expect(foo!.grund).toBe("max_output unbekannt"); + }); +});