w-dispatch-ai 1.0.22 → 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 (70) hide show
  1. package/README.md +627 -581
  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 +10 -4
  5. package/docs/adapters.mjs.html +2 -2
  6. package/docs/budgetFor.mjs.html +2 -2
  7. package/docs/buildValidator.mjs.html +2 -2
  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 +2 -2
  15. package/docs/dispatchApiOpenaiResponses.mjs.html +2 -2
  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 +2 -2
  22. package/docs/global.html +10615 -3368
  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 +2 -2
  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/package.json +2 -2
  47. package/src/WDispatchAi.mjs +8 -2
  48. package/src/quota/dfQuotaTimeoutMs.mjs +27 -0
  49. package/src/quota/fetchQuotaJson.mjs +276 -0
  50. package/src/quota/fromCodexUsageHttp.mjs +263 -0
  51. package/src/quota/getQuotaAntigravity.mjs +391 -0
  52. package/src/quota/getQuotaClaude.mjs +385 -0
  53. package/src/quota/getQuotaCodex.mjs +443 -0
  54. package/src/quota/readJsonOrNull.mjs +41 -0
  55. package/src/quota/toQuotaLabel.mjs +74 -0
  56. package/src/quota/toQuotaResult.mjs +142 -0
  57. package/src/quota/toQuotaScopedLabel.mjs +45 -0
  58. package/src/quota/toQuotaWindow.mjs +167 -0
  59. package/test/tools/fakeCliForTest.mjs +4 -3
  60. package/test/tools/fakeServerForQuotaTest.mjs +171 -0
  61. package/test/unit-WDispatchAi.test.mjs +11 -2
  62. package/test/unit-fetchQuotaJson.test.mjs +70 -0
  63. package/test/unit-fromCodexUsageHttp.test.mjs +76 -0
  64. package/test/unit-getQuotaAntigravity.test.mjs +123 -0
  65. package/test/unit-getQuotaClaude.test.mjs +137 -0
  66. package/test/unit-getQuotaCodex.test.mjs +191 -0
  67. package/test/unit-readJsonOrNull.test.mjs +44 -0
  68. package/test/unit-toQuotaLabel.test.mjs +43 -0
  69. package/test/unit-toQuotaResult.test.mjs +53 -0
  70. package/test/unit-toQuotaWindow.test.mjs +73 -0
