From 8684d43ebd953866a17806dd5dc232b1ce3d51d4 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 25 Apr 2026 09:19:20 -0300 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + README.md | 1 + open-sse/config/providerRegistry.ts | 16 ++ scripts/scratch/update_ui.cjs | 170 ++++++++++++++++++ .../dashboard/providers/[id]/page.tsx | 126 +++++++++++++ src/app/api/models/test/route.ts | 135 ++++++++++++++ src/i18n/messages/en.json | 3 + src/shared/constants/config.ts | 1 + src/shared/constants/pricing.ts | 3 + src/shared/constants/providers.ts | 11 ++ tests/unit/search-provider-validation.test.ts | 2 +- tests/unit/t20-t22-provider-headers.test.ts | 8 +- 12 files changed, 473 insertions(+), 5 deletions(-) create mode 100644 scripts/scratch/update_ui.cjs create mode 100644 src/app/api/models/test/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd4aa50d2..7a5f7cef94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index cd4b658283..bb5a3a6ad7 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index da4685875f..03770c2c70 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -848,6 +848,22 @@ export const REGISTRY: Record = { ], }, + 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", diff --git a/scripts/scratch/update_ui.cjs b/scripts/scratch/update_ui.cjs new file mode 100644 index 0000000000..98e083adca --- /dev/null +++ b/scripts/scratch/update_ui.cjs @@ -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;\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;\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;\n modelTestStatus?: Record;\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;\n modelTestStatus?: Record;\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( + '
', + `
+ {onTestModel && ( + + )}` +); + +// 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( + '
', + `
+ {onTestModel && ( + + )}` +); + +// 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(null);', + ' const [togglingModelId, setTogglingModelId] = useState(null);\n const [testingModelId, setTestingModelId] = useState(null);\n const [modelTestStatus, setModelTestStatus] = useState>({});' +); + +// 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"); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index b0effebe32..2aac6c6861 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -346,6 +346,9 @@ interface ModelRowProps { compatDisabled?: boolean; onToggleHidden?: (modelId: string, hidden: boolean) => Promise; togglingHidden?: boolean; + onTestModel?: (modelId: string, fullModel: string) => Promise; + testStatus?: "ok" | "error" | null; + testingModel?: boolean; } interface PassthroughModelRowProps { @@ -365,6 +368,9 @@ interface PassthroughModelRowProps { compatDisabled?: boolean; onToggleHidden?: (modelId: string, hidden: boolean) => Promise; togglingHidden?: boolean; + onTestModel?: (modelId: string, fullModel: string) => Promise; + testStatus?: "ok" | "error" | null; + testingModel?: boolean; } interface PassthroughModelsSectionProps { @@ -393,6 +399,9 @@ interface PassthroughModelsSectionProps { onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise; bulkTogglePending?: boolean; togglingModelId?: string | null; + onTestModel?: (modelId: string, fullModel: string) => Promise; + modelTestStatus?: Record; + testingModelId?: string | null; } interface CustomModelsSectionProps { @@ -440,6 +449,9 @@ interface CompatibleModelsSectionProps { onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise; bulkTogglePending?: boolean; togglingModelId?: string | null; + onTestModel?: (modelId: string, fullModel: string) => Promise; + modelTestStatus?: Record; + testingModelId?: string | null; } interface CooldownTimerProps { @@ -995,6 +1007,8 @@ export default function ProviderDetailPage() { const [compatSavingModelId, setCompatSavingModelId] = useState(null); const [modelFilter, setModelFilter] = useState(""); const [togglingModelId, setTogglingModelId] = useState(null); + const [testingModelId, setTestingModelId] = useState(null); + const [modelTestStatus, setModelTestStatus] = useState>({}); 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} />
); @@ -2464,6 +2516,9 @@ export default function ProviderDetailPage() { } bulkTogglePending={bulkVisibilityAction !== null} togglingModelId={togglingModelId} + onTestModel={onTestModel} + modelTestStatus={modelTestStatus} + testingModelId={testingModelId} />
); @@ -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({
+ {onTestModel && ( + + )} {onToggleHidden && ( + )} {onToggleHidden && (