Skip to content

Commit 0d1c067

Browse files
committed
fix: restore provider stats and sticky config
1 parent a91258c commit 0d1c067

17 files changed

Lines changed: 404 additions & 48 deletions

File tree

src/components/config/VisualConfigEditor.tsx

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ export function VisualConfigEditor({
194194
const isMobile = useMediaQuery('(max-width: 768px)');
195195
const routingStrategyLabelId = useId();
196196
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
197+
const stickyTTLInputId = useId();
198+
const quotaRefreshIntervalInputId = useId();
199+
const quotaRefreshIntervalHintId = `${quotaRefreshIntervalInputId}-hint`;
200+
const quotaRefreshIntervalErrorId = `${quotaRefreshIntervalInputId}-error`;
197201
const disableImageGenerationLabelId = useId();
198202
const disableImageGenerationHintId = `${disableImageGenerationLabelId}-hint`;
199203
const keepaliveInputId = useId();
@@ -351,6 +355,11 @@ export function VisualConfigEditor({
351355
const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry);
352356
const maxRetryCredentialsError = getValidationMessage(t, validationErrors?.maxRetryCredentials);
353357
const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval);
358+
const quotaCacheRefreshIntervalError = getValidationMessage(
359+
t,
360+
validationErrors?.quotaCacheRefreshInterval
361+
);
362+
const routingStickyTTLError = getValidationMessage(t, validationErrors?.routingStickyTTL);
354363
const authAutoRefreshWorkersError = getValidationMessage(
355364
t,
356365
validationErrors?.authAutoRefreshWorkers
@@ -437,6 +446,7 @@ export function VisualConfigEditor({
437446
'requestRetry',
438447
'maxRetryCredentials',
439448
'maxRetryInterval',
449+
'routingStickyTTL',
440450
'authAutoRefreshWorkers',
441451
]),
442452
},
@@ -454,7 +464,7 @@ export function VisualConfigEditor({
454464
id: 'quota',
455465
title: t('config_management.visual.sections.quota.title'),
456466
icon: IconTimer,
457-
errorCount: 0,
467+
errorCount: countErrors(['quotaCacheRefreshInterval']),
458468
},
459469
{
460470
id: 'streaming',
@@ -1136,6 +1146,12 @@ export function VisualConfigEditor({
11361146
'config_management.visual.sections.network.strategy_round_robin'
11371147
),
11381148
},
1149+
{
1150+
value: 'sticky-round-robin',
1151+
label: t(
1152+
'config_management.visual.sections.network.strategy_sticky_round_robin'
1153+
),
1154+
},
11391155
{
11401156
value: 'fill-first',
11411157
label: t(
@@ -1155,6 +1171,21 @@ export function VisualConfigEditor({
11551171
/>
11561172
</FieldShell>
11571173
</FieldAnchor>
1174+
{values.routingStrategy === 'sticky-round-robin' ? (
1175+
<FieldAnchor fieldId="routingStickyTTL">
1176+
<Input
1177+
id={stickyTTLInputId}
1178+
label={t('config_management.visual.sections.network.sticky_ttl')}
1179+
type="number"
1180+
placeholder="1800"
1181+
value={values.routingStickyTTL}
1182+
onChange={(e) => onChange({ routingStickyTTL: e.target.value })}
1183+
disabled={disabled}
1184+
hint={t('config_management.visual.sections.network.sticky_ttl_hint')}
1185+
error={routingStickyTTLError}
1186+
/>
1187+
</FieldAnchor>
1188+
) : null}
11581189
<FieldAnchor fieldId="disableImageGeneration">
11591190
<FieldShell
11601191
label={t(
@@ -1354,18 +1385,49 @@ export function VisualConfigEditor({
13541385
title={t('config_management.visual.sections.quota.title')}
13551386
description={t('config_management.visual.sections.quota.description')}
13561387
>
1357-
<SectionGrid>
1358-
{quotaSwitchProjectToggle}
1359-
{quotaSwitchPreviewModelToggle}
1360-
<FieldAnchor fieldId="quotaAntigravityCredits">
1361-
<ToggleRow
1362-
title={t('config_management.visual.sections.quota.antigravity_credits')}
1363-
checked={values.quotaAntigravityCredits}
1364-
disabled={disabled}
1365-
onChange={(quotaAntigravityCredits) => onChange({ quotaAntigravityCredits })}
1366-
/>
1367-
</FieldAnchor>
1368-
</SectionGrid>
1388+
<SectionStack>
1389+
<SectionGrid>
1390+
<FieldAnchor fieldId="quotaCacheRefreshInterval">
1391+
<FieldShell
1392+
label={t('config_management.visual.sections.quota.refresh_interval')}
1393+
htmlFor={quotaRefreshIntervalInputId}
1394+
hint={t('config_management.visual.sections.quota.refresh_interval_hint')}
1395+
hintId={quotaRefreshIntervalHintId}
1396+
error={quotaCacheRefreshIntervalError}
1397+
errorId={quotaRefreshIntervalErrorId}
1398+
>
1399+
<input
1400+
id={quotaRefreshIntervalInputId}
1401+
className="input"
1402+
type="number"
1403+
placeholder="3600"
1404+
value={values.quotaCacheRefreshInterval}
1405+
onChange={(e) => onChange({ quotaCacheRefreshInterval: e.target.value })}
1406+
disabled={disabled}
1407+
aria-describedby={
1408+
quotaCacheRefreshIntervalError
1409+
? `${quotaRefreshIntervalErrorId} ${quotaRefreshIntervalHintId}`
1410+
: quotaRefreshIntervalHintId
1411+
}
1412+
aria-invalid={quotaCacheRefreshIntervalError ? true : undefined}
1413+
/>
1414+
</FieldShell>
1415+
</FieldAnchor>
1416+
</SectionGrid>
1417+
1418+
<SectionGrid>
1419+
{quotaSwitchProjectToggle}
1420+
{quotaSwitchPreviewModelToggle}
1421+
<FieldAnchor fieldId="quotaAntigravityCredits">
1422+
<ToggleRow
1423+
title={t('config_management.visual.sections.quota.antigravity_credits')}
1424+
checked={values.quotaAntigravityCredits}
1425+
disabled={disabled}
1426+
onChange={(quotaAntigravityCredits) => onChange({ quotaAntigravityCredits })}
1427+
/>
1428+
</FieldAnchor>
1429+
</SectionGrid>
1430+
</SectionStack>
13691431
</ConfigSection>
13701432

13711433
<ConfigSection

src/components/config/configSearchIndex.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,15 @@ export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [
159159
labelKey: L('sections.network.routing_strategy'),
160160
hintKey: L('sections.network.routing_strategy_hint'),
161161
yamlKeys: ['routing', 'strategy'],
162-
keywords: ['round-robin', 'fill-first'],
162+
keywords: ['round-robin', 'fill-first', 'sticky-round-robin', 'sticky'],
163+
},
164+
{
165+
fieldId: 'routingStickyTTL',
166+
sectionId: 'network',
167+
labelKey: L('sections.network.sticky_ttl'),
168+
hintKey: L('sections.network.sticky_ttl_hint'),
169+
yamlKeys: ['routing', 'sticky-ttl'],
170+
keywords: ['sticky', 'ttl'],
163171
},
164172
{
165173
fieldId: 'disableImageGeneration',
@@ -265,6 +273,14 @@ export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [
265273
yamlKeys: ['usage-statistics-enabled'],
266274
},
267275
// ── quota ─────────────────────────────────────────────────────────────────
276+
{
277+
fieldId: 'quotaCacheRefreshInterval',
278+
sectionId: 'quota',
279+
labelKey: L('sections.quota.refresh_interval'),
280+
hintKey: L('sections.quota.refresh_interval_hint'),
281+
yamlKeys: ['quota-cache-refresh-interval'],
282+
keywords: ['quota', 'refresh', 'interval'],
283+
},
268284
{
269285
fieldId: 'quotaSwitchProject',
270286
sectionId: 'quota',

src/components/providers/hooks/useProviderRecentRequests.ts

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { useCallback, useEffect, useState } from 'react';
22
import { useInterval } from '@/hooks/useInterval';
3-
import { apiKeyUsageApi } from '@/services/api';
3+
import { apiKeyUsageApi, usageApi } from '@/services/api';
4+
import { normalizeAuthIndex, normalizeUsageSourceId } from '@/utils/usage';
45
import {
56
normalizeRecentRequestUsageEntry,
67
type ApiKeyUsageResponse,
8+
type RecentRequestBucket,
79
type RecentRequestUsageEntry,
810
} from '@/utils/recentRequests';
911

1012
const PROVIDER_RECENT_REQUESTS_STALE_TIME_MS = 240_000;
13+
const USAGE_FALLBACK_PROVIDER_KEY = '__usage_fallback__';
14+
const RECENT_REQUEST_BLOCK_COUNT = 20;
15+
const RECENT_REQUEST_BLOCK_DURATION_MS = 10 * 60 * 1000;
1116

1217
export type ProviderRecentRequests = Map<string, Map<string, RecentRequestUsageEntry>>;
1318

@@ -26,6 +31,30 @@ const normalizeProviderKey = (value: unknown): string =>
2631
.trim()
2732
.toLowerCase();
2833

34+
const isRecord = (value: unknown): value is Record<string, unknown> =>
35+
Boolean(value && typeof value === 'object' && !Array.isArray(value));
36+
37+
const parseTimestampMs = (value: unknown): number | null => {
38+
if (typeof value !== 'string' || !value.trim()) return null;
39+
const parsed = Date.parse(value);
40+
return Number.isFinite(parsed) ? parsed : null;
41+
};
42+
43+
const createEmptyRecentBuckets = (now: number): RecentRequestBucket[] => {
44+
const windowStart = now - RECENT_REQUEST_BLOCK_COUNT * RECENT_REQUEST_BLOCK_DURATION_MS;
45+
return Array.from({ length: RECENT_REQUEST_BLOCK_COUNT }, (_, index) => ({
46+
time: new Date(windowStart + index * RECENT_REQUEST_BLOCK_DURATION_MS).toISOString(),
47+
success: 0,
48+
failed: 0,
49+
}));
50+
};
51+
52+
const getUsageSnapshotRoot = (payload: unknown): Record<string, unknown> | null => {
53+
const record = isRecord(payload) ? payload : null;
54+
if (!record) return null;
55+
return isRecord(record.usage) ? record.usage : record;
56+
};
57+
2958
const normalizeApiKeyUsageResponse = (payload: ApiKeyUsageResponse): ProviderRecentRequests => {
3059
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
3160
return EMPTY_USAGE_BY_PROVIDER;
@@ -50,15 +79,134 @@ const normalizeApiKeyUsageResponse = (payload: ApiKeyUsageResponse): ProviderRec
5079
return usageByProvider;
5180
};
5281

82+
const ensureUsageFallbackEntry = (
83+
usageByCompositeKey: Map<string, RecentRequestUsageEntry>,
84+
compositeKey: string,
85+
now: number
86+
): RecentRequestUsageEntry => {
87+
const existing = usageByCompositeKey.get(compositeKey);
88+
if (existing) return existing;
89+
const created: RecentRequestUsageEntry = {
90+
success: 0,
91+
failed: 0,
92+
recentRequests: createEmptyRecentBuckets(now),
93+
};
94+
usageByCompositeKey.set(compositeKey, created);
95+
return created;
96+
};
97+
98+
const addUsageFallbackDetail = (
99+
usageByCompositeKey: Map<string, RecentRequestUsageEntry>,
100+
compositeKey: string,
101+
failed: boolean,
102+
timestampMs: number | null,
103+
now: number
104+
) => {
105+
const entry = ensureUsageFallbackEntry(usageByCompositeKey, compositeKey, now);
106+
if (failed) {
107+
entry.failed += 1;
108+
} else {
109+
entry.success += 1;
110+
}
111+
112+
if (timestampMs === null) return;
113+
const windowStart = now - RECENT_REQUEST_BLOCK_COUNT * RECENT_REQUEST_BLOCK_DURATION_MS;
114+
const bucketIndex = Math.floor((timestampMs - windowStart) / RECENT_REQUEST_BLOCK_DURATION_MS);
115+
if (bucketIndex < 0 || bucketIndex >= RECENT_REQUEST_BLOCK_COUNT) return;
116+
117+
const bucket = entry.recentRequests[bucketIndex];
118+
if (!bucket) return;
119+
if (failed) {
120+
bucket.failed += 1;
121+
} else {
122+
bucket.success += 1;
123+
}
124+
};
125+
126+
const normalizeUsageFallbackResponse = (
127+
payload: unknown,
128+
now = Date.now()
129+
): ProviderRecentRequests => {
130+
const usageRoot = getUsageSnapshotRoot(payload);
131+
const apis = isRecord(usageRoot?.apis) ? usageRoot.apis : null;
132+
if (!apis) return EMPTY_USAGE_BY_PROVIDER;
133+
134+
const usageByCompositeKey = new Map<string, RecentRequestUsageEntry>();
135+
136+
Object.values(apis).forEach((apiEntry) => {
137+
if (!isRecord(apiEntry) || !isRecord(apiEntry.models)) return;
138+
139+
Object.values(apiEntry.models).forEach((modelEntry) => {
140+
if (!isRecord(modelEntry) || !Array.isArray(modelEntry.details)) return;
141+
142+
modelEntry.details.forEach((detail) => {
143+
if (!isRecord(detail)) return;
144+
const candidateKeys = new Set<string>();
145+
const sourceKey = normalizeUsageSourceId(detail.source);
146+
const authIndexKey = normalizeAuthIndex(detail.auth_index);
147+
if (sourceKey) candidateKeys.add(sourceKey);
148+
if (authIndexKey) candidateKeys.add(authIndexKey);
149+
if (candidateKeys.size === 0) return;
150+
151+
const failed = detail.failed === true;
152+
const timestampMs = parseTimestampMs(detail.timestamp);
153+
candidateKeys.forEach((candidateKey) =>
154+
addUsageFallbackDetail(usageByCompositeKey, candidateKey, failed, timestampMs, now)
155+
);
156+
});
157+
});
158+
});
159+
160+
if (usageByCompositeKey.size === 0) {
161+
return EMPTY_USAGE_BY_PROVIDER;
162+
}
163+
164+
const usageByProvider: ProviderRecentRequests = new Map();
165+
usageByProvider.set(USAGE_FALLBACK_PROVIDER_KEY, usageByCompositeKey);
166+
return usageByProvider;
167+
};
168+
169+
const mergeProviderRecentRequests = (
170+
primary: ProviderRecentRequests,
171+
fallback: ProviderRecentRequests
172+
): ProviderRecentRequests => {
173+
if (fallback.size === 0) return primary;
174+
if (primary.size === 0) return fallback;
175+
176+
const merged: ProviderRecentRequests = new Map(primary);
177+
fallback.forEach((fallbackEntries, provider) => {
178+
const providerKey = normalizeProviderKey(provider);
179+
const nextEntries = new Map(merged.get(providerKey) ?? []);
180+
fallbackEntries.forEach((entry, compositeKey) => {
181+
if (!nextEntries.has(compositeKey)) {
182+
nextEntries.set(compositeKey, entry);
183+
}
184+
});
185+
merged.set(providerKey, nextEntries);
186+
});
187+
return merged;
188+
};
189+
53190
const fetchProviderRecentRequests = async (): Promise<ProviderRecentRequests> => {
54191
if (!inFlightRequest) {
55-
inFlightRequest = apiKeyUsageApi
56-
.getUsage()
57-
.then((payload) => {
58-
const normalized = normalizeApiKeyUsageResponse(payload);
59-
cachedUsageByProvider = normalized;
192+
inFlightRequest = Promise.allSettled([apiKeyUsageApi.getUsage(), usageApi.getUsage()])
193+
.then(([apiKeyUsageResult, usageResult]) => {
194+
if (apiKeyUsageResult.status === 'rejected' && usageResult.status === 'rejected') {
195+
throw apiKeyUsageResult.reason;
196+
}
197+
const primary =
198+
apiKeyUsageResult.status === 'fulfilled'
199+
? normalizeApiKeyUsageResponse(apiKeyUsageResult.value)
200+
: EMPTY_USAGE_BY_PROVIDER;
201+
// `/api-key-usage` 是主数据源;`/usage` 只作为历史/裁剪场景的补洞来源。
202+
const fallback =
203+
usageResult.status === 'fulfilled'
204+
? normalizeUsageFallbackResponse(usageResult.value)
205+
: EMPTY_USAGE_BY_PROVIDER;
206+
const merged = mergeProviderRecentRequests(primary, fallback);
207+
cachedUsageByProvider = merged;
60208
cachedAt = Date.now();
61-
return normalized;
209+
return merged;
62210
})
63211
.finally(() => {
64212
inFlightRequest = null;

0 commit comments

Comments
 (0)