Skip to content

suno stems fails with "error decoding response body" — stems() deserializes response as Clip but Suno returns stem-specific objects (v0.5.7) #5

Description

@devops-adeel

Summary

suno stems <clip_id> exits with code 1 and "error decoding response body" on v0.5.7
for every clip, including clips where suno info <clip_id> confirms "has_stem": true.
The root cause is a Rust type mismatch in src/api/stems.rs: the stems() function
attempts to deserialize the Suno API response as the generic Clip struct, but the
stems endpoint returns a different schema — and Clip.model_name is a required
(non-Option) String that stem responses almost certainly don't include, causing
serde_json to fail immediately with a missing-field error that reqwest wraps as
"error decoding response body".


Environment

Field Value
suno-cli version 0.5.7 (confirmed current latest via suno update)
OS macOS 15.5 (Darwin arm64 / Apple Silicon M-series)
Install method Homebrew (brew tap paperfoot/tap && brew install suno)
Auth status Valid — JWT refreshed via suno auth --refresh, suno credits returns correct balance and plan
Plan Pro (includes get_stems feature in usage_plan_features)

Steps to Reproduce

# 1. Generate a clip (any method), wait for completion, note the clip ID
# 2. Confirm stems exist for the clip
suno info <clip_id> --json | jq .data.metadata.has_stem
# → true

# 3. Request stems
suno stems <clip_id> --json
# → exit 1

Also fails with:

  • suno stems <clip_id> (without --json)
  • suno stems <clip_id> --wait --json (--wait makes no difference)

After auth refresh (auth is not the cause):

suno auth --refresh
# → "JWT refreshed successfully / Authenticated! Plan: Pro Plan"
suno stems <clip_id> --json
# → still fails with same error

Actual Output

{
  "version": "1",
  "status": "error",
  "error": {
    "code": "http_error",
    "message": "error decoding response body",
    "suggestion": "Check your network connection and retry"
  }
}

Exit code: 1


Expected Output

Something shaped like this (two stem objects — vocal + instrumental):

{
  "version": "1",
  "status": "success",
  "data": [
    {
      "id": "<vocal-stem-clip-id>",
      "title": "My Song (Vocals)",
      "audio_url": "https://cdn1.suno.ai/<vocal-stem-clip-id>.wav",
      "status": "complete"
    },
    {
      "id": "<instrumental-stem-clip-id>",
      "title": "My Song (Instrumental)",
      "audio_url": "https://cdn1.suno.ai/<instrumental-stem-clip-id>.wav",
      "status": "complete"
    }
  ]
}

Root Cause (code-level)

src/api/stems.rs — the failing function

pub async fn stems(&self, clip_id: &str) -> Result<Clip, CliError> {
    self.with_auth_retry(|| async {
        let resp = self
            .post(&format!("/api/edit/stems/{clip_id}"))
            .send()
            .await?;
        let resp = self.check_response(resp).await?;
        Ok(resp.json().await?)   // ← attempts to deserialize as Clip — FAILS
    })
    .await
}

src/api/types.rs — the Clip struct that deserialization targets

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Clip {
    pub id: String,
    pub title: String,
    pub status: String,
    pub model_name: String,        // ← required, non-Option
    pub audio_url: Option<String>,
    pub video_url: Option<String>,
    pub image_url: Option<String>,
    pub created_at: String,        // ← required, non-Option
    #[serde(default)]
    pub play_count: u64,
    #[serde(default)]
    pub upvote_count: u64,
    #[serde(default)]
    pub metadata: ClipMetadata,
}

Why deserialization fails

The Suno stems endpoint (POST /api/edit/stems/{clip_id}) returns stem clip objects,
not standard generated clips. Based on documentation from third-party Suno API wrappers
(gcui-art/suno-api), stem objects have a different shape:

  • They include a stem_from_id field (the source clip's ID) not present in Clip
  • They likely omit model_name, which is String (required) in Clip — this
    would cause serde_json to return missing field 'model_name' immediately
  • The response is likely an array of two clip objects (vocal + instrumental),
    not a single object — so resp.json::<Clip>() would fail on the type mismatch
    even if all fields were present

Additionally, main.rs dispatches stems as:

Commands::Stems(args) => {
    let clip = client().await?.stems(&args.clip_id).await?;
    match fmt {
        OutputFormat::Json => output::json::success(&clip),
        OutputFormat::Table => output::table::clips(&[clip]),
    }
}

This treats the result as a single Clip, which doesn't match a two-stem array response.

The comment in stems.rs is worth noting

// Uses /api/edit/stems/{song_id} per gcui-art/paean-ai evidence.

The endpoint was inferred from a third-party source. It's possible the endpoint path,
request format, or response schema has changed since that evidence was gathered.


Misleading dry-run fixture

The project's test fixture at tests/fixtures/stems.json reads:

{
  "data": {
    "clip_id": "clip_dryrun_a",
    "instrumental_url": "https://example.invalid/clip_dryrun_a-instrumental.wav",
    "vocals_url": "https://example.invalid/clip_dryrun_a-vocals.wav"
  }
}

This fixture is consumed by the dry-run shim and downstream tooling, not by the
actual CLI deserialization path. Its format doesn't reflect what the live Suno API returns.
The fixture working correctly in dry-run tests masks the live API schema mismatch.


Also: --wait flag not implemented

StemsArgs declares --wait as a flag but stems() in src/api/stems.rs is a single
POST with no polling loop — the flag is accepted but silently ignored. Stem generation is
likely async on Suno's side; without polling, the response may also return an in-progress
status rather than final stem data, which could be a second contributing factor.


Suggested Fix

  1. Determine the actual response schema from the live Suno API (most important step — the CLI crashes before the body is visible to the caller).

  2. Add a dedicated response type (adjust field names to match actual schema):

#[derive(Debug, Deserialize)]
pub struct StemClip {
    pub id: String,
    pub title: String,
    pub status: String,
    pub audio_url: Option<String>,
    pub stem_from_id: Option<String>,
    // model_name intentionally omitted — not returned by the stems endpoint
}
  1. Update stems() (likely Vec<StemClip> if the endpoint returns an array):
pub async fn stems(&self, clip_id: &str) -> Result<Vec<StemClip>, CliError> {
    // ...
    Ok(resp.json::<Vec<StemClip>>().await?)
}
  1. Implement --wait polling if stem generation is async.

  2. Update main.rs to handle the array and distinguish vocal from instrumental by title or a stem-type field.

Capturing the raw response for debugging

// In stems.rs, temporarily replace resp.json() with:
let body = resp.text().await?;
eprintln!("DEBUG stems body: {}", body);

I'm happy to test a patched binary — I have the affected clips saved and can re-run immediately.


Impact

  • suno stems is completely non-functional for all clips in v0.5.7
  • Pro plan subscribers pay for get_stems but cannot use it programmatically
  • Automated quality checks that depend on stem separation always return MANUAL,
    requiring manual stem download from the web UI as a workaround
  • The --wait flag being silently ignored is a secondary UX issue

Tested clip IDs (all confirmed "has_stem": true via suno info):

  • b8759abd-6a12-4538-8c45-d0dc78f26753
  • 40c54f63-a66b-456d-a1fa-c042cc710820
  • 2d848894-5329-4856-9f62-61b8ad28efda

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions