Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**The regex is gone.** The route ran a pattern over the user's message and enqueued an image from the answer text when the model had not called the tool. It was a compatibility path for models without tool calling and it did more harm than good: it decided in English only, it could not see the conversation, and "image summary" fell through it while reading as an obvious image request to the model itself — which was measured, not assumed. A second and worse decision-maker sitting behind the first. Whether a message deserves a picture is now the model's call, made from the tool description, which is the only place it ever belonged. **Lending eyes.** The same shape, for a different capability. When someone attaches a photograph and the chat model cannot accept image input, the attachment was either refused by the provider or silently dropped — an answer about a picture nobody had looked at, which is worse than a refusal. The chat model is now offered look_at_image beside the image tool and decides when to use it. The attachment goes to clinical_assistant.vision_model, whose description comes back as a tool result, and the chat model answers in its own voice with its own sources. Only the seeing is delegated; the clinical reasoning stays with the model an administrator chose. The seeing model is told to report and not to diagnose, because it has a picture and no context and an opinion from it would carry weight it has not earned. Delegation triggers only on an explicit supports_vision: false from the gateway. An unknown is left alone — most of a roster reports nothing, and treating silence as blindness would route good models through a detour. The capability lookup moved to its own module, is cached for five minutes because it runs on exactly the requests that are already slowest, and is never inferred from the model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the other gateway helpers live. The new setting is guarded like the slide reviewer: a model the gateway calls text-only cannot be saved as the one that looks at images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
94 lines
4.5 KiB
JavaScript
94 lines
4.5 KiB
JavaScript
// The slide reviewer is shown rendered images. Setting a text-only model there
|
|
// produces a failure on every generation, at request time, long after the
|
|
// administrator could have chosen differently — so the choice is checked when
|
|
// it is made.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
|
|
// Runs the real liteLLMVisionSupport out of the route file against a mocked
|
|
// gateway, so the parsing is exercised rather than read.
|
|
function visionSupport(modelInfo, opts = {}) {
|
|
const src = read('src/routes/adminConfig.js');
|
|
const start = src.indexOf('async function liteLLMVisionSupport');
|
|
const end = src.indexOf('\n}\n', start) + 3;
|
|
assert.ok(start > 0 && end > start, 'liteLLMVisionSupport not found');
|
|
|
|
const sandbox = {
|
|
liteLLMBaseUrl: () => 'https://gateway.invalid',
|
|
getLiteLLMAdminHeaders: () => ({}),
|
|
liteLLMModelId: m => (m && m.model_name) || '',
|
|
process: { env: opts.noGateway ? {} : { LITELLM_API_BASE: 'https://gateway.invalid' } },
|
|
require: name => {
|
|
assert.equal(name, 'axios');
|
|
return { get: async () => {
|
|
if (opts.unreachable) throw new Error('ECONNREFUSED');
|
|
return { data: { data: modelInfo } };
|
|
} };
|
|
},
|
|
module: { exports: {} }
|
|
};
|
|
vm.runInNewContext(src.slice(start, end) + '\nmodule.exports = liteLLMVisionSupport;', sandbox);
|
|
return sandbox.module.exports;
|
|
}
|
|
|
|
const ROSTER = [
|
|
{ model_name: 'text-only-model', model_info: { mode: 'chat', supports_vision: false } },
|
|
{ model_name: 'seeing-model', model_info: { mode: 'chat', supports_vision: true } },
|
|
{ model_name: 'unknown-model', model_info: { mode: 'chat' } }
|
|
];
|
|
|
|
test('the gateway is read for each of the three answers', async () => {
|
|
const ask = visionSupport(ROSTER);
|
|
assert.equal(await ask('text-only-model'), false, 'stated blind');
|
|
assert.equal(await ask('seeing-model'), true, 'stated sighted');
|
|
assert.equal(await ask('unknown-model'), null, 'the gateway does not say');
|
|
assert.equal(await ask('not-on-the-roster'), null, 'a model it has never heard of');
|
|
});
|
|
|
|
test('an unreachable gateway is not evidence that a model is blind', async () => {
|
|
// Returning false here would refuse a perfectly good model because the
|
|
// network blipped while an administrator was clicking Save.
|
|
assert.equal(await visionSupport(ROSTER, { unreachable: true })('seeing-model'), null);
|
|
assert.equal(await visionSupport(ROSTER, { noGateway: true })('text-only-model'), null);
|
|
assert.equal(await visionSupport(ROSTER)(''), null, 'and an empty model means review is off');
|
|
});
|
|
|
|
test('the vision check is asked of the gateway, not guessed from the model name', () => {
|
|
const route = read('src/routes/adminConfig.js');
|
|
assert.match(route, /async function liteLLMVisionSupport/);
|
|
assert.match(route, /info\.supports_vision === 'boolean' \? info\.supports_vision : null/,
|
|
'three answers: true, false, and "the gateway does not say"');
|
|
assert.doesNotMatch(route, /supports_vision.*test\(|\/vision\/\.test/,
|
|
'never inferred from the model id');
|
|
});
|
|
|
|
test('a model the gateway reports as text-only is refused for slide review', () => {
|
|
const route = read('src/routes/adminConfig.js');
|
|
assert.match(route, /key === 'my_resources\.review_model'/);
|
|
assert.match(route, /if \(canSee === false\)/,
|
|
'strictly false — not falsy, which would also catch null');
|
|
assert.match(route, /is a text-only model, so it cannot be shown/);
|
|
assert.match(route, /key === 'clinical_assistant\.vision_model'/,
|
|
'the assistant\'s vision model is guarded the same way');
|
|
});
|
|
|
|
test('an unknown model is allowed, because unknown is not proof of blindness', () => {
|
|
// Most of the roster reports no supports_vision at all. Refusing those would
|
|
// block the working configuration this deployment already runs on.
|
|
const route = read('src/routes/adminConfig.js');
|
|
const guard = route.slice(route.indexOf("key === 'my_resources.review_model'"));
|
|
assert.doesNotMatch(guard.slice(0, 600), /canSee !== true/,
|
|
'an unknown must not be treated as a refusal');
|
|
assert.match(route, /The gateway being unreachable is not evidence about the model/);
|
|
});
|
|
|
|
test('turning slide review off is never blocked by the check', () => {
|
|
const route = read('src/routes/adminConfig.js');
|
|
assert.match(route, /\&\& String\(value\)\.trim\(\)\) \{/,
|
|
'an empty value means off, and must skip the lookup entirely');
|
|
});
|