Require live responses for combo tests (#735)

This commit is contained in:
Randi
2026-03-29 03:30:06 -04:00
committed by GitHub
parent 7690b364e7
commit ec06a345cc
5 changed files with 293 additions and 152 deletions

View File

@@ -1129,20 +1129,12 @@ function TestResultsView({ results }) {
className={`material-symbols-outlined text-[14px] ${
r.status === "ok"
? "text-emerald-500"
: r.status === "reachable"
? "text-amber-500"
: r.status === "skipped"
? "text-text-muted"
: "text-red-500"
: r.status === "skipped"
? "text-text-muted"
: "text-red-500"
}`}
>
{r.status === "ok"
? "check_circle"
: r.status === "reachable"
? "network_check"
: r.status === "skipped"
? "skip_next"
: "error"}
{r.status === "ok" ? "check_circle" : r.status === "skipped" ? "skip_next" : "error"}
</span>
<code className="font-mono flex-1">{r.model}</code>
{r.latencyMs !== undefined && <span className="text-text-muted">{r.latencyMs}ms</span>}
@@ -1150,11 +1142,9 @@ function TestResultsView({ results }) {
className={`text-[10px] uppercase font-medium ${
r.status === "ok"
? "text-emerald-500"
: r.status === "reachable"
? "text-amber-500"
: r.status === "skipped"
? "text-text-muted"
: "text-red-500"
: r.status === "skipped"
? "text-text-muted"
: "text-red-500"
}`}
>
{r.status}

View File

@@ -1,16 +1,13 @@
import { NextResponse } from "next/server";
import {
buildComboTestRequestBody,
probeComboModelReachability,
shouldProbeComboTestReachability,
} from "@/lib/combos/testHealth";
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
import { getComboByName } from "@/lib/localDb";
import { testComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* POST /api/combos/test - Quick test a combo
* Sends a minimal request through each model in the combo to verify availability
* 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;
@@ -53,34 +50,55 @@ export async function POST(request) {
for (const modelStr of models) {
const startTime = Date.now();
try {
// Send a minimal chat request to the internal SSE handler
// Use a tiny but realistic request body so gateway-routed models do not
// get flagged as dead just because the probe payload is too synthetic.
// Send a minimal but real chat request through the same internal
// endpoint an external OpenAI-compatible client would use.
const testBody = buildComboTestRequestBody(modelStr);
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000); // 20s timeout (was 15s, slow providers need more)
const timeout = setTimeout(() => controller.abort(), 20000);
const res = await fetch(internalUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Fix #350: bypass REQUIRE_API_KEY for internal admin combo tests
"X-Internal-Test": "combo-health-check",
},
body: JSON.stringify(testBody),
signal: controller.signal,
});
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",
},
body: JSON.stringify(testBody),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
clearTimeout(timeout);
const latencyMs = Date.now() - startTime;
if (res.ok) {
results.push({ model: modelStr, status: "ok", latencyMs });
let responseBody = null;
try {
responseBody = await res.json();
} catch {
responseBody = null;
}
const responseText = extractComboTestResponseText(responseBody);
if (!responseText) {
results.push({
model: modelStr,
status: "error",
statusCode: res.status,
error: "Provider returned HTTP 200 but no text content.",
latencyMs,
});
continue;
}
results.push({ model: modelStr, status: "ok", latencyMs, responseText });
if (!resolvedBy) resolvedBy = modelStr;
// For test, we can stop after first success (like a real combo would)
// But let's test all models to show full health
} else {
let errorMsg = "";
try {
@@ -90,28 +108,6 @@ export async function POST(request) {
errorMsg = res.statusText;
}
let reachability = null;
if (shouldProbeComboTestReachability(res.status)) {
try {
reachability = await probeComboModelReachability(modelStr);
} catch {
reachability = null;
}
}
if (reachability?.reachable) {
results.push({
model: modelStr,
status: "reachable",
statusCode: res.status,
error: errorMsg,
latencyMs,
provider: reachability.provider,
probeMethod: reachability.method,
});
continue;
}
results.push({
model: modelStr,
status: "error",
@@ -125,7 +121,7 @@ export async function POST(request) {
results.push({
model: modelStr,
status: "error",
error: error.name === "AbortError" ? "Timeout (15s)" : error.message,
error: error.name === "AbortError" ? "Timeout (20s)" : error.message,
latencyMs,
});
}

View File

@@ -1,74 +1,70 @@
import { validateProviderApiKey } from "@/lib/providers/validation";
import { getProviderCredentials } from "@/sse/services/auth";
import { getModelInfo } from "@/sse/services/model";
type JsonRecord = Record<string, unknown>;
const SOFT_REACHABILITY_STATUSES = new Set([400, 405, 406, 409, 422]);
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function extractTextFromContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";
return content
.map((part) => {
if (typeof part === "string") return part.trim();
const block = asRecord(part);
const blockType = typeof block.type === "string" ? block.type : "";
const blockText = typeof block.text === "string" ? block.text.trim() : "";
if (blockText && (blockType === "" || blockType === "text" || blockType === "output_text")) {
return blockText;
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
export function buildComboTestRequestBody(modelStr: string) {
return {
model: modelStr,
messages: [{ role: "user", content: "Reply with OK only." }],
// Some gateway-routed models reject ultra-tiny budgets during smoke tests.
// Keep this close to a real client request without inflating cost.
max_tokens: 16,
stream: false,
};
}
export function shouldProbeComboTestReachability(statusCode: number) {
return SOFT_REACHABILITY_STATUSES.has(Number(statusCode));
}
export function extractComboTestResponseText(responseBody: unknown): string {
const body = asRecord(responseBody);
type ProbeDeps = {
getModelInfo?: typeof getModelInfo;
getProviderCredentials?: typeof getProviderCredentials;
validateProviderApiKey?: typeof validateProviderApiKey;
};
export async function probeComboModelReachability(modelStr: string, deps: ProbeDeps = {}) {
const resolveModel = deps.getModelInfo || getModelInfo;
const loadCredentials = deps.getProviderCredentials || getProviderCredentials;
const validateKey = deps.validateProviderApiKey || validateProviderApiKey;
const modelInfo = await resolveModel(modelStr);
if (!modelInfo?.provider) {
return { reachable: false, reason: "unresolved_model" };
}
const credentials = await loadCredentials(
modelInfo.provider,
null,
null,
modelInfo.model || modelStr
);
if (!credentials || credentials.allRateLimited) {
return { reachable: false, reason: "credentials_unavailable" };
if (typeof body.output_text === "string" && body.output_text.trim()) {
return body.output_text.trim();
}
const apiKey = credentials.apiKey || credentials.accessToken;
if (typeof apiKey !== "string" || apiKey.trim().length === 0) {
return { reachable: false, reason: "missing_auth_material" };
if (Array.isArray(body.choices)) {
for (const choice of body.choices) {
const choiceRecord = asRecord(choice);
const message = asRecord(choiceRecord.message);
const messageText = extractTextFromContent(message.content);
if (messageText) return messageText;
if (typeof choiceRecord.text === "string" && choiceRecord.text.trim()) {
return choiceRecord.text.trim();
}
}
}
const providerSpecificData =
credentials.providerSpecificData && typeof credentials.providerSpecificData === "object"
? { ...credentials.providerSpecificData }
: {};
if (!providerSpecificData.validationModelId && modelInfo.model) {
providerSpecificData.validationModelId = modelInfo.model;
if (Array.isArray(body.output)) {
for (const item of body.output) {
const itemRecord = asRecord(item);
const contentText = extractTextFromContent(itemRecord.content);
if (contentText) return contentText;
}
}
const validation = await validateKey({
provider: modelInfo.provider,
apiKey,
providerSpecificData,
});
return {
reachable: Boolean(validation?.valid),
provider: modelInfo.provider,
model: modelInfo.model || null,
method: validation?.method || null,
warning: validation?.warning || null,
};
return extractTextFromContent(body.content);
}