Merge pull request #98 from razorback16/main

feat(quota): add Claude OAuth usage quota detection
This commit is contained in:
Supra4E8C
2026-02-12 15:50:42 +08:00
committed by GitHub
12 changed files with 335 additions and 4 deletions

View File

@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';

View File

@@ -10,6 +10,10 @@ import type {
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
ClaudeExtraUsage,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
CodexRateLimitInfo,
CodexQuotaState,
CodexUsageWindow,
@@ -23,6 +27,9 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CLAUDE_USAGE_URL,
CLAUDE_REQUEST_HEADERS,
CLAUDE_USAGE_WINDOW_KEYS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
@@ -34,6 +41,7 @@ import {
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseClaudeUsagePayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId,
@@ -46,6 +54,7 @@ import {
createStatusError,
getStatusFromError,
isAntigravityFile,
isClaudeFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
@@ -56,15 +65,17 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -558,6 +569,149 @@ const renderGeminiCliItems = (
});
};
const buildClaudeQuotaWindows = (
payload: ClaudeUsagePayload,
t: TFunction
): ClaudeQuotaWindow[] => {
const windows: ClaudeQuotaWindow[] = [];
for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
const window = payload[key as keyof ClaudeUsagePayload];
if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
const typedWindow = window as { utilization: number; resets_at: string };
const usedPercent = normalizeNumberValue(typedWindow.utilization);
const resetLabel = formatQuotaResetTime(typedWindow.resets_at);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel,
});
}
return windows;
};
const fetchClaudeQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('claude_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_USAGE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('claude_quota.empty_windows'));
}
const windows = buildClaudeQuotaWindows(payload, t);
return { windows, extraUsage: payload.extra_usage };
};
const renderClaudeItems = (
quota: ClaudeQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const extraUsage = quota.extraUsage ?? null;
const nodes: ReactNode[] = [];
if (extraUsage && extraUsage.is_enabled) {
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
nodes.push(
h(
'div',
{ key: 'extra', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')),
h('span', { className: styleMap.codexPlanValue }, usedLabel)
)
);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
export const CLAUDE_CONFIG: QuotaConfig<
ClaudeQuotaState,
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
> = {
type: 'claude',
i18nPrefix: 'claude_quota',
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchClaudeQuota,
storeSelector: (state) => state.claudeQuota,
storeSetter: 'setClaudeQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
extraUsage: data.extraUsage,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.claudeCard,
controlsClassName: styles.claudeControls,
controlClassName: styles.claudeControl,
gridClassName: styles.claudeGrid,
renderQuotaItems: renderClaudeItems,
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',

View File

@@ -443,6 +443,26 @@
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All"
},
"claude_quota": {
"title": "Claude Quota",
"empty_title": "No Claude OAuth Files",
"empty_desc": "Log in with Claude OAuth to view quota.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_windows": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"five_hour": "5-hour limit",
"seven_day": "7-day limit",
"seven_day_oauth_apps": "7-day OAuth apps",
"seven_day_opus": "7-day Opus",
"seven_day_sonnet": "7-day Sonnet",
"seven_day_cowork": "7-day Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Extra Usage"
},
"codex_quota": {
"title": "Codex Quota",
"empty_title": "No Codex Auth Files",

View File

@@ -446,6 +446,26 @@
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все"
},
"claude_quota": {
"title": "Квота Claude",
"empty_title": "Файлы авторизации Claude OAuth отсутствуют",
"empty_desc": "Войдите через Claude OAuth, чтобы увидеть квоту.",
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
"loading": "Загрузка квоты...",
"load_failed": "Не удалось загрузить квоту: {{message}}",
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_windows": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все",
"five_hour": "Лимит на 5 часов",
"seven_day": "Лимит на 7 дней",
"seven_day_oauth_apps": "7 дней OAuth приложения",
"seven_day_opus": "7 дней Opus",
"seven_day_sonnet": "7 дней Sonnet",
"seven_day_cowork": "7 дней Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Дополнительное использование"
},
"codex_quota": {
"title": "Квота Codex",
"empty_title": "Файлы авторизации Codex отсутствуют",

View File

@@ -443,6 +443,26 @@
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
},
"claude_quota": {
"title": "Claude 额度",
"empty_title": "暂无 Claude OAuth 认证",
"empty_desc": "使用 Claude OAuth 登录后即可查看额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_windows": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"five_hour": "5 小时限额",
"seven_day": "7 天限额",
"seven_day_oauth_apps": "7 天 OAuth 应用",
"seven_day_opus": "7 天 Opus",
"seven_day_sonnet": "7 天 Sonnet",
"seven_day_cowork": "7 天 Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "额外用量"
},
"codex_quota": {
"title": "Codex 额度",
"empty_title": "暂无 Codex 认证",

View File

@@ -103,6 +103,7 @@
}
.antigravityGrid,
.claudeGrid,
.codexGrid,
.geminiCliGrid {
display: grid;
@@ -115,6 +116,7 @@
}
.antigravityControls,
.claudeControls,
.codexControls,
.geminiCliControls {
display: flex;
@@ -125,6 +127,7 @@
}
.antigravityControl,
.claudeControl,
.codexControl,
.geminiCliControl {
display: flex;
@@ -145,6 +148,12 @@
align-items: center;
}
.claudeCard {
background-image: linear-gradient(180deg,
rgba(252, 228, 236, 0.18),
rgba(252, 228, 236, 0));
}
.antigravityCard {
background-image: linear-gradient(180deg,
rgba(224, 247, 250, 0.12),

View File

@@ -10,6 +10,7 @@ import { authFilesApi, configFileApi } from '@/services/api';
import {
QuotaSection,
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG
} from '@/components/quota';
@@ -69,6 +70,12 @@ export function QuotaPage() {
{error && <div className={styles.errorBox}>{error}</div>}
<QuotaSection
config={CLAUDE_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={ANTIGRAVITY_CONFIG}
files={files}

View File

@@ -3,15 +3,17 @@
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -26,12 +28,17 @@ const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
})),
setClaudeQuota: (updater) =>
set((state) => ({
claudeQuota: resolveUpdater(updater, state.claudeQuota)
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
@@ -43,6 +50,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
clearQuotaCache: () =>
set({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {}
})

View File

@@ -97,6 +97,46 @@ export interface CodexUsagePayload {
codeReviewRateLimit?: CodexRateLimitInfo | null;
}
// Claude API payload types
export interface ClaudeUsageWindow {
utilization: number;
resets_at: string;
}
export interface ClaudeExtraUsage {
is_enabled: boolean;
monthly_limit: number;
used_credits: number;
utilization: number | null;
}
export interface ClaudeUsagePayload {
five_hour?: ClaudeUsageWindow | null;
seven_day?: ClaudeUsageWindow | null;
seven_day_oauth_apps?: ClaudeUsageWindow | null;
seven_day_opus?: ClaudeUsageWindow | null;
seven_day_sonnet?: ClaudeUsageWindow | null;
seven_day_cowork?: ClaudeUsageWindow | null;
iguana_necktie?: ClaudeUsageWindow | null;
extra_usage?: ClaudeExtraUsage | null;
}
export interface ClaudeQuotaWindow {
id: string;
label: string;
labelKey?: string;
usedPercent: number | null;
resetLabel: string;
}
export interface ClaudeQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: ClaudeQuotaWindow[];
extraUsage?: ClaudeExtraUsage | null;
error?: string;
errorStatus?: number;
}
// Quota state types
export interface AntigravityQuotaGroup {
id: string;

View File

@@ -151,6 +151,25 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
// Claude API configuration
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
export const CLAUDE_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
'anthropic-beta': 'oauth-2025-04-20',
};
export const CLAUDE_USAGE_WINDOW_KEYS = [
{ key: 'five_hour', id: 'five-hour', labelKey: 'claude_quota.five_hour' },
{ key: 'seven_day', id: 'seven-day', labelKey: 'claude_quota.seven_day' },
{ key: 'seven_day_oauth_apps', id: 'seven-day-oauth-apps', labelKey: 'claude_quota.seven_day_oauth_apps' },
{ key: 'seven_day_opus', id: 'seven-day-opus', labelKey: 'claude_quota.seven_day_opus' },
{ key: 'seven_day_sonnet', id: 'seven-day-sonnet', labelKey: 'claude_quota.seven_day_sonnet' },
{ key: 'seven_day_cowork', id: 'seven-day-cowork', labelKey: 'claude_quota.seven_day_cowork' },
{ key: 'iguana_necktie', id: 'iguana-necktie', labelKey: 'claude_quota.iguana_necktie' },
] as const;
// Codex API configuration
export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';

View File

@@ -2,7 +2,7 @@
* Normalization and parsing functions for quota data.
*/
import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
@@ -129,6 +129,23 @@ export function parseAntigravityPayload(payload: unknown): Record<string, unknow
return null;
}
export function parseClaudeUsagePayload(payload: unknown): ClaudeUsagePayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as ClaudeUsagePayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as ClaudeUsagePayload;
}
return null;
}
export function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {

View File

@@ -14,6 +14,23 @@ export function isAntigravityFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'antigravity';
}
export function isClaudeFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'claude';
}
export function isClaudeOAuthFile(file: AuthFileItem): boolean {
if (!isClaudeFile(file)) return false;
const metadata =
file && typeof file.metadata === 'object' && file.metadata !== null
? (file.metadata as Record<string, unknown>)
: null;
const accessToken =
metadata && typeof metadata.access_token === 'string'
? metadata.access_token.trim()
: '';
return accessToken.includes('sk-ant-oat');
}
export function isCodexFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'codex';
}