w-dispatch-ai 1.0.2 → 1.0.4

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 (45) hide show
  1. package/README.md +123 -35
  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 +8 -4
  5. package/docs/adapters.mjs.html +43 -7
  6. package/docs/dispatchAi.mjs.html +2 -2
  7. package/docs/dispatchAiFallback.mjs.html +42 -8
  8. package/docs/dispatchAiWkf.mjs.html +197 -0
  9. package/docs/dispatchAntigravity.mjs.html +2 -2
  10. package/docs/dispatchApiOpenaiCompat.mjs.html +516 -0
  11. package/docs/dispatchClaude.mjs.html +2 -2
  12. package/docs/dispatchCodex.mjs.html +2 -2
  13. package/docs/dispatchOpencode.mjs.html +2 -2
  14. package/docs/getCliArgs.mjs.html +2 -2
  15. package/docs/getErrorResult.mjs.html +2 -2
  16. package/docs/global.html +5768 -1469
  17. package/docs/index.html +2 -2
  18. package/docs/wkf_callAiWithFallback.mjs.html +282 -0
  19. package/docs/wkf_extractJsonLoose.mjs.html +180 -0
  20. package/docs/wkf_runFanout.mjs.html +227 -0
  21. package/docs/wkf_runFanoutPipeline.mjs.html +178 -0
  22. package/docs/wkf_runRolePipeline.mjs.html +195 -0
  23. package/g.mjs +41 -25
  24. package/package.json +1 -1
  25. package/src/WDispatchAi.mjs +6 -2
  26. package/src/adapters.mjs +41 -5
  27. package/src/dispatchAiFallback.mjs +40 -6
  28. package/src/dispatchAiWkf.mjs +125 -0
  29. package/src/dispatchApiOpenaiCompat.mjs +444 -0
  30. package/src/wkf/callAiWithFallback.mjs +210 -0
  31. package/src/wkf/extractJsonLoose.mjs +108 -0
  32. package/src/wkf/runFanout.mjs +155 -0
  33. package/src/wkf/runFanoutPipeline.mjs +106 -0
  34. package/src/wkf/runRolePipeline.mjs +123 -0
  35. package/test/tools/fakeServerForApiTest.mjs +151 -0
  36. package/test/unit-WDispatchAi.test.mjs +13 -6
  37. package/test/unit-adapters.test.mjs +5 -3
  38. package/test/unit-callAiWithFallback.test.mjs +146 -0
  39. package/test/unit-dispatchAi.test.mjs +1 -1
  40. package/test/unit-dispatchAiWkf.test.mjs +118 -0
  41. package/test/unit-dispatchApiOpenaiCompat.test.mjs +264 -0
  42. package/test/unit-extractJsonLoose.test.mjs +78 -0
  43. package/test/unit-runFanout.test.mjs +166 -0
  44. package/test/unit-runFanoutPipeline.test.mjs +86 -0
  45. package/test/unit-runRolePipeline.test.mjs +163 -0
@@ -4,10 +4,12 @@ import map from 'lodash-es/map.js'
4
4
  import wi from '../src/WDispatchAi.mjs'
5
5
  import dispatchAi from '../src/dispatchAi.mjs'
6
6
  import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
7
+ import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
7
8
  import dispatchOpencode from '../src/dispatchOpencode.mjs'
8
9
  import dispatchClaude from '../src/dispatchClaude.mjs'
9
10
  import dispatchCodex from '../src/dispatchCodex.mjs'
10
11
  import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
12
+ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
11
13
 
12
14
 
