quota/getQuotaCodex.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 iseobj from 'wsemi/src/iseobj.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import execCliJsonRpc from 'wsemi/src/execCliJsonRpc.mjs'
import castPintOr from '../castPintOr.mjs'
import dfQuotaTimeoutMs from './dfQuotaTimeoutMs.mjs'
import readJsonOrNull from './readJsonOrNull.mjs'
import fetchQuotaJson from './fetchQuotaJson.mjs'
import fromCodexUsageHttp from './fromCodexUsageHttp.mjs'
import toQuotaScopedLabel from './toQuotaScopedLabel.mjs'
import toQuotaWindow from './toQuotaWindow.mjs'
import toQuotaResult from './toQuotaResult.mjs'


// getQuotaCodex.mjs — 查詢Codex(ChatGPT訂閱)帳號之當前額度
//
// 【主路徑: codex app-server(第一方協定), 2026-09-05本機實測】
//   `codex app-server --stdio` JSON-RPC: account/read → {account:{type:'chatgpt',email,planType}}(0.4s);
//   account/rateLimits/read → {rateLimits:{primary,secondary,credits,planType,...}, rateLimitsByLimitId, rateLimitResetCredits}(1.4s)。
//   認證與權杖刷新由codex自理, 本路徑不讀auth.json、不碰任何token; app-server子進程繼承本進程環境,
//   故查的是本進程CODEX_HOME(未設則~/.codex)所指之帳號——opt.codexHome不影響此路徑, 只用於下方備援;
//   要讓主路徑查別的目錄, 須於本進程環境設CODEX_HOME, 或以useAppServer:false改走備援。
//   Codex Desktop/IDE走同一協定, 有`codex app-server generate-json-schema`可產schema, 遠比
//   chatgpt.com之內部端點穩。同類工具wakamex/codex-cli-usage亦以此為主路徑。
//   工作階段由wsemi之execCliJsonRpc(1.8.85起)承擔: Windows之.cmd解析、維持stdin開啟直到回應到齊、
//   stdin.end()令其自行退出、逾寬限樹殺、以及「收尾之退出不算失敗」皆在其內; 本檔只負責
//   握手內容(initialize之clientInfo、initialized通知)與codex專屬之回應對映。
//   rateLimitResetCredits(額度重置券)由此路徑取得, 實測含id/title/description/grantedAt/expiresAt,
//   比chatgpt.com之rate-limit-reset-credits專用端點更完整; 兌換重置券亦有第一方方法
//   account/rateLimitResetCredit/consume({idempotencyKey, creditId?}), 屬動作非查詢, 本檔刻意不做。
//
// 【備援: GET https://chatgpt.com/backend-api/wham/usage】codex不存在、版本無app-server、
//   或RPC失敗時退回直打(實測200), 憑證取自<opt.codexHome|CODEX_HOME|~/.codex>/auth.json(opt.codexHome僅於此生效); 該回應本身即帶
//   email與plan_type。此為ChatGPT前端自用之內部端點, 路徑與欄位可能隨時變動, 對映邏輯獨立於
//   fromCodexUsageHttp以便用離線fixture驗證; 錯誤訊息一律先遮蔽權杖與帳號ID。
//
// 【認證模式】auth.json之auth_mode欄位實測存在(本機'chatgpt'); API key模式下無tokens而有
//   OPENAI_API_KEY, 此模式按用量計費、無訂閱額度窗口, 回unsupported而非誤導其去登入。
//   app-server之account.type同義(非'chatgpt'即非訂閱)。
//
// 【為何備援不自行刷新權杖】auth.json之access_token實測壽命10天且由codex於執行時自行刷新;
//   監控程式不擁有憑證生命週期(理由同getQuotaClaude), 401時指引執行一次codex即可。


//備援端點與其UA/originator(Codex CLI對此端點之自我標識)
let URL_USAGE = 'https://chatgpt.com/backend-api/wham/usage'
let DEFAULT_UA = 'codex-cli/0.153.0'
let DEFAULT_ORIGINATOR = 'codex_cli_rs'


//app-server之請求序列: 握手(initialize須帶clientInfo; initialized為通知)後讀帳號與額度
let DEFAULT_EXE = 'codex'
let RPC_REQUESTS = [
    { method: 'initialize', params: { clientInfo: { name: 'w-dispatch-ai', title: 'w-dispatch-ai', version: '1.0.0' } } },
    { notify: 'initialized', params: {} },
    { method: 'account/read', params: {} },
    { method: 'account/rateLimits/read', params: {} },
]


/**
 * 解出JWT之payload, 僅供顯示用途, 不驗簽
 *
 * @param {String} jwt 輸入JWT字串
 * @returns {Object|null} 回傳payload物件, 解析失敗回傳null
 */
