w-dispatch-ai 1.0.21 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +627 -569
  2. package/dist/w-dispatch-ai.umd.js +2 -2
  3. package/dist/w-dispatch-ai.umd.js.map +1 -1
  4. package/docs/WDispatchAi.mjs.html +12 -4
  5. package/docs/adapters.mjs.html +5 -3
  6. package/docs/budgetFor.mjs.html +2 -2
  7. package/docs/buildValidator.mjs.html +166 -0
  8. package/docs/castPintOr.mjs.html +2 -2
  9. package/docs/dfTimeoutMs.mjs.html +2 -2
  10. package/docs/dispatchAi.mjs.html +2 -2
  11. package/docs/dispatchAiFallback.mjs.html +2 -2
  12. package/docs/dispatchAiWkf.mjs.html +2 -2
  13. package/docs/dispatchAntigravity.mjs.html +2 -2
  14. package/docs/dispatchApiOpenaiCompat.mjs.html +4 -68
  15. package/docs/dispatchApiOpenaiResponses.mjs.html +498 -0
  16. package/docs/dispatchClaude.mjs.html +2 -2
  17. package/docs/dispatchCodex.mjs.html +2 -2
  18. package/docs/dispatchOpencode.mjs.html +2 -2
  19. package/docs/getCliArgs.mjs.html +2 -2
  20. package/docs/getErrorResult.mjs.html +2 -2
  21. package/docs/getErrorType.mjs.html +5 -3
  22. package/docs/global.html +11790 -3425
  23. package/docs/index.html +2 -2
  24. package/docs/quota_dfQuotaTimeoutMs.mjs.html +99 -0
  25. package/docs/quota_fetchQuotaJson.mjs.html +348 -0
  26. package/docs/quota_fromCodexUsageHttp.mjs.html +335 -0
  27. package/docs/quota_getQuotaAntigravity.mjs.html +463 -0
  28. package/docs/quota_getQuotaClaude.mjs.html +457 -0
  29. package/docs/quota_getQuotaCodex.mjs.html +515 -0
  30. package/docs/quota_readJsonOrNull.mjs.html +113 -0
  31. package/docs/quota_toQuotaLabel.mjs.html +146 -0
  32. package/docs/quota_toQuotaResult.mjs.html +214 -0
  33. package/docs/quota_toQuotaScopedLabel.mjs.html +117 -0
  34. package/docs/quota_toQuotaWindow.mjs.html +239 -0
  35. package/docs/readEnvFile.mjs.html +2 -2
  36. package/docs/resolveProviders.mjs.html +3 -3
  37. package/docs/wkf_callAiWithFallback.mjs.html +2 -2
  38. package/docs/wkf_createFileStore.mjs.html +2 -2
  39. package/docs/wkf_createUsageCounter.mjs.html +2 -2
  40. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  41. package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
  42. package/docs/wkf_runFanout.mjs.html +2 -2
  43. package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
  44. package/docs/wkf_runRolePipeline.mjs.html +2 -2
  45. package/docs/wkf_salvageTruncatedArray.mjs.html +2 -2
  46. package/g.mjs +2 -2
  47. package/package.json +2 -2
  48. package/src/WDispatchAi.mjs +10 -2
  49. package/src/adapters.mjs +3 -1
  50. package/src/buildValidator.mjs +94 -0
  51. package/src/dispatchApiOpenaiCompat.mjs +2 -66
  52. package/src/dispatchApiOpenaiResponses.mjs +426 -0
  53. package/src/getErrorType.mjs +3 -1
  54. package/src/providers.mjs +303 -213
  55. package/src/quota/dfQuotaTimeoutMs.mjs +27 -0
  56. package/src/quota/fetchQuotaJson.mjs +276 -0
  57. package/src/quota/fromCodexUsageHttp.mjs +263 -0
  58. package/src/quota/getQuotaAntigravity.mjs +391 -0
  59. package/src/quota/getQuotaClaude.mjs +385 -0
  60. package/src/quota/getQuotaCodex.mjs +443 -0
  61. package/src/quota/readJsonOrNull.mjs +41 -0
  62. package/src/quota/toQuotaLabel.mjs +74 -0
  63. package/src/quota/toQuotaResult.mjs +142 -0
  64. package/src/quota/toQuotaScopedLabel.mjs +45 -0
  65. package/src/quota/toQuotaWindow.mjs +167 -0
  66. package/src/resolveProviders.mjs +1 -1
  67. package/test/tools/fakeCliForTest.mjs +4 -3
  68. package/test/tools/fakeServerForApiTest.mjs +108 -3
  69. package/test/tools/fakeServerForQuotaTest.mjs +171 -0
  70. package/test/unit-WDispatchAi.test.mjs +19 -6
  71. package/test/unit-adapters.test.mjs +5 -3
  72. package/test/unit-dispatchAi.test.mjs +1 -1
  73. package/test/unit-dispatchApiOpenaiResponses.test.mjs +198 -0
  74. package/test/unit-fetchQuotaJson.test.mjs +70 -0
  75. package/test/unit-fromCodexUsageHttp.test.mjs +76 -0
  76. package/test/unit-getQuotaAntigravity.test.mjs +123 -0
  77. package/test/unit-getQuotaClaude.test.mjs +137 -0
  78. package/test/unit-getQuotaCodex.test.mjs +191 -0
  79. package/test/unit-readJsonOrNull.test.mjs +44 -0
  80. package/test/unit-toQuotaLabel.test.mjs +43 -0
  81. package/test/unit-toQuotaResult.test.mjs +53 -0
  82. package/test/unit-toQuotaWindow.test.mjs +73 -0
