fix: use dedicated /api/oauth/usage endpoint for reliable plan data

Replaces the old approach (sending a real messages API request just to
read rate-limit headers) with the dedicated usage endpoint that Claude
Code CLI uses. Fixes intermittent "unknown" plan usage.

- GET /api/oauth/usage with Bearer auth + anthropic-beta header
- Auto-refresh expired OAuth tokens via refresh_token grant
- Try both keychain label formats (claude-code-credentials, Claude Code-credentials)
- Zero API quota consumption for usage checks
- Parse new JSON response format (five_hour/seven_day structure)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 10:52:16 +10:00
co-authored by Claude Opus 4.6
parent 69b20815fa
commit b87992fa3b
2 changed files with 146 additions and 100 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-claude-proxy",
"version": "3.5.0",
"version": "3.5.1",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module",
"bin": {
+110 -64
View File
@@ -652,86 +652,132 @@ function completionResponse(res, id, model, content) {
}
// ── Plan usage probe ────────────────────────────────────────────────────
// Reads the OAuth token from macOS keychain and makes a minimal API call
// to Anthropic to capture rate-limit headers (plan usage info).
// Uses the dedicated /api/oauth/usage endpoint (same as Claude Code CLI)
// with Bearer auth + anthropic-beta header. Auto-refreshes expired tokens.
// Caches the result for 5 minutes to avoid excessive API calls.
let usageCache = { data: null, fetchedAt: 0 };
const USAGE_CACHE_TTL = 300000; // 5 min
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
const OAUTH_BETA_HEADER = "oauth-2025-04-20";
function getOAuthToken() {
function getOAuthCredentials() {
// Try Linux file-based credentials first
try {
const credPath = join(homedir(), ".claude", ".credentials.json");
const creds = JSON.parse(readFileSync(credPath, "utf8"));
const token = creds?.claudeAiOauth?.accessToken;
if (token) return token;
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
} catch { /* fall through to macOS keychain */ }
// Try macOS keychain
// Try macOS keychain (both label formats)
for (const label of ["claude-code-credentials", "Claude Code-credentials"]) {
try {
const raw = execFileSync("security", [
"find-generic-password", "-s", "Claude Code-credentials", "-w"
"find-generic-password", "-s", label, "-w"
], { encoding: "utf8", timeout: 5000 }).trim();
const creds = JSON.parse(raw);
return creds?.claudeAiOauth?.accessToken || null;
} catch {
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
} catch { /* try next */ }
}
return null;
}
async function refreshOAuthToken(refreshToken) {
try {
const resp = await fetch(OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: OAUTH_CLIENT_ID,
scope: "user:inference user:profile",
}),
});
if (!resp.ok) {
const body = await resp.text();
logEvent("warn", "oauth_refresh_failed", { status: resp.status, body: body.slice(0, 200) });
return null;
}
const data = await resp.json();
return data.access_token || null;
} catch (err) {
logEvent("warn", "oauth_refresh_error", { error: err.message });
return null;
}
}
async function fetchUsageFromApi() {
const token = getOAuthToken();
if (!token) {
const creds = getOAuthCredentials();
if (!creds?.accessToken) {
return { error: "No OAuth token found in keychain" };
}
// Minimal API call to haiku (cheapest) with max_tokens=1 — we only need the headers
const body = JSON.stringify({
model: "claude-haiku-4-5-20251001",
max_tokens: 1,
messages: [{ role: "user", content: "." }],
});
let token = creds.accessToken;
// Check if token looks expired (5 min buffer, same as Claude Code)
if (creds.expiresAt && Date.now() + 300000 >= creds.expiresAt) {
if (creds.refreshToken) {
logEvent("info", "oauth_token_expired_refreshing");
const newToken = await refreshOAuthToken(creds.refreshToken);
if (newToken) token = newToken;
}
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const resp = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
method: "GET",
headers: {
"x-api-key": token,
"anthropic-version": "2023-06-01",
"Authorization": `Bearer ${token}`,
"anthropic-beta": OAUTH_BETA_HEADER,
"Content-Type": "application/json",
},
body,
signal: controller.signal,
});
clearTimeout(timeout);
// Extract all rate-limit headers
const rl = {};
for (const [k, v] of resp.headers) {
if (k.startsWith("anthropic-ratelimit")) {
rl[k] = v;
if (!resp.ok) {
// If 401, try refreshing token once
if (resp.status === 401 && creds.refreshToken) {
logEvent("info", "oauth_usage_401_refreshing");
const newToken = await refreshOAuthToken(creds.refreshToken);
if (newToken) {
const retryResp = await fetch("https://api.anthropic.com/api/oauth/usage", {
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}` };
}
// Parse into structured usage object
const data = await resp.json();
return parseUsageResponse(data);
} catch (err) {
clearTimeout(timeout);
return { error: `Failed to fetch usage: ${err.message}` };
}
}
function parseUsageResponse(data) {
const now = Date.now();
const session5hUtil = parseFloat(rl["anthropic-ratelimit-unified-5h-utilization"] || "0");
const session5hReset = parseInt(rl["anthropic-ratelimit-unified-5h-reset"] || "0", 10);
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;
function formatReset(isoStr) {
if (!isoStr) return "unknown";
const diff = new Date(isoStr).getTime() - now;
if (diff <= 0) return "now";
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
@@ -742,38 +788,42 @@ async function fetchUsageFromApi() {
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function resetDay(epochSec) {
if (!epochSec) return "";
const d = new Date(epochSec * 1000);
function resetDay(isoStr) {
if (!isoStr) return "";
const d = new Date(isoStr);
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 {
status,
status: "active",
fetchedAt: new Date(now).toISOString(),
plan: {
currentSession: {
utilization: session5hUtil,
percent: `${Math.round(session5hUtil * 100)}%`,
resetsIn: formatReset(session5hReset),
resetsAt: session5hReset ? new Date(session5hReset * 1000).toISOString() : null,
resetsAtHuman: resetDay(session5hReset),
utilization: (fiveHour.utilization || 0) / 100,
percent: `${Math.round(fiveHour.utilization || 0)}%`,
resetsIn: formatReset(fiveHour.resets_at),
resetsAt: fiveHour.resets_at || null,
resetsAtHuman: resetDay(fiveHour.resets_at),
},
weeklyLimits: {
allModels: {
utilization: weekly7dUtil,
percent: `${Math.round(weekly7dUtil * 100)}%`,
resetsIn: formatReset(weekly7dReset),
resetsAt: weekly7dReset ? new Date(weekly7dReset * 1000).toISOString() : null,
resetsAtHuman: resetDay(weekly7dReset),
utilization: (sevenDay.utilization || 0) / 100,
percent: `${Math.round(sevenDay.utilization || 0)}%`,
resetsIn: formatReset(sevenDay.resets_at),
resetsAt: sevenDay.resets_at || null,
resetsAtHuman: resetDay(sevenDay.resets_at),
},
},
extraUsage: {
status: overageStatus,
disabledReason: overageDisabledReason || undefined,
status: extraUsage.is_enabled ? "enabled" : "disabled",
monthlyLimit: extraUsage.monthly_limit,
usedCredits: extraUsage.used_credits,
utilization: extraUsage.utilization,
},
representativeClaim,
fallbackPercentage: fallbackPct,
},
proxy: {
totalRequests: stats.totalRequests,
@@ -783,12 +833,8 @@ async function fetchUsageFromApi() {
uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`,
},
models: getModelStatsSnapshot(),
_raw: rl,
_raw: data,
};
} catch (err) {
clearTimeout(timeout);
return { error: `Failed to fetch usage: ${err.message}` };
}
}
async function handleUsage(_req, res) {