function decodeJwtPayload(jwt) {
    try {
        let ss = jwt.split('.')
        if (ss.length < 2) {
            return null
        }
        return JSON.parse(Buffer.from(ss[1], 'base64url').toString('utf8'))
    }
    catch (err) {
        return null
    }
}


/**
 * 將app-server之單一窗口(primary/secondary)正規化為統一窗口物件
 *
 * @param {Object} o 輸入窗口物件{usedPercent, windowDurationMins, resetsAt}
 * @param {String} key 輸入窗口鍵字串
 * @param {String} scope 輸入範圍字串
 * @returns {Object|null} 回傳統一窗口物件, 輸入非物件回傳null
 */
function fromRpcWindow(o, key, scope) {
    if (!iseobj(o)) {
        return null
    }
    let mins = get(o, 'windowDurationMins', null)
    let windowSeconds = isnum(mins) ? cdbl(mins) * 60 : null
    return toQuotaWindow({
        key,
        label: toQuotaScopedLabel(windowSeconds, scope),
        windowSeconds,
        usedPercent: get(o, 'usedPercent', null),
        resetAt: get(o, 'resetsAt', ''),
        scope,
    })
}


/**
 * 以app-server取得額度並正規化
 *
 * @param {Object} opt 輸入設定物件
 * @returns {Promise} 回傳結果片段物件{ok, email, plan, accountType, windows, credits, raw, error, errorType}
 */
async function viaAppServer(opt) {

    //exe
    let exe = get(opt, 'exe', null)
    if (!isestr(exe)) {
        exe = DEFAULT_EXE
    }

    //rpc, errorType為params/notfound/timeout/exit/rpc
    let r = await execCliJsonRpc(exe, ['app-server', '--stdio'], RPC_REQUESTS, {
        timeoutMs: get(opt, 'timeoutMs', null),
    })
    if (!r.ok) {
        return { ok: false, error: r.error, errorType: r.errorType, raw: { appServer: { error: r.error, errorType: r.errorType, exitCode: r.exitCode, responses: r.responses } } }
    }

    let acc = get(r.results, ['account/read', 'account'], null)
    let rl = get(r.results, ['account/rateLimits/read'], null)

    //account, null代表codex尚未登入
    if (!iseobj(acc)) {
        return { ok: false, notLoggedIn: true, error: 'codex app-server reports not logged in (account is null); run [codex login] first', errorType: 'notfound', raw: { appServer: r.results } }
    }
    let email = get(acc, 'email', '')
    let plan = get(acc, 'planType', '')
    let accountType = get(acc, 'type', '')

    //accountType, 非chatgpt(例如apikey)即非訂閱, 無額度窗口
    if (isestr(accountType) && accountType !== 'chatgpt') {
        return {
            ok: false,
            email,
            plan,
            accountType,
            error: `codex is logged in with [${accountType}] mode; this mode is billed by usage and has no 5-hour/7-day subscription quota windows`,
            errorType: 'unsupported',
            raw: { appServer: r.results },
        }
    }

    //windows, 彙總窗口為主; rateLimitsByLimitId內與彙總同id者略過, 其餘以limitId為範圍逐一納入
    let windows = []
    let agg = get(rl, 'rateLimits', null)
    let aggId = get(agg, 'limitId', 'codex')
    let wPri = fromRpcWindow(get(agg, 'primary', null), 'primary', '')
    if (wPri !== null) {
        windows.push(wPri)
    }
    let wSec = fromRpcWindow(get(agg, 'secondary', null), 'secondary', '')
    if (wSec !== null) {
        windows.push(wSec)
    }
    let byId = get(rl, 'rateLimitsByLimitId', null)
    if (iseobj(byId)) {
        for (let limitId of Object.keys(byId)) {
            if (limitId === aggId) {
                continue
            }
            let it = byId[limitId]
            let scope = get(it, 'limitName', '')
            if (!isestr(scope)) {
                scope = limitId
            }
            for (let k of ['primary', 'secondary']) {
                let w = fromRpcWindow(get(it, k, null), `${limitId}:${k}`, scope)
                if (w !== null) {
                    windows.push(w)
                }
            }
        }
    }

    //credits, 點數餘額、額度重置券(Codex特有, 可提前重置窗口)與限額狀態
    let credits = {
        hasCredits: get(agg, 'credits.hasCredits', false),
        unlimited: get(agg, 'credits.unlimited', false),
        balance: get(agg, 'credits.balance', ''),
        resetCreditsAvailable: get(rl, 'rateLimitResetCredits.availableCount', null),
        resetCredits: get(rl, 'rateLimitResetCredits.credits', null),
        spendControlReached: get(agg, 'spendControlReached', false),
        rateLimitReachedType: get(agg, 'rateLimitReachedType', null),
        individualLimit: get(agg, 'individualLimit', null),
    }

    //plan, account/read為先, 其次rateLimits
    if (!isestr(plan)) {
        plan = get(agg, 'planType', '')
    }

    return {
        ok: true,
        email,
        plan,
        accountType,
        windows,
        credits,
        raw: { appServer: r.results },
    }
}


