ai_api / src /app /api /combos /test /route.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
6.9 kB
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
import { getApiKeys, getComboByName, getCombos } from "@/lib/localDb";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo.ts";
import { testComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
async function getInternalApiKey(): Promise<string | null> {
try {
const keys = await getApiKeys();
const active = (
keys as Array<{ key: string; isActive?: boolean; revokedAt?: string | null }>
).find((k) => k.key && k.isActive !== false && !k.revokedAt);
return active?.key ?? null;
} catch {
return null;
}
}
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, baseInternalUrl, internalApiKey: string | null) {
const startTime = Date.now();
try {
// Issue #2359: combo entries with a malformed/missing modelStr surfaced
// as `e.startsWith is not a function` / similar TypeError 500s. Coerce
// defensively at the boundary so the test path returns a clean error
// instead of crashing the request handler.
const modelStr = typeof target?.modelStr === "string" ? target.modelStr : "";
if (!modelStr) {
return buildComboTestResult(target, {
status: "error",
error: "Combo step is missing a model id (modelStr). Re-save the combo to refresh it.",
latencyMs: 0,
});
}
const modelLower = modelStr.toLowerCase();
const isEmbedding =
modelLower.includes("embedding") ||
modelLower.includes("bge-") ||
modelLower.includes("text-embed");
const internalUrl = `${baseInternalUrl}/v1/${isEmbedding ? "embeddings" : "chat/completions"}`;
const testBody = buildComboTestRequestBody(modelStr, isEmbedding);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
let res;
try {
res = await fetch(internalUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(internalApiKey ? { Authorization: `Bearer ${internalApiKey}` } : {}),
"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) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
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 baseInternalUrl = getInternalBaseUrl();
const internalApiKey = await getInternalApiKey();
const results = await Promise.all(
targets.map((target) => testComboTarget(target, baseInternalUrl, internalApiKey))
);
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 });
}
}
function getInternalBaseUrl(): string {
const { apiPort } = getRuntimePorts();
return `http://127.0.0.1:${apiPort}`;
}