w-dispatch-ai 1.0.6 → 1.0.8
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 +31 -2
- package/dist/w-dispatch-ai.umd.js +2 -2
- package/dist/w-dispatch-ai.umd.js.map +1 -1
- package/docs/WDispatchAi.mjs.html +4 -3
- package/docs/adapters.mjs.html +2 -2
- package/docs/castPintOr.mjs.html +109 -0
- package/docs/dfTimeoutMs.mjs.html +2 -2
- package/docs/dispatchAi.mjs.html +14 -13
- package/docs/dispatchAiFallback.mjs.html +182 -58
- package/docs/dispatchAiWkf.mjs.html +2 -2
- package/docs/dispatchAntigravity.mjs.html +10 -14
- package/docs/dispatchApiOpenaiCompat.mjs.html +50 -64
- package/docs/dispatchClaude.mjs.html +8 -13
- package/docs/dispatchCodex.mjs.html +8 -13
- package/docs/dispatchOpencode.mjs.html +8 -13
- package/docs/getCliArgs.mjs.html +2 -2
- package/docs/getErrorResult.mjs.html +12 -5
- package/docs/getErrorType.mjs.html +171 -0
- package/docs/global.html +1336 -109
- package/docs/index.html +2 -2
- package/docs/resolveProviders.mjs.html +2 -2
- package/docs/wkf_callAiWithFallback.mjs.html +14 -10
- package/docs/wkf_extractJsonLoose.mjs.html +2 -2
- package/docs/wkf_runFanout.mjs.html +12 -9
- package/docs/wkf_runFanoutPipeline.mjs.html +15 -19
- package/docs/wkf_runRolePipeline.mjs.html +5 -4
- package/package.json +1 -1
- package/src/WDispatchAi.mjs +2 -1
- package/src/castPintOr.mjs +37 -0
- package/src/dispatchAi.mjs +12 -11
- package/src/dispatchAiFallback.mjs +180 -56
- package/src/dispatchAntigravity.mjs +8 -12
- package/src/dispatchApiOpenaiCompat.mjs +48 -62
- package/src/dispatchClaude.mjs +6 -11
- package/src/dispatchCodex.mjs +6 -11
- package/src/dispatchOpencode.mjs +6 -11
- package/src/getErrorResult.mjs +10 -3
- package/src/getErrorType.mjs +99 -0
- package/src/wkf/callAiWithFallback.mjs +12 -8
- package/src/wkf/runFanout.mjs +10 -7
- package/src/wkf/runFanoutPipeline.mjs +13 -17
- package/src/wkf/runRolePipeline.mjs +3 -2
- package/test/tools/fakeServerForApiTest.mjs +4 -1
- package/test/unit-callAiWithFallback.test.mjs +24 -0
- package/test/unit-castPintOr.test.mjs +28 -0
- package/test/unit-dispatchAiFallback.test.mjs +232 -0
- package/test/unit-dispatchApiOpenaiCompat.test.mjs +67 -0
- package/test/unit-getErrorResult.test.mjs +13 -1
- package/test/unit-getErrorType.test.mjs +36 -0
- package/test/unit-runFanout.test.mjs +60 -0
- package/test/unit-runRolePipeline.test.mjs +12 -0
|
@@ -316,6 +316,101 @@ describe('dispatchAiFallback', function() {
|
|
|
316
316
|
assert.strict.deepEqual(r, rr)
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
+
it('cooldownMs啟用時, 逾時觸發冷卻且次輪該條目降至鏈尾(只降序不移除)', async function() {
|
|
320
|
+
let stored = { cursors: {}, cooling: {} }
|
|
321
|
+
let store = {
|
|
322
|
+
get: () => stored,
|
|
323
|
+
set: (s) => {
|
|
324
|
+
stored = s
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
let pHang = { id: 'cd-hang', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-sleep=9000'], timeoutMs: 500 }
|
|
328
|
+
let pOk = { id: 'cd-ok', kind: 'claude', exe: fake.exe }
|
|
329
|
+
//第1輪: hang在鏈首 → 逾時跳組並觸發冷卻 → ok遞補
|
|
330
|
+
let r1 = await dispatchAiFallback('abc', { providers: [pHang, pOk], store, cooldownMs: 300000 })
|
|
331
|
+
//第2輪: hang冷卻中 → 降至鏈尾, ok先上且成功, hang完全未被嘗試
|
|
332
|
+
let r2 = await dispatchAiFallback('abc', { providers: [pHang, pOk], store, cooldownMs: 300000 })
|
|
333
|
+
let r = [
|
|
334
|
+
r1.providerId,
|
|
335
|
+
r1.tried.map((x) => x.providerId),
|
|
336
|
+
typeof stored.cooling['cd-hang'],
|
|
337
|
+
r2.providerId,
|
|
338
|
+
r2.tried.map((x) => x.providerId), //只有cd-ok, 未浪費一次逾時
|
|
339
|
+
]
|
|
340
|
+
let rr = ['cd-ok', ['cd-hang', 'cd-ok'], 'number', 'cd-ok', ['cd-ok']]
|
|
341
|
+
assert.strict.deepEqual(r, rr)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('冷卻中的條目於前面全敗時仍會被嘗試, 且成功即解除冷卻', async function() {
|
|
345
|
+
let stored = { cursors: {}, cooling: { 'cd2-a': Date.now() } } //預置: a冷卻中
|
|
346
|
+
let store = {
|
|
347
|
+
get: () => stored,
|
|
348
|
+
set: (s) => {
|
|
349
|
+
stored = s
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
let r1 = await dispatchAiFallback('abc', {
|
|
353
|
+
providers: [
|
|
354
|
+
{ id: 'cd2-a', kind: 'claude', exe: fake.exe }, //冷卻中 → 降至鏈尾
|
|
355
|
+
{ id: 'cd2-b', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] }, //鏈首但必敗
|
|
356
|
+
],
|
|
357
|
+
store,
|
|
358
|
+
cooldownMs: 300000,
|
|
359
|
+
})
|
|
360
|
+
let r = [
|
|
361
|
+
r1.ok,
|
|
362
|
+
r1.providerId, //b先上失敗 → 降序後的a仍被嘗試且成功
|
|
363
|
+
r1.tried.map((x) => x.providerId),
|
|
364
|
+
stored.cooling['cd2-a'], //成功即解除
|
|
365
|
+
]
|
|
366
|
+
let rr = [true, 'cd2-a', ['cd2-b', 'cd2-a'], undefined]
|
|
367
|
+
assert.strict.deepEqual(r, rr)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('冷卻過期後恢復原順序並清除過期紀錄', async function() {
|
|
371
|
+
let stored = { cursors: {}, cooling: { 'cd3-a': Date.now() - 1000 } } //1秒前觸發
|
|
372
|
+
let store = {
|
|
373
|
+
get: () => stored,
|
|
374
|
+
set: (s) => {
|
|
375
|
+
stored = s
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
let r1 = await dispatchAiFallback('abc', {
|
|
379
|
+
providers: [
|
|
380
|
+
{ id: 'cd3-a', kind: 'claude', exe: fake.exe },
|
|
381
|
+
{ id: 'cd3-b', kind: 'claude', exe: fake.exe },
|
|
382
|
+
],
|
|
383
|
+
store,
|
|
384
|
+
cooldownMs: 500, //視窗0.5秒, 已過期
|
|
385
|
+
})
|
|
386
|
+
let r = [r1.providerId, stored.cooling['cd3-a']]
|
|
387
|
+
let rr = ['cd3-a', undefined] //恢復鏈首, 過期紀錄被清除
|
|
388
|
+
assert.strict.deepEqual(r, rr)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('cooldownMs未啟用(預設)時不重排也不寫入cooling', async function() {
|
|
392
|
+
let stored = { cursors: {}, cooling: { 'cd4-a': Date.now() } }
|
|
393
|
+
let store = {
|
|
394
|
+
get: () => stored,
|
|
395
|
+
set: (s) => {
|
|
396
|
+
stored = s
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
let r1 = await dispatchAiFallback('abc', {
|
|
400
|
+
providers: [
|
|
401
|
+
{ id: 'cd4-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-sleep=9000'], timeoutMs: 500 },
|
|
402
|
+
{ id: 'cd4-b', kind: 'claude', exe: fake.exe },
|
|
403
|
+
],
|
|
404
|
+
store,
|
|
405
|
+
})
|
|
406
|
+
let r = [
|
|
407
|
+
r1.tried.map((x) => x.providerId), //cooling有預置紀錄仍不重排(未啟用)
|
|
408
|
+
typeof stored.cooling['cd4-a'] === 'number' && stored.cooling['cd4-b'] === undefined, //逾時亦不新增紀錄
|
|
409
|
+
]
|
|
410
|
+
let rr = [['cd4-a', 'cd4-b'], true]
|
|
411
|
+
assert.strict.deepEqual(r, rr)
|
|
412
|
+
})
|
|
413
|
+
|
|
319
414
|
it('失敗事件與tried帶被拒回覆(stdout)與錯誤輸出(stderr)供診斷', async function() {
|
|
320
415
|
let evs = []
|
|
321
416
|
let t = await dispatchAiFallback('abc', {
|
|
@@ -444,4 +539,141 @@ describe('dispatchAiFallback', function() {
|
|
|
444
539
|
assert.strict.deepEqual(r, rr)
|
|
445
540
|
})
|
|
446
541
|
|
|
542
|
+
it('shouldStop於嘗試邊界檢查, true即停止遞補回報ABORTED', async function() {
|
|
543
|
+
//首次嘗試邊界放行(n=0), 第二次嘗試邊界(st-a已敗轉st-b前)即中止——
|
|
544
|
+
//「斷線後仍空耗整條鏈」縮成「至多再耗當前這一家」
|
|
545
|
+
let n = 0
|
|
546
|
+
let t = await dispatchAiFallback('abc', {
|
|
547
|
+
providers: [
|
|
548
|
+
{ id: 'st-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] },
|
|
549
|
+
{ id: 'st-b', kind: 'claude', exe: fake.exe },
|
|
550
|
+
],
|
|
551
|
+
shouldStop: () => n++ >= 1,
|
|
552
|
+
})
|
|
553
|
+
let r = [t.ok, t.error, t.tried.map((x) => [x.providerId, x.outcome])]
|
|
554
|
+
let rr = [false, 'ABORTED', [['st-a', 'next-key'], ['st-b', 'aborted']]]
|
|
555
|
+
assert.strict.deepEqual(r, rr)
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('shouldStop一開始即true時完全不嘗試; 回調拋出例外視同false不中止', async function() {
|
|
559
|
+
let t1 = await dispatchAiFallback('abc', {
|
|
560
|
+
providers: [{ id: 'st2-a', kind: 'claude', exe: fake.exe }],
|
|
561
|
+
shouldStop: () => true,
|
|
562
|
+
})
|
|
563
|
+
let t2 = await dispatchAiFallback('abc', {
|
|
564
|
+
providers: [{ id: 'st2-a', kind: 'claude', exe: fake.exe }],
|
|
565
|
+
shouldStop: () => {
|
|
566
|
+
throw new Error('callback error should not break the loop')
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
let r = [[t1.ok, t1.error, t1.tried.map((x) => x.outcome)], [t2.ok, t2.error]]
|
|
570
|
+
let rr = [[false, 'ABORTED', ['aborted']], [true, '']]
|
|
571
|
+
assert.strict.deepEqual(r, rr)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('coolDetect注入CLI限流簽章判定, 命中即觸發冷卻次輪降尾', async function() {
|
|
575
|
+
let stored = { cursors: {}, cooling: {} }
|
|
576
|
+
let store = {
|
|
577
|
+
get: () => stored,
|
|
578
|
+
set: (s) => {
|
|
579
|
+
stored = s
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
//CLI限流埋在stderr(如Zen之FreeUsageLimitError), 內建429/TIMEOUT觸發不到,
|
|
583
|
+
//簽章由呼叫端以coolDetect注入判定
|
|
584
|
+
let pLimited = { id: 'cdx-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1', '--fake-stderr=FreeUsageLimitError: quota exceeded'] }
|
|
585
|
+
let pOk = { id: 'cdx-b', kind: 'claude', exe: fake.exe }
|
|
586
|
+
let coolDetect = (r) => /FreeUsageLimitError/i.test(r.stderr || '')
|
|
587
|
+
let r1 = await dispatchAiFallback('abc', { providers: [pLimited, pOk], store, cooldownMs: 300000, coolDetect })
|
|
588
|
+
let r2 = await dispatchAiFallback('abc', { providers: [pLimited, pOk], store, cooldownMs: 300000, coolDetect })
|
|
589
|
+
let r = [
|
|
590
|
+
r1.providerId,
|
|
591
|
+
typeof stored.cooling['cdx-a'],
|
|
592
|
+
r2.providerId,
|
|
593
|
+
r2.tried.map((x) => x.providerId), //次輪cdx-a降尾, 完全未被嘗試
|
|
594
|
+
]
|
|
595
|
+
let rr = ['cdx-b', 'number', 'cdx-b', ['cdx-b']]
|
|
596
|
+
assert.strict.deepEqual(r, rr)
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('coolDetect拋出例外視同false不觸發冷卻; meta為保留鍵不影響呼叫行為', async function() {
|
|
600
|
+
let stored = { cursors: {}, cooling: {} }
|
|
601
|
+
let store = {
|
|
602
|
+
get: () => stored,
|
|
603
|
+
set: (s) => {
|
|
604
|
+
stored = s
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
let t1 = await dispatchAiFallback('abc', {
|
|
608
|
+
providers: [
|
|
609
|
+
{ id: 'cde-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] },
|
|
610
|
+
{ id: 'cde-b', kind: 'claude', exe: fake.exe },
|
|
611
|
+
],
|
|
612
|
+
store,
|
|
613
|
+
cooldownMs: 300000,
|
|
614
|
+
coolDetect: () => {
|
|
615
|
+
throw new Error('callback error should not break the loop')
|
|
616
|
+
},
|
|
617
|
+
})
|
|
618
|
+
//條目與opt掛meta(保留鍵)不轉傳: 假CLI收到之args與stdin與未掛時完全一致
|
|
619
|
+
let t2 = await dispatchAiFallback('abc', {
|
|
620
|
+
providers: [{ id: 'mt-a', kind: 'claude', exe: fake.exe, model: 'sonnet', meta: { tag: 'draft' } }],
|
|
621
|
+
meta: { batch: 1 },
|
|
622
|
+
})
|
|
623
|
+
let o = JSON.parse(t2.stdout)
|
|
624
|
+
let r = [t1.ok, stored.cooling['cde-a'], t2.ok, o.args, o.stdin]
|
|
625
|
+
let rr = [true, undefined, true, ['-p', '--dangerously-skip-permissions', '--model', 'sonnet'], 'abc']
|
|
626
|
+
assert.strict.deepEqual(r, rr)
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('cooled事件於冷卻觸發時發出, tried與失敗事件帶機器可讀errorType', async function() {
|
|
630
|
+
let evs = []
|
|
631
|
+
let stored = { cursors: {}, cooling: {} }
|
|
632
|
+
let store = {
|
|
633
|
+
get: () => stored,
|
|
634
|
+
set: (s) => {
|
|
635
|
+
stored = s
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
let t = await dispatchAiFallback('abc', {
|
|
639
|
+
providers: [
|
|
640
|
+
{ id: 'ev-hang', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-sleep=9000'], timeoutMs: 500 }, //逾時 → 冷卻觸發
|
|
641
|
+
{ id: 'ev-ok', kind: 'claude', exe: fake.exe },
|
|
642
|
+
],
|
|
643
|
+
store,
|
|
644
|
+
cooldownMs: 300000,
|
|
645
|
+
onEvent: (ev) => evs.push(ev),
|
|
646
|
+
})
|
|
647
|
+
let evCooled = evs.find((x) => x.type === 'cooled')
|
|
648
|
+
let evSkip = evs.find((x) => x.type === 'skip-group')
|
|
649
|
+
let r = [
|
|
650
|
+
t.ok,
|
|
651
|
+
evCooled.providerId,
|
|
652
|
+
evCooled.cooldownMs,
|
|
653
|
+
evCooled.error.indexOf('TIMEOUT') === 0,
|
|
654
|
+
evSkip.errorType, //失敗事件帶機器可讀分類
|
|
655
|
+
t.tried[0].errorType, //tried歷程各項一併帶上
|
|
656
|
+
]
|
|
657
|
+
let rr = [true, 'ev-hang', 300000, true, 'timeout', 'timeout']
|
|
658
|
+
assert.strict.deepEqual(r, rr)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('最終失敗結果帶errorType: 一般執行失敗exec, 中止aborted, 預算用盡budget', async function() {
|
|
662
|
+
let t1 = await dispatchAiFallback('abc', {
|
|
663
|
+
providers: [{ id: 'et-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] }],
|
|
664
|
+
})
|
|
665
|
+
let t2 = await dispatchAiFallback('abc', {
|
|
666
|
+
providers: [{ id: 'et-b', kind: 'claude', exe: fake.exe }],
|
|
667
|
+
shouldStop: () => true,
|
|
668
|
+
})
|
|
669
|
+
let t3 = await dispatchAiFallback('abc', {
|
|
670
|
+
providers: [{ id: 'et-c', kind: 'claude', exe: fake.exe }],
|
|
671
|
+
budgetMs: 60000,
|
|
672
|
+
minAttemptMs: 600000,
|
|
673
|
+
})
|
|
674
|
+
let r = [[t1.ok, t1.errorType], [t2.ok, t2.errorType], [t3.ok, t3.errorType]]
|
|
675
|
+
let rr = [[false, 'exec'], [false, 'aborted'], [false, 'budget']]
|
|
676
|
+
assert.strict.deepEqual(r, rr)
|
|
677
|
+
})
|
|
678
|
+
|
|
447
679
|
})
|
|
@@ -245,6 +245,34 @@ describe('dispatchApiOpenaiCompat', function() {
|
|
|
245
245
|
assert.strict.deepEqual(r, rr)
|
|
246
246
|
})
|
|
247
247
|
|
|
248
|
+
it('HTTP 429觸發冷卻: 次輪該條目降至鏈尾', async function() {
|
|
249
|
+
let stored = { cursors: {}, cooling: {} }
|
|
250
|
+
let store = {
|
|
251
|
+
get: () => stored,
|
|
252
|
+
set: (s) => {
|
|
253
|
+
stored = s
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
//flaky-429對同一金鑰首次回429; maxRetries預設0故第1輪即失敗並觸發冷卻
|
|
257
|
+
let p429 = { id: 'cd-api-429', kind: 'api-openai-compat', baseURL: svr.url, model: 'flaky-429', keys: ['sk-good-cd429'] }
|
|
258
|
+
let pEcho = { id: 'cd-api-echo', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-good-cd-e'] }
|
|
259
|
+
let r1 = await dispatchAiFallback('abc', { providers: [p429, pEcho], store, cooldownMs: 300000 })
|
|
260
|
+
let r2 = await dispatchAiFallback('abc', { providers: [p429, pEcho], store, cooldownMs: 300000 })
|
|
261
|
+
let r = [
|
|
262
|
+
r1.providerId,
|
|
263
|
+
r1.tried.map((x) => [x.providerId, x.error || null]),
|
|
264
|
+
typeof stored.cooling['cd-api-429'],
|
|
265
|
+
r2.tried.map((x) => x.providerId), //429者已降尾, echo先上即成功
|
|
266
|
+
]
|
|
267
|
+
let rr = [
|
|
268
|
+
'cd-api-echo',
|
|
269
|
+
[['cd-api-429', 'HTTP 429'], ['cd-api-echo', null]],
|
|
270
|
+
'number',
|
|
271
|
+
['cd-api-echo'],
|
|
272
|
+
]
|
|
273
|
+
assert.strict.deepEqual(r, rr)
|
|
274
|
+
})
|
|
275
|
+
|
|
248
276
|
it('可作為工作流provider, 回覆JSON經解析與檢核', async function() {
|
|
249
277
|
let wkf = dispatchAiWkf({
|
|
250
278
|
providers: {
|
|
@@ -261,4 +289,43 @@ describe('dispatchApiOpenaiCompat', function() {
|
|
|
261
289
|
assert.strict.deepEqual(r, rr)
|
|
262
290
|
})
|
|
263
291
|
|
|
292
|
+
it('usage原樣透傳: 成功帶回應之token用量, 網路層與HTTP錯誤為null', async function() {
|
|
293
|
+
let t1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
|
|
294
|
+
let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'err-500' })
|
|
295
|
+
let r = [t1.ok, t1.usage, t2.ok, t2.usage]
|
|
296
|
+
let rr = [true, { prompt_tokens: 3, completion_tokens: 7, total_tokens: 10 }, false, null]
|
|
297
|
+
assert.strict.deepEqual(r, rr)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('usage經遞補層與工作流層流出於結果與tried, CLI路徑無此欄', async function() {
|
|
301
|
+
let tf = await dispatchAiFallback('abc', {
|
|
302
|
+
providers: [{ id: 'us-api', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-good-us'] }],
|
|
303
|
+
})
|
|
304
|
+
let wkf = dispatchAiWkf({
|
|
305
|
+
providers: {
|
|
306
|
+
'us-w-api': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-good-us2'] },
|
|
307
|
+
},
|
|
308
|
+
defaults: { promptPrefix: '' },
|
|
309
|
+
})
|
|
310
|
+
let tw = await wkf.callAi('abc', { spec: { use: 'us-w-api' } })
|
|
311
|
+
let r = [
|
|
312
|
+
tf.usage.total_tokens,
|
|
313
|
+
tf.tried[0].usage.total_tokens, //tried歷程各項一併帶上, 供加總實際耗用
|
|
314
|
+
tw.usage.total_tokens,
|
|
315
|
+
]
|
|
316
|
+
let rr = [10, 10, 10]
|
|
317
|
+
assert.strict.deepEqual(r, rr)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('errorType機器可讀分類: http/tool-unsupported/validation/timeout, 成功無此欄', async function() {
|
|
321
|
+
let t1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'err-500' })
|
|
322
|
+
let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'tool-calls' })
|
|
323
|
+
let t3 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: 'min:100000' })
|
|
324
|
+
let t4 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'slow', timeoutMs: 500 })
|
|
325
|
+
let t5 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
|
|
326
|
+
let r = [t1.errorType, t2.errorType, t3.errorType, t4.errorType, t5.errorType]
|
|
327
|
+
let rr = ['http', 'tool-unsupported', 'validation', 'timeout', undefined]
|
|
328
|
+
assert.strict.deepEqual(r, rr)
|
|
329
|
+
})
|
|
330
|
+
|
|
264
331
|
})
|
|
@@ -12,6 +12,7 @@ describe('getErrorResult', function() {
|
|
|
12
12
|
stderr: '',
|
|
13
13
|
code: null,
|
|
14
14
|
error: 'something wrong',
|
|
15
|
+
errorType: 'params',
|
|
15
16
|
durationMs: 0,
|
|
16
17
|
attempts: 0,
|
|
17
18
|
}
|
|
@@ -20,7 +21,18 @@ describe('getErrorResult', function() {
|
|
|
20
21
|
|
|
21
22
|
it('鍵名與順序固定', function() {
|
|
22
23
|
let r = Object.keys(getErrorResult('abc'))
|
|
23
|
-
let rr = ['ok', 'stdout', 'stderr', 'code', 'error', 'durationMs', 'attempts']
|
|
24
|
+
let rr = ['ok', 'stdout', 'stderr', 'code', 'error', 'errorType', 'durationMs', 'attempts']
|
|
25
|
+
assert.strict.deepEqual(r, rr)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('errorType可指定, 非有效字串回退params', function() {
|
|
29
|
+
let r = [
|
|
30
|
+
getErrorResult('x').errorType,
|
|
31
|
+
getErrorResult('x', 'aborted').errorType,
|
|
32
|
+
getErrorResult('x', null).errorType,
|
|
33
|
+
getErrorResult('x', '').errorType,
|
|
34
|
+
]
|
|
35
|
+
let rr = ['params', 'aborted', 'params', 'params']
|
|
24
36
|
assert.strict.deepEqual(r, rr)
|
|
25
37
|
})
|
|
26
38
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import getErrorType, { attachErrorType } from '../src/getErrorType.mjs'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('getErrorType', function() {
|
|
6
|
+
|
|
7
|
+
it('機械可判之類別: timeout/spawn/validation, 其餘歸exec', function() {
|
|
8
|
+
let r = [
|
|
9
|
+
getErrorType({ error: 'TIMEOUT after 300s' }),
|
|
10
|
+
getErrorType({ error: 'spawn my-cli ENOENT' }),
|
|
11
|
+
getErrorType({ error: 'ENAMETOOLONG: spawn error' }),
|
|
12
|
+
getErrorType({ error: 'OUTPUT_VALIDATION_FAILED' }),
|
|
13
|
+
getErrorType({ error: 'Exit code 1' }),
|
|
14
|
+
getErrorType({ error: 'Invalid API key.' }),
|
|
15
|
+
getErrorType({}),
|
|
16
|
+
getErrorType({ error: '' }),
|
|
17
|
+
]
|
|
18
|
+
let rr = ['timeout', 'spawn', 'spawn', 'validation', 'exec', 'exec', 'exec', 'exec']
|
|
19
|
+
assert.strict.deepEqual(r, rr)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('attachErrorType僅對失敗且未帶errorType之結果追加', function() {
|
|
23
|
+
let rOk = { ok: true, stdout: 'abc' }
|
|
24
|
+
let rFail = { ok: false, error: 'TIMEOUT after 10s' }
|
|
25
|
+
let rTyped = { ok: false, error: 'HTTP 429', errorType: 'http' }
|
|
26
|
+
let r = [
|
|
27
|
+
attachErrorType(rOk).errorType,
|
|
28
|
+
attachErrorType(rOk) === rOk, //成功結果原樣回傳
|
|
29
|
+
attachErrorType(rFail).errorType,
|
|
30
|
+
attachErrorType(rTyped).errorType, //已帶有效errorType則不覆寫
|
|
31
|
+
]
|
|
32
|
+
let rr = [undefined, true, 'timeout', 'http']
|
|
33
|
+
assert.strict.deepEqual(r, rr)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
})
|
|
@@ -156,6 +156,66 @@ describe('runFanout', function() {
|
|
|
156
156
|
assert.strict.deepEqual(r, rr)
|
|
157
157
|
})
|
|
158
158
|
|
|
159
|
+
it('integrate可帶獨立check(終稿判準), 未給則沿用頂層check', async function() {
|
|
160
|
+
//頂層check放行所有echo物件; integrate.check要求stdin含候選標記(僅整合者符合)
|
|
161
|
+
let t1 = await runFanout({
|
|
162
|
+
providers,
|
|
163
|
+
task: 'abc',
|
|
164
|
+
agents: [{ use: 'p-a' }, { use: 'p-b' }],
|
|
165
|
+
integrate: { use: 'p-int', check: (j) => j.stdin.includes('【候選 1】') },
|
|
166
|
+
check: (j) => Array.isArray(j.args),
|
|
167
|
+
callOpt,
|
|
168
|
+
})
|
|
169
|
+
//integrate.check不通過時整合失敗, 但候選不受影響(判準不再互相牽制)
|
|
170
|
+
let t2 = await runFanout({
|
|
171
|
+
providers,
|
|
172
|
+
task: 'abc',
|
|
173
|
+
agents: [{ use: 'p-a' }, { use: 'p-b' }],
|
|
174
|
+
integrate: { use: 'p-int', check: () => false },
|
|
175
|
+
check: (j) => Array.isArray(j.args),
|
|
176
|
+
callOpt,
|
|
177
|
+
})
|
|
178
|
+
let r = [
|
|
179
|
+
[t1.ok, t1.integrated],
|
|
180
|
+
[t2.ok, t2.integrated, t2.candidates.length, t2.error.indexOf('integrate failed:') === 0],
|
|
181
|
+
]
|
|
182
|
+
let rr = [
|
|
183
|
+
[true, true],
|
|
184
|
+
[false, false, 2, true],
|
|
185
|
+
]
|
|
186
|
+
assert.strict.deepEqual(r, rr)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('名額規格可帶獨立check覆寫頂層(曾為被靜默覆蓋之死鍵)', async function() {
|
|
190
|
+
let t = await runFanout({
|
|
191
|
+
providers,
|
|
192
|
+
task: 'abc',
|
|
193
|
+
agents: [
|
|
194
|
+
{ use: 'p-a', check: () => false }, //此名額專屬判準: 必不過
|
|
195
|
+
{ use: 'p-b' }, //沿用頂層(未給即預設放行)
|
|
196
|
+
],
|
|
197
|
+
integrate: { use: 'p-int' },
|
|
198
|
+
callOpt,
|
|
199
|
+
})
|
|
200
|
+
let r = [t.ok, t.integrated, t.agents[0].ok, t.agents[1].ok, t.candidates.length]
|
|
201
|
+
let rr = [true, false, false, true, 1] //單稿放行(integrated:false)
|
|
202
|
+
assert.strict.deepEqual(r, rr)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('meta為保留鍵: 名額與integrate規格掛meta不影響呼叫行為', async function() {
|
|
206
|
+
let t = await runFanout({
|
|
207
|
+
providers,
|
|
208
|
+
task: 'abc',
|
|
209
|
+
agents: [{ use: 'p-a', meta: { stage: 'draft' } }],
|
|
210
|
+
integrate: { use: 'p-int', meta: { stage: 'final' } },
|
|
211
|
+
callOpt,
|
|
212
|
+
})
|
|
213
|
+
//單稿放行, 假CLI收到之args與未掛meta時完全一致(meta未被轉傳成未知旗標)
|
|
214
|
+
let r = [t.ok, t.integrated, t.result.args]
|
|
215
|
+
let rr = [true, false, ['-p', '--dangerously-skip-permissions', '--model', 'sonnet']]
|
|
216
|
+
assert.strict.deepEqual(r, rr)
|
|
217
|
+
})
|
|
218
|
+
|
|
159
219
|
it('defaultIntegratePrompt嵌入候選數與schema', function() {
|
|
160
220
|
let s = defaultIntegratePrompt([{ a: 1 }, { b: 2 }], { schema: '{"x":"..."}' })
|
|
161
221
|
let r = [s.includes('2 個獨立執行'), s.includes('{"x":"..."}'), s.includes('【候選 2】')]
|
|
@@ -160,4 +160,16 @@ describe('runRolePipeline', function() {
|
|
|
160
160
|
assert.strict.deepEqual(r, rr)
|
|
161
161
|
})
|
|
162
162
|
|
|
163
|
+
it('meta為保留鍵: 階段規格掛meta(如draft/audit分類)不影響呼叫行為', async function() {
|
|
164
|
+
let t = await runRolePipeline({
|
|
165
|
+
providers,
|
|
166
|
+
stages: [{ id: 's1', use: 'p-a', prompt: () => 'abc', meta: { stage: 'draft' } }],
|
|
167
|
+
callOpt,
|
|
168
|
+
})
|
|
169
|
+
//假CLI收到之args與未掛meta時完全一致(meta未被轉傳成未知旗標)
|
|
170
|
+
let r = [t.ok, t.result.args, t.result.stdin]
|
|
171
|
+
let rr = [true, ['-p', '--dangerously-skip-permissions', '--model', 'sonnet'], 'abc']
|
|
172
|
+
assert.strict.deepEqual(r, rr)
|
|
173
|
+
})
|
|
174
|
+
|
|
163
175
|
})
|