feat(proxy): batch delete, auto-test, health scheduler + transitive alias fix (#5918)

Proxy-registry batch management (batch-delete, auto-test, background health scheduler) + fix resolveProviderAlias to follow the alias chain transitively (oc -> opencode -> opencode-zen). Probe target now operator-configurable via PROXY_HEALTH_TEST_URL. Scope-creep files from the original branch dropped.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
Paijo
2026-07-03 23:31:44 +07:00
committed by GitHub
parent 9c6a3640fd
commit ad58bc15d6
19 changed files with 1196 additions and 52 deletions

View File

@@ -1495,6 +1495,20 @@ APP_LOG_TO_FILE=true
# healthy-result cache window under high concurrency.
# PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS=2000
# Background proxy health scheduler (src/lib/proxyHealth/scheduler.ts).
# Periodically probes every registered proxy and (optionally) removes dead ones.
# Set "false" to disable the scheduler entirely. Default: enabled.
# PROXY_HEALTH_ENABLED=true
# Sweep interval in ms (minimum 60000). Default: 600000 (10min).
# PROXY_HEALTH_INTERVAL_MS=600000
# Reachability probe target for the scheduler and the auto-test endpoint.
# Point it at an internal/self-hosted URL to avoid the public default.
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
# Set "true" to let the scheduler auto-remove proxies after repeated failures.
# PROXY_AUTO_REMOVE=false
# Consecutive failures before an auto-remove fires. Default: 3.
# PROXY_AUTO_REMOVE_AFTER=3
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
# directly when proxy reachability pre-checks fail. Default: false.
# Also configurable from Dashboard > Settings > Feature Flags.

View File

@@ -22,9 +22,12 @@
- **feat(xai):** surface Grok usage on the quota dashboard via local usage-history aggregation. (thanks @DevEstacion)
- **feat(services):** add **Mux** (`coder/mux`) as a managed embedded service — install/start/stop/restart/logs lifecycle + dashboard tab, loopback-only API, `127.0.0.1`-bound with the auth token passed via env (never argv). Ported from upstream 9router#1802. (thanks @Ansh7473)
- **feat(services):** promote **Bifrost** (`@maximhq/bifrost`) to a supervised embedded service ([#5670](https://github.com/diegosouzapw/OmniRoute/issues/5670)) — full install/start/stop/restart/update/status/auto-start lifecycle + dashboard tab, loopback-only API (hard rule #17). When a supervised Bifrost is running and `BIFROST_BASE_URL` is unset, the relay route auto-selects it as the routing backend (`getBifrostRoutingConfig()`); an explicit `BIFROST_BASE_URL` always takes precedence.
- **feat(proxy):** proxy-registry batch management ([#5918](https://github.com/diegosouzapw/OmniRoute/pull/5918)) — `POST /api/settings/proxies/batch-delete` (delete many proxies in one request) and `POST /api/settings/proxies/auto-test` (reachability-test proxies with optional auto-remove of dead ones), plus a background health scheduler (`src/lib/proxyHealth/scheduler.ts`) that periodically probes registered proxies and can auto-remove persistently-dead ones. Configurable via `PROXY_HEALTH_ENABLED` / `PROXY_HEALTH_INTERVAL_MS` / `PROXY_HEALTH_TEST_URL` / `PROXY_AUTO_REMOVE` / `PROXY_AUTO_REMOVE_AFTER` (the probe target is now operator-configurable instead of a hardcoded endpoint). Dashboard gains batch-select checkboxes, a "Test All" action, and colored proxy health indicators. Regression guard: `tests/unit/proxy-batch-routes-5918.test.ts`. (thanks @oyi77)
### 🔧 Bug Fixes
- **providers (alias resolution followed only one hop):** `resolveProviderAlias()` did a single-hop lookup, so a genuine two-hop alias chain (`oc``opencode``opencode-zen`, where the parent OpenCode provider registers `alias: "oc"` and a manual override maps `opencode` → the `opencode-zen` free tier) resolved `oc/<model>` to the intermediate `opencode` instead of the final provider. Resolution now follows the chain transitively, guarded by both a depth limit and a seen-set so cycles can't loop. Regression guard: `tests/unit/provider-alias-transitive-5918.test.ts`. ([#5918](https://github.com/diegosouzapw/OmniRoute/pull/5918) — thanks @oyi77)
- fix(providers): strip orphan tool_result blocks on the Antigravity MITM path before forwarding to Claude ([#6026](https://github.com/diegosouzapw/OmniRoute/issues/6026))
- **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991))

View File

@@ -212,7 +212,7 @@
"src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 974,
"src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 898,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1089,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1117,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1183,
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629,
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924,
@@ -342,6 +342,7 @@
"_rebaseline_2026_06_13_2743d_skipbreaker": "Re-baseline #2743 gap-d (testar consumer do skipProviderBreaker): combo.ts 5131→5162 (+31). Crescimento = extração do boolean inline da decisão de circuit-breaker para o predicado puro EXPORTADO shouldRecordProviderBreakerFailure() (byte-idêntico) + JSDoc, para torná-lo unit-testável sem o harness completo de combo. Shrink estrutural segue com #3501.",
"_rebaseline_2026_06_13_v3825_prettier_reconcile": "Reconciliação tardia: o prettier do pre-commit reformatou 3 arquivos DEPOIS da medição de file-size dos PRs, inflando linhas além do baseline setado — OAuthModal.tsx 956→960 e providers.ts 3146→3147 (#3324), combo.ts 5162→5164 (#2743d). Bumps de reformatação automática (sem lógica nova). LIÇÃO: medir file-size pós-commit (pós-prettier), não antes.",
"_rebaseline_2026_06_14_3826_release_drift": "Re-baseline release/v3.8.25 drift already documented from #3809 owner changes: ProxyRegistryManager.tsx 1072→1089, sidebarVisibility.ts 990→1006, schemas.ts 2519→2522. This PR does not touch those source files; updating the frozen values restores Fast Quality Gates on the current release branch.",
"_rebaseline_2026_07_03_5918_proxy_batch": "PR #5918 own growth: ProxyRegistryManager.tsx 1089→1117 (+28 = wiring the new batch-select/Test-All proxy management components — checkboxes, batch actions bar, health cells). Cohesive UI wiring for the batch-delete/auto-test feature; the reusable pieces already live in separate leaf components (ProxyBatchActions/ProxyCheckboxCell/ProxyHealthCell/useProxyBatchOperations). Legitimate feature growth, not a quality regression.",
"_rebaseline_2026_06_14_3825_combo_stickiness": "Re-baseline #3825 (sessionless combo stickiness + reasoning-aware readiness): combo.ts 5164→5198 (+34, pós-prettier). Crescimento = deriveComboSessionKey() + effectiveSessionId threading nos sites de read/write do pin server-side. streamReadinessPolicy.ts não-frozen (sob cap). Lógica coesa no handler de combo; não-extraível.",
"_rebaseline_2026_06_14_r3_3835_kiro_pricing": "PR #3835 own growth: pricing.ts 1470→1508 (+38 = missing Kiro pricing rows, claude-sonnet-4.6 etc., pure data). Also carries inherited release/v3.8.25 drift not yet frozen by prior r2 merges: RequestLoggerV2.tsx 1276→1282 (#3820 resizable log table) and combo.ts 5198→5203 (#3811 round-robin replay-response fix). This PR does not touch RequestLoggerV2/combo.ts source; updating the frozen values restores Fast Quality Gates on the current release branch.",
"_rebaseline_2026_06_14_r3_3849_transient_hide": "PR #3849 own growth: providerPageHelpers.ts 939→955 (+16 = expanded JSDoc on the auto-hide policy + transient-failure guard in evaluateTestAllEntry). Cohesive helper logic; not extractable.",

View File

@@ -831,6 +831,11 @@ Anthropic-compatible provider instead.
| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. |
| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. |
| `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | Cache TTL for failed proxy health probes. Keep this shorter than `PROXY_HEALTH_CACHE_TTL_MS` so transient proxy timeouts under high concurrency retry quickly without disabling fast-fail for truly dead proxies. |
| `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. |
| `PROXY_HEALTH_INTERVAL_MS` | `600000` | `src/lib/proxyHealth/scheduler.ts` | Background health-scheduler sweep interval in ms (minimum `60000`). |
| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/scheduler.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. |
| `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. |
| `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). |
| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Effective precedence is Feature Flags DB override > env var > default. |
| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. |
| `RATE_LIMIT_AUTO_ENABLE` | _(unset)_ | `open-sse/services/rateLimitManager.ts` | Force the auto-enable rate limit safety net on/off regardless of the persisted Dashboard setting. Accepts `true`/`1`/`on` to force on, `false`/`0`/`off` to force off. |

View File

@@ -32,11 +32,6 @@ for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
// or backward-compatible slug changes, not a single provider's display name.
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// Manual aliases for external compatibility not covered by PROVIDER_ID_TO_ALIAS.
// OpenCode's Zen provider now uses the "opencode" slug, but OmniRoute registers
// it as "opencode-zen". This alias ensures `opencode/<model>` resolves correctly.
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// xiaomi/ is the user-visible prefix for MiMo models; register it so
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
// of falling through to the identity fallback ("xiaomi").
@@ -147,7 +142,19 @@ interface ProviderConnectionLike {
*/
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
if (typeof aliasOrId !== "string") return null;
return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId;
// Follow the alias chain transitively so intermediate aliases
// (e.g. "oc" -> "opencode" -> "opencode-zen") resolve to the final target.
// Guarded against infinite loops with both a depth limit and a seen-set.
let current = aliasOrId;
const seen = new Set<string>();
for (let i = 0; i < 10; i++) {
const next = ALIAS_TO_PROVIDER_ID[current];
if (!next || next === current) return current;
if (seen.has(next)) return next;
seen.add(next);
current = next;
}
return current;
}
/**

View File

@@ -0,0 +1,55 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
interface ProxyBatchActionsProps {
selectedCount: number;
batchDeleting: boolean;
autoTesting: boolean;
onBatchDelete: () => void;
onAutoTestAll: () => void;
}
export function ProxyBatchActions({
selectedCount,
batchDeleting,
autoTesting,
onBatchDelete,
onAutoTestAll,
}: ProxyBatchActionsProps) {
const t = useTranslations("proxyRegistry");
return (
<>
{selectedCount > 0 && (
<>
<span className="text-xs text-text-muted">
{t("batchSelectedCount", { count: selectedCount })}
</span>
<Button
size="sm"
variant="secondary"
icon="delete"
onClick={onBatchDelete}
loading={batchDeleting}
className="!text-red-400 !border-red-500/30"
data-testid="proxy-registry-batch-delete"
>
{t("batchDeleteSelected", { count: selectedCount })}
</Button>
</>
)}
<Button
size="sm"
variant="secondary"
icon="network_check"
onClick={onAutoTestAll}
loading={autoTesting}
data-testid="proxy-registry-test-all"
>
{t("testAll")}
</Button>
</>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Modal } from "@/shared/components";
type ParsedProxyEntry = {
name: string;
type: string;
host: string;
port: number;
username?: string;
region?: string;
status: string;
};
type ParseError = { line: number; reason: string };
interface ProxyBulkImportModalProps {
isOpen: boolean;
onClose: () => void;
onImported: () => Promise<void>;
}
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
# Format: name | type | host | port | username | region | status
# Example:
# My Proxy | http | 1.2.3.4 | 8080 | user | US | active
`;
function parseBulkImportText(text: string): {
parsed: ParsedProxyEntry[];
errors: ParseError[];
skipped: number;
} {
const lines = text.split("\n");
const parsed: ParsedProxyEntry[] = [];
const errors: ParseError[] = [];
let skipped = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith("#")) {
skipped++;
continue;
}
const parts = line.split("|").map((p) => p.trim());
if (parts.length < 4) {
errors.push({ line: i + 1, reason: "bulkImportMinFields" });
continue;
}
const [name, type, host, portStr, username, region, status] = parts;
const port = parseInt(portStr, 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
errors.push({ line: i + 1, reason: "bulkImportInvalidPort" });
continue;
}
parsed.push({
name: name || `${host}:${port}`,
type: ["http", "https", "socks5"].includes(type) ? type : "http",
host,
port,
username: username || undefined,
region: region || undefined,
status: status === "inactive" ? "inactive" : "active",
});
}
return { parsed, errors, skipped };
}
export function ProxyBulkImportModal({ isOpen, onClose, onImported }: ProxyBulkImportModalProps) {
const t = useTranslations("proxyRegistry");
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false);
const [bulkImporting, setBulkImporting] = useState(false);
const [bulkImportResult, setBulkImportResult] = useState<{ created: number; updated: number; failed: number } | null>(null);
const handleParse = () => {
const result = parseBulkImportText(bulkImportText);
setBulkImportParsed(result.parsed);
setBulkImportErrors(result.errors);
setBulkImportSkipped(result.skipped);
setBulkImportParsedOnce(true);
setBulkImportResult(null);
};
const handleExecute = async () => {
if (bulkImportParsed.length === 0) return;
setBulkImporting(true);
try {
const res = await fetch("/api/settings/proxies/bulk-import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: bulkImportParsed }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setBulkImportResult(data);
await onImported();
}
} finally {
setBulkImporting(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={() => {
if (!bulkImporting) onClose();
}}
title={t("bulkImportTitle")}
maxWidth="xl"
>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{t("bulkImportDescription")}</p>
<div>
<textarea
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
rows={14}
value={bulkImportText}
onChange={(e) => {
setBulkImportText(e.target.value);
setBulkImportParsedOnce(false);
setBulkImportResult(null);
}}
spellCheck={false}
/>
</div>
<div className="flex items-center gap-3">
<Button size="sm" variant="secondary" icon="search" onClick={handleParse}>
{t("bulkImportParse")}
</Button>
{bulkImportParsedOnce && (
<div className="flex items-center gap-3 text-xs">
<span className="text-emerald-400">{t("bulkImportParsed", { count: bulkImportParsed.length })}</span>
<span className="text-text-muted">{t("bulkImportSkipped", { count: bulkImportSkipped })}</span>
{bulkImportErrors.length > 0 && (
<span className="text-red-400">{t("bulkImportParseErrors", { count: bulkImportErrors.length })}</span>
)}
</div>
)}
</div>
{bulkImportErrors.length > 0 && (
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
{bulkImportErrors.map((err, idx) => (
<div key={idx} className="text-xs text-red-400">
{t("bulkImportErrorLine", { line: err.line, reason: t(err.reason as "bulkImportMinFields" | "bulkImportInvalidPort") })}
</div>
))}
</div>
)}
{bulkImportParsedOnce && bulkImportParsed.length > 0 && (
<div className="overflow-x-auto max-h-48 overflow-y-auto rounded border border-border">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
<th className="py-1.5 px-2">{t("tableName")}</th>
<th className="py-1.5 px-2">{t("labelType")}</th>
<th className="py-1.5 px-2">{t("labelHost")}</th>
<th className="py-1.5 px-2">{t("labelPort")}</th>
<th className="py-1.5 px-2">{t("labelUsername")}</th>
<th className="py-1.5 px-2">{t("labelRegion")}</th>
<th className="py-1.5 px-2">{t("labelStatus")}</th>
</tr>
</thead>
<tbody>
{bulkImportParsed.map((entry, idx) => (
<tr key={idx} className="border-b border-border/40">
<td className="py-1 px-2 font-medium text-text-main">{entry.name}</td>
<td className="py-1 px-2">
<span className="px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-[10px]">{entry.type}</span>
</td>
<td className="py-1 px-2 font-mono text-text-muted">{entry.host}</td>
<td className="py-1 px-2 font-mono text-text-muted">{entry.port}</td>
<td className="py-1 px-2 text-text-muted">{entry.username || "—"}</td>
<td className="py-1 px-2 text-text-muted">{entry.region || "—"}</td>
<td className="py-1 px-2">
<span className={entry.status === "active" ? "text-emerald-400" : "text-text-muted"}>
{entry.status === "active" ? t("statusActive") : t("statusInactive")}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{bulkImportParsedOnce && bulkImportParsed.length === 0 && bulkImportErrors.length === 0 && (
<div className="text-sm text-amber-400">{t("bulkImportNoValidEntries")}</div>
)}
{bulkImportResult && (
<div className="px-3 py-2 rounded border border-emerald-500/30 bg-emerald-500/10 text-sm text-emerald-400">
{t("bulkImportSuccess", { created: bulkImportResult.created, updated: bulkImportResult.updated, failed: bulkImportResult.failed })}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={onClose}>
{t("cancel")}
</Button>
<Button
size="sm"
icon="upload"
onClick={handleExecute}
loading={bulkImporting}
disabled={!bulkImportParsedOnce || bulkImportParsed.length === 0}
>
{bulkImporting ? t("bulkImportImporting") : bulkImportParsed.length > 0 ? t("bulkImportImport", { count: bulkImportParsed.length }) : t("bulkImport")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
interface ProxyCheckboxCellProps {
checked: boolean;
onChange: () => void;
label: string;
}
export function ProxyCheckboxCell({ checked, onChange, label }: ProxyCheckboxCellProps) {
return (
<td className="py-2 pr-2 w-8">
<input
type="checkbox"
className="accent-blue-500 w-4 h-4 cursor-pointer"
checked={checked}
onChange={onChange}
aria-label={label}
/>
</td>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useTranslations } from "next-intl";
interface TestResult {
success: boolean;
publicIp?: string;
latencyMs?: number | null;
error?: string;
}
interface HealthInfo {
successRate?: number;
avgLatencyMs?: number;
}
interface ProxyHealthCellProps {
testResult?: TestResult | null;
health?: HealthInfo | null;
}
export function ProxyHealthCell({ testResult, health }: ProxyHealthCellProps) {
const t = useTranslations("proxyRegistry");
if (testResult) {
if (testResult.success) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-emerald-400">{t("testPassed")}</span>
{testResult.latencyMs != null && (
<span
className={
testResult.latencyMs < 1000
? "text-emerald-400"
: testResult.latencyMs < 3000
? "text-amber-400"
: "text-red-400"
}
>
{testResult.latencyMs}ms
</span>
)}
</div>
);
}
return (
<span className="text-red-400"> {testResult.error || t("failed")}</span>
);
}
if (health) {
return (
<div className="flex flex-col gap-0.5">
<span>{t("successRate", { rate: health.successRate ?? 0 })}</span>
<span>{t("avgLatency", { latency: health.avgLatencyMs ?? "-" })}</span>
</div>
);
}
return <span></span>;
}

View File

@@ -3,8 +3,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Card, Modal } from "@/shared/components";
import { parseBulkImportText } from "./parseBulkProxyImport.ts";
import type { ParsedProxyEntry, ParseError } from "./parseBulkProxyImport.ts";
import { useProxyBatchOperations } from "./useProxyBatchOperations";
import { ProxyStatusBadge } from "./ProxyStatusBadge";
import { ProxyHealthCell } from "./ProxyHealthCell";
import { ProxyBatchActions } from "./ProxyBatchActions";
import { ProxyCheckboxCell } from "./ProxyCheckboxCell";
type ProxyItem = {
id: string;
@@ -41,6 +44,23 @@ type TestResult = {
error?: string;
};
type ParsedProxyEntry = {
name: string;
host: string;
port: number;
username: string;
password: string;
type: string;
region: string;
status: string;
notes: string;
};
type ParseError = {
line: number;
reason: string;
};
const EMPTY_FORM = {
id: "",
name: "",
@@ -56,11 +76,9 @@ const EMPTY_FORM = {
};
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
# Format A (pipe-delimited): NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
# Required: NAME, HOST, PORT
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
# Format B (auth-less shorthand): HOST:PORT
# Imports an HTTP proxy without credentials; name is auto-generated.
# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
# Required: NAME, HOST, PORT
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
#
# SOCKS5 examples:
@@ -70,12 +88,72 @@ const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
# HTTP/HTTPS examples:
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
# https-proxy|proxy.example.com|443|admin|secret123|https|US|active
#
# Auth-less shorthand examples:
# 127.0.0.1:7897
# proxy.example.com:3128
`;
const VALID_TYPES = new Set(["http", "https", "socks5"]);
const VALID_STATUSES = new Set(["active", "inactive"]);
function parseBulkImportText(text: string): {
entries: ParsedProxyEntry[];
errors: ParseError[];
skipped: number;
} {
const lines = text.split("\n");
const entries: ParsedProxyEntry[] = [];
const errors: ParseError[] = [];
let skipped = 0;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i].trim();
if (!raw || raw.startsWith("#")) {
skipped++;
continue;
}
const parts = raw.split("|").map((p) => p.trim());
const [name, host, portStr, username, password, type, region, status, notes] = parts;
const lineNum = i + 1;
if (!name) {
errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" });
continue;
}
if (!host) {
errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" });
continue;
}
const port = Number(portStr);
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" });
continue;
}
const normalizedType = (type || "socks5").toLowerCase();
if (!VALID_TYPES.has(normalizedType)) {
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" });
continue;
}
const normalizedStatus = (status || "active").toLowerCase();
if (!VALID_STATUSES.has(normalizedStatus)) {
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" });
continue;
}
entries.push({
name,
host,
port,
username: username || "",
password: password || "",
type: normalizedType,
region: region || "",
status: normalizedStatus,
notes: notes || "",
});
}
return { entries, errors, skipped };
}
export default function ProxyRegistryManager() {
const t = useTranslations("proxyRegistry");
const [items, setItems] = useState<ProxyItem[]>([]);
@@ -111,6 +189,29 @@ export default function ProxyRegistryManager() {
failed: number;
} | null>(null);
const {
selectedIds,
setSelectedIds,
batchDeleting,
autoTesting,
toggleSelectAll: hookToggleSelectAll,
toggleSelect,
handleBatchDelete: hookHandleBatchDelete,
handleAutoTestAll: hookHandleAutoTestAll,
} = useProxyBatchOperations(load);
const allSelected = items.length > 0 && items.every((item) => selectedIds.has(item.id));
const handleBatchDelete = useCallback(() => {
hookHandleBatchDelete(setError);
}, [hookHandleBatchDelete, setError]);
const handleAutoTestAll = useCallback(() => {
hookHandleAutoTestAll(setError, setTestById);
}, [hookHandleAutoTestAll, setError, setTestById]);
const editingId = useMemo(() => form.id || "", [form.id]);
const loadHealth = useCallback(async () => {
@@ -174,8 +275,9 @@ export default function ProxyRegistryManager() {
const ids = loaded.map((p) => p.id).filter(Boolean);
void loadHealth();
void loadAllUsage(ids);
} catch (e: any) {
setError(e?.message || t("errorLoadFailed"));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg || t("errorLoadFailed"));
setItems([]);
} finally {
setLoading(false);
@@ -186,6 +288,7 @@ export default function ProxyRegistryManager() {
void load();
}, [load]);
useEffect(() => {
if (items.length > 0 && !bulkProxyId) {
setBulkProxyId(items[0].id);
@@ -531,6 +634,13 @@ export default function ProxyRegistryManager() {
>
{t("bulkAssign")}
</Button>
<ProxyBatchActions
selectedCount={selectedIds.size}
batchDeleting={batchDeleting}
autoTesting={autoTesting}
onBatchDelete={handleBatchDelete}
onAutoTestAll={handleAutoTestAll}
/>
<Button
size="sm"
icon="add"
@@ -557,8 +667,19 @@ export default function ProxyRegistryManager() {
<table className="w-full text-sm">
<thead>
<tr className="text-left text-text-muted border-b border-border">
<th className="py-2 pr-2 w-8">
<input
type="checkbox"
className="accent-blue-500 w-4 h-4 cursor-pointer"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = !allSelected && items.some((item) => selectedIds.has(item.id));
}}
onChange={() => hookToggleSelectAll(allSelected, items)}
aria-label="Select all proxies"
/>
</th>
<th className="py-2 pr-3">{t("tableName")}</th>
<th className="py-2 pr-3">{t("tableEndpoint")}</th>
<th className="py-2 pr-3">{t("tableStatus")}</th>
<th className="py-2 pr-3">{t("tableHealth")}</th>
<th className="py-2 pr-3">{t("tableUsage")}</th>
@@ -571,6 +692,11 @@ export default function ProxyRegistryManager() {
const health = healthById[item.id];
return (
<tr key={item.id} className="border-b border-border/60">
<ProxyCheckboxCell
checked={selectedIds.has(item.id)}
onChange={() => toggleSelect(item.id)}
label={`Select ${item.name}`}
/>
<td className="py-2 pr-3">
<div className="font-medium text-text-main">{item.name}</div>
{item.region && (
@@ -581,38 +707,13 @@ export default function ProxyRegistryManager() {
{item.type}://{item.host}:{item.port}
</td>
<td className="py-2 pr-3">
<span className="text-xs px-2 py-1 rounded border border-border bg-bg-subtle">
{item.status === "inactive" ? t("statusInactive") : t("statusActive")}
</span>
<ProxyStatusBadge status={item.status} />
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
<div className="flex flex-col gap-0.5">
{testById[item.id] ? (
testById[item.id]!.success ? (
<>
<span className="text-emerald-400">
{testById[item.id]!.publicIp}
</span>
{testById[item.id]!.latencyMs && (
<span>{testById[item.id]!.latencyMs}ms</span>
)}
</>
) : (
<span className="text-red-400">
{testById[item.id]!.error || t("failed")}
</span>
)
) : health ? (
<>
<span>{t("successRate", { rate: health.successRate ?? 0 })}</span>
<span>
{t("avgLatency", { latency: health.avgLatencyMs ?? "-" })}
</span>
</>
) : (
<span></span>
)}
</div>
<ProxyHealthCell
testResult={testById[item.id] ?? undefined}
health={health ?? undefined}
/>
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{usageById[item.id] != null

View File

@@ -0,0 +1,23 @@
"use client";
interface ProxyStatusBadgeProps {
status?: string;
}
export function ProxyStatusBadge({ status }: ProxyStatusBadgeProps) {
const isInactive = status === "inactive";
return (
<span
className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border ${
isInactive
? "border-red-500/30 bg-red-500/10 text-red-400"
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${isInactive ? "bg-red-400" : "bg-emerald-400"}`}
/>
{isInactive ? "Inactive" : "Active"}
</span>
);
}

View File

@@ -0,0 +1,114 @@
import { useCallback, useState } from "react";
interface BatchDeleteResult {
id: string;
success: boolean;
error?: string;
}
interface AutoTestResult {
proxyId: string;
host: string;
port: number;
alive: boolean;
latencyMs: number | null;
error?: string;
}
export function useProxyBatchOperations(load: () => Promise<void>) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const [autoTesting, setAutoTesting] = useState(false);
const toggleSelectAll = useCallback(
(allSelected: boolean, items: Array<{ id: string }>) => {
setSelectedIds(allSelected ? new Set() : new Set(items.map((i) => i.id)));
},
[]
);
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleBatchDelete = useCallback(
async (setError: (msg: string | null) => void) => {
if (selectedIds.size === 0) return;
setBatchDeleting(true);
try {
const res = await fetch("/api/settings/proxies/batch-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selectedIds), force: true }),
});
const data: { error?: { message?: string }; results?: BatchDeleteResult[] } =
await res.json().catch(() => ({}));
if (res.ok) {
setSelectedIds(new Set());
await load();
} else {
setError(data?.error?.message || "Batch delete failed");
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Batch delete failed");
} finally {
setBatchDeleting(false);
}
},
[selectedIds, load]
);
const handleAutoTestAll = useCallback(
async (
setError: (msg: string | null) => void,
setTestById: React.Dispatch<React.SetStateAction<Record<string, { success: boolean; publicIp?: string; latencyMs?: number | null; error?: string } | null>>>
) => {
setAutoTesting(true);
try {
const res = await fetch("/api/settings/proxies/auto-test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data: { error?: { message?: string }; results?: AutoTestResult[] } =
await res.json().catch(() => ({}));
if (res.ok && data?.results) {
const newTestResults: Record<string, { success: boolean; publicIp?: string; latencyMs?: number | null; error?: string } | null> = {};
for (const r of data.results) {
newTestResults[r.proxyId] = {
success: r.alive,
publicIp: r.alive ? r.host : undefined,
latencyMs: r.latencyMs,
error: r.error,
};
}
setTestById((prev) => ({ ...prev, ...newTestResults }));
} else if (data?.error?.message) {
setError(data.error.message);
}
await load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Auto-test failed");
} finally {
setAutoTesting(false);
}
},
[load]
);
return {
selectedIds,
setSelectedIds,
batchDeleting,
autoTesting,
toggleSelectAll,
toggleSelect,
handleBatchDelete,
handleAutoTestAll,
};
}

View File

@@ -0,0 +1,126 @@
import { z } from "zod";
import { deleteProxyById, listProxies, updateProxy } from "@/lib/localDb";
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createProxyDispatcher } from "@omniroute/open-sse/utils/proxyDispatcher";
import { fetch as undiciFetch } from "undici";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse } from "@/lib/api/errorResponse";
const TEST_TIMEOUT_MS = 5000;
// Reachability probe target. Configurable so operators can point it at an
// internal/self-hosted endpoint instead of the public default.
const TEST_URL = process.env.PROXY_HEALTH_TEST_URL || "https://httpbin.org/ip";
const CONCURRENCY = 10;
const autoTestSchema = z.object({
ids: z.array(z.string()).optional(),
autoRemove: z.boolean().optional().default(false),
});
interface TestResult {
proxyId: string;
host: string;
port: number;
alive: boolean;
latencyMs: number | null;
error?: string;
}
async function testSingleProxy(proxy: { id: string; type: string; host: string; port: number }): Promise<TestResult> {
const proxyUrl = `${proxy.type}://${proxy.host}:${proxy.port}`;
const start = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
try {
const dispatcher = createProxyDispatcher(proxyUrl);
const resp = await undiciFetch(TEST_URL, {
method: "HEAD",
signal: controller.signal,
dispatcher,
headers: { "User-Agent": "OmniRoute/1.0" },
});
const latencyMs = Date.now() - start;
const alive = resp.status < 500;
await updateProxy(proxy.id, { status: alive ? "active" : "inactive" }).catch(() => {});
return { proxyId: proxy.id, host: proxy.host, port: proxy.port, alive, latencyMs };
} catch (err) {
const latencyMs = Date.now() - start;
await updateProxy(proxy.id, { status: "inactive" }).catch(() => {});
return {
proxyId: proxy.id,
host: proxy.host,
port: proxy.port,
alive: false,
latencyMs,
error: err instanceof Error ? err.message : "Connection failed",
};
} finally {
clearTimeout(timeout);
}
}
/**
* POST /api/settings/proxies/auto-test
* Tests proxy reachability. If autoRemove is true, removes dead proxies.
*/
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
rawBody = {};
}
const validation = validateBody(autoTestSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({ status: 400, message: validation.error.message, type: "invalid_request" });
}
const { ids: specificIds, autoRemove } = validation.data;
try {
const allProxies = await listProxies({ includeSecrets: false });
const proxiesToTest = specificIds
? allProxies.filter((p) => specificIds.includes(p.id))
: allProxies;
if (proxiesToTest.length === 0) {
return Response.json({ results: [], removed: [] });
}
const results: TestResult[] = [];
for (let i = 0; i < proxiesToTest.length; i += CONCURRENCY) {
const batch = proxiesToTest.slice(i, i + CONCURRENCY);
const batchResults = await Promise.allSettled(batch.map((proxy) => testSingleProxy(proxy)));
for (const result of batchResults) {
if (result.status === "fulfilled") results.push(result.value);
}
}
const removed: string[] = [];
if (autoRemove) {
for (const r of results) {
if (!r.alive) {
try {
if (await deleteProxyById(r.proxyId, { force: true })) removed.push(r.proxyId);
} catch { /* skip */ }
}
}
}
return Response.json({
tested: results.length,
alive: results.filter((r) => r.alive).length,
dead: results.filter((r) => !r.alive).length,
removed: removed.length,
results,
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to auto-test proxies");
}
}

View File

@@ -0,0 +1,60 @@
import { z } from "zod";
import { deleteProxyById } from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const batchDeleteSchema = z.object({
ids: z.array(z.string()).min(1).max(100),
force: z.boolean().optional().default(false),
});
/**
* POST /api/settings/proxies/batch-delete
* Deletes multiple proxies in a single request.
*/
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body", type: "invalid_request" });
}
const validation = validateBody(batchDeleteSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({ status: 400, message: validation.error.message, type: "invalid_request" });
}
const { ids, force } = validation.data;
try {
const results: Array<{ id: string; success: boolean; error?: string }> = [];
let deletedCount = 0;
for (const id of ids) {
try {
if (await deleteProxyById(id, { force })) {
results.push({ id, success: true });
deletedCount++;
} else {
results.push({ id, success: false, error: "Proxy not found" });
}
} catch (err) {
results.push({ id, success: false, error: err instanceof Error ? err.message : "Unknown error" });
}
}
if (deletedCount > 0) {
try { clearDispatcherCache(); } catch { /* non-critical */ }
}
return Response.json({ success: deletedCount > 0, deleted: deletedCount, failed: ids.length - deletedCount, results });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to batch delete proxies");
}
}

View File

@@ -7926,7 +7926,12 @@
"bulkImportMaxExceeded": "Maximum 100 proxies per import",
"bulkImportPreview": "Preview",
"clearAssignment": "(clear assignment)",
"bulkProxyAssignment": "Bulk Proxy Assignment"
"bulkProxyAssignment": "Bulk Proxy Assignment",
"testAll": "Test All",
"errorTestFailed": "Failed to test proxies",
"batchSelectedCount": "{count} selected",
"batchDeleteSelected": "Delete {count} selected",
"testPassed": "✓ OK"
},
"playground": {
"title": "Model Playground",

View File

@@ -151,6 +151,9 @@ export async function registerNodejs(): Promise<void> {
import("@/lib/skills/builtins"),
]);
// Proxy health scheduler (auto-removes dead proxies on interval)
await import("@/lib/proxyHealth/scheduler");
initGracefulShutdown();
initApiBridgeServer();
startSpendBatchWriter();

View File

@@ -0,0 +1,171 @@
/**
* Proxy Health Check Scheduler
*
* Periodically tests all proxy registry entries and automatically
* removes proxies that have been failing consecutively.
*
* Config via environment:
* PROXY_HEALTH_INTERVAL_MS — sweep interval (default: 600000 = 10min)
* PROXY_HEALTH_ENABLED — set "false" to disable
* PROXY_AUTO_REMOVE — set "true" to auto-remove dead proxies
* PROXY_AUTO_REMOVE_AFTER — consecutive failures before removal (default: 3)
*/
import { deleteProxyById, listProxies, updateProxy } from "@/lib/localDb";
import { createProxyDispatcher, clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { fetch as undiciFetch } from "undici";
const TEST_TIMEOUT_MS = 5000;
// Reachability probe target for proxy health checks. Configurable so operators
// can point it at an internal/self-hosted endpoint instead of the public default.
const TEST_URL = process.env.PROXY_HEALTH_TEST_URL || "https://httpbin.org/ip";
const CONCURRENCY = 10;
const INITIAL_DELAY_MS = 60_000;
const DEFAULT_INTERVAL_MS = 600_000;
const DEFAULT_REMOVE_AFTER = 3;
const LOG_PREFIX = "[ProxyHealth]";
declare global {
var __proxyHealthInterval: ReturnType<typeof setInterval> | undefined;
var __proxyHealthConsecutiveFailures: Map<string, number> | undefined;
}
function getFailureMap(): Map<string, number> {
if (!globalThis.__proxyHealthConsecutiveFailures) {
globalThis.__proxyHealthConsecutiveFailures = new Map();
}
return globalThis.__proxyHealthConsecutiveFailures;
}
function isEnabled(): boolean {
return process.env.PROXY_HEALTH_ENABLED !== "false";
}
function getIntervalMs(): number {
const raw = parseInt(process.env.PROXY_HEALTH_INTERVAL_MS ?? "", 10);
return Number.isFinite(raw) && raw >= 60_000 ? raw : DEFAULT_INTERVAL_MS;
}
function isAutoRemoveEnabled(): boolean {
return process.env.PROXY_AUTO_REMOVE === "true";
}
function getRemoveAfter(): number {
const raw = parseInt(process.env.PROXY_AUTO_REMOVE_AFTER ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_REMOVE_AFTER;
}
function isBuildProcess(): boolean {
return typeof process !== "undefined" && process.env.NEXT_PHASE === "phase-production-build";
}
function isBackgroundServicesDisabled(): boolean {
const raw = process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES;
if (!raw) return false;
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
}
async function testOneProxy(proxy: { id: string; type: string; host: string; port: number }): Promise<boolean> {
const proxyUrl = `${proxy.type}://${proxy.host}:${proxy.port}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
try {
const dispatcher = createProxyDispatcher(proxyUrl);
const resp = await undiciFetch(TEST_URL, {
method: "HEAD",
signal: controller.signal,
dispatcher,
headers: { "User-Agent": "OmniRoute/1.0" },
});
return resp.status < 500;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function sweep(): Promise<void> {
const proxies = await listProxies({ includeSecrets: false });
if (proxies.length === 0) return;
const failureMap = getFailureMap();
const removeAfter = getRemoveAfter();
const autoRemove = isAutoRemoveEnabled();
let tested = 0;
let alive = 0;
let removed = 0;
for (let i = 0; i < proxies.length; i += CONCURRENCY) {
const batch = proxies.slice(i, i + CONCURRENCY);
const results = await Promise.allSettled(
batch.map(async (proxy) => {
const ok = await testOneProxy(proxy);
return { id: proxy.id, ok };
})
);
for (const result of results) {
if (result.status !== "fulfilled") continue;
const { id, ok } = result.value;
tested++;
if (ok) {
alive++;
failureMap.delete(id);
await updateProxy(id, { status: "active" }).catch(() => {});
} else {
const failures = (failureMap.get(id) ?? 0) + 1;
failureMap.set(id, failures);
await updateProxy(id, { status: "inactive" }).catch(() => {});
if (autoRemove && failures >= removeAfter) {
if (await deleteProxyById(id, { force: true }).catch(() => false)) {
failureMap.delete(id);
removed++;
try { clearDispatcherCache(); } catch { /* non-critical */ }
}
}
}
}
}
console.log(
`${LOG_PREFIX} Sweep complete: ${tested} tested, ${alive} alive, ${removed} auto-removed`
);
}
function scheduleSweep(): void {
const interval = getIntervalMs();
globalThis.__proxyHealthInterval = setInterval(() => {
void sweep().catch((err) => {
console.error(`${LOG_PREFIX} Sweep error:`, err);
});
}, interval);
}
export function initProxyHealthCheck(): void {
if (!isEnabled() || isBuildProcess() || isBackgroundServicesDisabled()) return;
if (globalThis.__proxyHealthInterval) return;
setTimeout(() => {
console.log(`${LOG_PREFIX} Starting proxy health scheduler (interval: ${getIntervalMs()}ms)`);
void sweep().catch(() => {});
scheduleSweep();
}, INITIAL_DELAY_MS);
}
export function stopProxyHealthCheck(): void {
if (globalThis.__proxyHealthInterval) {
clearInterval(globalThis.__proxyHealthInterval);
globalThis.__proxyHealthInterval = undefined;
}
}
export async function forceProxyHealthSweep(): Promise<void> {
await sweep();
}
// Auto-initialize on first import
initProxyHealthCheck();

View File

@@ -0,0 +1,45 @@
/**
* Regression tests for #5918: resolveProviderAlias must follow the alias chain
* transitively.
*
* Root cause: resolveProviderAlias() did a single-hop lookup
* (`ALIAS_TO_PROVIDER_ID[alias] || alias`). The registry has genuine two-hop chains:
* the parent OpenCode provider registers `id: "opencode", alias: "oc"` (so
* `oc -> opencode`), and a manual override maps `opencode -> opencode-zen` (the
* main free tier). With single-hop, `resolveProviderAlias("oc")` returned the
* intermediate `"opencode"` instead of the final `"opencode-zen"`, so `oc/<model>`
* resolved to the wrong provider.
*
* Fix: resolve transitively with a depth limit AND a seen-set so cycles cannot loop.
* These assertions FAIL on the old single-hop implementation and pass on the fix.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { resolveProviderAlias } from "../../open-sse/services/model.ts";
test("resolveProviderAlias follows the oc -> opencode -> opencode-zen chain transitively", () => {
// The bug: single-hop returned "opencode" (intermediate) instead of "opencode-zen".
assert.equal(resolveProviderAlias("oc"), "opencode-zen");
});
test("resolveProviderAlias resolves a direct one-hop alias", () => {
assert.equal(resolveProviderAlias("opencode"), "opencode-zen");
});
test("resolveProviderAlias returns a terminal id unchanged (id === alias)", () => {
// opencode-zen registers id === alias === "opencode-zen"; must not loop or drift.
assert.equal(resolveProviderAlias("opencode-zen"), "opencode-zen");
});
test("resolveProviderAlias falls back to identity for an unknown provider/id", () => {
assert.equal(resolveProviderAlias("gpt-4"), "gpt-4");
assert.equal(resolveProviderAlias("some-unregistered-provider"), "some-unregistered-provider");
});
test("resolveProviderAlias returns null for non-string input", () => {
assert.equal(resolveProviderAlias(null), null);
assert.equal(resolveProviderAlias(undefined), null);
// @ts-expect-error — exercising the runtime guard against non-string input
assert.equal(resolveProviderAlias(123), null);
});

View File

@@ -0,0 +1,103 @@
/**
* Route coverage for #5918 proxy management endpoints:
* POST /api/settings/proxies/batch-delete
* POST /api/settings/proxies/auto-test
*
* DB-backed and network-free: batch-delete is exercised end-to-end against a
* temp SQLite DB; auto-test is exercised only on its no-proxy early-return path
* (which returns before any outbound probe), so no real network is touched.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-batch-5918-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
delete process.env.INITIAL_PASSWORD; // auth not required in this test env
const core = await import("../../src/lib/db/core.ts");
const proxiesDb = await import("../../src/lib/db/proxies.ts");
const { POST: batchDeletePost } = await import(
"../../src/app/api/settings/proxies/batch-delete/route.ts"
);
const { POST: autoTestPost } = await import(
"../../src/app/api/settings/proxies/auto-test/route.ts"
);
function jsonRequest(body: unknown): Request {
return new Request("http://localhost/api/settings/proxies/batch-delete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("batch-delete removes multiple existing proxies in one request", async () => {
await resetStorage();
const a = await proxiesDb.createProxy({ name: "A", type: "http", host: "127.0.0.1", port: 8080 });
const b = await proxiesDb.createProxy({ name: "B", type: "http", host: "127.0.0.1", port: 8081 });
assert.ok(a?.id && b?.id);
const res = await batchDeletePost(jsonRequest({ ids: [a.id, b.id] }));
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.deleted, 2);
assert.equal(body.failed, 0);
// Both are actually gone from the store.
const remaining = await proxiesDb.listProxies({ includeSecrets: false });
assert.equal(remaining.filter((p) => p.id === a.id || p.id === b.id).length, 0);
});
test("batch-delete reports non-existent ids as failed without throwing", async () => {
await resetStorage();
const res = await batchDeletePost(jsonRequest({ ids: ["does-not-exist"] }));
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.deleted, 0);
assert.equal(body.failed, 1);
});
test("batch-delete rejects an empty ids array with 400 (validation, pre-DB)", async () => {
await resetStorage();
const res = await batchDeletePost(jsonRequest({ ids: [] }));
assert.equal(res.status, 400);
});
test("batch-delete rejects invalid JSON with 400", async () => {
await resetStorage();
const bad = new Request("http://localhost/x", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{ not json",
});
const res = await batchDeletePost(bad);
assert.equal(res.status, 400);
});
test("auto-test returns an empty result set when there are no proxies (no network probe)", async () => {
await resetStorage();
const req = new Request("http://localhost/api/settings/proxies/auto-test", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
const res = await autoTestPost(req);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.results, []);
});