Files
OmniRoute/src/app/api/combos/test/route.ts
diegosouzapw 25bd04e400 feat(settings): unify routing rules and model aliases controls
Move model routing management into Settings and add a unified
model alias editor for exact and wildcard remaps.

Sync combo defaults with global routing strategy settings, add
localized combo onboarding copy, and expand routing i18n across
supported locales.

Also fix supporting routing behavior by accepting all strategy
values in settings schemas, preserving connection ids in combo
tests, honoring non-stream JSON requests for CC-compatible
providers, and handling hashed external package subpaths.
2026-04-12 18:43:19 -03:00

181 lines
5.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
import { getComboByName, getCombos } from "@/lib/localDb";
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo.ts";
import { testComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function buildComboTestResult(target, partial = {}) {
return {
model: target.modelStr,
provider: target.provider,
stepId: target.stepId,
executionKey: target.executionKey,
connectionId: target.connectionId,
label: target.label,
...partial,
};
}
async function testComboTarget(target, internalUrl) {
const startTime = Date.now();
try {
// Send a minimal but real chat request through the same internal
// endpoint an external OpenAI-compatible client would use.
const testBody = buildComboTestRequestBody(target.modelStr);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
let res;
try {
res = await fetch(internalUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Internal dashboard tests still use the normal /v1 pipeline but
// bypass REQUIRE_API_KEY so admins can test with local session auth.
"X-Internal-Test": "combo-health-check",
// Force a fresh execution path so combo tests cannot be satisfied by
// OmniRoute's semantic cache or other request reuse layers.
"X-OmniRoute-No-Cache": "true",
...(target.connectionId ? { "X-OmniRoute-Connection": target.connectionId } : {}),
"X-Request-Id": `combo-test-${randomUUID()}`,
},
body: JSON.stringify(testBody),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
const latencyMs = Date.now() - startTime;
if (res.ok) {
let responseBody = null;
try {
responseBody = await res.json();
} catch {
responseBody = null;
}
const responseText = extractComboTestResponseText(responseBody);
if (!responseText) {
return buildComboTestResult(target, {
status: "error",
statusCode: res.status,
error: "Provider returned HTTP 200 but no text content.",
latencyMs,
});
}
return buildComboTestResult(target, { status: "ok", latencyMs, responseText });
}
let errorMsg = "";
try {
const errBody = await res.json();
errorMsg = errBody?.error?.message || errBody?.error || res.statusText;
} catch {
errorMsg = res.statusText;
}
return buildComboTestResult(target, {
status: "error",
statusCode: res.status,
error: errorMsg,
latencyMs,
});
} catch (error) {
const latencyMs = Date.now() - startTime;
return buildComboTestResult(target, {
status: "error",
error: error.name === "AbortError" ? "Timeout (20s)" : error.message,
latencyMs,
});
}
}
/**
* POST /api/combos/test - Quick test a combo
* Sends a real chat completion request through each model in the combo
* and only reports success when the model returns usable text content.
*/
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(testComboSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { comboName } = validation.data;
const combo = await getComboByName(comboName);
if (!combo) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
const allCombos = await getCombos();
const targets = resolveNestedComboTargets(combo, allCombos);
if (targets.length === 0) {
return NextResponse.json({ error: "Combo has no models" }, { status: 400 });
}
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
const results = await Promise.all(
targets.map((target) => testComboTarget(target, internalUrl))
);
const resolvedResult = results.find((result) => result.status === "ok") || null;
const resolvedBy = resolvedResult?.model || null;
return NextResponse.json({
comboName,
strategy: combo.strategy || "priority",
resolvedBy,
resolvedByExecutionKey: resolvedResult?.executionKey || null,
resolvedByTarget: resolvedResult
? {
model: resolvedResult.model,
provider: resolvedResult.provider,
stepId: resolvedResult.stepId,
executionKey: resolvedResult.executionKey,
connectionId: resolvedResult.connectionId,
label: resolvedResult.label,
}
: null,
results,
testedAt: new Date().toISOString(),
});
} catch (error) {
console.log("Error testing combo:", error);
return NextResponse.json({ error: "Failed to test combo" }, { status: 500 });
}
}
/**
* Get the base URL for internal requests (VPS-safe: respects reverse proxy headers)
*/
function getBaseUrl(request) {
const fwdHost = request.headers.get("x-forwarded-host");
const fwdProto = request.headers.get("x-forwarded-proto") || "https";
if (fwdHost) return `${fwdProto}://${fwdHost}`;
const url = new URL(request.url);
return `${url.protocol}//${url.host}`;
}