quota/fetchQuotaJson.mjs

import get from 'lodash-es/get.js'
import isestr from 'wsemi/src/isestr.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import castPintOr from '../castPintOr.mjs'
import dfQuotaTimeoutMs from './dfQuotaTimeoutMs.mjs'


// fetchQuotaJson.mjs — 各額度查詢端點共用之HTTP請求並解析JSON
//
// 【為何自寫而不引HTTP套件】本套件相依僅wsemi一項, 額度查詢只需單次請求,
//   Node 18+已內建fetch, 引入額外相依不划算。
//
// 【為何逾時走AbortController且涵蓋讀取本文】fetch本身無逾時參數; 且fetch resolve只代表收到標頭,
//   本文仍可能無限期慢流, 故計時器須持續到本文讀完才解除。
//
// 【為何錯誤要分類】呼叫端對「token過期(auth)」與「暫時性網路失敗(network/timeout)」
//   之處置完全不同, 只給錯誤訊息字串則呼叫端須解析文字判斷。
//
// 【為何有redact與maxBodyBytes】錯誤訊息會夾帶回應本文片段, 若對端把請求標頭回顯(除錯頁、代理錯誤頁),
//   權杖或帳號ID便會流入log; 呼叫端把機密值交給redact, 進訊息前一律替換。本文大小上限則防止
//   異常回應(例如導向到整頁HTML或無限串流)撐爆記憶體; 額度端點正常回應不到10KB, 1MB綽綽有餘。
//   兩者皆參考同類工具codex-reset-checker之做法。


//回應本文預設上限位元組
let DEFAULT_MAX_BODY_BYTES = 1048576


//遮蔽替代文字; 短於此長度之機密值不遮(避免把常見短字串誤遮成無法閱讀)
let REDACT_MASK = '[REDACTED]'
let REDACT_MIN_LEN = 6


/**
 * 將文字中之機密值替換為遮蔽字
 *
 * @param {String} text 輸入文字
 * @param {Array} redact 輸入機密值字串陣列
 * @returns {String} 回傳替換後文字
 */
function redactText(text, redact) {
    let s = String(text)
    for (let v of redact) {
        if (isestr(v) && v.length >= REDACT_MIN_LEN) {
            s = s.split(v).join(REDACT_MASK)
        }
    }
    return s
}


/**
 * 讀取回應本文, 超過上限即中止
 *
 * @param {Object} r 輸入fetch之Response
 * @param {Number} maxBytes 輸入上限位元組
 * @returns {Promise} 回傳物件{ok, text, bytes}, 超限時ok為false且bytes為已讀位元組(或content-length)
 */
async function readBodyCapped(r, maxBytes) {

    //content-length, 先擋明顯超限者, 不必開始讀
    let cl = Number(r.headers.get('content-length') || 0)
    if (cl > maxBytes) {
        return { ok: false, text: '', bytes: cl }
    }

    //無串流介面時退回text(), 讀完再量
    let body = r.body
    if (!body || typeof body.getReader !== 'function') {
        let t = await r.text()
        let n = Buffer.byteLength(t)
        if (n > maxBytes) {
            return { ok: false, text: '', bytes: n }
        }
        return { ok: true, text: t, bytes: n }
    }

    //串流讀取, 邊讀邊量, 超限即取消
    let reader = body.getReader()
    let chunks = []
    let total = 0
    while (true) {
        let { done, value } = await reader.read()
        if (done) {
            break
        }
        total += value.byteLength
        if (total > maxBytes) {
            try {
                await reader.cancel()
            }
            catch (err) {}
            return { ok: false, text: '', bytes: total }
        }
        chunks.push(Buffer.from(value))
    }
    return { ok: true, text: Buffer.concat(chunks).toString('utf8'), bytes: total }
}


/**
 * 各額度查詢端點共用之HTTP請求並解析JSON
 *
 * 本函數不會reject,一律以結果物件之ok與error欄位回報成敗,
 * 並將失敗歸入機器可讀之errorType,令呼叫端無須解析錯誤訊息字串即可分流處置
 *
 * @param {String} url 輸入查詢端點網址字串
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.method='GET'] 輸入HTTP方法字串,預設'GET'
 * @param {Object|String} [opt.body=null] 輸入請求內容,給物件時序列化為JSON並自動補Content-Type,給字串時原樣送出,預設null代表無內容
 * @param {Object} [opt.headers={}] 輸入額外HTTP標頭物件,預設{}
 * @param {Number} [opt.timeoutMs=20000] 輸入逾時毫秒正整數,涵蓋連線至本文讀完,逾時將中止連線,預設20000
 * @param {Number} [opt.maxBodyBytes=1048576] 輸入回應本文上限位元組正整數,超過即中止並回errorType 'toolarge',預設1048576
 * @param {Array} [opt.redact=[]] 輸入須自錯誤訊息中遮蔽之機密值字串陣列(例如權杖、帳號ID),長度不足6者不處理,預設[]
 * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、status(HTTP狀態碼整數,未連上為0)、data(解析後之JSON物件,失敗為null)、error(錯誤訊息字串,成功時為空字串,已套用redact)、errorType(錯誤類別字串:params/auth/forbidden/ratelimit/http/parse/timeout/network/toolarge)、durationMs(耗時毫秒),本函數不會reject
 * @example
 * //need network
 *
 * import fetchQuotaJson from './src/quota/fetchQuotaJson.mjs'
 *
 * let test = async () => {
 *     let r = await fetchQuotaJson('https://api.anthropic.com/api/oauth/usage', {
 *         headers: { Authorization: 'Bearer xxx' },
 *     })
 *     console.log(r.ok, r.status)
 *     // => false 401
 * }
 * test()
 *
 */
