fix(server): restore header-based /usage (revert b87992f drift) (#21)

Replaces fetchUsageFromApi() hallucinated /api/oauth/usage endpoint
with the original header-based approach: POST /v1/messages with
max_tokens=1 and extract anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}
from response headers.

Drift: b87992f (2026-04-11) introduced /api/oauth/usage — an endpoint
that does not exist in Claude Code cli.js. Hallucinated. Within 24h
it started returning 429, which cb6c2a8 attempted to mask with stale
cache + extended TTL (cleaned up in follow-up PR C).

Golden reference: 47e39d7 v3.0.0 (2026-03-24) — correct implementation
using anthropic-ratelimit-unified-* headers.

Claude Code cli.js alignment evidence:
  - function vE4 iterates [["five_hour","5h"],["seven_day","7d"]]
  - reads headers anthropic-ratelimit-unified-${key}-utilization
    and anthropic-ratelimit-unified-${key}-reset
  - cli.js path: /home/opc/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js

Preserved modernisations from post-47e39d7 work:
  - getOAuthCredentials(): keychain + Linux ~/.claude/.credentials.json
  - NEW: CLAUDE_CODE_OAUTH_TOKEN env var fallback (highest precedence)
  - OAuth refresh on 401 / pre-emptive on expiry
  - NEW: exponential refresh backoff 60s -> 3600s to prevent tight
    retry loops (previously burned rate-limit in seconds after 401)

Added ALIGNMENT anchor comment at top of block referencing cli.js vE4
and ALIGNMENT.md, so future refactors can grep before re-drifting.

