Merge remote-tracking branch 'origin/dev' into codex/fix-logs-filtering-to-show-dashboard-audits

This commit is contained in:
Tam Nhu Tran committed 2026-05-30 15:57:03 -04:00
commit 3d9636351e
156 files changed
+4169 -625

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="180" height="180" viewBox="0 0 180 180"><defs><clipPath id="master_svg0_381_5952"><rect x="0" y="0" width="180" height="180" rx="40"/></clipPath><clipPath id="master_svg1_381_5952/255_01913"><rect x="18" y="18" width="144" height="144" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_381_5952)"><rect x="0" y="0" width="180" height="180" rx="40" fill="#111113" fill-opacity="1"/><g clip-path="url(#master_svg1_381_5952/255_01913)"><g><path d="M147.59971253967285,101.41062772064208L147.59971253967285,81.42072772064209C147.59971253967285,70.06382772064208,142.65821253967283,60.949927720642094,134.05571253967287,56.41322772064209L89.62531253967285,32.98532772064209L89.40251253967284,33.41064472064209L89.17971253967285,33.83596072064209C97.45821253967284,38.20051772064209,102.20761253967285,47.00562772064209,102.20761253967285,57.992927720642086L102.20761253967285,77.98282772064209C102.20761253967285,78.58532772064208,102.19241253967286,79.21822772064209,102.16201253967284,79.8511277206421C102.15691253967285,79.9726277206421,102.14681253967285,80.0942277206421,102.13671253967286,80.22072772064209L102.11641253967285,80.51952772064209C102.09111253967285,80.9195277206421,102.06581253967286,81.3245277206421,102.02531253967285,81.72452772064209C102.01011253967286,81.88662772064208,101.98981253967285,82.05372772064209,101.96961253967285,82.21572772064209L101.92401253967284,82.59542772064209C101.88351253967285,82.92962772064209,101.84811253967285,83.2587277206421,101.79741253967285,83.59292772064208C101.77211253967286,83.78022772064209,101.74171253967285,83.9625277206421,101.71141253967285,84.1499277206421L101.66581253967286,84.4385277206421C101.61011253967285,84.77772772064209,101.55441253967285,85.11692772064208,101.48861253967286,85.45622772064209C101.39241253967285,85.9625277206421,101.29111253967285,86.46382772064209,101.17971253967285,86.9650277206421L101.14431253967285,87.11192772064209C101.03291253967285,87.59802772064208,100.91141253967285,88.07902772064209,100.77971253967286,88.5600277206421L100.66331253967286,88.98532772064209C100.52661253967285,89.47142772064208,100.38481253967285,89.95742772064209,100.23291253967285,90.4435277206421L100.09621253967285,90.86382772064209C99.92401253967284,91.39542772064209,99.74171253967285,91.92712772064209,99.54931253967285,92.45372772064209C99.49871253967285,92.59542772064209,99.44301253967285,92.7321277206421,99.38731253967285,92.87392772064209L99.30121253967286,93.10182772064209C99.14431253967285,93.5068277206421,98.98731253967284,93.9170277206421,98.82021253967285,94.3220277206421C98.72401253967286,94.5600277206421,98.61771253967285,94.7980277206421,98.51641253967284,95.03592772064209L98.48101253967286,95.12202772064208C98.34931253967285,95.43092772064209,98.21261253967285,95.7397277206421,98.07081253967286,96.04352772064209C97.94931253967286,96.31702772064209,97.81771253967285,96.58532772064208,97.69111253967284,96.8537277206421C97.54431253967286,97.1575277206421,97.40251253967284,97.45622772064209,97.25061253967286,97.75492772064209C97.10881253967285,98.0334277206421,96.96701253967285,98.30682772064209,96.82021253967285,98.58022772064209C96.67841253967285,98.84862772064209,96.53161253967285,99.1220277206421,96.38481253967285,99.3904277206421C96.23291253967285,99.66382772064209,96.07591253967286,99.93722772064208,95.91891253967285,100.2106277206421C95.76701253967286,100.4739277206421,95.61521253967285,100.7423277206421,95.45311253967284,101.00052772064208C95.29111253967285,101.27392772064209,95.12401253967286,101.54232772064209,94.95191253967286,101.81062772064209C94.78981253967285,102.0689277206421,94.63291253967284,102.32202772064208,94.46581253967285,102.57522772064209C94.29361253967285,102.84352772064209,94.11641253967285,103.10182772064209,93.91391253967285,103.40562772064209C93.75191253967284,103.64352772064208,93.58981253967285,103.88152772064208,93.42781253967286,104.11442772064208C93.25571253967286,104.3575277206421,93.08351253967285,104.59542772064209,92.88101253967285,104.87392772064209C92.69361253967284,105.13212772064209,92.50631253967285,105.3853277206421,92.31391253967286,105.63852772064209C92.12651253967286,105.8866277206421,91.93921253967285,106.1246277206421,91.74681253967285,106.36762772064209C91.54931253967285,106.61572772064208,91.35191253967285,106.86892772064209,91.15441253967285,107.11192772064209C90.98221253967284,107.32462772064208,90.80501253967284,107.53222772064208,90.62781253967285,107.7397277206421L90.59241253967285,107.78032772064209C90.35951253967285,108.0587277206421,90.12151253967285,108.33212772064209,89.87841253967285,108.60562772064209C89.68601253967284,108.82332772064208,89.49361253967285,109.0360277206421,89.30121253967286,109.24862772064209C89.05821253967285,109.51702772064209,88.81521253967284,109.77522772064209,88.56711253967285,110.0385277206421C88.37971253967285,110.2359277206421,88.18731253967286,110.4Line truncated

