w-dispatch-ai 1.0.24 → 1.0.26

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 (62) hide show
  1. package/README.md +57 -5
  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 +6 -4
  5. package/docs/adapters.mjs.html +10 -3
  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/dispatchApiTypesafeSystemone.mjs.html +447 -0
  17. package/docs/dispatchClaude.mjs.html +2 -2
  18. package/docs/dispatchCodex.mjs.html +2 -2
  19. package/docs/dispatchOpencode.mjs.html +2 -2
  20. package/docs/getCliArgs.mjs.html +2 -2
  21. package/docs/getErrorResult.mjs.html +2 -2
  22. package/docs/getErrorType.mjs.html +4 -3
  23. package/docs/global.html +1223 -295
  24. package/docs/index.html +2 -2
  25. package/docs/quota_dfQuotaTimeoutMs.mjs.html +2 -2
  26. package/docs/quota_fetchQuotaJson.mjs.html +2 -2
  27. package/docs/quota_fromCodexUsageHttp.mjs.html +2 -2
  28. package/docs/quota_getQuotaAntigravity.mjs.html +2 -2
  29. package/docs/quota_getQuotaClaude.mjs.html +2 -2
  30. package/docs/quota_getQuotaCodex.mjs.html +2 -2
  31. package/docs/quota_readJsonOrNull.mjs.html +2 -2
  32. package/docs/quota_toQuotaLabel.mjs.html +2 -2
  33. package/docs/quota_toQuotaResult.mjs.html +2 -2
  34. package/docs/quota_toQuotaScopedLabel.mjs.html +2 -2
  35. package/docs/quota_toQuotaWindow.mjs.html +2 -2
  36. package/docs/readEnvFile.mjs.html +62 -62
  37. package/docs/resolveProviders.mjs.html +183 -183
  38. package/docs/wkf_callAiWithFallback.mjs.html +2 -2
  39. package/docs/wkf_createFileStore.mjs.html +2 -2
  40. package/docs/wkf_createUsageCounter.mjs.html +2 -2
  41. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  42. package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
  43. package/docs/wkf_runFanout.mjs.html +2 -2
  44. package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
  45. package/docs/wkf_runRolePipeline.mjs.html +2 -2
  46. package/docs/wkf_salvageTruncatedArray.mjs.html +2 -2
  47. package/g.mjs +2 -2
  48. package/package.json +1 -6
  49. package/src/WDispatchAi.mjs +4 -2
  50. package/src/adapters.mjs +8 -1
  51. package/src/dispatchApiTypesafeSystemone.mjs +375 -0
  52. package/src/getErrorType.mjs +2 -1
  53. package/src/providers.mjs +336 -303
  54. package/src/readEnvFile.mjs +60 -60
  55. package/src/resolveProviders.mjs +181 -181
  56. package/test/tools/fakeServerForApiTest.mjs +75 -5
  57. package/test/unit-WDispatchAi.test.mjs +10 -6
  58. package/test/unit-adapters.test.mjs +5 -3
  59. package/test/unit-dispatchAi.test.mjs +1 -1
  60. package/test/unit-dispatchApiTypesafeSystemone.test.mjs +159 -0
  61. package/test/unit-providers.test.mjs +7 -0
  62. package/test/unit-resolveProviders.test.mjs +2 -2
