Files
llmgw/internal/router/images.go
T
Ilker ManapandClaude Opus 5.5 fde1501e8a ComfyUI as an engine of the cluster's own
A model whose profiles run engine comfyui is placed on a node's card like
any other, and makes pictures there. ComfyUI asks for no key, so the agent
starts it behind llmgw comfyui-gate: ComfyUI on the loopback, the gate on
the replica's port demanding the replica's key and letting through only
what the router asks — queueing, waiting, fetching, cancelling. The gate
answers the agent's /health and /v1/models, shows ComfyUI the fetched
weights by their own names, and gives each replica its own base directory.
ComfyUI runs as the gate's child, so its card memory is the replica's.

A node offers the engine when agent.yaml names its ComfyUI
(engines.comfyui.dir). The model's comfyui: block names the workflow and,
for a source that is a set of files, the checkpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 00:39:10 +02:00

286 lines
9.6 KiB
Go

package router
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/comfy"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
"gitea.mynodes.xyz/ilker/llmgw/internal/web"
)
// /v1/images/generations, OpenAI's shape, made by ComfyUI: the request goes
// through the same checks as any other — key, allowlist, limits, admission,
// guardrails on its prompt — and only at the replica is it turned into a
// workflow, queued and waited on, and its pictures sent back as base64.
const imagesPath = "/v1/images/generations"
// imageRequest is what a request for pictures asks. Beyond OpenAI's fields,
// those a diffusion model has and DALL·E does not: seed, steps,
// negative_prompt and guidance.
type imageRequest struct {
Prompt string `json:"prompt"`
N int `json:"n"`
Size string `json:"size"`
ResponseFormat string `json:"response_format"`
Seed *int64 `json:"seed"`
Steps int `json:"steps"`
NegativePrompt string `json:"negative_prompt"`
Guidance *float64 `json:"guidance"`
width, height int
}
// The bounds of a request. A FLUX latent is 1/16 of the picture, so a side
// that is not a multiple of 16 is not one it can make; the rest keep one
// request from holding a card for the length of a film.
const (
maxImages = 4
minSide = 256
maxSide = 2048
maxSteps = 100
defaultSide = 1024
)
func parseImageRequest(body []byte) (imageRequest, error) {
var in imageRequest
if err := json.Unmarshal(body, &in); err != nil {
return in, err
}
if strings.TrimSpace(in.Prompt) == "" {
return in, errors.New("prompt is required")
}
switch {
case in.N == 0:
in.N = 1
case in.N < 0 || in.N > maxImages:
return in, fmt.Errorf("n is between 1 and %d", maxImages)
}
switch in.ResponseFormat {
case "", "b64_json":
case "url":
// A URL would have to be served by somebody and kept for some time,
// and a picture made from a prompt is the prompt's owner's: it is
// not kept here.
return in, errors.New(`response_format "url" is not offered: the picture comes back as b64_json and is kept nowhere`)
default:
return in, fmt.Errorf("response_format %q is not b64_json", in.ResponseFormat)
}
in.width, in.height = defaultSide, defaultSide
if s := strings.TrimSpace(in.Size); s != "" && s != "auto" {
w, h, ok := strings.Cut(strings.ToLower(s), "x")
wi, err1 := strconv.Atoi(w)
hi, err2 := strconv.Atoi(h)
if !ok || err1 != nil || err2 != nil {
return in, fmt.Errorf("size %q is not WIDTHxHEIGHT", s)
}
in.width, in.height = wi, hi
}
for _, side := range []int{in.width, in.height} {
if side < minSide || side > maxSide || side%16 != 0 {
return in, fmt.Errorf("size %dx%d: each side is a multiple of 16 from %d to %d", in.width, in.height, minSide, maxSide)
}
}
if in.Steps < 0 || in.Steps > maxSteps {
return in, fmt.Errorf("steps is between 1 and %d", maxSteps)
}
if in.Guidance != nil && (*in.Guidance <= 0 || *in.Guidance > 30) {
return in, errors.New("guidance is above 0 and at most 30")
}
return in, nil
}
// imageRouteRefusal says why a request cannot be answered at the path it was
// sent to, before it takes a place in a queue: a picture asked of a model
// that writes text, or text of one that makes pictures. Empty when it can,
// and for a model the catalog does not know, which a hand-configured
// endpoint may serve as it likes.
func (r *Router) imageRouteRefusal(path, model string, body []byte) string {
m, known := r.modelMap()[model]
if path == imagesPath {
if known && !m.MakesImages() {
return fmt.Sprintf("%s does not make pictures", m.Name)
}
if _, err := parseImageRequest(body); err != nil {
return err.Error()
}
return ""
}
if known && m.ServesComfyUI() {
return fmt.Sprintf("%s makes pictures: ask for them at %s", m.Name, imagesPath)
}
return ""
}
// generateImages answers a request at a ComfyUI replica. It returns what the
// proxy would: a failure of this replica that another may not have is
// retryable, and nothing is written until the pictures are all in hand.
func (r *Router) generateImages(ctx context.Context, w http.ResponseWriter, req *parsedRequest, ep endpoint.Info, body []byte) (proxy.Outcome, error) {
if req.Path != imagesPath {
return proxy.Outcome{}, fmt.Errorf("%s is a ComfyUI server: it answers %s only", ep.ID, imagesPath)
}
in, err := parseImageRequest(body)
if err != nil {
return proxy.Outcome{}, err
}
var upstream struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &upstream)
tpl, err := comfy.Load(ep.Workflow)
if err != nil {
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
}
seed := comfy.RandomSeed()
if in.Seed != nil {
seed = *in.Seed
}
p := comfy.Params{
Checkpoint: upstream.Model, Prompt: in.Prompt, NegativePrompt: in.NegativePrompt,
Width: in.width, Height: in.height, N: in.N, Seed: seed, Steps: in.Steps,
}
if in.Guidance != nil {
p.Guidance = *in.Guidance
}
workflow, err := tpl.Fill(p)
if err != nil {
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
}
if t := r.cfg.Timeouts.Total; t > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, t)
defer cancel()
}
start := time.Now()
images, err := r.comfy.Generate(ctx, ep.URL, ep.APIKey, workflow)
if err != nil {
var ce *comfy.Error
switch {
case errors.As(err, &ce):
return proxy.Outcome{Retryable: ce.Retryable}, err
case errors.Is(err, context.Canceled) && ctxCauseIsClient(ctx):
return proxy.Outcome{}, proxy.ErrClientGone
case errors.Is(err, context.DeadlineExceeded):
return proxy.Outcome{}, fmt.Errorf("%s made no picture within %s", ep.ID, r.cfg.Timeouts.Total)
}
return proxy.Outcome{}, err
}
data := make([]map[string]any, 0, len(images))
for _, im := range images {
data = append(data, map[string]any{"b64_json": base64.StdEncoding.EncodeToString(im.Data)})
}
out, err := json.Marshal(map[string]any{"created": time.Now().Unix(), "data": data})
if err != nil {
return proxy.Outcome{}, err
}
// The seed that made them, so a picture liked can be made again.
w.Header().Set("x-llmgw-seed", strconv.FormatInt(seed, 10))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(out)
took := time.Since(start)
return proxy.Outcome{Wrote: true, Status: http.StatusOK, Bytes: int64(len(out)), TTFT: took, Decode: took,
Images: len(images)}, nil
}
// ctxCauseIsClient reports whether a context ended because the client went
// away, rather than because a deadline set here passed.
func ctxCauseIsClient(ctx context.Context) bool {
return !errors.Is(context.Cause(ctx), context.DeadlineExceeded)
}
// registerImagesPage mounts the page that makes pictures, beside the chat
// and for the same reasons: a model tried without a client, with the
// browser holding a session and never a key, and the request sent down the
// one path every API request takes.
func (r *Router) registerImagesPage(mux *http.ServeMux, pages *web.AccountPages) {
pages.SetImages(func() bool {
for _, m := range r.modelMap() {
if m.MakesImages() {
return true
}
}
return false
})
mux.HandleFunc("GET /images", pages.Guard(func(w http.ResponseWriter, req *http.Request, user store.User) {
contexts, _ := r.chatContexts(req.Context(), user)
current, err := r.pickContext(req.Context(), user, req.URL.Query().Get("context"))
if err != nil {
current = ""
}
if current == "" && !r.personalOff {
current = personalContext
}
web.Images(user, r.modelsOf(req, user, true), contexts, current, version.Version)(w, req)
}))
mux.HandleFunc("POST /images/generate", pages.Guard(r.imagesSend))
}
// imagesSend makes pictures on behalf of a signed-in person.
func (r *Router) imagesSend(w http.ResponseWriter, req *http.Request, user store.User) {
body, err := io.ReadAll(io.LimitReader(req.Body, r.cfg.MaxBodyBytes))
if err != nil {
http.Error(w, "could not read the request", http.StatusBadRequest)
return
}
var in struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Size string `json:"size"`
N int `json:"n"`
Seed *int64 `json:"seed"`
Steps int `json:"steps"`
NegativePrompt string `json:"negative_prompt"`
// Context is what the pictures are charged to, checked as the
// chat's is.
Context string `json:"context"`
}
if err := json.Unmarshal(body, &in); err != nil || in.Model == "" {
http.Error(w, "a model and a prompt are required", http.StatusBadRequest)
return
}
principal, err := r.principalFor(req, user, in.Context)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
// Rebuilt from the fields the page has, so it can set nothing else.
out := map[string]any{"model": in.Model, "prompt": in.Prompt, "size": in.Size, "n": in.N}
if in.Seed != nil {
out["seed"] = *in.Seed
}
if in.Steps > 0 {
out["steps"] = in.Steps
}
if s := strings.TrimSpace(in.NegativePrompt); s != "" {
out["negative_prompt"] = s
}
upstream, err := json.Marshal(out)
if err != nil {
http.Error(w, "could not build the request", http.StatusInternalServerError)
return
}
next := req.Clone(auth.WithPrincipal(req.Context(), principal))
next.Body = io.NopCloser(bytes.NewReader(upstream))
next.ContentLength = int64(len(upstream))
next.Header.Set("Content-Type", "application/json")
next.Header.Del(knowledgeHeader)
r.handleInference(imagesPath)(w, next)
}