After

Width:  |  Height:  |  Size: 38 KiB

@@ -29,7 +29,7 @@ import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from
import { useKiroImport } from '@/hooks/use-cliproxy';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
import { applyDefaultPreset } from '@/lib/preset-utils';
import type { CliproxyProviderCatalog } from '@/lib/api-client';
import type { CliproxyProviderCatalog, OAuthAccount } from '@/lib/api-client';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist';
import {
@@ -58,6 +58,8 @@ interface AddAccountDialogProps {
provider: string;
displayName: string;
catalog?: CliproxyProviderCatalog;
/** Existing account to reauthenticate instead of adding a new account. */
account?: OAuthAccount;
/** Whether this is the first account being added (shows different toast message) */
isFirstAccount?: boolean;
}
@@ -77,6 +79,7 @@ export function AddAccountDialog({
provider,
displayName,
catalog,
account,
isFirstAccount = false,
}: AddAccountDialogProps) {
const [nickname, setNickname] = useState('');
@@ -127,6 +130,8 @@ export function AddAccountDialog({
const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim();
const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim();
const errorMessage = localError || authFlow.error;
const isReauth = Boolean(account);
const accountLabel = account?.email || account?.nickname || account?.id || displayName;
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
const response = await fetch('/api/settings/auth/antigravity-risk');
@@ -325,7 +330,8 @@ export function AddAccountDialog({
}
wasAuthenticatingRef.current = true;
authFlow.startAuth(provider, {
nickname: nicknameTrimmed || undefined,
accountId: account?.id,
nickname: account ? account.nickname : nicknameTrimmed || undefined,
kiroMethod: isKiro ? kiroAuthMethod : undefined,
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
@@ -385,13 +391,19 @@ export function AddAccountDialog({
}}
>
<DialogHeader className="min-w-0 px-6 pt-6 pr-12">
<DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle>
<DialogTitle>
{isReauth
? `Reauthenticate ${displayName}`
: t('addAccountDialog.title', { displayName })}
</DialogTitle>
<DialogDescription>
{isKiro
? t('addAccountDialog.descKiro')
: isDeviceCode
? t('addAccountDialog.descDeviceCode')
: t('addAccountDialog.descOauth')}
{isReauth
? `Refresh credentials for ${accountLabel}.`
: isKiro
? t('addAccountDialog.descKiro')
: isDeviceCode
? t('addAccountDialog.descDeviceCode')
: t('addAccountDialog.descOauth')}
</DialogDescription>
</DialogHeader>
@@ -600,7 +612,16 @@ export function AddAccountDialog({
)}
{/* Nickname input - only show before auth starts */}
{!showAuthUI && (
{!showAuthUI && isReauth && (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<div className="font-medium">{accountLabel}</div>
<div className="text-xs text-muted-foreground">
This sign-in updates the selected account instead of creating another account.
</div>
</div>
)}
{!showAuthUI && !isReauth && (
<div className="space-y-2">
<Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
<div className="flex items-center gap-2">
@@ -784,7 +805,7 @@ export function AddAccountDialog({
}
>
<ExternalLink className="w-4 h-4 mr-2" />
{t('addAccountDialog.authenticate')}
{isReauth ? 'Reauthenticate' : t('addAccountDialog.authenticate')}
</Button>
)}
</div>
@@ -57,6 +57,10 @@ function clearLocalControlPanelSession(): void {
window.localStorage.removeItem(CONTROL_PANEL_LOGIN_FLAG_KEY);
}
function clearPersistedLocalControlPanelSecret(): void {
window.localStorage.removeItem(CONTROL_PANEL_MANAGEMENT_KEY);
}
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
const { t } = useTranslation();
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -162,7 +166,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
return;
}
const handleBeforeUnload = () => {
clearLocalControlPanelSession();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
clearLocalControlPanelSession();
};
}, [isRemote]);
@@ -266,9 +276,15 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
const handleIframeLoad = () => {
setLoadedFrameKey(iframeKey);
postRemoteAutoLoginCredentials();
if (!isRemote) {
clearPersistedLocalControlPanelSecret();
}
};
const handleRefresh = () => {
if (!isRemote) {
clearLocalControlPanelSession();
}
setLoadedFrameKey(null);
setIframeRevision((value) => value + 1);
setError(null);
@@ -24,6 +24,7 @@ import {
MoreHorizontal,
Pause,
Play,
RefreshCw,
Star,
Trash2,
} from 'lucide-react';
@@ -87,6 +88,7 @@ export function AccountItem({
account,
onSetDefault,
onRemove,
onReauth,
onPauseToggle,
isRemoving,
isPausingAccount,
@@ -167,6 +169,12 @@ export function AccountItem({
Set as default
</DropdownMenuItem>
)}
{onReauth && (
<DropdownMenuItem onClick={onReauth}>
<RefreshCw className="w-4 h-4 mr-2" />
Reauthenticate
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
interface AccountsSectionProps {
accounts: OAuthAccount[];
onAddAccount: () => void;
onReauthAccount?: (account: OAuthAccount) => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
@@ -48,6 +49,7 @@ interface AccountsSectionProps {
export function AccountsSection({
accounts,
onAddAccount,
onReauthAccount,
onSetDefault,
onRemoveAccount,
onPauseToggle,
@@ -175,6 +177,7 @@ export function AccountsSection({
account={account}
onSetDefault={() => onSetDefault(account.id)}
onRemove={() => onRemoveAccount(account.id)}
onReauth={onReauthAccount ? () => onReauthAccount(account) : undefined}
onPauseToggle={
onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined
}
@@ -42,6 +42,7 @@ export function ProviderEditor({
defaultTarget,
topNotice,
onAddAccount,
onReauthAccount,
onSetDefault,
onRemoveAccount,
onPauseToggle,
@@ -92,6 +93,7 @@ export function ProviderEditor({
qwen: ['alibaba', 'qwen'],
iflow: ['iflow'],
kilo: ['kilo'],
qoder: ['qoder'],
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
kimi: ['kimi', 'moonshot'],
@@ -282,6 +284,7 @@ export function ProviderEditor({
isDeletePending={deletePresetMutation.isPending}
accounts={accounts}
onAddAccount={onAddAccount}
onReauthAccount={onReauthAccount}
onSetDefault={onSetDefault}
onRemoveAccount={onRemoveAccount}
onPauseToggle={onPauseToggle}
@@ -40,6 +40,7 @@ interface ModelConfigTabProps {
isDeletePending?: boolean;
accounts: OAuthAccount[];
onAddAccount: () => void;
onReauthAccount?: (account: OAuthAccount) => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
@@ -82,6 +83,7 @@ export function ModelConfigTab({
isDeletePending,
accounts,
onAddAccount,
onReauthAccount,
onSetDefault,
onRemoveAccount,
onPauseToggle,
@@ -176,6 +178,7 @@ export function ModelConfigTab({
<AccountsSection
accounts={accounts}
onAddAccount={onAddAccount}
onReauthAccount={onReauthAccount}
onSetDefault={onSetDefault}
onRemoveAccount={onRemoveAccount}
onPauseToggle={onPauseToggle}
@@ -39,6 +39,7 @@ export interface ProviderEditorProps {
/** Optional contextual notice shown directly under the editor header */
topNotice?: ReactNode;
onAddAccount: () => void;
onReauthAccount?: (account: OAuthAccount) => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
@@ -63,6 +64,7 @@ export interface AccountItemProps {
account: OAuthAccount;
onSetDefault: () => void;
onRemove: () => void;
onReauth?: () => void;
onPauseToggle?: (paused: boolean) => void;
/** Solo mode: activate this account, pause all others */
onSoloMode?: () => void;
+24 -8
View File
@@ -17,20 +17,30 @@ export interface LeafItem {
export type DerivedItem = LeafItem | TraceGroup;
function stableStringify(value: unknown): string {
if (value === undefined) return '';
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
return `{${Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(',')}}`;
}
/**
* Tuple key for coalescing standalone leaves. Includes `message` and
* `source` so two adjacent logs that share event/module/level but report
* different content (or come from a different service) stay distinct
* (e.g. `User logged in: alice` and `User logged in: bob`). Excludes
* `latencyMs` and `metadata` because those drift per request even on
* truly redundant polls — including them would prevent any coalescing.
* Tuple key for coalescing standalone leaves. The row/detail UI exposes more
* than event/module/level, so every inspectable payload field must participate
* in equality; otherwise two adjacent logs with different metadata, context,
* error, or latency could collapse into one selectable row. `timestamp` stays
* out of the key because collapsed rows surface their time span separately.
*
* NB: this only applies to *leaves* (entries without `requestId`). Trace
* children render uncoalesced so retries and duplicated-stage emissions
* stay individually inspectable.
*/
function coalesceKey(entry: LogsEntry): string {
return [
return JSON.stringify([
entry.event ?? '',
entry.message ?? '',
entry.stage ?? '',
@@ -38,7 +48,13 @@ function coalesceKey(entry: LogsEntry): string {
entry.level,
entry.requestId ?? '',
entry.source ?? '',
].join(' ');
entry.runId ?? '',
String(entry.processId ?? ''),
String(entry.latencyMs ?? ''),
stableStringify(entry.context),
stableStringify(entry.metadata),
stableStringify(entry.error),
]);
}
/**
+2 -1
View File
@@ -9,6 +9,7 @@ import { LogLevelBadge } from './log-level-badge';
import { LogsEmpty } from './logs-empty';
import {
formatJson,
formatLogTimestampIso,
getDisplayLatency,
getDisplayModule,
getDisplayRequestId,
@@ -34,7 +35,7 @@ interface OverviewRow {
function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] {
// Use shared accessors so this panel and the list row never diverge.
return [
{ label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true },
{ label: 'Time', value: formatLogTimestampIso(entry.timestamp), mono: true },
{ label: 'Level', value: entry.level },
{ label: 'Module', value: getDisplayModule(entry, sourceLabel) },
{ label: 'Stage', value: getDisplayStage(entry) },
+13
View File
@@ -21,6 +21,19 @@ export function formatLogTimestamp(timestamp: string | null | undefined) {
}).format(date);
}
export function formatLogTimestampIso(timestamp: string | null | undefined) {
if (!timestamp) {
return 'No activity yet';
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return timestamp;
}
return date.toISOString();
}
export function formatRelativeLogTime(timestamp: string | null | undefined) {
if (!timestamp) {
return 'No activity yet';
@@ -4,7 +4,7 @@
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
import { getProviderDisplayName } from '@/lib/provider-config';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
@@ -26,14 +26,25 @@ export interface AuthMonitorData {
/** Hook for computing auth monitor data from CLIProxy auth and stats */
export function useAuthMonitorData(): AuthMonitorData {
const { data, isLoading, error } = useCliproxyAuth();
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
const { data: proxyStatus } = useCliproxyStatus();
const statsEnabled = proxyStatus?.running === true;
const {
data: statsData,
isLoading: statsLoading,
dataUpdatedAt,
} = useCliproxyStats(statsEnabled);
const activeStatsData = statsEnabled ? statsData : undefined;
const activeStatsUpdatedAt = statsEnabled ? dataUpdatedAt : 0;
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
// Live countdown showing time since last data update
useEffect(() => {
if (!dataUpdatedAt) return;
if (!activeStatsUpdatedAt) {
setTimeSinceUpdate('');
return;
}
const updateTime = () => {
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
const diff = Math.floor((Date.now() - activeStatsUpdatedAt) / 1000);
if (diff < 60) {
setTimeSinceUpdate(`${diff}s ago`);
} else {
@@ -43,7 +54,7 @@ export function useAuthMonitorData(): AuthMonitorData {
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, [dataUpdatedAt]);
}, [activeStatsUpdatedAt]);
// Transform auth status data into account rows
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
@@ -79,7 +90,7 @@ export function useAuthMonitorData(): AuthMonitorData {
provider: account.provider || status.provider,
}));
buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
buildAccountVisualGroups(normalizedAccounts, activeStatsData).forEach((groupedAccount) => {
tSuccess += groupedAccount.successCount;
tFailure += groupedAccount.failureCount;
providerData.success += groupedAccount.successCount;
@@ -131,7 +142,7 @@ export function useAuthMonitorData(): AuthMonitorData {
totalRequests: tSuccess + tFailure,
providerStats: providerStatsArr,
};
}, [data?.authStatus, statsData]);
}, [data?.authStatus, activeStatsData]);
const overallSuccessRate =
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
@@ -143,7 +154,7 @@ export function useAuthMonitorData(): AuthMonitorData {
totalRequests,
providerStats,
overallSuccessRate,
isLoading: isLoading || statsLoading,
isLoading: isLoading || (statsEnabled && statsLoading),
error: error ?? null,
timeSinceUpdate,
};
@@ -16,7 +16,7 @@ import {
usePauseAccount,
useResumeAccount,
} from '@/hooks/use-cliproxy';
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
import { Activity, AlertTriangle, CheckCircle2, XCircle, Radio } from 'lucide-react';
import { useAuthMonitorData } from './hooks';
import { LivePulse } from './components/live-pulse';
@@ -86,6 +86,8 @@ export function AuthMonitor() {
const displayedSuccessRate = selectedProviderData
? getSuccessRate(selectedProviderData.successCount, selectedProviderData.failureCount)
: overallSuccessRate;
const showHighFailureGuidance =
displayedAccountCount >= 5 && displayedFailure >= 20 && displayedSuccessRate < 50;
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
if (
@@ -204,6 +206,21 @@ export function AuthMonitor() {
/>
</div>
{showHighFailureGuidance && (
<div className="flex items-start gap-3 border-b border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="space-y-1">
<p className="font-semibold text-foreground">High CLIProxy failure rate detected</p>
<p className="text-muted-foreground">
Select the affected provider, pause failing accounts, then check{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono">ccs cliproxy routing</code>.
For large Codex pools, prefer a smaller healthy active set and attach CLIProxy logs if
failures continue.
</p>
</div>
</div>
)}
{/* Flow Visualization */}
<div className="relative overflow-hidden">
{selectedProviderData ? (
@@ -25,6 +25,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
'iflow',
'kilo',
'kiro',
'qoder',
];
export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({
@@ -378,7 +378,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
if (isGeminiQuotaResult(quota)) {
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
const hasEntitlementTier =
!!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown';
!!quota.entitlement?.rawTierLabel ||
(!!quota.entitlement && quota.entitlement.normalizedTier !== 'unknown');
const distinctTokenTypes = Array.from(
new Set(
quota.buckets
+2
View File
@@ -25,6 +25,7 @@ interface AuthFlowState {
}
interface StartAuthOptions {
accountId?: string;
nickname?: string;
kiroMethod?: string;
kiroIDCStartUrl?: string;
@@ -286,6 +287,7 @@ export function useCliproxyAuthFlow() {
const deviceCodeFlow = flowType === 'device_code';
const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url');
const payload = {
accountId: options?.accountId,
nickname: options?.nickname,
kiroMethod: options?.kiroMethod,
kiroIDCStartUrl: options?.kiroIDCStartUrl,
+32 -13
View File
@@ -1,20 +1,39 @@
function normalizeVersionParts(version: string): number[] {
return version.replace(/-\d+$/, '').split('.').map(Number);
interface ParsedCliproxyVersion {
major: number;
minor: number;
patch: number;
forkRelease: number;
}
function parseCliproxyVersion(version: string): ParsedCliproxyVersion {
const normalized = version.trim().replace(/^v/, '');
const [coreVersion, forkReleaseValue = '0'] = normalized.split('-', 2);
const [major = 0, minor = 0, patch = 0] = coreVersion
.split('.')
.map((part) => Number.parseInt(part, 10) || 0);
const forkRelease = /^\d+$/.test(forkReleaseValue)
? Number.parseInt(forkReleaseValue, 10) || 0
: 0;
return { major, minor, patch, forkRelease };
}
function compareVersionPart(a: number, b: number): number {
if (a > b) return 1;
if (a < b) return -1;
return 0;
}
export function compareCliproxyVersions(a: string, b: string): number {
const aParts = normalizeVersionParts(a);
const bParts = normalizeVersionParts(b);
const left = parseCliproxyVersion(a);
const right = parseCliproxyVersion(b);
for (let index = 0; index < 3; index += 1) {
const aPart = aParts[index] || 0;
const bPart = bParts[index] || 0;
if (aPart > bPart) return 1;
if (aPart < bPart) return -1;
}
return 0;
return (
compareVersionPart(left.major, right.major) ||
compareVersionPart(left.minor, right.minor) ||
compareVersionPart(left.patch, right.patch) ||
compareVersionPart(left.forkRelease, right.forkRelease)
);
}
export function isCliproxyVersionExperimental(version: string, maxStableVersion: string): boolean {
+122
View File
@@ -454,6 +454,128 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
},
],
},
qoder: {
provider: 'qoder',
displayName: 'Qoder',
defaultModel: 'qoder/auto',
models: [
{
id: 'qoder/auto',
name: 'Qoder Auto',
description: 'Auto selects the best Qoder model for your prompt',
presetMapping: {
default: 'qoder/auto',
opus: 'qoder/auto',
sonnet: 'qoder/auto',
haiku: 'qoder/auto',
},
},
{
id: 'qoder/ultimate',
name: 'Qoder Ultimate',
description: 'Highest quality Qoder tier',
presetMapping: {
default: 'qoder/ultimate',
opus: 'qoder/ultimate',
sonnet: 'qoder/ultimate',
haiku: 'qoder/ultimate',
},
},
{
id: 'qoder/performance',
name: 'Qoder Performance',
description: 'Balanced quality and speed',
presetMapping: {
default: 'qoder/performance',
opus: 'qoder/performance',
sonnet: 'qoder/performance',
haiku: 'qoder/performance',
},
},
{
id: 'qoder/efficient',
name: 'Qoder Efficient',
description: 'Cost-efficient Qoder tier',
presetMapping: {
default: 'qoder/efficient',
opus: 'qoder/efficient',
sonnet: 'qoder/efficient',
haiku: 'qoder/efficient',
},
},
{
id: 'qoder/lite',
name: 'Qoder Lite',
description: 'Fastest and most affordable Qoder tier',
presetMapping: {
default: 'qoder/lite',
opus: 'qoder/lite',
sonnet: 'qoder/lite',
haiku: 'qoder/lite',
},
},
{
id: 'qoder/qmodel',
name: 'Qwen 3.6 Plus (via Qoder)',
description: 'Qwen 3.6 Plus frontier model',
presetMapping: {
default: 'qoder/qmodel',
opus: 'qoder/qmodel',
sonnet: 'qoder/qmodel',
haiku: 'qoder/qmodel',
},
},
{
id: 'qoder/dmodel',
name: 'DeepSeek V4 Pro (via Qoder)',
description: 'DeepSeek V4 Pro frontier model',
presetMapping: {
default: 'qoder/dmodel',
opus: 'qoder/dmodel',
sonnet: 'qoder/dmodel',
haiku: 'qoder/dfmodel',
},
},
{
id: 'qoder/dfmodel',
name: 'DeepSeek V4 Flash (via Qoder)',
description: 'DeepSeek V4 Flash frontier model',
},
{
id: 'qoder/gm51model',
name: 'GLM 5.1 (via Qoder)',
description: 'GLM 5.1 frontier model',
presetMapping: {
default: 'qoder/gm51model',
opus: 'qoder/gm51model',
sonnet: 'qoder/gm51model',
haiku: 'qoder/gm51model',
},
},
{
id: 'qoder/kmodel',
name: 'Kimi K2.6 (via Qoder)',
description: 'Kimi K2.6 frontier model',
presetMapping: {
default: 'qoder/kmodel',
opus: 'qoder/kmodel',
sonnet: 'qoder/kmodel',
haiku: 'qoder/kmodel',
},
},
{
id: 'qoder/mmodel',
name: 'MiniMax M2.7 (via Qoder)',
description: 'MiniMax M2.7 frontier model',
presetMapping: {
default: 'qoder/mmodel',
opus: 'qoder/mmodel',
sonnet: 'qoder/mmodel',
haiku: 'qoder/mmodel',
},
},
],
},
kimi: {
provider: 'kimi',
displayName: 'Kimi (Moonshot)',
+3
View File
@@ -105,6 +105,7 @@ export const PROVIDER_ASSETS: Partial<Record<ProviderVisualId, string>> = {
gitlab: '/assets/providers/gitlab.svg',
codebuddy: '/assets/providers/codebuddy.png',
kilo: '/assets/providers/kilo.png',
qoder: '/assets/providers/qoder.svg',
ghcp: '/assets/providers/copilot.svg',
claude: '/assets/providers/claude.svg',
kimi: '/assets/providers/kimi.svg',
@@ -135,6 +136,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record<ProviderVisualId, ProviderFallbac
gitlab: { textClass: 'text-orange-600', letter: 'G' },
codebuddy: { textClass: 'text-blue-600', letter: 'B' },
kilo: { textClass: 'text-rose-600', letter: 'K' },
qoder: { textClass: 'text-amber-600', letter: 'Q' },
ghcp: { textClass: 'text-green-600', letter: 'C' },
kimi: { textClass: 'text-orange-500', letter: 'K' },
openai: { textClass: 'text-slate-900', letter: 'O' },
@@ -272,6 +274,7 @@ export const PROVIDER_COLORS: Record<string, string> = {
gitlab: '#FC6D26',
codebuddy: '#2563EB',
kilo: '#E11D48',
qoder: '#D97706',
ghcp: '#43aa8b',
claude: '#D97757',
kimi: '#FF6B35',
@@ -15,8 +15,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RefreshCw } from 'lucide-react';
import { Info, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { AnalyticsProfileOption } from '../hooks';
interface AnalyticsHeaderProps {
@@ -53,21 +54,44 @@ export function AnalyticsHeader({
<p className="text-sm text-muted-foreground">{t('analytics.subtitle')}</p>
</div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Select value={selectedProfile} onValueChange={onProfileChange}>
<SelectTrigger className="h-8 w-[190px]" aria-label="Analytics profile">
<SelectValue placeholder="All profiles" />
</SelectTrigger>
<SelectContent>
{profileOptions.map((option) => (
<SelectItem key={option.value} value={option.value} disabled={!option.supported}>
<span className="flex flex-col">
<span>{option.label}</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="inline-flex items-center gap-1 shrink-0">
<Select value={selectedProfile} onValueChange={onProfileChange}>
<SelectTrigger className="h-8 w-[170px]" aria-label="Analytics profile">
<SelectValue placeholder="All profiles">
{profileOptions.find((o) => o.value === selectedProfile)?.label ?? 'All profiles'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{profileOptions.map((option) => (
<SelectItem key={option.value} value={option.value} disabled={!option.supported}>
<span className="flex flex-col">
<span>{option.label}</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedProfile !== 'all' && (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Selected-profile analytics scope"
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Info className="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs text-xs">
Selected-profile analytics include only default/account data with stable profile
attribution. CLIProxy and native runtime snapshots remain in All profiles.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
@@ -92,8 +116,11 @@ export function AnalyticsHeader({
/>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{t('analytics.updated', { value: lastUpdatedText })}
<span
className="text-xs text-muted-foreground whitespace-nowrap font-mono"
title={t('analytics.updated', { value: lastUpdatedText })}
>
{lastUpdatedText}
</span>
)}
<Button
@@ -106,12 +133,6 @@ export function AnalyticsHeader({
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
{selectedProfile !== 'all' && (
<p className="text-xs text-muted-foreground xl:text-right">
Selected-profile analytics include only default/account data with stable profile
attribution. CLIProxy and native runtime snapshots remain in All profiles.
</p>
)}
</div>
);
}
+14 -3
View File
@@ -6,7 +6,18 @@
import { useState, useMemo, useCallback } from 'react';
import type { DateRange } from 'react-day-picker';
import { subDays, formatDistanceToNow } from 'date-fns';
import { subDays } from 'date-fns';
function formatCompactRelativeTime(date: Date): string {
const seconds = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
import {
useUsageSummary,
useUsageTrends,
@@ -151,10 +162,10 @@ export function useAnalyticsPage() {
persistSelectedProfile(profile);
}, []);
// Format "Last updated" text
// Compact "Last updated" text (e.g. "1m ago", "2h ago")
const lastUpdatedText = useMemo(() => {
if (!status?.lastFetch) return null;
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
return formatCompactRelativeTime(new Date(status.lastFetch));
}, [status?.lastFetch]);
// Handle model click for popover
+36 -1
View File
@@ -31,7 +31,7 @@ import {
useBulkResumeAccounts,
useDeleteVariant,
} from '@/hooks/use-cliproxy';
import type { AuthStatus, Variant } from '@/lib/api-client';
import type { AuthStatus, OAuthAccount, Variant } from '@/lib/api-client';
import { buildUiCatalogs } from '@/lib/model-catalogs';
import {
getProviderDisplayName,
@@ -256,6 +256,7 @@ export function CliproxyPage() {
provider: string;
displayName: string;
isFirstAccount: boolean;
account?: OAuthAccount;
} | null>(() => {
if (typeof window === 'undefined') {
return null;
@@ -404,6 +405,23 @@ export function CliproxyPage() {
});
};
const handleReauthAccount = (
provider: string,
displayName: string,
accounts: OAuthAccount[] | undefined,
accountId: string
) => {
const account = accounts?.find((candidate) => candidate.id === accountId);
if (!account) return;
setAddAccountProvider({
provider,
displayName,
isFirstAccount: false,
account,
});
};
return (
<div className="flex h-full min-h-0 overflow-hidden">
{/* Left Sidebar */}
@@ -567,6 +585,14 @@ export function CliproxyPage() {
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
})
}
onReauthAccount={(account) =>
handleReauthAccount(
selectedVariantData.provider,
parentAuthForVariant.displayName,
parentAuthForVariant.accounts,
account.id
)
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedVariantData.provider,
@@ -617,6 +643,14 @@ export function CliproxyPage() {
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
})
}
onReauthAccount={(account) =>
handleReauthAccount(
selectedStatus.provider,
selectedStatus.displayName,
selectedStatus.accounts,
account.id
)
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedStatus.provider,
@@ -663,6 +697,7 @@ export function CliproxyPage() {
: undefined
}
isFirstAccount={addAccountProvider?.isFirstAccount || false}
account={addAccountProvider?.account}
/>
</div>
);
@@ -94,6 +94,7 @@ describe('ControlPanelEmbed', () => {
const iframe = await screen.findByTitle('CLIProxy Management Panel');
expect(iframe).toHaveAttribute('src', '/api/cliproxy-local/management.html');
fireEvent.load(iframe);
await waitFor(() => {
expect(window.localStorage.removeItem).toHaveBeenCalledWith('cli-proxy-auth');
@@ -107,6 +108,7 @@ describe('ControlPanelEmbed', () => {
);
expect(window.localStorage.setItem).toHaveBeenCalledWith('managementKey', 'custom-secret');
expect(window.localStorage.setItem).toHaveBeenCalledWith('isLoggedIn', 'true');
expect(window.localStorage.removeItem).toHaveBeenCalledWith('managementKey');
});
vi.clearAllMocks();
@@ -86,6 +86,47 @@ describe('useCliproxyAuthFlow', () => {
);
});
it('includes the selected account id when reauth starts from the dashboard', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/codex/start-url')) {
return Promise.resolve(
createJsonResponse({
success: true,
authUrl: 'https://auth.example.com/codex?state=codex-reauth',
state: 'codex-reauth',
})
);
}
if (url.includes('/status?state=codex-reauth')) {
return Promise.resolve(createJsonResponse({ status: 'wait' }));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('codex', {
accountId: 'existing@example.com',
startEndpoint: 'start-url',
});
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/cliproxy/auth/codex/start-url',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"accountId":"existing@example.com"'),
})
);
});
it('surfaces manual OAuth start guidance from the dashboard API', async () => {
const guidance = [
'Start local CLIProxy first: ccs cliproxy start',
@@ -141,6 +141,65 @@ describe('deriveTraceGroups', () => {
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
});
it('keeps adjacent leaves with different detail payloads as separate rows', () => {
const result = deriveTraceGroups(
leafEntries(
{
id: '1',
timestamp: 't1',
event: 'poll',
message: 'same',
latencyMs: 25,
metadata: { attempt: 1, nested: { status: 'warm' } },
context: { account: 'alpha' },
error: { code: 'E_ONE', message: 'first' },
},
{
id: '2',
timestamp: 't2',
event: 'poll',
message: 'same',
latencyMs: 50,
metadata: { attempt: 2, nested: { status: 'cold' } },
context: { account: 'beta' },
error: { code: 'E_TWO', message: 'second' },
}
)
);
expect(result).toHaveLength(2);
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
expect(result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace'))).toEqual(['2', '1']);
});
it('coalesces leaves with semantically identical structured payloads', () => {
const result = deriveTraceGroups(
leafEntries(
{
id: '1',
timestamp: 't1',
event: 'poll',
message: 'tick',
latencyMs: 25,
metadata: { b: 2, a: 1 },
context: { nested: { b: false, a: true } },
},
{
id: '2',
timestamp: 't2',
event: 'poll',
message: 'tick',
latencyMs: 25,
metadata: { a: 1, b: 2 },
context: { nested: { a: true, b: false } },
}
)
);
expect(result).toHaveLength(1);
expect((result[0] as LeafItem).repeatCount).toBe(2);
});
it('display-sorts items reverse-chronologically', () => {
// Use distinct events so leaves don't coalesce — testing display sort,
// not coalesce.
@@ -0,0 +1,27 @@
import { render, screen } from '@tests/setup/test-utils';
import { describe, expect, it } from 'vitest';
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
import type { LogsEntry } from '@/lib/api-client';
function buildEntry(overrides: Partial<LogsEntry> = {}): LogsEntry {
return {
id: 'entry-1',
timestamp: '2026-04-07T11:00:00.000Z',
level: 'info',
source: 'dashboard',
event: 'logs.bootstrap',
message: 'Dashboard log entry',
processId: 25582,
runId: 'run-1',
...overrides,
};
}
describe('LogsDetailPanel', () => {
it('falls back to the raw timestamp when the selected log entry has an invalid timestamp', () => {
render(<LogsDetailPanel entry={buildEntry({ timestamp: 'not-a-date' })} />);
expect(screen.getByText('not-a-date')).toBeInTheDocument();
expect(screen.getByTitle('not-a-date')).toHaveTextContent('not-a-date');
});
});
@@ -0,0 +1,98 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthMonitorData } from '@/components/monitoring/auth-monitor/hooks';
const { useCliproxyAuthMock, useCliproxyStatsMock, useCliproxyStatusMock } = vi.hoisted(() => ({
useCliproxyAuthMock: vi.fn(),
useCliproxyStatsMock: vi.fn(),
useCliproxyStatusMock: vi.fn(),
}));
vi.mock('@/hooks/use-cliproxy', () => ({
useCliproxyAuth: useCliproxyAuthMock,
}));
vi.mock('@/hooks/use-cliproxy-stats', () => ({
useCliproxyStats: useCliproxyStatsMock,
useCliproxyStatus: useCliproxyStatusMock,
}));
const authStatus = [
{
provider: 'codex',
displayName: 'OpenAI Codex',
accounts: [
{
id: 'codex-account',
email: 'codex@example.com',
tokenFile: '/tmp/codex.json',
provider: 'codex',
isDefault: true,
},
],
},
];
describe('useAuthMonitorData', () => {
beforeEach(() => {
vi.clearAllMocks();
useCliproxyAuthMock.mockReturnValue({
data: { authStatus },
isLoading: false,
error: null,
});
useCliproxyStatsMock.mockReturnValue({
data: undefined,
isLoading: false,
dataUpdatedAt: 0,
});
});
it('keeps account data visible without polling stats when CLIProxy is unavailable', () => {
useCliproxyStatusMock.mockReturnValue({
data: { running: false },
isLoading: false,
});
const { result } = renderHook(() => useAuthMonitorData());
expect(useCliproxyStatsMock).toHaveBeenCalledWith(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.accounts).toHaveLength(1);
expect(result.current.totalRequests).toBe(0);
expect(result.current.providerStats[0]).toMatchObject({
provider: 'codex',
accountCount: 1,
totalRequests: 0,
});
});
it('enables live stats only after CLIProxy is running', () => {
useCliproxyStatusMock.mockReturnValue({
data: { running: true },
isLoading: false,
});
useCliproxyStatsMock.mockReturnValue({
data: {
accountStats: {
'codex:codex@example.com': {
source: 'codex@example.com',
successCount: 8,
failureCount: 2,
totalTokens: 100,
provider: 'codex',
},
},
},
isLoading: false,
dataUpdatedAt: Date.now(),
});
const { result } = renderHook(() => useAuthMonitorData());
expect(useCliproxyStatsMock).toHaveBeenCalledWith(true);
expect(result.current.totalSuccess).toBe(8);
expect(result.current.totalFailure).toBe(2);
expect(result.current.totalRequests).toBe(10);
});
});
@@ -191,4 +191,41 @@ describe('AuthMonitor', () => {
expect(within(getSummaryCard('Failed')).getByText('3')).toBeInTheDocument();
expect(within(getSummaryCard('Success Rate')).getByText('80%')).toBeInTheDocument();
});
it('shows CLIProxy guidance when a large account pool has a high failure rate', () => {
useAuthMonitorDataMock.mockReturnValue({
...authMonitorData,
accounts: Array.from({ length: 5 }, (_, index) => ({
id: `codex-${index}`,
email: `codex-${index}@example.com`,
tokenFile: `/tmp/codex-${index}.json`,
provider: 'codex',
displayName: 'OpenAI Codex',
isDefault: index === 0,
successCount: index === 0 ? 1 : 0,
failureCount: 5,
color: '#10a37f',
})),
totalSuccess: 1,
totalFailure: 25,
totalRequests: 26,
providerStats: [
{
provider: 'codex',
displayName: 'OpenAI Codex',
totalRequests: 26,
successCount: 1,
failureCount: 25,
accountCount: 5,
accounts: [],
},
],
overallSuccessRate: 4,
});
render(<AuthMonitor />);
expect(screen.getByText('High CLIProxy failure rate detected')).toBeInTheDocument();
expect(screen.getByText(/ccs cliproxy routing/)).toBeInTheDocument();
});
});
@@ -83,6 +83,20 @@ describe('QuotaTooltipContent', () => {
expect(screen.getByText(expectedReset)).toBeInTheDocument();
});
it('renders the Gemini tier label when entitlement evidence is absent', () => {
const quota = createGeminiQuotaResult({
entitlement: undefined,
tierLabel: 'Legacy Pro',
tierId: null,
creditBalance: null,
});
render(<QuotaTooltipContent quota={quota} resetTime={null} />);
expect(screen.getByText('Tier')).toBeInTheDocument();
expect(screen.getByText('Legacy Pro')).toBeInTheDocument();
});
it('falls back to the shared reset indicator when Gemini buckets omit reset timestamps', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-29T00:00:00Z'));
@@ -68,6 +68,7 @@ describe('provider presentation metadata', () => {
'gitlab',
'codebuddy',
'kilo',
'qoder',
]);
expect(getProviderSection('gitlab')?.id).toBe('plus-extra');
expect(getProviderSection('gemini')?.id).toBe('core');
@@ -128,6 +129,7 @@ describe('provider presentation metadata', () => {
'/assets/providers/codebuddy.png',
],
['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.png'],
['qoder', 'Qoder', 'Qoder AI coding assistant', '/assets/providers/qoder.svg'],
])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => {
expect(getProviderDisplayName(provider)).toBe(name);
expect(getProviderDescription(provider)).toBe(description);