/**
 * 以備援端點取得額度並正規化
 *
 * @param {Object} opt 輸入設定物件
 * @param {String} codexHome 輸入codex設定目錄字串
 * @param {Number} timeoutMs 輸入逾時毫秒
 * @returns {Promise} 回傳結果片段物件{ok, email, plan, windows, credits, raw, error, errorType}
 */
async function viaHttp(opt, codexHome, timeoutMs) {

    //env, 同主函數之注入來源
    let env = get(opt, 'env', null)
    if (!isobj(env)) {
        env = process.env
    }

    //auth
    let fpAuth = path.join(codexHome, 'auth.json')
    let auth = readJsonOrNull(fpAuth)
    let token = get(auth, 'tokens.access_token', '')
    let accountId = get(auth, 'tokens.account_id', '')

    //email與plan, 先由id_token取得(查詢失敗時仍能指出本機綁定之帳號), 查詢成功後以回應為準
    let email = ''
    let plan = ''
    let idToken = get(auth, 'tokens.id_token', '')
    if (isestr(idToken)) {
        let pl = decodeJwtPayload(idToken)
        let e = get(pl, 'email', '')
        if (isestr(e)) {
            email = e
        }
        //claim名本身含點號, 路徑須給陣列形式, 給字串會被lodash切開而永遠取不到
        let p = get(pl, ['https://api.openai.com/auth', 'chatgpt_plan_type'], '')
        if (isestr(p)) {
            plan = p
        }
    }

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

        //unsupported, API key模式: auth.json只有OPENAI_API_KEY而無tokens
        let apiKey = get(auth, 'OPENAI_API_KEY', '')
        if (isestr(apiKey) || isestr(get(env, 'OPENAI_API_KEY', ''))) {
            return { ok: false, email, plan, error: 'codex is running in API key mode (auth.json has OPENAI_API_KEY but no tokens); this mode is billed by usage and has no subscription quota windows', errorType: 'unsupported' }
        }

        return { ok: false, email, plan, error: `Codex CLI credential not found (${fpAuth}); run [codex login] first`, errorType: 'notfound' }
    }

    //headers
    let userAgent = get(opt, 'userAgent', null)
    if (!isestr(userAgent)) {
        userAgent = DEFAULT_UA
    }
    let originator = get(opt, 'originator', null)
    if (!isestr(originator)) {
        originator = DEFAULT_ORIGINATOR
    }
    let headers = {
        'Authorization': `Bearer ${token}`,
        'User-Agent': userAgent,
        originator,
    }
    if (isestr(accountId)) {
        headers['ChatGPT-Account-ID'] = accountId
    }

    //fetch, 錯誤訊息中之權杖與帳號ID一律遮蔽
    let usageUrl = get(opt, 'usageUrl', null)
    if (!isestr(usageUrl)) {
        usageUrl = URL_USAGE
    }
    let r = await fetchQuotaJson(usageUrl, { headers, timeoutMs, redact: [token, accountId] })
    if (!r.ok) {
        let msg = r.error
        if (r.errorType === 'auth') {
            msg = `${msg} → codex refreshes the token on every run; run any codex command then retry, and only fall back to [codex login] if it still fails`
        }
        return { ok: false, email, plan, error: msg, errorType: r.errorType }
    }

    //map, 回應之帳號與方案為權威值
    let m = fromCodexUsageHttp(r.data)
    if (isestr(m.email)) {
        email = m.email
    }
    if (isestr(m.plan)) {
        plan = m.plan
    }

    return { ok: true, email, plan, windows: m.windows, credits: m.credits, raw: { usage: r.data } }
}


