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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ _In development — bullets added per PR; finalized at release._
- **feat(providers): expose `gpt-4o` on the built-in GitHub Copilot (`gh`) provider** — GitHub Copilot still serves the original `gpt-4o` chat model via its `/chat/completions` endpoint, but the OmniRoute registry only shipped the GPT-5.x family, so clients that explicitly request `gpt-4o` against `gh` got an unknown-model error. `gpt-4o` is now registered under the `github` provider next to the GPT-5.x lineup (chat/completions, 128k context — no `openai-responses` targetFormat). Ported from [9router#98](https://github.com/decolua/9router/pull/98). (thanks @I3eka)
- **feat(pricing): default pricing for Qwen `coder-model` on the `qw` provider** — the Qwen Coder Free (`qw`) registry already exposed the `coder-model` id (Qwen3.5/3.6 Coder Model) but `DEFAULT_PRICING.qw` was missing the row, so usage tracking reported `$0.00` for that model. The pricing row is now added with the same shape as the sibling `vision-model` tier, restoring non-zero cost tracking. Ported from upstream 9router PR [decolua/9router#156](https://github.com/decolua/9router/pull/156). (thanks @LinearSakana)
- **feat(usage): Codex review-quota now surfaces the weekly window and the `additional_rate_limits` fallback shape** — the dashboard's Codex usage card showed only the **session** half of `code_review_rate_limit` and dropped review descriptors that arrived inside `additional_rate_limits` (the shape some ChatGPT Codex plans report). `buildCodexUsageQuotas` now emits the secondary window as `quotas.code_review_weekly` and, when the dedicated `code_review_rate_limit` block is empty, falls back to the matching descriptor in `additional_rate_limits` (matched on `limit_name`/`metered_feature`/`limit_id` containing `code_review` / `codex_review` / `review`). The new label `code_review_weekly → "Code Review Weekly"` is registered in `ProviderLimits/utils.tsx` so the card renders both windows side-by-side. The existing `quotas.code_review` key is preserved for back-compat. Inspired by upstream decolua/9router PR #836. (thanks @hiepau1231)
- **feat(dashboard): per-provider dropdown filter on the quota dashboard** — the Quota dashboard now has a "Provider" dropdown alongside the existing Status / Type / Tier / Env filters. Choosing a provider narrows the visible accounts to that provider only; the selection persists in `localStorage` (`omniroute:limits:providerFilter`) and the dropdown auto-falls back to "All providers" if the persisted key no longer matches a connection in the current session. The dropdown only renders when there are at least two distinct providers in view, so single-provider setups aren't cluttered. The upstream "Expiring first" toggle is intentionally not ported — `visibleConnections` already always sorts by soonest reset within each status group, so the toggle would be redundant. Inspired by [decolua/9router#769](https://github.com/decolua/9router/pull/769) — thanks @DEYLNN.

### 🐛 Fixed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
normalizePlanTier,
resolvePlanValue,
calculatePercentage,
matchesProviderFilter,
buildProviderOptions,
} from "./utils";
import Card from "@/shared/components/Card";
import { CardSkeleton } from "@/shared/components/Loading";
Expand All @@ -25,6 +27,7 @@ import { compareTr } from "@/shared/utils/turkishText";
const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter";
const LS_STATUS_FILTER = "omniroute:limits:statusFilter";
const LS_ENV_FILTER = "omniroute:limits:envFilter";
const LS_PROVIDER_FILTER = "omniroute:limits:providerFilter";

const MIN_FETCH_INTERVAL_MS = 30000;
const QUOTA_BAR_GREEN_THRESHOLD = 50;
Expand Down Expand Up @@ -260,6 +263,10 @@ export default function ProviderLimits({
if (typeof window === "undefined") return "all";
return localStorage.getItem(LS_ENV_FILTER) || "all";
});
const [providerFilter, setProviderFilter] = useState<string>(() => {
if (typeof window === "undefined") return "all";
return localStorage.getItem(LS_PROVIDER_FILTER) || "all";
});

const lastFetchTimeRef = useRef<Record<string, number>>({});
const staleProbeRef = useRef<Record<string, number>>({});
Expand Down Expand Up @@ -663,6 +670,7 @@ export default function ProviderLimits({

const visibleConnections = useMemo(() => {
const filtered = sortedConnections.filter((conn) => {
if (!matchesProviderFilter(conn, providerFilter)) return false;
Comment on lines 671 to +673

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.

high

There is a potential UI/state desynchronization bug when a stale provider filter is loaded from localStorage (e.g., if a provider was used in a previous session but is no longer present in the current session's connections).

In the dropdown UI, the <select> element falls back to "all" if the persisted providerFilter is not present in providerOptions:

value={
  providerFilter === "all" || providerOptions.includes(providerFilter)
    ? providerFilter
    : "all"
}

However, visibleConnections filters using the raw providerFilter state directly:

if (!matchesProviderFilter(conn, providerFilter)) return false;

This causes the list of connections to be completely empty (since no connection matches the stale provider), while the dropdown UI falsely displays "All providers" as selected.

To fix this, we should resolve the active provider filter inside visibleConnections using the same fallback logic (by checking if the filter is "all" or exists in the current connections).

Suggested change
const visibleConnections = useMemo(() => {
const filtered = sortedConnections.filter((conn) => {
if (!matchesProviderFilter(conn, providerFilter)) return false;
const visibleConnections = useMemo(() => {
const activeProvider =
providerFilter === "all" || sortedConnections.some((c) => c.provider === providerFilter)
? providerFilter
: "all";
const filtered = sortedConnections.filter((conn) => {
if (!matchesProviderFilter(conn, activeProvider)) return false;

const tierKey = tierByConnection[conn.id]?.key || "unknown";
if (tierFilter !== "all" && tierKey !== tierFilter) return false;
if (purchaseTypeFilter !== "all" && purchaseTypeByConnection[conn.id] !== purchaseTypeFilter)
Expand Down Expand Up @@ -702,9 +710,18 @@ export default function ProviderLimits({
statusFilter,
statusByConnection,
envFilter,
providerFilter,
quotaData,
]);

// Distinct provider keys present in the current connection set (after the
// upstream OAuth/api-key + USAGE_SUPPORTED_PROVIDERS filter), sorted via
// the i18n-aware comparator so the dropdown follows the locale's collation.
const providerOptions = useMemo(
() => buildProviderOptions(sortedConnections, compareTr),
[sortedConnections]
);

// Auto-fetch LIVE quota on open for visible connections that have no cached
// quota yet (e.g. a Codex account whose access_token expired — its per-connection
// live fetch refreshes the token serialized/cascade-safe and surfaces real quota).
Expand Down Expand Up @@ -750,6 +767,15 @@ export default function ProviderLimits({
}
}, []);

const handleSetProviderFilter = useCallback((value: string) => {
setProviderFilter(value);
try {
localStorage.setItem(LS_PROVIDER_FILTER, value);
} catch {
/* ignore */
}
}, []);

const renderInlineQuotaSummary = (quotas: any[]) => {
if (!quotas || quotas.length === 0) return null;
return (
Expand Down Expand Up @@ -951,6 +977,34 @@ export default function ProviderLimits({
})}
</div>

{/* Provider filter — single-select dropdown of providers actually
present in the current account set. Auto-falls back to "all" if
the persisted choice no longer exists in this session. */}
{providerOptions.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] uppercase tracking-wider text-text-muted font-semibold mr-1">
{tr("filterProviderLabel", "Provider")}
</span>
<select
value={
providerFilter === "all" || providerOptions.includes(providerFilter)
? providerFilter
: "all"
}
onChange={(event) => handleSetProviderFilter(event.target.value)}
aria-label={tr("filterProviderAriaLabel", "Filter quota providers")}
className="h-8 rounded-full border border-border bg-transparent px-3 text-xs font-semibold text-text-muted cursor-pointer"
>
<option value="all">{tr("filterProviderAll", "All providers")}</option>
{providerOptions.map((provider) => (
<option key={provider} value={provider}>
{PROVIDER_LABEL[provider] || provider}
</option>
))}
</select>
</div>
)}

{/* Env filter — only renders when at least one connection has a tag */}
{envTags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -690,3 +690,43 @@ export function getNextResetSummary(quotas: any[] | undefined): string | null {
}
return soonestIso ? formatCountdown(soonestIso) : null;
}

// --- Provider dropdown filter (PR #769 port) -----------------------------
// Pure helpers extracted from <ProviderLimits/> so the filter+dropdown logic
// can be exercised by unit tests without rendering React. Keep them free of
// browser-only globals so Node's native test runner can import them directly.

/**
* Returns true when `connection` should be visible under the selected
* `providerFilter`. The sentinel `"all"` matches every connection; any other
* value must equal the connection's `provider` key exactly. Connections with a
* missing/non-string provider are filtered out when a specific provider is
* selected (defensive — the live route only emits string provider keys).
*/
export function matchesProviderFilter(
connection: { provider?: unknown } | null | undefined,
providerFilter: string
): boolean {
if (!providerFilter || providerFilter === "all") return true;
if (!connection || typeof connection.provider !== "string") return false;
return connection.provider === providerFilter;
}

/**
* Distinct provider keys present in `connections`, optionally sorted with the
* supplied `compare` function (defaults to `String.prototype.localeCompare` so
* tests get deterministic output without depending on the i18n-aware
* `compareTr` helper). Empty / non-string provider values are skipped.
*/
export function buildProviderOptions(
connections: ReadonlyArray<{ provider?: unknown }>,
compare: (a: string, b: string) => number = (a, b) => a.localeCompare(b)
): string[] {
const seen = new Set<string>();
for (const conn of connections) {
if (conn && typeof conn.provider === "string" && conn.provider) {
seen.add(conn.provider);
}
}
return Array.from(seen).sort(compare);
}
87 changes: 87 additions & 0 deletions tests/unit/provider-limits-provider-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Port of decolua/9router PR #769 — provider dropdown filter for the quota
// dashboard. The upstream PR added the dropdown plus an "Expiring first" sort
// toggle; OmniRoute already always sorts by soonest reset within each status
// group (see ProviderLimits/index.tsx `visibleConnections`), so only the
// dropdown is genuinely new here. These tests guard the pure helpers that
// back the dropdown so regressions in the inline `useMemo` predicates fail
// loudly instead of silently widening / breaking the filter.

import test from "node:test";
import assert from "node:assert/strict";

import {
buildProviderOptions,
matchesProviderFilter,
} from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx";

test("PR#769: matchesProviderFilter — 'all' lets every connection through", () => {
assert.equal(matchesProviderFilter({ provider: "openai" }, "all"), true);
assert.equal(matchesProviderFilter({ provider: "anthropic" }, "all"), true);
// Defensive: even a malformed connection still matches the "all" sentinel
// so the dropdown's default never collapses the list to zero.
assert.equal(matchesProviderFilter({}, "all"), true);
assert.equal(matchesProviderFilter(null, "all"), true);
});

test("PR#769: matchesProviderFilter — exact match on a specific provider key", () => {
assert.equal(matchesProviderFilter({ provider: "openai" }, "openai"), true);
assert.equal(matchesProviderFilter({ provider: "openai" }, "anthropic"), false);
// Case-sensitive: provider keys are normalized upstream, so the filter must
// not match "OpenAI" against "openai" — that would silently union two
// distinct registry rows.
assert.equal(matchesProviderFilter({ provider: "OpenAI" }, "openai"), false);
});

test("PR#769: matchesProviderFilter — non-string provider is excluded under a specific filter", () => {
// Connections with a missing or non-string provider field cannot match a
// specific provider; only the "all" sentinel accepts them. This keeps
// dropdown filtering deterministic when the API returns partial rows.
assert.equal(matchesProviderFilter({}, "openai"), false);
assert.equal(matchesProviderFilter({ provider: undefined }, "openai"), false);
assert.equal(matchesProviderFilter({ provider: 42 as unknown }, "openai"), false);
assert.equal(matchesProviderFilter(null, "openai"), false);
});

test("PR#769: matchesProviderFilter — empty filter string is treated like 'all'", () => {
// Defensive: if the persisted localStorage value is somehow blanked the
// dropdown should keep listing everything instead of hiding all rows.
assert.equal(matchesProviderFilter({ provider: "openai" }, ""), true);
});

test("PR#769: buildProviderOptions — de-duplicates and sorts alphabetically", () => {
const connections = [
{ provider: "openai" },
{ provider: "anthropic" },
{ provider: "openai" },
{ provider: "gemini" },
{ provider: "anthropic" },
];
assert.deepEqual(buildProviderOptions(connections), ["anthropic", "gemini", "openai"]);
});

test("PR#769: buildProviderOptions — drops missing/empty/non-string provider keys", () => {
const connections = [
{ provider: "openai" },
{ provider: "" },
{ provider: undefined },
{ provider: null as unknown },
{ provider: 42 as unknown },
{},
{ provider: "anthropic" },
];
assert.deepEqual(buildProviderOptions(connections), ["anthropic", "openai"]);
});

test("PR#769: buildProviderOptions — honors a custom comparator (e.g. Turkish-aware)", () => {
// The production call site passes `compareTr` so locale-aware ordering
// applies. The helper must thread the comparator through Array#sort
// unchanged — verify with a reverse comparator instead of pulling in the
// real i18n helper.
const reverse = (a: string, b: string) => b.localeCompare(a);
const out = buildProviderOptions([{ provider: "a" }, { provider: "c" }, { provider: "b" }], reverse);
assert.deepEqual(out, ["c", "b", "a"]);
});

test("PR#769: buildProviderOptions — returns [] for an empty connection list", () => {
assert.deepEqual(buildProviderOptions([]), []);
});
Loading