Skip to content

Commit 8b6f188

Browse files
committed
feat(rate-limit): add backend rate limit engine and cliproxy client types
Includes rate limit Go implementation, CLI proxy API client updates, preview data for account rate limits, and account component wiring.
1 parent 5b04708 commit 8b6f188

16 files changed

Lines changed: 691 additions & 24 deletions

frontend/src/features/accounts/AccountsFeature.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { isCodexAuthFile } from './model/accountPresentation';
2424
import { buildRelayModelProviderSignature } from './model/apiKeyModelCatalog';
2525
import useGroupCardHeights from './hooks/useGroupCardHeights';
2626
import { buildAccountDetailFrameHash, clearAccountDetailFrameHash } from '../../utils/pagePersistence';
27+
import { hasWailsAppBindings } from '../../utils/previewMode';
28+
import { shouldLoadAccountsData } from './model/accountRuntime';
2729
import type { AccountRecord } from './model/types';
2830
import type { OpenAICompatibleProvider } from './model/openAICompatible';
2931

@@ -39,7 +41,7 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
3941
const fileInputRef = useRef<HTMLInputElement | null>(null);
4042
const headerActionsMenuRef = useRef<HTMLDivElement | null>(null);
4143

42-
const ready = sidecarStatus?.code === 'ready';
44+
const ready = shouldLoadAccountsData(sidecarStatus, hasWailsAppBindings());
4345

4446
const [relayModelNames, setRelayModelNames] = useState<string[]>([]);
4547
const loadRelayModelNames = useCallback(async (isCancelled: () => boolean = () => false) => {
@@ -94,6 +96,8 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
9496
pasteError,
9597
codexQuotaByName,
9698
accountUsageByID,
99+
accountRateLimitByID,
100+
rateLimitStrategies,
97101
isSelectionMode,
98102
selectedAccountIDs,
99103
isHeaderActionsMenuOpen,
@@ -104,6 +108,7 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
104108
allFilteredSelected,
105109
loadAccounts,
106110
loadAccountUsage,
111+
loadAccountRateLimits,
107112
startCodexOAuth,
108113
cancelCodexOAuth,
109114
verifySelectedApiKey,
@@ -172,7 +177,8 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
172177
return;
173178
}
174179
void loadAccountUsage(usageAccounts);
175-
}, [loadAccountUsage, ready, usageAccounts]);
180+
void loadAccountRateLimits(usageAccounts);
181+
}, [loadAccountRateLimits, loadAccountUsage, ready, usageAccounts]);
176182

177183
async function reloadRotationAccounts() {
178184
await loadAccounts();
@@ -238,6 +244,7 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
238244
pendingDeleteName={openAICompatibleState.pendingDeleteName}
239245
pendingStatusName={openAICompatibleState.pendingStatusName}
240246
accountUsageByID={accountUsageByID}
247+
accountRateLimitByID={accountRateLimitByID}
241248
onCreate={openAICompatibleState.openCreateModal}
242249
onRefresh={() => void openAICompatibleState.loadProviders()}
243250
onOpenDetail={openOpenAICompatibleDetail}
@@ -262,6 +269,8 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
262269
<OpenAICompatibleDetailModal
263270
t={t}
264271
draft={openAICompatibleState.detailDraft}
272+
rateLimitStatus={accountRateLimitByID[`openai-compatible:${openAICompatibleState.detailDraft.currentName}`]}
273+
rateLimitStrategies={rateLimitStrategies}
265274
verifyState={
266275
openAICompatibleState.verifyStates[openAICompatibleState.detailDraft.currentName] ?? {
267276
model: openAICompatibleState.detailDraft.verifyModel,
@@ -279,6 +288,7 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
279288
onVerify={() => void openAICompatibleState.verifyDetail()}
280289
onFetchModels={() => void openAICompatibleState.fetchDetailModels()}
281290
onApplyFetchedModels={openAICompatibleState.applyFetchedModelsToDetailDraft}
291+
onRateLimitRulesChanged={() => void loadAccountRateLimits(usageAccounts)}
282292
/>
283293
) : null}
284294
</>
@@ -394,6 +404,7 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
394404
groupCardHeight={groupCardHeights[group.id]}
395405
codexQuotaByName={codexQuotaByName}
396406
accountUsageByID={accountUsageByID}
407+
accountRateLimitByID={accountRateLimitByID}
397408
ready={ready}
398409
isSelectionMode={isSelectionMode}
399410
selectedAccountIDSet={selectedAccountIDSet}
@@ -425,6 +436,7 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
425436
pendingDeleteName={openAICompatibleState.pendingDeleteName}
426437
pendingStatusName={openAICompatibleState.pendingStatusName}
427438
accountUsageByID={accountUsageByID}
439+
accountRateLimitByID={accountRateLimitByID}
428440
onCreate={openAICompatibleState.openCreateModal}
429441
onRefresh={() => void openAICompatibleState.loadProviders()}
430442
onOpenDetail={openOpenAICompatibleDetail}
@@ -455,13 +467,16 @@ export default function AccountsFeature({ sidecarStatus, workspace }: AccountsFe
455467
<ApiKeyDetailModal
456468
account={selectedAccount}
457469
usageSummary={accountUsageByID[selectedAccount.id]}
470+
rateLimitStatus={accountRateLimitByID[selectedAccount.id]}
471+
rateLimitStrategies={rateLimitStrategies}
458472
verifyState={apiKeyVerifyState}
459473
modelNames={relayModelNames}
460474
onClose={closeAccountDetail}
461475
onRename={renameSelectedApiKey}
462476
onSaveConfig={(draft) => updateSelectedApiKeyConfig(draft)}
463477
onVerify={(input) => void verifySelectedApiKey(input)}
464478
onTestQuotaCurl={(input) => testSelectedApiKeyQuotaCurl(input)}
479+
onRateLimitRulesChanged={() => void loadAccountRateLimits(usageAccounts)}
465480
t={t}
466481
/>
467482
) : null}

