mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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.
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
),
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
13
src/shared/utils/providerQuotaVisibility.ts
Normal file
13
src/shared/utils/providerQuotaVisibility.ts
Normal 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);
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
18
tests/unit/provider-quota-visibility.test.ts
Normal file
18
tests/unit/provider-quota-visibility.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user