feat(FN-3289): restore Claude usage from Fusion Anthropic auth storage in d
Step 2 of FN-3289 restores Claude usage tracking by integrating with Fusion's existing Anthropic auth-storage instead of maintaining separate credentials, eliminating credential duplication and improving consistency between the dashboard and CLI. The changes update the usage tracking module, add cor Fusion-Task-Id: FN-3289
This commit is contained in:
@@ -255,6 +255,172 @@ describe("usage", () => {
|
||||
expect(claude).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads Claude credentials from Fusion auth-storage anthropic oauth when no CLI files exist", async () => {
|
||||
mockReadFile.mockImplementation(async () => {
|
||||
return Promise.reject(new Error("File not found"));
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const authStorage = {
|
||||
reload: vi.fn(),
|
||||
hasAuth: vi.fn((provider: string) => provider === "anthropic"),
|
||||
get: vi.fn((provider: string) => {
|
||||
if (provider !== "anthropic") return null;
|
||||
return {
|
||||
type: "oauth",
|
||||
access: "fusion-access-token",
|
||||
refresh: "fusion-refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
scopes: ["user:profile"],
|
||||
subscriptionType: "pro",
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Mock the usage API response
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((_options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from(JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 40.0,
|
||||
resets_at: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 15.0,
|
||||
resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
})));
|
||||
}
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage(authStorage);
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
// Claude should now be authenticated via Fusion auth-storage anthropic credentials
|
||||
expect(claude).toBeDefined();
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.plan).toBe("Pro");
|
||||
expect(claude.windows).toHaveLength(2);
|
||||
|
||||
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"));
|
||||
expect(sessionWindow).toBeDefined();
|
||||
expect(sessionWindow!.percentUsed).toBe(40);
|
||||
|
||||
// Verify authStorage.get was called for "anthropic"
|
||||
expect(authStorage.get).toHaveBeenCalledWith("anthropic");
|
||||
});
|
||||
|
||||
it("falls back to CLI when Fusion auth-storage anthropic token is expired and refresh fails", async () => {
|
||||
mockReadFile.mockImplementation(async () => {
|
||||
return Promise.reject(new Error("File not found"));
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const authStorage = {
|
||||
reload: vi.fn(),
|
||||
hasAuth: vi.fn(() => true),
|
||||
get: vi.fn((provider: string) => {
|
||||
if (provider !== "anthropic") return null;
|
||||
return {
|
||||
type: "oauth",
|
||||
access: "expired-fusion-token",
|
||||
refresh: "bad-refresh-token",
|
||||
expires: Date.now() - 60_000, // expired 1 minute ago
|
||||
scopes: ["user:profile"],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Token refresh fails, CLI fallback fails (node-pty mocked to throw)
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((_options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 400,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from('{"error":"invalid_grant"}'));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage(authStorage);
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
// Falls back to CLI (which fails in test env) — should get error, not no-auth
|
||||
expect(claude.status).toBe("error");
|
||||
});
|
||||
|
||||
it("prefers Fusion auth-storage over legacy Claude CLI files", async () => {
|
||||
// Both sources available — Fusion should win
|
||||
mockReadFile.mockImplementation((filePath: string) => {
|
||||
if (filePath.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "legacy-cli-token",
|
||||
scopes: ["user:profile"],
|
||||
subscriptionType: "free",
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("File not found"));
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const authStorage = {
|
||||
reload: vi.fn(),
|
||||
hasAuth: vi.fn(() => true),
|
||||
get: vi.fn((provider: string) => {
|
||||
if (provider !== "anthropic") return null;
|
||||
return {
|
||||
type: "oauth",
|
||||
access: "fusion-access-token",
|
||||
refresh: "fusion-refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
scopes: ["user:profile"],
|
||||
subscriptionType: "max",
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((_options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({ five_hour: { utilization: 10.0 } })));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage(authStorage);
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
// Fusion plan (max) should take precedence over CLI plan (free)
|
||||
expect(claude.plan).toBe("Max");
|
||||
});
|
||||
|
||||
it("reads credentials from macOS keychain when file paths fail", async () => {
|
||||
setupClaudeMocks({
|
||||
keychainContent: {
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface AuthStorageLike {
|
||||
/** Get the configured API key for usage providers. */
|
||||
getApiKey?(providerId: string): string | null | undefined | Promise<string | null | undefined>;
|
||||
/** Get raw stored credentials for usage providers. */
|
||||
get?(providerId: string): { type?: string; key?: string } | null | undefined;
|
||||
get?(providerId: string): { type?: string; key?: string; access?: string; refresh?: string; expires?: number; [key: string]: unknown } | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,10 +67,27 @@ export interface ProviderUsage {
|
||||
export interface AuthStorageLike {
|
||||
reload(): void;
|
||||
hasAuth(provider: string): boolean;
|
||||
get?(provider: string): { type?: string; key?: string } | null | undefined;
|
||||
get?(provider: string): AuthCredentialEntry | null | undefined;
|
||||
getApiKey?(provider: string): string | null | undefined | Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential entry returned by AuthStorage.get().
|
||||
* Covers both API-key entries (`{ type: "api_key", key }`) and OAuth entries
|
||||
* (`{ type: "oauth", access, refresh, expires }`) stored by Fusion's auth
|
||||
* subsystem. The `[key: string]: unknown` index signature allows additional
|
||||
* provider-specific fields (e.g. `scopes`, `subscriptionType`) without
|
||||
* widening the entire interface.
|
||||
*/
|
||||
export interface AuthCredentialEntry {
|
||||
type?: string;
|
||||
key?: string;
|
||||
access?: string;
|
||||
refresh?: string;
|
||||
expires?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Cache for usage data with TTL
|
||||
interface CacheEntry {
|
||||
data: ProviderUsage[];
|
||||
@@ -835,12 +852,18 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
/**
|
||||
* Fetch Claude usage data via the Anthropic OAuth usage API.
|
||||
*
|
||||
* Reads credentials from the Claude CLI's credential store (files or macOS
|
||||
* keychain) and calls api.anthropic.com/api/oauth/usage directly.
|
||||
* Reads credentials from (in order of precedence):
|
||||
* 1. Fusion auth storage (`authStorage.get("anthropic")`) — OAuth credentials
|
||||
* stored by the `fn auth login anthropic` flow.
|
||||
* 2. Claude CLI credential files (`~/.claude/.credentials.json`,
|
||||
* `~/.config/claude/.credentials.json`).
|
||||
* 3. macOS keychain (`Claude Code-credentials`).
|
||||
*
|
||||
* Then calls api.anthropic.com/api/oauth/usage directly.
|
||||
* Includes retry logic with exponential backoff for transient 429 responses.
|
||||
* Falls back to parsing `claude /usage` CLI output when rate limited.
|
||||
*/
|
||||
async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "Claude",
|
||||
icon: "🟠",
|
||||
@@ -849,30 +872,59 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
};
|
||||
|
||||
// ── Credential reading for plan detection & auth check ──────────────
|
||||
const credPaths = [
|
||||
path.join(getHomeDir(), ".claude", ".credentials.json"),
|
||||
path.join(getHomeDir(), ".config", "claude", ".credentials.json"),
|
||||
];
|
||||
|
||||
// Try Fusion auth storage first (OAuth credentials from `fn auth login anthropic`).
|
||||
// Normalize to the same shape as Claude CLI credentials so the rest of the
|
||||
// fetcher works unchanged.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped credentials JSON
|
||||
let creds: any = null;
|
||||
for (const p of credPaths) {
|
||||
try {
|
||||
creds = JSON.parse(await readFile(p, "utf-8"));
|
||||
break;
|
||||
} catch {
|
||||
// File doesn't exist or invalid JSON - continue to next path
|
||||
|
||||
try {
|
||||
authStorage?.reload();
|
||||
} catch {
|
||||
// Reload may fail if no storage - ignore
|
||||
}
|
||||
try {
|
||||
const fusionCreds = authStorage?.get?.("anthropic");
|
||||
if (fusionCreds?.type === "oauth" && fusionCreds.access) {
|
||||
creds = {
|
||||
accessToken: fusionCreds.access,
|
||||
refreshToken: fusionCreds.refresh || undefined,
|
||||
expiresAt: typeof fusionCreds.expires === "number" ? fusionCreds.expires : undefined,
|
||||
scopes: Array.isArray(fusionCreds.scopes) ? fusionCreds.scopes : ["user:profile"],
|
||||
...(fusionCreds.subscriptionType ? { subscriptionType: fusionCreds.subscriptionType } : {}),
|
||||
...(fusionCreds.rateLimitTier ? { rateLimitTier: fusionCreds.rateLimitTier } : {}),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// get() may not be implemented or throw - ignore
|
||||
}
|
||||
|
||||
// Fallback to macOS keychain if file credentials not found
|
||||
// Legacy: Claude CLI credential files
|
||||
if (!creds) {
|
||||
creds = await readClaudeKeychainCredentials();
|
||||
const credPaths = [
|
||||
path.join(getHomeDir(), ".claude", ".credentials.json"),
|
||||
path.join(getHomeDir(), ".config", "claude", ".credentials.json"),
|
||||
];
|
||||
|
||||
for (const p of credPaths) {
|
||||
try {
|
||||
creds = JSON.parse(await readFile(p, "utf-8"));
|
||||
break;
|
||||
} catch {
|
||||
// File doesn't exist or invalid JSON - continue to next path
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to macOS keychain if file credentials not found
|
||||
if (!creds) {
|
||||
creds = await readClaudeKeychainCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
const oauthCreds = creds?.claudeAiOauth || creds;
|
||||
if (!oauthCreds?.accessToken) {
|
||||
usage.error = "No Claude CLI credentials — run 'claude' to login";
|
||||
usage.error = "No Claude credentials — run 'claude' to login or 'fn auth login anthropic'";
|
||||
return usage;
|
||||
}
|
||||
|
||||
@@ -1686,7 +1738,7 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom
|
||||
// Fetch all providers in parallel with per-provider timeout
|
||||
// Currently includes: Claude, Codex, Gemini, Minimax, Zai, GitHub Copilot
|
||||
const results = await Promise.allSettled([
|
||||
withTimeout(fetchClaudeUsage(), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
|
||||
withTimeout(fetchClaudeUsage(authStorage), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
|
||||
withTimeout(fetchCodexUsage(), "Codex"),
|
||||
withTimeout(fetchGeminiUsage(), "Gemini"),
|
||||
withTimeout(fetchMinimaxUsage(authStorage), "Minimax"),
|
||||
|
||||
Reference in New Issue
Block a user