mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(plugin+api): auto combos + free model quota display + /api/combos/auto (#3435)
Integrated into release/v3.8.17
This commit is contained in:
File diff suppressed because it is too large
Load Diff
74
@omniroute/opencode-plugin/src/logger.ts
Normal file
74
@omniroute/opencode-plugin/src/logger.ts
Normal 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),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
296
@omniroute/opencode-plugin/src/naming.ts
Normal file
296
@omniroute/opencode-plugin/src/naming.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
49
src/app/api/combos/auto/route.ts
Normal file
49
src/app/api/combos/auto/route.ts
Normal 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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/api/free-models/route.ts
Normal file
27
src/app/api/free-models/route.ts
Normal 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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
139
tests/unit/auto-combos-free-models-routes.test.ts
Normal file
139
tests/unit/auto-combos-free-models-routes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user