feat(plugin+api): auto combos + free model quota display + /api/combos/auto (#3435)

Integrated into release/v3.8.17
This commit is contained in:
M.M
2026-06-09 00:08:17 +02:00
committed by GitHub
parent 617a648088
commit 003e6a80b7
7 changed files with 2066 additions and 439 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
/**
* Structured logger for the OmniRoute plugin.
*
* Levels: error < warn < info < debug
* Default: warn (matches current console.warn behavior)
* Set via features.logLevel in plugin options.
*/
export type LogLevel = "error" | "warn" | "info" | "debug";
const LEVEL_ORDER: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
const TAG = "[omniroute-plugin]";
function shouldLog(current: LogLevel, target: LogLevel): boolean {
return LEVEL_ORDER[current] >= LEVEL_ORDER[target];
}
let _level: LogLevel = "warn";
export function setLogLevel(level: LogLevel): void {
_level = level;
}
export function getLogLevel(): LogLevel {
return _level;
}
function fmt(level: LogLevel, msg: string, tag?: string): string {
const prefix = tag ? `${TAG}${tag}` : TAG;
return `${prefix} [${level.toUpperCase()}] ${msg}`;
}
export const logger = {
error(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
},
warn(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
},
info(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
},
debug(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
},
/** Always emit regardless of level (for critical init breadcrumbs). */
always(msg: string, ...args: unknown[]): void {
console.warn(TAG, msg, ...args);
},
// ── Tagged child loggers ──────────────────────────────────────────────
child(tag: string) {
return {
error: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "error") &&
console.error(fmt("error", msg, tag), ...args),
warn: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "warn") &&
console.warn(fmt("warn", msg, tag), ...args),
info: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "info") &&
console.warn(fmt("info", msg, tag), ...args),
debug: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "debug") &&
console.warn(fmt("debug", msg, tag), ...args),
};
},
};

View File

