w-dispatch-ai 1.0.25 → 1.0.27
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 +57 -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 +27 -5
- 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 +1254 -247
- 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/dispatchOpencode.mjs +25 -3
- package/src/getErrorType.mjs +2 -1
- package/src/providers.mjs +35 -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-dispatchOpencode.test.mjs +25 -0
- package/test/unit-providers.test.mjs +14 -0
- package/test/unit-resolveProviders.test.mjs +2 -2
package/src/providers.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// 【id命名】依「路徑:模型」規則(詳dispatchAiFallback.mjs檔頭之id設計規則):
|
|
8
8
|
// oc:=經opencode CLI(有工具、較慢) / agy:=antigravity CLI / claude:/codex:=訂閱登入態CLI
|
|
9
9
|
// zen:/agnes:/poolside:=REST直呼(免CLI免登入、快, 純文字生成)
|
|
10
|
+
// typesafe:=REST直呼之決策模型(非文字生成, 須給questions, 見該條註解)
|
|
10
11
|
// 同一模型之CLI版與REST版屬不同供應商(能力與額度池皆不同), 故各為一條。
|
|
11
12
|
//
|
|
12
13
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
@@ -51,6 +52,9 @@
|
|
|
51
52
|
// HTTP 503 → 上游時斷時續(server_error: Upstream request failed: Endpoint is unavailable),
|
|
52
53
|
// 暫時性, 遞補層換家即可; 判別法: 同時段打另一免費模型作對照組。
|
|
53
54
|
// HTTP 429 → 閘道容量型限流(FreeUsageLimitError), 與金鑰無關(換金鑰照樣429), 暫時性。
|
|
55
|
+
// HTTP 403 FreeTierError(free tier can only be used from within OpenCode)
|
|
56
|
+
// → 該免費模型只開放opencode客戶端, REST直呼被刻意擋下(2026-09-17 union-alpha實測), 屬政策非故障,
|
|
57
|
+
// 只能收oc:版。另401 Model is disabled=該金鑰所屬工作區未開此模型, 換工作區或於後台開啟。
|
|
54
58
|
// HTTP 401/400 → 額度/促銷結束或模型暫時下架(訊息如Free promotion has ended、
|
|
55
59
|
// Upstream request failed: Model is unavailable), 屬服務端狀態非設定錯誤。
|
|
56
60
|
// 任一碼皆非「條目寫錯」之證據——model id寫錯時閘道回的是其他4xx且訊息明指model。
|
|
@@ -80,9 +84,11 @@
|
|
|
80
84
|
// import providers from 'w-dispatch-ai/src/providers.mjs'
|
|
81
85
|
// import resolveProviders from 'w-dispatch-ai/src/resolveProviders.mjs'
|
|
82
86
|
// import readEnvFile from 'w-dispatch-ai/src/readEnvFile.mjs'
|
|
83
|
-
// let env = readEnvFile('./.env') //OPENCODE_KEYS/AGNES_KEYS/POOLSIDE_KEYS, 逗號分隔多把; 不污染process.env
|
|
87
|
+
// let env = readEnvFile('./.env') //OPENCODE_KEYS/AGNES_KEYS/POOLSIDE_KEYS/TYPESAFE_KEYS, 逗號分隔多把; 不污染process.env
|
|
84
88
|
// let { providers: ps, table, skipped } = resolveProviders(providers, { env }) //全取
|
|
85
89
|
// let r2 = resolveProviders(providers, { env, pick: ['agnes:agnes-3.0-flash', 'claude:sonnet'] }) //自選, pick順序即遞補優先序
|
|
90
|
+
// let r3 = resolveProviders(providers, { env, pick: ['typesafe:jev-latest'] }) //決策模型單獨取出
|
|
91
|
+
// await dispatchAiFallback(state, { providers: r3.providers, questions }) //questions置呼叫層
|
|
86
92
|
//
|
|
87
93
|
// 【timeout規劃】各條目刻意不帶timeoutMs, 由上層依任務型態統一給定、條目僅於特例覆寫:
|
|
88
94
|
// 簡單任務(秒級~分鐘級): 沿用套件統一預設即可(全轉接器一律300000=5分鐘, 見dfTimeoutMs.mjs)。
|
|
@@ -162,6 +168,23 @@ let providers = [
|
|
|
162
168
|
//2026-09-03實測6.1s(opencode CLI 1.18.27)。註: 同日同模型走zen REST回HTTP 500,
|
|
163
169
|
//僅CLI路徑可用——同模型不同路徑屬不同供應商之實例(故未增zen:對應條目)
|
|
164
170
|
},
|
|
171
|
+
{
|
|
172
|
+
id: 'oc:opencode/union-alpha',
|
|
173
|
+
model: 'opencode/union-alpha',
|
|
174
|
+
kind: 'opencode',
|
|
175
|
+
provider: 'opencode',
|
|
176
|
+
useStoredAuth: false,
|
|
177
|
+
config: {
|
|
178
|
+
permission: { edit: 'deny', write: 'deny', bash: 'deny' },
|
|
179
|
+
},
|
|
180
|
+
//Union Alpha Free(官方文件: 限時免費之stealth模型, 端點/messages)。2026-09-17實測(opencode CLI 1.18.31):
|
|
181
|
+
//刻意不帶envVar且useStoredAuth:false——以匿名免費存取呼叫, 不隨本機登入帳號而異。
|
|
182
|
+
//OPENCODE_KEYS之第1把金鑰所屬工作區未開此模型(回Model is disabled), 第2把雖可用但該工作區
|
|
183
|
+
//會預設開啟新模型(可能非免費), 皆不採用。原先只「不帶envVar」, 但未注入金鑰時opencode會沿用
|
|
184
|
+
//本機auth.json之登入: 登入帳號之工作區未開此模型的機器即回Model is disabled(使用端回報並經本機以
|
|
185
|
+
//XDG_DATA_HOME暫存auth.json重現), 故加useStoredAuth:false令本次不讀auth.json(實測7.0s成功)。
|
|
186
|
+
//REST不收zen:版: /messages對免費模型回403 FreeTierError(free tier can only be used from within OpenCode)
|
|
187
|
+
},
|
|
165
188
|
{
|
|
166
189
|
id: 'oc:opencode/deepseek-v4-flash-free',
|
|
167
190
|
model: 'opencode/deepseek-v4-flash-free',
|
|
@@ -198,6 +221,17 @@ let providers = [
|
|
|
198
221
|
},
|
|
199
222
|
|
|
200
223
|
//api版
|
|
224
|
+
{
|
|
225
|
+
id: 'typesafe:jev-latest',
|
|
226
|
+
model: 'jev-latest',
|
|
227
|
+
kind: 'api-typesafe-systemone',
|
|
228
|
+
envVar: 'TYPESAFE_KEYS',
|
|
229
|
+
//TypeSafe之jev為決策模型(非文字生成): prompt為被評估之state, 呼叫時須給questions, 回型別化答案。
|
|
230
|
+
//2026-09-17實測0.3~1.0s(實際版本jev-1.13.0); 官方無CLI故僅此API條目。用法: 以pick單獨取出,
|
|
231
|
+
//questions置dispatchAiFallback呼叫層(詳dispatchApiTypesafeSystemone.mjs檔頭)。
|
|
232
|
+
//混入文字遞補鏈(全取)時: 因無questions而以params錯誤0ms失敗(逐把金鑰各一次)後換下一家, 不影響他家(2026-09-17實測);
|
|
233
|
+
//刻意不放清單末端——前面全敗時fallback回傳最後一筆失敗, 放末端會以「questions必填」掩蓋真正原因
|
|
234
|
+
},
|
|
201
235
|
//zen:系為2026-08-21快照(檔頭聲明), 各條註記當日以「請只回覆兩個字:完成」實測之結果
|
|
202
236
|
{
|
|
203
237
|
id: 'agnes:agnes-3.0-flash',
|
|
@@ -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
|
+
})
|
|
@@ -82,6 +82,31 @@ describe('dispatchOpencode', function() {
|
|
|
82
82
|
assert.strict.deepEqual(r, rr)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it('useStoredAuth為false且未注入金鑰時注入空憑證\'{}\'(不讀auth.json); 非false一律不注入; 旗標不進命令列', async function() {
|
|
86
|
+
let t1 = await dispatchOpencode('abc', { exe: fake.exe, provider: 'opencode', useStoredAuth: false })
|
|
87
|
+
let t2 = await dispatchOpencode('abc', { exe: fake.exe, useStoredAuth: false })
|
|
88
|
+
let t3 = await dispatchOpencode('abc', { exe: fake.exe, useStoredAuth: true })
|
|
89
|
+
let t4 = await dispatchOpencode('abc', { exe: fake.exe, useStoredAuth: 'false' })
|
|
90
|
+
let r = [
|
|
91
|
+
JSON.parse(t1.stdout).env.OPENCODE_AUTH_CONTENT,
|
|
92
|
+
JSON.parse(t2.stdout).env.OPENCODE_AUTH_CONTENT,
|
|
93
|
+
JSON.parse(t3.stdout).env.OPENCODE_AUTH_CONTENT,
|
|
94
|
+
JSON.parse(t4.stdout).env.OPENCODE_AUTH_CONTENT,
|
|
95
|
+
JSON.parse(t1.stdout).args,
|
|
96
|
+
]
|
|
97
|
+
let rr = ['{}', '{}', '', '', ['run', '--agent', 'build']]
|
|
98
|
+
assert.strict.deepEqual(r, rr)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('useStoredAuth為false但同時給key與provider時以金鑰注入為準; 亦覆寫呼叫端env內之同名變數', async function() {
|
|
102
|
+
let t1 = await dispatchOpencode('abc', { exe: fake.exe, provider: 'opencode', key: 'sk-abc123', useStoredAuth: false })
|
|
103
|
+
let t2 = await dispatchOpencode('abc', { exe: fake.exe, useStoredAuth: false, env: { OPENCODE_AUTH_CONTENT: '', FAKE_ENV: 'x' } })
|
|
104
|
+
let o2 = JSON.parse(t2.stdout)
|
|
105
|
+
let r = [JSON.parse(JSON.parse(t1.stdout).env.OPENCODE_AUTH_CONTENT), o2.env.OPENCODE_AUTH_CONTENT, o2.env.FAKE_ENV]
|
|
106
|
+
let rr = [{ opencode: { type: 'api', key: 'sk-abc123' } }, '{}', 'x']
|
|
107
|
+
assert.strict.deepEqual(r, rr)
|
|
108
|
+
})
|
|
109
|
+
|
|
85
110
|
it('金鑰注入僅作用於當次子進程, 不影響本進程之process.env', async function() {
|
|
86
111
|
let before = process.env.OPENCODE_AUTH_CONTENT
|
|
87
112
|
let t = await dispatchOpencode('abc', { exe: fake.exe, provider: 'opencode', key: 'sk-abc123' })
|
|
@@ -57,6 +57,20 @@ describe('providers', function() {
|
|
|
57
57
|
assert.strict.deepEqual(r, rr)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
it('opencode條目不帶envVar者必設useStoredAuth:false(否則會沿用本機auth.json, 結果隨執行機器之登入帳號而異)', function() {
|
|
61
|
+
let noKey = providers.filter((p) => p.kind === 'opencode' && p.envVar === undefined)
|
|
62
|
+
let r = noKey.map((p) => [p.id, p.useStoredAuth])
|
|
63
|
+
let rr = noKey.map((p) => [p.id, false])
|
|
64
|
+
assert.strict.deepEqual([noKey.length > 0, r], [true, rr])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('決策型條目(api-typesafe-systemone)不位於清單末端, 免得全取遞補全敗時以「questions必填」掩蓋真正錯誤', function() {
|
|
68
|
+
let idx = providers.map((p, i) => p.kind === 'api-typesafe-systemone' ? i : -1).filter((i) => i >= 0)
|
|
69
|
+
let r = [idx.length > 0, idx.every((i) => i < providers.length - 1)]
|
|
70
|
+
let rr = [true, true]
|
|
71
|
+
assert.strict.deepEqual(r, rr)
|
|
72
|
+
})
|
|
73
|
+
|
|
60
74
|
it('條目不自帶timeoutMs(由上層依任務型態統一給定)', function() {
|
|
61
75
|
let r = providers.every((p) => p.timeoutMs === undefined)
|
|
62
76
|
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,
|