mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +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",
|
"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.",
|
"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",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+145
-99
@@ -652,145 +652,191 @@ function completionResponse(res, id, model, content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Plan usage probe ────────────────────────────────────────────────────
|
// ── Plan usage probe ────────────────────────────────────────────────────
|
||||||
// Reads the OAuth token from macOS keychain and makes a minimal API call
|
// Uses the dedicated /api/oauth/usage endpoint (same as Claude Code CLI)
|
||||||
// to Anthropic to capture rate-limit headers (plan usage info).
|
// with Bearer auth + anthropic-beta header. Auto-refreshes expired tokens.
|
||||||
// Caches the result for 5 minutes to avoid excessive API calls.
|
// Caches the result for 5 minutes to avoid excessive API calls.
|
||||||
|
|
||||||
let usageCache = { data: null, fetchedAt: 0 };
|
let usageCache = { data: null, fetchedAt: 0 };
|
||||||
const USAGE_CACHE_TTL = 300000; // 5 min
|
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 Linux file-based credentials first
|
||||||
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"));
|
||||||
const token = creds?.claudeAiOauth?.accessToken;
|
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
|
||||||
if (token) return token;
|
|
||||||
} catch { /* fall through to macOS keychain */ }
|
} 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", label, "-w"
|
||||||
|
], { encoding: "utf8", timeout: 5000 }).trim();
|
||||||
|
const creds = JSON.parse(raw);
|
||||||
|
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
|
||||||
|
} catch { /* try next */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOAuthToken(refreshToken) {
|
||||||
try {
|
try {
|
||||||
const raw = execFileSync("security", [
|
const resp = await fetch(OAUTH_TOKEN_URL, {
|
||||||
"find-generic-password", "-s", "Claude Code-credentials", "-w"
|
method: "POST",
|
||||||
], { encoding: "utf8", timeout: 5000 }).trim();
|
headers: { "Content-Type": "application/json" },
|
||||||
const creds = JSON.parse(raw);
|
body: JSON.stringify({
|
||||||
return creds?.claudeAiOauth?.accessToken || null;
|
grant_type: "refresh_token",
|
||||||
} catch {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUsageFromApi() {
|
async function fetchUsageFromApi() {
|
||||||
const token = getOAuthToken();
|
const creds = getOAuthCredentials();
|
||||||
if (!token) {
|
if (!creds?.accessToken) {
|
||||||
return { error: "No OAuth token found in keychain" };
|
return { error: "No OAuth token found in keychain" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal API call to haiku (cheapest) with max_tokens=1 — we only need the headers
|
let token = creds.accessToken;
|
||||||
const body = JSON.stringify({
|
|
||||||
model: "claude-haiku-4-5-20251001",
|
// Check if token looks expired (5 min buffer, same as Claude Code)
|
||||||
max_tokens: 1,
|
if (creds.expiresAt && Date.now() + 300000 >= creds.expiresAt) {
|
||||||
messages: [{ role: "user", content: "." }],
|
if (creds.refreshToken) {
|
||||||
});
|
logEvent("info", "oauth_token_expired_refreshing");
|
||||||
|
const newToken = await refreshOAuthToken(creds.refreshToken);
|
||||||
|
if (newToken) token = newToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("https://api.anthropic.com/v1/messages", {
|
const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
||||||
method: "POST",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"x-api-key": token,
|
"Authorization": `Bearer ${token}`,
|
||||||
"anthropic-version": "2023-06-01",
|
"anthropic-beta": OAUTH_BETA_HEADER,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body,
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
// Extract all rate-limit headers
|
if (!resp.ok) {
|
||||||
const rl = {};
|
// If 401, try refreshing token once
|
||||||
for (const [k, v] of resp.headers) {
|
if (resp.status === 401 && creds.refreshToken) {
|
||||||
if (k.startsWith("anthropic-ratelimit")) {
|
logEvent("info", "oauth_usage_401_refreshing");
|
||||||
rl[k] = v;
|
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();
|
||||||
const now = Date.now();
|
return parseUsageResponse(data);
|
||||||
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;
|
|
||||||
if (diff <= 0) return "now";
|
|
||||||
const h = Math.floor(diff / 3600000);
|
|
||||||
const m = Math.floor((diff % 3600000) / 60000);
|
|
||||||
if (h > 24) {
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
return `${d}d ${h % 24}h`;
|
|
||||||
}
|
|
||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDay(epochSec) {
|
|
||||||
if (!epochSec) return "";
|
|
||||||
const d = new Date(epochSec * 1000);
|
|
||||||
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
weeklyLimits: {
|
|
||||||
allModels: {
|
|
||||||
utilization: weekly7dUtil,
|
|
||||||
percent: `${Math.round(weekly7dUtil * 100)}%`,
|
|
||||||
resetsIn: formatReset(weekly7dReset),
|
|
||||||
resetsAt: weekly7dReset ? new Date(weekly7dReset * 1000).toISOString() : null,
|
|
||||||
resetsAtHuman: resetDay(weekly7dReset),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extraUsage: {
|
|
||||||
status: overageStatus,
|
|
||||||
disabledReason: overageDisabledReason || undefined,
|
|
||||||
},
|
|
||||||
representativeClaim,
|
|
||||||
fallbackPercentage: fallbackPct,
|
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
totalRequests: stats.totalRequests,
|
|
||||||
activeRequests: stats.activeRequests,
|
|
||||||
errors: stats.errors,
|
|
||||||
timeouts: stats.timeouts,
|
|
||||||
uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`,
|
|
||||||
},
|
|
||||||
models: getModelStatsSnapshot(),
|
|
||||||
_raw: 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) {
|
||||||
|
const now = Date.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);
|
||||||
|
if (h > 24) {
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
return `${d}d ${h % 24}h`;
|
||||||
|
}
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "active",
|
||||||
|
fetchedAt: new Date(now).toISOString(),
|
||||||
|
plan: {
|
||||||
|
currentSession: {
|
||||||
|
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: (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: extraUsage.is_enabled ? "enabled" : "disabled",
|
||||||
|
monthlyLimit: extraUsage.monthly_limit,
|
||||||
|
usedCredits: extraUsage.used_credits,
|
||||||
|
utilization: extraUsage.utilization,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
totalRequests: stats.totalRequests,
|
||||||
|
activeRequests: stats.activeRequests,
|
||||||
|
errors: stats.errors,
|
||||||
|
timeouts: stats.timeouts,
|
||||||
|
uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`,
|
||||||
|
},
|
||||||
|
models: getModelStatsSnapshot(),
|
||||||
|
_raw: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUsage(_req, res) {
|
async function handleUsage(_req, res) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let data;
|
let data;
|
||||||
|
|||||||
Reference in New Issue
Block a user