Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@
- **feat(combo):** add an option to **disable session stickiness**, per-combo or globally — round-robin / random combos can rotate to a different connection on every request instead of pinning a whole conversation to one connection by its first-message hash. Resolution precedence per-combo `config.disableSessionStickiness` → global `settings.disableSessionStickiness` → default `false` (preserves the #3825 prompt-cache/504 fix); gates **both** stickiness call sites in `open-sse/services/combo.ts`. Exposed as a global toggle (Combo Defaults) and a per-combo Inherit/on/off control. ([#6168](https://github.com/diegosouzapw/OmniRoute/issues/6168)) Regression guard: `tests/unit/combo-disable-session-stickiness.test.ts`. (thanks @RCrushMe)
- **feat(docker):** add the `OMNIROUTE_NO_SUDO` env flag for root-less / user-namespaced deployments — the MITM cert-trust command path (`resolveSudoSpawn` in `src/mitm/systemCommands.ts`) now strips the leading `sudo` when the flag is truthy, in addition to the existing root / sudo-missing cases, so the Proxy Agent runs without `sudo` (the operator trusts the CA manually, e.g. via `NODE_EXTRA_CA_CERTS`). Argv-array `spawn` preserved — no shell interpolation (Hard Rule #13). ([#6122](https://github.com/diegosouzapw/OmniRoute/issues/6122)) Regression guard: `tests/unit/mitm-systemCommands-no-sudo.test.ts`. (thanks @powellnorma)
- **feat(providers):** add **Requesty** as an OpenAI-compatible gateway provider (BYOK, base `https://router.requesty.ai/v1`, ~200 free requests/day) — wired through the shared OpenAI-compatible registry with full model passthrough (`open-sse/config/providers/registry/requesty/`, `src/shared/constants/providers/apikey/gateways.ts`). ([#6120](https://github.com/diegosouzapw/OmniRoute/issues/6120)) Regression guard: `tests/unit/requesty-provider.test.ts`. (thanks @chirag127)
- **feat(skills):** add a **GitHub skill-discovery** subsystem — search/score/scan/import agent skills from public GitHub repos that ship `SKILL.md` / `CLAUDE.md` / `.cursorrules` files, exposed as MCP tools (`omniroute_github_skills_search`/`scan`/`install`, gated behind `read:skills`/`write:skills` scopes) and a `GET/POST /api/github-skills` route (host-pinned to `api.github.com`, `encodeURIComponent`-escaped, error bodies sanitized). Registers `omni-github-skills` in the agent-skills catalog. Regression guards: `tests/unit/github-collector.test.ts` + the agent-skills catalog/routes/generator/mcp count suites. ([#6186](https://github.com/diegosouzapw/OmniRoute/pull/6186) — thanks @Moseyuh333)
- **feat(dashboard):** add **configured-only / available-only filters** to the Free Provider Rankings page ([#6150](https://github.com/diegosouzapw/OmniRoute/issues/6150)) — hide providers you haven't configured, or whose connections are all rate-limited / out of quota, via server-side query params (`?configuredOnly` / `?availableOnly` on `GET /api/free-provider-rankings`) backed by a testable lib helper reusing the in-process connection state (no Redis). Both filters default off, so the default view is unchanged; this supersedes the earlier client-side "Configured Only" toggle (#6245) with an available-only dimension and unit-tested logic. Regression guard: `tests/unit/freeProviderRankings-filters.test.ts`.

### 🐛 Bug Fixes

- **combo/streaming: fix intermittent 500s, corrupted SSE, and Gemini malformed-response handling ([#5976](https://github.com/diegosouzapw/OmniRoute/issues/5976)).** Five related fixes on the combo streaming path: (1) the quality check now reads a **clone** of the response so the original stream stays unlocked (kills the random `ERR_INVALID_STATE: ReadableStream is locked` 500s), and the abandoned clone branch is cancelled so it no longer buffers the whole body per request; (2) `withEarlyStreamKeepalive` only emits an in-band error frame when **no** bytes were forwarded yet, so a mid-flight upstream drop no longer corrupts a partially-delivered SSE stream; (3) Gemini `finishReason: "MALFORMED_RESPONSE"` maps to OpenAI `content_filter` and triggers combo failover instead of returning broken-but-"successful" output; (4) `/api/usage/call-logs?correlationId=` uses parameterized substring (`LIKE`) matching so partial IDs resolve; (5) per-model 500s skip model-lockout/cooldown for per-model-quota providers. Plus request-logger UI detail improvements. Regression guards: `tests/unit/{earlyStreamKeepalive,finishReason,combo-provider-cooldown-sibling,combo-context-relay,call-logs-correlation-substring,save-call-log-persistence,validate-response-quality}.test.ts`. ([#6216](https://github.com/diegosouzapw/OmniRoute/pull/6216) — thanks @hartmark)
- **chatcore (tools): stop the default 128-tool cap from silently dropping opencode's `task`/MCP tools.** opencode (used as an MCP/agent host) sends a large tool list; when it exceeds the speculative `MAX_TOOLS_LIMIT` (128) default, `truncateToolList` did a blind `tools.slice(0, 128)`, dropping every tool past index 128 — including opencode's built-in `task` tool (subagent launch) and many MCP tools, so models routed through OmniRoute could no longer spawn subagents or reach part of their tools. The cap exists to avoid upstream `400`s for providers with real hard limits (e.g. grok-cli 200), so it is kept for those: detection of the opencode client (`isOpencodeClient` — any `x-opencode-*` header, or `opencode` in the user-agent) now only bypasses the **speculative 128 default**, never a known provider ceiling. Precedence is explicit — a proactive/detected provider limit always truncates (even for opencode); otherwise opencode forwards its full tool list; otherwise the unchanged 128 default applies to every other client. Refactors `getEffectiveToolLimit` into `getKnownToolLimit(provider) ?? DEFAULT_LIMIT` (byte-identical for existing callers) and fixes a cosmetic debug-log that reported the truncated count instead of the original. Regression guard: `tests/unit/tool-limit-detector.test.ts`.

- **fix(mitm):** the macOS MITM-cert install check now matches the system keychain again. `security find-certificate -a -Z` prints the SHA-1 as a colon-less hex string, but the installed-check compared it against `getCertFingerprint()`'s colon-separated form, so the substring match never hit — the cert was reported as not-installed and re-prompted for the sudo install on every run. Fingerprints are now normalized (colons stripped, upper-cased) on both sides via the extracted `macCertOutputHasFingerprint` helper. Regression guard: `tests/unit/mitm-cert-mac-fingerprint.test.ts`. ([#6204](https://github.com/diegosouzapw/OmniRoute/pull/6204), closes [#6134](https://github.com/diegosouzapw/OmniRoute/issues/6134) — thanks @rianonehub)
Expand Down
118 changes: 44 additions & 74 deletions src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,20 @@ export default function FreeProviderRankingsPage() {
const [error, setError] = useState("");
const [filter, setFilter] = useState<string>("");
const [configuredOnly, setConfiguredOnly] = useState(false);
const [configuredProviderIds, setConfiguredProviderIds] = useState<Set<string>>(new Set());
const [availableOnly, setAvailableOnly] = useState(false);

const fetchRankings = useCallback(
async (category?: string) => {
async (category?: string, opts?: { configuredOnly?: boolean; availableOnly?: boolean }) => {
setLoading(true);
setError("");
try {
const url = category
? `/api/free-provider-rankings?category=${category}`
const params = new URLSearchParams();
if (category) params.set("category", category);
if (opts?.configuredOnly) params.set("configuredOnly", "1");
if (opts?.availableOnly) params.set("availableOnly", "1");
const qs = params.toString();
const url = qs
? `/api/free-provider-rankings?${qs}`
: "/api/free-provider-rankings";
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
Expand All @@ -85,30 +90,8 @@ export default function FreeProviderRankingsPage() {
);

useEffect(() => {
fetchRankings(filter || undefined);
}, [filter, fetchRankings]);

useEffect(() => {
let active = true;
fetch("/api/providers")
.then((res) => (res.ok ? res.json() : { connections: [] }))
.then((data) => {
if (!active) return;
const ids = new Set<string>();
for (const conn of data.connections || []) {
if (conn?.provider) ids.add(conn.provider);
}
setConfiguredProviderIds(ids);
})
.catch(() => {});
return () => {
active = false;
};
}, []);

const displayedRankings = configuredOnly
? rankings.filter((r) => configuredProviderIds.has(r.id))
: rankings;
fetchRankings(filter || undefined, { configuredOnly, availableOnly });
}, [filter, configuredOnly, availableOnly, fetchRankings]);

return (
<div className="flex flex-col gap-6">
Expand All @@ -135,30 +118,33 @@ export default function FreeProviderRankingsPage() {
{t(opt.labelKey)}
</button>
))}
<div className="ml-auto flex items-center gap-2">
<label
htmlFor="configured-only-toggle"
className="text-sm text-text-muted select-none cursor-pointer"
title={t("configuredOnlyHint")}
>
{t("configuredOnly")}
</label>
<button
id="configured-only-toggle"
role="switch"
aria-checked={configuredOnly}
onClick={() => setConfiguredOnly(!configuredOnly)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 ${
configuredOnly ? "bg-violet-500" : "bg-border"
}`}
>
<span
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg transition-transform ${
configuredOnly ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
</div>
</div>

{/* Availability toggles (default off → show all providers) */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setConfiguredOnly((v) => !v)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When configuredOnly is toggled off, availableOnly should also be toggled off to maintain UI state consistency, since a provider cannot be available if it is not configured.

          onClick={() => setConfiguredOnly((v) => {
            const next = !v;
            if (!next) setAvailableOnly(false);
            return next;
          })}

aria-pressed={configuredOnly}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
configuredOnly
? "bg-emerald-500 border-emerald-500 text-white"
: "border-border text-text-muted hover:text-text-main hover:border-emerald-500/50"
}`}
>
{t("filterConfiguredOnly")}
</button>
<button
onClick={() => setAvailableOnly((v) => !v)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When availableOnly is toggled on, configuredOnly should also be toggled on to maintain UI state consistency, since available providers are a subset of configured providers.

          onClick={() => setAvailableOnly((v) => {
            const next = !v;
            if (next) setConfiguredOnly(true);
            return next;
          })}

aria-pressed={availableOnly}
title={t("filterAvailableOnlyHelp")}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
availableOnly
? "bg-emerald-500 border-emerald-500 text-white"
: "border-border text-text-muted hover:text-text-main hover:border-emerald-500/50"
}`}
>
{t("filterAvailableOnly")}
</button>
</div>

{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
Expand All @@ -170,9 +156,9 @@ export default function FreeProviderRankingsPage() {
) : (
<>
{/* Top 3 Podium */}
{displayedRankings.length >= 3 && (
{rankings.length >= 3 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{displayedRankings.slice(0, 3).map((provider, idx) => (
{rankings.slice(0, 3).map((provider, idx) => (
<Card key={provider.id} className="relative overflow-hidden">
<div
className={`absolute top-0 left-0 right-0 h-1 ${
Expand Down Expand Up @@ -216,7 +202,7 @@ export default function FreeProviderRankingsPage() {
)}

{/* Full List */}
{displayedRankings.length > 0 && (
{rankings.length > 0 && (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
Expand All @@ -229,11 +215,10 @@ export default function FreeProviderRankingsPage() {
<th className="pb-3 font-medium text-right">{t("colAvgScore")}</th>
<th className="pb-3 font-medium text-right">{t("colModels")}</th>
<th className="pb-3 font-medium text-right">{t("colType")}</th>
<th className="pb-3 font-medium text-right">{t("colConfigured")}</th>
</tr>
</thead>
<tbody>
{displayedRankings.map((provider, idx) => (
{rankings.map((provider, idx) => (
<tr key={provider.id} className="border-b border-border/50 last:border-b-0">
<td className="py-3 text-text-muted font-mono">{idx + 1}</td>
<td className="py-3">
Expand Down Expand Up @@ -278,17 +263,6 @@ export default function FreeProviderRankingsPage() {
{provider.category.toUpperCase()}
</span>
</td>
<td className="py-3 text-right">
{configuredProviderIds.has(provider.id) ? (
<span className="text-xs px-2 py-1 rounded bg-green-500/10 text-green-500">
✓
</span>
) : (
<span className="text-xs px-2 py-1 rounded bg-text-muted/10 text-text-muted">
—
</span>
)}
</td>
</tr>
))}
</tbody>
Expand All @@ -297,13 +271,9 @@ export default function FreeProviderRankingsPage() {
</Card>
)}

{displayedRankings.length === 0 && !error && (
{rankings.length === 0 && !error && (
<Card>
<div className="text-center py-12 text-text-muted">
{configuredOnly && rankings.length > 0
? t("noConfiguredProviders")
: t("emptyState")}
</div>
<div className="text-center py-12 text-text-muted">{t("emptyState")}</div>
</Card>
)}
</>
Expand Down
18 changes: 16 additions & 2 deletions src/app/api/free-provider-rankings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { z } from "zod";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { computeFreeProviderRankings } from "@/lib/freeProviderRankings";

// Coerce common truthy query-string forms ("1", "true", "yes") to a boolean.
const boolParam = z
.string()
.optional()
.transform((val) => val === "1" || val === "true" || val === "yes");
Comment on lines +7 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current boolParam transform does not treat an empty string "" as truthy. When a query parameter is passed as a flag without a value (e.g., ?configuredOnly), URLSearchParams.get returns "". To support this common query string pattern, include "" in the truthy coercion list.

Suggested change
const boolParam = z
.string()
.optional()
.transform((val) => val === "1" || val === "true" || val === "yes");
const boolParam = z
.string()
.optional()
.transform((val) => val === "" || val === "1" || val === "true" || val === "yes");


const QuerySchema = z.object({
category: z.string().min(1).max(50).optional(),
limit: z
Expand All @@ -13,6 +19,9 @@ const QuerySchema = z.object({
const n = Number(val);
return Number.isFinite(n) && n >= 1 ? Math.min(Math.round(n), 100) : 50;
}),
// Additive filters (default off → current behavior). `availableOnly` implies configured.
configuredOnly: boolParam,
availableOnly: boolParam,
});

export async function OPTIONS() {
Expand All @@ -24,6 +33,8 @@ export async function GET(request: NextRequest) {
const parsed = QuerySchema.safeParse({
category: url.searchParams.get("category") || undefined,
limit: url.searchParams.get("limit") || undefined,
configuredOnly: url.searchParams.get("configuredOnly") || undefined,
availableOnly: url.searchParams.get("availableOnly") || undefined,
Comment on lines +36 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the logical OR operator || converts empty strings "" to undefined. This prevents query parameters passed as flags without values (e.g., ?configuredOnly) from being parsed correctly. Use the nullish coalescing operator ?? instead to preserve empty strings.

Suggested change
configuredOnly: url.searchParams.get("configuredOnly") || undefined,
availableOnly: url.searchParams.get("availableOnly") || undefined,
configuredOnly: url.searchParams.get("configuredOnly") ?? undefined,
availableOnly: url.searchParams.get("availableOnly") ?? undefined,

});

if (!parsed.success) {
Expand All @@ -33,8 +44,11 @@ export async function GET(request: NextRequest) {
);
}

const { category, limit } = parsed.data;
const rankings = computeFreeProviderRankings(category, limit);
const { category, limit, configuredOnly, availableOnly } = parsed.data;
const rankings = await computeFreeProviderRankings(category, limit, {
configuredOnly,
availableOnly,
});

return NextResponse.json({ rankings }, { headers: CORS_HEADERS });
}
3 changes: 3 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -9040,6 +9040,9 @@
"colAvgScore": "Avg Score",
"colModels": "Models",
"colType": "Type",
"filterConfiguredOnly": "Configured only",
"filterAvailableOnly": "Available only",
"filterAvailableOnlyHelp": "Hide providers whose connections are all rate-limited or out of quota.",
"configuredOnly": "Configured Only",
"configuredOnlyHint": "Show only providers with active connections",
"noConfiguredProviders": "No configured providers found. Add a provider connection first.",
Expand Down
Loading
Loading