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,76 @@
1
+ import assert from 'assert'
2
+ import fromCodexUsageHttp from '../src/quota/fromCodexUsageHttp.mjs'
3
+ import { FIXTURE_CODEX_USAGE } from './tools/fakeServerForQuotaTest.mjs'
4
+
5
+
6
+ describe('fromCodexUsageHttp', function() {
7
+
8
+ it('對映主窗口、code review限額與additional_rate_limits(陣列形態), 標籤帶範圍', function() {
9
+ let m = fromCodexUsageHttp(FIXTURE_CODEX_USAGE)
10
+ let r = [
11
+ m.email,
12
+ m.plan,
13
+ m.windows.map((w) => `${w.key}=${w.label}|${w.usedPercent}|${w.scope}`),
14
+ m.credits.resetCreditsAvailable,
15
+ m.credits.hasCredits,
16
+ ]
17
+ let rr = [
18
+ 'codex-user@example.com',
19
+ 'plus',
20
+ [
21
+ 'primary=5小時|0|',
22
+ 'secondary=7天|31|',
23
+ 'code_review:primary=5小時(code review)|5|code review',
24
+ 'codex-spark:primary=5小時(GPT-5.3-Codex-Spark)|12|GPT-5.3-Codex-Spark',
25
+ ],
26
+ 1,
27
+ false,
28
+ ]
29
+ assert.strict.deepEqual(r, rr)
30
+ })
31
+
32
+ it('additional_rate_limits為物件形態(名稱為鍵)時亦可解析, 缺識別欄位者以鍵名為id', function() {
33
+ let m = fromCodexUsageHttp({
34
+ additional_rate_limits: {
35
+ 'gpt-reserve': { primary_window: { used_percent: 9, limit_window_seconds: 604800 } },
36
+ },
37
+ })
38
+ let r = m.windows.map((w) => `${w.key}=${w.label}`)
39
+ let rr = ['gpt-reserve:primary=7天(gpt-reserve)']
40
+ assert.strict.deepEqual(r, rr)
41
+ })
42
+
43
+ it('單筆本身即窗口時: 名稱含weekly/reserve或長度≥6天判為secondary, 否則primary', function() {
44
+ let m = fromCodexUsageHttp({
45
+ additional_rate_limits: [
46
+ { name: 'gpt-reserve', used_percent: 1, limit_window_seconds: 18000 },
47
+ { name: 'spark', used_percent: 2, limit_window_seconds: 18000 },
48
+ { name: 'longwin', used_percent: 3, limit_window_seconds: 6 * 86400 },
49
+ ],
50
+ })
51
+ let r = m.windows.map((w) => w.key)
52
+ let rr = ['gpt-reserve:secondary', 'spark:primary', 'longwin:secondary']
53
+ assert.strict.deepEqual(r, rr)
54
+ })
55
+
56
+ it('窗口可置於rate_limit/rateLimit之下(snake或camel), 缺漏欄位之窗口略過不拋錯', function() {
57
+ let m = fromCodexUsageHttp({
58
+ additional_rate_limits: [
59
+ { limit_name: 'a', rateLimit: { primaryWindow: { used_percent: 4, limit_window_seconds: 18000 } } },
60
+ { limit_name: 'b' }, //無任何窗口 → 不列入
61
+ 'garbage', //非物件 → 略過
62
+ ],
63
+ })
64
+ let r = m.windows.map((w) => w.key)
65
+ let rr = ['a:primary']
66
+ assert.strict.deepEqual(r, rr)
67
+ })
68
+
69
+ it('空物件或缺欄位時回傳空值結構不拋錯', function() {
70
+ let m = fromCodexUsageHttp({})
71
+ let r = [m.email, m.plan, m.windows, m.credits.hasCredits, m.credits.resetCreditsAvailable]
72
+ let rr = ['', '', [], false, null]
73
+ assert.strict.deepEqual(r, rr)
74
+ })
75
+
76
+ })
@@ -0,0 +1,123 @@
1
+ import assert from 'assert'
2
+ import getQuotaAntigravity from '../src/quota/getQuotaAntigravity.mjs'
3
+ import createFakeCli from './tools/fakeCliForTest.mjs'
4
+
5
+
6
+ //假agy: 依MODE決定--version與-p /usage之行為; 帶--log-file時寫入含applyAuthResult之log
7
+ //(模擬2026-09-05實測agy 1.1.27之print模式JSON形狀)
8
+ let codeAgy = (mode) => `
9
+ import fs from 'fs'
10
+ let args = process.argv.slice(2)
11
+ let mode = '${mode}'
12
+ let say = (o) => process.stdout.write((typeof o === 'string' ? o : JSON.stringify(o)) + '\\n')
13
+ if (args.includes('--version')) {
14
+ say(mode === 'old' ? '1.1.10' : '1.1.27')
15
+ process.exit(0)
16
+ }
17
+ let iLog = args.indexOf('--log-file')
18
+ if (iLog >= 0) {
19
+ fs.writeFileSync(args[iLog + 1], '[info] boot\\n[info] applyAuthResult: email=agy-user@example.com, authMethod=consumer\\n', 'utf8')
20
+ }
21
+ let cmd = args[args.indexOf('-p') + 1]
22
+ if (mode === 'prompt') {
23
+ say({ status: 'SUCCESS', num_turns: 1, response: 'I treated it as a prompt' })
24
+ process.exit(0)
25
+ }
26
+ if (mode === 'fail') {
27
+ say({ status: 'ERROR', num_turns: 0, response: 'backend down', command: { name: 'usage' } })
28
+ process.exit(0)
29
+ }
30
+ if (cmd === '/usage') {
31
+ say('noise line before json')
32
+ say({ status: 'SUCCESS', num_turns: 0, usage: {}, command: { name: 'usage', data: { description: 'd', groups: [
33
+ { name: 'Gemini Models', buckets: [
34
+ { id: 'gemini-weekly', window: 'weekly', remaining_fraction: 0.7852, reset_time: '2099-01-03T00:00:00Z' },
35
+ { id: 'gemini-5h', window: '5h', remaining_fraction: '0.9829', reset_time: '2099-01-01T05:00:00Z' },
36
+ ] },
37
+ { name: 'Claude and GPT models', buckets: [
38
+ { id: '3p-5h', window: '5h', remaining_fraction: 0, reset_time: '2099-01-01T05:00:00Z' },
39
+ { id: '3p-x', window: '3d', remaining_fraction: 1, disabled: true },
40
+ { window: '5h', remaining_fraction: 1 },
41
+ ] },
42
+ ] } } })
43
+ process.exit(0)
44
+ }
45
+ if (cmd === '/credits') {
46
+ say({ status: 'SUCCESS', num_turns: 0, command: { name: 'credits', data: { remaining_credits: 123, upgrade_uri: 'https://x' } } })
47
+ process.exit(0)
48
+ }
49
+ say({})
50
+ process.exit(0)
51
+ `
52
+
53
+
54
+ describe('getQuotaAntigravity', function() {
55
+
56
+ let fakes = []
57
+ let mkFake = (mode) => {
58
+ let f = createFakeCli(`fake-agy-${mode}`, codeAgy(mode))
59
+ fakes.push(f)
60
+ return f.exe
61
+ }
62
+
63
+ after(function() {
64
+ for (let f of fakes) {
65
+ f.clean()
66
+ }
67
+ })
68
+
69
+ it('成功: 版本把關通過, 自log取得email, 各群組桶正規化(剩餘比例反推已用, 字串型比例亦可), 停用/用罄之severity明示, 無id之桶略過', async function() {
70
+ let r = await getQuotaAntigravity('', { exe: mkFake('ok'), timeoutMs: 20000 })
71
+ let rr = [
72
+ r.ok, r.matched, r.provider, r.email, r.plan, r.source, r.raw.version, r.raw.authMethod,
73
+ r.windows.map((w) => `${w.key}|${w.label}|${w.usedPercent}|${w.scope}|${w.severity}`),
74
+ r.credits,
75
+ ]
76
+ assert.strict.deepEqual(rr, [
77
+ true, null, 'antigravity', 'agy-user@example.com', '', 'agy-print-usage', '1.1.27', 'consumer',
78
+ [
79
+ 'gemini-weekly|7天(Gemini Models)|21.48|Gemini Models|normal',
80
+ 'gemini-5h|5小時(Gemini Models)|1.71|Gemini Models|normal',
81
+ '3p-5h|5小時(Claude and GPT models)|100|Claude and GPT models|exhausted',
82
+ '3p-x|3天(Claude and GPT models)|0|Claude and GPT models|disabled',
83
+ ],
84
+ null,
85
+ ])
86
+ })
87
+
88
+ it('帳號比對相符與不符', async function() {
89
+ let exe = mkFake('ok2')
90
+ let r1 = await getQuotaAntigravity('AGY-User@example.com', { exe, timeoutMs: 20000 })
91
+ let r2 = await getQuotaAntigravity('other@example.com', { exe, timeoutMs: 20000 })
92
+ let rr = [[r1.ok, r1.matched], [r2.ok, r2.matched, r2.errorType, r2.windows.length]]
93
+ assert.strict.deepEqual(rr, [[true, true], [false, false, 'account', 4]])
94
+ })
95
+
96
+ it('withCredits時另執行/credits取得點數', async function() {
97
+ let r = await getQuotaAntigravity('', { exe: mkFake('ok3'), withCredits: true, timeoutMs: 20000 })
98
+ let rr = [r.ok, r.credits, r.raw.credits !== null]
99
+ assert.strict.deepEqual(rr, [true, { remainingCredits: 123, upgradeUri: 'https://x' }, true])
100
+ })
101
+
102
+ it('版本低於門檻→unsupported且不執行/usage(舊版會把它當prompt跑而耗額度); checkVersion關閉則照跑', async function() {
103
+ let exe = mkFake('old')
104
+ let r1 = await getQuotaAntigravity('', { exe, timeoutMs: 20000 })
105
+ let r2 = await getQuotaAntigravity('', { exe, checkVersion: false, timeoutMs: 20000 })
106
+ let rr = [[r1.ok, r1.errorType, /1\.1\.10.*below 1\.1\.11/.test(r1.error), r1.raw.version], [r2.ok, r2.windows.length, r2.raw.version]]
107
+ assert.strict.deepEqual(rr, [[false, 'unsupported', true, '1.1.10'], [true, 4, '']])
108
+ })
109
+
110
+ it('agy把指令當prompt執行(num_turns>0)→unsupported防呆; status非SUCCESS→exit', async function() {
111
+ let r1 = await getQuotaAntigravity('', { exe: mkFake('prompt'), timeoutMs: 20000 })
112
+ let r2 = await getQuotaAntigravity('', { exe: mkFake('fail'), timeoutMs: 20000 })
113
+ let rr = [[r1.ok, r1.errorType, /num_turns=1/.test(r1.error)], [r2.ok, r2.errorType, /status=ERROR/.test(r2.error)]]
114
+ assert.strict.deepEqual(rr, [[false, 'unsupported', true], [false, 'exit', true]])
115
+ })
116
+
117
+ it('exe不存在→notfound且附安裝指引', async function() {
118
+ let r = await getQuotaAntigravity('', { exe: 'agy-no-such-exe-xyz', timeoutMs: 20000 })
119
+ let rr = [r.ok, r.errorType, /antigravity\.google\/docs\/cli/.test(r.error)]
120
+ assert.strict.deepEqual(rr, [false, 'notfound', true])
121
+ })
122
+
123
+ })
@@ -0,0 +1,137 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import assert from 'assert'
5
+ import getQuotaClaude from '../src/quota/getQuotaClaude.mjs'
6
+ import fakeServerForQuotaTest from './tools/fakeServerForQuotaTest.mjs'
7
+
8
+
9
+ //產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
10
+ let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'quota-claude')
11
+
12
+
13
+ //建立一組假Claude設定目錄(憑證檔+帳號檔), 回傳目錄路徑
14
+ let mkConfigDir = (name, cred, conf) => {
15
+ let fd = path.join(FD, name)
16
+ fs.mkdirSync(fd, { recursive: true })
17
+ if (cred !== null) {
18
+ fs.writeFileSync(path.join(fd, '.credentials.json'), JSON.stringify(cred), 'utf8')
19
+ }
20
+ if (conf !== null) {
21
+ fs.writeFileSync(path.join(fd, '.claude.json'), JSON.stringify(conf), 'utf8')
22
+ }
23
+ return fd
24
+ }
25
+
26
+
27
+ describe('getQuotaClaude', function() {
28
+
29
+ let svr = null
30
+ let base = null
31
+
32
+ before(async function() {
33
+ svr = await fakeServerForQuotaTest()
34
+ fs.mkdirSync(FD, { recursive: true })
35
+ //env注入空物件: 隔離本機之CLAUDE_CONFIG_DIR/CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_API_KEY
36
+ base = { env: {}, usageUrl: svr.usageUrlClaude, profileUrl: svr.profileUrlClaude, timeoutMs: 5000 }
37
+ })
38
+
39
+ after(async function() {
40
+ if (svr) {
41
+ await svr.close()
42
+ }
43
+ fs.rmSync(FD, { recursive: true, force: true })
44
+ try {
45
+ fs.rmdirSync(path.dirname(FD))
46
+ }
47
+ catch (e) {}
48
+ })
49
+
50
+ it('成功: 優先取limits[](含模型別週限額), plan/planTier取自憑證檔, email取自帳號檔', async function() {
51
+ let configDir = mkConfigDir('ok',
52
+ { claudeAiOauth: { accessToken: 'tok-ok', subscriptionType: 'max', rateLimitTier: 'default_claude_max_20x', expiresAt: 4102444800000 } },
53
+ { oauthAccount: { emailAddress: 'claude-user@example.com' } },
54
+ )
55
+ let r = await getQuotaClaude('', { ...base, configDir })
56
+ let rr = [
57
+ r.ok, r.matched, r.provider, r.email, r.plan, r.planTier, r.source,
58
+ r.windows.map((w) => `${w.key}|${w.label}|${w.usedPercent}|${w.active}|${w.severity}`),
59
+ r.raw.configDir === configDir,
60
+ r.credits.spendCurrency,
61
+ ]
62
+ assert.strict.deepEqual(rr, [
63
+ true, null, 'claude', 'claude-user@example.com', 'max', 'default_claude_max_20x', 'anthropic-oauth-usage-api',
64
+ ['session|5小時|3|false|normal', 'weekly_all|7天|13|false|normal', 'weekly_scoped|7天(Fable)|15|true|normal'],
65
+ true,
66
+ 'USD',
67
+ ])
68
+ })
69
+
70
+ it('帳號比對: 相符ok為true; 不符ok為false且matched為false但額度仍回傳', async function() {
71
+ let configDir = mkConfigDir('match',
72
+ { claudeAiOauth: { accessToken: 'tok-ok' } },
73
+ { oauthAccount: { emailAddress: 'Claude-User@Example.com' } },
74
+ )
75
+ let r1 = await getQuotaClaude('claude-user@example.com', { ...base, configDir })
76
+ let r2 = await getQuotaClaude('other@example.com', { ...base, configDir })
77
+ let rr = [[r1.ok, r1.matched], [r2.ok, r2.matched, r2.errorType, r2.windows.length]]
78
+ assert.strict.deepEqual(rr, [[true, true], [false, false, 'account', 3]])
79
+ })
80
+
81
+ it('無limits[]時回退頂層舊欄位(含模型別), 標籤由秒數推導', async function() {
82
+ let configDir = mkConfigDir('legacy', { claudeAiOauth: { accessToken: 'tok-legacy' } }, { oauthAccount: { emailAddress: 'a@b.c' } })
83
+ let r = await getQuotaClaude('', { ...base, configDir })
84
+ let rr = r.windows.map((w) => `${w.key}|${w.label}|${w.usedPercent}|${w.scope}`)
85
+ assert.strict.deepEqual(rr, ['five_hour|5小時|42|', 'seven_day|7天|7|', 'seven_day_sonnet|7天(Sonnet)|1|Sonnet'])
86
+ })
87
+
88
+ it('憑證不存在→notfound(訊息含檔案路徑與指引); 非訂閱模式環境變數→unsupported', async function() {
89
+ let configDir = mkConfigDir('nocred', null, null)
90
+ let r1 = await getQuotaClaude('', { ...base, configDir })
91
+ let r2 = await getQuotaClaude('', { ...base, configDir, env: { ANTHROPIC_API_KEY: 'sk-ant-xxx' } })
92
+ let rr = [
93
+ [r1.ok, r1.errorType, r1.error.includes('.credentials.json'), r1.error.includes('claude auth login')],
94
+ [r2.ok, r2.errorType, r2.error.includes('ANTHROPIC_API_KEY')],
95
+ ]
96
+ assert.strict.deepEqual(rr, [[false, 'notfound', true, true], [false, 'unsupported', true]])
97
+ })
98
+
99
+ it('權杖被拒(401)→auth, 訊息附本機到期時刻與「執行一次claude自行刷新、不需重新登入」指引', async function() {
100
+ let configDir = mkConfigDir('expired', { claudeAiOauth: { accessToken: 'tok-401', expiresAt: 946684800000 } }, { oauthAccount: { emailAddress: 'a@b.c' } })
101
+ let r = await getQuotaClaude('', { ...base, configDir })
102
+ //錯誤訊息不得外洩權杖明文(redact於fetchQuotaJson層施作)
103
+ let rr = [r.ok, r.errorType, /local recorded expiry: 2000-01-01/.test(r.error), /re-login is not needed/.test(r.error), /tok-401/.test(r.error)]
104
+ assert.strict.deepEqual(rr, [false, 'auth', true, true, false])
105
+ })
106
+
107
+ it('環境變數CLAUDE_CODE_OAUTH_TOKEN不作為權杖來源(setup-token缺user:profile scope, 2026-09-05實測403): 有憑證檔即用憑證檔; 無憑證檔時notfound並說明該變數被忽略之原因', async function() {
108
+ let configDir = mkConfigDir('envtok', { claudeAiOauth: { accessToken: 'tok-ok', subscriptionType: 'max' } }, { oauthAccount: { emailAddress: 'a@b.c' } })
109
+ let configDir2 = mkConfigDir('envtok-nocred', null, null)
110
+ //環境變數給一枚會被端點拒絕的權杖, 若被誤用為來源便會401而非ok
111
+ let r1 = await getQuotaClaude('', { ...base, configDir, env: { CLAUDE_CODE_OAUTH_TOKEN: 'tok-401' } })
112
+ let r2 = await getQuotaClaude('', { ...base, configDir: configDir2, env: { CLAUDE_CODE_OAUTH_TOKEN: 'tok-ok' } })
113
+ let r3 = await getQuotaClaude('', { ...base, configDir: configDir2 })
114
+ let rr = [
115
+ [r1.ok, r1.email, r1.plan],
116
+ [r2.ok, r2.errorType, /CLAUDE_CODE_OAUTH_TOKEN is set but is ignored/.test(r2.error), /user:profile/.test(r2.error)],
117
+ [r3.ok, r3.errorType, /CLAUDE_CODE_OAUTH_TOKEN/.test(r3.error)],
118
+ ]
119
+ assert.strict.deepEqual(rr, [[true, 'a@b.c', 'max'], [false, 'notfound', true, true], [false, 'notfound', false]])
120
+ })
121
+
122
+ it('帳號檔無email時改打profile端點取得(profileFallback), 關閉則email為空', async function() {
123
+ let configDir = mkConfigDir('noemail', { claudeAiOauth: { accessToken: 'tok-ok' } }, {})
124
+ let r1 = await getQuotaClaude('', { ...base, configDir })
125
+ let r2 = await getQuotaClaude('', { ...base, configDir, profileFallback: false })
126
+ let rr = [[r1.ok, r1.email], [r2.ok, r2.email]]
127
+ assert.strict.deepEqual(rr, [[true, 'profile-user@example.com'], [true, '']])
128
+ })
129
+
130
+ it('403之oauth_not_allowed_for_organization附組織限制說明', async function() {
131
+ let configDir = mkConfigDir('org', { claudeAiOauth: { accessToken: 'tok-403-org' } }, { oauthAccount: { emailAddress: 'a@b.c' } })
132
+ let r = await getQuotaClaude('', { ...base, configDir })
133
+ let rr = [r.ok, r.errorType, /organization disallows OAuth usage queries/.test(r.error)]
134
+ assert.strict.deepEqual(rr, [false, 'forbidden', true])
135
+ })
136
+
137
+ })
@@ -0,0 +1,191 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import assert from 'assert'
5
+ import getQuotaCodex from '../src/quota/getQuotaCodex.mjs'
6
+ import fakeServerForQuotaTest from './tools/fakeServerForQuotaTest.mjs'
7
+ import createFakeCli from './tools/fakeCliForTest.mjs'
8
+
9
+
10
+ //產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
11
+ let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'quota-codex')
12
+
13
+
14
+ //假codex app-server: 一行一則JSON之RPC, 依MODE決定account/read之回應
15
+ //(模擬2026-09-05實測之真實形狀: account.type/email/planType; rateLimits含primary/secondary與byLimitId)
16
+ let codeAppServer = (mode) => `
17
+ import readline from 'readline'
18
+ let mode = '${mode}'
19
+ let rl = readline.createInterface({ input: process.stdin })
20
+ rl.on('line', (l) => {
21
+ let m = null
22
+ try {
23
+ m = JSON.parse(l)
24
+ }
25
+ catch (e) {
26
+ return
27
+ }
28
+ if (m.id === undefined) {
29
+ return
30
+ }
31
+ let out = (result) => process.stdout.write(JSON.stringify({ id: m.id, result }) + '\\n')
32
+ let err = (message) => process.stdout.write(JSON.stringify({ id: m.id, error: { code: -1, message } }) + '\\n')
33
+ if (m.method === 'initialize') {
34
+ return out({ userAgent: 'fake-codex' })
35
+ }
36
+ if (m.method === 'account/read') {
37
+ if (mode === 'nologin') return out({ account: null })
38
+ if (mode === 'apikey') return out({ account: { type: 'apikey' } })
39
+ if (mode === 'rpcfail') return err('boom')
40
+ return out({ account: { type: 'chatgpt', email: 'codex-user@example.com', planType: 'plus' } })
41
+ }
42
+ if (m.method === 'account/rateLimits/read') {
43
+ return out({
44
+ rateLimits: {
45
+ limitId: 'codex', planType: 'plus',
46
+ primary: { usedPercent: 0, windowDurationMins: 300, resetsAt: 4102444800 },
47
+ secondary: { usedPercent: 31, windowDurationMins: 10080, resetsAt: 4102444800 },
48
+ credits: { hasCredits: false, unlimited: false, balance: '0' },
49
+ },
50
+ rateLimitsByLimitId: {
51
+ codex: {},
52
+ 'codex-spark': { limitName: 'GPT-5.3-Codex-Spark', primary: { usedPercent: 12, windowDurationMins: 300, resetsAt: 4102444800 } },
53
+ },
54
+ rateLimitResetCredits: { availableCount: 1, credits: [{ id: 'c1', title: 'Reset' }] },
55
+ })
56
+ }
57
+ err('unknown method ' + m.method)
58
+ })
59
+ rl.on('close', () => process.exit(0))
60
+ `
61
+
62
+
63
+ //以base64url組一枚不驗簽之JWT(僅供解出payload)
64
+ let mkJwt = (payload) => {
65
+ let b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
66
+ return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
67
+ }
68
+
69
+
70
+ //建立假codexHome(auth.json)
71
+ let mkCodexHome = (name, auth) => {
72
+ let fd = path.join(FD, name)
73
+ fs.mkdirSync(fd, { recursive: true })
74
+ if (auth !== null) {
75
+ fs.writeFileSync(path.join(fd, 'auth.json'), JSON.stringify(auth), 'utf8')
76
+ }
77
+ return fd
78
+ }
79
+
80
+
81
+ describe('getQuotaCodex', function() {
82
+
83
+ let svr = null
84
+ let fakes = []
85
+ let mkFake = (mode) => {
86
+ let f = createFakeCli(`fake-codex-${mode}`, codeAppServer(mode))
87
+ fakes.push(f)
88
+ return f.exe
89
+ }
90
+
91
+ before(async function() {
92
+ svr = await fakeServerForQuotaTest()
93
+ fs.mkdirSync(FD, { recursive: true })
94
+ })
95
+
96
+ after(async function() {
97
+ if (svr) {
98
+ await svr.close()
99
+ }
100
+ for (let f of fakes) {
101
+ f.clean()
102
+ }
103
+ fs.rmSync(FD, { recursive: true, force: true })
104
+ try {
105
+ fs.rmdirSync(path.dirname(FD))
106
+ }
107
+ catch (e) {}
108
+ })
109
+
110
+ it('app-server主路徑: 帳號與額度經JSON-RPC取得, byLimitId另列並略過與彙總同id者, severity對稱推導', async function() {
111
+ let r = await getQuotaCodex('', { exe: mkFake('ok'), env: {}, timeoutMs: 20000 })
112
+ let rr = [
113
+ r.ok, r.matched, r.provider, r.email, r.plan, r.source,
114
+ r.windows.map((w) => `${w.key}|${w.label}|${w.usedPercent}|${w.scope}|${w.severity}`),
115
+ r.credits.resetCreditsAvailable,
116
+ r.raw.accountType,
117
+ ]
118
+ assert.strict.deepEqual(rr, [
119
+ true, null, 'codex', 'codex-user@example.com', 'plus', 'codex-app-server',
120
+ ['primary|5小時|0||normal', 'secondary|7天|31||normal', 'codex-spark:primary|5小時(GPT-5.3-Codex-Spark)|12|GPT-5.3-Codex-Spark|normal'],
121
+ 1,
122
+ 'chatgpt',
123
+ ])
124
+ })
125
+
126
+ it('app-server回報尚未登入(account為null)→notfound且不走備援(以notLoggedIn旗標判別, 非字串比對)', async function() {
127
+ let codexHome = mkCodexHome('nologin', { tokens: { access_token: 'tok-ok' } })
128
+ let r = await getQuotaCodex('', { exe: mkFake('nologin'), env: {}, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
129
+ let rr = [r.ok, r.errorType, r.source, /codex login/.test(r.error)]
130
+ assert.strict.deepEqual(rr, [false, 'notfound', 'codex-app-server', true])
131
+ })
132
+
133
+ it('app-server回報非chatgpt帳號型別(如apikey)→unsupported且不走備援', async function() {
134
+ let codexHome = mkCodexHome('apikey-rpc', { tokens: { access_token: 'tok-ok' } })
135
+ let r = await getQuotaCodex('', { exe: mkFake('apikey'), env: {}, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
136
+ let rr = [r.ok, r.errorType, r.source, /\[apikey\]/.test(r.error)]
137
+ assert.strict.deepEqual(rr, [false, 'unsupported', 'codex-app-server', true])
138
+ })
139
+
140
+ it('app-server之RPC失敗或exe不存在→退回HTTP備援, 成功時raw記錄app-server之錯誤', async function() {
141
+ let codexHome = mkCodexHome('fallback', {
142
+ auth_mode: 'chatgpt',
143
+ tokens: { access_token: 'tok-ok', account_id: 'acct-123456', id_token: mkJwt({ 'email': 'jwt@example.com', 'https://api.openai.com/auth': { chatgpt_plan_type: 'pro' } }) },
144
+ })
145
+ let r1 = await getQuotaCodex('', { exe: mkFake('rpcfail'), env: {}, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
146
+ let r2 = await getQuotaCodex('', { exe: 'codex-no-such-exe-xyz', env: {}, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
147
+ let rr = [
148
+ [r1.ok, r1.source, r1.email, r1.plan, r1.windows.length, /boom/.test(r1.raw.appServerError)],
149
+ [r2.ok, r2.source, r2.raw.appServerError !== ''],
150
+ ]
151
+ //回應之email/plan為權威值(覆蓋id_token所解); fixture有主窗口2+code review1+additional1=4
152
+ assert.strict.deepEqual(rr, [[true, 'chatgpt-wham-usage-api', 'codex-user@example.com', 'plus', 4, true], [true, 'chatgpt-wham-usage-api', true]])
153
+ })
154
+
155
+ it('fallbackHttp為false時app-server失敗即回報, 不打備援', async function() {
156
+ let r = await getQuotaCodex('', { exe: mkFake('rpcfail'), env: {}, fallbackHttp: false, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
157
+ let rr = [r.ok, r.source, r.errorType]
158
+ assert.strict.deepEqual(rr, [false, 'codex-app-server', 'rpc'])
159
+ })
160
+
161
+ it('HTTP備援(useAppServer:false): 憑證不存在→notfound; API key模式→unsupported; 401→auth附刷新指引', async function() {
162
+ let h1 = mkCodexHome('nocred', null)
163
+ let h2 = mkCodexHome('apikey-http', { OPENAI_API_KEY: 'sk-xxx' })
164
+ let h3 = mkCodexHome('expired', { tokens: { access_token: 'tok-401', id_token: mkJwt({ email: 'old@example.com' }) } })
165
+ let o = { useAppServer: false, env: {}, usageUrl: svr.usageUrlCodex, timeoutMs: 5000 }
166
+ let r1 = await getQuotaCodex('', { ...o, codexHome: h1 })
167
+ let r2 = await getQuotaCodex('', { ...o, codexHome: h2 })
168
+ let r3 = await getQuotaCodex('', { ...o, codexHome: h3 })
169
+ let rr = [
170
+ [r1.ok, r1.errorType, /auth\.json/.test(r1.error)],
171
+ [r2.ok, r2.errorType, /API key mode/.test(r2.error)],
172
+ [r3.ok, r3.errorType, r3.email, /run any codex command/.test(r3.error), /tok-401/.test(r3.error)],
173
+ ]
174
+ //401時仍以id_token指出本機綁定帳號; 錯誤訊息不外洩權杖
175
+ assert.strict.deepEqual(rr, [[false, 'notfound', true], [false, 'unsupported', true], [false, 'auth', 'old@example.com', true, false]])
176
+ })
177
+
178
+ it('HTTP備援之env注入: OPENAI_API_KEY僅存在於env時亦判為API key模式', async function() {
179
+ let codexHome = mkCodexHome('apikey-env', {})
180
+ let r = await getQuotaCodex('', { useAppServer: false, env: { OPENAI_API_KEY: 'sk-yyy' }, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 5000 })
181
+ let rr = [r.ok, r.errorType]
182
+ assert.strict.deepEqual(rr, [false, 'unsupported'])
183
+ })
184
+
185
+ it('帳號比對: 不符時ok為false、matched為false且額度仍回傳', async function() {
186
+ let r = await getQuotaCodex('someone-else@example.com', { exe: mkFake('ok'), env: {}, timeoutMs: 20000 })
187
+ let rr = [r.ok, r.matched, r.errorType, r.windows.length, /codex-user@example.com/.test(r.error)]
188
+ assert.strict.deepEqual(rr, [false, false, 'account', 3, true])
189
+ })
190
+
191
+ })
@@ -0,0 +1,44 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import assert from 'assert'
5
+ import readJsonOrNull from '../src/quota/readJsonOrNull.mjs'
6
+
7
+
8
+ //產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
9
+ let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'read-json-or-null')
10
+
11
+
12
+ describe('readJsonOrNull', function() {
13
+
14
+ before(function() {
15
+ fs.mkdirSync(FD, { recursive: true })
16
+ })
17
+
18
+ after(function() {
19
+ fs.rmSync(FD, { recursive: true, force: true })
20
+ try {
21
+ fs.rmdirSync(path.dirname(FD))
22
+ }
23
+ catch (e) {}
24
+ })
25
+
26
+ it('合法JSON回解析值(物件或陣列皆可)', function() {
27
+ let fp1 = path.join(FD, 'a.json')
28
+ let fp2 = path.join(FD, 'b.json')
29
+ fs.writeFileSync(fp1, '{"a":1,"b":{"c":[1,2]}}', 'utf8')
30
+ fs.writeFileSync(fp2, '[1,2,3]', 'utf8')
31
+ let r = [readJsonOrNull(fp1), readJsonOrNull(fp2)]
32
+ let rr = [{ a: 1, b: { c: [1, 2] } }, [1, 2, 3]]
33
+ assert.strict.deepEqual(r, rr)
34
+ })
35
+
36
+ it('檔案不存在、內容非JSON、路徑無效皆回null不throw(三種情形對呼叫端皆為「取不到」)', function() {
37
+ let fp = path.join(FD, 'broken.json')
38
+ fs.writeFileSync(fp, '{ not json', 'utf8')
39
+ let r = [readJsonOrNull(path.join(FD, 'nope.json')), readJsonOrNull(fp), readJsonOrNull(''), readJsonOrNull(null)]
40
+ let rr = [null, null, null, null]
41
+ assert.strict.deepEqual(r, rr)
42
+ })
43
+
44
+ })
@@ -0,0 +1,43 @@
1
+ import assert from 'assert'
2
+ import toQuotaLabel from '../src/quota/toQuotaLabel.mjs'
3
+ import toQuotaScopedLabel from '../src/quota/toQuotaScopedLabel.mjs'
4
+
5
+
6
+ describe('toQuotaLabel', function() {
7
+
8
+ it('由窗口秒數推導中文標籤: 整除者取最大單位(天>小時>分鐘), 皆不整除則秒', function() {
9
+ let r = [toQuotaLabel(604800), toQuotaLabel(18000), toQuotaLabel(900), toQuotaLabel(90), toQuotaLabel(86400), toQuotaLabel(3600)]
10
+ let rr = ['7天', '5小時', '15分鐘', '90秒', '1天', '1小時']
11
+ assert.strict.deepEqual(r, rr)
12
+ })
13
+
14
+ it('無效或非正數回傳空字串(呼叫端據此判斷供應商未提供窗口長度)', function() {
15
+ let r = [toQuotaLabel(null), toQuotaLabel(undefined), toQuotaLabel(0), toQuotaLabel(-5), toQuotaLabel('abc'), toQuotaLabel({})]
16
+ let rr = ['', '', '', '', '', '']
17
+ assert.strict.deepEqual(r, rr)
18
+ })
19
+
20
+ it('數值字串亦可推導(供應商可能回字串型秒數)', function() {
21
+ let r = toQuotaLabel('18000')
22
+ let rr = '5小時'
23
+ assert.strict.deepEqual(r, rr)
24
+ })
25
+
26
+ })
27
+
28
+
29
+ describe('toQuotaScopedLabel', function() {
30
+
31
+ it('有範圍時為「基底(範圍)」, 基底取自toQuotaLabel(單一來源, 窗口變動標籤自動正確)', function() {
32
+ let r = [toQuotaScopedLabel(604800, 'Fable'), toQuotaScopedLabel(18000, 'Gemini Models')]
33
+ let rr = ['7天(Fable)', '5小時(Gemini Models)']
34
+ assert.strict.deepEqual(r, rr)
35
+ })
36
+
37
+ it('有範圍但窗口秒數無效時僅以範圍為標籤; 無範圍回傳空字串令toQuotaWindow自行推導', function() {
38
+ let r = [toQuotaScopedLabel(null, 'codex-spark'), toQuotaScopedLabel(18000, ''), toQuotaScopedLabel(18000, null)]
39
+ let rr = ['codex-spark', '', '']
40
+ assert.strict.deepEqual(r, rr)
41
+ })
42
+
43
+ })