13
15
  describe('WDispatchAi', function() {
@@ -18,23 +20,25 @@ describe('WDispatchAi', function() {
18
20
  'KINDS',
19
21
  'dispatchAi',
20
22
  'dispatchAiFallback',
23
+ 'dispatchAiWkf',
21
24
  'dispatchOpencode',
22
25
  'dispatchClaude',
23
26
  'dispatchCodex',
24
27
  'dispatchAntigravity',
28
+ 'dispatchApiOpenaiCompat',
25
29
  ]
26
30
  assert.strict.deepEqual(r, rr)
27
31
  })
28
32
 
29
33
  it('KINDS為可用之供應商種類字串陣列', function() {
30
34
  let r = wi.KINDS
31
- let rr = ['opencode', 'claude', 'codex', 'antigravity']
35
+ let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']
32
36
  assert.strict.deepEqual(r, rr)
33
37
  })
34
38
 
35
39
  it('除KINDS外各鍵值皆為函數', function() {
36
40
  let r = map(keys(wi), (k) => typeof wi[k])
37
- let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function']
41
+ let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function']
38
42
  assert.strict.deepEqual(r, rr)
39
43
  })
40
44
 
@@ -42,12 +46,14 @@ describe('WDispatchAi', function() {
42
46
  let r = [
43
47
  wi.dispatchAi === dispatchAi,
44
48
  wi.dispatchAiFallback === dispatchAiFallback,
49
+ wi.dispatchAiWkf === dispatchAiWkf,
45
50
  wi.dispatchOpencode === dispatchOpencode,
46
51
  wi.dispatchClaude === dispatchClaude,
47
52
  wi.dispatchCodex === dispatchCodex,
48
53
  wi.dispatchAntigravity === dispatchAntigravity,
54
+ wi.dispatchApiOpenaiCompat === dispatchApiOpenaiCompat,
49
55
  ]
50
- let rr = [true, true, true, true, true, true]
56
+ let rr = [true, true, true, true, true, true, true, true]
51
57
  assert.strict.deepEqual(r, rr)
52
58
  })
53
59
 
@@ -57,20 +63,20 @@ describe('WDispatchAi', function() {
57
63
  let t = await wi.dispatchAi(kind, '')
58
64
  r.push(t.error)
59
65
  }
60
- 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']
66
+ 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']
61
67
  assert.strict.deepEqual(r, rr)
62
68
  })
63
69
 
64
70
  it('可由WDispatchAi呼叫dispatchAi', async function() {
65
71
  let t = await wi.dispatchAi('gemini', 'abc')
66
72
  let r = [t.ok, t.error]
67
- let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity)']
73
+ let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)']
68
74
  assert.strict.deepEqual(r, rr)
69
75
  })
70
76
 
71
77
  it('可由WDispatchAi呼叫各轉接器', async function() {
72
78
  let r = []
73
- for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity]) {
79
+ for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity, wi.dispatchApiOpenaiCompat]) {
74
80
  let t = await fn('')
75
81
  r.push([t.ok, t.error])
76
82
  }
@@ -79,6 +85,7 @@ describe('WDispatchAi', function() {
79
85
  [false, 'prompt must be a non-empty string'],
80
86
  [false, 'prompt must be a non-empty string'],
81
87
  [false, 'prompt must be a non-empty string'],
88
+ [false, 'prompt must be a non-empty string'],
82
89
  ]
83
90
  assert.strict.deepEqual(r, rr)
84
91
  })
@@ -6,19 +6,20 @@ import dispatchOpencode from '../src/dispatchOpencode.mjs'
6
6
  import dispatchClaude from '../src/dispatchClaude.mjs'
7
7
  import dispatchCodex from '../src/dispatchCodex.mjs'
8
8
  import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
9
+ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
9
10
 
10
11
 
