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.
@@ -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('passes through when no vision model exists on the provider', async () => {
104
- const models = { 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] } }
105
- const { ctx, calls, listeners } = makeCtx({ models })
106
- apply(ctx, {})
107
-
108
- const out = await drain(listeners[0](
109
- { provider: 'p', model: 'text', messages: [imageMessage()] },
110
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
111
- ))
112
- assert.equal(calls.length, 0)
113
- assert.deepEqual(out, [{ type: 'text-delta', text: 'native' }])
114
- })
115
-
116
- test('describes one attachment only once', async () => {
117
- const models = {
118
- 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
119
- 'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
120
- }
121
- const { ctx, calls, listeners } = makeCtx({ models })
122
- apply(ctx, { timeoutMs: 5000 })
123
- const options = { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-cache')] }
124
- const next = () => (async function* () { yield { type: 'text-delta', text: 'native' } })()
125
-
126
- await drain(listeners[0](options, next))
127
- await drain(listeners[0](options, next))
128
-
129
- assert.equal(calls.filter((call) => call.model === 'vision').length, 1)
130
- })
131
-
132
- test('uses the vision model selected through a volatile config reference', async () => {
133
- const models = {
134
- 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
135
- 'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
136
- 'p/vision-2': { id: 'vision-2', provider: 'p', inputModalities: ['text', 'image'] },
137
- }
138
- const { ctx, calls, listeners } = makeCtx({ models })
139
- apply(ctx, {
140
- timeoutMs: 5000,
141
- visionProvider: { get: () => 'p' },
142
- visionModel: { get: () => 'vision-2' },
143
- })
144
-
145
- await drain(listeners[0](
146
- { provider: 'p', model: 'text', messages: [imageMessage()] },
147
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
148
- ))
149
-
150
- assert.equal(calls[0].model, 'vision-2')
151
- })
152
-
153
- test('ignores a selection from another API and discovers on the request API', async () => {
154
- const models = {
155
- 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
156
- 'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
157
- }
158
- const { ctx, calls, listeners } = makeCtx({ models })
159
- apply(ctx, {
160
- timeoutMs: 5000,
161
- visionProvider: { get: () => 'q' },
162
- visionModel: { get: () => 'vision-2' },
163
- })
164
-
165
- await drain(listeners[0](
166
- { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-other-api')] },
167
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
168
- ))
169
-
170
- assert.equal(calls[0].provider, 'p')
171
- assert.equal(calls[0].model, 'vision')
172
- })
173
-
174
- test('registers the official catalog for the Settings picker', async () => {
175
- const { ctx } = makeCtx({ models: {} })
176
- const discoveries = []
177
- ctx.llm.registerModelDiscovery = (ns, discover) => {
178
- assert.equal(ns, 'auto-vision')
179
- discoveries.push(discover)
180
- return () => {}
181
- }
182
- ctx.settings = {
183
- describe: () => [{
184
- ns: 'llm-pi-ai',
185
- value: { providers: { p: { baseURL: 'https://api.example/v1', apiKeyEnv: 'P_KEY' } } },
186
- }],
187
- }
188
- ctx.credentials = { resolve: async (ref) => (ref === 'P_KEY' ? { value: 'secret' } : undefined) }
189
- const originalFetch = globalThis.fetch
190
- globalThis.fetch = async (url, init) => {
191
- assert.equal(url, 'https://api.example/v1/models')
192
- assert.equal(init.headers.authorization, 'Bearer secret')
193
- return {
194
- ok: true,
195
- async json() {
196
- return { data: [{ id: 'text-model' }, { id: 'GLM-4.6V' }, { id: 'Qwen2.5-VL' }] }
197
- },
198
- }
199
- }
200
- try {
201
- apply(ctx, {})
202
- const listed = await discoveries[0]({ provider: 'p' })
203
- assert.deepEqual(
204
- listed.filter((model) => model.inputModalities.includes('image')).map((model) => model.id),
205
- ['GLM-4.6V', 'Qwen2.5-VL'],
206
- )
207
- } finally {
208
- globalThis.fetch = originalFetch
209
- }
210
- })
211
-
212
- test('calls an official-list model directly when the adapter does not know it', async () => {
213
- const models = { 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] } }
214
- const { ctx, calls, listeners } = makeCtx({ models, throwUnknown: true })
215
- let mutation
216
- ctx.settings = {
217
- describe: () => [{
218
- ns: 'llm-pi-ai',
219
- value: { providers: { p: {
220
- baseURL: 'https://api.example/v1',
221
- apiKeyEnv: 'P_KEY',
222
- models: [],
223
- } } },
224
- }],
225
- async mutate(ns, ops) {
226
- mutation = { ns, ops }
227
- },
228
- }
229
- ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
230
- ctx.attachments = {
231
- readImage: async () => ({ ref: { mediaType: 'image/png' }, data: Uint8Array.from([1, 2, 3]) }),
232
- }
233
- const originalFetch = globalThis.fetch
234
- let sent
235
- globalThis.fetch = async (url, init) => {
236
- assert.equal(url, 'https://api.example/v1/chat/completions')
237
- sent = JSON.parse(init.body)
238
- return {
239
- ok: true,
240
- async text() {
241
- return JSON.stringify({ choices: [{ message: { content: 'direct evidence' } }] })
242
- },
243
- }
244
- }
245
- try {
246
- apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'GLM-4.6V' })
247
- await drain(listeners[0](
248
- { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-direct')] },
249
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
250
- ))
251
- assert.equal(sent.model, 'GLM-4.6V')
252
- assert.equal(sent.messages[1].content[1].type, 'image_url')
253
- assert.equal(calls.length, 1)
254
- assert.match(calls[0].messages[0].content[1].text, /direct evidence/)
255
- assert.equal(mutation.ns, 'llm-pi-ai')
256
- assert.deepEqual(mutation.ops[0].value[0].input, ['text', 'image'])
257
- } finally {
258
- globalThis.fetch = originalFetch
259
- }
260
- })
261
-
262
- test('keeps a configured vision model that the adapter knows as text-only', async () => {
263
- const models = {
264
- 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
265
- 'p/DeepSeek-V4-Flash-Vision-Exp': {
266
- id: 'DeepSeek-V4-Flash-Vision-Exp',
267
- provider: 'p',
268
- inputModalities: ['text'],
269
- },
270
- }
271
- const { ctx, calls, listeners } = makeCtx({ models })
272
- let mutation
273
- ctx.settings = {
274
- describe: () => [{
275
- ns: 'llm-pi-ai',
276
- value: { providers: { p: {
277
- baseURL: 'https://api.example/v1',
278
- apiKeyEnv: 'P_KEY',
279
- models: [{ id: 'DeepSeek-V4-Flash-Vision-Exp', name: 'DeepSeek-V4-Flash-Vision-Exp' }],
280
- } } },
281
- }],
282
- async mutate(ns, ops) {
283
- mutation = { ns, ops }
284
- },
285
- }
286
- ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
287
- ctx.attachments = {
288
- readImage: async () => ({ ref: { mediaType: 'image/png' }, data: Uint8Array.from([1, 2, 3]) }),
289
- }
290
- const originalFetch = globalThis.fetch
291
- let sent
292
- globalThis.fetch = async (url, init) => {
293
- assert.equal(url, 'https://api.example/v1/chat/completions')
294
- sent = JSON.parse(init.body)
295
- return {
296
- ok: true,
297
- async text() {
298
- return JSON.stringify({ choices: [{ message: { content: 'declared direct evidence' } }] })
299
- },
300
- }
301
- }
302
- try {
303
- apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'DeepSeek-V4-Flash-Vision-Exp' })
304
- await drain(listeners[0](
305
- { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-configured-text-only')] },
306
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
307
- ))
308
- assert.equal(sent.model, 'DeepSeek-V4-Flash-Vision-Exp')
309
- assert.equal(calls.length, 1)
310
- assert.match(calls[0].messages[0].content[1].text, /declared direct evidence/)
311
- assert.equal(mutation.ns, 'llm-pi-ai')
312
- assert.deepEqual(mutation.ops[0].path, ['providers', 'p', 'models'])
313
- assert.deepEqual(mutation.ops[0].value[0].input, ['text', 'image'])
314
- } finally {
315
- globalThis.fetch = originalFetch
316
- }
317
- })
318
-
319
- test('falls back to another vision model and tells the main model about the switch', async () => {
320
- const models = {
321
- 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
322
- 'p/vision-bad': { id: 'vision-bad', provider: 'p', inputModalities: ['text', 'image'] },
323
- 'p/vision-good': { id: 'vision-good', provider: 'p', inputModalities: ['text', 'image'] },
324
- }
325
- const { ctx, calls, listeners } = makeCtx({ models, failModels: ['vision-bad'] })
326
- apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'vision-bad' })
327
-
328
- await drain(listeners[0](
329
- { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-fallback')] },
330
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
331
- ))
332
-
333
- assert.deepEqual(calls.map((call) => call.model), ['vision-bad', 'vision-good', 'text'])
334
- assert.match(calls[2].system, /图片 #1/)
335
- assert.match(calls[2].system, /vision-bad/)
336
- assert.match(calls[2].system, /vision-good/)
337
- assert.match(calls[2].system, /最终答复/)
338
- assert.match(calls[2].messages[0].content[1].text, /A cat on a keyboard/)
339
- assert.match(calls[2].messages[0].content[1].text, /after p\/vision-bad failed/)
340
- })
341
-
342
- test('falls back to the official list after mounted vision models fail', async () => {
343
- const models = {
344
- 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
345
- 'p/vision-bad': { id: 'vision-bad', provider: 'p', inputModalities: ['text', 'image'] },
346
- }
347
- const { ctx, calls, listeners } = makeCtx({
348
- models,
349
- failModels: ['vision-bad'],
350
- throwUnknown: true,
351
- })
352
- ctx.settings = {
353
- describe: () => [{
354
- ns: 'llm-pi-ai',
355
- value: { providers: { p: { baseURL: 'https://api.example/v1', apiKeyEnv: 'P_KEY' } } },
356
- }],
357
- }
358
- ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
359
- ctx.attachments = {
360
- readImage: async () => ({ ref: { mediaType: 'image/png' }, data: Uint8Array.from([1, 2, 3]) }),
361
- }
362
- const originalFetch = globalThis.fetch
363
- let sent
364
- globalThis.fetch = async (url, init) => {
365
- if (url === 'https://api.example/v1/models') {
366
- return {
367
- ok: true,
368
- async json() {
369
- return { data: [{ id: 'GLM-4.6V' }] }
370
- },
371
- }
372
- }
373
- assert.equal(url, 'https://api.example/v1/chat/completions')
374
- sent = JSON.parse(init.body)
375
- return {
376
- ok: true,
377
- async text() {
378
- return JSON.stringify({ choices: [{ message: { content: 'official evidence' } }] })
379
- },
380
- }
381
- }
382
- try {
383
- apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'vision-bad' })
384
- await drain(listeners[0](
385
- { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-official-fallback')] },
386
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
387
- ))
388
- assert.equal(sent.model, 'GLM-4.6V')
389
- assert.deepEqual(calls.map((call) => call.model), ['vision-bad', 'text'])
390
- assert.match(calls[1].system, /GLM-4\.6V/)
391
- assert.match(calls[1].system, /vision-bad/)
392
- assert.match(calls[1].messages[0].content[1].text, /official evidence/)
393
- assert.match(calls[1].messages[0].content[1].text, /GLM-4\.6V/)
394
- } finally {
395
- globalThis.fetch = originalFetch
396
- }
397
- })
398
-
399
- test('uses an official-list model when no mounted vision model exists', async () => {
400
- const models = { 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] } }
401
- const { ctx, calls, listeners } = makeCtx({ models, throwUnknown: true })
402
- ctx.settings = {
403
- describe: () => [{
404
- ns: 'llm-pi-ai',
405
- value: { providers: { p: { baseURL: 'https://api.example/v1', apiKeyEnv: 'P_KEY' } } },
406
- }],
407
- }
408
- ctx.credentials = { resolve: async () => ({ value: 'secret' }) }
409
- ctx.attachments = {
410
- readImage: async () => ({ ref: { mediaType: 'image/png' }, data: Uint8Array.from([1, 2, 3]) }),
411
- }
412
- const originalFetch = globalThis.fetch
413
- let sent
414
- globalThis.fetch = async (url, init) => {
415
- if (url === 'https://api.example/v1/models') {
416
- return {
417
- ok: true,
418
- async json() {
419
- return { data: [{ id: 'GLM-4.6V' }] }
420
- },
421
- }
422
- }
423
- assert.equal(url, 'https://api.example/v1/chat/completions')
424
- sent = JSON.parse(init.body)
425
- return {
426
- ok: true,
427
- async text() {
428
- return JSON.stringify({ choices: [{ message: { content: 'official evidence' } }] })
429
- },
430
- }
431
- }
432
- try {
433
- apply(ctx, { timeoutMs: 5000 })
434
- await drain(listeners[0](
435
- { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-official-auto')] },
436
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
437
- ))
438
- assert.equal(sent.model, 'GLM-4.6V')
439
- assert.equal(calls.length, 1)
440
- assert.match(calls[0].messages[0].content[1].text, /official evidence/)
441
- assert.match(calls[0].messages[0].content[1].text, /GLM-4\.6V/)
442
- } finally {
443
- globalThis.fetch = originalFetch
444
- }
445
- })
446
-
447
- test('tells the main model when every vision model fails', async () => {
448
- const models = {
449
- 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
450
- 'p/vision-bad': { id: 'vision-bad', provider: 'p', inputModalities: ['text', 'image'] },
451
- 'p/vision-worse': { id: 'vision-worse', provider: 'p', inputModalities: ['text', 'image'] },
452
- }
453
- const { ctx, calls, listeners } = makeCtx({
454
- models,
455
- failModels: ['vision-bad', 'vision-worse'],
456
- })
457
- apply(ctx, { timeoutMs: 5000, visionProvider: 'p', visionModel: 'vision-bad' })
458
-
459
- await drain(listeners[0](
460
- { provider: 'p', model: 'text', messages: [imageMessage('What is this?', 'img-all-fail')] },
461
- () => (async function* () { yield { type: 'text-delta', text: 'native' } })(),
462
- ))
463
-
464
- assert.deepEqual(calls.map((call) => call.model), ['vision-bad', 'vision-worse', 'text'])
465
- assert.match(calls[2].system, /所有视觉模型均调用失败/)
466
- assert.match(calls[2].system, /vision-bad/)
467
- assert.match(calls[2].system, /vision-worse/)
468
- assert.match(calls[2].messages[0].content[1].text, /could not be interpreted by any vision model/)
469
- })
470
-
471
- test('advertises image input for text routes that have a vision sibling', async () => {
472
- const models = {
473
- 'p/text': { id: 'text', provider: 'p', inputModalities: ['text'] },
474
- 'p/vision': { id: 'vision', provider: 'p', inputModalities: ['text', 'image'] },
475
- }
476
- const { ctx } = makeCtx({ models })
477
- apply(ctx, {})
478
- const info = await ctx.llm.resolveModelInfo('p', 'text')
479
- assert.deepEqual(info.inputModalities, ['text', 'image'])
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
+ })