@@ -0,0 +1,142 @@
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
+
5
+
6
+ // toQuotaResult.mjs — 將各家額度查詢結果正規化為統一結構, 並判定帳號是否相符
7
+ //
8
+ // 【email在此類查詢中的真實角色】三家訂閱額度皆綁定「本機該CLI當前登入之憑證」,
9
+ // 無任一家提供「給email即可查任意帳號額度」之公開介面(那將是帳號列舉漏洞)。
10
+ // 故email之作用為比對——查出本機實際登入者,與呼叫端指定之email核對,
11
+ // 相符才視為查到「該帳號」之額度。此設計於一機多帳號(實測本機claude/codex/agy
12
+ // 分屬不同gmail)之情境尤其必要,否則呼叫端會把甲帳號的額度當成乙帳號的。
13
+ //
14
+ // 【為何不符時仍回傳額度資料】資料已取得,丟棄只是浪費一次往返;
15
+ // 但ok一律為false且matched為false,令呼叫端不會誤把他人額度當成指定帳號之額度。
16
+ //
17
+ // 【source欄位】各轉接器可能有主路徑與備援(例如codex以app-server為主、HTTP為備援),
18
+ // 同一供應商回來的資料可能來自不同介面,呼叫端排錯時須知道走了哪條,故獨立一欄記錄。
19
+
20
+
21
+ /**
22
+ * 將各家額度查詢結果正規化為統一結構,並判定帳號是否相符
23
+ *
24
+ * 帳號比對採去空白且不分大小寫之比較,因email本地部分雖理論上區分大小寫,
25
+ * 但三家供應商實務上皆以不分大小寫視為同一帳號;
26
+ * 未指定email時不做比對,matched為null,ok僅取決於查詢本身是否成功
27
+ *
28
+ * @param {String} provider 輸入供應商種類字串,例如'claude'、'codex'、'antigravity'
29
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
30
+ * @param {String} [opt.email=''] 輸入本機該CLI實際登入之帳號email字串,預設''代表無從取得
31
+ * @param {String} [opt.emailWant=''] 輸入呼叫端指定欲查詢之帳號email字串,預設''代表不比對
32
+ * @param {String} [opt.plan=''] 輸入方案別字串,例如'max'、'plus',預設''
33
+ * @param {String} [opt.planTier=''] 輸入方案細部級距字串,例如'default_claude_max_20x',預設''
34
+ * @param {String} [opt.source=''] 輸入資料來源介面字串,例如'oauth-usage-api'、'codex-app-server'、'agy-print',預設''
35
+ * @param {Array} [opt.windows=[]] 輸入額度窗口物件陣列,預設[]
36
+ * @param {Object} [opt.credits=null] 輸入額外用量或點數資訊物件,預設null代表該供應商無此概念或未啟用
37
+ * @param {Object} [opt.raw=null] 輸入供應商原始回應物件,預設null
38
+ * @param {String} [opt.error=''] 輸入錯誤訊息字串,預設''代表查詢成功
39
+ * @param {String} [opt.errorType=''] 輸入機器可讀之錯誤類別字串,預設''
40
+ * @param {Number} [opt.durationMs=0] 輸入耗時毫秒,預設0
41
+ * @returns {Object} 回傳結果物件,內含ok(查詢成功且帳號相符布林值)、provider、email(本機實際登入帳號)、matched(帳號是否相符布林值,未指定email時為null)、plan、planTier、source、windows(窗口陣列)、credits、raw、error、errorType、durationMs
42
+ * @example
43
+ *
44
+ * import toQuotaResult from './src/quota/toQuotaResult.mjs'
45
+ *
46
+ * let r = toQuotaResult('claude', { email: 'a@b.com', emailWant: 'c@d.com' })
47
+ * console.log(r.ok, r.matched)
48
+ * // => false false
49
+ *
50
+ */
51
+ function toQuotaResult(provider, opt = {}) {
52
+
53
+ //provider
54
+ if (!isestr(provider)) {
55
+ provider = ''
56
+ }
57
+
58
+ //email, 本機該CLI實際登入之帳號
59
+ let email = get(opt, 'email', '')
60
+ if (!isestr(email)) {
61
+ email = ''
62
+ }
63
+
64
+ //emailWant, 呼叫端指定欲查詢之帳號
65
+ let emailWant = get(opt, 'emailWant', '')
66
+ if (!isestr(emailWant)) {
67
+ emailWant = ''
68
+ }
69
+
70
+ //error
71
+ let error = get(opt, 'error', '')
72
+ if (!isestr(error)) {
73
+ error = ''
74
+ }
75
+
76
+ //errorType
77
+ let errorType = get(opt, 'errorType', '')
78
+ if (!isestr(errorType)) {
79
+ errorType = ''
80
+ }
81
+
82
+ //matched, 未指定emailWant時不比對(null); 指定但無從取得本機帳號時視為不符並補述原因
83
+ let matched = null
84
+ if (emailWant !== '') {
85
+ if (email === '') {
86
+ matched = false
87
+ if (error === '') {
88
+ error = `cannot determine the local ${provider} login account, so it is unknown whether it is the requested account [${emailWant}]`
89
+ errorType = 'account'
90
+ }
91
+ }
92
+ else {
93
+ matched = email.trim().toLowerCase() === emailWant.trim().toLowerCase()
94
+ if (!matched && error === '') {
95
+ error = `local ${provider} login account is [${email}], which does not match the requested account [${emailWant}]`
96
+ errorType = 'account'
97
+ }
98
+ }
99
+ }
100
+
101
+ //windows
102
+ let windows = get(opt, 'windows', null)
103
+ if (!isearr(windows)) {
104
+ windows = []
105
+ }
106
+
107
+ //plan與planTier與source
108
+ let plan = get(opt, 'plan', '')
109
+ if (!isestr(plan)) {
110
+ plan = ''
111
+ }
112
+ let planTier = get(opt, 'planTier', '')
113
+ if (!isestr(planTier)) {
114
+ planTier = ''
115
+ }
116
+ let source = get(opt, 'source', '')
117
+ if (!isestr(source)) {
118
+ source = ''
119
+ }
120
+
121
+ //ok, 須查詢無誤且帳號相符(未指定email時視為相符)
122
+ let ok = error === '' && matched !== false
123
+
124
+ return {
125
+ ok,
126
+ provider,
127
+ email,
128
+ matched,
129
+ plan,
130
+ planTier,
131
+ source,
132
+ windows,
133
+ credits: get(opt, 'credits', null),
134
+ raw: get(opt, 'raw', null),
135
+ error,
136
+ errorType,
137
+ durationMs: get(opt, 'durationMs', 0),
138
+ }
139
+ }
140
+
141
+
142
+ export default toQuotaResult
@@ -0,0 +1,45 @@
1
+ import isestr from 'wsemi/src/isestr.mjs'
2
+ import toQuotaLabel from './toQuotaLabel.mjs'
3
+
4
+
5
+ // toQuotaScopedLabel.mjs — 組出帶適用範圍之額度窗口標籤(單一來源)
6
+ //
7
+ // 【為何獨立成檔】三個額度轉接器都要把「窗口長度」與「範圍」拼成「7天(Fable)」「5小時(Gemini Models)」
8
+ // 這類標籤, 同一段拼接規則曾於fromCodexUsageHttp/getQuotaClaude/getQuotaCodex各手寫一份——
9
+ // 依全域規範「同一規則手寫≥2處即補丁訊號」收斂於此。基底一律取自toQuotaLabel,
10
+ // 窗口長度變動時標籤自動跟著正確, 不得手寫「7天」字串。
11
+
12
+
13
+ /**
14
+ * 組出帶適用範圍之額度窗口標籤
15
+ *
16
+ * 無範圍時回傳空字串,令toQuotaWindow自行由windowSeconds推導預設標籤;
17
+ * 有範圍但窗口秒數無效(供應商未提供)時僅以範圍為標籤
18
+ *
19
+ * @param {Number} windowSeconds 輸入窗口長度秒數
20
+ * @param {String} scope 輸入適用範圍字串,例如模型名或群組名
21
+ * @returns {String} 回傳標籤字串,例如'7天(Fable)';無範圍回傳''
22
+ * @example
23
+ *
24
+ * import toQuotaScopedLabel from './src/quota/toQuotaScopedLabel.mjs'
25
+ *
26
+ * console.log(toQuotaScopedLabel(604800, 'Fable'))
27
+ * // => 7天(Fable)
28
+ *
29
+ * console.log(toQuotaScopedLabel(null, 'codex-spark'))
30
+ * // => codex-spark
31
+ *
32
+ * console.log(toQuotaScopedLabel(18000, ''))
33
+ * // => (空字串)
34
+ *
35
+ */
36
+ function toQuotaScopedLabel(windowSeconds, scope) {
37
+ if (!isestr(scope)) {
38
+ return ''
39
+ }
40
+ let base = toQuotaLabel(windowSeconds)
41
+ return base === '' ? scope : `${base}(${scope})`
42
+ }
43
+
44
+
45
+ export default toQuotaScopedLabel
@@ -0,0 +1,167 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isnum from 'wsemi/src/isnum.mjs'
3
+ import isestr from 'wsemi/src/isestr.mjs'
4
+ import isbol from 'wsemi/src/isbol.mjs'
5
+ import cdbl from 'wsemi/src/cdbl.mjs'
6
+ import toQuotaLabel from './toQuotaLabel.mjs'
7
+
8
+
9
+ // toQuotaWindow.mjs — 將各家額度窗口正規化為統一結構
10
+ //
11
+ // 【為何要正規化】三家回傳形狀互異:Claude給utilization(已用百分比)與ISO字串resets_at,
12
+ // Codex給usedPercent與unix秒數resetsAt外加windowDurationMins,
13
+ // Antigravity給remaining_fraction(剩餘比例)與ISO字串reset_time;
14
+ // 呼叫端若直接吃原始形狀,每加一家就要改一次判斷。統一於此後,
15
+ // 呼叫端只認一組欄位,各家差異收斂在各自的轉接器內。
16
+ //
17
+ // 【為何同時給resetAt與resetAfterSeconds】前者適合顯示絕對時間,後者適合倒數與排程判斷;
18
+ // 兩者能互推,故任一有值即補算另一,令呼叫端不必自行換算。
19
+ //
20
+ // 【label由誰決定】未給label時由windowSeconds經toQuotaLabel推導;轉接器需要帶範圍之標籤
21
+ // (如「7天(Fable)」)時, 亦應以toQuotaLabel取基底再拼接, 不得手寫「7天」字串。
22
+ //
23
+ // 【key是供應商原生識別, 刻意不統一; 跨家比較用windowSeconds+scope】統一的是信封(上列欄位),
24
+ // key則原樣透傳各家自己的窗口識別——Claude為limits[].kind(session/weekly_all/weekly_scoped;
25
+ // 回退舊欄位時為five_hour/seven_day_opus等欄位名)、Codex為rateLimits之primary/secondary物件名、
26
+ // agy為buckets[].id(gemini-5h/gemini-weekly/3p-5h/3p-weekly); 僅巢狀限額需組唯一鍵時
27
+ // 由轉接器以「<識別>:primary|secondary」複合(如code_review:primary)。保留原生值可回溯raw,
28
+ // 也不必為求一致把agy之4桶2群組硬壓成2個。故呼叫端要「跨家找5小時窗口」請以
29
+ // windowSeconds===18000(7天為604800)配scope判斷, 不得比對key字串。
30
+
31
+
32
+ /**
33
+ * 將各家額度窗口正規化為統一結構
34
+ *
35
+ * resetAt可給ISO字串或unix秒數(整數),一律轉為ISO字串;
36
+ * resetAt與resetAfterSeconds任一有值即自動補算另一;
37
+ * usedPercent夾至0~100,remainingPercent由其推得,未知時兩者皆為null(不假造100);
38
+ * label未給時由windowSeconds推導,故供應商調整窗口長度時標籤自動跟著正確
39
+ *
40
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
41
+ * @param {String} [opt.key=''] 輸入窗口之機器可讀鍵字串,為供應商原生識別原樣透傳(Claude之limits[].kind如'session'、Codex之'primary'/'secondary'、agy之buckets[].id如'gemini-5h'),各家不同且刻意不統一;跨家比較請用windowSeconds配scope而非key,預設''
42
+ * @param {String} [opt.label=''] 輸入窗口之人類可讀標籤字串,預設''代表由windowSeconds推導
43
+ * @param {Number} [opt.windowSeconds=null] 輸入窗口長度秒數,預設null代表供應商未提供
44
+ * @param {Number} [opt.usedPercent=null] 輸入已用百分比數值(0~100),預設null代表未知
45
+ * @param {String|Number} [opt.resetAt=''] 輸入窗口重置時刻,可為ISO字串或unix秒數整數,預設''
46
+ * @param {Number} [opt.resetAfterSeconds=null] 輸入距重置之剩餘秒數,預設null代表由resetAt推算
47
+ * @param {String} [opt.scope=''] 輸入窗口適用範圍字串,例如模型名稱或群組名稱,預設''代表全域
48
+ * @param {Boolean} [opt.active=false] 輸入是否為當前生效(最先觸頂)窗口布林值,預設false
49
+ * @param {String} [opt.severity=''] 輸入嚴重度字串,例如'normal'、'warning'、'exhausted',預設''代表由usedPercent推導(用罄為'exhausted'、其餘'normal'、usedPercent未知則'')
50
+ * @returns {Object} 回傳窗口物件,內含key(供應商原生識別,各家不同)、label、windowSeconds(跨家比較之正規化維度:18000=5小時、604800=7天)、usedPercent、remainingPercent、resetAt(ISO字串)、resetAfterSeconds、scope、active、severity
51
+ * @example
52
+ *
53
+ * import toQuotaWindow from './src/quota/toQuotaWindow.mjs'
54
+ *
55
+ * let w = toQuotaWindow({ key: 'five_hour', windowSeconds: 18000, usedPercent: 11 })
56
+ * console.log(w.label, w.remainingPercent)
57
+ * // => 5小時 89
58
+ *
59
+ */
60
+ function toQuotaWindow(opt = {}) {
61
+
62
+ //key
63
+ let key = get(opt, 'key', '')
64
+ if (!isestr(key)) {
65
+ key = ''
66
+ }
67
+
68
+ //windowSeconds, 無效視為供應商未提供
69
+ let windowSeconds = get(opt, 'windowSeconds', null)
70
+ if (!isnum(windowSeconds)) {
71
+ windowSeconds = null
72
+ }
73
+ else {
74
+ windowSeconds = cdbl(windowSeconds)
75
+ }
76
+
77
+ //label, 未給時由windowSeconds推導
78
+ let label = get(opt, 'label', '')
79
+ if (!isestr(label)) {
80
+ label = toQuotaLabel(windowSeconds)
81
+ }
82
+
83
+ //usedPercent, 無效視為未知; 有效則夾至0~100避免供應商回傳越界值污染剩餘量
84
+ let usedPercent = get(opt, 'usedPercent', null)
85
+ if (!isnum(usedPercent)) {
86
+ usedPercent = null
87
+ }
88
+ else {
89
+ usedPercent = Math.min(100, Math.max(0, cdbl(usedPercent)))
90
+ }
91
+
92
+ //remainingPercent, 由usedPercent推得, 未知時同為未知
93
+ let remainingPercent = null
94
+ if (usedPercent !== null) {
95
+ remainingPercent = Math.round((100 - usedPercent) * 100) / 100
96
+ }
97
+
98
+ //resetAt, 接受ISO字串或unix秒數, 一律轉ISO字串
99
+ let resetAt = get(opt, 'resetAt', '')
100
+ if (isnum(resetAt) && cdbl(resetAt) > 0) {
101
+ resetAt = new Date(cdbl(resetAt) * 1000).toISOString()
102
+ }
103
+ else if (isestr(resetAt)) {
104
+ let d = new Date(resetAt)
105
+ resetAt = isNaN(d.getTime()) ? '' : d.toISOString()
106
+ }
107
+ else {
108
+ resetAt = ''
109
+ }
110
+
111
+ //resetAfterSeconds, 無效時由resetAt推算, 令呼叫端不論供應商給哪一種都拿得到倒數
112
+ let resetAfterSeconds = get(opt, 'resetAfterSeconds', null)
113
+ if (!isnum(resetAfterSeconds)) {
114
+ resetAfterSeconds = null
115
+ if (resetAt !== '') {
116
+ let dt = Math.round((new Date(resetAt).getTime() - Date.now()) / 1000)
117
+ resetAfterSeconds = Math.max(0, dt)
118
+ }
119
+ }
120
+ else {
121
+ resetAfterSeconds = Math.max(0, Math.round(cdbl(resetAfterSeconds)))
122
+ }
123
+
124
+ //resetAt, 供應商只給倒數秒數時反推絕對時刻
125
+ if (resetAt === '' && resetAfterSeconds !== null) {
126
+ resetAt = new Date(Date.now() + resetAfterSeconds * 1000).toISOString()
127
+ }
128
+
129
+ //scope, 例如僅適用某模型或某群組之窗口
130
+ let scope = get(opt, 'scope', '')
131
+ if (!isestr(scope)) {
132
+ scope = ''
133
+ }
134
+
135
+ //active
136
+ let active = get(opt, 'active', null)
137
+ if (!isbol(active)) {
138
+ active = false
139
+ }
140
+
141
+ //severity, 供應商有給(如Claude之limits[].severity)即用之; 未給則由usedPercent推導——
142
+ //用罄為'exhausted'、其餘'normal'、usedPercent未知則''。三家對稱: 曾因codex路徑未給而為空字串,
143
+ //與claude/agy之'normal'不對稱, 呼叫端得分家判斷; 收斂於此後任一家未給皆有一致預設
144
+ let severity = get(opt, 'severity', '')
145
+ if (!isestr(severity)) {
146
+ severity = ''
147
+ if (usedPercent !== null) {
148
+ severity = usedPercent >= 100 ? 'exhausted' : 'normal'
149
+ }
150
+ }
151
+
152
+ return {
153
+ key,
154
+ label,
155
+ windowSeconds,
156
+ usedPercent,
157
+ remainingPercent,
158
+ resetAt,
159
+ resetAfterSeconds,
160
+ scope,
161
+ active,
162
+ severity,
163
+ }
164
+ }
165
+
166
+
167
+ export default toQuotaWindow
@@ -81,9 +81,10 @@ process.stdin.on('end', () => {
81
81
  * 產生一支測試用的假CLI,回傳其執行檔路徑與清除函數
82
82
  *
83
83
  * @param {String} name 輸入假CLI名稱字串,各測試檔須給予不同名稱,避免mocha並行執行時互相干擾
84
+ * @param {String} [entryCode=CODE_ENTRY] 輸入假CLI之node腳本原始碼字串,預設為回聲args/stdin/env之腳本;需模擬特定協定(如codex app-server之JSON-RPC、agy之--version與-p /usage)時自訂
84
85
  * @returns {Object} 回傳物件,內含exe(假CLI執行檔絕對路徑字串)、fd(產物資料夾絕對路徑字串)、clean(清除產物之函數)
85
86
  */
86
- function createFakeCli(name) {
87
+ function createFakeCli(name, entryCode = CODE_ENTRY) {
87
88
 
88
89
  //fd, 印出absolute路徑供除錯驗收
89
90
  let fd = path.resolve(FD_BASE, name)
@@ -92,9 +93,9 @@ function createFakeCli(name) {
92
93
  let fdEntry = path.join(fd, 'node_modules', name)
93
94
  fs.mkdirSync(fdEntry, { recursive: true })
94
95
 
95
- //entry
96
+ //entry, 預設回聲腳本或呼叫端自訂之協定模擬腳本
96
97
  let fpEntry = path.join(fdEntry, 'entry.mjs')
97
- fs.writeFileSync(fpEntry, CODE_ENTRY, 'utf8')
98
+ fs.writeFileSync(fpEntry, typeof entryCode === 'string' && entryCode !== '' ? entryCode : CODE_ENTRY, 'utf8')
98
99
 
99
100
  //exe
100
101
  let exe = ''
@@ -0,0 +1,171 @@
1
+ import http from 'http'
2
+
3
+
4
+ // fakeServerForQuotaTest.mjs — 額度查詢用之假HTTP伺服器
5
+ //
6
+ // 三個額度轉接器之REST路徑分別打Anthropic之/api/oauth/usage(+/profile)與chatgpt.com之
7
+ // /backend-api/wham/usage; 其可觀察行為為「送了什麼標頭、如何處理各種狀態碼與回應形狀」,
8
+ // 故起本機伺服器依路徑與Authorization權杖決定回應, 令getQuotaClaude/getQuotaCodex/fetchQuotaJson
9
+ // 可離線逐條斷言, 不依賴真帳號與網路。回應fixture形狀取自2026-09-05本機實測之真實回應。
10
+ //
11
+ // 【行為路由(依Bearer權杖)】對所有路徑一致:
12
+ // tok-ok — 200, 依路徑回對應fixture
13
+ // tok-401 — 401 {error:{type:'authentication_error'}}
14
+ // tok-403-org — 403, 本文含oauth_not_allowed_for_organization
15
+ // tok-429 — 429
16
+ // tok-500 — 500
17
+ // tok-notjson — 200但本體非JSON
18
+ // tok-slow — 延遲10秒(逾時路徑用)
19
+ // tok-big — 200但本體約2MB(toolarge路徑用)
20
+ // tok-echo — 200, 回{ headers }(斷言送出之標頭用)
21
+ // tok-legacy — 200, claude usage僅有頂層舊欄位(無limits[], 回退路徑用)
22
+ // 其他/無權杖 — 401
23
+
24
+
25
+ //claude之/api/oauth/usage回應fixture(新結構limits[]+頂層舊欄位並存, 實測形狀)
26
+ let FIXTURE_CLAUDE_USAGE = {
27
+ five_hour: { utilization: 3, resets_at: '2099-01-01T05:00:00.000Z' },
28
+ seven_day: { utilization: 13, resets_at: '2099-01-03T00:00:00.000Z' },
29
+ seven_day_opus: { utilization: 0, resets_at: '2099-01-03T00:00:00.000Z' },
30
+ limits: [
31
+ { kind: 'session', group: 'session', percent: 3, severity: 'normal', resets_at: '2099-01-01T05:00:00.000Z', scope: null, is_active: false },
32
+ { kind: 'weekly_all', group: 'weekly', percent: 13, severity: 'normal', resets_at: '2099-01-03T00:00:00.000Z', scope: null, is_active: false },
33
+ { kind: 'weekly_scoped', group: 'weekly', percent: 15, severity: 'normal', resets_at: '2099-01-03T00:00:00.000Z', scope: { model: { display_name: 'Fable' }, surface: null }, is_active: true },
34
+ ],
35
+ extra_usage: { is_enabled: false, monthly_limit: null, used_credits: null, utilization: null },
36
+ spend: { used: { amount_minor: 0, currency: 'USD' }, percent: 0 },
37
+ }
38
+
39
+
40
+ //claude之/api/oauth/usage僅頂層舊欄位之fixture(回退路徑用)
41
+ let FIXTURE_CLAUDE_USAGE_LEGACY = {
42
+ five_hour: { utilization: 42, resets_at: '2099-01-01T05:00:00.000Z' },
43
+ seven_day: { utilization: 7, resets_at: '2099-01-03T00:00:00.000Z' },
44
+ seven_day_sonnet: { utilization: 1, resets_at: '2099-01-03T00:00:00.000Z' },
45
+ }
46
+
47
+
48
+ //claude之/api/oauth/profile回應fixture
49
+ let FIXTURE_CLAUDE_PROFILE = {
50
+ account: { email: 'profile-user@example.com', full_name: 'Profile User' },
51
+ }
52
+
53
+
54
+ //codex之/backend-api/wham/usage回應fixture(實測形狀, additional_rate_limits取自同類工具fixture)
55
+ let FIXTURE_CODEX_USAGE = {
56
+ email: 'codex-user@example.com',
57
+ plan_type: 'plus',
58
+ rate_limit: {
59
+ primary_window: { used_percent: 0, limit_window_seconds: 18000, reset_after_seconds: 17999, reset_at: 4102444800 },
60
+ secondary_window: { used_percent: 31, limit_window_seconds: 604800, reset_after_seconds: 501107, reset_at: 4102444800 },
61
+ },
62
+ code_review_rate_limit: {
63
+ primary_window: { used_percent: 5, limit_window_seconds: 18000, reset_after_seconds: 1000 },
64
+ },
65
+ additional_rate_limits: [
66
+ { limit_name: 'codex-spark', display_name: 'GPT-5.3-Codex-Spark', primary_window: { used_percent: 12, limit_window_seconds: 18000, reset_after_seconds: 500 } },
67
+ ],
68
+ credits: { has_credits: false, unlimited: false, balance: '0' },
69
+ rate_limit_reset_credits: { available_count: 1 },
70
+ spend_control: { reached: false, individual_limit: null },
71
+ rate_limit_reached_type: null,
72
+ }
73
+
74
+
75
+ /**
76
+ * 啟動額度查詢用之假HTTP伺服器
77
+ *
78
+ * @returns {Promise} 回傳Promise,resolve回傳物件,內含port(埠號)、url(基底網址字串,如http://127.0.0.1:PORT)、usageUrlClaude、profileUrlClaude、usageUrlCodex(三個端點完整網址)、close(關閉伺服器之async函數)
79
+ */
80
+ async function fakeServerForQuotaTest() {
81
+
82
+ //sockets, 追蹤連線供close時強制斷開(避免keep-alive令close懸置)
83
+ let sockets = new Set()
84
+
85
+ let server = http.createServer((req, res) => {
86
+
87
+ let auth = req.headers['authorization'] || ''
88
+ let tok = auth.replace(/^Bearer\s+/i, '')
89
+ let send = (code, obj) => {
90
+ res.writeHead(code, { 'Content-Type': 'application/json' })
91
+ res.end(JSON.stringify(obj))
92
+ }
93
+
94
+ //權杖行為
95
+ if (tok === 'tok-401' || tok === '') {
96
+ return send(401, { type: 'error', error: { type: 'authentication_error', message: 'OAuth token has expired.' } })
97
+ }
98
+ if (tok === 'tok-403-org') {
99
+ return send(403, { type: 'error', error: { type: 'permission_error', message: 'oauth_not_allowed_for_organization' } })
100
+ }
101
+ if (tok === 'tok-429') {
102
+ return send(429, { type: 'error', error: { type: 'rate_limit_error', message: 'Too many requests' } })
103
+ }
104
+ if (tok === 'tok-500') {
105
+ return send(500, { type: 'error', error: { type: 'api_error', message: 'internal' } })
106
+ }
107
+ if (tok === 'tok-notjson') {
108
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
109
+ return res.end('<html>not json</html>')
110
+ }
111
+ if (tok === 'tok-slow') {
112
+ return setTimeout(() => send(200, FIXTURE_CLAUDE_USAGE), 10000)
113
+ }
114
+ if (tok === 'tok-big') {
115
+ res.writeHead(200, { 'Content-Type': 'application/json' })
116
+ return res.end(JSON.stringify({ pad: 'x'.repeat(2 * 1024 * 1024) }))
117
+ }
118
+ if (tok === 'tok-echo') {
119
+ return send(200, { headers: req.headers })
120
+ }
121
+ if (tok !== 'tok-ok' && tok !== 'tok-legacy') {
122
+ return send(401, { type: 'error', error: { type: 'authentication_error', message: 'Invalid token' } })
123
+ }
124
+
125
+ //路徑fixture
126
+ if (req.url.endsWith('/api/oauth/usage')) {
127
+ return send(200, tok === 'tok-legacy' ? FIXTURE_CLAUDE_USAGE_LEGACY : FIXTURE_CLAUDE_USAGE)
128
+ }
129
+ if (req.url.endsWith('/api/oauth/profile')) {
130
+ return send(200, FIXTURE_CLAUDE_PROFILE)
131
+ }
132
+ if (req.url.endsWith('/backend-api/wham/usage')) {
133
+ return send(200, FIXTURE_CODEX_USAGE)
134
+ }
135
+ return send(404, { error: { message: 'not found' } })
136
+ })
137
+
138
+ server.on('connection', (s) => {
139
+ sockets.add(s)
140
+ s.on('close', () => sockets.delete(s))
141
+ })
142
+
143
+ //listen於127.0.0.1動態埠
144
+ await new Promise((resolve) => {
145
+ server.listen(0, '127.0.0.1', resolve)
146
+ })
147
+ let port = server.address().port
148
+ let url = `http://127.0.0.1:${port}`
149
+
150
+ let close = async () => {
151
+ for (let s of sockets) {
152
+ s.destroy()
153
+ }
154
+ await new Promise((resolve) => {
155
+ server.close(resolve)
156
+ })
157
+ }
158
+
159
+ return {
160
+ port,
161
+ url,
162
+ usageUrlClaude: `${url}/api/oauth/usage`,
163
+ profileUrlClaude: `${url}/api/oauth/profile`,
164
+ usageUrlCodex: `${url}/backend-api/wham/usage`,
165
+ close,
166
+ }
167
+ }
168
+
169
+
170
+ export default fakeServerForQuotaTest
171
+ export { FIXTURE_CLAUDE_USAGE, FIXTURE_CLAUDE_USAGE_LEGACY, FIXTURE_CLAUDE_PROFILE, FIXTURE_CODEX_USAGE }
@@ -13,6 +13,9 @@ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
13
13
  import dispatchApiOpenaiResponses from '../src/dispatchApiOpenaiResponses.mjs'
14
14
  import providers from '../src/providers.mjs'
15
15
  import resolveProviders from '../src/resolveProviders.mjs'
16
+ import getQuotaClaude from '../src/quota/getQuotaClaude.mjs'
17
+ import getQuotaCodex from '../src/quota/getQuotaCodex.mjs'
18
+ import getQuotaAntigravity from '../src/quota/getQuotaAntigravity.mjs'
16
19
 
17
20
 
18
21
  describe('WDispatchAi', function() {
@@ -38,6 +41,9 @@ describe('WDispatchAi', function() {
38
41
  'createFileStore',
39
42
  'createUsageCounter',
40
43
  'salvageTruncatedArray',
44
+ 'getQuotaClaude',
45
+ 'getQuotaCodex',
46
+ 'getQuotaAntigravity',
41
47
  ]
42
48
  assert.strict.deepEqual(r, rr)
43
49
  })
@@ -50,7 +56,7 @@ describe('WDispatchAi', function() {
50
56
 
51
57
  it('各鍵值型別正確(KINDS與providers為物件, NO_SIDE_EFFECT為字串, 其餘為函數)', function() {
52
58
  let r = map(keys(wi), (k) => typeof wi[k])
53
- let rr = ['object', 'string', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'object', 'function', 'function', 'function', 'function', 'function', 'function']
59
+ let rr = ['object', 'string', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function']
54
60
  assert.strict.deepEqual(r, rr)
55
61
  })
56
62
 
@@ -67,8 +73,11 @@ describe('WDispatchAi', function() {
67
73
  wi.dispatchApiOpenaiResponses === dispatchApiOpenaiResponses,
68
74
  wi.providers === providers,
69
75
  wi.resolveProviders === resolveProviders,
76
+ wi.getQuotaClaude === getQuotaClaude,
77
+ wi.getQuotaCodex === getQuotaCodex,
78
+ wi.getQuotaAntigravity === getQuotaAntigravity,
70
79
  ]
71
- let rr = [true, true, true, true, true, true, true, true, true, true, true]
80
+ let rr = [true, true, true, true, true, true, true, true, true, true, true, true, true, true]
72
81
  assert.strict.deepEqual(r, rr)
73
82
  })
74
83
 
@@ -0,0 +1,70 @@
1
+ import assert from 'assert'
2
+ import fetchQuotaJson from '../src/quota/fetchQuotaJson.mjs'
3
+ import fakeServerForQuotaTest from './tools/fakeServerForQuotaTest.mjs'
4
+
5
+
6
+ describe('fetchQuotaJson', function() {
7
+
8
+ let svr = null
9
+
10
+ before(async function() {
11
+ svr = await fakeServerForQuotaTest()
12
+ })
13
+
14
+ after(async function() {
15
+ if (svr) {
16
+ await svr.close()
17
+ }
18
+ })
19
+
20
+ let call = (tok, o = {}) => fetchQuotaJson(svr.usageUrlClaude, { headers: { Authorization: `Bearer ${tok}` }, ...o })
21
+
22
+ it('url無效回params, 不reject', async function() {
23
+ let r = await fetchQuotaJson('')
24
+ let rr = [r.ok, r.status, r.data, r.errorType]
25
+ assert.strict.deepEqual(rr, [false, 0, null, 'params'])
26
+ })
27
+
28
+ it('200且JSON合法時回data; 物件body自動序列化並補Content-Type', async function() {
29
+ let r1 = await call('tok-ok')
30
+ let r2 = await fetchQuotaJson(svr.usageUrlClaude, { method: 'POST', headers: { Authorization: 'Bearer tok-echo' }, body: { a: 1 } })
31
+ let rr = [r1.ok, r1.status, typeof r1.data.limits, r2.ok, r2.data.headers['content-type'], r2.data.headers['content-length']]
32
+ assert.strict.deepEqual(rr, [true, 200, 'object', true, 'application/json', '7'])
33
+ })
34
+
35
+ it('狀態碼分類: 401→auth, 403→forbidden, 429→ratelimit, 500→http; 錯誤訊息含本文片段', async function() {
36
+ let r1 = await call('tok-401')
37
+ let r2 = await call('tok-403-org')
38
+ let r3 = await call('tok-429')
39
+ let r4 = await call('tok-500')
40
+ let rr = [
41
+ [r1.ok, r1.status, r1.errorType, r1.error.indexOf('unauthorized(401)') === 0],
42
+ [r2.ok, r2.status, r2.errorType, /oauth_not_allowed_for_organization/.test(r2.error)],
43
+ [r3.ok, r3.status, r3.errorType],
44
+ [r4.ok, r4.status, r4.errorType],
45
+ ]
46
+ assert.strict.deepEqual(rr, [[false, 401, 'auth', true], [false, 403, 'forbidden', true], [false, 429, 'ratelimit'], [false, 500, 'http']])
47
+ })
48
+
49
+ it('200但非JSON→parse; 本文超過maxBodyBytes→toolarge; 逾時→timeout; 連線拒絕→network', async function() {
50
+ let r1 = await call('tok-notjson')
51
+ let r2 = await call('tok-big', { maxBodyBytes: 1024 })
52
+ let r3 = await call('tok-slow', { timeoutMs: 300 })
53
+ let r4 = await fetchQuotaJson('http://127.0.0.1:9/nothing', { timeoutMs: 3000 })
54
+ let rr = [
55
+ [r1.ok, r1.status, r1.errorType],
56
+ [r2.ok, r2.errorType, /too large/.test(r2.error)],
57
+ [r3.ok, r3.status, r3.errorType],
58
+ [r4.ok, r4.status, r4.errorType],
59
+ ]
60
+ assert.strict.deepEqual(rr, [[false, 200, 'parse'], [false, 'toolarge', true], [false, 0, 'timeout'], [false, 0, 'network']])
61
+ })
62
+
63
+ it('redact: 錯誤訊息中之機密值被遮蔽(長度不足6者不處理, 避免誤遮常見短字串)', async function() {
64
+ //tok-echo會把標頭回顯, 模擬對端把權杖夾進錯誤頁的情境: 以403路徑令本文含權杖字樣
65
+ let r = await fetchQuotaJson(svr.usageUrlClaude, { headers: { 'Authorization': 'Bearer tok-403-org', 'X-Secret': 'my-secret-value' }, redact: ['oauth_not_allowed_for_organization', 'org'] })
66
+ let rr = [r.errorType, /\[REDACTED\]/.test(r.error), /oauth_not_allowed_for_organization/.test(r.error), /forbidden\(403\)/.test(r.error)]
67
+ assert.strict.deepEqual(rr, ['forbidden', true, false, true])
68
+ })
69
+
70
+ })