@@ -0,0 +1,276 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+ import iseobj from 'wsemi/src/iseobj.mjs'
4
+ import isearr from 'wsemi/src/isearr.mjs'
5
+ import castPintOr from '../castPintOr.mjs'
6
+ import dfQuotaTimeoutMs from './dfQuotaTimeoutMs.mjs'
7
+
8
+
9
+ // fetchQuotaJson.mjs — 各額度查詢端點共用之HTTP請求並解析JSON
10
+ //
11
+ // 【為何自寫而不引HTTP套件】本套件相依僅wsemi一項, 額度查詢只需單次請求,
12
+ // Node 18+已內建fetch, 引入額外相依不划算。
13
+ //
14
+ // 【為何逾時走AbortController且涵蓋讀取本文】fetch本身無逾時參數; 且fetch resolve只代表收到標頭,
15
+ // 本文仍可能無限期慢流, 故計時器須持續到本文讀完才解除。
16
+ //
17
+ // 【為何錯誤要分類】呼叫端對「token過期(auth)」與「暫時性網路失敗(network/timeout)」
18
+ // 之處置完全不同, 只給錯誤訊息字串則呼叫端須解析文字判斷。
19
+ //
20
+ // 【為何有redact與maxBodyBytes】錯誤訊息會夾帶回應本文片段, 若對端把請求標頭回顯(除錯頁、代理錯誤頁),
21
+ // 權杖或帳號ID便會流入log; 呼叫端把機密值交給redact, 進訊息前一律替換。本文大小上限則防止
22
+ // 異常回應(例如導向到整頁HTML或無限串流)撐爆記憶體; 額度端點正常回應不到10KB, 1MB綽綽有餘。
23
+ // 兩者皆參考同類工具codex-reset-checker之做法。
24
+
25
+
26
+ //回應本文預設上限位元組
27
+ let DEFAULT_MAX_BODY_BYTES = 1048576
28
+
29
+
30
+ //遮蔽替代文字; 短於此長度之機密值不遮(避免把常見短字串誤遮成無法閱讀)
31
+ let REDACT_MASK = '[REDACTED]'
32
+ let REDACT_MIN_LEN = 6
33
+
34
+
35
+ /**
36
+ * 將文字中之機密值替換為遮蔽字
37
+ *
38
+ * @param {String} text 輸入文字
39
+ * @param {Array} redact 輸入機密值字串陣列
40
+ * @returns {String} 回傳替換後文字
41
+ */
42
+ function redactText(text, redact) {
43
+ let s = String(text)
44
+ for (let v of redact) {
45
+ if (isestr(v) && v.length >= REDACT_MIN_LEN) {
46
+ s = s.split(v).join(REDACT_MASK)
47
+ }
48
+ }
49
+ return s
50
+ }
51
+
52
+
53
+ /**
54
+ * 讀取回應本文, 超過上限即中止
55
+ *
56
+ * @param {Object} r 輸入fetch之Response
57
+ * @param {Number} maxBytes 輸入上限位元組
58
+ * @returns {Promise} 回傳物件{ok, text, bytes}, 超限時ok為false且bytes為已讀位元組(或content-length)
59
+ */
60
+ async function readBodyCapped(r, maxBytes) {
61
+
62
+ //content-length, 先擋明顯超限者, 不必開始讀
63
+ let cl = Number(r.headers.get('content-length') || 0)
64
+ if (cl > maxBytes) {
65
+ return { ok: false, text: '', bytes: cl }
66
+ }
67
+
68
+ //無串流介面時退回text(), 讀完再量
69
+ let body = r.body
70
+ if (!body || typeof body.getReader !== 'function') {
71
+ let t = await r.text()
72
+ let n = Buffer.byteLength(t)
73
+ if (n > maxBytes) {
74
+ return { ok: false, text: '', bytes: n }
75
+ }
76
+ return { ok: true, text: t, bytes: n }
77
+ }
78
+
79
+ //串流讀取, 邊讀邊量, 超限即取消
80
+ let reader = body.getReader()
81
+ let chunks = []
82
+ let total = 0
83
+ while (true) {
84
+ let { done, value } = await reader.read()
85
+ if (done) {
86
+ break
87
+ }
88
+ total += value.byteLength
89
+ if (total > maxBytes) {
90
+ try {
91
+ await reader.cancel()
92
+ }
93
+ catch (err) {}
94
+ return { ok: false, text: '', bytes: total }
95
+ }
96
+ chunks.push(Buffer.from(value))
97
+ }
98
+ return { ok: true, text: Buffer.concat(chunks).toString('utf8'), bytes: total }
99
+ }
100
+
101
+
102
+ /**
103
+ * 各額度查詢端點共用之HTTP請求並解析JSON
104
+ *
105
+ * 本函數不會reject,一律以結果物件之ok與error欄位回報成敗,
106
+ * 並將失敗歸入機器可讀之errorType,令呼叫端無須解析錯誤訊息字串即可分流處置
107
+ *
108
+ * @param {String} url 輸入查詢端點網址字串
109
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
110
+ * @param {String} [opt.method='GET'] 輸入HTTP方法字串,預設'GET'
111
+ * @param {Object|String} [opt.body=null] 輸入請求內容,給物件時序列化為JSON並自動補Content-Type,給字串時原樣送出,預設null代表無內容
112
+ * @param {Object} [opt.headers={}] 輸入額外HTTP標頭物件,預設{}
113
+ * @param {Number} [opt.timeoutMs=20000] 輸入逾時毫秒正整數,涵蓋連線至本文讀完,逾時將中止連線,預設20000
114
+ * @param {Number} [opt.maxBodyBytes=1048576] 輸入回應本文上限位元組正整數,超過即中止並回errorType 'toolarge',預設1048576
115
+ * @param {Array} [opt.redact=[]] 輸入須自錯誤訊息中遮蔽之機密值字串陣列(例如權杖、帳號ID),長度不足6者不處理,預設[]
116
+ * @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
117
+ * @example
118
+ * //need network
119
+ *
120
+ * import fetchQuotaJson from './src/quota/fetchQuotaJson.mjs'
121
+ *
122
+ * let test = async () => {
123
+ * let r = await fetchQuotaJson('https://api.anthropic.com/api/oauth/usage', {
124
+ * headers: { Authorization: 'Bearer xxx' },
125
+ * })
126
+ * console.log(r.ok, r.status)
127
+ * // => false 401
128
+ * }
129
+ * test()
130
+ *
131
+ */
132
+ async function fetchQuotaJson(url, opt = {}) {
133
+
134
+ let t0 = Date.now()
135
+
136
+ //rs, 各分支共用之結果骨架
137
+ let rs = (ok, status, data, error, errorType) => {
138
+ return {
139
+ ok,
140
+ status,
141
+ data,
142
+ error,
143
+ errorType,
144
+ durationMs: Date.now() - t0,
145
+ }
146
+ }
147
+
148
+ //check url
149
+ if (!isestr(url)) {
150
+ return rs(false, 0, null, 'url must be a non-empty string', 'params')
151
+ }
152
+
153
+ //headers, 非物件回退空物件
154
+ let headers = get(opt, 'headers', null)
155
+ if (!iseobj(headers)) {
156
+ headers = {}
157
+ }
158
+
159
+ //method, 無效回退GET
160
+ let method = get(opt, 'method', null)
161
+ if (!isestr(method)) {
162
+ method = 'GET'
163
+ }
164
+
165
+ //body, 物件即序列化為JSON並補Content-Type, 字串原樣送出
166
+ let body = get(opt, 'body', null)
167
+ if (iseobj(body)) {
168
+ body = JSON.stringify(body)
169
+ if (!isestr(get(headers, 'Content-Type', null))) {
170
+ headers = {
171
+ ...headers,
172
+ 'Content-Type': 'application/json',
173
+ }
174
+ }
175
+ }
176
+ else if (!isestr(body)) {
177
+ body = null
178
+ }
179
+
180
+ //timeoutMs
181
+ let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), dfQuotaTimeoutMs)
182
+
183
+ //maxBodyBytes
184
+ let maxBodyBytes = castPintOr(get(opt, 'maxBodyBytes', null), DEFAULT_MAX_BODY_BYTES)
185
+
186
+ //redact
187
+ let redact = get(opt, 'redact', null)
188
+ if (!isearr(redact)) {
189
+ redact = []
190
+ }
191
+
192
+ //snip, 錯誤訊息用之本文片段: 先遮蔽再截短, 避免機密值被截半後漏出
193
+ let snip = (text) => redactText(text, redact).slice(0, 300)
194
+
195
+ //ac, 逾時中止連線; 計時器持續至本文讀完
196
+ let ac = new AbortController()
197
+ let idTimer = setTimeout(() => {
198
+ ac.abort()
199
+ }, timeoutMs)
200
+
201
+ let r = null
202
+ let text = ''
203
+ try {
204
+
205
+ //fetch, resolve僅代表收到標頭
206
+ try {
207
+ r = await fetch(url, {
208
+ method,
209
+ headers,
210
+ body,
211
+ signal: ac.signal,
212
+ })
213
+ }
214
+ catch (err) {
215
+ if (get(err, 'name', '') === 'AbortError') {
216
+ return rs(false, 0, null, `request timeout over ${timeoutMs}ms`, 'timeout')
217
+ }
218
+ return rs(false, 0, null, `network error: ${redactText(get(err, 'message', 'unknown'), redact)}`, 'network')
219
+ }
220
+
221
+ //body, 先取原文再解析, 令非JSON之錯誤頁能原樣回報而非只說解析失敗
222
+ try {
223
+ let rb = await readBodyCapped(r, maxBodyBytes)
224
+ if (!rb.ok) {
225
+ ac.abort()
226
+ return rs(false, r.status, null, `response body too large: ${rb.bytes} bytes over limit ${maxBodyBytes}`, 'toolarge')
227
+ }
228
+ text = rb.text
229
+ }
230
+ catch (err) {
231
+ if (get(err, 'name', '') === 'AbortError') {
232
+ return rs(false, r.status, null, `request timeout over ${timeoutMs}ms while reading body`, 'timeout')
233
+ }
234
+ return rs(false, r.status, null, `read body error: ${redactText(get(err, 'message', 'unknown'), redact)}`, 'network')
235
+ }
236
+
237
+ }
238
+ finally {
239
+ clearTimeout(idTimer)
240
+ }
241
+
242
+ //auth, 401代表憑證本身不被接受(過期或撤銷)。此處不給處置指引——各家正確處置不同
243
+ //(Claude/Codex之CLI會於執行時自行刷新, 存檔之refresh token仍有效, 貿然指引「重新登入」是多餘的), 由各轉接器補述
244
+ if (r.status === 401) {
245
+ return rs(false, r.status, null, `unauthorized(401), credential rejected (expired or revoked): ${snip(text)}`, 'auth')
246
+ }
247
+
248
+ //forbidden, 403代表憑證有效但此帳號無權存取該資源, 與401分流(401重新登入可解, 403重新登入一百次也一樣)
249
+ if (r.status === 403) {
250
+ 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')
251
+ }
252
+
253
+ //ratelimit, 429為端點自身之查詢頻率限制, 與訂閱額度用罄無關
254
+ if (r.status === 429) {
255
+ return rs(false, r.status, null, `rate limited(429) by usage endpoint: ${snip(text)}`, 'ratelimit')
256
+ }
257
+
258
+ //http
259
+ if (!r.ok) {
260
+ return rs(false, r.status, null, `http error(${r.status}): ${snip(text)}`, 'http')
261
+ }
262
+
263
+ //data
264
+ let data = null
265
+ try {
266
+ data = JSON.parse(text)
267
+ }
268
+ catch (err) {
269
+ return rs(false, r.status, null, `invalid json: ${snip(text)}`, 'parse')
270
+ }
271
+
272
+ return rs(true, r.status, data, '', '')
273
+ }
274
+
275
+
276
+ export default fetchQuotaJson
@@ -0,0 +1,263 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+ import isearr from 'wsemi/src/isearr.mjs'
4
+ import iseobj from 'wsemi/src/iseobj.mjs'
5
+ import isnum from 'wsemi/src/isnum.mjs'
6
+ import cdbl from 'wsemi/src/cdbl.mjs'
7
+ import toQuotaScopedLabel from './toQuotaScopedLabel.mjs'
8
+ import toQuotaWindow from './toQuotaWindow.mjs'
9
+
10
+
11
+ // fromCodexUsageHttp.mjs — 將chatgpt.com之/wham/usage回應對映為統一結構
12
+ //
13
+ // 【為何獨立成檔】此端點為ChatGPT前端自用之內部端點, 欄位形狀已知會變(尤其additional_rate_limits),
14
+ // 對映邏輯獨立後可用離線fixture斷言, 不必每次都靠真帳號才能驗證。
15
+ //
16
+ // 【additional_rate_limits之寬容解析】本機帳號此欄實測為null, 形狀取自同類工具codex-reset-checker
17
+ // 之測試fixture(其作者觀察到的真實回應): 集合可為陣列或以名稱為鍵之物件; 單筆之識別可為
18
+ // limit_name / metered_limit_name / name / id; 顯示名可為display_name / title; 窗口可在單筆頂層、
19
+ // rate_limit之下(snake或camel), 或單筆本身就是一個窗口(直接帶used_percent, 或置於window鍵);
20
+ // 直接窗口不分primary/secondary時, 以名稱含weekly/secondary/reserve或窗口長度≥6天判為週限額。
21
+ // 已知名稱: codex-spark(GPT-5.3-Codex-Spark)、gpt-reserve(Luna reserve之週限額)。
22
+ // 任何欄位缺漏皆「該窗口不列入」而不拋錯, 原始回應由呼叫端保留於raw。
23
+
24
+
25
+ //直接窗口判為週限額之窗口長度門檻(秒)
26
+ let SEC_WEEKLY_MIN = 6 * 86400
27
+
28
+
29
+ /**
30
+ * 取第一個為非空字串之鍵值
31
+ *
32
+ * @param {Object} o 輸入物件
33
+ * @param {Array} keys 輸入鍵陣列
34
+ * @returns {String} 回傳字串, 皆無回傳''
35
+ */
36
+ function firstStr(o, keys) {
37
+ for (let k of keys) {
38
+ let v = get(o, k, '')
39
+ if (isestr(v)) {
40
+ return v
41
+ }
42
+ }
43
+ return ''
44
+ }
45
+
46
+
47
+ /**
48
+ * 將單一窗口{used_percent, limit_window_seconds, reset_at, reset_after_seconds}正規化
49
+ *
50
+ * @param {Object} o 輸入窗口物件
51
+ * @param {String} key 輸入窗口鍵字串
52
+ * @param {String} scope 輸入範圍字串
53
+ * @returns {Object|null} 回傳統一窗口物件, 輸入非物件回傳null
54
+ */
55
+ function fromWindow(o, key, scope) {
56
+ if (!iseobj(o)) {
57
+ return null
58
+ }
59
+ let ws = get(o, 'limit_window_seconds', null)
60
+ let windowSeconds = isnum(ws) ? cdbl(ws) : null
61
+ return toQuotaWindow({
62
+ key,
63
+ label: toQuotaScopedLabel(windowSeconds, scope),
64
+ windowSeconds,
65
+ usedPercent: get(o, 'used_percent', null),
66
+ resetAt: get(o, 'reset_at', ''),
67
+ resetAfterSeconds: get(o, 'reset_after_seconds', null),
68
+ scope,
69
+ })
70
+ }
71
+
72
+
73
+ /**
74
+ * 取出additional_rate_limits之集合並統一為陣列
75
+ *
76
+ * @param {Object} data 輸入回應物件
77
+ * @returns {Array} 回傳單筆物件陣列, 無則[]
78
+ */
79
+ function collectAdditional(data) {
80
+ let cands = [
81
+ get(data, 'additional_rate_limits', null),
82
+ get(data, 'rate_limit.additional_rate_limits', null),
83
+ ]
84
+ for (let c of cands) {
85
+ if (isearr(c)) {
86
+ return c
87
+ }
88
+ if (iseobj(c)) {
89
+ return Object.keys(c).map((k) => {
90
+ let v = c[k]
91
+ if (!iseobj(v)) {
92
+ return v
93
+ }
94
+ let nm = firstStr(v, ['limit_name', 'name', 'id'])
95
+ return { ...v, limit_name: nm === '' ? k : nm }
96
+ })
97
+ }
98
+ }
99
+ return []
100
+ }
101
+
102
+
103
+ /**
104
+ * 自單筆取出指定窗口(頂層、rate_limit、rateLimit之下, snake或camel)
105
+ *
106
+ * @param {Object} it 輸入單筆物件
107
+ * @param {String} snake 輸入snake鍵, 例如'primary_window'
108
+ * @param {String} camel 輸入camel鍵, 例如'primaryWindow'
109
+ * @returns {Object|null} 回傳窗口物件, 無回傳null
110
+ */
111
+ function pickWindow(it, snake, camel) {
112
+ for (let c of [it, get(it, 'rate_limit', null), get(it, 'rateLimit', null)]) {
113
+ if (!iseobj(c)) {
114
+ continue
115
+ }
116
+ let w = get(c, snake, null)
117
+ if (!iseobj(w)) {
118
+ w = get(c, camel, null)
119
+ }
120
+ if (iseobj(w)) {
121
+ return w
122
+ }
123
+ }
124
+ return null
125
+ }
126
+
127
+
128
+ /**
129
+ * 單筆本身即為窗口時取出之
130
+ *
131
+ * @param {Object} it 輸入單筆物件
132
+ * @returns {Object|null} 回傳窗口物件, 無回傳null
133
+ */
134
+ function directWindow(it) {
135
+ if (!iseobj(it)) {
136
+ return null
137
+ }
138
+ if (get(it, 'used_percent', undefined) !== undefined) {
139
+ return it
140
+ }
141
+ let w = get(it, 'window', null)
142
+ if (iseobj(w)) {
143
+ return w
144
+ }
145
+ let rl = get(it, 'rate_limit', null)
146
+ if (iseobj(rl) && get(rl, 'used_percent', undefined) !== undefined) {
147
+ return rl
148
+ }
149
+ return null
150
+ }
151
+
152
+
153
+ /**
154
+ * 將chatgpt.com之/wham/usage回應對映為統一結構
155
+ *
156
+ * 主窗口(rate_limit.primary_window / secondary_window)、程式碼審查限額(code_review_rate_limit)、
157
+ * 以及寬容解析之additional_rate_limits皆納入windows;額外限額之窗口鍵為「<識別>:primary|secondary」,
158
+ * scope為其顯示名或識別。任何欄位缺漏皆略過該窗口而不拋錯
159
+ *
160
+ * @param {Object} data 輸入/wham/usage之回應物件
161
+ * @returns {Object} 回傳物件,內含email(字串,無則'')、plan(字串,無則'')、windows(統一窗口陣列)、credits(點數與重置券資訊物件)
162
+ * @example
163
+ *
164
+ * import fromCodexUsageHttp from './src/quota/fromCodexUsageHttp.mjs'
165
+ *
166
+ * let m = fromCodexUsageHttp({
167
+ * email: 'a@b.c',
168
+ * plan_type: 'plus',
169
+ * rate_limit: { primary_window: { used_percent: 42, limit_window_seconds: 18000, reset_after_seconds: 100 } },
170
+ * additional_rate_limits: [{ limit_name: 'codex-spark', primary_window: { used_percent: 12, limit_window_seconds: 18000 } }],
171
+ * })
172
+ * console.log(m.windows.map((w) => `${w.key}=${w.label}`))
173
+ * // => [ 'primary=5小時', 'codex-spark:primary=5小時(codex-spark)' ]
174
+ *
175
+ */
176
+ function fromCodexUsageHttp(data) {
177
+
178
+ //email與plan
179
+ let email = firstStr(data, ['email'])
180
+ let plan = firstStr(data, ['plan_type'])
181
+
182
+ //windows, 主窗口
183
+ let windows = []
184
+ for (let k of ['primary', 'secondary']) {
185
+ let w = fromWindow(get(data, `rate_limit.${k}_window`, null), k, '')
186
+ if (w !== null) {
187
+ windows.push(w)
188
+ }
189
+ }
190
+
191
+ //code review限額, 與對話額度分開計算
192
+ for (let k of ['primary', 'secondary']) {
193
+ let w = fromWindow(get(data, `code_review_rate_limit.${k}_window`, null), `code_review:${k}`, 'code review')
194
+ if (w !== null) {
195
+ windows.push(w)
196
+ }
197
+ }
198
+
199
+ //additional_rate_limits, 寬容解析
200
+ let addi = collectAdditional(data)
201
+ for (let i = 0; i < addi.length; i++) {
202
+ let it = addi[i]
203
+ if (!iseobj(it)) {
204
+ continue
205
+ }
206
+
207
+ //id與scope
208
+ let id = firstStr(it, ['limit_name', 'metered_limit_name', 'name', 'id'])
209
+ if (id === '') {
210
+ id = `additional_${i + 1}`
211
+ }
212
+ let display = firstStr(it, ['display_name', 'title', 'name', 'limit_name', 'metered_limit_name'])
213
+ let scope = display === '' ? id : display
214
+ let identity = `${id} ${display}`.toLowerCase()
215
+
216
+ //窗口, 先找明確之primary/secondary, 皆無時把單筆本身當窗口並以名稱或長度判別週限額
217
+ let pri = pickWindow(it, 'primary_window', 'primaryWindow')
218
+ let sec = pickWindow(it, 'secondary_window', 'secondaryWindow')
219
+ if (pri === null && sec === null) {
220
+ let d = directWindow(it)
221
+ if (d !== null) {
222
+ let ws = get(d, 'limit_window_seconds', null)
223
+ let weekly = /weekly|secondary|reserve/.test(identity) || (isnum(ws) && cdbl(ws) >= SEC_WEEKLY_MIN)
224
+ if (weekly) {
225
+ sec = d
226
+ }
227
+ else {
228
+ pri = d
229
+ }
230
+ }
231
+ }
232
+
233
+ let wp = fromWindow(pri, `${id}:primary`, scope)
234
+ if (wp !== null) {
235
+ windows.push(wp)
236
+ }
237
+ let ws2 = fromWindow(sec, `${id}:secondary`, scope)
238
+ if (ws2 !== null) {
239
+ windows.push(ws2)
240
+ }
241
+ }
242
+
243
+ //credits, 點數餘額與「額度重置券」(可提前重置窗口)與限額狀態
244
+ let credits = {
245
+ hasCredits: get(data, 'credits.has_credits', false),
246
+ unlimited: get(data, 'credits.unlimited', false),
247
+ balance: get(data, 'credits.balance', ''),
248
+ resetCreditsAvailable: get(data, 'rate_limit_reset_credits.available_count', null),
249
+ spendControlReached: get(data, 'spend_control.reached', false),
250
+ rateLimitReachedType: get(data, 'rate_limit_reached_type', null),
251
+ individualLimit: get(data, 'spend_control.individual_limit', null),
252
+ }
253
+
254
+ return {
255
+ email,
256
+ plan,
257
+ windows,
258
+ credits,
259
+ }
260
+ }
261
+
262
+
263
+ export default fromCodexUsageHttp