/**
 * 查詢Codex(ChatGPT訂閱)帳號之當前額度
 *
 * 主路徑以codex app-server之JSON-RPC取得帳號與額度(認證與刷新由codex自理),
 * codex不存在或RPC失敗時退回直打chatgpt.com之用量端點。
 * 額度綁定本機Codex CLI之登入憑證,無「給email查任意帳號」之公開介面,
 * 故email參數之作用為比對——不符時ok為false且matched為false,並於error載明本機實際登入之帳號;
 * 未給email時不比對。本函數不會reject,一律以結果物件之ok與error欄位回報成敗
 *
 * @param {String} [email=''] 輸入欲查詢之帳號email字串,預設''代表不比對而直接回報本機當前帳號
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.exe='codex'] 輸入codex執行檔名稱或路徑字串,預設'codex'
 * @param {Boolean} [opt.useAppServer=true] 輸入是否以app-server為主路徑布林值,false代表直接走備援端點,預設true
 * @param {Boolean} [opt.fallbackHttp=true] 輸入app-server失敗時是否退回備援端點布林值,預設true
 * @param {String} [opt.codexHome] 輸入codex設定目錄字串,僅備援路徑讀其auth.json時生效;app-server主路徑之子進程繼承本進程環境,查的是本進程CODEX_HOME所指之帳號,不受此參數影響(要讓主路徑查別的目錄,須於本進程環境設CODEX_HOME,或以useAppServer:false改走備援),預設取環境變數CODEX_HOME,未設則<homeDir>/.codex
 * @param {String} [opt.homeDir=os.homedir()] 輸入家目錄字串,僅於未指定codexHome且未設環境變數時使用
 * @param {String} [opt.userAgent='codex-cli/0.153.0'] 輸入備援端點之User-Agent字串
 * @param {String} [opt.originator='codex_cli_rs'] 輸入備援端點之originator標頭字串
 * @param {String} [opt.usageUrl='https://chatgpt.com/backend-api/wham/usage'] 輸入備援端點網址字串,供測試指向假伺服器或經企業代理,預設官方端點
 * @param {Object} [opt.env=process.env] 輸入環境變數來源物件(讀CODEX_HOME與API key模式判定用之OPENAI_API_KEY),供測試隔離本機環境,預設process.env
 * @param {Number} [opt.timeoutMs=20000] 輸入逾時毫秒正整數(app-server整體或單次HTTP),預設20000
 * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok、provider('codex')、email、matched、plan(例如'plus')、planTier(空字串)、source('codex-app-server'或'chatgpt-wham-usage-api')、windows(含5小時與7天,另有依limitId分列之窗口)、credits(點數、重置券清單、限額狀態)、raw、error、errorType、durationMs,本函數不會reject
 * @example
 * //need codex cli logged in
 *
 * import getQuotaCodex from './src/quota/getQuotaCodex.mjs'
 *
 * let test = async () => {
 *     let r = await getQuotaCodex('firsemisphere@gmail.com')
 *     console.log(r.ok, r.plan, r.source)
 *     // => true plus codex-app-server
 *     console.log(r.windows[0].label, r.windows[0].usedPercent)
 *     // => 5小時 37 (百分比為查詢當下之即時值, 每次不同)
 * }
 * test()
 *
 */
async function getQuotaCodex(email = '', opt = {}) {

    let t0 = Date.now()

    //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()
    }

    //codexHome, 依序取opt、CODEX_HOME、<homeDir>/.codex(codex --help: auth still uses CODEX_HOME);
    //僅備援路徑用, app-server主路徑之子進程繼承本進程環境而不看此值(見檔頭)
    let codexHome = get(opt, 'codexHome', null)
    if (!isestr(codexHome)) {
        codexHome = get(env, 'CODEX_HOME', '')
        if (!isestr(codexHome)) {
            codexHome = path.join(homeDir, '.codex')
        }
    }

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

    //useAppServer與fallbackHttp
    let useAppServer = get(opt, 'useAppServer', true) !== false
    let fallbackHttp = get(opt, 'fallbackHttp', true) !== false

    //fin
    let fin = (source, o) => {
        return toQuotaResult('codex', {
            emailWant,
            source,
            durationMs: Date.now() - t0,
            ...o,
        })
    }

    //app-server
    let appErr = null
    if (useAppServer) {
        let a = await viaAppServer({ ...opt, timeoutMs })
        if (a.ok) {
            return fin('codex-app-server', {
                email: a.email,
                plan: a.plan,
                windows: a.windows,
                credits: a.credits,
                raw: { accountType: a.accountType, ...a.raw },
            })
        }

        //unsupported與「尚未登入」(notLoggedIn旗標, 不靠錯誤字串比對)為帳號狀態, 備援亦無解, 直接回報
        if (a.errorType === 'unsupported' || a.notLoggedIn === true) {
            return fin('codex-app-server', { email: a.email, plan: a.plan, error: a.error, errorType: a.errorType, raw: a.raw })
        }
        appErr = a
        if (!fallbackHttp) {
            return fin('codex-app-server', { error: a.error, errorType: a.errorType, raw: a.raw })
        }
    }

    //http fallback
    let h = await viaHttp(opt, codexHome, timeoutMs)
    let note = appErr !== null ? ` (app-server path failed: ${appErr.error})` : ''
    if (!h.ok) {
        return fin('chatgpt-wham-usage-api', {
            email: h.email,
            plan: h.plan,
            error: `${h.error}${note}`,
            errorType: h.errorType,
            raw: appErr !== null ? appErr.raw : null,
        })
    }
    return fin('chatgpt-wham-usage-api', {
        email: h.email,
        plan: h.plan,
        windows: h.windows,
        credits: h.credits,
        raw: { codexHome, appServerError: appErr !== null ? appErr.error : '', ...h.raw },
    })
}


export default getQuotaCodex