Fix stable-diffusion.cpp model routing and URL path handling (#5427) * fix: include model field in sd.cpp SDAPI requests and preserve URL path The sd.cpp integration overwrites the URL pathname when constructing requests, which breaks proxy servers like llama-swap that use path-based routing (e.g. /upstream/model-name). Additionally, the model field was never included in SDAPI requests, which is required by llama-swap to route requests to the correct backend. Changes: - Server: Append to URL pathname instead of overwriting (same pattern as #5178) - Server: Pass model field through to sd-server payload - Client: Add model name text input for sd.cpp source settings - Client: Send model name in generate request payload * fix: fetch models from server and populate standard Model dropdown Instead of a separate text input for the model name, fetch the model list from the sd.cpp server's /v1/models endpoint and populate the standard Model dropdown. This provides a seamless experience where users just pick a model from the dropdown like any other source. Works with both standalone sd-server and proxy servers like llama-swap that expose multiple models via the OpenAI-compatible models endpoint. * fix: don't send clip_skip=1 to sd.cpp, it produces blank images sd-server generates blank white images when clip_skip is set to 1. Since clip_skip=1 means 'use all CLIP layers' (the default behavior), only send the parameter when it's > 1. * Fix eslint * Replace string appends with urlJoin * fix: convert URL strings to URL objects in sdcpp routes --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -1815,6 +1815,34 @@ async function loadAutoSamplers() { | |||
| 1815 | } | 1815 | } |
| 1816 | } | 1816 | } |
| 1817 | 1817 | ||
| 1818 | async function loadSdcppModels() { | ||
| 1819 | if (!extension_settings.sd.sdcpp_url) { | ||
| 1820 | return [{ value: '', text: 'N/A' }]; | ||
| 1821 | } | ||
| 1822 | |||
| 1823 | try { | ||
| 1824 | const result = await fetch('/api/sd/sdcpp/models', { | ||
| 1825 | method: 'POST', | ||
| 1826 | headers: getRequestHeaders(), | ||
| 1827 | body: JSON.stringify({ url: extension_settings.sd.sdcpp_url }), | ||
| 1828 | }); | ||
| 1829 | |||
| 1830 | if (!result.ok) { | ||
| 1831 | return [{ value: '', text: 'N/A' }]; | ||
| 1832 | } | ||
| 1833 | |||
| 1834 | const data = await result.json(); | ||
| 1835 | |||
| 1836 | if (data?.data?.length > 0) { | ||
| 1837 | return data.data.map(model => ({ value: model.id, text: model.name || model.id })); | ||
| 1838 | } | ||
| 1839 | } catch (error) { | ||
| 1840 | console.error('Failed to load sd.cpp models:', error); | ||
| 1841 | } | ||
| 1842 | |||
| 1843 | return [{ value: '', text: 'N/A' }]; | ||
| 1844 | } | ||
| 1845 | |||
| 1818 | async function loadSdcppSamplers() { | 1846 | async function loadSdcppSamplers() { |
| 1819 | // The sdcpp server does not provide an API for samplers, so we return the known list. | 1847 | // The sdcpp server does not provide an API for samplers, so we return the known list. |
| 1820 | return ['euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd']; | 1848 | return ['euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd']; |
| @@ -1910,7 +1938,7 @@ async function loadModels() { | |||
| 1910 | models = await loadAutoModels(); | 1938 | models = await loadAutoModels(); |
| 1911 | break; | 1939 | break; |
| 1912 | case sources.sdcpp: | 1940 | case sources.sdcpp: |
| 1913 | models = [{ value: '', text: 'N/A' }]; | 1941 | models = await loadSdcppModels(); |
| 1914 | break; | 1942 | break; |
| 1915 | case sources.drawthings: | 1943 | case sources.drawthings: |
| 1916 | models = await loadDrawthingsModels(); | 1944 | models = await loadDrawthingsModels(); |
| @@ -3858,6 +3886,7 @@ async function generateAutoImage(prompt, negativePrompt, signal) { | |||
| 3858 | async function generateSdcppImage(prompt, negativePrompt, signal) { | 3886 | async function generateSdcppImage(prompt, negativePrompt, signal) { |
| 3859 | const payload = { | 3887 | const payload = { |
| 3860 | url: extension_settings.sd.sdcpp_url, | 3888 | url: extension_settings.sd.sdcpp_url, |
| 3889 | model: extension_settings.sd.model || undefined, | ||
| 3861 | prompt: prompt, | 3890 | prompt: prompt, |
| 3862 | negative_prompt: negativePrompt, | 3891 | negative_prompt: negativePrompt, |
| 3863 | steps: extension_settings.sd.steps, | 3892 | steps: extension_settings.sd.steps, |
| @@ -833,8 +833,7 @@ const sdcpp = express.Router(); | |||
| 833 | 833 | ||
| 834 | sdcpp.post('/ping', async (request, response) => { | 834 | sdcpp.post('/ping', async (request, response) => { |
| 835 | try { | 835 | try { |
| 836 | const url = new URL(request.body.url); | 836 | const url = new URL(urlJoin(request.body.url, '/v1/images/generations')); |
| 837 | url.pathname = '/v1/images/generations'; | ||
| 838 | 837 | ||
| 839 | const result = await fetch(url, { method: 'OPTIONS' }); | 838 | const result = await fetch(url, { method: 'OPTIONS' }); |
| 840 | if (!result.ok) { | 839 | if (!result.ok) { |
| @@ -848,12 +847,29 @@ sdcpp.post('/ping', async (request, response) => { | |||
| 848 | } | 847 | } |
| 849 | }); | 848 | }); |
| 850 | 849 | ||
| 850 | sdcpp.post('/models', async (request, response) => { | ||
| 851 | try { | ||
| 852 | const url = new URL(urlJoin(request.body.url, '/v1/models')); | ||
| 853 | |||
| 854 | const result = await fetch(url); | ||
| 855 | if (!result.ok) { | ||
| 856 | throw new Error('stable-diffusion.cpp server returned an error.'); | ||
| 857 | } | ||
| 858 | |||
| 859 | const data = await result.json(); | ||
| 860 | return response.send(data); | ||
| 861 | } catch (error) { | ||
| 862 | console.error(error); | ||
| 863 | return response.sendStatus(500); | ||
| 864 | } | ||
| 865 | }); | ||
| 866 | |||
| 851 | sdcpp.post('/generate', async (request, response) => { | 867 | sdcpp.post('/generate', async (request, response) => { |
| 852 | try { | 868 | try { |
| 853 | const url = new URL(request.body.url); | 869 | const url = new URL(urlJoin(request.body.url, '/sdapi/v1/txt2img')); |
| 854 | url.pathname = '/sdapi/v1/txt2img'; | ||
| 855 | 870 | ||
| 856 | const payload = { | 871 | const payload = { |
| 872 | model: request.body.model, | ||
| 857 | prompt: request.body.prompt, | 873 | prompt: request.body.prompt, |
| 858 | negative_prompt: request.body.negative_prompt, | 874 | negative_prompt: request.body.negative_prompt, |
| 859 | width: request.body.width, | 875 | width: request.body.width, |
| @@ -864,7 +880,9 @@ sdcpp.post('/generate', async (request, response) => { | |||
| 864 | batch_size: request.body.batch_size, | 880 | batch_size: request.body.batch_size, |
| 865 | sampler_name: request.body.sampler_name, | 881 | sampler_name: request.body.sampler_name, |
| 866 | scheduler: request.body.scheduler, | 882 | scheduler: request.body.scheduler, |
| 867 | clip_skip: request.body.clip_skip, | 883 | // sd.cpp produces blank images when clip_skip is 1, which is the |
| 884 | // default (no skipping). Only send clip_skip when it's > 1. | ||
| 885 | clip_skip: request.body.clip_skip > 1 ? request.body.clip_skip : undefined, | ||
| 868 | }; | 886 | }; |
| 869 | 887 | ||
| 870 | for (const [key, value] of Object.entries(payload)) { | 888 | for (const [key, value] of Object.entries(payload)) { |