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:
Fusion
2026-05-03 10:36:28 -07:00
committed by gsxdsm
parent fbd2ed0c5f
commit a418662b2f
3 changed files with 237 additions and 19 deletions

View File

@@ -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: {

View File

@@ -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;
}
/**

View File

@@ -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"),