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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions