diff --git a/ui/server/routes/config.js b/ui/server/routes/config.js index d300be405..c84cda483 100644 --- a/ui/server/routes/config.js +++ b/ui/server/routes/config.js @@ -50,6 +50,38 @@ const MASKED_SECRET = '********'; 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'; +function imageSupportResultFromProbe(probe) { + if (probe.ok) { + return { + status: 'supported', + supported: true, + source: 'probe', + retryable: false, + manualConfirmationAllowed: false, + }; + } + if (probe.imageUnsupported) { + return { + status: 'unsupported', + supported: false, + source: 'probe', + reasonCode: 'explicit_unsupported', + retryable: false, + manualConfirmationAllowed: false, + ...(probe.error ? { message: probe.error } : {}), + }; + } + return { + status: 'detection_failed', + supported: null, + source: 'probe', + reasonCode: probe.code || 'ENDPOINT_UNREACHABLE', + retryable: true, + manualConfirmationAllowed: true, + ...(probe.error ? { message: probe.error } : {}), + }; +} + function normalizeWebSearchProvider(provider) { return provider === 'tavily' || provider === 'custom' ? provider : 'glm'; } @@ -670,7 +702,23 @@ router.post('/test-connection', async (req, res) => { maxTokens: isOpenAIResponses ? 16 : 8, }); if (probe.ok) { - return res.json({ ok: true, message: `Connected successfully — Model ${model} is available.` }); + const imageProbe = await probeModelConnection({ + protocol, + baseUrl: normalizedBaseUrl, + endpointUrl: probe.endpointUrl, + apiKey: effectiveApiKey, + model, + image: true, + maxTokens: 8, + }); + const imageSupport = imageSupportResultFromProbe(imageProbe); + return res.json({ + ok: true, + message: `Connected successfully — Model ${model} is available.`, + imageSupport, + supportsImage: imageSupport.supported, + imageCheckSource: imageSupport.source, + }); } return res.json({ ok: false, error: probe.error }); diff --git a/ui/server/routes/config.test.js b/ui/server/routes/config.test.js index 40b0d9ad3..b9eb5b2cc 100644 --- a/ui/server/routes/config.test.js +++ b/ui/server/routes/config.test.js @@ -51,13 +51,19 @@ describe('config test-connection route', () => { }); expect(data.ok).toBe(true); - expect(calls).toEqual(['https://api.openai.com/v1/chat/completions']); + expect(data.supportsImage).toBe(true); + expect(data.imageCheckSource).toBe('probe'); + expect(data.imageSupport).toMatchObject({ status: 'supported', supported: true }); + expect(calls).toEqual([ + 'https://api.openai.com/v1/chat/completions', + 'https://api.openai.com/v1/chat/completions', + ]); }); it('allows enough completion tokens for reasoning models to return chat text', async () => { - let requestBody; + const requestBodies = []; vi.stubGlobal('fetch', vi.fn(async (_url, options) => { - requestBody = JSON.parse(options.body); + requestBodies.push(JSON.parse(options.body)); return jsonResponse({ choices: [{ message: { content: 'ok' } }] }); })); @@ -73,7 +79,7 @@ describe('config test-connection route', () => { }); expect(data.ok).toBe(true); - expect(requestBody).toMatchObject({ + expect(requestBodies[0]).toMatchObject({ model: 'kimi-k3', max_tokens: 8, messages: [{ role: 'user', content: 'Reply exactly: 1' }], @@ -105,16 +111,22 @@ describe('config test-connection route', () => { expect(calls).toEqual([ 'https://api.openai.com/v1/chat/completions', 'https://api.openai.com/chat/completions', + 'https://api.openai.com/chat/completions', ]); }); it('falls back to unversioned chat completions when protocol-versioned probing returns unexpected JSON', async () => { const calls = []; - vi.stubGlobal('fetch', vi.fn(async (url) => { + vi.stubGlobal('fetch', vi.fn(async (url, init) => { calls.push(String(url)); + const body = init?.body ? JSON.parse(init.body) : {}; + const hasImage = JSON.stringify(body).includes('image_url'); if (String(url) === 'https://api.openai.com/v1/chat/completions') { return jsonResponse({ ok: true }); } + if (hasImage) { + return jsonResponse({ choices: [{ message: { content: 'image ok' } }] }); + } return jsonResponse({ choices: [{ message: { content: 'ok' } }] }); })); @@ -130,9 +142,51 @@ describe('config test-connection route', () => { }); expect(data.ok).toBe(true); + expect(data.supportsImage).toBe(true); expect(calls).toEqual([ 'https://api.openai.com/v1/chat/completions', 'https://api.openai.com/chat/completions', + 'https://api.openai.com/chat/completions', + ]); + }); + + it('returns supportsImage false when the validated endpoint rejects image input', async () => { + const calls = []; + vi.stubGlobal('fetch', vi.fn(async (url, init) => { + calls.push(String(url)); + const body = init?.body ? JSON.parse(init.body) : {}; + const hasImage = JSON.stringify(body).includes('image_url'); + if (hasImage) { + return jsonResponse( + { error: { message: 'image input not supported' } }, + { ok: false, status: 400, statusText: 'Bad Request' }, + ); + } + return jsonResponse({ choices: [{ message: { content: 'ok' } }] }); + })); + + const { request } = await createConfigApp(); + const data = await request('/api/config/test-connection', { + method: 'POST', + body: JSON.stringify({ + providerType: 'openai', + baseUrl: 'https://api.openai.com', + apiKey: 'sk-test', + model: 'gpt-test', + }), + }); + + expect(data.ok).toBe(true); + expect(data.supportsImage).toBe(false); + expect(data.imageCheckSource).toBe('probe'); + expect(data.imageSupport).toMatchObject({ + status: 'unsupported', + supported: false, + reasonCode: 'explicit_unsupported', + }); + expect(calls).toEqual([ + 'https://api.openai.com/v1/chat/completions', + 'https://api.openai.com/v1/chat/completions', ]); }); @@ -161,6 +215,7 @@ describe('config test-connection route', () => { expect(calls).toEqual([ 'https://api.anthropic.com/v1/messages', 'https://api.anthropic.com/messages', + 'https://api.anthropic.com/messages', ]); }); @@ -189,6 +244,7 @@ describe('config test-connection route', () => { expect(calls).toEqual([ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent', 'https://generativelanguage.googleapis.com/models/gemini-pro:generateContent', + 'https://generativelanguage.googleapis.com/models/gemini-pro:generateContent', ]); }); @@ -211,7 +267,10 @@ describe('config test-connection route', () => { }); expect(data.ok).toBe(true); - expect(calls).toEqual(['https://api.openai.com/v1/chat/completions']); + expect(calls).toEqual([ + 'https://api.openai.com/v1/chat/completions', + 'https://api.openai.com/v1/chat/completions', + ]); }); it('accepts full OpenAI-compatible endpoint URLs', async () => { @@ -233,7 +292,10 @@ describe('config test-connection route', () => { }); expect(data.ok).toBe(true); - expect(calls).toEqual(['https://api.openai.com/v1/chat/completions']); + expect(calls).toEqual([ + 'https://api.openai.com/v1/chat/completions', + 'https://api.openai.com/v1/chat/completions', + ]); }); it('fails when the provider returns no chat text or reasoning output', async () => { @@ -318,8 +380,11 @@ describe('config test-connection route', () => { }); expect(data.ok).toBe(true); - expect(calls).toEqual(['http://localhost:11434/v1/chat/completions']); - expect(authHeaders).toEqual([undefined]); + expect(calls).toEqual([ + 'http://localhost:11434/v1/chat/completions', + 'http://localhost:11434/v1/chat/completions', + ]); + expect(authHeaders).toEqual([undefined, undefined]); }); }); diff --git a/ui/server/services/modelConnectionProbe.js b/ui/server/services/modelConnectionProbe.js index ac508ebfd..679ac7d14 100644 --- a/ui/server/services/modelConnectionProbe.js +++ b/ui/server/services/modelConnectionProbe.js @@ -112,14 +112,14 @@ function requestFor({ protocol, apiKey, model, image, maxTokens }) { */ // Onboarding needs enough output budget for reasoning models to emit their // visible answer. The legacy config endpoint passes its historical 8/16 value. -export async function probeModelConnection({ protocol, baseUrl, apiKey = '', model, image = false, maxTokens = 256, signal }) { +export async function probeModelConnection({ protocol, baseUrl, endpointUrl, apiKey = '', model, image = false, maxTokens = 256, signal }) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new NetworkFetchError('network_timeout', 'Connection timed out.')), TIMEOUT_MS); const forwardAbort = () => controller.abort(signal.reason); if (signal?.aborted) forwardAbort(); else signal?.addEventListener('abort', forwardAbort, { once: true }); try { - const urls = buildProviderChatEndpointCandidates({ protocol, baseUrl, model }); + const urls = endpointUrl ? [endpointUrl] : buildProviderChatEndpointCandidates({ protocol, baseUrl, model }); const request = requestFor({ protocol, apiKey, model, image, maxTokens }); let last = null; for (const url of urls) { @@ -134,7 +134,7 @@ export async function probeModelConnection({ protocol, baseUrl, apiKey = '', mod let body; try { body = JSON.parse(responseText); } catch { body = null; } if (isExpectedProviderResponseShape(protocol, body) && !hasErrorFinish(body, protocol) && hasUsableOutput(body, protocol)) { - return { ok: true }; + return { ok: true, endpointUrl: url }; } last = { detail: isExpectedProviderResponseShape(protocol, body) ? hasErrorFinish(body, protocol) diff --git a/ui/server/services/modelConnectionProbe.test.js b/ui/server/services/modelConnectionProbe.test.js index 646cc0a15..23133d196 100644 --- a/ui/server/services/modelConnectionProbe.test.js +++ b/ui/server/services/modelConnectionProbe.test.js @@ -17,11 +17,53 @@ describe('model connection probe request formats', () => { return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(response) }; })); const result = await probeModelConnection({ protocol, baseUrl: 'https://example.test/v1', apiKey: 'key', model: 'test-model', image: true }); - expect(result).toEqual({ ok: true }); + expect(result).toMatchObject({ ok: true }); assertBody(requestBody); }); } + it('returns the endpoint URL that passed the text probe after fallback', async () => { + const calls = []; + vi.stubGlobal('fetch', vi.fn(async (url) => { + calls.push(String(url)); + if (String(url) === 'https://example.test/v1/chat/completions') { + return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify({ ok: true }) }; + } + return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify({ choices: [{ message: { content: 'ok' } }] }) }; + })); + + const result = await probeModelConnection({ protocol: 'openai', baseUrl: 'https://example.test', apiKey: 'key', model: 'test-model' }); + + expect(result).toMatchObject({ + ok: true, + endpointUrl: 'https://example.test/chat/completions', + }); + expect(calls).toEqual([ + 'https://example.test/v1/chat/completions', + 'https://example.test/chat/completions', + ]); + }); + + it('uses only the provided endpoint URL for image probes', async () => { + const calls = []; + vi.stubGlobal('fetch', vi.fn(async (url) => { + calls.push(String(url)); + return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify({ choices: [{ message: { content: 'ok' } }] }) }; + })); + + const result = await probeModelConnection({ + protocol: 'openai', + baseUrl: 'https://example.test', + endpointUrl: 'https://example.test/chat/completions', + apiKey: 'key', + model: 'test-model', + image: true, + }); + + expect(result).toMatchObject({ ok: true, endpointUrl: 'https://example.test/chat/completions' }); + expect(calls).toEqual(['https://example.test/chat/completions']); + }); + it('preserves an explicit image-unsupported response before endpoint fallback', async () => { const fetch = vi.fn(async () => ({ ok: false, status: 400, statusText: 'Bad Request', text: async () => JSON.stringify({ error: { message: 'This model does not support image input' } }) })); vi.stubGlobal('fetch', fetch);