@@ -1,60 +1,60 @@
1
- import fs from 'fs'
2
-
3
-
4
- // readEnvFile.mjs — 讀.env為金鑰來源物件(不污染process.env)
5
- //
6
- // 【為何需要】resolveProviders之opt.env本為「不用process.env」而設計——多專案並行時
7
- // 污染全域環境變數會互相覆蓋金鑰——但「env物件從哪來」套件原無答案, README舊教法
8
- // process.loadEnvFile('./.env')反而把金鑰塞進process.env, 與該設計目的自相矛盾。
9
- // 兩個消費端專案已各寫一份解析器且行為不一致(2026-08-18使用端調研), 收斂於此。
10
- //
11
- // 【為何讀不到回空物件而非拋錯】金鑰缺失由resolveProviders之skipped機制回報
12
- // (缺金鑰停用該條目、不中斷), 此處拋錯反而讓「部分供應商無金鑰」升級成整體啟動失敗;
13
- // 要fail-loud的呼叫端自行檢查回傳(如Object.keys(env).length)。
14
- //
15
- // 【只解析KEY=value最簡格式】不引入dotenv: 金鑰為單行純文字, 僅剝除成對之首尾引號,
16
- // 不做跳脫與多行處理——加解析規則只會製造與其他.env工具的行為差異。
17
-
18
-
19
- /**
20
- * 讀取.env檔為鍵值物件(金鑰來源, 不寫入process.env)
21
- *
22
- * 特點:
23
- * 只解析`KEY=value`形式(KEY限英數底線),忽略空行、註解與無效行;
24
- * value修剪空白並剝除成對之首尾引號(單雙引號皆可);
25
- * 檔案不存在或不可讀一律回空物件不throw(金鑰缺失交由resolveProviders之skipped回報)
26
- *
27
- * @param {String} file 輸入.env檔案路徑字串
28
- * @returns {Object} 回傳鍵值物件(變數名 → 字串值),讀取失敗回空物件
29
- * @example
30
- *
31
- * import readEnvFile from './src/readEnvFile.mjs'
32
- * import resolveProviders from './src/resolveProviders.mjs'
33
- * import providersAll from './src/providers.mjs'
34
- *
35
- * //金鑰放.env(變數值以逗號分隔多把), 讀成物件交resolveProviders, 不污染process.env
36
- * let env = readEnvFile('./.env')
37
- * let { providers, skipped } = resolveProviders(providersAll, { env, pick: ['agnes:agnes-2.5-flash'] })
38
- *
39
- */
40
- function readEnvFile(file) {
41
- let out = {}
42
- let text = ''
43
- try {
44
- text = fs.readFileSync(file, 'utf8')
45
- }
46
- catch (e) {
47
- return out //檔案不存在或不可讀, 回空由skipped機制回報
48
- }
49
- for (let line of text.split(/\r?\n/)) {
50
- let m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*)$/)
51
- if (!m) {
52
- continue
53
- }
54
- out[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2')
55
- }
56
- return out
57
- }
58
-
59
-
60
- export default readEnvFile
1
+ import fs from 'fs'
2
+
3
+
4
+ // readEnvFile.mjs — 讀.env為金鑰來源物件(不污染process.env)
5
+ //
6
+ // 【為何需要】resolveProviders之opt.env本為「不用process.env」而設計——多專案並行時
7
+ // 污染全域環境變數會互相覆蓋金鑰——但「env物件從哪來」套件原無答案, README舊教法
8
+ // process.loadEnvFile('./.env')反而把金鑰塞進process.env, 與該設計目的自相矛盾。
9
+ // 兩個消費端專案已各寫一份解析器且行為不一致(2026-08-18使用端調研), 收斂於此。
10
+ //
11
+ // 【為何讀不到回空物件而非拋錯】金鑰缺失由resolveProviders之skipped機制回報
12
+ // (缺金鑰停用該條目、不中斷), 此處拋錯反而讓「部分供應商無金鑰」升級成整體啟動失敗;
13
+ // 要fail-loud的呼叫端自行檢查回傳(如Object.keys(env).length)。
14
+ //
15
+ // 【只解析KEY=value最簡格式】不引入dotenv: 金鑰為單行純文字, 僅剝除成對之首尾引號,
16
+ // 不做跳脫與多行處理——加解析規則只會製造與其他.env工具的行為差異。
17
+
18
+
19
+ /**
20
+ * 讀取.env檔為鍵值物件(金鑰來源, 不寫入process.env)
21
+ *
22
+ * 特點:
23
+ * 只解析`KEY=value`形式(KEY限英數底線),忽略空行、註解與無效行;
24
+ * value修剪空白並剝除成對之首尾引號(單雙引號皆可);
25
+ * 檔案不存在或不可讀一律回空物件不throw(金鑰缺失交由resolveProviders之skipped回報)
26
+ *
27
+ * @param {String} file 輸入.env檔案路徑字串
28
+ * @returns {Object} 回傳鍵值物件(變數名 → 字串值),讀取失敗回空物件
29
+ * @example
30
+ *
31
+ * import readEnvFile from './src/readEnvFile.mjs'
32
+ * import resolveProviders from './src/resolveProviders.mjs'
33
+ * import providersAll from './src/providers.mjs'
34
+ *
35
+ * //金鑰放.env(變數值以逗號分隔多把), 讀成物件交resolveProviders, 不污染process.env
36
+ * let env = readEnvFile('./.env')
37
+ * let { providers, skipped } = resolveProviders(providersAll, { env, pick: ['agnes:agnes-3.0-flash'] })
38
+ *
39
+ */
40
+ function readEnvFile(file) {
41
+ let out = {}
42
+ let text = ''
43
+ try {
44
+ text = fs.readFileSync(file, 'utf8')
45
+ }
46
+ catch (e) {
47
+ return out //檔案不存在或不可讀, 回空由skipped機制回報
48
+ }
49
+ for (let line of text.split(/\r?\n/)) {
50
+ let m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*)$/)
51
+ if (!m) {
52
+ continue
53
+ }
54
+ out[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2')
55
+ }
56
+ return out
57
+ }
58
+
59
+
60
+ export default readEnvFile
@@ -1,181 +1,181 @@
1
- import get from 'lodash-es/get.js'
2
- import omit from 'lodash-es/omit.js'
3
- import isarr from 'wsemi/src/isarr.mjs'
4
- import isobj from 'wsemi/src/isobj.mjs'
5
- import isestr from 'wsemi/src/isestr.mjs'
6
- import isearr from 'wsemi/src/isearr.mjs'
7
- import strFindSimilar from 'wsemi/src/strFindSimilar.mjs'
8
-
9
-
10
- // resolveProviders.mjs — 把providers定義檔展開為可直接使用的條目
11
- //
12
- // 【為何需要】providers.mjs之條目以envVar間接引用金鑰(機密不落設定檔, 只寫變數名),
13
- // 而dispatchAiFallback只認keys陣列——本函數負責envVar → keys之展開,
14
- // 缺對應環境變數者停用該條目並列入skipped回報(不中斷、不throw),
15
- // 訂閱登入態條目(claude/codex等無envVar)原樣通過。
16
- //
17
- // 【自選】opt.pick給id陣列即可只取用部分條目, 且依pick之順序回傳
18
- // (順序即dispatchAiFallback之優先序); 查無之id列入missing回報。
19
- //
20
- // 【後處理單一入口: opt.exes與opt.patch】消費端常需於展開後對條目做兩類覆寫:
21
- // ①逐kind注入CLI執行檔絕對路徑(Windows排程於session 0執行時PATH可能不含npm
22
- // 全域目錄, 靠指令名會ENOENT); ②逐id覆寫任意欄位(如各家實測耗時相差近10倍,
23
- // timeoutMs須逐條指定)。此前只能於呼叫端自行map, 而陣列版(providers)與表格版
24
- // (table)若各自後處理, 極易只套用其一——使用端殷鑑(2026-08-14): 逐條timeout
25
- // 只加在陣列版, 工作流走表格版全部落回全域預設而不自知。後處理收進本函數,
26
- // 兩種輸出同源產出, 結構性杜絕單邊套用。
27
-
28
-
29
- /**
30
- * 展開providers定義條目:envVar → keys,並可依id自選子集
31
- *
32
- * 特點:
33
- * 條目之envVar依env來源(預設process.env)展開為keys陣列(變數值以逗號分隔多把),envVar欄位自輸出移除;
34
- * 缺對應環境變數(或值為空)之條目停用並列入skipped,不中斷不throw;
35
- * 無envVar之條目(訂閱登入態CLI)原樣通過;條目已自帶有效keys者以keys為準;
36
- * opt.pick可依id自選子集並以pick順序回傳(順序即遞補優先序);
37
- * opt.exes依kind注入CLI執行檔路徑、opt.patch依id淺合併覆寫欄位——皆於展開後施作,
38
- * providers與table同源產出故必然一致;
39
- * 輸入陣列與條目皆不被改動(輸出為淺拷貝)
40
- *
41
- * @param {Array} providers 輸入providers條目物件陣列(如providers.mjs之預設匯出)
42
- * @param {Object} [opt={}] 輸入設定物件,預設{}
43
- * @param {Object} [opt.env=process.env] 輸入金鑰來源物件(變數名 → 逗號分隔之金鑰字串),預設process.env。建議以readEnvFile('./.env')讀成物件傳入,不污染process.env
44
- * @param {Array} [opt.pick=null] 輸入自選id字串陣列,依此順序回傳對應條目,查無之id列入missing,預設null代表全取(依原順序)
45
- * @param {Object} [opt.exes=null] 輸入kind對執行檔絕對路徑之物件(如{claude:'C:/.../claude.exe'}),命中kind之條目補上exe欄位(條目已自帶exe者不覆寫——條目層設定優先),預設null代表不注入
46
- * @param {Object} [opt.patch=null] 輸入id對部分欄位之物件(如{'claude:sonnet':{timeoutMs:360000}}),命中id之條目淺合併覆寫,於exes之後施作故可覆寫exe,預設null代表不覆寫
47
- * @returns {Object} 回傳物件,內含providers(可直接餵dispatchAiFallback之條目陣列)、table(id對條目之物件,可直接餵dispatchAiWkf)、skipped(缺環境變數而停用之{id,envVar}陣列)、missing(pick查無之id字串陣列)、hints(missing id對最接近可用id之拼寫提示物件,pick查無多半是打錯字,附最接近id供直接定位;僅為相似度最高者非保證正解,無missing時為空物件)
48
- * @example
49
- *
50
- * import resolveProviders from './src/resolveProviders.mjs'
51
- * import readEnvFile from './src/readEnvFile.mjs'
52
- * import providersAll from './src/providers.mjs'
53
- *
54
- * //金鑰放.env(變數值以逗號分隔多把), 以readEnvFile讀成物件, 不污染process.env
55
- * let env = readEnvFile('./.env')
56
- *
57
- * //全取: envVar展開為keys, 缺環境變數者列入skipped
58
- * let { providers, table, skipped } = resolveProviders(providersAll, { env })
59
- * console.log(providers.length, skipped)
60
- * // => 17 []
61
- *
62
- * //pick打錯字時, missing附拼寫提示hints(最接近之可用id)
63
- * let rm = resolveProviders(providersAll, { env, pick: ['poolside/laguna-s-2.1'] })
64
- * console.log(rm.missing, rm.hints)
65
- * // => [ 'poolside/laguna-s-2.1' ] { 'poolside/laguna-s-2.1': 'poolside:laguna-s-2.1' }
66
- *
67
- * //自選: 依pick順序回傳(順序即遞補優先序), 可直接餵dispatchAiFallback
68
- * let r2 = resolveProviders(providersAll, { env, pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'] })
69
- * console.log(r2.providers.map((p) => p.id))
70
- * // => [ 'agnes:agnes-2.5-flash', 'claude:sonnet' ]
71
- *
72
- * //後處理: exes逐kind注入執行檔、patch逐id覆寫欄位, 陣列與table同步生效
73
- * let r3 = resolveProviders(providersAll, {
74
- * env,
75
- * pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'],
76
- * exes: { claude: 'C:/Users/x/.local/bin/claude.exe' },
77
- * patch: { 'claude:sonnet': { timeoutMs: 360000 } },
78
- * })
79
- * console.log(r3.table['claude:sonnet'].exe, r3.table['claude:sonnet'].timeoutMs)
80
- * // => 'C:/Users/x/.local/bin/claude.exe' 360000
81
- *
82
- * //table可直接餵dispatchAiWkf之providers定義表
83
- * //let wkf = dispatchAiWkf({ providers: r2.table, defaults: { timeoutMs: 1200000 } })
84
- *
85
- */
86
- function resolveProviders(providers, opt = {}) {
87
-
88
- //env, 無效回退process.env(瀏覽器環境無process則空物件)
89
- let env = get(opt, 'env', null)
90
- if (!isobj(env)) {
91
- env = (typeof process !== 'undefined' && process && isobj(process.env)) ? process.env : {}
92
- }
93
-
94
- //providersRaw
95
- let providersRaw = isarr(providers) ? providers.filter(isobj) : []
96
-
97
- //pick, 依id自選並依pick順序回傳
98
- let pick = get(opt, 'pick', null)
99
- let missing = []
100
- let selected = providersRaw
101
- if (isearr(pick)) {
102
- selected = []
103
- for (let id of pick) {
104
- let entry = providersRaw.find((p) => get(p, 'id', null) === id)
105
- if (entry) {
106
- selected.push(entry)
107
- }
108
- else {
109
- missing.push(String(id))
110
- }
111
- }
112
- }
113
-
114
- //hints, missing之拼寫提示: 自全部條目id中找最接近者——實務上pick查無多半是打錯字
115
- //(如'poolside/laguna'誤作分隔符), 附上最接近id可直接定位; 僅為相似度最高者, 非保證正解
116
- let hints = {}
117
- if (missing.length > 0) {
118
- let ids = providersRaw.map((p) => get(p, 'id', null)).filter(isestr)
119
- for (let mid of missing) {
120
- hints[mid] = (ids.length > 0) ? get(strFindSimilar(mid, ids), 'bestMatch.target', null) : null
121
- }
122
- }
123
-
124
- //展開envVar → keys
125
- let out = []
126
- let skipped = []
127
- for (let entry of selected) {
128
- let envVar = get(entry, 'envVar', null)
129
-
130
- //無envVar(訂閱登入態CLI)或已自帶有效keys, 原樣通過(僅移除envVar欄位)
131
- if (!isestr(envVar) || isearr(get(entry, 'keys', null))) {
132
- out.push(omit(entry, ['envVar']))
133
- continue
134
- }
135
-
136
- //envVar → keys, 變數值以逗號分隔多把
137
- let keys = String(get(env, envVar, '') || '').split(',').map((k) => k.trim()).filter(Boolean)
138
- if (keys.length === 0) {
139
-
140
- //缺環境變數: 停用該條目並回報, 不中斷(設計providers原則§四.4)
141
- skipped.push({ id: get(entry, 'id', ''), envVar })
142
- continue
143
- }
144
- out.push({ ...omit(entry, ['envVar']), keys })
145
- }
146
-
147
- //後處理: exes(逐kind)與patch(逐id), 於table組建「之前」施作——
148
- //providers與table由同一份out產出, 兩種輸出必然一致(檔頭之單邊套用殷鑑)
149
- let exes = get(opt, 'exes', null)
150
- let patch = get(opt, 'patch', null)
151
- if (isobj(exes) || isobj(patch)) {
152
- out = out.map((entry) => {
153
- let e = entry
154
-
155
- //exes: 條目已自帶exe者不覆寫(條目層設定優先於全域注入)
156
- let exe = isobj(exes) ? get(exes, get(e, 'kind', ''), null) : null
157
- if (isestr(exe) && !isestr(get(e, 'exe', null))) {
158
- e = { ...e, exe }
159
- }
160
-
161
- //patch: 依id淺合併, 於exes之後施作故可覆寫exe
162
- let pt = isobj(patch) ? get(patch, get(e, 'id', ''), null) : null
163
- if (isobj(pt)) {
164
- e = { ...e, ...pt }
165
- }
166
-
167
- return e
168
- })
169
- }
170
-
171
- //table, id → 條目, 可直接作dispatchAiWkf之providers定義表
172
- let table = {}
173
- for (let entry of out) {
174
- table[entry.id] = entry
175
- }
176
-
177
- return { providers: out, table, skipped, missing, hints }
178
- }
179
-
180
-
181
- export default resolveProviders
1
+ import get from 'lodash-es/get.js'
2
+ import omit from 'lodash-es/omit.js'
3
+ import isarr from 'wsemi/src/isarr.mjs'
4
+ import isobj from 'wsemi/src/isobj.mjs'
5
+ import isestr from 'wsemi/src/isestr.mjs'
6
+ import isearr from 'wsemi/src/isearr.mjs'
7
+ import strFindSimilar from 'wsemi/src/strFindSimilar.mjs'
8
+
9
+
10
+ // resolveProviders.mjs — 把providers定義檔展開為可直接使用的條目
11
+ //
12
+ // 【為何需要】providers.mjs之條目以envVar間接引用金鑰(機密不落設定檔, 只寫變數名),
13
+ // 而dispatchAiFallback只認keys陣列——本函數負責envVar → keys之展開,
14
+ // 缺對應環境變數者停用該條目並列入skipped回報(不中斷、不throw),
15
+ // 訂閱登入態條目(claude/codex等無envVar)原樣通過。
16
+ //
17
+ // 【自選】opt.pick給id陣列即可只取用部分條目, 且依pick之順序回傳
18
+ // (順序即dispatchAiFallback之優先序); 查無之id列入missing回報。
19
+ //
20
+ // 【後處理單一入口: opt.exes與opt.patch】消費端常需於展開後對條目做兩類覆寫:
21
+ // ①逐kind注入CLI執行檔絕對路徑(Windows排程於session 0執行時PATH可能不含npm
22
+ // 全域目錄, 靠指令名會ENOENT); ②逐id覆寫任意欄位(如各家實測耗時相差近10倍,
23
+ // timeoutMs須逐條指定)。此前只能於呼叫端自行map, 而陣列版(providers)與表格版
24
+ // (table)若各自後處理, 極易只套用其一——使用端殷鑑(2026-08-14): 逐條timeout
25
+ // 只加在陣列版, 工作流走表格版全部落回全域預設而不自知。後處理收進本函數,
26
+ // 兩種輸出同源產出, 結構性杜絕單邊套用。
27
+
28
+
29
+ /**
30
+ * 展開providers定義條目:envVar → keys,並可依id自選子集
31
+ *
32
+ * 特點:
33
+ * 條目之envVar依env來源(預設process.env)展開為keys陣列(變數值以逗號分隔多把),envVar欄位自輸出移除;
34
+ * 缺對應環境變數(或值為空)之條目停用並列入skipped,不中斷不throw;
35
+ * 無envVar之條目(訂閱登入態CLI)原樣通過;條目已自帶有效keys者以keys為準;
36
+ * opt.pick可依id自選子集並以pick順序回傳(順序即遞補優先序);
37
+ * opt.exes依kind注入CLI執行檔路徑、opt.patch依id淺合併覆寫欄位——皆於展開後施作,
38
+ * providers與table同源產出故必然一致;
39
+ * 輸入陣列與條目皆不被改動(輸出為淺拷貝)
40
+ *
41
+ * @param {Array} providers 輸入providers條目物件陣列(如providers.mjs之預設匯出)
42
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
43
+ * @param {Object} [opt.env=process.env] 輸入金鑰來源物件(變數名 → 逗號分隔之金鑰字串),預設process.env。建議以readEnvFile('./.env')讀成物件傳入,不污染process.env
44
+ * @param {Array} [opt.pick=null] 輸入自選id字串陣列,依此順序回傳對應條目,查無之id列入missing,預設null代表全取(依原順序)
45
+ * @param {Object} [opt.exes=null] 輸入kind對執行檔絕對路徑之物件(如{claude:'C:/.../claude.exe'}),命中kind之條目補上exe欄位(條目已自帶exe者不覆寫——條目層設定優先),預設null代表不注入
46
+ * @param {Object} [opt.patch=null] 輸入id對部分欄位之物件(如{'claude:sonnet':{timeoutMs:360000}}),命中id之條目淺合併覆寫,於exes之後施作故可覆寫exe,預設null代表不覆寫
47
+ * @returns {Object} 回傳物件,內含providers(可直接餵dispatchAiFallback之條目陣列)、table(id對條目之物件,可直接餵dispatchAiWkf)、skipped(缺環境變數而停用之{id,envVar}陣列)、missing(pick查無之id字串陣列)、hints(missing id對最接近可用id之拼寫提示物件,pick查無多半是打錯字,附最接近id供直接定位;僅為相似度最高者非保證正解,無missing時為空物件)
48
+ * @example
49
+ *
50
+ * import resolveProviders from './src/resolveProviders.mjs'
51
+ * import readEnvFile from './src/readEnvFile.mjs'
52
+ * import providersAll from './src/providers.mjs'
53
+ *
54
+ * //金鑰放.env(變數值以逗號分隔多把), 以readEnvFile讀成物件, 不污染process.env
55
+ * let env = readEnvFile('./.env')
56
+ *
57
+ * //全取: envVar展開為keys, 缺環境變數者列入skipped
58
+ * let { providers, table, skipped } = resolveProviders(providersAll, { env })
59
+ * console.log(providers.length, skipped)
60
+ * // => 17 []
61
+ *
62
+ * //pick打錯字時, missing附拼寫提示hints(最接近之可用id)
63
+ * let rm = resolveProviders(providersAll, { env, pick: ['poolside/laguna-s-2.1'] })
64
+ * console.log(rm.missing, rm.hints)
65
+ * // => [ 'poolside/laguna-s-2.1' ] { 'poolside/laguna-s-2.1': 'poolside:laguna-s-2.1' }
66
+ *
67
+ * //自選: 依pick順序回傳(順序即遞補優先序), 可直接餵dispatchAiFallback
68
+ * let r2 = resolveProviders(providersAll, { env, pick: ['agnes:agnes-3.0-flash', 'claude:sonnet'] })
69
+ * console.log(r2.providers.map((p) => p.id))
70
+ * // => [ 'agnes:agnes-3.0-flash', 'claude:sonnet' ]
71
+ *
72
+ * //後處理: exes逐kind注入執行檔、patch逐id覆寫欄位, 陣列與table同步生效
73
+ * let r3 = resolveProviders(providersAll, {
74
+ * env,
75
+ * pick: ['agnes:agnes-3.0-flash', 'claude:sonnet'],
76
+ * exes: { claude: 'C:/Users/x/.local/bin/claude.exe' },
77
+ * patch: { 'claude:sonnet': { timeoutMs: 360000 } },
78
+ * })
79
+ * console.log(r3.table['claude:sonnet'].exe, r3.table['claude:sonnet'].timeoutMs)
80
+ * // => 'C:/Users/x/.local/bin/claude.exe' 360000
81
+ *
82
+ * //table可直接餵dispatchAiWkf之providers定義表
83
+ * //let wkf = dispatchAiWkf({ providers: r2.table, defaults: { timeoutMs: 1200000 } })
84
+ *
85
+ */
86
+ function resolveProviders(providers, opt = {}) {
87
+
88
+ //env, 無效回退process.env(瀏覽器環境無process則空物件)
89
+ let env = get(opt, 'env', null)
90
+ if (!isobj(env)) {
91
+ env = (typeof process !== 'undefined' && process && isobj(process.env)) ? process.env : {}
92
+ }
93
+
94
+ //providersRaw
95
+ let providersRaw = isarr(providers) ? providers.filter(isobj) : []
96
+
97
+ //pick, 依id自選並依pick順序回傳
98
+ let pick = get(opt, 'pick', null)
99
+ let missing = []
100
+ let selected = providersRaw
101
+ if (isearr(pick)) {
102
+ selected = []
103
+ for (let id of pick) {
104
+ let entry = providersRaw.find((p) => get(p, 'id', null) === id)
105
+ if (entry) {
106
+ selected.push(entry)
107
+ }
108
+ else {
109
+ missing.push(String(id))
110
+ }
111
+ }
112
+ }
113
+
114
+ //hints, missing之拼寫提示: 自全部條目id中找最接近者——實務上pick查無多半是打錯字
115
+ //(如'poolside/laguna'誤作分隔符), 附上最接近id可直接定位; 僅為相似度最高者, 非保證正解
116
+ let hints = {}
117
+ if (missing.length > 0) {
118
+ let ids = providersRaw.map((p) => get(p, 'id', null)).filter(isestr)
119
+ for (let mid of missing) {
120
+ hints[mid] = (ids.length > 0) ? get(strFindSimilar(mid, ids), 'bestMatch.target', null) : null
121
+ }
122
+ }
123
+
124
+ //展開envVar → keys
125
+ let out = []
126
+ let skipped = []
127
+ for (let entry of selected) {
128
+ let envVar = get(entry, 'envVar', null)
129
+
130
+ //無envVar(訂閱登入態CLI)或已自帶有效keys, 原樣通過(僅移除envVar欄位)
131
+ if (!isestr(envVar) || isearr(get(entry, 'keys', null))) {
132
+ out.push(omit(entry, ['envVar']))
133
+ continue
134
+ }
135
+
136
+ //envVar → keys, 變數值以逗號分隔多把
137
+ let keys = String(get(env, envVar, '') || '').split(',').map((k) => k.trim()).filter(Boolean)
138
+ if (keys.length === 0) {
139
+
140
+ //缺環境變數: 停用該條目並回報, 不中斷(設計providers原則§四.4)
141
+ skipped.push({ id: get(entry, 'id', ''), envVar })
142
+ continue
143
+ }
144
+ out.push({ ...omit(entry, ['envVar']), keys })
145
+ }
146
+
147
+ //後處理: exes(逐kind)與patch(逐id), 於table組建「之前」施作——
148
+ //providers與table由同一份out產出, 兩種輸出必然一致(檔頭之單邊套用殷鑑)
149
+ let exes = get(opt, 'exes', null)
150
+ let patch = get(opt, 'patch', null)
151
+ if (isobj(exes) || isobj(patch)) {
152
+ out = out.map((entry) => {
153
+ let e = entry
154
+
155
+ //exes: 條目已自帶exe者不覆寫(條目層設定優先於全域注入)
156
+ let exe = isobj(exes) ? get(exes, get(e, 'kind', ''), null) : null
157
+ if (isestr(exe) && !isestr(get(e, 'exe', null))) {
158
+ e = { ...e, exe }
159
+ }
160
+
161
+ //patch: 依id淺合併, 於exes之後施作故可覆寫exe
162
+ let pt = isobj(patch) ? get(patch, get(e, 'id', ''), null) : null
163
+ if (isobj(pt)) {
164
+ e = { ...e, ...pt }
165
+ }
166
+
167
+ return e
168
+ })
169
+ }
170
+
171
+ //table, id → 條目, 可直接作dispatchAiWkf之providers定義表
172
+ let table = {}
173
+ for (let entry of out) {
174
+ table[entry.id] = entry
175
+ }
176
+
177
+ return { providers: out, table, skipped, missing, hints }
178
+ }
179
+
180
+
181
+ export default resolveProviders
@@ -32,7 +32,15 @@ import http from 'http'
32
32
  // no-output — 200但缺output陣列
