quota/getQuotaClaude.mjs

import os from 'os'
import path from 'path'
import get from 'lodash-es/get.js'
import isobj from 'wsemi/src/isobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import castPintOr from '../castPintOr.mjs'
import dfQuotaTimeoutMs from './dfQuotaTimeoutMs.mjs'
import readJsonOrNull from './readJsonOrNull.mjs'
import fetchQuotaJson from './fetchQuotaJson.mjs'
import toQuotaScopedLabel from './toQuotaScopedLabel.mjs'
import toQuotaWindow from './toQuotaWindow.mjs'
import toQuotaResult from './toQuotaResult.mjs'


// getQuotaClaude.mjs — 查詢Claude(Claude Code訂閱)帳號之當前額度
//
// 【資料來源, 2026-09-05於本機實測】
//   設定目錄: CLAUDE_CONFIG_DIR(未設則~/.claude); 實測設此變數指向空目錄後claude auth status回
//     loggedIn:false且該目錄長出.claude.json, 即憑證檔與帳號檔皆隨此變數搬遷——它正是官方的
//     多帳號機制(anthropics/claude-code issue #33430), 也是本套件email比對設計存在的理由。
//   憑證: <configDir>/.credentials.json 之 claudeAiOauth.accessToken, 這是唯一權杖來源。
//     環境變數CLAUDE_CODE_OAUTH_TOKEN(claude setup-token產生之一年期token)【刻意不作為來源】: 2026-09-05實測
//     以setup-token打usage端點回403 {error_code:'oauth_scope_insufficient', required_scopes:['user:profile']},
//     即setup-token僅有user:inference(官方文件「can only make model requests」), 查額度必須用/login存檔之
//     權杖(scopes含user:profile)。故該變數只在憑證檔不存在時用於錯誤提示, 提醒使用者它不能拿來查額度。
//   帳號: <configDir>/.claude.json(設變數時)或~/.claude.json 之 oauthAccount.emailAddress。
//   額度: GET https://api.anthropic.com/api/oauth/usage(即/usage指令之同一來源; 同類工具
//     token-burn、wakamex/ccusage、pinkpixel quota皆用此端點)。
//
// 【為何不自行刷新權杖(刻意)】Anthropic之refresh token每次使用即輪替並作廢前一枚;
//   本函數若刷新而不寫回, Claude Code存檔的refresh token立即失效, 使用者被迫重新登入;
//   寫回則與Claude Code競爭同一檔(anthropics/claude-code #54443即此類race)。
//   同類工具token-burn與ccusage以「原子寫回+身分守衛+重讀」做到了, 但監控程式不擁有憑證
//   生命週期是更穩的邊界, 故本函數維持唯讀。代價: access token實測壽命8小時, Claude Code閒置
//   逾8小時後查詢會401——此時正確指引是「執行一次claude讓它自行刷新」; 不需重新登入,
//   因為存檔之refresh token仍有效(本機實測其期限約登入後30天), 只是要由Claude Code去用它。
//
// 【為何windows優先取limits[]】回應同時存在頂層five_hour/seven_day(舊欄位)與limits[](新結構),
//   後者多出weekly_scoped(帶模型別, 實測Fable 25%), 是實際會先觸頂的窗口; 故優先limits[],
//   缺漏才回退頂層。limits[]實測欄位: kind/group/percent/severity/resets_at/scope{model{display_name},surface}/is_active。
//
// 【已知限制】macOS之Claude Code將憑證存於Keychain, 本函數於該平台會回notfound(未實測故不臆造);
//   該平台亦無替代來源(setup-token不能查額度, 見上方實測)。


//額度查詢端點與帳號端點
let URL_USAGE = 'https://api.anthropic.com/api/oauth/usage'
let URL_PROFILE = 'https://api.anthropic.com/api/oauth/profile'


//端點所需之beta旗標與預設UA(未帶claude-code UA會落入嚴苛之429請求池, 見claude-code #30930)
let BETA_HEADER = 'oauth-2025-04-20'
let DEFAULT_UA = 'claude-code/2.1.259'


//各窗口群組之長度秒數。端點未回傳窗口長度, 此為Anthropic公告之定義(session=5小時、weekly=7天),
//屬推導值; 供應商若調整而此處未更新, 僅label與windowSeconds失準, usedPercent與resets_at仍為實值
let SEC_SESSION = 18000
let SEC_WEEKLY = 604800


