Skip to content

Commit c42b30f

Browse files
committed
feat(claude): add Claude (Anthropic) provider support for quota checks
This commit introduces support for checking quota usage for Claude (Anthropic) accounts. - **Add Claude API constants:** Define `CLAUDE_USAGE_URL` and `CLAUDE_HEADERS` in `src/constants/api.ts` to facilitate API requests to Anthropic. - **Integrate Claude into provider display:** Update `useProvidersPresenter.ts` to include 'anthropic' and 'other' as recognized providers, allowing Claude files to be grouped and displayed correctly in the UI. - **Extend quota presenter logic:** Modify `useQuotaPresenter.ts` to recognize 'anthropic' as a provider type, add its icon mapping, and include it in the list of displayed providers. - **Implement Claude quota fetching:** Add a new `fetchClaude` method to `quota.service.ts` to handle API calls to the Claude usage endpoint. - **Create Claude parser:** Introduce `src/services/api/parsers/claude.parser.ts` to parse the response from the Claude usage API into a standardized `ClaudeQuotaResult` format. - **Update quota types:** Add `ClaudeQuotaResult` interface to `src/types/quota.ts` to define the structure of Claude quota data.
1 parent c5f8d75 commit c42b30f

7 files changed

Lines changed: 117 additions & 4 deletions

File tree

src/constants/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const GEMINI_CLI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1inter
1313
export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
1414
export const KIRO_USAGE_URL = 'https://codewhisperer.us-east-1.amazonaws.com/getUsageLimits?isEmailRequired=true&origin=AI_EDITOR&resourceType=AGENTIC_REQUEST';
1515
export const COPILOT_ENTITLEMENT_URL = 'https://api.github.com/copilot_internal/user';
16+
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
1617

1718
export const ANTIGRAVITY_HEADERS: Record<string, string> = {
1819
Authorization: 'Bearer $TOKEN$',
@@ -43,3 +44,9 @@ export const COPILOT_HEADERS: Record<string, string> = {
4344
Accept: 'application/vnd.github+json',
4445
'X-GitHub-Api-Version': '2022-11-28'
4546
};
47+
48+
export const CLAUDE_HEADERS: Record<string, string> = {
49+
Authorization: 'Bearer $TOKEN$',
50+
'anthropic-beta': 'oauth-2025-04-20',
51+
Accept: 'application/json'
52+
};

src/features/providers/useProvidersPresenter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ export function useProvidersPresenter() {
7979
codex: true,
8080
'gemini-cli': true,
8181
kiro: true,
82-
copilot: true
82+
copilot: true,
83+
anthropic: true,
84+
other: true
8385
});
8486

8587
const groupedFiles = useMemo(() => {
@@ -88,7 +90,9 @@ export function useProvidersPresenter() {
8890
codex: { displayName: 'Codex (OpenAI)', files: [], iconInfo: { path: '/openai/openai.png', needsInvert: false } },
8991
'gemini-cli': { displayName: 'Gemini CLI', files: [], iconInfo: { path: '/gemini/gemini.png', needsInvert: false } },
9092
kiro: { displayName: 'Kiro (CodeWhisperer)', files: [], iconInfo: { path: '/kiro/kiro.png', needsInvert: false } },
91-
copilot: { displayName: 'GitHub Copilot', files: [], iconInfo: { path: '/copilot/copilot.png', needsInvert: true } }
93+
copilot: { displayName: 'GitHub Copilot', files: [], iconInfo: { path: '/copilot/copilot.png', needsInvert: true } },
94+
anthropic: { displayName: 'Claude (Anthropic)', files: [], iconInfo: { path: '/claude/claude.png', needsInvert: false } },
95+
other: { displayName: 'Other', files: [], iconInfo: { path: '', needsInvert: false } }
9296
};
9397

9498
files.forEach(file => {
@@ -98,6 +102,8 @@ export function useProvidersPresenter() {
98102
else if (p.includes('gemini')) groups['gemini-cli'].files.push(file);
99103
else if (p.includes('kiro')) groups.kiro.files.push(file);
100104
else if (p.includes('copilot') || p.includes('github')) groups.copilot.files.push(file);
105+
else if (p.includes('claude') || p.includes('anthropic')) groups.anthropic.files.push(file);
106+
else groups.other.files.push(file);
101107
});
102108

103109
return Object.entries(groups).filter(([, group]) => group.files.length > 0);

src/features/quota/useQuotaPresenter.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,23 @@ import type { AuthFile, FileQuota, ProviderSection } from '@/types';
66
import type { ProviderFilterItem } from '@/features/quota/components/ProviderFilter';
77
import { resolveCodexChatgptAccountId, resolveCodexPlanType, resolveGeminiCliProjectId } from '@/shared/utils/quota.helpers';
88

9-
function getProviderType(file: AuthFile): 'antigravity' | 'codex' | 'gemini-cli' | 'kiro' | 'copilot' | 'unknown' {
9+
function getProviderType(file: AuthFile): 'antigravity' | 'codex' | 'gemini-cli' | 'kiro' | 'copilot' | 'anthropic' | 'unknown' {
1010
const filename = (file?.filename || file?.id || '').toLowerCase();
1111

1212
if (filename.startsWith('antigravity-') || filename.includes('antigravity')) return 'antigravity';
1313
if (filename.startsWith('codex-') || filename.includes('codex')) return 'codex';
1414
if (filename.startsWith('gemini-cli-') || filename.includes('gemini')) return 'gemini-cli';
1515
if (filename.startsWith('kiro-') || filename.includes('kiro')) return 'kiro';
1616
if (filename.startsWith('github-copilot-') || filename.includes('copilot')) return 'copilot';
17+
if (filename.startsWith('claude-') || filename.includes('claude') || filename.includes('anthropic')) return 'anthropic';
1718

1819
const provider = (file?.provider || '').toLowerCase();
1920
if (provider.includes('antigravity')) return 'antigravity';
2021
if (provider.includes('codex')) return 'codex';
2122
if (provider.includes('gemini')) return 'gemini-cli';
2223
if (provider.includes('kiro')) return 'kiro';
2324
if (provider.includes('copilot') || provider.includes('github')) return 'copilot';
25+
if (provider.includes('claude') || provider.includes('anthropic')) return 'anthropic';
2426

2527
return 'unknown';
2628
}
@@ -35,6 +37,7 @@ const ICON_MAP: Record<string, { path?: string; needsInvert: boolean }> = {
3537
'gemini-cli': { path: '/gemini/gemini.png', needsInvert: false },
3638
kiro: { path: '/kiro/kiro.png', needsInvert: false },
3739
copilot: { path: '/copilot/copilot.png', needsInvert: true },
40+
anthropic: { path: '/claude/claude.png', needsInvert: false },
3841
};
3942

4043
const PROVIDER_DISPLAY: { key: string; name: string }[] = [
@@ -43,6 +46,8 @@ const PROVIDER_DISPLAY: { key: string; name: string }[] = [
4346
{ key: 'gemini-cli', name: 'Gemini CLI' },
4447
{ key: 'kiro', name: 'Kiro (CodeWhisperer)' },
4548
{ key: 'copilot', name: 'GitHub Copilot' },
49+
{ key: 'anthropic', name: 'Claude (Anthropic)' },
50+
{ key: 'unknown', name: 'Other' },
4651
];
4752

4853
export function useQuotaPresenter() {
@@ -149,6 +154,14 @@ export function useQuotaPresenter() {
149154
...f, loading: false, plan: result.plan, models: result.models, error: result.error
150155
} : f)
151156
} : s));
157+
} else if (targetProvider === 'anthropic') {
158+
const result = await quotaApi.fetchClaude(authIndex);
159+
setSections((prev) => prev.map(s => s.provider === 'anthropic' ? {
160+
...s,
161+
files: s.files.map(f => f.fileId === fileId ? {
162+
...f, loading: false, email: result.email, models: result.models, error: result.error
163+
} : f)
164+
} : s));
152165
}
153166
} catch (err) {
154167
const msg = (err as Error).message;
@@ -170,7 +183,7 @@ export function useQuotaPresenter() {
170183
const files: AuthFile[] = Array.isArray(resp) ? resp : (resp as any).items || (resp as any).files || [];
171184

172185
const grouped: Record<string, FileQuota[]> = {
173-
antigravity: [], codex: [], 'gemini-cli': [], kiro: [], copilot: [],
186+
antigravity: [], codex: [], 'gemini-cli': [], kiro: [], copilot: [], anthropic: [], unknown: []
174187
};
175188

176189
files.forEach((file) => {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { ClaudeQuotaResult, QuotaModel } from '@/types/quota';
2+
3+
export function parseClaudeUsage(data: any): ClaudeQuotaResult {
4+
if (!data || typeof data !== 'object') {
5+
return { models: [], error: 'Invalid response format' };
6+
}
7+
8+
if (data.type === 'error' && data.error) {
9+
return { models: [], error: data.error.message || 'API Error' };
10+
}
11+
12+
const models: QuotaModel[] = [];
13+
14+
const addUsage = (key: string, name: string) => {
15+
const usage = data[key];
16+
if (usage) {
17+
const utilization = typeof usage.utilization === 'number' ? usage.utilization : parseFloat(usage.utilization);
18+
if (!isNaN(utilization)) {
19+
models.push({
20+
name,
21+
percentage: Math.max(0, Math.min(100, 100 - utilization)),
22+
resetTime: usage.resets_at || ''
23+
});
24+
}
25+
}
26+
};
27+
28+
addUsage('five_hour', 'five-hour-session');
29+
addUsage('seven_day', 'seven-day-weekly');
30+
addUsage('seven_day_sonnet', 'seven-day-sonnet');
31+
addUsage('seven_day_opus', 'seven-day-opus');
32+
33+
const extra = data.extra_usage;
34+
if (extra && extra.is_enabled) {
35+
const utilization = typeof extra.utilization === 'number' ? extra.utilization : parseFloat(extra.utilization);
36+
if (!isNaN(utilization)) {
37+
models.push({
38+
name: 'extra-usage',
39+
percentage: Math.max(0, Math.min(100, 100 - utilization)),
40+
resetTime: '',
41+
displayValue: extra.used_credits !== undefined && extra.monthly_limit !== undefined
42+
? `${extra.used_credits} / ${extra.monthly_limit}`
43+
: undefined
44+
});
45+
}
46+
}
47+
48+
if (models.length === 0) {
49+
return { models: [], error: 'No quota data found' };
50+
}
51+
52+
return { models };
53+
}

src/services/api/parsers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { parseCodexUsage } from './codex.parser';
33
export { parseGeminiCliQuota } from './gemini.parser';
44
export { parseKiroQuota } from './kiro.parser';
55
export { parseCopilotQuota } from './copilot.parser';
6+
export { parseClaudeUsage } from './claude.parser';

src/services/api/quota.service.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,24 @@ import {
1010
KIRO_HEADERS,
1111
COPILOT_ENTITLEMENT_URL,
1212
COPILOT_HEADERS,
13+
CLAUDE_USAGE_URL,
14+
CLAUDE_HEADERS,
1315
} from '@/constants';
1416
import type {
1517
AntigravityQuotaResult,
1618
CodexQuotaResult,
1719
GeminiCliQuotaResult,
1820
KiroQuotaResult,
1921
CopilotQuotaResult,
22+
ClaudeQuotaResult,
2023
} from '@/types';
2124
import {
2225
parseAntigravityModels,
2326
parseCodexUsage,
2427
parseGeminiCliQuota,
2528
parseKiroQuota,
2629
parseCopilotQuota,
30+
parseClaudeUsage,
2731
} from './parsers';
2832

2933
function formatQuotaError(result: { statusCode: number; body?: unknown; bodyText?: string }): string {
@@ -49,6 +53,29 @@ function formatQuotaError(result: { statusCode: number; body?: unknown; bodyText
4953
}
5054

5155
export const quotaApi = {
56+
async fetchClaude(authIndex: string): Promise<ClaudeQuotaResult> {
57+
try {
58+
const result = await apiCallApi.request({
59+
authIndex,
60+
method: 'GET',
61+
url: CLAUDE_USAGE_URL,
62+
header: { ...CLAUDE_HEADERS }
63+
});
64+
65+
if (result.statusCode >= 200 && result.statusCode < 300) {
66+
return parseClaudeUsage(result.body);
67+
}
68+
69+
if (result.statusCode === 401) {
70+
return { models: [], error: 'Token expired, please re-authenticate' };
71+
}
72+
73+
return { models: [], error: formatQuotaError(result) };
74+
} catch (err) {
75+
return { models: [], error: (err as Error).message };
76+
}
77+
},
78+
5279
async fetchAntigravity(authIndex: string): Promise<AntigravityQuotaResult> {
5380
let lastError = '';
5481

src/types/quota.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export interface AntigravityQuotaResult {
1212
error?: string;
1313
}
1414

15+
export interface ClaudeQuotaResult {
16+
models: QuotaModel[];
17+
email?: string;
18+
error?: string;
19+
}
20+
1521
export interface CodexQuotaResult {
1622
plan?: string;
1723
limits: Array<{ name: string; percentage: number; resetTime?: string }>;

0 commit comments

Comments
 (0)