33
33
  // not-json/slow/err-500/flaky-429 — 同chat/completions之對應行為
34
34
  // 其他 — 401(同Zen實測: 未知model回401非404)
35
- // 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰。
35
+ // 【systemone之行為路由(POST /v1/systemone, 依body.model; 形狀取自2026-09-17 TypeSafe實測)】
36
+ // echo / jev-latest — 200, 每題回{type:'noul', noul:0.5, echoAuth, echoBody}, 供斷言請求組成
37
+ // missing-answer — 200但answers缺最後一題
38
+ // no-answers — 200但無answers
39
+ // bad-question — 422 {detail:[{type:'union_tag_invalid',...}]}
40
+ // not-json/slow/err-500/flaky-429 — 同chat/completions之對應行為
41
+ // 其他 — 400 {detail:{error_type:'api_usage_error', message:'Unknown model: X'}}
42
+ // 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰;
43
+ // systemone路由之401本體採TypeSafe形狀{detail:{error_type:'authentication_error'}}。
36
44
 
37
45
 
38
46
  /**
@@ -50,9 +58,10 @@ async function fakeServerForApiTest() {
50
58
 
51
59
  let server = http.createServer((req, res) => {
52
60
 
53
- //僅受理POST /v1/chat/completions與POST /v1/responses
61
+ //僅受理POST /v1/chat/completions、POST /v1/responses與POST /v1/systemone
54
62
  let isResponses = req.url.endsWith('/responses')
55
- if (req.method !== 'POST' || (!req.url.endsWith('/chat/completions') && !isResponses)) {
63
+ let isSystemOne = req.url.endsWith('/systemone')
64
+ if (req.method !== 'POST' || (!req.url.endsWith('/chat/completions') && !isResponses && !isSystemOne)) {
56
65
  res.writeHead(404, { 'Content-Type': 'application/json' })
57
66
  res.end(JSON.stringify({ error: { message: 'not found' } }))
58
67
  return
@@ -75,15 +84,76 @@ async function fakeServerForApiTest() {
75
84
  return
76
85
  }
77
86
 
78
- //無效金鑰, 模擬Zen之401形態
87
+ //無效金鑰, 模擬Zen之401形態(systemone則模擬TypeSafe之形態)
79
88
  if (auth.includes('sk-bad')) {
80
89
  res.writeHead(401, { 'Content-Type': 'application/json' })
81
- res.end(JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Invalid API key.' } }))
90
+ if (isSystemOne) {
91
+ res.end(JSON.stringify({ detail: { error_type: 'authentication_error', message: 'Cannot authenticate with the server. Please check your API key and try again.' } }))
92
+ }
93
+ else {
94
+ res.end(JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Invalid API key.' } }))
95
+ }
82
96
  return
83
97
  }
84
98
 
85
99
  let model = body.model || ''
86
100
 
101
+ //System One路由(/systemone): 回answers而非文字, 見dispatchApiTypesafeSystemone檔頭
102
+ if (isSystemOne) {
103
+ let send = (code, obj) => {
104
+ res.writeHead(code, { 'Content-Type': 'application/json' })
105
+ res.end(JSON.stringify(obj))
106
+ }
107
+ let ids = Object.keys(body.questions || {})
108
+ let usage = { input_tokens: 7, output_tokens: 3 }
109
+ let okResp = (extra = {}) => {
110
+ let answers = {}
111
+ for (let id of ids) {
112
+ answers[id] = { type: 'noul', noul: 0.5, ...extra }
113
+ }
114
+ send(200, { model: 'jev-1.13.0', answers, usage })
115
+ }
116
+ if (model === 'echo' || model === 'jev-latest') {
117
+ okResp({ echoAuth: auth, echoBody: body })
118
+ }
119
+ else if (model === 'missing-answer') {
120
+ let answers = {}
121
+ for (let id of ids.slice(0, -1)) {
122
+ answers[id] = { type: 'noul', noul: 0.5 }
123
+ }
124
+ send(200, { model: 'jev-1.13.0', answers, usage })
125
+ }
126
+ else if (model === 'no-answers') {
127
+ send(200, { model: 'jev-1.13.0', usage })
128
+ }
129
+ else if (model === 'bad-question') {
130
+ send(422, { detail: [{ type: 'union_tag_invalid', loc: ['body', 'questions', ids[0]], msg: 'Input tag does not match any of the expected tags' }] })
131
+ }
132
+ else if (model === 'not-json') {
133
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
134
+ res.end('plain text body')
135
+ }
136
+ else if (model === 'slow') {
137
+ setTimeout(() => okResp(), 10000)
138
+ }
139
+ else if (model === 'err-500') {
140
+ send(500, { detail: { error_type: 'server_error', message: 'internal error' } })
141
+ }
142
+ else if (model === 'flaky-429') {
143
+ flakyCount[auth] = (flakyCount[auth] || 0) + 1
144
+ if (flakyCount[auth] === 1) {
145
+ send(429, { detail: { error_type: 'rate_limit_error', message: 'rate limited' } })
146
+ }
147
+ else {
148
+ okResp({ attempt: flakyCount[auth] })
149
+ }
150
+ }
151
+ else {
152
+ send(400, { detail: { error_type: 'api_usage_error', message: `Unknown model: ${model}` } })
153
+ }
154
+ return
155
+ }
156
+
87
157
  //Responses API路由(/responses): 形狀與chat/completions完全不同, 見dispatchApiOpenaiResponses檔頭
88
158
  if (isResponses) {
89
159
  let send = (code, obj) => {