Files
majordomo/provider/llamaswap/edit_test.go
T
steve 434d721b99
CI / Tidy (pull_request) Successful in 10m9s
CI / Build & Test (pull_request) Successful in 11m16s
Adversarial Review (Gadfly) / review (pull_request) Successful in 14m51s
feat: audio surfaces (TTS + transcription), imagegen.Editor, llamaswap health probe
- New leaf package `audio` (ADR-0017): SpeechModel/SpeechProvider and
  TranscriptionModel/TranscriptionProvider with imagegen conventions
  (zero value = backend default, functional options + Apply, bytes
  in/out, never URLs). Root re-exports added.
- imagegen.Editor (ADR-0018): optional image-to-image interface —
  EditRequest carries the generation knobs plus Init image and
  denoising Strength; separate interface so existing Models keep
  compiling.
- provider/llamaswap implements all of it: POST /v1/audio/speech (JSON,
  raw-audio response, MIME from Content-Type with format fallback),
  POST /v1/audio/transcriptions (multipart, response_format=json),
  ListVoices (GET /v1/audio/voices?model=, tolerant of string-list and
  object-list shapes), POST /sdapi/v1/img2img (txt2img wire +
  init_images/denoising_strength, shared image decode), and Health(ctx)
  (GET /health) — a cheap liveness probe for often-offline hosts.
- Hermetic httptest coverage for every new wire shape and validation
  path; README sections + support-matrix footnote updated in the same
  commit (also corrects the stale /v1/images/generations claim — the
  image path has been SDAPI since the seed fix).

First consumer: mort's llamaswap media tool cluster (status / image /
TTS / STT agent tools against the netherstorm host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
2026-07-11 23:24:14 -04:00

111 lines
3.4 KiB
Go

package llamaswap
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo/imagegen"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
func editInit(t *testing.T) imagegen.Image {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(onePixelPNG)
if err != nil {
t.Fatalf("decode fixture: %v", err)
}
return imagegen.Image{MIME: "image/png", Data: raw}
}
func TestImageEdit(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/sdapi/v1/img2img" {
t.Errorf("path = %q", r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
im, _ := p.ImageModel("sd")
ed, ok := im.(imagegen.Editor)
if !ok {
t.Fatal("imageModel does not implement imagegen.Editor")
}
res, err := ed.Edit(context.Background(),
imagegen.EditRequest{Prompt: "make it night", Init: editInit(t)},
imagegen.WithEditStrength(0.6),
)
if err != nil {
t.Fatalf("Edit: %v", err)
}
if len(res.Images) != 1 || res.Images[0].MIME != "image/png" {
t.Fatalf("images = %+v", res.Images)
}
if gotBody["model"] != "sd" || gotBody["prompt"] != "make it night" {
t.Errorf("model/prompt = %v/%v", gotBody["model"], gotBody["prompt"])
}
inits, ok := gotBody["init_images"].([]any)
if !ok || len(inits) != 1 || inits[0] != onePixelPNG {
t.Errorf("init_images = %v, want the b64 fixture", gotBody["init_images"])
}
if gotBody["denoising_strength"] != 0.6 {
t.Errorf("denoising_strength = %v, want 0.6", gotBody["denoising_strength"])
}
}
func TestImageEditOmitsUnsetOverrides(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
im, _ := p.ImageModel("sd")
ed := im.(imagegen.Editor)
if _, err := ed.Edit(context.Background(), imagegen.EditRequest{Prompt: "x", Init: editInit(t)}); err != nil {
t.Fatalf("Edit: %v", err)
}
for _, k := range []string{"denoising_strength", "steps", "cfg_scale", "negative_prompt", "sample_method", "seed", "width", "height"} {
if v, ok := gotBody[k]; ok {
t.Errorf("unset request sent %q = %v, want omitted", k, v)
}
}
}
func TestImageEditValidation(t *testing.T) {
p := New(WithBaseURL("http://example.invalid"))
im, _ := p.ImageModel("sd")
ed := im.(imagegen.Editor)
cases := []struct {
name string
req imagegen.EditRequest
}{
{"empty prompt", imagegen.EditRequest{Prompt: " ", Init: imagegen.Image{Data: []byte{1}}}},
{"missing init", imagegen.EditRequest{Prompt: "x"}},
{"negative N", imagegen.EditRequest{Prompt: "x", Init: imagegen.Image{Data: []byte{1}}, N: -1}},
}
for _, tc := range cases {
if _, err := ed.Edit(context.Background(), tc.req); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("%s: err = %v, want ErrUnsupported", tc.name, err)
}
}
bad := 1.5
if _, err := ed.Edit(context.Background(), imagegen.EditRequest{Prompt: "x", Init: imagegen.Image{Data: []byte{1}}, Strength: &bad}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("out-of-range strength: err = %v, want ErrUnsupported", err)
}
}