11
12
  describe('adapters', function() {
12
13
 
13
14
  it('對照表鍵名即為可用之供應商種類', function() {
14
15
  let r = keys(adapters)
15
- let rr = ['opencode', 'claude', 'codex', 'antigravity']
16
+ let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']
16
17
  assert.strict.deepEqual(r, rr)
17
18
  })
18
19
 
19
20
  it('各鍵值皆為函數', function() {
20
21
  let r = map(keys(adapters), (k) => typeof adapters[k])
21
- let rr = ['function', 'function', 'function', 'function']
22
+ let rr = ['function', 'function', 'function', 'function', 'function']
22
23
  assert.strict.deepEqual(r, rr)
23
24
  })
24
25
 
@@ -28,8 +29,9 @@ describe('adapters', function() {
28
29
  adapters.claude === dispatchClaude,
29
30
  adapters.codex === dispatchCodex,
30
31
  adapters.antigravity === dispatchAntigravity,
32
+ adapters['api-openai-compat'] === dispatchApiOpenaiCompat,
31
33
  ]
32
- let rr = [true, true, true, true]
34
+ let rr = [true, true, true, true, true]
33
35
  assert.strict.deepEqual(r, rr)
34
36
  })
35
37
 
@@ -0,0 +1,146 @@
1
+ import assert from 'assert'
2
+ import callAiWithFallback, { buildChain, NO_SIDE_EFFECT } from '../src/wkf/callAiWithFallback.mjs'
3
+ import createFakeCli from './tools/fakeCliForTest.mjs'
4
+
5
+
6
+ describe('callAiWithFallback', function() {
7
+
8
+ let fake = null
9
+ let providers = null
10
+
11
+ before(function() {
12
+ fake = createFakeCli('fake-wkf-call')
13
+ providers = {
14
+ 'p-claude': { kind: 'claude', exe: fake.exe, model: 'sonnet' },
15
+ 'p-codex': { kind: 'codex', exe: fake.exe, model: 'gpt-5.6-luna' },
16
+ 'p-oc': { kind: 'opencode', exe: fake.exe, model: 'opencode/free', provider: 'opencode', keys: ['sk-c0', 'sk-c1'] },
17
+ 'p-dead': { kind: 'claude', exe: fake.exe, model: 'sonnet', extraArgs: ['--fake-exit=1'] },
18
+ 'p-noexe': { kind: 'claude', exe: 'wkf-no-such-cli' },
19
+ }
20
+ })
21
+
22
+ after(function() {
23
+ if (fake) {
24
+ fake.clean()
25
+ }
26
+ })
27
+
28
+ it('buildChain以名稱展開條目並回報查無定義之名稱', function() {
29
+ let { chain, missing } = buildChain(providers, { use: 'p-claude', fallback: ['p-codex', 'p-typo'] })
30
+ let r = [chain.map((x) => x.id), missing]
31
+ let rr = [['p-claude', 'p-codex'], ['p-typo']]
32
+ assert.strict.deepEqual(r, rr)
33
+ })
34
+
35
+ it('prompt非有效字串時回傳錯誤結果物件', async function() {
36
+ let t = await callAiWithFallback('', { providers, spec: { use: 'p-claude' } })
37
+ let r = [t.ok, t.json, t.error, t.tried]
38
+ let rr = [false, null, 'prompt must be a non-empty string', []]
39
+ assert.strict.deepEqual(r, rr)
40
+ })
41
+
42
+ it('providers或spec無效時回傳錯誤結果物件', async function() {
43
+ let r = []
44
+ for (let [p, s] of [[null, { use: 'x' }], [providers, null], [providers, 'abc']]) {
45
+ let t = await callAiWithFallback('abc', { providers: p, spec: s })
46
+ r.push([t.ok, t.error.indexOf('no valid provider') === 0])
47
+ }
48
+ let rr = [[false, true], [false, true], [false, true]]
49
+ assert.strict.deepEqual(r, rr)
50
+ })
51
+
52
+ it('名稱查無定義時fail fast回報, 不靜默略過', async function() {
53
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude', fallback: ['p-typo1', 'p-codex', 'p-typo2'] } })
54
+ let r = [t.ok, t.error]
55
+ let rr = [false, 'unknown provider name(s): p-typo1, p-typo2']
56
+ assert.strict.deepEqual(r, rr)
57
+ })
58
+
59
+ it('成功時回傳解析後json與名稱式providerId', async function() {
60
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '' })
61
+ let r = [t.ok, t.providerId, t.keyIndex, t.keyId, t.json.args, t.json.stdin]
62
+ let rr = [true, 'p-claude', null, 'p-claude', ['-p', '--dangerously-skip-permissions', '--model', 'sonnet'], 'abc']
63
+ assert.strict.deepEqual(r, rr)
64
+ })
65
+
66
+ it('預設掛防寫檔前綴, promptPrefix傳空字串可關閉, 自訂前綴可覆寫', async function() {
67
+ let t1 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' } })
68
+ let t2 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '' })
69
+ let t3 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: 'PREFIX:' })
70
+ let r = [t1.json.stdin, t2.json.stdin, t3.json.stdin]
71
+ let rr = [NO_SIDE_EFFECT + 'abc', 'abc', 'PREFIX:abc']
72
+ assert.strict.deepEqual(r, rr)
73
+ })
74
+
75
+ it('check未過視為該家失敗, 遞補層自動換下一家', async function() {
76
+ //check要求args含opencode之run旗標 → p-claude不合(換家) → p-oc合
77
+ let t = await callAiWithFallback('abc', {
78
+ providers,
79
+ spec: { use: 'p-claude', fallback: ['p-oc'] },
80
+ check: (j) => j.args[0] === 'run',
81
+ promptPrefix: '',
82
+ })
83
+ let r = [t.ok, t.providerId, t.keyIndex, t.keyId, t.tried.map((x) => [x.providerId, x.outcome])]
84
+ let rr = [true, 'p-oc', 0, 'p-oc#0', [['p-claude', 'skip-group'], ['p-oc', 'ok']]]
85
+ assert.strict.deepEqual(r, rr)
86
+ })
87
+
88
+ it('CLI失敗(exit 1)時遞補至下一家', async function() {
89
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-dead', fallback: ['p-codex'] }, promptPrefix: '' })
90
+ let r = [t.ok, t.providerId, t.tried.map((x) => [x.providerId, x.outcome])]
91
+ let rr = [true, 'p-codex', [['p-dead', 'next-key'], ['p-codex', 'ok']]]
92
+ assert.strict.deepEqual(r, rr)
93
+ })
94
+
95
+ it('全數失敗時回傳error與完整tried', async function() {
96
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-dead', fallback: ['p-noexe'] }, promptPrefix: '' })
97
+ let r = [t.ok, t.json, t.error !== '', t.tried.length]
98
+ let rr = [false, null, true, 2]
99
+ assert.strict.deepEqual(r, rr)
100
+ })
101
+
102
+ it('rawText模式不解析JSON, json欄為修剪後文字', async function() {
103
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, rawText: true, promptPrefix: '' })
104
+ let r = [t.ok, typeof t.json, JSON.parse(t.json).stdin]
105
+ let rr = [true, 'string', 'abc']
106
+ assert.strict.deepEqual(r, rr)
107
+ })
108
+
109
+ it('rawText模式check收文字', async function() {
110
+ let seen = []
111
+ let t = await callAiWithFallback('abc', {
112
+ providers,
113
+ spec: { use: 'p-claude' },
114
+ rawText: true,
115
+ check: (s) => {
116
+ seen.push(typeof s)
117
+ return s.includes('"stdin"')
118
+ },
119
+ promptPrefix: '',
120
+ })
121
+ let r = [t.ok, seen.includes('string')]
122
+ let rr = [true, true]
123
+ assert.strict.deepEqual(r, rr)
124
+ })
125
+
126
+ it('自訂parse可覆寫預設解析器', async function() {
127
+ let t = await callAiWithFallback('abc', {
128
+ providers,
129
+ spec: { use: 'p-claude' },
130
+ parse: (stdout) => ({ wrapped: JSON.parse(stdout).stdin }),
131
+ promptPrefix: '',
132
+ })
133
+ let r = t.json
134
+ let rr = { wrapped: 'abc' }
135
+ assert.strict.deepEqual(r, rr)
136
+ })
137
+
138
+ it('多金鑰條目經由keys輪替且keyId帶索引', async function() {
139
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-oc' }, promptPrefix: '' })
140
+ let auth = JSON.parse(t.json.env.OPENCODE_AUTH_CONTENT)
141
+ let r = [t.ok, t.providerId, typeof t.keyIndex, t.keyId.indexOf('p-oc#') === 0, ['sk-c0', 'sk-c1'].includes(auth.opencode.key)]
142
+ let rr = [true, 'p-oc', 'number', true, true]
143
+ assert.strict.deepEqual(r, rr)
144
+ })
145
+
146
+ })
@@ -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)', 0]
23
+ let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)', 0]
24
24
  assert.strict.deepEqual(r, rr)
