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,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 }
@@ -10,8 +10,12 @@ import dispatchClaude from '../src/dispatchClaude.mjs'
10
10
  import dispatchCodex from '../src/dispatchCodex.mjs'
11
11
  import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
12
12
  import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
13
+ import dispatchApiOpenaiResponses from '../src/dispatchApiOpenaiResponses.mjs'
13
14
  import providers from '../src/providers.mjs'
14
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'
15
19
 
16
20
 
17
21
  describe('WDispatchAi', function() {
@@ -29,6 +33,7 @@ describe('WDispatchAi', function() {
29
33
  'dispatchCodex',
30
34
  'dispatchAntigravity',
31
35
  'dispatchApiOpenaiCompat',
36
+ 'dispatchApiOpenaiResponses',
32
37
  'providers',
33
38
  'resolveProviders',
34
39
  'readEnvFile',
@@ -36,19 +41,22 @@ describe('WDispatchAi', function() {
36
41
  'createFileStore',
37
42
  'createUsageCounter',
38
43
  'salvageTruncatedArray',
44
+ 'getQuotaClaude',
45
+ 'getQuotaCodex',
46
+ 'getQuotaAntigravity',
39
47
  ]
40
48
  assert.strict.deepEqual(r, rr)
41
49
  })
42
50
 
43
51
  it('KINDS為可用之供應商種類字串陣列', function() {
44
52
  let r = wi.KINDS
45
- let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']
53
+ let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat', 'api-openai-responses']
46
54
  assert.strict.deepEqual(r, rr)
47
55
  })
48
56
 
49
57
  it('各鍵值型別正確(KINDS與providers為物件, NO_SIDE_EFFECT為字串, 其餘為函數)', function() {
50
58
  let r = map(keys(wi), (k) => typeof wi[k])
51
- let rr = ['object', 'string', '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']
52
60
  assert.strict.deepEqual(r, rr)
53
61
  })
54
62
 
@@ -62,10 +70,14 @@ describe('WDispatchAi', function() {
62
70
  wi.dispatchCodex === dispatchCodex,
63
71
  wi.dispatchAntigravity === dispatchAntigravity,
64
72
  wi.dispatchApiOpenaiCompat === dispatchApiOpenaiCompat,
73
+ wi.dispatchApiOpenaiResponses === dispatchApiOpenaiResponses,
65
74
  wi.providers === providers,
66
75
  wi.resolveProviders === resolveProviders,
76
+ wi.getQuotaClaude === getQuotaClaude,
77
+ wi.getQuotaCodex === getQuotaCodex,
78
+ wi.getQuotaAntigravity === getQuotaAntigravity,
67
79
  ]
68
- let rr = [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]
69
81
  assert.strict.deepEqual(r, rr)
70
82
  })
71
83
 
@@ -75,20 +87,20 @@ describe('WDispatchAi', function() {
75
87
  let t = await wi.dispatchAi(kind, '')
76
88
  r.push(t.error)
77
89
  }
78
- let rr = ['prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string']
90
+ let rr = ['prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string']
79
91
  assert.strict.deepEqual(r, rr)
80
92
  })
81
93
 
82
94
  it('可由WDispatchAi呼叫dispatchAi', async function() {
83
95
  let t = await wi.dispatchAi('gemini', 'abc')
84
96
  let r = [t.ok, t.error]
85
- let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)']
97
+ let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat, api-openai-responses)']
86
98
  assert.strict.deepEqual(r, rr)
87
99
  })
88
100
 
89
101
  it('可由WDispatchAi呼叫各轉接器', async function() {
90
102
  let r = []
91
- for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity, wi.dispatchApiOpenaiCompat]) {
103
+ for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity, wi.dispatchApiOpenaiCompat, wi.dispatchApiOpenaiResponses]) {
92
104
  let t = await fn('')
93
105
  r.push([t.ok, t.error])
94
106
  }
@@ -98,6 +110,7 @@ describe('WDispatchAi', function() {
98
110
  [false, 'prompt must be a non-empty string'],
99
111
  [false, 'prompt must be a non-empty string'],
100
112
  [false, 'prompt must be a non-empty string'],
113
+ [false, 'prompt must be a non-empty string'],
101
114
  ]
102
115
  assert.strict.deepEqual(r, rr)
103
116
  })
