Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@
"minimum": 0,
"maximum": 4,
"description": "Number of decimal places for cost display"
},
"claudeCodeUsage.usageLimitTracking": {
"type": "boolean",
"default": true,
"description": "Enable usage limit tracking (requires Claude Code authentication via 'claude auth login')"
}
}
},
Expand Down
199 changes: 199 additions & 0 deletions src/claudeApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ClaudeCredentials, ClaudeApiUsageResponse } from './types';

export class ClaudeApiClient {
private credentialsPath: string;
private credentials: ClaudeCredentials | null = null;
private rateLimitedUntil: number = 0;

constructor() {
const homeDir = os.homedir();
this.credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
}

/**
* Load credentials from disk
*/
private async loadCredentials(): Promise<ClaudeCredentials | null> {
try {
if (!fs.existsSync(this.credentialsPath)) {
console.warn('[ClaudeAPI] Credentials file not found:', this.credentialsPath);
return null;
}

const content = await fs.promises.readFile(this.credentialsPath, 'utf-8');
this.credentials = JSON.parse(content);
return this.credentials;
} catch (error) {
console.error('[ClaudeAPI] Failed to load credentials:', error);
return null;
}
}

/**
* Save updated credentials to disk
*/
private async saveCredentials(credentials: ClaudeCredentials): Promise<void> {
try {
await fs.promises.writeFile(
this.credentialsPath,
JSON.stringify(credentials),
'utf-8'
);
this.credentials = credentials;
} catch (error) {
console.error('[ClaudeAPI] Failed to save credentials:', error);
throw error;
}
}

/**
* Check if access token is expired or about to expire (within 60 seconds)
*/
private isTokenExpired(credentials: ClaudeCredentials): boolean {
const now = Date.now();
const expiresAt = credentials.claudeAiOauth.expiresAt;
const bufferTime = 60 * 1000; // 60 seconds buffer
return now >= (expiresAt - bufferTime);
}

/**
* Refresh the access token using the refresh token
* Uses console.anthropic.com endpoint (same as Claude Code CLI)
*/
private async refreshAccessToken(credentials: ClaudeCredentials): Promise<ClaudeCredentials> {
console.log('[ClaudeAPI] Refreshing access token...');
const response = await fetch('https://console.anthropic.com/v1/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: credentials.claudeAiOauth.refreshToken,
grant_type: 'refresh_token',
}),
});