async function fetchQuotaJson(url, opt = {}) {

    let t0 = Date.now()

    //rs, 各分支共用之結果骨架
    let rs = (ok, status, data, error, errorType) => {
        return {
            ok,
            status,
            data,
            error,
            errorType,
            durationMs: Date.now() - t0,
        }
    }

    //check url
    if (!isestr(url)) {
        return rs(false, 0, null, 'url must be a non-empty string', 'params')
    }

    //headers, 非物件回退空物件
    let headers = get(opt, 'headers', null)
    if (!iseobj(headers)) {
        headers = {}
    }

    //method, 無效回退GET
    let method = get(opt, 'method', null)
    if (!isestr(method)) {
        method = 'GET'
    }

    //body, 物件即序列化為JSON並補Content-Type, 字串原樣送出
    let body = get(opt, 'body', null)
    if (iseobj(body)) {
        body = JSON.stringify(body)
        if (!isestr(get(headers, 'Content-Type', null))) {
            headers = {
                ...headers,
                'Content-Type': 'application/json',
            }
        }
    }
    else if (!isestr(body)) {
        body = null
    }

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

    //maxBodyBytes
    let maxBodyBytes = castPintOr(get(opt, 'maxBodyBytes', null), DEFAULT_MAX_BODY_BYTES)

    //redact
    let redact = get(opt, 'redact', null)
    if (!isearr(redact)) {
        redact = []
    }

    //snip, 錯誤訊息用之本文片段: 先遮蔽再截短, 避免機密值被截半後漏出
    let snip = (text) => redactText(text, redact).slice(0, 300)

    //ac, 逾時中止連線; 計時器持續至本文讀完
    let ac = new AbortController()
    let idTimer = setTimeout(() => {
        ac.abort()
    }, timeoutMs)

    let r = null
    let text = ''
    try {

        //fetch, resolve僅代表收到標頭
        try {
            r = await fetch(url, {
                method,
                headers,
                body,
                signal: ac.signal,
            })
        }
        catch (err) {
            if (get(err, 'name', '') === 'AbortError') {
                return rs(false, 0, null, `request timeout over ${timeoutMs}ms`, 'timeout')
            }
            return rs(false, 0, null, `network error: ${redactText(get(err, 'message', 'unknown'), redact)}`, 'network')
        }

        //body, 先取原文再解析, 令非JSON之錯誤頁能原樣回報而非只說解析失敗
        try {
            let rb = await readBodyCapped(r, maxBodyBytes)
            if (!rb.ok) {
                ac.abort()
                return rs(false, r.status, null, `response body too large: ${rb.bytes} bytes over limit ${maxBodyBytes}`, 'toolarge')
            }
            text = rb.text
        }
        catch (err) {
            if (get(err, 'name', '') === 'AbortError') {
                return rs(false, r.status, null, `request timeout over ${timeoutMs}ms while reading body`, 'timeout')
            }
            return rs(false, r.status, null, `read body error: ${redactText(get(err, 'message', 'unknown'), redact)}`, 'network')
        }

    }
    finally {
        clearTimeout(idTimer)
    }

    //auth, 401代表憑證本身不被接受(過期或撤銷)。此處不給處置指引——各家正確處置不同
    //(Claude/Codex之CLI會於執行時自行刷新, 存檔之refresh token仍有效, 貿然指引「重新登入」是多餘的), 由各轉接器補述
    if (r.status === 401) {
        return rs(false, r.status, null, `unauthorized(401), credential rejected (expired or revoked): ${snip(text)}`, 'auth')
    }

    //forbidden, 403代表憑證有效但此帳號無權存取該資源, 與401分流(401重新登入可解, 403重新登入一百次也一樣)
    if (r.status === 403) {
        return rs(false, r.status, null, `forbidden(403), credential valid but this account has no access to the resource (plan does not include it, or the service no longer serves this account tier): ${snip(text)}`, 'forbidden')
    }

    //ratelimit, 429為端點自身之查詢頻率限制, 與訂閱額度用罄無關
    if (r.status === 429) {
        return rs(false, r.status, null, `rate limited(429) by usage endpoint: ${snip(text)}`, 'ratelimit')
    }

    //http
    if (!r.ok) {
        return rs(false, r.status, null, `http error(${r.status}): ${snip(text)}`, 'http')
    }

    //data
    let data = null
    try {
        data = JSON.parse(text)
    }
    catch (err) {
        return rs(false, r.status, null, `invalid json: ${snip(text)}`, 'parse')
    }

    return rs(true, r.status, data, '', '')
}


export default fetchQuotaJson