@@ -7,19 +7,20 @@ import dispatchClaude from '../src/dispatchClaude.mjs'
7
7
  import dispatchCodex from '../src/dispatchCodex.mjs'
8
8
  import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
9
9
  import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
10
+ import dispatchApiOpenaiResponses from '../src/dispatchApiOpenaiResponses.mjs'
10
11
 
11
12
 
12
13
  describe('adapters', function() {
13
14
 
14
15
  it('對照表鍵名即為可用之供應商種類', function() {
15
16
  let r = keys(adapters)
16
- let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']
17
+ let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat', 'api-openai-responses']
17
18
  assert.strict.deepEqual(r, rr)
18
19
  })
19
20
 
20
21
  it('各鍵值皆為函數', function() {
21
22
  let r = map(keys(adapters), (k) => typeof adapters[k])
22
- let rr = ['function', 'function', 'function', 'function', 'function']
23
+ let rr = ['function', 'function', 'function', 'function', 'function', 'function']
23
24
  assert.strict.deepEqual(r, rr)
24
25
  })
25
26
 
@@ -30,8 +31,9 @@ describe('adapters', function() {
30
31
  adapters.codex === dispatchCodex,
31
32
  adapters.antigravity === dispatchAntigravity,
32
33
  adapters['api-openai-compat'] === dispatchApiOpenaiCompat,
34
+ adapters['api-openai-responses'] === dispatchApiOpenaiResponses,
33
35
  ]
34
- let rr = [true, true, true, true, true]
36
+ let rr = [true, true, true, true, true, true]
35
37
  assert.strict.deepEqual(r, rr)
36
38
  })
37
39
 
@@ -20,7 +20,7 @@ describe('dispatchAi', function() {
20
20
  it('未知kind時回傳錯誤結果物件且不reject', async function() {
21
21
  let t = await dispatchAi('gemini', 'abc')
22
22
  let r = [t.ok, t.error, t.attempts]
23
- let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)', 0]
23
+ let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat, api-openai-responses)', 0]
24
24
  assert.strict.deepEqual(r, rr)
25
25
  })
26
26
 
@@ -0,0 +1,198 @@
1
+ import assert from 'assert'
2
+ import dispatchApiOpenaiResponses, { extractOutputText } from '../src/dispatchApiOpenaiResponses.mjs'
3
+ import dispatchAi from '../src/dispatchAi.mjs'
4
+ import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
5
+ import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
6
+ import fakeServerForApiTest from './tools/fakeServerForApiTest.mjs'
7
+
8
+
9
+ describe('dispatchApiOpenaiResponses', function() {
10
+
11
+ let svr = null
12
+
13
+ before(async function() {
14
+ svr = await fakeServerForApiTest()
15
+ })
16
+
17
+ after(async function() {
18
+ if (svr) {
19
+ await svr.close()
20
+ }
21
+ })
22
+
23
+ it('extractOutputText只取message型之output_text, 略過reasoning且多段依序串接', function() {
24
+ let r = [
25
+ extractOutputText([
26
+ { type: 'reasoning', content: [] },
27
+ { type: 'message', content: [{ type: 'output_text', text: '完成' }] },
28
+ ]),
29
+ extractOutputText([
30
+ { type: 'message', content: [{ type: 'output_text', text: 'AA' }] },
31
+ { type: 'reasoning', content: [] },
32
+ { type: 'message', content: [{ type: 'output_text', text: 'BB' }] },
33
+ ]),
34
+ extractOutputText([{ type: 'reasoning', content: [] }]), //僅思考
35
+ extractOutputText(null),
36
+ extractOutputText([]),
37
+ ]
38
+ let rr = ['完成', 'AABB', '', '', '']
39
+ assert.strict.deepEqual(r, rr)
40
+ })
41
+
42
+ it('prompt/baseURL/model之必填檢核, 回傳錯誤結果物件且不reject', async function() {
43
+ let t1 = await dispatchApiOpenaiResponses('')
44
+ let t2 = await dispatchApiOpenaiResponses('abc', { model: 'echo' })
45
+ let t3 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url })
46
+ let r = [[t1.ok, t1.error], [t2.ok, t2.error], [t3.ok, t3.error, t3.errorType]]
47
+ let rr = [
48
+ [false, 'prompt must be a non-empty string'],
49
+ [false, 'baseURL must be a non-empty string'],
50
+ [false, 'model must be a non-empty string', 'params'],
51
+ ]
52
+ assert.strict.deepEqual(r, rr)
53
+ })
54
+
55
+ it('請求打/responses端點, prompt置於input、system置於instructions', async function() {
56
+ let t = await dispatchApiOpenaiResponses('中文prompt\n第二行', {
57
+ baseURL: svr.url, key: 'sk-good-1', model: 'echo', system: '你是審計者', body: { max_output_tokens: 2048 },
58
+ })
59
+ let o = JSON.parse(t.stdout)
60
+ let r = [t.ok, t.code, t.attempts, o.auth, o.body.model, o.body.input, o.body.instructions, o.body.max_output_tokens, o.body.messages]
61
+ let rr = [true, 200, 1, 'Bearer sk-good-1', 'echo', '中文prompt\n第二行', '你是審計者', 2048, undefined]
62
+ assert.strict.deepEqual(r, rr)
63
+ })
64
+
65
+ it('baseURL尾端斜線正規化, 未給key時不帶Authorization標頭', async function() {
66
+ let t = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url + '///', model: 'echo' })
67
+ let o = JSON.parse(t.stdout)
68
+ let r = [t.ok, t.code, o.auth]
69
+ let rr = [true, 200, '']
70
+ assert.strict.deepEqual(r, rr)
71
+ })
72
+
73
+ it('多個message元素依序串接為stdout', async function() {
74
+ let t = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'multi-message' })
75
+ let r = [t.ok, t.stdout]
76
+ let rr = [true, 'AABB']
77
+ assert.strict.deepEqual(r, rr)
78
+ })
79
+
80
+ it('status非completed一律失敗不回半截內容: incomplete與failed', async function() {
81
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'incomplete' })
82
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'failed' })
83
+ let r = [
84
+ [t1.ok, t1.errorType, t1.error],
85
+ [t2.ok, t2.errorType, t2.error],
86
+ ]
87
+ let rr = [
88
+ [false, 'incomplete', 'INCOMPLETE_RESPONSE: status=incomplete (max_output_tokens)'],
89
+ [false, 'incomplete', 'INCOMPLETE_RESPONSE: status=failed (upstream blew up)'],
90
+ ]
91
+ assert.strict.deepEqual(r, rr)
92
+ })
93
+
94
+ it('結構不合規之回應: 缺output陣列、非JSON、僅reasoning無message', async function() {
95
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'no-output' })
96
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'not-json' })
97
+ let t3 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'reasoning-only' })
98
+ let r = [
99
+ [t1.ok, t1.errorType, t1.error],
100
+ [t2.ok, t2.errorType],
101
+ [t3.ok, t3.errorType, t3.error],
102
+ ]
103
+ let rr = [
104
+ [false, 'invalid-response', 'INVALID_RESPONSE: missing output array'],
105
+ [false, 'invalid-response'],
106
+ [false, 'invalid-response', 'INVALID_RESPONSE: no output_text in output messages'],
107
+ ]
108
+ assert.strict.deepEqual(r, rr)
109
+ })
110
+
111
+ it('output含function_call時以TOOL_CALLS_UNSUPPORTED回報而不假裝成功', async function() {
112
+ let t = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'tool-calls' })
113
+ let r = [t.ok, t.errorType, t.error.indexOf('TOOL_CALLS_UNSUPPORTED') === 0, t.stdout]
114
+ let rr = [false, 'tool-unsupported', true, '']
115
+ assert.strict.deepEqual(r, rr)
116
+ })
117
+
118
+ it('validate未過為OUTPUT_VALIDATION_FAILED(令遞補層可統一分流)', async function() {
119
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: 'min:100000' })
120
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'empty-content', validate: 'nonempty' })
121
+ let r = [
122
+ [t1.ok, t1.error, t1.errorType, t1.stdout.length > 0],
123
+ [t2.ok, t2.errorType], //空內容於取值階段即判invalid-response
124
+ ]
125
+ let rr = [
126
+ [false, 'OUTPUT_VALIDATION_FAILED', 'validation', true],
127
+ [false, 'invalid-response'],
128
+ ]
129
+ assert.strict.deepEqual(r, rr)
130
+ })
131
+
132
+ it('壞金鑰與未知model皆401(同Zen實測), 4xx不重試', async function() {
133
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-bad', model: 'echo', maxRetries: 3 })
134
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'no-such-model', maxRetries: 3 })
135
+ let r = [
136
+ [t1.ok, t1.code, t1.errorType, t1.attempts, t1.stderr.includes('AuthError')],
137
+ [t2.ok, t2.code, t2.attempts, t2.stderr.includes('is not supported')],
138
+ ]
139
+ let rr = [
140
+ [false, 401, 'http', 1, true],
141
+ [false, 401, 1, true],
142
+ ]
143
+ assert.strict.deepEqual(r, rr)
144
+ })
145
+
146
+ it('429與5xx依maxRetries重試, 逾時error以TIMEOUT開頭', async function() {
147
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-retry', model: 'flaky-429', maxRetries: 2, retryDelayMs: 10 })
148
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'slow', timeoutMs: 500 })
149
+ let r = [
150
+ [t1.ok, t1.attempts, JSON.parse(t1.stdout).attempt],
151
+ [t2.ok, t2.code, t2.errorType, t2.error.indexOf('TIMEOUT') === 0],
152
+ ]
153
+ let rr = [
154
+ [true, 2, 2],
155
+ [false, null, 'timeout', true],
156
+ ]
157
+ assert.strict.deepEqual(r, rr)
158
+ })
159
+
160
+ it('usage原樣透傳(欄位名為input_tokens/output_tokens, 與chat/completions不同)', async function() {
161
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
162
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'incomplete' })
163
+ let r = [t1.usage, t2.usage] //失敗結果亦帶出已耗token
164
+ let rr = [
165
+ { input_tokens: 5, output_tokens: 11, total_tokens: 16 },
166
+ { input_tokens: 5, output_tokens: 11, total_tokens: 16 },
167
+ ]
168
+ assert.strict.deepEqual(r, rr)
169
+ })
170
+
171
+ it('可作為dispatchAi之kind與dispatchAiFallback之條目', async function() {
172
+ let t1 = await dispatchAi('api-openai-responses', 'abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
173
+ //遞補: responses型條目失敗(未知model→401)後換chat/completions型條目成功, 兩kind混用於同一鏈
174
+ let t2 = await dispatchAiFallback('abc', {
175
+ providers: [
176
+ { id: 'r-bad', kind: 'api-openai-responses', baseURL: svr.url, model: 'no-such-model', keys: ['sk-good-a'] },
177
+ { id: 'c-ok', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-good-b'] },
178
+ ],
179
+ })
180
+ let r = [t1.ok, JSON.parse(t1.stdout).body.model, t2.ok, t2.providerId, t2.tried.map((x) => [x.providerId, x.outcome])]
181
+ let rr = [true, 'echo', true, 'c-ok', [['r-bad', 'next-key'], ['c-ok', 'ok']]]
182
+ assert.strict.deepEqual(r, rr)
183
+ })
184
+
185
+ it('可作為工作流provider, 回覆經解析與檢核', async function() {
186
+ let wkf = dispatchAiWkf({
187
+ providers: {
188
+ 'resp-echo': { kind: 'api-openai-responses', baseURL: svr.url, model: 'echo', keys: ['sk-good-w'] },
189
+ },
190
+ defaults: { promptPrefix: '' },
191
+ })
192
+ let t = await wkf.callAi('abc', { spec: { use: 'resp-echo' }, check: (j) => j.body.model === 'echo' })
193
+ let r = [t.ok, t.providerId, t.keyId, t.json.auth, t.json.body.input, t.usage.total_tokens]
194
+ let rr = [true, 'resp-echo', 'resp-echo#0', 'Bearer sk-good-w', 'abc', 16]
195
+ assert.strict.deepEqual(r, rr)
196
+ })
197
+
198
+ })
@@ -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
+ })
@@ -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
+ })