Co-authored-by: Oracle Public Cloud User <opc@instance-20230820-1333.subnet07301351.vcn07301351.oraclevcn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-04-20 13:12:29 +10:00
committed by GitHub
co-authored by Oracle Public Cloud User Claude Opus 4.6
parent b908fec7b4
commit fd7973addb
+134 -78
View File
@@ -680,25 +680,42 @@ function completionResponse(res, id, model, content) {
} }
// ── Plan usage probe ──────────────────────────────────────────────────── // ── Plan usage probe ────────────────────────────────────────────────────
// Uses the dedicated /api/oauth/usage endpoint (same as Claude Code CLI) // ── Plan usage probe ────────────────────────────────────────────────────
// with Bearer auth + anthropic-beta header. Auto-refreshes expired tokens. // ALIGNMENT: mirrors Claude Code cli.js vE4 rate-limit header extraction.
// Caches the result for 5 minutes to avoid excessive API calls. // DO NOT switch endpoints without grepping "anthropic-ratelimit-unified" in cli.js.
// 2026-04-11 b87992f drift lesson: /api/oauth/usage is a hallucinated endpoint.
// See ALIGNMENT.md for full history.
//
// Reads OAuth token (keychain / Linux credentials / CLAUDE_CODE_OAUTH_TOKEN env)
// and makes a minimal /v1/messages request to capture anthropic-ratelimit-unified-*
// headers. Caches the result for 5 minutes.
let usageCache = { data: null, fetchedAt: 0 }; let usageCache = { data: null, fetchedAt: 0 };
const USAGE_CACHE_TTL = 900000; // 15 min const USAGE_CACHE_TTL = 5 * 60 * 1000; // 5 min
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
const OAUTH_BETA_HEADER = "oauth-2025-04-20";
// Refresh backoff state — exponential 60s → 3600s.
// Prevents tight loops hammering the token endpoint after a failure
// (lesson from pre-fix session that burned through rate-limit in seconds).
const OAUTH_REFRESH_MIN_BACKOFF = 60 * 1000;
const OAUTH_REFRESH_MAX_BACKOFF = 3600 * 1000;
let oauthRefreshBackoff = { nextAttemptAt: 0, currentDelay: OAUTH_REFRESH_MIN_BACKOFF };
function getOAuthCredentials() { function getOAuthCredentials() {
// Try Linux file-based credentials first // 1. Env var fallback — highest precedence for explicit overrides.
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
return { accessToken: process.env.CLAUDE_CODE_OAUTH_TOKEN };
}
// 2. Linux file-based credentials
try { try {
const credPath = join(homedir(), ".claude", ".credentials.json"); const credPath = join(homedir(), ".claude", ".credentials.json");
const creds = JSON.parse(readFileSync(credPath, "utf8")); const creds = JSON.parse(readFileSync(credPath, "utf8"));
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth; if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
} catch { /* fall through to macOS keychain */ } } catch { /* fall through to macOS keychain */ }
// Try macOS keychain (both label formats) // 3. macOS keychain (both label formats)
for (const label of ["claude-code-credentials", "Claude Code-credentials"]) { for (const label of ["claude-code-credentials", "Claude Code-credentials"]) {
try { try {
const raw = execFileSync("security", [ const raw = execFileSync("security", [
@@ -712,6 +729,13 @@ function getOAuthCredentials() {
} }
async function refreshOAuthToken(refreshToken) { async function refreshOAuthToken(refreshToken) {
const now = Date.now();
if (now < oauthRefreshBackoff.nextAttemptAt) {
logEvent("info", "oauth_refresh_backoff_skip", {
waitMs: oauthRefreshBackoff.nextAttemptAt - now,
});
return null;
}
try { try {
const resp = await fetch(OAUTH_TOKEN_URL, { const resp = await fetch(OAUTH_TOKEN_URL, {
method: "POST", method: "POST",
@@ -725,13 +749,34 @@ async function refreshOAuthToken(refreshToken) {
}); });
if (!resp.ok) { if (!resp.ok) {
const body = await resp.text(); const body = await resp.text();
logEvent("warn", "oauth_refresh_failed", { status: resp.status, body: body.slice(0, 200) }); // Exponential backoff on failure
oauthRefreshBackoff.nextAttemptAt = Date.now() + oauthRefreshBackoff.currentDelay;
oauthRefreshBackoff.currentDelay = Math.min(
oauthRefreshBackoff.currentDelay * 2,
OAUTH_REFRESH_MAX_BACKOFF,
);
logEvent("warn", "oauth_refresh_failed", {
status: resp.status,
body: body.slice(0, 200),
nextBackoffMs: oauthRefreshBackoff.currentDelay,
});
return null; return null;
} }
const data = await resp.json(); const data = await resp.json();
// Reset backoff on success
oauthRefreshBackoff.currentDelay = OAUTH_REFRESH_MIN_BACKOFF;
oauthRefreshBackoff.nextAttemptAt = 0;
return data.access_token || null; return data.access_token || null;
} catch (err) { } catch (err) {
logEvent("warn", "oauth_refresh_error", { error: err.message }); oauthRefreshBackoff.nextAttemptAt = Date.now() + oauthRefreshBackoff.currentDelay;
oauthRefreshBackoff.currentDelay = Math.min(
oauthRefreshBackoff.currentDelay * 2,
OAUTH_REFRESH_MAX_BACKOFF,
);
logEvent("warn", "oauth_refresh_error", {
error: err.message,
nextBackoffMs: oauthRefreshBackoff.currentDelay,
});
return null; return null;
} }
} }
@@ -739,73 +784,88 @@ async function refreshOAuthToken(refreshToken) {
async function fetchUsageFromApi() { async function fetchUsageFromApi() {
const creds = getOAuthCredentials(); const creds = getOAuthCredentials();
if (!creds?.accessToken) { if (!creds?.accessToken) {
return { error: "No OAuth token found in keychain" }; return { error: "No OAuth token found (keychain / ~/.claude/.credentials.json / CLAUDE_CODE_OAUTH_TOKEN)" };
} }
let token = creds.accessToken; let token = creds.accessToken;
// Check if token looks expired (5 min buffer, same as Claude Code) // Pre-emptive refresh if token looks expired (5 min buffer, same as Claude Code)
if (creds.expiresAt && Date.now() + 300000 >= creds.expiresAt) { if (creds.expiresAt && Date.now() + 300000 >= creds.expiresAt && creds.refreshToken) {
if (creds.refreshToken) { logEvent("info", "oauth_token_expired_refreshing");
logEvent("info", "oauth_token_expired_refreshing"); const newToken = await refreshOAuthToken(creds.refreshToken);
const newToken = await refreshOAuthToken(creds.refreshToken); if (newToken) token = newToken;
if (newToken) token = newToken;
}
} }
// Minimal /v1/messages request — we only need the response headers.
// Mirrors Claude Code cli.js vE4: headers anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}.
const body = JSON.stringify({
model: "claude-haiku-4-5-20251001",
max_tokens: 1,
messages: [{ role: "user", content: "." }],
});
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); const timeout = setTimeout(() => controller.abort(), 15000);
const doFetch = (bearerToken) => fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": bearerToken,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
body,
signal: controller.signal,
});
try { try {
const resp = await fetch("https://api.anthropic.com/api/oauth/usage", { let resp = await doFetch(token);
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
"anthropic-beta": OAUTH_BETA_HEADER,
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) { // 401 → try a single refresh-and-retry
// If 401, try refreshing token once if (resp.status === 401 && creds.refreshToken) {
if (resp.status === 401 && creds.refreshToken) { logEvent("info", "oauth_usage_401_refreshing");
logEvent("info", "oauth_usage_401_refreshing"); const newToken = await refreshOAuthToken(creds.refreshToken);
const newToken = await refreshOAuthToken(creds.refreshToken); if (newToken) {
if (newToken) { token = newToken;
const retryResp = await fetch("https://api.anthropic.com/api/oauth/usage", { resp = await doFetch(token);
method: "GET",
headers: {
"Authorization": `Bearer ${newToken}`,
"anthropic-beta": OAUTH_BETA_HEADER,
"Content-Type": "application/json",
},
});
if (retryResp.ok) {
const retryData = await retryResp.json();
return parseUsageResponse(retryData);
}
}
return { error: `Usage API auth failed after refresh (${resp.status})` };
} }
return { error: `Usage API returned ${resp.status}` };
} }
const data = await resp.json(); clearTimeout(timeout);
return parseUsageResponse(data);
// Extract all rate-limit headers (we do not need the response body)
const rl = {};
for (const [k, v] of resp.headers) {
if (k.startsWith("anthropic-ratelimit")) rl[k] = v;
}
if (!resp.ok && Object.keys(rl).length === 0) {
return { error: `Usage API returned ${resp.status} with no rate-limit headers` };
}
return parseRateLimitHeaders(rl);
} catch (err) { } catch (err) {
clearTimeout(timeout); clearTimeout(timeout);
return { error: `Failed to fetch usage: ${err.message}` }; return { error: `Failed to fetch usage: ${err.message}` };
} }
} }
function parseUsageResponse(data) { function parseRateLimitHeaders(rl) {
const now = Date.now(); const now = Date.now();
function formatReset(isoStr) { const session5hUtil = parseFloat(rl["anthropic-ratelimit-unified-5h-utilization"] || "0");
if (!isoStr) return "unknown"; const session5hReset = parseInt(rl["anthropic-ratelimit-unified-5h-reset"] || "0", 10);
const diff = new Date(isoStr).getTime() - now; const weekly7dUtil = parseFloat(rl["anthropic-ratelimit-unified-7d-utilization"] || "0");
const weekly7dReset = parseInt(rl["anthropic-ratelimit-unified-7d-reset"] || "0", 10);
const overageStatus = rl["anthropic-ratelimit-unified-overage-status"] || "unknown";
const overageDisabledReason = rl["anthropic-ratelimit-unified-overage-disabled-reason"] || "";
const status = rl["anthropic-ratelimit-unified-status"] || "unknown";
const representativeClaim = rl["anthropic-ratelimit-unified-representative-claim"] || "";
const fallbackPct = parseFloat(rl["anthropic-ratelimit-unified-fallback-percentage"] || "0");
function formatReset(epochSec) {
if (!epochSec) return "unknown";
const diff = epochSec * 1000 - now;
if (diff <= 0) return "now"; if (diff <= 0) return "now";
const h = Math.floor(diff / 3600000); const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000); const m = Math.floor((diff % 3600000) / 60000);
@@ -816,42 +876,38 @@ function parseUsageResponse(data) {
return h > 0 ? `${h}h ${m}m` : `${m}m`; return h > 0 ? `${h}h ${m}m` : `${m}m`;
} }
function resetDay(isoStr) { function resetDay(epochSec) {
if (!isoStr) return ""; if (!epochSec) return "";
const d = new Date(isoStr); const d = new Date(epochSec * 1000);
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
} }
const fiveHour = data.five_hour || {};
const sevenDay = data.seven_day || {};
const extraUsage = data.extra_usage || {};
return { return {
status: "active", status,
fetchedAt: new Date(now).toISOString(), fetchedAt: new Date(now).toISOString(),
plan: { plan: {
currentSession: { currentSession: {
utilization: (fiveHour.utilization || 0) / 100, utilization: session5hUtil,
percent: `${Math.round(fiveHour.utilization || 0)}%`, percent: `${Math.round(session5hUtil * 100)}%`,
resetsIn: formatReset(fiveHour.resets_at), resetsIn: formatReset(session5hReset),
resetsAt: fiveHour.resets_at || null, resetsAt: session5hReset ? new Date(session5hReset * 1000).toISOString() : null,
resetsAtHuman: resetDay(fiveHour.resets_at), resetsAtHuman: resetDay(session5hReset),
}, },
weeklyLimits: { weeklyLimits: {
allModels: { allModels: {
utilization: (sevenDay.utilization || 0) / 100, utilization: weekly7dUtil,
percent: `${Math.round(sevenDay.utilization || 0)}%`, percent: `${Math.round(weekly7dUtil * 100)}%`,
resetsIn: formatReset(sevenDay.resets_at), resetsIn: formatReset(weekly7dReset),
resetsAt: sevenDay.resets_at || null, resetsAt: weekly7dReset ? new Date(weekly7dReset * 1000).toISOString() : null,
resetsAtHuman: resetDay(sevenDay.resets_at), resetsAtHuman: resetDay(weekly7dReset),
}, },
}, },
extraUsage: { extraUsage: {
status: extraUsage.is_enabled ? "enabled" : "disabled", status: overageStatus,
monthlyLimit: extraUsage.monthly_limit, disabledReason: overageDisabledReason || undefined,
usedCredits: extraUsage.used_credits,
utilization: extraUsage.utilization,
}, },
representativeClaim,
fallbackPercentage: fallbackPct,
}, },
proxy: { proxy: {
totalRequests: stats.totalRequests, totalRequests: stats.totalRequests,
@@ -861,7 +917,7 @@ function parseUsageResponse(data) {
uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`, uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`,
}, },
models: getModelStatsSnapshot(), models: getModelStatsSnapshot(),
_raw: data, _raw: rl,
}; };
} }