mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
fix: harden combo fallback and health checks (#704)
This commit is contained in:
@@ -1122,18 +1122,27 @@ function TestResultsView({ results }) {
|
||||
{results.results?.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={r.error || undefined}
|
||||
className="flex items-center gap-2 text-xs px-2 py-1.5 rounded bg-black/[0.02] dark:bg-white/[0.02]"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${
|
||||
r.status === "ok"
|
||||
? "text-emerald-500"
|
||||
: r.status === "skipped"
|
||||
? "text-text-muted"
|
||||
: "text-red-500"
|
||||
: r.status === "reachable"
|
||||
? "text-amber-500"
|
||||
: r.status === "skipped"
|
||||
? "text-text-muted"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{r.status === "ok" ? "check_circle" : r.status === "skipped" ? "skip_next" : "error"}
|
||||
{r.status === "ok"
|
||||
? "check_circle"
|
||||
: r.status === "reachable"
|
||||
? "network_check"
|
||||
: 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>}
|
||||
@@ -1141,9 +1150,11 @@ function TestResultsView({ results }) {
|
||||
className={`text-[10px] uppercase font-medium ${
|
||||
r.status === "ok"
|
||||
? "text-emerald-500"
|
||||
: r.status === "skipped"
|
||||
? "text-text-muted"
|
||||
: "text-red-500"
|
||||
: r.status === "reachable"
|
||||
? "text-amber-500"
|
||||
: r.status === "skipped"
|
||||
? "text-text-muted"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{r.status}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
buildComboTestRequestBody,
|
||||
probeComboModelReachability,
|
||||
shouldProbeComboTestReachability,
|
||||
} from "@/lib/combos/testHealth";
|
||||
import { getComboByName } from "@/lib/localDb";
|
||||
import { testComboSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -49,13 +54,9 @@ export async function POST(request) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
// Send a minimal chat request to the internal SSE handler
|
||||
// Use OpenAI-compatible format — universally accepted by all providers via the translator
|
||||
const testBody = {
|
||||
model: modelStr,
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
max_tokens: 5,
|
||||
stream: false,
|
||||
};
|
||||
// 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.
|
||||
const testBody = buildComboTestRequestBody(modelStr);
|
||||
|
||||
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
@@ -88,6 +89,29 @@ export async function POST(request) {
|
||||
} catch {
|
||||
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",
|
||||
|
||||
74
src/lib/combos/testHealth.ts
Normal file
74
src/lib/combos/testHealth.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { validateProviderApiKey } from "@/lib/providers/validation";
|
||||
import { getProviderCredentials } from "@/sse/services/auth";
|
||||
import { getModelInfo } from "@/sse/services/model";
|
||||
|
||||
const SOFT_REACHABILITY_STATUSES = new Set([400, 405, 406, 409, 422]);
|
||||
|
||||
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.
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldProbeComboTestReachability(statusCode: number) {
|
||||
return SOFT_REACHABILITY_STATUSES.has(Number(statusCode));
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
const apiKey = credentials.apiKey || credentials.accessToken;
|
||||
if (typeof apiKey !== "string" || apiKey.trim().length === 0) {
|
||||
return { reachable: false, reason: "missing_auth_material" };
|
||||
}
|
||||
|
||||
const providerSpecificData =
|
||||
credentials.providerSpecificData && typeof credentials.providerSpecificData === "object"
|
||||
? { ...credentials.providerSpecificData }
|
||||
: {};
|
||||
|
||||
if (!providerSpecificData.validationModelId && modelInfo.model) {
|
||||
providerSpecificData.validationModelId = modelInfo.model;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user