@@ -0,0 +1,296 @@
/**
* Universal model naming template for the OmniRoute plugin.
*
* Naming pipeline:
* [tag] <provider-label><separator><display-name><suffix>
*
* [Free] <provider> - <name> · <budget> ← free model
* Auto: <variant> (<N>p) ← auto combo
* Combo: <name> ← DB combo
* <provider> - <name> ← regular model
*/
// ── Constants ────────────────────────────────────────────────────────────
/** Separator between provider label and model display name. */
export const PROVIDER_TAG_SEPARATOR = " - ";
/** Threshold beyond which providerDisplayName is abbreviated. */
const PROVIDER_LABEL_MAX_CHARS = 12;
/** Aliases longer than this get title-case instead of UPPER. */
const ALIAS_UPPER_MAX_CHARS = 5;
// ── Auto Combo Types ─────────────────────────────────────────────────────
export type AutoVariant =
| "coding"
| "fast"
| "cheap"
| "offline"
| "smart"
| "lkgp";
export const AUTO_VARIANTS: AutoVariant[] = [
"coding",
"fast",
"cheap",
"offline",
"smart",
"lkgp",
];
export const AUTO_VARIANT_DESCRIPTIONS: Record<
AutoVariant | "default",
string
> = {
default: "Best provider via scoring",
coding: "Quality-first for code tasks",
fast: "Latency-optimized routing",
cheap: "Cost-optimized routing",
offline: "Offline-friendly providers",
smart: "Quality-first with exploration",
lkgp: "Last-Known-Good-Provider routing",
};
// ── Free Model Types ─────────────────────────────────────────────────────
export type FreeModelFreeType =
| "recurring-daily"
| "recurring-monthly"
| "recurring-credit"
| "one-time-initial"
| "keyless"
| "discontinued";
// ── Provider Label ────────────────────────────────────────────────────────
/**
* Title-case a long, lowercase-looking alias.
* `antigravity` → `Antigravity`
*/
function titleCaseAlias(alias: string): string {
if (alias.length === 0) return alias;
return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase();
}
/**
* Pick the short label for an upstream provider.
*
* Rules:
* 1. Trim `providerDisplayName`. If ≤12 chars → use verbatim.
* 2. Alias ≤5 chars → UPPER(alias). Alias >5 → titleCase.
* 3. Neither → undefined.
*/
export function shortProviderLabel(
enrichment:
| { providerDisplayName?: string; providerAlias?: string }
| undefined,
): string | undefined {
if (!enrichment) return undefined;
const raw =
typeof enrichment.providerDisplayName === "string"
? enrichment.providerDisplayName.trim()
: "";
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
const alias =
typeof enrichment.providerAlias === "string"
? enrichment.providerAlias.trim()
: "";
if (alias.length > 0) {
return alias.length <= ALIAS_UPPER_MAX_CHARS
? alias.toUpperCase()
: titleCaseAlias(alias);
}
return undefined;
}
// ── Free Label ────────────────────────────────────────────────────────────
/**
* Normalise display name so free-tier models get a consistent `[Free] ` prefix.
*
* "GPT-4.1 (Free)" → "[Free] GPT-4.1"
* "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash"
* "Claude Opus 4.7" → "Claude Opus 4.7" (unchanged)
*/
export function normaliseFreeLabel(name: string): string {
const cleaned = name
.replace(/\s*\(free\)\s*$/i, "")
.replace(/[\s-]+free\s*$/i, "")
.trim();
const wasFree = cleaned.length < name.trim().length;
if (!wasFree) return name;
return `[Free] ${cleaned}`;
}
// ── Free Budget Formatting ────────────────────────────────────────────────
function fmtTokens(n: number): string {
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
return String(n);
}
/**
* Format a free model budget into a short human-readable suffix.
*
* recurring-daily → "25M tokens/day"
* recurring-monthly → "25M tokens/month"
* recurring-credit → "10M credits"
* one-time-initial → "1M credits (one-time)"
* keyless → "(keyless)"
* discontinued → "(discontinued)"
*/
export function formatFreeBudget(params: {
freeType: FreeModelFreeType;
monthlyTokens?: number;
creditTokens?: number;
}): string {
const { freeType, monthlyTokens = 0, creditTokens = 0 } = params;
switch (freeType) {
case "recurring-daily":
return `${fmtTokens(monthlyTokens)} tokens/day`;
case "recurring-monthly":
return `${fmtTokens(monthlyTokens)} tokens/month`;
case "recurring-credit":
return `${fmtTokens(creditTokens)} credits`;
case "one-time-initial":
return `${fmtTokens(creditTokens)} credits (one-time)`;
case "keyless":
return "(keyless)";
case "discontinued":
return "(discontinued)";
default:
return "";
}
}
// ── Auto Combo Naming ─────────────────────────────────────────────────────
/**
* Format auto combo display name.
*
* "Auto: Coding (4p)"
* "Auto: Default (6p)"
* "Auto" (no candidate count when unknown)
*/
export function formatAutoComboName(
variant: AutoVariant | undefined,
candidateCount?: number,
): string {
const label = variant
? variant.charAt(0).toUpperCase() + variant.slice(1)
: "Default";
const count =
typeof candidateCount === "number" && candidateCount > 0
? ` (${candidateCount}p)`
: "";
return `Auto: ${label}${count}`;
}
/**
* Build the model ID for an auto combo entry.
* "auto/coding", "auto/fast", "auto" (default).
*/
export function autoComboModelId(variant: AutoVariant | undefined): string {
return variant ? `auto/${variant}` : "auto";
}
// ── Universal Display Name Builder ────────────────────────────────────────
export interface ModelDisplayNameParams {
/** Raw model ID (e.g. "cc/claude-sonnet-4-6"). */
rawId: string;
/** Enrichment display name (e.g. "Claude Sonnet 4.6"). */
enrichmentName?: string;
/** Provider tag enrichment. */
providerAlias?: string;
/** Human-readable upstream provider label. */
providerDisplayName?: string;
/** Whether model is free tier. */
isFree?: boolean;
/** Free model budget info. */
freeType?: FreeModelFreeType;
/** Monthly token budget (for recurring free models). */
monthlyTokens?: number;
/** Credit token budget (for credit-based free models). */
creditTokens?: number;
/** Whether this is a combo entry (skip provider tag). */
isCombo?: boolean;
/** Whether this is an auto combo entry. */
isAutoCombo?: boolean;
/** Auto combo variant. */
autoVariant?: AutoVariant;
/** Auto combo candidate count. */
autoCandidateCount?: number;
}
/**
* Build the final display name following the universal template.
*
* Priority:
* 1. Auto combo → "Auto: <variant> (<N>p)"
* 2. DB combo → "Combo: <name>"
* 3. Free + enrichment + provider tag → "[Free] <label> - <name> · <budget>"
* 4. Free + enrichment → "[Free] <name> · <budget>"
* 5. Free + raw → "[Free] <rawId> · <budget>"
* 6. Enrichment + provider tag → "<label> - <name>"
* 7. Enrichment only → "<name>"
* 8. Raw fallback → normaliseFreeLabel(rawId)
*/
export function buildModelDisplayName(params: ModelDisplayNameParams): string {
// Auto combos
if (params.isAutoCombo) {
return formatAutoComboName(params.autoVariant, params.autoCandidateCount);
}
// Determine base name — strip any existing free suffix first
const rawBase =
params.enrichmentName && params.enrichmentName.trim().length > 0
? params.enrichmentName
: params.rawId;
const cleanedBase = rawBase
.replace(/\s*\(free\)\s*$/i, "")
.replace(/[\s-]+free\s*$/i, "")
.trim();
const wasFree = cleanedBase.length < rawBase.trim().length;
const isFree = !!params.isFree || wasFree;
let baseName = cleanedBase;
// Provider tag (skip for combos)
if (!params.isCombo) {
const label = shortProviderLabel({
providerDisplayName: params.providerDisplayName,
providerAlias: params.providerAlias,
});
if (label) {
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
if (!baseName.startsWith(prefix)) {
baseName = `${prefix}${baseName}`;
}
}
}
// Prepend [Free] if applicable (AFTER provider tag for correct ordering)
if (isFree) {
baseName = `[Free] ${baseName}`;
}
// Free budget suffix
if (isFree && params.freeType) {
const budget = formatFreeBudget({
freeType: params.freeType,
monthlyTokens: params.monthlyTokens,
creditTokens: params.creditTokens,
});
if (budget) {
baseName = `${baseName} · ${budget}`;
}
}
return baseName;
}

