fix(FN-000): restore Claude usage dashboard after Anthropic OAuth changes

Anthropic now requires `anthropic-beta: oauth-2025-04-20` on /api/oauth/usage
for OAuth-scoped tokens; without it the endpoint returns 401 "OAuth
authentication is currently not supported" and the dashboard falls back to
a PTY-based CLI parser that times out at 75s. Mirrors the header the Claude
CLI (2.1.114) sends from `claude /usage`, and also aligns the refresh call
with the CLI's JSON body + `scope` field so token refresh keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-18 20:53:55 -07:00
parent 0904760f5e
commit 14cf34bef2
2 changed files with 52 additions and 18 deletions

View File

@@ -446,7 +446,7 @@ describe("usage", () => {
expect(claude.status).toBe("error");
});
it("does not send anthropic-beta header in requests", async () => {
it("sends anthropic-beta=oauth-2025-04-20 header so the OAuth usage endpoint authorizes the request", async () => {
const mockResponse = {
five_hour: { utilization: 10.0 },
};
@@ -491,8 +491,11 @@ describe("usage", () => {
await fetchAllProviderUsage();
// Verify no anthropic-beta header is sent
expect(capturedHeaders).not.toHaveProperty("anthropic-beta");
// Anthropic now requires the `anthropic-beta: oauth-2025-04-20` header on
// /api/oauth/usage for OAuth-scoped tokens; without it the endpoint
// replies with 401 "OAuth authentication is currently not supported".
// The value mirrors what the Claude CLI sends from `claude /usage`.
expect(capturedHeaders["anthropic-beta"]).toBe("oauth-2025-04-20");
});
it("retries on 429 and succeeds after transient rate limit", async () => {
@@ -1206,19 +1209,20 @@ describe("usage", () => {
await fetchAllProviderUsage();
// Verify the refresh request (first call)
// Verify the refresh request (first call). Body is JSON with a `scope`
// field — matches what the Claude CLI sends; form-urlencoded bodies or
// missing scope cause Anthropic to reject the refresh.
const refreshOpts = capturedOptions[0];
expect(refreshOpts.hostname).toBe("platform.claude.com");
expect(refreshOpts.path).toBe("/v1/oauth/token");
expect(refreshOpts.headers["content-type"]).toBe("application/x-www-form-urlencoded");
expect(refreshOpts.headers["content-type"]).toBe("application/json");
// Verify body contains required parameters
expect(capturedBodies.length).toBeGreaterThanOrEqual(1);
const body = capturedBodies[0];
const params = new URLSearchParams(body);
expect(params.get("grant_type")).toBe("refresh_token");
expect(params.get("refresh_token")).toBe("refresh-token-456");
expect(params.get("client_id")).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e");
const body = JSON.parse(capturedBodies[0]);
expect(body.grant_type).toBe("refresh_token");
expect(body.refresh_token).toBe("refresh-token-456");
expect(body.client_id).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e");
expect(body.scope).toBe("user:profile");
});
it("falls back to CLI when refresh fails for expired token", async () => {

View File

@@ -323,6 +323,20 @@ const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
*/
const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
/**
* `anthropic-beta` header value required by the Anthropic API to authorize
* OAuth-scoped access to `/api/oauth/usage`. Without this header the endpoint
* returns 401 "OAuth authentication is currently not supported". Value mirrors
* what the Claude CLI (`claude /usage`) sends — bump when the CLI does.
*/
const ANTHROPIC_OAUTH_BETA = "oauth-2025-04-20";
/**
* User-Agent sent alongside OAuth usage requests. Matches the format used by
* the Claude CLI so the call is recognizable to Anthropic.
*/
const CLAUDE_USAGE_USER_AGENT = "claude-code-fusion-dashboard";
/**
* Check whether an OAuth access token is expired using the `expiresAt` timestamp
* from the credential store. Returns true if expired or expiring within 60 seconds.
@@ -337,19 +351,33 @@ function isTokenExpired(expiresAt: number | undefined): boolean {
* Attempt to refresh the OAuth access token using the refresh token.
* Returns the new access token on success, or null on failure.
* The refreshed token is cached in memory only (not written to disk/keychain).
*
* Request shape mirrors what the Claude CLI sends: JSON body, includes a
* `scope` field, and posts to platform.claude.com. Sending the body as
* form-urlencoded or omitting `scope` causes Anthropic to respond with 4xx
* errors (or silently rate-limit) even when the refresh token is valid.
*/
async function refreshClaudeAccessToken(refreshToken: string): Promise<string | null> {
async function refreshClaudeAccessToken(
refreshToken: string,
scopes?: string[],
): Promise<string | null> {
try {
const body = new URLSearchParams({
const payload: Record<string, string> = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
}).toString();
};
if (scopes && scopes.length > 0) {
payload.scope = scopes.join(" ");
}
const res = await httpsRequest(ANTHROPIC_TOKEN_ENDPOINT, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
headers: {
"content-type": "application/json",
"user-agent": CLAUDE_USAGE_USER_AGENT,
},
body: JSON.stringify(payload),
timeout: 10_000, // 10s timeout for refresh
});
@@ -870,7 +898,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
if (tokenExpired && !refreshedAccessToken) {
// Token is expired — attempt refresh before calling the usage API
if (oauthCreds.refreshToken) {
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken, scopes);
if (newToken) {
activeToken = newToken;
} else {
@@ -893,6 +921,8 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
method: "GET",
headers: {
authorization: `Bearer ${activeToken}`,
"anthropic-beta": ANTHROPIC_OAUTH_BETA,
"user-agent": CLAUDE_USAGE_USER_AGENT,
},
});
@@ -902,7 +932,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
if (res.status === 401 || res.status === 403) {
if (oauthCreds.refreshToken && activeToken !== refreshedAccessToken) {
// Try refreshing the token as a recovery path
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken, scopes);
if (newToken) {
activeToken = newToken;
continue; // Retry with refreshed token