From 538e01a239ad069eb6581c0fa64371dd0dd85918 Mon Sep 17 00:00:00 2001 From: Elton Turing Date: Tue, 8 Sep 2026 20:40:28 +0200 Subject: [PATCH] =?UTF-8?q?llmrouter-Auswahl:=20Gate=2065536=20Output-Toke?= =?UTF-8?q?n=20f=C3=BCr=20Arbeitsmodelle=20(42i/intern#1283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port aus 42i/agents llmlite/ (Merge 78c3c57, Ursprung 5730cfd) — der Router lebt seit dem 2026-09-08 in diesem Repo. Arbeitsmodelle (nicht TTS) brauchen mindestens 65536 Output-Token; inclusionai/ling-3.0-flash (hart 32768) fällt damit aus der Auswahl. Geprüfte Kette: Rot-Beweis (Produktion wählte ling für low + haiku, state.json 06:44), Nachher (Ablehnung "max_output < 65536 (32768)"), Gegenprobe (beschädigtes Gate quittiert die Testsuite rot mit benanntem Schaden). Refs 42i/intern#1283 --- src/openrouter-auswahl.ts | 31 ++++++++-- test/auswahl.test.ts | 126 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 test/auswahl.test.ts diff --git a/src/openrouter-auswahl.ts b/src/openrouter-auswahl.ts index e48b80d..4568e36 100644 --- a/src/openrouter-auswahl.ts +++ b/src/openrouter-auswahl.ts @@ -32,6 +32,7 @@ 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 export const MAX_LATENCY_MS = 3_000; export const INPUT_WEIGHT = 10; // Agenten-Lastprofil: Input dominiert export const OUTPUT_WEIGHT = 1; @@ -42,7 +43,7 @@ export type Model = { context_length?: number; pricing?: { prompt?: string; completion?: string; input_cache_read?: string; input_cache_write?: string }; supported_parameters?: string[]; - top_provider?: { context_length?: number }; + top_provider?: { context_length?: number; max_completion_tokens?: number }; benchmarks?: { artificial_analysis?: { intelligence_index?: number; coding_index?: number; agentic_index?: number } }; }; @@ -73,6 +74,8 @@ export type Candidate = { frei: boolean; /** Kann Tool-Calls und ist kein Batch-Modell -- unabhaengig von MIN_CONTEXT. */ tauglich: boolean; + /** Maximal erlaubte Output-Token (0 = unbekannt). */ + maxOutput: number; /** Effort-Stufe, auf die sich `index` bezieht; null = OpenRouter-Zahl (Hoechststufe). */ effort?: Effort | null; }; @@ -194,7 +197,7 @@ export function kandidatenBauen( cacheRead: preis(modell.pricing?.input_cache_read), cacheWrite: preis(modell.pricing?.input_cache_write), weighted: gewichtet(input, output), context, frei: input === 0 && output === 0, - tauglich: false, + tauglich: false, maxOutput: modell.top_provider?.max_completion_tokens ?? 0, }; const istBatch = /batch/u.test(`${modell.id} ${modell.name ?? ""}`) || modell.supported_parameters?.includes("batch") === true; const kannTools = modell.supported_parameters?.includes("tools") === true; @@ -349,6 +352,17 @@ export async function billigsterTauglicher( ): Promise { const infrage = kandidaten .filter((k) => k.index > schwelle) + .filter((k) => { + if (k.maxOutput > 0 && k.maxOutput < MIN_OUTPUT) { + abgelehnt?.(k.id, `max_output < ${MIN_OUTPUT} (${k.maxOutput})`); + return false; + } + if (k.maxOutput === 0) { + abgelehnt?.(k.id, "max_output unbekannt"); + return false; + } + return true; + }) .sort((a, b) => a.weighted - b.weighted); return erstesTaugliches(infrage, key, abgelehnt, opts); } @@ -519,7 +533,7 @@ export async function guenstigereAlternative( minContext: referenz.context * KONTEXT_TOLERANZ, maxWeighted: referenz.weighted, ausser: referenz.id, - }, varianten, gesperrt); + }, varianten, gesperrt, abgelehnt); const auswahl = await erstesTaugliches(infrage, key, abgelehnt, opts); return { referenz, auswahl, infrage }; } @@ -546,7 +560,7 @@ export async function billigsterUeberSchwelle( // heisst "auf dieser Achse keine Anforderung". opts?: { lasttest?: boolean; varianten?: Variante[]; bestand?: string; minCoding?: number; minAgentic?: number }, ): Promise<{ auswahl: Auswahl | null; infrage: Candidate[] }> { - const infrage = klasseFiltern(alle, { minIndex: schwelle, minContext, minCoding: opts?.minCoding, minAgentic: opts?.minAgentic }, opts?.varianten ?? [], gesperrt); + const infrage = klasseFiltern(alle, { minIndex: schwelle, minContext, minCoding: opts?.minCoding, minAgentic: opts?.minAgentic }, opts?.varianten ?? [], gesperrt, abgelehnt); const auswahl = await erstesTaugliches(infrage, key, abgelehnt, opts); return { auswahl, infrage }; } @@ -564,6 +578,7 @@ function klasseFiltern( regel: { minIndex: number; minCoding?: number; minAgentic?: number; minContext: number; maxWeighted?: number; ausser?: string }, varianten: Variante[], gesperrt: Map, + ablehnen?: (id: string, grund: string) => void, ): Candidate[] { const infrage: Candidate[] = []; for (const k of alle.values()) { @@ -571,6 +586,14 @@ function klasseFiltern( 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})`); + continue; + } + if (k.maxOutput === 0) { + ablehnen?.(k.id, "max_output unbekannt"); + continue; + } const eigene = varianten.filter((v) => v.id === k.id); const stufen = eigene.length === 0 ? [{ effort: null as Effort | null, index: k.index, coding: Number.isFinite(k.coding) ? k.coding : null }] diff --git a/test/auswahl.test.ts b/test/auswahl.test.ts new file mode 100644 index 0000000..892b9ed --- /dev/null +++ b/test/auswahl.test.ts @@ -0,0 +1,126 @@ +process.env.OPENROUTER_API_URL = "http://127.0.0.1:1"; + +import { describe, it, expect } from "bun:test"; +import { + MIN_OUTPUT, + kandidatenBauen, + billigsterUeberSchwelle, + type Model, +} from "../src/openrouter-auswahl.ts"; + +const modelle: Model[] = [ + { + id: "anthropic/claude-haiku-4.5", + context_length: 131072, + pricing: { prompt: "0.01", completion: "0.01" }, + supported_parameters: ["tools"], + benchmarks: { + artificial_analysis: { intelligence_index: 90 }, + }, + top_provider: { max_completion_tokens: 131072 }, + }, + { + id: "inclusionai/ling-3.0-flash", + context_length: 32768, + pricing: { prompt: "0.001", completion: "0.001" }, + supported_parameters: ["tools"], + benchmarks: { + artificial_analysis: { intelligence_index: 50 }, + }, + top_provider: { max_completion_tokens: 32768 }, + }, + { + id: "z-ai/glm-5.3-flash", + context_length: 131072, + pricing: { prompt: "0.002", completion: "0.002" }, + supported_parameters: ["tools"], + benchmarks: { + artificial_analysis: { intelligence_index: 55 }, + }, + top_provider: { max_completion_tokens: 131072 }, + }, + { + id: "foo/ohne-angabe", + context_length: 131072, + pricing: { prompt: "0.0015", completion: "0.0015" }, + supported_parameters: ["tools"], + benchmarks: { + artificial_analysis: { intelligence_index: 60 }, + }, + }, +]; + +describe("MIN_OUTPUT", () => { + it("ist genau 65536", () => { + expect(MIN_OUTPUT).toBe(65536); + }); +}); + +describe("kandidatenBauen", () => { + it("fuellt maxOutput", () => { + const { alle } = kandidatenBauen({ voll: modelle, konto: null }); + expect(alle.get("inclusionai/ling-3.0-flash")?.maxOutput).toBe(32768); + expect(alle.get("anthropic/claude-haiku-4.5")?.maxOutput).toBe(131072); + expect(alle.get("foo/ohne-angabe")?.maxOutput).toBe(0); + }); +}); + +describe("billigsterUeberSchwelle", () => { + it("wahlt glm-5.3-flash und lehnt ling sowie foo ab", async () => { + const { alle } = kandidatenBauen({ voll: modelle, konto: null }); + const abgelehnt: Array<{ id: string; grund: string }> = []; + const ablehnen = (id: string, grund: string) => { + abgelehnt.push({ id, grund }); + }; + const result = await billigsterUeberSchwelle( + 40, + 0, + alle, + undefined, + ablehnen, + new Map(), + { varianten: [] }, + ); + expect(result.auswahl?.candidate.id).toBe("z-ai/glm-5.3-flash"); + const ling = abgelehnt.find((e) => e.id === "inclusionai/ling-3.0-flash"); + expect(ling).toBeDefined(); + expect(ling!.grund).toContain("max_output"); + expect(ling!.grund).toContain("32768"); + const foo = abgelehnt.find((e) => e.id === "foo/ohne-angabe"); + expect(foo).toBeDefined(); + expect(foo!.grund).toBe("max_output unbekannt"); + }); +}); + +describe("Grenze maxOutput", () => { + it("ein Kandidat mit maxOutput genau 65536 bleibt infrage", async () => { + const grenzwert: Model[] = [ + { + id: "bar/grenzwertig", + context_length: 131072, + pricing: { prompt: "0.001", completion: "0.001" }, + supported_parameters: ["tools"], + benchmarks: { + artificial_analysis: { intelligence_index: 70 }, + }, + top_provider: { max_completion_tokens: 65536 }, + }, + ]; + const { alle } = kandidatenBauen({ voll: grenzwert, konto: null }); + const abgelehnt: Array<{ id: string; grund: string }> = []; + const ablehnen = (id: string, grund: string) => { + abgelehnt.push({ id, grund }); + }; + const result = await billigsterUeberSchwelle( + 0, + 0, + alle, + undefined, + ablehnen, + new Map(), + { varianten: [] }, + ); + expect(result.auswahl?.candidate.id).toBe("bar/grenzwertig"); + expect(abgelehnt).toHaveLength(0); + }); +});