diff --git a/.env.example b/.env.example index 869fc6a4ed..ba419dac43 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0572ed6bc3..e5520653ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` 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)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 5c9e0e3db2..c014e793d3 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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.", diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 428992caca..e45e4f1897 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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. | diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index e359f0ed46..122247a966 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -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/` 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(); + 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; } /** diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyBatchActions.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyBatchActions.tsx new file mode 100644 index 0000000000..a9e537a884 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyBatchActions.tsx @@ -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 && ( + <> + + {t("batchSelectedCount", { count: selectedCount })} + + + + )} + + + ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyBulkImportModal.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyBulkImportModal.tsx new file mode 100644 index 0000000000..59e9b8526b --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyBulkImportModal.tsx @@ -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; +} + +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([]); + const [bulkImportErrors, setBulkImportErrors] = useState([]); + 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 ( + { + if (!bulkImporting) onClose(); + }} + title={t("bulkImportTitle")} + maxWidth="xl" + > +
+

{t("bulkImportDescription")}

+ +
+