Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/app/api/combos/auto/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,19 @@ export async function GET(request: Request) {
if (authError) return authError;

try {
const { createVirtualAutoCombo } =
const { prepareVirtualAutoComboInputs, createVirtualAutoComboFromPrepared } =
await import("@omniroute/open-sse/services/autoCombo/virtualFactory");

// #14889: every variant below is built from the same candidate pool, so prepare
// it once per request. createVirtualAutoCombo() prepares it again on each call,
// which made this route rebuild the whole pool once per listed variant.
const prepared = await prepareVirtualAutoComboInputs();

const combos = [];
const seenIds = new Set<string>();
for (const { variant, name } of ALL_VARIANTS) {
try {
const virtual = await createVirtualAutoCombo(variant);
const virtual = await createVirtualAutoComboFromPrepared(prepared, variant);
const id = variant ? `auto/${variant}` : "auto";
seenIds.add(id);
combos.push({
Expand Down Expand Up @@ -68,7 +73,7 @@ export async function GET(request: Request) {
try {
const variant = AUTO_TEMPLATE_VARIANTS[modelStr];
const spec = modelStr === "auto/best-free" ? { tier: "free" as const } : undefined;
const virtual = await createVirtualAutoCombo(variant, spec);
const virtual = await createVirtualAutoComboFromPrepared(prepared, variant, spec);

const displayName = variant
? `Auto ${variant.charAt(0).toUpperCase() + variant.slice(1)}`
Expand Down Expand Up @@ -107,7 +112,7 @@ export async function GET(request: Request) {
const parsed = parseAutoSuffix(suffix);
if (!parsed.valid) continue;

const virtual = await createVirtualAutoCombo(undefined, {
const virtual = await createVirtualAutoComboFromPrepared(prepared, undefined, {
category: parsed.category,
tier: parsed.tier,
});
Expand Down Expand Up @@ -150,7 +155,9 @@ export async function GET(request: Request) {
if (seenIds.has(modelStr)) continue;
try {
const suffix = modelStr.slice("auto/".length);
const virtual = await createVirtualAutoCombo(undefined, { family: suffix });
const virtual = await createVirtualAutoComboFromPrepared(prepared, undefined, {
family: suffix,
});

const displayName = `Auto ${suffix.charAt(0).toUpperCase() + suffix.slice(1)}`;

Expand Down
82 changes: 82 additions & 0 deletions tests/unit/14889-combos-auto-prepare-once.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* #14889 — GET /api/combos/auto must build the candidate pool once per request.
*
* The route lists every auto variant (bare "auto", the named variants, template,
* suffix and family variants) and called createVirtualAutoCombo() for each one.
* createVirtualAutoCombo() runs prepareVirtualAutoComboInputs() every time, so one
* request rebuilt the whole candidate pool once per variant. On a real install that
* made the endpoint take tens of seconds and hold the event loop the whole time.
*
* Pool preparation yields to the event loop (setImmediate) every few candidates and
* nothing else on this path does, so counting setImmediate calls measures how much
* preparation a request did. mock.module() is not usable under this tsx/ESM runner.
*/
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";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-14889-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "combos-auto-14889-test-secret";

const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const virtualFactory = await import("../../open-sse/services/autoCombo/virtualFactory.ts");
const combosAutoRoute = await import("../../src/app/api/combos/auto/route.ts");

test.after(() => {
core.resetDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
// best-effort cleanup
}
});

async function countYields(fn: () => Promise<unknown>): Promise<number> {
const realSetImmediate = globalThis.setImmediate;
let count = 0;
globalThis.setImmediate = ((...args: Parameters<typeof setImmediate>) => {
count++;
return realSetImmediate(...args);
}) as typeof setImmediate;
try {
await fn();
} finally {
globalThis.setImmediate = realSetImmediate;
}
return count;
}

test("GET /api/combos/auto prepares the candidate pool once, not once per variant", async () => {
await settingsDb.updateSettings({ requireLogin: false });
// Enough API-key providers that a single pool preparation yields at least once.
for (const provider of ["openai", "anthropic", "gemini", "groq", "deepseek", "mistral"]) {
await providersDb.createProviderConnection({
provider,
authType: "apikey",
apiKey: `sk-test-14889-${provider}`,
name: `test-14889-${provider}`,
isActive: true,
});
}

const onePreparation = await countYields(() => virtualFactory.prepareVirtualAutoComboInputs());
assert.ok(onePreparation > 0, "the seeded pool should be large enough to yield while preparing");

let combos: Array<{ id: string }> = [];
const routeYields = await countYields(async () => {
const res = await combosAutoRoute.GET(new Request("http://localhost/api/combos/auto"));
combos = (await res.json()).combos;
});

assert.ok(combos.length > 1, "the route should list several auto variants");
assert.equal(
routeYields,
onePreparation,
`listing ${combos.length} variants should prepare the pool once`
);
});
Loading