From 15bd55d3a9dc98a5e52180979171bcdc553e28e9 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Thu, 19 Mar 2026 22:08:31 +0900 Subject: [PATCH] proxy, ui-svelte: add /sdapi/v1 endpoint support (#587) Add proxy routes for stable-diffusion.cpp's /sdapi/v1/txt2img, /sdapi/v1/img2img, and /sdapi/v1/loras endpoints. POST endpoints use proxyInferenceHandler (model in JSON body), GET /loras uses proxyGETModelHandler (model in query param). Update the image playground with a dual-mode UI supporting both OpenAI and SDAPI backends. In SDAPI mode, loras are fetched first to prime the server-side cache, and all txt2img parameters are exposed (negative prompt, steps, cfg_scale, seed, batch_size, clip_skip, sampler, scheduler, lora selection with multipliers). - Add 3 sdapi route registrations in proxymanager.go - Add sdApi.ts client with generateSdImage and fetchSdLoras - Add SDAPI types (SdApiTxt2ImgRequest, SdApiResponse, etc.) - Add /sdapi to vite dev proxy config - Add backend tests for sdapi routing - Support batch image display in gallery grid https://claude.ai/code/session_0186MGX6NXdHVBTv2KH45fqn --------- Co-authored-by: Claude --- .github/workflows/go-ci.yml | 6 +- cmd/simple-responder/simple-responder.go | 37 ++ go.mod | 2 +- proxy/proxymanager.go | 5 + proxy/proxymanager_test.go | 79 ++++ .../playground/ImageInterface.svelte | 343 ++++++++++++++++-- ui-svelte/src/lib/sdApi.ts | 39 ++ ui-svelte/src/lib/types.ts | 34 ++ ui-svelte/vite.config.ts | 1 + 9 files changed, 514 insertions(+), 32 deletions(-) create mode 100644 ui-svelte/src/lib/sdApi.ts diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index aa51b1b..0a95ab2 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.23' + go-version-file: go.mod # Only run in this linux based runner - name: Check Formatting @@ -51,7 +51,7 @@ jobs: uses: actions/cache/restore@v4 with: path: ./build - key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }} + key: ${{ runner.os }}-simple-responder-${{ hashFiles('cmd/simple-responder/simple-responder.go') }} # necessary for testing proxy/Process swapping - name: Create simple-responder @@ -67,4 +67,4 @@ jobs: key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }} - name: Test all - run: make test-all \ No newline at end of file + run: make test-all diff --git a/cmd/simple-responder/simple-responder.go b/cmd/simple-responder/simple-responder.go index ffe3c88..a08c93b 100644 --- a/cmd/simple-responder/simple-responder.go +++ b/cmd/simple-responder/simple-responder.go @@ -274,6 +274,43 @@ func main() { c.String(200, fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path)) }) + // SD API endpoints + r.POST("/sdapi/v1/txt2img", func(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read request body"}) + return + } + defer c.Request.Body.Close() + + modelName := gjson.GetBytes(body, "model").String() + c.JSON(http.StatusOK, gin.H{ + "model": modelName, + "images": []string{}, + }) + }) + + r.POST("/sdapi/v1/img2img", func(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read request body"}) + return + } + defer c.Request.Body.Close() + + modelName := gjson.GetBytes(body, "model").String() + c.JSON(http.StatusOK, gin.H{ + "model": modelName, + "images": []string{}, + }) + }) + + r.GET("/sdapi/v1/loras", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "loras": []string{}, + }) + }) + address := "127.0.0.1:" + *port // Address with the specified port srv := &http.Server{ diff --git a/go.mod b/go.mod index 1268bb8..bf7db2c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mostlygeek/llama-swap -go 1.25.4 +go 1.25.1 require ( github.com/billziss-gh/golib v0.2.0 diff --git a/proxy/proxymanager.go b/proxy/proxymanager.go index c5042ba..a04d1cc 100644 --- a/proxy/proxymanager.go +++ b/proxy/proxymanager.go @@ -346,6 +346,11 @@ func (pm *ProxyManager) setupGinEngine() { pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler) pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyOAIPostFormHandler) + // sd.cpp /sdapi/v1 endpoints + pm.ginEngine.POST("/sdapi/v1/txt2img", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler) + pm.ginEngine.POST("/sdapi/v1/img2img", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler) + pm.ginEngine.GET("/sdapi/v1/loras", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyGETModelHandler) + pm.ginEngine.GET("/v1/models", pm.apiKeyAuth(), pm.listModelsHandler) // in proxymanager_loghandlers.go diff --git a/proxy/proxymanager_test.go b/proxy/proxymanager_test.go index e3a42bf..dc59e0d 100644 --- a/proxy/proxymanager_test.go +++ b/proxy/proxymanager_test.go @@ -1659,3 +1659,82 @@ models: assert.Equal(t, "no", w.Header().Get("X-Accel-Buffering")) }) } + +func TestProxyManager_SdApiTxt2ImgRouting(t *testing.T) { + conf := config.AddDefaultGroupToConfig(config.Config{ + HealthCheckTimeout: 15, + Models: map[string]config.ModelConfig{ + "sd-model": getTestSimpleResponderConfig("sd-model"), + }, + LogLevel: "error", + }) + + proxy := New(conf) + defer proxy.StopProcesses(StopWaitForInflightRequest) + + t.Run("successful txt2img with model", func(t *testing.T) { + reqBody := `{"model":"sd-model","prompt":"a cat"}` + req := httptest.NewRequest("POST", "/sdapi/v1/txt2img", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "sd-model") + }) + + t.Run("successful img2img with model", func(t *testing.T) { + reqBody := `{"model":"sd-model","prompt":"a cat","init_images":[]}` + req := httptest.NewRequest("POST", "/sdapi/v1/img2img", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "sd-model") + }) + + t.Run("missing model returns 400", func(t *testing.T) { + reqBody := `{"prompt":"a cat"}` + req := httptest.NewRequest("POST", "/sdapi/v1/txt2img", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "missing or invalid 'model' key") + }) +} + +func TestProxyManager_SdApiGetLoras(t *testing.T) { + conf := config.AddDefaultGroupToConfig(config.Config{ + HealthCheckTimeout: 15, + Models: map[string]config.ModelConfig{ + "sd-model": getTestSimpleResponderConfig("sd-model"), + }, + LogLevel: "error", + }) + + proxy := New(conf) + defer proxy.StopProcesses(StopWaitForInflightRequest) + + t.Run("successful GET loras with model query param", func(t *testing.T) { + req := httptest.NewRequest("GET", "/sdapi/v1/loras?model=sd-model", nil) + w := CreateTestResponseRecorder() + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("missing model query param returns 400", func(t *testing.T) { + req := httptest.NewRequest("GET", "/sdapi/v1/loras", nil) + w := CreateTestResponseRecorder() + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "missing required 'model' query parameter") + }) + + t.Run("unknown model returns 400", func(t *testing.T) { + req := httptest.NewRequest("GET", "/sdapi/v1/loras?model=nonexistent", nil) + w := CreateTestResponseRecorder() + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "could not find suitable handler") + }) +} diff --git a/ui-svelte/src/components/playground/ImageInterface.svelte b/ui-svelte/src/components/playground/ImageInterface.svelte index 86e254f..7c0a5b9 100644 --- a/ui-svelte/src/components/playground/ImageInterface.svelte +++ b/ui-svelte/src/components/playground/ImageInterface.svelte @@ -2,26 +2,91 @@ import { models } from "../../stores/api"; import { persistentStore } from "../../stores/persistent"; import { generateImage } from "../../lib/imageApi"; + import { generateSdImage, fetchSdLoras } from "../../lib/sdApi"; import { playgroundStores } from "../../stores/playgroundActivity"; import ModelSelector from "./ModelSelector.svelte"; import ExpandableTextarea from "./ExpandableTextarea.svelte"; + import type { ImageApiMode, SdApiLora, SdApiLoraRef } from "../../lib/types"; const selectedModelStore = persistentStore("playground-image-model", ""); const selectedSizeStore = persistentStore("playground-image-size", "1024x1024"); + const apiModeStore = persistentStore("playground-image-api-mode", "openai"); + + // SDAPI persistent settings + const sdNegativePromptStore = persistentStore("playground-sdapi-negative-prompt", ""); + const sdStepsStore = persistentStore("playground-sdapi-steps", 20); + const sdCfgScaleStore = persistentStore("playground-sdapi-cfg-scale", 7); + const sdSeedStore = persistentStore("playground-sdapi-seed", -1); + const sdSamplerStore = persistentStore("playground-sdapi-sampler", ""); + const sdSchedulerStore = persistentStore("playground-sdapi-scheduler", ""); + const sdBatchSizeStore = persistentStore("playground-sdapi-batch-size", 1); let prompt = $state(""); let isGenerating = $state(false); - let generatedImage = $state(null); + let generatedImages = $state([]); let error = $state(null); let abortController = $state(null); let showFullscreen = $state(false); + let fullscreenIndex = $state(0); + let showSettings = $state(false); + + // SDAPI lora state + let availableLoras = $state([]); + let selectedLoras = $state([]); + let isLoadingLoras = $state(false); + let lorasLoaded = $state(false); + let loraError = $state(null); let hasModels = $derived($models.some((m) => !m.unlisted)); + let isSdapi = $derived($apiModeStore === "sdapi"); $effect(() => { playgroundStores.imageGenerating.set(isGenerating); }); + async function loadLoras() { + if (!$selectedModelStore || isLoadingLoras) return; + isLoadingLoras = true; + loraError = null; + try { + const loras = await fetchSdLoras($selectedModelStore); + availableLoras = loras; + lorasLoaded = true; + } catch (err) { + availableLoras = []; + loraError = err instanceof Error ? err.message : "Failed to load LoRAs"; + lorasLoaded = false; + } finally { + isLoadingLoras = false; + } + } + + function addLora(event: Event) { + const select = event.target as HTMLSelectElement; + const path = select.value; + if (!path) return; + + const lora = availableLoras.find((l) => l.path === path); + if (lora && !selectedLoras.some((l) => l.path === path)) { + selectedLoras = [...selectedLoras, { path: lora.path, multiplier: 1.0 }]; + } + select.value = ""; + } + + function removeLora(path: string) { + selectedLoras = selectedLoras.filter((l) => l.path !== path); + } + + function updateLoraMultiplier(path: string, multiplier: number) { + selectedLoras = selectedLoras.map((l) => + l.path === path ? { ...l, multiplier } : l + ); + } + + function getLoraName(path: string): string { + return availableLoras.find((l) => l.path === path)?.name ?? path; + } + async function generate() { const trimmedPrompt = prompt.trim(); if (!trimmedPrompt || !$selectedModelStore || isGenerating) return; @@ -31,19 +96,44 @@ abortController = new AbortController(); try { - const response = await generateImage( - $selectedModelStore, - trimmedPrompt, - $selectedSizeStore, - abortController.signal - ); + if (isSdapi) { + const [w, h] = $selectedSizeStore.split("x").map(Number); + const request = { + model: $selectedModelStore, + prompt: trimmedPrompt, + negative_prompt: $sdNegativePromptStore || undefined, + width: w, + height: h, + steps: $sdStepsStore, + cfg_scale: $sdCfgScaleStore, + seed: $sdSeedStore, + batch_size: $sdBatchSizeStore, + sampler_name: $sdSamplerStore || undefined, + scheduler: $sdSchedulerStore || undefined, + lora: selectedLoras.length > 0 ? selectedLoras : undefined, + }; - if (response.data && response.data.length > 0) { - const imageData = response.data[0]; - if (imageData.b64_json) { - generatedImage = `data:image/png;base64,${imageData.b64_json}`; - } else if (imageData.url) { - generatedImage = imageData.url; + const response = await generateSdImage(request, abortController.signal); + if (response.images && response.images.length > 0) { + generatedImages = response.images.map( + (img) => `data:image/png;base64,${img}` + ); + } + } else { + const response = await generateImage( + $selectedModelStore, + trimmedPrompt, + $selectedSizeStore, + abortController.signal + ); + + if (response.data && response.data.length > 0) { + const imageData = response.data[0]; + if (imageData.b64_json) { + generatedImages = [`data:image/png;base64,${imageData.b64_json}`]; + } else if (imageData.url) { + generatedImages = [imageData.url]; + } } } } catch (err) { @@ -63,28 +153,29 @@ } function clearImage() { - generatedImage = null; + generatedImages = []; error = null; prompt = ""; } - function downloadImage() { - if (!generatedImage) return; + function downloadImage(index: number = 0) { + const img = generatedImages[index]; + if (!img) return; const link = document.createElement("a"); - link.href = generatedImage; - link.download = `generated-image-${Date.now()}.png`; + link.href = img; + link.download = `generated-image-${Date.now()}-${index}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link); } - function openFullscreen() { + function openFullscreen(index: number = 0) { + fullscreenIndex = index; showFullscreen = true; } function closeFullscreen(event?: MouseEvent) { - // Only close if clicking the background, not the image if (event && event.target !== event.currentTarget) { return; } @@ -100,9 +191,19 @@
- +
+ + + + + {#if isSdapi} + + {/if}
+ + {#if isSdapi && showSettings} +
+
+ + + + + + +
+ + + + +
+ LoRAs +
+ + {#if lorasLoaded && availableLoras.length > 0} + + {/if} +
+ {#if loraError} +

{loraError}

+ {/if} + {#if lorasLoaded && availableLoras.length === 0} +

No LoRAs available

+ {/if} + {#if selectedLoras.length > 0} +
+ {#each selectedLoras as lora} +
+ {getLoraName(lora.path)} + updateLoraMultiplier(lora.path, parseFloat((e.target as HTMLInputElement).value) || 1)} + min="0" + max="2" + step="0.1" + /> + +
+ {/each} +
+ {/if} +
+
+ {/if} + {#if !hasModels}
@@ -143,22 +402,50 @@

Error

{error}

- {:else if generatedImage} + {:else if generatedImages.length > 1} + +
+ {#each generatedImages as img, i} +
+ + +
+ {/each} +
+ {:else if generatedImages.length === 1}
-{#if showFullscreen && generatedImage} +{#if showFullscreen && generatedImages[fullscreenIndex]}
closeFullscreen(e)} @@ -226,7 +513,7 @@ × AI generated content diff --git a/ui-svelte/src/lib/sdApi.ts b/ui-svelte/src/lib/sdApi.ts new file mode 100644 index 0000000..b8fa378 --- /dev/null +++ b/ui-svelte/src/lib/sdApi.ts @@ -0,0 +1,39 @@ +import type { SdApiTxt2ImgRequest, SdApiResponse, SdApiLora } from "./types"; + +export async function generateSdImage( + request: SdApiTxt2ImgRequest, + signal?: AbortSignal +): Promise { + const response = await fetch("/sdapi/v1/txt2img", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SDAPI error: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +export async function fetchSdLoras( + model: string, + signal?: AbortSignal +): Promise { + const response = await fetch( + `/sdapi/v1/loras?model=${encodeURIComponent(model)}`, + { signal } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SDAPI loras error: ${response.status} - ${errorText}`); + } + + return response.json(); +} diff --git a/ui-svelte/src/lib/types.ts b/ui-svelte/src/lib/types.ts index 890f41b..718c413 100644 --- a/ui-svelte/src/lib/types.ts +++ b/ui-svelte/src/lib/types.ts @@ -115,6 +115,40 @@ export interface ImageGenerationResponse { }>; } +// SDAPI types (stable-diffusion.cpp) +export type ImageApiMode = "openai" | "sdapi"; + +export interface SdApiLora { + name: string; + path: string; +} + +export interface SdApiLoraRef { + path: string; + multiplier: number; +} + +export interface SdApiTxt2ImgRequest { + model?: string; + prompt: string; + negative_prompt?: string; + width?: number; + height?: number; + steps?: number; + cfg_scale?: number; + seed?: number; + batch_size?: number; + sampler_name?: string; + scheduler?: string; + lora?: SdApiLoraRef[]; +} + +export interface SdApiResponse { + images: string[]; + parameters: Record; + info: string; +} + export interface AudioTranscriptionRequest { file: File; model: string; diff --git a/ui-svelte/vite.config.ts b/ui-svelte/vite.config.ts index 1b0c1dc..5904451 100644 --- a/ui-svelte/vite.config.ts +++ b/ui-svelte/vite.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ "/upstream": "http://localhost:8080", "/unload": "http://localhost:8080", "/v1": "http://localhost:8080", + "/sdapi": "http://localhost:8080", }, }, });