import get from 'lodash-es/get.js'
import isestr from 'wsemi/src/isestr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import toQuotaScopedLabel from './toQuotaScopedLabel.mjs'
import toQuotaWindow from './toQuotaWindow.mjs'
// fromCodexUsageHttp.mjs — 將chatgpt.com之/wham/usage回應對映為統一結構
//
// 【為何獨立成檔】此端點為ChatGPT前端自用之內部端點, 欄位形狀已知會變(尤其additional_rate_limits),
// 對映邏輯獨立後可用離線fixture斷言, 不必每次都靠真帳號才能驗證。
//
// 【additional_rate_limits之寬容解析】本機帳號此欄實測為null, 形狀取自同類工具codex-reset-checker
// 之測試fixture(其作者觀察到的真實回應): 集合可為陣列或以名稱為鍵之物件; 單筆之識別可為
// limit_name / metered_limit_name / name / id; 顯示名可為display_name / title; 窗口可在單筆頂層、
// rate_limit之下(snake或camel), 或單筆本身就是一個窗口(直接帶used_percent, 或置於window鍵);
// 直接窗口不分primary/secondary時, 以名稱含weekly/secondary/reserve或窗口長度≥6天判為週限額。
// 已知名稱: codex-spark(GPT-5.3-Codex-Spark)、gpt-reserve(Luna reserve之週限額)。
// 任何欄位缺漏皆「該窗口不列入」而不拋錯, 原始回應由呼叫端保留於raw。
//直接窗口判為週限額之窗口長度門檻(秒)
let SEC_WEEKLY_MIN = 6 * 86400
/**
* 取第一個為非空字串之鍵值
*
* @param {Object} o 輸入物件
* @param {Array} keys 輸入鍵陣列
* @returns {String} 回傳字串, 皆無回傳''
*/
function firstStr(o, keys) {
for (let k of keys) {
let v = get(o, k, '')
if (isestr(v)) {
return v
}
}
return ''
}
/**
* 將單一窗口{used_percent, limit_window_seconds, reset_at, reset_after_seconds}正規化
*
* @param {Object} o 輸入窗口物件
* @param {String} key 輸入窗口鍵字串
* @param {String} scope 輸入範圍字串
* @returns {Object|null} 回傳統一窗口物件, 輸入非物件回傳null
*/
function fromWindow(o, key, scope) {
if (!iseobj(o)) {
return null
}
let ws = get(o, 'limit_window_seconds', null)
let windowSeconds = isnum(ws) ? cdbl(ws) : null
return toQuotaWindow({
key,
label: toQuotaScopedLabel(windowSeconds, scope),
windowSeconds,
usedPercent: get(o, 'used_percent', null),
resetAt: get(o, 'reset_at', ''),
resetAfterSeconds: get(o, 'reset_after_seconds', null),
scope,
})
}
/**
* 取出additional_rate_limits之集合並統一為陣列
*
* @param {Object} data 輸入回應物件
* @returns {Array} 回傳單筆物件陣列, 無則[]
*/
function collectAdditional(data) {
let cands = [
get(data, 'additional_rate_limits', null),
get(data, 'rate_limit.additional_rate_limits', null),
]
for (let c of cands) {
if (isearr(c)) {
return c
}
if (iseobj(c)) {
return Object.keys(c).map((k) => {
let v = c[k]
if (!iseobj(v)) {
return v
}
let nm = firstStr(v, ['limit_name', 'name', 'id'])
return { ...v, limit_name: nm === '' ? k : nm }
})
}
}
return []
}
/**
* 自單筆取出指定窗口(頂層、rate_limit、rateLimit之下, snake或camel)
*
* @param {Object} it 輸入單筆物件
* @param {String} snake 輸入snake鍵, 例如'primary_window'
* @param {String} camel 輸入camel鍵, 例如'primaryWindow'
* @returns {Object|null} 回傳窗口物件, 無回傳null
*/
function pickWindow(it, snake, camel) {
for (let c of [it, get(it, 'rate_limit', null), get(it, 'rateLimit', null)]) {
if (!iseobj(c)) {
continue
}
let w = get(c, snake, null)
if (!iseobj(w)) {
w = get(c, camel, null)
}
if (iseobj(w)) {
return w
}
}
return null
}
/**
* 單筆本身即為窗口時取出之
*
* @param {Object} it 輸入單筆物件
* @returns {Object|null} 回傳窗口物件, 無回傳null
*/
function directWindow(it) {
if (!iseobj(it)) {
return null
}
if (get(it, 'used_percent', undefined) !== undefined) {
return it
}
let w = get(it, 'window', null)
if (iseobj(w)) {
return w
}
let rl = get(it, 'rate_limit', null)
if (iseobj(rl) && get(rl, 'used_percent', undefined) !== undefined) {
return rl
}
return null
}
/**
* 將chatgpt.com之/wham/usage回應對映為統一結構
*
* 主窗口(rate_limit.primary_window / secondary_window)、程式碼審查限額(code_review_rate_limit)、
* 以及寬容解析之additional_rate_limits皆納入windows;額外限額之窗口鍵為「<識別>:primary|secondary」,
* scope為其顯示名或識別。任何欄位缺漏皆略過該窗口而不拋錯
*
* @param {Object} data 輸入/wham/usage之回應物件
* @returns {Object} 回傳物件,內含email(字串,無則'')、plan(字串,無則'')、windows(統一窗口陣列)、credits(點數與重置券資訊物件)
* @example
*
* import fromCodexUsageHttp from './src/quota/fromCodexUsageHttp.mjs'
*
* let m = fromCodexUsageHttp({
* email: 'a@b.c',
* plan_type: 'plus',
* rate_limit: { primary_window: { used_percent: 42, limit_window_seconds: 18000, reset_after_seconds: 100 } },
* additional_rate_limits: [{ limit_name: 'codex-spark', primary_window: { used_percent: 12, limit_window_seconds: 18000 } }],
* })
* console.log(m.windows.map((w) => `${w.key}=${w.label}`))
* // => [ 'primary=5小時', 'codex-spark:primary=5小時(codex-spark)' ]
*
*/
function fromCodexUsageHttp(data) {
//email與plan
let email = firstStr(data, ['email'])
let plan = firstStr(data, ['plan_type'])
//windows, 主窗口
let windows = []
for (let k of ['primary', 'secondary']) {
let w = fromWindow(get(data, `rate_limit.${k}_window`, null), k, '')
if (w !== null) {
windows.push(w)
}
}
//code review限額, 與對話額度分開計算
for (let k of ['primary', 'secondary']) {
let w = fromWindow(get(data, `code_review_rate_limit.${k}_window`, null), `code_review:${k}`, 'code review')
if (w !== null) {
windows.push(w)
}
}
//additional_rate_limits, 寬容解析
let addi = collectAdditional(data)
for (let i = 0; i < addi.length; i++) {
let it = addi[i]
if (!iseobj(it)) {
continue
}
//id與scope
let id = firstStr(it, ['limit_name', 'metered_limit_name', 'name', 'id'])
if (id === '') {
id = `additional_${i + 1}`
}
let display = firstStr(it, ['display_name', 'title', 'name', 'limit_name', 'metered_limit_name'])
let scope = display === '' ? id : display
let identity = `${id} ${display}`.toLowerCase()
//窗口, 先找明確之primary/secondary, 皆無時把單筆本身當窗口並以名稱或長度判別週限額
let pri = pickWindow(it, 'primary_window', 'primaryWindow')
let sec = pickWindow(it, 'secondary_window', 'secondaryWindow')
if (pri === null && sec === null) {
let d = directWindow(it)
if (d !== null) {
let ws = get(d, 'limit_window_seconds', null)
let weekly = /weekly|secondary|reserve/.test(identity) || (isnum(ws) && cdbl(ws) >= SEC_WEEKLY_MIN)
if (weekly) {
sec = d
}
else {
pri = d
}
}
}
let wp = fromWindow(pri, `${id}:primary`, scope)
if (wp !== null) {
windows.push(wp)
}
let ws2 = fromWindow(sec, `${id}:secondary`, scope)
if (ws2 !== null) {
windows.push(ws2)
}
}
//credits, 點數餘額與「額度重置券」(可提前重置窗口)與限額狀態
let credits = {
hasCredits: get(data, 'credits.has_credits', false),
unlimited: get(data, 'credits.unlimited', false),
balance: get(data, 'credits.balance', ''),
resetCreditsAvailable: get(data, 'rate_limit_reset_credits.available_count', null),
spendControlReached: get(data, 'spend_control.reached', false),
rateLimitReachedType: get(data, 'rate_limit_reached_type', null),
individualLimit: get(data, 'spend_control.individual_limit', null),
}
return {
email,
plan,
windows,
credits,
}
}
export default fromCodexUsageHttp