w-dispatch-ai 1.0.21 → 1.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +627 -569
- package/dist/w-dispatch-ai.umd.js +2 -2
- package/dist/w-dispatch-ai.umd.js.map +1 -1
- package/docs/WDispatchAi.mjs.html +12 -4
- package/docs/adapters.mjs.html +5 -3
- package/docs/budgetFor.mjs.html +2 -2
- package/docs/buildValidator.mjs.html +166 -0
- package/docs/castPintOr.mjs.html +2 -2
- package/docs/dfTimeoutMs.mjs.html +2 -2
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +2 -2
- package/docs/dispatchAiWkf.mjs.html +2 -2
- package/docs/dispatchAntigravity.mjs.html +2 -2
- package/docs/dispatchApiOpenaiCompat.mjs.html +4 -68
- package/docs/dispatchApiOpenaiResponses.mjs.html +498 -0
- package/docs/dispatchClaude.mjs.html +2 -2
- package/docs/dispatchCodex.mjs.html +2 -2
- package/docs/dispatchOpencode.mjs.html +2 -2
- package/docs/getCliArgs.mjs.html +2 -2
- package/docs/getErrorResult.mjs.html +2 -2
- package/docs/getErrorType.mjs.html +5 -3
- package/docs/global.html +11790 -3425
- package/docs/index.html +2 -2
- package/docs/quota_dfQuotaTimeoutMs.mjs.html +99 -0
- package/docs/quota_fetchQuotaJson.mjs.html +348 -0
- package/docs/quota_fromCodexUsageHttp.mjs.html +335 -0
- package/docs/quota_getQuotaAntigravity.mjs.html +463 -0
- package/docs/quota_getQuotaClaude.mjs.html +457 -0
- package/docs/quota_getQuotaCodex.mjs.html +515 -0
- package/docs/quota_readJsonOrNull.mjs.html +113 -0
- package/docs/quota_toQuotaLabel.mjs.html +146 -0
- package/docs/quota_toQuotaResult.mjs.html +214 -0
- package/docs/quota_toQuotaScopedLabel.mjs.html +117 -0
- package/docs/quota_toQuotaWindow.mjs.html +239 -0
- package/docs/readEnvFile.mjs.html +2 -2
- package/docs/resolveProviders.mjs.html +3 -3
- package/docs/wkf_callAiWithFallback.mjs.html +2 -2
- package/docs/wkf_createFileStore.mjs.html +2 -2
- package/docs/wkf_createUsageCounter.mjs.html +2 -2
- package/docs/wkf_extractJsonLoose.mjs.html +2 -2
- package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
- package/docs/wkf_runFanout.mjs.html +2 -2
- package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
- package/docs/wkf_runRolePipeline.mjs.html +2 -2
- package/docs/wkf_salvageTruncatedArray.mjs.html +2 -2
- package/g.mjs +2 -2
- package/package.json +2 -2
- package/src/WDispatchAi.mjs +10 -2
- package/src/adapters.mjs +3 -1
- package/src/buildValidator.mjs +94 -0
- package/src/dispatchApiOpenaiCompat.mjs +2 -66
- package/src/dispatchApiOpenaiResponses.mjs +426 -0
- package/src/getErrorType.mjs +3 -1
- package/src/providers.mjs +303 -213
- package/src/quota/dfQuotaTimeoutMs.mjs +27 -0
- package/src/quota/fetchQuotaJson.mjs +276 -0
- package/src/quota/fromCodexUsageHttp.mjs +263 -0
- package/src/quota/getQuotaAntigravity.mjs +391 -0
- package/src/quota/getQuotaClaude.mjs +385 -0
- package/src/quota/getQuotaCodex.mjs +443 -0
- package/src/quota/readJsonOrNull.mjs +41 -0
- package/src/quota/toQuotaLabel.mjs +74 -0
- package/src/quota/toQuotaResult.mjs +142 -0
- package/src/quota/toQuotaScopedLabel.mjs +45 -0
- package/src/quota/toQuotaWindow.mjs +167 -0
- package/src/resolveProviders.mjs +1 -1
- package/test/tools/fakeCliForTest.mjs +4 -3
- package/test/tools/fakeServerForApiTest.mjs +108 -3
- package/test/tools/fakeServerForQuotaTest.mjs +171 -0
- package/test/unit-WDispatchAi.test.mjs +19 -6
- package/test/unit-adapters.test.mjs +5 -3
- package/test/unit-dispatchAi.test.mjs +1 -1
- package/test/unit-dispatchApiOpenaiResponses.test.mjs +198 -0
- package/test/unit-fetchQuotaJson.test.mjs +70 -0
- package/test/unit-fromCodexUsageHttp.test.mjs +76 -0
- package/test/unit-getQuotaAntigravity.test.mjs +123 -0
- package/test/unit-getQuotaClaude.test.mjs +137 -0
- package/test/unit-getQuotaCodex.test.mjs +191 -0
- package/test/unit-readJsonOrNull.test.mjs +44 -0
- package/test/unit-toQuotaLabel.test.mjs +43 -0
- package/test/unit-toQuotaResult.test.mjs +53 -0
- package/test/unit-toQuotaWindow.test.mjs +73 -0
|
@@ -0,0 +1,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
|
package/src/resolveProviders.mjs
CHANGED
|
@@ -57,7 +57,7 @@ import strFindSimilar from 'wsemi/src/strFindSimilar.mjs'
|
|
|
57
57
|
* //全取: envVar展開為keys, 缺環境變數者列入skipped
|
|
58
58
|
* let { providers, table, skipped } = resolveProviders(providersAll, { env })
|
|
59
59
|
* console.log(providers.length, skipped)
|
|
60
|
-
* // =>
|
|
60
|
+
* // => 17 []
|
|
61
61
|
*
|
|
62
62
|
* //pick打錯字時, missing附拼寫提示hints(最接近之可用id)
|
|
63
63
|
* let rm = resolveProviders(providersAll, { env, pick: ['poolside/laguna-s-2.1'] })
|
|
@@ -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 = ''
|
|
@@ -7,7 +7,11 @@ import http from 'http'
|
|
|
7
7
|
// 故起一個本機http伺服器, 依請求之model與Authorization決定回應行為,
|
|
8
8
|
// 即可對成功/401/429/500/逾時/畸形回應逐條斷言, 而無須真的呼叫外部API。
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// 【路由】POST /v1/chat/completions 與 POST /v1/responses 兩種端點,
|
|
11
|
+
// 回應形狀依端點而異(Responses API無choices, 改為output陣列且usage欄位名不同),
|
|
12
|
+
// 對應dispatchApiOpenaiCompat與dispatchApiOpenaiResponses。
|
|
13
|
+
//
|
|
14
|
+
// 【chat/completions之行為路由(依body.model)】
|
|
11
15
|
// echo — 200, content為JSON字串{ auth, body }, 供斷言請求組成
|
|
12
16
|
// empty-content — 200, content為空字串(驗證路徑用)
|
|
13
17
|
// slow — 延遲10秒才回應(逾時路徑用)
|
|
@@ -17,6 +21,17 @@ import http from 'http'
|
|
|
17
21
|
// not-json — 200但本體非JSON(畸形回應路徑用)
|
|
18
22
|
// tool-calls — 200但finish_reason為tool_calls(工具不支援路徑用)
|
|
19
23
|
// 其他 — 404
|
|
24
|
+
// 【responses之行為路由(依body.model)】
|
|
25
|
+
// echo — 200, message之output_text為JSON字串{ auth, body }
|
|
26
|
+
// multi-message — 200, 多個message元素(驗證依序串接、reasoning項被略過)
|
|
27
|
+
// empty-content — 200但output_text為空字串
|
|
28
|
+
// incomplete — 200但status為incomplete(reason: max_output_tokens, output為空)
|
|
29
|
+
// failed — 200但status為failed(帶error.message)
|
|
30
|
+
// reasoning-only — 200且completed但output僅reasoning無message(結構不合規)
|
|
31
|
+
// tool-calls — 200但output含function_call元素
|
|
32
|
+
// no-output — 200但缺output陣列
|
|
33
|
+
// not-json/slow/err-500/flaky-429 — 同chat/completions之對應行為
|
|
34
|
+
// 其他 — 401(同Zen實測: 未知model回401非404)
|
|
20
35
|
// 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰。
|
|
21
36
|
|
|
22
37
|
|
|
@@ -35,8 +50,9 @@ async function fakeServerForApiTest() {
|
|
|
35
50
|
|
|
36
51
|
let server = http.createServer((req, res) => {
|
|
37
52
|
|
|
38
|
-
//僅受理POST /v1/chat/completions
|
|
39
|
-
|
|
53
|
+
//僅受理POST /v1/chat/completions與POST /v1/responses
|
|
54
|
+
let isResponses = req.url.endsWith('/responses')
|
|
55
|
+
if (req.method !== 'POST' || (!req.url.endsWith('/chat/completions') && !isResponses)) {
|
|
40
56
|
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
41
57
|
res.end(JSON.stringify({ error: { message: 'not found' } }))
|
|
42
58
|
return
|
|
@@ -67,6 +83,95 @@ async function fakeServerForApiTest() {
|
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
let model = body.model || ''
|
|
86
|
+
|
|
87
|
+
//Responses API路由(/responses): 形狀與chat/completions完全不同, 見dispatchApiOpenaiResponses檔頭
|
|
88
|
+
if (isResponses) {
|
|
89
|
+
let send = (code, obj) => {
|
|
90
|
+
res.writeHead(code, { 'Content-Type': 'application/json' })
|
|
91
|
+
res.end(JSON.stringify(obj))
|
|
92
|
+
}
|
|
93
|
+
//usage欄位名為input_tokens/output_tokens(與chat/completions不同), 供斷言原樣透傳
|
|
94
|
+
let usage = { input_tokens: 5, output_tokens: 11, total_tokens: 16 }
|
|
95
|
+
let okResp = (text) => send(200, {
|
|
96
|
+
id: 'resp_x',
|
|
97
|
+
object: 'response',
|
|
98
|
+
status: 'completed',
|
|
99
|
+
model,
|
|
100
|
+
error: null,
|
|
101
|
+
incomplete_details: null,
|
|
102
|
+
output: [
|
|
103
|
+
{ id: 'rs_1', type: 'reasoning', status: 'completed', content: [] }, //思考項應被略過
|
|
104
|
+
{ id: 'msg_1', type: 'message', status: 'completed', content: [{ type: 'output_text', text }] },
|
|
105
|
+
],
|
|
106
|
+
usage,
|
|
107
|
+
})
|
|
108
|
+
if (model === 'echo') {
|
|
109
|
+
okResp(JSON.stringify({ auth, body }))
|
|
110
|
+
}
|
|
111
|
+
else if (model === 'multi-message') { //多個message元素應依序串接
|
|
112
|
+
send(200, {
|
|
113
|
+
status: 'completed',
|
|
114
|
+
model,
|
|
115
|
+
error: null,
|
|
116
|
+
incomplete_details: null,
|
|
117
|
+
output: [
|
|
118
|
+
{ type: 'message', content: [{ type: 'output_text', text: 'AA' }] },
|
|
119
|
+
{ type: 'reasoning', content: [] },
|
|
120
|
+
{ type: 'message', content: [{ type: 'output_text', text: 'BB' }] },
|
|
121
|
+
],
|
|
122
|
+
usage,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
else if (model === 'empty-content') {
|
|
126
|
+
okResp('')
|
|
127
|
+
}
|
|
128
|
+
else if (model === 'incomplete') { //max_output_tokens耗盡, output為空
|
|
129
|
+
send(200, { status: 'incomplete', model, error: null, incomplete_details: { reason: 'max_output_tokens' }, output: [], usage })
|
|
130
|
+
}
|
|
131
|
+
else if (model === 'failed') {
|
|
132
|
+
send(200, { status: 'failed', model, error: { code: 'server_error', message: 'upstream blew up' }, incomplete_details: null, output: [], usage })
|
|
133
|
+
}
|
|
134
|
+
else if (model === 'reasoning-only') { //僅思考無message, 屬結構不合規
|
|
135
|
+
send(200, { status: 'completed', model, error: null, incomplete_details: null, output: [{ type: 'reasoning', content: [] }], usage })
|
|
136
|
+
}
|
|
137
|
+
else if (model === 'tool-calls') {
|
|
138
|
+
send(200, {
|
|
139
|
+
status: 'completed',
|
|
140
|
+
model,
|
|
141
|
+
error: null,
|
|
142
|
+
incomplete_details: null,
|
|
143
|
+
output: [{ type: 'function_call', name: 'get_weather', arguments: '{"city":"台北"}', call_id: 'call-1' }],
|
|
144
|
+
usage,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
else if (model === 'no-output') { //缺output陣列
|
|
148
|
+
send(200, { status: 'completed', model, id: 'resp_y' })
|
|
149
|
+
}
|
|
150
|
+
else if (model === 'not-json') {
|
|
151
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
152
|
+
res.end('plain text body')
|
|
153
|
+
}
|
|
154
|
+
else if (model === 'slow') {
|
|
155
|
+
setTimeout(() => okResp('too late'), 10000)
|
|
156
|
+
}
|
|
157
|
+
else if (model === 'err-500') {
|
|
158
|
+
send(500, { error: { message: 'internal error' } })
|
|
159
|
+
}
|
|
160
|
+
else if (model === 'flaky-429') {
|
|
161
|
+
flakyCount[auth] = (flakyCount[auth] || 0) + 1
|
|
162
|
+
if (flakyCount[auth] === 1) {
|
|
163
|
+
send(429, { error: { message: 'rate limited' } })
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
okResp(JSON.stringify({ attempt: flakyCount[auth] }))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
send(401, { type: 'error', error: { type: 'ModelError', message: `Model ${model} is not supported` } }) //同Zen實測: 未知model回401非404
|
|
171
|
+
}
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
70
175
|
let ok = (content) => {
|
|
71
176
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
72
177
|
res.end(JSON.stringify({
|