diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 231ab850..868c9a89 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -389,6 +389,8 @@ export interface OAuthOptions { account?: string; add?: boolean; nickname?: string; + /** Existing account id to update during reauthentication. */ + expectedAccountId?: string; /** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */ acceptAgyRisk?: boolean; /** Kiro auth method override (CLI + Dashboard parity). */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index a326722a..3c0c06f3 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -1131,8 +1131,9 @@ export async function triggerOAuth( // Check for existing accounts const existingAccounts = getProviderAccounts(provider); const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; + const targetAccountId = options.expectedAccountId || existingNameMatch?.id; const nicknameError = !fromUI - ? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id) + ? getCliAuthNicknameError(provider, nickname, existingAccounts, targetAccountId) : null; if (nicknameError) { console.log(fail(nicknameError)); @@ -1144,7 +1145,7 @@ export async function triggerOAuth( const tokenDir = getProviderTokenDir(provider); const success = await importKiroToken(verbose); if (success) { - return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id); + return registerAccountFromToken(provider, tokenDir, nickname, verbose, targetAccountId); } return null; } @@ -1235,7 +1236,7 @@ export async function triggerOAuth( verbose, tokenDir, nickname, - existingNameMatch?.id, + targetAccountId, { gitlabBaseUrl: resolvedGitLabBaseUrl, gitlabPersonalAccessToken: options.gitlabPersonalAccessToken, @@ -1251,7 +1252,7 @@ export async function triggerOAuth( verbose, tokenDir, nickname, - existingNameMatch?.id, + targetAccountId, { kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined, @@ -1336,7 +1337,7 @@ export async function triggerOAuth( verbose, isCLI, nickname, - expectedAccountId: existingNameMatch?.id, + expectedAccountId: targetAccountId, authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code', kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, manualCallback: useSelectedKiroLocalPasteCallback, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 72920473..f6874a08 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -348,6 +348,24 @@ export function getStartAuthNicknameError( return null; } +export function getReauthAccountTarget( + accountId: string | undefined, + existingAccounts: Array<{ id: string; nickname?: string }> +): { account?: { id: string; nickname?: string }; error?: string } { + if (!accountId) { + return {}; + } + + const account = existingAccounts.find((candidate) => candidate.id === accountId); + if (!account) { + return { + error: `Account '${accountId}' not found for this provider`, + }; + } + + return { account }; +} + /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers @@ -623,6 +641,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise) : {}; const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; + const accountId = + typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined; const noIncognitoBody = typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined; const kiroMethodRaw = requestBody.kiroMethod; @@ -659,6 +679,16 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise) : {}; const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; + const accountId = + typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined; const kiroMethodRaw = requestBody.kiroMethod; const gitlabAuthModeRaw = requestBody.gitlabAuthMode; const gitlabBaseUrl = @@ -1037,11 +1070,20 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } - const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); + const localProvider = provider as CLIProxyProvider; + const existingAccounts = getProviderAccounts(localProvider); + const reauthTarget = getReauthAccountTarget(accountId, existingAccounts); + if (reauthTarget.error) { + res.status(404).json({ error: reauthTarget.error }); + return; + } + const targetAccountId = reauthTarget.account?.id; + const effectiveNickname = nickname || reauthTarget.account?.nickname; const nicknameError = getStartAuthNicknameError( - provider as CLIProxyProvider, - nickname, - existingAccounts + localProvider, + effectiveNickname, + existingAccounts, + targetAccountId ); if (nicknameError) { res.status(400).json(nicknameError); @@ -1136,8 +1178,9 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise if (oauthState) { rememberManualAuthState(oauthState, { - nickname: nickname || undefined, - knownTokenFiles: listProviderTokenSnapshots(provider as CLIProxyProvider), + nickname: effectiveNickname || undefined, + expectedAccountId: targetAccountId, + knownTokenFiles: listProviderTokenSnapshots(localProvider), }); } @@ -1237,7 +1280,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise { } }); + it('reauthenticates a targeted existing Codex account when its token file is rewritten', async () => { + const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(tokenDir, { recursive: true }); + const tokenPath = path.join(tokenDir, 'codex-existing@example.com.json'); + fs.writeFileSync( + tokenPath, + JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 1 }), + 'utf8' + ); + + const initialAccount = registerAccountFromToken('codex', tokenDir, 'work'); + expect(initialAccount?.id).toBe('existing@example.com'); + + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + response: { + auth_url: 'https://auth.example.com/authorize?state=state-targeted-rewrite', + state: 'state-targeted-rewrite', + }, + }, + { + url: /\/v0\/management\/get-auth-status\?state=state-targeted-rewrite$/, + response: { status: 'ok' }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', { + accountId: 'existing@example.com', + }); + expect(startResponse.status).toBe(200); + + fs.writeFileSync( + tokenPath, + JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 2 }), + 'utf8' + ); + + const statusResponse = await getJson( + '/api/cliproxy/auth/codex/status?state=state-targeted-rewrite' + ); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body).toEqual({ + status: 'ok', + account: { + id: 'existing@example.com', + email: 'existing@example.com', + nickname: 'work', + provider: 'codex', + isDefault: true, + }, + }); + }); + it('registers the new account before reporting polled auth success', async () => { mockFetch([ { diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index b6cf9d82..93a1eed5 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { getKiroStartIDCValidationError, + getReauthAccountTarget, getStartAuthFailureMessage, getStartAuthNicknameError, getStartUrlUnsupportedReason, @@ -142,3 +143,27 @@ describe('cliproxy-auth-routes nickname validation', () => { ).toBeNull(); }); }); + +describe('cliproxy-auth-routes reauth account targeting', () => { + const existingAccounts = [ + { id: 'codex-user@example.com', nickname: 'work' }, + { id: 'codex-personal@example.com', nickname: 'personal' }, + ]; + + it('does not require a target for normal add-account auth', () => { + expect(getReauthAccountTarget(undefined, existingAccounts)).toEqual({}); + expect(getReauthAccountTarget('', existingAccounts)).toEqual({}); + }); + + it('resolves an existing account target for reauth', () => { + expect(getReauthAccountTarget('codex-user@example.com', existingAccounts)).toEqual({ + account: { id: 'codex-user@example.com', nickname: 'work' }, + }); + }); + + it('rejects unknown account targets instead of falling back to ambiguous registration', () => { + expect(getReauthAccountTarget('missing', existingAccounts)).toEqual({ + error: "Account 'missing' not found for this provider", + }); + }); +}); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 12cfda55..77e8a2fb 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -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 => { 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({ }} > - {t('addAccountDialog.title', { displayName })} + + {isReauth + ? `Reauthenticate ${displayName}` + : t('addAccountDialog.title', { displayName })} + - {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')} @@ -600,7 +612,16 @@ export function AddAccountDialog({ )} {/* Nickname input - only show before auth starts */} - {!showAuthUI && ( + {!showAuthUI && isReauth && ( +
+
{accountLabel}
+
+ This sign-in updates the selected account instead of creating another account. +
+
+ )} + + {!showAuthUI && !isReauth && (
@@ -784,7 +805,7 @@ export function AddAccountDialog({ } > - {t('addAccountDialog.authenticate')} + {isReauth ? 'Reauthenticate' : t('addAccountDialog.authenticate')} )}
diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 5da3a360..1627706d 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -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 )} + {onReauth && ( + + + Reauthenticate + + )} 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 } diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 65be5b3b..dc3cb84a 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -42,6 +42,7 @@ export function ProviderEditor({ defaultTarget, topNotice, onAddAccount, + onReauthAccount, onSetDefault, onRemoveAccount, onPauseToggle, @@ -282,6 +283,7 @@ export function ProviderEditor({ isDeletePending={deletePresetMutation.isPending} accounts={accounts} onAddAccount={onAddAccount} + onReauthAccount={onReauthAccount} onSetDefault={onSetDefault} onRemoveAccount={onRemoveAccount} onPauseToggle={onPauseToggle} diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index 5ba8219e..c49f6225 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -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({ 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; diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx index a521a162..d9e6e2b7 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -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() { />
+ {showHighFailureGuidance && ( +
+ +
+

High CLIProxy failure rate detected

+

+ Select the affected provider, pause failing accounts, then check{' '} + ccs cliproxy routing. + For large Codex pools, prefer a smaller healthy active set and attach CLIProxy logs if + failures continue. +

+
+
+ )} + {/* Flow Visualization */}
{selectedProviderData ? ( diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 509abdf9..176a95fe 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -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, diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 95a8d6e3..e28f21dd 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -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 (
{/* 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} />
); diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx index 5827b444..8cf8f533 100644 --- a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -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', diff --git a/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor.test.tsx b/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor.test.tsx index c7174ff6..e98c1630 100644 --- a/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor.test.tsx +++ b/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor.test.tsx @@ -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(); + + expect(screen.getByText('High CLIProxy failure rate detected')).toBeInTheDocument(); + expect(screen.getByText(/ccs cliproxy routing/)).toBeInTheDocument(); + }); });