//頂層舊欄位回退清單: [欄位鍵, 窗口秒數, 範圍]
let TOP_FIELDS = [
    ['five_hour', SEC_SESSION, ''],
    ['seven_day', SEC_WEEKLY, ''],
    ['seven_day_opus', SEC_WEEKLY, 'Opus'],
    ['seven_day_sonnet', SEC_WEEKLY, 'Sonnet'],
    ['seven_day_cowork', SEC_WEEKLY, 'Cowork'],
    ['seven_day_oauth_apps', SEC_WEEKLY, 'OAuth apps'],
]


//代表Claude Code以非訂閱模式運作之環境變數; 任一有值即無訂閱額度窗口可查
let ENV_NON_SUBSCRIPTION = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX']


/**
 * 將limits[]之單筆正規化為統一窗口物件
 *
 * @param {Object} it 輸入limits[]之單筆物件
 * @returns {Object} 回傳統一窗口物件
 */
function fromLimitItem(it) {

    //group, 決定窗口長度
    let group = get(it, 'group', '')
    let windowSeconds = null
    if (group === 'session') {
        windowSeconds = SEC_SESSION
    }
    else if (group === 'weekly') {
        windowSeconds = SEC_WEEKLY
    }

    //scope, 模型別與介面別皆可能出現(實測scope:{model:{display_name:'Fable'},surface:null}),
    //兩者並列以斜線相接, 令surface-scoped窗口不會與全域窗口同標籤而無從分辨
    let parts = []
    let model = get(it, 'scope.model.display_name', '')
    if (isestr(model)) {
        parts.push(model)
    }
    let surface = get(it, 'scope.surface', '')
    if (isestr(surface)) {
        parts.push(surface)
    }
    let scope = parts.join('/')

    return toQuotaWindow({
        key: get(it, 'kind', ''),
        label: toQuotaScopedLabel(windowSeconds, scope),
        windowSeconds,
        usedPercent: get(it, 'percent', null),
        resetAt: get(it, 'resets_at', ''),
        scope,
        active: get(it, 'is_active', false),
        severity: get(it, 'severity', ''),
    })
}


/**
 * 將頂層舊欄位正規化為統一窗口物件
 *
 * @param {Object} data 輸入端點回應物件
 * @param {String} key 輸入欄位鍵字串
 * @param {Number} windowSeconds 輸入窗口長度秒數
 * @param {String} scope 輸入適用範圍字串
 * @returns {Object|null} 回傳統一窗口物件, 該欄位不存在或為null回傳null
 */
function fromTopField(data, key, windowSeconds, scope) {
    let o = get(data, key, null)
    if (o === null || o === undefined) {
        return null
    }
    return toQuotaWindow({
        key,
        label: toQuotaScopedLabel(windowSeconds, scope),
        windowSeconds,
        usedPercent: get(o, 'utilization', null),
        resetAt: get(o, 'resets_at', ''),
        scope,
    })
}


/**
 * 查詢Claude(Claude Code訂閱)帳號之當前額度
 *
 * 額度綁定本機Claude Code之登入憑證,無「給email查任意帳號」之公開介面,
 * 故email參數之作用為比對——查出本機實際登入者後與其核對,
 * 不符時ok為false且matched為false,並於error載明本機實際登入之帳號;
 * 未給email時不比對,直接回報本機當前帳號之額度。
 * 本函數唯讀憑證、不自行刷新權杖(理由見檔頭);不會reject,一律以結果物件之ok與error欄位回報成敗
 *
 * @param {String} [email=''] 輸入欲查詢之帳號email字串,預設''代表不比對而直接回報本機當前帳號
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.configDir] 輸入Claude設定目錄字串,預設取環境變數CLAUDE_CONFIG_DIR,未設則<homeDir>/.claude
 * @param {String} [opt.homeDir=os.homedir()] 輸入家目錄字串,僅於未指定configDir且未設環境變數時使用,供測試指向替身目錄
 * @param {String} [opt.userAgent='claude-code/2.1.259'] 輸入User-Agent字串,須為claude-code/<版本>形式否則端點將以嚴苛頻率限制回應429
 * @param {Boolean} [opt.profileFallback=true] 輸入本機帳號檔無email時是否改打/api/oauth/profile取得布林值,預設true
 * @param {String} [opt.usageUrl='https://api.anthropic.com/api/oauth/usage'] 輸入額度端點網址字串,供測試指向假伺服器或經企業代理,預設官方端點
 * @param {String} [opt.profileUrl='https://api.anthropic.com/api/oauth/profile'] 輸入帳號端點網址字串,用途同usageUrl,預設官方端點
 * @param {Object} [opt.env=process.env] 輸入環境變數來源物件(讀CLAUDE_CONFIG_DIR、非訂閱模式判定用之ANTHROPIC_API_KEY等;CLAUDE_CODE_OAUTH_TOKEN不作為權杖來源,僅於無憑證時用於錯誤提示),供測試隔離本機環境,預設process.env
 * @param {Number} [opt.timeoutMs=20000] 輸入單次請求之逾時毫秒正整數,預設20000
 * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok、provider('claude')、email(本機實際登入帳號)、matched、plan(方案別,例如'max')、planTier(級距,例如'default_claude_max_20x')、source('anthropic-oauth-usage-api')、windows(額度窗口陣列,含5小時、7天、7天模型別)、credits(額外用量與花費資訊)、raw(原始回應)、error、errorType、durationMs,本函數不會reject
 * @example
 * //need claude code logged in
 *
 * import getQuotaClaude from './src/quota/getQuotaClaude.mjs'
 *
 * let test = async () => {
 *     let r = await getQuotaClaude('firsemisphere2@gmail.com')
 *     console.log(r.ok, r.plan)
 *     // => true max
 *     console.log(r.windows[0].label, r.windows[0].usedPercent)
 *     // => 5小時 11 (百分比為查詢當下之即時值, 每次不同)
 * }
 * test()
 *
 */
