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,515 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+
5
+ <meta charset="utf-8">
6
+ <title>quota/getQuotaCodex.mjs - Documentation</title>
7
+
8
+
9
+ <script src="scripts/prettify/prettify.js"></script>
10
+ <script src="scripts/prettify/lang-css.js"></script>
11
+ <!--[if lt IE 9]>
12
+ <script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
13
+ <![endif]-->
14
+ <link type="text/css" rel="stylesheet" href="styles/prettify.css">
15
+ <link type="text/css" rel="stylesheet" href="styles/jsdoc.css">
16
+ <script src="scripts/nav.js" defer></script>
17
+
18
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
19
+ </head>
20
+ <body>
21
+
22
+ <input type="checkbox" id="nav-trigger" class="nav-trigger" />
23
+ <label for="nav-trigger" class="navicon-button x">
24
+ <div class="navicon"></div>
25
+ </label>
26
+
27
+ <label for="nav-trigger" class="overlay"></label>
28
+
29
+ <nav >
30
+
31
+
32
+ <h2><a href="index.html">Home</a></h2><h3>Global</h3><ul><li><a href="global.html#NO_SIDE_EFFECT">NO_SIDE_EFFECT</a></li><li><a href="global.html#WDispatchAi">WDispatchAi</a></li><li><a href="global.html#adapters">adapters</a></li><li><a href="global.html#agyPrint">agyPrint</a></li><li><a href="global.html#attachErrorType">attachErrorType</a></li><li><a href="global.html#budgetFor">budgetFor</a></li><li><a href="global.html#buildChain">buildChain</a></li><li><a href="global.html#buildValidator">buildValidator</a></li><li><a href="global.html#callAiWithFallback">callAiWithFallback</a></li><li><a href="global.html#callOnce">callOnce</a></li><li><a href="global.html#castPintOr">castPintOr</a></li><li><a href="global.html#collectAdditional">collectAdditional</a></li><li><a href="global.html#createFileStore">createFileStore</a></li><li><a href="global.html#createUsageCounter">createUsageCounter</a></li><li><a href="global.html#decodeJwtPayload">decodeJwtPayload</a></li><li><a href="global.html#defaultIntegratePrompt">defaultIntegratePrompt</a></li><li><a href="global.html#dfQuotaTimeoutMs">dfQuotaTimeoutMs</a></li><li><a href="global.html#dfTimeoutMs">dfTimeoutMs</a></li><li><a href="global.html#directWindow">directWindow</a></li><li><a href="global.html#dispatchAi">dispatchAi</a></li><li><a href="global.html#dispatchAiFallback">dispatchAiFallback</a></li><li><a href="global.html#dispatchAiWkf">dispatchAiWkf</a></li><li><a href="global.html#dispatchAntigravity">dispatchAntigravity</a></li><li><a href="global.html#dispatchApiOpenaiCompat">dispatchApiOpenaiCompat</a></li><li><a href="global.html#dispatchApiOpenaiResponses">dispatchApiOpenaiResponses</a></li><li><a href="global.html#dispatchClaude">dispatchClaude</a></li><li><a href="global.html#dispatchCodex">dispatchCodex</a></li><li><a href="global.html#dispatchOpencode">dispatchOpencode</a></li><li><a href="global.html#extractJsonLoose">extractJsonLoose</a></li><li><a href="global.html#extractOutputText">extractOutputText</a></li><li><a href="global.html#fetchQuotaJson">fetchQuotaJson</a></li><li><a href="global.html#firstStr">firstStr</a></li><li><a href="global.html#fromBucket">fromBucket</a></li><li><a href="global.html#fromCodexUsageHttp">fromCodexUsageHttp</a></li><li><a href="global.html#fromLimitItem">fromLimitItem</a></li><li><a href="global.html#fromRpcWindow">fromRpcWindow</a></li><li><a href="global.html#fromTopField">fromTopField</a></li><li><a href="global.html#fromWindow">fromWindow</a></li><li><a href="global.html#getCliArgs">getCliArgs</a></li><li><a href="global.html#getErrorResult">getErrorResult</a></li><li><a href="global.html#getErrorType">getErrorType</a></li><li><a href="global.html#getQuotaAntigravity">getQuotaAntigravity</a></li><li><a href="global.html#getQuotaClaude">getQuotaClaude</a></li><li><a href="global.html#getQuotaCodex">getQuotaCodex</a></li><li><a href="global.html#initState">initState</a></li><li><a href="global.html#isKeyIndependentFail">isKeyIndependentFail</a></li><li><a href="global.html#pickWindow">pickWindow</a></li><li><a href="global.html#readBodyCapped">readBodyCapped</a></li><li><a href="global.html#readEnvFile">readEnvFile</a></li><li><a href="global.html#readJsonOrNull">readJsonOrNull</a></li><li><a href="global.html#redactText">redactText</a></li><li><a href="global.html#reorderByCooling">reorderByCooling</a></li><li><a href="global.html#resolveProviders">resolveProviders</a></li><li><a href="global.html#runFanout">runFanout</a></li><li><a href="global.html#runFanoutPipeline">runFanoutPipeline</a></li><li><a href="global.html#runRolePipeline">runRolePipeline</a></li><li><a href="global.html#salvageTruncatedArray">salvageTruncatedArray</a></li><li><a href="global.html#toQuotaLabel">toQuotaLabel</a></li><li><a href="global.html#toQuotaResult">toQuotaResult</a></li><li><a href="global.html#toQuotaScopedLabel">toQuotaScopedLabel</a></li><li><a href="global.html#toQuotaWindow">toQuotaWindow</a></li><li><a href="global.html#viaAppServer">viaAppServer</a></li><li><a href="global.html#viaHttp">viaHttp</a></li><li><a href="global.html#windowToSeconds">windowToSeconds</a></li></ul>
33
+
34
+ </nav>
35
+
36
+ <div id="main">
37
+
38
+ <h1 class="page-title">quota/getQuotaCodex.mjs</h1>
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+ <section>
47
+ <article>
48
+ <pre class="prettyprint source linenums"><code>import os from 'os'
49
+ import path from 'path'
50
+ import get from 'lodash-es/get.js'
51
+ import isobj from 'wsemi/src/isobj.mjs'
52
+ import isestr from 'wsemi/src/isestr.mjs'
53
+ import iseobj from 'wsemi/src/iseobj.mjs'
54
+ import isnum from 'wsemi/src/isnum.mjs'
55
+ import cdbl from 'wsemi/src/cdbl.mjs'
56
+ import execCliJsonRpc from 'wsemi/src/execCliJsonRpc.mjs'
57
+ import castPintOr from '../castPintOr.mjs'
58
+ import dfQuotaTimeoutMs from './dfQuotaTimeoutMs.mjs'
59
+ import readJsonOrNull from './readJsonOrNull.mjs'
60
+ import fetchQuotaJson from './fetchQuotaJson.mjs'
61
+ import fromCodexUsageHttp from './fromCodexUsageHttp.mjs'
62
+ import toQuotaScopedLabel from './toQuotaScopedLabel.mjs'
63
+ import toQuotaWindow from './toQuotaWindow.mjs'
64
+ import toQuotaResult from './toQuotaResult.mjs'
65
+
66
+
67
+ // getQuotaCodex.mjs — 查詢Codex(ChatGPT訂閱)帳號之當前額度
68
+ //
69
+ // 【主路徑: codex app-server(第一方協定), 2026-09-05本機實測】
70
+ // `codex app-server --stdio` JSON-RPC: account/read → {account:{type:'chatgpt',email,planType}}(0.4s);
71
+ // account/rateLimits/read → {rateLimits:{primary,secondary,credits,planType,...}, rateLimitsByLimitId, rateLimitResetCredits}(1.4s)。
72
+ // 認證與權杖刷新由codex自理, 本路徑不讀auth.json、不碰任何token; app-server子進程繼承本進程環境,
73
+ // 故查的是本進程CODEX_HOME(未設則~/.codex)所指之帳號——opt.codexHome不影響此路徑, 只用於下方備援;
74
+ // 要讓主路徑查別的目錄, 須於本進程環境設CODEX_HOME, 或以useAppServer:false改走備援。
75
+ // Codex Desktop/IDE走同一協定, 有`codex app-server generate-json-schema`可產schema, 遠比
76
+ // chatgpt.com之內部端點穩。同類工具wakamex/codex-cli-usage亦以此為主路徑。
77
+ // 工作階段由wsemi之execCliJsonRpc(1.8.85起)承擔: Windows之.cmd解析、維持stdin開啟直到回應到齊、
78
+ // stdin.end()令其自行退出、逾寬限樹殺、以及「收尾之退出不算失敗」皆在其內; 本檔只負責
79
+ // 握手內容(initialize之clientInfo、initialized通知)與codex專屬之回應對映。
80
+ // rateLimitResetCredits(額度重置券)由此路徑取得, 實測含id/title/description/grantedAt/expiresAt,
81
+ // 比chatgpt.com之rate-limit-reset-credits專用端點更完整; 兌換重置券亦有第一方方法
82
+ // account/rateLimitResetCredit/consume({idempotencyKey, creditId?}), 屬動作非查詢, 本檔刻意不做。
83
+ //
84
+ // 【備援: GET https://chatgpt.com/backend-api/wham/usage】codex不存在、版本無app-server、
85
+ // 或RPC失敗時退回直打(實測200), 憑證取自&lt;opt.codexHome|CODEX_HOME|~/.codex>/auth.json(opt.codexHome僅於此生效); 該回應本身即帶
86
+ // email與plan_type。此為ChatGPT前端自用之內部端點, 路徑與欄位可能隨時變動, 對映邏輯獨立於
87
+ // fromCodexUsageHttp以便用離線fixture驗證; 錯誤訊息一律先遮蔽權杖與帳號ID。
88
+ //
89
+ // 【認證模式】auth.json之auth_mode欄位實測存在(本機'chatgpt'); API key模式下無tokens而有
90
+ // OPENAI_API_KEY, 此模式按用量計費、無訂閱額度窗口, 回unsupported而非誤導其去登入。
91
+ // app-server之account.type同義(非'chatgpt'即非訂閱)。
92
+ //
93
+ // 【為何備援不自行刷新權杖】auth.json之access_token實測壽命10天且由codex於執行時自行刷新;
94
+ // 監控程式不擁有憑證生命週期(理由同getQuotaClaude), 401時指引執行一次codex即可。
95
+
96
+
97
+ //備援端點與其UA/originator(Codex CLI對此端點之自我標識)
98
+ let URL_USAGE = 'https://chatgpt.com/backend-api/wham/usage'
99
+ let DEFAULT_UA = 'codex-cli/0.153.0'
100
+ let DEFAULT_ORIGINATOR = 'codex_cli_rs'
101
+
102
+
103
+ //app-server之請求序列: 握手(initialize須帶clientInfo; initialized為通知)後讀帳號與額度
104
+ let DEFAULT_EXE = 'codex'
105
+ let RPC_REQUESTS = [
106
+ { method: 'initialize', params: { clientInfo: { name: 'w-dispatch-ai', title: 'w-dispatch-ai', version: '1.0.0' } } },
107
+ { notify: 'initialized', params: {} },
108
+ { method: 'account/read', params: {} },
109
+ { method: 'account/rateLimits/read', params: {} },
110
+ ]
111
+
112
+
113
+ /**
114
+ * 解出JWT之payload, 僅供顯示用途, 不驗簽
115
+ *
116
+ * @param {String} jwt 輸入JWT字串
117
+ * @returns {Object|null} 回傳payload物件, 解析失敗回傳null
118
+ */
119
+ function decodeJwtPayload(jwt) {
120
+ try {
121
+ let ss = jwt.split('.')
122
+ if (ss.length &lt; 2) {
123
+ return null
124
+ }
125
+ return JSON.parse(Buffer.from(ss[1], 'base64url').toString('utf8'))
126
+ }
127
+ catch (err) {
128
+ return null
129
+ }
130
+ }
131
+
132
+
133
+ /**
134
+ * 將app-server之單一窗口(primary/secondary)正規化為統一窗口物件
135
+ *
136
+ * @param {Object} o 輸入窗口物件{usedPercent, windowDurationMins, resetsAt}
137
+ * @param {String} key 輸入窗口鍵字串
138
+ * @param {String} scope 輸入範圍字串
139
+ * @returns {Object|null} 回傳統一窗口物件, 輸入非物件回傳null
140
+ */
141
+ function fromRpcWindow(o, key, scope) {
142
+ if (!iseobj(o)) {
143
+ return null
144
+ }
145
+ let mins = get(o, 'windowDurationMins', null)
146
+ let windowSeconds = isnum(mins) ? cdbl(mins) * 60 : null
147
+ return toQuotaWindow({
148
+ key,
149
+ label: toQuotaScopedLabel(windowSeconds, scope),
150
+ windowSeconds,
151
+ usedPercent: get(o, 'usedPercent', null),
152
+ resetAt: get(o, 'resetsAt', ''),
153
+ scope,
154
+ })
155
+ }
156
+
157
+
158
+ /**
159
+ * 以app-server取得額度並正規化
160
+ *
161
+ * @param {Object} opt 輸入設定物件
162
+ * @returns {Promise} 回傳結果片段物件{ok, email, plan, accountType, windows, credits, raw, error, errorType}
163
+ */
164
+ async function viaAppServer(opt) {
165
+
166
+ //exe
167
+ let exe = get(opt, 'exe', null)
168
+ if (!isestr(exe)) {
169
+ exe = DEFAULT_EXE
170
+ }
171
+
172
+ //rpc, errorType為params/notfound/timeout/exit/rpc
173
+ let r = await execCliJsonRpc(exe, ['app-server', '--stdio'], RPC_REQUESTS, {
174
+ timeoutMs: get(opt, 'timeoutMs', null),
175
+ })
176
+ if (!r.ok) {
177
+ return { ok: false, error: r.error, errorType: r.errorType, raw: { appServer: { error: r.error, errorType: r.errorType, exitCode: r.exitCode, responses: r.responses } } }
178
+ }
179
+
180
+ let acc = get(r.results, ['account/read', 'account'], null)
181
+ let rl = get(r.results, ['account/rateLimits/read'], null)
182
+
183
+ //account, null代表codex尚未登入
184
+ if (!iseobj(acc)) {
185
+ 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 } }
186
+ }
187
+ let email = get(acc, 'email', '')
188
+ let plan = get(acc, 'planType', '')
189
+ let accountType = get(acc, 'type', '')
190
+
191
+ //accountType, 非chatgpt(例如apikey)即非訂閱, 無額度窗口
192
+ if (isestr(accountType) &amp;&amp; accountType !== 'chatgpt') {
193
+ return {
194
+ ok: false,
195
+ email,
196
+ plan,
197
+ accountType,
198
+ error: `codex is logged in with [${accountType}] mode; this mode is billed by usage and has no 5-hour/7-day subscription quota windows`,
199
+ errorType: 'unsupported',
200
+ raw: { appServer: r.results },
201
+ }
202
+ }
203
+
204
+ //windows, 彙總窗口為主; rateLimitsByLimitId內與彙總同id者略過, 其餘以limitId為範圍逐一納入
205
+ let windows = []
206
+ let agg = get(rl, 'rateLimits', null)
207
+ let aggId = get(agg, 'limitId', 'codex')
208
+ let wPri = fromRpcWindow(get(agg, 'primary', null), 'primary', '')
209
+ if (wPri !== null) {
210
+ windows.push(wPri)
211
+ }
212
+ let wSec = fromRpcWindow(get(agg, 'secondary', null), 'secondary', '')
213
+ if (wSec !== null) {
214
+ windows.push(wSec)
215
+ }
216
+ let byId = get(rl, 'rateLimitsByLimitId', null)
217
+ if (iseobj(byId)) {
218
+ for (let limitId of Object.keys(byId)) {
219
+ if (limitId === aggId) {
220
+ continue
221
+ }
222
+ let it = byId[limitId]
223
+ let scope = get(it, 'limitName', '')
224
+ if (!isestr(scope)) {
225
+ scope = limitId
226
+ }
227
+ for (let k of ['primary', 'secondary']) {
228
+ let w = fromRpcWindow(get(it, k, null), `${limitId}:${k}`, scope)
229
+ if (w !== null) {
230
+ windows.push(w)
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ //credits, 點數餘額、額度重置券(Codex特有, 可提前重置窗口)與限額狀態
237
+ let credits = {
238
+ hasCredits: get(agg, 'credits.hasCredits', false),
239
+ unlimited: get(agg, 'credits.unlimited', false),
240
+ balance: get(agg, 'credits.balance', ''),
241
+ resetCreditsAvailable: get(rl, 'rateLimitResetCredits.availableCount', null),
242
+ resetCredits: get(rl, 'rateLimitResetCredits.credits', null),
243
+ spendControlReached: get(agg, 'spendControlReached', false),
244
+ rateLimitReachedType: get(agg, 'rateLimitReachedType', null),
245
+ individualLimit: get(agg, 'individualLimit', null),
246
+ }
247
+
248
+ //plan, account/read為先, 其次rateLimits
249
+ if (!isestr(plan)) {
250
+ plan = get(agg, 'planType', '')
251
+ }
252
+
253
+ return {
254
+ ok: true,
255
+ email,
256
+ plan,
257
+ accountType,
258
+ windows,
259
+ credits,
260
+ raw: { appServer: r.results },
261
+ }
262
+ }
263
+
264
+
265
+ /**
266
+ * 以備援端點取得額度並正規化
267
+ *
268
+ * @param {Object} opt 輸入設定物件
269
+ * @param {String} codexHome 輸入codex設定目錄字串
270
+ * @param {Number} timeoutMs 輸入逾時毫秒
271
+ * @returns {Promise} 回傳結果片段物件{ok, email, plan, windows, credits, raw, error, errorType}
272
+ */
273
+ async function viaHttp(opt, codexHome, timeoutMs) {
274
+
275
+ //env, 同主函數之注入來源
276
+ let env = get(opt, 'env', null)
277
+ if (!isobj(env)) {
278
+ env = process.env
279
+ }
280
+
281
+ //auth
282
+ let fpAuth = path.join(codexHome, 'auth.json')
283
+ let auth = readJsonOrNull(fpAuth)
284
+ let token = get(auth, 'tokens.access_token', '')
285
+ let accountId = get(auth, 'tokens.account_id', '')
286
+
287
+ //email與plan, 先由id_token取得(查詢失敗時仍能指出本機綁定之帳號), 查詢成功後以回應為準
288
+ let email = ''
289
+ let plan = ''
290
+ let idToken = get(auth, 'tokens.id_token', '')
291
+ if (isestr(idToken)) {
292
+ let pl = decodeJwtPayload(idToken)
293
+ let e = get(pl, 'email', '')
294
+ if (isestr(e)) {
295
+ email = e
296
+ }
297
+ //claim名本身含點號, 路徑須給陣列形式, 給字串會被lodash切開而永遠取不到
298
+ let p = get(pl, ['https://api.openai.com/auth', 'chatgpt_plan_type'], '')
299
+ if (isestr(p)) {
300
+ plan = p
301
+ }
302
+ }
303
+
304
+ //check token
305
+ if (!isestr(token)) {
306
+
307
+ //unsupported, API key模式: auth.json只有OPENAI_API_KEY而無tokens
308
+ let apiKey = get(auth, 'OPENAI_API_KEY', '')
309
+ if (isestr(apiKey) || isestr(get(env, 'OPENAI_API_KEY', ''))) {
310
+ 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' }
311
+ }
312
+
313
+ return { ok: false, email, plan, error: `Codex CLI credential not found (${fpAuth}); run [codex login] first`, errorType: 'notfound' }
314
+ }
315
+
316
+ //headers
317
+ let userAgent = get(opt, 'userAgent', null)
318
+ if (!isestr(userAgent)) {
319
+ userAgent = DEFAULT_UA
320
+ }
321
+ let originator = get(opt, 'originator', null)
322
+ if (!isestr(originator)) {
323
+ originator = DEFAULT_ORIGINATOR
324
+ }
325
+ let headers = {
326
+ 'Authorization': `Bearer ${token}`,
327
+ 'User-Agent': userAgent,
328
+ originator,
329
+ }
330
+ if (isestr(accountId)) {
331
+ headers['ChatGPT-Account-ID'] = accountId
332
+ }
333
+
334
+ //fetch, 錯誤訊息中之權杖與帳號ID一律遮蔽
335
+ let usageUrl = get(opt, 'usageUrl', null)
336
+ if (!isestr(usageUrl)) {
337
+ usageUrl = URL_USAGE
338
+ }
339
+ let r = await fetchQuotaJson(usageUrl, { headers, timeoutMs, redact: [token, accountId] })
340
+ if (!r.ok) {
341
+ let msg = r.error
342
+ if (r.errorType === 'auth') {
343
+ 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`
344
+ }
345
+ return { ok: false, email, plan, error: msg, errorType: r.errorType }
346
+ }
347
+
348
+ //map, 回應之帳號與方案為權威值
349
+ let m = fromCodexUsageHttp(r.data)
350
+ if (isestr(m.email)) {
351
+ email = m.email
352
+ }
353
+ if (isestr(m.plan)) {
354
+ plan = m.plan
355
+ }
356
+
357
+ return { ok: true, email, plan, windows: m.windows, credits: m.credits, raw: { usage: r.data } }
358
+ }
359
+
360
+
361
+ /**
362
+ * 查詢Codex(ChatGPT訂閱)帳號之當前額度
363
+ *
364
+ * 主路徑以codex app-server之JSON-RPC取得帳號與額度(認證與刷新由codex自理),
365
+ * codex不存在或RPC失敗時退回直打chatgpt.com之用量端點。
366
+ * 額度綁定本機Codex CLI之登入憑證,無「給email查任意帳號」之公開介面,
367
+ * 故email參數之作用為比對——不符時ok為false且matched為false,並於error載明本機實際登入之帳號;
368
+ * 未給email時不比對。本函數不會reject,一律以結果物件之ok與error欄位回報成敗
369
+ *
370
+ * @param {String} [email=''] 輸入欲查詢之帳號email字串,預設''代表不比對而直接回報本機當前帳號
371
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
372
+ * @param {String} [opt.exe='codex'] 輸入codex執行檔名稱或路徑字串,預設'codex'
373
+ * @param {Boolean} [opt.useAppServer=true] 輸入是否以app-server為主路徑布林值,false代表直接走備援端點,預設true
374
+ * @param {Boolean} [opt.fallbackHttp=true] 輸入app-server失敗時是否退回備援端點布林值,預設true
375
+ * @param {String} [opt.codexHome] 輸入codex設定目錄字串,僅備援路徑讀其auth.json時生效;app-server主路徑之子進程繼承本進程環境,查的是本進程CODEX_HOME所指之帳號,不受此參數影響(要讓主路徑查別的目錄,須於本進程環境設CODEX_HOME,或以useAppServer:false改走備援),預設取環境變數CODEX_HOME,未設則&lt;homeDir>/.codex
376
+ * @param {String} [opt.homeDir=os.homedir()] 輸入家目錄字串,僅於未指定codexHome且未設環境變數時使用
377
+ * @param {String} [opt.userAgent='codex-cli/0.153.0'] 輸入備援端點之User-Agent字串
378
+ * @param {String} [opt.originator='codex_cli_rs'] 輸入備援端點之originator標頭字串
379
+ * @param {String} [opt.usageUrl='https://chatgpt.com/backend-api/wham/usage'] 輸入備援端點網址字串,供測試指向假伺服器或經企業代理,預設官方端點
380
+ * @param {Object} [opt.env=process.env] 輸入環境變數來源物件(讀CODEX_HOME與API key模式判定用之OPENAI_API_KEY),供測試隔離本機環境,預設process.env
381
+ * @param {Number} [opt.timeoutMs=20000] 輸入逾時毫秒正整數(app-server整體或單次HTTP),預設20000
382
+ * @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
383
+ * @example
384
+ * //need codex cli logged in
385
+ *
386
+ * import getQuotaCodex from './src/quota/getQuotaCodex.mjs'
387
+ *
388
+ * let test = async () => {
389
+ * let r = await getQuotaCodex('firsemisphere@gmail.com')
390
+ * console.log(r.ok, r.plan, r.source)
391
+ * // => true plus codex-app-server
392
+ * console.log(r.windows[0].label, r.windows[0].usedPercent)
393
+ * // => 5小時 37 (百分比為查詢當下之即時值, 每次不同)
394
+ * }
395
+ * test()
396
+ *
397
+ */
398
+ async function getQuotaCodex(email = '', opt = {}) {
399
+
400
+ let t0 = Date.now()
401
+
402
+ //emailWant
403
+ let emailWant = isestr(email) ? email : ''
404
+
405
+ //env, 環境變數來源可注入(測試以替身隔離本機環境), 預設process.env
406
+ let env = get(opt, 'env', null)
407
+ if (!isobj(env)) {
408
+ env = process.env
409
+ }
410
+
411
+ //homeDir
412
+ let homeDir = get(opt, 'homeDir', null)
413
+ if (!isestr(homeDir)) {
414
+ homeDir = os.homedir()
415
+ }
416
+
417
+ //codexHome, 依序取opt、CODEX_HOME、&lt;homeDir>/.codex(codex --help: auth still uses CODEX_HOME);
418
+ //僅備援路徑用, app-server主路徑之子進程繼承本進程環境而不看此值(見檔頭)
419
+ let codexHome = get(opt, 'codexHome', null)
420
+ if (!isestr(codexHome)) {
421
+ codexHome = get(env, 'CODEX_HOME', '')
422
+ if (!isestr(codexHome)) {
423
+ codexHome = path.join(homeDir, '.codex')
424
+ }
425
+ }
426
+
427
+ //timeoutMs
428
+ let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), dfQuotaTimeoutMs)
429
+
430
+ //useAppServer與fallbackHttp
431
+ let useAppServer = get(opt, 'useAppServer', true) !== false
432
+ let fallbackHttp = get(opt, 'fallbackHttp', true) !== false
433
+
434
+ //fin
435
+ let fin = (source, o) => {
436
+ return toQuotaResult('codex', {
437
+ emailWant,
438
+ source,
439
+ durationMs: Date.now() - t0,
440
+ ...o,
441
+ })
442
+ }
443
+
444
+ //app-server
445
+ let appErr = null
446
+ if (useAppServer) {
447
+ let a = await viaAppServer({ ...opt, timeoutMs })
448
+ if (a.ok) {
449
+ return fin('codex-app-server', {
450
+ email: a.email,
451
+ plan: a.plan,
452
+ windows: a.windows,
453
+ credits: a.credits,
454
+ raw: { accountType: a.accountType, ...a.raw },
455
+ })
456
+ }
457
+
458
+ //unsupported與「尚未登入」(notLoggedIn旗標, 不靠錯誤字串比對)為帳號狀態, 備援亦無解, 直接回報
459
+ if (a.errorType === 'unsupported' || a.notLoggedIn === true) {
460
+ return fin('codex-app-server', { email: a.email, plan: a.plan, error: a.error, errorType: a.errorType, raw: a.raw })
461
+ }
462
+ appErr = a
463
+ if (!fallbackHttp) {
464
+ return fin('codex-app-server', { error: a.error, errorType: a.errorType, raw: a.raw })
465
+ }
466
+ }
467
+
468
+ //http fallback
469
+ let h = await viaHttp(opt, codexHome, timeoutMs)
470
+ let note = appErr !== null ? ` (app-server path failed: ${appErr.error})` : ''
471
+ if (!h.ok) {
472
+ return fin('chatgpt-wham-usage-api', {
473
+ email: h.email,
474
+ plan: h.plan,
475
+ error: `${h.error}${note}`,
476
+ errorType: h.errorType,
477
+ raw: appErr !== null ? appErr.raw : null,
478
+ })
479
+ }
480
+ return fin('chatgpt-wham-usage-api', {
481
+ email: h.email,
482
+ plan: h.plan,
483
+ windows: h.windows,
484
+ credits: h.credits,
485
+ raw: { codexHome, appServerError: appErr !== null ? appErr.error : '', ...h.raw },
486
+ })
487
+ }
488
+
489
+
490
+ export default getQuotaCodex
491
+ </code></pre>
492
+ </article>
493
+ </section>
494
+
495
+
496
+
497
+
498
+
499
+
500
+ </div>
501
+
502
+ <br class="clear">
503
+
504
+ <footer>
505
+ Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Sat Sep 05 2026 22:03:07 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
506
+ </footer>
507
+
508
+ <script>prettyPrint();</script>
509
+ <script src="scripts/polyfill.js"></script>
510
+ <script src="scripts/linenumber.js"></script>
511
+
512
+
513
+
514
+ </body>
515
+ </html>
@@ -0,0 +1,113 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+
5
+ <meta charset="utf-8">
6
+ <title>quota/readJsonOrNull.mjs - Documentation</title>
7
+
8
+
9
+ <script src="scripts/prettify/prettify.js"></script>
10
+ <script src="scripts/prettify/lang-css.js"></script>
11
+ <!--[if lt IE 9]>
12
+ <script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
13
+ <![endif]-->
14
+ <link type="text/css" rel="stylesheet" href="styles/prettify.css">
15
+ <link type="text/css" rel="stylesheet" href="styles/jsdoc.css">
16
+ <script src="scripts/nav.js" defer></script>
17
+
18
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
19
+ </head>
20
+ <body>
21
+
22
+ <input type="checkbox" id="nav-trigger" class="nav-trigger" />
23
+ <label for="nav-trigger" class="navicon-button x">
24
+ <div class="navicon"></div>
25
+ </label>
26
+
27
+ <label for="nav-trigger" class="overlay"></label>
28
+
29
+ <nav >
30
+
31
+
32
+ <h2><a href="index.html">Home</a></h2><h3>Global</h3><ul><li><a href="global.html#NO_SIDE_EFFECT">NO_SIDE_EFFECT</a></li><li><a href="global.html#WDispatchAi">WDispatchAi</a></li><li><a href="global.html#adapters">adapters</a></li><li><a href="global.html#agyPrint">agyPrint</a></li><li><a href="global.html#attachErrorType">attachErrorType</a></li><li><a href="global.html#budgetFor">budgetFor</a></li><li><a href="global.html#buildChain">buildChain</a></li><li><a href="global.html#buildValidator">buildValidator</a></li><li><a href="global.html#callAiWithFallback">callAiWithFallback</a></li><li><a href="global.html#callOnce">callOnce</a></li><li><a href="global.html#castPintOr">castPintOr</a></li><li><a href="global.html#collectAdditional">collectAdditional</a></li><li><a href="global.html#createFileStore">createFileStore</a></li><li><a href="global.html#createUsageCounter">createUsageCounter</a></li><li><a href="global.html#decodeJwtPayload">decodeJwtPayload</a></li><li><a href="global.html#defaultIntegratePrompt">defaultIntegratePrompt</a></li><li><a href="global.html#dfQuotaTimeoutMs">dfQuotaTimeoutMs</a></li><li><a href="global.html#dfTimeoutMs">dfTimeoutMs</a></li><li><a href="global.html#directWindow">directWindow</a></li><li><a href="global.html#dispatchAi">dispatchAi</a></li><li><a href="global.html#dispatchAiFallback">dispatchAiFallback</a></li><li><a href="global.html#dispatchAiWkf">dispatchAiWkf</a></li><li><a href="global.html#dispatchAntigravity">dispatchAntigravity</a></li><li><a href="global.html#dispatchApiOpenaiCompat">dispatchApiOpenaiCompat</a></li><li><a href="global.html#dispatchApiOpenaiResponses">dispatchApiOpenaiResponses</a></li><li><a href="global.html#dispatchClaude">dispatchClaude</a></li><li><a href="global.html#dispatchCodex">dispatchCodex</a></li><li><a href="global.html#dispatchOpencode">dispatchOpencode</a></li><li><a href="global.html#extractJsonLoose">extractJsonLoose</a></li><li><a href="global.html#extractOutputText">extractOutputText</a></li><li><a href="global.html#fetchQuotaJson">fetchQuotaJson</a></li><li><a href="global.html#firstStr">firstStr</a></li><li><a href="global.html#fromBucket">fromBucket</a></li><li><a href="global.html#fromCodexUsageHttp">fromCodexUsageHttp</a></li><li><a href="global.html#fromLimitItem">fromLimitItem</a></li><li><a href="global.html#fromRpcWindow">fromRpcWindow</a></li><li><a href="global.html#fromTopField">fromTopField</a></li><li><a href="global.html#fromWindow">fromWindow</a></li><li><a href="global.html#getCliArgs">getCliArgs</a></li><li><a href="global.html#getErrorResult">getErrorResult</a></li><li><a href="global.html#getErrorType">getErrorType</a></li><li><a href="global.html#getQuotaAntigravity">getQuotaAntigravity</a></li><li><a href="global.html#getQuotaClaude">getQuotaClaude</a></li><li><a href="global.html#getQuotaCodex">getQuotaCodex</a></li><li><a href="global.html#initState">initState</a></li><li><a href="global.html#isKeyIndependentFail">isKeyIndependentFail</a></li><li><a href="global.html#pickWindow">pickWindow</a></li><li><a href="global.html#readBodyCapped">readBodyCapped</a></li><li><a href="global.html#readEnvFile">readEnvFile</a></li><li><a href="global.html#readJsonOrNull">readJsonOrNull</a></li><li><a href="global.html#redactText">redactText</a></li><li><a href="global.html#reorderByCooling">reorderByCooling</a></li><li><a href="global.html#resolveProviders">resolveProviders</a></li><li><a href="global.html#runFanout">runFanout</a></li><li><a href="global.html#runFanoutPipeline">runFanoutPipeline</a></li><li><a href="global.html#runRolePipeline">runRolePipeline</a></li><li><a href="global.html#salvageTruncatedArray">salvageTruncatedArray</a></li><li><a href="global.html#toQuotaLabel">toQuotaLabel</a></li><li><a href="global.html#toQuotaResult">toQuotaResult</a></li><li><a href="global.html#toQuotaScopedLabel">toQuotaScopedLabel</a></li><li><a href="global.html#toQuotaWindow">toQuotaWindow</a></li><li><a href="global.html#viaAppServer">viaAppServer</a></li><li><a href="global.html#viaHttp">viaHttp</a></li><li><a href="global.html#windowToSeconds">windowToSeconds</a></li></ul>
33
+
34
+ </nav>
35
+
36
+ <div id="main">
37
+
38
+ <h1 class="page-title">quota/readJsonOrNull.mjs</h1>
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+ <section>
47
+ <article>
48
+ <pre class="prettyprint source linenums"><code>import get from 'lodash-es/get.js'
49
+ import fsReadJson from 'wsemi/src/fsReadJson.mjs'
50
+
51
+
52
+ // readJsonOrNull.mjs — 讀取JSON檔, 任何失敗一律回null
53
+ //
54
+ // 【為何獨立成檔】三個額度轉接器都要讀各自CLI之憑證或設定檔, 且處置一致(檔案不存在、
55
+ // 無權限、內容非JSON皆視為「取不到」而非錯誤); 同一段判斷曾各自手寫三份,
56
+ // 依全域規範「同一規則手寫≥2處即補丁訊號」收斂於此。
57
+ //
58
+ // 【為何包wsemi之fsReadJson而非自寫try/catch】fsReadJson已是套件內讀JSON之canonical實作
59
+ // (純JSON.parse, 無型別轉換), 以{ success }/{ error }回報; 本檔只把它收斂成
60
+ // 「成功回值、失敗回null」之單一契約, 令呼叫端不必每處都判斷error鍵。
61
+
62
+
63
+ /**
64
+ * 讀取JSON檔,任何失敗一律回傳null
65
+ *
66
+ * 檔案不存在、無讀取權限、內容非合法JSON三種情形對呼叫端而言皆為「取不到」,
67
+ * 無須區分,故一律回null,由呼叫端決定後續處置(例如回報未登入)
68
+ *
69
+ * @param {String} fp 輸入檔案路徑字串
70
+ * @returns {Object|Array|null} 回傳解析後之JSON值,讀取或解析失敗回傳null
71
+ * @example
72
+ *
73
+ * import readJsonOrNull from './src/quota/readJsonOrNull.mjs'
74
+ *
75
+ * console.log(readJsonOrNull('./package.json') !== null)
76
+ * // => true
77
+ *
78
+ * console.log(readJsonOrNull('./no-such-file.json'))
79
+ * // => null
80
+ *
81
+ */
82
+ function readJsonOrNull(fp) {
83
+ let rj = fsReadJson(fp)
84
+ return get(rj, 'success', null)
85
+ }
86
+
87
+
88
+ export default readJsonOrNull
89
+ </code></pre>
90
+ </article>
91
+ </section>
92
+
93
+
94
+
95
+
96
+
97
+
98
+ </div>
99
+
100
+ <br class="clear">
101
+
102
+ <footer>
103
+ Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Sat Sep 05 2026 22:03:08 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
104
+ </footer>
105
+
106
+ <script>prettyPrint();</script>
107
+ <script src="scripts/polyfill.js"></script>
108
+ <script src="scripts/linenumber.js"></script>
109
+
110
+
111
+
112
+ </body>
113
+ </html>