Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 203 additions & 1 deletion ui/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ const router = express.Router();
let configWriteQueue = Promise.resolve();

const MASKED_SECRET = '********';

// 32x32 visible PNG: white background with a centered black square.
const IMAGE_PROBE_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAR0lEQVR4nO2UwQkAIAzEsv/S5wr2UY5CAv5EAmklZVAgJiiDAjHBpQTA11FgAqPLJsAhxDVM9SPagJVXFRhAyqBATFCmnuABKBrF/2313aEAAAAASUVORK5CYII=';
const IMAGE_PROBE_PROMPT = 'Describe this image in one word.';
const IMAGE_PROBE_TIMEOUT_MS = 8_000;

const DEFAULT_GLM_WEB_SEARCH_ENDPOINT = 'https://api.z.ai/api/paas/v4/web_search';
const DEFAULT_TAVILY_WEB_SEARCH_ENDPOINT = 'https://api.tavily.com/search';

Expand Down Expand Up @@ -289,6 +295,187 @@ function extractProbeText(body, providerKind) {
.trim();
}

const EXPLICIT_IMAGE_UNSUPPORTED_PATTERNS = [
/\bis not an? multimodal model\b/i,
/\bdoes not support (?:image|vision) inputs?\s*(?:[.!?]|$)/i,
/\bdoes not support (?:images|vision)\s*(?:[.!?]|$)/i,
/\b(?:image|vision) inputs? (?:is |are )?not supported\s*(?:[.!?]|$)/i,
/\bunsupported (?:image|vision) input\s*(?:[.!?]|$)/i,
];

const IMAGE_INPUT_QUALIFIER_PATTERN =
/\b(?:larger than|smaller than|too large|too small|size|dimension|dimensions|resolution|pixel|pixels|width|height|format|mime|media type|file type|url|uri|base64|data url|data uri)\b/i;

function imageProbeSupported() {
return {
status: 'supported',
supported: true,
source: 'probe',
retryable: false,
manualConfirmationAllowed: false,
};
}

function imageProbeUnsupported(message) {
return {
status: 'unsupported',
supported: false,
source: 'probe',
reasonCode: 'explicit_unsupported',
retryable: false,
manualConfirmationAllowed: false,
...(message ? { message } : {}),
};
}

function imageProbeFailed(reasonCode, message) {
return {
status: 'detection_failed',
supported: null,
source: 'probe',
reasonCode,
retryable: true,
manualConfirmationAllowed: true,
...(message ? { message } : {}),
};
}

function isExplicitImageUnsupportedMessage(message) {
const text = String(message || '');
if (IMAGE_INPUT_QUALIFIER_PATTERN.test(text)) return false;
return EXPLICIT_IMAGE_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text));
}

function parseProviderErrorMessage(responseText, response) {
if (typeof responseText !== 'string' || !responseText.trim()) {
return `${response.status} ${response.statusText}`.trim();
}
try {
const body = JSON.parse(responseText);
if (typeof body?.error?.message === 'string' && body.error.message.trim()) return body.error.message.trim();
if (typeof body?.message === 'string' && body.message.trim()) return body.message.trim();
if (typeof body?.error === 'string' && body.error.trim()) return body.error.trim();
if (typeof body?.error?.type === 'string' && body.error.type.trim()) return body.error.type.trim();
} catch { /* use raw text */ }
return responseText.trim();
}

function imageProbeFailureReasonForStatus(status) {
if (status === 408) return 'timeout';
if (status === 429) return 'rate_limited';
if (status === 401 || status === 403) return 'auth_error';
if (status >= 400 && status < 500) return 'invalid_request';
return 'network_error';
}

/**
* Send a minimal image+text completion to determine whether the model accepts
* image input. Returns a structured tri-state probe result.
*/
async function probeImageSupport({ protocol, baseUrl, apiKey, model }) {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new NetworkFetchError('network_timeout', 'Image probe timed out')),
IMAGE_PROBE_TIMEOUT_MS,
);

