Skip to content

Commit 3b2a0d8

Browse files
v0.5.2 — fix generation against new /api/generate/v2-web/ endpoint
Suno migrated their generation endpoint server-side: `/api/generate/v2/` now returns `Token validation failed.` for everything, while the live web app posts to `/api/generate/v2-web/` with a substantially extended body schema. Captured the new request shape from a real browser session, ported the CLI to match, and end-to-end verified generate → wait → download → MP3 with embedded ID3/lyrics tags. API changes - POST `/api/generate/v2-web/` (was `/api/generate/v2/`) - New required body: `negative_tags` (empty string default), `user_uploaded_images_b64`, `metadata.{web_client_pathname, is_max_mode, is_mumble, create_mode, user_tier, create_session_token, disable_volume_normalization}`, `override_fields`, `cover_start_s`, `cover_end_s`, `artist_clip_id`, `artist_start_s`, `artist_end_s`, `continued_aligned_prompt`, `transaction_uuid` - `gpt_description_prompt` and `task` fields are gone — describe mode now uses `create_mode: "inspiration"` with the prompt in the same `prompt` field as custom mode - `create_session_token` and `transaction_uuid` are random UUIDs per request (verified server-side does not validate them) - `user_tier` is also not validated server-side (verified with empty string and arbitrary text — both succeed) so we send empty string JWT staleness fix - Suno's generation endpoint silently rejects JWTs older than ~30 min with `Token validation failed.` even when the JWT's own `exp` claim says it's still valid. Auto-refresh threshold widened from 30s to 30min so every cold-start command lands with a fresh token. - `Token validation failed` body pattern now maps to `CliError::AuthExpired` so the user gets the right hint instead of an opaque API dump. Separate `suno credits` fix - `RemasterModelInfo.can_use` is missing from billing/info responses in the wild — added `#[serde(default)]` so deserialization stops failing with "error decoding response body". New `suno update` command - Self-update from GitHub Releases via the `self_update` crate, ported from the agent-cli-framework reference implementation. - `suno update --check` (peek) and `suno update` (install) both return the standard JSON envelope. Added to agent-info commands list. Schema-drift error mapper - `check_response()` now detects pydantic-style `loc: ['body','params'…]` failures and returns `CliError::Api { code: "schema_drift" }` with a suggestion to run `suno update`. Future schema changes will surface the right action automatically. Cover / remaster (best-effort port) - Both still route through `/api/generate/v2-web/` but with `cover_clip_id` set instead of the legacy `task: "cover"` field. NOT verified against the real web app — separate captures still needed for those flows. Cleanup - Removed the dead `check_captcha()` helper. Its field-name check (`captcha_required`) was wrong anyway — Suno's `/api/c/check` returns `{"required": ...}`, not `{"captcha_required": ...}`, so the warning it gated never fired. README + SKILL.md updated to mention `suno update` and the new endpoint. Codex review (gpt-5.4 xhigh) approved the patch with two notes that have been addressed (captcha helper removed cleanly, schema_drift mapper added). Verified end-to-end: - `suno credits` → success JSON - `suno generate --instrumental --wait --download` → 2 complete MP3s - `suno generate --lyrics-file --wait --download` → 2 complete MP3s with prompt embedded in metadata - `suno info`, `suno list`, `suno models`, `suno agent-info` → success - `suno update --check` → reports current 0.5.2 vs latest 0.5.1
1 parent 024aba4 commit 3b2a0d8

14 files changed

Lines changed: 548 additions & 178 deletions

File tree

Cargo.lock

Lines changed: 264 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "suno"
3-
version = "0.5.1"
3+
version = "0.5.2"
44
edition = "2024"
55
description = "Generate AI music from your terminal — Suno v5.5 with tags, exclude, vocal control, and all generation features"
66
license = "MIT"
@@ -30,6 +30,7 @@ futures-util = "0.3"
3030
id3 = "1"
3131
rookie = "0.5"
3232
uuid = { version = "1", features = ["v4"] }
33+
self_update = { version = "0.42", default-features = false, features = ["rustls", "compression-flate2", "archive-tar"] }
3334

3435
[dev-dependencies]
3536
assert_cmd = "2"

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ cargo install suno
5353