View File

@@ -6,7 +6,14 @@ export interface AutoPrefixParseResult {
error?: string; error?: string;
} }
const VALID_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "smart", "lkgp"]; export const VALID_VARIANTS: AutoVariant[] = [
"coding",
"fast",
"cheap",
"offline",
"smart",
"lkgp",
];
/** /**
* Parses a model name to determine if it's an auto-prefixed model and extracts the variant. * Parses a model name to determine if it's an auto-prefixed model and extracts the variant.

View File

@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
VALID_VARIANTS,
type AutoVariant,
} from "@omniroute/open-sse/services/autoCombo/autoPrefix";
const ALL_VARIANTS: Array<{ variant: AutoVariant | undefined; name: string }> = [
{ variant: undefined, name: "Auto" },
...VALID_VARIANTS.map((v) => ({
variant: v,
name: `Auto ${v.charAt(0).toUpperCase() + v.slice(1)}`,
})),
];
// GET /api/combos/auto - List available auto combo variants with candidate info
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { createVirtualAutoCombo } =
await import("@omniroute/open-sse/services/autoCombo/virtualFactory");
const combos = [];
for (const { variant, name } of ALL_VARIANTS) {
try {
const virtual = await createVirtualAutoCombo(variant);
combos.push({
id: variant ? `auto/${variant}` : "auto",
name,
variant: variant ?? null,
type: "auto",
isHidden: false,
candidatePool: virtual.candidatePool ?? [],
candidateCount: virtual.candidatePool?.length ?? 0,
config: virtual.config ?? {},
});
} catch {
// Individual variant failure — skip, don't break the whole list
}
}
return NextResponse.json({ combos });
} catch (error) {
console.error("Error fetching auto combos:", error);
return NextResponse.json({ combos: [] });
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { FREE_MODEL_BUDGETS } from "@omniroute/open-sse/config/freeModelCatalog";
// GET /api/free-models - List free model budgets for plugin enrichment
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const models = FREE_MODEL_BUDGETS.map((m) => ({
provider: m.provider,
modelId: m.modelId,
displayName: m.displayName,
monthlyTokens: m.monthlyTokens,
creditTokens: m.creditTokens,
freeType: m.freeType,
poolKey: m.poolKey,
tos: m.tos,
}));
return NextResponse.json({ models });
} catch (error) {
console.error("Error fetching free models:", error);
return NextResponse.json({ models: [] });
}
}

View File

@@ -0,0 +1,139 @@
/**
* Unit tests for GET /api/combos/auto and GET /api/free-models (PR #3435).
*
* Rule #18 regression coverage for two new endpoints added by mm/auto-combos-plugin.
* Tests: happy-path response shape, auth gate (401/403 when requireManagementAuth blocks).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── DB / auth setup ───────────────────────────────────────────────────────────
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-auto-combos-free-models-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "auto-combos-free-models-test-secret";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
// Routes loaded AFTER env is set
const combosAutoRoute = await import("../../src/app/api/combos/auto/route.ts");
const freeModelsRoute = await import("../../src/app/api/free-models/route.ts");
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeRequest(url: string, apiKey?: string): Request {
return new Request(url, {
method: "GET",
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {},
});
}
test.after(() => {
core.resetDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
});
// ── /api/free-models ──────────────────────────────────────────────────────────
test("GET /api/free-models returns 200 with models array (no auth required by default)", async () => {
await settingsDb.updateSettings({ requireLogin: false });
const req = makeRequest("http://localhost/api/free-models");
const res = await freeModelsRoute.GET(req as never);
const body = await res.json();
assert.equal(res.status, 200);
assert.ok(Array.isArray(body.models), "body.models should be an array");
assert.ok(body.models.length > 0, "should have at least one free model entry");
const first = body.models[0];
assert.ok(typeof first.provider === "string", "model.provider should be a string");
assert.ok(typeof first.modelId === "string", "model.modelId should be a string");
assert.ok(typeof first.monthlyTokens === "number", "model.monthlyTokens should be a number");
});
test("GET /api/free-models returns 401/403 when auth is required and no token provided", async () => {
await settingsDb.updateSettings({ requireLogin: true });
process.env.INITIAL_PASSWORD = "test-password-free-models";
const req = makeRequest("http://localhost/api/free-models");
const res = await freeModelsRoute.GET(req as never);
assert.ok(
res.status === 401 || res.status === 403,
`Expected 401 or 403 without auth, got ${res.status}`
);
await settingsDb.updateSettings({ requireLogin: false });
delete process.env.INITIAL_PASSWORD;
});
test("GET /api/free-models returns 200 with valid management API key", async () => {
await settingsDb.updateSettings({ requireLogin: true });
process.env.INITIAL_PASSWORD = "test-password-free-models2";
const { key } = await apiKeysDb.createApiKey("free-models-test", "machine-free-models", [
"manage",
]);
const req = makeRequest("http://localhost/api/free-models", key);
const res = await freeModelsRoute.GET(req as never);
const body = await res.json();
assert.equal(res.status, 200);
assert.ok(Array.isArray(body.models));
await settingsDb.updateSettings({ requireLogin: false });
delete process.env.INITIAL_PASSWORD;
});
// ── /api/combos/auto ─────────────────────────────────────────────────────────
test("GET /api/combos/auto returns 200 with combos array (no auth required by default)", async () => {
await settingsDb.updateSettings({ requireLogin: false });
const req = makeRequest("http://localhost/api/combos/auto");
const res = await combosAutoRoute.GET(req as never);
const body = await res.json();
assert.equal(res.status, 200);
assert.ok(Array.isArray(body.combos), "body.combos should be an array");
});
test("GET /api/combos/auto returns 401/403 when auth is required and no token provided", async () => {
await settingsDb.updateSettings({ requireLogin: true });
process.env.INITIAL_PASSWORD = "test-password-combos-auto";
const req = makeRequest("http://localhost/api/combos/auto");
const res = await combosAutoRoute.GET(req as never);
assert.ok(
res.status === 401 || res.status === 403,
`Expected 401 or 403 without auth, got ${res.status}`
);
await settingsDb.updateSettings({ requireLogin: false });
delete process.env.INITIAL_PASSWORD;
});
test("GET /api/combos/auto soft-fails and returns empty array on createVirtualAutoCombo error", async () => {
await settingsDb.updateSettings({ requireLogin: false });
// Even when virtualFactory throws (no combos configured), route should return empty array
const req = makeRequest("http://localhost/api/combos/auto");
const res = await combosAutoRoute.GET(req as never);
const body = await res.json();
assert.equal(res.status, 200);
assert.ok(Array.isArray(body.combos), "should always return an array even on errors");
});