if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`);
}

const data = await response.json() as { access_token: string; expires_in: number };

const updatedCredentials: ClaudeCredentials = {
...credentials,
claudeAiOauth: {
...credentials.claudeAiOauth,
accessToken: data.access_token,
expiresAt: Date.now() + (data.expires_in * 1000),
},
};

await this.saveCredentials(updatedCredentials);
console.log('[ClaudeAPI] Token refreshed successfully');
return updatedCredentials;
}

/**
* Get valid credentials, refreshing if necessary
*/
private async getValidCredentials(): Promise<ClaudeCredentials | null> {
let credentials = this.credentials || await this.loadCredentials();

if (!credentials) {
return null;
}

if (this.isTokenExpired(credentials)) {
console.log('[ClaudeAPI] Access token expired, refreshing...');
try {
credentials = await this.refreshAccessToken(credentials);
} catch (error) {
console.error('[ClaudeAPI] Failed to refresh token:', error);
return null;
}
}

return credentials;
}

/**
* Fetch usage limits from the Anthropic API
* Uses api.anthropic.com/api/oauth/usage (NOT claude.ai which has Cloudflare)
*/
private async callUsageApi(accessToken: string): Promise<Response> {
return fetch('https://api.anthropic.com/api/oauth/usage', {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'anthropic-beta': 'oauth-2025-04-20',
'Content-Type': 'application/json',
},
});
}

async fetchUsageLimits(): Promise<ClaudeApiUsageResponse | null> {
try {
// Respect rate limit backoff
if (Date.now() < this.rateLimitedUntil) {
console.log('[ClaudeAPI] Rate limited, skipping until', new Date(this.rateLimitedUntil).toISOString());
return null;
}

const credentials = await this.getValidCredentials();
if (!credentials) {
console.warn('[ClaudeAPI] No valid credentials available. Run "claude auth login" first.');
return null;
}

console.log('[ClaudeAPI] Fetching usage limits from api.anthropic.com...');
const response = await this.callUsageApi(credentials.claudeAiOauth.accessToken);
console.log('[ClaudeAPI] Response status:', response.status);

// Handle rate limiting with backoff
if (response.status === 429) {
const retryAfter = response.headers.get('retry-after');
const backoffMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5 * 60 * 1000;
this.rateLimitedUntil = Date.now() + backoffMs;
console.warn(`[ClaudeAPI] Rate limited, backing off for ${backoffMs / 1000}s`);
return null;
}

// If 401, try refreshing token and retry once
if (response.status === 401) {
console.log('[ClaudeAPI] Got 401, attempting token refresh and retry...');
try {
const refreshed = await this.refreshAccessToken(credentials);
const retryResponse = await this.callUsageApi(refreshed.claudeAiOauth.accessToken);

if (!retryResponse.ok) {
console.error('[ClaudeAPI] Retry failed:', retryResponse.status);
return null;
}

const data = await retryResponse.json() as ClaudeApiUsageResponse;
console.log('[ClaudeAPI] Usage limits fetched successfully (after retry)');
return data;
} catch (refreshError) {
console.error('[ClaudeAPI] Token refresh failed:', refreshError);
return null;
}
}

if (!response.ok) {
const errorText = await response.text();
console.error('[ClaudeAPI] API error:', response.status, errorText);
return null;
}

const data = await response.json() as ClaudeApiUsageResponse;
console.log('[ClaudeAPI] Usage limits fetched successfully:', JSON.stringify(data, null, 2));
return data;
} catch (error) {
console.error('[ClaudeAPI] Failed to fetch usage limits:', error);
return null;
}
}
}
13 changes: 10 additions & 3 deletions src/dataLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,20 @@ export class ClaudeDataLoader {
static async loadUsageRecords(dataDirectory?: string): Promise<ClaudeUsageRecord[]> {
try {
const claudePaths = dataDirectory ? [dataDirectory] : this.getClaudePaths();
console.log('[DataLoader] Loading records from paths:', claudePaths);
const allFiles: string[] = [];

for (const claudePath of claudePaths) {
const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME);
console.log('[DataLoader] Checking directory:', claudeDir, 'exists:', fs.existsSync(claudeDir));
if (fs.existsSync(claudeDir)) {
const files = await findJsonlFiles(claudeDir);
console.log('[DataLoader] Found JSONL files:', files.length);
allFiles.push(...files);
}
}

console.log('[DataLoader] Total files found:', allFiles.length);
const sortedFiles = await this.sortFilesByTimestamp(allFiles);
const processedHashes = new Set<string>();
const records: ClaudeUsageRecord[] = [];
Expand All @@ -159,12 +163,14 @@ export class ClaudeDataLoader {
.trim()
.split('\n')
.filter((line) => line.trim() !== '');
console.log('[DataLoader] Processing file:', file, 'lines:', lines.length);

for (const line of lines) {
try {
const parsed = JSON.parse(line) as unknown;

if (!validateUsageRecord(parsed)) {
console.log('[DataLoader] Record validation failed for line in', file);
continue;
}

Expand All @@ -181,17 +187,18 @@ export class ClaudeDataLoader {

records.push(data as ClaudeUsageRecord);
} catch (parseError) {
console.warn(`Failed to parse line in ${file}:`, parseError);
console.warn('[DataLoader] Failed to parse line in', file, ':', parseError);
}
}
} catch (fileError) {
console.warn(`Failed to read file ${file}:`, fileError);
console.warn('[DataLoader] Failed to read file', file, ':', fileError);
}
}

console.log('[DataLoader] Loaded total records:', records.length);
return records;
} catch (error) {
console.error('Error loading usage records:', error);
console.error('[DataLoader] Error loading usage records:', error);
return [];
}
}
Expand Down
Loading