llmrouter-Auswahl: Gate 65536 Output-Token für Arbeitsmodelle (42i/intern#1283)
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
This commit is contained in:
@@ -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<Auswahl | null> {
|
||||
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<string, string>,
|
||||
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 }]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user