25
25
  })
26
26
 
@@ -0,0 +1,118 @@
1
+ import assert from 'assert'
2
+ import keys from 'lodash-es/keys.js'
3
+ import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
4
+ import { NO_SIDE_EFFECT } from '../src/wkf/callAiWithFallback.mjs'
5
+ import createFakeCli from './tools/fakeCliForTest.mjs'
6
+
7
+
8
+ describe('dispatchAiWkf', function() {
9
+
10
+ let fake = null
11
+ let providers = null
12
+
13
+ before(function() {
14
+ fake = createFakeCli('fake-wkf-factory')
15
+ providers = {
16
+ 'p-a': { kind: 'claude', exe: fake.exe, model: 'sonnet' },
17
+ 'p-b': { kind: 'codex', exe: fake.exe, model: 'gpt-5.6-luna' },
18
+ 'p-int': { kind: 'opencode', exe: fake.exe, model: 'opencode/free' },
19
+ }
20
+ })
21
+
22
+ after(function() {
23
+ if (fake) {
24
+ fake.clean()
25
+ }
26
+ })
27
+
28
+ it('providers無效時throw(設定錯誤於啟動期即失敗)', function() {
29
+ let r = []
30
+ for (let providers of [undefined, null, 'abc', 123, []]) {
31
+ try {
32
+ dispatchAiWkf({ providers })
33
+ r.push('no-throw')
34
+ }
35
+ catch (err) {
36
+ r.push(err.message.indexOf('dispatchAiWkf:') === 0)
37
+ }
38
+ }
39
+ let rr = [true, true, true, true, true]
40
+ assert.strict.deepEqual(r, rr)
41
+ })
42
+
43
+ it('回傳綁定版API物件', function() {
44
+ let wkf = dispatchAiWkf({ providers })
45
+ let r = [
46
+ keys(wkf),
47
+ wkf.providers === providers,
48
+ typeof wkf.callAi,
49
+ typeof wkf.runFanout,
50
+ typeof wkf.runRolePipeline,
51
+ typeof wkf.runFanoutPipeline,
52
+ ]
53
+ let rr = [
54
+ ['providers', 'callAi', 'runFanout', 'runRolePipeline', 'runFanoutPipeline'],
55
+ true,
56
+ 'function',
57
+ 'function',
58
+ 'function',
59
+ 'function',
60
+ ]
61
+ assert.strict.deepEqual(r, rr)
62
+ })
63
+
64
+ it('callAi綁定providers, 以名稱宣告即可呼叫', async function() {
65
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
66
+ let t = await wkf.callAi('abc', { spec: { use: 'p-a' } })
67
+ let r = [t.ok, t.providerId, t.json.stdin]
68
+ let rr = [true, 'p-a', 'abc']
69
+ assert.strict.deepEqual(r, rr)
70
+ })
71
+
72
+ it('defaults為共用預設且呼叫時可逐項覆寫', async function() {
73
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: 'DEF:' } })
74
+ let t1 = await wkf.callAi('abc', { spec: { use: 'p-a' } })
75
+ let t2 = await wkf.callAi('abc', { spec: { use: 'p-a' }, promptPrefix: 'OVR:' })
76
+ let r = [t1.json.stdin, t2.json.stdin]
77
+ let rr = ['DEF:abc', 'OVR:abc']
78
+ assert.strict.deepEqual(r, rr)
79
+ })
80
+
81
+ it('未給defaults時預設掛防寫檔前綴', async function() {
82
+ let wkf = dispatchAiWkf({ providers })
83
+ let t = await wkf.callAi('abc', { spec: { use: 'p-a' } })
84
+ let r = t.json.stdin
85
+ let rr = NO_SIDE_EFFECT + 'abc'
86
+ assert.strict.deepEqual(r, rr)
87
+ })
88
+
89
+ it('runFanout綁定providers與defaults(defaults併入callOpt)', async function() {
90
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
91
+ let t = await wkf.runFanout({
92
+ task: 'abc',
93
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
94
+ integrate: { use: 'p-int' },
95
+ })
96
+ let r = [t.ok, t.integrated, t.result.stdin.includes('【候選 1】')]
97
+ let rr = [true, true, true]
98
+ assert.strict.deepEqual(r, rr)
99
+ })
100
+
101
+ it('runRolePipeline與runFanoutPipeline皆已綁定可用', async function() {
102
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
103
+ let t1 = await wkf.runRolePipeline({
104
+ input: 'x',
105
+ stages: [{ id: 's1', use: 'p-a', prompt: (ctx) => `on ${ctx.input}` }],
106
+ })
107
+ let t2 = await wkf.runFanoutPipeline({
108
+ task: 'abc',
109
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
110
+ integrate: { use: 'p-int' },
111
+ stages: [{ id: 's1', use: 'p-a', prompt: () => 'y' }],
112
+ })
113
+ let r = [t1.ok, t1.results.s1.stdin, t2.ok, t2.B.order]
114
+ let rr = [true, 'on x', true, ['s1']]
115
+ assert.strict.deepEqual(r, rr)
116
+ })
117
+
118
+ })
@@ -0,0 +1,264 @@
1
+ import assert from 'assert'
2
+ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.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('dispatchApiOpenaiCompat', 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('prompt非有效字串時回傳錯誤結果物件且不reject', async function() {
24
+ let r = []
25
+ for (let prompt of [null, undefined, '', 123, {}, []]) {
26
+ let t = await dispatchApiOpenaiCompat(prompt)
27
+ r.push([t.ok, t.error, t.attempts])
28
+ }
29
+ let rr = [
30
+ [false, 'prompt must be a non-empty string', 0],
31
+ [false, 'prompt must be a non-empty string', 0],
32
+ [false, 'prompt must be a non-empty string', 0],
33
+ [false, 'prompt must be a non-empty string', 0],
34
+ [false, 'prompt must be a non-empty string', 0],
35
+ [false, 'prompt must be a non-empty string', 0],
36
+ ]
37
+ assert.strict.deepEqual(r, rr)
38
+ })
39
+
40
+ it('baseURL與model必填, 缺一即回傳錯誤結果物件', async function() {
41
+ let t1 = await dispatchApiOpenaiCompat('abc', { model: 'echo' })
42
+ let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url })
43
+ let r = [[t1.ok, t1.error], [t2.ok, t2.error]]
44
+ let rr = [
45
+ [false, 'baseURL must be a non-empty string'],
46
+ [false, 'model must be a non-empty string'],
47
+ ]
48
+ assert.strict.deepEqual(r, rr)
49
+ })
50
+
51
+ it('成功時stdout為回覆內容, 請求含Bearer金鑰與model與user訊息', async function() {
52
+ let t = await dispatchApiOpenaiCompat('中文prompt\n第二行', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
53
+ let o = JSON.parse(t.stdout)
54
+ let r = [
55
+ t.ok,
56
+ t.code,
57
+ t.error,
58
+ t.attempts,
59
+ o.auth,
60
+ o.body.model,
61
+ o.body.messages,
62
+ ]
63
+ let rr = [
64
+ true,
65
+ 200,
66
+ '',
67
+ 1,
68
+ 'Bearer sk-good-1',
69
+ 'echo',
70
+ [{ role: 'user', content: '中文prompt\n第二行' }],
71
+ ]
72
+ assert.strict.deepEqual(r, rr)
73
+ })
74
+
75
+ it('未給key時不帶Authorization標頭', async function() {
76
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, model: 'echo' })
77
+ let o = JSON.parse(t.stdout)
78
+ let r = [t.ok, o.auth]
79
+ let rr = [true, '']
80
+ assert.strict.deepEqual(r, rr)
81
+ })
82
+
83
+ it('baseURL尾端斜線自動正規化', async function() {
84
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url + '///', key: 'sk-good-1', model: 'echo' })
85
+ let r = [t.ok, t.code]
86
+ let rr = [true, 200]
87
+ assert.strict.deepEqual(r, rr)
88
+ })
89
+
90
+ it('system置於messages首位, body額外鍵可併入', async function() {
91
+ let t = await dispatchApiOpenaiCompat('abc', {
92
+ baseURL: svr.url,
93
+ key: 'sk-good-1',
94
+ model: 'echo',
95
+ system: '你是審計者',
96
+ body: { temperature: 0.2, max_tokens: 100 },
97
+ })
98
+ let o = JSON.parse(t.stdout)
99
+ let r = [
100
+ o.body.messages,
101
+ o.body.temperature,
102
+ o.body.max_tokens,
103
+ ]
104
+ let rr = [
105
+ [{ role: 'system', content: '你是審計者' }, { role: 'user', content: 'abc' }],
106
+ 0.2,
107
+ 100,
108
+ ]
109
+ assert.strict.deepEqual(r, rr)
110
+ })
111
+
112
+ it('無效金鑰回401且不重試(4xx為客戶端錯誤)', async function() {
113
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-bad-x', model: 'echo', maxRetries: 3, retryDelayMs: 100 })
114
+ let r = [t.ok, t.code, t.error, t.attempts, t.stderr.includes('Invalid API key')]
115
+ let rr = [false, 401, 'HTTP 401', 1, true]
116
+ assert.strict.deepEqual(r, rr)
117
+ })
118
+
119
+ it('429依maxRetries重試後成功', async function() {
120
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-flaky1', model: 'flaky-429', maxRetries: 1, retryDelayMs: 100 })
121
+ let r = [t.ok, t.code, t.attempts, JSON.parse(t.stdout).attempt]
122
+ let rr = [true, 200, 2, 2]
123
+ assert.strict.deepEqual(r, rr)
124
+ })
125
+
126
+ it('429且maxRetries為0時直接失敗', async function() {
127
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-flaky2', model: 'flaky-429' })
128
+ let r = [t.ok, t.code, t.error, t.attempts]
129
+ let rr = [false, 429, 'HTTP 429', 1]
130
+ assert.strict.deepEqual(r, rr)
131
+ })
132
+
133
+ it('HTTP 500回傳失敗且stderr含回應本體', async function() {
134
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'err-500' })
135
+ let r = [t.ok, t.code, t.error, t.stderr.includes('internal error')]
136
+ let rr = [false, 500, 'HTTP 500', true]
137
+ assert.strict.deepEqual(r, rr)
138
+ })
139
+
140
+ it('逾時時中止請求且error以TIMEOUT開頭', async function() {
141
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'slow', timeoutMs: 1000 })
142
+ let r = [t.ok, t.code, t.error, t.durationMs < 9000]
143
+ let rr = [false, null, 'TIMEOUT after 1s', true]
144
+ assert.strict.deepEqual(r, rr)
145
+ })
146
+
147
+ it('網路層錯誤回傳FETCH_ERROR', async function() {
148
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: 'http://127.0.0.1:1/v1', key: 'sk-good-1', model: 'echo', timeoutMs: 3000 })
149
+ let r = [t.ok, t.code, t.error.indexOf('FETCH_ERROR') === 0]
150
+ let rr = [false, null, true]
151
+ assert.strict.deepEqual(r, rr)
152
+ })
153
+
154
+ it('回應缺choices時回傳INVALID_RESPONSE', async function() {
155
+ let r = []
156
+ for (let model of ['no-choices', 'not-json']) {
157
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model })
158
+ r.push([t.ok, t.code, t.error.indexOf('INVALID_RESPONSE') === 0])
159
+ }
160
+ let rr = [[false, 200, true], [false, 200, true]]
161
+ assert.strict.deepEqual(r, rr)
162
+ })
163
+
164
+ it('validate字串規則與自訂函數皆可用, 失敗回傳OUTPUT_VALIDATION_FAILED', async function() {
165
+ let t1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'empty-content', validate: 'nonempty' })
166
+ let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: 'nonempty,json' })
167
+ let t3 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: (s) => s.includes('絕不存在的字串') })
168
+ let r = [
169
+ [t1.ok, t1.code, t1.error],
170
+ [t2.ok, t2.error],
171
+ [t3.ok, t3.error],
172
+ ]
173
+ let rr = [
174
+ [false, 200, 'OUTPUT_VALIDATION_FAILED'],
175
+ [true, ''],
176
+ [false, 'OUTPUT_VALIDATION_FAILED'],
177
+ ]
178
+ assert.strict.deepEqual(r, rr)
179
+ })
180
+
181
+ it('模型回tool_calls時明確回報不支援而非靜默成功', async function() {
182
+ let t = await dispatchApiOpenaiCompat('台北天氣如何', {
183
+ baseURL: svr.url,
184
+ key: 'sk-good-1',
185
+ model: 'tool-calls',
186
+ body: { tools: [{ type: 'function', function: { name: 'get_weather', parameters: {} } }] },
187
+ })
188
+ let r = [
189
+ t.ok,
190
+ t.code,
191
+ t.stdout,
192
+ t.error.indexOf('TOOL_CALLS_UNSUPPORTED') === 0,
193
+ t.error.includes('cli kind'),
194
+ t.stderr.includes('get_weather'), //原始回應保留供除錯
195
+ ]
196
+ let rr = [false, 200, '', true, true, true]
197
+ assert.strict.deepEqual(r, rr)
198
+ })
199
+
200
+ it('tool_calls為與金鑰無關之失敗, 於fallback鏈中不逐把空耗而遞補下一組', async function() {
201
+ let t = await dispatchAiFallback('abc', {
202
+ providers: [
203
+ { id: 'g-tool', kind: 'api-openai-compat', baseURL: svr.url, model: 'tool-calls', keys: ['sk-t0', 'sk-t1'] },
204
+ { id: 'g-text', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-x0'] },
205
+ ],
206
+ })
207
+ let r = [t.ok, t.providerId, t.tried.map((x) => [x.keyId, x.outcome])]
208
+ let rr = [true, 'g-text', [['g-tool#0', 'next-key'], ['g-tool#1', 'next-key'], ['g-text#0', 'ok']]]
209
+ assert.strict.deepEqual(r, rr)
210
+ })
211
+
212
+ it('可經dispatchAi以kind api-openai-compat分派', async function() {
213
+ let t = await dispatchAi('api-openai-compat', 'abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
214
+ let o = JSON.parse(t.stdout)
215
+ let r = [t.ok, o.body.model]
216
+ let rr = [true, 'echo']
217
+ assert.strict.deepEqual(r, rr)
218
+ })
219
+
220
+ it('可作為dispatchAiFallback條目, 多金鑰輪替(401換下一把)', async function() {
221
+ let t = await dispatchAiFallback('abc', {
222
+ providers: [{
223
+ id: 'g-api',
224
+ kind: 'api-openai-compat',
225
+ baseURL: svr.url,
226
+ model: 'echo',
227
+ keys: ['sk-bad-r0', 'sk-good-r1'],
228
+ }],
229
+ })
230
+ let o = JSON.parse(t.stdout)
231
+ let r = [
232
+ t.ok,
233
+ t.providerId,
234
+ t.keyIndex,
235
+ o.auth,
236
+ t.tried.map((x) => [x.keyId, x.outcome, x.error || null]),
237
+ ]
238
+ let rr = [
239
+ true,
240
+ 'g-api',
241
+ 1,
242
+ 'Bearer sk-good-r1',
243
+ [['g-api#0', 'next-key', 'HTTP 401'], ['g-api#1', 'ok', null]],
244
+ ]
245
+ assert.strict.deepEqual(r, rr)
246
+ })
247
+
248
+ it('可作為工作流provider, 回覆JSON經解析與檢核', async function() {
249
+ let wkf = dispatchAiWkf({
250
+ providers: {
251
+ 'api-echo': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-good-w0'] },
252
+ },
253
+ defaults: { promptPrefix: '' },
254
+ })
255
+ let t = await wkf.callAi('abc', {
256
+ spec: { use: 'api-echo' },
257
+ check: (j) => j.body.model === 'echo',
258
+ })
259
+ let r = [t.ok, t.providerId, t.keyId, t.json.auth, t.json.body.messages[0].content]
260
+ let rr = [true, 'api-echo', 'api-echo#0', 'Bearer sk-good-w0', 'abc']
261
+ assert.strict.deepEqual(r, rr)
262
+ })
263
+
264
+ })