dsh-auto-sight 0.4.6 → 0.4.9
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/LICENSE +21 -21
- package/README.md +81 -77
- package/README.zh-CN.md +81 -77
- package/client.js +349 -349
- package/cordis.patch.yml +6 -6
- package/icon.svg +6 -6
- package/index.js +672 -627
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +60 -58
- package/test/auto-vision.test.mjs +550 -480
- package/test/client-render.mjs +158 -158
|
@@ -1,480 +1,550 @@
|
|
|
1
|
-
import assert from 'node:assert/strict'
|
|
2
|
-
import test from 'node:test'
|
|
3
|
-
import { apply } from '../index.js'
|
|
4
|
-
|
|
5
|
-
function makeCtx({ models, visionText = 'A cat on a keyboard.', throwUnknown = false, failModels = [] }) {
|
|
6
|
-
const calls = []
|
|
7
|
-
const listeners = []
|
|
8
|
-
const llm = {
|
|
9
|
-
async resolveModelInfo(provider, model) {
|
|
10
|
-
const found = models[`${provider}/${model}`]
|
|
11
|
-
if (found) return found
|
|
12
|
-
if (throwUnknown) {
|
|
13
|
-
const error = new Error(`unknown model ${provider}/${model}`)
|
|
14
|
-
error.code = 'UNKNOWN_MODEL'
|
|
15
|
-
throw error
|
|
16
|
-
}
|
|
17
|
-
return { id: model, provider, inputModalities: ['text'] }
|
|
18
|
-
},
|
|
19
|
-
async listModels(provider) {
|
|
20
|
-
return Object.entries(models)
|
|
21
|
-
.filter(([key]) => key.startsWith(`${provider}/`))
|
|
22
|
-
.map(([, value]) => value)
|
|
23
|
-
},
|
|
24
|
-
stream(options) {
|
|
25
|
-
calls.push(options)
|
|
26
|
-
return (async function* () {
|
|
27
|
-
if (failModels.includes(options.model)) {
|
|
28
|
-
yield { type: 'finish', reason: { kind: 'error', failure: { message: `${options.model} boom` } } }
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
const text = options.model.startsWith('vision') ? visionText : 'main-ok'
|
|
32
|
-
yield { type: 'text-delta', index: 0, text }
|
|
33
|
-
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
34
|
-
})()
|
|
35
|
-
},
|
|
36
|
-
}
|
|
37
|
-
const ctx = {
|
|
38
|
-
llm,
|
|
39
|
-
on(event, listener) {
|
|
40
|
-
if (event === 'llm/stream') listeners.push(listener)
|
|
41
|
-
return () => {}
|
|
42
|
-
},
|
|
43
|
-
effect(register) {
|
|
44
|
-
register()
|
|
45
|
-
return () => {}
|
|
46
|
-
},
|
|
47
|
-
}
|
|
48
|
-
return { ctx, calls, listeners }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function imageMessage(text = 'What is this?', attachmentId = 'img-1') {
|
|
52
|
-
return {
|
|
53
|
-
role: 'user',
|
|
54
|
-
content: [
|
|
55
|
-
{ type: 'text', text },
|
|
56
|
-
{ type: 'image', attachment: { attachmentId, mediaType: 'image/png' } },
|
|
57
|
-
],
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function drain(iterable) {
|
|
62
|
-
const chunks = []
|
|
63
|
-
for await (const chunk of iterable) chunks.push(chunk)
|
|
64
|
-
return chunks
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
test('routes images through a same-provider vision model before the text model', async () => {
|
|
68
|
-
const models = {
|
|
69
|
-
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
70
|
-
'p/vision': { id: 'vision', provider: 'p', name: 'Vision', inputModalities: ['text', 'image'] },
|
|
71
|
-
}
|
|
72
|
-
const { ctx, calls, listeners } = makeCtx({ models })
|
|
73
|
-
apply(ctx, { timeoutMs: 5000 })
|
|
74
|
-
|
|
75
|
-
const out = await drain(listeners[0](
|
|
76
|
-
{ provider: 'p', model: 'text', messages: [imageMessage()] },
|
|
77
|
-
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
78
|
-
))
|
|
79
|
-
|
|
80
|
-
assert.equal(calls.length, 2)
|
|
81
|
-
assert.equal(calls[0].model, 'vision')
|
|
82
|
-
assert.equal(calls[0].messages[0].content[1].type, 'image')
|
|
83
|
-
assert.equal(calls[1].model, 'text')
|
|
84
|
-
assert.equal(calls[1].messages[0].content.some((block) => block.type === 'image'), false)
|
|
85
|
-
assert.match(calls[1].messages[0].content[1].text, /A cat on a keyboard/)
|
|
86
|
-
assert.equal(out[0].text, 'main-ok')
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
test('leaves native vision routes untouched', async () => {
|
|
90
|
-
const models = {
|
|
91
|
-
'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
|
|
92
|
-
}
|
|
93
|
-
const { ctx, listeners } = makeCtx({ models })
|
|
94
|
-
apply(ctx, {})
|
|
95
|
-
|
|
96
|
-
const out = await drain(listeners[0](
|
|
97
|
-
{ provider: 'p', model: 'vision', messages: [imageMessage()] },
|
|
98
|
-
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
99
|
-
))
|
|
100
|
-
assert.deepEqual(out, [{ type: 'text-delta', text: 'native' }])
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
test('
|
|
104
|
-
const models = {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
() =>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
{
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
ctx.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { apply } from '../index.js'
|
|
4
|
+
|
|
5
|
+
function makeCtx({ models, visionText = 'A cat on a keyboard.', throwUnknown = false, failModels = [] }) {
|
|
6
|
+
const calls = []
|
|
7
|
+
const listeners = []
|
|
8
|
+
const llm = {
|
|
9
|
+
async resolveModelInfo(provider, model) {
|
|
10
|
+
const found = models[`${provider}/${model}`]
|
|
11
|
+
if (found) return found
|
|
12
|
+
if (throwUnknown) {
|
|
13
|
+
const error = new Error(`unknown model ${provider}/${model}`)
|
|
14
|
+
error.code = 'UNKNOWN_MODEL'
|
|
15
|
+
throw error
|
|
16
|
+
}
|
|
17
|
+
return { id: model, provider, inputModalities: ['text'] }
|
|
18
|
+
},
|
|
19
|
+
async listModels(provider) {
|
|
20
|
+
return Object.entries(models)
|
|
21
|
+
.filter(([key]) => key.startsWith(`${provider}/`))
|
|
22
|
+
.map(([, value]) => value)
|
|
23
|
+
},
|
|
24
|
+
stream(options) {
|
|
25
|
+
calls.push(options)
|
|
26
|
+
return (async function* () {
|
|
27
|
+
if (failModels.includes(options.model)) {
|
|
28
|
+
yield { type: 'finish', reason: { kind: 'error', failure: { message: `${options.model} boom` } } }
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const text = options.model.startsWith('vision') ? visionText : 'main-ok'
|
|
32
|
+
yield { type: 'text-delta', index: 0, text }
|
|
33
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
34
|
+
})()
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
const ctx = {
|
|
38
|
+
llm,
|
|
39
|
+
on(event, listener) {
|
|
40
|
+
if (event === 'llm/stream') listeners.push(listener)
|
|
41
|
+
return () => {}
|
|
42
|
+
},
|
|
43
|
+
effect(register) {
|
|
44
|
+
register()
|
|
45
|
+
return () => {}
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
return { ctx, calls, listeners }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function imageMessage(text = 'What is this?', attachmentId = 'img-1') {
|
|
52
|
+
return {
|
|
53
|
+
role: 'user',
|
|
54
|
+
content: [
|
|
55
|
+
{ type: 'text', text },
|
|
56
|
+
{ type: 'image', attachment: { attachmentId, mediaType: 'image/png' } },
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function drain(iterable) {
|
|
62
|
+
const chunks = []
|
|
63
|
+
for await (const chunk of iterable) chunks.push(chunk)
|
|
64
|
+
return chunks
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('routes images through a same-provider vision model before the text model', async () => {
|
|
68
|
+
const models = {
|
|
69
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
70
|
+
'p/vision': { id: 'vision', provider: 'p', name: 'Vision', inputModalities: ['text', 'image'] },
|
|
71
|
+
}
|
|
72
|
+
const { ctx, calls, listeners } = makeCtx({ models })
|
|
73
|
+
apply(ctx, { timeoutMs: 5000 })
|
|
74
|
+
|
|
75
|
+
const out = await drain(listeners[0](
|
|
76
|
+
{ provider: 'p', model: 'text', messages: [imageMessage()] },
|
|
77
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
assert.equal(calls.length, 2)
|
|
81
|
+
assert.equal(calls[0].model, 'vision')
|
|
82
|
+
assert.equal(calls[0].messages[0].content[1].type, 'image')
|
|
83
|
+
assert.equal(calls[1].model, 'text')
|
|
84
|
+
assert.equal(calls[1].messages[0].content.some((block) => block.type === 'image'), false)
|
|
85
|
+
assert.match(calls[1].messages[0].content[1].text, /A cat on a keyboard/)
|
|
86
|
+
assert.equal(out[0].text, 'main-ok')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('leaves native vision routes untouched', async () => {
|
|
90
|
+
const models = {
|
|
91
|
+
'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
|
|
92
|
+
}
|
|
93
|
+
const { ctx, listeners } = makeCtx({ models })
|
|
94
|
+
apply(ctx, {})
|
|
95
|
+
|
|
96
|
+
const out = await drain(listeners[0](
|
|
97
|
+
{ provider: 'p', model: 'vision', messages: [imageMessage()] },
|
|
98
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
99
|
+
))
|
|
100
|
+
assert.deepEqual(out, [{ type: 'text-delta', text: 'native' }])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('bridges when the declared config says text-only even if resolveModelInfo claims image', async () => {
|
|
104
|
+
const models = {
|
|
105
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text', 'image'] },
|
|
106
|
+
'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
|
|
107
|
+
}
|
|
108
|
+
const { ctx, calls, listeners } = makeCtx({ models })
|
|
109
|
+
ctx.settings = {
|
|
110
|
+
describe: () => [{
|
|
111
|
+
ns: 'llm-pi-ai',
|
|
112
|
+
value: { providers: { p: { models: [
|
|
113
|
+
{ id: 'text' },
|
|
114
|
+
{ id: 'vision', input: ['text', 'image'] },
|
|
115
|
+
] } } },
|
|
116
|
+
}],
|
|
117
|
+
}
|
|
118
|
+
apply(ctx, { timeoutMs: 5000 })
|
|
119
|
+
|
|
120
|
+
await drain(listeners[0](
|
|
121
|
+
{ provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-declared-config')] },
|
|
122
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
assert.equal(calls.length, 2)
|
|
126
|
+
assert.equal(calls[0].model, 'vision')
|
|
127
|
+
assert.equal(calls[1].model, 'text')
|
|
128
|
+
assert.equal(calls[1].messages[0].content.some((block) => block.type === 'image'), false)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('passes through when no vision model exists on the provider', async () => {
|
|
132
|
+
const models = { 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] } }
|
|
133
|
+
const { ctx, calls, listeners } = makeCtx({ models })
|
|
134
|
+
apply(ctx, {})
|
|
135
|
+
|
|
136
|
+
const out = await drain(listeners[0](
|
|
137
|
+
{ provider: 'p', model: 'text', messages: [imageMessage()] },
|
|
138
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
139
|
+
))
|
|
140
|
+
assert.equal(calls.length, 0)
|
|
141
|
+
assert.deepEqual(out, [{ type: 'text-delta', text: 'native' }])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('describes one attachment only once', async () => {
|
|
145
|
+
const models = {
|
|
146
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
147
|
+
'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
|
|
148
|
+
}
|
|
149
|
+
const { ctx, calls, listeners } = makeCtx({ models })
|
|
150
|
+
apply(ctx, { timeoutMs: 5000 })
|
|
151
|
+
const options = { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-cache')] }
|
|
152
|
+
const next = () => (async function* () { yield { type: 'text-delta', text: 'native' } })()
|
|
153
|
+
|
|
154
|
+
await drain(listeners[0](options, next))
|
|
155
|
+
await drain(listeners[0](options, next))
|
|
156
|
+
|
|
157
|
+
assert.equal(calls.filter((call) => call.model === 'vision').length, 1)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('uses the vision model selected through a volatile config reference', async () => {
|
|
161
|
+
const models = {
|
|
162
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
163
|
+
'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
|
|
164
|
+
'p/vision-2': { id: 'vision-2', provider: 'p', inputModalities: ['text', 'image'] },
|
|
165
|
+
}
|
|
166
|
+
const { ctx, calls, listeners } = makeCtx({ models })
|
|
167
|
+
apply(ctx, {
|
|
168
|
+
timeoutMs: 5000,
|
|
169
|
+
visionProvider: { get: () => 'p' },
|
|
170
|
+
visionModel: { get: () => 'vision-2' },
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
await drain(listeners[0](
|
|
174
|
+
{ provider: 'p', model: 'text', messages: [imageMessage()] },
|
|
175
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
176
|
+
))
|
|
177
|
+
|
|
178
|
+
assert.equal(calls[0].model, 'vision-2')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('ignores a selection from another API and discovers on the request API', async () => {
|
|
182
|
+
const models = {
|
|
183
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
184
|
+
'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
|
|
185
|
+
}
|
|
186
|
+
const { ctx, calls, listeners } = makeCtx({ models })
|
|
187
|
+
apply(ctx, {
|
|
188
|
+
timeoutMs: 5000,
|
|
189
|
+
visionProvider: { get: () => 'q' },
|
|
190
|
+
visionModel: { get: () => 'vision-2' },
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await drain(listeners[0](
|
|
194
|
+
{ provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-other-api')] },
|
|
195
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
196
|
+
))
|
|
197
|
+
|
|
198
|
+
assert.equal(calls[0].provider, 'p')
|
|
199
|
+
assert.equal(calls[0].model, 'vision')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('registers the official catalog for the Settings picker', async () => {
|
|
203
|
+
const { ctx } = makeCtx({ models: {} })
|
|
204
|
+
const discoveries = []
|
|
205
|
+
ctx.llm.registerModelDiscovery = (ns, discover) => {
|
|
206
|
+
assert.equal(ns, 'auto-vision')
|
|
207
|
+
discoveries.push(discover)
|
|
208
|
+
return () => {}
|
|
209
|
+
}
|
|
210
|
+
ctx.settings = {
|
|
211
|
+
describe: () => [{
|
|
212
|
+
ns: 'llm-pi-ai',
|
|
213
|
+
value: { providers: { p: { baseURL: 'https://api.example/v1', apiKeyEnv: 'P_KEY' } } },
|
|
214
|
+
}],
|
|
215
|
+
}
|
|
216
|
+
ctx.credentials = { resolve: async (ref) => (ref === 'P_KEY' ? { value: 'secret' } : undefined) }
|
|
217
|
+
const originalFetch = globalThis.fetch
|
|
218
|
+
globalThis.fetch = async (url, init) => {
|
|
219
|
+
assert.equal(url, 'https://api.example/v1/models')
|
|
220
|
+
assert.equal(init.headers.authorization, 'Bearer secret')
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
async json() {
|
|
224
|
+
return { data: [{ id: 'text-model' }, { id: 'GLM-4.6V' }, { id: 'Qwen2.5-VL' }] }
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
apply(ctx, {})
|
|
230
|
+
const listed = await discoveries[0]({ provider: 'p' })
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
listed.filter((model) => model.inputModalities.includes('image')).map((model) => model.id),
|
|
233
|
+
['GLM-4.6V', 'Qwen2.5-VL'],
|
|
234
|
+
)
|
|
235
|
+
} finally {
|
|
236
|
+
globalThis.fetch = originalFetch
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('calls an official-list model directly when the adapter does not know it', async () => {
|
|
241
|
+
const models = { 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] } }
|
|
242
|
+
const { ctx, calls, listeners } = makeCtx({ models, throwUnknown: true })
|
|
243
|
+
let mutation
|
|
244
|
+
ctx.settings = {
|
|
245
|
+
describe: () => [{
|
|
246
|
+
ns: 'llm-pi-ai',
|
|
247
|
+
value: { providers: { p: {
|
|
248
|
+
baseURL: 'https://api.example/v1',
|
|
249
|
+
apiKeyEnv: 'P_KEY',
|
|
250
|
+
models: [],
|
|
251
|
+
} } },
|
|
252
|
+
}],
|
|
253
|
+
async mutate(ns, ops) {
|
|
254
|
+
mutation = { ns, ops }
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
|
|
258
|
+
ctx.attachments = {
|
|
259
|
+
readImage: async () => ({ ref: { mediaType: 'image/png' }, data: Uint8Array.from([1, 2, 3]) }),
|
|
260
|
+
}
|
|
261
|
+
const originalFetch = globalThis.fetch
|
|
262
|
+
let sent
|
|
263
|
+
globalThis.fetch = async (url, init) => {
|
|
264
|
+
assert.equal(url, 'https://api.example/v1/chat/completions')
|
|
265
|
+
sent = JSON.parse(init.body)
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
async text() {
|
|
269
|
+
return JSON.stringify({ choices: [{ message: { content: 'direct evidence' } }] })
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'GLM-4.6V' })
|
|
275
|
+
await drain(listeners[0](
|
|
276
|
+
{ provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-direct')] },
|
|
277
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
278
|
+
))
|
|
279
|
+
assert.equal(sent.model, 'GLM-4.6V')
|
|
280
|
+
assert.equal(sent.messages[1].content[1].type, 'image_url')
|
|
281
|
+
assert.equal(calls.length, 1)
|
|
282
|
+
assert.match(calls[0].messages[0].content[1].text, /direct evidence/)
|
|
283
|
+
assert.equal(mutation.ns, 'llm-pi-ai')
|
|
284
|
+
assert.deepEqual(mutation.ops[0].value[0].input, ['text', 'image'])
|
|
285
|
+
} finally {
|
|
286
|
+
globalThis.fetch = originalFetch
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('keeps a configured vision model that the adapter knows as text-only', async () => {
|
|
291
|
+
const models = {
|
|
292
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
293
|
+
'p/DeepSeek-V4-Flash-Vision-Exp': {
|
|
294
|
+
id: 'DeepSeek-V4-Flash-Vision-Exp',
|
|
295
|
+
provider: 'p',
|
|
296
|
+
inputModalities: ['text'],
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
const { ctx, calls, listeners } = makeCtx({ models })
|
|
300
|
+
let mutation
|
|
301
|
+
ctx.settings = {
|
|
302
|
+
describe: () => [{
|
|
303
|
+
ns: 'llm-pi-ai',
|
|
304
|
+
value: { providers: { p: {
|
|
305
|
+
baseURL: 'https://api.example/v1',
|
|
306
|
+
apiKeyEnv: 'P_KEY',
|
|
307
|
+
models: [{ id: 'DeepSeek-V4-Flash-Vision-Exp', name: 'DeepSeek-V4-Flash-Vision-Exp' }],
|
|
308
|
+
} } },
|
|
309
|
+
}],
|
|
310
|
+
async mutate(ns, ops) {
|
|
311
|
+
mutation = { ns, ops }
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
|
|
315
|
+
ctx.attachments = {
|
|
316
|
+
readImage: async () => ({ ref: { mediaType: 'image/png' }, data: Uint8Array.from([1, 2, 3]) }),
|
|
317
|
+
}
|
|
318
|
+
const originalFetch = globalThis.fetch
|
|
319
|
+
let sent
|
|
320
|
+
globalThis.fetch = async (url, init) => {
|
|
321
|
+
assert.equal(url, 'https://api.example/v1/chat/completions')
|
|
322
|
+
sent = JSON.parse(init.body)
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
async text() {
|
|
326
|
+
return JSON.stringify({ choices: [{ message: { content: 'declared direct evidence' } }] })
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'DeepSeek-V4-Flash-Vision-Exp' })
|
|
332
|
+
await drain(listeners[0](
|
|
333
|
+
{ provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-configured-text-only')] },
|
|
334
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
335
|
+
))
|
|
336
|
+
assert.equal(sent.model, 'DeepSeek-V4-Flash-Vision-Exp')
|
|
337
|
+
assert.equal(calls.length, 1)
|
|
338
|
+
assert.match(calls[0].messages[0].content[1].text, /declared direct evidence/)
|
|
339
|
+
assert.equal(mutation.ns, 'llm-pi-ai')
|
|
340
|
+
assert.deepEqual(mutation.ops[0].path, ['providers', 'p', 'models'])
|
|
341
|
+
assert.deepEqual(mutation.ops[0].value[0].input, ['text', 'image'])
|
|
342
|
+
} finally {
|
|
343
|
+
globalThis.fetch = originalFetch
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('falls back to another vision model and tells the main model about the switch', async () => {
|
|
348
|
+
const models = {
|
|
349
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
350
|
+
'p/vision-bad': { id: 'vision-bad', provider: 'p', inputModalities: ['text', 'image'] },
|
|
351
|
+
'p/vision-good': { id: 'vision-good', provider: 'p', inputModalities: ['text', 'image'] },
|
|
352
|
+
}
|
|
353
|
+
const { ctx, calls, listeners } = makeCtx({ models, failModels: ['vision-bad'] })
|
|
354
|
+
apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'vision-bad' })
|
|
355
|
+
|
|
356
|
+
await drain(listeners[0](
|
|
357
|
+
{ provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-fallback')] },
|
|
358
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
359
|
+
))
|
|
360
|
+
|
|
361
|
+
assert.deepEqual(calls.map((call) => call.model), ['vision-bad', 'vision-good', 'text'])
|
|
362
|
+
assert.match(calls[2].system, /图片 #1/)
|
|
363
|
+
assert.match(calls[2].system, /vision-bad/)
|
|
364
|
+
assert.match(calls[2].system, /vision-good/)
|
|
365
|
+
assert.match(calls[2].system, /最终答复/)
|
|
366
|
+
assert.match(calls[2].messages[0].content[1].text, /A cat on a keyboard/)
|
|
367
|
+
assert.match(calls[2].messages[0].content[1].text, /after p\/vision-bad failed/)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test('falls back to the official list after mounted vision models fail', async () => {
|
|
371
|
+
const models = {
|
|
372
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
373
|
+
'p/vision-bad': { id: 'vision-bad', provider: 'p', inputModalities: ['text', 'image'] },
|
|
374
|
+
}
|
|
375
|
+
const { ctx, calls, listeners } = makeCtx({
|
|
376
|
+
models,
|
|
377
|
+
failModels: ['vision-bad'],
|
|
378
|
+
throwUnknown: true,
|
|
379
|
+
})
|
|
380
|
+
ctx.settings = {
|
|
381
|
+
describe: () => [{
|
|
382
|
+
ns: 'llm-pi-ai',
|
|
383
|
+
value: { providers: { p: { baseURL: 'https://api.example/v1', apiKeyEnv: 'P_KEY' } } },
|
|
384
|
+
}],
|
|
385
|
+
}
|
|
386
|
+
ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
|
|
387
|
+
ctx.attachments = {
|
|
388
|
+
readImage: async () => ({ ref: { mediaType: 'image/png' }, data: Uint8Array.from([1, 2, 3]) }),
|
|
389
|
+
}
|
|
390
|
+
const originalFetch = globalThis.fetch
|
|
391
|
+
let sent
|
|
392
|
+
globalThis.fetch = async (url, init) => {
|
|
393
|
+
if (url === 'https://api.example/v1/models') {
|
|
394
|
+
return {
|
|
395
|
+
ok: true,
|
|
396
|
+
async json() {
|
|
397
|
+
return { data: [{ id: 'GLM-4.6V' }] }
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
assert.equal(url, 'https://api.example/v1/chat/completions')
|
|
402
|
+
sent = JSON.parse(init.body)
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
async text() {
|
|
406
|
+
return JSON.stringify({ choices: [{ message: { content: 'official evidence' } }] })
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'vision-bad' })
|
|
412
|
+
await drain(listeners[0](
|
|
413
|
+
{ provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-official-fallback')] },
|
|
414
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
415
|
+
))
|
|
416
|
+
assert.equal(sent.model, 'GLM-4.6V')
|
|
417
|
+
assert.deepEqual(calls.map((call) => call.model), ['vision-bad', 'text'])
|
|
418
|
+
assert.match(calls[1].system, /GLM-4\.6V/)
|
|
419
|
+
assert.match(calls[1].system, /vision-bad/)
|
|
420
|
+
assert.match(calls[1].messages[0].content[1].text, /official evidence/)
|
|
421
|
+
assert.match(calls[1].messages[0].content[1].text, /GLM-4\.6V/)
|
|
422
|
+
} finally {
|
|
423
|
+
globalThis.fetch = originalFetch
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('uses an official-list model when no mounted vision model exists', async () => {
|
|
428
|
+
const models = { 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] } }
|
|
429
|
+
const { ctx, calls, listeners } = makeCtx({ models, throwUnknown: true })
|
|
430
|
+
ctx.settings = {
|
|
431
|
+
describe: () => [{
|
|
432
|
+
ns: 'llm-pi-ai',
|
|
433
|
+
value: { providers: { p: { baseURL: 'https://api.example/v1', apiKeyEnv: 'P_KEY' } } },
|
|
434
|
+
}],
|
|
435
|
+
}
|
|
436
|
+
ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
|
|
437
|
+
ctx.attachments = {
|
|
438
|
+
readImage: async () => ({ ref: { mediaType: 'image/png' }, data: Uint8Array.from([1, 2, 3]) }),
|
|
439
|
+
}
|
|
440
|
+
const originalFetch = globalThis.fetch
|
|
441
|
+
let sent
|
|
442
|
+
globalThis.fetch = async (url, init) => {
|
|
443
|
+
if (url === 'https://api.example/v1/models') {
|
|
444
|
+
return {
|
|
445
|
+
ok: true,
|
|
446
|
+
async json() {
|
|
447
|
+
return { data: [{ id: 'GLM-4.6V' }] }
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
assert.equal(url, 'https://api.example/v1/chat/completions')
|
|
452
|
+
sent = JSON.parse(init.body)
|
|
453
|
+
return {
|
|
454
|
+
ok: true,
|
|
455
|
+
async text() {
|
|
456
|
+
return JSON.stringify({ choices: [{ message: { content: 'official evidence' } }] })
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
apply(ctx, { timeoutMs: 5000 })
|
|
462
|
+
await drain(listeners[0](
|
|
463
|
+
{ provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-official-auto')] },
|
|
464
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
465
|
+
))
|
|
466
|
+
assert.equal(sent.model, 'GLM-4.6V')
|
|
467
|
+
assert.equal(calls.length, 1)
|
|
468
|
+
assert.match(calls[0].messages[0].content[1].text, /official evidence/)
|
|
469
|
+
assert.match(calls[0].messages[0].content[1].text, /GLM-4\.6V/)
|
|
470
|
+
} finally {
|
|
471
|
+
globalThis.fetch = originalFetch
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('tells the main model when every vision model fails', async () => {
|
|
476
|
+
const models = {
|
|
477
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
478
|
+
'p/vision-bad': { id: 'vision-bad', provider: 'p', inputModalities: ['text', 'image'] },
|
|
479
|
+
'p/vision-worse': { id: 'vision-worse', provider: 'p', inputModalities: ['text', 'image'] },
|
|
480
|
+
}
|
|
481
|
+
const { ctx, calls, listeners } = makeCtx({
|
|
482
|
+
models,
|
|
483
|
+
failModels: ['vision-bad', 'vision-worse'],
|
|
484
|
+
})
|
|
485
|
+
apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'vision-bad' })
|
|
486
|
+
|
|
487
|
+
await drain(listeners[0](
|
|
488
|
+
{ provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-all-fail')] },
|
|
489
|
+
() => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
|
|
490
|
+
))
|
|
491
|
+
|
|
492
|
+
assert.deepEqual(calls.map((call) => call.model), ['vision-bad', 'vision-worse', 'text'])
|
|
493
|
+
assert.match(calls[2].system, /所有视觉模型均调用失败/)
|
|
494
|
+
assert.match(calls[2].system, /vision-bad/)
|
|
495
|
+
assert.match(calls[2].system, /vision-worse/)
|
|
496
|
+
assert.match(calls[2].messages[0].content[1].text, /could not be interpreted by any vision model/)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test('advertises image input from a declared profile model when the adapter catalog is empty', async () => {
|
|
500
|
+
const models = { 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] } }
|
|
501
|
+
const { ctx } = makeCtx({ models })
|
|
502
|
+
ctx.settings = {
|
|
503
|
+
describe: () => [{
|
|
504
|
+
ns: 'llm-pi-ai',
|
|
505
|
+
value: { providers: { p: {
|
|
506
|
+
baseURL: 'https://api.example/v1',
|
|
507
|
+
apiKeyEnv: 'P_KEY',
|
|
508
|
+
models: [{ id: 'GLM-4.6V', name: 'GLM-4.6V', input: ['text', 'image'] }],
|
|
509
|
+
} } },
|
|
510
|
+
}],
|
|
511
|
+
}
|
|
512
|
+
apply(ctx, {})
|
|
513
|
+
const info = await ctx.llm.resolveModelInfo('p', 'text')
|
|
514
|
+
assert.deepEqual(info.inputModalities, ['text', 'image'])
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
test('advertises image input from the official list when nothing is mounted', async () => {
|
|
518
|
+
const models = { 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] } }
|
|
519
|
+
const { ctx } = makeCtx({ models })
|
|
520
|
+
ctx.settings = {
|
|
521
|
+
describe: () => [{
|
|
522
|
+
ns: 'llm-pi-ai',
|
|
523
|
+
value: { providers: { p: { baseURL: 'https://api.example/v1', apiKeyEnv: 'P_KEY', models: [] } } },
|
|
524
|
+
}],
|
|
525
|
+
}
|
|
526
|
+
ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
|
|
527
|
+
const originalFetch = globalThis.fetch
|
|
528
|
+
globalThis.fetch = async (url) => {
|
|
529
|
+
assert.equal(url, 'https://api.example/v1/models')
|
|
530
|
+
return { ok: true, async json() { return { data: [{ id: 'GLM-4.6V' }] } } }
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
apply(ctx, {})
|
|
534
|
+
const info = await ctx.llm.resolveModelInfo('p', 'text')
|
|
535
|
+
assert.deepEqual(info.inputModalities, ['text', 'image'])
|
|
536
|
+
} finally {
|
|
537
|
+
globalThis.fetch = originalFetch
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
test('advertises image input for text routes that have a vision sibling', async () => {
|
|
542
|
+
const models = {
|
|
543
|
+
'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
|
|
544
|
+
'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
|
|
545
|
+
}
|
|
546
|
+
const { ctx } = makeCtx({ models })
|
|
547
|
+
apply(ctx, {})
|
|
548
|
+
const info = await ctx.llm.resolveModelInfo('p', 'text')
|
|
549
|
+
assert.deepEqual(info.inputModalities, ['text', 'image'])
|
|
550
|
+
})
|