Files
majordomo/provider/llamaswap/upstream.go
T
steve 0ff90d80f6
Gadfly review (reusable) / review (pull_request) Successful in 5m7s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m7s
CI / Tidy (pull_request) Successful in 9m24s
CI / Build & Test (pull_request) Successful in 9m52s
feat(imagegen): face swap (identity transfer), a separate operation from Edit
Measured against the instruction-edit models on 2026-07-31: asking a diffusion
model to put a SPECIFIC person's face into a photo does not work by any route.
qwen-image-edit returns the picture essentially unchanged whether asked by
name, by attribute, or by supplying the portrait as a second reference image;
flux-kontext replaces the face with a different generic person. Identity
transfer is a detect/align/blend pipeline, not a better prompt, so it gets its
own interface rather than more Edit options.

imagegen.FaceSwapper is optional and type-asserted, like Editor — a provider
that cannot do this must not have Edit quietly stand in for it.

ListFaces is part of the interface, not a convenience: a caller asked to
change "the man on the right" needs a stable way to NAME one face, and pixel
boxes let it check its own choice. The llamaswap shim orders faces left to
right for exactly that reason (insightface's own order is score-ranked and
unstable between near-identical images), and a malformed box is a protocol
error rather than a zero-filled struct, because a wrong box aims the swap at
the wrong person.

The provider is the first here to POST more than one file, so buildMultipart
gained buildMultipartFiles and now delegates to it — one writer loop, so the
two cannot drift in how they escape names or terminate the body.

index and all are mutually exclusive ON THE WIRE: the shim ignores index under
all=true, and sending both would imply a precedence the caller cannot see.
A JSON body is refused rather than returned as image bytes — the shim answers
JSON on a semantic miss (no face in the source), and handing that back as a
picture would report success while delivering a file that is not one.
2026-07-31 12:25:12 -04:00

84 lines
3.3 KiB
Go

package llamaswap
import (
"bytes"
"fmt"
"mime/multipart"
"strings"
)
// upstreamPath builds a path through llama-swap's generic /upstream/<model>/
// passthrough, which pins the model (triggering the normal load/swap queue)
// and forwards the remaining path to the upstream verbatim. This is how the
// provider reaches upstreams whose native APIs carry no routable `model`
// field (rembg, mediautils, WhisperX, ACE-Step, ...) without needing a
// llama-swap route per endpoint (ADR-0020).
//
// Why reject rather than escape: same rationale as Unload — model ids
// legitimately contain ":" but never path-structure characters, and escaping
// would mask a config error instead of surfacing it. '%' is rejected too:
// ids never legitimately carry percent-escapes, and %2F/%2E%2E would decode
// back into path structure on the server side.
func upstreamPath(model, rest string) (string, error) {
if strings.TrimSpace(model) == "" {
return "", fmt.Errorf("llama-swap: upstream call requires a model id")
}
if strings.ContainsAny(model, "/?#%") || strings.Contains(model, "..") {
return "", fmt.Errorf("llama-swap: invalid model id %q for upstream call (contains a path separator)", model)
}
if !strings.HasPrefix(rest, "/") {
rest = "/" + rest
}
// rest may embed SERVER-SUPPLIED components (e.g. ACE-Step's result
// file URL) — refuse dot-dot segments and absolute-URL smuggling so a
// hostile/buggy upstream cannot redirect the follow-up request at
// another proxy endpoint (/api/models/unload, ...).
if strings.Contains(rest, "..") || strings.Contains(rest, "://") {
return "", fmt.Errorf("llama-swap: invalid upstream path %q (dot-dot or scheme)", rest)
}
return "/upstream/" + model + rest, nil
}
// filePart is the single file entry of a media multipart form.
type filePart struct {
field string // form field name ("file", "audio_file", ...)
filename string // already sanitized
data []byte
}
// buildMultipart assembles a one-file multipart body: the file part first,
// then the given fields (optional fields skipped when empty, matching
// writeFormFields). wrap labels errors. Returns the body and its content
// type.
func buildMultipart(wrap string, file filePart, fields []formField) (*bytes.Buffer, string, error) {
return buildMultipartFiles(wrap, []filePart{file}, fields)
}
// buildMultipartFiles is buildMultipart for endpoints taking SEVERAL files
// (face swap sends a target and a source). Files are written in the given
// order, then the fields. One writer loop serves both so the two cannot drift
// in how they escape names or terminate the body.
func buildMultipartFiles(wrap string, files []filePart, fields []formField) (*bytes.Buffer, string, error) {
if len(files) == 0 {
return nil, "", fmt.Errorf("llama-swap: %s: no file parts", wrap)
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for _, file := range files {
fw, err := w.CreateFormFile(file.field, file.filename)
if err != nil {
return nil, "", fmt.Errorf("llama-swap: %s: %w", wrap, err)
}
if _, err := fw.Write(file.data); err != nil {
return nil, "", fmt.Errorf("llama-swap: %s: %w", wrap, err)
}
}
if err := writeFormFields(w, wrap, fields); err != nil {
return nil, "", err
}
if err := w.Close(); err != nil {
return nil, "", fmt.Errorf("llama-swap: %s: %w", wrap, err)
}
return &buf, w.FormDataContentType(), nil
}