diff --git a/package.json b/package.json index cdb7eb5..032802e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server.mjs b/server.mjs index 0a19949..cd7e9f7 100644 --- a/server.mjs +++ b/server.mjs @@ -652,145 +652,191 @@ 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", 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 { - const raw = execFileSync("security", [ - "find-generic-password", "-s", "Claude Code-credentials", "-w" - ], { encoding: "utf8", timeout: 5000 }).trim(); - const creds = JSON.parse(raw); - return creds?.claudeAiOauth?.accessToken || null; - } catch { + 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 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; - 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, - }; + 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(); + + 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) { const now = Date.now(); let data;