async function getQuotaClaude(email = '', opt = {}) {

    let t0 = Date.now()
    let source = 'anthropic-oauth-usage-api'

    //emailWant, 非有效字串視為不比對
    let emailWant = isestr(email) ? email : ''

    //env, 環境變數來源可注入(測試以替身隔離本機環境; 亦供多帳號部署逐呼叫給定), 預設process.env
    let env = get(opt, 'env', null)
    if (!isobj(env)) {
        env = process.env
    }

    //homeDir
    let homeDir = get(opt, 'homeDir', null)
    if (!isestr(homeDir)) {
        homeDir = os.homedir()
    }

    //configDir, 依序取opt、CLAUDE_CONFIG_DIR、<homeDir>/.claude; 前二者代表使用者刻意搬遷,
    //此時.claude.json亦在該目錄(實測), 否則在家目錄
    let configDir = get(opt, 'configDir', null)
    let relocated = true
    if (!isestr(configDir)) {
        configDir = get(env, 'CLAUDE_CONFIG_DIR', '')
        if (!isestr(configDir)) {
            configDir = path.join(homeDir, '.claude')
            relocated = false
        }
    }
    let fpCred = path.join(configDir, '.credentials.json')
    let fpConf = relocated ? path.join(configDir, '.claude.json') : path.join(homeDir, '.claude.json')

    //timeoutMs
    let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), dfQuotaTimeoutMs)

    //userAgent
    let userAgent = get(opt, 'userAgent', null)
    if (!isestr(userAgent)) {
        userAgent = DEFAULT_UA
    }

    //profileFallback
    let profileFallback = get(opt, 'profileFallback', true) !== false

    //usageUrl與profileUrl, 可覆寫(測試指向假伺服器、或經企業代理), 預設官方端點
    let usageUrl = get(opt, 'usageUrl', null)
    if (!isestr(usageUrl)) {
        usageUrl = URL_USAGE
    }
    let profileUrl = get(opt, 'profileUrl', null)
    if (!isestr(profileUrl)) {
        profileUrl = URL_PROFILE
    }

    //cred與conf
    let cred = readJsonOrNull(fpCred)
    let conf = readJsonOrNull(fpConf)

    //token, 僅取憑證檔(登入所存之access token, 帶user:profile scope); 環境變數CLAUDE_CODE_OAUTH_TOKEN
    //不作為來源(setup-token缺usage端點要求之scope, 見檔頭實測), 僅於下方無憑證時用來給更準的提示
    let token = get(cred, 'claudeAiOauth.accessToken', '')

    //emailHave, 僅帳號檔載有email, 額度端點本身不回傳帳號
    let emailHave = get(conf, 'oauthAccount.emailAddress', '')
    if (!isestr(emailHave)) {
        emailHave = ''
    }

    //plan與planTier, 憑證檔為先(登入當下寫入), 帳號檔次之
    let plan = get(cred, 'claudeAiOauth.subscriptionType', '')
    if (!isestr(plan)) {
        plan = get(conf, 'oauthAccount.organizationType', '')
        if (!isestr(plan)) {
            plan = ''
        }
    }
    let planTier = get(cred, 'claudeAiOauth.rateLimitTier', '')
    if (!isestr(planTier)) {
        planTier = get(conf, 'oauthAccount.organizationRateLimitTier', '')
        if (!isestr(planTier)) {
            planTier = ''
        }
    }

    //fin, 統一收尾
    let fin = (o) => {
        return toQuotaResult('claude', {
            email: emailHave,
            emailWant,
            plan,
            planTier,
            source,
            durationMs: Date.now() - t0,
            ...o,
        })
    }

    //check token
    if (!isestr(token)) {

        //unsupported, 以API key或雲端閘道模式運作者按用量計費, 無訂閱額度窗口, 指引其登入只是誤導
        let envs = ENV_NON_SUBSCRIPTION.filter((k) => isestr(get(env, k, '')))
        if (envs.length > 0) {
            return fin({
                error: `Claude Code is running in non-subscription mode (env ${envs.join(', ')} is set); this mode is billed by usage and has no 5-hour/7-day subscription quota windows`,
                errorType: 'unsupported',
            })
        }

        //notfound; 環境變數有setup-token時特別說明它不能拿來查額度(2026-09-05實測403 oauth_scope_insufficient), 免得使用者以為設了就該能查
        let noteEnv = isestr(get(env, 'CLAUDE_CODE_OAUTH_TOKEN', ''))
            ? ' Env CLAUDE_CODE_OAUTH_TOKEN is set but is ignored here: a setup-token lacks the user:profile scope the usage endpoint requires, so only a stored login can be queried.'
            : ''
        return fin({
            error: `Claude Code credential not found (${fpCred}); run [claude auth login] first (with the same CLAUDE_CONFIG_DIR if this config dir is relocated). On macOS the credential lives in Keychain instead of this file, which this function cannot read.${noteEnv}`,
            errorType: 'notfound',
        })
    }

    //headers
    let headers = {
        'Authorization': `Bearer ${token}`,
        'anthropic-beta': BETA_HEADER,
        'User-Agent': userAgent,
        'Content-Type': 'application/json',
    }

    //fetch usage, 錯誤訊息中之權杖一律遮蔽
    let r = await fetchQuotaJson(usageUrl, { headers, timeoutMs, redact: [token] })
    if (!r.ok) {

        let msg = r.error

        //auth, 最常見成因是access token自然到期(實測壽命8小時); Claude Code每次執行皆會自行刷新,
        //正確指引是執行一次claude; 不需重新登入(存檔之refresh token仍有效, 只是要由Claude Code去用它)
        if (r.errorType === 'auth') {
            let expiresAt = get(cred, 'claudeAiOauth.expiresAt', null)
            let hint = 'run any claude command (just open claude) so Claude Code refreshes the token itself, then retry; re-login is not needed, the stored refresh token is still valid and only has to be used by Claude Code'
            let when = isnum(expiresAt) ? `(local recorded expiry: ${new Date(expiresAt).toISOString()})` : ''
            msg = `${msg} ${when} → ${hint}`
        }

        //forbidden, 組織層級禁用OAuth用量查詢時端點回403並帶此錯誤碼(見wakamex/ccusage)
        if (r.errorType === 'forbidden' && /oauth_not_allowed_for_organization/.test(msg)) {
            msg = `this account's organization disallows OAuth usage queries (oauth_not_allowed_for_organization); subscription quota must be viewed by an org admin in the console: ${msg}`
        }

        return fin({ error: msg, errorType: r.errorType })
    }

    let data = r.data

    //emailHave, 帳號檔無email(例如僅以環境變數提供權杖)時改打profile端點; 形狀採寬容取法
    if (emailHave === '' && profileFallback) {
        let rp = await fetchQuotaJson(profileUrl, { headers, timeoutMs, redact: [token] })
        if (rp.ok) {
            for (let k of ['account.email', 'account.email_address', 'email', 'email_address']) {
                let v = get(rp.data, k, '')
                if (isestr(v)) {
                    emailHave = v
                    break
                }
            }
        }
    }

    //windows, 優先取新版limits[](含模型別與介面別週限額), 缺漏才回退頂層舊欄位
    let windows = []
    let limits = get(data, 'limits', null)
    if (isearr(limits)) {
        windows = limits.map(fromLimitItem)
    }
    else {
        windows = TOP_FIELDS
            .map(([key, sec, scope]) => fromTopField(data, key, sec, scope))
            .filter((v) => v !== null)
    }

    //credits, 額外用量(超出方案額度後之付費用量)與已花費金額
    let credits = {
        extraUsageEnabled: get(data, 'extra_usage.is_enabled', false),
        monthlyLimit: get(data, 'extra_usage.monthly_limit', null),
        usedCredits: get(data, 'extra_usage.used_credits', null),
        utilization: get(data, 'extra_usage.utilization', null),
        spendUsedMinor: get(data, 'spend.used.amount_minor', null),
        spendCurrency: get(data, 'spend.used.currency', ''),
        spendPercent: get(data, 'spend.percent', null),
    }

    return fin({
        windows,
        credits,
        raw: {
            configDir,
            usage: data,
        },
    })
}


export default getQuotaClaude