4 Commits

Author SHA1 Message Date
steve cf2d83f157 fix: music poll resilience + hostile-URL guard + embed dup-index (gadfly round 1)
- pollResult tolerates up to 5 CONSECUTIVE bad polls (transport blip,
  unparseable payload, task momentarily absent) instead of killing a
  multi-minute exclusive-GPU job on the first hiccup; only status=2, a
  failure run, or ctx deadline aborts
- server-supplied result.File must be server-relative; combined with the
  upstreamPath dot-dot/scheme rejection this stops a hostile upstream
  from steering the follow-up GET at other proxy endpoints (test:
  ../../api/models/unload refused)
- WithSteps(<=0) rejected; embed responses repeating an index rejected;
  musicFormatMIME now wraps speechMIME (one format table, wav32
  normalized); poll interval is a test-shrinkable var (CI no longer
  burns 2s+ per music test); parens + comments per review

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 00:52:06 -04:00
steve cacce61ddc feat: musicgen + embeddings/rerank surfaces (ADR-0021, ADR-0022)
- NEW musicgen leaf package: blocking Generate over ACE-Step's async job
  queue (release_task -> poll query_result -> fetch file, all via
  /upstream); tolerant envelope parsing, double-encoded result handled
- NEW embeddings leaf package: EmbedModel + RerankModel as separate mints
  (two server instances on the host, llama.cpp #20085); InstructedQuery
  helper for Qwen3-style query/document asymmetry
- provider/llamaswap: /v1/embeddings + /v1/rerank clients with strict
  validation (index-ordered vectors, count mismatch and out-of-range
  index are hard errors; rerank sorted descending, minimal parser)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 00:50:36 -04:00
steve 4a752ffa2a fix: harden upstream path + binary-body validation (gadfly round 1)
CI / Tidy (pull_request) Successful in 9m24s
CI / Build & Test (pull_request) Successful in 10m17s
- upstreamPath rejects '..' in model ids AND in the rest path — the rest
  can embed SERVER-SUPPLIED components (ACE-Step result file URLs), so
  dot-dot/scheme smuggling toward other proxy endpoints is refused
- singleImageResult requires positive image evidence (sniffed magic OR
  declared image/*): an empty-Content-Type error page can no longer pass
  as 'the image' via sniffImageMIME's PNG-default labelling
- upscale/background responses get a dedicated 256MB cap (the 64MB cap
  is JSON-sized; a 4x PNG legitimately exceeds it)
- mesh JSON-detection widened (512-byte whitespace-tolerant peek + reject
  declared application/json)
- Transcribe now reuses buildMultipart; transcriptionFilename takes
  (filename, mime) so diarize shares it without a fake request struct;
  truncateForError stops shadowing builtin cap; OnlyMask doc de-ambiguated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 00:50:21 -04:00
steve e98493bcfb feat: media expansion surfaces — edit mask, upscale, background removal, interpolation, diarization, meshgen (ADR-0020)
CI / Tidy (pull_request) Successful in 9m23s
CI / Build & Test (pull_request) Successful in 10m24s
Adversarial Review (Gadfly) / review (pull_request) Successful in 15m37s
- imagegen.EditRequest.Mask -> sd-server img2img inpainting (white=repaint)
- imagegen.Upscaler + BackgroundRemover, videogen.Interpolator,
  audio.DiarizationModel: new optional provider-minted surfaces
- NEW meshgen leaf package (image->3D, glb/stl/obj)
- provider/llamaswap: all five via the /upstream/<model>/<path> passthrough
  (upstreamPath helper, shared one-file multipart builder); binary success
  bodies validated (non-image, non-video, JSON-mesh rejection); diarization
  pins output=json (vtt/srt drop speaker labels)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:52:34 -04:00
23 changed files with 2679 additions and 22 deletions
+113
View File
@@ -0,0 +1,113 @@
package audio
import "context"
// DiarizationRequest is a speaker-labelled transcription request ("who said
// what"). Audio is carried as bytes (never a URL), mirroring
// TranscriptionRequest. Zero values mean "backend default" (ADR-0020).
type DiarizationRequest struct {
// Audio is the encoded audio (or video container) to transcribe.
Audio []byte
// MIME is the audio MIME type (e.g. "audio/mpeg"); "" = let the backend
// sniff it.
MIME string
// Filename is the multipart filename hint some backends key their format
// detection on; "" derives one from MIME or falls back to "audio".
Filename string
// Language is a BCP-47/ISO-639 hint (e.g. "en"); "" = auto-detect.
Language string
// MinSpeakers / MaxSpeakers bound the speaker count when the caller knows
// it; 0 = let the backend estimate.
MinSpeakers int
MaxSpeakers int
}
// DiarizationSegment is one speaker turn.
type DiarizationSegment struct {
// Start and End are offsets into the audio, in seconds.
Start float64
End float64
// Speaker is the backend's per-file speaker label (e.g. "SPEAKER_00").
// Labels are relative to this one file — they are NOT stable identities
// across files.
Speaker string
// Text is the transcript of this turn.
Text string
}
// DiarizationResult is the canonical diarization result.
type DiarizationResult struct {
// Text is the full transcript, unlabelled.
Text string
// Language is the detected (or requested) language code; may be "".
Language string
// Segments are the speaker turns in time order.
Segments []DiarizationSegment
// Raw is the provider-native response object. May be nil.
Raw any
}
// DiarizationOption mutates a DiarizationRequest before it is sent.
type DiarizationOption func(*DiarizationRequest)
// WithDiarizationLanguage sets the language hint (e.g. "en").
func WithDiarizationLanguage(l string) DiarizationOption {
return func(r *DiarizationRequest) { r.Language = l }
}
// WithSpeakerBounds bounds the expected speaker count (0 leaves a bound
// unset).
func WithSpeakerBounds(minSpeakers, maxSpeakers int) DiarizationOption {
return func(r *DiarizationRequest) {
r.MinSpeakers, r.MaxSpeakers = minSpeakers, maxSpeakers
}
}
// Apply returns a copy of the request with all options applied.
func (r DiarizationRequest) Apply(opts ...DiarizationOption) DiarizationRequest {
for _, opt := range opts {
opt(&r)
}
return r
}
// DiarizationModel transcribes audio with speaker labels.
type DiarizationModel interface {
// Diarize converts the request's audio into speaker-labelled segments.
Diarize(ctx context.Context, req DiarizationRequest, opts ...DiarizationOption) (*DiarizationResult, error)
}
// DiarizationModelOption configures a DiarizationModel at construction time.
// Reserved for future per-model settings.
type DiarizationModelOption func(*DiarizationModelConfig)
// DiarizationModelConfig carries per-model construction settings.
type DiarizationModelConfig struct{}
// ApplyDiarizationModelOptions folds options into a config.
func ApplyDiarizationModelOptions(opts []DiarizationModelOption) DiarizationModelConfig {
var cfg DiarizationModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// DiarizationProvider mints diarization models bound to one backend.
type DiarizationProvider interface {
// Name is the registry identifier for the provider.
Name() string
// DiarizationModel returns a DiarizationModel bound to the given id
// (passed through to the backend verbatim; no catalog validation).
DiarizationModel(id string, opts ...DiarizationModelOption) (DiarizationModel, error)
}
@@ -0,0 +1,62 @@
# ADR-0020: Upstream-passthrough media surfaces (mask, upscale, background removal, interpolation, diarization, meshgen)
Status: Accepted (2026-07-12)
## Context
The llama-swap host is growing capabilities whose native HTTP APIs are not
OpenAI-shaped and carry no routable `model` field: rembg (`/api/remove`),
a Real-ESRGAN+RIFE shim (`/v1/upscale`, `/v1/interpolate`), a WhisperX
diarization sidecar (`/asr?diarize=true`), and Hunyuan3D (`/generate`).
llama-swap's fork already exposes a generic passthrough —
`/upstream/<model>/<any path>` — that pins the model, runs the normal
load/swap queue, and proxies the rest of the path verbatim.
Separately, sd-server's `/sdapi/v1/img2img` accepts an inpainting `mask`
field that `imagegen.EditRequest` could not express.
## Decision
1. **Route odd-shaped upstreams through `/upstream/<model>/<path>`** via a
shared `upstreamPath` helper (model-id validation identical to `Unload`:
reject `/?#`, never escape). No per-endpoint llama-swap routes; the
passthrough is the contract.
2. **Grow the existing leaf contracts instead of inventing parallel ones**,
keeping the ADR-0016→0019 conventions (functional options, zero value =
backend default, bytes-only I/O, `Raw` escape hatch, provider mints
model):
- `imagegen.EditRequest.Mask` (white = repaint, black = keep; backends
without mask support must reject, not ignore).
- `imagegen.Upscaler` / `UpscaleProvider` — super-resolution is not a
diffusion model, so it binds its own backend id.
- `imagegen.BackgroundRemover` / `BackgroundRemovalProvider` — request
`Net` selects the remover's internal network (rembg `model=` param);
`OnlyMask` returns the segmentation mask (the natural mask source for
inpainting).
- `videogen.Interpolator` / `InterpolationProvider` — fps boost or
slow-mo (`SlowMo` plays synthesized frames at the source rate; audio
stripped by the backend in that mode).
- `audio.DiarizationModel` / `DiarizationProvider` — speaker-labelled
segments; per-file `SPEAKER_NN` labels are explicitly NOT stable
identities.
3. **New `meshgen` leaf package** for image→3D (`Mesh{Data, Format, MIME}`,
formats glb/stl/obj). Image-to-3D only: text-to-3D is the caller-owned
composition imagegen → meshgen.
4. **Binary success bodies are validated before wrapping**: one-image
results reject non-image payloads, interpolation reuses the videogen
MIME rules and 512MB cap, meshes reject JSON-shaped "success" bodies
(a queue-full detail page must never become "the mesh"), and
diarization requires `output=json` because the vtt/srt shapes drop
speaker labels.
## Consequences
- provider/llamaswap gains five surfaces with no new wire machinery beyond
`upstreamPath` + a shared one-file-multipart builder.
- The passthrough couples majordomo to the fork's `/upstream` route
(upstream llama-swap has it too) and to each upstream's native API shape;
those shapes are pinned by the netherstorm image builds, not by version
negotiation — smoke tests on the host are the drift defence.
- `upstream.ignorePaths` (llama-swap config) can 409 asset-looking paths on
cold models; none of the paths used here match the default pattern, but
new surfaces must check.
+35
View File
@@ -0,0 +1,35 @@
# ADR-0021: musicgen interface (blocking Generate over an async job queue)
Status: Accepted (2026-07-12)
## Context
The llama-swap host gained ACE-Step 1.5 (full songs with vocals, seconds per
clip warm on the reference GPU). Its API is an async job queue —
`POST /release_task` → poll `POST /query_result``GET` the result file —
unlike every other majordomo media backend, which is one blocking call.
## Decision
- New `musicgen` leaf package, ADR-0016→0020 conventions: `Request{Prompt,
Lyrics, DurationSeconds, Format, Steps, Seed}`, `Result{Audio{Data, MIME},
Raw}`, functional options, zero value = backend default, bytes-only.
- **`Model.Generate` blocks, polling internally** (2s interval, ctx-bounded).
The one-call contract is the package's value: callers budget the whole job
with a context deadline exactly like imagegen/videogen, and the async
mechanics stay a provider detail. An async job surface (mirroring the
deliberately-deferred videogen one, ADR-0019) can come later if a caller
ever needs progress.
- provider/llamaswap reaches ACE-Step through the `/upstream/<model>/`
passthrough (ADR-0020). Envelope parsing is tolerant (`data` wrapper or
bare array; `result` arrives as a double-encoded JSON string) and the
result-file URL is routed back through the same upstream. `audio_duration`
is the v1 param name — an unknown field degrades to default clip length
upstream, never an error; verify at smoke.
## Consequences
- Music jobs occupy the exclusive GPU group like video; callers own the
timeout budget (mort keeps the tool timeout under its agent ceiling).
- The double-encoded `result` string and envelope shapes are pinned by the
netherstorm image build; host smoke tests are the drift defence.
@@ -0,0 +1,41 @@
# ADR-0022: embeddings + rerank interface
Status: Accepted (2026-07-12)
## Context
majordomo had no embedding or reranking surface at all. The llama-swap host
now runs two persistent CPU-only llama-server members (Qwen3-Embedding-0.6B
via `/v1/embeddings`, bge-reranker-v2-m3 via `/v1/rerank`), and mort wants a
reranking stage in memory retrieval with embedding-backed retrieval as a
later step.
## Decision
- New `embeddings` leaf package with TWO half-surfaces, split like audio's
Speech/Transcription: `EmbedModel`/`EmbedProvider` and
`RerankModel`/`RerankProvider`. They are separate mints because on the
reference host they are two DIFFERENT server instances — llama-server with
`--embeddings` and `--rerank` together returns all-zero embeddings
(llama.cpp #20085) — and because rerankers are cross-encoders, not
embedders.
- `EmbedResult.Vectors [][]float32` in input order (provider must order by
the response's `index`, never trust wire order). No `dimensions` param:
llama-server doesn't implement it; Matryoshka truncation is caller-side.
- `InstructedQuery(task, query)` helper encodes the instruction-aware
asymmetry (queries wrapped, documents bare) so call sites can't silently
degrade retrieval by forgetting the prefix.
- `RerankResult` sorted by descending score; parser reads ONLY
`results[].index` and `results[].relevance_score` because llama-server
documents the shape as subject to change. Scores are model-specific —
comparable within one response only.
## Consequences
- Callers get vectors/scores with strict validation (count mismatch, index
out of range, empty vector are hard errors — a silently missing vector is
a retrieval bug factory).
- llama-server's rerank scoring has open correctness issues for some models
(llama.cpp #16407); consumers must validate against a fixture before
trusting scores in production (mort gates its memory-rerank convar on
exactly that).
+166
View File
@@ -0,0 +1,166 @@
// Package embeddings is majordomo's canonical text-embedding and reranking
// surface (ADR-0022, following the ADR-0016→0021 leaf-contract lineage).
// Two small halves, split like audio's Speech/Transcription so backends can
// implement either:
//
// - EmbedModel turns texts into dense vectors (/v1/embeddings-style).
// - RerankModel scores documents against a query with a cross-encoder
// (/v1/rerank-style) — usually a DIFFERENT backend model than the
// embedder, hence a separate mint.
//
// Instruction-aware embedders (Qwen3-Embedding et al.) want queries wrapped
// as "Instruct: {task}\nQuery: {query}" while documents go in bare;
// InstructedQuery encodes that so callers don't hand-roll (and silently
// degrade retrieval) at each site.
package embeddings
import "context"
// EmbedRequest is a batch embedding request. Inputs are embedded
// independently; the result vector order matches the input order.
type EmbedRequest struct {
// Inputs are the texts to embed. Required (at least one).
Inputs []string
}
// EmbedResult is the canonical embedding result.
type EmbedResult struct {
// Vectors holds one embedding per input, in input order. Backends
// normalize per their own convention (llama-server: Euclidean-normalized).
Vectors [][]float32
// Raw is the provider-native response object. May be nil.
Raw any
}
// EmbedOption mutates an EmbedRequest before it is sent. Reserved: the
// request shape is deliberately minimal today.
type EmbedOption func(*EmbedRequest)
// Apply returns a copy of the request with all options applied.
func (r EmbedRequest) Apply(opts ...EmbedOption) EmbedRequest {
for _, opt := range opts {
opt(&r)
}
return r
}
// InstructedQuery wraps a retrieval QUERY for instruction-aware embedding
// models. Documents must NOT be wrapped — the asymmetry is the point, and
// getting it wrong silently costs retrieval quality. An empty task uses the
// generic web-search instruction the reference model was trained with.
func InstructedQuery(task, query string) string {
if task == "" {
task = "Given a web search query, retrieve relevant passages that answer the query"
}
return "Instruct: " + task + "\nQuery: " + query
}
// EmbedModel embeds texts as dense vectors.
type EmbedModel interface {
// Embed returns one vector per input, in input order.
Embed(ctx context.Context, req EmbedRequest, opts ...EmbedOption) (*EmbedResult, error)
}
// EmbedModelOption configures an EmbedModel at construction time. Reserved
// for future per-model settings.
type EmbedModelOption func(*EmbedModelConfig)
// EmbedModelConfig carries per-model construction settings.
type EmbedModelConfig struct{}
// ApplyEmbedModelOptions folds options into a config.
func ApplyEmbedModelOptions(opts []EmbedModelOption) EmbedModelConfig {
var cfg EmbedModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// EmbedProvider mints embedding models bound to one backend.
type EmbedProvider interface {
// Name is the registry identifier for the provider.
Name() string
// EmbedModel returns an EmbedModel bound to the given id (passed through
// to the backend verbatim; no catalog validation).
EmbedModel(id string, opts ...EmbedModelOption) (EmbedModel, error)
}
// RerankRequest scores documents against a query.
type RerankRequest struct {
// Query is the search query. Required.
Query string
// Documents are the candidate texts to score. Required (at least one).
Documents []string
// TopN limits how many results the backend returns; 0 = all.
TopN int
}
// RerankItem is one scored document.
type RerankItem struct {
// Index is the document's position in the request's Documents slice.
Index int
// Score is the backend's relevance score (higher = more relevant).
// Scales are model-specific — compare within one response only.
Score float64
}
// RerankResult is the canonical rerank result, sorted by descending Score.
type RerankResult struct {
// Results are the scored documents (top-N when the request bounded it).
Results []RerankItem
// Raw is the provider-native response object. May be nil.
Raw any
}
// RerankOption mutates a RerankRequest before it is sent.
type RerankOption func(*RerankRequest)
// WithTopN limits how many results the backend returns.
func WithTopN(n int) RerankOption { return func(r *RerankRequest) { r.TopN = n } }
// Apply returns a copy of the request with all options applied.
func (r RerankRequest) Apply(opts ...RerankOption) RerankRequest {
for _, opt := range opts {
opt(&r)
}
return r
}
// RerankModel scores documents against a query with a cross-encoder.
type RerankModel interface {
// Rerank returns scored documents sorted by descending relevance.
Rerank(ctx context.Context, req RerankRequest, opts ...RerankOption) (*RerankResult, error)
}
// RerankModelOption configures a RerankModel at construction time. Reserved
// for future per-model settings.
type RerankModelOption func(*RerankModelConfig)
// RerankModelConfig carries per-model construction settings.
type RerankModelConfig struct{}
// ApplyRerankModelOptions folds options into a config.
func ApplyRerankModelOptions(opts []RerankModelOption) RerankModelConfig {
var cfg RerankModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// RerankProvider mints rerank models bound to one backend.
type RerankProvider interface {
// Name is the registry identifier for the provider.
Name() string
// RerankModel returns a RerankModel bound to the given id (passed
// through to the backend verbatim; no catalog validation).
RerankModel(id string, opts ...RerankModelOption) (RerankModel, error)
}
+76
View File
@@ -0,0 +1,76 @@
package imagegen
import "context"
// BackgroundRemovalRequest asks a matting/segmentation backend to cut the
// subject out of an image. Zero values mean "backend default" (ADR-0020).
type BackgroundRemovalRequest struct {
// Image is the image to process. Required.
Image Image
// Net selects the remover's internal network where the backend offers
// several (rembg: "u2net", "birefnet-general", "isnet-general-use", ...);
// "" = backend default. This is NOT the provider model id — that is fixed
// when the BackgroundRemover is minted.
Net string
// OnlyMask returns the black/white segmentation mask instead of the
// cutout — WHITE marks the SUBJECT. To inpaint (repaint) the subject,
// use it directly as EditRequest.Mask; to repaint the BACKGROUND,
// invert it first (EditRequest.Mask is white-means-repaint).
OnlyMask bool
}
// BackgroundRemovalOption mutates a BackgroundRemovalRequest before it is sent.
type BackgroundRemovalOption func(*BackgroundRemovalRequest)
// WithBackgroundNet selects the remover's internal network.
func WithBackgroundNet(n string) BackgroundRemovalOption {
return func(r *BackgroundRemovalRequest) { r.Net = n }
}
// WithOnlyMask requests the segmentation mask instead of the cutout.
func WithOnlyMask() BackgroundRemovalOption {
return func(r *BackgroundRemovalRequest) { r.OnlyMask = true }
}
// Apply returns a copy of the request with all options applied.
func (r BackgroundRemovalRequest) Apply(opts ...BackgroundRemovalOption) BackgroundRemovalRequest {
for _, opt := range opts {
opt(&r)
}
return r
}
// BackgroundRemover cuts subjects out of images (RGBA PNG with transparent
// background, or the mask alone with OnlyMask).
type BackgroundRemover interface {
// RemoveBackground returns the cutout (or mask) as a one-image Result.
RemoveBackground(ctx context.Context, req BackgroundRemovalRequest, opts ...BackgroundRemovalOption) (*Result, error)
}
// BackgroundRemovalModelOption configures a BackgroundRemover at construction
// time. Reserved for future per-model settings.
type BackgroundRemovalModelOption func(*BackgroundRemovalModelConfig)
// BackgroundRemovalModelConfig carries per-model construction settings.
type BackgroundRemovalModelConfig struct{}
// ApplyBackgroundRemovalModelOptions folds options into a config.
func ApplyBackgroundRemovalModelOptions(opts []BackgroundRemovalModelOption) BackgroundRemovalModelConfig {
var cfg BackgroundRemovalModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// BackgroundRemovalProvider mints BackgroundRemovers bound to one backend.
type BackgroundRemovalProvider interface {
// Name is the registry identifier for the provider.
Name() string
// BackgroundRemovalModel returns a BackgroundRemover bound to the given
// id (passed through to the backend verbatim; no catalog validation).
BackgroundRemovalModel(id string, opts ...BackgroundRemovalModelOption) (BackgroundRemover, error)
}
+9
View File
@@ -12,6 +12,12 @@ type EditRequest struct {
// Init is the initial image the edit starts from. Required. // Init is the initial image the edit starts from. Required.
Init Image Init Image
// Mask restricts the edit to a region (inpainting): a single-channel or
// RGB image the same size as Init where WHITE pixels are repainted and
// BLACK pixels are kept. Empty = whole-image edit. Backends without mask
// support must reject a masked request rather than silently ignoring it.
Mask Image
// Strength is the denoising strength in [0,1] — how far the result may // Strength is the denoising strength in [0,1] — how far the result may
// depart from Init (0 = return the input, 1 = ignore it); nil = backend // depart from Init (0 = return the input, 1 = ignore it); nil = backend
// default. // default.
@@ -45,6 +51,9 @@ type EditRequest struct {
// are applied to a copy of the request, so an EditRequest value can be reused. // are applied to a copy of the request, so an EditRequest value can be reused.
type EditOption func(*EditRequest) type EditOption func(*EditRequest)
// WithEditMask restricts the edit to a region (white = repaint, black = keep).
func WithEditMask(m Image) EditOption { return func(r *EditRequest) { r.Mask = m } }
// WithEditStrength sets the denoising strength in [0,1]. // WithEditStrength sets the denoising strength in [0,1].
func WithEditStrength(s float64) EditOption { return func(r *EditRequest) { r.Strength = &s } } func WithEditStrength(s float64) EditOption { return func(r *EditRequest) { r.Strength = &s } }
+62
View File
@@ -0,0 +1,62 @@
package imagegen
import "context"
// UpscaleRequest asks a super-resolution backend to enlarge an image. Zero
// values mean "backend default", mirroring EditRequest (ADR-0020).
type UpscaleRequest struct {
// Image is the image to upscale. Required.
Image Image
// Scale is the enlargement factor (2 or 4 on the reference backend);
// 0 = backend default.
Scale int
}
// UpscaleOption mutates an UpscaleRequest before it is sent.
type UpscaleOption func(*UpscaleRequest)
// WithUpscaleScale sets the enlargement factor.
func WithUpscaleScale(s int) UpscaleOption { return func(r *UpscaleRequest) { r.Scale = s } }
// Apply returns a copy of the request with all options applied.
func (r UpscaleRequest) Apply(opts ...UpscaleOption) UpscaleRequest {
for _, opt := range opts {
opt(&r)
}
return r
}
// Upscaler enlarges images with a super-resolution model. It is its own
// small interface (not a method on Model) because upscalers are not
// diffusion models — they bind to a different backend id entirely.
type Upscaler interface {
// Upscale returns the enlarged image (a one-image Result).
Upscale(ctx context.Context, req UpscaleRequest, opts ...UpscaleOption) (*Result, error)
}
// UpscaleModelOption configures an Upscaler at construction time. Reserved
// for future per-model settings (mirrors ModelOption).
type UpscaleModelOption func(*UpscaleModelConfig)
// UpscaleModelConfig carries per-model construction settings.
type UpscaleModelConfig struct{}
// ApplyUpscaleModelOptions folds options into a config.
func ApplyUpscaleModelOptions(opts []UpscaleModelOption) UpscaleModelConfig {
var cfg UpscaleModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// UpscaleProvider mints Upscalers bound to one backend.
type UpscaleProvider interface {
// Name is the registry identifier for the provider.
Name() string
// UpscaleModel returns an Upscaler bound to the given id (passed through
// to the backend verbatim; no catalog validation).
UpscaleModel(id string, opts ...UpscaleModelOption) (Upscaler, error)
}
+152
View File
@@ -0,0 +1,152 @@
// Package meshgen is majordomo's canonical image-to-3D surface. Like
// imagegen/audio/videogen, it is a deliberately separate leaf contract from
// the llm package: mesh generation shares none of the chat machinery, so it
// gets its own small Provider/Model interfaces (ADR-0020, following the
// ADR-0016→0019 lineage: functional options, zero values = backend default,
// bytes-only I/O, Raw escape hatch).
//
// The first implementation is provider/llamaswap, which targets a
// Hunyuan3D-2.1-style api_server reached through the /upstream passthrough.
// The surface is image-to-3D only: text-to-3D is the composition
// imagegen.Generate → meshgen.Generate, owned by the caller.
package meshgen
import (
"context"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// Image is the conditioning input (bytes + MIME), aliased to llm.ImagePart so
// chat- or imagegen-sourced images feed mesh generation without conversion.
type Image = llm.ImagePart
// Mesh is one generated 3D asset: raw encoded bytes plus its format.
type Mesh struct {
// Data is the encoded mesh (binary GLB/STL/OBJ container).
Data []byte
// Format is the lowercase container name ("glb", "stl", "obj").
Format string
// MIME is the corresponding MIME type, e.g. "model/gltf-binary",
// "model/stl".
MIME string
}
// Request is an image-to-3D generation request. Zero values mean "backend
// default".
type Request struct {
// Image is the source image the mesh is reconstructed from. Required.
Image Image
// Format is the requested output container ("glb", "stl", "obj");
// "" = backend default (glb).
Format string
// Texture asks the backend to also synthesize surface textures. Much
// slower and much more VRAM-hungry than shape-only on the reference
// backend; leave false for print-pipeline geometry.
Texture bool
// RemoveBackground lets the backend cut the subject out first; nil =
// backend default (true on Hunyuan3D). Set explicitly to false when the
// input is already a clean cutout.
RemoveBackground *bool
// OctreeResolution is the shape-decoder grid resolution; 0 = backend
// default.
OctreeResolution int
// Steps is the number of diffusion steps; nil = backend default.
Steps *int
// GuidanceScale is the guidance strength; nil = backend default.
GuidanceScale *float64
// Seed fixes the RNG seed for reproducible output; nil = backend default.
Seed *int64
// FaceCount caps the output mesh's face count; 0 = backend default.
FaceCount int
}
// Result is the canonical mesh-generation result.
type Result struct {
// Mesh is the generated asset.
Mesh Mesh
// Raw is the provider-native response object. May be nil.
Raw any
}
// Option mutates a Request before it is sent. Options passed to Generate are
// applied to a copy of the request, so a Request value can be reused.
type Option func(*Request)
// WithFormat sets the output container ("glb", "stl", "obj").
func WithFormat(f string) Option { return func(r *Request) { r.Format = f } }
// WithTexture enables texture synthesis.
func WithTexture() Option { return func(r *Request) { r.Texture = true } }
// WithRemoveBackground sets the backend's background-removal preprocessing.
func WithRemoveBackground(v bool) Option {
return func(r *Request) { r.RemoveBackground = &v }
}
// WithOctreeResolution sets the shape-decoder grid resolution.
func WithOctreeResolution(n int) Option { return func(r *Request) { r.OctreeResolution = n } }
// WithSteps overrides the number of diffusion steps.
func WithSteps(n int) Option { return func(r *Request) { r.Steps = &n } }
// WithGuidanceScale overrides the guidance strength.
func WithGuidanceScale(s float64) Option { return func(r *Request) { r.GuidanceScale = &s } }
// WithSeed fixes the RNG seed.
func WithSeed(seed int64) Option { return func(r *Request) { r.Seed = &seed } }
// WithFaceCount caps the output mesh's face count.
func WithFaceCount(n int) Option { return func(r *Request) { r.FaceCount = n } }
// Apply returns a copy of the request with all options applied. Providers
// call this once at the top of Generate.
func (r Request) Apply(opts ...Option) Request {
for _, opt := range opts {
opt(&r)
}
return r
}
// Model generates 3D meshes from images.
type Model interface {
// Generate reconstructs a mesh from the request's image.
Generate(ctx context.Context, req Request, opts ...Option) (*Result, error)
}
// ModelOption configures a Model at construction time. Reserved for future
// per-model settings.
type ModelOption func(*ModelConfig)
// ModelConfig carries per-model construction settings.
type ModelConfig struct{}
// ApplyModelOptions folds options into a config.
func ApplyModelOptions(opts []ModelOption) ModelConfig {
var cfg ModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// Provider mints mesh models bound to one backend.
type Provider interface {
// Name is the registry identifier for the provider.
Name() string
// MeshModel returns a Model bound to the given id (passed through to the
// backend verbatim; no catalog validation).
MeshModel(id string, opts ...ModelOption) (Model, error)
}
+120
View File
@@ -0,0 +1,120 @@
// Package musicgen is majordomo's canonical music-generation surface (full
// songs from a text prompt, optionally with lyrics). Like imagegen/audio/
// videogen/meshgen, it is a deliberately separate leaf contract from the llm
// package (ADR-0021, following the ADR-0016→0020 lineage: functional
// options, zero values = backend default, bytes-only I/O, Raw escape hatch).
//
// The first implementation is provider/llamaswap, which targets an
// ACE-Step-1.5-style async job API reached through the /upstream
// passthrough; Generate blocks (polling internally) so callers get the
// imagegen-style one-call contract — bound it with a context deadline.
package musicgen
import "context"
// Audio is one generated piece: raw encoded bytes plus a MIME type.
type Audio struct {
// Data is the encoded audio container.
Data []byte
// MIME is the audio MIME type, e.g. "audio/mpeg".
MIME string
}
// Request is a music-generation request. Zero values mean "backend default".
type Request struct {
// Prompt describes the music (genre, mood, instrumentation, tempo...).
Prompt string
// Lyrics are optional song lyrics for models that sing; "" =
// instrumental or model-written lyrics, per backend behavior.
Lyrics string
// DurationSeconds is the requested clip length; 0 = backend default.
DurationSeconds int
// Format is the audio container ("mp3", "wav", "flac", "opus", "aac");
// "" = backend default (mp3 on ACE-Step).
Format string
// Steps is the number of inference steps; nil = backend default.
Steps *int
// Seed fixes the RNG seed for reproducible output; nil = backend
// default (random).
Seed *int64
}
// Result is the canonical music-generation result.
type Result struct {
// Audio is the generated piece.
Audio Audio
// Raw is the provider-native response object (e.g. the job result with
// bpm/keyscale metadata). May be nil.
Raw any
}
// Option mutates a Request before it is sent. Options passed to Generate are
// applied to a copy of the request, so a Request value can be reused.
type Option func(*Request)
// WithLyrics sets song lyrics.
func WithLyrics(l string) Option { return func(r *Request) { r.Lyrics = l } }
// WithDuration sets the requested clip length in seconds.
func WithDuration(seconds int) Option {
return func(r *Request) { r.DurationSeconds = seconds }
}
// WithFormat sets the audio container format.
func WithFormat(f string) Option { return func(r *Request) { r.Format = f } }
// WithSteps overrides the number of inference steps.
func WithSteps(n int) Option { return func(r *Request) { r.Steps = &n } }
// WithSeed fixes the RNG seed.
func WithSeed(seed int64) Option { return func(r *Request) { r.Seed = &seed } }
// Apply returns a copy of the request with all options applied. Providers
// call this once at the top of Generate.
func (r Request) Apply(opts ...Option) Request {
for _, opt := range opts {
opt(&r)
}
return r
}
// Model generates music from text.
type Model interface {
// Generate renders the request as one audio clip. It blocks until the
// backend finishes (or ctx expires) even when the backend is an async
// job queue — polling is the provider's business, not the caller's.
Generate(ctx context.Context, req Request, opts ...Option) (*Result, error)
}
// ModelOption configures a Model at construction time. Reserved for future
// per-model settings.
type ModelOption func(*ModelConfig)
// ModelConfig carries per-model construction settings.
type ModelConfig struct{}
// ApplyModelOptions folds options into a config.
func ApplyModelOptions(opts []ModelOption) ModelConfig {
var cfg ModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// Provider mints music models bound to one backend.
type Provider interface {
// Name is the registry identifier for the provider.
Name() string
// MusicModel returns a Model bound to the given id (passed through to
// the backend verbatim; no catalog validation).
MusicModel(id string, opts ...ModelOption) (Model, error)
}
+9 -19
View File
@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"io" "io"
"mime" "mime"
"mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -119,28 +118,19 @@ func (m *transcriptionModel) Transcribe(ctx context.Context, req audio.Transcrip
return nil, fmt.Errorf("%w: transcription requires audio bytes", llm.ErrUnsupported) return nil, fmt.Errorf("%w: transcription requires audio bytes", llm.ErrUnsupported)
} }
var buf bytes.Buffer buf, formType, err := buildMultipart("build transcription form",
w := multipart.NewWriter(&buf) filePart{field: "file", filename: transcriptionFilename(req.Filename, req.MIME), data: req.Audio},
fw, err := w.CreateFormFile("file", transcriptionFilename(req)) []formField{
if err != nil {
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
}
if _, err := fw.Write(req.Audio); err != nil {
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
}
if err := writeFormFields(w, "build transcription form", []formField{
{"model", m.id, true}, {"model", m.id, true},
{"response_format", "json", true}, {"response_format", "json", true},
{"language", req.Language, false}, {"language", req.Language, false},
{"prompt", req.Prompt, false}, {"prompt", req.Prompt, false},
}); err != nil { })
if err != nil {
return nil, err return nil, err
} }
if err := w.Close(); err != nil {
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
}
raw, _, err := m.p.doRaw(ctx, http.MethodPost, "/v1/audio/transcriptions", m.id, w.FormDataContentType(), &buf, maxResponseBytes) raw, _, err := m.p.doRaw(ctx, http.MethodPost, "/v1/audio/transcriptions", m.id, formType, buf, maxResponseBytes)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -158,11 +148,11 @@ func (m *transcriptionModel) Transcribe(ctx context.Context, req audio.Transcrip
// headers), else one derived from the MIME subtype ("audio.mp3"), else // headers), else one derived from the MIME subtype ("audio.mp3"), else
// "audio". MIME parameters ("audio/ogg; codecs=opus") are stripped before // "audio". MIME parameters ("audio/ogg; codecs=opus") are stripped before
// matching. // matching.
func transcriptionFilename(req audio.TranscriptionRequest) string { func transcriptionFilename(filename, mimeType string) string {
if name := sanitizeFilename(req.Filename); name != "" { if name := sanitizeFilename(filename); name != "" {
return name return name
} }
mt := strings.ToLower(strings.TrimSpace(req.MIME)) mt := strings.ToLower(strings.TrimSpace(mimeType))
if parsed, _, err := mime.ParseMediaType(mt); err == nil { if parsed, _, err := mime.ParseMediaType(mt); err == nil {
mt = parsed mt = parsed
} }
+113
View File
@@ -0,0 +1,113 @@
// diarize.go implements audio.DiarizationProvider against a
// whisper-asr-webservice upstream (WhisperX engine) reached through
// llama-swap's /upstream passthrough (ADR-0020):
//
// POST /upstream/<id>/asr?output=json&diarize=true[&language&min_speakers&max_speakers]
//
// output=json is load-bearing: the vtt/srt output formats DROP the speaker
// labels; only the json shape carries per-segment `speaker` fields.
package llamaswap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"gitea.stevedudenhoeffer.com/steve/majordomo/audio"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// DiarizationModel implements audio.DiarizationProvider. The id selects
// which upstream llama-swap loads (the pyannote-equipped WhisperX sidecar,
// not the plain whisper.cpp model).
func (p *Provider) DiarizationModel(id string, opts ...audio.DiarizationModelOption) (audio.DiarizationModel, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = audio.ApplyDiarizationModelOptions(opts)
return &diarizationModel{p: p, id: id}, nil
}
type diarizationModel struct {
p *Provider
id string
}
// asrResponse is whisper-asr-webservice's output=json shape (the subset this
// package relies on; per-word entries are ignored — segment granularity is
// the contract).
type asrResponse struct {
Text string `json:"text"`
Language string `json:"language"`
Segments []struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Speaker string `json:"speaker"`
} `json:"segments"`
}
// Diarize implements audio.DiarizationModel.
func (m *diarizationModel) Diarize(ctx context.Context, req audio.DiarizationRequest, opts ...audio.DiarizationOption) (*audio.DiarizationResult, error) {
req = req.Apply(opts...)
if len(req.Audio) == 0 {
return nil, fmt.Errorf("%w: diarization requires audio bytes", llm.ErrUnsupported)
}
if req.MinSpeakers < 0 || req.MaxSpeakers < 0 ||
(req.MaxSpeakers > 0 && req.MinSpeakers > req.MaxSpeakers) {
return nil, fmt.Errorf("%w: invalid speaker bounds [%d,%d]", llm.ErrUnsupported, req.MinSpeakers, req.MaxSpeakers)
}
q := url.Values{}
q.Set("output", "json")
q.Set("diarize", "true")
if req.Language != "" {
q.Set("language", req.Language)
}
if req.MinSpeakers > 0 {
q.Set("min_speakers", strconv.Itoa(req.MinSpeakers))
}
if req.MaxSpeakers > 0 {
q.Set("max_speakers", strconv.Itoa(req.MaxSpeakers))
}
path, err := upstreamPath(m.id, "/asr?"+q.Encode())
if err != nil {
return nil, err
}
body, contentType, err := buildMultipart("build diarization form",
filePart{field: "audio_file", filename: transcriptionFilename(req.Filename, req.MIME), data: req.Audio},
nil)
if err != nil {
return nil, err
}
raw, _, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, contentType, body, maxResponseBytes)
if err != nil {
return nil, err
}
var out asrResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("llama-swap: decode diarization response: %w", err)
}
res := &audio.DiarizationResult{
Text: out.Text,
Language: out.Language,
Raw: json.RawMessage(raw),
}
for _, s := range out.Segments {
res.Segments = append(res.Segments, audio.DiarizationSegment{
Start: s.Start,
End: s.End,
Speaker: s.Speaker,
Text: s.Text,
})
}
if len(res.Segments) == 0 && res.Text == "" {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "diarization response contained no transcript"}
}
return res, nil
}
+44
View File
@@ -108,3 +108,47 @@ func TestImageEditValidation(t *testing.T) {
t.Errorf("out-of-range strength: err = %v, want ErrUnsupported", err) t.Errorf("out-of-range strength: err = %v, want ErrUnsupported", err)
} }
} }
func TestImageEditWithMask(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)
mask := imagegen.Image{MIME: "image/png", Data: []byte{0xDE, 0xAD}}
if _, err := ed.Edit(context.Background(),
imagegen.EditRequest{Prompt: "replace the sky", Init: editInit(t)},
imagegen.WithEditMask(mask),
); err != nil {
t.Fatalf("Edit: %v", err)
}
if got := gotBody["mask"]; got != base64.StdEncoding.EncodeToString(mask.Data) {
t.Errorf("mask = %v, want the b64 mask", got)
}
}
func TestImageEditWithoutMaskOmitsField(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: "p", Init: editInit(t)}); err != nil {
t.Fatalf("Edit: %v", err)
}
if _, ok := gotBody["mask"]; ok {
t.Error("mask field sent for unmasked edit; want omitted")
}
}
+150
View File
@@ -0,0 +1,150 @@
// embed.go implements embeddings.EmbedProvider and embeddings.RerankProvider
// against llama-server instances behind llama-swap (ADR-0022):
//
// POST /v1/embeddings {model, input: [...]} (OpenAI shape)
// POST /v1/rerank {model, query, documents, top_n} (Jina-ish shape)
//
// Both paths are in llama-swap's normal model-routed tables — no /upstream
// needed. The two surfaces are minted separately because they are two
// DIFFERENT server instances on the host: llama-server with --embeddings
// and --rerank enabled together returns all-zero embeddings (llama.cpp
// #20085), so the host runs one of each and the ids differ.
package llamaswap
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/embeddings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// EmbedModel implements embeddings.EmbedProvider. The id selects which
// upstream llama-swap loads (a persistent CPU member on the reference host,
// so calls are cheap and never evict GPU models).
func (p *Provider) EmbedModel(id string, opts ...embeddings.EmbedModelOption) (embeddings.EmbedModel, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = embeddings.ApplyEmbedModelOptions(opts)
return &embedModel{p: p, id: id}, nil
}
type embedModel struct {
p *Provider
id string
}
// Embed implements embeddings.EmbedModel via POST {base}/v1/embeddings.
func (m *embedModel) Embed(ctx context.Context, req embeddings.EmbedRequest, opts ...embeddings.EmbedOption) (*embeddings.EmbedResult, error) {
req = req.Apply(opts...)
if len(req.Inputs) == 0 {
return nil, fmt.Errorf("%w: embedding requires at least one input", llm.ErrUnsupported)
}
for i, in := range req.Inputs {
if strings.TrimSpace(in) == "" {
return nil, fmt.Errorf("%w: embedding input %d is empty", llm.ErrUnsupported, i)
}
}
wire := struct {
Model string `json:"model"`
Input []string `json:"input"`
}{Model: m.id, Input: req.Inputs}
var resp struct {
Data []struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
if err := m.p.doJSON(ctx, http.MethodPost, "/v1/embeddings", m.id, &wire, &resp); err != nil {
return nil, err
}
if len(resp.Data) != len(req.Inputs) {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: fmt.Sprintf("embeddings response has %d vectors for %d inputs", len(resp.Data), len(req.Inputs))}
}
// The OpenAI shape carries an index per entry; order by it rather than
// trusting response order.
vectors := make([][]float32, len(req.Inputs))
for _, d := range resp.Data {
if d.Index < 0 || d.Index >= len(vectors) || len(d.Embedding) == 0 {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: fmt.Sprintf("embeddings response entry index %d invalid or empty", d.Index)}
}
if vectors[d.Index] != nil {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: fmt.Sprintf("embeddings response repeats index %d", d.Index)}
}
vectors[d.Index] = d.Embedding
}
for i, v := range vectors {
if v == nil {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: fmt.Sprintf("embeddings response missing vector for input %d", i)}
}
}
return &embeddings.EmbedResult{Vectors: vectors, Raw: &resp}, nil
}
// RerankModel implements embeddings.RerankProvider.
func (p *Provider) RerankModel(id string, opts ...embeddings.RerankModelOption) (embeddings.RerankModel, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = embeddings.ApplyRerankModelOptions(opts)
return &rerankModel{p: p, id: id}, nil
}
type rerankModel struct {
p *Provider
id string
}
// Rerank implements embeddings.RerankModel via POST {base}/v1/rerank. The
// response parser reads only results[].index and results[].relevance_score —
// llama-server documents the shape as "might change", so stay minimal.
func (m *rerankModel) Rerank(ctx context.Context, req embeddings.RerankRequest, opts ...embeddings.RerankOption) (*embeddings.RerankResult, error) {
req = req.Apply(opts...)
if strings.TrimSpace(req.Query) == "" {
return nil, fmt.Errorf("%w: rerank requires a query", llm.ErrUnsupported)
}
if len(req.Documents) == 0 {
return nil, fmt.Errorf("%w: rerank requires at least one document", llm.ErrUnsupported)
}
if req.TopN < 0 {
return nil, fmt.Errorf("%w: rerank top_n must be >= 0, got %d", llm.ErrUnsupported, req.TopN)
}
wire := struct {
Model string `json:"model"`
Query string `json:"query"`
Documents []string `json:"documents"`
TopN int `json:"top_n,omitempty"`
}{Model: m.id, Query: req.Query, Documents: req.Documents, TopN: req.TopN}
var resp struct {
Results []struct {
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
} `json:"results"`
}
if err := m.p.doJSON(ctx, http.MethodPost, "/v1/rerank", m.id, &wire, &resp); err != nil {
return nil, err
}
if len(resp.Results) == 0 {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "rerank response contained no results"}
}
out := &embeddings.RerankResult{Raw: &resp}
for _, r := range resp.Results {
if r.Index < 0 || r.Index >= len(req.Documents) {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: fmt.Sprintf("rerank result index %d out of range", r.Index)}
}
out.Results = append(out.Results, embeddings.RerankItem{Index: r.Index, Score: r.RelevanceScore})
}
sort.SliceStable(out.Results, func(i, j int) bool { return out.Results[i].Score > out.Results[j].Score })
return out, nil
}
+8
View File
@@ -128,6 +128,11 @@ type img2imgRequest struct {
txt2imgRequest txt2imgRequest
InitImages []string `json:"init_images"` InitImages []string `json:"init_images"`
DenoisingStrength *float64 `json:"denoising_strength,omitempty"` DenoisingStrength *float64 `json:"denoising_strength,omitempty"`
// Mask enables inpainting: base64 image, white = repaint, black = keep
// (sd-server also accepts a data URL; plain base64 keeps symmetry with
// init_images). sd-server has no mask_blur/inpaint_full_res — callers
// wanting soft edges pre-feather the mask.
Mask string `json:"mask,omitempty"`
} }
// Edit implements imagegen.Editor via POST {base}/sdapi/v1/img2img. // Edit implements imagegen.Editor via POST {base}/sdapi/v1/img2img.
@@ -148,6 +153,9 @@ func (m *imageModel) Edit(ctx context.Context, req imagegen.EditRequest, opts ..
InitImages: []string{base64.StdEncoding.EncodeToString(req.Init.Data)}, InitImages: []string{base64.StdEncoding.EncodeToString(req.Init.Data)},
DenoisingStrength: req.Strength, DenoisingStrength: req.Strength,
} }
if len(req.Mask.Data) > 0 {
wire.Mask = base64.StdEncoding.EncodeToString(req.Mask.Data)
}
var resp txt2imgResponse var resp txt2imgResponse
if err := m.p.doJSON(ctx, http.MethodPost, "/sdapi/v1/img2img", m.id, &wire, &resp); err != nil { if err := m.p.doJSON(ctx, http.MethodPost, "/sdapi/v1/img2img", m.id, &wire, &resp); err != nil {
+218
View File
@@ -0,0 +1,218 @@
// mediautil.go implements the imagegen.UpscaleProvider,
// imagegen.BackgroundRemovalProvider, and videogen.InterpolationProvider
// surfaces against upstreams reached through llama-swap's /upstream
// passthrough (ADR-0020):
//
// upscale POST /upstream/<id>/v1/upscale (mediautils shim)
// background POST /upstream/<id>/api/remove (rembg server)
// interpolate POST /upstream/<id>/v1/interpolate (mediautils shim)
//
// All three are one-file multipart in, raw bytes out.
package llamaswap
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/imagegen"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/videogen"
)
// maxImageResponseBytes caps the raw-image bodies from the upscale and
// background-removal endpoints. The 64MB maxResponseBytes is a JSON cap;
// a 4x-upscaled PNG legitimately exceeds it. Bounded (not video-sized)
// because a single still image past this is an upstream bug, not data.
const maxImageResponseBytes = 256 << 20
// --- upscale ---
// UpscaleModel implements imagegen.UpscaleProvider against the mediautils
// shim's POST /v1/upscale. The id selects which upstream llama-swap loads.
func (p *Provider) UpscaleModel(id string, opts ...imagegen.UpscaleModelOption) (imagegen.Upscaler, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = imagegen.ApplyUpscaleModelOptions(opts)
return &upscaleModel{p: p, id: id}, nil
}
type upscaleModel struct {
p *Provider
id string
}
// Upscale implements imagegen.Upscaler.
func (m *upscaleModel) Upscale(ctx context.Context, req imagegen.UpscaleRequest, opts ...imagegen.UpscaleOption) (*imagegen.Result, error) {
req = req.Apply(opts...)
if len(req.Image.Data) == 0 {
return nil, fmt.Errorf("%w: upscale requires an image", llm.ErrUnsupported)
}
if req.Scale != 0 && req.Scale != 2 && req.Scale != 4 {
return nil, fmt.Errorf("%w: upscale scale must be 2 or 4, got %d", llm.ErrUnsupported, req.Scale)
}
path, err := upstreamPath(m.id, "/v1/upscale")
if err != nil {
return nil, err
}
scale := ""
if req.Scale != 0 {
scale = strconv.Itoa(req.Scale)
}
body, contentType, err := buildMultipart("build upscale form",
filePart{field: "file", filename: "image.png", data: req.Image.Data},
[]formField{{"scale", scale, false}})
if err != nil {
return nil, err
}
raw, respType, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, contentType, body, maxImageResponseBytes)
if err != nil {
return nil, err
}
return singleImageResult(m.p.name, m.id, "upscale", raw, respType)
}
// --- background removal ---
// BackgroundRemovalModel implements imagegen.BackgroundRemovalProvider
// against a rembg server's POST /api/remove.
func (p *Provider) BackgroundRemovalModel(id string, opts ...imagegen.BackgroundRemovalModelOption) (imagegen.BackgroundRemover, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = imagegen.ApplyBackgroundRemovalModelOptions(opts)
return &backgroundModel{p: p, id: id}, nil
}
type backgroundModel struct {
p *Provider
id string
}
// RemoveBackground implements imagegen.BackgroundRemover. rembg's `model`
// form field is the request's Net (its internal network); the llama-swap
// model id only picks the upstream. `om=true` returns the black/white
// foreground mask instead of the RGBA cutout.
func (m *backgroundModel) RemoveBackground(ctx context.Context, req imagegen.BackgroundRemovalRequest, opts ...imagegen.BackgroundRemovalOption) (*imagegen.Result, error) {
req = req.Apply(opts...)
if len(req.Image.Data) == 0 {
return nil, fmt.Errorf("%w: background removal requires an image", llm.ErrUnsupported)
}
path, err := upstreamPath(m.id, "/api/remove")
if err != nil {
return nil, err
}
om := ""
if req.OnlyMask {
om = "true"
}
body, contentType, err := buildMultipart("build background-removal form",
filePart{field: "file", filename: "image.png", data: req.Image.Data},
[]formField{
{"model", req.Net, false},
{"om", om, false},
})
if err != nil {
return nil, err
}
raw, respType, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, contentType, body, maxImageResponseBytes)
if err != nil {
return nil, err
}
return singleImageResult(m.p.name, m.id, "background removal", raw, respType)
}
// singleImageResult wraps one raw image body into an imagegen.Result.
// Acceptance requires positive evidence of image-ness — sniffed magic
// bytes or a declared image/* Content-Type — so a proxy error page with
// an empty Content-Type can never become "the image" (sniffImageMIME's
// PNG default is a labelling fallback, not a validator).
func singleImageResult(provider, model, verb string, raw []byte, contentType string) (*imagegen.Result, error) {
if len(raw) == 0 {
return nil, &llm.APIError{Provider: provider, Model: model, Message: verb + " response contained no image"}
}
sniffed := http.DetectContentType(raw)
declared := strings.TrimSpace(contentType)
if !strings.HasPrefix(sniffed, "image/") && !strings.HasPrefix(declared, "image/") {
return nil, &llm.APIError{Provider: provider, Model: model,
Message: fmt.Sprintf("%s response is not an image (Content-Type %q, sniffed %q)", verb, declared, sniffed)}
}
mimeType := sniffed
if !strings.HasPrefix(mimeType, "image/") {
mimeType = declared
}
return &imagegen.Result{Images: []llm.ImagePart{{MIME: mimeType, Data: raw}}}, nil
}
// --- frame interpolation ---
// InterpolatorModel implements videogen.InterpolationProvider against the
// mediautils shim's POST /v1/interpolate.
func (p *Provider) InterpolatorModel(id string, opts ...videogen.InterpolatorModelOption) (videogen.Interpolator, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = videogen.ApplyInterpolatorModelOptions(opts)
return &interpolatorModel{p: p, id: id}, nil
}
type interpolatorModel struct {
p *Provider
id string
}
// Interpolate implements videogen.Interpolator. The response body IS the
// encoded clip (same contract as /v1/videos/sync), hence the video-sized
// response cap and the same MIME resolution rules.
func (m *interpolatorModel) Interpolate(ctx context.Context, req videogen.InterpolateRequest, opts ...videogen.InterpolateOption) (*videogen.Result, error) {
req = req.Apply(opts...)
if len(req.Video.Data) == 0 {
return nil, fmt.Errorf("%w: interpolation requires a video", llm.ErrUnsupported)
}
if req.Multi != 0 && req.Multi != 2 && req.Multi != 4 {
return nil, fmt.Errorf("%w: interpolation multi must be 2 or 4, got %d", llm.ErrUnsupported, req.Multi)
}
if req.TargetFPS < 0 {
return nil, fmt.Errorf("%w: target fps must be >= 0, got %d", llm.ErrUnsupported, req.TargetFPS)
}
path, err := upstreamPath(m.id, "/v1/interpolate")
if err != nil {
return nil, err
}
multi := ""
if req.Multi != 0 {
multi = strconv.Itoa(req.Multi)
}
mode := ""
targetFPS := ""
if req.SlowMo {
mode = "slowmo"
} else if req.TargetFPS > 0 {
targetFPS = strconv.Itoa(req.TargetFPS)
}
body, contentType, err := buildMultipart("build interpolate form",
filePart{field: "file", filename: "video.mp4", data: req.Video.Data},
[]formField{
{"multi", multi, false},
{"mode", mode, false},
{"target_fps", targetFPS, false},
})
if err != nil {
return nil, err
}
raw, respType, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, contentType, body, maxVideoResponseBytes)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "interpolate response contained no video"}
}
mimeType := videoMIME(respType, raw)
if mimeType == "" {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "interpolate response is not a video"}
}
return &videogen.Result{Video: videogen.Video{Data: raw, MIME: mimeType}}, nil
}
+282
View File
@@ -0,0 +1,282 @@
package llamaswap
import (
"context"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo/imagegen"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/videogen"
)
func pngFixture(t *testing.T) []byte {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(onePixelPNG)
if err != nil {
t.Fatalf("decode fixture: %v", err)
}
return raw
}
func TestUpscale(t *testing.T) {
png := pngFixture(t)
var gotPath, gotScale, gotFilename string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
gotScale = r.FormValue("scale")
if f, hdr, err := r.FormFile("file"); err == nil {
gotFilename = hdr.Filename
f.Close()
} else {
t.Errorf("file part: %v", err)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(png)
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
up, err := p.UpscaleModel("mediautils")
if err != nil {
t.Fatalf("UpscaleModel: %v", err)
}
res, err := up.Upscale(context.Background(),
imagegen.UpscaleRequest{Image: imagegen.Image{MIME: "image/png", Data: png}},
imagegen.WithUpscaleScale(2))
if err != nil {
t.Fatalf("Upscale: %v", err)
}
if gotPath != "/upstream/mediautils/v1/upscale" {
t.Errorf("path = %q", gotPath)
}
if gotScale != "2" || gotFilename != "image.png" {
t.Errorf("scale/filename = %q/%q", gotScale, gotFilename)
}
if len(res.Images) != 1 || res.Images[0].MIME != "image/png" {
t.Fatalf("images = %+v", res.Images)
}
}
func TestUpscaleRejectsBadArgs(t *testing.T) {
p := New(WithBaseURL("http://unused"))
up, _ := p.UpscaleModel("mediautils")
if _, err := up.Upscale(context.Background(), imagegen.UpscaleRequest{}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("no image: err = %v, want ErrUnsupported", err)
}
if _, err := up.Upscale(context.Background(),
imagegen.UpscaleRequest{Image: imagegen.Image{Data: []byte{1}}, Scale: 3}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("scale 3: err = %v, want ErrUnsupported", err)
}
}
func TestUpscaleRejectsNonImageResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<html>proxy error page</html>"))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
up, _ := p.UpscaleModel("mediautils")
_, err := up.Upscale(context.Background(),
imagegen.UpscaleRequest{Image: imagegen.Image{Data: pngFixture(t)}})
var apiErr *llm.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want APIError for non-image body", err)
}
}
func TestRemoveBackground(t *testing.T) {
png := pngFixture(t)
var gotPath, gotNet, gotOM string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
gotNet = r.FormValue("model")
gotOM = r.FormValue("om")
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(png)
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
br, err := p.BackgroundRemovalModel("rembg")
if err != nil {
t.Fatalf("BackgroundRemovalModel: %v", err)
}
res, err := br.RemoveBackground(context.Background(),
imagegen.BackgroundRemovalRequest{Image: imagegen.Image{Data: png}},
imagegen.WithBackgroundNet("birefnet-general"), imagegen.WithOnlyMask())
if err != nil {
t.Fatalf("RemoveBackground: %v", err)
}
if gotPath != "/upstream/rembg/api/remove" {
t.Errorf("path = %q", gotPath)
}
if gotNet != "birefnet-general" || gotOM != "true" {
t.Errorf("net/om = %q/%q", gotNet, gotOM)
}
if len(res.Images) != 1 {
t.Fatalf("images = %+v", res.Images)
}
}
func TestRemoveBackgroundOmitsUnsetFields(t *testing.T) {
png := pngFixture(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
if _, ok := r.MultipartForm.Value["model"]; ok {
t.Error("model field sent for default net; want omitted")
}
if _, ok := r.MultipartForm.Value["om"]; ok {
t.Error("om field sent for cutout mode; want omitted")
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(png)
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
br, _ := p.BackgroundRemovalModel("rembg")
if _, err := br.RemoveBackground(context.Background(),
imagegen.BackgroundRemovalRequest{Image: imagegen.Image{Data: png}}); err != nil {
t.Fatalf("RemoveBackground: %v", err)
}
}
// mp4Fixture is a minimal ftyp box so http.DetectContentType sniffs video/mp4.
func mp4Fixture() []byte {
return []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm',
0, 0, 2, 0, 'i', 's', 'o', 'm', 'i', 's', 'o', '2'}
}
func TestInterpolate(t *testing.T) {
var gotPath, gotMulti, gotMode, gotTargetFPS string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
gotMulti = r.FormValue("multi")
gotMode = r.FormValue("mode")
gotTargetFPS = r.FormValue("target_fps")
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write(mp4Fixture())
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
ip, err := p.InterpolatorModel("mediautils")
if err != nil {
t.Fatalf("InterpolatorModel: %v", err)
}
res, err := ip.Interpolate(context.Background(),
videogen.InterpolateRequest{Video: videogen.Video{Data: mp4Fixture(), MIME: "video/mp4"}},
videogen.WithInterpolateMulti(2), videogen.WithTargetFPS(60))
if err != nil {
t.Fatalf("Interpolate: %v", err)
}
if gotPath != "/upstream/mediautils/v1/interpolate" {
t.Errorf("path = %q", gotPath)
}
if gotMulti != "2" || gotMode != "" || gotTargetFPS != "60" {
t.Errorf("multi/mode/target_fps = %q/%q/%q", gotMulti, gotMode, gotTargetFPS)
}
if res.Video.MIME != "video/mp4" || len(res.Video.Data) == 0 {
t.Fatalf("video = %q/%d bytes", res.Video.MIME, len(res.Video.Data))
}
}
func TestInterpolateSlowMoSetsModeAndDropsTargetFPS(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
if got := r.FormValue("mode"); got != "slowmo" {
t.Errorf("mode = %q, want slowmo", got)
}
if _, ok := r.MultipartForm.Value["target_fps"]; ok {
t.Error("target_fps sent in slowmo mode; want omitted")
}
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write(mp4Fixture())
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
ip, _ := p.InterpolatorModel("mediautils")
_, err := ip.Interpolate(context.Background(),
videogen.InterpolateRequest{Video: videogen.Video{Data: mp4Fixture()}, TargetFPS: 60},
videogen.WithSlowMo())
if err != nil {
t.Fatalf("Interpolate: %v", err)
}
}
func TestInterpolateRejectsBadArgs(t *testing.T) {
p := New(WithBaseURL("http://unused"))
ip, _ := p.InterpolatorModel("mediautils")
if _, err := ip.Interpolate(context.Background(), videogen.InterpolateRequest{}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("no video: err = %v, want ErrUnsupported", err)
}
if _, err := ip.Interpolate(context.Background(),
videogen.InterpolateRequest{Video: videogen.Video{Data: []byte{1}}, Multi: 3}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("multi 3: err = %v, want ErrUnsupported", err)
}
}
func TestUpstreamPathRejectsSeparators(t *testing.T) {
for _, bad := range []string{"", "a/b", "a?b", "a#b"} {
if _, err := upstreamPath(bad, "/x"); err == nil {
t.Errorf("upstreamPath(%q) succeeded; want error", bad)
}
}
got, err := upstreamPath("rembg", "api/remove")
if err != nil || got != "/upstream/rembg/api/remove" {
t.Errorf("upstreamPath = %q, %v", got, err)
}
if !strings.HasPrefix(got, "/upstream/") {
t.Errorf("path prefix wrong: %q", got)
}
}
func TestUpscaleRejectsEmptyContentTypeNonImage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header()["Content-Type"] = nil // suppress auto-detection: NO Content-Type at all
_, _ = w.Write([]byte("502 bad gateway"))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
up, _ := p.UpscaleModel("mediautils")
_, err := up.Upscale(context.Background(),
imagegen.UpscaleRequest{Image: imagegen.Image{Data: pngFixture(t)}})
var apiErr *llm.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want APIError for headerless non-image body", err)
}
}
func TestUpstreamPathRejectsDotDot(t *testing.T) {
if _, err := upstreamPath("..", "/x"); err == nil {
t.Error("model '..' accepted")
}
if _, err := upstreamPath("m", "/v1/audio?path=../../api/models/unload"); err == nil {
t.Error("dot-dot rest accepted")
}
if _, err := upstreamPath("m", "/v1/audio?path=https://evil.example/x"); err == nil {
t.Error("absolute-URL rest accepted")
}
}
+136
View File
@@ -0,0 +1,136 @@
// mesh.go implements meshgen.Provider against a Hunyuan3D-2.1-style
// api_server reached through llama-swap's /upstream passthrough (ADR-0020):
//
// POST /upstream/<id>/generate JSON {image: <b64>, type: "glb"|"stl"|...}
//
// The response body IS the encoded mesh (binary, Content-Type
// application/octet-stream), so one request yields exactly one asset.
package llamaswap
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/meshgen"
)
// maxMeshResponseBytes caps the /generate body — a high-resolution textured
// mesh legitimately passes the 64MB JSON cap, but stays far under video
// scale.
const maxMeshResponseBytes = 256 << 20
// MeshModel implements meshgen.Provider. The id selects which upstream
// llama-swap loads.
func (p *Provider) MeshModel(id string, opts ...meshgen.ModelOption) (meshgen.Model, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = meshgen.ApplyModelOptions(opts)
return &meshModel{p: p, id: id}, nil
}
type meshModel struct {
p *Provider
id string
}
// hunyuanGenerateRequest is the Hunyuan3D api_server /generate shape.
// Optional fields are pointers/omitempty so unset values fall back to the
// server's defaults (mirrors the sd-server wire structs).
type hunyuanGenerateRequest struct {
Image string `json:"image"`
Type string `json:"type,omitempty"`
Texture bool `json:"texture"`
RemoveBackground *bool `json:"remove_background,omitempty"`
OctreeResolution int `json:"octree_resolution,omitempty"`
NumInferenceSteps *int `json:"num_inference_steps,omitempty"`
GuidanceScale *float64 `json:"guidance_scale,omitempty"`
Seed *int64 `json:"seed,omitempty"`
FaceCount int `json:"face_count,omitempty"`
}
// meshFormats maps the supported output containers to MIME types.
var meshFormats = map[string]string{
"glb": "model/gltf-binary",
"stl": "model/stl",
"obj": "model/obj",
}
// Generate implements meshgen.Model.
func (m *meshModel) Generate(ctx context.Context, req meshgen.Request, opts ...meshgen.Option) (*meshgen.Result, error) {
req = req.Apply(opts...)
if len(req.Image.Data) == 0 {
return nil, fmt.Errorf("%w: mesh generation requires an image", llm.ErrUnsupported)
}
format := strings.ToLower(strings.TrimSpace(req.Format))
if format == "" {
format = "glb"
}
mimeType, ok := meshFormats[format]
if !ok {
return nil, fmt.Errorf("%w: unsupported mesh format %q (want glb, stl, or obj)", llm.ErrUnsupported, req.Format)
}
if req.OctreeResolution < 0 || req.FaceCount < 0 {
return nil, fmt.Errorf("%w: octree resolution and face count must be >= 0", llm.ErrUnsupported)
}
path, err := upstreamPath(m.id, "/generate")
if err != nil {
return nil, err
}
wire := hunyuanGenerateRequest{
Image: base64.StdEncoding.EncodeToString(req.Image.Data),
Type: format,
Texture: req.Texture,
RemoveBackground: req.RemoveBackground,
OctreeResolution: req.OctreeResolution,
NumInferenceSteps: req.Steps,
GuidanceScale: req.GuidanceScale,
Seed: req.Seed,
FaceCount: req.FaceCount,
}
// doRaw + manual JSON encode (not doJSON): the SUCCESS body is binary
// mesh bytes, not JSON.
encoded, err := json.Marshal(wire)
if err != nil {
return nil, fmt.Errorf("llama-swap: encode mesh request: %w", err)
}
raw, respType, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, "application/json", bytes.NewReader(encoded), maxMeshResponseBytes)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "mesh response contained no data"}
}
// A JSON body on the success path is the server reporting a soft error
// (or an API drift) — never hand it back as "the mesh". Two signals:
// the declared Content-Type, and a whitespace-tolerant peek at the
// leading bytes (512 covers any indented error envelope; a binary STL
// header theoretically CAN start with '{', but a real one also won't
// be all-whitespace-then-brace).
if strings.Contains(strings.ToLower(respType), "application/json") {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "mesh response is JSON, not mesh bytes: " + truncateForError(raw)}
}
if first := strings.TrimLeft(string(raw[:min(len(raw), 512)]), " \t\r\n"); strings.HasPrefix(first, "{") || strings.HasPrefix(first, "[") {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "mesh response is JSON, not mesh bytes: " + truncateForError(raw)}
}
return &meshgen.Result{Mesh: meshgen.Mesh{Data: raw, Format: format, MIME: mimeType}}, nil
}
// truncateForError bounds a payload quoted into an error message.
func truncateForError(b []byte) string {
const maxErrLen = 500
if len(b) > maxErrLen {
return string(b[:maxErrLen]) + "..."
}
return string(b)
}
+170
View File
@@ -0,0 +1,170 @@
package llamaswap
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo/audio"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/meshgen"
)
func TestMeshGenerate(t *testing.T) {
png := pngFixture(t)
stl := []byte("solid mort\nendsolid mort\n")
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(stl)
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
mm, err := p.MeshModel("image3d-hunyuan21")
if err != nil {
t.Fatalf("MeshModel: %v", err)
}
res, err := mm.Generate(context.Background(),
meshgen.Request{Image: meshgen.Image{MIME: "image/png", Data: png}},
meshgen.WithFormat("stl"), meshgen.WithSeed(7))
if err != nil {
t.Fatalf("Generate: %v", err)
}
if gotPath != "/upstream/image3d-hunyuan21/generate" {
t.Errorf("path = %q", gotPath)
}
if gotBody["type"] != "stl" || gotBody["image"] != base64.StdEncoding.EncodeToString(png) {
t.Errorf("type/image = %v/(b64 mismatch)", gotBody["type"])
}
if gotBody["seed"] != float64(7) {
t.Errorf("seed = %v", gotBody["seed"])
}
if _, ok := gotBody["octree_resolution"]; ok {
t.Error("octree_resolution sent when unset; want omitted")
}
if res.Mesh.Format != "stl" || res.Mesh.MIME != "model/stl" || string(res.Mesh.Data) != string(stl) {
t.Fatalf("mesh = %+v", res.Mesh)
}
}
func TestMeshGenerateRejectsJSONBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte(` {"detail": "queue full"}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
mm, _ := p.MeshModel("image3d-hunyuan21")
_, err := mm.Generate(context.Background(),
meshgen.Request{Image: meshgen.Image{Data: pngFixture(t)}})
var apiErr *llm.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want APIError for JSON success body", err)
}
}
func TestMeshGenerateRejectsBadArgs(t *testing.T) {
p := New(WithBaseURL("http://unused"))
mm, _ := p.MeshModel("image3d-hunyuan21")
if _, err := mm.Generate(context.Background(), meshgen.Request{}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("no image: err = %v, want ErrUnsupported", err)
}
if _, err := mm.Generate(context.Background(),
meshgen.Request{Image: meshgen.Image{Data: []byte{1}}, Format: "fbx"}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("format fbx: err = %v, want ErrUnsupported", err)
}
}
func TestDiarize(t *testing.T) {
var gotPath string
var gotQuery map[string][]string
var gotField string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.Query()
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
if f, _, err := r.FormFile("audio_file"); err == nil {
gotField = "audio_file"
f.Close()
}
_, _ = w.Write([]byte(`{
"text": "hello there. general kenobi.",
"language": "en",
"segments": [
{"start": 0.0, "end": 1.2, "text": "hello there.", "speaker": "SPEAKER_00"},
{"start": 1.4, "end": 3.0, "text": "general kenobi.", "speaker": "SPEAKER_01"}
]
}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
dm, err := p.DiarizationModel("whisperx-diarize")
if err != nil {
t.Fatalf("DiarizationModel: %v", err)
}
res, err := dm.Diarize(context.Background(),
audio.DiarizationRequest{Audio: []byte("RIFFfake"), MIME: "audio/wav"},
audio.WithSpeakerBounds(2, 4), audio.WithDiarizationLanguage("en"))
if err != nil {
t.Fatalf("Diarize: %v", err)
}
if gotPath != "/upstream/whisperx-diarize/asr" {
t.Errorf("path = %q", gotPath)
}
for k, want := range map[string]string{
"output": "json", "diarize": "true",
"min_speakers": "2", "max_speakers": "4", "language": "en",
} {
if got := gotQuery[k]; len(got) != 1 || got[0] != want {
t.Errorf("query %s = %v, want %q", k, got, want)
}
}
if gotField != "audio_file" {
t.Error("audio_file part missing")
}
if len(res.Segments) != 2 || res.Segments[1].Speaker != "SPEAKER_01" {
t.Fatalf("segments = %+v", res.Segments)
}
if res.Language != "en" || res.Text == "" {
t.Errorf("language/text = %q/%q", res.Language, res.Text)
}
}
func TestDiarizeRejectsBadArgs(t *testing.T) {
p := New(WithBaseURL("http://unused"))
dm, _ := p.DiarizationModel("whisperx-diarize")
if _, err := dm.Diarize(context.Background(), audio.DiarizationRequest{}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("no audio: err = %v, want ErrUnsupported", err)
}
if _, err := dm.Diarize(context.Background(),
audio.DiarizationRequest{Audio: []byte{1}, MinSpeakers: 5, MaxSpeakers: 2}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("bounds 5>2: err = %v, want ErrUnsupported", err)
}
}
func TestDiarizeEmptyTranscriptIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"text": "", "segments": []}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
dm, _ := p.DiarizationModel("whisperx-diarize")
_, err := dm.Diarize(context.Background(), audio.DiarizationRequest{Audio: []byte{1}})
var apiErr *llm.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want APIError for empty transcript", err)
}
}
+265
View File
@@ -0,0 +1,265 @@
// music.go implements musicgen.Provider against an ACE-Step-1.5-style API
// server reached through llama-swap's /upstream passthrough (ADR-0021):
//
// POST /upstream/<id>/release_task {prompt, lyrics, ...} -> {task_id}
// POST /upstream/<id>/query_result {task_id_list: [...]} -> status+result
// GET /upstream/<id>/<result file URL> -> audio bytes
//
// The backend is an async job queue; Generate wraps it into the blocking
// one-call contract by polling, so a context deadline is the caller's
// budget for the whole job (mort's tool timeout sits well under its
// agent-runtime ceiling for exactly this reason).
package llamaswap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/musicgen"
)
// musicPollInterval is the delay between query_result polls. Long enough to
// be polite to the queue, short enough that a ~10s xl-turbo song isn't
// dominated by poll latency. A var so tests can shrink it.
var musicPollInterval = 2 * time.Second
// musicPollMaxConsecutiveFailures bounds how many consecutive BAD polls
// (transport error, unparseable payload, task momentarily absent) are
// tolerated before aborting. A multi-minute GPU job must not die to one
// blip; a genuinely broken upstream still fails within ~5 intervals.
const musicPollMaxConsecutiveFailures = 5
// MusicModel implements musicgen.Provider. The id selects which upstream
// llama-swap loads.
func (p *Provider) MusicModel(id string, opts ...musicgen.ModelOption) (musicgen.Model, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = musicgen.ApplyModelOptions(opts)
return &musicModel{p: p, id: id}, nil
}
type musicModel struct {
p *Provider
id string
}
// releaseTaskRequest is the ACE-Step POST /release_task shape. audio_duration
// is the v1 param name; verify against the upstream ACE-Step-1.5 repo's
// docs/en/API.md at smoke time — an
// unknown field is ignored upstream, degrading to the default clip length,
// never an error.
type releaseTaskRequest struct {
Prompt string `json:"prompt"`
Lyrics string `json:"lyrics,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
TaskType string `json:"task_type"`
AudioDuration int `json:"audio_duration,omitempty"`
InferenceSteps *int `json:"inference_steps,omitempty"`
Seed *int64 `json:"seed,omitempty"`
}
// queryItem is one task's poll state. `result` arrives as a JSON-encoded
// STRING (the ACE-Step API double-encodes it).
type queryItem struct {
TaskID string `json:"task_id"`
Status int `json:"status"` // 0 queued/running, 1 succeeded, 2 failed
Result string `json:"result"`
}
// musicFormatMIME resolves the clip MIME from the response Content-Type
// or the requested format, reusing speechMIME's table (one format->MIME
// map for the whole provider). wav32 is ACE-Step-specific: normalize it
// to wav before the shared lookup.
func musicFormatMIME(contentType, format string) string {
format = strings.ToLower(strings.TrimSpace(format))
if format == "wav32" {
format = "wav"
}
return speechMIME(contentType, format)
}
// Generate implements musicgen.Model.
func (m *musicModel) Generate(ctx context.Context, req musicgen.Request, opts ...musicgen.Option) (*musicgen.Result, error) {
req = req.Apply(opts...)
if strings.TrimSpace(req.Prompt) == "" {
return nil, fmt.Errorf("%w: music generation requires a prompt", llm.ErrUnsupported)
}
if req.DurationSeconds < 0 {
return nil, fmt.Errorf("%w: duration must be >= 0, got %d", llm.ErrUnsupported, req.DurationSeconds)
}
if req.Steps != nil && *req.Steps <= 0 {
return nil, fmt.Errorf("%w: inference steps must be > 0, got %d", llm.ErrUnsupported, *req.Steps)
}
taskID, err := m.releaseTask(ctx, req)
if err != nil {
return nil, err
}
item, err := m.pollResult(ctx, taskID)
if err != nil {
return nil, err
}
return m.fetchResult(ctx, req.Format, item)
}
// releaseTask submits the job and returns its task id.
func (m *musicModel) releaseTask(ctx context.Context, req musicgen.Request) (string, error) {
path, err := upstreamPath(m.id, "/release_task")
if err != nil {
return "", err
}
wire := releaseTaskRequest{
Prompt: req.Prompt,
Lyrics: req.Lyrics,
AudioFormat: req.Format,
TaskType: "text2music",
AudioDuration: req.DurationSeconds,
InferenceSteps: req.Steps,
Seed: req.Seed,
}
// Tolerant envelope: {"data": {"task_id": ...}} per the docs, with a
// top-level fallback in case the wrapper changes.
var resp struct {
Data struct {
TaskID string `json:"task_id"`
} `json:"data"`
TaskID string `json:"task_id"`
}
if err := m.p.doJSON(ctx, http.MethodPost, path, m.id, &wire, &resp); err != nil {
return "", err
}
taskID := resp.Data.TaskID
if taskID == "" {
taskID = resp.TaskID
}
if taskID == "" {
return "", &llm.APIError{Provider: m.p.name, Model: m.id, Message: "release_task returned no task_id"}
}
return taskID, nil
}
// pollResult polls query_result until the task succeeds, fails, or ctx
// expires. Transient trouble — a transport blip, a momentarily
// unparseable payload, the task briefly absent from the response — is
// tolerated up to musicPollMaxConsecutiveFailures in a row: a
// multi-minute exclusive-GPU job must not die to one flaky poll. Only an
// explicit status=2, a run of consecutive failures, or the ctx deadline
// aborts.
func (m *musicModel) pollResult(ctx context.Context, taskID string) (*queryItem, error) {
path, err := upstreamPath(m.id, "/query_result")
if err != nil {
return nil, err
}
body := map[string]any{"task_id_list": []string{taskID}}
ticker := time.NewTicker(musicPollInterval)
defer ticker.Stop()
failures := 0
var lastErr error
for {
item, pollErr := m.pollOnce(ctx, path, body, taskID)
switch {
case pollErr != nil:
// Ctx expiry is never transient — bail with the deadline error.
if ctx.Err() != nil {
return nil, fmt.Errorf("llama-swap: music generation: %w", ctx.Err())
}
failures++
lastErr = pollErr
if failures >= musicPollMaxConsecutiveFailures {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: fmt.Sprintf("music poll failed %d times in a row: %v", failures, lastErr)}
}
case item.Status == 1:
return item, nil
case item.Status == 2:
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "music generation failed upstream: " + truncateForError([]byte(item.Result))}
default:
failures = 0 // healthy queued/running poll
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("llama-swap: music generation: %w", ctx.Err())
case <-ticker.C:
}
}
}
// pollOnce performs one query_result round trip and locates the task.
func (m *musicModel) pollOnce(ctx context.Context, path string, body any, taskID string) (*queryItem, error) {
// Tolerant envelope: items under "data" or a bare array.
var raw json.RawMessage
if err := m.p.doJSON(ctx, http.MethodPost, path, m.id, body, &raw); err != nil {
return nil, err
}
return findQueryItem(raw, taskID)
}
// findQueryItem digs the task's entry out of the query_result payload,
// tolerating {"data": [...]}, {"data": {...}}, and bare-array envelopes.
func findQueryItem(raw json.RawMessage, taskID string) (*queryItem, error) {
var env struct {
Data json.RawMessage `json:"data"`
}
candidates := raw
if json.Unmarshal(raw, &env) == nil && len(env.Data) > 0 {
candidates = env.Data
}
var items []queryItem
if err := json.Unmarshal(candidates, &items); err != nil {
var one queryItem
if err := json.Unmarshal(candidates, &one); err != nil {
return nil, fmt.Errorf("unrecognized query_result payload shape")
}
items = []queryItem{one}
}
for i := range items {
// Single-item responses without a task_id echo are assumed to be
// ours — we only ever poll one task; a mismatch surfaces as a
// transient miss and is retried by the caller.
if items[i].TaskID == taskID || (items[i].TaskID == "" && len(items) == 1) {
return &items[i], nil
}
}
return nil, fmt.Errorf("query_result did not include task %s", taskID)
}
// fetchResult downloads the finished clip named by the job's result blob.
func (m *musicModel) fetchResult(ctx context.Context, format string, item *queryItem) (*musicgen.Result, error) {
var result struct {
File string `json:"file"`
}
if err := json.Unmarshal([]byte(item.Result), &result); err != nil || result.File == "" {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "music result blob missing file URL: " + truncateForError([]byte(item.Result))}
}
// The file URL is server-relative (e.g. "/v1/audio?path=..."); route it
// back through the same upstream. upstreamPath additionally refuses
// dot-dot/scheme smuggling in this SERVER-SUPPLIED value.
if !strings.HasPrefix(result.File, "/") {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "music result file URL is not server-relative: " + truncateForError([]byte(result.File))}
}
path, err := upstreamPath(m.id, result.File)
if err != nil {
return nil, err
}
raw, contentType, err := m.p.doRaw(ctx, http.MethodGet, path, m.id, "", nil, maxResponseBytes)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "music response contained no audio"}
}
mimeType := musicFormatMIME(contentType, format)
return &musicgen.Result{
Audio: musicgen.Audio{Data: raw, MIME: mimeType},
Raw: json.RawMessage(item.Result),
}, nil
}
+295
View File
@@ -0,0 +1,295 @@
package llamaswap
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"gitea.stevedudenhoeffer.com/steve/majordomo/embeddings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/musicgen"
)
// aceStepStub emulates the ACE-Step job API: one queued poll, then success.
func aceStepStub(t *testing.T, mp3 []byte) (*httptest.Server, *atomic.Int32) {
t.Helper()
var polls atomic.Int32
var gotRelease map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/upstream/musicgen-acestep/release_task":
_ = json.NewDecoder(r.Body).Decode(&gotRelease)
if gotRelease["task_type"] != "text2music" {
t.Errorf("task_type = %v", gotRelease["task_type"])
}
_, _ = w.Write([]byte(`{"data": {"task_id": "t-1", "status": "queued"}}`))
case "/upstream/musicgen-acestep/query_result":
n := polls.Add(1)
if n == 1 {
_, _ = w.Write([]byte(`{"data": [{"task_id": "t-1", "status": 0, "result": ""}]}`))
return
}
_, _ = w.Write([]byte(`{"data": [{"task_id": "t-1", "status": 1,
"result": "{\"file\": \"/v1/audio?path=out.mp3\", \"metas\": {\"bpm\": 120}}"}]}`))
case "/upstream/musicgen-acestep/v1/audio":
if got := r.URL.Query().Get("path"); got != "out.mp3" {
t.Errorf("audio path = %q", got)
}
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write(mp3)
default:
t.Errorf("unexpected path %q", r.URL.Path)
w.WriteHeader(404)
}
}))
return srv, &polls
}
func TestMusicGenerate(t *testing.T) {
old := musicPollInterval
musicPollInterval = 5 * time.Millisecond
defer func() { musicPollInterval = old }()
mp3 := []byte("ID3fakeaudio")
srv, polls := aceStepStub(t, mp3)
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
mm, err := p.MusicModel("musicgen-acestep")
if err != nil {
t.Fatalf("MusicModel: %v", err)
}
// Shrink the poll interval indirectly by bounding the whole call: the
// stub succeeds on poll #2, so a generous deadline still finishes fast.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := mm.Generate(ctx,
musicgen.Request{Prompt: "chiptune anthem about mortbux"},
musicgen.WithLyrics("mort mort mort"), musicgen.WithDuration(30))
if err != nil {
t.Fatalf("Generate: %v", err)
}
if polls.Load() < 2 {
t.Errorf("polls = %d, want >= 2 (queued then done)", polls.Load())
}
if res.Audio.MIME != "audio/mpeg" || string(res.Audio.Data) != string(mp3) {
t.Fatalf("audio = %q/%d bytes", res.Audio.MIME, len(res.Audio.Data))
}
}
func TestMusicGenerateUpstreamFailure(t *testing.T) {
old := musicPollInterval
musicPollInterval = 5 * time.Millisecond
defer func() { musicPollInterval = old }()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/upstream/musicgen-acestep/release_task":
_, _ = w.Write([]byte(`{"data": {"task_id": "t-2"}}`))
case "/upstream/musicgen-acestep/query_result":
_, _ = w.Write([]byte(`{"data": [{"task_id": "t-2", "status": 2, "result": "{\"error\": \"OOM\"}"}]}`))
}
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
mm, _ := p.MusicModel("musicgen-acestep")
_, err := mm.Generate(context.Background(), musicgen.Request{Prompt: "p"})
var apiErr *llm.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want APIError for failed job", err)
}
}
func TestMusicGenerateRejectsBadArgs(t *testing.T) {
p := New(WithBaseURL("http://unused"))
mm, _ := p.MusicModel("musicgen-acestep")
if _, err := mm.Generate(context.Background(), musicgen.Request{}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("no prompt: err = %v, want ErrUnsupported", err)
}
}
func TestEmbed(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/embeddings" {
t.Errorf("path = %q", r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
// Deliberately out of order: the client must sort by index.
_, _ = w.Write([]byte(`{"object":"list","data":[
{"object":"embedding","index":1,"embedding":[0.3,0.4]},
{"object":"embedding","index":0,"embedding":[0.1,0.2]}
]}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
em, err := p.EmbedModel("embed-qwen3-0.6b")
if err != nil {
t.Fatalf("EmbedModel: %v", err)
}
res, err := em.Embed(context.Background(), embeddings.EmbedRequest{Inputs: []string{"a", "b"}})
if err != nil {
t.Fatalf("Embed: %v", err)
}
if gotBody["model"] != "embed-qwen3-0.6b" {
t.Errorf("model = %v", gotBody["model"])
}
if len(res.Vectors) != 2 || res.Vectors[0][0] != 0.1 || res.Vectors[1][0] != 0.3 {
t.Fatalf("vectors = %+v (index ordering broken?)", res.Vectors)
}
}
func TestEmbedCountMismatchIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1]}]}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
em, _ := p.EmbedModel("embed-qwen3-0.6b")
_, err := em.Embed(context.Background(), embeddings.EmbedRequest{Inputs: []string{"a", "b"}})
var apiErr *llm.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want APIError for count mismatch", err)
}
}
func TestEmbedRejectsEmptyInputs(t *testing.T) {
p := New(WithBaseURL("http://unused"))
em, _ := p.EmbedModel("embed-qwen3-0.6b")
if _, err := em.Embed(context.Background(), embeddings.EmbedRequest{}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("no inputs: err = %v, want ErrUnsupported", err)
}
if _, err := em.Embed(context.Background(), embeddings.EmbedRequest{Inputs: []string{"a", " "}}); !errors.Is(err, llm.ErrUnsupported) {
t.Errorf("blank input: err = %v, want ErrUnsupported", err)
}
}
func TestRerank(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/rerank" {
t.Errorf("path = %q", r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
// Out of score order: the client must sort descending.
_, _ = w.Write([]byte(`{"results":[
{"index":0,"relevance_score":0.11},
{"index":2,"relevance_score":0.93},
{"index":1,"relevance_score":0.42}
]}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
rm, err := p.RerankModel("rerank-bge-v2-m3")
if err != nil {
t.Fatalf("RerankModel: %v", err)
}
res, err := rm.Rerank(context.Background(),
embeddings.RerankRequest{Query: "what is a panda?", Documents: []string{"a", "b", "c"}},
embeddings.WithTopN(3))
if err != nil {
t.Fatalf("Rerank: %v", err)
}
if gotBody["top_n"] != float64(3) || gotBody["query"] != "what is a panda?" {
t.Errorf("top_n/query = %v/%v", gotBody["top_n"], gotBody["query"])
}
if len(res.Results) != 3 || res.Results[0].Index != 2 || res.Results[2].Index != 0 {
t.Fatalf("results = %+v (descending sort broken?)", res.Results)
}
}
func TestRerankRejectsOutOfRangeIndex(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"results":[{"index":7,"relevance_score":0.9}]}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
rm, _ := p.RerankModel("rerank-bge-v2-m3")
_, err := rm.Rerank(context.Background(),
embeddings.RerankRequest{Query: "q", Documents: []string{"a"}})
var apiErr *llm.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want APIError for out-of-range index", err)
}
}
func TestInstructedQuery(t *testing.T) {
got := embeddings.InstructedQuery("", "how tall is everest")
want := "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: how tall is everest"
if got != want {
t.Errorf("InstructedQuery = %q", got)
}
}
func TestMusicGenerateSurvivesTransientPollFailures(t *testing.T) {
old := musicPollInterval
musicPollInterval = 5 * time.Millisecond
defer func() { musicPollInterval = old }()
mp3 := []byte("ID3fakeaudio")
var polls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/upstream/musicgen-acestep/release_task":
_, _ = w.Write([]byte(`{"data": {"task_id": "t-3"}}`))
case "/upstream/musicgen-acestep/query_result":
switch polls.Add(1) {
case 1:
w.WriteHeader(http.StatusBadGateway) // transient transport blip
case 2:
_, _ = w.Write([]byte(`{"data": []}`)) // task momentarily absent
default:
_, _ = w.Write([]byte(`{"data": [{"task_id": "t-3", "status": 1,
"result": "{\"file\": \"/v1/audio?path=out.mp3\"}"}]}`))
}
case "/upstream/musicgen-acestep/v1/audio":
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write(mp3)
}
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
mm, _ := p.MusicModel("musicgen-acestep")
res, err := mm.Generate(context.Background(), musicgen.Request{Prompt: "p"})
if err != nil {
t.Fatalf("Generate should survive 2 transient failures: %v", err)
}
if len(res.Audio.Data) == 0 {
t.Fatal("no audio")
}
}
func TestMusicGenerateRejectsHostileFileURL(t *testing.T) {
old := musicPollInterval
musicPollInterval = 5 * time.Millisecond
defer func() { musicPollInterval = old }()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/upstream/musicgen-acestep/release_task":
_, _ = w.Write([]byte(`{"data": {"task_id": "t-4"}}`))
case "/upstream/musicgen-acestep/query_result":
_, _ = w.Write([]byte(`{"data": [{"task_id": "t-4", "status": 1,
"result": "{\"file\": \"/v1/audio?path=../../api/models/unload\"}"}]}`))
}
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
mm, _ := p.MusicModel("musicgen-acestep")
_, err := mm.Generate(context.Background(), musicgen.Request{Prompt: "p"})
if err == nil {
t.Fatal("dot-dot result file URL accepted")
}
}
+68
View File
@@ -0,0 +1,68 @@
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.
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) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
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
}
+82
View File
@@ -0,0 +1,82 @@
package videogen
import "context"
// InterpolateRequest asks a frame-interpolation backend (RIFE-style) to
// synthesize intermediate frames in an existing clip. Zero values mean
// "backend default" (ADR-0020).
type InterpolateRequest struct {
// Video is the clip to interpolate. Required.
Video Video
// Multi is the frame multiplier (2 or 4 on the reference backend);
// 0 = backend default (2).
Multi int
// SlowMo plays the synthesized frames at the SOURCE frame rate instead of
// multiplying it: Multi-times slow motion. The backend strips audio in
// this mode (time-stretched audio is noise).
SlowMo bool
// TargetFPS resamples the smoothed clip to a specific frame rate
// (e.g. 60); 0 = Multi times the source rate. Ignored in SlowMo mode.
TargetFPS int
}
// InterpolateOption mutates an InterpolateRequest before it is sent.
type InterpolateOption func(*InterpolateRequest)
// WithInterpolateMulti sets the frame multiplier.
func WithInterpolateMulti(m int) InterpolateOption {
return func(r *InterpolateRequest) { r.Multi = m }
}
// WithSlowMo switches to slow-motion output.
func WithSlowMo() InterpolateOption { return func(r *InterpolateRequest) { r.SlowMo = true } }
// WithTargetFPS resamples the smoothed clip to a specific frame rate.
func WithTargetFPS(fps int) InterpolateOption {
return func(r *InterpolateRequest) { r.TargetFPS = fps }
}
// Apply returns a copy of the request with all options applied.
func (r InterpolateRequest) Apply(opts ...InterpolateOption) InterpolateRequest {
for _, opt := range opts {
opt(&r)
}
return r
}
// Interpolator synthesizes intermediate frames (fps boost or slow-mo). Its
// own small interface rather than a method on Model: interpolators are not
// generators — they bind to a different backend id entirely.
type Interpolator interface {
// Interpolate returns the smoothed (or slowed) clip.
Interpolate(ctx context.Context, req InterpolateRequest, opts ...InterpolateOption) (*Result, error)
}
// InterpolatorModelOption configures an Interpolator at construction time.
// Reserved for future per-model settings.
type InterpolatorModelOption func(*InterpolatorModelConfig)
// InterpolatorModelConfig carries per-model construction settings.
type InterpolatorModelConfig struct{}
// ApplyInterpolatorModelOptions folds options into a config.
func ApplyInterpolatorModelOptions(opts []InterpolatorModelOption) InterpolatorModelConfig {
var cfg InterpolatorModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// InterpolationProvider mints Interpolators bound to one backend.
type InterpolationProvider interface {
// Name is the registry identifier for the provider.
Name() string
// InterpolatorModel returns an Interpolator bound to the given id (passed
// through to the backend verbatim; no catalog validation).
InterpolatorModel(id string, opts ...InterpolatorModelOption) (Interpolator, error)
}