5454
Download from [GitHub Releases](https://github.com/199-biotechnologies/suno-cli/releases) — binaries for macOS (Apple Silicon + Intel), Linux (x86_64 + ARM), and Windows.
5555

56+
### Self-update
57+
58+
Already have `suno` installed? Pull the latest binary from GitHub Releases without touching your package manager:
59+
60+
```bash
61+
suno update --check # see what's available
62+
suno update # install the latest release
63+
```
64+
65+
> Tip: when Suno changes their API mid-cycle, run `suno update` first — it's faster than `cargo install suno` or waiting for the Homebrew bottle to refresh.
66+
5667
## Quick Start
5768

5869
```bash
@@ -128,6 +139,7 @@ suno auth Set up authentication
128139
suno config show | set | check
129140
suno agent-info Machine-readable capabilities JSON
130141
suno install-skill Install agent skill into Claude Code / Cursor
142+
suno update Self-update from GitHub Releases (--check to peek first)
131143
```
132144

133145
## Features
@@ -191,7 +203,7 @@ suno cover <clip_id> --tags "jazz, smooth piano" --model v5.5 --wait
191203
suno remaster <clip_id> --model v5.5 --wait --download ./remastered/
192204
```
193205

194-
Both route through Suno's unified generation endpoint (`/api/generate/v2/`).
206+
Both route through Suno's unified web generation endpoint (`/api/generate/v2-web/`).
195207

196208
### Clip Info
197209

assets/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,5 +185,6 @@ suno download $ids --output ./archive/
185185

186186
- Auth refreshes automatically (~7-day session lifetime).
187187
- Captcha is **not** required for Premier accounts with 200+ credits consumed.
188-
- All generation paths (normal, voice persona, cover, extend) go through `/api/generate/v2/` with different `task` values — but you don't need to know that, just use the subcommands.
188+
- All generation paths (normal, voice persona, cover, extend) go through `/api/generate/v2-web/` — but you don't need to know that, just use the subcommands.
189+
- When the CLI returns `schema_drift` (Suno changed their API), run `suno update` to pull the latest binary from GitHub Releases.
189190
- When unsure about flags, run `suno <command> --help` or `suno agent-info`.

src/api/cover.rs

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,20 @@ use crate::errors::CliError;
44

55
impl SunoClient {
66
/// Create a cover of an existing clip.
7-
/// Routes through /api/generate/v2/ with task="cover" + cover_clip_id.
7+
/// Posts to `/api/generate/v2-web/` with `cover_clip_id` set. The legacy
8+
/// `task: "cover"` field is gone in v2-web; we still don't have a fresh
9+
/// web-app capture for the cover flow, so this is a best-guess port — if
10+
/// the API rejects, we'll need to capture a real cover request and add
11+
/// any missing required fields (e.g. cover_start_s/cover_end_s).
812
pub async fn cover(
913
&self,
1014
clip_id: &str,
1115
model_key: &str,
1216
tags: Option<&str>,
1317
) -> Result<Vec<Clip>, CliError> {
14-
let req = GenerateRequest {
15-
mv: model_key.to_string(),
16-
prompt: None,
17-
gpt_description_prompt: None,
18-
title: None,
19-
tags: tags.map(String::from),
20-
negative_tags: None,
21-
make_instrumental: false,
22-
generation_type: None,
23-
token: None,
24-
continue_clip_id: None,
25-
continue_at: None,
26-
task: Some("cover".into()),
27-
persona_id: None,
28-
cover_clip_id: Some(clip_id.to_string()),
29-
metadata: None,
30-
};
18+
let mut req = GenerateRequest::new(model_key, "cover");
19+
req.tags = tags.map(String::from);
20+
req.cover_clip_id = Some(clip_id.to_string());
3121
self.generate(&req).await
3222
}
3323
}

src/api/generate.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ use crate::errors::CliError;
44

55
impl SunoClient {
66
/// Submit a music generation request (custom mode or inspiration mode).
7+
/// Posts to `/api/generate/v2-web/` — the legacy `/api/generate/v2/`
8+
/// returns `Token validation failed` since Suno migrated creates to
9+
/// `v2-web` server-side (verified 2026-04-07).
710
pub async fn generate(&self, req: &GenerateRequest) -> Result<Vec<Clip>, CliError> {
8-
let resp = self.post("/api/generate/v2/").json(req).send().await?;
11+
let resp = self.post("/api/generate/v2-web/").json(req).send().await?;
912
let resp = self.check_response(resp).await?;
1013
let result: GenerateResponse = resp.json().await?;
1114
Ok(result.clips)

src/api/metadata.rs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,4 @@ impl SunoClient {
4747
})?;
4848
Ok(serde_json::from_value(words.clone())?)
4949
}
50-
51-
/// Check whether captcha is required before generation.
52-
pub async fn check_captcha(&self) -> Result<bool, CliError> {
53-
let resp = self
54-
.post("/api/c/check")
55-
.json(&serde_json::json!({"ctype": "generation"}))
56-
.send()
57-
.await?;
58-
let resp = self.check_response(resp).await?;
59-
// Try to parse; if the response doesn't have captcha_required, assume false
60-
let body: serde_json::Value = resp.json().await?;
61-
Ok(body
62-
.get("captcha_required")
63-
.and_then(|v| v.as_bool())
64-
.unwrap_or(false))
65-
}
6650
}

src/api/mod.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,26 @@ impl SunoClient {
111111
}
112112
if !status.is_success() {
113113
let body = resp.text().await.unwrap_or_default();
114+
// Map known Suno error patterns to actionable codes so callers
115+
// get a meaningful suggestion instead of an opaque HTTP dump.
116+
//
117+
// `Token validation failed` is what Suno returns when the JWT
118+
// has crossed their server-side staleness threshold (~30 min)
119+
// even when the JWT's own `exp` claim is still valid. We treat
120+
// it as `AuthExpired` so the next CLI invocation will refresh
121+
// via the Clerk session cookie and pick up a fresh token.
122+
if body.contains("Token validation failed") {
123+
return Err(CliError::AuthExpired);
124+
}
125+
if body.contains("'loc': ['body', 'params'") || body.contains("\"loc\": [\"body\", \"params\"")
126+
{
127+
return Err(CliError::Api {
128+
code: "schema_drift",
129+
message: format!(
130+
"HTTP {status}: Suno's request schema has changed — the CLI needs an update. Body: {body}"
131+
),
132+
});
133+
}
114134
return Err(CliError::Api {
115135
code: "api_error",
116136
message: format!("HTTP {status}: {body}"),

src/api/remaster.rs

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,16 @@ use crate::errors::CliError;
44

55
impl SunoClient {
66
/// Remaster a clip with a different model version.
7-
/// Routes through /api/generate/v2/ with the remaster model key
8-
/// and cover_clip_id pointing to the original.
7+
/// Posts to `/api/generate/v2-web/` with the remaster model key and
8+
/// `cover_clip_id` pointing to the original. As with `cover()`, this is
9+
/// a best-guess port pending a real captured remaster request.
910
pub async fn remaster(
1011
&self,
1112
clip_id: &str,
1213
remaster_model_key: &str,
1314
) -> Result<Vec<Clip>, CliError> {
14-
let req = GenerateRequest {
15-
mv: remaster_model_key.to_string(),
16-
prompt: None,
17-
gpt_description_prompt: None,
18-
title: None,
19-
tags: None,
20-
negative_tags: None,
21-
make_instrumental: false,
22-
generation_type: None,
23-
token: None,
24-
continue_clip_id: None,
25-
continue_at: None,
26-
task: Some("cover".into()),
27-
persona_id: None,
28-
cover_clip_id: Some(clip_id.to_string()),
29-
metadata: None,
30-
};
15+
let mut req = GenerateRequest::new(remaster_model_key, "remaster");
16+
req.cover_clip_id = Some(clip_id.to_string());
3117
self.generate(&req).await
3218
}
3319
}

src/api/types.rs

Lines changed: 94 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ pub struct RemasterModelInfo {
6060
pub name: String,
6161
pub external_key: String,
6262
pub is_default_model: bool,
63+
/// Suno's billing/info response for remaster models does NOT include this
64+
/// field — keep it optional so deserialization succeeds.
65+
#[serde(default)]
6366
pub can_use: bool,
6467
}
6568

@@ -142,48 +145,113 @@ pub struct FilterPresence {
142145
}
143146

144147
// --- Generation ---
148+
//
149+
// Schema captured from a real Suno web-app POST to `/api/generate/v2-web/`
150+
// on 2026-04-07 (see API_INTELLIGENCE.md). The old `/api/generate/v2/` path
151+
// returns `Token validation failed` since Suno started routing creates
152+
// through `v2-web` exclusively. Most of the new `null` fields are pure
153+
// placeholders the web app sends regardless of mode — they MUST be present
154+
// or pydantic returns `missing field`.
145155

146156
#[derive(Debug, Serialize)]
147157
pub struct GenerateRequest {
148-
pub mv: String,
149-
#[serde(skip_serializing_if = "Option::is_none")]
150-
pub prompt: Option<String>,
151-
#[serde(skip_serializing_if = "Option::is_none")]
152-
pub gpt_description_prompt: Option<String>,
153-
#[serde(skip_serializing_if = "Option::is_none")]
158+
/// Captcha/anti-bot token. Always serialized as `null` from the CLI; the
159+
/// real validation happens via `metadata.create_session_token`.
160+
pub token: Option<String>,
161+
pub generation_type: String,
154162
pub title: Option<String>,
155-
#[serde(skip_serializing_if = "Option::is_none")]
156163
pub tags: Option<String>,
157-
#[serde(skip_serializing_if = "Option::is_none")]
158-
pub negative_tags: Option<String>,
164+
/// Always present, defaults to "" (empty string, NOT null).
165+
pub negative_tags: String,
166+
pub mv: String,
167+
pub prompt: String,
159168
pub make_instrumental: bool,
160-
#[serde(skip_serializing_if = "Option::is_none")]
161-
pub generation_type: Option<String>,
162-
#[serde(skip_serializing_if = "Option::is_none")]
163-
pub token: Option<String>,
164-
#[serde(skip_serializing_if = "Option::is_none")]
169+
pub user_uploaded_images_b64: Option<String>,
170+
pub metadata: GenerateMetadata,
171+
/// Always present, empty array unless overriding model fields.
172+
pub override_fields: Vec<serde_json::Value>,
173+
pub cover_clip_id: Option<String>,
174+
pub cover_start_s: Option<f64>,
175+
pub cover_end_s: Option<f64>,
176+
pub persona_id: Option<String>,
177+
pub artist_clip_id: Option<String>,
178+
pub artist_start_s: Option<f64>,
179+
pub artist_end_s: Option<f64>,
165180
pub continue_clip_id: Option<String>,
166-
#[serde(skip_serializing_if = "Option::is_none")]
181+
pub continued_aligned_prompt: Option<String>,
167182
pub continue_at: Option<f64>,
168-
#[serde(skip_serializing_if = "Option::is_none")]
169-
pub task: Option<String>,
170-
/// Voice persona ID — used with task="vox"
171-
#[serde(skip_serializing_if = "Option::is_none")]
172-
pub persona_id: Option<String>,
173-
/// Source clip for covers/remasters — used with task="cover"
174-
#[serde(skip_serializing_if = "Option::is_none")]
175-
pub cover_clip_id: Option<String>,
176-
/// Control sliders — nested correctly under metadata per xiliourt/Suno-Architect
177-
#[serde(skip_serializing_if = "Option::is_none")]
178-
pub metadata: Option<GenerateMetadata>,
183+
/// Random UUID generated per request — required.
184+
pub transaction_uuid: String,
185+
}
186+
187+
impl GenerateRequest {
188+
/// Build a `GenerateRequest` with all the new-schema placeholder fields
189+
/// pre-populated (nulls, empty arrays, fresh UUIDs). Callers only need to
190+
/// override the fields that matter for their command.
191+
pub fn new(mv: &str, create_mode: &str) -> Self {
192+
Self {
193+
token: None,
194+
generation_type: "TEXT".to_string(),
195+
title: None,
196+
tags: None,
197+
negative_tags: String::new(),
198+
mv: mv.to_string(),
199+
prompt: String::new(),
200+
make_instrumental: false,
201+
user_uploaded_images_b64: None,
202+
metadata: GenerateMetadata::new(create_mode),
203+
override_fields: Vec::new(),
204+
cover_clip_id: None,
205+
cover_start_s: None,
206+
cover_end_s: None,
207+
persona_id: None,
208+
artist_clip_id: None,
209+
artist_start_s: None,
210+
artist_end_s: None,
211+
continue_clip_id: None,
212+
continued_aligned_prompt: None,
213+
continue_at: None,
214+
transaction_uuid: uuid::Uuid::new_v4().to_string(),
215+
}
216+
}
179217
}
180218

219+
/// Web-app metadata block. All fields are required by the new schema even if
220+
/// they're decorative. `user_tier` is NOT validated server-side (verified with
221+
/// empty string and arbitrary text — both succeed).
181222
#[derive(Debug, Serialize)]
182223
pub struct GenerateMetadata {
224+
pub web_client_pathname: String,
225+
pub is_max_mode: bool,
226+
pub is_mumble: bool,
227+
pub create_mode: String,
228+
pub user_tier: String,
229+
/// Random UUID generated per request — looks decorative but must be present.
230+
pub create_session_token: String,
231+
pub disable_volume_normalization: bool,
232+
/// Control sliders (weirdness / style influence). Optional — only sent
233+
/// when --weirdness or --style-influence is passed.
183234
#[serde(skip_serializing_if = "Option::is_none")]
184235
pub control_sliders: Option<ControlSliders>,
185236
}
186237

238+
impl GenerateMetadata {
239+
/// Build a metadata block with default web-app values + a fresh session
240+
/// token. This matches what the real Suno UI sends per generation.
241+
pub fn new(create_mode: &str) -> Self {
242+
Self {
243+
web_client_pathname: "/create".to_string(),
244+
is_max_mode: false,
245+
is_mumble: false,
246+
create_mode: create_mode.to_string(),
247+
user_tier: String::new(),
248+
create_session_token: uuid::Uuid::new_v4().to_string(),
249+
disable_volume_normalization: false,
250+
control_sliders: None,
251+
}
252+
}
253+
}
254+
187255
#[derive(Debug, Serialize)]
188256
pub struct ControlSliders {
189257
/// Weirdness: 0.0-1.0 (maps from 0-100 in UI)

0 commit comments

Comments
 (0)