mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(providers): add AgentRouter support and per-model health checks
Register AgentRouter across the provider registry, pricing, docs, and dashboard metadata so it appears as a first-class OpenAI-compatible passthrough option. Add a dedicated `/api/models/test` endpoint and provider-page controls for on-demand single-model diagnostics, including latency feedback and success/error status, to help verify mappings without triggering broader connection tests or rate limits. Align header casing expectations in provider validation tests with the current registry contract.
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
- **fix(mitm):** Compile MITM utilities as NodeNext ESM during prepublish, copy the CommonJS MITM server into the standalone artifact, and resolve MITM data paths without relying on Next.js aliases in packaged runtime.
|
||||
- **fix(build):** Move the local `.tmp/wine32` Wine prefix out of the isolated Next.js build path so Windows Electron packaging artifacts cannot trigger `EACCES` scans during Node 24 builds.
|
||||
- **fix(build):** Copy the `wreq-js` native runtime directory into the isolated Next.js standalone output so packaged Playwright/E2E starts can load the instrumentation hook on Linux.
|
||||
- **feat(providers):** Integrate AgentRouter as a new OpenAI-compatible passthrough provider with $200 free credits via sign-up (Issue #1572).
|
||||
- **feat(ui):** Implement on-demand per-model testing in the provider dashboard, allowing single-token diagnostic checks without triggering rate-limits (Issue #1532).
|
||||
- **fix(api):** Validate the Codex Responses websocket bridge and `/v1/batches` JSON payloads with Zod before use, keeping `request.json()` route validation green and returning explicit 400 responses for invalid bodies.
|
||||
- **fix(providers):** Add explicit typing to provider alias and category helpers so the strict `typecheck:noimplicit:core` CI gate passes.
|
||||
- **fix(ui):** Keep the upstream proxy provider detail page labeled with a fallback "Managed via Upstream Proxy Settings" management surface when translations are unavailable.
|
||||
|
||||
@@ -1160,6 +1160,7 @@ When minimized, OmniRoute lives in your system tray with quick actions:
|
||||
| | xAI Grok-4 (standard) | $0.20/$1.50 per 1M 🆕 | None | Reasoning flagship from xAI |
|
||||
| | Mistral | Free trial + paid | Rate limited | European AI |
|
||||
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
|
||||
| | AgentRouter 🆕 | Pay-per-use | None | $200 free credits at signup |
|
||||
| **💰 CHEAP** | GLM-5 (via Z.AI) 🆕 | $0.5/1M | Daily 10AM | 128K output, newest flagship |
|
||||
| | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
|
||||
| | MiniMax M2.5 🆕 | $0.3/1M input | 5-hour rolling | Reasoning + agentic tasks |
|
||||
|
||||
@@ -848,6 +848,22 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
agentrouter: {
|
||||
id: "agentrouter",
|
||||
alias: "agentrouter",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://agentrouter.org/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
defaultContextLength: 128000,
|
||||
headers: {
|
||||
"HTTP-Referer": "https://endpoint-proxy.local",
|
||||
"X-Title": "OmniRoute",
|
||||
},
|
||||
models: [{ id: "auto", name: "Auto (Best Available)" }],
|
||||
},
|
||||
|
||||
openrouter: {
|
||||
id: "openrouter",
|
||||
alias: "openrouter",
|
||||
|
||||
170
scripts/scratch/update_ui.cjs
Normal file
170
scripts/scratch/update_ui.cjs
Normal file
@@ -0,0 +1,170 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const path = 'src/app/(dashboard)/dashboard/providers/[id]/page.tsx';
|
||||
let content = fs.readFileSync(path, 'utf8');
|
||||
|
||||
// 1. Add ModelRowProps fields
|
||||
content = content.replace(
|
||||
' togglingHidden?: boolean;\n}',
|
||||
' togglingHidden?: boolean;\n onTestModel?: (modelId: string, fullModel: string) => Promise<void>;\n testStatus?: "ok" | "error" | null;\n testingModel?: boolean;\n}'
|
||||
);
|
||||
|
||||
// 2. Add PassthroughModelRowProps fields
|
||||
content = content.replace(
|
||||
' togglingHidden?: boolean;\n}',
|
||||
' togglingHidden?: boolean;\n onTestModel?: (modelId: string, fullModel: string) => Promise<void>;\n testStatus?: "ok" | "error" | null;\n testingModel?: boolean;\n}'
|
||||
);
|
||||
|
||||
// 3. Add PassthroughModelsSectionProps fields
|
||||
content = content.replace(
|
||||
' togglingModelId?: string | null;\n}',
|
||||
' togglingModelId?: string | null;\n onTestModel?: (modelId: string, fullModel: string) => Promise<void>;\n modelTestStatus?: Record<string, "ok" | "error" | null>;\n testingModelId?: string | null;\n}'
|
||||
);
|
||||
|
||||
// 4. Add CompatibleModelsSectionProps fields
|
||||
content = content.replace(
|
||||
' togglingModelId?: string | null;\n}',
|
||||
' togglingModelId?: string | null;\n onTestModel?: (modelId: string, fullModel: string) => Promise<void>;\n modelTestStatus?: Record<string, "ok" | "error" | null>;\n testingModelId?: string | null;\n}'
|
||||
);
|
||||
|
||||
// 5. Update ModelRow
|
||||
content = content.replace(
|
||||
' compatDisabled,\n onToggleHidden,\n togglingHidden,\n}: ModelRowProps) {',
|
||||
' compatDisabled,\n onToggleHidden,\n togglingHidden,\n onTestModel,\n testStatus,\n testingModel,\n}: ModelRowProps) {'
|
||||
);
|
||||
content = content.replace(
|
||||
' <div className="flex shrink-0 items-center gap-1">',
|
||||
` <div className="flex shrink-0 items-center gap-1">
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(model.id, fullModel)}
|
||||
disabled={testingModel}
|
||||
className={\`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed \${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}\`}
|
||||
title={testingModel ? t("testingModel", "Testing...") : testStatus === "ok" ? "OK" : testStatus === "error" ? "Error" : t("testModel", "Test Model")}
|
||||
>
|
||||
{testingModel ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
) : testStatus === "ok" ? (
|
||||
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||
) : testStatus === "error" ? (
|
||||
<span className="material-symbols-outlined text-sm">error</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">play_circle</span>
|
||||
)}
|
||||
</button>
|
||||
)}`
|
||||
);
|
||||
|
||||
// 6. Update PassthroughModelRow
|
||||
content = content.replace(
|
||||
' compatDisabled,\n onToggleHidden,\n togglingHidden,\n}: PassthroughModelRowProps) {',
|
||||
' compatDisabled,\n onToggleHidden,\n togglingHidden,\n onTestModel,\n testStatus,\n testingModel,\n}: PassthroughModelRowProps) {'
|
||||
);
|
||||
content = content.replace(
|
||||
' <div className="flex shrink-0 items-center gap-1 self-start">',
|
||||
` <div className="flex shrink-0 items-center gap-1 self-start">
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(modelId, fullModel)}
|
||||
disabled={testingModel}
|
||||
className={\`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed \${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}\`}
|
||||
title={testingModel ? t("testingModel", "Testing...") : testStatus === "ok" ? "OK" : testStatus === "error" ? "Error" : t("testModel", "Test Model")}
|
||||
>
|
||||
{testingModel ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
) : testStatus === "ok" ? (
|
||||
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||
) : testStatus === "error" ? (
|
||||
<span className="material-symbols-outlined text-sm">error</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">play_circle</span>
|
||||
)}
|
||||
</button>
|
||||
)}`
|
||||
);
|
||||
|
||||
// 7. Update PassthroughModelsSection component passing props down
|
||||
content = content.replace(
|
||||
' togglingModelId,\n}: PassthroughModelsSectionProps)',
|
||||
' togglingModelId,\n onTestModel,\n modelTestStatus,\n testingModelId,\n}: PassthroughModelsSectionProps)'
|
||||
);
|
||||
content = content.replace(
|
||||
' saveModelCompatFlags={saveModelCompatFlags}\n compatDisabled={compatSavingModelId === cm.id}',
|
||||
' saveModelCompatFlags={saveModelCompatFlags}\n compatDisabled={compatSavingModelId === cm.id}\n onTestModel={onTestModel}\n testStatus={modelTestStatus?.[cm.id] || null}\n testingModel={testingModelId === cm.id}'
|
||||
);
|
||||
content = content.replace(
|
||||
' compatDisabled={compatSavingModelId === alias}\n onToggleHidden={onToggleHidden}',
|
||||
' compatDisabled={compatSavingModelId === alias}\n onToggleHidden={onToggleHidden}\n onTestModel={onTestModel}\n testStatus={modelTestStatus?.[alias] || null}\n testingModel={testingModelId === alias}'
|
||||
);
|
||||
|
||||
// 8. Update CompatibleModelsSection component passing props down
|
||||
content = content.replace(
|
||||
' togglingModelId,\n}: CompatibleModelsSectionProps) {',
|
||||
' togglingModelId,\n onTestModel,\n modelTestStatus,\n testingModelId,\n}: CompatibleModelsSectionProps) {'
|
||||
);
|
||||
content = content.replace(
|
||||
' saveModelCompatFlags={saveModelCompatFlags}\n compatDisabled={compatSavingModelId === cm.id}',
|
||||
' saveModelCompatFlags={saveModelCompatFlags}\n compatDisabled={compatSavingModelId === cm.id}\n onTestModel={onTestModel}\n testStatus={modelTestStatus?.[cm.id] || null}\n testingModel={testingModelId === cm.id}'
|
||||
);
|
||||
content = content.replace(
|
||||
' compatDisabled={compatSavingModelId === model.id}\n onToggleHidden={(modelId, hidden)',
|
||||
' compatDisabled={compatSavingModelId === model.id}\n onTestModel={onTestModel}\n testStatus={modelTestStatus?.[model.id] || null}\n testingModel={testingModelId === model.id}\n onToggleHidden={(modelId, hidden)'
|
||||
);
|
||||
|
||||
// 9. Update ProviderDetailPage
|
||||
content = content.replace(
|
||||
' const [togglingModelId, setTogglingModelId] = useState<string | null>(null);',
|
||||
' const [togglingModelId, setTogglingModelId] = useState<string | null>(null);\n const [testingModelId, setTestingModelId] = useState<string | null>(null);\n const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});'
|
||||
);
|
||||
|
||||
// Add the onTestModel function
|
||||
content = content.replace(
|
||||
' const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {',
|
||||
` const onTestModel = async (modelId: string, fullModel: string) => {
|
||||
setTestingModelId(modelId);
|
||||
setModelTestStatus(prev => ({ ...prev, [modelId]: undefined }));
|
||||
try {
|
||||
const res = await fetch("/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerId: selectedConnection?.provider || providerNode?.id || providerId, modelId: fullModel })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === "ok") {
|
||||
notify.success(providerText(t, "testModelSuccess", \`Model \${modelId} is working. Latency: \${data.latencyMs}ms\`, { modelId, latencyMs: data.latencyMs }));
|
||||
setModelTestStatus(prev => ({ ...prev, [modelId]: "ok" }));
|
||||
} else {
|
||||
notify.error(data.error || "Model test failed");
|
||||
setModelTestStatus(prev => ({ ...prev, [modelId]: "error" }));
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error("Network error testing model");
|
||||
setModelTestStatus(prev => ({ ...prev, [modelId]: "error" }));
|
||||
} finally {
|
||||
setTestingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {`
|
||||
);
|
||||
|
||||
// Pass to PassthroughModelsSection in ProviderDetailPage
|
||||
content = content.replace(
|
||||
' togglingModelId={togglingModelId}\n />',
|
||||
' togglingModelId={togglingModelId}\n onTestModel={onTestModel}\n modelTestStatus={modelTestStatus}\n testingModelId={testingModelId}\n />'
|
||||
);
|
||||
|
||||
// Pass to CompatibleModelsSection in ProviderDetailPage
|
||||
content = content.replace(
|
||||
' togglingModelId={togglingModelId}\n />',
|
||||
' togglingModelId={togglingModelId}\n onTestModel={onTestModel}\n modelTestStatus={modelTestStatus}\n testingModelId={testingModelId}\n />'
|
||||
);
|
||||
|
||||
// Pass to ModelRow in ProviderDetailPage (fallback/normal models list)
|
||||
content = content.replace(
|
||||
' togglingHidden={togglingModelId === model.id}\n />',
|
||||
' togglingHidden={togglingModelId === model.id}\n onTestModel={onTestModel}\n testStatus={modelTestStatus[model.id] || null}\n testingModel={testingModelId === model.id}\n />'
|
||||
);
|
||||
|
||||
fs.writeFileSync(path, content);
|
||||
console.log("Successfully updated page.tsx");
|
||||
@@ -346,6 +346,9 @@ interface ModelRowProps {
|
||||
compatDisabled?: boolean;
|
||||
onToggleHidden?: (modelId: string, hidden: boolean) => Promise<void>;
|
||||
togglingHidden?: boolean;
|
||||
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
|
||||
testStatus?: "ok" | "error" | null;
|
||||
testingModel?: boolean;
|
||||
}
|
||||
|
||||
interface PassthroughModelRowProps {
|
||||
@@ -365,6 +368,9 @@ interface PassthroughModelRowProps {
|
||||
compatDisabled?: boolean;
|
||||
onToggleHidden?: (modelId: string, hidden: boolean) => Promise<void>;
|
||||
togglingHidden?: boolean;
|
||||
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
|
||||
testStatus?: "ok" | "error" | null;
|
||||
testingModel?: boolean;
|
||||
}
|
||||
|
||||
interface PassthroughModelsSectionProps {
|
||||
@@ -393,6 +399,9 @@ interface PassthroughModelsSectionProps {
|
||||
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
|
||||
bulkTogglePending?: boolean;
|
||||
togglingModelId?: string | null;
|
||||
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
|
||||
modelTestStatus?: Record<string, "ok" | "error" | null>;
|
||||
testingModelId?: string | null;
|
||||
}
|
||||
|
||||
interface CustomModelsSectionProps {
|
||||
@@ -440,6 +449,9 @@ interface CompatibleModelsSectionProps {
|
||||
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
|
||||
bulkTogglePending?: boolean;
|
||||
togglingModelId?: string | null;
|
||||
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
|
||||
modelTestStatus?: Record<string, "ok" | "error" | null>;
|
||||
testingModelId?: string | null;
|
||||
}
|
||||
|
||||
interface CooldownTimerProps {
|
||||
@@ -995,6 +1007,8 @@ export default function ProviderDetailPage() {
|
||||
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
|
||||
const [modelFilter, setModelFilter] = useState("");
|
||||
const [togglingModelId, setTogglingModelId] = useState<string | null>(null);
|
||||
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
||||
const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});
|
||||
const [bulkVisibilityAction, setBulkVisibilityAction] = useState<"select" | "deselect" | null>(
|
||||
null
|
||||
);
|
||||
@@ -1254,6 +1268,41 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
}, [loading, connections, loadConnProxies]);
|
||||
|
||||
const onTestModel = async (modelId: string, fullModel: string) => {
|
||||
setTestingModelId(modelId);
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined }));
|
||||
try {
|
||||
const res = await fetch("/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerId: selectedConnection?.provider || providerNode?.id || providerId,
|
||||
modelId: fullModel,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === "ok") {
|
||||
notify.success(
|
||||
providerText(
|
||||
t,
|
||||
"testModelSuccess",
|
||||
`Model ${modelId} is working. Latency: ${data.latencyMs}ms`,
|
||||
{ modelId, latencyMs: data.latencyMs }
|
||||
)
|
||||
);
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" }));
|
||||
} else {
|
||||
notify.error(data.error || "Model test failed");
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error("Network error testing model");
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
} finally {
|
||||
setTestingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {
|
||||
const fullModel = `${providerAliasOverride}/${modelId}`;
|
||||
try {
|
||||
@@ -2417,6 +2466,9 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
bulkTogglePending={bulkVisibilityAction !== null}
|
||||
togglingModelId={togglingModelId}
|
||||
onTestModel={onTestModel}
|
||||
modelTestStatus={modelTestStatus}
|
||||
testingModelId={testingModelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -2464,6 +2516,9 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
bulkTogglePending={bulkVisibilityAction !== null}
|
||||
togglingModelId={togglingModelId}
|
||||
onTestModel={onTestModel}
|
||||
modelTestStatus={modelTestStatus}
|
||||
testingModelId={testingModelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -2559,6 +2614,9 @@ export default function ProviderDetailPage() {
|
||||
handleToggleModelHidden(providerId, modelId, hidden)
|
||||
}
|
||||
togglingHidden={togglingModelId === model.id}
|
||||
onTestModel={onTestModel}
|
||||
testStatus={modelTestStatus[model.id] || null}
|
||||
testingModel={testingModelId === model.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -3416,6 +3474,9 @@ function ModelRow({
|
||||
compatDisabled,
|
||||
onToggleHidden,
|
||||
togglingHidden,
|
||||
onTestModel,
|
||||
testStatus,
|
||||
testingModel,
|
||||
}: ModelRowProps) {
|
||||
const isHidden = Boolean(model.isHidden);
|
||||
return (
|
||||
@@ -3446,6 +3507,34 @@ function ModelRow({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(model.id, fullModel)}
|
||||
disabled={testingModel}
|
||||
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
|
||||
title={
|
||||
testingModel
|
||||
? t("testingModel", "Testing...")
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
: t("testModel", "Test Model")
|
||||
}
|
||||
>
|
||||
{testingModel ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
) : testStatus === "ok" ? (
|
||||
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||
) : testStatus === "error" ? (
|
||||
<span className="material-symbols-outlined text-sm">error</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">play_circle</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
onClick={() => onToggleHidden(model.id, !isHidden)}
|
||||
@@ -3579,6 +3668,9 @@ function PassthroughModelsSection({
|
||||
onBulkToggleHidden,
|
||||
bulkTogglePending,
|
||||
togglingModelId,
|
||||
onTestModel,
|
||||
modelTestStatus,
|
||||
testingModelId,
|
||||
}: PassthroughModelsSectionProps) {
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -3764,6 +3856,9 @@ function PassthroughModelRow({
|
||||
compatDisabled,
|
||||
onToggleHidden,
|
||||
togglingHidden,
|
||||
onTestModel,
|
||||
testStatus,
|
||||
testingModel,
|
||||
}: PassthroughModelRowProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -3798,6 +3893,34 @@ function PassthroughModelRow({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 self-start">
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(modelId, fullModel)}
|
||||
disabled={testingModel}
|
||||
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
|
||||
title={
|
||||
testingModel
|
||||
? t("testingModel", "Testing...")
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
: t("testModel", "Test Model")
|
||||
}
|
||||
>
|
||||
{testingModel ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
) : testStatus === "ok" ? (
|
||||
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||
) : testStatus === "error" ? (
|
||||
<span className="material-symbols-outlined text-sm">error</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">play_circle</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
onClick={() => onToggleHidden(modelId, !isHidden)}
|
||||
@@ -4406,6 +4529,9 @@ function CompatibleModelsSection({
|
||||
onBulkToggleHidden,
|
||||
bulkTogglePending,
|
||||
togglingModelId,
|
||||
onTestModel,
|
||||
modelTestStatus,
|
||||
testingModelId,
|
||||
}: CompatibleModelsSectionProps) {
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
135
src/app/api/models/test/route.ts
Normal file
135
src/app/api/models/test/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
|
||||
import { z } from "zod";
|
||||
|
||||
const testModelSchema = z.object({
|
||||
providerId: z.string().min(1),
|
||||
modelId: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the base URL for internal requests (VPS-safe: respects reverse proxy headers)
|
||||
*/
|
||||
function getBaseUrl(request: 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}`;
|
||||
}
|
||||
|
||||
export async function POST(request: 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 = testModelSchema.safeParse(rawBody);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json({ error: validation.error.format() }, { status: 400 });
|
||||
}
|
||||
const { providerId, modelId } = validation.data;
|
||||
|
||||
// Construct target format (providerId/modelId)
|
||||
// Some models (like free alias models) might not need the prefix if it's an alias.
|
||||
// However, the wildcard router expects provider/model.
|
||||
let fullModelStr = modelId;
|
||||
if (!fullModelStr.includes("/")) {
|
||||
fullModelStr = `${providerId}/${modelId}`;
|
||||
}
|
||||
|
||||
const baseInternalUrl = getBaseUrl(request);
|
||||
const startTime = Date.now();
|
||||
const isEmbedding =
|
||||
fullModelStr.toLowerCase().includes("embedding") ||
|
||||
fullModelStr.toLowerCase().includes("bge-") ||
|
||||
fullModelStr.toLowerCase().includes("text-embed");
|
||||
|
||||
const internalUrl = `${baseInternalUrl}/v1/${isEmbedding ? "embeddings" : "chat/completions"}`;
|
||||
const testBody = buildComboTestRequestBody(fullModelStr, isEmbedding);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 20000);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(internalUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Test": "model-health-check",
|
||||
"X-OmniRoute-No-Cache": "true",
|
||||
"X-Request-Id": `model-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 && !isEmbedding) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
statusCode: res.status,
|
||||
error: "Provider returned HTTP 200 but no text content.",
|
||||
latencyMs,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ 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 NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
statusCode: res.status,
|
||||
error: errorMsg,
|
||||
latencyMs,
|
||||
},
|
||||
{ status: res.status }
|
||||
);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
error: error.name === "AbortError" ? "Timeout (20s)" : error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -491,6 +491,9 @@
|
||||
"noActiveProvidersDesc": "Please add and connect providers first to configure CLI tools.",
|
||||
"mapModels": "Map Models",
|
||||
"testConnection": "Test Connection",
|
||||
"testModel": "Test Model",
|
||||
"testingModel": "Testing...",
|
||||
"testModelSuccess": "Model {modelId} working. Latency: {latencyMs}ms",
|
||||
"connectionStatus": "Connection Status",
|
||||
"configureEndpoint": "Configure Endpoint",
|
||||
"instructions": "Instructions",
|
||||
|
||||
@@ -18,6 +18,7 @@ export const API_ENDPOINTS = {
|
||||
|
||||
// Provider API endpoints (for display only)
|
||||
export const PROVIDER_ENDPOINTS = {
|
||||
agentrouter: "https://agentrouter.org/v1/chat/completions",
|
||||
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
||||
glm: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
glmt: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
|
||||
@@ -797,6 +797,9 @@ export const DEFAULT_PRICING = {
|
||||
},
|
||||
|
||||
// OpenRouter
|
||||
agentrouter: {
|
||||
auto: { input: 2.0, output: 8.0 },
|
||||
},
|
||||
openrouter: {
|
||||
auto: {
|
||||
input: 2.0,
|
||||
|
||||
@@ -141,6 +141,17 @@ export const WEB_COOKIE_PROVIDERS = {
|
||||
|
||||
// API Key Providers
|
||||
export const APIKEY_PROVIDERS = {
|
||||
agentrouter: {
|
||||
id: "agentrouter",
|
||||
alias: "agentrouter",
|
||||
name: "AgentRouter",
|
||||
icon: "router",
|
||||
color: "#10B981",
|
||||
textIcon: "AR",
|
||||
passthroughModels: true,
|
||||
website: "https://agentrouter.org",
|
||||
apiHint: "Get $200 free credits at https://agentrouter.org/register — no credit card required.",
|
||||
},
|
||||
openrouter: {
|
||||
id: "openrouter",
|
||||
alias: "openrouter",
|
||||
|
||||
@@ -79,7 +79,7 @@ test("kimi-coding-apikey validation uses Kimi Coding messages endpoint", async (
|
||||
assert.equal(calls[0].url, "https://api.kimi.com/coding/v1/messages");
|
||||
assert.equal(calls[0].method, "POST");
|
||||
assert.equal(calls[0].headers["x-api-key"], "sk-kimi-test");
|
||||
assert.equal(calls[0].headers["anthropic-version"], "2023-06-01");
|
||||
assert.equal(calls[0].headers["Anthropic-Version"], "2023-06-01");
|
||||
|
||||
for (const call of calls) {
|
||||
assert.equal(call.url.includes("?beta=true/messages"), false);
|
||||
|
||||
@@ -33,10 +33,10 @@ test("T20: gemini CLI fingerprint uses 0.31.0 and preserves darwin platform name
|
||||
|
||||
test("T25: anthropic API-key config includes the full Anthropic beta header set", () => {
|
||||
const anthropic = REGISTRY.anthropic;
|
||||
assert.equal(anthropic.headers["anthropic-version"], "2023-06-01");
|
||||
assert.ok(anthropic.headers["anthropic-beta"]?.includes("advanced-tool-use-2025-11-20"));
|
||||
assert.ok(anthropic.headers["anthropic-beta"]?.includes("structured-outputs-2025-12-15"));
|
||||
assert.ok(anthropic.headers["anthropic-beta"]?.includes("token-efficient-tools-2026-03-28"));
|
||||
assert.equal(anthropic.headers["Anthropic-Version"], "2023-06-01");
|
||||
assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("advanced-tool-use-2025-11-20"));
|
||||
assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("structured-outputs-2025-12-15"));
|
||||
assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("token-efficient-tools-2026-03-28"));
|
||||
});
|
||||
|
||||
test("T22: github headers include updated editor/plugin versions and required fields", () => {
|
||||
|
||||
Reference in New Issue
Block a user