diff --git a/src/components/quota/index.ts b/src/components/quota/index.ts index 6e915fa..6d0ee9d 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, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs'; +export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIRO_CONFIG } from './quotaConfigs'; export type { QuotaConfig } from './quotaConfigs'; diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index 096bc1f..d4dc906 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -16,7 +16,8 @@ import type { CodexUsagePayload, GeminiCliParsedBucket, GeminiCliQuotaBucketState, - GeminiCliQuotaState + GeminiCliQuotaState, + KiroQuotaState } from '@/types'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; import { @@ -26,6 +27,8 @@ import { CODEX_REQUEST_HEADERS, GEMINI_CLI_QUOTA_URL, GEMINI_CLI_REQUEST_HEADERS, + KIRO_QUOTA_URL, + KIRO_REQUEST_HEADERS, normalizeAuthIndexValue, normalizeNumberValue, normalizePlanType, @@ -34,6 +37,7 @@ import { parseAntigravityPayload, parseCodexUsagePayload, parseGeminiCliQuotaPayload, + parseKiroQuotaPayload, resolveCodexChatgptAccountId, resolveCodexPlanType, resolveGeminiCliProjectId, @@ -47,6 +51,7 @@ import { isCodexFile, isDisabledAuthFile, isGeminiCliFile, + isKiroFile, isRuntimeOnlyAuthFile } from '@/utils/quota'; import type { QuotaRenderHelpers } from './QuotaCard'; @@ -54,7 +59,7 @@ import styles from '@/pages/QuotaPage.module.scss'; type QuotaUpdater = T | ((prev: T) => T); -type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; +type QuotaType = 'antigravity' | 'codex' | 'gemini-cli' | 'kiro'; const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn'; @@ -62,9 +67,11 @@ export interface QuotaStore { antigravityQuota: Record; codexQuota: Record; geminiCliQuota: Record; + kiroQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; + setKiroQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; } @@ -590,3 +597,291 @@ export const GEMINI_CLI_CONFIG: QuotaConfig => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndexValue(rawAuthIndex); + if (!authIndex) { + throw new Error(t('kiro_quota.missing_auth_index')); + } + + const result = await apiCallApi.request({ + authIndex, + method: 'GET', + url: KIRO_QUOTA_URL, + header: { ...KIRO_REQUEST_HEADERS } + }); + + if (result.statusCode < 200 || result.statusCode >= 300) { + throw createStatusError(getApiCallErrorMessage(result), result.statusCode); + } + + const payload = parseKiroQuotaPayload(result.body ?? result.bodyText); + if (!payload) { + throw new Error(t('kiro_quota.empty_data')); + } + + // Extract usage data from usageBreakdownList (separating base and bonus) + const breakdownList = payload.usageBreakdownList ?? []; + let baseLimit = 0; + let baseUsage = 0; + let bonusLimit = 0; + let bonusUsage = 0; + let bonusStatus: string | undefined; + + for (const breakdown of breakdownList) { + // Add base quota + const limit = normalizeNumberValue(breakdown.usageLimitWithPrecision ?? breakdown.usageLimit); + const usage = normalizeNumberValue(breakdown.currentUsageWithPrecision ?? breakdown.currentUsage); + if (limit !== null) baseLimit += limit; + if (usage !== null) baseUsage += usage; + + // Add free trial quota if available (e.g., 500 bonus credits) + const freeTrialInfo = breakdown.freeTrialInfo; + if (freeTrialInfo) { + const freeLimit = normalizeNumberValue(freeTrialInfo.usageLimitWithPrecision ?? freeTrialInfo.usageLimit); + const freeUsage = normalizeNumberValue(freeTrialInfo.currentUsageWithPrecision ?? freeTrialInfo.currentUsage); + if (freeLimit !== null) bonusLimit += freeLimit; + if (freeUsage !== null) bonusUsage += freeUsage; + if (freeTrialInfo.freeTrialStatus) { + bonusStatus = freeTrialInfo.freeTrialStatus; + } + } + } + + const totalLimit = baseLimit + bonusLimit; + const totalUsage = baseUsage + bonusUsage; + + // Calculate next reset time + // Note: nextDateReset from Kiro API is in SECONDS (e.g., 1.769904E9 = 1769904000) + // JavaScript Date() requires milliseconds, so multiply by 1000 + let nextReset: string | undefined; + if (payload.nextDateReset) { + // API returns seconds timestamp (scientific notation like 1.769904E9) + const timestampSeconds = payload.nextDateReset; + const resetDate = new Date(timestampSeconds * 1000); + if (!isNaN(resetDate.getTime())) { + nextReset = resetDate.toISOString(); + } + } + + // Get subscription type + const subscriptionType = payload.subscriptionInfo?.subscriptionTitle ?? payload.subscriptionInfo?.type; + + return { + baseUsage, + baseLimit, + baseRemaining: baseLimit > 0 ? Math.max(0, baseLimit - baseUsage) : null, + bonusUsage, + bonusLimit, + bonusRemaining: bonusLimit > 0 ? Math.max(0, bonusLimit - bonusUsage) : null, + bonusStatus, + currentUsage: totalUsage, + usageLimit: totalLimit, + remainingCredits: totalLimit > 0 ? Math.max(0, totalLimit - totalUsage) : null, + nextReset, + subscriptionType + }; +}; + +const renderKiroItems = ( + quota: KiroQuotaState, + t: TFunction, + helpers: QuotaRenderHelpers +): ReactNode => { + const { styles: styleMap, QuotaProgressBar } = helpers; + const { createElement: h, Fragment } = React; + + const nodes: ReactNode[] = []; + + // Show subscription type if available + if (quota.subscriptionType) { + nodes.push( + h( + 'div', + { key: 'subscription', className: styleMap.codexPlan }, + h('span', { className: styleMap.codexPlanLabel }, t('kiro_quota.subscription_label')), + h('span', { className: styleMap.codexPlanValue }, quota.subscriptionType) + ) + ); + } + + const usageLimit = quota.usageLimit; + + if (usageLimit === null || usageLimit === 0) { + nodes.push( + h('div', { key: 'empty', className: styleMap.quotaMessage }, t('kiro_quota.empty_data')) + ); + return h(Fragment, null, ...nodes); + } + + const resetLabel = formatQuotaResetTime(quota.nextReset); + + // Base quota display (原本额度) + const baseLimit = quota.baseLimit; + const baseRemaining = quota.baseRemaining; + if (baseLimit !== null && baseLimit > 0) { + const baseRemainingPercent = baseRemaining !== null && baseLimit > 0 + ? Math.round((baseRemaining / baseLimit) * 100) + : 0; + + nodes.push( + h( + 'div', + { key: 'base-credits', className: styleMap.quotaRow }, + h( + 'div', + { className: styleMap.quotaRowHeader }, + h('span', { className: styleMap.quotaModel }, t('kiro_quota.base_credits_label')), + h( + 'div', + { className: styleMap.quotaMeta }, + h('span', { className: styleMap.quotaPercent }, `${baseRemainingPercent}%`), + baseRemaining !== null + ? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(baseRemaining) })) + : null, + h('span', { className: styleMap.quotaReset }, resetLabel) + ) + ), + h(QuotaProgressBar, { percent: baseRemainingPercent, highThreshold: 60, mediumThreshold: 20 }) + ) + ); + } + + // Bonus quota display (赠送额度) + const bonusLimit = quota.bonusLimit; + const bonusRemaining = quota.bonusRemaining; + if (bonusLimit !== null && bonusLimit > 0) { + const bonusRemainingPercent = bonusRemaining !== null && bonusLimit > 0 + ? Math.round((bonusRemaining / bonusLimit) * 100) + : 0; + + nodes.push( + h( + 'div', + { key: 'bonus-credits', className: styleMap.quotaRow }, + h( + 'div', + { className: styleMap.quotaRowHeader }, + h('span', { className: styleMap.quotaModel }, t('kiro_quota.bonus_credits_label')), + h( + 'div', + { className: styleMap.quotaMeta }, + h('span', { className: styleMap.quotaPercent }, `${bonusRemainingPercent}%`), + bonusRemaining !== null + ? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(bonusRemaining) })) + : null + ) + ), + h(QuotaProgressBar, { percent: bonusRemainingPercent, highThreshold: 60, mediumThreshold: 20 }) + ) + ); + } + + // Total credits display (合计) + const currentUsage = quota.currentUsage; + const remainingCredits = quota.remainingCredits; + const totalRemainingPercent = currentUsage !== null && usageLimit > 0 + ? Math.max(0, 100 - Math.round((currentUsage / usageLimit) * 100)) + : 0; + + nodes.push( + h( + 'div', + { key: 'total-credits', className: styleMap.quotaRow }, + h( + 'div', + { className: styleMap.quotaRowHeader }, + h('span', { className: styleMap.quotaModel }, t('kiro_quota.total_credits_label')), + h( + 'div', + { className: styleMap.quotaMeta }, + h('span', { className: styleMap.quotaPercent }, `${totalRemainingPercent}%`), + remainingCredits !== null + ? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(remainingCredits) })) + : null + ) + ), + h(QuotaProgressBar, { percent: totalRemainingPercent, highThreshold: 60, mediumThreshold: 20 }) + ) + ); + + return h(Fragment, null, ...nodes); +}; + +export const KIRO_CONFIG: QuotaConfig = { + type: 'kiro', + i18nPrefix: 'kiro_quota', + filterFn: (file) => isKiroFile(file), + fetchQuota: fetchKiroQuota, + storeSelector: (state) => state.kiroQuota, + storeSetter: 'setKiroQuota', + buildLoadingState: () => ({ + status: 'loading', + baseUsage: null, + baseLimit: null, + baseRemaining: null, + bonusUsage: null, + bonusLimit: null, + bonusRemaining: null, + currentUsage: null, + usageLimit: null, + remainingCredits: null + }), + buildSuccessState: (data) => ({ + status: 'success', + baseUsage: data.baseUsage, + baseLimit: data.baseLimit, + baseRemaining: data.baseRemaining, + bonusUsage: data.bonusUsage, + bonusLimit: data.bonusLimit, + bonusRemaining: data.bonusRemaining, + bonusStatus: data.bonusStatus, + currentUsage: data.currentUsage, + usageLimit: data.usageLimit, + remainingCredits: data.remainingCredits, + nextReset: data.nextReset, + subscriptionType: data.subscriptionType + }), + buildErrorState: (message, status) => ({ + status: 'error', + baseUsage: null, + baseLimit: null, + baseRemaining: null, + bonusUsage: null, + bonusLimit: null, + bonusRemaining: null, + currentUsage: null, + usageLimit: null, + remainingCredits: null, + error: message, + errorStatus: status + }), + cardClassName: styles.kiroCard, + controlsClassName: styles.kiroControls, + controlClassName: styles.kiroControl, + gridClassName: styles.kiroGrid, + renderQuotaItems: renderKiroItems +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0aa6612..b1a056e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -384,6 +384,7 @@ "filter_claude": "Claude", "filter_codex": "Codex", "filter_antigravity": "Antigravity", + "filter_kiro": "Kiro", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "Empty", @@ -395,6 +396,7 @@ "type_claude": "Claude", "type_codex": "Codex", "type_antigravity": "Antigravity", + "type_kiro": "Kiro", "type_iflow": "iFlow", "type_vertex": "Vertex", "type_empty": "Empty", @@ -469,6 +471,23 @@ "fetch_all": "Fetch All", "remaining_amount": "Remaining {{count}}" }, + "kiro_quota": { + "title": "Kiro Quota", + "empty_title": "No Kiro Auth Files", + "empty_desc": "Upload a Kiro credential to view remaining quota.", + "idle": "Not loaded. Click Refresh Button.", + "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", + "subscription_label": "Subscription", + "base_credits_label": "Base Credits", + "bonus_credits_label": "Bonus Credits", + "total_credits_label": "Total Credits", + "remaining_credits": "Remaining {{count}}" + }, "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/zh-CN.json b/src/i18n/locales/zh-CN.json index de5eccf..9b526a5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -384,6 +384,7 @@ "filter_claude": "Claude", "filter_codex": "Codex", "filter_antigravity": "Antigravity", + "filter_kiro": "Kiro", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "空文件", @@ -395,6 +396,7 @@ "type_claude": "Claude", "type_codex": "Codex", "type_antigravity": "Antigravity", + "type_kiro": "Kiro", "type_iflow": "iFlow", "type_vertex": "Vertex", "type_empty": "空文件", @@ -469,6 +471,23 @@ "fetch_all": "获取全部", "remaining_amount": "剩余 {{count}}" }, + "kiro_quota": { + "title": "Kiro 额度", + "empty_title": "暂无 Kiro 认证", + "empty_desc": "上传 Kiro 认证文件后即可查看额度。", + "idle": "尚未加载额度,请点击刷新按钮。", + "loading": "正在加载额度...", + "load_failed": "额度获取失败:{{message}}", + "missing_auth_index": "认证文件缺少 auth_index", + "empty_data": "暂无额度数据", + "refresh_button": "刷新额度", + "fetch_all": "获取全部", + "subscription_label": "订阅类型", + "base_credits_label": "基础额度", + "bonus_credits_label": "赠送额度", + "total_credits_label": "合计额度", + "remaining_credits": "剩余 {{count}}" + }, "vertex_import": { "title": "Vertex JSON 登录", "description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-.json。", diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index 6995232..b9c0aec 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -104,7 +104,8 @@ .antigravityGrid, .codexGrid, -.geminiCliGrid { +.geminiCliGrid, +.kiroGrid { display: grid; gap: $spacing-md; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); @@ -116,7 +117,8 @@ .antigravityControls, .codexControls, -.geminiCliControls { +.geminiCliControls, +.kiroControls { display: flex; gap: $spacing-md; flex-wrap: wrap; @@ -126,7 +128,8 @@ .antigravityControl, .codexControl, -.geminiCliControl { +.geminiCliControl, +.kiroControl { display: flex; flex-direction: column; gap: 4px; @@ -163,6 +166,12 @@ rgba(231, 239, 255, 0)); } +.kiroCard { + background-image: linear-gradient(180deg, + rgba(255, 248, 225, 0.18), + rgba(255, 248, 225, 0)); +} + .quotaSection { display: flex; flex-direction: column; diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index a72d812..e597aa6 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -1,5 +1,5 @@ /** - * Quota management page - coordinates the three quota sections. + * Quota management page - coordinates the four quota sections. */ import { useCallback, useEffect, useState } from 'react'; @@ -11,7 +11,8 @@ import { QuotaSection, ANTIGRAVITY_CONFIG, CODEX_CONFIG, - GEMINI_CLI_CONFIG + GEMINI_CLI_CONFIG, + KIRO_CONFIG } from '@/components/quota'; import type { AuthFileItem } from '@/types'; import styles from './QuotaPage.module.scss'; @@ -75,6 +76,12 @@ export function QuotaPage() { loading={loading} disabled={disableControls} /> + = T | ((prev: T) => T); @@ -11,9 +11,11 @@ interface QuotaStoreState { antigravityQuota: Record; codexQuota: Record; geminiCliQuota: Record; + kiroQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; + setKiroQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; } @@ -28,6 +30,7 @@ export const useQuotaStore = create((set) => ({ antigravityQuota: {}, codexQuota: {}, geminiCliQuota: {}, + kiroQuota: {}, setAntigravityQuota: (updater) => set((state) => ({ antigravityQuota: resolveUpdater(updater, state.antigravityQuota) @@ -40,10 +43,15 @@ export const useQuotaStore = create((set) => ({ set((state) => ({ geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota) })), + setKiroQuota: (updater) => + set((state) => ({ + kiroQuota: resolveUpdater(updater, state.kiroQuota) + })), clearQuotaCache: () => set({ antigravityQuota: {}, codexQuota: {}, - geminiCliQuota: {} + geminiCliQuota: {}, + kiroQuota: {} }) })); diff --git a/src/types/quota.ts b/src/types/quota.ts index 3e335a0..b13100a 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -145,3 +145,58 @@ export interface CodexQuotaState { error?: string; errorStatus?: number; } + +// Kiro (AWS CodeWhisperer) quota types +export interface KiroFreeTrialInfo { + freeTrialStatus?: string; + usageLimit?: number; + currentUsage?: number; + usageLimitWithPrecision?: number; + currentUsageWithPrecision?: number; +} + +export interface KiroUsageBreakdown { + usageLimit?: number; + currentUsage?: number; + usageLimitWithPrecision?: number; + currentUsageWithPrecision?: number; + nextDateReset?: number; + displayName?: string; + resourceType?: string; + freeTrialInfo?: KiroFreeTrialInfo; +} + +export interface KiroQuotaPayload { + daysUntilReset?: number; + nextDateReset?: number; + userInfo?: { + email?: string; + userId?: string; + }; + subscriptionInfo?: { + subscriptionTitle?: string; + type?: string; + }; + usageBreakdownList?: KiroUsageBreakdown[]; +} + +export interface KiroQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + // Base quota (原本额度) + baseUsage: number | null; + baseLimit: number | null; + baseRemaining: number | null; + // Free trial/bonus quota (赠送额度) + bonusUsage: number | null; + bonusLimit: number | null; + bonusRemaining: number | null; + bonusStatus?: string; + // Total (合计) + currentUsage: number | null; + usageLimit: number | null; + remainingCredits: number | null; + nextReset?: string; + subscriptionType?: string; + error?: string; + errorStatus?: number; +} diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index 330af65..eac603d 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -38,6 +38,10 @@ export const TYPE_COLORS: Record = { light: { bg: '#e0f7fa', text: '#006064' }, dark: { bg: '#004d40', text: '#80deea' }, }, + kiro: { + light: { bg: '#fff8e1', text: '#ff8f00' }, + dark: { bg: '#ff6f00', text: '#ffe082' }, + }, iflow: { light: { bg: '#f3e5f5', text: '#7b1fa2' }, dark: { bg: '#4a148c', text: '#ce93d8' }, @@ -149,3 +153,15 @@ export const CODEX_REQUEST_HEADERS = { 'Content-Type': 'application/json', 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal', }; + +// Kiro (AWS CodeWhisperer) API configuration +export const KIRO_QUOTA_URL = + 'https://codewhisperer.us-east-1.amazonaws.com/getUsageLimits?isEmailRequired=true&origin=AI_EDITOR&resourceType=AGENTIC_REQUEST'; + +export const KIRO_REQUEST_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'x-amz-user-agent': 'aws-sdk-js/1.0.0 KiroIDE-0.6.18-cpamc', + 'User-Agent': 'aws-sdk-js/1.0.0 ua/2.1 os/windows lang/js md/nodejs#20.16.0 api/codewhispererruntime#1.0.0 m/E KiroIDE-0.6.18-cpamc', + 'amz-sdk-request': 'attempt=1; max=1', + Connection: 'close', +}; diff --git a/src/utils/quota/parsers.ts b/src/utils/quota/parsers.ts index 2383833..51d8ebd 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 { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types'; +import type { CodexUsagePayload, GeminiCliQuotaPayload, KiroQuotaPayload } from '@/types'; export function normalizeAuthIndexValue(value: unknown): string | null { if (typeof value === 'number' && Number.isFinite(value)) { @@ -151,3 +151,20 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl } return null; } + +export function parseKiroQuotaPayload(payload: unknown): KiroQuotaPayload | 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 KiroQuotaPayload; + } catch { + return null; + } + } + if (typeof payload === 'object') { + return payload as KiroQuotaPayload; + } + return null; +} diff --git a/src/utils/quota/validators.ts b/src/utils/quota/validators.ts index ba90c3c..1923278 100644 --- a/src/utils/quota/validators.ts +++ b/src/utils/quota/validators.ts @@ -22,6 +22,10 @@ export function isGeminiCliFile(file: AuthFileItem): boolean { return resolveAuthProvider(file) === 'gemini-cli'; } +export function isKiroFile(file: AuthFileItem): boolean { + return resolveAuthProvider(file) === 'kiro'; +} + export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { const raw = file['runtime_only'] ?? file.runtimeOnly; if (typeof raw === 'boolean') return raw;