mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
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:
+1
-1
@@ -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
@@ -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}` };
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
return parseUsageResponse(data);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
return { error: `Failed to fetch usage: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Parse into structured usage object
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user