Compare commits

...

5 Commits

24 changed files with 616 additions and 293 deletions
+1 -1
View File
@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';
+115 -1
View File
@@ -22,6 +22,8 @@ import type {
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState,
KimiQuotaRow,
KimiQuotaState,
} from '@/types';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import {
@@ -34,6 +36,8 @@ import {
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
KIMI_USAGE_URL,
KIMI_REQUEST_HEADERS,
normalizeGeminiCliModelId,
normalizeNumberValue,
normalizePlanType,
@@ -43,13 +47,16 @@ import {
parseClaudeUsagePayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
parseKimiUsagePayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
resolveGeminiCliProjectId,
formatCodexResetLabel,
formatQuotaResetTime,
formatKimiResetHint,
buildAntigravityQuotaGroups,
buildGeminiCliQuotaBuckets,
buildKimiQuotaRows,
createStatusError,
getStatusFromError,
isAntigravityFile,
@@ -57,6 +64,7 @@ import {
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
isKimiFile,
isRuntimeOnlyAuthFile,
} from '@/utils/quota';
import { normalizeAuthIndex } from '@/utils/usage';
@@ -65,7 +73,7 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
@@ -74,10 +82,12 @@ export interface QuotaStore {
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
kimiQuota: Record<string, KimiQuotaState>;
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;
setKimiQuota: (updater: QuotaUpdater<Record<string, KimiQuotaState>>) => void;
clearQuotaCache: () => void;
}
@@ -859,3 +869,107 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems,
};
const fetchKimiQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<KimiQuotaRow[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
throw new Error(t('kimi_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: KIMI_USAGE_URL,
header: { ...KIMI_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseKimiUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('kimi_quota.empty_data'));
}
return buildKimiQuotaRows(payload);
};
const renderKimiItems = (
quota: KimiQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const rows = quota.rows ?? [];
if (rows.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('kimi_quota.empty_data'));
}
return rows.map((row) => {
const limit = row.limit;
const used = row.used;
const remaining =
limit > 0
? Math.max(0, Math.min(100, Math.round(((limit - used) / limit) * 100)))
: used > 0
? 0
: null;
const percentLabel = remaining === null ? '--' : `${remaining}%`;
const rowLabel = row.labelKey
? t(row.labelKey, (row.labelParams ?? {}) as Record<string, string | number>)
: row.label ?? '';
const resetLabel = formatKimiResetHint(t, row.resetHint);
return h(
'div',
{ key: row.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, rowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
limit > 0
? h('span', { className: styleMap.quotaAmount }, `${used} / ${limit}`)
: null,
resetLabel
? h('span', { className: styleMap.quotaReset }, resetLabel)
: null
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 60, mediumThreshold: 20 })
);
});
};
export const KIMI_CONFIG: QuotaConfig<KimiQuotaState, KimiQuotaRow[]> = {
type: 'kimi',
i18nPrefix: 'kimi_quota',
cardIdleMessageKey: 'quota_management.card_idle_hint',
filterFn: (file) => isKimiFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchKimiQuota,
storeSelector: (state) => state.kimiQuota,
storeSetter: 'setKimiQuota',
buildLoadingState: () => ({ status: 'loading', rows: [] }),
buildSuccessState: (rows) => ({ status: 'success', rows }),
buildErrorState: (message, status) => ({
status: 'error',
rows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.kimiCard,
controlsClassName: styles.kimiControls,
controlClassName: styles.kimiControl,
gridClassName: styles.kimiGrid,
renderQuotaItems: renderKimiItems,
};
@@ -2,14 +2,20 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconBot, IconCheck, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import {
IconBot,
IconCheck,
IconCode,
IconDownload,
IconInfo,
IconTrash2,
} from '@/components/ui/icons';
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
import type { AuthFileItem } from '@/types';
import { resolveAuthProvider } from '@/utils/quota';
import { calculateStatusBarData, normalizeAuthIndex, type KeyStats } from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import {
AUTH_FILE_REFRESH_WARNING_MS,
QUOTA_PROVIDER_TYPES,
formatModified,
getTypeColor,
@@ -17,26 +23,13 @@ import {
isRuntimeOnlyAuthFile,
resolveAuthFileStats,
type QuotaProviderType,
type ResolvedTheme
type ResolvedTheme,
} from '@/features/authFiles/constants';
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
import styles from '@/pages/AuthFilesPage.module.scss';
type AuthFileHealthStatus = 'healthy' | 'warning' | 'disabled' | 'unknown';
const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'available']);
const GOOD_STATUS_VALUES = new Set(['', 'ok', 'ready', 'healthy', 'available']);
const parseDateFromUnknown = (value: unknown): Date | null => {
if (value === null || value === undefined || value === '') return null;
const asNumber = Number(value);
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(Math.abs(asNumber) < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date;
};
export type AuthFileCardProps = {
file: AuthFileItem;
@@ -48,11 +41,10 @@ export type AuthFileCardProps = {
quotaFilterType: QuotaProviderType | null;
keyStats: KeyStats;
statusBarCache: Map<string, AuthFileStatusBarData>;
nowMs: number;
onShowModels: (file: AuthFileItem) => void;
onShowDetails: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
onOpenPrefixProxyEditor: (name: string) => void;
onOpenPrefixProxyEditor: (file: AuthFileItem) => void;
onDelete: (name: string) => void;
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
onToggleSelect: (name: string) => void;
@@ -65,7 +57,7 @@ const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
};
export function AuthFileCard(props: AuthFileCardProps) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const {
file,
selected,
@@ -76,14 +68,13 @@ export function AuthFileCard(props: AuthFileCardProps) {
quotaFilterType,
keyStats,
statusBarCache,
nowMs,
onShowModels,
onShowDetails,
onDownload,
onOpenPrefixProxyEditor,
onDelete,
onToggleStatus,
onToggleSelect
onToggleSelect,
} = props;
const fileStats = resolveAuthFileStats(file, keyStats);
@@ -104,69 +95,17 @@ export function AuthFileCard(props: AuthFileCardProps) {
? styles.codexCard
: quotaType === 'gemini-cli'
? styles.geminiCliCard
: '';
: quotaType === 'kimi'
? styles.kimiCard
: '';
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
const rawStatus = String(file.status ?? file['status'] ?? '')
.trim()
.toLowerCase();
const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim();
const normalizedStatusMessage = rawStatusMessage.toLowerCase();
const isFileDisabled = file.disabled === true || rawStatus === 'disabled';
const isUnavailable = file.unavailable === true || rawStatus === 'unavailable';
const lastRefreshDate = parseDateFromUnknown(file['last_refresh'] ?? file.lastRefresh);
const isRefreshStale = lastRefreshDate
? nowMs - lastRefreshDate.getTime() > AUTH_FILE_REFRESH_WARNING_MS
: false;
const hasStatusWarning =
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(normalizedStatusMessage);
const hasStatusFailure = rawStatus === 'error' || rawStatus === 'failed' || rawStatus === 'warning';
const healthStatus: AuthFileHealthStatus = isFileDisabled
? 'disabled'
: hasStatusWarning || hasStatusFailure || isUnavailable || isRefreshStale
? 'warning'
: lastRefreshDate && !isRefreshStale && GOOD_STATUS_VALUES.has(rawStatus)
? 'healthy'
: 'unknown';
const healthStatusClass =
healthStatus === 'healthy'
? styles.healthStatusHealthy
: healthStatus === 'warning'
? styles.healthStatusWarning
: healthStatus === 'disabled'
? styles.healthStatusDisabled
: styles.healthStatusUnknown;
const healthStatusLabel = t(`auth_files.health_status_${healthStatus}`);
const lastRefreshText = (() => {
if (!lastRefreshDate) return t('auth_files.refresh_not_available');
const diffMs = lastRefreshDate.getTime() - nowMs;
const absMs = Math.abs(diffMs);
if (absMs < 30 * 1000) {
return t('auth_files.refresh_just_now');
}
const units: ReadonlyArray<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
{ unit: 'hour', ms: 60 * 60 * 1000 },
{ unit: 'minute', ms: 60 * 1000 },
{ unit: 'second', ms: 1000 }
];
const matched = units.find(({ ms }) => absMs >= ms) || units[units.length - 1];
const value = Math.round(diffMs / matched.ms);
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat !== 'function') {
return lastRefreshDate.toLocaleString(i18n.language);
}
const formatter = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' });
return formatter.format(value, matched.unit);
})();
const lastRefreshTitle = lastRefreshDate
? lastRefreshDate.toLocaleString(i18n.language)
: t('auth_files.refresh_not_available');
const healthStatusTitle = rawStatusMessage || t('auth_files.health_status_no_message');
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
return (
<div
@@ -180,7 +119,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
type="button"
className={`${styles.selectionToggle} ${selected ? styles.selectionToggleActive : ''}`}
onClick={() => onToggleSelect(file.name)}
aria-label={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
aria-label={
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
}
aria-pressed={selected}
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
>
@@ -192,7 +133,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
{getTypeLabel(t, file.type || 'unknown')}
@@ -209,17 +150,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
</span>
</div>
<div className={styles.cardHealthRow}>
<span className={`${styles.healthStatusBadge} ${healthStatusClass}`} title={healthStatusTitle}>
{t('auth_files.health_status_label')}: {healthStatusLabel}
</span>
<span
className={`${styles.lastRefreshText} ${isRefreshStale ? styles.lastRefreshStale : ''}`}
title={lastRefreshTitle}
>
{t('auth_files.last_refresh_label')}: {lastRefreshText}
</span>
</div>
{rawStatusMessage && hasStatusWarning && (
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
{rawStatusMessage}
@@ -238,7 +168,11 @@ export function AuthFileCard(props: AuthFileCardProps) {
<ProviderStatusBar statusData={statusData} styles={styles} />
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
<AuthFileQuotaSection
file={file}
quotaType={quotaType}
disableControls={disableControls}
/>
)}
<div className={styles.cardActions}>
@@ -279,7 +213,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
<Button
variant="secondary"
size="sm"
onClick={() => onOpenPrefixProxyEditor(file.name)}
onClick={() => onOpenPrefixProxyEditor(file)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
@@ -313,7 +247,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
</div>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
<div className={styles.virtualBadge}>
{t('auth_files.type_virtual') || '虚拟认证文件'}
</div>
)}
</div>
</div>
@@ -1,7 +1,7 @@
import { useCallback, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from '@/components/quota';
import { useNotificationStore, useQuotaStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { getStatusFromError } from '@/utils/quota';
@@ -18,6 +18,7 @@ type QuotaState = { status?: string; error?: string; errorStatus?: number } | un
const getQuotaConfig = (type: QuotaProviderType) => {
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
if (type === 'codex') return CODEX_CONFIG;
if (type === 'kimi') return KIMI_CONFIG;
return GEMINI_CLI_CONFIG;
};
@@ -35,12 +36,14 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
const quota = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
if (quotaType === 'kimi') return state.kimiQuota[file.name] as QuotaState;
return state.geminiCliQuota[file.name] as QuotaState;
});
const updateQuotaState = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
if (quotaType === 'kimi') return state.setKimiQuota as unknown as (updater: unknown) => void;
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
});
@@ -3,9 +3,11 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import type {
PrefixProxyEditorField,
PrefixProxyEditorState
PrefixProxyEditorFieldValue,
PrefixProxyEditorState,
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import styles from '@/pages/AuthFilesPage.module.scss';
@@ -16,7 +18,7 @@ export type AuthFilesPrefixProxyEditorModalProps = {
dirty: boolean;
onClose: () => void;
onSave: () => void;
onChange: (field: PrefixProxyEditorField, value: string) => void;
onChange: (field: PrefixProxyEditorField, value: PrefixProxyEditorFieldValue) => void;
};
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
@@ -42,9 +44,7 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
<Button
onClick={onSave}
loading={editor?.saving === true}
disabled={
disableControls || editor?.saving === true || !dirty || !editor?.json
}
disabled={disableControls || editor?.saving === true || !dirty || !editor?.json}
>
{t('common.save')}
</Button>
@@ -114,6 +114,18 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('disableCooling', e.target.value)}
/>
{editor.isCodexFile && (
<div className="form-group">
<label>{t('ai_providers.codex_websockets_label')}</label>
<ToggleSwitch
checked={Boolean(editor.websocket)}
disabled={disableControls || editor.saving || !editor.json}
ariaLabel={t('ai_providers.codex_websockets_label')}
onChange={(value) => onChange('websocket', value)}
/>
<div className="hint">{t('ai_providers.codex_websockets_hint')}</div>
</div>
)}
</div>
</>
)}
@@ -122,4 +134,3 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
</Modal>
);
}
+2 -2
View File
@@ -12,9 +12,9 @@ export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
export type ResolvedTheme = 'light' | 'dark';
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli' | 'kimi';
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli', 'kimi']);
export const MIN_CARD_PAGE_SIZE = 3;
export const MAX_CARD_PAGE_SIZE = 30;
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import type { AuthFileItem } from '@/types';
import { useNotificationStore } from '@/stores';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
@@ -8,7 +9,7 @@ import {
normalizeExcludedModels,
parseDisableCoolingValue,
parseExcludedModelsText,
parsePriorityValue
parsePriorityValue,
} from '@/features/authFiles/constants';
export type PrefixProxyEditorField =
@@ -16,10 +17,14 @@ export type PrefixProxyEditorField =
| 'proxyUrl'
| 'priority'
| 'excludedModelsText'
| 'disableCooling';
| 'disableCooling'
| 'websocket';
export type PrefixProxyEditorFieldValue = string | boolean;
export type PrefixProxyEditorState = {
fileName: string;
isCodexFile: boolean;
loading: boolean;
saving: boolean;
error: string | null;
@@ -31,6 +36,7 @@ export type PrefixProxyEditorState = {
priority: string;
excludedModelsText: string;
disableCooling: string;
websocket: boolean;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
@@ -43,9 +49,12 @@ export type UseAuthFilesPrefixProxyEditorResult = {
prefixProxyEditor: PrefixProxyEditorState | null;
prefixProxyUpdatedText: string;
prefixProxyDirty: boolean;
openPrefixProxyEditor: (name: string) => Promise<void>;
openPrefixProxyEditor: (file: Pick<AuthFileItem, 'name' | 'type' | 'provider'>) => Promise<void>;
closePrefixProxyEditor: () => void;
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
handlePrefixProxyChange: (
field: PrefixProxyEditorField,
value: PrefixProxyEditorFieldValue
) => void;
handlePrefixProxySave: () => Promise<void>;
};
@@ -80,6 +89,10 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
delete next.disable_cooling;
}
if (editor.isCodexFile) {
next.websocket = editor.websocket;
}
return JSON.stringify(next);
};
@@ -102,7 +115,16 @@ export function useAuthFilesPrefixProxyEditor(
setPrefixProxyEditor(null);
};
const openPrefixProxyEditor = async (name: string) => {
const openPrefixProxyEditor = async (file: Pick<AuthFileItem, 'name' | 'type' | 'provider'>) => {
const name = file.name;
const normalizedType = String(file.type ?? '')
.trim()
.toLowerCase();
const normalizedProvider = String(file.provider ?? '')
.trim()
.toLowerCase();
const isCodexFile = normalizedType === 'codex' || normalizedProvider === 'codex';
if (disableControls) return;
if (prefixProxyEditor?.fileName === name) {
setPrefixProxyEditor(null);
@@ -111,6 +133,7 @@ export function useAuthFilesPrefixProxyEditor(
setPrefixProxyEditor({
fileName: name,
isCodexFile,
loading: true,
saving: false,
error: null,
@@ -121,7 +144,8 @@ export function useAuthFilesPrefixProxyEditor(
proxyUrl: '',
priority: '',
excludedModelsText: '',
disableCooling: ''
disableCooling: '',
websocket: false,
});
try {
@@ -139,7 +163,7 @@ export function useAuthFilesPrefixProxyEditor(
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
originalText: trimmed,
};
});
return;
@@ -153,19 +177,24 @@ export function useAuthFilesPrefixProxyEditor(
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
originalText: trimmed,
};
});
return;
}
const json = parsed as Record<string, unknown>;
const json = { ...(parsed as Record<string, unknown>) };
if (isCodexFile) {
const websocketValue = parseDisableCoolingValue(json.websocket);
json.websocket = websocketValue ?? false;
}
const originalText = JSON.stringify(json);
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
const priority = parsePriorityValue(json.priority);
const excludedModels = normalizeExcludedModels(json.excluded_models);
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
const websocketValue = parseDisableCoolingValue(json.websocket);
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
@@ -181,7 +210,8 @@ export function useAuthFilesPrefixProxyEditor(
excludedModelsText: excludedModels.join('\n'),
disableCooling:
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
error: null
websocket: websocketValue ?? false,
error: null,
};
});
} catch (err: unknown) {
@@ -194,14 +224,18 @@ export function useAuthFilesPrefixProxyEditor(
}
};
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
const handlePrefixProxyChange = (
field: PrefixProxyEditorField,
value: PrefixProxyEditorFieldValue
) => {
setPrefixProxyEditor((prev) => {
if (!prev) return prev;
if (field === 'prefix') return { ...prev, prefix: value };
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
if (field === 'priority') return { ...prev, priority: value };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
return { ...prev, disableCooling: value };
if (field === 'prefix') return { ...prev, prefix: String(value) };
if (field === 'proxyUrl') return { ...prev, proxyUrl: String(value) };
if (field === 'priority') return { ...prev, priority: String(value) };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
return { ...prev, websocket: Boolean(value) };
});
};
@@ -249,6 +283,6 @@ export function useAuthFilesPrefixProxyEditor(
openPrefixProxyEditor,
closePrefixProxyEditor,
handlePrefixProxyChange,
handlePrefixProxySave
handlePrefixProxySave,
};
}
+17
View File
@@ -619,6 +619,22 @@
"fetch_all": "Fetch All",
"remaining_amount": "Remaining {{count}}"
},
"kimi_quota": {
"title": "Kimi Quota",
"empty_title": "No Kimi Auth Files",
"empty_desc": "Upload a Kimi credential to view remaining 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_data": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"weekly_limit": "Weekly limit",
"limit_window": "{{duration}} limit",
"limit_index": "Limit #{{index}}",
"reset_hint": "resets in {{hint}}"
},
"vertex_import": {
"title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
@@ -1023,6 +1039,7 @@
"trace_confidence_low": "Low",
"trace_score": "Score {{score}}",
"trace_delta_seconds": "Δt {{seconds}}s",
"trace_model_matched": "Model Matched",
"trace_request_id": "Request ID",
"trace_method": "Method",
"trace_path": "Path",
+17
View File
@@ -622,6 +622,22 @@
"fetch_all": "Получить все",
"remaining_amount": "Осталось {{count}}"
},
"kimi_quota": {
"title": "Квота Kimi",
"empty_title": "Файлы авторизации Kimi отсутствуют",
"empty_desc": "Загрузите учётные данные Kimi, чтобы увидеть оставшуюся квоту.",
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
"loading": "Загрузка квоты...",
"load_failed": "Не удалось загрузить квоту: {{message}}",
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_data": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все",
"weekly_limit": "Недельный лимит",
"limit_window": "Лимит {{duration}}",
"limit_index": "Лимит #{{index}}",
"reset_hint": "сброс через {{hint}}"
},
"vertex_import": {
"title": "Вход с Vertex JSON",
"description": "Загрузите JSON ключа сервисного аккаунта Google, чтобы сохранить его как auth-dir/vertex-<project>.json по тем же правилам, что и помощник CLI vertex-import.",
@@ -1026,6 +1042,7 @@
"trace_confidence_low": "Низкая",
"trace_score": "Оценка {{score}}",
"trace_delta_seconds": "Δt {{seconds}}с",
"trace_model_matched": "Модель совпала",
"trace_request_id": "Request ID",
"trace_method": "Метод",
"trace_path": "Путь",
+17
View File
@@ -619,6 +619,22 @@
"fetch_all": "获取全部",
"remaining_amount": "剩余 {{count}}"
},
"kimi_quota": {
"title": "Kimi 额度",
"empty_title": "暂无 Kimi 认证",
"empty_desc": "上传 Kimi 认证文件后即可查看额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_data": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"weekly_limit": "周限额",
"limit_window": "{{duration}} 限额",
"limit_index": "限额 #{{index}}",
"reset_hint": "{{hint}} 后重置"
},
"vertex_import": {
"title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
@@ -1023,6 +1039,7 @@
"trace_confidence_low": "低",
"trace_score": "分数 {{score}}",
"trace_delta_seconds": "时间差 {{seconds}} 秒",
"trace_model_matched": "模型匹配",
"trace_request_id": "请求 ID",
"trace_method": "请求方法",
"trace_path": "路径",
+4 -53
View File
@@ -322,6 +322,10 @@
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
}
.kimiCard {
background-image: linear-gradient(180deg, rgba(255, 244, 229, 0.2), rgba(255, 244, 229, 0));
}
.quotaSection {
display: flex;
flex-direction: column;
@@ -605,59 +609,6 @@
border-bottom: 1px solid var(--border-color);
}
.cardHealthRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
}
.healthStatusBadge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.healthStatusHealthy {
color: var(--success-badge-text, #065f46);
background-color: var(--success-badge-bg, #d1fae5);
border-color: var(--success-badge-border, #6ee7b7);
}
.healthStatusWarning {
color: var(--warning-text);
background-color: var(--warning-bg);
border-color: var(--warning-border);
}
.healthStatusDisabled {
color: var(--text-secondary);
background-color: var(--bg-tertiary);
border-color: var(--border-color);
}
.healthStatusUnknown {
color: var(--text-secondary);
background-color: var(--bg-secondary);
border-style: dashed;
}
.lastRefreshText {
font-size: 12px;
color: var(--text-secondary);
}
.lastRefreshStale {
color: var(--warning-text);
}
.healthStatusMessage {
font-size: 12px;
color: var(--warning-text);
-3
View File
@@ -58,7 +58,6 @@ export function AuthFilesPage() {
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const [nowMs, setNowMs] = useState(() => Date.now());
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
@@ -223,7 +222,6 @@ export function AuthFilesPage() {
},
isCurrentLayer ? 240_000 : null
);
useInterval(() => setNowMs(Date.now()), isCurrentLayer ? 60_000 : null);
const existingTypes = useMemo(() => {
const types = new Set<string>(['all']);
@@ -519,7 +517,6 @@ export function AuthFilesPage() {
quotaFilterType={quotaFilterType}
keyStats={keyStats}
statusBarCache={statusBarCache}
nowMs={nowMs}
onShowModels={showModels}
onShowDetails={showDetails}
onDownload={handleDownload}
+2 -18
View File
@@ -693,34 +693,18 @@
flex-wrap: wrap;
}
.traceConfidenceBadge {
.traceModelBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
border: 1px solid var(--success-badge-border, #6ee7b7);
font-size: 11px;
font-weight: 700;
}
.traceConfidenceHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
border-color: var(--success-badge-border, #6ee7b7);
}
.traceConfidenceMedium {
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.traceConfidenceLow {
color: var(--text-secondary);
background: var(--bg-primary);
}
.traceScore,
.traceDelta {
font-size: 11px;
color: var(--text-secondary);
+5 -12
View File
@@ -970,12 +970,6 @@ export function LogsPage() {
) : (
<div className={styles.traceCandidates}>
{trace.traceCandidates.map((candidate) => {
const confidenceClass =
candidate.confidence === 'high'
? styles.traceConfidenceHigh
: candidate.confidence === 'medium'
? styles.traceConfidenceMedium
: styles.traceConfidenceLow;
const sourceInfo = trace.resolveTraceSourceInfo(
String(candidate.detail.source ?? ''),
candidate.detail.auth_index
@@ -986,12 +980,11 @@ export function LogsPage() {
className={styles.traceCandidate}
>
<div className={styles.traceCandidateHeader}>
<span className={`${styles.traceConfidenceBadge} ${confidenceClass}`}>
{t(`logs.trace_confidence_${candidate.confidence}`)}
</span>
<span className={styles.traceScore}>
{t('logs.trace_score', { score: candidate.score })}
</span>
{candidate.modelMatched && (
<span className={styles.traceModelBadge}>
{t('logs.trace_model_matched')}
</span>
)}
{candidate.timeDeltaMs !== null && (
<span className={styles.traceDelta}>
{t('logs.trace_delta_seconds', {
+12 -3
View File
@@ -105,7 +105,8 @@
.antigravityGrid,
.claudeGrid,
.codexGrid,
.geminiCliGrid {
.geminiCliGrid,
.kimiGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
@@ -118,7 +119,8 @@
.antigravityControls,
.claudeControls,
.codexControls,
.geminiCliControls {
.geminiCliControls,
.kimiControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
@@ -129,7 +131,8 @@
.antigravityControl,
.claudeControl,
.codexControl,
.geminiCliControl {
.geminiCliControl,
.kimiControl {
display: flex;
flex-direction: column;
gap: 4px;
@@ -172,6 +175,12 @@
rgba(231, 239, 255, 0));
}
.kimiCard {
background-image: linear-gradient(180deg,
rgba(255, 244, 229, 0.2),
rgba(255, 244, 229, 0));
}
.quotaSection {
display: flex;
flex-direction: column;
+8 -1
View File
@@ -12,7 +12,8 @@ import {
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG
GEMINI_CLI_CONFIG,
KIMI_CONFIG
} from '@/components/quota';
import type { AuthFileItem } from '@/types';
import styles from './QuotaPage.module.scss';
@@ -94,6 +95,12 @@ export function QuotaPage() {
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={KIMI_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
</div>
);
}
+47 -79
View File
@@ -12,19 +12,14 @@ import {
} from '@/utils/usage';
import type { ParsedLogLine } from './logTypes';
type TraceConfidence = 'high' | 'medium' | 'low';
export type TraceCandidate = {
detail: UsageDetailWithEndpoint;
score: number;
confidence: TraceConfidence;
modelMatched: boolean;
timeDeltaMs: number | null;
};
const TRACE_AUTH_CACHE_MS = 60 * 1000;
const TRACE_MATCH_STRONG_WINDOW_MS = 3 * 1000;
const TRACE_MATCH_WINDOW_MS = 10 * 1000;
const TRACE_MATCH_MAX_WINDOW_MS = 30 * 1000;
const TRACE_MAX_CANDIDATES = 5;
const TRACEABLE_EXACT_PATHS = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses']);
const TRACEABLE_PREFIX_PATHS = ['/v1beta/models'];
@@ -48,70 +43,17 @@ export const isTraceableRequestPath = (value?: string): boolean => {
return TRACEABLE_PREFIX_PATHS.some((prefix) => normalizedPath.startsWith(prefix));
};
const scoreTraceCandidate = (
line: ParsedLogLine,
detail: UsageDetailWithEndpoint
): TraceCandidate | null => {
let score = 0;
let timeDeltaMs: number | null = null;
const MODEL_EXTRACT_REGEX = /\bmodel[=:]\s*"?([a-zA-Z0-9._\-/]+)"?/i;
const logTimestampMs = line.timestamp ? Date.parse(line.timestamp) : Number.NaN;
const detailTimestampMs = detail.__timestampMs;
if (!Number.isNaN(logTimestampMs) && detailTimestampMs > 0) {
timeDeltaMs = Math.abs(logTimestampMs - detailTimestampMs);
if (timeDeltaMs <= TRACE_MATCH_STRONG_WINDOW_MS) {
score += 42;
} else if (timeDeltaMs <= TRACE_MATCH_WINDOW_MS) {
score += 30;
} else if (timeDeltaMs <= TRACE_MATCH_MAX_WINDOW_MS) {
score += 12;
} else {
score -= 12;
}
}
const extractModelFromMessage = (message?: string): string | undefined => {
if (!message) return undefined;
const match = message.match(MODEL_EXTRACT_REGEX);
return match?.[1] || undefined;
};
let methodMatched = false;
if (line.method && detail.__endpointMethod) {
if (line.method.toUpperCase() === detail.__endpointMethod.toUpperCase()) {
score += 18;
methodMatched = true;
} else {
score -= 8;
}
}
const logPath = normalizeTracePath(line.path);
const detailPath = normalizeTracePath(detail.__endpointPath);
let pathMatched = false;
if (logPath && detailPath) {
if (logPath === detailPath) {
score += 24;
pathMatched = true;
} else if (logPath.startsWith(detailPath) || detailPath.startsWith(logPath)) {
score += 12;
pathMatched = true;
} else {
score -= 8;
}
}
if (typeof line.statusCode === 'number') {
const logFailed = line.statusCode >= 400;
score += logFailed === detail.failed ? 10 : -6;
}
if (
timeDeltaMs !== null &&
timeDeltaMs > TRACE_MATCH_MAX_WINDOW_MS &&
!methodMatched &&
!pathMatched
) {
return null;
}
if (score <= 0) return null;
const confidence: TraceConfidence = score >= 70 ? 'high' : score >= 45 ? 'medium' : 'low';
return { detail, score, confidence, timeDeltaMs };
const isPathMatch = (logPath: string, detailPath: string): boolean => {
if (!logPath || !detailPath) return false;
return logPath === detailPath || logPath.startsWith(detailPath) || detailPath.startsWith(logPath);
};
const getErrorMessage = (err: unknown): string => {
@@ -236,16 +178,42 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
const traceCandidates = useMemo(() => {
if (!traceLogLine) return [];
const scored = traceUsageDetails
.map((detail) => scoreTraceCandidate(traceLogLine, detail))
.filter((item): item is TraceCandidate => item !== null)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const aDelta = a.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
const bDelta = b.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
return aDelta - bDelta;
});
return scored.slice(0, 8);
const logPath = normalizeTracePath(traceLogLine.path);
if (!logPath) return [];
const logTimestampMs = traceLogLine.timestamp
? Date.parse(traceLogLine.timestamp)
: Number.NaN;
// Step 1: filter by path match
const pathMatched = traceUsageDetails.filter((detail) =>
isPathMatch(logPath, normalizeTracePath(detail.__endpointPath))
);
if (pathMatched.length === 0) return [];
// Step 2: try to extract model from log message, then filter by model
const logModel = extractModelFromMessage(traceLogLine.message);
const modelMatched = logModel
? pathMatched.filter(
(d) => d.__modelName?.toLowerCase() === logModel.toLowerCase()
)
: [];
// Step 3: prefer model-matched set; fall back to path-matched
const useModelSet = modelMatched.length > 0;
const source = useModelSet ? modelMatched : pathMatched;
return source
.map((detail) => {
const timeDeltaMs =
!Number.isNaN(logTimestampMs) && detail.__timestampMs > 0
? Math.abs(logTimestampMs - detail.__timestampMs)
: null;
return { detail, modelMatched: useModelSet, timeDeltaMs } satisfies TraceCandidate;
})
.sort((a, b) => (b.detail.__timestampMs || 0) - (a.detail.__timestampMs || 0))
.slice(0, TRACE_MAX_CANDIDATES);
}, [traceLogLine, traceUsageDetails]);
const resolveTraceSourceInfo = useCallback(
+10 -2
View File
@@ -3,7 +3,7 @@
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState, KimiQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
@@ -12,10 +12,12 @@ interface QuotaStoreState {
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
kimiQuota: Record<string, KimiQuotaState>;
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;
setKimiQuota: (updater: QuotaUpdater<Record<string, KimiQuotaState>>) => void;
clearQuotaCache: () => void;
}
@@ -31,6 +33,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {},
kimiQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
@@ -47,11 +50,16 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
set((state) => ({
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
})),
setKimiQuota: (updater) =>
set((state) => ({
kimiQuota: resolveUpdater(updater, state.kimiQuota)
})),
clearQuotaCache: () =>
set({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {}
geminiCliQuota: {},
kimiQuota: {}
})
}));
+61
View File
@@ -197,3 +197,64 @@ export interface CodexQuotaState {
error?: string;
errorStatus?: number;
}
// Kimi API payload types
export interface KimiUsageDetail {
used?: number;
limit?: number;
remaining?: number;
name?: string;
title?: string;
resetAt?: string;
reset_at?: string;
resetTime?: string;
reset_time?: string;
resetIn?: number;
reset_in?: number;
ttl?: number;
}
export interface KimiLimitWindow {
duration?: number;
timeUnit?: string;
}
export interface KimiLimitItem {
name?: string;
title?: string;
scope?: string;
detail?: KimiUsageDetail;
window?: KimiLimitWindow;
used?: number;
limit?: number;
remaining?: number;
duration?: number;
timeUnit?: string;
resetAt?: string;
reset_at?: string;
resetIn?: number;
reset_in?: number;
ttl?: number;
}
export interface KimiUsagePayload {
usage?: KimiUsageDetail;
limits?: KimiLimitItem[];
}
export interface KimiQuotaRow {
id: string;
label?: string;
labelKey?: string;
labelParams?: Record<string, string | number>;
used: number;
limit: number;
resetHint?: string;
}
export interface KimiQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
rows: KimiQuotaRow[];
error?: string;
errorStatus?: number;
}
+158
View File
@@ -9,6 +9,11 @@ import type {
AntigravityModelsPayload,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
KimiUsagePayload,
KimiUsageDetail,
KimiLimitItem,
KimiLimitWindow,
KimiQuotaRow,
} from '@/types';
import {
ANTIGRAVITY_QUOTA_GROUPS,
@@ -260,3 +265,156 @@ export function buildAntigravityQuotaGroups(
return groups;
}
function toInt(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
}
return null;
}
type KimiRowLabel = Pick<KimiQuotaRow, 'label' | 'labelKey' | 'labelParams'>;
function kimiResetHint(data: Record<string, unknown>): string | undefined {
const absoluteKeys = ['reset_at', 'resetAt', 'reset_time', 'resetTime'];
for (const key of absoluteKeys) {
const raw = data[key];
if (typeof raw === 'string' && raw.trim()) {
try {
const truncated = raw.replace(/(\.\d{6})\d+/, '$1');
const date = new Date(truncated);
if (Number.isNaN(date.getTime())) continue;
const now = Date.now();
const delta = date.getTime() - now;
if (delta <= 0) return undefined;
const totalMinutes = Math.floor(delta / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return '<1m';
} catch {
continue;
}
}
}
const relativeKeys = ['reset_in', 'resetIn', 'ttl'];
for (const key of relativeKeys) {
const raw = toInt(data[key]);
if (raw !== null && raw > 0) {
const hours = Math.floor(raw / 3600);
const minutes = Math.floor((raw % 3600) / 60);
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return '<1m';
}
}
return undefined;
}
function kimiDurationToken(duration: number, rawTimeUnit: unknown): string {
const unit = typeof rawTimeUnit === 'string' ? rawTimeUnit.trim().toUpperCase() : '';
if (unit === 'MINUTES') {
return duration % 60 === 0 ? `${duration / 60}h` : `${duration}m`;
}
if (unit === 'HOURS') return `${duration}h`;
if (unit === 'DAYS') return `${duration}d`;
return `${duration}s`;
}
function kimiLimitLabel(
item: KimiLimitItem,
detail: KimiUsageDetail | KimiLimitItem,
window: KimiLimitWindow,
index: number
): KimiRowLabel {
for (const key of ['name', 'title', 'scope'] as const) {
const val = (item as Record<string, unknown>)[key] ?? (detail as Record<string, unknown>)[key];
if (typeof val === 'string' && val.trim()) return { label: val.trim() };
}
const duration =
toInt(window.duration) ??
toInt((item as Record<string, unknown>).duration) ??
toInt((detail as Record<string, unknown>).duration);
const timeUnit =
(window as Record<string, unknown>).timeUnit ??
(item as Record<string, unknown>).timeUnit ??
(detail as Record<string, unknown>).timeUnit;
if (duration !== null && duration > 0) {
return {
labelKey: 'kimi_quota.limit_window',
labelParams: {
duration: kimiDurationToken(duration, timeUnit),
},
};
}
return {
labelKey: 'kimi_quota.limit_index',
labelParams: {
index: index + 1,
},
};
}
function toKimiUsageRow(
data: Record<string, unknown>,
fallbackLabel: KimiRowLabel
): (KimiRowLabel & { used: number; limit: number; resetHint?: string }) | null {
const limit = toInt(data.limit);
let used = toInt(data.used);
if (used === null) {
const remaining = toInt(data.remaining);
if (remaining !== null && limit !== null) {
used = limit - remaining;
}
}
if (used === null && limit === null) return null;
const explicitLabel =
(typeof data.name === 'string' && data.name.trim()) ||
(typeof data.title === 'string' && data.title.trim());
const label = explicitLabel ? { label: explicitLabel } : fallbackLabel;
return {
...label,
used: used ?? 0,
limit: limit ?? 0,
resetHint: kimiResetHint(data),
};
}
export function buildKimiQuotaRows(payload: KimiUsagePayload): KimiQuotaRow[] {
const rows: KimiQuotaRow[] = [];
const usage = payload.usage;
if (usage && typeof usage === 'object') {
const summary = toKimiUsageRow(usage as Record<string, unknown>, {
labelKey: 'kimi_quota.weekly_limit',
});
if (summary) {
rows.push({ id: 'summary', ...summary });
}
}
const limits = payload.limits;
if (Array.isArray(limits)) {
limits.forEach((item, idx) => {
const detail = (item.detail && typeof item.detail === 'object' ? item.detail : item) as KimiUsageDetail | KimiLimitItem;
const window = (item.window && typeof item.window === 'object' ? item.window : {}) as KimiLimitWindow;
const fallbackLabel = kimiLimitLabel(item, detail, window, idx);
const row = toKimiUsageRow(detail as Record<string, unknown>, fallbackLabel);
if (row) {
rows.push({ id: `limit-${idx}`, ...row });
}
});
}
return rows;
}
+11
View File
@@ -34,6 +34,10 @@ export const TYPE_COLORS: Record<string, TypeColorSet> = {
light: { bg: '#fff3e0', text: '#ef6c00' },
dark: { bg: '#e65100', text: '#ffb74d' },
},
kimi: {
light: { bg: '#fff4e5', text: '#ad6800' },
dark: { bg: '#7c4a03', text: '#ffd591' },
},
antigravity: {
light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' },
@@ -178,3 +182,10 @@ export const CODEX_REQUEST_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
};
// Kimi API configuration
export const KIMI_USAGE_URL = 'https://api.kimi.com/coding/v1/usages';
export const KIMI_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
};
+6
View File
@@ -2,6 +2,7 @@
* Formatting functions for quota display.
*/
import type { TFunction } from 'i18next';
import type { CodexUsageWindow } from '@/types';
import { normalizeNumberValue } from './parsers';
@@ -66,3 +67,8 @@ export function getStatusFromError(err: unknown): number | undefined {
}
return undefined;
}
export function formatKimiResetHint(t: TFunction, hint?: string): string {
if (!hint) return '';
return t('kimi_quota.reset_hint', { hint });
}
+18 -1
View File
@@ -2,7 +2,7 @@
* Normalization and parsing functions for quota data.
*/
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
import { normalizeAuthIndex } from '@/utils/usage';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
@@ -170,3 +170,20 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl
}
return null;
}
export function parseKimiUsagePayload(payload: unknown): KimiUsagePayload | 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 KimiUsagePayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as KimiUsagePayload;
}
return null;
}
+4
View File
@@ -39,6 +39,10 @@ export function isGeminiCliFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'gemini-cli';
}
export function isKimiFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'kimi';
}
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;