try {
let urls;
let fetchOptions;

if (protocol === 'google') {
urls = buildProviderChatEndpointCandidates({ protocol: 'google', baseUrl, model });
fetchOptions = {
method: 'POST',
headers: { 'x-goog-api-key': apiKey, 'content-type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [
{ text: IMAGE_PROBE_PROMPT },
{ inlineData: { mimeType: 'image/png', data: IMAGE_PROBE_PNG_BASE64 } },
] }],
generationConfig: { maxOutputTokens: 8 },
}),
signal: controller.signal,
};
} else if (protocol === 'anthropic') {
urls = buildProviderChatEndpointCandidates({ protocol: 'anthropic', baseUrl });
fetchOptions = {
method: 'POST',
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
body: JSON.stringify({
model,
max_tokens: 8,
messages: [{ role: 'user', content: [
{ type: 'text', text: IMAGE_PROBE_PROMPT },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: IMAGE_PROBE_PNG_BASE64 } },
] }],
}),
signal: controller.signal,
};
} else if (protocol === 'openai-responses') {
urls = buildProviderChatEndpointCandidates({ protocol: 'openai-responses', baseUrl });
fetchOptions = {
method: 'POST',
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
'content-type': 'application/json',
},
body: JSON.stringify({
model,
max_output_tokens: 8,
store: false,
input: [{ type: 'message', role: 'user', content: [
{ type: 'input_text', text: IMAGE_PROBE_PROMPT },
{ type: 'input_image', image_url: `data:image/png;base64,${IMAGE_PROBE_PNG_BASE64}` },
] }],
}),
signal: controller.signal,
};
} else {
urls = buildProviderChatEndpointCandidates({ protocol: 'openai', baseUrl });
fetchOptions = {
method: 'POST',
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
'content-type': 'application/json',
},
body: JSON.stringify({
model,
max_tokens: 8,
messages: [{ role: 'user', content: [
{ type: 'text', text: IMAGE_PROBE_PROMPT },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${IMAGE_PROBE_PNG_BASE64}` } },
] }],
}),
signal: controller.signal,
};
}

const result = await fetchWithEndpointFallback(urls, fetchOptions);
clearTimeout(timer);

if (result.response.ok) {
try {
const body = JSON.parse(result.responseText);
return isExpectedProviderResponseShape(protocol, body)
? imageProbeSupported()
: imageProbeFailed('unexpected_shape', `Image probe returned HTTP ${result.response.status}, but the response was not a valid completion.`);
} catch {
return imageProbeFailed('non_json', `Image probe returned HTTP ${result.response.status}, but the response was not JSON.`);
}
}

const message = parseProviderErrorMessage(result.responseText, result.response);
if (isExplicitImageUnsupportedMessage(message)) return imageProbeUnsupported(message);

return imageProbeFailed(imageProbeFailureReasonForStatus(result.response.status), message);
} catch (error) {
clearTimeout(timer);
const message = error instanceof Error ? error.message : String(error);
return imageProbeFailed(isNetworkTimeout(error) ? 'timeout' : 'network_error', message);
}
}

function normalizeModelListItem(item) {
if (!item || typeof item !== 'object') return null;
const rawId = typeof item.id === 'string'
Expand Down Expand Up @@ -847,7 +1034,22 @@ router.post('/test-connection', async (req, res) => {
});
}

return res.json({ ok: true, message: `Connected successfully — Model ${model} is available.` });
const successMessage = `Connected successfully — Model ${model} is available.`;

// --- Image-support detection (live probe) ---
const imageProbeResult = await probeImageSupport({
protocol: responseProtocol,
baseUrl: normalizedBaseUrl,
apiKey: effectiveApiKey,
model,
});
return res.json({
ok: true,
message: successMessage,
imageSupport: imageProbeResult,
supportsImage: imageProbeResult.supported,
imageCheckSource: imageProbeResult.source,
});
}

let detail = `${response.status} ${response.statusText}`;
Expand Down
Loading