feat: musicgen + embeddings/rerank surfaces (ADR-0021, ADR-0022) #15
@@ -75,6 +75,10 @@ func (m *embedModel) Embed(ctx context.Context, req embeddings.EmbedRequest, opt
|
|||||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
|
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
|
||||||
Message: fmt.Sprintf("embeddings response entry index %d invalid or empty", d.Index)}
|
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
|
vectors[d.Index] = d.Embedding
|
||||||
}
|
}
|
||||||
for i, v := range vectors {
|
for i, v := range vectors {
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ import (
|
|||||||
|
|
||||||
// musicPollInterval is the delay between query_result polls. Long enough to
|
// 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
|
// be polite to the queue, short enough that a ~10s xl-turbo song isn't
|
||||||
// dominated by poll latency.
|
// dominated by poll latency. A var so tests can shrink it.
|
||||||
const musicPollInterval = 2 * time.Second
|
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
|
// MusicModel implements musicgen.Provider. The id selects which upstream
|
||||||
// llama-swap loads.
|
// llama-swap loads.
|
||||||
@@ -44,7 +50,8 @@ type musicModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// releaseTaskRequest is the ACE-Step POST /release_task shape. audio_duration
|
// releaseTaskRequest is the ACE-Step POST /release_task shape. audio_duration
|
||||||
// is the v1 param name; verify against docs/en/API.md at smoke time — an
|
// 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,
|
// unknown field is ignored upstream, degrading to the default clip length,
|
||||||
// never an error.
|
// never an error.
|
||||||
type releaseTaskRequest struct {
|
type releaseTaskRequest struct {
|
||||||
@@ -65,20 +72,16 @@ type queryItem struct {
|
|||||||
Result string `json:"result"`
|
Result string `json:"result"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// musicFormatMIME maps ACE-Step's audio_format values to MIME types.
|
// musicFormatMIME resolves the clip MIME from the response Content-Type
|
||||||
func musicFormatMIME(format string) string {
|
// or the requested format, reusing speechMIME's table (one format->MIME
|
||||||
|
gitea-actions
commented
🟡 musicFormatMIME("opus") returns audio/ogg instead of the correct audio/opus (RFC 7845 §9) correctness, error-handling · flagged by 2 models
🪰 Gadfly · advisory 🟡 **musicFormatMIME("opus") returns audio/ogg instead of the correct audio/opus (RFC 7845 §9)**
_correctness, error-handling · flagged by 2 models_
- **`provider/llamaswap/music.go:76` — Wrong MIME type for Opus in the fallback path**
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
// map for the whole provider). wav32 is ACE-Step-specific: normalize it
|
||||||
case "wav", "wav32":
|
// to wav before the shared lookup.
|
||||||
return "audio/wav"
|
func musicFormatMIME(contentType, format string) string {
|
||||||
case "flac":
|
format = strings.ToLower(strings.TrimSpace(format))
|
||||||
return "audio/flac"
|
if format == "wav32" {
|
||||||
case "opus":
|
format = "wav"
|
||||||
return "audio/ogg"
|
|
||||||
case "aac":
|
|
||||||
return "audio/aac"
|
|
||||||
default: // "", "mp3"
|
|
||||||
return "audio/mpeg"
|
|
||||||
}
|
}
|
||||||
|
return speechMIME(contentType, format)
|
||||||
}
|
}
|
||||||
|
gitea-actions
commented
🟠 Missing input length validation on Prompt/Lyrics enables DoS security · flagged by 1 model
🪰 Gadfly · advisory 🟠 **Missing input length validation on Prompt/Lyrics enables DoS**
_security · flagged by 1 model_
- **`provider/llamaswap/music.go:85-92` — Missing input length validation (DoS vector)** The `Generate` function validates that `Prompt` is non-empty and `DurationSeconds >= 0`, but does not bound the length of `Prompt` or `Lyrics`. An attacker could send extremely large strings (MBs) that get JSON-encoded and sent upstream, consuming memory and bandwidth. This is especially relevant since the PR description notes callers "budget the whole job with a ctx deadline" but doesn't mention input size…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
|
||||||
// Generate implements musicgen.Model.
|
// Generate implements musicgen.Model.
|
||||||
@@ -90,6 +93,9 @@ func (m *musicModel) Generate(ctx context.Context, req musicgen.Request, opts ..
|
|||||||
if req.DurationSeconds < 0 {
|
if req.DurationSeconds < 0 {
|
||||||
return nil, fmt.Errorf("%w: duration must be >= 0, got %d", llm.ErrUnsupported, req.DurationSeconds)
|
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)
|
taskID, err := m.releaseTask(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,7 +145,12 @@ func (m *musicModel) releaseTask(ctx context.Context, req musicgen.Request) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pollResult polls query_result until the task succeeds, fails, or ctx
|
// pollResult polls query_result until the task succeeds, fails, or ctx
|
||||||
// expires.
|
// expires. Transient trouble — a transport blip, a momentarily
|
||||||
|
// unparseable payload, the task briefly absent from the response — is
|
||||||
|
gitea-actions
commented
🔴 Ticker in pollResult causes back-to-back polls when backend is slow performance · flagged by 1 model
🪰 Gadfly · advisory 🔴 **Ticker in pollResult causes back-to-back polls when backend is slow**
_performance · flagged by 1 model_
* **`provider/llamaswap/music.go:149` — `time.NewTicker` in polling loop creates back-to-back polls when backend is slow.** `pollResult` creates a `time.NewTicker(musicPollInterval)` before the loop and then `select`s on `ticker.C` at the bottom. A Go `Ticker` has a buffer of 1. If `doJSON` + `findQueryItem` ever take longer than 2 s, the buffered tick is consumed immediately on the next `select`, so the loop fires the next request with **zero delay**. On a slow or congested upstream this turns…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
// 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) {
|
func (m *musicModel) pollResult(ctx context.Context, taskID string) (*queryItem, error) {
|
||||||
|
gitea-actions
commented
🟠 No transient-error retry in polling loop — single HTTP blip kills the entire Generate job correctness, error-handling · flagged by 5 models
🪰 Gadfly · advisory 🟠 **No transient-error retry in polling loop — single HTTP blip kills the entire Generate job**
_correctness, error-handling · flagged by 5 models_
- **No transient-error retry in polling loop** (`provider/llamaswap/music.go:154`): If `doJSON` fails with a transient HTTP error (502, 503, connection reset) during any poll iteration, the entire `Generate` call fails immediately — even though the async job may still be running successfully upstream. The caller gets an error with no way to recover the result of an expensive GPU job. This is consistent with the rest of the codebase (no retry infrastructure exists anywhere in `provider/llamaswap`…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
path, err := upstreamPath(m.id, "/query_result")
|
path, err := upstreamPath(m.id, "/query_result")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -148,22 +159,29 @@ func (m *musicModel) pollResult(ctx context.Context, taskID string) (*queryItem,
|
|||||||
body := map[string]any{"task_id_list": []string{taskID}}
|
body := map[string]any{"task_id_list": []string{taskID}}
|
||||||
ticker := time.NewTicker(musicPollInterval)
|
ticker := time.NewTicker(musicPollInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
failures := 0
|
||||||
|
var lastErr error
|
||||||
for {
|
for {
|
||||||
// Tolerant envelope: items under "data" or a bare array.
|
item, pollErr := m.pollOnce(ctx, path, body, taskID)
|
||||||
var raw json.RawMessage
|
switch {
|
||||||
if err := m.p.doJSON(ctx, http.MethodPost, path, m.id, body, &raw); err != nil {
|
case pollErr != nil:
|
||||||
return nil, err
|
// Ctx expiry is never transient — bail with the deadline error.
|
||||||
}
|
if ctx.Err() != nil {
|
||||||
item, err := findQueryItem(raw, taskID)
|
return nil, fmt.Errorf("llama-swap: music generation: %w", ctx.Err())
|
||||||
if err != nil {
|
}
|
||||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: err.Error()}
|
failures++
|
||||||
}
|
lastErr = pollErr
|
||||||
switch item.Status {
|
if failures >= musicPollMaxConsecutiveFailures {
|
||||||
case 1:
|
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
|
return item, nil
|
||||||
case 2:
|
case item.Status == 2:
|
||||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
|
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
|
||||||
Message: "music generation failed upstream: " + truncateForError([]byte(item.Result))}
|
Message: "music generation failed upstream: " + truncateForError([]byte(item.Result))}
|
||||||
|
default:
|
||||||
|
gitea-actions
commented
🔴 findQueryItem treats {"data": null} as fatal parse error instead of empty response error-handling · flagged by 1 model
🪰 Gadfly · advisory 🔴 **findQueryItem treats {"data": null} as fatal parse error instead of empty response**
_error-handling · flagged by 1 model_
* **`provider/llamaswap/music.go:183` — `findQueryItem` chokes on `{"data": null}`** When the upstream returns `{"data": null}`, `env.Data` becomes `json.RawMessage("null")` whose length is 4, so the `len(env.Data) > 0` guard passes and `candidates` is set to the literal `"null"`. Both `json.Unmarshal` attempts into `[]queryItem` and `queryItem` then fail, yielding `"unrecognized query_result payload shape"`. `pollResult` wraps this as a hard `APIError`, killing the generation. A null `data` fie…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
failures = 0 // healthy queued/running poll
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -173,6 +191,16 @@ func (m *musicModel) pollResult(ctx context.Context, taskID string) (*queryItem,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
gitea-actions
commented
🟠 findQueryItem accepts empty JSON object {} as valid, causing poll spin until deadline error-handling, maintainability · flagged by 5 models
🪰 Gadfly · advisory 🟠 **findQueryItem accepts empty JSON object {} as valid, causing poll spin until deadline**
_error-handling, maintainability · flagged by 5 models_
- **`findQueryItem` accepts `{}` as a valid single-item response** (`provider/llamaswap/music.go:195`): When the upstream returns `{"data": {}}` (a degenerate case of the `{"data": {...}}` envelope), `json.Unmarshal` into `[]queryItem` fails, then `json.Unmarshal` into a single `queryItem` succeeds with all zero values. The fallback at line 195 matches it (`TaskID == "" && len(items) == 1`), returning a `queryItem` with `Status: 0`. The polling loop treats status 0 as "still queued/running" and…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
// 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,
|
// findQueryItem digs the task's entry out of the query_result payload,
|
||||||
// tolerating {"data": [...]}, {"data": {...}}, and bare-array envelopes.
|
// tolerating {"data": [...]}, {"data": {...}}, and bare-array envelopes.
|
||||||
func findQueryItem(raw json.RawMessage, taskID string) (*queryItem, error) {
|
func findQueryItem(raw json.RawMessage, taskID string) (*queryItem, error) {
|
||||||
@@ -192,7 +220,10 @@ func findQueryItem(raw json.RawMessage, taskID string) (*queryItem, error) {
|
|||||||
items = []queryItem{one}
|
items = []queryItem{one}
|
||||||
}
|
}
|
||||||
for i := range items {
|
for i := range items {
|
||||||
if items[i].TaskID == taskID || items[i].TaskID == "" && len(items) == 1 {
|
// 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 &items[i], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +240,12 @@ func (m *musicModel) fetchResult(ctx context.Context, format string, item *query
|
|||||||
Message: "music result blob missing file URL: " + truncateForError([]byte(item.Result))}
|
Message: "music result blob missing file URL: " + truncateForError([]byte(item.Result))}
|
||||||
}
|
}
|
||||||
// The file URL is server-relative (e.g. "/v1/audio?path=..."); route it
|
// The file URL is server-relative (e.g. "/v1/audio?path=..."); route it
|
||||||
// back through the same upstream.
|
// 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)
|
path, err := upstreamPath(m.id, result.File)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -221,10 +257,7 @@ func (m *musicModel) fetchResult(ctx context.Context, format string, item *query
|
|||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "music response contained no audio"}
|
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "music response contained no audio"}
|
||||||
}
|
}
|
||||||
mimeType := mimeFromContentType(contentType, "audio/")
|
mimeType := musicFormatMIME(contentType, format)
|
||||||
if mimeType == "" {
|
|
||||||
mimeType = musicFormatMIME(format)
|
|
||||||
}
|
|
||||||
return &musicgen.Result{
|
return &musicgen.Result{
|
||||||
Audio: musicgen.Audio{Data: raw, MIME: mimeType},
|
Audio: musicgen.Audio{Data: raw, MIME: mimeType},
|
||||||
Raw: json.RawMessage(item.Result),
|
Raw: json.RawMessage(item.Result),
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ func aceStepStub(t *testing.T, mp3 []byte) (*httptest.Server, *atomic.Int32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMusicGenerate(t *testing.T) {
|
func TestMusicGenerate(t *testing.T) {
|
||||||
|
old := musicPollInterval
|
||||||
|
musicPollInterval = 5 * time.Millisecond
|
||||||
|
defer func() { musicPollInterval = old }()
|
||||||
|
|
||||||
mp3 := []byte("ID3fakeaudio")
|
mp3 := []byte("ID3fakeaudio")
|
||||||
srv, polls := aceStepStub(t, mp3)
|
srv, polls := aceStepStub(t, mp3)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -79,6 +83,10 @@ func TestMusicGenerate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMusicGenerateUpstreamFailure(t *testing.T) {
|
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) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/upstream/musicgen-acestep/release_task":
|
case "/upstream/musicgen-acestep/release_task":
|
||||||
@@ -222,3 +230,66 @@ func TestInstructedQuery(t *testing.T) {
|
|||||||
t.Errorf("InstructedQuery = %q", got)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
🟡 Hardcoded 2s poll interval forces TestMusicGenerate to burn 2+ real seconds on every CI run
performance · flagged by 1 model
provider/llamaswap/music.go:29🪰 Gadfly · advisory