diff --git a/changelog.d/features/provider-quota-connection-visibility.md b/changelog.d/features/provider-quota-connection-visibility.md new file mode 100644 index 0000000000..46e18fd848 --- /dev/null +++ b/changelog.d/features/provider-quota-connection-visibility.md @@ -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. diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 6917dbb5e4..262870277a 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -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.", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 6d9c34b3e8..383f7fb39a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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 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, 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, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 1c4465a2b8..7340e7e6fc 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -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 && ( - + { "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. diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx index 72e9229c02..6a9ff815a0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx @@ -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; 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({ shield {rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")} + {onToggleQuotaVisibility && ( + + )} {isClaude && ( <> | diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx index 266ac8707d..fc1de3069a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx @@ -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) } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderQuotaVisibilityToggle.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderQuotaVisibilityToggle.tsx new file mode 100644 index 0000000000..cebeb3c755 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderQuotaVisibilityToggle.tsx @@ -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 ( + <> + | + + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx index ae3cdacb47..577b292c4b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx @@ -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( + + ); + + 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( - + ); expect(c).toBeDefined(); }); it("SiliconFlowEndpointModal mounts when isOpen=true without throwing", () => { const c = renderComponent( - + ); expect(c).toBeDefined(); }); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts index fc358c358d..8d6b9f3109 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts @@ -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; handleToggleRateLimit: (connectionId: string, enabled: boolean) => Promise; + handleToggleQuotaVisibility: (connectionId: string, visible: boolean) => Promise; handleToggleClaudeExtraUsage: (connectionId: string, enabled: boolean) => Promise; handleToggleCodexLimit: (connectionId: string, field: string, enabled: boolean) => Promise; handleToggleCliproxyapiMode: (connectionId: string, enabled: boolean) => Promise; @@ -135,6 +136,7 @@ export function useProviderConnections( // ── core state ────────────────────────────────────────────────────────── const [connections, setConnections] = useState([]); + const handleToggleQuotaVisibility = useProviderQuotaVisibility(setConnections, notify, t); const [providerNode, setProviderNode] = useState(null); const [loading, setLoading] = useState(true); @@ -894,6 +896,7 @@ export function useProviderConnections( deleteConfirm, handleUpdateConnectionStatus, handleToggleRateLimit, + handleToggleQuotaVisibility, handleToggleClaudeExtraUsage, handleToggleCodexLimit, handleToggleCliproxyapiMode, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderQuotaVisibility.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderQuotaVisibility.ts new file mode 100644 index 0000000000..dda6f29564 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderQuotaVisibility.ts @@ -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>, + 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] + ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index c7381721cf..2ead860172 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -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") ), diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 79c4204bd2..0144ac458e 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -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; @@ -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") ); diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index c92a7a2c67..a60b4a5c3a 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -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") { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9f2f0c98d2..4d2d19f944 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3580989e7b..c23daa0855 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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}", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 4a2114f86d..1838f76179 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -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}", diff --git a/src/lib/db/caseMapping.ts b/src/lib/db/caseMapping.ts index 007a3d3d72..1cbb0e602b 100644 --- a/src/lib/db/caseMapping.ts +++ b/src/lib/db/caseMapping.ts @@ -11,6 +11,14 @@ type JsonRecord = Record; +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 { diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index ee3b303e1b..165ecae456 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -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 diff --git a/src/lib/db/migrations/125_provider_connection_quota_visibility.sql b/src/lib/db/migrations/125_provider_connection_quota_visibility.sql new file mode 100644 index 0000000000..26bf92df36 --- /dev/null +++ b/src/lib/db/migrations/125_provider_connection_quota_visibility.sql @@ -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; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index e3f6e1241e..fdaa5f6a37 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -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) { diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index ab8ec16525..2951e214e8 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -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"); diff --git a/src/shared/utils/providerQuotaVisibility.ts b/src/shared/utils/providerQuotaVisibility.ts new file mode 100644 index 0000000000..3968005204 --- /dev/null +++ b/src/shared/utils/providerQuotaVisibility.ts @@ -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); +} diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 95ee820d55..a315489958 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -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()) diff --git a/tests/unit/db-schema-columns-split.test.ts b/tests/unit/db-schema-columns-split.test.ts index f4f9643e9f..0e89a5fa1f 100644 --- a/tests/unit/db-schema-columns-split.test.ts +++ b/tests/unit/db-schema-columns-split.test.ts @@ -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?.(); + } +}); diff --git a/tests/unit/provider-connections-quota-threshold.test.ts b/tests/unit/provider-connections-quota-threshold.test.ts index d9bfebe935..5caaeea369 100644 --- a/tests/unit/provider-connections-quota-threshold.test.ts +++ b/tests/unit/provider-connections-quota-threshold.test.ts @@ -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); +}); diff --git a/tests/unit/provider-quota-visibility.test.ts b/tests/unit/provider-quota-visibility.test.ts new file mode 100644 index 0000000000..665687feed --- /dev/null +++ b/tests/unit/provider-quota-visibility.test.ts @@ -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); +});