diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa50bdb16..0b479d2f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,15 +13,18 @@ - **feat(flags): expose the emergency budget fallback in the Feature Flags page** — `OMNIROUTE_EMERGENCY_FALLBACK` is now a runtime boolean (enabled by default, applied without restart) resolved through the feature-flag stack, so a DB override can toggle it while still honoring the raw env fallback. Follow-up to [#3741](https://github.com/diegosouzapw/OmniRoute/pull/3741) by @zoispag. ([#3752](https://github.com/diegosouzapw/OmniRoute/pull/3752) — thanks @rdself) - **feat(reasoning): preserve `xhigh` reasoning effort by default** — `xhigh` now passes through unless a model explicitly sets `supportsXHighEffort: false`, with the existing `max` normalization kept separate. ([#3756](https://github.com/diegosouzapw/OmniRoute/pull/3756) — thanks @rdself) - **feat(codex): inject OmniRoute memory into Codex Responses WebSocket requests** — retrieved memory is injected into the Responses WebSocket prepare request via the `instructions` field, with the retrieval query derived from the latest user input (skipping tool/reasoning payloads) and duplicate-safe injection. ([#3749](https://github.com/diegosouzapw/OmniRoute/pull/3749) — thanks @kkkayye) +- **feat(dashboard): free provider rankings page** — a new dashboard page (with sidebar entry) that ranks free providers (no-auth / OAuth / API-key) by their models' Arena ELO / intelligence scores, joining the provider registry with the `model_intelligence` table via fuzzy model-name matching. Pure computation over existing data — no external calls. ([#3799](https://github.com/diegosouzapw/OmniRoute/pull/3799) — thanks @pizzav-xyz) ### 🔒 Security - **security(proxy): IPv6-only egress enforcement + closing IP-leak paths (L1/L2/L3)** — de-brackets IPv6 literals at the SOCKS host and the proxy-health tcpCheck (so `socks5://[2001:db8::1]` and any v6-literal proxy connect instead of dying with `ENOTFOUND`), adds a per-proxy `family` policy (`auto`/`ipv4`/`ipv6`), and enforces it end-to-end across SOCKS5/HTTP/HTTPS × global/provider/key × literal/hostname. 16 commits, TDD+BDD, 73 tests. ([#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777)) - **security(marketplace): harden the custom-URL SSRF guard** against three bypasses found by automated security review — IPv6/AAAA records (only `dns.resolve4` was checked, so a private AAAA record or an IPv6 literal slipped through), redirect-following (a public URL could 30x to an internal one), and DNS-rebinding TOCTOU. The guard now resolves A+AAAA via the canonical `isPrivateHost`, routes the fetch through `safeOutboundFetch` (public-only, blocks redirects to private hosts), and re-validates on fetch. Reachable only with management auth + a custom `pluginMarketplaceUrl`. Follow-up to [#3656](https://github.com/diegosouzapw/OmniRoute/pull/3656). ([#3774](https://github.com/diegosouzapw/OmniRoute/pull/3774)) -- **security: resolve all open CodeQL + Dependabot alerts** — CodeQL `js/insufficient-password-hash` (the semantic-cache `apiKeyId` is now a plaintext key prefix, `${apiKeyId}.${digest}`, instead of being folded into the SHA-256 digest, clearing the false positive while preserving per-key cache isolation) and a URL-substring check tightened to an exact host match; Dependabot `esbuild < 0.28.1` pinned via override in both workspaces. ([#3778](https://github.com/diegosouzapw/OmniRoute/pull/3778)) +- **security: resolve all open CodeQL + Dependabot alerts** — CodeQL `js/insufficient-password-hash` (the semantic-cache `apiKeyId` is now a plaintext key prefix, `${apiKeyId}.${digest}`, instead of being folded into the SHA-256 digest, clearing the false positive while preserving per-key cache isolation) and a URL-substring check tightened to an exact host match; Dependabot `esbuild < 0.28.1` pinned via override in both workspaces. ([#3778](https://github.com/diegosouzapw/OmniRoute/pull/3778)) The remaining `js/incomplete-url-substring-sanitization` instances in the api-key proxy-context test were also cleared by asserting on the parsed URL host/port instead of a substring `includes`. (thanks @diegosouzapw) ### 🐛 Fixed +- **fix(dashboard): surface the Plugins page (plugin manager + marketplace) in the sidebar** — the plugins page (`/dashboard/plugins`), which hosts the custom plugin marketplace shipped in [#3656](https://github.com/diegosouzapw/OmniRoute/pull/3656), had no menu entry and was reachable only by typing the URL. It now appears under **Agentic Features**. (thanks @diegosouzapw) +- **fix(proxy): add the IP-family selector (auto / IPv4-only / IPv6-only) to the proxy form** — the per-proxy `family` egress policy from [#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777) was backend-only (the dashboard had no control, so every proxy stayed on `auto`). The proxy registry form now exposes the selector and the create/update schema accepts it, so IPv6-only egress can be enabled from the UI. (thanks @diegosouzapw) - **fix(combo): deep audit of the combo + quota-shared routing system** — repairs 5 dead/broken rules (streaming-USD cost recording, quota-pool usage provider resolution, provider-diversity wiring, `maxComboDepth` threading, and scoring clamp/NaN-safety incl. `connectionDensity`) and revives the dead `tierAffinity`/`specificityMatch` scoring factors — root cause was a `require()` that throws under ESM, so both factors silently collapsed to `0.5`; now a static import. Validates every auto-router strategy (cost / latency / sla-aware / lkgp / `selectWithStrategy` + aliases) and the predictive-TTFT decision, adds E2E coverage (3-hop priority failover, per-target timeout failover, real `strategy:auto` dispatch), and introduces opt-in complexity-aware routing (2026) layered over the existing specificity detector. Per-target credential+proxy isolation verified clean (`AsyncLocalStorage`). 4 TDD waves, 10 new/updated test files. ([#3779](https://github.com/diegosouzapw/OmniRoute/pull/3779) — thanks @diegosouzapw) - fix(anthropic): normalize sampling params under extended thinking — Claude models with extended thinking (e.g. Opus 4.8 via the Claude Code provider) returned **HTTP 400** when a request carried non-default `temperature`/`top_p` (`temperature may only be set to 1 …`, `top_p must be ≥ 0.95 or unset …`). Tools like VS Code Copilot's "Ollama" BYOK send `temperature: 0.7` + `top_p: 0.9`, so every thinking-enabled Claude request failed; the proxy now drops/normalizes these params at the chokepoint so the request succeeds. ([#3780](https://github.com/diegosouzapw/OmniRoute/pull/3780) — thanks @zhiru) - fix(sse): pass Claude passthrough `thinking` blocks through unchanged — the Anthropic-native Claude OAuth passthrough rewrote every assistant `thinking` block to `redacted_thinking`, which the Messages API rejects (submitted thinking blocks are validated against the original response), so every multi-turn request with extended thinking failed with `400 … thinking blocks … cannot be modified` (very visible on long Claude Code tool-loops). The blocks are now passed through verbatim; the signature is validated server-side and stays valid on replay (including across an OAuth token switch), so the redaction was unnecessary. ([#3775](https://github.com/diegosouzapw/OmniRoute/pull/3775) — thanks @havockdev) diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 8dcb74fb33..14987db342 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -15,6 +15,7 @@ type ProxyItem = { region?: string | null; notes?: string | null; status?: string; + family?: string; }; type UsageInfo = { @@ -66,6 +67,7 @@ const EMPTY_FORM = { region: "", notes: "", status: "active", + family: "auto", }; const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import @@ -280,6 +282,7 @@ export default function ProxyRegistryManager() { region: item.region || "", notes: item.notes || "", status: item.status || "active", + family: item.family || "auto", }); setModalOpen(true); }; @@ -366,6 +369,7 @@ export default function ProxyRegistryManager() { region: (form.region || "").trim() || null, notes: (form.notes || "").trim() || null, status: form.status, + family: form.family || "auto", }; if (!editingId || normalizedUsername.length > 0) { payload.username = normalizedUsername; @@ -766,6 +770,19 @@ export default function ProxyRegistryManager() { +
+ + +

{t("familyHint")}

+
{ + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("stripVersionSuffix strips only the trailing version numbers", () => { + assert.equal(stripVersionSuffix("kimi-k2.6"), "kimi-k2"); + assert.equal(stripVersionSuffix("gpt-5.5"), "gpt-5"); + assert.equal(stripVersionSuffix("claude-opus-4.8.1"), "claude-opus-4"); + assert.equal(stripVersionSuffix("gpt-5"), "gpt-5"); // no version suffix → unchanged + assert.equal(stripVersionSuffix("llama-3-70b"), "llama-3-70b"); // digits not a trailing .N +}); + +test("findMatchingIntelligence prefers exact, then version-stripped, then prefix", () => { + const intelMap = new Map([ + ["gpt-5", [{ score: 90, eloRaw: 1400, confidence: "high", category: "default" }]], + ["kimi-k2", [{ score: 80, eloRaw: 1300, confidence: "mid", category: "default" }]], + ]); + + // Strategy 1: exact match. + assert.equal(findMatchingIntelligence("gpt-5", intelMap)?.score, 90); + // Strategy 2: "kimi-k2.6" → stripped "kimi-k2". + assert.equal(findMatchingIntelligence("kimi-k2.6", intelMap)?.score, 80); + // Strategy 3: prefix — "gpt-5-turbo" starts with "gpt-5-". + assert.equal(findMatchingIntelligence("gpt-5-turbo", intelMap)?.score, 90); + // No match anywhere. + assert.equal(findMatchingIntelligence("mistral-large", intelMap), null); +}); + +test("findMatchingIntelligence picks the highest-scoring entry on a tie key", () => { + const intelMap = new Map([ + [ + "gpt-5", + [ + { score: 70, eloRaw: 1200, confidence: "low", category: "default" }, + { score: 95, eloRaw: 1450, confidence: "high", category: "default" }, + ], + ], + ]); + assert.equal(findMatchingIntelligence("gpt-5", intelMap)?.score, 95); +}); + +test("computeFreeProviderRankings returns an array and respects the limit", () => { + const all = computeFreeProviderRankings(); + assert.ok(Array.isArray(all), "expected an array of rankings"); + + const limited = computeFreeProviderRankings(undefined, 2); + assert.ok(limited.length <= 2, `expected at most 2 rankings, got ${limited.length}`); + + for (const ranking of limited) { + assert.equal(typeof ranking.id, "string"); + assert.ok(["noauth", "oauth", "apikey"].includes(ranking.category)); + } +}); diff --git a/tests/unit/provider-limits-apikey-proxy-context.test.ts b/tests/unit/provider-limits-apikey-proxy-context.test.ts index c1c505e5e9..95017cecda 100644 --- a/tests/unit/provider-limits-apikey-proxy-context.test.ts +++ b/tests/unit/provider-limits-apikey-proxy-context.test.ts @@ -12,7 +12,12 @@ describe("API-key usage egresses through proxy context", () => { // Deterministic, no network dependency: this is the core mechanism the L3 fix uses // when wrapping getUsageForProvider in runWithProxyContext(apiKeyProxy?.proxy ?? null). const url = proxyConfigToUrl({ type: "http", host: "p.example.com", port: 8080 }); - assert.ok(url && url.includes("p.example.com:8080"), `expected proxy url, got ${url}`); + assert.ok(url, `expected proxy url, got ${url}`); + // Parse and compare host/port exactly (substring matching on a URL is unsafe — CodeQL + // js/incomplete-url-substring-sanitization — and a weaker assertion than equality). + const parsed = new URL(url); + assert.equal(parsed.hostname, "p.example.com"); + assert.equal(parsed.port, "8080"); }); it("a null proxy config (no connection proxy) resolves to no proxy", () => { @@ -28,7 +33,8 @@ describe("API-key usage egresses through proxy context", () => { await runWithProxyContext({ type: "http", host: "p.example.com", port: 8080 }, async () => { const r = resolveProxyForRequest("https://api.example.com"); assert.equal(r.source, "context"); - assert.ok(r.proxyUrl && r.proxyUrl.includes("p.example.com")); + assert.ok(r.proxyUrl, "expected a proxy url from context"); + assert.equal(new URL(r.proxyUrl).hostname, "p.example.com"); }); } catch (err) { // Expected when the proxy host is unreachable; the mechanism is still proven by the diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index 9dcec7d88b..4de351fcee 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -14,6 +14,7 @@ const proxiesDb = await import("../../src/lib/db/proxies.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const proxiesRoute = await import("../../src/app/api/settings/proxies/route.ts"); +const { createProxyRegistrySchema } = await import("../../src/shared/validation/schemas.ts"); async function resetStorage() { delete process.env.INITIAL_PASSWORD; @@ -479,3 +480,59 @@ test("createProxy persists type:vercel and source:vercel-relay to DB (schema gap assert.equal(created?.type, "vercel"); assert.equal(created?.source, "vercel-relay"); }); + +test("proxy registry schema accepts the IP family policy and defaults it to auto (#3777)", () => { + // The dashboard proxy form (ProxyRegistryManager) now sends `family`. The schema is + // .strict(), so an undeclared field would 400 the whole request — this guards the wiring. + const explicit = createProxyRegistrySchema.parse({ + name: "v6-only proxy", + type: "socks5", + host: "proxy.example.com", + port: 1080, + family: "ipv6", + }); + assert.equal(explicit.family, "ipv6"); + + const defaulted = createProxyRegistrySchema.parse({ + name: "default family proxy", + type: "http", + host: "proxy.example.com", + port: 8080, + }); + assert.equal(defaulted.family, "auto"); + + assert.throws(() => + createProxyRegistrySchema.parse({ + name: "bad family", + type: "http", + host: "proxy.example.com", + port: 8080, + family: "ipv7", + }) + ); +}); + +test("createProxy persists the IP family and reads it back (#3777)", async () => { + await resetStorage(); + + const created = await proxiesDb.createProxy({ + name: "Family RoundTrip", + type: "socks5", + host: "v6.example.com", + port: 1080, + family: "ipv6", + }); + assert.equal(created?.family, "ipv6"); + + const fetched = await proxiesDb.getProxyById(created.id, { includeSecrets: false }); + assert.equal(fetched?.family, "ipv6"); + + // Omitting family defaults to "auto" (prior dual-stack behavior, no regression). + const legacy = await proxiesDb.createProxy({ + name: "Family Default", + type: "http", + host: "dual.example.com", + port: 8080, + }); + assert.equal(legacy?.family, "auto"); +}); diff --git a/tests/unit/sidebar-costs-section.test.ts b/tests/unit/sidebar-costs-section.test.ts index d3333d0c06..8e1dc01532 100644 --- a/tests/unit/sidebar-costs-section.test.ts +++ b/tests/unit/sidebar-costs-section.test.ts @@ -12,15 +12,21 @@ test("costs section exists in SIDEBAR_SECTIONS", () => { assert.ok(section, "costs section must exist"); }); -test("costs section has exactly 4 items in the correct order", () => { +test("costs section has exactly 5 items in the correct order", () => { const section = findSection("costs"); assert.ok(section, "costs section must exist"); const items = sidebarVisibility.getSectionItems(section); - assert.equal(items.length, 4, "costs section must have 4 items"); + assert.equal(items.length, 5, "costs section must have 5 items"); const itemIds = items.map((i) => i.id); - assert.deepEqual(itemIds, ["costs", "costs-pricing", "costs-budget", "costs-free-tiers"]); + assert.deepEqual(itemIds, [ + "costs", + "costs-pricing", + "costs-budget", + "costs-free-tiers", + "free-provider-rankings", + ]); }); test("costs section items have correct hrefs", () => { @@ -35,6 +41,7 @@ test("costs section items have correct hrefs", () => { { id: "costs-pricing", href: "/dashboard/costs/pricing" }, { id: "costs-budget", href: "/dashboard/costs/budget" }, { id: "costs-free-tiers", href: "/dashboard/free-tiers" }, + { id: "free-provider-rankings", href: "/dashboard/free-provider-rankings" }, ]); }); @@ -58,7 +65,7 @@ test("costs item was removed from analytics section", () => { assert.equal( analyticsItemIds.includes("costs" as sidebarVisibility.HideableSidebarItemId), false, - "costs item must not be in analytics section", + "costs item must not be in analytics section" ); }); @@ -74,11 +81,11 @@ test("costs section is positioned between analytics and monitoring", () => { assert.ok( analyticsIdx < costsIdx, - `analytics (${analyticsIdx}) must come before costs (${costsIdx})`, + `analytics (${analyticsIdx}) must come before costs (${costsIdx})` ); assert.ok( costsIdx < monitoringIdx, - `costs (${costsIdx}) must come before monitoring (${monitoringIdx})`, + `costs (${costsIdx}) must come before monitoring (${monitoringIdx})` ); }); diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index 8e035e00b7..5b04a3dc1c 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -113,6 +113,22 @@ test("help sidebar exposes changelog after docs and issues", () => { assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("changelog"), true); }); +test("plugins (marketplace) has a discoverable sidebar entry (#3656 follow-up)", async () => { + const items = sectionItems("agentic-features"); + const plugins = items.find((item) => item.id === "plugins"); + assert.ok(plugins, "expected a plugins item in the agentic-features section"); + assert.equal(plugins.href, "/dashboard/plugins"); + assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("plugins"), true); + + // It must be a real page (plugin manager + marketplace tab), not a legacy redirect stub. + const pluginsPage = await readFile( + join(repoRoot, "src/app/(dashboard)/dashboard/plugins/page.tsx"), + "utf8" + ); + assert.doesNotMatch(pluginsPage, /^\s*redirect\(/m); + assert.match(pluginsPage, /marketplace/i); +}); + test("legacy dashboard routes redirect to their consolidated surfaces", async () => { const autoComboPage = await readFile( join(repoRoot, "src/app/(dashboard)/dashboard/auto-combo/page.tsx"),