feat: enhance credential management with cards, settings, and quota controls

- Display credentials in responsive cards with quota progress bars
- Add request statistics and detailed health and cooldown information
- Consolidate nine credential settings into a dialog with partial updates
- Reuse Codex quota reset logic and confirmation across pages
- Move notes above card actions and hide the healthy status indicator
- Add translations and regression coverage
This commit is contained in:
lzt404 committed 2026-09-20 18:09:32 +08:00
1 parent bc8b34f9f1
commit fe83731b94
24 files changed
+1973 -209

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
.auth-file-health {
--health-accent: var(--text-tertiary);
grid-column: 2 / -1;
min-width: 0;
color: var(--text-secondary);
font-size: var(--font-size-label);
line-height: 1.55;
}
.auth-file-health.success { --health-accent: var(--success-color); }
.auth-file-health.warning { --health-accent: var(--amber-text); }
.auth-file-health.error { --health-accent: var(--failure-badge-text); }
.auth-file-health.info { --health-accent: var(--primary-color); }
.auth-file-health.detailed {
border: 1px solid var(--border-color);
border-left: 3px solid var(--health-accent);
border-radius: 6px;
background: color-mix(in srgb, var(--health-accent) 4%, var(--bg-card));
}
.auth-health-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
min-width: 0;
list-style: none;
}
.auth-file-health.detailed .auth-health-summary {
padding: 9px 12px;
cursor: pointer;
}
.auth-health-summary::-webkit-details-marker { display: none; }
.auth-health-summary:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: 4px;
}
.auth-health-icon { flex-shrink: 0; color: var(--health-accent); }
.auth-health-summary-content { flex: 1; min-width: 0; }
.auth-health-heading,
.auth-health-record-heading,
.auth-health-record-meta {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 3px 12px;
}
.auth-health-heading strong { color: var(--health-accent); font-weight: 600; }
.auth-health-heading > span { color: var(--text-secondary); }
.auth-health-message-preview {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
color: var(--text-secondary);
}
.auth-health-countdown,
.auth-health-record-heading > span {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.auth-health-disclosure {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-tertiary);
font-size: var(--font-size-meta);
}
.auth-file-health details[open] .auth-health-disclosure svg { transform: rotate(180deg); }
.auth-health-body { padding: 0 12px 10px; }
.auth-health-core-state { margin: 0 0 8px; }
.auth-health-core-message > span { color: var(--text-tertiary); font-size: var(--font-size-meta); }
.auth-health-core-message p { margin: 4px 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
.auth-health-records { margin: 0; padding: 0; list-style: none; }
.auth-health-records li { padding: 9px 0; border-top: 1px solid var(--border-color); }
.auth-health-record-heading { justify-content: space-between; }
.auth-health-record-heading strong { min-width: 0; color: var(--text-primary); overflow-wrap: anywhere; }
.auth-health-record-meta { margin: 3px 0; }
.auth-health-hint { display: block; margin: 3px 0 0; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; }
@media (max-width: 760px) {
.auth-health-summary-content { flex-basis: calc(100% - 30px); }
.auth-health-countdown { margin-left: 26px; }
.auth-health-disclosure { margin-left: auto; }
}
+143
View File
@@ -0,0 +1,143 @@
import { useMemo, useSyncExternalStore } from 'react';
import { ChevronDown, CircleHelp, Clock3, Info, PauseCircle, ShieldAlert } from 'lucide-react';
import { useI18n } from '../i18n';
import {
authFileHealth,
cooldownReasonKey,
normalizeAuthFileCooldowns,
summarizeAuthFileCooldowns,
type AuthFileCooldownSnapshot,
} from '../services/authFileHealth';
import './AuthFileHealthStatus.css';
let clockNow = Date.now();
let timer: ReturnType<typeof setInterval> | undefined;
const listeners = new Set<() => void>();
const getClockSnapshot = () => clockNow;
const subscribeClock = (listener: () => void) => {
listeners.add(listener);
if (!timer) {
clockNow = Date.now();
timer = setInterval(() => {
clockNow = Date.now();
listeners.forEach((notify) => notify());
}, 1000);
}
return () => {
listeners.delete(listener);
if (!listeners.size) {
clearInterval(timer);
timer = undefined;
}
};
};
type HealthProps = {
file: Record<string, unknown>;
snapshot?: AuthFileCooldownSnapshot;
};
export function AuthFileHealthStatus({ file, receivedAtMs, observedAt }: {
file: Record<string, unknown>;
receivedAtMs: number;
observedAt?: string;
}) {
const snapshot = useMemo(
() => normalizeAuthFileCooldowns(file.cooldowns, receivedAtMs, observedAt),
[file, receivedAtMs, observedAt],
);
return snapshot?.records?.length
? <TimedHealthStatus file={file} snapshot={snapshot} />
: <HealthStatus file={file} snapshot={snapshot} nowMs={receivedAtMs} />;
}
function TimedHealthStatus(props: HealthProps) {
const nowMs = useSyncExternalStore(subscribeClock, getClockSnapshot, getClockSnapshot);
return <HealthStatus {...props} nowMs={nowMs} />;
}
function HealthStatus({ file, snapshot, nowMs }: HealthProps & { nowMs: number }) {
const { t, formatNumber, formatDate } = useI18n();
const health = authFileHealth(file);
const cooldown = summarizeAuthFileCooldowns(snapshot, nowMs);
const hasTimers = cooldown.rows.length > 0;
const healthy = health.label === 'authFiles.health.active';
if (healthy && !hasTimers && snapshot?.records !== null) return null;
const hasDetails = hasTimers || Boolean(health.message);
const reasonSet = new Set(cooldown.active.map(({ record }) => cooldownReasonKey(record.reason)));
if (reasonSet.has('authFiles.health.reason.credentialQuota')) reasonSet.delete('authFiles.health.reason.quota');
const reasons = Array.from(reasonSet);
const title = health.disabled ? t(health.label)
: cooldown.elapsed ? t('authFiles.health.elapsed')
: cooldown.active.length ? reasons.map((key) => t(key)).join(' · ')
: healthy ? t('authFiles.health.unknownCooldown') : t(health.label);
const tone = health.disabled || (healthy && !hasTimers) ? 'neutral' : hasTimers ? 'warning' : health.tone;
const separateState = hasTimers && !health.disabled && (
health.status === 'pending' || health.status === 'refreshing'
|| health.label === 'authFiles.health.reason.invalidGrant'
|| health.label === 'authFiles.health.reason.unauthorized'
|| health.label === 'authFiles.health.reason.tokenExpired'
);
const Icon = health.disabled ? PauseCircle : hasTimers ? Clock3
: tone === 'error' ? ShieldAlert
: tone === 'warning' || tone === 'info' ? Info : CircleHelp;
const duration = (seconds: number) => seconds < 60
? t('authFiles.health.seconds', { count: formatNumber(seconds) })
: seconds < 3600 ? t('authFiles.health.minutes', { count: formatNumber(Math.ceil(seconds / 60)) })
: t('authFiles.health.hours', { count: formatNumber(Math.ceil(seconds / 3600)) });
const scope = cooldown.credentialWide
? cooldown.modelCount ? t('authFiles.health.credentialModels', { count: cooldown.modelCount })
: t('authFiles.health.credential')
: cooldown.modelCount ? t('authFiles.health.models', { count: cooldown.modelCount }) : '';
const formatTimestamp = (value: string) => formatDate(value, {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
const summary = <>
<Icon size={16} className="auth-health-icon" aria-hidden="true" />
<span className="auth-health-summary-content">
<span className="auth-health-heading"><strong>{title}</strong>{scope ? <span>{scope}</span> : null}</span>
{separateState ? <span className="auth-health-hint">{t('authFiles.health.coreStatus', { status: t(health.label) })}</span> : null}
{health.message && !hasTimers && !health.label.startsWith('authFiles.health.reason.')
? <span className="auth-health-message-preview">{health.message}</span> : null}
{cooldown.elapsed ? <span className="auth-health-hint">{t('authFiles.health.expiredHint')}</span> : null}
{snapshot?.records === null && !healthy ? <span className="auth-health-hint">{t('authFiles.health.unknownCooldown')}</span> : null}
{snapshot === undefined && health.tone === 'error' ? <span className="auth-health-hint">{t('authFiles.health.missingCooldown')}</span> : null}
</span>
{cooldown.active.length > 0 ? <span className="auth-health-countdown">
{t('authFiles.health.earliest', { time: duration(cooldown.earliestSeconds) })}
</span> : null}
{hasDetails ? <span className="auth-health-disclosure"><span>{t('authFiles.health.details')}</span><ChevronDown size={14} aria-hidden="true" /></span> : null}
</>;
return (
<section className={`auth-file-health ${tone}${hasDetails ? ' detailed' : ''}`} aria-label={t('authFiles.health.title')}>
{hasDetails ? <details>
<summary className="auth-health-summary">{summary}</summary>
<div className="auth-health-body">
{hasTimers && !healthy ? <p className="auth-health-core-state">{t('authFiles.health.coreStatus', { status: t(health.label) })}</p> : null}
{health.message ? <div className="auth-health-core-message">
<span>{t('authFiles.health.coreMessage')}</span><p>{health.message}</p>
</div> : null}
<ul className="auth-health-records">
{cooldown.rows.map(({ record, remainingSeconds }, index) => (
<li key={`${record.scope}:${record.model ?? ''}:${index}`}>
<div className="auth-health-record-heading">
<strong>{record.scope === 'credential' ? t('authFiles.health.credentialScope') : record.model}</strong>
<span>{remainingSeconds > 0 ? t('authFiles.health.remaining', { time: duration(remainingSeconds) }) : t('authFiles.health.waiting')}</span>
</div>
<div className="auth-health-record-meta">
<span>{t(cooldownReasonKey(record.reason))}</span>
{record.httpStatus !== undefined ? <span>HTTP {record.httpStatus}</span> : null}
{record.backoffLevel !== undefined ? <span title={t('authFiles.health.backoffHint')}>{t('authFiles.health.backoff', { level: record.backoffLevel })}</span> : null}
</div>
<div className="auth-health-hint">{t('authFiles.health.deadline')} <time dateTime={record.retryAt}>{formatTimestamp(record.retryAt)}</time></div>
</li>
))}
</ul>
{snapshot?.observedAt ? <p className="auth-health-hint">{t('authFiles.health.observed')} <time dateTime={snapshot.observedAt}>{formatTimestamp(snapshot.observedAt)}</time></p> : null}
{hasTimers ? <p className="auth-health-hint">{t('authFiles.health.note')}</p> : null}
</div>
</details> : <div className="auth-health-summary">{summary}</div>}
</section>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { LoaderCircle, RefreshCw } from 'lucide-react';
import { useI18n } from '../i18n';
import { MessageNotice } from '../appNotice';
import { fileName, formatQuotaTimestamp, type AuthFile, type QuotaState } from '../services/quotaService';
import { canResetCodexQuota } from '../services/quotaActions';
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset }: {
quota: QuotaState;
file: AuthFile;
disabled: boolean;
onRefresh: () => void;
onReset?: () => void;
}) {
const { locale, t } = useI18n();
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
const loading = quota.status === 'loading';
const name = fileName(file);
return (
<section className="credential-quota" aria-label={t('authFiles.quota.aria')} aria-busy={loading}>
<div className="credential-quota-heading">
<strong>{t('authFiles.settings.quotaRemaining')}</strong>
{quota.plan ? <span className="credential-quota-plan">{quota.plan}</span> : null}
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button credential-quota-reset" onClick={onReset} disabled={disabled || !canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
<button type="button" className="credential-quota-refresh" disabled={disabled || loading} onClick={onRefresh}
title={disabled ? t('quota.fileDisabled') : t('authFiles.quota.refresh')}>
{loading ? <LoaderCircle size={13} className="spin" /> : <RefreshCw size={13} />}
{t(loading ? 'authFiles.quota.querying' : quota.status === 'idle' ? 'authFiles.quota.fetch' : 'common.refresh')}
</button>
</div>
{loading ? <div className="credential-quota-loading" role="status"><span>{t(quota.pendingAction === 'reset' ? 'quota.resetting' : 'authFiles.quota.loading')}</span><div className="credential-quota-track indeterminate"><i /></div></div> : null}
{quota.status === 'idle' ? <p className="credential-quota-empty">{t(disabled ? 'quota.fileDisabled' : 'authFiles.settings.quotaIdle')}</p> : null}
{quota.status === 'error' ? <div className="credential-quota-error" role="status"><span>{t('authFiles.quota.failed')}</span>{quota.error ? <small>{quota.error}</small> : null}</div> : null}
{quota.status === 'success' ? <>
{quota.rows.length ? <div className="credential-quota-rows">{quota.rows.map((row, index) => {
const percent = row.remainingPercent !== null && Number.isFinite(row.remainingPercent) ? Math.max(0, Math.min(100, row.remainingPercent)) : null;
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
const tone = percent === null ? 'unknown' : percent <= 10 ? 'low' : percent <= 30 ? 'medium' : 'high';
return <div className={`credential-quota-row ${tone}`} key={`${row.label}-${index}`}>
<div className="credential-quota-label"><span title={row.label}>{row.label}</span><strong>{percent === null ? '—' : `${Math.round(percent)}%`}</strong></div>
<div className="credential-quota-track" role={percent === null ? undefined : 'progressbar'} aria-label={`${row.label} · ${t('authFiles.settings.quotaRemaining')}`}
aria-valuemin={percent === null ? undefined : 0} aria-valuemax={percent === null ? undefined : 100} aria-valuenow={percent ?? undefined}>
{percent === null ? <span className="sr-only">{t('authFiles.settings.quotaUnknown')}</span> : <i style={{ width: `${percent}%` }} />}
</div>
{reset ? <small>{reset}</small> : null}
{row.detail ? <small>{row.detail}</small> : null}
</div>;
})}</div> : <p className="credential-quota-empty">{t('authFiles.quota.empty')}</p>}
<div className="credential-quota-footnotes">
{quota.resetCredits !== undefined ? <small>{t('authFiles.quota.resets', { count: quota.resetCredits })}</small> : null}
{quota.resetCreditsApplicable !== undefined ? <small>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</small> : null}
{quota.subscriptionActiveUntil ? <small>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</small> : null}
{quota.resetCreditsEarliestExpiry ? <small>{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</small> : null}
</div>
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
</> : null}
</section>
);
}
+108
View File
@@ -0,0 +1,108 @@
.auth-file-requests {
grid-column: 2 / -1;
display: grid;
gap: 6px;
min-width: 0;
color: var(--text-tertiary);
font-size: var(--font-size-meta);
font-variant-numeric: tabular-nums;
}
.auth-file-requests-heading,
.auth-file-requests-counts,
.auth-file-requests-timeline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px 12px;
}
.auth-file-requests-heading > span:first-child {
font-weight: 600;
}
.auth-file-requests-counts {
gap: 5px 10px;
}
.auth-file-requests-window {
color: var(--text-tertiary);
}
.auth-file-requests-chart {
position: relative;
flex: 1 1 160px;
max-width: 440px;
min-width: 0;
}
.auth-file-requests-blocks {
display: flex;
gap: 3px;
padding: 3px 0;
}
.auth-file-request-block {
flex: 1 1 0;
min-width: 0;
height: 14px;
padding: 0;
border: 0;
border-radius: 2px;
background: var(--border-primary);
cursor: pointer;
}
.auth-file-request-block.active,
.auth-file-request-block:focus-visible {
outline: 1px solid var(--text-secondary);
outline-offset: 1px;
}
.auth-file-requests-rate {
min-width: 42px;
text-align: right;
font-weight: 600;
}
.auth-file-requests .success {
color: var(--success-color);
}
.auth-file-requests .failure {
color: var(--failure-badge-text);
}
.auth-file-requests .mixed {
color: var(--amber-text);
}
.auth-file-requests-tooltip {
position: absolute;
bottom: calc(100% + 5px);
left: 50%;
z-index: 10;
display: grid;
gap: 5px;
width: max-content;
max-width: 100%;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: 7px;
background: var(--bg-card);
color: var(--text-secondary);
box-shadow: 0 4px 14px #0002;
pointer-events: none;
transform: translateX(-50%);
}
.auth-file-requests-tooltip.start {
left: 0;
transform: none;
}
.auth-file-requests-tooltip.end {
right: 0;
left: auto;
transform: none;
}
+85
View File
@@ -0,0 +1,85 @@
import { useId, useMemo, useState } from 'react';
import { useI18n } from '../i18n';
import { authFileRequestStats, requestRateColor } from '../services/authFileRequests';
import './AuthFileRequestStatus.css';
export function AuthFileRequestStatus({ file }: { file: Record<string, unknown> }) {
const { t, formatNumber } = useI18n();
const stats = useMemo(() => authFileRequestStats(file), [file]);
const [active, setActive] = useState<number | null>(null);
const tooltipId = useId();
const rateText = (rate: number | null) => rate === null ? '—' : formatNumber(rate, {
style: 'percent', maximumFractionDigits: 1,
});
const countText = (count: number | null) => count === null ? '—' : formatNumber(count);
const rateClass = stats.recentRate === null ? ''
: stats.recentRate >= 0.9 ? 'success' : stats.recentRate >= 0.5 ? 'mixed' : 'failure';
const detail = active === null ? null : stats.buckets[active];
const bucketLabel = (index: number) => {
const bucket = stats.buckets[index];
const time = bucket.time || t('authFiles.requests.interval', { index: index + 1 });
return bucket.rate === null
? `${time} · ${t('authFiles.requests.empty')}`
: `${time} · ${t('authFiles.requests.success', { count: formatNumber(bucket.success) })} · ${t('authFiles.requests.failure', { count: formatNumber(bucket.failure) })} · ${t('authFiles.requests.rate', { rate: rateText(bucket.rate) })}`;
};
return (
<div className="auth-file-requests" role="group" aria-label={t('authFiles.requests.title')}>
<div className="auth-file-requests-heading">
<span>{t('authFiles.requests.title')}</span>
<span className="auth-file-requests-counts" title={t('authFiles.requests.totalsHint')}>
<span className={stats.success ? 'success' : ''}>{t('authFiles.requests.success', { count: countText(stats.success) })}</span>
<span className={stats.failure ? 'failure' : ''}>{t('authFiles.requests.failure', { count: countText(stats.failure) })}</span>
</span>
{stats.recentAvailable ? <span className="auth-file-requests-window">{t('authFiles.requests.window')}</span> : null}
</div>
{stats.recentAvailable ? (
<div className="auth-file-requests-timeline">
<div className="auth-file-requests-chart">
<div className="auth-file-requests-blocks" onMouseLeave={() => setActive(null)}>
{stats.buckets.map((bucket, index) => (
<button
key={index}
type="button"
className={`auth-file-request-block${active === index ? ' active' : ''}`}
style={bucket.rate === null ? undefined : { backgroundColor: requestRateColor(bucket.rate) }}
aria-label={bucketLabel(index)}
aria-describedby={active === index ? tooltipId : undefined}
onMouseEnter={() => setActive(index)}
onFocus={() => setActive(index)}
onBlur={() => setActive(null)}
onClick={() => setActive(index)}
onKeyDown={(event) => {
if (event.key === 'Escape') setActive(null);
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
const next = event.key === 'ArrowLeft'
? event.currentTarget.previousElementSibling : event.currentTarget.nextElementSibling;
if (next instanceof HTMLButtonElement) next.focus();
}
}}
/>
))}
</div>
{detail && active !== null ? (
<div className={`auth-file-requests-tooltip ${active < 7 ? 'start' : active > 12 ? 'end' : ''}`} id={tooltipId} role="tooltip">
<strong>{detail.time || t('authFiles.requests.interval', { index: active + 1 })}</strong>
{detail.rate === null ? <span>{t('authFiles.requests.empty')}</span> : <>
<span className="auth-file-requests-counts">
<span className="success">{t('authFiles.requests.success', { count: formatNumber(detail.success) })}</span>
<span className="failure">{t('authFiles.requests.failure', { count: formatNumber(detail.failure) })}</span>
</span>
<span>{t('authFiles.requests.rate', { rate: rateText(detail.rate) })}</span>
</>}
</div>
) : null}
</div>
<span className={`auth-file-requests-rate ${rateClass}`} title={t('authFiles.requests.recentRate')}>
{rateText(stats.recentRate)}
</span>
{stats.recentRate === null ? <span>{t('authFiles.requests.empty')}</span> : null}
</div>
) : <span className="auth-file-requests-unavailable">{t('authFiles.requests.unavailable')}</span>}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
.credential-settings-dialog {
width: min(760px, calc(100vw - 32px));
max-height: min(900px, calc(100dvh - 40px));
padding: 0;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 16px;
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 24px 80px #0004;
}
.credential-settings-dialog::backdrop { background: #0b122080; backdrop-filter: blur(3px); }
.credential-settings-dialog form { display: flex; flex-direction: column; max-height: min(898px, calc(100dvh - 42px)); min-height: 0; }
.credential-settings-heading, .credential-settings-footer { flex-shrink: 0; }
.credential-settings-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--border-color); }
.credential-settings-heading > div { min-width: 0; }
.credential-settings-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: 18px; }
.credential-settings-heading p { margin: 6px 0 0; color: var(--text-tertiary); font-size: 12px; overflow-wrap: anywhere; }
.credential-settings-body { overflow-y: auto; min-height: 0; padding: 22px 24px; overscroll-behavior: contain; }
.credential-settings-body fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.credential-settings-section + .credential-settings-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border-color); }
.credential-settings-section h3 { margin: 0 0 15px; font-size: 14px; }
.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px; }
.credential-settings-field { display: grid; align-content: start; gap: 7px; min-width: 0; font-size: 13px; }
.credential-settings-field + .credential-settings-field { margin-top: 16px; }
.credential-settings-grid .credential-settings-field { margin: 0; }
.credential-settings-dialog code { font-family: ui-monospace, monospace; font-size: 11px; font-weight: 400; color: var(--text-tertiary); margin-left: 5px; }
.credential-settings-dialog small { display: block; color: var(--text-tertiary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.credential-settings-dialog input:not([type=checkbox]), .credential-settings-dialog select, .credential-settings-dialog textarea {
box-sizing: border-box; width: 100%; min-width: 0; padding: 9px 11px; border: 1px solid var(--border-color); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); font: inherit; line-height: 1.5;
}
.credential-settings-dialog textarea { resize: vertical; }
.credential-settings-dialog textarea.credential-settings-json { font-family: ui-monospace, monospace; font-size: 12px; }
.credential-settings-dialog :is(input, textarea, select, summary):focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.credential-settings-catalog { margin-top: 12px; padding: 11px 12px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 12px; }
.credential-settings-catalog summary { cursor: pointer; color: var(--text-secondary); }
.credential-settings-catalog[open] summary { margin-bottom: 12px; }
.credential-settings-models { display: grid; gap: 8px; max-height: 210px; overflow-y: auto; margin-top: 12px; }
.credential-settings-models label { display: flex; align-items: flex-start; gap: 8px; overflow-wrap: anywhere; }
.credential-settings-models input { accent-color: var(--primary-color); margin-top: 3px; }
.credential-settings-models span { min-width: 0; }
.credential-settings-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); }
.credential-settings-actions { display: flex; gap: 10px; justify-content: flex-end; }
.credential-settings-error { color: var(--failure-badge-text); margin: 0 0 12px; font-size: 12px; overflow-wrap: anywhere; max-height: 90px; overflow-y: auto; }
.credential-settings-warning { color: var(--amber-text); }
.credential-settings-discard { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: 12px; }
.credential-settings-discard span { flex: 1 1 100%; }
.credential-settings-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 150px; }
@media (max-width: 560px) {
.credential-settings-grid { grid-template-columns: minmax(0, 1fr); }
.credential-settings-heading, .credential-settings-body, .credential-settings-footer { padding: 16px; }
}
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check, LoaderCircle, Settings2, X } from 'lucide-react';
import { useI18n } from '../i18n';
import { managementApi } from '../services/managementApi';
import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft, type BooleanOverride } from '../services/authFileSettings';
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
import './AuthFileSettingsDialog.css';
export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
name: string;
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useI18n();
const id = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
const savingRef = useRef(false);
const [original, setOriginal] = useState<AuthFileSettingsDraft | null>(null);
const [draft, setDraft] = useState<AuthFileSettingsDraft | null>(null);
const [models, setModels] = useState<OAuthModelDefinition[]>([]);
const [catalogError, setCatalogError] = useState('');
const [catalogLoading, setCatalogLoading] = useState(true);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [discard, setDiscard] = useState(false);
const [attempt, setAttempt] = useState(0);
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(original);
useEffect(() => {
const previousFocus = document.activeElement;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const dialog = dialogRef.current;
dialog?.showModal();
return () => {
dialog?.close();
document.body.style.overflow = previousOverflow;
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
};
}, []);
useEffect(() => {
let active = true;
setLoading(true);
setError('');
setCatalogLoading(true);
setCatalogError('');
void loadAuthFileSettings(name).then((value) => {
if (active) { setOriginal(value); setDraft(value); }
}).catch((reason: unknown) => {
if (active) setError(reason instanceof Error ? reason.message : String(reason));
}).finally(() => { if (active) setLoading(false); });
void managementApi.get('/auth-files/models', { name }).then((payload) => {
if (active) setModels(oauthModelsFromPayload(payload));
}).catch((reason: unknown) => {
if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason));
}).finally(() => { if (active) setCatalogLoading(false); });
return () => { active = false; };
}, [name, attempt]);
const close = () => {
if (savingRef.current) return;
if (dirty) setDiscard(true);
else onClose();
};
const update = <K extends keyof AuthFileSettingsDraft>(key: K, value: AuthFileSettingsDraft[K]) => {
setDraft((current) => current ? { ...current, [key]: value } : current);
setError('');
setDiscard(false);
};
const save = async () => {
if (!draft || !original || savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError('');
setDiscard(false);
try {
const changed = await saveAuthFileSettings(name, original, draft);
if (changed) onSaved();
else onClose();
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
savingRef.current = false;
setSaving(false);
}
};
const rules = normalizeOAuthExcludedRules((draft?.excluded_models ?? '').split(/\r?\n/));
const candidates = oauthModelCandidates(models, rules).filter((model) =>
`${model.id} ${model.displayName ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()));
const textField = (key: 'prefix' | 'proxy_url' | 'priority' | 'weight') => (
<label className="credential-settings-field">
<span>{t(`authFiles.settings.${key}`)} <code>{key}</code></span>
<input value={draft?.[key] ?? ''} onChange={(event) => update(key, event.currentTarget.value)}
inputMode={key === 'priority' || key === 'weight' ? 'numeric' : undefined}
placeholder={key === 'priority' ? '0' : key === 'weight' ? '1' : key === 'proxy_url' ? 'socks5://127.0.0.1:1080' : ''}
autoComplete="off" spellCheck={false} />
<small>{t(`authFiles.settings.${key}Hint`)}</small>
</label>
);
const booleanField = (key: 'disable_cooling' | 'websockets') => (
<label className="credential-settings-field">
<span>{t(`authFiles.settings.${key}`)} <code>{key}</code></span>
<select value={draft?.[key] ?? ''} onChange={(event) => update(key, event.currentTarget.value as BooleanOverride)}>
<option value="">{t('authFiles.settings.inherit')}</option>
<option value="true">{t('common.enable')}</option>
<option value="false">{t('common.disable')}</option>
</select>
<small>{t(`authFiles.settings.${key}Hint`)}</small>
</label>
);
const content = (
<dialog ref={dialogRef} className="credential-settings-dialog" aria-labelledby={`${id}-title`}
onCancel={(event) => { event.preventDefault(); close(); }} onKeyDown={(event) => {
if (event.key !== 'Tab') return;
const controls = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('button, input, select, textarea, summary'))
.filter((element) => !element.matches(':disabled') && element.getClientRects().length > 0);
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}}>
<form onSubmit={(event) => { event.preventDefault(); void save(); }}>
<header className="credential-settings-heading">
<div><h2 id={`${id}-title`}><Settings2 size={20} />{t('authFiles.settings.title')}</h2><p>{name}</p></div>
<button type="button" className="icon-button quiet" disabled={saving} onClick={close} aria-label={t('common.close')}><X size={18} /></button>
</header>
<div className="credential-settings-body" aria-busy={loading}>
{loading ? <div className="credential-settings-loading"><LoaderCircle size={20} className="spin" />{t('common.loading')}</div> : draft ? (
<fieldset disabled={saving}>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.routing')}</h3>
<div className="credential-settings-grid">{textField('prefix')}{textField('proxy_url')}{textField('priority')}{textField('weight')}{booleanField('disable_cooling')}{booleanField('websockets')}</div>
</section>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.excluded_models')} <code>excluded_models</code></h3>
<label className="credential-settings-field">
<span>{t('authFiles.settings.rules')}</span>
<textarea rows={3} value={draft.excluded_models} onChange={(event) => update('excluded_models', event.currentTarget.value)} spellCheck={false} placeholder={'gpt-example\nclaude-*'} />
<small>{t('authFiles.settings.excludedHint')}</small>
</label>
<details className="credential-settings-catalog">
<summary>{t('authFiles.settings.catalog', { count: models.length })}</summary>
{catalogLoading ? <p role="status">{t('common.loading')}</p> : null}
{catalogError ? <p className="credential-settings-warning">{t('authFiles.settings.catalogError')}<small>{catalogError}</small></p> : null}
<input aria-label={t('authFiles.settings.searchModels')} placeholder={t('authFiles.settings.searchModels')} value={search} onChange={(event) => setSearch(event.currentTarget.value)} />
<div className="credential-settings-models">
{candidates.map((model) => {
const wildcard = rules.some((rule) => rule.includes('*') && modelMatchesRule(model.id, rule));
return <label key={model.id} title={wildcard ? t('authFiles.settings.wildcard') : model.displayName}>
<input type="checkbox" checked={rules.some((rule) => modelMatchesRule(model.id, rule))} disabled={wildcard}
onChange={(event) => update('excluded_models', setOAuthModelsExcluded(rules, [model], event.currentTarget.checked).join('\n'))} />
<span>{model.id}{wildcard ? <small>{t('authFiles.settings.wildcard')}</small> : null}</span>
</label>;
})}
{!catalogLoading && !candidates.length ? <p>{t('authFiles.settings.noModels')}</p> : null}
</div>
</details>
</section>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.additional')}</h3>
<label className="credential-settings-field">
<span>{t('authFiles.settings.headers')} <code>headers</code></span>
<textarea className="credential-settings-json" rows={5} value={draft.headers} onChange={(event) => update('headers', event.currentTarget.value)} spellCheck={false} autoComplete="off" />
<small>{t('authFiles.settings.headersHint')}</small>
</label>
<label className="credential-settings-field">
<span>{t('authFiles.settings.note')} <code>note</code></span>
<textarea rows={2} value={draft.note} onChange={(event) => update('note', event.currentTarget.value)} />
</label>
</section>
</fieldset>
) : null}
</div>
<footer className="credential-settings-footer">
{error ? <p className="credential-settings-error" role="alert">{error}</p> : null}
{discard ? <div className="credential-settings-discard" role="alert"><span>{t('authFiles.settings.unsaved')}</span><button type="button" className="secondary-button compact-button" onClick={() => setDiscard(false)}>{t('authFiles.settings.keepEditing')}</button><button type="button" className="danger-button compact-button" onClick={onClose}>{t('authFiles.settings.discard')}</button></div> : null}
<div className="credential-settings-actions">
<button type="button" className="secondary-button" disabled={saving} onClick={close}>{t('common.cancel')}</button>
{!loading && !draft ? <button type="button" className="primary-button" onClick={() => setAttempt((value) => value + 1)}>{t('common.refresh')}</button> : <button type="submit" className="primary-button" disabled={!draft || loading || saving || !dirty}>{saving ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{t(saving ? 'common.saving' : 'common.save')}</button>}
</div>
</footer>
</form>
</dialog>
);
return typeof document === 'undefined' ? content : createPortal(content, document.body);
}
+29
View File
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { useI18n } from '../i18n';
import { resetCodexQuotaWithConfirmation } from '../services/quotaActions';
import { fileName, formatQuotaTimestamp, type AuthFile, type QuotaState } from '../services/quotaService';
import type { ConfirmationOptions } from './ConfirmationDialog';
export function useCodexQuotaReset(
askConfirmation: (options: ConfirmationOptions) => Promise<boolean>,
setError: (message: string) => void,
) {
const { locale, t } = useI18n();
return useCallback(async (file: AuthFile, quota: QuotaState) => {
setError('');
try {
await resetCodexQuotaWithConfirmation(file, () => askConfirmation({
title: t('quota.reset'),
message: t('quota.confirm.title', { name: fileName(file) }),
confirmText: t('quota.confirm.button'),
details: [
{ label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') },
{ label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) },
],
warning: t('quota.confirm.warning'),
}));
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : String(requestError));
}
}, [askConfirmation, locale, setError, t]);
}
+90
View File
@@ -3,6 +3,96 @@ import { easyModeJa } from './easyMode';
export const jaOverrides = {
...easyModeJa,
'authFiles.settings.noModels': '該当するモデルはありません。上の欄で除外ルールを入力できます。',
'authFiles.settings.button': "設定",
'authFiles.settings.title': "認証情報の設定",
'authFiles.settings.updated': "{name} の設定を保存しました",
'authFiles.settings.routing': "ルーティングと接続",
'authFiles.settings.additional': "ヘッダーとメモ",
'authFiles.settings.prefix': "プレフィックス",
'authFiles.settings.prefixHint': "モデルのルーティング用。空欄で未設定。",
'authFiles.settings.proxy_url': "プロキシ URL",
'authFiles.settings.proxy_urlHint': "この認証情報専用のプロキシ。空欄で既定の設定を使用。",
'authFiles.settings.priority': "優先度",
'authFiles.settings.priorityHint': "整数。大きいほど優先。空欄は 0。",
'authFiles.settings.weight': "振り分けの重み",
'authFiles.settings.weightHint': "同じ優先度内の相対的な重み。最大 1000000。空欄は既定値 1、0 以下は 0 として加重振り分けから除外。",
'authFiles.settings.disable_cooling': "指数バックオフの無効化",
'authFiles.settings.disable_coolingHint': "有効にすると、この認証情報の指数バックオフを無効化。既定は全体設定に従います。",
'authFiles.settings.websockets': "WebSockets",
'authFiles.settings.websocketsHint': "この認証情報の WebSocket 対応(Codex / xAI)。接続にはバックエンドと上流の対応も必要です。",
'authFiles.settings.inherit': "既定(上書きなし)",
'authFiles.settings.excluded_models': "除外モデル",
'authFiles.settings.rules': "除外ルール",
'authFiles.settings.excludedHint': "1 行に 1 つのモデル ID。* ワイルドカード対応。空欄で個別の除外を解除。全体の除外設定は引き続き適用。",
'authFiles.settings.catalog': "モデル一覧から除外を選択({count})",
'authFiles.settings.catalogError': "モデル一覧を読み込めません。ルールは手動で編集できます。",
'authFiles.settings.searchModels': "モデルを検索",
'authFiles.settings.wildcard': "ワイルドカードで除外済み。上のルールを変更してください。",
'authFiles.settings.headers': "カスタムヘッダー",
'authFiles.settings.headersHint': "キーと値が文字列の JSON オブジェクト。キーの削除、または {} でヘッダーを解除。",
'authFiles.settings.note': "メモ",
'authFiles.settings.unsaved': "未保存の変更を破棄しますか?",
'authFiles.settings.keepEditing': "編集を続ける",
'authFiles.settings.discard': "変更を破棄",
'authFiles.settings.invalid.metadata': "設定を読み込めません。有効な JSON オブジェクトではありません。",
'authFiles.settings.invalid.headers': "ヘッダーは JSON オブジェクトで、有効かつ重複しない名前と改行のない文字列値が必要です。",
'authFiles.settings.invalid.priority': "優先度は有効な整数で指定してください。",
'authFiles.settings.invalid.weight': "重みは 1000000 以下の整数で指定してください。",
'authFiles.settings.runtime': "実行時の認証情報は設定を保存できません。",
'authFiles.settings.quotaRemaining': "残りのクォータ",
'authFiles.settings.quotaUnknown': "残量不明",
'authFiles.settings.quotaIdle': "クォータ未取得",
'authFiles.health.title': '認証情報の状態',
'authFiles.health.active': '利用可能',
'authFiles.health.error': 'リクエストエラー',
'authFiles.health.refreshing': '認証情報を更新中',
'authFiles.health.pending': '認証待ち',
'authFiles.health.warning': '状態に関する通知',
'authFiles.health.unknown': '状態不明',
'authFiles.health.elapsed': 'クールダウン時間が経過',
'authFiles.health.unknownCooldown': '有効な詳細情報がコアから提供されていないため、クールダウン状態は不明です。',
'authFiles.health.missingCooldown': 'コアからクールダウンの詳細が提供されていないため、復旧時刻は不明です。',
'authFiles.health.credential': '認証情報全体のクールダウン',
'authFiles.health.models': '{count} 個のモデルがクールダウン中',
'authFiles.health.credentialModels': '認証情報および {count} 個のモデルがクールダウン中',
'authFiles.health.earliest': '最短の残り時間:約 {time}',
'authFiles.health.seconds': '{count} 秒',
'authFiles.health.minutes': '{count} 分',
'authFiles.health.hours': '{count} 時間',
'authFiles.health.expiredHint': '一覧を更新して最新の状態を確認してください。時間の経過だけでは利用可能になったとは限りません。',
'authFiles.health.details': '詳細',
'authFiles.health.coreStatus': 'コアが報告した状態:{status}',
'authFiles.health.coreMessage': 'コアの状態メッセージ',
'authFiles.health.credentialScope': '認証情報全体',
'authFiles.health.remaining': '残り {time}',
'authFiles.health.waiting': '時間経過 · 要確認',
'authFiles.health.backoff': 'バックオフレベル {level}',
'authFiles.health.backoffHint': 'バックオフレベルは再試行の間隔を制御する値で、失敗回数ではありません。',
'authFiles.health.deadline': 'クールダウン終了予定',
'authFiles.health.observed': '状態の取得時刻',
'authFiles.health.note': '残り時間はコアのスナップショットに基づく推定です。時間の経過はクォータや利用可否の回復を保証せず、テストリクエストも自動送信しません。',
'authFiles.health.reason.quota': 'クォータまたはレート制限',
'authFiles.health.reason.credentialQuota': '認証情報のクォータ制限',
'authFiles.health.reason.cloudflare': 'Cloudflare 認証チャレンジ',
'authFiles.health.reason.invalidGrant': '認可が無効',
'authFiles.health.reason.unauthorized': '認証失敗',
'authFiles.health.reason.accessDenied': 'アクセス制限(402/403)',
'authFiles.health.reason.notFound': 'リソースが見つかりません',
'authFiles.health.reason.modelUnsupported': 'モデル非対応',
'authFiles.health.reason.upstream': '上流サービスの一時的なエラー',
'authFiles.health.reason.tokenExpired': 'アクセストークンの有効期限切れ',
'authFiles.health.reason.unknown': 'その他または不明な制限',
'authFiles.requests.title': 'リクエスト履歴',
'authFiles.requests.success': '成功 {count}',
'authFiles.requests.failure': '失敗 {count}',
'authFiles.requests.window': '直近200分 · 1ブロック10分',
'authFiles.requests.rate': '成功率 {rate}',
'authFiles.requests.recentRate': '直近200分のリクエスト成功率',
'authFiles.requests.empty': 'リクエストなし',
'authFiles.requests.unavailable': 'コアから最近のリクエスト統計が提供されていません',
'authFiles.requests.interval': '時間帯 {index}',
'authFiles.requests.totalsHint': 'コアの稼働中の累計回数です。再起動でリセットされます。一覧の更新で統計を更新できます。',
'apiAccess.provider.codex': 'Codex API',
'apiAccess.provider.deepseek': 'DeepSeek',
'apiAccess.provider.claude': 'Claude',
+90
View File
@@ -3,6 +3,96 @@ import { easyModeEn } from '../easyMode';
export const en: Record<MessageKey, string> = {
...easyModeEn,
'authFiles.settings.noModels': 'No matching models. Enter exclusion rules above.',
'authFiles.settings.button': "Settings",
'authFiles.settings.title': "Credential settings",
'authFiles.settings.updated': "Settings saved for {name}",
'authFiles.settings.routing': "Routing & connection",
'authFiles.settings.additional': "Headers & note",
'authFiles.settings.prefix': "Prefix",
'authFiles.settings.prefixHint': "Model routing prefix. Leave empty for no prefix.",
'authFiles.settings.proxy_url': "Proxy URL",
'authFiles.settings.proxy_urlHint': "Proxy for this credential. Leave empty to use the default configuration.",
'authFiles.settings.priority': "Priority",
'authFiles.settings.priorityHint': "Integer; higher values take precedence. Empty means 0.",
'authFiles.settings.weight': "Scheduling weight",
'authFiles.settings.weightHint': "Relative weight within a priority level, up to 1000000. Empty defaults to 1; nonpositive values become 0 and are excluded from weighted scheduling.",
'authFiles.settings.disable_cooling': "Disable exponential cooling",
'authFiles.settings.disable_coolingHint': "Enable to disable exponential cooling for this credential. Default follows global configuration.",
'authFiles.settings.websockets': "WebSockets",
'authFiles.settings.websocketsHint': "WebSocket support for this credential (Codex / xAI). Connections also depend on backend and upstream support.",
'authFiles.settings.inherit': "Default (no override)",
'authFiles.settings.excluded_models': "Excluded models",
'authFiles.settings.rules': "Exclusion rules",
'authFiles.settings.excludedHint': "One model ID per line; * wildcards are supported. Clearing removes credential exclusions; global exclusions still apply.",
'authFiles.settings.catalog': "Choose exclusions from models ({count})",
'authFiles.settings.catalogError': "Could not load models. Rules can still be edited manually.",
'authFiles.settings.searchModels': "Search models",
'authFiles.settings.wildcard': "Excluded by a wildcard; edit the rule above.",
'authFiles.settings.headers': "Custom headers",
'authFiles.settings.headersHint': "Enter a JSON object with string keys and values. Remove keys or use {} to clear headers.",
'authFiles.settings.note': "Note",
'authFiles.settings.unsaved': "Discard unsaved changes?",
'authFiles.settings.keepEditing': "Keep editing",
'authFiles.settings.discard': "Discard changes",
'authFiles.settings.invalid.metadata': "Cannot read settings: the credential is not a valid JSON object.",
'authFiles.settings.invalid.headers': "Headers must be a JSON object with valid, unique header names and string values without line breaks.",
'authFiles.settings.invalid.priority': "Priority must be a valid integer.",
'authFiles.settings.invalid.weight': "Scheduling weight must be an integer no greater than 1000000.",
'authFiles.settings.runtime': "Runtime credentials cannot persist settings.",
'authFiles.settings.quotaRemaining': "Remaining quota",
'authFiles.settings.quotaUnknown': "Remaining quota unknown",
'authFiles.settings.quotaIdle': "Quota not fetched",
'authFiles.health.title': 'Credential status',
'authFiles.health.active': 'Available',
'authFiles.health.error': 'Request error',
'authFiles.health.refreshing': 'Refreshing credentials',
'authFiles.health.pending': 'Awaiting verification',
'authFiles.health.warning': 'Status notice',
'authFiles.health.unknown': 'Status unknown',
'authFiles.health.elapsed': 'Cooldown timer elapsed',
'authFiles.health.unknownCooldown': 'Cooldown state is unknown; the core did not provide valid details.',
'authFiles.health.missingCooldown': 'This core did not provide cooldown details; recovery time is unknown.',
'authFiles.health.credential': 'Credential-wide cooldown',
'authFiles.health.models': '{count} models cooling down',
'authFiles.health.credentialModels': 'Credential and {count} models cooling down',
'authFiles.health.earliest': 'Earliest cooldown ends in ~{time}',
'authFiles.health.seconds': '{count}s',
'authFiles.health.minutes': '{count} min',
'authFiles.health.hours': '{count} hr',
'authFiles.health.expiredHint': 'Refresh the list to confirm the latest state; an elapsed timer does not confirm availability.',
'authFiles.health.details': 'Details',
'authFiles.health.coreStatus': 'Core-reported status: {status}',
'authFiles.health.coreMessage': 'Core status message',
'authFiles.health.credentialScope': 'Entire credential',
'authFiles.health.remaining': '{time} remaining',
'authFiles.health.waiting': 'Elapsed · confirmation needed',
'authFiles.health.backoff': 'Backoff level {level}',
'authFiles.health.backoffHint': 'Backoff level controls retry delays; it is not a failure count.',
'authFiles.health.deadline': 'Cooldown deadline',
'authFiles.health.observed': 'State observed at',
'authFiles.health.note': 'Remaining time is estimated from the core snapshot. Expiry does not confirm restored quota or availability, and no test request is sent automatically.',
'authFiles.health.reason.quota': 'Quota or rate limit',
'authFiles.health.reason.credentialQuota': 'Credential quota limit',
'authFiles.health.reason.cloudflare': 'Cloudflare challenge',
'authFiles.health.reason.invalidGrant': 'Authorization grant invalid',
'authFiles.health.reason.unauthorized': 'Authentication failed',
'authFiles.health.reason.accessDenied': 'Access restricted (402/403)',
'authFiles.health.reason.notFound': 'Resource not found',
'authFiles.health.reason.modelUnsupported': 'Model not supported',
'authFiles.health.reason.upstream': 'Temporary upstream error',
'authFiles.health.reason.tokenExpired': 'Access token expired',
'authFiles.health.reason.unknown': 'Other or unknown restriction',
'authFiles.requests.title': 'Requests',
'authFiles.requests.success': 'Success {count}',
'authFiles.requests.failure': 'Failed {count}',
'authFiles.requests.window': 'Last 200 min · 10 min per block',
'authFiles.requests.rate': 'Success rate {rate}',
'authFiles.requests.recentRate': 'Request success rate over the last 200 minutes',
'authFiles.requests.empty': 'No requests',
'authFiles.requests.unavailable': 'The core did not provide recent request statistics',
'authFiles.requests.interval': 'Interval {index}',
'authFiles.requests.totalsHint': 'Totals since the core started; reset on restart. Refresh the list to update statistics.',
'apiAccess.provider.codex': 'Codex API',
'apiAccess.provider.deepseek': 'DeepSeek',
'apiAccess.provider.claude': 'Claude',
+90
View File
@@ -2,6 +2,96 @@ import { easyModeZhCN } from '../easyMode';
export const zhCN = {
...easyModeZhCN,
'authFiles.settings.noModels': '没有匹配的候选模型,可在上方手动填写排除规则。',
'authFiles.settings.button': "设置",
'authFiles.settings.title': "凭证设置",
'authFiles.settings.updated': "已保存 {name} 的设置",
'authFiles.settings.routing': "路由与连接",
'authFiles.settings.additional': "请求头与备注",
'authFiles.settings.prefix': "前缀",
'authFiles.settings.prefixHint': "用于模型路由的前缀,留空不设置。",
'authFiles.settings.proxy_url': "代理 URL",
'authFiles.settings.proxy_urlHint': "此凭证专用的代理地址,留空使用默认配置。",
'authFiles.settings.priority': "优先级",
'authFiles.settings.priorityHint': "整数,数值越大越优先;留空为 0。",
'authFiles.settings.weight': "调度权重",
'authFiles.settings.weightHint': "同优先级下的相对权重,最大 1000000;留空默认 1,非正数按 0 处理,不参与加权调度。",
'authFiles.settings.disable_cooling': "禁用指数冷却",
'authFiles.settings.disable_coolingHint': "启用后关闭此凭证的指数冷却;默认跟随全局配置。",
'authFiles.settings.websockets': "WebSockets",
'authFiles.settings.websocketsHint': "控制此凭证的 WebSocket 支持,适用于 Codex / xAI,实际连接仍取决于内核及上游支持。",
'authFiles.settings.inherit': "默认(不覆盖)",
'authFiles.settings.excluded_models': "排除模型",
'authFiles.settings.rules': "排除规则",
'authFiles.settings.excludedHint': "每行一个模型 ID,支持 * 通配符。清空即移除此凭证的排除规则,全局排除规则仍然生效。",
'authFiles.settings.catalog': "从模型列表选择排除项({count})",
'authFiles.settings.catalogError': "模型列表读取失败,仍可手动编辑规则。",
'authFiles.settings.searchModels': "搜索模型",
'authFiles.settings.wildcard': "已被通配符规则排除,请修改上方规则。",
'authFiles.settings.headers': "自定义请求头",
'authFiles.settings.headersHint': "填写 JSON 对象,键和值均为字符串。删除键或填写 {} 可清空对应请求头。",
'authFiles.settings.note': "备注",
'authFiles.settings.unsaved': "有尚未保存的修改,是否放弃?",
'authFiles.settings.keepEditing': "继续编辑",
'authFiles.settings.discard': "放弃修改",
'authFiles.settings.invalid.metadata': "无法读取凭证设置:文件内容不是有效的 JSON 对象。",
'authFiles.settings.invalid.headers': "请求头必须是 JSON 对象,键和值为字符串,名称不能重复,值不能包含换行。",
'authFiles.settings.invalid.priority': "优先级必须是有效整数。",
'authFiles.settings.invalid.weight': "调度权重必须是整数且不能超过 1000000。",
'authFiles.settings.runtime': "运行时凭证不支持保存设置。",
'authFiles.settings.quotaRemaining': "剩余额度",
'authFiles.settings.quotaUnknown': "剩余额度未知",
'authFiles.settings.quotaIdle': "尚未获取额度",
'authFiles.health.title': '凭证状态',
'authFiles.health.active': '正常可用',
'authFiles.health.error': '请求异常',
'authFiles.health.refreshing': '凭证刷新中',
'authFiles.health.pending': '等待验证',
'authFiles.health.warning': '状态提示',
'authFiles.health.unknown': '状态未知',
'authFiles.health.elapsed': '冷却计时已到期',
'authFiles.health.unknownCooldown': '冷却状态未知,内核当前未提供有效详情。',
'authFiles.health.missingCooldown': '当前内核未提供冷却详情,无法确定恢复时间。',
'authFiles.health.credential': '凭证整体冷却',
'authFiles.health.models': '{count} 个模型冷却',
'authFiles.health.credentialModels': '凭证及 {count} 个模型冷却',
'authFiles.health.earliest': '最早结束约 {time}',
'authFiles.health.seconds': '{count} 秒',
'authFiles.health.minutes': '{count} 分钟',
'authFiles.health.hours': '{count} 小时',
'authFiles.health.expiredHint': '刷新列表确认最新状态;计时结束不代表已恢复可用。',
'authFiles.health.details': '详情',
'authFiles.health.coreStatus': '内核报告状态:{status}',
'authFiles.health.coreMessage': '内核状态说明',
'authFiles.health.credentialScope': '整个凭证',
'authFiles.health.remaining': '剩余 {time}',
'authFiles.health.waiting': '已到期 · 待确认',
'authFiles.health.backoff': '退避等级 {level}',
'authFiles.health.backoffHint': '退避等级用于延长重试间隔,不等于失败次数。',
'authFiles.health.deadline': '冷却截止',
'authFiles.health.observed': '状态采集于',
'authFiles.health.note': '剩余时间基于内核快照估算。到期不代表额度恢复或凭证可用,也不会自动发起测试请求。',
'authFiles.health.reason.quota': '额度或速率限制',
'authFiles.health.reason.credentialQuota': '凭证额度限制',
'authFiles.health.reason.cloudflare': 'Cloudflare 验证拦截',
'authFiles.health.reason.invalidGrant': '授权已失效',
'authFiles.health.reason.unauthorized': '身份认证失败',
'authFiles.health.reason.accessDenied': '访问受限(402/403)',
'authFiles.health.reason.notFound': '资源未找到',
'authFiles.health.reason.modelUnsupported': '模型不受支持',
'authFiles.health.reason.upstream': '上游服务暂时异常',
'authFiles.health.reason.tokenExpired': '访问令牌已过期',
'authFiles.health.reason.unknown': '其他或未知限制',
'authFiles.requests.title': '请求记录',
'authFiles.requests.success': '成功 {count}',
'authFiles.requests.failure': '失败 {count}',
'authFiles.requests.window': '最近 200 分钟 · 每格 10 分钟',
'authFiles.requests.rate': '成功率 {rate}',
'authFiles.requests.recentRate': '最近 200 分钟的请求成功率',
'authFiles.requests.empty': '暂无请求',
'authFiles.requests.unavailable': '内核未提供近期请求统计',
'authFiles.requests.interval': '时段 {index}',
'authFiles.requests.totalsHint': '内核运行期间的累计次数,重启后重置;刷新列表可更新统计。',
'apiAccess.provider.codex': 'Codex API',
'apiAccess.provider.deepseek': 'DeepSeek',
'apiAccess.provider.claude': 'Claude',
+57
View File
@@ -0,0 +1,57 @@
.auth-file-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 14px; padding: 16px; }
.auth-file-card { display: flex; flex-direction: column; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--border-color); border-radius: 14px; background: var(--bg-card); transition: border-color .15s; }
.auth-file-card:hover { border-color: color-mix(in srgb, var(--primary-color) 35%, var(--border-color)); }
.auth-file-card.is-disabled { border-style: dashed; }
.auth-card-header { display: flex; gap: 10px; align-items: center; min-width: 0; }
.auth-card-header .provider-logo { width: 32px; height: 32px; object-fit: contain; flex-shrink: 0; }
.auth-card-identity { min-width: 0; flex: 1; display: grid; gap: 4px; }
.auth-card-identity strong { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-card-header.filename-only strong { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; white-space: normal; overflow-wrap: anywhere; }
.auth-card-provider { font-size: 11px; color: var(--text-tertiary); letter-spacing: .02em; }
.auth-card-filename { margin: -3px 0 0; color: var(--text-secondary); overflow-wrap: anywhere; font-family: ui-monospace, monospace; font-size: 11px; line-height: 1.6; }
.auth-card-note { display: flex; align-items: baseline; gap: 8px; min-width: 0; padding: 8px 10px; border-radius: 7px; background: color-mix(in srgb, var(--text-tertiary) 5%, var(--bg-card)); font-size: 12px; line-height: 1.6; }
.auth-card-note > span { flex-shrink: 0; color: var(--text-tertiary); font-size: 11px; }
.auth-card-note p { min-width: 0; margin: 0; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.auth-card-meta { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 5px 12px; margin-top: auto; color: var(--text-tertiary); font-size: 11px; }
.auth-card-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding-top: 12px; border-top: 1px solid var(--border-color); }
.auth-card-actions .compact-button { min-height: 30px; padding: 5px 9px; font-size: 12px; }
.auth-card-actions .icon-button { width: 30px; height: 30px; }
.auth-card-actions .auth-card-toggle { margin-left: auto; }
.auth-file-card .auth-file-health, .auth-file-card .auth-file-requests { grid-column: auto; }
.auth-file-card .auth-file-requests { padding-top: 12px; border-top: 1px solid var(--border-color); }
.auth-file-card .auth-file-requests-chart { max-width: none; }
.auth-file-card .auth-health-summary { gap: 6px; }
.auth-file-card .auth-health-heading { gap: 2px 8px; }
.auth-file-card .auth-health-countdown { margin-left: 25px; }
.auth-file-card .auth-health-summary-content { flex-basis: calc(100% - 28px); }
.auth-file-card .auth-health-disclosure { margin-left: auto; }
.auth-file-card .auth-health-body, .auth-file-card .auth-health-summary { overflow-wrap: anywhere; }
.credential-quota { display: grid; gap: 12px; padding: 11px 12px; border: 1px solid var(--border-color); border-radius: 10px; background: color-mix(in srgb, var(--text-tertiary) 3%, var(--bg-card)); font-size: 12px; }
.credential-quota-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
.credential-quota-heading > strong { color: var(--text-secondary); font-size: 11px; font-weight: 500; }
.credential-quota-plan { max-width: 100%; overflow-wrap: anywhere; padding: 2px 6px; border-radius: 4px; font-size: 10px; background: var(--bg-secondary); color: var(--text-secondary); }
.credential-quota-refresh { display: inline-flex; align-items: center; gap: 4px; border: 0; padding: 3px 0 3px 5px; background: transparent; color: var(--primary-color); font: inherit; font-size: 11px; margin-left: auto; cursor: pointer; }
.credential-quota-refresh:disabled { color: var(--text-tertiary); cursor: default; }
.credential-quota-heading .credential-quota-reset { min-height: 26px; padding: 3px 7px; margin-left: auto; font-size: 11px; }
.credential-quota-reset + .credential-quota-refresh { margin-left: 0; }
.credential-quota-rows { display: grid; gap: 14px; }
.credential-quota-row { --quota-color: var(--success-color); display: grid; gap: 5px; min-width: 0; }
.credential-quota-row.medium { --quota-color: var(--amber-text); }
.credential-quota-row.low { --quota-color: var(--failure-badge-text); }
.credential-quota-label { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.credential-quota-label span { min-width: 0; overflow-wrap: anywhere; color: var(--text-secondary); }
.credential-quota-label strong { flex-shrink: 0; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--quota-color); }
.credential-quota-row.unknown strong { color: var(--text-tertiary); }
.credential-quota-track { height: 6px; border-radius: 3px; overflow: hidden; background: var(--border-color); }
.credential-quota-track i { display: block; height: 100%; border-radius: inherit; background: var(--quota-color, var(--primary-color)); transition: width .25s ease; }
.credential-quota-row.unknown .credential-quota-track { background: repeating-linear-gradient(115deg, var(--border-color) 0 4px, transparent 4px 8px); }
.credential-quota small, .credential-quota-empty, .credential-quota-loading { font-size: 11px; line-height: 1.6; color: var(--text-tertiary); overflow-wrap: anywhere; }
.credential-quota-empty { margin: 0; }
.credential-quota-footnotes { display: grid; gap: 3px; }
.credential-quota-footnotes:empty { display: none; }
.credential-quota-error { display: grid; gap: 4px; color: var(--failure-badge-text); }
.credential-quota-loading { display: grid; gap: 8px; }
.credential-quota-track.indeterminate i { width: 35%; animation: credential-quota-pending 1.3s ease-in-out infinite alternate; }
@keyframes credential-quota-pending { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
@media (prefers-reduced-motion: reduce) { .credential-quota-track.indeterminate i { animation: none; width: 100%; opacity: .3; } }
@media (max-width: 600px) { .auth-file-card-grid { padding: 10px; gap: 10px; } .auth-file-card { padding: 14px; } }
+45 -190
View File
@@ -1,8 +1,14 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useConfirmation } from '../components/ConfirmationDialog';
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
import { AuthFileQuotaPanel } from '../components/AuthFileQuotaPanel';
import { AuthFileSettingsDialog } from '../components/AuthFileSettingsDialog';
import { useCodexQuotaReset } from '../components/useCodexQuotaReset';
import './AuthFileManagementPage.css';
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
import { AuthFileRequestStatus } from '../components/AuthFileRequestStatus';
import { AuthFileHealthStatus } from '../components/AuthFileHealthStatus';
import {
Check,
Copy,
@@ -10,7 +16,6 @@ import {
FolderOpen,
Import,
LoaderCircle,
Pencil,
RefreshCw,
Search,
Settings2,
@@ -34,12 +39,10 @@ import {
responseList,
} from '../services/managementApi';
import {
formatQuotaTimestamp,
idleQuota,
loadQuota,
providerForFile as quotaProviderForFile,
quotaKey,
type QuotaState,
} from '../services/quotaService';
import {
captureQuotaCacheGeneration,
@@ -53,7 +56,6 @@ import {
authFileName,
dedupeAuthFiles,
isRuntimeOnlyAuthFile,
normalizeAuthFilePriorityInput,
parseAuthFilePriority,
} from '../services/authFiles';
import {
@@ -70,17 +72,9 @@ import {
type OAuthModelTarget,
} from '../services/oauthModelSettings';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
type AuthFile = Record<string, unknown>;
type PriorityEditor = {
fileName: string;
originalPriority: number;
value: string;
error: string;
};
const providerIcons: Record<string, string> = {
antigravity: antigravityIcon,
claude: claudeIcon,
@@ -111,75 +105,26 @@ const fileName = authFileName;
const isRuntimeOnly = isRuntimeOnlyAuthFile;
const statusText = (file: AuthFile) => {
if (readBoolean(file, 'disabled')) return translate(getCurrentLocale(), 'authFiles.status.disabled');
if (readBoolean(file, 'unavailable')) return translate(getCurrentLocale(), 'authFiles.status.unavailable');
return readString(file, 'status') || translate(getCurrentLocale(), 'authFiles.status.ready');
};
function AuthFileQuotaSummary({ quota, name }: { quota: QuotaState; name: string }) {
const { locale, t } = useI18n();
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
if (quota.status === 'loading') {
return (
<div className="auth-file-quota loading">
<LoaderCircle size={13} className="spin" />
<span>{t('authFiles.quota.loading')}</span>
</div>
);
}
if (quota.status === 'error') {
return (
<div className="auth-file-quota error" title={quota.error}>
<span>{t('authFiles.quota.failed')}</span>
<MessageNotice message={quota.error ? name + ': ' + quota.error : null} />
</div>
);
}
if (quota.status !== 'success') return null;
return (
<div className="auth-file-quota" aria-label={t('authFiles.quota.aria')}>
{quota.plan ? <span className="auth-file-quota-plan">{quota.plan}</span> : null}
{quota.rows.length > 0 ? quota.rows.map((row, index) => {
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
const detail = [row.detail, reset].filter(Boolean).join(' · ');
return (
<span className="auth-file-quota-item" key={`${row.label}-${index}`} title={detail || undefined}>
<span>{row.label}</span>
<strong>{row.remainingPercent === null ? '—' : `${Math.round(row.remainingPercent)}%`}</strong>
{reset ? <small>{reset}</small> : null}
{row.remainingPercent === null && row.detail ? <small>{row.detail}</small> : null}
</span>
);
}) : <span className="auth-file-quota-empty">{t('authFiles.quota.empty')}</span>}
{quota.resetCredits !== undefined ? (
<span className="auth-file-quota-credit">{t('authFiles.quota.resets', { count: quota.resetCredits })}</span>
) : null}
{quota.resetCreditsApplicable !== undefined ? <span className="auth-file-quota-credit">{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</span> : null}
{quota.subscriptionActiveUntil ? <span className="auth-file-quota-credit">{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span> : null}
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
{quota.resetCreditsEarliestExpiry ? (
<span className="auth-file-quota-credit">{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</span>
) : null}
</div>
);
}
export function AuthFileManagementPage() {
const { t } = useI18n();
const { askConfirmation, confirmationDialog } = useConfirmation();
const [files, setFiles] = useState<AuthFile[]>([]);
const [fileSnapshot, setFileSnapshot] = useState<{
files: AuthFile[];
receivedAtMs: number;
observedAt?: string;
}>({ files: [], receivedAtMs: 0 });
const { files, receivedAtMs, observedAt } = fileSnapshot;
const [filter, setFilter] = useState('');
const [providerFilter, setProviderFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled' | 'runtime'>('all');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
const feedback = useAppNotice();
const { showNotice } = feedback;
const [copied, setCopied] = useState('');
const [priorityEditor, setPriorityEditor] = useState<PriorityEditor | null>(null);
const [settingsName, setSettingsName] = useState<string | null>(null);
const [oauthModelTarget, setOauthModelTarget] = useState<OAuthModelTarget | null>(null);
const [oauthModelSettings, setOauthModelSettings] = useState<OAuthModelSettings | null>(null);
const [oauthExcludedRulesText, setOauthExcludedRulesText] = useState('');
@@ -208,7 +153,7 @@ export function AuthFileManagementPage() {
try {
const payload = await managementApi.get('/auth-files');
const nextFiles = dedupeAuthFiles(responseList(payload, 'files'));
setFiles(nextFiles);
setFileSnapshot({ files: nextFiles, receivedAtMs: Date.now(), observedAt: readString(payload, 'observed_at') });
const validQuotaKeys = new Set(nextFiles.map(quotaKey));
pruneQuotaCache(validQuotaKeys);
updateQuotaCache((current) => {
@@ -267,13 +212,6 @@ export function AuthFileManagementPage() {
}
};
const openOauthModels = (file: AuthFile) => {
const name = readString(file, 'name');
const provider = providerKey(file);
if (!name || !provider || isRuntimeOnly(file)) return;
return openOauthModelSettings({ scope: 'credential', name, provider, label: providerName(file) });
};
const saveOauthModels = async () => {
if (!oauthModelSettings || oauthModelLoading || oauthModelSaveRef.current) return;
const settings = oauthModelSettings;
@@ -385,51 +323,6 @@ export function AuthFileManagementPage() {
}
};
const openPriorityEditor = (file: AuthFile) => {
const priority = parseAuthFilePriority(file.priority);
setPriorityEditor({
fileName: fileName(file),
originalPriority: priority ?? 0,
value: priority === undefined || priority === 0 ? '' : String(priority),
error: '',
});
};
const closePriorityEditor = () => {
if (!busy) setPriorityEditor(null);
};
const savePriority = async () => {
if (!priorityEditor || busy) return;
const priority = normalizeAuthFilePriorityInput(priorityEditor.value);
if (priority === null) {
setPriorityEditor((current) => current
? { ...current, error: t('authFiles.priority.invalid') }
: current);
return;
}
if (priority === priorityEditor.originalPriority) {
setPriorityEditor(null);
return;
}
const name = priorityEditor.fileName;
setBusy(true);
setError('');
try {
await managementApi.patch('/auth-files/fields', { name, priority });
setPriorityEditor(null);
showNotice({ key: 'authFiles.priority.updated', variables: { name } });
await loadFiles();
} catch (requestError) {
setPriorityEditor((current) => current
? { ...current, error: String(requestError) }
: current);
} finally {
setBusy(false);
}
};
const deleteFile = async (file: AuthFile) => {
const name = fileName(file);
if (isRuntimeOnly(file)) {
@@ -527,35 +420,39 @@ export function AuthFileManagementPage() {
) : visibleFiles.length === 0 ? (
<div className="management-empty"><FileDown size={24} /><strong>{files.length ? t('authFiles.empty.filtered') : t('authFiles.empty.none')}</strong><span>{files.length ? t('authFiles.empty.tryFilter') : t('authFiles.empty.upload')}</span></div>
) : (
<div className="real-auth-file-list">
<div className="auth-file-card-grid">
{visibleFiles.map((file) => {
const name = fileName(file);
const icon = providerIcons[providerKey(file)] ?? geminiIcon;
const disabled = readBoolean(file, 'disabled');
const priority = parseAuthFilePriority(file.priority) ?? 0;
const identity = readString(file, 'email', 'project_id', 'label');
const note = readString(file, 'note');
const quota = quotas[quotaKey(file)] ?? idleQuota();
return (
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<img src={icon} alt="" className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<div className="auth-file-main">
<div className="auth-file-title"><strong title={name}>{name}</strong><span className={`state-pill ${disabled ? 'error' : readBoolean(file, 'unavailable') ? 'error' : 'success'}`} title={readString(file, 'status_message', 'statusMessage') || undefined}>{statusText(file)}</span></div>
<span>{providerName(file)}{readString(file, 'email', 'account', 'label') ? ` · ${readString(file, 'email', 'account', 'label')}` : ''}</span>
</div>
<div className="auth-file-meta">
<span>{readNumber(file, 'size') === null ? t('authFiles.unknownSize') : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
<span>{formatDate(file.modtime ?? file.updated_at ?? file.last_refresh)}</span>
<article className={`auth-file-card ${disabled ? 'is-disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<header className={`auth-card-header ${identity ? '' : 'filename-only'}`}>
<img src={icon} alt="" className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<div className="auth-card-identity"><span className="auth-card-provider">{providerName(file)}</span><strong title={identity || name}>{identity || name}</strong></div>
{isRuntimeOnly(file) ? <span className="state-pill">{t('authFiles.runtime')}</span> : null}
</header>
{identity ? <p className="auth-card-filename">{name}</p> : null}
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} observedAt={observedAt} />
<AuthFileRequestStatus file={file} />
{quotaProviderForFile(file) ? <AuthFileQuotaPanel quota={quota} file={file} disabled={busy || disabled} onRefresh={() => void refreshQuota(file)} onReset={quotaProviderForFile(file) === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} /> : null}
<QuotaActionFeedback quota={quota} name={name} />
<div className="auth-card-meta">
<span>{t('authFiles.priority.button', { priority })} · {readNumber(file, 'size') === null ? t('authFiles.unknownSize') : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
<span>{formatDate(file.modtime ?? file.updated_at ?? file.last_refresh)}</span>
</div>
<div className="auth-file-actions">
{quotaProviderForFile(file) && !disabled ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || quotas[quotaKey(file)]?.status === 'loading'}>{quotas[quotaKey(file)]?.status === 'loading' ? t('authFiles.quota.querying') : quotas[quotaKey(file)]?.status === 'success' ? t('authFiles.quota.refresh') : t('authFiles.quota.fetch')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy || disabled} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy || disabled || oauthModelSaving || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.models.runtimeUnsupported' : 'authFiles.models.settings')}>{t('authFiles.models.excludeButton')}</button> : null}
<button type="button" className="secondary-button compact-button auth-file-priority-button" onClick={() => openPriorityEditor(file)} disabled={busy || disabled} title={t('authFiles.priority.hint')}><Pencil size={14} />{t('authFiles.priority.button', { priority })}</button>
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy || disabled} title={t('authFiles.copyName')}>{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button`} onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={16} /></button>
</div>
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? <AuthFileQuotaSummary quota={quotas[quotaKey(file)] ?? idleQuota()} name={name} /> : null}
<QuotaActionFeedback quota={quotas[quotaKey(file)] ?? idleQuota()} name={name} />
{note ? <div className="auth-card-note"><span>{t('authFiles.settings.note')}</span><p title={note}>{note}</p></div> : null}
<footer className="auth-card-actions">
<button type="button" className="secondary-button compact-button" onClick={() => setSettingsName(name)} disabled={busy || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.settings.runtime' : 'authFiles.settings.title')}><Settings2 size={14} />{t('authFiles.settings.button')}</button>
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')}>{copied === name ? <Check size={15} /> : <Copy size={15} />}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={15} /></button>
</footer>
</article>
);
})}
@@ -564,53 +461,11 @@ export function AuthFileManagementPage() {
</section>
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
{priorityEditor ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closePriorityEditor()}>
<form
className="config-dialog auth-priority-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="auth-priority-title"
onSubmit={(event) => {
event.preventDefault();
void savePriority();
}}
>
<div className="config-dialog-heading">
<div><Pencil size={19} /><h2 id="auth-priority-title">{t('authFiles.priority.title')}</h2></div>
<button type="button" className="icon-button quiet" onClick={closePriorityEditor} disabled={busy} title={t('common.close')}><X size={18} /></button>
</div>
<div className="auth-priority-target" title={priorityEditor.fileName}>{priorityEditor.fileName}</div>
<label className="auth-priority-field" htmlFor="auth-file-priority-input">
<span>{t('authFiles.priority.label')}</span>
<input
id="auth-file-priority-input"
autoFocus
type="text"
inputMode="numeric"
value={priorityEditor.value}
placeholder={t('authFiles.priority.placeholder')}
disabled={busy}
aria-invalid={Boolean(priorityEditor.error)}
onChange={(event) => {
const value = event.currentTarget.value;
setPriorityEditor((current) => current
? { ...current, value, error: '' }
: current);
}}
/>
<small>{t('authFiles.priority.hint')}</small>
</label>
<div className={`config-form-message ${priorityEditor.error ? 'error' : ''}`} role={priorityEditor.error ? 'alert' : undefined}>
{priorityEditor.error || ' '}
</div>
<div className="config-dialog-actions two-actions">
<button type="button" className="secondary-button" onClick={closePriorityEditor} disabled={busy}>{t('common.cancel')}</button>
<button type="submit" className="primary-button" disabled={busy}>{busy ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{busy ? t('common.saving') : t('common.save')}</button>
</div>
</form>
</div>
) : null}
{settingsName ? <AuthFileSettingsDialog key={settingsName} name={settingsName} onClose={() => setSettingsName(null)} onSaved={() => {
showNotice({ key: 'authFiles.settings.updated', variables: { name: settingsName } });
setSettingsName(null);
void loadFiles(false);
}} /> : null}
{modelViewName ? <AuthFileModelsDialog name={modelViewName} onClose={() => setModelViewName(null)} /> : null}
+4 -19
View File
@@ -3,7 +3,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react';
import { useConfirmation } from '../components/ConfirmationDialog';
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
import { canResetCodexQuota, resetCodexQuotaWithConfirmation } from '../services/quotaActions';
import { canResetCodexQuota } from '../services/quotaActions';
import { useCodexQuotaReset } from '../components/useCodexQuotaReset';
import antigravityIcon from '../assets/icons/antigravity.svg';
import claudeIcon from '../assets/icons/claude.svg';
import codexIcon from '../assets/icons/codex.svg';
@@ -47,7 +48,7 @@ const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai',
const REFRESH_CONCURRENCY = 4;
export function QuotaPage() {
const { locale, t } = useI18n();
const { t } = useI18n();
const { askConfirmation, confirmationDialog } = useConfirmation();
const [files, setFiles] = useState<AuthFile[]>([]);
const quotas = useQuotaCache();
@@ -96,23 +97,7 @@ export function QuotaPage() {
});
}, [t]);
const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => {
setError('');
try {
await resetCodexQuotaWithConfirmation(file, () => askConfirmation({
title: t('quota.reset'),
message: t('quota.confirm.title', { name: fileName(file) }),
confirmText: t('quota.confirm.button'),
details: [
{ label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') },
{ label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) },
],
warning: t('quota.confirm.warning'),
}));
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : String(requestError));
}
}, [askConfirmation, locale, t]);
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
const refreshAll = useCallback(async () => {
if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return;
+138
View File
@@ -0,0 +1,138 @@
import type { MessageKey } from '../i18n/resources';
import { isRecord, readBoolean, readString } from './managementApi';
export type AuthFileCooldown = {
scope: 'credential' | 'model';
model?: string;
reason: string;
retryAt: string;
remainingSeconds: number;
httpStatus?: number;
backoffLevel?: number;
};
export type AuthFileCooldownSnapshot = {
receivedAtMs: number;
observedAt?: string;
records: AuthFileCooldown[] | null;
};
export type AuthFileHealth = {
label: MessageKey;
tone: 'success' | 'warning' | 'error' | 'neutral' | 'info';
message: string;
status: string;
disabled: boolean;
};
const reasonKeys: Record<string, MessageKey> = {
quota: 'authFiles.health.reason.quota',
credential_quota: 'authFiles.health.reason.credentialQuota',
cloudflare_challenge: 'authFiles.health.reason.cloudflare',
invalid_grant: 'authFiles.health.reason.invalidGrant',
unauthorized: 'authFiles.health.reason.unauthorized',
payment_required: 'authFiles.health.reason.accessDenied',
not_found: 'authFiles.health.reason.notFound',
model_not_supported: 'authFiles.health.reason.modelUnsupported',
transient_error: 'authFiles.health.reason.upstream',
token_expired: 'authFiles.health.reason.tokenExpired',
};
export function cooldownReasonKey(reason: string): MessageKey {
return Object.prototype.hasOwnProperty.call(reasonKeys, reason)
? reasonKeys[reason] : 'authFiles.health.reason.unknown';
}
export function cooldownTimestamp(value: unknown): string | undefined {
if (typeof value !== 'string' || !value.trim()) return undefined;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) && timestamp > 0 ? new Date(timestamp).toISOString() : undefined;
}
function normalizeCooldown(value: unknown): AuthFileCooldown | null {
if (!isRecord(value)) return null;
const scope = value.scope;
const model = typeof value.model_key === 'string' ? value.model_key.trim() : '';
const retryAt = cooldownTimestamp(value.retry_at);
const remainingSeconds = value.remaining_seconds;
if ((scope !== 'credential' && scope !== 'model') || (scope === 'model' && !model)
|| !retryAt || typeof remainingSeconds !== 'number'
|| !Number.isSafeInteger(remainingSeconds) || remainingSeconds <= 0
|| remainingSeconds > Number.MAX_SAFE_INTEGER / 1000) return null;
const httpStatus = value.http_status;
const backoffLevel = value.backoff_level;
return {
scope,
...(scope === 'model' ? { model } : {}),
retryAt,
remainingSeconds,
reason: readString(value, 'reason') || 'unknown',
...(typeof httpStatus === 'number' && Number.isInteger(httpStatus)
&& httpStatus >= 400 && httpStatus <= 599 ? { httpStatus } : {}),
...(typeof backoffLevel === 'number' && Number.isSafeInteger(backoffLevel)
&& backoffLevel >= 0 ? { backoffLevel } : {}),
};
}
export function normalizeAuthFileCooldowns(
value: unknown,
receivedAtMs: number,
observedAt?: string,
): AuthFileCooldownSnapshot | undefined {
if (value === undefined) return undefined;
const snapshot = { receivedAtMs, observedAt: cooldownTimestamp(observedAt) };
if (!Array.isArray(value)) return { ...snapshot, records: null };
const records = value.map(normalizeCooldown);
if (records.some((record) => record === null)) return { ...snapshot, records: null };
return { ...snapshot, records: records as AuthFileCooldown[] };
}
export function summarizeAuthFileCooldowns(snapshot: AuthFileCooldownSnapshot | undefined, nowMs: number) {
const elapsedSeconds = snapshot ? Math.max(0, nowMs - snapshot.receivedAtMs) / 1000 : 0;
const rows = (snapshot?.records ?? []).map((record) => ({
record,
remainingSeconds: Math.max(0, Math.ceil(record.remainingSeconds - elapsedSeconds)),
}));
const active = rows.filter((row) => row.remainingSeconds > 0);
return {
rows,
active,
modelCount: new Set(active.filter(({ record }) => record.scope === 'model').map(({ record }) => record.model)).size,
credentialWide: active.some(({ record }) => record.scope === 'credential'),
earliestSeconds: active.length ? Math.min(...active.map((row) => row.remainingSeconds)) : 0,
elapsed: rows.length > 0 && active.length === 0,
};
}
const healthyMessages = new Set(['ok', 'healthy', 'ready', 'success', 'available', 'active']);
const messageReasons: Record<string, string> = {
'quota exhausted': 'quota',
'cloudflare challenge': 'cloudflare_challenge',
'token expired': 'token_expired',
'transient upstream error': 'transient_error',
};
export function authFileHealth(file: Record<string, unknown>): AuthFileHealth {
const status = readString(file, 'status').toLowerCase();
const rawMessage = readString(file, 'status_message', 'statusMessage');
const message = healthyMessages.has(rawMessage.toLowerCase()) ? '' : rawMessage;
const disabled = readBoolean(file, 'disabled') || status === 'disabled';
const base = { status, message, disabled };
if (disabled) return { ...base, label: 'authFiles.status.disabled', tone: 'neutral' };
if (readBoolean(file, 'unavailable') || status === 'error') {
const marker = message.toLowerCase();
const reason = Object.prototype.hasOwnProperty.call(messageReasons, marker) ? messageReasons[marker] : marker;
const reasonKey = cooldownReasonKey(reason);
return {
...base,
label: reasonKey !== 'authFiles.health.reason.unknown' ? reasonKey
: readBoolean(file, 'unavailable') ? 'authFiles.status.unavailable' : 'authFiles.health.error',
tone: 'error',
};
}
if (status === 'refreshing') return { ...base, label: 'authFiles.health.refreshing', tone: 'info' };
if (status === 'pending') return { ...base, label: 'authFiles.health.pending', tone: 'warning' };
if (message) return { ...base, label: 'authFiles.health.warning', tone: 'warning' };
if (status === 'active' || status === 'ready') return { ...base, label: 'authFiles.health.active', tone: 'success' };
return { ...base, label: 'authFiles.health.unknown', tone: 'neutral' };
}
+70
View File
@@ -0,0 +1,70 @@
export const AUTH_REQUEST_BUCKET_COUNT = 20;
export type AuthRequestBucket = {
time: string;
success: number;
failure: number;
rate: number | null;
};
export type AuthFileRequestStats = {
success: number | null;
failure: number | null;
recentAvailable: boolean;
buckets: AuthRequestBucket[];
recentSuccess: number;
recentFailure: number;
recentRate: number | null;
};
function requestCount(value: unknown): number | null {
if (typeof value !== 'number' && typeof value !== 'string') return null;
if (typeof value === 'string' && !value.trim()) return null;
const count = Number(value);
return Number.isSafeInteger(count) && count >= 0 ? count : null;
}
export function authFileRequestStats(file: Record<string, unknown>): AuthFileRequestStats {
const recent = file.recent_requests ?? file.recentRequests;
const recentAvailable = Array.isArray(recent);
const buckets: AuthRequestBucket[] = (recentAvailable ? recent : [])
.slice(-AUTH_REQUEST_BUCKET_COUNT)
.map((value: unknown) => {
const bucket = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> : {};
const success = requestCount(bucket.success) ?? 0;
const failure = requestCount(bucket.failed) ?? 0;
return {
time: typeof bucket.time === 'string' ? bucket.time.trim() : '',
success,
failure,
rate: success + failure > 0 ? success / (success + failure) : null,
};
});
while (buckets.length < AUTH_REQUEST_BUCKET_COUNT) {
buckets.unshift({ time: '', success: 0, failure: 0, rate: null });
}
const recentSuccess = buckets.reduce((total, bucket) => total + bucket.success, 0);
const recentFailure = buckets.reduce((total, bucket) => total + bucket.failure, 0);
return {
success: requestCount(file.success ?? file.successCount),
failure: requestCount(file.failed ?? file.failureCount),
recentAvailable,
buckets,
recentSuccess,
recentFailure,
recentRate: recentSuccess + recentFailure > 0
? recentSuccess / (recentSuccess + recentFailure) : null,
};
}
export function requestRateColor(rate: number): string {
const stops = [[239, 68, 68], [250, 204, 21], [34, 197, 94]];
const normalized = Math.max(0, Math.min(1, rate));
const segment = normalized < 0.5 ? 0 : 1;
const progress = segment === 0 ? normalized * 2 : (normalized - 0.5) * 2;
const color = stops[segment].map((value, index) =>
Math.round(value + (stops[segment + 1][index] - value) * progress),
);
return `rgb(${color.join(', ')})`;
}
+131
View File
@@ -0,0 +1,131 @@
import { getCurrentLocale, translate } from '../i18n';
import { isRecord, managementApi } from './managementApi';
import { normalizeAuthFilePriorityInput } from './authFiles';
import { authFileExcludedRulesFromPayload } from './oauthModelSettings';
import { normalizeOAuthExcludedRules } from './oauthModels';
export type BooleanOverride = '' | 'true' | 'false';
export type AuthFileSettingsDraft = {
prefix: string;
proxy_url: string;
priority: string;
weight: string;
disable_cooling: BooleanOverride;
websockets: BooleanOverride;
excluded_models: string;
headers: string;
note: string;
};
const fail = (key: 'metadata' | 'headers' | 'weight' | 'priority') => {
throw new Error(translate(getCurrentLocale(), `authFiles.settings.invalid.${key}`));
};
const override = (value: unknown, allowNumber = false): BooleanOverride => {
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (allowNumber && typeof value === 'number' && Number.isFinite(value)) return value !== 0 ? 'true' : 'false';
if (typeof value === 'string') {
if (['1', 't', 'T', 'TRUE', 'true', 'True'].includes(value.trim())) return 'true';
if (['0', 'f', 'F', 'FALSE', 'false', 'False'].includes(value.trim())) return 'false';
}
return '';
};
const headersFromText = (text: string): Record<string, string> => {
let value: unknown;
try { value = JSON.parse(text.trim() || '{}'); } catch { return fail('headers'); }
if (!isRecord(value)) return fail('headers');
const names = new Set<string>();
const result: Record<string, string> = Object.create(null);
for (const [key, item] of Object.entries(value)) {
const name = key.trim();
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(name) || typeof item !== 'string'
|| /[\r\n\0]/.test(item) || names.has(name.toLowerCase())) return fail('headers');
names.add(name.toLowerCase());
if (item.trim()) result[name] = item.trim();
}
return result;
};
export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsDraft => {
let metadata = payload;
if (typeof metadata === 'string') {
try { metadata = JSON.parse(metadata); } catch { return fail('metadata'); }
}
if (!isRecord(metadata)) return fail('metadata');
const read = (key: string, legacy?: string) => Object.prototype.hasOwnProperty.call(metadata, key)
? metadata[key] : legacy ? metadata[legacy] : undefined;
const text = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? String(value) : '';
const headers = metadata.headers == null ? {} : metadata.headers;
if (!isRecord(headers) || Object.values(headers).some((value) => typeof value !== 'string')) return fail('headers');
return {
prefix: text(metadata.prefix),
proxy_url: text(read('proxy_url', 'proxy-url')),
priority: text(metadata.priority),
weight: text(metadata.weight),
disable_cooling: override(read('disable_cooling', 'disable-cooling'), true),
websockets: override(read('websockets', 'websocket')),
excluded_models: authFileExcludedRulesFromPayload(metadata).join('\n'),
headers: JSON.stringify(headers, null, 2),
note: text(metadata.note),
};
};
export const buildAuthFileSettingsPatch = (
original: AuthFileSettingsDraft,
draft: AuthFileSettingsDraft,
): Record<string, unknown> => {
const patch: Record<string, unknown> = {};
for (const key of ['prefix', 'proxy_url', 'note'] as const) {
if (draft[key].trim() !== original[key].trim()) patch[key] = draft[key].trim();
}
if (draft.priority !== original.priority) {
const value = normalizeAuthFilePriorityInput(draft.priority);
if (value === null) return fail('priority');
if (value !== normalizeAuthFilePriorityInput(original.priority)) patch.priority = value;
}
if (draft.weight !== original.weight) {
const text = draft.weight.trim();
const value = text ? Number(text) : null;
if (value !== null && (!/^-?\d+$/.test(text) || !Number.isSafeInteger(value) || value > 1_000_000)) return fail('weight');
const normalized = value === null ? null : Math.max(0, value);
const previous = original.weight.trim() ? Math.max(0, Number(original.weight)) : null;
if (normalized !== previous) patch.weight = normalized;
}
for (const key of ['disable_cooling', 'websockets'] as const) {
if (draft[key] !== original[key]) patch[key] = draft[key] === '' ? null : draft[key] === 'true';
}
const rules = (text: string) => normalizeOAuthExcludedRules(text.split(/\r?\n/));
const nextRules = rules(draft.excluded_models);
if (JSON.stringify([...nextRules].sort()) !== JSON.stringify(rules(original.excluded_models).sort())) {
patch.excluded_models = nextRules;
}
if (draft.headers !== original.headers) {
const previous = headersFromText(original.headers);
const next = headersFromText(draft.headers);
const headers: Record<string, string> = Object.create(null);
for (const name of new Set([...Object.keys(previous), ...Object.keys(next)])) {
if (previous[name] !== next[name]) headers[name] = next[name] ?? '';
}
if (Object.keys(headers).length) patch.headers = headers;
}
return patch;
};
type SettingsApi = {
get: (path: string, query: Record<string, string>) => Promise<unknown>;
patch: (path: string, body: Record<string, unknown>) => Promise<unknown>;
};
export const loadAuthFileSettings = async (name: string, api: SettingsApi = managementApi) =>
authFileSettingsFromPayload(await api.get('/auth-files/download', { name }));
export const saveAuthFileSettings = async (
name: string, original: AuthFileSettingsDraft, draft: AuthFileSettingsDraft,
api: SettingsApi = managementApi,
) => {
const patch = buildAuthFileSettingsPatch(original, draft);
if (!Object.keys(patch).length) return false;
await api.patch('/auth-files/fields', { name, ...patch });
return true;
};
+1
View File
@@ -111,6 +111,7 @@ const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => {
const merged = { ...sorted[0] };
sorted.slice(1).forEach((entry) => {
Object.entries(entry).forEach(([key, value]) => {
if (key === 'cooldowns' && Object.prototype.hasOwnProperty.call(merged, key)) return;
if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) merged[key] = value;
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'bun:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../src/i18n';
import { AuthFileHealthStatus } from '../src/components/AuthFileHealthStatus';
import { dedupeAuthFiles } from '../src/services/authFiles';
import {
authFileHealth,
cooldownReasonKey,
normalizeAuthFileCooldowns,
summarizeAuthFileCooldowns,
} from '../src/services/authFileHealth';
const receivedAtMs = Date.now();
const record = {
scope: 'model', model_key: 'model-a', reason: 'quota',
retry_at: '2026-01-01T10:00:32Z', remaining_seconds: 32, http_status: 429, backoff_level: 0,
};
const normalize = (value: unknown) => normalizeAuthFileCooldowns(value, receivedAtMs, '2026-01-01T10:00:00Z');
const render = (file: Record<string, unknown>, received = receivedAtMs) => renderToStaticMarkup(
createElement(I18nProvider, { children: createElement(AuthFileHealthStatus, { file, receivedAtMs: received }) }),
);
describe('credential health and cooldowns', () => {
it('distinguishes old cores, unknown snapshots and known-empty restrictions', () => {
expect(normalize(undefined)).toBeUndefined();
expect(normalize(null)?.records).toBeNull();
expect(normalize([])?.records).toEqual([]);
const snapshot = normalize([record])!;
expect(snapshot.records?.[0]).toMatchObject({
model: 'model-a', reason: 'quota', httpStatus: 429, backoffLevel: 0,
retryAt: '2026-01-01T10:00:32.000Z', remainingSeconds: 32,
});
expect(snapshot.observedAt).toBe('2026-01-01T10:00:00.000Z');
});
it('keeps malformed restrictions unknown instead of silently clearing them', () => {
for (const value of [null, {}, 'bad', [null], [{ ...record, scope: 'future' }],
[{ ...record, model_key: '' }], [{ ...record, model_key: {} }],
[{ ...record, retry_at: 'bad-date' }], [{ ...record, remaining_seconds: 0 }],
[{ ...record, remaining_seconds: -1 }], [{ ...record, remaining_seconds: 1.5 }],
[{ ...record, remaining_seconds: Infinity }], [record, { scope: 'future' }]]) {
expect(normalize(value)?.records).toBeNull();
}
const diagnostics = normalize([{ ...record, http_status: 200, backoff_level: -1 }])?.records?.[0];
expect(diagnostics?.httpStatus).toBeUndefined();
expect(diagnostics?.backoffLevel).toBeUndefined();
});
it('preserves authoritative empty/unknown snapshots when deduplicating files', () => {
for (const cooldowns of [[], null]) {
const [file] = dedupeAuthFiles([
{ name: 'a.json', source: 'file', path: '/synthetic/a.json', cooldowns },
{ name: 'a.json', source: 'memory', cooldowns: [record] },
]);
expect(file.cooldowns).toEqual(cooldowns);
}
});
it('uses server-relative time despite clock skew and keeps elapsed restrictions pending confirmation', () => {
const snapshot = normalize([record])!;
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs - 5000).earliestSeconds).toBe(32);
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs + 1001).earliestSeconds).toBe(31);
const elapsed = summarizeAuthFileCooldowns(snapshot, receivedAtMs + 33_000);
expect(elapsed.earliestSeconds).toBe(0);
expect(elapsed.elapsed).toBe(true);
expect(elapsed.rows).toHaveLength(1);
expect(elapsed.active).toHaveLength(0);
expect(authFileHealth({ status: 'error', unavailable: true, cooldowns: [] }).tone).toBe('error');
});
it('separates credential-wide cooldowns from model restrictions as timers expire', () => {
const snapshot = normalize([
record,
{ ...record, scope: 'credential', reason: 'credential_quota', remaining_seconds: 10 },
]);
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs)).toMatchObject({
modelCount: 1, credentialWide: true, earliestSeconds: 10,
});
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs + 11_000)).toMatchObject({
modelCount: 1, credentialWide: false, earliestSeconds: 21,
});
});
it('uses appropriate status tones and only exact known diagnostic markers', () => {
expect(authFileHealth({ status: 'active', status_message: 'OK' })).toMatchObject({ tone: 'success', message: '' });
expect(authFileHealth({ status: 'error', unavailable: false }).tone).toBe('error');
expect(authFileHealth({ status: 'refreshing' }).label).toBe('authFiles.health.refreshing');
expect(authFileHealth({ status: 'pending' }).label).toBe('authFiles.health.pending');
expect(authFileHealth({ status: 'disabled', unavailable: true }).tone).toBe('neutral');
expect(authFileHealth({}).label).toBe('authFiles.health.unknown');
expect(authFileHealth({ status: 'active', status_message: 'Unexpected upstream response' }).tone).toBe('warning');
expect(authFileHealth({ status: 'error', status_message: 'token expired' }).label)
.toBe('authFiles.health.reason.tokenExpired');
expect(authFileHealth({ status: 'error', status_message: 'HTTP 403: policy restriction' }).label)
.toBe('authFiles.health.error');
for (const reason of ['future', '__proto__', 'constructor']) {
expect(cooldownReasonKey(reason)).toBe('authFiles.health.reason.unknown');
}
expect(cooldownReasonKey('payment_required')).toBe('authFiles.health.reason.accessDenied');
});
it('renders expandable diagnostics with escaped error text and no fabricated HTTP status', () => {
const html = render({ status: 'error', status_message: '<script>unsafe</script>', cooldowns: [record] });
expect(html).toContain('<details>');
expect(html).toContain('<summary');
expect(html).toContain('model-a');
expect(html).toContain('HTTP 429');
expect(html).toContain('dateTime="2026-01-01T10:00:32.000Z"');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
expect(render({ status: 'active', cooldowns: [{ ...record, http_status: undefined }] })).not.toContain('HTTP 429');
});
it('does not render elapsed timers as healthy, or hide disabled state behind a cooldown', () => {
expect(render({ status: 'active', cooldowns: [record] }, receivedAtMs - 60_000)).toContain('冷却计时已到期');
expect(render({ disabled: true, status: 'error', cooldowns: [record] })).toContain('已停用');
expect(render({ status: 'error', unavailable: true })).toContain('无法确定恢复时间');
expect(render({ status: 'active', cooldowns: null })).toContain('冷却状态未知');
const expiredToken = render({ status: 'error', status_message: 'token expired', cooldowns: [record] }, receivedAtMs - 60_000);
expect(expiredToken.slice(0, expiredToken.indexOf('</summary>'))).toContain('访问令牌已过期');
});
});
+62
View File
@@ -0,0 +1,62 @@
import { expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AuthFileQuotaPanel } from '../src/components/AuthFileQuotaPanel';
import { I18nProvider } from '../src/i18n';
import type { QuotaState } from '../src/services/quotaService';
import { QuotaCard } from '../src/pages/QuotaPage';
const render = (quota: QuotaState) => renderToStaticMarkup(<I18nProvider><AuthFileQuotaPanel file={{ name: 'test.json' }} quota={quota} disabled={false} onRefresh={() => {}} /></I18nProvider>);
it('renders real quota values with bounded bars and keeps unknown distinct from zero', () => {
const html = render({ status: 'success', rows: [
{ label: 'empty', remainingPercent: 0 }, { label: 'full', remainingPercent: 100 },
{ label: 'too high', remainingPercent: 140 }, { label: 'too low', remainingPercent: -20 },
{ label: 'unknown', remainingPercent: null }, { label: 'invalid', remainingPercent: NaN },
] });
expect(html.match(/role="progressbar"/g)).toHaveLength(4);
expect(html.match(/aria-valuenow="0"/g)).toHaveLength(2);
expect(html.match(/aria-valuenow="100"/g)).toHaveLength(2);
expect(html.match(/credential-quota-row unknown/g)).toHaveLength(2);
expect(html).not.toContain('NaN');
expect(html).not.toContain('140%');
});
it('does not fabricate progress percentages while loading or on failure', () => {
const loading = render({ status: 'loading', rows: [] });
expect(loading).toContain('indeterminate');
expect(loading).not.toContain('aria-valuenow');
const error = render({ status: 'error', rows: [], error: 'upstream failed' });
expect(error).toContain('upstream failed');
expect(error).not.toContain('role="progressbar"');
});
it.each([
{ credits: undefined, visible: false, disabled: false },
{ credits: 0, visible: false, disabled: false },
{ credits: 2, visible: true, disabled: false },
{ credits: 2, applicable: 0, remaining: 100, visible: true, disabled: false },
{ credits: 2, loading: true, visible: true, disabled: true },
{ credits: 2, fileDisabled: true, visible: true, disabled: true },
{ credits: 2, provider: 'claude', visible: false, disabled: false },
{ credits: 2, result: 'error', visible: true, disabled: false },
{ credits: 2, result: 'refresh-error', visible: true, disabled: false },
] as const)('matches quota-page reset visibility and availability: %j', (scenario) => {
const values = scenario as { credits?: number; applicable?: number; remaining?: number; loading?: boolean; fileDisabled?: boolean; provider?: string; result?: 'error' | 'refresh-error'; visible: boolean; disabled: boolean };
const file = { name: 'test.json', provider: values.provider ?? 'codex', disabled: values.fileDisabled ?? false };
const quota: QuotaState = {
status: values.loading ? 'loading' : 'success', rows: [{ label: '5h', remainingPercent: values.remaining ?? 0 }],
resetCredits: values.credits, resetCreditsApplicable: values.applicable,
pendingAction: values.loading ? 'reset' : undefined,
actionResult: values.result ? { action: 'reset', status: values.result, error: 'test error' } : undefined,
};
const onReset = file.provider === 'codex' ? () => {} : undefined;
const credentialHtml = renderToStaticMarkup(<I18nProvider><AuthFileQuotaPanel file={file} quota={quota} disabled={file.disabled} onRefresh={() => {}} onReset={onReset} /></I18nProvider>);
const quotaHtml = renderToStaticMarkup(<I18nProvider><QuotaCard file={file} quota={quota} onRefresh={() => {}} onReset={onReset} /></I18nProvider>);
const buttonState = (html: string) => {
const button = html.match(/<button[^>]*title="重置额度"[^>]*>重置额度<\/button>/)?.[0];
return { visible: Boolean(button), disabled: Boolean(button?.includes('disabled=""')) };
};
expect(buttonState(credentialHtml)).toEqual({ visible: values.visible, disabled: values.disabled });
expect(buttonState(credentialHtml)).toEqual(buttonState(quotaHtml));
if (values.loading) expect(credentialHtml).toContain('正在提交重置并刷新额度');
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'bun:test';
import { authFileRequestStats, requestRateColor } from '../src/services/authFileRequests';
describe('credential request statistics', () => {
it('keeps cumulative counts separate from the recent success rate and preserves server times', () => {
const stats = authFileRequestStats({
success: 900,
failed: 100,
recent_requests: [
{ time: '23:50-00:00', success: 3, failed: 1 },
{ time: '00:00-00:10', success: 0, failed: 2 },
],
});
expect(stats.success).toBe(900);
expect(stats.failure).toBe(100);
expect(stats.recentSuccess).toBe(3);
expect(stats.recentFailure).toBe(3);
expect(stats.recentRate).toBe(0.5);
expect(stats.buckets).toHaveLength(20);
expect(stats.buckets[18]).toEqual({ time: '23:50-00:00', success: 3, failure: 1, rate: 0.75 });
expect(stats.buckets[19].rate).toBe(0);
expect(stats.buckets[0].rate).toBeNull();
});
it('distinguishes unsupported statistics from an empty interval, without inventing a success rate', () => {
const missing = authFileRequestStats({});
expect(missing.recentAvailable).toBe(false);
expect(missing.success).toBeNull();
expect(missing.failure).toBeNull();
expect(missing.recentRate).toBeNull();
const empty = authFileRequestStats({ success: 0, failed: 0, recent_requests: [] });
expect(empty.recentAvailable).toBe(true);
expect(empty.success).toBe(0);
expect(empty.failure).toBe(0);
expect(empty.recentRate).toBeNull();
expect(empty.buckets.every((bucket) => bucket.rate === null)).toBe(true);
});
it('accepts numeric strings and camelCase fields while rejecting malformed counts', () => {
const stats = authFileRequestStats({
successCount: ' 12 ', failureCount: '2',
recentRequests: [
null,
{ success: -2, failed: true },
{ success: Infinity, failed: 'NaN' },
{ success: 1.2, failed: [] },
{ time: '10:00-10:10', success: '3', failed: '1' },
],
});
expect(stats.success).toBe(12);
expect(stats.failure).toBe(2);
expect(stats.recentRate).toBe(0.75);
expect(stats.buckets.slice(0, 19).every((bucket) => bucket.rate === null)).toBe(true);
for (const value of ['', ' ', -1, true, [], {}, NaN, Infinity, 1.5]) {
expect(authFileRequestStats({ success: value }).success).toBeNull();
}
});
it('shows only the latest twenty intervals in chronological order', () => {
const recent = Array.from({ length: 25 }, (_, index) => ({
time: `bucket-${index}`, success: index, failed: 0,
}));
const stats = authFileRequestStats({ recent_requests: recent });
expect(stats.buckets).toHaveLength(20);
expect(stats.buckets[0].time).toBe('bucket-5');
expect(stats.buckets[19].time).toBe('bucket-24');
expect(stats.recentRate).toBe(1);
expect(recent).toHaveLength(25);
});
it('maps failure, mixed and success rates onto the red-yellow-green scale', () => {
expect(requestRateColor(0)).toBe('rgb(239, 68, 68)');
expect(requestRateColor(0.5)).toBe('rgb(250, 204, 21)');
expect(requestRateColor(1)).toBe('rgb(34, 197, 94)');
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'bun:test';
import { authFileSettingsFromPayload, buildAuthFileSettingsPatch, loadAuthFileSettings, saveAuthFileSettings } from '../src/services/authFileSettings';
describe('credential settings', () => {
it('reads all fields and legacy aliases without retaining credential tokens', () => {
const draft = authFileSettingsFromPayload(JSON.stringify({ prefix: 'team', 'proxy-url': 'socks5://localhost:1080', priority: -4, weight: 6,
'disable-cooling': false, websocket: true, 'excluded-models': [' GPT-* ', 'gpt-*'], headers: { 'X-Team': 'test' }, note: 'note', access_token: 'secret' }));
expect(draft).toEqual({ prefix: 'team', proxy_url: 'socks5://localhost:1080', priority: '-4', weight: '6', disable_cooling: 'false',
websockets: 'true', excluded_models: 'gpt-*', headers: '{\n "X-Team": "test"\n}', note: 'note' });
expect(JSON.stringify(draft)).not.toContain('secret');
});
it('patches only changed settings while tokens or other fields refresh concurrently', async () => {
const file = { access_token: 'before', priority: 5, note: 'old', auth_index: 'private-index' };
const writes: unknown[] = [];
const api = {
async get(path: string, query: Record<string, string>) {
expect([path, query]).toEqual(['/auth-files/download', { name: 'test.json' }]);
return { ...file };
},
async patch(path: string, body: Record<string, unknown>) {
writes.push({ path, body });
const { name, ...fields } = body;
Object.assign(file, fields);
},
};
const original = await loadAuthFileSettings('test.json', api);
file.access_token = 'refreshed';
file.priority = 9;
expect(await saveAuthFileSettings('test.json', original, { ...original, note: 'new' }, api)).toBe(true);
expect(writes).toEqual([{ path: '/auth-files/fields', body: { name: 'test.json', note: 'new' } }]);
expect(file).toEqual({ access_token: 'refreshed', priority: 9, note: 'new', auth_index: 'private-index' });
expect(await saveAuthFileSettings('test.json', original, original, api)).toBe(false);
expect(writes).toHaveLength(1);
});
it('honors explicit canonical defaults and backend-supported boolean representations', () => {
expect(authFileSettingsFromPayload({ disable_cooling: null, 'disable-cooling': true, excluded_models: [], 'excluded-models': ['old'] }))
.toMatchObject({ disable_cooling: '', excluded_models: '' });
expect(authFileSettingsFromPayload({ disable_cooling: 0, websockets: 'TRUE' }))
.toMatchObject({ disable_cooling: 'false', websockets: 'true' });
expect(authFileSettingsFromPayload({ disable_cooling: ' 1 ', websockets: 'False' }))
.toMatchObject({ disable_cooling: 'true', websockets: 'false' });
});
it('clears all nine settings with backend-compatible values', () => {
const original = authFileSettingsFromPayload({ prefix: 'p', proxy_url: 'http://proxy', priority: 4, weight: 4, disable_cooling: true,
websockets: true, excluded_models: ['model'], headers: { 'X-One': '1', 'X-Two': '2' }, note: 'note' });
const empty = authFileSettingsFromPayload({});
expect(buildAuthFileSettingsPatch(original, empty)).toEqual({ prefix: '', proxy_url: '', priority: 0, weight: null,
disable_cooling: null, websockets: null, excluded_models: [], headers: { 'X-One': '', 'X-Two': '' }, note: '' });
});
it('merges header edits and deletions without resending unchanged headers', () => {
const original = authFileSettingsFromPayload({ headers: { 'X-Keep': 'a', 'X-Remove': 'b', 'X-Edit': 'old' } });
expect(buildAuthFileSettingsPatch(original, { ...original, headers: '{"X-Keep":"a","X-Edit":"new","X-Add":"c"}' }))
.toEqual({ headers: { 'X-Remove': '', 'X-Edit': 'new', 'X-Add': 'c' } });
});
it('preserves inheritance and recognizes semantically unchanged values', () => {
const original = authFileSettingsFromPayload({ priority: 0, weight: 1, excluded_models: ['b', 'a'], headers: { 'X-Team': 'a' } });
expect(buildAuthFileSettingsPatch(original, { ...original, priority: '', weight: '01', excluded_models: ' A \nb\na', headers: '{"X-Team":"a"}' })).toEqual({});
expect(buildAuthFileSettingsPatch(original, { ...original, disable_cooling: 'false', websockets: 'true', weight: '-3' }))
.toEqual({ disable_cooling: false, websockets: true, weight: 0 });
});
it('rejects unsafe values before sending a request', async () => {
const original = authFileSettingsFromPayload({});
for (const change of [{ priority: '1.5' }, { priority: '9007199254740992' }, { weight: '1e3' }, { weight: '1000001' }, { weight: 'NaN' },
{ headers: '[]' }, { headers: '{"X-Test":3}' }, { headers: '{"Invalid Header":"a"}' }, { headers: '{"X-Test":"a\\r\\nb"}' },
{ headers: '{"X-Test":"a","x-test":"b"}' }]) {
let called = false;
const api = { get: async () => ({}), patch: async () => { called = true; } };
await expect(saveAuthFileSettings('test.json', original, { ...original, ...change }, api)).rejects.toThrow();
expect(called).toBe(false);
}
});
it('blocks editing when metadata is malformed and propagates save failures', async () => {
for (const payload of ['not-json', [], null, { headers: [] }, { excluded_models: [3] }]) {
expect(() => authFileSettingsFromPayload(payload)).toThrow();
}
const original = authFileSettingsFromPayload({});
await expect(saveAuthFileSettings('test.json', original, { ...original, note: 'test' }, {
get: async () => ({}), patch: async () => { throw new Error('save failed'); },
})).rejects.toThrow('save failed');
});
});
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head><body><div id="root"></div><script type="module" src="/tests/fixtures/auth-file-requests.tsx"></script></body></html>
+143
View File
@@ -0,0 +1,143 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage';
import { QuotaPage } from '../../src/pages/QuotaPage';
import { updateQuotaCache } from '../../src/services/quotaCache';
import '../../src/styles.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
document.documentElement.dataset.theme = params.get('theme') || 'light';
let refreshCount = 0;
let resetCount = 0;
const metadata: Record<string, Record<string, unknown>> = {};
const settingsFor = (name: string) => metadata[name] ??= {
prefix: 'team', proxy_url: '', priority: 3, weight: 2, disable_cooling: false,
websockets: true, excluded_models: ['gpt-old-*'], headers: { 'X-Team': 'demo' }, note: '开发环境 · 主用凭证',
};
const cardFiles = [
{ name: 'codex-demo.json', provider: 'codex', auth_index: 'demo', email: 'demo@example.com', status: 'active', cooldowns: [] },
{ name: 'claude-idle.json', provider: 'claude', auth_index: 'idle', email: 'developer@example.com', status: 'error', unavailable: true, status_message: 'Rate limit exceeded', cooldowns: [{ scope: 'model', reason: 'quota', model_key: 'claude-example', remaining_seconds: 620, retry_at: new Date(Date.now() + 620_000).toISOString(), http_status: 429 }] },
{ name: 'codex-disabled.json', provider: 'codex', auth_index: 'disabled', disabled: true, status: 'disabled', cooldowns: [] },
{ name: 'runtime.json', provider: 'codex', auth_index: 'runtime', runtime_only: true, status: 'active', cooldowns: [] },
{ name: 'a-very-long-credential-file-name-with-extra-identification-for-layout-testing.json', provider: 'codex', auth_index: 'long', status: 'pending', cooldowns: [] },
];
if (params.has('cards')) updateQuotaCache({
'claude-idle.json::idle': { status: 'success', rows: [{ label: '5 小时', remainingPercent: 0, resetAtMs: Date.now() + 620_000 }, { label: '每周', remainingPercent: 24, resetAtMs: Date.now() + 3_600_000 }] },
'codex-disabled.json::disabled': { status: 'success', rows: [{ label: '5 小时', remainingPercent: null, detail: '上游未返回百分比' }] },
});
const buckets = Array.from({ length: 20 }, (_, index) => ({
time: `${String(10 + Math.floor(index / 6)).padStart(2, '0')}:${String(index % 6 * 10).padStart(2, '0')}-${String(10 + Math.floor((index + 1) / 6)).padStart(2, '0')}:${String((index + 1) % 6 * 10).padStart(2, '0')}`,
success: index < 3 ? 0 : index === 15 ? 0 : 18,
failed: index < 3 ? 0 : index === 15 ? 4 : index % 4 === 0 ? 6 : 0,
}));
mockIPC(async (cmd, args) => {
if (cmd === 'set_app_locale') return null;
if (cmd === 'management_request') {
const request = args?.request as { path: string; method: string; query?: Record<string, string>; body?: Record<string, unknown> };
if (request.path === '/auth-files/download') {
if (params.has('metadataError')) throw new Error('Fixture metadata read failed');
return { ...settingsFor(request.query?.name ?? '') };
}
if (request.path === '/auth-files/models') {
if (params.has('catalogError')) throw new Error('Fixture catalog read failed');
return { models: [{ id: 'gpt-example' }, { id: 'gpt-old-example' }, { id: 'claude-example' }] };
}
if (request.path === '/auth-files/fields' && request.method === 'PATCH') {
if (params.has('saveError')) throw new Error('Fixture settings save failed');
const { name, headers, ...patch } = request.body ?? {};
const target = settingsFor(String(name));
Object.assign(target, patch);
if (headers) {
const merged = { ...(target.headers as Record<string, string>) };
for (const [key, value] of Object.entries(headers as Record<string, string>)) {
if (value) merged[key] = value;
else delete merged[key];
}
target.headers = merged;
}
const output = document.getElementById('fixture-writes');
if (output) output.textContent = JSON.stringify(request.body, null, 2);
return { status: 'ok' };
}
if (request.path === '/api-call') {
await new Promise((resolve) => setTimeout(resolve, 800));
if (params.has('quotaError')) throw new Error('Fixture quota unavailable');
if (String(request.body?.url).endsWith('/consume')) {
resetCount += 1;
const output = document.getElementById('fixture-reset-count');
if (output) output.textContent = String(resetCount);
return { status_code: 200, body: '{}' };
}
return { status_code: 200, body: JSON.stringify(String(request.body?.url).includes('reset-credits')
? { available_count: Math.max(0, 2 - resetCount), applicable_available_count: 0, credits: [{ reset_type: 'codex_rate_limits', status: 'available', expires_at: '2030-01-01T00:00:00Z' }] }
: { plan_type: 'Plus', rate_limit: { primary_window: { used_percent: resetCount ? 0 : 16, limit_window_seconds: 18000, reset_after_seconds: 3600 }, secondary_window: { used_percent: resetCount ? 0 : 72, limit_window_seconds: 604800, reset_after_seconds: 86400 } } }) };
}
if (request.path === '/auth-files' && request.method === 'GET') {
refreshCount += 1;
if (params.has('cards')) return { files: cardFiles.map((file) => ({ source: 'file', size: 2048, updated_at: '2026-09-20T01:20:00Z', success: 1200, failed: 28, recent_requests: buckets, ...file, ...settingsFor(file.name) })) };
if (params.has('health')) {
const now = Date.now();
const timer = (scope: string, reason: string, seconds: number, model?: string, httpStatus?: number) => ({
scope, reason, remaining_seconds: seconds,
retry_at: new Date(now + seconds * 1000 + 3_600_000).toISOString(),
model_key: model, http_status: httpStatus,
});
const recovered = params.has('fast') && refreshCount > 1;
const files = [
{ name: '01-healthy.json', status: 'active', cooldowns: [], status_message: 'ok' },
{ name: '02-quota.json', status: recovered ? 'active' : 'error', unavailable: !recovered,
status_message: recovered ? '' : 'quota exhausted',
cooldowns: recovered ? [] : [
timer('credential', 'credential_quota', params.has('fast') ? 3 : 125),
timer('model', 'quota', params.has('fast') ? 3 : 125, 'gpt-example', 429),
] },
{ name: '03-model-errors.json', status: 'error', unavailable: false,
cooldowns: [
{ ...timer('model', 'transient_error', 45, 'claude-example', 503), backoff_level: 0 },
timer('model', 'model_not_supported', 7200, 'model-with-a-long-name-for-wrapping-layout-verification', 404),
] },
{ name: '04-auth-expired.json', status: 'error', unavailable: true, status_message: 'token expired', cooldowns: [] },
{ name: '05-pending.json', status: 'pending', cooldowns: [] },
{ name: '06-refreshing.json', status: 'refreshing', cooldowns: [] },
{ name: '07-disabled.json', status: 'disabled', disabled: true, cooldowns: [] },
{ name: '08-unknown-cooldown.json', status: 'unknown', cooldowns: null },
{ name: '09-legacy-error.json', status: 'error', status_message: '{"error":{"code":"upstream_error","message":"Example upstream failure with a long message. Refresh the list after the provider becomes available."}}' },
];
return {
observed_at: new Date(now + 3_600_000).toISOString(),
files: files.map((file) => ({ provider: 'codex', auth_index: file.name,
source: 'file', size: 1024, success: 20, failed: 2, recent_requests: buckets, ...file })),
};
}
return { files: [
{ name: 'codex-demo.json', provider: 'codex', email: 'demo@example.com',
auth_index: 'demo', source: 'file', size: 2048, status: 'ready',
success: 1200 + refreshCount, failed: 28, recent_requests: buckets },
{ name: 'claude-idle.json', provider: 'claude', auth_index: 'idle',
source: 'file', size: 1024, status: 'active', success: 0, failed: 0,
recent_requests: buckets.map((bucket) => ({ ...bucket, success: 0, failed: 0 })) },
{ name: 'gemini-disabled.json', provider: 'gemini', auth_index: 'disabled',
source: 'file', disabled: true, size: 1024, success: 0, failed: 40,
recent_requests: buckets.map((bucket) => ({ ...bucket, success: 0, failed: 2 })) },
{ name: 'legacy-no-stats.json', provider: 'codex', auth_index: 'legacy', size: 1024 },
] };
}
}
throw new Error(`Unhandled fixture command: ${cmd}`);
});
createRoot(document.getElementById('root')!).render(
<I18nProvider>
<div className="app-shell">
<aside className="sidebar" aria-hidden="true" />
<div className="workspace">
<main className="content">{params.has('quotaPage') ? <QuotaPage /> : <AuthFileManagementPage />}{params.has('cards') ? <><pre id="fixture-writes" aria-label="Fixture saved payload" /><output id="fixture-reset-count" aria-label="Fixture reset calls">0</output></> : null}</main>
</div>
</div>
</I18nProvider>,
);