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