frontend/src/features/accounts/components/AccountCard.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '../model/accountPresentation';
1414
import type { AccountRecord, CodexQuotaState, Translator } from '../model/types';
1515
import type { AccountUsageSummary } from '../model/accountUsage';
16+
import { rateLimitStateTone, type RateLimitState } from '../model/rateLimit';
1617
import AccountCardSkeleton from './AccountCardSkeleton';
1718
import AttributionCard, { type AttributionCardBadge, type AttributionCardEvidenceRow } from './AttributionCard';
1819

@@ -21,6 +22,7 @@ interface AccountCardProps {
2122
account: AccountRecord;
2223
quotaState?: CodexQuotaState;
2324
usageSummary?: AccountUsageSummary;
25+
rateLimitStatus?: RateLimitState;
2426
minHeight?: number;
2527
ready: boolean;
2628
isSelectionMode: boolean;
@@ -41,6 +43,7 @@ export default function AccountCard({
4143
account,
4244
quotaState,
4345
usageSummary,
46+
rateLimitStatus,
4447
minHeight,
4548
ready,
4649
isSelectionMode,
@@ -69,8 +72,16 @@ export default function AccountCard({
6972
? 'positive'
7073
: operationalState.tone === 'warning'
7174
? 'warning'
72-
: resolveAccountStatusTone(account);
73-
const cardTone = statusTone === 'positive' ? 'positive' : statusTone === 'warning' ? 'warning' : 'critical';
75+
: resolveAccountStatusTone(account);
76+
const guardTone = rateLimitStateTone(rateLimitStatus);
77+
const cardTone =
78+
guardTone === 'critical'
79+
? 'critical'
80+
: statusTone === 'positive'
81+
? 'positive'
82+
: statusTone === 'warning' || guardTone === 'warning'
83+
? 'warning'
84+
: 'critical';
7485
const badges: AttributionCardBadge[] = [
7586
{
7687
label: account.credentialSource === 'auth-file' ? t('accounts.source_auth_file') : t('accounts.source_api_key'),
@@ -82,6 +93,9 @@ export default function AccountCard({
8293
if (account.disabled) {
8394
badges.push({ label: t('accounts.rotation_disabled_badge'), tone: 'critical' });
8495
}
96+
if (rateLimitStatus?.blocked) {
97+
badges.push({ label: rateLimitStatus.blockReason || 'ROUTE GUARD', tone: 'critical' });
98+
}
8599
const evidenceRows: AttributionCardEvidenceRow[] = [
86100
{
87101
label: t('accounts.card_asset'),
@@ -200,6 +214,7 @@ export default function AccountCard({
200214
badges={badges}
201215
usageSummary={usageSummary}
202216
quotaDisplay={quotaDisplay}
217+
rateLimitStatus={rateLimitStatus}
203218
evidenceRows={evidenceRows}
204219
tone={cardTone}
205220
style={minHeight ? { minHeight: `${minHeight}px` } : undefined}

frontend/src/features/accounts/components/AccountGroupSection.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { groupProviderLabel } from '../model/accountPresentation';
22
import type { AccountGroup, AccountRecord, CodexQuotaState, Translator } from '../model/types';
33
import type { AccountUsageSummary } from '../model/accountUsage';
4+
import type { RateLimitState } from '../model/rateLimit';
45
import AccountCard from './AccountCard';
56

67
interface AccountGroupSectionProps {
@@ -9,6 +10,7 @@ interface AccountGroupSectionProps {
910
groupCardHeight?: number;
1011
codexQuotaByName: Record<string, CodexQuotaState>;
1112
accountUsageByID: Record<string, AccountUsageSummary>;
13+
accountRateLimitByID: Record<string, RateLimitState>;
1214
ready: boolean;
1315
isSelectionMode: boolean;
1416
selectedAccountIDSet: Set<string>;
@@ -29,6 +31,7 @@ export default function AccountGroupSection({
2931
groupCardHeight,
3032
codexQuotaByName,
3133
accountUsageByID,
34+
accountRateLimitByID,
3235
ready,
3336
isSelectionMode,
3437
selectedAccountIDSet,
@@ -66,6 +69,7 @@ export default function AccountGroupSection({
6669
account={account}
6770
quotaState={codexQuotaByName[account.quotaKey || '']}
6871
usageSummary={accountUsageByID[account.id]}
72+
rateLimitStatus={accountRateLimitByID[account.id]}
6973
minHeight={groupCardHeight}
7074
ready={ready}
7175
isSelectionMode={isSelectionMode}

frontend/src/features/accounts/components/ApiKeyDetailModal.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
import { buildAccountHealthMetaItems } from '../model/accountHealthMeta';
1010
import type { AccountRecord, TextInputEvent, Translator } from '../model/types';
1111
import type { AccountUsageSummary } from '../model/accountUsage';
12+
import { DEFAULT_RATE_LIMIT_STRATEGIES, type RateLimitState, type RateLimitStrategyMeta } from '../model/rateLimit';
1213
import AccountHealthBar from './AccountHealthBar';
1314
import AccountDetailModalFrame from './AccountDetailModalFrame';
15+
import RateLimitRulesSection from './RateLimitRulesSection';
1416
import { resolveAPIKeyModelMenuNames, type APIKeyModelMenuMode } from '../model/apiKeyModelCatalog';
1517
import { buildDefaultCodexQuotaCurl } from '../model/accountConfig';
1618
import type { CodexQuota } from '../../../types';
@@ -28,13 +30,16 @@ interface APIKeyVerifyState {
2830
interface ApiKeyDetailModalProps {
2931
account: AccountRecord;
3032
usageSummary?: AccountUsageSummary;
33+
rateLimitStatus?: RateLimitState;
34+
rateLimitStrategies?: RateLimitStrategyMeta[];
3135
verifyState: APIKeyVerifyState;
3236
modelNames?: string[];
3337
onClose: () => void;
3438
onRename: (nextName: string) => void;
3539
onSaveConfig: (draft: { apiKey: string; baseUrl: string; prefix: string; quotaCurl: string; quotaEnabled: boolean }) => Promise<void>;
3640
onVerify: (input: { apiKey: string; baseUrl: string; model: string }) => void;
3741
onTestQuotaCurl: (input: { apiKey: string; baseUrl: string; prefix: string; quotaCurl: string }) => Promise<CodexQuota>;
42+
onRateLimitRulesChanged: () => void;
3843
t: Translator;
3944
}
4045

@@ -54,13 +59,16 @@ function formatLastVerifiedAt(timestamp: number | null) {
5459
export default function ApiKeyDetailModal({
5560
account,
5661
usageSummary,
62+
rateLimitStatus,
63+
rateLimitStrategies = DEFAULT_RATE_LIMIT_STRATEGIES,
5764
verifyState,
5865
modelNames,
5966
onClose,
6067
onRename,
6168
onSaveConfig,
6269
onVerify,
6370
onTestQuotaCurl,
71+
onRateLimitRulesChanged,
6472
t,
6573
}: ApiKeyDetailModalProps) {
6674
const [draftName, setDraftName] = useState(account.displayName);
@@ -505,6 +513,15 @@ export default function ApiKeyDetailModal({
505513
</div>
506514
</section>
507515

516+
<RateLimitRulesSection
517+
accountKey={account.id}
518+
matchKey={usageSummary?.attributionKey}
519+
rateLimitStatus={rateLimitStatus}
520+
rateLimitStrategies={rateLimitStrategies}
521+
onRateLimitRulesChanged={onRateLimitRulesChanged}
522+
t={t}
523+
/>
524+
508525
<section className="bg-[var(--bg-surface)]/30 px-6 py-5">
509526
<div className="space-y-4">
510527
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-dashed border-[var(--border-color)] pb-3">

frontend/src/features/accounts/components/AttributionCard.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { CSSProperties, ReactNode } from 'react';
22
import AccountCardFrame from './AccountCardFrame';
33
import type { AccountUsageSummary } from '../model/accountUsage';
4+
import {
5+
formatRateLimitMetric,
6+
rateLimitRuleLabel,
7+
type RateLimitState,
8+
} from '../model/rateLimit';
49
import type { QuotaDisplay, Translator } from '../model/types';
510

611
type AttributionCardTone = 'neutral' | 'positive' | 'warning' | 'critical';
@@ -26,6 +31,7 @@ interface AttributionCardProps {
2631
badges?: AttributionCardBadge[];
2732
usageSummary?: AccountUsageSummary;
2833
quotaDisplay?: QuotaDisplay;
34+
rateLimitStatus?: RateLimitState;
2935
evidenceRows?: AttributionCardEvidenceRow[];
3036
tone?: AttributionCardTone;
3137
density?: AttributionCardDensity;
@@ -72,6 +78,7 @@ export default function AttributionCard({
7278
badges = [],
7379
usageSummary,
7480
quotaDisplay,
81+
rateLimitStatus,
7582
evidenceRows = [],
7683
tone = 'neutral',
7784
density = 'full',
@@ -88,6 +95,7 @@ export default function AttributionCard({
8895
const accentBorderClass = CARD_TONE_CLASS[tone];
8996
const accentFillClass = CARD_FILL_CLASS[tone];
9097
const quotaWindows = quotaDisplay?.windows ?? [];
98+
const rateLimitRules = rateLimitStatus?.rules ?? [];
9199
const flow = buildTrafficCurveState(usageSummary);
92100
const sourceLabel =
93101
usageSummary?.source === 'attribution'
@@ -254,6 +262,58 @@ export default function AttributionCard({
254262
)}
255263
</section>
256264

265+
{rateLimitRules.length > 0 ? (
266+
<section className="grid gap-3 border-b border-dashed border-[var(--border-color)] px-4 py-4">
267+
<div className="flex items-center justify-between gap-3">
268+
<div className="font-mono text-[0.5625rem] font-black uppercase tracking-[0.18em] text-[var(--text-muted)]">
269+
ROUTE GUARD
270+
</div>
271+
<div
272+
className={`font-mono text-[0.5625rem] font-black uppercase tracking-[0.12em] ${
273+
rateLimitStatus?.blocked ? 'text-red-500' : 'text-[var(--text-muted)]'
274+
}`}
275+
>
276+
{rateLimitStatus?.blocked ? rateLimitStatus.blockReason || 'BLOCKED' : 'PASS'}
277+
</div>
278+
</div>
279+
{rateLimitRules.map((ruleState) => {
280+
const exceeded = ruleState.exceeded && ruleState.rule.action === 'block';
281+
const fillClass = exceeded ? 'bg-red-500' : ruleState.exceeded ? 'bg-yellow-500' : 'bg-amber-600';
282+
const textClass = exceeded ? 'text-red-500' : 'text-[var(--text-primary)]';
283+
const pct = Math.min(100, Math.max(0, Number(ruleState.usagePct || 0)));
284+
return (
285+
<div
286+
key={ruleState.rule.id || `${ruleState.rule.strategy}-${ruleState.rule.window}`}
287+
className="grid grid-cols-[5.5rem_minmax(0,1fr)_5rem] items-center gap-2"
288+
>
289+
<div className={`truncate font-mono text-[0.625rem] font-black uppercase tracking-[0.1em] ${textClass}`}>
290+
{rateLimitRuleLabel(ruleState.rule)}
291+
</div>
292+
<div
293+
className="relative h-4 overflow-hidden border border-[var(--border-color)] bg-[var(--bg-surface)]"
294+
style={{
295+
backgroundImage:
296+
'repeating-linear-gradient(to right, color-mix(in srgb, var(--border-color) 12%, transparent) 0 8px, transparent 8px 14px)',
297+
}}
298+
>
299+
<div className={`absolute inset-y-0 left-0 ${fillClass}`} style={{ width: `${pct}%` }} />
300+
</div>
301+
<div className={`truncate text-right font-mono text-[0.625rem] font-black uppercase tracking-[0.06em] ${textClass}`}>
302+
{ruleState.exceeded && ruleState.reason
303+
? ruleState.reason
304+
: `${formatRateLimitMetric(ruleState.currentUsage)}/${formatRateLimitMetric(ruleState.rule.limitValue)}`}
305+
</div>
306+
</div>
307+
);
308+
})}
309+
{rateLimitStatus?.updatedAt ? (
310+
<div className="font-mono text-[0.5rem] font-black uppercase tracking-[0.14em] text-[var(--text-muted)]">
311+
CACHE {new Date(rateLimitStatus.updatedAt).toLocaleString()}
312+
</div>
313+
) : null}
314+
</section>
315+
) : null}
316+
257317
<section className="grid gap-2 px-4 py-4">
258318
<div className="font-mono text-[0.5625rem] font-black uppercase tracking-[0.18em] text-[var(--text-muted)]">
259319
{t('accounts.card_evidence')}

frontend/src/features/accounts/components/OpenAICompatibleProviderCard.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import AttributionCard from './AttributionCard';
22
import type { AccountUsageSummary } from '../model/accountUsage';
3+
import { rateLimitStateTone, type RateLimitState } from '../model/rateLimit';
34
import type { Translator } from '../model/types';
45
import type { OpenAICompatibleProvider, ProviderVerifyState } from '../model/openAICompatible';
56
import { maskProviderAPIKey } from '../model/openAICompatible';
@@ -17,6 +18,7 @@ interface OpenAICompatibleProviderCardProps {
1718
verifyState: ProviderVerifyState;
1819
effectiveModelCount: number;
1920
usageSummary?: AccountUsageSummary;
21+
rateLimitStatus?: RateLimitState;
2022
pendingDelete: boolean;
2123
pendingStatus: boolean;
2224
onOpenDetail: (provider: OpenAICompatibleProvider) => void;
@@ -30,15 +32,20 @@ export default function OpenAICompatibleProviderCard({
3032
verifyState,
3133
effectiveModelCount,
3234
usageSummary,
35+
rateLimitStatus,
3336
pendingDelete,
3437
pendingStatus,
3538
onOpenDetail,
3639
onDelete,
3740
onToggleDisabled,
3841
}: OpenAICompatibleProviderCardProps) {
39-
const tone = resolveOpenAICompatibleCardTone(provider, verifyState);
42+
const guardTone = rateLimitStateTone(rateLimitStatus);
43+
const tone = guardTone === 'critical' ? 'critical' : resolveOpenAICompatibleCardTone(provider, verifyState);
4044
const eyebrow = resolveOpenAICompatibleCardEyebrow(t, provider, verifyState);
4145
const badges = buildOpenAICompatibleCardBadges(t, provider);
46+
if (rateLimitStatus?.blocked) {
47+
badges.push({ label: rateLimitStatus.blockReason || 'ROUTE GUARD', tone: 'critical' });
48+
}
4249
const evidenceRows = buildOpenAICompatibleCardEvidenceRows(t, provider, verifyState, effectiveModelCount);
4350
const verifyMessage = resolveOpenAICompatibleVerifyMessage(t, verifyState);
4451

@@ -51,6 +58,7 @@ export default function OpenAICompatibleProviderCard({
5158
badges={badges}
5259
evidenceRows={evidenceRows}
5360
usageSummary={usageSummary}
61+
rateLimitStatus={rateLimitStatus}
5462
tone={tone}
5563
style={{ minHeight: '48rem' }}
5664
customBody={

0 commit comments

Comments
 (0)