w-dispatch-ai 1.0.25 → 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.
- package/README.md +56 -4
- package/dist/w-dispatch-ai.umd.js +2 -2
- package/dist/w-dispatch-ai.umd.js.map +1 -1
- package/docs/WDispatchAi.mjs.html +6 -4
- package/docs/adapters.mjs.html +10 -3
- package/docs/budgetFor.mjs.html +2 -2
- package/docs/buildValidator.mjs.html +2 -2
- 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 +2 -2
- package/docs/dispatchApiOpenaiResponses.mjs.html +2 -2
- package/docs/dispatchApiTypesafeSystemone.mjs.html +447 -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 +4 -3
- package/docs/global.html +1221 -254
- package/docs/index.html +2 -2
- package/docs/quota_dfQuotaTimeoutMs.mjs.html +2 -2
- package/docs/quota_fetchQuotaJson.mjs.html +2 -2
- package/docs/quota_fromCodexUsageHttp.mjs.html +2 -2
- package/docs/quota_getQuotaAntigravity.mjs.html +2 -2
- package/docs/quota_getQuotaClaude.mjs.html +2 -2
- package/docs/quota_getQuotaCodex.mjs.html +2 -2
- package/docs/quota_readJsonOrNull.mjs.html +2 -2
- package/docs/quota_toQuotaLabel.mjs.html +2 -2
- package/docs/quota_toQuotaResult.mjs.html +2 -2
- package/docs/quota_toQuotaScopedLabel.mjs.html +2 -2
- package/docs/quota_toQuotaWindow.mjs.html +2 -2
- package/docs/readEnvFile.mjs.html +2 -2
- package/docs/resolveProviders.mjs.html +2 -2
- 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 +4 -2
- package/src/adapters.mjs +8 -1
- package/src/dispatchApiTypesafeSystemone.mjs +375 -0
- package/src/getErrorType.mjs +2 -1
- package/src/providers.mjs +32 -1
- package/test/tools/fakeServerForApiTest.mjs +75 -5
- 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-dispatchApiTypesafeSystemone.test.mjs +159 -0
- package/test/unit-providers.test.mjs +7 -0
- package/test/unit-resolveProviders.test.mjs +2 -2
|
@@ -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
|
-
//
|
|
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/
|
|
61
|
+
//僅受理POST /v1/chat/completions、POST /v1/responses與POST /v1/systemone
|
|
54
62
|
let isResponses = req.url.endsWith('/responses')
|
|
55
|
-
|
|
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
|
-
|
|
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) => {
|
|
@@ -11,6 +11,7 @@ import dispatchCodex from '../src/dispatchCodex.mjs'
|
|
|
11
11
|
import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
|
|
12
12
|
import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
|
|
13
13
|
import dispatchApiOpenaiResponses from '../src/dispatchApiOpenaiResponses.mjs'
|
|
14
|
+
import dispatchApiTypesafeSystemone from '../src/dispatchApiTypesafeSystemone.mjs'
|
|
14
15
|
import providers from '../src/providers.mjs'
|
|
15
16
|
import resolveProviders from '../src/resolveProviders.mjs'
|
|
16
17
|
import getQuotaClaude from '../src/quota/getQuotaClaude.mjs'
|
|
@@ -34,6 +35,7 @@ describe('WDispatchAi', function() {
|
|
|
34
35
|
'dispatchAntigravity',
|
|
35
36
|
'dispatchApiOpenaiCompat',
|
|
36
37
|
'dispatchApiOpenaiResponses',
|
|
38
|
+
'dispatchApiTypesafeSystemone',
|
|
37
39
|
'providers',
|
|
38
40
|
'resolveProviders',
|
|
39
41
|
'readEnvFile',
|
|
@@ -50,13 +52,13 @@ describe('WDispatchAi', function() {
|
|
|
50
52
|
|
|
51
53
|
it('KINDS為可用之供應商種類字串陣列', function() {
|
|
52
54
|
let r = wi.KINDS
|
|
53
|
-
let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat', 'api-openai-responses']
|
|
55
|
+
let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat', 'api-openai-responses', 'api-typesafe-systemone']
|
|
54
56
|
assert.strict.deepEqual(r, rr)
|
|
55
57
|
})
|
|
56
58
|
|
|
57
59
|
it('各鍵值型別正確(KINDS與providers為物件, NO_SIDE_EFFECT為字串, 其餘為函數)', function() {
|
|
58
60
|
let r = map(keys(wi), (k) => typeof wi[k])
|
|
59
|
-
let rr = ['object', 'string', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function']
|
|
61
|
+
let rr = ['object', 'string', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function']
|
|
60
62
|
assert.strict.deepEqual(r, rr)
|
|
61
63
|
})
|
|
62
64
|
|
|
@@ -71,13 +73,14 @@ describe('WDispatchAi', function() {
|
|
|
71
73
|
wi.dispatchAntigravity === dispatchAntigravity,
|
|
72
74
|
wi.dispatchApiOpenaiCompat === dispatchApiOpenaiCompat,
|
|
73
75
|
wi.dispatchApiOpenaiResponses === dispatchApiOpenaiResponses,
|
|
76
|
+
wi.dispatchApiTypesafeSystemone === dispatchApiTypesafeSystemone,
|
|
74
77
|
wi.providers === providers,
|
|
75
78
|
wi.resolveProviders === resolveProviders,
|
|
76
79
|
wi.getQuotaClaude === getQuotaClaude,
|
|
77
80
|
wi.getQuotaCodex === getQuotaCodex,
|
|
78
81
|
wi.getQuotaAntigravity === getQuotaAntigravity,
|
|
79
82
|
]
|
|
80
|
-
let rr = [true, true, true, true, true, true, true, true, true, true, true, true, true, true]
|
|
83
|
+
let rr = [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true]
|
|
81
84
|
assert.strict.deepEqual(r, rr)
|
|
82
85
|
})
|
|
83
86
|
|
|
@@ -87,20 +90,20 @@ describe('WDispatchAi', function() {
|
|
|
87
90
|
let t = await wi.dispatchAi(kind, '')
|
|
88
91
|
r.push(t.error)
|
|
89
92
|
}
|
|
90
|
-
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']
|
|
93
|
+
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', 'prompt must be a non-empty string']
|
|
91
94
|
assert.strict.deepEqual(r, rr)
|
|
92
95
|
})
|
|
93
96
|
|
|
94
97
|
it('可由WDispatchAi呼叫dispatchAi', async function() {
|
|
95
98
|
let t = await wi.dispatchAi('gemini', 'abc')
|
|
96
99
|
let r = [t.ok, t.error]
|
|
97
|
-
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat, api-openai-responses)']
|
|
100
|
+
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat, api-openai-responses, api-typesafe-systemone)']
|
|
98
101
|
assert.strict.deepEqual(r, rr)
|
|
99
102
|
})
|
|
100
103
|
|
|
101
104
|
it('可由WDispatchAi呼叫各轉接器', async function() {
|
|
102
105
|
let r = []
|
|
103
|
-
for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity, wi.dispatchApiOpenaiCompat, wi.dispatchApiOpenaiResponses]) {
|
|
106
|
+
for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity, wi.dispatchApiOpenaiCompat, wi.dispatchApiOpenaiResponses, wi.dispatchApiTypesafeSystemone]) {
|
|
104
107
|
let t = await fn('')
|
|
105
108
|
r.push([t.ok, t.error])
|
|
106
109
|
}
|
|
@@ -111,6 +114,7 @@ describe('WDispatchAi', function() {
|
|
|
111
114
|
[false, 'prompt must be a non-empty string'],
|
|
112
115
|
[false, 'prompt must be a non-empty string'],
|
|
113
116
|
[false, 'prompt must be a non-empty string'],
|
|
117
|
+
[false, 'prompt must be a non-empty string'],
|
|
114
118
|
]
|
|
115
119
|
assert.strict.deepEqual(r, rr)
|
|
116
120
|
})
|
|
@@ -8,19 +8,20 @@ import dispatchCodex from '../src/dispatchCodex.mjs'
|
|
|
8
8
|
import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
|
|
9
9
|
import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
|
|
10
10
|
import dispatchApiOpenaiResponses from '../src/dispatchApiOpenaiResponses.mjs'
|
|
11
|
+
import dispatchApiTypesafeSystemone from '../src/dispatchApiTypesafeSystemone.mjs'
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
describe('adapters', function() {
|
|
14
15
|
|
|
15
16
|
it('對照表鍵名即為可用之供應商種類', function() {
|
|
16
17
|
let r = keys(adapters)
|
|
17
|
-
let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat', 'api-openai-responses']
|
|
18
|
+
let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat', 'api-openai-responses', 'api-typesafe-systemone']
|
|
18
19
|
assert.strict.deepEqual(r, rr)
|
|
19
20
|
})
|
|
20
21
|
|
|
21
22
|
it('各鍵值皆為函數', function() {
|
|
22
23
|
let r = map(keys(adapters), (k) => typeof adapters[k])
|
|
23
|
-
let rr = ['function', 'function', 'function', 'function', 'function', 'function']
|
|
24
|
+
let rr = ['function', 'function', 'function', 'function', 'function', 'function', 'function']
|
|
24
25
|
assert.strict.deepEqual(r, rr)
|
|
25
26
|
})
|
|
26
27
|
|
|
@@ -32,8 +33,9 @@ describe('adapters', function() {
|
|
|
32
33
|
adapters.antigravity === dispatchAntigravity,
|
|
33
34
|
adapters['api-openai-compat'] === dispatchApiOpenaiCompat,
|
|
34
35
|
adapters['api-openai-responses'] === dispatchApiOpenaiResponses,
|
|
36
|
+
adapters['api-typesafe-systemone'] === dispatchApiTypesafeSystemone,
|
|
35
37
|
]
|
|
36
|
-
let rr = [true, true, true, true, true, true]
|
|
38
|
+
let rr = [true, true, true, true, true, true, true]
|
|
37
39
|
assert.strict.deepEqual(r, rr)
|
|
38
40
|
})
|
|
39
41
|
|
|
@@ -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, api-openai-responses)', 0]
|
|
23
|
+
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat, api-openai-responses, api-typesafe-systemone)', 0]
|
|
24
24
|
assert.strict.deepEqual(r, rr)
|
|
25
25
|
})
|
|
26
26
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import dispatchApiTypesafeSystemone from '../src/dispatchApiTypesafeSystemone.mjs'
|
|
3
|
+
import dispatchAi from '../src/dispatchAi.mjs'
|
|
4
|
+
import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
|
|
5
|
+
import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
|
|
6
|
+
import NO_SIDE_EFFECT from '../src/wkf/noSideEffectPrefix.mjs'
|
|
7
|
+
import fakeServerForApiTest from './tools/fakeServerForApiTest.mjs'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
//共用題目: 兩題, 用於檢核「每題皆有答案」
|
|
11
|
+
let Q = {
|
|
12
|
+
category: { type: 'choice', instructions: '哪一類?', criteria: { '報修': '設備故障', '其他': null } },
|
|
13
|
+
urgent: { type: 'noul', instructions: '是否急迫?' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
describe('dispatchApiTypesafeSystemone', function() {
|
|
18
|
+
|
|
19
|
+
let svr = null
|
|
20
|
+
|
|
21
|
+
before(async function() {
|
|
22
|
+
svr = await fakeServerForApiTest()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
after(async function() {
|
|
26
|
+
if (svr) {
|
|
27
|
+
await svr.close()
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('prompt與questions之必填檢核(questions須為非空物件), 回傳錯誤結果物件且不reject', async function() {
|
|
32
|
+
let t1 = await dispatchApiTypesafeSystemone('')
|
|
33
|
+
let t2 = await dispatchApiTypesafeSystemone('abc')
|
|
34
|
+
let t3 = await dispatchApiTypesafeSystemone('abc', { questions: {} })
|
|
35
|
+
let t4 = await dispatchApiTypesafeSystemone('abc', { questions: [Q.urgent] })
|
|
36
|
+
let r = [[t1.ok, t1.error, t1.errorType], [t2.ok, t2.error, t2.attempts], [t3.ok, t3.error], [t4.ok, t4.error]]
|
|
37
|
+
let rr = [
|
|
38
|
+
[false, 'prompt must be a non-empty string', 'params'],
|
|
39
|
+
[false, 'questions must be a non-empty object', 0],
|
|
40
|
+
[false, 'questions must be a non-empty object'],
|
|
41
|
+
[false, 'questions must be a non-empty object'],
|
|
42
|
+
]
|
|
43
|
+
assert.strict.deepEqual(r, rr)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('請求打/systemone: prompt置於state、questions原樣送出、model預設jev-latest、body額外鍵併入', async function() {
|
|
47
|
+
let t = await dispatchApiTypesafeSystemone('房間水龍頭滴水\n第二行', { baseURL: svr.url, key: 'sk-good-1', questions: Q, body: { tag: 'x' } })
|
|
48
|
+
let a = t.answers.urgent
|
|
49
|
+
let r = [t.ok, t.code, t.attempts, a.echoAuth, a.echoBody.model, a.echoBody.state, a.echoBody.questions, a.echoBody.tag]
|
|
50
|
+
let rr = [true, 200, 1, 'Bearer sk-good-1', 'jev-latest', '房間水龍頭滴水\n第二行', Q, 'x']
|
|
51
|
+
assert.strict.deepEqual(r, rr)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('baseURL尾端斜線正規化, 未給key時不帶Authorization標頭', async function() {
|
|
55
|
+
let t = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url + '///', model: 'echo', questions: Q })
|
|
56
|
+
let r = [t.ok, t.answers.urgent.echoAuth]
|
|
57
|
+
let rr = [true, '']
|
|
58
|
+
assert.strict.deepEqual(r, rr)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('成功: stdout為answers之JSON字串且與answers一致, 追加modelResolved, usage原樣透傳', async function() {
|
|
62
|
+
let t = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', questions: Q })
|
|
63
|
+
let r = [t.ok, t.error, JSON.parse(t.stdout), Object.keys(t.answers), t.modelResolved, t.usage]
|
|
64
|
+
let rr = [true, '', t.answers, ['category', 'urgent'], 'jev-1.13.0', { input_tokens: 7, output_tokens: 3 }]
|
|
65
|
+
assert.strict.deepEqual(r, rr)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('回應不合規: 缺任一題答案、缺answers、非JSON皆為invalid-response, 答案不外流', async function() {
|
|
69
|
+
let t1 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'missing-answer', questions: Q })
|
|
70
|
+
let t2 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'no-answers', questions: Q })
|
|
71
|
+
let t3 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'not-json', questions: Q })
|
|
72
|
+
let r = [
|
|
73
|
+
[t1.ok, t1.errorType, t1.error, t1.answers, t1.stdout, t1.modelResolved, t1.usage],
|
|
74
|
+
[t2.ok, t2.errorType, t2.error],
|
|
75
|
+
[t3.ok, t3.errorType, t3.error],
|
|
76
|
+
]
|
|
77
|
+
let rr = [
|
|
78
|
+
[false, 'invalid-response', 'INVALID_RESPONSE: missing answers for [urgent]', null, '', 'jev-1.13.0', { input_tokens: 7, output_tokens: 3 }],
|
|
79
|
+
[false, 'invalid-response', 'INVALID_RESPONSE: missing answers object'],
|
|
80
|
+
[false, 'invalid-response', 'INVALID_RESPONSE: missing answers object'],
|
|
81
|
+
]
|
|
82
|
+
assert.strict.deepEqual(r, rr)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('壞金鑰401、未知model 400、題型不合規422皆為http且4xx不重試, 原始本體置stderr', async function() {
|
|
86
|
+
let t1 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-bad', model: 'echo', questions: Q, maxRetries: 3 })
|
|
87
|
+
let t2 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'jev-nope', questions: Q, maxRetries: 3 })
|
|
88
|
+
let t3 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'bad-question', questions: Q, maxRetries: 3 })
|
|
89
|
+
let r = [
|
|
90
|
+
[t1.ok, t1.code, t1.error, t1.errorType, t1.attempts, t1.stderr.includes('authentication_error')],
|
|
91
|
+
[t2.ok, t2.code, t2.attempts, t2.stderr.includes('Unknown model: jev-nope')],
|
|
92
|
+
[t3.ok, t3.code, t3.attempts, t3.stderr.includes('union_tag_invalid')],
|
|
93
|
+
]
|
|
94
|
+
let rr = [
|
|
95
|
+
[false, 401, 'HTTP 401', 'http', 1, true],
|
|
96
|
+
[false, 400, 1, true],
|
|
97
|
+
[false, 422, 1, true],
|
|
98
|
+
]
|
|
99
|
+
assert.strict.deepEqual(r, rr)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('429依maxRetries重試後成功, 5xx重試用盡仍失敗, 逾時error以TIMEOUT開頭', async function() {
|
|
103
|
+
let t1 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-retry', model: 'flaky-429', questions: Q, maxRetries: 2, retryDelayMs: 10 })
|
|
104
|
+
let t2 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'err-500', questions: Q, maxRetries: 1, retryDelayMs: 10 })
|
|
105
|
+
let t3 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'slow', questions: Q, timeoutMs: 500 })
|
|
106
|
+
let r = [
|
|
107
|
+
[t1.ok, t1.attempts, t1.answers.urgent.attempt],
|
|
108
|
+
[t2.ok, t2.code, t2.attempts, t2.errorType],
|
|
109
|
+
[t3.ok, t3.code, t3.errorType, t3.error.indexOf('TIMEOUT') === 0],
|
|
110
|
+
]
|
|
111
|
+
let rr = [
|
|
112
|
+
[true, 2, 2],
|
|
113
|
+
[false, 500, 2, 'http'],
|
|
114
|
+
[false, null, 'timeout', true],
|
|
115
|
+
]
|
|
116
|
+
assert.strict.deepEqual(r, rr)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('連線失敗為fetch, validate未過為OUTPUT_VALIDATION_FAILED', async function() {
|
|
120
|
+
let t1 = await dispatchApiTypesafeSystemone('abc', { baseURL: 'http://127.0.0.1:9/v1', questions: Q, timeoutMs: 3000 })
|
|
121
|
+
let t2 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', questions: Q, validate: 'min:100000' })
|
|
122
|
+
let r = [[t1.ok, t1.errorType, t1.error.indexOf('FETCH_ERROR') === 0], [t2.ok, t2.errorType, t2.error, t2.answers]]
|
|
123
|
+
let rr = [[false, 'fetch', true], [false, 'validation', 'OUTPUT_VALIDATION_FAILED', null]]
|
|
124
|
+
assert.strict.deepEqual(r, rr)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('可作為dispatchAi之kind; dispatchAiFallback之questions可置於呼叫層並隨金鑰輪替透傳', async function() {
|
|
128
|
+
let t1 = await dispatchAi('api-typesafe-systemone', 'abc', { baseURL: svr.url, key: 'sk-good-1', questions: Q })
|
|
129
|
+
let t2 = await dispatchAiFallback('abc', {
|
|
130
|
+
providers: [
|
|
131
|
+
{ id: 'typesafe:jev-latest', kind: 'api-typesafe-systemone', baseURL: svr.url, model: 'jev-latest', keys: ['sk-bad-a', 'sk-good-b'] },
|
|
132
|
+
],
|
|
133
|
+
questions: Q,
|
|
134
|
+
})
|
|
135
|
+
let r = [
|
|
136
|
+
t1.ok, t1.answers.urgent.echoBody.model,
|
|
137
|
+
t2.ok, t2.providerId, t2.tried.map((x) => [x.keyId, x.outcome]), JSON.parse(t2.stdout).category.echoAuth,
|
|
138
|
+
]
|
|
139
|
+
let rr = [
|
|
140
|
+
true, 'jev-latest',
|
|
141
|
+
true, 'typesafe:jev-latest', [['typesafe:jev-latest#0', 'next-key'], ['typesafe:jev-latest#1', 'ok']], 'Bearer sk-good-b',
|
|
142
|
+
]
|
|
143
|
+
assert.strict.deepEqual(r, rr)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('工作流callAi: promptPrefix為空時state即原文; 預設前綴會混入state(故須明傳promptPrefix:\'\')', async function() {
|
|
147
|
+
let wkf = dispatchAiWkf({
|
|
148
|
+
providers: {
|
|
149
|
+
'jev': { kind: 'api-typesafe-systemone', baseURL: svr.url, model: 'echo', keys: ['sk-good-w'] },
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
let t1 = await wkf.callAi('原文', { spec: { use: 'jev' }, questions: Q, promptPrefix: '', check: (j) => j.urgent.type === 'noul' })
|
|
153
|
+
let t2 = await wkf.callAi('原文', { spec: { use: 'jev' }, questions: Q })
|
|
154
|
+
let r = [t1.ok, t1.json.urgent.echoBody.state, t2.ok, t2.json.urgent.echoBody.state === NO_SIDE_EFFECT + '原文']
|
|
155
|
+
let rr = [true, '原文', true, true]
|
|
156
|
+
assert.strict.deepEqual(r, rr)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
})
|
|
@@ -57,6 +57,13 @@ describe('providers', function() {
|
|
|
57
57
|
assert.strict.deepEqual(r, rr)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
it('決策型條目(api-typesafe-systemone)不位於清單末端, 免得全取遞補全敗時以「questions必填」掩蓋真正錯誤', function() {
|
|
61
|
+
let idx = providers.map((p, i) => p.kind === 'api-typesafe-systemone' ? i : -1).filter((i) => i >= 0)
|
|
62
|
+
let r = [idx.length > 0, idx.every((i) => i < providers.length - 1)]
|
|
63
|
+
let rr = [true, true]
|
|
64
|
+
assert.strict.deepEqual(r, rr)
|
|
65
|
+
})
|
|
66
|
+
|
|
60
67
|
it('條目不自帶timeoutMs(由上層依任務型態統一給定)', function() {
|
|
61
68
|
let r = providers.every((p) => p.timeoutMs === undefined)
|
|
62
69
|
let rr = true
|
|
@@ -70,10 +70,10 @@ describe('resolveProviders', function() {
|
|
|
70
70
|
})
|
|
71
71
|
|
|
72
72
|
it('可展開套件內建providers.mjs(以模擬env驗證, 不依賴真實.env)', function() {
|
|
73
|
-
let fakeEnv = { OPENCODE_KEYS: 'sk-1,sk-2', AGNES_KEYS: 'sk-3', POOLSIDE_KEYS: 'sk-4' }
|
|
73
|
+
let fakeEnv = { OPENCODE_KEYS: 'sk-1,sk-2', AGNES_KEYS: 'sk-3', POOLSIDE_KEYS: 'sk-4', TYPESAFE_KEYS: 'apikey_5' }
|
|
74
74
|
let { providers, skipped } = resolveProviders(providersAll, { env: fakeEnv })
|
|
75
75
|
let r = [
|
|
76
|
-
providers.length === providersAll.length,
|
|
76
|
+
providers.length === providersAll.length, //各envVar皆有值 → 無停用
|
|
77
77
|
skipped.length,
|
|
78
78
|
providers.every((p) => p.envVar === undefined),
|
|
79
79
|
providers.filter((p) => Array.isArray(p.keys)).length > 0,
|