Files
openchamber/packages/ui/src/stores/useQuotaStore.ts
T
Nelson Pires 7af81c2436 Add per-model quotas with collapsible model groups in header (#355)
* feat(ui): show per-model quota groups in header with collapsible families

Add per-model quota groups under each provider in the header
Introduce collapsible sections for model families to reveal models
Show all models when none are explicitly selected or respect explicit selections

* feat: add toggle to UsageCard for dropdown visibility

Introduce a switch in UsageCard to control inclusion in the dropdown
Hide the percent label when the toggle is visible

* feat(usage): enhance UsagePage with model grouping and collapsibles

Add model lists grouped by family for the selected provider
Enable collapsible sections per family to toggle visibility
Apply and persist default model selections on provider change

* feat(quota): add model family helpers

Add utilities to categorize models into families by provider
Enable grouping of models by family for header and usage pages
Define default models for Gemini 3.x and Claude families

* feat(desktop): extend settings with model grouping and selection

Track per-provider selected models for usage
Allow collapsing and expanding families in the usage page
Support per-provider custom model groups with labels and assignments

* feat: persist usage preferences in persistence

Persist usageSelectedModels per provider
Persist usageCollapsedFamilies and usageExpandedFamilies states
Support custom usageModelGroups with groups and assignments

* feat: track selected quota models and expanded families per provider

Initialize selectedModels and expandedFamilies state for quota providers
Add actions to set, toggle, and apply default model selections and expanded families
Persist selections to desktop settings and apply defaults on load

* feat(server): sanitize usage model configuration in settings update

Validate and sanitize usage selections per provider
Persist only valid usageCollapsedFamilies and usageExpandedFamilies in settings
Enforce limits on custom model groups and model assignments

* feat: add collapsible model families in header

Add collapsible sections for model families within each provider in the header
Show per-model usage with percent display and a progress bar
Toggle expansion via arrow icons and preserve expanded state per provider

* fix(ui): treat explicit per-provider model selections correctly in header

Enable showing all models by default when a provider has no explicit selection
Recognize an explicit per-provider selection when a provider key exists in selectedModels
Filter to selected models only if an explicit selection is present for the provider
2026-02-08 15:42:15 +02:00

299 lines
11 KiB
TypeScript

import React from 'react';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type { ProviderResult, QuotaProviderId } from '@/types';
import { QUOTA_PROVIDERS } from '@/lib/quota';
import { isVSCodeRuntime } from '@/lib/desktop';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getDefaultModels } from '@/lib/quota/model-families';
import { updateDesktopSettings } from '@/lib/persistence';
const DEFAULT_REFRESH_INTERVAL_MS = 60000;
interface QuotaSettingsState {
autoRefresh: boolean;
refreshIntervalMs: number;
displayMode: 'usage' | 'remaining';
dropdownProviderIds: QuotaProviderId[];
selectedModels: Record<string, string[]>; // Map of providerId -> selected model names
expandedFamilies: Record<string, string[]>; // Map of providerId -> EXPANDED family IDs (header dropdown - inverted)
}
interface QuotaStore extends QuotaSettingsState {
results: ProviderResult[];
selectedProviderId: QuotaProviderId | null;
isLoading: boolean;
isFetchingProvider: Record<string, boolean>;
lastUpdated: number | null;
error: string | null;
loadSettings: () => Promise<void>;
fetchAllQuotas: () => Promise<void>;
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<void>;
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
setAutoRefresh: (enabled: boolean) => void;
setRefreshInterval: (intervalMs: number) => void;
setDisplayMode: (mode: 'usage' | 'remaining') => void;
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
setSelectedModels: (providerId: string, modelNames: string[]) => void;
toggleModelSelected: (providerId: string, modelName: string) => void;
setExpandedFamilies: (providerId: string, familyIds: string[]) => void;
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
applyDefaultSelections: (providerId: string, availableModels: string[]) => void;
}
const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState => {
const allProviderIds = QUOTA_PROVIDERS.map((provider) => provider.id);
const autoRefresh = typeof data?.usageAutoRefresh === 'boolean'
? data.usageAutoRefresh
: false;
const refreshIntervalMs =
typeof data?.usageRefreshIntervalMs === 'number' && Number.isFinite(data.usageRefreshIntervalMs)
? Math.max(30000, Math.min(300000, Math.round(data.usageRefreshIntervalMs)))
: DEFAULT_REFRESH_INTERVAL_MS;
const displayMode = data?.usageDisplayMode === 'remaining' ? 'remaining' : 'usage';
const rawDropdownProviders = Array.isArray(data?.usageDropdownProviders)
? data?.usageDropdownProviders
: null;
const dropdownProviderIds = rawDropdownProviders
? rawDropdownProviders.filter((entry): entry is QuotaProviderId =>
typeof entry === 'string' && allProviderIds.includes(entry as QuotaProviderId)
)
: allProviderIds;
// Parse selected models (providerId -> array of model names)
const selectedModels: Record<string, string[]> = {};
const rawSelectedModels = data?.usageSelectedModels;
if (rawSelectedModels && typeof rawSelectedModels === 'object') {
for (const [providerId, models] of Object.entries(rawSelectedModels)) {
if (Array.isArray(models)) {
selectedModels[providerId] = models.filter((m): m is string => typeof m === 'string');
}
}
}
// Parse expanded families (inverted collapsed logic for header dropdown)
const expandedFamilies: Record<string, string[]> = {};
const rawExpandedFamilies = data?.usageExpandedFamilies;
if (rawExpandedFamilies && typeof rawExpandedFamilies === 'object') {
for (const [providerId, families] of Object.entries(rawExpandedFamilies)) {
if (Array.isArray(families)) {
expandedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string');
}
}
}
return {
autoRefresh,
refreshIntervalMs,
displayMode,
dropdownProviderIds,
selectedModels,
expandedFamilies,
};
};
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
const settings = result?.settings as Record<string, unknown> | undefined;
return parseSettings(settings ?? null);
} catch {
// fall through
}
}
if (!isVSCodeRuntime()) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (response.ok) {
const data = await response.json().catch(() => null);
return parseSettings(data as Record<string, unknown> | null);
}
}
return {
autoRefresh: false,
refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
displayMode: 'usage',
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
selectedModels: {},
expandedFamilies: {},
};
};
export const useQuotaStore = create<QuotaStore>()(
devtools(
(set, get) => ({
results: [],
selectedProviderId: null,
isLoading: false,
isFetchingProvider: {},
lastUpdated: null,
error: null,
autoRefresh: false,
refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
displayMode: 'usage',
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
selectedModels: {},
expandedFamilies: {},
loadSettings: async () => {
try {
const settings = await loadSettingsFromRuntime();
set(settings);
} catch (error) {
console.warn('Failed to load usage settings:', error);
}
},
fetchAllQuotas: async () => {
set({ isLoading: true, error: null });
const providerIds = QUOTA_PROVIDERS.map((provider) => provider.id);
try {
await Promise.all(
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
);
set({
isLoading: false,
lastUpdated: Date.now()
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch quotas';
set({ isLoading: false, error: message });
}
},
fetchProviderQuota: async (providerId) => {
set((state) => ({
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
}));
try {
const response = await fetch(`/api/quota/${encodeURIComponent(providerId)}`);
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.error || 'Failed to fetch quota');
}
const result = payload as ProviderResult;
set((state) => {
const next = state.results.filter((entry) => entry.providerId !== providerId);
next.push(result);
return { results: next, error: null };
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch quota';
const fallback: ProviderResult = {
providerId,
providerName: providerId,
ok: false,
configured: false,
error: message,
usage: null,
fetchedAt: Date.now()
};
set((state) => {
const next = state.results.filter((entry) => entry.providerId !== providerId);
next.push(fallback);
return { results: next, error: message };
});
} finally {
set((state) => ({
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false }
}));
}
},
setSelectedProvider: (providerId) => set({ selectedProviderId: providerId }),
setAutoRefresh: (enabled) => set({ autoRefresh: enabled }),
setRefreshInterval: (intervalMs) => {
const clamped = Math.max(30000, Math.min(300000, Math.round(intervalMs)));
set({ refreshIntervalMs: clamped });
},
setDisplayMode: (mode) => set({ displayMode: mode }),
setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }),
setSelectedModels: (providerId, modelNames) => {
set((state) => ({
selectedModels: { ...state.selectedModels, [providerId]: modelNames }
}));
},
toggleModelSelected: (providerId, modelName) => {
set((state) => {
const currentSelected = state.selectedModels[providerId] ?? [];
const isSelected = currentSelected.includes(modelName);
const nextSelected = isSelected
? currentSelected.filter((m) => m !== modelName)
: [...currentSelected, modelName];
return {
selectedModels: { ...state.selectedModels, [providerId]: nextSelected }
};
});
},
setExpandedFamilies: (providerId, familyIds) => {
set((state) => ({
expandedFamilies: { ...state.expandedFamilies, [providerId]: familyIds }
}));
// Persist
void updateDesktopSettings({ usageExpandedFamilies: get().expandedFamilies });
},
toggleFamilyExpanded: (providerId, familyId) => {
set((state) => {
const currentExpanded = state.expandedFamilies[providerId] ?? [];
const isExpanded = currentExpanded.includes(familyId);
const nextExpanded = isExpanded
? currentExpanded.filter((id) => id !== familyId)
: [...currentExpanded, familyId];
return {
expandedFamilies: { ...state.expandedFamilies, [providerId]: nextExpanded }
};
});
// Persist
void updateDesktopSettings({ usageExpandedFamilies: get().expandedFamilies });
},
applyDefaultSelections: (providerId, availableModels) => {
const state = get();
// Only apply if no prior selections exist
if ((state.selectedModels[providerId]?.length ?? 0) > 0) return;
const defaults = getDefaultModels(providerId as QuotaProviderId, availableModels);
if (defaults.length === 0) return;
set((s) => ({
selectedModels: { ...s.selectedModels, [providerId]: defaults },
}));
// Persist
void updateDesktopSettings({ usageSelectedModels: get().selectedModels });
},
}),
{ name: 'quota-store' }
)
);
export const useQuotaAutoRefresh = () => {
const autoRefresh = useQuotaStore((state) => state.autoRefresh);
const refreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
React.useEffect(() => {
if (!autoRefresh) {
return;
}
const interval = window.setInterval(() => {
fetchAllQuotas();
}, refreshIntervalMs);
return () => window.clearInterval(interval);
}, [autoRefresh, refreshIntervalMs, fetchAllQuotas]);
};