Compare commits

..

12 Commits

23 changed files with 1107 additions and 149 deletions
+30 -6
View File
@@ -426,8 +426,24 @@ function PayloadRulesEditor({
protocolFirst?: boolean;
onChange: (next: PayloadRule[]) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const payloadValueTypeOptions = useMemo(
() =>
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -533,7 +549,7 @@ function PayloadRulesEditor({
<>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -561,7 +577,7 @@ function PayloadRulesEditor({
/>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -603,7 +619,7 @@ function PayloadRulesEditor({
/>
<ToastSelect
value={param.valueType}
options={VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS}
options={payloadValueTypeOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.param_type')}
onChange={(nextValue) =>
@@ -669,8 +685,16 @@ function PayloadFilterRulesEditor({
disabled?: boolean;
onChange: (next: PayloadFilterRule[]) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -738,7 +762,7 @@ function PayloadFilterRulesEditor({
/>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -87,7 +87,7 @@ export function OpenAISection({
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(item) => item.name}
keyField={(_, index) => `openai-provider-${index}`}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
+2 -2
View File
@@ -6,7 +6,7 @@ import { EmptyState } from '@/components/ui/EmptyState';
interface ProviderListProps<T> {
items: T[];
loading: boolean;
keyField: (item: T) => string;
keyField: (item: T, index: number) => string;
renderContent: (item: T, index: number) => ReactNode;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
@@ -48,7 +48,7 @@ export function ProviderList<T>({
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
return (
<div
key={keyField(item)}
key={keyField(item, index)}
className="item-row"
style={rowDisabled ? { opacity: 0.6 } : undefined}
>
+1 -1
View File
@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';
+155 -1
View File
@@ -10,6 +10,10 @@ import type {
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
ClaudeExtraUsage,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
CodexRateLimitInfo,
CodexQuotaState,
CodexUsageWindow,
@@ -23,6 +27,9 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CLAUDE_USAGE_URL,
CLAUDE_REQUEST_HEADERS,
CLAUDE_USAGE_WINDOW_KEYS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
@@ -34,6 +41,7 @@ import {
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseClaudeUsagePayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId,
@@ -46,6 +54,7 @@ import {
createStatusError,
getStatusFromError,
isAntigravityFile,
isClaudeFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
@@ -56,15 +65,17 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -558,6 +569,149 @@ const renderGeminiCliItems = (
});
};
const buildClaudeQuotaWindows = (
payload: ClaudeUsagePayload,
t: TFunction
): ClaudeQuotaWindow[] => {
const windows: ClaudeQuotaWindow[] = [];
for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
const window = payload[key as keyof ClaudeUsagePayload];
if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
const typedWindow = window as { utilization: number; resets_at: string };
const usedPercent = normalizeNumberValue(typedWindow.utilization);
const resetLabel = formatQuotaResetTime(typedWindow.resets_at);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel,
});
}
return windows;
};
const fetchClaudeQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('claude_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_USAGE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('claude_quota.empty_windows'));
}
const windows = buildClaudeQuotaWindows(payload, t);
return { windows, extraUsage: payload.extra_usage };
};
const renderClaudeItems = (
quota: ClaudeQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const extraUsage = quota.extraUsage ?? null;
const nodes: ReactNode[] = [];
if (extraUsage && extraUsage.is_enabled) {
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
nodes.push(
h(
'div',
{ key: 'extra', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')),
h('span', { className: styleMap.codexPlanValue }, usedLabel)
)
);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
export const CLAUDE_CONFIG: QuotaConfig<
ClaudeQuotaState,
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
> = {
type: 'claude',
i18nPrefix: 'claude_quota',
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchClaudeQuota,
storeSelector: (state) => state.claudeQuota,
storeSetter: 'setClaudeQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
extraUsage: data.extraUsage,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.claudeCard,
controlsClassName: styles.claudeControls,
controlClassName: styles.claudeControl,
gridClassName: styles.claudeGrid,
renderQuotaItems: renderClaudeItems,
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',
+7 -5
View File
@@ -9,6 +9,7 @@ export interface UseChartDataOptions {
chartLines: string[];
isDark: boolean;
isMobile: boolean;
hourWindowHours?: number;
}
export interface UseChartDataReturn {
@@ -26,20 +27,21 @@ export function useChartData({
usage,
chartLines,
isDark,
isMobile
isMobile,
hourWindowHours
}: UseChartDataOptions): UseChartDataReturn {
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
const requestsChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
}, [usage, requestsPeriod, chartLines]);
return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
}, [usage, requestsPeriod, chartLines, hourWindowHours]);
const tokensChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
}, [usage, tokensPeriod, chartLines]);
return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
}, [usage, tokensPeriod, chartLines, hourWindowHours]);
const requestsChartOptions = useMemo(
() =>
+175 -96
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
import type {
PayloadFilterRule,
PayloadParamValueType,
@@ -8,10 +8,6 @@ import type {
} from '@/types/visualConfig';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -48,53 +44,58 @@ function parseApiKeysText(raw: unknown): string {
return keys.join('\n');
}
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asRecord(parent[key]);
if (existing) return existing;
const next: Record<string, unknown> = {};
parent[key] = next;
return next;
type YamlDocument = ReturnType<typeof parseDocument>;
type YamlPath = string[];
function docHas(doc: YamlDocument, path: YamlPath): boolean {
return doc.hasIn(path);
}
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
const value = asRecord(parent[key]);
if (!value) return;
if (Object.keys(value).length === 0) delete parent[key];
function ensureMapInDoc(doc: YamlDocument, path: YamlPath): void {
const existing = doc.getIn(path, true);
if (isMap(existing)) return;
doc.setIn(path, {});
}
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void {
function deleteIfMapEmpty(doc: YamlDocument, path: YamlPath): void {
const value = doc.getIn(path, true);
if (!isMap(value)) return;
if (value.items.length === 0) doc.deleteIn(path);
}
function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): void {
if (value) {
obj[key] = true;
doc.setIn(path, true);
return;
}
if (hasOwn(obj, key)) obj[key] = false;
if (docHas(doc, path)) doc.setIn(path, false);
}
function setString(obj: Record<string, unknown>, key: string, value: unknown): void {
function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed !== '') {
obj[key] = safe;
doc.setIn(path, safe);
return;
}
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
}
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void {
function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed === '') {
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isFinite(parsed)) {
obj[key] = parsed;
doc.setIn(path, parsed);
return;
}
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
}
function deepClone<T>(value: T): T {
@@ -351,78 +352,95 @@ export function useVisualConfig() {
const applyVisualChangesToYaml = useCallback(
(currentYaml: string): string => {
try {
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>;
const doc = parseDocument(currentYaml);
if (doc.errors.length > 0) return currentYaml;
if (!isMap(doc.contents)) {
doc.contents = doc.createNode({}) as unknown as typeof doc.contents;
}
const values = visualValues;
setString(parsed, 'host', values.host);
setIntFromString(parsed, 'port', values.port);
setStringInDoc(doc, ['host'], values.host);
setIntFromStringInDoc(doc, ['port'], values.port);
if (
hasOwn(parsed, 'tls') ||
docHas(doc, ['tls']) ||
values.tlsEnable ||
values.tlsCert.trim() ||
values.tlsKey.trim()
) {
const tls = ensureRecord(parsed, 'tls');
setBoolean(tls, 'enable', values.tlsEnable);
setString(tls, 'cert', values.tlsCert);
setString(tls, 'key', values.tlsKey);
deleteIfEmpty(parsed, 'tls');
ensureMapInDoc(doc, ['tls']);
setBooleanInDoc(doc, ['tls', 'enable'], values.tlsEnable);
setStringInDoc(doc, ['tls', 'cert'], values.tlsCert);
setStringInDoc(doc, ['tls', 'key'], values.tlsKey);
deleteIfMapEmpty(doc, ['tls']);
}
if (
hasOwn(parsed, 'remote-management') ||
docHas(doc, ['remote-management']) ||
values.rmAllowRemote ||
values.rmSecretKey.trim() ||
values.rmDisableControlPanel ||
values.rmPanelRepo.trim()
) {
const rm = ensureRecord(parsed, 'remote-management');
setBoolean(rm, 'allow-remote', values.rmAllowRemote);
setString(rm, 'secret-key', values.rmSecretKey);
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel);
setString(rm, 'panel-github-repository', values.rmPanelRepo);
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo'];
deleteIfEmpty(parsed, 'remote-management');
ensureMapInDoc(doc, ['remote-management']);
setBooleanInDoc(doc, ['remote-management', 'allow-remote'], values.rmAllowRemote);
setStringInDoc(doc, ['remote-management', 'secret-key'], values.rmSecretKey);
setBooleanInDoc(
doc,
['remote-management', 'disable-control-panel'],
values.rmDisableControlPanel
);
setStringInDoc(doc, ['remote-management', 'panel-github-repository'], values.rmPanelRepo);
if (docHas(doc, ['remote-management', 'panel-repo'])) {
doc.deleteIn(['remote-management', 'panel-repo']);
}
deleteIfMapEmpty(doc, ['remote-management']);
}
setString(parsed, 'auth-dir', values.authDir);
setStringInDoc(doc, ['auth-dir'], values.authDir);
if (values.apiKeysText !== baselineValues.apiKeysText) {
const apiKeys = values.apiKeysText
.split('\n')
.map((key) => key.trim())
.filter(Boolean);
if (apiKeys.length > 0) {
parsed['api-keys'] = apiKeys;
} else if (hasOwn(parsed, 'api-keys')) {
delete parsed['api-keys'];
doc.setIn(['api-keys'], apiKeys);
} else if (docHas(doc, ['api-keys'])) {
doc.deleteIn(['api-keys']);
}
}
setBoolean(parsed, 'debug', values.debug);
setBooleanInDoc(doc, ['debug'], values.debug);
setBoolean(parsed, 'commercial-mode', values.commercialMode);
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
setString(parsed, 'proxy-url', values.proxyUrl);
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
setIntFromString(parsed, 'request-retry', values.requestRetry);
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval);
setBoolean(parsed, 'ws-auth', values.wsAuth);
setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) {
const quota = ensureRecord(parsed, 'quota-exceeded');
quota['switch-project'] = values.quotaSwitchProject;
quota['switch-preview-model'] = values.quotaSwitchPreviewModel;
deleteIfEmpty(parsed, 'quota-exceeded');
if (
docHas(doc, ['quota-exceeded']) ||
!values.quotaSwitchProject ||
!values.quotaSwitchPreviewModel
) {
ensureMapInDoc(doc, ['quota-exceeded']);
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(
['quota-exceeded', 'switch-preview-model'],
values.quotaSwitchPreviewModel
);
deleteIfMapEmpty(doc, ['quota-exceeded']);
}
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') {
const routing = ensureRecord(parsed, 'routing');
routing.strategy = values.routingStrategy;
deleteIfEmpty(parsed, 'routing');
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
ensureMapInDoc(doc, ['routing']);
doc.setIn(['routing', 'strategy'], values.routingStrategy);
deleteIfMapEmpty(doc, ['routing']);
}
const keepaliveSeconds =
@@ -435,42 +453,55 @@ export function useVisualConfig() {
: '';
const streamingDefined =
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim();
docHas(doc, ['streaming']) || keepaliveSeconds.trim() || bootstrapRetries.trim();
if (streamingDefined) {
const streaming = ensureRecord(parsed, 'streaming');
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds);
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries);
deleteIfEmpty(parsed, 'streaming');
ensureMapInDoc(doc, ['streaming']);
setIntFromStringInDoc(doc, ['streaming', 'keepalive-seconds'], keepaliveSeconds);
setIntFromStringInDoc(doc, ['streaming', 'bootstrap-retries'], bootstrapRetries);
deleteIfMapEmpty(doc, ['streaming']);
}
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval);
setIntFromStringInDoc(
doc,
['nonstream-keepalive-interval'],
nonstreamKeepaliveInterval
);
if (
hasOwn(parsed, 'payload') ||
docHas(doc, ['payload']) ||
values.payloadDefaultRules.length > 0 ||
values.payloadOverrideRules.length > 0 ||
values.payloadFilterRules.length > 0
) {
const payload = ensureRecord(parsed, 'payload');
ensureMapInDoc(doc, ['payload']);
if (values.payloadDefaultRules.length > 0) {
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules);
} else if (hasOwn(payload, 'default')) {
delete payload.default;
doc.setIn(
['payload', 'default'],
serializePayloadRulesForYaml(values.payloadDefaultRules)
);
} else if (docHas(doc, ['payload', 'default'])) {
doc.deleteIn(['payload', 'default']);
}
if (values.payloadOverrideRules.length > 0) {
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules);
} else if (hasOwn(payload, 'override')) {
delete payload.override;
doc.setIn(
['payload', 'override'],
serializePayloadRulesForYaml(values.payloadOverrideRules)
);
} else if (docHas(doc, ['payload', 'override'])) {
doc.deleteIn(['payload', 'override']);
}
if (values.payloadFilterRules.length > 0) {
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules);
} else if (hasOwn(payload, 'filter')) {
delete payload.filter;
doc.setIn(
['payload', 'filter'],
serializePayloadFilterRulesForYaml(values.payloadFilterRules)
);
} else if (docHas(doc, ['payload', 'filter'])) {
doc.deleteIn(['payload', 'filter']);
}
deleteIfEmpty(parsed, 'payload');
deleteIfMapEmpty(doc, ['payload']);
}
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 });
return doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch {
return currentYaml;
}
@@ -498,18 +529,66 @@ export function useVisualConfig() {
}
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
{ value: '', label: '默认' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'openai-response', label: 'OpenAI Response' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'claude', label: 'Claude' },
{ value: 'codex', label: 'Codex' },
{ value: 'antigravity', label: 'Antigravity' },
{
value: '',
labelKey: 'config_management.visual.payload_rules.provider_default',
defaultLabel: 'Default',
},
{
value: 'openai',
labelKey: 'config_management.visual.payload_rules.provider_openai',
defaultLabel: 'OpenAI',
},
{
value: 'openai-response',
labelKey: 'config_management.visual.payload_rules.provider_openai_response',
defaultLabel: 'OpenAI Response',
},
{
value: 'gemini',
labelKey: 'config_management.visual.payload_rules.provider_gemini',
defaultLabel: 'Gemini',
},
{
value: 'claude',
labelKey: 'config_management.visual.payload_rules.provider_claude',
defaultLabel: 'Claude',
},
{
value: 'codex',
labelKey: 'config_management.visual.payload_rules.provider_codex',
defaultLabel: 'Codex',
},
{
value: 'antigravity',
labelKey: 'config_management.visual.payload_rules.provider_antigravity',
defaultLabel: 'Antigravity',
},
] as const;
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
{ value: 'string', label: '字符串' },
{ value: 'number', label: '数字' },
{ value: 'boolean', label: '布尔' },
{ value: 'json', label: 'JSON' },
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>;
{
value: 'string',
labelKey: 'config_management.visual.payload_rules.value_type_string',
defaultLabel: 'String',
},
{
value: 'number',
labelKey: 'config_management.visual.payload_rules.value_type_number',
defaultLabel: 'Number',
},
{
value: 'boolean',
labelKey: 'config_management.visual.payload_rules.value_type_boolean',
defaultLabel: 'Boolean',
},
{
value: 'json',
labelKey: 'config_management.visual.payload_rules.value_type_json',
defaultLabel: 'JSON',
},
] as const satisfies ReadonlyArray<{
value: PayloadParamValueType;
labelKey: string;
defaultLabel: string;
}>;
+36
View File
@@ -443,6 +443,26 @@
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All"
},
"claude_quota": {
"title": "Claude Quota",
"empty_title": "No Claude OAuth Files",
"empty_desc": "Log in with Claude OAuth to view quota.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_windows": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"five_hour": "5-hour limit",
"seven_day": "7-day limit",
"seven_day_oauth_apps": "7-day OAuth apps",
"seven_day_opus": "7-day Opus",
"seven_day_sonnet": "7-day Sonnet",
"seven_day_cowork": "7-day Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Extra Usage"
},
"codex_quota": {
"title": "Codex Quota",
"empty_title": "No Codex Auth Files",
@@ -730,6 +750,11 @@
"api_details": "API Details",
"by_hour": "By Hour",
"by_day": "By Day",
"range_filter": "Time Range",
"range_all": "All Time",
"range_7h": "Last 7 Hours",
"range_24h": "Last 24 Hours",
"range_7d": "Last 7 Days",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
@@ -975,6 +1000,17 @@
"add_param": "Add Parameter",
"no_rules": "No rules",
"add_rule": "Add Rule",
"provider_default": "Default",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "String",
"value_type_number": "Number",
"value_type_boolean": "Boolean",
"value_type_json": "JSON",
"value_string": "String value",
"value_number": "Number value (e.g., 0.7)",
"value_boolean": "true or false",
+36
View File
@@ -446,6 +446,26 @@
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все"
},
"claude_quota": {
"title": "Квота Claude",
"empty_title": "Файлы авторизации Claude OAuth отсутствуют",
"empty_desc": "Войдите через Claude OAuth, чтобы увидеть квоту.",
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
"loading": "Загрузка квоты...",
"load_failed": "Не удалось загрузить квоту: {{message}}",
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_windows": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все",
"five_hour": "Лимит на 5 часов",
"seven_day": "Лимит на 7 дней",
"seven_day_oauth_apps": "7 дней OAuth приложения",
"seven_day_opus": "7 дней Opus",
"seven_day_sonnet": "7 дней Sonnet",
"seven_day_cowork": "7 дней Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Дополнительное использование"
},
"codex_quota": {
"title": "Квота Codex",
"empty_title": "Файлы авторизации Codex отсутствуют",
@@ -733,6 +753,11 @@
"api_details": "Детали API",
"by_hour": "По часам",
"by_day": "По дням",
"range_filter": "Диапазон времени",
"range_all": "За всё время",
"range_7h": "Последние 7 часов",
"range_24h": "Последние 24 часа",
"range_7d": "Последние 7 дней",
"refresh": "Обновить",
"export": "Экспорт",
"import": "Импорт",
@@ -980,6 +1005,17 @@
"add_param": "Добавить параметр",
"no_rules": "Правил нет",
"add_rule": "Добавить правило",
"provider_default": "По умолчанию",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "Строка",
"value_type_number": "Число",
"value_type_boolean": "Булево",
"value_type_json": "JSON",
"value_string": "Строковое значение",
"value_number": "Числовое значение (например, 0.7)",
"value_boolean": "true или false",
+36
View File
@@ -443,6 +443,26 @@
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
},
"claude_quota": {
"title": "Claude 额度",
"empty_title": "暂无 Claude OAuth 认证",
"empty_desc": "使用 Claude OAuth 登录后即可查看额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_windows": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"five_hour": "5 小时限额",
"seven_day": "7 天限额",
"seven_day_oauth_apps": "7 天 OAuth 应用",
"seven_day_opus": "7 天 Opus",
"seven_day_sonnet": "7 天 Sonnet",
"seven_day_cowork": "7 天 Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "额外用量"
},
"codex_quota": {
"title": "Codex 额度",
"empty_title": "暂无 Codex 认证",
@@ -730,6 +750,11 @@
"api_details": "API 详细统计",
"by_hour": "按小时",
"by_day": "按天",
"range_filter": "时间范围",
"range_all": "全部时间",
"range_7h": "最近7小时",
"range_24h": "最近24小时",
"range_7d": "最近7天",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
@@ -975,6 +1000,17 @@
"add_param": "添加参数",
"no_rules": "暂无规则",
"add_rule": "添加规则",
"provider_default": "默认",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "字符串",
"value_type_number": "数字",
"value_type_boolean": "布尔",
"value_type_json": "JSON",
"value_string": "字符串值",
"value_number": "数字值 (如 0.7)",
"value_boolean": "true 或 false",
+13 -7
View File
@@ -77,8 +77,6 @@ export function AiProvidersOpenAIEditLayout() {
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
@@ -335,9 +333,18 @@ export function AiProvidersOpenAIEditLayout() {
: [...providers, payload];
await providersApi.saveOpenAIProviders(nextList);
setProviders(nextList);
updateConfigValue('openai-compatibility', nextList);
clearCache('openai-compatibility');
let syncedProviders = nextList;
try {
const latest = await fetchConfig('openai-compatibility', true);
if (Array.isArray(latest)) {
syncedProviders = latest as OpenAIProviderConfig[];
}
} catch {
// 保存成功后刷新失败时,回退到本地计算结果,避免页面数据为空或回退
}
setProviders(syncedProviders);
showNotification(
editIndex !== null
? t('notification.openai_provider_updated')
@@ -351,15 +358,14 @@ export function AiProvidersOpenAIEditLayout() {
setSaving(false);
}
}, [
clearCache,
editIndex,
fetchConfig,
form,
handleBack,
providers,
testModel,
showNotification,
t,
updateConfigValue,
]);
const resolvedLoading = !draft?.initialized;
+46 -10
View File
@@ -170,6 +170,34 @@ const writeAuthFilesUiState = (state: AuthFilesUiState) => {
}
};
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
return copied;
} catch {
return false;
}
};
interface PrefixProxyEditorState {
fileName: string;
loading: boolean;
@@ -1016,14 +1044,28 @@ export function AuthFilesPage() {
}
};
const copyTextWithNotification = async (text: string) => {
const copied = await copyToClipboard(text);
showNotification(
copied
? t('notification.link_copied', { defaultValue: 'Copied to clipboard' })
: t('notification.copy_failed', { defaultValue: 'Copy failed' }),
copied ? 'success' : 'error'
);
};
// 检查模型是否被 OAuth 排除
const isModelExcluded = (modelId: string, providerType: string): boolean => {
const providerKey = normalizeProviderKey(providerType);
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
return excludedModels.some((pattern) => {
if (pattern.includes('*')) {
// 支持通配符匹配
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
const regexSafePattern = pattern
.split('*')
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
return regex.test(modelId);
}
return pattern.toLowerCase() === modelId.toLowerCase();
@@ -2046,9 +2088,7 @@ export function AuthFilesPage() {
onClick={() => {
if (selectedFile) {
const text = JSON.stringify(selectedFile, null, 2);
navigator.clipboard.writeText(text).then(() => {
showNotification(t('notification.link_copied'), 'success');
});
void copyTextWithNotification(text);
}
}}
>
@@ -2104,11 +2144,7 @@ export function AuthFilesPage() {
key={model.id}
className={`${styles.modelItem} ${isExcluded ? styles.modelItemExcluded : ''}`}
onClick={() => {
navigator.clipboard.writeText(model.id);
showNotification(
t('notification.link_copied', { defaultValue: '已复制到剪贴板' }),
'success'
);
void copyTextWithNotification(model.id);
}}
title={
isExcluded
+16
View File
@@ -400,6 +400,8 @@ export function LogsPage() {
startY: number;
fired: boolean;
} | null>(null);
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
@@ -424,6 +426,15 @@ export function LogsPage() {
return;
}
if (logRequestInFlightRef.current) {
if (!incremental) {
pendingFullReloadRef.current = true;
}
return;
}
logRequestInFlightRef.current = true;
if (!incremental) {
setLoading(true);
}
@@ -474,6 +485,11 @@ export function LogsPage() {
if (!incremental) {
setLoading(false);
}
logRequestInFlightRef.current = false;
if (pendingFullReloadRef.current) {
pendingFullReloadRef.current = false;
void loadLogs(false);
}
}
};
+9
View File
@@ -103,6 +103,7 @@
}
.antigravityGrid,
.claudeGrid,
.codexGrid,
.geminiCliGrid {
display: grid;
@@ -115,6 +116,7 @@
}
.antigravityControls,
.claudeControls,
.codexControls,
.geminiCliControls {
display: flex;
@@ -125,6 +127,7 @@
}
.antigravityControl,
.claudeControl,
.codexControl,
.geminiCliControl {
display: flex;
@@ -145,6 +148,12 @@
align-items: center;
}
.claudeCard {
background-image: linear-gradient(180deg,
rgba(252, 228, 236, 0.18),
rgba(252, 228, 236, 0));
}
.antigravityCard {
background-image: linear-gradient(180deg,
rgba(224, 247, 250, 0.12),
+7
View File
@@ -10,6 +10,7 @@ import { authFilesApi, configFileApi } from '@/services/api';
import {
QuotaSection,
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG
} from '@/components/quota';
@@ -69,6 +70,12 @@ export function QuotaPage() {
{error && <div className={styles.errorBox}>{error}</div>}
<QuotaSection
config={CLAUDE_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={ANTIGRAVITY_CONFIG}
files={files}
+109
View File
@@ -25,6 +25,115 @@
flex-wrap: wrap;
}
.timeRangeGroup {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.timeRangeLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.timeRangeSelectWrap {
position: relative;
display: inline-flex;
align-items: center;
}
.timeRangeSelect {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 164px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
.timeRangeSelectedText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeRangeSelectIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.timeRangeDropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
}
.timeRangeOption {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
}
.timeRangeOptionActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
font-weight: 600;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
+171 -10
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -13,6 +13,7 @@ import {
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronDown } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore } from '@/stores';
@@ -27,7 +28,13 @@ import {
useSparklines,
useChartData
} from '@/components/usage';
import { getModelNamesFromUsage, getApiStats, getModelStats } from '@/utils/usage';
import {
getModelNamesFromUsage,
getApiStats,
getModelStats,
filterUsageByTimeRange,
type UsageTimeRange
} from '@/utils/usage';
import styles from './UsagePage.module.scss';
// Register Chart.js components
@@ -42,12 +49,86 @@ ChartJS.register(
Filler
);
const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1';
const TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-time-range-v1';
const DEFAULT_CHART_LINES = ['all'];
const DEFAULT_TIME_RANGE: UsageTimeRange = '24h';
const MAX_CHART_LINES = 9;
const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: string }> = [
{ value: 'all', labelKey: 'usage_stats.range_all' },
{ value: '7h', labelKey: 'usage_stats.range_7h' },
{ value: '24h', labelKey: 'usage_stats.range_24h' },
{ value: '7d', labelKey: 'usage_stats.range_7d' },
];
const HOUR_WINDOW_BY_TIME_RANGE: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7,
'24h': 24,
'7d': 7 * 24
};
const isUsageTimeRange = (value: unknown): value is UsageTimeRange =>
value === '7h' || value === '24h' || value === '7d' || value === 'all';
const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => {
if (!Array.isArray(value)) {
return DEFAULT_CHART_LINES;
}
const filtered = value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
.slice(0, maxLines);
return filtered.length ? filtered : DEFAULT_CHART_LINES;
};
const loadChartLines = (): string[] => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_CHART_LINES;
}
const raw = localStorage.getItem(CHART_LINES_STORAGE_KEY);
if (!raw) {
return DEFAULT_CHART_LINES;
}
return normalizeChartLines(JSON.parse(raw));
} catch {
return DEFAULT_CHART_LINES;
}
};
const loadTimeRange = (): UsageTimeRange => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_TIME_RANGE;
}
const raw = localStorage.getItem(TIME_RANGE_STORAGE_KEY);
return isUsageTimeRange(raw) ? raw : DEFAULT_TIME_RANGE;
} catch {
return DEFAULT_TIME_RANGE;
}
};
export function UsagePage() {
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)');
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
// Time range dropdown
const [timeRangeOpen, setTimeRangeOpen] = useState(false);
const timeRangeRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!timeRangeOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!timeRangeRef.current?.contains(event.target as Node)) setTimeRangeOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [timeRangeOpen]);
// Data hook
const {
usage,
@@ -67,8 +148,41 @@ export function UsagePage() {
useHeaderRefresh(loadUsage);
// Chart lines state
const [chartLines, setChartLines] = useState<string[]>(['all']);
const MAX_CHART_LINES = 9;
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
const filteredUsage = useMemo(
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
[usage, timeRange]
);
const hourWindowHours =
timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
const handleChartLinesChange = useCallback((lines: string[]) => {
setChartLines(normalizeChartLines(lines));
}, []);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(CHART_LINES_STORAGE_KEY, JSON.stringify(chartLines));
} catch {
// Ignore storage errors.
}
}, [chartLines]);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange);
} catch {
// Ignore storage errors.
}
}, [timeRange]);
// Sparklines hook
const {
@@ -77,7 +191,7 @@ export function UsagePage() {
rpmSparkline,
tpmSparkline,
costSparkline
} = useSparklines({ usage, loading });
} = useSparklines({ usage: filteredUsage, loading });
// Chart data hook
const {
@@ -89,12 +203,18 @@ export function UsagePage() {
tokensChartData,
requestsChartOptions,
tokensChartOptions
} = useChartData({ usage, chartLines, isDark, isMobile });
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
// Derived data
const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]);
const apiStats = useMemo(() => getApiStats(usage, modelPrices), [usage, modelPrices]);
const modelStats = useMemo(() => getModelStats(usage, modelPrices), [usage, modelPrices]);
const apiStats = useMemo(
() => getApiStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const modelStats = useMemo(
() => getModelStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const hasPrices = Object.keys(modelPrices).length > 0;
return (
@@ -111,6 +231,47 @@ export function UsagePage() {
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<div className={styles.headerActions}>
<div className={styles.timeRangeGroup}>
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
<div className={styles.timeRangeSelectWrap} ref={timeRangeRef}>
<button
type="button"
className={styles.timeRangeSelect}
onClick={() => setTimeRangeOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={timeRangeOpen}
>
<span className={styles.timeRangeSelectedText}>
{t(TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.labelKey ?? 'usage_stats.range_24h')}
</span>
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{timeRangeOpen && (
<div className={styles.timeRangeDropdown} role="listbox" aria-label={t('usage_stats.range_filter')}>
{TIME_RANGE_OPTIONS.map((opt) => {
const active = opt.value === timeRange;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.timeRangeOption} ${active ? styles.timeRangeOptionActive : ''}`}
onClick={() => {
setTimeRange(opt.value);
setTimeRangeOpen(false);
}}
>
{t(opt.labelKey)}
</button>
);
})}
</div>
)}
</div>
</div>
<Button
variant="secondary"
size="sm"
@@ -151,7 +312,7 @@ export function UsagePage() {
{/* Stats Overview Cards */}
<StatCards
usage={usage}
usage={filteredUsage}
loading={loading}
modelPrices={modelPrices}
sparklines={{
@@ -168,7 +329,7 @@ export function UsagePage() {
chartLines={chartLines}
modelNames={modelNames}
maxLines={MAX_CHART_LINES}
onChange={setChartLines}
onChange={handleChartLinesChange}
/>
{/* Charts Grid */}
+9 -1
View File
@@ -3,15 +3,17 @@
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -26,12 +28,17 @@ const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
})),
setClaudeQuota: (updater) =>
set((state) => ({
claudeQuota: resolveUpdater(updater, state.claudeQuota)
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
@@ -43,6 +50,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
clearQuotaCache: () =>
set({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {}
})
+40
View File
@@ -97,6 +97,46 @@ export interface CodexUsagePayload {
codeReviewRateLimit?: CodexRateLimitInfo | null;
}
// Claude API payload types
export interface ClaudeUsageWindow {
utilization: number;
resets_at: string;
}
export interface ClaudeExtraUsage {
is_enabled: boolean;
monthly_limit: number;
used_credits: number;
utilization: number | null;
}
export interface ClaudeUsagePayload {
five_hour?: ClaudeUsageWindow | null;
seven_day?: ClaudeUsageWindow | null;
seven_day_oauth_apps?: ClaudeUsageWindow | null;
seven_day_opus?: ClaudeUsageWindow | null;
seven_day_sonnet?: ClaudeUsageWindow | null;
seven_day_cowork?: ClaudeUsageWindow | null;
iguana_necktie?: ClaudeUsageWindow | null;
extra_usage?: ClaudeExtraUsage | null;
}
export interface ClaudeQuotaWindow {
id: string;
label: string;
labelKey?: string;
usedPercent: number | null;
resetLabel: string;
}
export interface ClaudeQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: ClaudeQuotaWindow[];
extraUsage?: ClaudeExtraUsage | null;
error?: string;
errorStatus?: number;
}
// Quota state types
export interface AntigravityQuotaGroup {
id: string;
+19
View File
@@ -151,6 +151,25 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
// Claude API configuration
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
export const CLAUDE_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
'anthropic-beta': 'oauth-2025-04-20',
};
export const CLAUDE_USAGE_WINDOW_KEYS = [
{ key: 'five_hour', id: 'five-hour', labelKey: 'claude_quota.five_hour' },
{ key: 'seven_day', id: 'seven-day', labelKey: 'claude_quota.seven_day' },
{ key: 'seven_day_oauth_apps', id: 'seven-day-oauth-apps', labelKey: 'claude_quota.seven_day_oauth_apps' },
{ key: 'seven_day_opus', id: 'seven-day-opus', labelKey: 'claude_quota.seven_day_opus' },
{ key: 'seven_day_sonnet', id: 'seven-day-sonnet', labelKey: 'claude_quota.seven_day_sonnet' },
{ key: 'seven_day_cowork', id: 'seven-day-cowork', labelKey: 'claude_quota.seven_day_cowork' },
{ key: 'iguana_necktie', id: 'iguana-necktie', labelKey: 'claude_quota.iguana_necktie' },
] as const;
// Codex API configuration
export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
+18 -1
View File
@@ -2,7 +2,7 @@
* Normalization and parsing functions for quota data.
*/
import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
@@ -129,6 +129,23 @@ export function parseAntigravityPayload(payload: unknown): Record<string, unknow
return null;
}
export function parseClaudeUsagePayload(payload: unknown): ClaudeUsagePayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as ClaudeUsagePayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as ClaudeUsagePayload;
}
return null;
}
export function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
+17
View File
@@ -14,6 +14,23 @@ export function isAntigravityFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'antigravity';
}
export function isClaudeFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'claude';
}
export function isClaudeOAuthFile(file: AuthFileItem): boolean {
if (!isClaudeFile(file)) return false;
const metadata =
file && typeof file.metadata === 'object' && file.metadata !== null
? (file.metadata as Record<string, unknown>)
: null;
const accessToken =
metadata && typeof metadata.access_token === 'string'
? metadata.access_token.trim()
: '';
return accessToken.includes('sk-ant-oat');
}
export function isCodexFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'codex';
}
+154 -8
View File
@@ -61,8 +61,15 @@ export interface ApiStats {
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
}
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
const TOKENS_PER_PRICE_UNIT = 1_000_000;
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -73,6 +80,140 @@ const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
return isRecord(apisRaw) ? apisRaw : null;
};
interface UsageSummary {
totalRequests: number;
successCount: number;
failureCount: number;
totalTokens: number;
}
const createUsageSummary = (): UsageSummary => ({
totalRequests: 0,
successCount: 0,
failureCount: 0,
totalTokens: 0
});
const toUsageSummaryFields = (summary: UsageSummary) => ({
total_requests: summary.totalRequests,
success_count: summary.successCount,
failure_count: summary.failureCount,
total_tokens: summary.totalTokens
});
const isDetailWithinWindow = (detail: unknown, windowStart: number, nowMs: number): detail is Record<string, unknown> => {
if (!isRecord(detail) || typeof detail.timestamp !== 'string') {
return false;
}
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
return false;
}
return timestamp >= windowStart && timestamp <= nowMs;
};
const updateSummaryFromDetails = (summary: UsageSummary, details: unknown[]) => {
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
if (!detailRecord) {
return;
}
summary.totalRequests += 1;
if (detailRecord.failed === true) {
summary.failureCount += 1;
} else {
summary.successCount += 1;
}
summary.totalTokens += extractTotalTokens(detailRecord);
});
};
export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
if (range === 'all') {
return usageData;
}
const usageRecord = isRecord(usageData) ? usageData : null;
const apis = getApisRecord(usageData);
if (!usageRecord || !apis) {
return usageData;
}
const rangeMs = USAGE_TIME_RANGE_MS[range];
if (!Number.isFinite(rangeMs) || rangeMs <= 0) {
return usageData;
}
const windowStart = nowMs - rangeMs;
const filteredApis: Record<string, unknown> = {};
const totalSummary = createUsageSummary();
Object.entries(apis).forEach(([apiName, apiEntry]) => {
if (!isRecord(apiEntry)) {
return;
}
const models = isRecord(apiEntry.models) ? apiEntry.models : null;
if (!models) {
return;
}
const filteredModels: Record<string, unknown> = {};
const apiSummary = createUsageSummary();
Object.entries(models).forEach(([modelName, modelEntry]) => {
if (!isRecord(modelEntry)) {
return;
}
const detailsRaw = Array.isArray(modelEntry.details) ? modelEntry.details : [];
const filteredDetails = detailsRaw.filter((detail) =>
isDetailWithinWindow(detail, windowStart, nowMs)
);
if (!filteredDetails.length) {
return;
}
const modelSummary = createUsageSummary();
updateSummaryFromDetails(modelSummary, filteredDetails);
filteredModels[modelName] = {
...modelEntry,
...toUsageSummaryFields(modelSummary),
details: filteredDetails
};
apiSummary.totalRequests += modelSummary.totalRequests;
apiSummary.successCount += modelSummary.successCount;
apiSummary.failureCount += modelSummary.failureCount;
apiSummary.totalTokens += modelSummary.totalTokens;
});
if (Object.keys(filteredModels).length === 0) {
return;
}
filteredApis[apiName] = {
...apiEntry,
...toUsageSummaryFields(apiSummary),
models: filteredModels
};
totalSummary.totalRequests += apiSummary.totalRequests;
totalSummary.successCount += apiSummary.successCount;
totalSummary.failureCount += apiSummary.failureCount;
totalSummary.totalTokens += apiSummary.totalTokens;
});
return {
...usageRecord,
...toUsageSummaryFields(totalSummary),
apis: filteredApis
} as T;
}
const normalizeAuthIndex = (value: unknown) => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
@@ -735,23 +876,28 @@ export function formatDayLabel(date: Date): string {
*/
export function buildHourlySeriesByModel(
usageData: unknown,
metric: 'requests' | 'tokens' = 'requests'
metric: 'requests' | 'tokens' = 'requests',
hourWindow: number = 24
): {
labels: string[];
dataByModel: Map<string, number[]>;
hasData: boolean;
} {
const hourMs = 60 * 60 * 1000;
const resolvedHourWindow =
Number.isFinite(hourWindow) && hourWindow > 0
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
: 24;
const now = new Date();
const currentHour = new Date(now);
currentHour.setMinutes(0, 0, 0);
const earliestBucket = new Date(currentHour);
earliestBucket.setHours(earliestBucket.getHours() - 23);
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
const earliestTime = earliestBucket.getTime();
const labels: string[] = [];
for (let i = 0; i < 24; i++) {
for (let i = 0; i < resolvedHourWindow; i++) {
const bucketStart = earliestTime + i * hourMs;
labels.push(formatHourLabel(new Date(bucketStart)));
}
@@ -927,10 +1073,11 @@ export function buildChartData(
usageData: unknown,
period: 'hour' | 'day' = 'day',
metric: 'requests' | 'tokens' = 'requests',
selectedModels: string[] = []
selectedModels: string[] = [],
options: { hourWindowHours?: number } = {}
): ChartData {
const baseSeries = period === 'hour'
? buildHourlySeriesByModel(usageData, metric)
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
: buildDailySeriesByModel(usageData, metric);
const { labels, dataByModel } = baseSeries;
@@ -992,9 +1139,8 @@ export interface StatusBarData {
}
/**
* 计算状态栏数据(最近1小时,分为20个5分钟的时间块)
* 注意:20个块 × 5分钟 = 100分钟,但我们只使用最近60分钟的数据
* 所以实际只有最后12个块可能有数据,前8个块将始终为 idle
* 计算状态栏数据(最近200分钟,分为20个10分钟的时间块)
* 每个时间块代表窗口内的一个等长区间,用于展示成功/失败趋势
*/
export function calculateStatusBarData(
usageDetails: UsageDetail[],