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