Add per-connection Provider Quota visibility (#7360)

* feat(dashboard): add Provider Quota visibility toggle per connection

* refactor(dashboard): extract provider quota visibility controls

Move quota visibility UI and update logic into reusable components, add Portuguese translations, and remove the stale migration gap allowlist entry.

* Hide quota visibility controls for unsupported providers

* chore(ci): retrigger GitHub checks

* fix(db): renumber quota-visibility migration past release tip (121→125)

122_free_proxy_sync_errors.sql, 123_quota_auto_ping.sql, and
124_generic_session_affinity_ttl.sql have since landed on
release/v3.8.49, so 121 is now out-of-sequence and would not apply on
databases already past 122+. Renumbers to 125 (the next free slot past
the current release tip) and restores "121" in check-migration-numbering's
KNOWN_GAPS allowlist, since 121 remains a genuine unfilled gap once this
migration moves off that number.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): rebaseline file-size + complexity for resync merge

The release-resync merge unions two already-compliant features in the
same god-component (ConnectionRow.tsx/ConnectionsListPanel.tsx): this
PR's per-connection quota-visibility wiring and release's confirm-
delete-account wiring (#7361). Both were individually within budget
(785/786 lines); combined they land at 791. Complexity count moves
2058->2059 for the same reason (2 previously-compliant .map() render
callbacks in ConnectionsListPanel.tsx now marginally exceed the
80-line function cap). No new logic was written — see the
_rebaseline_2026_07_18_pr7360_quota_visibility_resync justification
entries in both baseline files for the full accounting. Verified via
a byte-for-byte diff of the violation lists between origin/release/
v3.8.49 tip and this merge. Structural shrink stays tracked in #3501.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(db): split _updateConnectionRow update assembly (complexity gate)

_updateConnectionRow grew past the 80-line max-lines-per-function ceiling
after this branch added quota_visible column handling. Extract the
`.run()` params assembly (field mapping/normalization, unchanged) into a
module-private `_buildUpdateConnectionRowParams` helper in the same file
so the SQL statement + call site stay in `_updateConnectionRow` while the
function itself drops back under the gate. No behavior change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Jan Leon
2026-07-18 20:12:52 +02:00
committed by GitHub
parent 5f04d5bcbd
commit 78d2eee914
27 changed files with 340 additions and 61 deletions

View File

@@ -0,0 +1,3 @@
- Provider connections can now be shown or hidden individually on the Provider Quota page. The
visibility setting is available on each account in the provider detail view and does not affect
routing or account activation.

View File

@@ -1,6 +1,7 @@
{
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
"count": 2058,
"count": 2059,
"_rebaseline_2026_07_18_pr7360_quota_visibility_resync": "2058->2059 (+1 vs recorded ceiling; measured 2056 fresh on release tip cab9e5f0c alone, so this ceiling still carries 2 units of un-banked slack from prior shrinkage — real regression from this merge is 2056->2059, +3). PR #7360 (JxnLexn) release-resync: merging origin/release/v3.8.49 to resolve the 3-file conflict (ConnectionRow.tsx/ConnectionsListPanel.tsx/useProviderConnections.ts) unions two already-compliant features in the same already-oversized god-component: release's confirm-delete-account wiring (#7361) and this PR's per-connection quota-visibility wiring. Diffed release-tip-only vs merged violation lists (scripts dumped via getComplexityEslintReport): most entries are the SAME pre-existing violations shifted a few lines (ConnectionRow/getStatusPresentation/inferErrorType — no count change) or marginally bigger (ConnectionRow function complexity 85->86, ConnectionsListPanel function 498->510 lines) from the two ConnectionRow call sites each gaining both PRs' multi-line JSX props. The 2 genuinely NEW crossings are the 'no tag' and 'tagged groups' .map() render callbacks in ConnectionsListPanel.tsx (83 and 85 lines, was <=80 on both parents individually) tipping over 80 lines specifically because both PRs' props land on the same call sites. No new logic was written during the resync itself (only import-statement unions); the growth is inherent to combining the two already-reviewed feature branches. Structural shrink tracked in #3501. Tighten via --update next cycle (true floor is 2056, not 2058).",
"_rebaseline_2026_07_17_v3849_ownerprs_providers": "2056->2058 (+2). v3.8.49 owner-PR merge campaign own-growth: the new provider handlers/dispatch branches merged this cycle (freetheai/felo/notion/segmind/deepinfra/novita/msdesigner image+video handlers, each adding a format-dispatch guard) pushed cyclomatic violations 2056->2058. Fast-gates PR->release do not run the complexity ratchet, so this surfaced only on re-sync. Spread across the new leaf handlers (not a single extractable function); measured on the release tip. Structural shrink tracked in #3501.",
"_rebaseline_2026_07_10_v3847_merge_burst": "2053->2054 (+1). Drift herdado do merge burst do dia em release/v3.8.47 (campanha /implement-prs: ~36 PRs mergeados — órfãos, features do dono, ports). O check:complexity NÃO roda no fast-path PR->release, então o ramo acumulou o +1 sem rebaselinar (mesma família de todos os rebaselines abaixo). Trust-but-verify: medido 2054 no tip da release pós-burst; a única função flagada nova é pré-existente (getResolvedModelCapabilities em modelCapabilities.ts, já >teto antes de #6714). Nenhum PR órfão/feature introduz violação NOVA — os fixes deste ciclo são complexity-net-zero. Rebaseline aprovado pelo dono (2026-07-10) para destravar o FQG dos ~7 órfãos verdes-exceto-complexity. Tighten via --update next cycle.",
"_rebaseline_2026_07_10_gcf_v3_2": "2054->2056 (+2). PR feat/headroom-gcf-v3.2-nested-flattening: own growth from re-vendoring the GCF (Headroom) codec to spec v3.2 (nested flattening). The 2 new over-threshold functions are the v3.2 `>`-path flatten/unflatten walk in the vendored generic-profile encode/decode paths (open-sse/services/compression/engines/headroom/gcf/{generic,decode_generic}.ts). This is imported third-party code kept byte-faithful to upstream gcf-typescript, not extractable without diverging from the vendored source; local measures 2055 on the merged tree; frozen at 2056 = the base's CI-observed 2054 + this PR's 2 new functions, matching the documented local-vs-CI off-by-one convention (see _rebaseline_2026_07_02_v3844_ci_observed) so the GitHub runner stays green. Round-trip guarded by tests/unit/compression/headroom-smartcrusher.test.ts (deep-nested case). Structural shrink belongs upstream in gcf, not here.",

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_07_18_pr7360_quota_visibility_resync": "PR #7360 (JxnLexn, per-connection Provider Quota visibility) release-resync: merging origin/release/v3.8.49 into the PR's own branch to resolve its 3-file conflict (ConnectionRow.tsx/ConnectionsListPanel.tsx/useProviderConnections.ts import unions) surfaced ProviderDetailPageClient.tsx 785->791 (measured via check-file-size.mjs's split('\\n').length; wc -l reports 790). Both parents were individually within the 786 cap: release tip alone measures 785 (its own confirm-delete-account #7361 wiring), and the PR's own pre-merge tip also measured 785 (its own quota-visibility wiring) — the two independent, already-cohesive features simply sum past the frozen ceiling once unioned in this same already-oversized god-component (tracked for decomposition under #3501). No new logic was written during the resync; the only edits were import-statement unions. Frozen raised 786->791.",
"_rebaseline_2026_07_18_pr7653_chat_tracker_import": "PR #7653 merge-interaction growth: release moved chat.ts to its 1796 cap while this PR adds the single side-effect import 'quotaTrackersBatch.ts' (line 130) — chat.ts IS the canonical quota-fetcher registration point (codex/bailian/deepseek/openrouter/opencode/generic all import+register there), so the +1 is irreducible call-site wiring. 1796->1797. Covered by tests/unit/{agentrouter,v0,freemodel}-quota-fetcher.test.ts.",
"_rebaseline_2026_07_17_pr7653_agentrouter_console_fields": "PR #7653 own growth (missing acceptance criterion: the AgentRouter quota tracker (#6850) read providerSpecificData.consoleApiKey/newApiUserId but neither field had dashboard UI for provider agentrouter — consoleApiKey was gated to bailian-coding-plan only and newApiUserId had zero UI). AddApiKeyModal.tsx 961->967 (+6) and EditConnectionModal.tsx 1278->1286 (+8) = import + a single <AgentrouterConsoleFields .../> render call plus the newApiUserId formData init field. The actual Input rendering (both consoleApiKey reuse + the new newApiUserId field) was EXTRACTED into a new leaf src/app/(dashboard)/dashboard/providers/[id]/components/modals/AgentrouterConsoleFields.tsx (48 LOC, <cap), mirroring the QuotaScrapingFields.tsx / GlmTeamQuotaFields.tsx precedent (#6351) so the frozen modals only carry the irreducible call-site wiring. Persist logic lives in connectionProviderSpecificData.ts (not frozen). Covered by tests/unit/agentrouter-connection-modal-fields.test.ts.",
"_rebaseline_2026_07_17_v3849_6842_free_window_wiring": "PR #7651 (openrouter :free-window quota tracking) follow-up: the counter shipped built but never wired into the request pipeline, so combos kept spending guaranteed-429 requests on exhausted free-tier targets. Own growth: src/sse/services/auth.ts 2461->2462 (+1, irreducible at the existing model-aware preflight chokepoint — the `provider === \"codex\"` check that forwards requestedModel into the connection arg is extended to also cover `openrouter`, one added boolean + a doc comment, offset to a single net line by dropping the now-redundant inline condition). Enforcement itself lives in open-sse/services/openrouterQuotaFetcher.ts (not frozen) and the dispatch-time record/correct hooks live in open-sse/executors/base.ts (not frozen). Covered by tests/unit/openrouter-free-window-wiring-6842.test.ts.",
@@ -213,7 +214,7 @@
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2612,
"src/app/(dashboard)/dashboard/health/page.tsx": 1091,
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 786,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 791,
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 942,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 967,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1286,

View File

@@ -123,6 +123,7 @@ export default function ProviderDetailPageClient() {
deleteConfirm,
handleUpdateConnectionStatus,
handleToggleRateLimit,
handleToggleQuotaVisibility,
handleToggleClaudeExtraUsage,
handleToggleCodexLimit,
handleToggleCliproxyapiMode,
@@ -486,7 +487,10 @@ export default function ProviderDetailPageClient() {
)}
{!isUpstreamProxyProvider && !isFreeNoAuth && (
<Card>
<ProviderAccountRoutingCard providerKey={providerId} connectionCount={connections.length} />
<ProviderAccountRoutingCard
providerKey={providerId}
connectionCount={connections.length}
/>
<ConnectionsHeaderToolbar
providerId={providerId}
providerInfo={providerInfo}
@@ -585,6 +589,7 @@ export default function ProviderDetailPageClient() {
deleteConfirm={deleteConfirm}
handleUpdateConnectionStatus={handleUpdateConnectionStatus}
handleToggleRateLimit={handleToggleRateLimit}
handleToggleQuotaVisibility={handleToggleQuotaVisibility}
handleToggleClaudeExtraUsage={handleToggleClaudeExtraUsage}
handleToggleCliproxyapiMode={handleToggleCliproxyapiMode}
handleToggleCodexLimit={handleToggleCodexLimit}

View File

@@ -129,6 +129,7 @@ describe("useProviderConnections — initial state", () => {
"fetchProxyConfig",
"handleUpdateConnectionStatus",
"handleToggleRateLimit",
"handleToggleQuotaVisibility",
"handleToggleClaudeExtraUsage",
"handleToggleCodexLimit",
"handleToggleCliproxyapiMode",
@@ -334,10 +335,7 @@ describe("useProviderModels — initial state", () => {
// Resolve the hooks dir from the repo root (vitest runs from cwd). Was a
// hardcoded absolute worktree path that broke the test outside that worktree
// (#3501 Phase 1g-1j).
const HOOKS_DIR = path.join(
process.cwd(),
"src/app/(dashboard)/dashboard/providers/[id]/hooks"
);
const HOOKS_DIR = path.join(process.cwd(), "src/app/(dashboard)/dashboard/providers/[id]/hooks");
describe("Cycle-safety — hooks do not import ProviderDetailPageClient", () => {
// We allow the name in JSDoc comments; what we forbid is an actual ES import statement.

View File

@@ -21,6 +21,7 @@ import {
ERROR_TYPE_LABELS,
} from "../providerPageHelpers";
import { getCodexPlanLabel } from "../codexPlanLabel";
import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle";
// ---------------------------------------------------------------------------
// Types (exported so the client can reference them without re-importing)
@@ -48,6 +49,7 @@ export interface ConnectionRowConnection {
authType?: string;
proxyEnabled?: boolean;
perKeyProxyEnabled?: boolean;
quotaVisible?: boolean;
}
export interface ConnectionRowProps {
@@ -64,6 +66,7 @@ export interface ConnectionRowProps {
onMoveDown: () => void;
onToggleActive: (isActive?: boolean) => void | Promise<void>;
onToggleRateLimit: (enabled?: boolean) => void;
onToggleQuotaVisibility?: (visible: boolean) => void;
onToggleClaudeExtraUsage?: (enabled?: boolean) => void;
onToggleCodex5h?: (enabled?: boolean) => void;
onToggleCodexWeekly?: (enabled?: boolean) => void;
@@ -347,6 +350,7 @@ export default function ConnectionRow({
onMoveDown,
onToggleActive,
onToggleRateLimit,
onToggleQuotaVisibility,
onToggleClaudeExtraUsage,
onToggleCodex5h,
onToggleCodexWeekly,
@@ -447,6 +451,7 @@ export default function ConnectionRow({
const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown, t);
const rateLimitEnabled = !!connection.rateLimitProtection;
const quotaVisible = connection.quotaVisible !== false;
const codexPolicy =
connection.providerSpecificData &&
typeof connection.providerSpecificData === "object" &&
@@ -623,6 +628,12 @@ export default function ConnectionRow({
<span className="material-symbols-outlined text-[13px]">shield</span>
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
</button>
{onToggleQuotaVisibility && (
<ProviderQuotaVisibilityToggle
visible={quotaVisible}
onToggle={onToggleQuotaVisibility}
/>
)}
{isClaude && (
<>
<span className="text-text-muted/30 select-none">|</span>

View File

@@ -7,6 +7,7 @@ import { pickDisplayValue } from "@/shared/utils/maskEmail";
import { readBooleanToggle, providerCountText } from "../providerPageHelpers";
import { compareTr } from "@/shared/utils/turkishText";
import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
import { supportsProviderQuota } from "@/shared/utils/providerQuotaVisibility";
import type { ConnectionDeleteConfirmState } from "../hooks/useConnectionDeleteConfirm";
type ConnectionsListPanelProps = {
@@ -41,6 +42,7 @@ type ConnectionsListPanelProps = {
deleteConfirm: ConnectionDeleteConfirmState;
handleUpdateConnectionStatus: (id: string, isActive: boolean) => void;
handleToggleRateLimit: (id: string, enabled: boolean) => void;
handleToggleQuotaVisibility: (id: string, visible: boolean) => void;
handleToggleClaudeExtraUsage: (id: string, enabled: boolean) => void;
handleToggleCliproxyapiMode: (id: string, enabled: boolean) => void;
handleToggleCodexLimit: (id: string, type: "use5h" | "useWeekly", enabled: boolean) => void;
@@ -68,6 +70,14 @@ type ConnectionsListPanelProps = {
t: any; // ProviderMessageTranslator
};
function quotaVisibilityHandler(
supported: boolean,
connectionId: string,
toggle: (id: string, visible: boolean) => void
) {
return supported ? (visible: boolean) => toggle(connectionId, visible) : undefined;
}
export default function ConnectionsListPanel({
connections,
providerId,
@@ -98,6 +108,7 @@ export default function ConnectionsListPanel({
deleteConfirm,
handleUpdateConnectionStatus,
handleToggleRateLimit,
handleToggleQuotaVisibility,
handleToggleClaudeExtraUsage,
handleToggleCliproxyapiMode,
handleToggleCodexLimit,
@@ -124,6 +135,7 @@ export default function ConnectionsListPanel({
t,
}: ConnectionsListPanelProps) {
const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0));
const quotaSupported = supportsProviderQuota(providerId);
const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined);
const allSelected = selectedIds.size === connections.length && connections.length > 0;
const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length;
@@ -332,6 +344,11 @@ export default function ConnectionsListPanel({
onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
onToggleQuotaVisibility={quotaVisibilityHandler(
quotaSupported,
conn.id,
handleToggleQuotaVisibility
)}
onToggleClaudeExtraUsage={(enabled) =>
handleToggleClaudeExtraUsage(conn.id, enabled)
}
@@ -504,6 +521,11 @@ export default function ConnectionsListPanel({
onMoveDown={() => handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
onToggleQuotaVisibility={quotaVisibilityHandler(
quotaSupported,
conn.id,
handleToggleQuotaVisibility
)}
onToggleClaudeExtraUsage={(enabled) =>
handleToggleClaudeExtraUsage(conn.id, enabled)
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useTranslations } from "next-intl";
import { providerText } from "../providerPageHelpers";
interface ProviderQuotaVisibilityToggleProps {
visible: boolean;
onToggle: (visible: boolean) => void;
}
export default function ProviderQuotaVisibilityToggle({
visible,
onToggle,
}: ProviderQuotaVisibilityToggleProps) {
const t = useTranslations("providers");
const actionLabel = visible
? providerText(t, "hideConnectionFromProviderQuota", "Hide this account from Provider Quota")
: providerText(t, "showConnectionInProviderQuota", "Show this account in Provider Quota");
return (
<>
<span className="text-text-muted/30 select-none">|</span>
<button
type="button"
onClick={() => onToggle(!visible)}
aria-pressed={visible}
aria-label={actionLabel}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
visible
? "bg-sky-500/15 text-sky-500 hover:bg-sky-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title={actionLabel}
>
<span className="material-symbols-outlined text-[13px]">
{visible ? "visibility" : "visibility_off"}
</span>
{providerText(t, "providerQuotaShort", "Quota")}
</button>
</>
);
}

View File

@@ -119,6 +119,31 @@ describe("phase-1d extractions (#3501)", () => {
expect(c).toBeDefined();
});
it("ConnectionRow toggles Provider Quota visibility", () => {
const onToggleQuotaVisibility = vi.fn();
const c = renderComponent(
<ConnectionRow
connection={{ id: "conn-quota", name: "Hidden quota", quotaVisible: false }}
isOAuth={false}
isFirst={true}
isLast={true}
onMoveUp={vi.fn()}
onMoveDown={vi.fn()}
onToggleActive={vi.fn()}
onToggleRateLimit={vi.fn()}
onToggleQuotaVisibility={onToggleQuotaVisibility}
onRetest={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>
);
const button = c.querySelector('button[aria-pressed="false"]') as HTMLButtonElement;
expect(button).not.toBeNull();
act(() => button.click());
expect(onToggleQuotaVisibility).toHaveBeenCalledWith(true);
});
it("ConnectionRow renders cooldown badge when rateLimitedUntil is in the future", () => {
const conn = {
id: "conn-3",
@@ -180,22 +205,14 @@ describe("phase-1d extractions (#3501)", () => {
it("SiliconFlowEndpointModal mounts when isOpen=false (renders nothing visible)", () => {
const c = renderComponent(
<SiliconFlowEndpointModal
isOpen={false}
onSelect={vi.fn()}
onClose={vi.fn()}
/>
<SiliconFlowEndpointModal isOpen={false} onSelect={vi.fn()} onClose={vi.fn()} />
);
expect(c).toBeDefined();
});
it("SiliconFlowEndpointModal mounts when isOpen=true without throwing", () => {
const c = renderComponent(
<SiliconFlowEndpointModal
isOpen={true}
onSelect={vi.fn()}
onClose={vi.fn()}
/>
<SiliconFlowEndpointModal isOpen={true} onSelect={vi.fn()} onClose={vi.fn()} />
);
expect(c).toBeDefined();
});

View File

@@ -27,6 +27,7 @@ import { useNotificationStore } from "@/store/notificationStore";
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
import type { ConnectionRowConnection } from "../components/ConnectionRow";
import { normalizeCodexLimitPolicy } from "../providerPageHelpers";
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
import { useReorderByAvailability } from "./useReorderByAvailability";
import {
useConnectionDeleteConfirm,
@@ -74,8 +75,7 @@ export interface UseProviderConnectionsReturn {
setBatchTestResults: (r: BatchTestResults) => void;
setConnections: (
updater:
| ConnectionRowConnection[]
| ((prev: ConnectionRowConnection[]) => ConnectionRowConnection[])
ConnectionRowConnection[] | ((prev: ConnectionRowConnection[]) => ConnectionRowConnection[])
) => void;
setProviderNode: (node: any) => void;
@@ -87,6 +87,7 @@ export interface UseProviderConnectionsReturn {
deleteConfirm: ConnectionDeleteConfirmState;
handleUpdateConnectionStatus: (id: string, isActive: boolean) => Promise<void>;
handleToggleRateLimit: (connectionId: string, enabled: boolean) => Promise<void>;
handleToggleQuotaVisibility: (connectionId: string, visible: boolean) => Promise<void>;
handleToggleClaudeExtraUsage: (connectionId: string, enabled: boolean) => Promise<void>;
handleToggleCodexLimit: (connectionId: string, field: string, enabled: boolean) => Promise<void>;
handleToggleCliproxyapiMode: (connectionId: string, enabled: boolean) => Promise<void>;
@@ -135,6 +136,7 @@ export function useProviderConnections(
// ── core state ──────────────────────────────────────────────────────────
const [connections, setConnections] = useState<ConnectionRowConnection[]>([]);
const handleToggleQuotaVisibility = useProviderQuotaVisibility(setConnections, notify, t);
const [providerNode, setProviderNode] = useState<any>(null);
const [loading, setLoading] = useState(true);
@@ -894,6 +896,7 @@ export function useProviderConnections(
deleteConfirm,
handleUpdateConnectionStatus,
handleToggleRateLimit,
handleToggleQuotaVisibility,
handleToggleClaudeExtraUsage,
handleToggleCodexLimit,
handleToggleCliproxyapiMode,

View File

@@ -0,0 +1,45 @@
"use client";
import { useCallback, type Dispatch, type SetStateAction } from "react";
import type { ConnectionRowConnection } from "../components/ConnectionRow";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
interface NotificationStore {
error: (message: string) => void;
}
export function useProviderQuotaVisibility(
setConnections: Dispatch<SetStateAction<ConnectionRowConnection[]>>,
notify: NotificationStore,
t: ProviderMessageTranslator
) {
return useCallback(
async (connectionId: string, visible: boolean) => {
try {
const response = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quotaVisible: visible }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setConnections((previous) =>
previous.map((connection) =>
connection.id === connectionId ? { ...connection, quotaVisible: visible } : connection
)
);
} catch (error) {
console.error("Error toggling provider quota visibility:", error);
notify.error(
providerText(
t,
"quotaVisibilityUpdateFailed",
"Failed to update Provider Quota visibility"
)
);
}
},
[notify, setConnections, t]
);
}

View File

@@ -29,6 +29,7 @@ import { formatAutoRefreshCountdown } from "./formatters";
import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback";
import { compareTr } from "@/shared/utils/turkishText";
import { fetchWithTimeout } from "@/shared/utils/fetchTimeout";
import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility";
// Bound the two first-paint requests so a stalled connection cannot wedge
// `initialLoading` on `true` and freeze the quota page on its skeleton forever
@@ -517,6 +518,7 @@ export default function ProviderLimits({
() =>
connections.filter(
(conn) =>
isProviderQuotaVisible(conn) &&
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
(conn.authType === "oauth" || conn.authType === "apikey")
),

View File

@@ -6,6 +6,7 @@ import Card from "@/shared/components/Card";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback";
import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility";
type Connection = {
id: string;
@@ -13,6 +14,7 @@ type Connection = {
authType?: string;
email?: string;
name?: string;
quotaVisible?: boolean;
};
type QuotaData = Record<string, any>;
@@ -121,6 +123,7 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide
// Only keep connections that are usage/quota supported
const relevant = conns.filter(
(c) =>
isProviderQuotaVisible(c) &&
USAGE_SUPPORTED_PROVIDERS.includes(c.provider) &&
(c.authType === "oauth" || c.authType === "apikey")
);

View File

@@ -24,7 +24,10 @@ import {
} from "@/lib/providers/claudeExtraUsage";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
import { refreshConnectionRateLimits, enableRateLimitProtection } from "@/../open-sse/services/rateLimitManager";
import {
refreshConnectionRateLimits,
enableRateLimitProtection,
} from "@/../open-sse/services/rateLimitManager";
function normalizeCodexLimitPolicy(
incoming: unknown,
@@ -135,6 +138,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
quotaWindowThresholds: incomingWindowThresholds,
proxyEnabled,
perKeyProxyEnabled,
quotaVisible,
projectId,
providerSpecificData: incomingPsd,
rateLimitOverrides,
@@ -191,6 +195,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
if (rateLimitOverrides !== undefined) updateData.rateLimitOverrides = rateLimitOverrides;
if (proxyEnabled !== undefined) updateData.proxyEnabled = proxyEnabled;
if (perKeyProxyEnabled !== undefined) updateData.perKeyProxyEnabled = perKeyProxyEnabled;
if (quotaVisible !== undefined) updateData.quotaVisible = quotaVisible;
// Merge providerSpecificData (partial update — preserve existing keys not sent by caller)
if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") {

View File

@@ -3837,6 +3837,10 @@
"removeModel": "Modell entfernen",
"rateLimitProtected": "Geschützt",
"rateLimitUnprotected": "Ungeschützt",
"providerQuotaShort": "Quota",
"hideConnectionFromProviderQuota": "Diesen Account auf der Seite „Provider Quota“ ausblenden",
"showConnectionInProviderQuota": "Diesen Account auf der Seite „Provider Quota“ anzeigen",
"quotaVisibilityUpdateFailed": "Die Sichtbarkeit unter „Provider Quota“ konnte nicht aktualisiert werden",
"enableRateLimitProtection": "Klicken Sie hier, um den Ratenbegrenzungsschutz zu aktivieren",
"disableRateLimitProtection": "Klicken Sie hier, um den Ratenbegrenzungsschutz zu deaktivieren",
"productionKey": "Produktionsschlüssel",

View File

@@ -4227,6 +4227,10 @@
"removeModel": "Remove model",
"rateLimitProtected": "Protected",
"rateLimitUnprotected": "Unprotected",
"providerQuotaShort": "Quota",
"hideConnectionFromProviderQuota": "Hide this account from Provider Quota",
"showConnectionInProviderQuota": "Show this account in Provider Quota",
"quotaVisibilityUpdateFailed": "Failed to update Provider Quota visibility",
"enableRateLimitProtection": "Click to enable rate limit protection",
"disableRateLimitProtection": "Click to disable rate limit protection",
"testAllProgress": "Tested {done}/{total}",

View File

@@ -4227,6 +4227,10 @@
"removeModel": "Remover modelo",
"rateLimitProtected": "Protegido",
"rateLimitUnprotected": "Desprotegido",
"providerQuotaShort": "Cota",
"hideConnectionFromProviderQuota": "Ocultar esta conta da página de Cotas dos Provedores",
"showConnectionInProviderQuota": "Mostrar esta conta na página de Cotas dos Provedores",
"quotaVisibilityUpdateFailed": "Não foi possível atualizar a visibilidade em Cotas dos Provedores",
"enableRateLimitProtection": "Clique para habilitar proteção de limite de taxa",
"disableRateLimitProtection": "Clique para desabilitar proteção de limite de taxa",
"testAllProgress": "__MISSING__:Tested {done}/{total}",

View File

@@ -11,6 +11,14 @@
type JsonRecord = Record<string, unknown>;
const BOOLEAN_CAMEL_COLUMNS = new Set([
"isActive",
"rateLimitProtection",
"proxyEnabled",
"perKeyProxyEnabled",
"quotaVisible",
]);
export function toSnakeCase(str: string): string {
return str.replace(/([A-Z])/g, "_$1").toLowerCase();
}
@@ -33,12 +41,7 @@ export function rowToCamel(row: unknown): JsonRecord | null {
const result: JsonRecord = {};
for (const [k, v] of Object.entries(row as JsonRecord)) {
const camelKey = toCamelCase(k);
if (
camelKey === "isActive" ||
camelKey === "rateLimitProtection" ||
camelKey === "proxyEnabled" ||
camelKey === "perKeyProxyEnabled"
) {
if (BOOLEAN_CAMEL_COLUMNS.has(camelKey)) {
result[camelKey] = v === 1 || v === true;
} else if (camelKey === "providerSpecificData" && typeof v === "string") {
try {

View File

@@ -241,6 +241,7 @@ const SCHEMA_SQL = `
max_concurrent INTEGER,
proxy_enabled INTEGER NOT NULL DEFAULT 1,
per_key_proxy_enabled INTEGER NOT NULL DEFAULT 0,
quota_visible INTEGER NOT NULL DEFAULT 1,
quota_window_thresholds_json TEXT,
rate_limit_overrides_json TEXT,
created_at TEXT NOT NULL,
@@ -1089,7 +1090,11 @@ export function getDbInstance(): SqliteDatabase {
// V8 heap (sql.js loads the whole file into WASM memory). Throwing
// immediately gives the user a clear "increase --max-old-space-size"
// signal instead of silently renaming a perfectly good DB.
if (/out of memory|allocation failure|Array buffer allocation failed|allocation failed/i.test(message)) {
if (
/out of memory|allocation failure|Array buffer allocation failed|allocation failed/i.test(
message
)
) {
// Cycle-breaker (#6835): the OOM path never renames the file away,
// so it never trips the generic probe-failed/restore cap above. Cap
// it independently after 3 consecutive OOM failures (same threshold

View File

@@ -0,0 +1,5 @@
-- 125_provider_connection_quota_visibility.sql
-- Controls whether a provider connection is shown in quota-focused dashboard views.
-- This is presentation-only and does not affect routing or provider-limit collection.
ALTER TABLE provider_connections ADD COLUMN quota_visible INTEGER NOT NULL DEFAULT 1;

View File

@@ -380,6 +380,7 @@ export async function createProviderConnection(data: JsonRecord) {
updatedAt: now,
proxyEnabled: normalizeBooleanColumn(data.proxyEnabled, true),
perKeyProxyEnabled: normalizeBooleanColumn(data.perKeyProxyEnabled, false),
quotaVisible: normalizeBooleanColumn(data.quotaVisible, true),
};
// Optional fields
@@ -411,6 +412,7 @@ export async function createProviderConnection(data: JsonRecord) {
"maxConcurrent",
"proxyEnabled",
"perKeyProxyEnabled",
"quotaVisible",
"quotaWindowThresholds",
"rateLimitOverrides",
"healthCheckInterval",
@@ -469,7 +471,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
last_tested, api_key, id_token, provider_specific_data,
expires_in, display_name, global_priority, default_model,
token_type, consecutive_use_count, rate_limit_protection, last_used_at, "group", max_concurrent,
proxy_enabled, per_key_proxy_enabled, quota_window_thresholds_json, rate_limit_overrides_json,
proxy_enabled, per_key_proxy_enabled, quota_visible, quota_window_thresholds_json, rate_limit_overrides_json,
created_at, updated_at
) VALUES (
@id, @provider, @authType, @name, @email, @priority, @isActive,
@@ -480,7 +482,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
@lastTested, @apiKey, @idToken, @providerSpecificData,
@expiresIn, @displayName, @globalPriority, @defaultModel,
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @group, @maxConcurrent,
@proxyEnabled, @perKeyProxyEnabled, @quotaWindowThresholdsJson, @rateLimitOverridesJson,
@proxyEnabled, @perKeyProxyEnabled, @quotaVisible, @quotaWindowThresholdsJson, @rateLimitOverridesJson,
@createdAt, @updatedAt
)
`
@@ -527,6 +529,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
maxConcurrent: conn.maxConcurrent ?? null,
proxyEnabled: normalizeBooleanColumn(conn.proxyEnabled, true) ? 1 : 0,
perKeyProxyEnabled: normalizeBooleanColumn(conn.perKeyProxyEnabled, false) ? 1 : 0,
quotaVisible: normalizeBooleanColumn(conn.quotaVisible, true) ? 1 : 0,
quotaWindowThresholdsJson: serializeJsonField(conn.quotaWindowThresholds),
rateLimitOverridesJson: serializeJsonField(conn.rateLimitOverrides),
createdAt: conn.createdAt,
@@ -534,37 +537,11 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
});
}
function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
const now = data.updatedAt || new Date().toISOString();
db.prepare(
`
UPDATE provider_connections SET
provider = @provider, auth_type = @authType, name = @name, email = @email,
priority = @priority, is_active = @isActive, access_token = @accessToken,
refresh_token = @refreshToken, expires_at = @expiresAt, token_expires_at = @tokenExpiresAt,
scope = @scope, project_id = @projectId, test_status = @testStatus, error_code = @errorCode,
last_error = @lastError, last_error_at = @lastErrorAt, last_error_type = @lastErrorType,
last_error_source = @lastErrorSource, backoff_level = @backoffLevel,
rate_limited_until = @rateLimitedUntil, health_check_interval = @healthCheckInterval,
last_health_check_at = @lastHealthCheckAt, last_tested = @lastTested, api_key = @apiKey,
id_token = @idToken, provider_specific_data = @providerSpecificData,
expires_in = @expiresIn, display_name = @displayName, global_priority = @globalPriority,
default_model = @defaultModel, token_type = @tokenType,
consecutive_use_count = @consecutiveUseCount,
rate_limit_protection = @rateLimitProtection,
last_used_at = @lastUsedAt,
"group" = @group,
max_concurrent = @maxConcurrent,
quota_window_thresholds_json = @quotaWindowThresholdsJson,
proxy_enabled = @proxyEnabled,
per_key_proxy_enabled = @perKeyProxyEnabled,
rate_limit_overrides_json = @rateLimitOverridesJson,
last_ping_at = @lastPingAt,
last_pinged_reset_key = @lastPingedResetKey,
updated_at = @updatedAt
WHERE id = @id
`
).run({
// Assembles the `.run()` params for _updateConnectionRow's UPDATE statement.
// Split out purely to keep _updateConnectionRow under the max-lines-per-function
// gate — same field mapping/normalization as before, just relocated.
function _buildUpdateConnectionRowParams(id: string, data: JsonRecord, now: unknown) {
return {
id,
provider: data.provider,
authType: data.authType || null,
@@ -608,11 +585,46 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
quotaWindowThresholdsJson: serializeJsonField(data.quotaWindowThresholds),
proxyEnabled: normalizeBooleanColumn(data.proxyEnabled, true) ? 1 : 0,
perKeyProxyEnabled: normalizeBooleanColumn(data.perKeyProxyEnabled, false) ? 1 : 0,
quotaVisible: normalizeBooleanColumn(data.quotaVisible, true) ? 1 : 0,
rateLimitOverridesJson: serializeJsonField(data.rateLimitOverrides),
lastPingAt: data.lastPingAt || null,
lastPingedResetKey: data.lastPingedResetKey || null,
updatedAt: now,
});
};
}
function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
const now = data.updatedAt || new Date().toISOString();
db.prepare(
`
UPDATE provider_connections SET
provider = @provider, auth_type = @authType, name = @name, email = @email,
priority = @priority, is_active = @isActive, access_token = @accessToken,
refresh_token = @refreshToken, expires_at = @expiresAt, token_expires_at = @tokenExpiresAt,
scope = @scope, project_id = @projectId, test_status = @testStatus, error_code = @errorCode,
last_error = @lastError, last_error_at = @lastErrorAt, last_error_type = @lastErrorType,
last_error_source = @lastErrorSource, backoff_level = @backoffLevel,
rate_limited_until = @rateLimitedUntil, health_check_interval = @healthCheckInterval,
last_health_check_at = @lastHealthCheckAt, last_tested = @lastTested, api_key = @apiKey,
id_token = @idToken, provider_specific_data = @providerSpecificData,
expires_in = @expiresIn, display_name = @displayName, global_priority = @globalPriority,
default_model = @defaultModel, token_type = @tokenType,
consecutive_use_count = @consecutiveUseCount,
rate_limit_protection = @rateLimitProtection,
last_used_at = @lastUsedAt,
"group" = @group,
max_concurrent = @maxConcurrent,
quota_window_thresholds_json = @quotaWindowThresholdsJson,
proxy_enabled = @proxyEnabled,
per_key_proxy_enabled = @perKeyProxyEnabled,
quota_visible = @quotaVisible,
rate_limit_overrides_json = @rateLimitOverridesJson,
last_ping_at = @lastPingAt,
last_pinged_reset_key = @lastPingedResetKey,
updated_at = @updatedAt
WHERE id = @id
`
).run(_buildUpdateConnectionRowParams(id, data, now));
}
export async function updateProviderConnection(id: string, data: JsonRecord) {

View File

@@ -48,6 +48,12 @@ export function ensureProviderConnectionsColumns(db: SqliteDatabase) {
);
console.log("[DB] Added provider_connections.per_key_proxy_enabled column");
}
if (!columnNames.has("quota_visible")) {
db.exec(
"ALTER TABLE provider_connections ADD COLUMN quota_visible INTEGER NOT NULL DEFAULT 1"
);
console.log("[DB] Added provider_connections.quota_visible column");
}
if (!columnNames.has("quota_window_thresholds_json")) {
db.exec("ALTER TABLE provider_connections ADD COLUMN quota_window_thresholds_json TEXT");
console.log("[DB] Added provider_connections.quota_window_thresholds_json column");

View File

@@ -0,0 +1,13 @@
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
export interface ProviderQuotaVisibilityConnection {
quotaVisible?: boolean;
}
export function isProviderQuotaVisible(connection: ProviderQuotaVisibilityConnection): boolean {
return connection.quotaVisible !== false;
}
export function supportsProviderQuota(providerId: string): boolean {
return USAGE_SUPPORTED_PROVIDERS.includes(providerId);
}

View File

@@ -433,6 +433,7 @@ export const updateProviderConnectionSchema = z
.optional(),
proxyEnabled: z.boolean().optional(),
perKeyProxyEnabled: z.boolean().optional(),
quotaVisible: z.boolean().optional(),
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
providerSpecificData: z
.record(z.string(), z.unknown())

View File

@@ -7,6 +7,7 @@ import assert from "node:assert/strict";
import { tryOpenSync } from "../../src/lib/db/adapters/driverFactory.ts";
import {
ensureUsageHistoryColumns,
ensureProviderConnectionsColumns,
hasColumn,
hasTable,
quoteIdentifier,
@@ -63,3 +64,24 @@ test("ensureUsageHistoryColumns adds missing columns and is idempotent", () => {
db.close?.();
}
});
test("ensureProviderConnectionsColumns repairs quota visibility with a visible default", () => {
const db = openMemoryDb();
try {
db.exec("CREATE TABLE provider_connections (id TEXT PRIMARY KEY, provider TEXT NOT NULL)");
assert.equal(hasColumn(db, "provider_connections", "quota_visible"), false);
ensureProviderConnectionsColumns(db);
assert.equal(hasColumn(db, "provider_connections", "quota_visible"), true);
const column = db
.prepare("PRAGMA table_info(provider_connections)")
.all()
.find((entry: { name?: string }) => entry.name === "quota_visible") as
{ notnull?: number; dflt_value?: string } | undefined;
assert.equal(column?.notnull, 1);
assert.equal(column?.dflt_value, "1");
assert.doesNotThrow(() => ensureProviderConnectionsColumns(db));
} finally {
db.close?.();
}
});

View File

@@ -162,3 +162,26 @@ test("updateProviderConnectionSchema rejects out-of-range values", () => {
assert.equal(result.success, false, `expected window5h=${v} to be rejected`);
}
});
test("provider quota visibility defaults to visible and persists explicit changes", async () => {
const created = await providersDb.createProviderConnection({
provider: "codex",
authType: "apikey",
name: "Codex Visibility",
apiKey: "sk-visibility",
});
assert.equal(created.quotaVisible, true);
const hidden = await providersDb.updateProviderConnection(created.id, { quotaVisible: false });
assert.equal(hidden.quotaVisible, false);
assert.equal((await providersDb.getProviderConnectionById(created.id)).quotaVisible, false);
const visible = await providersDb.updateProviderConnection(created.id, { quotaVisible: true });
assert.equal(visible.quotaVisible, true);
assert.equal((await providersDb.getProviderConnectionById(created.id)).quotaVisible, true);
});
test("updateProviderConnectionSchema accepts only boolean quota visibility", () => {
assert.equal(updateProviderConnectionSchema.safeParse({ quotaVisible: false }).success, true);
assert.equal(updateProviderConnectionSchema.safeParse({ quotaVisible: "false" }).success, false);
});

View File

@@ -0,0 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
isProviderQuotaVisible,
supportsProviderQuota,
} from "../../src/shared/utils/providerQuotaVisibility.ts";
test("provider quota visibility is opt-out so existing connections stay visible", () => {
assert.equal(isProviderQuotaVisible({}), true);
assert.equal(isProviderQuotaVisible({ quotaVisible: true }), true);
assert.equal(isProviderQuotaVisible({ quotaVisible: false }), false);
});
test("quota visibility controls are limited to providers with quota support", () => {
assert.equal(supportsProviderQuota("codex"), true);
assert.equal(supportsProviderQuota("openai"), false);
});