From dc941f68578a370d1bc48f54ee8074b947f79db0 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Tue, 24 Feb 2026 17:54:35 +0800 Subject: [PATCH] feat(quota): add Kimi quota management with auth file inline viewing --- src/components/quota/index.ts | 2 +- src/components/quota/quotaConfigs.ts | 108 +++++++++++++- .../authFiles/components/AuthFileCard.tsx | 4 +- .../components/AuthFileQuotaSection.tsx | 5 +- src/features/authFiles/constants.ts | 4 +- src/i18n/locales/en.json | 12 ++ src/i18n/locales/ru.json | 12 ++ src/i18n/locales/zh-CN.json | 12 ++ src/pages/AuthFilesPage.module.scss | 4 + src/pages/QuotaPage.module.scss | 15 +- src/pages/QuotaPage.tsx | 9 +- src/stores/useQuotaStore.ts | 12 +- src/types/quota.ts | 59 ++++++++ src/utils/quota/builders.ts | 136 ++++++++++++++++++ src/utils/quota/constants.ts | 11 ++ src/utils/quota/formatters.ts | 5 + src/utils/quota/parsers.ts | 19 ++- src/utils/quota/validators.ts | 4 + 18 files changed, 420 insertions(+), 13 deletions(-) diff --git a/src/components/quota/index.ts b/src/components/quota/index.ts index eb624569..079e8e3f 100644 --- a/src/components/quota/index.ts +++ b/src/components/quota/index.ts @@ -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'; diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index 1cd6b3c8..66be1cf3 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -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 | ((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; codexQuota: Record; geminiCliQuota: Record; + kimiQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setClaudeQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; + setKimiQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; } @@ -859,3 +869,99 @@ export const GEMINI_CLI_CONFIG: QuotaConfig => { + 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 resetLabel = formatKimiResetHint(row.resetHint); + + return h( + 'div', + { key: row.id, className: styleMap.quotaRow }, + h( + 'div', + { className: styleMap.quotaRowHeader }, + h('span', { className: styleMap.quotaModel }, row.label), + 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 = { + 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, +}; diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx index 748f1618..e471374b 100644 --- a/src/features/authFiles/components/AuthFileCard.tsx +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -95,7 +95,9 @@ 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); diff --git a/src/features/authFiles/components/AuthFileQuotaSection.tsx b/src/features/authFiles/components/AuthFileQuotaSection.tsx index 95d933b7..d562df70 100644 --- a/src/features/authFiles/components/AuthFileQuotaSection.tsx +++ b/src/features/authFiles/components/AuthFileQuotaSection.tsx @@ -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; }); diff --git a/src/features/authFiles/constants.ts b/src/features/authFiles/constants.ts index 0bb27c6d..119fcde7 100644 --- a/src/features/authFiles/constants.ts +++ b/src/features/authFiles/constants.ts @@ -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(['antigravity', 'codex', 'gemini-cli']); +export const QUOTA_PROVIDER_TYPES = new Set(['antigravity', 'codex', 'gemini-cli', 'kimi']); export const MIN_CARD_PAGE_SIZE = 3; export const MAX_CARD_PAGE_SIZE = 30; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 03d96da1..04ea0879 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -619,6 +619,18 @@ "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" + }, "vertex_import": { "title": "Vertex JSON Login", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-.json using the same rules as the CLI vertex-import helper.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1bebdb09..75bcb6ae 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -622,6 +622,18 @@ "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": "Получить все" + }, "vertex_import": { "title": "Вход с Vertex JSON", "description": "Загрузите JSON ключа сервисного аккаунта Google, чтобы сохранить его как auth-dir/vertex-.json по тем же правилам, что и помощник CLI vertex-import.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ea2ad977..116a6d53 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -619,6 +619,18 @@ "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": "获取全部" + }, "vertex_import": { "title": "Vertex JSON 登录", "description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-.json。", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 73938b9e..e2b466d2 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -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; diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index f30b4858..3e9e9e52 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -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; diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 17831c7b..cdca9d2a 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -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} /> + ); } diff --git a/src/stores/useQuotaStore.ts b/src/stores/useQuotaStore.ts index c4cfb071..2bab3941 100644 --- a/src/stores/useQuotaStore.ts +++ b/src/stores/useQuotaStore.ts @@ -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 | ((prev: T) => T); @@ -12,10 +12,12 @@ interface QuotaStoreState { claudeQuota: Record; codexQuota: Record; geminiCliQuota: Record; + kimiQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setClaudeQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; + setKimiQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; } @@ -31,6 +33,7 @@ export const useQuotaStore = create((set) => ({ claudeQuota: {}, codexQuota: {}, geminiCliQuota: {}, + kimiQuota: {}, setAntigravityQuota: (updater) => set((state) => ({ antigravityQuota: resolveUpdater(updater, state.antigravityQuota) @@ -47,11 +50,16 @@ export const useQuotaStore = create((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: {} }) })); diff --git a/src/types/quota.ts b/src/types/quota.ts index 15d58b89..e8f84ba7 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -197,3 +197,62 @@ 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; + used: number; + limit: number; + resetHint?: string; +} + +export interface KimiQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + rows: KimiQuotaRow[]; + error?: string; + errorStatus?: number; +} diff --git a/src/utils/quota/builders.ts b/src/utils/quota/builders.ts index 95351af6..d6723b92 100644 --- a/src/utils/quota/builders.ts +++ b/src/utils/quota/builders.ts @@ -9,6 +9,11 @@ import type { AntigravityModelsPayload, GeminiCliParsedBucket, GeminiCliQuotaBucketState, + KimiUsagePayload, + KimiUsageDetail, + KimiLimitItem, + KimiLimitWindow, + KimiQuotaRow, } from '@/types'; import { ANTIGRAVITY_QUOTA_GROUPS, @@ -260,3 +265,134 @@ 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; +} + +function kimiResetHint(data: Record): 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 kimiLimitLabel( + item: KimiLimitItem, + detail: KimiUsageDetail | KimiLimitItem, + window: KimiLimitWindow, + index: number +): string { + for (const key of ['name', 'title', 'scope'] as const) { + const val = (item as Record)[key] ?? (detail as Record)[key]; + if (typeof val === 'string' && val.trim()) return val.trim(); + } + + const duration = + toInt(window.duration) ?? toInt((item as Record).duration) ?? toInt((detail as Record).duration); + const timeUnit = + (window.timeUnit as string) ?? (item as Record).timeUnit ?? (detail as Record).timeUnit; + + if (duration !== null && duration > 0) { + const unit = typeof timeUnit === 'string' ? timeUnit.toUpperCase() : ''; + if (unit === 'MINUTES') { + return duration % 60 === 0 ? `${duration / 60}h limit` : `${duration}m limit`; + } + if (unit === 'HOURS') return `${duration}h limit`; + if (unit === 'DAYS') return `${duration}d limit`; + return `${duration}s limit`; + } + + return `Limit #${index + 1}`; +} + +function toKimiUsageRow( + data: Record, + defaultLabel: string +): { label: string; 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 label = + (typeof data.name === 'string' && data.name.trim()) || + (typeof data.title === 'string' && data.title.trim()) || + defaultLabel; + 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, '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 label = kimiLimitLabel(item, detail, window, idx); + const row = toKimiUsageRow(detail as Record, label); + if (row) { + rows.push({ id: `limit-${idx}`, ...row }); + } + }); + } + + return rows; +} diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index ece51136..1fe2b36c 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -34,6 +34,10 @@ export const TYPE_COLORS: Record = { 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$', +}; diff --git a/src/utils/quota/formatters.ts b/src/utils/quota/formatters.ts index 1425eba2..c7ff41dc 100644 --- a/src/utils/quota/formatters.ts +++ b/src/utils/quota/formatters.ts @@ -66,3 +66,8 @@ export function getStatusFromError(err: unknown): number | undefined { } return undefined; } + +export function formatKimiResetHint(hint?: string): string { + if (!hint) return ''; + return `resets in ${hint}`; +} diff --git a/src/utils/quota/parsers.ts b/src/utils/quota/parsers.ts index a7aa6df9..3b69a79f 100644 --- a/src/utils/quota/parsers.ts +++ b/src/utils/quota/parsers.ts @@ -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; +} diff --git a/src/utils/quota/validators.ts b/src/utils/quota/validators.ts index 6d0305c0..18097e42 100644 --- a/src/utils/quota/validators.ts +++ b/src/utils/quota/validators.ts @@ -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;